feat: add workbench kafka migration support

This commit is contained in:
richjohnrambo
2026-09-13 22:17:02 +08:00
parent c46cb4b382
commit 67e8c2c6f6
211 changed files with 7391 additions and 478 deletions
+85 -3
View File
@@ -99,24 +99,106 @@ QUIC传输技术
群成员信息这个是很耗性能的 一个群几千个人 你要考虑是进一次就拿一次群成员信息 还是拿一次
网页上一个tab一个用户
我说的进不是给你拉进群 是你点击群聊这个会话框
敏感词 开关
组织机构树的处理逻辑 我现在处理逻辑
1,手机号注册,或者上架用的测试账号 可以看到好友的组织结构,
2,通过邀请进来的 只能看到自己
对了,强调一点,移动端所有加好友的入口都是需要后端控制的,管理后台需要开关控制能不能加好友。
然后还要增加宵禁功能,宵禁功能是单聊私聊都不能发消息。这个也是管理后台控制的。
tinode 移除
监控接口
制定联调计划
多租户的测试
不能全存
图片只存oss地址 给前端的是oss地址 文件也是oss地址
本地只存100条 历史分页50条拉
群聊要跨月建表
聊天的菜单元素是前端写死的么
群成员变动发消息 然后拉取 群成员 要考虑何时全部 何时只是一个
只有加入的群管理消息都可见 其他的比如移除啥的 只有管理可见
离线用户的推送 未读消息数的处理 每个用户的不一样 群成员每个消息的推送
前端返回一个现有序号 后端跟最新序号比对 然后一减 就是未读消息
全局禁止加好友 比如下班时间到了 不能加 从群成员去看 不能显示个人信息 个人名片也不能看 二维码也不能加
阿里云的oss放一个域名清单 要加密
设备类型 ip 全部采集入库
离线时间 和上线时间的采集
检查一下多处存同一个信息的。尤其是用户的
音视频要加上开关 控制前端的显示 已经处理
拆成工作台 + IM
文案部分全部拆出来 做配置
个人注册的企业码是默认system 不需要企业码的位置
先是企业合作,填写信息,审核之后得到企业码
企业注册的要带上企业码
kafka批量消费 批量落库
1.进群的时候拿群成员列表信息 这个怎么做成缓存 然后怎么更新缓存
2.多标签要独立的登录状态,可以用不同域名来解决这个问题,但是要token过期时间要限制一下 7天或者10天 每次登录都重置这个时间
3.转发消息 涉及到转发群和转发个人 大批量转发 消息会不会串
4.我在第一个标签转发了100个群,然后直接切换到第二个标签,你第一个标签还没转发完 你该怎么处理 会不会串到第二个标签页
新增图片的功能 ??
拉一下git提交记录 已经处理
多实例 断线重连 其他ws的坑
### 1. Fitness-Food(GitHub,MIT 协议|中文友好!重点推荐)
🔗 [https://github.com/saudademjj/fitness-food](https://github.com/saudademjj/fitness-food)
- 定位:**中文智能饮食管理系统**,完全匹配你的健身 APP 饮食模块
- 自带接口能力:
- 输入中文食物名称,返回全套营养素(热量 / 蛋白 / 碳水 / 脂肪、微量元素)
- 三餐记录、每日营养汇总、目标对比(减脂 / 增肌)
- PostgreSQL 内置中文食物库
- 技术栈:Next.js + Postgres,可以单独拆出后端 API 给你的 APP 调用
- 协议:MIT,可商用,二次开发无压力
我打算弄一个饮食健康的产品,主要是想记录三餐,目标对比,返回食物热量
默认system企业
内容审核要根据企业来分。做成开关 新建企业的时候加上
用户要和商户绑定 进去只能看某一个商户的数据 这个要改造 要从多商户的角度思考系统还有哪些问题
调研:
阿里云上传apk包 下载下来就装不上
各个厂商push的申请
新加一个状态,消息免打扰后,对方发消息,红点这么显示
Bundle ID:com.ysl.IM
KEY ID:J4ZUVHW59G
Team ID:2DN6KF37CH
@@ -0,0 +1,206 @@
# MeetingIM App 版本更新前后端对接文档
## 1. 适用范围
本文档用于 iOS、Android 客户端检查新版本,以及执行推荐更新和强制更新。
- 版本策略为全局配置,存储在 `hotgo.im_app_releases`,不区分租户。
- iOS 安装包由 App Store 托管,客户端通过 `store_url` 跳转。
- Android APK 由发布人员上传到专用 OSS 发布目录,客户端通过 `package_url` 下载。
- 客户端不直接访问数据库,也不自行拼接 OSS 地址。
## 2. 检查版本接口
```http
GET https://api.118im.com/v1/im/app/releases/latest
```
该接口允许未登录调用。
### 2.1 请求 Header
| Header | 必填 | 示例 | 说明 |
| --- | --- | --- | --- |
| `X-IM-Schema` | 是 | `1` | MeetingIM HTTP 协议版本 |
| `X-Client-Platform` | 建议 | `ios` / `android` | 客户端平台 |
### 2.2 Query 参数
| 参数 | 必填 | 示例 | 说明 |
| --- | --- | --- | --- |
| `apikey` | 是 | `AQAAAA...` | 应用 API Key |
| `platform` | 是 | `android` | 仅支持 `ios`、`android` |
| `current_version` | 是 | `1.2.0` | 当前版本,使用数字点分格式 |
| `channel` | 否 | `stable` | 发布渠道,默认 `stable` |
Android 请求示例:
```bash
curl -H 'X-IM-Schema: 1' \
-H 'X-Client-Platform: android' \
'https://api.118im.com/v1/im/app/releases/latest?apikey=AQAAAA...&platform=android&current_version=1.2.0&channel=stable'
```
## 3. 成功响应
```json
{
"code": 0,
"data": {
"platform": "android",
"channel": "stable",
"current_version": "1.2.0",
"latest_version": "1.3.0",
"min_supported_version": "1.1.0",
"has_update": true,
"force_update": false,
"update_type": "recommended",
"release_notes": "优化消息稳定性并修复已知问题。",
"store_url": "",
"package_url": "https://download.example.com/app-releases/android/stable/1.3.0/meetingim-1.3.0.apk",
"checksum": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
"remind_after_seconds": 86400,
"published_at": "2026-09-12T08:00:00Z"
},
"request_id": "req_xxx",
"server_time": "2026-09-12T08:10:00Z"
}
```
### 3.1 响应字段
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `current_version` | string | 客户端传入的当前版本 |
| `latest_version` | string | 后台发布的最新版本 |
| `min_supported_version` | string | 最低可继续使用的版本 |
| `has_update` | boolean | 是否存在更高版本 |
| `force_update` | boolean | 是否必须完成更新后才能继续使用 |
| `update_type` | string | `none`、`recommended`、`force` |
| `release_notes` | string | 更新说明 |
| `store_url` | string | iOS App Store 地址 |
| `package_url` | string | Android APK 的 OSS/CDN HTTPS 下载地址 |
| `checksum` | string | Android APK 的 SHA-256,小写十六进制 |
| `remind_after_seconds` | number | 推荐更新关闭后再次提醒的间隔 |
| `published_at` | string | 发布时间,UTC RFC 3339 |
## 4. 更新判断规则
客户端以服务端返回结果为准,不自行重新计算强制更新规则。
| 条件 | 客户端行为 |
| --- | --- |
| `has_update=false` | 不弹更新窗口;手动检查时提示已是最新版本 |
| `has_update=true, force_update=false, update_type=recommended` | 弹推荐更新窗口,允许取消或稍后更新 |
| `has_update=true, force_update=true` | 弹强制更新窗口,不允许关闭,不允许进入主业务 |
| `update_type=none` | 不主动弹窗,可展示版本信息 |
当 `current_version < min_supported_version` 时,服务端无条件返回强制更新:
```json
{
"has_update": true,
"force_update": true,
"update_type": "force"
}
```
## 5. Android APK 与 OSS
### 5.1 OSS 对象路径
建议使用独立于聊天附件的发布目录:
```text
app-releases/android/{channel}/{version}/meetingim-{version}.apk
```
示例:
```text
app-releases/android/stable/1.3.0/meetingim-1.3.0.apk
```
### 5.2 下载地址要求
1. `package_url` 必须使用 HTTPS。
2. 地址必须长期稳定,至少覆盖该版本的完整支持周期。
3. 可以使用 OSS 公共读地址,或绑定 OSS/CDN 的固定下载域名。
4. 不得把有效期只有几分钟的 OSS 签名 URL 保存到版本表。
5. 如果 Bucket 必须私有,应提供固定公开下载接口,由接口实时生成签名并返回 302;版本表保存该固定接口地址。
6. APK 响应建议设置 `Content-Type: application/vnd.android.package-archive`。
7. 文件名、对象路径和 URL 必须包含版本号,禁止覆盖旧版本对象。
聊天附件接口 `/v0/file/s/{file_id}` 需要用户鉴权,不作为 Android 安装包的默认下载地址。
### 5.3 发布步骤
1. Android 构建正式签名 APK。
2. 计算 APK 的 SHA-256。
3. 上传到 OSS 的 `app-releases/android/{channel}/{version}/` 目录。
4. 使用浏览器或 `curl -I` 验证 HTTPS 地址可以下载。
5. 在 Admin 的“IM 全局版本更新”中编辑 Android 策略。
6. 填写最新版本、最低支持版本、更新类型、更新说明、`package_url` 和 SHA-256。
7. 保存后由 Kafka `app.release.updated` 事件通知 IM Server 清理缓存。
8. 使用旧版本号请求检查接口,确认下载地址、校验值和强更状态。
计算 SHA-256 示例:
```bash
shasum -a 256 meetingim-1.3.0.apk
```
## 6. Android 客户端处理
1. 调用版本检查接口。
2. `has_update=false` 时结束检查。
3. 根据 `force_update` 展示推荐更新或不可关闭的强更弹窗。
4. 使用系统下载组件请求 `package_url`,支持进度展示和失败重试。
5. 下载完成后计算本地文件 SHA-256,并与 `checksum` 比较。
6. 校验 APK 包名和签名证书必须与已安装应用一致。
7. 通过 Android `FileProvider` 发起安装,不使用 `file://` URI。
8. 未授予“安装未知应用”权限时,引导用户进入系统授权页。
9. 强更下载或安装失败时保留重试、网络设置和退出应用入口,不能绕过进入主业务。
客户端不得在 SHA-256 或应用签名校验失败后继续安装。
## 7. iOS 客户端处理
1. iOS 只读取 `store_url`,忽略 `package_url` 和 `checksum`。
2. `store_url` 必须是 `https://apps.apple.com/...` 地址。
3. 推荐更新允许取消;强制更新不得进入主业务。
4. 点击更新后使用系统能力打开 App Store。
## 8. 错误响应
缺少或错误的 `current_version`:
```json
{
"code": 30000,
"reason": "MALFORMED_REQUEST",
"message": "malformed request",
"details": {
"field": "current_version"
}
}
```
| HTTP 状态 | 原因 |
| --- | --- |
| 400 | 缺少 `X-IM-Schema: 1` |
| 400 | 缺少或错误的 `current_version` |
| 400 | `platform` 不是 `ios` 或 `android` |
| 500 | Admin 版本配置缺失、格式错误或内部配置接口不可用 |
## 9. 当前测试环境
```text
Base URL: https://api.118im.com
版本接口: /v1/im/app/releases/latest
API Key: AQAAAAABAAAoeOI7tA3HsYvdzDhYhZJy
协议版本: X-IM-Schema: 1
默认渠道: stable
```
当前测试库已配置 iOS、Android `1.0.0` 默认策略。正式验证 Android 更新前,需要先上传测试 APK,并在 Admin 填写对应 `package_url` 与 SHA-256。
+9 -4
View File
@@ -899,9 +899,11 @@ IM Server 不再生产或消费 `im.file.lifecycle`。文件相关的小流量
3. 文件对象字节流仍走 OSS/文件服务,Kafka 不承载上传/下载内容。
4. 合规删除、临时文件 TTL 回收、OSS 删除动作仍保留为后续深化项。
### 11.11 IM Server 工作台事件同步处理
### 11.11 IM Server 工作台事件消费处理
IM Server 不再生产或消费 `im.workbench.event`。工作台小流量事件由业务接口同步创建系统通知和 `im_business_events_outbox`。
工作台拆分为独立 `im_workbench` 服务后,工作台业务变更由 `im_workbench` 生产 `im.workbench.event`,IM Server 新增独立 Kafka consumer 消费该 Topic,并转为系统通知和 C 端业务实时事件。
说明:IM Server 当前保留旧工作台 HTTP 代码和同步处理函数,仅作为迁移过渡兼容。待 `im_workbench` 全部接口稳定后,再统一删除 IM Server 内的旧工作台业务代码。
当前生产范围:
@@ -914,8 +916,11 @@ IM Server 不再生产或消费 `im.workbench.event`。工作台小流量事件
当前消费行为:
1. 按 `target_user_ids` 同步生成系统通知。
2. 同步写入 `im_business_events_outbox`,由现有 WS 业务事件投递器下发在线客户端。
1. `im_workbench` 直接发送 JSON 事件到 `im.workbench.event`,Topic 配置键为 `workbench_external`。
2. IM Server 按 `event_id + consumer_group` 做消费幂等。
3. 按 `target_user_ids` 为非发布人生成系统通知。
4. 写入 `im_business_events_outbox`,由现有 WS 业务事件投递器下发在线客户端。
5. 消费失败按统一 Kafka 重试逻辑处理,超过最大重试次数后进入 `im.dead.letter`。
### 11.12 IM Server 已接入的审计事件
+1 -1
View File
@@ -29,7 +29,7 @@ Kafka 主要用于跨系统异步事件、削峰落库、可靠重试、失败
| `im.file.security` | 文件内容安全和恶意文件检测 | 上传完成链路 | 阿里云安全检测消费者 |
| `im.file.lifecycle` | 已不采用 | 文件生命周期小流量动作同步处理 | 无 |
| `im.audit.event` | 跨系统审计事件 | IM Server、Admin 后台 | Admin/HotGo 审计消费者 |
| `im.workbench.event` | 已不采用 | 工作台小流量动作同步处理 | 无 |
| `im.workbench.event` | 工作台拆分后的业务事件、红点、系统通知、WS 下发 | im_workbench | IM Server 工作台事件消费者 |
| `im.dead.letter` | 失败事件归档 | 各消费者 | 运维补偿任务 |
## 3. 幂等与重试
+139 -1
View File
@@ -1018,7 +1018,7 @@
功能说明:
工作台聚合组织内部的常用办公应用和业务入口,包括组织架构、日报/月报、会议通知、总经理信箱等模块。应用入口应根据企业配置和用户权限动态展示,并支持未读数量提示。
工作台聚合组织内部的常用办公应用和业务入口,包括组织架构、日报/月报、会议通知、会议笔记、总经理信箱等模块。应用入口应根据企业配置和用户权限动态展示,并支持未读数量提示。
### 9.2 部门组织框架.png
@@ -1116,6 +1116,144 @@
页面通过提示文案说明信箱用途、处理方式及隐私边界。提示内容应由企业后台可配置,并在用户提交前清晰告知实名/匿名规则和信息接收方。
### 9.14 会议笔记
会议笔记是企业工作台下的办公协同模块,用于围绕会议记录会议资料、纪要文件和相关附件。功能需要和会议通知、参会人、会议状态及文件能力联动,保证发起人、会议记录员、参会人和普通成员在不同权限下看到合适的入口、文件列表和操作按钮。
通知与刷新规则:
1. 工作台底部 Tab 的红点数字需要与工作台内可接收通知的模块上下对应。当前原型中的红点 `3` 表示工作台下共有 3 条未读通知,这 3 条通知只归属到“会议通知”模块。
2. 当前只有“会议通知”模块接收移动端通知,并承担会议创建、会议变更、会议提醒、参会反馈等通知展示。
3. 打卡、月报、日报、部门框架、举报信箱、会议笔记等其他工作台模块不做移动端通知,也不产生独立通知红点。
4. 除会议通知外,其他模块在用户进入模块时调用各自接口获取最新数据并刷新页面;客户端不得依赖通知事件来更新这些模块。
5. 会议笔记的文件新增、预览、下载状态只在会议笔记列表/详情页内刷新展示,不向工作台 Tab 追加通知数。
### 9.14.1 会议笔记-工作台入口.png
<img src="images/会议笔记-工作台入口.png" alt="会议笔记-工作台入口" width="360">
功能说明:
工作台新增“会议笔记”入口,与打卡、月报、日报、部门框架、举报信箱、会议通知等企业应用并列展示。入口点击后进入会议笔记列表页。
业务规则:
1. 会议笔记入口应由企业工作台配置和用户权限控制,未启用或无权限时不展示。
2. 工作台底部 Tab 红点只展示可通知模块的未读通知总数;当前只统计“会议通知”,不统计会议笔记、日报、月报、打卡、部门框架和举报信箱。
3. 入口排序、图标和名称应支持后台配置,但客户端需限制为白名单内部路由,不能跳转任意外部地址。
### 9.14.2 会议笔记-列表.png
<img src="images/会议笔记-列表.png" alt="会议笔记-列表" width="360">
功能说明:
会议笔记列表展示“与我有关”的会议记录,顶部提供会议搜索。每条会议卡片展示会议主题、发起人、会议时间、参会人、会议时长和会议状态,并提供“会议笔记”按钮进入详情;部分卡片支持“查看更多”查看完整会议信息或参会人信息。
业务规则:
1. “与我有关”仅展示当前用户作为发起人、会议记录员、参会人或具备组织查看权限的会议,且会议必须是已结束状态;未开始、进行中和已取消会议不进入会议笔记列表。
2. 列表按会议开始时间倒序展示,今天、昨天、前天等相对日期应由客户端按本地时区展示,排序仍以后端时间戳为准。
3. 搜索支持按会议主题、发起人、参会人和会议地点检索;搜索结果仍必须受会议可见权限限制。
4. 已结束但会议记录员尚未上传笔记的会议仍需要在会议笔记列表展示,卡片只展示会议基础信息和已结束状态,进入详情后展示“暂无笔记”空状态。
5. 已结束会议可查看有权限访问的会议笔记;会议记录员进入详情可上传文件,普通参会人进入详情不展示上传按钮且只能预览已上传文件。
6. 参会人过多时列表只展示前几名并省略,点击“查看更多”进入详情查看完整信息。
### 9.14.3 会议笔记-发布页面.png
<img src="images/会议笔记-发布页面.png" alt="会议笔记-发布页面" width="360">
功能说明:
会议发布页用于创建会议并指定会议记录员。页面包含发起人、地点、会议记录员、参会人、开始时间、时长、会议主题等字段,底部通过“发起会议”按钮提交。
业务规则:
1. 发起人默认取当前登录用户,不允许普通用户手动伪造;具备代发权限时应明确展示实际发起人和创建人。
2. 发布页面字段顺序中,会议记录员应位于参会人上方;会议记录员默认取会议发起人,用户可按权限改选其他成员。
3. 会议记录员、参会人、开始时间、时长和会议主题为必填项;地点可选。
4. 参会人可选择“所有人”或指定成员。选择所有人时应按组织权限和企业规模做确认提示,避免误发全员会议。
5. 会议记录员默认拥有上传、替换、删除本人上传文件和查看全部会议笔记的权限;普通参会人只能查看和预览已上传文件,不提供上传入口。
6. 创建成功后应进入会议详情或会议笔记列表;需要通知参会人时统一走“会议通知”模块,会议笔记模块本身不产生移动端通知。重复点击“发起会议”需防抖和幂等处理。
### 9.14.4 会议笔记-详情页.png
<img src="images/会议笔记-详情页.png" alt="会议笔记-详情页" width="360">
功能说明:
会议详情页展示会议主题、状态、发起人、时间、地点、会议记录员、参会人和会议时长。下方“全部笔记(文件)”展示会议相关文件,文件项包含类型图标、文件名、大小、上传人、上传日期和下载按钮。底部提供“上传文件”按钮。
业务规则:
1. 会议基础信息应与会议通知模块共用同一份会议数据,避免会议时间、参会人或状态在两个模块展示不一致。
2. 文件列表按上传时间倒序展示;同名文件允许存在,但应保留唯一文件 ID、上传人和上传时间。
3. 文件类型需识别 PDF、Office 文档、图片、压缩包等常见格式;未知格式展示通用文件图标。
4. “上传文件”按钮只对会议记录员展示;如后续扩展管理员代传能力,必须由服务端显式下发上传权限。
5. 当前用户是会议记录员时,详情页展示上传文件按钮;当前用户只是参会人时,不展示上传按钮,文件操作只保留预览能力。
6. 下载和预览都必须校验当前用户是否仍具备会议可见权限,不能仅凭文件 ID 访问。
### 9.14.5 会议笔记-详情页(普通成员).png
<img src="images/会议笔记-详情页(普通成员).png" alt="会议笔记-详情页(普通成员)" width="360">
功能说明:
普通成员或普通参会人进入会议笔记详情时,可查看会议基础信息;当没有可见笔记文件时,文件区域展示空状态“暂无笔记”。普通参会人页面不展示上传按钮,已上传文件仅提供预览入口。
业务规则:
1. 普通参会人只可查看对自己开放的会议笔记文件;无文件或无权限时展示统一空状态。
2. 空状态应区分“暂无笔记”和“无权限查看”两类业务原因,但默认文案可统一为不泄露权限细节的提示。
3. 普通参会人不能通过前端构造请求上传、删除、替换或下载文件;服务端必须再次校验角色和会议权限。
4. 若会议后续新增了对该成员可见的笔记文件,列表和详情应能刷新出新文件。
### 9.14.6 会议笔记-详情页-在线预览.png
<img src="images/会议笔记-详情页-在线预览.png" alt="会议笔记-详情页-在线预览" width="360">
功能说明:
当文件支持在线预览时,文件项右侧展示“预览”按钮,文件状态显示“在线预览”。点击后进入文件预览页;不支持预览的文件仍展示下载按钮。
业务规则:
1. 支持预览的文件类型至少包括 PDF 和常见图片,Office 文档是否预览取决于后端转换能力。
2. 预览状态由服务端返回,客户端不得仅按扩展名判断是否可预览。
3. 预览入口和下载入口都需要受文件权限控制;预览失败时应允许用户回到详情页并选择下载。
4. 预览文件应使用短期授权地址或受控文件接口,禁止暴露长期有效的公开下载链接。
### 9.14.7 会议笔记-详情页-文件下载中.png
<img src="images/会议笔记-详情页-文件下载中.png" alt="会议笔记-详情页-文件下载中" width="360">
功能说明:
文件下载过程中,当前文件项展示“正在下载”、进度条和“取消”按钮;其他文件仍保持可下载状态。
业务规则:
1. 每个文件需独立展示下载进度、成功、失败和取消状态。
2. 下载中允许取消,取消后应停止网络请求并恢复可下载状态;已完成文件可进入系统打开或本地文件管理流程。
3. 下载失败需展示可重试状态,并保留失败原因用于问题排查。
4. 后台、断网、弱网场景下应保存下载任务状态,恢复网络后按产品策略继续或提示重试。
### 9.14.8 会议笔记-文件预览.png
<img src="images/会议笔记-文件预览.png" alt="会议笔记-文件预览" width="360">
功能说明:
文件预览页展示文档内容,顶部提供返回、标题和更多菜单。预览区域展示页码进度,例如 `1/213`;更多菜单包含举报和分享操作。
业务规则:
1. 文件预览页应支持多页文档翻页、缩放、滑动浏览和加载失败重试。
2. 页码、加载状态和错误状态必须与当前文件一致,切换文件或返回详情页后应清理旧状态。
3. 分享权限由会议和文件权限共同决定;无分享权限时隐藏分享入口或提示无权限。
4. 举报入口用于举报文件内容或会议资料,提交后应关联会议 ID、文件 ID、上传人和当前查看人。
5. 预览页不得缓存超出权限有效期的文件内容;退出登录、切换企业或权限被收回后必须清理本地可访问副本。
## 10. 个人资料、通用设置、隐私与青少年模式
### 10.1 我的.png
@@ -0,0 +1,147 @@
# IM 会议笔记产品原型需求文档
> 本文档根据 `docs/note/` 中的 8 张会议笔记产品原型图整理。文档格式与 `IM-产品原型需求文档.md` 保持一致,后续可合并到第 9 章“企业工作台与办公协同”中,建议插入在总经理信箱之后、个人资料与设置之前。
## 9.14 会议笔记
会议笔记是企业工作台下的办公协同模块,用于围绕会议记录会议资料、纪要文件和相关附件。功能需要和会议通知、参会人、会议状态及文件能力联动,保证发起人、会议记录员、参会人和普通成员在不同权限下看到合适的入口、文件列表和操作按钮。
通知与刷新规则:
1. 工作台底部 Tab 的红点数字需要与工作台内可接收通知的模块上下对应。当前原型中的红点 `3` 表示工作台下共有 3 条未读通知,这 3 条通知只归属到“会议通知”模块。
2. 当前只有“会议通知”模块接收移动端通知,并承担会议创建、会议变更、会议提醒、参会反馈等通知展示。
3. 打卡、月报、日报、部门框架、举报信箱、会议笔记等其他工作台模块不做移动端通知,也不产生独立通知红点。
4. 除会议通知外,其他模块在用户进入模块时调用各自接口获取最新数据并刷新页面;客户端不得依赖通知事件来更新这些模块。
5. 会议笔记的文件新增、预览、下载状态只在会议笔记列表/详情页内刷新展示,不向工作台 Tab 追加通知数。
### 9.14.1 会议笔记-工作台入口.png
<img src="images/会议笔记-工作台入口.png" alt="会议笔记-工作台入口" width="360">
功能说明:
工作台新增“会议笔记”入口,与打卡、月报、日报、部门框架、举报信箱、会议通知等企业应用并列展示。入口点击后进入会议笔记列表页。
业务规则:
1. 会议笔记入口应由企业工作台配置和用户权限控制,未启用或无权限时不展示。
2. 工作台底部 Tab 红点只展示可通知模块的未读通知总数;当前只统计“会议通知”,不统计会议笔记、日报、月报、打卡、部门框架和举报信箱。
3. 入口排序、图标和名称应支持后台配置,但客户端需限制为白名单内部路由,不能跳转任意外部地址。
### 9.14.2 会议笔记-列表.png
<img src="images/会议笔记-列表.png" alt="会议笔记-列表" width="360">
功能说明:
会议笔记列表展示“与我有关”的会议记录,顶部提供会议搜索。每条会议卡片展示会议主题、发起人、会议时间、参会人、会议时长和会议状态,并提供“会议笔记”按钮进入详情;部分卡片支持“查看更多”查看完整会议信息或参会人信息。
业务规则:
1. “与我有关”仅展示当前用户作为发起人、会议记录员、参会人或具备组织查看权限的会议,且会议必须是已结束状态;未开始、进行中和已取消会议不进入会议笔记列表。
2. 列表按会议开始时间倒序展示,今天、昨天、前天等相对日期应由客户端按本地时区展示,排序仍以后端时间戳为准。
3. 搜索支持按会议主题、发起人、参会人和会议地点检索;搜索结果仍必须受会议可见权限限制。
4. 已结束但会议记录员尚未上传笔记的会议仍需要在会议笔记列表展示,卡片只展示会议基础信息和已结束状态,进入详情后展示“暂无笔记”空状态。
5. 已结束会议可查看有权限访问的会议笔记;会议记录员进入详情可上传文件,普通参会人进入详情不展示上传按钮且只能预览已上传文件。
6. 参会人过多时列表只展示前几名并省略,点击“查看更多”进入详情查看完整信息。
### 9.14.3 会议笔记-发布页面.png
<img src="images/会议笔记-发布页面.png" alt="会议笔记-发布页面" width="360">
功能说明:
会议发布页用于创建会议并指定会议记录员。页面包含发起人、地点、会议记录员、参会人、开始时间、时长、会议主题等字段,底部通过“发起会议”按钮提交。
业务规则:
1. 发起人默认取当前登录用户,不允许普通用户手动伪造;具备代发权限时应明确展示实际发起人和创建人。
2. 发布页面字段顺序中,会议记录员应位于参会人上方;会议记录员默认取会议发起人,用户可按权限改选其他成员。
3. 会议记录员、参会人、开始时间、时长和会议主题为必填项;地点可选。
4. 参会人可选择“所有人”或指定成员。选择所有人时应按组织权限和企业规模做确认提示,避免误发全员会议。
5. 会议记录员默认拥有上传、替换、删除本人上传文件和查看全部会议笔记的权限;普通参会人只能查看和预览已上传文件,不提供上传入口。
6. 创建成功后应进入会议详情或会议笔记列表;需要通知参会人时统一走“会议通知”模块,会议笔记模块本身不产生移动端通知。重复点击“发起会议”需防抖和幂等处理。
### 9.14.4 会议笔记-详情页.png
<img src="images/会议笔记-详情页.png" alt="会议笔记-详情页" width="360">
功能说明:
会议详情页展示会议主题、状态、发起人、时间、地点、会议记录员、参会人和会议时长。下方“全部笔记(文件)”展示会议相关文件,文件项包含类型图标、文件名、大小、上传人、上传日期和下载按钮。底部提供“上传文件”按钮。
业务规则:
1. 会议基础信息应与会议通知模块共用同一份会议数据,避免会议时间、参会人或状态在两个模块展示不一致。
2. 文件列表按上传时间倒序展示;同名文件允许存在,但应保留唯一文件 ID、上传人和上传时间。
3. 文件类型需识别 PDF、Office 文档、图片、压缩包等常见格式;未知格式展示通用文件图标。
4. “上传文件”按钮只对会议记录员展示;如后续扩展管理员代传能力,必须由服务端显式下发上传权限。
5. 当前用户是会议记录员时,详情页展示上传文件按钮;当前用户只是参会人时,不展示上传按钮,文件操作只保留预览能力。
6. 下载和预览都必须校验当前用户是否仍具备会议可见权限,不能仅凭文件 ID 访问。
### 9.14.5 会议笔记-详情页(普通成员).png
<img src="images/会议笔记-详情页(普通成员).png" alt="会议笔记-详情页(普通成员)" width="360">
功能说明:
普通成员或普通参会人进入会议笔记详情时,可查看会议基础信息;当没有可见笔记文件时,文件区域展示空状态“暂无笔记”。普通参会人页面不展示上传按钮,已上传文件仅提供预览入口。
业务规则:
1. 普通参会人只可查看对自己开放的会议笔记文件;无文件或无权限时展示统一空状态。
2. 空状态应区分“暂无笔记”和“无权限查看”两类业务原因,但默认文案可统一为不泄露权限细节的提示。
3. 普通参会人不能通过前端构造请求上传、删除、替换或下载文件;服务端必须再次校验角色和会议权限。
4. 若会议后续新增了对该成员可见的笔记文件,列表和详情应能刷新出新文件。
### 9.14.6 会议笔记-详情页-在线预览.png
<img src="images/会议笔记-详情页-在线预览.png" alt="会议笔记-详情页-在线预览" width="360">
功能说明:
当文件支持在线预览时,文件项右侧展示“预览”按钮,文件状态显示“在线预览”。点击后进入文件预览页;不支持预览的文件仍展示下载按钮。
业务规则:
1. 支持预览的文件类型至少包括 PDF 和常见图片,Office 文档是否预览取决于后端转换能力。
2. 预览状态由服务端返回,客户端不得仅按扩展名判断是否可预览。
3. 预览入口和下载入口都需要受文件权限控制;预览失败时应允许用户回到详情页并选择下载。
4. 预览文件应使用短期授权地址或受控文件接口,禁止暴露长期有效的公开下载链接。
### 9.14.7 会议笔记-详情页-文件下载中.png
<img src="images/会议笔记-详情页-文件下载中.png" alt="会议笔记-详情页-文件下载中" width="360">
功能说明:
文件下载过程中,当前文件项展示“正在下载”、进度条和“取消”按钮;其他文件仍保持可下载状态。
业务规则:
1. 每个文件需独立展示下载进度、成功、失败和取消状态。
2. 下载中允许取消,取消后应停止网络请求并恢复可下载状态;已完成文件可进入系统打开或本地文件管理流程。
3. 下载失败需展示可重试状态,并保留失败原因用于问题排查。
4. 后台、断网、弱网场景下应保存下载任务状态,恢复网络后按产品策略继续或提示重试。
### 9.14.8 会议笔记-文件预览.png
<img src="images/会议笔记-文件预览.png" alt="会议笔记-文件预览" width="360">
功能说明:
文件预览页展示文档内容,顶部提供返回、标题和更多菜单。预览区域展示页码进度,例如 `1/213`;更多菜单包含举报和分享操作。
业务规则:
1. 文件预览页应支持多页文档翻页、缩放、滑动浏览和加载失败重试。
2. 页码、加载状态和错误状态必须与当前文件一致,切换文件或返回详情页后应清理旧状态。
3. 分享权限由会议和文件权限共同决定;无分享权限时隐藏分享入口或提示无权限。
4. 举报入口用于举报文件内容或会议资料,提交后应关联会议 ID、文件 ID、上传人和当前查看人。
5. 预览页不得缓存超出权限有效期的文件内容;退出登录、切换企业或权限被收回后必须清理本地可访问副本。
## 合并建议
1. 合并到 `IM-产品原型需求文档.md` 时,建议将本章插入到第 9 章“企业工作台与办公协同”的 `9.13 总经理信箱-文案提示.png` 之后。
2. 如果主文档继续按图片顺序编号,可将本章编号调整为 `9.14` 至 `9.21`;如果按业务模块编号,可保留 `9.14.x` 子编号。
3. 本文档引用的图片已经复制到 `docs/images/`,文件名均以 `会议笔记-` 开头,避免与现有 `工作台.png` 等图片重名。
@@ -0,0 +1,181 @@
# IM 会议笔记前后端逐图对接协议
> 版本:v1.0
> 日期:2026-09-05
> 需求依据:[IM 会议笔记产品原型需求文档](./IM-会议笔记产品原型需求文档.md)
> 公共协议依据:[IM 前后端逐图对接协议](./IM-前后端对接文档.md)
> 说明:本文档的图片标题和顺序与会议笔记产品原型完全一致。后续合并时建议作为第 9 章的 `9.14 会议笔记`,原主文档 `9.14 接口示例` 顺延为 `9.15 接口示例`。
## 9.14 会议笔记
会议笔记属于企业工作台模块,复用会议主数据和统一文件能力。服务端负责判断会议是否结束、当前用户是否与会议有关、是否为会议记录员,以及文件查看和操作权限;客户端只根据响应中的状态与 `permissions` 展示页面,不自行推导权限。
本模块遵循主文档第 0 章的公共约定:产品 HTTP 使用 `X-MeetingIM-APIKey`、`X-IM-Schema: 1`、`X-Request-ID` 和 `Authorization: Bearer <token>`;写操作额外使用 `Idempotency-Key`;成功和失败响应均使用统一包裹结构。
会议笔记不产生移动端通知、不写入会议通知未读数,也不增加工作台底部 Tab 红点。客户端进入会议笔记列表或详情页时主动请求最新数据;从上传、预览或下载页面返回后,只刷新当前会议笔记页面。
### 9.14.1 会议笔记-工作台入口.png
<img src="images/会议笔记-工作台入口.png" alt="会议笔记-工作台入口" width="360">
**状态:工作台接口需改造。**
- `GET /v1/im/workbench` 的 `modules` 增加 `code="meeting_notes"`、`route="/meeting-notes"`。服务端按租户功能开关和当前用户权限决定是否返回该入口。
- 会议笔记模块固定返回 `unread_count=0`,不参与 `GET /v1/im/bootstrap` 的 `entry_badges.workbench_total_unread` 汇总。
- 当前工作台红点只对应“会议通知”模块的未读通知。日报、月报、打卡、部门框架、举报信箱和会议笔记均不产生工作台通知数。
- 客户端点击入口后请求 `GET /v1/im/meeting-notes`。切换企业后必须清空原租户的会议笔记缓存并重新加载。
### 9.14.2 会议笔记-列表.png
<img src="images/会议笔记-列表.png" alt="会议笔记-列表" width="360">
**状态:新增。**
- 列表:`GET /v1/im/meeting-notes?query=&cursor=&limit=20`。接口只返回当前用户作为发起人、会议记录员或参会人的已结束会议,不返回未开始、进行中或已取消会议。
- `query` 支持匹配会议主题、发起人、会议记录员、参会人和地点;搜索范围仍受当前用户会议可见权限限制。
- 列表按 `end_at` 倒序排列,分页使用不透明 `cursor`。客户端不得用本地时间过滤会议状态或自行扩大搜索范围。
- 已结束但尚未上传笔记的会议仍返回,使用 `note_status="empty"`、`file_count=0`;客户端展示会议基础信息,进入详情后显示“暂无笔记”。
- `permissions.can_view=true` 决定是否允许进入详情;`permissions.can_upload` 只对当前会议记录员为 `true`。普通参会人可以进入详情,但不显示上传按钮。
- `state="ended"` 是服务端按会议状态及服务端当前时间计算或持久化的有效状态。服务端必须以 `end_at` 和取消状态为准,不能信任客户端传入的“已结束”条件。
### 9.14.3 会议笔记-发布页面.png
<img src="images/会议笔记-发布页面.png" alt="会议笔记-发布页面" width="360">
**状态:现有会议创建接口需改造。**
- 页面字段顺序为发起人、地点、会议记录员、参会人、开始时间、时长、会议主题。会议记录员默认显示当前会议发起人。
- 创建仍使用 `POST /v1/im/meetings`,请求新增 `recorder_user_id`。客户端未传或传空字符串时,服务端默认使用当前登录用户;客户端不能通过 `organizer_id` 伪造发起人。
- 服务端校验会议记录员属于当前租户且账号有效。首版允许会议记录员是发起人或参会人;若选择了非参会人,服务端自动加入参会人列表并在响应中返回最终名单。
- 服务端保存的会议必须有 `recorder_user_id`;创建请求可省略该字段以使用发起人默认值。`attendee_user_ids`、`start_at`、`end_at` 和 `title` 为必填业务数据,`location` 可为空,且 `end_at` 必须晚于 `start_at`。
- 创建成功后的会议通知仍统一归属“会议通知”模块;会议笔记模块不额外生成通知或未读数。
- `PATCH /v1/im/meetings/{meeting_id}` 支持在会议结束前修改 `recorder_user_id`,并使用 `version` 做乐观锁。会议结束后返回 `MEETING_CLOSED`。
### 9.14.4 会议笔记-详情页.png
<img src="images/会议笔记-详情页.png" alt="会议笔记-详情页" width="360">
**状态:新增。**
- 详情:`GET /v1/im/meetings/{meeting_id}/notes`,一次返回会议基础信息、发起人、会议记录员、参会人、文件列表、笔记状态和当前用户权限。
- 会议基础信息与会议通知页面共用 `im_meetings` 和 `im_meeting_participants` 数据,不复制会议标题、时间和成员快照作为另一份可编辑主数据。
- 当前用户为会议记录员且会议已结束时,`permissions.can_upload=true`,客户端展示“上传文件”;其他用户不展示该按钮。
- 上传文件先调用 `POST /v0/file/u/`,Multipart 携带 `purpose=meeting_note` 和 `meeting_id`;上传成功取得 `file_id` 后,再调用 `POST /v1/im/meetings/{meeting_id}/notes/files` 建立会议笔记文件记录。
- 文件登记成功后才进入详情列表。上传成功但登记失败的临时文件不得展示,并由服务端按临时文件 TTL 回收。
- 当前会议记录员可替换或删除自己登记的文件;所有写操作均需 `Idempotency-Key`,服务端记录操作审计。
### 9.14.5 会议笔记-详情页(普通成员).png
<img src="images/会议笔记-详情页(普通成员).png" alt="会议笔记-详情页(普通成员)" width="360">
**状态:新增权限响应。**
- 普通参会人调用与 9.14.4 相同的详情接口。响应中 `permissions.can_view=true`、`can_upload=false`、`can_replace=false`、`can_delete=false`。
- 没有已上传文件时返回 `note_status="empty"` 和空数组 `files=[]`,客户端展示“暂无笔记”,不把空状态当作请求失败。
- 有文件时普通参会人只展示文件名、大小、类型、上传人、上传时间和预览入口,不展示上传、替换、删除按钮。
- 服务端必须在文件登记、替换、删除、预览和下载接口逐次校验会议关系及角色。隐藏客户端按钮不能代替服务端鉴权。
- 与会议无关、已离开可见范围或跨租户的用户统一返回 `RESOURCE_NOT_FOUND` 或 `FORBIDDEN`,不得通过 `meeting_id`、`note_file_id` 或 `file_id` 探测资源。
### 9.14.6 会议笔记-详情页-在线预览.png
<img src="images/会议笔记-详情页-在线预览.png" alt="会议笔记-详情页-在线预览" width="360">
**状态:基础文件读取已实现;会议笔记 ACL 和预览转换新增。**
- 文件项返回 `preview.status=ready|processing|unsupported|failed`、`preview.mode=native|converted|download_only` 和可选 `preview_file_id`,客户端根据状态选择直接预览、轮询转换状态或提示暂不支持。
- 原生可预览文件使用受认证的 `GET /v0/file/s/{file_id}`;转换预览使用 `GET /v0/file/s/{preview_file_id}`。两个接口都必须校验当前用户仍有会议笔记查看权限。
- 服务端不得返回长期有效的公共下载 URL。客户端缓存键至少包含 `tenant_code + user_id + file_id + etag`,退出登录或切换企业时清理受控缓存。
- `preview.status="processing"` 时客户端可在当前详情页低频刷新,建议 2 秒后开始、最长 30 秒;离开页面立即停止轮询。
### 9.14.7 会议笔记-详情页-文件下载中.png
<img src="images/会议笔记-详情页-文件下载中.png" alt="会议笔记-详情页-文件下载中" width="360">
**状态:复用统一下载任务;会议笔记 ACL 新增。**
- 下载使用 `GET /v0/file/s/{file_id}`,Header 传 API Key 与 `X-MeetingIM-Auth: token <base64_token>`,并支持 `Range` 和 `If-Range` 断点续传。
- 下载任务保存 `file_id,etag,total_bytes,downloaded_bytes,temp_path,state`。Token 更新后使用新 Token 续传,禁止把 Token 写入任务文件或日志。
- 服务端在每次下载及续传时重新校验会议可见权限。返回 `FILE_FORBIDDEN` 时客户端停止重试;返回 `FILE_UNAVAILABLE` 时标记文件失效并刷新详情。
- 下载进度属于客户端本地状态,不更新会议笔记业务状态、不产生工作台通知,也不写入未读数。
### 9.14.8 会议笔记-文件预览.png
<img src="images/会议笔记-文件预览.png" alt="会议笔记-文件预览" width="360">
**状态:客户端预览页;服务端复用 9.14.6 文件读取协议。**
- 预览页展示服务端返回的安全文件名和实际内容;标题不得直接信任未经清理的上传文件名。
- 图片、PDF、文本等受支持类型在应用内预览;Office 等类型优先使用受控转换文件,无法转换时按 `preview.mode="download_only"` 提示下载后打开。
- 文件被删除、审核拒绝、转换失败或权限撤销时,服务端返回稳定 `reason`,客户端关闭预览并刷新详情,不能继续显示旧的授权链接。
- 从预览页返回详情后调用 `GET /v1/im/meetings/{meeting_id}/notes` 获取最新文件状态。
### 9.14.9 接口示例
以下示例用于会议笔记 HTTP 联调快速查阅。成功响应统一包裹为 `{"code":0,"data":示例,"request_id":"...","server_time":"..."}`,下表只展示 `data`。
#### 9.14.9.1 工作台、会议记录员与会议笔记
| 接口 | 输入示例 | 成功 `data` 示例 |
| --- | --- | --- |
| `GET /v1/im/workbench` 需改造 | Query only | `{"layout_version":2,"modules":[{"code":"meeting_notifications","name":"会议通知","route":"/meetings","unread_count":3,"enabled":true,"permission":"enabled"},{"code":"meeting_notes","name":"会议笔记","route":"/meeting-notes","unread_count":0,"enabled":true,"permission":"enabled"}]}` |
| `POST /v1/im/meetings` 需改造 | Header `Idempotency-Key`;Body `{"title":"项目周会","start_at":"2026-09-05T10:00:00+08:00","end_at":"2026-09-05T10:30:00+08:00","location":"会议室 A","recorder_user_id":"usr_me","attendee_user_ids":["usr_2"],"reminder_minutes":[10]}` | `{"meeting_id":"mtg_1","title":"项目周会","organizer":{"user_id":"usr_me","display_name":"张三"},"recorder":{"user_id":"usr_me","display_name":"张三"},"start_at":"2026-09-05T02:00:00Z","end_at":"2026-09-05T02:30:00Z","state":"scheduled","participants":[{"user_id":"usr_me","response":"accepted"},{"user_id":"usr_2","response":"pending"}],"permissions":{"can_update":true,"can_cancel":true},"version":1}` |
| `PATCH /v1/im/meetings/{meeting_id}` 需改造 | Header `Idempotency-Key`;Body `{"recorder_user_id":"usr_2","version":1}` | `{"meeting_id":"mtg_1","recorder":{"user_id":"usr_2","display_name":"李四"},"version":2,"updated_at":"2026-09-05T01:30:00Z"}` |
| `GET /v1/im/meeting-notes?query=&cursor=&limit=20` 新增 | Query only | `{"items":[{"meeting_id":"mtg_1","title":"项目周会","organizer":{"user_id":"usr_me","display_name":"张三"},"recorder":{"user_id":"usr_me","display_name":"张三"},"start_at":"2026-09-05T02:00:00Z","end_at":"2026-09-05T02:30:00Z","location":"会议室 A","state":"ended","participant_count":2,"participant_names":["张三","李四"],"note_status":"empty","file_count":0,"permissions":{"can_view":true,"can_upload":true}}],"next_cursor":"","has_more":false}` |
| `GET /v1/im/meetings/{meeting_id}/notes` 新增 | Path meeting ID | `{"meeting":{"meeting_id":"mtg_1","title":"项目周会","organizer":{"user_id":"usr_me","display_name":"张三"},"recorder":{"user_id":"usr_me","display_name":"张三"},"start_at":"2026-09-05T02:00:00Z","end_at":"2026-09-05T02:30:00Z","location":"会议室 A","state":"ended","participants":[{"user_id":"usr_me","display_name":"张三"},{"user_id":"usr_2","display_name":"李四"}]},"note_status":"available","files":[{"note_file_id":"mnote_1","file_id":"file_1","name":"会议纪要.pdf","size":204800,"mime":"application/pdf","uploader":{"user_id":"usr_me","display_name":"张三"},"created_at":"2026-09-05T03:00:00Z","version":1,"preview":{"status":"ready","mode":"native","preview_file_id":"file_1"},"permissions":{"can_preview":true,"can_download":true,"can_replace":true,"can_delete":true}}],"permissions":{"can_view":true,"can_upload":true},"version":1}` |
| `POST /v0/file/u/` | Multipart 文件;字段 `purpose=meeting_note`、`meeting_id=mtg_1`;Header `X-MeetingIM-Auth` | `{"ctrl":{"code":200,"params":{"file_id":"file_1","url":"/v0/file/s/file_1","name":"会议纪要.pdf","size":204800,"mime":"application/pdf"}}}` |
| `POST /v1/im/meetings/{meeting_id}/notes/files` 新增 | Header `Idempotency-Key`;Body `{"file_id":"file_1"}` | `{"note_file_id":"mnote_1","meeting_id":"mtg_1","file_id":"file_1","name":"会议纪要.pdf","size":204800,"mime":"application/pdf","created_at":"2026-09-05T03:00:00Z","version":1,"preview":{"status":"processing","mode":"converted"}}` |
| `PUT /v1/im/meetings/{meeting_id}/notes/files/{note_file_id}` 新增 | Header `Idempotency-Key`;Body `{"file_id":"file_2","version":1}` | `{"note_file_id":"mnote_1","meeting_id":"mtg_1","file_id":"file_2","name":"会议纪要-v2.pdf","version":2,"updated_at":"2026-09-05T03:10:00Z"}` |
| `DELETE /v1/im/meetings/{meeting_id}/notes/files/{note_file_id}` 新增 | Header `Idempotency-Key`;Query `version=2` | `{"note_file_id":"mnote_1","deleted":true,"deleted_at":"2026-09-05T03:20:00Z"}` |
| `GET /v0/file/s/{file_id}` | Header `X-MeetingIM-Auth`;可带 `Range: bytes=0-` | 返回文件字节流及 `Content-Type`、`Content-Length`、`ETag`、`Accept-Ranges`;失败返回 `FILE_FORBIDDEN` 或 `FILE_UNAVAILABLE` |
### 9.14.10 字段与状态约定
| 字段 | 取值 | 前端处理 |
| --- | --- | --- |
| `meeting.state` | `scheduled`、`in_progress`、`ended`、`canceled` | 会议笔记列表只展示 `ended` |
| `note_status` | `empty`、`available` | `empty` 展示“暂无笔记”,`available` 展示文件列表 |
| `preview.status` | `ready`、`processing`、`unsupported`、`failed` | 分别进入预览、短时刷新、仅下载提示、失败提示 |
| `preview.mode` | `native`、`converted`、`download_only` | 使用原文件、转换文件或仅提供下载 |
| `permissions.can_view` | Boolean | 是否可以查看当前会议笔记 |
| `permissions.can_upload` | Boolean | 是否显示并允许使用上传按钮,仅会议记录员为 `true` |
| `permissions.can_replace` | Boolean | 是否允许替换当前笔记文件 |
| `permissions.can_delete` | Boolean | 是否允许删除当前笔记文件 |
| `permissions.can_preview` | Boolean | 是否允许预览当前文件 |
| `permissions.can_download` | Boolean | 是否允许下载当前文件;首版普通参会人若产品要求“只能预览”,该值返回 `false` |
客户端必须以服务端返回的 `permissions` 为准。特别是普通参会人“只能预览”的要求,由文件项 `can_preview=true,can_download=false` 表达;即使预览底层使用文件读取接口,客户端也不得提供导出、系统分享或保存到相册入口。
### 9.14.11 错误码与客户端处理
| `reason` | 典型 HTTP 状态 | 场景 | 客户端处理 |
| --- | --- | --- | --- |
| `MEETING_NOT_ENDED` | 409 | 未结束会议上传或登记笔记 | 提示“会议结束后可上传笔记”,刷新会议详情 |
| `MEETING_CLOSED` | 409 | 会议结束后修改记录员或会议数据 | 切换为只读并刷新详情 |
| `NOT_MEETING_RECORDER` | 403 | 非记录员上传、替换或删除文件 | 隐藏操作入口并刷新权限 |
| `MEETING_NOTE_VERSION_CONFLICT` | 409 | 替换或删除时版本过期 | 拉取最新详情,不自动覆盖 |
| `MEETING_NOTE_FILE_INVALID` | 400 | 文件未上传完成、用途不符或不属于当前租户 | 提示重新上传 |
| `FILE_PREVIEW_PROCESSING` | 409 | 转换预览尚未完成 | 保持当前页并按退避策略刷新 |
| `FILE_PREVIEW_UNSUPPORTED` | 422 | 文件类型不支持在线预览 | 根据 `can_download` 决定是否提供下载 |
| `FILE_FORBIDDEN` | 403 | 当前用户无文件访问权限 | 关闭预览或停止下载并刷新详情 |
| `FILE_UNAVAILABLE` | 404/410 | 文件被删除、失效或审核拒绝 | 标记不可用并刷新文件列表 |
| `RESOURCE_NOT_FOUND` | 404 | 会议或笔记不可见、跨租户访问 | 返回列表,不提示资源是否真实存在 |
### 9.14.12 前后端联调与验收要点
1. 发起会议时不传 `recorder_user_id`,响应中的记录员必须默认为当前发起人;指定其他记录员时,响应返回最终记录员和参会人名单。
2. 会议笔记列表只包含与当前用户有关的已结束会议;已结束但无文件的会议仍展示,详情为空状态。
3. 记录员进入详情显示上传按钮,普通参会人进入同一会议不显示上传按钮;直接调用写接口仍必须被服务端拒绝。
4. 普通参会人只允许在线预览时,响应为 `can_preview=true,can_download=false`,客户端不展示下载、分享或导出入口。
5. 文件上传成功但会议笔记登记失败时,详情列表不出现该文件;重复登记同一幂等键返回首次结果。
6. 文件替换和删除必须校验 `version`;旧版本请求返回冲突,不能覆盖新文件。
7. 进入列表、进入详情以及从上传/预览/下载页面返回时均主动刷新;断开 WebSocket 不影响会议笔记数据获取。
8. 会议笔记新增、替换、删除均不产生 Push、会议通知未读或工作台红点;工作台红点只随会议通知模块变化。
9. 跨租户、无关用户和已撤销权限用户无法通过会议 ID 或文件 ID 查看、预览、下载会议笔记。
10. 切换企业或退出登录后,会议笔记列表、详情、预览文件和下载临时数据按租户及账号隔离清理。
## 合并建议
1. 将本文 `9.14 会议笔记` 整段插入现有《IM 前后端逐图对接协议》第 9 章的 `9.13 总经理信箱-文案提示.png` 之后。
2. 将现有 `9.14 接口示例` 及其子章节顺延为 `9.15`,并把本文 `9.14.9.1` 的接口行合并到新的第 9 章接口示例表中。
3. 同步扩展会议数据模型:`im_meetings` 增加会议记录员字段,并新增会议笔记文件关联表;具体表结构在系统设计和数据库变更文档中统一维护。
4. 主文档第 9 章现有会议创建示例需要补充 `recorder_user_id`,工作台示例需要增加 `meeting_notes` 模块并明确其 `unread_count=0`。
@@ -0,0 +1,159 @@
# IM 多开跨 Pod 实时投递压测方案
日期:2026-09-13
## 1. 目标
验证 IM Server 使用 Redis realtime fanout 后,在多 Pod、多端登录、群消息和群业务事件场景下的实时投递能力。
本次压测重点观察:
1. 跨 Pod 在线消息是否能投递到所有目标 WS 连接。
2. 群消息在多 Pod 下的端到端延迟。
3. Redis Pub/Sub 发布、接收、投递失败和本地无连接的比例。
4. Hub 队列满、WS 写队列满、Redis 发布失败等异常是否可观测。
5. Pod 扩缩容、单 Pod 重启时是否出现明显丢消息或延迟尖刺。
## 2. 前置环境
建议至少准备:
- IM Server:3 个 Pod。
- Redis:独立实例或 Redis Cluster,开启 `group_fanout_enabled`。
- MySQL:使用压测租户库。
- 监控:开启 `/metrics`,采集 Prometheus 指标。
- 账号:准备足够多的群成员账号 CSV,格式为 `username,password[,token]`。
- 群 topic:提前创建一个大群,并把压测账号加入该群。
每个 Pod 必须配置不同的节点标识:
```json
"node_id": "im-pod-1"
```
或通过环境变量:
```shell
IM_NODE_ID=im-pod-1
```
## 3. 新增服务端指标
本次改造新增 Prometheus 指标:
```text
realtime_fanout_events_total{stage,kind,target_type,status}
realtime_fanout_delivery_duration_seconds_bucket{kind,target_type}
```
常用查询:
```promql
sum by (stage, kind, target_type, status) (rate(realtime_fanout_events_total[1m]))
```
```promql
histogram_quantile(
0.95,
sum by (le, kind, target_type) (rate(realtime_fanout_delivery_duration_seconds_bucket[5m]))
)
```
重点关注 status:
- `publish/ok`:本 Pod 成功发布到 Redis。
- `publish/error`:Redis 发布失败。
- `publish/skip_native_cluster`:启用了 Tinode 原生 cluster,跳过 Redis topic fanout。
- `receive/ok`:其他 Pod 收到 Redis fanout。
- `receive/malformed`:收到无法解析的消息。
- `deliver/local_delivered`:本 Pod 找到本地 WS 并投递。
- `deliver/no_local_session`:本 Pod 没有目标本地连接,属于正常情况。
- `deliver/hub_full`:Hub routeSrv 队列满,需要重点告警。
- `deliver/skip_no_session_store`:session store 未初始化。
## 4. 压测脚本
脚本位置:
```shell
loadtest/realtime_fanout_probe.go
```
运行示例:
```shell
go run ./loadtest/realtime_fanout_probe.go \
-ws "ws://im-pod-1.example.com/v0/channels,ws://im-pod-2.example.com/v0/channels,ws://im-pod-3.example.com/v0/channels" \
-apikey "$IM_API_KEY" \
-accounts loadtest/users.csv \
-topic grpXXXXXXXXXXXX \
-subscribers 3000 \
-publishers 10 \
-rate 200 \
-duration 10m \
-ramp 2m
```
脚本行为:
1. 按 CSV 账号建立 WS 连接。
2. 将连接均匀分布到 `-ws` 提供的多个 Pod 地址。
3. 每个连接登录并订阅同一个群 topic。
4. 从前 `-publishers` 个连接中选择发布者。
5. 按 `-rate` 总速率发布带 `probe_id` 和 `sent_at_unix_nano` 的群消息。
6. 所有订阅连接统计收到的 `{data}`,输出接收数、丢失率、p50/p95/p99/max 延迟。
输出示例:
```text
==== realtime fanout probe summary ====
published=120000 active_subscribers=3000 expected_deliveries=360000000 received=359998200 lost=1800 loss_rate=0.0005% unique_messages_seen=120000 errors=0 ctrl_acks=120000
latency p50=18ms p95=65ms p99=140ms max=900ms samples=359998200
```
## 5. 压测阶梯
建议按下面阶梯逐步推进,每个阶梯至少跑 10 分钟:
| 阶段 | Pod | 在线连接 | 发布者 | 总 QPS | 目标 |
| --- | --- | ---: | ---: | ---: | --- |
| S1 | 3 | 300 | 1 | 10 | 验证链路正确性 |
| S2 | 3 | 3000 | 10 | 100 | 验证常规群聊压力 |
| S3 | 3 | 10000 | 20 | 300 | 验证大群在线 fanout |
| S4 | 6 | 30000 | 50 | 500 | 验证扩容后的 Redis fanout |
| S5 | 6 | 30000 | 50 | 1000 | 探测上限和队列瓶颈 |
## 6. 通过标准
建议初始通过标准:
- `publish/error` 为 0。
- `receive/malformed` 为 0。
- `deliver/hub_full` 为 0。
- 压测脚本统计丢失率低于 0.01%。
- p95 端到端延迟低于 200ms。
- p99 端到端延迟低于 500ms。
- Pod CPU、内存、GC 无持续上升趋势。
- Redis CPU 不超过 70%,网络出入带宽有余量。
## 7. 故障注入
基础压测通过后执行:
1. 压测中重启一个 IM Pod,观察其他 Pod 是否继续投递。
2. 压测中扩容 Pod 数,观察 `receive/ok` 是否按 Pod 数增加。
3. 临时降低 Hub/WS 队列容量或制造慢客户端,确认 `hub_full`、WS 写失败日志可见。
4. Redis 短暂不可用,确认 `publish/error` 增加,恢复后新消息继续投递。
## 8. 结果记录
每轮记录:
- Git commit。
- IM Pod 数量和规格。
- Redis 规格。
- 在线连接数、发布 QPS、消息大小。
- 脚本输出摘要。
- Prometheus p95/p99。
- `publish/error`、`deliver/hub_full`、WS 写失败数量。
- Pod 重启/扩容期间的最大延迟和丢失率。
@@ -0,0 +1,488 @@
# IM 多开与跨 Pod 消息投递改造方案
> 日期:2026-09-12
> 适用范围:IMServer、Admin Server / HotGo、Redis、Kafka、JPush
> 关联文档:`docs/对接文档最新/IM-前后端对接文档.md`、`docs/IM-服务端架构与新功能开发指南.md`、`docs/IM-Redis三层数据分层改造方案.md`
## 1. 背景与目标
多开指同一用户在多设备、多 App 实例、多浏览器窗口或同设备多连接同时在线。服务端必须保证任意一个 Pod 收到的实时消息、业务通知、在线状态变化和账号治理命令,都能正确投递到同一租户内该用户在所有相关 Pod 上的 WebSocket / Long Polling 连接。
当前代码已有以下基础:
- `SessionStore` 管理本 Pod 内的实时连接。
- Redis presence 记录用户在线状态、session 集合和节点维度在线用户。
- 群消息已有 `redis_fanout.go`,通过 Redis Pub/Sub 通知其他 Pod。
- 群消息已拆到 `im_group_messages`,并具备 Redis seq、`client_mid` 幂等和 Kafka 异步持久化基础。
- `im_business_events.go` 仍使用租户库 business event outbox 做本地轮询投递。
本功能仍处于开发完善阶段,尚未上线。本次改动按“完整补齐多开跨 Pod 能力”处理,不按线上热修补或灰度兼容旧缺陷的思路处理。
本次改造目标:
1. 不再依赖租户库 business event outbox 作为 C 端实时通知主链路。
2. 建立统一的跨 Pod 实时投递总线,覆盖个人消息、群消息、系统消息、好友申请、好友状态、群治理、账号治理等需要多开同步的数据。
3. IMServer 只把本 Pod 连接保存在内存,全局在线、节点路由和跨 Pod 投递统一依赖 Redis。
4. Admin Server 不直连 IM 租户库,不直接操作用户 WS;后台写操作通过 Kafka Command 到 IMServer,由 IMServer 更新租户库并通过实时投递总线通知在线端。
5. 离线 Push 只做提醒和唤醒,不作为消息正文或业务状态真源。
## 1.1 前置确认
实施前必须确认生产部署是否启用 Tinode 原生 `cluster_config`:
- 如果未启用,Redis realtime dispatcher 是跨 Pod 实时投递唯一主链路。
- 如果启用,Topic 消息不要同时走 Tinode 原生 Topic master/proxy 和 Redis 广播式 dispatcher,否则可能重复投递。当前代码实现策略是:`globals.cluster != nil` 时,Topic 消息 fanout 不再额外发布 Redis realtime;session control 仍可通过 Redis 做幂等控制补充。
未启用原生 cluster 时,每个 Pod 必须配置唯一节点 ID。优先级为:
1. `node_id` 配置项。
2. `IM_NODE_ID` 环境变量。
3. `HOSTNAME` 环境变量。
4. 本地开发兜底值 `local`。
多 Pod 环境不能让所有实例都使用 `local`,否则 Redis fanout 会误认为其他 Pod 是本节点并跳过投递。
## 2. 核心结论
跨 Pod 实时投递使用 Redis Pub/Sub 或 Redis Stream,不使用数据库 outbox,也不使用 Kafka 作为 WebSocket fanout 主链路。
Kafka 仍保留以下职责:
- Admin Server 到 IMServer 的后台写命令。
- IMServer 到 Admin Server 的命令结果、审核结果、摘要同步。
- 私聊/群聊消息异步持久化。
- 文件安全检测、离线 Push 任务、审计、死信。
Redis 实时投递总线负责:
- 这个 Pod 收到消息后,通知其他 Pod。
- 其他 Pod 根据本地 `SessionStore` 找到真实 WS 连接并投递。
- 通过事件 ID / 消息 ID / `topic+seq` 做短期去重,优先复用已有 `ClaimShortState` / `ClaimShortStateValue` Redis 原语。
MySQL 负责:
- 最终状态和历史补偿。
- 用户、好友、群、系统通知、消息、文件、设备会话等可追溯数据。
- 客户端重连后按 `topic+seq`、通知 ID 或业务 ID 拉取缺口。
## 3. 总体架构
```text
Client A
-> Pod-1 WebSocket
-> Session / Topic / HTTP handler
-> 权限、幂等、seq、状态写入
-> 投递本 Pod 本地 sessions
-> Redis realtime bus
channel/stream: im:{tenant_id}:rt:{kind}
payload: envelope + server message
-> Pod-2 / Pod-3 subscriber
-> 按 tenant + target user/topic 找本 Pod sessions
-> queueOut 到对应 WS / Long Polling
Client B reconnect
-> hi -> token login -> sub me/topics
-> 按 seq/read/recv 或 notification cursor 从 MySQL/Redis 热缓存补齐
```
Admin Server 写操作:
```text
Admin Server
-> 校验后台权限
-> 写 hotgo 命令状态
-> Kafka im.admin.command
-> IMServer 任一 Pod 消费
-> 更新租户 MySQL / Redis / Topic 权限快照
-> Redis realtime bus 通知在线 C 端
-> Kafka im.admin.command.result 回写 Admin
```
## 4. 跨 Pod 实时投递总线设计
### 4.1 统一 Envelope
所有需要投递到其他 Pod 的实时事件统一包装:
```json
{
"event_id": "rt_01K...",
"tenant_id": "1",
"tenant_code": "default",
"kind": "message|topic_event|user_event|presence|session_control",
"target_type": "user|topic|device|session",
"target": "usr_xxx 或 grp_xxx 或 p2p_xxx",
"from": "usr_sender",
"source_node": "im-pod-1",
"skip_sid": "sid_sender",
"dedup_key": "message:grp_xxx:123",
"occurred_at": "2026-09-08T10:00:00.000Z",
"payload": {}
}
```
字段约定:
- `event_id`:全局唯一,消费者短期去重。
- `tenant_id`:必填,Redis key 和投递过滤必须带租户。
- `kind`:决定消费者如何解释 payload。
- `target_type=user`:投递给某用户在本 Pod 的所有连接。
- `target_type=topic`:投递给本 Pod 已订阅该 Topic 的连接。
- `target_type=device`:投递或控制某用户指定设备连接。
- `target_type=session`:精确控制某个 Session,主要用于本 Pod 本地,跨 Pod 可选。
- `skip_sid`:避免给发起连接重复投递,除非业务要求多端含当前端也收到。
- `dedup_key`:短期幂等键,例如 `msg:topic:seq`、`friend_request:id:version`。
- `payload`:可以是 Tinode `ServerComMessage` 的 JSON,也可以是业务刷新事件。
### 4.2 Redis Channel 建议
首版可以使用 Pub/Sub,要求客户端重连补偿兜底:
```text
im:{tenant_id}:rt:user
im:{tenant_id}:rt:topic
im:{tenant_id}:rt:presence
im:{tenant_id}:rt:session_control
```
如果需要更强的可观测和短暂重放,可升级为 Redis Stream:
```text
im:{tenant_id}:stream:rt
consumer_group = imserver:{node_id}
```
选择建议:
- 在线消息 fanout:Pub/Sub 足够,丢失后客户端按 seq 补偿。
- 踢设备、封禁、强制退出:建议 Pub/Sub + Redis control key 双写,避免订阅瞬断导致控制命令遗漏。
- Admin 重要业务变更:状态已落 MySQL,实时事件可以 Pub/Sub;客户端重进页面重新 GET。
### 4.3 本 Pod 投递规则
收到跨 Pod envelope 后:
1. 丢弃 `source_node == currentNodeID()` 的回环事件,除非显式需要自消费。
2. 使用 Redis `rt:dedup:{event_id}` 或本地 LRU 做短期去重。
3. 校验 `tenant_id`,不得跨租户投递。
4. 根据 `target_type` 选择本地 sessions:
- `user`:`session.uid == target && session.tenantID == tenantID`
- `topic`:`session.hasSub(target) && session.tenantID == tenantID`
- `device`:`session.uid == uid && session.deviceID == device_id`
5. 对每个匹配 Session 调用 `queueOut`。
6. 队列满时记录指标,不阻塞 Redis 订阅协程;客户端靠重连补偿。
## 5. 哪些数据需要多开处理
| 类别 | 场景 | 是否需要跨 Pod 实时投递 | 目标 | 最终补偿来源 | 说明 |
| --- | --- | --- | --- | --- | --- |
| 个人消息 | P2P 文本、图片、语音、文件、名片、通话邀请消息 | 是 | P2P Topic 在线成员、发送人其他端 | 私聊消息表 / Topic seq | Pod-1 收到发送后,本 Pod 投递并通过 Redis 投递到接收人和发送人其他端所在 Pod。 |
| 群消息 | 群文本、媒体、文件、@、合并转发 | 是 | 群 Topic 在线成员 | `im_group_messages` / Redis 热消息 | 已有群 fanout 雏形,需要纳入统一 envelope 和节点去重。 |
| 发送回执 | `pub` ack、消息发送成功 seq | 当前连接本地为主,发送人其他端可选 | 发送人其他端 | 消息表 / seq | 当前连接必须收到 `ctrl 202`;其他端同步消息用 `{data}`,不依赖 ctrl。 |
| 送达/已读 | `note recv/read` | 是 | 对端所有在线端、本人其他端 | subscription 游标 | 多端 read/recv 取最大值,所有端刷新气泡状态和未读数。 |
| 正在输入 | `kp/kpa` 临时状态 | 是,但 TTL 短 | 对端在线端或群内订阅端 | 无 | 不落库,允许丢失,限频。 |
| 消息撤回/编辑 | recall、revision | 是 | Topic 在线成员 | 消息版本表 / 消息状态 | HTTP 成功后发 topic 事件,在线端刷新 `topic+seq+version`。 |
| 删除/清空记录 | 当前用户视图删除 | 是,用户维度 | 本人所有端 | 删除游标 / del 记录 | 只影响操作者多端,不影响对方。 |
| 系统消息 | 系统通知列表新增、已读 | 是 | 目标用户所有端 | `im_system_notifications` | 新增需要提醒所有端;已读要同步清红点。 |
| 好友申请 | 创建、同意、拒绝 | 是 | 申请人、被申请人所有端 | 好友申请表、系统通知表 | 刷新新朋友列表、bootstrap badge、会话 context。 |
| 好友关系 | 添加、删除好友、拉黑/取消拉黑、备注 | 是 | 双方或操作者所有端 | 好友/黑名单表 | 输入区权限、会话标题、通讯录和黑名单需要同步。 |
| 好友在线状态 | 上线、离线、最近活跃 | 是 | 订阅相关 P2P/me Topic 的在线端 | Redis presence | 不应扫库;presence 以 Redis TTL 为准。 |
| 群成员 | 入群、退群、踢人、邀请审批 | 是 | 群 Topic 在线成员、相关用户所有端 | 群成员表、订阅表 | 同步群人数、成员列表、会话权限和系统提示。 |
| 群角色 | 群主转让、管理员增删 | 是 | 群 Topic 在线成员、目标用户所有端 | 群成员角色表 | 必须刷新 Topic `perUser` 权限快照和 Redis 成员缓存。 |
| 群禁言 | 全员禁言、成员禁言/解禁 | 是 | 群 Topic 在线成员、被禁言用户所有端 | 群治理/禁言表 | 输入框权限不能只靠前端本地状态。 |
| 群公告 | 发布、更新、已读 | 是 | 群 Topic 在线成员;已读为本人所有端 | 群公告表、公告已读表 | 顶部入口、公告详情和红点同步。 |
| 群资料 | 名称、头像、简介 | 是 | 群 Topic 在线成员 | 群表 / Topic desc | 会话标题、列表摘要、成员页都要刷新。 |
| 群文件 | 上传登记、删除、审核结果 | 是 | 群 Topic 在线成员 | 群文件表、文件引用表 | 文件正文不走 Redis,只投递文件项变化事件。 |
| 会话设置 | 置顶、免打扰、群昵称、草稿 | 是,用户维度 | 本人所有端 | subscription/private 设置表 | 不影响其他用户。 |
| 未读数/入口红点 | bootstrap badge、会话 unread、系统通知 unread | 是 | 本人所有端 | seq/read、系统通知表、业务表 | 实时事件只触发刷新;服务端 GET 返回最终值。 |
| 设备会话 | 新设备登录、退出当前设备、踢设备、全端退出 | 是 | 被控制用户所有端或指定设备 | 设备会话表、Token auth_version、Redis control key | 需要跨 Pod 关闭 WS。 |
| 账号治理 | 封禁、解封、冻结、注销、密码重置 | 是 | 目标用户所有端 | 用户状态、auth_version | Admin command 触发,IM 执行后踢在线并清 Token。 |
| 安全风控 | 风险弹窗确认/取消 | 是,用户维度 | 操作者所有端 | 风控挑战表 / Redis action token | 防止多端同时继续敏感操作。 |
| 会议/工作台 | 会议通知、日报月报提醒、工作台红点 | 是 | 目标用户所有端 | 对应业务表 | 会议笔记不产生通知,但页面返回后主动刷新。 |
| 文件安全 | 审核通过/拒绝、文件失效 | 是 | 文件引用可见用户 | 文件元数据/引用表 | 通知在线端刷新消息卡片或文件列表。 |
| 离线 Push | 用户离线、后台或不可实时投递 | 不属于 WS 多开,但需去重 | JPush alias / registration_id | Push 任务表或 Kafka | 多设备允许同 alias,payload 只带定位字段。 |
## 6. IMServer 改造方案
### 6.1 新增 Realtime Dispatcher
建议新增模块:
```text
server/realtime_dispatcher.go
server/store/realtime_runtime.go
```
职责:
- 发布跨 Pod envelope 到 Redis。
- 订阅 Redis envelope 并投递到本 Pod `SessionStore`。
- 提供 `PublishToUser`、`PublishToTopic`、`PublishSessionControl` 等方法。
- 统一 `event_id`、`dedup_key`、`skip_sid`、租户校验和指标。
接口示意:
```go
type RealtimeTarget struct {
TenantID types.TenantID
Type string // user/topic/device/session
Target string
SkipSid string
}
func PublishRealtime(target RealtimeTarget, msg *ServerComMessage) error
func PublishRealtimeEvent(target RealtimeTarget, event IMRealtimeEvent) error
```
现有 `redis_fanout.go` 后续可以改为调用统一 dispatcher,保留兼容封装。
### 6.2 个人消息跨 Pod
当前群消息已有专用 fanout,个人消息也需要同等级处理。
发送流程:
1. WS 收到 P2P `pub`。
2. Session 完成租户、登录、Topic 归一化。
3. Topic 校验好友关系、黑名单、风控、文件 ACL。
4. 使用 `client_mid` 做幂等,生成 Topic seq。
5. 本 Pod 投递:
- 当前连接返回 `ctrl 202`。
- 发送人其他端收到同一 `{data}`。
- 接收人在本 Pod 的端收到 `{data}`。
6. Redis realtime bus 投递到其他 Pod:
- `target_type=topic`,`target=p2p...`
- 或拆成 `target_type=user` 给双方用户。
7. 异步持久化到 MySQL。
8. 不在线设备只生成 Push 任务,客户端重连按 seq 补齐。
关键点:
- `noecho=false` 时发送人多端应同步消息。
- `skip_sid` 只能跳过当前连接的重复 `{data}`,不能跳过发送人其他端。
- P2P 传入 `usr...` 时必须先归一化为真实 P2P Topic。
### 6.3 群消息跨 Pod
保留当前 Redis seq、`client_mid` 幂等、热消息和 `im_group_messages` 最终落库模型。
需要补强:
- `redis_fanout.go` 使用统一 envelope。
- Redis channel 从全局 `im:0:group_fanout` 调整为带租户或 envelope 必带 `tenant_id`。
- 投递时优先使用 `topic:{topic}:node:{node_id}:online_members`,避免所有 Pod 都扫描全部 session。
- 消费端支持 topic 已加载和未加载两种路径:
- Topic 已加载:投递到 `hub.routeSrv`,由 Topic 统一处理。
- Topic 未加载:直接扫描本 Pod 已订阅该 Topic 的 session 投递。
- Redis Pub/Sub 丢失不补发,客户端重连通过 `since=last_seq+1` 补齐。
### 6.4 业务状态事件跨 Pod
替换 `im_business_events.go` 的数据库 outbox 轮询模式。
HTTP handler 或 Store 写成功后:
1. 同步写 MySQL 最终状态。
2. 失效 Redis 缓存和 Topic 权限快照。
3. 调用 realtime dispatcher 发布事件。
4. 如需通知离线用户,投 Kafka Push 任务。
业务事件 payload 示例:
```json
{
"info": {
"topic": "usr_target",
"from": "usr_actor",
"what": "im",
"event": "friend.request.created",
"payload": {"request_id": "frq_1"},
"ts": "2026-09-08T10:00:00Z"
}
}
```
群事件:
```json
{
"info": {
"topic": "grp_xxx",
"from": "usr_actor",
"what": "group",
"event": "group.role.updated",
"payload": {"topic": "grp_xxx", "user_id": "usr_2", "role": "admin"},
"ts": "2026-09-08T10:00:00Z"
}
}
```
### 6.5 Session Control 跨 Pod
设备退出、踢设备、全端退出、密码重置、账号封禁必须跨 Pod 生效。
建议 Redis key:
```text
im:{tenant_id}:session_control:user:{uid}:version
im:{tenant_id}:session_control:device:{uid}:{device_id}:version
im:{tenant_id}:session_control:event:{event_id}
```
流程:
1. IMServer 或 Admin command 更新 MySQL 用户状态、设备会话状态或 `auth_version`。
2. 写 Redis control version,TTL 可 1 到 7 天。
3. 发布 `target_type=user/device` 的 `session_control` envelope。
4. 各 Pod 收到后调用 `SessionStore.EvictUser` 或 `EvictUserDevice`。
5. Session 心跳或上行业务包也检查 Redis control version,防止 Pub/Sub 丢失。
### 6.6 Presence 多开规则
Redis presence 是全局在线真源:
- 用户登录成功:增加 session,写 `presence:user:{uid}`、`presence:user:{uid}:sessions`、`presence:node:{node_id}:users`。
- 订阅群 Topic:写 `topic:{topic}:online_members` 和 `topic:{topic}:node:{node_id}:online_members`。
- 心跳:刷新 session TTL、user TTL、已订阅群成员 TTL。
- 断线:移除 session;最后一个 session 离线时标记 `online=0`。
- 节点异常:依赖 TTL 自动过期。
好友在线状态变化:
- 不扫描所有用户。
- 只通知与该用户有订阅关系或正在会话的在线端。
- 客户端展示最近在线状态时,HTTP/WS 查询均以 Redis presence 为准。
## 7. Admin Server 改造方案
### 7.1 Admin 不直接做 WS 投递
Admin Server / HotGo 只负责后台权限、配置、审核和命令生产,不直接连接 IMServer 内存态,也不直接写租户库。
Admin 写操作统一:
1. 校验后台管理员权限。
2. 写 hotgo 命令表,记录 `command_id/event_id/idempotency_key/actor/request_id`。
3. 发送 Kafka `im.admin.command`。
4. 等待 IMServer `im.admin.command.result` 更新命令状态。
### 7.2 Admin 命令触发的多开场景
| Admin 操作 | Kafka Command | IMServer 动作 | C 端实时投递 |
| --- | --- | --- | --- |
| 封禁用户 | `user.disable` | 更新用户状态、递增 `auth_version`、踢在线 | `session_control user.disabled` 给目标用户所有端 |
| 解封用户 | `user.enable` | 更新用户状态、失效缓存 | 可选 `user.enabled` 给本人和相关管理视图 |
| 重置密码 | `user.password.reset` | 更新密码、递增 `auth_version`、撤销 Token | `session_control security.password_reset` |
| 踢设备 | `user.device.revoke` | 撤销设备会话 | `session_control device.revoked` |
| 解散群 | `group.delete` | 解散 Topic、撤销订阅和二维码 | `group.deleted` 给群在线成员 |
| 踢群成员 | `group.member.remove` | 更新成员状态和 Topic 订阅 | `group.member.removed` 给群成员和被踢用户多端 |
| 设置管理员 | `group.admin.set` | 更新角色、刷新权限快照 | `group.role.updated` |
| 删除管理员 | `group.admin.remove` | 降级角色、刷新权限快照 | `group.role.updated` |
| 禁言成员 | `group.member.mute` | 写禁言表、刷新权限 | `group.mute.updated` |
| 撤回/处置消息 | `message.moderate` | 更新消息状态、审核记录 | `message.recalled` 或 `message.moderated` |
| 文件审核处置 | `file.audit.update` | 更新文件状态和引用可见性 | `file.unavailable` 给引用可见用户 |
| 举报处理结果 | `abuse.report.resolve` | 更新举报状态、生成系统通知 | `system_notification.created` 给举报人 |
### 7.3 Admin 摘要同步
Admin 页面需要列表、统计和审核台数据,不应通过实时总线获得。
推荐:
- Admin 查询租户详情:调用 IM 内部只读 API。
- Admin 高频列表:维护 hotgo 摘要表。
- IM 状态变更后通过 Kafka result/event 同步摘要给 Admin。
这些摘要同步不属于 C 端多开实时投递,不要求发到用户 WS。
## 8. Redis、Kafka、MySQL 边界
| 组件 | 用途 | 不应用于 |
| --- | --- | --- |
| Redis Pub/Sub / Stream | 在线跨 Pod WS 投递、session control、presence 变化、短期去重 | 最终历史、长事务、后台命令可靠执行 |
| Redis KV/Set/Hash | presence、节点在线用户、Topic 在线成员、短期幂等、热消息 | 长期业务主数据 |
| Kafka | Admin command/result、消息持久化、Push、文件安全、审计、死信 | WebSocket 实时 fanout、验证码、在线状态 |
| MySQL 租户库 | 最终状态、历史查询、重连补偿、审计 | 在线连接表、实时 fanout |
| hotgo | Admin 后台主数据、配置、命令状态、摘要 | C 端 IM 消息和租户业务最终状态 |
## 9. 渐进实施计划
### 阶段一:统一跨 Pod 投递
1. 新增 realtime dispatcher。
2. 先增加 P2P 个人消息跨 Pod 投递。当前 P2P 是完全空白,应优先于群消息迁移。
3. 将现有群消息 Redis fanout 改为 dispatcher,并修复 `ServerComMessage` 内部路由字段 `json:"-"` 导致跨 Pod 丢 `TenantID/RcptTo/SkipSid` 的问题。
4. 增加用户维度事件投递,先覆盖好友申请、系统通知、已读同步。
5. 增加 session control 跨 Pod 踢设备/全端退出。
6. 同步上线 Redis 订阅健康检查、发布/消费/队列满指标和基础告警。
### 阶段二:替换 business event outbox
1. 已停止把 C 端在线通知写入 `im_business_events_outbox`,本次改造直接替换为 realtime dispatcher。
2. `publishIMRelationEvent`、`publishIMMeetingEvent` 等改为:
- 需要历史展示的系统通知写 `im_system_notifications`。
- 在线刷新直接调用 realtime dispatcher。
- 离线提醒投 Kafka Push。
3. 已移除 `startIMExternalBusinessEventPoller`。
4. `registration.invite_used` 改为注册提交后直接同步邀请码使用聚合;`security.password_reset` 改为密码重置后直接跨 Pod 踢线。
### 阶段三:Admin 联动
1. Admin Server 后台写操作全部通过 Kafka command。
2. IMServer command consumer 执行业务状态更新。
3. IMServer 执行成功后通过 realtime dispatcher 通知 C 端。
4. IMServer 通过 Kafka result/event 回写 Admin 命令状态和摘要。
### 阶段四:可观测和压测
1. 完善跨 Pod 投递指标:发布数、消费数、投递 session 数、丢弃数、队列满数、延迟。
2. 完善 Redis 订阅健康检查和告警。
3. 压测同一用户 10 端、多 Pod P2P 消息、千人群/万人群消息。
4. 验证 Pub/Sub 丢失后重连补偿路径。
## 10. 验收用例
| 用例 | 步骤 | 预期 |
| --- | --- | --- |
| 同用户多端收个人消息 | 用户 A 两端分别连 Pod-1、Pod-2,用户 B 发 P2P 消息到 Pod-1 | A 两端都收到消息,B 其他端也同步,消息只落一条。 |
| 群消息跨 Pod | 群成员分布在 Pod-1、Pod-2、Pod-3,Pod-1 收到群消息 | 所有在线订阅成员实时收到;离线成员重连按 seq 补齐。 |
| 重复发送幂等 | 断线后使用同一 `client_mid` 重发 | 返回同一 `seq`,不重复落库,不重复 Push。 |
| 已读多端同步 | A 在手机读到 seq=100 | A 其他端未读清除,B 多端看到 read 更新。 |
| 好友申请多端同步 | B 向 A 发好友申请 | A 所有在线端刷新新朋友红点和申请列表。 |
| 系统通知已读同步 | A 在 Pod-1 上把系统通知标记已读 | A 在 Pod-2 的端红点同步消失。 |
| 群角色变更 | Admin 将 A 设为管理员 | A 所有端群管理入口出现;群在线成员看到角色标识刷新。 |
| 禁言即时生效 | Admin 禁言 A,A 另一个端在 Pod-2 继续发消息 | 服务端返回 `GROUP_MUTED`,不能成功发送。 |
| 踢设备 | Admin 踢 A 的 device-1 | device-1 在任意 Pod 的连接被关闭,其他设备保持在线。 |
| 全端退出 | 密码重置或封禁 A | A 所有 Pod 上的连接关闭,旧 Token 后续登录失败。 |
| Pub/Sub 瞬断补偿 | 人为断开 Pod-2 Redis 订阅后发送消息,再恢复并重连客户端 | 实时期间可能漏收,但重连后按 seq 补齐,最终状态正确。 |
## 11. 代码落点建议
IMServer:
- `server/realtime_dispatcher.go`:统一跨 Pod 发布与订阅。
- `server/redis_fanout.go`:改造成群消息 dispatcher 适配层。
- `server/sessionstore.go`:补充按租户、用户、设备、Topic 的本地投递辅助方法。
- `server/session.go`:登录、心跳、断线时刷新 Redis presence 和 control version 检查。
- `server/topic.go`:个人/群消息发布成功后调用 dispatcher。
- `server/im_http.go`:好友、系统通知、群治理、会话设置等 HTTP 写成功后调用 dispatcher。
- `server/kafka_admin_commands.go`:Admin command 执行成功后调用 dispatcher。
- `server/store/group_message_runtime.go`:补充 realtime bus Redis 发布、订阅、去重 key。
Admin Server / HotGo:
- 后台写操作保留 command/outbox 到 Kafka 的可靠链路。
- 不新增任何直接调用 IM Pod WS 内存态的接口。
- 命令结果和摘要通过 Kafka result/event 回写 hotgo。
- 后台实时页面如需刷新,使用 Admin 自己的 WebSocket/SSE,从 hotgo 命令结果或摘要表驱动,不复用 C 端 WS。
## 12. 风险与注意事项
1. Pub/Sub 不保证离线重放,因此所有实时投递都必须有最终补偿来源。
2. `event_id` 去重不能代替消息 `client_mid` 幂等;二者层级不同。
3. 群消息 seq 只能有一个发号源,Redis 不可用时不能临时改用 MySQL 发号。
4. Session control 不能只靠 Pub/Sub,必须在心跳/上行业务包检查版本兜底。
5. Topic 权限变化后必须刷新内存快照和 Redis 缓存,否则多开端会出现权限漂移。
6. Push payload 不得带敏感正文,只带 `tenant_code/topic/seq/notification_id` 等定位字段。
7. Admin command 成功但实时投递失败时,不回滚业务状态;客户端通过重连或重新 GET 补偿。
8. 跨租户 Redis key、channel、payload 都必须带 `tenant_id`,不允许使用裸 `topic` 做全局路由。
@@ -90,6 +90,8 @@ C 端在线用户
代码层面禁止服务启动时自动建库建表。`im_server` 和 `im_workbench` 都只负责连接已有数据库和执行正常业务 SQL;所有 `CREATE DATABASE`、`CREATE TABLE`、`ALTER TABLE`、索引变更、初始化字典和种子数据,统一整理为 SQL 文件,经审核后由人工或运维发布流程执行。
当前 MySQL adapter 已禁用 `CreateDb` 自动建库建表和 `UpgradeDb` 自动升级 DDL。服务启动时如果数据库不存在会直接连接失败;数据库版本不一致时只报错提示执行人工 SQL,不再自动补表、补字段或补索引。
数据库迁移作为后置专项处理,触发条件是:
1. `/v1/workbench/*` 全部接口在本地、测试环境、前端联调中稳定。
@@ -307,14 +309,14 @@ meetingim_common/auth/session
"tenant_code": "default",
"actor_user_id": 42,
"module": "meeting",
"event_type": "meeting.created",
"action": "meeting.created",
"resource_id": "mtg_1",
"target_user_ids": [42, 43],
"title": "会议通知",
"content": "项目周会",
"badge_delta": 1,
"payload": {
"summary": "项目周会",
"data": {
"meeting_id": "mtg_1",
"badge_delta": 1,
"version": 1
},
"occurred_at": "2026-09-13T13:30:00+08:00"
-20
View File
@@ -182,26 +182,6 @@ CREATE TABLE IF NOT EXISTS tinode.im_http_idempotency (
INDEX idx_im_http_idempotency_expiry (expires_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
CREATE TABLE IF NOT EXISTS tinode.im_business_events_outbox (
id BIGINT NOT NULL AUTO_INCREMENT,
event_id VARCHAR(64) NOT NULL,
target_type VARCHAR(16) NOT NULL,
target VARCHAR(128) NOT NULL,
actor_user_id BIGINT NOT NULL DEFAULT 0,
event_type VARCHAR(64) NOT NULL,
payload JSON,
status VARCHAR(16) NOT NULL DEFAULT 'pending',
attempts INT NOT NULL DEFAULT 0,
next_retry_at DATETIME(3),
delivered_at DATETIME(3),
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (id),
UNIQUE INDEX uk_im_business_events_outbox_event (event_id),
INDEX idx_im_business_events_outbox_pending (status, next_retry_at, id),
INDEX idx_im_business_events_outbox_target (target_type, target, id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
CREATE TABLE IF NOT EXISTS tinode.im_message_revisions (
id BIGINT NOT NULL AUTO_INCREMENT,
createdat DATETIME(3) NOT NULL,
@@ -0,0 +1,470 @@
# IM 服务端架构与新功能开发指南
> 文档状态:按 2026-09-08 代码实现整理
> 适用项目:`im_server`
> 文档目标:让新接手的开发者能够快速判断一个功能应放在哪里、如何调用、如何存储、如何做多节点一致性,以及如何验收。
## 1. 系统定位
`im_server` 是基于 Tinode 扩展的多租户 IM 业务服务,同时承担两类职责:
1. IM 实时核心:WebSocket 连接、登录会话、Topic 订阅、消息路由、在线状态、音视频信令。
2. C 端产品业务:注册、安全、好友、群治理、收藏、文件、工作台、会议、日报、举报等 HTTP API。
系统不是传统的 `controller -> service -> repository` 三层单体。它的核心是“Topic Actor 实时模型 + HTTP 业务扩展层 + Store 统一持久化边界”。开发新功能时必须先判断它属于实时 Topic 链路,还是 HTTP 业务链路,或者同时涉及两者。
## 2. 系统边界
### 2.1 服务与数据库归属
| 服务 | 直连数据库 | 负责数据 |
| --- | --- | --- |
| IM Server | 租户业务库,默认库为 `tinode` | 用户、认证、Topic、订阅、私聊/群聊消息、群业务、好友关系、文件元数据、C 端业务数据 |
| Admin / HotGo | `hotgo` | 租户注册表、系统配置、字典、后台账号、菜单权限、后台命令 Outbox、管理端摘要数据 |
原则:服务与库一一对应,Admin 不直连租户库,IM 不直连 `hotgo`。跨系统写操作通过 Kafka 命令/事件,必须同步返回的少量查询通过受控的内部 HTTP API。
### 2.2 租户隔离
目标模型是“一个租户一个业务库”:
- `tenant_id` 是请求上下文和路由键,不要因为租户库内没有 `tenant_id` 列就在代码中丢掉租户上下文。
- 所有业务 Store 调用必须显式传递 `types.TenantID`。
- `store.ForTenant` 和 `tenantBusinessAdapter` 负责验证租户与选择租户适配器。
- MySQL 的 `TenantRoutedAdapter.TenantAdapterFor` 负责将租户路由到对应连接池。
- 任何无租户上下文的业务查询都应失败关闭,不允许回退到旧的无租户查询。
## 3. 技术栈
| 领域 | 当前实现 | 用途 |
| --- | --- | --- |
| 语言 | Go 1.26 | 服务端主体 |
| 实时协议 | Tinode JSON Protocol、WebSocket、Long Polling、gRPC | 长连接、Topic 订阅、消息和集群通信 |
| HTTP | `net/http` + `http.ServeMux` | C 端 REST API、文件 API、内部 API |
| 数据库 | MySQL + `database/sql` + `sqlx` | 租户业务最终状态 |
| 热状态/缓存 | Redis 7,`go-redis/v9` | 在线状态、Seq、幂等、群热消息、对象缓存、关系 Set、限流、跨节点 fanout |
| 异步事件 | Kafka,`segmentio/kafka-go` | 跨系统命令、消息异步持久化、审计、缓存失效、失败重试与死信 |
| 文件 | 阿里云 OSS,保留 FS/S3 适配器 | 文件对象存储,业务库只存元数据和引用 |
| 文件安全 | 阿里云内容安全 | 上传完成后的异步审核 |
| 离线推送 | JPush,同时保留 FCM/TNPG 插件能力 | 离线提醒;客户端重连后按 Topic seq 补偿 |
| 音视频 | Tinode 通话信令 + 声网 RTC Token | 呼叫邀请/接听/挂断与 RTC 加入 |
| 认证 | basic/token/code/rest 可插拔认证器 | 密码、Token、验证码和外部认证 |
| 可观测性 | Go `log`、expvar、Prometheus exporter、pprof | 日志、运行指标和性能诊断 |
## 4. 运行时架构
```text
Client
|-- WebSocket /v0/channels
| -> Session -> Hub -> Topic actor -> Store/Redis
| |-> local sessions
| `-> Redis fanout -> other IM nodes -> WebSocket
|
|-- HTTP /v1/im/*
| -> protocol middleware -> bearer/API key/tenant
| -> handler -> Store mapper -> tenant adapter -> tenant MySQL
| |-> Redis cache/short state
| `-> business event -> online WebSocket/Kafka
|
`-- Upload/Download
-> media handler -> OSS
-> file metadata/link -> tenant MySQL
`-> file security task -> Kafka -> Aliyun security
Admin / HotGo
|-- Kafka admin command/config event -> IM consumer -> tenant business mutation
`-- signed internal HTTP <-------> IM internal read API
```
### 4.1 启动顺序
`server/main.go` 是服务入口,主要顺序为:
1. 读取 JSON with comments 格式的 `meetingim.conf`/`tinode.conf`。
2. 初始化日志、expvar、pprof 和集群 worker ID。
3. 打开 Store,校验数据库版本与租户适配能力,同时初始化 Redis runtime。
4. 初始化 Kafka、IM 内部 API 和 Admin 内部配置 API 客户端。
5. 初始化认证器、验证器、文件处理器、Push 和音视频。
6. 创建 `SessionStore` 和 `Hub`,启动 Redis 群消息 fanout 订阅。
7. 启动 Kafka Admin Command、Config Event、Cache Invalidate、Message Persist 和 Dead Letter 消费者。
8. 启动集群、gRPC、HTTP/WebSocket 路由和优雅退出。
任何新增的基础组件都应明确放在这个顺序中,初始化失败时应快速终止启动,不要让必需依赖在不可用状态下继续运行。
## 5. 核心业务模型
### 5.1 Session
`Session` 表示一条客户端连接,保存租户、用户、设备、认证等级、已订阅 Topic 和输出队列。一个用户可以同时有多个 Session。
登录和断线时需同步 Redis 在线状态;心跳用于刷新 presence TTL。不应仅依赖本进程 Session 判断全局在线状态。
### 5.2 Hub
`Hub` 是 Topic 路由中心,核心通道包括:
| 通道 | 用途 |
| --- | --- |
| `join` | 加载/创建 Topic,将 Session 附加到 Topic |
| `routeCli` | 客户端消息路由到 Topic |
| `routeSrv` | 服务端业务消息路由到 Topic/Session |
| `meta` | 未订阅 Topic 的元数据查询 |
| `unreg` | 卸载或删除 Topic |
| `userStatus` | 用户停用/恢复等状态变更 |
Topic 索引不是纯字符串,而是 `types.TopicKey{TenantID, Topic}`,以防止不同租户的同名 Topic 串数据。
### 5.3 Topic Actor
`Topic` 是会话和消息处理单元,通过 channel 串行处理加入、退出、客户端消息、服务端消息和元数据操作。主要分类包括:
| Topic | 含义 |
| --- | --- |
| `me` | 用户主 Topic,会话列表和用户级 presence |
| `fnd` | 搜索/发现 Topic |
| `usr...` | 客户端可见的 P2P 对端标识,入库前需归一化为真实 P2P Topic |
| `grp...` | 群聊 Topic |
| `chn...` | 频道 Topic |
| `slf...` | 个人收藏/保存消息 Topic |
| `sys...` | 系统通知 Topic |
涉及消息发送、已读、撤回、编辑、通话信令、在线投递时,优先复用 Topic/Session 链路,不要另造一套无状态 HTTP 投递逻辑。
## 6. 四类标准调用链路
### 6.1 WebSocket 实时消息
```text
Client packet
-> hdl_websock.go 解码
-> Session 认证/租户上下文
-> Hub.join / Hub.routeCli
-> Topic 权限和业务校验
-> Redis 原子 Seq + client_mid 幂等
-> 本地 Session 实时投递
-> Redis Pub/Sub 跨节点 fanout
-> Kafka persist 异步落 MySQL
```
群消息使用 `im_group_messages`,私聊仍使用私聊消息表。`SeqId` 全链路必须使用 `int64/BIGINT`,群 Topic seq 由 Redis 原子递增,数据库只作基线与最终持久化。
群热消息可在 Redis 保留 24 小时左右,历史补偿最终以 MySQL 为准。离线用户只接收 Push 提醒,重连后按 Topic seq 拉取缺口。
### 6.2 C 端 HTTP 业务请求
```text
/v1/im/*
-> withIMHTTPProtocol
-> API key + X-IM-Schema + X-Request-ID
-> Bearer token -> principal{tenant_id, uid}
-> 写请求 Idempotency-Key 中间件
-> serveIM... handler
-> store.<Domain> mapper
-> TenantBusinessAdapter
-> tenant MySQL / Redis
-> writeIMSuccess / writeIMError
```
所有已认证的 `POST/PUT/PATCH/DELETE` 默认要求 `Idempotency-Key`,幂等结果保留 24 小时。同一 key 使用不同请求体必须返回冲突,已完成请求应重放原响应。
HTTP 统一响应包含 `code/data/reason/message/details/request_id/server_time`。业务错误应返回稳定 `reason`,不应将 SQL 或内部异常直接暴露给客户端。
### 6.3 Admin 后台写操作
```text
Admin API
-> hotgo 本地权限校验
-> 同事务写 command + outbox
-> Kafka im.admin.command
-> IM consumer 校验 tenant/event/actor
-> event_id + consumer_group 幂等抢占
-> 租户库业务变更
-> 缓存失效 + Topic/Session 同步
-> Kafka im.admin.command.result
-> Admin 更新命令结果
```
不可重试的参数、权限、版本冲突应直接返回稳定失败原因。临时故障按退避策略重试,超过上限写 `im.dead.letter` 和租户失败表。人工重放必须生成新 `event_id`,并使用 `parent_event_id` 串起审计链。
### 6.4 文件链路
```text
Client request upload
-> IM 创建文件元数据
-> OSS 上传/回调完成
-> 租户库更新文件状态
-> 绑定消息/群文件/业务引用
-> Kafka im.file.security
-> 阿里云安全检测
-> 更新审核状态并通知用户
```
文件字节流不经 Kafka,Kafka 只传递文件 ID、OSS 对象信息和审核任务。任何业务文件都必须建立引用记录,否则垃圾回收可能将它当作孤儿文件删除。
## 7. 数据分层与一致性
### 7.1 进程内缓存
适合低频变更、全局或租户级、可短时间最终一致的数据:
- 租户基本配置和状态。
- 注册短信开关。
- 字典和第三方配置(OSS、文件安全、短信、JPush、Agora)。
更新来源为定时刷新和 Kafka `im.config.event`/`im.cache.invalidate`。进程内缓存不应与 Redis 重复缓存同一份高频业务对象。
### 7.2 Redis
Redis 用于跨节点共享且高频的数据:
- 用户在线状态、Session 和群成员节点索引。
- 群消息原子 `SeqId`、`client_mid` 唯一占位和状态。
- 24 小时左右的群热消息。
- 用户、Topic、Subscription、群设置、群成员、成员分页快照和未读数缓存。
- 好友/黑名单 Set。
- 验证码、Action Token、临时占位、限流等 TTL 状态。
- 群消息 Redis Pub/Sub 跨节点实时 fanout。
Redis key 必须包含统一前缀和租户维度,不允许跨租户共用业务 key。写 MySQL 成功后必须失效或刷新对应 Redis 数据,多节点通过 Kafka 失效事件收敛。
### 7.3 MySQL
MySQL 是最终业务状态、历史查询和审计依据。以下数据不得仅存 Redis:
- 用户、凭据、好友、黑名单、Topic、订阅和群治理最终状态。
- 私聊/群聊历史消息、撤回和编辑版本。
- 文件元数据、业务引用和安全审核结果。
- HTTP 幂等结果、Kafka 消费幂等、死信和审计数据。
### 7.4 一致性顺序
普通业务写的推荐顺序:
1. 校验租户、身份、权限、幂等键和业务版本。
2. 在同一事务中更新 MySQL 业务状态及 outbox(需要可靠事件时)。
3. 提交事务。
4. 失效本节点 Redis/进程内缓存。
5. 投递 Kafka 失效或业务事件,通知其他节点和客户端。
如果业务要求“先实时到达,再异步落库”,则必须先完成 Redis 幂等占位和 seq 分配,投递 Kafka 持久化事件,并为失败准备重试和死信补偿。
## 8. Kafka 责任边界
| Topic | 主要用途 |
| --- | --- |
| `im.admin.command` | Admin 到 IM 的业务写命令 |
| `im.admin.command.result` | IM 回传命令结果 |
| `im.config.event` | 租户、字典、第三方配置变更 |
| `im.business.event` | 好友、群、会议、工作台等 C 端业务事件 |
| `im.message.persist` | 私聊消息异步落库 |
| `im.group.message.persist` | 群聊消息异步落库 |
| `im.message.push` | 离线 Push 任务,属增强链路 |
| `im.file.security` | 文件安全检测 |
| `im.cache.invalidate` | 多节点缓存失效 |
| `im.audit.event` | 跨系统审计 |
| `im.dead.letter` | 超过重试上限的事件 |
Kafka 不用于 WebSocket 建连、验证码、在线状态、实时群消息 fanout、OSS 字节流或必须当场返回结果的查询。
统一 Envelope 至少包含:`event_id`、`trace_id`、`tenant_id/tenant_code`、`source_service`、`target_service`、`event_type`、`biz_type`、`biz_id`、`idempotency_key`、`version`、`occurred_at`、`actor`、`payload`。
## 9. 代码组织
| 路径 | 职责 | 新功能常见修改 |
| --- | --- | --- |
| `server/main.go` | 进程入口、组件初始化、HTTP 路由注册 | 新增路由或新基础组件启动 |
| `server/session.go` | 连接、登录、请求分发、设备和 Session 状态 | 账号登录或连接级功能 |
| `server/hub.go` | Topic 加载与全局消息路由 | 路由、Topic 生命周期 |
| `server/topic.go` / `init_topic.go` | Topic actor、权限、订阅和消息处理 | 消息、群权限、Topic 内实时行为 |
| `server/datamodel.go` | WebSocket 客户端/服务端协议结构 | 协议字段或服务端推送类型 |
| `server/im_http.go` | C 端产品 HTTP API 的路由处理与通用约定 | 大部分 C 端业务入口 |
| `server/internal_api.go` | IM 向 Admin 提供的签名内部查询 API | 后台需要同步读取租户数据 |
| `server/admin_internal_config.go` | IM 调用 Admin 内部配置 API | IM 需要 hotgo 配置兜底查询 |
| `server/kafka_*.go` | Kafka runtime、生产、消费、重试、死信和各业务 handler | 跨系统或异步事件 |
| `server/im_business_events.go` | 业务事件持久化与在线投递 | 需主动推给 C 端的状态变更 |
| `server/redis_fanout.go` | 群消息跨节点实时投递 | 群大并发/未加载 Topic 投递 |
| `server/store/types/` | 业务实体、值对象、查询参数和稳定错误 | 先定义领域数据结构 |
| `server/store/store.go` | Store mapper、参数验证、租户边界、缓存编排 | 对 handler 提供统一持久化 API |
| `server/store/infrastructure.go` | HTTP 幂等、业务事件、Kafka 幂等/死信 Store | 可靠基础链路 |
| `server/store/group_message_runtime.go` | Redis seq、唯一 key、热消息、presence、缓存和限流 | Redis 新 key 或新的 TTL 状态 |
| `server/db/adapter.go` | 持久化能力接口 | 新增 Store 数据能力 |
| `server/db/mysql/tenant_adapter.go` | MySQL 租户业务 SQL 实现 | 业务 CRUD/事务 |
| `server/db/mysql/*_schema.go` | 扩展业务表建表/升级逻辑 | 新表、列、索引 |
| `server/media/` | FS/OSS/S3 文件处理插件 | 文件存储能力 |
| `server/filesafety/` | 文件安全适配器 | 文件检测能力 |
| `server/push/` | 离线 Push 插件 | 厂商 Push 接入 |
| `server/auth/` / `server/validate/` | 认证和凭据验证插件 | 登录或验证方式 |
| `server/*_test.go` | 单元与链路测试 | handler、Topic、Kafka、Redis 逻辑测试 |
| `server/db/mysql/*_test.go` | MySQL schema 和 SQL 回归 | 数据库变更测试 |
## 10. 新功能放置决策
| 问题 | 选择 |
| --- | --- |
| 是否是客户端建连、订阅、发消息或通话实时信令? | WebSocket + Session/Hub/Topic |
| 是否是 C 端业务 CRUD、列表、搜索、设置或审批? | `/v1/im/*` HTTP + Store |
| 是否是 Admin 对租户业务的写操作? | Admin 本地 command/outbox + Kafka + IM consumer |
| Admin 是否必须当场读取租户详情? | IM 受控内部 GET API |
| IM 是否需要读取 hotgo 的低频配置? | 进程内缓存 + Kafka 刷新 + Admin 内部 API 兜底 |
| 是否需要跨节点高频共享、TTL 或原子计数? | Redis |
| 是否需要最终可追溯的业务状态? | 租户 MySQL |
| 是否跨系统、可异步、需重试/削峰/审计? | Kafka |
| 是否为在线群消息实时扩散? | Redis Pub/Sub + WS,不用 Kafka 做主链路 |
| 是否为文件本体? | OSS;MySQL 只存元数据/引用 |
## 11. 新功能标准开发流程
### 11.1 需求分析
1. 从前后端对接文档提取请求、响应、权限、状态机、幂等、并发和通知要求。
2. 标出数据归属:`hotgo` 公共数据或租户库业务数据。
3. 选择入口:HTTP、WebSocket、Kafka Admin Command 或内部 API。
4. 列出需要同步的状态:MySQL、Redis、Topic 内存、进程内缓存、C 端推送。
5. 明确失败类型:可重试、不可重试、并发冲突、部分成功和补偿。
### 11.2 领域与数据库
1. 在 `server/store/types/` 按业务域定义实体、状态常量、查询参数和结果。
2. 在 `server/db/adapter.go` 增加最小必要的租户持久化接口,方法必须携带 `TenantID`。
3. 在 MySQL 适配器实现 SQL;复杂写操作使用事务,状态迁移在 SQL `WHERE` 中校验旧状态/版本。
4. 在对应 `*_schema.go` 添加幂等建表/升级 SQL,为高频查询、唯一幂等键和状态扫描建索引。
5. 在 `server/store/store.go` 增加 mapper,负责参数验证、调用 adapter 和缓存编排;handler 不应直接拼 SQL。
表命名遵循当前租户库风格,类型必须与 Go 全链路一致,尤其是 ID、seq、version 使用 `BIGINT/int64`。面向 JavaScript 客户端的 64 位 ID 优先以字符串输出,避免精度丢失。
### 11.3 API 或协议层
HTTP 功能:
1. 在 `server/im_http.go` 定义独立 request/response 结构,不要直接暴露数据库 row。
2. 使用 `decodeIMJSONBody`、`authenticateIMHTTPBearer`、`requireIMGroupMember` 等公共函数做解码、认证和权限检查。
3. 在 `server/main.go` 注册具体路由,路由顺序要避免通配前缀吞掉更具体的路径。
4. 使用 `writeIMSuccess`/`writeIMError` 统一输出,新增稳定业务 `reason`。
5. 资源更新需并发保护时使用 version/`If-Match`,不允许用 `version=0` 绕过乐观锁。
WebSocket 功能:
1. 在 `datamodel.go` 增加最小协议字段,考虑 JSON 和 protobuf 转换兼容。
2. 在 Session 做连接级验证,在 Topic 做资源权限与业务状态验证。
3. 所有可重发消息必须有 `client_mid`/业务幂等键。
4. 成功后同时考虑本节点 Session、跨节点 fanout、离线 Push 与历史补偿。
### 11.4 缓存、事件与审计
1. 对高频读对象选择 cache-aside:先 Redis,miss 后读 MySQL 并回填。
2. 所有写方法列出需失效的 key,包括对象 key、关系 Set、分页快照和未读数。
3. 需可靠异步处理时同事务写 outbox,消费端使用 `event_id + consumer_group` 幂等。
4. 影响管理权限、账号安全、消息内容、文件处置或人工重放的操作要写审计。
5. 业务状态变更需立即告知 C 端时,创建业务事件并通过已认证服务端链路投递,不信任客户端伪造的系统消息。
### 11.5 测试与文档
每个功能至少覆盖:
1. 正常路径、无认证、跨租户、无权限、资源不存在和方法错误。
2. 重复 `Idempotency-Key`/`client_mid` 不产生重复数据。
3. 同一幂等键配不同 payload 返回冲突。
4. version 冲突和并发状态迁移。
5. MySQL 成功后 Redis/Topic 缓存及在线客户端状态正确刷新。
6. Kafka 重复消费、临时失败重试、不可重试失败和死信重放。
7. 64 位 ID/seq 在 JSON 编解码时不丢失精度。
8. 更新前后端对接文档、SQL 变更集和未实现增强项。
## 12. 关键约定
### 12.1 ID 与时间
- 用户 ID 由 Snowflake UID 体系生成,对外使用 `usr...` 编码形式或协议规定的字符串。
- 业务 ID 必须全局或租户内唯一,不能用秒级时间戳直接代替唯一 ID。
- `SeqId`、数据库主键和 version 使用 `int64/BIGINT`。
- 持久化与对外时间使用 UTC,HTTP 输出 RFC3339/RFC3339Nano。
### 12.2 Topic 归一化
客户端传入的 `usr...`、`slf` 等可见 Topic 不一定是数据库真实 Topic。历史查询、上下文、收藏、转发、媒体、回执等接口在查库前必须统一归一化,并在鉴权、查询和响应之间保持明确的 Topic 语义。
### 12.3 权限
- HTTP handler 负责验证主体和请求参数,Store/adapter 仍应使用租户和业务条件限制写入。
- Topic 管理员、群主、普通成员权限以服务端为准,不接受客户端声明的角色。
- 群主转让、设管理员、禁言、踢人等变更后,必须刷新 Topic 内 `perUser` 权限快照和 Redis 订阅/成员缓存,不能等待重连后生效。
- 封禁、冻结、踢设备、全端退出和注销等账号治理必须同步处理已存在 Session。
### 12.4 可观测性
- 日志使用 `server/logs` 的 `Info/Warn/Err`,默认输出 stderr,由容器日志系统收集。
- 日志行前缀为 `I/W/E` 加 Go log flags 生成的日期时间;敏感值、Token、密码、验证码、证件号和第三方密钥不得记录。
- HTTP 日志和异步事件应携带 `request_id/trace_id`、租户、业务类型和业务 ID。
- 新增核心链路应增加请求数、失败数、延迟、队列深度/积压和死信指标。
## 13. 禁止的实现方式
1. 在 HTTP handler 内直接打开 MySQL 或拼接 SQL。
2. 因租户库表没有 `tenant_id` 列,就不向 Store 传递 `TenantID`。
3. Admin 直连 `tinode` 或 IM 直连 `hotgo`。
4. 将 Kafka 当作 HTTP RPC 或实时 WebSocket fanout 使用。
5. 将业务最终状态只存在 Redis。
6. 为同一份高频数据同时增加进程内缓存和 Redis 缓存。
7. 业务写成功后不失效 Redis、Topic 权限快照和成员分页快照。
8. 重试请求没有 `Idempotency-Key/client_mid/event_id`。
9. 使用 `int` 或 JSON number 承载可能超过 JavaScript 安全范围的 ID/seq。
10. 直接信任客户端构造的系统通知、管理员角色或审计信息。
11. 文件上传成功后不创建业务引用和安全审核任务。
12. 新增数据表只在开发者本机手工执行,不写幂等 schema SQL、升级逻辑和回归测试。
## 14. 开发与验收命令
MySQL 构建使用 build tag:
```bash
go build -tags mysql -o bin/tinode ./server
go build -tags mysql -o bin/init-db ./tinode-db
```
建议测试顺序:
```bash
# 先跑修改模块的定向测试
go test ./server -run 'TestName'
# Store 与 MySQL schema/adapter
go test -tags mysql ./server/store/... ./server/db/mysql/...
# 完整服务端回归
go test -tags mysql ./server/...
```
发版前至少检查:
- 配置文件可解析,MySQL、Redis、Kafka、OSS 等必需依赖可连通。
- 数据库版本与代码一致,所有新表/列/索引已在目标环境幂等执行。
- Kafka Topic、消费组、重试和死信链路可用。
- 单节点和多节点下的在线状态、群消息、权限变更均一致。
- 新增接口与前后端对接文档一致,无密钥、密码或测试 Token 进入 Git。
## 15. 新功能提交清单
开发者在提交 PR/代码前应逐项确认:
- [ ] 已确定功能入口是 HTTP、WebSocket、Kafka 还是内部 API。
- [ ] 已确定数据归属 `hotgo` 还是租户库。
- [ ] 所有 Store/adapter 调用都显式携带 `TenantID`。
- [ ] 已设计幂等键、唯一索引、业务版本和状态机。
- [ ] 已明确 MySQL、Redis、进程内缓存的使用和失效顺序。
- [ ] 需跨系统可靠投递时使用 outbox + Kafka,消费端已幂等。
- [ ] 已处理 Topic 内存权限快照和多节点同步。
- [ ] 文件已存 OSS,业务引用和安全审核已建立。
- [ ] `SeqId`/ID/version 已在 Go、JSON、Kafka 和 MySQL 全链路使用 64 位安全类型。
- [ ] 已增加正常、权限、跨租户、幂等、并发、缓存和重试测试。
- [ ] 已补充 API 对接文档、SQL 变更和未完成增强项。
## 16. 相关文档
- `docs/IM-前后端对接文档.md`:C 端业务 API 和交互协议。
- `docs/IM-系统设计文档.md`:原始产品系统设计。
- `docs/IM-Redis三层数据分层改造方案.md`:进程内、Redis、MySQL 分层细节。
- `docs/IM-Kafka接入方案.md`:Admin/IM 边界、Topic、幂等、重试和死信。
- `docs/IM-Kafka接入范围识别.md`:按业务场景判断 Kafka 接入范围。
- `docs/IM-Kafka接入联调测试文档.md`:Admin 与 IM 联调用例。
- `docs/third/IM-文件安全第三方接入记录.md`:OSS 和文件安全链路。
- `docs/IM-未实现后续增强项.md`:已知未完成能力和深度优化项。
+220
View File
@@ -0,0 +1,220 @@
# IM 监控运维对接文档
本文档用于 IM Server 与 IM Manager 发版、健康检查和 Prometheus 监控对接。
## 1. 接入范围
| 服务 | 端口 | 健康检查 | 指标接口 | 说明 |
|---|---:|---|---|---|
| im-server | 6060 | `GET /healthz` | `GET /metrics` | 对外 IM API 与 WebSocket 服务。 |
| im-manager | 8000 | `GET /healthz` | 暂无 | 后台管理服务。 |
两个接口均不需要业务鉴权,不需要 `X-MeetingIM-APIKey`、`Authorization`、租户 Header。
## 2. 应用配置
本轮监控改造没有新增业务配置项,应用配置文件不需要额外增加开关。
需要运维侧配置的是:
| 配置位置 | 配置内容 |
|---|---|
| Docker / K8s 健康探针 | 调用 `/healthz`,不要再用 `nc -z` 只探测端口。 |
| Prometheus | 抓取 `im-server:6060/metrics`。 |
| 发版脚本 | 蓝绿切换前调用新实例 `/healthz`,返回 200 后再切流。 |
| 网关/Nginx | `/healthz`、`/metrics` 建议只允许内网或监控网段访问。 |
## 3. 健康检查
### 3.1 im-server
请求:
```bash
curl -fsS http://im-server:6060/healthz
```
正常响应:
```json
{
"status": "ok",
"checks": {
"mysql": "ok"
}
}
```
异常响应 HTTP 状态码为 `503`,示例:
```json
{
"status": "error",
"checks": {
"mysql": "dial tcp 127.0.0.1:3306: i/o timeout"
}
}
```
检测内容:
| 依赖 | 检测方式 |
|---|---|
| MySQL | `PingContext`,包含租户注册库连接检查。 |
### 3.2 im-manager
请求:
```bash
curl -fsS http://im-manager:8000/healthz
```
正常响应:
```json
{
"status": "ok",
"checks": {
"mysql": "ok",
"redis": "ok",
"kafka": "ok"
}
}
```
如果 Kafka 未启用,响应为:
```json
{
"status": "ok",
"checks": {
"mysql": "ok",
"redis": "ok",
"kafka": "disabled"
}
}
```
检测内容:
| 依赖 | 检测方式 |
|---|---|
| MySQL | `SELECT 1`。 |
| Redis | `PING`。 |
| Kafka | 临时 Sarama client 拉取 broker metadata,可发现网络、认证、broker 不可用问题。 |
## 4. Docker 健康检查
镜像内已改为请求 `/healthz`:
```dockerfile
HEALTHCHECK --interval=1m --timeout=3s --start-period=30s \
CMD wget -q -O /dev/null http://localhost:6060/healthz || exit 1
```
```dockerfile
HEALTHCHECK --interval=1m --timeout=3s --start-period=20s \
CMD wget -q -O /dev/null http://localhost:8000/healthz || exit 1
```
运维如果使用 K8s,建议:
| 探针 | 路径 | 初始延迟 | 周期 | 超时 | 失败次数 |
|---|---|---:|---:|---:|---:|
| im-server readinessProbe | `/healthz` | 15s | 10s | 3s | 3 |
| im-server livenessProbe | `/healthz` | 30s | 30s | 3s | 3 |
| im-manager readinessProbe | `/healthz` | 15s | 10s | 3s | 3 |
| im-manager livenessProbe | `/healthz` | 30s | 30s | 3s | 3 |
## 5. Prometheus 指标
目前只在 `im-server` 暴露:
```bash
curl -fsS http://im-server:6060/metrics
```
已支持指标:
| 指标 | 类型 | 说明 |
|---|---|---|
| `ws_connections_active` | Gauge | 当前 WebSocket 连接数。 |
| `http_requests_total{method,path,status}` | Counter | HTTP 请求量,按方法、归一化路径、状态码统计。 |
| `http_request_duration_seconds{method,path,status}` | Histogram | HTTP 请求耗时分布。 |
| `db_query_duration_seconds{driver,operation,status}` | Histogram | MySQL 操作耗时分布。 |
路径中的用户 ID、Topic ID、UUID 等动态片段会归一化为 `{id}`,避免 Prometheus label 过多。
Prometheus 示例配置:
```yaml
scrape_configs:
- job_name: im-server
metrics_path: /metrics
scrape_interval: 15s
scrape_timeout: 5s
static_configs:
- targets:
- im-server:6060
```
## 6. 发版脚本建议
蓝绿部署或滚动发布时,切流前执行:
```bash
curl -fsS --max-time 3 http://127.0.0.1:6060/healthz
```
后台服务:
```bash
curl -fsS --max-time 3 http://127.0.0.1:8000/healthz
```
只有 HTTP 状态码为 `200` 才允许切流。返回 `503` 时应保留旧实例,并输出响应 body 方便定位具体依赖。
## 7. 告警建议
| 告警项 | 建议规则 |
|---|---|
| im-server 健康检查失败 | `/healthz` 连续 3 次非 200。 |
| im-manager 健康检查失败 | `/healthz` 连续 3 次非 200。 |
| WebSocket 连接数异常 | `ws_connections_active` 突降 50% 以上或接近 0。 |
| HTTP 5xx | `rate(http_requests_total{status=~"5.."}[5m]) > 0`。 |
| HTTP 延迟 | P95 超过业务阈值,例如 1s。 |
| DB 延迟 | `db_query_duration_seconds` P95 超过 200ms 或 500ms。 |
## 8. 安全要求
`/healthz` 和 `/metrics` 不带业务鉴权,必须通过网络层控制访问范围。
建议:
1. 只允许内网、K8s 探针、Prometheus、运维跳板机访问。
2. 不要直接暴露到公网。
3. 如必须公网访问,需要网关加 IP 白名单或 Basic Auth。
4. 不在日志中记录 `/metrics` 的完整响应内容。
## 9. 验收命令
im-server:
```bash
curl -i http://im-server:6060/healthz
curl -s http://im-server:6060/metrics | grep -E 'ws_connections_active|http_requests_total|http_request_duration_seconds|db_query_duration_seconds'
```
im-manager:
```bash
curl -i http://im-manager:8000/healthz
```
预期:
1. `/healthz` 正常返回 HTTP 200,`status=ok`。
2. 任一依赖不可用时返回 HTTP 503,并在 `checks` 中标明具体原因。
3. `/metrics` 返回 Prometheus 文本格式。
4. Docker 或 K8s 健康状态不再只依赖端口探测。
-22
View File
@@ -261,25 +261,3 @@ CREATE TABLE IF NOT EXISTS im_group_idempotency (
UNIQUE INDEX uk_im_group_idempotency_request (user_id, method, route, idempotency_key),
INDEX idx_im_group_idempotency_created (created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
-- 12. Group outbox events.
-- Transactional bridge for group.membership.updated, group.join_request.created,
-- group.announcement.updated, group.role.updated, group.mute.updated, group.dissolved, etc.
CREATE TABLE IF NOT EXISTS im_group_events_outbox (
id BIGINT NOT NULL AUTO_INCREMENT,
event_id VARCHAR(64) NOT NULL,
topic VARCHAR(32) NOT NULL,
actor_user_id BIGINT NOT NULL DEFAULT 0,
event_type VARCHAR(64) NOT NULL,
payload JSON,
status VARCHAR(16) NOT NULL DEFAULT 'pending',
attempts INT NOT NULL DEFAULT 0,
next_retry_at DATETIME(3),
delivered_at DATETIME(3),
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (id),
UNIQUE INDEX uk_im_group_events_outbox_event (event_id),
INDEX idx_im_group_events_outbox_pending (status, next_retry_at, id),
INDEX idx_im_group_events_outbox_topic (topic, id DESC)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
Binary file not shown.

After

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 254 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 336 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 254 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 336 KiB

@@ -0,0 +1,239 @@
# IM 后端问题报告:好友申请通过后 `friend_added` 系统提示被投递到旧命名话题 `usr<uid>`,同一会话存在两套 Topic
> 报告日期:2026-09-13
> 客户端:iOS(MeetingIM Swift SDK)
> 涉及账号:`usrv97xq86jw3c`(echarles5,**通过人本人**,本报告取证端)、`usrHnkwn8VSfKc`(charles1,**申请人**)
> 涉及话题:`usrHnkwn8VSfKc`(旧标准命名)与 `p2pv97xq86jw3ceeTCfxVJ8pw`(后端内部命名)
> 优先级建议:**高**(会话列表消息与未读不更新,用户可见)
---
## 1. 问题概述
好友申请通过后,服务端把一条**会话内系统提示** `system_notice / friend_added`("你们已成为好友,现在可以开始聊天了。")下发到了
- ❌ `usrHnkwn8VSfKc`(MeetingIM 的 `usr<对端uid>` 旧标准命名,**本端并未订阅该话题**)
而不是
- ✅ `p2pv97xq86jw3ceeTCfxVJ8pw`(服务端自己在 `me` 的 `meta sub` 里下发、并在 `friend.request.accepted` 的 `payload.topic` 里给出的**真正会话**)。
同一时刻服务端还把**同一条逻辑消息**(同 `ts`、同 `seq=17`)写进了 `p2p...` 话题,形成**双写**。
结果:客户端本地同一好友出现**两条单聊会话**,导致用户可见的两个问题:
1. 会话行的 `usr<uid>` 那条没有服务端下发的 `acs` → `isJoiner == false` → 客户端在会话行右下角显示出 `nosign`(🚫)禁止图标;
2. 客户端会话列表去重逻辑保留 `usr<uid>`、隐藏 `p2p...` → **对方的后续消息与未读数都不更新**(未读还被从总数里扣掉,"消息"Tab 角标偏小)。
---
## 2. 时间线(取证端 = 通过人本人 B,2026-09-13)
| 时间(UTC) | 事件 |
| --- | --- |
| 06:13:39 | B(echarles5)删除好友 A(charles1),双方关系解除(A 端收到 `friend.deleted`) |
| 06:27:09.610 | A 申请、B 通过;服务端下发 `friend_added` 系统提示,**同时写入 `usrHnkwn8VSfKc`(seq 17)与 `p2pv97xq86jw3ceeTCfxVJ8pw`(seq 17)** |
| 06:27:09.594 | `GET /v1/im/friends` 返回 A 的会话 `topic = p2pv97xq86jw3ceeTCfxVJ8pw` |
| 06:27:10.367 | `POST /v1/im/conversations/p2p {"peer_user_id":"usrHnkwn8VSfKc"}` 返回 `topic = p2pv97xq86jw3ceeTCfxVJ8pw` |
| 06:27:11.070 | `me` 的 `meta sub` 只下发 `p2pv97xq86jw3ceeTCfxVJ8pw`(订阅 `acs=JRWPA`、`priv.peer=usrHnkwn8VSfKc`),**没有 `usrHnkwn8VSfKc`** |
| 06:27:32.089 | A 发送消息 `a`,落在 `p2pv97xq86jw3ceeTCfxVJ8pw` seq 18 |
---
## 3. 服务端下行报文(原文)
### 3.1 系统提示被实时投递到旧命名话题(本端未订阅)
```json
{
"data": {
"topic": "usrHnkwn8VSfKc",
"ts": "2026-09-13T06:27:09.61Z",
"seq": 17,
"head": {
"x-im-from": "system",
"x-im-schema": 1,
"x-im-template": "friend_added",
"x-im-type": "system_notice"
},
"content": {
"actors": [
{ "user_id": "usrv97xq86jw3c", "click": { "target_id": "usrv97xq86jw3c", "type": "user_profile" } },
{ "user_id": "usrHnkwn8VSfKc", "click": { "target_id": "usrHnkwn8VSfKc", "type": "user_profile" } }
],
"extra": { "request_id": "frq_OYo72xKkkgU" },
"level": "normal",
"template": "friend_added",
"text": "你们已成为好友,现在可以开始聊天了。"
}
}
}
```
客户端侧随即落库并回执(说明客户端把它当作**合法会话消息**处理):
```text
SqlStore - msgReceived:来自话题 payload usrHnkwn8VSfKc,topicId=9,userId=3,seq=17
出站[回执(note)] {"note":{"what":"recv","topic":"usrHnkwn8VSfKc","seq":17}}
入站[在线状态(pres)] {"pres":{"topic":"me","src":"usrHnkwn8VSfKc","what":"msg","seq":17}}
```
### 3.2 同一条消息也被写进真会话(双写证据)
随后客户端订阅真会话拉历史,拿到**同 `ts`、同 `seq`** 的同一条提示:
```json
{
"data": {
"topic": "p2pv97xq86jw3ceeTCfxVJ8pw",
"ts": "2026-09-13T06:27:09.61Z",
"seq": 17,
"head": { "x-im-from": "system", "x-im-schema": 1, "x-im-template": "friend_added", "x-im-type": "system_notice" },
"content": { "template": "friend_added", "text": "你们已成为好友,现在可以开始聊天了。", "...": "与上一条 content 完全一致" }
}
}
```
```text
SqlStore - msgReceived:来自话题 payload p2pv97xq86jw3ceeTCfxVJ8pw,topicId=11,userId=3,seq=17
```
> 同一 `ts` + 同一 `seq=17` 出现在两个 topic ⇒ 服务端对该提示**写了两份**。
> 本地 `topicId`:`usrHnkwn8VSfKc = 9`、`p2pv97xq86jw3ceeTCfxVJ8pw = 11` —— 旧命名话题**更早存在**,本轮关系重建后并未被撤销或合并。
### 3.3 服务端自己只承认 `p2p...`(`me` 的 `meta sub`)
```json
{
"meta": {
"id": "115975",
"topic": "me",
"ts": "2026-09-13T06:27:32.444Z",
"sub": [
{
"updated": "2026-09-13T06:27:11.07Z",
"online": true,
"acs": { "want": "JRWPA", "given": "JRWPA", "mode": "JRWPA" },
"public": {
"avatar_file_id": "ZrI4Fut4heE.jpg",
"display_name": "charles1",
"fn": "charles1",
"name": "charles1",
"nickname": "charles1",
"public_account": "usrHnkwn8VSfKc"
},
"private": { "created_by": "http", "peer": "usrHnkwn8VSfKc" },
"topic": "p2pv97xq86jw3ceeTCfxVJ8pw",
"touched": "2026-09-13T06:27:32.089Z",
"seq": 18
}
]
}
}
```
> `sub` 数组里**只有** `p2pv97xq86jw3ceeTCfxVJ8pw`。服务端却把 `{data}` 投到了 `usrHnkwn8VSfKc` —— 这是一个**本端从未订阅**的话题。
### 3.4 业务事件与 HTTP 接口都指向 `p2p...`
```json
{"info":{"topic":"usrv97xq86jw3c","from":"usrHnkwn8VSfKc","what":"im","event":"friend.request.accepted",
"payload":{"topic":"p2pv97xq86jw3ceeTCfxVJ8pw","user_id":"usrHnkwn8VSfKc","request_id":"frq_OYo72xKkkgU"}}}
```
```json
{"user_id":"usrHnkwn8VSfKc","display_name":"charles1","relationship":"friend",
"topic":"p2pv97xq86jw3ceeTCfxVJ8pw","avatar_file_id":"ZrI4Fut4heE.jpg"}
```
> `friend.request.accepted` 的 `payload.topic`、`GET /v1/im/friends` 的 `topic`、`POST /v1/im/conversations/p2p` 的响应都明确是 `p2pv97xq86jw3ceeTCfxVJ8pw`。
> 说明服务端**知道**该会话的真实 topic,但系统提示仍写到了 `usr<uid>`。
---
## 4. 与接口文档契约的冲突
| 文档位置 | 约定 | 实际 |
| --- | --- | --- |
| 《IM-前后端对接文档》0.5.4.3.1(约 330 行) | "会话内系统消息**必须进入对应 P2P 或群 Topic 的消息流**,占用 `seq`" | `friend_added` 同时进入 `usr<uid>`(非对应会话) |
| 同章 `system_notice` 下行示例(约 337 行) | `{"data":{"topic":"p2p...", ... "x-im-template":"friend_added" ...}}` | 实际 `"topic":"usrHnkwn8VSfKc"` |
| 同文档 3.5(约 1108 行) | 好友申请通过后服务端"在同一事务内建立双方关系和 **P2P 订阅**" | 建立了 `p2p...` 订阅,但旧 `usr<uid>` topic/订阅仍在,且系统提示写到旧 topic |
| 同文档 `GET /v1/im/friends`(约 1564 行) | 好友的会话 `topic` 为 `p2p...` | 一致 ✅(正因如此客户端更无法理解 `usr<uid>` 上的消息) |
---
## 5. 对客户端的影响(为什么客户端只能兜、不能治)
1. **会话列表出现两条同一好友的会话**,去重只能靠启发式规则(按对端 uid 分组打分)择一保留;
2. **保留错的那条就丢消息/未读**:旧命名话题 `acs` 为空(`isJoiner == false`)但本地已落库消息,客户端历史上"标准命名优先"的规则会保留它、隐藏真会话 → 用户看到会话不更新;
3. **误显示禁止图标**:`acs` 为空时客户端无法区分"未同步"与"已不是成员",会在会话行右下角画 `nosign`(🚫);
4. **历史数据永久分裂**:已有一批用户的本地历史落在 `usr<uid>`,服务端新会话在 `p2p...`,客户端无法自动合并;
5. 客户端对**未订阅**话题收到的 `{data}` 只能被动落库(已回执 `recv`),无法判断该不该收。
---
## 6. 需要后端确认 / 处理
1. **`friend_added` 等会话内系统提示为什么写到 `usr<uid>`?**
请改为只写**会话 topic**(当前为 `p2p...`),与 `payload.topic` / `GET /friends` 的 `topic` 保持一致。
2. **同一对好友为什么存在两套 Topic + 两份订阅?**
重装/删好友/重新加好友后,旧的 `usr<uid>` topic 与订阅应当被撤销或合并;
当前 `usrHnkwn8VSfKc`(本端 uid 维度)与 `p2pv97xq86jw3ceeTCfxVJ8pw` 同时存在,且系统提示双写。
3. **`{data}` 投递到未在 `me` 的 `meta sub` 里的话题是否预期?**
若不是,请修投递;若是(设计如此),请在文档里明确 `usr<uid>` 仍是合法会话名,并同步在 `meta sub` 里下发它的 `acs`/`seq`/`pub`,否则客户端无法正确渲染。
4. **是否可以把单聊统一收敛到 `p2p...`**,并提供旧 `usr<uid>` 历史消息的迁移/合并方案(客户端愿意配合按话题重定向)。
5. **(附带,需确认口径)** `friend.request.accepted` 的 `payload.user_id` 与文档不一致:
文档写 `payload:{"user_id":"usr_actor"}`(通过人),实测该帧在**通过人本人**设备上 `from` 和 `payload.user_id` 都是**申请人** `usrHnkwn8VSfKc`。客户端目前已按"`payload.user_id` = 对方"消费(用于刷新会话 context、反查会话),若后端要改回文档口径请提前同步。
---
## 7. 复现步骤
### 前置
- 账号 A:`usrHnkwn8VSfKc`(charles1),账号 B:`usrv97xq86jw3c`(echarles5),两台设备/两个模拟器分别登录;
- 两端都开启 Debug 日志(会打印 `入站[消息数据(data)]` 与 `SqlStore - msgReceived`)。
### 步骤
1. **先制造"旧命名话题"残留**:B 删除 A 好友(B 端:聊天 → A 的会话 → 右上角设置 → 「删除好友」,确认删除)。
- 预期:A 端收到 `{"info":{"...","event":"friend.deleted","payload":{"user_id":"usrv97xq86jw3c"}}}`。
- 此时 B 端本地已存在旧命名话题 `usr<A的uid>`(本次取证中为 `usrHnkwn8VSfKc`)。
2. A 向 B 发起好友申请(A 端:添加好友 → 搜索 `echarles5` → 发送申请)。
3. B 通过好友申请(B 端:通讯录 → 新的朋友 → 通过)。
4. **观察 B 端日志**,应能复现:
```text
入站[消息数据(data)] {"data":{"topic":"usrHnkwn8VSfKc","seq":17,"head":{"x-im-template":"friend_added","x-im-type":"system_notice"},...}}
SqlStore - msgReceived:来自话题 payload usrHnkwn8VSfKc,topicId=9,userId=3,seq=17
入站[在线状态(pres)] {"pres":{"topic":"me","src":"usrHnkwn8VSfKc","what":"msg","seq":17}}
```
随后拉真会话历史时,同 `ts`/`seq` 的提示又出现在 `p2pv97xq86jw3ceeTCfxVJ8pw`;
并且 `me` 的 `{meta sub}` 里**只有** `p2pv97xq86jw3ceeTCfxVJ8pw`。
5. **观察 B 端界面**:会话列表里 A 的那一行副标题是"你们已成为好友,现在可以开始聊天了。",右下角带 🚫 图标。
6. A 再发一条消息(如 `a`)→ B 端该会话行**副标题与未读数都不更新**(消息实际落在 `p2pv97xq86jw3ceeTCfxVJ8pw`,被去重隐藏)。
7. **服务端核对**:`topics` 表应能查到同一对好友的两行
(`usrHnkwn8VSfKc` 与 `p2pv97xq86jw3ceeTCfxVJ8pw`),以及双方的订阅记录;`messages` 表里 `friend_added` 在两个话题下各有一条(同 `ts`、同 `seq`)。
### 最小验证(不依赖"先删好友"这段历史)
让 A 申请、B 通过,直接核对服务端把 `friend_added` 写到了哪个 topic:若仍是 `usr<A的uid>`,即复现本问题(第 1 步只是让残留话题必然存在,便于稳定复现)。
---
## 8. 验收标准
1. 好友申请通过后,`friend_added` **只**下发到会话 topic(当前为 `p2p...`),且该 topic 出现在 `me` 的 `meta sub` 中;`usr<uid>` 上不再出现任何消息;
2. `me` 的 `meta sub` 中同一对好友**只有一个** P2P topic、一份订阅;历史遗留的 `usr<uid>` topic/订阅被撤销或合并;
3. 对不存在的会话不再有"投递到未订阅话题"的 `{data}`(或按文档补齐该话题的 `meta sub`);
4. 客户端表现(不依赖本轮客户端兜底):B 端会话列表只出现**一行** A 的会话,副标题为最新消息、未读角标正确、**无 🚫 图标**;A 端同理。
---
## 9. 客户端已做的兜底(不依赖后端修复,但不能替代后端修)
1. `Utils.duplicateP2PTopicNames` 改为**按对端 uid 分组 + 打分择一保留**
(本地有消息 8 > 仍是成员 `isJoiner` 4 > 标准命名 2 > 有 `priv.peer` 1),
让"有消息且在关系内"的 `p2p...` 真会话优先,同时保留"有历史优先"以免去重丢消息;
2. `ChatListViewCell` 的禁止图标改为**仅在确认服务端下发过 `acs.mode` 且确非成员**时显示
(`acs` 未同步不再误画 🚫);
3. 会话列表过滤对"本地已有消息但 `acs` 未下发"的单聊放宽(`isLocalKnownP2PConversation`),
保证"有消息就一定有会话行"。
> 以上兜底只解决"显示哪一条、图标对不对"。**系统提示被写进隐藏话题、历史分裂在两个 topic** 这两点必须后端收敛后才能根治。
+35
View File
@@ -0,0 +1,35 @@
**1. `/healthz`接口**
现在的健康检查(`nc -z localhost 6060`/`8000`)只能证明端口开着,测不出"连接池耗尽、死锁"这种进程还活着但已经不干活的情况。
要求:`GET /healthz`,不需要鉴权,正常200/异常503,body统一格式:
```json
{"status": "ok", "checks": {"mysql": "ok", "redis": "ok", "kafka": "ok"}}
```
异常时把出问题的依赖写清楚原因,其他正常的照样标ok:
```json
{"status": "error", "checks": {"mysql": "ok", "kafka": "timeout: dial tcp kafka:9092: i/o timeout"}}
```
各服务测什么:
- **im-server**:MySQL(唯一确认在用的外部依赖)。Redis代码里还没真正接入,先不用测
- **im-manager**:MySQL、Redis(`REDIS_DB=2`在用)、Kafka(崩溃日志能看到它真的在跑queue consumer,是真实依赖)
检测建议用真实操作确认能用(MySQL `SELECT 1`/`Ping()`,Redis `PING`,Kafka查broker元数据),不要只判断连接对象是不是nil。
顺手把两个服务Dockerfile里的`HEALTHCHECK`换成打这个接口,`deploy.sh`的探测逻辑也建议一起换。
---
**2. `/metrics`业务指标接口**(不急,可以往后放)
`im-server`加`GET /metrics`,标准Prometheus文本格式,Go生态直接用官方库`prometheus/client_golang/promhttp`,几行代码接完。
按价值排序,可以只做第一个:
| 指标 | 类型 | 说明 |
|---|---|---|
| `ws_connections_active` | Gauge | 当前WS连接数,**优先级最高**——直接对应之前"WS总掉线"那次排查,有曲线就不用再靠人工连测 |
| `http_requests_total{method,path,status}` | Counter | 按路径/状态码分的请求量,看5xx占比 |
| `http_request_duration_seconds` | Histogram | 接口延迟分布 |
| `db_query_duration_seconds` | Histogram | 数据库查询延迟 |
@@ -0,0 +1,181 @@
# IM 会议笔记前后端逐图对接协议
> 版本:v1.0
> 日期:2026-09-05
> 需求依据:[IM 会议笔记产品原型需求文档](./IM-会议笔记产品原型需求文档.md)
> 公共协议依据:[IM 前后端逐图对接协议](./IM-前后端对接文档.md)
> 说明:本文档的图片标题和顺序与会议笔记产品原型完全一致。后续合并时建议作为第 9 章的 `9.14 会议笔记`,原主文档 `9.14 接口示例` 顺延为 `9.15 接口示例`。
## 9.14 会议笔记
会议笔记属于企业工作台模块,复用会议主数据和统一文件能力。服务端负责判断会议是否结束、当前用户是否与会议有关、是否为会议记录员,以及文件查看和操作权限;客户端只根据响应中的状态与 `permissions` 展示页面,不自行推导权限。
本模块遵循主文档第 0 章的公共约定:产品 HTTP 使用 `X-MeetingIM-APIKey`、`X-IM-Schema: 1`、`X-Request-ID` 和 `Authorization: Bearer <token>`;写操作额外使用 `Idempotency-Key`;成功和失败响应均使用统一包裹结构。
会议笔记不产生移动端通知、不写入会议通知未读数,也不增加工作台底部 Tab 红点。客户端进入会议笔记列表或详情页时主动请求最新数据;从上传、预览或下载页面返回后,只刷新当前会议笔记页面。
### 9.14.1 会议笔记-工作台入口.png
<img src="images/会议笔记-工作台入口.png" alt="会议笔记-工作台入口" width="360">
**状态:工作台接口需改造。**
- `GET /v1/im/workbench` 的 `modules` 增加 `code="meeting_notes"`、`route="/meeting-notes"`。服务端按租户功能开关和当前用户权限决定是否返回该入口。
- 会议笔记模块固定返回 `unread_count=0`,不参与 `GET /v1/im/bootstrap` 的 `entry_badges.workbench_total_unread` 汇总。
- 当前工作台红点只对应“会议通知”模块的未读通知。日报、月报、打卡、部门框架、举报信箱和会议笔记均不产生工作台通知数。
- 客户端点击入口后请求 `GET /v1/im/meeting-notes`。切换企业后必须清空原租户的会议笔记缓存并重新加载。
### 9.14.2 会议笔记-列表.png
<img src="images/会议笔记-列表.png" alt="会议笔记-列表" width="360">
**状态:新增。**
- 列表:`GET /v1/im/meeting-notes?query=&cursor=&limit=20`。接口只返回当前用户作为发起人、会议记录员或参会人的已结束会议,不返回未开始、进行中或已取消会议。
- `query` 支持匹配会议主题、发起人、会议记录员、参会人和地点;搜索范围仍受当前用户会议可见权限限制。
- 列表按 `end_at` 倒序排列,分页使用不透明 `cursor`。客户端不得用本地时间过滤会议状态或自行扩大搜索范围。
- 已结束但尚未上传笔记的会议仍返回,使用 `note_status="empty"`、`file_count=0`;客户端展示会议基础信息,进入详情后显示“暂无笔记”。
- `permissions.can_view=true` 决定是否允许进入详情;`permissions.can_upload` 只对当前会议记录员为 `true`。普通参会人可以进入详情,但不显示上传按钮。
- `state="ended"` 是服务端按会议状态及服务端当前时间计算或持久化的有效状态。服务端必须以 `end_at` 和取消状态为准,不能信任客户端传入的“已结束”条件。
### 9.14.3 会议笔记-发布页面.png
<img src="images/会议笔记-发布页面.png" alt="会议笔记-发布页面" width="360">
**状态:现有会议创建接口需改造。**
- 页面字段顺序为发起人、地点、会议记录员、参会人、开始时间、时长、会议主题。会议记录员默认显示当前会议发起人。
- 创建仍使用 `POST /v1/im/meetings`,请求新增 `recorder_user_id`。客户端未传或传空字符串时,服务端默认使用当前登录用户;客户端不能通过 `organizer_id` 伪造发起人。
- 服务端校验会议记录员属于当前租户且账号有效。首版允许会议记录员是发起人或参会人;若选择了非参会人,服务端自动加入参会人列表并在响应中返回最终名单。
- 服务端保存的会议必须有 `recorder_user_id`;创建请求可省略该字段以使用发起人默认值。`attendee_user_ids`、`start_at`、`end_at` 和 `title` 为必填业务数据,`location` 可为空,且 `end_at` 必须晚于 `start_at`。
- 创建成功后的会议通知仍统一归属“会议通知”模块;会议笔记模块不额外生成通知或未读数。
- `PATCH /v1/im/meetings/{meeting_id}` 支持在会议结束前修改 `recorder_user_id`,并使用 `version` 做乐观锁。会议结束后返回 `MEETING_CLOSED`。
### 9.14.4 会议笔记-详情页.png
<img src="images/会议笔记-详情页.png" alt="会议笔记-详情页" width="360">
**状态:新增。**
- 详情:`GET /v1/im/meetings/{meeting_id}/notes`,一次返回会议基础信息、发起人、会议记录员、参会人、文件列表、笔记状态和当前用户权限。
- 会议基础信息与会议通知页面共用 `im_meetings` 和 `im_meeting_participants` 数据,不复制会议标题、时间和成员快照作为另一份可编辑主数据。
- 当前用户为会议记录员且会议已结束时,`permissions.can_upload=true`,客户端展示“上传文件”;其他用户不展示该按钮。
- 上传文件先调用 `POST /v0/file/u/`,Multipart 携带 `purpose=meeting_note` 和 `meeting_id`;上传成功取得 `file_id` 后,再调用 `POST /v1/im/meetings/{meeting_id}/notes/files` 建立会议笔记文件记录。
- 文件登记成功后才进入详情列表。上传成功但登记失败的临时文件不得展示,并由服务端按临时文件 TTL 回收。
- 当前会议记录员可替换或删除自己登记的文件;所有写操作均需 `Idempotency-Key`,服务端记录操作审计。
### 9.14.5 会议笔记-详情页(普通成员).png
<img src="images/会议笔记-详情页(普通成员).png" alt="会议笔记-详情页(普通成员)" width="360">
**状态:新增权限响应。**
- 普通参会人调用与 9.14.4 相同的详情接口。响应中 `permissions.can_view=true`、`can_upload=false`、`can_replace=false`、`can_delete=false`。
- 没有已上传文件时返回 `note_status="empty"` 和空数组 `files=[]`,客户端展示“暂无笔记”,不把空状态当作请求失败。
- 有文件时普通参会人只展示文件名、大小、类型、上传人、上传时间和预览入口,不展示上传、替换、删除按钮。
- 服务端必须在文件登记、替换、删除、预览和下载接口逐次校验会议关系及角色。隐藏客户端按钮不能代替服务端鉴权。
- 与会议无关、已离开可见范围或跨租户的用户统一返回 `RESOURCE_NOT_FOUND` 或 `FORBIDDEN`,不得通过 `meeting_id`、`note_file_id` 或 `file_id` 探测资源。
### 9.14.6 会议笔记-详情页-在线预览.png
<img src="images/会议笔记-详情页-在线预览.png" alt="会议笔记-详情页-在线预览" width="360">
**状态:基础文件读取已实现;会议笔记 ACL 和预览转换新增。**
- 文件项返回 `preview.status=ready|processing|unsupported|failed`、`preview.mode=native|converted|download_only` 和可选 `preview_file_id`,客户端根据状态选择直接预览、轮询转换状态或提示暂不支持。
- 原生可预览文件使用受认证的 `GET /v0/file/s/{file_id}`;转换预览使用 `GET /v0/file/s/{preview_file_id}`。两个接口都必须校验当前用户仍有会议笔记查看权限。
- 服务端不得返回长期有效的公共下载 URL。客户端缓存键至少包含 `tenant_code + user_id + file_id + etag`,退出登录或切换企业时清理受控缓存。
- `preview.status="processing"` 时客户端可在当前详情页低频刷新,建议 2 秒后开始、最长 30 秒;离开页面立即停止轮询。
### 9.14.7 会议笔记-详情页-文件下载中.png
<img src="images/会议笔记-详情页-文件下载中.png" alt="会议笔记-详情页-文件下载中" width="360">
**状态:复用统一下载任务;会议笔记 ACL 新增。**
- 下载使用 `GET /v0/file/s/{file_id}`,Header 传 API Key 与 `X-MeetingIM-Auth: token <base64_token>`,并支持 `Range` 和 `If-Range` 断点续传。
- 下载任务保存 `file_id,etag,total_bytes,downloaded_bytes,temp_path,state`。Token 更新后使用新 Token 续传,禁止把 Token 写入任务文件或日志。
- 服务端在每次下载及续传时重新校验会议可见权限。返回 `FILE_FORBIDDEN` 时客户端停止重试;返回 `FILE_UNAVAILABLE` 时标记文件失效并刷新详情。
- 下载进度属于客户端本地状态,不更新会议笔记业务状态、不产生工作台通知,也不写入未读数。
### 9.14.8 会议笔记-文件预览.png
<img src="images/会议笔记-文件预览.png" alt="会议笔记-文件预览" width="360">
**状态:客户端预览页;服务端复用 9.14.6 文件读取协议。**
- 预览页展示服务端返回的安全文件名和实际内容;标题不得直接信任未经清理的上传文件名。
- 图片、PDF、文本等受支持类型在应用内预览;Office 等类型优先使用受控转换文件,无法转换时按 `preview.mode="download_only"` 提示下载后打开。
- 文件被删除、审核拒绝、转换失败或权限撤销时,服务端返回稳定 `reason`,客户端关闭预览并刷新详情,不能继续显示旧的授权链接。
- 从预览页返回详情后调用 `GET /v1/im/meetings/{meeting_id}/notes` 获取最新文件状态。
### 9.14.9 接口示例
以下示例用于会议笔记 HTTP 联调快速查阅。成功响应统一包裹为 `{"code":0,"data":示例,"request_id":"...","server_time":"..."}`,下表只展示 `data`。
#### 9.14.9.1 工作台、会议记录员与会议笔记
| 接口 | 输入示例 | 成功 `data` 示例 |
| --- | --- | --- |
| `GET /v1/im/workbench` 需改造 | Query only | `{"layout_version":2,"modules":[{"code":"meeting_notifications","name":"会议通知","route":"/meetings","unread_count":3,"enabled":true,"permission":"enabled"},{"code":"meeting_notes","name":"会议笔记","route":"/meeting-notes","unread_count":0,"enabled":true,"permission":"enabled"}]}` |
| `POST /v1/im/meetings` 需改造 | Header `Idempotency-Key`;Body `{"title":"项目周会","start_at":"2026-09-05T10:00:00+08:00","end_at":"2026-09-05T10:30:00+08:00","location":"会议室 A","recorder_user_id":"usr_me","attendee_user_ids":["usr_2"],"reminder_minutes":[10]}` | `{"meeting_id":"mtg_1","title":"项目周会","organizer":{"user_id":"usr_me","display_name":"张三"},"recorder":{"user_id":"usr_me","display_name":"张三"},"start_at":"2026-09-05T02:00:00Z","end_at":"2026-09-05T02:30:00Z","state":"scheduled","participants":[{"user_id":"usr_me","response":"accepted"},{"user_id":"usr_2","response":"pending"}],"permissions":{"can_update":true,"can_cancel":true},"version":1}` |
| `PATCH /v1/im/meetings/{meeting_id}` 需改造 | Header `Idempotency-Key`;Body `{"recorder_user_id":"usr_2","version":1}` | `{"meeting_id":"mtg_1","recorder":{"user_id":"usr_2","display_name":"李四"},"version":2,"updated_at":"2026-09-05T01:30:00Z"}` |
| `GET /v1/im/meeting-notes?query=&cursor=&limit=20` 新增 | Query only | `{"items":[{"meeting_id":"mtg_1","title":"项目周会","organizer":{"user_id":"usr_me","display_name":"张三"},"recorder":{"user_id":"usr_me","display_name":"张三"},"start_at":"2026-09-05T02:00:00Z","end_at":"2026-09-05T02:30:00Z","location":"会议室 A","state":"ended","participant_count":2,"participant_names":["张三","李四"],"note_status":"empty","file_count":0,"permissions":{"can_view":true,"can_upload":true}}],"next_cursor":"","has_more":false}` |
| `GET /v1/im/meetings/{meeting_id}/notes` 新增 | Path meeting ID | `{"meeting":{"meeting_id":"mtg_1","title":"项目周会","organizer":{"user_id":"usr_me","display_name":"张三"},"recorder":{"user_id":"usr_me","display_name":"张三"},"start_at":"2026-09-05T02:00:00Z","end_at":"2026-09-05T02:30:00Z","location":"会议室 A","state":"ended","participants":[{"user_id":"usr_me","display_name":"张三"},{"user_id":"usr_2","display_name":"李四"}]},"note_status":"available","files":[{"note_file_id":"mnote_1","file_id":"file_1","name":"会议纪要.pdf","size":204800,"mime":"application/pdf","uploader":{"user_id":"usr_me","display_name":"张三"},"created_at":"2026-09-05T03:00:00Z","version":1,"preview":{"status":"ready","mode":"native","preview_file_id":"file_1"},"permissions":{"can_preview":true,"can_download":true,"can_replace":true,"can_delete":true}}],"permissions":{"can_view":true,"can_upload":true},"version":1}` |
| `POST /v0/file/u/` | Multipart 文件;字段 `purpose=meeting_note`、`meeting_id=mtg_1`;Header `X-MeetingIM-Auth` | `{"ctrl":{"code":200,"params":{"file_id":"file_1","url":"/v0/file/s/file_1","name":"会议纪要.pdf","size":204800,"mime":"application/pdf"}}}` |
| `POST /v1/im/meetings/{meeting_id}/notes/files` 新增 | Header `Idempotency-Key`;Body `{"file_id":"file_1"}` | `{"note_file_id":"mnote_1","meeting_id":"mtg_1","file_id":"file_1","name":"会议纪要.pdf","size":204800,"mime":"application/pdf","created_at":"2026-09-05T03:00:00Z","version":1,"preview":{"status":"processing","mode":"converted"}}` |
| `PUT /v1/im/meetings/{meeting_id}/notes/files/{note_file_id}` 新增 | Header `Idempotency-Key`;Body `{"file_id":"file_2","version":1}` | `{"note_file_id":"mnote_1","meeting_id":"mtg_1","file_id":"file_2","name":"会议纪要-v2.pdf","version":2,"updated_at":"2026-09-05T03:10:00Z"}` |
| `DELETE /v1/im/meetings/{meeting_id}/notes/files/{note_file_id}` 新增 | Header `Idempotency-Key`;Query `version=2` | `{"note_file_id":"mnote_1","deleted":true,"deleted_at":"2026-09-05T03:20:00Z"}` |
| `GET /v0/file/s/{file_id}` | Header `X-MeetingIM-Auth`;可带 `Range: bytes=0-` | 返回文件字节流及 `Content-Type`、`Content-Length`、`ETag`、`Accept-Ranges`;失败返回 `FILE_FORBIDDEN` 或 `FILE_UNAVAILABLE` |
### 9.14.10 字段与状态约定
| 字段 | 取值 | 前端处理 |
| --- | --- | --- |
| `meeting.state` | `scheduled`、`in_progress`、`ended`、`canceled` | 会议笔记列表只展示 `ended` |
| `note_status` | `empty`、`available` | `empty` 展示“暂无笔记”,`available` 展示文件列表 |
| `preview.status` | `ready`、`processing`、`unsupported`、`failed` | 分别进入预览、短时刷新、仅下载提示、失败提示 |
| `preview.mode` | `native`、`converted`、`download_only` | 使用原文件、转换文件或仅提供下载 |
| `permissions.can_view` | Boolean | 是否可以查看当前会议笔记 |
| `permissions.can_upload` | Boolean | 是否显示并允许使用上传按钮,仅会议记录员为 `true` |
| `permissions.can_replace` | Boolean | 是否允许替换当前笔记文件 |
| `permissions.can_delete` | Boolean | 是否允许删除当前笔记文件 |
| `permissions.can_preview` | Boolean | 是否允许预览当前文件 |
| `permissions.can_download` | Boolean | 是否允许下载当前文件;首版普通参会人若产品要求“只能预览”,该值返回 `false` |
客户端必须以服务端返回的 `permissions` 为准。特别是普通参会人“只能预览”的要求,由文件项 `can_preview=true,can_download=false` 表达;即使预览底层使用文件读取接口,客户端也不得提供导出、系统分享或保存到相册入口。
### 9.14.11 错误码与客户端处理
| `reason` | 典型 HTTP 状态 | 场景 | 客户端处理 |
| --- | --- | --- | --- |
| `MEETING_NOT_ENDED` | 409 | 未结束会议上传或登记笔记 | 提示“会议结束后可上传笔记”,刷新会议详情 |
| `MEETING_CLOSED` | 409 | 会议结束后修改记录员或会议数据 | 切换为只读并刷新详情 |
| `NOT_MEETING_RECORDER` | 403 | 非记录员上传、替换或删除文件 | 隐藏操作入口并刷新权限 |
| `MEETING_NOTE_VERSION_CONFLICT` | 409 | 替换或删除时版本过期 | 拉取最新详情,不自动覆盖 |
| `MEETING_NOTE_FILE_INVALID` | 400 | 文件未上传完成、用途不符或不属于当前租户 | 提示重新上传 |
| `FILE_PREVIEW_PROCESSING` | 409 | 转换预览尚未完成 | 保持当前页并按退避策略刷新 |
| `FILE_PREVIEW_UNSUPPORTED` | 422 | 文件类型不支持在线预览 | 根据 `can_download` 决定是否提供下载 |
| `FILE_FORBIDDEN` | 403 | 当前用户无文件访问权限 | 关闭预览或停止下载并刷新详情 |
| `FILE_UNAVAILABLE` | 404/410 | 文件被删除、失效或审核拒绝 | 标记不可用并刷新文件列表 |
| `RESOURCE_NOT_FOUND` | 404 | 会议或笔记不可见、跨租户访问 | 返回列表,不提示资源是否真实存在 |
### 9.14.12 前后端联调与验收要点
1. 发起会议时不传 `recorder_user_id`,响应中的记录员必须默认为当前发起人;指定其他记录员时,响应返回最终记录员和参会人名单。
2. 会议笔记列表只包含与当前用户有关的已结束会议;已结束但无文件的会议仍展示,详情为空状态。
3. 记录员进入详情显示上传按钮,普通参会人进入同一会议不显示上传按钮;直接调用写接口仍必须被服务端拒绝。
4. 普通参会人只允许在线预览时,响应为 `can_preview=true,can_download=false`,客户端不展示下载、分享或导出入口。
5. 文件上传成功但会议笔记登记失败时,详情列表不出现该文件;重复登记同一幂等键返回首次结果。
6. 文件替换和删除必须校验 `version`;旧版本请求返回冲突,不能覆盖新文件。
7. 进入列表、进入详情以及从上传/预览/下载页面返回时均主动刷新;断开 WebSocket 不影响会议笔记数据获取。
8. 会议笔记新增、替换、删除均不产生 Push、会议通知未读或工作台红点;工作台红点只随会议通知模块变化。
9. 跨租户、无关用户和已撤销权限用户无法通过会议 ID 或文件 ID 查看、预览、下载会议笔记。
10. 切换企业或退出登录后,会议笔记列表、详情、预览文件和下载临时数据按租户及账号隔离清理。
## 合并建议
1. 将本文 `9.14 会议笔记` 整段插入现有《IM 前后端逐图对接协议》第 9 章的 `9.13 总经理信箱-文案提示.png` 之后。
2. 将现有 `9.14 接口示例` 及其子章节顺延为 `9.15`,并把本文 `9.14.9.1` 的接口行合并到新的第 9 章接口示例表中。
3. 同步扩展会议数据模型:`im_meetings` 增加会议记录员字段,并新增会议笔记文件关联表;具体表结构在系统设计和数据库变更文档中统一维护。
4. 主文档第 9 章现有会议创建示例需要补充 `recorder_user_id`,工作台示例需要增加 `meeting_notes` 模块并明确其 `unread_count=0`。
File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 717 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 167 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 134 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 124 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 116 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 413 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 288 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 311 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 214 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 254 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 336 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 115 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 224 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 147 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 156 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 190 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 197 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 198 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 253 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 211 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Some files were not shown because too many files have changed in this diff Show More