diff --git a/docs/IM-bootstrap入口与未读数改造需求文档.md b/docs/IM-bootstrap入口与未读数改造需求文档.md new file mode 100644 index 0000000..e88c727 --- /dev/null +++ b/docs/IM-bootstrap入口与未读数改造需求文档.md @@ -0,0 +1,446 @@ +# IM bootstrap 入口与未读数改造需求文档 + +## 1. 背景 + +当前 `GET /v1/im/bootstrap` 已返回登录后的功能开关、权限、工作台模块、系统 Topic 和安全摘要。前端同事在接入时提出以下问题: + +1. 新朋友未处理数、群列表、文件助手和系统消息入口是否都由 bootstrap 控制显示。 +2. 未读数字段分别从哪里读取。 +3. 工作台是否需要总未读数。 +4. 文件助手和系统消息是否完全依赖 WebSocket Topic 推送,还是 bootstrap 也要返回入口和初始未读。 +5. 文档需要给出完整响应示例,避免入口字段和里层结构继续口头确认。 + +现有前后端对接文档已有基础约定: + +1. 登录后客户端必须执行 `sub me`,从 `meta.sub` 获取每个会话 Topic 的 `seq/read/recv`。 +2. 普通聊天会话未读数使用 `max(seq - read, 0)`。 +3. `bootstrap` 返回固定入口、功能开关、权限和系统 Topic。 +4. `workbench.modules[].unread_count` 已在结构中定义,但当前实现固定为 0。 + +缺口在于:bootstrap 没有明确返回通讯录入口、系统消息入口、文件助手入口、工作台总未读和业务 badge;文档也没有明确哪些未读来自 Topic,哪些未读来自业务表统计。 + +## 2. 目标 + +1. 明确 `bootstrap` 是登录后首屏入口配置和初始 badge 的聚合接口。 +2. 明确 Topic 类未读和业务类未读的分工。 +3. 为前端提供稳定响应结构,减少后续沟通成本。 +4. 保持聊天实时链路轻量:普通聊天、群聊、文件助手和系统消息的实时增量仍通过 Tinode WebSocket Topic 更新。 +5. 为后续工作台 badge、系统通知和好友申请的实时事件留出兼容空间。 + +## 3. 非目标 + +1. 不把所有会话列表塞入 bootstrap;普通会话列表仍通过 `sub me` 和会话搜索/列表能力获取。 +2. 不用 bootstrap 替代 WebSocket 增量更新。 +3. 不让客户端根据本地按钮状态绕过服务端权限校验。 +4. 不要求本期实现复杂实时聚合服务;可先返回准确初始值,再通过已有 Topic 或系统事件增量更新。 + +## 4. 概念定义 + +| 名称 | 含义 | +| --- | --- | +| 入口 entry | 登录后首页、通讯录、工作台等固定可见入口配置 | +| Topic 未读 | 由 Tinode Topic 的 `seq/read/recv` 推导出的消息未读 | +| 业务 badge | 不能用 Topic `seq/read` 表达的业务待处理数量,例如新朋友待处理申请 | +| 工作台模块未读 | `modules[].unread_count`,表示单个工作台模块的业务未读 | +| 工作台总未读 | 所有可见工作台模块未读汇总,供底部 Tab 或一级入口展示 | +| 系统消息 | 服务端可信系统通知,展示页可查 HTTP 列表,实时通知可走系统 Topic 或系统事件 | +| 文件助手 | 用户自己的固定会话入口,用于收藏/文件转发/个人文件消息;若实现为 Topic,则未读遵循 Topic 规则 | + +## 5. 未读数来源规则 + +### 5.1 Topic 类未读 + +以下未读原则上来自 Tinode Topic: + +1. 单聊会话。 +2. 群聊会话。 +3. 系统消息入口,如果系统消息以 `system_topic.topic` 作为可信 Topic 承载。 +4. 文件助手入口,如果文件助手以固定 Topic 承载。 + +计算规则: + +```text +topic_unread_count = max(seq - read, 0) +``` + +客户端登录后必须执行: + +```text +WebSocket Upgrade -> hi -> token login -> sub me -> get sub/data +``` + +初始会话列表从 `meta.sub` 获取,后续通过 `{data}`、`{meta}`、`{pres}`、`{info}` 和 `note read/recv` 更新本地状态。 + +### 5.2 业务类未读 + +以下未读不适合用 Topic `seq/read` 计算,应由 HTTP 业务接口或 bootstrap 返回: + +1. 新朋友待处理申请数。 +2. 工作台模块待处理数,例如会议、日报、月报、总经理信箱。 +3. 未来的审批、公告、任务等非聊天会话类 badge。 + +业务类未读的真源在业务表,bootstrap 返回登录后的初始聚合值;后续增量可通过系统事件通知前端刷新或局部更新。 + +## 6. bootstrap 响应结构扩展 + +### 6.1 顶层新增字段 + +`GET /v1/im/bootstrap` 的 `data` 建议新增: + +```json +{ + "entry_badges": { + "contacts_total_unread": 2, + "new_friends_pending_count": 2, + "groups_unread_count": 0, + "file_assistant_unread_count": 0, + "system_unread_count": 0, + "workbench_total_unread": 5 + }, + "entries": { + "new_friends": { + "enabled": true, + "route": "/contacts/new-friends", + "unread_count": 2 + }, + "groups": { + "enabled": true, + "route": "/contacts/groups", + "unread_count": 0 + }, + "file_assistant": { + "enabled": true, + "route": "/chat/file-assistant", + "topic": "slf", + "unread_count": 0, + "unread_source": "topic" + }, + "system_messages": { + "enabled": true, + "route": "/chat/system", + "topic": "sys", + "unread_count": 0, + "unread_source": "business" + } + } +} +``` + +字段说明: + +| 字段 | 类型 | 说明 | +| --- | --- | --- | +| `entry_badges.contacts_total_unread` | int | 通讯录一级入口红点总数;本期可等于 `new_friends_pending_count` | +| `entry_badges.new_friends_pending_count` | int | 当前用户待处理入站好友申请数 | +| `entry_badges.groups_unread_count` | int | 群入口业务未读;若只是群聊消息未读,则可为 0,由会话列表负责 | +| `entry_badges.file_assistant_unread_count` | int | 文件助手入口初始未读 | +| `entry_badges.system_unread_count` | int | 系统消息入口初始未读 | +| `entry_badges.workbench_total_unread` | int | 工作台可见模块未读总和 | +| `entries.*.enabled` | bool | 是否展示入口 | +| `entries.*.route` | string | 前端内部路由,只允许白名单内部路径 | +| `entries.*.topic` | string | 若入口对应 Topic,则返回 Topic 名;没有 Topic 时省略 | +| `entries.*.unread_count` | int | 当前入口初始未读或待处理数 | +| `entries.*.unread_source` | string | `topic` 或 `business`,用于前端判断后续更新来源 | + +### 6.2 unread_source 约定 + +| unread_source | 含义 | 后续更新方式 | +| --- | --- | --- | +| `topic` | 未读来自 Topic `seq/read` | WebSocket Topic 增量和 `note read/recv` | +| `business` | 未读来自业务表统计 | 业务 HTTP 刷新或系统事件 | +| `none` | 入口无未读概念 | 不展示 badge | + +`new_friends.unread_source` 可省略,默认按 `business` 处理。若为了统一,也可以显式返回: + +```json +{ + "enabled": true, + "route": "/contacts/new-friends", + "unread_count": 2, + "unread_source": "business" +} +``` + +## 7. 工作台 unread 规则 + +当前 `modules` 结构保留: + +```json +{ + "code": "meetings", + "name": "会议通知", + "icon_file_id": "", + "route": "/meetings", + "unread_count": 3, + "enabled": true, + "permission": "enabled" +} +``` + +新增规则: + +1. `modules[].unread_count` 是模块级业务未读。 +2. `entry_badges.workbench_total_unread` 是所有可见且 `enabled=true` 模块的未读总和。 +3. 隐藏或无权限模块不计入总未读。 +4. 后续增量事件使用 `workbench.badge.updated`: + +```json +{ + "type": "system_event", + "event_type": "workbench.badge.updated", + "event_id": "evt_...", + "created_at": "2026-08-20T12:00:00.000Z", + "params": { + "module_code": "meetings", + "unread_count": 4, + "workbench_total_unread": 6, + "workbench_version": 1787153039 + } +} +``` + +前端收到后只更新对应模块和工作台总未读;如果 `workbench_version` 大于本地版本,应重新调用 `GET /v1/im/workbench` 或 `GET /v1/im/bootstrap`。 + +## 8. 系统消息规则 + +系统消息有两个视角: + +1. 列表查询:`GET /v1/im/system-notifications?cursor=` 返回历史通知列表。 +2. 实时增量:通过服务端可信系统事件推送。 + +bootstrap 返回: + +```json +{ + "system_topic": { + "topic": "sys", + "type": "system" + }, + "entries": { + "system_messages": { + "enabled": true, + "route": "/chat/system", + "topic": "sys", + "unread_count": 0, + "unread_source": "business" + } + } +} +``` + +实现要求: + +1. 普通用户不能向系统 Topic 发布可信系统消息。 +2. 客户端不能把普通用户伪造的 `system_event` 渲染为可信通知。 +3. 本期系统消息未读使用 `im_system_notifications.read_at` 统计,`unread_source` 固定为 `business`,并由服务端返回准确 `system_unread_count`。 + +本期约定:系统消息入口保留 `topic:"sys"` 作为入口标识和后续事件通道标识,初始未读数以系统通知业务表为准。若后续确认普通用户不能订阅 `sys`,应改为每用户系统通知 Topic 或纯 HTTP 通知列表 + business badge。 + +## 9. 文件助手规则 + +文件助手需要先明确产品实现形态: + +### 9.1 Topic 形态 + +如果文件助手是用户自己的固定 Topic,例如 `slf` 或专用 `file_assistant_topic`: + +```json +{ + "file_assistant": { + "enabled": true, + "route": "/chat/file-assistant", + "topic": "slf", + "unread_count": 0, + "unread_source": "topic" + } +} +``` + +未读通过 `sub me` 中该 Topic 的 `seq/read` 计算。 + +### 9.2 纯入口形态 + +如果文件助手只是本地入口,不对应消息 Topic: + +```json +{ + "file_assistant": { + "enabled": true, + "route": "/files/assistant", + "unread_count": 0, + "unread_source": "none" + } +} +``` + +此时不展示消息未读,只展示入口。 + +本期建议采用 Topic 形态,便于复用消息、文件、转发、未读和多端同步能力。 + +## 10. 通讯录入口规则 + +通讯录页包含以下固定入口: + +1. 新朋友。 +2. 群聊。 +3. 文件助手。 +4. 系统消息。 + +bootstrap 控制入口展示和初始 badge。具体列表仍由各自接口负责: + +| 入口 | 展示控制 | 初始 badge | 列表数据 | +| --- | --- | --- | --- | +| 新朋友 | `entries.new_friends.enabled` | `new_friends_pending_count` | `GET /v1/im/friend-requests` | +| 群聊 | `entries.groups.enabled` | 群业务 badge,群消息未读走 Topic | 群列表接口或 `sub me` 中群 Topic | +| 文件助手 | `entries.file_assistant.enabled` | Topic 或 none | Topic 消息或文件助手专用接口 | +| 系统消息 | `entries.system_messages.enabled` | Topic 或 business | `GET /v1/im/system-notifications` | + +## 11. 完整 bootstrap 示例 + +```json +{ + "code": 0, + "data": { + "features": { + "create_group": true, + "message_edit": true, + "message_recall": true, + "scan_qr": true, + "system_notifications": true, + "user_search": true + }, + "permissions": { + "create_group": true, + "scan_qr": true, + "user_search": true + }, + "entry_badges": { + "contacts_total_unread": 2, + "new_friends_pending_count": 2, + "groups_unread_count": 0, + "file_assistant_unread_count": 0, + "system_unread_count": 1, + "workbench_total_unread": 5 + }, + "entries": { + "new_friends": { + "enabled": true, + "route": "/contacts/new-friends", + "unread_count": 2, + "unread_source": "business" + }, + "groups": { + "enabled": true, + "route": "/contacts/groups", + "unread_count": 0, + "unread_source": "business" + }, + "file_assistant": { + "enabled": true, + "route": "/chat/file-assistant", + "topic": "slf", + "unread_count": 0, + "unread_source": "topic" + }, + "system_messages": { + "enabled": true, + "route": "/chat/system", + "topic": "sys", + "unread_count": 1, + "unread_source": "business" + } + }, + "modules": [ + { + "code": "organization", + "enabled": true, + "icon_file_id": "", + "name": "组织架构", + "permission": "enabled", + "route": "/organization", + "unread_count": 0 + }, + { + "code": "meetings", + "enabled": true, + "icon_file_id": "", + "name": "会议通知", + "permission": "enabled", + "route": "/meetings", + "unread_count": 3 + } + ], + "workbench_version": 1787153039, + "system_topic": { + "topic": "sys", + "type": "system" + }, + "message_policy": { + "edit_window_seconds": 300, + "recall_window_seconds": 120, + "supported_actions": ["copy", "quote", "delete", "recall", "edit"] + }, + "security": { + "realname": { + "allow_skip": true, + "remind_after": "24h", + "required": false, + "status": "unverified" + } + } + }, + "request_id": "req_xxx", + "server_time": "2026-08-20T12:00:00.000Z" +} +``` + +## 12. 前端处理规则 + +1. bootstrap 成功后,先渲染固定入口和初始 badge。 +2. 随后完成 WebSocket `sub me`,用 Topic `seq/read` 覆盖 Topic 类入口未读。 +3. `unread_source=topic` 的入口以后以 Topic 状态为准。 +4. `unread_source=business` 的入口以后以业务接口刷新或系统事件为准。 +5. 切租户、退出登录、切账号时必须清空本地入口和 badge 缓存。 +6. 前端不得把 `tenant_id`、内部数据库 ID 或未在白名单中的 route 写入跳转。 + +## 13. 服务端实现要求 + +1. `serveIMBootstrap` 增加 `entry_badges` 和 `entries`。 +2. `serveIMBootstrap` 的入口数据必须按当前 Token 的租户和用户权限裁剪。 +3. `route` 必须走服务端白名单,不能从数据库任意透传外部 URL。 +4. 新朋友待处理数按当前租户、当前用户、入站、待处理状态统计。 +5. 工作台 `modules[].unread_count` 从业务统计或模块 unread provider 获取,不再固定为 0。 +6. `workbench_total_unread` 只汇总当前用户可见模块。 +7. 文件助手和系统消息的 `topic` 必须是当前租户和当前用户有权限访问的 Topic。 +8. 如果某入口当前未实现,仍可返回 `enabled:false`,前端隐藏入口。 + +## 14. 后端建议实现顺序 + +1. 扩展 bootstrap 响应结构,先返回固定入口和 0 badge,确保前端结构稳定。 +2. 增加新朋友待处理数统计。 +3. 增加工作台模块 unread provider 和总未读。 +4. 明确文件助手 Topic,补齐入口 topic 和未读来源。 +5. 明确系统消息未读来源;若 `sys` 不适合普通用户订阅,则改为业务 unread。 +6. 增加系统事件 `workbench.badge.updated` 和入口 badge 更新事件。 +7. 更新前后端对接文档,把本需求中的响应示例合并到 bootstrap 章节。 + +## 15. 验收用例 + +1. 新用户登录调用 bootstrap,返回 `entries`、`entry_badges`、`modules`、`workbench_version`。 +2. 有 2 条待处理好友申请时,`new_friends_pending_count=2`,`entries.new_friends.unread_count=2`。 +3. 没有待处理好友申请时,新朋友入口仍展示但 unread 为 0。 +4. 工作台有两个可见模块未读分别为 3 和 2 时,`workbench_total_unread=5`。 +5. 隐藏模块未读不计入 `workbench_total_unread`。 +6. `unread_source=topic` 的入口在 `sub me` 返回后可用 Topic `seq/read` 覆盖初始 unread。 +7. 切换租户后 bootstrap 返回另一租户入口和 badge,本地旧 badge 不复用。 +8. 普通用户构造外部 route 或系统消息字段不能影响 bootstrap 返回。 +9. 未实现入口返回 `enabled:false` 时前端隐藏入口。 +10. 接口响应字段保持向后兼容,旧客户端忽略新增字段仍可正常工作。 + +## 16. 待确认问题 + +1. 文件助手最终使用 `slf` Topic,还是新增专用文件助手 Topic。 +2. 普通用户系统消息入口是否继续使用 `sys`,还是改为每用户系统通知 Topic 或纯 HTTP 通知列表。 +3. 群聊入口是否需要业务未读,还是完全由群 Topic 会话列表承担。 +4. 工作台每个模块的 unread provider 由 IM 直接查库,还是由 HotGo/业务库提供统计。 +5. 是否需要新增统一 badge 增量事件,例如 `entry.badge.updated`。 diff --git a/docs/IM-前后端对接文档.md b/docs/IM-前后端对接文档.md index 0c13c5c..e23382f 100644 --- a/docs/IM-前后端对接文档.md +++ b/docs/IM-前后端对接文档.md @@ -519,7 +519,7 @@ X-Tinode-APIKey: <预置的普通 API Key> - 登录后发送 `sub me`,再通过 `get` 拉取 `sub` 元数据;每项使用 `topic`、`public`、`private`、`updated`、`seq/read/recv` 计算名称、摘要和未读数。 - 消息增量通过各 Topic 的 `{data}` 更新;未读数为 `max(seq-read,0)`,回执以服务端游标为准,不能只用本地计数。 -- `GET /v1/im/bootstrap` 返回固定入口、功能开关、权限和系统 Topic;搜索调用 `GET /v1/im/search/conversations?q=&cursor=`。 +- `GET /v1/im/bootstrap` 返回固定入口、功能开关、权限、系统 Topic、`entry_badges` 和 `entries`;搜索调用 `GET /v1/im/search/conversations?q=&cursor=`。 - 本地缓存按 `tenant_code + user_id` 隔离。切租户、退出或账号变化时不得复用另一身份的会话摘要。 ### 2.2 消息-无网络状态.png @@ -890,7 +890,7 @@ X-Tinode-APIKey: <预置的普通 API Key> - 好友列表:`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` 返回。列表缓存以租户和用户隔离。 +- 新朋友、群列表、文件助手和系统消息入口由 `GET /v1/im/bootstrap` 的 `entries` 控制显示,初始红点由 `entry_badges` 和各入口 `unread_count` 返回。新朋友待处理数使用业务统计;文件助手当前返回 `topic:"slf", unread_source:"topic"`;系统消息当前返回 `topic:"sys", unread_source:"business"`,未读来自系统通知业务表。列表缓存以租户和用户隔离。 ### 5.2 新朋友.png @@ -1031,7 +1031,7 @@ X-Tinode-APIKey: <预置的普通 API Key> **状态:Tinode 群 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":"...","member_user_ids":[],"avatar_file_id":"..."}`;原子校验租户群数上限和群成员上限,返回 `topic`。 +- 创建:`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` 决定;创建成功或角色变化后通过系统事件刷新,不由前端自行移动分组。 ### 6.2 空白页.png @@ -1570,6 +1570,7 @@ X-Tinode-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` 的工作台模块。 - 服务端按当前 Token 的租户、组织角色和功能开关裁剪;前端只接受白名单内部 `route`,禁止直接执行服务端任意 URL。 - 未读数通过 `workbench.badge.updated` 系统事件增量更新;切租户必须重新加载,不复用另一企业工作台。 diff --git a/server/db/adapter.go b/server/db/adapter.go index ba244be..98e5e15 100644 --- a/server/db/adapter.go +++ b/server/db/adapter.go @@ -432,10 +432,12 @@ type TenantBusinessAdapter interface { TenantSystemNotificationCreate(t.TenantID, *t.SystemNotification) error TenantSystemNotificationList(t.TenantID, t.Uid, string, int) ([]t.SystemNotification, string, error) + TenantSystemNotificationUnreadCount(t.TenantID, t.Uid) (int, error) TenantSystemNotificationMarkRead(t.TenantID, t.Uid, string, time.Time) error TenantFriendRequestCreate(t.TenantID, *t.FriendRequest) (*t.FriendRequest, error) TenantFriendRequestList(t.TenantID, t.Uid, string, string, string, int) ([]t.FriendRequestListItem, string, error) + TenantFriendRequestPendingCount(t.TenantID, t.Uid) (int, error) TenantFriendRequestLatestBetween(t.TenantID, t.Uid, t.Uid) (*t.FriendRequest, error) TenantFriendRequestAccept(t.TenantID, string, t.Uid, time.Time) (*t.FriendRequest, error) TenantFriendRequestResolve(t.TenantID, string, t.Uid, string, time.Time) (*t.FriendRequest, error) diff --git a/server/db/mysql/tenant_adapter.go b/server/db/mysql/tenant_adapter.go index 06a4b34..ef68532 100644 --- a/server/db/mysql/tenant_adapter.go +++ b/server/db/mysql/tenant_adapter.go @@ -6221,6 +6221,32 @@ func (a *adapter) TenantSystemNotificationList(id t.TenantID, uid t.Uid, cursor return notifications, nextCursor, nil } +func (a *adapter) TenantSystemNotificationUnreadCount(id t.TenantID, uid t.Uid) (int, error) { + if !a.tenantScopedDB { + tenantAdp, err := a.routedTenantAdapter(id) + if err != nil { + return 0, err + } + if tenantAdp != a { + return tenantAdp.TenantSystemNotificationUnreadCount(id, uid) + } + } + if err := validTenant(id); err != nil || uid.IsZero() { + return 0, t.ErrMalformed + } + ctx, cancel := a.getContext() + if cancel != nil { + defer cancel() + } + var count int + if err := a.db.GetContext(ctx, &count, + `SELECT COUNT(*) FROM im_system_notifications WHERE user_id=? AND read_at IS NULL`, + decodeUID(uid)); err != nil { + return 0, err + } + return count, nil +} + func (a *adapter) TenantSystemNotificationMarkRead(id t.TenantID, uid t.Uid, eventID string, readAt time.Time) error { if !a.tenantScopedDB { tenantAdp, err := a.routedTenantAdapter(id) @@ -6430,6 +6456,32 @@ func (a *adapter) TenantFriendRequestList(id t.TenantID, uid t.Uid, direction, s return items, nextCursor, nil } +func (a *adapter) TenantFriendRequestPendingCount(id t.TenantID, uid t.Uid) (int, error) { + if !a.tenantScopedDB { + tenantAdp, err := a.routedTenantAdapter(id) + if err != nil { + return 0, err + } + if tenantAdp != a { + return tenantAdp.TenantFriendRequestPendingCount(id, uid) + } + } + if err := validTenant(id); err != nil || uid.IsZero() { + return 0, t.ErrMalformed + } + ctx, cancel := a.getContext() + if cancel != nil { + defer cancel() + } + var count int + if err := a.db.GetContext(ctx, &count, + `SELECT COUNT(*) FROM im_friend_requests WHERE target_id=? AND status=? AND expires_at>?`, + decodeUID(uid), t.FriendRequestPending, t.TimeNow()); err != nil { + return 0, err + } + return count, nil +} + func (a *adapter) TenantFriendRequestLatestBetween(id t.TenantID, uid1, uid2 t.Uid) (*t.FriendRequest, error) { if !a.tenantScopedDB { tenantAdp, err := a.routedTenantAdapter(id) diff --git a/server/im_http.go b/server/im_http.go index de576ae..d241e0d 100644 --- a/server/im_http.go +++ b/server/im_http.go @@ -391,6 +391,7 @@ type imGroupCreateRequest struct { Name string `json:"name"` MemberUserIDs []string `json:"member_user_ids,omitempty"` AvatarFileID string `json:"avatar_file_id,omitempty"` + Description string `json:"description,omitempty"` } type imGroupPatchRequest struct { @@ -1340,6 +1341,12 @@ func serveIMBootstrap(wrt http.ResponseWriter, req *http.Request) { return } } + modulePayloads := workbenchModulePayloads(modules) + entryBadges, entries, err := bootstrapEntryPayloads(principal.TenantID, principal.Uid, modulePayloads) + if err != nil { + writeIMRelationError(wrt, req, err) + return + } writeIMSuccess(wrt, req, map[string]any{ "features": map[string]any{ "create_group": true, @@ -1354,7 +1361,9 @@ func serveIMBootstrap(wrt http.ResponseWriter, req *http.Request) { "user_search": true, "scan_qr": true, }, - "modules": workbenchModulePayloads(modules), + "entry_badges": entryBadges, + "entries": entries, + "modules": modulePayloads, "workbench_version": workbenchVersion, "system_topic": map[string]any{ "topic": "sys", @@ -3293,12 +3302,20 @@ func serveIMGroupCreate(wrt http.ResponseWriter, req *http.Request) { writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", nil) return } + description := strings.TrimSpace(body.Description) + if len([]rune(description)) > 200 { + writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "description"}) + return + } topicName := genTopicName() now := time.Now().UTC().Round(time.Millisecond) public := map[string]any{"name": name, "version": 1} if avatar := strings.TrimSpace(body.AvatarFileID); avatar != "" { public["avatar_file_id"] = avatar } + if description != "" { + public["description"] = description + } topic := &types.Topic{ ObjHeader: types.ObjHeader{Id: topicName}, TenantID: principal.TenantID, @@ -3312,6 +3329,7 @@ func serveIMGroupCreate(wrt http.ResponseWriter, req *http.Request) { OwnerUserID: principal.Uid, Name: name, AvatarFileID: strings.TrimSpace(body.AvatarFileID), + Description: description, GroupStatus: types.GroupStatusActive, MemberUploadFiles: true, MemberPrivateChat: true, @@ -5921,7 +5939,7 @@ func workbenchModulePayloads(modules []types.WorkbenchModule) []map[string]any { "name": module.Name, "icon_file_id": module.IconFileID, "route": module.Route, - "unread_count": 0, + "unread_count": module.UnreadCount, "enabled": module.Enabled, "permission": module.Permission, }) @@ -5929,6 +5947,77 @@ func workbenchModulePayloads(modules []types.WorkbenchModule) []map[string]any { return items } +func bootstrapEntryPayloads(tenantID types.TenantID, uid types.Uid, modules []map[string]any) (map[string]any, map[string]any, error) { + newFriendsPending := 0 + if store.Relations != nil { + count, err := store.Relations.CountPendingFriendRequests(tenantID, uid) + if err != nil && !errors.Is(err, types.ErrNotFound) && !errors.Is(err, types.ErrUnsupported) { + return nil, nil, err + } + if err == nil { + newFriendsPending = count + } + } + + systemUnread := 0 + if store.Security != nil { + count, err := store.Security.CountUnreadSystemNotifications(tenantID, uid) + if err != nil && !errors.Is(err, types.ErrNotFound) && !errors.Is(err, types.ErrUnsupported) { + return nil, nil, err + } + if err == nil { + systemUnread = count + } + } + + workbenchTotalUnread := 0 + for _, module := range modules { + enabled, _ := module["enabled"].(bool) + if !enabled { + continue + } + workbenchTotalUnread += intFromAny(module["unread_count"]) + } + + entryBadges := map[string]any{ + "contacts_total_unread": newFriendsPending, + "new_friends_pending_count": newFriendsPending, + "groups_unread_count": 0, + "file_assistant_unread_count": 0, + "system_unread_count": systemUnread, + "workbench_total_unread": workbenchTotalUnread, + } + entries := map[string]any{ + "new_friends": map[string]any{ + "enabled": true, + "route": "/contacts/new-friends", + "unread_count": newFriendsPending, + "unread_source": "business", + }, + "groups": map[string]any{ + "enabled": true, + "route": "/contacts/groups", + "unread_count": 0, + "unread_source": "business", + }, + "file_assistant": map[string]any{ + "enabled": true, + "route": "/chat/file-assistant", + "topic": "slf", + "unread_count": 0, + "unread_source": "topic", + }, + "system_messages": map[string]any{ + "enabled": true, + "route": "/chat/system", + "topic": "sys", + "unread_count": systemUnread, + "unread_source": "business", + }, + } + return entryBadges, entries, nil +} + func organizationDepartmentPayloads(departments []types.OrganizationDepartment) []map[string]any { items := make([]map[string]any, 0, len(departments)) for _, department := range departments { diff --git a/server/im_http_test.go b/server/im_http_test.go index 879d627..bed3a5c 100644 --- a/server/im_http_test.go +++ b/server/im_http_test.go @@ -92,6 +92,9 @@ type testRelationsStore struct { deleted bool canSend *bool reason string + pendingCount int + countTenant types.TenantID + countUser types.Uid friends []types.FriendListItem friendRequests []types.FriendRequestListItem searchResults []types.UserSearchItem @@ -794,6 +797,12 @@ func (s *testRelationsStore) ListFriendRequests(_ types.TenantID, _ types.Uid, _ return s.friendRequests, "", nil } +func (s *testRelationsStore) CountPendingFriendRequests(tenantID types.TenantID, uid types.Uid) (int, error) { + s.countTenant = tenantID + s.countUser = uid + return s.pendingCount, nil +} + func (s *testRelationsStore) ResolveFriendRequest(_ types.TenantID, requestID string, actor types.Uid, status string, handledAt time.Time) (*types.FriendRequest, error) { return &types.FriendRequest{ TenantID: 7, @@ -1695,15 +1704,23 @@ func TestServeIMBootstrapIncludesHomeCapabilities(t *testing.T) { expectIMBearer(t, ctrl, "token-1", 7, 42) oldSecurity := store.Security oldWorkbench := store.Workbench - security := &testSecurityStore{realName: &types.RealNameVerification{Status: "verified"}} + oldRelations := store.Relations + security := &testSecurityStore{ + realName: &types.RealNameVerification{Status: "verified"}, + unreadNotifications: 4, + } + relations := &testRelationsStore{pendingCount: 2} workbench := &testWorkbenchStore{version: 9, modules: []types.WorkbenchModule{ - {Code: "organization", Name: "组织架构", Route: "/organization", Enabled: true, Permission: "enabled"}, - {Code: "external", Name: "外链", Route: "https://example.com", Enabled: true, Permission: "enabled"}, + {Code: "organization", Name: "组织架构", Route: "/organization", UnreadCount: 3, Enabled: true, Permission: "enabled"}, + {Code: "meetings", Name: "会议通知", Route: "/meetings", UnreadCount: 5, Enabled: false, Permission: "enabled"}, + {Code: "external", Name: "外链", Route: "https://example.com", UnreadCount: 9, Enabled: true, Permission: "enabled"}, }} store.Security = security + store.Relations = relations store.Workbench = workbench defer func() { store.Security = oldSecurity + store.Relations = oldRelations store.Workbench = oldWorkbench }() @@ -1723,6 +1740,12 @@ func TestServeIMBootstrapIncludesHomeCapabilities(t *testing.T) { if security.realNameTenant != 7 || security.realNameUser != 42 { t.Fatalf("bootstrap realname lookup scope: tenant=%d uid=%s", security.realNameTenant, security.realNameUser) } + if security.unreadTenant != 7 || security.unreadUser != 42 { + t.Fatalf("bootstrap system unread lookup scope: tenant=%d uid=%s", security.unreadTenant, security.unreadUser) + } + if relations.countTenant != 7 || relations.countUser != 42 { + t.Fatalf("bootstrap friend request count scope: tenant=%d uid=%s", relations.countTenant, relations.countUser) + } if !workbench.calledOnce || workbench.tenantID != 7 || workbench.uid != 42 { t.Fatalf("bootstrap workbench lookup scope: store=%#v", workbench) } @@ -1730,9 +1753,32 @@ func TestServeIMBootstrapIncludesHomeCapabilities(t *testing.T) { t.Fatalf("unexpected bootstrap workbench version: %#v", data) } modules := data["modules"].([]any) - if len(modules) != 1 || modules[0].(map[string]any)["code"] != "organization" { + if len(modules) != 2 || modules[0].(map[string]any)["code"] != "organization" || modules[1].(map[string]any)["code"] != "meetings" { t.Fatalf("unexpected bootstrap modules: %#v", modules) } + if modules[0].(map[string]any)["unread_count"].(float64) != 3 { + t.Fatalf("unexpected bootstrap module unread: %#v", modules[0]) + } + badges := data["entry_badges"].(map[string]any) + if badges["new_friends_pending_count"].(float64) != 2 || + badges["contacts_total_unread"].(float64) != 2 || + badges["system_unread_count"].(float64) != 4 || + badges["workbench_total_unread"].(float64) != 3 { + t.Fatalf("unexpected bootstrap badges: %#v", badges) + } + entries := data["entries"].(map[string]any) + newFriends := entries["new_friends"].(map[string]any) + if newFriends["route"] != "/contacts/new-friends" || newFriends["unread_count"].(float64) != 2 || newFriends["unread_source"] != "business" { + t.Fatalf("unexpected new friends entry: %#v", newFriends) + } + systemMessages := entries["system_messages"].(map[string]any) + if systemMessages["topic"] != "sys" || systemMessages["unread_count"].(float64) != 4 || systemMessages["unread_source"] != "business" { + t.Fatalf("unexpected system messages entry: %#v", systemMessages) + } + fileAssistant := entries["file_assistant"].(map[string]any) + if fileAssistant["topic"] != "slf" || fileAssistant["unread_source"] != "topic" { + t.Fatalf("unexpected file assistant entry: %#v", fileAssistant) + } realname := data["security"].(map[string]any)["realname"].(map[string]any) if realname["status"] != "verified" { t.Fatalf("unexpected bootstrap realname status: %#v", realname) @@ -2747,7 +2793,8 @@ func TestServeIMGroupCreate(t *testing.T) { subs := mock_store.NewMockSubsPersistenceInterface(ctrl) store.Topics = topics store.Subs = subs - store.GroupBusiness = &testGroupBusinessStore{} + groupBusiness := &testGroupBusinessStore{} + store.GroupBusiness = groupBusiness defer func() { store.Store = oldStore store.Topics = oldTopics @@ -2760,7 +2807,7 @@ func TestServeIMGroupCreate(t *testing.T) { TenantID: 7, Owner: types.Uid(42).String(), SubCnt: 2, - Public: map[string]any{"name": "New Group", "avatar_file_id": "file-1", "version": 1}, + Public: map[string]any{"name": "New Group", "avatar_file_id": "file-1", "description": "Project discussion", "version": 1}, }, nil) subs.EXPECT().Get(types.TenantID(7), "grpnewgroup", types.Uid(42), false).Return(&types.Subscription{ TenantID: 7, @@ -2770,7 +2817,7 @@ func TestServeIMGroupCreate(t *testing.T) { ModeGiven: types.ModeCFull, }, nil) - req := httptest.NewRequest(http.MethodPost, "/v1/im/groups", bytes.NewBufferString(`{"name":"New Group","member_user_ids":["usrAgAAAAAAAAA"],"avatar_file_id":"file-1"}`)) + req := httptest.NewRequest(http.MethodPost, "/v1/im/groups", bytes.NewBufferString(`{"name":"New Group","description":"Project discussion","member_user_ids":["usrAgAAAAAAAAA"],"avatar_file_id":"file-1"}`)) req.Header.Set("Authorization", "Bearer token-1") rec := httptest.NewRecorder() @@ -2780,9 +2827,12 @@ func TestServeIMGroupCreate(t *testing.T) { t.Fatalf("status: got %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String()) } data := decodeIMHTTPResponse(t, rec).Data.(map[string]any) - if data["topic"] != "grpnewgroup" || data["name"] != "New Group" { + if data["topic"] != "grpnewgroup" || data["name"] != "New Group" || data["description"] != "Project discussion" { t.Fatalf("unexpected group create response: %#v", data) } + if groupBusiness.settings == nil || groupBusiness.settings.Description != "Project discussion" { + t.Fatalf("group settings missing description: %#v", groupBusiness.settings) + } } func TestServeIMGroupDetailPatchSettingsProfileAndLeave(t *testing.T) { diff --git a/server/store/store.go b/server/store/store.go index 950fc2e..0260b88 100644 --- a/server/store/store.go +++ b/server/store/store.go @@ -2073,6 +2073,7 @@ func validUserProfilePatch(patch types.UserProfilePatch) bool { type RelationsPersistenceInterface interface { CreateFriendRequest(tenantID types.TenantID, req *types.FriendRequest) (*types.FriendRequest, error) ListFriendRequests(tenantID types.TenantID, uid types.Uid, direction, status, cursor string, limit int) ([]types.FriendRequestListItem, string, error) + CountPendingFriendRequests(tenantID types.TenantID, uid types.Uid) (int, error) ResolveFriendRequest(tenantID types.TenantID, requestID string, actor types.Uid, status string, handledAt time.Time) (*types.FriendRequest, error) ListFriends(tenantID types.TenantID, uid types.Uid, query, initial, cursor string, limit int) ([]types.FriendListItem, string, error) GetRelationshipContext(tenantID types.TenantID, userID, peerID types.Uid, topic string) (*types.RelationshipContext, error) @@ -2182,6 +2183,17 @@ func (relationsMapper) ListFriendRequests(tenantID types.TenantID, uid types.Uid return tenantAdp.TenantFriendRequestList(tenantID, uid, direction, status, cursor, limit) } +func (relationsMapper) CountPendingFriendRequests(tenantID types.TenantID, uid types.Uid) (int, error) { + if uid.IsZero() { + return 0, types.ErrMalformed + } + tenantAdp, err := tenantBusinessAdapter(tenantID) + if err != nil { + return 0, err + } + return tenantAdp.TenantFriendRequestPendingCount(tenantID, uid) +} + func (relationsMapper) ResolveFriendRequest(tenantID types.TenantID, requestID string, actor types.Uid, status string, handledAt time.Time) (*types.FriendRequest, error) { if strings.TrimSpace(requestID) == "" || actor.IsZero() || (status != types.FriendRequestAccepted && status != types.FriendRequestRejected) { return nil, types.ErrMalformed @@ -3115,6 +3127,7 @@ type UserSecurityPersistenceInterface interface { SetYouthMode(tenantID types.TenantID, uid types.Uid, enabled bool, policyVersion int, now time.Time) (*types.YouthModeSettings, error) CreateSystemNotification(tenantID types.TenantID, notification *types.SystemNotification) error ListSystemNotifications(tenantID types.TenantID, uid types.Uid, cursor string, limit int) ([]types.SystemNotification, string, error) + CountUnreadSystemNotifications(tenantID types.TenantID, uid types.Uid) (int, error) MarkSystemNotificationRead(tenantID types.TenantID, uid types.Uid, eventID string, readAt time.Time) error } @@ -3245,6 +3258,17 @@ func (userSecurityMapper) ListSystemNotifications(tenantID types.TenantID, uid t return tenantAdp.TenantSystemNotificationList(tenantID, uid, cursor, limit) } +func (userSecurityMapper) CountUnreadSystemNotifications(tenantID types.TenantID, uid types.Uid) (int, error) { + if uid.IsZero() { + return 0, types.ErrMalformed + } + tenantAdp, err := tenantBusinessAdapter(tenantID) + if err != nil { + return 0, err + } + return tenantAdp.TenantSystemNotificationUnreadCount(tenantID, uid) +} + func (userSecurityMapper) MarkSystemNotificationRead(tenantID types.TenantID, uid types.Uid, eventID string, readAt time.Time) error { tenantAdp, err := tenantBusinessAdapter(tenantID) if err != nil { diff --git a/server/store/types/workbench.go b/server/store/types/workbench.go index 797f803..0ed89d1 100644 --- a/server/store/types/workbench.go +++ b/server/store/types/workbench.go @@ -3,15 +3,16 @@ package types import "time" type WorkbenchModule struct { - ID int64 `db:"id"` - Code string `db:"code"` - Name string `db:"name"` - IconFileID string `db:"icon_file_id"` - Route string `db:"route"` - SortOrder int `db:"sort_order"` - UnreadKey string `db:"unread_key"` - Enabled bool `db:"enabled"` - Permission string `db:"permission"` - CreatedAt time.Time `db:"created_at"` - UpdatedAt time.Time `db:"updated_at"` + ID int64 `db:"id"` + Code string `db:"code"` + Name string `db:"name"` + IconFileID string `db:"icon_file_id"` + Route string `db:"route"` + SortOrder int `db:"sort_order"` + UnreadKey string `db:"unread_key"` + UnreadCount int `db:"unread_count"` + Enabled bool `db:"enabled"` + Permission string `db:"permission"` + CreatedAt time.Time `db:"created_at"` + UpdatedAt time.Time `db:"updated_at"` } diff --git a/server/user_verification_test.go b/server/user_verification_test.go index 60f9ce0..5c27b7d 100644 --- a/server/user_verification_test.go +++ b/server/user_verification_test.go @@ -227,6 +227,7 @@ type testSecurityStore struct { attempts int lockedUntil *time.Time notifications []types.SystemNotification + unreadNotifications int readNotificationID string realNameTenant types.TenantID realNameUser types.Uid @@ -235,6 +236,8 @@ type testSecurityStore struct { listCursor string listLimit int nextCursor string + unreadTenant types.TenantID + unreadUser types.Uid readTenant types.TenantID readUser types.Uid } @@ -368,6 +371,21 @@ func (s *testSecurityStore) ListSystemNotifications(tenantID types.TenantID, uid return s.notifications, s.nextCursor, s.err } +func (s *testSecurityStore) CountUnreadSystemNotifications(tenantID types.TenantID, uid types.Uid) (int, error) { + s.unreadTenant = tenantID + s.unreadUser = uid + if s.unreadNotifications != 0 { + return s.unreadNotifications, s.err + } + count := 0 + for _, notification := range s.notifications { + if notification.ReadAt == nil { + count++ + } + } + return count, s.err +} + func (s *testSecurityStore) MarkSystemNotificationRead(tenantID types.TenantID, uid types.Uid, eventID string, readAt time.Time) error { s.readTenant = tenantID s.readUser = uid