This commit is contained in:
parent
07584f38e1
commit
6ac9455d4c
|
|
@ -40,10 +40,10 @@ ai_scheduler/
|
||||||
│ └── config_env.yaml # 环境变量覆盖
|
│ └── config_env.yaml # 环境变量覆盖
|
||||||
├── internal/
|
├── internal/
|
||||||
│ ├── biz/ # ★ 业务逻辑层(最核心的开发区域)
|
│ ├── biz/ # ★ 业务逻辑层(最核心的开发区域)
|
||||||
│ │ ├── advice_wx.go # 微信回调处理 + 会话列表/消息查询
|
│ │ ├── advice_wx.go # 微信回调处理 + 消息流水 + 会话聚合查询
|
||||||
│ │ ├── advice_wx_send.go # 微信消息发送
|
│ │ ├── advice_wx_send.go # 微信消息发送
|
||||||
│ │ ├── advice_strategy.go # 智能策略(四级回复/主动对话)
|
│ │ ├── advice_strategy.go # 智能策略(四级回复/主动对话)
|
||||||
│ │ ├── advice_client.go # 客户管理 + 等级评估
|
│ │ ├── advice_client.go # 客户管理 + 等级评估 + 互动时间
|
||||||
│ │ ├── advice_advicer.go # 销售管理
|
│ │ ├── advice_advicer.go # 销售管理
|
||||||
│ │ ├── advice_project.go # 项目管理
|
│ │ ├── advice_project.go # 项目管理
|
||||||
│ │ ├── advice_skill.go # 聊天技巧
|
│ │ ├── advice_skill.go # 聊天技巧
|
||||||
|
|
@ -53,6 +53,7 @@ ai_scheduler/
|
||||||
│ │ ├── advice_proactive.go # 主动触达(生日/活动推送)
|
│ │ ├── advice_proactive.go # 主动触达(生日/活动推送)
|
||||||
│ │ ├── advice_activity.go # 产品活动
|
│ │ ├── advice_activity.go # 产品活动
|
||||||
│ │ ├── advice_model_sup.go # LLM 模型配置
|
│ │ ├── advice_model_sup.go # LLM 模型配置
|
||||||
|
│ │ ├── advice_customer_new.go # ★ 微信好友 CRUD + 差集对比(ai_advice_customer)
|
||||||
│ │ ├── advicer_admin.go # 管理员账号
|
│ │ ├── advicer_admin.go # 管理员账号
|
||||||
│ │ ├── advicer_industry.go # 行业模板
|
│ │ ├── advicer_industry.go # 行业模板
|
||||||
│ │ ├── provider_set.go # Wire Provider 集合
|
│ │ ├── provider_set.go # Wire Provider 集合
|
||||||
|
|
@ -61,38 +62,41 @@ ai_scheduler/
|
||||||
│ ├── services/advice/ # ★ HTTP Handler 适配层
|
│ ├── services/advice/ # ★ HTTP Handler 适配层
|
||||||
│ │ ├── wxhook.go # 回调 + 会话接口 Handler
|
│ │ ├── wxhook.go # 回调 + 会话接口 Handler
|
||||||
│ │ ├── wx_proxy.go # 微信 API 代理(分发到 wxDispatchMap)
|
│ │ ├── 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
|
│ │ ├── advicer.go # 销售/项目/技巧等 Handler
|
||||||
│ │ ├── client.go # 客户 Handler
|
│ │ ├── client.go # 客户 Handler
|
||||||
│ │ ├── smart.go # 智能策略 Handler
|
│ │ ├── smart.go # 智能策略 Handler
|
||||||
│ │ └── provider_set.go # Wire Provider 集合
|
│ │ └── provider_set.go # Wire Provider 集合
|
||||||
│ ├── server/ # HTTP 服务器
|
│ ├── server/ # HTTP 服务器
|
||||||
│ │ ├── router/
|
│ │ ├── router/
|
||||||
│ │ │ ├── router.go # ★ 路由总装 + registerCommon 响应包装中间件
|
│ │ │ ├── router.go # ★ 路由总装 + registerCommon 响应包装中间件 + WebSocket 路由
|
||||||
│ │ │ ├── advicer.go # ★ 业务路由注册(~80 个接口)
|
│ │ │ └── advicer.go # ★ 全部业务路由注册(~80 个接口)
|
||||||
│ │ │ └── wx.go # WebSocket 路由
|
│ │ ├── http.go # Fiber 服务器创建(NewHTTPServer 参数列表需同步)
|
||||||
│ │ ├── http.go # Fiber 服务器创建
|
|
||||||
│ │ └── server.go # 服务器生命周期
|
│ │ └── server.go # 服务器生命周期
|
||||||
│ ├── entitys/ # ★ 请求/响应 DTO
|
│ ├── entitys/ # ★ 请求/响应 DTO
|
||||||
│ │ ├── advicer_data.go # ★ 核心 DTO(~450 行,最频繁修改)
|
│ │ ├── advicer_data.go # ★ 核心 DTO(~500 行,最频繁修改)
|
||||||
│ │ ├── advicer.go # 销售相关 DTO
|
│ │ ├── advicer.go # 销售相关 DTO
|
||||||
│ │ ├── advicer_admin.go # 管理员 DTO
|
│ │ ├── advicer_admin.go # 管理员 DTO
|
||||||
│ │ └── response.go # 通用响应结构
|
│ │ └── response.go # 通用响应结构
|
||||||
│ ├── data/
|
│ ├── data/
|
||||||
│ │ ├── mongo_model/ # ★ MongoDB 集合模型
|
│ │ ├── mongo_model/ # ★ MongoDB 集合模型
|
||||||
│ │ │ ├── advicer_wx_msg.go # 微信消息流水
|
│ │ │ ├── advicer_wx_msg.go # 微信消息流水
|
||||||
│ │ │ ├── advicer_version.go # 销售版本(画像维度)
|
│ │ │ ├── advicer_version.go # 销售版本(画像维度)
|
||||||
│ │ │ ├── advicer_client.go # 客户(MongoDB 侧)
|
│ │ │ ├── advicer_client.go # 客户(MongoDB 侧)
|
||||||
│ │ │ ├── advicer_project.go # 项目资料
|
│ │ │ ├── advicer_project.go # 项目资料
|
||||||
│ │ │ ├── advicer_talk_skill.go # 聊天技巧
|
│ │ │ ├── advicer_talk_skill.go # 聊天技巧
|
||||||
│ │ │ ├── advicer_activity.go # 活动
|
│ │ │ ├── advicer_activity.go # 活动
|
||||||
│ │ │ ├── advicer_proactive_log.go # 主动触达记录
|
│ │ │ ├── advicer_proactive_log.go # 主动触达记录
|
||||||
│ │ │ ├── advicer_chat_his.go # AI 对话历史
|
│ │ │ ├── advicer_chat_his.go # AI 对话历史
|
||||||
│ │ │ ├── advicer_project_data.go # 项目资料(新版)
|
│ │ │ ├── advicer_project_data.go # 项目资料(新版)
|
||||||
│ │ │ ├── common.go # 公共结构(*Item 包装)
|
│ │ │ ├── common.go # 公共结构(*Item 包装)
|
||||||
│ │ │ └── provider_set.go # Wire Provider
|
│ │ │ └── provider_set.go # Wire Provider
|
||||||
│ │ ├── model/ # MySQL GORM 模型(*.gen.go 禁止手动编辑)
|
│ │ ├── model/ # MySQL GORM 模型(*.gen.go 禁止手动编辑)
|
||||||
|
│ │ │ └── ai_advice_customer.gen.go # ★ 微信好友表模型
|
||||||
│ │ ├── impl/ # MySQL GORM 数据访问实现
|
│ │ ├── impl/ # MySQL GORM 数据访问实现
|
||||||
|
│ │ │ ├── advice_customer_impl.go # ★ 微信好友 Impl(含 BatchInsert)
|
||||||
|
│ │ │ └── provider_set.go # Wire Provider 集合
|
||||||
│ │ ├── constants/ # 常量(Prompt 模板、模型映射)
|
│ │ ├── constants/ # 常量(Prompt 模板、模型映射)
|
||||||
│ │ └── error/ # 错误码
|
│ │ └── error/ # 错误码
|
||||||
│ ├── config/config.go # 配置结构体
|
│ ├── config/config.go # 配置结构体
|
||||||
|
|
@ -102,7 +106,7 @@ ai_scheduler/
|
||||||
│ ├── wx/ # ★ 微信协议封装
|
│ ├── wx/ # ★ 微信协议封装
|
||||||
│ │ ├── api.go # API 端点注册
|
│ │ ├── api.go # API 端点注册
|
||||||
│ │ ├── request.go # HTTP 请求封装
|
│ │ ├── request.go # HTTP 请求封装
|
||||||
│ │ ├── callback.go # 回调报文解析
|
│ │ ├── callback.go # 回调报文解析(CallbackEvent 结构 + 消息过滤)
|
||||||
│ │ ├── qs_*.go # 各类型请求/响应结构体
|
│ │ ├── qs_*.go # 各类型请求/响应结构体
|
||||||
│ │ └── apidoc/ # 上游 API 文档
|
│ │ └── apidoc/ # 上游 API 文档
|
||||||
│ ├── utils_mongo/ # MongoDB 工具
|
│ ├── utils_mongo/ # MongoDB 工具
|
||||||
|
|
@ -133,6 +137,11 @@ ai_scheduler/
|
||||||
│ ├── admin_industry.js # 行业模板管理
|
│ ├── admin_industry.js # 行业模板管理
|
||||||
│ └── admin_modelsup.js # 模型配置管理
|
│ └── admin_modelsup.js # 模型配置管理
|
||||||
├── sql/ # 数据库初始化脚本
|
├── 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 模板文件
|
├── tmpl/ # Excel 模板文件
|
||||||
├── go.mod / go.sum # Go 模块依赖
|
├── go.mod / go.sum # Go 模块依赖
|
||||||
├── Makefile # 构建命令
|
├── Makefile # 构建命令
|
||||||
|
|
@ -160,7 +169,12 @@ HTTP → Fiber中间件(CORS/JWT) → Router(Vali校验) → Service → Biz →
|
||||||
- `cmd/server/wire.go` 声明依赖关系(`//go:build wireinject`)
|
- `cmd/server/wire.go` 声明依赖关系(`//go:build wireinject`)
|
||||||
- `cmd/server/wire_gen.go` 由 `wire` 工具自动生成(**禁止手动编辑**)
|
- `cmd/server/wire_gen.go` 由 `wire` 工具自动生成(**禁止手动编辑**)
|
||||||
- 各层通过 `provider_set.go` 暴露 Wire Provider
|
- 各层通过 `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_admin` | 管理员账号 |
|
||||||
| `ai_advice_advicer` | 销售基本信息 |
|
| `ai_advice_advicer` | 销售基本信息(含 `wx_device_id` 微信设备标识) |
|
||||||
| `ai_advice_advicer_version` | 销售版本(画像维度 JSON) |
|
| `ai_advice_advicer_version` | 销售版本(画像维度 JSON) |
|
||||||
| `ai_advice_client` | 客户基本信息 |
|
| `ai_advice_client` | 客户基本信息 |
|
||||||
|
| `ai_advice_customer` | ★ 微信好友列表(`self_wxid` + `user_name` 联合唯一键) |
|
||||||
| `ai_advice_industry_temp` | 行业模板 |
|
| `ai_advice_industry_temp` | 行业模板 |
|
||||||
| `ai_advice_model_sup` | LLM 模型配置 |
|
| `ai_advice_model_sup` | LLM 模型配置 |
|
||||||
| `ai_advice_project` | 项目信息 |
|
| `ai_advice_project` | 项目信息(含项目级 `wx_token`) |
|
||||||
| `ai_advice_session` | AI 对话会话 |
|
| `ai_advice_session` | AI 对话会话 |
|
||||||
| `ai_advice_talk` | 聊天技巧 |
|
| `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_version` | 销售画像维度 | dialectFeatures, sentencePatterns, toneTags, personalityTags |
|
||||||
| `advicer_project` | 项目资料维度 | regionValue, competitionComparison, coreSellingPoints |
|
| `advicer_project` | 项目资料维度 | regionValue, competitionComparison, coreSellingPoints |
|
||||||
| `advicer_talk_skill` | 聊天技巧维度 | needsMining, painPointResponse, valueBuilding, closingTechniques |
|
| `advicer_talk_skill` | 聊天技巧维度 | needsMining, painPointResponse, valueBuilding, closingTechniques |
|
||||||
|
|
@ -242,9 +257,9 @@ HTTP → Fiber中间件(CORS/JWT) → Router(Vali校验) → Service → Biz →
|
||||||
|
|
||||||
| 文件 | 职责 | 导出(WxN.xxx) |
|
| 文件 | 职责 | 导出(WxN.xxx) |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `wx.js` | 核心:state 管理/生命周期/通讯录渲染/WebSocket/AI 设置 | loadProfile, loadContacts, renderContacts, connectWs, openAiConfig |
|
| `wx.js` | 核心:state 管理/生命周期/通讯录/WebSocket/AI 设置/好友差集同步 | loadProfile, loadContacts, renderContacts, connectWs, openAiConfig |
|
||||||
| `wx_login.js` | 登录:二维码/在线检测/登出/回调设置 | renderLoginCard, checkOnline, doLogout, resetToOffline |
|
| `wx_login.js` | 登录:二维码/在线检测/登出/回调设置 | renderLoginCard, checkOnline, checkOnlineLoop, doLogout, resetToOffline |
|
||||||
| `wx_chat.js` | 聊天:会话列表/消息渲染/发送/联系人详情 | loadConversations, openConversationChat, renderChat, doSend, sendBubbles |
|
| `wx_chat.js` | 聊天:会话列表/消息渲染/发送/联系人详情 | loadConversations, openConversationChat, renderChat, doSend, handleNewMessage |
|
||||||
|
|
||||||
**架构要点**:
|
**架构要点**:
|
||||||
- `wx.js` 创建 `WxN` 命名空间并通过 `Object.defineProperty` 暴露 `state` getter
|
- `wx.js` 创建 `WxN` 命名空间并通过 `Object.defineProperty` 暴露 `state` getter
|
||||||
|
|
@ -257,7 +272,9 @@ HTTP → Fiber中间件(CORS/JWT) → Router(Vali校验) → Service → Biz →
|
||||||
```javascript
|
```javascript
|
||||||
// HTTP 请求
|
// HTTP 请求
|
||||||
Core.api(path, params) // 管理后台 API(POST JSON,自动 JWT,自动解包 data)
|
Core.api(path, params) // 管理后台 API(POST JSON,自动 JWT,自动解包 data)
|
||||||
|
// 完整路径:/api/v1/admin/advice/admin/{path}
|
||||||
Core.wxApi(path, params) // 微信代理 API(自动注入 projectId,解包 {code, data})
|
Core.wxApi(path, params) // 微信代理 API(自动注入 projectId,解包 {code, data})
|
||||||
|
// 完整路径:/api/v1/admin/advice/admin/wx/{path}
|
||||||
|
|
||||||
// UI
|
// UI
|
||||||
Core.toast(msg, type) // 提示(type: "ok"|"err"|"warn"|"info")
|
Core.toast(msg, type) // 提示(type: "ok"|"err"|"warn"|"info")
|
||||||
|
|
@ -305,6 +322,13 @@ render: renderXxx // ✗ 错误(函数提升时序问题
|
||||||
- 分页:`data` 内含 `{list, total, page, pageSize}`
|
- 分页:`data` 内含 `{list, total, page, pageSize}`
|
||||||
- 失败:`{code: <错误码>, message: <错误信息>, data: null}`
|
- 失败:`{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` 包装
|
### Mongo 列表接口必须用 `*Item` 包装
|
||||||
|
|
||||||
```go
|
```go
|
||||||
|
|
@ -332,32 +356,73 @@ type XxxItem struct {
|
||||||
- **后端代理**:`wx_proxy.go` 将前端请求分发到上游微信协议服务(100+ API 端点)
|
- **后端代理**:`wx_proxy.go` 将前端请求分发到上游微信协议服务(100+ API 端点)
|
||||||
- **消息回调**:上游推送客户消息 → `/api/v1/advicer/wx/callback` → `advice_wx.go` 处理入库 MongoDB
|
- **消息回调**:上游推送客户消息 → `/api/v1/advicer/wx/callback` → `advice_wx.go` 处理入库 MongoDB
|
||||||
- **实时通信**:WebSocket(`/api/v1/advicer/ws`)推送新消息和在线状态变化
|
- **实时通信**:WebSocket(`/api/v1/advicer/ws`)推送新消息和在线状态变化
|
||||||
|
- **好友同步**:前端 `syncCustomerDiff()` → 后端 `customer/diff` + `customer/batch_add` → MySQL `ai_advice_customer`
|
||||||
|
|
||||||
### 初始化流程
|
### 初始化流程(重要)
|
||||||
|
|
||||||
```
|
```
|
||||||
页面加载/刷新 → mount() → freshState() 从 localStorage 恢复 state.app
|
页面加载/刷新 → mount() → freshState()
|
||||||
→ 有 state.app → checkOnline()
|
→ 从 URL hash 解析 wx-advicer=advicerId
|
||||||
→ 在线 → loadProfile() 获取 wxid → .then(loadConversations()) 加载会话
|
→ fetchDeviceAndCheck(advicerId)
|
||||||
loadContacts() 并行加载通讯录
|
→ Core.api("advicer/info", {advicerId}) 获取最新 wx_device_id
|
||||||
→ 离线 → getQrCode() 显示二维码
|
→ state.app = { appId: wx_device_id } ← 始终从 API 获取,不缓存
|
||||||
→ 无 state.app → 从 URL hash 读取 advicerId → fetchDeviceAndCheck()
|
→ 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
|
上游协议服务 → POST /api/v1/advicer/wx/callback
|
||||||
→ advice_wx.go.HandleCallback()
|
→ advice_wx.go.HandleCallback()
|
||||||
→ 解析 AddMsg 事件 → 提取消息字段
|
→ 解析 AddMsg 事件 → 提取消息字段
|
||||||
|
→ 过滤:gh_ 开头的 wxid(公众号消息)→ 忽略
|
||||||
|
→ 过滤:msgType 为 "other" → 忽略
|
||||||
→ 去重(AppId + NewMsgId)
|
→ 去重(AppId + NewMsgId)
|
||||||
→ 入库 MongoDB advicer_wx_msg
|
→ 入库 MongoDB advicer_wx_msg
|
||||||
→ WebSocket 广播 new_message 事件
|
→ WebSocket 广播 new_message 事件(按 appId 匹配推送)
|
||||||
→ 触发智能策略(客户文本消息异步 AI 决策)
|
→ 触发智能策略(客户文本消息异步 AI 决策)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -365,14 +430,33 @@ type XxxItem struct {
|
||||||
|
|
||||||
```
|
```
|
||||||
前端 loadConversations()
|
前端 loadConversations()
|
||||||
→ POST wx/conversation/list { appId, selfWxid }
|
→ POST wx/conversation/list { selfWxid } ← 不再传 appId
|
||||||
→ MongoDB 聚合管道:$match(appId+selfWxid) → $sort → $group by wxid → 分页
|
→ MongoDB 聚合管道:$match(selfWxid) → $sort → $group by wxid → 分页
|
||||||
→ 返回会话列表(每个 wxid 的最新消息预览)
|
→ 返回会话列表(每个 wxid 的最新消息预览 + 未读数)
|
||||||
|
|
||||||
前端 openConversationChat(wxid)
|
前端 openConversationChat(wxid)
|
||||||
→ POST wx/conversation/msgs { appId, selfWxid, wxid }
|
→ POST wx/conversation/msgs { selfWxid, wxid } ← 不再传 appId
|
||||||
→ MongoDB 查询:filter(appId+selfWxid+wxid) → sort(createAt:1) → 分页
|
→ 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 接口**:
|
**新增一个 MySQL CRUD 模块**(参考 `ai_advice_customer` 模块):
|
||||||
1. `entitys/advicer_data.go` 定义请求/响应结构体
|
1. `sql/04_xxx.sql` 建表 DDL
|
||||||
2. `biz/advice_xxx.go` 实现业务逻辑
|
2. `data/model/xxx.gen.go` 定义 GORM 模型
|
||||||
3. `services/advice/xxx.go` 实现 Handler
|
3. `data/impl/xxx_impl.go` 继承 DataTemp 实现数据访问
|
||||||
4. `server/router/advicer.go` 注册路由
|
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 集合**:
|
**新增一个 MongoDB 集合**:
|
||||||
1. `data/mongo_model/xxx.go` 定义模型 + `*Item` 包装
|
1. `data/mongo_model/xxx.go` 定义模型 + `*Item` 包装
|
||||||
|
|
@ -428,13 +520,18 @@ type XxxItem struct {
|
||||||
1. **`*.gen.go` 和 `wire_gen.go` 禁止手动编辑**——它们由工具自动生成
|
1. **`*.gen.go` 和 `wire_gen.go` 禁止手动编辑**——它们由工具自动生成
|
||||||
2. **Mongo 全量更新是 `$set` 语义**——务必"先读后合并再写",否则清空未提交字段
|
2. **Mongo 全量更新是 `$set` 语义**——务必"先读后合并再写",否则清空未提交字段
|
||||||
3. **微信代理响应必须设 `skip_response_wrap`**——否则被 `registerCommon` 二次包装
|
3. **微信代理响应必须设 `skip_response_wrap`**——否则被 `registerCommon` 二次包装
|
||||||
4. **前端修改必须更新版本号**——`?v=N` 控制浏览器缓存
|
4. **前端修改必须更新版本号**——`?v=N` 控制浏览器缓存,不更新则浏览器用旧缓存
|
||||||
5. **历史遗留代码**(`internal/biz/handle`、`llm_service` 等)是死代码,编译报错不影响主服务,可忽略
|
5. **历史遗留代码**(`internal/biz/handle`、`llm_service` 等)是死代码,可忽略
|
||||||
6. **新增无需登录的接口**须在 `router.go` 的 `AuthMiddleware` 白名单中追加
|
6. **新增无需登录的接口**须在 `router.go` 的 `AuthMiddleware` 白名单中追加
|
||||||
7. **JS 文件行尾必须是 LF**——Windows 环境下编辑器可能写入 CRLF,导致浏览器 `SyntaxError`
|
7. **`loadProfile()` 返回 Promise**——依赖 `state.app.wxid` 的操作必须在其 `.then()` 回调中执行
|
||||||
8. **JS 字符串中的引号必须匹配**——双引号开头的字符串必须以双引号闭合,单引号同理
|
8. **子模块通过 `WxN.state` 访问状态**——不要在闭包中缓存 state 引用,始终通过 getter 获取最新值
|
||||||
9. **`loadProfile()` 返回 Promise**——依赖 `state.app.wxid` 的操作必须在其 `.then()` 回调中执行
|
9. **`state.app.appId` 不缓存**——每次进入工作台都从 `advicer/info` API 实时获取 `wx_device_id`
|
||||||
10. **子模块通过 `WxN.state` 访问状态**——不要在闭包中缓存 state 引用,始终通过 getter 获取最新值
|
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
|
go run cmd/server/main.go
|
||||||
|
|
||||||
|
# Wire 重新生成(修改依赖后执行)
|
||||||
|
cd cmd/server && wire
|
||||||
|
|
||||||
# Docker 部署
|
# Docker 部署
|
||||||
docker-compose up -d
|
docker-compose up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
*最后更新:2026-09-23 · 完整文档见 `README.md`*
|
*最后更新:2026-09-24 · 完整文档见 `README.md`*
|
||||||
|
|
|
||||||
|
|
@ -319,28 +319,47 @@ func (a *AdviceProjectBiz) ConfigInfo(ctx context.Context, param *entitys.Advice
|
||||||
}
|
}
|
||||||
|
|
||||||
// ProjectDataLoad 加载项目资料(新表)
|
// ProjectDataLoad 加载项目资料(新表)
|
||||||
|
// 文档中除 projectId / lastUpdateTime 外的字段均为栏目数据
|
||||||
func (a *AdviceProjectBiz) ProjectDataLoad(ctx context.Context, projectId int32) (info mongo_model.AdvicerProjectDataMongo, err error) {
|
func (a *AdviceProjectBiz) ProjectDataLoad(ctx context.Context, projectId int32) (info mongo_model.AdvicerProjectDataMongo, err error) {
|
||||||
filter := bson.M{"projectId": projectId}
|
filter := bson.M{"projectId": projectId}
|
||||||
res := a.mongo.Co(a.AdvicerProjectDataMongo).FindOne(ctx, filter)
|
res := a.mongo.Co(a.AdvicerProjectDataMongo).FindOne(ctx, filter)
|
||||||
if res.Err() != nil && !errors.Is(res.Err(), mongo.ErrNoDocuments) {
|
if res.Err() != nil && !errors.Is(res.Err(), mongo.ErrNoDocuments) {
|
||||||
return info, res.Err()
|
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
|
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
|
return info, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ProjectDataSave 保存项目资料(新表,upsert)
|
// ProjectDataSave 保存项目资料(新表,upsert)
|
||||||
|
// 栏目数据直接展平到文档顶层字段
|
||||||
func (a *AdviceProjectBiz) ProjectDataSave(ctx context.Context, param *entitys.AdvicerProjectDataSaveReq) error {
|
func (a *AdviceProjectBiz) ProjectDataSave(ctx context.Context, param *entitys.AdvicerProjectDataSaveReq) error {
|
||||||
filter := bson.M{"projectId": param.ProjectId}
|
filter := bson.M{"projectId": param.ProjectId}
|
||||||
update := bson.M{
|
setFields := bson.M{
|
||||||
"$set": bson.M{
|
"projectId": param.ProjectId,
|
||||||
"projectId": param.ProjectId,
|
"lastUpdateTime": time.Now(),
|
||||||
"sections": param.Sections,
|
|
||||||
"lastUpdateTime": time.Now(),
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
for k, v := range param.Data {
|
||||||
|
setFields[k] = v
|
||||||
|
}
|
||||||
|
update := bson.M{"$set": setFields}
|
||||||
opts := options.Update().SetUpsert(true)
|
opts := options.Update().SetUpsert(true)
|
||||||
_, err := a.mongo.Co(a.AdvicerProjectDataMongo).UpdateOne(ctx, filter, update, opts)
|
_, err := a.mongo.Co(a.AdvicerProjectDataMongo).UpdateOne(ctx, filter, update, opts)
|
||||||
return err
|
return err
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,8 @@ import (
|
||||||
|
|
||||||
"go.mongodb.org/mongo-driver/bson"
|
"go.mongodb.org/mongo-driver/bson"
|
||||||
"go.mongodb.org/mongo-driver/mongo/options"
|
"go.mongodb.org/mongo-driver/mongo/options"
|
||||||
|
|
||||||
|
"github.com/gofiber/fiber/v2/log"
|
||||||
)
|
)
|
||||||
|
|
||||||
// WsBroadcaster WebSocket 广播接口(由 services/advice.WxWsHub 实现)
|
// WsBroadcaster WebSocket 广播接口(由 services/advice.WxWsHub 实现)
|
||||||
|
|
@ -196,6 +198,7 @@ func (a *AdviceWxBiz) handleOneEvent(ctx context.Context, body []byte) error {
|
||||||
|
|
||||||
// WebSocket 实时广播:向对应 appId 的已连接前端推送新消息事件
|
// WebSocket 实时广播:向对应 appId 的已连接前端推送新消息事件
|
||||||
if a.wsBroadcaster != nil {
|
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{}{
|
a.wsBroadcaster.Broadcast(event.AppId, "new_message", map[string]interface{}{
|
||||||
"appId": event.AppId,
|
"appId": event.AppId,
|
||||||
"wxid": peerWxid,
|
"wxid": peerWxid,
|
||||||
|
|
@ -352,9 +355,6 @@ func (a *AdviceWxBiz) MsgList(ctx context.Context, param *entitys.AdvicerWxMsgLi
|
||||||
// ConversationList 查询会话列表(按最新消息时间倒序,聚合每个 wxid 的最新一条消息)
|
// ConversationList 查询会话列表(按最新消息时间倒序,聚合每个 wxid 的最新一条消息)
|
||||||
func (a *AdviceWxBiz) ConversationList(ctx context.Context, param *entitys.AdvicerWxConversationListReq) ([]entitys.AdvicerWxConversationItem, error) {
|
func (a *AdviceWxBiz) ConversationList(ctx context.Context, param *entitys.AdvicerWxConversationListReq) ([]entitys.AdvicerWxConversationItem, error) {
|
||||||
filter := bson.M{}
|
filter := bson.M{}
|
||||||
if len(param.AppId) != 0 {
|
|
||||||
filter["appId"] = param.AppId
|
|
||||||
}
|
|
||||||
if len(param.SelfWxid) != 0 {
|
if len(param.SelfWxid) != 0 {
|
||||||
filter["selfWxid"] = param.SelfWxid
|
filter["selfWxid"] = param.SelfWxid
|
||||||
}
|
}
|
||||||
|
|
@ -437,11 +437,10 @@ func (a *AdviceWxBiz) ConversationList(ctx context.Context, param *entitys.Advic
|
||||||
|
|
||||||
// MarkAsRead 标记某个会话中所有客户消息为已读
|
// MarkAsRead 标记某个会话中所有客户消息为已读
|
||||||
func (a *AdviceWxBiz) MarkAsRead(ctx context.Context, param *entitys.AdvicerWxMarkAsReadReq) (int64, error) {
|
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
|
return 0, nil
|
||||||
}
|
}
|
||||||
filter := bson.M{
|
filter := bson.M{
|
||||||
"appId": param.AppId,
|
|
||||||
"wxid": param.Wxid,
|
"wxid": param.Wxid,
|
||||||
"direction": mongo_model.WxMsgDirectionCustomer,
|
"direction": mongo_model.WxMsgDirectionCustomer,
|
||||||
"read": false,
|
"read": false,
|
||||||
|
|
@ -460,9 +459,6 @@ func (a *AdviceWxBiz) MarkAsRead(ctx context.Context, param *entitys.AdvicerWxMa
|
||||||
// ConversationMsgs 查询某个会话的消息记录(按时间正序,用于聊天展示)
|
// ConversationMsgs 查询某个会话的消息记录(按时间正序,用于聊天展示)
|
||||||
func (a *AdviceWxBiz) ConversationMsgs(ctx context.Context, param *entitys.AdvicerWxConversationMsgsReq) ([]mongo_model.AdvicerWxMsgItem, error) {
|
func (a *AdviceWxBiz) ConversationMsgs(ctx context.Context, param *entitys.AdvicerWxConversationMsgsReq) ([]mongo_model.AdvicerWxMsgItem, error) {
|
||||||
filter := bson.M{}
|
filter := bson.M{}
|
||||||
if len(param.AppId) != 0 {
|
|
||||||
filter["appId"] = param.AppId
|
|
||||||
}
|
|
||||||
if len(param.SelfWxid) != 0 {
|
if len(param.SelfWxid) != 0 {
|
||||||
filter["selfWxid"] = param.SelfWxid
|
filter["selfWxid"] = param.SelfWxid
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,19 +2,12 @@ package mongo_model
|
||||||
|
|
||||||
import "time"
|
import "time"
|
||||||
|
|
||||||
// ProjectDataSection 项目资料动态栏目
|
// AdvicerProjectDataMongo 项目资料(新表,与项目一对一绑定)
|
||||||
type ProjectDataSection struct {
|
// 各栏目内容直接作为文档顶层字段存储(key=栏目名,value=内容)
|
||||||
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 项目资料(新表,与项目一对一绑定,初始无栏目)
|
|
||||||
type AdvicerProjectDataMongo struct {
|
type AdvicerProjectDataMongo struct {
|
||||||
ProjectId int32 `json:"projectId" bson:"projectId"`
|
ProjectId int32 `json:"projectId" bson:"projectId"`
|
||||||
Sections []ProjectDataSection `json:"sections" bson:"sections"`
|
Data map[string]interface{} `json:"data" bson:"-"` // 不直接映射 bson,save/load 时手动展平
|
||||||
LastUpdateTime time.Time `json:"lastUpdateTime" bson:"lastUpdateTime"`
|
LastUpdateTime time.Time `json:"lastUpdateTime" bson:"lastUpdateTime"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewAdvicerProjectDataMongo() *AdvicerProjectDataMongo {
|
func NewAdvicerProjectDataMongo() *AdvicerProjectDataMongo {
|
||||||
|
|
|
||||||
|
|
@ -189,8 +189,8 @@ type AdvicerProjectInfoRes struct {
|
||||||
|
|
||||||
// AdvicerProjectDataSaveReq 保存项目资料(新表)
|
// AdvicerProjectDataSaveReq 保存项目资料(新表)
|
||||||
type AdvicerProjectDataSaveReq struct {
|
type AdvicerProjectDataSaveReq struct {
|
||||||
ProjectId int32 `json:"projectId"`
|
ProjectId int32 `json:"projectId"`
|
||||||
Sections []mongo_model.ProjectDataSection `json:"sections"`
|
Data map[string]interface{} `json:"data"` // 栏目内容(扁平 JSON,key=栏目名,直接入库)
|
||||||
}
|
}
|
||||||
|
|
||||||
type AdvicerClientAddReq struct {
|
type AdvicerClientAddReq struct {
|
||||||
|
|
@ -408,16 +408,14 @@ type AdvicerActivityPushReq struct {
|
||||||
|
|
||||||
// AdvicerWxConversationListReq 会话列表查询
|
// AdvicerWxConversationListReq 会话列表查询
|
||||||
type AdvicerWxConversationListReq struct {
|
type AdvicerWxConversationListReq struct {
|
||||||
AppId string `json:"appId"`
|
SelfWxid string `json:"selfWxid"` // 销售本人 wxid
|
||||||
SelfWxid string `json:"selfWxid"` // 销售本人 wxid(可选,传入时只查该销售的会话)
|
|
||||||
Page int `json:"page"`
|
Page int `json:"page"`
|
||||||
PageSize int `json:"page_size"`
|
PageSize int `json:"page_size"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// AdvicerWxConversationMsgsReq 会话消息记录查询
|
// AdvicerWxConversationMsgsReq 会话消息记录查询
|
||||||
type AdvicerWxConversationMsgsReq struct {
|
type AdvicerWxConversationMsgsReq struct {
|
||||||
AppId string `json:"appId"`
|
SelfWxid string `json:"selfWxid"` // 销售本人 wxid
|
||||||
SelfWxid string `json:"selfWxid"` // 销售本人 wxid(可选,传入时只查该销售的消息)
|
|
||||||
Wxid string `json:"wxid"`
|
Wxid string `json:"wxid"`
|
||||||
Page int `json:"page"`
|
Page int `json:"page"`
|
||||||
PageSize int `json:"page_size"`
|
PageSize int `json:"page_size"`
|
||||||
|
|
@ -425,8 +423,7 @@ type AdvicerWxConversationMsgsReq struct {
|
||||||
|
|
||||||
// AdvicerWxMarkAsReadReq 标记会话消息已读
|
// AdvicerWxMarkAsReadReq 标记会话消息已读
|
||||||
type AdvicerWxMarkAsReadReq struct {
|
type AdvicerWxMarkAsReadReq struct {
|
||||||
AppId string `json:"appId"`
|
SelfWxid string `json:"selfWxid"` // 销售本人 wxid
|
||||||
SelfWxid string `json:"selfWxid"` // 销售本人 wxid(可选)
|
|
||||||
Wxid string `json:"wxid"`
|
Wxid string `json:"wxid"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -100,7 +100,18 @@ func (d *ProjectService) ProjectDataLoad(c *fiber.Ctx) error {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
info, err := d.adviceProjectBiz.ProjectDataLoad(c.UserContext(), req.ProjectId)
|
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 保存项目资料(新表)
|
// ProjectDataSave 保存项目资料(新表)
|
||||||
|
|
|
||||||
|
|
@ -94,7 +94,9 @@ func (h *WxWsHub) handleClientMsg(client *wsClient, raw []byte) {
|
||||||
client.mu.Lock()
|
client.mu.Lock()
|
||||||
client.appId = msg.AppId
|
client.appId = msg.AppId
|
||||||
client.mu.Unlock()
|
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":
|
case "ping":
|
||||||
// 客户端心跳,回复 pong
|
// 客户端心跳,回复 pong
|
||||||
|
|
@ -130,9 +132,13 @@ func (h *WxWsHub) Broadcast(appId string, event string, data interface{}) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
clientCount := 0
|
||||||
|
matchedCount := 0
|
||||||
for client := range h.clients {
|
for client := range h.clients {
|
||||||
client.mu.Lock()
|
client.mu.Lock()
|
||||||
|
clientCount++
|
||||||
if client.appId == appId {
|
if client.appId == appId {
|
||||||
|
matchedCount++
|
||||||
// 设置写超时,避免慢客户端阻塞广播
|
// 设置写超时,避免慢客户端阻塞广播
|
||||||
_ = client.conn.SetWriteDeadline(time.Now().Add(3 * time.Second))
|
_ = client.conn.SetWriteDeadline(time.Now().Add(3 * time.Second))
|
||||||
_ = client.conn.WriteMessage(websocket.TextMessage, payload)
|
_ = client.conn.WriteMessage(websocket.TextMessage, payload)
|
||||||
|
|
@ -140,6 +146,7 @@ func (h *WxWsHub) Broadcast(appId string, event string, data interface{}) {
|
||||||
}
|
}
|
||||||
client.mu.Unlock()
|
client.mu.Unlock()
|
||||||
}
|
}
|
||||||
|
log.Infof("[WS] Broadcast event=%s appId=%s clients=%d matched=%d", event, appId, clientCount, matchedCount)
|
||||||
}
|
}
|
||||||
|
|
||||||
// BroadcastOnline 广播在线状态变化
|
// BroadcastOnline 广播在线状态变化
|
||||||
|
|
|
||||||
BIN
server.exe
BIN
server.exe
Binary file not shown.
|
|
@ -12,7 +12,8 @@ function renderProjectDetail() {
|
||||||
})
|
})
|
||||||
.then(function (data) {
|
.then(function (data) {
|
||||||
state.projectData = data || {};
|
state.projectData = data || {};
|
||||||
state.pjiSections = (state.projectData.sections || []);
|
// 从展平的响应中还原栏目(排除 projectId / lastUpdateTime)
|
||||||
|
state.pjiSections = buildSectionsFromData(state.projectData);
|
||||||
// 保存初始快照(用于重置)
|
// 保存初始快照(用于重置)
|
||||||
state.pjiInitialSections = JSON.parse(JSON.stringify(state.pjiSections));
|
state.pjiInitialSections = JSON.parse(JSON.stringify(state.pjiSections));
|
||||||
renderProjectDetailForm();
|
renderProjectDetailForm();
|
||||||
|
|
@ -112,7 +113,7 @@ function renderProjectDetailForm() {
|
||||||
'<button class="btn ghost sm" id="pji-add-section">+ \u65b0\u589e\u680f\u76ee</button>' +
|
'<button class="btn ghost sm" id="pji-add-section">+ \u65b0\u589e\u680f\u76ee</button>' +
|
||||||
'<button class="btn ghost sm" id="pji-reset" style="margin-left:8px">\u91cd\u7f6e</button>' +
|
'<button class="btn ghost sm" id="pji-reset" style="margin-left:8px">\u91cd\u7f6e</button>' +
|
||||||
'<button class="btn primary sm" id="pji-save" style="margin-left:8px">\u4fdd\u5b58\u9879\u76ee\u8d44\u6599</button></div>';
|
'<button class="btn primary sm" id="pji-save" style="margin-left:8px">\u4fdd\u5b58\u9879\u76ee\u8d44\u6599</button></div>';
|
||||||
html += '<div class="card-sub">\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</div>';
|
html += '<div class="card-sub" style="margin-bottom:12px">\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</div>';
|
||||||
html += '<div id="pji-sections"></div>';
|
html += '<div id="pji-sections"></div>';
|
||||||
html += '</div>'; // end pd-tab-projectInfo
|
html += '</div>'; // end pd-tab-projectInfo
|
||||||
|
|
||||||
|
|
@ -256,13 +257,14 @@ function renderProjectDetailForm() {
|
||||||
// ===== 保存项目资料 =====
|
// ===== 保存项目资料 =====
|
||||||
$("#pji-save").onclick = function () {
|
$("#pji-save").onclick = function () {
|
||||||
var sections = collectPjiSectionsData();
|
var sections = collectPjiSectionsData();
|
||||||
|
var dataObj = {};
|
||||||
|
sections.forEach(function (s) { dataObj[s.key] = s.content; });
|
||||||
Core.loading(true, "\u4fdd\u5b58\u4e2d\u2026");
|
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 () {
|
.then(function () {
|
||||||
Core.loading(false);
|
Core.loading(false);
|
||||||
Core.toast("\u9879\u76ee\u8d44\u6599\u5df2\u4fdd\u5b58", "ok");
|
Core.toast("\u9879\u76ee\u8d44\u6599\u5df2\u4fdd\u5b58", "ok");
|
||||||
state.pjiSections = sections;
|
state.pjiSections = sections;
|
||||||
// 更新初始快照
|
|
||||||
state.pjiInitialSections = JSON.parse(JSON.stringify(sections));
|
state.pjiInitialSections = JSON.parse(JSON.stringify(sections));
|
||||||
})
|
})
|
||||||
.catch(function (e) { Core.loading(false); Core.toast(e.message, "err"); });
|
.catch(function (e) { Core.loading(false); Core.toast(e.message, "err"); });
|
||||||
|
|
@ -281,7 +283,7 @@ function renderProjectDetailForm() {
|
||||||
.then(function (data) {
|
.then(function (data) {
|
||||||
Core.loading(false);
|
Core.loading(false);
|
||||||
state.projectData = data || {};
|
state.projectData = data || {};
|
||||||
state.pjiSections = (state.projectData.sections || []);
|
state.pjiSections = buildSectionsFromData(state.projectData);
|
||||||
state.pjiInitialSections = JSON.parse(JSON.stringify(state.pjiSections));
|
state.pjiInitialSections = JSON.parse(JSON.stringify(state.pjiSections));
|
||||||
drawPjiSections();
|
drawPjiSections();
|
||||||
Core.toast("\u5df2\u91cd\u7f6e\u4e3a\u63a5\u53e3\u6570\u636e", "ok");
|
Core.toast("\u5df2\u91cd\u7f6e\u4e3a\u63a5\u53e3\u6570\u636e", "ok");
|
||||||
|
|
@ -345,6 +347,30 @@ function normalizeKvPairs(data) {
|
||||||
return [];
|
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) {
|
function parseSectionContent(content, type) {
|
||||||
if (content == null || content === "") return type === "string" ? "" : [];
|
if (content == null || content === "") return type === "string" ? "" : [];
|
||||||
|
|
@ -591,7 +617,11 @@ function bindPjiSectionEvents() {
|
||||||
input.select();
|
input.select();
|
||||||
var finish = function () {
|
var finish = function () {
|
||||||
var newLabel = input.value.trim();
|
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;
|
state.pjiSections = sections;
|
||||||
drawPjiSections();
|
drawPjiSections();
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -481,11 +481,15 @@
|
||||||
/* ==================== WebSocket 实时通信 ==================== */
|
/* ==================== WebSocket 实时通信 ==================== */
|
||||||
|
|
||||||
function connectWs() {
|
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(); // 先断开已有连接
|
disconnectWs(); // 先断开已有连接
|
||||||
|
|
||||||
var token = Core.store(Core.KEYS.token);
|
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 protocol = location.protocol === "https:" ? "wss:" : "ws:";
|
||||||
var url = protocol + "//" + location.host + "/api/v1/advicer/ws?token=" + encodeURIComponent(token);
|
var url = protocol + "//" + location.host + "/api/v1/advicer/ws?token=" + encodeURIComponent(token);
|
||||||
|
|
@ -498,6 +502,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
wsConn.onopen = function () {
|
wsConn.onopen = function () {
|
||||||
|
console.log("[WS] connected, subscribing appId=" + state.app.appId);
|
||||||
wsReconnectDelay = 1000; // 重置重连延迟
|
wsReconnectDelay = 1000; // 重置重连延迟
|
||||||
// 订阅当前 appId 的事件
|
// 订阅当前 appId 的事件
|
||||||
wsConn.send(JSON.stringify({ action: "subscribe", appId: state.app.appId }));
|
wsConn.send(JSON.stringify({ action: "subscribe", appId: state.app.appId }));
|
||||||
|
|
@ -508,11 +513,13 @@
|
||||||
wsConn.onmessage = function (e) {
|
wsConn.onmessage = function (e) {
|
||||||
try {
|
try {
|
||||||
var msg = JSON.parse(e.data);
|
var msg = JSON.parse(e.data);
|
||||||
|
console.log("[WS] received event:", msg.event, msg.data);
|
||||||
handleWsEvent(msg);
|
handleWsEvent(msg);
|
||||||
} catch (err) { /* 忽略非 JSON 消息 */ }
|
} catch (err) { console.warn("[WS] parse error:", err); }
|
||||||
};
|
};
|
||||||
|
|
||||||
wsConn.onclose = function () {
|
wsConn.onclose = function () {
|
||||||
|
console.log("[WS] connection closed");
|
||||||
stopWsHeartbeat();
|
stopWsHeartbeat();
|
||||||
wsConn = null;
|
wsConn = null;
|
||||||
scheduleWsReconnect();
|
scheduleWsReconnect();
|
||||||
|
|
|
||||||
|
|
@ -50,7 +50,7 @@
|
||||||
function loadConversations() {
|
function loadConversations() {
|
||||||
var s = state();
|
var s = state();
|
||||||
if (!s.app || !s.app.appId) return;
|
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;
|
if (s.app.wxid) params.selfWxid = s.app.wxid;
|
||||||
Core.api("wx/conversation/list", params)
|
Core.api("wx/conversation/list", params)
|
||||||
.then(function (list) {
|
.then(function (list) {
|
||||||
|
|
@ -121,7 +121,7 @@
|
||||||
WxN.renderContacts();
|
WxN.renderContacts();
|
||||||
var box = $("#wxMsgs", s.container);
|
var box = $("#wxMsgs", s.container);
|
||||||
box.innerHTML = '<div class="empty" style="margin:auto"><span class="spin"></span><div class="mt-1 fs-12">加载聊天记录…</div></div>';
|
box.innerHTML = '<div class="empty" style="margin:auto"><span class="spin"></span><div class="mt-1 fs-12">加载聊天记录…</div></div>';
|
||||||
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;
|
if (s.app.wxid) msgParams.selfWxid = s.app.wxid;
|
||||||
Core.api("wx/conversation/msgs", msgParams)
|
Core.api("wx/conversation/msgs", msgParams)
|
||||||
.then(function (list) {
|
.then(function (list) {
|
||||||
|
|
@ -159,7 +159,7 @@
|
||||||
function markConvAsRead(wxid) {
|
function markConvAsRead(wxid) {
|
||||||
var s = state();
|
var s = state();
|
||||||
if (!s.app || !s.app.appId) return;
|
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;
|
if (s.app.wxid) params.selfWxid = s.app.wxid;
|
||||||
Core.api("wx/conversation/read", params)
|
Core.api("wx/conversation/read", params)
|
||||||
.then(function () {
|
.then(function () {
|
||||||
|
|
|
||||||
|
|
@ -92,12 +92,12 @@
|
||||||
<script src="https://cdn.jsdelivr.net/npm/flatpickr@4.6.13/dist/l10n/zh.js"></script>
|
<script src="https://cdn.jsdelivr.net/npm/flatpickr@4.6.13/dist/l10n/zh.js"></script>
|
||||||
<script src="assets/js/core.js?v=21"></script>
|
<script src="assets/js/core.js?v=21"></script>
|
||||||
<script src="assets/js/admin_industry.js?v=18"></script>
|
<script src="assets/js/admin_industry.js?v=18"></script>
|
||||||
<script src="assets/js/wx.js?v=47"></script>
|
<script src="assets/js/wx.js?v=49"></script>
|
||||||
<script src="assets/js/wx_login.js?v=44"></script>
|
<script src="assets/js/wx_login.js?v=44"></script>
|
||||||
<script src="assets/js/wx_chat.js?v=42"></script>
|
<script src="assets/js/wx_chat.js?v=43"></script>
|
||||||
<script src="assets/js/project.js?v=21"></script>
|
<script src="assets/js/project.js?v=21"></script>
|
||||||
<script src="assets/js/project_list.js?v=18"></script>
|
<script src="assets/js/project_list.js?v=18"></script>
|
||||||
<script src="assets/js/project_detail.js?v=18"></script>
|
<script src="assets/js/project_detail.js?v=22"></script>
|
||||||
<script src="assets/js/project_analysis.js?v=18"></script>
|
<script src="assets/js/project_analysis.js?v=18"></script>
|
||||||
<script src="assets/js/project_data.js?v=23"></script>
|
<script src="assets/js/project_data.js?v=23"></script>
|
||||||
<script src="assets/js/project_activity.js?v=18"></script>
|
<script src="assets/js/project_activity.js?v=18"></script>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue