This commit is contained in:
renzhiyuan 2026-09-28 08:16:39 +08:00
parent 26060b7985
commit 7b96c79ab4
33 changed files with 1292 additions and 42 deletions

View File

@ -116,8 +116,9 @@ ai_scheduler/
| `advicer_wx_msg` | ★ 微信消息流水 | appId, selfWxid, wxid, direction, msgType, content, read |
| `advicer_version` | 销售画像 | dialectFeatures, sentencePatterns, toneTags, personalityTags |
| `advicer_client` | 客户画像 | clientLevel, personalInfo, purchasePurpose, coreDemands |
| `advicer_project` | 项目资料维度 | regionValue, competitionComparison, coreSellingPoints |
| `advicer_talk_skill` | 聊天技巧维度 | needsMining, painPointResponse, closingTechniques |
| `advicer_project` | 项目资料维度(结构化) | regionValue, competitionComparison, coreSellingPoints, supportingFacilities, developerBacking |
| `advicer_project_data` | ★ 项目资料(扁平栏目) | projectId + 动态栏目(key=栏目名, value=内容),与 advicer_project 是**两个独立的集合** |
| `advicer_talk_skill` | 聊天技巧维度 | needsMining, painPointResponse, closingTechniques, communicationRhythm, valueBuilding |
| `advicer_activity` | 产品活动 | name, content, startAt, endAt |
### 命名差异
@ -221,8 +222,16 @@ loadLabels()(并行)
labelList 为空 → 直接 return,不做回复
→ (4) 取 ai_advice_label 中该销售 hosting=1 的标签 ID 列表
→ (5) 判断好友 labelList 与托管标签是否有交集,无交集 → return
→ (6) 查 ai_advice_customer 取 session_id(无则自动注册;filehelper 不在 customer 表,每次都自动注册)
→ (7) hostingChatAndSend(Chat 生成 AI 回复 → 拆段 → 模拟打字延迟 → 分段发送)
→ (6) 查 ai_advice_customer 取 session_id
→ (7) 无会话 → autoRegisSession 全量加载数据并注册:
- 加载版本数据(advicer_version)
- 加载销售技巧(advicer_talk_skill,有 HostingSkillId 按 ID 取,无则按 projectId 取最新)
- 加载客户画像(advicer_client,按 projectId+advicerId+wxid 查)
- 加载项目资料(advicer_project 结构化 + advicer_project_data 扁平栏目)
- 构建 ChatData → Regis 存入 Redis(key=chatdata:{sessionId})
→ (8) hostingChatAndSend:
- 从 Redis 取 ChatData → buildChatPromptResponse 全量注入提示词
- Chat 生成 AI 回复 → 拆段 → 模拟打字延迟 → 分段发送
```
**关键设计**:
@ -230,7 +239,45 @@ loadLabels()(并行)
- projectInfo 提前到步骤 2 查询(后续 getDetailInfo 和 Chat 都需要 wxToken)
- 客户昵称优先使用微信备注名(Remark),回退到 NickName,最后才用 customer 表中的昵称
- customer 表仅用于存取 session_id,不再承担标签判断职责
- 文件传输助手(filehelper)特殊路径:销售开启 `reply_filehelper` 后,跳过标签检查,直接作为客户进行 AI 回复;由于 filehelper 是微信系统账号,不在 customer 表,因此不写回 session_id,每条消息都新建一个会话
- 文件传输助手(filehelper)特殊路径:销售开启 `reply_filehelper` 后,跳过标签检查,直接作为客户进行 AI 回复
### ChatData 与全量提示词注入
**ChatData 结构**(`entitys/advicer.go`):
```go
type ChatData struct {
ClientInfo *AdvicerClientMongoEntity // 客户画像(advicer_client)
TalkSkill *AdvicerTalkSkillMongoEntity // 销售技巧(advicer_talk_skill)
ProjectInfo *AdvicerProjectMongoEntity // 项目资料-结构化(advicer_project)
ProjectData map[string]interface{} // 项目资料-扁平栏目(advicer_project_data)
AdvicerInfo *AiAdviceAdvicerEntity // 销售信息(MySQL)
AdvicerVersion map[string]interface{} // 销售人设维度数据(advicer_version)
RuleDimension string // 项目风控红线
}
```
**提示词构建流程**(`buildChatPromptResponse`):
```
System Prompt = BasePrompt + Mission + RuleDimension + BasePrompt2
+ [项目信息] (advicer_project JSON)
+ [项目资料] (advicer_project_data JSON)
+ [销售信息] (advicer MySQL JSON)
+ [销售人设风格] (advicer_version JSON)
+ [客户信息] (advicer_client JSON)
+ [销售技巧] (advicer_talk_skill JSON)
+ [输出格式] (永远在最后)
---
System Prompt = 聊天记录(客户/我 对话格式)
---
User Message = 当前消息
```
**数据流**:
- Regis 时:`autoRegisSession` 全量加载所有数据 → 构建 ChatData → `json.Marshal` 存入 Redis(key=`chatdata:{sessionId}`,TTL 1h)
- Chat 时:从 Redis 取 ChatData → `compactJSON`(递归去空值省 token)→ 注入 system prompt
- `compactJSON`:序列化前递归去除空字符串、null、空数组、空对象,减少无效 token
**注意**:`advicer_project` 和 `advicer_project_data` 是**两个独立的 MongoDB 集合**,前者存结构化维度(区域价值/竞品对比等),后者存扁平栏目数据(key=栏目名)。两者都需要加载。
**模拟真人发送算法**:
- AI 回复拆分为多条短消息(每条 ≤80 字,按换行/句末标点拆分)
@ -251,7 +298,7 @@ loadLabels()(并行)
---
## 当前开发状态(截至 2026-09-25)
## 当前开发状态(截至 2026-09-27)
### 已完成
@ -268,7 +315,10 @@ loadLabels()(并行)
- [x] 托管自动回复:注册会话 → Chat → 拆段 → 模拟真人打字延迟 → 分段发送
- [x] 项目级 wx_token 支持:SendTextWithToken 方法,托管场景使用项目级 token
- [x] 查询优化:消除重复查询(projectInfo/advicer/session),新注册路径从 ~16 次降至 ~10 次 DB
- [x] 文件传输助手自动回复:销售级开关 `reply_filehelper`,开启后 peerWxid=filehelper 跳过标签检查直接作为客户进行 AI 回复
- [x] 文件传输助手自动回复:销售级开关 `reply_filehelper`,开启后跳过标签检查直接 AI 回复
- [x] filehelper 普通客户化改造:走标准 customer 流程复用 session_id
- [x] ChatData 全量数据注入:Regis 时加载全部数据(项目/销售/客户/技巧/人设),Chat 时从 Redis 取出全量注入提示词
- [x] 提示词 token 优化:`compactJSON` 递归去空值,减少无效 token
### 待开发(下一步)
@ -358,6 +408,10 @@ loadLabels()(并行)
12. **会话/消息查询用 `selfWxid` 筛选** — 不用 `appId`(appId 会变)
13. **WebSocket 订阅 appId 必须与广播 appId 一致** — 前端用 `wx_device_id`,后端用回调的 `Appid` 字段
14. **历史遗留代码**(`internal/biz/handle`、`llm_service`)是死代码,可忽略
15. **`advicer_project` 和 `advicer_project_data` 是两个独立的 MongoDB 集合** — 前者存结构化维度(RegionValue/CompetitionComparison 等),后者存扁平栏目数据(key=栏目名, value=内容)。加载项目数据时两者都要查
16. **HostingSkillId 为空时取最新** — 用 `VersionList(projectId)` 取第一条,不是跳过
17. **PreviousResponseID 续写不继承 system prompt** — Chat 时必须从 Redis 取 ChatData 重新注入全部数据,不能依赖 context cache 续写
18. **修改 JS/CSS 后必须更新版本号** — 否则浏览器用旧缓存,看起来像没改
---
@ -379,4 +433,4 @@ docker-compose up -d
---
*最后更新:2026-09-25*
*最后更新:2026-09-27*

View File

@ -102,6 +102,14 @@ func (a *AdviceAdvicerBiz) UpdateWxDeviceId(ctx context.Context, advicerId int32
return a.advicerImpl.UpdateByCond(&cond, updates)
}
// UpdateWxId 更新销售的登录微信 wxid
func (a *AdviceAdvicerBiz) UpdateWxId(ctx context.Context, advicerId int32, wxId string) error {
updates := map[string]interface{}{"wx_id": wxId}
cond := builder.NewCond()
cond = cond.And(builder.Eq{"advicer_id": advicerId})
return a.advicerImpl.UpdateByCond(&cond, updates)
}
// HostingUpdate 更新销售的托管配置
func (a *AdviceAdvicerBiz) HostingUpdate(ctx context.Context, req *entitys.AdvicerHostingUpdateReq) error {
updates := map[string]interface{}{

View File

@ -146,3 +146,13 @@ func (a *AdviceCustomerBiz) FindBySelfWxidAndUserName(ctx context.Context, selfW
func (a *AdviceCustomerBiz) UpdateSessionId(ctx context.Context, customerId int32, sessionId string) error {
return a.impl.UpdateSessionId(ctx, customerId, sessionId)
}
// FindBySelfWxids 查询多个 selfWxid 下的所有客户记录
func (a *AdviceCustomerBiz) FindBySelfWxids(ctx context.Context, selfWxids []string) ([]model.AiAdviceCustomer, error) {
return a.impl.FindBySelfWxids(ctx, selfWxids)
}
// UpdateFromWxDetail 根据微信好友详情更新 ai_advice_customer 记录
func (a *AdviceCustomerBiz) UpdateFromWxDetail(ctx context.Context, selfWxid, userName string, updates map[string]interface{}) error {
return a.impl.UpdateFromWxDetail(ctx, selfWxid, userName, updates)
}

View File

@ -35,6 +35,7 @@ type AdviceProactiveBiz struct {
activityBiz *AdviceActivityBiz
strategyBiz *AdviceStrategyBiz
sendBiz *AdviceWxSendBiz
projectBiz *AdviceProjectBiz
}
func NewAdviceProactiveBiz(
@ -45,6 +46,7 @@ func NewAdviceProactiveBiz(
activityBiz *AdviceActivityBiz,
strategyBiz *AdviceStrategyBiz,
sendBiz *AdviceWxSendBiz,
projectBiz *AdviceProjectBiz,
) *AdviceProactiveBiz {
return &AdviceProactiveBiz{
cfg: cfg,
@ -54,9 +56,20 @@ func NewAdviceProactiveBiz(
activityBiz: activityBiz,
strategyBiz: strategyBiz,
sendBiz: sendBiz,
projectBiz: projectBiz,
}
}
// getProjectWxToken 获取项目级 wx_token,优先项目配置,回退全局配置
func (a *AdviceProactiveBiz) getProjectWxToken(ctx context.Context, projectId int32) string {
if a.projectBiz != nil && projectId != 0 {
if info, err := a.projectBiz.BaseInfo(projectId); err == nil && info.WxToken != "" {
return info.WxToken
}
}
return a.cfg.Advicer.WxToken
}
// Scan 主动触达扫描(生日祝福 + 新活动推送),定时任务每小时调用,也可后台手动触发。
// 自动链路:受 AutoReply 总开关与活跃时段(active_hour_start/end)限制;Force=true 忽略两者。
func (a *AdviceProactiveBiz) Scan(ctx context.Context, param *entitys.AdvicerProactiveRunReq) (res *entitys.AdvicerProactiveRunRes, err error) {
@ -72,9 +85,7 @@ func (a *AdviceProactiveBiz) Scan(ctx context.Context, param *entitys.AdvicerPro
return res, nil
}
}
if !a.sendBiz.Available() {
return res, errors.New("未配置 advicer.wx_token,无法发送消息")
}
// wx_token 从项目级获取,此处不再做全局 Available 检查
filter := bson.M{"aiEnabled": true, "wxid": bson.M{"$ne": ""}}
if param.ProjectId != 0 {
@ -110,7 +121,7 @@ func (a *AdviceProactiveBiz) Scan(ctx context.Context, param *entitys.AdvicerPro
}
// 1) 生日触达
if normalizeBirthday(item.PersonalInfo.Birthday) == todayMD {
stat, detail := a.sendBirthday(ctx, &item, param)
stat, detail := a.sendBirthday(ctx, &item, param, a.getProjectWxToken(ctx, item.ProjectId))
if detail != "" {
res.Details = append(res.Details, detail)
}
@ -124,7 +135,7 @@ func (a *AdviceProactiveBiz) Scan(ctx context.Context, param *entitys.AdvicerPro
}
}
// 2) 活动触达
stat, detail := a.sendActivityForClient(ctx, &item, param)
stat, detail := a.sendActivityForClient(ctx, &item, param, a.getProjectWxToken(ctx, item.ProjectId))
if detail != "" {
res.Details = append(res.Details, detail)
}
@ -141,7 +152,7 @@ func (a *AdviceProactiveBiz) Scan(ctx context.Context, param *entitys.AdvicerPro
}
// sendBirthday 生日祝福触达(同一客户每年度一次)
func (a *AdviceProactiveBiz) sendBirthday(ctx context.Context, c *mongo_model.AdvicerClientItem, param *entitys.AdvicerProactiveRunReq) (stat int, detail string) {
func (a *AdviceProactiveBiz) sendBirthday(ctx context.Context, c *mongo_model.AdvicerClientItem, param *entitys.AdvicerProactiveRunReq, wxToken string) (stat int, detail string) {
clientId := c.Id.Hex()
refId := time.Now().Format("2006")
if a.hasLog(ctx, clientId, mongo_model.ProactiveTypeBirthday, refId) {
@ -153,12 +164,15 @@ func (a *AdviceProactiveBiz) sendBirthday(ctx context.Context, c *mongo_model.Ad
if param.DryRun {
return proactiveSkipped, clientName(c) + ":今日生日,待发送祝福(dry-run)"
}
if len(wxToken) == 0 {
return proactiveFailed, clientName(c) + ":项目未配置 wx_token,无法发送消息"
}
replies, err := a.strategyBiz.GenerateReply(ctx, c, constants.StrategyBirthdayMission, "", "")
if err != nil || len(replies) == 0 {
a.logContact(ctx, c, mongo_model.ProactiveTypeBirthday, refId, "", err)
return proactiveFailed, fmt.Sprintf("%s:生日祝福生成失败: %v", clientName(c), err)
}
sentList, e := a.sendBiz.SendMulti(ctx, c.AppId, c.Wxid, replies, mongo_model.WxMsgSourceWeb)
sentList, e := a.sendBiz.SendMultiWithToken(ctx, wxToken, c.AppId, c.Wxid, replies, mongo_model.WxMsgSourceWeb)
a.logContact(ctx, c, mongo_model.ProactiveTypeBirthday, refId, strings.Join(sentList, "\n"), e)
if e != nil {
return proactiveFailed, fmt.Sprintf("%s:生日祝福发送失败: %v", clientName(c), e)
@ -167,7 +181,7 @@ func (a *AdviceProactiveBiz) sendBirthday(ctx context.Context, c *mongo_model.Ad
}
// sendActivityForClient 活动推送触达(同一客户同一活动一次,每类型每天一次)
func (a *AdviceProactiveBiz) sendActivityForClient(ctx context.Context, c *mongo_model.AdvicerClientItem, param *entitys.AdvicerProactiveRunReq) (stat int, detail string) {
func (a *AdviceProactiveBiz) sendActivityForClient(ctx context.Context, c *mongo_model.AdvicerClientItem, param *entitys.AdvicerProactiveRunReq, wxToken string) (stat int, detail string) {
acts, _ := a.activityBiz.ActiveList(ctx, c.ProjectId, c.ClientLevel)
if len(acts) == 0 {
return proactiveNone, ""
@ -185,19 +199,22 @@ func (a *AdviceProactiveBiz) sendActivityForClient(ctx context.Context, c *mongo
if param.DryRun {
return proactiveSkipped, fmt.Sprintf("%s:待推送活动「%s」(dry-run)", clientName(c), act.Name)
}
return a.pushActivity(ctx, c, act.AdvicerActivityMongo, refId)
return a.pushActivity(ctx, c, act.AdvicerActivityMongo, refId, wxToken)
}
return proactiveNone, ""
}
// pushActivity 生成活动推送内容并发送(单客户),返回结果状态
func (a *AdviceProactiveBiz) pushActivity(ctx context.Context, c *mongo_model.AdvicerClientItem, act mongo_model.AdvicerActivityMongo, refId string) (stat int, detail string) {
func (a *AdviceProactiveBiz) pushActivity(ctx context.Context, c *mongo_model.AdvicerClientItem, act mongo_model.AdvicerActivityMongo, refId string, wxToken string) (stat int, detail string) {
if len(wxToken) == 0 {
return proactiveFailed, clientName(c) + ":项目未配置 wx_token,无法发送消息"
}
replies, err := a.strategyBiz.GenerateReply(ctx, c, constants.StrategyProactiveActivityMission, buildActivityCtx(act), "")
if err != nil || len(replies) == 0 {
a.logContact(ctx, c, mongo_model.ProactiveTypeActivity, refId, "", err)
return proactiveFailed, fmt.Sprintf("%s:活动推送生成失败: %v", clientName(c), err)
}
sentList, e := a.sendBiz.SendMulti(ctx, c.AppId, c.Wxid, replies, mongo_model.WxMsgSourceWeb)
sentList, e := a.sendBiz.SendMultiWithToken(ctx, wxToken, c.AppId, c.Wxid, replies, mongo_model.WxMsgSourceWeb)
a.logContact(ctx, c, mongo_model.ProactiveTypeActivity, refId, strings.Join(sentList, "\n"), e)
if e != nil {
return proactiveFailed, fmt.Sprintf("%s:活动推送发送失败: %v", clientName(c), e)
@ -215,9 +232,7 @@ func (a *AdviceProactiveBiz) PushActivity(ctx context.Context, param *entitys.Ad
if err != nil {
return res, fmt.Errorf("活动不存在: %w", err)
}
if !a.sendBiz.Available() {
return res, errors.New("未配置 advicer.wx_token,无法发送消息")
}
// wx_token 从项目级获取,不再做全局 Available 检查
filter := bson.M{"aiEnabled": true, "wxid": bson.M{"$ne": ""}}
if act.ProjectId != 0 {
@ -264,7 +279,8 @@ func (a *AdviceProactiveBiz) PushActivity(ctx context.Context, param *entitys.Ad
res.Details = append(res.Details, fmt.Sprintf("%s:待推送活动「%s」(dry-run)", clientName(&item), act.Name))
continue
}
stat, detail := a.pushActivity(ctx, &item, act, param.Id)
wxToken := a.getProjectWxToken(ctx, item.ProjectId)
stat, detail := a.pushActivity(ctx, &item, act, param.Id, wxToken)
if detail != "" {
res.Details = append(res.Details, detail)
}
@ -302,9 +318,7 @@ func (a *AdviceProactiveBiz) SendToClient(ctx context.Context, param *entitys.Ad
if len(client.Wxid) == 0 {
return res, errors.New("客户未绑定微信 wxid")
}
if !a.sendBiz.Available() {
return res, errors.New("未配置 advicer.wx_token,无法发送消息")
}
// wx_token 从项目级获取,不再做全局 Available 检查
item := &mongo_model.AdvicerClientItem{AdvicerClientMongo: client}
var replies []string
@ -343,7 +357,11 @@ func (a *AdviceProactiveBiz) SendToClient(ctx context.Context, param *entitys.Ad
if len(replies) == 0 {
return res, errors.New("未生成可发送内容")
}
sentList, e := a.sendBiz.SendMulti(ctx, client.AppId, client.Wxid, replies, mongo_model.WxMsgSourceWeb)
wxToken := a.getProjectWxToken(ctx, client.ProjectId)
if len(wxToken) == 0 {
return res, errors.New("项目未配置 wx_token,无法发送消息")
}
sentList, e := a.sendBiz.SendMultiWithToken(ctx, wxToken, client.AppId, client.Wxid, replies, mongo_model.WxMsgSourceWeb)
a.logContactByWxid(ctx, client, refId, strings.Join(sentList, "\n"), e)
if e != nil {
return res, e

View File

@ -272,6 +272,9 @@ func (a *AdviceProjectBiz) BaseInfo(projectId int32) (baseInfo model.AiAdvicePro
if projectId == 0 {
return
}
if a.adviceProjectImpl == nil {
return baseInfo, errors.New("adviceProjectImpl 未初始化")
}
cond := builder.NewCond()
cond = cond.And(builder.Eq{"project_id": projectId})
err = a.adviceProjectImpl.GetOneBySearchToStrut(&cond, &baseInfo)

View File

@ -13,6 +13,7 @@ import (
"errors"
"fmt"
"math/rand"
"regexp"
"strings"
"time"
@ -342,6 +343,40 @@ func (a *AdviceWxBiz) tryHostingAutoRegis(ctx context.Context, appId, selfWxid,
return
}
// 5.5 更新客户信息到 ai_advice_customer(保持客户数据与微信实时同步)
updateMap := map[string]interface{}{
"nick_name": detail.NickName,
"py_initial": detail.PyInitial,
"quan_pin": detail.QuanPin,
"sex": detail.Sex,
"remark": detail.Remark,
"remark_py_initial": detail.RemarkPyInitial,
"remark_quan_pin": detail.RemarkQuanPin,
"signature": detail.Signature,
"alias": detail.Alias,
"big_head_img_url": detail.BigHeadImgUrl,
"small_head_img_url": detail.SmallHeadImgUrl,
"label_list": friendLabelList,
"province": detail.Province,
"city": detail.City,
}
switch v := detail.PhoneNumList.(type) {
case string:
updateMap["phone_num_list"] = cleanPhoneStr(v)
case []interface{}:
var phones []string
for _, p := range v {
if s, ok := p.(string); ok {
s = cleanPhoneStr(s)
if s != "" {
phones = append(phones, s)
}
}
}
updateMap["phone_num_list"] = strings.Join(phones, ",")
}
_ = a.adviceCustomerBiz.UpdateFromWxDetail(ctx, selfWxid, peerWxid, updateMap)
// 6. 查 ai_advice_customer 取 session_id
customer, err := a.adviceCustomerBiz.FindBySelfWxidAndUserName(ctx, selfWxid, peerWxid)
if err != nil {
@ -957,6 +992,14 @@ func toMap(b []byte) map[string]interface{} {
return m
}
// cleanPhoneRe 匹配控制字符(ASCII 0x00-0x1F 和 0x7F)
var cleanPhoneRe = regexp.MustCompile(`[\x00-\x1f\x7f]`)
// cleanPhoneStr 清除手机号中的控制字符并去除首尾空白
func cleanPhoneStr(s string) string {
return strings.TrimSpace(cleanPhoneRe.ReplaceAllString(s, ""))
}
// parseTimeStr 容错解析时间字符串(支持多种常见格式)
func parseTimeStr(s string) (time.Time, error) {
s = strings.TrimSpace(s)

View File

@ -78,3 +78,27 @@ func (a *AdviceCustomerImpl) UpdateSessionId(ctx context.Context, customerId int
Where("customer_id = ?", customerId).
Update("session_id", sessionId).Error
}
// FindBySelfWxids 查询多个 selfWxid 下的所有客户记录
func (a *AdviceCustomerImpl) FindBySelfWxids(ctx context.Context, selfWxids []string) ([]model.AiAdviceCustomer, error) {
if len(selfWxids) == 0 {
return nil, nil
}
var list []model.AiAdviceCustomer
err := a.Db.WithContext(ctx).
Where("self_wxid IN ?", selfWxids).
Order("create_at desc").
Find(&list).Error
return list, err
}
// UpdateFromWxDetail 根据微信好友详情更新客户记录(回调时保持客户信息最新)
func (a *AdviceCustomerImpl) UpdateFromWxDetail(ctx context.Context, selfWxid, userName string, updates map[string]interface{}) error {
if len(updates) == 0 {
return nil
}
return a.Db.WithContext(ctx).
Model(&model.AiAdviceCustomer{}).
Where("self_wxid = ? AND user_name = ?", selfWxid, userName).
Updates(updates).Error
}

View File

@ -20,6 +20,7 @@ type AiAdviceAdvicer struct {
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
WxId string `gorm:"column:wx_id;comment:登录微信wxid" json:"wx_id"` // 登录微信wxid
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"`

View File

@ -34,6 +34,12 @@ type AdvicerWxDeviceIdUpdateReq struct {
WxDeviceId string `json:"wxDeviceId"`
}
// AdvicerWxIdUpdateReq 更新销售的登录微信 wxid
type AdvicerWxIdUpdateReq struct {
AdvicerId int32 `json:"advicerId" validate:"required" zh:"销售ID"`
WxId string `json:"wxId"`
}
// AdvicerHostingUpdateReq 更新销售的托管配置
type AdvicerHostingUpdateReq struct {
AdvicerId int32 `json:"advicerId" validate:"required" zh:"销售ID"`
@ -535,6 +541,18 @@ type AdvicerCustomerUpdateLabelsReq struct {
LabelList string `json:"labelList"`
}
// AdvicerCustomerHostedListReq 查询托管客户列表
type AdvicerCustomerHostedListReq struct {
ProjectId int32 `json:"projectId" validate:"required" zh:"项目ID"`
}
// HostedCustomerItem 托管客户条目(包含客户信息 + 标签名 + 销售名)
type HostedCustomerItem struct {
model.AiAdviceCustomer
LabelNames string `json:"label_names"` // 托管标签名称(逗号分隔)
AdvicerName string `json:"advicer_name"` // 归属销售姓名
}
// ==================== 标签同步 ====================
// AdvicerLabelSyncReq 标签同步请求(前端从微信获取标签后提交)

View File

@ -41,6 +41,7 @@ func AdvicerRouterRegist(
advicer.Post("advicer/add", adviceData.AdvicerUpdate)
advicer.Post("advicer/update", adviceData.AdvicerUpdate)
advicer.Post("advicer/wx_device/update", adviceData.AdvicerWxDeviceIdUpdate)
advicer.Post("advicer/wx_id/update", adviceData.AdvicerWxIdUpdate)
advicer.Post("advicer/hosting/update", adviceData.HostingUpdate)
advicer.Post("advicer/list", adviceData.AdvicerList)
advicer.Post("advicer/info", adviceData.AdvicerInfo)
@ -132,6 +133,7 @@ func AdvicerRouterRegist(
advicer.Post("advicer/customer/batch_add", adviceCustomer.BatchAdd)
advicer.Post("advicer/customer/batch_update", adviceCustomer.BatchUpdate)
advicer.Post("advicer/customer/update_labels", adviceCustomer.UpdateLabels)
advicer.Post("advicer/customer/hosted", adviceCustomer.HostedList)
// 微信标签(ai_advice_label)
advicer.Post("advicer/label/list", adviceLabel.List)

View File

@ -71,6 +71,19 @@ func (d *AdvicerService) AdvicerWxDeviceIdUpdate(c *fiber.Ctx) error {
return pkg.HandleResponse(c, nil, err)
}
// AdvicerWxIdUpdate 更新销售的登录微信 wxid
func (d *AdvicerService) AdvicerWxIdUpdate(c *fiber.Ctx) error {
req := &entitys.AdvicerWxIdUpdateReq{}
if err := c.BodyParser(req); err != nil {
return err
}
if req.AdvicerId == 0 {
return errorcode.ParamErr("销售ID不能为空")
}
err := d.adviceBiz.UpdateWxId(c.UserContext(), req.AdvicerId, req.WxId)
return pkg.HandleResponse(c, nil, err)
}
// HostingUpdate 更新销售的托管配置
func (d *AdvicerService) HostingUpdate(c *fiber.Ctx) error {
req := &entitys.AdvicerHostingUpdateReq{}

View File

@ -6,6 +6,7 @@ import (
"ai_scheduler/internal/pkg"
"ai_scheduler/internal/pkg/wx"
"fmt"
"strconv"
"strings"
"github.com/gofiber/fiber/v2"
@ -16,10 +17,11 @@ type CustomerService struct {
customerBiz *biz.AdviceCustomerBiz
labelBiz *biz.AdviceLabelBiz
projectBiz *biz.AdviceProjectBiz
advicerBiz *biz.AdviceAdvicerBiz
}
func NewCustomerService(customerBiz *biz.AdviceCustomerBiz, labelBiz *biz.AdviceLabelBiz, projectBiz *biz.AdviceProjectBiz) *CustomerService {
return &CustomerService{customerBiz: customerBiz, labelBiz: labelBiz, projectBiz: projectBiz}
func NewCustomerService(customerBiz *biz.AdviceCustomerBiz, labelBiz *biz.AdviceLabelBiz, projectBiz *biz.AdviceProjectBiz, advicerBiz *biz.AdviceAdvicerBiz) *CustomerService {
return &CustomerService{customerBiz: customerBiz, labelBiz: labelBiz, projectBiz: projectBiz, advicerBiz: advicerBiz}
}
// Add 新增单条好友
@ -134,3 +136,89 @@ func (s *CustomerService) UpdateLabels(c *fiber.Ctx) error {
return pkg.HandleResponse(c, nil, nil)
}
// HostedList 查询项目下所有托管客户(在托管标签分组内的客户)
func (s *CustomerService) HostedList(c *fiber.Ctx) error {
req := &entitys.AdvicerCustomerHostedListReq{}
if err := c.BodyParser(req); err != nil {
return err
}
ctx := c.UserContext()
// 1. 查项目下所有销售
advicers, err := s.advicerBiz.List(ctx, &entitys.AdvicerListReq{ProjectId: req.ProjectId})
if err != nil {
return pkg.HandleResponse(c, nil, err)
}
// 构建 selfWxid -> advicerName 映射,收集所有 selfWxid
// 关联逻辑:advicer.wx_id = ai_advice_customer.self_wxid
advicerMap := make(map[string]string)
var selfWxids []string
for _, adv := range advicers {
wxId, _ := adv["wx_id"].(string)
name, _ := adv["name"].(string)
if wxId != "" {
advicerMap[wxId] = name
selfWxids = append(selfWxids, wxId)
}
}
if len(selfWxids) == 0 {
return pkg.HandleResponse(c, []entitys.HostedCustomerItem{}, nil)
}
// 2. 获取每个销售的托管标签 ID,构建 selfWxid -> hostingLabelIds 和 labelId -> labelName 映射
hostingLabelsMap := make(map[string]map[int]bool)
labelNameMap := make(map[int]string)
for _, selfWxid := range selfWxids {
hostIds, _ := s.labelBiz.GetHostingLabelIds(ctx, selfWxid)
if len(hostIds) == 0 {
continue
}
hostingLabelsMap[selfWxid] = make(map[int]bool)
for _, id := range hostIds {
hostingLabelsMap[selfWxid][id] = true
}
labels, _ := s.labelBiz.List(ctx, &entitys.AdvicerLabelListReq{SelfWxid: selfWxid})
for _, l := range labels {
labelNameMap[int(l.LabelId)] = l.LabelName
}
}
if len(hostingLabelsMap) == 0 {
return pkg.HandleResponse(c, []entitys.HostedCustomerItem{}, nil)
}
// 3. 查询这些 selfWxid 下的所有客户,筛选出在托管标签下的
allCustomers, err := s.customerBiz.FindBySelfWxids(ctx, selfWxids)
if err != nil {
return pkg.HandleResponse(c, nil, err)
}
var result []entitys.HostedCustomerItem
for _, cust := range allCustomers {
hostLabels, ok := hostingLabelsMap[cust.SelfWxid]
if !ok {
continue
}
// 判断客户的 label_list 与托管标签是否有交集
var matchedNames []string
for _, ls := range strings.Split(cust.LabelList, ",") {
id, e := strconv.Atoi(strings.TrimSpace(ls))
if e == nil && hostLabels[id] {
if name, exists := labelNameMap[id]; exists {
matchedNames = append(matchedNames, name)
}
}
}
if len(matchedNames) == 0 {
continue
}
result = append(result, entitys.HostedCustomerItem{
AiAdviceCustomer: cust,
LabelNames: strings.Join(matchedNames, ","),
AdvicerName: advicerMap[cust.SelfWxid],
})
}
return pkg.HandleResponse(c, result, nil)
}

View File

@ -16,11 +16,12 @@ import (
// 每个接口使用强类型请求/响应结构体,符合项目本身的输入输出规范
type WxProxyService struct {
projectBiz *biz.AdviceProjectBiz
advicerBiz *biz.AdviceAdvicerBiz
}
// NewWxProxyService 创建微信代理服务
func NewWxProxyService(projectBiz *biz.AdviceProjectBiz) *WxProxyService {
return &WxProxyService{projectBiz: projectBiz}
func NewWxProxyService(projectBiz *biz.AdviceProjectBiz, advicerBiz *biz.AdviceAdvicerBiz) *WxProxyService {
return &WxProxyService{projectBiz: projectBiz, advicerBiz: advicerBiz}
}
// wxDispatcher 微信接口调度函数类型
@ -206,10 +207,19 @@ 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))
// 登录成功时自动回写销售的 wx_id
if sub == "login/checkLogin" {
if resData, ok := data.(wx.CheckLoginResData); ok && resData.Status == 2 && resData.LoginInfo != nil && resData.LoginInfo.Wxid != "" {
var appId string
if v, ok := reqBody["appId"].(string); ok {
appId = v
}
if appId != "" {
advicer, err := s.advicerBiz.FindByWxDeviceId(c.UserContext(), appId)
if err == nil && advicer.AdvicerID != 0 {
_ = s.advicerBiz.UpdateWxId(c.UserContext(), advicer.AdvicerID, resData.LoginInfo.Wxid)
}
}
}
}

Binary file not shown.

2
sql/09_advicer_wx_id.sql Normal file
View File

@ -0,0 +1,2 @@
-- 销售表新增 wx_id 字段(登录微信后回写的个人 wxid)
ALTER TABLE ai_advice_advicer ADD COLUMN wx_id VARCHAR(128) NULL DEFAULT '' COMMENT '登录微信wxid' AFTER wx_device_id;

38
tmp_add_diag.go Normal file
View File

@ -0,0 +1,38 @@
//go:build ignore
package main
import (
"fmt"
"os"
"strings"
)
func main() {
f := "internal/biz/advice_chat.go"
b, _ := os.ReadFile(f)
s := string(b)
// Add diagnostic log at the start of formatPersonaPrompt, after the nil check
old := ` if v == nil || len(v) == 0 {
return ""
}
var b strings.Builder`
new := ` if v == nil || len(v) == 0 {
return ""
}
// 诊断:打印每个维度的实际类型
for k, val := range v {
log.Infof("[人设诊断] key=%q type=%T val=%v", k, val, fmt.Sprintf("%.80v", val))
}
var b strings.Builder`
if strings.Contains(s, old) {
s = strings.Replace(s, old, new, 1)
os.WriteFile(f, []byte(s), 0644)
println("done - added persona diagnostic log")
} else {
println("ERROR: block not found")
}
}

37
tmp_check_content.go Normal file
View File

@ -0,0 +1,37 @@
//go:build ignore
package main
import (
"fmt"
"os"
"strings"
)
func main() {
// Check advice_wx.go for project info code
f1 := "internal/biz/advice_wx.go"
b1, _ := os.ReadFile(f1)
lines1 := strings.Split(string(b1), "\n")
for i, line := range lines1 {
if strings.Contains(line, "ProjectInfo") || strings.Contains(line, "项目信息") || strings.Contains(line, "projectDesc") {
visible := strings.ReplaceAll(lines1[i], "\t", "→")
fmt.Printf("wx L%d: %s\n", i+1, visible)
}
}
fmt.Println("---")
// Check advice_chat.go for persona nil check
f2 := "internal/biz/advice_chat.go"
b2, _ := os.ReadFile(f2)
lines2 := strings.Split(string(b2), "\n")
for i, line := range lines2 {
if strings.Contains(line, "v == nil") || strings.Contains(line, "var b strings.Builder") {
if i > 340 && i < 380 {
visible := strings.ReplaceAll(lines2[i], "\t", "→")
fmt.Printf("chat L%d: %s\n", i+1, visible)
}
}
}
}

32
tmp_debug_skill.go Normal file
View File

@ -0,0 +1,32 @@
//go:build ignore
package main
import (
"fmt"
"os"
"strings"
)
func main() {
f := "internal/biz/advice_wx.go"
b, _ := os.ReadFile(f)
lines := strings.Split(string(b), "\n")
// Show talkSkill loading code
for i, line := range lines {
if strings.Contains(line, "talkSkillEntity") {
for j := i - 1; j < i+6 && j < len(lines); j++ {
visible := strings.ReplaceAll(lines[j], "\t", "→")
fmt.Printf("L%d: %s\n", j+1, visible)
}
fmt.Println("---")
}
}
// Show HostingSkillId usage
for i, line := range lines {
if strings.Contains(line, "HostingSkillId") {
visible := strings.ReplaceAll(lines[i], "\t", "→")
fmt.Printf("L%d: %s\n", i+1, visible)
}
}
}

81
tmp_fix_both.go Normal file
View File

@ -0,0 +1,81 @@
//go:build ignore
package main
import (
"os"
"strings"
)
func main() {
// === Fix 1: advice_wx.go - project info from MySQL Base.Name ===
f1 := "internal/biz/advice_wx.go"
b1, _ := os.ReadFile(f1)
lines1 := strings.Split(string(b1), "\n")
var out1 []string
for i := 0; i < len(lines1); i++ {
line := lines1[i]
trimmed := strings.TrimSpace(line)
// Replace the project info block (L468-L477 area)
if trimmed == `if pi := projectInfo.ConfigInfo.ProjectInfo; pi.Name != "" || pi.Address != "" {` {
// Get the indent
indent := line[:len(line)-len(strings.TrimLeft(line, "\t"))]
// Replace with new code that uses Base.Name
out1 = append(out1, indent+`// 项目信息:优先用 MySQL 的 Base.Name(一定有),MongoDB 的 ProjectInfo 作为补充`)
out1 = append(out1, indent+`projectDesc := "\n[项目信息]"`)
out1 = append(out1, indent+`hasProjectInfo := false`)
out1 = append(out1, indent+`if projectInfo.Base.Name != "" {`)
out1 = append(out1, indent+` projectDesc += "\n项目名称:" + projectInfo.Base.Name`)
out1 = append(out1, indent+` hasProjectInfo = true`)
out1 = append(out1, indent+`}`)
out1 = append(out1, indent+`if pi := projectInfo.ConfigInfo.ProjectInfo; pi.Address != "" {`)
out1 = append(out1, indent+` projectDesc += "\n项目地址:" + pi.Address`)
out1 = append(out1, indent+` hasProjectInfo = true`)
out1 = append(out1, indent+`}`)
out1 = append(out1, indent+`if hasProjectInfo {`)
out1 = append(out1, indent+` mission = mission + projectDesc`)
// Skip the old block (find closing })
braceCount := 0
for i < len(lines1)-1 {
i++
for _, ch := range lines1[i] {
if ch == '{' {
braceCount++
}
if ch == '}' {
braceCount--
}
}
if braceCount < 0 {
break
}
}
continue
}
out1 = append(out1, line)
}
os.WriteFile(f1, []byte(strings.Join(out1, "\n")), 0644)
println("fix1: project info updated")
// === Fix 2: advice_chat.go - add persona diagnostic log ===
f2 := "internal/biz/advice_chat.go"
b2, _ := os.ReadFile(f2)
s2 := string(b2)
old2 := "\tif v == nil || len(v) == 0 {\n\t\treturn \"\"\n\t}\n\tvar b strings.Builder"
new2 := "\tif v == nil || len(v) == 0 {\n\t\treturn \"\"\n\t}\n" +
"\t// 诊断:打印每个维度的实际类型\n" +
"\tfor _k, _val := range v {\n" +
"\t\tlog.Infof(\"[人设诊断] key=%q type=%T\", _k, _val)\n" +
"\t}\n" +
"\tvar b strings.Builder"
if strings.Contains(s2, old2) {
s2 = strings.Replace(s2, old2, new2, 1)
os.WriteFile(f2, []byte(s2), 0644)
println("fix2: persona diagnostic log added")
} else {
println("fix2: WARNING - block not found")
}
}

26
tmp_fix_brace.go Normal file
View File

@ -0,0 +1,26 @@
//go:build ignore
package main
import (
"os"
"strings"
)
func main() {
f := "internal/biz/advice_wx.go"
b, _ := os.ReadFile(f)
s := string(b)
// Fix: add missing closing brace for "if hasProjectInfo"
old := "\tif hasProjectInfo {\n\t\tmission = mission + projectDesc\n\t// 将销售人格拼接到 mission"
new := "\tif hasProjectInfo {\n\t\tmission = mission + projectDesc\n\t}\n\t// 将销售人格拼接到 mission"
if strings.Contains(s, old) {
s = strings.Replace(s, old, new, 1)
os.WriteFile(f, []byte(s), 0644)
println("done - fixed missing brace")
} else {
println("ERROR: block not found")
}
}

119
tmp_fix_chatdata.go Normal file
View File

@ -0,0 +1,119 @@
//go:build ignore
package main
import (
"os"
"strings"
)
func main() {
f := "internal/biz/advice_wx.go"
b, _ := os.ReadFile(f)
s := string(b)
ok := true
// === 1. autoRegisSession 签名加 peerWxid ===
old1 := `func (a *AdviceWxBiz) autoRegisSession(ctx context.Context,
advicer dbmodel.AiAdviceAdvicer,
projectInfo *entitys.AdvicerProjectInfoRes,
customerId int32, customerNickName string,
) (string, *dbmodel.AiAdviceSession, *dbmodel.AiAdviceModelSup, error) {`
new1 := `func (a *AdviceWxBiz) autoRegisSession(ctx context.Context,
advicer dbmodel.AiAdviceAdvicer,
projectInfo *entitys.AdvicerProjectInfoRes,
customerId int32, customerNickName string,
peerWxid string,
) (string, *dbmodel.AiAdviceSession, *dbmodel.AiAdviceModelSup, error) {`
if strings.Contains(s, old1) {
s = strings.Replace(s, old1, new1, 1)
println("1. autoRegisSession signature: peerWxid added")
} else {
println("1. ERROR: signature not found")
ok = false
}
// === 2. 调用点1(普通托管路径)加 peerWxid ===
old2 := `sessionId, cachedSession, cachedModelInfo, err = a.autoRegisSession(ctx, advicer, projectInfo, customer.CustomerID, customerNickName)
if err != nil {
// autoRegisSession error`
new2 := `sessionId, cachedSession, cachedModelInfo, err = a.autoRegisSession(ctx, advicer, projectInfo, customer.CustomerID, customerNickName, peerWxid)
if err != nil {
// autoRegisSession error`
if strings.Contains(s, old2) {
s = strings.Replace(s, old2, new2, 1)
println("2. call site 1: peerWxid added")
} else {
println("2. ERROR: call site 1 not found")
ok = false
}
// === 3. 调用点2(filehelper路径)加 peerWxid ===
old3 := `sessionId, cachedSession, cachedModelInfo, err = a.autoRegisSession(ctx, advicer, projectInfo, customer.CustomerID, customerNickName)
if err != nil {
// filehelper autoRegis error`
new3 := `sessionId, cachedSession, cachedModelInfo, err = a.autoRegisSession(ctx, advicer, projectInfo, customer.CustomerID, customerNickName, peerWxid)
if err != nil {
// filehelper autoRegis error`
if strings.Contains(s, old3) {
s = strings.Replace(s, old3, new3, 1)
println("3. call site 2 (filehelper): peerWxid added")
} else {
println("3. ERROR: call site 2 not found")
ok = false
}
// === 4. ChatData 构造:加载 TalkSkill + ClientInfo + 补全 ProjectInfo.Name ===
old4 := ` // 构建 ChatData(直接使用传入的 advicer,不再重复查询)
chatData := &entitys.ChatData{
AdvicerInfo: advicer.Entity(),
AdvicerVersion: versionInfo.Data,
ProjectInfo: projectInfo.ConfigInfo.Entity(),
RuleDimension: projectInfo.Base.RuleDimension,
}`
new4 := ` // 加载销售技巧
var talkSkillEntity *mongo_model.AdvicerTalkSkillMongoEntity
if advicer.HostingSkillId != "" {
if skillInfo, e := a.adviceSkillBiz.Info(ctx, &entitys.AdvicerTalkSkillInfoReq{Id: advicer.HostingSkillId}); e == nil && skillInfo.ProjectId > 0 {
talkSkillEntity = skillInfo.Entity()
}
}
// 加载客户信息(MongoDB advicer_client)
var clientEntity *mongo_model.AdvicerClientMongoEntity
if peerWxid != "" {
var clientDoc mongo_model.AdvicerClientMongo
filter := bson.M{"projectId": projectInfo.Base.ProjectID, "advicerId": advicer.AdvicerID, "wxid": peerWxid}
if e := a.mongo.Co(mongo_model.NewAdvicerClientMongo()).FindOne(ctx, filter).Decode(&clientDoc); e == nil {
clientEntity = clientDoc.Entity()
}
}
// 构建完整 ProjectInfo(确保 MySQL 项目名不丢)
projEntity := projectInfo.ConfigInfo.Entity()
if projEntity.ProjectInfo.Name == "" && projectInfo.Base.Name != "" {
projEntity.ProjectInfo.Name = projectInfo.Base.Name
}
// 构建 ChatData(全量数据注入)
chatData := &entitys.ChatData{
AdvicerInfo: advicer.Entity(),
AdvicerVersion: versionInfo.Data,
ProjectInfo: projEntity,
RuleDimension: projectInfo.Base.RuleDimension,
TalkSkill: talkSkillEntity,
ClientInfo: clientEntity,
}`
if strings.Contains(s, old4) {
s = strings.Replace(s, old4, new4, 1)
println("4. ChatData: TalkSkill + ClientInfo + ProjectInfo.Name fixed")
} else {
println("4. ERROR: ChatData block not found")
ok = false
}
if ok {
os.WriteFile(f, []byte(s), 0644)
println("\nALL DONE - advice_wx.go updated")
} else {
println("\nFAILED - some blocks not found")
}
}

49
tmp_fix_order.go Normal file
View File

@ -0,0 +1,49 @@
//go:build ignore
package main
import (
"os"
"strings"
)
func main() {
f := "internal/biz/advice_chat.go"
b, _ := os.ReadFile(f)
s := string(b)
// Fix buildChatPromptResponse: put output format AFTER everything
old := ` Content: a.taskPrompt(session) + outputFormatHint,`
new := ` Content: a.taskPrompt(session) + "\n" + outputFormatHint,`
// Actually the real fix: taskPrompt already has mission, we just need
// to ensure outputFormatHint is separated by a newline for clarity.
// But the real structural issue is that taskPrompt dumps everything.
// Let's restructure: taskPrompt returns context only,
// buildChatPromptResponse assembles: context + mission + format
// Revert taskPrompt to NOT include mission
oldTask := ` // 注入 mission(含人格 + 项目信息),确保每次 Chat 模型都能看到
if session.Mission != "" {
b.WriteString("\n" + session.Mission)
}
return b.String()`
newTask := ` return b.String()`
if strings.Contains(s, oldTask) {
s = strings.Replace(s, oldTask, newTask, 1)
println("reverted taskPrompt")
}
// Now fix buildChatPromptResponse to include mission BEFORE output format
oldBuild := ` Content: a.taskPrompt(session) + outputFormatHint,`
newBuild := ` Content: a.taskPrompt(session) + "\n" + session.Mission + outputFormatHint,`
if strings.Contains(s, oldBuild) {
s = strings.Replace(s, oldBuild, newBuild, 1)
println("fixed buildChatPromptResponse order")
}
os.WriteFile(f, []byte(s), 0644)
println("done")
}

110
tmp_fix_projdata.go Normal file
View File

@ -0,0 +1,110 @@
//go:build ignore
package main
import (
"os"
"strings"
)
func main() {
// === 1. ChatData 加 ProjectData 字段 ===
f1 := "internal/entitys/advicer.go"
b1, _ := os.ReadFile(f1)
s1 := string(b1)
old1 := `type ChatData struct {
ClientInfo *mongo_model.AdvicerClientMongoEntity ` + "`json:\"clientInfo\"`" + `
TalkSkill *mongo_model.AdvicerTalkSkillMongoEntity ` + "`json:\"talkSkill\"`" + `
ProjectInfo *mongo_model.AdvicerProjectMongoEntity ` + "`json:\"projectInfo\"`" + `
AdvicerInfo *model.AiAdviceAdvicerEntity ` + "`json:\"advicerInfo\"`" + `
AdvicerVersion map[string]interface{} ` + "`json:\"advicerVersion\"`" + ` // 扁平维度数据
// RuleDimension 项目风控红线(来自项目模板 rule_dimension,可为空)
RuleDimension string ` + "`json:\"ruleDimension\"`" + `
}`
new1 := `type ChatData struct {
ClientInfo *mongo_model.AdvicerClientMongoEntity ` + "`json:\"clientInfo\"`" + `
TalkSkill *mongo_model.AdvicerTalkSkillMongoEntity ` + "`json:\"talkSkill\"`" + `
ProjectInfo *mongo_model.AdvicerProjectMongoEntity ` + "`json:\"projectInfo\"`" + `
ProjectData map[string]interface{} ` + "`json:\"projectData\"`" + ` // advicer_project_data 扁平栏目数据
AdvicerInfo *model.AiAdviceAdvicerEntity ` + "`json:\"advicerInfo\"`" + `
AdvicerVersion map[string]interface{} ` + "`json:\"advicerVersion\"`" + ` // 扁平维度数据
// RuleDimension 项目风控红线(来自项目模板 rule_dimension,可为空)
RuleDimension string ` + "`json:\"ruleDimension\"`" + `
}`
if strings.Contains(s1, old1) {
s1 = strings.Replace(s1, old1, new1, 1)
os.WriteFile(f1, []byte(s1), 0644)
println("1. ChatData: ProjectData field added")
} else {
println("1. ERROR: ChatData struct not found")
return
}
// === 2. autoRegisSession: 加载 ProjectData 并填入 ChatData ===
f2 := "internal/biz/advice_wx.go"
b2, _ := os.ReadFile(f2)
s2 := string(b2)
old2 := ` // 构建 ChatData(全量数据注入)
chatData := &entitys.ChatData{
AdvicerInfo: advicer.Entity(),
AdvicerVersion: versionInfo.Data,
ProjectInfo: projEntity,
RuleDimension: projectInfo.Base.RuleDimension,
TalkSkill: talkSkillEntity,
ClientInfo: clientEntity,
}`
new2 := ` // 加载项目资料(advicer_project_data 集合,扁平栏目数据)
var projectData map[string]interface{}
if projData, e := a.adviceProjectBiz.ProjectDataLoad(ctx, projectInfo.Base.ProjectID); e == nil && len(projData.Data) > 0 {
projectData = projData.Data
}
// 构建 ChatData(全量数据注入)
chatData := &entitys.ChatData{
AdvicerInfo: advicer.Entity(),
AdvicerVersion: versionInfo.Data,
ProjectInfo: projEntity,
ProjectData: projectData,
RuleDimension: projectInfo.Base.RuleDimension,
TalkSkill: talkSkillEntity,
ClientInfo: clientEntity,
}`
if strings.Contains(s2, old2) {
s2 = strings.Replace(s2, old2, new2, 1)
os.WriteFile(f2, []byte(s2), 0644)
println("2. autoRegisSession: ProjectData load added")
} else {
println("2. ERROR: ChatData construction block not found")
return
}
// === 3. buildChatPromptResponse: 注入 ProjectData ===
f3 := "internal/biz/advice_chat.go"
b3, _ := os.ReadFile(f3)
s3 := string(b3)
old3 := ` if chatData.ProjectInfo != nil {
sys.WriteString("\n[项目信息]\n")
sys.WriteString(pkg.JsonStringIgonErr(chatData.ProjectInfo))
}`
new3 := ` if chatData.ProjectInfo != nil {
sys.WriteString("\n[项目信息]\n")
sys.WriteString(pkg.JsonStringIgonErr(chatData.ProjectInfo))
}
if chatData.ProjectData != nil && len(chatData.ProjectData) > 0 {
sys.WriteString("\n[项目资料]\n")
sys.WriteString(pkg.JsonStringIgonErr(chatData.ProjectData))
}`
if strings.Contains(s3, old3) {
s3 = strings.Replace(s3, old3, new3, 1)
os.WriteFile(f3, []byte(s3), 0644)
println("3. buildChatPromptResponse: ProjectData injection added")
} else {
println("3. ERROR: ProjectInfo block in buildChatPromptResponse not found")
return
}
println("\nALL DONE")
}

51
tmp_fix_project.go Normal file
View File

@ -0,0 +1,51 @@
//go:build ignore
package main
import (
"os"
"strings"
)
func main() {
f := "internal/biz/advice_wx.go"
b, _ := os.ReadFile(f)
s := string(b)
// Fix: Use Base.Name (MySQL) instead of ConfigInfo.ProjectInfo.Name (MongoDB)
// The MySQL ai_advice_project table has the project name, MongoDB might not have projectInfo sub-doc
old := ` if pi := projectInfo.ConfigInfo.ProjectInfo; pi.Name != "" || pi.Address != "" {
projectDesc := "\n[项目信息]"
if pi.Name != "" {
projectDesc += "\n项目名称:" + pi.Name
}
if pi.Address != "" {
projectDesc += "\n项目地址:" + pi.Address
}
mission = mission + projectDesc
log.Infof("[托管] 项目信息已注入 mission advicerId=%d projectName=%s", advicer.AdvicerID, pi.Name)
}`
new := ` // 项目信息:优先用 MySQL 的 Base.Name(一定有),MongoDB 的 ProjectInfo 作为补充
projectDesc := "\n[项目信息]"
hasProjectInfo := false
if projectInfo.Base.Name != "" {
projectDesc += "\n项目名称:" + projectInfo.Base.Name
hasProjectInfo = true
}
if pi := projectInfo.ConfigInfo.ProjectInfo; pi.Address != "" {
projectDesc += "\n项目地址:" + pi.Address
hasProjectInfo = true
}
if hasProjectInfo {
mission = mission + projectDesc
}`
if strings.Contains(s, old) {
s = strings.Replace(s, old, new, 1)
os.WriteFile(f, []byte(s), 0644)
println("done - fixed project info to use Base.Name")
} else {
println("ERROR: project info block not found")
}
}

196
tmp_full_inject.go Normal file
View File

@ -0,0 +1,196 @@
//go:build ignore
package main
import (
"os"
"strings"
)
func main() {
f := "internal/biz/advice_chat.go"
b, _ := os.ReadFile(f)
s := string(b)
ok := true
// === 1. Regis: 存 ChatData 到 Redis ===
old1 := ` err = a.rdb.Rdb.SetEx(ctx, sessionId, promptJson, 3600*time.Second).Err()
return sessionId, err
}`
new1 := ` err = a.rdb.Rdb.SetEx(ctx, sessionId, promptJson, 3600*time.Second).Err()
// 存储 ChatData 供后续 Chat 全量注入提示词
if chatDataJson, e := json.Marshal(chatData); e == nil {
_ = a.rdb.Rdb.SetEx(ctx, "chatdata:"+sessionId, string(chatDataJson), 3600*time.Second).Err()
}
return sessionId, err
}`
if strings.Contains(s, old1) {
s = strings.Replace(s, old1, new1, 1)
println("1. Regis: ChatData store added")
} else {
println("1. ERROR: Regis block not found")
ok = false
}
// === 2. Chat: 从 Redis 取 ChatData,传给 buildChatPromptResponse ===
old2 := ` prompt, err := a.buildChatPromptResponse(ctx, chat, &session, chatHis)
if err != nil {
return assistant, err
}
log.Infof("[提示词] sessionId=%s prompt=%s", session.SessionID, pkg.JsonStringIgonErr(prompt))
resContent, err := a.callLlmResponse(ctx, prompt, modelInfo.Key, modelInfo.URL, modelInfo.ChatModel, session.ContextCache)`
new2 := ` var chatData entitys.ChatData
if cdStr, e := a.rdb.Rdb.Get(ctx, "chatdata:"+session.SessionID).Result(); e == nil {
_ = json.Unmarshal([]byte(cdStr), &chatData)
}
prompt, err := a.buildChatPromptResponse(ctx, chat, &session, chatHis, &chatData)
if err != nil {
return assistant, err
}
log.Infof("[提示词] sessionId=%s prompt=%s", session.SessionID, pkg.JsonStringIgonErr(prompt))
resContent, err := a.callLlmResponse(ctx, prompt, modelInfo.Key, modelInfo.URL, modelInfo.ChatModel, session.ContextCache)`
if strings.Contains(s, old2) {
s = strings.Replace(s, old2, new2, 1)
println("2. Chat: ChatData load + pass added")
} else {
println("2. ERROR: Chat block not found")
ok = false
}
// === 3. ChatWithSession: 同样从 Redis 取 ChatData ===
old3 := ` prompt, err := a.buildChatPromptResponse(ctx, chat, session, chatHis)
if err != nil {
return assistant, err
}
log.Infof("[提示词] sessionId=%s prompt=%s", session.SessionID, pkg.JsonStringIgonErr(prompt))
resContent, err := a.callLlmResponse(ctx, prompt, modelInfo.Key, modelInfo.URL, modelInfo.ChatModel, session.ContextCache)`
new3 := ` var chatData entitys.ChatData
if cdStr, e := a.rdb.Rdb.Get(ctx, "chatdata:"+session.SessionID).Result(); e == nil {
_ = json.Unmarshal([]byte(cdStr), &chatData)
}
prompt, err := a.buildChatPromptResponse(ctx, chat, session, chatHis, &chatData)
if err != nil {
return assistant, err
}
log.Infof("[提示词] sessionId=%s prompt=%s", session.SessionID, pkg.JsonStringIgonErr(prompt))
resContent, err := a.callLlmResponse(ctx, prompt, modelInfo.Key, modelInfo.URL, modelInfo.ChatModel, session.ContextCache)`
if strings.Contains(s, old3) {
s = strings.Replace(s, old3, new3, 1)
println("3. ChatWithSession: ChatData load + pass added")
} else {
println("3. ERROR: ChatWithSession block not found")
ok = false
}
// === 4. buildChatPromptResponse: 全量数据注入 ===
old4 := `func (a *AdviceChatBiz) buildChatPromptResponse(ctx context.Context, chat *entitys.AdvicerChatReq, session *dbmodel.AiAdviceSession, chatList []mongo_model.AdvicerChatHisMongoEntity) ([]openai.ResponseInputMessage, error) {
// 输出格式指令:模型使用 response_format=json_object 时,prompt 必须包含 "json" 关键词,
// 同时明确告诉模型返回 JSON 结构,便于下游 json.Unmarshal 解析为 Assistant。
const outputFormatHint = "\n[输出格式]请用 json 格式返回你的回复,结构为 {\"result\":\"回复内容\",\"mission_status\":\"in_progress|completed|fail\",\"mission_complete_desc\":\"本轮进展小结\"}。"
message := []openai.ResponseInputMessage{
{
Role: openai.ChatMessageRoleSystem,
Content: a.taskPrompt(session) + outputFormatHint,
},
{
Role: openai.ChatMessageRoleSystem,
Content: formatChatHis(chatList),
},
{
Role: openai.ChatMessageRoleUser,
Content: chat.Content,
},
}
return message, nil
}`
new4 := `func (a *AdviceChatBiz) buildChatPromptResponse(ctx context.Context, chat *entitys.AdvicerChatReq, session *dbmodel.AiAdviceSession, chatList []mongo_model.AdvicerChatHisMongoEntity, chatData *entitys.ChatData) ([]openai.ResponseInputMessage, error) {
const outputFormatHint = "\n[输出格式]请用 json 格式返回你的回复,结构为 {\"result\":\"回复内容\",\"mission_status\":\"in_progress|completed|fail\",\"mission_complete_desc\":\"本轮进展小结\"}。"
var sys strings.Builder
// 基础规则 + 任务 + 风控红线
sys.WriteString(constants.BasePrompt)
if session.Mission != "" {
sys.WriteString(session.Mission)
sys.WriteString("\n")
}
if chatData != nil && chatData.RuleDimension != "" {
sys.WriteString(constants.RulePromptPrefix)
sys.WriteString(chatData.RuleDimension)
sys.WriteString("\n")
}
sys.WriteString(constants.BasePrompt2)
// 全量结构化数据注入
if chatData != nil {
if chatData.ProjectInfo != nil {
sys.WriteString("\n[项目信息]\n")
sys.WriteString(pkg.JsonStringIgonErr(chatData.ProjectInfo))
}
if chatData.AdvicerInfo != nil {
sys.WriteString("\n[销售信息]\n")
sys.WriteString(pkg.JsonStringIgonErr(chatData.AdvicerInfo))
}
if chatData.AdvicerVersion != nil && len(chatData.AdvicerVersion) > 0 {
sys.WriteString("\n[销售人设风格]\n")
sys.WriteString(pkg.JsonStringIgonErr(chatData.AdvicerVersion))
}
if chatData.ClientInfo != nil {
sys.WriteString("\n[客户信息]\n")
sys.WriteString(pkg.JsonStringIgonErr(chatData.ClientInfo))
}
if chatData.TalkSkill != nil {
sys.WriteString("\n[销售技巧]\n")
sys.WriteString(pkg.JsonStringIgonErr(chatData.TalkSkill))
}
}
sys.WriteString(outputFormatHint)
message := []openai.ResponseInputMessage{
{
Role: openai.ChatMessageRoleSystem,
Content: sys.String(),
},
{
Role: openai.ChatMessageRoleSystem,
Content: formatChatHis(chatList),
},
{
Role: openai.ChatMessageRoleUser,
Content: chat.Content,
},
}
return message, nil
}`
if strings.Contains(s, old4) {
s = strings.Replace(s, old4, new4, 1)
println("4. buildChatPromptResponse: full data injection done")
} else {
println("4. ERROR: buildChatPromptResponse block not found")
ok = false
}
// === 5. 删掉 taskPrompt 中的 session.Mission 注入(避免重复,已在 buildChatPromptResponse 中处理)===
old5 := ` // 注入 mission(含人格 + 项目信息),确保每次 Chat 模型都能看到
if session.Mission != "" {
b.WriteString("\n" + session.Mission)
}
return b.String()
}
func (a *AdviceChatBiz) buildBasePromptResponse`
new5 := ` return b.String()
}
func (a *AdviceChatBiz) buildBasePromptResponse`
if strings.Contains(s, old5) {
s = strings.Replace(s, old5, new5, 1)
println("5. taskPrompt: removed redundant mission injection")
} else {
println("5. SKIP: taskPrompt already clean")
}
if ok {
os.WriteFile(f, []byte(s), 0644)
println("\nALL DONE - advice_chat.go updated")
} else {
println("\nFAILED - some blocks not found")
}
}

44
tmp_show_chatdata.go Normal file
View File

@ -0,0 +1,44 @@
//go:build ignore
package main
import (
"fmt"
"os"
"strings"
)
func main() {
f := "internal/biz/advice_wx.go"
b, _ := os.ReadFile(f)
lines := strings.Split(string(b), "\n")
// Show chatData construction in autoRegisSession
for i, line := range lines {
if strings.Contains(line, "chatData := &entitys.ChatData") {
for j := i - 2; j < i+15 && j < len(lines); j++ {
visible := strings.ReplaceAll(lines[j], "\t", "→")
fmt.Printf("L%d: %s\n", j+1, visible)
}
break
}
}
fmt.Println("---")
// Show function signature of autoRegisSession
for i, line := range lines {
if strings.Contains(line, "func (a *AdviceWxBiz) autoRegisSession") {
for j := i; j < i+8 && j < len(lines); j++ {
visible := strings.ReplaceAll(lines[j], "\t", "→")
fmt.Printf("L%d: %s\n", j+1, visible)
}
break
}
}
fmt.Println("---")
// Show what variables are available (customer, projectInfo, advicer)
for i, line := range lines {
if strings.Contains(line, "customerNickName") && strings.Contains(line, "=") && i < 450 {
visible := strings.ReplaceAll(lines[i], "\t", "→")
fmt.Printf("L%d: %s\n", i+1, visible)
}
}
}

21
tmp_show_damage.go Normal file
View File

@ -0,0 +1,21 @@
//go:build ignore
package main
import (
"fmt"
"os"
"strings"
)
func main() {
f := "internal/biz/advice_wx.go"
b, _ := os.ReadFile(f)
lines := strings.Split(string(b), "\n")
fmt.Printf("Total lines: %d\n", len(lines))
// Show the area around the project info fix (L460-520)
for i := 455; i < 520 && i < len(lines); i++ {
visible := strings.ReplaceAll(lines[i], "\t", "→")
fmt.Printf("L%d: %s\n", i+1, visible)
}
}

19
tmp_show_err.go Normal file
View File

@ -0,0 +1,19 @@
//go:build ignore
package main
import (
"fmt"
"os"
"strings"
)
func main() {
f := "internal/biz/advice_wx.go"
b, _ := os.ReadFile(f)
lines := strings.Split(string(b), "\n")
for i := 510; i < 530 && i < len(lines); i++ {
visible := strings.ReplaceAll(lines[i], "\t", "→")
fmt.Printf("L%d: %s\n", i+1, visible)
}
}

26
tmp_show_prompt.go Normal file
View File

@ -0,0 +1,26 @@
//go:build ignore
package main
import (
"fmt"
"os"
"strings"
)
func main() {
f := "internal/biz/advice_chat.go"
b, _ := os.ReadFile(f)
lines := strings.Split(string(b), "\n")
// Show taskPrompt and buildChatPromptResponse
for i, line := range lines {
if strings.Contains(line, "func (a *AdviceChatBiz) taskPrompt") ||
strings.Contains(line, "func (a *AdviceChatBiz) buildChatPromptResponse") {
for j := i; j < i+25 && j < len(lines); j++ {
visible := strings.ReplaceAll(lines[j], "\t", "→")
fmt.Printf("L%d: %s\n", j+1, visible)
}
fmt.Println("---")
}
}
}

24
tmp_show_wx.go Normal file
View File

@ -0,0 +1,24 @@
//go:build ignore
package main
import (
"fmt"
"os"
"strings"
)
func main() {
f := "internal/biz/advice_wx.go"
b, _ := os.ReadFile(f)
lines := strings.Split(string(b), "\n")
// Show hostingChatAndSend function signature and call
for i, line := range lines {
if strings.Contains(line, "func (a *AdviceWxBiz) hostingChatAndSend") ||
strings.Contains(line, "a.hostingChatAndSend(") ||
strings.Contains(line, "ChatWithSession") {
visible := strings.ReplaceAll(lines[i], "\t", "→")
fmt.Printf("wx L%d: %s\n", i+1, visible)
}
}
}

View File

@ -38,13 +38,16 @@ function renderData() {
}
function renderDataBody() {
console.log("[renderDataBody] tab=" + state.data.tab);
var body = $("#data-body");
if (!body) { console.error("[renderDataBody] #data-body not found!"); return; }
body.innerHTML = '<div class="empty"><span class="spin"></span></div>';
var tab = state.data.tab;
if (tab === "advicers") return renderTabAdvicers(body);
if (tab === "versions") return renderTabVersions(body);
if (tab === "skills") return renderTabSkills(body);
if (tab === "clients") return renderTabClients(body);
console.warn("[renderDataBody] unknown tab: " + tab);
}
function dataHead(title, sub, addBtnId, addBtnText) {
@ -969,16 +972,42 @@ function skillAiGenerateDialog(parentModal, skDims, skItems) {
}
/* ---------- Tab 4:客户 ---------- */
var CLIENT_SUB_TABS = [["profiles", "\u5ba2\u6237\u753b\u50cf"], ["hosted", "\u6258\u7ba1\u5ba2\u6237"]];
function renderTabClients(body) {
console.log("[renderTabClients] called, subTab=" + (state.data.clientSubTab || "profiles"));
var subTab = state.data.clientSubTab || "profiles";
body.innerHTML =
dataHead("客户", "客户画像(身份/目的/需求/顾虑/决策)", "cli-add", "新增客户") +
'<div class="row mb-2"><span class="fs-13 c-main" style="font-weight:700">\u5ba2\u6237</span>' +
'<span class="fs-12 c-dim">\u5ba2\u6237\u753b\u50cf\u4e0e\u6258\u7ba1\u5ba2\u6237\u7ba1\u7406</span><span class="spacer"></span>' +
'<button class="btn primary sm" id="cli-add">+ \u65b0\u589e\u5ba2\u6237</button></div>' +
'<div class="tabs" id="cli-sub-tabs" style="margin-bottom:10px">' +
CLIENT_SUB_TABS.map(function (t) {
return '<div class="tab' + (subTab === t[0] ? " active" : "") + '" data-sub="' + t[0] + '">' + t[1] + '</div>';
}).join('') + '</div>' +
'<div id="cli-list"></div>';
$("#cli-add").onclick = function () { clientForm(null); };
loadClients();
// 使用 body 内部查询(body 可能脱离 document,document.querySelector 找不到)
var addBtn = body.querySelector("#cli-add");
var tabsEl = body.querySelector("#cli-sub-tabs");
if (tabsEl) {
Array.prototype.slice.call(tabsEl.children).forEach(function (t) {
t.onclick = function () {
state.data.clientSubTab = t.dataset.sub;
Array.prototype.slice.call(tabsEl.children).forEach(function (x) { x.classList.toggle("active", x === t); });
if (addBtn) addBtn.style.display = (t.dataset.sub === "profiles") ? "" : "none";
if (t.dataset.sub === "profiles") { loadClients(listBox); }
else { loadHostedCustomers(listBox); }
};
});
}
if (addBtn) addBtn.onclick = function () { clientForm(null); };
var listBox = body.querySelector("#cli-list");
if (subTab === "profiles") { loadClients(listBox); }
else { if (addBtn) addBtn.style.display = "none"; loadHostedCustomers(listBox); }
}
function loadClients() {
var box = $("#cli-list");
function loadClients(box) {
if (!box) box = document.querySelector("#cli-list");
if (!box) return;
box.innerHTML = '<div class="empty"><span class="spin"></span></div>';
Core.api("client/list", { projectId: state.project.projectId }).then(function (list) {
@ -1046,6 +1075,52 @@ function loadClients() {
}
/* ---------- 客户等级常量 / 微信绑定 / 等级评估 ---------- */
/* ---------- 托管客户列表 ---------- */
function loadHostedCustomers(box) {
if (!box) box = document.querySelector("#cli-list");
if (!box) return;
box.innerHTML = '<div class="empty"><span class="spin"></span></div>';
Core.api("advicer/customer/hosted", { projectId: state.project.projectId }).then(function (list) {
list = list || [];
if (!list.length) {
box.innerHTML = '<div class="empty"><div class="big">\u25c9</div>\u5c1a\u65e0\u6258\u7ba1\u5ba2\u6237\uff0c\u5f00\u542f\u6258\u7ba1\u6807\u7b7e\u540e\u6d88\u606f\u56de\u8c03\u65f6\u81ea\u52a8\u540c\u6b65</div>';
return;
}
box.innerHTML = '<div class="fs-12 c-dim" style="margin-bottom:8px">\u5171 ' + list.length + ' \u4f4d\u6258\u7ba1\u5ba2\u6237\uff08\u5904\u4e8e\u6258\u7ba1\u6807\u7b7e\u5206\u7ec4\u5185\u7684\u5fae\u4fe1\u597d\u53cb\uff0c\u6bcf\u6b21\u6d88\u606f\u56de\u8c03\u65f6\u81ea\u52a8\u540c\u6b65\u6700\u65b0\u4fe1\u606f\uff09</div>' +
'<div class="table-wrap"><table class="table"><thead><tr>' +
'<th>\u5934\u50cf</th><th>\u6635\u79f0</th><th>\u5907\u6ce8</th><th>\u6258\u7ba1\u6807\u7b7e</th><th>\u6027\u522b</th><th>\u5730\u533a</th><th>\u5f52\u5c5e\u9500\u552e</th><th>\u4f1a\u8bdd\u72b6\u6001</th><th>\u66f4\u65b0\u65f6\u95f4</th></tr></thead><tbody>' +
list.map(function (c) {
var displayName = c.remark || c.nick_name || c.user_name || "-";
var avatar = c.big_head_img_url || c.small_head_img_url || "";
var avatarCell = avatar
? '<img src="' + Core.esc(avatar) + '" style="width:32px;height:32px;border-radius:50%;object-fit:cover" onerror="this.style.display=\'none\'">'
: '<span style="display:inline-flex;width:32px;height:32px;border-radius:50%;background:var(--surface-2);align-items:center;justify-content:center;font-size:14px;color:var(--dim)">\u25c9</span>';
var sex = c.sex === 1 ? "\u7537" : c.sex === 2 ? "\u5973" : "-";
var area = [c.province, c.city].filter(function (s) { return s; }).join(" ") || "-";
var sessionStatus = c.session_id
? '<span class="badge green">\u5df2\u6258\u7ba1</span>'
: '<span class="badge gray">\u672a\u6ce8\u518c</span>';
var labels = (c.label_names || "").split(",").filter(function (s) { return s; });
var labelHtml = labels.map(function (l) {
return '<span class="badge blue fs-11">' + Core.esc(l) + '</span>';
}).join(" ");
return "<tr>" +
'<td>' + avatarCell + '</td>' +
'<td><span class="name">' + Core.esc(displayName) + '</span>' +
(c.user_name ? '<div class="mono fs-11 c-dim">' + Core.esc(c.user_name) + '</div>' : '') + '</td>' +
'<td class="fs-12">' + Core.esc(c.remark || "-") + '</td>' +
'<td>' + (labelHtml || "-") + '</td>' +
'<td>' + sex + '</td>' +
'<td class="fs-12">' + Core.esc(area) + '</td>' +
'<td class="fs-12">' + Core.esc(c.advicer_name || "-") + '</td>' +
'<td>' + sessionStatus + '</td>' +
'<td class="fs-12">' + Core.fmtTime(c.update_at) + '</td></tr>';
}).join("") + "</tbody></table></div>";
}).catch(function (e) {
box.innerHTML = '<div class="empty"><div class="big">\u26a0</div>' + Core.esc(e.message) + '</div>';
});
}
var CLIENT_LEVELS = { regular: "熟客", intent: "意向客户", sleeping: "沉睡客户", non: "非客户" };
var CLIENT_LEVEL_CLS = { regular: "purple", intent: "green", sleeping: "yellow", non: "gray" };

View File

@ -204,6 +204,14 @@
Core.toast("微信登录成功:" + (app.nickName || app.wxid), "ok");
renderLoginCard();
checkOnlineLoop();
// 登录成功后回写 wx_id 到销售记录
var hashMatch2 = (location.hash || '').match(/wx-advicer=(\d+)/);
var advicerId2 = hashMatch2 ? Number(hashMatch2[1]) : 0;
if (advicerId2 && app.wxid) {
Core.api("advicer/wx_id/update", { advicerId: advicerId2, wxId: app.wxid })
.then(function () { console.log("[login] wx_id updated: " + app.wxid); })
.catch(function (e) { console.warn("[login] update wx_id failed:", e); });
}
WxN.loadProfile().then(function () { WxN.loadConversations(); });
WxN.loadContacts();
WxN.loadLabels();

View File

@ -93,13 +93,13 @@
<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=68"></script>
<script src="assets/js/wx_login.js?v=45"></script>
<script src="assets/js/wx_login.js?v=46"></script>
<script src="assets/js/wx_chat.js?v=46"></script>
<script src="assets/js/project.js?v=21"></script>
<script src="assets/js/project_list.js?v=18"></script>
<script src="assets/js/project_detail.js?v=27"></script>
<script src="assets/js/project_analysis.js?v=21"></script>
<script src="assets/js/project_data.js?v=37"></script>
<script src="assets/js/project_data.js?v=40"></script>
<script src="assets/js/project_activity.js?v=18"></script>
<script src="assets/js/project_drill.js?v=19"></script>
<script src="assets/js/project_smart.js?v=18"></script>