diff --git a/docs/IM-前后端对接文档.md b/docs/IM-前后端对接文档.md index ece50ff..e2df47b 100644 --- a/docs/IM-前后端对接文档.md +++ b/docs/IM-前后端对接文档.md @@ -212,6 +212,18 @@ WebSocket Upgrade 阶段由 `server/hdl_websock.go` 先调用 `checkAPIKey`。 订阅成功后才允许 `{pub}`、`note read/recv` 等会话操作。断线恢复必须重新执行 `WebSocket Upgrade -> hi -> token login -> sub me -> 恢复 Topic`,同一连接内不得跳过 `hi` 或切换企业码。 +##### 0.5.1.1 WebSocket 心跳与在线状态 + +当前 IM 长连接心跳使用 WebSocket 协议层 Ping/Pong,不使用业务 JSON 消息做保活。服务端每 30 秒发送一次 WebSocket Ping 帧(`pingPeriod=30s`),并期望客户端在 45 秒读超时窗口内返回 Pong(`idleSessionTimeout=45s`);如果没有收到 Pong,或底层读写失败,服务端会关闭当前 Session。 + +客户端要求: + +1. 使用系统 WebSocket 库时,必须确认库会自动回复 Pong;如果库不会自动回复,需要在收到 Ping 时显式发送 Pong。 +2. 不要把 `{"note":{"what":"kp"}}` 当作心跳。`kp/kpa/kpv` 只是“正在输入/正在录音/正在视频输入”等 UI 状态通知,不落库,也不能替代 WebSocket Pong。 +3. 连接关闭或报错后,客户端本地应立即把 IM 长连接视为离线,并按指数退避重连。重连成功后完整执行 `WebSocket Upgrade -> hi -> token login -> sub me -> sub topics -> get data/del`,用本地最后完整持久化的 `seq + 1` 增量补消息。 +4. 服务端收到任意客户端上行 JSON 消息或 WebSocket Pong 后,都会刷新在线状态。当前在线状态写入 Redis,默认 TTL 为 120 秒,用于好友在线状态、群成员在线状态等展示。 +5. iOS/Android 进入后台后系统可能挂起 WebSocket。后台场景以推送唤醒和前台重连补偿为主,不要求客户端在后台无限重连。 + #### 0.5.2 Long Polling 兼容流程:先取得的是 sid 如果客户端无法使用 WebSocket,可以使用现有 `/v0/channels/lp`。该模式确实需要“先请求取得一个值”,但取得的是短期 Session ID `sid`,API Key 仍然必须由客户端预先持有。 @@ -357,6 +369,8 @@ X-MeetingIM-APIKey: <预置的普通 API Key> | 删除单条/多条消息 | `{"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}}}` | +`kp/kpa/kpv` 只表示输入态、录音态、视频输入态等短暂 UI 状态,不能作为长连接心跳。长连接保活必须依赖 0.5.1.1 中的 WebSocket Ping/Pong。 + ##### 0.5.4.5 Topic 资料、个人群设置和标签 | 场景 | 客户端发送 | 服务端返回/下行示例 | @@ -590,7 +604,7 @@ X-MeetingIM-APIKey: <预置的普通 API Key> | 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` | +| `GET /v0/file/s/{file_id}` | Header `X-MeetingIM-Auth: token `,可带 `Range: bytes=0-` | 对象存储模式下鉴权通过后返回 302 到短期签名 URL;客户端跟随 Location 获取文件 | ### 0.6 Topic 与产品消息 @@ -1396,7 +1410,7 @@ X-MeetingIM-APIKey: <预置的普通 API Key> **状态:新增产品通讯录接口。** -- 好友列表:`GET /v1/im/friends?query=&initial=&cursor=`,返回 `user_id,display_name,remark,avatar_file_id,initial,online_visibility`;显示名优先级为备注名、昵称、账号。 +- 好友列表:`GET /v1/im/friends?query=&initial=&cursor=`,返回 `user_id,display_name,remark,avatar_file_id,initial,online,online_visibility`;好友展示名统一读取 `display_name`,服务端按当前登录人计算,优先级为备注名、昵称、账号。`remark` 仅用于备注编辑页回显,`nickname` 表示对方自己设置的昵称,不应直接作为好友列表或单聊标题的最终展示名。 - 分页:`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"`,未读来自系统通知业务表。列表缓存以租户和用户隔离。 @@ -1458,7 +1472,7 @@ X-MeetingIM-APIKey: <预置的普通 API Key> **状态:新增。** -- `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`。 +- `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`。个人资料页头部和单聊标题统一使用 `display_name`,需要展示对方原始昵称时再使用 `nickname`。 - 查看该用户二维码调用 `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` 展示,真正呼叫时再次鉴权。隐藏字段必须从响应中省略,不能只由前端遮挡。 @@ -2218,10 +2232,10 @@ X-MeetingIM-APIKey: <预置的普通 API Key> **状态:新增。** -- `GET /v1/im/workbench` 返回 `modules[{code,name,icon_file_id,route,unread_count,enabled,permission}]`、布局版本和企业配置。 -- `GET /v1/im/bootstrap` 返回 `entry_badges.workbench_total_unread`,用于工作台一级入口总红点;总数只汇总当前用户可见且 `enabled=true` 的工作台模块。 +- `GET /v1/im/workbench` 返回 `modules[{code,name,icon_file_id,route,sort_order,unread_count,enabled,permission}]`、布局版本和企业配置;服务端按 `sort_order ASC` 返回,默认顺序为打卡、月报、日报、部门框架、举报信箱、会议通知、会议笔记。 +- `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"` 业务事件下发。切租户必须重新加载,不复用另一企业工作台。 +- 工作台一级总未读以 `GET /v1/im/bootstrap` 和 `GET /v1/im/workbench` 返回为准;当前只有会议通知可接收移动端通知,其他工作台模块在进入页面时主动拉取数据刷新。若后续启用 `workbench.badge.updated`,应按 0.5.4.6 的 `info.what="im"` 业务事件下发。切租户必须重新加载,不复用另一企业工作台。 ### 9.2 部门组织框架.png @@ -2251,7 +2265,7 @@ X-MeetingIM-APIKey: <预置的普通 API Key> **状态:新增。** -- 创建:`POST /v1/im/meetings`,Body `{"title":"...","start_at":"...","end_at":"...","location":"...","attendee_user_ids":[],"reminder_minutes":[]}`。 +- 创建:`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`;前端收到后刷新会议列表或会议详情。除操作者外,当前会写入系统通知列表。 @@ -2364,7 +2378,174 @@ X-MeetingIM-APIKey: <预置的普通 API Key> - 文案使用 9.12 配置中的 `notice{title,content,updated_at}`,由租户后台配置;客户端不得硬编码实名/匿名承诺。 - 提交按钮前必须展示当前版本文案;请求可携带 `notice_version`,服务端发现关键政策已变更时返回 `NOTICE_RECONFIRM_REQUIRED`。 -### 9.14 接口示例 +### 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}`。对象存储模式下服务端鉴权通过后返回 302 到短期签名 URL,客户端跟随 `Location` 获取文件内容。 +- 服务端不得返回长期有效的公共下载 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` 断点续传;对象存储模式下客户端跟随 302 `Location`。 +- 下载任务保存 `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-` | 对象存储模式下鉴权通过后返回 302 到短期签名 URL;客户端跟随 Location 获取文件内容 | + +### 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 联调快速查阅。除特别说明外: @@ -2374,11 +2555,11 @@ X-MeetingIM-APIKey: <预置的普通 API Key> - 失败响应统一包裹为 `{"code":30000,"reason":"MALFORMED_REQUEST","message":"...","details":{},"request_id":"...","server_time":"..."}`。 - 标注“待实现/需修复”的接口是产品目标协议,前端可按示例准备,但联调前以后端发布状态为准。 -#### 9.14.1 工作台、组织、打卡、会议、日报、月报、信箱 +#### 9.15.1 工作台、组织、打卡、会议、日报、月报、信箱 | 接口 | 输入示例 | 成功 `data` 示例 | | --- | --- | --- | -| `GET /v1/im/workbench` | Query only | `{"layout_version":1,"modules":[{"code":"organization","name":"组织架构","icon_file_id":"","route":"/organization","unread_count":0,"enabled":true,"permission":"enabled"},{"code":"attendance","name":"打卡考勤","icon_file_id":"","route":"/attendance","unread_count":0,"enabled":true,"permission":"enabled"}],"enterprise":{"tenant_code":"default","feature_flags":{"organization":true,"attendance":true,"meetings":true}}}` | +| `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":""}` | @@ -2390,7 +2571,7 @@ X-MeetingIM-APIKey: <预置的普通 API Key> | `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":"线上","attendee_user_ids":["usr..."],"reminder_minutes":[10]}` | `{"meeting_id":"mtg_1","title":"周会","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}` | +| `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}` | @@ -2655,7 +2836,7 @@ X-MeetingIM-APIKey: <预置的普通 API Key> | `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":"fcm","token":"push_token","platform":"android","app_version":"1.0.0"}` | `{"device_id":"device_1","provider":"fcm","platform":"android","app_version":"1.0.0","updated_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. 反诈安全、举报与意见反馈 @@ -2749,12 +2930,12 @@ X-MeetingIM-APIKey: <预置的普通 API Key> | 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、11.4~11.5 | +| 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 | 工作台、组织、会议、会议笔记、日报、月报、信箱 | 第 9 章 | | P2 | 隐私、青少年模式、反诈、举报、反馈 | 第 10、11 章 | ### 12.3 前后端联调最低验收用例 diff --git a/docs/MESSAGE-PROTOCOL.md b/docs/MESSAGE-PROTOCOL.md index ee209ec..c07ebd9 100644 --- a/docs/MESSAGE-PROTOCOL.md +++ b/docs/MESSAGE-PROTOCOL.md @@ -41,17 +41,16 @@ ServerComMessage 2. `sess.bkgTimer` — 后台超时定时器 3. `sess.stop` — 停止信号 4. `sess.detach` — 取消订阅 -5. `ticker` — Ping 帧(每 `pongWait * 9/10` 发送一次) +5. `ticker` — Ping 帧(每 30s 发送一次) ### Keep-Alive | 参数 | 值 | 说明 | |------|-----|------| | `writeWait` | 10s | 写超时 | -| `pongWait` | `idleSessionTimeout` | 期望收到 Pong 的时间(默认 180s) | -| `pingPeriod` | `pongWait * 9/10` | Ping 间隔(约 162s) | -| `pingPeriod` | `pongWait * 9/10` | Ping 间隔(约 162s) | -| 心跳探测 | `0x31` / `0x30` | 网络探针(单字节) | +| `pongWait` | `idleSessionTimeout` | 期望收到 Pong 的时间(当前默认 45s) | +| `pingPeriod` | 30s | WebSocket Ping 间隔 | +| 心跳探测 | WebSocket Ping/Pong | 当前 IM 长连接保活以协议层 Ping/Pong 为准;业务 `note kp/kpa/kpv` 不能替代 Pong | ### 缓冲区设置 diff --git a/server/hdl_websock.go b/server/hdl_websock.go index 99bdc8a..c55cc03 100644 --- a/server/hdl_websock.go +++ b/server/hdl_websock.go @@ -28,7 +28,7 @@ const ( pongWait = idleSessionTimeout // Send pings to peer with this period. Must be less than pongWait. - pingPeriod = (pongWait * 9) / 10 + pingPeriod = 30 * time.Second ) func (sess *Session) closeWS() { diff --git a/server/im_http.go b/server/im_http.go index e12ac91..ee1b667 100644 --- a/server/im_http.go +++ b/server/im_http.go @@ -9206,12 +9206,23 @@ func relationshipContextPayload(ctx *types.RelationshipContext) map[string]any { if ctx.RequestID != "" { data["request_id"] = ctx.RequestID } + if ctx.Remark != "" { + data["remark"] = ctx.Remark + } if ctx.Peer != nil { - data["peer"] = map[string]any{ - "user_id": ctx.PeerID.UserId(), - "public": ctx.Peer.Public, - "trusted": ctx.Peer.Trusted, + peer := userSummaryPayload(ctx.Peer, ctx.Remark) + peer["public"] = ctx.Peer.Public + peer["trusted"] = ctx.Peer.Trusted + data["peer"] = peer + if displayName, ok := peer["display_name"]; ok { + data["display_name"] = displayName } + if avatarFileID, ok := peer["avatar_file_id"]; ok { + data["avatar_file_id"] = avatarFileID + } + } + if _, ok := data["display_name"]; !ok { + data["display_name"] = ctx.PeerID.UserId() } return data } @@ -9366,14 +9377,14 @@ func userSummaryPayload(user *types.User, remark string) map[string]any { userID = user.Id } data := map[string]any{"user_id": userID} - if remark != "" { - data["display_name"] = remark - } if public, ok := user.Public.(map[string]any); ok { copyUserPublicSummary(data, public) } else if public, ok := user.Public.(types.KVMap); ok { copyUserPublicSummary(data, map[string]any(public)) } + if remark != "" { + data["display_name"] = remark + } if _, ok := data["display_name"]; !ok { data["display_name"] = userID } diff --git a/server/im_http_test.go b/server/im_http_test.go index aa97c56..828d3c6 100644 --- a/server/im_http_test.go +++ b/server/im_http_test.go @@ -1250,7 +1250,18 @@ func (s *testRelationsStore) DeleteFriend(tenantID types.TenantID, userID, peerI } func (s *testRelationsStore) UpdateFriendRemark(tenantID types.TenantID, userID, peerID types.Uid, remark string, now time.Time) (*types.Friendship, error) { - return &types.Friendship{TenantID: tenantID, UserID: userID, PeerID: peerID, Status: types.RelationshipFriend, Remark: remark, Topic: userID.P2PName(peerID), CreatedAt: now, UpdatedAt: now}, nil + friendship := &types.Friendship{TenantID: tenantID, UserID: userID, PeerID: peerID, Status: types.RelationshipFriend, Remark: remark, Topic: userID.P2PName(peerID), CreatedAt: now, UpdatedAt: now} + s.context = &types.RelationshipContext{ + TenantID: tenantID, + UserID: userID, + PeerID: peerID, + Topic: friendship.Topic, + Relationship: types.RelationshipFriend, + CanSend: true, + Remark: remark, + Peer: &types.User{ObjHeader: types.ObjHeader{Id: peerID.String()}, TenantID: tenantID, Public: map[string]any{"display_name": "Ming"}}, + } + return friendship, nil } func (s *testRelationsStore) SearchUsers(_ types.TenantID, _ types.Uid, _, _, _ string, _ int) ([]types.UserSearchItem, string, error) { @@ -7203,6 +7214,13 @@ func TestServeIMFriendRemark(t *testing.T) { if data["relationship"] != types.RelationshipFriend || data["topic"] == "" { t.Fatalf("unexpected friend remark response: %#v", data) } + if data["display_name"] != "小明" || data["remark"] != "小明" { + t.Fatalf("friend remark should drive display name: %#v", data) + } + peerData := data["peer"].(map[string]any) + if peerData["display_name"] != "小明" { + t.Fatalf("peer display name should use remark: %#v", data) + } } func TestServeIMFriendDeleteReturnsCannotSendContext(t *testing.T) { diff --git a/server/main.go b/server/main.go index bdb8e5f..dbd3c95 100644 --- a/server/main.go +++ b/server/main.go @@ -71,7 +71,7 @@ const ( minSupportedVersion = "0.20" // idleSessionTimeout defines duration of being idle before terminating a session. - idleSessionTimeout = time.Second * 55 + idleSessionTimeout = time.Second * 45 // idleMasterTopicTimeout defines now long to keep master topic alive after the last session detached. idleMasterTopicTimeout = time.Second * 4 // Same as above but shut down the proxy topic sooner. Otherwise master topic would be kept alive for too long. diff --git a/server/store/store.go b/server/store/store.go index f760d4c..4d2d6e4 100644 --- a/server/store/store.go +++ b/server/store/store.go @@ -3411,6 +3411,14 @@ func relationshipContext(tenantAdp adapter.TenantBusinessAdapter, tenantID types if cachedFriend, err := cachedFriendContains(tenantID, userID, peerID); err != nil { logs.Warn.Printf("cache friend[%s:%s]: read failed: %v", userID.UserId(), peerID.UserId(), err) } else if cachedFriend { + if friendship, err := tenantAdp.TenantFriendshipGet(tenantID, userID, peerID); err != nil { + logs.Warn.Printf("friendship[%s:%s]: read failed after cache hit: %v", userID.UserId(), peerID.UserId(), err) + } else if friendship != nil && friendship.Status == types.RelationshipFriend { + ctx.Remark = friendship.Remark + if friendship.Topic != "" { + ctx.Topic = friendship.Topic + } + } ctx.Relationship = types.RelationshipFriend ctx.CanSend = true if ctx.Topic == "" { @@ -3424,6 +3432,7 @@ func relationshipContext(tenantAdp adapter.TenantBusinessAdapter, tenantID types cacheFriendOneWay(tenantID, userID, peerID) ctx.Relationship = types.RelationshipFriend ctx.CanSend = true + ctx.Remark = friendship.Remark if ctx.Topic == "" { ctx.Topic = friendship.Topic } diff --git a/server/store/types/relations.go b/server/store/types/relations.go index ac7f4a2..139d315 100644 --- a/server/store/types/relations.go +++ b/server/store/types/relations.go @@ -59,6 +59,7 @@ type RelationshipContext struct { Topic string `json:"topic"` Relationship string `json:"relationship"` CanSend bool `json:"can_send"` + Remark string `json:"remark,omitempty"` RequestID string `json:"request_id,omitempty"` PendingStatus string `json:"pending_status,omitempty"` Peer *User `json:"peer,omitempty"`