This commit is contained in:
renzhiyuan 2026-09-25 02:03:12 +08:00
parent 3660ac1a4d
commit 4280442a76
28 changed files with 1081 additions and 98 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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"` // 任务

View File

@ -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=开启托管
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

Binary file not shown.

View File

@ -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`;

View File

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

View File

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

View File

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

View File

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

View File

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