feat: add redis cache and fanout support

This commit is contained in:
richjohnrambo
2026-08-25 20:54:03 +08:00
parent 07376c70e3
commit f900a08a01
68 changed files with 3949 additions and 5696 deletions
+4
View File
@@ -13,3 +13,7 @@
/.codex-gocache/ /.codex-gocache/
/.github/ /.github/
/.github/ /.github/
/docs/third/三方服务依赖信息.md
/docs/test/im_third_party_file_safety_test_plan.md
docs/*.zip
+75 -3
View File
@@ -18,11 +18,23 @@
多通过配置的方式处理 前端不要写死 多通过配置的方式处理 前端不要写死
一定要考虑好群聊的推送问题 一定要考虑好群聊的推送问题
离线和在线状态存到redis里面 离线和在线状态存到redis里面 这个是一个难点
离线的不推
离线的我们的服务不推 走极光的推送 离线的我们的服务不推 走极光的推送
在线的直接ws推
多用开关 多用开关
校验营业执照
第三方
短信
kyc
营业执照
声网 Agora 音视频服务
极光推送 离线 Push 服务
oss对象存储
文件安全扫描 / 内容安全服务
admin后台群操作通过kafka投递到c端 c端通过ws传到客户端 admin后台群操作通过kafka投递到c端 c端通过ws传到客户端
记录先放到Kafka、 再异步落库。用考虑重复落库的可能 记录先放到Kafka、 再异步落库。用考虑重复落库的可能
@@ -33,7 +45,67 @@ admin后台群操作通过kafka投递到c端 c端通过ws传到客户端
5. 有 OpenIM 或类似 IM 项目经验优先。 5. 有 OpenIM 或类似 IM 项目经验优先。
1.注册增加邀请码 (非必填 )不填邀请码可以正常注册。只要手机号符合11位格式就行 1.注册增加邀请码 (非必填 )不填邀请码可以正常注册。只要手机号符合11位格式就行
2.验证码验证作为开关 关闭的情况下的话后台不想第三方发送请求,前端正常ui的倒计时,但是收不到验证码 2.验证码验证作为开关 关闭的情况下的话后台不向短信服务商发送请求,前端正常ui的倒计时,但是收不到验证码
3.正常不填邀请码,用真实手机号注册的,且后台开关打开的情况下,可以收到验证码的流程也能正常注册 3.正常不填邀请码,用真实手机号注册的,且后台开关打开的情况下,可以收到验证码的流程也能正常注册
这个是现在的情况
后台开关在系统设置 字典管理 增加一个系统枚举 系统开关的 ID = 171
用户的唯一标识用user_id来标记,然后新增user_name,也就是用户名字段 用户的唯一标识用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传输技术
日志框架的接入
重中之重。加密 + 接口鉴权 防止被扫 日志要记录下来 每个小时告警
+5
View File
@@ -22,6 +22,11 @@ x-exporter:
x-tinode-env-vars: &tinode-env-vars x-tinode-env-vars: &tinode-env-vars
"STORE_USE_ADAPTER": "mysql" "STORE_USE_ADAPTER": "mysql"
"PPROF_URL": "/pprof" "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 # 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" # "EXT_CONFIG": "/etc/tinode/tinode.conf"
"WAIT_FOR": "mysql:3306" "WAIT_FOR": "mysql:3306"
@@ -17,6 +17,11 @@ x-tinode:
x-tinode-env-vars: &tinode-env-vars x-tinode-env-vars: &tinode-env-vars
"STORE_USE_ADAPTER": "mysql" "STORE_USE_ADAPTER": "mysql"
"PPROF_URL": "/pprof" "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 # 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" # "EXT_CONFIG": "/etc/tinode/tinode.conf"
"WAIT_FOR": "mysql:3306" "WAIT_FOR": "mysql:3306"
+16
View File
@@ -52,6 +52,22 @@ ENV DEFAULT_COUNTRY_CODE=US
# The MySQL DSN connection. # The MySQL DSN connection.
ENV MYSQL_DSN='root@tcp(mysql)/tinode?parseTime=true&collation=utf8mb4_0900_ai_ci' 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. # The PostgreSQL DSN connection.
ENV POSTGRES_DSN='postgresql://postgres:postgres@localhost:5432/tinode?sslmode=disable&connect_timeout=10' ENV POSTGRES_DSN='postgresql://postgres:postgres@localhost:5432/tinode?sslmode=disable&connect_timeout=10'
+16
View File
@@ -36,6 +36,22 @@ ENV DEFAULT_COUNTRY_CODE=US
# DB connection is provided at deploy time via secret-backed env var. No default value. # DB connection is provided at deploy time via secret-backed env var. No default value.
ENV MYSQL_DSN= ENV MYSQL_DSN=
ENV STORE_USE_ADAPTER=mysql 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 # 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. # DB routing resolves to the same tinode DB for the seeded "default" tenant.
+18
View File
@@ -96,6 +96,24 @@
"addresses": "mongodb", "addresses": "mongodb",
"replica_set": "rs0" "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
} }
}, },
@@ -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 热缓存已完成基础接入。
后续再逐步推进好友/黑名单全量预热、群成员分页快照、未读数等缓存改造。
+33 -1
View File
@@ -1,6 +1,6 @@
# IM 产品原型需求文档 # IM 产品原型需求文档
> 本文档根据 `/Users/superysh/im/第二项目/` 中的 156 张产品原型图整理。功能按业务域归类,每张图以原文件名作为标题,图片下方给出功能说明与界面内容描述。 > 本文档根据 `/Users/superysh/im/第二项目/` 中的 160 张产品原型图整理。功能按业务域归类,每张图以原文件名作为标题,图片下方给出功能说明与界面内容描述。
## 产品总体定位 ## 产品总体定位
@@ -18,6 +18,38 @@
提供企业租户入口。页面展示产品标识、企业码输入框和“进入企业”按钮;提交后应校验企业码是否存在、是否启用,并将用户导向对应企业的登录环境。 提供企业租户入口。页面展示产品标识、企业码输入框和“进入企业”按钮;提交后应校验企业码是否存在、是否启用,并将用户导向对应企业的登录环境。
### 1.1.1 企业注册-登录入口.png
<img src="images/企业注册-登录入口.png" alt="企业注册-登录入口" width="360">
功能说明:
企业码登录页右上角提供“企业注册”入口,底部提供“遇到问题?”反馈入口。点击“企业注册”进入企业资料提交流程;点击“遇到问题?”进入问题反馈页。入口文案应保持可见且易点击,避免与状态栏或背景装饰重叠。
### 1.1.2 企业注册-填写注册信息.png
<img src="images/企业注册-填写注册信息.png" alt="企业注册-填写注册信息" width="360">
功能说明:
企业注册页按步骤展示“填写企业信息、确定信息、提交审核”,当前处于第 1 步。用户需填写 18 位统一社会信用代码,支持扫描识别;可选上传营业执照,并填写企业邮箱用于接收审核结果及企业通知。提交前应校验统一社会信用代码格式、邮箱格式、图片大小和文件类型,并提示资料提交后预计审核时长。
### 1.1.3 企业注册-提交成功提示.png
<img src="images/企业注册-提交成功提示.png" alt="企业注册-提交成功提示" width="360">
功能说明:
企业注册资料提交成功后,在当前页展示明确 Toast 提示“提交成功,我们会尽快联系你”。提交成功状态应防止重复提交,并记录申请单状态;审核结果需通过企业邮箱或后续通知渠道触达。页面底部审核时长文案应与实际运营规则一致。
### 1.1.4 企业注册-问题反馈.png
<img src="images/企业注册-问题反馈.png" alt="企业注册-问题反馈" width="360">
功能说明:
企业入口问题反馈页支持填写问题描述并上传最多 9 张截图,便于定位企业码、注册资料、审核通知等问题。提交时应携带设备、版本、租户线索和可选联系方式;图片上传需显示数量限制、失败重试和大小校验,提交成功后给出明确受理反馈。
### 1.2 登录页面.png ### 1.2 登录页面.png
<img src="images/登录页面.png" alt="登录页面" width="360"> <img src="images/登录页面.png" alt="登录页面" width="360">
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+71 -23
View File
@@ -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-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"}}}` | | 设置搜索标签 | `{"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 系统通知、好友和群业务下行
| 事件 | 服务端下行示例 | 客户端动作 | 服务端主动下行分两类:Tinode 原生 Topic 消息使用 `{data}`、`{info}`、`{pres}` 等帧;IM 产品业务事件使用 `{info}` 帧下发,前端按 `info.what + info.event` 分发刷新。产品业务事件不占普通聊天 `seq`,不等同于系统通知列表记录;系统消息入口的未读数来自 `im_system_notifications` 和 `GET /v1/im/system-notifications`。
| --- | --- | --- |
| 好友申请创建 | `{"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"}}}` | 展示邀请卡,点击后走邀请接受或入群申请接口 | ```json
| 入群申请 | `{"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"}}}` | 刷新群成员列表、群详情成员数和会话系统提示 | "info": {
| 工作台未读变化 | `{"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` | "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 通话信令 ##### 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`。 - 查询:`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":"..."}`,必须带幂等键。 - 提交:`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 ### 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 只下发必要脱敏值。 - 新设备登录事件内容为 `device_name,platform,ip_masked,login_at,security_action`;IP 只下发必要脱敏值。
- 已读:`POST /v1/im/system-notifications/{id}/read`,带幂等键;跳转目标只接受服务端白名单 `action.type` 和内部参数。 - 已读:`POST /v1/im/system-notifications/{id}/read`,带幂等键;跳转目标只接受服务端白名单 `action.type` 和内部参数。
- 当前普通认证用户可向 `sys` Topic 发布的路径必须封禁。客户端不得渲染普通用户伪造的系统卡片为可信通知。 - 当前普通认证用户可向 `sys` Topic 发布的路径必须封禁。客户端不得渲染普通用户伪造的系统卡片为可信通知。
@@ -852,7 +896,7 @@ X-Tinode-APIKey: <预置的普通 API Key>
- 订阅 P2P Topic 后用 `{pres}` 更新在线/离线,`last_seen` 仅在对方隐私允许时展示。 - 订阅 P2P Topic 后用 `{pres}` 更新在线/离线,`last_seen` 仅在对方隐私允许时展示。
- `GET /v1/im/conversations/{topic}/context` 返回 `display_name,avatar_file_id,online_visibility,relationship`。 - `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 ### 3.3 个人对话框(我的状态).png
@@ -880,7 +924,7 @@ X-Tinode-APIKey: <预置的普通 API Key>
**状态:新增好友审批与 Topic 联动。** **状态:新增好友审批与 Topic 联动。**
- 好友申请通过后,服务端在同一事务内建立双方关系和 P2P 订阅,并生成 `friend_relationship_changed:accepted` 系统事件。 - 好友申请通过后,服务端在同一事务内建立双方关系和 P2P 订阅,并向申请人、通过人本人下发 `info.what="im", event="friend.request.accepted"`;申请人当前会写入系统通知,通过人本人当前只收到实时业务事件。
- 客户端收到事件或重新查询 `context.relationship=friend` 后解锁输入区;不得仅因本地“通过”按钮成功动画解锁。 - 客户端收到事件或重新查询 `context.relationship=friend` 后解锁输入区;不得仅因本地“通过”按钮成功动画解锁。
- 首次进入订阅 P2P Topic,再按 `since` 拉取申请上下文和正常消息。 - 首次进入订阅 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}/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":""}` | | `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. 通讯录、好友关系与黑名单
### 5.1 通讯录.png ### 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`。 - 列表:`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`。 - 同意:`POST /v1/im/friend-requests/{request_id}/accept`;拒绝:`POST /v1/im/friend-requests/{request_id}/reject`,均带幂等键和可选 `version`。
- 成功返回最终 `status` 和 P2P `topic`。重复审批返回原结果;并发冲突返回 `REQUEST_ALREADY_HANDLED` 并附当前状态,前端刷新该项。 - 成功返回最终 `status` 和 P2P `topic`。重复审批返回原结果;并发冲突返回 `REQUEST_ALREADY_HANDLED` 并附当前状态,前端刷新该项。
- 主动下行:创建申请时被申请人收到 `info.what="im", event="friend.request.created"`;通过/拒绝后申请人与处理人本人都收到 `friend.request.accepted/rejected`。前端收到后刷新新朋友列表、好友列表、会话 context 和 `bootstrap.entry_badges`。当前系统通知列表只给被申请人/申请人写记录,处理人本人主要靠实时业务事件刷新。
### 5.3 添加好友.png ### 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`。 - 列表:`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`。 - 创建:`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 ### 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`。 - 点击加入:`POST /v1/im/group-invitations/{invitation_id}/accept`,带幂等键;返回 `status=joined|pending_approval` 和 `topic`。
- 服务端校验邀请租户、有效期、群状态、成员资格和成员数上限;重复加入返回当前状态,过期返回 `INVITATION_EXPIRED`。 - 服务端校验邀请租户、有效期、群状态、成员资格和成员数上限;重复加入返回当前状态,过期返回 `INVITATION_EXPIRED`。
@@ -1389,8 +1436,8 @@ X-Tinode-APIKey: <预置的普通 API Key>
**状态:新增群成员事件。** **状态:新增群成员事件。**
- 邀请:`POST /v1/im/groups/{topic}/invitations`,Body `{"user_ids":[],"message":""}`;按群策略直接加入或创建待审批申请。 - 邀请:`POST /v1/im/groups/{topic}/invitations`,Body `{"user_ids":[],"message":""}`;按群策略直接加入或创建待审批申请。
- 加入、审批、拒绝等结果由服务端生成 `system_event{event_type:"group_membership_changed",operator,target_users,action,result}` 并写入群时间线。 - 加入、审批、拒绝等结果由服务端生成 `info.what="group"` 群业务事件,例如 `group.member.joined`、`group.join_request.accepted/rejected`。
- 客户端只渲染可信系统事件;服务端同时更新成员表、Topic 订阅和成员数,不允许三者不一致。 - 客户端只渲染服务端可信事件;服务端同时更新成员表、Topic 订阅和成员数,不允许三者不一致。
### 6.5 邀请进群申请.png ### 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}`。 - 查询最新公告:`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`。仅有权限角色可写,使用乐观锁防覆盖。 - 返回 `announcement_id,content,author,version,published_at`。仅有权限角色可写,使用乐观锁防覆盖。
- 发布成功后服务端生成群系统事件;顶部摘要使用接口返回数据,不解析聊天文本猜测。 - 发布成功后服务端生成 `group.announcement.updated` 群业务事件;顶部摘要使用接口返回数据,不解析聊天文本猜测。
### 6.8 群公告状态/邀请进群申请同时存在.png ### 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`。 - `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`,前端重新确认,不自动重试。 - 成功返回 `owner,previous_owner,roles,version`;并发成员变化返回 `GROUP_VERSION_CONFLICT`,前端重新确认,不自动重试。
### 6.27 群文件.png ### 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/workbench` 返回 `modules[{code,name,icon_file_id,route,unread_count,enabled,permission}]`、布局版本和企业配置。
- `GET /v1/im/bootstrap` 返回 `entry_badges.workbench_total_unread`,用于工作台一级入口总红点;总数只汇总当前用户可见且 `enabled=true` 的工作台模块。 - `GET /v1/im/bootstrap` 返回 `entry_badges.workbench_total_unread`,用于工作台一级入口总红点;总数只汇总当前用户可见且 `enabled=true` 的工作台模块。
- 服务端按当前 Token 的租户、组织角色和功能开关裁剪;前端只接受白名单内部 `route`,禁止直接执行服务端任意 URL。 - 服务端按当前 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 ### 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":[]}`。 - 创建:`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`。 - 详情:`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`。所有写操作带幂等键并生成通知和审计。 - 更新/取消:`PATCH /v1/im/meetings/{id}`、`POST /v1/im/meetings/{id}/cancel`;提醒未响应者 `POST /v1/im/meetings/{id}/reminders`。所有写操作带幂等键并生成通知和审计。
- 主动下行:参会人收到 `info.what="im"`,事件为 `meeting.created/updated/canceled/reminded/response.updated`,payload 包含 `meeting_id,version,state`;前端收到后刷新会议列表或会议详情。除操作者外,当前会写入系统通知列表。
### 9.4 会议通知-接收人状态.png ### 9.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}`。 - 详情同 9.3;响应:`PUT /v1/im/meetings/{meeting_id}/my-response`,Body `{"response":"accepted|declined|tentative","version":n}`。
- 返回我的最终响应和会议版本,并实时通知发起人;重复提交同状态幂等。 - 返回我的最终响应和会议版本,并通过 `meeting.response.updated` 实时通知参会人;重复提交同状态幂等。
- 会议过期返回 `MEETING_CLOSED` 并只读展示;客户端本地日历写入是可选系统动作,不替代服务端响应。 - 会议过期返回 `MEETING_CLOSED` 并只读展示;客户端本地日历写入是可选系统动作,不替代服务端响应。
### 9.5 日报.png ### 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}`。 - 高风险动作在原业务接口返回 `409 RISK_CONFIRMATION_REQUIRED`,`details={risk_challenge_id,level,notice_code,notice_params,expires_at,action}`。
- 客户端用 `notice_code` 映射经过审核的本地文案,不显示服务端规则、分数或命中特征;取消不重试原操作。 - 客户端用 `notice_code` 映射经过审核的本地文案,不显示服务端规则、分数或命中特征;取消不重试原操作。
- 低风险提示也可由可信 `security.risk_notice` 系统事件触发。普通消息不得伪装成系统反诈弹窗。 - 低风险提示也可由服务端可信安全事件触发。当前风险确认/取消会按 0.5.4.6 下发 `security.risk.continue/cancel`;普通消息不得伪装成系统反诈弹窗。
### 11.2 反诈弹窗(二次提示安卓).png ### 11.2 反诈弹窗(二次提示安卓).png
@@ -2489,7 +2537,7 @@ X-Tinode-APIKey: <预置的普通 API Key>
| --- | --- | --- | | --- | --- | --- |
| P0 | `/v1/im` 统一认证中间件、响应封装、幂等键、租户 Principal | 全部 HTTP 产品能力 | | P0 | `/v1/im` 统一认证中间件、响应封装、幂等键、租户 Principal | 全部 HTTP 产品能力 |
| P0 | 企业码解析、验证码、密码重置、二次认证、Token `auth_version`、登录换新和撤销 | 1.1~1.11、10.7~10.8 | | 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 | 消息 `x-im-schema/type/client-mid` 校验和服务端幂等 | 2~4 章 |
| P0 | 文件资源级 ACL、Range、ETag、用途绑定和租户存储配额 | 3.6、4.10~4.14、6.27~7.16、11.4~11.5 | | P0 | 文件资源级 ACL、Range、ETag、用途绑定和租户存储配额 | 3.6、4.10~4.14、6.27~7.16、11.4~11.5 |
| P1 | 好友申请、好友关系、黑名单状态机 | 第 3、5 章 | | P1 | 好友申请、好友关系、黑名单状态机 | 第 3、5 章 |
@@ -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. 好友申请通过后是否双方都写系统通知,需要产品确认;若确认双方都有,需要同步补代码。
+40 -34
View File
@@ -1,6 +1,6 @@
# IM 第一至十一章后续增强项 # IM 第一至十一章后续增强项
> 更新日期:2026-08-21 > 更新日期:2026-08-25
> 核对依据:`docs/IM-前后端对接文档.md`、`server/main.go` 当前 `/v1/im/*` 路由、`server/im_http.go` 当前 handler。 > 核对依据:`docs/IM-前后端对接文档.md`、`server/main.go` 当前 `/v1/im/*` 路由、`server/im_http.go` 当前 handler。
> 范围:第一章登录注册与安全、第二章消息首页/导航/系统通知、第三章单聊关系、第四章消息输入/转发/检索、第五章通讯录/好友/黑名单、第六章群聊创建/资料/治理、第七章文件/收藏/聊天记录检索、第八章实时语音与视频通话、第九章企业工作台与组织架构、第十章个人设置与客户端能力、第十一章反诈安全/举报/反馈。 > 范围:第一章登录注册与安全、第二章消息首页/导航/系统通知、第三章单聊关系、第四章消息输入/转发/检索、第五章通讯录/好友/黑名单、第六章群聊创建/资料/治理、第七章文件/收藏/聊天记录检索、第八章实时语音与视频通话、第九章企业工作台与组织架构、第十章个人设置与客户端能力、第十一章反诈安全/举报/反馈。
> 用途:记录当前代码基线之外仍需排期的增强项。已经落地的 P0/P1 不再作为待办保留;已经抽入专项开发文档的项目,在本文中仅保留来源和现状说明,不再作为未分流待办处理。 > 用途:记录当前代码基线之外仍需排期的增强项。已经落地的 P0/P1 不再作为待办保留;已经抽入专项开发文档的项目,在本文中仅保留来源和现状说明,不再作为未分流待办处理。
@@ -14,42 +14,38 @@
3. `GET /v1/im/forward-targets` 已新增,返回文件助手和可转发 P2P/群会话目标。 3. `GET /v1/im/forward-targets` 已新增,返回文件助手和可转发 P2P/群会话目标。
4. 总经理信箱配置与提交的 500 已修复,`GET /v1/im/executive-mailbox/config`、`POST /v1/im/executive-mailbox/submissions` 已可联调。 4. 总经理信箱配置与提交的 500 已修复,`GET /v1/im/executive-mailbox/config`、`POST /v1/im/executive-mailbox/submissions` 已可联调。
5. 举报原因 500 已修复,`GET /v1/im/reports/abuse/reasons` 已按 `items` 返回。 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` 保存手机号/邮箱。 7. 注册数据模型已改为 `im_users.username`、`im_users.phone`、`im_user_profiles.email`,不再依赖用户 `tags` 保存手机号/邮箱。
8. 邀请码主数据在 HotGo 库,注册使用明细在 IM 租户库;邀请码可多人复用,不再“核销”。 8. 邀请码主数据在 HotGo 库,注册使用明细在 IM 租户库;邀请码可多人复用,不再“核销”。
9. 用户搜索、用户主页、用户二维码已支持 `user_id/username/public_account` 作为 identifier。 9. 用户搜索、用户主页、用户二维码已支持 `user_id/username/public_account` 作为 identifier。
10. 第一批增强中的群文件列表/登记/删除、群邀请外部接受、实名更正申诉、组织全局成员接口、月报 reopen 已落地。
11. 第一批增强中的主动创建/获取 P2P 会话、会话设置、多端云草稿已落地。
### 0.2 当前仍明确缺口及分流状态 ### 0.2 当前仍明确缺口及分流状态
| 优先级 | 缺口 | 对接文档位置 | 当前代码状态 | 分流状态 | | 优先级 | 缺口 | 对接文档位置 | 当前代码状态 | 分流状态 |
| --- | --- | --- | --- | --- | | --- | --- | --- | --- | --- |
| P0 | 真实短信供应商发送、失败重试、发送审计 | 1.4、1.7、1.10 | 当前测试码 `123456` 可联调,但生产短信未完成 | 依赖第三方,暂留后续增强池 | | 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 | 消息转发落地接口 | 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` | | 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 已抽入第一批开发的项目 ### 0.3 已抽入第一批开发的项目
以下项目已经从本文档的普通后续增强池中抽出,形成专项开发文档 `docs/IM-增强项第一批开发.md`。它们当前代码上仍未完全落地,但不再按“未分流的未实现项”处理,后续应以第一批开发文档作为实现、验收和同步更新依据。 以下项目已经从本文档的普通后续增强池中抽出,形成专项开发文档 `docs/IM-增强项第一批开发.md`。其中已落地项已移入各章“当前已完成基线”;仍未完全落地的项目继续以第一批开发文档作为实现、验收和同步更新依据。
1. 登出与指定会话撤销。 1. 仍待落地:登出与指定会话撤销。
2. 普通消息转发。 2. 仍待落地:普通消息转发。
3. 合并转发 Bundle 详情。 3. 仍待落地:合并转发 Bundle 详情。
4. 群文件列表、登记和删除。 4. 已落地:群文件列表、登记和删除。
5. 主动创建/获取 P2P 会话。 5. 已落地:主动创建/获取 P2P 会话。
6. 会话设置。 6. 已落地:会话设置。
7. 多端云草稿。 7. 已落地:多端云草稿。
8. 群邀请外部接受。 8. 已落地:群邀请外部接受。
9. 实名更正申诉。 9. 已落地:实名更正申诉。
10. 组织全局成员接口。 10. 已落地:组织全局成员接口。
11. 月报 reopen。 11. 已落地:月报 reopen。
12. 列表 `has_more` 统一。 12. 仍待统一回归:列表 `has_more` 统一。
13. 风控 token 单次消费接入业务接口。 13. 仍待落地:风控 token 单次消费接入业务接口。
## 1. 第一章:登录、注册与安全 ## 1. 第一章:登录、注册与安全
@@ -70,6 +66,7 @@
13. 产品 HTTP 已统一校验 API Key、Schema 和 Request ID。 13. 产品 HTTP 已统一校验 API Key、Schema 和 Request ID。
14. 登录态写接口已接入租户库幂等记录,支持请求摘要冲突检测和首次响应重放。 14. 登录态写接口已接入租户库幂等记录,支持请求摘要冲突检测和首次响应重放。
15. 风险确认可生成安全事件和系统通知。 15. 风险确认可生成安全事件和系统通知。
16. 实名更正申诉 `POST /v1/im/security/realname/appeals` 已具备,申诉记录进入 pending 状态。
### 1.2 后续增强项 ### 1.2 后续增强项
@@ -78,7 +75,7 @@
| P0 | 真实短信供应商发送通道 | 当前为测试码联调模式;生产必须接短信服务商、模板、签名、失败重试和发送审计。 | 真实手机号收到验证码;供应商失败返回稳定 JSON,告警可观测。 | | P0 | 真实短信供应商发送通道 | 当前为测试码联调模式;生产必须接短信服务商、模板、签名、失败重试和发送审计。 | 真实手机号收到验证码;供应商失败返回稳定 JSON,告警可观测。 |
| P0 | 验证码多维限频 | 需要按租户、手机号、IP、设备、场景组合限频,并接 Redis 或落库。 | 高频请求返回 `VERIFICATION_RATE_LIMITED`,不同场景互不误伤。 | | P0 | 验证码多维限频 | 需要按租户、手机号、IP、设备、场景组合限频,并接 Redis 或落库。 | 高频请求返回 `VERIFICATION_RATE_LIMITED`,不同场景互不误伤。 |
| P0 | 登出与会话撤销 | 对接文档已有 `POST /v1/im/auth/logout` 和 `DELETE /v1/im/security/sessions/{id}`,代码未见路由。 | 当前设备退出不影响其他设备;删除指定 session 后该设备 token 立即失效。 | | 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 | 安全事件通知覆盖扩展 | 风险确认已接入通知;密码重置、实名审核、独立密码变更、新设备登录仍需补齐。 | `/v1/im/system-notifications` 可看到对应事件且重试不重复入箱。 |
| P1 | API Key 轮换策略 | 当前 API Key 校验兼容现有逻辑,尚未实现最低可接受序号或撤销表。 | 旧序号 Key 可按配置禁用,新 Key 不影响正常客户端。 | | P1 | API Key 轮换策略 | 当前 API Key 校验兼容现有逻辑,尚未实现最低可接受序号或撤销表。 | 旧序号 Key 可按配置禁用,新 Key 不影响正常客户端。 |
| P2 | 风控与反诈策略接入登录注册 | 安全动作后续应接入风险评分、反诈弹窗和二次确认策略。 | 高风险注册/登录/改密触发二次验证或反诈提示。 | | P2 | 风控与反诈策略接入登录注册 | 安全动作后续应接入风险评分、反诈弹窗和二次确认策略。 | 高风险注册/登录/改密触发二次验证或反诈提示。 |
@@ -103,12 +100,15 @@
| 优先级 | 增强项 | 说明 | 建议验收点 | | 优先级 | 增强项 | 说明 | 建议验收点 |
| --- | --- | --- | --- | | --- | --- | --- | --- |
| P0 | 消息变更查询占位策略 | 已撤回消息在普通拉取中应返回撤回占位,而不是仅靠客户端本地事件。 | 新设备登录拉历史时能看到撤回占位和版本。 | | P0 | 消息变更查询占位策略 | 已撤回消息在普通拉取中应返回撤回占位,而不是仅靠客户端本地事件。 | 新设备登录拉历史时能看到撤回占位和版本。 |
| P0 | 万人大群离线提醒与重连补偿 | 离线只走极光 Push 提醒,客户端重连后按 Topic seq 拉取补偿。 | 离线用户收到极光提醒;重连后按 seq 补齐且不丢不重。 |
| P0 | 系统通知未读聚合实时化 | `bootstrap.entry_badges.system_unread_count` 已有,后续需确认真源与增量事件。 | 读通知后 bootstrap 和 WS badge 均一致。 | | P0 | 系统通知未读聚合实时化 | `bootstrap.entry_badges.system_unread_count` 已有,后续需确认真源与增量事件。 | 读通知后 bootstrap 和 WS badge 均一致。 |
| P1 | 列表 `has_more` 统一 | 部分接口仍只返回 `items,next_cursor`,对接文档要求可统一补 `has_more`。 | 前端所有列表分页逻辑可复用同一协议。 | | P1 | 列表 `has_more` 统一 | 部分接口仍只返回 `items,next_cursor`,对接文档要求可统一补 `has_more`。 | 前端所有列表分页逻辑可复用同一协议。 |
| P1 | 群回执隐私和角色策略 | 当前返回基础 read/recv 列表,后续需按租户策略、群规模、角色裁剪。 | 普通成员/管理员在不同策略下看到不同粒度回执。 | | P1 | 群回执隐私和角色策略 | 当前返回基础 read/recv 列表,后续需按租户策略、群规模、角色裁剪。 | 普通成员/管理员在不同策略下看到不同粒度回执。 |
| P1 | `message_policy` 租户配置化 | 当前 recall/edit 窗口和菜单能力是固定值,后续需要来自租户配置。 | 不同租户可配置不同撤回/编辑时限和菜单能力。 | | P1 | `message_policy` 租户配置化 | 当前 recall/edit 窗口和菜单能力是固定值,后续需要来自租户配置。 | 不同租户可配置不同撤回/编辑时限和菜单能力。 |
| P1 | 系统通知 action 白名单 | `action.type` 和参数需要统一注册、校验和版本化。 | 非白名单 action 写入失败;客户端只收到可识别跳转。 | | P1 | 系统通知 action 白名单 | `action.type` 和参数需要统一注册、校验和版本化。 | 非白名单 action 写入失败;客户端只收到可识别跳转。 |
| P1 | 会话搜索排序与类型过滤 | 当前按订阅数据过滤,后续需支持会话类型、置顶、免打扰、最近活跃排序。 | 搜索结果与消息首页排序、筛选规则一致。 | | P1 | 会话搜索排序与类型过滤 | 当前按订阅数据过滤,后续需支持会话类型、置顶、免打扰、最近活跃排序。 | 搜索结果与消息首页排序、筛选规则一致。 |
| P1 | 群成员分页快照深度优化 | 当前已接入 Redis 短 TTL 响应快照,后续大群需要 sorted set、稳定游标和成员变更主动失效。 | 万人大群成员翻页不重复不漏,角色/昵称/退群变更后缓存能及时失效。 |
| P1 | 未读数 Redis 深度优化 | 当前已接入用户总未读 Redis 热缓存,后续需要 Topic 维度未读计数、bootstrap 聚合缓存和主动失效事件。 | 首页红点、会话未读和多端 read/recv 后的未读数实时一致。 |
| P2 | 离线重连恢复编排测试 | 需要端到端覆盖 `hi -> token login -> sub me -> 恢复 Topic -> get data`。 | 断网重连后未丢消息、未重复消息、未错乱回执。 | | P2 | 离线重连恢复编排测试 | 需要端到端覆盖 `hi -> token login -> sub me -> 恢复 Topic -> get data`。 | 断网重连后未丢消息、未重复消息、未错乱回执。 |
## 3. 第三章:单聊关系、权限与会话上下文 ## 3. 第三章:单聊关系、权限与会话上下文
@@ -121,15 +121,16 @@
4. 拉黑/解除拉黑后关系上下文即时反映 `blocked_by_me`、`blocked_by_peer` 等状态。 4. 拉黑/解除拉黑后关系上下文即时反映 `blocked_by_me`、`blocked_by_peer` 等状态。
5. 好友申请、通过/拒绝、删除、拉黑/解除和备注已生成稳定关系事件,相关对端通知可去重。 5. 好友申请、通过/拒绝、删除、拉黑/解除和备注已生成稳定关系事件,相关对端通知可去重。
6. 非好友资料查询已执行 `profile_visibility`。 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 后续增强项 ### 3.2 后续增强项
| 优先级 | 增强项 | 说明 | 建议验收点 | | 优先级 | 增强项 | 说明 | 建议验收点 |
| --- | --- | --- | --- | | --- | --- | --- | --- |
| P0 | P2P 策略统一入口 | 当前核心路径已校验好友/黑名单,后续需要把发消息、通话、名片、文件、历史查看统一收敛到同一策略服务。 | 任一 P2P 业务入口对同一关系状态返回一致权限。 | | P0 | P2P 策略统一入口 | 当前核心路径已校验好友/黑名单,后续需要把发消息、通话、名片、文件、历史查看统一收敛到同一策略服务。 | 任一 P2P 业务入口对同一关系状态返回一致权限。 |
| P1 | 主动建立 P2P 会话接口 | 对接文档有 `POST /v1/im/conversations/p2p`,当前未见路由。 | 好友关系成立时可显式获取/创建 P2P topic。 | | P1 | 会话策略与草稿联调回归 | P2P 会话、会话设置和云草稿接口已具备,仍需覆盖多端、冲突、非好友/黑名单状态下的联调回归。 | 多端草稿同步、版本冲突和权限拒绝都符合协议。 |
| P1 | 会话设置接口 | 对接文档有 `GET/PATCH /v1/im/conversations/{topic}/settings`,当前未见路由。 | 置顶、免打扰、备注、保留策略跨端同步。 |
| P1 | 多端草稿 | 对接文档有 `PUT /v1/im/topics/{topic}/draft`,当前仍建议本地加密保存。 | 同账号多端草稿同步,版本冲突可处理。 |
| P1 | Pending 会话体验增强 | 待处理好友申请对应单聊页应携带申请摘要、来源、过期时间和快速处理入口。 | 进入 pending 会话可直接通过/拒绝。 | | P1 | Pending 会话体验增强 | 待处理好友申请对应单聊页应携带申请摘要、来源、过期时间和快速处理入口。 | 进入 pending 会话可直接通过/拒绝。 |
| P1 | 黑名单历史消息策略 | 需明确拉黑后历史消息、资料、文件和已读回执可见范围。 | 被拉黑人在不同策略下看到一致的历史与资料裁剪结果。 | | P1 | 黑名单历史消息策略 | 需明确拉黑后历史消息、资料、文件和已读回执可见范围。 | 被拉黑人在不同策略下看到一致的历史与资料裁剪结果。 |
| P1 | 好友关系审计 | 删除、拉黑、解除拉黑、申请处理等敏感关系操作写审计日志。 | 后台可按租户、操作者、目标用户查询操作流水。 | | P1 | 好友关系审计 | 删除、拉黑、解除拉黑、申请处理等敏感关系操作写审计日志。 | 后台可按租户、操作者、目标用户查询操作流水。 |
@@ -199,14 +200,15 @@
8. 禁言候选、批量禁言、禁言列表和解除禁言接口已具备。 8. 禁言候选、批量禁言、禁言列表和解除禁言接口已具备。
9. 群写操作已使用 `im_group_idempotency` 和 `im_group_events_outbox`,提交后通过 Hub 派发,失败保留 pending 重试。 9. 群写操作已使用 `im_group_idempotency` 和 `im_group_events_outbox`,提交后通过 Hub 派发,失败保留 pending 重试。
10. WebSocket 群消息发布已校验成员禁言和全员禁言,支持过期恢复及管理员豁免。 10. WebSocket 群消息发布已校验成员禁言和全员禁言,支持过期恢复及管理员豁免。
11. 群文件和群公告未实现路径已不再误返回群详情。 11. 群文件列表、登记和删除接口 `GET/POST/DELETE /v1/im/groups/{topic}/files` 已具备。
12. 群邀请外部接受 `POST /v1/im/group-invitations/{id}/accept` 已具备。
### 6.2 后续增强项 ### 6.2 后续增强项
| 优先级 | 增强项 | 说明 | 建议验收点 | | 优先级 | 增强项 | 说明 | 建议验收点 |
| --- | --- | --- | --- | | --- | --- | --- | --- |
| P0 | 群文件索引与管理 | 对接文档要求 `GET/POST/DELETE /v1/im/groups/{topic}/files`,当前未实现,只修复了错误兜底。 | 群文件列表可搜索/分页,上传登记和删除权限正确,退群后不可下载旧 URL。 | | P0 | 群文件权限与下载闭环 | 群文件基础接口已具备,后续需与文件 ACL、退群后下载阻断、审核状态、OSS 短链失效联动。 | 群文件列表可搜索/分页,上传登记和删除权限正确,退群后不可下载旧 URL。 |
| P1 | 群邀请外部接受接口 | 对接文档有 `POST /v1/im/group-invitations/{id}/accept`,当前未见路由。 | 被邀请人可通过邀请卡入群或进入审批。 | | P1 | 群邀请外部接受体验回归 | 外部接受接口已具备,仍需覆盖邀请卡、多端重复接受、过期和审批模式切换。 | 被邀请人可通过邀请卡入群或进入审批;重复接受幂等返回稳定结果。 |
| P1 | 进群申请隐私与限频 | 申请入口需要按群治理、租户策略、用户状态、来源、IP/设备限频。 | 高频申请被限制;普通成员不可看到敏感申请人信息。 | | P1 | 进群申请隐私与限频 | 申请入口需要按群治理、租户策略、用户状态、来源、IP/设备限频。 | 高频申请被限制;普通成员不可看到敏感申请人信息。 |
| P1 | 管理员/群主角色层级策略 | 管理员互相操作、管理员禁言、群主转让二次认证等需要完整策略矩阵。 | 群主、管理员、普通成员在每个治理动作上的权限稳定一致。 | | P1 | 管理员/群主角色层级策略 | 管理员互相操作、管理员禁言、群主转让二次认证等需要完整策略矩阵。 | 群主、管理员、普通成员在每个治理动作上的权限稳定一致。 |
| P1 | 群解散与二维码吊销 | 群解散、二维码撤销、邀请失效需要状态联动和权限回收。 | 解散后 Topic、二维码、邀请、文件新增权限全部失效。 | | P1 | 群解散与二维码吊销 | 群解散、二维码撤销、邀请失效需要状态联动和权限回收。 | 解散后 Topic、二维码、邀请、文件新增权限全部失效。 |
@@ -275,6 +277,8 @@
8. 日报/月报草稿、提交、详情、列表、统计与提醒基线已具备。 8. 日报/月报草稿、提交、详情、列表、统计与提醒基线已具备。
9. 总经理信箱配置与提交接口已具备,包含匿名策略、文案版本和幂等键。 9. 总经理信箱配置与提交接口已具备,包含匿名策略、文案版本和幂等键。
10. 会议创建、变更、取消、提醒和参会回应已生成在线事件与系统通知,关键变更在事务内写 Outbox。 10. 会议创建、变更、取消、提醒和参会回应已生成在线事件与系统通知,关键变更在事务内写 Outbox。
11. 组织全局成员接口 `GET /v1/im/organization/members` 已具备。
12. 月报 reopen `POST /v1/im/reports/monthly/{id}/reopen` 已具备。
### 9.2 后续增强项 ### 9.2 后续增强项
@@ -284,9 +288,9 @@
| P0 | 工作台未读角标实时事件 | `workbench_total_unread` 已有,后续需要模块未读聚合和 `workbench.badge.updated` 增量事件。 | 会议、日报、信箱未读变化后工作台角标实时更新。 | | P0 | 工作台未读角标实时事件 | `workbench_total_unread` 已有,后续需要模块未读聚合和 `workbench.badge.updated` 增量事件。 | 会议、日报、信箱未读变化后工作台角标实时更新。 |
| P0 | 组织数据后台维护链路 | 当前前台读接口和表已具备,后续需要后台导入、增删改部门、调整成员和离职状态。 | 后台修改组织后前台接口版本变化,客户端可刷新。 | | P0 | 组织数据后台维护链路 | 当前前台读接口和表已具备,后续需要后台导入、增删改部门、调整成员和离职状态。 | 后台修改组织后前台接口版本变化,客户端可刷新。 |
| P0 | 组织权限与可见字段裁剪 | 成员手机号、邮箱、职位等字段需要按组织角色、隐私和企业策略裁剪。 | 普通员工和管理员看到的成员字段不同且不可绕过。 | | P0 | 组织权限与可见字段裁剪 | 成员手机号、邮箱、职位等字段需要按组织角色、隐私和企业策略裁剪。 | 普通员工和管理员看到的成员字段不同且不可绕过。 |
| P1 | 组织全局成员接口 | 对接文档提到 `GET /v1/im/organization/members`,当前可由部门成员和组织搜索替代,未见独立路由。 | 前端如需要全局成员列表,可直接分页查询并按部门过滤。 | | P1 | 组织全局成员接口深度联调 | `GET /v1/im/organization/members` 已具备,仍需按部门、关键词、权限和分页做测试环境回归。 | 前端可直接分页查询并按部门过滤,字段裁剪符合权限。 |
| P1 | 会议列表完整联调 | 对接文档标注 `GET /v1/im/meetings?cursor=` 待补列表;代码有 `serveIMMeetings`,仍需用测试数据确认返回结构。 | 会议列表、详情、响应、取消端到端一致。 | | P1 | 会议列表完整联调 | 对接文档标注 `GET /v1/im/meetings?cursor=` 待补列表;代码有 `serveIMMeetings`,仍需用测试数据确认返回结构。 | 会议列表、详情、响应、取消端到端一致。 |
| P1 | 月报 reopen | 对接文档有 `POST /v1/im/reports/monthly/{id}/reopen`,当前未见路由。 | 已提交月报可按权限退回草稿并保留审计。 | | P1 | 月报 reopen 审计与权限扩展 | reopen 接口已具备,后续需要后台权限、原因记录、审计和通知闭环。 | 已提交月报可按权限退回草稿并保留审计。 |
| P1 | 日报/月报后台统计口径 | 当前基础统计可用,后续需要按部门、周期、迟交、补交、未读等维度扩展。 | 管理员看板与业务口径一致。 | | P1 | 日报/月报后台统计口径 | 当前基础统计可用,后续需要按部门、周期、迟交、补交、未读等维度扩展。 | 管理员看板与业务口径一致。 |
| P1 | 总经理信箱后台流转 | 当前前台配置/提交已具备,后续需要后台处理、回复、转派、归档和通知。 | 信件全生命周期可追踪,匿名身份受保护。 | | P1 | 总经理信箱后台流转 | 当前前台配置/提交已具备,后续需要后台处理、回复、转派、归档和通知。 | 信件全生命周期可追踪,匿名身份受保护。 |
| P1 | 办公协同审计 | 组织查询、会议变更、报告查看、信箱提交等敏感动作需要审计。 | 后台可按租户、用户、对象查询办公操作流水。 | | P1 | 办公协同审计 | 组织查询、会议变更、报告查看、信箱提交等敏感动作需要审计。 | 后台可按租户、用户、对象查询办公操作流水。 |
@@ -341,7 +345,9 @@
## 12. 统一处理建议 ## 12. 统一处理建议
1. 第一优先级处理生产可用性 P0:真实短信、登出/会话撤销、群文件、普通消息转发/合并转发、文件安全、通话强状态机、工作台权限、风控 token 消费。 1. 第一优先级处理生产可用性 P0:真实短信、登出/会话撤销、普通消息转发/合并转发、文件安全、通话强状态机、工作台权限、风控 token 消费。
2. 第二优先级补齐前端文档缺口:`conversations/p2p`、会话设置、云草稿、群邀请外部接受、实名申诉、组织全局成员、月报 reopen。 2. 第二优先级补齐已落地接口的联调闭环:P2P 会话、会话设置、云草稿、群文件、群邀请外部接受、实名申诉、组织全局成员、月报 reopen。
3. 第三优先级统一协议和治理能力:所有列表补齐 `has_more`、稳定游标、审计流水、字段级隐私矩阵、租户配置化策略。 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` 和接口审核报告,避免已完成项继续出现在待办清单。
Binary file not shown.

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 288 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 311 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 288 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 311 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

@@ -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;
+37
View File
@@ -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;
@@ -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. 纯文本内容安全不属于文件审核链路,群简介、签名、举报/反馈正文、日报/月报正文仍需独立文本审核方案。
@@ -2,46 +2,46 @@
## 1. 测试范围 ## 1. 测试范围
- 测试时间:`2026-08-21 00:39:01 +0800` - 测试时间:`2026-08-24 11:15:16 +0800`
- HTTP:`https://api.118im.com` - HTTP:`https://api.118im.com`
- WebSocket:`wss://api.118im.com/v0/channels` - WebSocket:`wss://api.118im.com/v0/channels`
- 租户:`default` - 租户:`default`
- 用户 A:`usr7-zA3UMrw7c` / `wsa459000` / `15794459000` - 用户 A:`usrO4KoysjOOVE` / `wsa043000` / `15757043000`
- 用户 B:`usrnDXQ3A2oQP0` / `wsb459000` / `15894459000` - 用户 B:`usricjD1vg9CXU` / `wsb043000` / `15857043000`
- API Key 与 Token 已脱敏,不在文档中展开。 - API Key 与 Token 已脱敏,不在文档中展开。
## 2. 总体结论 ## 2. 总体结论
本轮专项验证 Tinode WebSocket 真实协议链路,共执行 `29` 项,通过 `27` 项,未通过 `2` 项。 本轮专项验证 Tinode WebSocket 真实协议链路,共执行 `29` 项,通过 `28` 项,未通过 `1` 项。
| ID | 场景 | 方法 | 目标 | 结果 | 备注 | | ID | 场景 | 方法 | 目标 | 结果 | 备注 |
| --- | --- | --- | --- | --- | --- | | --- | --- | --- | --- | --- | --- |
| `WS-01` | 注册用户 A: HTTP 验证码 + WS acc | `WS` | `/v0/channels` | 通过 | user=usr7-zA3UMrw7c token=AwEA...18Om | | `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=usrnDXQ3A2oQP0 token=AwEA...NX6b | | `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_KAOkEfarCOA topic=p2p7-zA3UMrw7ecNdDcDahA_Q | | `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=usr7-zA3UMrw7c | | `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=usrnDXQ3A2oQP0 | | `WS-05` | B token login + sub me | `WS` | `/v0/channels` | 通过 | login user=usricjD1vg9CXU |
| `WS-06` | A/B 订阅 P2P Topic | `WS` | `p2p7-zA3UMrw7ecNdDcDahA_Q` | 通过 | topic=p2p7-zA3UMrw7ecNdDcDahA_Q | | `WS-06` | A/B 订阅 P2P Topic | `WS` | `p2pO4KoysjOOVGJyMPW-D0JdQ` | 通过 | topic=p2pO4KoysjOOVGJyMPW-D0JdQ |
| `WS-07` | A pub 文本消息,B 收到 data | `WS` | `p2p7-zA3UMrw7ecNdDcDahA_Q` | 通过 | seq=1 bob_topic=usr7-zA3UMrw7c | | `WS-07` | A pub 文本消息,B 收到 data | `WS` | `p2pO4KoysjOOVGJyMPW-D0JdQ` | 通过 | seq=1 bob_topic=usrO4KoysjOOVE |
| `WS-08` | B 发送 recv/read 回执 | `WS` | `p2p7-zA3UMrw7ecNdDcDahA_Q` | 通过 | what=read seq=1 | | `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-09` | basic login + sub me | `WS` | `/v0/channels` | 通过 | basic login ok |
| `WS-10` | 重复 login 被拒绝 | `WS` | `/v0/channels` | 通过 | code=409 text=already authenticated | | `WS-10` | 重复 login 被拒绝 | `WS` | `/v0/channels` | 通过 | code=409 text=already authenticated |
| `WS-11` | 订阅 fnd 搜索 Topic | `WS` | `fnd` | 通过 | sub fnd ok | | `WS-11` | 订阅 fnd 搜索 Topic | `WS` | `fnd` | 通过 | sub fnd ok |
| `WS-12` | get me desc/sub/tags/cred | `WS` | `me` | 通过 | get me 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-13` | get P2P desc/sub/data/del | `WS` | `p2pO4KoysjOOVGJyMPW-D0JdQ` | 通过 | get p2p ok |
| `WS-14` | set P2P private 设置 | `WS` | `p2p7-zA3UMrw7ecNdDcDahA_Q` | 通过 | set private ok | | `WS-14` | set P2P private 设置 | `WS` | `p2pO4KoysjOOVGJyMPW-D0JdQ` | 通过 | set private ok |
| `WS-15` | pub 产品 x-im 基础头 | `WS` | `p2p7-zA3UMrw7ecNdDcDahA_Q` | 通过 | seq=2 bob_topic=usr7-zA3UMrw7c | | `WS-15` | pub 产品 x-im 基础头 | `WS` | `p2pO4KoysjOOVGJyMPW-D0JdQ` | 通过 | seq=2 bob_topic=usrO4KoysjOOVE |
| `WS-16` | pub 产品 x-im-client-mid 幂等头 | `WS` | `p2p7-zA3UMrw7ecNdDcDahA_Q` | 未通过 | pub ctrl: ctrl id=pub-003857.244320000 code=500 text=internal error | | `WS-16` | pub 产品 x-im-client-mid 幂等头 | `WS` | `p2pO4KoysjOOVGJyMPW-D0JdQ` | 通过 | seq=3 bob_topic=usrO4KoysjOOVE |
| `WS-17` | note kp 输入中通知 | `WS` | `usr7-zA3UMrw7c` | 通过 | what=kp | | `WS-17` | note kp 输入中通知 | `WS` | `usrO4KoysjOOVE` | 通过 | what=kp |
| `WS-18` | del 当前用户消息视图 | `WS` | `p2p7-zA3UMrw7ecNdDcDahA_Q` | 通过 | deleted seq=1 | | `WS-18` | del 当前用户消息视图 | `WS` | `p2pO4KoysjOOVGJyMPW-D0JdQ` | 通过 | deleted seq=1 |
| `WS-19` | leave P2P detach 后重新订阅 | `WS` | `p2p7-zA3UMrw7ecNdDcDahA_Q` | 通过 | leave/resub ok | | `WS-19` | leave P2P detach 后重新订阅 | `WS` | `p2pO4KoysjOOVGJyMPW-D0JdQ` | 通过 | leave/resub ok |
| `WS-20` | HTTP 创建群供 WS 群协议测试 | `HTTP` | `/v1/im/groups` | 通过 | topic=grpURzKYanXB1Y | | `WS-20` | HTTP 创建群供 WS 群协议测试 | `HTTP` | `/v1/im/groups` | 通过 | topic=grpNJqhCtAZwYM |
| `WS-21` | A/B 订阅群 Topic | `WS` | `grpURzKYanXB1Y` | 通过 | sub group ok | | `WS-21` | A/B 订阅群 Topic | `WS` | `grpNJqhCtAZwYM` | 通过 | sub group ok |
| `WS-22` | 群 pub 文本广播 | `WS` | `grpURzKYanXB1Y` | 通过 | seq=1 topic=grpURzKYanXB1Y | | `WS-22` | 群 pub 文本广播 | `WS` | `grpNJqhCtAZwYM` | 通过 | seq=1 topic=grpNJqhCtAZwYM |
| `WS-23` | get 群 desc/sub/data | `WS` | `grpURzKYanXB1Y` | 通过 | get group ok | | `WS-23` | get 群 desc/sub/data | `WS` | `grpNJqhCtAZwYM` | 通过 | get group ok |
| `WS-24` | 文件上传 /v0/file/u | `HTTP` | `/v0/file/u/` | 通过 | url=/v0/....bin | | `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-25` | WS 文件消息下发 | `WS` | `p2pO4KoysjOOVGJyMPW-D0JdQ` | 通过 | seq=4 bob_topic=usrO4KoysjOOVE |
| `WS-26` | WebRTC 通话 invite pub | `WS` | `p2p7-zA3UMrw7ecNdDcDahA_Q` | 未通过 | pub ctrl: ctrl id=pub-003859.867810000 code=501 text=not implemented | | `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-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-28` | hi 后切换 tenant 被拒绝 | `WS` | `/v0/channels` | 通过 | code=409 text=command out of sequence |
| `WS-29` | 非法 API Key 拒绝建连 | `WS` | `/v0/channels` | 通过 | http_status=403 | | `WS-29` | 非法 API Key 拒绝建连 | `WS` | `/v0/channels` | 通过 | http_status=403 |
@@ -53,6 +53,6 @@
## 4. 测试产生的数据 ## 4. 测试产生的数据
- P2P Topic:`p2p7-zA3UMrw7ecNdDcDahA_Q` - P2P Topic:`p2pO4KoysjOOVGJyMPW-D0JdQ`
- Bob 收到的 Topic:`usr7-zA3UMrw7c` - Bob 收到的 Topic:`usrO4KoysjOOVE`
- 消息 Seq:`1` - 消息 Seq:`1`
+2
View File
@@ -24,6 +24,7 @@ require (
github.com/nyaruka/phonenumbers v1.8.0 github.com/nyaruka/phonenumbers v1.8.0
github.com/prometheus/client_golang v1.23.2 github.com/prometheus/client_golang v1.23.2
github.com/prometheus/common v0.69.0 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/rivo/uniseg v0.4.7
github.com/tinode/jsonco v1.0.0 github.com/tinode/jsonco v1.0.0
github.com/tinode/snowflake 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/aws/aws-sdk-go-v2/service/sts v1.43.5 // indirect
github.com/clbanning/mxj/v2 v2.7.0 // indirect github.com/clbanning/mxj/v2 v2.7.0 // indirect
github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2 // 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/go-control-plane/envoy v1.37.0 // indirect
github.com/envoyproxy/protoc-gen-validate v1.3.3 // indirect github.com/envoyproxy/protoc-gen-validate v1.3.3 // indirect
github.com/go-jose/go-jose/v4 v4.1.4 // indirect github.com/go-jose/go-jose/v4 v4.1.4 // indirect
+16
View File
@@ -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/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 h1:RoO5+d7uCmDqovLrHCr2/BuViUXvdcrNxyNM1pN9dDQ=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.57.0/go.mod h1:YqwkQPrWSC7+byyc1VlKbWLBF5JsW5IoL6xUkemYSXk= 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-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.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 h1:zE8vH9C7JiZLNJJQ5OwjU9mSi4T9ef9u3BURT6LCLC8=
github.com/alibabacloud-go/alibabacloud-gateway-spi v0.0.5/go.mod h1:tWnyE9AjF8J8qqLk645oUmVUnFybApTQWklQmi5tY6g= 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-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-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-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 h1:o6veen0IZ/Fe1JawwhwQMZcbw67CVDY1pQwXcNWPyQo=
github.com/alibabacloud-go/darabonba-openapi/v2 v2.2.4/go.mod h1:eHjVxrT9g8uVYN/nyAwOFQEfkVA154ChiqRc2XnNKYU= 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-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/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 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.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 h1:MsW9SmUtbb1Fnt3ieC6NNZi6aEwrXfDksD4QA6GSbPg=
github.com/alibabacloud-go/debug v1.0.1/go.mod h1:8gfgZCCAC3+SCzjWtY053FrOcd4/qlH6IHTI4QyICOc= 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/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 h1:ZnlHri7tTLl28JWB5S2J6MYvD0ewJFI7hedK4E7+gSk=
github.com/alibabacloud-go/green-20220302/v3 v3.5.4/go.mod h1:PQTtoEuWOsuq7H7sgTRcXDw4Ut/Hekgy5z2mWGtO9OQ= 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/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.0/go.mod h1:IkGyUSX4Ba1V+k4pCtJUc6jDpZLFph9QMy2VUPTwukg=
github.com/alibabacloud-go/tea v1.1.7/go.mod h1:/tmnEaQMyb4Ky1/5D+SE1BAsa5zj/KeGOFfwYm3N/p4= 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/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 h1:DDGfHa7BWjL4YnC6+E63dPcxHo2sUxDIu8g3QgEJdRY=
github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869/go.mod h1:Ekp36dRnpXw/yCqJaO+ZrUyxD+3VXMFFr56k5XYrpB4= 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/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 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= 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.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 h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 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.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.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
github.com/envoyproxy/go-control-plane v0.14.0 h1:hbG2kr4RuFj222B6+7T83thSPqLjwBIfQawTkC++2HA= 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/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 h1:GljZCt+zSTS+NZq88cyQ1LjZ+RCHp3uVuabBWA5+OJI=
github.com/prometheus/procfs v0.21.1/go.mod h1:aB55Cww9pdSJVHk0hUf0inxWyyjPogFIjmHKYgMKmtY= 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 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
+32 -32
View File
@@ -810,8 +810,8 @@ type SeqRange struct {
sizeCache protoimpl.SizeCache sizeCache protoimpl.SizeCache
unknownFields protoimpl.UnknownFields unknownFields protoimpl.UnknownFields
Low int32 `protobuf:"varint,1,opt,name=low,proto3" json:"low,omitempty"` Low int64 `protobuf:"varint,1,opt,name=low,proto3" json:"low,omitempty"`
Hi int32 `protobuf:"varint,2,opt,name=hi,proto3" json:"hi,omitempty"` Hi int64 `protobuf:"varint,2,opt,name=hi,proto3" json:"hi,omitempty"`
} }
func (x *SeqRange) Reset() { func (x *SeqRange) Reset() {
@@ -846,14 +846,14 @@ func (*SeqRange) Descriptor() ([]byte, []int) {
return file_model_proto_rawDescGZIP(), []int{6} return file_model_proto_rawDescGZIP(), []int{6}
} }
func (x *SeqRange) GetLow() int32 { func (x *SeqRange) GetLow() int64 {
if x != nil { if x != nil {
return x.Low return x.Low
} }
return 0 return 0
} }
func (x *SeqRange) GetHi() int32 { func (x *SeqRange) GetHi() int64 {
if x != nil { if x != nil {
return x.Hi return x.Hi
} }
@@ -872,9 +872,9 @@ type GetOpts struct {
// Limit search results to one topic; // Limit search results to one topic;
Topic string `protobuf:"bytes,3,opt,name=topic,proto3" json:"topic,omitempty"` Topic string `protobuf:"bytes,3,opt,name=topic,proto3" json:"topic,omitempty"`
// Load messages with seq id equal or greater than this // 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 // 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 // Maximum number of results to return
Limit int32 `protobuf:"varint,6,opt,name=limit,proto3" json:"limit,omitempty"` Limit int32 `protobuf:"varint,6,opt,name=limit,proto3" json:"limit,omitempty"`
// Load messages by id or ranges of ids // Load messages by id or ranges of ids
@@ -934,14 +934,14 @@ func (x *GetOpts) GetTopic() string {
return "" return ""
} }
func (x *GetOpts) GetSinceId() int32 { func (x *GetOpts) GetSinceId() int64 {
if x != nil { if x != nil {
return x.SinceId return x.SinceId
} }
return 0 return 0
} }
func (x *GetOpts) GetBeforeId() int32 { func (x *GetOpts) GetBeforeId() int64 {
if x != nil { if x != nil {
return x.BeforeId return x.BeforeId
} }
@@ -1911,7 +1911,7 @@ type ClientNote struct {
// "kp" - typing notification, "call" - voice/video call // "kp" - typing notification, "call" - voice/video call
What InfoNote `protobuf:"varint,2,opt,name=what,proto3,enum=pbx.InfoNote" json:"what,omitempty"` What InfoNote `protobuf:"varint,2,opt,name=what,proto3,enum=pbx.InfoNote" json:"what,omitempty"`
// Server-issued message ID being reported // 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. // 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"` Unread int32 `protobuf:"varint,4,opt,name=unread,proto3" json:"unread,omitempty"`
// Call event. // Call event.
@@ -1966,7 +1966,7 @@ func (x *ClientNote) GetWhat() InfoNote {
return InfoNote_X1 return InfoNote_X1
} }
func (x *ClientNote) GetSeqId() int32 { func (x *ClientNote) GetSeqId() int64 {
if x != nil { if x != nil {
return x.SeqId 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"` 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"` Defacs *DefaultAcsMode `protobuf:"bytes,4,opt,name=defacs,proto3" json:"defacs,omitempty"`
Acs *AccessMode `protobuf:"bytes,5,opt,name=acs,proto3" json:"acs,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"` SeqId int64 `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"` ReadId int64 `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"` 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"` 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"` Public []byte `protobuf:"bytes,10,opt,name=public,proto3" json:"public,omitempty"`
Private []byte `protobuf:"bytes,11,opt,name=private,proto3" json:"private,omitempty"` Private []byte `protobuf:"bytes,11,opt,name=private,proto3" json:"private,omitempty"`
@@ -2421,21 +2421,21 @@ func (x *TopicDesc) GetAcs() *AccessMode {
return nil return nil
} }
func (x *TopicDesc) GetSeqId() int32 { func (x *TopicDesc) GetSeqId() int64 {
if x != nil { if x != nil {
return x.SeqId return x.SeqId
} }
return 0 return 0
} }
func (x *TopicDesc) GetReadId() int32 { func (x *TopicDesc) GetReadId() int64 {
if x != nil { if x != nil {
return x.ReadId return x.ReadId
} }
return 0 return 0
} }
func (x *TopicDesc) GetRecvId() int32 { func (x *TopicDesc) GetRecvId() int64 {
if x != nil { if x != nil {
return x.RecvId 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"` 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"` Online bool `protobuf:"varint,3,opt,name=online,proto3" json:"online,omitempty"`
Acs *AccessMode `protobuf:"bytes,4,opt,name=acs,proto3" json:"acs,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"` ReadId int64 `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"` 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"` 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! 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"` 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"` 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"` 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 // 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 // Messages are deleted up to this ID
DelId int32 `protobuf:"varint,13,opt,name=del_id,json=delId,proto3" json:"del_id,omitempty"` DelId int32 `protobuf:"varint,13,opt,name=del_id,json=delId,proto3" json:"del_id,omitempty"`
// Other user's last online timestamp & user agent // Other user's last online timestamp & user agent
@@ -2601,14 +2601,14 @@ func (x *TopicSub) GetAcs() *AccessMode {
return nil return nil
} }
func (x *TopicSub) GetReadId() int32 { func (x *TopicSub) GetReadId() int64 {
if x != nil { if x != nil {
return x.ReadId return x.ReadId
} }
return 0 return 0
} }
func (x *TopicSub) GetRecvId() int32 { func (x *TopicSub) GetRecvId() int64 {
if x != nil { if x != nil {
return x.RecvId return x.RecvId
} }
@@ -2657,7 +2657,7 @@ func (x *TopicSub) GetTouchedAt() int64 {
return 0 return 0
} }
func (x *TopicSub) GetSeqId() int32 { func (x *TopicSub) GetSeqId() int64 {
if x != nil { if x != nil {
return x.SeqId return x.SeqId
} }
@@ -2833,7 +2833,7 @@ type ServerData struct {
Timestamp int64 `protobuf:"varint,7,opt,name=timestamp,proto3" json:"timestamp,omitempty"` 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 // 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"` 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"` 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"` Content []byte `protobuf:"bytes,6,opt,name=content,proto3" json:"content,omitempty"`
} }
@@ -2898,7 +2898,7 @@ func (x *ServerData) GetDeletedAt() int64 {
return 0 return 0
} }
func (x *ServerData) GetSeqId() int32 { func (x *ServerData) GetSeqId() int64 {
if x != nil { if x != nil {
return x.SeqId return x.SeqId
} }
@@ -2929,7 +2929,7 @@ type ServerPres struct {
Src string `protobuf:"bytes,2,opt,name=src,proto3" json:"src,omitempty"` 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"` 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"` 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"` 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"` 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"` 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 "" return ""
} }
func (x *ServerPres) GetSeqId() int32 { func (x *ServerPres) GetSeqId() int64 {
if x != nil { if x != nil {
return x.SeqId return x.SeqId
} }
@@ -3152,7 +3152,7 @@ type ServerInfo struct {
Topic string `protobuf:"bytes,1,opt,name=topic,proto3" json:"topic,omitempty"` 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"` 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"` 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"` 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"` 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"` Payload []byte `protobuf:"bytes,7,opt,name=payload,proto3" json:"payload,omitempty"`
@@ -3211,7 +3211,7 @@ func (x *ServerInfo) GetWhat() InfoNote {
return InfoNote_X1 return InfoNote_X1
} }
func (x *ServerInfo) GetSeqId() int32 { func (x *ServerInfo) GetSeqId() int64 {
if x != nil { if x != nil {
return x.SeqId return x.SeqId
} }
@@ -3862,8 +3862,8 @@ type SubscriptionEvent struct {
Topic string `protobuf:"bytes,2,opt,name=topic,proto3" json:"topic,omitempty"` 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"` 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"` 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"` ReadId int64 `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"` 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"` Mode *AccessMode `protobuf:"bytes,7,opt,name=mode,proto3" json:"mode,omitempty"`
Private []byte `protobuf:"bytes,8,opt,name=private,proto3" json:"private,omitempty"` Private []byte `protobuf:"bytes,8,opt,name=private,proto3" json:"private,omitempty"`
} }
@@ -3928,14 +3928,14 @@ func (x *SubscriptionEvent) GetDelId() int32 {
return 0 return 0
} }
func (x *SubscriptionEvent) GetReadId() int32 { func (x *SubscriptionEvent) GetReadId() int64 {
if x != nil { if x != nil {
return x.ReadId return x.ReadId
} }
return 0 return 0
} }
func (x *SubscriptionEvent) GetRecvId() int32 { func (x *SubscriptionEvent) GetRecvId() int64 {
if x != nil { if x != nil {
return x.RecvId return x.RecvId
} }
+16 -16
View File
@@ -100,8 +100,8 @@ message SetDesc {
} }
message SeqRange { message SeqRange {
int32 low = 1; int64 low = 1;
int32 hi = 2; int64 hi = 2;
} }
message GetOpts { message GetOpts {
@@ -112,9 +112,9 @@ message GetOpts {
// Limit search results to one topic; // Limit search results to one topic;
string topic = 3; string topic = 3;
// Load messages with seq id equal or greater than this // 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 // Load messages with seq id lower than this
int32 before_id = 5; int64 before_id = 5;
// Maximum number of results to return // Maximum number of results to return
int32 limit = 6; int32 limit = 6;
// Load messages by id or ranges of ids // Load messages by id or ranges of ids
@@ -292,7 +292,7 @@ message ClientNote {
// "kp" - typing notification, "call" - voice/video call // "kp" - typing notification, "call" - voice/video call
InfoNote what = 2; InfoNote what = 2;
// Server-issued message ID being reported // 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. // Client's count of unread messages to report back to the server. Used in push notifications on iOS.
int32 unread = 4; int32 unread = 4;
// Call event. // Call event.
@@ -345,9 +345,9 @@ message TopicDesc {
int64 touched_at = 3; int64 touched_at = 3;
DefaultAcsMode defacs = 4; DefaultAcsMode defacs = 4;
AccessMode acs = 5; AccessMode acs = 5;
int32 seq_id = 6; int64 seq_id = 6;
int32 read_id = 7; int64 read_id = 7;
int32 recv_id = 8; int64 recv_id = 8;
int32 del_id = 9; int32 del_id = 9;
bytes public = 10; bytes public = 10;
bytes private = 11; bytes private = 11;
@@ -370,8 +370,8 @@ message TopicSub {
bool online = 3; bool online = 3;
AccessMode acs = 4; AccessMode acs = 4;
int32 read_id = 5; int64 read_id = 5;
int32 recv_id = 6; int64 recv_id = 6;
bytes public = 7; bytes public = 7;
bytes trusted = 16; // 16! bytes trusted = 16; // 16!
bytes private = 8; bytes private = 8;
@@ -387,7 +387,7 @@ message TopicSub {
string topic = 10; string topic = 10;
int64 touched_at = 11; int64 touched_at = 11;
// ID of the last {data} message in a topic // ID of the last {data} message in a topic
int32 seq_id = 12; int64 seq_id = 12;
// Messages are deleted up to this ID // Messages are deleted up to this ID
int32 del_id = 13; int32 del_id = 13;
@@ -421,7 +421,7 @@ message ServerData {
int64 timestamp = 7; int64 timestamp = 7;
// Timestamp when the message was deleted or 0. Milliseconds since the epoch 01/01/1970 // Timestamp when the message was deleted or 0. Milliseconds since the epoch 01/01/1970
int64 deleted_at = 3; int64 deleted_at = 3;
int32 seq_id = 4; int64 seq_id = 4;
map<string, bytes> head = 5; map<string, bytes> head = 5;
bytes content = 6; bytes content = 6;
} }
@@ -449,7 +449,7 @@ message ServerPres {
} }
What what = 3; What what = 3;
string user_agent = 4; string user_agent = 4;
int32 seq_id = 5; int64 seq_id = 5;
int32 del_id = 6; int32 del_id = 6;
repeated SeqRange del_seq = 7; repeated SeqRange del_seq = 7;
string target_user_id = 8; string target_user_id = 8;
@@ -475,7 +475,7 @@ message ServerInfo {
string topic = 1; string topic = 1;
string from_user_id = 2; string from_user_id = 2;
InfoNote what = 3; InfoNote what = 3;
int32 seq_id = 4; int64 seq_id = 4;
string src = 5; string src = 5;
CallEvent event = 6; CallEvent event = 6;
bytes payload = 7; bytes payload = 7;
@@ -577,8 +577,8 @@ message SubscriptionEvent {
string user_id = 3; string user_id = 3;
int32 del_id = 4; int32 del_id = 4;
int32 read_id = 5; int64 read_id = 5;
int32 recv_id = 6; int64 recv_id = 6;
AccessMode mode = 7; AccessMode mode = 7;
+2 -2
View File
@@ -86,7 +86,7 @@ type videoCall struct {
// Call participants (session sid -> callPartyData). // Call participants (session sid -> callPartyData).
parties map[string]callPartyData parties map[string]callPartyData
// Call message seq ID. // Call message seq ID.
seq int seq int64
// Call message content. // Call message content.
content any content any
// Call message content mime type. // Call message content mime type.
@@ -300,7 +300,7 @@ func (call *videoCall) messageHead(head map[string]any, newState string, duratio
head = map[string]any{} head = map[string]any{}
} }
head["replace"] = ":" + strconv.Itoa(call.seq) head["replace"] = ":" + strconv.FormatInt(call.seq, 10)
head["webrtc"] = newState head["webrtc"] = newState
if duration > 0 { if duration > 0 {
+23 -23
View File
@@ -27,9 +27,9 @@ type MsgGetOpts struct {
// Return results modified since this timespamp. // Return results modified since this timespamp.
IfModifiedSince *time.Time `json:"ims,omitempty"` IfModifiedSince *time.Time `json:"ims,omitempty"`
// Load messages/ranges with IDs equal or greater than this (inclusive or closed). // Load messages/ranges with IDs equal or greater than this (inclusive or closed).
SinceId int `json:"since,omitempty"` SinceId int64 `json:"since,omitempty"`
// Load messages/ranges with IDs lower than this (exclusive or open). // Load messages/ranges with IDs lower than this (exclusive or open).
BeforeId int `json:"before,omitempty"` BeforeId int64 `json:"before,omitempty"`
// Limit the number of messages loaded. // Limit the number of messages loaded.
Limit int `json:"limit,omitempty"` Limit int `json:"limit,omitempty"`
// Fetch messages with IDs in these ranges. // Fetch messages with IDs in these ranges.
@@ -100,8 +100,8 @@ type MsgSetQuery struct {
// MsgRange is either an individual ID (HiId=0) or a randge of IDs, low end inclusive (closed), // MsgRange is either an individual ID (HiId=0) or a randge of IDs, low end inclusive (closed),
// high-end exclusive (open): [LowId .. HiId), e.g. 1..5 -> 1, 2, 3, 4. // high-end exclusive (open): [LowId .. HiId), e.g. 1..5 -> 1, 2, 3, 4.
type MsgRange struct { type MsgRange struct {
LowId int `json:"low,omitempty"` LowId int64 `json:"low,omitempty"`
HiId int `json:"hi,omitempty"` HiId int64 `json:"hi,omitempty"`
} }
/**************************************************************** /****************************************************************
@@ -321,7 +321,7 @@ type MsgClientNote struct {
// what is being reported: "recv" - message received, "read" - message read, "kp" - typing notification // what is being reported: "recv" - message received, "read" - message read, "kp" - typing notification
What string `json:"what"` What string `json:"what"`
// Server-issued message ID being reported // Server-issued message ID being reported
SeqId int `json:"seq,omitempty"` SeqId int64 `json:"seq,omitempty"`
// Client's count of unread messages to report back to the server. Used in push notifications on iOS. // Client's count of unread messages to report back to the server. Used in push notifications on iOS.
Unread int `json:"unread,omitempty"` Unread int `json:"unread,omitempty"`
// Call event. // Call event.
@@ -453,9 +453,9 @@ type MsgTopicDesc struct {
// Actual access mode // Actual access mode
Acs *MsgAccessMode `json:"acs,omitempty"` Acs *MsgAccessMode `json:"acs,omitempty"`
// Max message ID // Max message ID
SeqId int `json:"seq,omitempty"` SeqId int64 `json:"seq,omitempty"`
ReadSeqId int `json:"read,omitempty"` ReadSeqId int64 `json:"read,omitempty"`
RecvSeqId int `json:"recv,omitempty"` RecvSeqId int64 `json:"recv,omitempty"`
// Id of the last delete operation as seen by the requesting user // Id of the last delete operation as seen by the requesting user
DelId int `json:"clear,omitempty"` DelId int `json:"clear,omitempty"`
SubCnt int `json:"subcnt,omitempty"` SubCnt int `json:"subcnt,omitempty"`
@@ -475,13 +475,13 @@ func (src *MsgTopicDesc) describe() string {
s += " acs={" + src.Acs.describe() + "}" s += " acs={" + src.Acs.describe() + "}"
} }
if src.SeqId != 0 { if src.SeqId != 0 {
s += " seq=" + strconv.Itoa(src.SeqId) s += " seq=" + strconv.FormatInt(src.SeqId, 10)
} }
if src.ReadSeqId != 0 { if src.ReadSeqId != 0 {
s += " read=" + strconv.Itoa(src.ReadSeqId) s += " read=" + strconv.FormatInt(src.ReadSeqId, 10)
} }
if src.RecvSeqId != 0 { if src.RecvSeqId != 0 {
s += " recv=" + strconv.Itoa(src.RecvSeqId) s += " recv=" + strconv.FormatInt(src.RecvSeqId, 10)
} }
if src.DelId != 0 { if src.DelId != 0 {
s += " clear=" + strconv.Itoa(src.DelId) s += " clear=" + strconv.Itoa(src.DelId)
@@ -517,9 +517,9 @@ type MsgTopicSub struct {
// Acs.Mode = want & given. The field is not a pointer because at least one value is always assigned. // Acs.Mode = want & given. The field is not a pointer because at least one value is always assigned.
Acs MsgAccessMode `json:"acs,omitempty"` Acs MsgAccessMode `json:"acs,omitempty"`
// ID of the message reported by the given user as read // ID of the message reported by the given user as read
ReadSeqId int `json:"read,omitempty"` ReadSeqId int64 `json:"read,omitempty"`
// ID of the message reported by the given user as received // ID of the message reported by the given user as received
RecvSeqId int `json:"recv,omitempty"` RecvSeqId int64 `json:"recv,omitempty"`
// Topic's public data // Topic's public data
Public any `json:"public,omitempty"` Public any `json:"public,omitempty"`
// Topic's trusted public data // Topic's trusted public data
@@ -540,7 +540,7 @@ type MsgTopicSub struct {
// Timestamp of the last message in the topic. // Timestamp of the last message in the topic.
TouchedAt *time.Time `json:"touched,omitempty"` TouchedAt *time.Time `json:"touched,omitempty"`
// ID of the last {data} message in a topic // ID of the last {data} message in a topic
SeqId int `json:"seq,omitempty"` SeqId int64 `json:"seq,omitempty"`
// Id of the latest Delete operation // Id of the latest Delete operation
DelId int `json:"clear,omitempty"` DelId int `json:"clear,omitempty"`
// Number of subscribers, group topics only. // Number of subscribers, group topics only.
@@ -555,13 +555,13 @@ func (src *MsgTopicSub) describe() string {
s := src.Topic + ":" + src.User + " online=" + strconv.FormatBool(src.Online) + " acs=" + src.Acs.describe() s := src.Topic + ":" + src.User + " online=" + strconv.FormatBool(src.Online) + " acs=" + src.Acs.describe()
if src.SeqId != 0 { if src.SeqId != 0 {
s += " seq=" + strconv.Itoa(src.SeqId) s += " seq=" + strconv.FormatInt(src.SeqId, 10)
} }
if src.ReadSeqId != 0 { if src.ReadSeqId != 0 {
s += " read=" + strconv.Itoa(src.ReadSeqId) s += " read=" + strconv.FormatInt(src.ReadSeqId, 10)
} }
if src.RecvSeqId != 0 { if src.RecvSeqId != 0 {
s += " recv=" + strconv.Itoa(src.RecvSeqId) s += " recv=" + strconv.FormatInt(src.RecvSeqId, 10)
} }
if src.DelId != 0 { if src.DelId != 0 {
s += " clear=" + strconv.Itoa(src.DelId) s += " clear=" + strconv.Itoa(src.DelId)
@@ -621,7 +621,7 @@ type MsgServerData struct {
From string `json:"from,omitempty"` From string `json:"from,omitempty"`
Timestamp time.Time `json:"ts"` Timestamp time.Time `json:"ts"`
DeletedAt *time.Time `json:"deleted,omitempty"` DeletedAt *time.Time `json:"deleted,omitempty"`
SeqId int `json:"seq"` SeqId int64 `json:"seq"`
Head map[string]any `json:"head,omitempty"` Head map[string]any `json:"head,omitempty"`
Content any `json:"content"` Content any `json:"content"`
} }
@@ -636,7 +636,7 @@ func (src *MsgServerData) copy() *MsgServerData {
} }
func (src *MsgServerData) describe() string { func (src *MsgServerData) describe() string {
s := src.Topic + " from=" + src.From + " seq=" + strconv.Itoa(src.SeqId) s := src.Topic + " from=" + src.From + " seq=" + strconv.FormatInt(src.SeqId, 10)
if src.DeletedAt != nil { if src.DeletedAt != nil {
s += " deleted" s += " deleted"
} else { } else {
@@ -654,7 +654,7 @@ type MsgServerPres struct {
Src string `json:"src,omitempty"` Src string `json:"src,omitempty"`
What string `json:"what"` What string `json:"what"`
UserAgent string `json:"ua,omitempty"` UserAgent string `json:"ua,omitempty"`
SeqId int `json:"seq,omitempty"` SeqId int64 `json:"seq,omitempty"`
DelId int `json:"clear,omitempty"` DelId int `json:"clear,omitempty"`
DelSeq []MsgRange `json:"delseq,omitempty"` DelSeq []MsgRange `json:"delseq,omitempty"`
AcsTarget string `json:"tgt,omitempty"` AcsTarget string `json:"tgt,omitempty"`
@@ -706,7 +706,7 @@ func (src *MsgServerPres) describe() string {
s += " ua=" + src.UserAgent s += " ua=" + src.UserAgent
} }
if src.SeqId != 0 { if src.SeqId != 0 {
s += " seq=" + strconv.Itoa(src.SeqId) s += " seq=" + strconv.FormatInt(src.SeqId, 10)
} }
if src.DelId != 0 { if src.DelId != 0 {
s += " clear=" + strconv.Itoa(src.DelId) s += " clear=" + strconv.Itoa(src.DelId)
@@ -799,7 +799,7 @@ type MsgServerInfo struct {
// The event being reported: "rcpt" - message received, "read" - message read, "kp" - typing notification, "call" - video call. // The event being reported: "rcpt" - message received, "read" - message read, "kp" - typing notification, "call" - video call.
What string `json:"what"` What string `json:"what"`
// Server-issued message ID being reported. // Server-issued message ID being reported.
SeqId int `json:"seq,omitempty"` SeqId int64 `json:"seq,omitempty"`
// Call event. // Call event.
Event string `json:"event,omitempty"` Event string `json:"event,omitempty"`
// Arbitrary json payload (used by video calls). // Arbitrary json payload (used by video calls).
@@ -829,7 +829,7 @@ func (src *MsgServerInfo) describe() string {
} }
s += " what=" + src.What + " from=" + src.From s += " what=" + src.What + " from=" + src.From
if src.SeqId > 0 { if src.SeqId > 0 {
s += " seq=" + strconv.Itoa(src.SeqId) s += " seq=" + strconv.FormatInt(src.SeqId, 10)
} }
if len(src.Payload) > 0 { if len(src.Payload) > 0 {
s += " payload=<..." + strconv.Itoa(len(src.Payload)) + " bytes ...>" s += " payload=<..." + strconv.Itoa(len(src.Payload)) + " bytes ...>"
+12 -5
View File
@@ -326,13 +326,20 @@ type TenantBusinessAdapter interface {
TenantMessageSave(t.TenantID, *t.Message) error TenantMessageSave(t.TenantID, *t.Message) error
TenantMessageGetByClientID(t.TenantID, t.Uid, string) (*t.Message, error) TenantMessageGetByClientID(t.TenantID, t.Uid, string) (*t.Message, error)
TenantMessageGetOne(t.TenantID, string, int) (*t.Message, error) TenantMessageGetOne(t.TenantID, string, int64) (*t.Message, error)
TenantMessageRecall(t.TenantID, string, int, t.Uid, int, time.Time) (*t.Message, error) TenantMessageRecall(t.TenantID, string, int64, t.Uid, int, time.Time) (*t.Message, error)
TenantMessageEdit(t.TenantID, string, int, t.Uid, int, t.KVMap, any, string, time.Time) (*t.Message, error) TenantMessageEdit(t.TenantID, string, int64, t.Uid, int, t.KVMap, any, string, time.Time) (*t.Message, error)
TenantMessageGetAll(t.TenantID, string, t.Uid, *t.QueryOpt) ([]t.Message, error) TenantMessageGetAll(t.TenantID, string, t.Uid, *t.QueryOpt) ([]t.Message, error)
TenantMessageDeleteList(t.TenantID, string, *t.DelMessage) error TenantMessageDeleteList(t.TenantID, string, *t.DelMessage) error
TenantMessageGetDeleted(t.TenantID, string, t.Uid, *t.QueryOpt) ([]t.DelMessage, error) TenantMessageGetDeleted(t.TenantID, string, t.Uid, *t.QueryOpt) ([]t.DelMessage, error)
TenantMessageSearch(t.TenantID, string, t.Uid, string, string, string, int) ([]t.Message, string, error) TenantMessageSearch(t.TenantID, string, t.Uid, string, string, string, int) ([]t.Message, string, error)
TenantGroupMessageSave(t.TenantID, *t.Message) error
TenantGroupMessageGetByClientID(t.TenantID, string, t.Uid, string) (*t.Message, error)
TenantGroupMessageGetOne(t.TenantID, string, int64) (*t.Message, error)
TenantGroupMessageRecall(t.TenantID, string, int64, t.Uid, int, time.Time) (*t.Message, error)
TenantGroupMessageEdit(t.TenantID, string, int64, t.Uid, int, t.KVMap, any, string, time.Time) (*t.Message, error)
TenantGroupMessageGetAll(t.TenantID, string, t.Uid, *t.QueryOpt) ([]t.Message, error)
TenantGroupMessageDeleteList(t.TenantID, string, *t.DelMessage) error
TenantConversationSettingsGet(t.TenantID, t.Uid, string) (*t.ConversationSettings, error) TenantConversationSettingsGet(t.TenantID, t.Uid, string) (*t.ConversationSettings, error)
TenantConversationSettingsUpdate(t.TenantID, t.Uid, string, t.ConversationSettingsPatch) (*t.ConversationSettings, error) TenantConversationSettingsUpdate(t.TenantID, t.Uid, string, t.ConversationSettingsPatch) (*t.ConversationSettings, error)
@@ -340,8 +347,8 @@ type TenantBusinessAdapter interface {
TenantTopicDraftPut(t.TenantID, t.Uid, string, string, []byte, int) (*t.TopicDraft, error) TenantTopicDraftPut(t.TenantID, t.Uid, string, string, []byte, int) (*t.TopicDraft, error)
TenantCallCreate(t.TenantID, *t.CallRecord) error TenantCallCreate(t.TenantID, *t.CallRecord) error
TenantCallAccept(t.TenantID, string, int, time.Time) error TenantCallAccept(t.TenantID, string, int64, time.Time) error
TenantCallFinish(t.TenantID, string, int, string, int64, time.Time) error TenantCallFinish(t.TenantID, string, int64, string, int64, time.Time) error
TenantCallList(t.TenantID, t.Uid, string, string, int) ([]t.CallRecord, string, error) TenantCallList(t.TenantID, t.Uid, string, string, int) ([]t.CallRecord, string, error)
TenantGroupSettingsCreate(t.TenantID, *t.GroupSettings) (*t.GroupSettings, error) TenantGroupSettingsCreate(t.TenantID, *t.GroupSettings) (*t.GroupSettings, error)
+3 -3
View File
@@ -126,7 +126,7 @@ func TestRangesToSql(t *testing.T) {
ranges := []types.Range{{Low: 5, Hi: 0}} ranges := []types.Range{{Low: 5, Hi: 0}}
sql, args := RangesToSql(ranges) sql, args := RangesToSql(ranges)
expectedSql := "IN (?)" expectedSql := "IN (?)"
expectedArgs := []any{5} expectedArgs := []any{int64(5)}
if sql != expectedSql { if sql != expectedSql {
t.Errorf("Expected SQL '%s', got '%s'", expectedSql, sql) t.Errorf("Expected SQL '%s', got '%s'", expectedSql, sql)
} }
@@ -138,7 +138,7 @@ func TestRangesToSql(t *testing.T) {
ranges = []types.Range{{Low: 5, Hi: 8}} ranges = []types.Range{{Low: 5, Hi: 8}}
sql, args = RangesToSql(ranges) sql, args = RangesToSql(ranges)
expectedSql = "BETWEEN ? AND ?" expectedSql = "BETWEEN ? AND ?"
expectedArgs = []any{5, 7} // Hi-1 for BETWEEN expectedArgs = []any{int64(5), int64(7)} // Hi-1 for BETWEEN
if sql != expectedSql { if sql != expectedSql {
t.Errorf("Expected SQL '%s', got '%s'", expectedSql, sql) t.Errorf("Expected SQL '%s', got '%s'", expectedSql, sql)
} }
@@ -150,7 +150,7 @@ func TestRangesToSql(t *testing.T) {
ranges = []types.Range{{Low: 1, Hi: 3}, {Low: 5, Hi: 0}, {Low: 8, Hi: 10}} ranges = []types.Range{{Low: 1, Hi: 3}, {Low: 5, Hi: 0}, {Low: 8, Hi: 10}}
sql, args = RangesToSql(ranges) sql, args = RangesToSql(ranges)
expectedSql = "IN (?,?,?,?,?)" expectedSql = "IN (?,?,?,?,?)"
expectedArgs = []any{1, 2, 5, 8, 9} expectedArgs = []any{int64(1), int64(2), int64(5), int64(8), int64(9)}
if sql != expectedSql { if sql != expectedSql {
t.Errorf("Expected SQL '%s', got '%s'", expectedSql, sql) t.Errorf("Expected SQL '%s', got '%s'", expectedSql, sql)
} }
+9 -9
View File
@@ -754,7 +754,7 @@ func (a *adapter) CreateDb(reset bool) error {
usebt TINYINT DEFAULT 0, usebt TINYINT DEFAULT 0,
owner BIGINT NOT NULL DEFAULT 0, owner BIGINT NOT NULL DEFAULT 0,
access JSON, access JSON,
seqid INT NOT NULL DEFAULT 0, seqid BIGINT NOT NULL DEFAULT 0,
delid INT DEFAULT 0, delid INT DEFAULT 0,
subcnt INT DEFAULT 0, subcnt INT DEFAULT 0,
public JSON, public JSON,
@@ -799,8 +799,8 @@ func (a *adapter) CreateDb(reset bool) error {
userid BIGINT NOT NULL, userid BIGINT NOT NULL,
topic CHAR(25) NOT NULL, topic CHAR(25) NOT NULL,
delid INT DEFAULT 0, delid INT DEFAULT 0,
recvseqid INT DEFAULT 0, recvseqid BIGINT DEFAULT 0,
readseqid INT DEFAULT 0, readseqid BIGINT DEFAULT 0,
modewant CHAR(8), modewant CHAR(8),
modegiven CHAR(8), modegiven CHAR(8),
private JSON, private JSON,
@@ -822,7 +822,7 @@ func (a *adapter) CreateDb(reset bool) error {
updatedat DATETIME(3) NOT NULL, updatedat DATETIME(3) NOT NULL,
deletedat DATETIME(3), deletedat DATETIME(3),
delid INT DEFAULT 0, delid INT DEFAULT 0,
seqid INT NOT NULL, seqid BIGINT NOT NULL,
topic CHAR(25) NOT NULL,` + topic CHAR(25) NOT NULL,` +
"`from` BIGINT NOT NULL," + "`from` BIGINT NOT NULL," +
`client_msg_id VARCHAR(64), `client_msg_id VARCHAR(64),
@@ -845,7 +845,7 @@ func (a *adapter) CreateDb(reset bool) error {
createdat DATETIME(3) NOT NULL, createdat DATETIME(3) NOT NULL,
updatedat DATETIME(3) NOT NULL, updatedat DATETIME(3) NOT NULL,
topic CHAR(25) NOT NULL, topic CHAR(25) NOT NULL,
seqid INT NOT NULL, seqid BIGINT NOT NULL,
version INT NOT NULL, version INT NOT NULL,
actor BIGINT NOT NULL, actor BIGINT NOT NULL,
head JSON, head JSON,
@@ -1050,7 +1050,7 @@ func createIMCallTables(tx *sql.Tx) error {
id BIGINT NOT NULL AUTO_INCREMENT, id BIGINT NOT NULL AUTO_INCREMENT,
call_id VARCHAR(64) NOT NULL, call_id VARCHAR(64) NOT NULL,
topic CHAR(25) NOT NULL, topic CHAR(25) NOT NULL,
seq_id INT NOT NULL, seq_id BIGINT NOT NULL,
caller_id BIGINT NOT NULL, caller_id BIGINT NOT NULL,
callee_id BIGINT NOT NULL, callee_id BIGINT NOT NULL,
call_type VARCHAR(16) NOT NULL, call_type VARCHAR(16) NOT NULL,
@@ -1403,7 +1403,7 @@ func createIMUserProductTables(tx *sql.Tx) error {
target_type VARCHAR(16) NOT NULL, target_type VARCHAR(16) NOT NULL,
target_user_id BIGINT NOT NULL DEFAULT 0, target_user_id BIGINT NOT NULL DEFAULT 0,
target_topic VARCHAR(32) NOT NULL DEFAULT '', target_topic VARCHAR(32) NOT NULL DEFAULT '',
target_seq INT NOT NULL DEFAULT 0, target_seq BIGINT NOT NULL DEFAULT 0,
reason_code VARCHAR(64) NOT NULL, reason_code VARCHAR(64) NOT NULL,
description TEXT NOT NULL, description TEXT NOT NULL,
evidence_file_ids JSON, evidence_file_ids JSON,
@@ -1481,7 +1481,7 @@ func upgradeIMMessageActions(a *adapter) error {
createdat DATETIME(3) NOT NULL, createdat DATETIME(3) NOT NULL,
updatedat DATETIME(3) NOT NULL, updatedat DATETIME(3) NOT NULL,
topic CHAR(25) NOT NULL, topic CHAR(25) NOT NULL,
seqid INT NOT NULL, seqid BIGINT NOT NULL,
version INT NOT NULL, version INT NOT NULL,
actor BIGINT NOT NULL, actor BIGINT NOT NULL,
head JSON, head JSON,
@@ -1554,7 +1554,7 @@ func createIMRelationTables(exec sqlExecutor) error {
user_id BIGINT NOT NULL, user_id BIGINT NOT NULL,
item_type VARCHAR(16) NOT NULL, item_type VARCHAR(16) NOT NULL,
source_topic CHAR(25) NOT NULL, source_topic CHAR(25) NOT NULL,
source_seq INT NOT NULL, source_seq BIGINT NOT NULL,
snapshot JSON, snapshot JSON,
attachment_refs JSON, attachment_refs JSON,
availability VARCHAR(16) NOT NULL DEFAULT 'available', availability VARCHAR(16) NOT NULL DEFAULT 'available',
+21 -1
View File
@@ -23,6 +23,26 @@ func ensureIMGroupBusinessTables(exec sqlExecutor) error {
INDEX idx_im_group_settings_owner(owner_user_id, group_status), INDEX idx_im_group_settings_owner(owner_user_id, group_status),
INDEX idx_im_group_settings_status_updated(group_status, updated_at) INDEX idx_im_group_settings_status_updated(group_status, updated_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`, ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
`CREATE TABLE IF NOT EXISTS im_group_messages (
id BIGINT NOT NULL AUTO_INCREMENT, topic VARCHAR(64) NOT NULL, group_id BIGINT NOT NULL,
seqid BIGINT NOT NULL, from_user BIGINT NOT NULL, client_msg_id VARCHAR(64),
msg_type VARCHAR(32) NOT NULL DEFAULT 'text', status VARCHAR(32) NOT NULL DEFAULT 'normal',
content JSON, head JSON, mentioned_user_ids JSON, mention_all TINYINT(1) NOT NULL DEFAULT 0,
reply_seqid BIGINT, root_seqid BIGINT, file_count INT NOT NULL DEFAULT 0,
content_size INT NOT NULL DEFAULT 0, has_risk TINYINT(1) NOT NULL DEFAULT 0,
audit_status VARCHAR(32) NOT NULL DEFAULT 'pass', version INT NOT NULL DEFAULT 1,
recalled_at DATETIME(3), recalled_by BIGINT, deleted_at DATETIME(3), expires_at DATETIME(3),
created_at DATETIME(3) NOT NULL, updated_at DATETIME(3) NOT NULL,
PRIMARY KEY(id), UNIQUE INDEX uk_im_group_messages_group_seq(group_id, seqid),
UNIQUE INDEX uk_im_group_messages_topic_seq(topic, seqid),
UNIQUE INDEX uk_im_group_messages_topic_sender_client_mid(topic, from_user, client_msg_id),
INDEX idx_im_group_messages_group_created(group_id, created_at),
INDEX idx_im_group_messages_group_status_seq(group_id, status, seqid),
INDEX idx_im_group_messages_topic_created(topic, created_at),
INDEX idx_im_group_messages_topic_from_seq(topic, from_user, seqid),
INDEX idx_im_group_messages_audit(audit_status, created_at),
INDEX idx_im_group_messages_expires(expires_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
`CREATE TABLE IF NOT EXISTS im_group_members ( `CREATE TABLE IF NOT EXISTS im_group_members (
id BIGINT NOT NULL AUTO_INCREMENT, topic VARCHAR(32) NOT NULL, user_id BIGINT NOT NULL, id BIGINT NOT NULL AUTO_INCREMENT, topic VARCHAR(32) NOT NULL, user_id BIGINT NOT NULL,
role VARCHAR(16) NOT NULL DEFAULT 'member', member_status VARCHAR(16) NOT NULL DEFAULT 'active', role VARCHAR(16) NOT NULL DEFAULT 'member', member_status VARCHAR(16) NOT NULL DEFAULT 'active',
@@ -105,7 +125,7 @@ func ensureIMGroupBusinessTables(exec sqlExecutor) error {
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`, ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
`CREATE TABLE IF NOT EXISTS im_group_files ( `CREATE TABLE IF NOT EXISTS im_group_files (
id BIGINT NOT NULL AUTO_INCREMENT, group_file_id VARCHAR(64) NOT NULL, topic VARCHAR(32) NOT NULL, id BIGINT NOT NULL AUTO_INCREMENT, group_file_id VARCHAR(64) NOT NULL, topic VARCHAR(32) NOT NULL,
file_id VARCHAR(64) NOT NULL, source_topic VARCHAR(32) NOT NULL DEFAULT '', source_seq INT NOT NULL DEFAULT 0, file_id VARCHAR(64) NOT NULL, source_topic VARCHAR(32) NOT NULL DEFAULT '', source_seq BIGINT NOT NULL DEFAULT 0,
uploader_id BIGINT NOT NULL, file_name VARCHAR(255) NOT NULL DEFAULT '', mime_type VARCHAR(128) NOT NULL DEFAULT '', uploader_id BIGINT NOT NULL, file_name VARCHAR(255) NOT NULL DEFAULT '', mime_type VARCHAR(128) NOT NULL DEFAULT '',
file_type VARCHAR(32) NOT NULL DEFAULT 'file', file_size BIGINT NOT NULL DEFAULT 0, file_type VARCHAR(32) NOT NULL DEFAULT 'file', file_size BIGINT NOT NULL DEFAULT 0,
status VARCHAR(16) NOT NULL DEFAULT 'active', deleted_by BIGINT, deleted_at DATETIME(3), status VARCHAR(16) NOT NULL DEFAULT 'active', deleted_by BIGINT, deleted_at DATETIME(3),
+434 -10
View File
@@ -92,6 +92,56 @@ func finalizeTenantMessage(id t.TenantID, msg t.Message, recalledBy sql.NullInt6
return msg return msg
} }
func normalizeGroupMessage(id t.TenantID, msg t.Message, recalledBy sql.NullInt64) t.Message {
msg.TenantID = id
msg.From = common.EncodeUidString(msg.From).String()
if recalledBy.Valid {
msg.RecalledBy = common.EncodeUidString(strconv.FormatInt(recalledBy.Int64, 10)).String()
}
msg.Content = common.FromJSON(msg.Content)
return msg
}
func messageHeadType(head t.KVMap) string {
if head == nil {
return "text"
}
for _, key := range []string{"x-im-type", "type"} {
if value, ok := head[key].(string); ok {
value = strings.TrimSpace(value)
if value != "" {
return value
}
}
}
return "text"
}
func messageHeadJSON(head t.KVMap, key string) []byte {
if head == nil {
return nil
}
if value, ok := head[key]; ok {
return common.ToJSON(value)
}
return nil
}
func messageHeadBool(head t.KVMap, key string) bool {
if head == nil {
return false
}
switch value := head[key].(type) {
case bool:
return value
case string:
parsed, _ := strconv.ParseBool(value)
return parsed
default:
return false
}
}
func stampTenantFile(id t.TenantID, file *t.FileDef) *t.FileDef { func stampTenantFile(id t.TenantID, file *t.FileDef) *t.FileDef {
if file != nil { if file != nil {
file.TenantID = id file.TenantID = id
@@ -1105,6 +1155,63 @@ func tenantMessageDeleteList(tx *sqlx.Tx, tenantID t.TenantID, topic string, toD
return nil return nil
} }
func groupMessageDeleteList(tx *sqlx.Tx, topic string, toDel *t.DelMessage) error {
var err error
if toDel == nil {
if _, err = tx.Exec("DELETE FROM im_dellog WHERE topic=?", topic); err != nil {
return err
}
_, err = tx.Exec("DELETE FROM im_group_messages WHERE topic=?", topic)
return err
}
delRanges := toDel.SeqIdRanges
if toDel.DeletedFor == "" {
where := "m.topic=?"
args := []any{topic}
if len(delRanges) > 0 {
rSQL, rArgs := common.RangesToSql(delRanges)
where += " AND m.seqid " + rSQL
args = append(args, rArgs...)
}
where += " AND m.deleted_at IS NULL"
if newerThan := toDel.GetNewerThan(); newerThan != nil {
where += " AND m.created_at>?"
args = append(args, newerThan)
}
var seqIDs []int
if err = tx.Select(&seqIDs, "SELECT seqid FROM im_group_messages AS m WHERE "+where, args...); err != nil {
return err
}
if len(seqIDs) == 0 {
return nil
}
sort.Ints(seqIDs)
delRanges = t.SliceToRanges(seqIDs)
rSQL, rArgs := common.RangesToSql(delRanges)
where = "m.topic=? AND m.seqid " + rSQL
args = append([]any{topic}, rArgs...)
if _, err = tx.Exec("UPDATE im_group_messages AS m SET m.updated_at=?,m.deleted_at=?,m.status='deleted',m.from_user=0,m.head=NULL,m.content=NULL,m.content_size=0 WHERE "+
where, append([]any{t.TimeNow(), t.TimeNow()}, args...)...); err != nil {
return err
}
}
stmt, err := tx.Prepare("INSERT INTO im_dellog(topic,deletedfor,delid,low,hi) VALUES(?,?,?,?,?)")
if err != nil {
return err
}
defer stmt.Close()
forUser := common.DecodeUidString(toDel.DeletedFor)
for _, rng := range delRanges {
if rng.Hi == 0 {
rng.Hi = rng.Low + 1
}
if _, err = stmt.Exec(topic, forUser, toDel.DelId, rng.Low, rng.Hi); err != nil {
return err
}
}
return nil
}
func (a *adapter) TenantTopicDelete(id t.TenantID, topic string, isChan, hard bool) error { func (a *adapter) TenantTopicDelete(id t.TenantID, topic string, isChan, hard bool) error {
tenantAdp, err := a.routedTenantAdapter(id) tenantAdp, err := a.routedTenantAdapter(id)
if err != nil || topic == "" { if err != nil || topic == "" {
@@ -1138,7 +1245,12 @@ func (a *adapter) TenantTopicDelete(id t.TenantID, topic string, isChan, hard bo
if _, err = tx.Exec(tx.Rebind(q), args...); err != nil { if _, err = tx.Exec(tx.Rebind(q), args...); err != nil {
return err return err
} }
if err = tenantMessageDeleteList(tx, id, topic, nil); err != nil { if t.GetTopicCat(topic) == t.TopicCatGrp {
err = groupMessageDeleteList(tx, topic, nil)
} else {
err = tenantMessageDeleteList(tx, id, topic, nil)
}
if err != nil {
return err return err
} }
if _, err = tx.Exec("DELETE FROM im_topictags WHERE tenant_id=? AND topic=?", id, topic); err != nil { if _, err = tx.Exec("DELETE FROM im_topictags WHERE tenant_id=? AND topic=?", id, topic); err != nil {
@@ -1652,6 +1764,42 @@ func (a *adapter) TenantMessageSave(id t.TenantID, msg *t.Message) error {
return err return err
} }
func (a *adapter) TenantGroupMessageSave(id t.TenantID, msg *t.Message) error {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil || msg == nil || msg.TenantID != id || msg.Topic == "" || msg.SeqId <= 0 {
return t.ErrMalformed
}
if tenantAdp != a {
return tenantAdp.TenantGroupMessageSave(id, msg)
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
groupID, err := a.groupIDByTopic(ctx, msg.Topic)
if err != nil {
return err
}
clientMsgID := sql.NullString{String: msg.ClientMsgID, Valid: msg.ClientMsgID != ""}
contentJSON := common.ToJSON(msg.Content)
res, err := a.db.ExecContext(ctx,
`INSERT INTO im_group_messages
(topic,group_id,seqid,from_user,client_msg_id,msg_type,status,content,head,mentioned_user_ids,mention_all,
file_count,content_size,version,created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
msg.Topic, groupID, msg.SeqId, decodeUID(t.ParseUid(msg.From)), clientMsgID, messageHeadType(msg.Head), "normal",
contentJSON, msg.Head, messageHeadJSON(msg.Head, "mentioned_user_ids"), messageHeadBool(msg.Head, "mention_all"),
0, len(contentJSON), messageVersion(msg.Version), msg.CreatedAt, msg.UpdatedAt)
if err == nil {
lastID, _ := res.LastInsertId()
msg.SetUid(t.Uid(lastID))
}
if isDupe(err) {
return t.ErrDuplicate
}
return err
}
func (a *adapter) TenantMessageGetByClientID(id t.TenantID, from t.Uid, clientMsgID string) (*t.Message, error) { func (a *adapter) TenantMessageGetByClientID(id t.TenantID, from t.Uid, clientMsgID string) (*t.Message, error) {
if !a.tenantScopedDB { if !a.tenantScopedDB {
tenantAdp, err := a.routedTenantAdapter(id) tenantAdp, err := a.routedTenantAdapter(id)
@@ -1687,7 +1835,40 @@ func (a *adapter) TenantMessageGetByClientID(id t.TenantID, from t.Uid, clientMs
return &msg, nil return &msg, nil
} }
func (a *adapter) TenantMessageGetOne(id t.TenantID, topic string, seq int) (*t.Message, error) { func (a *adapter) TenantGroupMessageGetByClientID(id t.TenantID, topic string, from t.Uid, clientMsgID string) (*t.Message, error) {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil {
return nil, err
}
if tenantAdp != a {
return tenantAdp.TenantGroupMessageGetByClientID(id, topic, from, clientMsgID)
}
if topic == "" || from.IsZero() || clientMsgID == "" {
return nil, t.ErrMalformed
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
row := struct {
t.Message
RecalledBy sql.NullInt64 `db:"recalled_by"`
}{}
err = a.db.QueryRowxContext(ctx,
"SELECT id,created_at AS createdat,updated_at AS updatedat,deleted_at AS deletedat,0 AS delid,seqid,topic,from_user AS `from`,client_msg_id,version,recalled_at,recalled_by,head,content "+
"FROM im_group_messages WHERE topic=? AND from_user=? AND client_msg_id=?",
topic, decodeUID(from), clientMsgID).StructScan(&row)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
msg := normalizeGroupMessage(id, row.Message, row.RecalledBy)
return &msg, nil
}
func (a *adapter) TenantMessageGetOne(id t.TenantID, topic string, seq int64) (*t.Message, error) {
if !a.tenantScopedDB { if !a.tenantScopedDB {
tenantAdp, err := a.routedTenantAdapter(id) tenantAdp, err := a.routedTenantAdapter(id)
if err != nil { if err != nil {
@@ -1711,7 +1892,29 @@ func (a *adapter) TenantMessageGetOne(id t.TenantID, topic string, seq int) (*t.
return msg, err return msg, err
} }
func (a *adapter) TenantMessageRecall(id t.TenantID, topic string, seq int, actor t.Uid, expectedVersion int, recalledAt time.Time) (*t.Message, error) { func (a *adapter) TenantGroupMessageGetOne(id t.TenantID, topic string, seq int64) (*t.Message, error) {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil {
return nil, err
}
if tenantAdp != a {
return tenantAdp.TenantGroupMessageGetOne(id, topic, seq)
}
if topic == "" || seq <= 0 {
return nil, t.ErrMalformed
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
msg, err := tenantGroupMessageGetOne(a.db, ctx, id, topic, seq, "")
if err == sql.ErrNoRows {
return nil, t.ErrNotFound
}
return msg, err
}
func (a *adapter) TenantMessageRecall(id t.TenantID, topic string, seq int64, actor t.Uid, expectedVersion int, recalledAt time.Time) (*t.Message, error) {
if !a.tenantScopedDB { if !a.tenantScopedDB {
tenantAdp, err := a.routedTenantAdapter(id) tenantAdp, err := a.routedTenantAdapter(id)
if err != nil { if err != nil {
@@ -1763,7 +1966,57 @@ func (a *adapter) TenantMessageRecall(id t.TenantID, topic string, seq int, acto
return a.TenantMessageGetOne(id, topic, seq) return a.TenantMessageGetOne(id, topic, seq)
} }
func (a *adapter) TenantMessageEdit(id t.TenantID, topic string, seq int, actor t.Uid, expectedVersion int, head t.KVMap, content any, reason string, editedAt time.Time) (*t.Message, error) { func (a *adapter) TenantGroupMessageRecall(id t.TenantID, topic string, seq int64, actor t.Uid, expectedVersion int, recalledAt time.Time) (*t.Message, error) {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil {
return nil, err
}
if tenantAdp != a {
return tenantAdp.TenantGroupMessageRecall(id, topic, seq, actor, expectedVersion, recalledAt)
}
if topic == "" || seq <= 0 || actor.IsZero() || expectedVersion <= 0 {
return nil, t.ErrMalformed
}
ctx, cancel := a.getContextForTx()
if cancel != nil {
defer cancel()
}
tx, err := a.db.BeginTxx(ctx, nil)
if err != nil {
return nil, err
}
defer func() {
if err != nil {
tx.Rollback()
}
}()
msg, err := tenantGroupMessageGetOne(tx, ctx, id, topic, seq, " FOR UPDATE")
if err != nil {
if err == sql.ErrNoRows {
return nil, t.ErrNotFound
}
return nil, err
}
if msg.Version != expectedVersion || msg.RecalledAt != nil || msg.DeletedAt != nil {
return nil, t.ErrDuplicate
}
_, err = tx.ExecContext(ctx,
"UPDATE im_group_messages SET updated_at=?,version=version+1,status='recalled',recalled_at=?,recalled_by=?,head=NULL,content=NULL WHERE topic=? AND seqid=? AND version=?",
recalledAt, recalledAt, decodeUID(actor), topic, seq, expectedVersion)
if err != nil {
return nil, err
}
if err = insertBusinessEventTx(tx, t.StableEventID("message", topic, seq, expectedVersion+1, "recalled"),
"topic", topic, actor, "message.recalled", map[string]any{"topic": topic, "seq": seq, "version": expectedVersion + 1}); err != nil {
return nil, err
}
if err = tx.Commit(); err != nil {
return nil, err
}
return a.TenantGroupMessageGetOne(id, topic, seq)
}
func (a *adapter) TenantMessageEdit(id t.TenantID, topic string, seq int64, actor t.Uid, expectedVersion int, head t.KVMap, content any, reason string, editedAt time.Time) (*t.Message, error) {
if !a.tenantScopedDB { if !a.tenantScopedDB {
tenantAdp, err := a.routedTenantAdapter(id) tenantAdp, err := a.routedTenantAdapter(id)
if err != nil { if err != nil {
@@ -1820,6 +2073,62 @@ func (a *adapter) TenantMessageEdit(id t.TenantID, topic string, seq int, actor
return a.TenantMessageGetOne(id, topic, seq) return a.TenantMessageGetOne(id, topic, seq)
} }
func (a *adapter) TenantGroupMessageEdit(id t.TenantID, topic string, seq int64, actor t.Uid, expectedVersion int, head t.KVMap, content any, reason string, editedAt time.Time) (*t.Message, error) {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil {
return nil, err
}
if tenantAdp != a {
return tenantAdp.TenantGroupMessageEdit(id, topic, seq, actor, expectedVersion, head, content, reason, editedAt)
}
if topic == "" || seq <= 0 || actor.IsZero() || expectedVersion <= 0 {
return nil, t.ErrMalformed
}
ctx, cancel := a.getContextForTx()
if cancel != nil {
defer cancel()
}
tx, err := a.db.BeginTxx(ctx, nil)
if err != nil {
return nil, err
}
defer func() {
if err != nil {
tx.Rollback()
}
}()
msg, err := tenantGroupMessageGetOne(tx, ctx, id, topic, seq, " FOR UPDATE")
if err != nil {
if err == sql.ErrNoRows {
return nil, t.ErrNotFound
}
return nil, err
}
if msg.Version != expectedVersion || msg.RecalledAt != nil || msg.DeletedAt != nil {
return nil, t.ErrDuplicate
}
if _, err = tx.ExecContext(ctx,
"INSERT INTO im_message_revisions(createdat,updatedat,topic,seqid,version,actor,head,content,reason) VALUES(?,?,?,?,?,?,?,?,?)",
editedAt, editedAt, topic, seq, msg.Version, decodeUID(actor), msg.Head, common.ToJSON(msg.Content), strings.TrimSpace(reason)); err != nil {
return nil, err
}
contentJSON := common.ToJSON(content)
_, err = tx.ExecContext(ctx,
"UPDATE im_group_messages SET updated_at=?,version=version+1,msg_type=?,head=?,content=?,content_size=? WHERE topic=? AND seqid=? AND version=?",
editedAt, messageHeadType(head), head, contentJSON, len(contentJSON), topic, seq, expectedVersion)
if err != nil {
return nil, err
}
if err = insertBusinessEventTx(tx, t.StableEventID("message", topic, seq, expectedVersion+1, "edited"),
"topic", topic, actor, "message.edited", map[string]any{"topic": topic, "seq": seq, "version": expectedVersion + 1}); err != nil {
return nil, err
}
if err = tx.Commit(); err != nil {
return nil, err
}
return a.TenantGroupMessageGetOne(id, topic, seq)
}
func (a *adapter) TenantMessageGetAll(id t.TenantID, topic string, uid t.Uid, opts *t.QueryOpt) ([]t.Message, error) { func (a *adapter) TenantMessageGetAll(id t.TenantID, topic string, uid t.Uid, opts *t.QueryOpt) ([]t.Message, error) {
tenantAdp, err := a.routedTenantAdapter(id) tenantAdp, err := a.routedTenantAdapter(id)
if err != nil || topic == "" { if err != nil || topic == "" {
@@ -1888,6 +2197,69 @@ func (a *adapter) TenantMessageGetAll(id t.TenantID, topic string, uid t.Uid, op
return msgs, rows.Err() return msgs, rows.Err()
} }
func (a *adapter) TenantGroupMessageGetAll(id t.TenantID, topic string, uid t.Uid, opts *t.QueryOpt) ([]t.Message, error) {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil || topic == "" {
return nil, t.ErrMalformed
}
if tenantAdp != a {
return tenantAdp.TenantGroupMessageGetAll(id, topic, uid, opts)
}
limit := a.maxMessageResults
args := []any{store.DecodeUid(uid), topic}
seq := ""
if opts != nil {
seq = "AND m.seqid "
if len(opts.IdRanges) > 0 {
constr, addArgs := common.RangesToSql(opts.IdRanges)
seq += constr
args = append(args, addArgs...)
} else {
seq += "BETWEEN ? AND ?"
if opts.Since > 0 {
args = append(args, opts.Since)
} else {
args = append(args, 0)
}
if opts.Before > 1 {
args = append(args, opts.Before-1)
} else {
args = append(args, 1<<31-1)
}
}
if opts.Limit > 0 && opts.Limit < limit {
limit = opts.Limit
}
}
args = append(args, limit)
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
rows, err := a.db.QueryxContext(ctx,
"SELECT m.id,m.created_at AS createdat,m.updated_at AS updatedat,m.deleted_at AS deletedat,0 AS delid,m.seqid,m.topic,m.from_user AS `from`,m.client_msg_id,m.version,m.recalled_at,m.recalled_by,m.head,m.content "+
"FROM im_group_messages AS m LEFT JOIN im_dellog AS d "+
"ON d.topic=m.topic AND m.seqid BETWEEN d.low AND d.hi-1 AND d.deletedfor=? "+
"WHERE m.status!='deleted' AND m.topic=? "+seq+" AND d.deletedfor IS NULL ORDER BY m.seqid DESC LIMIT ?",
args...)
if err != nil {
return nil, err
}
defer rows.Close()
msgs := make([]t.Message, 0, limit)
for rows.Next() {
row := struct {
t.Message
RecalledBy sql.NullInt64 `db:"recalled_by"`
}{}
if err = rows.StructScan(&row); err != nil {
return nil, err
}
msgs = append(msgs, normalizeGroupMessage(id, row.Message, row.RecalledBy))
}
return msgs, rows.Err()
}
func (a *adapter) TenantMessageSearch(id t.TenantID, topic string, uid t.Uid, queryText, messageType, cursor string, limit int) ([]t.Message, string, error) { func (a *adapter) TenantMessageSearch(id t.TenantID, topic string, uid t.Uid, queryText, messageType, cursor string, limit int) ([]t.Message, string, error) {
if !a.tenantScopedDB { if !a.tenantScopedDB {
tenantAdp, err := a.routedTenantAdapter(id) tenantAdp, err := a.routedTenantAdapter(id)
@@ -1951,7 +2323,7 @@ func (a *adapter) TenantMessageSearch(id t.TenantID, topic string, uid t.Uid, qu
msg := finalizeTenantMessage(id, row.Message, row.RecalledBy) msg := finalizeTenantMessage(id, row.Message, row.RecalledBy)
msg.Head = kvMapFromJSON(msg.Head) msg.Head = kvMapFromJSON(msg.Head)
if len(msgs) == limit { if len(msgs) == limit {
nextCursor = strconv.Itoa(msg.SeqId) nextCursor = strconv.FormatInt(msg.SeqId, 10)
continue continue
} }
msgs = append(msgs, msg) msgs = append(msgs, msg)
@@ -2001,7 +2373,7 @@ func (a *adapter) TenantCallCreate(id t.TenantID, record *t.CallRecord) error {
return err return err
} }
func (a *adapter) TenantCallAccept(id t.TenantID, callID string, seq int, acceptedAt time.Time) error { func (a *adapter) TenantCallAccept(id t.TenantID, callID string, seq int64, acceptedAt time.Time) error {
if !a.tenantScopedDB { if !a.tenantScopedDB {
tenantAdp, err := a.routedTenantAdapter(id) tenantAdp, err := a.routedTenantAdapter(id)
if err != nil { if err != nil {
@@ -2026,7 +2398,7 @@ func (a *adapter) TenantCallAccept(id t.TenantID, callID string, seq int, accept
return err return err
} }
func (a *adapter) TenantCallFinish(id t.TenantID, callID string, seq int, status string, durationMS int64, finishedAt time.Time) error { func (a *adapter) TenantCallFinish(id t.TenantID, callID string, seq int64, status string, durationMS int64, finishedAt time.Time) error {
if !a.tenantScopedDB { if !a.tenantScopedDB {
tenantAdp, err := a.routedTenantAdapter(id) tenantAdp, err := a.routedTenantAdapter(id)
if err != nil { if err != nil {
@@ -2102,7 +2474,7 @@ func (a *adapter) TenantCallList(id t.TenantID, uid t.Uid, topic, cursor string,
ID int64 ID int64
CallID string `db:"call_id"` CallID string `db:"call_id"`
Topic string Topic string
SeqID int `db:"seq_id"` SeqID int64 `db:"seq_id"`
CallerID int64 `db:"caller_id"` CallerID int64 `db:"caller_id"`
CalleeID int64 `db:"callee_id"` CalleeID int64 `db:"callee_id"`
CallType string `db:"call_type"` CallType string `db:"call_type"`
@@ -4509,7 +4881,7 @@ func (a *adapter) abuseReportByIdempotency(ctx context.Context, uid t.Uid, idemp
TargetType string `db:"target_type"` TargetType string `db:"target_type"`
TargetUserID int64 `db:"target_user_id"` TargetUserID int64 `db:"target_user_id"`
TargetTopic string `db:"target_topic"` TargetTopic string `db:"target_topic"`
TargetSeq int `db:"target_seq"` TargetSeq int64 `db:"target_seq"`
ReasonCode string `db:"reason_code"` ReasonCode string `db:"reason_code"`
Description string `db:"description"` Description string `db:"description"`
EvidenceFileIDs []byte `db:"evidence_file_ids"` EvidenceFileIDs []byte `db:"evidence_file_ids"`
@@ -4699,7 +5071,7 @@ func isAllowedWorkbenchRoute(route string) bool {
} }
} }
func tenantMessageGetOne(q sqlx.QueryerContext, ctx context.Context, tenantID t.TenantID, topic string, seq int, suffix string) (*t.Message, error) { func tenantMessageGetOne(q sqlx.QueryerContext, ctx context.Context, tenantID t.TenantID, topic string, seq int64, suffix string) (*t.Message, error) {
row := struct { row := struct {
t.Message t.Message
RecalledBy sql.NullInt64 `db:"recalled_by"` RecalledBy sql.NullInt64 `db:"recalled_by"`
@@ -4721,6 +5093,31 @@ func tenantMessageGetOne(q sqlx.QueryerContext, ctx context.Context, tenantID t.
return &msg, nil return &msg, nil
} }
func (a *adapter) groupIDByTopic(ctx context.Context, topic string) (int64, error) {
var groupID int64
err := a.db.QueryRowContext(ctx, "SELECT id FROM im_group_settings WHERE topic=? LIMIT 1", topic).Scan(&groupID)
if err == sql.ErrNoRows {
return 0, t.ErrNotFound
}
return groupID, err
}
func tenantGroupMessageGetOne(q sqlx.QueryerContext, ctx context.Context, tenantID t.TenantID, topic string, seq int64, suffix string) (*t.Message, error) {
row := struct {
t.Message
RecalledBy sql.NullInt64 `db:"recalled_by"`
}{}
err := sqlx.GetContext(ctx, q, &row,
"SELECT id,created_at AS createdat,updated_at AS updatedat,deleted_at AS deletedat,0 AS delid,seqid,topic,from_user AS `from`,client_msg_id,version,recalled_at,recalled_by,head,content "+
"FROM im_group_messages WHERE topic=? AND seqid=?"+suffix,
topic, seq)
if err != nil {
return nil, err
}
msg := normalizeGroupMessage(tenantID, row.Message, row.RecalledBy)
return &msg, nil
}
func messageVersion(version int) int { func messageVersion(version int) int {
if version <= 0 { if version <= 0 {
return 1 return 1
@@ -5174,6 +5571,33 @@ func (a *adapter) TenantMessageDeleteList(id t.TenantID, topic string, del *t.De
return tx.Commit() return tx.Commit()
} }
func (a *adapter) TenantGroupMessageDeleteList(id t.TenantID, topic string, del *t.DelMessage) error {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil || topic == "" || (del != nil && del.TenantID != id) {
return t.ErrMalformed
}
if tenantAdp != a {
return tenantAdp.TenantGroupMessageDeleteList(id, topic, del)
}
ctx, cancel := a.getContextForTx()
if cancel != nil {
defer cancel()
}
tx, err := a.db.BeginTxx(ctx, nil)
if err != nil {
return err
}
defer func() {
if err != nil {
tx.Rollback()
}
}()
if err = groupMessageDeleteList(tx, topic, del); err != nil {
return err
}
return tx.Commit()
}
func (a *adapter) TenantMessageGetDeleted(id t.TenantID, topic string, uid t.Uid, opts *t.QueryOpt) ([]t.DelMessage, error) { func (a *adapter) TenantMessageGetDeleted(id t.TenantID, topic string, uid t.Uid, opts *t.QueryOpt) ([]t.DelMessage, error) {
tenantAdp, err := a.routedTenantAdapter(id) tenantAdp, err := a.routedTenantAdapter(id)
if err != nil || topic == "" { if err != nil || topic == "" {
+1 -1
View File
@@ -1201,7 +1201,7 @@ func TestTopicUpdateOnMessage(t *testing.T) {
} }
var got struct { var got struct {
TouchedAt time.Time TouchedAt time.Time
SeqId int SeqId int64
} }
err = db.QueryRow("SELECT touchedat, seqid FROM im_topics WHERE name=?", testData.Topics[2].Id). err = db.QueryRow("SELECT touchedat, seqid FROM im_topics WHERE name=?", testData.Topics[2].Id).
Scan(&got.TouchedAt, &got.SeqId) Scan(&got.TouchedAt, &got.SeqId)
+1 -1
View File
@@ -830,7 +830,7 @@ func TestTopicUpdateOnMessage(t *testing.T) {
} }
var got struct { var got struct {
TouchedAt time.Time TouchedAt time.Time
SeqId int SeqId int64
} }
err = db.QueryRow(ctx, "SELECT touchedat, seqid FROM im_topics WHERE name=$1", testData.Topics[2].Id). err = db.QueryRow(ctx, "SELECT touchedat, seqid FROM im_topics WHERE name=$1", testData.Topics[2].Id).
Scan(&got.TouchedAt, &got.SeqId) Scan(&got.TouchedAt, &got.SeqId)
+1 -1
View File
@@ -844,7 +844,7 @@ func TestTopicUpdateOnMessage(t *testing.T) {
var got struct { var got struct {
TouchedAt time.Time TouchedAt time.Time
SeqId int SeqId int64
} }
if err = cursor.One(&got); err != nil { if err = cursor.One(&got); err != nil {
t.Fatal(err) t.Fatal(err)
+90 -15
View File
@@ -60,6 +60,7 @@ const (
imVerificationExpiresInSeconds = 300 imVerificationExpiresInSeconds = 300
imVerificationResendAfterSeconds = 60 imVerificationResendAfterSeconds = 60
imVerificationMaxAttempts = 5 imVerificationMaxAttempts = 5
imVerificationCreateRateWindow = time.Hour
imActionTokenExpiresInSeconds = 300 imActionTokenExpiresInSeconds = 300
imIndependentPasswordMaxAttempts = 5 imIndependentPasswordMaxAttempts = 5
imIndependentPasswordLockSeconds = 300 imIndependentPasswordLockSeconds = 300
@@ -380,7 +381,7 @@ type imMessageRevisionRequest struct {
type imFavoritesBatchCreateRequest struct { type imFavoritesBatchCreateRequest struct {
Messages []struct { Messages []struct {
Topic string `json:"topic"` Topic string `json:"topic"`
Seq int `json:"seq,string"` Seq int64 `json:"seq,string"`
} `json:"messages"` } `json:"messages"`
} }
@@ -479,7 +480,7 @@ type imGroupFileCreateRequest struct {
FileType string `json:"file_type,omitempty"` FileType string `json:"file_type,omitempty"`
FileSize int64 `json:"file_size,omitempty"` FileSize int64 `json:"file_size,omitempty"`
SourceTopic string `json:"source_topic,omitempty"` SourceTopic string `json:"source_topic,omitempty"`
SourceSeq int `json:"source_seq,omitempty"` SourceSeq int64 `json:"source_seq,omitempty"`
} }
type imGroupAnnouncementPutRequest struct { type imGroupAnnouncementPutRequest struct {
@@ -874,6 +875,18 @@ func serveIMVerificationCodes(wrt http.ResponseWriter, req *http.Request) {
now := time.Now().UTC().Round(time.Millisecond) now := time.Now().UTC().Round(time.Millisecond)
phoneHash := hashIMSecret(tenant.ID, scene, phone, "phone") phoneHash := hashIMSecret(tenant.ID, scene, phone, "phone")
if allowed, _, ttl, err := store.AllowRate(tenant.ID, "verification:create:"+scene+":"+phoneHash, imVerificationMaxAttempts, imVerificationCreateRateWindow); err != nil {
logs.Warn.Println("im verification create: redis rate limit failed", tenant.Code, scene, err)
} else if !allowed {
retryAfter := int(ttl.Seconds())
if retryAfter < 1 {
retryAfter = int(imVerificationCreateRateWindow.Seconds())
}
writeIMError(wrt, req, http.StatusTooManyRequests, 30020, imReasonVerificationRate, "verification rate limited", map[string]any{
"retry_after_seconds": retryAfter,
})
return
}
active, err := store.Verifications.GetActive(tenant.ID, scene, phoneHash, now) active, err := store.Verifications.GetActive(tenant.ID, scene, phoneHash, now)
if err != nil && !errors.Is(err, types.ErrNotFound) { if err != nil && !errors.Is(err, types.ErrNotFound) {
logs.Warn.Println("im verification create: active lookup failed", tenant.Code, scene, err) logs.Warn.Println("im verification create: active lookup failed", tenant.Code, scene, err)
@@ -3342,7 +3355,7 @@ func serveIMMessageContext(wrt http.ResponseWriter, req *http.Request) {
} }
before := parseIMLimit(req.URL.Query().Get("before"), 20, 100) before := parseIMLimit(req.URL.Query().Get("before"), 20, 100)
after := parseIMLimit(req.URL.Query().Get("after"), 20, 100) after := parseIMLimit(req.URL.Query().Get("after"), 20, 100)
msgs, err := store.Messages.GetAll(principal.TenantID, topic, principal.Uid, &types.QueryOpt{Since: seq - before, Before: seq + after + 1, Limit: before + after + 1}) msgs, err := store.Messages.GetAll(principal.TenantID, topic, principal.Uid, &types.QueryOpt{Since: seq - int64(before), Before: seq + int64(after) + 1, Limit: before + after + 1})
if err != nil { if err != nil {
writeIMRelationError(wrt, req, err) writeIMRelationError(wrt, req, err)
return return
@@ -3599,7 +3612,7 @@ func serveIMConversationContext(wrt http.ResponseWriter, req *http.Request) {
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil) writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
return return
} }
peer := peerFromP2PTopic(topic, principal.Uid) topic, peer := normalizeP2PConversationTopic(topic, principal.Uid)
if topic == "" || peer.IsZero() { if topic == "" || peer.IsZero() {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "topic"}) writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "topic"})
return return
@@ -3617,6 +3630,7 @@ func serveIMConversationSettings(wrt http.ResponseWriter, req *http.Request, pri
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "topic"}) writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "topic"})
return return
} }
topic = normalizeConversationSettingsTopic(topic, principal.Uid)
switch req.Method { switch req.Method {
case http.MethodGet: case http.MethodGet:
settings, err := store.Conversations.GetSettings(principal.TenantID, principal.Uid, topic) settings, err := store.Conversations.GetSettings(principal.TenantID, principal.Uid, topic)
@@ -4043,6 +4057,11 @@ func serveIMGroupMembers(wrt http.ResponseWriter, req *http.Request, topic strin
roleFilter := strings.TrimSpace(req.URL.Query().Get("role")) roleFilter := strings.TrimSpace(req.URL.Query().Get("role"))
eligibleRole := strings.TrimSpace(req.URL.Query().Get("eligible_role")) eligibleRole := strings.TrimSpace(req.URL.Query().Get("eligible_role"))
muteEligible := parseIMBool(req.URL.Query().Get("mute_eligible")) muteEligible := parseIMBool(req.URL.Query().Get("mute_eligible"))
cacheKey := groupMemberPageCacheKey(topic, principal.Uid, selfAccess, query, roleFilter, eligibleRole, limit, cursor, mentionableOnly, muteEligible)
if cached, ok := store.GetCachedGroupMemberPage(principal.TenantID, cacheKey); ok {
writeIMSuccess(wrt, req, cached)
return
}
members, err := store.Topics.GetUsers(principal.TenantID, topic, &types.QueryOpt{Limit: 1000}) members, err := store.Topics.GetUsers(principal.TenantID, topic, &types.QueryOpt{Limit: 1000})
if err != nil { if err != nil {
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil) writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
@@ -4093,12 +4112,14 @@ func serveIMGroupMembers(wrt http.ResponseWriter, req *http.Request, topic strin
permissions := map[string]any{ permissions := map[string]any{
"can_mention_all": selfAccess.IsAdmin(), "can_mention_all": selfAccess.IsAdmin(),
} }
writeIMSuccess(wrt, req, map[string]any{ payload := map[string]any{
"topic": topic, "topic": topic,
"items": items, "items": items,
"next_cursor": nextCursor, "next_cursor": nextCursor,
"permissions": permissions, "permissions": permissions,
}) }
store.CacheGroupMemberPage(principal.TenantID, cacheKey, payload)
writeIMSuccess(wrt, req, payload)
} }
func serveIMGroupDetail(wrt http.ResponseWriter, req *http.Request, topic string) { func serveIMGroupDetail(wrt http.ResponseWriter, req *http.Request, topic string) {
@@ -5767,6 +5788,18 @@ func currentUserPhone(tenantID types.TenantID, uid types.Uid) (string, error) {
func createSensitiveChallenge(wrt http.ResponseWriter, req *http.Request, principal *imPrincipal, action, phone string) { func createSensitiveChallenge(wrt http.ResponseWriter, req *http.Request, principal *imPrincipal, action, phone string) {
now := time.Now().UTC().Round(time.Millisecond) now := time.Now().UTC().Round(time.Millisecond)
phoneHash := hashIMSecret(principal.TenantID, "sensitive_action", phone, "phone") phoneHash := hashIMSecret(principal.TenantID, "sensitive_action", phone, "phone")
if allowed, _, ttl, err := store.AllowRate(principal.TenantID, "verification:create:sensitive_action:"+phoneHash, imVerificationMaxAttempts, imVerificationCreateRateWindow); err != nil {
logs.Warn.Println("im sensitive verification create: redis rate limit failed", principal.TenantID, err)
} else if !allowed {
retryAfter := int(ttl.Seconds())
if retryAfter < 1 {
retryAfter = int(imVerificationCreateRateWindow.Seconds())
}
writeIMError(wrt, req, http.StatusTooManyRequests, 30020, imReasonVerificationRate, "verification rate limited", map[string]any{
"retry_after_seconds": retryAfter,
})
return
}
active, err := store.Verifications.GetActive(principal.TenantID, "sensitive_action", phoneHash, now) active, err := store.Verifications.GetActive(principal.TenantID, "sensitive_action", phoneHash, now)
if err != nil && !errors.Is(err, types.ErrNotFound) { if err != nil && !errors.Is(err, types.ErrNotFound) {
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil) writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
@@ -6712,7 +6745,23 @@ func groupMemberMatches(item map[string]any, query string) bool {
return false return false
} }
func authorizeIMMessageMutation(principal *imPrincipal, topic string, seq int, allowAdmin bool) (*types.Message, bool, error) { func groupMemberPageCacheKey(topic string, uid types.Uid, access types.AccessMode, query, roleFilter, eligibleRole string,
limit, cursor int, mentionableOnly, muteEligible bool) string {
return strings.Join([]string{
topic,
uid.UserId(),
access.String(),
strings.ToLower(strings.TrimSpace(query)),
strings.TrimSpace(roleFilter),
strings.TrimSpace(eligibleRole),
strconv.Itoa(limit),
strconv.Itoa(cursor),
strconv.FormatBool(mentionableOnly),
strconv.FormatBool(muteEligible),
}, "|")
}
func authorizeIMMessageMutation(principal *imPrincipal, topic string, seq int64, allowAdmin bool) (*types.Message, bool, error) {
if principal == nil { if principal == nil {
return nil, false, types.ErrPermissionDenied return nil, false, types.ErrPermissionDenied
} }
@@ -8385,7 +8434,7 @@ func cleanTenantCodeHint(code string) string {
return code return code
} }
func normalizeIMAbuseTarget(tenantID types.TenantID, reporter types.Uid, target types.AbuseTarget) (string, types.Uid, string, int, map[string]any, error) { func normalizeIMAbuseTarget(tenantID types.TenantID, reporter types.Uid, target types.AbuseTarget) (string, types.Uid, string, int64, map[string]any, error) {
targetType := strings.ToLower(strings.TrimSpace(target.Type)) targetType := strings.ToLower(strings.TrimSpace(target.Type))
snapshot := map[string]any{"type": targetType} snapshot := map[string]any{"type": targetType}
switch targetType { switch targetType {
@@ -8413,7 +8462,7 @@ func normalizeIMAbuseTarget(tenantID types.TenantID, reporter types.Uid, target
return targetType, 0, topic, 0, snapshot, nil return targetType, 0, topic, 0, snapshot, nil
case "message": case "message":
topic := strings.TrimSpace(target.Topic) topic := strings.TrimSpace(target.Topic)
seq, err := strconv.Atoi(strings.TrimSpace(target.Seq)) seq, err := strconv.ParseInt(strings.TrimSpace(target.Seq), 10, 64)
if topic == "" || seq <= 0 || err != nil { if topic == "" || seq <= 0 || err != nil {
return "", 0, "", 0, nil, types.ErrMalformed return "", 0, "", 0, nil, types.ErrMalformed
} }
@@ -8476,7 +8525,7 @@ func groupInvitationResourcePath(path string) (string, string, bool) {
return strings.TrimSpace(invitationID), strings.Trim(tail, "/"), true return strings.TrimSpace(invitationID), strings.Trim(tail, "/"), true
} }
func messageReceiptsPath(path string) (string, int, bool) { func messageReceiptsPath(path string) (string, int64, bool) {
const marker = "/v1/im/topics/" const marker = "/v1/im/topics/"
idx := strings.Index(path, marker) idx := strings.Index(path, marker)
if idx < 0 { if idx < 0 {
@@ -8491,7 +8540,7 @@ func messageReceiptsPath(path string) (string, int, bool) {
if !ok || suffix != "receipts" { if !ok || suffix != "receipts" {
return "", 0, false return "", 0, false
} }
seq, err := strconv.Atoi(seqValue) seq, err := strconv.ParseInt(seqValue, 10, 64)
if err != nil { if err != nil {
return topic, 0, true return topic, 0, true
} }
@@ -8531,7 +8580,7 @@ func topicFromPathWithSuffix(path, prefix, suffix string) string {
return strings.Trim(rest, "/") return strings.Trim(rest, "/")
} }
func messageContextPath(path string) (string, int, bool) { func messageContextPath(path string) (string, int64, bool) {
const prefix = "/v1/im/topics/" const prefix = "/v1/im/topics/"
idx := strings.Index(path, prefix) idx := strings.Index(path, prefix)
if idx < 0 { if idx < 0 {
@@ -8542,14 +8591,14 @@ func messageContextPath(path string) (string, int, bool) {
if len(parts) != 4 || parts[1] != "messages" || parts[3] != "context" { if len(parts) != 4 || parts[1] != "messages" || parts[3] != "context" {
return "", 0, false return "", 0, false
} }
seq, err := strconv.Atoi(parts[2]) seq, err := strconv.ParseInt(parts[2], 10, 64)
if err != nil || seq <= 0 { if err != nil || seq <= 0 {
return "", 0, false return "", 0, false
} }
return parts[0], seq, true return parts[0], seq, true
} }
func messageActionPath(path, action string) (string, int, bool) { func messageActionPath(path, action string) (string, int64, bool) {
const marker = "/v1/im/topics/" const marker = "/v1/im/topics/"
idx := strings.Index(path, marker) idx := strings.Index(path, marker)
if idx < 0 { if idx < 0 {
@@ -8564,7 +8613,7 @@ func messageActionPath(path, action string) (string, int, bool) {
if !ok || suffix != action { if !ok || suffix != action {
return "", 0, false return "", 0, false
} }
seq, err := strconv.Atoi(seqValue) seq, err := strconv.ParseInt(seqValue, 10, 64)
if err != nil { if err != nil {
return topic, 0, true return topic, 0, true
} }
@@ -8756,3 +8805,29 @@ func peerFromP2PTopic(topic string, uid types.Uid) types.Uid {
} }
return types.ZeroUid return types.ZeroUid
} }
func normalizeP2PConversationTopic(topic string, uid types.Uid) (string, types.Uid) {
topic = strings.TrimSpace(topic)
if len(topic) < 3 || uid.IsZero() {
return "", types.ZeroUid
}
if types.GetTopicCat(topic) == types.TopicCatMe {
peer := types.ParseUserId(topic)
if peer.IsZero() || peer == uid {
return "", types.ZeroUid
}
return uid.P2PName(peer), peer
}
peer := peerFromP2PTopic(topic, uid)
if peer.IsZero() {
return "", types.ZeroUid
}
return topic, peer
}
func normalizeConversationSettingsTopic(topic string, uid types.Uid) string {
if normalized, peer := normalizeP2PConversationTopic(topic, uid); !peer.IsZero() {
return normalized
}
return strings.TrimSpace(topic)
}
+61 -7
View File
@@ -861,11 +861,11 @@ func (s *testCallsStore) Create(_ types.TenantID, _ *types.CallRecord) error {
return nil return nil
} }
func (s *testCallsStore) Accept(_ types.TenantID, _ string, _ int, _ time.Time) error { func (s *testCallsStore) Accept(_ types.TenantID, _ string, _ int64, _ time.Time) error {
return nil return nil
} }
func (s *testCallsStore) Finish(_ types.TenantID, _ string, _ int, _ string, _ int64, _ time.Time) error { func (s *testCallsStore) Finish(_ types.TenantID, _ string, _ int64, _ string, _ int64, _ time.Time) error {
return nil return nil
} }
@@ -885,6 +885,7 @@ func (s *testConversationsStore) EnsureP2P(_ types.TenantID, uid, peer types.Uid
} }
func (s *testConversationsStore) GetSettings(_ types.TenantID, _ types.Uid, topic string) (*types.ConversationSettings, error) { func (s *testConversationsStore) GetSettings(_ types.TenantID, _ types.Uid, topic string) (*types.ConversationSettings, error) {
s.topic = topic
if s.settings == nil { if s.settings == nil {
now := time.Now().UTC().Round(time.Millisecond) now := time.Now().UTC().Round(time.Millisecond)
s.settings = &types.ConversationSettings{Topic: topic, Pinned: false, Muted: false, MessageRetention: "default", Version: 1, CreatedAt: now, UpdatedAt: now} s.settings = &types.ConversationSettings{Topic: topic, Pinned: false, Muted: false, MessageRetention: "default", Version: 1, CreatedAt: now, UpdatedAt: now}
@@ -3770,7 +3771,7 @@ func TestServeIMFavoriteBatchCreateCountsExisting(t *testing.T) {
store.Messages = oldMessages store.Messages = oldMessages
}) })
messages.EXPECT().GetOne(types.TenantID(7), "grpAlpha", 3).Return(&types.Message{ messages.EXPECT().GetOne(types.TenantID(7), "grpAlpha", int64(3)).Return(&types.Message{
TenantID: 7, TenantID: 7,
Topic: "grpAlpha", Topic: "grpAlpha",
SeqId: 3, SeqId: 3,
@@ -4307,7 +4308,7 @@ func TestServeIMMessageRecall(t *testing.T) {
ModeWant: types.ModeCFull, ModeWant: types.ModeCFull,
ModeGiven: types.ModeCFull, ModeGiven: types.ModeCFull,
}, nil) }, nil)
messages.EXPECT().GetOne(types.TenantID(7), "grpRecall", 9).Return(&types.Message{ messages.EXPECT().GetOne(types.TenantID(7), "grpRecall", int64(9)).Return(&types.Message{
TenantID: 7, TenantID: 7,
SeqId: 9, SeqId: 9,
Topic: "grpRecall", Topic: "grpRecall",
@@ -4315,7 +4316,7 @@ func TestServeIMMessageRecall(t *testing.T) {
Version: 1, Version: 1,
ObjHeader: types.ObjHeader{CreatedAt: time.Now().Add(-time.Second)}, ObjHeader: types.ObjHeader{CreatedAt: time.Now().Add(-time.Second)},
}, nil) }, nil)
messages.EXPECT().Recall(types.TenantID(7), "grpRecall", 9, types.Uid(42), 1, gomock.Any()).Return(&types.Message{ messages.EXPECT().Recall(types.TenantID(7), "grpRecall", int64(9), types.Uid(42), 1, gomock.Any()).Return(&types.Message{
TenantID: 7, TenantID: 7,
SeqId: 9, SeqId: 9,
Topic: "grpRecall", Topic: "grpRecall",
@@ -4363,7 +4364,7 @@ func TestServeIMMessageRevision(t *testing.T) {
ModeWant: types.ModeCFull, ModeWant: types.ModeCFull,
ModeGiven: types.ModeCFull, ModeGiven: types.ModeCFull,
}, nil) }, nil)
messages.EXPECT().GetOne(types.TenantID(7), "grpEdit", 3).Return(&types.Message{ messages.EXPECT().GetOne(types.TenantID(7), "grpEdit", int64(3)).Return(&types.Message{
TenantID: 7, TenantID: 7,
SeqId: 3, SeqId: 3,
Topic: "grpEdit", Topic: "grpEdit",
@@ -4373,7 +4374,7 @@ func TestServeIMMessageRevision(t *testing.T) {
Content: "old", Content: "old",
ObjHeader: types.ObjHeader{CreatedAt: time.Now().Add(-time.Second)}, ObjHeader: types.ObjHeader{CreatedAt: time.Now().Add(-time.Second)},
}, nil) }, nil)
messages.EXPECT().Edit(types.TenantID(7), "grpEdit", 3, types.Uid(42), 1, gomock.Any(), "new", "", gomock.Any()).Return(&types.Message{ messages.EXPECT().Edit(types.TenantID(7), "grpEdit", int64(3), types.Uid(42), 1, gomock.Any(), "new", "", gomock.Any()).Return(&types.Message{
TenantID: 7, TenantID: 7,
SeqId: 3, SeqId: 3,
Topic: "grpEdit", Topic: "grpEdit",
@@ -4431,6 +4432,31 @@ func TestServeIMConversationContext(t *testing.T) {
} }
} }
func TestServeIMConversationContextAcceptsUserTopic(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
expectIMBearer(t, ctrl, "token-1", 7, 1)
oldRelations := store.Relations
peer := types.Uid(2)
topic := types.Uid(1).P2PName(peer)
store.Relations = &testRelationsStore{}
defer func() { store.Relations = oldRelations }()
req := httptest.NewRequest(http.MethodGet, "/v1/im/conversations/"+peer.UserId()+"/context", nil)
req.Header.Set("Authorization", "Bearer token-1")
rec := httptest.NewRecorder()
serveIMConversationContext(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status: got %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String())
}
data := decodeIMHTTPResponse(t, rec).Data.(map[string]any)
if data["topic"] != topic || data["peer_user_id"] != peer.UserId() {
t.Fatalf("unexpected context response: %#v", data)
}
}
func TestServeIMP2PConversation(t *testing.T) { func TestServeIMP2PConversation(t *testing.T) {
ctrl := gomock.NewController(t) ctrl := gomock.NewController(t)
defer ctrl.Finish() defer ctrl.Finish()
@@ -4487,6 +4513,34 @@ func TestServeIMConversationSettings(t *testing.T) {
} }
} }
func TestServeIMConversationSettingsNormalizesUserTopic(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
expectIMBearer(t, ctrl, "token-1", 7, 1)
oldConversations := store.Conversations
peer := types.Uid(2)
topic := types.Uid(1).P2PName(peer)
conversations := &testConversationsStore{}
store.Conversations = conversations
defer func() { store.Conversations = oldConversations }()
req := httptest.NewRequest(http.MethodGet, "/v1/im/conversations/"+peer.UserId()+"/settings", nil)
req.Header.Set("Authorization", "Bearer token-1")
rec := httptest.NewRecorder()
serveIMConversationContext(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status: got %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String())
}
if conversations.topic != topic {
t.Fatalf("settings topic: got %q, want %q", conversations.topic, topic)
}
data := decodeIMHTTPResponse(t, rec).Data.(map[string]any)
if data["topic"] != topic {
t.Fatalf("unexpected settings response: %#v", data)
}
}
func TestServeIMTopicDraftPutAndGet(t *testing.T) { func TestServeIMTopicDraftPutAndGet(t *testing.T) {
ctrl := gomock.NewController(t) ctrl := gomock.NewController(t)
defer ctrl.Finish() defer ctrl.Finish()
+2
View File
@@ -11,6 +11,7 @@ package main
//go:generate protoc --go_out=../pbx --go_opt=paths=source_relative --go-grpc_out=../pbx --go-grpc_opt=paths=source_relative ../pbx/model.proto //go:generate protoc --go_out=../pbx --go_opt=paths=source_relative --go-grpc_out=../pbx --go-grpc_opt=paths=source_relative ../pbx/model.proto
import ( import (
"context"
"encoding/json" "encoding/json"
"flag" "flag"
"net/http" "net/http"
@@ -676,6 +677,7 @@ func main() {
globals.sessionStore = NewSessionStore(idleSessionTimeout + 15*time.Second) globals.sessionStore = NewSessionStore(idleSessionTimeout + 15*time.Second)
// The hub (the main message router) // The hub (the main message router)
globals.hub = newHub() globals.hub = newHub()
startRedisGroupFanout(context.Background())
stopExternalBusinessEvents := startIMExternalBusinessEventPoller() stopExternalBusinessEvents := startIMExternalBusinessEventPoller()
defer stopExternalBusinessEvents() defer stopExternalBusinessEvents()
+31 -31
View File
@@ -41,7 +41,7 @@ func pbServDataSerialize(data *MsgServerData) *pbx.ServerMsg_Data {
FromUserId: data.From, FromUserId: data.From,
Timestamp: timeToInt64(&data.Timestamp), Timestamp: timeToInt64(&data.Timestamp),
DeletedAt: timeToInt64(data.DeletedAt), DeletedAt: timeToInt64(data.DeletedAt),
SeqId: int32(data.SeqId), SeqId: data.SeqId,
Head: interfaceMapToByteMap(data.Head), Head: interfaceMapToByteMap(data.Head),
Content: interfaceToBytes(data.Content), Content: interfaceToBytes(data.Content),
}, },
@@ -86,7 +86,7 @@ func pbServPresSerialize(pres *MsgServerPres) *pbx.ServerMsg_Pres {
Src: pres.Src, Src: pres.Src,
What: what, What: what,
UserAgent: pres.UserAgent, UserAgent: pres.UserAgent,
SeqId: int32(pres.SeqId), SeqId: pres.SeqId,
DelId: int32(pres.DelId), DelId: int32(pres.DelId),
DelSeq: pbDelQuerySerialize(pres.DelSeq), DelSeq: pbDelQuerySerialize(pres.DelSeq),
TargetUserId: pres.AcsTarget, TargetUserId: pres.AcsTarget,
@@ -103,7 +103,7 @@ func pbServInfoSerialize(info *MsgServerInfo) *pbx.ServerMsg_Info {
FromUserId: info.From, FromUserId: info.From,
Src: info.Src, Src: info.Src,
What: pbInfoNoteWhatSerialize(info.What), What: pbInfoNoteWhatSerialize(info.What),
SeqId: int32(info.SeqId), SeqId: info.SeqId,
Event: pbCallEventSerialize(info.Event), Event: pbCallEventSerialize(info.Event),
Payload: info.Payload, Payload: info.Payload,
}, },
@@ -165,7 +165,7 @@ func pbServDeserialize(pkt *pbx.ServerMsg) *ServerComMessage {
From: data.GetFromUserId(), From: data.GetFromUserId(),
Timestamp: *tsptr, Timestamp: *tsptr,
DeletedAt: int64ToTime(data.GetDeletedAt()), DeletedAt: int64ToTime(data.GetDeletedAt()),
SeqId: int(data.GetSeqId()), SeqId: data.GetSeqId(),
Head: byteMapToInterfaceMap(data.GetHead()), Head: byteMapToInterfaceMap(data.GetHead()),
Content: data.GetContent(), Content: data.GetContent(),
} }
@@ -204,7 +204,7 @@ func pbServDeserialize(pkt *pbx.ServerMsg) *ServerComMessage {
Src: pres.GetSrc(), Src: pres.GetSrc(),
What: what, What: what,
UserAgent: pres.GetUserAgent(), UserAgent: pres.GetUserAgent(),
SeqId: int(pres.GetSeqId()), SeqId: pres.GetSeqId(),
DelId: int(pres.GetDelId()), DelId: int(pres.GetDelId()),
DelSeq: pbDelQueryDeserialize(pres.GetDelSeq()), DelSeq: pbDelQueryDeserialize(pres.GetDelSeq()),
AcsTarget: pres.GetTargetUserId(), AcsTarget: pres.GetTargetUserId(),
@@ -217,7 +217,7 @@ func pbServDeserialize(pkt *pbx.ServerMsg) *ServerComMessage {
Src: info.GetSrc(), Src: info.GetSrc(),
From: info.GetFromUserId(), From: info.GetFromUserId(),
What: pbInfoNoteWhatDeserialize(info.GetWhat()), What: pbInfoNoteWhatDeserialize(info.GetWhat()),
SeqId: int(info.GetSeqId()), SeqId: info.GetSeqId(),
Event: pbCallEventDeserialize(info.GetEvent()), Event: pbCallEventDeserialize(info.GetEvent()),
Payload: info.GetPayload(), Payload: info.GetPayload(),
} }
@@ -365,7 +365,7 @@ func pbCliSerialize(msg *ClientComMessage) *pbx.ClientMsg {
Note: &pbx.ClientNote{ Note: &pbx.ClientNote{
Topic: msg.Note.Topic, Topic: msg.Note.Topic,
What: pbInfoNoteWhatSerialize(msg.Note.What), What: pbInfoNoteWhatSerialize(msg.Note.What),
SeqId: int32(msg.Note.SeqId), SeqId: msg.Note.SeqId,
Unread: int32(msg.Note.Unread), Unread: int32(msg.Note.Unread),
Event: pbCallEventSerialize(msg.Note.Event), Event: pbCallEventSerialize(msg.Note.Event),
Payload: msg.Note.Payload, Payload: msg.Note.Payload,
@@ -485,7 +485,7 @@ func pbCliDeserialize(pkt *pbx.ClientMsg) *ClientComMessage {
} else if note := pkt.GetNote(); note != nil { } else if note := pkt.GetNote(); note != nil {
msg.Note = &MsgClientNote{ msg.Note = &MsgClientNote{
Topic: note.GetTopic(), Topic: note.GetTopic(),
SeqId: int(note.GetSeqId()), SeqId: note.GetSeqId(),
What: pbInfoNoteWhatDeserialize(note.GetWhat()), What: pbInfoNoteWhatDeserialize(note.GetWhat()),
Unread: int(note.GetUnread()), Unread: int(note.GetUnread()),
Event: pbCallEventDeserialize(note.GetEvent()), Event: pbCallEventDeserialize(note.GetEvent()),
@@ -585,15 +585,15 @@ func pbGetQuerySerialize(in *MsgGetQuery) *pbx.GetQuery {
} }
if in.Data != nil { if in.Data != nil {
out.Data = &pbx.GetOpts{ out.Data = &pbx.GetOpts{
BeforeId: int32(in.Data.BeforeId), BeforeId: in.Data.BeforeId,
SinceId: int32(in.Data.SinceId), SinceId: in.Data.SinceId,
Limit: int32(in.Data.Limit), Limit: int32(in.Data.Limit),
} }
if len(in.Data.IdRanges) > 0 { if len(in.Data.IdRanges) > 0 {
out.Data.Ranges = make([]*pbx.SeqRange, len(in.Data.IdRanges)) out.Data.Ranges = make([]*pbx.SeqRange, len(in.Data.IdRanges))
for i, dq := range in.Data.IdRanges { for i, dq := range in.Data.IdRanges {
out.Data.Ranges[i] = &pbx.SeqRange{Low: int32(dq.LowId), Hi: int32(dq.HiId)} out.Data.Ranges[i] = &pbx.SeqRange{Low: dq.LowId, Hi: dq.HiId}
} }
} }
} }
@@ -623,16 +623,16 @@ func pbGetQueryDeserialize(in *pbx.GetQuery) *MsgGetQuery {
} }
if data := in.GetData(); data != nil { if data := in.GetData(); data != nil {
msg.Data = &MsgGetOpts{ msg.Data = &MsgGetOpts{
BeforeId: int(data.GetBeforeId()), BeforeId: data.GetBeforeId(),
SinceId: int(data.GetSinceId()), SinceId: data.GetSinceId(),
Limit: int(data.GetLimit()), Limit: int(data.GetLimit()),
} }
if ranges := data.GetRanges(); len(ranges) > 0 { if ranges := data.GetRanges(); len(ranges) > 0 {
msg.Data.IdRanges = make([]MsgRange, len(ranges)) msg.Data.IdRanges = make([]MsgRange, len(ranges))
for i, sr := range ranges { for i, sr := range ranges {
msg.Data.IdRanges[i].LowId = int(sr.GetLow()) msg.Data.IdRanges[i].LowId = sr.GetLow()
msg.Data.IdRanges[i].HiId = int(sr.GetHi()) msg.Data.IdRanges[i].HiId = sr.GetHi()
} }
} }
} }
@@ -890,9 +890,9 @@ func pbTopicDescSerialize(desc *MsgTopicDesc) *pbx.TopicDesc {
IsChan: desc.IsChan, IsChan: desc.IsChan,
Defacs: pbDefaultAcsSerialize(desc.DefaultAcs), Defacs: pbDefaultAcsSerialize(desc.DefaultAcs),
Acs: pbAccessModeSerialize(desc.Acs), Acs: pbAccessModeSerialize(desc.Acs),
SeqId: int32(desc.SeqId), SeqId: desc.SeqId,
ReadId: int32(desc.ReadSeqId), ReadId: desc.ReadSeqId,
RecvId: int32(desc.RecvSeqId), RecvId: desc.RecvSeqId,
DelId: int32(desc.DelId), DelId: int32(desc.DelId),
Public: interfaceToBytes(desc.Public), Public: interfaceToBytes(desc.Public),
Trusted: interfaceToBytes(desc.Trusted), Trusted: interfaceToBytes(desc.Trusted),
@@ -918,9 +918,9 @@ func pbTopicDescDeserialize(desc *pbx.TopicDesc) *MsgTopicDesc {
IsChan: desc.GetIsChan(), IsChan: desc.GetIsChan(),
DefaultAcs: pbDefaultAcsDeserialize(desc.GetDefacs()), DefaultAcs: pbDefaultAcsDeserialize(desc.GetDefacs()),
Acs: pbAccessModeDeserialize(desc.GetAcs()), Acs: pbAccessModeDeserialize(desc.GetAcs()),
SeqId: int(desc.SeqId), SeqId: desc.SeqId,
ReadSeqId: int(desc.ReadId), ReadSeqId: desc.ReadId,
RecvSeqId: int(desc.RecvId), RecvSeqId: desc.RecvId,
DelId: int(desc.DelId), DelId: int(desc.DelId),
Public: bytesToInterface(desc.Public), Public: bytesToInterface(desc.Public),
Trusted: bytesToInterface(desc.Trusted), Trusted: bytesToInterface(desc.Trusted),
@@ -947,7 +947,7 @@ func pbTopicSerializeToDesc(topic *Topic) *pbx.TopicDesc {
Auth: topic.accessAuth.String(), Auth: topic.accessAuth.String(),
Anon: topic.accessAnon.String(), Anon: topic.accessAnon.String(),
}, },
SeqId: int32(topic.lastID), SeqId: topic.lastID,
DelId: int32(topic.delID), DelId: int32(topic.delID),
Public: interfaceToBytes(topic.public), Public: interfaceToBytes(topic.public),
Trusted: interfaceToBytes(topic.trusted), Trusted: interfaceToBytes(topic.trusted),
@@ -972,15 +972,15 @@ func pbTopicSubSerialize(sub *MsgTopicSub) *pbx.TopicSub {
DeletedAt: timeToInt64(sub.DeletedAt), DeletedAt: timeToInt64(sub.DeletedAt),
Online: sub.Online, Online: sub.Online,
Acs: pbAccessModeSerialize(&sub.Acs), Acs: pbAccessModeSerialize(&sub.Acs),
ReadId: int32(sub.ReadSeqId), ReadId: sub.ReadSeqId,
RecvId: int32(sub.RecvSeqId), RecvId: sub.RecvSeqId,
Public: interfaceToBytes(sub.Public), Public: interfaceToBytes(sub.Public),
Trusted: interfaceToBytes(sub.Trusted), Trusted: interfaceToBytes(sub.Trusted),
Private: interfaceToBytes(sub.Private), Private: interfaceToBytes(sub.Private),
UserId: sub.User, UserId: sub.User,
Topic: sub.Topic, Topic: sub.Topic,
TouchedAt: timeToInt64(sub.TouchedAt), TouchedAt: timeToInt64(sub.TouchedAt),
SeqId: int32(sub.SeqId), SeqId: sub.SeqId,
DelId: int32(sub.DelId), DelId: int32(sub.DelId),
} }
if sub.LastSeen != nil { if sub.LastSeen != nil {
@@ -1001,15 +1001,15 @@ func pbTopicSubSliceDeserialize(subs []*pbx.TopicSub) []MsgTopicSub {
UpdatedAt: int64ToTime(subs[i].GetUpdatedAt()), UpdatedAt: int64ToTime(subs[i].GetUpdatedAt()),
DeletedAt: int64ToTime(subs[i].GetDeletedAt()), DeletedAt: int64ToTime(subs[i].GetDeletedAt()),
Online: subs[i].GetOnline(), Online: subs[i].GetOnline(),
ReadSeqId: int(subs[i].GetReadId()), ReadSeqId: subs[i].GetReadId(),
RecvSeqId: int(subs[i].GetRecvId()), RecvSeqId: subs[i].GetRecvId(),
Public: bytesToInterface(subs[i].GetPublic()), Public: bytesToInterface(subs[i].GetPublic()),
Trusted: bytesToInterface(subs[i].GetTrusted()), Trusted: bytesToInterface(subs[i].GetTrusted()),
Private: bytesToInterface(subs[i].GetPrivate()), Private: bytesToInterface(subs[i].GetPrivate()),
User: subs[i].GetUserId(), User: subs[i].GetUserId(),
Topic: subs[i].GetTopic(), Topic: subs[i].GetTopic(),
TouchedAt: int64ToTime(subs[i].GetTouchedAt()), TouchedAt: int64ToTime(subs[i].GetTouchedAt()),
SeqId: int(subs[i].GetSeqId()), SeqId: subs[i].GetSeqId(),
DelId: int(subs[i].GetDelId()), DelId: int(subs[i].GetDelId()),
} }
if acs := subs[i].GetAcs(); acs != nil { if acs := subs[i].GetAcs(); acs != nil {
@@ -1063,7 +1063,7 @@ func pbDelQuerySerialize(in []MsgRange) []*pbx.SeqRange {
out := make([]*pbx.SeqRange, len(in)) out := make([]*pbx.SeqRange, len(in))
for i, dq := range in { for i, dq := range in {
out[i] = &pbx.SeqRange{Low: int32(dq.LowId), Hi: int32(dq.HiId)} out[i] = &pbx.SeqRange{Low: dq.LowId, Hi: dq.HiId}
} }
return out return out
@@ -1076,8 +1076,8 @@ func pbDelQueryDeserialize(in []*pbx.SeqRange) []MsgRange {
out := make([]MsgRange, len(in)) out := make([]MsgRange, len(in))
for i, sr := range in { for i, sr := range in {
out[i].LowId = int(sr.GetLow()) out[i].LowId = sr.GetLow()
out[i].HiId = int(sr.GetHi()) out[i].HiId = sr.GetHi()
} }
return out return out
+2 -2
View File
@@ -577,8 +577,8 @@ func pluginSubscription(sub *types.Subscription, action int) {
UserId: sub.User, UserId: sub.User,
DelId: int32(sub.DelId), DelId: int32(sub.DelId),
ReadId: int32(sub.ReadSeqId), ReadId: sub.ReadSeqId,
RecvId: int32(sub.RecvSeqId), RecvId: sub.RecvSeqId,
Mode: &pbx.AccessMode{ Mode: &pbx.AccessMode{
Want: sub.ModeWant.String(), Want: sub.ModeWant.String(),
+5 -5
View File
@@ -12,7 +12,7 @@ import (
// presParams defines parameters for creating a presence notification. // presParams defines parameters for creating a presence notification.
type presParams struct { type presParams struct {
userAgent string userAgent string
seqID int seqID int64
delID int delID int
delSeq []MsgRange delSeq []MsgRange
@@ -483,7 +483,7 @@ func (t *Topic) presSubsOffline(what string, params *presParams,
// Publish {info what=read|recv|kp} to topic subscribers's sessions currently offline in the topic, // Publish {info what=read|recv|kp} to topic subscribers's sessions currently offline in the topic,
// on subscriber's 'me'. Group and P2P. // on subscriber's 'me'. Group and P2P.
func (t *Topic) infoSubsOffline(from types.Uid, what string, seq int, skipSid string) { func (t *Topic) infoSubsOffline(from types.Uid, what string, seq int64, skipSid string) {
user := from.UserId() user := from.UserId()
for uid, pud := range t.perUser { for uid, pud := range t.perUser {
@@ -509,7 +509,7 @@ func (t *Topic) infoSubsOffline(from types.Uid, what string, seq int, skipSid st
} }
// Publish {info what=call} to topic subscribers's sessions on subscriber's 'me'. // Publish {info what=call} to topic subscribers's sessions on subscriber's 'me'.
func (t *Topic) infoCallSubsOffline(from string, target types.Uid, event string, seq int, func (t *Topic) infoCallSubsOffline(from string, target types.Uid, event string, seq int64,
sdp json.RawMessage, skipSid string, offlineOnly bool) { sdp json.RawMessage, skipSid string, offlineOnly bool) {
if target.IsZero() { if target.IsZero() {
logs.Err.Printf("callSubs could not find target: topic %s - from %s", t.name, from) logs.Err.Printf("callSubs could not find target: topic %s - from %s", t.name, from)
@@ -672,9 +672,9 @@ func presSingleUserOfflineOffline(tenantID types.TenantID, uid types.Uid, origin
// Let other sessions of a given user know what messages are now received/read. // Let other sessions of a given user know what messages are now received/read.
// If both 'read' and 'recv' != 0 then 'read' takes precedence over 'recv'. // If both 'read' and 'recv' != 0 then 'read' takes precedence over 'recv'.
// Cases U // Cases U
func (t *Topic) presPubMessageCount(uid types.Uid, mode types.AccessMode, read, recv int, skip string) { func (t *Topic) presPubMessageCount(uid types.Uid, mode types.AccessMode, read, recv int64, skip string) {
var what string var what string
var seq int var seq int64
if read > 0 { if read > 0 {
what = "read" what = "read"
seq = read seq = read
+1 -1
View File
@@ -164,7 +164,7 @@ func pushForChanDelete(tenantID types.TenantID, topicName string, now time.Time)
} }
// Prepares payload to be delivered to a mobile device as a push notification in response to receiving "read" notification. // Prepares payload to be delivered to a mobile device as a push notification in response to receiving "read" notification.
func (t *Topic) pushForReadRcpt(uid types.Uid, seq int, now time.Time) *push.Receipt { func (t *Topic) pushForReadRcpt(uid types.Uid, seq int64, now time.Time) *push.Receipt {
// The `Topic` in the push receipt is `t.xoriginal` for group topics, `fromUid` for p2p topics, // The `Topic` in the push receipt is `t.xoriginal` for group topics, `fromUid` for p2p topics,
// not the t.original(fromUid) because it's the topic name as seen by the recipient, not by the sender. // not the t.original(fromUid) because it's the topic name as seen by the recipient, not by the sender.
topic := t.xoriginal topic := t.xoriginal
+2 -2
View File
@@ -39,7 +39,7 @@ func payloadToData(pl *push.Payload) (map[string]string, error) {
// Must use "xfrom" because "from" is a reserved word. Google did not bother to document it anywhere. // Must use "xfrom" because "from" is a reserved word. Google did not bother to document it anywhere.
data["xfrom"] = pl.From data["xfrom"] = pl.From
if pl.What == push.ActMsg { if pl.What == push.ActMsg {
data["seq"] = strconv.Itoa(pl.SeqId) data["seq"] = strconv.FormatInt(pl.SeqId, 10)
if pl.ContentType != "" { if pl.ContentType != "" {
data["mime"] = pl.ContentType data["mime"] = pl.ContentType
} }
@@ -81,7 +81,7 @@ func payloadToData(pl *push.Payload) (map[string]string, error) {
data["modeWant"] = pl.ModeWant.String() data["modeWant"] = pl.ModeWant.String()
data["modeGiven"] = pl.ModeGiven.String() data["modeGiven"] = pl.ModeGiven.String()
} else if pl.What == push.ActRead { } else if pl.What == push.ActRead {
data["seq"] = strconv.Itoa(pl.SeqId) data["seq"] = strconv.FormatInt(pl.SeqId, 10)
data["silent"] = "true" data["silent"] = "true"
} else { } else {
return nil, errors.New("unknown push type") return nil, errors.New("unknown push type")
+1 -1
View File
@@ -76,7 +76,7 @@ type Payload struct {
// Message sender 'usrXXX' // Message sender 'usrXXX'
From string `json:"from"` From string `json:"from"`
// Sequential ID of the message. // Sequential ID of the message.
SeqId int `json:"seq"` SeqId int64 `json:"seq"`
// MIME-Type of the message content, text/x-drafty or text/plain // MIME-Type of the message content, text/x-drafty or text/plain
ContentType string `json:"mime"` ContentType string `json:"mime"`
// Actual Data.Content of the message, if requested // Actual Data.Content of the message, if requested
+87
View File
@@ -0,0 +1,87 @@
package main
import (
"context"
"encoding/json"
"github.com/tinode/chat/server/logs"
"github.com/tinode/chat/server/store"
"github.com/tinode/chat/server/store/types"
)
func startRedisGroupFanout(ctx context.Context) {
if err := store.StartGroupFanoutSubscriber(ctx, currentNodeID(), func(raw []byte) {
var msg ServerComMessage
if err := json.Unmarshal(raw, &msg); err != nil {
logs.Warn.Println("redis group fanout: malformed message", err)
return
}
if !msg.TenantID.IsValid() || msg.RcptTo == "" || types.GetTopicCat(msg.RcptTo) != types.TopicCatGrp {
return
}
msg.sess = nil
deliverRedisGroupFanout(&msg)
}); err != nil {
logs.Warn.Println("redis group fanout: subscriber disabled", err)
}
}
func deliverRedisGroupFanout(msg *ServerComMessage) {
key := types.TopicKey{TenantID: msg.TenantID, Topic: msg.RcptTo}
if globals.hub.topicGet(key) != nil {
select {
case globals.hub.routeSrv <- msg:
default:
logs.Err.Println("redis group fanout: hub routeSrv queue full", msg.RcptTo)
}
return
}
delivered := 0
localMembers := redisGroupFanoutMembers(msg.TenantID, msg.RcptTo)
globals.sessionStore.Range(func(_ string, sess *Session) bool {
if sess == nil || sess.isCluster() || sess.isProxy() || sess.tenantID != msg.TenantID || sess.sid == msg.SkipSid {
return true
}
_, listedOnline := localMembers[sess.uid.UserId()]
if !listedOnline && !sess.hasSub(msg.RcptTo) {
return true
}
msgCopy := msg.copy()
prepareRedisGroupFanoutMessage(msgCopy, msg.RcptTo)
if sess.queueOut(msgCopy) {
delivered++
} else {
logs.Warn.Printf("redis group fanout: session queue full, sid=%s topic=%s", sess.sid, msg.RcptTo)
}
return true
})
if delivered == 0 {
logs.Info.Println("redis group fanout: no local subscribers for unloaded topic", msg.RcptTo)
}
}
func redisGroupFanoutMembers(tenantID types.TenantID, topic string) map[string]struct{} {
uids, err := store.GetTopicNodeOnlineMembers(tenantID, topic, currentNodeID())
if err != nil {
logs.Warn.Println("redis group fanout: failed to read local online members", topic, err)
return nil
}
members := make(map[string]struct{}, len(uids))
for _, uid := range uids {
members[uid.UserId()] = struct{}{}
}
return members
}
func prepareRedisGroupFanoutMessage(msg *ServerComMessage, topic string) {
switch {
case msg.Data != nil && msg.Data.Topic == "":
msg.Data.Topic = topic
case msg.Pres != nil && msg.Pres.Topic == "":
msg.Pres.Topic = topic
case msg.Info != nil && msg.Info.Topic == "":
msg.Info.Topic = topic
}
}
+63
View File
@@ -205,6 +205,32 @@ func (s *Session) getSub(topic string) *Subscription {
return s.subs[topic] return s.subs[topic]
} }
func (s *Session) hasSub(topic string) bool {
if s.multi != nil {
return s.multi.hasSub(topic)
}
s.subsLock.RLock()
defer s.subsLock.RUnlock()
_, ok := s.subs[topic]
return ok
}
func (s *Session) groupSubs() []string {
if s.multi != nil {
return s.multi.groupSubs()
}
s.subsLock.RLock()
defer s.subsLock.RUnlock()
topics := make([]string, 0, len(s.subs))
for topic := range s.subs {
if len(topic) >= 3 && types.GetTopicCat(topic) == types.TopicCatGrp {
topics = append(topics, topic)
}
}
return topics
}
func (s *Session) delSub(topic string) { func (s *Session) delSub(topic string) {
if s.multi != nil { if s.multi != nil {
s.multi.delSub(topic) s.multi.delSub(topic)
@@ -434,6 +460,11 @@ func (s *Session) purgeChannels() {
// cleanUp is called when the session is terminated to perform resource cleanup. // cleanUp is called when the session is terminated to perform resource cleanup.
func (s *Session) cleanUp(expired bool) { func (s *Session) cleanUp(expired bool) {
atomic.StoreInt32(&s.terminating, 1) atomic.StoreInt32(&s.terminating, 1)
if !s.uid.IsZero() && s.tenantID.IsValid() {
if err := store.MarkUserOffline(s.tenantID, s.uid, s.sid, currentNodeID()); err != nil {
logs.Warn.Println("s.cleanUp: failed to mark user offline", s.uid, err, s.sid)
}
}
s.purgeChannels() s.purgeChannels()
s.inflightReqs.Wait() s.inflightReqs.Wait()
s.inflightReqs = nil s.inflightReqs = nil
@@ -488,6 +519,16 @@ func (s *Session) dispatchRaw(raw []byte) {
func (s *Session) dispatch(msg *ClientComMessage) { func (s *Session) dispatch(msg *ClientComMessage) {
now := types.TimeNow() now := types.TimeNow()
atomic.StoreInt64(&s.lastAction, now.UnixNano()) atomic.StoreInt64(&s.lastAction, now.UnixNano())
if !s.uid.IsZero() && s.tenantID.IsValid() {
if err := store.RefreshUserPresence(s.tenantID, s.uid, s.sid, currentNodeID()); err != nil {
logs.Warn.Println("s.dispatch: failed to refresh user presence", s.uid, err, s.sid)
}
for _, topic := range s.groupSubs() {
if err := store.MarkTopicMemberOnline(s.tenantID, topic, s.uid, currentNodeID()); err != nil {
logs.Warn.Println("s.dispatch: failed to refresh group member presence", topic, s.uid, err, s.sid)
}
}
}
// This should be the first block here, before any other checks. // This should be the first block here, before any other checks.
var resp *ServerComMessage var resp *ServerComMessage
@@ -1200,6 +1241,9 @@ func (s *Session) onLogin(msgID string, timestamp time.Time, rec *auth.Rec, miss
// Authenticate the session. // Authenticate the session.
s.uid = rec.Uid s.uid = rec.Uid
s.authLvl = rec.AuthLevel s.authLvl = rec.AuthLevel
if err := store.MarkUserOnline(s.tenantID, s.uid, s.sid, currentNodeID(), s.platf, s.userAgent); err != nil {
logs.Warn.Println("s.onLogin: failed to mark user online", s.uid, err, s.sid)
}
// Reset expiration time. // Reset expiration time.
rec.Lifetime = 0 rec.Lifetime = 0
} }
@@ -1228,6 +1272,25 @@ func (s *Session) onLogin(msgID string, timestamp time.Time, rec *auth.Rec, miss
return reply return reply
} }
func currentNodeID() string {
if globals.cluster != nil && globals.cluster.thisNodeName != "" {
return globals.cluster.thisNodeName
}
return "local"
}
func redisUserOnline(tenantID types.TenantID, uid types.Uid, fallback bool) bool {
if !store.UserPresenceEnabled() || !tenantID.IsValid() || uid.IsZero() {
return fallback
}
presence, err := store.GetUserPresence(tenantID, uid)
if err != nil {
logs.Warn.Printf("presence[%s]: failed to read redis presence: %v", uid.UserId(), err)
return fallback
}
return presence.Online
}
func (s *Session) get(msg *ClientComMessage) { func (s *Session) get(msg *ClientComMessage) {
// Expand topic name. // Expand topic name.
var resp *ServerComMessage var resp *ServerComMessage
+85
View File
@@ -0,0 +1,85 @@
package store
import (
"sort"
"strings"
"sync"
"time"
"github.com/tinode/chat/server/store/types"
)
const configCacheTTL = time.Minute
type configCacheEntry[T any] struct {
value T
expiresAt time.Time
}
type configCache[T any] struct {
mu sync.RWMutex
items map[string]configCacheEntry[T]
}
func newConfigCache[T any]() *configCache[T] {
return &configCache[T]{items: make(map[string]configCacheEntry[T])}
}
func (c *configCache[T]) get(key string) (T, bool) {
var zero T
c.mu.RLock()
entry, ok := c.items[key]
c.mu.RUnlock()
if !ok || time.Now().After(entry.expiresAt) {
return zero, false
}
return entry.value, true
}
func (c *configCache[T]) set(key string, value T) {
c.mu.Lock()
c.items[key] = configCacheEntry[T]{value: value, expiresAt: time.Now().Add(configCacheTTL)}
c.mu.Unlock()
}
var (
tenantByCodeCache = newConfigCache[*types.Tenant]()
tenantByIDCache = newConfigCache[*types.Tenant]()
registrationSMSCache = newConfigCache[bool]()
thirdPartyConfigCache = newConfigCache[map[string]string]()
thirdPartyConfigAllKeys = "*"
)
func cloneTenant(t *types.Tenant) *types.Tenant {
if t == nil {
return nil
}
cp := *t
return &cp
}
func cloneStringMap(src map[string]string) map[string]string {
if src == nil {
return nil
}
dst := make(map[string]string, len(src))
for k, v := range src {
dst[k] = v
}
return dst
}
func thirdPartyCacheKey(group string, keys ...string) string {
group = strings.TrimSpace(group)
if len(keys) == 0 {
return group + ":" + thirdPartyConfigAllKeys
}
normalized := make([]string, 0, len(keys))
for _, key := range keys {
if key = strings.TrimSpace(key); key != "" {
normalized = append(normalized, key)
}
}
sort.Strings(normalized)
return group + ":" + strings.Join(normalized, ",")
}
+866
View File
@@ -0,0 +1,866 @@
package store
import (
"context"
"encoding/json"
"fmt"
"sort"
"strconv"
"strings"
"time"
"github.com/redis/go-redis/v9"
"github.com/tinode/chat/server/store/types"
)
type redisConfig struct {
Addr string `json:"addr"`
Username string `json:"username"`
Password string `json:"password"`
DB int `json:"db"`
KeyPrefix string `json:"key_prefix"`
DialMS int `json:"dial_timeout_ms"`
ReadMS int `json:"read_timeout_ms"`
WriteMS int `json:"write_timeout_ms"`
PendingTTL int `json:"pending_ttl_seconds"`
DoneTTL int `json:"done_ttl_seconds"`
FailedTTL int `json:"failed_ttl_seconds"`
HotMsgTTL int `json:"hot_message_ttl_seconds"`
PresenceTTL int `json:"presence_ttl_seconds"`
CacheTTL int `json:"cache_ttl_seconds"`
ShortTTL int `json:"short_state_ttl_seconds"`
GroupFanout bool `json:"group_fanout_enabled"`
}
type groupMessageUniqueRecord struct {
Status string `json:"status"`
Seq int64 `json:"seq,omitempty"`
MessageID string `json:"message_id,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
type groupFanoutEnvelope struct {
OriginNodeID string `json:"origin_node_id"`
Message json.RawMessage `json:"message"`
}
type groupMessageRuntime interface {
Close() error
Claim(tenantID types.TenantID, topic string, from types.Uid, clientMsgID string) (*groupMessageUniqueRecord, bool, error)
NextSeq(tenantID types.TenantID, topic string, baseline int64) (int64, error)
MarkDone(tenantID types.TenantID, topic string, from types.Uid, clientMsgID string, seq int64, msgID string) error
MarkFailed(tenantID types.TenantID, topic string, from types.Uid, clientMsgID string) error
CacheHotMessage(tenantID types.TenantID, msg *types.Message) error
GetHotMessages(tenantID types.TenantID, topic string, opts *types.QueryOpt, maxLimit int) ([]types.Message, bool, error)
InvalidateHotMessages(tenantID types.TenantID, topic string, ranges []types.Range) error
MarkUserOnline(tenantID types.TenantID, uid types.Uid, sid, nodeID, device, userAgent string) error
RefreshUserPresence(tenantID types.TenantID, uid types.Uid, sid, nodeID string) error
MarkUserOffline(tenantID types.TenantID, uid types.Uid, sid, nodeID string) error
GetUserPresence(tenantID types.TenantID, uid types.Uid) (*UserPresence, error)
MarkTopicMemberOnline(tenantID types.TenantID, topic string, uid types.Uid, nodeID string) error
MarkTopicMemberOffline(tenantID types.TenantID, topic string, uid types.Uid, nodeID string) error
GetTopicNodeOnlineMembers(tenantID types.TenantID, topic, nodeID string) ([]types.Uid, error)
PublishGroupFanout(originNodeID string, message any) error
StartGroupFanoutSubscriber(ctx context.Context, nodeID string, handler func([]byte)) error
CacheObject(tenantID types.TenantID, namespace, id string, value any) error
GetCachedObject(tenantID types.TenantID, namespace, id string, value any) (bool, error)
DeleteCachedObject(tenantID types.TenantID, namespace, id string) error
AddCachedSetMember(tenantID types.TenantID, namespace, id, member string) error
RemoveCachedSetMember(tenantID types.TenantID, namespace, id, member string) error
CachedSetContains(tenantID types.TenantID, namespace, id, member string) (bool, error)
AllowRate(tenantID types.TenantID, scope string, limit int, window time.Duration) (bool, int, time.Duration, error)
}
var groupMsgRuntime groupMessageRuntime
type UserPresence struct {
Online bool `json:"online"`
NodeID string `json:"node_id"`
SessionCount int `json:"session_count"`
LastActiveAt time.Time `json:"last_active_at"`
LastSeenAt time.Time `json:"last_seen_at"`
}
func MarkUserOnline(tenantID types.TenantID, uid types.Uid, sid, nodeID, device, userAgent string) error {
if groupMsgRuntime == nil || !tenantID.IsValid() || uid.IsZero() || sid == "" {
return nil
}
return groupMsgRuntime.MarkUserOnline(tenantID, uid, sid, nodeID, device, userAgent)
}
func RefreshUserPresence(tenantID types.TenantID, uid types.Uid, sid, nodeID string) error {
if groupMsgRuntime == nil || !tenantID.IsValid() || uid.IsZero() || sid == "" {
return nil
}
return groupMsgRuntime.RefreshUserPresence(tenantID, uid, sid, nodeID)
}
func MarkUserOffline(tenantID types.TenantID, uid types.Uid, sid, nodeID string) error {
if groupMsgRuntime == nil || !tenantID.IsValid() || uid.IsZero() || sid == "" {
return nil
}
return groupMsgRuntime.MarkUserOffline(tenantID, uid, sid, nodeID)
}
func GetUserPresence(tenantID types.TenantID, uid types.Uid) (*UserPresence, error) {
if groupMsgRuntime == nil || !tenantID.IsValid() || uid.IsZero() {
return &UserPresence{}, nil
}
return groupMsgRuntime.GetUserPresence(tenantID, uid)
}
func UserPresenceEnabled() bool {
return groupMsgRuntime != nil
}
func MarkTopicMemberOnline(tenantID types.TenantID, topic string, uid types.Uid, nodeID string) error {
if groupMsgRuntime == nil || !tenantID.IsValid() || topic == "" || uid.IsZero() {
return nil
}
return groupMsgRuntime.MarkTopicMemberOnline(tenantID, topic, uid, nodeID)
}
func MarkTopicMemberOffline(tenantID types.TenantID, topic string, uid types.Uid, nodeID string) error {
if groupMsgRuntime == nil || !tenantID.IsValid() || topic == "" || uid.IsZero() {
return nil
}
return groupMsgRuntime.MarkTopicMemberOffline(tenantID, topic, uid, nodeID)
}
func GetTopicNodeOnlineMembers(tenantID types.TenantID, topic, nodeID string) ([]types.Uid, error) {
if groupMsgRuntime == nil || !tenantID.IsValid() || topic == "" {
return nil, nil
}
return groupMsgRuntime.GetTopicNodeOnlineMembers(tenantID, topic, nodeID)
}
func PublishGroupFanout(originNodeID string, message any) error {
if groupMsgRuntime == nil || originNodeID == "" || message == nil {
return nil
}
return groupMsgRuntime.PublishGroupFanout(originNodeID, message)
}
func StartGroupFanoutSubscriber(ctx context.Context, nodeID string, handler func([]byte)) error {
if groupMsgRuntime == nil || nodeID == "" || handler == nil {
return nil
}
return groupMsgRuntime.StartGroupFanoutSubscriber(ctx, nodeID, handler)
}
func CacheObject(tenantID types.TenantID, namespace, id string, value any) error {
if groupMsgRuntime == nil || !tenantID.IsValid() || namespace == "" || id == "" || value == nil {
return nil
}
return groupMsgRuntime.CacheObject(tenantID, namespace, id, value)
}
func GetCachedObject(tenantID types.TenantID, namespace, id string, value any) (bool, error) {
if groupMsgRuntime == nil || !tenantID.IsValid() || namespace == "" || id == "" || value == nil {
return false, nil
}
return groupMsgRuntime.GetCachedObject(tenantID, namespace, id, value)
}
func DeleteCachedObject(tenantID types.TenantID, namespace, id string) error {
if groupMsgRuntime == nil || !tenantID.IsValid() || namespace == "" || id == "" {
return nil
}
return groupMsgRuntime.DeleteCachedObject(tenantID, namespace, id)
}
func AddCachedSetMember(tenantID types.TenantID, namespace, id, member string) error {
if groupMsgRuntime == nil || !tenantID.IsValid() || namespace == "" || id == "" || member == "" {
return nil
}
return groupMsgRuntime.AddCachedSetMember(tenantID, namespace, id, member)
}
func RemoveCachedSetMember(tenantID types.TenantID, namespace, id, member string) error {
if groupMsgRuntime == nil || !tenantID.IsValid() || namespace == "" || id == "" || member == "" {
return nil
}
return groupMsgRuntime.RemoveCachedSetMember(tenantID, namespace, id, member)
}
func CachedSetContains(tenantID types.TenantID, namespace, id, member string) (bool, error) {
if groupMsgRuntime == nil || !tenantID.IsValid() || namespace == "" || id == "" || member == "" {
return false, nil
}
return groupMsgRuntime.CachedSetContains(tenantID, namespace, id, member)
}
func AllowRate(tenantID types.TenantID, scope string, limit int, window time.Duration) (bool, int, time.Duration, error) {
if groupMsgRuntime == nil || !tenantID.IsValid() || scope == "" || limit <= 0 || window <= 0 {
return true, 0, 0, nil
}
return groupMsgRuntime.AllowRate(tenantID, scope, limit, window)
}
func initGroupMessageRuntime(raw json.RawMessage) error {
closeGroupMessageRuntime()
if len(raw) == 0 || string(raw) == "null" {
return nil
}
var cfg redisConfig
if err := json.Unmarshal(raw, &cfg); err != nil {
return err
}
cfg.Addr = strings.TrimSpace(cfg.Addr)
if cfg.Addr == "" {
return nil
}
if cfg.KeyPrefix == "" {
cfg.KeyPrefix = "im"
}
if cfg.PendingTTL <= 0 {
cfg.PendingTTL = 120
}
if cfg.DoneTTL <= 0 {
cfg.DoneTTL = 86400
}
if cfg.FailedTTL <= 0 {
cfg.FailedTTL = 600
}
if cfg.HotMsgTTL <= 0 {
cfg.HotMsgTTL = 86400
}
if cfg.PresenceTTL <= 0 {
cfg.PresenceTTL = 120
}
if cfg.CacheTTL <= 0 {
cfg.CacheTTL = 300
}
if cfg.ShortTTL <= 0 {
cfg.ShortTTL = 600
}
dialTimeout := timeoutOrDefault(cfg.DialMS, 2*time.Second)
readTimeout := timeoutOrDefault(cfg.ReadMS, 2*time.Second)
writeTimeout := timeoutOrDefault(cfg.WriteMS, 2*time.Second)
client := redis.NewClient(&redis.Options{
Addr: cfg.Addr,
Username: cfg.Username,
Password: cfg.Password,
DB: cfg.DB,
DialTimeout: dialTimeout,
ReadTimeout: readTimeout,
WriteTimeout: writeTimeout,
})
ctx, cancel := context.WithTimeout(context.Background(), dialTimeout)
defer cancel()
if err := client.Ping(ctx).Err(); err != nil {
_ = client.Close()
return err
}
groupMsgRuntime = &redisGroupMessageRuntime{client: client, prefix: cfg.KeyPrefix, cfg: cfg}
return nil
}
func closeGroupMessageRuntime() {
if groupMsgRuntime != nil {
_ = groupMsgRuntime.Close()
groupMsgRuntime = nil
}
}
func timeoutOrDefault(ms int, fallback time.Duration) time.Duration {
if ms <= 0 {
return fallback
}
return time.Duration(ms) * time.Millisecond
}
type redisGroupMessageRuntime struct {
client *redis.Client
prefix string
cfg redisConfig
}
func (r *redisGroupMessageRuntime) Close() error {
return r.client.Close()
}
func (r *redisGroupMessageRuntime) Claim(tenantID types.TenantID, topic string, from types.Uid, clientMsgID string) (*groupMessageUniqueRecord, bool, error) {
if clientMsgID == "" {
return nil, true, nil
}
key := r.uniqueKey(tenantID, topic, from, clientMsgID)
rec := &groupMessageUniqueRecord{Status: "pending", CreatedAt: time.Now().UTC()}
raw, _ := json.Marshal(rec)
ctx := context.Background()
ok, err := r.client.SetNX(ctx, key, raw, time.Duration(r.cfg.PendingTTL)*time.Second).Result()
if err != nil {
return nil, false, err
}
if ok {
return rec, true, nil
}
existing, err := r.client.Get(ctx, key).Bytes()
if err == redis.Nil {
return nil, false, nil
}
if err != nil {
return nil, false, err
}
var current groupMessageUniqueRecord
if err = json.Unmarshal(existing, &current); err != nil {
return nil, false, err
}
if current.Status == "failed" {
_ = r.client.Del(ctx, key).Err()
ok, err = r.client.SetNX(ctx, key, raw, time.Duration(r.cfg.PendingTTL)*time.Second).Result()
if err != nil {
return nil, false, err
}
if ok {
return rec, true, nil
}
}
return &current, false, nil
}
func (r *redisGroupMessageRuntime) NextSeq(tenantID types.TenantID, topic string, baseline int64) (int64, error) {
if baseline < 0 {
baseline = 0
}
seqKey := r.seqKey(tenantID, topic)
seq, err := r.client.Eval(context.Background(), `
if redis.call("EXISTS", KEYS[1]) == 0 then
redis.call("SET", KEYS[1], ARGV[1])
end
return redis.call("INCR", KEYS[1])
`, []string{seqKey}, baseline).Int64()
if err != nil {
return 0, err
}
return seq, nil
}
func (r *redisGroupMessageRuntime) MarkDone(tenantID types.TenantID, topic string, from types.Uid, clientMsgID string, seq int64, msgID string) error {
if clientMsgID == "" {
return nil
}
rec := &groupMessageUniqueRecord{Status: "done", Seq: seq, MessageID: msgID, CreatedAt: time.Now().UTC()}
raw, _ := json.Marshal(rec)
return r.client.Set(context.Background(), r.uniqueKey(tenantID, topic, from, clientMsgID), raw, time.Duration(r.cfg.DoneTTL)*time.Second).Err()
}
func (r *redisGroupMessageRuntime) MarkFailed(tenantID types.TenantID, topic string, from types.Uid, clientMsgID string) error {
if clientMsgID == "" {
return nil
}
rec := &groupMessageUniqueRecord{Status: "failed", CreatedAt: time.Now().UTC()}
raw, _ := json.Marshal(rec)
return r.client.Set(context.Background(), r.uniqueKey(tenantID, topic, from, clientMsgID), raw, time.Duration(r.cfg.FailedTTL)*time.Second).Err()
}
func (r *redisGroupMessageRuntime) CacheHotMessage(tenantID types.TenantID, msg *types.Message) error {
if msg == nil || msg.Topic == "" || msg.SeqId <= 0 {
return types.ErrMalformed
}
payload, err := json.Marshal(msg)
if err != nil {
return err
}
ctx := context.Background()
ttl := time.Duration(r.cfg.HotMsgTTL) * time.Second
seq := strconv.FormatInt(msg.SeqId, 10)
pipe := r.client.Pipeline()
pipe.Set(ctx, r.hotMsgKey(tenantID, msg.Topic, msg.SeqId), payload, ttl)
pipe.ZAdd(ctx, r.hotSeqKey(tenantID, msg.Topic), redis.Z{Score: float64(msg.SeqId), Member: seq})
pipe.Expire(ctx, r.hotSeqKey(tenantID, msg.Topic), ttl+time.Hour)
_, err = pipe.Exec(ctx)
return err
}
func (r *redisGroupMessageRuntime) GetHotMessages(tenantID types.TenantID, topic string, opts *types.QueryOpt, maxLimit int) ([]types.Message, bool, error) {
if topic == "" {
return nil, false, types.ErrMalformed
}
limit := maxLimit
if limit <= 0 {
limit = 100
}
if opts != nil && opts.Limit > 0 && opts.Limit < limit {
limit = opts.Limit
}
seqs, completeRange, err := r.hotSeqIDs(tenantID, topic, opts, limit)
if err != nil || len(seqs) == 0 || !completeRange {
return nil, false, err
}
keys := make([]string, 0, len(seqs))
for _, seq := range seqs {
keys = append(keys, r.hotMsgKey(tenantID, topic, seq))
}
values, err := r.client.MGet(context.Background(), keys...).Result()
if err != nil {
return nil, false, err
}
if len(values) != len(seqs) {
return nil, false, nil
}
msgs := make([]types.Message, 0, len(values))
for _, value := range values {
if value == nil {
return nil, false, nil
}
raw, ok := value.(string)
if !ok {
return nil, false, nil
}
var msg types.Message
if err := json.Unmarshal([]byte(raw), &msg); err != nil {
return nil, false, err
}
msg.TenantID = tenantID
msgs = append(msgs, msg)
}
return msgs, true, nil
}
func (r *redisGroupMessageRuntime) InvalidateHotMessages(tenantID types.TenantID, topic string, ranges []types.Range) error {
if topic == "" {
return types.ErrMalformed
}
seqs := expandRanges(ranges, int(^uint(0)>>1))
ctx := context.Background()
if len(seqs) == 0 {
pipe := r.client.Pipeline()
pipe.Del(ctx, r.hotSeqKey(tenantID, topic))
keys, err := r.client.Keys(ctx, r.hotMsgKey(tenantID, topic, "*")).Result()
if err != nil {
return err
}
if len(keys) > 0 {
pipe.Del(ctx, keys...)
}
_, err = pipe.Exec(ctx)
return err
}
pipe := r.client.Pipeline()
members := make([]any, 0, len(seqs))
for _, seq := range seqs {
pipe.Del(ctx, r.hotMsgKey(tenantID, topic, seq))
members = append(members, strconv.FormatInt(int64(seq), 10))
}
pipe.ZRem(ctx, r.hotSeqKey(tenantID, topic), members...)
_, err := pipe.Exec(ctx)
return err
}
func (r *redisGroupMessageRuntime) MarkUserOnline(tenantID types.TenantID, uid types.Uid, sid, nodeID, device, userAgent string) error {
if nodeID == "" {
nodeID = "local"
}
now := time.Now().UTC()
ttl := time.Duration(r.cfg.PresenceTTL) * time.Second
ctx := context.Background()
userKey := r.presenceUserKey(tenantID, uid)
sessionsKey := r.presenceSessionsKey(tenantID, uid)
sessionKey := r.presenceSessionKey(tenantID, uid, sid)
nodeKey := r.presenceNodeUsersKey(tenantID, nodeID)
member := nodeID + ":" + sid
pipe := r.client.Pipeline()
pipe.HSet(ctx, sessionKey, map[string]any{
"node_id": nodeID,
"session_id": sid,
"device": device,
"user_agent": userAgent,
"last_active_at": now.Format(time.RFC3339Nano),
})
pipe.Expire(ctx, sessionKey, ttl)
pipe.SAdd(ctx, sessionsKey, member)
pipe.Expire(ctx, sessionsKey, ttl)
pipe.SAdd(ctx, nodeKey, uid.UserId())
pipe.Expire(ctx, nodeKey, ttl)
pipe.HSet(ctx, userKey, map[string]any{
"online": "1",
"node_id": nodeID,
"last_active_at": now.Format(time.RFC3339Nano),
"last_seen_at": now.Format(time.RFC3339Nano),
})
pipe.Expire(ctx, userKey, ttl)
if _, err := pipe.Exec(ctx); err != nil {
return err
}
count, err := r.client.SCard(ctx, sessionsKey).Result()
if err != nil {
return err
}
return r.client.HSet(ctx, userKey, "session_count", count).Err()
}
func (r *redisGroupMessageRuntime) RefreshUserPresence(tenantID types.TenantID, uid types.Uid, sid, nodeID string) error {
if nodeID == "" {
nodeID = "local"
}
now := time.Now().UTC()
ttl := time.Duration(r.cfg.PresenceTTL) * time.Second
ctx := context.Background()
userKey := r.presenceUserKey(tenantID, uid)
sessionsKey := r.presenceSessionsKey(tenantID, uid)
sessionKey := r.presenceSessionKey(tenantID, uid, sid)
nodeKey := r.presenceNodeUsersKey(tenantID, nodeID)
pipe := r.client.Pipeline()
pipe.HSet(ctx, sessionKey, "last_active_at", now.Format(time.RFC3339Nano), "node_id", nodeID)
pipe.Expire(ctx, sessionKey, ttl)
pipe.Expire(ctx, sessionsKey, ttl)
pipe.SAdd(ctx, nodeKey, uid.UserId())
pipe.Expire(ctx, nodeKey, ttl)
pipe.HSet(ctx, userKey, "online", "1", "node_id", nodeID, "last_active_at", now.Format(time.RFC3339Nano), "last_seen_at", now.Format(time.RFC3339Nano))
pipe.Expire(ctx, userKey, ttl)
_, err := pipe.Exec(ctx)
return err
}
func (r *redisGroupMessageRuntime) MarkUserOffline(tenantID types.TenantID, uid types.Uid, sid, nodeID string) error {
if nodeID == "" {
nodeID = "local"
}
now := time.Now().UTC()
ttl := time.Duration(r.cfg.PresenceTTL) * time.Second
ctx := context.Background()
userKey := r.presenceUserKey(tenantID, uid)
sessionsKey := r.presenceSessionsKey(tenantID, uid)
sessionKey := r.presenceSessionKey(tenantID, uid, sid)
nodeKey := r.presenceNodeUsersKey(tenantID, nodeID)
member := nodeID + ":" + sid
pipe := r.client.Pipeline()
pipe.Del(ctx, sessionKey)
pipe.SRem(ctx, sessionsKey, member)
pipe.SRem(ctx, nodeKey, uid.UserId())
if _, err := pipe.Exec(ctx); err != nil {
return err
}
count, err := r.client.SCard(ctx, sessionsKey).Result()
if err != nil {
return err
}
if count > 0 {
pipe = r.client.Pipeline()
pipe.HSet(ctx, userKey, "online", "1", "session_count", count, "last_seen_at", now.Format(time.RFC3339Nano))
pipe.Expire(ctx, userKey, ttl)
pipe.Expire(ctx, sessionsKey, ttl)
_, err = pipe.Exec(ctx)
return err
}
pipe = r.client.Pipeline()
pipe.HSet(ctx, userKey, "online", "0", "session_count", 0, "node_id", nodeID, "last_seen_at", now.Format(time.RFC3339Nano))
pipe.Expire(ctx, userKey, ttl)
pipe.Del(ctx, sessionsKey)
_, err = pipe.Exec(ctx)
return err
}
func (r *redisGroupMessageRuntime) GetUserPresence(tenantID types.TenantID, uid types.Uid) (*UserPresence, error) {
raw, err := r.client.HGetAll(context.Background(), r.presenceUserKey(tenantID, uid)).Result()
if err != nil {
return nil, err
}
presence := &UserPresence{}
if len(raw) == 0 {
return presence, nil
}
presence.Online = raw["online"] == "1"
presence.NodeID = raw["node_id"]
presence.SessionCount, _ = strconv.Atoi(raw["session_count"])
presence.LastActiveAt, _ = time.Parse(time.RFC3339Nano, raw["last_active_at"])
presence.LastSeenAt, _ = time.Parse(time.RFC3339Nano, raw["last_seen_at"])
return presence, nil
}
func (r *redisGroupMessageRuntime) MarkTopicMemberOnline(tenantID types.TenantID, topic string, uid types.Uid, nodeID string) error {
if nodeID == "" {
nodeID = "local"
}
ttl := time.Duration(r.cfg.PresenceTTL) * time.Second
ctx := context.Background()
pipe := r.client.Pipeline()
pipe.SAdd(ctx, r.topicOnlineMembersKey(tenantID, topic), uid.UserId())
pipe.Expire(ctx, r.topicOnlineMembersKey(tenantID, topic), ttl)
pipe.SAdd(ctx, r.topicNodeOnlineMembersKey(tenantID, topic, nodeID), uid.UserId())
pipe.Expire(ctx, r.topicNodeOnlineMembersKey(tenantID, topic, nodeID), ttl)
_, err := pipe.Exec(ctx)
return err
}
func (r *redisGroupMessageRuntime) MarkTopicMemberOffline(tenantID types.TenantID, topic string, uid types.Uid, nodeID string) error {
if nodeID == "" {
nodeID = "local"
}
ctx := context.Background()
pipe := r.client.Pipeline()
pipe.SRem(ctx, r.topicOnlineMembersKey(tenantID, topic), uid.UserId())
pipe.SRem(ctx, r.topicNodeOnlineMembersKey(tenantID, topic, nodeID), uid.UserId())
_, err := pipe.Exec(ctx)
return err
}
func (r *redisGroupMessageRuntime) GetTopicNodeOnlineMembers(tenantID types.TenantID, topic, nodeID string) ([]types.Uid, error) {
if nodeID == "" {
nodeID = "local"
}
members, err := r.client.SMembers(context.Background(), r.topicNodeOnlineMembersKey(tenantID, topic, nodeID)).Result()
if err != nil {
return nil, err
}
uids := make([]types.Uid, 0, len(members))
for _, member := range members {
uid := types.ParseUserId(member)
if !uid.IsZero() {
uids = append(uids, uid)
}
}
return uids, nil
}
func (r *redisGroupMessageRuntime) PublishGroupFanout(originNodeID string, message any) error {
if !r.cfg.GroupFanout {
return nil
}
payload, err := json.Marshal(message)
if err != nil {
return err
}
envelope, err := json.Marshal(groupFanoutEnvelope{
OriginNodeID: originNodeID,
Message: payload,
})
if err != nil {
return err
}
return r.client.Publish(context.Background(), r.groupFanoutChannel(), envelope).Err()
}
func (r *redisGroupMessageRuntime) StartGroupFanoutSubscriber(ctx context.Context, nodeID string, handler func([]byte)) error {
if !r.cfg.GroupFanout {
return nil
}
pubsub := r.client.Subscribe(ctx, r.groupFanoutChannel())
if _, err := pubsub.Receive(ctx); err != nil {
_ = pubsub.Close()
return err
}
go func() {
defer pubsub.Close()
ch := pubsub.Channel()
for {
select {
case <-ctx.Done():
return
case msg, ok := <-ch:
if !ok {
return
}
var envelope groupFanoutEnvelope
if err := json.Unmarshal([]byte(msg.Payload), &envelope); err != nil || envelope.OriginNodeID == nodeID || len(envelope.Message) == 0 {
continue
}
handler(envelope.Message)
}
}
}()
return nil
}
func (r *redisGroupMessageRuntime) CacheObject(tenantID types.TenantID, namespace, id string, value any) error {
payload, err := json.Marshal(value)
if err != nil {
return err
}
return r.client.Set(context.Background(), r.objectCacheKey(tenantID, namespace, id), payload, r.cacheTTL(namespace)).Err()
}
func (r *redisGroupMessageRuntime) GetCachedObject(tenantID types.TenantID, namespace, id string, value any) (bool, error) {
payload, err := r.client.Get(context.Background(), r.objectCacheKey(tenantID, namespace, id)).Bytes()
if err == redis.Nil {
return false, nil
}
if err != nil {
return false, err
}
if err = json.Unmarshal(payload, value); err != nil {
_ = r.client.Del(context.Background(), r.objectCacheKey(tenantID, namespace, id)).Err()
return false, err
}
return true, nil
}
func (r *redisGroupMessageRuntime) DeleteCachedObject(tenantID types.TenantID, namespace, id string) error {
return r.client.Del(context.Background(), r.objectCacheKey(tenantID, namespace, id)).Err()
}
func (r *redisGroupMessageRuntime) AddCachedSetMember(tenantID types.TenantID, namespace, id, member string) error {
key := r.setCacheKey(tenantID, namespace, id)
ctx := context.Background()
pipe := r.client.Pipeline()
pipe.SAdd(ctx, key, member)
pipe.Expire(ctx, key, r.cacheTTL(namespace))
_, err := pipe.Exec(ctx)
return err
}
func (r *redisGroupMessageRuntime) RemoveCachedSetMember(tenantID types.TenantID, namespace, id, member string) error {
key := r.setCacheKey(tenantID, namespace, id)
ctx := context.Background()
pipe := r.client.Pipeline()
pipe.SRem(ctx, key, member)
pipe.Expire(ctx, key, r.cacheTTL(namespace))
_, err := pipe.Exec(ctx)
return err
}
func (r *redisGroupMessageRuntime) CachedSetContains(tenantID types.TenantID, namespace, id, member string) (bool, error) {
return r.client.SIsMember(context.Background(), r.setCacheKey(tenantID, namespace, id), member).Result()
}
func (r *redisGroupMessageRuntime) AllowRate(tenantID types.TenantID, scope string, limit int, window time.Duration) (bool, int, time.Duration, error) {
key := r.rateKey(tenantID, scope)
ctx := context.Background()
count, err := r.client.Incr(ctx, key).Result()
if err != nil {
return true, 0, 0, err
}
if count == 1 {
if err = r.client.Expire(ctx, key, window).Err(); err != nil {
return true, int(count), 0, err
}
}
ttl, _ := r.client.TTL(ctx, key).Result()
return count <= int64(limit), int(count), ttl, nil
}
func (r *redisGroupMessageRuntime) hotSeqIDs(tenantID types.TenantID, topic string, opts *types.QueryOpt, limit int) ([]int64, bool, error) {
if opts != nil && len(opts.IdRanges) > 0 {
seqs := expandRanges(opts.IdRanges, limit)
sort.Slice(seqs, func(i, j int) bool { return seqs[i] > seqs[j] })
return seqs, len(seqs) > 0 && len(seqs) <= limit, nil
}
min := "-inf"
max := "+inf"
if opts != nil {
if opts.Since > 0 {
min = strconv.FormatInt(opts.Since, 10)
}
if opts.Before > 1 {
max = strconv.FormatInt(opts.Before-1, 10)
}
}
raw, err := r.client.ZRevRangeByScore(context.Background(), r.hotSeqKey(tenantID, topic), &redis.ZRangeBy{
Min: min,
Max: max,
Offset: 0,
Count: int64(limit),
}).Result()
if err != nil {
return nil, false, err
}
if len(raw) < limit {
return nil, false, nil
}
seqs := make([]int64, 0, len(raw))
for _, value := range raw {
seq, err := strconv.ParseInt(value, 10, 64)
if err != nil {
return nil, false, err
}
seqs = append(seqs, seq)
}
return seqs, true, nil
}
func expandRanges(ranges []types.Range, limit int) []int64 {
if limit <= 0 {
return nil
}
seqs := make([]int64, 0)
for _, rng := range ranges {
if rng.Low <= 0 {
continue
}
if rng.Hi == 0 {
seqs = append(seqs, rng.Low)
} else {
for seq := rng.Low; seq < rng.Hi; seq++ {
seqs = append(seqs, seq)
if len(seqs) >= limit {
return seqs
}
}
}
if len(seqs) >= limit {
return seqs
}
}
return seqs
}
func (r *redisGroupMessageRuntime) seqKey(tenantID types.TenantID, topic string) string {
return r.key(tenantID, "topic", topic, "last_seq")
}
func (r *redisGroupMessageRuntime) uniqueKey(tenantID types.TenantID, topic string, from types.Uid, clientMsgID string) string {
return r.key(tenantID, "topic", topic, "uniq", "msg", from.UserId(), clientMsgID)
}
func (r *redisGroupMessageRuntime) hotSeqKey(tenantID types.TenantID, topic string) string {
return r.key(tenantID, "topic", topic, "hot_msg_seqs")
}
func (r *redisGroupMessageRuntime) hotMsgKey(tenantID types.TenantID, topic string, seq any) string {
return r.key(tenantID, "topic", topic, "hot_msg", fmt.Sprint(seq))
}
func (r *redisGroupMessageRuntime) presenceUserKey(tenantID types.TenantID, uid types.Uid) string {
return r.key(tenantID, "presence", "user", uid.UserId())
}
func (r *redisGroupMessageRuntime) presenceSessionsKey(tenantID types.TenantID, uid types.Uid) string {
return r.key(tenantID, "presence", "user", uid.UserId(), "sessions")
}
func (r *redisGroupMessageRuntime) presenceSessionKey(tenantID types.TenantID, uid types.Uid, sid string) string {
return r.key(tenantID, "presence", "user", uid.UserId(), "session", sid)
}
func (r *redisGroupMessageRuntime) presenceNodeUsersKey(tenantID types.TenantID, nodeID string) string {
return r.key(tenantID, "presence", "node", nodeID, "users")
}
func (r *redisGroupMessageRuntime) topicOnlineMembersKey(tenantID types.TenantID, topic string) string {
return r.key(tenantID, "topic", topic, "online_members")
}
func (r *redisGroupMessageRuntime) topicNodeOnlineMembersKey(tenantID types.TenantID, topic, nodeID string) string {
return r.key(tenantID, "topic", topic, "node", nodeID, "online_members")
}
func (r *redisGroupMessageRuntime) groupFanoutChannel() string {
return r.key(types.ZeroTenantID, "group_fanout")
}
func (r *redisGroupMessageRuntime) objectCacheKey(tenantID types.TenantID, namespace, id string) string {
return r.key(tenantID, "cache", namespace, id)
}
func (r *redisGroupMessageRuntime) setCacheKey(tenantID types.TenantID, namespace, id string) string {
return r.key(tenantID, "set", namespace, id)
}
func (r *redisGroupMessageRuntime) rateKey(tenantID types.TenantID, scope string) string {
return r.key(tenantID, "rate", scope)
}
func (r *redisGroupMessageRuntime) cacheTTL(namespace string) time.Duration {
ttl := r.cfg.CacheTTL
if namespace == "verification" || namespace == "action_token" {
ttl = r.cfg.ShortTTL
}
return time.Duration(ttl) * time.Second
}
func (r *redisGroupMessageRuntime) key(tenantID types.TenantID, parts ...string) string {
all := []string{strings.TrimSuffix(r.prefix, ":"), strconv.FormatInt(int64(tenantID), 10)}
all = append(all, parts...)
return strings.Join(all, ":")
}
+3 -3
View File
@@ -1114,7 +1114,7 @@ func (mr *MockMessagesPersistenceInterfaceMockRecorder) DeleteList(tenantID, top
} }
// Edit mocks base method. // Edit mocks base method.
func (m *MockMessagesPersistenceInterface) Edit(tenantID types.TenantID, topic string, seq int, actor types.Uid, expectedVersion int, head types.KVMap, content any, reason string, editedAt time.Time) (*types.Message, error) { func (m *MockMessagesPersistenceInterface) Edit(tenantID types.TenantID, topic string, seq int64, actor types.Uid, expectedVersion int, head types.KVMap, content any, reason string, editedAt time.Time) (*types.Message, error) {
m.ctrl.T.Helper() m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "Edit", tenantID, topic, seq, actor, expectedVersion, head, content, reason, editedAt) ret := m.ctrl.Call(m, "Edit", tenantID, topic, seq, actor, expectedVersion, head, content, reason, editedAt)
ret0, _ := ret[0].(*types.Message) ret0, _ := ret[0].(*types.Message)
@@ -1176,7 +1176,7 @@ func (mr *MockMessagesPersistenceInterfaceMockRecorder) Search(tenantID, topic,
} }
// GetOne mocks base method. // GetOne mocks base method.
func (m *MockMessagesPersistenceInterface) GetOne(tenantID types.TenantID, topic string, seq int) (*types.Message, error) { func (m *MockMessagesPersistenceInterface) GetOne(tenantID types.TenantID, topic string, seq int64) (*types.Message, error) {
m.ctrl.T.Helper() m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "GetOne", tenantID, topic, seq) ret := m.ctrl.Call(m, "GetOne", tenantID, topic, seq)
ret0, _ := ret[0].(*types.Message) ret0, _ := ret[0].(*types.Message)
@@ -1191,7 +1191,7 @@ func (mr *MockMessagesPersistenceInterfaceMockRecorder) GetOne(tenantID, topic,
} }
// Recall mocks base method. // Recall mocks base method.
func (m *MockMessagesPersistenceInterface) Recall(tenantID types.TenantID, topic string, seq int, actor types.Uid, expectedVersion int, recalledAt time.Time) (*types.Message, error) { func (m *MockMessagesPersistenceInterface) Recall(tenantID types.TenantID, topic string, seq int64, actor types.Uid, expectedVersion int, recalledAt time.Time) (*types.Message, error) {
m.ctrl.T.Helper() m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "Recall", tenantID, topic, seq, actor, expectedVersion, recalledAt) ret := m.ctrl.Call(m, "Recall", tenantID, topic, seq, actor, expectedVersion, recalledAt)
ret0, _ := ret[0].(*types.Message) ret0, _ := ret[0].(*types.Message)
+645 -47
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -16,7 +16,7 @@ type CallRecord struct {
TenantID TenantID `db:"tenant_id" json:"-" bson:"tenant_id"` TenantID TenantID `db:"tenant_id" json:"-" bson:"tenant_id"`
CallID string `db:"call_id"` CallID string `db:"call_id"`
Topic string Topic string
SeqID int `db:"seq_id"` SeqID int64 `db:"seq_id"`
CallerID Uid `db:"caller_id"` CallerID Uid `db:"caller_id"`
CalleeID Uid `db:"callee_id"` CalleeID Uid `db:"callee_id"`
CallType string `db:"call_type"` CallType string `db:"call_type"`
+1 -1
View File
@@ -9,7 +9,7 @@ type FavoriteItem struct {
UserID Uid `db:"user_id"` UserID Uid `db:"user_id"`
ItemType string `db:"item_type"` ItemType string `db:"item_type"`
SourceTopic string `db:"source_topic"` SourceTopic string `db:"source_topic"`
SourceSeq int `db:"source_seq"` SourceSeq int64 `db:"source_seq"`
Snapshot any Snapshot any
AttachmentRefs any `db:"attachment_refs"` AttachmentRefs any `db:"attachment_refs"`
Availability string `db:"availability"` Availability string `db:"availability"`
+1 -1
View File
@@ -187,7 +187,7 @@ type GroupFile struct {
Topic string `db:"topic"` Topic string `db:"topic"`
FileID string `db:"file_id"` FileID string `db:"file_id"`
SourceTopic string `db:"source_topic"` SourceTopic string `db:"source_topic"`
SourceSeq int `db:"source_seq"` SourceSeq int64 `db:"source_seq"`
UploaderID Uid `db:"-"` UploaderID Uid `db:"-"`
FileName string `db:"file_name"` FileName string `db:"file_name"`
MIMEType string `db:"mime_type"` MIMEType string `db:"mime_type"`
+1 -1
View File
@@ -63,7 +63,7 @@ type AbuseReport struct {
TargetType string TargetType string
TargetUserID Uid TargetUserID Uid
TargetTopic string TargetTopic string
TargetSeq int TargetSeq int64
ReasonCode string ReasonCode string
Description string Description string
EvidenceFileIDs []string EvidenceFileIDs []string
+14 -14
View File
@@ -922,9 +922,9 @@ type Subscription struct {
// ID of the latest Soft-delete operation // ID of the latest Soft-delete operation
DelId int DelId int
// Last SeqId reported by user as received by at least one of his sessions // Last SeqId reported by user as received by at least one of his sessions
RecvSeqId int RecvSeqId int64
// Last SeqID reported read by the user // Last SeqID reported read by the user
ReadSeqId int ReadSeqId int64
// Access mode requested by this user // Access mode requested by this user
ModeWant AccessMode ModeWant AccessMode
@@ -941,7 +941,7 @@ type Subscription struct {
// In case of P2P topics this is the Trusted value of the other user. // In case of P2P topics this is the Trusted value of the other user.
trusted any trusted any
// deserialized SeqID from user or topic // deserialized SeqID from user or topic
seqId int seqId int64
// Deserialized TouchedAt from topic // Deserialized TouchedAt from topic
touchedAt time.Time touchedAt time.Time
// Timestamp & user agent of when the user was last online. // Timestamp & user agent of when the user was last online.
@@ -1013,12 +1013,12 @@ func (s *Subscription) LastModified() time.Time {
} }
// GetSeqId returns seqId. // GetSeqId returns seqId.
func (s *Subscription) GetSeqId() int { func (s *Subscription) GetSeqId() int64 {
return s.seqId return s.seqId
} }
// SetSeqId sets seqId field. // SetSeqId sets seqId field.
func (s *Subscription) SetSeqId(id int) { func (s *Subscription) SetSeqId(id int64) {
s.seqId = id s.seqId = id
} }
@@ -1144,7 +1144,7 @@ type Topic struct {
Access DefaultAccess Access DefaultAccess
// Server-issued sequential ID // Server-issued sequential ID
SeqId int SeqId int64
// If messages were deleted, sequential id of the last operation to delete them // If messages were deleted, sequential id of the last operation to delete them
DelId int DelId int
@@ -1239,7 +1239,7 @@ type Message struct {
DelId int `json:"DelId,omitempty" bson:",omitempty"` DelId int `json:"DelId,omitempty" bson:",omitempty"`
// List of users who have marked this message as soft-deleted // List of users who have marked this message as soft-deleted
DeletedFor []SoftDelete `json:"DeletedFor,omitempty" bson:",omitempty"` DeletedFor []SoftDelete `json:"DeletedFor,omitempty" bson:",omitempty"`
SeqId int SeqId int64
Topic string Topic string
// Sender's user ID as string (without 'usr' prefix), could be empty. // Sender's user ID as string (without 'usr' prefix), could be empty.
From string From string
@@ -1256,7 +1256,7 @@ type MessageRevision struct {
ObjHeader `bson:",inline"` ObjHeader `bson:",inline"`
TenantID TenantID `db:"tenant_id" json:"-" bson:"tenant_id"` TenantID TenantID `db:"tenant_id" json:"-" bson:"tenant_id"`
Topic string Topic string
SeqId int SeqId int64
Version int Version int
Actor string Actor string
Head KVMap `json:"Head,omitempty" bson:",omitempty"` Head KVMap `json:"Head,omitempty" bson:",omitempty"`
@@ -1266,7 +1266,7 @@ type MessageRevision struct {
// MessageSaveResult describes the outcome of a message persistence attempt. // MessageSaveResult describes the outcome of a message persistence attempt.
type MessageSaveResult struct { type MessageSaveResult struct {
SeqId int SeqId int64
MarkedReadBySender bool MarkedReadBySender bool
Duplicate bool Duplicate bool
} }
@@ -1274,8 +1274,8 @@ type MessageSaveResult struct {
// Range is a range of message SeqIDs. Low end is inclusive (closed), high end is exclusive (open): [Low, Hi). // Range is a range of message SeqIDs. Low end is inclusive (closed), high end is exclusive (open): [Low, Hi).
// If the range contains just one ID, Hi is set to 0 // If the range contains just one ID, Hi is set to 0
type Range struct { type Range struct {
Low int Low int64
Hi int `json:"Hi,omitempty" bson:",omitempty"` Hi int64 `json:"Hi,omitempty" bson:",omitempty"`
} }
// RangeSorter is a helper type required by 'sort' package. // RangeSorter is a helper type required by 'sort' package.
@@ -1334,7 +1334,7 @@ func (rs RangeSorter) Normalize() RangeSorter {
// Convert a slice of int values to a slice of ranges. // Convert a slice of int values to a slice of ranges.
// The int slice must be sorted low -> high. // The int slice must be sorted low -> high.
func SliceToRanges(in []int) []Range { func SliceToRanges(in []int64) []Range {
if len(in) == 0 { if len(in) == 0 {
return nil return nil
} }
@@ -1390,8 +1390,8 @@ type QueryOpt struct {
Topic string Topic string
IfModifiedSince *time.Time IfModifiedSince *time.Time
// ID-based query parameters: Messages // ID-based query parameters: Messages
Since int Since int64
Before int Before int64
// Common parameter // Common parameter
Limit int Limit int
// Ranges of IDs. // Ranges of IDs.
+18
View File
@@ -85,6 +85,24 @@
"conn_max_lifetime": 60, "conn_max_lifetime": 60,
"sql_timeout": 10 "sql_timeout": 10
} }
},
"redis": {
"addr": "64.118.137.182:56379",
"username": "",
"password": "HMTJzkm.h>Y+E9ep",
"db": 0,
"key_prefix": "im",
"dial_timeout_ms": 2000,
"read_timeout_ms": 2000,
"write_timeout_ms": 2000,
"pending_ttl_seconds": 120,
"done_ttl_seconds": 86400,
"failed_ttl_seconds": 600,
"hot_message_ttl_seconds": 86400,
"presence_ttl_seconds": 120,
"cache_ttl_seconds": 300,
"short_state_ttl_seconds": 600,
"group_fanout_enabled": true
} }
}, },
+47 -14
View File
@@ -45,7 +45,7 @@ type Topic struct {
touched time.Time touched time.Time
// Server-side ID of the last data message // Server-side ID of the last data message
lastID int lastID int64
// ID of the deletion operation. Not an ID of the message. // ID of the deletion operation. Not an ID of the message.
delID int delID int
@@ -142,8 +142,8 @@ type perUserData struct {
online int online int
// Last t.lastId reported by user through {pres} as received or read // Last t.lastId reported by user through {pres} as received or read
recvID int recvID int64
readID int readID int64
// ID of the latest Delete operation // ID of the latest Delete operation
delID int delID int
@@ -646,14 +646,14 @@ func (t *Topic) handleServerMsg(msg *ServerComMessage) {
logs.Warn.Printf("topic[%s]: rejected cross-tenant server message: topic=%d message=%d", t.name, t.tenantID, msg.TenantID) logs.Warn.Printf("topic[%s]: rejected cross-tenant server message: topic=%d message=%d", t.name, t.tenantID, msg.TenantID)
return return
} }
// Server-generated message: {info} or {pres}. // Server-generated message: {data}, {info} or {pres}.
if t.isInactive() { if t.isInactive() {
// Ignore message - the topic is paused or being deleted. // Ignore message - the topic is paused or being deleted.
return return
} }
if msg.Pres != nil { if msg.Pres != nil {
t.handlePresence(msg) t.handlePresence(msg)
} else if msg.Info != nil { } else if msg.Info != nil || msg.Data != nil {
t.broadcastToSessions(msg) t.broadcastToSessions(msg)
} else { } else {
// TODO(gene): maybe remove this panic. // TODO(gene): maybe remove this panic.
@@ -797,6 +797,11 @@ func (t *Topic) handleLeaveRequest(msg *ClientComMessage, sess *Session) {
if !sess.background { if !sess.background {
pud.online-- pud.online--
t.perUser[uid] = pud t.perUser[uid] = pud
if t.cat == types.TopicCatGrp && pud.online == 0 {
if err := store.MarkTopicMemberOffline(t.tenantID, t.name, uid, currentNodeID()); err != nil {
logs.Warn.Printf("topic[%s]: failed to mark member offline in redis: %v", t.name, err)
}
}
} }
} else if len(pssd.muids) > 0 { } else if len(pssd.muids) > 0 {
// UID is zero: multiplexing session is dropped altogether. // UID is zero: multiplexing session is dropped altogether.
@@ -805,6 +810,11 @@ func (t *Topic) handleLeaveRequest(msg *ClientComMessage, sess *Session) {
pud := t.perUser[uid] pud := t.perUser[uid]
pud.online-- pud.online--
t.perUser[uid] = pud t.perUser[uid] = pud
if t.cat == types.TopicCatGrp && pud.online == 0 {
if err := store.MarkTopicMemberOffline(t.tenantID, t.name, uid, currentNodeID()); err != nil {
logs.Warn.Printf("topic[%s]: failed to mark member offline in redis: %v", t.name, err)
}
}
} }
} else if !sess.isCluster() { } else if !sess.isCluster() {
logs.Warn.Panic("cannot determine uid: leave req", msg, sess) logs.Warn.Panic("cannot determine uid: leave req", msg, sess)
@@ -1119,8 +1129,11 @@ func (t *Topic) saveAndBroadcastMessage(msg *ClientComMessage, asUid types.Uid,
return nil return nil
} }
markedReadBySender := saveResult != nil && saveResult.MarkedReadBySender markedReadBySender := saveResult != nil && saveResult.MarkedReadBySender
if saveResult != nil && saveResult.SeqId > t.lastID {
t.lastID++ t.lastID = saveResult.SeqId
} else {
t.lastID++
}
t.touched = msg.Timestamp t.touched = msg.Timestamp
if userFound { if userFound {
@@ -1163,6 +1176,11 @@ func (t *Topic) saveAndBroadcastMessage(msg *ClientComMessage, asUid types.Uid,
pluginMessage(t.tenantID, data.Data, plgActCreate) pluginMessage(t.tenantID, data.Data, plgActCreate)
t.broadcastToSessions(data) t.broadcastToSessions(data)
if t.cat == types.TopicCatGrp {
if err := store.PublishGroupFanout(currentNodeID(), data); err != nil {
logs.Warn.Printf("topic[%s]: failed to publish redis group fanout: %v", t.name, err)
}
}
// sendPush will update unread message count and send push notification. // sendPush will update unread message count and send push notification.
if pushRcpt := t.pushForData(asUid, data.Data, markedReadBySender); pushRcpt != nil { if pushRcpt := t.pushForData(asUid, data.Data, markedReadBySender); pushRcpt != nil {
@@ -1332,7 +1350,7 @@ func (t *Topic) handleNoteBroadcast(msg *ClientComMessage) {
return return
} }
var read, recv, unread, seq int var read, recv, unread, seq int64
switch msg.Note.What { switch msg.Note.What {
case "read": case "read":
@@ -1593,8 +1611,14 @@ func (t *Topic) subscriptionReply(asChan bool, msg *ClientComMessage) error {
// The user is online in the topic. Increment the counter if notifications are not deferred. // The user is online in the topic. Increment the counter if notifications are not deferred.
if !msg.sess.background { if !msg.sess.background {
userData := t.perUser[asUid] userData := t.perUser[asUid]
wasOffline := userData.online == 0
userData.online++ userData.online++
t.perUser[asUid] = userData t.perUser[asUid] = userData
if t.cat == types.TopicCatGrp && wasOffline {
if err := store.MarkTopicMemberOnline(t.tenantID, t.name, asUid, currentNodeID()); err != nil {
logs.Warn.Printf("topic[%s]: failed to mark member online in redis: %v", t.name, err)
}
}
} }
if t.cat == types.TopicCatGrp && msgsub.Newsub { if t.cat == types.TopicCatGrp && msgsub.Newsub {
@@ -2342,7 +2366,7 @@ func (t *Topic) replyGetDesc(sess *Session, asUid types.Uid, _ bool, opts *MsgGe
// t.delID <= pud.delID; t.readID <= t.recvID <= t.lastID // t.delID <= pud.delID; t.readID <= t.recvID <= t.lastID
desc.DelId = max(pud.delID, t.delID) desc.DelId = max(pud.delID, t.delID)
desc.ReadSeqId = pud.readID desc.ReadSeqId = pud.readID
desc.RecvSeqId = max(pud.recvID, pud.readID) desc.RecvSeqId = max64(pud.recvID, pud.readID)
} else { } else {
// Send some sane value of touched. // Send some sane value of touched.
desc.TouchedAt = &t.updated desc.TouchedAt = &t.updated
@@ -2755,12 +2779,18 @@ func (t *Topic) replyGetSub(sess *Session, asUid types.Uid, authLevel auth.Level
mts.Topic = with mts.Topic = with
canViewLastSeen = imCanViewLastSeen(t.tenantID, asUid, types.ParseUserId(with)) canViewLastSeen = imCanViewLastSeen(t.tenantID, asUid, types.ParseUserId(with))
mts.Online = t.perSubs[with].online && !deleted && presencer mts.Online = t.perSubs[with].online && !deleted && presencer
if !deleted && presencer {
mts.Online = redisUserOnline(t.tenantID, types.ParseUserId(with), mts.Online)
}
} else if strings.HasPrefix(sub.Topic, "slf") { } else if strings.HasPrefix(sub.Topic, "slf") {
mts.Topic = "slf" mts.Topic = "slf"
// Not reporting Online as it makes no sense for slf. // Not reporting Online as it makes no sense for slf.
} else { } else {
mts.Topic = sub.Topic mts.Topic = sub.Topic
mts.Online = t.perSubs[sub.Topic].online && !deleted && presencer mts.Online = t.perSubs[sub.Topic].online && !deleted && presencer
if !deleted && presencer && strings.HasPrefix(sub.Topic, "usr") {
mts.Online = redisUserOnline(t.tenantID, types.ParseUserId(sub.Topic), mts.Online)
}
} }
if !deleted && !banned { if !deleted && !banned {
@@ -2808,6 +2838,9 @@ func (t *Topic) replyGetSub(sess *Session, asUid types.Uid, authLevel auth.Level
if t.cat == types.TopicCatGrp { if t.cat == types.TopicCatGrp {
pud := t.perUser[uid] pud := t.perUser[uid]
mts.Online = pud.online > 0 && presencer mts.Online = pud.online > 0 && presencer
if presencer {
mts.Online = redisUserOnline(t.tenantID, uid, mts.Online)
}
} }
} }
} }
@@ -3392,7 +3425,7 @@ func (t *Topic) replyDelMsg(sess *Session, asUid types.Uid, asChan bool, msg *Cl
if dq.HiId == 0 { if dq.HiId == 0 {
count++ count++
} else { } else {
count += dq.HiId - dq.LowId count += int(dq.HiId - dq.LowId)
} }
ranges = append(ranges, types.Range{Low: dq.LowId, Hi: dq.HiId}) ranges = append(ranges, types.Range{Low: dq.LowId, Hi: dq.HiId})
@@ -3440,7 +3473,7 @@ func (t *Topic) replyDelMsg(sess *Session, asUid types.Uid, asChan bool, msg *Cl
unreadDeleted := calculateUnreadInRanges(pud.readID, t.lastID, ranges) unreadDeleted := calculateUnreadInRanges(pud.readID, t.lastID, ranges)
if unreadDeleted > 0 { if unreadDeleted > 0 {
// Decrease unread count (negative value) // Decrease unread count (negative value)
usersUpdateUnread(t.tenantID, uid, -unreadDeleted, true) usersUpdateUnread(t.tenantID, uid, -int64(unreadDeleted), true)
} }
} }
} }
@@ -4183,7 +4216,7 @@ func topicNameForUser(name string, uid types.Uid, isChan bool) string {
// calculateUnreadInRanges calculates how many unread messages are within the given ranges. // calculateUnreadInRanges calculates how many unread messages are within the given ranges.
// unreadStart is the first unread message SeqId (readID + 1), unreadEnd is the last possible message SeqId. // unreadStart is the first unread message SeqId (readID + 1), unreadEnd is the last possible message SeqId.
// Assumes ranges are sorted by Low ascending. // Assumes ranges are sorted by Low ascending.
func calculateUnreadInRanges(readID, lastID int, ranges []types.Range) int { func calculateUnreadInRanges(readID, lastID int64, ranges []types.Range) int {
if readID >= lastID { if readID >= lastID {
// No unread messages // No unread messages
return 0 return 0
@@ -4207,11 +4240,11 @@ func calculateUnreadInRanges(readID, lastID int, ranges []types.Range) int {
} }
// Find intersection of [unreadStart, unreadEnd] and [rangeStart, rangeEnd) // Find intersection of [unreadStart, unreadEnd] and [rangeStart, rangeEnd)
intersectionStart := max(unreadStart, rangeStart) intersectionStart := max64(unreadStart, rangeStart)
intersectionEnd := min(unreadEnd+1, rangeEnd) // +1 because unreadEnd is inclusive intersectionEnd := min(unreadEnd+1, rangeEnd) // +1 because unreadEnd is inclusive
if intersectionStart < intersectionEnd { if intersectionStart < intersectionEnd {
count += intersectionEnd - intersectionStart count += int(intersectionEnd - intersectionStart)
} }
} }
+3 -1
View File
@@ -55,7 +55,9 @@ func (t *Topic) runProxy(hub *Hub) {
} }
case msg := <-t.serverMsg: case msg := <-t.serverMsg:
if msg.Info != nil || msg.Pres != nil { if msg.Data != nil {
t.broadcastToSessions(msg)
} else if msg.Info != nil || msg.Pres != nil {
globals.cluster.routeToTopicIntraCluster(t.name, msg, msg.sess) globals.cluster.routeToTopicIntraCluster(t.name, msg, msg.sess)
} else { } else {
// FIXME: should something be done here? // FIXME: should something be done here?
+12 -12
View File
@@ -777,7 +777,7 @@ func TestHandleBroadcastDataDuplicateClientMessage(t *testing.T) {
if !ok { if !ok {
t.Fatalf("User1: expected ctrl params map, received %#v", ack.Ctrl.Params) t.Fatalf("User1: expected ctrl params map, received %#v", ack.Ctrl.Params)
} }
if seq, ok := params["seq"].(int); !ok || seq != 4 { if seq, ok := params["seq"].(int64); !ok || seq != 4 {
t.Errorf("User1: expected duplicate ack seq 4, received %#v", params["seq"]) t.Errorf("User1: expected duplicate ack seq 4, received %#v", params["seq"])
} }
if len(helper.results[1].messages) != 0 { if len(helper.results[1].messages) != 0 {
@@ -949,7 +949,7 @@ func TestHandleBroadcastDataInactiveTopic(t *testing.T) {
func TestHandleBroadcastInfoP2P(t *testing.T) { func TestHandleBroadcastInfoP2P(t *testing.T) {
topicName := "usrP2P" topicName := "usrP2P"
numUsers := 2 numUsers := 2
readId := 8 readId := int64(8)
helper := TopicTestHelper{} helper := TopicTestHelper{}
helper.setUp(t, numUsers, types.TopicCatP2P, topicName, true) helper.setUp(t, numUsers, types.TopicCatP2P, topicName, true)
defer helper.tearDown() defer helper.tearDown()
@@ -1065,7 +1065,7 @@ func TestHandleBroadcastInfoBogusNotification(t *testing.T) {
// Pretend we have 10 messages. // Pretend we have 10 messages.
helper.topic.lastID = 10 helper.topic.lastID = 10
// uid1 notifies uid2 that uid1 has read messages up to seqid 11. // uid1 notifies uid2 that uid1 has read messages up to seqid 11.
readId := 11 readId := int64(11)
from := helper.uids[0] from := helper.uids[0]
to := helper.uids[1] to := helper.uids[1]
@@ -1113,7 +1113,7 @@ func TestHandleBroadcastInfoFilterOutRecvWithoutRPermission(t *testing.T) {
// Pretend we have 10 messages. // Pretend we have 10 messages.
helper.topic.lastID = 10 helper.topic.lastID = 10
// uid1 notifies uid2 that uid1 has read messages up to seqid 11. // uid1 notifies uid2 that uid1 has read messages up to seqid 11.
readId := 8 readId := int64(8)
from := helper.uids[0] from := helper.uids[0]
to := helper.uids[1] to := helper.uids[1]
@@ -1166,7 +1166,7 @@ func TestHandleBroadcastInfoFilterOutKpWithoutWPermission(t *testing.T) {
// Pretend we have 10 messages. // Pretend we have 10 messages.
helper.topic.lastID = 10 helper.topic.lastID = 10
// uid1 notifies uid2 that uid1 has read messages up to seqid 11. // uid1 notifies uid2 that uid1 has read messages up to seqid 11.
readId := 8 readId := int64(8)
from := helper.uids[0] from := helper.uids[0]
to := helper.uids[1] to := helper.uids[1]
@@ -1219,7 +1219,7 @@ func TestHandleBroadcastInfoDuplicatedRead(t *testing.T) {
// Pretend we have 10 messages. // Pretend we have 10 messages.
helper.topic.lastID = 10 helper.topic.lastID = 10
// uid1 notifies uid2 that uid1 has read messages up to seqid 11. // uid1 notifies uid2 that uid1 has read messages up to seqid 11.
readId := 8 readId := int64(8)
from := helper.uids[0] from := helper.uids[0]
to := helper.uids[1] to := helper.uids[1]
@@ -1272,7 +1272,7 @@ func TestHandleBroadcastInfoDbError(t *testing.T) {
// Pretend we have 10 messages. // Pretend we have 10 messages.
helper.topic.lastID = 10 helper.topic.lastID = 10
// uid1 notifies uid2 that uid1 has read messages up to seqid 11. // uid1 notifies uid2 that uid1 has read messages up to seqid 11.
readId := 8 readId := int64(8)
from := helper.uids[0] from := helper.uids[0]
to := helper.uids[1] to := helper.uids[1]
@@ -1326,7 +1326,7 @@ func TestHandleBroadcastInfoInvalidChannelAccess(t *testing.T) {
// Pretend we have 10 messages. // Pretend we have 10 messages.
helper.topic.lastID = 10 helper.topic.lastID = 10
// uid1 notifies uid2 that uid1 has read messages up to seqid 11. // uid1 notifies uid2 that uid1 has read messages up to seqid 11.
readId := 8 readId := int64(8)
from := helper.uids[0] from := helper.uids[0]
for i := 1; i < numUsers; i++ { for i := 1; i < numUsers; i++ {
uid := helper.uids[i] uid := helper.uids[i]
@@ -1380,7 +1380,7 @@ func TestHandleBroadcastInfoChannelProcessing(t *testing.T) {
// Pretend we have 10 messages. // Pretend we have 10 messages.
helper.topic.lastID = 10 helper.topic.lastID = 10
// uid1 notifies uid2 that uid1 has read messages up to seqid 11. // uid1 notifies uid2 that uid1 has read messages up to seqid 11.
readId := 8 readId := int64(8)
from := helper.uids[0] from := helper.uids[0]
for i := 1; i < numUsers; i++ { for i := 1; i < numUsers; i++ {
uid := helper.uids[i] uid := helper.uids[i]
@@ -3222,7 +3222,7 @@ func TestHandleBroadcastInfoChannelWithMultipleReaders(t *testing.T) {
defer helper.tearDown() defer helper.tearDown()
helper.topic.lastID = 15 helper.topic.lastID = 15
readId := 12 readId := int64(12)
from := helper.uids[0] from := helper.uids[0]
// Set up multiple channel readers // Set up multiple channel readers
@@ -3606,8 +3606,8 @@ func TestReplyDelMsgUpdatesUnreadCounters(t *testing.T) {
func TestCalculateUnreadInRanges(t *testing.T) { func TestCalculateUnreadInRanges(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
readID int readID int64
lastID int lastID int64
ranges []types.Range ranges []types.Range
expected int expected int
}{ }{
+5 -2
View File
@@ -1069,12 +1069,12 @@ func usersShutdown() {
} }
} }
func usersUpdateUnread(tenantID types.TenantID, uid types.Uid, val int, inc bool) { func usersUpdateUnread(tenantID types.TenantID, uid types.Uid, val int64, inc bool) {
if globals.usersUpdate == nil || (val == 0 && inc) { if globals.usersUpdate == nil || (val == 0 && inc) {
return return
} }
upd := &UserCacheReq{TenantID: tenantID, UserId: uid, Unread: val, Inc: inc} upd := &UserCacheReq{TenantID: tenantID, UserId: uid, Unread: int(val), Inc: inc}
if globals.cluster.isRemoteTopic(types.TopicKey{TenantID: tenantID, Topic: uid.UserId()}) { if globals.cluster.isRemoteTopic(types.TopicKey{TenantID: tenantID, Topic: uid.UserId()}) {
// Send request to remote node which owns the user. // Send request to remote node which owns the user.
globals.cluster.routeUserReq(upd) globals.cluster.routeUserReq(upd)
@@ -1240,6 +1240,7 @@ func userUpdater() {
usersCache[key] = uce usersCache[key] = uce
counts[uid] = uce.unread counts[uid] = uce.unread
store.CacheUserUnreadCount(tenantID, uid, uce.unread)
} }
if len(dbPending) > 0 { if len(dbPending) > 0 {
@@ -1287,6 +1288,7 @@ func userUpdater() {
} }
uce.unread = count uce.unread = count
usersCache[key] = uce usersCache[key] = uce
store.CacheUserUnreadCount(io.tenantID, uid, count)
} else { } else {
logs.Warn.Println("users: missing users cache entry after IO completion, uid", uid) logs.Warn.Println("users: missing users cache entry after IO completion, uid", uid)
} }
@@ -1416,6 +1418,7 @@ func userUpdater() {
if upd.Gone { if upd.Gone {
// User is being deleted. Don't care if there is a record. // User is being deleted. Don't care if there is a record.
delete(usersCache, types.UserKey{TenantID: upd.TenantID, User: upd.UserId}) delete(usersCache, types.UserKey{TenantID: upd.TenantID, User: upd.UserId})
store.DeleteCachedUserUnreadCount(upd.TenantID, upd.UserId)
continue continue
} }
+7
View File
@@ -694,6 +694,13 @@ func max(a, b int) int {
return b return b
} }
func max64(a, b int64) int64 {
if a > b {
return a
}
return b
}
// Truncate string if it's too long. Used in logging. // Truncate string if it's too long. Used in logging.
func truncateStringIfTooLong(s string) string { func truncateStringIfTooLong(s string) string {
if len(s) <= 1024 { if len(s) <= 1024 {
+45
View File
@@ -0,0 +1,45 @@
ET /v1/im/favorites/{favorite_id} 返回的是单条收藏详情,当前核心字段大概是这样:
{
"favorite_id": "fav_demo_19179719768_text",
"type": "text",
"snapshot": {
"topic": "favSeed19179719768",
"seq": 7001,
"from": "usr...",
"ts": "2026-08-25T07:26:06Z",
"type": "text",
"head": {},
"content": {},
"text": "这是一条收藏的文本消息"
},
"source": {
"topic": "favSeed19179719768",
"seq": 7001
},
"collected_at": "2026-08-25T07:26:06Z",
"availability": "available"
}
字段说明:
字段 含义
favorite_id 收藏记录 ID,后续详情、删除、转发都用它。
type 收藏类型:text、image、video、voice、file、link。
snapshot 收藏时保存的展示快照。即使原消息后续变化或删除,收藏列表也主要用这个字段渲染。
source.topic 原始消息所在会话 Topic。
source.seq 原始消息在该 Topic 下的消息序号。
collected_at 收藏时间,ISO/RFC3339 时间格式。
availability 当前可用状态,通常是 available;如果原资源被合规删除或失效,可能返回 removed 等状态。
snapshot 里面比较重要:
字段 含义
snapshot.topic 原消息 Topic。
snapshot.seq 原消息 seq。
snapshot.from 原消息发送人。
snapshot.ts 原消息发送时间。
snapshot.type 消息类型。通常和外层 type 一致。
snapshot.head 原消息头部扩展,比如 mime、x-im-type 等。
snapshot.content 原消息内容。文本、文件、图片、语音、链接等具体展示数据一般在这里。
snapshot.text 服务端提取出的纯文本摘要,方便列表搜索和预览展示。
前端一般列表页用外层 type + snapshot + collected_at + availability 渲染;点开详情时,如果是文件/图片/视频/语音,再根据 snapshot.content.file_id、thumbnail_file_id 等字段去走文件下载/预览逻辑。