feat: add redis cache and fanout support
@@ -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
|
||||||
|
|||||||
@@ -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,也就是用户名字段
|
||||||
|
|
||||||
|
|
||||||
|
看看对接文档1.4的注册页面 增加逻辑 1.注册邀请码 (非必填 )不填邀请码可以正常注册。用真实手机号注册的,且后台发送注册验证码的开关打开的情况下,可以收到验证码的流程也能正常注册
|
||||||
|
2.发送注册验证码的开关关闭的情况下,后台不向短信服务商发送请求,前端正常ui的倒计时,但是收不到验证码,此时填了邀请码,且邀请码校验合格的话,没有验证码仍然可以注册
|
||||||
|
|
||||||
|
我看了很多users相关的表 你总结一下分别是什么作用
|
||||||
|
你看看手机号是如何存的
|
||||||
|
im_users的 tags 有啥用 存的啥
|
||||||
|
|
||||||
|
昵称用中文实现
|
||||||
|
|
||||||
|
对一样的,因为给好友改了备注,所以上面就会显示,但是昵称是好友,本身自己起的网名,如果对方没有起,系统会默认给一个
|
||||||
|
|
||||||
|
用户名和手机号注册的话 如果验证码开关是开的 以验证码为准 如果没开 就以邀请码流程 用户名不能忽略 要保存
|
||||||
|
|
||||||
|
前后端对接文档 补充 请求和响应的json
|
||||||
|
|
||||||
|
文档的覆盖测试
|
||||||
|
|
||||||
|
发的网站功能的复制
|
||||||
|
web地址:https://web.xcwykk.top/
|
||||||
|
管理后台地址:https://admin.adfahi.cn
|
||||||
|
账号:heiyue
|
||||||
|
密码:Heiyue123
|
||||||
|
测试名额:100
|
||||||
|
|
||||||
|
群操作
|
||||||
|
文件操作
|
||||||
|
|
||||||
|
redis的接入 性能提升
|
||||||
|
kafka的接入 异步操作 幂等性
|
||||||
|
|
||||||
|
弱网
|
||||||
|
QUIC传输技术
|
||||||
|
日志框架的接入
|
||||||
|
|
||||||
|
|
||||||
|
重中之重。加密 + 接口鉴权 防止被扫 日志要记录下来 每个小时告警
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
用户的唯一标识用user_id来标记,然后新增user_name,也就是用户名字段
|
|
||||||
@@ -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"
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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 热缓存已完成基础接入。
|
||||||
|
|
||||||
|
后续再逐步推进好友/黑名单全量预热、群成员分页快照、未读数等缓存改造。
|
||||||
@@ -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">
|
||||||
|
|||||||
@@ -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. 好友申请通过后是否双方都写系统通知,需要产品确认;若确认双方都有,需要同步补代码。
|
||||||
@@ -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` 和接口审核报告,避免已完成项继续出现在待办清单。
|
||||||
|
|||||||
|
After Width: | Height: | Size: 95 KiB |
|
After Width: | Height: | Size: 288 KiB |
|
After Width: | Height: | Size: 311 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 288 KiB |
|
After Width: | Height: | Size: 311 KiB |
|
After Width: | Height: | Size: 95 KiB |
|
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;
|
||||||
@@ -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`
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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=
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 ...>"
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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 == "" {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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, ",")
|
||||||
|
}
|
||||||
@@ -0,0 +1,866 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/redis/go-redis/v9"
|
||||||
|
"github.com/tinode/chat/server/store/types"
|
||||||
|
)
|
||||||
|
|
||||||
|
type redisConfig struct {
|
||||||
|
Addr string `json:"addr"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
Password string `json:"password"`
|
||||||
|
DB int `json:"db"`
|
||||||
|
KeyPrefix string `json:"key_prefix"`
|
||||||
|
DialMS int `json:"dial_timeout_ms"`
|
||||||
|
ReadMS int `json:"read_timeout_ms"`
|
||||||
|
WriteMS int `json:"write_timeout_ms"`
|
||||||
|
PendingTTL int `json:"pending_ttl_seconds"`
|
||||||
|
DoneTTL int `json:"done_ttl_seconds"`
|
||||||
|
FailedTTL int `json:"failed_ttl_seconds"`
|
||||||
|
HotMsgTTL int `json:"hot_message_ttl_seconds"`
|
||||||
|
PresenceTTL int `json:"presence_ttl_seconds"`
|
||||||
|
CacheTTL int `json:"cache_ttl_seconds"`
|
||||||
|
ShortTTL int `json:"short_state_ttl_seconds"`
|
||||||
|
GroupFanout bool `json:"group_fanout_enabled"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type groupMessageUniqueRecord struct {
|
||||||
|
Status string `json:"status"`
|
||||||
|
Seq int64 `json:"seq,omitempty"`
|
||||||
|
MessageID string `json:"message_id,omitempty"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type groupFanoutEnvelope struct {
|
||||||
|
OriginNodeID string `json:"origin_node_id"`
|
||||||
|
Message json.RawMessage `json:"message"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type groupMessageRuntime interface {
|
||||||
|
Close() error
|
||||||
|
Claim(tenantID types.TenantID, topic string, from types.Uid, clientMsgID string) (*groupMessageUniqueRecord, bool, error)
|
||||||
|
NextSeq(tenantID types.TenantID, topic string, baseline int64) (int64, error)
|
||||||
|
MarkDone(tenantID types.TenantID, topic string, from types.Uid, clientMsgID string, seq int64, msgID string) error
|
||||||
|
MarkFailed(tenantID types.TenantID, topic string, from types.Uid, clientMsgID string) error
|
||||||
|
CacheHotMessage(tenantID types.TenantID, msg *types.Message) error
|
||||||
|
GetHotMessages(tenantID types.TenantID, topic string, opts *types.QueryOpt, maxLimit int) ([]types.Message, bool, error)
|
||||||
|
InvalidateHotMessages(tenantID types.TenantID, topic string, ranges []types.Range) error
|
||||||
|
MarkUserOnline(tenantID types.TenantID, uid types.Uid, sid, nodeID, device, userAgent string) error
|
||||||
|
RefreshUserPresence(tenantID types.TenantID, uid types.Uid, sid, nodeID string) error
|
||||||
|
MarkUserOffline(tenantID types.TenantID, uid types.Uid, sid, nodeID string) error
|
||||||
|
GetUserPresence(tenantID types.TenantID, uid types.Uid) (*UserPresence, error)
|
||||||
|
MarkTopicMemberOnline(tenantID types.TenantID, topic string, uid types.Uid, nodeID string) error
|
||||||
|
MarkTopicMemberOffline(tenantID types.TenantID, topic string, uid types.Uid, nodeID string) error
|
||||||
|
GetTopicNodeOnlineMembers(tenantID types.TenantID, topic, nodeID string) ([]types.Uid, error)
|
||||||
|
PublishGroupFanout(originNodeID string, message any) error
|
||||||
|
StartGroupFanoutSubscriber(ctx context.Context, nodeID string, handler func([]byte)) error
|
||||||
|
CacheObject(tenantID types.TenantID, namespace, id string, value any) error
|
||||||
|
GetCachedObject(tenantID types.TenantID, namespace, id string, value any) (bool, error)
|
||||||
|
DeleteCachedObject(tenantID types.TenantID, namespace, id string) error
|
||||||
|
AddCachedSetMember(tenantID types.TenantID, namespace, id, member string) error
|
||||||
|
RemoveCachedSetMember(tenantID types.TenantID, namespace, id, member string) error
|
||||||
|
CachedSetContains(tenantID types.TenantID, namespace, id, member string) (bool, error)
|
||||||
|
AllowRate(tenantID types.TenantID, scope string, limit int, window time.Duration) (bool, int, time.Duration, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
var groupMsgRuntime groupMessageRuntime
|
||||||
|
|
||||||
|
type UserPresence struct {
|
||||||
|
Online bool `json:"online"`
|
||||||
|
NodeID string `json:"node_id"`
|
||||||
|
SessionCount int `json:"session_count"`
|
||||||
|
LastActiveAt time.Time `json:"last_active_at"`
|
||||||
|
LastSeenAt time.Time `json:"last_seen_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func MarkUserOnline(tenantID types.TenantID, uid types.Uid, sid, nodeID, device, userAgent string) error {
|
||||||
|
if groupMsgRuntime == nil || !tenantID.IsValid() || uid.IsZero() || sid == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return groupMsgRuntime.MarkUserOnline(tenantID, uid, sid, nodeID, device, userAgent)
|
||||||
|
}
|
||||||
|
|
||||||
|
func RefreshUserPresence(tenantID types.TenantID, uid types.Uid, sid, nodeID string) error {
|
||||||
|
if groupMsgRuntime == nil || !tenantID.IsValid() || uid.IsZero() || sid == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return groupMsgRuntime.RefreshUserPresence(tenantID, uid, sid, nodeID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func MarkUserOffline(tenantID types.TenantID, uid types.Uid, sid, nodeID string) error {
|
||||||
|
if groupMsgRuntime == nil || !tenantID.IsValid() || uid.IsZero() || sid == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return groupMsgRuntime.MarkUserOffline(tenantID, uid, sid, nodeID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetUserPresence(tenantID types.TenantID, uid types.Uid) (*UserPresence, error) {
|
||||||
|
if groupMsgRuntime == nil || !tenantID.IsValid() || uid.IsZero() {
|
||||||
|
return &UserPresence{}, nil
|
||||||
|
}
|
||||||
|
return groupMsgRuntime.GetUserPresence(tenantID, uid)
|
||||||
|
}
|
||||||
|
|
||||||
|
func UserPresenceEnabled() bool {
|
||||||
|
return groupMsgRuntime != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func MarkTopicMemberOnline(tenantID types.TenantID, topic string, uid types.Uid, nodeID string) error {
|
||||||
|
if groupMsgRuntime == nil || !tenantID.IsValid() || topic == "" || uid.IsZero() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return groupMsgRuntime.MarkTopicMemberOnline(tenantID, topic, uid, nodeID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func MarkTopicMemberOffline(tenantID types.TenantID, topic string, uid types.Uid, nodeID string) error {
|
||||||
|
if groupMsgRuntime == nil || !tenantID.IsValid() || topic == "" || uid.IsZero() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return groupMsgRuntime.MarkTopicMemberOffline(tenantID, topic, uid, nodeID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetTopicNodeOnlineMembers(tenantID types.TenantID, topic, nodeID string) ([]types.Uid, error) {
|
||||||
|
if groupMsgRuntime == nil || !tenantID.IsValid() || topic == "" {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return groupMsgRuntime.GetTopicNodeOnlineMembers(tenantID, topic, nodeID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func PublishGroupFanout(originNodeID string, message any) error {
|
||||||
|
if groupMsgRuntime == nil || originNodeID == "" || message == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return groupMsgRuntime.PublishGroupFanout(originNodeID, message)
|
||||||
|
}
|
||||||
|
|
||||||
|
func StartGroupFanoutSubscriber(ctx context.Context, nodeID string, handler func([]byte)) error {
|
||||||
|
if groupMsgRuntime == nil || nodeID == "" || handler == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return groupMsgRuntime.StartGroupFanoutSubscriber(ctx, nodeID, handler)
|
||||||
|
}
|
||||||
|
|
||||||
|
func CacheObject(tenantID types.TenantID, namespace, id string, value any) error {
|
||||||
|
if groupMsgRuntime == nil || !tenantID.IsValid() || namespace == "" || id == "" || value == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return groupMsgRuntime.CacheObject(tenantID, namespace, id, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetCachedObject(tenantID types.TenantID, namespace, id string, value any) (bool, error) {
|
||||||
|
if groupMsgRuntime == nil || !tenantID.IsValid() || namespace == "" || id == "" || value == nil {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
return groupMsgRuntime.GetCachedObject(tenantID, namespace, id, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
func DeleteCachedObject(tenantID types.TenantID, namespace, id string) error {
|
||||||
|
if groupMsgRuntime == nil || !tenantID.IsValid() || namespace == "" || id == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return groupMsgRuntime.DeleteCachedObject(tenantID, namespace, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
func AddCachedSetMember(tenantID types.TenantID, namespace, id, member string) error {
|
||||||
|
if groupMsgRuntime == nil || !tenantID.IsValid() || namespace == "" || id == "" || member == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return groupMsgRuntime.AddCachedSetMember(tenantID, namespace, id, member)
|
||||||
|
}
|
||||||
|
|
||||||
|
func RemoveCachedSetMember(tenantID types.TenantID, namespace, id, member string) error {
|
||||||
|
if groupMsgRuntime == nil || !tenantID.IsValid() || namespace == "" || id == "" || member == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return groupMsgRuntime.RemoveCachedSetMember(tenantID, namespace, id, member)
|
||||||
|
}
|
||||||
|
|
||||||
|
func CachedSetContains(tenantID types.TenantID, namespace, id, member string) (bool, error) {
|
||||||
|
if groupMsgRuntime == nil || !tenantID.IsValid() || namespace == "" || id == "" || member == "" {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
return groupMsgRuntime.CachedSetContains(tenantID, namespace, id, member)
|
||||||
|
}
|
||||||
|
|
||||||
|
func AllowRate(tenantID types.TenantID, scope string, limit int, window time.Duration) (bool, int, time.Duration, error) {
|
||||||
|
if groupMsgRuntime == nil || !tenantID.IsValid() || scope == "" || limit <= 0 || window <= 0 {
|
||||||
|
return true, 0, 0, nil
|
||||||
|
}
|
||||||
|
return groupMsgRuntime.AllowRate(tenantID, scope, limit, window)
|
||||||
|
}
|
||||||
|
|
||||||
|
func initGroupMessageRuntime(raw json.RawMessage) error {
|
||||||
|
closeGroupMessageRuntime()
|
||||||
|
if len(raw) == 0 || string(raw) == "null" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var cfg redisConfig
|
||||||
|
if err := json.Unmarshal(raw, &cfg); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
cfg.Addr = strings.TrimSpace(cfg.Addr)
|
||||||
|
if cfg.Addr == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if cfg.KeyPrefix == "" {
|
||||||
|
cfg.KeyPrefix = "im"
|
||||||
|
}
|
||||||
|
if cfg.PendingTTL <= 0 {
|
||||||
|
cfg.PendingTTL = 120
|
||||||
|
}
|
||||||
|
if cfg.DoneTTL <= 0 {
|
||||||
|
cfg.DoneTTL = 86400
|
||||||
|
}
|
||||||
|
if cfg.FailedTTL <= 0 {
|
||||||
|
cfg.FailedTTL = 600
|
||||||
|
}
|
||||||
|
if cfg.HotMsgTTL <= 0 {
|
||||||
|
cfg.HotMsgTTL = 86400
|
||||||
|
}
|
||||||
|
if cfg.PresenceTTL <= 0 {
|
||||||
|
cfg.PresenceTTL = 120
|
||||||
|
}
|
||||||
|
if cfg.CacheTTL <= 0 {
|
||||||
|
cfg.CacheTTL = 300
|
||||||
|
}
|
||||||
|
if cfg.ShortTTL <= 0 {
|
||||||
|
cfg.ShortTTL = 600
|
||||||
|
}
|
||||||
|
dialTimeout := timeoutOrDefault(cfg.DialMS, 2*time.Second)
|
||||||
|
readTimeout := timeoutOrDefault(cfg.ReadMS, 2*time.Second)
|
||||||
|
writeTimeout := timeoutOrDefault(cfg.WriteMS, 2*time.Second)
|
||||||
|
client := redis.NewClient(&redis.Options{
|
||||||
|
Addr: cfg.Addr,
|
||||||
|
Username: cfg.Username,
|
||||||
|
Password: cfg.Password,
|
||||||
|
DB: cfg.DB,
|
||||||
|
DialTimeout: dialTimeout,
|
||||||
|
ReadTimeout: readTimeout,
|
||||||
|
WriteTimeout: writeTimeout,
|
||||||
|
})
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), dialTimeout)
|
||||||
|
defer cancel()
|
||||||
|
if err := client.Ping(ctx).Err(); err != nil {
|
||||||
|
_ = client.Close()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
groupMsgRuntime = &redisGroupMessageRuntime{client: client, prefix: cfg.KeyPrefix, cfg: cfg}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func closeGroupMessageRuntime() {
|
||||||
|
if groupMsgRuntime != nil {
|
||||||
|
_ = groupMsgRuntime.Close()
|
||||||
|
groupMsgRuntime = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func timeoutOrDefault(ms int, fallback time.Duration) time.Duration {
|
||||||
|
if ms <= 0 {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
return time.Duration(ms) * time.Millisecond
|
||||||
|
}
|
||||||
|
|
||||||
|
type redisGroupMessageRuntime struct {
|
||||||
|
client *redis.Client
|
||||||
|
prefix string
|
||||||
|
cfg redisConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *redisGroupMessageRuntime) Close() error {
|
||||||
|
return r.client.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *redisGroupMessageRuntime) Claim(tenantID types.TenantID, topic string, from types.Uid, clientMsgID string) (*groupMessageUniqueRecord, bool, error) {
|
||||||
|
if clientMsgID == "" {
|
||||||
|
return nil, true, nil
|
||||||
|
}
|
||||||
|
key := r.uniqueKey(tenantID, topic, from, clientMsgID)
|
||||||
|
rec := &groupMessageUniqueRecord{Status: "pending", CreatedAt: time.Now().UTC()}
|
||||||
|
raw, _ := json.Marshal(rec)
|
||||||
|
ctx := context.Background()
|
||||||
|
ok, err := r.client.SetNX(ctx, key, raw, time.Duration(r.cfg.PendingTTL)*time.Second).Result()
|
||||||
|
if err != nil {
|
||||||
|
return nil, false, err
|
||||||
|
}
|
||||||
|
if ok {
|
||||||
|
return rec, true, nil
|
||||||
|
}
|
||||||
|
existing, err := r.client.Get(ctx, key).Bytes()
|
||||||
|
if err == redis.Nil {
|
||||||
|
return nil, false, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, false, err
|
||||||
|
}
|
||||||
|
var current groupMessageUniqueRecord
|
||||||
|
if err = json.Unmarshal(existing, ¤t); err != nil {
|
||||||
|
return nil, false, err
|
||||||
|
}
|
||||||
|
if current.Status == "failed" {
|
||||||
|
_ = r.client.Del(ctx, key).Err()
|
||||||
|
ok, err = r.client.SetNX(ctx, key, raw, time.Duration(r.cfg.PendingTTL)*time.Second).Result()
|
||||||
|
if err != nil {
|
||||||
|
return nil, false, err
|
||||||
|
}
|
||||||
|
if ok {
|
||||||
|
return rec, true, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ¤t, false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *redisGroupMessageRuntime) NextSeq(tenantID types.TenantID, topic string, baseline int64) (int64, error) {
|
||||||
|
if baseline < 0 {
|
||||||
|
baseline = 0
|
||||||
|
}
|
||||||
|
seqKey := r.seqKey(tenantID, topic)
|
||||||
|
seq, err := r.client.Eval(context.Background(), `
|
||||||
|
if redis.call("EXISTS", KEYS[1]) == 0 then
|
||||||
|
redis.call("SET", KEYS[1], ARGV[1])
|
||||||
|
end
|
||||||
|
return redis.call("INCR", KEYS[1])
|
||||||
|
`, []string{seqKey}, baseline).Int64()
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return seq, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *redisGroupMessageRuntime) MarkDone(tenantID types.TenantID, topic string, from types.Uid, clientMsgID string, seq int64, msgID string) error {
|
||||||
|
if clientMsgID == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
rec := &groupMessageUniqueRecord{Status: "done", Seq: seq, MessageID: msgID, CreatedAt: time.Now().UTC()}
|
||||||
|
raw, _ := json.Marshal(rec)
|
||||||
|
return r.client.Set(context.Background(), r.uniqueKey(tenantID, topic, from, clientMsgID), raw, time.Duration(r.cfg.DoneTTL)*time.Second).Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *redisGroupMessageRuntime) MarkFailed(tenantID types.TenantID, topic string, from types.Uid, clientMsgID string) error {
|
||||||
|
if clientMsgID == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
rec := &groupMessageUniqueRecord{Status: "failed", CreatedAt: time.Now().UTC()}
|
||||||
|
raw, _ := json.Marshal(rec)
|
||||||
|
return r.client.Set(context.Background(), r.uniqueKey(tenantID, topic, from, clientMsgID), raw, time.Duration(r.cfg.FailedTTL)*time.Second).Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *redisGroupMessageRuntime) CacheHotMessage(tenantID types.TenantID, msg *types.Message) error {
|
||||||
|
if msg == nil || msg.Topic == "" || msg.SeqId <= 0 {
|
||||||
|
return types.ErrMalformed
|
||||||
|
}
|
||||||
|
payload, err := json.Marshal(msg)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
ctx := context.Background()
|
||||||
|
ttl := time.Duration(r.cfg.HotMsgTTL) * time.Second
|
||||||
|
seq := strconv.FormatInt(msg.SeqId, 10)
|
||||||
|
pipe := r.client.Pipeline()
|
||||||
|
pipe.Set(ctx, r.hotMsgKey(tenantID, msg.Topic, msg.SeqId), payload, ttl)
|
||||||
|
pipe.ZAdd(ctx, r.hotSeqKey(tenantID, msg.Topic), redis.Z{Score: float64(msg.SeqId), Member: seq})
|
||||||
|
pipe.Expire(ctx, r.hotSeqKey(tenantID, msg.Topic), ttl+time.Hour)
|
||||||
|
_, err = pipe.Exec(ctx)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *redisGroupMessageRuntime) GetHotMessages(tenantID types.TenantID, topic string, opts *types.QueryOpt, maxLimit int) ([]types.Message, bool, error) {
|
||||||
|
if topic == "" {
|
||||||
|
return nil, false, types.ErrMalformed
|
||||||
|
}
|
||||||
|
limit := maxLimit
|
||||||
|
if limit <= 0 {
|
||||||
|
limit = 100
|
||||||
|
}
|
||||||
|
if opts != nil && opts.Limit > 0 && opts.Limit < limit {
|
||||||
|
limit = opts.Limit
|
||||||
|
}
|
||||||
|
seqs, completeRange, err := r.hotSeqIDs(tenantID, topic, opts, limit)
|
||||||
|
if err != nil || len(seqs) == 0 || !completeRange {
|
||||||
|
return nil, false, err
|
||||||
|
}
|
||||||
|
keys := make([]string, 0, len(seqs))
|
||||||
|
for _, seq := range seqs {
|
||||||
|
keys = append(keys, r.hotMsgKey(tenantID, topic, seq))
|
||||||
|
}
|
||||||
|
values, err := r.client.MGet(context.Background(), keys...).Result()
|
||||||
|
if err != nil {
|
||||||
|
return nil, false, err
|
||||||
|
}
|
||||||
|
if len(values) != len(seqs) {
|
||||||
|
return nil, false, nil
|
||||||
|
}
|
||||||
|
msgs := make([]types.Message, 0, len(values))
|
||||||
|
for _, value := range values {
|
||||||
|
if value == nil {
|
||||||
|
return nil, false, nil
|
||||||
|
}
|
||||||
|
raw, ok := value.(string)
|
||||||
|
if !ok {
|
||||||
|
return nil, false, nil
|
||||||
|
}
|
||||||
|
var msg types.Message
|
||||||
|
if err := json.Unmarshal([]byte(raw), &msg); err != nil {
|
||||||
|
return nil, false, err
|
||||||
|
}
|
||||||
|
msg.TenantID = tenantID
|
||||||
|
msgs = append(msgs, msg)
|
||||||
|
}
|
||||||
|
return msgs, true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *redisGroupMessageRuntime) InvalidateHotMessages(tenantID types.TenantID, topic string, ranges []types.Range) error {
|
||||||
|
if topic == "" {
|
||||||
|
return types.ErrMalformed
|
||||||
|
}
|
||||||
|
seqs := expandRanges(ranges, int(^uint(0)>>1))
|
||||||
|
ctx := context.Background()
|
||||||
|
if len(seqs) == 0 {
|
||||||
|
pipe := r.client.Pipeline()
|
||||||
|
pipe.Del(ctx, r.hotSeqKey(tenantID, topic))
|
||||||
|
keys, err := r.client.Keys(ctx, r.hotMsgKey(tenantID, topic, "*")).Result()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len(keys) > 0 {
|
||||||
|
pipe.Del(ctx, keys...)
|
||||||
|
}
|
||||||
|
_, err = pipe.Exec(ctx)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
pipe := r.client.Pipeline()
|
||||||
|
members := make([]any, 0, len(seqs))
|
||||||
|
for _, seq := range seqs {
|
||||||
|
pipe.Del(ctx, r.hotMsgKey(tenantID, topic, seq))
|
||||||
|
members = append(members, strconv.FormatInt(int64(seq), 10))
|
||||||
|
}
|
||||||
|
pipe.ZRem(ctx, r.hotSeqKey(tenantID, topic), members...)
|
||||||
|
_, err := pipe.Exec(ctx)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *redisGroupMessageRuntime) MarkUserOnline(tenantID types.TenantID, uid types.Uid, sid, nodeID, device, userAgent string) error {
|
||||||
|
if nodeID == "" {
|
||||||
|
nodeID = "local"
|
||||||
|
}
|
||||||
|
now := time.Now().UTC()
|
||||||
|
ttl := time.Duration(r.cfg.PresenceTTL) * time.Second
|
||||||
|
ctx := context.Background()
|
||||||
|
userKey := r.presenceUserKey(tenantID, uid)
|
||||||
|
sessionsKey := r.presenceSessionsKey(tenantID, uid)
|
||||||
|
sessionKey := r.presenceSessionKey(tenantID, uid, sid)
|
||||||
|
nodeKey := r.presenceNodeUsersKey(tenantID, nodeID)
|
||||||
|
member := nodeID + ":" + sid
|
||||||
|
pipe := r.client.Pipeline()
|
||||||
|
pipe.HSet(ctx, sessionKey, map[string]any{
|
||||||
|
"node_id": nodeID,
|
||||||
|
"session_id": sid,
|
||||||
|
"device": device,
|
||||||
|
"user_agent": userAgent,
|
||||||
|
"last_active_at": now.Format(time.RFC3339Nano),
|
||||||
|
})
|
||||||
|
pipe.Expire(ctx, sessionKey, ttl)
|
||||||
|
pipe.SAdd(ctx, sessionsKey, member)
|
||||||
|
pipe.Expire(ctx, sessionsKey, ttl)
|
||||||
|
pipe.SAdd(ctx, nodeKey, uid.UserId())
|
||||||
|
pipe.Expire(ctx, nodeKey, ttl)
|
||||||
|
pipe.HSet(ctx, userKey, map[string]any{
|
||||||
|
"online": "1",
|
||||||
|
"node_id": nodeID,
|
||||||
|
"last_active_at": now.Format(time.RFC3339Nano),
|
||||||
|
"last_seen_at": now.Format(time.RFC3339Nano),
|
||||||
|
})
|
||||||
|
pipe.Expire(ctx, userKey, ttl)
|
||||||
|
if _, err := pipe.Exec(ctx); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
count, err := r.client.SCard(ctx, sessionsKey).Result()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return r.client.HSet(ctx, userKey, "session_count", count).Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *redisGroupMessageRuntime) RefreshUserPresence(tenantID types.TenantID, uid types.Uid, sid, nodeID string) error {
|
||||||
|
if nodeID == "" {
|
||||||
|
nodeID = "local"
|
||||||
|
}
|
||||||
|
now := time.Now().UTC()
|
||||||
|
ttl := time.Duration(r.cfg.PresenceTTL) * time.Second
|
||||||
|
ctx := context.Background()
|
||||||
|
userKey := r.presenceUserKey(tenantID, uid)
|
||||||
|
sessionsKey := r.presenceSessionsKey(tenantID, uid)
|
||||||
|
sessionKey := r.presenceSessionKey(tenantID, uid, sid)
|
||||||
|
nodeKey := r.presenceNodeUsersKey(tenantID, nodeID)
|
||||||
|
pipe := r.client.Pipeline()
|
||||||
|
pipe.HSet(ctx, sessionKey, "last_active_at", now.Format(time.RFC3339Nano), "node_id", nodeID)
|
||||||
|
pipe.Expire(ctx, sessionKey, ttl)
|
||||||
|
pipe.Expire(ctx, sessionsKey, ttl)
|
||||||
|
pipe.SAdd(ctx, nodeKey, uid.UserId())
|
||||||
|
pipe.Expire(ctx, nodeKey, ttl)
|
||||||
|
pipe.HSet(ctx, userKey, "online", "1", "node_id", nodeID, "last_active_at", now.Format(time.RFC3339Nano), "last_seen_at", now.Format(time.RFC3339Nano))
|
||||||
|
pipe.Expire(ctx, userKey, ttl)
|
||||||
|
_, err := pipe.Exec(ctx)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *redisGroupMessageRuntime) MarkUserOffline(tenantID types.TenantID, uid types.Uid, sid, nodeID string) error {
|
||||||
|
if nodeID == "" {
|
||||||
|
nodeID = "local"
|
||||||
|
}
|
||||||
|
now := time.Now().UTC()
|
||||||
|
ttl := time.Duration(r.cfg.PresenceTTL) * time.Second
|
||||||
|
ctx := context.Background()
|
||||||
|
userKey := r.presenceUserKey(tenantID, uid)
|
||||||
|
sessionsKey := r.presenceSessionsKey(tenantID, uid)
|
||||||
|
sessionKey := r.presenceSessionKey(tenantID, uid, sid)
|
||||||
|
nodeKey := r.presenceNodeUsersKey(tenantID, nodeID)
|
||||||
|
member := nodeID + ":" + sid
|
||||||
|
pipe := r.client.Pipeline()
|
||||||
|
pipe.Del(ctx, sessionKey)
|
||||||
|
pipe.SRem(ctx, sessionsKey, member)
|
||||||
|
pipe.SRem(ctx, nodeKey, uid.UserId())
|
||||||
|
if _, err := pipe.Exec(ctx); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
count, err := r.client.SCard(ctx, sessionsKey).Result()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if count > 0 {
|
||||||
|
pipe = r.client.Pipeline()
|
||||||
|
pipe.HSet(ctx, userKey, "online", "1", "session_count", count, "last_seen_at", now.Format(time.RFC3339Nano))
|
||||||
|
pipe.Expire(ctx, userKey, ttl)
|
||||||
|
pipe.Expire(ctx, sessionsKey, ttl)
|
||||||
|
_, err = pipe.Exec(ctx)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
pipe = r.client.Pipeline()
|
||||||
|
pipe.HSet(ctx, userKey, "online", "0", "session_count", 0, "node_id", nodeID, "last_seen_at", now.Format(time.RFC3339Nano))
|
||||||
|
pipe.Expire(ctx, userKey, ttl)
|
||||||
|
pipe.Del(ctx, sessionsKey)
|
||||||
|
_, err = pipe.Exec(ctx)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *redisGroupMessageRuntime) GetUserPresence(tenantID types.TenantID, uid types.Uid) (*UserPresence, error) {
|
||||||
|
raw, err := r.client.HGetAll(context.Background(), r.presenceUserKey(tenantID, uid)).Result()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
presence := &UserPresence{}
|
||||||
|
if len(raw) == 0 {
|
||||||
|
return presence, nil
|
||||||
|
}
|
||||||
|
presence.Online = raw["online"] == "1"
|
||||||
|
presence.NodeID = raw["node_id"]
|
||||||
|
presence.SessionCount, _ = strconv.Atoi(raw["session_count"])
|
||||||
|
presence.LastActiveAt, _ = time.Parse(time.RFC3339Nano, raw["last_active_at"])
|
||||||
|
presence.LastSeenAt, _ = time.Parse(time.RFC3339Nano, raw["last_seen_at"])
|
||||||
|
return presence, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *redisGroupMessageRuntime) MarkTopicMemberOnline(tenantID types.TenantID, topic string, uid types.Uid, nodeID string) error {
|
||||||
|
if nodeID == "" {
|
||||||
|
nodeID = "local"
|
||||||
|
}
|
||||||
|
ttl := time.Duration(r.cfg.PresenceTTL) * time.Second
|
||||||
|
ctx := context.Background()
|
||||||
|
pipe := r.client.Pipeline()
|
||||||
|
pipe.SAdd(ctx, r.topicOnlineMembersKey(tenantID, topic), uid.UserId())
|
||||||
|
pipe.Expire(ctx, r.topicOnlineMembersKey(tenantID, topic), ttl)
|
||||||
|
pipe.SAdd(ctx, r.topicNodeOnlineMembersKey(tenantID, topic, nodeID), uid.UserId())
|
||||||
|
pipe.Expire(ctx, r.topicNodeOnlineMembersKey(tenantID, topic, nodeID), ttl)
|
||||||
|
_, err := pipe.Exec(ctx)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *redisGroupMessageRuntime) MarkTopicMemberOffline(tenantID types.TenantID, topic string, uid types.Uid, nodeID string) error {
|
||||||
|
if nodeID == "" {
|
||||||
|
nodeID = "local"
|
||||||
|
}
|
||||||
|
ctx := context.Background()
|
||||||
|
pipe := r.client.Pipeline()
|
||||||
|
pipe.SRem(ctx, r.topicOnlineMembersKey(tenantID, topic), uid.UserId())
|
||||||
|
pipe.SRem(ctx, r.topicNodeOnlineMembersKey(tenantID, topic, nodeID), uid.UserId())
|
||||||
|
_, err := pipe.Exec(ctx)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *redisGroupMessageRuntime) GetTopicNodeOnlineMembers(tenantID types.TenantID, topic, nodeID string) ([]types.Uid, error) {
|
||||||
|
if nodeID == "" {
|
||||||
|
nodeID = "local"
|
||||||
|
}
|
||||||
|
members, err := r.client.SMembers(context.Background(), r.topicNodeOnlineMembersKey(tenantID, topic, nodeID)).Result()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
uids := make([]types.Uid, 0, len(members))
|
||||||
|
for _, member := range members {
|
||||||
|
uid := types.ParseUserId(member)
|
||||||
|
if !uid.IsZero() {
|
||||||
|
uids = append(uids, uid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return uids, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *redisGroupMessageRuntime) PublishGroupFanout(originNodeID string, message any) error {
|
||||||
|
if !r.cfg.GroupFanout {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
payload, err := json.Marshal(message)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
envelope, err := json.Marshal(groupFanoutEnvelope{
|
||||||
|
OriginNodeID: originNodeID,
|
||||||
|
Message: payload,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return r.client.Publish(context.Background(), r.groupFanoutChannel(), envelope).Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *redisGroupMessageRuntime) StartGroupFanoutSubscriber(ctx context.Context, nodeID string, handler func([]byte)) error {
|
||||||
|
if !r.cfg.GroupFanout {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
pubsub := r.client.Subscribe(ctx, r.groupFanoutChannel())
|
||||||
|
if _, err := pubsub.Receive(ctx); err != nil {
|
||||||
|
_ = pubsub.Close()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
go func() {
|
||||||
|
defer pubsub.Close()
|
||||||
|
ch := pubsub.Channel()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case msg, ok := <-ch:
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var envelope groupFanoutEnvelope
|
||||||
|
if err := json.Unmarshal([]byte(msg.Payload), &envelope); err != nil || envelope.OriginNodeID == nodeID || len(envelope.Message) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
handler(envelope.Message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *redisGroupMessageRuntime) CacheObject(tenantID types.TenantID, namespace, id string, value any) error {
|
||||||
|
payload, err := json.Marshal(value)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return r.client.Set(context.Background(), r.objectCacheKey(tenantID, namespace, id), payload, r.cacheTTL(namespace)).Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *redisGroupMessageRuntime) GetCachedObject(tenantID types.TenantID, namespace, id string, value any) (bool, error) {
|
||||||
|
payload, err := r.client.Get(context.Background(), r.objectCacheKey(tenantID, namespace, id)).Bytes()
|
||||||
|
if err == redis.Nil {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
if err = json.Unmarshal(payload, value); err != nil {
|
||||||
|
_ = r.client.Del(context.Background(), r.objectCacheKey(tenantID, namespace, id)).Err()
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *redisGroupMessageRuntime) DeleteCachedObject(tenantID types.TenantID, namespace, id string) error {
|
||||||
|
return r.client.Del(context.Background(), r.objectCacheKey(tenantID, namespace, id)).Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *redisGroupMessageRuntime) AddCachedSetMember(tenantID types.TenantID, namespace, id, member string) error {
|
||||||
|
key := r.setCacheKey(tenantID, namespace, id)
|
||||||
|
ctx := context.Background()
|
||||||
|
pipe := r.client.Pipeline()
|
||||||
|
pipe.SAdd(ctx, key, member)
|
||||||
|
pipe.Expire(ctx, key, r.cacheTTL(namespace))
|
||||||
|
_, err := pipe.Exec(ctx)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *redisGroupMessageRuntime) RemoveCachedSetMember(tenantID types.TenantID, namespace, id, member string) error {
|
||||||
|
key := r.setCacheKey(tenantID, namespace, id)
|
||||||
|
ctx := context.Background()
|
||||||
|
pipe := r.client.Pipeline()
|
||||||
|
pipe.SRem(ctx, key, member)
|
||||||
|
pipe.Expire(ctx, key, r.cacheTTL(namespace))
|
||||||
|
_, err := pipe.Exec(ctx)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *redisGroupMessageRuntime) CachedSetContains(tenantID types.TenantID, namespace, id, member string) (bool, error) {
|
||||||
|
return r.client.SIsMember(context.Background(), r.setCacheKey(tenantID, namespace, id), member).Result()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *redisGroupMessageRuntime) AllowRate(tenantID types.TenantID, scope string, limit int, window time.Duration) (bool, int, time.Duration, error) {
|
||||||
|
key := r.rateKey(tenantID, scope)
|
||||||
|
ctx := context.Background()
|
||||||
|
count, err := r.client.Incr(ctx, key).Result()
|
||||||
|
if err != nil {
|
||||||
|
return true, 0, 0, err
|
||||||
|
}
|
||||||
|
if count == 1 {
|
||||||
|
if err = r.client.Expire(ctx, key, window).Err(); err != nil {
|
||||||
|
return true, int(count), 0, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ttl, _ := r.client.TTL(ctx, key).Result()
|
||||||
|
return count <= int64(limit), int(count), ttl, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *redisGroupMessageRuntime) hotSeqIDs(tenantID types.TenantID, topic string, opts *types.QueryOpt, limit int) ([]int64, bool, error) {
|
||||||
|
if opts != nil && len(opts.IdRanges) > 0 {
|
||||||
|
seqs := expandRanges(opts.IdRanges, limit)
|
||||||
|
sort.Slice(seqs, func(i, j int) bool { return seqs[i] > seqs[j] })
|
||||||
|
return seqs, len(seqs) > 0 && len(seqs) <= limit, nil
|
||||||
|
}
|
||||||
|
min := "-inf"
|
||||||
|
max := "+inf"
|
||||||
|
if opts != nil {
|
||||||
|
if opts.Since > 0 {
|
||||||
|
min = strconv.FormatInt(opts.Since, 10)
|
||||||
|
}
|
||||||
|
if opts.Before > 1 {
|
||||||
|
max = strconv.FormatInt(opts.Before-1, 10)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
raw, err := r.client.ZRevRangeByScore(context.Background(), r.hotSeqKey(tenantID, topic), &redis.ZRangeBy{
|
||||||
|
Min: min,
|
||||||
|
Max: max,
|
||||||
|
Offset: 0,
|
||||||
|
Count: int64(limit),
|
||||||
|
}).Result()
|
||||||
|
if err != nil {
|
||||||
|
return nil, false, err
|
||||||
|
}
|
||||||
|
if len(raw) < limit {
|
||||||
|
return nil, false, nil
|
||||||
|
}
|
||||||
|
seqs := make([]int64, 0, len(raw))
|
||||||
|
for _, value := range raw {
|
||||||
|
seq, err := strconv.ParseInt(value, 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return nil, false, err
|
||||||
|
}
|
||||||
|
seqs = append(seqs, seq)
|
||||||
|
}
|
||||||
|
return seqs, true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func expandRanges(ranges []types.Range, limit int) []int64 {
|
||||||
|
if limit <= 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
seqs := make([]int64, 0)
|
||||||
|
for _, rng := range ranges {
|
||||||
|
if rng.Low <= 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if rng.Hi == 0 {
|
||||||
|
seqs = append(seqs, rng.Low)
|
||||||
|
} else {
|
||||||
|
for seq := rng.Low; seq < rng.Hi; seq++ {
|
||||||
|
seqs = append(seqs, seq)
|
||||||
|
if len(seqs) >= limit {
|
||||||
|
return seqs
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(seqs) >= limit {
|
||||||
|
return seqs
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return seqs
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *redisGroupMessageRuntime) seqKey(tenantID types.TenantID, topic string) string {
|
||||||
|
return r.key(tenantID, "topic", topic, "last_seq")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *redisGroupMessageRuntime) uniqueKey(tenantID types.TenantID, topic string, from types.Uid, clientMsgID string) string {
|
||||||
|
return r.key(tenantID, "topic", topic, "uniq", "msg", from.UserId(), clientMsgID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *redisGroupMessageRuntime) hotSeqKey(tenantID types.TenantID, topic string) string {
|
||||||
|
return r.key(tenantID, "topic", topic, "hot_msg_seqs")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *redisGroupMessageRuntime) hotMsgKey(tenantID types.TenantID, topic string, seq any) string {
|
||||||
|
return r.key(tenantID, "topic", topic, "hot_msg", fmt.Sprint(seq))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *redisGroupMessageRuntime) presenceUserKey(tenantID types.TenantID, uid types.Uid) string {
|
||||||
|
return r.key(tenantID, "presence", "user", uid.UserId())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *redisGroupMessageRuntime) presenceSessionsKey(tenantID types.TenantID, uid types.Uid) string {
|
||||||
|
return r.key(tenantID, "presence", "user", uid.UserId(), "sessions")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *redisGroupMessageRuntime) presenceSessionKey(tenantID types.TenantID, uid types.Uid, sid string) string {
|
||||||
|
return r.key(tenantID, "presence", "user", uid.UserId(), "session", sid)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *redisGroupMessageRuntime) presenceNodeUsersKey(tenantID types.TenantID, nodeID string) string {
|
||||||
|
return r.key(tenantID, "presence", "node", nodeID, "users")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *redisGroupMessageRuntime) topicOnlineMembersKey(tenantID types.TenantID, topic string) string {
|
||||||
|
return r.key(tenantID, "topic", topic, "online_members")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *redisGroupMessageRuntime) topicNodeOnlineMembersKey(tenantID types.TenantID, topic, nodeID string) string {
|
||||||
|
return r.key(tenantID, "topic", topic, "node", nodeID, "online_members")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *redisGroupMessageRuntime) groupFanoutChannel() string {
|
||||||
|
return r.key(types.ZeroTenantID, "group_fanout")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *redisGroupMessageRuntime) objectCacheKey(tenantID types.TenantID, namespace, id string) string {
|
||||||
|
return r.key(tenantID, "cache", namespace, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *redisGroupMessageRuntime) setCacheKey(tenantID types.TenantID, namespace, id string) string {
|
||||||
|
return r.key(tenantID, "set", namespace, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *redisGroupMessageRuntime) rateKey(tenantID types.TenantID, scope string) string {
|
||||||
|
return r.key(tenantID, "rate", scope)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *redisGroupMessageRuntime) cacheTTL(namespace string) time.Duration {
|
||||||
|
ttl := r.cfg.CacheTTL
|
||||||
|
if namespace == "verification" || namespace == "action_token" {
|
||||||
|
ttl = r.cfg.ShortTTL
|
||||||
|
}
|
||||||
|
return time.Duration(ttl) * time.Second
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *redisGroupMessageRuntime) key(tenantID types.TenantID, parts ...string) string {
|
||||||
|
all := []string{strings.TrimSuffix(r.prefix, ":"), strconv.FormatInt(int64(tenantID), 10)}
|
||||||
|
all = append(all, parts...)
|
||||||
|
return strings.Join(all, ":")
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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"`
|
||||||
|
|||||||
@@ -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"`
|
||||||
|
|||||||
@@ -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"`
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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?
|
||||||
|
|||||||
@@ -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
|
||||||
}{
|
}{
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 等字段去走文件下载/预览逻辑。
|
||||||