diff --git a/.gitignore b/.gitignore index e184820..843e01f 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,7 @@ /.codex-gocache/ /.github/ /.github/ +/docs/third/三方服务依赖信息.md +/docs/test/im_third_party_file_safety_test_plan.md + +docs/*.zip diff --git a/chell要点.txt b/chell要点.txt index 824eeee..4684b4b 100644 --- a/chell要点.txt +++ b/chell要点.txt @@ -18,11 +18,23 @@ 多通过配置的方式处理 前端不要写死 一定要考虑好群聊的推送问题 -离线和在线状态存到redis里面 -离线的不推 +离线和在线状态存到redis里面 这个是一个难点 离线的我们的服务不推 走极光的推送 +在线的直接ws推 多用开关 +校验营业执照 + +第三方 +短信 +kyc +营业执照 +声网 Agora 音视频服务 +极光推送 离线 Push 服务 +oss对象存储 +文件安全扫描 / 内容安全服务 + + admin后台群操作通过kafka投递到c端 c端通过ws传到客户端 记录先放到Kafka、 再异步落库。用考虑重复落库的可能 @@ -33,7 +45,67 @@ admin后台群操作通过kafka投递到c端 c端通过ws传到客户端 5. 有 OpenIM 或类似 IM 项目经验优先。 1.注册增加邀请码 (非必填 )不填邀请码可以正常注册。只要手机号符合11位格式就行 -2.验证码验证作为开关 关闭的情况下的话后台不想第三方发送请求,前端正常ui的倒计时,但是收不到验证码 -3.正常不填邀请码,用真实手机号注册的,且后台开关打开的情况下,可以收到验证码的流程也能正常注册 +2.验证码验证作为开关 关闭的情况下的话后台不向短信服务商发送请求,前端正常ui的倒计时,但是收不到验证码 +3.正常不填邀请码,用真实手机号注册的,且后台开关打开的情况下,可以收到验证码的流程也能正常注册 + +这个是现在的情况 +后台开关在系统设置 字典管理 增加一个系统枚举 系统开关的 ID = 171 + +用户的唯一标识用user_id来标记,然后新增user_name,也就是用户名字段 + + +看看对接文档1.4的注册页面 增加逻辑 1.注册邀请码 (非必填 )不填邀请码可以正常注册。用真实手机号注册的,且后台发送注册验证码的开关打开的情况下,可以收到验证码的流程也能正常注册 +2.发送注册验证码的开关关闭的情况下,后台不向短信服务商发送请求,前端正常ui的倒计时,但是收不到验证码,此时填了邀请码,且邀请码校验合格的话,没有验证码仍然可以注册 + +我看了很多users相关的表 你总结一下分别是什么作用 +你看看手机号是如何存的 +im_users的 tags 有啥用 存的啥 + +昵称用中文实现 + +对一样的,因为给好友改了备注,所以上面就会显示,但是昵称是好友,本身自己起的网名,如果对方没有起,系统会默认给一个 + +用户名和手机号注册的话 如果验证码开关是开的 以验证码为准 如果没开 就以邀请码流程 用户名不能忽略 要保存 + +前后端对接文档 补充 请求和响应的json + +文档的覆盖测试 + +发的网站功能的复制 +web地址:https://web.xcwykk.top/ +管理后台地址:https://admin.adfahi.cn +账号:heiyue +密码:Heiyue123 +测试名额:100 + +群操作 +文件操作 + +redis的接入 性能提升 +kafka的接入 异步操作 幂等性 + +弱网 +QUIC传输技术 +日志框架的接入 + + +重中之重。加密 + 接口鉴权 防止被扫 日志要记录下来 每个小时告警 + + + + + + + + + + + + + + + + + + -用户的唯一标识用user_id来标记,然后新增user_name,也就是用户名字段 \ No newline at end of file diff --git a/docker/docker-compose/cluster.yml b/docker/docker-compose/cluster.yml index b0549c5..1b0e470 100644 --- a/docker/docker-compose/cluster.yml +++ b/docker/docker-compose/cluster.yml @@ -22,6 +22,11 @@ x-exporter: x-tinode-env-vars: &tinode-env-vars "STORE_USE_ADAPTER": "mysql" "PPROF_URL": "/pprof" + "REDIS_ADDR": "host.docker.internal:56379" + "REDIS_PASSWORD": "HMTJzkm.h>Y+E9ep" + "REDIS_DB": 0 + "REDIS_KEY_PREFIX": "im" + "REDIS_GROUP_FANOUT_ENABLED": "true" # You can provide your own tinode config by setting EXT_CONFIG env var and binding your configuration file to # "EXT_CONFIG": "/etc/tinode/tinode.conf" "WAIT_FOR": "mysql:3306" diff --git a/docker/docker-compose/single-instance.yml b/docker/docker-compose/single-instance.yml index d38cb8b..69eaa55 100644 --- a/docker/docker-compose/single-instance.yml +++ b/docker/docker-compose/single-instance.yml @@ -17,6 +17,11 @@ x-tinode: x-tinode-env-vars: &tinode-env-vars "STORE_USE_ADAPTER": "mysql" "PPROF_URL": "/pprof" + "REDIS_ADDR": "host.docker.internal:56379" + "REDIS_PASSWORD": "HMTJzkm.h>Y+E9ep" + "REDIS_DB": 0 + "REDIS_KEY_PREFIX": "im" + "REDIS_GROUP_FANOUT_ENABLED": "true" # You can provide your own tinode config by setting EXT_CONFIG env var and binding your configuration file to # "EXT_CONFIG": "/etc/tinode/tinode.conf" "WAIT_FOR": "mysql:3306" diff --git a/docker/tinode/Dockerfile b/docker/tinode/Dockerfile index 20a6686..e312c27 100644 --- a/docker/tinode/Dockerfile +++ b/docker/tinode/Dockerfile @@ -52,6 +52,22 @@ ENV DEFAULT_COUNTRY_CODE=US # The MySQL DSN connection. ENV MYSQL_DSN='root@tcp(mysql)/tinode?parseTime=true&collation=utf8mb4_0900_ai_ci' +ENV REDIS_ADDR= +ENV REDIS_USERNAME= +ENV REDIS_PASSWORD= +ENV REDIS_DB=0 +ENV REDIS_KEY_PREFIX=im +ENV REDIS_DIAL_TIMEOUT_MS=2000 +ENV REDIS_READ_TIMEOUT_MS=2000 +ENV REDIS_WRITE_TIMEOUT_MS=2000 +ENV REDIS_PENDING_TTL_SECONDS=120 +ENV REDIS_DONE_TTL_SECONDS=86400 +ENV REDIS_FAILED_TTL_SECONDS=600 +ENV REDIS_HOT_MESSAGE_TTL_SECONDS=86400 +ENV REDIS_PRESENCE_TTL_SECONDS=120 +ENV REDIS_CACHE_TTL_SECONDS=300 +ENV REDIS_SHORT_STATE_TTL_SECONDS=600 +ENV REDIS_GROUP_FANOUT_ENABLED=true # The PostgreSQL DSN connection. ENV POSTGRES_DSN='postgresql://postgres:postgres@localhost:5432/tinode?sslmode=disable&connect_timeout=10' diff --git a/docker/tinode/Dockerfile.build b/docker/tinode/Dockerfile.build index 3310a99..247f4a2 100644 --- a/docker/tinode/Dockerfile.build +++ b/docker/tinode/Dockerfile.build @@ -36,6 +36,22 @@ ENV DEFAULT_COUNTRY_CODE=US # DB connection is provided at deploy time via secret-backed env var. No default value. ENV MYSQL_DSN= ENV STORE_USE_ADAPTER=mysql +ENV REDIS_ADDR= +ENV REDIS_USERNAME= +ENV REDIS_PASSWORD= +ENV REDIS_DB=0 +ENV REDIS_KEY_PREFIX=im +ENV REDIS_DIAL_TIMEOUT_MS=2000 +ENV REDIS_READ_TIMEOUT_MS=2000 +ENV REDIS_WRITE_TIMEOUT_MS=2000 +ENV REDIS_PENDING_TTL_SECONDS=120 +ENV REDIS_DONE_TTL_SECONDS=86400 +ENV REDIS_FAILED_TTL_SECONDS=600 +ENV REDIS_HOT_MESSAGE_TTL_SECONDS=86400 +ENV REDIS_PRESENCE_TTL_SECONDS=120 +ENV REDIS_CACHE_TTL_SECONDS=300 +ENV REDIS_SHORT_STATE_TTL_SECONDS=600 +ENV REDIS_GROUP_FANOUT_ENABLED=true # Tenant registry lives in the hotgo database (shared with im_manager); tenant # DB routing resolves to the same tinode DB for the seeded "default" tenant. diff --git a/docker/tinode/config.template b/docker/tinode/config.template index 4030d93..04d0f0d 100644 --- a/docker/tinode/config.template +++ b/docker/tinode/config.template @@ -96,6 +96,24 @@ "addresses": "mongodb", "replica_set": "rs0" } + }, + "redis": { + "addr": "$REDIS_ADDR", + "username": "$REDIS_USERNAME", + "password": "$REDIS_PASSWORD", + "db": $REDIS_DB, + "key_prefix": "$REDIS_KEY_PREFIX", + "dial_timeout_ms": $REDIS_DIAL_TIMEOUT_MS, + "read_timeout_ms": $REDIS_READ_TIMEOUT_MS, + "write_timeout_ms": $REDIS_WRITE_TIMEOUT_MS, + "pending_ttl_seconds": $REDIS_PENDING_TTL_SECONDS, + "done_ttl_seconds": $REDIS_DONE_TTL_SECONDS, + "failed_ttl_seconds": $REDIS_FAILED_TTL_SECONDS, + "hot_message_ttl_seconds": $REDIS_HOT_MESSAGE_TTL_SECONDS, + "presence_ttl_seconds": $REDIS_PRESENCE_TTL_SECONDS, + "cache_ttl_seconds": $REDIS_CACHE_TTL_SECONDS, + "short_state_ttl_seconds": $REDIS_SHORT_STATE_TTL_SECONDS, + "group_fanout_enabled": $REDIS_GROUP_FANOUT_ENABLED } }, diff --git a/docs/IM-Redis三层数据分层改造方案.md b/docs/IM-Redis三层数据分层改造方案.md new file mode 100644 index 0000000..82e4cc3 --- /dev/null +++ b/docs/IM-Redis三层数据分层改造方案.md @@ -0,0 +1,595 @@ +# IM Redis 三层数据分层改造方案 + +日期:2026-08-25 + +## 1. 背景与目标 + +当前 IM 服务需要接入 Redis,但不希望出现“进程内缓存一份、Redis 缓存一份、MySQL 再存一份”的重复状态,避免后续一致性维护困难。 + +本方案按业务数据特征拆成三层: + +- 进程内:只放低频变更配置和本节点运行态。 +- Redis:放小体量、高频读取、需要跨节点一致的热数据。 +- MySQL:放最终持久化数据、事务数据、审计数据和大体量列表。 + +第一阶段不把消息正文作为 Redis 的长期存储。万人大群可以把 24 小时内热消息放入 Redis 作为读加速,私聊消息最终来源是租户库 `im_messages`,群聊消息最终来源是租户库 `im_group_messages`。 + +## 2. 核心原则 + +1. 同一类业务状态只确定一个主读取层,不在进程内和 Redis 同时维护两套可变状态。 +2. 进程内缓存只承载低频配置或本机连接态,不承载跨节点业务状态。 +3. 在线、离线、最近活跃等跨节点状态统一从 Redis 查询。 +4. MySQL 仍是需要持久化、事务和审计的数据最终来源。 +5. Redis 缓存 MySQL 数据时采用 cache-aside 模式,写入以 MySQL 事务成功为准。 +6. Redis key 必须带租户标识,虽然业务库是一租户一库,但 Redis 是共享基础设施,需要避免不同租户数据串扰。 + +## 3. 三层数据分类 + +| 层级 | 数据类型 | 典型数据 | 读写特点 | 一致性要求 | +| --- | --- | --- | --- | --- | +| 进程内 | 静态或低频配置、本机运行态 | 系统配置、字典、租户配置、OSS/短信/内容安全/声网配置、本机 WebSocket 连接、topic channel | 启动加载,定时刷新;连接态只在本机维护 | 配置允许秒级延迟;连接态只代表本节点 | +| Redis | 小体量热数据、跨节点状态、短期状态 | 用户在线状态、用户基础信息、群基础信息、群成员集合、好友/黑名单集合、24 小时热消息、验证码、临时 token、限流计数 | 高频读,TTL 或主动失效 | 跨节点查询一致,允许短暂最终一致 | +| MySQL | 持久化和事务数据 | 消息、文件记录、群申请审批、举报、审核任务、操作日志、账务类数据、大分页列表 | 事务写入,分页查询,审计追溯 | 最终权威来源 | + +## 4. 进程内层设计 + +### 4.1 适合放进程内的数据 + +- 系统配置:后台 hotgo 中的系统参数。 +- 字典配置:`hg_sys_dict_type`、`hg_sys_dict_data` 等字典项。 +- 租户配置:租户启停、基础配置、默认策略。 +- 第三方配置:OSS、阿里云内容安全、短信、声网等接入参数。 +- 本节点运行态:当前进程内的 WebSocket session、local topic channel、本机订阅关系、本机连接数。 + +### 4.2 刷新策略 + +- 服务启动时加载一次配置。 +- 定时刷新,建议 60 秒。 +- 后台配置变更后,后续可补充主动失效或广播刷新。 +- 配置读取失败时保留旧值,并打印告警日志。 + +### 4.3 边界说明 + +进程内层不保存用户在线状态的全局结果。进程内只知道“本节点有哪些连接”,跨节点判断用户是否在线必须查 Redis。 + +## 5. Redis 层设计 + +### 5.1 第一阶段必须接入 + +#### 用户在线状态 + +在线状态是 Redis 第一优先级改造内容。原因是 WebSocket 长连接固定落在某个节点上,本机内存可以管理本机连接,但其他节点无法直接知道该用户是否在线。 + +建议 key: + +```text +im:{tenant_id}:presence:user:{uid} +im:{tenant_id}:presence:user:{uid}:sessions +im:{tenant_id}:presence:node:{node_id}:users +``` + +`presence:user:{uid}` 建议使用 hash: + +```text +online = 1 +node_id = 当前节点 ID +session_count = 当前会话数 +last_active_at = 最近活跃时间 +last_seen_at = 最近可见时间 +device = 设备类型 +ua = 客户端 UA +``` + +生命周期: + +1. WebSocket 登录成功后写入 Redis 在线状态。 +2. 心跳或业务包刷新 TTL。 +3. 断开连接时扣减 session 数,最后一个 session 断开后标记离线。 +4. 节点异常退出时依赖 TTL 自动过期,避免永久在线。 +5. 其他节点查询用户在线状态时只查 Redis。 + +TTL 建议: + +- 在线状态 hash:60 到 120 秒。 +- session 集合:60 到 120 秒,随心跳刷新。 +- node 用户集合:可不强依赖 TTL,用于辅助排查和节点清理。 + +当前代码已经接入基础 PresenceStore: + +- 登录成功后写入 `presence:user:{uid}`、`presence:user:{uid}:sessions`、`presence:node:{node_id}:users`。 +- 客户端每次上行业务包刷新 `last_active_at` 和 TTL。 +- session 清理时移除当前 session,最后一个 session 离线后将 `online` 置为 `0`。 +- 对外提供 `store.GetUserPresence` 作为后续用户在线状态查询入口。 +- 群 Topic 在线成员已同步维护 `topic:{topic}:online_members` 和 `topic:{topic}:node:{node_id}:online_members`,订阅/离开时更新,客户端心跳或任意上行业务包会刷新已订阅群的在线成员 TTL。 +- 订阅列表中的用户在线状态已改为 Redis 优先读取;Redis 未启用或读取失败时,回退到原有进程内在线判断。 + +#### 短期状态 + +以下数据适合放 Redis,减少 MySQL 压力并天然支持过期: + +- 注册验证码、登录验证码。 +- 邀请码短期校验结果。 +- 企业码登录临时 token。 +- 登录失败次数、风控计数、接口限流计数。 +- 幂等 key、短期操作锁。 + +### 5.2 第二阶段建议接入 + +#### 用户基础信息 + +适合缓存: + +- 用户昵称、头像、状态签名。 +- 用户公开资料。 +- 用户账号到 UID 的映射。 + +不建议缓存: + +- 密码 hash。 +- 高敏认证字段。 +- KYC 原始材料。 + +读写规则: + +1. 查询先读 Redis。 +2. 未命中读 MySQL。 +3. MySQL 命中后写入 Redis。 +4. 用户资料变更时先提交 MySQL,再删除或更新 Redis。 + +#### 群相关数据 + +群数据体量相对可控,且聊天过程中读取频繁,适合逐步放入 Redis: + +- 群基础信息:群名、头像、简介、人数、群类型。 +- 群配置:入群审批、禁言策略、邀请权限、公告权限。 +- 群成员集合:成员 UID 集合。 +- 群成员角色:群主、管理员、普通成员。 +- 群成员状态:禁言、移出、黑名单。 + +建议 key: + +```text +im:{tenant_id}:group:{group_id}:profile +im:{tenant_id}:group:{group_id}:settings +im:{tenant_id}:group:{group_id}:members +im:{tenant_id}:group:{group_id}:admins +im:{tenant_id}:group:{group_id}:muted +``` + +#### 关系数据 + +好友、黑名单、关注类关系适合用 Redis set 承载热读: + +```text +im:{tenant_id}:relation:friends:{uid} +im:{tenant_id}:relation:blocklist:{uid} +``` + +用于快速判断: + +- 是否好友。 +- 是否被拉黑。 +- 是否允许发起会话。 +- 是否允许查看在线状态或最近在线时间。 + +### 5.3 第三阶段可选接入 + +- 首页 bootstrap 聚合结果:短 TTL 缓存,减少多表聚合查询。 +- 群成员分页快照深度优化:基础 HTTP 结果快照已接入,后续大群可继续补 sorted set、稳定游标和主动失效。 +- 未读数深度优化:基础总未读 Redis 热缓存已接入,后续可继续补 Topic 维度未读计数、bootstrap 聚合缓存和主动失效事件。 + +### 5.4 万人大群实时推送专项 + +万人大群的实时推送需要和 Redis 改造一起考虑。核心目标是避免普通小群模式下的全量 fanout 压垮服务端,同时保证在线用户实时可达、离线用户通过极光 Push 提醒、客户端重连后可按 seq 补齐消息。 + +该部分作为 Redis 改造的近期实现项处理,不再仅作为未实现增强项记录。 + +#### 数据分层 + +| 层级 | 承载内容 | 说明 | +| --- | --- | --- | +| 进程内 | 本节点在线连接、本节点 topic 订阅、本节点待发送队列 | 只负责当前节点连接和发送,不保存全局群成员在线状态。 | +| Redis | 群在线成员索引、用户所在节点、群分片、原子 seq 发号器、24 小时热消息、短期去重 key | 用于判断哪些在线用户需要实时推送、原子获取群消息 seq,以及近期高频消息读取加速。 | +| MySQL 租户库 | `im_group_messages` 群消息正文、群消息 seq 结果、消息索引、离线同步游标、审计数据 | 作为群消息最终存储,保存 Redis 分配后的 seq,不负责实时发号。 | +| 极光 Push | 离线提醒 | 只做提醒,不承载消息正文最终一致性。 | + +#### 建议 Redis key + +```text +im:{tenant_id}:presence:user:{uid} +im:{tenant_id}:topic:{topic}:online_members +im:{tenant_id}:topic:{topic}:node:{node_id}:online_members +im:{tenant_id}:topic:{topic}:shard:{shard_id}:members +im:{tenant_id}:topic:{topic}:last_seq +im:{tenant_id}:topic:{topic}:hot_msg_seqs +im:{tenant_id}:topic:{topic}:hot_msg:{seq} +im:{tenant_id}:topic:{topic}:uniq:msg:{from_uid}:{client_msg_id} +im:{tenant_id}:push:dedup:{topic}:{seq}:{uid} +``` + +说明: + +- `topic:online_members` 保存当前群在线成员集合,可由用户上线、订阅群、退订群、断开连接维护。 +- `topic:node:{node_id}:online_members` 用于跨节点推送时按节点拆分,减少广播范围。 +- `topic:shard:{shard_id}:members` 可用于万人群成员分片,后续支持分批扫描和补偿。 +- `topic:last_seq` 是群消息 seq 发号器,使用 Redis `INCR` 或 Lua 脚本原子获取下一个 seq。 +- `topic:hot_msg_seqs` 使用 sorted set 保存 24 小时内热消息 seq,score 为 seq 或发送时间。 +- `topic:hot_msg:{seq}` 保存单条热消息正文快照,TTL 建议 24 小时。 +- `topic:uniq:msg:{from_uid}:{client_msg_id}` 保存群消息发送唯一 key,用于客户端重试幂等和服务端去重。 +- `push:dedup` 用于极光 Push 或实时推送去重,TTL 建议按分钟级设置。 + +#### 唯一 key 与幂等规则 + +Redis 唯一 key 用于挡住短时间内的重复提交,MySQL 唯一索引作为最终兜底。不能只依赖 Redis 做永久唯一性,因为 Redis key 会过期,也可能被运维清理。 + +群消息发送唯一 key: + +```text +im:{tenant_id}:topic:{topic}:uniq:msg:{from_uid}:{client_msg_id} +``` + +建议 value: + +```json +{ + "status": "pending|done|failed", + "seq": 12345, + "message_id": 987654321, + "created_at": "2026-08-25T12:00:00.000+08:00" +} +``` + +处理规则: + +1. 客户端必须传 `client_msg_id`,同一发送人、同一 Topic 内唯一。 +2. 服务端收到群消息后先用 `SET key value NX EX ttl` 抢占唯一 key,初始状态为 `pending`。 +3. 抢占成功才继续 Redis `INCR` 获取群 `seq`。 +4. 抢占失败时读取 key: + - `done`:直接返回已生成的 `seq/message_id`。 + - `pending`:返回处理中或短暂等待后重试,避免重复发号。 + - `failed`:允许客户端用同一个 `client_msg_id` 重试,或要求重新生成 `client_msg_id`。 +5. 消息写入 `im_group_messages` 成功后,将唯一 key 更新为 `done`,记录 `seq/message_id`。 +6. 消息写入失败时,将唯一 key 更新为 `failed` 并设置较短 TTL;已经获取的 `seq` 不回退,允许空洞。 +7. `im_group_messages` 仍保留 `UNIQUE(topic, from_user, client_msg_id)`,防止 Redis key 过期后重复落库。 + +TTL 建议: + +- `pending`:30 到 120 秒。 +- `done`:24 小时到 7 天,按客户端重试窗口决定。 +- `failed`:5 到 30 分钟。 +- Push 去重 key:5 到 30 分钟。 + +#### Seq 发号规则 + +- 群消息 seq 由 Redis 维护,不从 MySQL 查询最大 seq。 +- 每个 Topic 独立一个 `topic:last_seq`,使用 `INCR im:{tenant_id}:topic:{topic}:last_seq` 原子获取。 +- 只要求单 Topic 内单调递增,不要求全局连续。 +- 如果取到 seq 后消息持久化失败,该 seq 可以形成空洞;客户端按 seq 补拉时允许跳过服务端确认不存在的 seq。 +- Topic 创建或 Redis key 丢失时,当前代码使用 topic 内存态 `lastID` 作为 Redis 初始化基线;该值来自 topic 加载结果或当前进程已确认的最新 seq,然后再执行原子 `INCR`。 +- Redis 不可用时不能用数据库临时发号继续写大群消息,应返回可重试错误,避免多源发号造成 seq 冲突。 + +#### 发送流程 + +1. 服务端收到群消息后,先完成权限校验和禁言校验。 +2. 使用 Redis 唯一 key 抢占 `topic + from_uid + client_msg_id`,防止客户端重试重复发送。 +3. 抢占成功后,使用 Redis `INCR` 或 Lua 脚本从 `topic:last_seq` 原子获取下一个群 `seq`。 +4. 消息正文带着该 `seq` 写入租户库 `im_group_messages`。 +5. 落库成功后将唯一 key 更新为 `done`,记录 `seq/message_id`。 +6. 对万人大群或高热群,将消息快照写入 `topic:hot_msg:{seq}`,并把 seq 加入 `topic:hot_msg_seqs`,TTL 保持 24 小时。 +7. 查询 Redis 中该群在线成员,按 `node_id` 分组。 +8. 本节点在线成员直接从进程内连接表推送。 +9. 其他节点在线成员通过 Redis Pub/Sub 通知目标节点推送;当前代码使用 `im:0:group_fanout` 广播精简后的 `ServerComMessage`,订阅节点收到后投递到本机 `hub.routeSrv`,只发送给本节点已加载 Topic 的在线连接。 +10. 不在线成员不做服务端全量离线消息 fanout,只更新离线游标或依赖消息 seq 补拉,并触发极光 Push。 +11. 客户端重连后按 Topic seq 拉取缺失消息,优先读 Redis 热消息,未命中再回源租户库 `im_group_messages`。 + +#### 读取流程 + +客户端消息读取仍以 seq 为准,Redis 只优化 24 小时内的热读: + +1. 在线实时收到消息后,客户端本地落库并更新本地最大 seq。 +2. 重连、切换设备或发现 seq 不连续时,调用历史消息接口按 `topic + after_seq` 补拉。 +3. 服务端先按 seq 范围读取 Redis 热消息。 +4. Redis 命中的消息直接返回;缺失、过期或不连续的 seq 回源租户库 `im_group_messages`。 +5. 回源命中后可按策略重新写入 Redis,保持 24 小时 TTL。 +6. 极光 Push 唤醒后,客户端仍走正常登录和 seq 补拉链路。 + +#### 热消息缓存边界 + +- 只缓存 24 小时内高频读取的群消息,普通群可不开启或按群规模阈值开启。 +- Redis 中的消息快照不作为审计和历史同步最终来源。 +- 删除、撤回、编辑消息后,必须同步删除或更新对应 `topic:hot_msg:{seq}`,避免客户端读到旧正文。 +- 大消息、文件二进制、音视频内容不直接放 Redis,只缓存消息元数据和 OSS object key。 +- Redis 容量按租户和群设置上限,超过阈值时优先淘汰低热群或旧 seq。 +- 当前代码只在 Redis 能完整命中一页群消息时直接返回;如果 Redis 缺失、过期、不连续或读取带有用户软删除视角,则回源 MySQL,避免缓存缺口导致消息缺失或读到用户已删除内容。 + +#### 离线 Push 口子 + +极光 Push 只用于离线提醒,建议 payload 只放最小必要信息: + +```json +{ + "type": "group_message", + "topic": "grp_xxx", + "seq": 12345, + "tenant_id": "tenant_xxx" +} +``` + +不建议在 Push payload 中放完整消息正文,避免隐私泄露、厂商通道截断和多端一致性问题。 + +#### 一致性边界 + +- Redis 的在线成员集合允许短暂不准,最终由心跳 TTL 和订阅变更修正。 +- Redis 唯一 key 负责短期幂等和去重,MySQL 唯一索引负责最终唯一性兜底。 +- seq 是否已分配以 Redis `topic:last_seq` 为准;群消息是否真实存在,以租户库 `im_group_messages` 为准。 +- Redis 发号成功但消息持久化失败时,允许 seq 空洞,不回退 `topic:last_seq`。 +- 实时推送失败不影响消息持久化,通过客户端 seq 补拉恢复;Redis 热消息缺失时回源租户库 `im_group_messages`。 +- 极光 Push 失败不影响消息存储,只影响离线提醒,需要记录失败并可观测。 +- Redis Pub/Sub 只负责在线实时投递,不作为可靠消息队列;节点短暂断线错过实时消息时,客户端仍以 Topic seq 补拉恢复。 + +## 6. MySQL 层设计 + +### 6.1 必须保留在 MySQL 的数据 + +- 私聊消息正文、私聊消息索引、离线同步游标。 +- 群消息正文、群消息索引、群消息审计数据。 +- 文件记录、OSS object key、文件安全审核结果。 +- 群邀请、进群申请、审批记录。 +- 群公告、公告确认、公告历史。 +- 用户认证、KYC 记录、企业注册资料。 +- 举报、投诉、内容安全审核任务。 +- 操作日志、审计日志、管理后台变更记录。 +- 需要事务一致性的业务状态。 + +### 6.2 消息表拆分 + +当前 `im_messages` 后续只作为私聊、文件助手等非群聊消息的主存储。群聊消息新增租户库业务表 `im_group_messages`,避免所有消息挤在一张表里,也方便后续针对大群按 `topic + seq`、时间、热度做分区、归档和缓存。 + +建议表结构: + +```sql +CREATE TABLE IF NOT EXISTS im_group_messages ( + id BIGINT NOT NULL AUTO_INCREMENT, + topic VARCHAR(64) 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) NULL COMMENT '客户端幂等消息 ID', + msg_type VARCHAR(32) NOT NULL DEFAULT 'text' COMMENT 'text/image/file/audio/video/system 等', + status VARCHAR(32) NOT NULL DEFAULT 'normal' COMMENT 'normal/recalled/deleted/blocked', + content JSON NULL COMMENT '消息正文或 Drafty 内容', + head JSON NULL COMMENT '消息头,包含 @、引用、客户端能力等', + mentioned_user_ids JSON NULL COMMENT '@ 用户列表,便于提醒和检索', + mention_all TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否 @ 全体', + reply_seqid BIGINT NULL COMMENT '回复的群消息 seq', + root_seqid BIGINT 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) NOT NULL DEFAULT 'pass' COMMENT 'pass/pending/reject', + version INT NOT NULL DEFAULT 1 COMMENT '编辑版本', + recalled_at DATETIME(3) NULL, + recalled_by BIGINT NULL, + deleted_at DATETIME(3) NULL, + expires_at DATETIME(3) 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; +``` + +设计说明: + +- `seqid` 使用 `BIGINT`,避免大群长期高频消息导致 `INT` 不够用。 +- `group_id + seqid` 和 `topic + seqid` 都保持唯一,业务治理优先用 `group_id`,协议补拉仍可用 `topic`。 +- `client_msg_id` 用于发送幂等,索引带上 `topic`,避免不同群复用客户端消息 ID 时互相冲突。 +- `status` 标记普通、撤回、删除、拦截状态,拉历史时可以返回占位或过滤。 +- `mentioned_user_ids`、`mention_all` 单独存,方便 @ 提醒、离线 Push 和后台治理,不必每次解析正文。 +- `audit_status`、`has_risk` 为内容安全和风控留字段,高风险消息可先 pending 后再放行。 +- `reply_seqid`、`root_seqid` 为引用回复、线程、合并转发上下文预留。 +- 文件二进制不进表,表内只保存消息内容和 OSS object key 等元数据;群消息附件会同步登记到 `im_group_files`,并记录 `source_topic/source_seq` 追溯来源消息。 +- 超大规模租户后续可按 `group_id` hash 分表,或按 `created_at/expires_at` 做归档分区。 + +读写规则: + +1. 私聊消息继续写 `im_messages`。 +2. 群聊消息写 `im_group_messages`。 +3. 群消息发号由 Redis `topic:last_seq` 原子递增完成,`im_group_messages.seqid` 只保存结果。 +4. Redis 24 小时热消息命中时直接返回;未命中按 `topic + seqid` 回源 `im_group_messages`。 +5. 撤回、编辑、删除群消息时,以 `im_group_messages` 为准,并同步更新或删除 Redis 热消息。 + +### 6.3 消息正文说明 + +第一阶段不把消息正文作为 Redis 长期存储。万人大群和高热群允许缓存 24 小时内热消息,作为客户端重连补拉、最近历史翻页和多端同步的读加速。 + +消息正文主要读取场景: + +- 新设备登录后的历史消息同步。 +- 离线用户重新上线后的消息补拉。 +- 客户端本地消息缺失后的重拉。 +- 举报、风控、内容安全复查。 +- 管理后台审计或客服处理。 + +这些场景的最终来源仍是 MySQL 的消息表。私聊回源 `im_messages`,群聊回源 `im_group_messages`。Redis 热消息只覆盖短期高频读取,命中则减少数据库压力,未命中必须回源。 + +## 7. 读写一致性规则 + +### 7.1 进程内配置 + +- 进程启动加载配置。 +- 定时刷新配置。 +- 配置读取失败时使用旧值。 +- 配置变更允许短时间延迟生效。 + +### 7.2 Redis 运行态 + +- 在线状态以 Redis 为跨节点查询来源。 +- 本机内存只用于管理本机连接。 +- Redis 写入失败时,在线状态降级为未知或离线,不阻断基础连接能力。 +- 心跳持续刷新 TTL,节点异常时依赖 TTL 自动回收。 + +### 7.3 Redis 缓存 MySQL 热数据 + +- 读:先 Redis,未命中再 MySQL,最后回填 Redis。 +- 写:先 MySQL 事务提交,再删除或更新 Redis。 +- 消息 seq:群聊先通过 Redis `INCR` 或 Lua 脚本原子获取 seq,再带 seq 写租户库 `im_group_messages`;私聊仍按现有 `im_messages` 链路处理。 +- 消息热缓存:消息持久化成功后,再写 Redis 24 小时热消息;热消息写失败不影响消息发送成功。 +- Redis 故障:直接回源 MySQL,记录降级日志和指标。 +- 不允许因为 Redis 写失败回滚已经成功的 MySQL 业务事务,除非该业务本身强依赖 Redis 状态。 + +### 7.4 MySQL 持久化 + +- 需要审计、事务、历史追溯的数据直接写 MySQL。 +- Redis 和进程内缓存只做读取加速或运行态存储。 +- 私聊消息使用 `im_messages`,群聊消息使用 `im_group_messages`,避免单表无限膨胀。 + +## 8. 租户隔离 + +当前方案是一租户一库,业务表中不再保留 `tenant_id` 字段。但 Redis、进程内配置、日志指标仍属于共享运行环境,必须保留租户维度。 + +Redis key 统一包含租户标识: + +```text +im:{tenant_id}:... +``` + +如果请求早期只有企业码或租户编码,应先解析到租户 ID 或租户库配置,再进入业务缓存读取。 + +## 9. 代码改造建议 + +### 9.1 基础设施 + +- 增加 Redis 配置:地址、密码、DB、连接池、超时、key 前缀。 +- 增加 Redis client 初始化和健康检查。 +- 增加统一 key builder,避免业务代码手写 key。 +- 增加缓存指标和日志。 + +当前代码将 Redis 配置挂在 `store_config.redis` 下;不配置则不启用 Redis 群消息发号和唯一 key,群消息仍兼容旧的本机 `lastID + 1` 发号。 + +配置示例: + +```json +{ + "store_config": { + "redis": { + "addr": "127.0.0.1:6379", + "username": "", + "password": "", + "db": 0, + "key_prefix": "im", + "dial_timeout_ms": 2000, + "read_timeout_ms": 2000, + "write_timeout_ms": 2000, + "pending_ttl_seconds": 120, + "done_ttl_seconds": 86400, + "failed_ttl_seconds": 600, + "hot_message_ttl_seconds": 86400, + "presence_ttl_seconds": 120, + "cache_ttl_seconds": 300, + "short_state_ttl_seconds": 600, + "group_fanout_enabled": true + } + } +} +``` + +`group_fanout_enabled` 默认关闭。启用后,群消息本节点投递完成后会发布 Redis Pub/Sub,其他节点订阅后只向本节点已在线的同群连接推送;如果节点没有加载该 Topic,则读取 Redis 本节点群在线成员集合做兜底直投,本地会话订阅表作为兼容兜底,实时投递失败再由客户端 seq 补拉恢复。 + +当前第二阶段已接入: + +- 用户资料单对象缓存:`store.Users.Get` 优先读 Redis,用户更新/删除后失效。 +- Topic 单对象缓存:`store.Topics.Get` 优先读 Redis,Topic 更新、转让、删除后失效。 +- 单条订阅关系缓存:`store.Subs.Get` 优先读 Redis,订阅更新/删除后 active/any 两种缓存同时失效。 +- 群治理资料缓存:群设置 `GroupBusiness.GetSettings`、群成员 `GroupBusiness.GetMember` 优先读 Redis,更新、离群、角色变更后刷新或失效。 +- 群成员分页快照:`GET /v1/im/groups/{topic}/members` 按租户、Topic、操作者权限、筛选条件、cursor 和 limit 缓存响应快照,未命中回源订阅列表。 +- 用户总未读热缓存:`store.Users.GetUnreadCount` 先读 Redis,未命中回源 MySQL 并回填;在线用户 unread updater 更新内存计数时同步写 Redis。 +- 好友/黑名单 Redis Set 热缓存:关系上下文判断先查 `friends:{uid}`、`blocklist:{uid}` 正向 set,命中后减少 MySQL 查询;未命中仍回源 MySQL,新增/删除好友、拉黑/解除拉黑会同步更新 set。 +- 验证码短状态缓存:验证码创建、查询、验证、失败、消费会同步 Redis,`GetActive` 使用 `scene + phone_hash` 的 active key 做重发窗口热读。 +- Redis 频控:验证码创建按 `tenant + scene + phone_hash` 使用 Redis 原子 `INCR + EXPIRE` 做 1 小时 5 次限制,手机号只使用 hash,不写明文。 +- 动作 token 短状态缓存:创建时写 Redis,消费成功后删除缓存;最终消费仍以 MySQL 状态机为准,保留审计一致性。 +- SeqId 收口:消息、Topic 游标、订阅读/收游标、消息范围、收藏/举报/群文件引用、通话消息引用、JSON/gRPC 协议结构均改为 `int64`;MySQL 新建表 schema 中对应字段改为 `BIGINT`。 +- 进程内配置缓存:租户解析、注册短信开关、第三方配置读取已加本地 TTL 缓存,减少 HotGo 控制库频繁查询;缓存过期后按下一次读取刷新。 + +### 9.2 接口抽象 + +建议新增接口: + +```go +type PresenceStore interface { + MarkOnline(ctx context.Context, tenantID string, uid string, sessionID string, meta PresenceMeta) error + Refresh(ctx context.Context, tenantID string, uid string, sessionID string) error + MarkOffline(ctx context.Context, tenantID string, uid string, sessionID string) error + Get(ctx context.Context, tenantID string, uid string) (Presence, error) +} + +type ConfigCache interface { + GetDict(ctx context.Context, key string) (DictValue, bool) + GetTenantConfig(ctx context.Context, tenantID string) (TenantConfig, bool) + Refresh(ctx context.Context) error +} + +type ReadModelCache interface { + GetUserProfile(ctx context.Context, tenantID string, uid string) (UserProfile, bool) + SetUserProfile(ctx context.Context, tenantID string, profile UserProfile) error + DeleteUserProfile(ctx context.Context, tenantID string, uid string) error +} +``` + +### 9.3 业务接入顺序 + +1. 先接入 Redis client 和配置。 +2. 接入在线状态 PresenceStore。 +3. 群消息接入 Redis `SET NX` 唯一 key 与 `INCR` 原子 seq 发号,群消息正文写入 `im_group_messages`。 +4. WebSocket 登录、心跳、断开链路写入 PresenceStore。 +5. 用户在线状态查询接口改为读 Redis。 +6. 配置和字典改为进程内 TTL 缓存刷新。 +7. 再逐步接入用户、群、好友关系热数据缓存。 + +## 10. 故障降级 + +| 场景 | 降级策略 | +| --- | --- | +| Redis 不可用,查询在线状态 | 返回未知或离线,记录告警 | +| Redis 不可用,读取用户/群缓存 | 回源 MySQL | +| Redis 不可用,验证码或短期 token | 涉及安全的接口应失败关闭 | +| Redis 不可用,万人大群发消息 | 无法原子获取群 seq,应返回可重试错误;不使用 MySQL 临时发号,避免 seq 冲突 | +| 配置刷新失败 | 使用进程内旧配置 | +| MySQL 不可用 | 持久化业务失败,不依赖 Redis 兜底 | + +## 11. 监控指标 + +- Redis 连接状态。 +- Redis 命令耗时。 +- Redis 缓存命中率。 +- 在线用户 key 数量。 +- 心跳刷新失败次数。 +- Redis 回源 MySQL 次数。 +- 大群在线成员集合大小。 +- 大群按节点分发耗时。 +- 大群实时推送成功率和补拉率。 +- 极光 Push 触发数、成功数和失败数。 +- 配置刷新成功和失败次数。 +- Presence TTL 过期导致的离线数量。 + +## 12. 第一阶段落地范围 + +第一阶段建议只做以下内容: + +- Redis 基础配置和 client 初始化。 +- 用户在线、离线、心跳刷新、跨节点查询。 +- 验证码、临时 token、限流计数等短期状态迁移到 Redis。 +- 系统配置、字典、租户配置、第三方配置改为进程内 TTL 缓存刷新。 +- 万人大群实时推送纳入 Redis 改造近期实现范围,先完成 Redis key、在线成员索引、节点分片和跨节点实时分发链路。 +- 明确消息正文不进入 Redis 长期存储;万人大群 24 小时热消息可进入 Redis 做读加速。 +- 群成员分页快照和用户总未读 Redis 热缓存已完成基础接入。 + +后续再逐步推进好友/黑名单全量预热、群成员分页快照、未读数等缓存改造。 diff --git a/docs/IM-产品原型需求文档.md b/docs/IM-产品原型需求文档.md index 53636b8..8cfdf59 100644 --- a/docs/IM-产品原型需求文档.md +++ b/docs/IM-产品原型需求文档.md @@ -1,6 +1,6 @@ # IM 产品原型需求文档 -> 本文档根据 `/Users/superysh/im/第二项目/` 中的 156 张产品原型图整理。功能按业务域归类,每张图以原文件名作为标题,图片下方给出功能说明与界面内容描述。 +> 本文档根据 `/Users/superysh/im/第二项目/` 中的 160 张产品原型图整理。功能按业务域归类,每张图以原文件名作为标题,图片下方给出功能说明与界面内容描述。 ## 产品总体定位 @@ -18,6 +18,38 @@ 提供企业租户入口。页面展示产品标识、企业码输入框和“进入企业”按钮;提交后应校验企业码是否存在、是否启用,并将用户导向对应企业的登录环境。 +### 1.1.1 企业注册-登录入口.png + +企业注册-登录入口 + +功能说明: + +企业码登录页右上角提供“企业注册”入口,底部提供“遇到问题?”反馈入口。点击“企业注册”进入企业资料提交流程;点击“遇到问题?”进入问题反馈页。入口文案应保持可见且易点击,避免与状态栏或背景装饰重叠。 + +### 1.1.2 企业注册-填写注册信息.png + +企业注册-填写注册信息 + +功能说明: + +企业注册页按步骤展示“填写企业信息、确定信息、提交审核”,当前处于第 1 步。用户需填写 18 位统一社会信用代码,支持扫描识别;可选上传营业执照,并填写企业邮箱用于接收审核结果及企业通知。提交前应校验统一社会信用代码格式、邮箱格式、图片大小和文件类型,并提示资料提交后预计审核时长。 + +### 1.1.3 企业注册-提交成功提示.png + +企业注册-提交成功提示 + +功能说明: + +企业注册资料提交成功后,在当前页展示明确 Toast 提示“提交成功,我们会尽快联系你”。提交成功状态应防止重复提交,并记录申请单状态;审核结果需通过企业邮箱或后续通知渠道触达。页面底部审核时长文案应与实际运营规则一致。 + +### 1.1.4 企业注册-问题反馈.png + +企业注册-问题反馈 + +功能说明: + +企业入口问题反馈页支持填写问题描述并上传最多 9 张截图,便于定位企业码、注册资料、审核通知等问题。提交时应携带设备、版本、租户线索和可选联系方式;图片上传需显示数量限制、失败重试和大小校验,提交成功后给出明确受理反馈。 + ### 1.2 登录页面.png 登录页面 diff --git a/docs/IM-前后端对接文档-正式版.html b/docs/IM-前后端对接文档-正式版.html deleted file mode 100644 index 5a8cba0..0000000 --- a/docs/IM-前后端对接文档-正式版.html +++ /dev/null @@ -1,1559 +0,0 @@ -IM-前后端对接文档 -

IM 前后端逐图对接协议

-
版本:v2.0
-
日期:2026-08-11
-
需求依据:IM 产品原型需求文档
-
设计依据:IM 系统设计文档
-
说明:第 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. -
  3. 已有服务端继续使用当前 api_key_salt 时,执行 go run ./keygen -sequence <新序号> -salt '<服务端 api_key_salt>' 生成兼容的普通应用 Key。
  4. -
  5. 输出中的 HMAC salt 配置到服务端 server/tinode.conf 的 api_key_salt;输出中的 API key 配置到 iOS、Android 或 Web 客户端构建配置/受控环境配置。
  6. -
  7. 客户端启动时从自己的应用配置读取 API Key,不调用 IM 接口获取。禁止把 -isroot 1 生成的 Root Key 放进客户端。
  8. -
-

生成示意:

-

-$ go run ./keygen
-API key v1 seq1 [ordinary]: <client-api-key>
-HMAC salt: <server-hmac-salt>
-
-

API Key、企业码和用户 Token 是三个不同概念:

- - - - -
值取得时间作用是否代表用户身份
api_key应用发布/环境配置时预置允许应用访问 Tinode 入口否
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/channelsUpgrade Header X-Tinode-APIKey同上
产品 HTTPhttps://{host}/{api_path}v1/im/*Authorization: Bearer <params.token>好友、群、收藏、搜索、工作台和设置
文件 HTTPPOST /v0/file/u/、GET /v0/file/s/{file_id}当前用 X-Tinode-Auth: token <base64_token>文件上传下载;禁止 Query 传 Token
-

HTTP 请求统一通过 X-Tinode-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:

-

-{"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/channelsHTTP 101 和已建立的 WebSocket;不会返回新 API Key 或 sid发送 {hi tenant}
Long Polling(兼容)host + api_key + tenant_codePOST /v0/channels/lpHTTP 201 和 ctrl.params.sid带 sid POST {hi},再 GET 等待响应
-

现有实现对应关系:

- - - - - - - -
源码作用
keygen/keygen.go生成普通/Root API Key 和服务端 HMAC Salt
server/api_key.gocheckAPIKey 校验 API Key 格式与签名
server/http.go按 Header、Query、Form、Cookie 顺序读取 API Key
server/hdl_websock.goWebSocket 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. -
  3. WebSocket 第一条消息必须是 {hi},其中 tenant 为企业码。服务端解析后把租户固定到 Session。
  4. -
  5. Token 内部绑定租户。Token 租户与当前 Session 租户不一致时返回 TENANT_MISMATCH,不得自动切换。
  6. -
  7. 切换企业必须退出登录、关闭 WebSocket、清除该租户的 Token/缓存,再重新解析企业码和握手。
  8. -
  9. 已登录 HTTP 请求的租户和用户只从 Token Principal 获取。请求体中的 tenant_id、user_id 不能覆盖身份。
  10. -
  11. 跨租户对象统一返回 RESOURCE_NOT_FOUND 或 FORBIDDEN,不得暴露对象是否存在。
  12. -
-

0.4 HTTP 请求与响应

-

成功响应统一为:

-

-{
-  "code": 0,
-  "data": {},
-  "request_id": "01K...",
-  "server_time": "2026-08-11T08:30:15.123Z"
-}
-
-

失败响应统一为:

-

-{
-  "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 现有服务端可直接执行的冷启动流程

-

-读取客户端预置的 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 请求:

-

-{"hi":{"id":"hi-1","ver":"0.25","ua":"android/1.0.0","dev":"device-id","lang":"zh-CN","tenant":"acme"}}
-
-

login 请求使用 basic 或 token:

-

-{"login":{"id":"login-1","scheme":"basic","secret":"base64(username:password)"}}
-
-

-{"login":{"id":"login-2","scheme":"token","secret":"<上次登录响应原样返回的 token 字符串>"}}
-
-

basic.secret 是 base64(username + ":" + password)。Token 登录必须把上次 params.token 的 JSON 字符串原样填入 secret,不能再次 Base64 编码。登录成功 {ctrl} 的 params 至少包含 user、authlvl、token、expires。产品 HTTP 也使用同一个 params.token:Authorization: Bearer <params.token>,客户端不要解码或二次编码。Token 对客户端是不可解析的 opaque 字符串,只能存入系统安全存储。id 只用于匹配本次 Session 的命令响应,不是业务幂等键。

-

hi 成功示例:

-

-{
-  "ctrl": {
-    "id": "hi-1",
-    "code": 201,
-    "text": "created",
-    "params": {
-      "ver": "0.25",
-      "tenant": {"code": "acme", "name": "示例企业"},
-      "maxFileUploadSize": 104857600,
-      "callTimeout": 30,
-      "iceServers": []
-    }
-  }
-}
-
-

客户端只使用实际响应中存在的限制字段;未知字段忽略。hi 返回非 2xx 时必须关闭连接,不得继续发送 login。

-

登录成功后发送:

-

-{"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:

-

-{"sub":{"id":"sub-fnd-1","topic":"fnd"}}
-
-

进入具体会话时订阅并增量拉取,since 使用客户端已完整持久化的最后 seq + 1:

-

-{"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:

-

-POST /v0/channels/lp HTTP/1.1
-Host: im.example.com
-X-Tinode-APIKey: <预置的普通 API Key>
-Content-Length: 0
-
-

服务端返回 HTTP 201:

-

-{"ctrl":{"code":201,"text":"created","params":{"sid":"session-id"},"ts":"2026-08-11T08:30:15.123Z"}}
-
-

第二步开始,所有 Long Polling 请求都同时携带 API Key 和 sid。发送 hi:

-

-POST /v0/channels/lp?sid=session-id HTTP/1.1
-X-Tinode-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}:

-

-GET /v0/channels/lp?sid=session-id HTTP/1.1
-X-Tinode-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 已实现,推荐产品入口流程为:

-

-读取预置 API Key
--> POST /v1/im/tenants/resolve 校验企业码并取得服务地址/品牌配置
--> 带同一 API Key 建立返回地址的 WebSocket
--> hi 再次绑定并校验 tenant_code
--> login -> sub me -> 恢复会话
-
-

企业解析接口只改善入口、服务发现和品牌配置,不能代替 {hi} 的租户绑定。即使 HTTP 解析成功,WebSocket 第一帧仍必须带相同 tenant_code。如果解析响应中的 endpoints 不符合当前发布环境,客户端应优先使用内置的公网环境配置,并上报脱敏诊断。

-

0.6 Topic 与产品消息

-

核心命令:sub 订阅、pub 发送、get 拉取、set 更新 Topic、leave 离开、del 删除、note 发送回执/临时状态。发送成功为 {ctrl code:202, params:{seq}},服务端下行消息为 {data}。

-

所有产品消息在 pub.head 中携带:

-

-{
-  "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_event。自定义表情启用后追加 sticker。媒体内容只保存 file_id、文件元数据和缩略图 ID,不保存可长期绕过鉴权的公共 URL。

-

0.7 HTTP 幂等、重试和安全

-
    -
  1. 创建、提交、同意、拒绝、删除、转让、收藏、转发等写请求必须携带 Idempotency-Key。
  2. -
  3. GET、断线同步和同幂等键写请求可自动重试;密码、验证码和风控确认只按接口返回的 retry_after_seconds 重试。
  4. -
  5. 401 TOKEN_EXPIRED 只允许一次静默刷新;刷新失败必须退出登录。403 不重试,429 按服务端退避。
  6. -
  7. 服务端必须在最终写入前再次校验好友关系、群角色、禁言、消息归属、文件 ACL、租户配额和风控,不能信任前端按钮状态。
  8. -
  9. 普通用户禁止向系统 Topic 发布;系统通知必须由服务端受控身份生成。
  10. -
-

1. 账号注册、登录与身份安全

-

1.1 企业码登录.png

-
企业码登录
企业码登录
-

状态:企业解析接口与 WebSocket 租户握手均已实现。

-

当前系统的执行方式:

- -

企业解析接口:

- -

成功响应示例:

-

-{
-  "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.2 登录页面.png

-
登录页面
登录页面
-

状态:基本账号密码登录已实现;WS Token 可复用于产品 HTTP。

- -

登录成功示例:

-

-{
-  "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

-
登录页面-密码错误
登录页面-密码错误
-

状态:已实现基础错误,错误码和限流需改造。

- -

1.4 注册页面.png

-
注册页面
注册页面
-

状态:Tinode {acc} 基础注册和验证码预校验已实现;短信供应商接入前固定测试码为 123456。

- -

-{"acc":{"id":"acc-1","user":"new","scheme":"basic","secret":"base64(+86138...:password)","login":true,"cred":[{"meth":"tel","val":"+86138...","resp":"verification_token","params":{"challenge_id":"challenge-id"}}],"desc":{"public":{"fn":"用户昵称"}}}}
-
- -

1.5 注册页面-倒计时.png

-
注册页面-倒计时
注册页面-倒计时
-

状态:新增。

- -

1.6 忘记密码.png

-
忘记密码
忘记密码
-

状态:新增。

- -

1.7 身份验证(安卓).png

-
身份验证(安卓)
身份验证(安卓)
-

状态:新增。

- -

1.8 实名认证.png

-
实名认证
实名认证
-

状态:新增。

- -

1.9 实名认证-已实名.png

-
实名认证-已实名
实名认证-已实名
-

状态:新增。

- -

1.10 实名认证(安卓).png

-
实名认证(安卓)
实名认证(安卓)
-

状态:新增。

- -

1.11 设置独立密码(安卓).png

-
设置独立密码(安卓)
设置独立密码(安卓)
-

状态:新增。

- -

2. 消息首页、全局导航与系统通知

-

2.1 消息.png

-
消息
消息
-

状态:会话订阅已实现;产品摘要和系统入口需改造。

- -

2.2 消息-无网络状态.png

-
消息-无网络状态
消息-无网络状态
-

状态:需改造客户端恢复流程和消息幂等。

- -

2.3 消息-更多.png

-
消息-更多
消息-更多
-

状态:菜单为纯客户端;权限数据新增。

- -

2.4 消息页面—长按弹出.png

-
消息页面—长按弹出
消息页面—长按弹出
-

状态:基础消息操作部分已实现;产品权限矩阵需改造。

- -

2.5 系统消息.png

-
系统消息
系统消息
-

状态:需改造。

- -

3. 单聊会话、会话建立与消息状态

-

3.1 个人对话框(对方的状态).png

-
个人对话框(对方的状态)
个人对话框(对方的状态)
-

状态:需改造好友关系状态。

- -

3.2 个人对话框(对方的状态)(1).png

-

!个人对话框(对方的状态)(1).png)

-

状态:在线状态已具备基础能力;关系文案新增。

- -

3.3 个人对话框(我的状态).png

-
个人对话框(我的状态)
个人对话框(我的状态)
-

状态:新增好友申请状态。

- -

3.4 个人对话框(我的状态)(1).png

-

!个人对话框(我的状态)(1).png)

-

状态:发送和读回执已实现基础能力;送达状态映射需统一。

- -

3.5 开启沟通页面.png

-
开启沟通页面
开启沟通页面
-

状态:新增好友审批与 Topic 联动。

- -

3.6 双方沟通状态.png

-
双方沟通状态
双方沟通状态
-

状态:上传基础已实现;媒体消息 Schema、ACL 和恢复需改造。

- -

3.7 个人对话—拉黑状态/已读状态.png

-
个人对话—拉黑状态/已读状态
个人对话—拉黑状态/已读状态
-

状态:黑名单产品规则新增。

- -

3.8 个人对话—删除状态.png

-
个人对话—删除状态
个人对话—删除状态
-

状态:新增好友删除后的发送约束。

- -

3.9 个人对话-键盘收起状态.png

-
个人对话-键盘收起状态
个人对话-键盘收起状态
-

状态:纯客户端。

- -

3.10 个人对话-发各种文件状态.png

-
个人对话-发各种文件状态
个人对话-发各种文件状态
-

状态:面板纯客户端;上传需改造。

- -

3.11 个人对话-发各种文件状态(1).png

-

!个人对话-发各种文件状态(1).png)

-

状态:需改造产品消息 Schema。

- -

3.12 个人对话-发送名片状态.png

-
个人对话-发送名片状态
个人对话-发送名片状态
-

状态:新增产品消息类型。

- -

4. 消息输入、媒体内容与消息操作

-

4.1 键盘展开.png

-
键盘展开
键盘展开
-

状态:纯客户端。

- -

4.2 @人员—选择提醒人.png

-
@人员—选择提醒人
@人员—选择提醒人
-

状态:新增群成员查询。

- -

4.3 @人员—完成.png

-
@人员—完成
@人员—完成
-

状态:纯客户端选择;发送协议需改造。

- -

4.4 @人员状态.png

-
@人员状态
@人员状态
-

状态:需改造提醒和 Push。

- -

4.5 表情包展开(发送按钮默认状态).png

-
表情包展开(发送按钮默认状态)
表情包展开(发送按钮默认状态)
-

状态:纯客户端。

- -

4.6 表情包发送页面.png

-
表情包发送页面
表情包发送页面
-

状态:Unicode 表情使用已实现文本消息;独立表情包新增。

- -

4.7 语音—按住说话.png

-
语音—按住说话
语音—按住说话
-

状态:录音纯客户端;上传和语音消息需改造。

- -

4.8 语音按住说话.png

-
语音按住说话
语音按住说话
-

状态:纯客户端。

- -

4.9 内容沟通对话3-语音录音中.png

-
内容沟通对话3-语音录音中
内容沟通对话3-语音录音中
-

状态:录音纯客户端;临时状态可选改造。

- -

4.10 内容沟通对话3-图片和语音发送中.png

-
内容沟通对话3-图片和语音发送中
内容沟通对话3-图片和语音发送中
-

状态:客户端任务管理新增;服务端上传需改造。

- -

4.11 内容沟通对话3-语音/视频/图片状态.png

-
内容沟通对话3-语音/视频/图片状态
内容沟通对话3-语音/视频/图片状态
-

状态:需改造媒体 Schema 和受权下载。

- -

4.12 语音图片发送状态.png

-
语音图片发送状态
语音图片发送状态
-

状态:需改造。

- -

4.13 上传文件.png

-
上传文件
上传文件
-

状态:纯客户端视觉资源。

- -

4.14 文件发送状态.png

-
文件发送状态
文件发送状态
-

状态:上传已实现基础能力;消息引用和 ACL 需改造。

- -

4.15 多选-选中对话框.png

-
多选-选中对话框
多选-选中对话框
-

状态:选择纯客户端;批量动作新增。

- -

4.16 多选-选中对话内容-收藏成功.png

-
多选-选中对话内容-收藏成功
多选-选中对话内容-收藏成功
-

状态:新增。

- -

4.17 转发-单选.png

-
转发-单选
转发-单选
-

状态:新增。

- -

4.18 转发-多选.png

-
转发-多选
转发-多选
-

状态:新增。

- -

4.19 转发—逐条转发.png

-
转发—逐条转发
转发—逐条转发
-

状态:新增。

- -

4.20 转发—合并转发—多选状态.png

-
转发—合并转发—多选状态
转发—合并转发—多选状态
-

状态:新增。

- -

5. 通讯录、好友关系与黑名单

-

5.1 通讯录.png

-
通讯录
通讯录
-

状态:新增产品通讯录接口。

- -

5.2 新朋友.png

-
新朋友
新朋友
-

状态:新增。

- -

5.3 添加好友.png

-
添加好友
添加好友
-

状态:新增。

- -

5.4 添加好友(1).png

-

!添加好友(1).png)

-

状态:新增。

- -

5.5 添加好友-输入精准用户ID.png

-
添加好友-输入精准用户ID
添加好友-输入精准用户ID
-

状态:新增。

- -

5.6 添加好友验证.png

-
添加好友验证
添加好友验证
-

状态:新增。

- -

5.7 好友个人主页.png

-
好友个人主页
好友个人主页
-

状态:新增。

- -

5.8 群聊—非好友个人主页.png

-
群聊—非好友个人主页
群聊—非好友个人主页
-

状态:新增。

- -

5.9 好友—聊天设置—未拉黑状态.png

-
好友—聊天设置—未拉黑状态
好友—聊天设置—未拉黑状态
-

状态:部分 Topic 设置已实现;产品设置新增。

- -

5.10 好友—聊天设置—拉黑状态.png

-
好友—聊天设置—拉黑状态
好友—聊天设置—拉黑状态
-

状态:新增。

- -

5.11 删除好友提示.png

-
删除好友提示
删除好友提示
-

状态:确认框纯客户端;删除接口新增。

- -

5.12 黑名单.png

-
黑名单
黑名单
-

状态:新增。

- -

5.13 黑名单提示(1).png

-

!黑名单提示(1).png)

-

状态:确认框纯客户端;写接口新增。

- -

5.14 黑名单提示.png

-
黑名单提示
黑名单提示
-

状态:图片文件名与画面语义不一致;按“清除聊天记录”实施,需改造。

- -

6. 群聊创建、资料维护与成员治理

-

6.1 群聊.png

-
群聊
群聊
-

状态:Tinode 群 Topic 已实现基础能力;产品群列表与配额需改造。

- -

6.2 空白页.png

-
空白页
空白页
-

状态:纯客户端。

- -

6.3 加入群聊.png

-
加入群聊
加入群聊
-

状态:新增邀请卡片和加入接口。

- -

6.4 内容沟通对话3-开启进群验证与拉人状态.png

-
内容沟通对话3-开启进群验证与拉人状态
内容沟通对话3-开启进群验证与拉人状态
-

状态:新增群成员事件。

- -

6.5 邀请进群申请.png

-
邀请进群申请
邀请进群申请
-

状态:新增。

- -

6.6 新邀请进群申请.png

-
新邀请进群申请
新邀请进群申请
-

状态:新增。

- -

6.7 群公告状态.png

-
群公告状态
群公告状态
-

状态:新增。

- -

6.8 群公告状态/邀请进群申请同时存在.png

-
群公告状态/邀请进群申请同时存在
群公告状态/邀请进群申请同时存在
-

状态:纯客户端组合;数据接口新增。

- -

6.9 群公告(对方查看状态).png

-
群公告(对方查看状态)
群公告(对方查看状态)
-

状态:新增。

- -

6.10 群聊设置.png

-
群聊设置
群聊设置
-

状态:部分订阅设置已实现;产品菜单新增。

- -

6.11 群资料.png

-
群资料
群资料
-

状态:需改造。

- -

6.12 设置群头像.png

-
设置群头像
设置群头像
-

状态:上传基础已实现;用途绑定需改造。

- -

6.13 设置昵称(1).png

-

!设置昵称(1).png)

-

状态:新增。

- -

6.14 群简介.png

-
群简介
群简介
-

状态:同 6.11,需改造。

- -

6.15 群标识.png

-
群标识
群标识
-

状态:角色已有基础数据;产品渲染需统一。

- -

6.16 群二维码页面.png

-
群二维码页面
群二维码页面
-

状态:新增。

- -

6.17 群组管理.png

-
群组管理
群组管理
-

状态:新增。

- -

6.18 设置群管理员.png

-
设置群管理员
设置群管理员
-

状态:新增。

- -

6.19 添加管理员.png

-
添加管理员
添加管理员
-

状态:新增。

- -

6.20 添加管理员-设置成功.png

-
添加管理员-设置成功
添加管理员-设置成功
-

状态:新增。

- -

6.21 添加管理员-设置成功(1).png

-

!添加管理员-设置成功(1).png)

-

状态:新增。

- -

6.22 删除管理员-设置成功.png

-
删除管理员-设置成功
删除管理员-设置成功
-

状态:新增。

- -

6.23 添加禁言成员.png

-
添加禁言成员
添加禁言成员
-

状态:新增。

- -

6.24 禁言的群成员.png

-
禁言的群成员
禁言的群成员
-

状态:新增。

- -

6.25 选择新群主.png

-
选择新群主
选择新群主
-

状态:新增。

- -

6.26 选择新群主提示.png

-
选择新群主提示
选择新群主提示
-

状态:新增。

- -

6.27 群文件.png

-
群文件
群文件
-

状态:新增群文件索引;底层文件存储已实现。

- -

6.28 群文件-上传状态.png

-
群文件-上传状态
群文件-上传状态
-

状态:需改造。

- -

7. 文件下载、收藏管理与聊天记录检索

-

7.1 我的收藏.png

-
我的收藏
我的收藏
-

状态:新增。

- -

7.2 我的收藏(1).png

-

!我的收藏(1).png)

-

状态:新增。

- -

7.3 我的收藏-文件.png

-
我的收藏-文件
我的收藏-文件
-

状态:新增。

- -

7.4 我的收藏-文本.png

-
我的收藏-文本
我的收藏-文本
-

状态:新增。

- -

7.5 我的收藏-语音.png

-
我的收藏-语音
我的收藏-语音
-

状态:新增。

- -

7.6 我的收藏—链接.png

-
我的收藏—链接
我的收藏—链接
-

状态:新增。

- -

7.7 收藏—文件(多选).png

-
收藏—文件(多选)
收藏—文件(多选)
-

状态:新增。

- -

7.8 收藏文件—分享给好友页面.png

-
收藏文件—分享给好友页面
收藏文件—分享给好友页面
-

状态:新增。

- -

7.9 文件-下载.png

-
文件-下载
文件-下载
-

状态:底层下载已实现租户校验;资源 ACL 需改造。

- -

7.10 文件-下载中.png

-
文件-下载中
文件-下载中
-

状态:客户端任务管理;服务端需支持 Range。

- -

7.11 文件-下载完成.png

-
文件-下载完成
文件-下载完成
-

状态:纯客户端完成态。

- -

7.12 查找聊天记录-搜索关键词选中状态(点击后跳出对应聊天内容).png

-
查找聊天记录-搜索关键词选中状态(点击后跳出对应聊天内容)
查找聊天记录-搜索关键词选中状态(点击后跳出对应聊天内容)
-

状态:新增服务端搜索。

- -

7.13 查找聊天记录-文件.png

-
查找聊天记录-文件
查找聊天记录-文件
-

状态:新增。

- -

7.14 查找聊天记录-链接.png

-
查找聊天记录-链接
查找聊天记录-链接
-

状态:新增。

- -

7.15 查看聊天记录-图片.png

-
查看聊天记录-图片
查看聊天记录-图片
-

状态:新增。

- -

7.16 查看聊天记录-视频.png

-
查看聊天记录-视频
查看聊天记录-视频
-

状态:新增。

- -

8. 实时语音与视频通话

-

8.1 各种通话提示状态.png

-
各种通话提示状态
各种通话提示状态
-

状态:已实现基础通话记录替换;产品文案映射需统一。

- -

8.2 对方状态.png

-
对方状态
对方状态
-

状态:在线邀请已实现;Push 与产品鉴权需改造。

- -

8.3 语音通话-对方待接受状态.png

-
语音通话-对方待接受状态
语音通话-对方待接受状态
-

状态:发起与超时已实现基础;好友/拉黑/租户开关校验需改造。

- -

-{"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}}}
-
- -

8.4 语音通话-双方语音沟通中.png

-
语音通话-双方语音沟通中
语音通话-双方语音沟通中
-

状态:WebRTC 客户端能力;信令和服务端计时已实现。

- -

8.5 语音通话-麦克风与扬声器已关.png

-
语音通话-麦克风与扬声器已关
语音通话-麦克风与扬声器已关
-

状态:纯客户端设备控制。

- -

8.6 语音通话-麦克风与扬声器已开.png

-
语音通话-麦克风与扬声器已开
语音通话-麦克风与扬声器已开
-

状态:纯客户端设备控制。

- -

8.7 视频通话—等待对方接受接受邀请中.png

-
视频通话—等待对方接受接受邀请中
视频通话—等待对方接受接受邀请中
-

状态:基础通话信令已实现;隐私约束需落实。

- -

8.8 视频通话—等待对方接受接受邀请-摄像头关闭.png

-
视频通话—等待对方接受接受邀请-摄像头关闭
视频通话—等待对方接受接受邀请-摄像头关闭
-

状态:纯客户端状态;媒体状态事件需改造。

- -

8.9 双方视频通话中.png

-
双方视频通话中
双方视频通话中
-

状态:WebRTC 客户端能力;服务端信令已具备基础。

- -

8.10 双方视频通话中-对方关闭摄像头状态.png

-
双方视频通话中-对方关闭摄像头状态
双方视频通话中-对方关闭摄像头状态
-

状态:现阶段纯客户端;可选媒体状态事件需改造。

- -

8.11 视频与语音通话悬浮状态中.png

-
视频与语音通话悬浮状态中
视频与语音通话悬浮状态中
-

状态:纯客户端。

- -

9. 企业工作台与办公协同

-

9.1 工作台.png

-
工作台
工作台
-

状态:新增。

- -

9.2 部门组织框架.png

-
部门组织框架
部门组织框架
-

状态:新增。

- -

9.3 会议通知-发起人状态.png

-
会议通知-发起人状态
会议通知-发起人状态
-

状态:新增。

- -

9.4 会议通知-接收人状态.png

-
会议通知-接收人状态
会议通知-接收人状态
-

状态:新增。

- -

9.5 日报.png

-
日报
日报
-

状态:新增。

- -

9.6 日报-普通用户页面.png

-
日报-普通用户页面
日报-普通用户页面
-

状态:新增。

- -

9.7 日报-管理员页面.png

-
日报-管理员页面
日报-管理员页面
-

状态:新增。

- -

9.8 日报-查看详情.png

-
日报-查看详情
日报-查看详情
-

状态:新增。

- -

9.9 月报(1).png

-

!月报(1).png)

-

状态:新增。

- -

9.10 月报.png

-
月报
月报
-

状态:新增。

- -

9.11 月报-详情页.png

-
月报-详情页
月报-详情页
-

状态:新增。

- -

9.12 总经理信箱.png

-
总经理信箱
总经理信箱
-

状态:新增。

- -

9.13 总经理信箱-文案提示.png

-
总经理信箱-文案提示
总经理信箱-文案提示
-

状态:新增配置接口。

- -

10. 个人资料、通用设置、隐私与青少年模式

-

10.1 我的.png

-
我的
我的
-

状态:基础用户资料已有;产品菜单新增。

- -

10.2 个人信息.png

-
个人信息
个人信息
-

状态:Tinode me 资料更新已有基础;目标产品接口需改造。

- -

10.3 设置昵称.png

-
设置昵称
设置昵称
-

状态:需改造。

- -

10.4 出生日期.png

-
出生日期
出生日期
-

状态:新增产品字段。

- -

10.5 个性签名.png

-
个性签名
个性签名
-

状态:需改造。

- -

10.6 二维码.png

-
二维码
二维码
-

状态:图标纯客户端;二维码协议新增。

- -

10.7 设置(iOS).png

-
设置(iOS)
设置(iOS)
-

状态:菜单纯客户端;数据接口部分新增。

- -

10.8 设置(安卓).png

-
设置(安卓)
设置(安卓)
-

状态:菜单纯客户端;业务协议同 10.7。

- -

10.9 清空缓存的提示.png

-
清空缓存的提示
清空缓存的提示
-

状态:纯客户端。

- -

10.10 版本更新页面.png

-
版本更新页面
版本更新页面
-

状态:新增 App 配置接口。

- -

10.11 发现新版本.png

-
发现新版本
发现新版本
-

状态:纯客户端弹窗;数据来自 10.10。

- -

10.12 隐私设置.png

-
隐私设置
隐私设置
-

状态:新增。

- -

10.13 隐私设置-添加我的方式选项.png

-
隐私设置-添加我的方式选项
隐私设置-添加我的方式选项
-

状态:新增。

- -

10.14 青少年模式未开启(安卓).png

-
青少年模式未开启(安卓)
青少年模式未开启(安卓)
-

状态:新增。

- -

10.15 青少年模式提示(安卓).png

-
青少年模式提示(安卓)
青少年模式提示(安卓)
-

状态:弹窗纯客户端;文案由服务端配置。

- -

10.16 青少年模式已开启(安卓).png

-
青少年模式已开启(安卓)
青少年模式已开启(安卓)
-

状态:新增。

- -

10.17 关闭青少年模式(安卓).png

-
关闭青少年模式(安卓)
关闭青少年模式(安卓)
-

状态:新增。

- -

10.18 获取推送权限(安卓).png

-
获取推送权限(安卓)
获取推送权限(安卓)
-

状态:权限引导纯客户端;设备注册需改造。

- -

10.19 提醒(安卓).png

-
提醒(安卓)
提醒(安卓)
-

状态:纯客户端。

- -

10.20 相机说明(安卓).png

-
相机说明(安卓)
相机说明(安卓)
-

状态:纯客户端。

- -

10.21 麦克风说明(安卓).png

-
麦克风说明(安卓)
麦克风说明(安卓)
-

状态:纯客户端。

- -

11. 反诈安全、举报与意见反馈

-

11.1 反诈弹窗(安卓).png

-
反诈弹窗(安卓)
反诈弹窗(安卓)
-

状态:新增风控决策协议。

- -

11.2 反诈弹窗(二次提示安卓).png

-
反诈弹窗(二次提示安卓)
反诈弹窗(二次提示安卓)
-

状态:新增。

- -

11.3 举报.png

-
举报
举报
-

状态:新增。

- -

11.4 举报(1).png

-

!举报(1).png)

-

状态:新增。

- -

11.5 意见反馈.png

-
意见反馈
意见反馈
-

状态:新增。

- -

12. 联调实施清单

-

12.1 当前可直接联调的基础能力

-
    -
  1. WebSocket /v0/channels、Wire Version 0.25 和最低版本 0.20。
  2. -
  3. 第一条 hi 携带企业码并固定 Session 租户;响应包含公开租户信息、限制、ICE 配置和呼叫超时。
  4. -
  5. basic/token 登录,sub/get/set/pub/leave/del/note 基础 Tinode 协议。
  6. -
  7. pub 成功返回 202 + seq,Topic 消息与基础回执同步。
  8. -
  9. /v0/file/u/ 和 /v0/file/s/{file_id} 的基础上传下载及租户存储隔离。
  10. -
-

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、11.4~11.5
P1好友申请、好友关系、黑名单状态机第 3、5 章
P1群成员、角色、禁言、公告、邀请审批和配额第 6 章
P1收藏、转发和聊天记录搜索第 4、7 章
P1通话媒体状态、结果消息、Push 来电第 8 章
P2工作台、组织、会议、日报、月报、信箱第 9 章
P2隐私、青少年模式、反诈、举报、反馈第 10、11 章
-

12.3 前后端联调最低验收用例

- - - - - - - - - - - - - -
编号场景预期结果
T-01A 企业 Token 连接 B 企业 Session登录失败 TENANT_MISMATCH,不产生用户/Topic 数据
T-02企业停用后使用旧 Token 登录和请求 HTTPWS/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 过期后刷新并 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、错误码、权限规则和可用测试租户;前端须提供请求日志脱敏、断线/重试行为和四态页面(加载、空、成功、失败)。联调完成必须逐图勾选对应协议、至少覆盖成功、权限拒绝、重复提交、断网恢复和跨租户五类场景。

- \ No newline at end of file diff --git a/docs/IM-前后端对接文档-禅道发布版.md b/docs/IM-前后端对接文档-禅道发布版.md deleted file mode 100644 index 35c9b2e..0000000 --- a/docs/IM-前后端对接文档-禅道发布版.md +++ /dev/null @@ -1,1813 +0,0 @@ -# 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/tinode.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]: <client-api-key> -HMAC salt: <server-hmac-salt> -``` - -API Key、企业码和用户 Token 是三个不同概念: - -| 值 | 取得时间 | 作用 | 是否代表用户身份 | -| --- | --- | --- | --- | -| `api_key` | 应用发布/环境配置时预置 | 允许应用访问 Tinode 入口 | 否 | -| `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-Tinode-APIKey` | 同上 | -| 产品 HTTP | `https://{host}/{api_path}v1/im/*` | `Authorization: Bearer <params.token>` | 好友、群、收藏、搜索、工作台和设置 | -| 文件 HTTP | `POST /v0/file/u/`、`GET /v0/file/s/{file_id}` | 当前用 `X-Tinode-Auth: token <base64_token>` | 文件上传下载;禁止 Query 传 Token | - -HTTP 请求统一通过 `X-Tinode-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)`。Token 登录必须把上次 `params.token` 的 JSON 字符串原样填入 `secret`,不能再次 Base64 编码。登录成功 `{ctrl}` 的 `params` 至少包含 `user`、`authlvl`、`token`、`expires`。产品 HTTP 也使用同一个 `params.token`:`Authorization: Bearer <params.token>`,客户端不要解码或二次编码。Token 对客户端是不可解析的 opaque 字符串,只能存入系统安全存储。`id` 只用于匹配本次 Session 的命令响应,不是业务幂等键。 - -`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-Tinode-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-Tinode-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-Tinode-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.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_event`。自定义表情启用后追加 `sticker`。媒体内容只保存 `file_id`、文件元数据和缩略图 ID,不保存可长期绕过鉴权的公共 URL。 - -### 0.7 HTTP 幂等、重试和安全 - -1. 创建、提交、同意、拒绝、删除、转让、收藏、转发等写请求必须携带 `Idempotency-Key`。 -2. GET、断线同步和同幂等键写请求可自动重试;密码、验证码和风控确认只按接口返回的 `retry_after_seconds` 重试。 -3. `401 TOKEN_EXPIRED` 只允许一次静默刷新;刷新失败必须退出登录。`403` 不重试,`429` 按服务端退避。 -4. 服务端必须在最终写入前再次校验好友关系、群角色、禁言、消息归属、文件 ACL、租户配额和风控,不能信任前端按钮状态。 -5. 普通用户禁止向系统 Topic 发布;系统通知必须由服务端受控身份生成。 - -## 1. 账号注册、登录与身份安全 - -### 1.1 企业码登录.png - -> 图片:企业码登录(images/企业码登录.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-Tinode-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.2 登录页面.png - -> 图片:登录页面(images/登录页面.png) -**状态:基本账号密码登录已实现;WS Token 可复用于产品 HTTP。** - -- 前端动作:账号、密码非空且已勾选协议后启用登录;密码仅驻留当前页面内存。 -- 协议:已完成 `hi` 的 WebSocket 发送 `{login scheme:"basic"}`,`secret=base64(username:password)`;不得先登录再传企业码。 -- 成功:读取 `{ctrl params.user/token/expires/authlvl}`,Token 存 Keychain/Keystore;立即 `sub me`,再加载会话。前端不解码 Token,也不再次 Base64 编码。 -- 产品 HTTP:使用同一个 `params.token` 作为 `Authorization: Bearer <params.token>`;所有 `/v1/im/*` HTTP 请求仍必须带 `X-Tinode-APIKey`、`X-IM-Schema: 1`,写请求再带 `Idempotency-Key`。 -- 待改造:后续可演进为独立短期 access token 与可轮换 refresh token;当前版本先复用 Tinode 登录 Token,并已绑定 `tenant_id/user_id/auth_version`。修改密码、封禁账号后旧 Token 应失效。 -- 错误:`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 - -> 图片:登录页面-密码错误(images/登录页面-密码错误.png) -**状态:已实现基础错误,错误码和限流需改造。** - -- 触发:`login` 返回 `{ctrl code:401}` 或产品错误 `AUTH_FAILED`。 -- 前端:保留账号、清空密码、聚焦密码框;统一显示“账号或密码错误”,不得显示账号是否存在。 -- 服务端:按 `tenant + account + device/IP` 限流,连续失败返回 `AUTH_RATE_LIMITED` 和 `retry_after_seconds`;日志仅记录账号哈希和 Request ID。 -- 验收:同一不存在账号与错误密码在响应码、耗时区间、前端文案上保持一致。 - -### 1.4 注册页面.png - -> 图片:注册页面(images/注册页面.png) -**状态:Tinode `{acc}` 基础注册和验证码预校验已实现;短信供应商接入前固定测试码为 `123456`。** - -- 获取验证码:调用 `POST /v1/im/auth/verification-codes`,必须带 `X-Tinode-APIKey`、`X-Tenant-Code`、`X-IM-Schema: 1`,Body `{"scene":"register","phone":"+86138..."}`;成功返回 `challenge_id`、`expires_in_seconds`、`resend_after_seconds`。 -- 提交注册:前端校验密码一致和协议后,先调用 `POST /v1/im/auth/verification-codes/{challenge_id}/verify`,Body `{"code":"123456"}`,获得一次性 `verification_token`。 -- WebSocket:已完成同一企业 `hi` 后发送 `{acc}`。把一次性验证结果放在 `{acc}.cred[0].resp`,它必须是 HTTP verify 返回的 `verification_token`,不是短信验证码 `123456`。不得写入 `desc.private`,也不得在 `{acc}` 顶层传 `tags`。服务端会根据已校验的 `cred` 建立手机号凭证;客户端主动设置 `tags:["tel:..."]` 会被 Tinode 权限策略拒绝并返回 `403 params.what=tags`。 -- 手机号格式:HTTP 发码、验证码校验、`{acc}.secret` 中的 username、`cred.val` 必须使用同一个规范化号码,推荐 E.164 格式如 `+86138...`;不要一处使用 `+86138...`、另一处使用 `86138...`。 - -```json -{"acc":{"id":"acc-1","user":"new","scheme":"basic","secret":"base64(+86138...:password)","login":true,"cred":[{"meth":"tel","val":"+86138...","resp":"verification_token","params":{"challenge_id":"challenge-id"}}],"desc":{"public":{"fn":"用户昵称"}}}} -``` -- 成功:返回新 `user` 和登录 Token;客户端保存 `params.token`,随后执行 `sub me` 和会话恢复。创建用户前原子校验 `im_tenant_config.max_users`,超限返回 `TENANT_USER_QUOTA_EXCEEDED`。 -- 安全:手机号在租户内唯一;验证码只保存摘要、单次消费;密码按服务端策略校验并使用强哈希保存。 - -### 1.5 注册页面-倒计时.png - -> 图片:注册页面-倒计时(images/注册页面-倒计时.png) -**状态:新增。** - -- 倒计时以 1.4 返回的 `resend_after_seconds` 为准,并结合 `server_time` 计算,不能只依赖本地固定 60 秒。 -- 倒计时内前端禁用按钮;即使绕过按钮重复调用,服务端返回 `VERIFICATION_RATE_LIMITED` 和剩余秒数。 -- App 重进页面可使用内存/安全临时存储中的 `challenge_id` 恢复;超过 `expires_in_seconds` 必须重新发送,旧验证码失效。 - -### 1.6 忘记密码.png - -> 图片:忘记密码(images/忘记密码.png) -**状态:新增。** - -- 发码:`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":"..."}`,带幂等键。 -- 成功:服务端更新密码、递增 `auth_version`、撤销该用户全部 access/refresh token 和在线 Session;客户端清理本租户凭证并返回登录页。 -- 失败:`PASSWORD_POLICY_VIOLATION` 返回规则明细;验证码错误不返回账号存在性,达到上限后冻结 challenge。 - -### 1.7 身份验证(安卓).png - -> 图片:身份验证(安卓)(images/身份验证(安卓).png) -**状态:新增。** - -- 敏感操作先调用 `POST /v1/im/security/challenges`,Body `{"scene":"close_youth_mode","channel":"sms"}`;手机号由登录身份读取,客户端不提交目标手机号。 -- 发码/校验:服务端返回 `challenge_id` 和脱敏手机号;提交 `POST /v1/im/security/challenges/{id}/verify`,Body `{"code":"..."}`。 -- 成功返回 `action_token`、`action`、`expires_at`;后续敏感接口以 `X-Action-Token` 携带,Token 只能用于指定动作且单次消费。 -- 错误:`CHALLENGE_EXPIRED`、`CODE_INVALID`、`TOO_MANY_ATTEMPTS`;前端不得把短信验证码或 action token 落普通存储。 - -### 1.8 实名认证.png - -> 图片:实名认证(images/实名认证.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` 系统事件更新状态。 -- 安全:证件号传输和存储加密、展示脱敏;普通日志、搜索索引和消息中禁止出现原值;重复身份由租户策略决定并返回稳定错误。 - -### 1.9 实名认证-已实名.png - -> 图片:实名认证-已实名(images/实名认证-已实名.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 - -> 图片:实名认证(安卓)(images/实名认证(安卓).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 - -> 图片:设置独立密码(安卓)(images/设置独立密码(安卓).png) -**状态:新增。** - -- 设置:`POST /v1/im/settings/independent-password`,Body `{"password":"1234","purpose":"youth_mode","action_token":"..."}`;服务端只存强哈希,不返回密码。 -- 验证:`POST /v1/im/settings/independent-password/verify` 返回短时 `action_token`,用于关闭青少年模式或修改密码。 -- 规则:四位数字、禁止明显连续/重复组合;连续错误返回 `TOO_MANY_ATTEMPTS` 和 `retry_after_seconds`。前端输入框禁截屏/自动填充,离开页面立即清空。 -- 忘记密码:跳转 1.7 的短信身份验证,验证后重置,不提供明文找回。 - -## 2. 消息首页、全局导航与系统通知 - -### 2.1 消息.png - -> 图片:消息(images/消息.png) -**状态:会话订阅已实现;产品摘要和系统入口需改造。** - -- 登录后发送 `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=`。 -- 本地缓存按 `tenant_code + user_id` 隔离。切租户、退出或账号变化时不得复用另一身份的会话摘要。 - -### 2.2 消息-无网络状态.png - -> 图片:消息-无网络状态(images/消息-无网络状态.png) -**状态:需改造客户端恢复流程和消息幂等。** - -- 纯网络状态由客户端监听;断网时展示缓存并禁止需要在线确认的写操作,未发送消息进入本地 `pending` 队列。 -- 使用指数退避加随机抖动重连;恢复后严格执行 `hi -> token login -> sub me -> 恢复 Topic -> get data`。 -- 每个 Topic 按最后持久化的 `seq/read/recv` 增量拉取。重发消息复用 `x-im-client-mid`,服务端需实现去重并返回原 `seq`。 -- Token 失效转登录页;租户停用返回企业不可用页;禁止无限快速重连。 - -### 2.3 消息-更多.png - -> 图片:消息-更多(images/消息-更多.png) -**状态:菜单为纯客户端;权限数据新增。** - -- 菜单打开/关闭不调用服务端。菜单项来自登录后 `GET /v1/im/bootstrap` 的 `features` 与 `permissions`。 -- `create_group=false` 时隐藏“创建群聊”;`user_search=false` 时隐藏“添加好友”;扫码权限只影响实际扫码,不影响服务端授权。 -- 各入口协议分别见 5.3、6.1、10.1;服务端在实际写接口再次鉴权,不能只依赖隐藏按钮。 - -### 2.4 消息页面—长按弹出.png - -> 图片:消息页面—长按弹出(images/消息页面—长按弹出.png) -**状态:基础消息操作部分已实现;产品权限矩阵需改造。** - -- 长按本身不请求。客户端根据消息的 `type/from/seq/created_at/status`、当前 Topic 权限和服务端下发的 `message_policy` 预裁剪菜单。 -- 引用/复制为本地动作;收藏见 4.16;转发见 4.17~4.20;删除使用 Tinode `{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 - -> 图片:系统消息(images/系统消息.png) -**状态:需改造。** - -- `GET /v1/im/system-notifications?cursor=` 返回 `id,event_type,title,summary,created_at,read_at,action`;新通知通过服务端专用系统 Topic 的 `system_event` 消息推送。 -- 新设备登录事件内容为 `device_name,platform,ip_masked,login_at,security_action`;IP 只下发必要脱敏值。 -- 已读:`POST /v1/im/system-notifications/{id}/read`,带幂等键;跳转目标只接受服务端白名单 `action.type` 和内部参数。 -- 当前普通认证用户可向 `sys` Topic 发布的路径必须封禁。客户端不得渲染普通用户伪造的系统卡片为可信通知。 - -## 3. 单聊会话、会话建立与消息状态 - -### 3.1 个人对话框(对方的状态).png - -> 图片:个人对话框(对方的状态)(images/个人对话框(对方的状态).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)(images/个人对话框(对方的状态)(1).png) -**状态:在线状态已具备基础能力;关系文案新增。** - -- 订阅 P2P Topic 后用 `{pres}` 更新在线/离线,`last_seen` 仅在对方隐私允许时展示。 -- `GET /v1/im/conversations/{topic}/context` 返回 `display_name,avatar_file_id,online_visibility,relationship`。 -- 好友关系变化由服务端生成 `system_event{event_type:"friend_relationship_changed"}`,客户端不得根据本地动作自行永久插入系统记录。 - -### 3.3 个人对话框(我的状态).png - -> 图片:个人对话框(我的状态)(images/个人对话框(我的状态).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)(images/个人对话框(我的状态)(1).png) -**状态:发送和读回执已实现基础能力;送达状态映射需统一。** - -- 本地生成 `client_mid` 后显示 `sending`;`pub` 收到 `202 + seq` 更新为 `sent`;失败保留同一 `client_mid` 重试。 -- 对方 `note what:"recv"` 更新 `delivered`,`note what:"read"` 更新 `read`;多设备取服务端最大游标。 -- 状态只附着在气泡元数据,不修改消息正文。服务端拒绝时映射为 `failed(reason)`,不得伪装成已发送。 - -### 3.5 开启沟通页面.png - -> 图片:开启沟通页面(images/开启沟通页面.png) -**状态:新增好友审批与 Topic 联动。** - -- 好友申请通过后,服务端在同一事务内建立双方关系和 P2P 订阅,并生成 `friend_relationship_changed:accepted` 系统事件。 -- 客户端收到事件或重新查询 `context.relationship=friend` 后解锁输入区;不得仅因本地“通过”按钮成功动画解锁。 -- 首次进入订阅 P2P Topic,再按 `since` 拉取申请上下文和正常消息。 - -### 3.6 双方沟通状态.png - -> 图片:双方沟通状态(images/双方沟通状态.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 - -> 图片:个人对话—拉黑状态/已读状态(images/个人对话—拉黑状态/已读状态.png) -**状态:黑名单产品规则新增。** - -- 拉黑后 `context.relationship=blocked_by_me|blocked_by_peer`、`can_send=false`;服务端 `pub` 返回统一 `MESSAGE_NOT_ALLOWED`,不得向发送者泄露具体是对方拉黑。 -- 失败消息只保存在发送端本地并显示失败,不下发给对方、不写入正常消息表。 -- 已读仍按历史最大 `read` 渲染;拉黑不能回滚既有回执。解除拉黑见 5.10、5.12。 - -### 3.8 个人对话—删除状态.png - -> 图片:个人对话—删除状态(images/个人对话—删除状态.png) -**状态:新增好友删除后的发送约束。** - -- 删除好友使用 `DELETE /v1/im/friends/{user_id}`;成功后双方关系变为 `none`,服务端推送关系变化事件。 -- 后续 `pub` 返回 `FRIEND_RELATION_REQUIRED`;前端保留失败草稿,提供“重新添加好友”入口,不自动发申请。 -- 历史聊天是否保留由 Topic 权限决定,删除好友不等于删除双方历史消息。 - -### 3.9 个人对话-键盘收起状态.png - -> 图片:个人对话-键盘收起状态(images/个人对话-键盘收起状态.png) -**状态:纯客户端。** - -- 键盘、表情和附加面板收起不调用服务端;保持列表锚点和未发送草稿。 -- 草稿默认按 `tenant + user + topic` 本地加密保存;若实现多端草稿,再单独调用 `PUT /v1/im/topics/{topic}/draft`,不得混入聊天消息。 - -### 3.10 个人对话-发各种文件状态.png - -> 图片:个人对话-发各种文件状态(images/个人对话-发各种文件状态.png) -**状态:面板纯客户端;上传需改造。** - -- 打开面板不请求。选择前依据 `context.capabilities` 判断图片、视频、语音、文件是否允许,调用系统选择器/相机。 -- 上传前校验 `mime,size` 和 `bootstrap.limits.max_file_size`;服务端仍必须重新校验租户配额、文件类型和会话权限。 -- 具体上传/发消息使用 3.6 协议;相机和麦克风授权见 10.20、10.21。 - -### 3.11 个人对话-发各种文件状态(1).png - -> 图片:个人对话-发各种文件状态(1)(images/个人对话-发各种文件状态(1).png) -**状态:需改造产品消息 Schema。** - -- 客户端按 `x-im-type` 选择文本、名片、文件等卡片,公共字段统一使用 `from,topic,seq,ts,head,content`。 -- 每种卡片必须实现未知字段忽略、未知类型占位、发送/失败/回执状态和长按动作。 -- 重新安装或换设备后仅凭服务端消息即可还原卡片,不能依赖发送端本地路径或临时 URL。 - -### 3.12 个人对话-发送名片状态.png - -> 图片:个人对话-发送名片状态(images/个人对话-发送名片状态.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/{user_id}/profile`;用户不可见或已离职时显示失效卡片,不跨租户查询。 - -## 4. 消息输入、媒体内容与消息操作 - -### 4.1 键盘展开.png - -> 图片:键盘展开(images/键盘展开.png) -**状态:纯客户端。** - -- 键盘展开、列表上推和光标变化不调用服务端;草稿按 `tenant + user + topic` 保存。 -- 点击发送时才校验 `context.can_send` 并执行 `pub text`;正文去首尾策略、最大长度从 `bootstrap.limits.max_text_length` 获取,服务端再次校验。 - -### 4.2 @人员—选择提醒人.png - -> 图片:@人员—选择提醒人(images/@人员—选择提醒人.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 - -> 图片:@人员—完成(images/@人员—完成.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 - -> 图片:@人员状态(images/@人员状态.png) -**状态:需改造提醒和 Push。** - -- 消息仍通过 `{data}` 下行,客户端使用结构化 `mentions` 高亮,禁止通过字符串扫描昵称判断。 -- 当前用户被提及时会话摘要标记 `mentioned=true`;读到对应 `seq` 后随 `note read` 清除。 -- Push 服务按结构化目标生成“有人@你”文案;已退群用户不能收到新提醒,但历史消息仍按快照渲染。 - -### 4.5 表情包展开(发送按钮默认状态).png - -> 图片:表情包展开(发送按钮默认状态)(images/表情包展开(发送按钮默认状态).png) -**状态:纯客户端。** - -- 面板、分类、最近使用和删除键均本地处理;未选择内容且文本为空时禁用发送。 -- Unicode 表情作为普通 `text` 内容发送。自定义表情资源首版未定义,不得把本地资源路径直接发送。 - -### 4.6 表情包发送页面.png - -> 图片:表情包发送页面(images/表情包发送页面.png) -**状态:Unicode 表情使用已实现文本消息;独立表情包新增。** - -- 文本表情按 `pub x-im-type:text` 发送。独立自定义表情若启用,使用 `x-im-type:"sticker"`,`content={pack_id,sticker_id,file_id,width,height}`。 -- 自定义表情发送前服务端校验表情包属于当前租户且可用;未知表情显示占位,不使用任意外链 URL。 - -### 4.7 语音—按住说话.png - -> 图片:语音—按住说话(images/语音—按住说话.png) -**状态:录音纯客户端;上传和语音消息需改造。** - -- 按下前检查麦克风权限和 `context.capabilities.voice`;权限不足进入 10.21,不发请求。 -- 松开发送:编码为服务端允许的 MIME,校验最短/最长时长,上传后 `pub x-im-type:voice`,内容含 `file_id,duration_ms,size,mime,waveform`。 -- 上滑取消、过短、系统来电打断时删除临时文件,不调用上传接口。 - -### 4.8 语音按住说话.png - -> 图片:语音按住说话(images/语音按住说话.png) -**状态:纯客户端。** - -- 音量幅度、上滑取消区域和浮层颜色来自本地录音会话,不上传实时音量。 -- 进入取消区只改变本地状态;最终抬手决定删除或执行 4.7 的上传发送。 - -### 4.9 内容沟通对话3-语音录音中.png - -> 图片:内容沟通对话3-语音录音中(images/内容沟通对话3-语音录音中.png) -**状态:录音纯客户端;临时状态可选改造。** - -- 录音期间客户端锁定其他媒体入口。默认不向群成员广播“正在录音”,以减少隐私暴露和状态风暴。 -- 若产品确认需要,可发送不落库的 `{note what:"kpa",topic}`;服务端限频,其他端超时自动消失,不能作为聊天记录。 - -### 4.10 内容沟通对话3-图片和语音发送中.png - -> 图片:内容沟通对话3-图片和语音发送中(images/内容沟通对话3-图片和语音发送中.png) -**状态:客户端任务管理新增;服务端上传需改造。** - -- 每个待发送项保存 `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-语音/视频/图片状态(images/内容沟通对话3-语音/视频/图片状态.png) -**状态:需改造媒体 Schema 和受权下载。** - -- 语音渲染 `duration_ms,waveform`,未听状态保存在客户端并可选跨端同步;视频渲染 `thumbnail_file_id,duration_ms,width,height`;图片渲染缩略图和原图 ID。 -- 预览前使用认证文件接口获取,服务端校验当前用户仍可访问引用消息;附件撤回或过期返回 `FILE_UNAVAILABLE`。 -- 缩略图和原文件分别授权,客户端缓存键必须包含租户与用户。 - -### 4.12 语音图片发送状态.png - -> 图片:语音图片发送状态(images/语音图片发送状态.png) -**状态:需改造。** - -- 图片选择后立即显示本地缩略图,上传成功后只替换资源来源,不改变 `client_mid` 或列表位置。 -- 失败展示重试按钮;重试复用同一上传任务和消息幂等 ID。语音播放与图片上传状态相互独立。 - -### 4.13 上传文件.png - -> 图片:上传文件(images/上传文件.png) -**状态:纯客户端视觉资源。** - -- 该图片仅定义统一上传图标和无障碍名称“上传文件”,不产生网络请求。 -- 文件助手、群文件、会话附件、反馈证据实际上传时统一走 0.2 文件接口,并额外提交业务 `purpose` 以便服务端执行权限和保留期策略。 - -### 4.14 文件发送状态.png - -> 图片:文件发送状态(images/文件发送状态.png) -**状态:上传已实现基础能力;消息引用和 ACL 需改造。** - -- 上传成功后发送 `x-im-type:file`,`content={file_id,name,size,mime,extension,checksum}`;文件名做长度和控制字符清理。 -- 服务端在消息落库事务中关联文件;未关联的临时上传按 TTL 清理。卡片下载见 7.9~7.11。 -- 多文件逐个生成 `client_mid`,某个失败不能改变其他文件状态。 - -### 4.15 多选-选中对话框.png - -> 图片:多选-选中对话框(images/多选-选中对话框.png) -**状态:选择纯客户端;批量动作新增。** - -- 客户端按 `(topic,seq)` 保存已选项并显示数量;默认最多 100 条,超限在本地阻止。 -- 转发、收藏、删除前把选中列表提交对应接口;服务端逐条校验仍可见、类型支持和权限,返回 `succeeded[]` 与 `failed[{seq,reason}]`。 -- 不允许只因第一条可操作就假定整批可操作。 - -### 4.16 多选-选中对话内容-收藏成功.png - -> 图片:多选-选中对话内容-收藏成功(images/多选-选中对话内容-收藏成功.png) -**状态:新增。** - -- `POST /v1/im/favorites/batch`,Body `{"messages":[{"topic":"grp...","seq":"123"}]}`,带幂等键。 -- 返回 `created_count,existing_count,failed[]`;同一用户对同一 `topic+seq` 重复收藏不新增记录。 -- 收藏保存原消息引用、不可变展示快照和附件授权关系;成功 Toast 只在至少一项成功时展示,部分失败需给出汇总。 - -### 4.17 转发-单选.png - -> 图片:转发-单选(images/转发-单选.png) -**状态:新增。** - -- 目标列表:`GET /v1/im/forward-targets?query=&cursor=`,只返回 `can_receive_forward=true` 的会话和文件助手。 -- 选中后本地进入确认页,不立即发送;服务端在最终转发接口再次校验目标 Topic、原消息和附件权限。 - -### 4.18 转发-多选.png - -> 图片:转发-多选(images/转发-多选.png) -**状态:新增。** - -- 目标查询同 4.17;多选最多取 `bootstrap.limits.max_forward_targets`,首版建议 9。 -- 确认前只保存目标 Topic 字符串;不得缓存或提交 `tenant_id`。跨租户 Topic 在服务端统一拒绝。 - -### 4.19 转发—逐条转发.png - -> 图片:转发—逐条转发(images/转发—逐条转发.png) -**状态:新增。** - -- `POST /v1/im/messages/forward`,Body `{"mode":"separate","sources":[{"topic":"p2p...","seq":"12"}],"target_topics":["grp..."],"comment":"..."}`。 -- 返回每个目标和源消息的 `client_mid/seq/status`;服务端保持源顺序,重新生成目标消息,不能复用原 `seq`。 -- 附件必须建立新的授权引用;原消息已撤回、过期或不可见时该项失败。留言作为每个目标的一条独立文本消息发送。 - -### 4.20 转发—合并转发—多选状态.png - -> 图片:转发—合并转发—多选状态(images/转发—合并转发—多选状态.png) -**状态:新增。** - -- 调用 4.19 接口,`mode:"bundle"`;服务端生成 `forward_bundle`,内容含 `bundle_id,title,item_count,preview[]`,完整内容通过 `GET /v1/im/forward-bundles/{bundle_id}?cursor=` 获取。 -- 快照保留显示名、时间和内容摘要,对撤回/敏感字段按策略脱敏;不得允许接收端利用 Bundle 获取原会话额外权限。 -- 多目标共享不可变 Bundle 内容但分别生成目标消息与授权记录。 - -## 5. 通讯录、好友关系与黑名单 - -### 5.1 通讯录.png - -> 图片:通讯录(images/通讯录.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` 返回。列表缓存以租户和用户隔离。 - -### 5.2 新朋友.png - -> 图片:新朋友(images/新朋友.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` 并附当前状态,前端刷新该项。 - -### 5.3 添加好友.png - -> 图片:添加好友(images/添加好友.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)(images/添加好友(1).png) -**状态:新增。** - -- 获取二维码:`POST /v1/im/me/qrcodes`,Body `{"purpose":"add_friend","expires_in_seconds":86400}`,返回 `qr_token,qr_payload,expires_at,display_profile`。 -- 二维码只含不透明、签名、带租户和用途约束的短期 Token,不直接放手机号、用户 ID 或 access token。 -- 分享/保存图片是客户端动作;扫码解析始终调用 5.3,过期返回 `QR_CODE_EXPIRED`,篡改返回 `QR_CODE_INVALID`。 - -### 5.5 添加好友-输入精准用户ID.png - -> 图片:添加好友-输入精准用户ID(images/添加好友-输入精准用户ID.png) -**状态:新增。** - -- 精准查询仍调用 `GET /v1/im/users/search?q=&type=exact_account`;手机号查询仅当租户和目标隐私同时允许时使用 `type=phone`。 -- 每项返回公开资料和 `action=add|chat|pending|none`。前端不得根据是否返回手机号推断账户存在。 -- 搜索限流返回 `SEARCH_RATE_LIMITED`;禁止客户端批量枚举 ID。 - -### 5.6 添加好友验证.png - -> 图片:添加好友验证(images/添加好友验证.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 - -> 图片:好友个人主页(images/好友个人主页.png) -**状态:新增。** - -- `GET /v1/im/users/{user_id}/profile` 返回按当前查看人裁剪后的 `display_name,remark,avatar_file_id,gender,region,public_account,signature,relationship,capabilities`。 -- 发消息使用返回的 P2P `topic`;没有 Topic 时调用 `POST /v1/im/conversations/p2p`,Body `{"peer_user_id":"..."}` 并由服务端验证好友关系。 -- 音视频按钮只按 `capabilities.can_call` 展示,真正呼叫时再次鉴权。隐藏字段必须从响应中省略,不能只由前端遮挡。 - -### 5.8 群聊—非好友个人主页.png - -> 图片:群聊—非好友个人主页(images/群聊—非好友个人主页.png) -**状态:新增。** - -- `GET /v1/im/groups/{topic}/members/{user_id}/profile` 返回群内昵称、角色和允许公开的账号资料。 -- `can_add_friend` 同时受群 `allow_member_add_friend`、目标隐私和租户策略控制;为 false 时隐藏添加入口。 -- 从该页申请时 5.6 传 `source:"group"` 和当前 Topic;服务端校验双方仍在群内。 - -### 5.9 好友—聊天设置—未拉黑状态.png - -> 图片:好友—聊天设置—未拉黑状态(images/好友—聊天设置—未拉黑状态.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 - -> 图片:好友—聊天设置—拉黑状态(images/好友—聊天设置—拉黑状态.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 - -> 图片:删除好友提示(images/删除好友提示.png) -**状态:确认框纯客户端;删除接口新增。** - -- 确认调用 `DELETE /v1/im/friends/{user_id}`,带幂等键。是否清除本地历史由客户端在成功后独立处理,不放入删除好友请求。 -- 服务端解除双向好友关系但不替对方删除历史;返回 `relationship:"none",topic`。当前用户是否删除本地/服务端可见记录是独立选择。 -- 取消不请求;确认后清理页面缓存并等待关系事件校正其他设备。 - -### 5.12 黑名单.png - -> 图片:黑名单(images/黑名单.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)(images/黑名单提示(1).png) -**状态:确认框纯客户端;写接口新增。** - -- 确认后调用 `PUT /v1/im/blocks/{user_id}`,相同幂等键重复提交返回当前 `blocked=true`。 -- 服务端在同一事务内更新黑名单和通信 ACL,并写审计;取消不调用接口。 -- 若对象跨租户或不存在统一返回 `RESOURCE_NOT_FOUND`。 - -### 5.14 黑名单提示.png - -> 图片:黑名单提示(images/黑名单提示.png) -**状态:图片文件名与画面语义不一致;按“清除聊天记录”实施,需改造。** - -- 确认调用 Tinode `{del what:"msg",topic,delseq:[{"low":1,"hi":last_seq+1}],hard:false}`,仅删除当前用户视图;或封装 `DELETE /v1/im/conversations/{topic}/messages`。 -- 不解除好友、不影响对方历史、不删除收藏;附件仅在无任何有效引用且过保留期后回收。 -- 前端需在确认文案明确“仅清除自己的聊天记录”,成功后清本地 Topic 消息缓存并保留会话关系。 - -## 6. 群聊创建、资料维护与成员治理 - -### 6.1 群聊.png - -> 图片:群聊(images/群聊.png) -**状态: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`。 -- 分类由服务端 `my_role` 决定;创建成功或角色变化后通过系统事件刷新,不由前端自行移动分组。 - -### 6.2 空白页.png - -> 图片:空白页(images/空白页.png) -**状态:纯客户端。** - -- 对 6.1 任一分类返回 `items=[]` 且 `has_more=false` 时显示对应空状态,不追加空白占位数据。 -- 收到 `group.membership.updated` 或创建成功后重新拉取当前分类。 - -### 6.3 加入群聊.png - -> 图片:加入群聊(images/加入群聊.png) -**状态:新增邀请卡片和加入接口。** - -- 邀请卡下行 `system_event{event_type:"group_invitation",invitation_id,group,inviter,status,expires_at}`。 -- 点击加入:`POST /v1/im/group-invitations/{invitation_id}/accept`,带幂等键;返回 `status=joined|pending_approval` 和 `topic`。 -- 服务端校验邀请租户、有效期、群状态、成员资格和成员数上限;重复加入返回当前状态,过期返回 `INVITATION_EXPIRED`。 - -### 6.4 内容沟通对话3-开启进群验证与拉人状态.png - -> 图片:内容沟通对话3-开启进群验证与拉人状态(images/内容沟通对话3-开启进群验证与拉人状态.png) -**状态:新增群成员事件。** - -- 邀请:`POST /v1/im/groups/{topic}/invitations`,Body `{"user_ids":[],"message":""}`;按群策略直接加入或创建待审批申请。 -- 加入、审批、拒绝等结果由服务端生成 `system_event{event_type:"group_membership_changed",operator,target_users,action,result}` 并写入群时间线。 -- 客户端只渲染可信系统事件;服务端同时更新成员表、Topic 订阅和成员数,不允许三者不一致。 - -### 6.5 邀请进群申请.png - -> 图片:邀请进群申请(images/邀请进群申请.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 - -> 图片:新邀请进群申请(images/新邀请进群申请.png) -**状态:新增。** - -- `GET /v1/im/groups/{topic}/summary` 返回 `pending_join_request_count`、最新公告和权限;新申请通过 `group.join_request.created` 推送给有审批权限的成员。 -- 审批完成后服务端广播新数量;前端归零时移除入口。普通成员响应中不得得到申请人敏感信息或数量。 - -### 6.7 群公告状态.png - -> 图片:群公告状态(images/群公告状态.png) -**状态:新增。** - -- 查询最新公告:`GET /v1/im/groups/{topic}/announcements/latest`;发布/更新:`PUT /v1/im/groups/{topic}/announcements/current`,Body `{"content":"...","version":1}`。 -- 返回 `announcement_id,content,author,version,published_at`。仅有权限角色可写,使用乐观锁防覆盖。 -- 发布成功后服务端生成群系统事件;顶部摘要使用接口返回数据,不解析聊天文本猜测。 - -### 6.8 群公告状态/邀请进群申请同时存在.png - -> 图片:群公告状态/邀请进群申请同时存在(images/群公告状态/邀请进群申请同时存在.png) -**状态:纯客户端组合;数据接口新增。** - -- 页面一次调用 `GET /v1/im/groups/{topic}/summary`,分别读取 `announcement` 和 `pending_join_request_count`,按产品固定顺序渲染两个入口。 -- 处理申请只刷新计数,不清公告;更新公告只替换公告对象,不影响申请入口。 - -### 6.9 群公告(对方查看状态).png - -> 图片:群公告(对方查看状态)(images/群公告(对方查看状态).png) -**状态:新增。** - -- 详情:`GET /v1/im/groups/{topic}/announcements/{announcement_id}`;普通成员响应不含编辑权限。 -- 已读:`POST /v1/im/groups/{topic}/announcements/{id}/read`,带幂等键,返回 `read_at`;用于清除新公告标记。 -- 服务端校验当前群成员资格,退群后不可借历史 ID 读取新公告。 - -### 6.10 群聊设置.png - -> 图片:群聊设置(images/群聊设置.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 - -> 图片:群资料(images/群资料.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 - -> 图片:设置群头像(images/设置群头像.png) -**状态:上传基础已实现;用途绑定需改造。** - -- 来源选择、裁剪是客户端动作。上传使用 `purpose=group_avatar`,服务端校验图片 MIME、尺寸、大小并返回 `file_id`。 -- 上传后调用 6.11 PATCH `{"avatar_file_id":"...","version":n}`;只有 PATCH 成功才替换正式头像。 -- 取消或 PATCH 失败的临时文件按 TTL 回收,不能污染群文件列表。 - -### 6.13 设置昵称(1).png - -> 图片:设置昵称(1)(images/设置昵称(1).png) -**状态:新增。** - -- `PUT /v1/im/groups/{topic}/members/me/profile`,Body `{"nickname":"...","version":n}`。 -- 服务端校验成员资格、长度和敏感词,返回 `nickname,version,updated_at` 并推送成员资料变化。 -- 该昵称仅作用当前群,不能修改全局用户昵称或其他群资料。 - -### 6.14 群简介.png - -> 图片:群简介(images/群简介.png) -**状态:同 6.11,需改造。** - -- 保存调用 `PATCH /v1/im/groups/{topic}`,Body `{"description":"...","version":n}`;空字符串表示清空,省略表示不修改。 -- 服务端返回 `VALIDATION_FAILED` 的 `details.field/max_length` 或 `CONTENT_REJECTED`;前端保留草稿供修正。 - -### 6.15 群标识.png - -> 图片:群标识(images/群标识.png) -**状态:角色已有基础数据;产品渲染需统一。** - -- 群消息下行增加服务端确认的 `sender_snapshot{display_name,role}`,角色枚举固定 `owner|admin|member`。 -- 成员角色变化事件到达后刷新成员缓存;历史消息可以保留发送时角色快照,详情以当前角色接口为准。 -- 客户端不得根据本地管理员列表给消息自行加可信标识。 - -### 6.16 群二维码页面.png - -> 图片:群二维码页面(images/群二维码页面.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 - -> 图片:群组管理(images/群组管理.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 - -> 图片:设置群管理员(images/设置群管理员.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 - -> 图片:添加管理员(images/添加管理员.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 - -> 图片:添加管理员-设置成功(images/添加管理员-设置成功.png) -**状态:新增。** - -- 6.19 成功返回 `admins,version,updated_at`;前端用响应替换列表并显示成功,不在请求完成前自行修改角色。 -- 服务端推送 `group.role.updated`,新管理员收到后刷新菜单权限和群标识。 - -### 6.21 添加管理员-设置成功(1).png - -> 图片:添加管理员-设置成功(1)(images/添加管理员-设置成功(1).png) -**状态:新增。** - -- 提交期间按幂等键禁重复点击。批量接口返回 `succeeded[]`、`failed[{user_id,reason}]` 和最新 `admins`。 -- 部分失败时保留成功角色,不做整批客户端回滚;前端展示失败原因并以服务端列表为准。 - -### 6.22 删除管理员-设置成功.png - -> 图片:删除管理员-设置成功(images/删除管理员-设置成功.png) -**状态:新增。** - -- `DELETE /v1/im/groups/{topic}/admins/{user_id}`,带幂等键和 `If-Match`/`version`。 -- 成功后目标角色变为 `member`,保留群成员关系;服务端立即撤销管理权限并广播角色事件。 -- 删除群主、非成员或最后状态冲突分别返回稳定错误,不允许前端拼接通用成功。 - -### 6.23 添加禁言成员.png - -> 图片:添加禁言成员(images/添加禁言成员.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 - -> 图片:禁言的群成员(images/禁言的群成员.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 - -> 图片:选择新群主(images/选择新群主.png) -**状态:新增。** - -- `GET /v1/im/groups/{topic}/members?eligible_role=owner&query=&cursor=`,只返回状态正常的当前成员。 -- 前端单选并保存 `target_user_id`;当前群主、已退出、停用账号返回 `eligible=false`。 -- 此步骤不修改权限,真正转让仅在 6.26 确认后执行。 - -### 6.26 选择新群主提示.png - -> 图片:选择新群主提示(images/选择新群主提示.png) -**状态:新增。** - -- `POST /v1/im/groups/{topic}/owner-transfer`,Body `{"target_user_id":"usr...","version":n}`,必须带幂等键和二次认证 `X-Action-Token`。 -- 服务端事务内把旧群主降为管理员或成员、目标升为群主、更新权限和审计,再生成系统事件。 -- 成功返回 `owner,previous_owner,roles,version`;并发成员变化返回 `GROUP_VERSION_CONFLICT`,前端重新确认,不自动重试。 - -### 6.27 群文件.png - -> 图片:群文件(images/群文件.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 - -> 图片:群文件-上传状态(images/群文件-上传状态.png) -**状态:需改造。** - -- 先用 `purpose=group_file,topic=...` 上传,再 `POST /v1/im/groups/{topic}/files`,Body `{"file_id":"..."}` 建立群文件记录。 -- 服务端校验 `member_upload_files`、单文件上限、租户总存储和群成员身份;成功返回完整群文件项。 -- 上传失败可复用 `upload_id`;登记失败不得在列表显示,孤立文件按 TTL 回收。 - -## 7. 文件下载、收藏管理与聊天记录检索 - -### 7.1 我的收藏.png - -> 图片:我的收藏(images/我的收藏.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)(images/我的收藏(1).png) -**状态:新增。** - -- 详情:`GET /v1/im/favorites/{favorite_id}`;删除:`DELETE /v1/im/favorites/{favorite_id}`;转发使用 7.8。 -- 收藏创建时保存受控快照和附件引用,因此原消息对当前用户删除后仍可按保留策略展示;原资源因合规删除时返回 `availability:"removed"`。 -- 删除收藏不删除原消息或已转发消息。 - -### 7.3 我的收藏-文件.png - -> 图片:我的收藏-文件(images/我的收藏-文件.png) -**状态:新增。** - -- 调用 7.1,固定 `type=file`;项中使用 `snapshot.file{name,size,mime,file_id}` 和 `availability`。 -- 点击前调用收藏详情获得当前授权,下载仍走 7.9 文件协议;失效显示原因,不反复请求公共 URL。 - -### 7.4 我的收藏-文本.png - -> 图片:我的收藏-文本(images/我的收藏-文本.png) -**状态:新增。** - -- 调用 7.1,固定 `type=text`;服务端返回纯文本/结构化 Drafty 快照和安全高亮区间 `matches[]`。 -- 展开、折叠、复制为本地动作;复制前遵守企业 DLP 策略 `permissions.can_copy`。 - -### 7.5 我的收藏-语音.png - -> 图片:我的收藏-语音(images/我的收藏-语音.png) -**状态:新增。** - -- 调用 7.1,固定 `type=voice`;播放前获取认证文件,返回 `duration_ms,waveform,availability`。 -- 播放/暂停和单实例播放器是客户端状态;服务端不接收逐秒播放进度,除非后续定义跨端收听状态。 - -### 7.6 我的收藏—链接.png - -> 图片:我的收藏—链接(images/我的收藏—链接.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 - -> 图片:收藏—文件(多选)(images/收藏—文件(多选).png) -**状态:新增。** - -- 选择状态本地维护;批量删除 `POST /v1/im/favorites/batch-delete`,Body `{"favorite_ids":[]}`;批量转发见 7.8。 -- 返回逐项成功/失败;删除必须二次确认并带幂等键。跨页选择只保存 ID,不保留失效的临时下载地址。 - -### 7.8 收藏文件—分享给好友页面.png - -> 图片:收藏文件—分享给好友页面(images/收藏文件—分享给好友页面.png) -**状态:新增。** - -- 目标列表使用 4.17;提交 `POST /v1/im/favorites/forward`,Body `{"favorite_ids":[],"target_topics":[],"comment":""}`。 -- 服务端从收藏的受控附件引用生成新消息和新 ACL,返回每个目标的消息结果;收藏原件不变。 -- 文件失效、目标禁言或无发送权限按目标逐项失败,不允许客户端直接复用旧下载 URL 发送。 - -### 7.9 文件-下载.png - -> 图片:文件-下载(images/文件-下载.png) -**状态:底层下载已实现租户校验;资源 ACL 需改造。** - -- 客户端使用 `GET /v0/file/s/{file_id}`,Header 传 API Key 与 `X-Tinode-Auth: token <base64_token>`;禁止 `?auth=&secret=`。 -- 服务端除 Token 租户外,还必须校验该用户通过消息、群文件或收藏拥有有效引用,并支持 `Range`、`ETag`、内容长度和安全文件名。 -- 下载前客户端校验本地空间和网络策略;`FILE_FORBIDDEN` 不自动重试,`FILE_UNAVAILABLE` 显示失效。 - -### 7.10 文件-下载中.png - -> 图片:文件-下载中(images/文件-下载中.png) -**状态:客户端任务管理;服务端需支持 Range。** - -- 下载任务保存 `file_id,etag,total_bytes,downloaded_bytes,temp_path,state`;恢复时携带 `Range` 与 `If-Range: <etag>`。 -- ETag 改变或服务端返回完整 200 时丢弃旧分片重新下载;取消时关闭连接并清理临时文件。 -- Token 过期先完成一次刷新后续传,不能把 Token 写入任务文件或日志。 - -### 7.11 文件-下载完成.png - -> 图片:文件-下载完成(images/文件-下载完成.png) -**状态:纯客户端完成态。** - -- 完成后校验长度和服务端提供的 checksum,再原子移动到受控目录;校验失败删除并标记重试。 -- 打开/分享遵守系统沙箱和企业 DLP。每次展示页面检查本地文件存在性,不存在则恢复“可下载”。 - -### 7.12 查找聊天记录-搜索关键词选中状态(点击后跳出对应聊天内容).png - -> 图片:查找聊天记录-搜索关键词选中状态(点击后跳出对应聊天内容)(images/查找聊天记录-搜索关键词选中状态(点击后跳出对应聊天内容).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 - -> 图片:查找聊天记录-文件(images/查找聊天记录-文件.png) -**状态:新增。** - -- 调用 `GET /v1/im/topics/{topic}/messages/search?type=file&q=&cursor=`,返回消息 `seq` 与文件快照、发送者、时间、`availability`。 -- 预览/下载使用 7.9;撤回或无授权时显示失效。点击来源用 7.12 上下文接口定位。 - -### 7.14 查找聊天记录-链接.png - -> 图片:查找聊天记录-链接(images/查找聊天记录-链接.png) -**状态:新增。** - -- 调用搜索接口 `type=link`,返回 `url,title,summary,seq,ts,risk_level`;打开前执行 7.6 URL 安全检查。 -- 链接预览由服务端受控抓取和缓存,客户端不直接抓取内网地址,防止 SSRF 和隐私泄露。 - -### 7.15 查看聊天记录-图片.png - -> 图片:查看聊天记录-图片(images/查看聊天记录-图片.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 - -> 图片:查看聊天记录-视频(images/查看聊天记录-视频.png) -**状态:新增。** - -- 调用 7.15 接口,`type=video`,额外返回 `duration_ms,mime,size`。 -- 播放器使用支持 Range 的认证下载/流式读取;文件失效、Token 过期和网络中断分别映射可理解状态,返回列表保持游标与滚动位置。 - -## 8. 实时语音与视频通话 - -### 8.1 各种通话提示状态.png - -> 图片:各种通话提示状态(images/各种通话提示状态.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 - -> 图片:对方状态(images/对方状态.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 - -> 图片:语音通话-对方待接受状态(images/语音通话-对方待接受状态.png) -**状态:发起与超时已实现基础;好友/拉黑/租户开关校验需改造。** - -- 发起前查询 `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 - -> 图片:语音通话-双方语音沟通中(images/语音通话-双方语音沟通中.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 - -> 图片:语音通话-麦克风与扬声器已关(images/语音通话-麦克风与扬声器已关.png) -**状态:纯客户端设备控制。** - -- 麦克风关闭通过禁用本地音轨,扬声器关闭/听筒切换通过系统音频路由完成,不调用业务 HTTP。 -- UI 必须读取实际音轨和路由状态;权限撤销时强制更新。是否告知对方静音可发送临时 `media-state`,不落聊天记录。 - -### 8.6 语音通话-麦克风与扬声器已开.png - -> 图片:语音通话-麦克风与扬声器已开(images/语音通话-麦克风与扬声器已开.png) -**状态:纯客户端设备控制。** - -- 开启麦克风恢复本地音轨,开启扬声器选择实际输出设备;蓝牙/耳机变化以操作系统回调为准。 -- 不把设备名称、系统音量上传服务端。切换失败恢复真实按钮状态并给本地提示。 - -### 8.7 视频通话—等待对方接受接受邀请中.png - -> 图片:视频通话—等待对方接受接受邀请中(images/视频通话—等待对方接受接受邀请中.png) -**状态:基础通话信令已实现;隐私约束需落实。** - -- 发起协议同 8.3,`media_type:"video"`。本地预览只在设备显示,接听前媒体轨默认不发给对方,SDP 只声明能力。 -- 切换摄像头纯客户端;取消发送 `hang-up:cancelled`。相机权限不足时改为音频呼叫或取消,不伪装视频已开启。 - -### 8.8 视频通话—等待对方接受接受邀请-摄像头关闭.png - -> 图片:视频通话—等待对方接受接受邀请-摄像头关闭(images/视频通话—等待对方接受接受邀请-摄像头关闭.png) -**状态:纯客户端状态;媒体状态事件需改造。** - -- 关闭本地视频轨并显示头像;接听后沿用 `video_enabled=false`。 -- 当前服务端只允许 `ringing/accept/offer/answer/ice-candidate/hang-up`;首版应直接通过 WebRTC 轨道 mute 状态呈现。若后续新增 `media-state`,必须先修改 `server/calls.go` 事件白名单、参与者校验和限频,客户端不能提前发送未知事件。 - -### 8.9 双方视频通话中.png - -> 图片:双方视频通话中(images/双方视频通话中.png) -**状态:WebRTC 客户端能力;服务端信令已具备基础。** - -- 主/小窗布局、拖动和横竖屏纯客户端;WebRTC offer/answer/ICE 与 8.2~8.4 相同。 -- 服务端只做身份鉴权、信令路由、TURN 配置和通话审计,不接收前端伪造的通话时长。 -- 网络恶化可本地降码率或关闭视频轨;最终结束必须发 `hang-up`。服务端未支持 `media-state` 前不得发送该事件。 - -### 8.10 双方视频通话中-对方关闭摄像头状态.png - -> 图片:双方视频通话中-对方关闭摄像头状态(images/双方视频通话中-对方关闭摄像头状态.png) -**状态:现阶段纯客户端;可选媒体状态事件需改造。** - -- 以对端 WebRTC 视频轨的 mute/unmute 作为真实信号;关闭时显示资料缓存中的对方头像并保留音频,轨道恢复后自动显示视频。 -- 不需要业务 HTTP。可选 `media-state` 必须等服务端扩展事件白名单后另行启用,且不持久化、不改变通话结果。 - -### 8.11 视频与语音通话悬浮状态中.png - -> 图片:视频与语音通话悬浮状态中(images/视频与语音通话悬浮状态中.png) -**状态:纯客户端。** - -- 最小化不结束 WebRTC 或 WebSocket,会话仍以同一 `call_id` 运行;点击恢复全屏不发送新 invite。 -- 系统悬浮窗权限、画中画和安全区域按平台实现。收到 `hang-up`、Token 失效或媒体会话结束后立即关闭悬浮窗并落正确结果态。 - -## 9. 企业工作台与办公协同 - -### 9.1 工作台.png - -> 图片:工作台(images/工作台.png) -**状态:新增。** - -- `GET /v1/im/workbench` 返回 `modules[{code,name,icon_file_id,route,unread_count,enabled,permission}]`、布局版本和企业配置。 -- 服务端按当前 Token 的租户、组织角色和功能开关裁剪;前端只接受白名单内部 `route`,禁止直接执行服务端任意 URL。 -- 未读数通过 `workbench.badge.updated` 系统事件增量更新;切租户必须重新加载,不复用另一企业工作台。 - -### 9.2 部门组织框架.png - -> 图片:部门组织框架(images/部门组织框架.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=`。服务端执行组织范围授权,离职用户标记不可联系或从结果移除。 - -### 9.3 会议通知-发起人状态.png - -> 图片:会议通知-发起人状态(images/会议通知-发起人状态.png) -**状态:新增。** - -- 创建:`POST /v1/im/meetings`,Body `{"title":"...","start_at":"...","end_at":"...","location":"...","attendee_user_ids":[],"reminder_minutes":[]}`。 -- 详情:`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`。所有写操作带幂等键并生成通知和审计。 - -### 9.4 会议通知-接收人状态.png - -> 图片:会议通知-接收人状态(images/会议通知-接收人状态.png) -**状态:新增。** - -- 详情同 9.3;响应:`PUT /v1/im/meetings/{meeting_id}/my-response`,Body `{"response":"accepted|declined|tentative","version":n}`。 -- 返回我的最终响应和会议版本,并实时通知发起人;重复提交同状态幂等。 -- 会议过期返回 `MEETING_CLOSED` 并只读展示;客户端本地日历写入是可选系统动作,不替代服务端响应。 - -### 9.5 日报.png - -> 图片:日报(images/日报.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 - -> 图片:日报-普通用户页面(images/日报-普通用户页面.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 - -> 图片:日报-管理员页面(images/日报-管理员页面.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 - -> 图片:日报-查看详情(images/日报-查看详情.png) -**状态:新增。** - -- `GET /v1/im/reports/daily/{report_id}` 返回填报人、日期、内容、附件、状态、版本、修改时间和 `permissions`。 -- 管理员查看时服务端记录必要的已读状态;附件下载复用第 7 章 ACL。 -- 撤回或修订使用专用状态和版本,客户端必须显示最新版本与 `revised_at`,不能混合旧缓存。 - -### 9.9 月报(1).png - -> 图片:月报(1)(images/月报(1).png) -**状态:新增。** - -- 创建/取得当月草稿:`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 - -> 图片:月报(images/月报.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 - -> 图片:月报-详情页(images/月报-详情页.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 - -> 图片:总经理信箱(images/总经理信箱.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 - -> 图片:总经理信箱-文案提示(images/总经理信箱-文案提示.png) -**状态:新增配置接口。** - -- 文案使用 9.12 配置中的 `notice{title,content,updated_at}`,由租户后台配置;客户端不得硬编码实名/匿名承诺。 -- 提交按钮前必须展示当前版本文案;请求可携带 `notice_version`,服务端发现关键政策已变更时返回 `NOTICE_RECONFIRM_REQUIRED`。 - -## 10. 个人资料、通用设置、隐私与青少年模式 - -### 10.1 我的.png - -> 图片:我的(images/我的.png) -**状态:基础用户资料已有;产品菜单新增。** - -- `GET /v1/im/me` 返回公开账号资料、实名/青少年模式摘要和资料版本;`GET /v1/im/bootstrap` 返回个人菜单与权限。 -- 头像、昵称更新事件到达后刷新头部;收藏和设置入口分别进入第 7、10 章。 -- 账号 ID 按字符串展示,客户端不得展示内部数据库 ID 或 tenant_id。 - -### 10.2 个人信息.png - -> 图片:个人信息(images/个人信息.png) -**状态:Tinode `me` 资料更新已有基础;目标产品接口需改造。** - -- 查询 `GET /v1/im/me/profile` 返回 `avatar_file_id,nickname,birth_date,signature,gender,region,public_account,version,field_rules`。 -- 更新统一使用 `PATCH /v1/im/me/profile`,仅提交变更字段并带 `version`;服务端更新后同步 Tinode `me.public` 并推送资料事件。 -- 头像先以 `purpose=user_avatar` 上传再 PATCH;任何字段的可见性由隐私策略裁剪。 - -### 10.3 设置昵称.png - -> 图片:设置昵称(images/设置昵称.png) -**状态:需改造。** - -- `PATCH /v1/im/me/profile`,Body `{"nickname":"新昵称","version":n}`。 -- 服务端校验 Unicode 长度、全空白、控制字符和敏感内容;返回更新后的 `nickname,version,updated_at`。 -- 成功刷新全局资料和联系人快照,但不得覆盖各群昵称。版本冲突先拉最新资料再由用户确认。 - -### 10.4 出生日期.png - -> 图片:出生日期(images/出生日期.png) -**状态:新增产品字段。** - -- 保存同 10.3,Body `{"birth_date":"2000-01-02","version":n}`;仅传 ISO 日期,不传本地化文本或时区时间戳。 -- 服务端校验不晚于当前租户日期及合理范围,返回 `PROFILE_FIELD_INVALID`。展示格式由客户端本地化。 - -### 10.5 个性签名.png - -> 图片:个性签名(images/个性签名.png) -**状态:需改造。** - -- 保存同 10.3,Body `{"signature":"...","version":n}`;空字符串表示清空。 -- 服务端按 `field_rules.signature.max_length` 和内容审核处理;失败保留本地草稿,成功刷新引用页面。 - -### 10.6 二维码.png - -> 图片:二维码(images/二维码.png) -**状态:图标纯客户端;二维码协议新增。** - -- 点击入口进入 5.4 并创建短期个人二维码。每次主动刷新调用 `POST /v1/im/me/qrcodes` 获取新 Token。 -- 二维码不能使用静态 `user_id`、手机号或登录 Token;过期、撤销和切租户后必须重新生成。 - -### 10.7 设置(iOS).png - -> 图片:设置(iOS)(images/设置(iOS).png) -**状态:菜单纯客户端;数据接口部分新增。** - -- 菜单来自 `bootstrap.settings_menu`,安全、通知、隐私、通用、关于按权限展示。系统通知权限状态由 iOS API 读取,不由服务端伪造。 -- 退出:`POST /v1/im/auth/logout` 撤销当前 refresh/access token 和设备会话,成功或 Token 已失效后关闭 WS、清安全存储和本租户用户缓存。 -- 多设备退出使用 `DELETE /v1/im/security/sessions/{session_id}`,需二次验证。 - -### 10.8 设置(安卓).png - -> 图片:设置(安卓)(images/设置(安卓).png) -**状态:菜单纯客户端;业务协议同 10.7。** - -- 使用相同 `bootstrap.settings_menu` 与退出协议;Android 系统权限、通知渠道和应用更新使用平台实现。 -- 服务端只保存应用内通知偏好与设备 Push Token,不把系统设置页面是否打开当作已授权。 - -### 10.9 清空缓存的提示.png - -> 图片:清空缓存的提示(images/清空缓存的提示.png) -**状态:纯客户端。** - -- 确认后只删除缩略图、可重下文件、临时上传下载和搜索缓存;不调用服务端删除消息、收藏或账号接口。 -- 不删除安全存储中的登录凭证,除非用户选择退出登录。完成后重新计算本地占用并显示结果。 - -### 10.10 版本更新页面.png - -> 图片:版本更新页面(images/版本更新页面.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 - -> 图片:发现新版本(images/发现新版本.png) -**状态:纯客户端弹窗;数据来自 10.10。** - -- `optional` 显示稍后/更新,记录本地已忽略版本并按服务端 `remind_after` 再提示;`required` 不显示绕过入口。 -- 下载和安装状态由平台管理;接口异常时不得误判为强制更新,除非本地已有有效签名的最低版本策略缓存。 - -### 10.12 隐私设置.png - -> 图片:隐私设置(images/隐私设置.png) -**状态:新增。** - -- `GET /v1/im/me/privacy` 返回 `add_me_methods,profile_visibility,last_seen_visibility,read_receipts,version` 和各字段 `editable`。 -- `PATCH /v1/im/me/privacy` 只提交变更字段和 `version`;成功返回完整最新配置并向其他设备推送 `privacy.updated`。 -- 黑名单入口使用 5.12。服务端在搜索、主页、回执等实际读写路径执行隐私规则,不能只保存开关。 - -### 10.13 隐私设置-添加我的方式选项.png - -> 图片:隐私设置-添加我的方式选项(images/隐私设置-添加我的方式选项.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 - -> 图片:青少年模式未开启(安卓)(images/青少年模式未开启(安卓).png) -**状态:新增。** - -- `GET /v1/im/settings/youth-mode` 返回 `enabled,policy_version,restrictions,password_set,can_enable`。 -- 点击开启先展示 10.15,再按 1.7 完成身份验证和 1.11 设置独立密码;最后调用 `POST /v1/im/settings/youth-mode/enable`,Body `{"policy_version":n,"action_token":"..."}`。 -- 服务端持久化到用户租户范围并推送所有设备,不能仅设置本地标志。 - -### 10.15 青少年模式提示(安卓).png - -> 图片:青少年模式提示(安卓)(images/青少年模式提示(安卓).png) -**状态:弹窗纯客户端;文案由服务端配置。** - -- 使用 10.14 返回的 `restrictions` 与 `policy_version` 渲染;取消不请求,确认进入密码设置/验证。 -- 最终 enable 请求携带用户确认的 `policy_version`;若策略更新返回 `POLICY_RECONFIRM_REQUIRED`,重新展示新内容。 - -### 10.16 青少年模式已开启(安卓).png - -> 图片:青少年模式已开启(安卓)(images/青少年模式已开启(安卓).png) -**状态:新增。** - -- 页面调用 10.14,`enabled=true` 时展示 `enabled_at,restrictions`。`bootstrap` 同时返回受限模块和动作。 -- 受限接口服务端返回 `YOUTH_MODE_RESTRICTED`;前端隐藏只是体验优化,不能作为安全控制。 -- 登录、App 重启和另一设备均从服务端恢复状态;收到 `youth_mode.updated` 即刷新。 - -### 10.17 关闭青少年模式(安卓).png - -> 图片:关闭青少年模式(安卓)(images/关闭青少年模式(安卓).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 - -> 图片:获取推送权限(安卓)(images/获取推送权限(安卓).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 - -> 图片:提醒(安卓)(images/提醒(安卓).png) -**状态:纯客户端。** - -- 仅在功能因系统权限不可用时显示;取消和“前往设置”不调用业务服务端。 -- 回到应用后重新读取真实权限,不能因用户打开过设置页就假定授权成功。 - -### 10.20 相机说明(安卓).png - -> 图片:相机说明(安卓)(images/相机说明(安卓).png) -**状态:纯客户端。** - -- 首次拍照、扫码或视频通话前展示用途说明,确认后再触发系统权限;拒绝不发媒体/扫码请求。 -- 永久拒绝时提供系统设置入口;允许从相册选择等替代能力。客户端不得上传系统权限状态作为服务端授权依据。 - -### 10.21 麦克风说明(安卓).png - -> 图片:麦克风说明(安卓)(images/麦克风说明(安卓).png) -**状态:纯客户端。** - -- 首次录音或通话前说明并申请系统权限;未授权时不得创建录音文件或发送 call invite。 -- 永久拒绝后进入系统设置引导;恢复授权后重新执行 4.7 或第 8 章动作,不复用过期媒体会话。 - -## 11. 反诈安全、举报与意见反馈 - -### 11.1 反诈弹窗(安卓).png - -> 图片:反诈弹窗(安卓)(images/反诈弹窗(安卓).png) -**状态:新增风控决策协议。** - -- 高风险动作在原业务接口返回 `409 RISK_CONFIRMATION_REQUIRED`,`details={risk_challenge_id,level,notice_code,notice_params,expires_at,action}`。 -- 客户端用 `notice_code` 映射经过审核的本地文案,不显示服务端规则、分数或命中特征;取消不重试原操作。 -- 低风险提示也可由可信 `security.risk_notice` 系统事件触发。普通消息不得伪装成系统反诈弹窗。 - -### 11.2 反诈弹窗(二次提示安卓).png - -> 图片:反诈弹窗(二次提示安卓)(images/反诈弹窗(二次提示安卓).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 - -> 图片:举报(images/举报.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)(images/举报(1).png) -**状态:新增。** - -- 证据使用 `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 - -> 图片:意见反馈(images/意见反馈.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"`,重复提交由幂等键去重。 - -## 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` 基础 Tinode 协议。 -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、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 过期后刷新并 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/IM-前后端对接文档-禅道安全版.md b/docs/IM-前后端对接文档-禅道安全版.md deleted file mode 100644 index c851a62..0000000 --- a/docs/IM-前后端对接文档-禅道安全版.md +++ /dev/null @@ -1,1969 +0,0 @@ -# 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/tinode.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]: <client-api-key> -HMAC salt: <server-hmac-salt> -``` - -API Key、企业码和用户 Token 是三个不同概念: - -| 值 | 取得时间 | 作用 | 是否代表用户身份 | -| --- | --- | --- | --- | -| `api_key` | 应用发布/环境配置时预置 | 允许应用访问 Tinode 入口 | 否 | -| `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-Tinode-APIKey` | 同上 | -| 产品 HTTP | `https://{host}/{api_path}v1/im/*` | `Authorization: Bearer <params.token>` | 好友、群、收藏、搜索、工作台和设置 | -| 文件 HTTP | `POST /v0/file/u/`、`GET /v0/file/s/{file_id}` | 当前用 `X-Tinode-Auth: token <base64_token>` | 文件上传下载;禁止 Query 传 Token | - -HTTP 请求统一通过 `X-Tinode-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)`。Token 登录必须把上次 `params.token` 的 JSON 字符串原样填入 `secret`,不能再次 Base64 编码。登录成功 `{ctrl}` 的 `params` 至少包含 `user`、`authlvl`、`token`、`expires`。产品 HTTP 也使用同一个 `params.token`:`Authorization: Bearer <params.token>`,客户端不要解码或二次编码。Token 对客户端是不可解析的 opaque 字符串,只能存入系统安全存储。`id` 只用于匹配本次 Session 的命令响应,不是业务幂等键。 - -`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-Tinode-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-Tinode-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-Tinode-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.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_event`。自定义表情启用后追加 `sticker`。媒体内容只保存 `file_id`、文件元数据和缩略图 ID,不保存可长期绕过鉴权的公共 URL。 - -### 0.7 HTTP 幂等、重试和安全 - -1. 创建、提交、同意、拒绝、删除、转让、收藏、转发等写请求必须携带 `Idempotency-Key`。 -2. GET、断线同步和同幂等键写请求可自动重试;密码、验证码和风控确认只按接口返回的 `retry_after_seconds` 重试。 -3. `401 TOKEN_EXPIRED` 只允许一次静默刷新;刷新失败必须退出登录。`403` 不重试,`429` 按服务端退避。 -4. 服务端必须在最终写入前再次校验好友关系、群角色、禁言、消息归属、文件 ACL、租户配额和风控,不能信任前端按钮状态。 -5. 普通用户禁止向系统 Topic 发布;系统通知必须由服务端受控身份生成。 - -## 1. 账号注册、登录与身份安全 - -### 1.1 企业码登录.png - -![企业码登录](images/企业码登录.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-Tinode-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.2 登录页面.png - -![登录页面](images/登录页面.png) - -**状态:基本账号密码登录已实现;WS Token 可复用于产品 HTTP。** - -- 前端动作:账号、密码非空且已勾选协议后启用登录;密码仅驻留当前页面内存。 -- 协议:已完成 `hi` 的 WebSocket 发送 `{login scheme:"basic"}`,`secret=base64(username:password)`;不得先登录再传企业码。 -- 成功:读取 `{ctrl params.user/token/expires/authlvl}`,Token 存 Keychain/Keystore;立即 `sub me`,再加载会话。前端不解码 Token,也不再次 Base64 编码。 -- 产品 HTTP:使用同一个 `params.token` 作为 `Authorization: Bearer <params.token>`;所有 `/v1/im/*` HTTP 请求仍必须带 `X-Tinode-APIKey`、`X-IM-Schema: 1`,写请求再带 `Idempotency-Key`。 -- 待改造:后续可演进为独立短期 access token 与可轮换 refresh token;当前版本先复用 Tinode 登录 Token,并已绑定 `tenant_id/user_id/auth_version`。修改密码、封禁账号后旧 Token 应失效。 -- 错误:`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 - -![登录页面-密码错误](images/登录页面-密码错误.png) - -**状态:已实现基础错误,错误码和限流需改造。** - -- 触发:`login` 返回 `{ctrl code:401}` 或产品错误 `AUTH_FAILED`。 -- 前端:保留账号、清空密码、聚焦密码框;统一显示“账号或密码错误”,不得显示账号是否存在。 -- 服务端:按 `tenant + account + device/IP` 限流,连续失败返回 `AUTH_RATE_LIMITED` 和 `retry_after_seconds`;日志仅记录账号哈希和 Request ID。 -- 验收:同一不存在账号与错误密码在响应码、耗时区间、前端文案上保持一致。 - -### 1.4 注册页面.png - -![注册页面](images/注册页面.png) - -**状态:Tinode `{acc}` 基础注册和验证码预校验已实现;短信供应商接入前固定测试码为 `123456`。** - -- 获取验证码:调用 `POST /v1/im/auth/verification-codes`,必须带 `X-Tinode-APIKey`、`X-Tenant-Code`、`X-IM-Schema: 1`,Body `{"scene":"register","phone":"+86138..."}`;成功返回 `challenge_id`、`expires_in_seconds`、`resend_after_seconds`。 -- 提交注册:前端校验密码一致和协议后,先调用 `POST /v1/im/auth/verification-codes/{challenge_id}/verify`,Body `{"code":"123456"}`,获得一次性 `verification_token`。 -- WebSocket:已完成同一企业 `hi` 后发送 `{acc}`。把一次性验证结果放在 `{acc}.cred[0].resp`,它必须是 HTTP verify 返回的 `verification_token`,不是短信验证码 `123456`。不得写入 `desc.private`,也不得在 `{acc}` 顶层传 `tags`。服务端会根据已校验的 `cred` 建立手机号凭证;客户端主动设置 `tags:["tel:..."]` 会被 Tinode 权限策略拒绝并返回 `403 params.what=tags`。 -- 手机号格式:HTTP 发码、验证码校验、`{acc}.secret` 中的 username、`cred.val` 必须使用同一个规范化号码,推荐 E.164 格式如 `+86138...`;不要一处使用 `+86138...`、另一处使用 `86138...`。 - -```json -{"acc":{"id":"acc-1","user":"new","scheme":"basic","secret":"base64(+86138...:password)","login":true,"cred":[{"meth":"tel","val":"+86138...","resp":"verification_token","params":{"challenge_id":"challenge-id"}}],"desc":{"public":{"fn":"用户昵称"}}}} -``` -- 成功:返回新 `user` 和登录 Token;客户端保存 `params.token`,随后执行 `sub me` 和会话恢复。创建用户前原子校验 `im_tenant_config.max_users`,超限返回 `TENANT_USER_QUOTA_EXCEEDED`。 -- 安全:手机号在租户内唯一;验证码只保存摘要、单次消费;密码按服务端策略校验并使用强哈希保存。 - -### 1.5 注册页面-倒计时.png - -![注册页面-倒计时](images/注册页面-倒计时.png) - -**状态:新增。** - -- 倒计时以 1.4 返回的 `resend_after_seconds` 为准,并结合 `server_time` 计算,不能只依赖本地固定 60 秒。 -- 倒计时内前端禁用按钮;即使绕过按钮重复调用,服务端返回 `VERIFICATION_RATE_LIMITED` 和剩余秒数。 -- App 重进页面可使用内存/安全临时存储中的 `challenge_id` 恢复;超过 `expires_in_seconds` 必须重新发送,旧验证码失效。 - -### 1.6 忘记密码.png - -![忘记密码](images/忘记密码.png) - -**状态:新增。** - -- 发码:`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":"..."}`,带幂等键。 -- 成功:服务端更新密码、递增 `auth_version`、撤销该用户全部 access/refresh token 和在线 Session;客户端清理本租户凭证并返回登录页。 -- 失败:`PASSWORD_POLICY_VIOLATION` 返回规则明细;验证码错误不返回账号存在性,达到上限后冻结 challenge。 - -### 1.7 身份验证(安卓).png - -![身份验证(安卓)](images/身份验证(安卓).png) - -**状态:新增。** - -- 敏感操作先调用 `POST /v1/im/security/challenges`,Body `{"scene":"close_youth_mode","channel":"sms"}`;手机号由登录身份读取,客户端不提交目标手机号。 -- 发码/校验:服务端返回 `challenge_id` 和脱敏手机号;提交 `POST /v1/im/security/challenges/{id}/verify`,Body `{"code":"..."}`。 -- 成功返回 `action_token`、`action`、`expires_at`;后续敏感接口以 `X-Action-Token` 携带,Token 只能用于指定动作且单次消费。 -- 错误:`CHALLENGE_EXPIRED`、`CODE_INVALID`、`TOO_MANY_ATTEMPTS`;前端不得把短信验证码或 action token 落普通存储。 - -### 1.8 实名认证.png - -![实名认证](images/实名认证.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` 系统事件更新状态。 -- 安全:证件号传输和存储加密、展示脱敏;普通日志、搜索索引和消息中禁止出现原值;重复身份由租户策略决定并返回稳定错误。 - -### 1.9 实名认证-已实名.png - -![实名认证-已实名](images/实名认证-已实名.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 - -![实名认证(安卓)](images/实名认证(安卓).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 - -![设置独立密码(安卓)](images/设置独立密码(安卓).png) - -**状态:新增。** - -- 设置:`POST /v1/im/settings/independent-password`,Body `{"password":"1234","purpose":"youth_mode","action_token":"..."}`;服务端只存强哈希,不返回密码。 -- 验证:`POST /v1/im/settings/independent-password/verify` 返回短时 `action_token`,用于关闭青少年模式或修改密码。 -- 规则:四位数字、禁止明显连续/重复组合;连续错误返回 `TOO_MANY_ATTEMPTS` 和 `retry_after_seconds`。前端输入框禁截屏/自动填充,离开页面立即清空。 -- 忘记密码:跳转 1.7 的短信身份验证,验证后重置,不提供明文找回。 - -## 2. 消息首页、全局导航与系统通知 - -### 2.1 消息.png - -![消息](images/消息.png) - -**状态:会话订阅已实现;产品摘要和系统入口需改造。** - -- 登录后发送 `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=`。 -- 本地缓存按 `tenant_code + user_id` 隔离。切租户、退出或账号变化时不得复用另一身份的会话摘要。 - -### 2.2 消息-无网络状态.png - -![消息-无网络状态](images/消息-无网络状态.png) - -**状态:需改造客户端恢复流程和消息幂等。** - -- 纯网络状态由客户端监听;断网时展示缓存并禁止需要在线确认的写操作,未发送消息进入本地 `pending` 队列。 -- 使用指数退避加随机抖动重连;恢复后严格执行 `hi -> token login -> sub me -> 恢复 Topic -> get data`。 -- 每个 Topic 按最后持久化的 `seq/read/recv` 增量拉取。重发消息复用 `x-im-client-mid`,服务端需实现去重并返回原 `seq`。 -- Token 失效转登录页;租户停用返回企业不可用页;禁止无限快速重连。 - -### 2.3 消息-更多.png - -![消息-更多](images/消息-更多.png) - -**状态:菜单为纯客户端;权限数据新增。** - -- 菜单打开/关闭不调用服务端。菜单项来自登录后 `GET /v1/im/bootstrap` 的 `features` 与 `permissions`。 -- `create_group=false` 时隐藏“创建群聊”;`user_search=false` 时隐藏“添加好友”;扫码权限只影响实际扫码,不影响服务端授权。 -- 各入口协议分别见 5.3、6.1、10.1;服务端在实际写接口再次鉴权,不能只依赖隐藏按钮。 - -### 2.4 消息页面—长按弹出.png - -![消息页面—长按弹出](images/消息页面—长按弹出.png) - -**状态:基础消息操作部分已实现;产品权限矩阵需改造。** - -- 长按本身不请求。客户端根据消息的 `type/from/seq/created_at/status`、当前 Topic 权限和服务端下发的 `message_policy` 预裁剪菜单。 -- 引用/复制为本地动作;收藏见 4.16;转发见 4.17~4.20;删除使用 Tinode `{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 - -![系统消息](images/系统消息.png) - -**状态:需改造。** - -- `GET /v1/im/system-notifications?cursor=` 返回 `id,event_type,title,summary,created_at,read_at,action`;新通知通过服务端专用系统 Topic 的 `system_event` 消息推送。 -- 新设备登录事件内容为 `device_name,platform,ip_masked,login_at,security_action`;IP 只下发必要脱敏值。 -- 已读:`POST /v1/im/system-notifications/{id}/read`,带幂等键;跳转目标只接受服务端白名单 `action.type` 和内部参数。 -- 当前普通认证用户可向 `sys` Topic 发布的路径必须封禁。客户端不得渲染普通用户伪造的系统卡片为可信通知。 - -## 3. 单聊会话、会话建立与消息状态 - -### 3.1 个人对话框(对方的状态).png - -![个人对话框(对方的状态)](images/个人对话框(对方的状态).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)](images/个人对话框(对方的状态)(1).png) - -**状态:在线状态已具备基础能力;关系文案新增。** - -- 订阅 P2P Topic 后用 `{pres}` 更新在线/离线,`last_seen` 仅在对方隐私允许时展示。 -- `GET /v1/im/conversations/{topic}/context` 返回 `display_name,avatar_file_id,online_visibility,relationship`。 -- 好友关系变化由服务端生成 `system_event{event_type:"friend_relationship_changed"}`,客户端不得根据本地动作自行永久插入系统记录。 - -### 3.3 个人对话框(我的状态).png - -![个人对话框(我的状态)](images/个人对话框(我的状态).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)](images/个人对话框(我的状态)(1).png) - -**状态:发送和读回执已实现基础能力;送达状态映射需统一。** - -- 本地生成 `client_mid` 后显示 `sending`;`pub` 收到 `202 + seq` 更新为 `sent`;失败保留同一 `client_mid` 重试。 -- 对方 `note what:"recv"` 更新 `delivered`,`note what:"read"` 更新 `read`;多设备取服务端最大游标。 -- 状态只附着在气泡元数据,不修改消息正文。服务端拒绝时映射为 `failed(reason)`,不得伪装成已发送。 - -### 3.5 开启沟通页面.png - -![开启沟通页面](images/开启沟通页面.png) - -**状态:新增好友审批与 Topic 联动。** - -- 好友申请通过后,服务端在同一事务内建立双方关系和 P2P 订阅,并生成 `friend_relationship_changed:accepted` 系统事件。 -- 客户端收到事件或重新查询 `context.relationship=friend` 后解锁输入区;不得仅因本地“通过”按钮成功动画解锁。 -- 首次进入订阅 P2P Topic,再按 `since` 拉取申请上下文和正常消息。 - -### 3.6 双方沟通状态.png - -![双方沟通状态](images/双方沟通状态.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 - -![个人对话—拉黑状态/已读状态](images/个人对话—拉黑状态/已读状态.png) - -**状态:黑名单产品规则新增。** - -- 拉黑后 `context.relationship=blocked_by_me|blocked_by_peer`、`can_send=false`;服务端 `pub` 返回统一 `MESSAGE_NOT_ALLOWED`,不得向发送者泄露具体是对方拉黑。 -- 失败消息只保存在发送端本地并显示失败,不下发给对方、不写入正常消息表。 -- 已读仍按历史最大 `read` 渲染;拉黑不能回滚既有回执。解除拉黑见 5.10、5.12。 - -### 3.8 个人对话—删除状态.png - -![个人对话—删除状态](images/个人对话—删除状态.png) - -**状态:新增好友删除后的发送约束。** - -- 删除好友使用 `DELETE /v1/im/friends/{user_id}`;成功后双方关系变为 `none`,服务端推送关系变化事件。 -- 后续 `pub` 返回 `FRIEND_RELATION_REQUIRED`;前端保留失败草稿,提供“重新添加好友”入口,不自动发申请。 -- 历史聊天是否保留由 Topic 权限决定,删除好友不等于删除双方历史消息。 - -### 3.9 个人对话-键盘收起状态.png - -![个人对话-键盘收起状态](images/个人对话-键盘收起状态.png) - -**状态:纯客户端。** - -- 键盘、表情和附加面板收起不调用服务端;保持列表锚点和未发送草稿。 -- 草稿默认按 `tenant + user + topic` 本地加密保存;若实现多端草稿,再单独调用 `PUT /v1/im/topics/{topic}/draft`,不得混入聊天消息。 - -### 3.10 个人对话-发各种文件状态.png - -![个人对话-发各种文件状态](images/个人对话-发各种文件状态.png) - -**状态:面板纯客户端;上传需改造。** - -- 打开面板不请求。选择前依据 `context.capabilities` 判断图片、视频、语音、文件是否允许,调用系统选择器/相机。 -- 上传前校验 `mime,size` 和 `bootstrap.limits.max_file_size`;服务端仍必须重新校验租户配额、文件类型和会话权限。 -- 具体上传/发消息使用 3.6 协议;相机和麦克风授权见 10.20、10.21。 - -### 3.11 个人对话-发各种文件状态(1).png - -![个人对话-发各种文件状态(1)](images/个人对话-发各种文件状态(1).png) - -**状态:需改造产品消息 Schema。** - -- 客户端按 `x-im-type` 选择文本、名片、文件等卡片,公共字段统一使用 `from,topic,seq,ts,head,content`。 -- 每种卡片必须实现未知字段忽略、未知类型占位、发送/失败/回执状态和长按动作。 -- 重新安装或换设备后仅凭服务端消息即可还原卡片,不能依赖发送端本地路径或临时 URL。 - -### 3.12 个人对话-发送名片状态.png - -![个人对话-发送名片状态](images/个人对话-发送名片状态.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/{user_id}/profile`;用户不可见或已离职时显示失效卡片,不跨租户查询。 - -## 4. 消息输入、媒体内容与消息操作 - -### 4.1 键盘展开.png - -![键盘展开](images/键盘展开.png) - -**状态:纯客户端。** - -- 键盘展开、列表上推和光标变化不调用服务端;草稿按 `tenant + user + topic` 保存。 -- 点击发送时才校验 `context.can_send` 并执行 `pub text`;正文去首尾策略、最大长度从 `bootstrap.limits.max_text_length` 获取,服务端再次校验。 - -### 4.2 @人员—选择提醒人.png - -![@人员—选择提醒人](images/@人员—选择提醒人.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 - -![@人员—完成](images/@人员—完成.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 - -![@人员状态](images/@人员状态.png) - -**状态:需改造提醒和 Push。** - -- 消息仍通过 `{data}` 下行,客户端使用结构化 `mentions` 高亮,禁止通过字符串扫描昵称判断。 -- 当前用户被提及时会话摘要标记 `mentioned=true`;读到对应 `seq` 后随 `note read` 清除。 -- Push 服务按结构化目标生成“有人@你”文案;已退群用户不能收到新提醒,但历史消息仍按快照渲染。 - -### 4.5 表情包展开(发送按钮默认状态).png - -![表情包展开(发送按钮默认状态)](images/表情包展开(发送按钮默认状态).png) - -**状态:纯客户端。** - -- 面板、分类、最近使用和删除键均本地处理;未选择内容且文本为空时禁用发送。 -- Unicode 表情作为普通 `text` 内容发送。自定义表情资源首版未定义,不得把本地资源路径直接发送。 - -### 4.6 表情包发送页面.png - -![表情包发送页面](images/表情包发送页面.png) - -**状态:Unicode 表情使用已实现文本消息;独立表情包新增。** - -- 文本表情按 `pub x-im-type:text` 发送。独立自定义表情若启用,使用 `x-im-type:"sticker"`,`content={pack_id,sticker_id,file_id,width,height}`。 -- 自定义表情发送前服务端校验表情包属于当前租户且可用;未知表情显示占位,不使用任意外链 URL。 - -### 4.7 语音—按住说话.png - -![语音—按住说话](images/语音—按住说话.png) - -**状态:录音纯客户端;上传和语音消息需改造。** - -- 按下前检查麦克风权限和 `context.capabilities.voice`;权限不足进入 10.21,不发请求。 -- 松开发送:编码为服务端允许的 MIME,校验最短/最长时长,上传后 `pub x-im-type:voice`,内容含 `file_id,duration_ms,size,mime,waveform`。 -- 上滑取消、过短、系统来电打断时删除临时文件,不调用上传接口。 - -### 4.8 语音按住说话.png - -![语音按住说话](images/语音按住说话.png) - -**状态:纯客户端。** - -- 音量幅度、上滑取消区域和浮层颜色来自本地录音会话,不上传实时音量。 -- 进入取消区只改变本地状态;最终抬手决定删除或执行 4.7 的上传发送。 - -### 4.9 内容沟通对话3-语音录音中.png - -![内容沟通对话3-语音录音中](images/内容沟通对话3-语音录音中.png) - -**状态:录音纯客户端;临时状态可选改造。** - -- 录音期间客户端锁定其他媒体入口。默认不向群成员广播“正在录音”,以减少隐私暴露和状态风暴。 -- 若产品确认需要,可发送不落库的 `{note what:"kpa",topic}`;服务端限频,其他端超时自动消失,不能作为聊天记录。 - -### 4.10 内容沟通对话3-图片和语音发送中.png - -![内容沟通对话3-图片和语音发送中](images/内容沟通对话3-图片和语音发送中.png) - -**状态:客户端任务管理新增;服务端上传需改造。** - -- 每个待发送项保存 `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-语音/视频/图片状态](images/内容沟通对话3-语音/视频/图片状态.png) - -**状态:需改造媒体 Schema 和受权下载。** - -- 语音渲染 `duration_ms,waveform`,未听状态保存在客户端并可选跨端同步;视频渲染 `thumbnail_file_id,duration_ms,width,height`;图片渲染缩略图和原图 ID。 -- 预览前使用认证文件接口获取,服务端校验当前用户仍可访问引用消息;附件撤回或过期返回 `FILE_UNAVAILABLE`。 -- 缩略图和原文件分别授权,客户端缓存键必须包含租户与用户。 - -### 4.12 语音图片发送状态.png - -![语音图片发送状态](images/语音图片发送状态.png) - -**状态:需改造。** - -- 图片选择后立即显示本地缩略图,上传成功后只替换资源来源,不改变 `client_mid` 或列表位置。 -- 失败展示重试按钮;重试复用同一上传任务和消息幂等 ID。语音播放与图片上传状态相互独立。 - -### 4.13 上传文件.png - -![上传文件](images/上传文件.png) - -**状态:纯客户端视觉资源。** - -- 该图片仅定义统一上传图标和无障碍名称“上传文件”,不产生网络请求。 -- 文件助手、群文件、会话附件、反馈证据实际上传时统一走 0.2 文件接口,并额外提交业务 `purpose` 以便服务端执行权限和保留期策略。 - -### 4.14 文件发送状态.png - -![文件发送状态](images/文件发送状态.png) - -**状态:上传已实现基础能力;消息引用和 ACL 需改造。** - -- 上传成功后发送 `x-im-type:file`,`content={file_id,name,size,mime,extension,checksum}`;文件名做长度和控制字符清理。 -- 服务端在消息落库事务中关联文件;未关联的临时上传按 TTL 清理。卡片下载见 7.9~7.11。 -- 多文件逐个生成 `client_mid`,某个失败不能改变其他文件状态。 - -### 4.15 多选-选中对话框.png - -![多选-选中对话框](images/多选-选中对话框.png) - -**状态:选择纯客户端;批量动作新增。** - -- 客户端按 `(topic,seq)` 保存已选项并显示数量;默认最多 100 条,超限在本地阻止。 -- 转发、收藏、删除前把选中列表提交对应接口;服务端逐条校验仍可见、类型支持和权限,返回 `succeeded[]` 与 `failed[{seq,reason}]`。 -- 不允许只因第一条可操作就假定整批可操作。 - -### 4.16 多选-选中对话内容-收藏成功.png - -![多选-选中对话内容-收藏成功](images/多选-选中对话内容-收藏成功.png) - -**状态:新增。** - -- `POST /v1/im/favorites/batch`,Body `{"messages":[{"topic":"grp...","seq":"123"}]}`,带幂等键。 -- 返回 `created_count,existing_count,failed[]`;同一用户对同一 `topic+seq` 重复收藏不新增记录。 -- 收藏保存原消息引用、不可变展示快照和附件授权关系;成功 Toast 只在至少一项成功时展示,部分失败需给出汇总。 - -### 4.17 转发-单选.png - -![转发-单选](images/转发-单选.png) - -**状态:新增。** - -- 目标列表:`GET /v1/im/forward-targets?query=&cursor=`,只返回 `can_receive_forward=true` 的会话和文件助手。 -- 选中后本地进入确认页,不立即发送;服务端在最终转发接口再次校验目标 Topic、原消息和附件权限。 - -### 4.18 转发-多选.png - -![转发-多选](images/转发-多选.png) - -**状态:新增。** - -- 目标查询同 4.17;多选最多取 `bootstrap.limits.max_forward_targets`,首版建议 9。 -- 确认前只保存目标 Topic 字符串;不得缓存或提交 `tenant_id`。跨租户 Topic 在服务端统一拒绝。 - -### 4.19 转发—逐条转发.png - -![转发—逐条转发](images/转发—逐条转发.png) - -**状态:新增。** - -- `POST /v1/im/messages/forward`,Body `{"mode":"separate","sources":[{"topic":"p2p...","seq":"12"}],"target_topics":["grp..."],"comment":"..."}`。 -- 返回每个目标和源消息的 `client_mid/seq/status`;服务端保持源顺序,重新生成目标消息,不能复用原 `seq`。 -- 附件必须建立新的授权引用;原消息已撤回、过期或不可见时该项失败。留言作为每个目标的一条独立文本消息发送。 - -### 4.20 转发—合并转发—多选状态.png - -![转发—合并转发—多选状态](images/转发—合并转发—多选状态.png) - -**状态:新增。** - -- 调用 4.19 接口,`mode:"bundle"`;服务端生成 `forward_bundle`,内容含 `bundle_id,title,item_count,preview[]`,完整内容通过 `GET /v1/im/forward-bundles/{bundle_id}?cursor=` 获取。 -- 快照保留显示名、时间和内容摘要,对撤回/敏感字段按策略脱敏;不得允许接收端利用 Bundle 获取原会话额外权限。 -- 多目标共享不可变 Bundle 内容但分别生成目标消息与授权记录。 - -## 5. 通讯录、好友关系与黑名单 - -### 5.1 通讯录.png - -![通讯录](images/通讯录.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` 返回。列表缓存以租户和用户隔离。 - -### 5.2 新朋友.png - -![新朋友](images/新朋友.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` 并附当前状态,前端刷新该项。 - -### 5.3 添加好友.png - -![添加好友](images/添加好友.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)](images/添加好友(1).png) - -**状态:新增。** - -- 获取二维码:`POST /v1/im/me/qrcodes`,Body `{"purpose":"add_friend","expires_in_seconds":86400}`,返回 `qr_token,qr_payload,expires_at,display_profile`。 -- 二维码只含不透明、签名、带租户和用途约束的短期 Token,不直接放手机号、用户 ID 或 access token。 -- 分享/保存图片是客户端动作;扫码解析始终调用 5.3,过期返回 `QR_CODE_EXPIRED`,篡改返回 `QR_CODE_INVALID`。 - -### 5.5 添加好友-输入精准用户ID.png - -![添加好友-输入精准用户ID](images/添加好友-输入精准用户ID.png) - -**状态:新增。** - -- 精准查询仍调用 `GET /v1/im/users/search?q=&type=exact_account`;手机号查询仅当租户和目标隐私同时允许时使用 `type=phone`。 -- 每项返回公开资料和 `action=add|chat|pending|none`。前端不得根据是否返回手机号推断账户存在。 -- 搜索限流返回 `SEARCH_RATE_LIMITED`;禁止客户端批量枚举 ID。 - -### 5.6 添加好友验证.png - -![添加好友验证](images/添加好友验证.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 - -![好友个人主页](images/好友个人主页.png) - -**状态:新增。** - -- `GET /v1/im/users/{user_id}/profile` 返回按当前查看人裁剪后的 `display_name,remark,avatar_file_id,gender,region,public_account,signature,relationship,capabilities`。 -- 发消息使用返回的 P2P `topic`;没有 Topic 时调用 `POST /v1/im/conversations/p2p`,Body `{"peer_user_id":"..."}` 并由服务端验证好友关系。 -- 音视频按钮只按 `capabilities.can_call` 展示,真正呼叫时再次鉴权。隐藏字段必须从响应中省略,不能只由前端遮挡。 - -### 5.8 群聊—非好友个人主页.png - -![群聊—非好友个人主页](images/群聊—非好友个人主页.png) - -**状态:新增。** - -- `GET /v1/im/groups/{topic}/members/{user_id}/profile` 返回群内昵称、角色和允许公开的账号资料。 -- `can_add_friend` 同时受群 `allow_member_add_friend`、目标隐私和租户策略控制;为 false 时隐藏添加入口。 -- 从该页申请时 5.6 传 `source:"group"` 和当前 Topic;服务端校验双方仍在群内。 - -### 5.9 好友—聊天设置—未拉黑状态.png - -![好友—聊天设置—未拉黑状态](images/好友—聊天设置—未拉黑状态.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 - -![好友—聊天设置—拉黑状态](images/好友—聊天设置—拉黑状态.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 - -![删除好友提示](images/删除好友提示.png) - -**状态:确认框纯客户端;删除接口新增。** - -- 确认调用 `DELETE /v1/im/friends/{user_id}`,带幂等键。是否清除本地历史由客户端在成功后独立处理,不放入删除好友请求。 -- 服务端解除双向好友关系但不替对方删除历史;返回 `relationship:"none",topic`。当前用户是否删除本地/服务端可见记录是独立选择。 -- 取消不请求;确认后清理页面缓存并等待关系事件校正其他设备。 - -### 5.12 黑名单.png - -![黑名单](images/黑名单.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)](images/黑名单提示(1).png) - -**状态:确认框纯客户端;写接口新增。** - -- 确认后调用 `PUT /v1/im/blocks/{user_id}`,相同幂等键重复提交返回当前 `blocked=true`。 -- 服务端在同一事务内更新黑名单和通信 ACL,并写审计;取消不调用接口。 -- 若对象跨租户或不存在统一返回 `RESOURCE_NOT_FOUND`。 - -### 5.14 黑名单提示.png - -![黑名单提示](images/黑名单提示.png) - -**状态:图片文件名与画面语义不一致;按“清除聊天记录”实施,需改造。** - -- 确认调用 Tinode `{del what:"msg",topic,delseq:[{"low":1,"hi":last_seq+1}],hard:false}`,仅删除当前用户视图;或封装 `DELETE /v1/im/conversations/{topic}/messages`。 -- 不解除好友、不影响对方历史、不删除收藏;附件仅在无任何有效引用且过保留期后回收。 -- 前端需在确认文案明确“仅清除自己的聊天记录”,成功后清本地 Topic 消息缓存并保留会话关系。 - -## 6. 群聊创建、资料维护与成员治理 - -### 6.1 群聊.png - -![群聊](images/群聊.png) - -**状态: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`。 -- 分类由服务端 `my_role` 决定;创建成功或角色变化后通过系统事件刷新,不由前端自行移动分组。 - -### 6.2 空白页.png - -![空白页](images/空白页.png) - -**状态:纯客户端。** - -- 对 6.1 任一分类返回 `items=[]` 且 `has_more=false` 时显示对应空状态,不追加空白占位数据。 -- 收到 `group.membership.updated` 或创建成功后重新拉取当前分类。 - -### 6.3 加入群聊.png - -![加入群聊](images/加入群聊.png) - -**状态:新增邀请卡片和加入接口。** - -- 邀请卡下行 `system_event{event_type:"group_invitation",invitation_id,group,inviter,status,expires_at}`。 -- 点击加入:`POST /v1/im/group-invitations/{invitation_id}/accept`,带幂等键;返回 `status=joined|pending_approval` 和 `topic`。 -- 服务端校验邀请租户、有效期、群状态、成员资格和成员数上限;重复加入返回当前状态,过期返回 `INVITATION_EXPIRED`。 - -### 6.4 内容沟通对话3-开启进群验证与拉人状态.png - -![内容沟通对话3-开启进群验证与拉人状态](images/内容沟通对话3-开启进群验证与拉人状态.png) - -**状态:新增群成员事件。** - -- 邀请:`POST /v1/im/groups/{topic}/invitations`,Body `{"user_ids":[],"message":""}`;按群策略直接加入或创建待审批申请。 -- 加入、审批、拒绝等结果由服务端生成 `system_event{event_type:"group_membership_changed",operator,target_users,action,result}` 并写入群时间线。 -- 客户端只渲染可信系统事件;服务端同时更新成员表、Topic 订阅和成员数,不允许三者不一致。 - -### 6.5 邀请进群申请.png - -![邀请进群申请](images/邀请进群申请.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 - -![新邀请进群申请](images/新邀请进群申请.png) - -**状态:新增。** - -- `GET /v1/im/groups/{topic}/summary` 返回 `pending_join_request_count`、最新公告和权限;新申请通过 `group.join_request.created` 推送给有审批权限的成员。 -- 审批完成后服务端广播新数量;前端归零时移除入口。普通成员响应中不得得到申请人敏感信息或数量。 - -### 6.7 群公告状态.png - -![群公告状态](images/群公告状态.png) - -**状态:新增。** - -- 查询最新公告:`GET /v1/im/groups/{topic}/announcements/latest`;发布/更新:`PUT /v1/im/groups/{topic}/announcements/current`,Body `{"content":"...","version":1}`。 -- 返回 `announcement_id,content,author,version,published_at`。仅有权限角色可写,使用乐观锁防覆盖。 -- 发布成功后服务端生成群系统事件;顶部摘要使用接口返回数据,不解析聊天文本猜测。 - -### 6.8 群公告状态/邀请进群申请同时存在.png - -![群公告状态/邀请进群申请同时存在](images/群公告状态/邀请进群申请同时存在.png) - -**状态:纯客户端组合;数据接口新增。** - -- 页面一次调用 `GET /v1/im/groups/{topic}/summary`,分别读取 `announcement` 和 `pending_join_request_count`,按产品固定顺序渲染两个入口。 -- 处理申请只刷新计数,不清公告;更新公告只替换公告对象,不影响申请入口。 - -### 6.9 群公告(对方查看状态).png - -![群公告(对方查看状态)](images/群公告(对方查看状态).png) - -**状态:新增。** - -- 详情:`GET /v1/im/groups/{topic}/announcements/{announcement_id}`;普通成员响应不含编辑权限。 -- 已读:`POST /v1/im/groups/{topic}/announcements/{id}/read`,带幂等键,返回 `read_at`;用于清除新公告标记。 -- 服务端校验当前群成员资格,退群后不可借历史 ID 读取新公告。 - -### 6.10 群聊设置.png - -![群聊设置](images/群聊设置.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 - -![群资料](images/群资料.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 - -![设置群头像](images/设置群头像.png) - -**状态:上传基础已实现;用途绑定需改造。** - -- 来源选择、裁剪是客户端动作。上传使用 `purpose=group_avatar`,服务端校验图片 MIME、尺寸、大小并返回 `file_id`。 -- 上传后调用 6.11 PATCH `{"avatar_file_id":"...","version":n}`;只有 PATCH 成功才替换正式头像。 -- 取消或 PATCH 失败的临时文件按 TTL 回收,不能污染群文件列表。 - -### 6.13 设置昵称(1).png - -![设置昵称(1)](images/设置昵称(1).png) - -**状态:新增。** - -- `PUT /v1/im/groups/{topic}/members/me/profile`,Body `{"nickname":"...","version":n}`。 -- 服务端校验成员资格、长度和敏感词,返回 `nickname,version,updated_at` 并推送成员资料变化。 -- 该昵称仅作用当前群,不能修改全局用户昵称或其他群资料。 - -### 6.14 群简介.png - -![群简介](images/群简介.png) - -**状态:同 6.11,需改造。** - -- 保存调用 `PATCH /v1/im/groups/{topic}`,Body `{"description":"...","version":n}`;空字符串表示清空,省略表示不修改。 -- 服务端返回 `VALIDATION_FAILED` 的 `details.field/max_length` 或 `CONTENT_REJECTED`;前端保留草稿供修正。 - -### 6.15 群标识.png - -![群标识](images/群标识.png) - -**状态:角色已有基础数据;产品渲染需统一。** - -- 群消息下行增加服务端确认的 `sender_snapshot{display_name,role}`,角色枚举固定 `owner|admin|member`。 -- 成员角色变化事件到达后刷新成员缓存;历史消息可以保留发送时角色快照,详情以当前角色接口为准。 -- 客户端不得根据本地管理员列表给消息自行加可信标识。 - -### 6.16 群二维码页面.png - -![群二维码页面](images/群二维码页面.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 - -![群组管理](images/群组管理.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 - -![设置群管理员](images/设置群管理员.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 - -![添加管理员](images/添加管理员.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 - -![添加管理员-设置成功](images/添加管理员-设置成功.png) - -**状态:新增。** - -- 6.19 成功返回 `admins,version,updated_at`;前端用响应替换列表并显示成功,不在请求完成前自行修改角色。 -- 服务端推送 `group.role.updated`,新管理员收到后刷新菜单权限和群标识。 - -### 6.21 添加管理员-设置成功(1).png - -![添加管理员-设置成功(1)](images/添加管理员-设置成功(1).png) - -**状态:新增。** - -- 提交期间按幂等键禁重复点击。批量接口返回 `succeeded[]`、`failed[{user_id,reason}]` 和最新 `admins`。 -- 部分失败时保留成功角色,不做整批客户端回滚;前端展示失败原因并以服务端列表为准。 - -### 6.22 删除管理员-设置成功.png - -![删除管理员-设置成功](images/删除管理员-设置成功.png) - -**状态:新增。** - -- `DELETE /v1/im/groups/{topic}/admins/{user_id}`,带幂等键和 `If-Match`/`version`。 -- 成功后目标角色变为 `member`,保留群成员关系;服务端立即撤销管理权限并广播角色事件。 -- 删除群主、非成员或最后状态冲突分别返回稳定错误,不允许前端拼接通用成功。 - -### 6.23 添加禁言成员.png - -![添加禁言成员](images/添加禁言成员.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 - -![禁言的群成员](images/禁言的群成员.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 - -![选择新群主](images/选择新群主.png) - -**状态:新增。** - -- `GET /v1/im/groups/{topic}/members?eligible_role=owner&query=&cursor=`,只返回状态正常的当前成员。 -- 前端单选并保存 `target_user_id`;当前群主、已退出、停用账号返回 `eligible=false`。 -- 此步骤不修改权限,真正转让仅在 6.26 确认后执行。 - -### 6.26 选择新群主提示.png - -![选择新群主提示](images/选择新群主提示.png) - -**状态:新增。** - -- `POST /v1/im/groups/{topic}/owner-transfer`,Body `{"target_user_id":"usr...","version":n}`,必须带幂等键和二次认证 `X-Action-Token`。 -- 服务端事务内把旧群主降为管理员或成员、目标升为群主、更新权限和审计,再生成系统事件。 -- 成功返回 `owner,previous_owner,roles,version`;并发成员变化返回 `GROUP_VERSION_CONFLICT`,前端重新确认,不自动重试。 - -### 6.27 群文件.png - -![群文件](images/群文件.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 - -![群文件-上传状态](images/群文件-上传状态.png) - -**状态:需改造。** - -- 先用 `purpose=group_file,topic=...` 上传,再 `POST /v1/im/groups/{topic}/files`,Body `{"file_id":"..."}` 建立群文件记录。 -- 服务端校验 `member_upload_files`、单文件上限、租户总存储和群成员身份;成功返回完整群文件项。 -- 上传失败可复用 `upload_id`;登记失败不得在列表显示,孤立文件按 TTL 回收。 - -## 7. 文件下载、收藏管理与聊天记录检索 - -### 7.1 我的收藏.png - -![我的收藏](images/我的收藏.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)](images/我的收藏(1).png) - -**状态:新增。** - -- 详情:`GET /v1/im/favorites/{favorite_id}`;删除:`DELETE /v1/im/favorites/{favorite_id}`;转发使用 7.8。 -- 收藏创建时保存受控快照和附件引用,因此原消息对当前用户删除后仍可按保留策略展示;原资源因合规删除时返回 `availability:"removed"`。 -- 删除收藏不删除原消息或已转发消息。 - -### 7.3 我的收藏-文件.png - -![我的收藏-文件](images/我的收藏-文件.png) - -**状态:新增。** - -- 调用 7.1,固定 `type=file`;项中使用 `snapshot.file{name,size,mime,file_id}` 和 `availability`。 -- 点击前调用收藏详情获得当前授权,下载仍走 7.9 文件协议;失效显示原因,不反复请求公共 URL。 - -### 7.4 我的收藏-文本.png - -![我的收藏-文本](images/我的收藏-文本.png) - -**状态:新增。** - -- 调用 7.1,固定 `type=text`;服务端返回纯文本/结构化 Drafty 快照和安全高亮区间 `matches[]`。 -- 展开、折叠、复制为本地动作;复制前遵守企业 DLP 策略 `permissions.can_copy`。 - -### 7.5 我的收藏-语音.png - -![我的收藏-语音](images/我的收藏-语音.png) - -**状态:新增。** - -- 调用 7.1,固定 `type=voice`;播放前获取认证文件,返回 `duration_ms,waveform,availability`。 -- 播放/暂停和单实例播放器是客户端状态;服务端不接收逐秒播放进度,除非后续定义跨端收听状态。 - -### 7.6 我的收藏—链接.png - -![我的收藏—链接](images/我的收藏—链接.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 - -![收藏—文件(多选)](images/收藏—文件(多选).png) - -**状态:新增。** - -- 选择状态本地维护;批量删除 `POST /v1/im/favorites/batch-delete`,Body `{"favorite_ids":[]}`;批量转发见 7.8。 -- 返回逐项成功/失败;删除必须二次确认并带幂等键。跨页选择只保存 ID,不保留失效的临时下载地址。 - -### 7.8 收藏文件—分享给好友页面.png - -![收藏文件—分享给好友页面](images/收藏文件—分享给好友页面.png) - -**状态:新增。** - -- 目标列表使用 4.17;提交 `POST /v1/im/favorites/forward`,Body `{"favorite_ids":[],"target_topics":[],"comment":""}`。 -- 服务端从收藏的受控附件引用生成新消息和新 ACL,返回每个目标的消息结果;收藏原件不变。 -- 文件失效、目标禁言或无发送权限按目标逐项失败,不允许客户端直接复用旧下载 URL 发送。 - -### 7.9 文件-下载.png - -![文件-下载](images/文件-下载.png) - -**状态:底层下载已实现租户校验;资源 ACL 需改造。** - -- 客户端使用 `GET /v0/file/s/{file_id}`,Header 传 API Key 与 `X-Tinode-Auth: token <base64_token>`;禁止 `?auth=&secret=`。 -- 服务端除 Token 租户外,还必须校验该用户通过消息、群文件或收藏拥有有效引用,并支持 `Range`、`ETag`、内容长度和安全文件名。 -- 下载前客户端校验本地空间和网络策略;`FILE_FORBIDDEN` 不自动重试,`FILE_UNAVAILABLE` 显示失效。 - -### 7.10 文件-下载中.png - -![文件-下载中](images/文件-下载中.png) - -**状态:客户端任务管理;服务端需支持 Range。** - -- 下载任务保存 `file_id,etag,total_bytes,downloaded_bytes,temp_path,state`;恢复时携带 `Range` 与 `If-Range: <etag>`。 -- ETag 改变或服务端返回完整 200 时丢弃旧分片重新下载;取消时关闭连接并清理临时文件。 -- Token 过期先完成一次刷新后续传,不能把 Token 写入任务文件或日志。 - -### 7.11 文件-下载完成.png - -![文件-下载完成](images/文件-下载完成.png) - -**状态:纯客户端完成态。** - -- 完成后校验长度和服务端提供的 checksum,再原子移动到受控目录;校验失败删除并标记重试。 -- 打开/分享遵守系统沙箱和企业 DLP。每次展示页面检查本地文件存在性,不存在则恢复“可下载”。 - -### 7.12 查找聊天记录-搜索关键词选中状态(点击后跳出对应聊天内容).png - -![查找聊天记录-搜索关键词选中状态(点击后跳出对应聊天内容)](images/查找聊天记录-搜索关键词选中状态(点击后跳出对应聊天内容).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 - -![查找聊天记录-文件](images/查找聊天记录-文件.png) - -**状态:新增。** - -- 调用 `GET /v1/im/topics/{topic}/messages/search?type=file&q=&cursor=`,返回消息 `seq` 与文件快照、发送者、时间、`availability`。 -- 预览/下载使用 7.9;撤回或无授权时显示失效。点击来源用 7.12 上下文接口定位。 - -### 7.14 查找聊天记录-链接.png - -![查找聊天记录-链接](images/查找聊天记录-链接.png) - -**状态:新增。** - -- 调用搜索接口 `type=link`,返回 `url,title,summary,seq,ts,risk_level`;打开前执行 7.6 URL 安全检查。 -- 链接预览由服务端受控抓取和缓存,客户端不直接抓取内网地址,防止 SSRF 和隐私泄露。 - -### 7.15 查看聊天记录-图片.png - -![查看聊天记录-图片](images/查看聊天记录-图片.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 - -![查看聊天记录-视频](images/查看聊天记录-视频.png) - -**状态:新增。** - -- 调用 7.15 接口,`type=video`,额外返回 `duration_ms,mime,size`。 -- 播放器使用支持 Range 的认证下载/流式读取;文件失效、Token 过期和网络中断分别映射可理解状态,返回列表保持游标与滚动位置。 - -## 8. 实时语音与视频通话 - -### 8.1 各种通话提示状态.png - -![各种通话提示状态](images/各种通话提示状态.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 - -![对方状态](images/对方状态.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 - -![语音通话-对方待接受状态](images/语音通话-对方待接受状态.png) - -**状态:发起与超时已实现基础;好友/拉黑/租户开关校验需改造。** - -- 发起前查询 `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 - -![语音通话-双方语音沟通中](images/语音通话-双方语音沟通中.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 - -![语音通话-麦克风与扬声器已关](images/语音通话-麦克风与扬声器已关.png) - -**状态:纯客户端设备控制。** - -- 麦克风关闭通过禁用本地音轨,扬声器关闭/听筒切换通过系统音频路由完成,不调用业务 HTTP。 -- UI 必须读取实际音轨和路由状态;权限撤销时强制更新。是否告知对方静音可发送临时 `media-state`,不落聊天记录。 - -### 8.6 语音通话-麦克风与扬声器已开.png - -![语音通话-麦克风与扬声器已开](images/语音通话-麦克风与扬声器已开.png) - -**状态:纯客户端设备控制。** - -- 开启麦克风恢复本地音轨,开启扬声器选择实际输出设备;蓝牙/耳机变化以操作系统回调为准。 -- 不把设备名称、系统音量上传服务端。切换失败恢复真实按钮状态并给本地提示。 - -### 8.7 视频通话—等待对方接受接受邀请中.png - -![视频通话—等待对方接受接受邀请中](images/视频通话—等待对方接受接受邀请中.png) - -**状态:基础通话信令已实现;隐私约束需落实。** - -- 发起协议同 8.3,`media_type:"video"`。本地预览只在设备显示,接听前媒体轨默认不发给对方,SDP 只声明能力。 -- 切换摄像头纯客户端;取消发送 `hang-up:cancelled`。相机权限不足时改为音频呼叫或取消,不伪装视频已开启。 - -### 8.8 视频通话—等待对方接受接受邀请-摄像头关闭.png - -![视频通话—等待对方接受接受邀请-摄像头关闭](images/视频通话—等待对方接受接受邀请-摄像头关闭.png) - -**状态:纯客户端状态;媒体状态事件需改造。** - -- 关闭本地视频轨并显示头像;接听后沿用 `video_enabled=false`。 -- 当前服务端只允许 `ringing/accept/offer/answer/ice-candidate/hang-up`;首版应直接通过 WebRTC 轨道 mute 状态呈现。若后续新增 `media-state`,必须先修改 `server/calls.go` 事件白名单、参与者校验和限频,客户端不能提前发送未知事件。 - -### 8.9 双方视频通话中.png - -![双方视频通话中](images/双方视频通话中.png) - -**状态:WebRTC 客户端能力;服务端信令已具备基础。** - -- 主/小窗布局、拖动和横竖屏纯客户端;WebRTC offer/answer/ICE 与 8.2~8.4 相同。 -- 服务端只做身份鉴权、信令路由、TURN 配置和通话审计,不接收前端伪造的通话时长。 -- 网络恶化可本地降码率或关闭视频轨;最终结束必须发 `hang-up`。服务端未支持 `media-state` 前不得发送该事件。 - -### 8.10 双方视频通话中-对方关闭摄像头状态.png - -![双方视频通话中-对方关闭摄像头状态](images/双方视频通话中-对方关闭摄像头状态.png) - -**状态:现阶段纯客户端;可选媒体状态事件需改造。** - -- 以对端 WebRTC 视频轨的 mute/unmute 作为真实信号;关闭时显示资料缓存中的对方头像并保留音频,轨道恢复后自动显示视频。 -- 不需要业务 HTTP。可选 `media-state` 必须等服务端扩展事件白名单后另行启用,且不持久化、不改变通话结果。 - -### 8.11 视频与语音通话悬浮状态中.png - -![视频与语音通话悬浮状态中](images/视频与语音通话悬浮状态中.png) - -**状态:纯客户端。** - -- 最小化不结束 WebRTC 或 WebSocket,会话仍以同一 `call_id` 运行;点击恢复全屏不发送新 invite。 -- 系统悬浮窗权限、画中画和安全区域按平台实现。收到 `hang-up`、Token 失效或媒体会话结束后立即关闭悬浮窗并落正确结果态。 - -## 9. 企业工作台与办公协同 - -### 9.1 工作台.png - -![工作台](images/工作台.png) - -**状态:新增。** - -- `GET /v1/im/workbench` 返回 `modules[{code,name,icon_file_id,route,unread_count,enabled,permission}]`、布局版本和企业配置。 -- 服务端按当前 Token 的租户、组织角色和功能开关裁剪;前端只接受白名单内部 `route`,禁止直接执行服务端任意 URL。 -- 未读数通过 `workbench.badge.updated` 系统事件增量更新;切租户必须重新加载,不复用另一企业工作台。 - -### 9.2 部门组织框架.png - -![部门组织框架](images/部门组织框架.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=`。服务端执行组织范围授权,离职用户标记不可联系或从结果移除。 - -### 9.3 会议通知-发起人状态.png - -![会议通知-发起人状态](images/会议通知-发起人状态.png) - -**状态:新增。** - -- 创建:`POST /v1/im/meetings`,Body `{"title":"...","start_at":"...","end_at":"...","location":"...","attendee_user_ids":[],"reminder_minutes":[]}`。 -- 详情:`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`。所有写操作带幂等键并生成通知和审计。 - -### 9.4 会议通知-接收人状态.png - -![会议通知-接收人状态](images/会议通知-接收人状态.png) - -**状态:新增。** - -- 详情同 9.3;响应:`PUT /v1/im/meetings/{meeting_id}/my-response`,Body `{"response":"accepted|declined|tentative","version":n}`。 -- 返回我的最终响应和会议版本,并实时通知发起人;重复提交同状态幂等。 -- 会议过期返回 `MEETING_CLOSED` 并只读展示;客户端本地日历写入是可选系统动作,不替代服务端响应。 - -### 9.5 日报.png - -![日报](images/日报.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 - -![日报-普通用户页面](images/日报-普通用户页面.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 - -![日报-管理员页面](images/日报-管理员页面.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 - -![日报-查看详情](images/日报-查看详情.png) - -**状态:新增。** - -- `GET /v1/im/reports/daily/{report_id}` 返回填报人、日期、内容、附件、状态、版本、修改时间和 `permissions`。 -- 管理员查看时服务端记录必要的已读状态;附件下载复用第 7 章 ACL。 -- 撤回或修订使用专用状态和版本,客户端必须显示最新版本与 `revised_at`,不能混合旧缓存。 - -### 9.9 月报(1).png - -![月报(1)](images/月报(1).png) - -**状态:新增。** - -- 创建/取得当月草稿:`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 - -![月报](images/月报.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 - -![月报-详情页](images/月报-详情页.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 - -![总经理信箱](images/总经理信箱.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 - -![总经理信箱-文案提示](images/总经理信箱-文案提示.png) - -**状态:新增配置接口。** - -- 文案使用 9.12 配置中的 `notice{title,content,updated_at}`,由租户后台配置;客户端不得硬编码实名/匿名承诺。 -- 提交按钮前必须展示当前版本文案;请求可携带 `notice_version`,服务端发现关键政策已变更时返回 `NOTICE_RECONFIRM_REQUIRED`。 - -## 10. 个人资料、通用设置、隐私与青少年模式 - -### 10.1 我的.png - -![我的](images/我的.png) - -**状态:基础用户资料已有;产品菜单新增。** - -- `GET /v1/im/me` 返回公开账号资料、实名/青少年模式摘要和资料版本;`GET /v1/im/bootstrap` 返回个人菜单与权限。 -- 头像、昵称更新事件到达后刷新头部;收藏和设置入口分别进入第 7、10 章。 -- 账号 ID 按字符串展示,客户端不得展示内部数据库 ID 或 tenant_id。 - -### 10.2 个人信息.png - -![个人信息](images/个人信息.png) - -**状态:Tinode `me` 资料更新已有基础;目标产品接口需改造。** - -- 查询 `GET /v1/im/me/profile` 返回 `avatar_file_id,nickname,birth_date,signature,gender,region,public_account,version,field_rules`。 -- 更新统一使用 `PATCH /v1/im/me/profile`,仅提交变更字段并带 `version`;服务端更新后同步 Tinode `me.public` 并推送资料事件。 -- 头像先以 `purpose=user_avatar` 上传再 PATCH;任何字段的可见性由隐私策略裁剪。 - -### 10.3 设置昵称.png - -![设置昵称](images/设置昵称.png) - -**状态:需改造。** - -- `PATCH /v1/im/me/profile`,Body `{"nickname":"新昵称","version":n}`。 -- 服务端校验 Unicode 长度、全空白、控制字符和敏感内容;返回更新后的 `nickname,version,updated_at`。 -- 成功刷新全局资料和联系人快照,但不得覆盖各群昵称。版本冲突先拉最新资料再由用户确认。 - -### 10.4 出生日期.png - -![出生日期](images/出生日期.png) - -**状态:新增产品字段。** - -- 保存同 10.3,Body `{"birth_date":"2000-01-02","version":n}`;仅传 ISO 日期,不传本地化文本或时区时间戳。 -- 服务端校验不晚于当前租户日期及合理范围,返回 `PROFILE_FIELD_INVALID`。展示格式由客户端本地化。 - -### 10.5 个性签名.png - -![个性签名](images/个性签名.png) - -**状态:需改造。** - -- 保存同 10.3,Body `{"signature":"...","version":n}`;空字符串表示清空。 -- 服务端按 `field_rules.signature.max_length` 和内容审核处理;失败保留本地草稿,成功刷新引用页面。 - -### 10.6 二维码.png - -![二维码](images/二维码.png) - -**状态:图标纯客户端;二维码协议新增。** - -- 点击入口进入 5.4 并创建短期个人二维码。每次主动刷新调用 `POST /v1/im/me/qrcodes` 获取新 Token。 -- 二维码不能使用静态 `user_id`、手机号或登录 Token;过期、撤销和切租户后必须重新生成。 - -### 10.7 设置(iOS).png - -![设置(iOS)](images/设置(iOS).png) - -**状态:菜单纯客户端;数据接口部分新增。** - -- 菜单来自 `bootstrap.settings_menu`,安全、通知、隐私、通用、关于按权限展示。系统通知权限状态由 iOS API 读取,不由服务端伪造。 -- 退出:`POST /v1/im/auth/logout` 撤销当前 refresh/access token 和设备会话,成功或 Token 已失效后关闭 WS、清安全存储和本租户用户缓存。 -- 多设备退出使用 `DELETE /v1/im/security/sessions/{session_id}`,需二次验证。 - -### 10.8 设置(安卓).png - -![设置(安卓)](images/设置(安卓).png) - -**状态:菜单纯客户端;业务协议同 10.7。** - -- 使用相同 `bootstrap.settings_menu` 与退出协议;Android 系统权限、通知渠道和应用更新使用平台实现。 -- 服务端只保存应用内通知偏好与设备 Push Token,不把系统设置页面是否打开当作已授权。 - -### 10.9 清空缓存的提示.png - -![清空缓存的提示](images/清空缓存的提示.png) - -**状态:纯客户端。** - -- 确认后只删除缩略图、可重下文件、临时上传下载和搜索缓存;不调用服务端删除消息、收藏或账号接口。 -- 不删除安全存储中的登录凭证,除非用户选择退出登录。完成后重新计算本地占用并显示结果。 - -### 10.10 版本更新页面.png - -![版本更新页面](images/版本更新页面.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 - -![发现新版本](images/发现新版本.png) - -**状态:纯客户端弹窗;数据来自 10.10。** - -- `optional` 显示稍后/更新,记录本地已忽略版本并按服务端 `remind_after` 再提示;`required` 不显示绕过入口。 -- 下载和安装状态由平台管理;接口异常时不得误判为强制更新,除非本地已有有效签名的最低版本策略缓存。 - -### 10.12 隐私设置.png - -![隐私设置](images/隐私设置.png) - -**状态:新增。** - -- `GET /v1/im/me/privacy` 返回 `add_me_methods,profile_visibility,last_seen_visibility,read_receipts,version` 和各字段 `editable`。 -- `PATCH /v1/im/me/privacy` 只提交变更字段和 `version`;成功返回完整最新配置并向其他设备推送 `privacy.updated`。 -- 黑名单入口使用 5.12。服务端在搜索、主页、回执等实际读写路径执行隐私规则,不能只保存开关。 - -### 10.13 隐私设置-添加我的方式选项.png - -![隐私设置-添加我的方式选项](images/隐私设置-添加我的方式选项.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 - -![青少年模式未开启(安卓)](images/青少年模式未开启(安卓).png) - -**状态:新增。** - -- `GET /v1/im/settings/youth-mode` 返回 `enabled,policy_version,restrictions,password_set,can_enable`。 -- 点击开启先展示 10.15,再按 1.7 完成身份验证和 1.11 设置独立密码;最后调用 `POST /v1/im/settings/youth-mode/enable`,Body `{"policy_version":n,"action_token":"..."}`。 -- 服务端持久化到用户租户范围并推送所有设备,不能仅设置本地标志。 - -### 10.15 青少年模式提示(安卓).png - -![青少年模式提示(安卓)](images/青少年模式提示(安卓).png) - -**状态:弹窗纯客户端;文案由服务端配置。** - -- 使用 10.14 返回的 `restrictions` 与 `policy_version` 渲染;取消不请求,确认进入密码设置/验证。 -- 最终 enable 请求携带用户确认的 `policy_version`;若策略更新返回 `POLICY_RECONFIRM_REQUIRED`,重新展示新内容。 - -### 10.16 青少年模式已开启(安卓).png - -![青少年模式已开启(安卓)](images/青少年模式已开启(安卓).png) - -**状态:新增。** - -- 页面调用 10.14,`enabled=true` 时展示 `enabled_at,restrictions`。`bootstrap` 同时返回受限模块和动作。 -- 受限接口服务端返回 `YOUTH_MODE_RESTRICTED`;前端隐藏只是体验优化,不能作为安全控制。 -- 登录、App 重启和另一设备均从服务端恢复状态;收到 `youth_mode.updated` 即刷新。 - -### 10.17 关闭青少年模式(安卓).png - -![关闭青少年模式(安卓)](images/关闭青少年模式(安卓).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 - -![获取推送权限(安卓)](images/获取推送权限(安卓).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 - -![提醒(安卓)](images/提醒(安卓).png) - -**状态:纯客户端。** - -- 仅在功能因系统权限不可用时显示;取消和“前往设置”不调用业务服务端。 -- 回到应用后重新读取真实权限,不能因用户打开过设置页就假定授权成功。 - -### 10.20 相机说明(安卓).png - -![相机说明(安卓)](images/相机说明(安卓).png) - -**状态:纯客户端。** - -- 首次拍照、扫码或视频通话前展示用途说明,确认后再触发系统权限;拒绝不发媒体/扫码请求。 -- 永久拒绝时提供系统设置入口;允许从相册选择等替代能力。客户端不得上传系统权限状态作为服务端授权依据。 - -### 10.21 麦克风说明(安卓).png - -![麦克风说明(安卓)](images/麦克风说明(安卓).png) - -**状态:纯客户端。** - -- 首次录音或通话前说明并申请系统权限;未授权时不得创建录音文件或发送 call invite。 -- 永久拒绝后进入系统设置引导;恢复授权后重新执行 4.7 或第 8 章动作,不复用过期媒体会话。 - -## 11. 反诈安全、举报与意见反馈 - -### 11.1 反诈弹窗(安卓).png - -![反诈弹窗(安卓)](images/反诈弹窗(安卓).png) - -**状态:新增风控决策协议。** - -- 高风险动作在原业务接口返回 `409 RISK_CONFIRMATION_REQUIRED`,`details={risk_challenge_id,level,notice_code,notice_params,expires_at,action}`。 -- 客户端用 `notice_code` 映射经过审核的本地文案,不显示服务端规则、分数或命中特征;取消不重试原操作。 -- 低风险提示也可由可信 `security.risk_notice` 系统事件触发。普通消息不得伪装成系统反诈弹窗。 - -### 11.2 反诈弹窗(二次提示安卓).png - -![反诈弹窗(二次提示安卓)](images/反诈弹窗(二次提示安卓).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 - -![举报](images/举报.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)](images/举报(1).png) - -**状态:新增。** - -- 证据使用 `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 - -![意见反馈](images/意见反馈.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"`,重复提交由幂等键去重。 - -## 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` 基础 Tinode 协议。 -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、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 过期后刷新并 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/IM-前后端对接文档.md b/docs/IM-前后端对接文档.md index 89ade44..be359ba 100644 --- a/docs/IM-前后端对接文档.md +++ b/docs/IM-前后端对接文档.md @@ -334,16 +334,60 @@ X-Tinode-APIKey: <预置的普通 API Key> | 更新会话私有设置 | `{"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 系统事件、好友和群业务下行 +##### 0.5.4.6 系统通知、好友和群业务下行 -| 事件 | 服务端下行示例 | 客户端动作 | -| --- | --- | --- | -| 好友申请创建 | `{"data":{"topic":"sys","seq":101,"from":"usr_system","head":{"mime":"application/json","x-im-type":"system_event"},"content":{"event_type":"friend.request.created","request_id":"frq_1","applicant":{"user_id":"usr_a","display_name":"张三"},"message":"我是张三","action":{"type":"friend_request","request_id":"frq_1"}}}}` | 系统消息入库/展示,刷新 `friend-requests` 和 `bootstrap.entry_badges` | -| 好友关系变化 | `{"data":{"topic":"sys","seq":102,"head":{"x-im-type":"system_event"},"content":{"event_type":"friend_relationship_changed","peer_user_id":"usr_a","relationship":"friend","topic":"p2p..."}}}` | 刷新会话 context,解锁或锁定输入区 | -| 群邀请 | `{"data":{"topic":"sys","seq":103,"head":{"x-im-type":"system_event"},"content":{"event_type":"group_invitation","invitation_id":"ginv_1","group":{"topic":"grp...","name":"项目群"},"inviter":{"user_id":"usr_a","display_name":"张三"},"status":"pending","expires_at":"2026-08-27T09:00:00Z"}}}` | 展示邀请卡,点击后走邀请接受或入群申请接口 | -| 入群申请 | `{"data":{"topic":"grp...","seq":20,"head":{"x-im-type":"system_event"},"content":{"event_type":"group.join_request.created","request_id":"gjr_1","applicant":{"user_id":"usr_a","display_name":"张三"},"source":"qr"}}}` | 管理员刷新 `join-requests` 和 `summary.pending_join_request_count` | -| 群成员变化 | `{"data":{"topic":"grp...","seq":21,"head":{"x-im-type":"system_event"},"content":{"event_type":"group_membership_changed","operator":"usr_owner","target_users":["usr_a"],"action":"join","result":"joined"}}}` | 刷新群成员列表、群详情成员数和会话系统提示 | -| 工作台未读变化 | `{"data":{"topic":"sys","seq":104,"head":{"x-im-type":"system_event"},"content":{"event_type":"workbench.badge.updated","modules":[{"code":"meetings","unread_count":2}],"workbench_total_unread":2}}}` | 更新工作台 badge;必要时重新拉 `GET /v1/im/workbench` | +服务端主动下行分两类:Tinode 原生 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 通话信令 @@ -696,7 +740,7 @@ X-Tinode-APIKey: <预置的普通 API Key> - 查询:`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` 系统事件更新状态。 +- 成功:返回 `submission_id`、`status:"pending"`;前端禁用重复提交,当前以 `GET /v1/im/security/realname` 查询状态为准。后续如启用 `security.realname.updated`,应按 0.5.4.6 的 `info.what="im"` 业务事件下发。 - 安全:证件号传输和存储加密、展示脱敏;普通日志、搜索索引和消息中禁止出现原值;重复身份由租户策略决定并返回稳定错误。 ### 1.9 实名认证-已实名.png @@ -808,7 +852,7 @@ X-Tinode-APIKey: <预置的普通 API Key> **状态:需改造。** -- `GET /v1/im/system-notifications?cursor=` 返回 `id,event_type,title,summary,created_at,read_at,action`;新通知通过服务端专用系统 Topic 的 `system_event` 消息推送。 +- `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 发布的路径必须封禁。客户端不得渲染普通用户伪造的系统卡片为可信通知。 @@ -852,7 +896,7 @@ X-Tinode-APIKey: <预置的普通 API Key> - 订阅 P2P Topic 后用 `{pres}` 更新在线/离线,`last_seen` 仅在对方隐私允许时展示。 - `GET /v1/im/conversations/{topic}/context` 返回 `display_name,avatar_file_id,online_visibility,relationship`。 -- 好友关系变化由服务端生成 `system_event{event_type:"friend_relationship_changed"}`,客户端不得根据本地动作自行永久插入系统记录。 +- 好友关系变化由服务端生成 0.5.4.6 定义的 WS 业务事件,客户端不得根据本地动作自行永久插入系统通知记录。 ### 3.3 个人对话框(我的状态).png @@ -880,7 +924,7 @@ X-Tinode-APIKey: <预置的普通 API Key> **状态:新增好友审批与 Topic 联动。** -- 好友申请通过后,服务端在同一事务内建立双方关系和 P2P 订阅,并生成 `friend_relationship_changed:accepted` 系统事件。 +- 好友申请通过后,服务端在同一事务内建立双方关系和 P2P 订阅,并向申请人、通过人本人下发 `info.what="im", event="friend.request.accepted"`;申请人当前会写入系统通知,通过人本人当前只收到实时业务事件。 - 客户端收到事件或重新查询 `context.relationship=friend` 后解锁输入区;不得仅因本地“通过”按钮成功动画解锁。 - 首次进入订阅 P2P Topic,再按 `since` 拉取申请上下文和正常消息。 @@ -1174,6 +1218,8 @@ X-Tinode-APIKey: <预置的普通 API Key> | `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 @@ -1196,6 +1242,7 @@ X-Tinode-APIKey: <预置的普通 API Key> - 列表:`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 @@ -1361,7 +1408,7 @@ X-Tinode-APIKey: <预置的普通 API Key> - 列表:`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` 决定;创建成功或角色变化后通过系统事件刷新,不由前端自行移动分组。 +- 分类由服务端 `my_role` 决定;创建成功或角色变化后通过 `info.what="group"` 业务事件刷新,不由前端自行移动分组。 ### 6.2 空白页.png @@ -1378,7 +1425,7 @@ X-Tinode-APIKey: <预置的普通 API Key> **状态:新增邀请卡片和加入接口。** -- 邀请卡下行 `system_event{event_type:"group_invitation",invitation_id,group,inviter,status,expires_at}`。 +- 邀请卡展示以群邀请接口返回和系统通知记录为准;相关实时刷新使用 `info.what="group"` 群业务事件。 - 点击加入:`POST /v1/im/group-invitations/{invitation_id}/accept`,带幂等键;返回 `status=joined|pending_approval` 和 `topic`。 - 服务端校验邀请租户、有效期、群状态、成员资格和成员数上限;重复加入返回当前状态,过期返回 `INVITATION_EXPIRED`。 @@ -1389,8 +1436,8 @@ X-Tinode-APIKey: <预置的普通 API Key> **状态:新增群成员事件。** - 邀请:`POST /v1/im/groups/{topic}/invitations`,Body `{"user_ids":[],"message":""}`;按群策略直接加入或创建待审批申请。 -- 加入、审批、拒绝等结果由服务端生成 `system_event{event_type:"group_membership_changed",operator,target_users,action,result}` 并写入群时间线。 -- 客户端只渲染可信系统事件;服务端同时更新成员表、Topic 订阅和成员数,不允许三者不一致。 +- 加入、审批、拒绝等结果由服务端生成 `info.what="group"` 群业务事件,例如 `group.member.joined`、`group.join_request.accepted/rejected`。 +- 客户端只渲染服务端可信事件;服务端同时更新成员表、Topic 订阅和成员数,不允许三者不一致。 ### 6.5 邀请进群申请.png @@ -1419,7 +1466,7 @@ X-Tinode-APIKey: <预置的普通 API Key> - 查询最新公告:`GET /v1/im/groups/{topic}/announcements/latest`;发布/更新:`PUT /v1/im/groups/{topic}/announcements/current`,Body `{"content":"...","version":1}`。 - 返回 `announcement_id,content,author,version,published_at`。仅有权限角色可写,使用乐观锁防覆盖。 -- 发布成功后服务端生成群系统事件;顶部摘要使用接口返回数据,不解析聊天文本猜测。 +- 发布成功后服务端生成 `group.announcement.updated` 群业务事件;顶部摘要使用接口返回数据,不解析聊天文本猜测。 ### 6.8 群公告状态/邀请进群申请同时存在.png @@ -1603,7 +1650,7 @@ X-Tinode-APIKey: <预置的普通 API Key> **状态:新增。** - `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 @@ -2001,7 +2048,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` 系统事件增量更新;切租户必须重新加载,不复用另一企业工作台。 +- 工作台一级总未读以 `GET /v1/im/bootstrap` 和 `GET /v1/im/workbench` 返回为准;若后续启用 `workbench.badge.updated`,应按 0.5.4.6 的 `info.what="im"` 业务事件下发。切租户必须重新加载,不复用另一企业工作台。 ### 9.2 部门组织框架.png @@ -2022,6 +2069,7 @@ X-Tinode-APIKey: <预置的普通 API Key> - 创建:`POST /v1/im/meetings`,Body `{"title":"...","start_at":"...","end_at":"...","location":"...","attendee_user_ids":[],"reminder_minutes":[]}`。 - 详情:`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 @@ -2030,7 +2078,7 @@ X-Tinode-APIKey: <预置的普通 API Key> **状态:新增。** - 详情同 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 @@ -2410,7 +2458,7 @@ X-Tinode-APIKey: <预置的普通 API Key> - 高风险动作在原业务接口返回 `409 RISK_CONFIRMATION_REQUIRED`,`details={risk_challenge_id,level,notice_code,notice_params,expires_at,action}`。 - 客户端用 `notice_code` 映射经过审核的本地文案,不显示服务端规则、分数或命中特征;取消不重试原操作。 -- 低风险提示也可由可信 `security.risk_notice` 系统事件触发。普通消息不得伪装成系统反诈弹窗。 +- 低风险提示也可由服务端可信安全事件触发。当前风险确认/取消会按 0.5.4.6 下发 `security.risk.continue/cancel`;普通消息不得伪装成系统反诈弹窗。 ### 11.2 反诈弹窗(二次提示安卓).png @@ -2489,7 +2537,7 @@ X-Tinode-APIKey: <预置的普通 API Key> | --- | --- | --- | | 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 | 普通用户禁止发布系统 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 | | P1 | 好友申请、好友关系、黑名单状态机 | 第 3、5 章 | diff --git a/docs/IM-服务端主动推送消息梳理.md b/docs/IM-服务端主动推送消息梳理.md new file mode 100644 index 0000000..f0828e1 --- /dev/null +++ b/docs/IM-服务端主动推送消息梳理.md @@ -0,0 +1,211 @@ +# IM 服务端主动推送消息梳理 + +> 本文先按当前代码实现梳理,后续确认产品口径后再合并到《IM-前后端对接文档》。 + +## 1. 术语和通道 + +服务端主动下行分两层: + +1. Tinode 原生实时消息:通过 WebSocket/Long Polling 下发 `{data}`、`{info}`、`{pres}`、`{meta}` 等帧,覆盖聊天消息、回执、输入中、在线状态、通话信令。 +2. IM 产品业务事件:HTTP 写操作或业务事务写入 `im_business_events_outbox` / 群业务 outbox 后,由服务端投递为 `{info}` 帧,前端据此刷新业务接口或局部状态。 + +当前产品业务事件不是聊天消息,不占普通会话消息 `seq`。系统通知列表是否出现记录,取决于是否额外写入 `im_system_notifications`。 + +## 2. 产品业务事件统一帧格式 + +### 2.1 用户维度事件 + +来自 `im_business_events_outbox`,投递到用户自己的在线会话。 + +```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" + } +} +``` + +字段说明: + +| 字段 | 说明 | +| --- | --- | +| `info.topic` | 目标用户 ID,即事件接收人 | +| `info.from` | 操作人用户 ID;系统事件可能为空 | +| `info.what` | 固定为 `im` | +| `info.event` | 产品事件类型 | +| `info.payload` | 事件载荷,按事件类型变化 | + +投递机制: + +- 写入 outbox 后会立即尝试投递。 +- Hub 不可用或队列满时保持 pending,后续 poller 每秒按在线租户重试。 +- 投递成功后标记 delivered。 +- `registration.invite_used` 与 `security.password_reset` 是内部消费事件,不作为普通 WS 业务事件下发。 + +### 2.2 群维度事件 + +来自群业务 outbox,投递到群 Topic,在线群成员收到。 + +```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" + } +} +``` + +当前群事件投递时,还会给除操作人外的群成员写入一条 `im_system_notifications`,标题为“群聊动态”,summary 暂为事件类型。 + +## 3. 好友和关系事件 + +| 触发点 | 接收方 | WS 事件 | payload | 系统通知 | +| --- | --- | --- | --- | --- | +| 创建好友申请 `POST /v1/im/friend-requests` | 被申请人 | `info.what="im"`, `event="friend.request.created"` | `{"user_id":"申请人"}` | 有,写给被申请人,标题“新的好友申请” | +| 同意好友申请 `POST /v1/im/friend-requests/{id}/accept` | 申请人 | `info.what="im"`, `event="friend.request.accepted"` | `{"user_id":"通过人"}` | 当前有,写给申请人,标题“好友申请已处理” | +| 同意好友申请 | 通过人本人 | `info.what="im"`, `event="friend.request.accepted"` | `{"user_id":"通过人"}` | 当前无 | +| 拒绝好友申请 `POST /v1/im/friend-requests/{id}/reject` | 申请人 | `info.what="im"`, `event="friend.request.rejected"` | `{"user_id":"拒绝人"}` | 当前有,写给申请人,标题“好友申请已处理” | +| 拒绝好友申请 | 拒绝人本人 | `info.what="im"`, `event="friend.request.rejected"` | `{"user_id":"拒绝人"}` | 当前无 | +| 删除好友 `DELETE /v1/im/friends/{user_id}` | 被删除方 | `info.what="im"`, `event="friend.deleted"` | `{"user_id":"操作人"}` | 有,标题“好友关系已解除” | +| 删除好友 | 操作人本人 | `info.what="im"`, `event="friend.deleted"` | `{"user_id":"操作人"}` | 当前无 | +| 修改好友备注 `PATCH /v1/im/friends/{user_id}` | 操作人本人 | `info.what="im"`, `event="friend.remark.updated"` | `{"user_id":"操作人"}` | 当前无 | +| 拉黑用户 `PUT /v1/im/blocks/{user_id}` | 操作人本人 | `info.what="im"`, `event="relationship.blocked"` | `{"user_id":"操作人"}` | 当前无 | +| 取消拉黑 `DELETE /v1/im/blocks/{user_id}` | 操作人本人 | `info.what="im"`, `event="relationship.unblocked"` | `{"user_id":"操作人"}` | 当前无 | + +前端建议动作: + +- 收到 `friend.request.created`:刷新新朋友列表 `GET /v1/im/friend-requests`,刷新 `GET /v1/im/bootstrap` 的 `entry_badges.new_friends_pending_count`。 +- 收到 `friend.request.accepted/rejected`:刷新新朋友列表;accepted 时刷新好友列表、P2P 会话 context。 +- 收到 `friend.deleted/relationship.blocked/relationship.unblocked`:刷新好友列表、黑名单列表、会话 context,重新判断输入区 `can_send`。 +- 收到 `friend.remark.updated`:刷新好友列表和会话头显示名。 + +当前待确认点: + +- 好友申请通过/拒绝、删除好友等操作人本人是否也要写入系统消息列表。当前代码只给对方或申请人写系统通知,操作人本人主要靠 WS 业务事件刷新。 +- 对接文档现有示例写成 `sys` topic `{data}` 的 `system_event`,但当前代码实际实时推送为 `{info what:"im"}`。后续需要统一口径。 + +## 4. 消息编辑和撤回事件 + +| 触发点 | 接收方 | WS 事件 | payload | 系统通知 | +| --- | --- | --- | --- | --- | +| 撤回消息 `POST /v1/im/messages/{topic}/{seq}/recall` | Topic 在线成员 | `info.what="im"`, `event="message.recalled"` | `{"topic":"...","seq":1,"version":2}` | 无 | +| 编辑消息 `POST /v1/im/messages/{topic}/{seq}/revisions` | Topic 在线成员 | `info.what="im"`, `event="message.edited"` | `{"topic":"...","seq":1,"version":2}` | 无 | + +前端建议动作: + +- 收到 `message.recalled`:按 `topic + seq + version` 重新拉取或本地标记该消息为已撤回。 +- 收到 `message.edited`:按 `topic + seq + version` 重新拉取消息详情或修订记录,更新气泡内容。 + +注意: + +- 这两个事件当前 target_type 是 `topic`,投递到 Topic,而不是某一个用户。 +- 数据库层和 HTTP 层都可能写入同类 outbox,依赖 `event_id` 幂等避免重复。 + +## 5. 群业务事件 + +| 触发点 | 接收方 | WS 事件 | payload 主要字段 | 系统通知 | +| --- | --- | --- | --- | --- | +| 创建群 | 群成员 | `info.what="group"`, `event="group.created"` | `topic`、群基础信息 | 除操作人外写入 | +| 更新群设置 | 群成员 | `event="group.settings.updated"` | `topic` | 除操作人外写入 | +| 成员资料/权限更新 | 群成员 | `event="group.member.updated"` | `user_id` | 除操作人外写入 | +| 成员加入 | 群成员 | `event="group.member.joined"` | `user_id`、`topic` | 除操作人外写入 | +| 入群申请创建 | 群成员/管理员可见链路 | `event="group.join_request.created"` | `request_id`、`applicant_user_id`、`topic` | 除操作人外写入 | +| 入群申请处理 | 群成员 | `event="group.join_request.accepted/rejected"` | `request_id`、`status`、`topic` | 除操作人外写入 | +| 群公告更新 | 群成员 | `event="group.announcement.updated"` | `topic`、公告信息 | 除操作人外写入 | +| 群禁言更新 | 群成员 | `event="group.mute.updated"` | `topic`、禁言信息 | 除操作人外写入 | +| 成员离群 | 群成员 | `event="group.member.left"` | `user_id` | 除操作人外写入 | +| 群角色更新 | 群成员 | `event="group.role.updated"` | `user_id`、角色信息 | 除操作人外写入 | +| 群主转让 | 群成员 | `event="group.owner.transferred"` | `previous_owner`、`new_owner` | 除操作人外写入 | + +前端建议动作: + +- 群成员、角色、禁言、群主变化:刷新群详情、成员列表、当前用户群权限。 +- 入群申请变化:管理员刷新入群申请列表和群 summary 待处理数。 +- 群公告变化:刷新公告区。 +- 成员加入/离开:刷新群成员数、成员列表和会话系统提示。 + +注意: + +- 当前群事件实时帧 `what="group"`,不是 `what="im"`。 +- 群系统通知的 summary 当前主要是事件类型,文案还比较粗,需要产品确认后优化。 + +## 6. 会议事件 + +| 触发点 | 接收方 | WS 事件 | payload | 系统通知 | +| --- | --- | --- | --- | --- | +| 创建会议 | 所有参会人 | `info.what="im"`, `event="meeting.created"` | `{"meeting_id":"...","version":1,"state":"scheduled"}` | 除操作者外写入,标题“会议通知” | +| 更新会议 | 所有参会人 | `event="meeting.updated"` | `meeting_id/version/state` | 除操作者外写入 | +| 取消会议 | 所有参会人 | `event="meeting.canceled"` | `meeting_id/version/state` | 除操作者外写入 | +| 发送会议提醒 | 待响应参会人 | `event="meeting.reminded"` | `meeting_id/version/state` | 除操作者外写入 | +| 更新参会响应 | 所有参会人 | `event="meeting.response.updated"` | `meeting_id/version/state` | 除操作者外写入 | + +前端建议动作: + +- 收到会议事件后刷新会议详情或会议列表。 +- `meeting.reminded` 可触发本地提醒 UI。 + +## 7. 安全事件 + +| 触发点 | 接收方 | WS 事件 | payload | 系统通知 | +| --- | --- | --- | --- | --- | +| 风险挑战确认/取消 | 操作人本人 | `info.what="im"`, `event="security.risk.continue"` 或 `security.risk.cancel` | `{"risk_challenge_id":"...","decision":"continue|cancel"}` | 有,标题“安全操作确认” | +| 密码重置成功 | 无普通 WS 事件 | 内部事件 `security.password_reset` | `{"user_id":123}` | 当前用于清退该用户在线 session | + +前端建议动作: + +- 收到 `security.risk.*`:刷新安全验证页面状态。 +- 密码重置后旧 token/session 会被服务端清退,客户端按登录失效流程处理。 + +## 8. 邀请码统计事件 + +| 触发点 | 接收方 | WS 事件 | payload | 系统通知 | +| --- | --- | --- | --- | --- | +| 注册成功使用邀请码 | 不下发给普通客户端 | 内部事件 `registration.invite_used` | `{"tenant_id":1,"invite_code_id":123,"user_id":456}` | 无 | + +说明: + +- 该事件当前由 IM 侧消费,用于把租户库 `im_registration_invite_uses` 聚合回 HotGo 控制库的邀请码统计。 +- 普通 App 前端不需要监听。 + +## 9. Tinode 原生实时下行 + +这类不是产品 outbox 事件,但也是服务端主动下行,前端需要持续处理。 + +| 帧类型 | 触发点 | 接收方 | 前端动作 | +| --- | --- | --- | --- | +| `{data}` | 普通聊天消息、文件消息、通话邀请 `webrtc=started`、服务端替换通话结果消息 | Topic 订阅者 | 写入/更新会话时间线、更新未读 | +| `{info what:"read"}` | 对端已读回执 | Topic 订阅者 | 更新消息已读状态 | +| `{info what:"recv"}` | 对端送达回执 | Topic 订阅者 | 更新送达状态 | +| `{info what:"kp"}` | 对端输入中 | Topic 订阅者 | 显示输入中状态,超时自动消失 | +| `{info what:"call"}` | WebRTC `ringing/accept/offer/answer/ice-candidate/hang-up` | 通话对端 | 驱动通话状态机和 SDP/ICE 交换 | +| `{pres}` | 订阅、离线、在线、权限/订阅变化等 presence | 相关订阅者 | 更新在线状态、订阅状态、必要时刷新列表 | +| `{meta}` | 服务端响应 get/sub 中的 desc/sub/data/del/cred 等查询结果 | 请求方 | 合并服务端状态 | +| `{ctrl}` | 客户端请求结果 | 请求方 | 更新请求状态、错误提示、幂等结果 | + +## 10. 当前文档与实现差异 + +当前《IM-前后端对接文档》部分位置把好友关系变化描述为 `sys` topic 的 `{data head.x-im-type="system_event"}`。代码当前更准确的模型是: + +- 实时刷新事件:`{info what:"im"}` 或 `{info what:"group"}`。 +- 系统通知列表:通过 `im_system_notifications` 暴露给 `GET /v1/im/system-notifications`,并不是所有实时事件都会写入。 +- 聊天/系统 Topic `{data}` 仍用于普通消息时间线和 Tinode 原生 Topic 消息,不等同于业务 outbox 实时事件。 + +建议后续合并到对接文档时明确: + +1. 前端必须监听所有 `{info}`,按 `what + event` 分发业务刷新。 +2. 系统消息入口的未读数来自 `im_system_notifications`,不是 `{info}` 事件数量。 +3. 好友申请通过后是否双方都写系统通知,需要产品确认;若确认双方都有,需要同步补代码。 diff --git a/docs/IM-未实现后续增强项.md b/docs/IM-未实现后续增强项.md index 576dcc9..89f8291 100644 --- a/docs/IM-未实现后续增强项.md +++ b/docs/IM-未实现后续增强项.md @@ -1,6 +1,6 @@ # IM 第一至十一章后续增强项 -> 更新日期:2026-08-21 +> 更新日期:2026-08-25 > 核对依据:`docs/IM-前后端对接文档.md`、`server/main.go` 当前 `/v1/im/*` 路由、`server/im_http.go` 当前 handler。 > 范围:第一章登录注册与安全、第二章消息首页/导航/系统通知、第三章单聊关系、第四章消息输入/转发/检索、第五章通讯录/好友/黑名单、第六章群聊创建/资料/治理、第七章文件/收藏/聊天记录检索、第八章实时语音与视频通话、第九章企业工作台与组织架构、第十章个人设置与客户端能力、第十一章反诈安全/举报/反馈。 > 用途:记录当前代码基线之外仍需排期的增强项。已经落地的 P0/P1 不再作为待办保留;已经抽入专项开发文档的项目,在本文中仅保留来源和现状说明,不再作为未分流待办处理。 @@ -14,42 +14,38 @@ 3. `GET /v1/im/forward-targets` 已新增,返回文件助手和可转发 P2P/群会话目标。 4. 总经理信箱配置与提交的 500 已修复,`GET /v1/im/executive-mailbox/config`、`POST /v1/im/executive-mailbox/submissions` 已可联调。 5. 举报原因 500 已修复,`GET /v1/im/reports/abuse/reasons` 已按 `items` 返回。 -6. `GET /v1/im/groups/{topic}/files` 和 `/announcements` 不再误落到群详情;未实现路径返回 404。 +6. 群文件和群公告路径已从群详情兜底中拆出,群文件基础接口已落地,未实现的子路径会返回明确错误。 7. 注册数据模型已改为 `im_users.username`、`im_users.phone`、`im_user_profiles.email`,不再依赖用户 `tags` 保存手机号/邮箱。 8. 邀请码主数据在 HotGo 库,注册使用明细在 IM 租户库;邀请码可多人复用,不再“核销”。 9. 用户搜索、用户主页、用户二维码已支持 `user_id/username/public_account` 作为 identifier。 +10. 第一批增强中的群文件列表/登记/删除、群邀请外部接受、实名更正申诉、组织全局成员接口、月报 reopen 已落地。 +11. 第一批增强中的主动创建/获取 P2P 会话、会话设置、多端云草稿已落地。 ### 0.2 当前仍明确缺口及分流状态 | 优先级 | 缺口 | 对接文档位置 | 当前代码状态 | 分流状态 | | --- | --- | --- | --- | --- | | P0 | 真实短信供应商发送、失败重试、发送审计 | 1.4、1.7、1.10 | 当前测试码 `123456` 可联调,但生产短信未完成 | 依赖第三方,暂留后续增强池 | -| P0 | 群文件完整接口 | 6.27、6.28 | `/groups/{topic}/files` 已不误兜底,但列表/登记/删除未实现 | 已抽入 `IM-增强项第一批开发.md` | | P0 | 消息转发落地接口 | 4.18、4.19、4.20 | 仅 `forward-targets` 已实现,`POST /v1/im/messages/forward`、`GET /v1/im/forward-bundles/{id}` 未实现 | 已抽入 `IM-增强项第一批开发.md` | | P0 | 登出与会话撤销 | 10.7 | `POST /v1/im/auth/logout`、`DELETE /v1/im/security/sessions/{id}` 未见路由 | 已抽入 `IM-增强项第一批开发.md` | -| P1 | 单聊主动建会话/会话设置/云草稿 | 3.2、4.5、4.21 | `GET /conversations/{topic}/context` 已有,`POST /conversations/p2p`、`/settings`、`PUT /topics/{topic}/draft` 未见路由 | 已抽入 `IM-增强项第一批开发.md` | -| P1 | 群邀请外部接受接口 | 6.8 | 群内 `/groups/{topic}/invitations` 已有,`POST /v1/im/group-invitations/{id}/accept` 未见路由 | 已抽入 `IM-增强项第一批开发.md` | -| P1 | 实名更正申诉 | 1.9、10.22 | `POST /v1/im/security/realname/appeals` 未见路由 | 已抽入 `IM-增强项第一批开发.md` | -| P1 | 组织全局成员接口 | 5.1、9.2 | 部门成员与组织搜索已具备,`GET /v1/im/organization/members` 未见路由 | 已抽入 `IM-增强项第一批开发.md` | -| P1 | 月报 reopen | 9.10 | 月报草稿/提交/详情已具备,`POST /reports/monthly/{id}/reopen` 未见路由 | 已抽入 `IM-增强项第一批开发.md` | ### 0.3 已抽入第一批开发的项目 -以下项目已经从本文档的普通后续增强池中抽出,形成专项开发文档 `docs/IM-增强项第一批开发.md`。它们当前代码上仍未完全落地,但不再按“未分流的未实现项”处理,后续应以第一批开发文档作为实现、验收和同步更新依据。 +以下项目已经从本文档的普通后续增强池中抽出,形成专项开发文档 `docs/IM-增强项第一批开发.md`。其中已落地项已移入各章“当前已完成基线”;仍未完全落地的项目继续以第一批开发文档作为实现、验收和同步更新依据。 -1. 登出与指定会话撤销。 -2. 普通消息转发。 -3. 合并转发 Bundle 详情。 -4. 群文件列表、登记和删除。 -5. 主动创建/获取 P2P 会话。 -6. 会话设置。 -7. 多端云草稿。 -8. 群邀请外部接受。 -9. 实名更正申诉。 -10. 组织全局成员接口。 -11. 月报 reopen。 -12. 列表 `has_more` 统一。 -13. 风控 token 单次消费接入业务接口。 +1. 仍待落地:登出与指定会话撤销。 +2. 仍待落地:普通消息转发。 +3. 仍待落地:合并转发 Bundle 详情。 +4. 已落地:群文件列表、登记和删除。 +5. 已落地:主动创建/获取 P2P 会话。 +6. 已落地:会话设置。 +7. 已落地:多端云草稿。 +8. 已落地:群邀请外部接受。 +9. 已落地:实名更正申诉。 +10. 已落地:组织全局成员接口。 +11. 已落地:月报 reopen。 +12. 仍待统一回归:列表 `has_more` 统一。 +13. 仍待落地:风控 token 单次消费接入业务接口。 ## 1. 第一章:登录、注册与安全 @@ -70,6 +66,7 @@ 13. 产品 HTTP 已统一校验 API Key、Schema 和 Request ID。 14. 登录态写接口已接入租户库幂等记录,支持请求摘要冲突检测和首次响应重放。 15. 风险确认可生成安全事件和系统通知。 +16. 实名更正申诉 `POST /v1/im/security/realname/appeals` 已具备,申诉记录进入 pending 状态。 ### 1.2 后续增强项 @@ -78,7 +75,7 @@ | P0 | 真实短信供应商发送通道 | 当前为测试码联调模式;生产必须接短信服务商、模板、签名、失败重试和发送审计。 | 真实手机号收到验证码;供应商失败返回稳定 JSON,告警可观测。 | | P0 | 验证码多维限频 | 需要按租户、手机号、IP、设备、场景组合限频,并接 Redis 或落库。 | 高频请求返回 `VERIFICATION_RATE_LIMITED`,不同场景互不误伤。 | | P0 | 登出与会话撤销 | 对接文档已有 `POST /v1/im/auth/logout` 和 `DELETE /v1/im/security/sessions/{id}`,代码未见路由。 | 当前设备退出不影响其他设备;删除指定 session 后该设备 token 立即失效。 | -| P1 | 实名审核与申诉 | 当前支持提交和状态查询,后续需要后台审核、驳回原因、通过状态变更和 `realname/appeals`。 | 审核通过后 `realname.status=verified`;驳回可再次提交;申诉有工单状态。 | +| P1 | 实名后台审核流转 | 当前支持实名提交、状态查询和申诉提交,后续需要后台审核、驳回原因、通过状态变更和申诉处理工单。 | 审核通过后 `realname.status=verified`;驳回可再次提交;申诉有处理状态。 | | P1 | 安全事件通知覆盖扩展 | 风险确认已接入通知;密码重置、实名审核、独立密码变更、新设备登录仍需补齐。 | `/v1/im/system-notifications` 可看到对应事件且重试不重复入箱。 | | P1 | API Key 轮换策略 | 当前 API Key 校验兼容现有逻辑,尚未实现最低可接受序号或撤销表。 | 旧序号 Key 可按配置禁用,新 Key 不影响正常客户端。 | | P2 | 风控与反诈策略接入登录注册 | 安全动作后续应接入风险评分、反诈弹窗和二次确认策略。 | 高风险注册/登录/改密触发二次验证或反诈提示。 | @@ -103,12 +100,15 @@ | 优先级 | 增强项 | 说明 | 建议验收点 | | --- | --- | --- | --- | | P0 | 消息变更查询占位策略 | 已撤回消息在普通拉取中应返回撤回占位,而不是仅靠客户端本地事件。 | 新设备登录拉历史时能看到撤回占位和版本。 | +| P0 | 万人大群离线提醒与重连补偿 | 离线只走极光 Push 提醒,客户端重连后按 Topic seq 拉取补偿。 | 离线用户收到极光提醒;重连后按 seq 补齐且不丢不重。 | | P0 | 系统通知未读聚合实时化 | `bootstrap.entry_badges.system_unread_count` 已有,后续需确认真源与增量事件。 | 读通知后 bootstrap 和 WS badge 均一致。 | | P1 | 列表 `has_more` 统一 | 部分接口仍只返回 `items,next_cursor`,对接文档要求可统一补 `has_more`。 | 前端所有列表分页逻辑可复用同一协议。 | | P1 | 群回执隐私和角色策略 | 当前返回基础 read/recv 列表,后续需按租户策略、群规模、角色裁剪。 | 普通成员/管理员在不同策略下看到不同粒度回执。 | | P1 | `message_policy` 租户配置化 | 当前 recall/edit 窗口和菜单能力是固定值,后续需要来自租户配置。 | 不同租户可配置不同撤回/编辑时限和菜单能力。 | | P1 | 系统通知 action 白名单 | `action.type` 和参数需要统一注册、校验和版本化。 | 非白名单 action 写入失败;客户端只收到可识别跳转。 | | P1 | 会话搜索排序与类型过滤 | 当前按订阅数据过滤,后续需支持会话类型、置顶、免打扰、最近活跃排序。 | 搜索结果与消息首页排序、筛选规则一致。 | +| P1 | 群成员分页快照深度优化 | 当前已接入 Redis 短 TTL 响应快照,后续大群需要 sorted set、稳定游标和成员变更主动失效。 | 万人大群成员翻页不重复不漏,角色/昵称/退群变更后缓存能及时失效。 | +| P1 | 未读数 Redis 深度优化 | 当前已接入用户总未读 Redis 热缓存,后续需要 Topic 维度未读计数、bootstrap 聚合缓存和主动失效事件。 | 首页红点、会话未读和多端 read/recv 后的未读数实时一致。 | | P2 | 离线重连恢复编排测试 | 需要端到端覆盖 `hi -> token login -> sub me -> 恢复 Topic -> get data`。 | 断网重连后未丢消息、未重复消息、未错乱回执。 | ## 3. 第三章:单聊关系、权限与会话上下文 @@ -121,15 +121,16 @@ 4. 拉黑/解除拉黑后关系上下文即时反映 `blocked_by_me`、`blocked_by_peer` 等状态。 5. 好友申请、通过/拒绝、删除、拉黑/解除和备注已生成稳定关系事件,相关对端通知可去重。 6. 非好友资料查询已执行 `profile_visibility`。 +7. 主动创建/获取 P2P 会话 `POST /v1/im/conversations/p2p` 已具备。 +8. 会话设置 `GET/PATCH /v1/im/conversations/{topic}/settings` 已具备。 +9. 多端云草稿 `GET/PUT /v1/im/topics/{topic}/draft` 已具备。 ### 3.2 后续增强项 | 优先级 | 增强项 | 说明 | 建议验收点 | | --- | --- | --- | --- | | P0 | P2P 策略统一入口 | 当前核心路径已校验好友/黑名单,后续需要把发消息、通话、名片、文件、历史查看统一收敛到同一策略服务。 | 任一 P2P 业务入口对同一关系状态返回一致权限。 | -| P1 | 主动建立 P2P 会话接口 | 对接文档有 `POST /v1/im/conversations/p2p`,当前未见路由。 | 好友关系成立时可显式获取/创建 P2P topic。 | -| P1 | 会话设置接口 | 对接文档有 `GET/PATCH /v1/im/conversations/{topic}/settings`,当前未见路由。 | 置顶、免打扰、备注、保留策略跨端同步。 | -| P1 | 多端草稿 | 对接文档有 `PUT /v1/im/topics/{topic}/draft`,当前仍建议本地加密保存。 | 同账号多端草稿同步,版本冲突可处理。 | +| P1 | 会话策略与草稿联调回归 | P2P 会话、会话设置和云草稿接口已具备,仍需覆盖多端、冲突、非好友/黑名单状态下的联调回归。 | 多端草稿同步、版本冲突和权限拒绝都符合协议。 | | P1 | Pending 会话体验增强 | 待处理好友申请对应单聊页应携带申请摘要、来源、过期时间和快速处理入口。 | 进入 pending 会话可直接通过/拒绝。 | | P1 | 黑名单历史消息策略 | 需明确拉黑后历史消息、资料、文件和已读回执可见范围。 | 被拉黑人在不同策略下看到一致的历史与资料裁剪结果。 | | P1 | 好友关系审计 | 删除、拉黑、解除拉黑、申请处理等敏感关系操作写审计日志。 | 后台可按租户、操作者、目标用户查询操作流水。 | @@ -199,14 +200,15 @@ 8. 禁言候选、批量禁言、禁言列表和解除禁言接口已具备。 9. 群写操作已使用 `im_group_idempotency` 和 `im_group_events_outbox`,提交后通过 Hub 派发,失败保留 pending 重试。 10. WebSocket 群消息发布已校验成员禁言和全员禁言,支持过期恢复及管理员豁免。 -11. 群文件和群公告未实现路径已不再误返回群详情。 +11. 群文件列表、登记和删除接口 `GET/POST/DELETE /v1/im/groups/{topic}/files` 已具备。 +12. 群邀请外部接受 `POST /v1/im/group-invitations/{id}/accept` 已具备。 ### 6.2 后续增强项 | 优先级 | 增强项 | 说明 | 建议验收点 | | --- | --- | --- | --- | -| P0 | 群文件索引与管理 | 对接文档要求 `GET/POST/DELETE /v1/im/groups/{topic}/files`,当前未实现,只修复了错误兜底。 | 群文件列表可搜索/分页,上传登记和删除权限正确,退群后不可下载旧 URL。 | -| P1 | 群邀请外部接受接口 | 对接文档有 `POST /v1/im/group-invitations/{id}/accept`,当前未见路由。 | 被邀请人可通过邀请卡入群或进入审批。 | +| P0 | 群文件权限与下载闭环 | 群文件基础接口已具备,后续需与文件 ACL、退群后下载阻断、审核状态、OSS 短链失效联动。 | 群文件列表可搜索/分页,上传登记和删除权限正确,退群后不可下载旧 URL。 | +| P1 | 群邀请外部接受体验回归 | 外部接受接口已具备,仍需覆盖邀请卡、多端重复接受、过期和审批模式切换。 | 被邀请人可通过邀请卡入群或进入审批;重复接受幂等返回稳定结果。 | | P1 | 进群申请隐私与限频 | 申请入口需要按群治理、租户策略、用户状态、来源、IP/设备限频。 | 高频申请被限制;普通成员不可看到敏感申请人信息。 | | P1 | 管理员/群主角色层级策略 | 管理员互相操作、管理员禁言、群主转让二次认证等需要完整策略矩阵。 | 群主、管理员、普通成员在每个治理动作上的权限稳定一致。 | | P1 | 群解散与二维码吊销 | 群解散、二维码撤销、邀请失效需要状态联动和权限回收。 | 解散后 Topic、二维码、邀请、文件新增权限全部失效。 | @@ -275,6 +277,8 @@ 8. 日报/月报草稿、提交、详情、列表、统计与提醒基线已具备。 9. 总经理信箱配置与提交接口已具备,包含匿名策略、文案版本和幂等键。 10. 会议创建、变更、取消、提醒和参会回应已生成在线事件与系统通知,关键变更在事务内写 Outbox。 +11. 组织全局成员接口 `GET /v1/im/organization/members` 已具备。 +12. 月报 reopen `POST /v1/im/reports/monthly/{id}/reopen` 已具备。 ### 9.2 后续增强项 @@ -284,9 +288,9 @@ | P0 | 工作台未读角标实时事件 | `workbench_total_unread` 已有,后续需要模块未读聚合和 `workbench.badge.updated` 增量事件。 | 会议、日报、信箱未读变化后工作台角标实时更新。 | | P0 | 组织数据后台维护链路 | 当前前台读接口和表已具备,后续需要后台导入、增删改部门、调整成员和离职状态。 | 后台修改组织后前台接口版本变化,客户端可刷新。 | | P0 | 组织权限与可见字段裁剪 | 成员手机号、邮箱、职位等字段需要按组织角色、隐私和企业策略裁剪。 | 普通员工和管理员看到的成员字段不同且不可绕过。 | -| P1 | 组织全局成员接口 | 对接文档提到 `GET /v1/im/organization/members`,当前可由部门成员和组织搜索替代,未见独立路由。 | 前端如需要全局成员列表,可直接分页查询并按部门过滤。 | +| P1 | 组织全局成员接口深度联调 | `GET /v1/im/organization/members` 已具备,仍需按部门、关键词、权限和分页做测试环境回归。 | 前端可直接分页查询并按部门过滤,字段裁剪符合权限。 | | P1 | 会议列表完整联调 | 对接文档标注 `GET /v1/im/meetings?cursor=` 待补列表;代码有 `serveIMMeetings`,仍需用测试数据确认返回结构。 | 会议列表、详情、响应、取消端到端一致。 | -| P1 | 月报 reopen | 对接文档有 `POST /v1/im/reports/monthly/{id}/reopen`,当前未见路由。 | 已提交月报可按权限退回草稿并保留审计。 | +| P1 | 月报 reopen 审计与权限扩展 | reopen 接口已具备,后续需要后台权限、原因记录、审计和通知闭环。 | 已提交月报可按权限退回草稿并保留审计。 | | P1 | 日报/月报后台统计口径 | 当前基础统计可用,后续需要按部门、周期、迟交、补交、未读等维度扩展。 | 管理员看板与业务口径一致。 | | P1 | 总经理信箱后台流转 | 当前前台配置/提交已具备,后续需要后台处理、回复、转派、归档和通知。 | 信件全生命周期可追踪,匿名身份受保护。 | | P1 | 办公协同审计 | 组织查询、会议变更、报告查看、信箱提交等敏感动作需要审计。 | 后台可按租户、用户、对象查询办公操作流水。 | @@ -341,7 +345,9 @@ ## 12. 统一处理建议 -1. 第一优先级处理生产可用性 P0:真实短信、登出/会话撤销、群文件、普通消息转发/合并转发、文件安全、通话强状态机、工作台权限、风控 token 消费。 -2. 第二优先级补齐前端文档缺口:`conversations/p2p`、会话设置、云草稿、群邀请外部接受、实名申诉、组织全局成员、月报 reopen。 +1. 第一优先级处理生产可用性 P0:真实短信、登出/会话撤销、普通消息转发/合并转发、文件安全、通话强状态机、工作台权限、风控 token 消费。 +2. 第二优先级补齐已落地接口的联调闭环:P2P 会话、会话设置、云草稿、群文件、群邀请外部接受、实名申诉、组织全局成员、月报 reopen。 3. 第三优先级统一协议和治理能力:所有列表补齐 `has_more`、稳定游标、审计流水、字段级隐私矩阵、租户配置化策略。 -4. 每个增强项落地后同步更新本文档、`IM-系统设计文档.md`、`IM-前后端对接文档.md` 和接口审核报告,避免已完成项继续出现在待办清单。 +4. 万人大群未实现增强项仅保留离线能力:离线只走极光 Push 提醒,客户端重连后按 Topic seq 拉取补偿;实时推送能力归入 `IM-Redis三层数据分层改造方案.md` 后续直接实现。 +5. Redis 读性能深度优化继续保留:群成员分页 sorted set/稳定游标/主动失效、Topic 维度未读计数、bootstrap 聚合缓存和主动失效事件。 +6. 每个增强项落地后同步更新本文档、`IM-系统设计文档.md`、`IM-前后端对接文档.md` 和接口审核报告,避免已完成项继续出现在待办清单。 diff --git a/docs/enterprize/1.登录页面.png b/docs/enterprize/1.登录页面.png new file mode 100644 index 0000000..a96f4f5 Binary files /dev/null and b/docs/enterprize/1.登录页面.png differ diff --git a/docs/enterprize/2.填写注册信息.png b/docs/enterprize/2.填写注册信息.png new file mode 100644 index 0000000..e55e512 Binary files /dev/null and b/docs/enterprize/2.填写注册信息.png differ diff --git a/docs/enterprize/3.填写成功提示.png b/docs/enterprize/3.填写成功提示.png new file mode 100644 index 0000000..cd00a14 Binary files /dev/null and b/docs/enterprize/3.填写成功提示.png differ diff --git a/docs/enterprize/4.填写问题反馈.png b/docs/enterprize/4.填写问题反馈.png new file mode 100644 index 0000000..5acfcc4 Binary files /dev/null and b/docs/enterprize/4.填写问题反馈.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/sql/IM-SeqId-BIGINT与Redis缓存改造SQL.sql b/docs/sql/IM-SeqId-BIGINT与Redis缓存改造SQL.sql new file mode 100644 index 0000000..9fe8564 --- /dev/null +++ b/docs/sql/IM-SeqId-BIGINT与Redis缓存改造SQL.sql @@ -0,0 +1,12 @@ +-- IM SeqId BIGINT upgrade for existing MySQL tenant databases. +-- New schemas have been updated in server/db/mysql/adapter.go and group_schema.go. + +ALTER TABLE im_topics MODIFY seqid BIGINT NOT NULL DEFAULT 0; +ALTER TABLE im_subscriptions MODIFY recvseqid BIGINT DEFAULT 0; +ALTER TABLE im_subscriptions MODIFY readseqid BIGINT DEFAULT 0; +ALTER TABLE im_messages MODIFY seqid BIGINT NOT NULL; +ALTER TABLE im_message_revisions MODIFY seqid BIGINT NOT NULL; +ALTER TABLE im_favorites MODIFY source_seq BIGINT NOT NULL; +ALTER TABLE im_abuse_reports MODIFY target_seq BIGINT NOT NULL DEFAULT 0; +ALTER TABLE im_call_records MODIFY seq_id BIGINT NOT NULL; +ALTER TABLE im_group_files MODIFY source_seq BIGINT NOT NULL DEFAULT 0; diff --git a/docs/sql/IM-群消息拆表SQL.sql b/docs/sql/IM-群消息拆表SQL.sql new file mode 100644 index 0000000..cc100a8 --- /dev/null +++ b/docs/sql/IM-群消息拆表SQL.sql @@ -0,0 +1,37 @@ +CREATE TABLE IF NOT EXISTS im_group_messages ( + id BIGINT NOT NULL AUTO_INCREMENT, + topic VARCHAR(64) 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) NULL COMMENT '客户端幂等消息 ID', + msg_type VARCHAR(32) NOT NULL DEFAULT 'text' COMMENT 'text/image/file/audio/video/system 等', + status VARCHAR(32) NOT NULL DEFAULT 'normal' COMMENT 'normal/recalled/deleted/blocked', + content JSON NULL COMMENT '消息正文或 Drafty 内容', + head JSON NULL COMMENT '消息头,包含 @、引用、客户端能力等', + mentioned_user_ids JSON NULL COMMENT '@ 用户列表,便于提醒和检索', + mention_all TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否 @ 全体', + reply_seqid BIGINT NULL COMMENT '回复的群消息 seq', + root_seqid BIGINT 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) NOT NULL DEFAULT 'pass' COMMENT 'pass/pending/reject', + version INT NOT NULL DEFAULT 1 COMMENT '编辑版本', + recalled_at DATETIME(3) NULL, + recalled_by BIGINT NULL, + deleted_at DATETIME(3) NULL, + expires_at DATETIME(3) 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; diff --git a/docs/test/im_third_party_file_safety_test_results_2026-08-24.md b/docs/test/im_third_party_file_safety_test_results_2026-08-24.md new file mode 100644 index 0000000..2913955 --- /dev/null +++ b/docs/test/im_third_party_file_safety_test_results_2026-08-24.md @@ -0,0 +1,110 @@ +# IM 第三方接入本地测试结果 + +测试日期:2026-08-24 + +测试环境:本地环境,MySQL `127.0.0.1:3306`,控制库 `hotgo`,业务库 `tinode`。 + +## 1. 测试结论 + +| 项 | 结果 | 说明 | +| --- | --- | --- | +| 第三方相关普通单元测试 | 通过 | OSS、文件安全配置、阿里云内容安全封装测试通过 | +| MySQL build tag 单元测试 | 通过 | 文件审核表结构、MySQL 适配相关测试通过 | +| 本地 API handler 测试 | 通过 | 群文件、群邀请接受、实名申诉、组织成员、月报 reopen、反馈/举报、信箱、会话设置、云草稿等接口级测试通过 | +| 本地 MySQL 连通性 | 通过 | 可连接 `hotgo` 与 `tinode` | +| 第三方配置读取 | 通过 | `im_third_party` 配置存在 18 条,已按本地第三方依赖配置启用 OSS、内容审核和恶意文件检测 | +| 文件审核字典 | 通过 | provider 2 条、task type 2 条、status 5 条 | +| 文件审核任务表 | 通过 | `tinode.im_file_review_tasks` 存在,且不包含 `tenant_id` | +| 真实 OSS 上传/下载 | 通过 | 使用普通 Bucket endpoint 完成 put/get/sign URL,并清理测试对象 | +| 真实内容安全 | 通过 | 使用阿里云内容安全增强版 `FileModeration + document_detection` 提交并轮询,返回 `clean/none` | +| 真实恶意文件检测 | 阻塞 | SDK 请求 `tds.aliyuncs.com` 时本地网络连接被 reset,`curl` 直连同样失败 | + +## 2. 执行命令 + +```bash +go test -count=1 ./server/media/oss ./server/filesafety ./server/filesafety/aliyun ./server/db/mysql +go test -count=1 -tags mysql ./server/media/oss ./server/filesafety ./server/filesafety/aliyun ./server/db/mysql +go test -count=1 ./server -run 'TestServeIM(GroupFiles|GroupInvitationAccept|RealNameAppeal|OrganizationMembers|MonthlyReportReopen|DailyReport|MonthlyReport|Mailbox|Feedback|Abuse|ConversationSettings|TopicDraft|P2PConversation|ForwardTargets)' +``` + +## 3. 命令结果 + +| 命令 | 结果 | +| --- | --- | +| `go test -count=1 ./server/media/oss ./server/filesafety ./server/filesafety/aliyun ./server/db/mysql` | 通过 | +| `go test -count=1 -tags mysql ./server/media/oss ./server/filesafety ./server/filesafety/aliyun ./server/db/mysql` | 通过 | +| `go test -count=1 ./server -run ...` | 通过 | + +## 4. 本地数据库核验 + +| 核验项 | 结果 | +| --- | --- | +| MySQL ping | 通过 | +| `hotgo` 数据库 | 存在 | +| `tinode` 数据库 | 存在 | +| `hotgo.hg_sys_config` 中 `im_third_party` 配置数量 | 18 | +| `oss_enabled` | 1 | +| `file_review_content_enabled` | 1 | +| `file_review_malware_enabled` | 1 | +| OSS 敏感配置 | 已配置,报告不输出具体值 | +| 阿里云审核敏感配置 | 已配置,报告不输出具体值 | +| `im_file_review_provider` 字典 | 2 条 | +| `im_file_review_task_type` 字典 | 2 条 | +| `im_file_review_status` 字典 | 5 条 | +| `tinode.im_file_review_tasks` | 存在 | +| `im_file_review_tasks.tenant_id` | 不存在,符合租户分库方案 | +| `im_file_review_tasks` 当前记录数 | 0 | + +## 5. 覆盖情况 + +| 测试范围 | 本次覆盖 | 结果 | +| --- | --- | --- | +| OSS 配置读取与对象路径规则 | 已覆盖单元测试 | 通过 | +| `im_third_party` 配置读取 | 已覆盖单元测试和本地库只读核验 | 通过 | +| `im_file_review_tasks` 表结构 | 已覆盖 MySQL tag 测试和本地库只读核验 | 通过 | +| 上传完成创建审核任务 | 已覆盖代码路径和表结构测试;本地无真实上传任务 | 部分通过 | +| 群文件 API handler | 已覆盖本地 handler 测试 | 通过 | +| 举报/反馈/信箱附件相关 API handler | 已覆盖本地 handler 测试 | 通过 | +| 日报/月报附件相关 API handler | 已覆盖本地 handler 测试 | 通过 | +| P2P 会话、会话设置、云草稿 | 已覆盖本地 handler 测试 | 通过 | +| 真实 OSS API | 已上传、读取、生成签名 URL、清理测试对象 | 通过 | +| 真实内容安全 API | 已提交 OSS 文件审核并轮询结果 | 通过 | +| 真实恶意文件检测 API | 已使用官方 SAS SDK 提交检测,受本地网络限制未到达服务端 | 阻塞 | +| 风险审核结果影响下载/展示 | worker 和策略闭环未完成 | 未执行 | + +## 6. 真实第三方调用补测 + +补测时间:2026-08-24 + +### 6.1 补测操作 + +1. 从本地第三方依赖文档读取测试账号、OSS Bucket 和地域信息。 +2. 将本地 `hotgo.hg_sys_config` 的 `im_third_party` 配置更新为启用状态。 +3. 使用普通 Bucket endpoint `oss-cn-shenzhen.aliyuncs.com` 上传测试 TXT 文件。 +4. 对同一 OSS 对象执行读取、签名 URL 生成和测试对象清理。 +5. 使用阿里云内容安全增强版 `FileModeration + document_detection` 提交该 OSS 对象并轮询结果。 +6. 使用阿里云云安全中心官方 SAS SDK 调用 `CreateFileDetect` 和 `GetFileDetectResult`。 + +### 6.2 补测结果 + +| 项 | 结果 | 说明 | +| --- | --- | --- | +| 本地配置启用 | 通过 | `oss_enabled`、`file_review_content_enabled`、`file_review_malware_enabled` 已置为 1 | +| OSS `PutObject` | 通过 | 测试文件成功写入 OSS | +| OSS `GetObject` | 通过 | 测试文件可读取且内容一致 | +| OSS `SignURL` | 通过 | 可生成短时下载 URL | +| OSS 测试对象清理 | 通过 | `codex-live/` 测试前缀已清理 | +| 内容安全提交 | 通过 | `FileModeration` 返回任务 ID | +| 内容安全轮询 | 通过 | 轮询结果为 `status=clean,risk=none` | +| 云安全中心提交 | 阻塞 | 本地网络访问 `https://tds.aliyuncs.com` 被 reset,未到达业务响应 | + +### 6.3 SAS 阻塞说明 + +云安全中心 SDK 内置中国区 endpoint 为 `tds.aliyuncs.com`。本地环境中该域名解析到 `198.18.1.113`,无论 SDK 调用还是 `curl -I https://tds.aliyuncs.com/` 都返回 connection reset。备用 endpoint `tds.ap-southeast-1.aliyuncs.com` 在当前网络下同样 reset。因此本轮无法判断 SAS 账号权限或检测结果,只能确认本地网络到 SAS endpoint 不通。 + +## 7. 遗留问题 + +1. 本地网络无法访问云安全中心恶意文件检测 endpoint `tds.aliyuncs.com`,需要换网络、服务器环境或排查网络策略后复测。 +2. 文件安全 worker 尚未完成,pending 审核任务不会自动提交第三方、轮询或回写状态。 +3. 下载和业务展示侧尚未完整按 `im_file_review_tasks` 风险状态阻断或告警。 +4. 纯文本内容安全不属于文件审核链路,群简介、签名、举报/反馈正文、日报/月报正文仍需独立文本审核方案。 diff --git a/docs/test/im_websocket_testenv_results_2026-08-21.md b/docs/test/im_websocket_testenv_results_2026-08-21.md index bd80fa9..23466b4 100644 --- a/docs/test/im_websocket_testenv_results_2026-08-21.md +++ b/docs/test/im_websocket_testenv_results_2026-08-21.md @@ -2,46 +2,46 @@ ## 1. 测试范围 -- 测试时间:`2026-08-21 00:39:01 +0800` +- 测试时间:`2026-08-24 11:15:16 +0800` - HTTP:`https://api.118im.com` - WebSocket:`wss://api.118im.com/v0/channels` - 租户:`default` -- 用户 A:`usr7-zA3UMrw7c` / `wsa459000` / `15794459000` -- 用户 B:`usrnDXQ3A2oQP0` / `wsb459000` / `15894459000` +- 用户 A:`usrO4KoysjOOVE` / `wsa043000` / `15757043000` +- 用户 B:`usricjD1vg9CXU` / `wsb043000` / `15857043000` - API Key 与 Token 已脱敏,不在文档中展开。 ## 2. 总体结论 -本轮专项验证 Tinode WebSocket 真实协议链路,共执行 `29` 项,通过 `27` 项,未通过 `2` 项。 +本轮专项验证 Tinode WebSocket 真实协议链路,共执行 `29` 项,通过 `28` 项,未通过 `1` 项。 | ID | 场景 | 方法 | 目标 | 结果 | 备注 | | --- | --- | --- | --- | --- | --- | -| `WS-01` | 注册用户 A: HTTP 验证码 + WS acc | `WS` | `/v0/channels` | 通过 | user=usr7-zA3UMrw7c token=AwEA...18Om | -| `WS-02` | 注册用户 B: HTTP 验证码 + WS acc | `WS` | `/v0/channels` | 通过 | user=usrnDXQ3A2oQP0 token=AwEA...NX6b | -| `WS-03` | HTTP 建立好友关系生成 P2P Topic | `HTTP` | `/v1/im/friend-requests` | 通过 | request_id=frq_KAOkEfarCOA topic=p2p7-zA3UMrw7ecNdDcDahA_Q | -| `WS-04` | A token login + sub me | `WS` | `/v0/channels` | 通过 | login user=usr7-zA3UMrw7c | -| `WS-05` | B token login + sub me | `WS` | `/v0/channels` | 通过 | login user=usrnDXQ3A2oQP0 | -| `WS-06` | A/B 订阅 P2P Topic | `WS` | `p2p7-zA3UMrw7ecNdDcDahA_Q` | 通过 | topic=p2p7-zA3UMrw7ecNdDcDahA_Q | -| `WS-07` | A pub 文本消息,B 收到 data | `WS` | `p2p7-zA3UMrw7ecNdDcDahA_Q` | 通过 | seq=1 bob_topic=usr7-zA3UMrw7c | -| `WS-08` | B 发送 recv/read 回执 | `WS` | `p2p7-zA3UMrw7ecNdDcDahA_Q` | 通过 | what=read seq=1 | +| `WS-01` | 注册用户 A: HTTP 验证码 + WS acc | `WS` | `/v0/channels` | 通过 | user=usrO4KoysjOOVE token=AwEA...S/Xa | +| `WS-02` | 注册用户 B: HTTP 验证码 + WS acc | `WS` | `/v0/channels` | 通过 | user=usricjD1vg9CXU token=AwEA...Reda | +| `WS-03` | HTTP 建立好友关系生成 P2P Topic | `HTTP` | `/v1/im/friend-requests` | 通过 | request_id=frq_xYKajMBbqQM topic=p2pO4KoysjOOVGJyMPW-D0JdQ | +| `WS-04` | A token login + sub me | `WS` | `/v0/channels` | 通过 | login user=usrO4KoysjOOVE | +| `WS-05` | B token login + sub me | `WS` | `/v0/channels` | 通过 | login user=usricjD1vg9CXU | +| `WS-06` | A/B 订阅 P2P Topic | `WS` | `p2pO4KoysjOOVGJyMPW-D0JdQ` | 通过 | topic=p2pO4KoysjOOVGJyMPW-D0JdQ | +| `WS-07` | A pub 文本消息,B 收到 data | `WS` | `p2pO4KoysjOOVGJyMPW-D0JdQ` | 通过 | seq=1 bob_topic=usrO4KoysjOOVE | +| `WS-08` | B 发送 recv/read 回执 | `WS` | `p2pO4KoysjOOVGJyMPW-D0JdQ` | 通过 | what=read seq=1 | | `WS-09` | basic login + sub me | `WS` | `/v0/channels` | 通过 | basic login ok | | `WS-10` | 重复 login 被拒绝 | `WS` | `/v0/channels` | 通过 | code=409 text=already authenticated | | `WS-11` | 订阅 fnd 搜索 Topic | `WS` | `fnd` | 通过 | sub fnd ok | | `WS-12` | get me desc/sub/tags/cred | `WS` | `me` | 通过 | get me ok | -| `WS-13` | get P2P desc/sub/data/del | `WS` | `p2p7-zA3UMrw7ecNdDcDahA_Q` | 通过 | get p2p ok | -| `WS-14` | set P2P private 设置 | `WS` | `p2p7-zA3UMrw7ecNdDcDahA_Q` | 通过 | set private ok | -| `WS-15` | pub 产品 x-im 基础头 | `WS` | `p2p7-zA3UMrw7ecNdDcDahA_Q` | 通过 | seq=2 bob_topic=usr7-zA3UMrw7c | -| `WS-16` | pub 产品 x-im-client-mid 幂等头 | `WS` | `p2p7-zA3UMrw7ecNdDcDahA_Q` | 未通过 | pub ctrl: ctrl id=pub-003857.244320000 code=500 text=internal error | -| `WS-17` | note kp 输入中通知 | `WS` | `usr7-zA3UMrw7c` | 通过 | what=kp | -| `WS-18` | del 当前用户消息视图 | `WS` | `p2p7-zA3UMrw7ecNdDcDahA_Q` | 通过 | deleted seq=1 | -| `WS-19` | leave P2P detach 后重新订阅 | `WS` | `p2p7-zA3UMrw7ecNdDcDahA_Q` | 通过 | leave/resub ok | -| `WS-20` | HTTP 创建群供 WS 群协议测试 | `HTTP` | `/v1/im/groups` | 通过 | topic=grpURzKYanXB1Y | -| `WS-21` | A/B 订阅群 Topic | `WS` | `grpURzKYanXB1Y` | 通过 | sub group ok | -| `WS-22` | 群 pub 文本广播 | `WS` | `grpURzKYanXB1Y` | 通过 | seq=1 topic=grpURzKYanXB1Y | -| `WS-23` | get 群 desc/sub/data | `WS` | `grpURzKYanXB1Y` | 通过 | get group ok | +| `WS-13` | get P2P desc/sub/data/del | `WS` | `p2pO4KoysjOOVGJyMPW-D0JdQ` | 通过 | get p2p ok | +| `WS-14` | set P2P private 设置 | `WS` | `p2pO4KoysjOOVGJyMPW-D0JdQ` | 通过 | set private ok | +| `WS-15` | pub 产品 x-im 基础头 | `WS` | `p2pO4KoysjOOVGJyMPW-D0JdQ` | 通过 | seq=2 bob_topic=usrO4KoysjOOVE | +| `WS-16` | pub 产品 x-im-client-mid 幂等头 | `WS` | `p2pO4KoysjOOVGJyMPW-D0JdQ` | 通过 | seq=3 bob_topic=usrO4KoysjOOVE | +| `WS-17` | note kp 输入中通知 | `WS` | `usrO4KoysjOOVE` | 通过 | what=kp | +| `WS-18` | del 当前用户消息视图 | `WS` | `p2pO4KoysjOOVGJyMPW-D0JdQ` | 通过 | deleted seq=1 | +| `WS-19` | leave P2P detach 后重新订阅 | `WS` | `p2pO4KoysjOOVGJyMPW-D0JdQ` | 通过 | leave/resub ok | +| `WS-20` | HTTP 创建群供 WS 群协议测试 | `HTTP` | `/v1/im/groups` | 通过 | topic=grpNJqhCtAZwYM | +| `WS-21` | A/B 订阅群 Topic | `WS` | `grpNJqhCtAZwYM` | 通过 | sub group ok | +| `WS-22` | 群 pub 文本广播 | `WS` | `grpNJqhCtAZwYM` | 通过 | seq=1 topic=grpNJqhCtAZwYM | +| `WS-23` | get 群 desc/sub/data | `WS` | `grpNJqhCtAZwYM` | 通过 | get group ok | | `WS-24` | 文件上传 /v0/file/u | `HTTP` | `/v0/file/u/` | 通过 | url=/v0/....bin | -| `WS-25` | WS 文件消息下发 | `WS` | `p2p7-zA3UMrw7ecNdDcDahA_Q` | 通过 | seq=3 bob_topic=usr7-zA3UMrw7c | -| `WS-26` | WebRTC 通话 invite pub | `WS` | `p2p7-zA3UMrw7ecNdDcDahA_Q` | 未通过 | pub ctrl: ctrl id=pub-003859.867810000 code=501 text=not implemented | +| `WS-25` | WS 文件消息下发 | `WS` | `p2pO4KoysjOOVGJyMPW-D0JdQ` | 通过 | seq=4 bob_topic=usrO4KoysjOOVE | +| `WS-26` | WebRTC 通话 invite pub | `WS` | `p2pO4KoysjOOVGJyMPW-D0JdQ` | 未通过 | pub ctrl: ctrl id=pub-111508.550364000 code=501 text=not implemented | | `WS-27` | 未 hi 直接 login 被拒绝 | `WS` | `/v0/channels` | 通过 | code=409 text=command out of sequence | | `WS-28` | hi 后切换 tenant 被拒绝 | `WS` | `/v0/channels` | 通过 | code=409 text=command out of sequence | | `WS-29` | 非法 API Key 拒绝建连 | `WS` | `/v0/channels` | 通过 | http_status=403 | @@ -53,6 +53,6 @@ ## 4. 测试产生的数据 -- P2P Topic:`p2p7-zA3UMrw7ecNdDcDahA_Q` -- Bob 收到的 Topic:`usr7-zA3UMrw7c` +- P2P Topic:`p2pO4KoysjOOVGJyMPW-D0JdQ` +- Bob 收到的 Topic:`usrO4KoysjOOVE` - 消息 Seq:`1` diff --git a/go.mod b/go.mod index 09ce047..fad910f 100644 --- a/go.mod +++ b/go.mod @@ -24,6 +24,7 @@ require ( github.com/nyaruka/phonenumbers v1.8.0 github.com/prometheus/client_golang v1.23.2 github.com/prometheus/common v0.69.0 + github.com/redis/go-redis/v9 v9.12.1 github.com/rivo/uniseg v0.4.7 github.com/tinode/jsonco v1.0.0 github.com/tinode/snowflake v1.0.0 @@ -65,6 +66,7 @@ require ( github.com/aws/aws-sdk-go-v2/service/sts v1.43.5 // indirect github.com/clbanning/mxj/v2 v2.7.0 // indirect github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2 // indirect + github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect github.com/envoyproxy/go-control-plane/envoy v1.37.0 // indirect github.com/envoyproxy/protoc-gen-validate v1.3.3 // indirect github.com/go-jose/go-jose/v4 v4.1.4 // indirect diff --git a/go.sum b/go.sum index a8e05e7..c494474 100644 --- a/go.sum +++ b/go.sum @@ -37,24 +37,32 @@ github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0 github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.57.0/go.mod h1:dzcEjy1WJ0Q4u9twNR3LcLhNoYMRCrMCMafpxa0TjPQ= github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.57.0 h1:RoO5+d7uCmDqovLrHCr2/BuViUXvdcrNxyNM1pN9dDQ= github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.57.0/go.mod h1:YqwkQPrWSC7+byyc1VlKbWLBF5JsW5IoL6xUkemYSXk= +github.com/alibabacloud-go/alibabacloud-gateway-pop v0.0.6 h1:eIf+iGJxdU4U9ypaUfbtOWCsZSbTb8AUHvyPrxu6mAA= github.com/alibabacloud-go/alibabacloud-gateway-pop v0.0.6/go.mod h1:4EUIoxs/do24zMOGGqYVWgw0s9NtiylnJglOeEB5UJo= github.com/alibabacloud-go/alibabacloud-gateway-spi v0.0.4/go.mod h1:sCavSAvdzOjul4cEqeVtvlSaSScfNsTQ+46HwlTL1hc= github.com/alibabacloud-go/alibabacloud-gateway-spi v0.0.5 h1:zE8vH9C7JiZLNJJQ5OwjU9mSi4T9ef9u3BURT6LCLC8= github.com/alibabacloud-go/alibabacloud-gateway-spi v0.0.5/go.mod h1:tWnyE9AjF8J8qqLk645oUmVUnFybApTQWklQmi5tY6g= +github.com/alibabacloud-go/darabonba-array v0.1.0 h1:vR8s7b1fWAQIjEjWnuF0JiKsCvclSRTfDzZHTYqfufY= github.com/alibabacloud-go/darabonba-array v0.1.0/go.mod h1:BLKxr0brnggqOJPqT09DFJ8g3fsDshapUD3C3aOEFaI= +github.com/alibabacloud-go/darabonba-encode-util v0.0.2 h1:1uJGrbsGEVqWcWxrS9MyC2NG0Ax+GpOM5gtupki31XE= github.com/alibabacloud-go/darabonba-encode-util v0.0.2/go.mod h1:JiW9higWHYXm7F4PKuMgEUETNZasrDM6vqVr/Can7H8= +github.com/alibabacloud-go/darabonba-map v0.0.2 h1:qvPnGB4+dJbJIxOOfawxzF3hzMnIpjmafa0qOTp6udc= github.com/alibabacloud-go/darabonba-map v0.0.2/go.mod h1:28AJaX8FOE/ym8OUFWga+MtEzBunJwQGceGQlvaPGPc= github.com/alibabacloud-go/darabonba-openapi/v2 v2.2.4 h1:o6veen0IZ/Fe1JawwhwQMZcbw67CVDY1pQwXcNWPyQo= github.com/alibabacloud-go/darabonba-openapi/v2 v2.2.4/go.mod h1:eHjVxrT9g8uVYN/nyAwOFQEfkVA154ChiqRc2XnNKYU= +github.com/alibabacloud-go/darabonba-signature-util v0.0.7 h1:UzCnKvsjPFzApvODDNEYqBHMFt1w98wC7FOo0InLyxg= github.com/alibabacloud-go/darabonba-signature-util v0.0.7/go.mod h1:oUzCYV2fcCH797xKdL6BDH8ADIHlzrtKVjeRtunBNTQ= +github.com/alibabacloud-go/darabonba-string v1.0.2 h1:E714wms5ibdzCqGeYJ9JCFywE5nDyvIXIIQbZVFkkqo= github.com/alibabacloud-go/darabonba-string v1.0.2/go.mod h1:93cTfV3vuPhhEwGGpKKqhVW4jLe7tDpo3LUM0i0g6mA= github.com/alibabacloud-go/debug v0.0.0-20190504072949-9472017b5c68/go.mod h1:6pb/Qy8c+lqua8cFpEy7g39NRRqOWc3rOwAy8m5Y2BY= github.com/alibabacloud-go/debug v1.0.0/go.mod h1:8gfgZCCAC3+SCzjWtY053FrOcd4/qlH6IHTI4QyICOc= github.com/alibabacloud-go/debug v1.0.1 h1:MsW9SmUtbb1Fnt3ieC6NNZi6aEwrXfDksD4QA6GSbPg= github.com/alibabacloud-go/debug v1.0.1/go.mod h1:8gfgZCCAC3+SCzjWtY053FrOcd4/qlH6IHTI4QyICOc= +github.com/alibabacloud-go/endpoint-util v1.1.0 h1:r/4D3VSw888XGaeNpP994zDUaxdgTSHBbVfZlzf6b5Q= github.com/alibabacloud-go/endpoint-util v1.1.0/go.mod h1:O5FuCALmCKs2Ff7JFJMudHs0I5EBgecXXxZRyswlEjE= github.com/alibabacloud-go/green-20220302/v3 v3.5.4 h1:ZnlHri7tTLl28JWB5S2J6MYvD0ewJFI7hedK4E7+gSk= github.com/alibabacloud-go/green-20220302/v3 v3.5.4/go.mod h1:PQTtoEuWOsuq7H7sgTRcXDw4Ut/Hekgy5z2mWGtO9OQ= +github.com/alibabacloud-go/openapi-util v0.1.0 h1:0z75cIULkDrdEhkLWgi9tnLe+KhAFE/r5Pb3312/eAY= github.com/alibabacloud-go/openapi-util v0.1.0/go.mod h1:sQuElr4ywwFRlCCberQwKRFhRzIyG4QTP/P4y1CJ6Ws= github.com/alibabacloud-go/tea v1.1.0/go.mod h1:IkGyUSX4Ba1V+k4pCtJUc6jDpZLFph9QMy2VUPTwukg= github.com/alibabacloud-go/tea v1.1.7/go.mod h1:/tmnEaQMyb4Ky1/5D+SE1BAsa5zj/KeGOFfwYm3N/p4= @@ -121,6 +129,10 @@ github.com/bitly/go-hostpool v0.1.0 h1:XKmsF6k5el6xHG3WPJ8U0Ku/ye7njX7W81Ng7O2io github.com/bitly/go-hostpool v0.1.0/go.mod h1:4gOCgp6+NZnVqlKyZ/iBZFTAJKembaVENUpMkpg42fw= github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869 h1:DDGfHa7BWjL4YnC6+E63dPcxHo2sUxDIu8g3QgEJdRY= github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869/go.mod h1:Ekp36dRnpXw/yCqJaO+ZrUyxD+3VXMFFr56k5XYrpB4= +github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= +github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= +github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= +github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= @@ -135,6 +147,8 @@ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= github.com/envoyproxy/go-control-plane v0.14.0 h1:hbG2kr4RuFj222B6+7T83thSPqLjwBIfQawTkC++2HA= @@ -277,6 +291,8 @@ github.com/prometheus/common v0.69.0 h1:OA85nJQS/T/MaYh/Q2CcgDKSGWqNIgrBDvDH85Cu github.com/prometheus/common v0.69.0/go.mod h1:ZzL3f6u94qUxh9p+tJTrF+FvBS1XXbbRAZCQkytAL0Y= github.com/prometheus/procfs v0.21.1 h1:GljZCt+zSTS+NZq88cyQ1LjZ+RCHp3uVuabBWA5+OJI= github.com/prometheus/procfs v0.21.1/go.mod h1:aB55Cww9pdSJVHk0hUf0inxWyyjPogFIjmHKYgMKmtY= +github.com/redis/go-redis/v9 v9.12.1 h1:k5iquqv27aBtnTm2tIkROUDp8JBXhXZIVu1InSgvovg= +github.com/redis/go-redis/v9 v9.12.1/go.mod h1:huWgSWd8mW6+m0VPhJjSSQ+d6Nh1VICQ6Q5lHuCH/Iw= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= diff --git a/pbx/model.pb.go b/pbx/model.pb.go index 78e7162..7d56bda 100644 --- a/pbx/model.pb.go +++ b/pbx/model.pb.go @@ -810,8 +810,8 @@ type SeqRange struct { sizeCache protoimpl.SizeCache unknownFields protoimpl.UnknownFields - Low int32 `protobuf:"varint,1,opt,name=low,proto3" json:"low,omitempty"` - Hi int32 `protobuf:"varint,2,opt,name=hi,proto3" json:"hi,omitempty"` + Low int64 `protobuf:"varint,1,opt,name=low,proto3" json:"low,omitempty"` + Hi int64 `protobuf:"varint,2,opt,name=hi,proto3" json:"hi,omitempty"` } func (x *SeqRange) Reset() { @@ -846,14 +846,14 @@ func (*SeqRange) Descriptor() ([]byte, []int) { return file_model_proto_rawDescGZIP(), []int{6} } -func (x *SeqRange) GetLow() int32 { +func (x *SeqRange) GetLow() int64 { if x != nil { return x.Low } return 0 } -func (x *SeqRange) GetHi() int32 { +func (x *SeqRange) GetHi() int64 { if x != nil { return x.Hi } @@ -872,9 +872,9 @@ type GetOpts struct { // Limit search results to one topic; Topic string `protobuf:"bytes,3,opt,name=topic,proto3" json:"topic,omitempty"` // Load messages with seq id equal or greater than this - SinceId int32 `protobuf:"varint,4,opt,name=since_id,json=sinceId,proto3" json:"since_id,omitempty"` + SinceId int64 `protobuf:"varint,4,opt,name=since_id,json=sinceId,proto3" json:"since_id,omitempty"` // Load messages with seq id lower than this - BeforeId int32 `protobuf:"varint,5,opt,name=before_id,json=beforeId,proto3" json:"before_id,omitempty"` + BeforeId int64 `protobuf:"varint,5,opt,name=before_id,json=beforeId,proto3" json:"before_id,omitempty"` // Maximum number of results to return Limit int32 `protobuf:"varint,6,opt,name=limit,proto3" json:"limit,omitempty"` // Load messages by id or ranges of ids @@ -934,14 +934,14 @@ func (x *GetOpts) GetTopic() string { return "" } -func (x *GetOpts) GetSinceId() int32 { +func (x *GetOpts) GetSinceId() int64 { if x != nil { return x.SinceId } return 0 } -func (x *GetOpts) GetBeforeId() int32 { +func (x *GetOpts) GetBeforeId() int64 { if x != nil { return x.BeforeId } @@ -1911,7 +1911,7 @@ type ClientNote struct { // "kp" - typing notification, "call" - voice/video call What InfoNote `protobuf:"varint,2,opt,name=what,proto3,enum=pbx.InfoNote" json:"what,omitempty"` // Server-issued message ID being reported - SeqId int32 `protobuf:"varint,3,opt,name=seq_id,json=seqId,proto3" json:"seq_id,omitempty"` + SeqId int64 `protobuf:"varint,3,opt,name=seq_id,json=seqId,proto3" json:"seq_id,omitempty"` // Client's count of unread messages to report back to the server. Used in push notifications on iOS. Unread int32 `protobuf:"varint,4,opt,name=unread,proto3" json:"unread,omitempty"` // Call event. @@ -1966,7 +1966,7 @@ func (x *ClientNote) GetWhat() InfoNote { return InfoNote_X1 } -func (x *ClientNote) GetSeqId() int32 { +func (x *ClientNote) GetSeqId() int64 { if x != nil { return x.SeqId } @@ -2338,9 +2338,9 @@ type TopicDesc struct { TouchedAt int64 `protobuf:"varint,3,opt,name=touched_at,json=touchedAt,proto3" json:"touched_at,omitempty"` Defacs *DefaultAcsMode `protobuf:"bytes,4,opt,name=defacs,proto3" json:"defacs,omitempty"` Acs *AccessMode `protobuf:"bytes,5,opt,name=acs,proto3" json:"acs,omitempty"` - SeqId int32 `protobuf:"varint,6,opt,name=seq_id,json=seqId,proto3" json:"seq_id,omitempty"` - ReadId int32 `protobuf:"varint,7,opt,name=read_id,json=readId,proto3" json:"read_id,omitempty"` - RecvId int32 `protobuf:"varint,8,opt,name=recv_id,json=recvId,proto3" json:"recv_id,omitempty"` + SeqId int64 `protobuf:"varint,6,opt,name=seq_id,json=seqId,proto3" json:"seq_id,omitempty"` + ReadId int64 `protobuf:"varint,7,opt,name=read_id,json=readId,proto3" json:"read_id,omitempty"` + RecvId int64 `protobuf:"varint,8,opt,name=recv_id,json=recvId,proto3" json:"recv_id,omitempty"` DelId int32 `protobuf:"varint,9,opt,name=del_id,json=delId,proto3" json:"del_id,omitempty"` Public []byte `protobuf:"bytes,10,opt,name=public,proto3" json:"public,omitempty"` Private []byte `protobuf:"bytes,11,opt,name=private,proto3" json:"private,omitempty"` @@ -2421,21 +2421,21 @@ func (x *TopicDesc) GetAcs() *AccessMode { return nil } -func (x *TopicDesc) GetSeqId() int32 { +func (x *TopicDesc) GetSeqId() int64 { if x != nil { return x.SeqId } return 0 } -func (x *TopicDesc) GetReadId() int32 { +func (x *TopicDesc) GetReadId() int64 { if x != nil { return x.ReadId } return 0 } -func (x *TopicDesc) GetRecvId() int32 { +func (x *TopicDesc) GetRecvId() int64 { if x != nil { return x.RecvId } @@ -2522,8 +2522,8 @@ type TopicSub struct { DeletedAt int64 `protobuf:"varint,2,opt,name=deleted_at,json=deletedAt,proto3" json:"deleted_at,omitempty"` Online bool `protobuf:"varint,3,opt,name=online,proto3" json:"online,omitempty"` Acs *AccessMode `protobuf:"bytes,4,opt,name=acs,proto3" json:"acs,omitempty"` - ReadId int32 `protobuf:"varint,5,opt,name=read_id,json=readId,proto3" json:"read_id,omitempty"` - RecvId int32 `protobuf:"varint,6,opt,name=recv_id,json=recvId,proto3" json:"recv_id,omitempty"` + ReadId int64 `protobuf:"varint,5,opt,name=read_id,json=readId,proto3" json:"read_id,omitempty"` + RecvId int64 `protobuf:"varint,6,opt,name=recv_id,json=recvId,proto3" json:"recv_id,omitempty"` Public []byte `protobuf:"bytes,7,opt,name=public,proto3" json:"public,omitempty"` Trusted []byte `protobuf:"bytes,16,opt,name=trusted,proto3" json:"trusted,omitempty"` // 16! Private []byte `protobuf:"bytes,8,opt,name=private,proto3" json:"private,omitempty"` @@ -2533,7 +2533,7 @@ type TopicSub struct { Topic string `protobuf:"bytes,10,opt,name=topic,proto3" json:"topic,omitempty"` TouchedAt int64 `protobuf:"varint,11,opt,name=touched_at,json=touchedAt,proto3" json:"touched_at,omitempty"` // ID of the last {data} message in a topic - SeqId int32 `protobuf:"varint,12,opt,name=seq_id,json=seqId,proto3" json:"seq_id,omitempty"` + SeqId int64 `protobuf:"varint,12,opt,name=seq_id,json=seqId,proto3" json:"seq_id,omitempty"` // Messages are deleted up to this ID DelId int32 `protobuf:"varint,13,opt,name=del_id,json=delId,proto3" json:"del_id,omitempty"` // Other user's last online timestamp & user agent @@ -2601,14 +2601,14 @@ func (x *TopicSub) GetAcs() *AccessMode { return nil } -func (x *TopicSub) GetReadId() int32 { +func (x *TopicSub) GetReadId() int64 { if x != nil { return x.ReadId } return 0 } -func (x *TopicSub) GetRecvId() int32 { +func (x *TopicSub) GetRecvId() int64 { if x != nil { return x.RecvId } @@ -2657,7 +2657,7 @@ func (x *TopicSub) GetTouchedAt() int64 { return 0 } -func (x *TopicSub) GetSeqId() int32 { +func (x *TopicSub) GetSeqId() int64 { if x != nil { return x.SeqId } @@ -2833,7 +2833,7 @@ type ServerData struct { Timestamp int64 `protobuf:"varint,7,opt,name=timestamp,proto3" json:"timestamp,omitempty"` // Timestamp when the message was deleted or 0. Milliseconds since the epoch 01/01/1970 DeletedAt int64 `protobuf:"varint,3,opt,name=deleted_at,json=deletedAt,proto3" json:"deleted_at,omitempty"` - SeqId int32 `protobuf:"varint,4,opt,name=seq_id,json=seqId,proto3" json:"seq_id,omitempty"` + SeqId int64 `protobuf:"varint,4,opt,name=seq_id,json=seqId,proto3" json:"seq_id,omitempty"` Head map[string][]byte `protobuf:"bytes,5,rep,name=head,proto3" json:"head,omitempty" protobuf_key:"bytes,1,opt,name=key,proto3" protobuf_val:"bytes,2,opt,name=value,proto3"` Content []byte `protobuf:"bytes,6,opt,name=content,proto3" json:"content,omitempty"` } @@ -2898,7 +2898,7 @@ func (x *ServerData) GetDeletedAt() int64 { return 0 } -func (x *ServerData) GetSeqId() int32 { +func (x *ServerData) GetSeqId() int64 { if x != nil { return x.SeqId } @@ -2929,7 +2929,7 @@ type ServerPres struct { Src string `protobuf:"bytes,2,opt,name=src,proto3" json:"src,omitempty"` What ServerPres_What `protobuf:"varint,3,opt,name=what,proto3,enum=pbx.ServerPres_What" json:"what,omitempty"` UserAgent string `protobuf:"bytes,4,opt,name=user_agent,json=userAgent,proto3" json:"user_agent,omitempty"` - SeqId int32 `protobuf:"varint,5,opt,name=seq_id,json=seqId,proto3" json:"seq_id,omitempty"` + SeqId int64 `protobuf:"varint,5,opt,name=seq_id,json=seqId,proto3" json:"seq_id,omitempty"` DelId int32 `protobuf:"varint,6,opt,name=del_id,json=delId,proto3" json:"del_id,omitempty"` DelSeq []*SeqRange `protobuf:"bytes,7,rep,name=del_seq,json=delSeq,proto3" json:"del_seq,omitempty"` TargetUserId string `protobuf:"bytes,8,opt,name=target_user_id,json=targetUserId,proto3" json:"target_user_id,omitempty"` @@ -2997,7 +2997,7 @@ func (x *ServerPres) GetUserAgent() string { return "" } -func (x *ServerPres) GetSeqId() int32 { +func (x *ServerPres) GetSeqId() int64 { if x != nil { return x.SeqId } @@ -3152,7 +3152,7 @@ type ServerInfo struct { Topic string `protobuf:"bytes,1,opt,name=topic,proto3" json:"topic,omitempty"` FromUserId string `protobuf:"bytes,2,opt,name=from_user_id,json=fromUserId,proto3" json:"from_user_id,omitempty"` What InfoNote `protobuf:"varint,3,opt,name=what,proto3,enum=pbx.InfoNote" json:"what,omitempty"` - SeqId int32 `protobuf:"varint,4,opt,name=seq_id,json=seqId,proto3" json:"seq_id,omitempty"` + SeqId int64 `protobuf:"varint,4,opt,name=seq_id,json=seqId,proto3" json:"seq_id,omitempty"` Src string `protobuf:"bytes,5,opt,name=src,proto3" json:"src,omitempty"` Event CallEvent `protobuf:"varint,6,opt,name=event,proto3,enum=pbx.CallEvent" json:"event,omitempty"` Payload []byte `protobuf:"bytes,7,opt,name=payload,proto3" json:"payload,omitempty"` @@ -3211,7 +3211,7 @@ func (x *ServerInfo) GetWhat() InfoNote { return InfoNote_X1 } -func (x *ServerInfo) GetSeqId() int32 { +func (x *ServerInfo) GetSeqId() int64 { if x != nil { return x.SeqId } @@ -3862,8 +3862,8 @@ type SubscriptionEvent struct { Topic string `protobuf:"bytes,2,opt,name=topic,proto3" json:"topic,omitempty"` UserId string `protobuf:"bytes,3,opt,name=user_id,json=userId,proto3" json:"user_id,omitempty"` DelId int32 `protobuf:"varint,4,opt,name=del_id,json=delId,proto3" json:"del_id,omitempty"` - ReadId int32 `protobuf:"varint,5,opt,name=read_id,json=readId,proto3" json:"read_id,omitempty"` - RecvId int32 `protobuf:"varint,6,opt,name=recv_id,json=recvId,proto3" json:"recv_id,omitempty"` + ReadId int64 `protobuf:"varint,5,opt,name=read_id,json=readId,proto3" json:"read_id,omitempty"` + RecvId int64 `protobuf:"varint,6,opt,name=recv_id,json=recvId,proto3" json:"recv_id,omitempty"` Mode *AccessMode `protobuf:"bytes,7,opt,name=mode,proto3" json:"mode,omitempty"` Private []byte `protobuf:"bytes,8,opt,name=private,proto3" json:"private,omitempty"` } @@ -3928,14 +3928,14 @@ func (x *SubscriptionEvent) GetDelId() int32 { return 0 } -func (x *SubscriptionEvent) GetReadId() int32 { +func (x *SubscriptionEvent) GetReadId() int64 { if x != nil { return x.ReadId } return 0 } -func (x *SubscriptionEvent) GetRecvId() int32 { +func (x *SubscriptionEvent) GetRecvId() int64 { if x != nil { return x.RecvId } diff --git a/pbx/model.proto b/pbx/model.proto index 6b9b196..33b75c7 100644 --- a/pbx/model.proto +++ b/pbx/model.proto @@ -100,8 +100,8 @@ message SetDesc { } message SeqRange { - int32 low = 1; - int32 hi = 2; + int64 low = 1; + int64 hi = 2; } message GetOpts { @@ -112,9 +112,9 @@ message GetOpts { // Limit search results to one topic; string topic = 3; // Load messages with seq id equal or greater than this - int32 since_id = 4; + int64 since_id = 4; // Load messages with seq id lower than this - int32 before_id = 5; + int64 before_id = 5; // Maximum number of results to return int32 limit = 6; // Load messages by id or ranges of ids @@ -292,7 +292,7 @@ message ClientNote { // "kp" - typing notification, "call" - voice/video call InfoNote what = 2; // Server-issued message ID being reported - int32 seq_id = 3; + int64 seq_id = 3; // Client's count of unread messages to report back to the server. Used in push notifications on iOS. int32 unread = 4; // Call event. @@ -345,9 +345,9 @@ message TopicDesc { int64 touched_at = 3; DefaultAcsMode defacs = 4; AccessMode acs = 5; - int32 seq_id = 6; - int32 read_id = 7; - int32 recv_id = 8; + int64 seq_id = 6; + int64 read_id = 7; + int64 recv_id = 8; int32 del_id = 9; bytes public = 10; bytes private = 11; @@ -370,8 +370,8 @@ message TopicSub { bool online = 3; AccessMode acs = 4; - int32 read_id = 5; - int32 recv_id = 6; + int64 read_id = 5; + int64 recv_id = 6; bytes public = 7; bytes trusted = 16; // 16! bytes private = 8; @@ -387,7 +387,7 @@ message TopicSub { string topic = 10; int64 touched_at = 11; // ID of the last {data} message in a topic - int32 seq_id = 12; + int64 seq_id = 12; // Messages are deleted up to this ID int32 del_id = 13; @@ -421,7 +421,7 @@ message ServerData { int64 timestamp = 7; // Timestamp when the message was deleted or 0. Milliseconds since the epoch 01/01/1970 int64 deleted_at = 3; - int32 seq_id = 4; + int64 seq_id = 4; map head = 5; bytes content = 6; } @@ -449,7 +449,7 @@ message ServerPres { } What what = 3; string user_agent = 4; - int32 seq_id = 5; + int64 seq_id = 5; int32 del_id = 6; repeated SeqRange del_seq = 7; string target_user_id = 8; @@ -475,7 +475,7 @@ message ServerInfo { string topic = 1; string from_user_id = 2; InfoNote what = 3; - int32 seq_id = 4; + int64 seq_id = 4; string src = 5; CallEvent event = 6; bytes payload = 7; @@ -577,8 +577,8 @@ message SubscriptionEvent { string user_id = 3; int32 del_id = 4; - int32 read_id = 5; - int32 recv_id = 6; + int64 read_id = 5; + int64 recv_id = 6; AccessMode mode = 7; diff --git a/server/calls.go b/server/calls.go index 14d625c..21981c5 100644 --- a/server/calls.go +++ b/server/calls.go @@ -86,7 +86,7 @@ type videoCall struct { // Call participants (session sid -> callPartyData). parties map[string]callPartyData // Call message seq ID. - seq int + seq int64 // Call message content. content any // Call message content mime type. @@ -300,7 +300,7 @@ func (call *videoCall) messageHead(head map[string]any, newState string, duratio head = map[string]any{} } - head["replace"] = ":" + strconv.Itoa(call.seq) + head["replace"] = ":" + strconv.FormatInt(call.seq, 10) head["webrtc"] = newState if duration > 0 { diff --git a/server/datamodel.go b/server/datamodel.go index 36c2399..05cc39e 100644 --- a/server/datamodel.go +++ b/server/datamodel.go @@ -27,9 +27,9 @@ type MsgGetOpts struct { // Return results modified since this timespamp. IfModifiedSince *time.Time `json:"ims,omitempty"` // Load messages/ranges with IDs equal or greater than this (inclusive or closed). - SinceId int `json:"since,omitempty"` + SinceId int64 `json:"since,omitempty"` // Load messages/ranges with IDs lower than this (exclusive or open). - BeforeId int `json:"before,omitempty"` + BeforeId int64 `json:"before,omitempty"` // Limit the number of messages loaded. Limit int `json:"limit,omitempty"` // Fetch messages with IDs in these ranges. @@ -100,8 +100,8 @@ type MsgSetQuery struct { // MsgRange is either an individual ID (HiId=0) or a randge of IDs, low end inclusive (closed), // high-end exclusive (open): [LowId .. HiId), e.g. 1..5 -> 1, 2, 3, 4. type MsgRange struct { - LowId int `json:"low,omitempty"` - HiId int `json:"hi,omitempty"` + LowId int64 `json:"low,omitempty"` + HiId int64 `json:"hi,omitempty"` } /**************************************************************** @@ -321,7 +321,7 @@ type MsgClientNote struct { // what is being reported: "recv" - message received, "read" - message read, "kp" - typing notification What string `json:"what"` // Server-issued message ID being reported - SeqId int `json:"seq,omitempty"` + SeqId int64 `json:"seq,omitempty"` // Client's count of unread messages to report back to the server. Used in push notifications on iOS. Unread int `json:"unread,omitempty"` // Call event. @@ -453,9 +453,9 @@ type MsgTopicDesc struct { // Actual access mode Acs *MsgAccessMode `json:"acs,omitempty"` // Max message ID - SeqId int `json:"seq,omitempty"` - ReadSeqId int `json:"read,omitempty"` - RecvSeqId int `json:"recv,omitempty"` + SeqId int64 `json:"seq,omitempty"` + ReadSeqId int64 `json:"read,omitempty"` + RecvSeqId int64 `json:"recv,omitempty"` // Id of the last delete operation as seen by the requesting user DelId int `json:"clear,omitempty"` SubCnt int `json:"subcnt,omitempty"` @@ -475,13 +475,13 @@ func (src *MsgTopicDesc) describe() string { s += " acs={" + src.Acs.describe() + "}" } if src.SeqId != 0 { - s += " seq=" + strconv.Itoa(src.SeqId) + s += " seq=" + strconv.FormatInt(src.SeqId, 10) } if src.ReadSeqId != 0 { - s += " read=" + strconv.Itoa(src.ReadSeqId) + s += " read=" + strconv.FormatInt(src.ReadSeqId, 10) } if src.RecvSeqId != 0 { - s += " recv=" + strconv.Itoa(src.RecvSeqId) + s += " recv=" + strconv.FormatInt(src.RecvSeqId, 10) } if src.DelId != 0 { s += " clear=" + strconv.Itoa(src.DelId) @@ -517,9 +517,9 @@ type MsgTopicSub struct { // Acs.Mode = want & given. The field is not a pointer because at least one value is always assigned. Acs MsgAccessMode `json:"acs,omitempty"` // ID of the message reported by the given user as read - ReadSeqId int `json:"read,omitempty"` + ReadSeqId int64 `json:"read,omitempty"` // ID of the message reported by the given user as received - RecvSeqId int `json:"recv,omitempty"` + RecvSeqId int64 `json:"recv,omitempty"` // Topic's public data Public any `json:"public,omitempty"` // Topic's trusted public data @@ -540,7 +540,7 @@ type MsgTopicSub struct { // Timestamp of the last message in the topic. TouchedAt *time.Time `json:"touched,omitempty"` // ID of the last {data} message in a topic - SeqId int `json:"seq,omitempty"` + SeqId int64 `json:"seq,omitempty"` // Id of the latest Delete operation DelId int `json:"clear,omitempty"` // Number of subscribers, group topics only. @@ -555,13 +555,13 @@ func (src *MsgTopicSub) describe() string { s := src.Topic + ":" + src.User + " online=" + strconv.FormatBool(src.Online) + " acs=" + src.Acs.describe() if src.SeqId != 0 { - s += " seq=" + strconv.Itoa(src.SeqId) + s += " seq=" + strconv.FormatInt(src.SeqId, 10) } if src.ReadSeqId != 0 { - s += " read=" + strconv.Itoa(src.ReadSeqId) + s += " read=" + strconv.FormatInt(src.ReadSeqId, 10) } if src.RecvSeqId != 0 { - s += " recv=" + strconv.Itoa(src.RecvSeqId) + s += " recv=" + strconv.FormatInt(src.RecvSeqId, 10) } if src.DelId != 0 { s += " clear=" + strconv.Itoa(src.DelId) @@ -621,7 +621,7 @@ type MsgServerData struct { From string `json:"from,omitempty"` Timestamp time.Time `json:"ts"` DeletedAt *time.Time `json:"deleted,omitempty"` - SeqId int `json:"seq"` + SeqId int64 `json:"seq"` Head map[string]any `json:"head,omitempty"` Content any `json:"content"` } @@ -636,7 +636,7 @@ func (src *MsgServerData) copy() *MsgServerData { } func (src *MsgServerData) describe() string { - s := src.Topic + " from=" + src.From + " seq=" + strconv.Itoa(src.SeqId) + s := src.Topic + " from=" + src.From + " seq=" + strconv.FormatInt(src.SeqId, 10) if src.DeletedAt != nil { s += " deleted" } else { @@ -654,7 +654,7 @@ type MsgServerPres struct { Src string `json:"src,omitempty"` What string `json:"what"` UserAgent string `json:"ua,omitempty"` - SeqId int `json:"seq,omitempty"` + SeqId int64 `json:"seq,omitempty"` DelId int `json:"clear,omitempty"` DelSeq []MsgRange `json:"delseq,omitempty"` AcsTarget string `json:"tgt,omitempty"` @@ -706,7 +706,7 @@ func (src *MsgServerPres) describe() string { s += " ua=" + src.UserAgent } if src.SeqId != 0 { - s += " seq=" + strconv.Itoa(src.SeqId) + s += " seq=" + strconv.FormatInt(src.SeqId, 10) } if src.DelId != 0 { s += " clear=" + strconv.Itoa(src.DelId) @@ -799,7 +799,7 @@ type MsgServerInfo struct { // The event being reported: "rcpt" - message received, "read" - message read, "kp" - typing notification, "call" - video call. What string `json:"what"` // Server-issued message ID being reported. - SeqId int `json:"seq,omitempty"` + SeqId int64 `json:"seq,omitempty"` // Call event. Event string `json:"event,omitempty"` // Arbitrary json payload (used by video calls). @@ -829,7 +829,7 @@ func (src *MsgServerInfo) describe() string { } s += " what=" + src.What + " from=" + src.From if src.SeqId > 0 { - s += " seq=" + strconv.Itoa(src.SeqId) + s += " seq=" + strconv.FormatInt(src.SeqId, 10) } if len(src.Payload) > 0 { s += " payload=<..." + strconv.Itoa(len(src.Payload)) + " bytes ...>" diff --git a/server/db/adapter.go b/server/db/adapter.go index c23eee8..51caa1a 100644 --- a/server/db/adapter.go +++ b/server/db/adapter.go @@ -326,13 +326,20 @@ type TenantBusinessAdapter interface { TenantMessageSave(t.TenantID, *t.Message) error TenantMessageGetByClientID(t.TenantID, t.Uid, string) (*t.Message, error) - TenantMessageGetOne(t.TenantID, string, int) (*t.Message, error) - TenantMessageRecall(t.TenantID, string, int, t.Uid, int, time.Time) (*t.Message, error) - TenantMessageEdit(t.TenantID, string, int, t.Uid, int, t.KVMap, any, string, time.Time) (*t.Message, error) + TenantMessageGetOne(t.TenantID, string, int64) (*t.Message, error) + TenantMessageRecall(t.TenantID, string, int64, t.Uid, int, time.Time) (*t.Message, error) + TenantMessageEdit(t.TenantID, string, int64, t.Uid, int, t.KVMap, any, string, time.Time) (*t.Message, error) TenantMessageGetAll(t.TenantID, string, t.Uid, *t.QueryOpt) ([]t.Message, error) TenantMessageDeleteList(t.TenantID, string, *t.DelMessage) error TenantMessageGetDeleted(t.TenantID, string, t.Uid, *t.QueryOpt) ([]t.DelMessage, error) TenantMessageSearch(t.TenantID, string, t.Uid, string, string, string, int) ([]t.Message, string, error) + TenantGroupMessageSave(t.TenantID, *t.Message) error + TenantGroupMessageGetByClientID(t.TenantID, string, t.Uid, string) (*t.Message, error) + TenantGroupMessageGetOne(t.TenantID, string, int64) (*t.Message, error) + TenantGroupMessageRecall(t.TenantID, string, int64, t.Uid, int, time.Time) (*t.Message, error) + TenantGroupMessageEdit(t.TenantID, string, int64, t.Uid, int, t.KVMap, any, string, time.Time) (*t.Message, error) + TenantGroupMessageGetAll(t.TenantID, string, t.Uid, *t.QueryOpt) ([]t.Message, error) + TenantGroupMessageDeleteList(t.TenantID, string, *t.DelMessage) error TenantConversationSettingsGet(t.TenantID, t.Uid, string) (*t.ConversationSettings, error) TenantConversationSettingsUpdate(t.TenantID, t.Uid, string, t.ConversationSettingsPatch) (*t.ConversationSettings, error) @@ -340,8 +347,8 @@ type TenantBusinessAdapter interface { TenantTopicDraftPut(t.TenantID, t.Uid, string, string, []byte, int) (*t.TopicDraft, error) TenantCallCreate(t.TenantID, *t.CallRecord) error - TenantCallAccept(t.TenantID, string, int, time.Time) error - TenantCallFinish(t.TenantID, string, int, string, int64, time.Time) error + TenantCallAccept(t.TenantID, string, int64, time.Time) error + TenantCallFinish(t.TenantID, string, int64, string, int64, time.Time) error TenantCallList(t.TenantID, t.Uid, string, string, int) ([]t.CallRecord, string, error) TenantGroupSettingsCreate(t.TenantID, *t.GroupSettings) (*t.GroupSettings, error) diff --git a/server/db/common/common_test.go b/server/db/common/common_test.go index 33b2632..f51bd34 100644 --- a/server/db/common/common_test.go +++ b/server/db/common/common_test.go @@ -126,7 +126,7 @@ func TestRangesToSql(t *testing.T) { ranges := []types.Range{{Low: 5, Hi: 0}} sql, args := RangesToSql(ranges) expectedSql := "IN (?)" - expectedArgs := []any{5} + expectedArgs := []any{int64(5)} if sql != expectedSql { t.Errorf("Expected SQL '%s', got '%s'", expectedSql, sql) } @@ -138,7 +138,7 @@ func TestRangesToSql(t *testing.T) { ranges = []types.Range{{Low: 5, Hi: 8}} sql, args = RangesToSql(ranges) expectedSql = "BETWEEN ? AND ?" - expectedArgs = []any{5, 7} // Hi-1 for BETWEEN + expectedArgs = []any{int64(5), int64(7)} // Hi-1 for BETWEEN if sql != expectedSql { t.Errorf("Expected SQL '%s', got '%s'", expectedSql, sql) } @@ -150,7 +150,7 @@ func TestRangesToSql(t *testing.T) { ranges = []types.Range{{Low: 1, Hi: 3}, {Low: 5, Hi: 0}, {Low: 8, Hi: 10}} sql, args = RangesToSql(ranges) expectedSql = "IN (?,?,?,?,?)" - expectedArgs = []any{1, 2, 5, 8, 9} + expectedArgs = []any{int64(1), int64(2), int64(5), int64(8), int64(9)} if sql != expectedSql { t.Errorf("Expected SQL '%s', got '%s'", expectedSql, sql) } diff --git a/server/db/mysql/adapter.go b/server/db/mysql/adapter.go index d540781..bb26e32 100644 --- a/server/db/mysql/adapter.go +++ b/server/db/mysql/adapter.go @@ -754,7 +754,7 @@ func (a *adapter) CreateDb(reset bool) error { usebt TINYINT DEFAULT 0, owner BIGINT NOT NULL DEFAULT 0, access JSON, - seqid INT NOT NULL DEFAULT 0, + seqid BIGINT NOT NULL DEFAULT 0, delid INT DEFAULT 0, subcnt INT DEFAULT 0, public JSON, @@ -799,8 +799,8 @@ func (a *adapter) CreateDb(reset bool) error { userid BIGINT NOT NULL, topic CHAR(25) NOT NULL, delid INT DEFAULT 0, - recvseqid INT DEFAULT 0, - readseqid INT DEFAULT 0, + recvseqid BIGINT DEFAULT 0, + readseqid BIGINT DEFAULT 0, modewant CHAR(8), modegiven CHAR(8), private JSON, @@ -822,7 +822,7 @@ func (a *adapter) CreateDb(reset bool) error { updatedat DATETIME(3) NOT NULL, deletedat DATETIME(3), delid INT DEFAULT 0, - seqid INT NOT NULL, + seqid BIGINT NOT NULL, topic CHAR(25) NOT NULL,` + "`from` BIGINT NOT NULL," + `client_msg_id VARCHAR(64), @@ -845,7 +845,7 @@ func (a *adapter) CreateDb(reset bool) error { createdat DATETIME(3) NOT NULL, updatedat DATETIME(3) NOT NULL, topic CHAR(25) NOT NULL, - seqid INT NOT NULL, + seqid BIGINT NOT NULL, version INT NOT NULL, actor BIGINT NOT NULL, head JSON, @@ -1050,7 +1050,7 @@ func createIMCallTables(tx *sql.Tx) error { id BIGINT NOT NULL AUTO_INCREMENT, call_id VARCHAR(64) NOT NULL, topic CHAR(25) NOT NULL, - seq_id INT NOT NULL, + seq_id BIGINT NOT NULL, caller_id BIGINT NOT NULL, callee_id BIGINT NOT NULL, call_type VARCHAR(16) NOT NULL, @@ -1403,7 +1403,7 @@ func createIMUserProductTables(tx *sql.Tx) error { target_type VARCHAR(16) NOT NULL, target_user_id BIGINT NOT NULL DEFAULT 0, target_topic VARCHAR(32) NOT NULL DEFAULT '', - target_seq INT NOT NULL DEFAULT 0, + target_seq BIGINT NOT NULL DEFAULT 0, reason_code VARCHAR(64) NOT NULL, description TEXT NOT NULL, evidence_file_ids JSON, @@ -1481,7 +1481,7 @@ func upgradeIMMessageActions(a *adapter) error { createdat DATETIME(3) NOT NULL, updatedat DATETIME(3) NOT NULL, topic CHAR(25) NOT NULL, - seqid INT NOT NULL, + seqid BIGINT NOT NULL, version INT NOT NULL, actor BIGINT NOT NULL, head JSON, @@ -1554,7 +1554,7 @@ func createIMRelationTables(exec sqlExecutor) error { user_id BIGINT NOT NULL, item_type VARCHAR(16) NOT NULL, source_topic CHAR(25) NOT NULL, - source_seq INT NOT NULL, + source_seq BIGINT NOT NULL, snapshot JSON, attachment_refs JSON, availability VARCHAR(16) NOT NULL DEFAULT 'available', diff --git a/server/db/mysql/group_schema.go b/server/db/mysql/group_schema.go index 6b2c2a6..86a5c7e 100644 --- a/server/db/mysql/group_schema.go +++ b/server/db/mysql/group_schema.go @@ -23,6 +23,26 @@ func ensureIMGroupBusinessTables(exec sqlExecutor) error { INDEX idx_im_group_settings_owner(owner_user_id, group_status), INDEX 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_group_messages ( + id BIGINT NOT NULL AUTO_INCREMENT, topic VARCHAR(64) NOT NULL, group_id BIGINT NOT NULL, + seqid BIGINT NOT NULL, from_user BIGINT NOT NULL, client_msg_id VARCHAR(64), + msg_type VARCHAR(32) NOT NULL DEFAULT 'text', status VARCHAR(32) NOT NULL DEFAULT 'normal', + content JSON, head JSON, mentioned_user_ids JSON, mention_all TINYINT(1) NOT NULL DEFAULT 0, + reply_seqid BIGINT, root_seqid BIGINT, file_count INT NOT NULL DEFAULT 0, + content_size INT NOT NULL DEFAULT 0, has_risk TINYINT(1) NOT NULL DEFAULT 0, + audit_status VARCHAR(32) NOT NULL DEFAULT 'pass', version INT NOT NULL DEFAULT 1, + recalled_at DATETIME(3), recalled_by BIGINT, deleted_at DATETIME(3), expires_at DATETIME(3), + created_at DATETIME(3) NOT NULL, updated_at DATETIME(3) NOT NULL, + PRIMARY KEY(id), UNIQUE INDEX uk_im_group_messages_group_seq(group_id, seqid), + UNIQUE INDEX uk_im_group_messages_topic_seq(topic, seqid), + UNIQUE INDEX uk_im_group_messages_topic_sender_client_mid(topic, from_user, client_msg_id), + INDEX idx_im_group_messages_group_created(group_id, created_at), + INDEX idx_im_group_messages_group_status_seq(group_id, status, seqid), + INDEX idx_im_group_messages_topic_created(topic, created_at), + INDEX idx_im_group_messages_topic_from_seq(topic, from_user, seqid), + INDEX idx_im_group_messages_audit(audit_status, created_at), + INDEX idx_im_group_messages_expires(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', @@ -105,7 +125,7 @@ func ensureIMGroupBusinessTables(exec sqlExecutor) error { ) 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 INT NOT NULL DEFAULT 0, + 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, deleted_at DATETIME(3), diff --git a/server/db/mysql/tenant_adapter.go b/server/db/mysql/tenant_adapter.go index 77b6643..a5c23c9 100644 --- a/server/db/mysql/tenant_adapter.go +++ b/server/db/mysql/tenant_adapter.go @@ -92,6 +92,56 @@ func finalizeTenantMessage(id t.TenantID, msg t.Message, recalledBy sql.NullInt6 return msg } +func normalizeGroupMessage(id t.TenantID, msg t.Message, recalledBy sql.NullInt64) t.Message { + msg.TenantID = id + msg.From = common.EncodeUidString(msg.From).String() + if recalledBy.Valid { + msg.RecalledBy = common.EncodeUidString(strconv.FormatInt(recalledBy.Int64, 10)).String() + } + msg.Content = common.FromJSON(msg.Content) + return msg +} + +func messageHeadType(head t.KVMap) string { + if head == nil { + return "text" + } + for _, key := range []string{"x-im-type", "type"} { + if value, ok := head[key].(string); ok { + value = strings.TrimSpace(value) + if value != "" { + return value + } + } + } + return "text" +} + +func messageHeadJSON(head t.KVMap, key string) []byte { + if head == nil { + return nil + } + if value, ok := head[key]; ok { + return common.ToJSON(value) + } + return nil +} + +func messageHeadBool(head t.KVMap, key string) bool { + if head == nil { + return false + } + switch value := head[key].(type) { + case bool: + return value + case string: + parsed, _ := strconv.ParseBool(value) + return parsed + default: + return false + } +} + func stampTenantFile(id t.TenantID, file *t.FileDef) *t.FileDef { if file != nil { file.TenantID = id @@ -1105,6 +1155,63 @@ func tenantMessageDeleteList(tx *sqlx.Tx, tenantID t.TenantID, topic string, toD return nil } +func groupMessageDeleteList(tx *sqlx.Tx, topic string, toDel *t.DelMessage) error { + var err error + if toDel == nil { + if _, err = tx.Exec("DELETE FROM im_dellog WHERE topic=?", topic); err != nil { + return err + } + _, err = tx.Exec("DELETE FROM im_group_messages WHERE topic=?", topic) + return err + } + delRanges := toDel.SeqIdRanges + if toDel.DeletedFor == "" { + where := "m.topic=?" + args := []any{topic} + if len(delRanges) > 0 { + rSQL, rArgs := common.RangesToSql(delRanges) + where += " AND m.seqid " + rSQL + args = append(args, rArgs...) + } + where += " AND m.deleted_at IS NULL" + if newerThan := toDel.GetNewerThan(); newerThan != nil { + where += " AND m.created_at>?" + args = append(args, newerThan) + } + var seqIDs []int + if err = tx.Select(&seqIDs, "SELECT seqid FROM im_group_messages AS m WHERE "+where, args...); err != nil { + return err + } + if len(seqIDs) == 0 { + return nil + } + sort.Ints(seqIDs) + delRanges = t.SliceToRanges(seqIDs) + rSQL, rArgs := common.RangesToSql(delRanges) + where = "m.topic=? AND m.seqid " + rSQL + args = append([]any{topic}, rArgs...) + if _, err = tx.Exec("UPDATE im_group_messages AS m SET m.updated_at=?,m.deleted_at=?,m.status='deleted',m.from_user=0,m.head=NULL,m.content=NULL,m.content_size=0 WHERE "+ + where, append([]any{t.TimeNow(), t.TimeNow()}, args...)...); err != nil { + return err + } + } + stmt, err := tx.Prepare("INSERT INTO im_dellog(topic,deletedfor,delid,low,hi) VALUES(?,?,?,?,?)") + if err != nil { + return err + } + defer stmt.Close() + forUser := common.DecodeUidString(toDel.DeletedFor) + for _, rng := range delRanges { + if rng.Hi == 0 { + rng.Hi = rng.Low + 1 + } + if _, err = stmt.Exec(topic, forUser, toDel.DelId, rng.Low, rng.Hi); err != nil { + return err + } + } + return nil +} + func (a *adapter) TenantTopicDelete(id t.TenantID, topic string, isChan, hard bool) error { tenantAdp, err := a.routedTenantAdapter(id) if err != nil || topic == "" { @@ -1138,7 +1245,12 @@ func (a *adapter) TenantTopicDelete(id t.TenantID, topic string, isChan, hard bo if _, err = tx.Exec(tx.Rebind(q), args...); err != nil { return err } - if err = tenantMessageDeleteList(tx, id, topic, nil); err != nil { + if t.GetTopicCat(topic) == t.TopicCatGrp { + err = groupMessageDeleteList(tx, topic, nil) + } else { + err = tenantMessageDeleteList(tx, id, topic, nil) + } + if err != nil { return err } if _, err = tx.Exec("DELETE FROM im_topictags WHERE tenant_id=? AND topic=?", id, topic); err != nil { @@ -1652,6 +1764,42 @@ func (a *adapter) TenantMessageSave(id t.TenantID, msg *t.Message) error { return err } +func (a *adapter) TenantGroupMessageSave(id t.TenantID, msg *t.Message) error { + tenantAdp, err := a.routedTenantAdapter(id) + if err != nil || msg == nil || msg.TenantID != id || msg.Topic == "" || msg.SeqId <= 0 { + return t.ErrMalformed + } + if tenantAdp != a { + return tenantAdp.TenantGroupMessageSave(id, msg) + } + ctx, cancel := a.getContext() + if cancel != nil { + defer cancel() + } + groupID, err := a.groupIDByTopic(ctx, msg.Topic) + if err != nil { + return err + } + clientMsgID := sql.NullString{String: msg.ClientMsgID, Valid: msg.ClientMsgID != ""} + contentJSON := common.ToJSON(msg.Content) + res, err := a.db.ExecContext(ctx, + `INSERT INTO im_group_messages + (topic,group_id,seqid,from_user,client_msg_id,msg_type,status,content,head,mentioned_user_ids,mention_all, + file_count,content_size,version,created_at,updated_at) + VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, + msg.Topic, groupID, msg.SeqId, decodeUID(t.ParseUid(msg.From)), clientMsgID, messageHeadType(msg.Head), "normal", + contentJSON, msg.Head, messageHeadJSON(msg.Head, "mentioned_user_ids"), messageHeadBool(msg.Head, "mention_all"), + 0, len(contentJSON), messageVersion(msg.Version), msg.CreatedAt, msg.UpdatedAt) + if err == nil { + lastID, _ := res.LastInsertId() + msg.SetUid(t.Uid(lastID)) + } + if isDupe(err) { + return t.ErrDuplicate + } + return err +} + func (a *adapter) TenantMessageGetByClientID(id t.TenantID, from t.Uid, clientMsgID string) (*t.Message, error) { if !a.tenantScopedDB { tenantAdp, err := a.routedTenantAdapter(id) @@ -1687,7 +1835,40 @@ func (a *adapter) TenantMessageGetByClientID(id t.TenantID, from t.Uid, clientMs return &msg, nil } -func (a *adapter) TenantMessageGetOne(id t.TenantID, topic string, seq int) (*t.Message, error) { +func (a *adapter) TenantGroupMessageGetByClientID(id t.TenantID, topic string, from t.Uid, clientMsgID string) (*t.Message, error) { + tenantAdp, err := a.routedTenantAdapter(id) + if err != nil { + return nil, err + } + if tenantAdp != a { + return tenantAdp.TenantGroupMessageGetByClientID(id, topic, from, clientMsgID) + } + if topic == "" || from.IsZero() || clientMsgID == "" { + return nil, t.ErrMalformed + } + ctx, cancel := a.getContext() + if cancel != nil { + defer cancel() + } + row := struct { + t.Message + RecalledBy sql.NullInt64 `db:"recalled_by"` + }{} + err = a.db.QueryRowxContext(ctx, + "SELECT id,created_at AS createdat,updated_at AS updatedat,deleted_at AS deletedat,0 AS delid,seqid,topic,from_user AS `from`,client_msg_id,version,recalled_at,recalled_by,head,content "+ + "FROM im_group_messages WHERE topic=? AND from_user=? AND client_msg_id=?", + topic, decodeUID(from), clientMsgID).StructScan(&row) + if err == sql.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, err + } + msg := normalizeGroupMessage(id, row.Message, row.RecalledBy) + return &msg, nil +} + +func (a *adapter) TenantMessageGetOne(id t.TenantID, topic string, seq int64) (*t.Message, error) { if !a.tenantScopedDB { tenantAdp, err := a.routedTenantAdapter(id) if err != nil { @@ -1711,7 +1892,29 @@ func (a *adapter) TenantMessageGetOne(id t.TenantID, topic string, seq int) (*t. return msg, err } -func (a *adapter) TenantMessageRecall(id t.TenantID, topic string, seq int, actor t.Uid, expectedVersion int, recalledAt time.Time) (*t.Message, error) { +func (a *adapter) TenantGroupMessageGetOne(id t.TenantID, topic string, seq int64) (*t.Message, error) { + tenantAdp, err := a.routedTenantAdapter(id) + if err != nil { + return nil, err + } + if tenantAdp != a { + return tenantAdp.TenantGroupMessageGetOne(id, topic, seq) + } + if topic == "" || seq <= 0 { + return nil, t.ErrMalformed + } + ctx, cancel := a.getContext() + if cancel != nil { + defer cancel() + } + msg, err := tenantGroupMessageGetOne(a.db, ctx, id, topic, seq, "") + if err == sql.ErrNoRows { + return nil, t.ErrNotFound + } + return msg, err +} + +func (a *adapter) TenantMessageRecall(id t.TenantID, topic string, seq int64, actor t.Uid, expectedVersion int, recalledAt time.Time) (*t.Message, error) { if !a.tenantScopedDB { tenantAdp, err := a.routedTenantAdapter(id) if err != nil { @@ -1763,7 +1966,57 @@ func (a *adapter) TenantMessageRecall(id t.TenantID, topic string, seq int, acto return a.TenantMessageGetOne(id, topic, seq) } -func (a *adapter) TenantMessageEdit(id t.TenantID, topic string, seq int, actor t.Uid, expectedVersion int, head t.KVMap, content any, reason string, editedAt time.Time) (*t.Message, error) { +func (a *adapter) TenantGroupMessageRecall(id t.TenantID, topic string, seq int64, actor t.Uid, expectedVersion int, recalledAt time.Time) (*t.Message, error) { + tenantAdp, err := a.routedTenantAdapter(id) + if err != nil { + return nil, err + } + if tenantAdp != a { + return tenantAdp.TenantGroupMessageRecall(id, topic, seq, actor, expectedVersion, recalledAt) + } + if topic == "" || seq <= 0 || actor.IsZero() || expectedVersion <= 0 { + return nil, t.ErrMalformed + } + ctx, cancel := a.getContextForTx() + if cancel != nil { + defer cancel() + } + tx, err := a.db.BeginTxx(ctx, nil) + if err != nil { + return nil, err + } + defer func() { + if err != nil { + tx.Rollback() + } + }() + msg, err := tenantGroupMessageGetOne(tx, ctx, id, topic, seq, " FOR UPDATE") + if err != nil { + if err == sql.ErrNoRows { + return nil, t.ErrNotFound + } + return nil, err + } + if msg.Version != expectedVersion || msg.RecalledAt != nil || msg.DeletedAt != nil { + return nil, t.ErrDuplicate + } + _, err = tx.ExecContext(ctx, + "UPDATE im_group_messages SET updated_at=?,version=version+1,status='recalled',recalled_at=?,recalled_by=?,head=NULL,content=NULL WHERE topic=? AND seqid=? AND version=?", + recalledAt, recalledAt, decodeUID(actor), topic, seq, expectedVersion) + if err != nil { + return nil, err + } + if err = insertBusinessEventTx(tx, t.StableEventID("message", topic, seq, expectedVersion+1, "recalled"), + "topic", topic, actor, "message.recalled", map[string]any{"topic": topic, "seq": seq, "version": expectedVersion + 1}); err != nil { + return nil, err + } + if err = tx.Commit(); err != nil { + return nil, err + } + return a.TenantGroupMessageGetOne(id, topic, seq) +} + +func (a *adapter) TenantMessageEdit(id t.TenantID, topic string, seq int64, actor t.Uid, expectedVersion int, head t.KVMap, content any, reason string, editedAt time.Time) (*t.Message, error) { if !a.tenantScopedDB { tenantAdp, err := a.routedTenantAdapter(id) if err != nil { @@ -1820,6 +2073,62 @@ func (a *adapter) TenantMessageEdit(id t.TenantID, topic string, seq int, actor return a.TenantMessageGetOne(id, topic, seq) } +func (a *adapter) TenantGroupMessageEdit(id t.TenantID, topic string, seq int64, actor t.Uid, expectedVersion int, head t.KVMap, content any, reason string, editedAt time.Time) (*t.Message, error) { + tenantAdp, err := a.routedTenantAdapter(id) + if err != nil { + return nil, err + } + if tenantAdp != a { + return tenantAdp.TenantGroupMessageEdit(id, topic, seq, actor, expectedVersion, head, content, reason, editedAt) + } + if topic == "" || seq <= 0 || actor.IsZero() || expectedVersion <= 0 { + return nil, t.ErrMalformed + } + ctx, cancel := a.getContextForTx() + if cancel != nil { + defer cancel() + } + tx, err := a.db.BeginTxx(ctx, nil) + if err != nil { + return nil, err + } + defer func() { + if err != nil { + tx.Rollback() + } + }() + msg, err := tenantGroupMessageGetOne(tx, ctx, id, topic, seq, " FOR UPDATE") + if err != nil { + if err == sql.ErrNoRows { + return nil, t.ErrNotFound + } + return nil, err + } + if msg.Version != expectedVersion || msg.RecalledAt != nil || msg.DeletedAt != nil { + return nil, t.ErrDuplicate + } + if _, err = tx.ExecContext(ctx, + "INSERT INTO im_message_revisions(createdat,updatedat,topic,seqid,version,actor,head,content,reason) VALUES(?,?,?,?,?,?,?,?,?)", + editedAt, editedAt, topic, seq, msg.Version, decodeUID(actor), msg.Head, common.ToJSON(msg.Content), strings.TrimSpace(reason)); err != nil { + return nil, err + } + contentJSON := common.ToJSON(content) + _, err = tx.ExecContext(ctx, + "UPDATE im_group_messages SET updated_at=?,version=version+1,msg_type=?,head=?,content=?,content_size=? WHERE topic=? AND seqid=? AND version=?", + editedAt, messageHeadType(head), head, contentJSON, len(contentJSON), topic, seq, expectedVersion) + if err != nil { + return nil, err + } + if err = insertBusinessEventTx(tx, t.StableEventID("message", topic, seq, expectedVersion+1, "edited"), + "topic", topic, actor, "message.edited", map[string]any{"topic": topic, "seq": seq, "version": expectedVersion + 1}); err != nil { + return nil, err + } + if err = tx.Commit(); err != nil { + return nil, err + } + return a.TenantGroupMessageGetOne(id, topic, seq) +} + func (a *adapter) TenantMessageGetAll(id t.TenantID, topic string, uid t.Uid, opts *t.QueryOpt) ([]t.Message, error) { tenantAdp, err := a.routedTenantAdapter(id) if err != nil || topic == "" { @@ -1888,6 +2197,69 @@ func (a *adapter) TenantMessageGetAll(id t.TenantID, topic string, uid t.Uid, op return msgs, rows.Err() } +func (a *adapter) TenantGroupMessageGetAll(id t.TenantID, topic string, uid t.Uid, opts *t.QueryOpt) ([]t.Message, error) { + tenantAdp, err := a.routedTenantAdapter(id) + if err != nil || topic == "" { + return nil, t.ErrMalformed + } + if tenantAdp != a { + return tenantAdp.TenantGroupMessageGetAll(id, topic, uid, opts) + } + limit := a.maxMessageResults + args := []any{store.DecodeUid(uid), topic} + seq := "" + if opts != nil { + seq = "AND m.seqid " + if len(opts.IdRanges) > 0 { + constr, addArgs := common.RangesToSql(opts.IdRanges) + seq += constr + args = append(args, addArgs...) + } else { + seq += "BETWEEN ? AND ?" + if opts.Since > 0 { + args = append(args, opts.Since) + } else { + args = append(args, 0) + } + if opts.Before > 1 { + args = append(args, opts.Before-1) + } else { + args = append(args, 1<<31-1) + } + } + if opts.Limit > 0 && opts.Limit < limit { + limit = opts.Limit + } + } + args = append(args, limit) + ctx, cancel := a.getContext() + if cancel != nil { + defer cancel() + } + rows, err := a.db.QueryxContext(ctx, + "SELECT m.id,m.created_at AS createdat,m.updated_at AS updatedat,m.deleted_at AS deletedat,0 AS delid,m.seqid,m.topic,m.from_user AS `from`,m.client_msg_id,m.version,m.recalled_at,m.recalled_by,m.head,m.content "+ + "FROM im_group_messages AS m LEFT JOIN im_dellog AS d "+ + "ON d.topic=m.topic AND m.seqid BETWEEN d.low AND d.hi-1 AND d.deletedfor=? "+ + "WHERE m.status!='deleted' AND m.topic=? "+seq+" AND d.deletedfor IS NULL ORDER BY m.seqid DESC LIMIT ?", + args...) + if err != nil { + return nil, err + } + defer rows.Close() + msgs := make([]t.Message, 0, limit) + for rows.Next() { + row := struct { + t.Message + RecalledBy sql.NullInt64 `db:"recalled_by"` + }{} + if err = rows.StructScan(&row); err != nil { + return nil, err + } + msgs = append(msgs, normalizeGroupMessage(id, row.Message, row.RecalledBy)) + } + return msgs, rows.Err() +} + func (a *adapter) TenantMessageSearch(id t.TenantID, topic string, uid t.Uid, queryText, messageType, cursor string, limit int) ([]t.Message, string, error) { if !a.tenantScopedDB { tenantAdp, err := a.routedTenantAdapter(id) @@ -1951,7 +2323,7 @@ func (a *adapter) TenantMessageSearch(id t.TenantID, topic string, uid t.Uid, qu msg := finalizeTenantMessage(id, row.Message, row.RecalledBy) msg.Head = kvMapFromJSON(msg.Head) if len(msgs) == limit { - nextCursor = strconv.Itoa(msg.SeqId) + nextCursor = strconv.FormatInt(msg.SeqId, 10) continue } msgs = append(msgs, msg) @@ -2001,7 +2373,7 @@ func (a *adapter) TenantCallCreate(id t.TenantID, record *t.CallRecord) error { return err } -func (a *adapter) TenantCallAccept(id t.TenantID, callID string, seq int, acceptedAt time.Time) error { +func (a *adapter) TenantCallAccept(id t.TenantID, callID string, seq int64, acceptedAt time.Time) error { if !a.tenantScopedDB { tenantAdp, err := a.routedTenantAdapter(id) if err != nil { @@ -2026,7 +2398,7 @@ func (a *adapter) TenantCallAccept(id t.TenantID, callID string, seq int, accept return err } -func (a *adapter) TenantCallFinish(id t.TenantID, callID string, seq int, status string, durationMS int64, finishedAt time.Time) error { +func (a *adapter) TenantCallFinish(id t.TenantID, callID string, seq int64, status string, durationMS int64, finishedAt time.Time) error { if !a.tenantScopedDB { tenantAdp, err := a.routedTenantAdapter(id) if err != nil { @@ -2102,7 +2474,7 @@ func (a *adapter) TenantCallList(id t.TenantID, uid t.Uid, topic, cursor string, ID int64 CallID string `db:"call_id"` Topic string - SeqID int `db:"seq_id"` + SeqID int64 `db:"seq_id"` CallerID int64 `db:"caller_id"` CalleeID int64 `db:"callee_id"` CallType string `db:"call_type"` @@ -4509,7 +4881,7 @@ func (a *adapter) abuseReportByIdempotency(ctx context.Context, uid t.Uid, idemp TargetType string `db:"target_type"` TargetUserID int64 `db:"target_user_id"` TargetTopic string `db:"target_topic"` - TargetSeq int `db:"target_seq"` + TargetSeq int64 `db:"target_seq"` ReasonCode string `db:"reason_code"` Description string `db:"description"` EvidenceFileIDs []byte `db:"evidence_file_ids"` @@ -4699,7 +5071,7 @@ func isAllowedWorkbenchRoute(route string) bool { } } -func tenantMessageGetOne(q sqlx.QueryerContext, ctx context.Context, tenantID t.TenantID, topic string, seq int, suffix string) (*t.Message, error) { +func tenantMessageGetOne(q sqlx.QueryerContext, ctx context.Context, tenantID t.TenantID, topic string, seq int64, suffix string) (*t.Message, error) { row := struct { t.Message RecalledBy sql.NullInt64 `db:"recalled_by"` @@ -4721,6 +5093,31 @@ func tenantMessageGetOne(q sqlx.QueryerContext, ctx context.Context, tenantID t. return &msg, nil } +func (a *adapter) groupIDByTopic(ctx context.Context, topic string) (int64, error) { + var groupID int64 + err := a.db.QueryRowContext(ctx, "SELECT id FROM im_group_settings WHERE topic=? LIMIT 1", topic).Scan(&groupID) + if err == sql.ErrNoRows { + return 0, t.ErrNotFound + } + return groupID, err +} + +func tenantGroupMessageGetOne(q sqlx.QueryerContext, ctx context.Context, tenantID t.TenantID, topic string, seq int64, suffix string) (*t.Message, error) { + row := struct { + t.Message + RecalledBy sql.NullInt64 `db:"recalled_by"` + }{} + err := sqlx.GetContext(ctx, q, &row, + "SELECT id,created_at AS createdat,updated_at AS updatedat,deleted_at AS deletedat,0 AS delid,seqid,topic,from_user AS `from`,client_msg_id,version,recalled_at,recalled_by,head,content "+ + "FROM im_group_messages WHERE topic=? AND seqid=?"+suffix, + topic, seq) + if err != nil { + return nil, err + } + msg := normalizeGroupMessage(tenantID, row.Message, row.RecalledBy) + return &msg, nil +} + func messageVersion(version int) int { if version <= 0 { return 1 @@ -5174,6 +5571,33 @@ func (a *adapter) TenantMessageDeleteList(id t.TenantID, topic string, del *t.De return tx.Commit() } +func (a *adapter) TenantGroupMessageDeleteList(id t.TenantID, topic string, del *t.DelMessage) error { + tenantAdp, err := a.routedTenantAdapter(id) + if err != nil || topic == "" || (del != nil && del.TenantID != id) { + return t.ErrMalformed + } + if tenantAdp != a { + return tenantAdp.TenantGroupMessageDeleteList(id, topic, del) + } + ctx, cancel := a.getContextForTx() + if cancel != nil { + defer cancel() + } + tx, err := a.db.BeginTxx(ctx, nil) + if err != nil { + return err + } + defer func() { + if err != nil { + tx.Rollback() + } + }() + if err = groupMessageDeleteList(tx, topic, del); err != nil { + return err + } + return tx.Commit() +} + func (a *adapter) TenantMessageGetDeleted(id t.TenantID, topic string, uid t.Uid, opts *t.QueryOpt) ([]t.DelMessage, error) { tenantAdp, err := a.routedTenantAdapter(id) if err != nil || topic == "" { diff --git a/server/db/mysql/tests/mysql_test.go b/server/db/mysql/tests/mysql_test.go index 8499b0b..c9abe36 100644 --- a/server/db/mysql/tests/mysql_test.go +++ b/server/db/mysql/tests/mysql_test.go @@ -1201,7 +1201,7 @@ func TestTopicUpdateOnMessage(t *testing.T) { } var got struct { TouchedAt time.Time - SeqId int + SeqId int64 } err = db.QueryRow("SELECT touchedat, seqid FROM im_topics WHERE name=?", testData.Topics[2].Id). Scan(&got.TouchedAt, &got.SeqId) diff --git a/server/db/postgres/tests/postgres_test.go b/server/db/postgres/tests/postgres_test.go index 57dae1d..c4c2078 100644 --- a/server/db/postgres/tests/postgres_test.go +++ b/server/db/postgres/tests/postgres_test.go @@ -830,7 +830,7 @@ func TestTopicUpdateOnMessage(t *testing.T) { } var got struct { TouchedAt time.Time - SeqId int + SeqId int64 } err = db.QueryRow(ctx, "SELECT touchedat, seqid FROM im_topics WHERE name=$1", testData.Topics[2].Id). Scan(&got.TouchedAt, &got.SeqId) diff --git a/server/db/rethinkdb/tests/rethink_test.go b/server/db/rethinkdb/tests/rethink_test.go index a3e5995..cdf832c 100644 --- a/server/db/rethinkdb/tests/rethink_test.go +++ b/server/db/rethinkdb/tests/rethink_test.go @@ -844,7 +844,7 @@ func TestTopicUpdateOnMessage(t *testing.T) { var got struct { TouchedAt time.Time - SeqId int + SeqId int64 } if err = cursor.One(&got); err != nil { t.Fatal(err) diff --git a/server/im_http.go b/server/im_http.go index 86c2114..3ab214b 100644 --- a/server/im_http.go +++ b/server/im_http.go @@ -60,6 +60,7 @@ const ( imVerificationExpiresInSeconds = 300 imVerificationResendAfterSeconds = 60 imVerificationMaxAttempts = 5 + imVerificationCreateRateWindow = time.Hour imActionTokenExpiresInSeconds = 300 imIndependentPasswordMaxAttempts = 5 imIndependentPasswordLockSeconds = 300 @@ -380,7 +381,7 @@ type imMessageRevisionRequest struct { type imFavoritesBatchCreateRequest struct { Messages []struct { Topic string `json:"topic"` - Seq int `json:"seq,string"` + Seq int64 `json:"seq,string"` } `json:"messages"` } @@ -479,7 +480,7 @@ type imGroupFileCreateRequest struct { FileType string `json:"file_type,omitempty"` FileSize int64 `json:"file_size,omitempty"` SourceTopic string `json:"source_topic,omitempty"` - SourceSeq int `json:"source_seq,omitempty"` + SourceSeq int64 `json:"source_seq,omitempty"` } type imGroupAnnouncementPutRequest struct { @@ -874,6 +875,18 @@ func serveIMVerificationCodes(wrt http.ResponseWriter, req *http.Request) { now := time.Now().UTC().Round(time.Millisecond) phoneHash := hashIMSecret(tenant.ID, scene, phone, "phone") + if allowed, _, ttl, err := store.AllowRate(tenant.ID, "verification:create:"+scene+":"+phoneHash, imVerificationMaxAttempts, imVerificationCreateRateWindow); err != nil { + logs.Warn.Println("im verification create: redis rate limit failed", tenant.Code, scene, err) + } else if !allowed { + retryAfter := int(ttl.Seconds()) + if retryAfter < 1 { + retryAfter = int(imVerificationCreateRateWindow.Seconds()) + } + writeIMError(wrt, req, http.StatusTooManyRequests, 30020, imReasonVerificationRate, "verification rate limited", map[string]any{ + "retry_after_seconds": retryAfter, + }) + return + } active, err := store.Verifications.GetActive(tenant.ID, scene, phoneHash, now) if err != nil && !errors.Is(err, types.ErrNotFound) { logs.Warn.Println("im verification create: active lookup failed", tenant.Code, scene, err) @@ -3342,7 +3355,7 @@ func serveIMMessageContext(wrt http.ResponseWriter, req *http.Request) { } before := parseIMLimit(req.URL.Query().Get("before"), 20, 100) after := parseIMLimit(req.URL.Query().Get("after"), 20, 100) - msgs, err := store.Messages.GetAll(principal.TenantID, topic, principal.Uid, &types.QueryOpt{Since: seq - before, Before: seq + after + 1, Limit: before + after + 1}) + msgs, err := store.Messages.GetAll(principal.TenantID, topic, principal.Uid, &types.QueryOpt{Since: seq - int64(before), Before: seq + int64(after) + 1, Limit: before + after + 1}) if err != nil { writeIMRelationError(wrt, req, err) return @@ -3599,7 +3612,7 @@ func serveIMConversationContext(wrt http.ResponseWriter, req *http.Request) { writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil) return } - peer := peerFromP2PTopic(topic, principal.Uid) + topic, peer := normalizeP2PConversationTopic(topic, principal.Uid) if topic == "" || peer.IsZero() { writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "topic"}) return @@ -3617,6 +3630,7 @@ func serveIMConversationSettings(wrt http.ResponseWriter, req *http.Request, pri writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "topic"}) return } + topic = normalizeConversationSettingsTopic(topic, principal.Uid) switch req.Method { case http.MethodGet: settings, err := store.Conversations.GetSettings(principal.TenantID, principal.Uid, topic) @@ -4043,6 +4057,11 @@ func serveIMGroupMembers(wrt http.ResponseWriter, req *http.Request, topic strin roleFilter := strings.TrimSpace(req.URL.Query().Get("role")) eligibleRole := strings.TrimSpace(req.URL.Query().Get("eligible_role")) muteEligible := parseIMBool(req.URL.Query().Get("mute_eligible")) + cacheKey := groupMemberPageCacheKey(topic, principal.Uid, selfAccess, query, roleFilter, eligibleRole, limit, cursor, mentionableOnly, muteEligible) + if cached, ok := store.GetCachedGroupMemberPage(principal.TenantID, cacheKey); ok { + writeIMSuccess(wrt, req, cached) + return + } members, err := store.Topics.GetUsers(principal.TenantID, topic, &types.QueryOpt{Limit: 1000}) if err != nil { writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil) @@ -4093,12 +4112,14 @@ func serveIMGroupMembers(wrt http.ResponseWriter, req *http.Request, topic strin permissions := map[string]any{ "can_mention_all": selfAccess.IsAdmin(), } - writeIMSuccess(wrt, req, map[string]any{ + payload := map[string]any{ "topic": topic, "items": items, "next_cursor": nextCursor, "permissions": permissions, - }) + } + store.CacheGroupMemberPage(principal.TenantID, cacheKey, payload) + writeIMSuccess(wrt, req, payload) } func serveIMGroupDetail(wrt http.ResponseWriter, req *http.Request, topic string) { @@ -5767,6 +5788,18 @@ func currentUserPhone(tenantID types.TenantID, uid types.Uid) (string, error) { func createSensitiveChallenge(wrt http.ResponseWriter, req *http.Request, principal *imPrincipal, action, phone string) { now := time.Now().UTC().Round(time.Millisecond) phoneHash := hashIMSecret(principal.TenantID, "sensitive_action", phone, "phone") + if allowed, _, ttl, err := store.AllowRate(principal.TenantID, "verification:create:sensitive_action:"+phoneHash, imVerificationMaxAttempts, imVerificationCreateRateWindow); err != nil { + logs.Warn.Println("im sensitive verification create: redis rate limit failed", principal.TenantID, err) + } else if !allowed { + retryAfter := int(ttl.Seconds()) + if retryAfter < 1 { + retryAfter = int(imVerificationCreateRateWindow.Seconds()) + } + writeIMError(wrt, req, http.StatusTooManyRequests, 30020, imReasonVerificationRate, "verification rate limited", map[string]any{ + "retry_after_seconds": retryAfter, + }) + return + } active, err := store.Verifications.GetActive(principal.TenantID, "sensitive_action", phoneHash, now) if err != nil && !errors.Is(err, types.ErrNotFound) { writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil) @@ -6712,7 +6745,23 @@ func groupMemberMatches(item map[string]any, query string) bool { return false } -func authorizeIMMessageMutation(principal *imPrincipal, topic string, seq int, allowAdmin bool) (*types.Message, bool, error) { +func groupMemberPageCacheKey(topic string, uid types.Uid, access types.AccessMode, query, roleFilter, eligibleRole string, + limit, cursor int, mentionableOnly, muteEligible bool) string { + return strings.Join([]string{ + topic, + uid.UserId(), + access.String(), + strings.ToLower(strings.TrimSpace(query)), + strings.TrimSpace(roleFilter), + strings.TrimSpace(eligibleRole), + strconv.Itoa(limit), + strconv.Itoa(cursor), + strconv.FormatBool(mentionableOnly), + strconv.FormatBool(muteEligible), + }, "|") +} + +func authorizeIMMessageMutation(principal *imPrincipal, topic string, seq int64, allowAdmin bool) (*types.Message, bool, error) { if principal == nil { return nil, false, types.ErrPermissionDenied } @@ -8385,7 +8434,7 @@ func cleanTenantCodeHint(code string) string { return code } -func normalizeIMAbuseTarget(tenantID types.TenantID, reporter types.Uid, target types.AbuseTarget) (string, types.Uid, string, int, map[string]any, error) { +func normalizeIMAbuseTarget(tenantID types.TenantID, reporter types.Uid, target types.AbuseTarget) (string, types.Uid, string, int64, map[string]any, error) { targetType := strings.ToLower(strings.TrimSpace(target.Type)) snapshot := map[string]any{"type": targetType} switch targetType { @@ -8413,7 +8462,7 @@ func normalizeIMAbuseTarget(tenantID types.TenantID, reporter types.Uid, target return targetType, 0, topic, 0, snapshot, nil case "message": topic := strings.TrimSpace(target.Topic) - seq, err := strconv.Atoi(strings.TrimSpace(target.Seq)) + seq, err := strconv.ParseInt(strings.TrimSpace(target.Seq), 10, 64) if topic == "" || seq <= 0 || err != nil { return "", 0, "", 0, nil, types.ErrMalformed } @@ -8476,7 +8525,7 @@ func groupInvitationResourcePath(path string) (string, string, bool) { return strings.TrimSpace(invitationID), strings.Trim(tail, "/"), true } -func messageReceiptsPath(path string) (string, int, bool) { +func messageReceiptsPath(path string) (string, int64, bool) { const marker = "/v1/im/topics/" idx := strings.Index(path, marker) if idx < 0 { @@ -8491,7 +8540,7 @@ func messageReceiptsPath(path string) (string, int, bool) { if !ok || suffix != "receipts" { return "", 0, false } - seq, err := strconv.Atoi(seqValue) + seq, err := strconv.ParseInt(seqValue, 10, 64) if err != nil { return topic, 0, true } @@ -8531,7 +8580,7 @@ func topicFromPathWithSuffix(path, prefix, suffix string) string { return strings.Trim(rest, "/") } -func messageContextPath(path string) (string, int, bool) { +func messageContextPath(path string) (string, int64, bool) { const prefix = "/v1/im/topics/" idx := strings.Index(path, prefix) if idx < 0 { @@ -8542,14 +8591,14 @@ func messageContextPath(path string) (string, int, bool) { if len(parts) != 4 || parts[1] != "messages" || parts[3] != "context" { return "", 0, false } - seq, err := strconv.Atoi(parts[2]) + seq, err := strconv.ParseInt(parts[2], 10, 64) if err != nil || seq <= 0 { return "", 0, false } return parts[0], seq, true } -func messageActionPath(path, action string) (string, int, bool) { +func messageActionPath(path, action string) (string, int64, bool) { const marker = "/v1/im/topics/" idx := strings.Index(path, marker) if idx < 0 { @@ -8564,7 +8613,7 @@ func messageActionPath(path, action string) (string, int, bool) { if !ok || suffix != action { return "", 0, false } - seq, err := strconv.Atoi(seqValue) + seq, err := strconv.ParseInt(seqValue, 10, 64) if err != nil { return topic, 0, true } @@ -8756,3 +8805,29 @@ func peerFromP2PTopic(topic string, uid types.Uid) types.Uid { } return types.ZeroUid } + +func normalizeP2PConversationTopic(topic string, uid types.Uid) (string, types.Uid) { + topic = strings.TrimSpace(topic) + if len(topic) < 3 || uid.IsZero() { + return "", types.ZeroUid + } + if types.GetTopicCat(topic) == types.TopicCatMe { + peer := types.ParseUserId(topic) + if peer.IsZero() || peer == uid { + return "", types.ZeroUid + } + return uid.P2PName(peer), peer + } + peer := peerFromP2PTopic(topic, uid) + if peer.IsZero() { + return "", types.ZeroUid + } + return topic, peer +} + +func normalizeConversationSettingsTopic(topic string, uid types.Uid) string { + if normalized, peer := normalizeP2PConversationTopic(topic, uid); !peer.IsZero() { + return normalized + } + return strings.TrimSpace(topic) +} diff --git a/server/im_http_test.go b/server/im_http_test.go index 7df3a69..a1e0a9e 100644 --- a/server/im_http_test.go +++ b/server/im_http_test.go @@ -861,11 +861,11 @@ func (s *testCallsStore) Create(_ types.TenantID, _ *types.CallRecord) error { return nil } -func (s *testCallsStore) Accept(_ types.TenantID, _ string, _ int, _ time.Time) error { +func (s *testCallsStore) Accept(_ types.TenantID, _ string, _ int64, _ time.Time) error { return nil } -func (s *testCallsStore) Finish(_ types.TenantID, _ string, _ int, _ string, _ int64, _ time.Time) error { +func (s *testCallsStore) Finish(_ types.TenantID, _ string, _ int64, _ string, _ int64, _ time.Time) error { return nil } @@ -885,6 +885,7 @@ func (s *testConversationsStore) EnsureP2P(_ types.TenantID, uid, peer types.Uid } func (s *testConversationsStore) GetSettings(_ types.TenantID, _ types.Uid, topic string) (*types.ConversationSettings, error) { + s.topic = topic if s.settings == nil { now := time.Now().UTC().Round(time.Millisecond) s.settings = &types.ConversationSettings{Topic: topic, Pinned: false, Muted: false, MessageRetention: "default", Version: 1, CreatedAt: now, UpdatedAt: now} @@ -3770,7 +3771,7 @@ func TestServeIMFavoriteBatchCreateCountsExisting(t *testing.T) { store.Messages = oldMessages }) - messages.EXPECT().GetOne(types.TenantID(7), "grpAlpha", 3).Return(&types.Message{ + messages.EXPECT().GetOne(types.TenantID(7), "grpAlpha", int64(3)).Return(&types.Message{ TenantID: 7, Topic: "grpAlpha", SeqId: 3, @@ -4307,7 +4308,7 @@ func TestServeIMMessageRecall(t *testing.T) { ModeWant: types.ModeCFull, ModeGiven: types.ModeCFull, }, nil) - messages.EXPECT().GetOne(types.TenantID(7), "grpRecall", 9).Return(&types.Message{ + messages.EXPECT().GetOne(types.TenantID(7), "grpRecall", int64(9)).Return(&types.Message{ TenantID: 7, SeqId: 9, Topic: "grpRecall", @@ -4315,7 +4316,7 @@ func TestServeIMMessageRecall(t *testing.T) { Version: 1, ObjHeader: types.ObjHeader{CreatedAt: time.Now().Add(-time.Second)}, }, nil) - messages.EXPECT().Recall(types.TenantID(7), "grpRecall", 9, types.Uid(42), 1, gomock.Any()).Return(&types.Message{ + messages.EXPECT().Recall(types.TenantID(7), "grpRecall", int64(9), types.Uid(42), 1, gomock.Any()).Return(&types.Message{ TenantID: 7, SeqId: 9, Topic: "grpRecall", @@ -4363,7 +4364,7 @@ func TestServeIMMessageRevision(t *testing.T) { ModeWant: types.ModeCFull, ModeGiven: types.ModeCFull, }, nil) - messages.EXPECT().GetOne(types.TenantID(7), "grpEdit", 3).Return(&types.Message{ + messages.EXPECT().GetOne(types.TenantID(7), "grpEdit", int64(3)).Return(&types.Message{ TenantID: 7, SeqId: 3, Topic: "grpEdit", @@ -4373,7 +4374,7 @@ func TestServeIMMessageRevision(t *testing.T) { Content: "old", ObjHeader: types.ObjHeader{CreatedAt: time.Now().Add(-time.Second)}, }, nil) - messages.EXPECT().Edit(types.TenantID(7), "grpEdit", 3, types.Uid(42), 1, gomock.Any(), "new", "", gomock.Any()).Return(&types.Message{ + messages.EXPECT().Edit(types.TenantID(7), "grpEdit", int64(3), types.Uid(42), 1, gomock.Any(), "new", "", gomock.Any()).Return(&types.Message{ TenantID: 7, SeqId: 3, Topic: "grpEdit", @@ -4431,6 +4432,31 @@ func TestServeIMConversationContext(t *testing.T) { } } +func TestServeIMConversationContextAcceptsUserTopic(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + expectIMBearer(t, ctrl, "token-1", 7, 1) + oldRelations := store.Relations + peer := types.Uid(2) + topic := types.Uid(1).P2PName(peer) + store.Relations = &testRelationsStore{} + defer func() { store.Relations = oldRelations }() + + req := httptest.NewRequest(http.MethodGet, "/v1/im/conversations/"+peer.UserId()+"/context", nil) + req.Header.Set("Authorization", "Bearer token-1") + rec := httptest.NewRecorder() + + serveIMConversationContext(rec, req) + + if rec.Code != http.StatusOK { + 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"] != topic || data["peer_user_id"] != peer.UserId() { + t.Fatalf("unexpected context response: %#v", data) + } +} + func TestServeIMP2PConversation(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() @@ -4487,6 +4513,34 @@ func TestServeIMConversationSettings(t *testing.T) { } } +func TestServeIMConversationSettingsNormalizesUserTopic(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + expectIMBearer(t, ctrl, "token-1", 7, 1) + oldConversations := store.Conversations + peer := types.Uid(2) + topic := types.Uid(1).P2PName(peer) + conversations := &testConversationsStore{} + store.Conversations = conversations + defer func() { store.Conversations = oldConversations }() + + req := httptest.NewRequest(http.MethodGet, "/v1/im/conversations/"+peer.UserId()+"/settings", nil) + req.Header.Set("Authorization", "Bearer token-1") + rec := httptest.NewRecorder() + serveIMConversationContext(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status: got %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + if conversations.topic != topic { + t.Fatalf("settings topic: got %q, want %q", conversations.topic, topic) + } + data := decodeIMHTTPResponse(t, rec).Data.(map[string]any) + if data["topic"] != topic { + t.Fatalf("unexpected settings response: %#v", data) + } +} + func TestServeIMTopicDraftPutAndGet(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() diff --git a/server/main.go b/server/main.go index 7ec13f6..5942441 100644 --- a/server/main.go +++ b/server/main.go @@ -11,6 +11,7 @@ package main //go:generate protoc --go_out=../pbx --go_opt=paths=source_relative --go-grpc_out=../pbx --go-grpc_opt=paths=source_relative ../pbx/model.proto import ( + "context" "encoding/json" "flag" "net/http" @@ -676,6 +677,7 @@ func main() { globals.sessionStore = NewSessionStore(idleSessionTimeout + 15*time.Second) // The hub (the main message router) globals.hub = newHub() + startRedisGroupFanout(context.Background()) stopExternalBusinessEvents := startIMExternalBusinessEventPoller() defer stopExternalBusinessEvents() diff --git a/server/pbconverter.go b/server/pbconverter.go index 958b6c4..c6c0a15 100644 --- a/server/pbconverter.go +++ b/server/pbconverter.go @@ -41,7 +41,7 @@ func pbServDataSerialize(data *MsgServerData) *pbx.ServerMsg_Data { FromUserId: data.From, Timestamp: timeToInt64(&data.Timestamp), DeletedAt: timeToInt64(data.DeletedAt), - SeqId: int32(data.SeqId), + SeqId: data.SeqId, Head: interfaceMapToByteMap(data.Head), Content: interfaceToBytes(data.Content), }, @@ -86,7 +86,7 @@ func pbServPresSerialize(pres *MsgServerPres) *pbx.ServerMsg_Pres { Src: pres.Src, What: what, UserAgent: pres.UserAgent, - SeqId: int32(pres.SeqId), + SeqId: pres.SeqId, DelId: int32(pres.DelId), DelSeq: pbDelQuerySerialize(pres.DelSeq), TargetUserId: pres.AcsTarget, @@ -103,7 +103,7 @@ func pbServInfoSerialize(info *MsgServerInfo) *pbx.ServerMsg_Info { FromUserId: info.From, Src: info.Src, What: pbInfoNoteWhatSerialize(info.What), - SeqId: int32(info.SeqId), + SeqId: info.SeqId, Event: pbCallEventSerialize(info.Event), Payload: info.Payload, }, @@ -165,7 +165,7 @@ func pbServDeserialize(pkt *pbx.ServerMsg) *ServerComMessage { From: data.GetFromUserId(), Timestamp: *tsptr, DeletedAt: int64ToTime(data.GetDeletedAt()), - SeqId: int(data.GetSeqId()), + SeqId: data.GetSeqId(), Head: byteMapToInterfaceMap(data.GetHead()), Content: data.GetContent(), } @@ -204,7 +204,7 @@ func pbServDeserialize(pkt *pbx.ServerMsg) *ServerComMessage { Src: pres.GetSrc(), What: what, UserAgent: pres.GetUserAgent(), - SeqId: int(pres.GetSeqId()), + SeqId: pres.GetSeqId(), DelId: int(pres.GetDelId()), DelSeq: pbDelQueryDeserialize(pres.GetDelSeq()), AcsTarget: pres.GetTargetUserId(), @@ -217,7 +217,7 @@ func pbServDeserialize(pkt *pbx.ServerMsg) *ServerComMessage { Src: info.GetSrc(), From: info.GetFromUserId(), What: pbInfoNoteWhatDeserialize(info.GetWhat()), - SeqId: int(info.GetSeqId()), + SeqId: info.GetSeqId(), Event: pbCallEventDeserialize(info.GetEvent()), Payload: info.GetPayload(), } @@ -365,7 +365,7 @@ func pbCliSerialize(msg *ClientComMessage) *pbx.ClientMsg { Note: &pbx.ClientNote{ Topic: msg.Note.Topic, What: pbInfoNoteWhatSerialize(msg.Note.What), - SeqId: int32(msg.Note.SeqId), + SeqId: msg.Note.SeqId, Unread: int32(msg.Note.Unread), Event: pbCallEventSerialize(msg.Note.Event), Payload: msg.Note.Payload, @@ -485,7 +485,7 @@ func pbCliDeserialize(pkt *pbx.ClientMsg) *ClientComMessage { } else if note := pkt.GetNote(); note != nil { msg.Note = &MsgClientNote{ Topic: note.GetTopic(), - SeqId: int(note.GetSeqId()), + SeqId: note.GetSeqId(), What: pbInfoNoteWhatDeserialize(note.GetWhat()), Unread: int(note.GetUnread()), Event: pbCallEventDeserialize(note.GetEvent()), @@ -585,15 +585,15 @@ func pbGetQuerySerialize(in *MsgGetQuery) *pbx.GetQuery { } if in.Data != nil { out.Data = &pbx.GetOpts{ - BeforeId: int32(in.Data.BeforeId), - SinceId: int32(in.Data.SinceId), + BeforeId: in.Data.BeforeId, + SinceId: in.Data.SinceId, Limit: int32(in.Data.Limit), } if len(in.Data.IdRanges) > 0 { out.Data.Ranges = make([]*pbx.SeqRange, len(in.Data.IdRanges)) for i, dq := range in.Data.IdRanges { - out.Data.Ranges[i] = &pbx.SeqRange{Low: int32(dq.LowId), Hi: int32(dq.HiId)} + out.Data.Ranges[i] = &pbx.SeqRange{Low: dq.LowId, Hi: dq.HiId} } } } @@ -623,16 +623,16 @@ func pbGetQueryDeserialize(in *pbx.GetQuery) *MsgGetQuery { } if data := in.GetData(); data != nil { msg.Data = &MsgGetOpts{ - BeforeId: int(data.GetBeforeId()), - SinceId: int(data.GetSinceId()), + BeforeId: data.GetBeforeId(), + SinceId: data.GetSinceId(), Limit: int(data.GetLimit()), } if ranges := data.GetRanges(); len(ranges) > 0 { msg.Data.IdRanges = make([]MsgRange, len(ranges)) for i, sr := range ranges { - msg.Data.IdRanges[i].LowId = int(sr.GetLow()) - msg.Data.IdRanges[i].HiId = int(sr.GetHi()) + msg.Data.IdRanges[i].LowId = sr.GetLow() + msg.Data.IdRanges[i].HiId = sr.GetHi() } } } @@ -890,9 +890,9 @@ func pbTopicDescSerialize(desc *MsgTopicDesc) *pbx.TopicDesc { IsChan: desc.IsChan, Defacs: pbDefaultAcsSerialize(desc.DefaultAcs), Acs: pbAccessModeSerialize(desc.Acs), - SeqId: int32(desc.SeqId), - ReadId: int32(desc.ReadSeqId), - RecvId: int32(desc.RecvSeqId), + SeqId: desc.SeqId, + ReadId: desc.ReadSeqId, + RecvId: desc.RecvSeqId, DelId: int32(desc.DelId), Public: interfaceToBytes(desc.Public), Trusted: interfaceToBytes(desc.Trusted), @@ -918,9 +918,9 @@ func pbTopicDescDeserialize(desc *pbx.TopicDesc) *MsgTopicDesc { IsChan: desc.GetIsChan(), DefaultAcs: pbDefaultAcsDeserialize(desc.GetDefacs()), Acs: pbAccessModeDeserialize(desc.GetAcs()), - SeqId: int(desc.SeqId), - ReadSeqId: int(desc.ReadId), - RecvSeqId: int(desc.RecvId), + SeqId: desc.SeqId, + ReadSeqId: desc.ReadId, + RecvSeqId: desc.RecvId, DelId: int(desc.DelId), Public: bytesToInterface(desc.Public), Trusted: bytesToInterface(desc.Trusted), @@ -947,7 +947,7 @@ func pbTopicSerializeToDesc(topic *Topic) *pbx.TopicDesc { Auth: topic.accessAuth.String(), Anon: topic.accessAnon.String(), }, - SeqId: int32(topic.lastID), + SeqId: topic.lastID, DelId: int32(topic.delID), Public: interfaceToBytes(topic.public), Trusted: interfaceToBytes(topic.trusted), @@ -972,15 +972,15 @@ func pbTopicSubSerialize(sub *MsgTopicSub) *pbx.TopicSub { DeletedAt: timeToInt64(sub.DeletedAt), Online: sub.Online, Acs: pbAccessModeSerialize(&sub.Acs), - ReadId: int32(sub.ReadSeqId), - RecvId: int32(sub.RecvSeqId), + ReadId: sub.ReadSeqId, + RecvId: sub.RecvSeqId, Public: interfaceToBytes(sub.Public), Trusted: interfaceToBytes(sub.Trusted), Private: interfaceToBytes(sub.Private), UserId: sub.User, Topic: sub.Topic, TouchedAt: timeToInt64(sub.TouchedAt), - SeqId: int32(sub.SeqId), + SeqId: sub.SeqId, DelId: int32(sub.DelId), } if sub.LastSeen != nil { @@ -1001,15 +1001,15 @@ func pbTopicSubSliceDeserialize(subs []*pbx.TopicSub) []MsgTopicSub { UpdatedAt: int64ToTime(subs[i].GetUpdatedAt()), DeletedAt: int64ToTime(subs[i].GetDeletedAt()), Online: subs[i].GetOnline(), - ReadSeqId: int(subs[i].GetReadId()), - RecvSeqId: int(subs[i].GetRecvId()), + ReadSeqId: subs[i].GetReadId(), + RecvSeqId: subs[i].GetRecvId(), Public: bytesToInterface(subs[i].GetPublic()), Trusted: bytesToInterface(subs[i].GetTrusted()), Private: bytesToInterface(subs[i].GetPrivate()), User: subs[i].GetUserId(), Topic: subs[i].GetTopic(), TouchedAt: int64ToTime(subs[i].GetTouchedAt()), - SeqId: int(subs[i].GetSeqId()), + SeqId: subs[i].GetSeqId(), DelId: int(subs[i].GetDelId()), } if acs := subs[i].GetAcs(); acs != nil { @@ -1063,7 +1063,7 @@ func pbDelQuerySerialize(in []MsgRange) []*pbx.SeqRange { out := make([]*pbx.SeqRange, len(in)) for i, dq := range in { - out[i] = &pbx.SeqRange{Low: int32(dq.LowId), Hi: int32(dq.HiId)} + out[i] = &pbx.SeqRange{Low: dq.LowId, Hi: dq.HiId} } return out @@ -1076,8 +1076,8 @@ func pbDelQueryDeserialize(in []*pbx.SeqRange) []MsgRange { out := make([]MsgRange, len(in)) for i, sr := range in { - out[i].LowId = int(sr.GetLow()) - out[i].HiId = int(sr.GetHi()) + out[i].LowId = sr.GetLow() + out[i].HiId = sr.GetHi() } return out diff --git a/server/plugins.go b/server/plugins.go index 24b5fb5..d18f717 100644 --- a/server/plugins.go +++ b/server/plugins.go @@ -577,8 +577,8 @@ func pluginSubscription(sub *types.Subscription, action int) { UserId: sub.User, DelId: int32(sub.DelId), - ReadId: int32(sub.ReadSeqId), - RecvId: int32(sub.RecvSeqId), + ReadId: sub.ReadSeqId, + RecvId: sub.RecvSeqId, Mode: &pbx.AccessMode{ Want: sub.ModeWant.String(), diff --git a/server/pres.go b/server/pres.go index ee7df02..16cf368 100644 --- a/server/pres.go +++ b/server/pres.go @@ -12,7 +12,7 @@ import ( // presParams defines parameters for creating a presence notification. type presParams struct { userAgent string - seqID int + seqID int64 delID int delSeq []MsgRange @@ -483,7 +483,7 @@ func (t *Topic) presSubsOffline(what string, params *presParams, // Publish {info what=read|recv|kp} to topic subscribers's sessions currently offline in the topic, // on subscriber's 'me'. Group and P2P. -func (t *Topic) infoSubsOffline(from types.Uid, what string, seq int, skipSid string) { +func (t *Topic) infoSubsOffline(from types.Uid, what string, seq int64, skipSid string) { user := from.UserId() for uid, pud := range t.perUser { @@ -509,7 +509,7 @@ func (t *Topic) infoSubsOffline(from types.Uid, what string, seq int, skipSid st } // Publish {info what=call} to topic subscribers's sessions on subscriber's 'me'. -func (t *Topic) infoCallSubsOffline(from string, target types.Uid, event string, seq int, +func (t *Topic) infoCallSubsOffline(from string, target types.Uid, event string, seq int64, sdp json.RawMessage, skipSid string, offlineOnly bool) { if target.IsZero() { logs.Err.Printf("callSubs could not find target: topic %s - from %s", t.name, from) @@ -672,9 +672,9 @@ func presSingleUserOfflineOffline(tenantID types.TenantID, uid types.Uid, origin // Let other sessions of a given user know what messages are now received/read. // If both 'read' and 'recv' != 0 then 'read' takes precedence over 'recv'. // Cases U -func (t *Topic) presPubMessageCount(uid types.Uid, mode types.AccessMode, read, recv int, skip string) { +func (t *Topic) presPubMessageCount(uid types.Uid, mode types.AccessMode, read, recv int64, skip string) { var what string - var seq int + var seq int64 if read > 0 { what = "read" seq = read diff --git a/server/push.go b/server/push.go index e0b305b..0fa404f 100644 --- a/server/push.go +++ b/server/push.go @@ -164,7 +164,7 @@ func pushForChanDelete(tenantID types.TenantID, topicName string, now time.Time) } // Prepares payload to be delivered to a mobile device as a push notification in response to receiving "read" notification. -func (t *Topic) pushForReadRcpt(uid types.Uid, seq int, now time.Time) *push.Receipt { +func (t *Topic) pushForReadRcpt(uid types.Uid, seq int64, now time.Time) *push.Receipt { // The `Topic` in the push receipt is `t.xoriginal` for group topics, `fromUid` for p2p topics, // not the t.original(fromUid) because it's the topic name as seen by the recipient, not by the sender. topic := t.xoriginal diff --git a/server/push/fcm/payload.go b/server/push/fcm/payload.go index 2e0a710..b56fc46 100644 --- a/server/push/fcm/payload.go +++ b/server/push/fcm/payload.go @@ -39,7 +39,7 @@ func payloadToData(pl *push.Payload) (map[string]string, error) { // Must use "xfrom" because "from" is a reserved word. Google did not bother to document it anywhere. data["xfrom"] = pl.From if pl.What == push.ActMsg { - data["seq"] = strconv.Itoa(pl.SeqId) + data["seq"] = strconv.FormatInt(pl.SeqId, 10) if pl.ContentType != "" { data["mime"] = pl.ContentType } @@ -81,7 +81,7 @@ func payloadToData(pl *push.Payload) (map[string]string, error) { data["modeWant"] = pl.ModeWant.String() data["modeGiven"] = pl.ModeGiven.String() } else if pl.What == push.ActRead { - data["seq"] = strconv.Itoa(pl.SeqId) + data["seq"] = strconv.FormatInt(pl.SeqId, 10) data["silent"] = "true" } else { return nil, errors.New("unknown push type") diff --git a/server/push/push.go b/server/push/push.go index 41260ec..f28bf7b 100644 --- a/server/push/push.go +++ b/server/push/push.go @@ -76,7 +76,7 @@ type Payload struct { // Message sender 'usrXXX' From string `json:"from"` // Sequential ID of the message. - SeqId int `json:"seq"` + SeqId int64 `json:"seq"` // MIME-Type of the message content, text/x-drafty or text/plain ContentType string `json:"mime"` // Actual Data.Content of the message, if requested diff --git a/server/redis_fanout.go b/server/redis_fanout.go new file mode 100644 index 0000000..a097936 --- /dev/null +++ b/server/redis_fanout.go @@ -0,0 +1,87 @@ +package main + +import ( + "context" + "encoding/json" + + "github.com/tinode/chat/server/logs" + "github.com/tinode/chat/server/store" + "github.com/tinode/chat/server/store/types" +) + +func startRedisGroupFanout(ctx context.Context) { + if err := store.StartGroupFanoutSubscriber(ctx, currentNodeID(), func(raw []byte) { + var msg ServerComMessage + if err := json.Unmarshal(raw, &msg); err != nil { + logs.Warn.Println("redis group fanout: malformed message", err) + return + } + if !msg.TenantID.IsValid() || msg.RcptTo == "" || types.GetTopicCat(msg.RcptTo) != types.TopicCatGrp { + return + } + msg.sess = nil + deliverRedisGroupFanout(&msg) + }); err != nil { + logs.Warn.Println("redis group fanout: subscriber disabled", err) + } +} + +func deliverRedisGroupFanout(msg *ServerComMessage) { + key := types.TopicKey{TenantID: msg.TenantID, Topic: msg.RcptTo} + if globals.hub.topicGet(key) != nil { + select { + case globals.hub.routeSrv <- msg: + default: + logs.Err.Println("redis group fanout: hub routeSrv queue full", msg.RcptTo) + } + return + } + + delivered := 0 + localMembers := redisGroupFanoutMembers(msg.TenantID, msg.RcptTo) + globals.sessionStore.Range(func(_ string, sess *Session) bool { + if sess == nil || sess.isCluster() || sess.isProxy() || sess.tenantID != msg.TenantID || sess.sid == msg.SkipSid { + return true + } + _, listedOnline := localMembers[sess.uid.UserId()] + if !listedOnline && !sess.hasSub(msg.RcptTo) { + return true + } + + msgCopy := msg.copy() + prepareRedisGroupFanoutMessage(msgCopy, msg.RcptTo) + if sess.queueOut(msgCopy) { + delivered++ + } else { + logs.Warn.Printf("redis group fanout: session queue full, sid=%s topic=%s", sess.sid, msg.RcptTo) + } + return true + }) + if delivered == 0 { + logs.Info.Println("redis group fanout: no local subscribers for unloaded topic", msg.RcptTo) + } +} + +func redisGroupFanoutMembers(tenantID types.TenantID, topic string) map[string]struct{} { + uids, err := store.GetTopicNodeOnlineMembers(tenantID, topic, currentNodeID()) + if err != nil { + logs.Warn.Println("redis group fanout: failed to read local online members", topic, err) + return nil + } + members := make(map[string]struct{}, len(uids)) + for _, uid := range uids { + members[uid.UserId()] = struct{}{} + } + return members +} + +func prepareRedisGroupFanoutMessage(msg *ServerComMessage, topic string) { + switch { + case msg.Data != nil && msg.Data.Topic == "": + msg.Data.Topic = topic + case msg.Pres != nil && msg.Pres.Topic == "": + msg.Pres.Topic = topic + case msg.Info != nil && msg.Info.Topic == "": + msg.Info.Topic = topic + } +} diff --git a/server/session.go b/server/session.go index a644a83..51c12f8 100644 --- a/server/session.go +++ b/server/session.go @@ -205,6 +205,32 @@ func (s *Session) getSub(topic string) *Subscription { return s.subs[topic] } +func (s *Session) hasSub(topic string) bool { + if s.multi != nil { + return s.multi.hasSub(topic) + } + s.subsLock.RLock() + defer s.subsLock.RUnlock() + _, ok := s.subs[topic] + return ok +} + +func (s *Session) groupSubs() []string { + if s.multi != nil { + return s.multi.groupSubs() + } + s.subsLock.RLock() + defer s.subsLock.RUnlock() + + topics := make([]string, 0, len(s.subs)) + for topic := range s.subs { + if len(topic) >= 3 && types.GetTopicCat(topic) == types.TopicCatGrp { + topics = append(topics, topic) + } + } + return topics +} + func (s *Session) delSub(topic string) { if s.multi != nil { s.multi.delSub(topic) @@ -434,6 +460,11 @@ func (s *Session) purgeChannels() { // cleanUp is called when the session is terminated to perform resource cleanup. func (s *Session) cleanUp(expired bool) { atomic.StoreInt32(&s.terminating, 1) + if !s.uid.IsZero() && s.tenantID.IsValid() { + if err := store.MarkUserOffline(s.tenantID, s.uid, s.sid, currentNodeID()); err != nil { + logs.Warn.Println("s.cleanUp: failed to mark user offline", s.uid, err, s.sid) + } + } s.purgeChannels() s.inflightReqs.Wait() s.inflightReqs = nil @@ -488,6 +519,16 @@ func (s *Session) dispatchRaw(raw []byte) { func (s *Session) dispatch(msg *ClientComMessage) { now := types.TimeNow() atomic.StoreInt64(&s.lastAction, now.UnixNano()) + if !s.uid.IsZero() && s.tenantID.IsValid() { + if err := store.RefreshUserPresence(s.tenantID, s.uid, s.sid, currentNodeID()); err != nil { + logs.Warn.Println("s.dispatch: failed to refresh user presence", s.uid, err, s.sid) + } + for _, topic := range s.groupSubs() { + if err := store.MarkTopicMemberOnline(s.tenantID, topic, s.uid, currentNodeID()); err != nil { + logs.Warn.Println("s.dispatch: failed to refresh group member presence", topic, s.uid, err, s.sid) + } + } + } // This should be the first block here, before any other checks. var resp *ServerComMessage @@ -1200,6 +1241,9 @@ func (s *Session) onLogin(msgID string, timestamp time.Time, rec *auth.Rec, miss // Authenticate the session. s.uid = rec.Uid s.authLvl = rec.AuthLevel + if err := store.MarkUserOnline(s.tenantID, s.uid, s.sid, currentNodeID(), s.platf, s.userAgent); err != nil { + logs.Warn.Println("s.onLogin: failed to mark user online", s.uid, err, s.sid) + } // Reset expiration time. rec.Lifetime = 0 } @@ -1228,6 +1272,25 @@ func (s *Session) onLogin(msgID string, timestamp time.Time, rec *auth.Rec, miss return reply } +func currentNodeID() string { + if globals.cluster != nil && globals.cluster.thisNodeName != "" { + return globals.cluster.thisNodeName + } + return "local" +} + +func redisUserOnline(tenantID types.TenantID, uid types.Uid, fallback bool) bool { + if !store.UserPresenceEnabled() || !tenantID.IsValid() || uid.IsZero() { + return fallback + } + presence, err := store.GetUserPresence(tenantID, uid) + if err != nil { + logs.Warn.Printf("presence[%s]: failed to read redis presence: %v", uid.UserId(), err) + return fallback + } + return presence.Online +} + func (s *Session) get(msg *ClientComMessage) { // Expand topic name. var resp *ServerComMessage diff --git a/server/store/config_cache.go b/server/store/config_cache.go new file mode 100644 index 0000000..5bae284 --- /dev/null +++ b/server/store/config_cache.go @@ -0,0 +1,85 @@ +package store + +import ( + "sort" + "strings" + "sync" + "time" + + "github.com/tinode/chat/server/store/types" +) + +const configCacheTTL = time.Minute + +type configCacheEntry[T any] struct { + value T + expiresAt time.Time +} + +type configCache[T any] struct { + mu sync.RWMutex + items map[string]configCacheEntry[T] +} + +func newConfigCache[T any]() *configCache[T] { + return &configCache[T]{items: make(map[string]configCacheEntry[T])} +} + +func (c *configCache[T]) get(key string) (T, bool) { + var zero T + c.mu.RLock() + entry, ok := c.items[key] + c.mu.RUnlock() + if !ok || time.Now().After(entry.expiresAt) { + return zero, false + } + return entry.value, true +} + +func (c *configCache[T]) set(key string, value T) { + c.mu.Lock() + c.items[key] = configCacheEntry[T]{value: value, expiresAt: time.Now().Add(configCacheTTL)} + c.mu.Unlock() +} + +var ( + tenantByCodeCache = newConfigCache[*types.Tenant]() + tenantByIDCache = newConfigCache[*types.Tenant]() + registrationSMSCache = newConfigCache[bool]() + thirdPartyConfigCache = newConfigCache[map[string]string]() + thirdPartyConfigAllKeys = "*" +) + +func cloneTenant(t *types.Tenant) *types.Tenant { + if t == nil { + return nil + } + cp := *t + return &cp +} + +func cloneStringMap(src map[string]string) map[string]string { + if src == nil { + return nil + } + dst := make(map[string]string, len(src)) + for k, v := range src { + dst[k] = v + } + return dst +} + +func thirdPartyCacheKey(group string, keys ...string) string { + group = strings.TrimSpace(group) + if len(keys) == 0 { + return group + ":" + thirdPartyConfigAllKeys + } + normalized := make([]string, 0, len(keys)) + for _, key := range keys { + if key = strings.TrimSpace(key); key != "" { + normalized = append(normalized, key) + } + } + sort.Strings(normalized) + return group + ":" + strings.Join(normalized, ",") +} diff --git a/server/store/group_message_runtime.go b/server/store/group_message_runtime.go new file mode 100644 index 0000000..72705c0 --- /dev/null +++ b/server/store/group_message_runtime.go @@ -0,0 +1,866 @@ +package store + +import ( + "context" + "encoding/json" + "fmt" + "sort" + "strconv" + "strings" + "time" + + "github.com/redis/go-redis/v9" + "github.com/tinode/chat/server/store/types" +) + +type redisConfig struct { + Addr string `json:"addr"` + Username string `json:"username"` + Password string `json:"password"` + DB int `json:"db"` + KeyPrefix string `json:"key_prefix"` + DialMS int `json:"dial_timeout_ms"` + ReadMS int `json:"read_timeout_ms"` + WriteMS int `json:"write_timeout_ms"` + PendingTTL int `json:"pending_ttl_seconds"` + DoneTTL int `json:"done_ttl_seconds"` + FailedTTL int `json:"failed_ttl_seconds"` + HotMsgTTL int `json:"hot_message_ttl_seconds"` + PresenceTTL int `json:"presence_ttl_seconds"` + CacheTTL int `json:"cache_ttl_seconds"` + ShortTTL int `json:"short_state_ttl_seconds"` + GroupFanout bool `json:"group_fanout_enabled"` +} + +type groupMessageUniqueRecord struct { + Status string `json:"status"` + Seq int64 `json:"seq,omitempty"` + MessageID string `json:"message_id,omitempty"` + CreatedAt time.Time `json:"created_at"` +} + +type groupFanoutEnvelope struct { + OriginNodeID string `json:"origin_node_id"` + Message json.RawMessage `json:"message"` +} + +type groupMessageRuntime interface { + Close() error + Claim(tenantID types.TenantID, topic string, from types.Uid, clientMsgID string) (*groupMessageUniqueRecord, bool, error) + NextSeq(tenantID types.TenantID, topic string, baseline int64) (int64, error) + MarkDone(tenantID types.TenantID, topic string, from types.Uid, clientMsgID string, seq int64, msgID string) error + MarkFailed(tenantID types.TenantID, topic string, from types.Uid, clientMsgID string) error + CacheHotMessage(tenantID types.TenantID, msg *types.Message) error + GetHotMessages(tenantID types.TenantID, topic string, opts *types.QueryOpt, maxLimit int) ([]types.Message, bool, error) + InvalidateHotMessages(tenantID types.TenantID, topic string, ranges []types.Range) error + MarkUserOnline(tenantID types.TenantID, uid types.Uid, sid, nodeID, device, userAgent string) error + RefreshUserPresence(tenantID types.TenantID, uid types.Uid, sid, nodeID string) error + MarkUserOffline(tenantID types.TenantID, uid types.Uid, sid, nodeID string) error + GetUserPresence(tenantID types.TenantID, uid types.Uid) (*UserPresence, error) + MarkTopicMemberOnline(tenantID types.TenantID, topic string, uid types.Uid, nodeID string) error + MarkTopicMemberOffline(tenantID types.TenantID, topic string, uid types.Uid, nodeID string) error + GetTopicNodeOnlineMembers(tenantID types.TenantID, topic, nodeID string) ([]types.Uid, error) + PublishGroupFanout(originNodeID string, message any) error + StartGroupFanoutSubscriber(ctx context.Context, nodeID string, handler func([]byte)) error + CacheObject(tenantID types.TenantID, namespace, id string, value any) error + GetCachedObject(tenantID types.TenantID, namespace, id string, value any) (bool, error) + DeleteCachedObject(tenantID types.TenantID, namespace, id string) error + AddCachedSetMember(tenantID types.TenantID, namespace, id, member string) error + RemoveCachedSetMember(tenantID types.TenantID, namespace, id, member string) error + CachedSetContains(tenantID types.TenantID, namespace, id, member string) (bool, error) + AllowRate(tenantID types.TenantID, scope string, limit int, window time.Duration) (bool, int, time.Duration, error) +} + +var groupMsgRuntime groupMessageRuntime + +type UserPresence struct { + Online bool `json:"online"` + NodeID string `json:"node_id"` + SessionCount int `json:"session_count"` + LastActiveAt time.Time `json:"last_active_at"` + LastSeenAt time.Time `json:"last_seen_at"` +} + +func MarkUserOnline(tenantID types.TenantID, uid types.Uid, sid, nodeID, device, userAgent string) error { + if groupMsgRuntime == nil || !tenantID.IsValid() || uid.IsZero() || sid == "" { + return nil + } + return groupMsgRuntime.MarkUserOnline(tenantID, uid, sid, nodeID, device, userAgent) +} + +func RefreshUserPresence(tenantID types.TenantID, uid types.Uid, sid, nodeID string) error { + if groupMsgRuntime == nil || !tenantID.IsValid() || uid.IsZero() || sid == "" { + return nil + } + return groupMsgRuntime.RefreshUserPresence(tenantID, uid, sid, nodeID) +} + +func MarkUserOffline(tenantID types.TenantID, uid types.Uid, sid, nodeID string) error { + if groupMsgRuntime == nil || !tenantID.IsValid() || uid.IsZero() || sid == "" { + return nil + } + return groupMsgRuntime.MarkUserOffline(tenantID, uid, sid, nodeID) +} + +func GetUserPresence(tenantID types.TenantID, uid types.Uid) (*UserPresence, error) { + if groupMsgRuntime == nil || !tenantID.IsValid() || uid.IsZero() { + return &UserPresence{}, nil + } + return groupMsgRuntime.GetUserPresence(tenantID, uid) +} + +func UserPresenceEnabled() bool { + return groupMsgRuntime != nil +} + +func MarkTopicMemberOnline(tenantID types.TenantID, topic string, uid types.Uid, nodeID string) error { + if groupMsgRuntime == nil || !tenantID.IsValid() || topic == "" || uid.IsZero() { + return nil + } + return groupMsgRuntime.MarkTopicMemberOnline(tenantID, topic, uid, nodeID) +} + +func MarkTopicMemberOffline(tenantID types.TenantID, topic string, uid types.Uid, nodeID string) error { + if groupMsgRuntime == nil || !tenantID.IsValid() || topic == "" || uid.IsZero() { + return nil + } + return groupMsgRuntime.MarkTopicMemberOffline(tenantID, topic, uid, nodeID) +} + +func GetTopicNodeOnlineMembers(tenantID types.TenantID, topic, nodeID string) ([]types.Uid, error) { + if groupMsgRuntime == nil || !tenantID.IsValid() || topic == "" { + return nil, nil + } + return groupMsgRuntime.GetTopicNodeOnlineMembers(tenantID, topic, nodeID) +} + +func PublishGroupFanout(originNodeID string, message any) error { + if groupMsgRuntime == nil || originNodeID == "" || message == nil { + return nil + } + return groupMsgRuntime.PublishGroupFanout(originNodeID, message) +} + +func StartGroupFanoutSubscriber(ctx context.Context, nodeID string, handler func([]byte)) error { + if groupMsgRuntime == nil || nodeID == "" || handler == nil { + return nil + } + return groupMsgRuntime.StartGroupFanoutSubscriber(ctx, nodeID, handler) +} + +func CacheObject(tenantID types.TenantID, namespace, id string, value any) error { + if groupMsgRuntime == nil || !tenantID.IsValid() || namespace == "" || id == "" || value == nil { + return nil + } + return groupMsgRuntime.CacheObject(tenantID, namespace, id, value) +} + +func GetCachedObject(tenantID types.TenantID, namespace, id string, value any) (bool, error) { + if groupMsgRuntime == nil || !tenantID.IsValid() || namespace == "" || id == "" || value == nil { + return false, nil + } + return groupMsgRuntime.GetCachedObject(tenantID, namespace, id, value) +} + +func DeleteCachedObject(tenantID types.TenantID, namespace, id string) error { + if groupMsgRuntime == nil || !tenantID.IsValid() || namespace == "" || id == "" { + return nil + } + return groupMsgRuntime.DeleteCachedObject(tenantID, namespace, id) +} + +func AddCachedSetMember(tenantID types.TenantID, namespace, id, member string) error { + if groupMsgRuntime == nil || !tenantID.IsValid() || namespace == "" || id == "" || member == "" { + return nil + } + return groupMsgRuntime.AddCachedSetMember(tenantID, namespace, id, member) +} + +func RemoveCachedSetMember(tenantID types.TenantID, namespace, id, member string) error { + if groupMsgRuntime == nil || !tenantID.IsValid() || namespace == "" || id == "" || member == "" { + return nil + } + return groupMsgRuntime.RemoveCachedSetMember(tenantID, namespace, id, member) +} + +func CachedSetContains(tenantID types.TenantID, namespace, id, member string) (bool, error) { + if groupMsgRuntime == nil || !tenantID.IsValid() || namespace == "" || id == "" || member == "" { + return false, nil + } + return groupMsgRuntime.CachedSetContains(tenantID, namespace, id, member) +} + +func AllowRate(tenantID types.TenantID, scope string, limit int, window time.Duration) (bool, int, time.Duration, error) { + if groupMsgRuntime == nil || !tenantID.IsValid() || scope == "" || limit <= 0 || window <= 0 { + return true, 0, 0, nil + } + return groupMsgRuntime.AllowRate(tenantID, scope, limit, window) +} + +func initGroupMessageRuntime(raw json.RawMessage) error { + closeGroupMessageRuntime() + if len(raw) == 0 || string(raw) == "null" { + return nil + } + var cfg redisConfig + if err := json.Unmarshal(raw, &cfg); err != nil { + return err + } + cfg.Addr = strings.TrimSpace(cfg.Addr) + if cfg.Addr == "" { + return nil + } + if cfg.KeyPrefix == "" { + cfg.KeyPrefix = "im" + } + if cfg.PendingTTL <= 0 { + cfg.PendingTTL = 120 + } + if cfg.DoneTTL <= 0 { + cfg.DoneTTL = 86400 + } + if cfg.FailedTTL <= 0 { + cfg.FailedTTL = 600 + } + if cfg.HotMsgTTL <= 0 { + cfg.HotMsgTTL = 86400 + } + if cfg.PresenceTTL <= 0 { + cfg.PresenceTTL = 120 + } + if cfg.CacheTTL <= 0 { + cfg.CacheTTL = 300 + } + if cfg.ShortTTL <= 0 { + cfg.ShortTTL = 600 + } + dialTimeout := timeoutOrDefault(cfg.DialMS, 2*time.Second) + readTimeout := timeoutOrDefault(cfg.ReadMS, 2*time.Second) + writeTimeout := timeoutOrDefault(cfg.WriteMS, 2*time.Second) + client := redis.NewClient(&redis.Options{ + Addr: cfg.Addr, + Username: cfg.Username, + Password: cfg.Password, + DB: cfg.DB, + DialTimeout: dialTimeout, + ReadTimeout: readTimeout, + WriteTimeout: writeTimeout, + }) + ctx, cancel := context.WithTimeout(context.Background(), dialTimeout) + defer cancel() + if err := client.Ping(ctx).Err(); err != nil { + _ = client.Close() + return err + } + groupMsgRuntime = &redisGroupMessageRuntime{client: client, prefix: cfg.KeyPrefix, cfg: cfg} + return nil +} + +func closeGroupMessageRuntime() { + if groupMsgRuntime != nil { + _ = groupMsgRuntime.Close() + groupMsgRuntime = nil + } +} + +func timeoutOrDefault(ms int, fallback time.Duration) time.Duration { + if ms <= 0 { + return fallback + } + return time.Duration(ms) * time.Millisecond +} + +type redisGroupMessageRuntime struct { + client *redis.Client + prefix string + cfg redisConfig +} + +func (r *redisGroupMessageRuntime) Close() error { + return r.client.Close() +} + +func (r *redisGroupMessageRuntime) Claim(tenantID types.TenantID, topic string, from types.Uid, clientMsgID string) (*groupMessageUniqueRecord, bool, error) { + if clientMsgID == "" { + return nil, true, nil + } + key := r.uniqueKey(tenantID, topic, from, clientMsgID) + rec := &groupMessageUniqueRecord{Status: "pending", CreatedAt: time.Now().UTC()} + raw, _ := json.Marshal(rec) + ctx := context.Background() + ok, err := r.client.SetNX(ctx, key, raw, time.Duration(r.cfg.PendingTTL)*time.Second).Result() + if err != nil { + return nil, false, err + } + if ok { + return rec, true, nil + } + existing, err := r.client.Get(ctx, key).Bytes() + if err == redis.Nil { + return nil, false, nil + } + if err != nil { + return nil, false, err + } + var current groupMessageUniqueRecord + if err = json.Unmarshal(existing, ¤t); err != nil { + return nil, false, err + } + if current.Status == "failed" { + _ = r.client.Del(ctx, key).Err() + ok, err = r.client.SetNX(ctx, key, raw, time.Duration(r.cfg.PendingTTL)*time.Second).Result() + if err != nil { + return nil, false, err + } + if ok { + return rec, true, nil + } + } + return ¤t, false, nil +} + +func (r *redisGroupMessageRuntime) NextSeq(tenantID types.TenantID, topic string, baseline int64) (int64, error) { + if baseline < 0 { + baseline = 0 + } + seqKey := r.seqKey(tenantID, topic) + seq, err := r.client.Eval(context.Background(), ` +if redis.call("EXISTS", KEYS[1]) == 0 then + redis.call("SET", KEYS[1], ARGV[1]) +end +return redis.call("INCR", KEYS[1]) +`, []string{seqKey}, baseline).Int64() + if err != nil { + return 0, err + } + return seq, nil +} + +func (r *redisGroupMessageRuntime) MarkDone(tenantID types.TenantID, topic string, from types.Uid, clientMsgID string, seq int64, msgID string) error { + if clientMsgID == "" { + return nil + } + rec := &groupMessageUniqueRecord{Status: "done", Seq: seq, MessageID: msgID, CreatedAt: time.Now().UTC()} + raw, _ := json.Marshal(rec) + return r.client.Set(context.Background(), r.uniqueKey(tenantID, topic, from, clientMsgID), raw, time.Duration(r.cfg.DoneTTL)*time.Second).Err() +} + +func (r *redisGroupMessageRuntime) MarkFailed(tenantID types.TenantID, topic string, from types.Uid, clientMsgID string) error { + if clientMsgID == "" { + return nil + } + rec := &groupMessageUniqueRecord{Status: "failed", CreatedAt: time.Now().UTC()} + raw, _ := json.Marshal(rec) + return r.client.Set(context.Background(), r.uniqueKey(tenantID, topic, from, clientMsgID), raw, time.Duration(r.cfg.FailedTTL)*time.Second).Err() +} + +func (r *redisGroupMessageRuntime) CacheHotMessage(tenantID types.TenantID, msg *types.Message) error { + if msg == nil || msg.Topic == "" || msg.SeqId <= 0 { + return types.ErrMalformed + } + payload, err := json.Marshal(msg) + if err != nil { + return err + } + ctx := context.Background() + ttl := time.Duration(r.cfg.HotMsgTTL) * time.Second + seq := strconv.FormatInt(msg.SeqId, 10) + pipe := r.client.Pipeline() + pipe.Set(ctx, r.hotMsgKey(tenantID, msg.Topic, msg.SeqId), payload, ttl) + pipe.ZAdd(ctx, r.hotSeqKey(tenantID, msg.Topic), redis.Z{Score: float64(msg.SeqId), Member: seq}) + pipe.Expire(ctx, r.hotSeqKey(tenantID, msg.Topic), ttl+time.Hour) + _, err = pipe.Exec(ctx) + return err +} + +func (r *redisGroupMessageRuntime) GetHotMessages(tenantID types.TenantID, topic string, opts *types.QueryOpt, maxLimit int) ([]types.Message, bool, error) { + if topic == "" { + return nil, false, types.ErrMalformed + } + limit := maxLimit + if limit <= 0 { + limit = 100 + } + if opts != nil && opts.Limit > 0 && opts.Limit < limit { + limit = opts.Limit + } + seqs, completeRange, err := r.hotSeqIDs(tenantID, topic, opts, limit) + if err != nil || len(seqs) == 0 || !completeRange { + return nil, false, err + } + keys := make([]string, 0, len(seqs)) + for _, seq := range seqs { + keys = append(keys, r.hotMsgKey(tenantID, topic, seq)) + } + values, err := r.client.MGet(context.Background(), keys...).Result() + if err != nil { + return nil, false, err + } + if len(values) != len(seqs) { + return nil, false, nil + } + msgs := make([]types.Message, 0, len(values)) + for _, value := range values { + if value == nil { + return nil, false, nil + } + raw, ok := value.(string) + if !ok { + return nil, false, nil + } + var msg types.Message + if err := json.Unmarshal([]byte(raw), &msg); err != nil { + return nil, false, err + } + msg.TenantID = tenantID + msgs = append(msgs, msg) + } + return msgs, true, nil +} + +func (r *redisGroupMessageRuntime) InvalidateHotMessages(tenantID types.TenantID, topic string, ranges []types.Range) error { + if topic == "" { + return types.ErrMalformed + } + seqs := expandRanges(ranges, int(^uint(0)>>1)) + ctx := context.Background() + if len(seqs) == 0 { + pipe := r.client.Pipeline() + pipe.Del(ctx, r.hotSeqKey(tenantID, topic)) + keys, err := r.client.Keys(ctx, r.hotMsgKey(tenantID, topic, "*")).Result() + if err != nil { + return err + } + if len(keys) > 0 { + pipe.Del(ctx, keys...) + } + _, err = pipe.Exec(ctx) + return err + } + pipe := r.client.Pipeline() + members := make([]any, 0, len(seqs)) + for _, seq := range seqs { + pipe.Del(ctx, r.hotMsgKey(tenantID, topic, seq)) + members = append(members, strconv.FormatInt(int64(seq), 10)) + } + pipe.ZRem(ctx, r.hotSeqKey(tenantID, topic), members...) + _, err := pipe.Exec(ctx) + return err +} + +func (r *redisGroupMessageRuntime) MarkUserOnline(tenantID types.TenantID, uid types.Uid, sid, nodeID, device, userAgent string) error { + if nodeID == "" { + nodeID = "local" + } + now := time.Now().UTC() + ttl := time.Duration(r.cfg.PresenceTTL) * time.Second + ctx := context.Background() + userKey := r.presenceUserKey(tenantID, uid) + sessionsKey := r.presenceSessionsKey(tenantID, uid) + sessionKey := r.presenceSessionKey(tenantID, uid, sid) + nodeKey := r.presenceNodeUsersKey(tenantID, nodeID) + member := nodeID + ":" + sid + pipe := r.client.Pipeline() + pipe.HSet(ctx, sessionKey, map[string]any{ + "node_id": nodeID, + "session_id": sid, + "device": device, + "user_agent": userAgent, + "last_active_at": now.Format(time.RFC3339Nano), + }) + pipe.Expire(ctx, sessionKey, ttl) + pipe.SAdd(ctx, sessionsKey, member) + pipe.Expire(ctx, sessionsKey, ttl) + pipe.SAdd(ctx, nodeKey, uid.UserId()) + pipe.Expire(ctx, nodeKey, ttl) + pipe.HSet(ctx, userKey, map[string]any{ + "online": "1", + "node_id": nodeID, + "last_active_at": now.Format(time.RFC3339Nano), + "last_seen_at": now.Format(time.RFC3339Nano), + }) + pipe.Expire(ctx, userKey, ttl) + if _, err := pipe.Exec(ctx); err != nil { + return err + } + count, err := r.client.SCard(ctx, sessionsKey).Result() + if err != nil { + return err + } + return r.client.HSet(ctx, userKey, "session_count", count).Err() +} + +func (r *redisGroupMessageRuntime) RefreshUserPresence(tenantID types.TenantID, uid types.Uid, sid, nodeID string) error { + if nodeID == "" { + nodeID = "local" + } + now := time.Now().UTC() + ttl := time.Duration(r.cfg.PresenceTTL) * time.Second + ctx := context.Background() + userKey := r.presenceUserKey(tenantID, uid) + sessionsKey := r.presenceSessionsKey(tenantID, uid) + sessionKey := r.presenceSessionKey(tenantID, uid, sid) + nodeKey := r.presenceNodeUsersKey(tenantID, nodeID) + pipe := r.client.Pipeline() + pipe.HSet(ctx, sessionKey, "last_active_at", now.Format(time.RFC3339Nano), "node_id", nodeID) + pipe.Expire(ctx, sessionKey, ttl) + pipe.Expire(ctx, sessionsKey, ttl) + pipe.SAdd(ctx, nodeKey, uid.UserId()) + pipe.Expire(ctx, nodeKey, ttl) + pipe.HSet(ctx, userKey, "online", "1", "node_id", nodeID, "last_active_at", now.Format(time.RFC3339Nano), "last_seen_at", now.Format(time.RFC3339Nano)) + pipe.Expire(ctx, userKey, ttl) + _, err := pipe.Exec(ctx) + return err +} + +func (r *redisGroupMessageRuntime) MarkUserOffline(tenantID types.TenantID, uid types.Uid, sid, nodeID string) error { + if nodeID == "" { + nodeID = "local" + } + now := time.Now().UTC() + ttl := time.Duration(r.cfg.PresenceTTL) * time.Second + ctx := context.Background() + userKey := r.presenceUserKey(tenantID, uid) + sessionsKey := r.presenceSessionsKey(tenantID, uid) + sessionKey := r.presenceSessionKey(tenantID, uid, sid) + nodeKey := r.presenceNodeUsersKey(tenantID, nodeID) + member := nodeID + ":" + sid + pipe := r.client.Pipeline() + pipe.Del(ctx, sessionKey) + pipe.SRem(ctx, sessionsKey, member) + pipe.SRem(ctx, nodeKey, uid.UserId()) + if _, err := pipe.Exec(ctx); err != nil { + return err + } + count, err := r.client.SCard(ctx, sessionsKey).Result() + if err != nil { + return err + } + if count > 0 { + pipe = r.client.Pipeline() + pipe.HSet(ctx, userKey, "online", "1", "session_count", count, "last_seen_at", now.Format(time.RFC3339Nano)) + pipe.Expire(ctx, userKey, ttl) + pipe.Expire(ctx, sessionsKey, ttl) + _, err = pipe.Exec(ctx) + return err + } + pipe = r.client.Pipeline() + pipe.HSet(ctx, userKey, "online", "0", "session_count", 0, "node_id", nodeID, "last_seen_at", now.Format(time.RFC3339Nano)) + pipe.Expire(ctx, userKey, ttl) + pipe.Del(ctx, sessionsKey) + _, err = pipe.Exec(ctx) + return err +} + +func (r *redisGroupMessageRuntime) GetUserPresence(tenantID types.TenantID, uid types.Uid) (*UserPresence, error) { + raw, err := r.client.HGetAll(context.Background(), r.presenceUserKey(tenantID, uid)).Result() + if err != nil { + return nil, err + } + presence := &UserPresence{} + if len(raw) == 0 { + return presence, nil + } + presence.Online = raw["online"] == "1" + presence.NodeID = raw["node_id"] + presence.SessionCount, _ = strconv.Atoi(raw["session_count"]) + presence.LastActiveAt, _ = time.Parse(time.RFC3339Nano, raw["last_active_at"]) + presence.LastSeenAt, _ = time.Parse(time.RFC3339Nano, raw["last_seen_at"]) + return presence, nil +} + +func (r *redisGroupMessageRuntime) MarkTopicMemberOnline(tenantID types.TenantID, topic string, uid types.Uid, nodeID string) error { + if nodeID == "" { + nodeID = "local" + } + ttl := time.Duration(r.cfg.PresenceTTL) * time.Second + ctx := context.Background() + pipe := r.client.Pipeline() + pipe.SAdd(ctx, r.topicOnlineMembersKey(tenantID, topic), uid.UserId()) + pipe.Expire(ctx, r.topicOnlineMembersKey(tenantID, topic), ttl) + pipe.SAdd(ctx, r.topicNodeOnlineMembersKey(tenantID, topic, nodeID), uid.UserId()) + pipe.Expire(ctx, r.topicNodeOnlineMembersKey(tenantID, topic, nodeID), ttl) + _, err := pipe.Exec(ctx) + return err +} + +func (r *redisGroupMessageRuntime) MarkTopicMemberOffline(tenantID types.TenantID, topic string, uid types.Uid, nodeID string) error { + if nodeID == "" { + nodeID = "local" + } + ctx := context.Background() + pipe := r.client.Pipeline() + pipe.SRem(ctx, r.topicOnlineMembersKey(tenantID, topic), uid.UserId()) + pipe.SRem(ctx, r.topicNodeOnlineMembersKey(tenantID, topic, nodeID), uid.UserId()) + _, err := pipe.Exec(ctx) + return err +} + +func (r *redisGroupMessageRuntime) GetTopicNodeOnlineMembers(tenantID types.TenantID, topic, nodeID string) ([]types.Uid, error) { + if nodeID == "" { + nodeID = "local" + } + members, err := r.client.SMembers(context.Background(), r.topicNodeOnlineMembersKey(tenantID, topic, nodeID)).Result() + if err != nil { + return nil, err + } + uids := make([]types.Uid, 0, len(members)) + for _, member := range members { + uid := types.ParseUserId(member) + if !uid.IsZero() { + uids = append(uids, uid) + } + } + return uids, nil +} + +func (r *redisGroupMessageRuntime) PublishGroupFanout(originNodeID string, message any) error { + if !r.cfg.GroupFanout { + return nil + } + payload, err := json.Marshal(message) + if err != nil { + return err + } + envelope, err := json.Marshal(groupFanoutEnvelope{ + OriginNodeID: originNodeID, + Message: payload, + }) + if err != nil { + return err + } + return r.client.Publish(context.Background(), r.groupFanoutChannel(), envelope).Err() +} + +func (r *redisGroupMessageRuntime) StartGroupFanoutSubscriber(ctx context.Context, nodeID string, handler func([]byte)) error { + if !r.cfg.GroupFanout { + return nil + } + pubsub := r.client.Subscribe(ctx, r.groupFanoutChannel()) + if _, err := pubsub.Receive(ctx); err != nil { + _ = pubsub.Close() + return err + } + go func() { + defer pubsub.Close() + ch := pubsub.Channel() + for { + select { + case <-ctx.Done(): + return + case msg, ok := <-ch: + if !ok { + return + } + var envelope groupFanoutEnvelope + if err := json.Unmarshal([]byte(msg.Payload), &envelope); err != nil || envelope.OriginNodeID == nodeID || len(envelope.Message) == 0 { + continue + } + handler(envelope.Message) + } + } + }() + return nil +} + +func (r *redisGroupMessageRuntime) CacheObject(tenantID types.TenantID, namespace, id string, value any) error { + payload, err := json.Marshal(value) + if err != nil { + return err + } + return r.client.Set(context.Background(), r.objectCacheKey(tenantID, namespace, id), payload, r.cacheTTL(namespace)).Err() +} + +func (r *redisGroupMessageRuntime) GetCachedObject(tenantID types.TenantID, namespace, id string, value any) (bool, error) { + payload, err := r.client.Get(context.Background(), r.objectCacheKey(tenantID, namespace, id)).Bytes() + if err == redis.Nil { + return false, nil + } + if err != nil { + return false, err + } + if err = json.Unmarshal(payload, value); err != nil { + _ = r.client.Del(context.Background(), r.objectCacheKey(tenantID, namespace, id)).Err() + return false, err + } + return true, nil +} + +func (r *redisGroupMessageRuntime) DeleteCachedObject(tenantID types.TenantID, namespace, id string) error { + return r.client.Del(context.Background(), r.objectCacheKey(tenantID, namespace, id)).Err() +} + +func (r *redisGroupMessageRuntime) AddCachedSetMember(tenantID types.TenantID, namespace, id, member string) error { + key := r.setCacheKey(tenantID, namespace, id) + ctx := context.Background() + pipe := r.client.Pipeline() + pipe.SAdd(ctx, key, member) + pipe.Expire(ctx, key, r.cacheTTL(namespace)) + _, err := pipe.Exec(ctx) + return err +} + +func (r *redisGroupMessageRuntime) RemoveCachedSetMember(tenantID types.TenantID, namespace, id, member string) error { + key := r.setCacheKey(tenantID, namespace, id) + ctx := context.Background() + pipe := r.client.Pipeline() + pipe.SRem(ctx, key, member) + pipe.Expire(ctx, key, r.cacheTTL(namespace)) + _, err := pipe.Exec(ctx) + return err +} + +func (r *redisGroupMessageRuntime) CachedSetContains(tenantID types.TenantID, namespace, id, member string) (bool, error) { + return r.client.SIsMember(context.Background(), r.setCacheKey(tenantID, namespace, id), member).Result() +} + +func (r *redisGroupMessageRuntime) AllowRate(tenantID types.TenantID, scope string, limit int, window time.Duration) (bool, int, time.Duration, error) { + key := r.rateKey(tenantID, scope) + ctx := context.Background() + count, err := r.client.Incr(ctx, key).Result() + if err != nil { + return true, 0, 0, err + } + if count == 1 { + if err = r.client.Expire(ctx, key, window).Err(); err != nil { + return true, int(count), 0, err + } + } + ttl, _ := r.client.TTL(ctx, key).Result() + return count <= int64(limit), int(count), ttl, nil +} + +func (r *redisGroupMessageRuntime) hotSeqIDs(tenantID types.TenantID, topic string, opts *types.QueryOpt, limit int) ([]int64, bool, error) { + if opts != nil && len(opts.IdRanges) > 0 { + seqs := expandRanges(opts.IdRanges, limit) + sort.Slice(seqs, func(i, j int) bool { return seqs[i] > seqs[j] }) + return seqs, len(seqs) > 0 && len(seqs) <= limit, nil + } + min := "-inf" + max := "+inf" + if opts != nil { + if opts.Since > 0 { + min = strconv.FormatInt(opts.Since, 10) + } + if opts.Before > 1 { + max = strconv.FormatInt(opts.Before-1, 10) + } + } + raw, err := r.client.ZRevRangeByScore(context.Background(), r.hotSeqKey(tenantID, topic), &redis.ZRangeBy{ + Min: min, + Max: max, + Offset: 0, + Count: int64(limit), + }).Result() + if err != nil { + return nil, false, err + } + if len(raw) < limit { + return nil, false, nil + } + seqs := make([]int64, 0, len(raw)) + for _, value := range raw { + seq, err := strconv.ParseInt(value, 10, 64) + if err != nil { + return nil, false, err + } + seqs = append(seqs, seq) + } + return seqs, true, nil +} + +func expandRanges(ranges []types.Range, limit int) []int64 { + if limit <= 0 { + return nil + } + seqs := make([]int64, 0) + for _, rng := range ranges { + if rng.Low <= 0 { + continue + } + if rng.Hi == 0 { + seqs = append(seqs, rng.Low) + } else { + for seq := rng.Low; seq < rng.Hi; seq++ { + seqs = append(seqs, seq) + if len(seqs) >= limit { + return seqs + } + } + } + if len(seqs) >= limit { + return seqs + } + } + return seqs +} + +func (r *redisGroupMessageRuntime) seqKey(tenantID types.TenantID, topic string) string { + return r.key(tenantID, "topic", topic, "last_seq") +} + +func (r *redisGroupMessageRuntime) uniqueKey(tenantID types.TenantID, topic string, from types.Uid, clientMsgID string) string { + return r.key(tenantID, "topic", topic, "uniq", "msg", from.UserId(), clientMsgID) +} + +func (r *redisGroupMessageRuntime) hotSeqKey(tenantID types.TenantID, topic string) string { + return r.key(tenantID, "topic", topic, "hot_msg_seqs") +} + +func (r *redisGroupMessageRuntime) hotMsgKey(tenantID types.TenantID, topic string, seq any) string { + return r.key(tenantID, "topic", topic, "hot_msg", fmt.Sprint(seq)) +} + +func (r *redisGroupMessageRuntime) presenceUserKey(tenantID types.TenantID, uid types.Uid) string { + return r.key(tenantID, "presence", "user", uid.UserId()) +} + +func (r *redisGroupMessageRuntime) presenceSessionsKey(tenantID types.TenantID, uid types.Uid) string { + return r.key(tenantID, "presence", "user", uid.UserId(), "sessions") +} + +func (r *redisGroupMessageRuntime) presenceSessionKey(tenantID types.TenantID, uid types.Uid, sid string) string { + return r.key(tenantID, "presence", "user", uid.UserId(), "session", sid) +} + +func (r *redisGroupMessageRuntime) presenceNodeUsersKey(tenantID types.TenantID, nodeID string) string { + return r.key(tenantID, "presence", "node", nodeID, "users") +} + +func (r *redisGroupMessageRuntime) topicOnlineMembersKey(tenantID types.TenantID, topic string) string { + return r.key(tenantID, "topic", topic, "online_members") +} + +func (r *redisGroupMessageRuntime) topicNodeOnlineMembersKey(tenantID types.TenantID, topic, nodeID string) string { + return r.key(tenantID, "topic", topic, "node", nodeID, "online_members") +} + +func (r *redisGroupMessageRuntime) groupFanoutChannel() string { + return r.key(types.ZeroTenantID, "group_fanout") +} + +func (r *redisGroupMessageRuntime) objectCacheKey(tenantID types.TenantID, namespace, id string) string { + return r.key(tenantID, "cache", namespace, id) +} + +func (r *redisGroupMessageRuntime) setCacheKey(tenantID types.TenantID, namespace, id string) string { + return r.key(tenantID, "set", namespace, id) +} + +func (r *redisGroupMessageRuntime) rateKey(tenantID types.TenantID, scope string) string { + return r.key(tenantID, "rate", scope) +} + +func (r *redisGroupMessageRuntime) cacheTTL(namespace string) time.Duration { + ttl := r.cfg.CacheTTL + if namespace == "verification" || namespace == "action_token" { + ttl = r.cfg.ShortTTL + } + return time.Duration(ttl) * time.Second +} + +func (r *redisGroupMessageRuntime) key(tenantID types.TenantID, parts ...string) string { + all := []string{strings.TrimSuffix(r.prefix, ":"), strconv.FormatInt(int64(tenantID), 10)} + all = append(all, parts...) + return strings.Join(all, ":") +} diff --git a/server/store/mock_store/mock_store.go b/server/store/mock_store/mock_store.go index 96efb26..5199004 100644 --- a/server/store/mock_store/mock_store.go +++ b/server/store/mock_store/mock_store.go @@ -1114,7 +1114,7 @@ func (mr *MockMessagesPersistenceInterfaceMockRecorder) DeleteList(tenantID, top } // Edit mocks base method. -func (m *MockMessagesPersistenceInterface) Edit(tenantID types.TenantID, topic string, seq int, actor types.Uid, expectedVersion int, head types.KVMap, content any, reason string, editedAt time.Time) (*types.Message, error) { +func (m *MockMessagesPersistenceInterface) Edit(tenantID types.TenantID, topic string, seq int64, actor types.Uid, expectedVersion int, head types.KVMap, content any, reason string, editedAt time.Time) (*types.Message, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "Edit", tenantID, topic, seq, actor, expectedVersion, head, content, reason, editedAt) ret0, _ := ret[0].(*types.Message) @@ -1176,7 +1176,7 @@ func (mr *MockMessagesPersistenceInterfaceMockRecorder) Search(tenantID, topic, } // GetOne mocks base method. -func (m *MockMessagesPersistenceInterface) GetOne(tenantID types.TenantID, topic string, seq int) (*types.Message, error) { +func (m *MockMessagesPersistenceInterface) GetOne(tenantID types.TenantID, topic string, seq int64) (*types.Message, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetOne", tenantID, topic, seq) ret0, _ := ret[0].(*types.Message) @@ -1191,7 +1191,7 @@ func (mr *MockMessagesPersistenceInterfaceMockRecorder) GetOne(tenantID, topic, } // Recall mocks base method. -func (m *MockMessagesPersistenceInterface) Recall(tenantID types.TenantID, topic string, seq int, actor types.Uid, expectedVersion int, recalledAt time.Time) (*types.Message, error) { +func (m *MockMessagesPersistenceInterface) Recall(tenantID types.TenantID, topic string, seq int64, actor types.Uid, expectedVersion int, recalledAt time.Time) (*types.Message, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "Recall", tenantID, topic, seq, actor, expectedVersion, recalledAt) ret0, _ := ret[0].(*types.Message) diff --git a/server/store/store.go b/server/store/store.go index 51e1644..52ac722 100644 --- a/server/store/store.go +++ b/server/store/store.go @@ -6,6 +6,7 @@ import ( "errors" "fmt" "sort" + "strconv" "strings" "sync" "time" @@ -35,6 +36,8 @@ type configType struct { UseAdapter string `json:"use_adapter"` // Configurations for individual adapters. Adapters map[string]json.RawMessage `json:"adapters"` + // Optional Redis configuration for group message seq and unique keys. + Redis json.RawMessage `json:"redis"` } func openAdapter(workerId int, jsonconf json.RawMessage) error { @@ -91,6 +94,10 @@ func openAdapter(workerId int, jsonconf json.RawMessage) error { return err } + if err := initGroupMessageRuntime(config.Redis); err != nil { + return err + } + var adapterConfig json.RawMessage if config.Adapters != nil { adapterConfig = config.Adapters[adp.GetName()] @@ -126,6 +133,20 @@ var Store PersistentStorageInterface type storeObj struct{} +const ( + cacheNamespaceUser = "user" + cacheNamespaceTopic = "topic" + cacheNamespaceSub = "sub" + cacheNamespaceGroupSettings = "group_settings" + cacheNamespaceGroupMember = "group_member" + cacheNamespaceVerification = "verification" + cacheNamespaceActionToken = "action_token" + cacheNamespaceFriends = "friends" + cacheNamespaceBlocklist = "blocklist" + cacheNamespaceUnreadCount = "unread_count" + cacheNamespaceGroupPage = "group_member_page" +) + // TenantsPersistenceInterface defines tenant registry lookups used before authentication. type TenantsPersistenceInterface interface { GetByCode(code string) (*types.Tenant, error) @@ -148,11 +169,18 @@ type registrationConfigMapper struct{} var RegistrationConfig RegistrationConfigPersistenceInterface func (registrationConfigMapper) SMSEnabled() (bool, error) { + if enabled, ok := registrationSMSCache.get("im:register_sms_enabled"); ok { + return enabled, nil + } configAdp, ok := adp.(adapter.RegistrationConfigAdapter) if !ok { return true, types.ErrUnsupported } - return configAdp.RegistrationSMSEnabled() + enabled, err := configAdp.RegistrationSMSEnabled() + if err == nil { + registrationSMSCache.set("im:register_sms_enabled", enabled) + } + return enabled, err } // ThirdPartyConfigPersistenceInterface exposes global third-party integration settings. @@ -166,11 +194,19 @@ type thirdPartyConfigMapper struct{} var ThirdPartyConfig ThirdPartyConfigPersistenceInterface = thirdPartyConfigMapper{} func (thirdPartyConfigMapper) Get(group string, keys ...string) (map[string]string, error) { + cacheKey := thirdPartyCacheKey(group, keys...) + if cached, ok := thirdPartyConfigCache.get(cacheKey); ok { + return cloneStringMap(cached), nil + } configAdp, ok := adp.(adapter.ThirdPartyConfigAdapter) if !ok { return nil, types.ErrUnsupported } - return configAdp.ThirdPartyConfig(group, keys...) + result, err := configAdp.ThirdPartyConfig(group, keys...) + if err == nil { + thirdPartyConfigCache.set(cacheKey, cloneStringMap(result)) + } + return result, err } type TenantEntryPersistenceInterface interface { @@ -221,11 +257,20 @@ func (tenantEntryMapper) CreateTenantEntryFeedback(feedback *types.TenantEntryFe // GetByCode resolves a tenant by its public enterprise code. func (tenantsMapper) GetByCode(code string) (*types.Tenant, error) { + code = strings.TrimSpace(code) + if tenant, ok := tenantByCodeCache.get(code); ok { + return cloneTenant(tenant), nil + } tenantAdp, ok := adp.(adapter.TenantAdapter) if !ok { return nil, types.ErrUnsupported } - return tenantAdp.TenantGetByCode(code) + tenant, err := tenantAdp.TenantGetByCode(code) + if err == nil && tenant != nil { + tenantByCodeCache.set(code, cloneTenant(tenant)) + tenantByIDCache.set(strconv.FormatInt(int64(tenant.ID), 10), cloneTenant(tenant)) + } + return tenant, err } // GetByID resolves an internal tenant ID to its registry record. @@ -233,11 +278,20 @@ func (tenantsMapper) GetByID(id types.TenantID) (*types.Tenant, error) { if !id.IsValid() { return nil, types.ErrMalformed } + idKey := strconv.FormatInt(int64(id), 10) + if tenant, ok := tenantByIDCache.get(idKey); ok { + return cloneTenant(tenant), nil + } tenantAdp, ok := adp.(adapter.TenantAdapter) if !ok { return nil, types.ErrUnsupported } - return tenantAdp.TenantGetByID(id) + tenant, err := tenantAdp.TenantGetByID(id) + if err == nil && tenant != nil { + tenantByIDCache.set(idKey, cloneTenant(tenant)) + tenantByCodeCache.set(tenant.Code, cloneTenant(tenant)) + } + return tenant, err } func tenantAuthAdapter(tenantID types.TenantID) (adapter.TenantAuthAdapter, error) { @@ -323,6 +377,7 @@ func (storeObj) Open(workerId int, jsonconf json.RawMessage) error { // Close terminates connection to persistent storage. func (storeObj) Close() error { + closeGroupMessageRuntime() if adp.IsOpen() { return adp.Close() } @@ -586,6 +641,15 @@ func (usersMapper) DeleteAuthRecord(tenantID types.TenantID, uid types.Uid) erro // Get returns a user object for the given user ID or nil if the user is not found. func (usersMapper) Get(tenantID types.TenantID, uid types.Uid) (*types.User, error) { + var cached types.User + if ok, err := GetCachedObject(tenantID, cacheNamespaceUser, uid.String(), &cached); err != nil { + logs.Warn.Printf("cache user[%s]: read failed: %v", uid.UserId(), err) + } else if ok { + if cached.TenantID != tenantID { + return nil, types.ErrNotFound + } + return &cached, nil + } tenantAdp, err := tenantBusinessAdapter(tenantID) if err != nil { return nil, err @@ -594,6 +658,11 @@ func (usersMapper) Get(tenantID types.TenantID, uid types.Uid) (*types.User, err if err == nil && user != nil && user.TenantID != tenantID { return nil, types.ErrNotFound } + if err == nil && user != nil { + if cacheErr := CacheObject(tenantID, cacheNamespaceUser, uid.String(), user); cacheErr != nil { + logs.Warn.Printf("cache user[%s]: write failed: %v", uid.UserId(), cacheErr) + } + } return user, err } @@ -640,7 +709,11 @@ func (usersMapper) Delete(tenantID types.TenantID, id types.Uid, hard bool) erro if err != nil { return err } - return tenantAdp.TenantUserDelete(tenantID, id, hard) + if err = tenantAdp.TenantUserDelete(tenantID, id, hard); err != nil { + return err + } + _ = DeleteCachedObject(tenantID, cacheNamespaceUser, id.String()) + return nil } // UpdateLastSeen updates LastSeen and UserAgent. @@ -657,7 +730,11 @@ func (usersMapper) Update(tenantID types.TenantID, uid types.Uid, update map[str if err != nil { return err } - return tenantAdp.TenantUserUpdate(tenantID, uid, update) + if err = tenantAdp.TenantUserUpdate(tenantID, uid, update); err != nil { + return err + } + _ = DeleteCachedObject(tenantID, cacheNamespaceUser, uid.String()) + return nil } // UpdateTags either adds, removes, or resets tags to the given slices. @@ -806,11 +883,67 @@ func (usersMapper) DelCred(tenantID types.TenantID, id types.Uid, method, value // GetUnreadCount returs users' total count of unread messages in all topics with the R permissions. func (usersMapper) GetUnreadCount(tenantID types.TenantID, ids ...types.Uid) (map[types.Uid]int, error) { + counts := make(map[types.Uid]int, len(ids)) + misses := make([]types.Uid, 0, len(ids)) + for _, uid := range ids { + if uid.IsZero() { + counts[uid] = 0 + continue + } + var cached int + if ok, err := GetCachedObject(tenantID, cacheNamespaceUnreadCount, uid.UserId(), &cached); err != nil { + logs.Warn.Printf("cache unread[%s]: read failed: %v", uid.UserId(), err) + misses = append(misses, uid) + } else if ok { + if cached < 0 { + cached = 0 + } + counts[uid] = cached + } else { + misses = append(misses, uid) + } + } + if len(misses) == 0 { + return counts, nil + } tenantAdp, err := tenantBusinessAdapter(tenantID) if err != nil { return nil, err } - return tenantAdp.TenantUserUnreadCount(tenantID, ids...) + dbCounts, err := tenantAdp.TenantUserUnreadCount(tenantID, misses...) + if err != nil { + return nil, err + } + for _, uid := range misses { + count := dbCounts[uid] + if count < 0 { + count = 0 + } + counts[uid] = count + CacheUserUnreadCount(tenantID, uid, count) + } + return counts, nil +} + +func CacheUserUnreadCount(tenantID types.TenantID, uid types.Uid, count int) { + if uid.IsZero() { + return + } + if count < 0 { + count = 0 + } + if err := CacheObject(tenantID, cacheNamespaceUnreadCount, uid.UserId(), count); err != nil { + logs.Warn.Printf("cache unread[%s]: write failed: %v", uid.UserId(), err) + } +} + +func DeleteCachedUserUnreadCount(tenantID types.TenantID, uid types.Uid) { + if uid.IsZero() { + return + } + if err := DeleteCachedObject(tenantID, cacheNamespaceUnreadCount, uid.UserId()); err != nil { + logs.Warn.Printf("cache unread[%s]: delete failed: %v", uid.UserId(), err) + } } // GetUnvalidated returns a list of stale user ids which have unvalidated credentials, @@ -896,6 +1029,15 @@ func (topicsMapper) CreateP2P(tenantID types.TenantID, initiator, invited *types // Get a single topic with a list of relevant users de-normalized into it func (topicsMapper) Get(tenantID types.TenantID, topic string) (*types.Topic, error) { + var cached types.Topic + if ok, err := GetCachedObject(tenantID, cacheNamespaceTopic, topic, &cached); err != nil { + logs.Warn.Printf("cache topic[%s]: read failed: %v", topic, err) + } else if ok { + if cached.TenantID != tenantID { + return nil, types.ErrNotFound + } + return &cached, nil + } tenantAdp, err := tenantBusinessAdapter(tenantID) if err != nil { return nil, err @@ -904,6 +1046,11 @@ func (topicsMapper) Get(tenantID types.TenantID, topic string) (*types.Topic, er if err == nil && result != nil && result.TenantID != tenantID { return nil, types.ErrNotFound } + if err == nil && result != nil { + if cacheErr := CacheObject(tenantID, cacheNamespaceTopic, topic, result); cacheErr != nil { + logs.Warn.Printf("cache topic[%s]: write failed: %v", topic, cacheErr) + } + } return result, err } @@ -965,7 +1112,11 @@ func (topicsMapper) Update(tenantID types.TenantID, topic string, update map[str if err != nil { return err } - return tenantAdp.TenantTopicUpdate(tenantID, topic, update) + if err = tenantAdp.TenantTopicUpdate(tenantID, topic, update); err != nil { + return err + } + _ = DeleteCachedObject(tenantID, cacheNamespaceTopic, topic) + return nil } // OwnerChange replaces the old topic owner with the new owner. @@ -974,7 +1125,11 @@ func (topicsMapper) OwnerChange(tenantID types.TenantID, topic string, newOwner if err != nil { return err } - return tenantAdp.TenantTopicOwnerChange(tenantID, topic, newOwner) + if err = tenantAdp.TenantTopicOwnerChange(tenantID, topic, newOwner); err != nil { + return err + } + _ = DeleteCachedObject(tenantID, cacheNamespaceTopic, topic) + return nil } // Delete deletes topic, messages, attachments, and subscriptions. @@ -983,7 +1138,11 @@ func (topicsMapper) Delete(tenantID types.TenantID, topic string, isChan, hard b if err != nil { return err } - return tenantAdp.TenantTopicDelete(tenantID, topic, isChan, hard) + if err = tenantAdp.TenantTopicDelete(tenantID, topic, isChan, hard); err != nil { + return err + } + _ = DeleteCachedObject(tenantID, cacheNamespaceTopic, topic) + return nil } // SubsPersistenceInterface is an interface which defines methods for persistent storage of subscriptions. @@ -1033,6 +1192,16 @@ func (subsMapper) Create(tenantID types.TenantID, subs ...*types.Subscription) e // Get subscription given topic and user ID. func (subsMapper) Get(tenantID types.TenantID, topic string, user types.Uid, keepDeleted bool) (*types.Subscription, error) { + cacheID := subscriptionCacheID(topic, user, keepDeleted) + var cached types.Subscription + if ok, err := GetCachedObject(tenantID, cacheNamespaceSub, cacheID, &cached); err != nil { + logs.Warn.Printf("cache sub[%s]: read failed: %v", cacheID, err) + } else if ok { + if cached.TenantID != tenantID { + return nil, types.ErrNotFound + } + return &cached, nil + } tenantAdp, err := tenantBusinessAdapter(tenantID) if err != nil { return nil, err @@ -1041,6 +1210,11 @@ func (subsMapper) Get(tenantID types.TenantID, topic string, user types.Uid, kee if err == nil && result != nil && result.TenantID != tenantID { return nil, types.ErrNotFound } + if err == nil && result != nil { + if cacheErr := CacheObject(tenantID, cacheNamespaceSub, cacheID, result); cacheErr != nil { + logs.Warn.Printf("cache sub[%s]: write failed: %v", cacheID, cacheErr) + } + } return result, err } @@ -1051,7 +1225,12 @@ func (subsMapper) Update(tenantID types.TenantID, topic string, user types.Uid, if err != nil { return err } - return tenantAdp.TenantSubsUpdate(tenantID, topic, user, update) + if err = tenantAdp.TenantSubsUpdate(tenantID, topic, user, update); err != nil { + return err + } + _ = DeleteCachedObject(tenantID, cacheNamespaceSub, subscriptionCacheID(topic, user, false)) + _ = DeleteCachedObject(tenantID, cacheNamespaceSub, subscriptionCacheID(topic, user, true)) + return nil } // Delete deletes a subscription. @@ -1061,15 +1240,27 @@ func (subsMapper) Delete(tenantID types.TenantID, topic string, user types.Uid) if err != nil { return err } - return tenantAdp.TenantSubsDelete(tenantID, topic, user) + if err = tenantAdp.TenantSubsDelete(tenantID, topic, user); err != nil { + return err + } + _ = DeleteCachedObject(tenantID, cacheNamespaceSub, subscriptionCacheID(topic, user, false)) + _ = DeleteCachedObject(tenantID, cacheNamespaceSub, subscriptionCacheID(topic, user, true)) + return nil +} + +func subscriptionCacheID(topic string, user types.Uid, keepDeleted bool) string { + if keepDeleted { + return topic + ":" + user.String() + ":any" + } + return topic + ":" + user.String() + ":active" } // MessagesPersistenceInterface is an interface which defines methods for persistent storage of messages. type MessagesPersistenceInterface interface { Save(tenantID types.TenantID, msg *types.Message, attachmentURLs []string, readBySender bool) (*types.MessageSaveResult, error) - GetOne(tenantID types.TenantID, topic string, seq int) (*types.Message, error) - Recall(tenantID types.TenantID, topic string, seq int, actor types.Uid, expectedVersion int, recalledAt time.Time) (*types.Message, error) - Edit(tenantID types.TenantID, topic string, seq int, actor types.Uid, expectedVersion int, head types.KVMap, content any, reason string, editedAt time.Time) (*types.Message, error) + GetOne(tenantID types.TenantID, topic string, seq int64) (*types.Message, error) + Recall(tenantID types.TenantID, topic string, seq int64, actor types.Uid, expectedVersion int, recalledAt time.Time) (*types.Message, error) + Edit(tenantID types.TenantID, topic string, seq int64, actor types.Uid, expectedVersion int, head types.KVMap, content any, reason string, editedAt time.Time) (*types.Message, error) DeleteList(tenantID types.TenantID, topic string, delID int, forUser types.Uid, msgDelAge time.Duration, ranges []types.Range) error GetAll(tenantID types.TenantID, topic string, forUser types.Uid, opt *types.QueryOpt) ([]types.Message, error) GetDeleted(tenantID types.TenantID, topic string, forUser types.Uid, opt *types.QueryOpt) ([]types.Range, int, error) @@ -1082,23 +1273,35 @@ type messagesMapper struct{} // Messages is a singleton ancor object for exporting MessagesPersistenceInterface. var Messages MessagesPersistenceInterface -func (messagesMapper) GetOne(tenantID types.TenantID, topic string, seq int) (*types.Message, error) { +func (messagesMapper) GetOne(tenantID types.TenantID, topic string, seq int64) (*types.Message, error) { tenantAdp, err := tenantBusinessAdapter(tenantID) if err != nil { return nil, err } + if types.GetTopicCat(topic) == types.TopicCatGrp { + return tenantAdp.TenantGroupMessageGetOne(tenantID, topic, seq) + } return tenantAdp.TenantMessageGetOne(tenantID, topic, seq) } -func (messagesMapper) Recall(tenantID types.TenantID, topic string, seq int, actor types.Uid, expectedVersion int, recalledAt time.Time) (*types.Message, error) { +func (messagesMapper) Recall(tenantID types.TenantID, topic string, seq int64, actor types.Uid, expectedVersion int, recalledAt time.Time) (*types.Message, error) { tenantAdp, err := tenantBusinessAdapter(tenantID) if err != nil { return nil, err } + if types.GetTopicCat(topic) == types.TopicCatGrp { + msg, err := tenantAdp.TenantGroupMessageRecall(tenantID, topic, seq, actor, expectedVersion, recalledAt) + if err == nil && groupMsgRuntime != nil { + if cacheErr := groupMsgRuntime.CacheHotMessage(tenantID, msg); cacheErr != nil { + logs.Warn.Printf("topic[%s]: failed to refresh recalled group message cache: %v", topic, cacheErr) + } + } + return msg, err + } return tenantAdp.TenantMessageRecall(tenantID, topic, seq, actor, expectedVersion, recalledAt) } -func (messagesMapper) Edit(tenantID types.TenantID, topic string, seq int, actor types.Uid, expectedVersion int, head types.KVMap, content any, reason string, editedAt time.Time) (*types.Message, error) { +func (messagesMapper) Edit(tenantID types.TenantID, topic string, seq int64, actor types.Uid, expectedVersion int, head types.KVMap, content any, reason string, editedAt time.Time) (*types.Message, error) { if err := validateMessageHeaders(head); err != nil { return nil, err } @@ -1106,6 +1309,15 @@ func (messagesMapper) Edit(tenantID types.TenantID, topic string, seq int, actor if err != nil { return nil, err } + if types.GetTopicCat(topic) == types.TopicCatGrp { + msg, err := tenantAdp.TenantGroupMessageEdit(tenantID, topic, seq, actor, expectedVersion, head, content, reason, editedAt) + if err == nil && groupMsgRuntime != nil { + if cacheErr := groupMsgRuntime.CacheHotMessage(tenantID, msg); cacheErr != nil { + logs.Warn.Printf("topic[%s]: failed to refresh edited group message cache: %v", topic, cacheErr) + } + } + return msg, err + } return tenantAdp.TenantMessageEdit(tenantID, topic, seq, actor, expectedVersion, head, content, reason, editedAt) } @@ -1219,8 +1431,8 @@ func (conversationsMapper) PutDraft(tenantID types.TenantID, uid types.Uid, topi type CallsPersistenceInterface interface { Create(tenantID types.TenantID, record *types.CallRecord) error - Accept(tenantID types.TenantID, callID string, seq int, acceptedAt time.Time) error - Finish(tenantID types.TenantID, callID string, seq int, status string, durationMS int64, finishedAt time.Time) error + Accept(tenantID types.TenantID, callID string, seq int64, acceptedAt time.Time) error + Finish(tenantID types.TenantID, callID string, seq int64, status string, durationMS int64, finishedAt time.Time) error List(tenantID types.TenantID, uid types.Uid, topic, cursor string, limit int) ([]types.CallRecord, string, error) } @@ -1241,7 +1453,7 @@ func (callsMapper) Create(tenantID types.TenantID, record *types.CallRecord) err return tenantAdp.TenantCallCreate(tenantID, record) } -func (callsMapper) Accept(tenantID types.TenantID, callID string, seq int, acceptedAt time.Time) error { +func (callsMapper) Accept(tenantID types.TenantID, callID string, seq int64, acceptedAt time.Time) error { if strings.TrimSpace(callID) == "" || seq <= 0 || acceptedAt.IsZero() { return types.ErrMalformed } @@ -1252,7 +1464,7 @@ func (callsMapper) Accept(tenantID types.TenantID, callID string, seq int, accep return tenantAdp.TenantCallAccept(tenantID, callID, seq, acceptedAt) } -func (callsMapper) Finish(tenantID types.TenantID, callID string, seq int, status string, durationMS int64, finishedAt time.Time) error { +func (callsMapper) Finish(tenantID types.TenantID, callID string, seq int64, status string, durationMS int64, finishedAt time.Time) error { switch status { case types.CallStatusFinished, types.CallStatusMissed, types.CallStatusDeclined, types.CallStatusDisconnected: default: @@ -1348,11 +1560,24 @@ func (groupBusinessMapper) GetSettings(tenantID types.TenantID, topic string) (* if strings.TrimSpace(topic) == "" { return nil, types.ErrMalformed } + topic = strings.TrimSpace(topic) + var cached types.GroupSettings + if ok, err := GetCachedObject(tenantID, cacheNamespaceGroupSettings, topic, &cached); err != nil { + logs.Warn.Printf("cache group settings[%s]: read failed: %v", topic, err) + } else if ok { + return &cached, nil + } tenantAdp, err := tenantBusinessAdapter(tenantID) if err != nil { return nil, err } - return tenantAdp.TenantGroupSettingsGet(tenantID, strings.TrimSpace(topic)) + settings, err := tenantAdp.TenantGroupSettingsGet(tenantID, topic) + if err == nil && settings != nil { + if cacheErr := CacheObject(tenantID, cacheNamespaceGroupSettings, topic, settings); cacheErr != nil { + logs.Warn.Printf("cache group settings[%s]: write failed: %v", topic, cacheErr) + } + } + return settings, err } func (groupBusinessMapper) UpdateSettings(tenantID types.TenantID, topic string, patch types.GroupSettingsPatch) (*types.GroupSettings, error) { @@ -1363,7 +1588,15 @@ func (groupBusinessMapper) UpdateSettings(tenantID types.TenantID, topic string, if err != nil { return nil, err } - return tenantAdp.TenantGroupSettingsUpdate(tenantID, strings.TrimSpace(topic), patch) + settings, err := tenantAdp.TenantGroupSettingsUpdate(tenantID, strings.TrimSpace(topic), patch) + if err == nil { + if settings != nil { + _ = CacheObject(tenantID, cacheNamespaceGroupSettings, strings.TrimSpace(topic), settings) + } else { + _ = DeleteCachedObject(tenantID, cacheNamespaceGroupSettings, strings.TrimSpace(topic)) + } + } + return settings, err } func (groupBusinessMapper) UpsertMember(tenantID types.TenantID, member *types.GroupMember) (*types.GroupMember, error) { @@ -1374,18 +1607,36 @@ func (groupBusinessMapper) UpsertMember(tenantID types.TenantID, member *types.G if err != nil { return nil, err } - return tenantAdp.TenantGroupMemberUpsert(tenantID, member) + member, err = tenantAdp.TenantGroupMemberUpsert(tenantID, member) + if err == nil && member != nil { + _ = CacheObject(tenantID, cacheNamespaceGroupMember, groupMemberCacheID(member.Topic, member.UserID), member) + } + return member, err } func (groupBusinessMapper) GetMember(tenantID types.TenantID, topic string, uid types.Uid) (*types.GroupMember, error) { if strings.TrimSpace(topic) == "" || uid.IsZero() { return nil, types.ErrMalformed } + topic = strings.TrimSpace(topic) + cacheID := groupMemberCacheID(topic, uid) + var cached types.GroupMember + if ok, err := GetCachedObject(tenantID, cacheNamespaceGroupMember, cacheID, &cached); err != nil { + logs.Warn.Printf("cache group member[%s]: read failed: %v", cacheID, err) + } else if ok { + return &cached, nil + } tenantAdp, err := tenantBusinessAdapter(tenantID) if err != nil { return nil, err } - return tenantAdp.TenantGroupMemberGet(tenantID, strings.TrimSpace(topic), uid) + member, err := tenantAdp.TenantGroupMemberGet(tenantID, topic, uid) + if err == nil && member != nil { + if cacheErr := CacheObject(tenantID, cacheNamespaceGroupMember, cacheID, member); cacheErr != nil { + logs.Warn.Printf("cache group member[%s]: write failed: %v", cacheID, cacheErr) + } + } + return member, err } func (groupBusinessMapper) ListMembers(tenantID types.TenantID, topic string, opts types.GroupMemberListOptions) ([]types.GroupMember, string, error) { @@ -1402,6 +1653,28 @@ func (groupBusinessMapper) ListMembers(tenantID types.TenantID, topic string, op return tenantAdp.TenantGroupMemberList(tenantID, strings.TrimSpace(topic), opts) } +func CacheGroupMemberPage(tenantID types.TenantID, key string, payload map[string]any) { + if strings.TrimSpace(key) == "" || payload == nil { + return + } + if err := CacheObject(tenantID, cacheNamespaceGroupPage, strings.TrimSpace(key), payload); err != nil { + logs.Warn.Printf("cache group member page[%s]: write failed: %v", key, err) + } +} + +func GetCachedGroupMemberPage(tenantID types.TenantID, key string) (map[string]any, bool) { + if strings.TrimSpace(key) == "" { + return nil, false + } + var payload map[string]any + if ok, err := GetCachedObject(tenantID, cacheNamespaceGroupPage, strings.TrimSpace(key), &payload); err != nil { + logs.Warn.Printf("cache group member page[%s]: read failed: %v", key, err) + } else if ok { + return payload, true + } + return nil, false +} + func (groupBusinessMapper) UpdateMember(tenantID types.TenantID, topic string, uid types.Uid, patch types.GroupMemberPatch) (*types.GroupMember, error) { if strings.TrimSpace(topic) == "" || uid.IsZero() { return nil, types.ErrMalformed @@ -1410,7 +1683,15 @@ func (groupBusinessMapper) UpdateMember(tenantID types.TenantID, topic string, u if err != nil { return nil, err } - return tenantAdp.TenantGroupMemberUpdate(tenantID, strings.TrimSpace(topic), uid, patch) + member, err := tenantAdp.TenantGroupMemberUpdate(tenantID, strings.TrimSpace(topic), uid, patch) + if err == nil { + if member != nil { + _ = CacheObject(tenantID, cacheNamespaceGroupMember, groupMemberCacheID(strings.TrimSpace(topic), uid), member) + } else { + _ = DeleteCachedObject(tenantID, cacheNamespaceGroupMember, groupMemberCacheID(strings.TrimSpace(topic), uid)) + } + } + return member, err } func (groupBusinessMapper) CreateInvitation(tenantID types.TenantID, invitation *types.GroupInvitation) (*types.GroupInvitation, error) { @@ -1597,7 +1878,13 @@ func (groupBusinessMapper) Leave(tenantID types.TenantID, topic string, uid type if err != nil { return err } - return tenantAdp.TenantGroupLeave(tenantID, strings.TrimSpace(topic), uid, now) + if err = tenantAdp.TenantGroupLeave(tenantID, strings.TrimSpace(topic), uid, now); err != nil { + return err + } + _ = DeleteCachedObject(tenantID, cacheNamespaceGroupMember, groupMemberCacheID(strings.TrimSpace(topic), uid)) + _ = DeleteCachedObject(tenantID, cacheNamespaceSub, subscriptionCacheID(strings.TrimSpace(topic), uid, false)) + _ = DeleteCachedObject(tenantID, cacheNamespaceSub, subscriptionCacheID(strings.TrimSpace(topic), uid, true)) + return nil } func (groupBusinessMapper) UpdateRole(tenantID types.TenantID, topic string, actor, target types.Uid, @@ -1609,7 +1896,13 @@ func (groupBusinessMapper) UpdateRole(tenantID types.TenantID, topic string, act if err != nil { return err } - return tenantAdp.TenantGroupRoleUpdate(tenantID, strings.TrimSpace(topic), actor, target, strings.TrimSpace(role), modeGiven, now) + if err = tenantAdp.TenantGroupRoleUpdate(tenantID, strings.TrimSpace(topic), actor, target, strings.TrimSpace(role), modeGiven, now); err != nil { + return err + } + _ = DeleteCachedObject(tenantID, cacheNamespaceGroupMember, groupMemberCacheID(strings.TrimSpace(topic), target)) + _ = DeleteCachedObject(tenantID, cacheNamespaceSub, subscriptionCacheID(strings.TrimSpace(topic), target, false)) + _ = DeleteCachedObject(tenantID, cacheNamespaceSub, subscriptionCacheID(strings.TrimSpace(topic), target, true)) + return nil } func (groupBusinessMapper) TransferOwner(tenantID types.TenantID, topic string, previousOwner, newOwner types.Uid, @@ -1657,6 +1950,10 @@ func (groupBusinessMapper) MarkEventDelivered(tenantID types.TenantID, eventID s return tenantAdp.TenantGroupEventMarkDelivered(tenantID, strings.TrimSpace(eventID), now) } +func groupMemberCacheID(topic string, uid types.Uid) string { + return strings.TrimSpace(topic) + ":" + uid.String() +} + type WorkbenchPersistenceInterface interface { Get(tenantID types.TenantID, uid types.Uid) ([]types.WorkbenchModule, int, error) } @@ -2453,7 +2750,11 @@ func (relationsMapper) ResolveFriendRequest(tenantID types.TenantID, requestID s return nil, err } if status == types.FriendRequestAccepted { - return tenantAdp.TenantFriendRequestAccept(tenantID, requestID, actor, handledAt) + req, err := tenantAdp.TenantFriendRequestAccept(tenantID, requestID, actor, handledAt) + if err == nil && req != nil { + cacheFriendPair(tenantID, req.ApplicantID, req.TargetID) + } + return req, err } req, err := tenantAdp.TenantFriendRequestResolve(tenantID, requestID, actor, status, handledAt) if err != nil { @@ -2473,7 +2774,13 @@ func (relationsMapper) ListFriends(tenantID types.TenantID, uid types.Uid, query if err != nil { return nil, "", err } - return tenantAdp.TenantFriendshipList(tenantID, uid, query, initial, cursor, limit) + items, nextCursor, err := tenantAdp.TenantFriendshipList(tenantID, uid, query, initial, cursor, limit) + if err == nil && query == "" && initial == "" { + for _, item := range items { + cacheFriendOneWay(tenantID, uid, item.Friendship.PeerID) + } + } + return items, nextCursor, err } func (relationsMapper) GetRelationshipContext(tenantID types.TenantID, userID, peerID types.Uid, topic string) (*types.RelationshipContext, error) { @@ -2506,7 +2813,11 @@ func (relationsMapper) BlockUser(tenantID types.TenantID, blockerID, blockedID t if err != nil { return nil, err } - return tenantAdp.TenantUserBlockUpsert(tenantID, blockerID, blockedID, now) + block, err := tenantAdp.TenantUserBlockUpsert(tenantID, blockerID, blockedID, now) + if err == nil { + cacheBlockOneWay(tenantID, blockerID, blockedID) + } + return block, err } func (relationsMapper) ListBlocks(tenantID types.TenantID, blockerID types.Uid, cursor string, limit int) ([]types.BlockListItem, string, error) { @@ -2520,7 +2831,13 @@ func (relationsMapper) ListBlocks(tenantID types.TenantID, blockerID types.Uid, if err != nil { return nil, "", err } - return tenantAdp.TenantUserBlockList(tenantID, blockerID, cursor, limit) + items, nextCursor, err := tenantAdp.TenantUserBlockList(tenantID, blockerID, cursor, limit) + if err == nil { + for _, item := range items { + cacheBlockOneWay(tenantID, blockerID, item.Block.BlockedID) + } + } + return items, nextCursor, err } func (relationsMapper) UnblockUser(tenantID types.TenantID, blockerID, blockedID types.Uid) error { @@ -2531,7 +2848,11 @@ func (relationsMapper) UnblockUser(tenantID types.TenantID, blockerID, blockedID if err != nil { return err } - return tenantAdp.TenantUserBlockDelete(tenantID, blockerID, blockedID) + err = tenantAdp.TenantUserBlockDelete(tenantID, blockerID, blockedID) + if err == nil { + uncacheBlockOneWay(tenantID, blockerID, blockedID) + } + return err } func (relationsMapper) DeleteFriend(tenantID types.TenantID, userID, peerID types.Uid, now time.Time) error { @@ -2542,7 +2863,11 @@ func (relationsMapper) DeleteFriend(tenantID types.TenantID, userID, peerID type if err != nil { return err } - return tenantAdp.TenantFriendshipDeletePair(tenantID, userID, peerID, now) + err = tenantAdp.TenantFriendshipDeletePair(tenantID, userID, peerID, now) + if err == nil { + uncacheFriendPair(tenantID, userID, peerID) + } + return err } func (relationsMapper) UpdateFriendRemark(tenantID types.TenantID, userID, peerID types.Uid, remark string, now time.Time) (*types.Friendship, error) { @@ -2620,21 +2945,46 @@ func relationshipContext(tenantAdp adapter.TenantBusinessAdapter, tenantID types return nil, err } ctx.Peer = peer + if cachedBlock, err := cachedBlockContains(tenantID, userID, peerID); err != nil { + logs.Warn.Printf("cache block[%s:%s]: read failed: %v", userID.UserId(), peerID.UserId(), err) + } else if cachedBlock { + ctx.Relationship = types.RelationshipBlockedByMe + return ctx, nil + } if block, err := tenantAdp.TenantUserBlockGet(tenantID, userID, peerID); err != nil { return nil, err } else if block != nil { + cacheBlockOneWay(tenantID, userID, peerID) ctx.Relationship = types.RelationshipBlockedByMe return ctx, nil } + if cachedBlock, err := cachedBlockContains(tenantID, peerID, userID); err != nil { + logs.Warn.Printf("cache block[%s:%s]: read failed: %v", peerID.UserId(), userID.UserId(), err) + } else if cachedBlock { + ctx.Relationship = types.RelationshipBlockedByPeer + return ctx, nil + } if block, err := tenantAdp.TenantUserBlockGet(tenantID, peerID, userID); err != nil { return nil, err } else if block != nil { + cacheBlockOneWay(tenantID, peerID, userID) ctx.Relationship = types.RelationshipBlockedByPeer return ctx, nil } + 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 { + ctx.Relationship = types.RelationshipFriend + ctx.CanSend = true + if ctx.Topic == "" { + ctx.Topic = userID.P2PName(peerID) + } + return ctx, nil + } if friendship, err := tenantAdp.TenantFriendshipGet(tenantID, userID, peerID); err != nil { return nil, err } else if friendship != nil && friendship.Status == types.RelationshipFriend { + cacheFriendOneWay(tenantID, userID, peerID) ctx.Relationship = types.RelationshipFriend ctx.CanSend = true if ctx.Topic == "" { @@ -2658,6 +3008,50 @@ func relationshipContext(tenantAdp adapter.TenantBusinessAdapter, tenantID types return ctx, nil } +func relationSetID(uid types.Uid) string { + return uid.UserId() +} + +func cacheFriendPair(tenantID types.TenantID, uid1, uid2 types.Uid) { + cacheFriendOneWay(tenantID, uid1, uid2) + cacheFriendOneWay(tenantID, uid2, uid1) +} + +func cacheFriendOneWay(tenantID types.TenantID, uid, peer types.Uid) { + if err := AddCachedSetMember(tenantID, cacheNamespaceFriends, relationSetID(uid), peer.UserId()); err != nil { + logs.Warn.Printf("cache friends[%s]: add failed: %v", uid.UserId(), err) + } +} + +func uncacheFriendPair(tenantID types.TenantID, uid1, uid2 types.Uid) { + if err := RemoveCachedSetMember(tenantID, cacheNamespaceFriends, relationSetID(uid1), uid2.UserId()); err != nil { + logs.Warn.Printf("cache friends[%s]: remove failed: %v", uid1.UserId(), err) + } + if err := RemoveCachedSetMember(tenantID, cacheNamespaceFriends, relationSetID(uid2), uid1.UserId()); err != nil { + logs.Warn.Printf("cache friends[%s]: remove failed: %v", uid2.UserId(), err) + } +} + +func cachedFriendContains(tenantID types.TenantID, uid, peer types.Uid) (bool, error) { + return CachedSetContains(tenantID, cacheNamespaceFriends, relationSetID(uid), peer.UserId()) +} + +func cacheBlockOneWay(tenantID types.TenantID, blocker, blocked types.Uid) { + if err := AddCachedSetMember(tenantID, cacheNamespaceBlocklist, relationSetID(blocker), blocked.UserId()); err != nil { + logs.Warn.Printf("cache blocklist[%s]: add failed: %v", blocker.UserId(), err) + } +} + +func uncacheBlockOneWay(tenantID types.TenantID, blocker, blocked types.Uid) { + if err := RemoveCachedSetMember(tenantID, cacheNamespaceBlocklist, relationSetID(blocker), blocked.UserId()); err != nil { + logs.Warn.Printf("cache blocklist[%s]: remove failed: %v", blocker.UserId(), err) + } +} + +func cachedBlockContains(tenantID types.TenantID, blocker, blocked types.Uid) (bool, error) { + return CachedSetContains(tenantID, cacheNamespaceBlocklist, relationSetID(blocker), blocked.UserId()) +} + // Save message func (messagesMapper) Save(tenantID types.TenantID, msg *types.Message, attachmentURLs []string, readBySender bool) (*types.MessageSaveResult, error) { if msg == nil || msg.TenantID != tenantID { @@ -2673,8 +3067,37 @@ func (messagesMapper) Save(tenantID types.TenantID, msg *types.Message, attachme } msg.ClientMsgID = clientMsgID fromUid := types.ParseUid(msg.From) + isGroupMessage := types.GetTopicCat(msg.Topic) == types.TopicCatGrp + if isGroupMessage && groupMsgRuntime != nil { + if clientMsgID != "" { + claim, claimed, err := groupMsgRuntime.Claim(tenantID, msg.Topic, fromUid, clientMsgID) + if err != nil { + return nil, err + } + if !claimed { + if claim != nil && claim.Status == "done" && claim.Seq > 0 { + msg.SeqId = claim.Seq + return &types.MessageSaveResult{SeqId: claim.Seq, Duplicate: true}, nil + } + return nil, types.ErrDuplicate + } + } + seq, err := groupMsgRuntime.NextSeq(tenantID, msg.Topic, msg.SeqId-1) + if err != nil { + if clientMsgID != "" { + _ = groupMsgRuntime.MarkFailed(tenantID, msg.Topic, fromUid, clientMsgID) + } + return nil, err + } + msg.SeqId = seq + } if clientMsgID != "" { - duplicate, err := tenantAdp.TenantMessageGetByClientID(tenantID, fromUid, clientMsgID) + var duplicate *types.Message + if isGroupMessage { + duplicate, err = tenantAdp.TenantGroupMessageGetByClientID(tenantID, msg.Topic, fromUid, clientMsgID) + } else { + duplicate, err = tenantAdp.TenantMessageGetByClientID(tenantID, fromUid, clientMsgID) + } if err != nil { return nil, err } @@ -2700,10 +3123,40 @@ func (messagesMapper) Save(tenantID types.TenantID, msg *types.Message, attachme return nil, err } - err = tenantAdp.TenantMessageSave(tenantID, msg) + if isGroupMessage { + err = tenantAdp.TenantGroupMessageSave(tenantID, msg) + } else { + err = tenantAdp.TenantMessageSave(tenantID, msg) + } if err != nil { + if isGroupMessage && err == types.ErrDuplicate && clientMsgID != "" { + duplicate, dupErr := tenantAdp.TenantGroupMessageGetByClientID(tenantID, msg.Topic, fromUid, clientMsgID) + if dupErr != nil { + return nil, dupErr + } + if duplicate != nil { + msg.SeqId = duplicate.SeqId + if groupMsgRuntime != nil { + _ = groupMsgRuntime.MarkDone(tenantID, msg.Topic, fromUid, clientMsgID, duplicate.SeqId, duplicate.Uid().String()) + } + return &types.MessageSaveResult{SeqId: duplicate.SeqId, Duplicate: true}, nil + } + } + if isGroupMessage && groupMsgRuntime != nil && clientMsgID != "" { + _ = groupMsgRuntime.MarkFailed(tenantID, msg.Topic, fromUid, clientMsgID) + } return nil, err } + if isGroupMessage && groupMsgRuntime != nil && clientMsgID != "" { + if err := groupMsgRuntime.MarkDone(tenantID, msg.Topic, fromUid, clientMsgID, msg.SeqId, msg.Uid().String()); err != nil { + logs.Warn.Printf("topic[%s]: failed to mark group message unique key done: %v", msg.Topic, err) + } + } + if isGroupMessage && groupMsgRuntime != nil { + if err := groupMsgRuntime.CacheHotMessage(tenantID, msg); err != nil { + logs.Warn.Printf("topic[%s]: failed to cache hot group message: %v", msg.Topic, err) + } + } markedReadBySender := false // Mark message as read by the sender. @@ -2730,7 +3183,22 @@ func (messagesMapper) Save(tenantID types.TenantID, msg *types.Message, attachme attachments = append(attachments, fid.String()) } } - if len(attachments) > 0 { + if len(attachments) > 0 && isGroupMessage { + fileType := groupFileTypeFromMessageHead(msg.Head) + for _, fileID := range attachments { + if _, err := tenantAdp.TenantGroupFileCreate(tenantID, &types.GroupFile{ + Topic: msg.Topic, + FileID: fileID, + SourceTopic: msg.Topic, + SourceSeq: msg.SeqId, + UploaderID: fromUid, + FileType: fileType, + IdempotencyKey: fmt.Sprintf("%s:%d:%s", msg.Topic, msg.SeqId, fileID), + }); err != nil { + return nil, err + } + } + } else if len(attachments) > 0 { if err := tenantAdp.TenantFileLinkAttachments(tenantID, "", types.ZeroUid, msg.Uid(), attachments); err != nil { return nil, err } @@ -2872,6 +3340,22 @@ func canonicalIMMessageType(value string) string { } } +func groupFileTypeFromMessageHead(head types.KVMap) string { + msgType, ok := messageHeaderString(head, "x-im-type") + if !ok { + msgType, ok = messageHeaderString(head, "type") + } + if !ok { + return "file" + } + switch canonicalIMMessageType(msgType) { + case "image", "audio", "voice", "video": + return canonicalIMMessageType(msgType) + default: + return "file" + } +} + // DeleteList deletes multiple messages defined by a list of ranges. func (messagesMapper) DeleteList(tenantID types.TenantID, topic string, delID int, forUser types.Uid, msgDelAge time.Duration, ranges []types.Range) error { tenantAdp, err := tenantBusinessAdapter(tenantID) @@ -2893,10 +3377,20 @@ func (messagesMapper) DeleteList(tenantID types.TenantID, topic string, delID in } } - err = tenantAdp.TenantMessageDeleteList(tenantID, topic, toDel) + isGroupMessage := types.GetTopicCat(topic) == types.TopicCatGrp + if isGroupMessage { + err = tenantAdp.TenantGroupMessageDeleteList(tenantID, topic, toDel) + } else { + err = tenantAdp.TenantMessageDeleteList(tenantID, topic, toDel) + } if err != nil { return err } + if isGroupMessage && groupMsgRuntime != nil { + if cacheErr := groupMsgRuntime.InvalidateHotMessages(tenantID, topic, ranges); cacheErr != nil { + logs.Warn.Printf("topic[%s]: failed to invalidate deleted group message cache: %v", topic, cacheErr) + } + } // TODO: move to adapter. if delID > 0 { @@ -2923,6 +3417,16 @@ func (messagesMapper) GetAll(tenantID types.TenantID, topic string, forUser type if err != nil { return nil, err } + if types.GetTopicCat(topic) == types.TopicCatGrp { + if groupMsgRuntime != nil && forUser.IsZero() { + if msgs, ok, cacheErr := groupMsgRuntime.GetHotMessages(tenantID, topic, opt, 100); cacheErr != nil { + logs.Warn.Printf("topic[%s]: failed to read hot group messages: %v", topic, cacheErr) + } else if ok { + return msgs, nil + } + } + return tenantAdp.TenantGroupMessageGetAll(tenantID, topic, forUser, opt) + } return tenantAdp.TenantMessageGetAll(tenantID, topic, forUser, opt) } @@ -3441,7 +3945,13 @@ func (userSecurityMapper) CreateActionToken(tenantID types.TenantID, token *type if err != nil { return err } - return tenantAdp.TenantActionTokenCreate(tenantID, token) + if err = tenantAdp.TenantActionTokenCreate(tenantID, token); err != nil { + return err + } + if cacheErr := CacheObject(tenantID, cacheNamespaceActionToken, actionTokenCacheID(token.UserID, token.TokenID, token.Action, token.Target), token); cacheErr != nil { + logs.Warn.Printf("cache action token[%s]: write failed: %v", token.TokenID, cacheErr) + } + return nil } func (userSecurityMapper) ConsumeActionToken(tenantID types.TenantID, uid types.Uid, token, action, target string, now time.Time) (*types.ActionToken, error) { @@ -3449,7 +3959,12 @@ func (userSecurityMapper) ConsumeActionToken(tenantID types.TenantID, uid types. if err != nil { return nil, err } - return tenantAdp.TenantActionTokenConsume(tenantID, uid, token, action, target, now) + consumed, err := tenantAdp.TenantActionTokenConsume(tenantID, uid, token, action, target, now) + if err == nil { + tokenID, _, _ := strings.Cut(token, ".") + _ = DeleteCachedObject(tenantID, cacheNamespaceActionToken, actionTokenCacheID(uid, tokenID, action, target)) + } + return consumed, err } func (userSecurityMapper) GetIndependentPassword(tenantID types.TenantID, uid types.Uid, purpose string) (*types.IndependentPassword, error) { @@ -3547,23 +4062,59 @@ func (verificationMapper) Create(tenantID types.TenantID, vc *types.Verification if err != nil { return err } - return tenantAdp.TenantVerificationCreate(tenantID, vc) + if err = tenantAdp.TenantVerificationCreate(tenantID, vc); err != nil { + return err + } + if cacheErr := cacheVerificationCode(tenantID, vc); cacheErr != nil { + logs.Warn.Printf("cache verification[%s]: write failed: %v", vc.ChallengeID, cacheErr) + } + return nil } func (verificationMapper) Get(tenantID types.TenantID, challengeID string) (*types.VerificationCode, error) { + var cached types.VerificationCode + if ok, err := GetCachedObject(tenantID, cacheNamespaceVerification, challengeID, &cached); err != nil { + logs.Warn.Printf("cache verification[%s]: read failed: %v", challengeID, err) + } else if ok { + return &cached, nil + } tenantAdp, err := tenantBusinessAdapter(tenantID) if err != nil { return nil, err } - return tenantAdp.TenantVerificationGet(tenantID, challengeID) + vc, err := tenantAdp.TenantVerificationGet(tenantID, challengeID) + if err == nil && vc != nil { + if cacheErr := cacheVerificationCode(tenantID, vc); cacheErr != nil { + logs.Warn.Printf("cache verification[%s]: write failed: %v", challengeID, cacheErr) + } + } + return vc, err } func (verificationMapper) GetActive(tenantID types.TenantID, scene, phoneHash string, now time.Time) (*types.VerificationCode, error) { + activeCacheID := verificationActiveCacheID(scene, phoneHash) + var challengeID string + if ok, err := GetCachedObject(tenantID, cacheNamespaceVerification, activeCacheID, &challengeID); err != nil { + logs.Warn.Printf("cache verification active[%s]: read failed: %v", activeCacheID, err) + } else if ok && challengeID != "" { + vc, err := Verifications.Get(tenantID, challengeID) + if err == nil && vc != nil && vc.Scene == scene && vc.PhoneHash == phoneHash && + vc.State == types.VerificationStatePending && now.Before(vc.ExpiresAt) { + return vc, nil + } + _ = DeleteCachedObject(tenantID, cacheNamespaceVerification, activeCacheID) + } tenantAdp, err := tenantBusinessAdapter(tenantID) if err != nil { return nil, err } - return tenantAdp.TenantVerificationGetActive(tenantID, scene, phoneHash, now) + vc, err := tenantAdp.TenantVerificationGetActive(tenantID, scene, phoneHash, now) + if err == nil && vc != nil { + if cacheErr := cacheVerificationCode(tenantID, vc); cacheErr != nil { + logs.Warn.Printf("cache verification active[%s]: write failed: %v", activeCacheID, cacheErr) + } + } + return vc, err } func (verificationMapper) Fail(tenantID types.TenantID, challengeID string, state types.VerificationState) error { @@ -3571,7 +4122,15 @@ func (verificationMapper) Fail(tenantID types.TenantID, challengeID string, stat if err != nil { return err } - return tenantAdp.TenantVerificationFail(tenantID, challengeID, state) + if err = tenantAdp.TenantVerificationFail(tenantID, challengeID, state); err != nil { + return err + } + if vc, getErr := tenantAdp.TenantVerificationGet(tenantID, challengeID); getErr == nil && vc != nil { + _ = cacheVerificationCode(tenantID, vc) + } else { + _ = DeleteCachedObject(tenantID, cacheNamespaceVerification, challengeID) + } + return nil } func (verificationMapper) MarkVerified(tenantID types.TenantID, challengeID, tokenHash string, verifiedAt time.Time) error { @@ -3579,7 +4138,16 @@ func (verificationMapper) MarkVerified(tenantID types.TenantID, challengeID, tok if err != nil { return err } - return tenantAdp.TenantVerificationMarkVerified(tenantID, challengeID, tokenHash, verifiedAt) + if err = tenantAdp.TenantVerificationMarkVerified(tenantID, challengeID, tokenHash, verifiedAt); err != nil { + return err + } + if vc, getErr := tenantAdp.TenantVerificationGet(tenantID, challengeID); getErr == nil && vc != nil { + _ = cacheVerificationCode(tenantID, vc) + _ = DeleteCachedObject(tenantID, cacheNamespaceVerification, verificationActiveCacheID(vc.Scene, vc.PhoneHash)) + } else { + _ = DeleteCachedObject(tenantID, cacheNamespaceVerification, challengeID) + } + return nil } func (verificationMapper) Consume(tenantID types.TenantID, challengeID, tokenHash string, consumedAt time.Time) error { @@ -3587,7 +4155,37 @@ func (verificationMapper) Consume(tenantID types.TenantID, challengeID, tokenHas if err != nil { return err } - return tenantAdp.TenantVerificationConsume(tenantID, challengeID, tokenHash, consumedAt) + if err = tenantAdp.TenantVerificationConsume(tenantID, challengeID, tokenHash, consumedAt); err != nil { + return err + } + if vc, getErr := tenantAdp.TenantVerificationGet(tenantID, challengeID); getErr == nil && vc != nil { + _ = cacheVerificationCode(tenantID, vc) + _ = DeleteCachedObject(tenantID, cacheNamespaceVerification, verificationActiveCacheID(vc.Scene, vc.PhoneHash)) + } else { + _ = DeleteCachedObject(tenantID, cacheNamespaceVerification, challengeID) + } + return nil +} + +func cacheVerificationCode(tenantID types.TenantID, vc *types.VerificationCode) error { + if vc == nil { + return nil + } + if err := CacheObject(tenantID, cacheNamespaceVerification, vc.ChallengeID, vc); err != nil { + return err + } + if vc.State == types.VerificationStatePending { + return CacheObject(tenantID, cacheNamespaceVerification, verificationActiveCacheID(vc.Scene, vc.PhoneHash), vc.ChallengeID) + } + return nil +} + +func verificationActiveCacheID(scene, phoneHash string) string { + return "active:" + strings.TrimSpace(scene) + ":" + strings.TrimSpace(phoneHash) +} + +func actionTokenCacheID(uid types.Uid, tokenID, action, target string) string { + return uid.String() + ":" + strings.TrimSpace(tokenID) + ":" + strings.TrimSpace(action) + ":" + strings.TrimSpace(target) } func tenantRegistrationInviteAdapter(tenantID types.TenantID) (adapter.TenantRegistrationInviteAdapter, error) { diff --git a/server/store/types/calls.go b/server/store/types/calls.go index 0cfd94d..bb4f2ff 100644 --- a/server/store/types/calls.go +++ b/server/store/types/calls.go @@ -16,7 +16,7 @@ type CallRecord struct { TenantID TenantID `db:"tenant_id" json:"-" bson:"tenant_id"` CallID string `db:"call_id"` Topic string - SeqID int `db:"seq_id"` + SeqID int64 `db:"seq_id"` CallerID Uid `db:"caller_id"` CalleeID Uid `db:"callee_id"` CallType string `db:"call_type"` diff --git a/server/store/types/favorites.go b/server/store/types/favorites.go index cca4a7d..4d84417 100644 --- a/server/store/types/favorites.go +++ b/server/store/types/favorites.go @@ -9,7 +9,7 @@ type FavoriteItem struct { UserID Uid `db:"user_id"` ItemType string `db:"item_type"` SourceTopic string `db:"source_topic"` - SourceSeq int `db:"source_seq"` + SourceSeq int64 `db:"source_seq"` Snapshot any AttachmentRefs any `db:"attachment_refs"` Availability string `db:"availability"` diff --git a/server/store/types/groups.go b/server/store/types/groups.go index ee6c7d4..e992198 100644 --- a/server/store/types/groups.go +++ b/server/store/types/groups.go @@ -187,7 +187,7 @@ type GroupFile struct { Topic string `db:"topic"` FileID string `db:"file_id"` SourceTopic string `db:"source_topic"` - SourceSeq int `db:"source_seq"` + SourceSeq int64 `db:"source_seq"` UploaderID Uid `db:"-"` FileName string `db:"file_name"` MIMEType string `db:"mime_type"` diff --git a/server/store/types/safety.go b/server/store/types/safety.go index 477a253..fb5fa75 100644 --- a/server/store/types/safety.go +++ b/server/store/types/safety.go @@ -63,7 +63,7 @@ type AbuseReport struct { TargetType string TargetUserID Uid TargetTopic string - TargetSeq int + TargetSeq int64 ReasonCode string Description string EvidenceFileIDs []string diff --git a/server/store/types/types.go b/server/store/types/types.go index c8c4b92..1e01018 100644 --- a/server/store/types/types.go +++ b/server/store/types/types.go @@ -922,9 +922,9 @@ type Subscription struct { // ID of the latest Soft-delete operation DelId int // Last SeqId reported by user as received by at least one of his sessions - RecvSeqId int + RecvSeqId int64 // Last SeqID reported read by the user - ReadSeqId int + ReadSeqId int64 // Access mode requested by this user ModeWant AccessMode @@ -941,7 +941,7 @@ type Subscription struct { // In case of P2P topics this is the Trusted value of the other user. trusted any // deserialized SeqID from user or topic - seqId int + seqId int64 // Deserialized TouchedAt from topic touchedAt time.Time // Timestamp & user agent of when the user was last online. @@ -1013,12 +1013,12 @@ func (s *Subscription) LastModified() time.Time { } // GetSeqId returns seqId. -func (s *Subscription) GetSeqId() int { +func (s *Subscription) GetSeqId() int64 { return s.seqId } // SetSeqId sets seqId field. -func (s *Subscription) SetSeqId(id int) { +func (s *Subscription) SetSeqId(id int64) { s.seqId = id } @@ -1144,7 +1144,7 @@ type Topic struct { Access DefaultAccess // Server-issued sequential ID - SeqId int + SeqId int64 // If messages were deleted, sequential id of the last operation to delete them DelId int @@ -1239,7 +1239,7 @@ type Message struct { DelId int `json:"DelId,omitempty" bson:",omitempty"` // List of users who have marked this message as soft-deleted DeletedFor []SoftDelete `json:"DeletedFor,omitempty" bson:",omitempty"` - SeqId int + SeqId int64 Topic string // Sender's user ID as string (without 'usr' prefix), could be empty. From string @@ -1256,7 +1256,7 @@ type MessageRevision struct { ObjHeader `bson:",inline"` TenantID TenantID `db:"tenant_id" json:"-" bson:"tenant_id"` Topic string - SeqId int + SeqId int64 Version int Actor string Head KVMap `json:"Head,omitempty" bson:",omitempty"` @@ -1266,7 +1266,7 @@ type MessageRevision struct { // MessageSaveResult describes the outcome of a message persistence attempt. type MessageSaveResult struct { - SeqId int + SeqId int64 MarkedReadBySender bool Duplicate bool } @@ -1274,8 +1274,8 @@ type MessageSaveResult struct { // Range is a range of message SeqIDs. Low end is inclusive (closed), high end is exclusive (open): [Low, Hi). // If the range contains just one ID, Hi is set to 0 type Range struct { - Low int - Hi int `json:"Hi,omitempty" bson:",omitempty"` + Low int64 + Hi int64 `json:"Hi,omitempty" bson:",omitempty"` } // RangeSorter is a helper type required by 'sort' package. @@ -1334,7 +1334,7 @@ func (rs RangeSorter) Normalize() RangeSorter { // Convert a slice of int values to a slice of ranges. // The int slice must be sorted low -> high. -func SliceToRanges(in []int) []Range { +func SliceToRanges(in []int64) []Range { if len(in) == 0 { return nil } @@ -1390,8 +1390,8 @@ type QueryOpt struct { Topic string IfModifiedSince *time.Time // ID-based query parameters: Messages - Since int - Before int + Since int64 + Before int64 // Common parameter Limit int // Ranges of IDs. diff --git a/server/tinode.conf b/server/tinode.conf index 52e2e7e..6a6481b 100644 --- a/server/tinode.conf +++ b/server/tinode.conf @@ -85,6 +85,24 @@ "conn_max_lifetime": 60, "sql_timeout": 10 } + }, + "redis": { + "addr": "64.118.137.182:56379", + "username": "", + "password": "HMTJzkm.h>Y+E9ep", + "db": 0, + "key_prefix": "im", + "dial_timeout_ms": 2000, + "read_timeout_ms": 2000, + "write_timeout_ms": 2000, + "pending_ttl_seconds": 120, + "done_ttl_seconds": 86400, + "failed_ttl_seconds": 600, + "hot_message_ttl_seconds": 86400, + "presence_ttl_seconds": 120, + "cache_ttl_seconds": 300, + "short_state_ttl_seconds": 600, + "group_fanout_enabled": true } }, diff --git a/server/topic.go b/server/topic.go index 49ec6e5..c0512c2 100644 --- a/server/topic.go +++ b/server/topic.go @@ -45,7 +45,7 @@ type Topic struct { touched time.Time // Server-side ID of the last data message - lastID int + lastID int64 // ID of the deletion operation. Not an ID of the message. delID int @@ -142,8 +142,8 @@ type perUserData struct { online int // Last t.lastId reported by user through {pres} as received or read - recvID int - readID int + recvID int64 + readID int64 // ID of the latest Delete operation delID int @@ -646,14 +646,14 @@ func (t *Topic) handleServerMsg(msg *ServerComMessage) { logs.Warn.Printf("topic[%s]: rejected cross-tenant server message: topic=%d message=%d", t.name, t.tenantID, msg.TenantID) return } - // Server-generated message: {info} or {pres}. + // Server-generated message: {data}, {info} or {pres}. if t.isInactive() { // Ignore message - the topic is paused or being deleted. return } if msg.Pres != nil { t.handlePresence(msg) - } else if msg.Info != nil { + } else if msg.Info != nil || msg.Data != nil { t.broadcastToSessions(msg) } else { // TODO(gene): maybe remove this panic. @@ -797,6 +797,11 @@ func (t *Topic) handleLeaveRequest(msg *ClientComMessage, sess *Session) { if !sess.background { pud.online-- t.perUser[uid] = pud + if t.cat == types.TopicCatGrp && pud.online == 0 { + if err := store.MarkTopicMemberOffline(t.tenantID, t.name, uid, currentNodeID()); err != nil { + logs.Warn.Printf("topic[%s]: failed to mark member offline in redis: %v", t.name, err) + } + } } } else if len(pssd.muids) > 0 { // UID is zero: multiplexing session is dropped altogether. @@ -805,6 +810,11 @@ func (t *Topic) handleLeaveRequest(msg *ClientComMessage, sess *Session) { pud := t.perUser[uid] pud.online-- t.perUser[uid] = pud + if t.cat == types.TopicCatGrp && pud.online == 0 { + if err := store.MarkTopicMemberOffline(t.tenantID, t.name, uid, currentNodeID()); err != nil { + logs.Warn.Printf("topic[%s]: failed to mark member offline in redis: %v", t.name, err) + } + } } } else if !sess.isCluster() { logs.Warn.Panic("cannot determine uid: leave req", msg, sess) @@ -1119,8 +1129,11 @@ func (t *Topic) saveAndBroadcastMessage(msg *ClientComMessage, asUid types.Uid, return nil } markedReadBySender := saveResult != nil && saveResult.MarkedReadBySender - - t.lastID++ + if saveResult != nil && saveResult.SeqId > t.lastID { + t.lastID = saveResult.SeqId + } else { + t.lastID++ + } t.touched = msg.Timestamp if userFound { @@ -1163,6 +1176,11 @@ func (t *Topic) saveAndBroadcastMessage(msg *ClientComMessage, asUid types.Uid, pluginMessage(t.tenantID, data.Data, plgActCreate) t.broadcastToSessions(data) + if t.cat == types.TopicCatGrp { + if err := store.PublishGroupFanout(currentNodeID(), data); err != nil { + logs.Warn.Printf("topic[%s]: failed to publish redis group fanout: %v", t.name, err) + } + } // sendPush will update unread message count and send push notification. if pushRcpt := t.pushForData(asUid, data.Data, markedReadBySender); pushRcpt != nil { @@ -1332,7 +1350,7 @@ func (t *Topic) handleNoteBroadcast(msg *ClientComMessage) { return } - var read, recv, unread, seq int + var read, recv, unread, seq int64 switch msg.Note.What { case "read": @@ -1593,8 +1611,14 @@ func (t *Topic) subscriptionReply(asChan bool, msg *ClientComMessage) error { // The user is online in the topic. Increment the counter if notifications are not deferred. if !msg.sess.background { userData := t.perUser[asUid] + wasOffline := userData.online == 0 userData.online++ t.perUser[asUid] = userData + if t.cat == types.TopicCatGrp && wasOffline { + if err := store.MarkTopicMemberOnline(t.tenantID, t.name, asUid, currentNodeID()); err != nil { + logs.Warn.Printf("topic[%s]: failed to mark member online in redis: %v", t.name, err) + } + } } if t.cat == types.TopicCatGrp && msgsub.Newsub { @@ -2342,7 +2366,7 @@ func (t *Topic) replyGetDesc(sess *Session, asUid types.Uid, _ bool, opts *MsgGe // t.delID <= pud.delID; t.readID <= t.recvID <= t.lastID desc.DelId = max(pud.delID, t.delID) desc.ReadSeqId = pud.readID - desc.RecvSeqId = max(pud.recvID, pud.readID) + desc.RecvSeqId = max64(pud.recvID, pud.readID) } else { // Send some sane value of touched. desc.TouchedAt = &t.updated @@ -2755,12 +2779,18 @@ func (t *Topic) replyGetSub(sess *Session, asUid types.Uid, authLevel auth.Level mts.Topic = with canViewLastSeen = imCanViewLastSeen(t.tenantID, asUid, types.ParseUserId(with)) mts.Online = t.perSubs[with].online && !deleted && presencer + if !deleted && presencer { + mts.Online = redisUserOnline(t.tenantID, types.ParseUserId(with), mts.Online) + } } else if strings.HasPrefix(sub.Topic, "slf") { mts.Topic = "slf" // Not reporting Online as it makes no sense for slf. } else { mts.Topic = sub.Topic mts.Online = t.perSubs[sub.Topic].online && !deleted && presencer + if !deleted && presencer && strings.HasPrefix(sub.Topic, "usr") { + mts.Online = redisUserOnline(t.tenantID, types.ParseUserId(sub.Topic), mts.Online) + } } if !deleted && !banned { @@ -2808,6 +2838,9 @@ func (t *Topic) replyGetSub(sess *Session, asUid types.Uid, authLevel auth.Level if t.cat == types.TopicCatGrp { pud := t.perUser[uid] mts.Online = pud.online > 0 && presencer + if presencer { + mts.Online = redisUserOnline(t.tenantID, uid, mts.Online) + } } } } @@ -3392,7 +3425,7 @@ func (t *Topic) replyDelMsg(sess *Session, asUid types.Uid, asChan bool, msg *Cl if dq.HiId == 0 { count++ } else { - count += dq.HiId - dq.LowId + count += int(dq.HiId - dq.LowId) } ranges = append(ranges, types.Range{Low: dq.LowId, Hi: dq.HiId}) @@ -3440,7 +3473,7 @@ func (t *Topic) replyDelMsg(sess *Session, asUid types.Uid, asChan bool, msg *Cl unreadDeleted := calculateUnreadInRanges(pud.readID, t.lastID, ranges) if unreadDeleted > 0 { // Decrease unread count (negative value) - usersUpdateUnread(t.tenantID, uid, -unreadDeleted, true) + usersUpdateUnread(t.tenantID, uid, -int64(unreadDeleted), true) } } } @@ -4183,7 +4216,7 @@ func topicNameForUser(name string, uid types.Uid, isChan bool) string { // calculateUnreadInRanges calculates how many unread messages are within the given ranges. // unreadStart is the first unread message SeqId (readID + 1), unreadEnd is the last possible message SeqId. // Assumes ranges are sorted by Low ascending. -func calculateUnreadInRanges(readID, lastID int, ranges []types.Range) int { +func calculateUnreadInRanges(readID, lastID int64, ranges []types.Range) int { if readID >= lastID { // No unread messages return 0 @@ -4207,11 +4240,11 @@ func calculateUnreadInRanges(readID, lastID int, ranges []types.Range) int { } // Find intersection of [unreadStart, unreadEnd] and [rangeStart, rangeEnd) - intersectionStart := max(unreadStart, rangeStart) + intersectionStart := max64(unreadStart, rangeStart) intersectionEnd := min(unreadEnd+1, rangeEnd) // +1 because unreadEnd is inclusive if intersectionStart < intersectionEnd { - count += intersectionEnd - intersectionStart + count += int(intersectionEnd - intersectionStart) } } diff --git a/server/topic_proxy.go b/server/topic_proxy.go index 8c6ff57..22b819c 100644 --- a/server/topic_proxy.go +++ b/server/topic_proxy.go @@ -55,7 +55,9 @@ func (t *Topic) runProxy(hub *Hub) { } case msg := <-t.serverMsg: - if msg.Info != nil || msg.Pres != nil { + if msg.Data != nil { + t.broadcastToSessions(msg) + } else if msg.Info != nil || msg.Pres != nil { globals.cluster.routeToTopicIntraCluster(t.name, msg, msg.sess) } else { // FIXME: should something be done here? diff --git a/server/topic_test.go b/server/topic_test.go index 54e6bfb..2ca4027 100644 --- a/server/topic_test.go +++ b/server/topic_test.go @@ -777,7 +777,7 @@ func TestHandleBroadcastDataDuplicateClientMessage(t *testing.T) { if !ok { t.Fatalf("User1: expected ctrl params map, received %#v", ack.Ctrl.Params) } - if seq, ok := params["seq"].(int); !ok || seq != 4 { + if seq, ok := params["seq"].(int64); !ok || seq != 4 { t.Errorf("User1: expected duplicate ack seq 4, received %#v", params["seq"]) } if len(helper.results[1].messages) != 0 { @@ -949,7 +949,7 @@ func TestHandleBroadcastDataInactiveTopic(t *testing.T) { func TestHandleBroadcastInfoP2P(t *testing.T) { topicName := "usrP2P" numUsers := 2 - readId := 8 + readId := int64(8) helper := TopicTestHelper{} helper.setUp(t, numUsers, types.TopicCatP2P, topicName, true) defer helper.tearDown() @@ -1065,7 +1065,7 @@ func TestHandleBroadcastInfoBogusNotification(t *testing.T) { // Pretend we have 10 messages. helper.topic.lastID = 10 // uid1 notifies uid2 that uid1 has read messages up to seqid 11. - readId := 11 + readId := int64(11) from := helper.uids[0] to := helper.uids[1] @@ -1113,7 +1113,7 @@ func TestHandleBroadcastInfoFilterOutRecvWithoutRPermission(t *testing.T) { // Pretend we have 10 messages. helper.topic.lastID = 10 // uid1 notifies uid2 that uid1 has read messages up to seqid 11. - readId := 8 + readId := int64(8) from := helper.uids[0] to := helper.uids[1] @@ -1166,7 +1166,7 @@ func TestHandleBroadcastInfoFilterOutKpWithoutWPermission(t *testing.T) { // Pretend we have 10 messages. helper.topic.lastID = 10 // uid1 notifies uid2 that uid1 has read messages up to seqid 11. - readId := 8 + readId := int64(8) from := helper.uids[0] to := helper.uids[1] @@ -1219,7 +1219,7 @@ func TestHandleBroadcastInfoDuplicatedRead(t *testing.T) { // Pretend we have 10 messages. helper.topic.lastID = 10 // uid1 notifies uid2 that uid1 has read messages up to seqid 11. - readId := 8 + readId := int64(8) from := helper.uids[0] to := helper.uids[1] @@ -1272,7 +1272,7 @@ func TestHandleBroadcastInfoDbError(t *testing.T) { // Pretend we have 10 messages. helper.topic.lastID = 10 // uid1 notifies uid2 that uid1 has read messages up to seqid 11. - readId := 8 + readId := int64(8) from := helper.uids[0] to := helper.uids[1] @@ -1326,7 +1326,7 @@ func TestHandleBroadcastInfoInvalidChannelAccess(t *testing.T) { // Pretend we have 10 messages. helper.topic.lastID = 10 // uid1 notifies uid2 that uid1 has read messages up to seqid 11. - readId := 8 + readId := int64(8) from := helper.uids[0] for i := 1; i < numUsers; i++ { uid := helper.uids[i] @@ -1380,7 +1380,7 @@ func TestHandleBroadcastInfoChannelProcessing(t *testing.T) { // Pretend we have 10 messages. helper.topic.lastID = 10 // uid1 notifies uid2 that uid1 has read messages up to seqid 11. - readId := 8 + readId := int64(8) from := helper.uids[0] for i := 1; i < numUsers; i++ { uid := helper.uids[i] @@ -3222,7 +3222,7 @@ func TestHandleBroadcastInfoChannelWithMultipleReaders(t *testing.T) { defer helper.tearDown() helper.topic.lastID = 15 - readId := 12 + readId := int64(12) from := helper.uids[0] // Set up multiple channel readers @@ -3606,8 +3606,8 @@ func TestReplyDelMsgUpdatesUnreadCounters(t *testing.T) { func TestCalculateUnreadInRanges(t *testing.T) { tests := []struct { name string - readID int - lastID int + readID int64 + lastID int64 ranges []types.Range expected int }{ diff --git a/server/user.go b/server/user.go index 3f4e450..ed6dffa 100644 --- a/server/user.go +++ b/server/user.go @@ -1069,12 +1069,12 @@ func usersShutdown() { } } -func usersUpdateUnread(tenantID types.TenantID, uid types.Uid, val int, inc bool) { +func usersUpdateUnread(tenantID types.TenantID, uid types.Uid, val int64, inc bool) { if globals.usersUpdate == nil || (val == 0 && inc) { return } - upd := &UserCacheReq{TenantID: tenantID, UserId: uid, Unread: val, Inc: inc} + upd := &UserCacheReq{TenantID: tenantID, UserId: uid, Unread: int(val), Inc: inc} if globals.cluster.isRemoteTopic(types.TopicKey{TenantID: tenantID, Topic: uid.UserId()}) { // Send request to remote node which owns the user. globals.cluster.routeUserReq(upd) @@ -1240,6 +1240,7 @@ func userUpdater() { usersCache[key] = uce counts[uid] = uce.unread + store.CacheUserUnreadCount(tenantID, uid, uce.unread) } if len(dbPending) > 0 { @@ -1287,6 +1288,7 @@ func userUpdater() { } uce.unread = count usersCache[key] = uce + store.CacheUserUnreadCount(io.tenantID, uid, count) } else { logs.Warn.Println("users: missing users cache entry after IO completion, uid", uid) } @@ -1416,6 +1418,7 @@ func userUpdater() { if upd.Gone { // User is being deleted. Don't care if there is a record. delete(usersCache, types.UserKey{TenantID: upd.TenantID, User: upd.UserId}) + store.DeleteCachedUserUnreadCount(upd.TenantID, upd.UserId) continue } diff --git a/server/utils.go b/server/utils.go index dfdabd4..ab81e8f 100644 --- a/server/utils.go +++ b/server/utils.go @@ -694,6 +694,13 @@ func max(a, b int) int { return b } +func max64(a, b int64) int64 { + if a > b { + return a + } + return b +} + // Truncate string if it's too long. Used in logging. func truncateStringIfTooLong(s string) string { if len(s) <= 1024 { diff --git a/收藏说明.txt b/收藏说明.txt new file mode 100644 index 0000000..72ebb48 --- /dev/null +++ b/收藏说明.txt @@ -0,0 +1,45 @@ +ET /v1/im/favorites/{favorite_id} 返回的是单条收藏详情,当前核心字段大概是这样: +{ + "favorite_id": "fav_demo_19179719768_text", + "type": "text", + "snapshot": { + "topic": "favSeed19179719768", + "seq": 7001, + "from": "usr...", + "ts": "2026-08-25T07:26:06Z", + "type": "text", + "head": {}, + "content": {}, + "text": "这是一条收藏的文本消息" + }, + "source": { + "topic": "favSeed19179719768", + "seq": 7001 + }, + "collected_at": "2026-08-25T07:26:06Z", + "availability": "available" +} +字段说明: +字段 含义 +favorite_id 收藏记录 ID,后续详情、删除、转发都用它。 +type 收藏类型:text、image、video、voice、file、link。 +snapshot 收藏时保存的展示快照。即使原消息后续变化或删除,收藏列表也主要用这个字段渲染。 +source.topic 原始消息所在会话 Topic。 +source.seq 原始消息在该 Topic 下的消息序号。 +collected_at 收藏时间,ISO/RFC3339 时间格式。 +availability 当前可用状态,通常是 available;如果原资源被合规删除或失效,可能返回 removed 等状态。 + + +snapshot 里面比较重要: +字段 含义 +snapshot.topic 原消息 Topic。 +snapshot.seq 原消息 seq。 +snapshot.from 原消息发送人。 +snapshot.ts 原消息发送时间。 +snapshot.type 消息类型。通常和外层 type 一致。 +snapshot.head 原消息头部扩展,比如 mime、x-im-type 等。 +snapshot.content 原消息内容。文本、文件、图片、语音、链接等具体展示数据一般在这里。 +snapshot.text 服务端提取出的纯文本摘要,方便列表搜索和预览展示。 + + +前端一般列表页用外层 type + snapshot + collected_at + availability 渲染;点开详情时,如果是文件/图片/视频/语音,再根据 snapshot.content.file_id、thumbnail_file_id 等字段去走文件下载/预览逻辑。 \ No newline at end of file