diff --git a/chell要点.txt b/chell要点.txt index 3855e03..47c7d30 100644 --- a/chell要点.txt +++ b/chell要点.txt @@ -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 diff --git a/docs/IM-App版本更新前后端对接文档.md b/docs/IM-App版本更新前后端对接文档.md new file mode 100644 index 0000000..bd5c6b8 --- /dev/null +++ b/docs/IM-App版本更新前后端对接文档.md @@ -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¤t_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。 diff --git a/docs/IM-Kafka接入方案.md b/docs/IM-Kafka接入方案.md index 9b850cc..6c7402b 100644 --- a/docs/IM-Kafka接入方案.md +++ b/docs/IM-Kafka接入方案.md @@ -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 已接入的审计事件 diff --git a/docs/IM-Kafka接入范围识别.md b/docs/IM-Kafka接入范围识别.md index bdb3822..84633bb 100644 --- a/docs/IM-Kafka接入范围识别.md +++ b/docs/IM-Kafka接入范围识别.md @@ -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. 幂等与重试 diff --git a/docs/IM-产品原型需求文档.md b/docs/IM-产品原型需求文档.md index 8cfdf59..b1769e0 100644 --- a/docs/IM-产品原型需求文档.md +++ b/docs/IM-产品原型需求文档.md @@ -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 + +会议笔记-工作台入口 + +功能说明: + +工作台新增“会议笔记”入口,与打卡、月报、日报、部门框架、举报信箱、会议通知等企业应用并列展示。入口点击后进入会议笔记列表页。 + +业务规则: + +1. 会议笔记入口应由企业工作台配置和用户权限控制,未启用或无权限时不展示。 +2. 工作台底部 Tab 红点只展示可通知模块的未读通知总数;当前只统计“会议通知”,不统计会议笔记、日报、月报、打卡、部门框架和举报信箱。 +3. 入口排序、图标和名称应支持后台配置,但客户端需限制为白名单内部路由,不能跳转任意外部地址。 + +### 9.14.2 会议笔记-列表.png + +会议笔记-列表 + +功能说明: + +会议笔记列表展示“与我有关”的会议记录,顶部提供会议搜索。每条会议卡片展示会议主题、发起人、会议时间、参会人、会议时长和会议状态,并提供“会议笔记”按钮进入详情;部分卡片支持“查看更多”查看完整会议信息或参会人信息。 + +业务规则: + +1. “与我有关”仅展示当前用户作为发起人、会议记录员、参会人或具备组织查看权限的会议,且会议必须是已结束状态;未开始、进行中和已取消会议不进入会议笔记列表。 +2. 列表按会议开始时间倒序展示,今天、昨天、前天等相对日期应由客户端按本地时区展示,排序仍以后端时间戳为准。 +3. 搜索支持按会议主题、发起人、参会人和会议地点检索;搜索结果仍必须受会议可见权限限制。 +4. 已结束但会议记录员尚未上传笔记的会议仍需要在会议笔记列表展示,卡片只展示会议基础信息和已结束状态,进入详情后展示“暂无笔记”空状态。 +5. 已结束会议可查看有权限访问的会议笔记;会议记录员进入详情可上传文件,普通参会人进入详情不展示上传按钮且只能预览已上传文件。 +6. 参会人过多时列表只展示前几名并省略,点击“查看更多”进入详情查看完整信息。 + +### 9.14.3 会议笔记-发布页面.png + +会议笔记-发布页面 + +功能说明: + +会议发布页用于创建会议并指定会议记录员。页面包含发起人、地点、会议记录员、参会人、开始时间、时长、会议主题等字段,底部通过“发起会议”按钮提交。 + +业务规则: + +1. 发起人默认取当前登录用户,不允许普通用户手动伪造;具备代发权限时应明确展示实际发起人和创建人。 +2. 发布页面字段顺序中,会议记录员应位于参会人上方;会议记录员默认取会议发起人,用户可按权限改选其他成员。 +3. 会议记录员、参会人、开始时间、时长和会议主题为必填项;地点可选。 +4. 参会人可选择“所有人”或指定成员。选择所有人时应按组织权限和企业规模做确认提示,避免误发全员会议。 +5. 会议记录员默认拥有上传、替换、删除本人上传文件和查看全部会议笔记的权限;普通参会人只能查看和预览已上传文件,不提供上传入口。 +6. 创建成功后应进入会议详情或会议笔记列表;需要通知参会人时统一走“会议通知”模块,会议笔记模块本身不产生移动端通知。重复点击“发起会议”需防抖和幂等处理。 + +### 9.14.4 会议笔记-详情页.png + +会议笔记-详情页 + +功能说明: + +会议详情页展示会议主题、状态、发起人、时间、地点、会议记录员、参会人和会议时长。下方“全部笔记(文件)”展示会议相关文件,文件项包含类型图标、文件名、大小、上传人、上传日期和下载按钮。底部提供“上传文件”按钮。 + +业务规则: + +1. 会议基础信息应与会议通知模块共用同一份会议数据,避免会议时间、参会人或状态在两个模块展示不一致。 +2. 文件列表按上传时间倒序展示;同名文件允许存在,但应保留唯一文件 ID、上传人和上传时间。 +3. 文件类型需识别 PDF、Office 文档、图片、压缩包等常见格式;未知格式展示通用文件图标。 +4. “上传文件”按钮只对会议记录员展示;如后续扩展管理员代传能力,必须由服务端显式下发上传权限。 +5. 当前用户是会议记录员时,详情页展示上传文件按钮;当前用户只是参会人时,不展示上传按钮,文件操作只保留预览能力。 +6. 下载和预览都必须校验当前用户是否仍具备会议可见权限,不能仅凭文件 ID 访问。 + +### 9.14.5 会议笔记-详情页(普通成员).png + +会议笔记-详情页(普通成员) + +功能说明: + +普通成员或普通参会人进入会议笔记详情时,可查看会议基础信息;当没有可见笔记文件时,文件区域展示空状态“暂无笔记”。普通参会人页面不展示上传按钮,已上传文件仅提供预览入口。 + +业务规则: + +1. 普通参会人只可查看对自己开放的会议笔记文件;无文件或无权限时展示统一空状态。 +2. 空状态应区分“暂无笔记”和“无权限查看”两类业务原因,但默认文案可统一为不泄露权限细节的提示。 +3. 普通参会人不能通过前端构造请求上传、删除、替换或下载文件;服务端必须再次校验角色和会议权限。 +4. 若会议后续新增了对该成员可见的笔记文件,列表和详情应能刷新出新文件。 + +### 9.14.6 会议笔记-详情页-在线预览.png + +会议笔记-详情页-在线预览 + +功能说明: + +当文件支持在线预览时,文件项右侧展示“预览”按钮,文件状态显示“在线预览”。点击后进入文件预览页;不支持预览的文件仍展示下载按钮。 + +业务规则: + +1. 支持预览的文件类型至少包括 PDF 和常见图片,Office 文档是否预览取决于后端转换能力。 +2. 预览状态由服务端返回,客户端不得仅按扩展名判断是否可预览。 +3. 预览入口和下载入口都需要受文件权限控制;预览失败时应允许用户回到详情页并选择下载。 +4. 预览文件应使用短期授权地址或受控文件接口,禁止暴露长期有效的公开下载链接。 + +### 9.14.7 会议笔记-详情页-文件下载中.png + +会议笔记-详情页-文件下载中 + +功能说明: + +文件下载过程中,当前文件项展示“正在下载”、进度条和“取消”按钮;其他文件仍保持可下载状态。 + +业务规则: + +1. 每个文件需独立展示下载进度、成功、失败和取消状态。 +2. 下载中允许取消,取消后应停止网络请求并恢复可下载状态;已完成文件可进入系统打开或本地文件管理流程。 +3. 下载失败需展示可重试状态,并保留失败原因用于问题排查。 +4. 后台、断网、弱网场景下应保存下载任务状态,恢复网络后按产品策略继续或提示重试。 + +### 9.14.8 会议笔记-文件预览.png + +会议笔记-文件预览 + +功能说明: + +文件预览页展示文档内容,顶部提供返回、标题和更多菜单。预览区域展示页码进度,例如 `1/213`;更多菜单包含举报和分享操作。 + +业务规则: + +1. 文件预览页应支持多页文档翻页、缩放、滑动浏览和加载失败重试。 +2. 页码、加载状态和错误状态必须与当前文件一致,切换文件或返回详情页后应清理旧状态。 +3. 分享权限由会议和文件权限共同决定;无分享权限时隐藏分享入口或提示无权限。 +4. 举报入口用于举报文件内容或会议资料,提交后应关联会议 ID、文件 ID、上传人和当前查看人。 +5. 预览页不得缓存超出权限有效期的文件内容;退出登录、切换企业或权限被收回后必须清理本地可访问副本。 + ## 10. 个人资料、通用设置、隐私与青少年模式 ### 10.1 我的.png diff --git a/docs/IM-会议笔记产品原型需求文档.md b/docs/IM-会议笔记产品原型需求文档.md new file mode 100644 index 0000000..678f2c6 --- /dev/null +++ b/docs/IM-会议笔记产品原型需求文档.md @@ -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 + +会议笔记-工作台入口 + +功能说明: + +工作台新增“会议笔记”入口,与打卡、月报、日报、部门框架、举报信箱、会议通知等企业应用并列展示。入口点击后进入会议笔记列表页。 + +业务规则: + +1. 会议笔记入口应由企业工作台配置和用户权限控制,未启用或无权限时不展示。 +2. 工作台底部 Tab 红点只展示可通知模块的未读通知总数;当前只统计“会议通知”,不统计会议笔记、日报、月报、打卡、部门框架和举报信箱。 +3. 入口排序、图标和名称应支持后台配置,但客户端需限制为白名单内部路由,不能跳转任意外部地址。 + +### 9.14.2 会议笔记-列表.png + +会议笔记-列表 + +功能说明: + +会议笔记列表展示“与我有关”的会议记录,顶部提供会议搜索。每条会议卡片展示会议主题、发起人、会议时间、参会人、会议时长和会议状态,并提供“会议笔记”按钮进入详情;部分卡片支持“查看更多”查看完整会议信息或参会人信息。 + +业务规则: + +1. “与我有关”仅展示当前用户作为发起人、会议记录员、参会人或具备组织查看权限的会议,且会议必须是已结束状态;未开始、进行中和已取消会议不进入会议笔记列表。 +2. 列表按会议开始时间倒序展示,今天、昨天、前天等相对日期应由客户端按本地时区展示,排序仍以后端时间戳为准。 +3. 搜索支持按会议主题、发起人、参会人和会议地点检索;搜索结果仍必须受会议可见权限限制。 +4. 已结束但会议记录员尚未上传笔记的会议仍需要在会议笔记列表展示,卡片只展示会议基础信息和已结束状态,进入详情后展示“暂无笔记”空状态。 +5. 已结束会议可查看有权限访问的会议笔记;会议记录员进入详情可上传文件,普通参会人进入详情不展示上传按钮且只能预览已上传文件。 +6. 参会人过多时列表只展示前几名并省略,点击“查看更多”进入详情查看完整信息。 + +### 9.14.3 会议笔记-发布页面.png + +会议笔记-发布页面 + +功能说明: + +会议发布页用于创建会议并指定会议记录员。页面包含发起人、地点、会议记录员、参会人、开始时间、时长、会议主题等字段,底部通过“发起会议”按钮提交。 + +业务规则: + +1. 发起人默认取当前登录用户,不允许普通用户手动伪造;具备代发权限时应明确展示实际发起人和创建人。 +2. 发布页面字段顺序中,会议记录员应位于参会人上方;会议记录员默认取会议发起人,用户可按权限改选其他成员。 +3. 会议记录员、参会人、开始时间、时长和会议主题为必填项;地点可选。 +4. 参会人可选择“所有人”或指定成员。选择所有人时应按组织权限和企业规模做确认提示,避免误发全员会议。 +5. 会议记录员默认拥有上传、替换、删除本人上传文件和查看全部会议笔记的权限;普通参会人只能查看和预览已上传文件,不提供上传入口。 +6. 创建成功后应进入会议详情或会议笔记列表;需要通知参会人时统一走“会议通知”模块,会议笔记模块本身不产生移动端通知。重复点击“发起会议”需防抖和幂等处理。 + +### 9.14.4 会议笔记-详情页.png + +会议笔记-详情页 + +功能说明: + +会议详情页展示会议主题、状态、发起人、时间、地点、会议记录员、参会人和会议时长。下方“全部笔记(文件)”展示会议相关文件,文件项包含类型图标、文件名、大小、上传人、上传日期和下载按钮。底部提供“上传文件”按钮。 + +业务规则: + +1. 会议基础信息应与会议通知模块共用同一份会议数据,避免会议时间、参会人或状态在两个模块展示不一致。 +2. 文件列表按上传时间倒序展示;同名文件允许存在,但应保留唯一文件 ID、上传人和上传时间。 +3. 文件类型需识别 PDF、Office 文档、图片、压缩包等常见格式;未知格式展示通用文件图标。 +4. “上传文件”按钮只对会议记录员展示;如后续扩展管理员代传能力,必须由服务端显式下发上传权限。 +5. 当前用户是会议记录员时,详情页展示上传文件按钮;当前用户只是参会人时,不展示上传按钮,文件操作只保留预览能力。 +6. 下载和预览都必须校验当前用户是否仍具备会议可见权限,不能仅凭文件 ID 访问。 + +### 9.14.5 会议笔记-详情页(普通成员).png + +会议笔记-详情页(普通成员) + +功能说明: + +普通成员或普通参会人进入会议笔记详情时,可查看会议基础信息;当没有可见笔记文件时,文件区域展示空状态“暂无笔记”。普通参会人页面不展示上传按钮,已上传文件仅提供预览入口。 + +业务规则: + +1. 普通参会人只可查看对自己开放的会议笔记文件;无文件或无权限时展示统一空状态。 +2. 空状态应区分“暂无笔记”和“无权限查看”两类业务原因,但默认文案可统一为不泄露权限细节的提示。 +3. 普通参会人不能通过前端构造请求上传、删除、替换或下载文件;服务端必须再次校验角色和会议权限。 +4. 若会议后续新增了对该成员可见的笔记文件,列表和详情应能刷新出新文件。 + +### 9.14.6 会议笔记-详情页-在线预览.png + +会议笔记-详情页-在线预览 + +功能说明: + +当文件支持在线预览时,文件项右侧展示“预览”按钮,文件状态显示“在线预览”。点击后进入文件预览页;不支持预览的文件仍展示下载按钮。 + +业务规则: + +1. 支持预览的文件类型至少包括 PDF 和常见图片,Office 文档是否预览取决于后端转换能力。 +2. 预览状态由服务端返回,客户端不得仅按扩展名判断是否可预览。 +3. 预览入口和下载入口都需要受文件权限控制;预览失败时应允许用户回到详情页并选择下载。 +4. 预览文件应使用短期授权地址或受控文件接口,禁止暴露长期有效的公开下载链接。 + +### 9.14.7 会议笔记-详情页-文件下载中.png + +会议笔记-详情页-文件下载中 + +功能说明: + +文件下载过程中,当前文件项展示“正在下载”、进度条和“取消”按钮;其他文件仍保持可下载状态。 + +业务规则: + +1. 每个文件需独立展示下载进度、成功、失败和取消状态。 +2. 下载中允许取消,取消后应停止网络请求并恢复可下载状态;已完成文件可进入系统打开或本地文件管理流程。 +3. 下载失败需展示可重试状态,并保留失败原因用于问题排查。 +4. 后台、断网、弱网场景下应保存下载任务状态,恢复网络后按产品策略继续或提示重试。 + +### 9.14.8 会议笔记-文件预览.png + +会议笔记-文件预览 + +功能说明: + +文件预览页展示文档内容,顶部提供返回、标题和更多菜单。预览区域展示页码进度,例如 `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` 等图片重名。 diff --git a/docs/IM-会议笔记前后端对接文档.md b/docs/IM-会议笔记前后端对接文档.md new file mode 100644 index 0000000..07e0c13 --- /dev/null +++ b/docs/IM-会议笔记前后端对接文档.md @@ -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 `;写操作额外使用 `Idempotency-Key`;成功和失败响应均使用统一包裹结构。 + +会议笔记不产生移动端通知、不写入会议通知未读数,也不增加工作台底部 Tab 红点。客户端进入会议笔记列表或详情页时主动请求最新数据;从上传、预览或下载页面返回后,只刷新当前会议笔记页面。 + +### 9.14.1 会议笔记-工作台入口.png + +会议笔记-工作台入口 + +**状态:工作台接口需改造。** + +- `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 + +会议笔记-列表 + +**状态:新增。** + +- 列表:`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 + +会议笔记-发布页面 + +**状态:现有会议创建接口需改造。** + +- 页面字段顺序为发起人、地点、会议记录员、参会人、开始时间、时长、会议主题。会议记录员默认显示当前会议发起人。 +- 创建仍使用 `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 + +会议笔记-详情页 + +**状态:新增。** + +- 详情:`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 + +会议笔记-详情页(普通成员) + +**状态:新增权限响应。** + +- 普通参会人调用与 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 + +会议笔记-详情页-在线预览 + +**状态:基础文件读取已实现;会议笔记 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 + +会议笔记-详情页-文件下载中 + +**状态:复用统一下载任务;会议笔记 ACL 新增。** + +- 下载使用 `GET /v0/file/s/{file_id}`,Header 传 API Key 与 `X-MeetingIM-Auth: 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 + +会议笔记-文件预览 + +**状态:客户端预览页;服务端复用 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`。 diff --git a/docs/IM-多开跨Pod实时投递压测方案.md b/docs/IM-多开跨Pod实时投递压测方案.md new file mode 100644 index 0000000..e1b091d --- /dev/null +++ b/docs/IM-多开跨Pod实时投递压测方案.md @@ -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 重启/扩容期间的最大延迟和丢失率。 diff --git a/docs/IM-多开跨Pod消息投递改造方案.md b/docs/IM-多开跨Pod消息投递改造方案.md new file mode 100644 index 0000000..cc358a1 --- /dev/null +++ b/docs/IM-多开跨Pod消息投递改造方案.md @@ -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` 做全局路由。 diff --git a/docs/IM-工作台服务拆分迁移稳定方案.md b/docs/IM-工作台服务拆分迁移稳定方案.md index 18facd5..14ceb73 100644 --- a/docs/IM-工作台服务拆分迁移稳定方案.md +++ b/docs/IM-工作台服务拆分迁移稳定方案.md @@ -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" diff --git a/docs/IM-数据库变更汇总.sql b/docs/IM-数据库变更汇总.sql index 9530529..2c4b0ba 100644 --- a/docs/IM-数据库变更汇总.sql +++ b/docs/IM-数据库变更汇总.sql @@ -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, diff --git a/docs/IM-服务端架构与新功能开发指南.md b/docs/IM-服务端架构与新功能开发指南.md new file mode 100644 index 0000000..124be9a --- /dev/null +++ b/docs/IM-服务端架构与新功能开发指南.md @@ -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. 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`:已知未完成能力和深度优化项。 diff --git a/docs/IM-监控运维对接文档.md b/docs/IM-监控运维对接文档.md new file mode 100644 index 0000000..0febf61 --- /dev/null +++ b/docs/IM-监控运维对接文档.md @@ -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 健康状态不再只依赖端口探测。 diff --git a/docs/IM-群聊业务表设计.sql b/docs/IM-群聊业务表设计.sql index dc8532c..4348ea5 100644 --- a/docs/IM-群聊业务表设计.sql +++ b/docs/IM-群聊业务表设计.sql @@ -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; diff --git a/docs/images/会议笔记-列表.png b/docs/images/会议笔记-列表.png new file mode 100644 index 0000000..b9f0cbc Binary files /dev/null and b/docs/images/会议笔记-列表.png differ diff --git a/docs/images/会议笔记-发布页面.png b/docs/images/会议笔记-发布页面.png new file mode 100644 index 0000000..000b647 Binary files /dev/null and b/docs/images/会议笔记-发布页面.png differ diff --git a/docs/images/会议笔记-工作台入口.png b/docs/images/会议笔记-工作台入口.png new file mode 100644 index 0000000..9682483 Binary files /dev/null and b/docs/images/会议笔记-工作台入口.png differ diff --git a/docs/images/会议笔记-文件预览.png b/docs/images/会议笔记-文件预览.png new file mode 100644 index 0000000..53d7827 Binary files /dev/null and b/docs/images/会议笔记-文件预览.png differ diff --git a/docs/images/会议笔记-详情页-在线预览.png b/docs/images/会议笔记-详情页-在线预览.png new file mode 100644 index 0000000..54a8243 Binary files /dev/null and b/docs/images/会议笔记-详情页-在线预览.png differ diff --git a/docs/images/会议笔记-详情页-文件下载中.png b/docs/images/会议笔记-详情页-文件下载中.png new file mode 100644 index 0000000..eaf64b1 Binary files /dev/null and b/docs/images/会议笔记-详情页-文件下载中.png differ diff --git a/docs/images/会议笔记-详情页.png b/docs/images/会议笔记-详情页.png new file mode 100644 index 0000000..8537fc6 Binary files /dev/null and b/docs/images/会议笔记-详情页.png differ diff --git a/docs/images/会议笔记-详情页(普通成员).png b/docs/images/会议笔记-详情页(普通成员).png new file mode 100644 index 0000000..2704121 Binary files /dev/null and b/docs/images/会议笔记-详情页(普通成员).png differ diff --git a/docs/note/会议笔记-详情页-在线预览.png b/docs/note/会议笔记-详情页-在线预览.png new file mode 100644 index 0000000..54a8243 Binary files /dev/null and b/docs/note/会议笔记-详情页-在线预览.png differ diff --git a/docs/note/会议笔记-详情页-文件下载中.png b/docs/note/会议笔记-详情页-文件下载中.png new file mode 100644 index 0000000..eaf64b1 Binary files /dev/null and b/docs/note/会议笔记-详情页-文件下载中.png differ diff --git a/docs/note/会议笔记-详情页.png b/docs/note/会议笔记-详情页.png new file mode 100644 index 0000000..8537fc6 Binary files /dev/null and b/docs/note/会议笔记-详情页.png differ diff --git a/docs/note/会议笔记-详情页(普通成员).png b/docs/note/会议笔记-详情页(普通成员).png new file mode 100644 index 0000000..2704121 Binary files /dev/null and b/docs/note/会议笔记-详情页(普通成员).png differ diff --git a/docs/note/会议笔记.png b/docs/note/会议笔记.png new file mode 100644 index 0000000..b9f0cbc Binary files /dev/null and b/docs/note/会议笔记.png differ diff --git a/docs/note/发布页面.png b/docs/note/发布页面.png new file mode 100644 index 0000000..000b647 Binary files /dev/null and b/docs/note/发布页面.png differ diff --git a/docs/note/工作台.png b/docs/note/工作台.png new file mode 100644 index 0000000..9682483 Binary files /dev/null and b/docs/note/工作台.png differ diff --git a/docs/note/文件-预览.png b/docs/note/文件-预览.png new file mode 100644 index 0000000..53d7827 Binary files /dev/null and b/docs/note/文件-预览.png differ diff --git a/docs/problems/IM后端问题报告-好友通过后系统提示投递到旧命名话题.md b/docs/problems/IM后端问题报告-好友通过后系统提示投递到旧命名话题.md new file mode 100644 index 0000000..ad1f1e4 --- /dev/null +++ b/docs/problems/IM后端问题报告-好友通过后系统提示投递到旧命名话题.md @@ -0,0 +1,239 @@ +# IM 后端问题报告:好友申请通过后 `friend_added` 系统提示被投递到旧命名话题 `usr`,同一会话存在两套 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` 那条没有服务端下发的 `acs` → `isJoiner == false` → 客户端在会话行右下角显示出 `nosign`(🚫)禁止图标; +2. 客户端会话列表去重逻辑保留 `usr`、隐藏 `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`。 + +--- + +## 4. 与接口文档契约的冲突 + +| 文档位置 | 约定 | 实际 | +| --- | --- | --- | +| 《IM-前后端对接文档》0.5.4.3.1(约 330 行) | "会话内系统消息**必须进入对应 P2P 或群 Topic 的消息流**,占用 `seq`" | `friend_added` 同时进入 `usr`(非对应会话) | +| 同章 `system_notice` 下行示例(约 337 行) | `{"data":{"topic":"p2p...", ... "x-im-template":"friend_added" ...}}` | 实际 `"topic":"usrHnkwn8VSfKc"` | +| 同文档 3.5(约 1108 行) | 好友申请通过后服务端"在同一事务内建立双方关系和 **P2P 订阅**" | 建立了 `p2p...` 订阅,但旧 `usr` topic/订阅仍在,且系统提示写到旧 topic | +| 同文档 `GET /v1/im/friends`(约 1564 行) | 好友的会话 `topic` 为 `p2p...` | 一致 ✅(正因如此客户端更无法理解 `usr` 上的消息) | + +--- + +## 5. 对客户端的影响(为什么客户端只能兜、不能治) + +1. **会话列表出现两条同一好友的会话**,去重只能靠启发式规则(按对端 uid 分组打分)择一保留; +2. **保留错的那条就丢消息/未读**:旧命名话题 `acs` 为空(`isJoiner == false`)但本地已落库消息,客户端历史上"标准命名优先"的规则会保留它、隐藏真会话 → 用户看到会话不更新; +3. **误显示禁止图标**:`acs` 为空时客户端无法区分"未同步"与"已不是成员",会在会话行右下角画 `nosign`(🚫); +4. **历史数据永久分裂**:已有一批用户的本地历史落在 `usr`,服务端新会话在 `p2p...`,客户端无法自动合并; +5. 客户端对**未订阅**话题收到的 `{data}` 只能被动落库(已回执 `recv`),无法判断该不该收。 + +--- + +## 6. 需要后端确认 / 处理 + +1. **`friend_added` 等会话内系统提示为什么写到 `usr`?** + 请改为只写**会话 topic**(当前为 `p2p...`),与 `payload.topic` / `GET /friends` 的 `topic` 保持一致。 +2. **同一对好友为什么存在两套 Topic + 两份订阅?** + 重装/删好友/重新加好友后,旧的 `usr` topic 与订阅应当被撤销或合并; + 当前 `usrHnkwn8VSfKc`(本端 uid 维度)与 `p2pv97xq86jw3ceeTCfxVJ8pw` 同时存在,且系统提示双写。 +3. **`{data}` 投递到未在 `me` 的 `meta sub` 里的话题是否预期?** + 若不是,请修投递;若是(设计如此),请在文档里明确 `usr` 仍是合法会话名,并同步在 `meta sub` 里下发它的 `acs`/`seq`/`pub`,否则客户端无法正确渲染。 +4. **是否可以把单聊统一收敛到 `p2p...`**,并提供旧 `usr` 历史消息的迁移/合并方案(客户端愿意配合按话题重定向)。 +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`(本次取证中为 `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`,即复现本问题(第 1 步只是让残留话题必然存在,便于稳定复现)。 + +--- + +## 8. 验收标准 + +1. 好友申请通过后,`friend_added` **只**下发到会话 topic(当前为 `p2p...`),且该 topic 出现在 `me` 的 `meta sub` 中;`usr` 上不再出现任何消息; +2. `me` 的 `meta sub` 中同一对好友**只有一个** P2P topic、一份订阅;历史遗留的 `usr` 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** 这两点必须后端收敛后才能根治。 diff --git a/docs/requirement/监控需求.md b/docs/requirement/监控需求.md new file mode 100644 index 0000000..5b83966 --- /dev/null +++ b/docs/requirement/监控需求.md @@ -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 | 数据库查询延迟 | diff --git a/docs/对接文档最新/IM-会议笔记前后端对接文档.md b/docs/对接文档最新/IM-会议笔记前后端对接文档.md new file mode 100644 index 0000000..07e0c13 --- /dev/null +++ b/docs/对接文档最新/IM-会议笔记前后端对接文档.md @@ -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 `;写操作额外使用 `Idempotency-Key`;成功和失败响应均使用统一包裹结构。 + +会议笔记不产生移动端通知、不写入会议通知未读数,也不增加工作台底部 Tab 红点。客户端进入会议笔记列表或详情页时主动请求最新数据;从上传、预览或下载页面返回后,只刷新当前会议笔记页面。 + +### 9.14.1 会议笔记-工作台入口.png + +会议笔记-工作台入口 + +**状态:工作台接口需改造。** + +- `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 + +会议笔记-列表 + +**状态:新增。** + +- 列表:`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 + +会议笔记-发布页面 + +**状态:现有会议创建接口需改造。** + +- 页面字段顺序为发起人、地点、会议记录员、参会人、开始时间、时长、会议主题。会议记录员默认显示当前会议发起人。 +- 创建仍使用 `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 + +会议笔记-详情页 + +**状态:新增。** + +- 详情:`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 + +会议笔记-详情页(普通成员) + +**状态:新增权限响应。** + +- 普通参会人调用与 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 + +会议笔记-详情页-在线预览 + +**状态:基础文件读取已实现;会议笔记 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 + +会议笔记-详情页-文件下载中 + +**状态:复用统一下载任务;会议笔记 ACL 新增。** + +- 下载使用 `GET /v0/file/s/{file_id}`,Header 传 API Key 与 `X-MeetingIM-Auth: 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 + +会议笔记-文件预览 + +**状态:客户端预览页;服务端复用 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`。 diff --git a/docs/对接文档最新/IM-前后端对接文档.md b/docs/对接文档最新/IM-前后端对接文档.md new file mode 100644 index 0000000..9a11672 --- /dev/null +++ b/docs/对接文档最新/IM-前后端对接文档.md @@ -0,0 +1,2936 @@ +# IM 前后端逐图对接协议 + +> 版本:v2.0 +> 日期:2026-08-11 +> 需求依据:[IM 产品原型需求文档](./IM-产品原型需求文档.md) +> 设计依据:[IM 系统设计文档](./IM-系统设计文档.md) +> 说明:第 1 至第 11 章的章节编号、图片标题和顺序与产品原型完全一致。每张图均给出可直接开发、联调和验收的协议。 + +## 0. 公共对接约定 + +### 0.1 实现状态 + +| 状态 | 含义 | +| --- | --- | +| `已实现` | 当前服务端已有能力,可按本文联调 | +| `需改造` | 已有基础能力,但字段、鉴权或业务规则需要调整 | +| `新增` | 当前服务端没有该产品接口或数据模型 | +| `纯客户端` | 界面或系统能力,不调用服务端;关联的提交协议会单独指出 | + +文档中所有 `新增`、`需改造` 接口都是目标契约,不代表当前代码已经可用。前后端必须以本文字段为准实施,接口完成前可使用同结构 Mock。 + +### 0.2 API Key、启动入口与通道 + +#### 0.2.1 现有系统的 API Key 从哪里来 + +**API Key 不是客户端启动后向 IM 服务端申请得到的值。现有代码没有“获取 API Key”的 HTTP 或 WebSocket 接口。** + +API Key 是应用级接入标识,由部署人员在发布客户端前生成并配置: + +1. 新部署同时生成 API Key 和 HMAC Salt:在项目根目录执行 `go run ./keygen`。 +2. 已有服务端继续使用当前 `api_key_salt` 时,执行 `go run ./keygen -sequence <新序号> -salt '<服务端 api_key_salt>'` 生成兼容的普通应用 Key。 +3. 输出中的 `HMAC salt` 配置到服务端 `server/meetingim.conf` 的 `api_key_salt`;输出中的 `API key` 配置到 iOS、Android 或 Web 客户端构建配置/受控环境配置。 +4. 客户端启动时从自己的应用配置读取 API Key,不调用 IM 接口获取。禁止把 `-isroot 1` 生成的 Root Key 放进客户端。 + +生成示意: + +```text +$ go run ./keygen +API key v1 seq1 [ordinary]: +HMAC salt: +``` + +API Key、企业码和用户 Token 是三个不同概念: + +| 值 | 取得时间 | 作用 | 是否代表用户身份 | +| --- | --- | --- | --- | +| `api_key` | 应用发布/环境配置时预置 | 允许应用访问 MeetingIM 入口 | 否 | +| `tenant_code` | 用户输入企业码 | 将 Session 路由并绑定到企业 | 否 | +| `token` | 用户登录成功后由服务端返回 | 标识当前租户内的登录用户 | 是 | + +API Key 可以被客户端应用提取,因此不能把它当成用户密码或租户授权。现有 `checkAPIKey` 只校验 Key 的格式和 HMAC 签名;最终权限仍必须依赖租户 Session、用户 Token 和 Topic/业务 ACL。 + +当前 `checkAPIKey` 没有按 `sequence` 吊销旧 Key 的逻辑。生成新序号不会自动让旧 Key 失效;若需要精细轮换,服务端必须增加最低可接受序号或撤销表。直接更换 `api_key_salt` 会让所有旧 Key 同时失效,必须与客户端升级协调。 + +#### 0.2.2 客户端怎样携带 API Key + +| 通道 | 地址 | 认证 | 用途 | +| --- | --- | --- | --- | +| WebSocket(浏览器/通用方式) | `wss://{host}/{api_path}v0/channels?apikey={api_key}` | Upgrade 校验 API Key,`hi` 绑定租户,`login` 登录 | Topic、消息、回执、在线状态、通话信令 | +| WebSocket(支持自定义 Header 的原生客户端) | `wss://{host}/{api_path}v0/channels` | Upgrade Header `X-MeetingIM-APIKey` | 同上 | +| 产品 HTTP | `https://{host}/{api_path}v1/im/*` | `Authorization: Bearer ` | 好友、群、收藏、搜索、工作台和设置 | +| 文件 HTTP | `POST /v0/file/u/`、`GET /v0/file/s/{file_id}` | 当前用 `X-MeetingIM-Auth: token ` | 文件上传下载;禁止 Query 传 Token | + +HTTP 请求统一通过 `X-MeetingIM-APIKey` 携带。浏览器 WebSocket 无法设置自定义 Upgrade Header 时才使用 `apikey` Query;日志和监控必须对该参数脱敏。产品 HTTP 还必须带 `X-IM-Schema: 1` 和 `X-Request-ID`;写操作带 `Idempotency-Key`。生产环境只允许 HTTPS/WSS。Token、密码、验证码、证件号不得写入 URL、普通日志、埋点或崩溃报告。 + +WebSocket Upgrade 阶段由 `server/hdl_websock.go` 先调用 `checkAPIKey`。缺少或无效时不会建立 WebSocket,直接返回 HTTP 403: + +```json +{"ctrl":{"code":403,"text":"valid API key required","ts":"2026-08-11T08:30:15.123Z"}} +``` + +客户端应把该错误视为应用配置/环境配置错误,停止自动重连并上报脱敏诊断;它不是账号密码错误,也不能通过重新登录解决。 + +两种连接方式的第一个请求对照如下: + +| 连接方式 | 发起第一个网络请求前已有的值 | 第一个网络请求 | 第一个响应得到的值 | 下一步 | +| --- | --- | --- | --- | --- | +| WebSocket(推荐) | `host + api_key + tenant_code` | 带 API Key Upgrade `/v0/channels` | HTTP 101 和已建立的 WebSocket;不会返回新 API Key 或 sid | 发送 `{hi tenant}` | +| Long Polling(兼容) | `host + api_key + tenant_code` | `POST /v0/channels/lp` | HTTP 201 和 `ctrl.params.sid` | 带 `sid` POST `{hi}`,再 GET 等待响应 | + +现有实现对应关系: + +| 源码 | 作用 | +| --- | --- | +| `keygen/keygen.go` | 生成普通/Root API Key 和服务端 HMAC Salt | +| `server/api_key.go` | `checkAPIKey` 校验 API Key 格式与签名 | +| `server/http.go` | 按 Header、Query、Form、Cookie 顺序读取 API Key | +| `server/hdl_websock.go` | WebSocket Upgrade 前校验 API Key,无效直接 403 | +| `server/hdl_longpoll.go` | 创建 Long Polling Session 并返回 `sid` | +| `server/session.go` | 处理 `{hi}` 租户绑定、`{login}` 和登录 Token 返回 | + +现有 API Key 是应用级、非租户级,同一个普通应用 Key 可以进入不同企业;具体租户只能由后续 `{hi}.tenant` 决定。若将来要求每个租户独立 API Key,需要新增 Key 与租户绑定关系,不能只修改客户端配置。 + +### 0.3 多租户强制规则 + +1. 未登录阶段只传公开的 `tenant_code`;客户端永远不接收、不提交数据库 `tenant_id`。 +2. WebSocket 第一条消息必须是 `{hi}`,其中 `tenant` 为企业码。服务端解析后把租户固定到 Session。 +3. Token 内部绑定租户。Token 租户与当前 Session 租户不一致时返回 `TENANT_MISMATCH`,不得自动切换。 +4. 切换企业必须退出登录、关闭 WebSocket、清除该租户的 Token/缓存,再重新解析企业码和握手。 +5. 已登录 HTTP 请求的租户和用户只从 Token Principal 获取。请求体中的 `tenant_id`、`user_id` 不能覆盖身份。 +6. 跨租户对象统一返回 `RESOURCE_NOT_FOUND` 或 `FORBIDDEN`,不得暴露对象是否存在。 + +### 0.4 HTTP 请求与响应 + +成功响应统一为: + +```json +{ + "code": 0, + "data": {}, + "request_id": "01K...", + "server_time": "2026-08-11T08:30:15.123Z" +} +``` + +失败响应统一为: + +```json +{ + "code": 30001, + "reason": "GROUP_MUTED", + "message": "permission denied", + "details": {}, + "request_id": "01K...", + "server_time": "2026-08-11T08:30:15.123Z" +} +``` + +客户端只使用稳定的 `reason` 分支和本地化文案,不解析 `message`。ID、Topic、Seq 游标在客户端均按字符串处理;时间统一为 UTC RFC3339 毫秒。分页统一使用 `limit` 与不透明 `cursor`,返回 `items`、`next_cursor`、`has_more`。 + +### 0.5 WebSocket 建连、登录和恢复顺序 + +#### 0.5.1 现有服务端可直接执行的冷启动流程 + +```text +读取客户端预置的 host 和普通 API Key +-> 用户输入/客户端读取 tenant_code +-> 可选:POST /v1/im/tenants/resolve 校验企业码并取得公开配置 +-> 带 API Key 完成 WebSocket Upgrade +-> hi(tenant_code) +-> 收到 hi 的 201 ctrl,确认 tenant 与服务端限制 +-> 有历史 Token 时 token login,否则 basic login/acc +-> 收到 login 的 200 ctrl,保存 user/token/expires +-> sub(me) 拉取本人资料和会话订阅 +-> sub(fnd) 建立搜索 Topic +-> 订阅需要在线的会话并按 seq 增量拉取 +-> 进入可发送状态 +``` + +当前服务端已提供 `/v1/im/tenants/resolve`,用于进入企业前校验企业码并返回公开配置;但它不能代替 WebSocket 第一帧 `{hi}`。即使 HTTP 解析成功,WebSocket Upgrade 返回 `101 Switching Protocols` 后仍必须先发送下面的 `{hi}` JSON 文本帧,并使用同一个 `tenant_code` 绑定当前 Session。 + +`hi` 请求: + +```json +{"hi":{"id":"hi-1","ver":"0.25","ua":"android/1.0.0","dev":"device-id","lang":"zh-CN","tenant":"acme"}} +``` + +`login` 请求使用 `basic` 或 `token`: + +```json +{"login":{"id":"login-1","scheme":"basic","secret":"base64(username:password)"}} +``` + +```json +{"login":{"id":"login-2","scheme":"token","secret":"<上次登录响应原样返回的 token 字符串>"}} +``` + +`basic.secret` 是 `base64(username + ":" + password)`。Base64 只是协议编码,不是加密,Basic 注册和登录必须使用 HTTPS/WSS;服务端解析密码后使用 bcrypt 哈希,数据库不保存密码明文。Token 登录必须把上次 `params.token` 的 JSON 字符串原样填入 `secret`,不能再次 Base64 编码。登录成功 `{ctrl}` 的 `params` 至少包含 `user`、`authlvl`、`token`、`expires`。产品 HTTP 也使用同一个 `params.token`:`Authorization: Bearer `,客户端不要解码或二次编码。Token 对客户端是不可解析的 opaque 字符串,只能存入系统安全存储。`id` 只用于匹配本次 Session 的命令响应,不是业务幂等键。 + +当前登录 Token 默认有效期为 14 天,不单独设置 Refresh Token 或刷新接口。每次 Basic 登录或未过期 Token 登录成功后,服务端都会返回一个新的 14 天 Token;客户端必须原子覆盖安全存储中的旧 `token/expires`。Token 已过期或校验失败时不能续期,必须回到账号密码登录。 + +`hi` 成功示例: + +```json +{ + "ctrl": { + "id": "hi-1", + "code": 201, + "text": "created", + "params": { + "ver": "0.25", + "tenant": {"code": "acme", "name": "示例企业"}, + "maxFileUploadSize": 104857600, + "callTimeout": 30, + "iceServers": [] + } + } +} +``` + +客户端只使用实际响应中存在的限制字段;未知字段忽略。`hi` 返回非 2xx 时必须关闭连接,不得继续发送 `login`。 + +登录成功后发送: + +```json +{"sub":{"id":"sub-me-1","topic":"me","get":{"what":"desc sub tags cred"}}} +``` + +客户端按相同 `id` 接收 `{ctrl}` 和一个或多个 `{meta}`,从 `meta.desc` 取得本人资料和读写游标,从 `meta.sub` 取得会话 Topic、访问权限、`seq/read/recv` 与会话摘要。不要依赖 `{ctrl}`、`{meta}` 的固定先后顺序。 + +随后建立查找 Topic: + +```json +{"sub":{"id":"sub-fnd-1","topic":"fnd"}} +``` + +进入具体会话时订阅并增量拉取,`since` 使用客户端已完整持久化的最后 `seq + 1`: + +```json +{"sub":{"id":"sub-topic-1","topic":"grp...","get":{"what":"desc sub data del","data":{"since":125,"limit":50},"del":{"since":1,"limit":50}}}} +``` + +订阅成功后才允许 `{pub}`、`note read/recv` 等会话操作。断线恢复必须重新执行 `WebSocket Upgrade -> hi -> token login -> sub me -> 恢复 Topic`,同一连接内不得跳过 `hi` 或切换企业码。 + +#### 0.5.2 Long Polling 兼容流程:先取得的是 sid + +如果客户端无法使用 WebSocket,可以使用现有 `/v0/channels/lp`。该模式确实需要“先请求取得一个值”,但取得的是短期 Session ID `sid`,API Key 仍然必须由客户端预先持有。 + +第一步,创建 Long Polling Session: + +```http +POST /v0/channels/lp HTTP/1.1 +Host: im.example.com +X-MeetingIM-APIKey: <预置的普通 API Key> +Content-Length: 0 +``` + +服务端返回 HTTP 201: + +```json +{"ctrl":{"code":201,"text":"created","params":{"sid":"session-id"},"ts":"2026-08-11T08:30:15.123Z"}} +``` + +第二步开始,所有 Long Polling 请求都同时携带 API Key 和 `sid`。发送 `hi`: + +```http +POST /v0/channels/lp?sid=session-id HTTP/1.1 +X-MeetingIM-APIKey: <预置的普通 API Key> +Content-Type: application/json + +{"hi":{"id":"hi-1","ver":"0.25","ua":"android/1.0.0","platf":"android","lang":"zh-CN","tenant":"acme"}} +``` + +POST 只负责把命令送入 Session。客户端再保持一个不带 Body 的 GET 来接收下一条 `{ctrl}`、`{meta}`、`{data}`、`{pres}` 或 `{info}`: + +```http +GET /v0/channels/lp?sid=session-id HTTP/1.1 +X-MeetingIM-APIKey: <预置的普通 API Key> +``` + +收到 `hi` 成功后,以相同方式 POST `{login}`,再 GET 登录结果;登录成功后依次 POST `{sub me}`、`{sub fnd}` 和会话订阅,并持续发起下一次 GET 等待下行消息。 + +`sid` 无效或过期时服务端返回 HTTP 403 `invalid or expired session`,客户端必须重新创建 Long Polling Session 并从 `hi` 开始,不能只重发 `login`。Long Polling 是兼容方案,移动端和 Web 端正常网络条件下优先使用 WebSocket。 + +#### 0.5.3 企业解析与产品流程 + +第 1.1 节的 `POST /v1/im/tenants/resolve` 已实现,推荐产品入口流程为: + +```text +读取预置 API Key +-> POST /v1/im/tenants/resolve 校验企业码并取得服务地址/品牌配置 +-> 带同一 API Key 建立返回地址的 WebSocket +-> hi 再次绑定并校验 tenant_code +-> login -> sub me -> 恢复会话 +``` + +企业解析接口只改善入口、服务发现和品牌配置,不能代替 `{hi}` 的租户绑定。即使 HTTP 解析成功,WebSocket 第一帧仍必须带相同 `tenant_code`。如果解析响应中的 `endpoints` 不符合当前发布环境,客户端应优先使用内置的公网环境配置,并上报脱敏诊断。 + +#### 0.5.4 WSS 契约输入输出示例 + +本节用于 WebSocket 联调。除特别说明外: + +- WebSocket 地址为 `wss://{host}/{api_path}v0/channels?apikey={api_key}`;支持自定义 Header 的原生客户端也可用 `X-MeetingIM-APIKey`。 +- 连接建立后第一帧必须是 `{hi}`,`hi` 成功后才能 `{login}` 或 `{acc}`。 +- 每个上行命令都带客户端生成的 `id`,客户端用该 `id` 匹配 `{ctrl}`;同一连接内保持唯一即可。 +- 服务端下行可能乱序到达,客户端不能假设 `{ctrl}` 一定先于 `{meta}` 或 `{data}`。 +- 成功控制包一般为 `{ctrl.code=2xx}`;失败控制包一般为 `{ctrl.code=4xx/5xx}`,产品稳定错误优先读 `ctrl.params.reason`,没有时按 `code/text` 兜底。 + +##### 0.5.4.1 连接、握手、登录和注册 + +| 场景 | 客户端发送 | 服务端返回示例 | +| --- | --- | --- | +| WebSocket Upgrade | `GET /v0/channels?apikey=AQ...` | HTTP `101 Switching Protocols`;API Key 无效时 HTTP `403` | +| 租户握手 `hi` | `{"hi":{"id":"hi-1","ver":"0.25","ua":"Tinodios/1","platf":"ios","lang":"zh-CN","tenant":"default"}}` | `{"ctrl":{"id":"hi-1","code":201,"text":"created","params":{"ver":"0.25","tenant":{"code":"default","name":"Default Tenant"},"maxMessageSize":4194304,"maxFileUploadSize":33554432,"maxSubscriberCount":128}}}` | +| 账号密码登录 | `{"login":{"id":"login-1","scheme":"basic","secret":"base64(username:password)"}}` | `{"ctrl":{"id":"login-1","code":200,"text":"ok","params":{"user":"usr...","username":"alice","authlvl":"auth","token":"token...","expires":"2026-09-03T09:00:00Z"}}}` | +| Token 登录 | `{"login":{"id":"login-2","scheme":"token","secret":"上次登录返回的 token 原文"}}` | `{"ctrl":{"id":"login-2","code":200,"text":"ok","params":{"user":"usr...","authlvl":"auth","token":"new_token...","expires":"2026-09-03T09:00:00Z"}}}` | +| 用户名注册 | `{"acc":{"id":"acc-1","user":"new","scheme":"basic","username":"alice","phone":"","invite_code":"AbCdE","secret":"base64(alice:password)","login":true,"desc":{"public":{"fn":"alice"}}}}` | `{"ctrl":{"id":"acc-1","code":200,"text":"ok","params":{"user":"usr...","username":"alice","authlvl":"auth","token":"token...","expires":"2026-09-03T09:00:00Z"}}}` | +| 手机号注册 | `{"acc":{"id":"acc-2","user":"new","scheme":"basic","username":"alice","phone":"15500000000","secret":"base64(alice:password)","login":true,"cred":[{"meth":"tel","val":"15500000000","resp":"verification_token","params":{"challenge_id":"chg_123"}}],"desc":{"public":{"fn":"alice"}}}}` | `{"ctrl":{"id":"acc-2","code":200,"text":"ok","params":{"user":"usr...","username":"alice","authlvl":"auth","token":"token...","expires":"2026-09-03T09:00:00Z"}}}` | +| 登录失败 | `{"login":{"id":"login-bad","scheme":"basic","secret":"base64(alice:wrong)"}}` | `{"ctrl":{"id":"login-bad","code":401,"text":"authentication failed","params":{"reason":"AUTH_FAILED"}}}` | +| 租户不匹配 | `{"login":{"id":"login-tenant","scheme":"token","secret":"其他租户 token"}}` | `{"ctrl":{"id":"login-tenant","code":403,"text":"tenant mismatch","params":{"reason":"TENANT_MISMATCH"}}}` | + +##### 0.5.4.2 启动订阅、会话列表和增量恢复 + +| 场景 | 客户端发送 | 服务端返回示例 | +| --- | --- | --- | +| 订阅本人 `me` | `{"sub":{"id":"sub-me-1","topic":"me","get":{"what":"desc sub tags cred"}}}` | `{"ctrl":{"id":"sub-me-1","code":200,"text":"ok"}}`,随后可能收到 `{"meta":{"id":"sub-me-1","topic":"me","desc":{"public":{"fn":"alice"}},"sub":[{"topic":"grp...","public":{"name":"项目群"},"seq":12,"read":10,"recv":12,"updated":"2026-08-20T09:00:00Z"}]}}` | +| 订阅查找 `fnd` | `{"sub":{"id":"sub-fnd-1","topic":"fnd"}}` | `{"ctrl":{"id":"sub-fnd-1","code":200,"text":"ok"}}` | +| 订阅会话并拉历史 | `{"sub":{"id":"sub-topic-1","topic":"grp...","get":{"what":"desc sub data del","data":{"since":1,"limit":50},"del":{"since":1,"limit":50}}}}` | `{"ctrl":{"id":"sub-topic-1","code":200,"text":"ok"}}`,随后 `{"meta":{"id":"sub-topic-1","topic":"grp...","desc":{"public":{"name":"项目群"},"seq":12},"sub":[{"user":"usr...","mode":"JRWP"}]}}` 和多条 `{data}` | +| 断线恢复增量 | `{"sub":{"id":"resume-grp-1","topic":"grp...","get":{"what":"data del","data":{"since":125,"limit":50},"del":{"since":1,"limit":50}}}}` | `{"data":{"topic":"grp...","seq":125,"from":"usr...","ts":"2026-08-20T09:00:00Z","content":{"text":"恢复后的第一条"}}}` | +| 只拉元数据 | `{"get":{"id":"get-topic-1","topic":"grp...","what":"desc sub"}}` | `{"meta":{"id":"get-topic-1","topic":"grp...","desc":{"seq":12,"public":{"name":"项目群"}},"sub":[{"user":"usr...","read":10,"recv":12}]}}` | +| 离开/取消订阅 | `{"leave":{"id":"leave-1","topic":"grp...","unsub":false}}` | `{"ctrl":{"id":"leave-1","code":200,"text":"ok"}}` | + +##### 0.5.4.3 发送消息、媒体卡片、名片和转发卡片 + +| 类型 | 客户端发送 | 服务端返回/下行示例 | +| --- | --- | --- | +| 文本消息 | `{"pub":{"id":"pub-text-1","topic":"p2p...","noecho":false,"head":{"mime":"application/json","x-im-schema":1,"x-im-type":"text","x-im-client-mid":"mid_1"},"content":{"text":"你好"}}}` | 发送方收到 `{"ctrl":{"id":"pub-text-1","code":202,"text":"accepted","params":{"seq":12}}}`;订阅者收到 `{"data":{"topic":"p2p...","seq":12,"from":"usr...","head":{"x-im-type":"text","x-im-client-mid":"mid_1"},"content":{"text":"你好"}}}` | +| 图片消息 | `{"pub":{"id":"pub-img-1","topic":"grp...","head":{"mime":"application/json","x-im-schema":1,"x-im-type":"image","x-im-client-mid":"mid_img_1"},"content":{"file_id":"file_img","thumbnail_file_id":"file_thumb","name":"a.jpg","size":12345,"mime":"image/jpeg","width":1080,"height":720}}}` | `{"ctrl":{"id":"pub-img-1","code":202,"params":{"seq":13}}}` | +| 语音消息 | `{"pub":{"id":"pub-voice-1","topic":"grp...","head":{"mime":"application/json","x-im-schema":1,"x-im-type":"voice","x-im-client-mid":"mid_voice_1"},"content":{"file_id":"file_voice","duration_ms":8000,"size":4096,"mime":"audio/aac","waveform":[1,3,5,2]}}}` | `{"ctrl":{"id":"pub-voice-1","code":202,"params":{"seq":14}}}` | +| 文件消息 | `{"pub":{"id":"pub-file-1","topic":"grp...","head":{"mime":"application/json","x-im-schema":1,"x-im-type":"file","x-im-client-mid":"mid_file_1"},"content":{"file_id":"file_pdf","name":"方案.pdf","size":102400,"mime":"application/pdf","extension":"pdf","checksum":"sha256:..."}}}` | `{"ctrl":{"id":"pub-file-1","code":202,"params":{"seq":15}}}` | +| 名片消息 | `{"pub":{"id":"pub-card-1","topic":"p2p...","head":{"mime":"application/json","x-im-schema":1,"x-im-type":"contact_card","x-im-client-mid":"mid_card_1"},"content":{"user_id":"usr...","display_name":"张三","avatar_file_id":"","public_account":"zhangsan"}}}` | `{"ctrl":{"id":"pub-card-1","code":202,"params":{"seq":16}}}` | +| 合并转发卡片 | `{"pub":{"id":"pub-fwd-1","topic":"grp...","head":{"mime":"application/json","x-im-schema":1,"x-im-type":"forward_bundle","x-im-client-mid":"mid_fwd_1"},"content":{"bundle_id":"fbundle_1","title":"聊天记录","item_count":3,"preview":[{"from_display":"张三","summary":"你好"}]}}}` | `{"ctrl":{"id":"pub-fwd-1","code":202,"params":{"seq":17}}}` | +| @成员消息 | `{"pub":{"id":"pub-mention-1","topic":"grp...","head":{"mime":"application/json","x-im-schema":1,"x-im-type":"text","x-im-client-mid":"mid_at_1","mentions":[{"user_id":"usr...","offset":0,"length":3}]},"content":{"text":"@张三 看一下"}}}` | `{"ctrl":{"id":"pub-mention-1","code":202,"params":{"seq":18}}}` | +| @所有人 | `{"pub":{"id":"pub-all-1","topic":"grp...","head":{"mime":"application/json","x-im-schema":1,"x-im-type":"text","x-im-client-mid":"mid_all_1","mention_all":true},"content":{"text":"@所有人 开会"}}}` | 无权限时 `{"ctrl":{"id":"pub-all-1","code":403,"text":"permission denied","params":{"reason":"MENTION_ALL_FORBIDDEN"}}}` | +| 重复 `client_mid` 重试 | 与第一次发送完全相同,复用 `x-im-client-mid:"mid_1"` | 目标行为:返回第一次成功的同一 `seq`;不得落两条消息 | + +##### 0.5.4.3.1 会话内系统提示和可点击动作消息 + +截图中的灰色提示、入群邀请说明、好友已添加提示等属于会话内系统消息,必须进入对应 P2P 或群 Topic 的消息流,占用 `seq`,参与历史拉取、重连补偿和多端同步。它们不是 `info` 实时业务事件,也不是系统通知列表记录;`info` 只负责触发刷新,真正需要在聊天窗口长期展示的内容必须以 `{data}` 下发并入库。 + +客户端不得主动发送 `system_notice`、`system_action`;这两类消息只能由服务端受控身份生成。服务端下发时 `data.from` 可以为空,客户端以 `head.x-im-from="system"` 判断系统身份,不依赖真实用户头像。客户端收到未知 `template` 或未知 `action.type` 时,展示 `content.text`,忽略不可识别动作,不允许执行任意 URL、脚本或非白名单路由。 + +| 类型 | 适用场景 | 服务端下行示例 | 客户端渲染 | +| --- | --- | --- | --- | +| `system_notice` | “你已添加了安妮,现在可以开始聊天了”、撤回提示、群名修改、成员进出群等纯提示 | `{"data":{"topic":"p2p...","seq":19,"from":"","head":{"x-im-schema":1,"x-im-type":"system_notice","x-im-template":"friend_added","x-im-from":"system"},"content":{"text":"你已添加了安妮,现在可以开始聊天了。","level":"normal","template":"friend_added","actors":[{"user_id":"usr_anne","name":"安妮"}],"actions":[]}}}` | 居中灰色提示;不显示发送人头像;如 `actors.click` 为空则不可点 | +| `system_action` | 群邀请确认说明、审批入口、群设置入口、可点击人名或群名提示 | `{"data":{"topic":"grp...","seq":20,"from":"","head":{"x-im-schema":1,"x-im-type":"system_action","x-im-template":"group_invite_confirm_enabled","x-im-from":"system"},"content":{"text":"安妮邀请栗子来了加入了群聊","template":"group_invite_confirm_enabled","actors":[{"user_id":"usr_anne","name":"安妮","click":{"type":"user_profile","target_id":"usr_anne"}},{"user_id":"usr_lizi","name":"栗子来了","click":{"type":"user_profile","target_id":"usr_lizi"}}],"actions":[{"type":"open_group_invite_settings","label":"群聊邀请确认","target_id":"grp..."}]}}}` | 居中灰色或业务提示样式;可点击片段按 `actors.click` 高亮;按钮或文字入口按 `actions` 渲染 | + +`content` 字段约定: + +| 字段 | 类型 | 必填 | 说明 | +| --- | --- | --- | --- | +| `text` | string | 是 | 降级展示文案;客户端可直接展示,也可按 `template` 重新排版 | +| `template` | string | 是 | 服务端稳定模板编码,例如 `friend_added`、`group_member_joined`、`group_invite_confirm_enabled` | +| `level` | string | 否 | `normal`、`warning`、`danger`,影响颜色和强调程度 | +| `actors` | array | 否 | 文案中的用户、群、文件等实体;支持局部高亮和点击 | +| `actions` | array | 否 | 当前消息可触发的操作入口;只允许客户端白名单动作 | +| `extra` | object | 否 | 模板扩展参数;客户端不得依赖未知字段完成关键流程 | + +`actors[].click` 和 `actions[]` 的 `type` 首版白名单: + +| 类型 | 参数 | 行为 | +| --- | --- | --- | +| `user_profile` | `target_id` | 打开用户资料页 | +| `group_profile` | `target_id` | 打开群资料页 | +| `open_group_invite_settings` | `target_id` | 打开群邀请确认设置 | +| `open_group_join_requests` | `target_id` | 打开群入群审批列表 | +| `open_message` | `topic,seq` | 定位到指定消息 | + +##### 0.5.4.4 回执、正在输入、删除和清空记录 + +| 场景 | 客户端发送 | 服务端返回/下行示例 | +| --- | --- | --- | +| 送达回执 | `{"note":{"topic":"p2p...","what":"recv","seq":12}}` | 对端收到 `{"info":{"topic":"p2p...","from":"usr...","what":"recv","seq":12}}` | +| 已读回执 | `{"note":{"topic":"p2p...","what":"read","seq":12}}` | 对端收到 `{"info":{"topic":"p2p...","from":"usr...","what":"read","seq":12}}`;客户端本地把该 topic `read` 更新为 12 | +| 正在输入 | `{"note":{"topic":"p2p...","what":"kp"}}` | 对端收到 `{"info":{"topic":"p2p...","from":"usr...","what":"kp"}}`,本地超时消失 | +| 正在录音可选 | `{"note":{"topic":"grp...","what":"kpa"}}` | 对端收到 `{"info":{"topic":"grp...","from":"usr...","what":"kpa"}}`,不落库 | +| 删除单条/多条消息 | `{"del":{"id":"del-1","topic":"grp...","what":"msg","delseq":[{"low":12,"hi":13}],"hard":false}}` | `{"ctrl":{"id":"del-1","code":200,"text":"ok","params":{"del":3}}}`;其他端可能收到 `{"pres":{"topic":"grp...","what":"del","seq":3}}` | +| 清空当前用户视图 | `{"del":{"id":"del-all-1","topic":"p2p...","what":"msg","delseq":[{"low":1,"hi":126}],"hard":false}}` | `{"ctrl":{"id":"del-all-1","code":200,"text":"ok","params":{"del":4}}}` | + +##### 0.5.4.5 Topic 资料、个人群设置和标签 + +| 场景 | 客户端发送 | 服务端返回/下行示例 | +| --- | --- | --- | +| 更新群公开资料 | `{"set":{"id":"set-grp-1","topic":"grp...","desc":{"public":{"name":"新群名","avatar_file_id":"file_avatar","description":"新简介","version":2}}}}` | `{"ctrl":{"id":"set-grp-1","code":200,"text":"ok"}}`;成员收到 `{"pres":{"topic":"grp...","what":"upd"}}` | +| 更新个人资料 | `{"set":{"id":"set-me-1","topic":"me","desc":{"public":{"fn":"新昵称","avatar_file_id":"file_avatar"}}}}` | `{"ctrl":{"id":"set-me-1","code":200,"text":"ok"}}` | +| 更新会话私有设置 | `{"set":{"id":"set-private-1","topic":"grp...","sub":{"user":"usr_me","private":{"pinned":true,"mute_notifications":false,"nickname":"群昵称","version":2}}}}` | `{"ctrl":{"id":"set-private-1","code":200,"text":"ok"}}` | +| 设置搜索标签 | `{"set":{"id":"set-tags-1","topic":"me","tags":["usr:alice"]}}` | 普通用户无权限时 `{"ctrl":{"id":"set-tags-1","code":403,"text":"permission denied","params":{"what":"tags"}}}` | + +##### 0.5.4.6 系统通知、好友和群业务下行 + +服务端主动下行分两类:MeetingIM 原生 Topic 消息使用 `{data}`、`{info}`、`{pres}` 等帧;IM 产品业务事件使用 `{info}` 帧下发,前端按 `info.what + info.event` 分发刷新。产品业务事件不占普通聊天 `seq`,不等同于系统通知列表记录;系统消息入口的未读数来自 `im_system_notifications` 和 `GET /v1/im/system-notifications`。 + +用户维度业务事件统一格式: + +```json +{ + "info": { + "topic": "usr_target", + "from": "usr_actor", + "what": "im", + "event": "friend.request.accepted", + "payload": { + "user_id": "usr_actor" + }, + "ts": "2026-08-25T10:00:00Z" + } +} +``` + +群维度业务事件统一格式: + +```json +{ + "info": { + "topic": "grp_xxx", + "from": "usr_actor", + "what": "group", + "event": "group.member.joined", + "payload": { + "topic": "grp_xxx", + "user_id": "usr_xxx" + }, + "ts": "2026-08-25T10:00:00Z" + } +} +``` + +| 事件 | 接收方 | 服务端下行示例 | 客户端动作 | +| --- | --- | --- | --- | +| 好友申请创建 | 被申请人 | `{"info":{"topic":"usr_target","from":"usr_applicant","what":"im","event":"friend.request.created","payload":{"user_id":"usr_applicant"}}}` | 刷新 `GET /v1/im/friend-requests` 和 `GET /v1/im/bootstrap` 的新朋友未处理数;系统通知列表也会新增记录 | +| 好友申请通过 | 申请人、通过人本人 | `{"info":{"topic":"usr_target","from":"usr_actor","what":"im","event":"friend.request.accepted","payload":{"user_id":"usr_actor"}}}` | 刷新新朋友列表、好友列表、P2P 会话 context;申请人当前有系统通知,通过人本人当前无系统通知 | +| 好友申请拒绝 | 申请人、拒绝人本人 | `{"info":{"topic":"usr_target","from":"usr_actor","what":"im","event":"friend.request.rejected","payload":{"user_id":"usr_actor"}}}` | 刷新新朋友列表和 `bootstrap.entry_badges`;申请人当前有系统通知,拒绝人本人当前无系统通知 | +| 删除好友 | 被删除方、操作人本人 | `{"info":{"topic":"usr_target","from":"usr_actor","what":"im","event":"friend.deleted","payload":{"user_id":"usr_actor"}}}` | 刷新好友列表、会话 context,重新判断输入区 `can_send`;被删除方当前有系统通知,操作人本人当前无系统通知 | +| 拉黑/取消拉黑 | 操作人本人 | `{"info":{"topic":"usr_actor","from":"usr_actor","what":"im","event":"relationship.blocked","payload":{"user_id":"usr_actor"}}}` | 刷新黑名单、好友列表和会话 context | +| 修改好友备注 | 操作人本人 | `{"info":{"topic":"usr_actor","from":"usr_actor","what":"im","event":"friend.remark.updated","payload":{"user_id":"usr_actor"}}}` | 刷新好友列表和会话头显示名 | +| 消息撤回 | Topic 在线成员 | `{"info":{"topic":"p2p_or_grp","from":"usr_actor","what":"im","event":"message.recalled","payload":{"topic":"p2p_or_grp","seq":12,"version":2}}}` | 按 `topic+seq+version` 重新拉取或本地标记已撤回 | +| 消息编辑 | Topic 在线成员 | `{"info":{"topic":"p2p_or_grp","from":"usr_actor","what":"im","event":"message.edited","payload":{"topic":"p2p_or_grp","seq":12,"version":2}}}` | 按 `topic+seq+version` 重新拉取消息详情或修订记录 | +| 群业务变化 | 群 Topic 在线成员 | `{"info":{"topic":"grp...","from":"usr_actor","what":"group","event":"group.member.joined","payload":{"topic":"grp...","user_id":"usr_xxx"}}}` | 按事件刷新群详情、成员列表、入群申请、公告、禁言或角色权限;除操作人外当前会写系统通知 | +| 会议变化 | 会议参会人 | `{"info":{"topic":"usr_target","from":"usr_actor","what":"im","event":"meeting.updated","payload":{"meeting_id":"mtg_1","version":2,"state":"scheduled"}}}` | 刷新会议列表/详情;除操作者外当前会写系统通知 | +| 安全风险确认 | 操作人本人 | `{"info":{"topic":"usr_actor","from":"usr_actor","what":"im","event":"security.risk.continue","payload":{"risk_challenge_id":"risk_1","decision":"continue"}}}` | 刷新安全验证页面;当前会写系统通知 | + +业务事件写入 outbox 后会立即尝试投递;Hub 不可用或队列满时保持 pending,后台 poller 按在线租户重试。`registration.invite_used` 和 `security.password_reset` 是服务端内部消费事件,不作为普通业务事件下发给 App。 + +##### 0.5.4.7 WebRTC 通话信令 + +| 场景 | 客户端发送 | 服务端返回/下行示例 | +| --- | --- | --- | +| 发起语音通话 | `{"pub":{"id":"call-pub-1","topic":"p2p...","head":{"mime":"application/json","webrtc":"started","x-im-type":"call","x-im-client-mid":"mid_call_1"},"content":{"callId":"call_1","callType":"audio","audio":true,"video":false}}}` | `{"ctrl":{"id":"call-pub-1","code":202,"text":"accepted","params":{"seq":30}}}`;被叫收到同一 `{data seq=30}` | +| 被叫响铃 | `{"note":{"topic":"p2p...","what":"call","event":"ringing","seq":30,"payload":{"callId":"call_1"}}}` | 主叫收到 `{"info":{"topic":"p2p...","what":"call","event":"ringing","seq":30,"payload":{"callId":"call_1"}}}` | +| 被叫接听 | `{"note":{"topic":"p2p...","what":"call","event":"accept","seq":30,"payload":{"callId":"call_1"}}}` | 双方收到通话状态 `accept`;原消息可被服务端替换为 `head.webrtc:"accepted"` | +| SDP offer | `{"note":{"topic":"p2p...","what":"call","event":"offer","seq":30,"payload":{"callId":"call_1","sdp":"v=0..."}}}` | 对端收到 `{"info":{"topic":"p2p...","what":"call","event":"offer","seq":30,"payload":{"callId":"call_1","sdp":"v=0..."}}}` | +| SDP answer | `{"note":{"topic":"p2p...","what":"call","event":"answer","seq":30,"payload":{"callId":"call_1","sdp":"v=0..."}}}` | 对端收到 `event:"answer"` | +| ICE candidate | `{"note":{"topic":"p2p...","what":"call","event":"ice-candidate","seq":30,"payload":{"callId":"call_1","candidate":{"candidate":"candidate:...","sdpMid":"0","sdpMLineIndex":0}}}}` | 对端收到 `event:"ice-candidate"` | +| 挂断/取消 | `{"note":{"topic":"p2p...","what":"call","event":"hang-up","seq":30,"payload":{"callId":"call_1","reason":"cancelled"}}}` | 服务端替换原通话消息:`{"data":{"topic":"p2p...","seq":30,"head":{"replace":":30","webrtc":"missed","webrtc-duration":0,"x-im-type":"call"},"content":{"callId":"call_1","callType":"audio","status":"missed"}}}` | +| 异常事件 | `{"note":{"topic":"p2p...","what":"call","event":"media-state","seq":30,"payload":{"callId":"call_1"}}}` | 当前未开放时返回 `{"ctrl":{"code":400,"text":"malformed","params":{"reason":"CALL_EVENT_UNSUPPORTED"}}}` 或直接忽略,客户端不要提前依赖 | + +##### 0.5.4.8 WSS 错误、重试和客户端处理 + +| 场景 | 服务端返回示例 | 客户端处理 | +| --- | --- | --- | +| API Key 无效 | Upgrade HTTP `403 {"ctrl":{"code":403,"text":"valid API key required"}}` | 停止重连,上报环境配置错误 | +| 未先 `hi` 就登录 | `{"ctrl":{"id":"login-1","code":400,"text":"tenant required","params":{"reason":"TENANT_REQUIRED"}}}` | 关闭连接,从 Upgrade + `hi` 重来 | +| 登录失败 | `{"ctrl":{"id":"login-1","code":401,"text":"authentication failed","params":{"reason":"AUTH_FAILED"}}}` | 清密码,保留账号,显示统一账号或密码错误 | +| Token 过期 | `{"ctrl":{"id":"login-token-1","code":401,"text":"authentication failed","params":{"reason":"TOKEN_EXPIRED"}}}` | 清当前租户 token,回登录页 | +| 无 Topic 权限 | `{"ctrl":{"id":"pub-1","code":403,"text":"permission denied","params":{"reason":"MESSAGE_NOT_ALLOWED"}}}` | 发送气泡置为失败,不伪装已发送 | +| 消息过大 | `{"ctrl":{"id":"pub-1","code":413,"text":"message too large","params":{"reason":"MESSAGE_TOO_LARGE","max_size":4194304}}}` | 禁止重试,提示压缩或拆分 | +| 断线 | WebSocket close | 指数退避重连,重新 `hi -> token login -> sub me -> sub topics`,按最后完整 `seq + 1` 增量拉取 | + +##### 0.5.4.9 极光离线 Push 与重连补偿 + +离线 Push 只承担提醒和唤醒 App,不作为消息正文或业务状态真源。客户端收到极光 Push 后,必须重新登录 IM,通过 `topic + seq`、`call_id` 或系统通知 ID 拉取服务端状态。普通聊天、群 @、系统通知、好友/群审批、音视频来电都复用同一套 extras 分发协议。 + +客户端侧要求: + +- App 集成 JPush SDK,初始化使用极光 `AppKey`,不接触 `MasterSecret`。 +- 登录 IM 后将极光 alias 绑定为当前 `user_id`,例如 `usr...`;同一用户多设备允许绑定同一个 alias。 +- SDK 返回 `registration_id` 后调用 `PUT /v1/im/devices/{device_id}/push-token` 上报,`provider` 固定传 `jpush`。 +- 点击 Push 后按 `extras.type` 跳转,再按服务端数据补偿,不能只凭 Push payload 渲染最终状态。 + +服务端侧要求: + +- `jpush_master_secret` 只保存在后台配置 `im_jpush` 分组,IM Server 通过内部配置接口读取并调用极光 REST API。 +- 用户在线时优先 WebSocket 实时投递;用户离线、后台或不可投递时生成 Push 任务。 +- 万人大群实时 fanout 不靠 Push;离线只对需要提醒的成员生成极光 Push,客户端重连后按 Topic seq 拉取补偿。 +- Push payload 默认不带敏感正文,通知栏文案按用户隐私设置降级。 + +极光 Push extras 统一格式: + +```json +{ + "type": "im_message", + "tenant_code": "default", + "topic": "p2p...", + "seq": "128", + "sender_id": "usr_sender", + "message_type": "text" +} +``` + +| `extras.type` | 必填字段 | 客户端动作 | +| --- | --- | --- | +| `im_message` | `tenant_code,topic,seq` | 打开会话,按本地最后 seq 补齐消息并定位。 | +| `group_mention` | `tenant_code,topic,seq,sender_id` | 打开群会话并定位被 @ 消息。 | +| `call_invite` | `tenant_code,topic,seq,call_id,call_type,caller_id` | 打开来电页前先登录 IM 并确认通话仍为有效来电。 | +| `call_missed` | `tenant_code,topic,call_id` | 打开通话记录或对应会话。 | +| `system_notification` | `tenant_code,notification_id` | 打开系统消息列表并刷新详情。 | +| `friend_request` | `tenant_code,request_id` | 打开新朋友列表并刷新未处理数。 | +| `group_request` | `tenant_code,topic,request_id` | 打开群审批入口并刷新列表。 | + +普通离线消息极光 payload 示例: + +```json +{ + "platform": "all", + "audience": { + "alias": ["usr_target"] + }, + "notification": { + "alert": "你收到一条新消息", + "android": { + "title": "MeetingIM", + "alert": "你收到一条新消息", + "extras": { + "type": "im_message", + "tenant_code": "default", + "topic": "p2p...", + "seq": "128" + } + }, + "ios": { + "alert": { + "title": "MeetingIM", + "body": "你收到一条新消息" + }, + "sound": "default", + "badge": "+1", + "extras": { + "type": "im_message", + "tenant_code": "default", + "topic": "p2p...", + "seq": "128" + } + } + }, + "options": { + "time_to_live": 86400, + "apns_production": false, + "classification": 1 + } +} +``` + +音视频来电极光 payload 示例: + +```json +{ + "platform": "all", + "audience": { + "alias": ["usr_callee"] + }, + "notification": { + "alert": "收到一通语音通话", + "android": { + "title": "语音通话", + "alert": "张三邀请你语音通话", + "extras": { + "type": "call_invite", + "tenant_code": "default", + "topic": "p2p...", + "seq": "30", + "call_id": "call_01K...", + "call_type": "audio", + "caller_id": "usr_caller" + } + }, + "ios": { + "alert": { + "title": "语音通话", + "body": "张三邀请你语音通话" + }, + "sound": "default", + "badge": "+1", + "extras": { + "type": "call_invite", + "tenant_code": "default", + "topic": "p2p...", + "seq": "30", + "call_id": "call_01K...", + "call_type": "audio", + "caller_id": "usr_caller" + } + } + }, + "options": { + "time_to_live": 60, + "apns_production": false, + "classification": 1 + } +} +``` + +#### 0.5.5 Long Polling 与文件基础示例 + +##### 0.5.5.1 WebSocket 与文件基础接口 + +| 接口 | 输入示例 | 成功输出示例 | +| --- | --- | --- | +| `GET /v0/channels?apikey={api_key}` | WebSocket Upgrade 后首帧 `{"hi":{"id":"hi-1","ver":"0.25","ua":"ios/1.0","lang":"zh-CN","tenant":"default"}}` | `{"ctrl":{"id":"hi-1","code":201,"text":"created","params":{"tenant":{"code":"default","name":"Default Tenant"},"ver":"0.25","maxMessageSize":4194304}}}` | +| WebSocket `login basic` | `{"login":{"id":"login-1","scheme":"basic","secret":"base64(username:password)"}}` | `{"ctrl":{"id":"login-1","code":200,"params":{"user":"usr...","token":"token...","expires":"2026-09-03T09:00:00Z","authlvl":"auth"}}}` | +| WebSocket `acc new` | `{"acc":{"id":"acc-1","user":"new","scheme":"basic","username":"alice","phone":"15500000000","secret":"base64(alice:password)","login":true,"cred":[{"meth":"tel","val":"15500000000","resp":"verification_token","params":{"challenge_id":"chg_123"}}]}}` | `{"ctrl":{"id":"acc-1","code":200,"params":{"user":"usr...","username":"alice","token":"token...","expires":"2026-09-03T09:00:00Z"}}}` | +| `POST /v0/channels/lp` | Long Polling 首包 `{"hi":{"id":"hi-1","ver":"0.25","tenant":"default"}}` | `{"ctrl":{"id":"hi-1","code":201,"params":{"sid":"lp_sid_123"}}}` | +| `POST /v0/file/u/` | Multipart 文件;Header `X-MeetingIM-Auth: token `;业务表单可带 `purpose=chat_file&topic=grp...` | `{"ctrl":{"code":200,"params":{"url":"/v0/file/s/file123","name":"a.pdf","size":1024,"mime":"application/pdf"}}}` | +| `GET /v0/file/s/{file_id}` | Header `X-MeetingIM-Auth: token `,可带 `Range: bytes=0-` | 返回文件字节流;失败返回 `FILE_FORBIDDEN` 或 `FILE_UNAVAILABLE` | + +### 0.6 Topic 与产品消息 + +核心命令:`sub` 订阅、`pub` 发送、`get` 拉取、`set` 更新 Topic、`leave` 离开、`del` 删除、`note` 发送回执/临时状态。发送成功为 `{ctrl code:202, params:{seq}}`,服务端下行消息为 `{data}`。 + +所有产品消息在 `pub.head` 中携带: + +```json +{ + "pub": { + "id": "pub-1", + "topic": "p2p...", + "noecho": false, + "head": { + "x-im-schema": 1, + "x-im-type": "text", + "x-im-client-mid": "01K...", + "mentions": [] + }, + "content": {"text":"你好"} + } +} +``` + +`x-im-client-mid` 由客户端生成 ULID/UUID,作用域为“租户 + 发送人”,断线重试必须复用;服务端目标实现应返回第一次写入的同一 `seq`。未知 `x-im-type` 显示“暂不支持的消息”,不得崩溃或删除消息。 + +消息类型首版固定为:`text`、`image`、`video`、`voice`、`file`、`contact_card`、`link`、`forward_bundle`、`call`、`system_notice`、`system_action`、`system_event`。自定义表情启用后追加 `sticker`。媒体内容只保存 `file_id`、文件元数据和缩略图 ID,不保存可长期绕过鉴权的公共 URL。`system_notice` 和 `system_action` 只能由服务端生成,普通用户构造后发送必须被服务端拒绝。 + +### 0.7 HTTP 幂等、重试和安全 + +1. 创建、提交、同意、拒绝、删除、转让、收藏、转发等写请求必须携带 `Idempotency-Key`。 +2. GET、断线同步和同幂等键写请求可自动重试;密码、验证码和风控确认只按接口返回的 `retry_after_seconds` 重试。 +3. `401 TOKEN_EXPIRED` 不执行刷新接口,立即清理失效 Token 并返回账号密码登录;其他 `401` 也不得无限重试。`403` 不重试,`429` 按服务端退避。 +4. 服务端必须在最终写入前再次校验好友关系、群角色、禁言、消息归属、文件 ACL、租户配额和风控,不能信任前端按钮状态。 +5. 普通用户禁止向系统 Topic 发布;系统通知必须由服务端受控身份生成。 + +## 1. 账号注册、登录与身份安全 + +### 1.1 企业码登录.png + +企业码登录 + +**状态:企业解析接口与 WebSocket 租户握手均已实现。** + +**当前系统的执行方式:** + +- 前端动作:输入企业码并点击“进入企业”。只去掉首尾空格并校验非空、最长 64 字符;当前服务端没有自动转小写,客户端不得擅自改变用户输入的大小写。 +- 客户端从构建/环境配置读取 `host` 和普通 API Key,可先调用 `POST /v1/im/tenants/resolve` 获取企业公开信息,再使用 `wss://{host}/{api_path}v0/channels?apikey={api_key}` 建立 WebSocket。 +- Upgrade 成功后第一帧发送 `{"hi":{"id":"hi-1","ver":"0.25","ua":"android/1.0.0","platf":"android","lang":"zh-CN","tenant":"用户输入的企业码"}}`。 +- 服务端通过 `im_tenant.code` 查询企业;成功返回 `{ctrl code:201}` 和 `params.tenant{code,name}`,同时固定 Session 的内部 `tenant_id`。客户端保存服务端返回的标准 `tenant.code/name`,再进入 1.2 登录。 +- 当前失败表现:企业码缺失/超过 64 字符返回 400,企业不存在按存储错误返回,企业停用返回 403。当前还没有统一 `TENANT_UNAVAILABLE` 产品错误码。 + +**企业解析接口:** + +- `POST /v1/im/tenants/resolve` 未登录可调用,但必须带 `X-MeetingIM-APIKey` 和 `X-IM-Schema: 1`;Header 传 `X-Tenant-Code: acme`,Body 可传 `{"tenant_code":"acme","platform":"android","app_version":"1.0.0"}`。 +- 成功数据:`tenant{code,name,tenant_desc,branding}`、`endpoints{websocket,api,file}`、`auth_methods`、`password_policy`、`app_update`。不得返回 `tenant_id` 或 `tenant_ticket`。 +- 后续:保存公开企业信息,连接响应给出的 WebSocket 地址,仍然发送 0.5 节的 `hi(tenant)` 做最终租户绑定;`hi` 成功后才进入 1.2。 +- 目标失败协议:企业不存在、停用或不允许登录统一返回 `TENANT_UNAVAILABLE`;客户端不得区分不存在和停用。网络失败保留输入并允许重试。 + +成功响应示例: + +```json +{ + "code": 0, + "data": { + "tenant": {"code": "acme", "name": "示例企业", "tenant_desc": null, "branding": {}}, + "endpoints": { + "websocket": "wss://im.example.com/v0/channels", + "api": "https://im.example.com/v1/im", + "file": "https://im.example.com/v0/file" + }, + "auth_methods": ["password", "sms"], + "password_policy": {"min_length": 8, "max_length": 64}, + "app_update": {"type": "none"} + }, + "request_id": "01K...", + "server_time": "2026-08-11T08:30:15.123Z" +} +``` + +### 1.1.1 企业注册-登录入口.png + +企业注册-登录入口 + +**状态:已实现。** + +- 页面入口:企业码登录页右上角展示“企业注册”,底部展示“遇到问题?”。两个入口均为未登录入口,但必须使用客户端预置的普通 API Key,不需要用户 Token。 +- 点击“企业注册”:进入 1.1.2 企业资料提交流程。客户端保留当前已输入的 `tenant_code` 作为可选线索,但不得把它当成已解析企业身份。 +- 点击“遇到问题?”:进入 1.1.4 问题反馈页。若用户已输入企业码,提交反馈时带 `tenant_code_hint`;未输入时可为空。 +- 埋点和日志:可记录入口点击事件,但不得记录 API Key、完整联系方式、证照图片 URL 或上传文件真实地址。 +- 入口展示:入口文案必须避开系统状态栏、安全区域和键盘遮挡;网络不可用时仍允许进入填写页,提交时再提示网络错误。 + +### 1.1.2 企业注册-填写注册信息.png + +企业注册-填写注册信息 + +**状态:已实现。** + +- 页面步骤:展示“填写企业信息、确定信息、提交审核”三步,当前处于第 1 步。提交前客户端在本页完成格式校验,服务端仍做最终校验。 +- 必填字段:`credit_code` 18 位统一社会信用代码,客户端仅去掉首尾空格并转大写展示;提交值必须匹配 `^[0-9A-Z]{18}$`。不得提交中文、空格或短码。 +- 可选字段:`license_file_id` 营业执照图片文件 ID,`enterprise_email` 企业邮箱。邮箱用于接收审核结果和后续通知,填写时必须符合邮箱格式。 +- 扫描识别:扫码或 OCR 只能作为填充辅助,提交前仍以输入框中的 `credit_code` 为准;识别失败不阻塞手动填写。 +- 图片上传:营业执照先调用公开用途受限上传接口,`purpose=enterprise_license`,支持 `jpg/png/webp/pdf`,单文件不超过 10MB。服务端通过当前 media handler 存储对象,生产环境必须配置 `oss` handler;上传成功返回 `file_id` 后再提交申请;未上传营业执照时 `license_file_id` 可为空。 +- 提交接口:`POST /v1/im/tenant-applications`,未登录可调用,必须带 `X-MeetingIM-APIKey`、`X-IM-Schema: 1`、`X-Request-ID` 和 `Idempotency-Key`。 +- 请求体: + +```json +{ + "credit_code": "91310000MA1K000000", + "enterprise_email": "admin@example.com", + "license_file_id": "file_01K...", + "tenant_code_hint": "acme", + "client_context": { + "platform": "android", + "app_version": "1.0.0", + "device_model": "Pixel 8" + } +} +``` + +- 成功响应:服务端创建企业注册申请单,状态为 `received` 或 `pending_review`。客户端进入 1.1.3,不得重复提交同一个幂等键。 + +```json +{ + "application_id": "entapp_01K...", + "status": "received", + "estimated_review_hours": 24, + "submitted_at": "2026-08-24T08:30:15.123Z" +} +``` + +- 失败处理:`INVALID_CREDIT_CODE` 显示统一社会信用代码格式错误;`INVALID_EMAIL` 定位邮箱输入;`FILE_UNAVAILABLE` 允许重新上传;`APPLICATION_DUPLICATED` 提示已有申请正在审核;`RATE_LIMITED` 按服务端退避时间禁用提交按钮。 + +### 1.1.3 企业注册-提交成功提示.png + +企业注册-提交成功提示 + +**状态:已实现。** + +- 触发条件:1.1.2 提交接口返回成功后,在当前页展示 Toast:“提交成功,我们会尽快联系你”。 +- 状态保存:客户端保存 `application_id,status,submitted_at,estimated_review_hours` 到本地短期缓存,用于返回页面时展示“已提交”状态。该缓存不代表审核结果,最终状态以服务端和邮件/通知为准。 +- 防重复:提交成功后禁用提交按钮;用户返回后再次进入企业注册页,可显示已提交提示并允许重新填写新申请,但必须使用新的 `Idempotency-Key`。 +- 审核通知:审核结果默认通过 `enterprise_email` 触达;如果后续后台接入站内通知或短信,客户端只按服务端下发的公开状态展示,不自行推断审核通过。 +- 文案规则:页面底部审核时长应使用服务端返回的 `estimated_review_hours` 或后台配置,不写死。 + +### 1.1.4 企业注册-问题反馈.png + +企业注册-问题反馈 + +**状态:已实现。** + +- 使用场景:企业码不可用、企业注册资料提交异常、审核通知未收到、登录入口无法进入等未登录阶段问题。 +- 输入字段:`description` 必填,长度 10~1000 字符;`contact` 可选,用于客服回访;`tenant_code_hint` 可选,来自用户输入;`screenshot_file_ids` 最多 9 个。 +- 截图上传:截图先调用公开用途受限上传接口,`purpose=tenant_entry_feedback`,支持 `jpg/png/webp`,单张不超过 10MB。服务端通过当前 media handler 存储对象,生产环境必须配置 `oss` handler;上传失败时允许单张重试或删除。 +- 提交接口:`POST /v1/im/tenant-entry-feedback`,未登录可调用,必须带 `X-MeetingIM-APIKey`、`X-IM-Schema: 1`、`X-Request-ID` 和 `Idempotency-Key`。 +- 请求体: + +```json +{ + "description": "企业码 acme 提示不可用,无法进入企业。", + "contact": "user@example.com", + "tenant_code_hint": "acme", + "screenshot_file_ids": ["file_01K..."], + "client_context": { + "platform": "ios", + "app_version": "1.0.0", + "network": "wifi" + } +} +``` + +- 成功响应:客户端展示“已收到反馈,我们会尽快处理”,并清空草稿和已提交截图引用。 + +```json +{ + "feedback_id": "entryfb_01K...", + "status": "received", + "submitted_at": "2026-08-24T08:35:15.123Z" +} +``` + +- 失败处理:`DESCRIPTION_REQUIRED` 或 `DESCRIPTION_TOO_LONG` 定位描述框;`TOO_MANY_FILES` 提示最多 9 张;`FILE_UNAVAILABLE` 提示重新上传失效图片;`RATE_LIMITED` 禁用提交并展示稍后重试。 + +### 1.2 登录页面.png + +登录页面 + +**状态:基本账号密码登录已实现;WS Token 可复用于产品 HTTP。** + +- 前端动作:账号、密码非空且已勾选协议后启用登录;密码仅驻留当前页面内存。 +- 协议:已完成 `hi` 的 WebSocket 发送 `{login scheme:"basic"}`,`secret=base64(username:password)`;Base64 不是加密,必须通过 WSS 传输,不得先登录再传企业码。 +- 成功:读取 `{ctrl params.user/token/expires/authlvl}`,Token 存 Keychain/Keystore;立即 `sub me`,再加载会话。前端不解码 Token,也不再次 Base64 编码。 +- 产品 HTTP:使用同一个 `params.token` 作为 `Authorization: Bearer `;所有 `/v1/im/*` HTTP 请求仍必须带 `X-MeetingIM-APIKey`、`X-IM-Schema: 1`,写请求再带 `Idempotency-Key`。 +- Token:当前复用 MeetingIM 单一登录 Token,默认有效期 14 天,不使用 Refresh Token。有效 Token 登录成功会签发新的 14 天 Token,客户端覆盖旧值;过期后重新账号密码登录。Token 已绑定 `tenant_id/user_id/auth_version`,修改密码、封禁账号后旧 Token 应失效。 +- 密码:服务端使用 `bcrypt.DefaultCost=10` 生成单向哈希,`im_auth.secret` 只保存哈希;客户端和服务端不得记录密码或完整 Basic `secret`。 +- 错误:`AUTH_FAILED` 统一显示账号或密码错误;`TENANT_MISMATCH` 清 Token 并回企业码页;`ACCOUNT_DISABLED`、`REALNAME_REQUIRED`、`YOUTH_RESTRICTED` 按专用页面处理。 + +登录成功示例: + +```json +{ + "ctrl": { + "id": "login-1", + "code": 200, + "text": "ok", + "params": { + "user": "usr...", + "authlvl": "auth", + "token": "base64-opaque-token", + "expires": "2026-08-11T10:30:15.123Z" + } + } +} +``` + +失败仍是同一 `{ctrl}` 结构,使用 `code` 和改造后的稳定 `params.reason` 分支;客户端不得用 `text` 做程序判断。 + +### 1.3 登录页面-密码错误.png + +登录页面-密码错误 + +**状态:已实现基础错误,错误码和限流需改造。** + +- 触发:`login` 返回 `{ctrl code:401}` 或产品错误 `AUTH_FAILED`。 +- 前端:保留账号、清空密码、聚焦密码框;统一显示“账号或密码错误”,不得显示账号是否存在。 +- 服务端:按 `tenant + account + device/IP` 限流,连续失败返回 `AUTH_RATE_LIMITED` 和 `retry_after_seconds`;日志仅记录账号哈希和 Request ID。 +- 验收:同一不存在账号与错误密码在响应码、耗时区间、前端文案上保持一致。 + +### 1.4 注册页面.png + +注册页面 + +**状态:注册原型和服务端注册分流均已完成;用户名和手机号为独立字段,服务端已接入 username、邀请码和短信开关逻辑。** + +- 页面规则:用户名和手机号是独立输入。用户名非空时必须保存为 `im_users.username`;手机号非空时必须保存为规范化后的 `im_users.phone`。两个字段同时为空时拒绝注册。 +- 用户名格式:普通用户名必须匹配 `^[a-z0-9]{1,10}$`,即 1~10 位小写英文字母或数字;允许纯数字,不允许大写、中文、空格、点、下划线和其他符号。前端限制输入,服务端按原始值再次校验,失败返回 `USERNAME_INVALID_FORMAT`。 +- 邀请码格式:非空时必须匹配 `^[A-Za-z]{5}$`,固定 5 位大小写英文字母并区分大小写;客户端不得自动转换大小写。服务端在当前租户内按明文精确查询。 +- 邀请码可复用:同一有效邀请码可以供多个用户注册,使用后仍保持 `active`;服务端为每个注册用户记录邀请来源。只有到期或管理员撤销后才不可继续使用。 +- 邀请码主数据位于 HotGo 控制库并按 `tenant_id` 隔离;注册使用明细位于当前租户业务库。客户端契约不受分库影响,仍只提交完整 5 位邀请码。 +- 用户名方式:手机号为空时提交用户名、密码和邀请码,不发送短信。邀请码必须有效,注册后 `im_users.username` 保存用户名,`im_users.phone=NULL`。 +- 找回提示:用户名方式提交注册前,客户端明确提示“未绑定手机号时无法自助找回密码,忘记密码需联系企业管理员重置”。 +- 手机号方式:前端只接受 11 位数字。调用 `POST /v1/im/auth/verification-codes`,Body `{"scene":"register","phone":"138..."}`;后端补 `+86` 后发送,成功返回 `challenge_id`、`expires_in_seconds`、`resend_after_seconds`。当前测试环境短信供应商未接入,注册、找回密码和敏感操作验证码固定填写 `123456`,用它调用 verify 接口换取一次性 `verification_token` 或 `action_token`;生产接入短信供应商后不得继续使用固定验证码。 +- 短信开关开启:手机号非空时必须完成验证码验证,邀请码不能绕过短信验证;验证成功后 `username` 保存用户填写的用户名,`phone` 保存规范化的 E.164 手机号。若用户未填写用户名,则服务端随机生成 3 位小写字母 + 6 位数字的 username。 +- 短信开关关闭:发码接口仍返回正常倒计时但不调用短信服务商。手机号非空时提供有效邀请码后可无验证码注册;`username` 保存用户填写的用户名,`phone` 保存规范化手机号。若用户未填写用户名,则服务端随机生成 3 位小写字母 + 6 位数字的 username。不建立已完成的 `tel credential`。 +- 提交注册:`{acc}` 增加独立的 `username`、`phone` 和 `invite_code`。短信验证成功后调用 `POST /v1/im/auth/verification-codes/{challenge_id}/verify` 获得一次性 `verification_token`。`username` 非空时,`secret` 使用 `username:password`;仅手机号注册且 username 为空时,客户端仍使用规范化手机号与密码生成 `secret`,服务端创建后返回随机 username。 +- 服务端判定:`username` 非空时校验 `^[a-z0-9]{1,10}$` 并要求 `secret` 登录名与 username 一致;`username` 为空且 `phone` 非空时要求 `secret` 登录名与规范化手机号一致,随后生成随机 username。普通用户名允许纯数字,因此不能只根据 `secret` 是否为 11 位数字推断注册方式。 +- 账户昵称:注册创建资料时,昵称暂时默认等于最终 username。填写用户名时昵称等于该用户名;仅手机号注册时昵称等于服务端随机 username,不使用手机号作为昵称。 +- 禁止客户端提交 `tags`;手机号方式始终写入 `im_users.phone`,只有短信验证成功时才建立已完成的电话凭证。 + +用户名方式: + +```json +{"acc":{"id":"acc-1","user":"new","scheme":"basic","username":"alice","phone":"","secret":"base64(alice:password)","invite_code":"AbCdE","login":true,"cred":[]}} +``` + +手机号验证方式: + +```json +{"acc":{"id":"acc-1","user":"new","scheme":"basic","username":"alice","phone":"138...","secret":"base64(alice:password)","invite_code":"","login":true,"cred":[{"meth":"tel","val":"138...","resp":"verification_token","params":{"challenge_id":"challenge-id"}}]}} +``` + +仅手机号注册方式: + +```json +{"acc":{"id":"acc-1","user":"new","scheme":"basic","username":"","phone":"138...","secret":"base64(+86138...:password)","invite_code":"","login":true,"cred":[{"meth":"tel","val":"138...","resp":"verification_token","params":{"challenge_id":"challenge-id"}}]}} +``` +- 成功:返回新 `user`、最终 `username` 和登录 Token;客户端保存 `params.token`,随后执行 `sub me` 和会话恢复。创建用户前原子校验 `im_tenant_config.max_users`,超限返回 `TENANT_USER_QUOTA_EXCEEDED`。 +- 安全:用户名、手机号分别在租户内唯一;验证码票据单次消费,邀请码在有效期内可重复使用;密码按服务端策略校验并使用强哈希保存。 + +### 1.5 注册页面-倒计时.png + +注册页面-倒计时 + +**状态:新增。** + +- 倒计时以 1.4 返回的 `resend_after_seconds` 为准,并结合 `server_time` 计算,不能只依赖本地固定 60 秒。 +- 倒计时内前端禁用按钮;即使绕过按钮重复调用,服务端返回 `VERIFICATION_RATE_LIMITED` 和剩余秒数。 +- App 重进页面可使用内存/安全临时存储中的 `challenge_id` 恢复;超过 `expires_in_seconds` 必须重新发送,旧验证码失效。 + +### 1.6 忘记密码.png + +忘记密码 + +**状态:新增。** + +- 适用范围:仅 `im_users.phone` 非空的账号支持短信自助找回。`phone=NULL` 的用户名账号不提供自助找回,客户端提示联系当前企业管理员重置密码。 +- 发码:`POST /v1/im/auth/verification-codes`,Body `{"scene":"reset_password","phone":"..."}`。无论账号是否存在都返回相同受理响应。 +- 校验:`POST /v1/im/auth/password-reset/challenges/{challenge_id}/verify`,Body `{"code":"123456"}`,成功返回短时、单次使用的 `reset_token`。 +- 重置:`POST /v1/im/auth/password-reset/confirm`,Body `{"reset_token":"...","new_password":"..."}`,带幂等键。 +- 凭证补建:只要账号的 `phone` 非空,成功完成 `reset_password` 短信挑战即可证明当前持有该号码;即使注册时未建立 `tel credential`,本次成功后也可补建完成状态的电话凭证。 +- 成功:服务端更新密码、递增 `auth_version`,使该用户全部旧登录 Token 失效并撤销在线 Session;客户端清理本租户凭证并返回登录页。 +- 失败:`PASSWORD_POLICY_VIOLATION` 返回规则明细;验证码错误不返回账号存在性,达到上限后冻结 challenge。 +- 管理员路径:无手机号账号只能由当前租户管理员调用 `POST /admin/v1/tenant/users/{user_id}/reset-password` 重置;不得根据用户名跨租户查找或操作。 + +### 1.7 身份验证(安卓).png + +身份验证(安卓) + +**状态:新增。** + +- 敏感操作先调用 `POST /v1/im/security/challenges`;开启青少年模式设置独立密码时 Body 为 `{"scene":"set_independent_password","channel":"sms"}`,关闭青少年模式时 Body 为 `{"scene":"close_youth_mode","channel":"sms"}`。手机号由登录身份读取,客户端不提交目标手机号。 +- 发码/校验:服务端返回 `challenge_id` 和脱敏手机号;提交 `POST /v1/im/security/challenges/{id}/verify`,Body `{"code":"..."}`。 +- 重复发码:同一用户、同一手机号、同一敏感场景存在未过期 challenge 且仍在 `resend_after_seconds` 冷却期内时,服务端返回 HTTP `429 VERIFICATION_RATE_LIMITED`,`details` 带 `challenge_id` 和 `retry_after_seconds`。客户端应复用该 `challenge_id` 继续倒计时,不要当成接口不可达或重新登录。 +- 小时限流:超过一小时创建次数上限时同样返回 HTTP `429 VERIFICATION_RATE_LIMITED`,`details.retry_after_seconds` 可能大于 60 秒且不一定带 `challenge_id`。客户端只展示倒计时,倒计时结束后允许用户重新获取验证码。 +- 成功返回 `action_token`、`action`、`expires_at`;Token 只能用于指定动作且单次消费。设置独立密码接口通过 Body `action_token` 携带;关闭青少年模式接口通过 Header `X-Action-Token` 携带。 +- 错误:`VERIFICATION_RATE_LIMITED`、`CHALLENGE_EXPIRED`、`CODE_INVALID`、`TOO_MANY_ATTEMPTS`;前端不得把短信验证码或 action token 落普通存储。 + +### 1.8 实名认证.png + +实名认证 + +**状态:新增。** + +- 查询:`GET /v1/im/security/realname` 返回 `status=unverified|pending|verified|rejected`、脱敏姓名/证件号和 `can_submit`。 +- 提交:`POST /v1/im/security/realname/submissions`,Body `{"name":"张三","id_type":"cn_id","id_number":"..."}`,必须带幂等键。 +- 成功:返回 `submission_id`、`status:"pending"`;前端禁用重复提交,当前以 `GET /v1/im/security/realname` 查询状态为准。后续如启用 `security.realname.updated`,应按 0.5.4.6 的 `info.what="im"` 业务事件下发。 +- 安全:证件号传输和存储加密、展示脱敏;普通日志、搜索索引和消息中禁止出现原值;重复身份由租户策略决定并返回稳定错误。 + +### 1.9 实名认证-已实名.png + +实名认证-已实名 + +**状态:新增。** + +- 页面只调用 `GET /v1/im/security/realname`。`verified` 时返回如 `masked_name:"张*"`、`masked_id_number:"110***********1234"`、`verified_at`。 +- 前端只读展示,不缓存原始证件信息,不提供直接修改接口。 +- 更正信息使用独立申诉 `POST /v1/im/security/realname/appeals`,需二次身份验证;不复用本页保存按钮。 + +### 1.10 实名认证(安卓).png + +实名认证(安卓) + +**状态:新增。** + +- 登录后 `GET /v1/im/bootstrap` 返回 `security.realname{status,required,allow_skip,remind_after}`;只有 `required=true` 且未认证时显示弹窗。 +- 点击“去认证”进入 1.8;点击稍后仅在 `allow_skip=true` 时本地关闭,并按 `remind_after` 控制再次提示。 +- 服务端对必须实名的受限写操作仍返回 `REALNAME_REQUIRED`,前端弹窗不能作为授权依据。 + +### 1.11 设置独立密码(安卓).png + +设置独立密码(安卓) + +**状态:新增。** + +- 设置:`POST /v1/im/settings/independent-password`,Body `{"password":"1234","purpose":"youth_mode","action_token":"..."}`;服务端只存强哈希,不返回密码。`action_token` 为一次性 token,会在设置密码时消费;设置成功后响应会返回新的 `enable_action_token`,用于下一步开启青少年模式,前端不要复用设置密码时提交的 token。 +- 验证:`POST /v1/im/settings/independent-password/verify` 返回短时 `action_token`,用于关闭青少年模式或修改密码。 +- 青少年模式完整开启和关闭流程见 `docs/IM-青少年模式前后端对接文档.md`。 +- 规则:四位数字、禁止明显连续/重复组合;连续错误返回 `TOO_MANY_ATTEMPTS` 和 `retry_after_seconds`。前端输入框禁截屏/自动填充,离开页面立即清空。 +- 忘记密码:跳转 1.7 的短信身份验证,验证后重置,不提供明文找回。 + +### 1.12 接口示例 + +以下示例用于本章 HTTP 联调快速查阅。除特别说明外: + +- 产品 HTTP 均使用 `https://{host}/{api_path}v1/im`,请求头包含 `X-MeetingIM-APIKey`、`X-IM-Schema: 1`、`X-Request-ID`;登录后接口再带 `Authorization: Bearer `。 +- 写接口必须带 `Idempotency-Key`。二次认证接口按正文说明额外带 `X-Action-Token` 或 `X-Risk-Action-Token`。 +- 成功响应统一包裹为 `{"code":0,"data":示例,"request_id":"...","server_time":"..."}`;下表只展示 `data`。 +- 失败响应统一包裹为 `{"code":30000,"reason":"MALFORMED_REQUEST","message":"...","details":{},"request_id":"...","server_time":"..."}`。 +- 标注“待实现/需修复”的接口是产品目标协议,前端可按示例准备,但联调前以后端发布状态为准。 + +#### 1.12.1 未登录、企业与账号安全 + +| 接口 | 输入示例 | 成功 `data` 示例 | +| --- | --- | --- | +| `POST /v1/im/tenants/resolve` | Header `X-Tenant-Code: default`;Body `{"tenant_code":"default","platform":"ios","app_version":"1.0.0"}` | `{"tenant":{"code":"default","name":"Default Tenant","tenant_desc":null,"branding":{}},"endpoints":{"websocket":"wss://api.118im.com/v0/channels","api":"https://api.118im.com/v1/im","file":"https://api.118im.com/v0/file"},"auth_methods":["password","sms"],"password_policy":{"min_length":8,"max_length":64},"app_update":{"type":"none"}}` | +| `POST /v1/im/public/uploads` 企业注册/反馈附件上传 | Multipart `purpose=enterprise_license\|tenant_entry_feedback`、`file=@license.png` | `{"file_id":"file_01K...","purpose":"enterprise_license","mime_type":"image/png","size":1024000,"expires_at":"2026-08-25T08:30:15Z"}` | +| `POST /v1/im/tenant-applications` 企业注册申请 | `{"credit_code":"91310000MA1K000000","enterprise_email":"admin@example.com","license_file_id":"file_01K...","tenant_code_hint":"acme","client_context":{"platform":"android","app_version":"1.0.0"}}` | `{"application_id":"entapp_01K...","status":"received","estimated_review_hours":24,"submitted_at":"2026-08-24T08:30:15.123Z"}` | +| `POST /v1/im/tenant-entry-feedback` 企业入口问题反馈 | `{"description":"企业码 acme 提示不可用,无法进入企业。","contact":"user@example.com","tenant_code_hint":"acme","screenshot_file_ids":["file_01K..."],"client_context":{"platform":"ios","app_version":"1.0.0"}}` | `{"feedback_id":"entryfb_01K...","status":"received","submitted_at":"2026-08-24T08:35:15.123Z"}` | +| `POST /v1/im/auth/verification-codes` 注册发码 | `{"scene":"register","phone":"15500000000"}` | `{"challenge_id":"chg_123","phone_mask":"+861****000","expires_in_seconds":300,"resend_after_seconds":60}` | +| `POST /v1/im/auth/verification-codes/{challenge_id}/verify` | `{"code":"123456"}` | `{"verification_token":"verify_token_123","expires_in_seconds":300}` | +| `POST /v1/im/auth/verification-codes` 找回密码发码 | `{"scene":"reset_password","phone":"15500000000"}` | `{"challenge_id":"chg_reset_123","phone_mask":"+861****000","expires_in_seconds":300,"resend_after_seconds":60}` | +| `POST /v1/im/auth/password-reset/challenges/{challenge_id}/verify` | `{"code":"123456"}` | `{"reset_token":"reset_token_123","expires_in_seconds":300}` | +| `POST /v1/im/auth/password-reset/confirm` | `{"reset_token":"reset_token_123","new_password":"NewPass123"}` | `{"status":"ok"}` | +| `POST /admin/v1/tenant/users/{user_id}/reset-password` 管理后台 | `{"new_password":"AdminReset123","reason":"用户无手机号无法自助找回"}` | `{"user_id":"usr...","status":"reset","auth_version":2}` | +| `POST /v1/im/auth/logout` 待实现 | `{}` | `{"status":"ok"}` | +| `GET /v1/im/app/releases/latest?platform=ios\|android¤t_version=1.0.0&channel=stable` | Query only | `{"platform":"ios","channel":"stable","latest_version":"1.0.0","min_supported_version":"1.0.0","update_type":"none","package_url":"","store_url":"","release_notes":"当前已是最新版本。","remind_after_seconds":0}` | + +## 2. 消息首页、全局导航与系统通知 + +### 2.1 消息.png + +消息 + +**状态:会话订阅已实现;产品摘要和系统入口需改造。** + +- 登录后发送 `sub me`,再通过 `get` 拉取 `sub` 元数据;每项使用 `topic`、`public`、`private`、`updated`、`seq/read/recv` 计算名称、摘要和未读数。 +- 消息增量通过各 Topic 的 `{data}` 更新;未读数为 `max(seq-read,0)`,回执以服务端游标为准,不能只用本地计数。 +- `GET /v1/im/bootstrap` 返回固定入口、功能开关、权限、系统 Topic、`entry_badges` 和 `entries`;搜索调用 `GET /v1/im/search/conversations?q=&cursor=`。 +- 本地缓存按 `tenant_code + user_id` 隔离。切租户、退出或账号变化时不得复用另一身份的会话摘要。 + +### 2.2 消息-无网络状态.png + +消息-无网络状态 + +**状态:需改造客户端恢复流程和消息幂等。** + +- 纯网络状态由客户端监听;断网时展示缓存并禁止需要在线确认的写操作,未发送消息进入本地 `pending` 队列。 +- 使用指数退避加随机抖动重连;恢复后严格执行 `hi -> token login -> sub me -> 恢复 Topic -> get data`。 +- 每个 Topic 按最后持久化的 `seq/read/recv` 增量拉取。重发消息复用 `x-im-client-mid`,服务端需实现去重并返回原 `seq`。 +- Token 失效转登录页;租户停用返回企业不可用页;禁止无限快速重连。 + +### 2.3 消息-更多.png + +消息-更多 + +**状态:菜单为纯客户端;权限数据新增。** + +- 菜单打开/关闭不调用服务端。菜单项来自登录后 `GET /v1/im/bootstrap` 的 `features` 与 `permissions`。 +- `create_group=false` 时隐藏“创建群聊”;`user_search=false` 时隐藏“添加好友”;扫码权限只影响实际扫码,不影响服务端授权。 +- 各入口协议分别见 5.3、6.1、10.1;服务端在实际写接口再次鉴权,不能只依赖隐藏按钮。 + +### 2.4 消息页面—长按弹出.png + +消息页面—长按弹出 + +**状态:基础消息操作部分已实现;产品权限矩阵需改造。** + +- 长按本身不请求。客户端根据消息的 `type/from/seq/created_at/status`、当前 Topic 权限和服务端下发的 `message_policy` 预裁剪菜单。 +- 引用/复制为本地动作;收藏见 4.16;转发见 4.17~4.20;删除使用 MeetingIM `{del what:"msg",topic,delseq}`;撤回需新增 `POST /v1/im/topics/{topic}/messages/{seq}/recall`。 +- 编辑使用 `POST /v1/im/topics/{topic}/messages/{seq}/revisions`,Body `{"content":...,"client_mid":"..."}`;仅文本且在时限内允许。 +- 群回执调用 `GET /v1/im/topics/{topic}/messages/{seq}/receipts?cursor=`。所有最终操作由服务端验证消息归属、时限、群角色和租户。 + +### 2.5 系统消息.png + +系统消息 + +**状态:需改造。** + +- `GET /v1/im/system-notifications?cursor=` 返回 `id,event_type,title,summary,created_at,read_at,action`;实时刷新以 0.5.4.6 的 WS `{info what:"im|group"}` 业务事件为准,系统通知列表记录不等同于每一条实时事件。 +- 新设备登录事件内容为 `device_name,platform,ip_masked,login_at,security_action`;IP 只下发必要脱敏值。 +- 已读:`POST /v1/im/system-notifications/{id}/read`,带幂等键;跳转目标只接受服务端白名单 `action.type` 和内部参数。 +- 当前普通认证用户可向 `sys` Topic 发布的路径必须封禁。客户端不得渲染普通用户伪造的系统卡片为可信通知。 + +### 2.6 接口示例 + +以下示例用于本章 HTTP 联调快速查阅。除特别说明外: + +- 产品 HTTP 均使用 `https://{host}/{api_path}v1/im`,请求头包含 `X-MeetingIM-APIKey`、`X-IM-Schema: 1`、`X-Request-ID`;登录后接口再带 `Authorization: Bearer `。 +- 写接口必须带 `Idempotency-Key`。二次认证接口按正文说明额外带 `X-Action-Token` 或 `X-Risk-Action-Token`。 +- 成功响应统一包裹为 `{"code":0,"data":示例,"request_id":"...","server_time":"..."}`;下表只展示 `data`。 +- 失败响应统一包裹为 `{"code":30000,"reason":"MALFORMED_REQUEST","message":"...","details":{},"request_id":"...","server_time":"..."}`。 +- 标注“待实现/需修复”的接口是产品目标协议,前端可按示例准备,但联调前以后端发布状态为准。 + +#### 2.6.1 登录后启动、消息首页与系统通知 + +| 接口 | 输入示例 | 成功 `data` 示例 | +| --- | --- | --- | +| `GET /v1/im/bootstrap` | Query only | `{"features":{"create_group":true,"user_search":true,"scan_qr":true,"system_notifications":true,"message_recall":true,"message_edit":true,"audio_call":true,"video_call":true},"permissions":{"create_group":true,"user_search":true,"scan_qr":true,"audio_call":true,"video_call":true},"entries":{"new_friends":{"enabled":true,"route":"/contacts/new-friends","unread_count":0},"groups":{"enabled":true,"route":"/contacts/groups","unread_count":0},"file_assistant":{"enabled":true,"route":"/chat/file-assistant","topic":"slf","unread_count":0},"system_messages":{"enabled":true,"route":"/chat/system","topic":"sys","unread_count":0}},"entry_badges":{"new_friends_pending_count":0,"groups_unread_count":0,"file_assistant_unread_count":0,"system_unread_count":0,"workbench_total_unread":0},"system_topic":{"topic":"sys","type":"system"},"message_policy":{"recall_window_seconds":120,"edit_window_seconds":300,"supported_actions":["copy","quote","delete","recall","edit"]},"security":{"realname":{"status":"unverified","required":false,"allow_skip":true,"remind_after":"24h"}}}` | +| `GET /v1/im/search/conversations?q=&cursor=` | Query only | `{"items":[{"topic":"grp...","public":{"name":"项目群"},"private":{"pinned":false,"mute_notifications":false},"seq":12,"read":10,"recv":12,"unread":2,"updated_at":"2026-08-20T09:00:00Z"}],"next_cursor":""}` | +| `GET /v1/im/system-notifications?cursor=&limit=20` | Query only | `{"items":[{"id":"evt_1","event_type":"friend.request.created","title":"新的好友申请","summary":"张三请求添加你为好友","action":{"type":"friend_request","request_id":"frq_1"},"read_at":null,"created_at":"2026-08-20T09:00:00Z"}],"next_cursor":""}` | +| `POST /v1/im/system-notifications/{id}/read` | `{"read_at":"2026-08-20T09:00:00Z"}` | `{"status":"ok","id":"evt_1","read_at":"2026-08-20T09:00:00Z"}` | + +## 3. 单聊会话、会话建立与消息状态 + +### 3.1 个人对话框(对方的状态).png + +个人对话框(对方的状态) + +**状态:需改造好友关系状态。** + +- 进入会话先 `GET /v1/im/conversations/{topic}/context`,返回 `relationship=pending_inbound`、`can_send=false`、对方公开资料和申请 ID。 +- 历史申请提示由 `relationship_event` 系统消息渲染;普通消息仍通过 Topic `{data}` 下行。 +- 输入区锁定;“通过/拒绝”调用 5.2 的好友申请接口。前端不可通过直接 `pub` 绕过,服务端发布路径必须校验好友关系。 + +### 3.2 个人对话框(对方的状态)(1).png + +个人对话框(对方的状态)(1) + +**状态:在线状态已具备基础能力;关系文案新增。** + +- 订阅 P2P Topic 后用 `{pres}` 更新在线/离线,`last_seen` 仅在对方隐私允许时展示。 +- `GET /v1/im/conversations/{topic}/context` 返回 `display_name,avatar_file_id,online_visibility,relationship`。 +- 好友关系变化由服务端生成 0.5.4.6 定义的 WS 业务事件,客户端不得根据本地动作自行永久插入系统通知记录。 + +### 3.3 个人对话框(我的状态).png + +个人对话框(我的状态) + +**状态:新增好友申请状态。** + +- 发起申请使用 `POST /v1/im/friend-requests`,Body `{"target_user_id":"usr...","message":"我是...","source":"search"}`。 +- 返回 `request_id,status:"pending",created_at`;页面以该服务端状态显示“已发送”,重复点击用同一幂等键返回原申请。 +- 申请待处理时 `context.relationship=pending_outbound`、`can_send=false`,禁止重复创建和普通消息发送。 + +### 3.4 个人对话框(我的状态)(1).png + +个人对话框(我的状态)(1) + +**状态:发送和读回执已实现基础能力;送达状态映射需统一。** + +- 本地生成 `client_mid` 后显示 `sending`;`pub` 收到 `202 + seq` 更新为 `sent`;失败保留同一 `client_mid` 重试。 +- 对方 `note what:"recv"` 更新 `delivered`,`note what:"read"` 更新 `read`;多设备取服务端最大游标。 +- 状态只附着在气泡元数据,不修改消息正文。服务端拒绝时映射为 `failed(reason)`,不得伪装成已发送。 + +### 3.5 开启沟通页面.png + +开启沟通页面 + +**状态:新增好友审批与 Topic 联动。** + +- 好友申请通过后,服务端在同一事务内建立双方关系和 P2P 订阅,并向申请人、通过人本人下发 `info.what="im", event="friend.request.accepted"`;申请人当前会写入系统通知,通过人本人当前只收到实时业务事件。 +- 客户端收到事件或重新查询 `context.relationship=friend` 后解锁输入区;不得仅因本地“通过”按钮成功动画解锁。 +- 首次进入订阅 P2P Topic,再按 `since` 拉取申请上下文和正常消息。 + +### 3.6 双方沟通状态.png + +双方沟通状态 + +**状态:上传基础已实现;媒体消息 Schema、ACL 和恢复需改造。** + +- 选择媒体后本地生成 `client_mid` 和稳定占位;调用文件上传,得到 `file_id` 后再 `pub x-im-type=image|video|voice`。 +- `content` 至少含 `file_id,name,size,mime,width,height,duration_ms,thumbnail_file_id` 中适用字段;上传进度是客户端任务状态,不写成聊天消息。 +- 服务端发布成功后关联文件与 Topic/消息;文件下载必须校验消息可见性。上传失败只重试上传,成功但 `pub` 失败时复用已上传 `file_id` 和 `client_mid`。 + +### 3.7 个人对话—拉黑状态/已读状态.png + +个人对话—拉黑状态/已读状态 + +**状态:黑名单产品规则新增。** + +- 拉黑后 `context.relationship=blocked_by_me|blocked_by_peer`、`can_send=false`;服务端 `pub` 返回统一 `MESSAGE_NOT_ALLOWED`,不得向发送者泄露具体是对方拉黑。 +- 失败消息只保存在发送端本地并显示失败,不下发给对方、不写入正常消息表。 +- 已读仍按历史最大 `read` 渲染;拉黑不能回滚既有回执。解除拉黑见 5.10、5.12。 + +### 3.8 个人对话—删除状态.png + +个人对话—删除状态 + +**状态:新增好友删除后的发送约束。** + +- 删除好友使用 `DELETE /v1/im/friends/{user_id}`;成功后双方关系变为 `none`,服务端推送关系变化事件。 +- 后续 `pub` 返回 `FRIEND_RELATION_REQUIRED`;前端保留失败草稿,提供“重新添加好友”入口,不自动发申请。 +- 历史聊天是否保留由 Topic 权限决定,删除好友不等于删除双方历史消息。 + +### 3.9 个人对话-键盘收起状态.png + +个人对话-键盘收起状态 + +**状态:纯客户端。** + +- 键盘、表情和附加面板收起不调用服务端;保持列表锚点和未发送草稿。 +- 草稿默认按 `tenant + user + topic` 本地加密保存;若实现多端草稿,再单独调用 `PUT /v1/im/topics/{topic}/draft`,不得混入聊天消息。 + +### 3.10 个人对话-发各种文件状态.png + +个人对话-发各种文件状态 + +**状态:面板纯客户端;上传需改造。** + +- 打开面板不请求。选择前依据 `context.capabilities` 判断图片、视频、语音、文件是否允许,调用系统选择器/相机。 +- 上传前校验 `mime,size` 和 `bootstrap.limits.max_file_size`;服务端仍必须重新校验租户配额、文件类型和会话权限。 +- 具体上传/发消息使用 3.6 协议;相机和麦克风授权见 10.20、10.21。 + +### 3.11 个人对话-发各种文件状态(1).png + +个人对话-发各种文件状态(1) + +**状态:需改造产品消息 Schema。** + +- 客户端按 `x-im-type` 选择文本、名片、文件等卡片,公共字段统一使用 `from,topic,seq,ts,head,content`。 +- 每种卡片必须实现未知字段忽略、未知类型占位、发送/失败/回执状态和长按动作。 +- 重新安装或换设备后仅凭服务端消息即可还原卡片,不能依赖发送端本地路径或临时 URL。 + +### 3.12 个人对话-发送名片状态.png + +个人对话-发送名片状态 + +**状态:新增产品消息类型。** + +- 选人:`GET /v1/im/friends?query=&cursor=`;选择后发送 `x-im-type:"contact_card"`。 +- `content` 为 `{"user_id":"usr...","display_name":"张三","avatar_file_id":"...","public_account":"A1024"}`;服务端按目标用户当前公开资料生成/校正快照,禁止手机号、证件号等敏感字段。 +- 点击卡片调用 `GET /v1/im/users/{identifier}/profile`;`identifier` 可传 `user_id`、`username` 或 `public_account`,用户不可见或已离职时显示失效卡片,不跨租户查询。 + +## 4. 消息输入、媒体内容与消息操作 + +### 4.1 键盘展开.png + +键盘展开 + +**状态:纯客户端。** + +- 键盘展开、列表上推和光标变化不调用服务端;草稿按 `tenant + user + topic` 保存。 +- 点击发送时才校验 `context.can_send` 并执行 `pub text`;正文去首尾策略、最大长度从 `bootstrap.limits.max_text_length` 获取,服务端再次校验。 + +### 4.2 @人员—选择提醒人.png + +@人员—选择提醒人 + +**状态:新增群成员查询。** + +- 输入 `@` 后调用 `GET /v1/im/groups/{topic}/members?query=&cursor=&mentionable=true`,返回 `user_id,display_name,avatar_file_id,role,mentionable`。 +- `@所有人` 是虚拟项,只有响应 `permissions.can_mention_all=true` 时展示,不以特殊用户 ID 表示。 +- 搜索做 300ms 防抖并取消旧请求;服务端只返回当前租户且仍在群内的成员。 + +### 4.3 @人员—完成.png + +@人员—完成 + +**状态:纯客户端选择;发送协议需改造。** + +- 完成后在编辑器插入带范围的 Mention 节点,内部保存 `user_id`,显示昵称只是快照。 +- 发送时 `pub.head.mentions=[{"user_id":"usr...","offset":0,"length":3}]`;`@所有人` 使用 `mention_all:true`,不得伪造用户 ID。 +- 服务端验证成员身份和 `mention_all` 权限;无效成员可删除提醒语义但保留可读文本,越权 `@所有人` 返回 `MENTION_ALL_FORBIDDEN`。 + +### 4.4 @人员状态.png + +@人员状态 + +**状态:需改造提醒和 Push。** + +- 消息仍通过 `{data}` 下行,客户端使用结构化 `mentions` 高亮,禁止通过字符串扫描昵称判断。 +- 当前用户被提及时会话摘要标记 `mentioned=true`;读到对应 `seq` 后随 `note read` 清除。 +- Push 服务按结构化目标生成“有人@你”文案;已退群用户不能收到新提醒,但历史消息仍按快照渲染。 + +### 4.5 表情包展开(发送按钮默认状态).png + +表情包展开(发送按钮默认状态) + +**状态:纯客户端。** + +- 面板、分类、最近使用和删除键均本地处理;未选择内容且文本为空时禁用发送。 +- Unicode 表情作为普通 `text` 内容发送。自定义表情资源首版未定义,不得把本地资源路径直接发送。 + +### 4.6 表情包发送页面.png + +表情包发送页面 + +**状态:Unicode 表情使用已实现文本消息;独立表情包新增。** + +- 文本表情按 `pub x-im-type:text` 发送。独立自定义表情若启用,使用 `x-im-type:"sticker"`,`content={pack_id,sticker_id,file_id,width,height}`。 +- 自定义表情发送前服务端校验表情包属于当前租户且可用;未知表情显示占位,不使用任意外链 URL。 + +### 4.7 语音—按住说话.png + +语音—按住说话 + +**状态:录音纯客户端;上传和语音消息需改造。** + +- 按下前检查麦克风权限和 `context.capabilities.voice`;权限不足进入 10.21,不发请求。 +- 松开发送:编码为服务端允许的 MIME,校验最短/最长时长,上传后 `pub x-im-type:voice`,内容含 `file_id,duration_ms,size,mime,waveform`。 +- 上滑取消、过短、系统来电打断时删除临时文件,不调用上传接口。 + +### 4.8 语音按住说话.png + +语音按住说话 + +**状态:纯客户端。** + +- 音量幅度、上滑取消区域和浮层颜色来自本地录音会话,不上传实时音量。 +- 进入取消区只改变本地状态;最终抬手决定删除或执行 4.7 的上传发送。 + +### 4.9 内容沟通对话3-语音录音中.png + +内容沟通对话3-语音录音中 + +**状态:录音纯客户端;临时状态可选改造。** + +- 录音期间客户端锁定其他媒体入口。默认不向群成员广播“正在录音”,以减少隐私暴露和状态风暴。 +- 若产品确认需要,可发送不落库的 `{note what:"kpa",topic}`;服务端限频,其他端超时自动消失,不能作为聊天记录。 + +### 4.10 内容沟通对话3-图片和语音发送中.png + +内容沟通对话3-图片和语音发送中 + +**状态:客户端任务管理新增;服务端上传需改造。** + +- 每个待发送项保存 `client_mid,local_uri,type,upload_id,progress,state`;同一文件可分片/断点续传时使用稳定 `upload_id`。 +- 上传完成后再发布消息;多任务可并发但必须各自保持原排序号。离开页面不取消,退出账号必须暂停并隔离任务。 +- 服务端返回 `FILE_TOO_LARGE`、`FILE_TYPE_NOT_ALLOWED`、`STORAGE_QUOTA_EXCEEDED` 时不可自动无限重试。 + +### 4.11 内容沟通对话3-语音/视频/图片状态.png + +内容沟通对话3-语音/视频/图片状态 + +**状态:需改造媒体 Schema 和受权下载。** + +- 语音渲染 `duration_ms,waveform`,未听状态保存在客户端并可选跨端同步;视频渲染 `thumbnail_file_id,duration_ms,width,height`;图片渲染缩略图和原图 ID。 +- 预览前使用认证文件接口获取,服务端校验当前用户仍可访问引用消息;附件撤回或过期返回 `FILE_UNAVAILABLE`。 +- 缩略图和原文件分别授权,客户端缓存键必须包含租户与用户。 + +### 4.12 语音图片发送状态.png + +语音图片发送状态 + +**状态:需改造。** + +- 图片选择后立即显示本地缩略图,上传成功后只替换资源来源,不改变 `client_mid` 或列表位置。 +- 失败展示重试按钮;重试复用同一上传任务和消息幂等 ID。语音播放与图片上传状态相互独立。 + +### 4.13 上传文件.png + +上传文件 + +**状态:纯客户端视觉资源。** + +- 该图片仅定义统一上传图标和无障碍名称“上传文件”,不产生网络请求。 +- 文件助手、群文件、会话附件、反馈证据实际上传时统一走 0.2 文件接口,并额外提交业务 `purpose` 以便服务端执行权限和保留期策略。 + +### 4.14 文件发送状态.png + +文件发送状态 + +**状态:上传已实现基础能力;消息引用和 ACL 需改造。** + +- 上传成功后发送 `x-im-type:file`,`content={file_id,name,size,mime,extension,checksum}`;文件名做长度和控制字符清理。 +- 服务端在消息落库事务中关联文件;未关联的临时上传按 TTL 清理。卡片下载见 7.9~7.11。 +- 多文件逐个生成 `client_mid`,某个失败不能改变其他文件状态。 + +### 4.15 多选-选中对话框.png + +多选-选中对话框 + +**状态:选择纯客户端;批量动作新增。** + +- 客户端按 `(topic,seq)` 保存已选项并显示数量;默认最多 100 条,超限在本地阻止。 +- 转发、收藏、删除前把选中列表提交对应接口;服务端逐条校验仍可见、类型支持和权限,返回 `succeeded[]` 与 `failed[{seq,reason}]`。 +- 不允许只因第一条可操作就假定整批可操作。 + +### 4.16 多选-选中对话内容-收藏成功.png + +多选-选中对话内容-收藏成功 + +**状态:新增。** + +- `POST /v1/im/favorites/batch`,Body `{"messages":[{"topic":"grp...","seq":"123"}]}`,带幂等键。 +- 返回 `created_count,existing_count,failed[]`;同一用户对同一 `topic+seq` 重复收藏不新增记录。 +- 收藏保存原消息引用、不可变展示快照和附件授权关系;成功 Toast 只在至少一项成功时展示,部分失败需给出汇总。 + +### 4.17 转发-单选.png + +转发-单选 + +**状态:新增。** + +- 目标列表:`GET /v1/im/forward-targets?query=&cursor=`,只返回 `can_receive_forward=true` 的会话和文件助手。 +- 选中后本地进入确认页,不立即发送;服务端在最终转发接口再次校验目标 Topic、原消息和附件权限。P2P 目标可以传转发目标列表返回的 `topic`,也可以传对方 `usr...` 用户 ID,服务端会归一化为当前用户与对方的 P2P topic。 + +### 4.18 转发-多选.png + +转发-多选 + +**状态:新增。** + +- 目标查询同 4.17;多选最多取 `bootstrap.limits.max_forward_targets`,首版建议 9。 +- 确认前只保存目标 Topic 字符串或对方 `usr...` 用户 ID;不得缓存或提交 `tenant_id`。跨租户 Topic 在服务端统一拒绝。 + +### 4.19 转发—逐条转发.png + +转发—逐条转发 + +**状态:新增。** + +- `POST /v1/im/messages/forward`,Body `{"mode":"separate","sources":[{"topic":"p2p...","seq":12}],"target_topics":["grp..."],"comment":"..."}`。`seq` 兼容数字和字符串;P2P 源会话也可以传对方 `usr...` 用户 ID,服务端会归一化为 P2P topic。 +- 返回每个目标和源消息的 `client_mid/seq/status`;服务端保持源顺序,重新生成目标消息,不能复用原 `seq`。 +- 附件必须建立新的授权引用;原消息已撤回、过期或不可见时该项失败。留言作为每个目标的一条独立文本消息发送。 + +### 4.20 转发—合并转发—多选状态.png + +转发—合并转发—多选状态 + +**状态:新增。** + +- 调用 4.19 接口,`mode:"bundle"`;服务端生成一条 `forward_bundle` 目标消息,内容含 `bundle_id,title,item_count,preview[],items[],source_refs[]`。首版 Bundle 明细以内联快照随消息下发,后续如消息量增大再扩展 `GET /v1/im/forward-bundles/{bundle_id}?cursor=` 分页详情。 +- 快照保留显示名、时间和内容摘要,对撤回/敏感字段按策略脱敏;不得允许接收端利用 Bundle 获取原会话额外权限。 +- 多目标共享不可变 Bundle 内容但分别生成目标消息与授权记录。 + +### 4.21 会话与消息接口示例 + +以下示例用于本章 HTTP 联调快速查阅。除特别说明外: + +- 产品 HTTP 均使用 `https://{host}/{api_path}v1/im`,请求头包含 `X-MeetingIM-APIKey`、`X-IM-Schema: 1`、`X-Request-ID`;登录后接口再带 `Authorization: Bearer `。 +- 写接口必须带 `Idempotency-Key`。二次认证接口按正文说明额外带 `X-Action-Token` 或 `X-Risk-Action-Token`。 +- 成功响应统一包裹为 `{"code":0,"data":示例,"request_id":"...","server_time":"..."}`;下表只展示 `data`。 +- 失败响应统一包裹为 `{"code":30000,"reason":"MALFORMED_REQUEST","message":"...","details":{},"request_id":"...","server_time":"..."}`。 +- 标注“待实现/需修复”的接口是产品目标协议,前端可按示例准备,但联调前以后端发布状态为准。 + +#### 4.21.1 会话上下文、消息操作与检索 + +| 接口 | 输入示例 | 成功 `data` 示例 | +| --- | --- | --- | +| `GET /v1/im/conversations/{topic}/context` | Path `p2p...` 或 `grp...` | `{"topic":"p2p...","type":"p2p","relationship":"friend","can_send":true,"capabilities":{"text":true,"image":true,"file":true,"voice":true,"can_call":true},"peer":{"user_id":"usr...","display_name":"张三","avatar_file_id":""}}` | +| `POST /v1/im/conversations/p2p` 待实现 | `{"peer_user_id":"usr..."}` | `{"topic":"p2p...","relationship":"friend","can_send":true}` | +| `GET /v1/im/conversations/{topic}/settings` 待实现 | Path topic | `{"pinned":false,"mute_notifications":false,"message_retention":"default","blocked":false,"remark":"","version":1,"permissions":{"can_update":true}}` | +| `PATCH /v1/im/conversations/{topic}/settings` 待实现 | `{"pinned":true,"mute_notifications":false,"version":1}` | `{"pinned":true,"mute_notifications":false,"message_retention":"default","blocked":false,"remark":"","version":2}` | +| `GET /v1/im/conversations/{topic}/messages` 待实现 | Query `?since=12&limit=20` | `{"topic":"p2p...","items":[{"seq":13,"from":"usr...","ts":"2026-08-20T09:00:00Z","head":{"x-im-type":"text"},"content":{"text":"你好"}}],"next_cursor":""}` | +| `PUT /v1/im/topics/{topic}/draft` 待实现 | `{"content":"未发送草稿","version":1}` | `{"topic":"grp...","content":"未发送草稿","version":2,"updated_at":"2026-08-20T09:00:00Z"}` | +| `POST /v1/im/topics/{topic}/messages/{seq}/recall` | `{}`;可选 `{"version":1,"reason":"mistake"}` | `{"topic":"grp...","seq":12,"status":"recalled","version":2,"updated_at":"2026-08-20T09:00:00Z"}` | +| `POST /v1/im/topics/{topic}/messages/{seq}/revisions` | `{"content":{"text":"修改后的文本"},"client_mid":"mid_1","version":1}` | `{"topic":"grp...","seq":12,"status":"edited","version":2,"updated_at":"2026-08-20T09:00:00Z"}` | +| `GET /v1/im/topics/{topic}/messages/{seq}/receipts?cursor=` | Query only | `{"topic":"grp...","seq":12,"items":[{"user_id":"usr...","read_at":"2026-08-20T09:00:00Z","recv_at":"2026-08-20T08:59:00Z"}],"next_cursor":""}` | +| `GET /v1/im/topics/{topic}/messages/search?q=&type=text&cursor=` | Query only | `{"topic":"grp...","items":[{"seq":12,"from":"usr...","ts":"2026-08-20T09:00:00Z","snippet":"命中的文本","matches":[{"start":0,"end":2}]}],"next_cursor":""}` | +| `GET /v1/im/topics/{topic}/messages/search?type=file&q=&cursor=` | Query only | `{"topic":"grp...","items":[{"seq":12,"from":"usr...","ts":"2026-08-20T09:00:00Z","file":{"file_id":"file_1","name":"方案.pdf","size":1024,"mime":"application/pdf"},"availability":"available"}],"next_cursor":""}` | +| `GET /v1/im/topics/{topic}/messages/{seq}/context?before=20&after=20` | Query only | `{"topic":"grp...","anchor_seq":12,"items":[{"seq":11,"content":{"text":"上一条"}},{"seq":12,"content":{"text":"命中消息"}}]}` | +| `GET /v1/im/topics/{topic}/media?type=image&cursor=` | Query only | `{"topic":"grp...","items":[{"seq":12,"thumbnail_file_id":"file_thumb","file_id":"file_img","width":1080,"height":720,"ts":"2026-08-20T09:00:00Z","availability":"available"}],"next_cursor":""}` | + +消息撤回和编辑成功后,Topic 在线成员会收到 0.5.4.6 定义的 `info.what="im"` 业务事件:`message.recalled` 或 `message.edited`。前端收到后按 `topic + seq + version` 刷新本地消息,不要等待用户重新进入会话才更新。 + +## 5. 通讯录、好友关系与黑名单 + +### 5.1 通讯录.png + +通讯录 + +**状态:新增产品通讯录接口。** + +- 好友列表:`GET /v1/im/friends?query=&initial=&cursor=`,返回 `user_id,display_name,remark,avatar_file_id,initial,online_visibility`;显示名优先级为备注名、昵称、账号。 +- 分页:`next_cursor` 当前按最后一条好友的 `peer_id` 生成,客户端必须作为不透明字符串原样回传,不要自行解析、排序或拼接。 +- 组织成员:`GET /v1/im/organization/members?query=&department_id=&cursor=`;好友和组织成员是两种关系,前端不得混为一个可发消息权限。 +- 新朋友、群列表、文件助手和系统消息入口由 `GET /v1/im/bootstrap` 的 `entries` 控制显示,初始红点由 `entry_badges` 和各入口 `unread_count` 返回。新朋友待处理数使用业务统计;文件助手当前返回 `topic:"slf", unread_source:"topic"`;系统消息当前返回 `topic:"sys", unread_source:"business"`,未读来自系统通知业务表。列表缓存以租户和用户隔离。 + +### 5.2 新朋友.png + +新朋友 + +**状态:新增。** + +- 列表:`GET /v1/im/friend-requests?direction=inbound&status=all&cursor=`,返回 `request_id,applicant,message,source,status,created_at,handled_at`。 +- 同意:`POST /v1/im/friend-requests/{request_id}/accept`;拒绝:`POST /v1/im/friend-requests/{request_id}/reject`,均带幂等键和可选 `version`。 +- 成功返回最终 `status` 和 P2P `topic`。重复审批返回原结果;并发冲突返回 `REQUEST_ALREADY_HANDLED` 并附当前状态,前端刷新该项。 +- 主动下行:创建申请时被申请人收到 `info.what="im", event="friend.request.created"`;通过/拒绝后申请人与处理人本人都收到 `friend.request.accepted/rejected`。前端收到后刷新新朋友列表、好友列表、会话 context 和 `bootstrap.entry_badges`。当前系统通知列表只给被申请人/申请人写记录,处理人本人主要靠实时业务事件刷新。 + +### 5.3 添加好友.png + +添加好友 + +**状态:新增。** + +- 账号搜索:`GET /v1/im/users/search?q=&type=account&cursor=`;扫码后调用 `POST /v1/im/qrcodes/resolve`,Body `{"token":"...","purpose":"add_friend"}`。 +- 结果统一返回 `relationship=none|friend|pending_inbound|pending_outbound` 和 `can_add`;隐私不允许或未找到统一为 `USER_NOT_DISCOVERABLE`。 +- 分享/二维码见 5.4;发申请见 5.6。服务端按租户配置 `allow_user_search` 和用户隐私再次拦截。 + +### 5.4 添加好友(1).png + +添加好友(1) + +**状态:新增。** + +- 获取二维码:`POST /v1/im/me/qrcodes`,Body `{"purpose":"add_friend","expires_in_seconds":86400}`,返回 `qr_token,qr_payload,expires_at,display_profile`。 +- 二维码只含不透明、签名、带租户和用途约束的短期 Token,不直接放手机号、用户 ID 或登录 Token。 +- 分享/保存图片是客户端动作;扫码解析始终调用 5.3,过期返回 `QR_CODE_EXPIRED`,篡改返回 `QR_CODE_INVALID`。 + +### 5.5 添加好友-输入精准用户ID.png + +添加好友-输入精准用户ID + +**状态:新增。** + +- 精准查询仍调用 `GET /v1/im/users/search?q=&type=exact_account`;手机号查询仅当租户和目标隐私同时允许时使用 `type=phone`。 +- 每项返回公开资料和 `action=add|chat|pending|none`。前端不得根据是否返回手机号推断账户存在。 +- 搜索限流返回 `SEARCH_RATE_LIMITED`;禁止客户端批量枚举 ID。 + +### 5.6 添加好友验证.png + +添加好友验证 + +**状态:新增。** + +- `POST /v1/im/friend-requests`,Body `{"target_user_id":"usr...","message":"验证留言","source":"search|qr|group","source_topic":"grp..."}`。 +- 留言最多 50 个 Unicode 字符,服务端做敏感内容审核;`source_topic` 只能是双方当前可见的同租户群。 +- 成功返回 `request_id,status:"pending"`;重复有效申请返回原记录,目标隐私关闭时返回统一 `FRIEND_REQUEST_NOT_ALLOWED`。 + +### 5.7 好友个人主页.png + +好友个人主页 + +**状态:新增。** + +- `GET /v1/im/users/{identifier}/profile` 返回按当前查看人裁剪后的 `display_name,remark,avatar_file_id,gender,region,public_account,signature,relationship,capabilities`;`identifier` 可传 `user_id`、`username` 或 `public_account`。 +- 查看该用户二维码调用 `POST /v1/im/users/{identifier}/qrcodes`,Body `{"purpose":"add_friend","expires_in_seconds":86400}`;前端在个人信息页只有用户名时直接把用户名作为 `identifier` 传入即可。服务端解析到真实用户后生成二维码,并按资料可见性与 `add_me_methods.qr` 做权限校验。 +- 发消息使用返回的 P2P `topic`;没有 Topic 时调用 `POST /v1/im/conversations/p2p`,Body `{"peer_user_id":"..."}` 并由服务端验证好友关系。 +- 音视频按钮只按 `capabilities.can_call` 展示,真正呼叫时再次鉴权。隐藏字段必须从响应中省略,不能只由前端遮挡。 + +### 5.8 群聊—非好友个人主页.png + +群聊—非好友个人主页 + +**状态:新增。** + +- `GET /v1/im/groups/{topic}/members/{user_id}/profile` 返回群内昵称、角色和允许公开的账号资料。`{topic}` 必须是群聊 topic(`grp...`);单聊、名片或普通用户资料页使用 `GET /v1/im/users/{identifier}/profile`,不要把 `usr...` 用户 ID 放到 groups 路径。 +- `can_add_friend` 同时受群 `allow_member_add_friend`、目标隐私和租户策略控制;为 false 时隐藏添加入口。 +- 从该页申请时 5.6 传 `source:"group"` 和当前 Topic;服务端校验双方仍在群内。 + +### 5.9 好友—聊天设置—未拉黑状态.png + +好友—聊天设置—未拉黑状态 + +**状态:部分 Topic 设置已实现;产品设置新增。** + +- 查询:`GET /v1/im/conversations/{topic}/settings` 返回 `pinned,mute_notifications,message_retention,blocked,remark,permissions`。 +- 更新置顶/免打扰/保存方式:`PATCH /v1/im/conversations/{topic}/settings`,Body 只提交变更字段并带 `version`;返回最新完整设置。 +- 备注:`PATCH /v1/im/friends/{user_id}`,Body `{"remark":"..."}`;查记录见第 7 章,举报见第 11 章,删除见 5.11。 + +### 5.10 好友—聊天设置—拉黑状态.png + +好友—聊天设置—拉黑状态 + +**状态:新增。** + +- 开启拉黑:`PUT /v1/im/blocks/{user_id}`;解除:`DELETE /v1/im/blocks/{user_id}`,均带幂等键。 +- 成功返回 `blocked,relationship,updated_at` 并向当前用户其他设备推送 `privacy.block.updated`。拉黑后服务端拦截对方消息和呼叫。 +- 解除拉黑只恢复黑名单 ACL;若双方已非好友,`relationship` 仍为 `none`,不能自动恢复好友。 + +### 5.11 删除好友提示.png + +删除好友提示 + +**状态:确认框纯客户端;删除接口新增。** + +- 确认调用 `DELETE /v1/im/friends/{user_id}`,带幂等键。是否清除本地历史由客户端在成功后独立处理,不放入删除好友请求。 +- 服务端解除双向好友关系但不替对方删除历史;返回 `relationship:"none",topic`。当前用户是否删除本地/服务端可见记录是独立选择。 +- 取消不请求;确认后清理页面缓存并等待关系事件校正其他设备。 + +### 5.12 黑名单.png + +黑名单 + +**状态:新增。** + +- `GET /v1/im/blocks?cursor=` 返回 `user,blocked_at`;移出调用 5.10 的 DELETE。 +- 批量解除:`POST /v1/im/blocks/batch-delete`,Body `{"user_ids":[...]}`,返回逐项结果;最多 100 人。 +- 用户资料不可见时仍用受控占位显示黑名单记录,不能因资料删除而无法解除。 + +### 5.13 黑名单提示(1).png + +黑名单提示(1) + +**状态:确认框纯客户端;写接口新增。** + +- 确认后调用 `PUT /v1/im/blocks/{user_id}`,相同幂等键重复提交返回当前 `blocked=true`。 +- 服务端在同一事务内更新黑名单和通信 ACL,并写审计;取消不调用接口。 +- 若对象跨租户或不存在统一返回 `RESOURCE_NOT_FOUND`。 + +### 5.14 黑名单提示.png + +黑名单提示 + +**状态:图片文件名与画面语义不一致;按“清除聊天记录”实施,需改造。** + +- 确认调用 MeetingIM `{del what:"msg",topic,delseq:[{"low":1,"hi":last_seq+1}],hard:false}`,仅删除当前用户视图;或封装 `DELETE /v1/im/conversations/{topic}/messages`。 +- 不解除好友、不影响对方历史、不删除收藏;附件仅在无任何有效引用且过保留期后回收。 +- 前端需在确认文案明确“仅清除自己的聊天记录”,成功后清本地 Topic 消息缓存并保留会话关系。 + +### 5.15 接口示例 + +以下示例用于本章 HTTP 联调快速查阅。除特别说明外: + +- 产品 HTTP 均使用 `https://{host}/{api_path}v1/im`,请求头包含 `X-MeetingIM-APIKey`、`X-IM-Schema: 1`、`X-Request-ID`;登录后接口再带 `Authorization: Bearer `。 +- 写接口必须带 `Idempotency-Key`。二次认证接口按正文说明额外带 `X-Action-Token` 或 `X-Risk-Action-Token`。 +- 成功响应统一包裹为 `{"code":0,"data":示例,"request_id":"...","server_time":"..."}`;下表只展示 `data`。 +- 失败响应统一包裹为 `{"code":30000,"reason":"MALFORMED_REQUEST","message":"...","details":{},"request_id":"...","server_time":"..."}`。 +- 标注“待实现/需修复”的接口是产品目标协议,前端可按示例准备,但联调前以后端发布状态为准。 + +#### 5.15.1 通讯录、好友、黑名单与二维码 + +| 接口 | 输入示例 | 成功 `data` 示例 | +| --- | --- | --- | +| `GET /v1/im/friends?query=&initial=&cursor=` | Query only | `{"items":[{"user_id":"usr...","display_name":"张三","remark":"","avatar_file_id":"","initial":"Z","topic":"p2p...","relationship":"friend","online_visibility":"unknown","capabilities":{"can_send":true,"can_call":true}}],"next_cursor":""}` | +| `GET /v1/im/friends?query=&cursor=` | Query only | `{"items":[{"user_id":"usr...","display_name":"张三","topic":"p2p...","relationship":"friend"}],"next_cursor":""}` | +| `GET /v1/im/friend-requests?direction=inbound&status=all&cursor=` | Query only | `{"items":[{"request_id":"frq_1","direction":"inbound","applicant_id":"usr_a","target_user_id":"usr_b","peer":{"user_id":"usr_a","display_name":"张三"},"message":"我是张三","source":"search","status":"pending","created_at":"2026-08-20T09:00:00Z"}],"next_cursor":""}` | +| `POST /v1/im/friend-requests` | `{"target_user_id":"usr...","message":"验证留言","source":"search","source_topic":""}` | `{"request_id":"frq_1","applicant_id":"usr_me","target_user_id":"usr...","message":"验证留言","source":"search","status":"pending","topic":"p2p...","created_at":"2026-08-20T09:00:00Z","expires_at":"2026-08-27T09:00:00Z"}` | +| `POST /v1/im/friend-requests/{request_id}/accept` | `{"version":1}` | `{"request_id":"frq_1","status":"accepted","topic":"p2p...","handled_at":"2026-08-20T09:00:00Z"}` | +| `POST /v1/im/friend-requests/{request_id}/reject` | `{"version":1}` | `{"request_id":"frq_1","status":"rejected","handled_at":"2026-08-20T09:00:00Z"}` | +| `GET /v1/im/users/search?q=&type=account&cursor=` | Query only | `{"items":[{"user_id":"usr...","display_name":"张三","avatar_file_id":"","public_account":"zhangsan","relationship":"none","can_add":true}],"next_cursor":""}` | +| `GET /v1/im/users/search?q=&type=exact_account` | Query only | `{"items":[{"user_id":"usr...","display_name":"张三","public_account":"zhangsan","relationship":"none","can_add":true}],"next_cursor":""}` | +| `GET /v1/im/users/{identifier}/profile` | Path `user_id`/`username`/`public_account` | `{"user_id":"usr...","display_name":"张三","remark":"","avatar_file_id":"","gender":"","region":"","public_account":"zhangsan","signature":"","relationship":"friend","capabilities":{"can_send":true,"can_call":true,"can_add":false},"topic":"p2p..."}` | +| `PATCH /v1/im/friends/{user_id}` | `{"remark":"老张"}` | `{"user_id":"usr...","remark":"老张","relationship":"friend","updated_at":"2026-08-20T09:00:00Z"}` | +| `DELETE /v1/im/friends/{user_id}` | Path user ID | `{"user_id":"usr...","relationship":"none","deleted":true}` | +| `GET /v1/im/blocks?cursor=` | Query only | `{"items":[{"user":{"user_id":"usr...","display_name":"张三","avatar_file_id":""},"blocked_at":"2026-08-20T09:00:00Z"}],"next_cursor":""}` | +| `PUT /v1/im/blocks/{user_id}` | `{}` | `{"user_id":"usr...","blocked":true,"blocked_at":"2026-08-20T09:00:00Z"}` | +| `DELETE /v1/im/blocks/{user_id}` | Path user ID | `{"user_id":"usr...","blocked":false}` | +| `POST /v1/im/blocks/batch-delete` | `{"user_ids":["usr_a","usr_b"]}` | `{"items":[{"user_id":"usr_a","blocked":false}],"failed":[]}` | +| `POST /v1/im/me/qrcodes` | `{"purpose":"add_friend","expires_in_seconds":86400}` | `{"qr_token":"token...","qr_payload":"im://qr?token=...&purpose=add_friend","expires_at":"2026-08-21T09:00:00Z","display_profile":{"user_id":"usr...","display_name":"张三"}}` | +| `POST /v1/im/users/{identifier}/qrcodes` | `{"purpose":"add_friend","expires_in_seconds":86400}` | `{"qr_token":"token...","qr_payload":"im://qr?token=...&purpose=add_friend","expires_at":"2026-08-21T09:00:00Z","display_profile":{"user_id":"usr...","display_name":"张三","public_account":"zhangsan"}}` | +| `POST /v1/im/qrcodes/resolve` | `{"token":"token...","purpose":"add_friend"}` | `{"purpose":"add_friend","user":{"user_id":"usr...","display_name":"张三"},"relationship":"none","can_add":true}` | + +## 6. 群聊创建、资料维护与成员治理 + +### 6.1 群聊.png + +群聊 + +**状态:MeetingIM 群 Topic 已实现基础能力;产品群列表与配额需改造。** + +- 列表:`GET /v1/im/groups?category=owned|managed|joined&query=&cursor=`,返回 `topic,name,avatar_file_id,member_count,my_role,updated_at`。 +- 创建:`POST /v1/im/groups`,Body `{"name":"...","description":"...","member_user_ids":[],"avatar_file_id":"..."}`;`description` 可省略或传空字符串,最长 200 字符;原子校验租户群数上限和群成员上限,返回 `topic,name,avatar_file_id,description,owner,member_count,version,permissions`。 +- 分类由服务端 `my_role` 决定;创建成功或角色变化后通过 `info.what="group"` 业务事件刷新,不由前端自行移动分组。 + +### 6.2 空白页.png + +空白页 + +**状态:纯客户端。** + +- 对 6.1 任一分类返回 `items=[]` 且 `has_more=false` 时显示对应空状态,不追加空白占位数据。 +- 收到 `group.membership.updated` 或创建成功后重新拉取当前分类。 + +### 6.3 加入群聊.png + +加入群聊 + +**状态:新增邀请卡片和加入接口。** + +- 邀请卡展示以群邀请接口返回和系统通知记录为准;相关实时刷新使用 `info.what="group"` 群业务事件。 +- 点击加入:`POST /v1/im/group-invitations/{invitation_id}/accept`,带幂等键;返回 `status=joined|pending_approval` 和 `topic`。 +- 服务端校验邀请租户、有效期、群状态、成员资格和成员数上限;重复加入返回当前状态,过期返回 `INVITATION_EXPIRED`。 + +### 6.4 内容沟通对话3-开启进群验证与拉人状态.png + +内容沟通对话3-开启进群验证与拉人状态 + +**状态:新增群成员事件。** + +- 邀请:`POST /v1/im/groups/{topic}/invitations`,Body `{"user_ids":[],"message":""}`;按群策略直接加入或创建待审批申请。 +- 加入、审批、拒绝等结果由服务端生成 `info.what="group"` 群业务事件,例如 `group.member.joined`、`group.join_request.accepted/rejected`。 +- 客户端只渲染服务端可信事件;服务端同时更新成员表、Topic 订阅和成员数,不允许三者不一致。 + +### 6.5 邀请进群申请.png + +邀请进群申请 + +**状态:新增。** + +- `GET /v1/im/groups/{topic}/join-requests?status=pending&cursor=` 返回申请人、邀请人、来源、时间、状态和 `version`。 +- 同意:`POST /v1/im/groups/{topic}/join-requests/{id}/accept`;拒绝:`POST /v1/im/groups/{topic}/join-requests/{id}/reject`,带幂等键与 `version`。 +- 仅群主/管理员可处理;同意时原子校验群成员上限。并发处理返回 `REQUEST_ALREADY_HANDLED` 和最终状态。 + +### 6.6 新邀请进群申请.png + +新邀请进群申请 + +**状态:新增。** + +- `GET /v1/im/groups/{topic}/summary` 返回 `pending_join_request_count`、最新公告和权限;新申请通过 `group.join_request.created` 推送给有审批权限的成员。 +- 审批完成后服务端广播新数量;前端归零时移除入口。普通成员响应中不得得到申请人敏感信息或数量。 + +### 6.7 群公告状态.png + +群公告状态 + +**状态:新增。** + +- 查询最新公告:`GET /v1/im/groups/{topic}/announcements/latest`;发布/更新:`PUT /v1/im/groups/{topic}/announcements/current`,Body `{"content":"...","version":1}`。 +- 返回 `announcement_id,topic,content,author,version,published_at,permissions.can_edit`;即使暂无公告也返回空公告和编辑权限。仅有权限角色可写,使用乐观锁防覆盖。 +- 发布成功后服务端生成 `group.announcement.updated` 群业务事件;顶部摘要使用接口返回数据,不解析聊天文本猜测。 + +### 6.8 群公告状态/邀请进群申请同时存在.png + +群公告状态/邀请进群申请同时存在 + +**状态:纯客户端组合;数据接口新增。** + +- 页面一次调用 `GET /v1/im/groups/{topic}/summary`,分别读取 `announcement` 和 `pending_join_request_count`,按产品固定顺序渲染两个入口。 +- 处理申请只刷新计数,不清公告;更新公告只替换公告对象,不影响申请入口。 + +### 6.9 群公告(对方查看状态).png + +群公告(对方查看状态) + +**状态:新增。** + +- 详情:`GET /v1/im/groups/{topic}/announcements/{announcement_id}`;普通成员响应不含编辑权限。 +- 已读:`POST /v1/im/groups/{topic}/announcements/{id}/read`,带幂等键,返回 `read_at`;用于清除新公告标记。 +- 服务端校验当前群成员资格,退群后不可借历史 ID 读取新公告。 + +### 6.10 群聊设置.png + +群聊设置 + +**状态:部分订阅设置已实现;产品菜单新增。** + +- `GET /v1/im/groups/{topic}/settings` 返回 `my_role,permissions,profile,announcement,member_count,pinned,mute_notifications` 和群治理开关。 +- 个人置顶/免打扰通过 `PATCH /v1/im/groups/{topic}/member-settings`;群级设置必须使用 6.17,不混在个人设置里。 +- 退出:`POST /v1/im/groups/{topic}/leave`;群主必须先转让或解散。清记录、举报分别见 5.14、11.3。 + +### 6.11 群资料.png + +群资料 + +**状态:需改造。** + +- `GET /v1/im/groups/{topic}` 返回 `name,avatar_file_id,description,owner,member_count,version,permissions`。 +- 更新:`PATCH /v1/im/groups/{topic}`,Body 可含 `name,avatar_file_id,description,version`;成功返回最新对象并推送 `group.profile.updated`。 +- 服务端做角色、长度、敏感内容、文件归属校验;前端只展示 `permissions.can_edit_profile` 允许的入口。 + +### 6.12 设置群头像.png + +设置群头像 + +**状态:上传基础已实现;用途绑定需改造。** + +- 来源选择、裁剪是客户端动作。上传使用 `purpose=group_avatar`,服务端校验图片 MIME、尺寸、大小并返回 `file_id`。 +- 上传后调用 6.11 PATCH `{"avatar_file_id":"...","version":n}`;只有 PATCH 成功才替换正式头像。 +- 取消或 PATCH 失败的临时文件按 TTL 回收,不能污染群文件列表。 + +### 6.13 设置昵称(1).png + +设置昵称(1) + +**状态:新增。** + +- `PUT /v1/im/groups/{topic}/members/me/profile`,Body `{"nickname":"...","version":n}`。 +- 服务端校验成员资格、长度和敏感词,返回 `nickname,version,updated_at` 并推送成员资料变化。 +- 该昵称仅作用当前群,不能修改全局用户昵称或其他群资料。 + +### 6.14 群简介.png + +群简介 + +**状态:同 6.11,需改造。** + +- 保存调用 `PATCH /v1/im/groups/{topic}`,Body `{"description":"...","version":n}`;空字符串表示清空,省略表示不修改。 +- 服务端返回 `VALIDATION_FAILED` 的 `details.field/max_length` 或 `CONTENT_REJECTED`;前端保留草稿供修正。 + +### 6.15 群标识.png + +群标识 + +**状态:角色已有基础数据;产品渲染需统一。** + +- 群消息下行增加服务端确认的 `sender_snapshot{display_name,role}`,角色枚举固定 `owner|admin|member`。 +- 成员角色变化事件到达后刷新成员缓存;历史消息可以保留发送时角色快照,详情以当前角色接口为准。 +- 客户端不得根据本地管理员列表给消息自行加可信标识。 + +### 6.16 群二维码页面.png + +群二维码页面 + +**状态:新增。** + +- `POST /v1/im/groups/{topic}/qrcodes`,Body `{"expires_in_seconds":86400}`,返回签名 `qr_token,qr_payload,expires_at,group_snapshot`。 +- 扫码调用 `POST /v1/im/qrcodes/resolve`,`purpose:"join_group"`;随后 `POST /v1/im/groups/{topic}/join-requests` 并携带 `qr_token`。 +- 群解散、二维码过期或管理员撤销后必须失效;二维码不得直接承载内部群权限。 + +### 6.17 群组管理.png + +群组管理 + +**状态:新增。** + +- 查询/更新:`GET/PATCH /v1/im/groups/{topic}/governance`,字段为 `join_approval,mute_all,member_upload_files,member_private_chat,member_invite` 和 `version`。 +- 每个字段返回 `editable`;PATCH 仅群主或被授权管理员可用,记录操作者、旧值、新值和 Request ID。 +- 解散:`DELETE /v1/im/groups/{topic}`,需二次 `X-Action-Token` 和幂等键;成功后撤销 Topic、二维码、邀请和文件新增权限。 + +### 6.18 设置群管理员.png + +设置群管理员 + +**状态:新增。** + +- `GET /v1/im/groups/{topic}/members?role=admin&cursor=` 返回管理员列表;`GET /v1/im/groups/{topic}/role-limits` 返回 `max_admin_count,current_admin_count`。 +- 只有群主可进入。群主本人以 `owner` 单独展示,不计入普通管理员列表和名额。 + +### 6.19 添加管理员.png + +添加管理员 + +**状态:新增。** + +- 候选:`GET /v1/im/groups/{topic}/members?eligible_role=admin&query=&cursor=`,返回 `eligible` 和不可选原因。 +- 提交:`POST /v1/im/groups/{topic}/admins/batch`,Body `{"user_ids":[],"version":n}`;服务端原子校验群主身份、成员资格和管理员上限。 +- 超限返回 `GROUP_ADMIN_LIMIT_EXCEEDED`,并附 `max/current/requested`。 + +### 6.20 添加管理员-设置成功.png + +添加管理员-设置成功 + +**状态:新增。** + +- 6.19 成功返回 `admins,version,updated_at`;前端用响应替换列表并显示成功,不在请求完成前自行修改角色。 +- 服务端推送 `group.role.updated`,新管理员收到后刷新菜单权限和群标识。 + +### 6.21 添加管理员-设置成功(1).png + +添加管理员-设置成功(1) + +**状态:新增。** + +- 提交期间按幂等键禁重复点击。批量接口返回 `succeeded[]`、`failed[{user_id,reason}]` 和最新 `admins`。 +- 部分失败时保留成功角色,不做整批客户端回滚;前端展示失败原因并以服务端列表为准。 + +### 6.22 删除管理员-设置成功.png + +删除管理员-设置成功 + +**状态:新增。** + +- `DELETE /v1/im/groups/{topic}/admins/{user_id}`,带幂等键和 `If-Match`/`version`。 +- 成功后目标角色变为 `member`,保留群成员关系;服务端立即撤销管理权限并广播角色事件。 +- 删除群主、非成员或最后状态冲突分别返回稳定错误,不允许前端拼接通用成功。 + +### 6.23 添加禁言成员.png + +添加禁言成员 + +**状态:新增。** + +- 候选:`GET /v1/im/groups/{topic}/members?mute_eligible=true&query=&cursor=`。 +- 提交:`POST /v1/im/groups/{topic}/mutes/batch`,Body `{"members":[{"user_id":"usr...","expires_at":null}],"reason":""}`。 +- 服务端校验操作者角色层级;管理员不能禁言群主,普通管理员互相操作规则由治理策略确定。 + +### 6.24 禁言的群成员.png + +禁言的群成员 + +**状态:新增。** + +- `GET /v1/im/groups/{topic}/mutes?cursor=` 返回 `user,muted_by,muted_at,expires_at`;解除 `DELETE /v1/im/groups/{topic}/mutes/{user_id}`。 +- 禁言成员发送消息时服务端返回 `GROUP_MUTED` 和可选 `expires_at`;不能只禁用前端输入框。 +- 退群时清理禁言记录;过期由服务端自动失效并推送更新。 + +### 6.25 选择新群主.png + +选择新群主 + +**状态:新增。** + +- `GET /v1/im/groups/{topic}/members?eligible_role=owner&query=&cursor=`,只返回状态正常的当前成员。 +- 前端单选并保存 `target_user_id`;当前群主、已退出、停用账号返回 `eligible=false`。 +- 此步骤不修改权限,真正转让仅在 6.26 确认后执行。 + +### 6.26 选择新群主提示.png + +选择新群主提示 + +**状态:新增。** + +- `POST /v1/im/groups/{topic}/owner-transfer`,Body `{"target_user_id":"usr...","version":n}`,必须带幂等键和二次认证 `X-Action-Token`。 +- 服务端事务内把旧群主降为管理员或成员、目标升为群主、更新权限和审计,再生成 `group.owner.transferred` 群业务事件。 +- 成功返回 `owner,previous_owner,roles,version`;并发成员变化返回 `GROUP_VERSION_CONFLICT`,前端重新确认,不自动重试。 + +### 6.27 群文件.png + +群文件 + +**状态:新增群文件索引;底层文件存储已实现。** + +- `GET /v1/im/groups/{topic}/files?query=&type=&cursor=` 返回 `group_file_id,file_id,name,size,mime,uploader,created_at,download_state,permissions`。 +- 下载使用认证文件接口,服务端校验当前群成员和文件状态;删除 `DELETE /v1/im/groups/{topic}/files/{group_file_id}` 需上传者或管理员权限。 +- 退群后不可继续使用旧 URL 下载;缓存文件是否保留由客户端隐私策略处理。 + +### 6.28 群文件-上传状态.png + +群文件-上传状态 + +**状态:需改造。** + +- 先用 `purpose=group_file,topic=...` 上传,再 `POST /v1/im/groups/{topic}/files`,Body `{"file_id":"..."}` 建立群文件记录。 +- 服务端校验 `member_upload_files`、单文件上限、租户总存储和群成员身份;成功返回完整群文件项。 +- 上传失败可复用 `upload_id`;登记失败不得在列表显示,孤立文件按 TTL 回收。 + +### 6.29 接口示例 + +以下示例用于本章 HTTP 联调快速查阅。除特别说明外: + +- 产品 HTTP 均使用 `https://{host}/{api_path}v1/im`,请求头包含 `X-MeetingIM-APIKey`、`X-IM-Schema: 1`、`X-Request-ID`;登录后接口再带 `Authorization: Bearer `。 +- 写接口必须带 `Idempotency-Key`。二次认证接口按正文说明额外带 `X-Action-Token` 或 `X-Risk-Action-Token`。 +- 成功响应统一包裹为 `{"code":0,"data":示例,"request_id":"...","server_time":"..."}`;下表只展示 `data`。 +- 失败响应统一包裹为 `{"code":30000,"reason":"MALFORMED_REQUEST","message":"...","details":{},"request_id":"...","server_time":"..."}`。 +- 标注“待实现/需修复”的接口是产品目标协议,前端可按示例准备,但联调前以后端发布状态为准。 + +#### 6.29.1 群聊、群成员与群治理 + +| 接口 | 输入示例 | 成功 `data` 示例 | +| --- | --- | --- | +| `GET /v1/im/groups?category=owned&query=&cursor=` | Query only | `{"items":[{"topic":"grp...","name":"项目群","avatar_file_id":"","member_count":3,"my_role":"owner","updated_at":"2026-08-20T09:00:00Z"}],"next_cursor":"","has_more":false}` | +| `GET /v1/im/groups?category=owned\|managed\|joined&query=&cursor=` | Query only | `{"items":[{"topic":"grp...","name":"项目群","avatar_file_id":"","member_count":3,"my_role":"admin","updated_at":"2026-08-20T09:00:00Z"}],"next_cursor":"","has_more":false}` | +| `POST /v1/im/groups` | `{"name":"项目群","description":"项目沟通","member_user_ids":["usr..."],"avatar_file_id":""}` | `{"topic":"grp...","name":"项目群","avatar_file_id":"","description":"项目沟通","owner":"usr_me","member_count":2,"version":1,"my_role":"owner","permissions":{"can_edit_profile":true,"can_invite_members":true,"can_manage_members":true,"can_manage_settings":true,"can_leave":false}}` | +| `GET /v1/im/groups/{topic}` | Path topic | `{"topic":"grp...","name":"项目群","avatar_file_id":"","description":"项目沟通","owner":"usr_me","member_count":2,"version":1,"my_role":"owner","permissions":{"can_edit_profile":true}}` | +| `PATCH /v1/im/groups/{topic}` | `{"name":"新群名","avatar_file_id":"file_avatar","description":"新简介","version":1}` | `{"topic":"grp...","name":"新群名","avatar_file_id":"file_avatar","description":"新简介","version":2,"permissions":{"can_edit_profile":true}}` | +| `DELETE /v1/im/groups/{topic}` 待实现 | Header `X-Action-Token: action_token`;Body `{}` | `{"topic":"grp...","deleted":true}` | +| `GET /v1/im/groups/{topic}/settings` | Path topic | `{"topic":"grp...","profile":{"name":"项目群","description":"项目沟通"},"pinned":false,"mute_notifications":false,"member_count":2,"permissions":{"can_manage_settings":true},"governance":{"join_approval":false,"mute_all":false,"member_invite":true}}` | +| `PATCH /v1/im/groups/{topic}/member-settings` | `{"pinned":true,"mute_notifications":false,"version":1}` | `{"topic":"grp...","pinned":true,"mute_notifications":false,"version":2,"updated_at":"2026-08-20T09:00:00Z"}` | +| `PUT /v1/im/groups/{topic}/members/me/profile` | `{"nickname":"我在群里的昵称","version":1}` | `{"nickname":"我在群里的昵称","version":2,"updated_at":"2026-08-20T09:00:00Z"}` | +| `POST /v1/im/groups/{topic}/leave` | `{}` | `{"topic":"grp...","status":"left"}` | +| `GET /v1/im/groups/{topic}/members?query=&cursor=&mentionable=true` | Query only | `{"topic":"grp...","items":[{"user_id":"usr...","display_name":"张三","avatar_file_id":"","role":"member","mentionable":true,"acs":{"want":"JRWP","given":"JRWP","mode":"JRWP"}}],"next_cursor":"","permissions":{"can_mention_all":true}}` | +| `GET /v1/im/groups/{topic}/members/{user_id}/profile` | Path topic/user;`topic` 必须是 `grp...`,不能传 `usr...` | `{"topic":"grp...","user_id":"usr...","display_name":"张三","group_nickname":"小张","role":"member","relationship":"friend","capabilities":{"can_send":true,"can_call":true}}` | +| `POST /v1/im/groups/{topic}/qrcodes` | `{"expires_in_seconds":86400}` | `{"qr_token":"token...","qr_payload":"im://qr?token=...&purpose=join_group","expires_at":"2026-08-21T09:00:00Z","group_snapshot":{"topic":"grp...","name":"项目群","member_count":2}}` | +| `POST /v1/im/groups/{topic}/invitations` | `{"user_ids":["usr..."],"message":"进群一起讨论"}` | `{"topic":"grp...","items":[{"user_id":"usr...","status":"joined","invitation_id":"ginv_1"}]}` | +| `POST /v1/im/group-invitations/{invitation_id}/accept` 待实现 | `{}` | `{"invitation_id":"ginv_1","status":"joined","topic":"grp..."}` | +| `POST /v1/im/groups/{topic}/join-requests` | `{"message":"申请入群","source":"qr","qr_token":"token..."}` | `{"request_id":"gjr_1","topic":"grp...","status":"pending","created_at":"2026-08-20T09:00:00Z","version":1}` | +| `GET /v1/im/groups/{topic}/join-requests?status=pending&cursor=` | Query only | `{"topic":"grp...","items":[{"request_id":"gjr_1","applicant":{"user_id":"usr...","display_name":"张三"},"status":"pending","source":"qr","created_at":"2026-08-20T09:00:00Z","version":1}],"next_cursor":""}` | +| `POST /v1/im/groups/{topic}/join-requests/{id}/accept` | `{"version":1}` | `{"request_id":"gjr_1","topic":"grp...","status":"accepted","handled_at":"2026-08-20T09:00:00Z","version":2}` | +| `POST /v1/im/groups/{topic}/join-requests/{id}/reject` | `{"version":1,"reason":"人数已满"}` | `{"request_id":"gjr_1","topic":"grp...","status":"rejected","handled_at":"2026-08-20T09:00:00Z","version":2}` | +| `GET /v1/im/groups/{topic}/summary` | Path topic | `{"topic":"grp...","pending_join_request_count":0,"announcement":{"announcement_id":"ann_1","content":"公告"},"permissions":{"can_manage_members":true}}` | +| `GET /v1/im/groups/{topic}/announcements/latest` | Path topic | `{"announcement_id":"ann_1","topic":"grp...","content":"公告内容","version":1,"published_at":"2026-08-20T09:00:00Z","permissions":{"can_edit":true}}` | +| `PUT /v1/im/groups/{topic}/announcements/current` | `{"content":"新的公告","version":1}` | `{"announcement_id":"ann_1","topic":"grp...","content":"新的公告","version":2,"published_at":"2026-08-20T09:00:00Z"}` | +| `GET /v1/im/groups/{topic}/announcements/{announcement_id}` | Path announcement | `{"announcement_id":"ann_1","topic":"grp...","content":"公告内容","version":2,"published_at":"2026-08-20T09:00:00Z"}` | +| `POST /v1/im/groups/{topic}/announcements/{id}/read` | `{}` | `{"announcement_id":"ann_1","read_at":"2026-08-20T09:00:00Z"}` | +| `GET /v1/im/groups/{topic}/governance` | Path topic | `{"join_approval":false,"mute_all":false,"member_upload_files":true,"member_private_chat":true,"member_invite":true,"version":1}` | +| `PATCH /v1/im/groups/{topic}/governance` | `{"join_approval":true,"member_invite":false,"version":1}` | `{"join_approval":true,"member_invite":false,"version":2}` | +| `GET /v1/im/groups/{topic}/role-limits` | Path topic | `{"max_admin_count":10,"current_admin_count":1}` | +| `GET /v1/im/groups/{topic}/members?role=admin&cursor=` | Query only | `{"topic":"grp...","items":[{"user_id":"usr_admin","display_name":"管理员","role":"admin"}],"next_cursor":""}` | +| `GET /v1/im/groups/{topic}/members?eligible_role=admin&query=&cursor=` | Query only | `{"topic":"grp...","items":[{"user_id":"usr...","display_name":"张三","role":"member","eligible":true}],"next_cursor":""}` | +| `POST /v1/im/groups/{topic}/admins/batch` | `{"user_ids":["usr..."],"version":1}` | `{"succeeded":[{"user_id":"usr...","role":"admin"}],"admins":[{"user_id":"usr...","role":"admin"}],"version":2,"updated_at":"2026-08-20T09:00:00Z"}` | +| `DELETE /v1/im/groups/{topic}/admins/{user_id}` | Header `If-Match: 2` 或 Body version | `{"user_id":"usr...","role":"member","updated_at":"2026-08-20T09:00:00Z"}` | +| `GET /v1/im/groups/{topic}/members?mute_eligible=true&query=&cursor=` | Query only | `{"topic":"grp...","items":[{"user_id":"usr...","display_name":"张三","eligible":true}],"next_cursor":""}` | +| `POST /v1/im/groups/{topic}/mutes/batch` | `{"members":[{"user_id":"usr...","expires_at":null}],"reason":"刷屏"}` | `{"topic":"grp...","items":[{"user_id":"usr...","muted":true,"muted_at":"2026-08-20T09:00:00Z","expires_at":null}]}` | +| `GET /v1/im/groups/{topic}/mutes?cursor=` | Query only | `{"topic":"grp...","items":[{"user":{"user_id":"usr...","display_name":"张三"},"muted_by":"usr_admin","muted_at":"2026-08-20T09:00:00Z","expires_at":null}],"next_cursor":""}` | +| `DELETE /v1/im/groups/{topic}/mutes/{user_id}` | Path user | `{"user_id":"usr...","muted":false}` | +| `GET /v1/im/groups/{topic}/members?eligible_role=owner&query=&cursor=` | Query only | `{"topic":"grp...","items":[{"user_id":"usr...","display_name":"张三","eligible":true}],"next_cursor":""}` | +| `POST /v1/im/groups/{topic}/owner-transfer` | Header `X-Action-Token: action_token`;Body `{"target_user_id":"usr...","version":2}` | `{"owner":"usr...","previous_owner":"usr_old","version":3}` | +| `GET /v1/im/groups/{topic}/files?query=&type=&cursor=` 待修复 | Query only | `{"topic":"grp...","items":[{"group_file_id":"gfile_1","file_id":"file_1","name":"方案.pdf","size":1024,"mime":"application/pdf","uploader":{"user_id":"usr...","display_name":"张三"},"created_at":"2026-08-20T09:00:00Z","download_state":"available","permissions":{"can_delete":true}}],"next_cursor":""}` | +| `POST /v1/im/groups/{topic}/files` 待修复 | `{"file_id":"file_1"}` | `{"group_file_id":"gfile_1","file_id":"file_1","topic":"grp...","created_at":"2026-08-20T09:00:00Z"}` | +| `DELETE /v1/im/groups/{topic}/files/{group_file_id}` 待修复 | Path group file ID | `{"group_file_id":"gfile_1","deleted":true}` | + +## 7. 文件下载、收藏管理与聊天记录检索 + +### 7.1 我的收藏.png + +我的收藏 + +**状态:新增。** + +- `GET /v1/im/favorites?type=all|text|image|video|voice|file|link&q=&cursor=`,返回 `favorite_id,type,snapshot,source,collected_at,availability`。 +- 类型与关键词由服务端过滤,客户端不下载全部收藏再筛选。`source` 仅下发用户仍可知晓的会话摘要。 +- 收藏数据属于当前租户当前用户;跨租户登录不得合并列表。 + +### 7.2 我的收藏(1).png + +我的收藏(1) + +**状态:新增。** + +- 详情:`GET /v1/im/favorites/{favorite_id}`;删除:`DELETE /v1/im/favorites/{favorite_id}`;转发使用 7.8。 +- 收藏创建时保存受控快照和附件引用,因此原消息对当前用户删除后仍可按保留策略展示;原资源因合规删除时返回 `availability:"removed"`。 +- 删除收藏不删除原消息或已转发消息。 + +### 7.3 我的收藏-文件.png + +我的收藏-文件 + +**状态:新增。** + +- 调用 7.1,固定 `type=file`;项中使用 `snapshot.file{name,size,mime,file_id}` 和 `availability`。 +- 点击前调用收藏详情获得当前授权,下载仍走 7.9 文件协议;失效显示原因,不反复请求公共 URL。 + +### 7.4 我的收藏-文本.png + +我的收藏-文本 + +**状态:新增。** + +- 调用 7.1,固定 `type=text`;服务端返回纯文本/结构化 Drafty 快照和安全高亮区间 `matches[]`。 +- 展开、折叠、复制为本地动作;复制前遵守企业 DLP 策略 `permissions.can_copy`。 + +### 7.5 我的收藏-语音.png + +我的收藏-语音 + +**状态:新增。** + +- 调用 7.1,固定 `type=voice`;播放前获取认证文件,返回 `duration_ms,waveform,availability`。 +- 播放/暂停和单实例播放器是客户端状态;服务端不接收逐秒播放进度,除非后续定义跨端收听状态。 + +### 7.6 我的收藏—链接.png + +我的收藏—链接 + +**状态:新增。** + +- 调用 7.1,固定 `type=link`;服务端快照返回 `url,title,summary,thumbnail_file_id,risk_level`。 +- 点击前 `POST /v1/im/security/urls/check`,Body `{"url":"...","source":"favorite"}`;返回 `allow|warn|block` 和 `risk_challenge_id`。 +- 客户端不得在未确认时自动打开或预加载高风险外链。 + +### 7.7 收藏—文件(多选).png + +收藏—文件(多选) + +**状态:新增。** + +- 选择状态本地维护;批量删除 `POST /v1/im/favorites/batch-delete`,Body `{"favorite_ids":[]}`;批量转发见 7.8。 +- 返回逐项成功/失败;删除必须二次确认并带幂等键。跨页选择只保存 ID,不保留失效的临时下载地址。 + +### 7.8 收藏文件—分享给好友页面.png + +收藏文件—分享给好友页面 + +**状态:新增。** + +- 目标列表使用 4.17;提交 `POST /v1/im/favorites/forward`,Body `{"favorite_ids":[],"target_topics":[],"comment":""}`。 +- 服务端从收藏的受控附件引用生成新消息和新 ACL,返回每个目标的消息结果;收藏原件不变。 +- 文件失效、目标禁言或无发送权限按目标逐项失败,不允许客户端直接复用旧下载 URL 发送。 + +### 7.9 文件-下载.png + +文件-下载 + +**状态:底层下载已实现租户校验;资源 ACL 需改造。** + +- 客户端使用 `GET /v0/file/s/{file_id}`,Header 传 API Key 与 `X-MeetingIM-Auth: token `;禁止 `?auth=&secret=`。 +- 服务端除 Token 租户外,还必须校验该用户通过消息、群文件或收藏拥有有效引用,并支持 `Range`、`ETag`、内容长度和安全文件名。 +- 下载前客户端校验本地空间和网络策略;`FILE_FORBIDDEN` 不自动重试,`FILE_UNAVAILABLE` 显示失效。 + +### 7.10 文件-下载中.png + +文件-下载中 + +**状态:客户端任务管理;服务端需支持 Range。** + +- 下载任务保存 `file_id,etag,total_bytes,downloaded_bytes,temp_path,state`;恢复时携带 `Range` 与 `If-Range: `。 +- ETag 改变或服务端返回完整 200 时丢弃旧分片重新下载;取消时关闭连接并清理临时文件。 +- 下载中断后,如果客户端通过有效 Token 登录取得了新 Token,则使用新 Token 和原 Range 状态续传;Token 已过期或失效时暂停任务并返回登录页。不能把 Token 写入任务文件或日志。 + +### 7.11 文件-下载完成.png + +文件-下载完成 + +**状态:纯客户端完成态。** + +- 完成后校验长度和服务端提供的 checksum,再原子移动到受控目录;校验失败删除并标记重试。 +- 打开/分享遵守系统沙箱和企业 DLP。每次展示页面检查本地文件存在性,不存在则恢复“可下载”。 + +### 7.12 查找聊天记录-搜索关键词选中状态(点击后跳出对应聊天内容).png + +查找聊天记录-搜索关键词选中状态(点击后跳出对应聊天内容) + +**状态:新增服务端搜索。** + +- `GET /v1/im/topics/{topic}/messages/search?q=&type=text&cursor=` 返回 `seq,from,ts,snippet,matches[]`。 +- 点击结果后 `GET /v1/im/topics/{topic}/messages/{seq}/context?before=20&after=20`,返回锚点及上下文;客户端定位 `seq` 并高亮。 +- 服务端只搜索当前用户可见且未硬删除的消息,关键词做长度/频率限制,搜索日志不得记录原文。 + +### 7.13 查找聊天记录-文件.png + +查找聊天记录-文件 + +**状态:新增。** + +- 调用 `GET /v1/im/topics/{topic}/messages/search?type=file&q=&cursor=`,返回消息 `seq` 与文件快照、发送者、时间、`availability`。 +- 预览/下载使用 7.9;撤回或无授权时显示失效。点击来源用 7.12 上下文接口定位。 + +### 7.14 查找聊天记录-链接.png + +查找聊天记录-链接 + +**状态:新增。** + +- 调用搜索接口 `type=link`,返回 `url,title,summary,seq,ts,risk_level`;打开前执行 7.6 URL 安全检查。 +- 链接预览由服务端受控抓取和缓存,客户端不直接抓取内网地址,防止 SSRF 和隐私泄露。 + +### 7.15 查看聊天记录-图片.png + +查看聊天记录-图片 + +**状态:新增。** + +- `GET /v1/im/topics/{topic}/media?type=image&cursor=` 返回 `seq,thumbnail_file_id,file_id,width,height,ts,availability`。 +- 缩略图懒加载使用认证文件请求;大图前再次授权。多选仅保存 `(topic,seq)`,操作使用第 4 章批量接口。 + +### 7.16 查看聊天记录-视频.png + +查看聊天记录-视频 + +**状态:新增。** + +- 调用 7.15 接口,`type=video`,额外返回 `duration_ms,mime,size`。 +- 播放器使用支持 Range 的认证下载/流式读取;文件失效、Token 过期和网络中断分别映射可理解状态,返回列表保持游标与滚动位置。 + +### 7.17 接口示例 + +以下示例用于本章 HTTP 联调快速查阅。除特别说明外: + +- 产品 HTTP 均使用 `https://{host}/{api_path}v1/im`,请求头包含 `X-MeetingIM-APIKey`、`X-IM-Schema: 1`、`X-Request-ID`;登录后接口再带 `Authorization: Bearer `。 +- 写接口必须带 `Idempotency-Key`。二次认证接口按正文说明额外带 `X-Action-Token` 或 `X-Risk-Action-Token`。 +- 成功响应统一包裹为 `{"code":0,"data":示例,"request_id":"...","server_time":"..."}`;下表只展示 `data`。 +- 失败响应统一包裹为 `{"code":30000,"reason":"MALFORMED_REQUEST","message":"...","details":{},"request_id":"...","server_time":"..."}`。 +- 标注“待实现/需修复”的接口是产品目标协议,前端可按示例准备,但联调前以后端发布状态为准。 + +#### 7.17.1 收藏、转发与文件安全 + +| 接口 | 输入示例 | 成功 `data` 示例 | +| --- | --- | --- | +| `GET /v1/im/favorites?type=all&q=&cursor=` | Query only | `{"items":[{"favorite_id":"fav_1","type":"text","snapshot":{"text":"收藏内容"},"source":{"topic":"grp...","seq":12},"collected_at":"2026-08-20T09:00:00Z","availability":"available"}],"next_cursor":""}` | +| `GET /v1/im/favorites?type=all\|text\|image\|video\|voice\|file\|link&q=&cursor=` | Query only | `{"items":[{"favorite_id":"fav_1","type":"image","snapshot":{"thumbnail_file_id":"file_thumb","file_id":"file_img"},"source":{"topic":"grp...","seq":12},"collected_at":"2026-08-20T09:00:00Z","availability":"available"}],"next_cursor":""}` | +| `POST /v1/im/favorites/batch` | `{"messages":[{"topic":"grp...","seq":"12"}]}` | `{"created_count":1,"existing_count":0,"failed":[]}` | +| `GET /v1/im/favorites/{favorite_id}` | Path favorite ID | `{"favorite_id":"fav_1","type":"file","snapshot":{"file_id":"file_1","name":"a.pdf"},"source":{"topic":"grp...","seq":12},"availability":"available"}` | +| `DELETE /v1/im/favorites/{favorite_id}` | Path favorite ID | `{"favorite_id":"fav_1","deleted":true}` | +| `POST /v1/im/favorites/batch-delete` | `{"favorite_ids":["fav_1","fav_2"]}` | `{"items":[{"favorite_id":"fav_1","deleted":true},{"favorite_id":"fav_2","deleted":true}],"failed":[]}` | +| `POST /v1/im/favorites/forward` | `{"favorite_ids":["fav_1"],"target_topics":["grp..."],"comment":"转给你"}` | `{"items":[{"favorite_id":"fav_1","target_topic":"grp...","status":"sent","seq":20}],"failed":[]}` | +| `GET /v1/im/forward-targets?query=&cursor=` | Query only | `{"items":[{"topic":"grp...","type":"group","display_name":"项目群","avatar_file_id":"","can_receive_forward":true},{"topic":"slf","type":"file_assistant","display_name":"文件助手","can_receive_forward":true}],"next_cursor":"","has_more":false}` | +| `POST /v1/im/messages/forward` 逐条转发 | `{"mode":"separate","sources":[{"topic":"grp...","seq":12}],"target_topics":["usr..."],"comment":""}` | `{"items":[{"source":{"topic":"grp...","seq":12},"target_topic":"p2p...","client_mid":"mid_1","seq":30,"status":"sent"}],"failed":[]}` | +| `POST /v1/im/messages/forward` 合并转发 | `{"mode":"bundle","sources":[{"topic":"grp...","seq":12},{"topic":"grp...","seq":13}],"target_topics":["usr..."],"comment":""}` | `{"items":[{"sources":[{"topic":"grp...","seq":12},{"topic":"grp...","seq":13}],"target_topic":"p2p...","client_mid":"mid_1","seq":31,"status":"sent","mode":"bundle","bundle_id":"fbundle_1"}],"failed":[]}` | +| `GET /v1/im/forward-bundles/{bundle_id}?cursor=` 待实现 | Query only | `{"bundle_id":"fbundle_1","title":"聊天记录","item_count":2,"items":[{"from_display":"张三","ts":"2026-08-20T09:00:00Z","summary":"文本摘要"}],"next_cursor":""}` | +| `POST /v1/im/security/urls/check` | `{"url":"https://example.com","source":"favorite"}` | `{"url":"https://example.com","decision":"allow","risk_level":"low","reason":"ok","source":"favorite"}` | + +## 8. 实时语音与视频通话 + +### 8.1 各种通话提示状态.png + +各种通话提示状态 + +**状态:已实现基础通话记录替换;产品文案映射需统一。** + +- 发起呼叫时的持久消息就是通话记录。服务端以相同 `seq` 写 replacement `{data}`,`head.replace=":{seq}"`,并把 `head.webrtc` 更新为 `accepted|finished|disconnected|missed|declined`。 +- 已接通结束时 `head.webrtc-duration` 为服务端计算的毫秒数。客户端映射:`finished=已结束`、`missed=未接听/已取消`、`declined=已拒绝`、`disconnected=异常结束`。 +- 双方使用原消息 `content.callId/callType` 和同一 `seq`;客户端不得另发一条“通话结果”消息或相信本地传入的 duration。 + +### 8.2 对方状态.png + +对方状态 + +**状态:在线邀请已实现;Push 与产品鉴权需改造。** + +- 来电是 Topic 下行的初始 `{data}`:`head.webrtc="started"`、`head.x-im-type="call"`,`content` 至少含 `callId,callType,audio,video`。客户端保存该消息 `seq` 作为后续所有 call note 的关联 ID。 +- 来电端开始响铃后发送 `{note:{topic,what:"call",event:"ringing",seq,payload:{"callId":"..."}}}`;接听发送 `event:"accept"`;拒绝发送 `event:"hang-up"`。 +- 后台 Push 只携带 `callId,topic,seq,caller_display,callType,expires_at`,不含 SDP、ICE 或 Token;用户操作后必须回 WebSocket 鉴权。 + +### 8.3 语音通话-对方待接受状态.png + +语音通话-对方待接受状态 + +**状态:发起与超时已实现基础;好友/拉黑/租户开关校验需改造。** + +- 音视频入口先看 `GET /v1/im/bootstrap` 的 `features.audio_call/video_call` 和 `permissions.audio_call/video_call`;这两个值来源于 Admin 后台现有配置读取链路 `im_client_feature` 分组中的 `audio_call_enabled`、`video_call_enabled`,与声网参数使用同一个配置读取方法,但不放在声网分组。具体到某个会话或用户时,再看 `capabilities.can_call`。任一为 `false` 时前端不展示对应语音/视频按钮。 +- 发起前查询 `capabilities.can_call`,再发布持久邀请消息: + +```json +{"pub":{"id":"call-1","topic":"p2p...","head":{"mime":"application/json","webrtc":"started","x-im-type":"call"},"content":{"callId":"01K...","callType":"audio","audio":true,"video":false}}} +``` + +- `{ctrl code:202,params.seq}` 的 `seq` 是后续 `ringing/accept/offer/answer/ice-candidate/hang-up` 必填关联值。服务端只允许 P2P 双方参与,并校验好友、拉黑、租户通话开关和当前 Topic 无并发呼叫。 +- 发起方取消发送 `{note event:"hang-up",seq,payload:{"callId":"...","reason":"cancelled"}}`;等待超时由服务端结束并把原消息替换为 `missed`。 + +### 8.4 语音通话-双方语音沟通中.png + +语音通话-双方语音沟通中 + +**状态:WebRTC 客户端能力;信令和服务端计时已实现。** + +- 被叫发送 `accept` 后,服务端把原消息替换为 `webrtc:"accepted"` 并向主叫发送 `{info what:"call",event:"accept",seq}`。 +- 双方随后用 `{note what:"call",event:"offer|answer|ice-candidate",seq,payload:{"callId":"...",...}}` 交换 SDP/ICE。ICE 配置使用 `hi.ctrl.params.iceServers`,不得在 App 中硬编码 TURN 长期凭证。 +- 任一端发送 `hang-up`,服务端按接受时间计算 duration 并替换原通话消息。异常断线后按原 `topic+seq+callId` 恢复或结束,不新建第二通呼叫。 + +### 8.5 语音通话-麦克风与扬声器已关.png + +语音通话-麦克风与扬声器已关 + +**状态:纯客户端设备控制。** + +- 麦克风关闭通过禁用本地音轨,扬声器关闭/听筒切换通过系统音频路由完成,不调用业务 HTTP。 +- UI 必须读取实际音轨和路由状态;权限撤销时强制更新。是否告知对方静音可发送临时 `media-state`,不落聊天记录。 + +### 8.6 语音通话-麦克风与扬声器已开.png + +语音通话-麦克风与扬声器已开 + +**状态:纯客户端设备控制。** + +- 开启麦克风恢复本地音轨,开启扬声器选择实际输出设备;蓝牙/耳机变化以操作系统回调为准。 +- 不把设备名称、系统音量上传服务端。切换失败恢复真实按钮状态并给本地提示。 + +### 8.7 视频通话—等待对方接受接受邀请中.png + +视频通话—等待对方接受接受邀请中 + +**状态:基础通话信令已实现;隐私约束需落实。** + +- 发起协议同 8.3,`media_type:"video"`。本地预览只在设备显示,接听前媒体轨默认不发给对方,SDP 只声明能力。 +- 切换摄像头纯客户端;取消发送 `hang-up:cancelled`。相机权限不足时改为音频呼叫或取消,不伪装视频已开启。 + +### 8.8 视频通话—等待对方接受接受邀请-摄像头关闭.png + +视频通话—等待对方接受接受邀请-摄像头关闭 + +**状态:纯客户端状态;媒体状态事件需改造。** + +- 关闭本地视频轨并显示头像;接听后沿用 `video_enabled=false`。 +- 当前服务端只允许 `ringing/accept/offer/answer/ice-candidate/hang-up`;首版应直接通过 WebRTC 轨道 mute 状态呈现。若后续新增 `media-state`,必须先修改 `server/calls.go` 事件白名单、参与者校验和限频,客户端不能提前发送未知事件。 + +### 8.9 双方视频通话中.png + +双方视频通话中 + +**状态:WebRTC 客户端能力;服务端信令已具备基础。** + +- 主/小窗布局、拖动和横竖屏纯客户端;WebRTC offer/answer/ICE 与 8.2~8.4 相同。 +- 服务端只做身份鉴权、信令路由、TURN 配置和通话审计,不接收前端伪造的通话时长。 +- 网络恶化可本地降码率或关闭视频轨;最终结束必须发 `hang-up`。服务端未支持 `media-state` 前不得发送该事件。 + +### 8.10 双方视频通话中-对方关闭摄像头状态.png + +双方视频通话中-对方关闭摄像头状态 + +**状态:现阶段纯客户端;可选媒体状态事件需改造。** + +- 以对端 WebRTC 视频轨的 mute/unmute 作为真实信号;关闭时显示资料缓存中的对方头像并保留音频,轨道恢复后自动显示视频。 +- 不需要业务 HTTP。可选 `media-state` 必须等服务端扩展事件白名单后另行启用,且不持久化、不改变通话结果。 + +### 8.11 视频与语音通话悬浮状态中.png + +视频与语音通话悬浮状态中 + +**状态:纯客户端。** + +- 最小化不结束 WebRTC 或 WebSocket,会话仍以同一 `call_id` 运行;点击恢复全屏不发送新 invite。 +- 系统悬浮窗权限、画中画和安全区域按平台实现。收到 `hang-up`、Token 失效或媒体会话结束后立即关闭悬浮窗并落正确结果态。 + +### 8.12 接口示例 + +以下示例用于本章 HTTP 联调快速查阅。除特别说明外: + +- 产品 HTTP 均使用 `https://{host}/{api_path}v1/im`,请求头包含 `X-MeetingIM-APIKey`、`X-IM-Schema: 1`、`X-Request-ID`;登录后接口再带 `Authorization: Bearer `。 +- 写接口必须带 `Idempotency-Key`。二次认证接口按正文说明额外带 `X-Action-Token` 或 `X-Risk-Action-Token`。 +- 成功响应统一包裹为 `{"code":0,"data":示例,"request_id":"...","server_time":"..."}`;下表只展示 `data`。 +- 失败响应统一包裹为 `{"code":30000,"reason":"MALFORMED_REQUEST","message":"...","details":{},"request_id":"...","server_time":"..."}`。 +- 标注“待实现/需修复”的接口是产品目标协议,前端可按示例准备,但联调前以后端发布状态为准。 + +#### 8.12.1 实时通话与通话记录 + +| 接口 | 输入示例 | 成功输出示例 | +| --- | --- | --- | +| WebSocket 发起通话 | `{"pub":{"id":"call-1","topic":"p2p...","head":{"mime":"application/json","webrtc":"started","x-im-type":"call"},"content":{"callId":"call_1","callType":"audio","audio":true,"video":false}}}` | `{"ctrl":{"id":"call-1","code":202,"params":{"seq":12}}}` | +| WebSocket 通话事件 | `{"note":{"topic":"p2p...","what":"call","event":"accept","seq":12,"payload":{"callId":"call_1"}}}` | 对端收到 `{"info":{"topic":"p2p...","what":"call","event":"accept","seq":12,"payload":{"callId":"call_1"}}}` | +| `POST /v1/im/calls/rtc-join` | `{"topic":"usr_peer 或 p2p...","call_id":"call_1","seq":12,"call_type":"audio","role":"caller"}` | `{"provider":"agora","auth_mode":"token","app_id":"声网 App ID","channel_name":"call_call_1","uid":"usr...","token":"007eJx...","token_expires_at":"2026-09-02T10:00:00Z","call_id":"call_1","topic":"p2p...","seq":12,"call_type":"audio","role":"caller"}` | +| `GET /v1/im/calls?limit=20&cursor=` | Query only | `{"items":[{"call_id":"call_1","topic":"p2p...","seq":12,"call_type":"audio","status":"finished","started_at":"2026-08-20T09:00:00Z","duration_ms":60000,"peer":{"user_id":"usr...","display_name":"张三"}}],"next_cursor":""}` | +| `GET /v1/im/calls/{topic}?cursor=` | Path topic | `{"topic":"p2p...","items":[{"call_id":"call_1","seq":12,"status":"missed","call_type":"video","created_at":"2026-08-20T09:00:00Z"}],"next_cursor":""}` | + +声网模式下,新客户端在发起方收到 `ctrl.params.seq` 后、被叫方点击接听后,分别调用 `POST /v1/im/calls/rtc-join` 获取 `app_id/channel_name/uid/token`。`topic` 可直接传当前会话里看到的 P2P 别名,例如发起方传被叫 `usr...`、被叫方传发起方 `usr...`;后端会归一到真实 `p2p...` 并在响应中返回。当前按正式 `token` 模式接入,后端使用 `im_shengwang` 分组中的 `agora_app_certificate` 生成 RTC Token。客户端不接触 `app_certificate`、`customer_id`、`customer_secret`,也不再通过 IM WebSocket 交换 SDP/ICE,音视频媒体协商和传输由声网 RTC SDK 完成。 + +## 9. 企业工作台与办公协同 + +### 9.1 工作台.png + +工作台 + +**状态:新增。** + +- `GET /v1/im/workbench` 返回 `modules[{code,name,icon_file_id,route,unread_count,enabled,permission}]`、布局版本和企业配置。 +- `GET /v1/im/bootstrap` 返回 `entry_badges.workbench_total_unread`,用于工作台一级入口总红点;当前只汇总“会议通知”模块的未读通知,会议笔记、日报、月报、打卡、部门框架和举报信箱均不产生工作台通知数。 +- 服务端按当前 Token 的租户、组织角色和功能开关裁剪;前端只接受白名单内部 `route`,禁止直接执行服务端任意 URL。 +- 工作台一级总未读以 `GET /v1/im/bootstrap` 和 `GET /v1/im/workbench` 返回为准;当前只有会议通知可接收移动端通知,其他工作台模块在进入页面时主动拉取数据刷新。若后续启用 `workbench.badge.updated`,应按 0.5.4.6 的 `info.what="im"` 业务事件下发。切租户必须重新加载,不复用另一企业工作台。 + +### 9.2 部门组织框架.png + +部门组织框架 + +**状态:新增。** + +- 根节点:`GET /v1/im/organization/departments?parent_id=root&cursor=`;子部门同接口传 `parent_id`;成员 `GET /v1/im/organization/departments/{id}/members?cursor=`。 +- 部门返回 `department_id,name,parent_id,child_count,member_count,version`;成员只返回当前查看人可见字段。 +- 全局搜索:`GET /v1/im/organization/search?q=&types=department,user&cursor=`。服务端执行组织范围授权,离职用户标记不可联系或从结果移除。 +- 组织架构可见范围由服务端按当前登录用户裁剪:上架测试账号可看全部组织;手机号注册账号默认只能看到自己与好友所在组织路径及成员;邀请码注册账号默认只能看到自己所在组织路径及本人。上架测试账号通过租户业务库 `im_org_visibility_overrides` 配置 `visibility_scope=all`,客户端不传可见范围参数。 + +### 9.2.1 打卡考勤 + +**状态:已实现基础版。** + +- 打卡规则、班次、位置/Wi-Fi 约束、补卡窗口、异常判定和统计结果必须以后端为真源;客户端只负责展示、采集设备侧上下文和临时离线队列,不能把正式打卡记录或规则只保存在本地。 +- 配置:`GET /v1/im/attendance/config?date=2026-08-31` 返回当前用户当天适用的规则、班次、允许打卡动作和前端展示文案。服务端按租户、部门、人员、节假日和特殊排班裁剪,前端不得自行推导是否迟到/早退/缺卡。 +- 记录:`GET /v1/im/attendance/records?from=2026-08-01&to=2026-08-31&scope=mine` 返回当前用户打卡记录、异常状态和补卡状态;管理员用 `scope=managed&department_id=&user_id=&cursor=` 查询权限范围内记录。 +- 打卡:`POST /v1/im/attendance/checkins`,Body 带 `date,action,rule_id,shift_id,client_time,location,wifi,device`,写操作必须带 `Idempotency-Key`。服务端校验打卡窗口、地理围栏/Wi-Fi、重复提交和设备风险,成功后返回服务端判定状态。 +- 补卡/异常申诉:`POST /v1/im/attendance/appeals` 创建申请,`GET /v1/im/attendance/appeals?scope=mine|managed&status=&cursor=` 查询列表,审批动作由管理端或后续办公审批接口处理。 +- 主动下行:规则变更、打卡状态变化、补卡审批变化可通过 `info.what="im"`,事件 `attendance.rule.updated/attendance.record.updated/attendance.appeal.updated` 下发;前端收到后刷新配置、记录或列表。 + +### 9.3 会议通知-发起人状态.png + +会议通知-发起人状态 + +**状态:新增。** + +- 创建:`POST /v1/im/meetings`,Body `{"title":"...","start_at":"...","end_at":"...","location":"...","recorder_user_id":"usr_me","attendee_user_ids":[],"reminder_minutes":[]}`;`recorder_user_id` 未传时服务端默认使用当前发起人。 +- 详情:`GET /v1/im/meetings/{meeting_id}` 返回会议、参会人的 `response=pending|accepted|declined|tentative`、权限和 `version`。 +- 更新/取消:`PATCH /v1/im/meetings/{id}`、`POST /v1/im/meetings/{id}/cancel`;提醒未响应者 `POST /v1/im/meetings/{id}/reminders`。所有写操作带幂等键并生成通知和审计。 +- 主动下行:参会人收到 `info.what="im"`,事件为 `meeting.created/updated/canceled/reminded/response.updated`,payload 包含 `meeting_id,version,state`;前端收到后刷新会议列表或会议详情。除操作者外,当前会写入系统通知列表。 + +### 9.4 会议通知-接收人状态.png + +会议通知-接收人状态 + +**状态:新增。** + +- 详情同 9.3;响应:`PUT /v1/im/meetings/{meeting_id}/my-response`,Body `{"response":"accepted|declined|tentative","version":n}`。 +- 返回我的最终响应和会议版本,并通过 `meeting.response.updated` 实时通知参会人;重复提交同状态幂等。 +- 会议过期返回 `MEETING_CLOSED` 并只读展示;客户端本地日历写入是可选系统动作,不替代服务端响应。 + +### 9.5 日报.png + +日报 + +**状态:新增。** + +- `GET /v1/im/reports/daily?scope=mine&from=&to=&status=&cursor=` 返回 `report_id,report_date,status,draft_updated_at,submitted_at,read_state`。 +- 新建草稿 `POST /v1/im/reports/daily`,Body `{"report_date":"2026-08-11"}`;同一用户同一天唯一,重复创建返回原草稿。 +- `permissions.can_create` 和可补交日期由服务端返回,前端不自行判断工作日规则。 + +### 9.6 日报-普通用户页面.png + +日报-普通用户页面 + +**状态:新增。** + +- 编辑:`PUT /v1/im/reports/daily/{id}/draft`,Body `{"content":"...","attachments":[],"version":n}`;提交 `POST /v1/im/reports/daily/{id}/submit`。 +- 服务端返回自动保存版本,客户端用 2~5 秒防抖且串行保存;版本冲突返回 `REPORT_VERSION_CONFLICT` 并给最新版本。 +- 提交后按 `editable_until` 决定是否可修改;附件使用 `purpose=daily_report` 上传并做权限绑定。 + +### 9.7 日报-管理员页面.png + +日报-管理员页面 + +**状态:新增。** + +- `GET /v1/im/reports/daily/statistics?date=&department_id=` 返回 `expected_count,submitted_count,missing_count` 和组织权限范围。 +- 明细:`GET /v1/im/reports/daily?scope=managed&date=&status=submitted|missing&department_id=&cursor=`。 +- 提醒:`POST /v1/im/reports/daily/reminders`,Body `{"date":"...","user_ids":[]}`;服务端过滤无管理权限对象并限流,返回逐项结果。 + +### 9.8 日报-查看详情.png + +日报-查看详情 + +**状态:新增。** + +- `GET /v1/im/reports/daily/{report_id}` 返回填报人、日期、内容、附件、状态、版本、修改时间和 `permissions`。 +- 管理员查看时服务端记录必要的已读状态;附件下载复用第 7 章 ACL。 +- 撤回或修订使用专用状态和版本,客户端必须显示最新版本与 `revised_at`,不能混合旧缓存。 + +### 9.9 月报(1).png + +月报(1) + +**状态:新增。** + +- 创建/取得当月草稿:`POST /v1/im/reports/monthly`,Body `{"month":"2026-08"}`。 +- 保存:`PUT /v1/im/reports/monthly/{id}/draft`,Body `{"work_summary":"...","next_plan":"...","attachments":[],"version":n}`;提交 `POST /v1/im/reports/monthly/{id}/submit`。 +- 字段必填、最大长度、提交截止时间由响应 `validation` 提供;自动保存和冲突处理同 9.6。 + +### 9.10 月报.png + +月报 + +**状态:新增。** + +- `GET /v1/im/reports/monthly?scope=mine|managed&from_month=&to_month=&status=&cursor=` 返回月份、填报人、状态和时间。 +- 管理员统计 `GET /v1/im/reports/monthly/statistics?month=&department_id=`;普通用户响应中不下发团队统计入口权限。 +- 未提交项进入 9.9,已提交项进入 9.11,路由由返回 `action=edit|view` 决定。 + +### 9.11 月报-详情页.png + +月报-详情页 + +**状态:新增。** + +- `GET /v1/im/reports/monthly/{report_id}` 返回 `month,author,work_summary,next_plan,attachments,status,submitted_at,updated_at,version,permissions`。 +- `permissions.can_edit=true` 时进入草稿更新;否则只读。修改已提交月报需专用 `POST /v1/im/reports/monthly/{id}/reopen` 或审核流程,不直接覆盖。 + +### 9.12 总经理信箱.png + +总经理信箱 + +**状态:新增。** + +- 页面配置:`GET /v1/im/executive-mailbox/config` 返回 `enabled,allow_anonymous,required_fields,max_length,attachment_policy,notice`。 +- 提交:`POST /v1/im/executive-mailbox/submissions`,Body `{"subject":"...","content":"...","anonymous":false,"attachment_file_ids":[]}`,带幂等键。 +- 成功仅返回 `submission_id,status:"received",submitted_at`;匿名模式下业务接收方不见身份,但安全审计是否保留内部追踪按企业政策执行并提前告知。 + +### 9.13 总经理信箱-文案提示.png + +总经理信箱-文案提示 + +**状态:新增配置接口。** + +- 文案使用 9.12 配置中的 `notice{title,content,updated_at}`,由租户后台配置;客户端不得硬编码实名/匿名承诺。 +- 提交按钮前必须展示当前版本文案;请求可携带 `notice_version`,服务端发现关键政策已变更时返回 `NOTICE_RECONFIRM_REQUIRED`。 + +### 9.14 会议笔记 + +会议笔记属于企业工作台模块,复用会议主数据和统一文件能力。服务端负责判断会议是否结束、当前用户是否与会议有关、是否为会议记录员,以及文件查看和操作权限;客户端只根据响应中的状态与 `permissions` 展示页面,不自行推导权限。 + +本模块遵循主文档第 0 章的公共约定:产品 HTTP 使用 `X-MeetingIM-APIKey`、`X-IM-Schema: 1`、`X-Request-ID` 和 `Authorization: Bearer `;写操作额外使用 `Idempotency-Key`;成功和失败响应均使用统一包裹结构。 + +会议笔记不产生移动端通知、不写入会议通知未读数,也不增加工作台底部 Tab 红点。客户端进入会议笔记列表或详情页时主动请求最新数据;从上传、预览或下载页面返回后,只刷新当前会议笔记页面。 + +### 9.14.1 会议笔记-工作台入口.png + +会议笔记-工作台入口 + +**状态:工作台接口需改造。** + +- `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 + +会议笔记-列表 + +**状态:新增。** + +- 列表:`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 + +会议笔记-发布页面 + +**状态:现有会议创建接口需改造。** + +- 页面字段顺序为发起人、地点、会议记录员、参会人、开始时间、时长、会议主题。会议记录员默认显示当前会议发起人。 +- 创建仍使用 `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 + +会议笔记-详情页 + +**状态:新增。** + +- 详情:`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 + +会议笔记-详情页(普通成员) + +**状态:新增权限响应。** + +- 普通参会人调用与 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 + +会议笔记-详情页-在线预览 + +**状态:基础文件读取已实现;会议笔记 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 + +会议笔记-详情页-文件下载中 + +**状态:复用统一下载任务;会议笔记 ACL 新增。** + +- 下载使用 `GET /v0/file/s/{file_id}`,Header 传 API Key 与 `X-MeetingIM-Auth: 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 + +会议笔记-文件预览 + +**状态:客户端预览页;服务端复用 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. 切换企业或退出登录后,会议笔记列表、详情、预览文件和下载临时数据按租户及账号隔离清理。 + +### 9.15 接口示例 + +以下示例用于本章 HTTP 联调快速查阅。除特别说明外: + +- 产品 HTTP 均使用 `https://{host}/{api_path}v1/im`,请求头包含 `X-MeetingIM-APIKey`、`X-IM-Schema: 1`、`X-Request-ID`;登录后接口再带 `Authorization: Bearer `。 +- 写接口必须带 `Idempotency-Key`。二次认证接口按正文说明额外带 `X-Action-Token` 或 `X-Risk-Action-Token`。 +- 成功响应统一包裹为 `{"code":0,"data":示例,"request_id":"...","server_time":"..."}`;下表只展示 `data`。 +- 失败响应统一包裹为 `{"code":30000,"reason":"MALFORMED_REQUEST","message":"...","details":{},"request_id":"...","server_time":"..."}`。 +- 标注“待实现/需修复”的接口是产品目标协议,前端可按示例准备,但联调前以后端发布状态为准。 + +#### 9.15.1 工作台、组织、打卡、会议、日报、月报、信箱 + +| 接口 | 输入示例 | 成功 `data` 示例 | +| --- | --- | --- | +| `GET /v1/im/workbench` | Query only | `{"layout_version":2,"modules":[{"code":"attendance","name":"打卡","icon_file_id":"","route":"/attendance","sort_order":10,"unread_count":0,"enabled":true,"permission":"enabled"},{"code":"monthly_reports","name":"月报","icon_file_id":"","route":"/reports/monthly","sort_order":20,"unread_count":0,"enabled":true,"permission":"enabled"},{"code":"daily_reports","name":"日报","icon_file_id":"","route":"/reports/daily","sort_order":30,"unread_count":0,"enabled":true,"permission":"enabled"},{"code":"organization","name":"部门框架","icon_file_id":"","route":"/organization","sort_order":40,"unread_count":0,"enabled":true,"permission":"enabled"},{"code":"executive_mailbox","name":"举报信箱","icon_file_id":"","route":"/executive-mailbox","sort_order":50,"unread_count":0,"enabled":true,"permission":"enabled"},{"code":"meetings","name":"会议通知","icon_file_id":"","route":"/meetings","sort_order":60,"unread_count":3,"enabled":true,"permission":"enabled"},{"code":"meeting_notes","name":"会议笔记","icon_file_id":"","route":"/meeting-notes","sort_order":70,"unread_count":0,"enabled":true,"permission":"enabled"}],"enterprise":{"tenant_code":"default","feature_flags":{"organization":true,"attendance":true,"meetings":true,"meeting_notes":true}}}` | +| `GET /v1/im/organization/departments?parent_id=root&cursor=` | Query only | `{"items":[{"department_id":"dep_1","name":"研发部","parent_id":"root","child_count":0,"member_count":12,"version":1}],"next_cursor":""}` | +| `GET /v1/im/organization/departments/{id}/members?cursor=` | Query only | `{"items":[{"user_id":"usr...","display_name":"张三","avatar_file_id":"","department_id":"dep_1","title":"工程师","can_contact":true}],"next_cursor":""}` | +| `GET /v1/im/organization/members?query=&department_id=&cursor=` 待实现/可由部门成员接口替代 | Query only | `{"items":[{"user_id":"usr...","display_name":"张三","department_id":"dep_1","department_name":"研发部","can_contact":true}],"next_cursor":""}` | +| `GET /v1/im/organization/search?q=&types=department,user&cursor=` | Query only | `{"items":[{"type":"user","user_id":"usr...","display_name":"张三","department_name":"研发部","can_contact":true}],"next_cursor":""}` | +| `GET /v1/im/attendance/config?date=2026-08-31` | Query only | `{"date":"2026-08-31","timezone":"Asia/Shanghai","rule":{"rule_id":"att_rule_default","name":"默认考勤规则","workday":true,"location_required":false,"wifi_required":false,"allow_outside_checkin":true,"appeal_allowed":true},"shifts":[{"shift_id":"shift_default","name":"默认班次","checkin_start":"08:30","work_start":"09:00","work_end":"18:00","checkout_end":"20:00"}],"allowed_actions":["check_in","check_out"],"today_record":{"record_id":"evt_...","user_id":"usr...","date":"2026-08-31","status":"checked_in","checkins":[]},"permissions":{"can_checkin":true,"can_appeal":true,"can_view_managed":true}}` | +| `GET /v1/im/attendance/records?scope=mine&from=2026-08-01&to=2026-08-31&cursor=` | Query only | `{"items":[{"record_id":"evt_...","user_id":"usr_me","date":"2026-08-31","rule_id":"att_rule_default","shift_id":"shift_default","status":"normal","checkins":[{"checkin_id":"attc_...","action":"check_in","client_time":"2026-08-31T00:55:00Z","server_time":"2026-08-31T00:55:02Z","result":"normal","location":{"name":"上海办公室","distance_meters":38},"wifi":{"ssid":"Office-WiFi"},"device":{"platform":"ios"}}],"exceptions":[],"appeal_status":"","version":3}],"next_cursor":"","has_more":false,"statistics":{"expected_count":1,"checked_count":1,"exception_count":0,"not_checked_count":0},"permissions":{"can_checkin":true,"can_view_managed":true,"can_appeal":true}}` | +| `GET /v1/im/attendance/records?scope=managed&department_id=dep_1&date=2026-08-31&status=late&cursor=` | Query only | `{"items":[{"record_id":"evt_...","user_id":"usr...","date":"2026-08-31","status":"late","exceptions":["late"],"appeal_status":"pending","version":2}],"next_cursor":"","has_more":false,"statistics":{"expected_count":20,"checked_count":19,"exception_count":2,"not_checked_count":1}}` | +| `POST /v1/im/attendance/checkins` | Header `Idempotency-Key`;Body `{"date":"2026-08-31","action":"check_in","rule_id":"att_rule_default","shift_id":"shift_default","client_time":"2026-08-31T08:55:00+08:00","location":{"latitude":31.2304,"longitude":121.4737,"accuracy_meters":30},"wifi":{"ssid":"Office-WiFi","bssid":"00:11:22:33:44:55"},"device":{"device_id":"ios-device-1","platform":"ios"}}` | `{"record":{"record_id":"evt_...","user_id":"usr...","date":"2026-08-31","status":"checked_in","version":2,"checkins":[{"checkin_id":"attc_...","action":"check_in","result":"normal"}]},"checkin":{"checkin_id":"attc_...","action":"check_in","client_time":"2026-08-31T00:55:00Z","server_time":"2026-08-31T00:55:02Z","result":"normal","location":{"latitude":31.2304,"longitude":121.4737},"wifi":{"ssid":"Office-WiFi"},"device":{"platform":"ios"}}}` | +| `POST /v1/im/attendance/appeals` | Header `Idempotency-Key`;Body `{"record_id":"evt_...","appeal_type":"missing_checkin","reason":"外出拜访客户,忘记打卡","target_action":"check_in","target_time":"2026-08-31T09:00:00+08:00","attachment_file_ids":["file_1"]}` | `{"appeal_id":"atta_...","record_id":"evt_...","user_id":"usr...","appeal_type":"missing_checkin","reason":"外出拜访客户,忘记打卡","target_action":"check_in","target_time":"2026-08-31T01:00:00Z","attachment_file_ids":["file_1"],"status":"pending","submitted_at":"2026-08-31T03:00:00Z","version":1}` | +| `GET /v1/im/attendance/appeals?scope=mine&status=pending&cursor=` | Query only | `{"items":[{"appeal_id":"atta_...","record_id":"evt_...","user_id":"usr...","appeal_type":"missing_checkin","status":"pending","submitted_at":"2026-08-31T03:00:00Z","version":1}],"next_cursor":"","has_more":false}` | +| `GET /v1/im/meetings?cursor=` 待补列表 | Query only | `{"items":[{"meeting_id":"mtg_1","title":"周会","start_at":"2026-08-21T10:00:00Z","end_at":"2026-08-21T10:30:00Z","my_response":"pending","state":"scheduled"}],"next_cursor":""}` | +| `POST /v1/im/meetings` | `{"title":"周会","start_at":"2026-08-21T10:00:00Z","end_at":"2026-08-21T10:30:00Z","location":"线上","recorder_user_id":"usr_me","attendee_user_ids":["usr..."],"reminder_minutes":[10]}` | `{"meeting_id":"mtg_1","title":"周会","recorder":{"user_id":"usr_me","display_name":"张三"},"start_at":"2026-08-21T10:00:00Z","end_at":"2026-08-21T10:30:00Z","location":"线上","participants":[{"user_id":"usr_me","response":"accepted"},{"user_id":"usr...","response":"pending"}],"permissions":{"can_update":true,"can_cancel":true,"can_remind":true,"can_respond":false},"version":1}` | +| `GET /v1/im/meetings/{meeting_id}` | Path meeting ID | `{"meeting_id":"mtg_1","title":"周会","participants":[{"user_id":"usr...","response":"pending"}],"permissions":{"can_respond":true},"version":1}` | +| `PATCH /v1/im/meetings/{id}` | `{"title":"新标题","version":1}` | `{"meeting_id":"mtg_1","title":"新标题","version":2,"updated_at":"2026-08-20T09:00:00Z"}` | +| `POST /v1/im/meetings/{id}/cancel` | `{"version":2,"reason":"时间冲突"}` | `{"meeting_id":"mtg_1","state":"cancelled","version":3}` | +| `POST /v1/im/meetings/{id}/reminders` | `{}` | `{"meeting_id":"mtg_1","items":[{"user_id":"usr...","status":"sent"}]}` | +| `PUT /v1/im/meetings/{meeting_id}/my-response` | `{"response":"accepted","version":1}` | `{"meeting_id":"mtg_1","my_response":"accepted","version":2,"response_at":"2026-08-20T09:00:00Z"}` | +| `GET /v1/im/reports/daily?scope=mine&from=&to=&status=&cursor=` | Query only | `{"items":[{"report_id":"dr_1","report_date":"2026-08-20","content_short":"今日完成联调和问题回归...","status":"draft","draft_updated_at":"2026-08-20T09:00:00Z","submitted_at":null,"read_state":"unread"}],"next_cursor":"","permissions":{"can_create":true,"makeup_allowed_days":7}}` | +| `GET /v1/im/reports/daily?scope=managed&date=&status=submitted\|missing&department_id=&cursor=` | Query only | `{"items":[{"report_id":"dr_1","author":{"user_id":"usr...","display_name":"张三"},"report_date":"2026-08-20","content_short":"完成日报提交和项目同步...","status":"submitted","submitted_at":"2026-08-20T09:00:00Z","read_state":"unread"}],"next_cursor":"","permissions":{"can_remind_unsubmitted":true,"can_view_managed":true}}` | +| `POST /v1/im/reports/daily` | `{"report_date":"2026-08-20"}` | `{"report_id":"dr_1","report_date":"2026-08-20","content":"","attachments":[],"status":"draft","version":1,"permissions":{"can_edit":true,"can_submit":true,"can_view":true}}` | +| `PUT /v1/im/reports/daily/{id}/draft` | `{"content":"今日完成联调","attachments":[],"version":1}` | `{"report_id":"dr_1","content":"今日完成联调","attachments":[],"status":"draft","version":2,"draft_updated_at":"2026-08-20T09:00:00Z"}` | +| `POST /v1/im/reports/daily/{id}/submit` | `{"version":2}` | `{"report_id":"dr_1","status":"submitted","submitted_at":"2026-08-20T09:00:00Z","version":3}` | +| `GET /v1/im/reports/daily/statistics?date=&department_id=` | Query only | `{"expected_count":10,"submitted_count":8,"missing_count":2,"scope":{"department_id":"dep_1"}}` | +| `POST /v1/im/reports/daily/reminders` | `{"date":"2026-08-20","user_ids":["usr..."]}` | `{"date":"2026-08-20","items":[{"user_id":"usr...","status":"sent"}]}` | +| `GET /v1/im/reports/daily/{report_id}` | Path report ID | `{"report_id":"dr_1","author":{"user_id":"usr...","display_name":"张三"},"report_date":"2026-08-20","content":"日报内容","attachments":[],"status":"submitted","version":3,"permissions":{"can_view":true,"can_edit":false}}` | +| `GET /v1/im/reports/monthly?scope=mine&from_month=&to_month=&status=&cursor=` | Query only | `{"items":[{"report_id":"mr_1","month":"2026-08","author_id":"usr...","work_summary_short":"本月完成客户拜访和合同整理...","next_plan_short":"下月推进回款和方案评审","status":"draft","action":"edit","updated_at":"2026-08-20T09:00:00Z"}],"next_cursor":""}` | +| `GET /v1/im/reports/monthly?scope=mine\|managed&from_month=&to_month=&status=&cursor=` | Query only | `{"items":[{"report_id":"mr_1","month":"2026-08","author_id":"usr...","work_summary_short":"完成项目阶段验收...","next_plan_short":"继续推进上线准备","status":"submitted","action":"view","submitted_at":"2026-08-20T09:00:00Z"}],"next_cursor":""}` | +| `POST /v1/im/reports/monthly` | `{"month":"2026-08"}` | `{"report_id":"mr_1","month":"2026-08","work_summary":"","next_plan":"","attachments":[],"status":"draft","action":"edit","version":1,"validation":{"work_summary_required":true,"next_plan_required":true,"max_length":10000},"permissions":{"can_edit":true,"can_submit":true}}` | +| `PUT /v1/im/reports/monthly/{id}/draft` | `{"work_summary":"本月总结","next_plan":"下月计划","attachments":[],"version":1}` | `{"report_id":"mr_1","work_summary":"本月总结","next_plan":"下月计划","status":"draft","version":2}` | +| `POST /v1/im/reports/monthly/{id}/submit` | `{"version":2}` | `{"report_id":"mr_1","status":"submitted","submitted_at":"2026-08-20T09:00:00Z","version":3}` | +| `GET /v1/im/reports/monthly/statistics?month=&department_id=` | Query only | `{"expected_count":10,"submitted_count":8,"missing_count":2,"scope":{"department_id":"dep_1"}}` | +| `GET /v1/im/reports/monthly/{report_id}` | Path report ID | `{"report_id":"mr_1","month":"2026-08","author":{"user_id":"usr...","display_name":"张三"},"work_summary":"总结","next_plan":"计划","attachments":[],"status":"submitted","version":3,"permissions":{"can_view":true,"can_edit":false}}` | +| `POST /v1/im/reports/monthly/{id}/reopen` 待实现 | `{"version":3,"reason":"补充内容"}` | `{"report_id":"mr_1","status":"draft","version":4}` | +| `GET /v1/im/executive-mailbox/config` | Query only | `{"enabled":true,"allow_anonymous":true,"required_fields":["subject","content"],"max_length":1000,"attachment_policy":{"max_files":5,"purpose":"executive_mailbox"},"notice":{"title":"总经理信箱","content":"请如实反馈","updated_at":"2026-08-20T09:00:00Z"}}` | +| `POST /v1/im/executive-mailbox/submissions` | Header `Idempotency-Key`;Body `{"subject":"建议","content":"建议内容","anonymous":false,"attachment_file_ids":[]}` | `{"submission_id":"mail_1","status":"received","submitted_at":"2026-08-20T09:00:00Z"}` | + +## 10. 个人资料、通用设置、隐私与青少年模式 + +### 10.1 我的.png + +我的 + +**状态:基础用户资料已有;产品菜单新增。** + +- `GET /v1/im/me` 返回公开账号资料、实名/青少年模式摘要和资料版本;`GET /v1/im/bootstrap` 返回个人菜单与权限。 +- 头像、昵称更新事件到达后刷新头部;收藏和设置入口分别进入第 7、10 章。 +- 账号 ID 按字符串展示,客户端不得展示内部数据库 ID 或 tenant_id。 + +### 10.2 个人信息.png + +个人信息 + +**状态:MeetingIM `me` 资料更新已有基础;目标产品接口需改造。** + +- 查询 `GET /v1/im/me/profile` 返回 `avatar_file_id,nickname,birth_date,signature,gender,region,email,public_account,version,field_rules`;`email` 当前仅为资料字段,不用于登录或找回密码。 +- 更新统一使用 `PATCH /v1/im/me/profile`,仅提交变更字段并带 `version`;服务端更新后同步 MeetingIM `me.public` 并推送资料事件。 +- 头像先以 `purpose=user_avatar` 上传再 PATCH;任何字段的可见性由隐私策略裁剪。 + +### 10.3 设置昵称.png + +设置昵称 + +**状态:需改造。** + +- `PATCH /v1/im/me/profile`,Body `{"nickname":"新昵称","version":n}`。 +- 服务端校验 Unicode 长度、全空白、控制字符和敏感内容;返回更新后的 `nickname,version,updated_at`。 +- 成功刷新全局资料和联系人快照,但不得覆盖各群昵称。版本冲突先拉最新资料再由用户确认。 + +### 10.4 出生日期.png + +出生日期 + +**状态:新增产品字段。** + +- 保存同 10.3,Body `{"birth_date":"2000-01-02","version":n}`;仅传 ISO 日期,不传本地化文本或时区时间戳。 +- 服务端校验不晚于当前租户日期及合理范围,返回 `PROFILE_FIELD_INVALID`。展示格式由客户端本地化。 + +### 10.5 个性签名.png + +个性签名 + +**状态:需改造。** + +- 保存同 10.3,Body `{"signature":"...","version":n}`;空字符串表示清空。 +- 服务端按 `field_rules.signature.max_length` 和内容审核处理;失败保留本地草稿,成功刷新引用页面。 + +### 10.6 二维码.png + +二维码 + +**状态:图标纯客户端;二维码协议新增。** + +- 点击入口进入 5.4 并创建短期个人二维码。每次主动刷新调用 `POST /v1/im/me/qrcodes` 获取新 Token。 +- 二维码不能使用静态 `user_id`、手机号或登录 Token;过期、撤销和切租户后必须重新生成。 + +### 10.7 设置(iOS).png + +设置(iOS) + +**状态:菜单纯客户端;数据接口部分新增。** + +- 菜单来自 `bootstrap.settings_menu`,安全、通知、隐私、通用、关于按权限展示。系统通知权限状态由 iOS API 读取,不由服务端伪造。 +- 退出:`POST /v1/im/auth/logout` 撤销当前登录 Token 对应的设备会话,成功或 Token 已失效后关闭 WS、清安全存储和本租户用户缓存。 +- 多设备退出使用 `DELETE /v1/im/security/sessions/{session_id}`,需二次验证。 + +### 10.8 设置(安卓).png + +设置(安卓) + +**状态:菜单纯客户端;业务协议同 10.7。** + +- 使用相同 `bootstrap.settings_menu` 与退出协议;Android 系统权限、通知渠道和应用更新使用平台实现。 +- 服务端只保存应用内通知偏好与设备 Push Token,不把系统设置页面是否打开当作已授权。 + +### 10.9 清空缓存的提示.png + +清空缓存的提示 + +**状态:纯客户端。** + +- 确认后只删除缩略图、可重下文件、临时上传下载和搜索缓存;不调用服务端删除消息、收藏或账号接口。 +- 不删除安全存储中的登录凭证,除非用户选择退出登录。完成后重新计算本地占用并显示结果。 + +### 10.10 版本更新页面.png + +版本更新页面 + +**状态:新增 App 配置接口。** + +- `GET /v1/im/app/releases/latest?platform=ios|android¤t_version=1.0.0&channel=stable`,未登录可在企业解析后调用。 +- 返回 `latest_version,min_supported_version,update_type=none|optional|required,release_notes,store_url,package_url,checksum,published_at`。 +- 客户端按平台只打开受信任商店/签名下载地址;Android 包必须校验签名和 checksum,服务端 URL 使用允许域名。 + +### 10.11 发现新版本.png + +发现新版本 + +**状态:纯客户端弹窗;数据来自 10.10。** + +- `optional` 显示稍后/更新,记录本地已忽略版本并按服务端 `remind_after` 再提示;`required` 不显示绕过入口。 +- 下载和安装状态由平台管理;接口异常时不得误判为强制更新,除非本地已有有效签名的最低版本策略缓存。 + +### 10.12 隐私设置.png + +隐私设置 + +**状态:新增。** + +- `GET /v1/im/me/privacy` 返回 `add_me_methods,profile_visibility,last_seen_visibility,read_receipts,group_invite_verification,version` 和各字段 `editable`;`group_invite_verification=true` 表示别人邀请我入群时需要走入群审批。 +- `PATCH /v1/im/me/privacy` 只提交变更字段和 `version`;成功返回完整最新配置并向其他设备推送 `privacy.updated`。 +- 黑名单入口使用 5.12。服务端在搜索、主页、回执等实际读写路径执行隐私规则,不能只保存开关。 + +### 10.13 隐私设置-添加我的方式选项.png + +隐私设置-添加我的方式选项 + +**状态:新增。** + +- `add_me_methods` 固定字段 `account,phone,qr,group`,每项为布尔值;更新用 10.12 PATCH,如 `{"add_me_methods":{"phone":false},"version":n}`。 +- 关闭后对应搜索/二维码/群来源申请由服务端统一返回 `FRIEND_REQUEST_NOT_ALLOWED` 或 `USER_NOT_DISCOVERABLE`,不泄露具体关闭项。 +- 企业强制策略可令字段 `editable=false`,前端禁用并显示服务端提供的通用说明。 + +### 10.14 青少年模式未开启(安卓).png + +青少年模式未开启(安卓) + +**状态:新增。** + +- `GET /v1/im/settings/youth-mode` 返回 `enabled,policy_version,restrictions,restriction_items,password_set,can_enable`。当前默认策略项为 `night_quiet`、`content_filter`、`payment_guard` 三条;后端现阶段完成状态持久化、开启/关闭校验和策略返回,业务接口层的深夜免打扰、内容过滤、支付守护尚未实际强制执行,`restriction_items[].enforced=false` 用于明确表达这一点。 +- 点击开启先展示 10.15,再按 1.7 完成身份验证和 1.11 设置独立密码;最后调用 `POST /v1/im/settings/youth-mode/enable`,Body `{"policy_version":n,"action_token":"..."}`,其中 `action_token` 使用设置独立密码响应里的 `enable_action_token`。 +- 开启链路中,短信校验返回的 `set_independent_password` token 只用于设置独立密码;设置密码成功响应中的 `enable_action_token` 才用于 `youth-mode/enable`。 +- 服务端持久化到用户租户范围并推送所有设备,不能仅设置本地标志。 + +### 10.15 青少年模式提示(安卓).png + +青少年模式提示(安卓) + +**状态:弹窗纯客户端;文案由服务端配置。** + +- 使用 10.14 返回的 `restriction_items` 与 `policy_version` 渲染;取消不请求,确认进入密码设置/验证。`restrictions` 仅作为兼容旧客户端的 code 数组。 +- 最终 enable 请求携带用户确认的 `policy_version`;若策略更新返回 `POLICY_RECONFIRM_REQUIRED`,重新展示新内容。 + +### 10.16 青少年模式已开启(安卓).png + +青少年模式已开启(安卓) + +**状态:新增。** + +- 页面调用 10.14,`enabled=true` 时展示 `enabled_at,restrictions`。`bootstrap` 同时返回受限模块和动作。 +- 后续某条规则真正接入业务拦截后,受限接口服务端返回 `YOUTH_MODE_RESTRICTED`,并把对应 `restriction_items[].enforced` 改为 `true`;前端隐藏只是体验优化,不能作为安全控制。 +- 登录、App 重启和另一设备均从服务端恢复状态;收到 `youth_mode.updated` 即刷新。 + +### 10.17 关闭青少年模式(安卓).png + +关闭青少年模式(安卓) + +**状态:新增。** + +- 先调用 1.11 的独立密码验证,获得 `action_token`;再 `POST /v1/im/settings/youth-mode/disable`,Header `X-Action-Token`,带幂等键。 +- 成功返回 `enabled:false,updated_at` 并广播;连续密码错误按服务端 `retry_after_seconds` 锁定。 +- 忘记密码走 1.7 身份验证后重置,不能绕过密码直接修改本地状态。 + +### 10.18 获取推送权限(安卓).png + +获取推送权限(安卓) + +**状态:权限引导纯客户端;设备注册需改造。** + +- 用户确认后调用 Android 系统权限;拒绝状态本地记录,最终以系统回调为准。 +- 获取 Push Token 后 `PUT /v1/im/devices/{device_id}/push-token`,Body `{"provider":"fcm","token":"...","app_version":"..."}`;Token 轮换时覆盖。 +- 退出登录删除/解绑该用户设备 Token。Push Token 属敏感凭证,不写日志。 + +### 10.19 提醒(安卓).png + +提醒(安卓) + +**状态:纯客户端。** + +- 仅在功能因系统权限不可用时显示;取消和“前往设置”不调用业务服务端。 +- 回到应用后重新读取真实权限,不能因用户打开过设置页就假定授权成功。 + +### 10.20 相机说明(安卓).png + +相机说明(安卓) + +**状态:纯客户端。** + +- 首次拍照、扫码或视频通话前展示用途说明,确认后再触发系统权限;拒绝不发媒体/扫码请求。 +- 永久拒绝时提供系统设置入口;允许从相册选择等替代能力。客户端不得上传系统权限状态作为服务端授权依据。 + +### 10.21 麦克风说明(安卓).png + +麦克风说明(安卓) + +**状态:纯客户端。** + +- 首次录音或通话前说明并申请系统权限;未授权时不得创建录音文件或发送 call invite。 +- 永久拒绝后进入系统设置引导;恢复授权后重新执行 4.7 或第 8 章动作,不复用过期媒体会话。 + +### 10.22 接口示例 + +以下示例用于本章 HTTP 联调快速查阅。除特别说明外: + +- 产品 HTTP 均使用 `https://{host}/{api_path}v1/im`,请求头包含 `X-MeetingIM-APIKey`、`X-IM-Schema: 1`、`X-Request-ID`;登录后接口再带 `Authorization: Bearer `。 +- 写接口必须带 `Idempotency-Key`。二次认证接口按正文说明额外带 `X-Action-Token` 或 `X-Risk-Action-Token`。 +- 成功响应统一包裹为 `{"code":0,"data":示例,"request_id":"...","server_time":"..."}`;下表只展示 `data`。 +- 失败响应统一包裹为 `{"code":30000,"reason":"MALFORMED_REQUEST","message":"...","details":{},"request_id":"...","server_time":"..."}`。 +- 标注“待实现/需修复”的接口是产品目标协议,前端可按示例准备,但联调前以后端发布状态为准。 + +#### 10.22.1 个人资料、设置、隐私、青少年模式与设备 + +| 接口 | 输入示例 | 成功 `data` 示例 | +| --- | --- | --- | +| `GET /v1/im/me` | Query only | `{"user_id":"usr...","profile":{"public_account":"usr...","nickname":"张三","avatar_file_id":"","signature":"","version":1},"profile_version":1,"realname":{"status":"unverified"},"youth_mode":{"enabled":false}}` | +| `GET /v1/im/me/profile` | Query only | `{"avatar_file_id":"","nickname":"张三","birth_date":"1990-01-01","signature":"签名","gender":"male","region":"上海","email":"a@example.com","public_account":"usr...","version":1,"field_rules":{"nickname":{"min_length":1,"max_length":32},"signature":{"max_length":120}}}` | +| `PATCH /v1/im/me/profile` | `{"nickname":"新昵称","signature":"新签名","version":1}` | `{"avatar_file_id":"","nickname":"新昵称","signature":"新签名","version":2,"updated_at":"2026-08-20T09:00:00Z"}` | +| `GET /v1/im/me/privacy` | Query only | `{"add_me_methods":{"account":true,"phone":true,"qr":true,"group":true},"profile_visibility":"friends","last_seen_visibility":"friends","read_receipts":true,"group_invite_verification":false,"version":1,"editable":{"add_me_methods":true,"profile_visibility":true,"last_seen_visibility":true,"read_receipts":true,"group_invite_verification":true}}` | +| `PATCH /v1/im/me/privacy` | `{"add_me_methods":{"phone":false},"read_receipts":false,"group_invite_verification":true,"version":1}` | `{"add_me_methods":{"account":true,"phone":false,"qr":true,"group":true},"read_receipts":false,"group_invite_verification":true,"version":2,"updated_at":"2026-08-20T09:00:00Z"}` | +| `GET /v1/im/settings/youth-mode` | Query only | `{"enabled":false,"policy_version":1,"restrictions":["night_quiet","content_filter","payment_guard"],"restriction_items":[{"code":"night_quiet","title":"深夜免打扰","description":"夜间减少消息提醒打扰,帮助用户保持健康作息。","enforced":false},{"code":"content_filter","title":"内容过滤","description":"对不适合青少年的内容进行识别和限制展示。","enforced":false},{"code":"payment_guard","title":"支付守护","description":"限制或确认支付、充值等敏感消费行为。","enforced":false}],"password_set":false,"can_enable":false,"updated_at":"2026-08-20T09:00:00Z"}` | +| `POST /v1/im/settings/independent-password` | `{"password":"1234","purpose":"youth_mode","action_token":"action_token"}` | `{"status":"ok","password_set":true,"purpose":"youth_mode","enable_action_token":"enable_token","enable_action_expires_at":"2026-08-20T09:05:00Z","next_action":"enable_youth_mode"}` | +| `POST /v1/im/settings/independent-password/verify` | `{"password":"1234","purpose":"close_youth_mode"}` | `{"action_token":"action_token","action":"close_youth_mode","expires_at":"2026-08-20T09:05:00Z"}` | +| `POST /v1/im/settings/youth-mode/enable` | `{"policy_version":1,"action_token":"enable_action_token"}` | `{"enabled":true,"policy_version":1,"password_set":true,"restrictions":["night_quiet","content_filter","payment_guard"],"restriction_items":[{"code":"night_quiet","title":"深夜免打扰","description":"夜间减少消息提醒打扰,帮助用户保持健康作息。","enforced":false},{"code":"content_filter","title":"内容过滤","description":"对不适合青少年的内容进行识别和限制展示。","enforced":false},{"code":"payment_guard","title":"支付守护","description":"限制或确认支付、充值等敏感消费行为。","enforced":false}],"updated_at":"2026-08-20T09:00:00Z"}` | +| `POST /v1/im/settings/youth-mode/disable` | Header `X-Action-Token: action_token`;Body `{}` | `{"enabled":false,"policy_version":1,"password_set":true,"updated_at":"2026-08-20T09:00:00Z"}` | +| `POST /v1/im/security/challenges` 开启青少年模式 | Header `Idempotency-Key`;Body `{"scene":"set_independent_password","channel":"sms"}` | `{"challenge_id":"sec_chg_1","phone_mask":"+861****000","expires_in_seconds":300,"resend_after_seconds":60}` | +| `POST /v1/im/security/challenges` 关闭青少年模式 | Header `Idempotency-Key`;Body `{"scene":"close_youth_mode","channel":"sms"}` | `{"challenge_id":"sec_chg_2","phone_mask":"+861****000","expires_in_seconds":300,"resend_after_seconds":60}` | +| `POST /v1/im/security/challenges` 限流 | 同上 | HTTP `429`:`{"code":30020,"reason":"VERIFICATION_RATE_LIMITED","message":"verification rate limited","details":{"challenge_id":"sec_chg_1","retry_after_seconds":45}}`;小时限流时 `retry_after_seconds` 可能为数百/数千秒且可不返回 `challenge_id` | +| `POST /v1/im/security/challenges/{id}/verify` 开启青少年模式 | `{"code":"123456"}` | `{"action_token":"set_password_token","action":"set_independent_password","expires_at":"2026-08-20T09:05:00Z"}` | +| `POST /v1/im/security/challenges/{id}/verify` 关闭青少年模式 | `{"code":"123456"}` | `{"action_token":"close_token","action":"close_youth_mode","expires_at":"2026-08-20T09:05:00Z"}` | +| `GET /v1/im/security/realname` | Query only | `{"status":"unverified","can_submit":true}` | +| `POST /v1/im/security/realname/submissions` | `{"name":"张三","id_type":"cn_id","id_number":"110101199001011234"}` | `{"submission_id":"rn_1","status":"pending","can_submit":false}` | +| `POST /v1/im/security/realname/appeals` 待实现 | `{"reason":"证件变更","description":"需要更正实名信息","action_token":"action_token"}` | `{"appeal_id":"rna_1","status":"pending","submitted_at":"2026-08-20T09:00:00Z"}` | +| `PUT /v1/im/devices/{device_id}/push-token` | `{"provider":"jpush","token":"registration_id","platform":"android","app_version":"1.0.0","push_env":"test","sdk_version":"5.0.0","notification_permission":"granted"}` | `{"device_id":"device_1","provider":"jpush","platform":"android","app_version":"1.0.0","updated_at":"2026-08-20T09:00:00Z"}` | +| `DELETE /v1/im/security/sessions/{session_id}` 待实现 | Header `X-Action-Token: action_token` | `{"session_id":"sess_1","revoked":true}` | + +## 11. 反诈安全、举报与意见反馈 + +### 11.1 反诈弹窗(安卓).png + +反诈弹窗(安卓) + +**状态:新增风控决策协议。** + +- 高风险动作在原业务接口返回 `409 RISK_CONFIRMATION_REQUIRED`,`details={risk_challenge_id,level,notice_code,notice_params,expires_at,action}`。 +- 客户端用 `notice_code` 映射经过审核的本地文案,不显示服务端规则、分数或命中特征;取消不重试原操作。 +- 低风险提示也可由服务端可信安全事件触发。当前风险确认/取消会按 0.5.4.6 下发 `security.risk.continue/cancel`;普通消息不得伪装成系统反诈弹窗。 + +### 11.2 反诈弹窗(二次提示安卓).png + +反诈弹窗(二次提示安卓) + +**状态:新增。** + +- 确认:`POST /v1/im/security/risk-challenges/{risk_challenge_id}/confirm`,Body `{"decision":"continue|cancel"}`,带幂等键。 +- `continue` 成功返回单次、短期 `risk_action_token`;客户端在重试原业务接口时以 `X-Risk-Action-Token` 携带,原请求幂等键保持不变。 +- 服务端可返回 `RISK_ACTION_BLOCKED` 直接阻断;确认、取消、过期均写审计,客户端不得无限创建 challenge 绕过。 + +### 11.3 举报.png + +举报 + +**状态:新增。** + +- 原因:`GET /v1/im/reports/abuse/reasons?target_type=user|group|message` 返回 `reason_code,label,requires_description,allows_evidence`。 +- 进入页面携带受控目标:用户 `user_id`、群 `topic` 或消息 `(topic,seq)`;客户端不提交 tenant_id 或被举报者任意文本身份。 +- 服务端先验证举报人能够看到目标,再返回适用原因;举报人身份不下发给被举报方。 + +### 11.4 举报(1).png + +举报(1) + +**状态:新增。** + +- 证据使用 `purpose=abuse_evidence` 上传;提交 `POST /v1/im/reports/abuse`,Body `{"target":{"type":"message","topic":"...","seq":"12"},"reason_code":"fraud","description":"...","evidence_file_ids":[]}`。 +- 服务端从目标消息保存必要、受控的审核快照,校验证据数量/MIME/大小,返回 `report_id,status:"received"`。 +- 重复举报按策略合并并返回原受理号或 `REPORT_RATE_LIMITED`;证据使用独立严格 ACL 和保留期。 + +### 11.5 意见反馈.png + +意见反馈 + +**状态:新增。** + +- 配置:`GET /v1/im/feedback/config` 返回类别、最大正文、附件规则、是否允许联系方式和日志授权说明。 +- 提交:`POST /v1/im/feedback`,Body `{"category":"bug","content":"...","contact":"...","attachment_file_ids":[],"client_context":{"platform":"android","app_version":"1.0.0","os_version":"..."},"include_diagnostics":false}`。 +- 附件用 `purpose=feedback`;只有用户明确同意 `include_diagnostics=true` 才上传经过脱敏的诊断包。成功返回 `feedback_id,status:"received"`,重复提交由幂等键去重。 + + +### 11.6 接口示例 + +以下示例用于本章 HTTP 联调快速查阅。除特别说明外: + +- 产品 HTTP 均使用 `https://{host}/{api_path}v1/im`,请求头包含 `X-MeetingIM-APIKey`、`X-IM-Schema: 1`、`X-Request-ID`;登录后接口再带 `Authorization: Bearer `。 +- 写接口必须带 `Idempotency-Key`。二次认证接口按正文说明额外带 `X-Action-Token` 或 `X-Risk-Action-Token`。 +- 成功响应统一包裹为 `{"code":0,"data":示例,"request_id":"...","server_time":"..."}`;下表只展示 `data`。 +- 失败响应统一包裹为 `{"code":30000,"reason":"MALFORMED_REQUEST","message":"...","details":{},"request_id":"...","server_time":"..."}`。 +- 标注“待实现/需修复”的接口是产品目标协议,前端可按示例准备,但联调前以后端发布状态为准。 + +#### 11.6.1 反诈、举报与意见反馈 + +| 接口 | 输入示例 | 成功 `data` 示例 | +| --- | --- | --- | +| `POST /v1/im/security/risk-challenges/{risk_challenge_id}/confirm` | `{"decision":"continue"}` | `{"risk_action_token":"risk_token","action":"send_message","expires_at":"2026-08-20T09:05:00Z"}` | +| `GET /v1/im/reports/abuse/reasons?target_type=user\|group\|message` 需修复 | Query only | `{"items":[{"reason_code":"fraud","label":"诈骗","requires_description":true,"allows_evidence":true},{"reason_code":"harassment","label":"骚扰","requires_description":false,"allows_evidence":true}]}` | +| `POST /v1/im/reports/abuse` | `{"target":{"type":"message","topic":"grp...","seq":"12"},"reason_code":"fraud","description":"疑似诈骗","evidence_file_ids":["file_1"]}` | `{"report_id":"abr_1","status":"received","submitted_at":"2026-08-20T09:00:00Z"}` | +| `GET /v1/im/feedback/config` | Query only | `{"categories":["bug","suggestion","complaint","other"],"max_length":1000,"allow_contact":true,"attachment_policy":{"max_files":5,"purpose":"feedback"},"diagnostics_notice":"仅在你同意时上传脱敏诊断信息。","updated_at":"2026-08-20T09:00:00Z"}` | +| `POST /v1/im/feedback` | `{"category":"bug","content":"页面异常","contact":"15500000000","attachment_file_ids":[],"client_context":{"platform":"android","app_version":"1.0.0","os_version":"14"},"include_diagnostics":false}` | `{"feedback_id":"fb_1","status":"received","created_at":"2026-08-20T09:00:00Z"}` | + +## 12. 联调实施清单 + +### 12.1 当前可直接联调的基础能力 + +1. WebSocket `/v0/channels`、Wire Version `0.25` 和最低版本 `0.20`。 +2. 第一条 `hi` 携带企业码并固定 Session 租户;响应包含公开租户信息、限制、ICE 配置和呼叫超时。 +3. `basic`/`token` 登录,`sub/get/set/pub/leave/del/note` 基础 MeetingIM 协议。 +4. `pub` 成功返回 `202 + seq`,Topic 消息与基础回执同步。 +5. `/v0/file/u/` 和 `/v0/file/s/{file_id}` 的基础上传下载及租户存储隔离。 + +### 12.2 必须优先完成的服务端阻塞项 + +| 优先级 | 改造项 | 阻塞图片章节 | +| --- | --- | --- | +| P0 | `/v1/im` 统一认证中间件、响应封装、幂等键、租户 Principal | 全部 HTTP 产品能力 | +| P0 | 企业码解析、验证码、密码重置、二次认证、Token `auth_version`、登录换新和撤销 | 1.1~1.11、10.7~10.8 | +| P0 | 普通用户禁止发布系统 Topic;系统通知和业务事件可信生产通道 | 2.5、3.1~3.5、6.4、11.1 | +| P0 | 消息 `x-im-schema/type/client-mid` 校验和服务端幂等 | 2~4 章 | +| P0 | 文件资源级 ACL、Range、ETag、用途绑定和租户存储配额 | 3.6、4.10~4.14、6.27~7.16、9.14、11.4~11.5 | +| P1 | 好友申请、好友关系、黑名单状态机 | 第 3、5 章 | +| P1 | 群成员、角色、禁言、公告、邀请审批和配额 | 第 6 章 | +| P1 | 收藏、转发和聊天记录搜索 | 第 4、7 章 | +| P1 | 通话媒体状态、结果消息、Push 来电 | 第 8 章 | +| P2 | 工作台、组织、会议、会议笔记、日报、月报、信箱 | 第 9 章 | +| P2 | 隐私、青少年模式、反诈、举报、反馈 | 第 10、11 章 | + +### 12.3 前后端联调最低验收用例 + +| 编号 | 场景 | 预期结果 | +| --- | --- | --- | +| T-01 | A 企业 Token 连接 B 企业 Session | 登录失败 `TENANT_MISMATCH`,不产生用户/Topic 数据 | +| T-02 | 企业停用后使用旧 Token 登录和请求 HTTP | WS/HTTP 均拒绝,客户端清除凭证并回企业入口 | +| T-03 | 同 `client_mid` 断线重发同一消息 | 只落一条消息并返回同一 `seq` | +| T-04 | 相同 HTTP 幂等键、相同请求体重复提交 | 返回第一次结果;不同请求体返回 `IDEMPOTENCY_CONFLICT` | +| T-05 | 被拉黑或删除好友后绕过按钮直接 `pub` | 服务端拒绝,对方不收到消息 | +| T-06 | 普通用户构造 `system_event` 或向系统 Topic 发布 | 服务端拒绝,系统消息页不展示可信样式 | +| T-07 | 普通群成员调用管理员、禁言、转让群主接口 | 返回 `FORBIDDEN`,群状态不变且写安全审计 | +| T-08 | 群成员数已达租户配置上限时同意两个并发申请 | 至多一个成功,成员数不超限 | +| T-09 | 非消息接收者持同租户 Token 下载文件 | 返回 `FILE_FORBIDDEN`,不能只通过 file_id 下载 | +| T-10 | 文件下载中断后以有效 Token 登录取得新 Token 并 Range 续传 | 文件完整、checksum 正确、不重复下载已完成分片 | +| T-11 | 风控二次确认后重复重放 risk token | 只有首次有效,其余返回 Token 已消费 | +| T-12 | 切换企业后查看缓存、上传任务、收藏和搜索 | 不出现上一租户数据或任务 | + +### 12.4 统一错误 reason + +首版客户端至少实现:`TENANT_UNAVAILABLE`、`TENANT_MISMATCH`、`AUTH_FAILED`、`TOKEN_EXPIRED`、`ACCOUNT_DISABLED`、`RATE_LIMITED`、`VALIDATION_FAILED`、`IDEMPOTENCY_CONFLICT`、`RESOURCE_NOT_FOUND`、`FORBIDDEN`、`FRIEND_RELATION_REQUIRED`、`MESSAGE_NOT_ALLOWED`、`GROUP_MUTED`、`GROUP_MEMBER_LIMIT_EXCEEDED`、`TENANT_USER_QUOTA_EXCEEDED`、`TENANT_GROUP_QUOTA_EXCEEDED`、`FILE_TOO_LARGE`、`FILE_FORBIDDEN`、`FILE_UNAVAILABLE`、`STORAGE_QUOTA_EXCEEDED`、`REALNAME_REQUIRED`、`YOUTH_MODE_RESTRICTED`、`RISK_CONFIRMATION_REQUIRED`、`RISK_ACTION_BLOCKED`。 + +未知 `reason` 使用通用失败文案并记录 `request_id`,不得把服务端 `message` 原样展示给用户。 + +### 12.5 完成定义 + +每个图片小节进入联调前,服务端须提供 OpenAPI/JSON Schema、错误码、权限规则和可用测试租户;前端须提供请求日志脱敏、断线/重试行为和四态页面(加载、空、成功、失败)。联调完成必须逐图勾选对应协议、至少覆盖成功、权限拒绝、重复提交、断网恢复和跨租户五类场景。 diff --git a/docs/对接文档最新/images/@人员—完成.png b/docs/对接文档最新/images/@人员—完成.png new file mode 100644 index 0000000..ff0cf07 Binary files /dev/null and b/docs/对接文档最新/images/@人员—完成.png differ diff --git a/docs/对接文档最新/images/@人员—选择提醒人.png b/docs/对接文档最新/images/@人员—选择提醒人.png new file mode 100644 index 0000000..e15a9e2 Binary files /dev/null and b/docs/对接文档最新/images/@人员—选择提醒人.png differ diff --git a/docs/对接文档最新/images/@人员状态.png b/docs/对接文档最新/images/@人员状态.png new file mode 100644 index 0000000..d0491d4 Binary files /dev/null and b/docs/对接文档最新/images/@人员状态.png differ diff --git a/docs/对接文档最新/images/上传文件.png b/docs/对接文档最新/images/上传文件.png new file mode 100644 index 0000000..81ccb2d Binary files /dev/null and b/docs/对接文档最新/images/上传文件.png differ diff --git a/docs/对接文档最新/images/个人信息.png b/docs/对接文档最新/images/个人信息.png new file mode 100644 index 0000000..e8a9b96 Binary files /dev/null and b/docs/对接文档最新/images/个人信息.png differ diff --git a/docs/对接文档最新/images/个人对话-发各种文件状态(1).png b/docs/对接文档最新/images/个人对话-发各种文件状态(1).png new file mode 100644 index 0000000..0314844 Binary files /dev/null and b/docs/对接文档最新/images/个人对话-发各种文件状态(1).png differ diff --git a/docs/对接文档最新/images/个人对话-发各种文件状态.png b/docs/对接文档最新/images/个人对话-发各种文件状态.png new file mode 100644 index 0000000..113156d Binary files /dev/null and b/docs/对接文档最新/images/个人对话-发各种文件状态.png differ diff --git a/docs/对接文档最新/images/个人对话-发送名片状态.png b/docs/对接文档最新/images/个人对话-发送名片状态.png new file mode 100644 index 0000000..240a068 Binary files /dev/null and b/docs/对接文档最新/images/个人对话-发送名片状态.png differ diff --git a/docs/对接文档最新/images/个人对话-键盘收起状态.png b/docs/对接文档最新/images/个人对话-键盘收起状态.png new file mode 100644 index 0000000..b97492a Binary files /dev/null and b/docs/对接文档最新/images/个人对话-键盘收起状态.png differ diff --git a/docs/对接文档最新/images/个人对话—删除状态.png b/docs/对接文档最新/images/个人对话—删除状态.png new file mode 100644 index 0000000..ae1a644 Binary files /dev/null and b/docs/对接文档最新/images/个人对话—删除状态.png differ diff --git a/docs/对接文档最新/images/个人对话—拉黑状态/已读状态.png b/docs/对接文档最新/images/个人对话—拉黑状态/已读状态.png new file mode 100644 index 0000000..7305031 Binary files /dev/null and b/docs/对接文档最新/images/个人对话—拉黑状态/已读状态.png differ diff --git a/docs/对接文档最新/images/个人对话框(对方的状态)(1).png b/docs/对接文档最新/images/个人对话框(对方的状态)(1).png new file mode 100644 index 0000000..989c8c0 Binary files /dev/null and b/docs/对接文档最新/images/个人对话框(对方的状态)(1).png differ diff --git a/docs/对接文档最新/images/个人对话框(对方的状态).png b/docs/对接文档最新/images/个人对话框(对方的状态).png new file mode 100644 index 0000000..e63317e Binary files /dev/null and b/docs/对接文档最新/images/个人对话框(对方的状态).png differ diff --git a/docs/对接文档最新/images/个人对话框(我的状态)(1).png b/docs/对接文档最新/images/个人对话框(我的状态)(1).png new file mode 100644 index 0000000..544c09c Binary files /dev/null and b/docs/对接文档最新/images/个人对话框(我的状态)(1).png differ diff --git a/docs/对接文档最新/images/个人对话框(我的状态).png b/docs/对接文档最新/images/个人对话框(我的状态).png new file mode 100644 index 0000000..ece3a36 Binary files /dev/null and b/docs/对接文档最新/images/个人对话框(我的状态).png differ diff --git a/docs/对接文档最新/images/个性签名.png b/docs/对接文档最新/images/个性签名.png new file mode 100644 index 0000000..6001349 Binary files /dev/null and b/docs/对接文档最新/images/个性签名.png differ diff --git a/docs/对接文档最新/images/举报(1).png b/docs/对接文档最新/images/举报(1).png new file mode 100644 index 0000000..6e02403 Binary files /dev/null and b/docs/对接文档最新/images/举报(1).png differ diff --git a/docs/对接文档最新/images/举报.png b/docs/对接文档最新/images/举报.png new file mode 100644 index 0000000..8a81202 Binary files /dev/null and b/docs/对接文档最新/images/举报.png differ diff --git a/docs/对接文档最新/images/二维码.png b/docs/对接文档最新/images/二维码.png new file mode 100644 index 0000000..5575573 Binary files /dev/null and b/docs/对接文档最新/images/二维码.png differ diff --git a/docs/对接文档最新/images/企业注册-填写注册信息.png b/docs/对接文档最新/images/企业注册-填写注册信息.png new file mode 100644 index 0000000..e55e512 Binary files /dev/null and b/docs/对接文档最新/images/企业注册-填写注册信息.png differ diff --git a/docs/对接文档最新/images/企业注册-提交成功提示.png b/docs/对接文档最新/images/企业注册-提交成功提示.png new file mode 100644 index 0000000..cd00a14 Binary files /dev/null and b/docs/对接文档最新/images/企业注册-提交成功提示.png differ diff --git a/docs/对接文档最新/images/企业注册-登录入口.png b/docs/对接文档最新/images/企业注册-登录入口.png new file mode 100644 index 0000000..a96f4f5 Binary files /dev/null and b/docs/对接文档最新/images/企业注册-登录入口.png differ diff --git a/docs/对接文档最新/images/企业注册-问题反馈.png b/docs/对接文档最新/images/企业注册-问题反馈.png new file mode 100644 index 0000000..5acfcc4 Binary files /dev/null and b/docs/对接文档最新/images/企业注册-问题反馈.png differ diff --git a/docs/对接文档最新/images/企业码登录.png b/docs/对接文档最新/images/企业码登录.png new file mode 100644 index 0000000..ff61c9c Binary files /dev/null and b/docs/对接文档最新/images/企业码登录.png differ diff --git a/docs/对接文档最新/images/会议笔记-列表.png b/docs/对接文档最新/images/会议笔记-列表.png new file mode 100644 index 0000000..b9f0cbc Binary files /dev/null and b/docs/对接文档最新/images/会议笔记-列表.png differ diff --git a/docs/对接文档最新/images/会议笔记-发布页面.png b/docs/对接文档最新/images/会议笔记-发布页面.png new file mode 100644 index 0000000..000b647 Binary files /dev/null and b/docs/对接文档最新/images/会议笔记-发布页面.png differ diff --git a/docs/对接文档最新/images/会议笔记-工作台入口.png b/docs/对接文档最新/images/会议笔记-工作台入口.png new file mode 100644 index 0000000..9682483 Binary files /dev/null and b/docs/对接文档最新/images/会议笔记-工作台入口.png differ diff --git a/docs/对接文档最新/images/会议笔记-文件预览.png b/docs/对接文档最新/images/会议笔记-文件预览.png new file mode 100644 index 0000000..53d7827 Binary files /dev/null and b/docs/对接文档最新/images/会议笔记-文件预览.png differ diff --git a/docs/对接文档最新/images/会议笔记-详情页-在线预览.png b/docs/对接文档最新/images/会议笔记-详情页-在线预览.png new file mode 100644 index 0000000..54a8243 Binary files /dev/null and b/docs/对接文档最新/images/会议笔记-详情页-在线预览.png differ diff --git a/docs/对接文档最新/images/会议笔记-详情页-文件下载中.png b/docs/对接文档最新/images/会议笔记-详情页-文件下载中.png new file mode 100644 index 0000000..eaf64b1 Binary files /dev/null and b/docs/对接文档最新/images/会议笔记-详情页-文件下载中.png differ diff --git a/docs/对接文档最新/images/会议笔记-详情页.png b/docs/对接文档最新/images/会议笔记-详情页.png new file mode 100644 index 0000000..8537fc6 Binary files /dev/null and b/docs/对接文档最新/images/会议笔记-详情页.png differ diff --git a/docs/对接文档最新/images/会议笔记-详情页(普通成员).png b/docs/对接文档最新/images/会议笔记-详情页(普通成员).png new file mode 100644 index 0000000..2704121 Binary files /dev/null and b/docs/对接文档最新/images/会议笔记-详情页(普通成员).png differ diff --git a/docs/对接文档最新/images/会议通知-发起人状态.png b/docs/对接文档最新/images/会议通知-发起人状态.png new file mode 100644 index 0000000..5ca94a2 Binary files /dev/null and b/docs/对接文档最新/images/会议通知-发起人状态.png differ diff --git a/docs/对接文档最新/images/会议通知-接收人状态.png b/docs/对接文档最新/images/会议通知-接收人状态.png new file mode 100644 index 0000000..9ba0166 Binary files /dev/null and b/docs/对接文档最新/images/会议通知-接收人状态.png differ diff --git a/docs/对接文档最新/images/关闭青少年模式(安卓).png b/docs/对接文档最新/images/关闭青少年模式(安卓).png new file mode 100644 index 0000000..f2d1f1d Binary files /dev/null and b/docs/对接文档最新/images/关闭青少年模式(安卓).png differ diff --git a/docs/对接文档最新/images/内容沟通对话3-图片和语音发送中.png b/docs/对接文档最新/images/内容沟通对话3-图片和语音发送中.png new file mode 100644 index 0000000..443258f Binary files /dev/null and b/docs/对接文档最新/images/内容沟通对话3-图片和语音发送中.png differ diff --git a/docs/对接文档最新/images/内容沟通对话3-开启进群验证与拉人状态.png b/docs/对接文档最新/images/内容沟通对话3-开启进群验证与拉人状态.png new file mode 100644 index 0000000..dc99738 Binary files /dev/null and b/docs/对接文档最新/images/内容沟通对话3-开启进群验证与拉人状态.png differ diff --git a/docs/对接文档最新/images/内容沟通对话3-语音录音中.png b/docs/对接文档最新/images/内容沟通对话3-语音录音中.png new file mode 100644 index 0000000..45a25c8 Binary files /dev/null and b/docs/对接文档最新/images/内容沟通对话3-语音录音中.png differ diff --git a/docs/对接文档最新/images/内容沟通对话3-语音/视频/图片状态.png b/docs/对接文档最新/images/内容沟通对话3-语音/视频/图片状态.png new file mode 100644 index 0000000..36c9544 Binary files /dev/null and b/docs/对接文档最新/images/内容沟通对话3-语音/视频/图片状态.png differ diff --git a/docs/对接文档最新/images/出生日期.png b/docs/对接文档最新/images/出生日期.png new file mode 100644 index 0000000..23f27a2 Binary files /dev/null and b/docs/对接文档最新/images/出生日期.png differ diff --git a/docs/对接文档最新/images/删除好友提示.png b/docs/对接文档最新/images/删除好友提示.png new file mode 100644 index 0000000..dfe594a Binary files /dev/null and b/docs/对接文档最新/images/删除好友提示.png differ diff --git a/docs/对接文档最新/images/删除管理员-设置成功.png b/docs/对接文档最新/images/删除管理员-设置成功.png new file mode 100644 index 0000000..3510b5a Binary files /dev/null and b/docs/对接文档最新/images/删除管理员-设置成功.png differ diff --git a/docs/对接文档最新/images/加入群聊.png b/docs/对接文档最新/images/加入群聊.png new file mode 100644 index 0000000..b2023cf Binary files /dev/null and b/docs/对接文档最新/images/加入群聊.png differ diff --git a/docs/对接文档最新/images/双方沟通状态.png b/docs/对接文档最新/images/双方沟通状态.png new file mode 100644 index 0000000..cc6726a Binary files /dev/null and b/docs/对接文档最新/images/双方沟通状态.png differ diff --git a/docs/对接文档最新/images/双方视频通话中-对方关闭摄像头状态.png b/docs/对接文档最新/images/双方视频通话中-对方关闭摄像头状态.png new file mode 100644 index 0000000..22af72a Binary files /dev/null and b/docs/对接文档最新/images/双方视频通话中-对方关闭摄像头状态.png differ diff --git a/docs/对接文档最新/images/双方视频通话中.png b/docs/对接文档最新/images/双方视频通话中.png new file mode 100644 index 0000000..b782809 Binary files /dev/null and b/docs/对接文档最新/images/双方视频通话中.png differ diff --git a/docs/对接文档最新/images/反诈弹窗(二次提示安卓).png b/docs/对接文档最新/images/反诈弹窗(二次提示安卓).png new file mode 100644 index 0000000..08b698e Binary files /dev/null and b/docs/对接文档最新/images/反诈弹窗(二次提示安卓).png differ diff --git a/docs/对接文档最新/images/反诈弹窗(安卓).png b/docs/对接文档最新/images/反诈弹窗(安卓).png new file mode 100644 index 0000000..151b3ef Binary files /dev/null and b/docs/对接文档最新/images/反诈弹窗(安卓).png differ diff --git a/docs/对接文档最新/images/发现新版本.png b/docs/对接文档最新/images/发现新版本.png new file mode 100644 index 0000000..8cb8280 Binary files /dev/null and b/docs/对接文档最新/images/发现新版本.png differ diff --git a/docs/对接文档最新/images/各种通话提示状态.png b/docs/对接文档最新/images/各种通话提示状态.png new file mode 100644 index 0000000..3dace39 Binary files /dev/null and b/docs/对接文档最新/images/各种通话提示状态.png differ diff --git a/docs/对接文档最新/images/多选-选中对话内容-收藏成功.png b/docs/对接文档最新/images/多选-选中对话内容-收藏成功.png new file mode 100644 index 0000000..36bbd79 Binary files /dev/null and b/docs/对接文档最新/images/多选-选中对话内容-收藏成功.png differ diff --git a/docs/对接文档最新/images/多选-选中对话框.png b/docs/对接文档最新/images/多选-选中对话框.png new file mode 100644 index 0000000..955431f Binary files /dev/null and b/docs/对接文档最新/images/多选-选中对话框.png differ diff --git a/docs/对接文档最新/images/好友—聊天设置—拉黑状态.png b/docs/对接文档最新/images/好友—聊天设置—拉黑状态.png new file mode 100644 index 0000000..6253363 Binary files /dev/null and b/docs/对接文档最新/images/好友—聊天设置—拉黑状态.png differ diff --git a/docs/对接文档最新/images/好友—聊天设置—未拉黑状态.png b/docs/对接文档最新/images/好友—聊天设置—未拉黑状态.png new file mode 100644 index 0000000..26255a7 Binary files /dev/null and b/docs/对接文档最新/images/好友—聊天设置—未拉黑状态.png differ diff --git a/docs/对接文档最新/images/好友个人主页.png b/docs/对接文档最新/images/好友个人主页.png new file mode 100644 index 0000000..c0e4a75 Binary files /dev/null and b/docs/对接文档最新/images/好友个人主页.png differ diff --git a/docs/对接文档最新/images/实名认证-已实名.png b/docs/对接文档最新/images/实名认证-已实名.png new file mode 100644 index 0000000..2b4a170 Binary files /dev/null and b/docs/对接文档最新/images/实名认证-已实名.png differ diff --git a/docs/对接文档最新/images/实名认证.png b/docs/对接文档最新/images/实名认证.png new file mode 100644 index 0000000..61a06bd Binary files /dev/null and b/docs/对接文档最新/images/实名认证.png differ diff --git a/docs/对接文档最新/images/实名认证(安卓).png b/docs/对接文档最新/images/实名认证(安卓).png new file mode 100644 index 0000000..b60db3c Binary files /dev/null and b/docs/对接文档最新/images/实名认证(安卓).png differ diff --git a/docs/对接文档最新/images/对方状态.png b/docs/对接文档最新/images/对方状态.png new file mode 100644 index 0000000..de2713e Binary files /dev/null and b/docs/对接文档最新/images/对方状态.png differ diff --git a/docs/对接文档最新/images/工作台.png b/docs/对接文档最新/images/工作台.png new file mode 100644 index 0000000..117abed Binary files /dev/null and b/docs/对接文档最新/images/工作台.png differ diff --git a/docs/对接文档最新/images/开启沟通页面.png b/docs/对接文档最新/images/开启沟通页面.png new file mode 100644 index 0000000..67e928e Binary files /dev/null and b/docs/对接文档最新/images/开启沟通页面.png differ diff --git a/docs/对接文档最新/images/忘记密码.png b/docs/对接文档最新/images/忘记密码.png new file mode 100644 index 0000000..9723c0e Binary files /dev/null and b/docs/对接文档最新/images/忘记密码.png differ diff --git a/docs/对接文档最新/images/总经理信箱-文案提示.png b/docs/对接文档最新/images/总经理信箱-文案提示.png new file mode 100644 index 0000000..edd2039 Binary files /dev/null and b/docs/对接文档最新/images/总经理信箱-文案提示.png differ diff --git a/docs/对接文档最新/images/总经理信箱.png b/docs/对接文档最新/images/总经理信箱.png new file mode 100644 index 0000000..b5e1cb2 Binary files /dev/null and b/docs/对接文档最新/images/总经理信箱.png differ diff --git a/docs/对接文档最新/images/意见反馈.png b/docs/对接文档最新/images/意见反馈.png new file mode 100644 index 0000000..93c9516 Binary files /dev/null and b/docs/对接文档最新/images/意见反馈.png differ diff --git a/docs/对接文档最新/images/我的.png b/docs/对接文档最新/images/我的.png new file mode 100644 index 0000000..028458d Binary files /dev/null and b/docs/对接文档最新/images/我的.png differ diff --git a/docs/对接文档最新/images/我的收藏(1).png b/docs/对接文档最新/images/我的收藏(1).png new file mode 100644 index 0000000..2a2f224 Binary files /dev/null and b/docs/对接文档最新/images/我的收藏(1).png differ diff --git a/docs/对接文档最新/images/我的收藏-文件.png b/docs/对接文档最新/images/我的收藏-文件.png new file mode 100644 index 0000000..c37ef78 Binary files /dev/null and b/docs/对接文档最新/images/我的收藏-文件.png differ diff --git a/docs/对接文档最新/images/我的收藏-文本.png b/docs/对接文档最新/images/我的收藏-文本.png new file mode 100644 index 0000000..a56dd94 Binary files /dev/null and b/docs/对接文档最新/images/我的收藏-文本.png differ diff --git a/docs/对接文档最新/images/我的收藏-语音.png b/docs/对接文档最新/images/我的收藏-语音.png new file mode 100644 index 0000000..03d4da7 Binary files /dev/null and b/docs/对接文档最新/images/我的收藏-语音.png differ diff --git a/docs/对接文档最新/images/我的收藏.png b/docs/对接文档最新/images/我的收藏.png new file mode 100644 index 0000000..065e26a Binary files /dev/null and b/docs/对接文档最新/images/我的收藏.png differ diff --git a/docs/对接文档最新/images/我的收藏—链接.png b/docs/对接文档最新/images/我的收藏—链接.png new file mode 100644 index 0000000..534d26a Binary files /dev/null and b/docs/对接文档最新/images/我的收藏—链接.png differ diff --git a/docs/对接文档最新/images/提醒(安卓).png b/docs/对接文档最新/images/提醒(安卓).png new file mode 100644 index 0000000..d6211ba Binary files /dev/null and b/docs/对接文档最新/images/提醒(安卓).png differ diff --git a/docs/对接文档最新/images/收藏—文件(多选).png b/docs/对接文档最新/images/收藏—文件(多选).png new file mode 100644 index 0000000..0e3578a Binary files /dev/null and b/docs/对接文档最新/images/收藏—文件(多选).png differ diff --git a/docs/对接文档最新/images/收藏文件—分享给好友页面.png b/docs/对接文档最新/images/收藏文件—分享给好友页面.png new file mode 100644 index 0000000..c509808 Binary files /dev/null and b/docs/对接文档最新/images/收藏文件—分享给好友页面.png differ diff --git a/docs/对接文档最新/images/文件-下载.png b/docs/对接文档最新/images/文件-下载.png new file mode 100644 index 0000000..d89fdc5 Binary files /dev/null and b/docs/对接文档最新/images/文件-下载.png differ diff --git a/docs/对接文档最新/images/文件-下载中.png b/docs/对接文档最新/images/文件-下载中.png new file mode 100644 index 0000000..80331da Binary files /dev/null and b/docs/对接文档最新/images/文件-下载中.png differ diff --git a/docs/对接文档最新/images/文件-下载完成.png b/docs/对接文档最新/images/文件-下载完成.png new file mode 100644 index 0000000..ded18bc Binary files /dev/null and b/docs/对接文档最新/images/文件-下载完成.png differ diff --git a/docs/对接文档最新/images/文件发送状态.png b/docs/对接文档最新/images/文件发送状态.png new file mode 100644 index 0000000..20f083b Binary files /dev/null and b/docs/对接文档最新/images/文件发送状态.png differ diff --git a/docs/对接文档最新/images/新朋友.png b/docs/对接文档最新/images/新朋友.png new file mode 100644 index 0000000..0e6a27c Binary files /dev/null and b/docs/对接文档最新/images/新朋友.png differ diff --git a/docs/对接文档最新/images/新邀请进群申请.png b/docs/对接文档最新/images/新邀请进群申请.png new file mode 100644 index 0000000..ccee33e Binary files /dev/null and b/docs/对接文档最新/images/新邀请进群申请.png differ diff --git a/docs/对接文档最新/images/日报-普通用户页面.png b/docs/对接文档最新/images/日报-普通用户页面.png new file mode 100644 index 0000000..9f44fa6 Binary files /dev/null and b/docs/对接文档最新/images/日报-普通用户页面.png differ diff --git a/docs/对接文档最新/images/日报-查看详情.png b/docs/对接文档最新/images/日报-查看详情.png new file mode 100644 index 0000000..ac9fb29 Binary files /dev/null and b/docs/对接文档最新/images/日报-查看详情.png differ diff --git a/docs/对接文档最新/images/日报-管理员页面.png b/docs/对接文档最新/images/日报-管理员页面.png new file mode 100644 index 0000000..dcd573e Binary files /dev/null and b/docs/对接文档最新/images/日报-管理员页面.png differ diff --git a/docs/对接文档最新/images/日报.png b/docs/对接文档最新/images/日报.png new file mode 100644 index 0000000..682dc57 Binary files /dev/null and b/docs/对接文档最新/images/日报.png differ diff --git a/docs/对接文档最新/images/月报(1).png b/docs/对接文档最新/images/月报(1).png new file mode 100644 index 0000000..354b821 Binary files /dev/null and b/docs/对接文档最新/images/月报(1).png differ diff --git a/docs/对接文档最新/images/月报-详情页.png b/docs/对接文档最新/images/月报-详情页.png new file mode 100644 index 0000000..8f90197 Binary files /dev/null and b/docs/对接文档最新/images/月报-详情页.png differ diff --git a/docs/对接文档最新/images/月报.png b/docs/对接文档最新/images/月报.png new file mode 100644 index 0000000..8a97e3e Binary files /dev/null and b/docs/对接文档最新/images/月报.png differ diff --git a/docs/对接文档最新/images/查找聊天记录-搜索关键词选中状态(点击后跳出对应聊天内容).png b/docs/对接文档最新/images/查找聊天记录-搜索关键词选中状态(点击后跳出对应聊天内容).png new file mode 100644 index 0000000..eb8042d Binary files /dev/null and b/docs/对接文档最新/images/查找聊天记录-搜索关键词选中状态(点击后跳出对应聊天内容).png differ diff --git a/docs/对接文档最新/images/查找聊天记录-文件.png b/docs/对接文档最新/images/查找聊天记录-文件.png new file mode 100644 index 0000000..e1f3d61 Binary files /dev/null and b/docs/对接文档最新/images/查找聊天记录-文件.png differ diff --git a/docs/对接文档最新/images/查找聊天记录-链接.png b/docs/对接文档最新/images/查找聊天记录-链接.png new file mode 100644 index 0000000..715c78a Binary files /dev/null and b/docs/对接文档最新/images/查找聊天记录-链接.png differ diff --git a/docs/对接文档最新/images/查看聊天记录-图片.png b/docs/对接文档最新/images/查看聊天记录-图片.png new file mode 100644 index 0000000..0b5ca2e Binary files /dev/null and b/docs/对接文档最新/images/查看聊天记录-图片.png differ diff --git a/docs/对接文档最新/images/查看聊天记录-视频.png b/docs/对接文档最新/images/查看聊天记录-视频.png new file mode 100644 index 0000000..55c4762 Binary files /dev/null and b/docs/对接文档最新/images/查看聊天记录-视频.png differ diff --git a/docs/对接文档最新/images/注册页面-倒计时.png b/docs/对接文档最新/images/注册页面-倒计时.png new file mode 100644 index 0000000..d622985 Binary files /dev/null and b/docs/对接文档最新/images/注册页面-倒计时.png differ diff --git a/docs/对接文档最新/images/注册页面.png b/docs/对接文档最新/images/注册页面.png new file mode 100644 index 0000000..ee93d31 Binary files /dev/null and b/docs/对接文档最新/images/注册页面.png differ diff --git a/docs/对接文档最新/images/注册页面2.png b/docs/对接文档最新/images/注册页面2.png new file mode 100644 index 0000000..025a6a0 Binary files /dev/null and b/docs/对接文档最新/images/注册页面2.png differ diff --git a/docs/对接文档最新/images/消息-无网络状态.png b/docs/对接文档最新/images/消息-无网络状态.png new file mode 100644 index 0000000..d03c948 Binary files /dev/null and b/docs/对接文档最新/images/消息-无网络状态.png differ diff --git a/docs/对接文档最新/images/消息-更多.png b/docs/对接文档最新/images/消息-更多.png new file mode 100644 index 0000000..db5e6b4 Binary files /dev/null and b/docs/对接文档最新/images/消息-更多.png differ diff --git a/docs/对接文档最新/images/消息.png b/docs/对接文档最新/images/消息.png new file mode 100644 index 0000000..24675b2 Binary files /dev/null and b/docs/对接文档最新/images/消息.png differ diff --git a/docs/对接文档最新/images/消息页面—长按弹出.png b/docs/对接文档最新/images/消息页面—长按弹出.png new file mode 100644 index 0000000..84e3e72 Binary files /dev/null and b/docs/对接文档最新/images/消息页面—长按弹出.png differ diff --git a/docs/对接文档最新/images/添加好友(1).png b/docs/对接文档最新/images/添加好友(1).png new file mode 100644 index 0000000..ce60f2b Binary files /dev/null and b/docs/对接文档最新/images/添加好友(1).png differ diff --git a/docs/对接文档最新/images/添加好友-输入精准用户ID.png b/docs/对接文档最新/images/添加好友-输入精准用户ID.png new file mode 100644 index 0000000..e367d8a Binary files /dev/null and b/docs/对接文档最新/images/添加好友-输入精准用户ID.png differ diff --git a/docs/对接文档最新/images/添加好友.png b/docs/对接文档最新/images/添加好友.png new file mode 100644 index 0000000..be2c4bd Binary files /dev/null and b/docs/对接文档最新/images/添加好友.png differ diff --git a/docs/对接文档最新/images/添加好友验证.png b/docs/对接文档最新/images/添加好友验证.png new file mode 100644 index 0000000..ca22198 Binary files /dev/null and b/docs/对接文档最新/images/添加好友验证.png differ diff --git a/docs/对接文档最新/images/添加禁言成员.png b/docs/对接文档最新/images/添加禁言成员.png new file mode 100644 index 0000000..bcab63f Binary files /dev/null and b/docs/对接文档最新/images/添加禁言成员.png differ diff --git a/docs/对接文档最新/images/添加管理员-设置成功(1).png b/docs/对接文档最新/images/添加管理员-设置成功(1).png new file mode 100644 index 0000000..effc341 Binary files /dev/null and b/docs/对接文档最新/images/添加管理员-设置成功(1).png differ diff --git a/docs/对接文档最新/images/添加管理员-设置成功.png b/docs/对接文档最新/images/添加管理员-设置成功.png new file mode 100644 index 0000000..4efc2d9 Binary files /dev/null and b/docs/对接文档最新/images/添加管理员-设置成功.png differ diff --git a/docs/对接文档最新/images/添加管理员.png b/docs/对接文档最新/images/添加管理员.png new file mode 100644 index 0000000..69614b8 Binary files /dev/null and b/docs/对接文档最新/images/添加管理员.png differ diff --git a/docs/对接文档最新/images/清空缓存的提示.png b/docs/对接文档最新/images/清空缓存的提示.png new file mode 100644 index 0000000..a38f82d Binary files /dev/null and b/docs/对接文档最新/images/清空缓存的提示.png differ diff --git a/docs/对接文档最新/images/版本更新页面.png b/docs/对接文档最新/images/版本更新页面.png new file mode 100644 index 0000000..e3402cb Binary files /dev/null and b/docs/对接文档最新/images/版本更新页面.png differ diff --git a/docs/对接文档最新/images/登录页面-密码错误.png b/docs/对接文档最新/images/登录页面-密码错误.png new file mode 100644 index 0000000..61e14c7 Binary files /dev/null and b/docs/对接文档最新/images/登录页面-密码错误.png differ diff --git a/docs/对接文档最新/images/登录页面.png b/docs/对接文档最新/images/登录页面.png new file mode 100644 index 0000000..c0e0a1a Binary files /dev/null and b/docs/对接文档最新/images/登录页面.png differ diff --git a/docs/对接文档最新/images/相机说明(安卓).png b/docs/对接文档最新/images/相机说明(安卓).png new file mode 100644 index 0000000..cae8942 Binary files /dev/null and b/docs/对接文档最新/images/相机说明(安卓).png differ diff --git a/docs/对接文档最新/images/禁言的群成员.png b/docs/对接文档最新/images/禁言的群成员.png new file mode 100644 index 0000000..21415ba Binary files /dev/null and b/docs/对接文档最新/images/禁言的群成员.png differ diff --git a/docs/对接文档最新/images/空白页.png b/docs/对接文档最新/images/空白页.png new file mode 100644 index 0000000..f466fd7 Binary files /dev/null and b/docs/对接文档最新/images/空白页.png differ diff --git a/docs/对接文档最新/images/系统消息.png b/docs/对接文档最新/images/系统消息.png new file mode 100644 index 0000000..7ad46f4 Binary files /dev/null and b/docs/对接文档最新/images/系统消息.png differ diff --git a/docs/对接文档最新/images/群二维码页面.png b/docs/对接文档最新/images/群二维码页面.png new file mode 100644 index 0000000..24cc5d2 Binary files /dev/null and b/docs/对接文档最新/images/群二维码页面.png differ diff --git a/docs/对接文档最新/images/群公告状态.png b/docs/对接文档最新/images/群公告状态.png new file mode 100644 index 0000000..4fae17a Binary files /dev/null and b/docs/对接文档最新/images/群公告状态.png differ diff --git a/docs/对接文档最新/images/群公告状态/邀请进群申请同时存在.png b/docs/对接文档最新/images/群公告状态/邀请进群申请同时存在.png new file mode 100644 index 0000000..5aaea9c Binary files /dev/null and b/docs/对接文档最新/images/群公告状态/邀请进群申请同时存在.png differ diff --git a/docs/对接文档最新/images/群公告(对方查看状态).png b/docs/对接文档最新/images/群公告(对方查看状态).png new file mode 100644 index 0000000..78d1dff Binary files /dev/null and b/docs/对接文档最新/images/群公告(对方查看状态).png differ diff --git a/docs/对接文档最新/images/群文件-上传状态.png b/docs/对接文档最新/images/群文件-上传状态.png new file mode 100644 index 0000000..ff9ce39 Binary files /dev/null and b/docs/对接文档最新/images/群文件-上传状态.png differ diff --git a/docs/对接文档最新/images/群文件.png b/docs/对接文档最新/images/群文件.png new file mode 100644 index 0000000..2cef984 Binary files /dev/null and b/docs/对接文档最新/images/群文件.png differ diff --git a/docs/对接文档最新/images/群标识.png b/docs/对接文档最新/images/群标识.png new file mode 100644 index 0000000..472a5a4 Binary files /dev/null and b/docs/对接文档最新/images/群标识.png differ diff --git a/docs/对接文档最新/images/群简介.png b/docs/对接文档最新/images/群简介.png new file mode 100644 index 0000000..89a5871 Binary files /dev/null and b/docs/对接文档最新/images/群简介.png differ diff --git a/docs/对接文档最新/images/群组管理.png b/docs/对接文档最新/images/群组管理.png new file mode 100644 index 0000000..9c1246e Binary files /dev/null and b/docs/对接文档最新/images/群组管理.png differ diff --git a/docs/对接文档最新/images/群聊.png b/docs/对接文档最新/images/群聊.png new file mode 100644 index 0000000..cf2c9ad Binary files /dev/null and b/docs/对接文档最新/images/群聊.png differ diff --git a/docs/对接文档最新/images/群聊—非好友个人主页.png b/docs/对接文档最新/images/群聊—非好友个人主页.png new file mode 100644 index 0000000..345c7a1 Binary files /dev/null and b/docs/对接文档最新/images/群聊—非好友个人主页.png differ diff --git a/docs/对接文档最新/images/群聊设置.png b/docs/对接文档最新/images/群聊设置.png new file mode 100644 index 0000000..d4fa914 Binary files /dev/null and b/docs/对接文档最新/images/群聊设置.png differ diff --git a/docs/对接文档最新/images/群资料.png b/docs/对接文档最新/images/群资料.png new file mode 100644 index 0000000..9409bd6 Binary files /dev/null and b/docs/对接文档最新/images/群资料.png differ diff --git a/docs/对接文档最新/images/获取推送权限(安卓).png b/docs/对接文档最新/images/获取推送权限(安卓).png new file mode 100644 index 0000000..c342b5d Binary files /dev/null and b/docs/对接文档最新/images/获取推送权限(安卓).png differ diff --git a/docs/对接文档最新/images/表情包发送页面.png b/docs/对接文档最新/images/表情包发送页面.png new file mode 100644 index 0000000..c0206e8 Binary files /dev/null and b/docs/对接文档最新/images/表情包发送页面.png differ diff --git a/docs/对接文档最新/images/表情包展开(发送按钮默认状态).png b/docs/对接文档最新/images/表情包展开(发送按钮默认状态).png new file mode 100644 index 0000000..d77f5dc Binary files /dev/null and b/docs/对接文档最新/images/表情包展开(发送按钮默认状态).png differ diff --git a/docs/对接文档最新/images/视频与语音通话悬浮状态中.png b/docs/对接文档最新/images/视频与语音通话悬浮状态中.png new file mode 100644 index 0000000..d15ab1d Binary files /dev/null and b/docs/对接文档最新/images/视频与语音通话悬浮状态中.png differ diff --git a/docs/对接文档最新/images/视频通话—等待对方接受接受邀请-摄像头关闭.png b/docs/对接文档最新/images/视频通话—等待对方接受接受邀请-摄像头关闭.png new file mode 100644 index 0000000..1018148 Binary files /dev/null and b/docs/对接文档最新/images/视频通话—等待对方接受接受邀请-摄像头关闭.png differ diff --git a/docs/对接文档最新/images/视频通话—等待对方接受接受邀请中.png b/docs/对接文档最新/images/视频通话—等待对方接受接受邀请中.png new file mode 100644 index 0000000..42f307f Binary files /dev/null and b/docs/对接文档最新/images/视频通话—等待对方接受接受邀请中.png differ diff --git a/docs/对接文档最新/images/设置昵称(1).png b/docs/对接文档最新/images/设置昵称(1).png new file mode 100644 index 0000000..2729912 Binary files /dev/null and b/docs/对接文档最新/images/设置昵称(1).png differ diff --git a/docs/对接文档最新/images/设置昵称.png b/docs/对接文档最新/images/设置昵称.png new file mode 100644 index 0000000..0e2322d Binary files /dev/null and b/docs/对接文档最新/images/设置昵称.png differ diff --git a/docs/对接文档最新/images/设置独立密码(安卓).png b/docs/对接文档最新/images/设置独立密码(安卓).png new file mode 100644 index 0000000..1e6c668 Binary files /dev/null and b/docs/对接文档最新/images/设置独立密码(安卓).png differ diff --git a/docs/对接文档最新/images/设置群头像.png b/docs/对接文档最新/images/设置群头像.png new file mode 100644 index 0000000..49137b1 Binary files /dev/null and b/docs/对接文档最新/images/设置群头像.png differ diff --git a/docs/对接文档最新/images/设置群管理员.png b/docs/对接文档最新/images/设置群管理员.png new file mode 100644 index 0000000..2f3aae5 Binary files /dev/null and b/docs/对接文档最新/images/设置群管理员.png differ diff --git a/docs/对接文档最新/images/设置(iOS).png b/docs/对接文档最新/images/设置(iOS).png new file mode 100644 index 0000000..f8d7536 Binary files /dev/null and b/docs/对接文档最新/images/设置(iOS).png differ diff --git a/docs/对接文档最新/images/设置(安卓).png b/docs/对接文档最新/images/设置(安卓).png new file mode 100644 index 0000000..3aff453 Binary files /dev/null and b/docs/对接文档最新/images/设置(安卓).png differ diff --git a/docs/对接文档最新/images/语音—按住说话.png b/docs/对接文档最新/images/语音—按住说话.png new file mode 100644 index 0000000..1e7304e Binary files /dev/null and b/docs/对接文档最新/images/语音—按住说话.png differ diff --git a/docs/对接文档最新/images/语音图片发送状态.png b/docs/对接文档最新/images/语音图片发送状态.png new file mode 100644 index 0000000..193b4c7 Binary files /dev/null and b/docs/对接文档最新/images/语音图片发送状态.png differ diff --git a/docs/对接文档最新/images/语音按住说话.png b/docs/对接文档最新/images/语音按住说话.png new file mode 100644 index 0000000..3715035 Binary files /dev/null and b/docs/对接文档最新/images/语音按住说话.png differ diff --git a/docs/对接文档最新/images/语音通话-双方语音沟通中.png b/docs/对接文档最新/images/语音通话-双方语音沟通中.png new file mode 100644 index 0000000..b8b061d Binary files /dev/null and b/docs/对接文档最新/images/语音通话-双方语音沟通中.png differ diff --git a/docs/对接文档最新/images/语音通话-对方待接受状态.png b/docs/对接文档最新/images/语音通话-对方待接受状态.png new file mode 100644 index 0000000..b638fdf Binary files /dev/null and b/docs/对接文档最新/images/语音通话-对方待接受状态.png differ diff --git a/docs/对接文档最新/images/语音通话-麦克风与扬声器已关.png b/docs/对接文档最新/images/语音通话-麦克风与扬声器已关.png new file mode 100644 index 0000000..fcc5fc7 Binary files /dev/null and b/docs/对接文档最新/images/语音通话-麦克风与扬声器已关.png differ diff --git a/docs/对接文档最新/images/语音通话-麦克风与扬声器已开.png b/docs/对接文档最新/images/语音通话-麦克风与扬声器已开.png new file mode 100644 index 0000000..a8efae5 Binary files /dev/null and b/docs/对接文档最新/images/语音通话-麦克风与扬声器已开.png differ diff --git a/docs/对接文档最新/images/身份验证(安卓).png b/docs/对接文档最新/images/身份验证(安卓).png new file mode 100644 index 0000000..01b2216 Binary files /dev/null and b/docs/对接文档最新/images/身份验证(安卓).png differ diff --git a/docs/对接文档最新/images/转发-单选.png b/docs/对接文档最新/images/转发-单选.png new file mode 100644 index 0000000..f2bc973 Binary files /dev/null and b/docs/对接文档最新/images/转发-单选.png differ diff --git a/docs/对接文档最新/images/转发-多选.png b/docs/对接文档最新/images/转发-多选.png new file mode 100644 index 0000000..271692b Binary files /dev/null and b/docs/对接文档最新/images/转发-多选.png differ diff --git a/docs/对接文档最新/images/转发—合并转发—多选状态.png b/docs/对接文档最新/images/转发—合并转发—多选状态.png new file mode 100644 index 0000000..1832fa7 Binary files /dev/null and b/docs/对接文档最新/images/转发—合并转发—多选状态.png differ diff --git a/docs/对接文档最新/images/转发—逐条转发.png b/docs/对接文档最新/images/转发—逐条转发.png new file mode 100644 index 0000000..0d125f0 Binary files /dev/null and b/docs/对接文档最新/images/转发—逐条转发.png differ diff --git a/docs/对接文档最新/images/选择新群主.png b/docs/对接文档最新/images/选择新群主.png new file mode 100644 index 0000000..8e096fc Binary files /dev/null and b/docs/对接文档最新/images/选择新群主.png differ diff --git a/docs/对接文档最新/images/选择新群主提示.png b/docs/对接文档最新/images/选择新群主提示.png new file mode 100644 index 0000000..53303c6 Binary files /dev/null and b/docs/对接文档最新/images/选择新群主提示.png differ diff --git a/docs/对接文档最新/images/通讯录.png b/docs/对接文档最新/images/通讯录.png new file mode 100644 index 0000000..bdbf5ca Binary files /dev/null and b/docs/对接文档最新/images/通讯录.png differ diff --git a/docs/对接文档最新/images/邀请进群申请.png b/docs/对接文档最新/images/邀请进群申请.png new file mode 100644 index 0000000..05812d0 Binary files /dev/null and b/docs/对接文档最新/images/邀请进群申请.png differ diff --git a/docs/对接文档最新/images/部门组织框架.png b/docs/对接文档最新/images/部门组织框架.png new file mode 100644 index 0000000..94f08e3 Binary files /dev/null and b/docs/对接文档最新/images/部门组织框架.png differ diff --git a/docs/对接文档最新/images/键盘展开.png b/docs/对接文档最新/images/键盘展开.png new file mode 100644 index 0000000..a1173fb Binary files /dev/null and b/docs/对接文档最新/images/键盘展开.png differ diff --git a/docs/对接文档最新/images/隐私设置-添加我的方式选项.png b/docs/对接文档最新/images/隐私设置-添加我的方式选项.png new file mode 100644 index 0000000..b2dbfe4 Binary files /dev/null and b/docs/对接文档最新/images/隐私设置-添加我的方式选项.png differ diff --git a/docs/对接文档最新/images/隐私设置.png b/docs/对接文档最新/images/隐私设置.png new file mode 100644 index 0000000..d97a7e1 Binary files /dev/null and b/docs/对接文档最新/images/隐私设置.png differ diff --git a/docs/对接文档最新/images/青少年模式已开启(安卓).png b/docs/对接文档最新/images/青少年模式已开启(安卓).png new file mode 100644 index 0000000..e4b2c09 Binary files /dev/null and b/docs/对接文档最新/images/青少年模式已开启(安卓).png differ diff --git a/docs/对接文档最新/images/青少年模式提示(安卓).png b/docs/对接文档最新/images/青少年模式提示(安卓).png new file mode 100644 index 0000000..913652d Binary files /dev/null and b/docs/对接文档最新/images/青少年模式提示(安卓).png differ diff --git a/docs/对接文档最新/images/青少年模式未开启(安卓).png b/docs/对接文档最新/images/青少年模式未开启(安卓).png new file mode 100644 index 0000000..c12c878 Binary files /dev/null and b/docs/对接文档最新/images/青少年模式未开启(安卓).png differ diff --git a/docs/对接文档最新/images/麦克风说明(安卓).png b/docs/对接文档最新/images/麦克风说明(安卓).png new file mode 100644 index 0000000..162d588 Binary files /dev/null and b/docs/对接文档最新/images/麦克风说明(安卓).png differ diff --git a/docs/对接文档最新/images/黑名单.png b/docs/对接文档最新/images/黑名单.png new file mode 100644 index 0000000..d452875 Binary files /dev/null and b/docs/对接文档最新/images/黑名单.png differ diff --git a/docs/对接文档最新/images/黑名单提示(1).png b/docs/对接文档最新/images/黑名单提示(1).png new file mode 100644 index 0000000..865510f Binary files /dev/null and b/docs/对接文档最新/images/黑名单提示(1).png differ diff --git a/docs/对接文档最新/images/黑名单提示.png b/docs/对接文档最新/images/黑名单提示.png new file mode 100644 index 0000000..0d0d184 Binary files /dev/null and b/docs/对接文档最新/images/黑名单提示.png differ diff --git a/scripts/IM-租户业务库初始化.sql b/scripts/IM-租户业务库初始化.sql new file mode 100644 index 0000000..fceb70d --- /dev/null +++ b/scripts/IM-租户业务库初始化.sql @@ -0,0 +1,1619 @@ +-- IM tenant business database initialization script. +-- Source baseline: local MySQL database `tinode` on 2026-09-13. +-- Purpose: create a clean business database for a new IM tenant. +-- +-- Usage: +-- 1. Replace placeholders before execution: +-- __TENANT_DB__ -> new tenant business database, for example `tinode_acme`. +-- __TENANT_ID__ -> hotgo.im_tenant.id assigned to this tenant. +-- __TENANT_CODE__ -> public enterprise code, for example `acme`. +-- __TENANT_NAME__ -> tenant display name. +-- 2. Execute this SQL with a MySQL account that can CREATE DATABASE and write hotgo.im_tenant. +-- 3. Keep business tables empty; do not copy users, topics, messages, files, Kafka logs, or audit data from `tinode`. +-- +-- Notes: +-- - This script intentionally seeds only stable metadata/default configuration. +-- - It does not copy default tenant's test users, groups, messages, login logs, or outbox rows. +-- - The IM Server must enable tenant_db_routing=true and use hotgo as tenant_registry_db when registry and tenant DB are separated. + +SET NAMES utf8mb4; +SET FOREIGN_KEY_CHECKS = 0; + +CREATE DATABASE IF NOT EXISTS meeting_im DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci; +USE meeting_im; + +-- ============================================================ +-- 1. Tenant business schema. +-- ============================================================ +CREATE TABLE IF NOT EXISTS `im_abuse_reasons` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `target_type` varchar(16) NOT NULL, + `reason_code` varchar(64) NOT NULL, + `label` varchar(128) NOT NULL, + `requires_description` tinyint NOT NULL DEFAULT '0', + `allows_evidence` tinyint NOT NULL DEFAULT '1', + `sort_order` int NOT NULL DEFAULT '0', + `enabled` tinyint NOT NULL DEFAULT '1', + `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 KEY `uk_im_abuse_reasons_type_code` (`target_type`,`reason_code`), + KEY `idx_im_abuse_reasons_type_order` (`target_type`,`enabled`,`sort_order`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS `im_abuse_reports` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `report_id` varchar(64) NOT NULL, + `reporter_id` bigint NOT NULL, + `target_type` varchar(16) NOT NULL, + `target_user_id` bigint NOT NULL DEFAULT '0', + `target_topic` varchar(32) NOT NULL DEFAULT '', + `target_seq` bigint NOT NULL DEFAULT '0', + `reason_code` varchar(64) NOT NULL, + `description` text NOT NULL, + `evidence_file_ids` json DEFAULT NULL, + `snapshot` json DEFAULT NULL, + `idempotency_key` varchar(128) NOT NULL, + `status` varchar(32) NOT NULL DEFAULT 'received', + `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 KEY `uk_im_abuse_reports_report` (`report_id`), + UNIQUE KEY `uk_im_abuse_reports_idempotency` (`reporter_id`,`idempotency_key`), + KEY `idx_im_abuse_reports_target` (`target_type`,`target_topic`,`target_seq`,`target_user_id`), + KEY `idx_im_abuse_reports_reporter` (`reporter_id`,`id` DESC) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS `im_action_tokens` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `user_id` bigint NOT NULL, + `token_id` varchar(64) NOT NULL, + `action` varchar(64) NOT NULL, + `target` varchar(128) NOT NULL DEFAULT '', + `token_hash` char(64) NOT NULL, + `state` smallint NOT NULL, + `expires_at` datetime(3) NOT NULL, + `consumed_at` datetime(3) DEFAULT NULL, + `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 KEY `uk_im_action_tokens_token` (`token_id`), + KEY `idx_im_action_tokens_user_action` (`user_id`,`action`,`target`,`state`,`expires_at`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS `im_app_releases` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `platform` varchar(32) NOT NULL, + `channel` varchar(32) NOT NULL DEFAULT 'stable', + `latest_version` varchar(64) NOT NULL, + `min_supported_version` varchar(64) NOT NULL, + `update_type` varchar(16) NOT NULL DEFAULT 'none', + `release_notes` text NOT NULL, + `store_url` varchar(512) NOT NULL DEFAULT '', + `package_url` varchar(512) NOT NULL DEFAULT '', + `checksum` varchar(128) NOT NULL DEFAULT '', + `remind_after_seconds` int NOT NULL DEFAULT '0', + `published_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(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 KEY `uk_im_app_releases_platform_channel` (`platform`,`channel`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS `im_attendance_appeals` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `appeal_id` varchar(64) NOT NULL, + `record_id` varchar(64) NOT NULL, + `user_id` bigint NOT NULL, + `appeal_type` varchar(64) NOT NULL, + `reason` varchar(512) NOT NULL, + `target_action` varchar(32) NOT NULL DEFAULT '', + `target_time` datetime(3) DEFAULT NULL, + `attachment_file_ids` json DEFAULT NULL, + `status` varchar(32) NOT NULL DEFAULT 'pending', + `version` int NOT NULL DEFAULT '1', + `idempotency_key` varchar(128) NOT NULL, + `submitted_at` datetime(3) NOT NULL, + `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 KEY `uk_im_attendance_appeals_appeal` (`appeal_id`), + UNIQUE KEY `uk_im_attendance_appeals_idempotency` (`user_id`,`idempotency_key`), + KEY `idx_im_attendance_appeals_record` (`record_id`), + KEY `idx_im_attendance_appeals_user_status` (`user_id`,`status`,`id` DESC) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS `im_attendance_checkins` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `checkin_id` varchar(64) NOT NULL, + `record_id` varchar(64) NOT NULL, + `user_id` bigint NOT NULL, + `record_date` date NOT NULL, + `action` varchar(32) NOT NULL, + `rule_id` varchar(64) NOT NULL, + `shift_id` varchar(64) NOT NULL, + `client_time` datetime(3) NOT NULL, + `server_time` datetime(3) NOT NULL, + `result` varchar(32) NOT NULL, + `location` json DEFAULT NULL, + `wifi` json DEFAULT NULL, + `device` json DEFAULT NULL, + `idempotency_key` varchar(128) NOT NULL, + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (`id`), + UNIQUE KEY `uk_im_attendance_checkins_checkin` (`checkin_id`), + UNIQUE KEY `uk_im_attendance_checkins_idempotency` (`user_id`,`idempotency_key`), + KEY `idx_im_attendance_checkins_record` (`record_id`,`id`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS `im_attendance_records` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `record_id` varchar(64) NOT NULL, + `user_id` bigint NOT NULL, + `record_date` date NOT NULL, + `rule_id` varchar(64) NOT NULL, + `shift_id` varchar(64) NOT NULL, + `status` varchar(32) NOT NULL DEFAULT 'not_checked', + `exceptions` json DEFAULT NULL, + `appeal_status` varchar(32) NOT NULL DEFAULT '', + `version` int NOT NULL DEFAULT '1', + `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 KEY `uk_im_attendance_records_record` (`record_id`), + UNIQUE KEY `uk_im_attendance_records_user_date_shift` (`user_id`,`record_date`,`shift_id`), + KEY `idx_im_attendance_records_user_id` (`user_id`,`id` DESC), + KEY `idx_im_attendance_records_date_status` (`record_date`,`status`,`id` DESC) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS `im_attendance_rules` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `rule_id` varchar(64) NOT NULL, + `name` varchar(128) NOT NULL, + `workday` tinyint NOT NULL DEFAULT '1', + `location_required` tinyint NOT NULL DEFAULT '0', + `wifi_required` tinyint NOT NULL DEFAULT '0', + `allow_outside_checkin` tinyint NOT NULL DEFAULT '1', + `appeal_allowed` tinyint NOT NULL DEFAULT '1', + `effective_from` date DEFAULT NULL, + `effective_to` date DEFAULT NULL, + `state` varchar(16) NOT NULL DEFAULT 'active', + `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 KEY `uk_im_attendance_rules_rule` (`rule_id`), + KEY `idx_im_attendance_rules_state` (`state`,`effective_from`,`effective_to`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS `im_attendance_shifts` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `shift_id` varchar(64) NOT NULL, + `rule_id` varchar(64) NOT NULL, + `name` varchar(128) NOT NULL, + `work_start` time NOT NULL, + `work_end` time NOT NULL, + `checkin_start` time NOT NULL, + `checkout_end` time NOT NULL, + `state` varchar(16) NOT NULL DEFAULT 'active', + `sort_order` int NOT NULL DEFAULT '0', + `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 KEY `uk_im_attendance_shifts_shift` (`shift_id`), + KEY `idx_im_attendance_shifts_rule` (`rule_id`,`state`,`sort_order`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS `im_audit_logs` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `actor_uid` bigint NOT NULL DEFAULT '0', + `actor_role` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '', + `action` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL, + `target_type` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '', + `target_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '', + `before_snapshot` json DEFAULT NULL, + `after_snapshot` json DEFAULT NULL, + `reason` varchar(512) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '', + `request_id` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '', + `ip` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '', + `user_agent` varchar(512) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '', + `metadata` json DEFAULT NULL, + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (`id`), + KEY `idx_im_audit_logs_actor` (`actor_uid`,`id` DESC), + KEY `idx_im_audit_logs_action` (`action`,`id` DESC), + KEY `idx_im_audit_logs_target` (`target_type`,`target_id`,`id` DESC), + KEY `idx_im_audit_logs_request` (`request_id`), + KEY `idx_im_audit_logs_created` (`created_at`,`id` DESC) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='IM租户审计日志'; + +CREATE TABLE IF NOT EXISTS `im_auth` ( + `id` int NOT NULL AUTO_INCREMENT, + `userid` bigint NOT NULL, + `authlvl` int NOT NULL, + `secret` varchar(255) NOT NULL, + `expires` datetime DEFAULT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `auth_userid` (`userid`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS `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 DEFAULT NULL, + `status` varchar(16) NOT NULL DEFAULT 'pending', + `attempts` int NOT NULL DEFAULT '0', + `next_retry_at` datetime(3) DEFAULT NULL, + `delivered_at` datetime(3) DEFAULT NULL, + `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 KEY `uk_im_business_events_outbox_event` (`event_id`), + KEY `idx_im_business_events_outbox_pending` (`status`,`next_retry_at`,`id`), + KEY `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 `im_call_records` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `call_id` varchar(64) NOT NULL, + `topic` char(25) NOT NULL, + `seq_id` bigint NOT NULL, + `caller_id` bigint NOT NULL, + `callee_id` bigint NOT NULL, + `call_type` varchar(16) NOT NULL, + `status` varchar(32) NOT NULL, + `end_reason` varchar(64) NOT NULL DEFAULT '', + `duration_ms` bigint NOT NULL DEFAULT '0', + `started_at` datetime(3) NOT NULL, + `accepted_at` datetime(3) DEFAULT NULL, + `finished_at` datetime(3) DEFAULT NULL, + `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 KEY `uk_im_call_records_call` (`call_id`), + UNIQUE KEY `uk_im_call_records_topic_seq` (`topic`,`seq_id`), + KEY `idx_im_call_records_caller` (`caller_id`,`id` DESC), + KEY `idx_im_call_records_callee` (`callee_id`,`id` DESC), + KEY `idx_im_call_records_status` (`status`,`updated_at`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS `im_conversation_settings` ( + `id` bigint NOT NULL, + `user_id` bigint NOT NULL, + `topic` varchar(128) NOT NULL, + `pinned` tinyint NOT NULL DEFAULT '0', + `muted` tinyint NOT NULL DEFAULT '0', + `remark` varchar(128) NOT NULL DEFAULT '', + `message_retention` varchar(32) NOT NULL DEFAULT 'default', + `version` int NOT NULL DEFAULT '1', + `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 KEY `uk_im_conversation_settings_user_topic` (`user_id`,`topic`), + KEY `idx_im_conversation_settings_user_pinned` (`user_id`,`pinned`,`updated_at`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS `im_credentials` ( + `id` int NOT NULL AUTO_INCREMENT, + `createdat` datetime(3) NOT NULL, + `updatedat` datetime(3) NOT NULL, + `deletedat` datetime(3) DEFAULT NULL, + `method` varchar(16) NOT NULL, + `value` varchar(128) NOT NULL, + `synthetic` varchar(192) NOT NULL, + `userid` bigint NOT NULL, + `resp` varchar(255) DEFAULT NULL, + `done` tinyint NOT NULL DEFAULT '0', + `retries` int NOT NULL DEFAULT '0', + PRIMARY KEY (`id`), + KEY `userid` (`userid`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS `im_daily_reports` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `report_id` varchar(64) NOT NULL, + `author_id` bigint NOT NULL, + `report_date` date NOT NULL, + `content` text NOT NULL, + `attachments` json DEFAULT NULL, + `status` varchar(16) NOT NULL DEFAULT 'draft', + `read_state` varchar(16) NOT NULL DEFAULT 'unread', + `version` int NOT NULL DEFAULT '1', + `submitted_at` datetime(3) DEFAULT NULL, + `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 KEY `uk_im_daily_reports_report` (`report_id`), + UNIQUE KEY `uk_im_daily_reports_author_date` (`author_id`,`report_date`), + KEY `idx_im_daily_reports_author_id` (`author_id`,`id` DESC), + KEY `idx_im_daily_reports_date_status` (`report_date`,`status`,`id` DESC) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS `im_dellog` ( + `id` int NOT NULL AUTO_INCREMENT, + `topic` char(25) NOT NULL, + `deletedfor` bigint NOT NULL DEFAULT '0', + `delid` int NOT NULL, + `low` int NOT NULL, + `hi` int NOT NULL, + PRIMARY KEY (`id`), + KEY `dellog_topic_delid_deletedfor` (`topic`,`delid`,`deletedfor`), + KEY `dellog_topic_deletedfor_low_hi` (`topic`,`deletedfor`,`low`,`hi`), + KEY `dellog_deletedfor` (`deletedfor`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS `im_device_push_tokens` ( + `id` bigint NOT NULL, + `device_id` varchar(128) NOT NULL, + `user_id` bigint NOT NULL, + `provider` varchar(32) NOT NULL, + `token` varchar(512) NOT NULL, + `app_version` varchar(64) NOT NULL DEFAULT '', + `platform` varchar(32) NOT NULL DEFAULT '', + `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 KEY `uk_im_device_push_tokens_provider_token` (`provider`,`token`), + UNIQUE KEY `uk_im_device_push_tokens_device` (`device_id`), + KEY `idx_im_device_push_tokens_user` (`user_id`,`updated_at`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS `im_devices` ( + `id` int NOT NULL AUTO_INCREMENT, + `userid` bigint NOT NULL, + `hash` char(16) NOT NULL, + `deviceid` text NOT NULL, + `platform` varchar(32) DEFAULT NULL, + `lastseen` datetime NOT NULL, + `lang` varchar(8) DEFAULT NULL, + PRIMARY KEY (`id`), + KEY `userid` (`userid`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS `im_favorites` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `favorite_id` varchar(64) NOT NULL, + `user_id` bigint NOT NULL, + `item_type` varchar(16) NOT NULL, + `source_topic` char(25) NOT NULL, + `source_seq` bigint NOT NULL, + `snapshot` json DEFAULT NULL, + `attachment_refs` json DEFAULT NULL, + `availability` varchar(16) NOT NULL DEFAULT 'available', + `idempotency_key` varchar(128) NOT NULL DEFAULT '', + `collected_at` datetime(3) NOT NULL, + `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 KEY `uk_im_favorites_favorite` (`favorite_id`), + UNIQUE KEY `uk_im_favorites_source` (`user_id`,`source_topic`,`source_seq`), + UNIQUE KEY `uk_im_favorites_idempotency` (`user_id`,(nullif(`idempotency_key`,_utf8mb4''))), + KEY `idx_im_favorites_inbox` (`user_id`,`id` DESC), + KEY `idx_im_favorites_type` (`user_id`,`item_type`,`id` DESC) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS `im_feedback_config` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `categories` json DEFAULT NULL, + `max_length` int NOT NULL DEFAULT '1000', + `attachment_policy` json DEFAULT NULL, + `allow_contact` tinyint NOT NULL DEFAULT '1', + `diagnostics_notice` text NOT NULL, + `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`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS `im_feedback_submissions` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `feedback_id` varchar(64) NOT NULL, + `user_id` bigint NOT NULL, + `category` varchar(64) NOT NULL, + `content` text NOT NULL, + `contact` varchar(128) NOT NULL DEFAULT '', + `attachment_file_ids` json DEFAULT NULL, + `client_context` json DEFAULT NULL, + `include_diagnostics` tinyint NOT NULL DEFAULT '0', + `idempotency_key` varchar(128) NOT NULL, + `status` varchar(32) NOT NULL DEFAULT 'received', + `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 KEY `uk_im_feedback_submissions_feedback` (`feedback_id`), + UNIQUE KEY `uk_im_feedback_submissions_idempotency` (`user_id`,`idempotency_key`), + KEY `idx_im_feedback_submissions_user` (`user_id`,`id` DESC) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS `im_file_review_tasks` ( + `id` bigint NOT NULL, + `task_id` varchar(64) NOT NULL, + `file_id` bigint NOT NULL, + `user_id` bigint NOT NULL DEFAULT '0', + `task_type` varchar(32) NOT NULL, + `provider` varchar(64) NOT NULL, + `storage_provider` varchar(32) NOT NULL DEFAULT '', + `object_name` varchar(2048) NOT NULL DEFAULT '', + `mime_type` varchar(255) NOT NULL DEFAULT '', + `file_size` bigint NOT NULL DEFAULT '0', + `provider_task_id` varchar(128) NOT NULL DEFAULT '', + `status` varchar(16) NOT NULL DEFAULT 'pending', + `risk_level` varchar(32) NOT NULL DEFAULT '', + `risk_labels` json DEFAULT NULL, + `result_json` json DEFAULT NULL, + `error_code` varchar(64) NOT NULL DEFAULT '', + `error_message` varchar(512) NOT NULL DEFAULT '', + `attempts` int NOT NULL DEFAULT '0', + `next_retry_at` datetime(3) DEFAULT NULL, + `completed_at` datetime(3) DEFAULT NULL, + `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 KEY `uk_im_file_review_tasks_task` (`task_id`), + UNIQUE KEY `uk_im_file_review_tasks_file_provider` (`file_id`,`task_type`,`provider`), + KEY `idx_im_file_review_tasks_pending` (`status`,`next_retry_at`,`id`), + KEY `idx_im_file_review_tasks_file` (`file_id`), + KEY `idx_im_file_review_tasks_provider_task` (`provider`,`provider_task_id`), + KEY `idx_im_file_review_tasks_object` (`storage_provider`,`object_name`(191)) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS `im_filemsglinks` ( + `id` int NOT NULL AUTO_INCREMENT, + `createdat` datetime(3) NOT NULL, + `fileid` bigint NOT NULL, + `msgid` int DEFAULT NULL, + `topic` char(25) DEFAULT NULL, + `userid` bigint DEFAULT NULL, + PRIMARY KEY (`id`), + KEY `fileid` (`fileid`), + KEY `msgid` (`msgid`), + KEY `topic` (`topic`), + KEY `userid` (`userid`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS `im_fileuploads` ( + `id` bigint NOT NULL, + `createdat` datetime(3) NOT NULL, + `updatedat` datetime(3) NOT NULL, + `userid` bigint DEFAULT NULL, + `status` int NOT NULL, + `mimetype` varchar(255) NOT NULL, + `size` bigint NOT NULL, + `etag` varchar(128) DEFAULT NULL, + `location` varchar(2048) NOT NULL, + PRIMARY KEY (`id`), + KEY `fileuploads_status` (`status`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS `im_friend_requests` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `request_id` varchar(64) NOT NULL, + `applicant_id` bigint NOT NULL, + `target_id` bigint NOT NULL, + `message` varchar(200) NOT NULL DEFAULT '', + `source` varchar(32) NOT NULL DEFAULT '', + `source_topic` varchar(64) NOT NULL DEFAULT '', + `status` varchar(16) NOT NULL, + `idempotency_key` varchar(128) NOT NULL DEFAULT '', + `expires_at` datetime(3) NOT NULL, + `handled_at` datetime(3) DEFAULT NULL, + `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 KEY `uk_im_friend_requests_request` (`request_id`), + UNIQUE KEY `uk_im_friend_requests_idempotency` (`applicant_id`,`idempotency_key`), + KEY `idx_im_friend_requests_pair` (`applicant_id`,`target_id`,`status`,`created_at`), + KEY `idx_im_friend_requests_inbox` (`target_id`,`status`,`created_at`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS `im_friendships` ( + `id` bigint NOT NULL, + `user_id` bigint NOT NULL, + `peer_id` bigint NOT NULL, + `status` varchar(16) NOT NULL, + `remark` varchar(64) NOT NULL DEFAULT '', + `topic` char(25) NOT NULL DEFAULT '', + `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 KEY `uk_im_friendships_pair` (`user_id`,`peer_id`), + KEY `idx_im_friendships_peer` (`peer_id`,`status`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS `im_group_admin_logs` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `log_id` varchar(64) NOT NULL, + `topic` varchar(32) NOT NULL, + `operator_id` bigint NOT NULL, + `target_user_id` bigint DEFAULT NULL, + `action` varchar(64) NOT NULL, + `object_type` varchar(32) NOT NULL DEFAULT '', + `object_id` varchar(64) NOT NULL DEFAULT '', + `before_data` json DEFAULT NULL, + `after_data` json DEFAULT NULL, + `request_id` varchar(128) NOT NULL DEFAULT '', + `idempotency_key` varchar(128) NOT NULL DEFAULT '', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (`id`), + UNIQUE KEY `uk_im_group_admin_logs_log` (`log_id`), + KEY `idx_im_group_admin_logs_topic_time` (`topic`,`id` DESC), + KEY `idx_im_group_admin_logs_operator` (`operator_id`,`id` DESC), + KEY `idx_im_group_admin_logs_object` (`object_type`,`object_id`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS `im_group_announcement_reads` ( + `id` bigint NOT NULL, + `announcement_id` varchar(64) NOT NULL, + `topic` varchar(32) NOT NULL, + `user_id` bigint NOT NULL, + `read_at` datetime(3) NOT NULL, + `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 KEY `uk_im_group_announcement_reads_announcement_user` (`announcement_id`,`user_id`), + KEY `idx_im_group_announcement_reads_topic_user` (`topic`,`user_id`,`read_at`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS `im_group_announcements` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `announcement_id` varchar(64) NOT NULL, + `topic` varchar(32) NOT NULL, + `author_user_id` bigint NOT NULL, + `content` text NOT NULL, + `status` varchar(16) NOT NULL DEFAULT 'current', + `version` int NOT NULL DEFAULT '1', + `published_at` datetime(3) NOT NULL, + `deleted_by` bigint DEFAULT NULL, + `deleted_at` datetime(3) DEFAULT NULL, + `idempotency_key` varchar(128) NOT NULL DEFAULT '', + `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 KEY `uk_im_group_announcements_announcement` (`announcement_id`), + UNIQUE KEY `uk_im_group_announcements_idempotency` (`topic`,`author_user_id`,`idempotency_key`), + KEY `idx_im_group_announcements_topic_status` (`topic`,`status`,`version` DESC,`id` DESC), + KEY `idx_im_group_announcements_topic_time` (`topic`,`published_at` DESC,`id` DESC) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +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 DEFAULT NULL, + `status` varchar(16) NOT NULL DEFAULT 'pending', + `attempts` int NOT NULL DEFAULT '0', + `next_retry_at` datetime(3) DEFAULT NULL, + `delivered_at` datetime(3) DEFAULT NULL, + `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 KEY `uk_im_group_events_outbox_event` (`event_id`), + KEY `idx_im_group_events_outbox_pending` (`status`,`next_retry_at`,`id`), + KEY `idx_im_group_events_outbox_topic` (`topic`,`id` DESC) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS `im_group_files` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `group_file_id` varchar(64) NOT NULL, + `topic` varchar(32) NOT NULL, + `file_id` varchar(64) NOT NULL, + `source_topic` varchar(32) NOT NULL DEFAULT '', + `source_seq` bigint NOT NULL DEFAULT '0', + `uploader_id` bigint NOT NULL, + `file_name` varchar(255) NOT NULL DEFAULT '', + `mime_type` varchar(128) NOT NULL DEFAULT '', + `file_type` varchar(32) NOT NULL DEFAULT 'file', + `file_size` bigint NOT NULL DEFAULT '0', + `status` varchar(16) NOT NULL DEFAULT 'active', + `deleted_by` bigint DEFAULT NULL, + `deleted_at` datetime(3) DEFAULT NULL, + `idempotency_key` varchar(128) NOT NULL DEFAULT '', + `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 KEY `uk_im_group_files_group_file` (`group_file_id`), + UNIQUE KEY `uk_im_group_files_topic_file` (`topic`,`file_id`), + UNIQUE KEY `uk_im_group_files_idempotency` (`topic`,`uploader_id`,`idempotency_key`), + KEY `idx_im_group_files_topic_type` (`topic`,`status`,`file_type`,`id` DESC), + KEY `idx_im_group_files_topic_name` (`topic`,`status`,`file_name`), + KEY `idx_im_group_files_uploader` (`uploader_id`,`id` DESC) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS `im_group_idempotency` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `user_id` bigint NOT NULL, + `method` varchar(8) NOT NULL, + `route` varchar(256) NOT NULL, + `idempotency_key` varchar(128) NOT NULL, + `body_hash` char(64) NOT NULL, + `result_code` int NOT NULL DEFAULT '0', + `result_body` json DEFAULT NULL, + `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 KEY `uk_im_group_idempotency_request` (`user_id`,`method`,`route`,`idempotency_key`), + KEY `idx_im_group_idempotency_created` (`created_at`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS `im_group_invitations` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `invitation_id` varchar(64) NOT NULL, + `topic` varchar(32) NOT NULL, + `inviter_user_id` bigint NOT NULL, + `target_user_id` bigint NOT NULL, + `message` varchar(256) NOT NULL DEFAULT '', + `status` varchar(16) NOT NULL DEFAULT 'pending', + `result` varchar(32) NOT NULL DEFAULT '', + `join_request_id` varchar(64) NOT NULL DEFAULT '', + `idempotency_key` varchar(128) NOT NULL DEFAULT '', + `expires_at` datetime(3) NOT NULL, + `handled_at` datetime(3) DEFAULT NULL, + `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 KEY `uk_im_group_invitations_invitation` (`invitation_id`), + UNIQUE KEY `uk_im_group_invitations_idempotency` (`inviter_user_id`,`idempotency_key`), + KEY `idx_im_group_invitations_target` (`target_user_id`,`status`,`expires_at`), + KEY `idx_im_group_invitations_topic_status` (`topic`,`status`,`id`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS `im_group_join_requests` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `request_id` varchar(64) NOT NULL, + `topic` varchar(32) NOT NULL, + `applicant_user_id` bigint NOT NULL, + `inviter_user_id` bigint DEFAULT NULL, + `source` varchar(32) NOT NULL DEFAULT 'manual', + `qr_token_hash` char(64) NOT NULL DEFAULT '', + `message` varchar(256) NOT NULL DEFAULT '', + `status` varchar(16) NOT NULL DEFAULT 'pending', + `reject_reason` varchar(256) NOT NULL DEFAULT '', + `handled_by` bigint DEFAULT NULL, + `handled_at` datetime(3) DEFAULT NULL, + `expires_at` datetime(3) DEFAULT NULL, + `idempotency_key` varchar(128) NOT NULL DEFAULT '', + `version` int NOT NULL DEFAULT '1', + `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 KEY `uk_im_group_join_requests_request` (`request_id`), + UNIQUE KEY `uk_im_group_join_requests_idempotency` (`applicant_user_id`,`idempotency_key`), + KEY `idx_im_group_join_requests_topic_status` (`topic`,`status`,`id`), + KEY `idx_im_group_join_requests_applicant` (`applicant_user_id`,`topic`,`status`,`id`), + KEY `idx_im_group_join_requests_expire` (`status`,`expires_at`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS `im_group_members` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `topic` varchar(32) NOT NULL, + `user_id` bigint NOT NULL, + `role` varchar(16) NOT NULL DEFAULT 'member', + `member_status` varchar(16) NOT NULL DEFAULT 'active', + `nickname` varchar(64) NOT NULL DEFAULT '', + `pinned` tinyint NOT NULL DEFAULT '0', + `mute_notifications` tinyint NOT NULL DEFAULT '0', + `join_source` varchar(32) NOT NULL DEFAULT 'manual', + `joined_by` bigint DEFAULT NULL, + `joined_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `left_at` datetime(3) DEFAULT NULL, + `removed_by` bigint DEFAULT NULL, + `version` int NOT NULL DEFAULT '1', + `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 KEY `uk_im_group_members_topic_user` (`topic`,`user_id`), + KEY `idx_im_group_members_user_status` (`user_id`,`member_status`,`updated_at`), + KEY `idx_im_group_members_topic_role` (`topic`,`role`,`member_status`,`id`), + KEY `idx_im_group_members_topic_status` (`topic`,`member_status`,`id`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS `im_group_messages` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `topic` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '群 Topic,如 grp_xxx', + `group_id` bigint NOT NULL COMMENT '业务群 ID,便于分区、治理和统计', + `seqid` bigint NOT NULL COMMENT '群内消息序号,由 Redis 原子发号', + `from_user` bigint NOT NULL COMMENT '发送人用户 ID', + `client_msg_id` varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '客户端幂等消息 ID', + `msg_type` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'text' COMMENT 'text/image/file/audio/video/system 等', + `status` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'normal' COMMENT 'normal/recalled/deleted/blocked', + `content` json DEFAULT NULL COMMENT '消息正文或 Drafty 内容', + `head` json DEFAULT NULL COMMENT '消息头,包含 @、引用、客户端能力等', + `mentioned_user_ids` json DEFAULT NULL COMMENT '@ 用户列表,便于提醒和检索', + `mention_all` tinyint(1) NOT NULL DEFAULT '0' COMMENT '是否 @ 全体', + `reply_seqid` bigint DEFAULT NULL COMMENT '回复的群消息 seq', + `root_seqid` bigint DEFAULT NULL COMMENT '线程或合并上下文根消息 seq', + `file_count` int NOT NULL DEFAULT '0' COMMENT '附件数量', + `content_size` int NOT NULL DEFAULT '0' COMMENT '正文 JSON 序列化后的字节数', + `has_risk` tinyint(1) NOT NULL DEFAULT '0' COMMENT '是否命中风控或内容安全', + `audit_status` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'pass' COMMENT 'pass/pending/reject', + `version` int NOT NULL DEFAULT '1' COMMENT '编辑版本', + `recalled_at` datetime(3) DEFAULT NULL, + `recalled_by` bigint DEFAULT NULL, + `deleted_at` datetime(3) DEFAULT NULL, + `expires_at` datetime(3) DEFAULT NULL COMMENT '可选保留期,便于过期归档或清理', + `created_at` datetime(3) NOT NULL, + `updated_at` datetime(3) NOT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uk_im_group_messages_group_seq` (`group_id`,`seqid`), + UNIQUE KEY `uk_im_group_messages_topic_seq` (`topic`,`seqid`), + UNIQUE KEY `uk_im_group_messages_topic_sender_client_mid` (`topic`,`from_user`,`client_msg_id`), + KEY `idx_im_group_messages_group_created` (`group_id`,`created_at`), + KEY `idx_im_group_messages_group_status_seq` (`group_id`,`status`,`seqid`), + KEY `idx_im_group_messages_topic_created` (`topic`,`created_at`), + KEY `idx_im_group_messages_topic_from_seq` (`topic`,`from_user`,`seqid`), + KEY `idx_im_group_messages_audit` (`audit_status`,`created_at`), + KEY `idx_im_group_messages_expires` (`expires_at`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS `im_group_mutes` ( + `id` bigint NOT NULL, + `topic` varchar(32) NOT NULL, + `user_id` bigint NOT NULL, + `muted_by` bigint NOT NULL, + `muted_at` datetime(3) NOT NULL, + `expires_at` datetime(3) DEFAULT NULL, + `reason` varchar(256) NOT NULL DEFAULT '', + `status` varchar(16) NOT NULL DEFAULT 'active', + `revoked_by` bigint DEFAULT NULL, + `revoked_at` datetime(3) DEFAULT NULL, + `version` int NOT NULL DEFAULT '1', + `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 KEY `uk_im_group_mutes_topic_user` (`topic`,`user_id`), + KEY `idx_im_group_mutes_topic_status` (`topic`,`status`,`expires_at`), + KEY `idx_im_group_mutes_user_status` (`user_id`,`status`,`expires_at`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS `im_group_qrcodes` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `token_id` varchar(64) NOT NULL, + `token_hash` char(64) NOT NULL, + `topic` varchar(32) NOT NULL, + `created_by` bigint NOT NULL, + `status` varchar(16) NOT NULL DEFAULT 'active', + `expires_at` datetime(3) NOT NULL, + `revoked_by` bigint DEFAULT NULL, + `revoked_at` datetime(3) DEFAULT NULL, + `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 KEY `uk_im_group_qrcodes_token` (`token_id`), + UNIQUE KEY `uk_im_group_qrcodes_hash` (`token_hash`), + KEY `idx_im_group_qrcodes_topic_status` (`topic`,`status`,`expires_at`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS `im_group_settings` ( + `id` bigint NOT NULL, + `topic` varchar(32) NOT NULL, + `owner_user_id` bigint NOT NULL, + `name` varchar(128) NOT NULL DEFAULT '', + `avatar_file_id` varchar(64) NOT NULL DEFAULT '', + `description` varchar(512) NOT NULL DEFAULT '', + `group_status` varchar(16) NOT NULL DEFAULT 'active', + `join_approval` tinyint NOT NULL DEFAULT '0', + `mute_all` tinyint NOT NULL DEFAULT '0', + `member_upload_files` tinyint NOT NULL DEFAULT '1', + `member_private_chat` tinyint NOT NULL DEFAULT '1', + `member_invite` tinyint NOT NULL DEFAULT '1', + `max_member_count` int NOT NULL DEFAULT '0' COMMENT '0 means use tenant default or unlimited', + `max_admin_count` int NOT NULL DEFAULT '10', + `member_count` int NOT NULL DEFAULT '0', + `version` int NOT NULL DEFAULT '1', + `created_by` bigint NOT NULL, + `dissolved_by` bigint DEFAULT NULL, + `dissolved_at` datetime(3) DEFAULT NULL, + `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 KEY `uk_im_group_settings_topic` (`topic`), + KEY `idx_im_group_settings_owner` (`owner_user_id`,`group_status`), + KEY `idx_im_group_settings_status_updated` (`group_status`,`updated_at`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS `im_http_idempotency` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `user_id` bigint NOT NULL, + `method` varchar(8) NOT NULL, + `route` varchar(512) NOT NULL, + `idempotency_key` varchar(128) NOT NULL, + `body_hash` char(64) NOT NULL, + `state` varchar(16) NOT NULL DEFAULT 'processing', + `response_status` int NOT NULL DEFAULT '0', + `response_body` mediumblob, + `expires_at` datetime(3) NOT NULL, + `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 KEY `uk_im_http_idempotency_scope` (`user_id`,`method`,`route`,`idempotency_key`), + KEY `idx_im_http_idempotency_expiry` (`expires_at`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS `im_kafka_consume_log` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `event_id` varchar(96) NOT NULL, + `consumer_group` varchar(128) NOT NULL, + `source_topic` varchar(128) NOT NULL, + `event_type` varchar(96) NOT NULL DEFAULT '', + `biz_type` varchar(64) NOT NULL DEFAULT '', + `biz_id` varchar(128) NOT NULL DEFAULT '', + `status` varchar(16) NOT NULL DEFAULT 'processing', + `retry_count` int NOT NULL DEFAULT '0', + `last_reason` varchar(64) NOT NULL DEFAULT '', + `last_error` varchar(1000) NOT NULL DEFAULT '', + `processing_deadline` datetime(3) DEFAULT NULL, + `succeeded_at` datetime(3) DEFAULT NULL, + `dead_at` datetime(3) DEFAULT NULL, + `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 KEY `uk_im_kafka_consume_log_event_group` (`event_id`,`consumer_group`), + KEY `idx_im_kafka_consume_log_status` (`status`,`processing_deadline`), + KEY `idx_im_kafka_consume_log_biz` (`biz_type`,`biz_id`), + KEY `idx_im_kafka_consume_log_topic` (`source_topic`,`id`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS `im_kafka_dead_letters` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `dead_id` varchar(96) NOT NULL, + `event_id` varchar(96) NOT NULL, + `parent_event_id` varchar(96) NOT NULL DEFAULT '', + `source_topic` varchar(128) NOT NULL, + `consumer_group` varchar(128) NOT NULL, + `event_type` varchar(96) NOT NULL DEFAULT '', + `biz_type` varchar(64) NOT NULL DEFAULT '', + `biz_id` varchar(128) NOT NULL DEFAULT '', + `envelope` json NOT NULL, + `retry_count` int NOT NULL DEFAULT '0', + `last_reason` varchar(64) NOT NULL DEFAULT '', + `last_error` varchar(1000) NOT NULL DEFAULT '', + `status` varchar(24) NOT NULL DEFAULT 'dead', + `replay_count` int NOT NULL DEFAULT '0', + `resolved_by` varchar(64) NOT NULL DEFAULT '', + `resolved_at` datetime(3) DEFAULT NULL, + `resolve_note` varchar(1000) NOT NULL DEFAULT '', + `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 KEY `uk_im_kafka_dead_letters_dead` (`dead_id`), + KEY `idx_im_kafka_dead_letters_event` (`event_id`), + KEY `idx_im_kafka_dead_letters_status` (`status`,`created_at`), + KEY `idx_im_kafka_dead_letters_biz` (`biz_type`,`biz_id`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS `im_kafka_outbox` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `event_id` varchar(96) NOT NULL, + `topic` varchar(128) NOT NULL, + `event_type` varchar(96) NOT NULL, + `biz_type` varchar(64) NOT NULL DEFAULT '', + `biz_id` varchar(128) NOT NULL DEFAULT '', + `key_value` varchar(256) NOT NULL DEFAULT '', + `envelope` json NOT NULL, + `status` varchar(16) NOT NULL DEFAULT 'pending', + `attempts` int NOT NULL DEFAULT '0', + `next_retry_at` datetime(3) DEFAULT NULL, + `sent_at` datetime(3) DEFAULT NULL, + `last_reason` varchar(64) NOT NULL DEFAULT '', + `last_error` varchar(1000) NOT NULL DEFAULT '', + `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 KEY `uk_im_kafka_outbox_event` (`event_id`), + KEY `idx_im_kafka_outbox_pending` (`status`,`next_retry_at`,`id`), + KEY `idx_im_kafka_outbox_biz` (`biz_type`,`biz_id`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS `im_kvmeta` ( + `id` bigint NOT NULL, + `key` varchar(64) NOT NULL, + `createdat` datetime(3) DEFAULT NULL, + `value` text, + PRIMARY KEY (`id`), + UNIQUE KEY `uk_im_kvmeta_key` (`key`), + KEY `kvmeta_createdat_key` (`createdat`,`key`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS `im_login_failure_logs` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `user_id` bigint DEFAULT NULL, + `account_type` varchar(24) NOT NULL DEFAULT '', + `account_hash` char(64) NOT NULL DEFAULT '', + `account_mask` varchar(128) NOT NULL DEFAULT '', + `failure_type` varchar(32) NOT NULL, + `reason` varchar(96) NOT NULL DEFAULT '', + `detail` varchar(500) NOT NULL DEFAULT '', + `ip` varchar(64) NOT NULL DEFAULT '', + `region` varchar(128) NOT NULL DEFAULT '', + `device_id` varchar(255) NOT NULL DEFAULT '', + `platform` varchar(32) NOT NULL DEFAULT 'unknown', + `app_version` varchar(32) NOT NULL DEFAULT '', + `user_agent` varchar(512) NOT NULL DEFAULT '', + `risk_level` varchar(16) NOT NULL DEFAULT 'medium', + `risk_action` varchar(24) NOT NULL DEFAULT 'audit_only', + `request_id` varchar(64) NOT NULL DEFAULT '', + `attempt_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `metadata` json DEFAULT NULL, + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (`id`), + KEY `idx_im_login_failure_account_time` (`account_hash`,`attempt_at` DESC,`id` DESC), + KEY `idx_im_login_failure_user_time` (`user_id`,`attempt_at` DESC,`id` DESC), + KEY `idx_im_login_failure_ip_time` (`ip`,`attempt_at` DESC,`id` DESC), + KEY `idx_im_login_failure_type_time` (`failure_type`,`attempt_at` DESC,`id` DESC), + KEY `idx_im_login_failure_request` (`request_id`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS `im_mailbox_config` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `enabled` tinyint NOT NULL DEFAULT '1', + `allow_anonymous` tinyint NOT NULL DEFAULT '1', + `required_fields` json DEFAULT NULL, + `max_length` int NOT NULL DEFAULT '5000', + `attachment_policy` json DEFAULT NULL, + `notice_title` varchar(128) NOT NULL DEFAULT '', + `notice_content` text NOT NULL, + `notice_updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(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`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS `im_mailbox_submissions` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `submission_id` varchar(64) NOT NULL, + `sender_id` bigint NOT NULL, + `subject` varchar(200) NOT NULL, + `content` text NOT NULL, + `anonymous` tinyint NOT NULL DEFAULT '0', + `attachment_file_ids` json DEFAULT NULL, + `notice_version` varchar(64) NOT NULL DEFAULT '', + `idempotency_key` varchar(128) NOT NULL, + `status` varchar(32) NOT NULL DEFAULT 'received', + `submitted_at` datetime(3) NOT NULL, + `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 KEY `uk_im_mailbox_submissions_submission` (`submission_id`), + UNIQUE KEY `uk_im_mailbox_submissions_idempotency` (`sender_id`,`idempotency_key`), + KEY `idx_im_mailbox_submissions_sender` (`sender_id`,`id` DESC), + KEY `idx_im_mailbox_submissions_status` (`status`,`id` DESC) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS `im_meeting_note_files` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `note_file_id` varchar(64) NOT NULL, + `meeting_id` varchar(64) NOT NULL, + `file_id` varchar(64) NOT NULL, + `uploader_id` bigint NOT NULL, + `file_name` varchar(255) NOT NULL DEFAULT '', + `mime_type` varchar(128) NOT NULL DEFAULT '', + `file_size` bigint NOT NULL DEFAULT '0', + `file_etag` varchar(128) NOT NULL DEFAULT '', + `status` varchar(16) NOT NULL DEFAULT 'active', + `preview_status` varchar(16) NOT NULL DEFAULT 'ready', + `preview_mode` varchar(16) NOT NULL DEFAULT 'native', + `preview_file_id` varchar(64) NOT NULL DEFAULT '', + `idempotency_key` varchar(128) NOT NULL DEFAULT '', + `version` int NOT NULL DEFAULT '1', + `deleted_by` bigint DEFAULT NULL, + `deleted_at` datetime(3) DEFAULT NULL, + `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 KEY `uk_im_meeting_note_files_id` (`note_file_id`), + UNIQUE KEY `uk_im_meeting_note_files_idempotency` (`meeting_id`,`uploader_id`,`idempotency_key`), + KEY `idx_im_meeting_note_files_file` (`meeting_id`,`file_id`,`status`), + KEY `idx_im_meeting_note_files_meeting` (`meeting_id`,`status`,`id` DESC), + KEY `idx_im_meeting_note_files_uploader` (`uploader_id`,`id` DESC) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS `im_meeting_participants` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `meeting_id` varchar(64) NOT NULL, + `user_id` bigint NOT NULL, + `response` varchar(16) NOT NULL DEFAULT 'pending', + `response_at` datetime(3) DEFAULT NULL, + `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 KEY `uk_im_meeting_participants_user` (`meeting_id`,`user_id`), + KEY `idx_im_meeting_participants_user_time` (`user_id`,`meeting_id`), + KEY `idx_im_meeting_participants_response` (`meeting_id`,`response`), + CONSTRAINT `fk_im_meeting_participants_meeting` FOREIGN KEY (`meeting_id`) REFERENCES `im_meetings` (`meeting_id`) ON DELETE CASCADE + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS `im_meetings` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `meeting_id` varchar(64) NOT NULL, + `organizer_id` bigint NOT NULL, + `recorder_user_id` bigint NOT NULL DEFAULT '0', + `title` varchar(200) NOT NULL, + `start_at` datetime(3) NOT NULL, + `end_at` datetime(3) NOT NULL, + `location` varchar(256) NOT NULL DEFAULT '', + `state` varchar(16) NOT NULL DEFAULT 'scheduled', + `reminder_minutes` json DEFAULT NULL, + `idempotency_key` varchar(128) DEFAULT NULL, + `version` int NOT NULL DEFAULT '1', + `canceled_at` datetime(3) DEFAULT NULL, + `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 KEY `uk_im_meetings_meeting` (`meeting_id`), + UNIQUE KEY `uk_im_meetings_idempotency` (`organizer_id`,`idempotency_key`), + KEY `idx_im_meetings_organizer` (`organizer_id`,`start_at`), + KEY `idx_im_meetings_state_time` (`state`,`start_at`), + KEY `idx_im_meetings_recorder` (`recorder_user_id`,`end_at`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS `im_message_admin_actions` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `action_id` varchar(96) NOT NULL, + `command_id` varchar(96) NOT NULL, + `conversation_type` varchar(16) NOT NULL, + `topic` varchar(64) NOT NULL, + `seqid` bigint NOT NULL, + `sender_user_id` bigint DEFAULT NULL, + `action` varchar(16) NOT NULL, + `action_status` varchar(16) NOT NULL DEFAULT 'pending', + `reason` varchar(500) NOT NULL DEFAULT '', + `actor_admin_id` bigint NOT NULL DEFAULT '0', + `request_id` varchar(64) NOT NULL DEFAULT '', + `before_snapshot` json DEFAULT NULL, + `after_snapshot` json DEFAULT NULL, + `failure_reason` varchar(96) NOT NULL DEFAULT '', + `failure_message` varchar(1000) NOT NULL DEFAULT '', + `processed_at` datetime(3) DEFAULT NULL, + `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 KEY `uk_im_message_admin_action_id` (`action_id`), + UNIQUE KEY `uk_im_message_admin_command` (`command_id`), + KEY `idx_im_message_admin_target` (`conversation_type`,`topic`,`seqid`,`id` DESC), + KEY `idx_im_message_admin_status` (`action_status`,`created_at`,`id`), + KEY `idx_im_message_admin_actor` (`actor_admin_id`,`created_at` DESC) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS `im_message_revisions` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `createdat` datetime(3) NOT NULL, + `updatedat` datetime(3) NOT NULL, + `topic` char(25) NOT NULL, + `seqid` bigint NOT NULL, + `version` int NOT NULL, + `actor` bigint NOT NULL, + `head` json DEFAULT NULL, + `content` json DEFAULT NULL, + `reason` varchar(256) NOT NULL DEFAULT '', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_im_message_revisions_version` (`topic`,`seqid`,`version`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS `im_messages` ( + `id` int NOT NULL AUTO_INCREMENT, + `createdat` datetime(3) NOT NULL, + `updatedat` datetime(3) NOT NULL, + `deletedat` datetime(3) DEFAULT NULL, + `delid` int DEFAULT '0', + `seqid` bigint NOT NULL, + `topic` char(25) NOT NULL, + `from` bigint NOT NULL, + `client_msg_id` varchar(64) DEFAULT NULL, + `version` int NOT NULL DEFAULT '1', + `recalled_at` datetime(3) DEFAULT NULL, + `recalled_by` bigint DEFAULT NULL, + `head` json DEFAULT NULL, + `content` json DEFAULT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `messages_sender_client_mid` (`from`,`client_msg_id`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS `im_monthly_reports` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `report_id` varchar(64) NOT NULL, + `author_id` bigint NOT NULL, + `report_month` char(7) NOT NULL, + `work_summary` text NOT NULL, + `next_plan` text NOT NULL, + `attachments` json DEFAULT NULL, + `status` varchar(16) NOT NULL DEFAULT 'draft', + `read_state` varchar(16) NOT NULL DEFAULT 'unread', + `version` int NOT NULL DEFAULT '1', + `submitted_at` datetime(3) DEFAULT NULL, + `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 KEY `uk_im_monthly_reports_report` (`report_id`), + UNIQUE KEY `uk_im_monthly_reports_author_month` (`author_id`,`report_month`), + KEY `idx_im_monthly_reports_author_id` (`author_id`,`id` DESC), + KEY `idx_im_monthly_reports_month_status` (`report_month`,`status`,`id` DESC) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS `im_org_departments` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `department_id` varchar(64) NOT NULL, + `name` varchar(128) NOT NULL, + `parent_id` varchar(64) NOT NULL DEFAULT 'root', + `sort_order` int NOT NULL DEFAULT '0', + `version` int NOT NULL DEFAULT '1', + `state` varchar(16) NOT NULL DEFAULT 'active', + `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 KEY `uk_im_org_departments_department` (`department_id`), + KEY `idx_im_org_departments_parent` (`parent_id`,`state`,`sort_order`,`id`), + KEY `idx_im_org_departments_name` (`name`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS `im_org_members` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `department_id` varchar(64) NOT NULL, + `user_id` bigint NOT NULL, + `display_name` varchar(128) NOT NULL DEFAULT '', + `position` varchar(128) NOT NULL DEFAULT '', + `avatar_file_id` varchar(64) NOT NULL DEFAULT '', + `mobile` varchar(32) NOT NULL DEFAULT '', + `email` varchar(128) NOT NULL DEFAULT '', + `employment_state` varchar(16) NOT NULL DEFAULT 'active', + `contactable` tinyint NOT NULL DEFAULT '1', + `sort_order` int NOT NULL DEFAULT '0', + `version` int NOT NULL DEFAULT '1', + `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 KEY `uk_im_org_members_department_user` (`department_id`,`user_id`), + KEY `idx_im_org_members_department` (`department_id`,`employment_state`,`sort_order`,`id`), + KEY `idx_im_org_members_user` (`user_id`), + KEY `idx_im_org_members_display_name` (`display_name`), + CONSTRAINT `fk_im_org_members_department` FOREIGN KEY (`department_id`) REFERENCES `im_org_departments` (`department_id`) ON DELETE CASCADE + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS `im_org_visibility_overrides` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `user_id` bigint NOT NULL, + `visibility_scope` varchar(16) NOT NULL, + `state` varchar(16) NOT NULL DEFAULT 'active', + `remark` varchar(256) NOT NULL DEFAULT '', + `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 KEY `uk_im_org_visibility_user` (`user_id`), + KEY `idx_im_org_visibility_scope` (`visibility_scope`,`state`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS `im_realname_appeals` ( + `id` bigint NOT NULL, + `appeal_id` varchar(64) NOT NULL, + `user_id` bigint NOT NULL, + `reason` varchar(500) NOT NULL DEFAULT '', + `attachments_json` json DEFAULT NULL, + `status` varchar(32) NOT NULL DEFAULT 'pending', + `review_comment` varchar(500) NOT NULL DEFAULT '', + `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 KEY `uk_im_realname_appeals_appeal` (`appeal_id`), + KEY `idx_im_realname_appeals_user` (`user_id`,`created_at`), + KEY `idx_im_realname_appeals_status` (`status`,`created_at`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS `im_realname_verifications` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `user_id` bigint NOT NULL, + `submission_id` varchar(64) NOT NULL, + `status` varchar(16) NOT NULL, + `name_cipher` text NOT NULL, + `id_type` varchar(32) NOT NULL, + `id_number_cipher` text NOT NULL, + `id_number_hash` char(64) NOT NULL, + `masked_name` varchar(64) NOT NULL, + `masked_id_number` varchar(64) NOT NULL, + `review_reason` varchar(256) DEFAULT NULL, + `verified_at` datetime(3) DEFAULT NULL, + `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 KEY `uk_im_realname_submission` (`submission_id`), + KEY `idx_im_realname_user_latest` (`user_id`,`created_at`), + KEY `idx_im_realname_id_hash` (`id_number_hash`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS `im_registration_invite_uses` ( + `id` bigint NOT NULL, + `invite_code_id` bigint NOT NULL, + `user_id` bigint NOT NULL, + `request_id` varchar(64) NOT NULL, + `used_at` datetime(3) NOT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uk_im_registration_invite_use_user` (`user_id`), + KEY `idx_im_registration_invite_use_request` (`request_id`), + KEY `idx_im_registration_invite_use_code` (`invite_code_id`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS `im_risk_challenges` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `challenge_id` varchar(64) NOT NULL, + `user_id` bigint NOT NULL, + `action` varchar(64) NOT NULL, + `level` varchar(16) NOT NULL, + `notice_code` varchar(64) NOT NULL, + `notice_params` json DEFAULT NULL, + `state` varchar(16) NOT NULL DEFAULT 'pending', + `expires_at` datetime(3) NOT NULL, + `decision` varchar(16) NOT NULL DEFAULT '', + `action_token_id` varchar(64) NOT NULL DEFAULT '', + `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 KEY `uk_im_risk_challenges_challenge` (`challenge_id`), + KEY `idx_im_risk_challenges_user_state` (`user_id`,`state`,`expires_at`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS `im_subscriptions` ( + `id` int NOT NULL AUTO_INCREMENT, + `createdat` datetime(3) NOT NULL, + `updatedat` datetime(3) NOT NULL, + `deletedat` datetime(3) DEFAULT NULL, + `userid` bigint NOT NULL, + `topic` char(25) NOT NULL, + `delid` int DEFAULT '0', + `recvseqid` bigint DEFAULT '0', + `readseqid` bigint DEFAULT '0', + `modewant` char(8) DEFAULT NULL, + `modegiven` char(8) DEFAULT NULL, + `private` json DEFAULT NULL, + PRIMARY KEY (`id`), + KEY `subscriptions_topic` (`topic`), + KEY `subscriptions_deletedat` (`deletedat`), + KEY `subscriptions_userid_topic_deletedat` (`userid`,`topic`,`deletedat`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS `im_system_notifications` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `user_id` bigint NOT NULL, + `event_id` varchar(64) NOT NULL, + `event_type` varchar(64) NOT NULL, + `title` varchar(128) NOT NULL, + `summary` varchar(512) NOT NULL, + `action` json DEFAULT NULL, + `read_at` datetime(3) DEFAULT NULL, + `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 KEY `uk_im_system_notifications_event` (`user_id`,`event_id`), + KEY `idx_im_system_notifications_inbox` (`user_id`,`id` DESC) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS `im_topic_drafts` ( + `id` bigint NOT NULL, + `user_id` bigint NOT NULL, + `topic` varchar(128) NOT NULL, + `content` text, + `entities_json` json DEFAULT NULL, + `version` int NOT NULL DEFAULT '1', + `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 KEY `uk_im_topic_drafts_user_topic` (`user_id`,`topic`), + KEY `idx_im_topic_drafts_user_updated` (`user_id`,`updated_at`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS `im_topics` ( + `id` int NOT NULL AUTO_INCREMENT, + `createdat` datetime(3) NOT NULL, + `updatedat` datetime(3) NOT NULL, + `state` smallint NOT NULL DEFAULT '0', + `stateat` datetime(3) DEFAULT NULL, + `touchedat` datetime(3) DEFAULT NULL, + `name` char(25) NOT NULL, + `usebt` tinyint DEFAULT '0', + `owner` bigint NOT NULL DEFAULT '0', + `access` json DEFAULT NULL, + `seqid` bigint NOT NULL DEFAULT '0', + `delid` int DEFAULT '0', + `subcnt` int DEFAULT '0', + `public` json DEFAULT NULL, + `trusted` json DEFAULT NULL, + `tags` json DEFAULT NULL, + `aux` json DEFAULT NULL, + PRIMARY KEY (`id`), + KEY `topics_owner` (`owner`), + KEY `topics_state_stateat` (`state`,`stateat`), + KEY `topics_name_state_seqid` (`name`,`state`,`seqid`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS `im_topictags` ( + `id` int NOT NULL AUTO_INCREMENT, + `topic` char(25) NOT NULL, + `tag` varchar(96) NOT NULL, + PRIMARY KEY (`id`), + KEY `topictags_tag` (`tag`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS `im_user_blocks` ( + `id` bigint NOT NULL, + `blocker_id` bigint NOT NULL, + `blocked_id` bigint NOT NULL, + `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 KEY `uk_im_user_blocks_pair` (`blocker_id`,`blocked_id`), + KEY `idx_im_user_blocks_blocked` (`blocked_id`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS `im_user_ip_whitelists` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `user_id` bigint NOT NULL, + `ip_cidr` varchar(64) NOT NULL COMMENT 'IPv4, IPv6 or CIDR', + `status` tinyint NOT NULL DEFAULT '1' COMMENT '1 enabled, 2 disabled', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `created_by` bigint NOT NULL DEFAULT '0', + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + `updated_by` bigint NOT NULL DEFAULT '0', + `deleted_at` datetime(3) DEFAULT NULL, + `deleted_by` bigint DEFAULT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uk_im_user_ip_whitelist` (`user_id`,`ip_cidr`), + KEY `idx_im_user_ip_whitelist_user` (`user_id`,`status`), + KEY `idx_im_user_ip_whitelist_deleted` (`deleted_at`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='Per-user login IP whitelist'; + +CREATE TABLE IF NOT EXISTS `im_user_login_logs` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `user_id` bigint NOT NULL, + `username` varchar(64) NOT NULL DEFAULT '', + `account_type` varchar(24) NOT NULL DEFAULT '', + `account_hash` char(64) NOT NULL DEFAULT '', + `account_mask` varchar(128) NOT NULL DEFAULT '', + `login_type` varchar(24) NOT NULL, + `event_type` varchar(24) NOT NULL DEFAULT 'login', + `session_id` varchar(96) NOT NULL DEFAULT '', + `device_id` varchar(255) NOT NULL DEFAULT '', + `platform` varchar(32) NOT NULL DEFAULT 'unknown', + `app_version` varchar(32) NOT NULL DEFAULT '', + `ip` varchar(64) NOT NULL DEFAULT '', + `region` varchar(128) NOT NULL DEFAULT '', + `user_agent` varchar(512) NOT NULL DEFAULT '', + `risk_level` varchar(16) NOT NULL DEFAULT 'low', + `request_id` varchar(64) NOT NULL DEFAULT '', + `login_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `logout_at` datetime(3) DEFAULT NULL, + `metadata` json DEFAULT NULL, + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (`id`), + KEY `idx_im_user_login_user_time` (`user_id`,`login_at` DESC,`id` DESC), + KEY `idx_im_user_login_ip_time` (`ip`,`login_at` DESC,`id` DESC), + KEY `idx_im_user_login_device_time` (`device_id`(64),`login_at` DESC), + KEY `idx_im_user_login_request` (`request_id`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS `im_user_privacy_settings` ( + `id` bigint NOT NULL, + `user_id` bigint NOT NULL, + `add_me_methods` json DEFAULT NULL, + `profile_visibility` varchar(32) NOT NULL DEFAULT 'friends', + `last_seen_visibility` varchar(32) NOT NULL DEFAULT 'friends', + `read_receipts` tinyint NOT NULL DEFAULT '1', + `version` int NOT NULL DEFAULT '1', + `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 KEY `uk_im_user_privacy_settings_user` (`user_id`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS `im_user_profiles` ( + `id` bigint NOT NULL, + `user_id` bigint NOT NULL, + `avatar_file_id` varchar(64) NOT NULL DEFAULT '', + `nickname` varchar(64) NOT NULL DEFAULT '', + `birth_date` date DEFAULT NULL, + `signature` varchar(256) NOT NULL DEFAULT '', + `gender` varchar(16) NOT NULL DEFAULT '', + `region` varchar(128) NOT NULL DEFAULT '', + `email` varchar(128) NOT NULL DEFAULT '', + `public_account` varchar(64) NOT NULL DEFAULT '', + `version` int NOT NULL DEFAULT '1', + `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 KEY `uk_im_user_profiles_user` (`user_id`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS `im_user_security` ( + `id` bigint NOT NULL, + `user_id` bigint NOT NULL, + `auth_version` int NOT NULL DEFAULT '1', + `password_updated_at` datetime(3) DEFAULT NULL, + `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 KEY `uk_im_user_security_user` (`user_id`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS `im_users` ( + `id` bigint NOT NULL, + `account_no` int unsigned NOT NULL DEFAULT '0' COMMENT '用户编号', + `username` varchar(32) NOT NULL, + `createdat` datetime(3) NOT NULL, + `updatedat` datetime(3) NOT NULL, + `state` smallint NOT NULL DEFAULT '0', + `stateat` datetime(3) DEFAULT NULL, + `access` json DEFAULT NULL, + `lastseen` datetime DEFAULT NULL, + `useragent` varchar(255) DEFAULT '', + `phone` varchar(32) DEFAULT NULL, + `public` json DEFAULT NULL, + `trusted` json DEFAULT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `users_username` (`username`), + UNIQUE KEY `uk_im_users_account_no` (`account_no`), + UNIQUE KEY `users_phone` (`phone`), + KEY `users_state_stateat` (`state`,`stateat`), + KEY `users_lastseen_updatedat` (`lastseen`,`updatedat`), + KEY `idx_im_users_account_no` (`account_no`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS `im_verification_codes` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `challenge_id` varchar(64) NOT NULL, + `scene` varchar(32) NOT NULL, + `action` varchar(64) NOT NULL DEFAULT '', + `phone_hash` char(64) NOT NULL, + `phone_mask` varchar(32) NOT NULL, + `target_user` bigint DEFAULT NULL, + `code_hash` char(64) NOT NULL, + `verification_token_hash` char(64) DEFAULT NULL, + `state` smallint NOT NULL, + `attempts` int NOT NULL DEFAULT '0', + `max_attempts` int NOT NULL, + `expires_at` datetime(3) NOT NULL, + `resend_after` datetime(3) NOT NULL, + `verified_at` datetime(3) DEFAULT NULL, + `consumed_at` datetime(3) DEFAULT NULL, + `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 KEY `uk_im_verification_challenge` (`challenge_id`), + KEY `idx_im_verification_scene_phone_state` (`scene`,`phone_hash`,`state`,`expires_at`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS `im_workbench_apps` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `code` varchar(64) NOT NULL, + `name` varchar(128) NOT NULL, + `icon_file_id` varchar(64) NOT NULL DEFAULT '', + `route` varchar(128) NOT NULL, + `sort_order` int NOT NULL DEFAULT '0', + `unread_key` varchar(64) NOT NULL DEFAULT '', + `enabled` tinyint NOT NULL DEFAULT '1', + `permission` varchar(64) NOT NULL DEFAULT 'enabled', + `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 KEY `uk_im_workbench_apps_code` (`code`), + KEY `idx_im_workbench_apps_enabled_order` (`enabled`,`sort_order`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS `im_youth_mode_settings` ( + `id` bigint NOT NULL, + `user_id` bigint NOT NULL, + `purpose` varchar(64) NOT NULL, + `password_hash` varbinary(255) NOT NULL, + `enabled` tinyint NOT NULL DEFAULT '0', + `policy_version` int NOT NULL DEFAULT '1', + `restrictions` json DEFAULT NULL, + `enabled_at` datetime(3) DEFAULT NULL, + `failed_attempts` int NOT NULL DEFAULT '0', + `locked_until` datetime(3) DEFAULT NULL, + `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 KEY `uk_im_youth_mode_settings_user_purpose` (`user_id`,`purpose`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +-- ============================================================ +-- 2. Tenant business seed data. +-- ============================================================ + +-- Tinode schema version expected by the current MySQL adapter. +INSERT INTO `im_kvmeta` (`id`, `key`, `createdat`, `value`) +VALUES (1, 'version', CURRENT_TIMESTAMP(3), 123) + ON DUPLICATE KEY UPDATE `value` = VALUES(`value`); + +-- Workbench default apps. These are product navigation entries, not tenant business data. +INSERT INTO `im_workbench_apps` (`code`, `name`, `route`, `sort_order`, `unread_key`, `enabled`, `permission`) +VALUES + ('attendance', '打卡', '/attendance', 10, 'attendance', 1, 'enabled'), + ('monthly_reports', '月报', '/reports/monthly', 20, 'monthly_reports', 1, 'enabled'), + ('daily_reports', '日报', '/reports/daily', 30, 'daily_reports', 1, 'enabled'), + ('organization', '部门框架', '/organization', 40, 'organization', 1, 'enabled'), + ('executive_mailbox', '举报信箱', '/executive-mailbox', 50, 'executive_mailbox', 1, 'enabled'), + ('meetings', '会议通知', '/meetings', 60, 'meetings', 1, 'enabled'), + ('meeting_notes', '会议笔记', '/meeting-notes', 70, '', 1, 'enabled') + ON DUPLICATE KEY UPDATE + `name` = VALUES(`name`), + `route` = VALUES(`route`), + `sort_order` = VALUES(`sort_order`), + `unread_key` = VALUES(`unread_key`), + `enabled` = VALUES(`enabled`), + `permission` = VALUES(`permission`), + `updated_at` = CURRENT_TIMESTAMP(3); + +-- Default attendance settings. Tenants can edit or replace them later. +INSERT INTO `im_attendance_rules` (`rule_id`, `name`, `workday`, `location_required`, `wifi_required`, `allow_outside_checkin`, `appeal_allowed`) +VALUES ('att_rule_default', '默认考勤规则', 1, 0, 0, 1, 1) + ON DUPLICATE KEY UPDATE `updated_at` = CURRENT_TIMESTAMP(3); + +INSERT INTO `im_attendance_shifts` (`shift_id`, `rule_id`, `name`, `work_start`, `work_end`, `checkin_start`, `checkout_end`, `sort_order`) +VALUES ('shift_default', 'att_rule_default', '默认班次', '09:00:00', '18:00:00', '08:30:00', '20:00:00', 10) + ON DUPLICATE KEY UPDATE `updated_at` = CURRENT_TIMESTAMP(3); + +-- Default mailbox configuration. +INSERT INTO `im_mailbox_config` (`id`, `enabled`, `allow_anonymous`, `required_fields`, `max_length`, `attachment_policy`, `notice_title`, `notice_content`) +VALUES (1, 1, 1, '["subject","content"]', 5000, '{"max_files":5,"purpose":"executive_mailbox"}', '总经理信箱', '请如实填写诉求,匿名提交将按企业政策保护展示身份。') + ON DUPLICATE KEY UPDATE + `enabled` = VALUES(`enabled`), + `allow_anonymous` = VALUES(`allow_anonymous`), + `required_fields` = VALUES(`required_fields`), + `max_length` = VALUES(`max_length`), + `attachment_policy` = VALUES(`attachment_policy`), + `notice_title` = VALUES(`notice_title`), + `notice_content` = VALUES(`notice_content`), + `updated_at` = CURRENT_TIMESTAMP(3); + +-- Default abuse report reasons. +INSERT INTO `im_abuse_reasons` (`target_type`, `reason_code`, `label`, `requires_description`, `allows_evidence`, `sort_order`) +VALUES + ('user', 'fraud', '诈骗欺诈', 0, 1, 10), + ('user', 'harassment', '骚扰辱骂', 0, 1, 20), + ('user', 'illegal', '违法违规', 1, 1, 30), + ('group', 'fraud', '诈骗欺诈', 0, 1, 10), + ('group', 'spam', '垃圾广告', 0, 1, 20), + ('group', 'illegal', '违法违规', 1, 1, 30), + ('message', 'fraud', '诈骗欺诈', 0, 1, 10), + ('message', 'spam', '垃圾广告', 0, 1, 20), + ('message', 'harassment', '骚扰辱骂', 0, 1, 30), + ('message', 'illegal', '违法违规', 1, 1, 40) + ON DUPLICATE KEY UPDATE + `label` = VALUES(`label`), + `requires_description` = VALUES(`requires_description`), + `allows_evidence` = VALUES(`allows_evidence`), + `sort_order` = VALUES(`sort_order`), + `updated_at` = CURRENT_TIMESTAMP(3); + +-- Default feedback configuration. +INSERT INTO `im_feedback_config` (`id`, `categories`, `max_length`, `attachment_policy`, `allow_contact`, `diagnostics_notice`) +VALUES (1, '["bug","suggestion","complaint","other"]', 1000, '{"max_files":5,"purpose":"feedback"}', 1, '仅在你同意时上传脱敏诊断信息。') + ON DUPLICATE KEY UPDATE + `categories` = VALUES(`categories`), + `max_length` = VALUES(`max_length`), + `attachment_policy` = VALUES(`attachment_policy`), + `allow_contact` = VALUES(`allow_contact`), + `diagnostics_notice` = VALUES(`diagnostics_notice`), + `updated_at` = CURRENT_TIMESTAMP(3); + +SET FOREIGN_KEY_CHECKS = 1; + +-- ============================================================ +-- 3. Control database registry seed. +-- ============================================================ +-- Keep this section in the same change set as tenant DB initialization. +-- If the tenant id is generated by Admin Server, replace __TENANT_ID__ with the generated id. + +INSERT INTO `hotgo`.`im_tenant` (`id`, `code`, `name`, `tenant_desc`, `db_name`, `db_dsn`, `state`, `created_by`, `updated_by`) +VALUES (2,'system', 'system merchant', 'system default merchant', 'meeting_im', '', 1, 0, 0) + ON DUPLICATE KEY UPDATE + `code` = VALUES(`code`), + `name` = VALUES(`name`), + `db_name` = VALUES(`db_name`), + `state` = VALUES(`state`), + `updated_at` = CURRENT_TIMESTAMP(3); + +INSERT INTO `hotgo`.`im_tenant_config` (`tenant_id`, `max_users`, `max_groups`, `max_group_members`, `created_by`, `updated_by`) +VALUES (2, 0, 0, 0, 0, 0) + ON DUPLICATE KEY UPDATE `updated_at` = CURRENT_TIMESTAMP(3); + diff --git a/server/db/mysql/adapter.go b/server/db/mysql/adapter.go index e633dcc..fc6c3a4 100644 --- a/server/db/mysql/adapter.go +++ b/server/db/mysql/adapter.go @@ -269,12 +269,6 @@ func (a *adapter) Open(jsonconfig json.RawMessage) error { // Actually opening the network connection. err = a.db.Ping() - databaseMissing := isMissingDb(err) - if databaseMissing { - // Ignore missing database here. If we are initializing the database - // missing DB is OK. - err = nil - } if err == nil { if config.SqlTimeout > 0 { a.sqlTimeout = time.Duration(config.SqlTimeout) * time.Second @@ -611,435 +605,31 @@ func createTenantRegistry(tx *sql.Tx, registryDB, businessDB string) error { return err } -// CreateDb initializes the storage. +// CreateDb is intentionally disabled for this deployment. +// +// Database creation, table creation, schema migration, indexes and seed data +// are handled by reviewed SQL files and the release process. The service and +// local database tools must not create or mutate schema implicitly. func (a *adapter) CreateDb(reset bool) error { - var err error - var tx *sql.Tx - - // Can't use an existing connection because it's configured with a database name which may not exist. - // Don't care if it does not close cleanly. - a.db.Close() - - // This DSN has been parsed before and produced no error, not checking for errors here. - cfg, _ := ms.ParseDSN(a.dsn) - // Clear database name - cfg.DBName = "" - - a.db, err = sqlx.Open(instrumentedMySQLDriverName, cfg.FormatDSN()) - if err != nil { - return err - } - - if tx, err = a.db.Begin(); err != nil { - return err - } - - defer func() { - if err != nil { - // FIXME: This is useless: MySQL auto-commits on every CREATE TABLE. - // Maybe DROP DATABASE instead. - tx.Rollback() - } - }() - - registryDB := a.tenantRegistryDB - if registryDB == "" { - registryDB = "hotgo" - } - businessDB := a.dbName - if businessDB == "" { - businessDB = defaultDatabase - } - if reset { - if _, err = tx.Exec("DROP DATABASE IF EXISTS " + quoteIdent(businessDB)); err != nil { - return err - } - if _, err = tx.Exec("CREATE DATABASE IF NOT EXISTS " + quoteIdent(registryDB) + " CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci"); err != nil { - return err - } - if _, err = tx.Exec("DROP TABLE IF EXISTS " + quoteIdent(registryDB) + ".im_tenant_config"); err != nil { - return err - } - if _, err = tx.Exec("DROP TABLE IF EXISTS " + quoteIdent(registryDB) + ".im_tenant"); err != nil { - return err - } - } - - if err = createTenantRegistry(tx, registryDB, businessDB); err != nil { - return err - } - - if _, err = tx.Exec("CREATE DATABASE IF NOT EXISTS " + quoteIdent(businessDB) + " CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci"); err != nil { - return err - } - - if _, err = tx.Exec("USE " + quoteIdent(businessDB)); err != nil { - return err - } - - if _, err = tx.Exec( - `CREATE TABLE im_users( - id BIGINT NOT NULL, - account_no INT UNSIGNED NOT NULL DEFAULT 0, - username VARCHAR(32) NOT NULL, - createdat DATETIME(3) NOT NULL, - updatedat DATETIME(3) NOT NULL, - state SMALLINT NOT NULL DEFAULT 0, - stateat DATETIME(3), - access JSON, - lastseen DATETIME, - useragent VARCHAR(255) DEFAULT '', - phone VARCHAR(32), - public JSON, - trusted JSON, - PRIMARY KEY(id), - UNIQUE INDEX uk_im_users_account_no(account_no), - UNIQUE INDEX users_username(username), - UNIQUE INDEX users_phone(phone), - INDEX users_state_stateat(state, stateat), - INDEX users_lastseen_updatedat(lastseen, updatedat) - )`); err != nil { - return err - } - - if _, err = tx.Exec( - `CREATE TABLE im_user_security( - id BIGINT NOT NULL, - user_id BIGINT NOT NULL, - auth_version INT NOT NULL DEFAULT 1, - password_updated_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_user_security_user(user_id) - )`); err != nil { - return err - } - - if err = createIMSecurityTables(tx); err != nil { - return err - } - if err = createIMNotificationTables(tx); err != nil { - return err - } - if err = ensureIMRegistrationInviteUseTable(tx); err != nil { - return err - } - if a.registryDB == nil { - if err = ensureIMRegistrationInviteControlTables(tx); err != nil { - return err - } - } - - // Indexed devices. Normalized into a separate table. - if _, err = tx.Exec( - `CREATE TABLE im_devices( - id INT NOT NULL AUTO_INCREMENT, - userid BIGINT NOT NULL, - hash CHAR(16) NOT NULL, - deviceid TEXT NOT NULL, - platform VARCHAR(32), - lastseen DATETIME NOT NULL, - lang VARCHAR(8), - PRIMARY KEY(id), - FOREIGN KEY(userid) REFERENCES im_users(id), - UNIQUE INDEX devices_hash(hash) - )`); err != nil { - return err - } - - // Authentication records for the basic authentication scheme. - if _, err = tx.Exec( - `CREATE TABLE im_auth( - id INT NOT NULL AUTO_INCREMENT, - userid BIGINT NOT NULL, - authlvl INT NOT NULL, - secret VARCHAR(255) NOT NULL, - expires DATETIME, - PRIMARY KEY(id), - FOREIGN KEY(userid) REFERENCES im_users(id), - UNIQUE INDEX auth_userid(userid) - )`); err != nil { - return err - } - - // Topics - if _, err = tx.Exec( - `CREATE TABLE im_topics( - id INT NOT NULL AUTO_INCREMENT, - createdat DATETIME(3) NOT NULL, - updatedat DATETIME(3) NOT NULL, - state SMALLINT NOT NULL DEFAULT 0, - stateat DATETIME(3), - touchedat DATETIME(3), - name CHAR(25) NOT NULL, - usebt TINYINT DEFAULT 0, - owner BIGINT NOT NULL DEFAULT 0, - access JSON, - seqid BIGINT NOT NULL DEFAULT 0, - delid INT DEFAULT 0, - subcnt INT DEFAULT 0, - public JSON, - trusted JSON, - tags JSON, - aux JSON, - PRIMARY KEY(id), - UNIQUE INDEX topics_name(name), - INDEX topics_owner(owner), - INDEX topics_state_stateat(state, stateat), - INDEX topics_name_state_seqid(name, state, seqid) - )`); err != nil { - return err - } - - // Create system topic 'sys'. - if err = createSystemTopic(tx); err != nil { - return err - } - - // Indexed topic tags. - if _, err = tx.Exec( - `CREATE TABLE im_topictags( - id INT NOT NULL AUTO_INCREMENT, - topic CHAR(25) NOT NULL, - tag VARCHAR(96) NOT NULL, - PRIMARY KEY(id), - FOREIGN KEY(topic) REFERENCES im_topics(name), - INDEX topictags_tag(tag), - UNIQUE INDEX topictags_topic_tag(topic, tag) - )`); err != nil { - return err - } - - // Subscriptions - if _, err = tx.Exec( - `CREATE TABLE im_subscriptions( - id INT NOT NULL AUTO_INCREMENT, - createdat DATETIME(3) NOT NULL, - updatedat DATETIME(3) NOT NULL, - deletedat DATETIME(3), - userid BIGINT NOT NULL, - topic CHAR(25) NOT NULL, - delid INT DEFAULT 0, - recvseqid BIGINT DEFAULT 0, - readseqid BIGINT DEFAULT 0, - modewant CHAR(8), - modegiven CHAR(8), - private JSON, - PRIMARY KEY(id), - FOREIGN KEY(userid) REFERENCES im_users(id), - UNIQUE INDEX subscriptions_topic_userid(topic, userid), - INDEX subscriptions_topic(topic), - INDEX subscriptions_deletedat(deletedat), - INDEX subscriptions_userid_topic_deletedat(userid, topic, deletedat) - )`); err != nil { - return err - } - - // Messages - if _, err = tx.Exec( - `CREATE TABLE im_messages( - id INT NOT NULL AUTO_INCREMENT, - createdat DATETIME(3) NOT NULL, - updatedat DATETIME(3) NOT NULL, - deletedat DATETIME(3), - delid INT DEFAULT 0, - seqid BIGINT NOT NULL, - topic CHAR(25) NOT NULL,` + - "`from` BIGINT NOT NULL," + - `client_msg_id VARCHAR(64), - version INT NOT NULL DEFAULT 1, - recalled_at DATETIME(3), - recalled_by BIGINT, - head JSON, - content JSON, - PRIMARY KEY(id), - FOREIGN KEY(topic) REFERENCES im_topics(name), - UNIQUE INDEX messages_sender_client_mid(` + "`from`" + `, client_msg_id), - UNIQUE INDEX messages_topic_seqid(topic, seqid) - )`); err != nil { - return err - } - - if _, err = tx.Exec( - `CREATE TABLE im_message_revisions( - id BIGINT NOT NULL AUTO_INCREMENT, - createdat DATETIME(3) NOT NULL, - updatedat DATETIME(3) NOT NULL, - topic CHAR(25) NOT NULL, - seqid BIGINT NOT NULL, - version INT NOT NULL, - actor BIGINT NOT NULL, - head JSON, - content JSON, - reason VARCHAR(256) NOT NULL DEFAULT '', - PRIMARY KEY(id), - UNIQUE INDEX uk_im_message_revisions_version(topic, seqid, version) - )`); err != nil { - return err - } - - if err = createIMRelationTables(tx); err != nil { - return err - } - if err = createIMCallTables(tx); err != nil { - return err - } - if err = createIMWorkbenchTables(tx); err != nil { - return err - } - if err = createIMOrganizationTables(tx); err != nil { - return err - } - if err = createIMAttendanceTables(tx); err != nil { - return err - } - if err = createIMMeetingTables(tx); err != nil { - return err - } - if err = createIMReportTables(tx); err != nil { - return err - } - if err = createIMMailboxTables(tx); err != nil { - return err - } - if err = createIMUserProductTables(tx); err != nil { - return err - } - if err = ensureIMGroupBusinessTables(tx); err != nil { - return err - } - if err = ensureIMHTTPInfrastructureTables(tx); err != nil { - return err - } - if err = ensureIMAdminGovernanceTables(tx); err != nil { - return err - } - if err = ensureIMFileReviewTables(tx); err != nil { - return err - } - - // Deletion log - if _, err = tx.Exec( - `CREATE TABLE im_dellog( - id INT NOT NULL AUTO_INCREMENT, - topic CHAR(25) NOT NULL, - deletedfor BIGINT NOT NULL DEFAULT 0, - delid INT NOT NULL, - low INT NOT NULL, - hi INT NOT NULL, - PRIMARY KEY(id), - FOREIGN KEY(topic) REFERENCES im_topics(name), - INDEX dellog_topic_delid_deletedfor(topic,delid,deletedfor), - INDEX dellog_topic_deletedfor_low_hi(topic,deletedfor,low,hi), - INDEX dellog_deletedfor(deletedfor) - )`); err != nil { - return err - } - - // User credentials - if _, err = tx.Exec( - `CREATE TABLE im_credentials( - id INT NOT NULL AUTO_INCREMENT, - createdat DATETIME(3) NOT NULL, - updatedat DATETIME(3) NOT NULL, - deletedat DATETIME(3), - method VARCHAR(16) NOT NULL, - value VARCHAR(128) NOT NULL, - synthetic VARCHAR(192) NOT NULL, - userid BIGINT NOT NULL, - resp VARCHAR(255), - done TINYINT NOT NULL DEFAULT 0, - retries INT NOT NULL DEFAULT 0, - PRIMARY KEY(id), - FOREIGN KEY(userid) REFERENCES im_users(id), - UNIQUE credentials_uniqueness(synthetic) - )`); err != nil { - return err - } - - // Records of uploaded files. - // Don't add FOREIGN KEY on userid. It's not needed and it will break user deletion. - if _, err = tx.Exec( - `CREATE TABLE im_fileuploads( - id BIGINT NOT NULL, - createdat DATETIME(3) NOT NULL, - updatedat DATETIME(3) NOT NULL, - userid BIGINT, - status INT NOT NULL, - mimetype VARCHAR(255) NOT NULL, - size BIGINT NOT NULL, - etag VARCHAR(128), - location VARCHAR(2048) NOT NULL, - PRIMARY KEY(id), - INDEX fileuploads_status(status) - )`); err != nil { - return err - } - - // Links between uploaded files and the topics, users or messages they are attached to. - if _, err = tx.Exec( - `CREATE TABLE im_filemsglinks( - id INT NOT NULL AUTO_INCREMENT, - createdat DATETIME(3) NOT NULL, - fileid BIGINT NOT NULL, - msgid INT, - topic CHAR(25), - userid BIGINT, - PRIMARY KEY(id), - FOREIGN KEY(fileid) REFERENCES im_fileuploads(id) ON DELETE CASCADE, - FOREIGN KEY(msgid) REFERENCES im_messages(id) ON DELETE CASCADE, - FOREIGN KEY(topic) REFERENCES im_topics(name) ON DELETE CASCADE, - FOREIGN KEY(userid) REFERENCES im_users(id) ON DELETE CASCADE - )`); err != nil { - return err - } - - if _, err = tx.Exec( - `CREATE TABLE im_kvmeta(` + - "id BIGINT NOT NULL," + - "`key` VARCHAR(64) NOT NULL," + - "createdat DATETIME(3)," + - "`value` TEXT," + - "PRIMARY KEY(id)," + - "UNIQUE INDEX uk_im_kvmeta_key(`key`)," + - "INDEX kvmeta_createdat_key(createdat, `key`)" + - `)`); err != nil { - return err - } - versionID, err := nextSnowflakeID() - if err != nil { - return err - } - if _, err = tx.Exec("INSERT INTO im_kvmeta(id, `key`, `value`) VALUES(?,'version',?)", versionID, adpVersion); err != nil { - return err - } - - return tx.Commit() + return errors.New("mysql schema auto-creation is disabled; apply reviewed SQL manually before starting the service") } -// UpgradeDb upgrades the database, if necessary. +// UpgradeDb is intentionally schema-read-only. +// +// Automatic ALTER/CREATE upgrade steps are disabled. If the database version +// differs from the adapter version, prepare and apply a reviewed SQL migration, +// then update im_kvmeta.version as part of that migration. func (a *adapter) UpgradeDb() error { if _, err := a.GetDbVersion(); err != nil { return err } - if err := a.ensureMessageTopicSeqIndex(); err != nil { - return err - } - if err := a.ensureTopicNameUniqueIndex(); err != nil { - return err - } - if err := a.ensureTopicSeqMatchesMessages(); err != nil { - return err - } - if a.version == adpVersion { return nil } - return errors.New("mysql independent-database multitenancy requires a fresh schema; automatic upgrade from version " + - strconv.Itoa(a.version) + " to " + strconv.Itoa(adpVersion) + " is not supported") + return errors.New("mysql schema auto-upgrade is disabled; apply reviewed SQL manually to upgrade from version " + + strconv.Itoa(a.version) + " to " + strconv.Itoa(adpVersion)) } func (a *adapter) ensureMessageTopicSeqIndex() error { diff --git a/server/kafka_runtime.go b/server/kafka_runtime.go index 69be8e4..9d58a6a 100644 --- a/server/kafka_runtime.go +++ b/server/kafka_runtime.go @@ -24,6 +24,7 @@ const ( kafkaTopicMessagePersist = "im.message.persist" kafkaTopicGroupMessagePersist = "im.group.message.persist" kafkaTopicMessagePush = "im.message.push" + kafkaTopicWorkbenchExternal = "im.workbench.event" kafkaTopicFileSecurity = "im.file.security" kafkaTopicCacheInvalidate = "im.cache.invalidate" kafkaTopicAuditEvent = "im.audit.event" @@ -210,6 +211,7 @@ func kafkaTopics(overrides map[string]string) map[string]string { "message_persist": kafkaTopicMessagePersist, "group_message_persist": kafkaTopicGroupMessagePersist, "message_push": kafkaTopicMessagePush, + "workbench_external": kafkaTopicWorkbenchExternal, "file_security": kafkaTopicFileSecurity, "cache_invalidate": kafkaTopicCacheInvalidate, "audit_event": kafkaTopicAuditEvent, diff --git a/server/kafka_workbench_events.go b/server/kafka_workbench_events.go new file mode 100644 index 0000000..2c6f64c --- /dev/null +++ b/server/kafka_workbench_events.go @@ -0,0 +1,163 @@ +package main + +import ( + "context" + "encoding/json" + "strings" + "time" + + "github.com/segmentio/kafka-go" + "github.com/tinode/chat/server/logs" + "github.com/tinode/chat/server/store" + "github.com/tinode/chat/server/store/types" +) + +type kafkaExternalWorkbenchEvent struct { + EventID string `json:"event_id"` + TraceID string `json:"trace_id,omitempty"` + TenantID types.TenantID `json:"tenant_id,omitempty"` + TenantCode string `json:"tenant_code,omitempty"` + ActorUserID int64 `json:"actor_user_id"` + Module string `json:"module"` + Action string `json:"action"` + ResourceID string `json:"resource_id"` + TargetUserIDs []int64 `json:"target_user_ids,omitempty"` + Title string `json:"title,omitempty"` + Summary string `json:"summary,omitempty"` + OccurredAt time.Time `json:"occurred_at"` + Data map[string]any `json:"data,omitempty"` +} + +func startKafkaWorkbenchEventConsumer() { + startKafkaConsumer("workbench_external", handleKafkaWorkbenchEvent) +} + +func handleKafkaWorkbenchEvent(ctx context.Context, envelope kafkaEnvelope, msg kafka.Message) error { + payload, normalized, err := decodeKafkaWorkbenchEvent(envelope, msg) + if err != nil { + logs.Warn.Printf("kafka workbench event malformed: topic=%s offset=%d err=%v", msg.Topic, msg.Offset, err) + return err + } + tenantID, err := kafkaTenantIDFromEnvelope(normalized) + if err != nil { + return err + } + consume, claimed, err := beginKafkaConsumeForTopic(tenantID, normalized, "workbench_external") + if err != nil { + return err + } + if !claimed { + logs.Info.Printf("kafka workbench event skipped: event=%s group=%s status=%s", normalized.EventID, kafkaBus.config.ConsumerGroup, consume.Status) + return nil + } + if err = executeKafkaExternalWorkbenchEvent(tenantID, normalized, payload); err != nil { + reason := kafkaCommandErrorReason(err) + failed, markErr := store.HTTPInfrastructure.MarkKafkaConsumeFailed(tenantID, normalized.EventID, kafkaBus.config.ConsumerGroup, reason, err.Error(), time.Now().UTC().Round(time.Millisecond)) + if markErr != nil { + return markErr + } + if failed != nil && failed.RetryCount >= kafkaBus.config.MaxRetryCount { + return markKafkaDeadLetter(ctx, tenantID, normalized, failed, reason, err.Error()) + } + if failed != nil { + sleepKafkaRetryBackoff(failed.RetryCount) + } + return err + } + return store.HTTPInfrastructure.MarkKafkaConsumeSucceeded(tenantID, normalized.EventID, kafkaBus.config.ConsumerGroup, time.Now().UTC().Round(time.Millisecond)) +} + +func decodeKafkaWorkbenchEvent(envelope kafkaEnvelope, msg kafka.Message) (kafkaExternalWorkbenchEvent, kafkaEnvelope, error) { + var payload kafkaExternalWorkbenchEvent + if len(envelope.Payload) > 0 { + if err := json.Unmarshal(envelope.Payload, &payload); err != nil { + return payload, envelope, err + } + } else if err := json.Unmarshal(msg.Value, &payload); err != nil { + return payload, envelope, err + } + if strings.TrimSpace(payload.EventID) == "" { + payload.EventID = envelope.EventID + } + if strings.TrimSpace(payload.Action) == "" && envelope.EventType != "" { + payload.Action = strings.TrimPrefix(envelope.EventType, "workbench.") + } + if strings.TrimSpace(payload.EventID) == "" || strings.TrimSpace(payload.Action) == "" || strings.TrimSpace(payload.ResourceID) == "" { + return payload, envelope, types.ErrMalformed + } + if payload.OccurredAt.IsZero() { + payload.OccurredAt = time.Now().UTC().Round(time.Millisecond) + } + normalized := kafkaEnvelope{ + EventID: payload.EventID, + TraceID: firstNonEmpty(payload.TraceID, envelope.TraceID), + TenantCode: firstNonEmpty(payload.TenantCode, envelope.TenantCode), + TenantID: firstTenantID(payload.TenantID, envelope.TenantID), + SourceService: firstNonEmpty(envelope.SourceService, "im-workbench"), + TargetService: firstNonEmpty(envelope.TargetService, "im-server"), + EventType: "workbench." + payload.Action, + BizType: firstNonEmpty(payload.Module, envelope.BizType, "workbench"), + BizID: firstNonEmpty(payload.ResourceID, envelope.BizID), + OccurredAt: payload.OccurredAt, + Payload: msg.Value, + } + if payload.ActorUserID > 0 { + normalized.Actor = &kafkaActor{Type: "user", ID: types.Uid(payload.ActorUserID).UserId()} + } + return payload, normalized, nil +} + +func executeKafkaExternalWorkbenchEvent(tenantID types.TenantID, envelope kafkaEnvelope, payload kafkaExternalWorkbenchEvent) error { + if store.Security == nil { + return types.ErrUnsupported + } + actor := types.Uid(payload.ActorUserID) + data := types.KVMap{ + "module": payload.Module, + "resource_id": payload.ResourceID, + } + for k, v := range payload.Data { + data[k] = v + } + targets := compactWorkbenchTargets(payload.TargetUserIDs, actor) + for _, target := range targets { + targetID := target.UserId() + eventID := stableIMEventID("workbench", envelope.EventID, targetID) + if target != actor && payload.Title != "" { + if err := createIMSystemNotification(tenantID, target, stableIMEventID(eventID, "notification"), payload.Action, + payload.Title, payload.Summary, data); err != nil { + return err + } + } + if err := enqueueIMBusinessEvent(tenantID, eventID, "user", targetID, actor, payload.Action, data); err != nil { + return err + } + } + return nil +} + +func compactWorkbenchTargets(raw []int64, actor types.Uid) []types.Uid { + seen := make(map[types.Uid]bool, len(raw)+1) + targets := make([]types.Uid, 0, len(raw)+1) + for _, value := range raw { + uid := types.Uid(value) + if uid.IsZero() || seen[uid] { + continue + } + seen[uid] = true + targets = append(targets, uid) + } + if len(targets) == 0 && !actor.IsZero() { + targets = append(targets, actor) + } + return targets +} + +func firstTenantID(values ...types.TenantID) types.TenantID { + for _, value := range values { + if !value.IsZero() { + return value + } + } + return types.ZeroTenantID +} diff --git a/server/kafka_workbench_events_test.go b/server/kafka_workbench_events_test.go new file mode 100644 index 0000000..ccfe36c --- /dev/null +++ b/server/kafka_workbench_events_test.go @@ -0,0 +1,89 @@ +package main + +import ( + "testing" + "time" + + "github.com/segmentio/kafka-go" + "github.com/tinode/chat/server/store" + "github.com/tinode/chat/server/store/types" +) + +func TestKafkaTopicsIncludeExternalWorkbenchEvent(t *testing.T) { + topics := kafkaTopics(nil) + if topics["workbench_external"] != "im.workbench.event" { + t.Fatalf("unexpected workbench external topic: %q", topics["workbench_external"]) + } + if _, ok := topics["workbench_event"]; ok { + t.Fatal("legacy in-process workbench_event topic key must not be configured") + } +} + +func TestDecodeKafkaWorkbenchEventDirectPayload(t *testing.T) { + msg := kafka.Message{Value: []byte(`{ + "event_id":"wbe_1", + "trace_id":"trace-1", + "tenant_id":7, + "tenant_code":"default", + "actor_user_id":42, + "module":"meeting", + "action":"meeting.created", + "resource_id":"meeting-1", + "target_user_ids":[42,43], + "title":"会议通知", + "summary":"周会", + "occurred_at":"2026-09-13T10:00:00Z", + "data":{"meeting_id":"meeting-1"} + }`)} + + payload, envelope, err := decodeKafkaWorkbenchEvent(kafkaEnvelope{}, msg) + if err != nil { + t.Fatalf("decodeKafkaWorkbenchEvent returned error: %v", err) + } + if payload.EventID != "wbe_1" || payload.ActorUserID != 42 || len(payload.TargetUserIDs) != 2 { + t.Fatalf("unexpected payload: %#v", payload) + } + if envelope.EventID != "wbe_1" || envelope.EventType != "workbench.meeting.created" || + envelope.BizType != "meeting" || envelope.BizID != "meeting-1" { + t.Fatalf("unexpected normalized envelope: %#v", envelope) + } + if envelope.Actor == nil || envelope.Actor.ID != types.Uid(42).UserId() { + t.Fatalf("unexpected actor: %#v", envelope.Actor) + } + if envelope.OccurredAt.IsZero() || envelope.TenantID != types.TenantID(7) { + t.Fatalf("unexpected occurrence or tenant: %#v", envelope) + } +} + +func TestExecuteKafkaExternalWorkbenchEventCreatesNotificationForNonActor(t *testing.T) { + oldSecurity := store.Security + defer func() { store.Security = oldSecurity }() + + security := &testSecurityStore{} + store.Security = security + + payload := kafkaExternalWorkbenchEvent{ + EventID: "wbe_1", + TenantID: 7, + ActorUserID: 42, + Module: "meeting", + Action: "meeting.created", + ResourceID: "meeting-1", + TargetUserIDs: []int64{42, 43}, + Title: "会议通知", + Summary: "周会", + OccurredAt: time.Date(2026, 9, 13, 10, 0, 0, 0, time.UTC), + Data: map[string]any{"meeting_id": "meeting-1"}, + } + envelope := kafkaEnvelope{EventID: payload.EventID} + + if err := executeKafkaExternalWorkbenchEvent(7, envelope, payload); err != nil { + t.Fatalf("executeKafkaExternalWorkbenchEvent returned error: %v", err) + } + if len(security.notifications) != 1 { + t.Fatalf("expected one notification for non-actor target, got %#v", security.notifications) + } + if security.notifications[0].UserID != types.Uid(43) || security.notifications[0].EventType != "meeting.created" { + t.Fatalf("unexpected notification: %#v", security.notifications[0]) + } +} diff --git a/server/main.go b/server/main.go index 9e71119..055019a 100644 --- a/server/main.go +++ b/server/main.go @@ -750,6 +750,7 @@ func main() { startKafkaConfigEventConsumer() startKafkaCacheInvalidateConsumer() startKafkaMessagePersistConsumer() + startKafkaWorkbenchEventConsumer() startKafkaDeadLetterConsumer() // Start accepting cluster traffic. diff --git a/server/meetingim.conf b/server/meetingim.conf index b4af5c5..53fc2b1 100644 --- a/server/meetingim.conf +++ b/server/meetingim.conf @@ -137,6 +137,7 @@ "message_persist": "im.message.persist", "group_message_persist": "im.group.message.persist", "message_push": "im.message.push", + "workbench_external": "im.workbench.event", "file_security": "im.file.security", "cache_invalidate": "im.cache.invalidate", "audit_event": "im.audit.event", diff --git a/server/tinode.conf b/server/tinode.conf index b4af5c5..53fc2b1 100644 --- a/server/tinode.conf +++ b/server/tinode.conf @@ -137,6 +137,7 @@ "message_persist": "im.message.persist", "group_message_persist": "im.group.message.persist", "message_push": "im.message.push", + "workbench_external": "im.workbench.event", "file_security": "im.file.security", "cache_invalidate": "im.cache.invalidate", "audit_event": "im.audit.event",