diff --git a/doc/AI_DEV_GUIDE.md b/doc/AI_DEV_GUIDE.md index 9d66253..f6a3590 100644 --- a/doc/AI_DEV_GUIDE.md +++ b/doc/AI_DEV_GUIDE.md @@ -40,10 +40,10 @@ ai_scheduler/ │ └── config_env.yaml # 环境变量覆盖 ├── internal/ │ ├── biz/ # ★ 业务逻辑层(最核心的开发区域) -│ │ ├── advice_wx.go # 微信回调处理 + 会话列表/消息查询 +│ │ ├── advice_wx.go # 微信回调处理 + 消息流水 + 会话聚合查询 │ │ ├── advice_wx_send.go # 微信消息发送 │ │ ├── advice_strategy.go # 智能策略(四级回复/主动对话) -│ │ ├── advice_client.go # 客户管理 + 等级评估 +│ │ ├── advice_client.go # 客户管理 + 等级评估 + 互动时间 │ │ ├── advice_advicer.go # 销售管理 │ │ ├── advice_project.go # 项目管理 │ │ ├── advice_skill.go # 聊天技巧 @@ -53,6 +53,7 @@ ai_scheduler/ │ │ ├── advice_proactive.go # 主动触达(生日/活动推送) │ │ ├── advice_activity.go # 产品活动 │ │ ├── advice_model_sup.go # LLM 模型配置 +│ │ ├── advice_customer_new.go # ★ 微信好友 CRUD + 差集对比(ai_advice_customer) │ │ ├── advicer_admin.go # 管理员账号 │ │ ├── advicer_industry.go # 行业模板 │ │ ├── provider_set.go # Wire Provider 集合 @@ -61,38 +62,41 @@ ai_scheduler/ │ ├── services/advice/ # ★ HTTP Handler 适配层 │ │ ├── wxhook.go # 回调 + 会话接口 Handler │ │ ├── wx_proxy.go # 微信 API 代理(分发到 wxDispatchMap) -│ │ ├── wx_ws_hub.go # WebSocket Hub(实时推送) +│ │ ├── wx_ws_hub.go # WebSocket Hub(实时推送新消息/在线状态) +│ │ ├── customer_new.go # ★ 微信好友 CRUD Handler(Add/List/Del/Diff/BatchAdd) │ │ ├── advicer.go # 销售/项目/技巧等 Handler │ │ ├── client.go # 客户 Handler │ │ ├── smart.go # 智能策略 Handler │ │ └── provider_set.go # Wire Provider 集合 │ ├── server/ # HTTP 服务器 │ │ ├── router/ -│ │ │ ├── router.go # ★ 路由总装 + registerCommon 响应包装中间件 -│ │ │ ├── advicer.go # ★ 业务路由注册(~80 个接口) -│ │ │ └── wx.go # WebSocket 路由 -│ │ ├── http.go # Fiber 服务器创建 +│ │ │ ├── router.go # ★ 路由总装 + registerCommon 响应包装中间件 + WebSocket 路由 +│ │ │ └── advicer.go # ★ 全部业务路由注册(~80 个接口) +│ │ ├── http.go # Fiber 服务器创建(NewHTTPServer 参数列表需同步) │ │ └── server.go # 服务器生命周期 │ ├── entitys/ # ★ 请求/响应 DTO -│ │ ├── advicer_data.go # ★ 核心 DTO(~450 行,最频繁修改) +│ │ ├── advicer_data.go # ★ 核心 DTO(~500 行,最频繁修改) │ │ ├── advicer.go # 销售相关 DTO │ │ ├── advicer_admin.go # 管理员 DTO │ │ └── response.go # 通用响应结构 │ ├── data/ │ │ ├── mongo_model/ # ★ MongoDB 集合模型 -│ │ │ ├── advicer_wx_msg.go # 微信消息流水 -│ │ │ ├── advicer_version.go # 销售版本(画像维度) -│ │ │ ├── advicer_client.go # 客户(MongoDB 侧) -│ │ │ ├── advicer_project.go # 项目资料 -│ │ │ ├── advicer_talk_skill.go # 聊天技巧 -│ │ │ ├── advicer_activity.go # 活动 -│ │ │ ├── advicer_proactive_log.go # 主动触达记录 -│ │ │ ├── advicer_chat_his.go # AI 对话历史 -│ │ │ ├── advicer_project_data.go # 项目资料(新版) -│ │ │ ├── common.go # 公共结构(*Item 包装) -│ │ │ └── provider_set.go # Wire Provider +│ │ │ ├── advicer_wx_msg.go # 微信消息流水 +│ │ │ ├── advicer_version.go # 销售版本(画像维度) +│ │ │ ├── advicer_client.go # 客户(MongoDB 侧) +│ │ │ ├── advicer_project.go # 项目资料 +│ │ │ ├── advicer_talk_skill.go # 聊天技巧 +│ │ │ ├── advicer_activity.go # 活动 +│ │ │ ├── advicer_proactive_log.go # 主动触达记录 +│ │ │ ├── advicer_chat_his.go # AI 对话历史 +│ │ │ ├── advicer_project_data.go # 项目资料(新版) +│ │ │ ├── common.go # 公共结构(*Item 包装) +│ │ │ └── provider_set.go # Wire Provider │ │ ├── model/ # MySQL GORM 模型(*.gen.go 禁止手动编辑) +│ │ │ └── ai_advice_customer.gen.go # ★ 微信好友表模型 │ │ ├── impl/ # MySQL GORM 数据访问实现 +│ │ │ ├── advice_customer_impl.go # ★ 微信好友 Impl(含 BatchInsert) +│ │ │ └── provider_set.go # Wire Provider 集合 │ │ ├── constants/ # 常量(Prompt 模板、模型映射) │ │ └── error/ # 错误码 │ ├── config/config.go # 配置结构体 @@ -102,7 +106,7 @@ ai_scheduler/ │ ├── wx/ # ★ 微信协议封装 │ │ ├── api.go # API 端点注册 │ │ ├── request.go # HTTP 请求封装 -│ │ ├── callback.go # 回调报文解析 +│ │ ├── callback.go # 回调报文解析(CallbackEvent 结构 + 消息过滤) │ │ ├── qs_*.go # 各类型请求/响应结构体 │ │ └── apidoc/ # 上游 API 文档 │ ├── utils_mongo/ # MongoDB 工具 @@ -133,6 +137,11 @@ ai_scheduler/ │ ├── admin_industry.js # 行业模板管理 │ └── admin_modelsup.js # 模型配置管理 ├── sql/ # 数据库初始化脚本 +│ ├── 01_ai_advice_project_template.sql +│ ├── 02_advicer_mongo_init.js +│ ├── 03_advicer_join_date.sql +│ ├── 03_advicer_wx_token.sql +│ └── 04_ai_advice_customer.sql # ★ 微信好友表 DDL ├── tmpl/ # Excel 模板文件 ├── go.mod / go.sum # Go 模块依赖 ├── Makefile # 构建命令 @@ -160,7 +169,12 @@ HTTP → Fiber中间件(CORS/JWT) → Router(Vali校验) → Service → Biz → - `cmd/server/wire.go` 声明依赖关系(`//go:build wireinject`) - `cmd/server/wire_gen.go` 由 `wire` 工具自动生成(**禁止手动编辑**) - 各层通过 `provider_set.go` 暴露 Wire Provider -- 修改依赖后需执行 `wire` 命令重新生成 +- **新增模块后必须**: + 1. 在各层 `provider_set.go` 添加新 Provider + 2. 在 `http.go` 的 `NewHTTPServer` 参数列表添加新 Service + 3. 在 `router.go` 的 `SetupRoutes` 参数列表添加新 Service + 4. 在 `advicer.go` 的 `AdvicerRouterRegist` 参数列表添加新 Service + 5. 执行 `wire` 命令重新生成 `wire_gen.go` --- @@ -171,12 +185,13 @@ HTTP → Fiber中间件(CORS/JWT) → Router(Vali校验) → Service → Biz → | 表名 | 说明 | |---|---| | `ai_advice_admin` | 管理员账号 | -| `ai_advice_advicer` | 销售基本信息 | +| `ai_advice_advicer` | 销售基本信息(含 `wx_device_id` 微信设备标识) | | `ai_advice_advicer_version` | 销售版本(画像维度 JSON) | | `ai_advice_client` | 客户基本信息 | +| `ai_advice_customer` | ★ 微信好友列表(`self_wxid` + `user_name` 联合唯一键) | | `ai_advice_industry_temp` | 行业模板 | | `ai_advice_model_sup` | LLM 模型配置 | -| `ai_advice_project` | 项目信息 | +| `ai_advice_project` | 项目信息(含项目级 `wx_token`) | | `ai_advice_session` | AI 对话会话 | | `ai_advice_talk` | 聊天技巧 | @@ -184,7 +199,7 @@ HTTP → Fiber中间件(CORS/JWT) → Router(Vali校验) → Service → Biz → | 集合名 | 说明 | 关键字段 | |---|---|---| -| `advicer_wx_msg` | ★ 微信消息流水(回调+发送) | appId, wxid, selfWxid, direction, msgType, content, createAt | +| `advicer_wx_msg` | ★ 微信消息流水(回调+发送) | appId, wxid, selfWxid, direction, msgType, content, read, createAt | | `advicer_version` | 销售画像维度 | dialectFeatures, sentencePatterns, toneTags, personalityTags | | `advicer_project` | 项目资料维度 | regionValue, competitionComparison, coreSellingPoints | | `advicer_talk_skill` | 聊天技巧维度 | needsMining, painPointResponse, valueBuilding, closingTechniques | @@ -242,9 +257,9 @@ HTTP → Fiber中间件(CORS/JWT) → Router(Vali校验) → Service → Biz → | 文件 | 职责 | 导出(WxN.xxx) | |---|---|---| -| `wx.js` | 核心:state 管理/生命周期/通讯录渲染/WebSocket/AI 设置 | loadProfile, loadContacts, renderContacts, connectWs, openAiConfig | -| `wx_login.js` | 登录:二维码/在线检测/登出/回调设置 | renderLoginCard, checkOnline, doLogout, resetToOffline | -| `wx_chat.js` | 聊天:会话列表/消息渲染/发送/联系人详情 | loadConversations, openConversationChat, renderChat, doSend, sendBubbles | +| `wx.js` | 核心:state 管理/生命周期/通讯录/WebSocket/AI 设置/好友差集同步 | loadProfile, loadContacts, renderContacts, connectWs, openAiConfig | +| `wx_login.js` | 登录:二维码/在线检测/登出/回调设置 | renderLoginCard, checkOnline, checkOnlineLoop, doLogout, resetToOffline | +| `wx_chat.js` | 聊天:会话列表/消息渲染/发送/联系人详情 | loadConversations, openConversationChat, renderChat, doSend, handleNewMessage | **架构要点**: - `wx.js` 创建 `WxN` 命名空间并通过 `Object.defineProperty` 暴露 `state` getter @@ -257,7 +272,9 @@ HTTP → Fiber中间件(CORS/JWT) → Router(Vali校验) → Service → Biz → ```javascript // HTTP 请求 Core.api(path, params) // 管理后台 API(POST JSON,自动 JWT,自动解包 data) + // 完整路径:/api/v1/admin/advice/admin/{path} Core.wxApi(path, params) // 微信代理 API(自动注入 projectId,解包 {code, data}) + // 完整路径:/api/v1/admin/advice/admin/wx/{path} // UI Core.toast(msg, type) // 提示(type: "ok"|"err"|"warn"|"info") @@ -305,6 +322,13 @@ render: renderXxx // ✗ 错误(函数提升时序问题 - 分页:`data` 内含 `{list, total, page, pageSize}` - 失败:`{code: <错误码>, message: <错误信息>, data: null}` +### DataTemp 通用 CRUD 基类模式 + +MySQL CRUD 模块遵循 `DataTemp` 模板模式: +- `impl` 继承 `dataTemp.DataTempBase[T]`,自动获得 `Add/Update/Del/GetListToStruct/GetRangeToMapStruct` 等方法 +- 查询条件使用 `*builder.Cond` **指针**参数(不是接口值) +- 分页使用 `dataTemp.ReqPageBo{Page: n, Limit: n}`(注意字段是 `Limit` 不是 `PageSize`) + ### Mongo 列表接口必须用 `*Item` 包装 ```go @@ -332,32 +356,73 @@ type XxxItem struct { - **后端代理**:`wx_proxy.go` 将前端请求分发到上游微信协议服务(100+ API 端点) - **消息回调**:上游推送客户消息 → `/api/v1/advicer/wx/callback` → `advice_wx.go` 处理入库 MongoDB - **实时通信**:WebSocket(`/api/v1/advicer/ws`)推送新消息和在线状态变化 +- **好友同步**:前端 `syncCustomerDiff()` → 后端 `customer/diff` + `customer/batch_add` → MySQL `ai_advice_customer` -### 初始化流程 +### 初始化流程(重要) ``` -页面加载/刷新 → mount() → freshState() 从 localStorage 恢复 state.app - → 有 state.app → checkOnline() - → 在线 → loadProfile() 获取 wxid → .then(loadConversations()) 加载会话 - loadContacts() 并行加载通讯录 - → 离线 → getQrCode() 显示二维码 - → 无 state.app → 从 URL hash 读取 advicerId → fetchDeviceAndCheck() - → 或直接显示二维码 +页面加载/刷新 → mount() → freshState() + → 从 URL hash 解析 wx-advicer=advicerId + → fetchDeviceAndCheck(advicerId) + → Core.api("advicer/info", {advicerId}) 获取最新 wx_device_id + → state.app = { appId: wx_device_id } ← 始终从 API 获取,不缓存 + → checkOnline() + → 在线 → checkOnlineLoop() ← 内部调用 connectWs() 建立 WebSocket + loadProfile() ← 获取 wxid/nickName 等 + .then(loadConversations()) ← 需要 wxid 作为 selfWxid + loadContacts() ← 拉取通讯录 + .then(syncCustomerDiff()) ← 好友差集同步 + → 离线 → getQrCode() 显示二维码 ``` -**关键时序**:`loadProfile()` 先于 `loadConversations()` 完成,因为会话查询需要 `selfWxid`(从 `getProfile` 获取)。 +**关键要点**: +- `state.app.appId` **始终从 advicer/info API 实时获取**,不使用 localStorage 缓存 +- `checkOnlineLoop()` 内部已调用 `connectWs()`,**不要额外调用** +- `loadProfile()` 先于 `loadConversations()` 完成,因为会话查询需要 `selfWxid` +- `loadProfile()` 返回 Promise,依赖 `state.app.wxid` 的操作必须在其 `.then()` 中执行 -**checkOnline 不清除 state.app**(只有主动退出登录才清除),确保刷新后总能重新检测。 +### QR 码登录流程 -### 消息数据流 +``` +getQrCode() → 选择地区 → login/getLoginQrCode → 返回 {uuid, appId, qrImgBase64} + → 更新销售 wx_device_id(Core.api("advicer/wx_device/update")) + → startLoginPolling() 每 10 秒轮询 login/checkLogin + → status=2(登录成功)→ state.app = {appId, wxid, nickName, ...} + → checkOnlineLoop() ← 内部调用 connectWs() + → loadProfile().then(loadConversations()) + → loadContacts() +``` + +### WebSocket 实时通信机制 + +``` +前端 connectWs() + → 创建 WebSocket 到 /api/v1/advicer/ws?token=JWT + → onopen 发送 {"action":"subscribe","appId": state.app.appId} + → 后端 WxWsHub 设置 client.appId = 订阅的 appId + +后端 Broadcast(appId, event, data) + → 遍历所有已连接 client + → 匹配 client.appId == appId → 推送 JSON 事件 + +前端 onmessage → handleWsEvent(msg) + → "new_message" → WxN.handleNewMessage(data) ← 定义在 wx_chat.js + → "online_status" → WxN.handleOnlineStatus(data) ← 定义在 wx_login.js +``` + +**appId 匹配**:前端订阅的 `appId` = `wx_device_id`(来自 advicer/info API),后端广播的 `appId` = 回调事件中的 `Appid` 字段(来自上游微信协议服务)。两者必须一致。 + +### 消息回调处理流程 ``` 上游协议服务 → POST /api/v1/advicer/wx/callback → advice_wx.go.HandleCallback() → 解析 AddMsg 事件 → 提取消息字段 + → 过滤:gh_ 开头的 wxid(公众号消息)→ 忽略 + → 过滤:msgType 为 "other" → 忽略 → 去重(AppId + NewMsgId) → 入库 MongoDB advicer_wx_msg - → WebSocket 广播 new_message 事件 + → WebSocket 广播 new_message 事件(按 appId 匹配推送) → 触发智能策略(客户文本消息异步 AI 决策) ``` @@ -365,14 +430,33 @@ type XxxItem struct { ``` 前端 loadConversations() - → POST wx/conversation/list { appId, selfWxid } - → MongoDB 聚合管道:$match(appId+selfWxid) → $sort → $group by wxid → 分页 - → 返回会话列表(每个 wxid 的最新消息预览) + → POST wx/conversation/list { selfWxid } ← 不再传 appId + → MongoDB 聚合管道:$match(selfWxid) → $sort → $group by wxid → 分页 + → 返回会话列表(每个 wxid 的最新消息预览 + 未读数) 前端 openConversationChat(wxid) - → POST wx/conversation/msgs { appId, selfWxid, wxid } - → MongoDB 查询:filter(appId+selfWxid+wxid) → sort(createAt:1) → 分页 + → POST wx/conversation/msgs { selfWxid, wxid } ← 不再传 appId + → MongoDB 查询:filter(selfWxid+wxid) → sort(createAt:1) → 分页 → 返回消息列表 → 渲染聊天气泡 + +前端 markConvAsRead(wxid) + → POST wx/conversation/read { selfWxid, wxid } ← 不再传 appId + → MongoDB 更新:filter(wxid+direction=customer+read=false) → set read=true +``` + +### 好友差集同步流程 + +``` +前端 loadContacts() 获取通讯录 {friends, chatrooms} + → syncCustomerDiff() + → POST advicer/customer/diff { selfWxid, userNames: friends } + → 后端返回 { list: [已有好友记录], diff: [缺失的 userName] } + → 用 list 填充 state.brief(联系人简要信息) + → diff 不为空时: + → 分批(每批 20 个)调用 getBriefInfo(带 3 次重试,间隔 2 秒) + → 组装入库数据 → POST advicer/customer/batch_add { list: records } + → 同时更新 state.brief 并刷新渲染 +群聊仍通过 loadBriefInfoForChatrooms() 单独拉取 brief info ``` --- @@ -392,11 +476,19 @@ type XxxItem struct { ## 常见开发场景速查 -**新增一个 API 接口**: -1. `entitys/advicer_data.go` 定义请求/响应结构体 -2. `biz/advice_xxx.go` 实现业务逻辑 -3. `services/advice/xxx.go` 实现 Handler -4. `server/router/advicer.go` 注册路由 +**新增一个 MySQL CRUD 模块**(参考 `ai_advice_customer` 模块): +1. `sql/04_xxx.sql` 建表 DDL +2. `data/model/xxx.gen.go` 定义 GORM 模型 +3. `data/impl/xxx_impl.go` 继承 DataTemp 实现数据访问 +4. `data/impl/provider_set.go` 注册 Wire Provider +5. `entitys/advicer_data.go` 定义请求/响应结构体 +6. `biz/xxx.go` 实现业务逻辑 +7. `biz/provider_set.go` 注册 Wire Provider +8. `services/advice/xxx.go` 实现 Handler +9. `services/advice/provider_set.go` 注册 Wire Provider +10. `server/http.go` + `router/router.go` + `router/advicer.go` 添加参数 + 注册路由 +11. 执行 `wire` 重新生成 `wire_gen.go` +12. `go build ./cmd/server` 编译验证 **新增一个 MongoDB 集合**: 1. `data/mongo_model/xxx.go` 定义模型 + `*Item` 包装 @@ -428,13 +520,18 @@ type XxxItem struct { 1. **`*.gen.go` 和 `wire_gen.go` 禁止手动编辑**——它们由工具自动生成 2. **Mongo 全量更新是 `$set` 语义**——务必"先读后合并再写",否则清空未提交字段 3. **微信代理响应必须设 `skip_response_wrap`**——否则被 `registerCommon` 二次包装 -4. **前端修改必须更新版本号**——`?v=N` 控制浏览器缓存 -5. **历史遗留代码**(`internal/biz/handle`、`llm_service` 等)是死代码,编译报错不影响主服务,可忽略 +4. **前端修改必须更新版本号**——`?v=N` 控制浏览器缓存,不更新则浏览器用旧缓存 +5. **历史遗留代码**(`internal/biz/handle`、`llm_service` 等)是死代码,可忽略 6. **新增无需登录的接口**须在 `router.go` 的 `AuthMiddleware` 白名单中追加 -7. **JS 文件行尾必须是 LF**——Windows 环境下编辑器可能写入 CRLF,导致浏览器 `SyntaxError` -8. **JS 字符串中的引号必须匹配**——双引号开头的字符串必须以双引号闭合,单引号同理 -9. **`loadProfile()` 返回 Promise**——依赖 `state.app.wxid` 的操作必须在其 `.then()` 回调中执行 -10. **子模块通过 `WxN.state` 访问状态**——不要在闭包中缓存 state 引用,始终通过 getter 获取最新值 +7. **`loadProfile()` 返回 Promise**——依赖 `state.app.wxid` 的操作必须在其 `.then()` 回调中执行 +8. **子模块通过 `WxN.state` 访问状态**——不要在闭包中缓存 state 引用,始终通过 getter 获取最新值 +9. **`state.app.appId` 不缓存**——每次进入工作台都从 `advicer/info` API 实时获取 `wx_device_id` +10. **`checkOnlineLoop()` 已内含 `connectWs()`**——不要在其外部再调 `connectWs()`,否则导致连接级联断开 +11. **`builder.Cond` 查询用指针**——DataTemp 方法接受 `*builder.Cond` 指针参数,传 `&cond` 而非 `cond` +12. **`dataTemp.ReqPageBo` 分页字段是 `Limit`**——不是 `PageSize` +13. **新增 Service 后需同步三个文件**——`http.go`、`router.go`、`advicer.go` 的函数签名都要加参数 +14. **会话/消息查询不传 `appId`**——`appId` 会变,用 `selfWxid` 作为筛选依据 +15. **WebSocket 订阅 appId 必须与广播 appId 一致**——前端订阅用 `wx_device_id`,后端广播用回调的 `Appid` 字段 --- @@ -450,10 +547,13 @@ go build ./internal/entitys/ ./internal/services/advice/ # 本地运行 go run cmd/server/main.go +# Wire 重新生成(修改依赖后执行) +cd cmd/server && wire + # Docker 部署 docker-compose up -d ``` --- -*最后更新:2026-09-23 · 完整文档见 `README.md`* +*最后更新:2026-09-24 · 完整文档见 `README.md`* diff --git a/internal/biz/advice_project.go b/internal/biz/advice_project.go index 056e87f..692b3ee 100644 --- a/internal/biz/advice_project.go +++ b/internal/biz/advice_project.go @@ -319,28 +319,47 @@ func (a *AdviceProjectBiz) ConfigInfo(ctx context.Context, param *entitys.Advice } // ProjectDataLoad 加载项目资料(新表) +// 文档中除 projectId / lastUpdateTime 外的字段均为栏目数据 func (a *AdviceProjectBiz) ProjectDataLoad(ctx context.Context, projectId int32) (info mongo_model.AdvicerProjectDataMongo, err error) { filter := bson.M{"projectId": projectId} res := a.mongo.Co(a.AdvicerProjectDataMongo).FindOne(ctx, filter) if res.Err() != nil && !errors.Is(res.Err(), mongo.ErrNoDocuments) { return info, res.Err() } - if err := res.Decode(&info); err != nil && !errors.Is(err, mongo.ErrNoDocuments) { + var raw bson.M + if err := res.Decode(&raw); err != nil && !errors.Is(err, mongo.ErrNoDocuments) { return info, err } + if raw == nil { + return info, nil + } + info.ProjectId = projectId + if t, ok := raw["lastUpdateTime"].(primitive.DateTime); ok { + info.LastUpdateTime = t.Time() + } + // 除已知字段外,其余均为栏目数据 + info.Data = make(map[string]interface{}) + for k, v := range raw { + if k == "_id" || k == "projectId" || k == "lastUpdateTime" { + continue + } + info.Data[k] = v + } return info, nil } // ProjectDataSave 保存项目资料(新表,upsert) +// 栏目数据直接展平到文档顶层字段 func (a *AdviceProjectBiz) ProjectDataSave(ctx context.Context, param *entitys.AdvicerProjectDataSaveReq) error { filter := bson.M{"projectId": param.ProjectId} - update := bson.M{ - "$set": bson.M{ - "projectId": param.ProjectId, - "sections": param.Sections, - "lastUpdateTime": time.Now(), - }, + setFields := bson.M{ + "projectId": param.ProjectId, + "lastUpdateTime": time.Now(), } + for k, v := range param.Data { + setFields[k] = v + } + update := bson.M{"$set": setFields} opts := options.Update().SetUpsert(true) _, err := a.mongo.Co(a.AdvicerProjectDataMongo).UpdateOne(ctx, filter, update, opts) return err diff --git a/internal/biz/advice_wx.go b/internal/biz/advice_wx.go index 75d5ae6..1e774bc 100644 --- a/internal/biz/advice_wx.go +++ b/internal/biz/advice_wx.go @@ -16,6 +16,8 @@ import ( "go.mongodb.org/mongo-driver/bson" "go.mongodb.org/mongo-driver/mongo/options" + + "github.com/gofiber/fiber/v2/log" ) // WsBroadcaster WebSocket 广播接口(由 services/advice.WxWsHub 实现) @@ -196,6 +198,7 @@ func (a *AdviceWxBiz) handleOneEvent(ctx context.Context, body []byte) error { // WebSocket 实时广播:向对应 appId 的已连接前端推送新消息事件 if a.wsBroadcaster != nil { + log.Infof("[WS] broadcasting new_message appId=%s wxid=%s", event.AppId, peerWxid) a.wsBroadcaster.Broadcast(event.AppId, "new_message", map[string]interface{}{ "appId": event.AppId, "wxid": peerWxid, @@ -352,9 +355,6 @@ func (a *AdviceWxBiz) MsgList(ctx context.Context, param *entitys.AdvicerWxMsgLi // ConversationList 查询会话列表(按最新消息时间倒序,聚合每个 wxid 的最新一条消息) func (a *AdviceWxBiz) ConversationList(ctx context.Context, param *entitys.AdvicerWxConversationListReq) ([]entitys.AdvicerWxConversationItem, error) { filter := bson.M{} - if len(param.AppId) != 0 { - filter["appId"] = param.AppId - } if len(param.SelfWxid) != 0 { filter["selfWxid"] = param.SelfWxid } @@ -437,11 +437,10 @@ func (a *AdviceWxBiz) ConversationList(ctx context.Context, param *entitys.Advic // MarkAsRead 标记某个会话中所有客户消息为已读 func (a *AdviceWxBiz) MarkAsRead(ctx context.Context, param *entitys.AdvicerWxMarkAsReadReq) (int64, error) { - if len(param.AppId) == 0 || len(param.Wxid) == 0 { + if len(param.Wxid) == 0 { return 0, nil } filter := bson.M{ - "appId": param.AppId, "wxid": param.Wxid, "direction": mongo_model.WxMsgDirectionCustomer, "read": false, @@ -460,9 +459,6 @@ func (a *AdviceWxBiz) MarkAsRead(ctx context.Context, param *entitys.AdvicerWxMa // ConversationMsgs 查询某个会话的消息记录(按时间正序,用于聊天展示) func (a *AdviceWxBiz) ConversationMsgs(ctx context.Context, param *entitys.AdvicerWxConversationMsgsReq) ([]mongo_model.AdvicerWxMsgItem, error) { filter := bson.M{} - if len(param.AppId) != 0 { - filter["appId"] = param.AppId - } if len(param.SelfWxid) != 0 { filter["selfWxid"] = param.SelfWxid } diff --git a/internal/data/mongo_model/advicer_project_data.go b/internal/data/mongo_model/advicer_project_data.go index 15b190e..57ff2c4 100644 --- a/internal/data/mongo_model/advicer_project_data.go +++ b/internal/data/mongo_model/advicer_project_data.go @@ -2,19 +2,12 @@ package mongo_model import "time" -// ProjectDataSection 项目资料动态栏目 -type ProjectDataSection struct { - Key string `json:"key" bson:"key"` - Label string `json:"label" bson:"label"` - Type string `json:"type" bson:"type"` // string / object / array / list - Content interface{} `json:"content" bson:"content"` // 根据 type 存储不同格式 -} - -// AdvicerProjectDataMongo 项目资料(新表,与项目一对一绑定,初始无栏目) +// AdvicerProjectDataMongo 项目资料(新表,与项目一对一绑定) +// 各栏目内容直接作为文档顶层字段存储(key=栏目名,value=内容) type AdvicerProjectDataMongo struct { - ProjectId int32 `json:"projectId" bson:"projectId"` - Sections []ProjectDataSection `json:"sections" bson:"sections"` - LastUpdateTime time.Time `json:"lastUpdateTime" bson:"lastUpdateTime"` + ProjectId int32 `json:"projectId" bson:"projectId"` + Data map[string]interface{} `json:"data" bson:"-"` // 不直接映射 bson,save/load 时手动展平 + LastUpdateTime time.Time `json:"lastUpdateTime" bson:"lastUpdateTime"` } func NewAdvicerProjectDataMongo() *AdvicerProjectDataMongo { diff --git a/internal/entitys/advicer_data.go b/internal/entitys/advicer_data.go index b58d872..5e85ef9 100644 --- a/internal/entitys/advicer_data.go +++ b/internal/entitys/advicer_data.go @@ -189,8 +189,8 @@ type AdvicerProjectInfoRes struct { // AdvicerProjectDataSaveReq 保存项目资料(新表) type AdvicerProjectDataSaveReq struct { - ProjectId int32 `json:"projectId"` - Sections []mongo_model.ProjectDataSection `json:"sections"` + ProjectId int32 `json:"projectId"` + Data map[string]interface{} `json:"data"` // 栏目内容(扁平 JSON,key=栏目名,直接入库) } type AdvicerClientAddReq struct { @@ -408,16 +408,14 @@ type AdvicerActivityPushReq struct { // AdvicerWxConversationListReq 会话列表查询 type AdvicerWxConversationListReq struct { - AppId string `json:"appId"` - SelfWxid string `json:"selfWxid"` // 销售本人 wxid(可选,传入时只查该销售的会话) + SelfWxid string `json:"selfWxid"` // 销售本人 wxid Page int `json:"page"` PageSize int `json:"page_size"` } // AdvicerWxConversationMsgsReq 会话消息记录查询 type AdvicerWxConversationMsgsReq struct { - AppId string `json:"appId"` - SelfWxid string `json:"selfWxid"` // 销售本人 wxid(可选,传入时只查该销售的消息) + SelfWxid string `json:"selfWxid"` // 销售本人 wxid Wxid string `json:"wxid"` Page int `json:"page"` PageSize int `json:"page_size"` @@ -425,8 +423,7 @@ type AdvicerWxConversationMsgsReq struct { // AdvicerWxMarkAsReadReq 标记会话消息已读 type AdvicerWxMarkAsReadReq struct { - AppId string `json:"appId"` - SelfWxid string `json:"selfWxid"` // 销售本人 wxid(可选) + SelfWxid string `json:"selfWxid"` // 销售本人 wxid Wxid string `json:"wxid"` } diff --git a/internal/services/advice/project.go b/internal/services/advice/project.go index eaf124d..734709d 100644 --- a/internal/services/advice/project.go +++ b/internal/services/advice/project.go @@ -100,7 +100,18 @@ func (d *ProjectService) ProjectDataLoad(c *fiber.Ctx) error { return err } info, err := d.adviceProjectBiz.ProjectDataLoad(c.UserContext(), req.ProjectId) - return pkg.HandleResponse(c, info, err) + if err != nil { + return pkg.HandleResponse(c, nil, err) + } + // 响应展平:栏目数据直接作为顶层字段 + resp := map[string]interface{}{ + "projectId": info.ProjectId, + "lastUpdateTime": info.LastUpdateTime, + } + for k, v := range info.Data { + resp[k] = v + } + return pkg.HandleResponse(c, resp, nil) } // ProjectDataSave 保存项目资料(新表) diff --git a/internal/services/advice/wx_ws_hub.go b/internal/services/advice/wx_ws_hub.go index aaf35dd..f8a7187 100644 --- a/internal/services/advice/wx_ws_hub.go +++ b/internal/services/advice/wx_ws_hub.go @@ -94,7 +94,9 @@ func (h *WxWsHub) handleClientMsg(client *wsClient, raw []byte) { client.mu.Lock() client.appId = msg.AppId client.mu.Unlock() - log.Infof("ws client subscribed appId=%s", msg.AppId) + log.Infof("[WS] client subscribed appId=%s", msg.AppId) + } else { + log.Warn("[WS] subscribe with empty appId") } case "ping": // 客户端心跳,回复 pong @@ -130,9 +132,13 @@ func (h *WxWsHub) Broadcast(appId string, event string, data interface{}) { return } + clientCount := 0 + matchedCount := 0 for client := range h.clients { client.mu.Lock() + clientCount++ if client.appId == appId { + matchedCount++ // 设置写超时,避免慢客户端阻塞广播 _ = client.conn.SetWriteDeadline(time.Now().Add(3 * time.Second)) _ = client.conn.WriteMessage(websocket.TextMessage, payload) @@ -140,6 +146,7 @@ func (h *WxWsHub) Broadcast(appId string, event string, data interface{}) { } client.mu.Unlock() } + log.Infof("[WS] Broadcast event=%s appId=%s clients=%d matched=%d", event, appId, clientCount, matchedCount) } // BroadcastOnline 广播在线状态变化 diff --git a/server.exe b/server.exe index be4a423..5369bf5 100755 Binary files a/server.exe and b/server.exe differ diff --git a/web/assets/js/project_detail.js b/web/assets/js/project_detail.js index 64f79bf..d531320 100644 --- a/web/assets/js/project_detail.js +++ b/web/assets/js/project_detail.js @@ -12,7 +12,8 @@ function renderProjectDetail() { }) .then(function (data) { state.projectData = data || {}; - state.pjiSections = (state.projectData.sections || []); + // 从展平的响应中还原栏目(排除 projectId / lastUpdateTime) + state.pjiSections = buildSectionsFromData(state.projectData); // 保存初始快照(用于重置) state.pjiInitialSections = JSON.parse(JSON.stringify(state.pjiSections)); renderProjectDetailForm(); @@ -112,7 +113,7 @@ function renderProjectDetailForm() { '' + '' + ''; - html += '
\u9879\u76ee\u8d44\u6599\u4e0e\u9879\u76ee\u4e00\u5bf9\u4e00\u7ed1\u5b9a\uff0c\u521d\u59cb\u65e0\u680f\u76ee\uff0c\u70b9\u51fb\u201c+ \u65b0\u589e\u680f\u76ee\u201d\u5f00\u59cb\u6dfb\u52a0
'; + html += '
\u6bcf\u4e2a\u680f\u76ee\u7684 key \u5373\u4e3a\u5b58\u50a8\u65f6\u7684\u5b57\u6bb5\u540d\uff0c\u6570\u636e\u4ee5\u6241\u5e73 JSON \u683c\u5f0f\u5b58\u50a8\uff0cAI \u53ef\u76f4\u63a5\u8bfb\u53d6\u3002
'; html += '
'; html += ''; // end pd-tab-projectInfo @@ -256,13 +257,14 @@ function renderProjectDetailForm() { // ===== 保存项目资料 ===== $("#pji-save").onclick = function () { var sections = collectPjiSectionsData(); + var dataObj = {}; + sections.forEach(function (s) { dataObj[s.key] = s.content; }); Core.loading(true, "\u4fdd\u5b58\u4e2d\u2026"); - Core.api("project/data/save", { projectId: state.project.projectId, sections: sections }) + Core.api("project/data/save", { projectId: state.project.projectId, data: dataObj }) .then(function () { Core.loading(false); Core.toast("\u9879\u76ee\u8d44\u6599\u5df2\u4fdd\u5b58", "ok"); state.pjiSections = sections; - // 更新初始快照 state.pjiInitialSections = JSON.parse(JSON.stringify(sections)); }) .catch(function (e) { Core.loading(false); Core.toast(e.message, "err"); }); @@ -281,7 +283,7 @@ function renderProjectDetailForm() { .then(function (data) { Core.loading(false); state.projectData = data || {}; - state.pjiSections = (state.projectData.sections || []); + state.pjiSections = buildSectionsFromData(state.projectData); state.pjiInitialSections = JSON.parse(JSON.stringify(state.pjiSections)); drawPjiSections(); Core.toast("\u5df2\u91cd\u7f6e\u4e3a\u63a5\u53e3\u6570\u636e", "ok"); @@ -345,6 +347,30 @@ function normalizeKvPairs(data) { return []; } +/** 从数据值推断栏目类型 */ +function inferSectionType(val) { + if (typeof val === "string") return "string"; + if (Array.isArray(val)) { + if (val.length && typeof val[0] === "object" && val[0] !== null) return "list"; + return "array"; + } + if (typeof val === "object" && val !== null) return "object"; + return "string"; +} + +/** 从展平的响应数据还原栏目数组 */ +function buildSectionsFromData(data) { + var skip = { projectId: 1, lastUpdateTime: 1 }; + var sections = []; + Object.keys(data).forEach(function (key) { + if (skip[key]) return; + var val = data[key]; + var type = inferSectionType(val); + sections.push({ key: key, label: key, type: type, content: parseSectionContent(val, type) }); + }); + return sections; +} + /** 解析栏目内容为编辑器所需格式 */ function parseSectionContent(content, type) { if (content == null || content === "") return type === "string" ? "" : []; @@ -591,7 +617,11 @@ function bindPjiSectionEvents() { input.select(); var finish = function () { var newLabel = input.value.trim(); - if (newLabel) sec.label = newLabel; + if (newLabel) { + sec.label = newLabel; + // 同步更新 key(存储字段名) + sec.key = newLabel.replace(/[^a-zA-Z0-9\u4e00-\u9fff]/g, "").replace(/^[0-9]+/, "") || ("sec_" + Date.now()); + } state.pjiSections = sections; drawPjiSections(); }; diff --git a/web/assets/js/wx.js b/web/assets/js/wx.js index 9e50dc5..6019667 100644 --- a/web/assets/js/wx.js +++ b/web/assets/js/wx.js @@ -481,11 +481,15 @@ /* ==================== WebSocket 实时通信 ==================== */ function connectWs() { - if (!state || !state.app || !state.app.appId) return; + if (!state || !state.app || !state.app.appId) { + console.warn("[WS] connectWs skipped: appId missing"); + return; + } + console.log("[WS] connecting, appId=" + state.app.appId); disconnectWs(); // 先断开已有连接 var token = Core.store(Core.KEYS.token); - if (!token) return; + if (!token) { console.warn("[WS] connectWs skipped: token missing"); return; } var protocol = location.protocol === "https:" ? "wss:" : "ws:"; var url = protocol + "//" + location.host + "/api/v1/advicer/ws?token=" + encodeURIComponent(token); @@ -498,6 +502,7 @@ } wsConn.onopen = function () { + console.log("[WS] connected, subscribing appId=" + state.app.appId); wsReconnectDelay = 1000; // 重置重连延迟 // 订阅当前 appId 的事件 wsConn.send(JSON.stringify({ action: "subscribe", appId: state.app.appId })); @@ -508,11 +513,13 @@ wsConn.onmessage = function (e) { try { var msg = JSON.parse(e.data); + console.log("[WS] received event:", msg.event, msg.data); handleWsEvent(msg); - } catch (err) { /* 忽略非 JSON 消息 */ } + } catch (err) { console.warn("[WS] parse error:", err); } }; wsConn.onclose = function () { + console.log("[WS] connection closed"); stopWsHeartbeat(); wsConn = null; scheduleWsReconnect(); diff --git a/web/assets/js/wx_chat.js b/web/assets/js/wx_chat.js index c8ff90a..6447606 100644 --- a/web/assets/js/wx_chat.js +++ b/web/assets/js/wx_chat.js @@ -50,7 +50,7 @@ function loadConversations() { var s = state(); if (!s.app || !s.app.appId) return; - var params = { appId: s.app.appId, page_size: 50 }; + var params = { page_size: 50 }; if (s.app.wxid) params.selfWxid = s.app.wxid; Core.api("wx/conversation/list", params) .then(function (list) { @@ -121,7 +121,7 @@ WxN.renderContacts(); var box = $("#wxMsgs", s.container); box.innerHTML = '
加载聊天记录…
'; - var msgParams = { appId: s.app.appId, wxid: wxid, page_size: 200 }; + var msgParams = { wxid: wxid, page_size: 200 }; if (s.app.wxid) msgParams.selfWxid = s.app.wxid; Core.api("wx/conversation/msgs", msgParams) .then(function (list) { @@ -159,7 +159,7 @@ function markConvAsRead(wxid) { var s = state(); if (!s.app || !s.app.appId) return; - var params = { appId: s.app.appId, wxid: wxid }; + var params = { wxid: wxid }; if (s.app.wxid) params.selfWxid = s.app.wxid; Core.api("wx/conversation/read", params) .then(function () { diff --git a/web/project.html b/web/project.html index 1de3c16..a55af2c 100644 --- a/web/project.html +++ b/web/project.html @@ -92,12 +92,12 @@ - + - + - +