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 += '