diff --git a/doc/AI_DEV_GUIDE.md b/doc/AI_DEV_GUIDE.md index 515aabd..c3db45f 100644 --- a/doc/AI_DEV_GUIDE.md +++ b/doc/AI_DEV_GUIDE.md @@ -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* diff --git a/internal/biz/advice_advicer.go b/internal/biz/advice_advicer.go index 3093a80..adc5da7 100644 --- a/internal/biz/advice_advicer.go +++ b/internal/biz/advice_advicer.go @@ -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{}{ diff --git a/internal/biz/advice_customer_new.go b/internal/biz/advice_customer_new.go index e2917ce..8e16f40 100644 --- a/internal/biz/advice_customer_new.go +++ b/internal/biz/advice_customer_new.go @@ -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) +} diff --git a/internal/biz/advice_proactive.go b/internal/biz/advice_proactive.go index 18e6f32..cd2d71c 100644 --- a/internal/biz/advice_proactive.go +++ b/internal/biz/advice_proactive.go @@ -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 diff --git a/internal/biz/advice_project.go b/internal/biz/advice_project.go index a180306..2cfc298 100644 --- a/internal/biz/advice_project.go +++ b/internal/biz/advice_project.go @@ -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) diff --git a/internal/biz/advice_wx.go b/internal/biz/advice_wx.go index c5fd258..4cbb2aa 100644 --- a/internal/biz/advice_wx.go +++ b/internal/biz/advice_wx.go @@ -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) diff --git a/internal/data/impl/advice_customer_impl.go b/internal/data/impl/advice_customer_impl.go index c85171d..dace191 100644 --- a/internal/data/impl/advice_customer_impl.go +++ b/internal/data/impl/advice_customer_impl.go @@ -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 +} diff --git a/internal/data/model/ai_advice_advicer.gen.go b/internal/data/model/ai_advice_advicer.gen.go index b7fc361..26d16ca 100644 --- a/internal/data/model/ai_advice_advicer.gen.go +++ b/internal/data/model/ai_advice_advicer.gen.go @@ -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"` diff --git a/internal/entitys/advicer_data.go b/internal/entitys/advicer_data.go index a1ad964..ec536a7 100644 --- a/internal/entitys/advicer_data.go +++ b/internal/entitys/advicer_data.go @@ -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 标签同步请求(前端从微信获取标签后提交) diff --git a/internal/server/router/advicer.go b/internal/server/router/advicer.go index 50b6b40..289d17a 100644 --- a/internal/server/router/advicer.go +++ b/internal/server/router/advicer.go @@ -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) diff --git a/internal/services/advice/advicer.go b/internal/services/advice/advicer.go index 23b61fb..8836c2f 100644 --- a/internal/services/advice/advicer.go +++ b/internal/services/advice/advicer.go @@ -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{} diff --git a/internal/services/advice/customer_new.go b/internal/services/advice/customer_new.go index a275e47..796343e 100644 --- a/internal/services/advice/customer_new.go +++ b/internal/services/advice/customer_new.go @@ -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) +} diff --git a/internal/services/advice/wx_proxy.go b/internal/services/advice/wx_proxy.go index 5c06258..5b2a27c 100644 --- a/internal/services/advice/wx_proxy.go +++ b/internal/services/advice/wx_proxy.go @@ -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) + } + } } } diff --git a/server.exe b/server.exe index 649aaf1..50d9487 100755 Binary files a/server.exe and b/server.exe differ diff --git a/sql/09_advicer_wx_id.sql b/sql/09_advicer_wx_id.sql new file mode 100644 index 0000000..d604d25 --- /dev/null +++ b/sql/09_advicer_wx_id.sql @@ -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; diff --git a/tmp_add_diag.go b/tmp_add_diag.go new file mode 100644 index 0000000..f5dba51 --- /dev/null +++ b/tmp_add_diag.go @@ -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") + } +} diff --git a/tmp_check_content.go b/tmp_check_content.go new file mode 100644 index 0000000..7ae0b70 --- /dev/null +++ b/tmp_check_content.go @@ -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) + } + } + } +} diff --git a/tmp_debug_skill.go b/tmp_debug_skill.go new file mode 100644 index 0000000..c95c3ad --- /dev/null +++ b/tmp_debug_skill.go @@ -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) + } + } +} diff --git a/tmp_fix_both.go b/tmp_fix_both.go new file mode 100644 index 0000000..9f755b1 --- /dev/null +++ b/tmp_fix_both.go @@ -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") + } +} diff --git a/tmp_fix_brace.go b/tmp_fix_brace.go new file mode 100644 index 0000000..fd7fdb3 --- /dev/null +++ b/tmp_fix_brace.go @@ -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") + } +} diff --git a/tmp_fix_chatdata.go b/tmp_fix_chatdata.go new file mode 100644 index 0000000..21bd639 --- /dev/null +++ b/tmp_fix_chatdata.go @@ -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") + } +} diff --git a/tmp_fix_order.go b/tmp_fix_order.go new file mode 100644 index 0000000..f1d33be --- /dev/null +++ b/tmp_fix_order.go @@ -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") +} diff --git a/tmp_fix_projdata.go b/tmp_fix_projdata.go new file mode 100644 index 0000000..4fd57f9 --- /dev/null +++ b/tmp_fix_projdata.go @@ -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") +} diff --git a/tmp_fix_project.go b/tmp_fix_project.go new file mode 100644 index 0000000..ae066d9 --- /dev/null +++ b/tmp_fix_project.go @@ -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") + } +} diff --git a/tmp_full_inject.go b/tmp_full_inject.go new file mode 100644 index 0000000..4fd66cd --- /dev/null +++ b/tmp_full_inject.go @@ -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") + } +} diff --git a/tmp_show_chatdata.go b/tmp_show_chatdata.go new file mode 100644 index 0000000..569c118 --- /dev/null +++ b/tmp_show_chatdata.go @@ -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) + } + } +} diff --git a/tmp_show_damage.go b/tmp_show_damage.go new file mode 100644 index 0000000..1442877 --- /dev/null +++ b/tmp_show_damage.go @@ -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) + } +} diff --git a/tmp_show_err.go b/tmp_show_err.go new file mode 100644 index 0000000..0417dad --- /dev/null +++ b/tmp_show_err.go @@ -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) + } +} diff --git a/tmp_show_prompt.go b/tmp_show_prompt.go new file mode 100644 index 0000000..f7fecff --- /dev/null +++ b/tmp_show_prompt.go @@ -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("---") + } + } +} diff --git a/tmp_show_wx.go b/tmp_show_wx.go new file mode 100644 index 0000000..c28a5c6 --- /dev/null +++ b/tmp_show_wx.go @@ -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) + } + } +} diff --git a/web/assets/js/project_data.js b/web/assets/js/project_data.js index a87d9fa..bc1deb3 100644 --- a/web/assets/js/project_data.js +++ b/web/assets/js/project_data.js @@ -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 = '
'; 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", "新增客户") + + '
\u5ba2\u6237' + + '\u5ba2\u6237\u753b\u50cf\u4e0e\u6258\u7ba1\u5ba2\u6237\u7ba1\u7406' + + '
' + + '
' + + CLIENT_SUB_TABS.map(function (t) { + return '
' + t[1] + '
'; + }).join('') + '
' + '
'; - $("#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 = '
'; 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 = '
'; + Core.api("advicer/customer/hosted", { projectId: state.project.projectId }).then(function (list) { + list = list || []; + if (!list.length) { + box.innerHTML = '
\u25c9
\u5c1a\u65e0\u6258\u7ba1\u5ba2\u6237\uff0c\u5f00\u542f\u6258\u7ba1\u6807\u7b7e\u540e\u6d88\u606f\u56de\u8c03\u65f6\u81ea\u52a8\u540c\u6b65
'; + return; + } + box.innerHTML = '
\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
' + + '
' + + '' + + 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 + ? '' + : '\u25c9'; + 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 + ? '\u5df2\u6258\u7ba1' + : '\u672a\u6ce8\u518c'; + var labels = (c.label_names || "").split(",").filter(function (s) { return s; }); + var labelHtml = labels.map(function (l) { + return '' + Core.esc(l) + ''; + }).join(" "); + return "" + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + ''; + }).join("") + "
\u5934\u50cf\u6635\u79f0\u5907\u6ce8\u6258\u7ba1\u6807\u7b7e\u6027\u522b\u5730\u533a\u5f52\u5c5e\u9500\u552e\u4f1a\u8bdd\u72b6\u6001\u66f4\u65b0\u65f6\u95f4
' + avatarCell + '' + Core.esc(displayName) + '' + + (c.user_name ? '
' + Core.esc(c.user_name) + '
' : '') + '
' + Core.esc(c.remark || "-") + '' + (labelHtml || "-") + '' + sex + '' + Core.esc(area) + '' + Core.esc(c.advicer_name || "-") + '' + sessionStatus + '' + Core.fmtTime(c.update_at) + '
"; + }).catch(function (e) { + box.innerHTML = '
\u26a0
' + Core.esc(e.message) + '
'; + }); +} var CLIENT_LEVELS = { regular: "熟客", intent: "意向客户", sleeping: "沉睡客户", non: "非客户" }; var CLIENT_LEVEL_CLS = { regular: "purple", intent: "green", sleeping: "yellow", non: "gray" }; diff --git a/web/assets/js/wx_login.js b/web/assets/js/wx_login.js index 4aa568e..b8cc142 100644 --- a/web/assets/js/wx_login.js +++ b/web/assets/js/wx_login.js @@ -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(); diff --git a/web/project.html b/web/project.html index a95656f..17910f5 100644 --- a/web/project.html +++ b/web/project.html @@ -93,13 +93,13 @@ - + - +