This commit is contained in:
parent
3660ac1a4d
commit
4280442a76
|
|
@ -22,11 +22,11 @@ import (
|
|||
)
|
||||
|
||||
type AdviceAdvicerBiz struct {
|
||||
advicerImpl *impl.AdviceAdvicerImpl
|
||||
advicerVersionMongo *mongo_model.AdvicerVersionMongo
|
||||
advicerImpl *impl.AdviceAdvicerImpl
|
||||
advicerVersionMongo *mongo_model.AdvicerVersionMongo
|
||||
aiAdviceModelSupImpl *impl.AiAdviceModelSupImpl
|
||||
adviceProjectImpl *impl.AdviceProjectImpl
|
||||
mongo *pkg.Mongo
|
||||
adviceProjectImpl *impl.AdviceProjectImpl
|
||||
mongo *pkg.Mongo
|
||||
}
|
||||
|
||||
func NewAdviceAdvicerBiz(
|
||||
|
|
@ -37,11 +37,11 @@ func NewAdviceAdvicerBiz(
|
|||
mongo *pkg.Mongo,
|
||||
) *AdviceAdvicerBiz {
|
||||
return &AdviceAdvicerBiz{
|
||||
advicerImpl: advicerImpl,
|
||||
advicerVersionMongo: advicerVersionMongo,
|
||||
advicerImpl: advicerImpl,
|
||||
advicerVersionMongo: advicerVersionMongo,
|
||||
aiAdviceModelSupImpl: aiAdviceModelSupImpl,
|
||||
adviceProjectImpl: adviceProjectImpl,
|
||||
mongo: mongo,
|
||||
adviceProjectImpl: adviceProjectImpl,
|
||||
mongo: mongo,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -114,6 +114,35 @@ func (a *AdviceAdvicerBiz) HostingUpdate(ctx context.Context, req *entitys.Advic
|
|||
return a.advicerImpl.UpdateByCond(&cond, updates)
|
||||
}
|
||||
|
||||
// FindByWxDeviceId 通过 wx_device_id 查销售记录
|
||||
func (a *AdviceAdvicerBiz) FindByWxDeviceId(ctx context.Context, wxDeviceId string) (model.AiAdviceAdvicer, error) {
|
||||
return a.advicerImpl.FindByWxDeviceId(ctx, wxDeviceId)
|
||||
}
|
||||
|
||||
// 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})
|
||||
if err != nil {
|
||||
return "", mongo_model.AdvicerVersionMongo{}, err
|
||||
}
|
||||
if len(list) == 0 {
|
||||
return "", mongo_model.AdvicerVersionMongo{}, fmt.Errorf("未找到该销售的版本")
|
||||
}
|
||||
// 按 LastUpdateTime 倒序找最新
|
||||
latest := list[0]
|
||||
for _, item := range list[1:] {
|
||||
if item.LastUpdateTime.After(latest.LastUpdateTime) {
|
||||
latest = item
|
||||
}
|
||||
}
|
||||
return latest.Id.Hex(), mongo_model.AdvicerVersionMongo{
|
||||
AdvicerId: latest.AdvicerId,
|
||||
VersionDesc: latest.VersionDesc,
|
||||
Data: latest.Data,
|
||||
LastUpdateTime: latest.LastUpdateTime,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (a *AdviceAdvicerBiz) VersionAdd(ctx context.Context, param *entitys.AdvicerVersionAddReq) (id interface{}, err error) {
|
||||
cond := builder.NewCond()
|
||||
cond = cond.And(builder.Eq{"advicer_id": param.AdvicerID})
|
||||
|
|
|
|||
|
|
@ -84,6 +84,7 @@ func (a *AdviceChatBiz) Regis(ctx context.Context, chatData *entitys.ChatData, r
|
|||
ProjectID: projectInfo.Base.ProjectID,
|
||||
SupID: projectInfo.Base.ModelSupID,
|
||||
AdvicerVersionID: req.AdvicerVersionId,
|
||||
AdvicerID: req.AdvicerId,
|
||||
ClientID: req.ClientId,
|
||||
TalkSkillID: req.TalkSkillId,
|
||||
Mission: req.Mission,
|
||||
|
|
@ -166,6 +167,62 @@ func (a *AdviceChatBiz) Chat(ctx context.Context, chat *entitys.AdvicerChatReq)
|
|||
return
|
||||
}
|
||||
|
||||
// ChatWithSession 与 Chat 逻辑相同,但直接接收已查到的 session 和 modelInfo,跳过重复查询。
|
||||
// 用于托管流程中 Regis 刚创建完 session 后立即 Chat 的场景。
|
||||
func (a *AdviceChatBiz) ChatWithSession(ctx context.Context, chat *entitys.AdvicerChatReq, session *dbmodel.AiAdviceSession, modelInfo *dbmodel.AiAdviceModelSup) (assistant mongo_model.Assistant, err error) {
|
||||
if session == nil || session.SessionID == "" {
|
||||
return assistant, errors.New("会话信息不能为空")
|
||||
}
|
||||
if modelInfo == nil || modelInfo.SupID == 0 {
|
||||
return assistant, errors.New("模型信息不能为空")
|
||||
}
|
||||
if len(chat.Content) == 0 {
|
||||
return assistant, nil
|
||||
}
|
||||
chatHis, err := a.getChatHis(ctx, session.SessionID, 6)
|
||||
if err != nil {
|
||||
return assistant, err
|
||||
}
|
||||
prompt, err := a.buildChatPromptResponse(ctx, chat, session, chatHis)
|
||||
if err != nil {
|
||||
return assistant, err
|
||||
}
|
||||
resContent, err := a.callLlmResponse(ctx, prompt, modelInfo.Key, modelInfo.URL, modelInfo.ChatModel, session.ContextCache)
|
||||
if err != nil {
|
||||
return assistant, err
|
||||
}
|
||||
|
||||
result := resContent.GetOutputText()
|
||||
if err = json.Unmarshal([]byte(result), &assistant); err != nil {
|
||||
return assistant, err
|
||||
}
|
||||
var inToken, outToken int64
|
||||
if resContent.Usage != nil {
|
||||
inToken = int64(resContent.Usage.InputTokens)
|
||||
outToken = int64(resContent.Usage.OutputTokens)
|
||||
}
|
||||
chatCtx, cancel := context.WithCancel(context.Background())
|
||||
go func(s dbmodel.AiAdviceSession) {
|
||||
defer cancel()
|
||||
_, _ = a.mongo.Co(a.advicerChatHisMongo).InsertOne(chatCtx, &mongo_model.AdvicerChatHisMongo{
|
||||
SessionId: chat.SessionId,
|
||||
User: chat.Content,
|
||||
Assistant: assistant,
|
||||
InToken: inToken,
|
||||
OutToken: outToken,
|
||||
CreatAt: time.Now(),
|
||||
})
|
||||
if assistant.MissionStatus == "fail" || assistant.MissionStatus == "completed" {
|
||||
cond := builder.NewCond()
|
||||
cond = cond.And(builder.Eq{"session_id": chat.SessionId})
|
||||
s.MissionStatus = assistant.MissionStatus
|
||||
s.MissionCompleteDesc = assistant.MissionCompleteDesc
|
||||
_ = a.aiAdviceSessionImpl.UpdateByCond(&cond, s)
|
||||
}
|
||||
}(*session)
|
||||
return
|
||||
}
|
||||
|
||||
func (a *AdviceChatBiz) buildChatPromptResponse(ctx context.Context, chat *entitys.AdvicerChatReq, session *dbmodel.AiAdviceSession, chatList []mongo_model.AdvicerChatHisMongoEntity) ([]openai.ResponseInputMessage, error) {
|
||||
message := []openai.ResponseInputMessage{
|
||||
{
|
||||
|
|
|
|||
|
|
@ -106,6 +106,11 @@ func (a *AdviceCustomerBiz) BatchUpdate(ctx context.Context, req *entitys.Advice
|
|||
return a.impl.BatchUpdateByWxid(ctx, records)
|
||||
}
|
||||
|
||||
// UpdateLabels 仅更新好友的标签列表
|
||||
func (a *AdviceCustomerBiz) UpdateLabels(ctx context.Context, req *entitys.AdvicerCustomerUpdateLabelsReq) error {
|
||||
return a.impl.UpdateLabelList(ctx, req.SelfWxid, req.UserName, req.LabelList)
|
||||
}
|
||||
|
||||
func (a *AdviceCustomerBiz) toModel(req *entitys.AdvicerCustomerAddReq) model.AiAdviceCustomer {
|
||||
return model.AiAdviceCustomer{
|
||||
SelfWxid: req.SelfWxid,
|
||||
|
|
@ -131,3 +136,13 @@ func (a *AdviceCustomerBiz) toModel(req *entitys.AdvicerCustomerAddReq) model.Ai
|
|||
PhoneNumList: req.PhoneNumList,
|
||||
}
|
||||
}
|
||||
|
||||
// FindBySelfWxidAndUserName 按登录wxid+客户wxid查单条客户记录
|
||||
func (a *AdviceCustomerBiz) FindBySelfWxidAndUserName(ctx context.Context, selfWxid, userName string) (model.AiAdviceCustomer, error) {
|
||||
return a.impl.FindBySelfWxidAndUserName(ctx, selfWxid, userName)
|
||||
}
|
||||
|
||||
// UpdateSessionId 更新客户的当前活跃会话ID
|
||||
func (a *AdviceCustomerBiz) UpdateSessionId(ctx context.Context, customerId int32, sessionId string) error {
|
||||
return a.impl.UpdateSessionId(ctx, customerId, sessionId)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -82,3 +82,13 @@ func (a *AdviceLabelBiz) Sync(ctx context.Context, req *entitys.AdvicerLabelSync
|
|||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ToggleHosting 切换标签托管状态
|
||||
func (a *AdviceLabelBiz) ToggleHosting(ctx context.Context, req *entitys.AdvicerLabelHostingReq) error {
|
||||
return a.impl.UpdateHosting(ctx, req.SelfWxid, req.LabelId, req.Hosting)
|
||||
}
|
||||
|
||||
// GetHostingLabelIds 返回某销售下 hosting=1 的标签 ID 列表
|
||||
func (a *AdviceLabelBiz) GetHostingLabelIds(ctx context.Context, selfWxid string) ([]int, error) {
|
||||
return a.impl.GetHostingLabelIds(ctx, selfWxid)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ package biz
|
|||
|
||||
import (
|
||||
"ai_scheduler/internal/config"
|
||||
dbmodel "ai_scheduler/internal/data/model"
|
||||
"ai_scheduler/internal/data/mongo_model"
|
||||
"ai_scheduler/internal/entitys"
|
||||
"ai_scheduler/internal/pkg"
|
||||
|
|
@ -11,6 +12,7 @@ import (
|
|||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math/rand"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
|
|
@ -18,6 +20,7 @@ import (
|
|||
"go.mongodb.org/mongo-driver/mongo/options"
|
||||
|
||||
"github.com/gofiber/fiber/v2/log"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// WsBroadcaster WebSocket 广播接口(由 services/advice.WxWsHub 实现)
|
||||
|
|
@ -29,12 +32,19 @@ type WsBroadcaster interface {
|
|||
// 上游(WTAPI)消息通过 login/setCallback 配置的回调地址推送到本服务,
|
||||
// 本模块负责容错解析回调报文、记录消息流水、更新客户互动时间。
|
||||
type AdviceWxBiz struct {
|
||||
cfg *config.Config
|
||||
mongo *pkg.Mongo
|
||||
wxMsgMongo *mongo_model.AdvicerWxMsgMongo
|
||||
clientBiz *AdviceClientBiz
|
||||
strategyBiz *AdviceStrategyBiz
|
||||
wsBroadcaster WsBroadcaster
|
||||
cfg *config.Config
|
||||
mongo *pkg.Mongo
|
||||
wxMsgMongo *mongo_model.AdvicerWxMsgMongo
|
||||
clientBiz *AdviceClientBiz
|
||||
strategyBiz *AdviceStrategyBiz
|
||||
wsBroadcaster WsBroadcaster
|
||||
adviceCustomerBiz *AdviceCustomerBiz
|
||||
adviceLabelBiz *AdviceLabelBiz
|
||||
adviceAdvicerBiz *AdviceAdvicerBiz
|
||||
adviceChatBiz *AdviceChatBiz
|
||||
adviceProjectBiz *AdviceProjectBiz
|
||||
adviceSkillBiz *AdviceSkillBiz
|
||||
wxSendBiz *AdviceWxSendBiz
|
||||
}
|
||||
|
||||
func NewAdviceWxBiz(
|
||||
|
|
@ -44,14 +54,28 @@ func NewAdviceWxBiz(
|
|||
clientBiz *AdviceClientBiz,
|
||||
strategyBiz *AdviceStrategyBiz,
|
||||
wsBroadcaster WsBroadcaster,
|
||||
adviceCustomerBiz *AdviceCustomerBiz,
|
||||
adviceLabelBiz *AdviceLabelBiz,
|
||||
adviceAdvicerBiz *AdviceAdvicerBiz,
|
||||
adviceChatBiz *AdviceChatBiz,
|
||||
adviceProjectBiz *AdviceProjectBiz,
|
||||
adviceSkillBiz *AdviceSkillBiz,
|
||||
wxSendBiz *AdviceWxSendBiz,
|
||||
) *AdviceWxBiz {
|
||||
return &AdviceWxBiz{
|
||||
cfg: cfg,
|
||||
mongo: mongo,
|
||||
wxMsgMongo: wxMsgMongo,
|
||||
clientBiz: clientBiz,
|
||||
strategyBiz: strategyBiz,
|
||||
wsBroadcaster: wsBroadcaster,
|
||||
cfg: cfg,
|
||||
mongo: mongo,
|
||||
wxMsgMongo: wxMsgMongo,
|
||||
clientBiz: clientBiz,
|
||||
strategyBiz: strategyBiz,
|
||||
wsBroadcaster: wsBroadcaster,
|
||||
adviceCustomerBiz: adviceCustomerBiz,
|
||||
adviceLabelBiz: adviceLabelBiz,
|
||||
adviceAdvicerBiz: adviceAdvicerBiz,
|
||||
adviceChatBiz: adviceChatBiz,
|
||||
adviceProjectBiz: adviceProjectBiz,
|
||||
adviceSkillBiz: adviceSkillBiz,
|
||||
wxSendBiz: wxSendBiz,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -226,9 +250,303 @@ func (a *AdviceWxBiz) handleOneEvent(ctx context.Context, body []byte) error {
|
|||
a.strategyBiz.OnCustomerMessage(sctx, wxid, msgContent)
|
||||
}()
|
||||
}
|
||||
|
||||
// 托管自动注册 + AI 回复:双向触发(不限 direction),文本私聊消息检查
|
||||
if wx.IsTextMsg(msgType) && !event.IsGroupMsg() {
|
||||
appId, selfWxid, msgContent := event.AppId, event.Wxid, content
|
||||
go a.tryHostingAutoRegis(context.Background(), appId, selfWxid, peerWxid, msgContent, direction)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ==================== 托管自动注册 ====================
|
||||
|
||||
// tryHostingAutoRegis 托管自动注册 + AI 回复:检查消息是否触发托管会话,
|
||||
// 若客户无 session 则自动注册,然后用触发消息调用 Chat 生成 AI 回复并发送。
|
||||
func (a *AdviceWxBiz) tryHostingAutoRegis(ctx context.Context, appId, selfWxid, peerWxid, content string, direction string) {
|
||||
if appId == "" || selfWxid == "" || peerWxid == "" {
|
||||
return
|
||||
}
|
||||
|
||||
// 1. 通过 appId(即 wx_device_id)查 advicer
|
||||
advicer, err := a.adviceAdvicerBiz.FindByWxDeviceId(ctx, appId)
|
||||
if err != nil {
|
||||
log.Infof("[托管] 查 advicer 失败 appId=%s err=%v", appId, err)
|
||||
return
|
||||
}
|
||||
if advicer.AdvicerID == 0 || advicer.HostingEnabled != 1 {
|
||||
return
|
||||
}
|
||||
|
||||
// 2. 查 customer(selfWxid + peerWxid)
|
||||
customer, err := a.adviceCustomerBiz.FindBySelfWxidAndUserName(ctx, selfWxid, peerWxid)
|
||||
if err != nil {
|
||||
log.Infof("[托管] 查 customer 失败 selfWxid=%s peerWxid=%s err=%v", selfWxid, peerWxid, err)
|
||||
return
|
||||
}
|
||||
if customer.CustomerID == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
sessionId := customer.SessionId
|
||||
var cachedSession *dbmodel.AiAdviceSession
|
||||
var cachedModelInfo *dbmodel.AiAdviceModelSup
|
||||
|
||||
// 3. 无会话 → 检查标签交集 → 自动注册(projectInfo 延迟到需要时才查)
|
||||
if sessionId == "" {
|
||||
hostingLabelIds, _ := a.adviceLabelBiz.GetHostingLabelIds(ctx, selfWxid)
|
||||
if len(hostingLabelIds) == 0 {
|
||||
return
|
||||
}
|
||||
customerLabelSet := make(map[int]bool)
|
||||
if customer.LabelList != "" {
|
||||
for _, s := range strings.Split(customer.LabelList, ",") {
|
||||
var id int
|
||||
if _, e := fmt.Sscanf(strings.TrimSpace(s), "%d", &id); e == nil {
|
||||
customerLabelSet[id] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
hasIntersection := false
|
||||
for _, id := range hostingLabelIds {
|
||||
if customerLabelSet[id] {
|
||||
hasIntersection = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hasIntersection {
|
||||
return
|
||||
}
|
||||
// 标签有交集,才查项目信息(用于注册 + 后续发送)
|
||||
projectInfo, err := a.adviceProjectBiz.Info(ctx, &entitys.AdvicerProjectInfoReq{ProjectId: advicer.ProjectID})
|
||||
if err != nil {
|
||||
log.Infof("[托管] 查项目信息失败 projectId=%d err=%v", advicer.ProjectID, err)
|
||||
return
|
||||
}
|
||||
sessionId, cachedSession, cachedModelInfo, err = a.autoRegisSession(ctx, advicer, projectInfo, customer.CustomerID, customer.NickName)
|
||||
if err != nil {
|
||||
log.Infof("[托管] 自动注册会话失败 err=%v", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 有会话 + 客户发的消息 → 调用 Chat 生成 AI 回复并发送
|
||||
if sessionId != "" && direction == mongo_model.WxMsgDirectionCustomer && content != "" {
|
||||
// 延迟获取 wxToken(已有会话路径才需要查项目信息)
|
||||
wxToken := ""
|
||||
if p, err := a.adviceProjectBiz.Info(ctx, &entitys.AdvicerProjectInfoReq{ProjectId: advicer.ProjectID}); err == nil {
|
||||
wxToken = p.Base.WxToken
|
||||
} else {
|
||||
log.Infof("[托管] 查项目信息失败 projectId=%d err=%v", advicer.ProjectID, err)
|
||||
return
|
||||
}
|
||||
a.hostingChatAndSend(ctx, sessionId, content, appId, peerWxid, wxToken, cachedSession, cachedModelInfo)
|
||||
}
|
||||
}
|
||||
|
||||
// autoRegisSession 自动注册托管会话,返回 sessionId + session对象 + modelInfo(供后续 Chat 复用)。
|
||||
// advicer 和 projectInfo 由调用方传入,避免重复查询。
|
||||
func (a *AdviceWxBiz) autoRegisSession(ctx context.Context,
|
||||
advicer dbmodel.AiAdviceAdvicer,
|
||||
projectInfo *entitys.AdvicerProjectInfoRes,
|
||||
customerId int32, customerNickName string,
|
||||
) (string, *dbmodel.AiAdviceSession, *dbmodel.AiAdviceModelSup, error) {
|
||||
// 解析版本
|
||||
versionId := advicer.HostingVersionId
|
||||
var err error
|
||||
if versionId == "" {
|
||||
versionId, _, err = a.adviceAdvicerBiz.GetLatestVersion(ctx, advicer.AdvicerID)
|
||||
if err != nil {
|
||||
return "", nil, nil, fmt.Errorf("取最新版本版本失败: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if len(projectInfo.ModelInfo.Key) == 0 || len(projectInfo.ModelInfo.ChatModel) == 0 {
|
||||
return "", nil, nil, fmt.Errorf("项目未配置模型 projectId=%d", advicer.ProjectID)
|
||||
}
|
||||
|
||||
// 查模型信息(用于后续 Chat,通过 projectInfo 中的 ModelSupID 获取)
|
||||
modelInfo, err := a.adviceProjectBiz.ModelInfo(projectInfo.Base.ModelSupID)
|
||||
if err != nil {
|
||||
return "", nil, nil, fmt.Errorf("查模型信息失败: %w", err)
|
||||
}
|
||||
|
||||
// 查版本数据
|
||||
versionInfo, err := a.adviceAdvicerBiz.VersionInfo(ctx, &entitys.AdvicerVersionInfoReq{Id: versionId})
|
||||
if err != nil {
|
||||
return "", nil, nil, fmt.Errorf("查版本数据失败: %w", err)
|
||||
}
|
||||
|
||||
// 构建 ChatData(直接使用传入的 advicer,不再重复查询)
|
||||
chatData := &entitys.ChatData{
|
||||
AdvicerInfo: advicer.Entity(),
|
||||
AdvicerVersion: versionInfo.Data,
|
||||
ProjectInfo: projectInfo.ConfigInfo.Entity(),
|
||||
RuleDimension: projectInfo.Base.RuleDimension,
|
||||
}
|
||||
|
||||
// 构建注册请求(托管场景额外强调短消息格式)
|
||||
mission := fmt.Sprintf("与%s的自动托管会话。注意:每条回复必须简短(10~30字),像真人微信聊天一样拆成多条短消息,用\\n分隔。", customerNickName)
|
||||
if customerNickName == "" {
|
||||
mission = "自动托管会话。注意:每条回复必须简短(10~30字),像真人微信聊天一样拆成多条短消息,用\\n分隔。"
|
||||
}
|
||||
regisReq := &entitys.AdvicerChatRegistReq{
|
||||
AdvicerVersionId: versionId,
|
||||
TalkSkillId: advicer.HostingSkillId,
|
||||
Mission: mission,
|
||||
AdvicerId: advicer.AdvicerID,
|
||||
}
|
||||
|
||||
// 构造 session 对象(Regis 内部会写入相同数据,这里同步保存供后续 Chat 复用)
|
||||
sessionId := uuid.New().String()
|
||||
session := &dbmodel.AiAdviceSession{
|
||||
SessionID: sessionId,
|
||||
ProjectID: projectInfo.Base.ProjectID,
|
||||
SupID: projectInfo.Base.ModelSupID,
|
||||
AdvicerVersionID: versionId,
|
||||
AdvicerID: advicer.AdvicerID,
|
||||
TalkSkillID: advicer.HostingSkillId,
|
||||
Mission: mission,
|
||||
}
|
||||
|
||||
// 注册会话
|
||||
regisSessionId, err := a.adviceChatBiz.Regis(ctx, chatData, regisReq, projectInfo)
|
||||
if err != nil {
|
||||
return "", nil, nil, fmt.Errorf("注册会话失败: %w", err)
|
||||
}
|
||||
sessionId = regisSessionId
|
||||
session.SessionID = sessionId
|
||||
|
||||
// 回写 customer.session_id
|
||||
if err = a.adviceCustomerBiz.UpdateSessionId(ctx, customerId, sessionId); err != nil {
|
||||
log.Infof("[托管] 回写 session_id 失败 customerId=%d sessionId=%s err=%v", customerId, sessionId, err)
|
||||
}
|
||||
log.Infof("[托管] 自动注册会话成功 customerId=%d sessionId=%s advicerId=%d", customerId, sessionId, advicer.AdvicerID)
|
||||
return sessionId, session, &modelInfo, nil
|
||||
}
|
||||
|
||||
// hostingChatAndSend 调用 Chat 生成 AI 回复,并通过微信发送。
|
||||
// 当 session 和 modelInfo 非空时(新注册路径),使用 ChatWithSession 跳过重复查询。
|
||||
func (a *AdviceWxBiz) hostingChatAndSend(ctx context.Context, sessionId, content, appId, peerWxid, wxToken string,
|
||||
session *dbmodel.AiAdviceSession, modelInfo *dbmodel.AiAdviceModelSup,
|
||||
) {
|
||||
var assistant mongo_model.Assistant
|
||||
var err error
|
||||
|
||||
chatReq := &entitys.AdvicerChatReq{SessionId: sessionId, Content: content}
|
||||
if session != nil && session.SessionID != "" && modelInfo != nil && modelInfo.SupID != 0 {
|
||||
// 新注册路径:session/model 已在 autoRegisSession 中获取,跳过重复查询
|
||||
assistant, err = a.adviceChatBiz.ChatWithSession(ctx, chatReq, session, modelInfo)
|
||||
} else {
|
||||
// 已有会话路径:走原 Chat 查询
|
||||
assistant, err = a.adviceChatBiz.Chat(ctx, chatReq)
|
||||
}
|
||||
if err != nil {
|
||||
log.Infof("[托管] Chat 失败 sessionId=%s err=%v", sessionId, err)
|
||||
return
|
||||
}
|
||||
reply := strings.TrimSpace(assistant.Result)
|
||||
if reply == "" {
|
||||
log.Infof("[托管] Chat 返回空回复 sessionId=%s", sessionId)
|
||||
return
|
||||
}
|
||||
// 拆分回复为多段消息,模拟真人分段发送
|
||||
chunks := splitReplyToChunks(reply)
|
||||
// 模拟阅读客户消息 + 思考的延迟(3~8秒)
|
||||
readDelay := time.Duration(3000+rand.Intn(5001)) * time.Millisecond
|
||||
time.Sleep(readDelay)
|
||||
// 通过微信分段发送 AI 回复(使用项目级 wx_token)
|
||||
if a.wxSendBiz != nil {
|
||||
if _, err = a.wxSendBiz.SendMultiWithToken(ctx, wxToken, appId, peerWxid, chunks, mongo_model.WxMsgSourceCallback); err != nil {
|
||||
log.Infof("[托管] 发送回复失败 appId=%s peerWxid=%s err=%v", appId, peerWxid, err)
|
||||
} else {
|
||||
log.Infof("[托管] 发送回复成功 sessionId=%s chunks=%d reply=%s", sessionId, len(chunks), reply)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// splitReplyToChunks 将 AI 回复拆分为多条短消息,模拟真人分段发送。
|
||||
// 拆分策略:先按换行分段,过长段落按句末标点拆分,过短段落合并。
|
||||
func splitReplyToChunks(reply string) []string {
|
||||
const maxChunkLen = 80 // 每条消息最大字数(接近真人微信习惯)
|
||||
const minChunkLen = 15 // 低于此字数考虑合并到上一条
|
||||
|
||||
// 1. 按双换行或单换行拆分为段落
|
||||
rawParagraphs := strings.Split(reply, "\n\n")
|
||||
var paragraphs []string
|
||||
for _, p := range rawParagraphs {
|
||||
p = strings.TrimSpace(p)
|
||||
if p == "" {
|
||||
continue
|
||||
}
|
||||
// 段落内如果还有单换行,也拆开
|
||||
for _, line := range strings.Split(p, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line != "" {
|
||||
paragraphs = append(paragraphs, line)
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(paragraphs) == 0 {
|
||||
return []string{reply}
|
||||
}
|
||||
|
||||
// 2. 过长段落按句末标点拆分
|
||||
sentenceEnds := []rune{'。', '!', '?', ';', '.', '!', '?', ';'}
|
||||
var segments []string
|
||||
for _, p := range paragraphs {
|
||||
if len([]rune(p)) <= maxChunkLen {
|
||||
segments = append(segments, p)
|
||||
continue
|
||||
}
|
||||
// 按句末标点拆分
|
||||
runes := []rune(p)
|
||||
start := 0
|
||||
for i, r := range runes {
|
||||
for _, sep := range sentenceEnds {
|
||||
if r == sep && i-start >= 10 {
|
||||
seg := string(runes[start : i+1])
|
||||
if len([]rune(seg)) <= maxChunkLen {
|
||||
segments = append(segments, seg)
|
||||
start = i + 1
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
// 剩余部分
|
||||
if start < len(runes) {
|
||||
remaining := strings.TrimSpace(string(runes[start:]))
|
||||
if remaining != "" {
|
||||
segments = append(segments, remaining)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 合并过短的段落(模拟真人一口气发完短句)
|
||||
var chunks []string
|
||||
buf := ""
|
||||
for _, s := range segments {
|
||||
if buf == "" {
|
||||
buf = s
|
||||
} else if len([]rune(buf))+len([]rune(s))+1 <= maxChunkLen && len([]rune(s)) < minChunkLen {
|
||||
// 当前段很短,合并到上一条
|
||||
buf = buf + "\n" + s
|
||||
} else {
|
||||
chunks = append(chunks, buf)
|
||||
buf = s
|
||||
}
|
||||
}
|
||||
if buf != "" {
|
||||
chunks = append(chunks, buf)
|
||||
}
|
||||
|
||||
if len(chunks) == 0 {
|
||||
return []string{reply}
|
||||
}
|
||||
return chunks
|
||||
}
|
||||
|
||||
// callbackCreateTime 将回调时间戳转为 time.Time(兼容秒/毫秒,缺省取当前时间)
|
||||
func callbackCreateTime(ts int64) time.Time {
|
||||
if ts <= 0 {
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import (
|
|||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math/rand"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
|
@ -43,11 +44,17 @@ func (a *AdviceWxSendBiz) Available() bool {
|
|||
|
||||
// SendText 向客户发送一条文本消息(appId 为空时自动按 wxid 取绑定客户的 appId)。
|
||||
// 发送成功后登记消息流水并更新客户互动时间(lastReplyAt)。
|
||||
// 使用全局配置 cfg.Advicer.WxToken 作为协议 Token。
|
||||
func (a *AdviceWxSendBiz) SendText(ctx context.Context, appId, wxid, content, source string) (err error) {
|
||||
token := a.cfg.Advicer.WxToken
|
||||
if len(token) == 0 {
|
||||
return errors.New("未配置 advicer.wx_token,无法发送微信消息")
|
||||
}
|
||||
return a.SendTextWithToken(ctx, token, appId, wxid, content, source)
|
||||
}
|
||||
|
||||
// SendTextWithToken 使用指定的 token 发送文本消息(托管场景使用项目级 wx_token)。
|
||||
func (a *AdviceWxSendBiz) SendTextWithToken(ctx context.Context, token, appId, wxid, content, source string) (err error) {
|
||||
content = strings.TrimSpace(content)
|
||||
if len(wxid) == 0 {
|
||||
return errors.New("wxid 不能为空")
|
||||
|
|
@ -106,3 +113,45 @@ func (a *AdviceWxSendBiz) SendMulti(ctx context.Context, appId, wxid string, con
|
|||
}
|
||||
return sent, nil
|
||||
}
|
||||
|
||||
// SendMultiWithToken 使用项目级 token 按顺序发送多条短消息,每条之间按打字速度计算延迟。
|
||||
func (a *AdviceWxSendBiz) SendMultiWithToken(ctx context.Context, token, appId, wxid string, contents []string, source string) (sent []string, err error) {
|
||||
for i, content := range contents {
|
||||
content = strings.TrimSpace(content)
|
||||
if len(content) == 0 {
|
||||
continue
|
||||
}
|
||||
if e := a.SendTextWithToken(ctx, token, appId, wxid, content, source); e != nil {
|
||||
return sent, e
|
||||
}
|
||||
sent = append(sent, content)
|
||||
// 最后一条之后不需要等待
|
||||
if i < len(contents)-1 {
|
||||
delay := typingDelay(len(content))
|
||||
time.Sleep(delay)
|
||||
}
|
||||
}
|
||||
return sent, nil
|
||||
}
|
||||
|
||||
// typingDelay 根据消息长度计算模拟真人打字延迟。
|
||||
// 模型:阅读上一条消息 + 思考 + 打字,每字约 300~500ms,加思考时间和随机抖动。
|
||||
func typingDelay(charCount int) time.Duration {
|
||||
if charCount <= 0 {
|
||||
return 3 * time.Second
|
||||
}
|
||||
// 思考时间:2~5秒(每条消息前都要想一想)
|
||||
thinkMs := 2000 + rand.Intn(3001)
|
||||
// 打字时间:每字 300~500ms(模拟手机端打字速度)
|
||||
typeMsPerChar := 300 + rand.Intn(201)
|
||||
typeMs := charCount * typeMsPerChar
|
||||
totalMs := thinkMs + typeMs
|
||||
// 下限 3秒,上限 20秒
|
||||
if totalMs < 3000 {
|
||||
totalMs = 3000
|
||||
}
|
||||
if totalMs > 20000 {
|
||||
totalMs = 20000
|
||||
}
|
||||
return time.Duration(totalMs) * time.Millisecond
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ const BasePrompt = `**[场景]**现在你是专业的顾问,我们在聊天软
|
|||
**[要求]**提供的json内容包含顾问,项目,对话技巧,客户信息,请根据json内容进行模仿真人进行对话,** 绝对不能让对方看出来你是ai!**
|
||||
**[规则]**
|
||||
1.必须模仿顾问的聊天方式,不能有markdown格式,聊天内容必须贴近聊天软件的正常聊天,非客户要求不要自顾自的介绍项目!开场白不要太长!
|
||||
2.像真人发微信一样说话:一条消息只表达一个意思,每条尽量简短(一般不超过40个字);要说的内容较多时,拆成2~3条短消息,在result里用\n分隔,不要把一大段话塞进同一条消息!
|
||||
2.像真人发微信一样说话:一条消息只表达一个意思,每条尽量控制在10~30个字;要说的内容较多时,必须拆成2~4条短消息,在result里用\n分隔。禁止把超过40个字的内容塞进同一条消息!记住:真人微信聊天不会发长段落,每条消息就像一口气打出来的那么短。
|
||||
3.结合[任务内容]信息,若任务状态为"in_progress",则在对话中尽量完成任务;如果任务状态为"completed"或"fail",则主动结束对话,且该轮对话中不再更新任务状态,mission_complete_desc记录最终结果且不再更改。不要每一句对话都包含任务内容,不要目的性太强!
|
||||
4.不能有任何画外音的文字格式出现!比如(等待对方回复)
|
||||
**[任务内容]**`
|
||||
|
|
@ -27,7 +27,7 @@ const BasePrompt2 = "**[输出格式]**\n" +
|
|||
"1. **result** (字符串)\n" +
|
||||
" - 对应变量:`{{chat_content}}`\n" +
|
||||
" - 内容:顾问的实际对话内容\n" +
|
||||
" - 要求:自然语言回复,面向用户;口语化、简短,多条消息必须用\\n转义符分隔(每条一般不超过40个字)\n\n" +
|
||||
" - 要求:自然语言回复,面向用户;口语化、简短,多条消息必须用\\n转义符分隔(每条控制在10~30字,禁止超过40字)\n\n" +
|
||||
"2. **mission** (字符串)\n" +
|
||||
" - 对应变量:`{{mission}}`\n" +
|
||||
" - 取值:任务内容\n" +
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import (
|
|||
"ai_scheduler/internal/data/model"
|
||||
"ai_scheduler/tmpl/dataTemp"
|
||||
"ai_scheduler/utils"
|
||||
"context"
|
||||
)
|
||||
|
||||
type AdviceAdvicerImpl struct {
|
||||
|
|
@ -15,3 +16,12 @@ func NewAdviceAdvicerImpl(db *utils.Db) *AdviceAdvicerImpl {
|
|||
DataTemp: *dataTemp.NewDataTemp(db, new(model.AiAdviceAdvicer)),
|
||||
}
|
||||
}
|
||||
|
||||
// FindByWxDeviceId 通过微信设备 ID 查询销售记录
|
||||
func (a *AdviceAdvicerImpl) FindByWxDeviceId(ctx context.Context, wxDeviceId string) (model.AiAdviceAdvicer, error) {
|
||||
var record model.AiAdviceAdvicer
|
||||
err := a.Db.WithContext(ctx).
|
||||
Where("wx_device_id = ?", wxDeviceId).
|
||||
First(&record).Error
|
||||
return record, err
|
||||
}
|
||||
|
|
|
|||
|
|
@ -53,3 +53,28 @@ func (a *AdviceCustomerImpl) BatchUpdateByWxid(ctx context.Context, records []mo
|
|||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdateLabelList 仅更新好友的标签列表
|
||||
func (a *AdviceCustomerImpl) UpdateLabelList(ctx context.Context, selfWxid, userName, labelList string) error {
|
||||
return a.Db.WithContext(ctx).
|
||||
Model(&model.AiAdviceCustomer{}).
|
||||
Where("self_wxid = ? AND user_name = ?", selfWxid, userName).
|
||||
Update("label_list", labelList).Error
|
||||
}
|
||||
|
||||
// FindBySelfWxidAndUserName 查询单个客户记录
|
||||
func (a *AdviceCustomerImpl) FindBySelfWxidAndUserName(ctx context.Context, selfWxid, userName string) (model.AiAdviceCustomer, error) {
|
||||
var record model.AiAdviceCustomer
|
||||
err := a.Db.WithContext(ctx).
|
||||
Where("self_wxid = ? AND user_name = ?", selfWxid, userName).
|
||||
First(&record).Error
|
||||
return record, err
|
||||
}
|
||||
|
||||
// UpdateSessionId 更新客户的会话 ID
|
||||
func (a *AdviceCustomerImpl) UpdateSessionId(ctx context.Context, customerId int32, sessionId string) error {
|
||||
return a.Db.WithContext(ctx).
|
||||
Model(&model.AiAdviceCustomer{}).
|
||||
Where("customer_id = ?", customerId).
|
||||
Update("session_id", sessionId).Error
|
||||
}
|
||||
|
|
|
|||
|
|
@ -52,3 +52,21 @@ func (a *AdviceLabelImpl) DeleteByLabelIds(ctx context.Context, selfWxid string,
|
|||
Where("self_wxid = ? AND label_id IN ?", selfWxid, labelIds).
|
||||
Delete(&model.AiAdviceLabel{}).Error
|
||||
}
|
||||
|
||||
// UpdateHosting 更新标签托管状态
|
||||
func (a *AdviceLabelImpl) UpdateHosting(ctx context.Context, selfWxid string, labelId int, hosting int8) error {
|
||||
return a.Db.WithContext(ctx).
|
||||
Model(&model.AiAdviceLabel{}).
|
||||
Where("self_wxid = ? AND label_id = ?", selfWxid, labelId).
|
||||
Update("hosting", hosting).Error
|
||||
}
|
||||
|
||||
// GetHostingLabelIds 查询某销售下所有已托管的标签 ID
|
||||
func (a *AdviceLabelImpl) GetHostingLabelIds(ctx context.Context, selfWxid string) ([]int, error) {
|
||||
var ids []int
|
||||
err := a.Db.WithContext(ctx).
|
||||
Model(&model.AiAdviceLabel{}).
|
||||
Where("self_wxid = ? AND hosting = 1", selfWxid).
|
||||
Pluck("label_id", &ids).Error
|
||||
return ids, err
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,15 +12,18 @@ const TableNameAiAdviceAdvicer = "ai_advice_advicer"
|
|||
|
||||
// AiAdviceAdvicer mapped from table <ai_advice_advicer>
|
||||
type AiAdviceAdvicer struct {
|
||||
AdvicerID int32 `gorm:"column:advicer_id;primaryKey;autoIncrement:true" json:"advicer_id"`
|
||||
ProjectID int32 `gorm:"column:project_id;not null" json:"project_id"`
|
||||
Name string `gorm:"column:name;not null;comment:姓名" json:"name"` // 姓名
|
||||
Birth time.Time `gorm:"column:birth;not null;comment:用户名称" json:"birth"` // 用户名称
|
||||
Gender int32 `gorm:"column:gender;not null;comment:1:男,2:女" json:"gender"` // 1:男,2:女
|
||||
WorkingYears int32 `gorm:"column:working_years;not null;default:1;comment:工作年限" json:"working_years"` // 工作年限
|
||||
JoinDate time.Time `gorm:"column:join_date;comment:入职时间" json:"join_date"` // 入职时间
|
||||
WxDeviceId string `gorm:"column:wx_device_id;comment:微信设备ID" json:"wx_device_id"` // 微信设备ID
|
||||
CreateAt time.Time `gorm:"column:create_at;default:CURRENT_TIMESTAMP" json:"create_at"`
|
||||
AdvicerID int32 `gorm:"column:advicer_id;primaryKey;autoIncrement:true" json:"advicer_id"`
|
||||
ProjectID int32 `gorm:"column:project_id;not null" json:"project_id"`
|
||||
Name string `gorm:"column:name;not null;comment:姓名" json:"name"` // 姓名
|
||||
Birth time.Time `gorm:"column:birth;not null;comment:用户名称" json:"birth"` // 用户名称
|
||||
Gender int32 `gorm:"column:gender;not null;comment:1:男,2:女" json:"gender"` // 1:男,2:女
|
||||
WorkingYears int32 `gorm:"column:working_years;not null;default:1;comment:工作年限" json:"working_years"` // 工作年限
|
||||
JoinDate time.Time `gorm:"column:join_date;comment:入职时间" json:"join_date"` // 入职时间
|
||||
WxDeviceId string `gorm:"column:wx_device_id;comment:微信设备ID" json:"wx_device_id"` // 微信设备ID
|
||||
HostingEnabled int32 `gorm:"column:hosting_enabled;not null;default:0;comment:托管开关 0关 1开" json:"hosting_enabled"`
|
||||
HostingVersionId string `gorm:"column:hosting_version_id;not null;default:'';comment:指定版本ID,空=最新" json:"hosting_version_id"`
|
||||
HostingSkillId string `gorm:"column:hosting_skill_id;not null;default:'';comment:指定技巧ID,空=最新" json:"hosting_skill_id"`
|
||||
CreateAt time.Time `gorm:"column:create_at;default:CURRENT_TIMESTAMP" json:"create_at"`
|
||||
}
|
||||
|
||||
// TableName AiAdviceAdvicer's table name
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ type AiAdviceCustomer struct {
|
|||
Description string `gorm:"column:description;not null;comment:描述" json:"description"`
|
||||
CardImgUrl string `gorm:"column:card_img_url;not null;comment:名片图 URL" json:"card_img_url"`
|
||||
LabelList string `gorm:"column:label_list;not null;comment:标签列表" json:"label_list"`
|
||||
SessionId string `gorm:"column:session_id;not null;default:'';comment:当前活跃会话ID" json:"session_id"`
|
||||
Province string `gorm:"column:province;not null;comment:省份" json:"province"`
|
||||
City string `gorm:"column:city;not null;comment:城市" json:"city"`
|
||||
PhoneNumList string `gorm:"column:phone_num_list;not null;comment:手机号列表" json:"phone_num_list"`
|
||||
|
|
|
|||
|
|
@ -6,12 +6,13 @@ const TableNameAiAdviceLabel = "ai_advice_label"
|
|||
|
||||
// AiAdviceLabel mapped from table <ai_advice_label>
|
||||
type AiAdviceLabel struct {
|
||||
ID int32 `gorm:"column:id;primaryKey;autoIncrement:true" json:"id"`
|
||||
SelfWxid string `gorm:"column:self_wxid;not null;comment:登录微信的 wxid" json:"self_wxid"`
|
||||
LabelId int `gorm:"column:label_id;not null;comment:微信标签 ID" json:"label_id"`
|
||||
LabelName string `gorm:"column:label_name;not null;comment:标签名称" json:"label_name"`
|
||||
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"`
|
||||
ID int32 `gorm:"column:id;primaryKey;autoIncrement:true" json:"id"`
|
||||
SelfWxid string `gorm:"column:self_wxid;not null;comment:登录微信的 wxid" json:"self_wxid"`
|
||||
LabelId int `gorm:"column:label_id;not null;comment:微信标签 ID" json:"label_id"`
|
||||
LabelName string `gorm:"column:label_name;not null;comment:标签名称" json:"label_name"`
|
||||
Hosting int8 `gorm:"column:hosting;not null;default:0;comment:是否托管中 0否1是" json:"hosting"`
|
||||
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"`
|
||||
}
|
||||
|
||||
// TableName AiAdviceLabel's table name
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ type AiAdviceSession struct {
|
|||
SupID int32 `gorm:"column:sup_id;not null" json:"sup_id"`
|
||||
AdvicerVersionID string `gorm:"column:advicer_version_id;not null;comment:顾问版本" json:"advicer_version_id"` // 顾问版本
|
||||
ClientID string `gorm:"column:client_id;not null;comment:客户信息id" json:"client_id"` // 客户信息id
|
||||
AdvicerID int32 `gorm:"column:advicer_id;not null;default:0;comment:销售人员ID" json:"advicer_id"` // 销售人员ID
|
||||
TalkSkillID string `gorm:"column:talk_skill_id;not null;comment:聊天话术id" json:"talk_skill_id"` // 聊天话术id
|
||||
ContextCache string `gorm:"column:context_cache;not null;comment:上下文缓存信息(火山引擎)" json:"context_cache"` // 上下文缓存信息(火山引擎)
|
||||
Mission string `gorm:"column:mission;not null;comment:任务" json:"mission"` // 任务
|
||||
|
|
|
|||
|
|
@ -83,9 +83,9 @@ type AdvicerVersionStyleGenerateReq struct {
|
|||
}
|
||||
|
||||
type AdvicerTalkSkillAddReq struct {
|
||||
ProjectId int32 `json:"projectId" bson:"projectId"`
|
||||
Desc string `json:"desc" bson:"desc"`
|
||||
Data map[string]interface{} `json:"data"` // 可视化编辑器数据(扁平 KV)
|
||||
ProjectId int32 `json:"projectId" bson:"projectId"`
|
||||
Desc string `json:"desc" bson:"desc"`
|
||||
Data map[string]interface{} `json:"data"` // 可视化编辑器数据(扁平 KV)
|
||||
NeedsMining mongo_model.NeedsMining `json:"needsMining" bson:"needsMining"`
|
||||
PainPointResponse mongo_model.PainPointResponse `json:"painPointResponse" bson:"painPointResponse"`
|
||||
ValueBuilding mongo_model.ValueBuilding `json:"valueBuilding" bson:"valueBuilding"`
|
||||
|
|
@ -94,10 +94,10 @@ type AdvicerTalkSkillAddReq struct {
|
|||
}
|
||||
|
||||
type AdvicerTalkSkillUpdateReq struct {
|
||||
Id string `json:"id"`
|
||||
ProjectId int32 `json:"projectId" bson:"projectId"`
|
||||
Desc string `json:"desc" bson:"desc"`
|
||||
Data map[string]interface{} `json:"data"` // 可视化编辑器数据(扁平 KV)
|
||||
Id string `json:"id"`
|
||||
ProjectId int32 `json:"projectId" bson:"projectId"`
|
||||
Desc string `json:"desc" bson:"desc"`
|
||||
Data map[string]interface{} `json:"data"` // 可视化编辑器数据(扁平 KV)
|
||||
NeedsMining mongo_model.NeedsMining `json:"needsMining" bson:"needsMining"`
|
||||
PainPointResponse mongo_model.PainPointResponse `json:"painPointResponse" bson:"painPointResponse"`
|
||||
ValueBuilding mongo_model.ValueBuilding `json:"valueBuilding" bson:"valueBuilding"`
|
||||
|
|
@ -254,6 +254,7 @@ type AdvicerChatRegistReq struct {
|
|||
ClientId string `json:"clientId"`
|
||||
TalkSkillId string `json:"talkSkillId"`
|
||||
Mission string `json:"mission"`
|
||||
AdvicerId int32 `json:"-"` // 内部填充,不由前端传入
|
||||
}
|
||||
|
||||
type AdvicerChatRegistRes struct {
|
||||
|
|
@ -524,6 +525,15 @@ type AdvicerCustomerBatchAddReq struct {
|
|||
List []AdvicerCustomerAddReq `json:"list"`
|
||||
}
|
||||
|
||||
// AdvicerCustomerUpdateLabelsReq 更新好友标签列表(wx API + DB 一次完成)
|
||||
type AdvicerCustomerUpdateLabelsReq struct {
|
||||
ProjectId int32 `json:"projectId" validate:"required" zh:"项目ID"`
|
||||
AppId string `json:"appId" zh:"设备ID"`
|
||||
SelfWxid string `json:"selfWxid" validate:"required" zh:"销售wxid"`
|
||||
UserName string `json:"userName" validate:"required" zh:"好友userName"`
|
||||
LabelList string `json:"labelList"`
|
||||
}
|
||||
|
||||
// ==================== 标签同步 ====================
|
||||
|
||||
// AdvicerLabelSyncReq 标签同步请求(前端从微信获取标签后提交)
|
||||
|
|
@ -542,3 +552,10 @@ type AdvicerLabelItemReq struct {
|
|||
type AdvicerLabelListReq struct {
|
||||
SelfWxid string `json:"selfWxid"`
|
||||
}
|
||||
|
||||
// AdvicerLabelHostingReq 切换标签托管状态
|
||||
type AdvicerLabelHostingReq struct {
|
||||
SelfWxid string `json:"selfWxid" validate:"required" zh:"销售wxid"`
|
||||
LabelId int `json:"labelId" validate:"required" zh:"标签ID"`
|
||||
Hosting int8 `json:"hosting"` // 0=取消托管 1=开启托管
|
||||
}
|
||||
|
|
|
|||
|
|
@ -33,26 +33,26 @@ type GetBriefInfoReq struct {
|
|||
|
||||
// GetBriefInfoResData data为数组,本结构体为数组元素类型
|
||||
type GetBriefInfoResData struct {
|
||||
UserName string `json:"userName"`
|
||||
NickName string `json:"nickName"`
|
||||
PyInitial string `json:"pyInitial"`
|
||||
QuanPin string `json:"quanPin"`
|
||||
Sex int `json:"sex"`
|
||||
Remark string `json:"remark"`
|
||||
RemarkPyInitial string `json:"remarkPyInitial"`
|
||||
RemarkQuanPin string `json:"remarkQuanPin"`
|
||||
Signature string `json:"signature"`
|
||||
Alias string `json:"alias"`
|
||||
SnsBgImg string `json:"snsBgImg"`
|
||||
Country string `json:"country"`
|
||||
BigHeadImgUrl string `json:"bigHeadImgUrl"`
|
||||
SmallHeadImgUrl string `json:"smallHeadImgUrl"`
|
||||
Description string `json:"description"`
|
||||
CardImgUrl string `json:"cardImgUrl"`
|
||||
LabelList string `json:"labelList"`
|
||||
Province string `json:"province"`
|
||||
City string `json:"city"`
|
||||
PhoneNumList string `json:"phoneNumList"`
|
||||
UserName string `json:"userName"`
|
||||
NickName string `json:"nickName"`
|
||||
PyInitial string `json:"pyInitial"`
|
||||
QuanPin string `json:"quanPin"`
|
||||
Sex int `json:"sex"`
|
||||
Remark string `json:"remark"`
|
||||
RemarkPyInitial string `json:"remarkPyInitial"`
|
||||
RemarkQuanPin string `json:"remarkQuanPin"`
|
||||
Signature string `json:"signature"`
|
||||
Alias string `json:"alias"`
|
||||
SnsBgImg string `json:"snsBgImg"`
|
||||
Country string `json:"country"`
|
||||
BigHeadImgUrl string `json:"bigHeadImgUrl"`
|
||||
SmallHeadImgUrl string `json:"smallHeadImgUrl"`
|
||||
Description string `json:"description"`
|
||||
CardImgUrl string `json:"cardImgUrl"`
|
||||
LabelList string `json:"labelList"`
|
||||
Province string `json:"province"`
|
||||
City string `json:"city"`
|
||||
PhoneNumList interface{} `json:"phoneNumList"`
|
||||
}
|
||||
|
||||
type GetDetailInfoReq struct {
|
||||
|
|
@ -102,8 +102,8 @@ type GetDetailInfoResData struct {
|
|||
Province string `json:"province"`
|
||||
// 城市
|
||||
City string `json:"city"`
|
||||
// 好友的手机号码
|
||||
PhoneNumList string `json:"phoneNumList"`
|
||||
// 好友的手机号码(上游有时返回数组有时返回字符串)
|
||||
PhoneNumList interface{} `json:"phoneNumList"`
|
||||
}
|
||||
|
||||
type ContactsSearchReq struct {
|
||||
|
|
|
|||
|
|
@ -121,6 +121,7 @@ func AdvicerRouterRegist(
|
|||
advicer.Post("modelsup/del", modelSup.Del)
|
||||
|
||||
// 微信 API 代理(包装转发,标准响应格式)
|
||||
advicer.Post("wx/batch_chatroom_info", wxProxy.BatchChatroomInfo)
|
||||
advicer.All("wx/*", wxProxy.Proxy)
|
||||
|
||||
// 微信好友(ai_advice_customer)
|
||||
|
|
@ -130,8 +131,10 @@ func AdvicerRouterRegist(
|
|||
advicer.Post("advicer/customer/diff", adviceCustomer.Diff)
|
||||
advicer.Post("advicer/customer/batch_add", adviceCustomer.BatchAdd)
|
||||
advicer.Post("advicer/customer/batch_update", adviceCustomer.BatchUpdate)
|
||||
advicer.Post("advicer/customer/update_labels", adviceCustomer.UpdateLabels)
|
||||
|
||||
// 微信标签(ai_advice_label)
|
||||
advicer.Post("advicer/label/list", adviceLabel.List)
|
||||
advicer.Post("advicer/label/sync", adviceLabel.Sync)
|
||||
advicer.Post("advicer/label/hosting", adviceLabel.ToggleHosting)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -115,6 +115,7 @@ func (a *ChatService) Regis(c *fiber.Ctx) error {
|
|||
AdvicerVersion: versionInfo.Data,
|
||||
RuleDimension: projectInfo.Base.RuleDimension,
|
||||
}
|
||||
req.AdvicerId = advicerInfo.AdvicerID
|
||||
sessionId, err := a.adviceChatBiz.Regis(c.UserContext(), &chat, req, projectInfo)
|
||||
|
||||
log.Info(sessionId)
|
||||
|
|
|
|||
|
|
@ -4,6 +4,9 @@ import (
|
|||
"ai_scheduler/internal/biz"
|
||||
"ai_scheduler/internal/entitys"
|
||||
"ai_scheduler/internal/pkg"
|
||||
"ai_scheduler/internal/pkg/wx"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
)
|
||||
|
|
@ -12,10 +15,11 @@ import (
|
|||
type CustomerService struct {
|
||||
customerBiz *biz.AdviceCustomerBiz
|
||||
labelBiz *biz.AdviceLabelBiz
|
||||
projectBiz *biz.AdviceProjectBiz
|
||||
}
|
||||
|
||||
func NewCustomerService(customerBiz *biz.AdviceCustomerBiz, labelBiz *biz.AdviceLabelBiz) *CustomerService {
|
||||
return &CustomerService{customerBiz: customerBiz, labelBiz: labelBiz}
|
||||
func NewCustomerService(customerBiz *biz.AdviceCustomerBiz, labelBiz *biz.AdviceLabelBiz, projectBiz *biz.AdviceProjectBiz) *CustomerService {
|
||||
return &CustomerService{customerBiz: customerBiz, labelBiz: labelBiz, projectBiz: projectBiz}
|
||||
}
|
||||
|
||||
// Add 新增单条好友
|
||||
|
|
@ -87,3 +91,46 @@ func (s *CustomerService) BatchUpdate(c *fiber.Ctx) error {
|
|||
err := s.customerBiz.BatchUpdate(c.UserContext(), req)
|
||||
return pkg.HandleResponse(c, nil, err)
|
||||
}
|
||||
|
||||
// UpdateLabels 更新好友标签列表(wx API + DB 一次完成)
|
||||
func (s *CustomerService) UpdateLabels(c *fiber.Ctx) error {
|
||||
req := &entitys.AdvicerCustomerUpdateLabelsReq{}
|
||||
if err := c.BodyParser(req); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 1. 查询项目的 wx_token
|
||||
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"))
|
||||
}
|
||||
|
||||
// 2. 调用微信 API 修改好友标签(全量替换)
|
||||
var wxIds []string
|
||||
if req.UserName != "" {
|
||||
wxIds = strings.Split(req.UserName, ",")
|
||||
}
|
||||
wxReq := wx.ModifyMemberListReq{
|
||||
AppId: req.AppId,
|
||||
LabelIds: req.LabelList,
|
||||
WxIds: wxIds,
|
||||
}
|
||||
var wxRes wx.ModifyMemberListResData
|
||||
if err := wx.Request(c.UserContext(), project.WxToken, wx.ModifyMemberList, wxReq, &wxRes); err != nil {
|
||||
return pkg.HandleResponse(c, nil, fmt.Errorf("微信标签修改失败: %w", err))
|
||||
}
|
||||
|
||||
// 3. 更新 DB
|
||||
if err := s.customerBiz.UpdateLabels(c.UserContext(), &entitys.AdvicerCustomerUpdateLabelsReq{
|
||||
SelfWxid: req.SelfWxid,
|
||||
UserName: req.UserName,
|
||||
LabelList: req.LabelList,
|
||||
}); err != nil {
|
||||
return pkg.HandleResponse(c, nil, fmt.Errorf("数据库更新失败: %w", err))
|
||||
}
|
||||
|
||||
return pkg.HandleResponse(c, nil, nil)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -36,3 +36,13 @@ func (s *LabelService) Sync(c *fiber.Ctx) error {
|
|||
err := s.labelBiz.Sync(c.UserContext(), req)
|
||||
return pkg.HandleResponse(c, nil, err)
|
||||
}
|
||||
|
||||
// ToggleHosting 切换标签托管状态
|
||||
func (s *LabelService) ToggleHosting(c *fiber.Ctx) error {
|
||||
req := &entitys.AdvicerLabelHostingReq{}
|
||||
if err := c.BodyParser(req); err != nil {
|
||||
return err
|
||||
}
|
||||
err := s.labelBiz.ToggleHosting(c.UserContext(), req)
|
||||
return pkg.HandleResponse(c, nil, err)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import (
|
|||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
)
|
||||
|
|
@ -205,6 +206,13 @@ func (s *WxProxyService) Proxy(c *fiber.Ctx) error {
|
|||
return pkg.HandleResponse(c, nil, fiber.NewError(fiber.StatusBadGateway, "微信接口调用失败: "+err.Error()))
|
||||
}
|
||||
|
||||
// TODO: 临时调试日志,排查完成后删除
|
||||
if sub == "contacts/getDetailInfo" {
|
||||
if raw, jerr := json.Marshal(data); jerr == nil {
|
||||
log.Printf("[DEBUG] contacts/getDetailInfo raw response: %s", string(raw))
|
||||
}
|
||||
}
|
||||
|
||||
// 标记跳过 registerCommon 的二次包装,避免响应被双层嵌套
|
||||
// Proxy 已自行返回标准 {code, data, message} 格式,无需中间件再包一层
|
||||
c.Locals("skip_response_wrap", true)
|
||||
|
|
@ -214,3 +222,52 @@ func (s *WxProxyService) Proxy(c *fiber.Ctx) error {
|
|||
"message": "ok",
|
||||
})
|
||||
}
|
||||
|
||||
// BatchChatroomInfoReq 批量获取群信息请求
|
||||
type BatchChatroomInfoReq struct {
|
||||
ProjectId int32 `json:"projectId"`
|
||||
AppId string `json:"appId"`
|
||||
ChatroomIds []string `json:"chatroomIds"`
|
||||
}
|
||||
|
||||
// BatchChatroomInfo 批量获取多个群的信息(内部循环调用 wx API,一次返回)
|
||||
func (s *WxProxyService) BatchChatroomInfo(c *fiber.Ctx) error {
|
||||
var req BatchChatroomInfoReq
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return pkg.HandleResponse(c, nil, err)
|
||||
}
|
||||
if len(req.ChatroomIds) == 0 {
|
||||
return pkg.HandleResponse(c, nil, nil)
|
||||
}
|
||||
|
||||
// 查询项目的 wx_token
|
||||
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"))
|
||||
}
|
||||
|
||||
// 循环调用 wx API,收集所有结果
|
||||
results := make([]wx.GetChatroomInfoResData, 0, len(req.ChatroomIds))
|
||||
for _, chatroomId := range req.ChatroomIds {
|
||||
wxReq := wx.GetChatroomInfoReq{
|
||||
AppId: req.AppId,
|
||||
ChatroomId: chatroomId,
|
||||
}
|
||||
var wxRes wx.GetChatroomInfoResData
|
||||
if err := wx.Request(c.UserContext(), project.WxToken, wx.GetChatroomInfo, wxReq, &wxRes); err != nil {
|
||||
log.Printf("[WARN] batch_chatroom_info: getChatroomInfo(%s) failed: %v", chatroomId, err)
|
||||
continue // 单个失败不影响其他
|
||||
}
|
||||
results = append(results, wxRes)
|
||||
}
|
||||
|
||||
c.Locals("skip_response_wrap", true)
|
||||
return c.JSON(fiber.Map{
|
||||
"code": 0,
|
||||
"data": results,
|
||||
"message": "ok",
|
||||
})
|
||||
}
|
||||
|
|
|
|||
BIN
server.exe
BIN
server.exe
Binary file not shown.
|
|
@ -0,0 +1,3 @@
|
|||
-- 标签托管标记:ai_advice_label 新增 hosting 字段
|
||||
ALTER TABLE `ai_advice_label`
|
||||
ADD COLUMN `hosting` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否托管中(0=否 1=是)' AFTER `label_name`;
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
-- 客户表新增会话字段(托管自动注册 AI 会话)
|
||||
ALTER TABLE ai_advice_customer
|
||||
ADD COLUMN session_id VARCHAR(64) NOT NULL DEFAULT '' COMMENT '当前活跃会话ID' AFTER label_list;
|
||||
|
||||
-- 会话表新增销售ID(用于动态取最新版本)
|
||||
ALTER TABLE ai_advice_session
|
||||
ADD COLUMN advicer_id INT NOT NULL DEFAULT 0 COMMENT '销售人员ID(托管自动注册时用于动态取最新版本)' AFTER client_id;
|
||||
|
|
@ -653,6 +653,7 @@ table.table td.ops .btn:last-child { margin-right: 0; }
|
|||
padding: 9px 13px; cursor: pointer;
|
||||
border-bottom: 1px solid rgba(105, 135, 255, 0.05);
|
||||
transition: background 0.13s ease;
|
||||
position: relative;
|
||||
}
|
||||
.contact-item:hover { background: var(--bg-hover); }
|
||||
.contact-item.active { background: var(--grad-soft); border-right: 2px solid var(--primary); }
|
||||
|
|
@ -732,6 +733,82 @@ table.table td.ops .btn:last-child { margin-right: 0; }
|
|||
}
|
||||
.label-row:hover { background: var(--bg-hover); }
|
||||
.label-arrow { font-size: 11px; color: var(--text-2); width: 14px; text-align: center; flex-shrink: 0; }
|
||||
.label-row .spacer { flex: 1; }
|
||||
.label-hosting-btn {
|
||||
font-size: 11px; padding: 2px 10px; border-radius: 10px;
|
||||
border: 1px solid var(--border-strong); background: transparent;
|
||||
color: var(--text-2); cursor: pointer; transition: all 0.16s ease;
|
||||
white-space: nowrap; flex-shrink: 0;
|
||||
}
|
||||
.label-hosting-btn:hover { background: var(--bg-hover); color: var(--text-0); }
|
||||
.label-hosting-btn.hosting {
|
||||
background: linear-gradient(135deg, rgba(45, 224, 255, 0.18), rgba(79, 124, 255, 0.18));
|
||||
border-color: rgba(45, 224, 255, 0.5); color: var(--cyan);
|
||||
}
|
||||
.label-hosting-btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
|
||||
/* 已托管标签小标签(青绿色调区分) */
|
||||
.ci-label-hosting {
|
||||
background: linear-gradient(135deg, rgba(45, 224, 255, 0.15), rgba(47, 208, 140, 0.15));
|
||||
border-color: rgba(45, 224, 255, 0.4);
|
||||
color: var(--cyan);
|
||||
}
|
||||
|
||||
/* 编辑标签按钮(好友/会话列表项右下角) */
|
||||
.ci-edit-label-btn {
|
||||
position: absolute; right: 6px; bottom: 4px;
|
||||
font-size: 10px; padding: 1px 7px; border-radius: 8px;
|
||||
border: 1px solid var(--border); background: rgba(14, 21, 40, 0.7);
|
||||
color: var(--text-2); cursor: pointer; transition: all 0.15s ease;
|
||||
opacity: 0; z-index: 2;
|
||||
}
|
||||
.contact-item:hover .ci-edit-label-btn { opacity: 1; }
|
||||
.ci-edit-label-btn:hover {
|
||||
background: var(--bg-hover); color: var(--text-0);
|
||||
border-color: var(--border-strong);
|
||||
}
|
||||
|
||||
/* 标签编辑弹窗 */
|
||||
.wx-label-editor-popup {
|
||||
width: 260px; max-height: 380px;
|
||||
background: rgba(14, 22, 44, 0.97);
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.55), 0 0 0 1px rgba(105, 135, 255, 0.12);
|
||||
backdrop-filter: blur(20px);
|
||||
animation: fadeIn 0.15s ease;
|
||||
display: flex; flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
.wx-le-head {
|
||||
padding: 10px 14px; font-size: 13px; font-weight: 700;
|
||||
border-bottom: 1px solid var(--border); color: var(--text-0);
|
||||
}
|
||||
.wx-le-body {
|
||||
flex: 1; overflow-y: auto; padding: 6px 0;
|
||||
max-height: 260px;
|
||||
}
|
||||
.wx-le-item {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
padding: 7px 14px; cursor: pointer; transition: background 0.12s ease;
|
||||
font-size: 12.5px; color: var(--text-1);
|
||||
}
|
||||
.wx-le-item:hover { background: var(--bg-hover); }
|
||||
.wx-le-item.wx-le-checked { color: var(--text-0); background: rgba(79, 124, 255, 0.08); }
|
||||
.wx-le-item input[type="checkbox"] {
|
||||
accent-color: var(--primary); width: 14px; height: 14px; flex-shrink: 0;
|
||||
}
|
||||
.wx-le-name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.wx-le-hosting-tag {
|
||||
font-size: 9px; padding: 1px 5px; border-radius: 6px;
|
||||
background: rgba(45, 224, 255, 0.15); color: var(--cyan);
|
||||
border: 1px solid rgba(45, 224, 255, 0.3);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.wx-le-foot {
|
||||
display: flex; justify-content: flex-end; gap: 8px;
|
||||
padding: 8px 14px; border-top: 1px solid var(--border);
|
||||
}
|
||||
.label-friend { padding-left: 36px; }
|
||||
.label-friend .wx-avatar { width: 32px !important; height: 32px !important; }
|
||||
.label-loading { text-align: center; padding: 10px 16px; }
|
||||
|
|
|
|||
|
|
@ -37,6 +37,18 @@
|
|||
function loadBriefCache() { return {}; }
|
||||
function saveBriefCache() {}
|
||||
function clearBriefCache() {}
|
||||
|
||||
/** 将上游可能返回数组或字符串的字段统一转为字符串 */
|
||||
function normalizeStr(val) {
|
||||
if (val == null) return "";
|
||||
if (Array.isArray(val)) {
|
||||
return val.map(function (item) {
|
||||
// 去掉控制字符(\n \u000b 等)和空白,只保留数字、+、-、空格
|
||||
return String(item).replace(/[\x00-\x1F\x7F]/g, "").trim();
|
||||
}).filter(Boolean).join(",");
|
||||
}
|
||||
return String(val).replace(/[\x00-\x1F\x7F]/g, "").trim();
|
||||
}
|
||||
|
||||
function freshState(container) {
|
||||
// 不使用缓存的 wxApp,每次 mount 都从 API 重新获取设备信息
|
||||
|
|
@ -145,7 +157,7 @@
|
|||
if (!state) return;
|
||||
if (state.loginTimer) clearInterval(state.loginTimer);
|
||||
disconnectWs();
|
||||
// 清理联系人详情缓存
|
||||
closeLabelEditor();
|
||||
WxN.clearDetailCache();
|
||||
WxN.hideContactDetail();
|
||||
state = null;
|
||||
|
|
@ -253,7 +265,7 @@
|
|||
return Core.api("advicer/label/list", { selfWxid: selfWxid })
|
||||
.then(function (list) {
|
||||
state.labels = (list || []).map(function (l) {
|
||||
return { labelId: l.label_id, labelName: l.label_name };
|
||||
return { labelId: l.label_id, labelName: l.label_name, hosting: l.hosting || 0 };
|
||||
});
|
||||
// 构建 labelId→labelName 映射
|
||||
state.labelMap = {};
|
||||
|
|
@ -362,7 +374,7 @@
|
|||
// 从 diff 响应中直接加载标签(进入工作台时一并获取)
|
||||
if (res.labels) {
|
||||
state.labels = res.labels.map(function (l) {
|
||||
return { labelId: l.label_id, labelName: l.label_name };
|
||||
return { labelId: l.label_id, labelName: l.label_name, hosting: l.hosting || 0 };
|
||||
});
|
||||
state.labelMap = {};
|
||||
state.labels.forEach(function (l) { state.labelMap[String(l.labelId)] = l.labelName; });
|
||||
|
|
@ -423,7 +435,7 @@
|
|||
smallHeadImgUrl: info.smallHeadImgUrl || "", description: info.description || "",
|
||||
cardImgUrl: info.cardImgUrl || "", labelList: info.labelList || "",
|
||||
province: info.province || "", city: info.city || "",
|
||||
phoneNumList: info.phoneNumList || ""
|
||||
phoneNumList: normalizeStr(info.phoneNumList)
|
||||
};
|
||||
});
|
||||
Core.api("advicer/customer/batch_add", { list: records })
|
||||
|
|
@ -481,7 +493,7 @@
|
|||
smallHeadImgUrl: info.smallHeadImgUrl || "", description: info.description || "",
|
||||
cardImgUrl: info.cardImgUrl || "", labelList: info.labelList || "",
|
||||
province: info.province || "", city: info.city || "",
|
||||
phoneNumList: info.phoneNumList || ""
|
||||
phoneNumList: normalizeStr(info.phoneNumList)
|
||||
});
|
||||
}
|
||||
});
|
||||
|
|
@ -510,7 +522,7 @@
|
|||
return { name: name, avatar: info.bigHeadImgUrl || info.smallHeadImgUrl || "" };
|
||||
}
|
||||
|
||||
/** 获取好友的标签 HTML(小标签展示) */
|
||||
/** 获取好友的标签 HTML(小标签展示,全部标签) */
|
||||
function contactLabelsHtml(wxid) {
|
||||
var info = state.brief[wxid];
|
||||
if (!info || !info.labelList) return "";
|
||||
|
|
@ -527,6 +539,45 @@
|
|||
}).join("") + '</div>';
|
||||
}
|
||||
|
||||
/** 获取好友的已托管标签 HTML(仅显示 hosting=1 的标签) */
|
||||
function hostingLabelsHtml(wxid) {
|
||||
var info = state.brief[wxid];
|
||||
if (!info || !info.labelList) return "";
|
||||
var ids = String(info.labelList).split(",").filter(Boolean);
|
||||
if (!ids.length) return "";
|
||||
var tags = [];
|
||||
ids.forEach(function (id) {
|
||||
// 查找该标签是否为托管标签
|
||||
for (var i = 0; i < state.labels.length; i++) {
|
||||
if (String(state.labels[i].labelId) === id && state.labels[i].hosting) {
|
||||
tags.push(state.labels[i].labelName);
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
if (!tags.length) return "";
|
||||
return '<div class="ci-labels">' + tags.map(function (t) {
|
||||
return '<span class="ci-label ci-label-hosting">' + Core.esc(t) + '</span>';
|
||||
}).join("") + '</div>';
|
||||
}
|
||||
|
||||
/** 获取好友所属的托管标签 ID 列表 */
|
||||
function getHostingLabelIds(wxid) {
|
||||
var info = state.brief[wxid];
|
||||
if (!info || !info.labelList) return [];
|
||||
var ids = String(info.labelList).split(",").filter(Boolean);
|
||||
var hostingIds = [];
|
||||
ids.forEach(function (id) {
|
||||
for (var i = 0; i < state.labels.length; i++) {
|
||||
if (String(state.labels[i].labelId) === id && state.labels[i].hosting) {
|
||||
hostingIds.push(id);
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
return hostingIds;
|
||||
}
|
||||
|
||||
function renderContacts() {
|
||||
if (WxN.hideContactDetail) WxN.hideContactDetail();
|
||||
var box = $("#wxContacts", state.container);
|
||||
|
|
@ -570,9 +621,11 @@
|
|||
unreadBadge + '</div>' +
|
||||
'<div class="ci-main"><div class="ci-name-row"><span class="ci-name">' + Core.esc(d.name) + '</span>' +
|
||||
(c.isGroup ? '<span class="ci-tag">群</span>' : '') +
|
||||
hostingLabelsHtml(c.wxid) +
|
||||
'<span class="ci-time">' + Core.esc(timeStr) + "</span></div>" +
|
||||
contactLabelsHtml(c.wxid) +
|
||||
'<div class="ci-sub">' + Core.esc(dirIcon + " " + (c.lastMsg || "")) + "</div></div></div>";
|
||||
'<div class="ci-sub">' + Core.esc(dirIcon + " " + (c.lastMsg || "")) + '</div></div>' +
|
||||
(!c.isGroup ? '<button class="ci-edit-label-btn" data-wxid="' + Core.esc(c.wxid) + '" title="编辑标签">标签</button>' : '') +
|
||||
'</div>';
|
||||
}).join("");
|
||||
}
|
||||
} else if (state.tab === "labels") {
|
||||
|
|
@ -595,10 +648,14 @@
|
|||
var countSpan = (count != null)
|
||||
? ' <span class="c-dim fs-11">(' + count + ')</span>'
|
||||
: ' <span class="c-dim fs-11">…</span>';
|
||||
var hostingCls = l.hosting ? ' hosting' : '';
|
||||
var hostingBtn = '<button class="label-hosting-btn' + hostingCls + '" data-label-id="' + Core.esc(lid) + '" data-hosting="' + (l.hosting ? 0 : 1) + '" title="' + (l.hosting ? '取消托管' : '开启托管') + '">' + (l.hosting ? '托管中' : '托管') + '</button>';
|
||||
var html = '<div class="label-row" data-label-id="' + Core.esc(lid) + '">' +
|
||||
'<span class="label-arrow">' + arrow + '</span>' +
|
||||
'<span class="label-name">' + Core.esc(l.labelName) + '</span>' +
|
||||
countSpan + '</div>';
|
||||
countSpan +
|
||||
'<span class="spacer"></span>' +
|
||||
hostingBtn + '</div>';
|
||||
if (expanded) {
|
||||
var isLoading = !!state.loadingLabels[lid];
|
||||
if (isLoading) {
|
||||
|
|
@ -643,9 +700,11 @@
|
|||
(d.avatar
|
||||
? '<img class="wx-avatar" style="width:36px;height:36px" src="' + Core.esc(d.avatar) + '" alt="" onerror="this.replaceWith(Object.assign(document.createElement(\'div\'),{className:\'wx-avatar\',style:\'width:36px;height:36px\',textContent:\'?\'}))">'
|
||||
: '<div class="wx-avatar" style="width:36px;height:36px">' + Core.esc(Core.initial(d.name)) + "</div>") +
|
||||
'<div class="ci-main"><div class="ci-name">' + Core.esc(d.name) + "</div>" +
|
||||
contactLabelsHtml(w) +
|
||||
'<div class="ci-sub mono">' + Core.esc(w) + "</div></div></div>";
|
||||
'<div class="ci-main"><div class="ci-name-row"><span class="ci-name">' + Core.esc(d.name) + '</span>' +
|
||||
hostingLabelsHtml(w) + '</div>' +
|
||||
'<div class="ci-sub mono">' + Core.esc(w) + "</div></div>" +
|
||||
'<button class="ci-edit-label-btn" data-wxid="' + Core.esc(w) + '" title="编辑标签">标签</button>' +
|
||||
'</div>';
|
||||
}).join("");
|
||||
}
|
||||
}
|
||||
|
|
@ -668,7 +727,9 @@
|
|||
});
|
||||
// 标签行点击展开/收起
|
||||
$$(".label-row", box).forEach(function (row) {
|
||||
row.onclick = function () {
|
||||
row.onclick = function (e) {
|
||||
// 点击托管按钮不触发展开/收起
|
||||
if (e.target.classList.contains('label-hosting-btn')) return;
|
||||
var lid = row.dataset.labelId;
|
||||
if (state.expandedLabels[lid]) {
|
||||
// 收起
|
||||
|
|
@ -683,6 +744,30 @@
|
|||
}
|
||||
};
|
||||
});
|
||||
// 托管按钮点击
|
||||
$$(".label-hosting-btn", box).forEach(function (btn) {
|
||||
btn.onclick = function (e) {
|
||||
e.stopPropagation();
|
||||
var lid = Number(btn.dataset.labelId);
|
||||
var newHosting = Number(btn.dataset.hosting);
|
||||
var selfWxid = state.app && state.app.wxid;
|
||||
if (!selfWxid) return;
|
||||
btn.disabled = true;
|
||||
Core.api("advicer/label/hosting", { selfWxid: selfWxid, labelId: lid, hosting: newHosting })
|
||||
.then(function () {
|
||||
// 更新 state.labels 中对应标签的 hosting 状态
|
||||
state.labels.forEach(function (l) {
|
||||
if (l.labelId === lid) l.hosting = newHosting;
|
||||
});
|
||||
Core.toast(newHosting ? "已开启托管" : "已取消托管", "ok");
|
||||
renderContacts();
|
||||
})
|
||||
.catch(function (e) {
|
||||
Core.toast("操作失败:" + e.message, "err");
|
||||
btn.disabled = false;
|
||||
});
|
||||
};
|
||||
});
|
||||
$$(".contact-item", box).forEach(function (c) {
|
||||
c.onclick = function () {
|
||||
var wxid = c.dataset.wxid;
|
||||
|
|
@ -698,6 +783,136 @@
|
|||
c.onmouseleave = function () { WxN.hideContactDetail(); };
|
||||
}
|
||||
});
|
||||
// 编辑标签按钮点击
|
||||
$$(".ci-edit-label-btn", box).forEach(function (btn) {
|
||||
btn.onclick = function (e) {
|
||||
e.stopPropagation();
|
||||
openLabelEditor(btn.dataset.wxid, btn);
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/* ==================== 标签编辑弹窗 ==================== */
|
||||
var _labelEditorPopup = null;
|
||||
|
||||
function openLabelEditor(wxid, anchorEl) {
|
||||
closeLabelEditor();
|
||||
var s = state;
|
||||
var info = s.brief[wxid] || {};
|
||||
var currentLabels = (info.labelList || "").split(",").filter(Boolean);
|
||||
var allLabels = s.labels;
|
||||
if (!allLabels.length) { Core.toast("暂无标签,请先在标签 tab 同步", "warn"); return; }
|
||||
|
||||
var popup = document.createElement('div');
|
||||
popup.className = 'wx-label-editor-popup';
|
||||
popup.style.position = 'fixed';
|
||||
popup.style.zIndex = '9999';
|
||||
|
||||
// 视口边界检测
|
||||
var popupWidth = 260;
|
||||
var rect = anchorEl.getBoundingClientRect();
|
||||
var leftPos = rect.right + 6;
|
||||
if (leftPos + popupWidth > window.innerWidth - 10) {
|
||||
leftPos = rect.left - popupWidth - 6;
|
||||
}
|
||||
if (leftPos < 10) leftPos = 10;
|
||||
var topPos = rect.top;
|
||||
if (topPos + 350 > window.innerHeight) {
|
||||
topPos = Math.max(10, window.innerHeight - 360);
|
||||
}
|
||||
popup.style.left = leftPos + 'px';
|
||||
popup.style.top = topPos + 'px';
|
||||
|
||||
// 渲染标签列表
|
||||
var html = '<div class="wx-le-head">编辑标签</div>';
|
||||
html += '<div class="wx-le-body">';
|
||||
allLabels.forEach(function (l) {
|
||||
var lid = String(l.labelId);
|
||||
var checked = currentLabels.indexOf(lid) >= 0 ? ' checked' : '';
|
||||
var checkedCls = checked ? ' wx-le-checked' : '';
|
||||
html += '<label class="wx-le-item' + checkedCls + '" data-label-id="' + Core.esc(lid) + '">' +
|
||||
'<input type="checkbox" value="' + Core.esc(lid) + '"' + checked + '>' +
|
||||
'<span class="wx-le-name">' + Core.esc(l.labelName) + '</span>' +
|
||||
(l.hosting ? '<span class="wx-le-hosting-tag">托管</span>' : '') +
|
||||
'</label>';
|
||||
});
|
||||
html += '</div>';
|
||||
html += '<div class="wx-le-foot">' +
|
||||
'<button class="btn ghost sm wx-le-cancel">取消</button>' +
|
||||
'<button class="btn primary sm wx-le-save">保存</button>' +
|
||||
'</div>';
|
||||
popup.innerHTML = html;
|
||||
document.body.appendChild(popup);
|
||||
_labelEditorPopup = popup;
|
||||
|
||||
// checkbox 变化时更新样式
|
||||
$$(".wx-le-item input", popup).forEach(function (cb) {
|
||||
cb.onchange = function () {
|
||||
var label = cb.closest('.wx-le-item');
|
||||
if (cb.checked) label.classList.add('wx-le-checked');
|
||||
else label.classList.remove('wx-le-checked');
|
||||
};
|
||||
});
|
||||
|
||||
// 取消
|
||||
$(".wx-le-cancel", popup).onclick = function () { closeLabelEditor(); };
|
||||
|
||||
// 保存
|
||||
$(".wx-le-save", popup).onclick = function () {
|
||||
var newLabelIds = [];
|
||||
$$(".wx-le-item input:checked", popup).forEach(function (cb) {
|
||||
newLabelIds.push(cb.value);
|
||||
});
|
||||
var selfWxid = s.app && s.app.wxid;
|
||||
if (!selfWxid) return;
|
||||
|
||||
var proj = Core.store(Core.KEYS.project);
|
||||
if (!proj || !proj.projectId) { Core.toast("请先选择项目", "err"); return; }
|
||||
|
||||
var saveBtn = $(".wx-le-save", popup);
|
||||
saveBtn.disabled = true;
|
||||
saveBtn.textContent = "保存中…";
|
||||
|
||||
// 一次调用后端接口:内部完成 wx API 调用 + DB 更新
|
||||
Core.api("advicer/customer/update_labels", {
|
||||
projectId: proj.projectId,
|
||||
appId: s.app.appId,
|
||||
selfWxid: selfWxid,
|
||||
userName: wxid,
|
||||
labelList: newLabelIds.join(",")
|
||||
}).then(function () {
|
||||
// 更新本地 brief 中的 labelList
|
||||
if (s.brief[wxid]) {
|
||||
s.brief[wxid].labelList = newLabelIds.join(",");
|
||||
}
|
||||
Core.toast("标签已更新", "ok");
|
||||
closeLabelEditor();
|
||||
renderContacts();
|
||||
}).catch(function (e) {
|
||||
Core.toast("保存失败:" + e.message, "err");
|
||||
saveBtn.disabled = false;
|
||||
saveBtn.textContent = "保存";
|
||||
});
|
||||
};
|
||||
|
||||
// 点击外部关闭
|
||||
setTimeout(function () {
|
||||
document.addEventListener('mousedown', _labelEditorOutsideClick, true);
|
||||
}, 0);
|
||||
}
|
||||
|
||||
function _labelEditorOutsideClick(e) {
|
||||
if (_labelEditorPopup && !_labelEditorPopup.contains(e.target) && !e.target.classList.contains('ci-edit-label-btn')) {
|
||||
closeLabelEditor();
|
||||
}
|
||||
}
|
||||
|
||||
function closeLabelEditor() {
|
||||
document.removeEventListener('mousedown', _labelEditorOutsideClick, true);
|
||||
if (_labelEditorPopup && _labelEditorPopup.parentNode) {
|
||||
_labelEditorPopup.parentNode.removeChild(_labelEditorPopup);
|
||||
}
|
||||
_labelEditorPopup = null;
|
||||
}
|
||||
|
||||
/* ==================== WebSocket 实时通信 ==================== */
|
||||
|
|
@ -985,6 +1200,7 @@
|
|||
WxN.connectWs = connectWs;
|
||||
WxN.disconnectWs = disconnectWs;
|
||||
WxN.openAiConfig = openAiConfig;
|
||||
WxN.closeLabelEditor = closeLabelEditor;
|
||||
|
||||
global.Wx = { mount: mount, unmount: unmount };
|
||||
})(window);
|
||||
|
|
|
|||
|
|
@ -82,28 +82,36 @@
|
|||
|
||||
function fixMissingChatroomAvatars() {
|
||||
var s = state();
|
||||
var missingIds = [];
|
||||
s.conversations.forEach(function (c) {
|
||||
if (!c.isGroup) return;
|
||||
var info = s.brief[c.wxid] || {};
|
||||
if (info.bigHeadImgUrl || info.smallHeadImgUrl) return;
|
||||
Core.wxApi("group/getChatroomInfo", { appId: s.app.appId, chatroomId: c.wxid })
|
||||
.then(function (data) {
|
||||
missingIds.push(c.wxid);
|
||||
});
|
||||
if (!missingIds.length) return;
|
||||
|
||||
// 一次批量调用后端,后端内部循环调 wx API 并返回所有群信息
|
||||
Core.wxApi("batch_chatroom_info", { appId: s.app.appId, chatroomIds: missingIds })
|
||||
.then(function (list) {
|
||||
(list || []).forEach(function (data) {
|
||||
if (!data || !data.chatroomId) return;
|
||||
var existing = s.brief[c.wxid] || {};
|
||||
var existing = s.brief[data.chatroomId] || {};
|
||||
existing.smallHeadImgUrl = existing.smallHeadImgUrl || data.smallHeadImgUrl || "";
|
||||
existing.nickName = existing.nickName || data.nickName || "";
|
||||
if (data.remark) existing.remark = existing.remark || data.remark;
|
||||
s.brief[c.wxid] = existing;
|
||||
saveBriefCache();
|
||||
if (s.active && s.active.wxid === c.wxid) {
|
||||
s.active.avatar = existing.bigHeadImgUrl || existing.smallHeadImgUrl || "";
|
||||
s.active.name = existing.remark || existing.nickName || s.active.name;
|
||||
WxN.renderChatHead();
|
||||
}
|
||||
if (s.tab === "conversations") WxN.renderContacts();
|
||||
})
|
||||
.catch(function () {});
|
||||
});
|
||||
s.brief[data.chatroomId] = existing;
|
||||
});
|
||||
saveBriefCache();
|
||||
if (s.active && s.brief[s.active.wxid]) {
|
||||
var ai = s.brief[s.active.wxid];
|
||||
s.active.avatar = ai.bigHeadImgUrl || ai.smallHeadImgUrl || s.active.avatar;
|
||||
s.active.name = ai.remark || ai.nickName || s.active.name;
|
||||
WxN.renderChatHead();
|
||||
}
|
||||
if (s.tab === "conversations") WxN.renderContacts();
|
||||
})
|
||||
.catch(function () {});
|
||||
}
|
||||
|
||||
function openConversationChat(wxid) {
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>项目后台 · AI 销售助手</title>
|
||||
<link rel="stylesheet" href="assets/css/theme.css?v=31">
|
||||
<link rel="stylesheet" href="assets/css/theme.css?v=33">
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/flatpickr@4.6.13/dist/flatpickr.min.css">
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/flatpickr@4.6.13/dist/themes/dark.css">
|
||||
</head>
|
||||
|
|
@ -92,9 +92,9 @@
|
|||
<script src="https://cdn.jsdelivr.net/npm/flatpickr@4.6.13/dist/l10n/zh.js"></script>
|
||||
<script src="assets/js/core.js?v=21"></script>
|
||||
<script src="assets/js/admin_industry.js?v=18"></script>
|
||||
<script src="assets/js/wx.js?v=60"></script>
|
||||
<script src="assets/js/wx.js?v=68"></script>
|
||||
<script src="assets/js/wx_login.js?v=45"></script>
|
||||
<script src="assets/js/wx_chat.js?v=45"></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>
|
||||
|
|
|
|||
Loading…
Reference in New Issue