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 @@
-
版本:v2.0-
日期:2026-08-11-
需求依据:IM 产品原型需求文档-
设计依据:IM 系统设计文档-
说明:第 1 至第 11 章的章节编号、图片标题和顺序与产品原型完全一致。每张图均给出可直接开发、联调和验收的协议。-
| 状态 | 含义 |
|---|---|
已实现 | 当前服务端已有能力,可按本文联调 |
需改造 | 已有基础能力,但字段、鉴权或业务规则需要调整 |
新增 | 当前服务端没有该产品接口或数据模型 |
纯客户端 | 界面或系统能力,不调用服务端;关联的提交协议会单独指出 |
文档中所有 新增、需改造 接口都是目标契约,不代表当前代码已经可用。前后端必须以本文字段为准实施,接口完成前可使用同结构 Mock。
API Key 不是客户端启动后向 IM 服务端申请得到的值。现有代码没有“获取 API Key”的 HTTP 或 WebSocket 接口。
-API Key 是应用级接入标识,由部署人员在发布客户端前生成并配置:
-go run ./keygen。api_key_salt 时,执行 go run ./keygen -sequence <新序号> -salt '<服务端 api_key_salt>' 生成兼容的普通应用 Key。HMAC salt 配置到服务端 server/tinode.conf 的 api_key_salt;输出中的 API key 配置到 iOS、Android 或 Web 客户端构建配置/受控环境配置。-isroot 1 生成的 Root Key 放进客户端。生成示意:
-
-$ 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 同时失效,必须与客户端升级协调。
| 通道 | 地址 | 认证 | 用途 |
|---|---|---|---|
| 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:
-{"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 与租户绑定关系,不能只修改客户端配置。
tenant_code;客户端永远不接收、不提交数据库 tenant_id。{hi},其中 tenant 为企业码。服务端解析后把租户固定到 Session。TENANT_MISMATCH,不得自动切换。tenant_id、user_id 不能覆盖身份。RESOURCE_NOT_FOUND 或 FORBIDDEN,不得暴露对象是否存在。成功响应统一为:
-
-{
- "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。
-读取客户端预置的 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 或切换企业码。
如果客户端无法使用 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。
第 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 不符合当前发布环境,客户端应优先使用内置的公网环境配置,并上报脱敏诊断。
核心命令: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。
Idempotency-Key。retry_after_seconds 重试。401 TOKEN_EXPIRED 只允许一次静默刷新;刷新失败必须退出登录。403 不重试,429 按服务端退避。
状态:企业解析接口与 WebSocket 租户握手均已实现。
-当前系统的执行方式:
-host 和普通 API Key,可先调用 POST /v1/im/tenants/resolve 获取企业公开信息,再使用 wss://{host}/{api_path}v0/channels?apikey={api_key} 建立 WebSocket。{"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 登录。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。hi(tenant) 做最终租户绑定;hi 成功后才进入 1.2。TENANT_UNAVAILABLE;客户端不得区分不存在和停用。网络失败保留输入并允许重试。成功响应示例:
-
-{
- "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"
-}
-
-
状态:基本账号密码登录已实现;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 编码。params.token 作为 Authorization: Bearer <params.token>;所有 /v1/im/* HTTP 请求仍必须带 X-Tinode-APIKey、X-IM-Schema: 1,写请求再带 Idempotency-Key。tenant_id/user_id/auth_version。修改密码、封禁账号后旧 Token 应失效。AUTH_FAILED 统一显示账号或密码错误;TENANT_MISMATCH 清 Token 并回企业码页;ACCOUNT_DISABLED、REALNAME_REQUIRED、YOUTH_RESTRICTED 按专用页面处理。登录成功示例:
-
-{
- "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 做程序判断。

状态:已实现基础错误,错误码和限流需改造。
-login 返回 {ctrl code:401} 或产品错误 AUTH_FAILED。tenant + account + device/IP 限流,连续失败返回 AUTH_RATE_LIMITED 和 retry_after_seconds;日志仅记录账号哈希和 Request ID。
状态: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。hi 后发送 {acc}。把一次性验证结果放在 {acc}.cred[0].resp,它必须是 HTTP verify 返回的 verification_token,不是短信验证码 123456。不得写入 desc.private,也不得在 {acc} 顶层传 tags。服务端会根据已校验的 cred 建立手机号凭证;客户端主动设置 tags:["tel:..."] 会被 Tinode 权限策略拒绝并返回 403 params.what=tags。{acc}.secret 中的 username、cred.val 必须使用同一个规范化号码,推荐 E.164 格式如 +86138...;不要一处使用 +86138...、另一处使用 86138...。
-{"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。
状态:新增。
-resend_after_seconds 为准,并结合 server_time 计算,不能只依赖本地固定 60 秒。VERIFICATION_RATE_LIMITED 和剩余秒数。challenge_id 恢复;超过 expires_in_seconds 必须重新发送,旧验证码失效。
状态:新增。
-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。.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 落普通存储。
状态:新增。
-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 系统事件更新状态。
状态:新增。
-GET /v1/im/security/realname。verified 时返回如 masked_name:"张*"、masked_id_number:"110***********1234"、verified_at。POST /v1/im/security/realname/appeals,需二次身份验证;不复用本页保存按钮。.png)
状态:新增。
-GET /v1/im/bootstrap 返回 security.realname{status,required,allow_skip,remind_after};只有 required=true 且未认证时显示弹窗。allow_skip=true 时本地关闭,并按 remind_after 控制再次提示。REALNAME_REQUIRED,前端弹窗不能作为授权依据。.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。前端输入框禁截屏/自动填充,离开页面立即清空。
状态:会话订阅已实现;产品摘要和系统入口需改造。
-sub me,再通过 get 拉取 sub 元数据;每项使用 topic、public、private、updated、seq/read/recv 计算名称、摘要和未读数。{data} 更新;未读数为 max(seq-read,0),回执以服务端游标为准,不能只用本地计数。GET /v1/im/bootstrap 返回固定入口、功能开关、权限和系统 Topic;搜索调用 GET /v1/im/search/conversations?q=&cursor=。tenant_code + user_id 隔离。切租户、退出或账号变化时不得复用另一身份的会话摘要。
状态:需改造客户端恢复流程和消息幂等。
-pending 队列。hi -> token login -> sub me -> 恢复 Topic -> get data。seq/read/recv 增量拉取。重发消息复用 x-im-client-mid,服务端需实现去重并返回原 seq。
状态:菜单为纯客户端;权限数据新增。
-GET /v1/im/bootstrap 的 features 与 permissions。create_group=false 时隐藏“创建群聊”;user_search=false 时隐藏“添加好友”;扫码权限只影响实际扫码,不影响服务端授权。
状态:基础消息操作部分已实现;产品权限矩阵需改造。
-type/from/seq/created_at/status、当前 Topic 权限和服务端下发的 message_policy 预裁剪菜单。{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=。所有最终操作由服务端验证消息归属、时限、群角色和租户。
状态:需改造。
-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 发布的路径必须封禁。客户端不得渲染普通用户伪造的系统卡片为可信通知。.png)
状态:需改造好友关系状态。
-GET /v1/im/conversations/{topic}/context,返回 relationship=pending_inbound、can_send=false、对方公开资料和申请 ID。relationship_event 系统消息渲染;普通消息仍通过 Topic {data} 下行。pub 绕过,服务端发布路径必须校验好友关系。!个人对话框(对方的状态)(1).png)
-状态:在线状态已具备基础能力;关系文案新增。
-{pres} 更新在线/离线,last_seen 仅在对方隐私允许时展示。GET /v1/im/conversations/{topic}/context 返回 display_name,avatar_file_id,online_visibility,relationship。system_event{event_type:"friend_relationship_changed"},客户端不得根据本地动作自行永久插入系统记录。.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,禁止重复创建和普通消息发送。!个人对话框(我的状态)(1).png)
-状态:发送和读回执已实现基础能力;送达状态映射需统一。
-client_mid 后显示 sending;pub 收到 202 + seq 更新为 sent;失败保留同一 client_mid 重试。note what:"recv" 更新 delivered,note what:"read" 更新 read;多设备取服务端最大游标。failed(reason),不得伪装成已发送。
状态:新增好友审批与 Topic 联动。
-friend_relationship_changed:accepted 系统事件。context.relationship=friend 后解锁输入区;不得仅因本地“通过”按钮成功动画解锁。since 拉取申请上下文和正常消息。
状态:上传基础已实现;媒体消息 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 中适用字段;上传进度是客户端任务状态,不写成聊天消息。pub 失败时复用已上传 file_id 和 client_mid。
状态:黑名单产品规则新增。
-context.relationship=blocked_by_me|blocked_by_peer、can_send=false;服务端 pub 返回统一 MESSAGE_NOT_ALLOWED,不得向发送者泄露具体是对方拉黑。read 渲染;拉黑不能回滚既有回执。解除拉黑见 5.10、5.12。
状态:新增好友删除后的发送约束。
-DELETE /v1/im/friends/{user_id};成功后双方关系变为 none,服务端推送关系变化事件。pub 返回 FRIEND_RELATION_REQUIRED;前端保留失败草稿,提供“重新添加好友”入口,不自动发申请。
状态:纯客户端。
-tenant + user + topic 本地加密保存;若实现多端草稿,再单独调用 PUT /v1/im/topics/{topic}/draft,不得混入聊天消息。
状态:面板纯客户端;上传需改造。
-context.capabilities 判断图片、视频、语音、文件是否允许,调用系统选择器/相机。mime,size 和 bootstrap.limits.max_file_size;服务端仍必须重新校验租户配额、文件类型和会话权限。!个人对话-发各种文件状态(1).png)
-状态:需改造产品消息 Schema。
-x-im-type 选择文本、名片、文件等卡片,公共字段统一使用 from,topic,seq,ts,head,content。
状态:新增产品消息类型。
-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;用户不可见或已离职时显示失效卡片,不跨租户查询。
状态:纯客户端。
-tenant + user + topic 保存。context.can_send 并执行 pub text;正文去首尾策略、最大长度从 bootstrap.limits.max_text_length 获取,服务端再次校验。
状态:新增群成员查询。
-@ 后调用 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 表示。
状态:纯客户端选择;发送协议需改造。
-user_id,显示昵称只是快照。pub.head.mentions=[{"user_id":"usr...","offset":0,"length":3}];@所有人 使用 mention_all:true,不得伪造用户 ID。mention_all 权限;无效成员可删除提醒语义但保留可读文本,越权 @所有人 返回 MENTION_ALL_FORBIDDEN。
状态:需改造提醒和 Push。
-{data} 下行,客户端使用结构化 mentions 高亮,禁止通过字符串扫描昵称判断。mentioned=true;读到对应 seq 后随 note read 清除。.png)
状态:纯客户端。
-text 内容发送。自定义表情资源首版未定义,不得把本地资源路径直接发送。
状态:Unicode 表情使用已实现文本消息;独立表情包新增。
-pub x-im-type:text 发送。独立自定义表情若启用,使用 x-im-type:"sticker",content={pack_id,sticker_id,file_id,width,height}。
状态:录音纯客户端;上传和语音消息需改造。
-context.capabilities.voice;权限不足进入 10.21,不发请求。pub x-im-type:voice,内容含 file_id,duration_ms,size,mime,waveform。
状态:纯客户端。
-
状态:录音纯客户端;临时状态可选改造。
-{note what:"kpa",topic};服务端限频,其他端超时自动消失,不能作为聊天记录。
状态:客户端任务管理新增;服务端上传需改造。
-client_mid,local_uri,type,upload_id,progress,state;同一文件可分片/断点续传时使用稳定 upload_id。FILE_TOO_LARGE、FILE_TYPE_NOT_ALLOWED、STORAGE_QUOTA_EXCEEDED 时不可自动无限重试。
状态:需改造媒体 Schema 和受权下载。
-duration_ms,waveform,未听状态保存在客户端并可选跨端同步;视频渲染 thumbnail_file_id,duration_ms,width,height;图片渲染缩略图和原图 ID。FILE_UNAVAILABLE。
状态:需改造。
-client_mid 或列表位置。
状态:纯客户端视觉资源。
-purpose 以便服务端执行权限和保留期策略。
状态:上传已实现基础能力;消息引用和 ACL 需改造。
-x-im-type:file,content={file_id,name,size,mime,extension,checksum};文件名做长度和控制字符清理。client_mid,某个失败不能改变其他文件状态。
状态:选择纯客户端;批量动作新增。
-(topic,seq) 保存已选项并显示数量;默认最多 100 条,超限在本地阻止。succeeded[] 与 failed[{seq,reason}]。
状态:新增。
-POST /v1/im/favorites/batch,Body {"messages":[{"topic":"grp...","seq":"123"}]},带幂等键。created_count,existing_count,failed[];同一用户对同一 topic+seq 重复收藏不新增记录。
状态:新增。
-GET /v1/im/forward-targets?query=&cursor=,只返回 can_receive_forward=true 的会话和文件助手。
状态:新增。
-bootstrap.limits.max_forward_targets,首版建议 9。tenant_id。跨租户 Topic 在服务端统一拒绝。
状态:新增。
-POST /v1/im/messages/forward,Body {"mode":"separate","sources":[{"topic":"p2p...","seq":"12"}],"target_topics":["grp..."],"comment":"..."}。client_mid/seq/status;服务端保持源顺序,重新生成目标消息,不能复用原 seq。
状态:新增。
-mode:"bundle";服务端生成 forward_bundle,内容含 bundle_id,title,item_count,preview[],完整内容通过 GET /v1/im/forward-bundles/{bundle_id}?cursor= 获取。
状态:新增产品通讯录接口。
-GET /v1/im/friends?query=&initial=&cursor=,返回 user_id,display_name,remark,avatar_file_id,initial,online_visibility;显示名优先级为备注名、昵称、账号。next_cursor 当前按最后一条好友的 peer_id 生成,客户端必须作为不透明字符串原样回传,不要自行解析、排序或拼接。GET /v1/im/organization/members?query=&department_id=&cursor=;好友和组织成员是两种关系,前端不得混为一个可发消息权限。GET /v1/im/bootstrap 返回。列表缓存以租户和用户隔离。
状态:新增。
-GET /v1/im/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 并附当前状态,前端刷新该项。
状态:新增。
-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。allow_user_search 和用户隐私再次拦截。!添加好友(1).png)
-状态:新增。
-POST /v1/im/me/qrcodes,Body {"purpose":"add_friend","expires_in_seconds":86400},返回 qr_token,qr_payload,expires_at,display_profile。QR_CODE_EXPIRED,篡改返回 QR_CODE_INVALID。
状态:新增。
-GET /v1/im/users/search?q=&type=exact_account;手机号查询仅当租户和目标隐私同时允许时使用 type=phone。action=add|chat|pending|none。前端不得根据是否返回手机号推断账户存在。SEARCH_RATE_LIMITED;禁止客户端批量枚举 ID。
状态:新增。
-POST /v1/im/friend-requests,Body {"target_user_id":"usr...","message":"验证留言","source":"search|qr|group","source_topic":"grp..."}。source_topic 只能是双方当前可见的同租户群。request_id,status:"pending";重复有效申请返回原记录,目标隐私关闭时返回统一 FRIEND_REQUEST_NOT_ALLOWED。
状态:新增。
-GET /v1/im/users/{user_id}/profile 返回按当前查看人裁剪后的 display_name,remark,avatar_file_id,gender,region,public_account,signature,relationship,capabilities。topic;没有 Topic 时调用 POST /v1/im/conversations/p2p,Body {"peer_user_id":"..."} 并由服务端验证好友关系。capabilities.can_call 展示,真正呼叫时再次鉴权。隐藏字段必须从响应中省略,不能只由前端遮挡。
状态:新增。
-GET /v1/im/groups/{topic}/members/{user_id}/profile 返回群内昵称、角色和允许公开的账号资料。can_add_friend 同时受群 allow_member_add_friend、目标隐私和租户策略控制;为 false 时隐藏添加入口。source:"group" 和当前 Topic;服务端校验双方仍在群内。
状态:部分 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。
状态:新增。
-PUT /v1/im/blocks/{user_id};解除:DELETE /v1/im/blocks/{user_id},均带幂等键。blocked,relationship,updated_at 并向当前用户其他设备推送 privacy.block.updated。拉黑后服务端拦截对方消息和呼叫。relationship 仍为 none,不能自动恢复好友。
状态:确认框纯客户端;删除接口新增。
-DELETE /v1/im/friends/{user_id},带幂等键。是否清除本地历史由客户端在成功后独立处理,不放入删除好友请求。relationship:"none",topic。当前用户是否删除本地/服务端可见记录是独立选择。
状态:新增。
-GET /v1/im/blocks?cursor= 返回 user,blocked_at;移出调用 5.10 的 DELETE。POST /v1/im/blocks/batch-delete,Body {"user_ids":[...]},返回逐项结果;最多 100 人。!黑名单提示(1).png)
-状态:确认框纯客户端;写接口新增。
-PUT /v1/im/blocks/{user_id},相同幂等键重复提交返回当前 blocked=true。RESOURCE_NOT_FOUND。
状态:图片文件名与画面语义不一致;按“清除聊天记录”实施,需改造。
-{del what:"msg",topic,delseq:[{"low":1,"hi":last_seq+1}],hard:false},仅删除当前用户视图;或封装 DELETE /v1/im/conversations/{topic}/messages。
状态: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 决定;创建成功或角色变化后通过系统事件刷新,不由前端自行移动分组。
状态:纯客户端。
-items=[] 且 has_more=false 时显示对应空状态,不追加空白占位数据。group.membership.updated 或创建成功后重新拉取当前分类。
状态:新增邀请卡片和加入接口。
-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。
状态:新增群成员事件。
-POST /v1/im/groups/{topic}/invitations,Body {"user_ids":[],"message":""};按群策略直接加入或创建待审批申请。system_event{event_type:"group_membership_changed",operator,target_users,action,result} 并写入群时间线。
状态:新增。
-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 和最终状态。
状态:新增。
-GET /v1/im/groups/{topic}/summary 返回 pending_join_request_count、最新公告和权限;新申请通过 group.join_request.created 推送给有审批权限的成员。
状态:新增。
-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。仅有权限角色可写,使用乐观锁防覆盖。
状态:纯客户端组合;数据接口新增。
-GET /v1/im/groups/{topic}/summary,分别读取 announcement 和 pending_join_request_count,按产品固定顺序渲染两个入口。.png)
状态:新增。
-GET /v1/im/groups/{topic}/announcements/{announcement_id};普通成员响应不含编辑权限。POST /v1/im/groups/{topic}/announcements/{id}/read,带幂等键,返回 read_at;用于清除新公告标记。
状态:部分订阅设置已实现;产品菜单新增。
-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。
状态:需改造。
-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 允许的入口。
状态:上传基础已实现;用途绑定需改造。
-purpose=group_avatar,服务端校验图片 MIME、尺寸、大小并返回 file_id。{"avatar_file_id":"...","version":n};只有 PATCH 成功才替换正式头像。!设置昵称(1).png)
-状态:新增。
-PUT /v1/im/groups/{topic}/members/me/profile,Body {"nickname":"...","version":n}。nickname,version,updated_at 并推送成员资料变化。
状态:同 6.11,需改造。
-PATCH /v1/im/groups/{topic},Body {"description":"...","version":n};空字符串表示清空,省略表示不修改。VALIDATION_FAILED 的 details.field/max_length 或 CONTENT_REJECTED;前端保留草稿供修正。
状态:角色已有基础数据;产品渲染需统一。
-sender_snapshot{display_name,role},角色枚举固定 owner|admin|member。
状态:新增。
-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。
状态:新增。
-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、二维码、邀请和文件新增权限。
状态:新增。
-GET /v1/im/groups/{topic}/members?role=admin&cursor= 返回管理员列表;GET /v1/im/groups/{topic}/role-limits 返回 max_admin_count,current_admin_count。owner 单独展示,不计入普通管理员列表和名额。
状态:新增。
-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。
状态:新增。
-admins,version,updated_at;前端用响应替换列表并显示成功,不在请求完成前自行修改角色。group.role.updated,新管理员收到后刷新菜单权限和群标识。!添加管理员-设置成功(1).png)
-状态:新增。
-succeeded[]、failed[{user_id,reason}] 和最新 admins。
状态:新增。
-DELETE /v1/im/groups/{topic}/admins/{user_id},带幂等键和 If-Match/version。member,保留群成员关系;服务端立即撤销管理权限并广播角色事件。
状态:新增。
-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":""}。
状态:新增。
-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;不能只禁用前端输入框。
状态:新增。
-GET /v1/im/groups/{topic}/members?eligible_role=owner&query=&cursor=,只返回状态正常的当前成员。target_user_id;当前群主、已退出、停用账号返回 eligible=false。
状态:新增。
-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,前端重新确认,不自动重试。
状态:新增群文件索引;底层文件存储已实现。
-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} 需上传者或管理员权限。
状态:需改造。
-purpose=group_file,topic=... 上传,再 POST /v1/im/groups/{topic}/files,Body {"file_id":"..."} 建立群文件记录。member_upload_files、单文件上限、租户总存储和群成员身份;成功返回完整群文件项。upload_id;登记失败不得在列表显示,孤立文件按 TTL 回收。
状态:新增。
-GET /v1/im/favorites?type=all|text|image|video|voice|file|link&q=&cursor=,返回 favorite_id,type,snapshot,source,collected_at,availability。source 仅下发用户仍可知晓的会话摘要。!我的收藏(1).png)
-状态:新增。
-GET /v1/im/favorites/{favorite_id};删除:DELETE /v1/im/favorites/{favorite_id};转发使用 7.8。availability:"removed"。
状态:新增。
-type=file;项中使用 snapshot.file{name,size,mime,file_id} 和 availability。
状态:新增。
-type=text;服务端返回纯文本/结构化 Drafty 快照和安全高亮区间 matches[]。permissions.can_copy。
状态:新增。
-type=voice;播放前获取认证文件,返回 duration_ms,waveform,availability。
状态:新增。
-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。.png)
状态:新增。
-POST /v1/im/favorites/batch-delete,Body {"favorite_ids":[]};批量转发见 7.8。
状态:新增。
-POST /v1/im/favorites/forward,Body {"favorite_ids":[],"target_topics":[],"comment":""}。
状态:底层下载已实现租户校验;资源 ACL 需改造。
-GET /v0/file/s/{file_id},Header 传 API Key 与 X-Tinode-Auth: token <base64_token>;禁止 ?auth=&secret=。Range、ETag、内容长度和安全文件名。FILE_FORBIDDEN 不自动重试,FILE_UNAVAILABLE 显示失效。
状态:客户端任务管理;服务端需支持 Range。
-file_id,etag,total_bytes,downloaded_bytes,temp_path,state;恢复时携带 Range 与 If-Range: <etag>。
状态:纯客户端完成态。
-.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 并高亮。
状态:新增。
-GET /v1/im/topics/{topic}/messages/search?type=file&q=&cursor=,返回消息 seq 与文件快照、发送者、时间、availability。
状态:新增。
-type=link,返回 url,title,summary,seq,ts,risk_level;打开前执行 7.6 URL 安全检查。
状态:新增。
-GET /v1/im/topics/{topic}/media?type=image&cursor= 返回 seq,thumbnail_file_id,file_id,width,height,ts,availability。(topic,seq),操作使用第 4 章批量接口。
状态:新增。
-type=video,额外返回 duration_ms,mime,size。
状态:已实现基础通话记录替换;产品文案映射需统一。
-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。
状态:在线邀请已实现;Push 与产品鉴权需改造。
-{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"。callId,topic,seq,caller_display,callType,expires_at,不含 SDP、ICE 或 Token;用户操作后必须回 WebSocket 鉴权。
状态:发起与超时已实现基础;好友/拉黑/租户开关校验需改造。
-capabilities.can_call,再发布持久邀请消息:
-{"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。
状态: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 恢复或结束,不新建第二通呼叫。
状态:纯客户端设备控制。
-media-state,不落聊天记录。
状态:纯客户端设备控制。
-
状态:基础通话信令已实现;隐私约束需落实。
-media_type:"video"。本地预览只在设备显示,接听前媒体轨默认不发给对方,SDP 只声明能力。hang-up:cancelled。相机权限不足时改为音频呼叫或取消,不伪装视频已开启。
状态:纯客户端状态;媒体状态事件需改造。
-video_enabled=false。ringing/accept/offer/answer/ice-candidate/hang-up;首版应直接通过 WebRTC 轨道 mute 状态呈现。若后续新增 media-state,必须先修改 server/calls.go 事件白名单、参与者校验和限频,客户端不能提前发送未知事件。
状态:WebRTC 客户端能力;服务端信令已具备基础。
-hang-up。服务端未支持 media-state 前不得发送该事件。
状态:现阶段纯客户端;可选媒体状态事件需改造。
-media-state 必须等服务端扩展事件白名单后另行启用,且不持久化、不改变通话结果。
状态:纯客户端。
-call_id 运行;点击恢复全屏不发送新 invite。hang-up、Token 失效或媒体会话结束后立即关闭悬浮窗并落正确结果态。
状态:新增。
-GET /v1/im/workbench 返回 modules[{code,name,icon_file_id,route,unread_count,enabled,permission}]、布局版本和企业配置。route,禁止直接执行服务端任意 URL。workbench.badge.updated 系统事件增量更新;切租户必须重新加载,不复用另一企业工作台。
状态:新增。
-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=。服务端执行组织范围授权,离职用户标记不可联系或从结果移除。
状态:新增。
-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。所有写操作带幂等键并生成通知和审计。
状态:新增。
-PUT /v1/im/meetings/{meeting_id}/my-response,Body {"response":"accepted|declined|tentative","version":n}。MEETING_CLOSED 并只读展示;客户端本地日历写入是可选系统动作,不替代服务端响应。
状态:新增。
-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 和可补交日期由服务端返回,前端不自行判断工作日规则。
状态:新增。
-PUT /v1/im/reports/daily/{id}/draft,Body {"content":"...","attachments":[],"version":n};提交 POST /v1/im/reports/daily/{id}/submit。REPORT_VERSION_CONFLICT 并给最新版本。editable_until 决定是否可修改;附件使用 purpose=daily_report 上传并做权限绑定。
状态:新增。
-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":[]};服务端过滤无管理权限对象并限流,返回逐项结果。
状态:新增。
-GET /v1/im/reports/daily/{report_id} 返回填报人、日期、内容、附件、状态、版本、修改时间和 permissions。revised_at,不能混合旧缓存。!月报(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。
状态:新增。
-GET /v1/im/reports/monthly?scope=mine|managed&from_month=&to_month=&status=&cursor= 返回月份、填报人、状态和时间。GET /v1/im/reports/monthly/statistics?month=&department_id=;普通用户响应中不下发团队统计入口权限。action=edit|view 决定。
状态:新增。
-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 或审核流程,不直接覆盖。
状态:新增。
-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;匿名模式下业务接收方不见身份,但安全审计是否保留内部追踪按企业政策执行并提前告知。
状态:新增配置接口。
-notice{title,content,updated_at},由租户后台配置;客户端不得硬编码实名/匿名承诺。notice_version,服务端发现关键政策已变更时返回 NOTICE_RECONFIRM_REQUIRED。
状态:基础用户资料已有;产品菜单新增。
-GET /v1/im/me 返回公开账号资料、实名/青少年模式摘要和资料版本;GET /v1/im/bootstrap 返回个人菜单与权限。
状态: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;任何字段的可见性由隐私策略裁剪。
状态:需改造。
-PATCH /v1/im/me/profile,Body {"nickname":"新昵称","version":n}。nickname,version,updated_at。
状态:新增产品字段。
-{"birth_date":"2000-01-02","version":n};仅传 ISO 日期,不传本地化文本或时区时间戳。PROFILE_FIELD_INVALID。展示格式由客户端本地化。
状态:需改造。
-{"signature":"...","version":n};空字符串表示清空。field_rules.signature.max_length 和内容审核处理;失败保留本地草稿,成功刷新引用页面。
状态:图标纯客户端;二维码协议新增。
-POST /v1/im/me/qrcodes 获取新 Token。user_id、手机号或登录 Token;过期、撤销和切租户后必须重新生成。.png)
状态:菜单纯客户端;数据接口部分新增。
-bootstrap.settings_menu,安全、通知、隐私、通用、关于按权限展示。系统通知权限状态由 iOS API 读取,不由服务端伪造。POST /v1/im/auth/logout 撤销当前 refresh/access token 和设备会话,成功或 Token 已失效后关闭 WS、清安全存储和本租户用户缓存。DELETE /v1/im/security/sessions/{session_id},需二次验证。.png)
状态:菜单纯客户端;业务协议同 10.7。
-bootstrap.settings_menu 与退出协议;Android 系统权限、通知渠道和应用更新使用平台实现。
状态:纯客户端。
-
状态:新增 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。
状态:纯客户端弹窗;数据来自 10.10。
-optional 显示稍后/更新,记录本地已忽略版本并按服务端 remind_after 再提示;required 不显示绕过入口。
状态:新增。
-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。
状态:新增。
-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,前端禁用并显示服务端提供的通用说明。.png)
状态:新增。
-GET /v1/im/settings/youth-mode 返回 enabled,policy_version,restrictions,password_set,can_enable。POST /v1/im/settings/youth-mode/enable,Body {"policy_version":n,"action_token":"..."}。.png)
状态:弹窗纯客户端;文案由服务端配置。
-restrictions 与 policy_version 渲染;取消不请求,确认进入密码设置/验证。policy_version;若策略更新返回 POLICY_RECONFIRM_REQUIRED,重新展示新内容。.png)
状态:新增。
-enabled=true 时展示 enabled_at,restrictions。bootstrap 同时返回受限模块和动作。YOUTH_MODE_RESTRICTED;前端隐藏只是体验优化,不能作为安全控制。youth_mode.updated 即刷新。.png)
状态:新增。
-action_token;再 POST /v1/im/settings/youth-mode/disable,Header X-Action-Token,带幂等键。enabled:false,updated_at 并广播;连续密码错误按服务端 retry_after_seconds 锁定。.png)
状态:权限引导纯客户端;设备注册需改造。
-PUT /v1/im/devices/{device_id}/push-token,Body {"provider":"fcm","token":"...","app_version":"..."};Token 轮换时覆盖。.png)
状态:纯客户端。
-.png)
状态:纯客户端。
-.png)
状态:纯客户端。
-.png)
状态:新增风控决策协议。
-409 RISK_CONFIRMATION_REQUIRED,details={risk_challenge_id,level,notice_code,notice_params,expires_at,action}。notice_code 映射经过审核的本地文案,不显示服务端规则、分数或命中特征;取消不重试原操作。security.risk_notice 系统事件触发。普通消息不得伪装成系统反诈弹窗。.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 绕过。
状态:新增。
-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 或被举报者任意文本身份。!举报(1).png)
-状态:新增。
-purpose=abuse_evidence 上传;提交 POST /v1/im/reports/abuse,Body {"target":{"type":"message","topic":"...","seq":"12"},"reason_code":"fraud","description":"...","evidence_file_ids":[]}。report_id,status:"received"。REPORT_RATE_LIMITED;证据使用独立严格 ACL 和保留期。
状态:新增。
-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",重复提交由幂等键去重。/v0/channels、Wire Version 0.25 和最低版本 0.20。hi 携带企业码并固定 Session 租户;响应包含公开租户信息、限制、ICE 配置和呼叫超时。basic/token 登录,sub/get/set/pub/leave/del/note 基础 Tinode 协议。pub 成功返回 202 + seq,Topic 消息与基础回执同步。/v0/file/u/ 和 /v0/file/s/{file_id} 的基础上传下载及租户存储隔离。| 优先级 | 改造项 | 阻塞图片章节 |
|---|---|---|
| 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 章 |
| 编号 | 场景 | 预期结果 |
|---|---|---|
| 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 | 切换企业后查看缓存、上传任务、收藏和搜索 | 不出现上一租户数据或任务 |
首版客户端至少实现: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 原样展示给用户。
每个图片小节进入联调前,服务端须提供 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 - - - -**状态:企业解析接口与 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 - - - -**状态:基本账号密码登录已实现;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 - - - -**状态:已实现基础错误,错误码和限流需改造。** - -- 触发:`login` 返回 `{ctrl code:401}` 或产品错误 `AUTH_FAILED`。 -- 前端:保留账号、清空密码、聚焦密码框;统一显示“账号或密码错误”,不得显示账号是否存在。 -- 服务端:按 `tenant + account + device/IP` 限流,连续失败返回 `AUTH_RATE_LIMITED` 和 `retry_after_seconds`;日志仅记录账号哈希和 Request ID。 -- 验收:同一不存在账号与错误密码在响应码、耗时区间、前端文案上保持一致。 - -### 1.4 注册页面.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 - - - -**状态:新增。** - -- 倒计时以 1.4 返回的 `resend_after_seconds` 为准,并结合 `server_time` 计算,不能只依赖本地固定 60 秒。 -- 倒计时内前端禁用按钮;即使绕过按钮重复调用,服务端返回 `VERIFICATION_RATE_LIMITED` 和剩余秒数。 -- App 重进页面可使用内存/安全临时存储中的 `challenge_id` 恢复;超过 `expires_in_seconds` 必须重新发送,旧验证码失效。 - -### 1.6 忘记密码.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 - -.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 - - - -**状态:新增。** - -- 查询:`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 - - - -**状态:新增。** - -- 页面只调用 `GET /v1/im/security/realname`。`verified` 时返回如 `masked_name:"张*"`、`masked_id_number:"110***********1234"`、`verified_at`。 -- 前端只读展示,不缓存原始证件信息,不提供直接修改接口。 -- 更正信息使用独立申诉 `POST /v1/im/security/realname/appeals`,需二次身份验证;不复用本页保存按钮。 - -### 1.10 实名认证(安卓).png - -.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 - -.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 - - - -**状态:会话订阅已实现;产品摘要和系统入口需改造。** - -- 登录后发送 `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 - - - -**状态:需改造客户端恢复流程和消息幂等。** - -- 纯网络状态由客户端监听;断网时展示缓存并禁止需要在线确认的写操作,未发送消息进入本地 `pending` 队列。 -- 使用指数退避加随机抖动重连;恢复后严格执行 `hi -> token login -> sub me -> 恢复 Topic -> get data`。 -- 每个 Topic 按最后持久化的 `seq/read/recv` 增量拉取。重发消息复用 `x-im-client-mid`,服务端需实现去重并返回原 `seq`。 -- Token 失效转登录页;租户停用返回企业不可用页;禁止无限快速重连。 - -### 2.3 消息-更多.png - - - -**状态:菜单为纯客户端;权限数据新增。** - -- 菜单打开/关闭不调用服务端。菜单项来自登录后 `GET /v1/im/bootstrap` 的 `features` 与 `permissions`。 -- `create_group=false` 时隐藏“创建群聊”;`user_search=false` 时隐藏“添加好友”;扫码权限只影响实际扫码,不影响服务端授权。 -- 各入口协议分别见 5.3、6.1、10.1;服务端在实际写接口再次鉴权,不能只依赖隐藏按钮。 - -### 2.4 消息页面—长按弹出.png - - - -**状态:基础消息操作部分已实现;产品权限矩阵需改造。** - -- 长按本身不请求。客户端根据消息的 `type/from/seq/created_at/status`、当前 Topic 权限和服务端下发的 `message_policy` 预裁剪菜单。 -- 引用/复制为本地动作;收藏见 4.16;转发见 4.17~4.20;删除使用 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 - - - -**状态:需改造。** - -- `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 - -.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).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 - -.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).png) - -**状态:发送和读回执已实现基础能力;送达状态映射需统一。** - -- 本地生成 `client_mid` 后显示 `sending`;`pub` 收到 `202 + seq` 更新为 `sent`;失败保留同一 `client_mid` 重试。 -- 对方 `note what:"recv"` 更新 `delivered`,`note what:"read"` 更新 `read`;多设备取服务端最大游标。 -- 状态只附着在气泡元数据,不修改消息正文。服务端拒绝时映射为 `failed(reason)`,不得伪装成已发送。 - -### 3.5 开启沟通页面.png - - - -**状态:新增好友审批与 Topic 联动。** - -- 好友申请通过后,服务端在同一事务内建立双方关系和 P2P 订阅,并生成 `friend_relationship_changed:accepted` 系统事件。 -- 客户端收到事件或重新查询 `context.relationship=friend` 后解锁输入区;不得仅因本地“通过”按钮成功动画解锁。 -- 首次进入订阅 P2P Topic,再按 `since` 拉取申请上下文和正常消息。 - -### 3.6 双方沟通状态.png - - - -**状态:上传基础已实现;媒体消息 Schema、ACL 和恢复需改造。** - -- 选择媒体后本地生成 `client_mid` 和稳定占位;调用文件上传,得到 `file_id` 后再 `pub x-im-type=image|video|voice`。 -- `content` 至少含 `file_id,name,size,mime,width,height,duration_ms,thumbnail_file_id` 中适用字段;上传进度是客户端任务状态,不写成聊天消息。 -- 服务端发布成功后关联文件与 Topic/消息;文件下载必须校验消息可见性。上传失败只重试上传,成功但 `pub` 失败时复用已上传 `file_id` 和 `client_mid`。 - -### 3.7 个人对话—拉黑状态/已读状态.png - - - -**状态:黑名单产品规则新增。** - -- 拉黑后 `context.relationship=blocked_by_me|blocked_by_peer`、`can_send=false`;服务端 `pub` 返回统一 `MESSAGE_NOT_ALLOWED`,不得向发送者泄露具体是对方拉黑。 -- 失败消息只保存在发送端本地并显示失败,不下发给对方、不写入正常消息表。 -- 已读仍按历史最大 `read` 渲染;拉黑不能回滚既有回执。解除拉黑见 5.10、5.12。 - -### 3.8 个人对话—删除状态.png - - - -**状态:新增好友删除后的发送约束。** - -- 删除好友使用 `DELETE /v1/im/friends/{user_id}`;成功后双方关系变为 `none`,服务端推送关系变化事件。 -- 后续 `pub` 返回 `FRIEND_RELATION_REQUIRED`;前端保留失败草稿,提供“重新添加好友”入口,不自动发申请。 -- 历史聊天是否保留由 Topic 权限决定,删除好友不等于删除双方历史消息。 - -### 3.9 个人对话-键盘收起状态.png - - - -**状态:纯客户端。** - -- 键盘、表情和附加面板收起不调用服务端;保持列表锚点和未发送草稿。 -- 草稿默认按 `tenant + user + topic` 本地加密保存;若实现多端草稿,再单独调用 `PUT /v1/im/topics/{topic}/draft`,不得混入聊天消息。 - -### 3.10 个人对话-发各种文件状态.png - - - -**状态:面板纯客户端;上传需改造。** - -- 打开面板不请求。选择前依据 `context.capabilities` 判断图片、视频、语音、文件是否允许,调用系统选择器/相机。 -- 上传前校验 `mime,size` 和 `bootstrap.limits.max_file_size`;服务端仍必须重新校验租户配额、文件类型和会话权限。 -- 具体上传/发消息使用 3.6 协议;相机和麦克风授权见 10.20、10.21。 - -### 3.11 个人对话-发各种文件状态(1).png - -.png) - -**状态:需改造产品消息 Schema。** - -- 客户端按 `x-im-type` 选择文本、名片、文件等卡片,公共字段统一使用 `from,topic,seq,ts,head,content`。 -- 每种卡片必须实现未知字段忽略、未知类型占位、发送/失败/回执状态和长按动作。 -- 重新安装或换设备后仅凭服务端消息即可还原卡片,不能依赖发送端本地路径或临时 URL。 - -### 3.12 个人对话-发送名片状态.png - - - -**状态:新增产品消息类型。** - -- 选人:`GET /v1/im/friends?query=&cursor=`;选择后发送 `x-im-type:"contact_card"`。 -- `content` 为 `{"user_id":"usr...","display_name":"张三","avatar_file_id":"...","public_account":"A1024"}`;服务端按目标用户当前公开资料生成/校正快照,禁止手机号、证件号等敏感字段。 -- 点击卡片调用 `GET /v1/im/users/{user_id}/profile`;用户不可见或已离职时显示失效卡片,不跨租户查询。 - -## 4. 消息输入、媒体内容与消息操作 - -### 4.1 键盘展开.png - - - -**状态:纯客户端。** - -- 键盘展开、列表上推和光标变化不调用服务端;草稿按 `tenant + user + topic` 保存。 -- 点击发送时才校验 `context.can_send` 并执行 `pub text`;正文去首尾策略、最大长度从 `bootstrap.limits.max_text_length` 获取,服务端再次校验。 - -### 4.2 @人员—选择提醒人.png - - - -**状态:新增群成员查询。** - -- 输入 `@` 后调用 `GET /v1/im/groups/{topic}/members?query=&cursor=&mentionable=true`,返回 `user_id,display_name,avatar_file_id,role,mentionable`。 -- `@所有人` 是虚拟项,只有响应 `permissions.can_mention_all=true` 时展示,不以特殊用户 ID 表示。 -- 搜索做 300ms 防抖并取消旧请求;服务端只返回当前租户且仍在群内的成员。 - -### 4.3 @人员—完成.png - - - -**状态:纯客户端选择;发送协议需改造。** - -- 完成后在编辑器插入带范围的 Mention 节点,内部保存 `user_id`,显示昵称只是快照。 -- 发送时 `pub.head.mentions=[{"user_id":"usr...","offset":0,"length":3}]`;`@所有人` 使用 `mention_all:true`,不得伪造用户 ID。 -- 服务端验证成员身份和 `mention_all` 权限;无效成员可删除提醒语义但保留可读文本,越权 `@所有人` 返回 `MENTION_ALL_FORBIDDEN`。 - -### 4.4 @人员状态.png - - - -**状态:需改造提醒和 Push。** - -- 消息仍通过 `{data}` 下行,客户端使用结构化 `mentions` 高亮,禁止通过字符串扫描昵称判断。 -- 当前用户被提及时会话摘要标记 `mentioned=true`;读到对应 `seq` 后随 `note read` 清除。 -- Push 服务按结构化目标生成“有人@你”文案;已退群用户不能收到新提醒,但历史消息仍按快照渲染。 - -### 4.5 表情包展开(发送按钮默认状态).png - -.png) - -**状态:纯客户端。** - -- 面板、分类、最近使用和删除键均本地处理;未选择内容且文本为空时禁用发送。 -- Unicode 表情作为普通 `text` 内容发送。自定义表情资源首版未定义,不得把本地资源路径直接发送。 - -### 4.6 表情包发送页面.png - - - -**状态:Unicode 表情使用已实现文本消息;独立表情包新增。** - -- 文本表情按 `pub x-im-type:text` 发送。独立自定义表情若启用,使用 `x-im-type:"sticker"`,`content={pack_id,sticker_id,file_id,width,height}`。 -- 自定义表情发送前服务端校验表情包属于当前租户且可用;未知表情显示占位,不使用任意外链 URL。 - -### 4.7 语音—按住说话.png - - - -**状态:录音纯客户端;上传和语音消息需改造。** - -- 按下前检查麦克风权限和 `context.capabilities.voice`;权限不足进入 10.21,不发请求。 -- 松开发送:编码为服务端允许的 MIME,校验最短/最长时长,上传后 `pub x-im-type:voice`,内容含 `file_id,duration_ms,size,mime,waveform`。 -- 上滑取消、过短、系统来电打断时删除临时文件,不调用上传接口。 - -### 4.8 语音按住说话.png - - - -**状态:纯客户端。** - -- 音量幅度、上滑取消区域和浮层颜色来自本地录音会话,不上传实时音量。 -- 进入取消区只改变本地状态;最终抬手决定删除或执行 4.7 的上传发送。 - -### 4.9 内容沟通对话3-语音录音中.png - - - -**状态:录音纯客户端;临时状态可选改造。** - -- 录音期间客户端锁定其他媒体入口。默认不向群成员广播“正在录音”,以减少隐私暴露和状态风暴。 -- 若产品确认需要,可发送不落库的 `{note what:"kpa",topic}`;服务端限频,其他端超时自动消失,不能作为聊天记录。 - -### 4.10 内容沟通对话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 - - - -**状态:需改造媒体 Schema 和受权下载。** - -- 语音渲染 `duration_ms,waveform`,未听状态保存在客户端并可选跨端同步;视频渲染 `thumbnail_file_id,duration_ms,width,height`;图片渲染缩略图和原图 ID。 -- 预览前使用认证文件接口获取,服务端校验当前用户仍可访问引用消息;附件撤回或过期返回 `FILE_UNAVAILABLE`。 -- 缩略图和原文件分别授权,客户端缓存键必须包含租户与用户。 - -### 4.12 语音图片发送状态.png - - - -**状态:需改造。** - -- 图片选择后立即显示本地缩略图,上传成功后只替换资源来源,不改变 `client_mid` 或列表位置。 -- 失败展示重试按钮;重试复用同一上传任务和消息幂等 ID。语音播放与图片上传状态相互独立。 - -### 4.13 上传文件.png - - - -**状态:纯客户端视觉资源。** - -- 该图片仅定义统一上传图标和无障碍名称“上传文件”,不产生网络请求。 -- 文件助手、群文件、会话附件、反馈证据实际上传时统一走 0.2 文件接口,并额外提交业务 `purpose` 以便服务端执行权限和保留期策略。 - -### 4.14 文件发送状态.png - - - -**状态:上传已实现基础能力;消息引用和 ACL 需改造。** - -- 上传成功后发送 `x-im-type:file`,`content={file_id,name,size,mime,extension,checksum}`;文件名做长度和控制字符清理。 -- 服务端在消息落库事务中关联文件;未关联的临时上传按 TTL 清理。卡片下载见 7.9~7.11。 -- 多文件逐个生成 `client_mid`,某个失败不能改变其他文件状态。 - -### 4.15 多选-选中对话框.png - - - -**状态:选择纯客户端;批量动作新增。** - -- 客户端按 `(topic,seq)` 保存已选项并显示数量;默认最多 100 条,超限在本地阻止。 -- 转发、收藏、删除前把选中列表提交对应接口;服务端逐条校验仍可见、类型支持和权限,返回 `succeeded[]` 与 `failed[{seq,reason}]`。 -- 不允许只因第一条可操作就假定整批可操作。 - -### 4.16 多选-选中对话内容-收藏成功.png - - - -**状态:新增。** - -- `POST /v1/im/favorites/batch`,Body `{"messages":[{"topic":"grp...","seq":"123"}]}`,带幂等键。 -- 返回 `created_count,existing_count,failed[]`;同一用户对同一 `topic+seq` 重复收藏不新增记录。 -- 收藏保存原消息引用、不可变展示快照和附件授权关系;成功 Toast 只在至少一项成功时展示,部分失败需给出汇总。 - -### 4.17 转发-单选.png - - - -**状态:新增。** - -- 目标列表:`GET /v1/im/forward-targets?query=&cursor=`,只返回 `can_receive_forward=true` 的会话和文件助手。 -- 选中后本地进入确认页,不立即发送;服务端在最终转发接口再次校验目标 Topic、原消息和附件权限。 - -### 4.18 转发-多选.png - - - -**状态:新增。** - -- 目标查询同 4.17;多选最多取 `bootstrap.limits.max_forward_targets`,首版建议 9。 -- 确认前只保存目标 Topic 字符串;不得缓存或提交 `tenant_id`。跨租户 Topic 在服务端统一拒绝。 - -### 4.19 转发—逐条转发.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 - - - -**状态:新增。** - -- 调用 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 - - - -**状态:新增产品通讯录接口。** - -- 好友列表:`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 - - - -**状态:新增。** - -- 列表:`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 - - - -**状态:新增。** - -- 账号搜索:`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 - -.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 - - - -**状态:新增。** - -- 精准查询仍调用 `GET /v1/im/users/search?q=&type=exact_account`;手机号查询仅当租户和目标隐私同时允许时使用 `type=phone`。 -- 每项返回公开资料和 `action=add|chat|pending|none`。前端不得根据是否返回手机号推断账户存在。 -- 搜索限流返回 `SEARCH_RATE_LIMITED`;禁止客户端批量枚举 ID。 - -### 5.6 添加好友验证.png - - - -**状态:新增。** - -- `POST /v1/im/friend-requests`,Body `{"target_user_id":"usr...","message":"验证留言","source":"search|qr|group","source_topic":"grp..."}`。 -- 留言最多 50 个 Unicode 字符,服务端做敏感内容审核;`source_topic` 只能是双方当前可见的同租户群。 -- 成功返回 `request_id,status:"pending"`;重复有效申请返回原记录,目标隐私关闭时返回统一 `FRIEND_REQUEST_NOT_ALLOWED`。 - -### 5.7 好友个人主页.png - - - -**状态:新增。** - -- `GET /v1/im/users/{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 - - - -**状态:新增。** - -- `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 - - - -**状态:部分 Topic 设置已实现;产品设置新增。** - -- 查询:`GET /v1/im/conversations/{topic}/settings` 返回 `pinned,mute_notifications,message_retention,blocked,remark,permissions`。 -- 更新置顶/免打扰/保存方式:`PATCH /v1/im/conversations/{topic}/settings`,Body 只提交变更字段并带 `version`;返回最新完整设置。 -- 备注:`PATCH /v1/im/friends/{user_id}`,Body `{"remark":"..."}`;查记录见第 7 章,举报见第 11 章,删除见 5.11。 - -### 5.10 好友—聊天设置—拉黑状态.png - - - -**状态:新增。** - -- 开启拉黑:`PUT /v1/im/blocks/{user_id}`;解除:`DELETE /v1/im/blocks/{user_id}`,均带幂等键。 -- 成功返回 `blocked,relationship,updated_at` 并向当前用户其他设备推送 `privacy.block.updated`。拉黑后服务端拦截对方消息和呼叫。 -- 解除拉黑只恢复黑名单 ACL;若双方已非好友,`relationship` 仍为 `none`,不能自动恢复好友。 - -### 5.11 删除好友提示.png - - - -**状态:确认框纯客户端;删除接口新增。** - -- 确认调用 `DELETE /v1/im/friends/{user_id}`,带幂等键。是否清除本地历史由客户端在成功后独立处理,不放入删除好友请求。 -- 服务端解除双向好友关系但不替对方删除历史;返回 `relationship:"none",topic`。当前用户是否删除本地/服务端可见记录是独立选择。 -- 取消不请求;确认后清理页面缓存并等待关系事件校正其他设备。 - -### 5.12 黑名单.png - - - -**状态:新增。** - -- `GET /v1/im/blocks?cursor=` 返回 `user,blocked_at`;移出调用 5.10 的 DELETE。 -- 批量解除:`POST /v1/im/blocks/batch-delete`,Body `{"user_ids":[...]}`,返回逐项结果;最多 100 人。 -- 用户资料不可见时仍用受控占位显示黑名单记录,不能因资料删除而无法解除。 - -### 5.13 黑名单提示(1).png - -.png) - -**状态:确认框纯客户端;写接口新增。** - -- 确认后调用 `PUT /v1/im/blocks/{user_id}`,相同幂等键重复提交返回当前 `blocked=true`。 -- 服务端在同一事务内更新黑名单和通信 ACL,并写审计;取消不调用接口。 -- 若对象跨租户或不存在统一返回 `RESOURCE_NOT_FOUND`。 - -### 5.14 黑名单提示.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 - - - -**状态: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 - - - -**状态:纯客户端。** - -- 对 6.1 任一分类返回 `items=[]` 且 `has_more=false` 时显示对应空状态,不追加空白占位数据。 -- 收到 `group.membership.updated` 或创建成功后重新拉取当前分类。 - -### 6.3 加入群聊.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 - - - -**状态:新增群成员事件。** - -- 邀请:`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 - - - -**状态:新增。** - -- `GET /v1/im/groups/{topic}/join-requests?status=pending&cursor=` 返回申请人、邀请人、来源、时间、状态和 `version`。 -- 同意:`POST /v1/im/groups/{topic}/join-requests/{id}/accept`;拒绝:`POST /v1/im/groups/{topic}/join-requests/{id}/reject`,带幂等键与 `version`。 -- 仅群主/管理员可处理;同意时原子校验群成员上限。并发处理返回 `REQUEST_ALREADY_HANDLED` 和最终状态。 - -### 6.6 新邀请进群申请.png - - - -**状态:新增。** - -- `GET /v1/im/groups/{topic}/summary` 返回 `pending_join_request_count`、最新公告和权限;新申请通过 `group.join_request.created` 推送给有审批权限的成员。 -- 审批完成后服务端广播新数量;前端归零时移除入口。普通成员响应中不得得到申请人敏感信息或数量。 - -### 6.7 群公告状态.png - - - -**状态:新增。** - -- 查询最新公告:`GET /v1/im/groups/{topic}/announcements/latest`;发布/更新:`PUT /v1/im/groups/{topic}/announcements/current`,Body `{"content":"...","version":1}`。 -- 返回 `announcement_id,content,author,version,published_at`。仅有权限角色可写,使用乐观锁防覆盖。 -- 发布成功后服务端生成群系统事件;顶部摘要使用接口返回数据,不解析聊天文本猜测。 - -### 6.8 群公告状态/邀请进群申请同时存在.png - - - -**状态:纯客户端组合;数据接口新增。** - -- 页面一次调用 `GET /v1/im/groups/{topic}/summary`,分别读取 `announcement` 和 `pending_join_request_count`,按产品固定顺序渲染两个入口。 -- 处理申请只刷新计数,不清公告;更新公告只替换公告对象,不影响申请入口。 - -### 6.9 群公告(对方查看状态).png - -.png) - -**状态:新增。** - -- 详情:`GET /v1/im/groups/{topic}/announcements/{announcement_id}`;普通成员响应不含编辑权限。 -- 已读:`POST /v1/im/groups/{topic}/announcements/{id}/read`,带幂等键,返回 `read_at`;用于清除新公告标记。 -- 服务端校验当前群成员资格,退群后不可借历史 ID 读取新公告。 - -### 6.10 群聊设置.png - - - -**状态:部分订阅设置已实现;产品菜单新增。** - -- `GET /v1/im/groups/{topic}/settings` 返回 `my_role,permissions,profile,announcement,member_count,pinned,mute_notifications` 和群治理开关。 -- 个人置顶/免打扰通过 `PATCH /v1/im/groups/{topic}/member-settings`;群级设置必须使用 6.17,不混在个人设置里。 -- 退出:`POST /v1/im/groups/{topic}/leave`;群主必须先转让或解散。清记录、举报分别见 5.14、11.3。 - -### 6.11 群资料.png - - - -**状态:需改造。** - -- `GET /v1/im/groups/{topic}` 返回 `name,avatar_file_id,description,owner,member_count,version,permissions`。 -- 更新:`PATCH /v1/im/groups/{topic}`,Body 可含 `name,avatar_file_id,description,version`;成功返回最新对象并推送 `group.profile.updated`。 -- 服务端做角色、长度、敏感内容、文件归属校验;前端只展示 `permissions.can_edit_profile` 允许的入口。 - -### 6.12 设置群头像.png - - - -**状态:上传基础已实现;用途绑定需改造。** - -- 来源选择、裁剪是客户端动作。上传使用 `purpose=group_avatar`,服务端校验图片 MIME、尺寸、大小并返回 `file_id`。 -- 上传后调用 6.11 PATCH `{"avatar_file_id":"...","version":n}`;只有 PATCH 成功才替换正式头像。 -- 取消或 PATCH 失败的临时文件按 TTL 回收,不能污染群文件列表。 - -### 6.13 设置昵称(1).png - -.png) - -**状态:新增。** - -- `PUT /v1/im/groups/{topic}/members/me/profile`,Body `{"nickname":"...","version":n}`。 -- 服务端校验成员资格、长度和敏感词,返回 `nickname,version,updated_at` 并推送成员资料变化。 -- 该昵称仅作用当前群,不能修改全局用户昵称或其他群资料。 - -### 6.14 群简介.png - - - -**状态:同 6.11,需改造。** - -- 保存调用 `PATCH /v1/im/groups/{topic}`,Body `{"description":"...","version":n}`;空字符串表示清空,省略表示不修改。 -- 服务端返回 `VALIDATION_FAILED` 的 `details.field/max_length` 或 `CONTENT_REJECTED`;前端保留草稿供修正。 - -### 6.15 群标识.png - - - -**状态:角色已有基础数据;产品渲染需统一。** - -- 群消息下行增加服务端确认的 `sender_snapshot{display_name,role}`,角色枚举固定 `owner|admin|member`。 -- 成员角色变化事件到达后刷新成员缓存;历史消息可以保留发送时角色快照,详情以当前角色接口为准。 -- 客户端不得根据本地管理员列表给消息自行加可信标识。 - -### 6.16 群二维码页面.png - - - -**状态:新增。** - -- `POST /v1/im/groups/{topic}/qrcodes`,Body `{"expires_in_seconds":86400}`,返回签名 `qr_token,qr_payload,expires_at,group_snapshot`。 -- 扫码调用 `POST /v1/im/qrcodes/resolve`,`purpose:"join_group"`;随后 `POST /v1/im/groups/{topic}/join-requests` 并携带 `qr_token`。 -- 群解散、二维码过期或管理员撤销后必须失效;二维码不得直接承载内部群权限。 - -### 6.17 群组管理.png - - - -**状态:新增。** - -- 查询/更新:`GET/PATCH /v1/im/groups/{topic}/governance`,字段为 `join_approval,mute_all,member_upload_files,member_private_chat,member_invite` 和 `version`。 -- 每个字段返回 `editable`;PATCH 仅群主或被授权管理员可用,记录操作者、旧值、新值和 Request ID。 -- 解散:`DELETE /v1/im/groups/{topic}`,需二次 `X-Action-Token` 和幂等键;成功后撤销 Topic、二维码、邀请和文件新增权限。 - -### 6.18 设置群管理员.png - - - -**状态:新增。** - -- `GET /v1/im/groups/{topic}/members?role=admin&cursor=` 返回管理员列表;`GET /v1/im/groups/{topic}/role-limits` 返回 `max_admin_count,current_admin_count`。 -- 只有群主可进入。群主本人以 `owner` 单独展示,不计入普通管理员列表和名额。 - -### 6.19 添加管理员.png - - - -**状态:新增。** - -- 候选:`GET /v1/im/groups/{topic}/members?eligible_role=admin&query=&cursor=`,返回 `eligible` 和不可选原因。 -- 提交:`POST /v1/im/groups/{topic}/admins/batch`,Body `{"user_ids":[],"version":n}`;服务端原子校验群主身份、成员资格和管理员上限。 -- 超限返回 `GROUP_ADMIN_LIMIT_EXCEEDED`,并附 `max/current/requested`。 - -### 6.20 添加管理员-设置成功.png - - - -**状态:新增。** - -- 6.19 成功返回 `admins,version,updated_at`;前端用响应替换列表并显示成功,不在请求完成前自行修改角色。 -- 服务端推送 `group.role.updated`,新管理员收到后刷新菜单权限和群标识。 - -### 6.21 添加管理员-设置成功(1).png - -.png) - -**状态:新增。** - -- 提交期间按幂等键禁重复点击。批量接口返回 `succeeded[]`、`failed[{user_id,reason}]` 和最新 `admins`。 -- 部分失败时保留成功角色,不做整批客户端回滚;前端展示失败原因并以服务端列表为准。 - -### 6.22 删除管理员-设置成功.png - - - -**状态:新增。** - -- `DELETE /v1/im/groups/{topic}/admins/{user_id}`,带幂等键和 `If-Match`/`version`。 -- 成功后目标角色变为 `member`,保留群成员关系;服务端立即撤销管理权限并广播角色事件。 -- 删除群主、非成员或最后状态冲突分别返回稳定错误,不允许前端拼接通用成功。 - -### 6.23 添加禁言成员.png - - - -**状态:新增。** - -- 候选:`GET /v1/im/groups/{topic}/members?mute_eligible=true&query=&cursor=`。 -- 提交:`POST /v1/im/groups/{topic}/mutes/batch`,Body `{"members":[{"user_id":"usr...","expires_at":null}],"reason":""}`。 -- 服务端校验操作者角色层级;管理员不能禁言群主,普通管理员互相操作规则由治理策略确定。 - -### 6.24 禁言的群成员.png - - - -**状态:新增。** - -- `GET /v1/im/groups/{topic}/mutes?cursor=` 返回 `user,muted_by,muted_at,expires_at`;解除 `DELETE /v1/im/groups/{topic}/mutes/{user_id}`。 -- 禁言成员发送消息时服务端返回 `GROUP_MUTED` 和可选 `expires_at`;不能只禁用前端输入框。 -- 退群时清理禁言记录;过期由服务端自动失效并推送更新。 - -### 6.25 选择新群主.png - - - -**状态:新增。** - -- `GET /v1/im/groups/{topic}/members?eligible_role=owner&query=&cursor=`,只返回状态正常的当前成员。 -- 前端单选并保存 `target_user_id`;当前群主、已退出、停用账号返回 `eligible=false`。 -- 此步骤不修改权限,真正转让仅在 6.26 确认后执行。 - -### 6.26 选择新群主提示.png - - - -**状态:新增。** - -- `POST /v1/im/groups/{topic}/owner-transfer`,Body `{"target_user_id":"usr...","version":n}`,必须带幂等键和二次认证 `X-Action-Token`。 -- 服务端事务内把旧群主降为管理员或成员、目标升为群主、更新权限和审计,再生成系统事件。 -- 成功返回 `owner,previous_owner,roles,version`;并发成员变化返回 `GROUP_VERSION_CONFLICT`,前端重新确认,不自动重试。 - -### 6.27 群文件.png - - - -**状态:新增群文件索引;底层文件存储已实现。** - -- `GET /v1/im/groups/{topic}/files?query=&type=&cursor=` 返回 `group_file_id,file_id,name,size,mime,uploader,created_at,download_state,permissions`。 -- 下载使用认证文件接口,服务端校验当前群成员和文件状态;删除 `DELETE /v1/im/groups/{topic}/files/{group_file_id}` 需上传者或管理员权限。 -- 退群后不可继续使用旧 URL 下载;缓存文件是否保留由客户端隐私策略处理。 - -### 6.28 群文件-上传状态.png - - - -**状态:需改造。** - -- 先用 `purpose=group_file,topic=...` 上传,再 `POST /v1/im/groups/{topic}/files`,Body `{"file_id":"..."}` 建立群文件记录。 -- 服务端校验 `member_upload_files`、单文件上限、租户总存储和群成员身份;成功返回完整群文件项。 -- 上传失败可复用 `upload_id`;登记失败不得在列表显示,孤立文件按 TTL 回收。 - -## 7. 文件下载、收藏管理与聊天记录检索 - -### 7.1 我的收藏.png - - - -**状态:新增。** - -- `GET /v1/im/favorites?type=all|text|image|video|voice|file|link&q=&cursor=`,返回 `favorite_id,type,snapshot,source,collected_at,availability`。 -- 类型与关键词由服务端过滤,客户端不下载全部收藏再筛选。`source` 仅下发用户仍可知晓的会话摘要。 -- 收藏数据属于当前租户当前用户;跨租户登录不得合并列表。 - -### 7.2 我的收藏(1).png - -.png) - -**状态:新增。** - -- 详情:`GET /v1/im/favorites/{favorite_id}`;删除:`DELETE /v1/im/favorites/{favorite_id}`;转发使用 7.8。 -- 收藏创建时保存受控快照和附件引用,因此原消息对当前用户删除后仍可按保留策略展示;原资源因合规删除时返回 `availability:"removed"`。 -- 删除收藏不删除原消息或已转发消息。 - -### 7.3 我的收藏-文件.png - - - -**状态:新增。** - -- 调用 7.1,固定 `type=file`;项中使用 `snapshot.file{name,size,mime,file_id}` 和 `availability`。 -- 点击前调用收藏详情获得当前授权,下载仍走 7.9 文件协议;失效显示原因,不反复请求公共 URL。 - -### 7.4 我的收藏-文本.png - - - -**状态:新增。** - -- 调用 7.1,固定 `type=text`;服务端返回纯文本/结构化 Drafty 快照和安全高亮区间 `matches[]`。 -- 展开、折叠、复制为本地动作;复制前遵守企业 DLP 策略 `permissions.can_copy`。 - -### 7.5 我的收藏-语音.png - - - -**状态:新增。** - -- 调用 7.1,固定 `type=voice`;播放前获取认证文件,返回 `duration_ms,waveform,availability`。 -- 播放/暂停和单实例播放器是客户端状态;服务端不接收逐秒播放进度,除非后续定义跨端收听状态。 - -### 7.6 我的收藏—链接.png - - - -**状态:新增。** - -- 调用 7.1,固定 `type=link`;服务端快照返回 `url,title,summary,thumbnail_file_id,risk_level`。 -- 点击前 `POST /v1/im/security/urls/check`,Body `{"url":"...","source":"favorite"}`;返回 `allow|warn|block` 和 `risk_challenge_id`。 -- 客户端不得在未确认时自动打开或预加载高风险外链。 - -### 7.7 收藏—文件(多选).png - -.png) - -**状态:新增。** - -- 选择状态本地维护;批量删除 `POST /v1/im/favorites/batch-delete`,Body `{"favorite_ids":[]}`;批量转发见 7.8。 -- 返回逐项成功/失败;删除必须二次确认并带幂等键。跨页选择只保存 ID,不保留失效的临时下载地址。 - -### 7.8 收藏文件—分享给好友页面.png - - - -**状态:新增。** - -- 目标列表使用 4.17;提交 `POST /v1/im/favorites/forward`,Body `{"favorite_ids":[],"target_topics":[],"comment":""}`。 -- 服务端从收藏的受控附件引用生成新消息和新 ACL,返回每个目标的消息结果;收藏原件不变。 -- 文件失效、目标禁言或无发送权限按目标逐项失败,不允许客户端直接复用旧下载 URL 发送。 - -### 7.9 文件-下载.png - - - -**状态:底层下载已实现租户校验;资源 ACL 需改造。** - -- 客户端使用 `GET /v0/file/s/{file_id}`,Header 传 API Key 与 `X-Tinode-Auth: token <base64_token>`;禁止 `?auth=&secret=`。 -- 服务端除 Token 租户外,还必须校验该用户通过消息、群文件或收藏拥有有效引用,并支持 `Range`、`ETag`、内容长度和安全文件名。 -- 下载前客户端校验本地空间和网络策略;`FILE_FORBIDDEN` 不自动重试,`FILE_UNAVAILABLE` 显示失效。 - -### 7.10 文件-下载中.png - - - -**状态:客户端任务管理;服务端需支持 Range。** - -- 下载任务保存 `file_id,etag,total_bytes,downloaded_bytes,temp_path,state`;恢复时携带 `Range` 与 `If-Range: <etag>`。 -- ETag 改变或服务端返回完整 200 时丢弃旧分片重新下载;取消时关闭连接并清理临时文件。 -- Token 过期先完成一次刷新后续传,不能把 Token 写入任务文件或日志。 - -### 7.11 文件-下载完成.png - - - -**状态:纯客户端完成态。** - -- 完成后校验长度和服务端提供的 checksum,再原子移动到受控目录;校验失败删除并标记重试。 -- 打开/分享遵守系统沙箱和企业 DLP。每次展示页面检查本地文件存在性,不存在则恢复“可下载”。 - -### 7.12 查找聊天记录-搜索关键词选中状态(点击后跳出对应聊天内容).png - -.png) - -**状态:新增服务端搜索。** - -- `GET /v1/im/topics/{topic}/messages/search?q=&type=text&cursor=` 返回 `seq,from,ts,snippet,matches[]`。 -- 点击结果后 `GET /v1/im/topics/{topic}/messages/{seq}/context?before=20&after=20`,返回锚点及上下文;客户端定位 `seq` 并高亮。 -- 服务端只搜索当前用户可见且未硬删除的消息,关键词做长度/频率限制,搜索日志不得记录原文。 - -### 7.13 查找聊天记录-文件.png - - - -**状态:新增。** - -- 调用 `GET /v1/im/topics/{topic}/messages/search?type=file&q=&cursor=`,返回消息 `seq` 与文件快照、发送者、时间、`availability`。 -- 预览/下载使用 7.9;撤回或无授权时显示失效。点击来源用 7.12 上下文接口定位。 - -### 7.14 查找聊天记录-链接.png - - - -**状态:新增。** - -- 调用搜索接口 `type=link`,返回 `url,title,summary,seq,ts,risk_level`;打开前执行 7.6 URL 安全检查。 -- 链接预览由服务端受控抓取和缓存,客户端不直接抓取内网地址,防止 SSRF 和隐私泄露。 - -### 7.15 查看聊天记录-图片.png - - - -**状态:新增。** - -- `GET /v1/im/topics/{topic}/media?type=image&cursor=` 返回 `seq,thumbnail_file_id,file_id,width,height,ts,availability`。 -- 缩略图懒加载使用认证文件请求;大图前再次授权。多选仅保存 `(topic,seq)`,操作使用第 4 章批量接口。 - -### 7.16 查看聊天记录-视频.png - - - -**状态:新增。** - -- 调用 7.15 接口,`type=video`,额外返回 `duration_ms,mime,size`。 -- 播放器使用支持 Range 的认证下载/流式读取;文件失效、Token 过期和网络中断分别映射可理解状态,返回列表保持游标与滚动位置。 - -## 8. 实时语音与视频通话 - -### 8.1 各种通话提示状态.png - - - -**状态:已实现基础通话记录替换;产品文案映射需统一。** - -- 发起呼叫时的持久消息就是通话记录。服务端以相同 `seq` 写 replacement `{data}`,`head.replace=":{seq}"`,并把 `head.webrtc` 更新为 `accepted|finished|disconnected|missed|declined`。 -- 已接通结束时 `head.webrtc-duration` 为服务端计算的毫秒数。客户端映射:`finished=已结束`、`missed=未接听/已取消`、`declined=已拒绝`、`disconnected=异常结束`。 -- 双方使用原消息 `content.callId/callType` 和同一 `seq`;客户端不得另发一条“通话结果”消息或相信本地传入的 duration。 - -### 8.2 对方状态.png - - - -**状态:在线邀请已实现;Push 与产品鉴权需改造。** - -- 来电是 Topic 下行的初始 `{data}`:`head.webrtc="started"`、`head.x-im-type="call"`,`content` 至少含 `callId,callType,audio,video`。客户端保存该消息 `seq` 作为后续所有 call note 的关联 ID。 -- 来电端开始响铃后发送 `{note:{topic,what:"call",event:"ringing",seq,payload:{"callId":"..."}}}`;接听发送 `event:"accept"`;拒绝发送 `event:"hang-up"`。 -- 后台 Push 只携带 `callId,topic,seq,caller_display,callType,expires_at`,不含 SDP、ICE 或 Token;用户操作后必须回 WebSocket 鉴权。 - -### 8.3 语音通话-对方待接受状态.png - - - -**状态:发起与超时已实现基础;好友/拉黑/租户开关校验需改造。** - -- 发起前查询 `capabilities.can_call`,再发布持久邀请消息: - -```json -{"pub":{"id":"call-1","topic":"p2p...","head":{"mime":"application/json","webrtc":"started","x-im-type":"call"},"content":{"callId":"01K...","callType":"audio","audio":true,"video":false}}} -``` - -- `{ctrl code:202,params.seq}` 的 `seq` 是后续 `ringing/accept/offer/answer/ice-candidate/hang-up` 必填关联值。服务端只允许 P2P 双方参与,并校验好友、拉黑、租户通话开关和当前 Topic 无并发呼叫。 -- 发起方取消发送 `{note event:"hang-up",seq,payload:{"callId":"...","reason":"cancelled"}}`;等待超时由服务端结束并把原消息替换为 `missed`。 - -### 8.4 语音通话-双方语音沟通中.png - - - -**状态:WebRTC 客户端能力;信令和服务端计时已实现。** - -- 被叫发送 `accept` 后,服务端把原消息替换为 `webrtc:"accepted"` 并向主叫发送 `{info what:"call",event:"accept",seq}`。 -- 双方随后用 `{note what:"call",event:"offer|answer|ice-candidate",seq,payload:{"callId":"...",...}}` 交换 SDP/ICE。ICE 配置使用 `hi.ctrl.params.iceServers`,不得在 App 中硬编码 TURN 长期凭证。 -- 任一端发送 `hang-up`,服务端按接受时间计算 duration 并替换原通话消息。异常断线后按原 `topic+seq+callId` 恢复或结束,不新建第二通呼叫。 - -### 8.5 语音通话-麦克风与扬声器已关.png - - - -**状态:纯客户端设备控制。** - -- 麦克风关闭通过禁用本地音轨,扬声器关闭/听筒切换通过系统音频路由完成,不调用业务 HTTP。 -- UI 必须读取实际音轨和路由状态;权限撤销时强制更新。是否告知对方静音可发送临时 `media-state`,不落聊天记录。 - -### 8.6 语音通话-麦克风与扬声器已开.png - - - -**状态:纯客户端设备控制。** - -- 开启麦克风恢复本地音轨,开启扬声器选择实际输出设备;蓝牙/耳机变化以操作系统回调为准。 -- 不把设备名称、系统音量上传服务端。切换失败恢复真实按钮状态并给本地提示。 - -### 8.7 视频通话—等待对方接受接受邀请中.png - - - -**状态:基础通话信令已实现;隐私约束需落实。** - -- 发起协议同 8.3,`media_type:"video"`。本地预览只在设备显示,接听前媒体轨默认不发给对方,SDP 只声明能力。 -- 切换摄像头纯客户端;取消发送 `hang-up:cancelled`。相机权限不足时改为音频呼叫或取消,不伪装视频已开启。 - -### 8.8 视频通话—等待对方接受接受邀请-摄像头关闭.png - - - -**状态:纯客户端状态;媒体状态事件需改造。** - -- 关闭本地视频轨并显示头像;接听后沿用 `video_enabled=false`。 -- 当前服务端只允许 `ringing/accept/offer/answer/ice-candidate/hang-up`;首版应直接通过 WebRTC 轨道 mute 状态呈现。若后续新增 `media-state`,必须先修改 `server/calls.go` 事件白名单、参与者校验和限频,客户端不能提前发送未知事件。 - -### 8.9 双方视频通话中.png - - - -**状态:WebRTC 客户端能力;服务端信令已具备基础。** - -- 主/小窗布局、拖动和横竖屏纯客户端;WebRTC offer/answer/ICE 与 8.2~8.4 相同。 -- 服务端只做身份鉴权、信令路由、TURN 配置和通话审计,不接收前端伪造的通话时长。 -- 网络恶化可本地降码率或关闭视频轨;最终结束必须发 `hang-up`。服务端未支持 `media-state` 前不得发送该事件。 - -### 8.10 双方视频通话中-对方关闭摄像头状态.png - - - -**状态:现阶段纯客户端;可选媒体状态事件需改造。** - -- 以对端 WebRTC 视频轨的 mute/unmute 作为真实信号;关闭时显示资料缓存中的对方头像并保留音频,轨道恢复后自动显示视频。 -- 不需要业务 HTTP。可选 `media-state` 必须等服务端扩展事件白名单后另行启用,且不持久化、不改变通话结果。 - -### 8.11 视频与语音通话悬浮状态中.png - - - -**状态:纯客户端。** - -- 最小化不结束 WebRTC 或 WebSocket,会话仍以同一 `call_id` 运行;点击恢复全屏不发送新 invite。 -- 系统悬浮窗权限、画中画和安全区域按平台实现。收到 `hang-up`、Token 失效或媒体会话结束后立即关闭悬浮窗并落正确结果态。 - -## 9. 企业工作台与办公协同 - -### 9.1 工作台.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 - - - -**状态:新增。** - -- 根节点:`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 - - - -**状态:新增。** - -- 创建:`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 - - - -**状态:新增。** - -- 详情同 9.3;响应:`PUT /v1/im/meetings/{meeting_id}/my-response`,Body `{"response":"accepted|declined|tentative","version":n}`。 -- 返回我的最终响应和会议版本,并实时通知发起人;重复提交同状态幂等。 -- 会议过期返回 `MEETING_CLOSED` 并只读展示;客户端本地日历写入是可选系统动作,不替代服务端响应。 - -### 9.5 日报.png - - - -**状态:新增。** - -- `GET /v1/im/reports/daily?scope=mine&from=&to=&status=&cursor=` 返回 `report_id,report_date,status,draft_updated_at,submitted_at,read_state`。 -- 新建草稿 `POST /v1/im/reports/daily`,Body `{"report_date":"2026-08-11"}`;同一用户同一天唯一,重复创建返回原草稿。 -- `permissions.can_create` 和可补交日期由服务端返回,前端不自行判断工作日规则。 - -### 9.6 日报-普通用户页面.png - - - -**状态:新增。** - -- 编辑:`PUT /v1/im/reports/daily/{id}/draft`,Body `{"content":"...","attachments":[],"version":n}`;提交 `POST /v1/im/reports/daily/{id}/submit`。 -- 服务端返回自动保存版本,客户端用 2~5 秒防抖且串行保存;版本冲突返回 `REPORT_VERSION_CONFLICT` 并给最新版本。 -- 提交后按 `editable_until` 决定是否可修改;附件使用 `purpose=daily_report` 上传并做权限绑定。 - -### 9.7 日报-管理员页面.png - - - -**状态:新增。** - -- `GET /v1/im/reports/daily/statistics?date=&department_id=` 返回 `expected_count,submitted_count,missing_count` 和组织权限范围。 -- 明细:`GET /v1/im/reports/daily?scope=managed&date=&status=submitted|missing&department_id=&cursor=`。 -- 提醒:`POST /v1/im/reports/daily/reminders`,Body `{"date":"...","user_ids":[]}`;服务端过滤无管理权限对象并限流,返回逐项结果。 - -### 9.8 日报-查看详情.png - - - -**状态:新增。** - -- `GET /v1/im/reports/daily/{report_id}` 返回填报人、日期、内容、附件、状态、版本、修改时间和 `permissions`。 -- 管理员查看时服务端记录必要的已读状态;附件下载复用第 7 章 ACL。 -- 撤回或修订使用专用状态和版本,客户端必须显示最新版本与 `revised_at`,不能混合旧缓存。 - -### 9.9 月报(1).png - -.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 - - - -**状态:新增。** - -- `GET /v1/im/reports/monthly?scope=mine|managed&from_month=&to_month=&status=&cursor=` 返回月份、填报人、状态和时间。 -- 管理员统计 `GET /v1/im/reports/monthly/statistics?month=&department_id=`;普通用户响应中不下发团队统计入口权限。 -- 未提交项进入 9.9,已提交项进入 9.11,路由由返回 `action=edit|view` 决定。 - -### 9.11 月报-详情页.png - - - -**状态:新增。** - -- `GET /v1/im/reports/monthly/{report_id}` 返回 `month,author,work_summary,next_plan,attachments,status,submitted_at,updated_at,version,permissions`。 -- `permissions.can_edit=true` 时进入草稿更新;否则只读。修改已提交月报需专用 `POST /v1/im/reports/monthly/{id}/reopen` 或审核流程,不直接覆盖。 - -### 9.12 总经理信箱.png - - - -**状态:新增。** - -- 页面配置:`GET /v1/im/executive-mailbox/config` 返回 `enabled,allow_anonymous,required_fields,max_length,attachment_policy,notice`。 -- 提交:`POST /v1/im/executive-mailbox/submissions`,Body `{"subject":"...","content":"...","anonymous":false,"attachment_file_ids":[]}`,带幂等键。 -- 成功仅返回 `submission_id,status:"received",submitted_at`;匿名模式下业务接收方不见身份,但安全审计是否保留内部追踪按企业政策执行并提前告知。 - -### 9.13 总经理信箱-文案提示.png - - - -**状态:新增配置接口。** - -- 文案使用 9.12 配置中的 `notice{title,content,updated_at}`,由租户后台配置;客户端不得硬编码实名/匿名承诺。 -- 提交按钮前必须展示当前版本文案;请求可携带 `notice_version`,服务端发现关键政策已变更时返回 `NOTICE_RECONFIRM_REQUIRED`。 - -## 10. 个人资料、通用设置、隐私与青少年模式 - -### 10.1 我的.png - - - -**状态:基础用户资料已有;产品菜单新增。** - -- `GET /v1/im/me` 返回公开账号资料、实名/青少年模式摘要和资料版本;`GET /v1/im/bootstrap` 返回个人菜单与权限。 -- 头像、昵称更新事件到达后刷新头部;收藏和设置入口分别进入第 7、10 章。 -- 账号 ID 按字符串展示,客户端不得展示内部数据库 ID 或 tenant_id。 - -### 10.2 个人信息.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 - - - -**状态:需改造。** - -- `PATCH /v1/im/me/profile`,Body `{"nickname":"新昵称","version":n}`。 -- 服务端校验 Unicode 长度、全空白、控制字符和敏感内容;返回更新后的 `nickname,version,updated_at`。 -- 成功刷新全局资料和联系人快照,但不得覆盖各群昵称。版本冲突先拉最新资料再由用户确认。 - -### 10.4 出生日期.png - - - -**状态:新增产品字段。** - -- 保存同 10.3,Body `{"birth_date":"2000-01-02","version":n}`;仅传 ISO 日期,不传本地化文本或时区时间戳。 -- 服务端校验不晚于当前租户日期及合理范围,返回 `PROFILE_FIELD_INVALID`。展示格式由客户端本地化。 - -### 10.5 个性签名.png - - - -**状态:需改造。** - -- 保存同 10.3,Body `{"signature":"...","version":n}`;空字符串表示清空。 -- 服务端按 `field_rules.signature.max_length` 和内容审核处理;失败保留本地草稿,成功刷新引用页面。 - -### 10.6 二维码.png - - - -**状态:图标纯客户端;二维码协议新增。** - -- 点击入口进入 5.4 并创建短期个人二维码。每次主动刷新调用 `POST /v1/im/me/qrcodes` 获取新 Token。 -- 二维码不能使用静态 `user_id`、手机号或登录 Token;过期、撤销和切租户后必须重新生成。 - -### 10.7 设置(iOS).png - -.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 - -.png) - -**状态:菜单纯客户端;业务协议同 10.7。** - -- 使用相同 `bootstrap.settings_menu` 与退出协议;Android 系统权限、通知渠道和应用更新使用平台实现。 -- 服务端只保存应用内通知偏好与设备 Push Token,不把系统设置页面是否打开当作已授权。 - -### 10.9 清空缓存的提示.png - - - -**状态:纯客户端。** - -- 确认后只删除缩略图、可重下文件、临时上传下载和搜索缓存;不调用服务端删除消息、收藏或账号接口。 -- 不删除安全存储中的登录凭证,除非用户选择退出登录。完成后重新计算本地占用并显示结果。 - -### 10.10 版本更新页面.png - - - -**状态:新增 App 配置接口。** - -- `GET /v1/im/app/releases/latest?platform=ios|android¤t_version=1.0.0&channel=stable`,未登录可在企业解析后调用。 -- 返回 `latest_version,min_supported_version,update_type=none|optional|required,release_notes,store_url,package_url,checksum,published_at`。 -- 客户端按平台只打开受信任商店/签名下载地址;Android 包必须校验签名和 checksum,服务端 URL 使用允许域名。 - -### 10.11 发现新版本.png - - - -**状态:纯客户端弹窗;数据来自 10.10。** - -- `optional` 显示稍后/更新,记录本地已忽略版本并按服务端 `remind_after` 再提示;`required` 不显示绕过入口。 -- 下载和安装状态由平台管理;接口异常时不得误判为强制更新,除非本地已有有效签名的最低版本策略缓存。 - -### 10.12 隐私设置.png - - - -**状态:新增。** - -- `GET /v1/im/me/privacy` 返回 `add_me_methods,profile_visibility,last_seen_visibility,read_receipts,version` 和各字段 `editable`。 -- `PATCH /v1/im/me/privacy` 只提交变更字段和 `version`;成功返回完整最新配置并向其他设备推送 `privacy.updated`。 -- 黑名单入口使用 5.12。服务端在搜索、主页、回执等实际读写路径执行隐私规则,不能只保存开关。 - -### 10.13 隐私设置-添加我的方式选项.png - - - -**状态:新增。** - -- `add_me_methods` 固定字段 `account,phone,qr,group`,每项为布尔值;更新用 10.12 PATCH,如 `{"add_me_methods":{"phone":false},"version":n}`。 -- 关闭后对应搜索/二维码/群来源申请由服务端统一返回 `FRIEND_REQUEST_NOT_ALLOWED` 或 `USER_NOT_DISCOVERABLE`,不泄露具体关闭项。 -- 企业强制策略可令字段 `editable=false`,前端禁用并显示服务端提供的通用说明。 - -### 10.14 青少年模式未开启(安卓).png - -.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 - -.png) - -**状态:弹窗纯客户端;文案由服务端配置。** - -- 使用 10.14 返回的 `restrictions` 与 `policy_version` 渲染;取消不请求,确认进入密码设置/验证。 -- 最终 enable 请求携带用户确认的 `policy_version`;若策略更新返回 `POLICY_RECONFIRM_REQUIRED`,重新展示新内容。 - -### 10.16 青少年模式已开启(安卓).png - -.png) - -**状态:新增。** - -- 页面调用 10.14,`enabled=true` 时展示 `enabled_at,restrictions`。`bootstrap` 同时返回受限模块和动作。 -- 受限接口服务端返回 `YOUTH_MODE_RESTRICTED`;前端隐藏只是体验优化,不能作为安全控制。 -- 登录、App 重启和另一设备均从服务端恢复状态;收到 `youth_mode.updated` 即刷新。 - -### 10.17 关闭青少年模式(安卓).png - -.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 - -.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 - -.png) - -**状态:纯客户端。** - -- 仅在功能因系统权限不可用时显示;取消和“前往设置”不调用业务服务端。 -- 回到应用后重新读取真实权限,不能因用户打开过设置页就假定授权成功。 - -### 10.20 相机说明(安卓).png - -.png) - -**状态:纯客户端。** - -- 首次拍照、扫码或视频通话前展示用途说明,确认后再触发系统权限;拒绝不发媒体/扫码请求。 -- 永久拒绝时提供系统设置入口;允许从相册选择等替代能力。客户端不得上传系统权限状态作为服务端授权依据。 - -### 10.21 麦克风说明(安卓).png - -.png) - -**状态:纯客户端。** - -- 首次录音或通话前说明并申请系统权限;未授权时不得创建录音文件或发送 call invite。 -- 永久拒绝后进入系统设置引导;恢复授权后重新执行 4.7 或第 8 章动作,不复用过期媒体会话。 - -## 11. 反诈安全、举报与意见反馈 - -### 11.1 反诈弹窗(安卓).png - -.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 - -.png) - -**状态:新增。** - -- 确认:`POST /v1/im/security/risk-challenges/{risk_challenge_id}/confirm`,Body `{"decision":"continue|cancel"}`,带幂等键。 -- `continue` 成功返回单次、短期 `risk_action_token`;客户端在重试原业务接口时以 `X-Risk-Action-Token` 携带,原请求幂等键保持不变。 -- 服务端可返回 `RISK_ACTION_BLOCKED` 直接阻断;确认、取消、过期均写审计,客户端不得无限创建 challenge 绕过。 - -### 11.3 举报.png - - - -**状态:新增。** - -- 原因:`GET /v1/im/reports/abuse/reasons?target_type=user|group|message` 返回 `reason_code,label,requires_description,allows_evidence`。 -- 进入页面携带受控目标:用户 `user_id`、群 `topic` 或消息 `(topic,seq)`;客户端不提交 tenant_id 或被举报者任意文本身份。 -- 服务端先验证举报人能够看到目标,再返回适用原因;举报人身份不下发给被举报方。 - -### 11.4 举报(1).png - -.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 - - - -**状态:新增。** - -- 配置:`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