Compare commits

...
25 Commits
Author SHA1 Message Date
richjohnrambo 5cea4d1e2e fix: sync persist conversation system notices 2026-09-24 11:26:27 +08:00
richjohnrambo 5ce04bad05 docs: track pending image review worker 2026-09-24 11:13:30 +08:00
richjohnrambo 48ca3ae684 fix: align member meta default avatars 2026-09-24 11:00:19 +08:00
richjohnrambo 75216c311d fix cluster proxy cleanup routing 2026-09-24 10:18:07 +08:00
richjohnrambo 9893452817 fix: reserve seq for p2p system messages 2026-09-24 10:03:27 +08:00
richjohnrambo a3df9d269c fix: broadcast forwarded messages after kafka publish 2026-09-24 00:00:50 +08:00
richjohnrambo 68a0228dff feat: include avatar url in member public meta 2026-09-23 23:53:26 +08:00
richjohnrambo da02eae6a4 fix: broadcast async forwarded messages after persistence 2026-09-23 18:14:34 +08:00
richjohnrambo 3e92021254 feat: support quoted message headers 2026-09-23 17:46:51 +08:00
richjohnrambo aa9f562e1e refactor: retire legacy tinode database routing 2026-09-23 17:04:30 +08:00
richjohnrambo 204043382c fix: scope login audit records by tenant 2026-09-23 15:54:07 +08:00
richjohnrambo 7d4bc4d049 fix: notify recipients for forwarded messages 2026-09-23 15:18:27 +08:00
Austin 134ac8580e fix(security): 修复宵禁策略未生效于群聊话题的问题
添加注释说明宵禁仅作用于私聊和群聊,同时将话题类型判断从仅私聊扩展至群聊,确保两类消息话题均会应用宵禁策略。
2026-09-23 14:14:44 +08:00
richjohnrambo 40aba2a3f5 fix tenant-scoped user search 2026-09-23 13:45:39 +08:00
Austin 6bbe955e75 feat: 新增共库模式下登录失败日志表及相关功能
- 新增登录失败日志表(共库多租户版),添加 tenant_id 列实现租户隔离
- 调整登录失败日志逻辑,仅记录触发账号锁定的事件
- 新增管理端接口功能,为登录失败日志补充实时锁定状态
- 更新测试用例适配共库模式下的租户过滤逻辑
2026-09-23 13:18:28 +08:00
Austin b8b21d3548 feat(server): 为内部管理查询添加租户隔离支持
实现共库模式下的租户数据隔离,新增 TenantColumn 字段标识查询表的租户列,添加 internalAdminTenantCondition 函数生成租户过滤条件。修改所有内部管理查询的构建逻辑,在列表和详情查询中自动注入租户条件,防止跨租户数据泄漏。同时更新所有资源查询规格,配置对应表的租户列。
2026-09-23 12:11:26 +08:00
richjohnrambo a0d0bf574e unbind device push token on logout 2026-09-23 11:39:16 +08:00
richjohnrambo c4355ac236 fix: accept raw phone secret for personal registration 2026-09-23 11:33:16 +08:00
richjohnrambo ac20f1ed25 fix message mutation responses 2026-09-23 10:47:31 +08:00
richjohnrambo b413560455 fix tenant group topic and member cache 2026-09-23 10:35:06 +08:00
richjohnrambo 6303d07580 fix: avoid stale profile projections in shared schema 2026-09-23 10:28:15 +08:00
richjohnrambo 7f0903cd31 fix tenant scoping for group business tables 2026-09-23 10:10:08 +08:00
richjohnrambo 35a70ce9ff fix group tenant topic creation 2026-09-23 09:59:53 +08:00
richjohnrambo 6fa3009987 fix tenant sharding for group topics 2026-09-23 09:49:14 +08:00
richjohnrambo a968fd252e fix: use shared tables for tenant group members 2026-09-23 09:41:45 +08:00
56 changed files with 2448 additions and 1068 deletions
+2 -2
View File
@@ -80,8 +80,8 @@ See [instructions](./docker/README.md)
5. Open `tinode.conf` (located at `$GOPATH/pkg/mod/github.com/tinode/chat@vX.XX.X/server/`). Check that the database connection parameters are correct for your database. If you are using MySQL make sure [DSN](https://github.com/go-sql-driver/mysql#dsn-data-source-name) in `"mysql"` section is appropriate for your MySQL installation. Option `parseTime=true` is required. 5. Open `tinode.conf` (located at `$GOPATH/pkg/mod/github.com/tinode/chat@vX.XX.X/server/`). Check that the database connection parameters are correct for your database. If you are using MySQL make sure [DSN](https://github.com/go-sql-driver/mysql#dsn-data-source-name) in `"mysql"` section is appropriate for your MySQL installation. Option `parseTime=true` is required.
```js ```js
"mysql": { "mysql": {
"dsn": "root@tcp(localhost)/tinode?parseTime=true", "dsn": "root@tcp(localhost)/meeting_im?parseTime=true",
"database": "tinode" "database": "meeting_im"
}, },
``` ```
+4 -4
View File
@@ -364,7 +364,7 @@ func (a *audit) run() {
if alice == nil || bob == nil || a.groupTopic == "" { if alice == nil || bob == nil || a.groupTopic == "" {
return "FAIL: websocket client or group missing" return "FAIL: websocket client or group missing"
} }
text := "WS 群消息 " + suffix text := "group audit message " + suffix
seq, topic, err := alice.pubWithHeadAndPeer(bob, a.groupTopic, map[string]any{"mime": "text/plain"}, text) seq, topic, err := alice.pubWithHeadAndPeer(bob, a.groupTopic, map[string]any{"mime": "text/plain"}, text)
if err != nil { if err != nil {
return "FAIL: " + err.Error() return "FAIL: " + err.Error()
@@ -377,11 +377,11 @@ func (a *audit) run() {
return "FAIL: websocket client missing" return "FAIL: websocket client missing"
} }
head := map[string]any{"mime": "text/plain", "x-im-client-mid": "dedup-" + suffix} head := map[string]any{"mime": "text/plain", "x-im-client-mid": "dedup-" + suffix}
first, err := alice.pubCtrl(a.groupTopic, head, "群消息幂等测试 "+suffix) first, err := alice.pubCtrl(a.groupTopic, head, "group dedup audit "+suffix)
if err != nil { if err != nil {
return "FAIL: first pub: " + err.Error() return "FAIL: first pub: " + err.Error()
} }
second, err := alice.pubCtrl(a.groupTopic, head, "群消息幂等测试 "+suffix) second, err := alice.pubCtrl(a.groupTopic, head, "group dedup audit "+suffix)
if err != nil { if err != nil {
return "FAIL: duplicate pub: " + err.Error() return "FAIL: duplicate pub: " + err.Error()
} }
@@ -406,7 +406,7 @@ func (a *audit) run() {
return fmt.Sprintf("anchor_seq=%d items=%d", anchor, count) return fmt.Sprintf("anchor_seq=%d items=%d", anchor, count)
}) })
a.step("WS-22G", "群聊天记录搜索", "HTTP", "/v1/im/topics/{topic}/messages/search", func() string { a.step("WS-22G", "群聊天记录搜索", "HTTP", "/v1/im/topics/{topic}/messages/search", func() string {
status, r, err := a.call("GET", "/v1/im/topics/"+url.PathEscape(a.groupTopic)+"/messages/search?q="+url.QueryEscape("WS 群消息")+"&type=text", nil, a.tokenB) status, r, err := a.call("GET", "/v1/im/topics/"+url.PathEscape(a.groupTopic)+"/messages/search?q="+url.QueryEscape("group audit message")+"&type=text", nil, a.tokenB)
if err != nil { if err != nil {
return "FAIL: " + err.Error() return "FAIL: " + err.Error()
} }
+2 -2
View File
@@ -203,9 +203,9 @@ You can specify the following environment variables when issuing `docker run` co
| `FS_CORS_ORIGINS`[^2] | string | `["*"]` | Cors origins when media is served from the file system. See `AWS_CORS_ORIGINS` for details. | | `FS_CORS_ORIGINS`[^2] | string | `["*"]` | Cors origins when media is served from the file system. See `AWS_CORS_ORIGINS` for details. |
| `ICE_SERVERS_FILE`[^2] | string | | Path to JSON file with configuration of ICE servers to be used for video calls. | | `ICE_SERVERS_FILE`[^2] | string | | Path to JSON file with configuration of ICE servers to be used for video calls. |
| `MEDIA_HANDLER`[^2] | string | `sys_config` | Legacy override for the large file handler. The generated runtime config uses `sys_config` so production can resolve `oss` from backend configuration. | | `MEDIA_HANDLER`[^2] | string | `sys_config` | Legacy override for the large file handler. The generated runtime config uses `sys_config` so production can resolve `oss` from backend configuration. |
| `MYSQL_DSN`[^2] | string | <code>'root@tcp(mysql)/tinode?&#8203;parseTime=true&#8203;&collation=utf8mb4_0900_ai_ci'</code> | MySQL [DSN](https://github.com/go-sql-driver/mysql#dsn-data-source-name). | | `MYSQL_DSN`[^2] | string | <code>'root@tcp(mysql)/meeting_im?&#8203;parseTime=true&#8203;&collation=utf8mb4_0900_ai_ci'</code> | MySQL [DSN](https://github.com/go-sql-driver/mysql#dsn-data-source-name). |
| `PLUGIN_PYTHON_CHAT_BOT_ENABLED`[^2] | bool | `false` | Enable calling into the plugin provided by Python chatbot. | | `PLUGIN_PYTHON_CHAT_BOT_ENABLED`[^2] | bool | `false` | Enable calling into the plugin provided by Python chatbot. |
| `POSTGRES_DSN`[^2] | string | <code>'postgresql://postgres:postgres@&#8203;localhost:5432/tinode?&#8203;sslmode=disable&#8203;&connect_timeout=10'</code> | PostgreSQL [DSN](https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNSTRING). | | `POSTGRES_DSN`[^2] | string | <code>'postgresql://postgres:postgres@&#8203;localhost:5432/meeting_im?&#8203;sslmode=disable&#8203;&connect_timeout=10'</code> | PostgreSQL [DSN](https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNSTRING). |
| `RESET_DB` | bool | `false` | Drop and recreate the database. | | `RESET_DB` | bool | `false` | Drop and recreate the database. |
| `SAMPLE_DATA` | string | _see comment →_ | File with sample data to load. Default `data.json` when resetting or generating new DB, none when upgrading. Use `` (empty string) to disable. | | `SAMPLE_DATA` | string | _see comment →_ | File with sample data to load. Default `data.json` when resetting or generating new DB, none when upgrading. Use `` (empty string) to disable. |
| `SMTP_AUTH_MECHANISM`[^2] | string | `"plain"` | SMTP authentication mechanism to use; one of "login", "cram-md5", "plain". | | `SMTP_AUTH_MECHANISM`[^2] | string | `"plain"` | SMTP authentication mechanism to use; one of "login", "cram-md5", "plain". |
+1 -1
View File
@@ -112,7 +112,7 @@ ENV ADMIN_INTERNAL_API_SECRET=
ENV ADMIN_INTERNAL_API_TIMEOUT_MS=3000 ENV ADMIN_INTERNAL_API_TIMEOUT_MS=3000
# 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/meeting_im?sslmode=disable&connect_timeout=10'
# Disable chatbot plugin by default. # Disable chatbot plugin by default.
ENV PLUGIN_PYTHON_CHAT_BOT_ENABLED=false ENV PLUGIN_PYTHON_CHAT_BOT_ENABLED=false
+1 -1
View File
@@ -14,7 +14,7 @@ COPY . .
RUN CGO_ENABLED=0 go build -tags mysql -o /out/tinode ./server RUN CGO_ENABLED=0 go build -tags mysql -o /out/tinode ./server
RUN CGO_ENABLED=0 go build -tags mysql -o /out/init-db ./tinode-db RUN CGO_ENABLED=0 go build -tags mysql -o /out/init-db ./tinode-db
# scripts/apply_hotgo_tenant_db.go is `//go:build ignore`-tagged (a standalone # scripts/apply_hotgo_tenant_db.go is `//go:build ignore`-tagged (a standalone
# migration script, not part of any package) - build it by exact file path. # startup database validator, not part of any package) - build it by exact path.
RUN CGO_ENABLED=0 go build -o /out/apply-hotgo-tenant-db ./scripts/apply_hotgo_tenant_db.go RUN CGO_ENABLED=0 go build -o /out/apply-hotgo-tenant-db ./scripts/apply_hotgo_tenant_db.go
# ---- Stage 2: runtime image ---- # ---- Stage 2: runtime image ----
+2 -2
View File
@@ -91,11 +91,11 @@
"dsn": "$POSTGRES_DSN" "dsn": "$POSTGRES_DSN"
}, },
"rethinkdb": { "rethinkdb": {
"database": "tinode", "database": "meeting_im",
"addresses": "rethinkdb" "addresses": "rethinkdb"
}, },
"mongodb": { "mongodb": {
"database": "tinode", "database": "meeting_im",
"addresses": "mongodb", "addresses": "mongodb",
"replica_set": "rs0" "replica_set": "rs0"
} }
+2 -2
View File
@@ -164,8 +164,8 @@ else
exit 1 exit 1
fi fi
# Create/upgrade the hotgo tenant registry and the workbench/org/meetings/ # Validate that startup is pointed at the shared meeting_im database. Schema
# reports/mailbox/etc. tables that init-db above does not manage. # changes are applied separately and are never performed during pod startup.
run_with_retries "apply-hotgo-tenant-db" apply_hotgo_tenant_db_once run_with_retries "apply-hotgo-tenant-db" apply_hotgo_tenant_db_once
if [ $? -ne 0 ]; then if [ $? -ne 0 ]; then
echo "./apply-hotgo-tenant-db failed. Quitting." echo "./apply-hotgo-tenant-db failed. Quitting."
+3
View File
@@ -343,6 +343,7 @@ X-MeetingIM-APIKey: <预置的普通 API Key>
| 类型 | 客户端发送 | 服务端返回/下行示例 | | 类型 | 客户端发送 | 服务端返回/下行示例 |
| --- | --- | --- | | --- | --- | --- |
| 文本消息 | `{"pub":{"id":"pub-text-1","topic":"p2p...","noecho":false,"head":{"mime":"application/json","x-im-schema":1,"x-im-type":"text","x-im-client-mid":"mid_1"},"content":{"text":"你好"}}}` | 发送方收到 `{"ctrl":{"id":"pub-text-1","code":202,"text":"accepted","params":{"seq":12}}}`;订阅者收到 `{"data":{"topic":"p2p...","seq":12,"from":"usr...","head":{"x-im-type":"text","x-im-client-mid":"mid_1"},"content":{"text":"你好"}}}` | | 文本消息 | `{"pub":{"id":"pub-text-1","topic":"p2p...","noecho":false,"head":{"mime":"application/json","x-im-schema":1,"x-im-type":"text","x-im-client-mid":"mid_1"},"content":{"text":"你好"}}}` | 发送方收到 `{"ctrl":{"id":"pub-text-1","code":202,"text":"accepted","params":{"seq":12}}}`;订阅者收到 `{"data":{"topic":"p2p...","seq":12,"from":"usr...","head":{"x-im-type":"text","x-im-client-mid":"mid_1"},"content":{"text":"你好"}}}` |
| 引用回复文本 | `{"pub":{"id":"pub-reply-1","topic":"p2p...","head":{"mime":"application/json","x-im-schema":1,"x-im-type":"text","x-im-client-mid":"mid_reply_1","x-im-reply":{"seq":128,"type":"text","text":"今天晚上一起吃饭吗?","from":"usr..."}},"content":{"text":"好啊"}}}` | `x-im-reply` 必须保留在 `head` 中;服务端不解析业务语义,入库、实时下行和历史读取均原样返回 |
| 图片消息 | `{"pub":{"id":"pub-img-1","topic":"grp...","head":{"mime":"application/json","x-im-schema":1,"x-im-type":"image","x-im-client-mid":"mid_img_1"},"content":{"file_id":"file_img","thumbnail_file_id":"file_thumb","name":"a.jpg","size":12345,"mime":"image/jpeg","width":1080,"height":720}}}` | `{"ctrl":{"id":"pub-img-1","code":202,"params":{"seq":13}}}` | | 图片消息 | `{"pub":{"id":"pub-img-1","topic":"grp...","head":{"mime":"application/json","x-im-schema":1,"x-im-type":"image","x-im-client-mid":"mid_img_1"},"content":{"file_id":"file_img","thumbnail_file_id":"file_thumb","name":"a.jpg","size":12345,"mime":"image/jpeg","width":1080,"height":720}}}` | `{"ctrl":{"id":"pub-img-1","code":202,"params":{"seq":13}}}` |
| 语音消息 | `{"pub":{"id":"pub-voice-1","topic":"grp...","head":{"mime":"application/json","x-im-schema":1,"x-im-type":"voice","x-im-client-mid":"mid_voice_1"},"content":{"file_id":"file_voice","duration_ms":8000,"size":4096,"mime":"audio/aac","waveform":[1,3,5,2]}}}` | `{"ctrl":{"id":"pub-voice-1","code":202,"params":{"seq":14}}}` | | 语音消息 | `{"pub":{"id":"pub-voice-1","topic":"grp...","head":{"mime":"application/json","x-im-schema":1,"x-im-type":"voice","x-im-client-mid":"mid_voice_1"},"content":{"file_id":"file_voice","duration_ms":8000,"size":4096,"mime":"audio/aac","waveform":[1,3,5,2]}}}` | `{"ctrl":{"id":"pub-voice-1","code":202,"params":{"seq":14}}}` |
| 文件消息 | `{"pub":{"id":"pub-file-1","topic":"grp...","head":{"mime":"application/json","x-im-schema":1,"x-im-type":"file","x-im-client-mid":"mid_file_1"},"content":{"file_id":"file_pdf","name":"方案.pdf","size":102400,"mime":"application/pdf","extension":"pdf","checksum":"sha256:..."}}}` | `{"ctrl":{"id":"pub-file-1","code":202,"params":{"seq":15}}}` | | 文件消息 | `{"pub":{"id":"pub-file-1","topic":"grp...","head":{"mime":"application/json","x-im-schema":1,"x-im-type":"file","x-im-client-mid":"mid_file_1"},"content":{"file_id":"file_pdf","name":"方案.pdf","size":102400,"mime":"application/pdf","extension":"pdf","checksum":"sha256:..."}}}` | `{"ctrl":{"id":"pub-file-1","code":202,"params":{"seq":15}}}` |
@@ -647,6 +648,8 @@ X-MeetingIM-APIKey: <预置的普通 API Key>
`x-im-client-mid` 由客户端生成 ULID/UUID,作用域为“租户 + 发送人”,断线重试必须复用;服务端目标实现应返回第一次写入的同一 `seq`。未知 `x-im-type` 显示“暂不支持的消息”,不得崩溃或删除消息。 `x-im-client-mid` 由客户端生成 ULID/UUID,作用域为“租户 + 发送人”,断线重试必须复用;服务端目标实现应返回第一次写入的同一 `seq`。未知 `x-im-type` 显示“暂不支持的消息”,不得崩溃或删除消息。
引用回复使用 `head.x-im-reply` 承载客户端生成的轻量快照,字段为 `seq:number`、`type:string`、`text:string`,可选 `from:string`。该对象必须放在 `head`,不能放入 `content`;服务端只负责把未知 `head` 字段随消息存储、实时转发、历史拉取和 HTTP 查询一起原样返回,不对被引用消息做二次查询或语义校验。客户端兼容历史服务端将 `x-im-reply` 作为 JSON 字符串返回的情况,但新版本客户端发送时必须使用 JSON 对象。
消息类型首版固定为:`text`、`image`、`video`、`voice`、`file`、`contact_card`、`link`、`forward_bundle`、`call`、`system_notice`、`system_action`、`system_event`。自定义表情启用后追加 `sticker`。媒体内容只保存 `file_id`、文件元数据和缩略图 ID,不保存可长期绕过鉴权的公共 URL。`system_notice` 和 `system_action` 只能由服务端生成,普通用户构造后发送必须被服务端拒绝。 消息类型首版固定为:`text`、`image`、`video`、`voice`、`file`、`contact_card`、`link`、`forward_bundle`、`call`、`system_notice`、`system_action`、`system_event`。自定义表情启用后追加 `sticker`。媒体内容只保存 `file_id`、文件元数据和缩略图 ID,不保存可长期绕过鉴权的公共 URL。`system_notice` 和 `system_action` 只能由服务端生成,普通用户构造后发送必须被服务端拒绝。
### 0.7 HTTP 幂等、重试和安全 ### 0.7 HTTP 幂等、重试和安全
+523
View File
@@ -0,0 +1,523 @@
# IM 后端问题清单
_2026-09-14 全面复核:本次对现有33条逐条重新核实(能实测的实测,能读代码的读代码,均标注核实方式),并把此前散落在各处(`docs/IM-后端问题清单-2026-09-04.md`、历次前端重写工程记录)但从未收录进本清单的问题一并归档,同时确认了几条此前记录、后来查明其实是前端自己的bug(已在下方"已排除"列出,不再作为后端问题)。这次复核期间发现后端正在把数据库从单机MySQL迁移到新k3s集群的Percona XtraDB Cluster(PXC)——`server/meetingim.conf`/`tinode.conf`的MySQL/Redis/Kafka地址已经从旧VM改成新集群地址,代码里也新增了专门处理PXC兼容性问题(命名锁、自增ID越界)的分支——这是今天新发现的几个MySQL相关问题(尤其是周期性的驱动层连接损坏)很可能与之相关的重要背景,供后续排查参考。_
## 目录
**P0 · 安全漏洞 / 核心功能完全不可用**
暂无。
**P1 · 数据完整性 / 权限失效 / 主要功能缺陷**
| 编号 | 接口 | 一句话 |
|---|---|---|
| [问题36](#问题36) | `POST /messages/forward` | 并发转发下响应说"失败"其实已经真实写入——2026-09-14用干净的12并发复测,仍有2/3的"失败"响应对应真实落库的消息,最近一次代码改动(`926b4bb`)改善了部分逻辑但未解决根本问题 |
| [新增80](#新增80) | Bearer Token鉴权(影响所有需要登录态的接口) | 同一个token并发请求,20-45%概率随机返回401 AUTH_REQUIRED,鉴权层把"token真无效"和"Redis/DB瞬时报错"统一处理成同一个错误码且完全不打日志,无法从现有日志区分具体原因 |
| [新增81](#新增81) | `GET /v0/file/s/{id}`(媒体文件服务) | 查询数据库里不存在的列`mtg.recorder_user_id`,25分钟观测窗口内命中这条SQL的请求243次全部失败 |
**P2 · 数据一致性 / 体验缺口 / 校验缺失**
| 编号 | 接口 | 一句话 |
|---|---|---|
| [问题25](#问题25) | `POST /messages/forward`、`/favorites/forward` | 带附言转发时 `head` 声明类型与 `content` 实际结构对不上 |
| [问题27](#问题27) | `GET /reports/daily` | `status` 参数不做取值校验,传任何值都返回200+空列表 |
| [问题30](#问题30) | `GET /system-notifications` | `summary`/`action`结构不一致,且会议响应事件被写成两条重复通知 |
| [问题33](#问题33) | `GET /reports/daily` | `permissions` 四个字段恒为同一组硬编码值,与用户/参数无关 |
| [问题35](#问题35) | `GET /users/{id}/profile` | 设了备注之后拿不到对方的原昵称(`GET /friends`一侧已修复) |
| [新增3](#新增3) | `PATCH /conversations/{topic}/settings` | 单聊置顶没有批量查询接口,会话列表永远看不出置顶效果 |
| [新增8](#新增8) | `GET /friends` | 完全不感知拉黑关系,与另外两个接口表现不一致 |
| [新增15](#新增15) | `POST /meetings/{id}/cancel` | 完全不校验 `version`,乐观锁摆设 |
| [新增24](#新增24) | `GET /groups/{topic}`、`/settings` | 群成员数存在两套互相独立、可永久失配的计数器 |
| [新增25](#新增25) | 原生WS `{pub}` | link类型消息协议层可构造,但没有真实用户可从UI触达的发送路径 |
| [新增30](#新增30) | `GET /settings/youth-mode` | 开启后不对任何功能做实际限制,`enforced` 恒为硬编码false(前端已移除此功能,仅保留记录完整性) |
| [新增31](#新增31) | `POST /reports/abuse` | 不校验 `requires_description`,可对必填原因提交空描述 |
| [新增32](#新增32) | `POST /system-notifications/{id}/read` | 对已读通知重复标记,响应回显请求值但真实存储值不变 |
| [新增34](#新增34) | `POST /tenant-applications` | 统一社会信用代码无去重机制,可无限重复提交 |
| [新增41](#新增41) | `POST /groups/{topic}/mutes` | 无角色层级保护,管理员可禁言群主、任何角色可自我禁言 |
| [新增47](#新增47) | `POST /v0/file/u/` | 真实文件上传上限是32MB而非声明的100MB,且从未用于前端预校验 |
| [新增49](#新增49) | `POST /reports/daily`、`/monthly` | 创建接口对日期只校验格式不校验范围,年份0000/2099均可创建 |
| [新增51](#新增51) | `POST /meetings` | 不校验 `attendee_user_ids` 是否真实存在,可创建任意规模"幽灵参会人" |
| [新增54](#新增54) | 日报/月报SUBMIT | 不校验必填内容,可提交完全空白报告 |
| [新增56](#新增56) | `POST /settings/youth-mode/enable` | `policy_version` 不做版本校验,传任意值即被无条件接受(前端已移除此功能) |
| [新增76](#新增76) | `GET /app/releases/latest?platform=web` | `platform` 只接受ios/android,web端完全不支持检查更新 |
| [新增77](#新增77) | `GET /groups/{topic}/announcements/latest` | 响应没有已读人数字段,"已读N/M"这个设计要求的功能无法实现 |
| [新增78](#新增78) | `GET /topics/{topic}/messages/search`、收藏/通话记录的相关字段 | 发送者/对方user_id被裁掉`usr`前缀,或返回内部hash而非可寻址id |
| [新增82](#新增82) | `cache group settings`(内部缓存,影响群相关读接口的性能与正确性) | 群设置缓存反序列化失败,高频出现(242次/25分钟),覆盖大量不同群 |
| [新增83](#新增83) | `media serve`(文件服务) | 死锁(1213/40001)没有捕获重试,命中的请求直接失败 |
| [新增84](#新增84) | 内部后台任务"media gc" | 周期性(约每50秒一次)触发MySQL驱动层连接损坏("busy buffer"/协议序号错乱),与PXC迁移的时间点吻合,怀疑相关 |
| [新增85](#新增85) | `GET /groups/{topic}/members?role=admin` 等按查询参数区分的缓存 | 写操作(如提升管理员)后,特定查询参数组合的结果有90秒以上的缓存滞后 |
| [新增86](#新增86) | `GET /calls` | 响应字段结构与协议文档完全不同(无`peer`对象、无`incoming`布尔值、时间戳拆成4个字段、`topic`是内部hash) |
| [新增87](#新增87) | 群二维码 `POST /groups/{topic}/qrcodes` vs `POST /qrcodes/resolve` | 铸造接口用`group_snapshot`字段名,解析接口对同样的数据用`group`字段名,两个接口不一致;解析接口另有一个协议文档完全没提过的`status`字段 |
| [新增88](#新增88) | `GET /topics/{topic}/messages/search` | 部分明显应该命中的关键词搜索返回空结果(搜索召回不稳定,2026-09-14复测比更早的记录有改善但未完全解决) |
| [新增89](#新增89) | `im_file_review_tasks` / 养成动态图片审核 | IM 仅创建文件审核任务,没有执行 Worker,所有带图动态都会永久停在 `reviewing/pending` |
| (无编号,功能缺口非bug) | 无 | 整份协议文档没有"移出/踢出群成员"这个操作,`03-group.dc.html`里"移出"按钮因此没有对应接口可用——确认是协议本身没设计这个功能,不是遗漏 |
| (无编号,可能是设计而非缺口) | 无 | 无手机号查询/换绑接口、无登录设备列表接口;`POST /security/challenges`这个"敏感操作二次验证"机制目前只服务青少年模式两个场景,没有覆盖改密码等场景 |
---
## 本次复核确认已修复(不再是问题,供归档参考)
以下条目在2026-09-14用真实账号实测或直接读当前代码确认已经修复,从清单中移除:
- **问题22**(无登出/Token撤销接口):**已修复**。`POST /v1/im/auth/logout`现在是真实可用的接口(之前误判404的路径是没有走对,正确路径是这个)。实测:新token连续3次200 → 调用logout返回200 → 同一token之后5次全部401,确认真实吊销,不是假成功。
- **问题28**(`purpose=user_avatar`被拒):**已修复**。实测提交真实PNG文件,200成功。
- **新增16**(总经理信箱超长内容返回500):**已修复**。代码确认`serveIMExecutiveMailboxSubmissions`现在显式捕获`ErrPolicy`并返回400 VALIDATION_FAILED,不再落到default的500分支。
- **新增17**(`writeIMRelationError`把`ErrNotFound`错误映射成`MALFORMED_REQUEST`):**已修复**。代码确认现在`ErrNotFound`正确映射到404+`RESOURCE_NOT_FOUND`,reason与message一致。
- **新增27**(工作台`unread_count`恒为0):**已修复**(2026-09-10记录"只接入bootstrap,workbench本身还没接",现在确认`serveIMWorkbench`内部也调用了`populateWorkbenchModuleUnreadCounts`)。注:目前只有"会议"模块会计算真实值,其余模块代码上仍是原始SQL的0值,但这正好是原始bug报告实测覆盖的场景,已修复。
- **新增42**(群公告按id查询恒定返回最新公告):**已修复**。代码确认现在正确解析路径里的`announcement_id`并调用`GetAnnouncement`按id查询,不再无视这个参数。
- **新增43**(群文件登记接口无长度/存在性校验):**已修复**。代码确认现在对`file_name`(255)/`mime_type`(128)做长度校验,对`file_size`做负数校验,且会调用`store.Files.Get`验证`file_id`真实存在。
- **新增45**(收藏/搜索/宫格分页off-by-one永久丢失边界记录):**已修复**。实测:24条真实收藏,用limit=10分3页拉取(10+10+4),合并后24条一条不少、无重复。
## 本次复核确认仍是历史误判(从未是真正的后端bug,不应再作为后端问题提出)
以下条目此前曾被记录为"疑似后端bug",后来(多为9月上中旬的复核)证实其实是前端自己调用错了接口/传错了参数,backend行为一直是对的——**不要把这几条再当成后端问题反馈**:
- `GET .../groups/{topic}/members/{user_id}/profile` 查自己恒定400——真相:有专门的`members/me/profile`接口可以正确查询,前端读操作一直在用错误的通用by-id接口,写操作反而一直用对了。已切换到正确接口,问题消失。
- `POST .../groups/{topic}/mutes/batch` 恒定405——真相:真实的批量禁言接口是`/mutes`(不带`/batch`后缀),前端一直在调一个不存在的路径。已改用正确路径。
- `GET /users/{id}/profile` 对好友`usrW9ijv236hHA`恒定403——真相:响应体里其实带着`message:"profile is private"`,是对方自己设置了资料保护,属于设计内行为,不是bug。
- `PATCH .../groups/{topic}/member-settings`(置顶/免打扰)恒定400——真相:这个接口的请求体结构里根本没有`version`字段,后端解码器对未声明字段严格拒绝;前端一直在照抄别的接口的乐观锁写法多传了一个`version`字段。已去掉,问题消失。
- 群设置`governance`字段"被后端写死成默认值"——这是一次静态代码审查的猜测,当场用真实接口交叉验证后证明是错的:`/settings`和`/governance`返回一致,写入后能正确反映在读接口上。从未真正成立过。
---
<a id="问题36"></a>
## 问题 36(P1,2026-09-14复测:仍未修复,最近一次代码改动效果有限):并发转发到同一个会话时,响应说"失败"的条目里相当一部分其实已经成功写入,重试会造成真实内容重复
**证据强度**:✅ 实测确认(多轮独立复现,含2026-09-14最新一轮干净复测)+代码确认
**2026-09-14复测方法与结果**:`git pull`确认`server/kafka_message_persist.go`在9月11日有过一次专门的修复提交(`926b4bb fix im forwarding and message list regressions`),代码上看这次改动新增了`Pending`状态(区分"确定成功"/"确定失败"/"不确定,可能已经发出"三种情况,而不是原来的非成功即失败)。但不能只信代码改动就下结论,实际用真实群(`grpDclMebv3MuI`)、真实消息(seq 135)做了一次干净的12并发转发测试(每个请求用独立的幂等键,模拟"用户看到失败提示后重新点击"的真实场景):
- 12个请求里7个报告"sent"成功(seq 136/137/138/139/140/142/143),3个报告"failed"(`INTERNAL_ERROR`),2个命中已知的401鉴权问题(新增80,未到达转发逻辑)。
- 测试前群里搜索"测试"能查到的最大seq是135;测试后变成130-144连续9个新seq(136-144)。
- **9个真实新消息 vs 7个确认成功——多出来的136-144里的141和144,对应的正是那3条报"failed"里的2条,它们实际上真的写入成功了,只是响应告诉客户端"失败"。**
**结论**:本质问题没有解决——响应仍然不可信,"failed"不能被客户端安全地当作"真的没发生"来处理,重试依然会造成真实重复。9月11日的那次改动可能改善了部分场景(比如更细粒度的pending状态跟踪),但没有堵住这个具体的漏洞。
**根因**(此前已逐行核对,本次未见改变):开启Kafka时,`saveMessageMaybeKafka`先原子预留seq,再异步把消息发布到Kafka——如果produce调用本身报错(例如含糊的ack超时),HTTP handler判定失败并返回给客户端,但已预留的seq和可能已经在网络层提交成功、只是没收到ack的Kafka消息都回滚不了,只要Kafka消费者最终异步写入,"失败"响应背后就会真实落库。
**接口**:`POST /v1/im/messages/forward`
**前端现状**:批量转发的失败提示照原样展示后端返回的`status`/`reason`,无法感知也无法规避"响应可能撒谎"这个问题,需要后端修正响应准确性(例如:明确区分"确定失败可安全重试"和"结果不确定,建议先检查再决定是否重试"两种状态,而不是都归为同一种"failed")。
---
<a id="新增80"></a>
## 新增 80(P1,2026-09-14新发现):Bearer Token鉴权在并发请求下随机出现401,且无法从日志定位具体原因
**证据强度**:✅ 实测确认(自己独立复现,两个不同接口各自独立测试)+代码确认(完整调用链,含具体文件行号)
**现象**:用同一个有效token对`GET /v1/im/groups/{topic}`或`GET /v1/im/me`连续发起20个并发请求,大约15%-65%会随机返回`401 {"code":30043,"reason":"AUTH_REQUIRED"}`,比例不稳定(同一token连续测两轮,一轮5/20失败,几秒后另一轮13/20失败)。对照组:同样并发打`POST /v1/im/tenants/resolve`(只需要API Key,不需要bearer token)20/20全部成功,证明问题只出在bearer token鉴权这一层,不是网络或网关层面的问题。
**排除的可能性**:进入当前所有正在运行的3个`im-server` Pod内部,对`AUTH_TOKEN_KEY`/`MYSQL_DSN`/`REDIS_ADDR`/`REDIS_PASSWORD`/`TENANT_REGISTRY_DB`/`API_KEY_SALT`/`UID_ENCRYPTION_KEY`共7个关键配置项,在每个Pod自己进程内部单独计算哈希值做比对(不在外部解密Secret),3个Pod完全一致——排除"多实例配置不一致"这个最初怀疑的方向;3个Pod是同一批次创建、同一个ReplicaSet,没有游离的旧实例。
**根因(读当前`im_be`源码确认,文件行号)**:`GET /v1/im/groups/{topic}`等接口的鉴权链条是 `authenticateIMHTTPBearer`(`server/im_http.go:7093`) → `resolveIMHTTPBearerToken`(`server/im_http.go:7111`) → `(*authenticator).Authenticate`(`server/auth/token/auth_token.go:103`)。这个函数内部,token签名错误、过期、`serial_num`不匹配、Redis撤销状态查询报错(`IsLoginTokenRevoked`)、按租户路由的DB鉴权版本号查询报错(`GetAuthVersion`/`TenantAdapterFor`)——**所有这些情况的错误全部被统一转换成同一个`types.ErrPermissionDenied`**,最终一律报401,且**这条失败路径上完全没有任何日志输出**,导致即使线上真实发生了这类失败,事后也无法从日志区分具体是哪一种原因触发的。
其中`GetAuthVersion`背后的`TenantAdapterFor`(`server/db/mysql/adapter.go:469`)使用**每个进程自己独立维护的懒加载租户连接缓存**,某个租户如果还没被当前进程缓存过,一波并发请求会各自独立地去开一条新数据库连接并`Ping()`,这种"并发抢着建连接"的场景比"某个pod配置固定错误"更能解释"20个并发里随机几个失败、且失败比例还会波动"这个现象。
**支持证据**:查了同一时间窗口的k8s事件,3个pod中有2个在过去1小时内真实触发过健康检查失败(`Unhealthy: context deadline exceeded`,探测超时只给了1秒);同时机器上2/3节点内存占用在69%-81%(这是个多个服务共享的测试集群)。另外,MySQL的`aborted_connects`计数器非零(13),跟"存在瞬时连接失败"这个方向一致,但不是决定性证据。
**重要背景**:这次复核时发现后端仓库刚经历一次数据库基础设施迁移——`server/meetingim.conf`/`tinode.conf`里的MySQL/Redis/Kafka地址从旧VM换成了新k3s集群的PXC(Percona XtraDB Cluster)地址,且代码里新增了`isPXCStrictModeNamedLockError`/`isIDOutOfRange`两个专门处理PXC兼容性问题的错误分支,说明这个迁移目前还在持续排查兼容性问题的阶段。这个401问题的时间点与这次迁移吻合,虽然鉴权代码本身没有直接调用命名锁,但不排除是同一次基础设施变动带来的连锁不稳定,建议后端一并排查。
**接口**:影响所有需要bearer token鉴权的接口(穷举测试了`GET /v1/im/groups/{topic}`、`GET /v1/im/me`两个,机制上应该影响全部同类接口)
**给后端的建议**:(1)在这条失败路径上补充日志,区分"token确实无效"和"内部依赖(Redis/DB)报错"两种情况,不要两者都报同一个401;(2)审视`TenantAdapterFor`懒加载连接的并发安全性,避免同一租户的并发请求各自重复建连接。
---
<a id="新增81"></a>
## 新增 81(P1,2026-09-14新发现):文件服务查询数据库里不存在的列,几乎每次命中都失败
**证据强度**:✅ 实测确认(真实运行日志,25分钟窗口内3个pod合计243次)
**现象**:`media serve`(文件服务,`/v0/file/s/{id}`)报错`Error 1054 (42S22): Unknown column 'mtg.recorder_user_id' in 'where clause'`,25分钟的观测窗口里3个pod合计出现243次,是这次复核发现的所有问题里频率最高的一个——几乎所有命中这条SQL路径的真实请求都会失败。
**推测**:列名前缀`mtg`大概率是"会议(meeting)"相关表,可能是代码引用了一个数据库里还不存在的列(缺一次迁移),也可能是列名本身写错了。不确定具体原因,需要后端自己确认。
**接口**:`GET /v0/file/s/{id}`(原生Tinode大文件下载,命中会议关联文件时)
**基础设施侧的态度**:这类"代码查询数据库结构对不上"的问题,本身可能是缺一次迁移,但我们不建议自己去手动改数据库列(不清楚正确的类型/默认值/是否有对应的后端代码逻辑依赖这个列的具体语义),必须由后端确认。
---
<a id="问题25"></a>
## 问题 25(P2,最常见路径已修故降级):带附言转发/收藏转发时,`head` 声明的类型与 `content` 的实际结构对不上
**证据强度**:✅ 实测确认(四条路径全部真实构造验证)
**现象**:逐条转发带附言、收藏转发带附言两条路径,`head`仍声明原消息类型(如image/text),但`content`换成`{forwarded,comment,snapshot}`等包装对象,客户端按head类型解析content会得到空白/无效内容。逐条转发不带附言这条最常见路径已经修好(content原样透传)。
**期望**:附言作为独立的一条文本消息发送(对接文档本来就是这么描述的),转发的那条消息仍然原样透传原content。
**接口**:`POST /v1/im/messages/forward`;`POST /v1/im/favorites/forward`
---
<a id="问题27"></a>
## 问题 27(P2):`status` 参数不做取值校验,传任何值都返回200+空列表
**证据强度**:✅ 实测确认(2026-09-14重测,传`status=totally_bogus_value_xyz`依然200+`items:[]`,未变)
**现象**:传完全不存在的status值也返回200,客户端无法区分"确实没数据"和"根本不支持"。
**接口**:`GET /v1/im/reports/daily?status=`(月报同构)
---
<a id="问题30"></a>
## 问题 30(P2):系统通知的 `summary` 要么是空串、要么是机器码,`action` 的结构因来源而异,且会议响应事件会被写成两条重复通知
**证据强度**:✅ 实测确认(三种summary情况+两条重复通知具体案例)+代码确认(重复通知根因)
**现象**:`summary`有时空串、有时机器码(如`accepted`/`rejected`)、有时真实数据;`action`是自由结构,不同来源放的键完全不同;会议响应更新会被两条独立代码路径各写一条内容几乎相同、结构不同的重复通知。
**根因(重复通知)**:`serveIMMeetingRespond`同时调用受开关`imReliableHTTPEventsEnabled`控制的`publishIMMeetingEvent`(生成`{meeting_id}`形态)和无条件执行的`publishIMWorkbenchAndAudit`(经Kafka产生`{module,resource_id}`形态),该开关对标准请求头调用恒为true。
**接口**:`GET /v1/im/system-notifications`
**前端现状**:`summary`能在已知事件码表里查到就替换成中文,查不到就原样显示;跳转按钮按action里出现的键分派;重复通知没有做任何去重(不能安全启发式判断"内容相同就是重复")。
---
<a id="问题33"></a>
## 问题 33(P2):`permissions` 的四个字段在不同参数下恒为同一组值
**证据强度**:✅ 代码确认(2026-09-14重读源码,确认仍是硬编码map字面量,行号已随文件改动漂移到2614-2619行左右)
**现象**:`can_create`/`can_remind_unsubmitted`/`can_view_managed`/`makeup_allowed_days`换任何用户、scope、日期区间组合都恒为同一组值,因为响应体里这部分是Go代码写死的map字面量,完全不引用`principal`/`reports`/任何请求参数。
**接口**:`GET /v1/im/reports/daily`
---
<a id="问题35"></a>
## 问题 35(P2,`GET /friends`一侧已修复,范围收窄到`GET /users/{id}/profile`):设了备注之后,好友主页详情接口拿不到对方的原昵称
**证据强度**:✅ 实测确认(2026-09-14再次复核,问题依然存在)
**现象**:`GET /v1/im/friends`响应里带的嵌套`peer`对象(`peer.display_name`)能正确带对方的真实原始昵称,这一侧的诉求已经满足。但`GET /v1/im/users/{id}/profile`依然没有修:既没有remark字段,也没有独立的原昵称字段——保存备注后本地乐观更新正确显示新备注,但一旦触发这个详情接口的背景刷新重新拉取,会因为接口不带remark字段而把显示"冲"回"未设置",数据没有真的丢,只是这个详情页的显示会"看起来复原了"。
**接口**:`GET /v1/im/users/{id}/profile`(仍缺);对照已修复的`GET /v1/im/friends`
**期望**:`GET /users/{id}/profile`比照`GET /friends`已经落地的`peer`对象模式,补上对方的真实原始昵称字段。
---
<a id="新增3"></a>
## 新增 3(P2):单聊的"置顶聊天"没有任何办法让会话列表知道,因为没有批量查询会话设置的接口
**证据强度**:✅ 实测确认(对照群聊置顶排除法)
**现象**:群聊置顶写入订阅原生private字段能随meta同步下发;单聊置顶写入独立表且不写private字段,只有单条查询接口无批量接口,前端无法为几十上百个会话逐一查询,设置生效但用户看不出效果。
**接口**:`PATCH /v1/im/conversations/{topic}/settings`(写入);缺失批量查询接口
---
<a id="新增8"></a>
## 新增 8(P2):`GET /friends` 完全不感知拉黑关系,与另外两个接口表现不一致
**证据强度**:✅ 实测确认+代码确认
**现象**:拉黑后该好友仍显示`relationship:"friend"`且权限位全开,与`GET /conversations/{topic}/context`(正确显示`blocked_by_me`)、`GET /users/{id}/profile`(裸403)三者互不一致。
**根因**:`friendListItemPayload`及数据源`types.FriendListItem`从结构体到SQL全程未涉及黑名单表。
**接口**:`GET /v1/im/friends`;对照`GET /v1/im/users/{id}/profile`、`GET /v1/im/conversations/{topic}/context`
---
<a id="新增15"></a>
## 新增 15(P2):`POST /meetings/{id}/cancel` 完全不校验 `version`
**证据强度**:✅ 实测确认+代码确认(2026-09-14重读源码,`serveIMMeetingCancel`依然只解析`Version int`但从未在调用`store.Meetings.Cancel`时传入这个字段)
**现象**:故意传`version:999`也直接成功,与同资源my-response接口(版本校验正常409)形成对照——乐观锁形同虚设。
**接口**:`POST /v1/im/meetings/{id}/cancel`
---
<a id="新增24"></a>
## 新增 24(P2):群成员数存在两套互相独立、可永久失配的计数器
**证据强度**:✅ 实测确认(3个真实群交叉对照)+代码确认(2026-09-14重读源码确认未变)
**现象**:同一群同一时刻,`GET /groups/{topic}`与`/settings`显示的`member_count`,跟真实成员列表、qrcodes的`group_snapshot`可能不一致。
**根因**:业务层`im_group_settings.member_count`(只看`im_group_members`)与Tinode原生层`im_topics.subcnt`(只看`im_subscriptions`)是两套独立计数器,`mergeGroupSettingsPayload`(`server/im_http.go:8012`)无条件用业务层值覆盖了本来准确的subcnt值。
**接口**:`GET /v1/im/groups/{topic}`;`/settings`;`POST /groups/{topic}/qrcodes`;`GET /groups/{topic}/members`
---
<a id="新增25"></a>
## 新增 25(P2,产品需求缺口):link类型消息——协议层面可构造,但没有任何真实用户可从webapp UI触达的路径
**证据强度**:✅ 实测确认(正反两方向均构造真实数据验证)
**现象**:WS`{pub}`可直接构造出`content:{url:...}`扁平结构被判定为link类型(可收藏可搜索),但`im_webapp`所有正常发送路径产出的content都不是这个形状,"我的收藏-链接"等tab对真实用户永远是空的。
**接口**:WS `{pub}`(消息发送),无特定REST路径
---
<a id="新增30"></a>
## 新增 30(P2):青少年模式开启后不对任何功能做实际限制,`enforced` 恒为硬编码false
**证据强度**:✅ 实测确认(2026-09-14重新实测,未变)+代码确认
**现象**:restriction_items三项(深夜免打扰/内容过滤/支付守护)的`enforced`恒为硬编码false,全仓库穷举确认没有任何消息发送/好友添加/群聊/支付相关接口会查询用户的青少年模式状态。
**接口**:`GET /v1/im/settings/youth-mode`
**⚠️ 注**:`im_webapp`已明确移除青少年模式这个前端功能(用户产品决策,非技术限制),这条对当前`im_webapp`已无实际影响,仅为完整性保留在清单里。
---
<a id="新增31"></a>
## 新增 31(P2):`POST /reports/abuse` 不校验 `requires_description`,可以对必填原因提交空描述
**证据强度**:✅ 实测确认+代码确认(2026-09-14重读源码确认未变)
**现象**:某原因码(如illegal/other)的`requires_description`为true,但提交该reason_code、description为空依然200成功——代码里只校验了`description`长度上限(500),从未检查对应`reason_code`是否要求必填。
**接口**:`POST /v1/im/reports/abuse`
---
<a id="新增32"></a>
## 新增 32(P2):系统通知已读接口对已有 `read_at` 的通知,响应会原样回显客户端提交的值,但真实存储值不变
**证据强度**:✅ 实测确认+代码确认(2026-09-14重读源码确认未变)
**现象**:对已有read_at的通知重复调用已读接口,响应200并原样回显本次提交的read_at(哪怕故意传明显不同值),但真实存储的read_at纹丝不动。
**根因**:`TenantSystemNotificationMarkRead`执行`UPDATE...SET read_at=COALESCE(read_at,?)`,已非空时保留原值;`serveIMSystemNotificationRead`(`server/im_http.go:3576`)直接回显请求体局部变量`readAt`,从未回读真实值。
**接口**:`POST /v1/im/system-notifications/{id}/read`
---
<a id="新增34"></a>
## 新增 34(P2):企业注册申请接口对同一个统一社会信用代码没有任何去重/防重复提交机制
**证据强度**:✅ 实测确认+代码确认(2026-09-14重读源码确认未变)
**现象**:同一信用代码用三个不同幂等键连续提交三次,全部独立成功建单,与需求文档"应防止重复提交"矛盾。代码上`serveIMTenantApplications`直接调用`CreateTenantApplication`,没有先查询该信用代码是否已有pending申请。
**接口**:`POST /v1/im/tenant-applications`
---
<a id="新增41"></a>
## 新增 41(P2):群禁言接口没有任何角色层级保护——管理员可以禁言群主、任何角色都能自我禁言
**证据强度**:✅ 实测确认(代码逐行核对根因,2026-09-14重读源码确认未变)
**现象**:GET成员列表明确标注群主`eligible:false`/`ineligible_reason:"owner"`,但`POST /groups/{topic}/mutes`对群主本人自我禁言、以及管理员禁言群主均返回200成功——代码只检查了操作者本人是否是管理员,从未检查目标成员的角色。
**接口**:`POST /groups/{topic}/mutes`;对照`GET /groups/{topic}/members?mute_eligible=true`
---
<a id="新增47"></a>
## 新增 47(P2):真实文件上传大小上限是32MB,不是仓库配置声明的100MB,且这个真实上限从未被前端用来做任何提前校验
**证据强度**:✅ 实测确认(2026-09-14重测`GET /bootstrap`,`max_file_size`依然是33554432即32MB,与之前记录一致)
**现象**:`GET /v1/im/bootstrap`声明的`data.limits.max_file_size`是33554432(32MB),但代码仓库`tinode.conf`/`meetingim.conf`里配置的是104857600(100MB)——声明值和真实生效值不一致,且这个字段从未被前端读取用于上传前的预校验。
**接口**:`POST /v0/file/u/`(Tinode原生大文件上传);`GET /v1/im/bootstrap`(`data.limits.max_file_size`)
**⚠️ 说明**:此前记录曾提到仓库配置文件已经改成一致的104857600,但线上实测一直是32MB——这次复核确认线上依然是32MB,怀疑部署环境的实际配置和仓库里能看到的配置文件不是同一份,或者中间有额外的网关/环境变量覆盖,建议后端确认线上部署实际生效的配置来源。
---
<a id="新增49"></a>
## 新增 49(P2):日报/月报创建接口对日期只校验格式不校验范围,年份0000、2099均可创建并成功提交
**证据强度**:✅ 实测确认+代码确认(2026-09-14重读源码确认未变)
**现象**:`report_date:"2099-12-31"`、`"0000-01-01"`均能创建、保存草稿、提交成功走完整流程。`parseIMDate`只用`time.ParseInLocation("2006-01-02",...)`校验格式,没有任何年份范围校验。
**接口**:`POST /v1/im/reports/daily`(report_date)、`POST /v1/im/reports/monthly`(month)及对应的`/submit`接口
---
<a id="新增51"></a>
## 新增 51(P2):会议创建接口不校验 `attendee_user_ids` 对应的用户是否真实存在,可创建任意规模的"幽灵参会人"
**证据强度**:✅ 实测确认+代码确认(2026-09-14重读源码确认未变)
**现象**:用50个语法合法但真实不存在的user_id+1个真实好友,一次性创建52人会议成功。`parseIMUserIDList`只做格式校验(`types.ParseUserId`)和去重,从未查询数据库确认这些用户真实存在。
**接口**:`POST /v1/im/meetings`
---
<a id="新增54"></a>
## 新增 54(P2):日报/月报SUBMIT接口不校验必填内容,可提交完全空白的报告,与响应自身声明的 `validation.*_required:true` 矛盾
**证据强度**:✅ 实测确认(现象)+代码确认(2026-09-14找到确切根因:`TenantDailyReportSubmit`的UPDATE语句只匹配`report_id`/`author_id`/`version`,完全不检查`work_summary`/`next_plan`等内容字段)
**现象**:`work_summary`/`next_plan`均为空字符串的月报草稿直接调用submit,返回200且`status:"submitted"`,同时响应体自己声明这两个字段`validation.*_required:true`。
**接口**:`POST /v1/im/reports/daily/{id}/submit`、`POST /v1/im/reports/monthly/{id}/submit`
---
<a id="新增56"></a>
## 新增 56(P2):青少年模式启用接口 `policy_version` 不做版本校验,传任意正整数即被无条件接受并永久覆盖
**证据强度**:✅ 实测确认+代码确认(2026-09-14重读源码确认未变)
**现象**:代码只检查`body.PolicyVersion <= 0`(必须是正数),从未跟服务端已知的当前真实policy_version做比对,传任意大的正整数都会被接受并覆盖存储值。
**接口**:`POST /v1/im/settings/youth-mode/enable`
**⚠️ 注**:同新增30,`im_webapp`已移除此功能,仅为完整性保留记录。
---
<a id="新增76"></a>
## 新增 76(P2):`GET /app/releases/latest` 的 `platform` 参数只接受 `ios`/`android`,web端完全不支持检查更新
**证据强度**:✅ 代码确认(2026-09-14重读当前HEAD确认未变)
**现象**:`GET /v1/im/app/releases/latest?platform=web`恒定`400 MALFORMED_REQUEST {"details":{"field":"platform"}}`。
**接口**:`GET /v1/im/app/releases/latest`
**前端现状**:`AboutPage.tsx`捕获这个错误后显示"暂不支持检查更新",不会一直转圈或显示假的"已是最新版本"。
---
<a id="新增77"></a>
## 新增 77(P2):群公告接口响应里没有已读人数字段,设计要求的"已读N/M"功能无法实现
**证据强度**:✅ 实测确认+代码确认(2026-09-14重读`groupAnnouncementPayload`/`groupAnnouncementPayloadWithPermissions`确认未变)
**现象**:`GET /groups/{topic}/announcements/latest`真实响应确认没有任何"已读数"/"已读人数"相关字段,设计稿明确要求的"已读18/28"这类展示前端拿不到数据来源。
**接口**:`GET /v1/im/groups/{topic}/announcements/latest`
**前端现状**:不展示已读人数(没有编造假数据)。
---
<a id="新增78"></a>
## 新增 78(P2):消息搜索/收藏/通话记录里,涉及用户/话题id的字段有不一致的裁剪或哈希化处理
**证据强度**:✅ 实测确认(三处独立复现)
**现象**:
- `GET /topics/{topic}/messages/search`的`items[].from`、收藏`type:"text"`的`snapshot.from`:真实user_id被裁掉`usr`前缀(如`usrH5dvZ3ckWxo`变成`H5dvZ3ckWxo`),直接拿这个值和当前用户完整id比较会永远为false。
- 收藏的`source.topic`(P2P来源)、通话记录`GET /calls`的`topic`字段:用的是服务端内部`p2p...`哈希形式,不是SDK寻址用的`usr...`形式,客户端没有办法把这个哈希反解回对方user_id。
**接口**:`GET /v1/im/topics/{topic}/messages/search`;收藏接口的`snapshot.from`/`source.topic`;`GET /v1/im/calls`的`topic`
**前端现状**:都补了对应的兜底逻辑(`normalizeSender()`补回`usr`前缀;`p2p`前缀的topic降级显示"单聊消息"通用文案),不会崩溃,只是丢失了直接寻址能力。
---
<a id="新增82"></a>
## 新增 82(P2,2026-09-14新发现):群设置缓存反序列化失败,高频出现,覆盖大量不同群
**证据强度**:✅ 实测确认(真实运行日志,25分钟窗口内3个pod合计242次,覆盖十几个不同真实群)
**现象**:`cache group settings[{topic}]: read failed: Uid.UnmarshalJSON: invalid length`,几乎是常态出现,不是个别数据问题——说明这条缓存读取逻辑本身有系统性bug,大概率是并发访问时缓存里的数据被写坏/读到了不完整的中间状态。其中至少一次复现(`grpDclMebv3MuI`)是被我们自己的并发请求触发的,其余多次是某个后台任务在循环访问一批固定的群时触发的。
**接口**:内部缓存逻辑,不直接对应某个REST接口,但会影响所有需要读取群设置的接口的正确性
---
<a id="新增83"></a>
## 新增 83(P2,2026-09-14新发现):`media serve` 死锁没有捕获重试
**证据强度**:✅ 实测确认(真实运行日志,25分钟窗口内3个pod合计82次)
**现象**:`Error 1213 (40001): Deadlock found when trying to get lock; try restarting transaction`——MySQL自己在错误信息里就写明了"try restarting transaction",这是标准的死锁处理方式,但代码里没有捕获这个特定错误码后自动重试,命中死锁的请求直接失败。
**接口**:`GET /v0/file/s/{id}`(文件服务)
---
<a id="新增84"></a>
## 新增 84(P2,2026-09-14新发现):内部周期性后台任务"media gc"约每50秒触发一次MySQL驱动层连接损坏
**证据强度**:✅ 实测确认(真实运行日志,从09:42到10:03连续观察,间隔稳定在46-67秒之间,与是否有外部请求流量无关)
**现象**:`[mysql] connection.go:215 busy buffer`紧接着`packets.go:68 [warn] unexpected sequence nr: expected 0, got 5`,然后`media gc: N driver: bad connection`——这是MySQL驱动协议层面的连接状态错乱,是同一个进程内部对同一条MySQL连接被并发误用导致的典型症状,不是网络问题。
**与PXC迁移的关系**:这个问题的出现时间与本次复核发现的数据库迁移(旧VM单机MySQL → 新k3s集群PXC)时间点吻合,怀疑与迁移相关,但没有直接证据(比如没有看到这个后台任务代码里调用了新增的PXC兼容分支),仅供后端排查参考。
**接口**:内部后台任务,不直接对应外部接口,但可能影响同一时间段内其它数据库操作的稳定性
---
<a id="新增85"></a>
## 新增 85(P2,历史遗留,曾用编号"新增7"记录在`docs/IM-后端问题清单-2026-09-04.md`但未被带入本清单,本次复核重新收录):按查询参数区分的缓存,写操作后特定参数组合的结果有明显滞后
**证据强度**:✅ 实测确认(提升管理员后90秒以上仍查不到);⚠️2026-09-14本次复核未重新实测(判断优先级较低,仅确认历史记录未遗漏)
**现象**:`POST /groups/{topic}/admins/batch`提升成功(version从1到2),但`GET /groups/{topic}/members?role=admin`反复查询超过90秒仍然是`items:[]`——用直连接口的方式排除了前端自己的客户端缓存,是服务端按`role=admin`这个具体参数组合单独缓存了旧的空结果,写操作没有使它失效。
**接口**:`GET /v1/im/groups/{topic}/members?role=admin`(及其它类似的按查询参数缓存的接口)
---
<a id="新增86"></a>
## 新增 86(P2):`GET /calls` 响应字段结构与协议文档完全不同
**证据强度**:✅ 实测确认(真实响应逐字段核对)
**现象**:协议文档描述的字段形状和真实响应完全对不上——没有`peer`对象,只有扁平的`peer_user_id`(真的是`usr...`形式);没有`incoming`布尔值,而是`direction:"incoming"|"outgoing"`字符串;时间戳拆成了`created_at/started_at/finished_at/updated_at`四个字段,没有单一的`ts`;`topic`字段是`p2p...`哈希形式(同新增78的毛病)。
**接口**:`GET /v1/im/calls`
**前端现状**:已按实测字段改正类型声明和渲染逻辑。
---
<a id="新增87"></a>
## 新增 87(P2):群二维码铸造/解析两个接口对同一份群数据用了不同的字段名,解析接口还多了一个文档没提到的字段
**证据强度**:✅ 实测确认(真实铸造+解析双向验证)
**现象**:`POST /groups/{topic}/qrcodes`(铸造)响应把群信息放在`group_snapshot`字段下;`POST /qrcodes/resolve`(解析,`purpose:"join_group"`)响应把同样性质的数据放在`group`字段下——同一份数据两个接口用了不同字段名。另外`resolve`响应还带了一个协议文档完全没提过的`status`字段,扫描"自己已经在的群"时返回`status:"joined"`;"真的还不是成员"这个场景下`status`具体是什么值没有实测到(当时没有第二个测试账号)。
**接口**:`POST /v1/im/groups/{topic}/qrcodes`;`POST /v1/im/qrcodes/resolve`
**前端现状**:做了双重兜底读取(同时尝试`group_snapshot`和`group`两个字段名);`status`按"不是joined就走提交申请"的宽松逻辑兜底处理。
---
<a id="新增88"></a>
## 新增 88(P2,未定论,需要后续继续观察):部分明显应该命中的关键词搜索返回空结果
**证据强度**:⚠️实测确认现象,但成因未定论
**现象**:在真实群里搜索"验证"/"链接"等明显应该命中历史消息的关键词,一度返回`items:[]`,同一时间搜索"测试"能正常命中。2026-09-14复测:搜索"验证"这次能正确命中真实消息了,看起来比之前有改善;但"链接"依然返回空——不过这次没能确认这个群里是否真的存在包含"链接"二字的消息,不能算作确凿的回归证据。
**接口**:`GET /v1/im/topics/{topic}/messages/search`
**建议**:这条目前证据不够充分下定论(可能是搜索索引/召回策略的问题,也可能是测试数据本身的问题),建议后续有更多测试数据时再复核一次。
---
<a id="新增89"></a>
## 新增 89(P1,2026-09-24):文件审核任务无 Worker 消费,带图养成动态永久无法发布
**证据强度**:✅ 测试环境真实日志 + 数据库 + 代码确认
**现象**:用户发布带图动态后,动态保持 `status=reviewing,audit_status=pending`,`scope=friends`列表只查询 `published` 状态,因此列表返回空。实例动态 `1577072518975639552` 的两条图片审核任务(`aliyun_green` 和 `aliyun_sas`)均为 `pending`。
**根因**:文件上传时会无条件向 `im_file_review_tasks` 插入内容审核和恶意文件检测任务,但 IM 当前仅有阿里云提交/轮询客户端,没有实现读取、执行和回写这些任务的 Worker。测试库 `meeting_im.im_file_review_tasks` 共有 174 条任务,全部为 `pending`,`attempts=0`。
**当前临时策略**(2026-09-24 确认):图片审核暂时关闭并默认通过。`cultivate_server` 仍调用 IM 校验文件存在性、归属和上传完成状态,但不再因文件审核的 `pending/risky` 状态阻塞动态发布。文字内容审核保持原有逻辑。
**待正式解决**:
1. 在 IM 中实现文件审核 Worker,完成任务抢占、阿里云提交/轮询、重试、超时、失败降级和结果回写。
2. 区分内容审核与恶意文件检测是否真正启用,未启用的检测不得创建永久 `pending` 的任务。
3. Worker 稳定上线后恢复 `cultivate_server` 的图片审核门禁,并确定现有 174 条历史任务的补偿或废弃策略。
@@ -56,6 +56,67 @@ ALTER TABLE im_auth
DROP INDEX auth_userid, DROP INDEX auth_userid,
ADD UNIQUE INDEX auth_tenant_userid (tenant_id, userid); ADD UNIQUE INDEX auth_tenant_userid (tenant_id, userid);
-- Topic/subscription runtime indexes for shared-database deployments.
-- These tables are on hot paths for topic lookup, message seq updates,
-- unread calculation and subscription permission checks. Keep tenant_id as
-- the leading column so one tenant does not scan another tenant's rows.
SET @schema_name := DATABASE();
SET @sql := IF((
SELECT COUNT(*) FROM information_schema.statistics
WHERE table_schema = @schema_name
AND table_name = 'im_topics'
AND index_name = 'uk_topics_tenant_name'
) = 0,
'ALTER TABLE im_topics ADD UNIQUE INDEX uk_topics_tenant_name(tenant_id, name)',
'SELECT ''skip uk_topics_tenant_name'' AS status'
);
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @sql := IF((
SELECT COUNT(*) FROM information_schema.statistics
WHERE table_schema = @schema_name
AND table_name = 'im_topics'
AND index_name = 'idx_topics_tenant_name_state_seqid'
) = 0,
'ALTER TABLE im_topics ADD INDEX idx_topics_tenant_name_state_seqid(tenant_id, name, state, seqid)',
'SELECT ''skip idx_topics_tenant_name_state_seqid'' AS status'
);
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @sql := IF((
SELECT COUNT(*) FROM information_schema.statistics
WHERE table_schema = @schema_name
AND table_name = 'im_topics'
AND index_name = 'idx_topics_tenant_owner'
) = 0,
'ALTER TABLE im_topics ADD INDEX idx_topics_tenant_owner(tenant_id, owner)',
'SELECT ''skip idx_topics_tenant_owner'' AS status'
);
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @sql := IF((
SELECT COUNT(*) FROM information_schema.statistics
WHERE table_schema = @schema_name
AND table_name = 'im_subscriptions'
AND index_name = 'idx_subscriptions_tenant_topic_deletedat'
) = 0,
'ALTER TABLE im_subscriptions ADD INDEX idx_subscriptions_tenant_topic_deletedat(tenant_id, topic, deletedat)',
'SELECT ''skip idx_subscriptions_tenant_topic_deletedat'' AS status'
);
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @sql := IF((
SELECT COUNT(*) FROM information_schema.statistics
WHERE table_schema = @schema_name
AND table_name = 'im_subscriptions'
AND index_name = 'idx_subscriptions_tenant_user_topic_deletedat'
) = 0,
'ALTER TABLE im_subscriptions ADD INDEX idx_subscriptions_tenant_user_topic_deletedat(tenant_id, userid, topic, deletedat)',
'SELECT ''skip idx_subscriptions_tenant_user_topic_deletedat'' AS status'
);
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- Default app release rows used by /v1/im/app/releases/latest. -- Default app release rows used by /v1/im/app/releases/latest.
INSERT INTO hotgo.im_app_releases ( INSERT INTO hotgo.im_app_releases (
platform, channel, latest_version, min_supported_version, update_type, platform, channel, latest_version, min_supported_version, update_type,
@@ -1,8 +1,9 @@
-- MySQL 8.0. Admin message governance and authentication audit tables. -- MySQL 8.0. Admin message governance and authentication audit tables.
-- Execute this script in each tenant business database. -- Execute this script in the shared meeting_im database; tenant_id provides tenant isolation.
CREATE TABLE IF NOT EXISTS im_user_login_logs ( CREATE TABLE IF NOT EXISTS im_user_login_logs (
id BIGINT NOT NULL AUTO_INCREMENT, id BIGINT NOT NULL AUTO_INCREMENT,
tenant_id BIGINT NOT NULL,
user_id BIGINT NOT NULL, user_id BIGINT NOT NULL,
username VARCHAR(64) NOT NULL DEFAULT '', username VARCHAR(64) NOT NULL DEFAULT '',
account_type VARCHAR(24) NOT NULL DEFAULT '', account_type VARCHAR(24) NOT NULL DEFAULT '',
@@ -24,6 +25,7 @@ CREATE TABLE IF NOT EXISTS im_user_login_logs (
metadata JSON NULL, metadata JSON NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
PRIMARY KEY (id), PRIMARY KEY (id),
INDEX idx_im_user_login_tenant_time (tenant_id, login_at DESC, id DESC),
INDEX idx_im_user_login_user_time (user_id, login_at DESC, id DESC), INDEX idx_im_user_login_user_time (user_id, login_at DESC, id DESC),
INDEX idx_im_user_login_ip_time (ip, login_at DESC, id DESC), INDEX idx_im_user_login_ip_time (ip, login_at DESC, id DESC),
INDEX idx_im_user_login_device_time (device_id(64), login_at DESC), INDEX idx_im_user_login_device_time (device_id(64), login_at DESC),
@@ -0,0 +1,36 @@
-- 登录失败日志表(共库多租户版)。
-- 背景:登录失败日志仅记录触发账号锁定的事件(见 session.go lockedNow 分支),
-- 普通失败不再落库;锁定计数器存 Redis,im_login_lockouts 不再使用。
-- 执行范围:每个租户业务库(共库模式即 tinode / meeting_im 库各执行一次)。
-- 上游 scripts/IM-租户业务库初始化.sql 中的同名表无 tenant_id 列,勿用旧版执行。
CREATE TABLE IF NOT EXISTS `im_login_failure_logs` (
`id` bigint NOT NULL AUTO_INCREMENT,
`tenant_id` bigint NOT NULL DEFAULT 0,
`user_id` bigint DEFAULT NULL,
`account_type` varchar(24) NOT NULL DEFAULT '',
`account_hash` char(64) NOT NULL DEFAULT '',
`account_mask` varchar(128) NOT NULL DEFAULT '',
`failure_type` varchar(32) NOT NULL,
`reason` varchar(96) NOT NULL DEFAULT '',
`detail` varchar(500) NOT NULL DEFAULT '',
`ip` varchar(64) NOT NULL DEFAULT '',
`region` varchar(128) NOT NULL DEFAULT '',
`device_id` varchar(255) NOT NULL DEFAULT '',
`platform` varchar(32) NOT NULL DEFAULT 'unknown',
`app_version` varchar(32) NOT NULL DEFAULT '',
`user_agent` varchar(512) NOT NULL DEFAULT '',
`risk_level` varchar(16) NOT NULL DEFAULT 'medium',
`risk_action` varchar(24) NOT NULL DEFAULT 'audit_only',
`request_id` varchar(64) NOT NULL DEFAULT '',
`attempt_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`metadata` json DEFAULT NULL,
`created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
PRIMARY KEY (`id`),
KEY `idx_im_login_failure_tenant_time` (`tenant_id`,`attempt_at` DESC,`id` DESC),
KEY `idx_im_login_failure_account_time` (`account_hash`,`attempt_at` DESC,`id` DESC),
KEY `idx_im_login_failure_user_time` (`user_id`,`attempt_at` DESC,`id` DESC),
KEY `idx_im_login_failure_ip_time` (`ip`,`attempt_at` DESC,`id` DESC),
KEY `idx_im_login_failure_type_time` (`failure_type`,`attempt_at` DESC,`id` DESC),
KEY `idx_im_login_failure_request` (`request_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
+37 -35
View File
@@ -1,15 +1,15 @@
-- IM tenant business database initialization script. -- IM tenant business database initialization script.
-- Source baseline: local MySQL database `tinode` on 2026-09-13. -- Source baseline: shared MySQL database `meeting_im` on 2026-09-13.
-- Purpose: create a clean business database for a new IM tenant. -- Purpose: create a clean business database for a new IM tenant.
-- --
-- Usage: -- Usage:
-- 1. Replace placeholders before execution: -- 1. Replace placeholders before execution:
-- __TENANT_DB__ -> new tenant business database, for example `tinode_acme`. -- __TENANT_DB__ -> target tenant business database, normally `meeting_im`.
-- __TENANT_ID__ -> hotgo.im_tenant.id assigned to this tenant. -- __TENANT_ID__ -> hotgo.im_tenant.id assigned to this tenant.
-- __TENANT_CODE__ -> public enterprise code, for example `acme`. -- __TENANT_CODE__ -> public enterprise code, for example `acme`.
-- __TENANT_NAME__ -> tenant display name. -- __TENANT_NAME__ -> tenant display name.
-- 2. Execute this SQL with a MySQL account that can CREATE DATABASE and write hotgo.im_tenant. -- 2. Execute this SQL with a MySQL account that can CREATE DATABASE and write hotgo.im_tenant.
-- 3. Keep business tables empty; do not copy users, topics, messages, files, Kafka logs, or audit data from `tinode`. -- 3. Keep business tables empty; do not copy users, topics, messages, files, Kafka logs, or audit data from another database.
-- --
-- Notes: -- Notes:
-- - This script intentionally seeds only stable metadata/default configuration. -- - This script intentionally seeds only stable metadata/default configuration.
@@ -168,9 +168,10 @@ CREATE TABLE IF NOT EXISTS `im_attendance_records` (
CREATE TABLE IF NOT EXISTS `im_attendance_rules` ( CREATE TABLE IF NOT EXISTS `im_attendance_rules` (
`id` bigint NOT NULL AUTO_INCREMENT, `id` bigint NOT NULL AUTO_INCREMENT,
`tenant_id` bigint NOT NULL DEFAULT '2' COMMENT '商户ID',
`rule_id` varchar(64) NOT NULL, `rule_id` varchar(64) NOT NULL,
`name` varchar(128) NOT NULL, `name` varchar(128) NOT NULL,
`workday` tinyint NOT NULL DEFAULT '1', `workday` varchar(32) NOT NULL DEFAULT '1,2,3,4,5',
`location_required` tinyint NOT NULL DEFAULT '0', `location_required` tinyint NOT NULL DEFAULT '0',
`wifi_required` tinyint NOT NULL DEFAULT '0', `wifi_required` tinyint NOT NULL DEFAULT '0',
`allow_outside_checkin` tinyint NOT NULL DEFAULT '1', `allow_outside_checkin` tinyint NOT NULL DEFAULT '1',
@@ -181,12 +182,13 @@ CREATE TABLE IF NOT EXISTS `im_attendance_rules` (
`created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (`id`), PRIMARY KEY (`id`),
UNIQUE KEY `uk_im_attendance_rules_rule` (`rule_id`), UNIQUE KEY `uk_im_attendance_rules_tenant_rule` (`tenant_id`,`rule_id`),
KEY `idx_im_attendance_rules_state` (`state`,`effective_from`,`effective_to`) KEY `idx_im_attendance_rules_state` (`tenant_id`,`state`,`effective_from`,`effective_to`)
) 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_attendance_shifts` ( CREATE TABLE IF NOT EXISTS `im_attendance_shifts` (
`id` bigint NOT NULL AUTO_INCREMENT, `id` bigint NOT NULL AUTO_INCREMENT,
`tenant_id` bigint NOT NULL DEFAULT '2' COMMENT '商户ID',
`shift_id` varchar(64) NOT NULL, `shift_id` varchar(64) NOT NULL,
`rule_id` varchar(64) NOT NULL, `rule_id` varchar(64) NOT NULL,
`name` varchar(128) NOT NULL, `name` varchar(128) NOT NULL,
@@ -199,8 +201,8 @@ CREATE TABLE IF NOT EXISTS `im_attendance_shifts` (
`created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (`id`), PRIMARY KEY (`id`),
UNIQUE KEY `uk_im_attendance_shifts_shift` (`shift_id`), UNIQUE KEY `uk_im_attendance_shifts_tenant_shift` (`tenant_id`,`shift_id`),
KEY `idx_im_attendance_shifts_rule` (`rule_id`,`state`,`sort_order`) KEY `idx_im_attendance_shifts_rule` (`tenant_id`,`rule_id`,`state`,`sort_order`)
) 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_audit_logs` ( CREATE TABLE IF NOT EXISTS `im_audit_logs` (
@@ -397,6 +399,7 @@ CREATE TABLE IF NOT EXISTS `im_favorites` (
CREATE TABLE IF NOT EXISTS `im_feedback_config` ( CREATE TABLE IF NOT EXISTS `im_feedback_config` (
`id` bigint NOT NULL AUTO_INCREMENT, `id` bigint NOT NULL AUTO_INCREMENT,
`tenant_id` bigint NOT NULL DEFAULT '2' COMMENT '商户ID',
`categories` json DEFAULT NULL, `categories` json DEFAULT NULL,
`max_length` int NOT NULL DEFAULT '1000', `max_length` int NOT NULL DEFAULT '1000',
`attachment_policy` json DEFAULT NULL, `attachment_policy` json DEFAULT NULL,
@@ -404,7 +407,8 @@ CREATE TABLE IF NOT EXISTS `im_feedback_config` (
`diagnostics_notice` text NOT NULL, `diagnostics_notice` text NOT NULL,
`created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (`id`) PRIMARY KEY (`id`),
UNIQUE KEY `uk_im_feedback_config_tenant` (`tenant_id`)
) 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_feedback_submissions` ( CREATE TABLE IF NOT EXISTS `im_feedback_submissions` (
@@ -951,6 +955,7 @@ CREATE TABLE IF NOT EXISTS `im_login_failure_logs` (
CREATE TABLE IF NOT EXISTS `im_mailbox_config` ( CREATE TABLE IF NOT EXISTS `im_mailbox_config` (
`id` bigint NOT NULL AUTO_INCREMENT, `id` bigint NOT NULL AUTO_INCREMENT,
`tenant_id` bigint NOT NULL DEFAULT '2' COMMENT '商户ID',
`enabled` tinyint NOT NULL DEFAULT '1', `enabled` tinyint NOT NULL DEFAULT '1',
`allow_anonymous` tinyint NOT NULL DEFAULT '1', `allow_anonymous` tinyint NOT NULL DEFAULT '1',
`required_fields` json DEFAULT NULL, `required_fields` json DEFAULT NULL,
@@ -961,7 +966,8 @@ CREATE TABLE IF NOT EXISTS `im_mailbox_config` (
`notice_updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), `notice_updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (`id`) PRIMARY KEY (`id`),
UNIQUE KEY `uk_im_mailbox_config_tenant` (`tenant_id`)
) 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_mailbox_submissions` ( CREATE TABLE IF NOT EXISTS `im_mailbox_submissions` (
@@ -1491,6 +1497,7 @@ CREATE TABLE IF NOT EXISTS `im_verification_codes` (
CREATE TABLE IF NOT EXISTS `im_workbench_apps` ( CREATE TABLE IF NOT EXISTS `im_workbench_apps` (
`id` bigint NOT NULL AUTO_INCREMENT, `id` bigint NOT NULL AUTO_INCREMENT,
`tenant_id` bigint NOT NULL DEFAULT '2' COMMENT '商户ID',
`code` varchar(64) NOT NULL, `code` varchar(64) NOT NULL,
`name` varchar(128) NOT NULL, `name` varchar(128) NOT NULL,
`icon_file_id` varchar(64) NOT NULL DEFAULT '', `icon_file_id` varchar(64) NOT NULL DEFAULT '',
@@ -1502,8 +1509,8 @@ CREATE TABLE IF NOT EXISTS `im_workbench_apps` (
`created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (`id`), PRIMARY KEY (`id`),
UNIQUE KEY `uk_im_workbench_apps_code` (`code`), UNIQUE KEY `uk_im_workbench_apps_tenant_code` (`tenant_id`,`code`),
KEY `idx_im_workbench_apps_enabled_order` (`enabled`,`sort_order`) KEY `idx_im_workbench_apps_enabled_order` (`tenant_id`,`enabled`,`sort_order`)
) 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_youth_mode_settings` ( CREATE TABLE IF NOT EXISTS `im_youth_mode_settings` (
@@ -1533,15 +1540,15 @@ VALUES (1, 'version', CURRENT_TIMESTAMP(3), 123)
ON DUPLICATE KEY UPDATE `value` = VALUES(`value`); ON DUPLICATE KEY UPDATE `value` = VALUES(`value`);
-- Workbench default apps. These are product navigation entries, not tenant business data. -- Workbench default apps. These are product navigation entries, not tenant business data.
INSERT INTO `im_workbench_apps` (`code`, `name`, `route`, `sort_order`, `unread_key`, `enabled`, `permission`) INSERT INTO `im_workbench_apps` (`tenant_id`, `code`, `name`, `route`, `sort_order`, `unread_key`, `enabled`, `permission`)
VALUES VALUES
('attendance', '打卡', '/attendance', 10, 'attendance', 1, 'enabled'), (2, 'attendance', '打卡', '/attendance', 10, 'attendance', 1, 'enabled'),
('monthly_reports', '月报', '/reports/monthly', 20, 'monthly_reports', 1, 'enabled'), (2, 'monthly_reports', '月报', '/reports/monthly', 20, 'monthly_reports', 1, 'enabled'),
('daily_reports', '日报', '/reports/daily', 30, 'daily_reports', 1, 'enabled'), (2, 'daily_reports', '日报', '/reports/daily', 30, 'daily_reports', 1, 'enabled'),
('organization', '部门框架', '/organization', 40, 'organization', 1, 'enabled'), (2, 'organization', '部门框架', '/organization', 40, 'organization', 1, 'enabled'),
('executive_mailbox', '举报信箱', '/executive-mailbox', 50, 'executive_mailbox', 1, 'enabled'), (2, 'executive_mailbox', '举报信箱', '/executive-mailbox', 50, 'executive_mailbox', 1, 'enabled'),
('meetings', '会议通知', '/meetings', 60, 'meetings', 1, 'enabled'), (2, 'meetings', '会议通知', '/meetings', 60, 'meetings', 1, 'enabled'),
('meeting_notes', '会议笔记', '/meeting-notes', 70, '', 1, 'enabled') (2, 'meeting_notes', '会议笔记', '/meeting-notes', 70, '', 1, 'enabled')
ON DUPLICATE KEY UPDATE ON DUPLICATE KEY UPDATE
`name` = VALUES(`name`), `name` = VALUES(`name`),
`route` = VALUES(`route`), `route` = VALUES(`route`),
@@ -1552,17 +1559,17 @@ VALUES
`updated_at` = CURRENT_TIMESTAMP(3); `updated_at` = CURRENT_TIMESTAMP(3);
-- Default attendance settings. Tenants can edit or replace them later. -- Default attendance settings. Tenants can edit or replace them later.
INSERT INTO `im_attendance_rules` (`rule_id`, `name`, `workday`, `location_required`, `wifi_required`, `allow_outside_checkin`, `appeal_allowed`) INSERT INTO `im_attendance_rules` (`tenant_id`, `rule_id`, `name`, `workday`, `location_required`, `wifi_required`, `allow_outside_checkin`, `appeal_allowed`)
VALUES ('att_rule_default', '默认考勤规则', 1, 0, 0, 1, 1) VALUES (2, 'att_rule_default', '默认考勤规则', '1,2,3,4,5', 0, 0, 1, 1)
ON DUPLICATE KEY UPDATE `updated_at` = CURRENT_TIMESTAMP(3); ON DUPLICATE KEY UPDATE `updated_at` = CURRENT_TIMESTAMP(3);
INSERT INTO `im_attendance_shifts` (`shift_id`, `rule_id`, `name`, `work_start`, `work_end`, `checkin_start`, `checkout_end`, `sort_order`) INSERT INTO `im_attendance_shifts` (`tenant_id`, `shift_id`, `rule_id`, `name`, `work_start`, `work_end`, `checkin_start`, `checkout_end`, `sort_order`)
VALUES ('shift_default', 'att_rule_default', '默认班次', '09:00:00', '18:00:00', '08:30:00', '20:00:00', 10) VALUES (2, 'shift_default', 'att_rule_default', '默认班次', '09:00:00', '18:00:00', '08:00:00', '19:00:00', 10)
ON DUPLICATE KEY UPDATE `updated_at` = CURRENT_TIMESTAMP(3); ON DUPLICATE KEY UPDATE `updated_at` = CURRENT_TIMESTAMP(3);
-- Default mailbox configuration. -- Default mailbox configuration.
INSERT INTO `im_mailbox_config` (`id`, `enabled`, `allow_anonymous`, `required_fields`, `max_length`, `attachment_policy`, `notice_title`, `notice_content`) INSERT INTO `im_mailbox_config` (`tenant_id`, `enabled`, `allow_anonymous`, `required_fields`, `max_length`, `attachment_policy`, `notice_title`, `notice_content`)
VALUES (1, 1, 1, '["subject","content"]', 5000, '{"max_files":5,"purpose":"executive_mailbox"}', '总经理信箱', '请如实填写诉求,匿名提交将按企业政策保护展示身份。') VALUES (2, 1, 1, '["subject","content"]', 5000, '{"max_files":5,"purpose":"executive_mailbox"}', '总经理信箱', '请如实填写诉求。')
ON DUPLICATE KEY UPDATE ON DUPLICATE KEY UPDATE
`enabled` = VALUES(`enabled`), `enabled` = VALUES(`enabled`),
`allow_anonymous` = VALUES(`allow_anonymous`), `allow_anonymous` = VALUES(`allow_anonymous`),
@@ -1594,8 +1601,8 @@ VALUES
`updated_at` = CURRENT_TIMESTAMP(3); `updated_at` = CURRENT_TIMESTAMP(3);
-- Default feedback configuration. -- Default feedback configuration.
INSERT INTO `im_feedback_config` (`id`, `categories`, `max_length`, `attachment_policy`, `allow_contact`, `diagnostics_notice`) INSERT INTO `im_feedback_config` (`tenant_id`, `categories`, `max_length`, `attachment_policy`, `allow_contact`, `diagnostics_notice`)
VALUES (1, '["bug","suggestion","complaint","other"]', 1000, '{"max_files":5,"purpose":"feedback"}', 1, '仅在你同意时上传脱敏诊断信息。') VALUES (2, '["bug","suggestion","complaint","other"]', 2000, '{"max_files":5,"purpose":"feedback"}', 1, '')
ON DUPLICATE KEY UPDATE ON DUPLICATE KEY UPDATE
`categories` = VALUES(`categories`), `categories` = VALUES(`categories`),
`max_length` = VALUES(`max_length`), `max_length` = VALUES(`max_length`),
@@ -1612,15 +1619,10 @@ SET FOREIGN_KEY_CHECKS = 1;
-- Keep this section in the same change set as tenant DB initialization. -- Keep this section in the same change set as tenant DB initialization.
-- If the tenant id is generated by Admin Server, replace __TENANT_ID__ with the generated id. -- If the tenant id is generated by Admin Server, replace __TENANT_ID__ with the generated id.
INSERT INTO `hotgo`.`im_tenant` (`id`, `code`, `name`, `tenant_desc`, `db_name`, `db_dsn`, `state`, `created_by`, `updated_by`) INSERT INTO `hotgo`.`im_tenant` (`id`, `code`, `name`, `tenant_desc`, `state`, `created_by`, `updated_by`)
VALUES (2,'system', 'system merchant', 'system default merchant', 'meeting_im', '', 1, 0, 0) VALUES (2,'system', 'system merchant', 'system default merchant', 1, 0, 0)
ON DUPLICATE KEY UPDATE ON DUPLICATE KEY UPDATE
`code` = VALUES(`code`), `code` = VALUES(`code`),
`name` = VALUES(`name`), `name` = VALUES(`name`),
`db_name` = VALUES(`db_name`),
`state` = VALUES(`state`), `state` = VALUES(`state`),
`updated_at` = CURRENT_TIMESTAMP(3); `updated_at` = CURRENT_TIMESTAMP(3);
INSERT INTO `hotgo`.`im_tenant_config` (`tenant_id`, `max_users`, `max_groups`, `max_group_members`, `created_by`, `updated_by`)
VALUES (2, 0, 0, 0, 0, 0)
ON DUPLICATE KEY UPDATE `updated_at` = CURRENT_TIMESTAMP(3);
+23 -683
View File
@@ -8,15 +8,30 @@ import (
"os" "os"
"strings" "strings"
_ "github.com/go-sql-driver/mysql" mysqlDriver "github.com/go-sql-driver/mysql"
) )
const sharedBusinessDatabase = "meeting_im"
// This startup helper intentionally performs validation only. Database schema
// changes are managed by the checked-in SQL migrations and must not be applied
// implicitly while an IM Server pod is starting.
func main() { func main() {
dsn := strings.TrimPrefix(os.Getenv("IM_MYSQL_DSN"), "mysql://") rawDSN := strings.TrimSpace(os.Getenv("IM_MYSQL_DSN"))
dsn := strings.TrimPrefix(rawDSN, "mysql://")
if dsn == "" { if dsn == "" {
fatal("IM_MYSQL_DSN is required") fatal("IM_MYSQL_DSN is required")
} }
db, err := sql.Open("mysql", dsn)
config, err := mysqlDriver.ParseDSN(dsn)
if err != nil {
fatal(fmt.Errorf("parse IM_MYSQL_DSN: %w", err))
}
if !strings.EqualFold(strings.TrimSpace(config.DBName), sharedBusinessDatabase) {
fatal(fmt.Errorf("IM_MYSQL_DSN must select %s; got %q", sharedBusinessDatabase, config.DBName))
}
db, err := sql.Open("mysql", config.FormatDSN())
if err != nil { if err != nil {
fatal(err) fatal(err)
} }
@@ -25,693 +40,18 @@ func main() {
fatal(err) fatal(err)
} }
statements := []string{ var database string
"CREATE DATABASE IF NOT EXISTS hotgo CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci", if err = db.QueryRow("SELECT DATABASE()").Scan(&database); err != nil {
`CREATE TABLE IF NOT EXISTS hotgo.im_tenant(
id BIGINT NOT NULL AUTO_INCREMENT,
code VARCHAR(64) NOT NULL,
name VARCHAR(128) NOT NULL,
tenant_desc VARCHAR(256),
db_name VARCHAR(128) NOT NULL DEFAULT '',
db_dsn VARCHAR(1024) NOT NULL DEFAULT '',
state SMALLINT NOT NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
created_by BIGINT NOT NULL,
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
updated_by BIGINT NOT NULL,
PRIMARY KEY(id),
UNIQUE INDEX uk_im_tenant_code(code)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
`CREATE TABLE IF NOT EXISTS hotgo.im_tenant_config(
id BIGINT NOT NULL AUTO_INCREMENT,
tenant_id BIGINT NOT NULL,
max_users BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '0 means unlimited',
max_groups BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '0 means unlimited',
max_group_members INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '0 means unlimited',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
created_by BIGINT NOT NULL,
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
updated_by BIGINT NOT NULL,
PRIMARY KEY(id),
UNIQUE INDEX uk_im_tenant_config_tenant_id(tenant_id),
CONSTRAINT fk_im_tenant_config_tenant FOREIGN KEY(tenant_id) REFERENCES hotgo.im_tenant(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
"CREATE DATABASE IF NOT EXISTS tinode CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci",
// createIMSecurityTables, createIMNotificationTables and createIMRelationTables
// (server/db/mysql/adapter.go) are only reached from CreateDb's fresh-install
// path, which our pre-existing tinode DB never runs. Recreate their tables here.
`CREATE TABLE IF NOT EXISTS tinode.im_realname_verifications(
id BIGINT NOT NULL AUTO_INCREMENT,
user_id BIGINT NOT NULL,
submission_id VARCHAR(64) NOT NULL,
status VARCHAR(16) NOT NULL,
name_cipher TEXT NOT NULL,
id_type VARCHAR(32) NOT NULL,
id_number_cipher TEXT NOT NULL,
id_number_hash CHAR(64) NOT NULL,
masked_name VARCHAR(64) NOT NULL,
masked_id_number VARCHAR(64) NOT NULL,
review_reason VARCHAR(256),
verified_at DATETIME(3),
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY(id),
UNIQUE INDEX uk_im_realname_submission(submission_id),
INDEX idx_im_realname_user_latest(user_id, created_at),
INDEX idx_im_realname_id_hash(id_number_hash)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
`CREATE TABLE IF NOT EXISTS tinode.im_action_tokens(
id BIGINT NOT NULL AUTO_INCREMENT,
user_id BIGINT NOT NULL,
token_id VARCHAR(64) NOT NULL,
action VARCHAR(64) NOT NULL,
target VARCHAR(128) NOT NULL DEFAULT '',
token_hash CHAR(64) NOT NULL,
state SMALLINT NOT NULL,
expires_at DATETIME(3) NOT NULL,
consumed_at DATETIME(3),
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY(id),
UNIQUE INDEX uk_im_action_tokens_token(token_id),
INDEX idx_im_action_tokens_user_action(user_id, action, target, state, expires_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
`CREATE TABLE IF NOT EXISTS tinode.im_system_notifications(
id BIGINT NOT NULL AUTO_INCREMENT,
user_id BIGINT NOT NULL,
event_id VARCHAR(64) NOT NULL,
event_type VARCHAR(64) NOT NULL,
title VARCHAR(128) NOT NULL,
summary VARCHAR(512) NOT NULL,
action JSON,
read_at DATETIME(3),
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY(id),
UNIQUE INDEX uk_im_system_notifications_event(user_id, event_id),
INDEX idx_im_system_notifications_inbox(user_id, id DESC)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
`CREATE TABLE IF NOT EXISTS tinode.im_friend_requests(
id BIGINT NOT NULL AUTO_INCREMENT,
request_id VARCHAR(64) NOT NULL,
applicant_id BIGINT NOT NULL,
target_id BIGINT NOT NULL,
message VARCHAR(200) NOT NULL DEFAULT '',
source VARCHAR(32) NOT NULL DEFAULT '',
source_topic VARCHAR(64) NOT NULL DEFAULT '',
status VARCHAR(16) NOT NULL,
idempotency_key VARCHAR(128) NOT NULL DEFAULT '',
expires_at DATETIME(3) NOT NULL,
handled_at DATETIME(3),
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY(id),
UNIQUE INDEX uk_im_friend_requests_request(request_id),
UNIQUE INDEX uk_im_friend_requests_idempotency(applicant_id, idempotency_key),
INDEX idx_im_friend_requests_pair(applicant_id, target_id, status, created_at),
INDEX idx_im_friend_requests_inbox(target_id, status, created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
`CREATE TABLE IF NOT EXISTS tinode.im_friendships(
id BIGINT NOT NULL,
user_id BIGINT NOT NULL,
peer_id BIGINT NOT NULL,
status VARCHAR(16) NOT NULL,
remark VARCHAR(64) NOT NULL DEFAULT '',
topic CHAR(25) NOT NULL DEFAULT '',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY(id),
UNIQUE INDEX uk_im_friendships_pair(user_id, peer_id),
INDEX idx_im_friendships_peer(peer_id, status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
`CREATE TABLE IF NOT EXISTS tinode.im_user_blocks(
id BIGINT NOT NULL,
blocker_id BIGINT NOT NULL,
blocked_id BIGINT NOT NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY(id),
UNIQUE INDEX uk_im_user_blocks_pair(blocker_id, blocked_id),
INDEX idx_im_user_blocks_blocked(blocked_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
`CREATE TABLE IF NOT EXISTS tinode.im_favorites(
id BIGINT NOT NULL AUTO_INCREMENT,
favorite_id VARCHAR(64) NOT NULL,
user_id BIGINT NOT NULL,
item_type VARCHAR(16) NOT NULL,
source_topic CHAR(25) NOT NULL,
source_seq INT NOT NULL,
snapshot JSON,
attachment_refs JSON,
availability VARCHAR(16) NOT NULL DEFAULT 'available',
idempotency_key VARCHAR(128) NOT NULL DEFAULT '',
collected_at DATETIME(3) NOT NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY(id),
UNIQUE INDEX uk_im_favorites_favorite(favorite_id),
UNIQUE INDEX uk_im_favorites_source(user_id, source_topic, source_seq),
UNIQUE INDEX uk_im_favorites_idempotency(user_id, ((NULLIF(idempotency_key, '')))),
INDEX idx_im_favorites_inbox(user_id, id DESC),
INDEX idx_im_favorites_type(user_id, item_type, id DESC)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
`CREATE TABLE IF NOT EXISTS tinode.im_call_records(
id BIGINT NOT NULL AUTO_INCREMENT,
call_id VARCHAR(64) NOT NULL,
topic CHAR(25) NOT NULL,
seq_id INT NOT NULL,
caller_id BIGINT NOT NULL,
callee_id BIGINT NOT NULL,
call_type VARCHAR(16) NOT NULL,
status VARCHAR(32) NOT NULL,
end_reason VARCHAR(64) NOT NULL DEFAULT '',
duration_ms BIGINT NOT NULL DEFAULT 0,
started_at DATETIME(3) NOT NULL,
accepted_at DATETIME(3),
finished_at DATETIME(3),
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY(id),
UNIQUE INDEX uk_im_call_records_call(call_id),
UNIQUE INDEX uk_im_call_records_topic_seq(topic, seq_id),
INDEX idx_im_call_records_caller(caller_id, id DESC),
INDEX idx_im_call_records_callee(callee_id, id DESC),
INDEX idx_im_call_records_status(status, updated_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
`CREATE TABLE IF NOT EXISTS tinode.im_workbench_apps(
id BIGINT NOT NULL AUTO_INCREMENT,
code VARCHAR(64) NOT NULL,
name VARCHAR(128) NOT NULL,
icon_file_id VARCHAR(64) NOT NULL DEFAULT '',
route VARCHAR(128) NOT NULL,
sort_order INT NOT NULL DEFAULT 0,
unread_key VARCHAR(64) NOT NULL DEFAULT '',
enabled TINYINT NOT NULL DEFAULT 1,
permission VARCHAR(64) NOT NULL DEFAULT 'enabled',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY(id),
UNIQUE INDEX uk_im_workbench_apps_code(code),
INDEX idx_im_workbench_apps_enabled_order(enabled, sort_order)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
`CREATE TABLE IF NOT EXISTS tinode.im_org_departments(
id BIGINT NOT NULL AUTO_INCREMENT,
department_id VARCHAR(64) NOT NULL,
name VARCHAR(128) NOT NULL,
parent_id VARCHAR(64) NOT NULL DEFAULT 'root',
sort_order INT NOT NULL DEFAULT 0,
version INT NOT NULL DEFAULT 1,
state VARCHAR(16) NOT NULL DEFAULT 'active',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY(id),
UNIQUE INDEX uk_im_org_departments_department(department_id),
INDEX idx_im_org_departments_parent(parent_id, state, sort_order, id),
INDEX idx_im_org_departments_name(name)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
`CREATE TABLE IF NOT EXISTS tinode.im_org_members(
id BIGINT NOT NULL AUTO_INCREMENT,
department_id VARCHAR(64) NOT NULL,
user_id BIGINT NOT NULL,
display_name VARCHAR(128) NOT NULL DEFAULT '',
position VARCHAR(128) NOT NULL DEFAULT '',
avatar_file_id VARCHAR(64) NOT NULL DEFAULT '',
mobile VARCHAR(32) NOT NULL DEFAULT '',
email VARCHAR(128) NOT NULL DEFAULT '',
employment_state VARCHAR(16) NOT NULL DEFAULT 'active',
contactable TINYINT NOT NULL DEFAULT 1,
sort_order INT NOT NULL DEFAULT 0,
version INT NOT NULL DEFAULT 1,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY(id),
UNIQUE INDEX uk_im_org_members_department_user(department_id, user_id),
INDEX idx_im_org_members_department(department_id, employment_state, sort_order, id),
INDEX idx_im_org_members_user(user_id),
INDEX idx_im_org_members_display_name(display_name),
CONSTRAINT fk_im_org_members_department FOREIGN KEY(department_id) REFERENCES tinode.im_org_departments(department_id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
`CREATE TABLE IF NOT EXISTS tinode.im_meetings(
id BIGINT NOT NULL AUTO_INCREMENT,
meeting_id VARCHAR(64) NOT NULL,
organizer_id BIGINT NOT NULL,
title VARCHAR(200) NOT NULL,
start_at DATETIME(3) NOT NULL,
end_at DATETIME(3) NOT NULL,
location VARCHAR(256) NOT NULL DEFAULT '',
state VARCHAR(16) NOT NULL DEFAULT 'scheduled',
reminder_minutes JSON,
idempotency_key VARCHAR(128),
version INT NOT NULL DEFAULT 1,
canceled_at DATETIME(3),
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY(id),
UNIQUE INDEX uk_im_meetings_meeting(meeting_id),
UNIQUE INDEX uk_im_meetings_idempotency(organizer_id, idempotency_key),
INDEX idx_im_meetings_organizer(organizer_id, start_at),
INDEX idx_im_meetings_state_time(state, start_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
`CREATE TABLE IF NOT EXISTS tinode.im_meeting_participants(
id BIGINT NOT NULL AUTO_INCREMENT,
meeting_id VARCHAR(64) NOT NULL,
user_id BIGINT NOT NULL,
response VARCHAR(16) NOT NULL DEFAULT 'pending',
response_at DATETIME(3),
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY(id),
UNIQUE INDEX uk_im_meeting_participants_user(meeting_id, user_id),
INDEX idx_im_meeting_participants_user_time(user_id, meeting_id),
INDEX idx_im_meeting_participants_response(meeting_id, response),
CONSTRAINT fk_im_meeting_participants_meeting FOREIGN KEY(meeting_id) REFERENCES tinode.im_meetings(meeting_id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
`CREATE TABLE IF NOT EXISTS tinode.im_daily_reports(
id BIGINT NOT NULL AUTO_INCREMENT,
report_id VARCHAR(64) NOT NULL,
author_id BIGINT NOT NULL,
report_date DATE NOT NULL,
content TEXT NOT NULL,
attachments JSON,
status VARCHAR(16) NOT NULL DEFAULT 'draft',
read_state VARCHAR(16) NOT NULL DEFAULT 'unread',
version INT NOT NULL DEFAULT 1,
submitted_at DATETIME(3),
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY(id),
UNIQUE INDEX uk_im_daily_reports_report(report_id),
UNIQUE INDEX uk_im_daily_reports_author_date(author_id, report_date),
INDEX idx_im_daily_reports_author_id(author_id, id DESC),
INDEX idx_im_daily_reports_date_status(report_date, status, id DESC)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
`CREATE TABLE IF NOT EXISTS tinode.im_monthly_reports(
id BIGINT NOT NULL AUTO_INCREMENT,
report_id VARCHAR(64) NOT NULL,
author_id BIGINT NOT NULL,
report_month CHAR(7) NOT NULL,
work_summary TEXT NOT NULL,
next_plan TEXT NOT NULL,
attachments JSON,
status VARCHAR(16) NOT NULL DEFAULT 'draft',
read_state VARCHAR(16) NOT NULL DEFAULT 'unread',
version INT NOT NULL DEFAULT 1,
submitted_at DATETIME(3),
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY(id),
UNIQUE INDEX uk_im_monthly_reports_report(report_id),
UNIQUE INDEX uk_im_monthly_reports_author_month(author_id, report_month),
INDEX idx_im_monthly_reports_author_id(author_id, id DESC),
INDEX idx_im_monthly_reports_month_status(report_month, status, id DESC)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
`CREATE TABLE IF NOT EXISTS tinode.im_mailbox_config(
id BIGINT NOT NULL AUTO_INCREMENT,
enabled TINYINT NOT NULL DEFAULT 1,
allow_anonymous TINYINT NOT NULL DEFAULT 1,
required_fields JSON,
max_length INT NOT NULL DEFAULT 5000,
attachment_policy JSON,
notice_title VARCHAR(128) NOT NULL DEFAULT '',
notice_content TEXT NOT NULL,
notice_updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY(id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
`CREATE TABLE IF NOT EXISTS tinode.im_mailbox_submissions(
id BIGINT NOT NULL AUTO_INCREMENT,
submission_id VARCHAR(64) NOT NULL,
sender_id BIGINT NOT NULL,
subject VARCHAR(200) NOT NULL,
content TEXT NOT NULL,
anonymous TINYINT NOT NULL DEFAULT 0,
attachment_file_ids JSON,
notice_version VARCHAR(64) NOT NULL DEFAULT '',
idempotency_key VARCHAR(128) NOT NULL,
status VARCHAR(32) NOT NULL DEFAULT 'received',
submitted_at DATETIME(3) NOT NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY(id),
UNIQUE INDEX uk_im_mailbox_submissions_submission(submission_id),
UNIQUE INDEX uk_im_mailbox_submissions_idempotency(sender_id, idempotency_key),
INDEX idx_im_mailbox_submissions_sender(sender_id, id DESC),
INDEX idx_im_mailbox_submissions_status(status, id DESC)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
`CREATE TABLE IF NOT EXISTS tinode.im_user_profiles(
id BIGINT NOT NULL,
user_id BIGINT NOT NULL,
avatar_file_id VARCHAR(64) NOT NULL DEFAULT '',
nickname VARCHAR(64) NOT NULL DEFAULT '',
birth_date DATE,
signature VARCHAR(256) NOT NULL DEFAULT '',
gender VARCHAR(16) NOT NULL DEFAULT '',
region VARCHAR(128) NOT NULL DEFAULT '',
email VARCHAR(128) NOT NULL DEFAULT '',
public_account VARCHAR(64) NOT NULL DEFAULT '',
version INT NOT NULL DEFAULT 1,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY(id),
UNIQUE INDEX uk_im_user_profiles_user(user_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
`CREATE TABLE IF NOT EXISTS tinode.im_user_privacy_settings(
id BIGINT NOT NULL,
user_id BIGINT NOT NULL,
add_me_methods JSON,
profile_visibility VARCHAR(32) NOT NULL DEFAULT 'friends',
last_seen_visibility VARCHAR(32) NOT NULL DEFAULT 'friends',
read_receipts TINYINT NOT NULL DEFAULT 1,
friend_request_verification TINYINT NOT NULL DEFAULT 1,
group_invite_verification TINYINT NOT NULL DEFAULT 0,
version INT NOT NULL DEFAULT 1,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY(id),
UNIQUE INDEX uk_im_user_privacy_settings_user(user_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
`CREATE TABLE IF NOT EXISTS tinode.im_app_releases(
id BIGINT NOT NULL AUTO_INCREMENT,
platform VARCHAR(32) NOT NULL,
channel VARCHAR(32) NOT NULL DEFAULT 'stable',
latest_version VARCHAR(64) NOT NULL,
min_supported_version VARCHAR(64) NOT NULL,
update_type VARCHAR(16) NOT NULL DEFAULT 'none',
release_notes TEXT NOT NULL,
store_url VARCHAR(512) NOT NULL DEFAULT '',
package_url VARCHAR(512) NOT NULL DEFAULT '',
checksum VARCHAR(128) NOT NULL DEFAULT '',
remind_after_seconds INT NOT NULL DEFAULT 0,
published_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY(id),
UNIQUE INDEX uk_im_app_releases_platform_channel(platform, channel)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
`CREATE TABLE IF NOT EXISTS tinode.im_device_push_tokens(
id BIGINT NOT NULL,
device_id VARCHAR(128) NOT NULL,
user_id BIGINT NOT NULL,
provider VARCHAR(32) NOT NULL,
token VARCHAR(512) NOT NULL,
app_version VARCHAR(64) NOT NULL DEFAULT '',
platform VARCHAR(32) NOT NULL DEFAULT '',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY(id),
UNIQUE INDEX uk_im_device_push_tokens_device(device_id),
UNIQUE INDEX uk_im_device_push_tokens_provider_token(provider, token),
INDEX idx_im_device_push_tokens_user(user_id, updated_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
`CREATE TABLE IF NOT EXISTS tinode.im_youth_mode_settings(
id BIGINT NOT NULL,
user_id BIGINT NOT NULL,
purpose VARCHAR(64) NOT NULL,
password_hash VARBINARY(255) NOT NULL,
enabled TINYINT NOT NULL DEFAULT 0,
policy_version INT NOT NULL DEFAULT 1,
restrictions JSON,
enabled_at DATETIME(3),
failed_attempts INT NOT NULL DEFAULT 0,
locked_until DATETIME(3),
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY(id),
UNIQUE INDEX uk_im_youth_mode_settings_user_purpose(user_id, purpose)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
`CREATE TABLE IF NOT EXISTS tinode.im_risk_challenges(
id BIGINT NOT NULL AUTO_INCREMENT,
challenge_id VARCHAR(64) NOT NULL,
user_id BIGINT NOT NULL,
action VARCHAR(64) NOT NULL,
level VARCHAR(16) NOT NULL,
notice_code VARCHAR(64) NOT NULL,
notice_params JSON,
state VARCHAR(16) NOT NULL DEFAULT 'pending',
expires_at DATETIME(3) NOT NULL,
decision VARCHAR(16) NOT NULL DEFAULT '',
action_token_id VARCHAR(64) NOT NULL DEFAULT '',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY(id),
UNIQUE INDEX uk_im_risk_challenges_challenge(challenge_id),
INDEX idx_im_risk_challenges_user_state(user_id, state, expires_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
`CREATE TABLE IF NOT EXISTS tinode.im_abuse_reasons(
id BIGINT NOT NULL AUTO_INCREMENT,
target_type VARCHAR(16) NOT NULL,
reason_code VARCHAR(64) NOT NULL,
label VARCHAR(128) NOT NULL,
requires_description TINYINT NOT NULL DEFAULT 0,
allows_evidence TINYINT NOT NULL DEFAULT 1,
sort_order INT NOT NULL DEFAULT 0,
enabled TINYINT NOT NULL DEFAULT 1,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY(id),
UNIQUE INDEX uk_im_abuse_reasons_type_code(target_type, reason_code),
INDEX idx_im_abuse_reasons_type_order(target_type, enabled, sort_order)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
`CREATE TABLE IF NOT EXISTS tinode.im_abuse_reports(
id BIGINT NOT NULL AUTO_INCREMENT,
report_id VARCHAR(64) NOT NULL,
reporter_id BIGINT NOT NULL,
target_type VARCHAR(16) NOT NULL,
target_user_id BIGINT NOT NULL DEFAULT 0,
target_topic VARCHAR(32) NOT NULL DEFAULT '',
target_seq INT NOT NULL DEFAULT 0,
reason_code VARCHAR(64) NOT NULL,
description TEXT NOT NULL,
evidence_file_ids JSON,
snapshot JSON,
idempotency_key VARCHAR(128) NOT NULL,
status VARCHAR(32) NOT NULL DEFAULT 'received',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY(id),
UNIQUE INDEX uk_im_abuse_reports_report(report_id),
UNIQUE INDEX uk_im_abuse_reports_idempotency(reporter_id, idempotency_key),
INDEX idx_im_abuse_reports_target(target_type, target_topic, target_seq, target_user_id),
INDEX idx_im_abuse_reports_reporter(reporter_id, id DESC)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
`CREATE TABLE IF NOT EXISTS tinode.im_feedback_config(
id BIGINT NOT NULL AUTO_INCREMENT,
categories JSON,
max_length INT NOT NULL DEFAULT 1000,
attachment_policy JSON,
allow_contact TINYINT NOT NULL DEFAULT 1,
diagnostics_notice TEXT NOT NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY(id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
`CREATE TABLE IF NOT EXISTS tinode.im_feedback_submissions(
id BIGINT NOT NULL AUTO_INCREMENT,
feedback_id VARCHAR(64) NOT NULL,
user_id BIGINT NOT NULL,
category VARCHAR(64) NOT NULL,
content TEXT NOT NULL,
contact VARCHAR(128) NOT NULL DEFAULT '',
attachment_file_ids JSON,
client_context JSON,
include_diagnostics TINYINT NOT NULL DEFAULT 0,
idempotency_key VARCHAR(128) NOT NULL,
status VARCHAR(32) NOT NULL DEFAULT 'received',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY(id),
UNIQUE INDEX uk_im_feedback_submissions_feedback(feedback_id),
UNIQUE INDEX uk_im_feedback_submissions_idempotency(user_id, idempotency_key),
INDEX idx_im_feedback_submissions_user(user_id, id DESC)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
}
for _, stmt := range statements {
if _, err = db.Exec(stmt); err != nil {
fatal(fmt.Errorf("exec failed: %s: %w", stmt, err))
}
}
for _, stmt := range []string{
"ALTER TABLE hotgo.im_tenant ADD COLUMN tenant_desc VARCHAR(256) AFTER name",
"ALTER TABLE hotgo.im_tenant ADD COLUMN db_name VARCHAR(128) NOT NULL DEFAULT '' AFTER tenant_desc",
"ALTER TABLE hotgo.im_tenant ADD COLUMN db_dsn VARCHAR(1024) NOT NULL DEFAULT '' AFTER db_name",
"ALTER TABLE tinode.im_youth_mode_settings ADD COLUMN enabled TINYINT NOT NULL DEFAULT 0 AFTER password_hash",
"ALTER TABLE tinode.im_youth_mode_settings ADD COLUMN policy_version INT NOT NULL DEFAULT 1 AFTER enabled",
"ALTER TABLE tinode.im_youth_mode_settings ADD COLUMN restrictions JSON AFTER policy_version",
"ALTER TABLE tinode.im_youth_mode_settings ADD COLUMN enabled_at DATETIME(3) AFTER restrictions",
"ALTER TABLE tinode.im_users ADD COLUMN phone VARCHAR(32) NULL AFTER useragent",
"ALTER TABLE tinode.im_user_profiles ADD COLUMN email VARCHAR(128) NOT NULL DEFAULT '' AFTER region",
"ALTER TABLE tinode.im_user_privacy_settings ADD COLUMN friend_request_verification TINYINT NOT NULL DEFAULT 1 AFTER read_receipts",
"ALTER TABLE tinode.im_user_privacy_settings ADD COLUMN group_invite_verification TINYINT NOT NULL DEFAULT 0 AFTER read_receipts",
} {
if _, err = db.Exec(stmt); err != nil && !strings.Contains(err.Error(), "Duplicate column name") {
fatal(fmt.Errorf("exec failed: %s: %w", stmt, err))
}
}
if _, err = db.Exec(`INSERT INTO hotgo.im_tenant(code,name,tenant_desc,db_name,db_dsn,state,created_by,updated_by)
VALUES('default','Default Tenant',NULL,'tinode','',1,0,0)
ON DUPLICATE KEY UPDATE name=VALUES(name), db_name=VALUES(db_name), updated_at=CURRENT_TIMESTAMP(3)`); err != nil {
fatal(err) fatal(err)
} }
if _, err = db.Exec(`INSERT INTO hotgo.im_tenant_config(tenant_id,max_users,max_groups,max_group_members,created_by,updated_by) if !strings.EqualFold(database, sharedBusinessDatabase) {
SELECT id,0,0,0,0,0 FROM hotgo.im_tenant WHERE code='default' fatal(fmt.Errorf("connected database is %q, want %q", database, sharedBusinessDatabase))
ON DUPLICATE KEY UPDATE updated_at=CURRENT_TIMESTAMP(3)`); err != nil {
fatal(err)
}
if _, err = db.Exec(`INSERT INTO tinode.im_workbench_apps(code,name,route,sort_order,unread_key,enabled,permission)
VALUES
('attendance','打卡','/attendance',10,'attendance',1,'enabled'),
('monthly_reports','月报','/reports/monthly',20,'monthly_reports',1,'enabled'),
('daily_reports','日报','/reports/daily',30,'daily_reports',1,'enabled'),
('organization','部门框架','/organization',40,'organization',1,'enabled'),
('executive_mailbox','举报信箱','/executive-mailbox',50,'executive_mailbox',1,'enabled'),
('meetings','会议通知','/meetings',60,'meetings',1,'enabled'),
('meeting_notes','会议笔记','/meeting-notes',70,'',1,'enabled')
ON DUPLICATE KEY UPDATE name=VALUES(name),route=VALUES(route),sort_order=VALUES(sort_order),unread_key=VALUES(unread_key),updated_at=CURRENT_TIMESTAMP(3)`); err != nil {
fatal(err)
}
if _, err = db.Exec(`INSERT INTO tinode.im_mailbox_config(id,enabled,allow_anonymous,required_fields,max_length,attachment_policy,notice_title,notice_content)
VALUES(1,1,1,'["subject","content"]',5000,'{"max_files":5,"purpose":"executive_mailbox"}','总经理信箱','请如实填写诉求,匿名提交将按企业政策保护展示身份。')
ON DUPLICATE KEY UPDATE updated_at=updated_at`); err != nil {
fatal(err)
}
if _, err = db.Exec(`INSERT INTO tinode.im_app_releases(platform,channel,latest_version,min_supported_version,update_type,release_notes,remind_after_seconds)
VALUES
('ios','stable','1.0.0','1.0.0','none','当前已是最新版本。',0),
('android','stable','1.0.0','1.0.0','none','当前已是最新版本。',0)
ON DUPLICATE KEY UPDATE updated_at=CURRENT_TIMESTAMP(3)`); err != nil {
fatal(err)
}
if _, err = db.Exec(`INSERT INTO tinode.im_abuse_reasons(target_type,reason_code,label,requires_description,allows_evidence,sort_order)
VALUES
('user','fraud','诈骗欺诈',0,1,10),
('user','harassment','骚扰辱骂',0,1,20),
('user','illegal','违法违规',1,1,30),
('group','fraud','诈骗欺诈',0,1,10),
('group','spam','垃圾广告',0,1,20),
('group','illegal','违法违规',1,1,30),
('message','fraud','诈骗欺诈',0,1,10),
('message','spam','垃圾广告',0,1,20),
('message','harassment','骚扰辱骂',0,1,30),
('message','illegal','违法违规',1,1,40)
ON DUPLICATE KEY UPDATE label=VALUES(label),requires_description=VALUES(requires_description),allows_evidence=VALUES(allows_evidence),sort_order=VALUES(sort_order),updated_at=CURRENT_TIMESTAMP(3)`); err != nil {
fatal(err)
}
if _, err = db.Exec(`INSERT INTO tinode.im_feedback_config(id,categories,max_length,attachment_policy,allow_contact,diagnostics_notice)
VALUES(1,'["bug","suggestion","complaint","other"]',1000,'{"max_files":5,"purpose":"feedback"}',1,'仅在你同意时上传脱敏诊断信息。')
ON DUPLICATE KEY UPDATE updated_at=updated_at`); err != nil {
fatal(err)
}
if err = dropTenantIDColumns(db, "tinode"); err != nil {
fatal(err)
} }
var code, dbName string fmt.Printf("IM shared database validated: %s\n", database)
var state int
if err = db.QueryRow("SELECT code, db_name, state FROM hotgo.im_tenant WHERE code='default'").Scan(&code, &dbName, &state); err != nil {
fatal(err)
}
fmt.Printf("hotgo.im_tenant default: code=%s db_name=%s state=%d\n", code, dbName, state)
rows, err := db.Query(`SELECT table_name FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA='tinode' AND COLUMN_NAME='tenant_id'
ORDER BY table_name`)
if err != nil {
fatal(err)
}
defer rows.Close()
var tables []string
for rows.Next() {
var table string
if err = rows.Scan(&table); err != nil {
fatal(err)
}
tables = append(tables, table)
}
if err = rows.Err(); err != nil {
fatal(err)
}
if len(tables) == 0 {
fmt.Println("tinode tenant_id columns: none")
} else {
fmt.Printf("tinode tenant_id columns: %s\n", strings.Join(tables, ","))
}
} }
func fatal(v any) { func fatal(v any) {
fmt.Fprintln(os.Stderr, v) fmt.Fprintln(os.Stderr, v)
os.Exit(1) os.Exit(1)
} }
func dropTenantIDColumns(db *sql.DB, schema string) error {
fks, err := queryPairs(db, `SELECT DISTINCT table_name, constraint_name
FROM information_schema.KEY_COLUMN_USAGE
WHERE TABLE_SCHEMA=? AND COLUMN_NAME='tenant_id' AND REFERENCED_TABLE_NAME IS NOT NULL`,
schema)
if err != nil {
return err
}
for _, fk := range fks {
if _, err = db.Exec("ALTER TABLE " + quoteIdent(schema) + "." + quoteIdent(fk[0]) + " DROP FOREIGN KEY " + quoteIdent(fk[1])); err != nil {
return fmt.Errorf("drop foreign key %s.%s: %w", fk[0], fk[1], err)
}
}
indexes, err := queryPairs(db, `SELECT DISTINCT table_name, index_name
FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA=? AND COLUMN_NAME='tenant_id' AND INDEX_NAME<>'PRIMARY'`,
schema)
if err != nil {
return err
}
for _, idx := range indexes {
if _, err = db.Exec("ALTER TABLE " + quoteIdent(schema) + "." + quoteIdent(idx[0]) + " DROP INDEX " + quoteIdent(idx[1])); err != nil {
return fmt.Errorf("drop index %s.%s: %w", idx[0], idx[1], err)
}
}
rows, err := db.Query(`SELECT table_name FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA=? AND COLUMN_NAME='tenant_id'
ORDER BY table_name`, schema)
if err != nil {
return err
}
defer rows.Close()
var tables []string
for rows.Next() {
var table string
if err = rows.Scan(&table); err != nil {
return err
}
tables = append(tables, table)
}
if err = rows.Err(); err != nil {
return err
}
for _, table := range tables {
if _, err = db.Exec("ALTER TABLE " + quoteIdent(schema) + "." + quoteIdent(table) + " DROP COLUMN tenant_id"); err != nil {
return fmt.Errorf("drop column %s.tenant_id: %w", table, err)
}
}
return nil
}
func queryPairs(db *sql.DB, query string, args ...any) ([][2]string, error) {
rows, err := db.Query(query, args...)
if err != nil {
return nil, err
}
defer rows.Close()
var pairs [][2]string
for rows.Next() {
var first, second string
if err = rows.Scan(&first, &second); err != nil {
return nil, err
}
pairs = append(pairs, [2]string{first, second})
}
return pairs, rows.Err()
}
func quoteIdent(name string) string {
return "`" + strings.ReplaceAll(name, "`", "``") + "`"
}
+12 -6
View File
@@ -74,7 +74,7 @@ func TestMessageQuerySpecsExposeAdminFields(t *testing.T) {
func TestGroupMessageQuerySupportsOperationalFilters(t *testing.T) { func TestGroupMessageQuerySupportsOperationalFilters(t *testing.T) {
req := httptest.NewRequest("GET", "/?seq_id=42&sender_username=alice&sender_nickname=%E5%B0%8F%E6%98%8E&group_name=%E6%B5%8B%E8%AF%95%E7%BE%A4", nil) req := httptest.NewRequest("GET", "/?seq_id=42&sender_username=alice&sender_nickname=%E5%B0%8F%E6%98%8E&group_name=%E6%B5%8B%E8%AF%95%E7%BE%A4", nil)
query, _, args, _, _, err := buildInternalAdminListQuery(req, groupMessagesInternalQuerySpec(), "") query, _, args, _, _, err := buildInternalAdminListQuery(req, groupMessagesInternalQuerySpec(), "", types.TenantID(1))
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -83,7 +83,7 @@ func TestGroupMessageQuerySupportsOperationalFilters(t *testing.T) {
t.Errorf("group message query missing %q: %s", clause, query) t.Errorf("group message query missing %q: %s", clause, query)
} }
} }
want := []any{int64(42), "%alice%", "%小明%", "%测试群%"} want := []any{int64(1), int64(42), "%alice%", "%小明%", "%测试群%"}
if !reflect.DeepEqual(args, want) { if !reflect.DeepEqual(args, want) {
t.Fatalf("unexpected filter arguments: got %#v, want %#v", args, want) t.Fatalf("unexpected filter arguments: got %#v, want %#v", args, want)
} }
@@ -97,14 +97,20 @@ func TestLoginAuditQuerySpecsExposeUserIDAsString(t *testing.T) {
if !strings.Contains(spec.Select, "CAST(user_id AS CHAR) AS user_id") { if !strings.Contains(spec.Select, "CAST(user_id AS CHAR) AS user_id") {
t.Fatalf("user-login-logs query must expose 64-bit user IDs as strings: %s", spec.Select) t.Fatalf("user-login-logs query must expose 64-bit user IDs as strings: %s", spec.Select)
} }
if internalTenantResourceSpecs["login-failure-logs"] != nil { // login-failure-logs 保留(仅记录触发锁定的事件,见 session.go lockedNow 分支),
t.Fatal("login-failure-logs query spec must be removed with im_login_failure_logs") // 但共库模式必须带 tenant_id 隔离列,防止跨租户看到彼此的失败日志。
failureSpec := internalTenantResourceSpecs["login-failure-logs"]
if failureSpec == nil {
t.Fatal("login-failure-logs query spec missing")
}
if failureSpec.TenantColumn != "tenant_id" {
t.Fatalf("login-failure-logs query spec must filter by tenant_id: %+v", failureSpec)
} }
} }
func TestUserQuerySupportsKeywordAndUserIDs(t *testing.T) { func TestUserQuerySupportsKeywordAndUserIDs(t *testing.T) {
req := httptest.NewRequest("GET", "/?keyword=alice&user_ids=101,202&account_no=1000", nil) req := httptest.NewRequest("GET", "/?keyword=alice&user_ids=101,202&account_no=1000", nil)
query, countQuery, args, _, _, err := buildInternalAdminListQuery(req, usersInternalQuerySpec(), "") query, countQuery, args, _, _, err := buildInternalAdminListQuery(req, usersInternalQuerySpec(), "", types.TenantID(1))
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -113,7 +119,7 @@ func TestUserQuerySupportsKeywordAndUserIDs(t *testing.T) {
t.Fatalf("user query missing %q: %s", clause, query) t.Fatalf("user query missing %q: %s", clause, query)
} }
} }
want := []any{int64(101), int64(202), int64(1000), "%alice%"} want := []any{int64(1), int64(101), int64(202), int64(1000), "%alice%"}
if !reflect.DeepEqual(args, want) { if !reflect.DeepEqual(args, want) {
t.Fatalf("unexpected filter arguments: got %#v, want %#v", args, want) t.Fatalf("unexpected filter arguments: got %#v, want %#v", args, want)
} }
+30 -9
View File
@@ -36,6 +36,8 @@ const (
clusterRpcCompletionBuffer = 64 clusterRpcCompletionBuffer = 64
) )
var errClusterTopicOwnerUnavailable = errors.New("cluster topic owner unavailable")
// ProxyReqType is the type of proxy requests. // ProxyReqType is the type of proxy requests.
type ProxyReqType int type ProxyReqType int
@@ -926,9 +928,15 @@ func (c *Cluster) routeToTopicMaster(reqType ProxyReqType, msg *ClientComMessage
req := c.makeClusterReq(reqType, msg, topic, sess) req := c.makeClusterReq(reqType, msg, topic, sess)
// Find the cluster node which owns the topic, then forward to it. // Find the cluster node which owns the topic, then forward to it.
n := c.nodeForTopic(types.TopicKey{TenantID: req.TenantID, Topic: topic}) key := types.TopicKey{TenantID: req.TenantID, Topic: topic}
if reqType == ProxyReqLeave && !c.isRemoteTopic(key) {
// The proxy topic is stale after rehash/failover. The local session has already been
// detached, and there is no remote master to notify anymore.
return errClusterTopicOwnerUnavailable
}
n := c.nodeForTopic(key)
if n == nil { if n == nil {
return errors.New("node for topic not found") return errClusterTopicOwnerUnavailable
} }
return n.proxyToMasterAsync(req) return n.proxyToMasterAsync(req)
} }
@@ -967,9 +975,14 @@ func (c *Cluster) topicProxyGone(tenantID types.TenantID, topicName string) erro
} }
// Find the cluster node which owns the topic, then forward to it. // Find the cluster node which owns the topic, then forward to it.
n := c.nodeForTopic(types.TopicKey{TenantID: tenantID, Topic: topicName}) key := types.TopicKey{TenantID: tenantID, Topic: topicName}
if !c.isRemoteTopic(key) {
// The proxy topic is stale after rehash/failover. Nothing remote needs to be cleaned.
return errClusterTopicOwnerUnavailable
}
n := c.nodeForTopic(key)
if n == nil { if n == nil {
return errors.New("node for topic not found") return errClusterTopicOwnerUnavailable
} }
req := c.makeClusterReq(ProxyReqLeave, nil, topicName, &Session{tenantID: tenantID}) req := c.makeClusterReq(ProxyReqLeave, nil, topicName, &Session{tenantID: tenantID})
@@ -1015,11 +1028,7 @@ func clusterInit(configString json.RawMessage, self *string) int {
return 1 return 1
} }
gob.Register([]any{}) registerClusterGobTypes()
gob.Register(map[string]any{})
gob.Register(map[string]int{})
gob.Register(map[string]string{})
gob.Register(MsgAccessMode{})
if config.NumProxyEventGoRoutines != 0 { if config.NumProxyEventGoRoutines != 0 {
logs.Warn.Println("Cluster config: field num_proxy_event_goroutines is deprecated.") logs.Warn.Println("Cluster config: field num_proxy_event_goroutines is deprecated.")
@@ -1072,6 +1081,18 @@ func clusterInit(configString json.RawMessage, self *string) int {
return workerId return workerId
} }
func registerClusterGobTypes() {
gob.Register([]any{})
gob.Register(map[string]any{})
gob.Register(map[string]int{})
gob.Register(map[string]string{})
gob.Register(MsgAccessMode{})
gob.Register(imSystemMessageActor{})
gob.Register([]imSystemMessageActor{})
gob.Register(imSystemMessageAction{})
gob.Register([]imSystemMessageAction{})
}
// Proxied session is being closed at the Master node. // Proxied session is being closed at the Master node.
func (sess *Session) closeRPC() { func (sess *Session) closeRPC() {
if sess.isMultiplex() { if sess.isMultiplex() {
+72
View File
@@ -0,0 +1,72 @@
package main
import (
"bytes"
"encoding/gob"
"errors"
"testing"
"time"
"github.com/tinode/chat/server/store/types"
)
func TestClusterGobRegistersSystemMessagePayloadTypes(t *testing.T) {
registerClusterGobTypes()
msg := &ClusterRoute{
SrvMsg: &ServerComMessage{
TenantID: types.TenantID(10001),
RcptTo: "grpTest",
Data: &MsgServerData{
Topic: "grpTest",
Timestamp: time.Now().UTC().Round(time.Millisecond),
SeqId: 1,
Head: map[string]any{
"x-im-type": "system_action",
"x-im-template": "group_member_joined",
},
Content: map[string]any{
"text": "成员加入了群聊",
"template": "group_member_joined",
"actors": []imSystemMessageActor{{
UserID: "usr123",
Click: map[string]any{"type": "user_profile", "target_id": "usr123"},
}},
"actions": []imSystemMessageAction{{
Type: "group_profile",
Label: "查看群聊",
TargetID: "grpTest",
}},
},
},
},
}
var buf bytes.Buffer
if err := gob.NewEncoder(&buf).Encode(msg); err != nil {
t.Fatalf("encode cluster route with system message payload: %v", err)
}
var decoded ClusterRoute
if err := gob.NewDecoder(&buf).Decode(&decoded); err != nil {
t.Fatalf("decode cluster route with system message payload: %v", err)
}
}
func TestClusterRouteLeaveIgnoresStaleLocalProxyOwner(t *testing.T) {
cluster := &Cluster{
thisNodeName: "node-a",
nodes: map[string]*ClusterNode{},
}
cluster.rehash([]string{"node-a"})
err := cluster.routeToTopicMaster(ProxyReqLeave, nil, "grpStaleProxy", &Session{tenantID: types.TenantID(10001)})
if !errors.Is(err, errClusterTopicOwnerUnavailable) {
t.Fatalf("expected stale proxy leave to return owner unavailable, got %v", err)
}
err = cluster.topicProxyGone(types.TenantID(10001), "grpStaleProxy")
if !errors.Is(err, errClusterTopicOwnerUnavailable) {
t.Fatalf("expected stale proxy shutdown to return owner unavailable, got %v", err)
}
}
+1 -1
View File
@@ -43,7 +43,7 @@ const (
adapterName = "mongodb" adapterName = "mongodb"
defaultHost = "localhost:27017" defaultHost = "localhost:27017"
defaultDatabase = "tinode" defaultDatabase = "meeting_im"
defaultMaxResults = 1024 defaultMaxResults = 1024
// This is capped by the Session's send queue limit (128). // This is capped by the Session's send queue limit (128).
+8 -4
View File
@@ -248,6 +248,9 @@ func (a *adapter) Open(jsonconfig json.RawMessage) error {
if a.dbName == "" { if a.dbName == "" {
a.dbName = defaultDatabase a.dbName = defaultDatabase
} }
if strings.EqualFold(strings.TrimSpace(a.dbName), "tinode") {
return errors.New("mysql database tinode is deprecated; use meeting_im")
}
config.Config.DBName = a.dbName config.Config.DBName = a.dbName
a.baseConfig = &config a.baseConfig = &config
a.tenantDBRouting = config.TenantDBRouting a.tenantDBRouting = config.TenantDBRouting
@@ -2627,10 +2630,11 @@ func (a *adapter) UsersForTopic(topic string, keepDeleted bool, opts *t.QueryOpt
// Fetch all subscribed users. The number of users is not large. // Fetch all subscribed users. The number of users is not large.
q := `SELECT s.createdat,s.updatedat,s.deletedat,s.userid,s.topic,s.delid,s.recvseqid, q := `SELECT s.createdat,s.updatedat,s.deletedat,s.userid,s.topic,s.delid,s.recvseqid,
s.readseqid,s.modewant,s.modegiven,u.trusted,u.lastseen,u.useragent,s.private, s.readseqid,s.modewant,s.modegiven,u.trusted,u.lastseen,u.useragent,s.private,
p.nickname AS profile_nickname,p.avatar_file_id AS profile_avatar_file_id,p.signature AS profile_signature, p.nickname AS profile_nickname,p.avatar_file_id AS profile_avatar_file_id,pu.location AS profile_avatar_url,p.signature AS profile_signature,
p.gender AS profile_gender,p.region AS profile_region,p.public_account AS profile_public_account p.gender AS profile_gender,p.region AS profile_region,p.public_account AS profile_public_account
FROM im_subscriptions AS s JOIN im_users AS u ON s.userid=u.id FROM im_subscriptions AS s JOIN im_users AS u ON s.userid=u.id
LEFT JOIN im_user_profiles AS p ON p.user_id=s.userid LEFT JOIN im_user_profiles AS p ON p.user_id=s.userid
LEFT JOIN im_public_uploads AS pu ON pu.file_id=p.avatar_file_id
WHERE s.topic=?` WHERE s.topic=?`
args := []any{topic} args := []any{topic}
if !keepDeleted { if !keepDeleted {
@@ -2683,21 +2687,21 @@ func (a *adapter) UsersForTopic(topic string, keepDeleted bool, opts *t.QueryOpt
var lastSeen sql.NullTime var lastSeen sql.NullTime
var userAgent string var userAgent string
var trusted any var trusted any
var profileNickname, profileAvatarFileID, profileSignature, profileGender, profileRegion, profilePublicAccount sql.NullString var profileNickname, profileAvatarFileID, profileAvatarURL, profileSignature, profileGender, profileRegion, profilePublicAccount sql.NullString
for rows.Next() { for rows.Next() {
if err = rows.Scan( if err = rows.Scan(
&sub.CreatedAt, &sub.UpdatedAt, &sub.DeletedAt, &sub.CreatedAt, &sub.UpdatedAt, &sub.DeletedAt,
&sub.User, &sub.Topic, &sub.DelId, &sub.RecvSeqId, &sub.User, &sub.Topic, &sub.DelId, &sub.RecvSeqId,
&sub.ReadSeqId, &sub.ModeWant, &sub.ModeGiven, &sub.ReadSeqId, &sub.ModeWant, &sub.ModeGiven,
&trusted, &lastSeen, &userAgent, &sub.Private, &trusted, &lastSeen, &userAgent, &sub.Private,
&profileNickname, &profileAvatarFileID, &profileSignature, &profileNickname, &profileAvatarFileID, &profileAvatarURL, &profileSignature,
&profileGender, &profileRegion, &profilePublicAccount); err != nil { &profileGender, &profileRegion, &profilePublicAccount); err != nil {
break break
} }
sub.User = common.EncodeUidString(sub.User).String() sub.User = common.EncodeUidString(sub.User).String()
sub.Private = common.FromJSON(sub.Private) sub.Private = common.FromJSON(sub.Private)
sub.SetPublic(profilePublicFromFields(profileNickname, profileAvatarFileID, profileSignature, profileGender, profileRegion, profilePublicAccount)) sub.SetPublic(profilePublicFromFieldsWithAvatarURL(profileNickname, profileAvatarFileID, profileAvatarURL, profileSignature, profileGender, profileRegion, profilePublicAccount))
sub.SetTrusted(common.FromJSON(trusted)) sub.SetTrusted(common.FromJSON(trusted))
sub.SetLastSeenAndUA(&lastSeen.Time, userAgent) sub.SetLastSeenAndUA(&lastSeen.Time, userAgent)
subs = append(subs, sub) subs = append(subs, sub)
+15 -3
View File
@@ -4,6 +4,8 @@
package mysql package mysql
import ( import (
"encoding/json"
"strings"
"testing" "testing"
ms "github.com/go-sql-driver/mysql" ms "github.com/go-sql-driver/mysql"
@@ -17,17 +19,17 @@ func TestNormalizeMySQLDSN(t *testing.T) {
}{ }{
{ {
name: "go driver dsn", name: "go driver dsn",
dsn: "root:secret@tcp(127.0.0.1:3306)/tinode?parseTime=true", dsn: "root:secret@tcp(127.0.0.1:3306)/meeting_im?parseTime=true",
user: "root", user: "root",
}, },
{ {
name: "url scheme dsn", name: "url scheme dsn",
dsn: "mysql://root:secret@tcp(127.0.0.1:3306)/tinode?parseTime=true", dsn: "mysql://root:secret@tcp(127.0.0.1:3306)/meeting_im?parseTime=true",
user: "root", user: "root",
}, },
{ {
name: "legacy scheme dsn", name: "legacy scheme dsn",
dsn: "mysql:root:secret@tcp(127.0.0.1:3306)/tinode?parseTime=true", dsn: "mysql:root:secret@tcp(127.0.0.1:3306)/meeting_im?parseTime=true",
user: "root", user: "root",
}, },
} }
@@ -43,3 +45,13 @@ func TestNormalizeMySQLDSN(t *testing.T) {
}) })
} }
} }
func TestOpenRejectsDeprecatedTinodeDatabase(t *testing.T) {
config := json.RawMessage(`{"dsn":"mysql://root:secret@tcp(127.0.0.1:3306)/tinode?parseTime=true"}`)
adp := &adapter{}
err := adp.Open(config)
if err == nil || !strings.Contains(err.Error(), "tinode is deprecated") {
t.Fatalf("Open() error = %v, want deprecated database error", err)
}
}
+3 -2
View File
@@ -6,7 +6,7 @@ package mysql
func ensureIMAdminGovernanceTables(exec sqlExecutor) error { func ensureIMAdminGovernanceTables(exec sqlExecutor) error {
statements := []string{ statements := []string{
`CREATE TABLE IF NOT EXISTS im_user_login_logs ( `CREATE TABLE IF NOT EXISTS im_user_login_logs (
id BIGINT NOT NULL AUTO_INCREMENT, user_id BIGINT NOT NULL, id BIGINT NOT NULL AUTO_INCREMENT, tenant_id BIGINT NOT NULL, user_id BIGINT NOT NULL,
username VARCHAR(64) NOT NULL DEFAULT '', account_type VARCHAR(24) NOT NULL DEFAULT '', username VARCHAR(64) NOT NULL DEFAULT '', account_type VARCHAR(24) NOT NULL DEFAULT '',
account_hash CHAR(64) NOT NULL DEFAULT '', account_mask VARCHAR(128) NOT NULL DEFAULT '', account_hash CHAR(64) NOT NULL DEFAULT '', account_mask VARCHAR(128) NOT NULL DEFAULT '',
login_type VARCHAR(24) NOT NULL, event_type VARCHAR(24) NOT NULL DEFAULT 'login', login_type VARCHAR(24) NOT NULL, event_type VARCHAR(24) NOT NULL DEFAULT 'login',
@@ -16,7 +16,8 @@ func ensureIMAdminGovernanceTables(exec sqlExecutor) error {
user_agent VARCHAR(512) NOT NULL DEFAULT '', risk_level VARCHAR(16) NOT NULL DEFAULT 'low', user_agent VARCHAR(512) NOT NULL DEFAULT '', risk_level VARCHAR(16) NOT NULL DEFAULT 'low',
request_id VARCHAR(64) NOT NULL DEFAULT '', login_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), request_id VARCHAR(64) NOT NULL DEFAULT '', login_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
logout_at DATETIME(3), metadata JSON, created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), logout_at DATETIME(3), metadata JSON, created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
PRIMARY KEY(id), INDEX idx_im_user_login_user_time(user_id, login_at DESC, id DESC), PRIMARY KEY(id), INDEX idx_im_user_login_tenant_time(tenant_id, login_at DESC, id DESC),
INDEX idx_im_user_login_user_time(user_id, login_at DESC, id DESC),
INDEX idx_im_user_login_ip_time(ip, login_at DESC, id DESC), INDEX idx_im_user_login_ip_time(ip, login_at DESC, id DESC),
INDEX idx_im_user_login_device_time(device_id(64), login_at DESC), INDEX idx_im_user_login_device_time(device_id(64), login_at DESC),
INDEX idx_im_user_login_request(request_id) INDEX idx_im_user_login_request(request_id)
@@ -8,7 +8,7 @@ import (
"testing" "testing"
) )
func TestEnsureIMAdminGovernanceTablesUsesSelectedTenantDatabase(t *testing.T) { func TestEnsureIMAdminGovernanceTablesDefinesLoginAuditTenantScope(t *testing.T) {
exec := &recordingGroupSchemaExecutor{} exec := &recordingGroupSchemaExecutor{}
if err := ensureIMAdminGovernanceTables(exec); err != nil { if err := ensureIMAdminGovernanceTables(exec); err != nil {
t.Fatal(err) t.Fatal(err)
@@ -27,7 +27,15 @@ func TestEnsureIMAdminGovernanceTablesUsesSelectedTenantDatabase(t *testing.T) {
t.Fatalf("hot login state table must not be created: %s", table) t.Fatalf("hot login state table must not be created: %s", table)
} }
} }
if strings.Contains(joined, "tinode.") || strings.Contains(joined, "create database") || strings.Contains(joined, "tenant_id") { if strings.Contains(joined, "tinode.") || strings.Contains(joined, "create database") {
t.Fatalf("admin governance schema must stay inside the selected tenant database: %s", joined) t.Fatalf("admin governance schema must use the active shared database: %s", joined)
}
start := strings.Index(joined, "create table if not exists im_user_login_logs")
if start < 0 {
t.Fatal("missing im_user_login_logs")
}
end := strings.Index(joined[start:], ") engine=")
if end < 0 || !strings.Contains(joined[start:start+end], "tenant_id bigint not null") {
t.Fatal("im_user_login_logs must declare tenant_id")
} }
} }
+35 -35
View File
@@ -19,9 +19,9 @@ func ensureIMGroupBusinessTables(exec sqlExecutor) error {
version INT NOT NULL DEFAULT 1, created_by BIGINT NOT NULL, dissolved_by BIGINT, version INT NOT NULL DEFAULT 1, created_by BIGINT NOT NULL, dissolved_by BIGINT,
dissolved_at DATETIME(3), created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), dissolved_at DATETIME(3), created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY(id), UNIQUE INDEX uk_im_group_settings_topic(topic), PRIMARY KEY(id), UNIQUE INDEX uk_im_group_settings_topic(tenant_id, topic),
INDEX idx_im_group_settings_owner(owner_user_id, group_status), INDEX idx_im_group_settings_owner(tenant_id, owner_user_id, group_status),
INDEX idx_im_group_settings_status_updated(group_status, updated_at) INDEX idx_im_group_settings_status_updated(tenant_id, 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 ( `CREATE TABLE IF NOT EXISTS im_group_messages (
tenant_id BIGINT NOT NULL DEFAULT 0, id BIGINT NOT NULL AUTO_INCREMENT, topic VARCHAR(64) NOT NULL, group_id BIGINT NOT NULL, tenant_id BIGINT NOT NULL DEFAULT 0, id BIGINT NOT NULL AUTO_INCREMENT, topic VARCHAR(64) NOT NULL, group_id BIGINT NOT NULL,
@@ -53,10 +53,10 @@ func ensureIMGroupBusinessTables(exec sqlExecutor) error {
removed_by BIGINT, version INT NOT NULL DEFAULT 1, removed_by BIGINT, version INT NOT NULL DEFAULT 1,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY(id), UNIQUE INDEX uk_im_group_members_topic_user(topic, user_id), PRIMARY KEY(id), UNIQUE INDEX uk_im_group_members_topic_user(tenant_id, topic, user_id),
INDEX idx_im_group_members_user_status(user_id, member_status, updated_at), INDEX idx_im_group_members_user_status(tenant_id, user_id, member_status, updated_at),
INDEX idx_im_group_members_topic_role(topic, role, member_status, id), INDEX idx_im_group_members_topic_role(tenant_id, topic, role, member_status, id),
INDEX idx_im_group_members_topic_status(topic, member_status, id) INDEX idx_im_group_members_topic_status(tenant_id, topic, member_status, id)
) 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_invitations ( `CREATE TABLE IF NOT EXISTS im_group_invitations (
tenant_id BIGINT NOT NULL DEFAULT 0, id BIGINT NOT NULL AUTO_INCREMENT, invitation_id VARCHAR(64) NOT NULL, topic VARCHAR(32) NOT NULL, tenant_id BIGINT NOT NULL DEFAULT 0, id BIGINT NOT NULL AUTO_INCREMENT, invitation_id VARCHAR(64) NOT NULL, topic VARCHAR(32) NOT NULL,
@@ -66,10 +66,10 @@ func ensureIMGroupBusinessTables(exec sqlExecutor) error {
expires_at DATETIME(3) NOT NULL, handled_at DATETIME(3), expires_at DATETIME(3) NOT NULL, handled_at DATETIME(3),
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY(id), UNIQUE INDEX uk_im_group_invitations_invitation(invitation_id), PRIMARY KEY(id), UNIQUE INDEX uk_im_group_invitations_invitation(tenant_id, invitation_id),
UNIQUE INDEX uk_im_group_invitations_idempotency(inviter_user_id, idempotency_key), UNIQUE INDEX uk_im_group_invitations_idempotency(tenant_id, inviter_user_id, idempotency_key),
INDEX idx_im_group_invitations_target(target_user_id, status, expires_at), INDEX idx_im_group_invitations_target(tenant_id, target_user_id, status, expires_at),
INDEX idx_im_group_invitations_topic_status(topic, status, id) INDEX idx_im_group_invitations_topic_status(tenant_id, topic, status, id)
) 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_join_requests ( `CREATE TABLE IF NOT EXISTS im_group_join_requests (
tenant_id BIGINT NOT NULL DEFAULT 0, id BIGINT NOT NULL AUTO_INCREMENT, request_id VARCHAR(64) NOT NULL, topic VARCHAR(32) NOT NULL, tenant_id BIGINT NOT NULL DEFAULT 0, id BIGINT NOT NULL AUTO_INCREMENT, request_id VARCHAR(64) NOT NULL, topic VARCHAR(32) NOT NULL,
@@ -80,11 +80,11 @@ func ensureIMGroupBusinessTables(exec sqlExecutor) error {
idempotency_key VARCHAR(128) NOT NULL DEFAULT '', version INT NOT NULL DEFAULT 1, idempotency_key VARCHAR(128) NOT NULL DEFAULT '', version INT NOT NULL DEFAULT 1,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY(id), UNIQUE INDEX uk_im_group_join_requests_request(request_id), PRIMARY KEY(id), UNIQUE INDEX uk_im_group_join_requests_request(tenant_id, request_id),
UNIQUE INDEX uk_im_group_join_requests_idempotency(applicant_user_id, idempotency_key), UNIQUE INDEX uk_im_group_join_requests_idempotency(tenant_id, applicant_user_id, idempotency_key),
INDEX idx_im_group_join_requests_topic_status(topic, status, id), INDEX idx_im_group_join_requests_topic_status(tenant_id, topic, status, id),
INDEX idx_im_group_join_requests_applicant(applicant_user_id, topic, status, id), INDEX idx_im_group_join_requests_applicant(tenant_id, applicant_user_id, topic, status, id),
INDEX idx_im_group_join_requests_expire(status, expires_at) INDEX idx_im_group_join_requests_expire(tenant_id, status, expires_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_announcements ( `CREATE TABLE IF NOT EXISTS im_group_announcements (
tenant_id BIGINT NOT NULL DEFAULT 0, id BIGINT NOT NULL AUTO_INCREMENT, announcement_id VARCHAR(64) NOT NULL, topic VARCHAR(32) NOT NULL, tenant_id BIGINT NOT NULL DEFAULT 0, id BIGINT NOT NULL AUTO_INCREMENT, announcement_id VARCHAR(64) NOT NULL, topic VARCHAR(32) NOT NULL,
@@ -93,17 +93,17 @@ func ensureIMGroupBusinessTables(exec sqlExecutor) error {
idempotency_key VARCHAR(128) NOT NULL DEFAULT '', idempotency_key VARCHAR(128) NOT NULL DEFAULT '',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY(id), UNIQUE INDEX uk_im_group_announcements_announcement(announcement_id), PRIMARY KEY(id), UNIQUE INDEX uk_im_group_announcements_announcement(tenant_id, announcement_id),
UNIQUE INDEX uk_im_group_announcements_idempotency(topic, author_user_id, idempotency_key), UNIQUE INDEX uk_im_group_announcements_idempotency(tenant_id, topic, author_user_id, idempotency_key),
INDEX idx_im_group_announcements_topic_status(topic, status, version DESC, id DESC), INDEX idx_im_group_announcements_topic_status(tenant_id, topic, status, version DESC, id DESC),
INDEX idx_im_group_announcements_topic_time(topic, published_at DESC, id DESC) INDEX idx_im_group_announcements_topic_time(tenant_id, topic, published_at DESC, id DESC)
) 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_announcement_reads ( `CREATE TABLE IF NOT EXISTS im_group_announcement_reads (
tenant_id BIGINT NOT NULL DEFAULT 0, id BIGINT NOT NULL, announcement_id VARCHAR(64) NOT NULL, topic VARCHAR(32) NOT NULL, user_id BIGINT NOT NULL, tenant_id BIGINT NOT NULL DEFAULT 0, id BIGINT NOT NULL, announcement_id VARCHAR(64) NOT NULL, topic VARCHAR(32) NOT NULL, user_id BIGINT NOT NULL,
read_at DATETIME(3) NOT NULL, created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), read_at DATETIME(3) NOT NULL, created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY(id), UNIQUE INDEX uk_im_group_announcement_reads_announcement_user(announcement_id, user_id), PRIMARY KEY(id), UNIQUE INDEX uk_im_group_announcement_reads_announcement_user(tenant_id, announcement_id, user_id),
INDEX idx_im_group_announcement_reads_topic_user(topic, user_id, read_at) INDEX idx_im_group_announcement_reads_topic_user(tenant_id, topic, user_id, read_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_mutes ( `CREATE TABLE IF NOT EXISTS im_group_mutes (
tenant_id BIGINT NOT NULL DEFAULT 0, id BIGINT NOT NULL, topic VARCHAR(32) NOT NULL, user_id BIGINT NOT NULL, muted_by BIGINT NOT NULL, tenant_id BIGINT NOT NULL DEFAULT 0, id BIGINT NOT NULL, topic VARCHAR(32) NOT NULL, user_id BIGINT NOT NULL, muted_by BIGINT NOT NULL,
@@ -111,9 +111,9 @@ func ensureIMGroupBusinessTables(exec sqlExecutor) error {
status VARCHAR(16) NOT NULL DEFAULT 'active', revoked_by BIGINT, revoked_at DATETIME(3), status VARCHAR(16) NOT NULL DEFAULT 'active', revoked_by BIGINT, revoked_at DATETIME(3),
version INT NOT NULL DEFAULT 1, created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), version INT NOT NULL DEFAULT 1, created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY(id), UNIQUE INDEX uk_im_group_mutes_topic_user(topic, user_id), PRIMARY KEY(id), UNIQUE INDEX uk_im_group_mutes_topic_user(tenant_id, topic, user_id),
INDEX idx_im_group_mutes_topic_status(topic, status, expires_at), INDEX idx_im_group_mutes_topic_status(tenant_id, topic, status, expires_at),
INDEX idx_im_group_mutes_user_status(user_id, status, expires_at) INDEX idx_im_group_mutes_user_status(tenant_id, user_id, status, expires_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_qrcodes ( `CREATE TABLE IF NOT EXISTS im_group_qrcodes (
tenant_id BIGINT NOT NULL DEFAULT 0, id BIGINT NOT NULL AUTO_INCREMENT, token_id VARCHAR(64) NOT NULL, token_hash CHAR(64) NOT NULL, tenant_id BIGINT NOT NULL DEFAULT 0, id BIGINT NOT NULL AUTO_INCREMENT, token_id VARCHAR(64) NOT NULL, token_hash CHAR(64) NOT NULL,
@@ -121,8 +121,8 @@ func ensureIMGroupBusinessTables(exec sqlExecutor) error {
expires_at DATETIME(3) NOT NULL, revoked_by BIGINT, revoked_at DATETIME(3), expires_at DATETIME(3) NOT NULL, revoked_by BIGINT, revoked_at DATETIME(3),
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY(id), UNIQUE INDEX uk_im_group_qrcodes_token(token_id), PRIMARY KEY(id), UNIQUE INDEX uk_im_group_qrcodes_token(tenant_id, token_id),
UNIQUE INDEX uk_im_group_qrcodes_hash(token_hash), INDEX idx_im_group_qrcodes_topic_status(topic, status, expires_at) UNIQUE INDEX uk_im_group_qrcodes_hash(tenant_id, token_hash), INDEX idx_im_group_qrcodes_topic_status(tenant_id, topic, status, expires_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_files ( `CREATE TABLE IF NOT EXISTS im_group_files (
tenant_id BIGINT NOT NULL DEFAULT 0, id BIGINT NOT NULL AUTO_INCREMENT, group_file_id VARCHAR(64) NOT NULL, topic VARCHAR(32) NOT NULL, tenant_id BIGINT NOT NULL DEFAULT 0, id BIGINT NOT NULL AUTO_INCREMENT, group_file_id VARCHAR(64) NOT NULL, topic VARCHAR(32) NOT NULL,
@@ -133,11 +133,11 @@ func ensureIMGroupBusinessTables(exec sqlExecutor) error {
idempotency_key VARCHAR(128) NOT NULL DEFAULT '', idempotency_key VARCHAR(128) NOT NULL DEFAULT '',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY(id), UNIQUE INDEX uk_im_group_files_group_file(group_file_id), PRIMARY KEY(id), UNIQUE INDEX uk_im_group_files_group_file(tenant_id, group_file_id),
UNIQUE INDEX uk_im_group_files_topic_file(topic, file_id), UNIQUE INDEX uk_im_group_files_topic_file(tenant_id, topic, file_id),
UNIQUE INDEX uk_im_group_files_idempotency(topic, uploader_id, idempotency_key), UNIQUE INDEX uk_im_group_files_idempotency(tenant_id, topic, uploader_id, idempotency_key),
INDEX idx_im_group_files_topic_type(topic, status, file_type, id DESC), INDEX idx_im_group_files_topic_type(tenant_id, topic, status, file_type, id DESC),
INDEX idx_im_group_files_topic_name(topic, status, file_name), INDEX idx_im_group_files_uploader(uploader_id, id DESC) INDEX idx_im_group_files_topic_name(tenant_id, topic, status, file_name), INDEX idx_im_group_files_uploader(tenant_id, uploader_id, id DESC)
) 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_admin_logs ( `CREATE TABLE IF NOT EXISTS im_group_admin_logs (
tenant_id BIGINT NOT NULL DEFAULT 0, id BIGINT NOT NULL AUTO_INCREMENT, log_id VARCHAR(64) NOT NULL, topic VARCHAR(32) NOT NULL, tenant_id BIGINT NOT NULL DEFAULT 0, id BIGINT NOT NULL AUTO_INCREMENT, log_id VARCHAR(64) NOT NULL, topic VARCHAR(32) NOT NULL,
@@ -145,9 +145,9 @@ func ensureIMGroupBusinessTables(exec sqlExecutor) error {
object_type VARCHAR(32) NOT NULL DEFAULT '', object_id VARCHAR(64) NOT NULL DEFAULT '', object_type VARCHAR(32) NOT NULL DEFAULT '', object_id VARCHAR(64) NOT NULL DEFAULT '',
before_data JSON, after_data JSON, request_id VARCHAR(128) NOT NULL DEFAULT '', before_data JSON, after_data JSON, request_id VARCHAR(128) NOT NULL DEFAULT '',
idempotency_key VARCHAR(128) NOT NULL DEFAULT '', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), idempotency_key VARCHAR(128) NOT NULL DEFAULT '', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
PRIMARY KEY(id), UNIQUE INDEX uk_im_group_admin_logs_log(log_id), PRIMARY KEY(id), UNIQUE INDEX uk_im_group_admin_logs_log(tenant_id, log_id),
INDEX idx_im_group_admin_logs_topic_time(topic, id DESC), INDEX idx_im_group_admin_logs_topic_time(tenant_id, topic, id DESC),
INDEX idx_im_group_admin_logs_operator(operator_id, id DESC), INDEX idx_im_group_admin_logs_object(object_type, object_id) INDEX idx_im_group_admin_logs_operator(tenant_id, operator_id, id DESC), INDEX idx_im_group_admin_logs_object(tenant_id, object_type, object_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`, ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
`UPDATE im_topics SET public=JSON_SET(public, '$.fn', JSON_UNQUOTE(JSON_EXTRACT(public, '$.name'))) `UPDATE im_topics SET public=JSON_SET(public, '$.fn', JSON_UNQUOTE(JSON_EXTRACT(public, '$.name')))
WHERE name LIKE 'grp%' AND JSON_VALID(public) WHERE name LIKE 'grp%' AND JSON_VALID(public)
+13 -7
View File
@@ -40,9 +40,9 @@ func TestEnsureIMGroupBusinessTablesUsesSelectedTenantDatabase(t *testing.T) {
t.Fatal("group idempotency table must not be created") t.Fatal("group idempotency table must not be created")
} }
for _, expected := range []string{ for _, expected := range []string{
"primary key(id), unique index uk_im_group_settings_topic(topic)", "primary key(id), unique index uk_im_group_settings_topic(tenant_id, topic)",
"primary key(id), unique index uk_im_group_announcement_reads_announcement_user(announcement_id, user_id)", "primary key(id), unique index uk_im_group_announcement_reads_announcement_user(tenant_id, announcement_id, user_id)",
"primary key(id), unique index uk_im_group_mutes_topic_user(topic, user_id)", "primary key(id), unique index uk_im_group_mutes_topic_user(tenant_id, topic, user_id)",
} { } {
if !strings.Contains(joined, expected) { if !strings.Contains(joined, expected) {
t.Fatalf("group schema is missing snowflake primary-key definition %q", expected) t.Fatalf("group schema is missing snowflake primary-key definition %q", expected)
@@ -116,15 +116,21 @@ func TestTenantPartitionTableNames(t *testing.T) {
} }
func TestTenantShardSQL(t *testing.T) { func TestTenantShardSQL(t *testing.T) {
query := tenantShardSQL(7, "SELECT * FROM im_topics t JOIN im_group_members gm ON gm.topic=t.name JOIN im_subscriptions s ON s.topic=gm.topic; INSERT INTO im_system_notifications(event_id) VALUES(?)") query := tenantShardSQL(7, "SELECT * FROM im_topics t JOIN im_group_members AS gm FORCE INDEX (`idx_im_group_members_topic_role`) ON gm.topic=t.name JOIN im_subscriptions s ON s.topic=gm.topic; INSERT INTO im_system_notifications(event_id) VALUES(?)")
for _, expected := range []string{"im_007_topics", "im_007_group_members", "im_007_subscriptions", "im_007_system_notifications"} { for _, expected := range []string{"im_007_topics", "im_007_group_members", "im_007_subscriptions", "im_007_system_notifications"} {
if !strings.Contains(query, expected) { if !strings.Contains(query, expected) {
t.Fatalf("tenant shard query is missing %q: %s", expected, query) t.Fatalf("tenant shard query is missing %q: %s", expected, query)
} }
} }
for _, legacy := range []string{" im_topics", " im_group_members", " im_subscriptions", " im_system_notifications"} { for _, shared := range []string{" im_topics ", " im_group_members ", " im_subscriptions ", " im_system_notifications"} {
if strings.Contains(query, legacy) { if strings.Contains(query, shared) {
t.Fatalf("tenant shard query still references %q: %s", legacy, query) t.Fatalf("tenant shard query should not reference shared table %q: %s", shared, query)
} }
} }
if !strings.Contains(query, "idx_im_group_members_topic_role") {
t.Fatalf("tenant shard query should not rewrite index names: %s", query)
}
if strings.Contains(query, "idx_im_007_group_members_topic_role") {
t.Fatalf("tenant shard query rewrote index name: %s", query)
}
} }
+3 -3
View File
@@ -29,9 +29,9 @@ var snowflakePrimaryKeySpecs = []snowflakePrimaryKeySpec{
{table: "im_friendships", uniqueIndex: "uk_im_friendships_pair", businessKey: []string{"user_id", "peer_id"}}, {table: "im_friendships", uniqueIndex: "uk_im_friendships_pair", businessKey: []string{"user_id", "peer_id"}},
{table: "im_user_blocks", uniqueIndex: "uk_im_user_blocks_pair", businessKey: []string{"blocker_id", "blocked_id"}}, {table: "im_user_blocks", uniqueIndex: "uk_im_user_blocks_pair", businessKey: []string{"blocker_id", "blocked_id"}},
{table: "im_youth_mode_settings", uniqueIndex: "uk_im_youth_mode_settings_user_purpose", businessKey: []string{"user_id", "purpose"}}, {table: "im_youth_mode_settings", uniqueIndex: "uk_im_youth_mode_settings_user_purpose", businessKey: []string{"user_id", "purpose"}},
{table: "im_group_settings", uniqueIndex: "uk_im_group_settings_topic", businessKey: []string{"topic"}}, {table: "im_group_settings", uniqueIndex: "uk_im_group_settings_topic", businessKey: []string{"tenant_id", "topic"}},
{table: "im_group_announcement_reads", uniqueIndex: "uk_im_group_announcement_reads_announcement_user", businessKey: []string{"announcement_id", "user_id"}}, {table: "im_group_announcement_reads", uniqueIndex: "uk_im_group_announcement_reads_announcement_user", businessKey: []string{"tenant_id", "announcement_id", "user_id"}},
{table: "im_group_mutes", uniqueIndex: "uk_im_group_mutes_topic_user", businessKey: []string{"topic", "user_id"}}, {table: "im_group_mutes", uniqueIndex: "uk_im_group_mutes_topic_user", businessKey: []string{"tenant_id", "topic", "user_id"}},
} }
const snowflakeMigrationLock = "CONCAT('im_snowflake_pk:', LEFT(SHA2(DATABASE(), 256), 40))" const snowflakeMigrationLock = "CONCAT('im_snowflake_pk:', LEFT(SHA2(DATABASE(), 256), 40))"
+96 -11
View File
@@ -62,6 +62,7 @@ type profileUserRow struct {
Trusted any `db:"trusted"` Trusted any `db:"trusted"`
ProfileNickname sql.NullString `db:"profile_nickname"` ProfileNickname sql.NullString `db:"profile_nickname"`
ProfileAvatarFileID sql.NullString `db:"profile_avatar_file_id"` ProfileAvatarFileID sql.NullString `db:"profile_avatar_file_id"`
ProfileAvatarURL sql.NullString `db:"profile_avatar_url"`
ProfileSignature sql.NullString `db:"profile_signature"` ProfileSignature sql.NullString `db:"profile_signature"`
ProfileGender sql.NullString `db:"profile_gender"` ProfileGender sql.NullString `db:"profile_gender"`
ProfileRegion sql.NullString `db:"profile_region"` ProfileRegion sql.NullString `db:"profile_region"`
@@ -85,7 +86,7 @@ func (row profileUserRow) lastSeen() *time.Time {
} }
func (row profileUserRow) profilePublic() any { func (row profileUserRow) profilePublic() any {
return profilePublicFromFields(row.ProfileNickname, row.ProfileAvatarFileID, row.ProfileSignature, row.ProfileGender, row.ProfileRegion, row.ProfilePublicAccount) return profilePublicFromFieldsWithAvatarURL(row.ProfileNickname, row.ProfileAvatarFileID, row.ProfileAvatarURL, row.ProfileSignature, row.ProfileGender, row.ProfileRegion, row.ProfilePublicAccount)
} }
func profileUserFromRow(row profileUserRow) t.User { func profileUserFromRow(row profileUserRow) t.User {
@@ -111,6 +112,10 @@ func profileUserFromRow(row profileUserRow) t.User {
} }
func profilePublicFromFields(nickname, avatarFileID, signature, gender, region, publicAccount sql.NullString) any { func profilePublicFromFields(nickname, avatarFileID, signature, gender, region, publicAccount sql.NullString) any {
return profilePublicFromFieldsWithAvatarURL(nickname, avatarFileID, sql.NullString{}, signature, gender, region, publicAccount)
}
func profilePublicFromFieldsWithAvatarURL(nickname, avatarFileID, avatarURL, signature, gender, region, publicAccount sql.NullString) any {
public := map[string]any{} public := map[string]any{}
if nickname.Valid { if nickname.Valid {
value := strings.TrimSpace(nickname.String) value := strings.TrimSpace(nickname.String)
@@ -124,6 +129,9 @@ func profilePublicFromFields(nickname, avatarFileID, signature, gender, region,
value := strings.TrimSpace(avatarFileID.String) value := strings.TrimSpace(avatarFileID.String)
if value != "" { if value != "" {
public["avatar_file_id"] = value public["avatar_file_id"] = value
if url := profileAvatarURL(value, avatarURL); url != "" {
public["avatar_url"] = url
}
} }
} }
if signature.Valid { if signature.Valid {
@@ -186,6 +194,20 @@ func sanitizeUserUpdate(update map[string]any) map[string]any {
return update return update
} }
func profileAvatarURL(avatarFileID string, avatarURL sql.NullString) string {
if avatarURL.Valid {
if value := strings.TrimSpace(avatarURL.String); value != "" {
return value
}
}
value := strings.TrimSpace(avatarFileID)
if strings.HasPrefix(value, "http://") || strings.HasPrefix(value, "https://") ||
strings.HasPrefix(value, "data:") || strings.HasPrefix(value, "/") {
return value
}
return ""
}
func profileSeedFromPublic(public any) (nickname, avatarFileID string) { func profileSeedFromPublic(public any) (nickname, avatarFileID string) {
values := publicMap(public) values := publicMap(public)
for _, key := range []string{"display_name", "nickname", "fn", "name"} { for _, key := range []string{"display_name", "nickname", "fn", "name"} {
@@ -1302,10 +1324,11 @@ func (a *adapter) TenantUsersForTopic(id t.TenantID, topic string, keepDeleted b
tcat := t.GetTopicCat(topic) tcat := t.GetTopicCat(topic)
q := `SELECT s.tenant_id,s.createdat,s.updatedat,s.deletedat,s.userid,s.topic,s.delid,s.recvseqid, q := `SELECT s.tenant_id,s.createdat,s.updatedat,s.deletedat,s.userid,s.topic,s.delid,s.recvseqid,
s.readseqid,s.modewant,s.modegiven,u.trusted,u.lastseen,u.useragent,s.private, s.readseqid,s.modewant,s.modegiven,u.trusted,u.lastseen,u.useragent,s.private,
p.nickname AS profile_nickname,p.avatar_file_id AS profile_avatar_file_id,p.signature AS profile_signature, p.nickname AS profile_nickname,p.avatar_file_id AS profile_avatar_file_id,pu.location AS profile_avatar_url,p.signature AS profile_signature,
p.gender AS profile_gender,p.region AS profile_region,p.public_account AS profile_public_account p.gender AS profile_gender,p.region AS profile_region,p.public_account AS profile_public_account
FROM im_subscriptions AS s JOIN im_users AS u ON s.tenant_id=u.tenant_id AND s.userid=u.id FROM im_subscriptions AS s JOIN im_users AS u ON s.tenant_id=u.tenant_id AND s.userid=u.id
LEFT JOIN im_user_profiles AS p ON p.tenant_id=s.tenant_id AND p.user_id=s.userid LEFT JOIN im_user_profiles AS p ON p.tenant_id=s.tenant_id AND p.user_id=s.userid
LEFT JOIN im_public_uploads AS pu ON pu.file_id=p.avatar_file_id
WHERE s.tenant_id=? AND s.topic=?` WHERE s.tenant_id=? AND s.topic=?`
q = tenantShardSQL(id, q) q = tenantShardSQL(id, q)
args := []any{id, topic} args := []any{id, topic}
@@ -1347,17 +1370,17 @@ func (a *adapter) TenantUsersForTopic(id t.TenantID, topic string, keepDeleted b
var sub t.Subscription var sub t.Subscription
var lastSeen sql.NullTime var lastSeen sql.NullTime
var trusted any var trusted any
var profileNickname, profileAvatarFileID, profileSignature, profileGender, profileRegion, profilePublicAccount sql.NullString var profileNickname, profileAvatarFileID, profileAvatarURL, profileSignature, profileGender, profileRegion, profilePublicAccount sql.NullString
if err = rows.Scan(&sub.TenantID, &sub.CreatedAt, &sub.UpdatedAt, &sub.DeletedAt, if err = rows.Scan(&sub.TenantID, &sub.CreatedAt, &sub.UpdatedAt, &sub.DeletedAt,
&sub.User, &sub.Topic, &sub.DelId, &sub.RecvSeqId, &sub.ReadSeqId, &sub.ModeWant, &sub.ModeGiven, &sub.User, &sub.Topic, &sub.DelId, &sub.RecvSeqId, &sub.ReadSeqId, &sub.ModeWant, &sub.ModeGiven,
&trusted, &lastSeen, &userAgent, &sub.Private, &trusted, &lastSeen, &userAgent, &sub.Private,
&profileNickname, &profileAvatarFileID, &profileSignature, &profileNickname, &profileAvatarFileID, &profileAvatarURL, &profileSignature,
&profileGender, &profileRegion, &profilePublicAccount); err != nil { &profileGender, &profileRegion, &profilePublicAccount); err != nil {
return nil, err return nil, err
} }
sub.User = common.EncodeUidString(sub.User).String() sub.User = common.EncodeUidString(sub.User).String()
sub.Private = common.FromJSON(sub.Private) sub.Private = common.FromJSON(sub.Private)
sub.SetPublic(profilePublicFromFields(profileNickname, profileAvatarFileID, profileSignature, profileGender, profileRegion, profilePublicAccount)) sub.SetPublic(profilePublicFromFieldsWithAvatarURL(profileNickname, profileAvatarFileID, profileAvatarURL, profileSignature, profileGender, profileRegion, profilePublicAccount))
sub.SetTrusted(common.FromJSON(trusted)) sub.SetTrusted(common.FromJSON(trusted))
sub.SetLastSeenAndUA(&lastSeen.Time, userAgent) sub.SetLastSeenAndUA(&lastSeen.Time, userAgent)
subs = append(subs, sub) subs = append(subs, sub)
@@ -2463,7 +2486,13 @@ func (a *adapter) TenantMessageRecall(id t.TenantID, topic string, seq int64, ac
if err = tx.Commit(); err != nil { if err = tx.Commit(); err != nil {
return nil, err return nil, err
} }
return a.TenantMessageGetOne(id, topic, seq) msg.UpdatedAt = recalledAt
msg.Version++
msg.RecalledAt = &recalledAt
msg.RecalledBy = actor.String()
msg.Head = nil
msg.Content = nil
return msg, nil
} }
func (a *adapter) TenantGroupMessageRecall(id t.TenantID, topic string, seq int64, actor t.Uid, expectedVersion int, recalledAt 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) {
@@ -2512,7 +2541,13 @@ func (a *adapter) TenantGroupMessageRecall(id t.TenantID, topic string, seq int6
if err = tx.Commit(); err != nil { if err = tx.Commit(); err != nil {
return nil, err return nil, err
} }
return a.TenantGroupMessageGetOne(id, topic, seq) msg.UpdatedAt = recalledAt
msg.Version++
msg.RecalledAt = &recalledAt
msg.RecalledBy = actor.String()
msg.Head = nil
msg.Content = nil
return msg, nil
} }
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) { 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) {
@@ -2569,7 +2604,13 @@ func (a *adapter) TenantMessageEdit(id t.TenantID, topic string, seq int64, acto
if err = tx.Commit(); err != nil { if err = tx.Commit(); err != nil {
return nil, err return nil, err
} }
return a.TenantMessageGetOne(id, topic, seq) msg.UpdatedAt = editedAt
msg.Version++
msg.Head = head
msg.Content = storedContent
msg.ContentNonce = nonce
msg.KeyVersion = keyVersion
return msg, nil
} }
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) { 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) {
@@ -2625,7 +2666,13 @@ func (a *adapter) TenantGroupMessageEdit(id t.TenantID, topic string, seq int64,
if err = tx.Commit(); err != nil { if err = tx.Commit(); err != nil {
return nil, err return nil, err
} }
return a.TenantGroupMessageGetOne(id, topic, seq) msg.UpdatedAt = editedAt
msg.Version++
msg.Head = head
msg.Content = storedContent
msg.ContentNonce = nonce
msg.KeyVersion = keyVersion
return msg, nil
} }
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) {
@@ -3648,6 +3695,34 @@ func (a *adapter) TenantDevicePushTokenUpsert(id t.TenantID, token *t.DevicePush
return token, nil return token, nil
} }
func (a *adapter) TenantDevicePushTokenDelete(id t.TenantID, uid t.Uid, deviceID string) error {
if !a.tenantScopedDB {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil {
return err
}
if tenantAdp != a {
return tenantAdp.TenantDevicePushTokenDelete(id, uid, deviceID)
}
}
deviceID = strings.TrimSpace(deviceID)
if err := validTenant(id); err != nil || uid.IsZero() || deviceID == "" {
return t.ErrMalformed
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
res, err := a.db.ExecContext(ctx, "DELETE FROM im_device_push_tokens WHERE tenant_id=? AND user_id=? AND device_id=?", id, decodeUID(uid), deviceID)
if err != nil {
return err
}
if affected, _ := res.RowsAffected(); affected == 0 {
return t.ErrNotFound
}
return nil
}
func (a *adapter) TenantDevicePushTokenGetAll(id t.TenantID, uids ...t.Uid) (map[t.Uid][]t.DeviceDef, int, error) { func (a *adapter) TenantDevicePushTokenGetAll(id t.TenantID, uids ...t.Uid) (map[t.Uid][]t.DeviceDef, int, error) {
return a.tenantDevicePushTokenGetAllByProviders(id, []string{"fcm", "firebase"}, uids...) return a.tenantDevicePushTokenGetAllByProviders(id, []string{"fcm", "firebase"}, uids...)
} }
@@ -5270,7 +5345,7 @@ func (a *adapter) TenantFileUserHasAccess(id t.TenantID, fid string, uid t.Uid)
SELECT 1 FROM im_group_files AS gf SELECT 1 FROM im_group_files AS gf
INNER JOIN im_subscriptions AS s INNER JOIN im_subscriptions AS s
ON s.tenant_id=fu.tenant_id AND s.topic=gf.topic AND s.userid=? ON s.tenant_id=fu.tenant_id AND s.topic=gf.topic AND s.userid=?
WHERE (gf.file_id=? OR gf.file_id LIKE CONCAT(?,'.%%')) AND gf.status='active' WHERE gf.tenant_id=fu.tenant_id AND (gf.file_id=? OR gf.file_id LIKE CONCAT(?,'.%%')) AND gf.status='active'
AND s.deletedat IS NULL AND INSTR(s.modewant,'R')>0 AND INSTR(s.modegiven,'R')>0 AND s.deletedat IS NULL AND INSTR(s.modewant,'R')>0 AND INSTR(s.modegiven,'R')>0
) )
) LIMIT 1`, tenantFriendshipsTable(id), tenantMessagesTable(id))), ) LIMIT 1`, tenantFriendshipsTable(id), tenantMessagesTable(id))),
@@ -6773,7 +6848,7 @@ func (a *adapter) TenantFriendshipList(id t.TenantID, uid t.Uid, queryText, init
if cancel != nil { if cancel != nil {
defer cancel() defer cancel()
} }
rows, err := a.db.QueryxContext(ctx, fmt.Sprintf(`SELECT fs.*,u.trusted, rows, err := a.db.QueryxContext(ctx, fmt.Sprintf(`SELECT fs.id,fs.user_id,fs.peer_id,fs.status,fs.remark,fs.topic,fs.created_at,fs.updated_at,u.trusted,
p.nickname AS profile_nickname,p.avatar_file_id AS profile_avatar_file_id,p.signature AS profile_signature, p.nickname AS profile_nickname,p.avatar_file_id AS profile_avatar_file_id,p.signature AS profile_signature,
p.gender AS profile_gender,p.region AS profile_region,p.public_account AS profile_public_account,p.updated_at AS profile_updated_at p.gender AS profile_gender,p.region AS profile_region,p.public_account AS profile_public_account,p.updated_at AS profile_updated_at
FROM %s AS fs FROM %s AS fs
@@ -7149,6 +7224,10 @@ func (a *adapter) TenantUserSearch(id t.TenantID, viewer t.Uid, queryText, searc
} }
where := []string{"u.id<>?", "u.state=?"} where := []string{"u.id<>?", "u.state=?"}
args := []any{decodeUID(viewer), t.StateOK} args := []any{decodeUID(viewer), t.StateOK}
if !a.tenantScopedDB {
where = append([]string{"u.tenant_id=?"}, where...)
args = append([]any{id}, args...)
}
if cursor != "" { if cursor != "" {
parsed, err := strconv.ParseInt(cursor, 10, 64) parsed, err := strconv.ParseInt(cursor, 10, 64)
if err != nil || parsed <= 0 { if err != nil || parsed <= 0 {
@@ -7161,6 +7240,12 @@ func (a *adapter) TenantUserSearch(id t.TenantID, viewer t.Uid, queryText, searc
"LEFT JOIN im_user_privacy_settings AS privacy ON privacy.user_id=u.id", "LEFT JOIN im_user_privacy_settings AS privacy ON privacy.user_id=u.id",
"LEFT JOIN im_user_profiles AS profile ON profile.user_id=u.id", "LEFT JOIN im_user_profiles AS profile ON profile.user_id=u.id",
} }
if !a.tenantScopedDB {
joins = []string{
"LEFT JOIN im_user_privacy_settings AS privacy ON privacy.tenant_id=u.tenant_id AND privacy.user_id=u.id",
"LEFT JOIN im_user_profiles AS profile ON profile.tenant_id=u.tenant_id AND profile.user_id=u.id",
}
}
addMethod := "account" addMethod := "account"
switch searchType { switch searchType {
case "", "account": case "", "account":
+103 -95
View File
@@ -17,13 +17,15 @@ import (
) )
const ( const (
groupSettingsSelectColumns = `topic,owner_user_id,name,avatar_file_id,description,group_status,join_approval,mute_all, groupSettingsSelectColumns = `tenant_id,topic,owner_user_id,name,avatar_file_id,description,group_status,join_approval,mute_all,
member_upload_files,member_private_chat,member_invite,max_member_count,max_admin_count,member_count,version, member_upload_files,member_private_chat,member_invite,max_member_count,max_admin_count,member_count,version,
created_by,dissolved_by,dissolved_at,created_at,updated_at` created_by,dissolved_by,dissolved_at,created_at,updated_at`
groupAnnouncementSelectColumnsBase = `id,announcement_id,topic,author_user_id,content,status,version,published_at,idempotency_key,created_at,updated_at, groupAnnouncementSelectColumnsBase = `tenant_id,id,announcement_id,topic,author_user_id,content,status,version,published_at,idempotency_key,created_at,updated_at,
(SELECT COUNT(*) FROM im_group_announcement_reads AS ar WHERE ar.topic=im_group_announcements.topic AND ar.announcement_id=im_group_announcements.announcement_id) AS read_count, (SELECT COUNT(*) FROM im_group_announcement_reads AS ar WHERE ar.tenant_id=im_group_announcements.tenant_id AND ar.topic=im_group_announcements.topic AND ar.announcement_id=im_group_announcements.announcement_id) AS read_count,
(SELECT COUNT(*) FROM im_group_members AS gm WHERE gm.topic=im_group_announcements.topic AND gm.member_status='active') AS member_count` (SELECT COUNT(*) FROM im_group_members AS gm WHERE gm.tenant_id=im_group_announcements.tenant_id AND gm.topic=im_group_announcements.topic AND gm.member_status='active') AS member_count`
groupMuteSelectColumns = `topic,user_id,muted_by,muted_at,expires_at,reason,status,revoked_by,revoked_at,version,created_at,updated_at` groupMemberSelectColumns = `id,topic,user_id,role,member_status,nickname,pinned,mute_notifications,
join_source,joined_by,joined_at,left_at,removed_by,version,created_at,updated_at`
groupMuteSelectColumns = `tenant_id,topic,user_id,muted_by,muted_at,expires_at,reason,status,revoked_by,revoked_at,version,created_at,updated_at`
) )
func groupAnnouncementSelectColumns(tenantID t.TenantID) string { func groupAnnouncementSelectColumns(tenantID t.TenantID) string {
@@ -64,10 +66,10 @@ func (a *adapter) TenantGroupSettingsCreate(id t.TenantID, settings *t.GroupSett
return nil, err return nil, err
} }
_, err = a.db.ExecContext(ctx, `INSERT INTO im_group_settings _, err = a.db.ExecContext(ctx, `INSERT INTO im_group_settings
(id,topic,owner_user_id,name,avatar_file_id,description,group_status,join_approval,mute_all,member_upload_files,member_private_chat,member_invite,max_member_count,max_admin_count,member_count,version,created_by,created_at,updated_at) (id,tenant_id,topic,owner_user_id,name,avatar_file_id,description,group_status,join_approval,mute_all,member_upload_files,member_private_chat,member_invite,max_member_count,max_admin_count,member_count,version,created_by,created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
ON DUPLICATE KEY UPDATE name=VALUES(name),avatar_file_id=VALUES(avatar_file_id),description=VALUES(description),updated_at=VALUES(updated_at)`, ON DUPLICATE KEY UPDATE name=VALUES(name),avatar_file_id=VALUES(avatar_file_id),description=VALUES(description),updated_at=VALUES(updated_at)`,
settingsID, settings.Topic, decodeUID(settings.OwnerUserID), settings.Name, settings.AvatarFileID, settings.Description, settings.GroupStatus, settingsID, id, settings.Topic, decodeUID(settings.OwnerUserID), settings.Name, settings.AvatarFileID, settings.Description, settings.GroupStatus,
settings.JoinApproval, settings.MuteAll, settings.MemberUploadFiles, settings.MemberPrivateChat, settings.MemberInvite, settings.JoinApproval, settings.MuteAll, settings.MemberUploadFiles, settings.MemberPrivateChat, settings.MemberInvite,
settings.MaxMemberCount, settings.MaxAdminCount, settings.MemberCount, settings.Version, decodeUID(settings.CreatedBy), settings.CreatedAt, settings.UpdatedAt) settings.MaxMemberCount, settings.MaxAdminCount, settings.MemberCount, settings.Version, decodeUID(settings.CreatedBy), settings.CreatedAt, settings.UpdatedAt)
if err != nil { if err != nil {
@@ -89,7 +91,7 @@ func (a *adapter) TenantGroupSettingsGet(id t.TenantID, topic string) (*t.GroupS
if cancel != nil { if cancel != nil {
defer cancel() defer cancel()
} }
return a.groupSettingsByTopic(ctx, topic) return a.groupSettingsByTopic(ctx, id, topic)
} }
func (a *adapter) TenantGroupSettingsUpdate(id t.TenantID, topic string, patch t.GroupSettingsPatch) (*t.GroupSettings, error) { func (a *adapter) TenantGroupSettingsUpdate(id t.TenantID, topic string, patch t.GroupSettingsPatch) (*t.GroupSettings, error) {
@@ -152,8 +154,8 @@ func (a *adapter) TenantGroupSettingsUpdate(id t.TenantID, topic string, patch t
args = append(args, patch.MemberCountDelta) args = append(args, patch.MemberCountDelta)
} }
set = append(set, "version=version+1") set = append(set, "version=version+1")
args = append(args, topic) args = append(args, id, topic)
where := "topic=?" where := "tenant_id=? AND topic=?"
if patch.Version > 0 { if patch.Version > 0 {
where += " AND version=?" where += " AND version=?"
args = append(args, patch.Version) args = append(args, patch.Version)
@@ -185,7 +187,7 @@ func (a *adapter) TenantGroupSettingsUpdate(id t.TenantID, topic string, patch t
if err = tx.Commit(); err != nil { if err = tx.Commit(); err != nil {
return nil, err return nil, err
} }
return a.groupSettingsByTopic(ctx, topic) return a.groupSettingsByTopic(ctx, id, topic)
} }
func (a *adapter) TenantGroupDissolve(id t.TenantID, topic string, actor t.Uid, reason string, now time.Time) (*t.GroupSettings, error) { func (a *adapter) TenantGroupDissolve(id t.TenantID, topic string, actor t.Uid, reason string, now time.Time) (*t.GroupSettings, error) {
@@ -211,7 +213,7 @@ func (a *adapter) TenantGroupDissolve(id t.TenantID, topic string, actor t.Uid,
defer tx.Rollback() defer tx.Rollback()
res, err := tx.ExecContext(ctx, `UPDATE im_group_settings res, err := tx.ExecContext(ctx, `UPDATE im_group_settings
SET group_status='dissolved',dissolved_by=?,dissolved_at=?,member_count=0,version=version+1,updated_at=? SET group_status='dissolved',dissolved_by=?,dissolved_at=?,member_count=0,version=version+1,updated_at=?
WHERE topic=? AND group_status<>'dissolved'`, decodeUID(actor), now, now, strings.TrimSpace(topic)) WHERE tenant_id=? AND topic=? AND group_status<>'dissolved'`, decodeUID(actor), now, now, id, strings.TrimSpace(topic))
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -236,7 +238,7 @@ func (a *adapter) TenantGroupDissolve(id t.TenantID, topic string, actor t.Uid,
if err = tx.Commit(); err != nil { if err = tx.Commit(); err != nil {
return nil, err return nil, err
} }
return a.groupSettingsByTopic(ctx, topic) return a.groupSettingsByTopic(ctx, id, topic)
} }
func (a *adapter) TenantGroupMemberUpsert(id t.TenantID, member *t.GroupMember) (*t.GroupMember, error) { func (a *adapter) TenantGroupMemberUpsert(id t.TenantID, member *t.GroupMember) (*t.GroupMember, error) {
@@ -300,7 +302,7 @@ func (a *adapter) TenantGroupMemberGet(id t.TenantID, topic string, uid t.Uid) (
if cancel != nil { if cancel != nil {
defer cancel() defer cancel()
} }
return a.groupMemberByQuery(ctx, tenantShardSQL(id, "SELECT * FROM im_group_members WHERE tenant_id=? AND topic=? AND user_id=?"), id, topic, decodeUID(uid)) return a.groupMemberByQuery(ctx, tenantShardSQL(id, "SELECT "+groupMemberSelectColumns+" FROM im_group_members WHERE tenant_id=? AND topic=? AND user_id=?"), id, topic, decodeUID(uid))
} }
func (a *adapter) TenantGroupMemberList(id t.TenantID, topic string, opts t.GroupMemberListOptions) ([]t.GroupMember, string, error) { func (a *adapter) TenantGroupMemberList(id t.TenantID, topic string, opts t.GroupMemberListOptions) ([]t.GroupMember, string, error) {
@@ -347,7 +349,9 @@ func (a *adapter) TenantGroupMemberList(id t.TenantID, topic string, opts t.Grou
if cancel != nil { if cancel != nil {
defer cancel() defer cancel()
} }
rows, err := a.db.QueryxContext(ctx, tenantShardSQL(id, `SELECT STRAIGHT_JOIN gm.*,s.modewant,s.modegiven,u.trusted, rows, err := a.db.QueryxContext(ctx, tenantShardSQL(id, `SELECT STRAIGHT_JOIN gm.id,gm.topic,gm.user_id,gm.role,gm.member_status,gm.nickname,
gm.pinned,gm.mute_notifications,gm.join_source,gm.joined_by,gm.joined_at,gm.left_at,gm.removed_by,
gm.version,gm.created_at,gm.updated_at,s.modewant,s.modegiven,u.trusted,
p.nickname AS profile_nickname,p.avatar_file_id AS profile_avatar_file_id,p.signature AS profile_signature, p.nickname AS profile_nickname,p.avatar_file_id AS profile_avatar_file_id,p.signature AS profile_signature,
p.gender AS profile_gender,p.region AS profile_region,p.public_account AS profile_public_account p.gender AS profile_gender,p.region AS profile_region,p.public_account AS profile_public_account
FROM im_group_members AS gm FORCE INDEX (`+memberIndex+`) FROM im_group_members AS gm FORCE INDEX (`+memberIndex+`)
@@ -457,7 +461,7 @@ func (a *adapter) TenantGroupMemberUpdate(id t.TenantID, topic string, uid t.Uid
if err = tx.Commit(); err != nil { if err = tx.Commit(); err != nil {
return nil, err return nil, err
} }
return a.groupMemberByQuery(ctx, tenantShardSQL(id, "SELECT * FROM im_group_members WHERE tenant_id=? AND topic=? AND user_id=?"), id, topic, decodeUID(uid)) return a.groupMemberByQuery(ctx, tenantShardSQL(id, "SELECT "+groupMemberSelectColumns+" FROM im_group_members WHERE tenant_id=? AND topic=? AND user_id=?"), id, topic, decodeUID(uid))
} }
func (a *adapter) TenantGroupInvitationCreate(id t.TenantID, inv *t.GroupInvitation) (*t.GroupInvitation, error) { func (a *adapter) TenantGroupInvitationCreate(id t.TenantID, inv *t.GroupInvitation) (*t.GroupInvitation, error) {
@@ -484,13 +488,13 @@ func (a *adapter) TenantGroupInvitationCreate(id t.TenantID, inv *t.GroupInvitat
defer cancel() defer cancel()
} }
if strings.TrimSpace(inv.JoinRequestID) != "" { if strings.TrimSpace(inv.JoinRequestID) != "" {
existing, err := a.groupInvitationByQuery(ctx, `SELECT inv.id,inv.invitation_id,inv.topic,inv.inviter_user_id,inv.target_user_id,inv.message,inv.status,inv.result, existing, err := a.groupInvitationByQuery(ctx, `SELECT inv.tenant_id,inv.id,inv.invitation_id,inv.topic,inv.inviter_user_id,inv.target_user_id,inv.message,inv.status,inv.result,
inv.join_request_id,inv.idempotency_key,inv.expires_at,inv.handled_at,inv.created_at,inv.updated_at inv.join_request_id,inv.idempotency_key,inv.expires_at,inv.handled_at,inv.created_at,inv.updated_at
FROM im_group_invitations AS inv FROM im_group_invitations AS inv
INNER JOIN im_group_join_requests AS req ON req.request_id=inv.join_request_id INNER JOIN im_group_join_requests AS req ON req.tenant_id=inv.tenant_id AND req.request_id=inv.join_request_id
WHERE inv.topic=? AND inv.target_user_id=? AND inv.status=? AND req.status=? WHERE inv.tenant_id=? AND inv.topic=? AND inv.target_user_id=? AND inv.status=? AND req.status=?
ORDER BY req.id DESC LIMIT 1`, ORDER BY req.id DESC LIMIT 1`,
inv.Topic, decodeUID(inv.TargetUserID), t.GroupInvitationStatusPending, t.GroupJoinRequestStatusPending) id, inv.Topic, decodeUID(inv.TargetUserID), t.GroupInvitationStatusPending, t.GroupJoinRequestStatusPending)
if err == nil { if err == nil {
return existing, nil return existing, nil
} }
@@ -503,16 +507,16 @@ func (a *adapter) TenantGroupInvitationCreate(id t.TenantID, inv *t.GroupInvitat
return nil, err return nil, err
} }
defer tx.Rollback() defer tx.Rollback()
_, err = tx.ExecContext(ctx, `INSERT INTO im_group_invitations(invitation_id,topic,inviter_user_id,target_user_id,message,status,result,join_request_id,idempotency_key,expires_at,handled_at,created_at,updated_at) _, err = tx.ExecContext(ctx, `INSERT INTO im_group_invitations(tenant_id,invitation_id,topic,inviter_user_id,target_user_id,message,status,result,join_request_id,idempotency_key,expires_at,handled_at,created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?)`, VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
inv.InvitationID, inv.Topic, decodeUID(inv.InviterUserID), decodeUID(inv.TargetUserID), inv.Message, inv.Status, inv.Result, inv.JoinRequestID, inv.IdempotencyKey, inv.ExpiresAt, inv.HandledAt, now, now) id, inv.InvitationID, inv.Topic, decodeUID(inv.InviterUserID), decodeUID(inv.TargetUserID), inv.Message, inv.Status, inv.Result, inv.JoinRequestID, inv.IdempotencyKey, inv.ExpiresAt, inv.HandledAt, now, now)
if err != nil { if err != nil {
return nil, err return nil, err
} }
if strings.TrimSpace(inv.JoinRequestID) != "" { if strings.TrimSpace(inv.JoinRequestID) != "" {
_, err = tx.ExecContext(ctx, `INSERT INTO im_group_join_requests(request_id,topic,applicant_user_id,inviter_user_id,source,qr_token_hash,message,status,reject_reason,expires_at,idempotency_key,version,created_at,updated_at) _, err = tx.ExecContext(ctx, `INSERT INTO im_group_join_requests(tenant_id,request_id,topic,applicant_user_id,inviter_user_id,source,qr_token_hash,message,status,reject_reason,expires_at,idempotency_key,version,created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
inv.JoinRequestID, inv.Topic, decodeUID(inv.TargetUserID), nullableUID(inv.InviterUserID), "invitation", "", inv.Message, t.GroupJoinRequestStatusPending, "", inv.ExpiresAt, inv.IdempotencyKey, 1, now, now) id, inv.JoinRequestID, inv.Topic, decodeUID(inv.TargetUserID), nullableUID(inv.InviterUserID), "invitation", "", inv.Message, t.GroupJoinRequestStatusPending, "", inv.ExpiresAt, inv.IdempotencyKey, 1, now, now)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -544,10 +548,10 @@ func (a *adapter) TenantGroupInvitationAccept(id t.TenantID, invitationID string
var inv t.GroupInvitation var inv t.GroupInvitation
var handledAt sql.NullTime var handledAt sql.NullTime
err = tx.QueryRowxContext(ctx, `SELECT id,invitation_id,topic,inviter_user_id,target_user_id,message,status,result, err = tx.QueryRowxContext(ctx, `SELECT tenant_id,id,invitation_id,topic,inviter_user_id,target_user_id,message,status,result,
join_request_id,idempotency_key,expires_at,handled_at,created_at,updated_at join_request_id,idempotency_key,expires_at,handled_at,created_at,updated_at
FROM im_group_invitations WHERE invitation_id=? FOR UPDATE`, strings.TrimSpace(invitationID)). FROM im_group_invitations WHERE tenant_id=? AND invitation_id=? FOR UPDATE`, id, strings.TrimSpace(invitationID)).
Scan(&inv.ID, &inv.InvitationID, &inv.Topic, &inv.InviterUserRaw, &inv.TargetUserRaw, &inv.Message, &inv.Status, Scan(&inv.TenantID, &inv.ID, &inv.InvitationID, &inv.Topic, &inv.InviterUserRaw, &inv.TargetUserRaw, &inv.Message, &inv.Status,
&inv.Result, &inv.JoinRequestID, &inv.IdempotencyKey, &inv.ExpiresAt, &handledAt, &inv.CreatedAt, &inv.UpdatedAt) &inv.Result, &inv.JoinRequestID, &inv.IdempotencyKey, &inv.ExpiresAt, &handledAt, &inv.CreatedAt, &inv.UpdatedAt)
if err == sql.ErrNoRows { if err == sql.ErrNoRows {
return nil, t.ErrNotFound return nil, t.ErrNotFound
@@ -602,15 +606,15 @@ func (a *adapter) TenantGroupInvitationAccept(id t.TenantID, invitationID string
return nil, err return nil, err
} }
if _, err = tx.ExecContext(ctx, `UPDATE im_group_invitations if _, err = tx.ExecContext(ctx, `UPDATE im_group_invitations
SET status=?,result=?,handled_at=?,updated_at=? WHERE invitation_id=?`, SET status=?,result=?,handled_at=?,updated_at=? WHERE tenant_id=? AND invitation_id=?`,
t.GroupInvitationStatusJoined, "joined", now, now, inv.InvitationID); err != nil { t.GroupInvitationStatusJoined, "joined", now, now, id, inv.InvitationID); err != nil {
return nil, err return nil, err
} }
if strings.TrimSpace(inv.JoinRequestID) != "" { if strings.TrimSpace(inv.JoinRequestID) != "" {
if _, err = tx.ExecContext(ctx, `UPDATE im_group_join_requests if _, err = tx.ExecContext(ctx, `UPDATE im_group_join_requests
SET status=?,handled_by=?,handled_at=?,updated_at=?,version=version+1 SET status=?,handled_by=?,handled_at=?,updated_at=?,version=version+1
WHERE topic=? AND request_id=? AND status=?`, WHERE tenant_id=? AND topic=? AND request_id=? AND status=?`,
t.GroupJoinRequestStatusAccepted, decodeUID(uid), now, now, inv.Topic, inv.JoinRequestID, t.GroupJoinRequestStatusPending); err != nil { t.GroupJoinRequestStatusAccepted, decodeUID(uid), now, now, id, inv.Topic, inv.JoinRequestID, t.GroupJoinRequestStatusPending); err != nil {
return nil, err return nil, err
} }
} }
@@ -647,8 +651,8 @@ func (a *adapter) TenantGroupJoinRequestCreate(id t.TenantID, req *t.GroupJoinRe
if cancel != nil { if cancel != nil {
defer cancel() defer cancel()
} }
existing, err := a.groupJoinRequestByQuery(ctx, "SELECT * FROM im_group_join_requests WHERE topic=? AND applicant_user_id=? AND status=? ORDER BY id DESC LIMIT 1", existing, err := a.groupJoinRequestByQuery(ctx, "SELECT * FROM im_group_join_requests WHERE tenant_id=? AND topic=? AND applicant_user_id=? AND status=? ORDER BY id DESC LIMIT 1",
req.Topic, decodeUID(req.ApplicantUserID), t.GroupJoinRequestStatusPending) id, req.Topic, decodeUID(req.ApplicantUserID), t.GroupJoinRequestStatusPending)
if err == nil { if err == nil {
return existing, nil return existing, nil
} }
@@ -660,16 +664,16 @@ func (a *adapter) TenantGroupJoinRequestCreate(id t.TenantID, req *t.GroupJoinRe
return nil, err return nil, err
} }
defer tx.Rollback() defer tx.Rollback()
_, err = tx.ExecContext(ctx, `INSERT INTO im_group_join_requests(request_id,topic,applicant_user_id,inviter_user_id,source,qr_token_hash,message,status,reject_reason,expires_at,idempotency_key,version,created_at,updated_at) _, err = tx.ExecContext(ctx, `INSERT INTO im_group_join_requests(tenant_id,request_id,topic,applicant_user_id,inviter_user_id,source,qr_token_hash,message,status,reject_reason,expires_at,idempotency_key,version,created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
req.RequestID, req.Topic, decodeUID(req.ApplicantUserID), nullableUID(req.InviterUserID), req.Source, req.QRTokenHash, req.Message, req.Status, req.RejectReason, req.ExpiresAt, req.IdempotencyKey, req.Version, now, now) id, req.RequestID, req.Topic, decodeUID(req.ApplicantUserID), nullableUID(req.InviterUserID), req.Source, req.QRTokenHash, req.Message, req.Status, req.RejectReason, req.ExpiresAt, req.IdempotencyKey, req.Version, now, now)
if err != nil { if err != nil {
return nil, err return nil, err
} }
if err = tx.Commit(); err != nil { if err = tx.Commit(); err != nil {
return nil, err return nil, err
} }
return a.groupJoinRequestByQuery(ctx, "SELECT * FROM im_group_join_requests WHERE request_id=?", req.RequestID) return a.groupJoinRequestByQuery(ctx, "SELECT * FROM im_group_join_requests WHERE tenant_id=? AND request_id=?", id, req.RequestID)
} }
func (a *adapter) TenantGroupJoinRequestList(id t.TenantID, topic, status, cursor string, limit int) ([]t.GroupJoinRequest, string, error) { func (a *adapter) TenantGroupJoinRequestList(id t.TenantID, topic, status, cursor string, limit int) ([]t.GroupJoinRequest, string, error) {
@@ -678,15 +682,15 @@ func (a *adapter) TenantGroupJoinRequestList(id t.TenantID, topic, status, curso
} else if tenantAdp != a { } else if tenantAdp != a {
return tenantAdp.TenantGroupJoinRequestList(id, topic, status, cursor, limit) return tenantAdp.TenantGroupJoinRequestList(id, topic, status, cursor, limit)
} }
where := []string{"r.topic=?"} where := []string{"r.tenant_id=?", "r.topic=?"}
args := []any{topic} args := []any{id, topic}
if status != "" && status != "all" { if status != "" && status != "all" {
where = append(where, "r.status=?") where = append(where, "r.status=?")
args = append(args, status) args = append(args, status)
if status == t.GroupJoinRequestStatusPending { if status == t.GroupJoinRequestStatusPending {
where = append(where, `NOT EXISTS ( where = append(where, `NOT EXISTS (
SELECT 1 FROM im_group_join_requests AS newer SELECT 1 FROM im_group_join_requests AS newer
WHERE newer.topic=r.topic AND newer.applicant_user_id=r.applicant_user_id WHERE newer.tenant_id=r.tenant_id AND newer.topic=r.topic AND newer.applicant_user_id=r.applicant_user_id
AND newer.status=? AND newer.id>r.id AND newer.status=? AND newer.id>r.id
)`) )`)
args = append(args, status) args = append(args, status)
@@ -747,7 +751,7 @@ func (a *adapter) TenantGroupJoinRequestResolve(id t.TenantID, topic, requestID
} }
defer tx.Rollback() defer tx.Rollback()
var current t.GroupJoinRequest var current t.GroupJoinRequest
err = tx.QueryRowxContext(ctx, "SELECT * FROM im_group_join_requests WHERE topic=? AND request_id=? FOR UPDATE", topic, requestID).StructScan(&current) err = tx.QueryRowxContext(ctx, "SELECT * FROM im_group_join_requests WHERE tenant_id=? AND topic=? AND request_id=? FOR UPDATE", id, topic, requestID).StructScan(&current)
if err == sql.ErrNoRows { if err == sql.ErrNoRows {
return nil, t.ErrNotFound return nil, t.ErrNotFound
} }
@@ -776,7 +780,7 @@ func (a *adapter) TenantGroupJoinRequestResolve(id t.TenantID, topic, requestID
} }
} }
if _, err = tx.ExecContext(ctx, `UPDATE im_group_join_requests SET status=?,reject_reason=?,handled_by=?,handled_at=?,updated_at=?,version=version+1 if _, err = tx.ExecContext(ctx, `UPDATE im_group_join_requests SET status=?,reject_reason=?,handled_by=?,handled_at=?,updated_at=?,version=version+1
WHERE topic=? AND request_id=? AND status=?`, status, rejectReason, decodeUID(actor), now, now, topic, requestID, t.GroupJoinRequestStatusPending); err != nil { WHERE tenant_id=? AND topic=? AND request_id=? AND status=?`, status, rejectReason, decodeUID(actor), now, now, id, topic, requestID, t.GroupJoinRequestStatusPending); err != nil {
return nil, err return nil, err
} }
if action == "accept" { if action == "accept" {
@@ -790,7 +794,7 @@ func (a *adapter) TenantGroupJoinRequestResolve(id t.TenantID, topic, requestID
if err = tx.Commit(); err != nil { if err = tx.Commit(); err != nil {
return nil, err return nil, err
} }
return a.groupJoinRequestByQuery(ctx, "SELECT * FROM im_group_join_requests WHERE topic=? AND request_id=?", topic, requestID) return a.groupJoinRequestByQuery(ctx, "SELECT * FROM im_group_join_requests WHERE tenant_id=? AND topic=? AND request_id=?", id, topic, requestID)
} }
func (a *adapter) TenantGroupAnnouncementLatest(id t.TenantID, topic string) (*t.GroupAnnouncement, error) { func (a *adapter) TenantGroupAnnouncementLatest(id t.TenantID, topic string) (*t.GroupAnnouncement, error) {
@@ -803,7 +807,7 @@ func (a *adapter) TenantGroupAnnouncementLatest(id t.TenantID, topic string) (*t
if cancel != nil { if cancel != nil {
defer cancel() defer cancel()
} }
return a.groupAnnouncementByQuery(ctx, "SELECT "+groupAnnouncementSelectColumns(id)+" FROM im_group_announcements WHERE topic=? AND status=? ORDER BY version DESC,id DESC LIMIT 1", topic, t.GroupAnnouncementStatusCurrent) return a.groupAnnouncementByQuery(ctx, "SELECT "+groupAnnouncementSelectColumns(id)+" FROM im_group_announcements WHERE tenant_id=? AND topic=? AND status=? ORDER BY version DESC,id DESC LIMIT 1", id, topic, t.GroupAnnouncementStatusCurrent)
} }
func (a *adapter) TenantGroupAnnouncementGet(id t.TenantID, topic, announcementID string) (*t.GroupAnnouncement, error) { func (a *adapter) TenantGroupAnnouncementGet(id t.TenantID, topic, announcementID string) (*t.GroupAnnouncement, error) {
@@ -816,7 +820,7 @@ func (a *adapter) TenantGroupAnnouncementGet(id t.TenantID, topic, announcementI
if cancel != nil { if cancel != nil {
defer cancel() defer cancel()
} }
return a.groupAnnouncementByQuery(ctx, "SELECT "+groupAnnouncementSelectColumns(id)+" FROM im_group_announcements WHERE topic=? AND announcement_id=? LIMIT 1", topic, announcementID) return a.groupAnnouncementByQuery(ctx, "SELECT "+groupAnnouncementSelectColumns(id)+" FROM im_group_announcements WHERE tenant_id=? AND topic=? AND announcement_id=? LIMIT 1", id, topic, announcementID)
} }
func (a *adapter) TenantGroupAnnouncementPut(id t.TenantID, ann *t.GroupAnnouncement, expectedVersion int) (*t.GroupAnnouncement, error) { func (a *adapter) TenantGroupAnnouncementPut(id t.TenantID, ann *t.GroupAnnouncement, expectedVersion int) (*t.GroupAnnouncement, error) {
@@ -839,7 +843,7 @@ func (a *adapter) TenantGroupAnnouncementPut(id t.TenantID, ann *t.GroupAnnounce
} }
}() }()
var currentVersion int var currentVersion int
err = tx.QueryRowContext(ctx, "SELECT COALESCE(MAX(version),0) FROM im_group_announcements WHERE topic=?", ann.Topic).Scan(&currentVersion) err = tx.QueryRowContext(ctx, "SELECT COALESCE(MAX(version),0) FROM im_group_announcements WHERE tenant_id=? AND topic=?", id, ann.Topic).Scan(&currentVersion)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -851,7 +855,7 @@ func (a *adapter) TenantGroupAnnouncementPut(id t.TenantID, ann *t.GroupAnnounce
if now.IsZero() { if now.IsZero() {
now = time.Now().UTC().Round(time.Millisecond) now = time.Now().UTC().Round(time.Millisecond)
} }
if _, err = tx.ExecContext(ctx, "UPDATE im_group_announcements SET status=?,updated_at=? WHERE topic=? AND status=?", t.GroupAnnouncementStatusSuperseded, now, ann.Topic, t.GroupAnnouncementStatusCurrent); err != nil { if _, err = tx.ExecContext(ctx, "UPDATE im_group_announcements SET status=?,updated_at=? WHERE tenant_id=? AND topic=? AND status=?", t.GroupAnnouncementStatusSuperseded, now, id, ann.Topic, t.GroupAnnouncementStatusCurrent); err != nil {
return nil, err return nil, err
} }
if ann.AnnouncementID == "" { if ann.AnnouncementID == "" {
@@ -860,8 +864,8 @@ func (a *adapter) TenantGroupAnnouncementPut(id t.TenantID, ann *t.GroupAnnounce
if ann.IdempotencyKey == "" { if ann.IdempotencyKey == "" {
ann.IdempotencyKey = ann.AnnouncementID ann.IdempotencyKey = ann.AnnouncementID
} }
if _, err = tx.ExecContext(ctx, `INSERT INTO im_group_announcements(announcement_id,topic,author_user_id,content,status,version,published_at,idempotency_key,created_at,updated_at) if _, err = tx.ExecContext(ctx, `INSERT INTO im_group_announcements(tenant_id,announcement_id,topic,author_user_id,content,status,version,published_at,idempotency_key,created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?,?,?)`, ann.AnnouncementID, ann.Topic, decodeUID(ann.AuthorUserID), ann.Content, t.GroupAnnouncementStatusCurrent, version, now, ann.IdempotencyKey, now, now); err != nil { VALUES(?,?,?,?,?,?,?,?,?,?,?)`, id, ann.AnnouncementID, ann.Topic, decodeUID(ann.AuthorUserID), ann.Content, t.GroupAnnouncementStatusCurrent, version, now, ann.IdempotencyKey, now, now); err != nil {
return nil, err return nil, err
} }
if err = tx.Commit(); err != nil { if err = tx.Commit(); err != nil {
@@ -884,8 +888,8 @@ func (a *adapter) TenantGroupAnnouncementRead(id t.TenantID, topic, announcement
if err != nil { if err != nil {
return err return err
} }
_, err = a.db.ExecContext(ctx, `INSERT INTO im_group_announcement_reads(id,announcement_id,topic,user_id,read_at) _, err = a.db.ExecContext(ctx, `INSERT INTO im_group_announcement_reads(id,tenant_id,announcement_id,topic,user_id,read_at)
VALUES(?,?,?,?,?) ON DUPLICATE KEY UPDATE read_at=VALUES(read_at),updated_at=CURRENT_TIMESTAMP(3)`, readID, announcementID, topic, decodeUID(uid), readAt) VALUES(?,?,?,?,?,?) ON DUPLICATE KEY UPDATE read_at=VALUES(read_at),updated_at=CURRENT_TIMESTAMP(3)`, readID, id, announcementID, topic, decodeUID(uid), readAt)
return err return err
} }
@@ -895,8 +899,8 @@ func (a *adapter) TenantGroupMuteList(id t.TenantID, topic, cursor string, limit
} else if tenantAdp != a { } else if tenantAdp != a {
return tenantAdp.TenantGroupMuteList(id, topic, cursor, limit) return tenantAdp.TenantGroupMuteList(id, topic, cursor, limit)
} }
where := []string{"topic=?", "status=?"} where := []string{"tenant_id=?", "topic=?", "status=?"}
args := []any{topic, t.GroupMuteStatusActive} args := []any{id, topic, t.GroupMuteStatusActive}
if cursor != "" { if cursor != "" {
parsed, err := strconv.ParseInt(cursor, 10, 64) parsed, err := strconv.ParseInt(cursor, 10, 64)
if err != nil || parsed <= 0 { if err != nil || parsed <= 0 {
@@ -946,8 +950,8 @@ func (a *adapter) TenantGroupMuteGet(id t.TenantID, topic string, uid t.Uid) (*t
if cancel != nil { if cancel != nil {
defer cancel() defer cancel()
} }
return a.groupMuteByQuery(ctx, "SELECT "+groupMuteSelectColumns+" FROM im_group_mutes WHERE topic=? AND user_id=? AND status=? AND (expires_at IS NULL OR expires_at>?)", return a.groupMuteByQuery(ctx, "SELECT "+groupMuteSelectColumns+" FROM im_group_mutes WHERE tenant_id=? AND topic=? AND user_id=? AND status=? AND (expires_at IS NULL OR expires_at>?)",
topic, decodeUID(uid), t.GroupMuteStatusActive, now) id, topic, decodeUID(uid), t.GroupMuteStatusActive, now)
} }
func (a *adapter) TenantGroupMuteUpsert(id t.TenantID, mute *t.GroupMute) (*t.GroupMute, error) { func (a *adapter) TenantGroupMuteUpsert(id t.TenantID, mute *t.GroupMute) (*t.GroupMute, error) {
@@ -976,17 +980,17 @@ func (a *adapter) TenantGroupMuteUpsert(id t.TenantID, mute *t.GroupMute) (*t.Gr
if err != nil { if err != nil {
return nil, err return nil, err
} }
_, err = tx.ExecContext(ctx, `INSERT INTO im_group_mutes(id,topic,user_id,muted_by,muted_at,expires_at,reason,status,version) _, err = tx.ExecContext(ctx, `INSERT INTO im_group_mutes(id,tenant_id,topic,user_id,muted_by,muted_at,expires_at,reason,status,version)
VALUES(?,?,?,?,?,?,?,?,1) VALUES(?,?,?,?,?,?,?,?,?,1)
ON DUPLICATE KEY UPDATE muted_by=VALUES(muted_by),muted_at=VALUES(muted_at),expires_at=VALUES(expires_at),reason=VALUES(reason),status=VALUES(status),revoked_by=NULL,revoked_at=NULL,version=version+1,updated_at=CURRENT_TIMESTAMP(3)`, ON DUPLICATE KEY UPDATE muted_by=VALUES(muted_by),muted_at=VALUES(muted_at),expires_at=VALUES(expires_at),reason=VALUES(reason),status=VALUES(status),revoked_by=NULL,revoked_at=NULL,version=version+1,updated_at=CURRENT_TIMESTAMP(3)`,
muteID, mute.Topic, decodeUID(mute.UserID), decodeUID(mute.MutedBy), now, mute.ExpiresAt, mute.Reason, mute.Status) muteID, id, mute.Topic, decodeUID(mute.UserID), decodeUID(mute.MutedBy), now, mute.ExpiresAt, mute.Reason, mute.Status)
if err != nil { if err != nil {
return nil, err return nil, err
} }
if err = tx.Commit(); err != nil { if err = tx.Commit(); err != nil {
return nil, err return nil, err
} }
return a.groupMuteByQuery(ctx, "SELECT "+groupMuteSelectColumns+" FROM im_group_mutes WHERE topic=? AND user_id=?", mute.Topic, decodeUID(mute.UserID)) return a.groupMuteByQuery(ctx, "SELECT "+groupMuteSelectColumns+" FROM im_group_mutes WHERE tenant_id=? AND topic=? AND user_id=?", id, mute.Topic, decodeUID(mute.UserID))
} }
func (a *adapter) TenantGroupMuteDelete(id t.TenantID, topic string, uid, actor t.Uid, now time.Time) error { func (a *adapter) TenantGroupMuteDelete(id t.TenantID, topic string, uid, actor t.Uid, now time.Time) error {
@@ -1004,8 +1008,8 @@ func (a *adapter) TenantGroupMuteDelete(id t.TenantID, topic string, uid, actor
return err return err
} }
defer tx.Rollback() defer tx.Rollback()
res, err := tx.ExecContext(ctx, "UPDATE im_group_mutes SET status=?,revoked_by=?,revoked_at=?,version=version+1,updated_at=? WHERE topic=? AND user_id=? AND status=?", res, err := tx.ExecContext(ctx, "UPDATE im_group_mutes SET status=?,revoked_by=?,revoked_at=?,version=version+1,updated_at=? WHERE tenant_id=? AND topic=? AND user_id=? AND status=?",
t.GroupMuteStatusRevoked, decodeUID(actor), now, now, topic, decodeUID(uid), t.GroupMuteStatusActive) t.GroupMuteStatusRevoked, decodeUID(actor), now, now, id, topic, decodeUID(uid), t.GroupMuteStatusActive)
if err != nil { if err != nil {
return err return err
} }
@@ -1024,8 +1028,8 @@ func (a *adapter) TenantGroupFileList(id t.TenantID, topic, fileType, query, cur
if strings.TrimSpace(topic) == "" { if strings.TrimSpace(topic) == "" {
return nil, "", t.ErrMalformed return nil, "", t.ErrMalformed
} }
where := []string{"topic=?", "status='active'"} where := []string{"tenant_id=?", "topic=?", "status='active'"}
args := []any{strings.TrimSpace(topic)} args := []any{id, strings.TrimSpace(topic)}
if fileType != "" { if fileType != "" {
where = append(where, "file_type=?") where = append(where, "file_type=?")
args = append(args, strings.TrimSpace(fileType)) args = append(args, strings.TrimSpace(fileType))
@@ -1047,7 +1051,7 @@ func (a *adapter) TenantGroupFileList(id t.TenantID, topic, fileType, query, cur
if cancel != nil { if cancel != nil {
defer cancel() defer cancel()
} }
rows, err := a.db.QueryxContext(ctx, `SELECT id,group_file_id,topic,file_id,source_topic,source_seq,uploader_id, rows, err := a.db.QueryxContext(ctx, `SELECT tenant_id,id,group_file_id,topic,file_id,source_topic,source_seq,uploader_id,
file_name,mime_type,file_type,file_size,status,deleted_by,deleted_at,idempotency_key,created_at,updated_at file_name,mime_type,file_type,file_size,status,deleted_by,deleted_at,idempotency_key,created_at,updated_at
FROM im_group_files WHERE `+strings.Join(where, " AND ")+` ORDER BY id DESC LIMIT ?`, args...) FROM im_group_files WHERE `+strings.Join(where, " AND ")+` ORDER BY id DESC LIMIT ?`, args...)
if err != nil { if err != nil {
@@ -1104,15 +1108,15 @@ func (a *adapter) TenantGroupFileCreate(id t.TenantID, file *t.GroupFile) (*t.Gr
} }
defer tx.Rollback() defer tx.Rollback()
_, err = tx.ExecContext(ctx, `INSERT INTO im_group_files _, err = tx.ExecContext(ctx, `INSERT INTO im_group_files
(group_file_id,topic,file_id,source_topic,source_seq,uploader_id,file_name,mime_type,file_type,file_size,status,idempotency_key,created_at,updated_at) (tenant_id,group_file_id,topic,file_id,source_topic,source_seq,uploader_id,file_name,mime_type,file_type,file_size,status,idempotency_key,created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
ON DUPLICATE KEY UPDATE ON DUPLICATE KEY UPDATE
file_name=IF(file_name='', VALUES(file_name), file_name), file_name=IF(file_name='', VALUES(file_name), file_name),
mime_type=IF(mime_type='', VALUES(mime_type), mime_type), mime_type=IF(mime_type='', VALUES(mime_type), mime_type),
file_size=IF(file_size=0, VALUES(file_size), file_size), file_size=IF(file_size=0, VALUES(file_size), file_size),
file_type=IF(file_type='' OR file_type='file', VALUES(file_type), file_type), file_type=IF(file_type='' OR file_type='file', VALUES(file_type), file_type),
updated_at=updated_at`, updated_at=updated_at`,
file.GroupFileID, file.Topic, file.FileID, file.SourceTopic, file.SourceSeq, decodeUID(file.UploaderID), id, file.GroupFileID, file.Topic, file.FileID, file.SourceTopic, file.SourceSeq, decodeUID(file.UploaderID),
file.FileName, file.MIMEType, file.FileType, file.FileSize, file.Status, file.IdempotencyKey, now, now) file.FileName, file.MIMEType, file.FileType, file.FileSize, file.Status, file.IdempotencyKey, now, now)
if err != nil && !isDupe(err) { if err != nil && !isDupe(err) {
return nil, err return nil, err
@@ -1123,15 +1127,15 @@ func (a *adapter) TenantGroupFileCreate(id t.TenantID, file *t.GroupFile) (*t.Gr
} }
if fid := t.ParseUid(uploadID); !fid.IsZero() { if fid := t.ParseUid(uploadID); !fid.IsZero() {
if a.tenantScopedDB { if a.tenantScopedDB {
_, err = tx.ExecContext(ctx, `INSERT INTO im_filemsglinks(createdat,fileid,topic)
SELECT ?,?,? WHERE NOT EXISTS (
SELECT 1 FROM im_filemsglinks WHERE fileid=? AND topic=?)`,
now, decodeUID(fid), file.Topic, decodeUID(fid), file.Topic)
} else {
_, err = tx.ExecContext(ctx, `INSERT INTO im_filemsglinks(tenant_id,createdat,fileid,topic) _, err = tx.ExecContext(ctx, `INSERT INTO im_filemsglinks(tenant_id,createdat,fileid,topic)
SELECT ?,?,?,? WHERE NOT EXISTS ( SELECT ?,?,?,? WHERE NOT EXISTS (
SELECT 1 FROM im_filemsglinks WHERE tenant_id=? AND fileid=? AND topic=?)`, SELECT 1 FROM im_filemsglinks WHERE tenant_id=? AND fileid=? AND topic=?)`,
id, now, decodeUID(fid), file.Topic, id, decodeUID(fid), file.Topic) id, now, decodeUID(fid), file.Topic, id, decodeUID(fid), file.Topic)
} else {
_, err = tx.ExecContext(ctx, `INSERT INTO im_filemsglinks(createdat,fileid,topic)
SELECT ?,?,? WHERE NOT EXISTS (
SELECT 1 FROM im_filemsglinks WHERE fileid=? AND topic=?)`,
now, decodeUID(fid), file.Topic, decodeUID(fid), file.Topic)
} }
if err != nil { if err != nil {
return nil, err return nil, err
@@ -1140,7 +1144,7 @@ func (a *adapter) TenantGroupFileCreate(id t.TenantID, file *t.GroupFile) (*t.Gr
if err = tx.Commit(); err != nil { if err = tx.Commit(); err != nil {
return nil, err return nil, err
} }
created, err := a.groupFileByQuery(ctx, "SELECT * FROM im_group_files WHERE topic=? AND file_id=?", file.Topic, file.FileID) created, err := a.groupFileByQuery(ctx, "SELECT * FROM im_group_files WHERE tenant_id=? AND topic=? AND file_id=?", id, file.Topic, file.FileID)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -1196,8 +1200,8 @@ func (a *adapter) TenantGroupFileDelete(id t.TenantID, topic, groupFileID string
} }
query := `UPDATE im_group_files query := `UPDATE im_group_files
SET status='deleted',deleted_by=?,deleted_at=?,updated_at=? SET status='deleted',deleted_by=?,deleted_at=?,updated_at=?
WHERE topic=? AND group_file_id=? AND status='active'` WHERE tenant_id=? AND topic=? AND group_file_id=? AND status='active'`
args := []any{decodeUID(actor), now, now, strings.TrimSpace(topic), strings.TrimSpace(groupFileID)} args := []any{decodeUID(actor), now, now, id, strings.TrimSpace(topic), strings.TrimSpace(groupFileID)}
if !canManage { if !canManage {
query += " AND uploader_id=?" query += " AND uploader_id=?"
args = append(args, decodeUID(actor)) args = append(args, decodeUID(actor))
@@ -1209,7 +1213,7 @@ func (a *adapter) TenantGroupFileDelete(id t.TenantID, topic, groupFileID string
if affected, _ := res.RowsAffected(); affected == 0 { if affected, _ := res.RowsAffected(); affected == 0 {
return nil, t.ErrNotFound return nil, t.ErrNotFound
} }
return a.groupFileByQuery(ctx, "SELECT * FROM im_group_files WHERE topic=? AND group_file_id=?", strings.TrimSpace(topic), strings.TrimSpace(groupFileID)) return a.groupFileByQuery(ctx, "SELECT * FROM im_group_files WHERE tenant_id=? AND topic=? AND group_file_id=?", id, strings.TrimSpace(topic), strings.TrimSpace(groupFileID))
} }
func (a *adapter) TenantGroupCreate(id t.TenantID, topic *t.Topic, owner t.Uid, ownerPrivate any, func (a *adapter) TenantGroupCreate(id t.TenantID, topic *t.Topic, owner t.Uid, ownerPrivate any,
@@ -1222,6 +1226,10 @@ func (a *adapter) TenantGroupCreate(id t.TenantID, topic *t.Topic, owner t.Uid,
if topic == nil || settings == nil || topic.Id == "" || settings.Topic != topic.Id || owner.IsZero() { if topic == nil || settings == nil || topic.Id == "" || settings.Topic != topic.Id || owner.IsZero() {
return t.ErrMalformed return t.ErrMalformed
} }
topic.TenantID = id
topic.Owner = owner.String()
settings.TenantID = id
settings.OwnerUserID = owner
ctx, cancel := a.getContextForTx() ctx, cancel := a.getContextForTx()
if cancel != nil { if cancel != nil {
defer cancel() defer cancel()
@@ -1231,7 +1239,7 @@ func (a *adapter) TenantGroupCreate(id t.TenantID, topic *t.Topic, owner t.Uid,
return err return err
} }
defer tx.Rollback() defer tx.Rollback()
if err = a.topicCreate(tx, topic); err != nil { if err = tenantTopicCreate(tx, topic); err != nil {
return err return err
} }
ownerSub := &t.Subscription{TenantID: id, ObjHeader: t.ObjHeader{CreatedAt: topic.CreatedAt}, User: owner.String(), ownerSub := &t.Subscription{TenantID: id, ObjHeader: t.ObjHeader{CreatedAt: topic.CreatedAt}, User: owner.String(),
@@ -1241,7 +1249,7 @@ func (a *adapter) TenantGroupCreate(id t.TenantID, topic *t.Topic, owner t.Uid,
return err return err
} }
settings.MemberCount = 1 settings.MemberCount = 1
if err = insertGroupSettingsTx(tx, settings); err != nil { if err = insertGroupSettingsTx(tx, id, settings); err != nil {
return err return err
} }
ownerMember := &t.GroupMember{Topic: topic.Id, UserID: owner, Role: t.GroupMemberRoleOwner, ownerMember := &t.GroupMember{Topic: topic.Id, UserID: owner, Role: t.GroupMemberRoleOwner,
@@ -1315,8 +1323,8 @@ func (a *adapter) TenantGroupInviteMember(id t.TenantID, topic string, inviter,
idempotencyKey += ":" + target.UserId() idempotencyKey += ":" + target.UserId()
} }
if _, err = tx.ExecContext(ctx, `INSERT INTO im_group_invitations if _, err = tx.ExecContext(ctx, `INSERT INTO im_group_invitations
(invitation_id,topic,inviter_user_id,target_user_id,message,status,result,idempotency_key,expires_at,handled_at,created_at,updated_at) (tenant_id,invitation_id,topic,inviter_user_id,target_user_id,message,status,result,idempotency_key,expires_at,handled_at,created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?,?,?,?,?)`, invitationID, topic, decodeUID(inviter), decodeUID(target), message, VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?)`, id, invitationID, topic, decodeUID(inviter), decodeUID(target), message,
t.GroupInvitationStatusJoined, "joined", idempotencyKey, now.Add(7*24*time.Hour), now, now, now); err != nil { t.GroupInvitationStatusJoined, "joined", idempotencyKey, now.Add(7*24*time.Hour), now, now, now); err != nil {
return false, err return false, err
} }
@@ -1454,8 +1462,8 @@ func (a *adapter) TenantGroupOwnerTransfer(id t.TenantID, topic string, previous
if _, err = tx.ExecContext(ctx, tenantShardSQL(id, "UPDATE im_topics SET owner=?,updatedat=? WHERE tenant_id=? AND name=?"), decodeUID(newOwner), now, id, topic); err != nil { if _, err = tx.ExecContext(ctx, tenantShardSQL(id, "UPDATE im_topics SET owner=?,updatedat=? WHERE tenant_id=? AND name=?"), decodeUID(newOwner), now, id, topic); err != nil {
return err return err
} }
args := []any{decodeUID(newOwner), now, topic} args := []any{decodeUID(newOwner), now, id, topic}
query := "UPDATE im_group_settings SET owner_user_id=?,version=version+1,updated_at=? WHERE topic=?" query := "UPDATE im_group_settings SET owner_user_id=?,version=version+1,updated_at=? WHERE tenant_id=? AND topic=?"
if version > 0 { if version > 0 {
query += " AND version=?" query += " AND version=?"
args = append(args, version) args = append(args, version)
@@ -1485,23 +1493,23 @@ func (a *adapter) TenantGroupCanPost(id t.TenantID, topic string, uid t.Uid, adm
} }
var muteAll, memberMuted bool var muteAll, memberMuted bool
err := a.db.QueryRowContext(ctx, `SELECT err := a.db.QueryRowContext(ctx, `SELECT
COALESCE((SELECT mute_all FROM im_group_settings WHERE topic=? AND group_status=? LIMIT 1),0), COALESCE((SELECT mute_all FROM im_group_settings WHERE tenant_id=? AND topic=? AND group_status=? LIMIT 1),0),
EXISTS(SELECT 1 FROM im_group_mutes WHERE topic=? AND user_id=? AND status=? AND (expires_at IS NULL OR expires_at>?))`, EXISTS(SELECT 1 FROM im_group_mutes WHERE tenant_id=? AND topic=? AND user_id=? AND status=? AND (expires_at IS NULL OR expires_at>?))`,
topic, t.GroupStatusActive, topic, decodeUID(uid), t.GroupMuteStatusActive, now).Scan(&muteAll, &memberMuted) id, topic, t.GroupStatusActive, id, topic, decodeUID(uid), t.GroupMuteStatusActive, now).Scan(&muteAll, &memberMuted)
if err != nil { if err != nil {
return false, err return false, err
} }
return !muteAll && !memberMuted, nil return !muteAll && !memberMuted, nil
} }
func insertGroupSettingsTx(tx *sqlx.Tx, settings *t.GroupSettings) error { func insertGroupSettingsTx(tx *sqlx.Tx, tenantID t.TenantID, settings *t.GroupSettings) error {
settingsID, err := nextSnowflakeID() settingsID, err := nextSnowflakeID()
if err != nil { if err != nil {
return err return err
} }
_, err = tx.Exec(`INSERT INTO im_group_settings _, err = tx.Exec(`INSERT INTO im_group_settings
(id,topic,owner_user_id,name,avatar_file_id,description,group_status,join_approval,mute_all,member_upload_files,member_private_chat,member_invite,max_member_count,max_admin_count,member_count,version,created_by,created_at,updated_at) (id,tenant_id,topic,owner_user_id,name,avatar_file_id,description,group_status,join_approval,mute_all,member_upload_files,member_private_chat,member_invite,max_member_count,max_admin_count,member_count,version,created_by,created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, settingsID, settings.Topic, decodeUID(settings.OwnerUserID), settings.Name, VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, settingsID, tenantID, settings.Topic, decodeUID(settings.OwnerUserID), settings.Name,
settings.AvatarFileID, settings.Description, settings.GroupStatus, settings.JoinApproval, settings.MuteAll, settings.AvatarFileID, settings.Description, settings.GroupStatus, settings.JoinApproval, settings.MuteAll,
settings.MemberUploadFiles, settings.MemberPrivateChat, settings.MemberInvite, settings.MaxMemberCount, settings.MemberUploadFiles, settings.MemberPrivateChat, settings.MemberInvite, settings.MaxMemberCount,
settings.MaxAdminCount, settings.MemberCount, settings.Version, decodeUID(settings.CreatedBy), settings.CreatedAt, settings.UpdatedAt) settings.MaxAdminCount, settings.MemberCount, settings.Version, decodeUID(settings.CreatedBy), settings.CreatedAt, settings.UpdatedAt)
@@ -1545,7 +1553,7 @@ func syncGroupMuteAllTx(tx *sqlx.Tx, tenantID t.TenantID, topic string, muteAll
mode = t.ModeCPublic &^ t.ModeWrite mode = t.ModeCPublic &^ t.ModeWrite
} }
_, err := tx.Exec(tenantShardSQL(tenantID, `UPDATE im_subscriptions AS s _, err := tx.Exec(tenantShardSQL(tenantID, `UPDATE im_subscriptions AS s
JOIN im_group_members AS gm ON gm.topic=s.topic AND gm.user_id=s.userid JOIN im_group_members AS gm ON gm.tenant_id=s.tenant_id AND gm.topic=s.topic AND gm.user_id=s.userid
SET s.modewant=?,s.modegiven=?,s.updatedat=?, SET s.modewant=?,s.modegiven=?,s.updatedat=?,
gm.version=gm.version+1,gm.updated_at=? gm.version=gm.version+1,gm.updated_at=?
WHERE s.topic=? AND s.deletedat IS NULL WHERE s.topic=? AND s.deletedat IS NULL
@@ -1565,9 +1573,9 @@ func (a *adapter) routeGroupTenant(id t.TenantID) (*adapter, error) {
return a, validTenant(id) return a, validTenant(id)
} }
func (a *adapter) groupSettingsByTopic(ctx context.Context, topic string) (*t.GroupSettings, error) { func (a *adapter) groupSettingsByTopic(ctx context.Context, tenantID t.TenantID, topic string) (*t.GroupSettings, error) {
var item t.GroupSettings var item t.GroupSettings
err := a.db.QueryRowxContext(ctx, "SELECT "+groupSettingsSelectColumns+" FROM im_group_settings WHERE topic=?", topic).StructScan(&item) err := a.db.QueryRowxContext(ctx, "SELECT "+groupSettingsSelectColumns+" FROM im_group_settings WHERE tenant_id=? AND topic=?", tenantID, topic).StructScan(&item)
if err == sql.ErrNoRows { if err == sql.ErrNoRows {
return nil, t.ErrNotFound return nil, t.ErrNotFound
} }
@@ -1607,7 +1615,7 @@ func (a *adapter) groupJoinRequestByQuery(ctx context.Context, query string, arg
func (a *adapter) groupInvitationByQuery(ctx context.Context, query string, args ...any) (*t.GroupInvitation, error) { func (a *adapter) groupInvitationByQuery(ctx context.Context, query string, args ...any) (*t.GroupInvitation, error) {
var item t.GroupInvitation var item t.GroupInvitation
var handledAt sql.NullTime var handledAt sql.NullTime
err := a.db.QueryRowxContext(ctx, query, args...).Scan(&item.ID, &item.InvitationID, &item.Topic, &item.InviterUserRaw, &item.TargetUserRaw, err := a.db.QueryRowxContext(ctx, query, args...).Scan(&item.TenantID, &item.ID, &item.InvitationID, &item.Topic, &item.InviterUserRaw, &item.TargetUserRaw,
&item.Message, &item.Status, &item.Result, &item.JoinRequestID, &item.IdempotencyKey, &item.ExpiresAt, &handledAt, &item.CreatedAt, &item.UpdatedAt) &item.Message, &item.Status, &item.Result, &item.JoinRequestID, &item.IdempotencyKey, &item.ExpiresAt, &handledAt, &item.CreatedAt, &item.UpdatedAt)
if err == sql.ErrNoRows { if err == sql.ErrNoRows {
return nil, t.ErrNotFound return nil, t.ErrNotFound
+19 -5
View File
@@ -28,9 +28,9 @@ func (a *adapter) TenantLoginAuditCreate(id t.TenantID, event *t.LoginAuditEvent
return err return err
} }
_, err = a.db.Exec(`INSERT INTO im_user_login_logs _, err = a.db.Exec(`INSERT INTO im_user_login_logs
(user_id,username,account_type,account_hash,account_mask,login_type,event_type,session_id,device_id,platform,app_version,ip,region,user_agent,risk_level,request_id,login_at,logout_at,metadata) (tenant_id,user_id,username,account_type,account_hash,account_mask,login_type,event_type,session_id,device_id,platform,app_version,ip,region,user_agent,risk_level,request_id,login_at,logout_at,metadata)
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
decodeUID(event.UserID), event.Username, event.AccountType, event.AccountHash, event.AccountMask, int64(id), decodeUID(event.UserID), event.Username, event.AccountType, event.AccountHash, event.AccountMask,
event.LoginType, event.EventType, event.SessionID, event.DeviceID, event.Platform, event.AppVersion, event.LoginType, event.EventType, event.SessionID, event.DeviceID, event.Platform, event.AppVersion,
event.IP, event.Region, event.UserAgent, event.RiskLevel, event.RequestID, event.LoginAt, event.LogoutAt, nullableJSON(metadata)) event.IP, event.Region, event.UserAgent, event.RiskLevel, event.RequestID, event.LoginAt, event.LogoutAt, nullableJSON(metadata))
return err return err
@@ -68,7 +68,21 @@ func (a *adapter) TenantLoginFailureCreate(id t.TenantID, event *t.LoginFailureE
if tenantAdp != a { if tenantAdp != a {
return tenantAdp.TenantLoginFailureCreate(id, event) return tenantAdp.TenantLoginFailureCreate(id, event)
} }
return nil metadata, err := json.Marshal(event.Metadata)
if err != nil {
return err
}
var userID any
if !event.UserID.IsZero() {
userID = decodeUID(event.UserID)
}
_, err = a.db.Exec(`INSERT INTO im_login_failure_logs
(tenant_id,user_id,account_type,account_hash,account_mask,failure_type,reason,detail,ip,region,device_id,platform,app_version,user_agent,risk_level,risk_action,request_id,attempt_at,metadata)
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
int64(id), userID, event.AccountType, event.AccountHash, event.AccountMask, event.FailureType, event.Reason, event.Detail,
event.IP, event.Region, event.DeviceID, event.Platform, event.AppVersion, event.UserAgent,
event.RiskLevel, event.RiskAction, event.RequestID, event.AttemptAt, nullableJSON(metadata))
return err
} }
func (a *adapter) TenantLoginAuditClose(id t.TenantID, sessionID string, at time.Time) error { func (a *adapter) TenantLoginAuditClose(id t.TenantID, sessionID string, at time.Time) error {
@@ -79,7 +93,7 @@ func (a *adapter) TenantLoginAuditClose(id t.TenantID, sessionID string, at time
if tenantAdp != a { if tenantAdp != a {
return tenantAdp.TenantLoginAuditClose(id, sessionID, at) return tenantAdp.TenantLoginAuditClose(id, sessionID, at)
} }
_, err = a.db.Exec("UPDATE im_user_login_logs SET logout_at=? WHERE session_id=? AND logout_at IS NULL", at, sessionID) _, err = a.db.Exec("UPDATE im_user_login_logs SET logout_at=? WHERE tenant_id=? AND session_id=? AND logout_at IS NULL", at, int64(id), sessionID)
return err return err
} }
@@ -0,0 +1,26 @@
//go:build mysql
package mysql
import (
"os"
"strings"
"testing"
)
func TestLoginAuditQueriesAreTenantScoped(t *testing.T) {
source, err := os.ReadFile("tenant_security_audit.go")
if err != nil {
t.Fatal(err)
}
body := string(source)
for _, required := range []string{
"(tenant_id,user_id,username",
"(tenant_id,user_id,account_type",
"WHERE tenant_id=? AND session_id=?",
} {
if !strings.Contains(body, required) {
t.Fatalf("login audit query is missing tenant scope %q", required)
}
}
}
+12 -5
View File
@@ -5,16 +5,23 @@ package mysql
import ( import (
"fmt" "fmt"
"strings" "regexp"
t "github.com/tinode/chat/server/store/types" t "github.com/tinode/chat/server/store/types"
) )
var (
tenantTopicsTableRE = regexp.MustCompile(`\bim_topics\b`)
tenantGroupMembersTableRE = regexp.MustCompile(`\bim_group_members\b`)
tenantSubscriptionsTableRE = regexp.MustCompile(`\bim_subscriptions\b`)
tenantSystemNotificationsTableRE = regexp.MustCompile(`\bim_system_notifications\b`)
)
func tenantShardSQL(tenantID t.TenantID, query string) string { func tenantShardSQL(tenantID t.TenantID, query string) string {
query = strings.ReplaceAll(query, "im_topics", tenantTopicsTable(tenantID)) query = tenantTopicsTableRE.ReplaceAllString(query, tenantTopicsTable(tenantID))
query = strings.ReplaceAll(query, "im_group_members", tenantGroupMembersTable(tenantID)) query = tenantGroupMembersTableRE.ReplaceAllString(query, tenantGroupMembersTable(tenantID))
query = strings.ReplaceAll(query, "im_subscriptions", tenantSubscriptionsTable(tenantID)) query = tenantSubscriptionsTableRE.ReplaceAllString(query, tenantSubscriptionsTable(tenantID))
return strings.ReplaceAll(query, "im_system_notifications", tenantSystemNotificationsTable(tenantID)) return tenantSystemNotificationsTableRE.ReplaceAllString(query, tenantSystemNotificationsTable(tenantID))
} }
func tenantTablePrefix(tenantID t.TenantID) string { func tenantTablePrefix(tenantID t.TenantID) string {
+63
View File
@@ -407,6 +407,69 @@ func TestTenantUserSearchMatchesUsernamePhoneAndPublicAccount(t *testing.T) {
} }
} }
func TestTenantUserSearchDoesNotLeakAcrossTenants(t *testing.T) {
businessAdp, ok := adp.(adapter.TenantBusinessAdapter)
if !ok {
t.Fatal("MySQL adapter does not implement TenantBusinessAdapter")
}
var tenantColumns int
if err := db.Get(&tenantColumns, `SELECT COUNT(*) FROM information_schema.columns
WHERE table_schema=DATABASE() AND table_name='im_users' AND column_name='tenant_id'`); err != nil {
t.Fatal(err)
}
if tenantColumns == 0 {
t.Skip("im_users.tenant_id is missing in the current test database")
}
var tenantID types.TenantID
if err := db.Get(&tenantID, "SELECT id FROM im_tenant WHERE code='default'"); err != nil {
t.Fatal(err)
}
now := types.TimeNow()
viewerID := now.UnixNano() & 0x3fffffffffffffff
targetID := viewerID + 1
otherTenantID := tenantID + 1000
otherTargetID := targetID + 1000
viewer := store.EncodeUid(viewerID)
target := store.EncodeUid(targetID)
t.Cleanup(func() {
db.Exec("DELETE FROM im_user_privacy_settings WHERE user_id IN (?,?,?)", viewerID, targetID, otherTargetID)
db.Exec("DELETE FROM im_user_profiles WHERE user_id IN (?,?,?)", viewerID, targetID, otherTargetID)
db.Exec("DELETE FROM im_users WHERE id IN (?,?,?)", viewerID, targetID, otherTargetID)
})
for _, row := range []struct {
tenantID types.TenantID
userID int64
username string
}{
{tenantID, viewerID, "viewer-scope"},
{tenantID, targetID, "shared-name"},
{otherTenantID, otherTargetID, "shared-name"},
} {
if _, err := db.Exec(
`INSERT INTO im_users(tenant_id,id,account_no,username,createdat,updatedat,state,access,phone,trusted)
VALUES(?,?,?,?,?,?,?,?,?,?)`,
row.tenantID, row.userID, row.userID%1000000, row.username, now, now, types.StateOK, `{"Auth":"JRWPAS","Anon":"N"}`, nil, `{}`); err != nil {
t.Fatal(err)
}
if _, err := db.Exec(
`INSERT INTO im_user_privacy_settings(tenant_id,id,user_id,add_me_methods,created_at,updated_at)
VALUES(?,?,?,?,?,?)`,
row.tenantID, row.userID+1000, row.userID, `{"account":true,"phone":true,"qr":true,"group":true}`, now, now); err != nil {
t.Fatal(err)
}
}
users, _, err := businessAdp.TenantUserSearch(tenantID, viewer, "shared-name", "account", "", 20)
if err != nil {
t.Fatalf("tenant-scoped search failed: %v", err)
}
if len(users) != 1 || users[0].Uid() != target {
t.Fatalf("tenant-scoped search returned %+v, want %s only", users, target.UserId())
}
}
// ================== Create tests ================================ // ================== Create tests ================================
func TestUserCreate(t *testing.T) { func TestUserCreate(t *testing.T) {
for _, user := range testData.Users { for _, user := range testData.Users {
+14 -1
View File
@@ -42,9 +42,22 @@ func TestProfilePublicFromFields(t *testing.T) {
t.Fatalf("profile public not overlaid: %#v", public) t.Fatalf("profile public not overlaid: %#v", public)
} }
public = profilePublicFromFieldsWithAvatarURL(sql.NullString{}, sql.NullString{String: "file_avatar_1", Valid: true},
sql.NullString{String: "https://cdn.example.com/avatar.png", Valid: true}, sql.NullString{}, sql.NullString{},
sql.NullString{}, sql.NullString{}).(map[string]any)
if public["avatar_file_id"] != "file_avatar_1" || public["avatar_url"] != "https://cdn.example.com/avatar.png" {
t.Fatalf("avatar url not overlaid: %#v", public)
}
public = profilePublicFromFields(sql.NullString{}, sql.NullString{String: "https://cdn.example.com/default.png", Valid: true},
sql.NullString{}, sql.NullString{}, sql.NullString{}, sql.NullString{}).(map[string]any)
if public["avatar_file_id"] != "https://cdn.example.com/default.png" || public["avatar_url"] != "https://cdn.example.com/default.png" {
t.Fatalf("absolute avatar value should be exposed as avatar_url: %#v", public)
}
public = profilePublicFromFields(sql.NullString{Valid: true}, sql.NullString{Valid: true}, sql.NullString{Valid: true}, public = profilePublicFromFields(sql.NullString{Valid: true}, sql.NullString{Valid: true}, sql.NullString{Valid: true},
sql.NullString{Valid: true}, sql.NullString{Valid: true}, sql.NullString{Valid: true}).(map[string]any) sql.NullString{Valid: true}, sql.NullString{Valid: true}, sql.NullString{Valid: true}).(map[string]any)
for _, key := range []string{"fn", "name", "nickname", "display_name", "avatar_file_id", "photo", "avatar", "signature", "gender", "region", "public_account"} { for _, key := range []string{"fn", "name", "nickname", "display_name", "avatar_file_id", "avatar_url", "photo", "avatar", "signature", "gender", "region", "public_account"} {
if _, ok := public[key]; ok { if _, ok := public[key]; ok {
t.Fatalf("legacy profile field %s should not be read from im_users.public: %#v", key, public) t.Fatalf("legacy profile field %s should not be read from im_users.public: %#v", key, public)
} }
+1 -1
View File
@@ -36,7 +36,7 @@ const (
adapterName = "rethinkdb" adapterName = "rethinkdb"
defaultHost = "localhost:28015" defaultHost = "localhost:28015"
defaultDatabase = "tinode" defaultDatabase = "meeting_im"
defaultMaxResults = 1024 defaultMaxResults = 1024
// This is capped by the Session's send queue limit (128). // This is capped by the Session's send queue limit (128).
+50 -2
View File
@@ -16,12 +16,60 @@ const (
) )
func applyDefaultUserAvatar(data map[string]any, userID, gender string) { func applyDefaultUserAvatar(data map[string]any, userID, gender string) {
if data == nil || strings.TrimSpace(stringFromAny(data["avatar_file_id"])) != "" { if data == nil {
return return
} }
if avatarURL := defaultUserAvatarURL(userID, gender); avatarURL != "" { if strings.TrimSpace(stringFromAny(data["avatar_file_id"])) == "" {
avatarURL := defaultUserAvatarURL(userID, gender)
if avatarURL == "" {
return
}
data["avatar_file_id"] = avatarURL data["avatar_file_id"] = avatarURL
} }
applyAvatarURL(data)
}
func applyAvatarURL(data map[string]any) {
if data == nil || strings.TrimSpace(stringFromAny(data["avatar_url"])) != "" {
return
}
avatar := strings.TrimSpace(stringFromAny(data["avatar_file_id"]))
if isAvatarURLValue(avatar) {
data["avatar_url"] = avatar
}
}
func publicWithDefaultUserAvatar(public any, userID string) any {
data := publicToMap(public)
gender := stringFromAny(data["gender"])
applyDefaultUserAvatar(data, userID, gender)
return data
}
func publicToMap(public any) map[string]any {
switch value := public.(type) {
case nil:
return map[string]any{}
case map[string]any:
out := make(map[string]any, len(value)+1)
for k, v := range value {
out[k] = v
}
return out
case types.KVMap:
out := make(map[string]any, len(value)+1)
for k, v := range value {
out[k] = v
}
return out
default:
return map[string]any{}
}
}
func isAvatarURLValue(value string) bool {
return strings.HasPrefix(value, "http://") || strings.HasPrefix(value, "https://") ||
strings.HasPrefix(value, "data:") || strings.HasPrefix(value, "/")
} }
func defaultUserAvatarURL(userID, gender string) string { func defaultUserAvatarURL(userID, gender string) string {
+50 -3
View File
@@ -21,8 +21,8 @@ func (s defaultAvatarConfigStore) Get(_ string, keys ...string) (map[string]stri
func TestUserSummaryPayloadAppliesDefaultAvatarByGenderAndUserID(t *testing.T) { func TestUserSummaryPayloadAppliesDefaultAvatarByGenderAndUserID(t *testing.T) {
oldConfig := store.ThirdPartyConfig oldConfig := store.ThirdPartyConfig
store.ThirdPartyConfig = defaultAvatarConfigStore{ store.ThirdPartyConfig = defaultAvatarConfigStore{
imDefaultMaleAvatarURLsConfig: `["m0","m1","m2","m3","m4"]`, imDefaultMaleAvatarURLsConfig: `["https://oss.example.com/m0.png","https://oss.example.com/m1.png","https://oss.example.com/m2.png","https://oss.example.com/m3.png","https://oss.example.com/m4.png"]`,
imDefaultFemaleAvatarURLsConfig: `["f0","f1","f2","f3","f4"]`, imDefaultFemaleAvatarURLsConfig: `["https://oss.example.com/f0.png","https://oss.example.com/f1.png","https://oss.example.com/f2.png","https://oss.example.com/f3.png","https://oss.example.com/f4.png"]`,
} }
defer func() { store.ThirdPartyConfig = oldConfig }() defer func() { store.ThirdPartyConfig = oldConfig }()
@@ -36,10 +36,13 @@ func TestUserSummaryPayloadAppliesDefaultAvatarByGenderAndUserID(t *testing.T) {
if gender == "female" { if gender == "female" {
wantPrefix = "f" wantPrefix = "f"
} }
want := fmt.Sprintf("%s%d", wantPrefix, uint64(uid)%5) want := fmt.Sprintf("https://oss.example.com/%s%d.png", wantPrefix, uint64(uid)%5)
if payload["avatar_file_id"] != want { if payload["avatar_file_id"] != want {
t.Fatalf("gender=%s avatar=%v want %s", gender, payload["avatar_file_id"], want) t.Fatalf("gender=%s avatar=%v want %s", gender, payload["avatar_file_id"], want)
} }
if payload["avatar_url"] != want {
t.Fatalf("gender=%s avatar_url=%v want %s", gender, payload["avatar_url"], want)
}
} }
} }
@@ -57,4 +60,48 @@ func TestUserSummaryPayloadKeepsUploadedAvatar(t *testing.T) {
if payload["avatar_file_id"] != "uploaded.png" { if payload["avatar_file_id"] != "uploaded.png" {
t.Fatalf("avatar=%v", payload["avatar_file_id"]) t.Fatalf("avatar=%v", payload["avatar_file_id"])
} }
if _, ok := payload["avatar_url"]; ok {
t.Fatalf("non-url uploaded avatar must not be exposed as avatar_url: %#v", payload)
}
}
func TestPublicWithDefaultUserAvatarMatchesHTTPSummary(t *testing.T) {
oldConfig := store.ThirdPartyConfig
store.ThirdPartyConfig = defaultAvatarConfigStore{
imDefaultMaleAvatarURLsConfig: `["https://oss.example.com/m0.png","https://oss.example.com/m1.png"]`,
imDefaultFemaleAvatarURLsConfig: `["https://oss.example.com/f0.png","https://oss.example.com/f1.png"]`,
}
defer func() { store.ThirdPartyConfig = oldConfig }()
uid := types.Uid(11)
public := publicWithDefaultUserAvatar(map[string]any{"display_name": "Alice", "gender": "female"}, uid.UserId()).(map[string]any)
want := "https://oss.example.com/f1.png"
if public["avatar_file_id"] != want || public["avatar_url"] != want {
t.Fatalf("default avatar not applied to protocol public: %#v", public)
}
summary := userSummaryPayload(&types.User{
ObjHeader: types.ObjHeader{Id: uid.String()},
Public: map[string]any{"display_name": "Alice", "gender": "female"},
}, "")
if summary["avatar_file_id"] != public["avatar_file_id"] || summary["avatar_url"] != public["avatar_url"] {
t.Fatalf("protocol public and HTTP summary diverged: public=%#v summary=%#v", public, summary)
}
}
func TestPublicWithDefaultUserAvatarKeepsExistingAvatarURL(t *testing.T) {
oldConfig := store.ThirdPartyConfig
store.ThirdPartyConfig = defaultAvatarConfigStore{
imDefaultMaleAvatarURLsConfig: `["https://oss.example.com/default.png"]`,
}
defer func() { store.ThirdPartyConfig = oldConfig }()
public := publicWithDefaultUserAvatar(types.KVMap{
"display_name": "Bob",
"avatar_file_id": "file_1",
"avatar_url": "https://oss.example.com/file_1.png",
}, types.Uid(20).UserId()).(map[string]any)
if public["avatar_file_id"] != "file_1" || public["avatar_url"] != "https://oss.example.com/file_1.png" {
t.Fatalf("existing avatar should be kept: %#v", public)
}
} }
+16 -2
View File
@@ -75,16 +75,30 @@ func createIMSystemNotification(tenantID types.TenantID, uid types.Uid, eventID,
} }
func publishIMRelationEvent(tenantID types.TenantID, actor, target types.Uid, stableID, eventType, title, summary string) error { func publishIMRelationEvent(tenantID types.TenantID, actor, target types.Uid, stableID, eventType, title, summary string) error {
return publishIMRelationEventWithAction(tenantID, actor, target, stableID, eventType, title, summary, nil)
}
func publishIMRelationEventWithAction(tenantID types.TenantID, actor, target types.Uid, stableID, eventType, title, summary string, action types.KVMap) error {
if target.IsZero() { if target.IsZero() {
return types.ErrMalformed return types.ErrMalformed
} }
eventID := stableIMEventID("relation", stableID, eventType, target.UserId()) eventID := stableIMEventID("relation", stableID, eventType, target.UserId())
payload := map[string]any{"user_id": actor.UserId()}
notificationAction := types.KVMap{"user_id": actor.UserId()}
for key, value := range action {
key = strings.TrimSpace(key)
if key == "" || key == "user_id" {
continue
}
payload[key] = value
notificationAction[key] = value
}
if target != actor && title != "" { if target != actor && title != "" {
if err := createIMSystemNotification(tenantID, target, stableIMEventID(eventID, "notification"), eventType, if err := createIMSystemNotification(tenantID, target, stableIMEventID(eventID, "notification"), eventType,
title, summary, types.KVMap{"user_id": actor.UserId()}); err != nil { title, summary, notificationAction); err != nil {
return err return err
} }
} }
return enqueueIMBusinessEvent(tenantID, eventID, "user", target.UserId(), actor, eventType, return enqueueIMBusinessEvent(tenantID, eventID, "user", target.UserId(), actor, eventType,
map[string]any{"user_id": actor.UserId()}) payload)
} }
+57
View File
@@ -147,3 +147,60 @@ func TestSendPushForStoredGroupMessageIncludesEverySubscription(t *testing.T) {
t.Fatal("group system message did not enqueue push") t.Fatal("group system message did not enqueue push")
} }
} }
func TestSendPushForHTTPForwardMessageIncrementsRecipientUnreadOnly(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
oldTopics := store.Topics
oldUsersUpdate := globals.usersUpdate
oldCluster := globals.cluster
oldSessions := globals.sessionStore
topics := mock_store.NewMockTopicsPersistenceInterface(ctrl)
store.Topics = topics
globals.usersUpdate = make(chan *UserCacheReq, 1)
globals.cluster = nil
globals.sessionStore = NewSessionStore(time.Minute)
t.Cleanup(func() {
store.Topics = oldTopics
globals.usersUpdate = oldUsersUpdate
globals.cluster = oldCluster
globals.sessionStore = oldSessions
})
sender := types.Uid(42)
receiver := types.Uid(99)
topic := sender.P2PName(receiver)
topics.EXPECT().GetSubs(types.TenantID(7), topic, nil).Return([]types.Subscription{
{User: sender.String(), Topic: topic, ModeWant: types.ModeCFull, ModeGiven: types.ModeCFull},
{User: receiver.String(), Topic: topic, ModeWant: types.ModeCFull, ModeGiven: types.ModeCFull},
}, nil)
msg := &types.Message{
ObjHeader: types.ObjHeader{CreatedAt: time.Now().UTC()},
TenantID: 7,
Topic: topic,
SeqId: 12,
From: sender.String(),
Head: types.KVMap{"x-im-type": "text"},
Content: map[string]any{"text": "forwarded"},
}
sendPushForHTTPForwardMessage(7, msg, true, msg.CreatedAt)
select {
case req := <-globals.usersUpdate:
if req.PushRcpt == nil || len(req.PushRcpt.To) != 2 {
t.Fatalf("push recipients: %#v", req)
}
if req.PushRcpt.To[sender].ShouldIncrementUnreadCountInCache {
t.Fatal("sender should not get unread increment for a sender-read forward")
}
if !req.PushRcpt.To[receiver].ShouldIncrementUnreadCountInCache {
t.Fatal("receiver should get unread increment for forwarded message")
}
if req.PushRcpt.Payload.Topic != topic || req.PushRcpt.Payload.SeqId != 12 {
t.Fatalf("unexpected push payload: %#v", req.PushRcpt.Payload)
}
default:
t.Fatal("http forward message did not enqueue push")
}
}
+280 -41
View File
@@ -26,6 +26,7 @@ import (
"github.com/tinode/chat/server/logs" "github.com/tinode/chat/server/logs"
"github.com/tinode/chat/server/media" "github.com/tinode/chat/server/media"
"github.com/tinode/chat/server/moderation" "github.com/tinode/chat/server/moderation"
"github.com/tinode/chat/server/push"
realnamealiyun "github.com/tinode/chat/server/realname/aliyun" realnamealiyun "github.com/tinode/chat/server/realname/aliyun"
"github.com/tinode/chat/server/store" "github.com/tinode/chat/server/store"
"github.com/tinode/chat/server/store/types" "github.com/tinode/chat/server/store/types"
@@ -447,6 +448,10 @@ type imDevicePushTokenRequest struct {
NotificationPermission string `json:"notification_permission,omitempty"` NotificationPermission string `json:"notification_permission,omitempty"`
} }
type imAuthLogoutRequest struct {
DeviceID string `json:"device_id,omitempty"`
}
type imP2PConversationRequest struct { type imP2PConversationRequest struct {
PeerUserID string `json:"peer_user_id"` PeerUserID string `json:"peer_user_id"`
} }
@@ -2751,12 +2756,20 @@ func serveIMFavoriteForward(wrt http.ResponseWriter, req *http.Request) {
continue continue
} }
if saved != nil && saved.Pending { if saved != nil && saved.Pending {
msg.SeqId = saved.SeqId
if !saved.Duplicate {
broadcastHTTPForwardMessage(principal.TenantID, msg, saved.MarkedReadBySender)
}
result := map[string]any{"target_topic": target, "favorite_id": favoriteID, "status": "pending", "seq": saved.SeqId, "reason": "MESSAGE_PERSIST_PENDING"} result := map[string]any{"target_topic": target, "favorite_id": favoriteID, "status": "pending", "seq": saved.SeqId, "reason": "MESSAGE_PERSIST_PENDING"}
result["client_mid"] = clientMid result["client_mid"] = clientMid
mergeForwardCommentResult(result, commentResult) mergeForwardCommentResult(result, commentResult)
results = append(results, result) results = append(results, result)
continue continue
} }
msg.SeqId = saved.SeqId
if !saved.Duplicate {
broadcastHTTPForwardMessage(principal.TenantID, msg, saved.MarkedReadBySender)
}
result := map[string]any{"target_topic": target, "favorite_id": favoriteID, "status": "sent", "seq": saved.SeqId, "client_mid": clientMid} result := map[string]any{"target_topic": target, "favorite_id": favoriteID, "status": "sent", "seq": saved.SeqId, "client_mid": clientMid}
mergeForwardCommentResult(result, commentResult) mergeForwardCommentResult(result, commentResult)
results = append(results, result) results = append(results, result)
@@ -2876,6 +2889,10 @@ func serveIMMessageForward(wrt http.ResponseWriter, req *http.Request) {
continue continue
} }
if saved != nil && saved.Pending { if saved != nil && saved.Pending {
forwarded.SeqId = saved.SeqId
if !saved.Duplicate {
broadcastHTTPForwardMessage(principal.TenantID, forwarded, saved.MarkedReadBySender)
}
item := map[string]any{ item := map[string]any{
"sources": bundle["source_refs"], "sources": bundle["source_refs"],
"target_topic": target, "target_topic": target,
@@ -2891,7 +2908,9 @@ func serveIMMessageForward(wrt http.ResponseWriter, req *http.Request) {
continue continue
} }
forwarded.SeqId = saved.SeqId forwarded.SeqId = saved.SeqId
broadcastHTTPForwardMessage(principal.TenantID, forwarded) if !saved.Duplicate {
broadcastHTTPForwardMessage(principal.TenantID, forwarded, saved.MarkedReadBySender)
}
item := map[string]any{ item := map[string]any{
"sources": bundle["source_refs"], "sources": bundle["source_refs"],
"target_topic": target, "target_topic": target,
@@ -2969,6 +2988,10 @@ func serveIMMessageForward(wrt http.ResponseWriter, req *http.Request) {
continue continue
} }
if saved != nil && saved.Pending { if saved != nil && saved.Pending {
forwarded.SeqId = saved.SeqId
if !saved.Duplicate {
broadcastHTTPForwardMessage(principal.TenantID, forwarded, saved.MarkedReadBySender)
}
item := map[string]any{ item := map[string]any{
"source": map[string]any{"topic": sourceTopic, "seq": int64(source.Seq)}, "source": map[string]any{"topic": sourceTopic, "seq": int64(source.Seq)},
"target_topic": target, "target_topic": target,
@@ -2982,7 +3005,9 @@ func serveIMMessageForward(wrt http.ResponseWriter, req *http.Request) {
continue continue
} }
forwarded.SeqId = saved.SeqId forwarded.SeqId = saved.SeqId
broadcastHTTPForwardMessage(principal.TenantID, forwarded) if !saved.Duplicate {
broadcastHTTPForwardMessage(principal.TenantID, forwarded, saved.MarkedReadBySender)
}
item := map[string]any{ item := map[string]any{
"source": map[string]any{"topic": sourceTopic, "seq": int64(source.Seq)}, "source": map[string]any{"topic": sourceTopic, "seq": int64(source.Seq)},
"target_topic": target, "target_topic": target,
@@ -3407,16 +3432,17 @@ func serveIMMessageRecall(wrt http.ResponseWriter, req *http.Request) {
eventID := stableIMEventID("message", topic, seq, updated.Version, "recalled") eventID := stableIMEventID("message", topic, seq, updated.Version, "recalled")
if err = enqueueIMBusinessEvent(principal.TenantID, eventID, "topic", topic, principal.Uid, if err = enqueueIMBusinessEvent(principal.TenantID, eventID, "topic", topic, principal.Uid,
"message.recalled", map[string]any{"topic": topic, "seq": seq, "version": updated.Version}); err != nil { "message.recalled", map[string]any{"topic": topic, "seq": seq, "version": updated.Version}); err != nil {
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil) logs.Warn.Printf("im message recall: failed to enqueue business event tenant=%d topic=%s seq=%d version=%d: %v", principal.TenantID, topic, seq, updated.Version, err)
return } else if err = enqueueIMP2PMessageMutationEvents(principal.TenantID, topic, principal.Uid, seq, updated.Version, "message.recalled"); err != nil {
} logs.Warn.Printf("im message recall: failed to enqueue p2p mutation events tenant=%d topic=%s seq=%d version=%d: %v", principal.TenantID, topic, seq, updated.Version, err)
if err = enqueueIMP2PMessageMutationEvents(principal.TenantID, topic, principal.Uid, seq, updated.Version, "message.recalled"); err != nil {
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return
} }
} }
broadcastIMMessageRecalled(principal.TenantID, updated.Topic, []int64{seq}, principal.Uid) broadcastIMMessageRecalled(principal.TenantID, updated.Topic, []int64{seq}, principal.Uid)
writeIMSuccess(wrt, req, messageMutationPayload(updated, "recalled")) payload := messageMutationPayload(updated, "recalled")
if err != nil {
payload["event_status"] = "enqueue_failed"
}
writeIMSuccess(wrt, req, payload)
} }
func serveIMMessageBatchRecall(wrt http.ResponseWriter, req *http.Request) { func serveIMMessageBatchRecall(wrt http.ResponseWriter, req *http.Request) {
@@ -3554,15 +3580,16 @@ func serveIMMessageRevision(wrt http.ResponseWriter, req *http.Request) {
eventID := stableIMEventID("message", topic, seq, updated.Version, "edited") eventID := stableIMEventID("message", topic, seq, updated.Version, "edited")
if err = enqueueIMBusinessEvent(principal.TenantID, eventID, "topic", topic, principal.Uid, if err = enqueueIMBusinessEvent(principal.TenantID, eventID, "topic", topic, principal.Uid,
"message.edited", map[string]any{"topic": topic, "seq": seq, "version": updated.Version}); err != nil { "message.edited", map[string]any{"topic": topic, "seq": seq, "version": updated.Version}); err != nil {
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil) logs.Warn.Printf("im message edit: failed to enqueue business event tenant=%d topic=%s seq=%d version=%d: %v", principal.TenantID, topic, seq, updated.Version, err)
return } else if err = enqueueIMP2PMessageMutationEvents(principal.TenantID, topic, principal.Uid, seq, updated.Version, "message.edited"); err != nil {
} logs.Warn.Printf("im message edit: failed to enqueue p2p mutation events tenant=%d topic=%s seq=%d version=%d: %v", principal.TenantID, topic, seq, updated.Version, err)
if err = enqueueIMP2PMessageMutationEvents(principal.TenantID, topic, principal.Uid, seq, updated.Version, "message.edited"); err != nil {
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return
} }
} }
writeIMSuccess(wrt, req, messageMutationPayload(updated, "edited")) payload := messageMutationPayload(updated, "edited")
if err != nil {
payload["event_status"] = "enqueue_failed"
}
writeIMSuccess(wrt, req, payload)
} }
func serveIMConversationContext(wrt http.ResponseWriter, req *http.Request) { func serveIMConversationContext(wrt http.ResponseWriter, req *http.Request) {
@@ -3801,7 +3828,7 @@ func serveIMFriendRequestCreate(wrt http.ResponseWriter, req *http.Request) {
source := strings.TrimSpace(body.Source) source := strings.TrimSpace(body.Source)
sourceTopic := strings.TrimSpace(body.SourceTopic) sourceTopic := strings.TrimSpace(body.SourceTopic)
switch source { switch source {
case "search", "qr": case "search", "qr", "profile_card":
if sourceTopic != "" { if sourceTopic != "" {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "source_topic is only valid for group source", nil) writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "source_topic is only valid for group source", nil)
return return
@@ -3889,19 +3916,22 @@ func serveIMFriendRequestCreate(wrt http.ResponseWriter, req *http.Request) {
eventType := "friend.request.created" eventType := "friend.request.created"
title := "新的好友申请" title := "新的好友申请"
content := message content := message
action := types.KVMap{}
if created != nil && created.Status == types.FriendRequestAccepted { if created != nil && created.Status == types.FriendRequestAccepted {
eventType = "friend.request.accepted" eventType = "friend.request.accepted"
title = "已成为好友" title = "已成为好友"
content = "你们已成为好友,现在可以开始聊天了。" content = "你们已成为好友,现在可以开始聊天了。"
publishIMConversationSystemNotice(principal.TenantID, principal.Uid.P2PName(target), topic := principal.Uid.P2PName(target)
action["topic"] = topic
publishIMConversationSystemNotice(principal.TenantID, topic,
"system_notice", "friend_added", content, "system_notice", "friend_added", content,
[]imSystemMessageActor{ []imSystemMessageActor{
{UserID: principal.Uid.UserId(), Click: map[string]any{"type": "user_profile", "target_id": principal.Uid.UserId()}}, {UserID: principal.Uid.UserId(), Click: map[string]any{"type": "user_profile", "target_id": principal.Uid.UserId()}},
{UserID: target.UserId(), Click: map[string]any{"type": "user_profile", "target_id": target.UserId()}}, {UserID: target.UserId(), Click: map[string]any{"type": "user_profile", "target_id": target.UserId()}},
}, nil, map[string]any{"request_id": created.RequestID}) }, nil, map[string]any{"request_id": created.RequestID})
} }
if err = publishIMRelationEvent(principal.TenantID, principal.Uid, target, created.RequestID, if err = publishIMRelationEventWithAction(principal.TenantID, principal.Uid, target, created.RequestID,
eventType, title, content); err != nil { eventType, title, content, action); err != nil {
logs.Warn.Printf("im friend request create: relation event failed request_id=%s friend_request_id=%s target=%s: %v", logs.Warn.Printf("im friend request create: relation event failed request_id=%s friend_request_id=%s target=%s: %v",
req.Header.Get("X-Request-ID"), created.RequestID, target.UserId(), err) req.Header.Get("X-Request-ID"), created.RequestID, target.UserId(), err)
} }
@@ -4252,6 +4282,8 @@ func serveIMGroupMembers(wrt http.ResponseWriter, req *http.Request, topic strin
Mentionable: mentionableOnly, Mentionable: mentionableOnly,
}) })
if err != nil { if err != nil {
logs.Warn.Printf("im http: list group members failed tenant=%d topic=%s uid=%s limit=%d cursor=%q query=%q role=%q mentionable=%t err=%v",
principal.TenantID, topic, principal.Uid.UserId(), limit, cursor, query, roleFilter, mentionableOnly, err)
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil) writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return return
} }
@@ -4356,10 +4388,28 @@ func serveIMGroupMember(wrt http.ResponseWriter, req *http.Request, topic string
} }
func serveIMGroupDetail(wrt http.ResponseWriter, req *http.Request, topic string) { func serveIMGroupDetail(wrt http.ResponseWriter, req *http.Request, topic string) {
principal, selfSub, ok := requireIMGroupMember(wrt, req, topic, true) principal, ok := authenticateIMHTTPBearer(wrt, req)
if !ok { if !ok {
return return
} }
if types.GetTopicCat(topic) != types.TopicCatGrp {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "topic"})
return
}
if req.Method == http.MethodGet {
if writeIMGroupDissolvedIfGone(wrt, req, principal.TenantID, topic) {
return
}
}
selfSub, err := store.Subs.Get(principal.TenantID, topic, principal.Uid, false)
if err != nil {
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return
}
if selfSub == nil || !(selfSub.ModeGiven & selfSub.ModeWant).IsJoiner() || !(selfSub.ModeGiven & selfSub.ModeWant).IsReader() {
writeIMError(wrt, req, http.StatusForbidden, 30005, imReasonPermissionDenied, "topic permission required", nil)
return
}
switch req.Method { switch req.Method {
case http.MethodGet: case http.MethodGet:
group, err := store.Topics.Get(principal.TenantID, topic) group, err := store.Topics.Get(principal.TenantID, topic)
@@ -4506,6 +4556,19 @@ func serveIMGroupDetail(wrt http.ResponseWriter, req *http.Request, topic string
} }
} }
func writeIMGroupDissolvedIfGone(wrt http.ResponseWriter, req *http.Request, tenantID types.TenantID, topic string) bool {
if settings, err := store.GroupBusiness.GetSettings(tenantID, topic); err == nil && settings != nil && settings.GroupStatus == types.GroupStatusDissolved {
writeIMError(wrt, req, http.StatusGone, 30004, imReasonGroupDissolved, "group dissolved", map[string]any{"topic": topic})
return true
}
group, err := store.Topics.Get(tenantID, topic)
if err != nil || group == nil || group.State != types.StateDeleted {
return false
}
writeIMError(wrt, req, http.StatusGone, 30004, imReasonGroupDissolved, "group dissolved", map[string]any{"topic": topic})
return true
}
func serveIMGroupSettings(wrt http.ResponseWriter, req *http.Request, topic string) { func serveIMGroupSettings(wrt http.ResponseWriter, req *http.Request, topic string) {
if req.Method != http.MethodGet { if req.Method != http.MethodGet {
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil) writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
@@ -5335,7 +5398,9 @@ func publishGroupAnnouncementTextMessage(ctx context.Context, tenantID types.Ten
if saved != nil && saved.SeqId > 0 { if saved != nil && saved.SeqId > 0 {
msg.SeqId = saved.SeqId msg.SeqId = saved.SeqId
} }
broadcastHTTPForwardMessage(tenantID, msg) if saved == nil || !saved.Duplicate {
broadcastHTTPForwardMessage(tenantID, msg, saved == nil || saved.MarkedReadBySender)
}
return nil return nil
} }
@@ -5987,6 +6052,10 @@ func serveIMUserProfile(wrt http.ResponseWriter, req *http.Request, identifier s
writeIMSuccess(wrt, req, blockedUserProfilePayload(user, ctx)) writeIMSuccess(wrt, req, blockedUserProfilePayload(user, ctx))
return return
} }
if ctx.Relationship == types.RelationshipNone {
writeIMSuccess(wrt, req, limitedUserProfilePayload(user, ctx, privacy))
return
}
writeIMError(wrt, req, http.StatusForbidden, 30005, imReasonPermissionDenied, "profile is private", nil) writeIMError(wrt, req, http.StatusForbidden, 30005, imReasonPermissionDenied, "profile is private", nil)
return return
} }
@@ -6144,7 +6213,8 @@ func serveIMFriendRequestAction(wrt http.ResponseWriter, req *http.Request) {
return return
} }
if status == types.FriendRequestAccepted && imReliableHTTPEventsEnabled(req) { if status == types.FriendRequestAccepted && imReliableHTTPEventsEnabled(req) {
publishIMConversationSystemNotice(principal.TenantID, updated.ApplicantID.P2PName(updated.TargetID), topic := updated.ApplicantID.P2PName(updated.TargetID)
publishIMConversationSystemNotice(principal.TenantID, topic,
"system_notice", "friend_added", "你们已成为好友,现在可以开始聊天了。", "system_notice", "friend_added", "你们已成为好友,现在可以开始聊天了。",
[]imSystemMessageActor{ []imSystemMessageActor{
{UserID: updated.ApplicantID.UserId(), Click: map[string]any{"type": "user_profile", "target_id": updated.ApplicantID.UserId()}}, {UserID: updated.ApplicantID.UserId(), Click: map[string]any{"type": "user_profile", "target_id": updated.ApplicantID.UserId()}},
@@ -6153,13 +6223,17 @@ func serveIMFriendRequestAction(wrt http.ResponseWriter, req *http.Request) {
} }
if imReliableHTTPEventsEnabled(req) { if imReliableHTTPEventsEnabled(req) {
eventType := "friend.request." + status eventType := "friend.request." + status
if err = publishIMRelationEvent(principal.TenantID, principal.Uid, updated.ApplicantID, action := types.KVMap{}
updated.RequestID+":"+status, eventType, "好友申请已处理", status); err != nil { if status == types.FriendRequestAccepted {
action["topic"] = updated.ApplicantID.P2PName(updated.TargetID)
}
if err = publishIMRelationEventWithAction(principal.TenantID, principal.Uid, updated.ApplicantID,
updated.RequestID+":"+status, eventType, "好友申请已处理", status, action); err != nil {
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil) writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return return
} }
if err = publishIMRelationEvent(principal.TenantID, principal.Uid, principal.Uid, if err = publishIMRelationEventWithAction(principal.TenantID, principal.Uid, principal.Uid,
updated.RequestID+":"+status+":self", eventType, "", ""); err != nil { updated.RequestID+":"+status+":self", eventType, "", "", action); err != nil {
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil) writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return return
} }
@@ -6188,8 +6262,9 @@ func serveIMBlockUser(wrt http.ResponseWriter, req *http.Request) {
return return
} }
if imReliableHTTPEventsEnabled(req) { if imReliableHTTPEventsEnabled(req) {
if err := publishIMRelationEvent(principal.TenantID, principal.Uid, principal.Uid, if err := publishIMRelationEventWithAction(principal.TenantID, principal.Uid, principal.Uid,
stableIMEventID(req.Header.Get("Idempotency-Key"), peer.UserId()), "relationship.blocked", "", ""); err != nil { stableIMEventID(req.Header.Get("Idempotency-Key"), peer.UserId()), "relationship.blocked", "", "",
types.KVMap{"topic": principal.Uid.P2PName(peer)}); err != nil {
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil) writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return return
} }
@@ -6200,8 +6275,9 @@ func serveIMBlockUser(wrt http.ResponseWriter, req *http.Request) {
return return
} }
if imReliableHTTPEventsEnabled(req) { if imReliableHTTPEventsEnabled(req) {
if err := publishIMRelationEvent(principal.TenantID, principal.Uid, principal.Uid, if err := publishIMRelationEventWithAction(principal.TenantID, principal.Uid, principal.Uid,
stableIMEventID(req.Header.Get("Idempotency-Key"), peer.UserId()), "relationship.unblocked", "", ""); err != nil { stableIMEventID(req.Header.Get("Idempotency-Key"), peer.UserId()), "relationship.unblocked", "", "",
types.KVMap{"topic": principal.Uid.P2PName(peer)}); err != nil {
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil) writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return return
} }
@@ -6298,13 +6374,14 @@ func serveIMFriendResource(wrt http.ResponseWriter, req *http.Request) {
} }
if imReliableHTTPEventsEnabled(req) { if imReliableHTTPEventsEnabled(req) {
stableID := stableIMEventID(req.Header.Get("Idempotency-Key"), principal.Uid.UserId(), peer.UserId()) stableID := stableIMEventID(req.Header.Get("Idempotency-Key"), principal.Uid.UserId(), peer.UserId())
if err := publishIMRelationEvent(principal.TenantID, principal.Uid, peer, stableID, action := types.KVMap{"topic": principal.Uid.P2PName(peer)}
"friend.deleted", "好友关系已解除", ""); err != nil { if err := publishIMRelationEventWithAction(principal.TenantID, principal.Uid, peer, stableID,
"friend.deleted", "好友关系已解除", "", action); err != nil {
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil) writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return return
} }
if err := publishIMRelationEvent(principal.TenantID, principal.Uid, principal.Uid, stableID+":self", if err := publishIMRelationEventWithAction(principal.TenantID, principal.Uid, principal.Uid, stableID+":self",
"friend.deleted", "", ""); err != nil { "friend.deleted", "", "", action); err != nil {
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil) writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return return
} }
@@ -6319,8 +6396,9 @@ func serveIMFriendResource(wrt http.ResponseWriter, req *http.Request) {
return return
} }
if imReliableHTTPEventsEnabled(req) { if imReliableHTTPEventsEnabled(req) {
if err := publishIMRelationEvent(principal.TenantID, principal.Uid, principal.Uid, if err := publishIMRelationEventWithAction(principal.TenantID, principal.Uid, principal.Uid,
stableIMEventID(req.Header.Get("Idempotency-Key"), peer.UserId()), "friend.remark.updated", "", ""); err != nil { stableIMEventID(req.Header.Get("Idempotency-Key"), peer.UserId()), "friend.remark.updated", "", "",
types.KVMap{"topic": principal.Uid.P2PName(peer)}); err != nil {
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil) writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return return
} }
@@ -6560,6 +6638,10 @@ func serveIMAuthLogout(wrt http.ResponseWriter, req *http.Request) {
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30000, imReasonMethodNotAllowed, "method not allowed", nil) writeIMError(wrt, req, http.StatusMethodNotAllowed, 30000, imReasonMethodNotAllowed, "method not allowed", nil)
return return
} }
deviceID, ok := imLogoutDeviceID(wrt, req)
if !ok {
return
}
principal, token, ttl, err := resolveIMHTTPBearerToken(req) principal, token, ttl, err := resolveIMHTTPBearerToken(req)
if err != nil { if err != nil {
writeIMError(wrt, req, http.StatusUnauthorized, 30043, imReasonAuthRequired, "authentication required", nil) writeIMError(wrt, req, http.StatusUnauthorized, 30043, imReasonAuthRequired, "authentication required", nil)
@@ -6574,7 +6656,50 @@ func serveIMAuthLogout(wrt http.ResponseWriter, req *http.Request) {
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil) writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return return
} }
writeIMSuccess(wrt, req, map[string]any{"status": "ok"}) pushUnbound := false
if deviceID != "" {
if deleter, ok := store.DevicePushTokens.(interface {
Delete(types.TenantID, types.Uid, string) error
}); ok {
if err = deleter.Delete(principal.TenantID, principal.Uid, deviceID); err != nil && !errors.Is(err, types.ErrNotFound) {
logs.Warn.Println("im logout: push token unbind failed", principal.TenantID, principal.Uid, deviceID, err)
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return
}
pushUnbound = err == nil
}
if store.Devices != nil {
if err = store.Devices.Delete(principal.TenantID, principal.Uid, deviceID); err != nil && !errors.Is(err, types.ErrNotFound) {
logs.Warn.Println("im logout: legacy device unbind failed", principal.TenantID, principal.Uid, deviceID, err)
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return
}
}
}
writeIMSuccess(wrt, req, map[string]any{"status": "ok", "device_id": deviceID, "push_unbound": pushUnbound})
}
func imLogoutDeviceID(wrt http.ResponseWriter, req *http.Request) (string, bool) {
deviceID := strings.TrimSpace(req.Header.Get("X-Device-ID"))
if deviceID == "" {
deviceID = strings.TrimSpace(req.Header.Get("X-IM-Device-ID"))
}
if req.Body != nil && req.Body != http.NoBody {
var body imAuthLogoutRequest
dec := json.NewDecoder(req.Body)
if err := dec.Decode(&body); err != nil && !errors.Is(err, io.EOF) {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "body"})
return "", false
}
if strings.TrimSpace(body.DeviceID) != "" {
deviceID = strings.TrimSpace(body.DeviceID)
}
}
if strings.Contains(deviceID, "/") {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "device_id"})
return "", false
}
return deviceID, true
} }
func currentUserPhone(tenantID types.TenantID, uid types.Uid) (string, error) { func currentUserPhone(tenantID types.TenantID, uid types.Uid) (string, error) {
@@ -8933,6 +9058,39 @@ func blockedUserProfilePayload(user *types.User, ctx *types.RelationshipContext)
return data return data
} }
func limitedUserProfilePayload(user *types.User, ctx *types.RelationshipContext, privacy *types.UserPrivacySettings) map[string]any {
data := userSummaryPayload(user, "")
data["username"] = user.Username
if original := userOriginalDisplayName(user); original != "" {
data["profile_display_name"] = original
data["original_display_name"] = original
}
data["relationship"] = ctx.Relationship
data["topic"] = ctx.Topic
data["capabilities"] = map[string]any{
"can_send": false,
"can_call": false,
"can_add_friend": ctx.Relationship == types.RelationshipNone && imPrivacyAllowsAdd(privacy, "profile_card"),
}
if ctx.RequestID != "" {
data["request_id"] = ctx.RequestID
}
if public, ok := user.Public.(map[string]any); ok {
copyLimitedProfileFields(data, public)
} else if public, ok := user.Public.(types.KVMap); ok {
copyLimitedProfileFields(data, map[string]any(public))
}
return data
}
func copyLimitedProfileFields(data map[string]any, public map[string]any) {
for _, key := range []string{"nickname", "public_account"} {
if value, ok := public[key]; ok {
data[key] = value
}
}
}
func copyOptionalProfileFields(data map[string]any, public map[string]any) { func copyOptionalProfileFields(data map[string]any, public map[string]any) {
for _, key := range []string{"nickname", "gender", "region", "public_account", "signature"} { for _, key := range []string{"nickname", "gender", "region", "public_account", "signature"} {
if value, ok := public[key]; ok { if value, ok := public[key]; ok {
@@ -8997,6 +9155,9 @@ func copyUserPublicSummary(data map[string]any, public map[string]any) {
break break
} }
} }
if value, ok := public["avatar_url"]; ok && strings.TrimSpace(fmt.Sprint(value)) != "" {
data["avatar_url"] = value
}
} }
func displayInitial(value any) string { func displayInitial(value any) string {
@@ -9390,6 +9551,10 @@ func saveForwardCommentMessage(ctx context.Context, principal *imPrincipal, targ
return result return result
} }
if saved != nil && saved.Pending { if saved != nil && saved.Pending {
msg.SeqId = saved.SeqId
if !saved.Duplicate {
broadcastHTTPForwardMessage(principal.TenantID, msg, saved.MarkedReadBySender)
}
result["comment_status"] = "pending" result["comment_status"] = "pending"
result["comment_seq"] = saved.SeqId result["comment_seq"] = saved.SeqId
result["comment_reason"] = "MESSAGE_PERSIST_PENDING" result["comment_reason"] = "MESSAGE_PERSIST_PENDING"
@@ -9397,7 +9562,9 @@ func saveForwardCommentMessage(ctx context.Context, principal *imPrincipal, targ
} }
if saved != nil { if saved != nil {
msg.SeqId = saved.SeqId msg.SeqId = saved.SeqId
broadcastHTTPForwardMessage(principal.TenantID, msg) if !saved.Duplicate {
broadcastHTTPForwardMessage(principal.TenantID, msg, saved.MarkedReadBySender)
}
result["comment_status"] = "sent" result["comment_status"] = "sent"
result["comment_seq"] = saved.SeqId result["comment_seq"] = saved.SeqId
return result return result
@@ -9700,7 +9867,7 @@ func prepareHTTPForwardMessage(tenantID types.TenantID, msg *types.Message) erro
return nil return nil
} }
func broadcastHTTPForwardMessage(tenantID types.TenantID, msg *types.Message) { func broadcastHTTPForwardMessage(tenantID types.TenantID, msg *types.Message, markedReadBySender bool) {
if globals.hub == nil || msg == nil || msg.SeqId <= 0 || msg.Topic == "" { if globals.hub == nil || msg == nil || msg.SeqId <= 0 || msg.Topic == "" {
return return
} }
@@ -9727,6 +9894,7 @@ func broadcastHTTPForwardMessage(tenantID types.TenantID, msg *types.Message) {
logs.Warn.Printf("im message forward: hub route queue full topic=%s seq=%d", msg.Topic, msg.SeqId) logs.Warn.Printf("im message forward: hub route queue full topic=%s seq=%d", msg.Topic, msg.SeqId)
} }
broadcastHTTPForwardPresence(tenantID, msg, timestamp) broadcastHTTPForwardPresence(tenantID, msg, timestamp)
sendPushForHTTPForwardMessage(tenantID, msg, markedReadBySender, timestamp)
} }
func broadcastHTTPForwardPresence(tenantID types.TenantID, msg *types.Message, timestamp time.Time) { func broadcastHTTPForwardPresence(tenantID types.TenantID, msg *types.Message, timestamp time.Time) {
@@ -9769,6 +9937,71 @@ func broadcastHTTPForwardPresence(tenantID types.TenantID, msg *types.Message, t
} }
} }
func sendPushForHTTPForwardMessage(tenantID types.TenantID, msg *types.Message, markedReadBySender bool, timestamp time.Time) {
if msg == nil || msg.SeqId <= 0 || msg.Topic == "" || store.Topics == nil {
return
}
subs, err := store.Topics.GetSubs(tenantID, msg.Topic, nil)
if err != nil {
logs.Warn.Printf("im message forward: load recipients topic=%s seq=%d: %v", msg.Topic, msg.SeqId, err)
return
}
contentType, _ := msg.Head["mime"].(string)
receipt := &push.Receipt{
TenantID: tenantID,
To: make(map[types.Uid]push.Recipient, len(subs)),
Payload: push.Payload{
What: push.ActMsg,
Silent: false,
Topic: msg.Topic,
From: types.ParseUid(msg.From).UserId(),
Timestamp: timestamp,
SeqId: msg.SeqId,
ContentType: contentType,
Content: msg.Content,
},
}
fromUid := types.ParseUid(msg.From)
for _, sub := range subs {
uid := types.ParseUid(sub.User)
mode := sub.ModeWant & sub.ModeGiven
if uid.IsZero() || !mode.IsPresencer() || !mode.IsReader() {
continue
}
delivered := httpForwardDeliveredCount(tenantID, uid)
if uid == fromUid && delivered == 0 {
delivered = 1
}
recipient := push.Recipient{
Delivered: delivered,
Silent: types.GetTopicCat(msg.Topic) == types.TopicCatGrp && boolFromAny(groupPrivateMap(sub.Private)["mute_notifications"]),
ShouldIncrementUnreadCountInCache: uid != fromUid || !markedReadBySender,
}
receipt.To[uid] = recipient
}
if len(receipt.To) > 0 {
sendPush(receipt)
}
}
func httpForwardDeliveredCount(tenantID types.TenantID, uid types.Uid) int {
delivered := 0
if globals.sessionStore != nil {
globals.sessionStore.Range(func(_ string, sess *Session) bool {
if sess != nil && !sess.isCluster() && !sess.isProxy() && sess.tenantID == tenantID && sess.uid == uid {
delivered++
}
return true
})
}
if presence, err := store.GetUserPresence(tenantID, uid); err != nil {
logs.Warn.Printf("im message forward: read presence uid=%s: %v", uid.UserId(), err)
} else if presence != nil && presence.Online && presence.SessionCount > delivered {
delivered = presence.SessionCount
}
return delivered
}
func forwardedMessageHead(msg *types.Message, clientMid string) types.KVMap { func forwardedMessageHead(msg *types.Message, clientMid string) types.KVMap {
head := types.KVMap{} head := types.KVMap{}
if msg != nil { if msg != nil {
@@ -10508,11 +10741,17 @@ func publishIMConversationSystemNotice(tenantID types.TenantID, topic, msgType,
}, },
Content: content, Content: content,
} }
_, err = store.Messages.Save(tenantID, msg, nil, false) saved, err := store.Messages.Save(tenantID, msg, nil, false)
if err != nil && !errors.Is(err, types.ErrDuplicate) { if err != nil && !errors.Is(err, types.ErrDuplicate) {
logs.Warn.Printf("im system message: failed to save %s for topic %s: %v", template, topic, err) logs.Warn.Printf("im system message: failed to save %s for topic %s: %v", template, topic, err)
return return
} }
if saved != nil && saved.SeqId > 0 {
msg.SeqId = saved.SeqId
}
if saved != nil && saved.Duplicate {
return
}
broadcastIMConversationSystemMessage(tenantID, msg, now) broadcastIMConversationSystemMessage(tenantID, msg, now)
sendPushForStoredGroupMessage(tenantID, msg) sendPushForStoredGroupMessage(tenantID, msg)
} }
+223 -4
View File
@@ -235,8 +235,11 @@ type testAppReleaseStore struct {
} }
type testDevicePushTokenStore struct { type testDevicePushTokenStore struct {
token *types.DevicePushToken token *types.DevicePushToken
tenantID types.TenantID tenantID types.TenantID
deletedTenantID types.TenantID
deletedUserID types.Uid
deletedDeviceID string
} }
type testSafetyStore struct { type testSafetyStore struct {
@@ -752,6 +755,13 @@ func (s *testDevicePushTokenStore) Upsert(tenantID types.TenantID, token *types.
return token, nil return token, nil
} }
func (s *testDevicePushTokenStore) Delete(tenantID types.TenantID, uid types.Uid, deviceID string) error {
s.deletedTenantID = tenantID
s.deletedUserID = uid
s.deletedDeviceID = deviceID
return nil
}
func (s *testSafetyStore) CreateRiskChallenge(tenantID types.TenantID, challenge *types.RiskChallenge) (*types.RiskChallenge, error) { func (s *testSafetyStore) CreateRiskChallenge(tenantID types.TenantID, challenge *types.RiskChallenge) (*types.RiskChallenge, error) {
now := time.Now().UTC().Round(time.Millisecond) now := time.Now().UTC().Round(time.Millisecond)
challenge.CreatedAt = now challenge.CreatedAt = now
@@ -2907,6 +2917,30 @@ func TestServeIMDevicePushToken(t *testing.T) {
} }
} }
func TestIMLogoutDeviceID(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/v1/im/auth/logout", bytes.NewBufferString(`{"device_id":"device-1","ignored":true}`))
req.Header.Set("X-Device-ID", "header-device")
rec := httptest.NewRecorder()
deviceID, ok := imLogoutDeviceID(rec, req)
if !ok || deviceID != "device-1" || rec.Code != http.StatusOK {
t.Fatalf("unexpected body device id: ok=%v device_id=%q status=%d body=%s", ok, deviceID, rec.Code, rec.Body.String())
}
req = httptest.NewRequest(http.MethodPost, "/v1/im/auth/logout", nil)
req.Header.Set("X-IM-Device-ID", "header-device")
rec = httptest.NewRecorder()
deviceID, ok = imLogoutDeviceID(rec, req)
if !ok || deviceID != "header-device" || rec.Code != http.StatusOK {
t.Fatalf("unexpected header device id: ok=%v device_id=%q status=%d body=%s", ok, deviceID, rec.Code, rec.Body.String())
}
req = httptest.NewRequest(http.MethodPost, "/v1/im/auth/logout", bytes.NewBufferString(`{"device_id":"bad/device"}`))
rec = httptest.NewRecorder()
if deviceID, ok = imLogoutDeviceID(rec, req); ok || rec.Code != http.StatusBadRequest {
t.Fatalf("invalid device id accepted: ok=%v device_id=%q status=%d body=%s", ok, deviceID, rec.Code, rec.Body.String())
}
}
func TestServeIMConversationSearch(t *testing.T) { func TestServeIMConversationSearch(t *testing.T) {
ctrl := gomock.NewController(t) ctrl := gomock.NewController(t)
defer ctrl.Finish() defer ctrl.Finish()
@@ -3780,6 +3814,83 @@ func TestServeIMGroupDetailPatchUsesBusinessSettingsVersion(t *testing.T) {
} }
} }
func TestServeIMGroupDetailReturnsGoneForDissolvedSettingsWithoutSubscription(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
oldStore := store.Store
oldGroupBusiness := store.GroupBusiness
mockStore := mock_store.NewMockPersistentStorageInterface(ctrl)
tokenAuth := mock_auth.NewMockAuthHandler(ctrl)
mockStore.EXPECT().GetLogicalAuthHandler("token").Return(tokenAuth)
tokenAuth.EXPECT().IsInitialized().Return(true)
tokenAuth.EXPECT().Authenticate(auth.AuthContext{}, []byte("token-1")).
Return(&auth.Rec{TenantID: 7, Uid: 42, AuthLevel: auth.LevelAuth}, nil, nil)
store.Store = mockStore
store.GroupBusiness = &testGroupBusinessStore{settings: &types.GroupSettings{
Topic: "grpAlpha", OwnerUserID: types.Uid(42), Name: "Alpha",
GroupStatus: types.GroupStatusDissolved, Version: 2,
}}
defer func() {
store.Store = oldStore
store.GroupBusiness = oldGroupBusiness
}()
req := httptest.NewRequest(http.MethodGet, "/v1/im/groups/grpAlpha", nil)
req.Header.Set("Authorization", "Bearer token-1")
rec := httptest.NewRecorder()
serveIMGroupResource(rec, req)
if rec.Code != http.StatusGone {
t.Fatalf("detail status: got %d body=%s", rec.Code, rec.Body.String())
}
if reason := decodeIMHTTPResponse(t, rec).Reason; reason != imReasonGroupDissolved {
t.Fatalf("unexpected reason: %q", reason)
}
}
func TestServeIMGroupDetailReturnsGoneForDeletedTopicWithoutSubscription(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
oldStore := store.Store
oldGroupBusiness := store.GroupBusiness
oldTopics := store.Topics
mockStore := mock_store.NewMockPersistentStorageInterface(ctrl)
tokenAuth := mock_auth.NewMockAuthHandler(ctrl)
mockStore.EXPECT().GetLogicalAuthHandler("token").Return(tokenAuth)
tokenAuth.EXPECT().IsInitialized().Return(true)
tokenAuth.EXPECT().Authenticate(auth.AuthContext{}, []byte("token-1")).
Return(&auth.Rec{TenantID: 7, Uid: 42, AuthLevel: auth.LevelAuth}, nil, nil)
topics := mock_store.NewMockTopicsPersistenceInterface(ctrl)
topics.EXPECT().Get(types.TenantID(7), "grpAlpha").Return(&types.Topic{
ObjHeader: types.ObjHeader{Id: "grpAlpha"},
TenantID: 7,
State: types.StateDeleted,
}, nil)
store.Store = mockStore
store.Topics = topics
store.GroupBusiness = &testGroupBusinessStore{settings: &types.GroupSettings{
Topic: "grpAlpha", OwnerUserID: types.Uid(42), Name: "Alpha",
GroupStatus: types.GroupStatusActive, Version: 1,
}}
defer func() {
store.Store = oldStore
store.Topics = oldTopics
store.GroupBusiness = oldGroupBusiness
}()
req := httptest.NewRequest(http.MethodGet, "/v1/im/groups/grpAlpha", nil)
req.Header.Set("Authorization", "Bearer token-1")
rec := httptest.NewRecorder()
serveIMGroupResource(rec, req)
if rec.Code != http.StatusGone {
t.Fatalf("detail status: got %d body=%s", rec.Code, rec.Body.String())
}
if reason := decodeIMHTTPResponse(t, rec).Reason; reason != imReasonGroupDissolved {
t.Fatalf("unexpected reason: %q", reason)
}
}
func TestServeIMGroupMyProfileKeepsSubscriptionNicknameWhenBusinessMemberMissing(t *testing.T) { func TestServeIMGroupMyProfileKeepsSubscriptionNicknameWhenBusinessMemberMissing(t *testing.T) {
ctrl := gomock.NewController(t) ctrl := gomock.NewController(t)
defer ctrl.Finish() defer ctrl.Finish()
@@ -5717,6 +5828,7 @@ func TestServeIMMessageForwardKafkaAssignsSeqAndBroadcasts(t *testing.T) {
TenantID: 7, TenantID: 7,
SeqId: 34, SeqId: 34,
}, nil) }, nil)
topics.EXPECT().GetSubs(types.TenantID(7), targetTopic, nil).Return(nil, nil)
messages.EXPECT().GetOne(types.TenantID(7), "grpSource", int64(14)).Return(&types.Message{ messages.EXPECT().GetOne(types.TenantID(7), "grpSource", int64(14)).Return(&types.Message{
TenantID: 7, TenantID: 7,
Topic: "grpSource", Topic: "grpSource",
@@ -5768,8 +5880,11 @@ func TestServeIMMessageForwardKafkaAssignsSeqAndBroadcasts(t *testing.T) {
} }
select { select {
case delivered := <-globals.hub.routeSrv: case delivered := <-globals.hub.routeSrv:
t.Fatalf("pending forward should not broadcast immediately: %#v", delivered) if delivered.Data == nil || delivered.Data.Topic != targetTopic || delivered.Data.SeqId != 35 {
t.Fatalf("unexpected immediate broadcast: %#v", delivered)
}
default: default:
t.Fatalf("pending forward should broadcast immediately after kafka publish")
} }
} }
@@ -6201,7 +6316,8 @@ func TestServeIMMessageSearchContextAndMedia(t *testing.T) {
store.Messages = oldMessages store.Messages = oldMessages
}() }()
subs.EXPECT().Get(types.TenantID(7), "grpAlpha", types.Uid(42), false).Return(&types.Subscription{TenantID: 7, User: types.Uid(42).String(), Topic: "grpAlpha", ModeWant: types.ModeCPublic, ModeGiven: types.ModeCPublic}, nil).Times(3) subs.EXPECT().Get(types.TenantID(7), "grpAlpha", types.Uid(42), false).Return(&types.Subscription{TenantID: 7, User: types.Uid(42).String(), Topic: "grpAlpha", ModeWant: types.ModeCPublic, ModeGiven: types.ModeCPublic}, nil).Times(3)
textMsg := types.Message{TenantID: 7, SeqId: 3, Topic: "grpAlpha", From: types.Uid(42).String(), Head: types.KVMap{"type": "text"}, Content: map[string]any{"text": "hello world"}, ObjHeader: types.ObjHeader{CreatedAt: time.Date(2026, 8, 13, 9, 0, 0, 0, time.UTC)}} replyHead := map[string]any{"seq": float64(2), "type": "text", "text": "quoted hello", "from": types.Uid(43).UserId()}
textMsg := types.Message{TenantID: 7, SeqId: 3, Topic: "grpAlpha", From: types.Uid(42).String(), Head: types.KVMap{"type": "text", "x-im-reply": replyHead}, Content: map[string]any{"text": "hello world"}, ObjHeader: types.ObjHeader{CreatedAt: time.Date(2026, 8, 13, 9, 0, 0, 0, time.UTC)}}
fileMsg := types.Message{TenantID: 7, SeqId: 4, Topic: "grpAlpha", From: types.Uid(42).String(), Head: types.KVMap{"type": "image"}, Content: map[string]any{"file_id": "file-1", "mime": "image/png"}, ObjHeader: types.ObjHeader{CreatedAt: time.Date(2026, 8, 13, 9, 1, 0, 0, time.UTC)}} fileMsg := types.Message{TenantID: 7, SeqId: 4, Topic: "grpAlpha", From: types.Uid(42).String(), Head: types.KVMap{"type": "image"}, Content: map[string]any{"file_id": "file-1", "mime": "image/png"}, ObjHeader: types.ObjHeader{CreatedAt: time.Date(2026, 8, 13, 9, 1, 0, 0, time.UTC)}}
messages.EXPECT().Search(types.TenantID(7), "grpAlpha", types.Uid(42), "hello", "text", "", 50).Return([]types.Message{textMsg}, "", nil) messages.EXPECT().Search(types.TenantID(7), "grpAlpha", types.Uid(42), "hello", "text", "", 50).Return([]types.Message{textMsg}, "", nil)
messages.EXPECT().GetAll(types.TenantID(7), "grpAlpha", types.Uid(42), gomock.Any()).Return([]types.Message{textMsg}, nil) messages.EXPECT().GetAll(types.TenantID(7), "grpAlpha", types.Uid(42), gomock.Any()).Return([]types.Message{textMsg}, nil)
@@ -6223,6 +6339,10 @@ func TestServeIMMessageSearchContextAndMedia(t *testing.T) {
if searchSnapshot["from"] != types.Uid(42).UserId() { if searchSnapshot["from"] != types.Uid(42).UserId() {
t.Fatalf("search snapshot from must use full user_id: %#v", searchSnapshot) t.Fatalf("search snapshot from must use full user_id: %#v", searchSnapshot)
} }
searchHead := searchSnapshot["head"].(map[string]any)
if got := searchHead["x-im-reply"].(map[string]any); got["seq"] != replyHead["seq"] || got["type"] != replyHead["type"] || got["text"] != replyHead["text"] || got["from"] != replyHead["from"] {
t.Fatalf("search snapshot must preserve x-im-reply: %#v", got)
}
contextReq := httptest.NewRequest(http.MethodGet, "/v1/im/topics/grpAlpha/messages/3/context", nil) contextReq := httptest.NewRequest(http.MethodGet, "/v1/im/topics/grpAlpha/messages/3/context", nil)
contextReq.Header.Set("Authorization", "Bearer token-1") contextReq.Header.Set("Authorization", "Bearer token-1")
@@ -6236,6 +6356,10 @@ func TestServeIMMessageSearchContextAndMedia(t *testing.T) {
if len(contextItems) != 1 || contextItems[0].(map[string]any)["from"] != types.Uid(42).UserId() { if len(contextItems) != 1 || contextItems[0].(map[string]any)["from"] != types.Uid(42).UserId() {
t.Fatalf("context from must use full user_id: %#v", contextData) t.Fatalf("context from must use full user_id: %#v", contextData)
} }
contextHead := contextItems[0].(map[string]any)["head"].(map[string]any)
if got := contextHead["x-im-reply"].(map[string]any); got["seq"] != replyHead["seq"] || got["type"] != replyHead["type"] || got["text"] != replyHead["text"] || got["from"] != replyHead["from"] {
t.Fatalf("context must preserve x-im-reply: %#v", got)
}
mediaReq := httptest.NewRequest(http.MethodGet, "/v1/im/topics/grpAlpha/media?type=image", nil) mediaReq := httptest.NewRequest(http.MethodGet, "/v1/im/topics/grpAlpha/media?type=image", nil)
mediaReq.Header.Set("Authorization", "Bearer token-1") mediaReq.Header.Set("Authorization", "Bearer token-1")
@@ -7438,6 +7562,37 @@ func TestServeIMFriendRequestCreate(t *testing.T) {
} }
} }
func TestServeIMFriendRequestCreateAllowsProfileCardSource(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
mockStore := expectIMBearer(t, ctrl, "token-1", 7, 1)
mockStore.EXPECT().GetUidString().Return("card-1")
oldRelations := store.Relations
oldProfiles := store.Profiles
relations := &testRelationsStore{context: &types.RelationshipContext{Relationship: types.RelationshipNone}}
store.Relations = relations
store.Profiles = &testProfilesStore{}
defer func() {
store.Relations = oldRelations
store.Profiles = oldProfiles
}()
req := httptest.NewRequest(http.MethodPost, "/v1/im/friend-requests",
bytes.NewBufferString(`{"target_user_id":"`+types.Uid(2).UserId()+`","message":"你好","source":"profile_card"}`))
req.Header.Set("Authorization", "Bearer token-1")
req.Header.Set("Idempotency-Key", "fr-card-1")
rec := httptest.NewRecorder()
serveIMFriendRequests(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status: got %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String())
}
if relations.created == nil || relations.created.Source != "profile_card" || relations.created.SourceTopic != "" {
t.Fatalf("unexpected friend request created from profile card: %#v", relations.created)
}
}
func TestServeIMFriendRequestCreateReturnsNotFoundForMissingTarget(t *testing.T) { func TestServeIMFriendRequestCreateReturnsNotFoundForMissingTarget(t *testing.T) {
ctrl := gomock.NewController(t) ctrl := gomock.NewController(t)
defer ctrl.Finish() defer ctrl.Finish()
@@ -7994,6 +8149,70 @@ func TestServeIMUserProfile(t *testing.T) {
} }
} }
func TestServeIMUserProfilePrivateNonFriendReturnsLimitedProfile(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
expectIMBearer(t, ctrl, "token-1", 7, 1)
oldUsers := store.Users
oldRelations := store.Relations
oldProfiles := store.Profiles
users := mock_store.NewMockUsersPersistenceInterface(ctrl)
peer := types.Uid(2)
users.EXPECT().Get(types.TenantID(7), peer).Return(&types.User{
ObjHeader: types.ObjHeader{Id: peer.String()},
TenantID: 7,
Username: "alice7",
Public: map[string]any{
"display_name": "Alice",
"nickname": "Alice",
"avatar_file_id": "file-1",
"signature": "hello",
"gender": "female",
"region": "Shanghai",
"public_account": "alice001",
},
}, nil)
store.Users = users
store.Relations = &testRelationsStore{context: &types.RelationshipContext{
TenantID: 7,
UserID: 1,
PeerID: peer,
Topic: types.Uid(1).P2PName(peer),
Relationship: types.RelationshipNone,
CanSend: false,
}}
store.Profiles = &testProfilesStore{}
defer func() {
store.Users = oldUsers
store.Relations = oldRelations
store.Profiles = oldProfiles
}()
req := httptest.NewRequest(http.MethodGet, "/v1/im/users/"+peer.UserId()+"/profile", nil)
req.Header.Set("Authorization", "Bearer token-1")
rec := httptest.NewRecorder()
serveIMUserResource(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["relationship"] != types.RelationshipNone || data["display_name"] != "Alice" ||
data["avatar_file_id"] != "file-1" || data["public_account"] != "alice001" || data["username"] != "alice7" {
t.Fatalf("unexpected limited profile response: %#v", data)
}
capabilities := data["capabilities"].(map[string]any)
if capabilities["can_send"] != false || capabilities["can_call"] != false || capabilities["can_add_friend"] != true {
t.Fatalf("unexpected limited profile capabilities: %#v", data)
}
for _, field := range []string{"signature", "gender", "region"} {
if _, ok := data[field]; ok {
t.Fatalf("limited profile should omit private field %q: %#v", field, data)
}
}
}
func TestServeIMUserProfileBlockedByMeReturnsMinimalProfile(t *testing.T) { func TestServeIMUserProfileBlockedByMeReturnsMinimalProfile(t *testing.T) {
ctrl := gomock.NewController(t) ctrl := gomock.NewController(t)
defer ctrl.Finish() defer ctrl.Finish()
+2 -1
View File
@@ -206,7 +206,8 @@ func imFriendEntryBlocked(policy map[string]any, entry string) bool {
} }
func imMessageCurfewDecision(tenantID types.TenantID, tenantCode string, actor types.Uid, topic string, cat types.TopicCat) imPolicyDecision { func imMessageCurfewDecision(tenantID types.TenantID, tenantCode string, actor types.Uid, topic string, cat types.TopicCat) imPolicyDecision {
if cat != types.TopicCatP2P { // 宵禁只作用于私聊与群聊两类消息话题;me/fnd 等非消息话题直接放行。
if cat != types.TopicCatP2P && cat != types.TopicCatGrp {
return imAllowDecision() return imAllowDecision()
} }
policy, err := imSecurityPolicy(tenantID, tenantCode) policy, err := imSecurityPolicy(tenantID, tenantCode)
+109 -37
View File
@@ -6,6 +6,7 @@ import (
"net/http" "net/http"
"strconv" "strconv"
"strings" "strings"
"time"
"github.com/tinode/chat/server/store" "github.com/tinode/chat/server/store"
"github.com/tinode/chat/server/store/types" "github.com/tinode/chat/server/store/types"
@@ -25,37 +26,48 @@ type internalQuerySpec struct {
Filters []internalQueryFilter Filters []internalQueryFilter
DetailKey string DetailKey string
ScopeColumn string ScopeColumn string
// TenantColumn 指定共库模式下的租户隔离列(如 "u.tenant_id")。
// 共库模式下所有租户共用同一物理库,列表与详情查询必须无条件注入 tenant_id 谓词。
TenantColumn string
}
// internalAdminTenantCondition 返回单条详情查询的租户过滤片段与追加参数。
func internalAdminTenantCondition(spec *internalQuerySpec, tenantID types.TenantID) (string, []any) {
if spec == nil || spec.TenantColumn == "" {
return "", nil
}
return " AND " + spec.TenantColumn + "=?", []any{int64(tenantID)}
} }
var internalGroupResourceSpecs = map[string]*internalQuerySpec{ var internalGroupResourceSpecs = map[string]*internalQuerySpec{
"join-requests": { "join-requests": {
Select: "SELECT id,request_id,topic,applicant_user_id,inviter_user_id,source,message,status,reject_reason,handled_by,handled_at,expires_at,version,created_at,updated_at", Select: "SELECT id,request_id,topic,applicant_user_id,inviter_user_id,source,message,status,reject_reason,handled_by,handled_at,expires_at,version,created_at,updated_at",
From: "FROM im_group_join_requests", DefaultSort: "ORDER BY id DESC", ScopeColumn: "topic", From: "FROM im_group_join_requests", DefaultSort: "ORDER BY id DESC", ScopeColumn: "topic", TenantColumn: "tenant_id",
Filters: []internalQueryFilter{{"status", "status", "=", "string"}, {"applicant_user_id", "applicant_user_id", "=", "uid"}}, Filters: []internalQueryFilter{{"status", "status", "=", "string"}, {"applicant_user_id", "applicant_user_id", "=", "uid"}},
}, },
"invitations": { "invitations": {
Select: "SELECT id,invitation_id,topic,inviter_user_id,target_user_id,message,status,result,join_request_id,expires_at,handled_at,created_at,updated_at", Select: "SELECT id,invitation_id,topic,inviter_user_id,target_user_id,message,status,result,join_request_id,expires_at,handled_at,created_at,updated_at",
From: "FROM im_group_invitations", DefaultSort: "ORDER BY id DESC", ScopeColumn: "topic", From: "FROM im_group_invitations", DefaultSort: "ORDER BY id DESC", ScopeColumn: "topic", TenantColumn: "tenant_id",
Filters: []internalQueryFilter{{"status", "status", "=", "string"}, {"target_user_id", "target_user_id", "=", "uid"}, {"inviter_user_id", "inviter_user_id", "=", "uid"}}, Filters: []internalQueryFilter{{"status", "status", "=", "string"}, {"target_user_id", "target_user_id", "=", "uid"}, {"inviter_user_id", "inviter_user_id", "=", "uid"}},
}, },
"announcements": { "announcements": {
Select: "SELECT id,announcement_id,topic,author_user_id,content,status,version,published_at,deleted_by,deleted_at,created_at,updated_at", Select: "SELECT id,announcement_id,topic,author_user_id,content,status,version,published_at,deleted_by,deleted_at,created_at,updated_at",
From: "FROM im_group_announcements", DefaultSort: "ORDER BY id DESC", ScopeColumn: "topic", From: "FROM im_group_announcements", DefaultSort: "ORDER BY id DESC", ScopeColumn: "topic", TenantColumn: "tenant_id",
Filters: []internalQueryFilter{{"status", "status", "=", "string"}}, Filters: []internalQueryFilter{{"status", "status", "=", "string"}},
}, },
"mutes": { "mutes": {
Select: "SELECT id,topic,user_id,muted_by,muted_at,expires_at,reason,status,revoked_by,revoked_at,version,created_at,updated_at", Select: "SELECT id,topic,user_id,muted_by,muted_at,expires_at,reason,status,revoked_by,revoked_at,version,created_at,updated_at",
From: "FROM im_group_mutes", DefaultSort: "ORDER BY id DESC", ScopeColumn: "topic", From: "FROM im_group_mutes", DefaultSort: "ORDER BY id DESC", ScopeColumn: "topic", TenantColumn: "tenant_id",
Filters: []internalQueryFilter{{"status", "status", "=", "string"}, {"user_id", "user_id", "=", "uid"}}, Filters: []internalQueryFilter{{"status", "status", "=", "string"}, {"user_id", "user_id", "=", "uid"}},
}, },
"admin-logs": { "admin-logs": {
Select: "SELECT id,log_id,topic,operator_id,target_user_id,action,object_type,object_id,before_data,after_data,request_id,idempotency_key,created_at", Select: "SELECT id,log_id,topic,operator_id,target_user_id,action,object_type,object_id,before_data,after_data,request_id,idempotency_key,created_at",
From: "FROM im_group_admin_logs", DefaultSort: "ORDER BY id DESC", ScopeColumn: "topic", From: "FROM im_group_admin_logs", DefaultSort: "ORDER BY id DESC", ScopeColumn: "topic", TenantColumn: "tenant_id",
Filters: []internalQueryFilter{{"action", "action", "=", "string"}, {"operator_id", "operator_id", "=", "uid"}}, Filters: []internalQueryFilter{{"action", "action", "=", "string"}, {"operator_id", "operator_id", "=", "uid"}},
}, },
"files": { "files": {
Select: "SELECT id,group_file_id,topic,file_id,source_topic,source_seq,uploader_id,file_name,mime_type,file_type,file_size,status,deleted_by,deleted_at,created_at,updated_at", Select: "SELECT id,group_file_id,topic,file_id,source_topic,source_seq,uploader_id,file_name,mime_type,file_type,file_size,status,deleted_by,deleted_at,created_at,updated_at",
From: "FROM im_group_files", DefaultSort: "ORDER BY id DESC", ScopeColumn: "topic", From: "FROM im_group_files", DefaultSort: "ORDER BY id DESC", ScopeColumn: "topic", TenantColumn: "tenant_id",
Filters: []internalQueryFilter{{"status", "status", "=", "string"}, {"file_type", "file_type", "=", "string"}, {"file_name", "file_name", "LIKE", "string"}, {"uploader_id", "uploader_id", "=", "uid"}}, Filters: []internalQueryFilter{{"status", "status", "=", "string"}, {"file_type", "file_type", "=", "string"}, {"file_name", "file_name", "LIKE", "string"}, {"uploader_id", "uploader_id", "=", "uid"}},
}, },
} }
@@ -65,41 +77,46 @@ var internalTenantResourceSpecs = map[string]*internalQuerySpec{
"group-messages": groupMessagesInternalQuerySpec(), "group-messages": groupMessagesInternalQuerySpec(),
"user-login-logs": { "user-login-logs": {
Select: `SELECT id,CAST(user_id AS CHAR) AS user_id,username,account_type,account_mask,login_type,event_type,session_id,device_id,platform,app_version,ip,region,user_agent,risk_level,request_id,login_at,logout_at,metadata`, Select: `SELECT id,CAST(user_id AS CHAR) AS user_id,username,account_type,account_mask,login_type,event_type,session_id,device_id,platform,app_version,ip,region,user_agent,risk_level,request_id,login_at,logout_at,metadata`,
From: "FROM im_user_login_logs", DefaultSort: "ORDER BY id DESC", DetailKey: "id", From: "FROM im_user_login_logs", DefaultSort: "ORDER BY id DESC", DetailKey: "id", TenantColumn: "tenant_id",
Filters: []internalQueryFilter{{"user_id", "user_id", "=", "uid"}, {"account", "account_mask", "LIKE", "string"}, {"login_type", "login_type", "=", "string"}, {"event_type", "event_type", "=", "string"}, {"platform", "platform", "=", "string"}, {"ip", "ip", "LIKE", "string"}, {"login_at_start", "login_at", ">=", "time"}, {"login_at_end", "login_at", "<=", "time"}}, Filters: []internalQueryFilter{{"user_id", "user_id", "=", "uid"}, {"account", "account_mask", "LIKE", "string"}, {"login_type", "login_type", "=", "string"}, {"event_type", "event_type", "=", "string"}, {"platform", "platform", "=", "string"}, {"ip", "ip", "LIKE", "string"}, {"login_at_start", "login_at", ">=", "time"}, {"login_at_end", "login_at", "<=", "time"}},
}, },
"login-failure-logs": {
Select: `SELECT id,CAST(user_id AS CHAR) AS user_id,account_type,account_mask,account_hash,failure_type,reason,detail,ip,region,device_id,platform,app_version,user_agent,risk_level,risk_action,request_id,attempt_at,metadata`,
From: "FROM im_login_failure_logs", DefaultSort: "ORDER BY id DESC", DetailKey: "id", TenantColumn: "tenant_id",
Filters: []internalQueryFilter{{"user_id", "user_id", "=", "uid"}, {"account", "account_mask", "LIKE", "string"}, {"failure_type", "failure_type", "=", "string"}, {"platform", "platform", "=", "string"}, {"ip", "ip", "LIKE", "string"}, {"attempt_at_start", "attempt_at", ">=", "time"}, {"attempt_at_end", "attempt_at", "<=", "time"}},
},
"audit-logs": { "audit-logs": {
Select: `SELECT id,request_id,actor_uid AS actor_user_id,action,target_type,target_id,ip,user_agent, Select: `SELECT id,request_id,actor_uid AS actor_user_id,action,target_type,target_id,ip,user_agent,
JSON_OBJECT('actor_role',actor_role,'before_snapshot',before_snapshot,'after_snapshot',after_snapshot,'reason',reason,'metadata',metadata) AS detail,created_at`, JSON_OBJECT('actor_role',actor_role,'before_snapshot',before_snapshot,'after_snapshot',after_snapshot,'reason',reason,'metadata',metadata) AS detail,created_at`,
From: "FROM im_audit_logs", DefaultSort: "ORDER BY id DESC", DetailKey: "id", From: "FROM im_audit_logs", DefaultSort: "ORDER BY id DESC", DetailKey: "id", TenantColumn: "tenant_id",
Filters: []internalQueryFilter{{"actor_user_id", "actor_uid", "=", "uid"}, {"action", "action", "=", "string"}, {"target_type", "target_type", "=", "string"}, {"target_id", "target_id", "=", "string"}, {"created_at_start", "created_at", ">=", "time"}, {"created_at_end", "created_at", "<=", "time"}}, Filters: []internalQueryFilter{{"actor_user_id", "actor_uid", "=", "uid"}, {"action", "action", "=", "string"}, {"target_type", "target_type", "=", "string"}, {"target_id", "target_id", "=", "string"}, {"created_at_start", "created_at", ">=", "time"}, {"created_at_end", "created_at", "<=", "time"}},
}, },
"realname-verifications": { "realname-verifications": {
Select: "SELECT id,user_id,submission_id,status,masked_name AS real_name,masked_id_number AS id_card_no_masked,review_reason AS reject_reason,verified_at AS reviewed_at,created_at,updated_at", Select: "SELECT id,user_id,submission_id,status,masked_name AS real_name,masked_id_number AS id_card_no_masked,review_reason AS reject_reason,verified_at AS reviewed_at,created_at,updated_at",
From: "FROM im_realname_verifications", DefaultSort: "ORDER BY id DESC", DetailKey: "id", From: "FROM im_realname_verifications", DefaultSort: "ORDER BY id DESC", DetailKey: "id", TenantColumn: "tenant_id",
Filters: []internalQueryFilter{{"user_id", "user_id", "=", "uid"}, {"status", "status", "=", "string"}, {"real_name", "masked_name", "LIKE", "string"}, {"created_at_start", "created_at", ">=", "time"}, {"created_at_end", "created_at", "<=", "time"}}, Filters: []internalQueryFilter{{"user_id", "user_id", "=", "uid"}, {"status", "status", "=", "string"}, {"real_name", "masked_name", "LIKE", "string"}, {"created_at_start", "created_at", ">=", "time"}, {"created_at_end", "created_at", "<=", "time"}},
}, },
"abuse-reports": { "abuse-reports": {
Select: "SELECT id,report_id,reporter_id AS reporter_user_id,target_type,target_user_id,target_topic,target_seq,reason_code,description,evidence_file_ids,snapshot,status,created_at,updated_at", Select: "SELECT id,report_id,reporter_id AS reporter_user_id,target_type,target_user_id,target_topic,target_seq,reason_code,description,evidence_file_ids,snapshot,status,created_at,updated_at",
From: "FROM im_abuse_reports", DefaultSort: "ORDER BY id DESC", DetailKey: "id", From: "FROM im_abuse_reports", DefaultSort: "ORDER BY id DESC", DetailKey: "id", TenantColumn: "tenant_id",
Filters: []internalQueryFilter{{"reporter_user_id", "reporter_id", "=", "uid"}, {"target_type", "target_type", "=", "string"}, {"status", "status", "=", "string"}, {"created_at_start", "created_at", ">=", "time"}, {"created_at_end", "created_at", "<=", "time"}}, Filters: []internalQueryFilter{{"reporter_user_id", "reporter_id", "=", "uid"}, {"target_type", "target_type", "=", "string"}, {"status", "status", "=", "string"}, {"created_at_start", "created_at", ">=", "time"}, {"created_at_end", "created_at", "<=", "time"}},
}, },
"feedback-submissions": { "feedback-submissions": {
Select: "SELECT id,feedback_id,user_id,category,content,contact,attachment_file_ids AS file_ids,client_context,include_diagnostics,status,created_at,updated_at", Select: "SELECT id,feedback_id,user_id,category,content,contact,attachment_file_ids AS file_ids,client_context,include_diagnostics,status,created_at,updated_at",
From: "FROM im_feedback_submissions", DefaultSort: "ORDER BY id DESC", DetailKey: "id", From: "FROM im_feedback_submissions", DefaultSort: "ORDER BY id DESC", DetailKey: "id", TenantColumn: "tenant_id",
Filters: []internalQueryFilter{{"user_id", "user_id", "=", "uid"}, {"category", "category", "=", "string"}, {"status", "status", "=", "string"}, {"created_at_start", "created_at", ">=", "time"}, {"created_at_end", "created_at", "<=", "time"}}, Filters: []internalQueryFilter{{"user_id", "user_id", "=", "uid"}, {"category", "category", "=", "string"}, {"status", "status", "=", "string"}, {"created_at_start", "created_at", ">=", "time"}, {"created_at_end", "created_at", "<=", "time"}},
}, },
"feedback-config": { "feedback-config": {
Select: "SELECT id,categories,max_length,attachment_policy,allow_contact,diagnostics_notice,created_at,updated_at", Select: "SELECT id,categories,max_length,attachment_policy,allow_contact,diagnostics_notice,created_at,updated_at",
From: "FROM im_feedback_config", DefaultSort: "ORDER BY id DESC", DetailKey: "id", From: "FROM im_feedback_config", DefaultSort: "ORDER BY id DESC", DetailKey: "id", TenantColumn: "tenant_id",
}, "abuse-reasons": { }, "abuse-reasons": {
Select: "SELECT id,target_type,reason_code,label,requires_description,allows_evidence,enabled,sort_order,created_at,updated_at", Select: "SELECT id,target_type,reason_code,label,requires_description,allows_evidence,enabled,sort_order,created_at,updated_at",
From: "FROM im_abuse_reasons", DefaultSort: "ORDER BY sort_order ASC,id ASC", DetailKey: "id", From: "FROM im_abuse_reasons", DefaultSort: "ORDER BY sort_order ASC,id ASC", DetailKey: "id", TenantColumn: "tenant_id",
Filters: []internalQueryFilter{{"target_type", "target_type", "=", "string"}}, Filters: []internalQueryFilter{{"target_type", "target_type", "=", "string"}},
}, },
"app-releases": { "app-releases": {
Select: "SELECT id,platform,channel,latest_version,min_supported_version,update_type,release_notes,store_url,package_url,checksum,remind_after_seconds,published_at,created_at,updated_at", Select: "SELECT id,platform,channel,latest_version,min_supported_version,update_type,release_notes,store_url,package_url,checksum,remind_after_seconds,published_at,created_at,updated_at",
From: "FROM im_app_releases", DefaultSort: "ORDER BY id DESC", DetailKey: "id", From: "FROM im_app_releases", DefaultSort: "ORDER BY id DESC", DetailKey: "id", TenantColumn: "tenant_id",
}} }}
var internalOutboxResourceSpecs = map[string]*internalQuerySpec{} var internalOutboxResourceSpecs = map[string]*internalQuerySpec{}
@@ -118,7 +135,7 @@ func serveInternalAdminResourceList(wrt http.ResponseWriter, req *http.Request,
return return
} }
} }
query, countQuery, args, page, perPage, err := buildInternalAdminListQuery(req, spec, scope) query, countQuery, args, page, perPage, err := buildInternalAdminListQuery(req, spec, scope, tenantID)
if err != nil { if err != nil {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", nil) writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", nil)
return return
@@ -140,6 +157,9 @@ func serveInternalAdminResourceList(wrt http.ResponseWriter, req *http.Request,
return return
} }
} }
if resource == "login-failure-logs" {
attachLoginLockoutState(tenantID, result.Items)
}
writeIMSuccess(wrt, req, map[string]any{ writeIMSuccess(wrt, req, map[string]any{
"items": normalizeInternalAdminRowsForAPI(result.Items), "items": normalizeInternalAdminRowsForAPI(result.Items),
"total": result.Total, "total": result.Total,
@@ -155,7 +175,7 @@ func queryEncryptedAdminMessages(req *http.Request, tenantID types.TenantID, spe
queryValues.Del("keyword") queryValues.Del("keyword")
urlCopy.RawQuery = queryValues.Encode() urlCopy.RawQuery = queryValues.Encode()
requestCopy.URL = &urlCopy requestCopy.URL = &urlCopy
query, countQuery, args, _, _, err := buildInternalAdminListQuery(requestCopy, spec, scope) query, countQuery, args, _, _, err := buildInternalAdminListQuery(requestCopy, spec, scope, tenantID)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -230,7 +250,12 @@ func serveInternalAdminResourceDetail(wrt http.ResponseWriter, req *http.Request
return return
} }
query := spec.Select + " " + spec.From + " WHERE " + spec.DetailKey + "=?" query := spec.Select + " " + spec.From + " WHERE " + spec.DetailKey + "=?"
row, err := store.InternalAdminQueries.QueryOne(tenantID, query, []any{id}) args := []any{id}
if cond, extra := internalAdminTenantCondition(spec, tenantID); cond != "" {
query += cond
args = append(args, extra...)
}
row, err := store.InternalAdminQueries.QueryOne(tenantID, query, args)
if err != nil { if err != nil {
writeInternalQueryError(wrt, req, err) writeInternalQueryError(wrt, req, err)
return return
@@ -299,13 +324,13 @@ func internalAdminQuerySpec(resource string) (*internalQuerySpec, bool) {
case "users": case "users":
return usersInternalQuerySpec(), true return usersInternalQuerySpec(), true
case "user_devices": case "user_devices":
return &internalQuerySpec{Select: "SELECT id,deviceid AS device_id,platform,lastseen AS last_seen,lang", From: "FROM im_devices", DefaultSort: "ORDER BY lastseen DESC", ScopeColumn: "userid", Filters: []internalQueryFilter{{"device_id", "deviceid", "LIKE", "string"}, {"platform", "platform", "=", "string"}}}, true return &internalQuerySpec{Select: "SELECT id,deviceid AS device_id,platform,lastseen AS last_seen,lang", From: "FROM im_devices", DefaultSort: "ORDER BY lastseen DESC", ScopeColumn: "userid", TenantColumn: "tenant_id", Filters: []internalQueryFilter{{"device_id", "deviceid", "LIKE", "string"}, {"platform", "platform", "=", "string"}}}, true
case "groups": case "groups":
return groupsInternalQuerySpec(), true return groupsInternalQuerySpec(), true
case "file_links": case "file_links":
return &internalQuerySpec{Select: "SELECT id,topic,msgid AS seq_id,userid AS user_id,createdat AS created_at", From: "FROM im_filemsglinks", DefaultSort: "ORDER BY id DESC", ScopeColumn: "fileid"}, true return &internalQuerySpec{Select: "SELECT id,topic,msgid AS seq_id,userid AS user_id,createdat AS created_at", From: "FROM im_filemsglinks", DefaultSort: "ORDER BY id DESC", ScopeColumn: "fileid", TenantColumn: "tenant_id"}, true
case "files": case "files":
return &internalQuerySpec{Select: "SELECT id,id AS file_id,userid AS user_id,mimetype AS mime_type,size AS file_size,location,etag,status,createdat AS created_at,updatedat AS updated_at", From: "FROM im_fileuploads", DefaultSort: "ORDER BY id DESC", DetailKey: "id", Filters: []internalQueryFilter{{"file_id", "id", "=", "int"}, {"user_id", "userid", "=", "uid"}, {"status", "status", "=", "int"}, {"mime_type", "mimetype", "LIKE", "string"}, {"created_at_start", "createdat", ">=", "time"}, {"created_at_end", "createdat", "<=", "time"}}}, true return &internalQuerySpec{Select: "SELECT id,id AS file_id,userid AS user_id,mimetype AS mime_type,size AS file_size,location,etag,status,createdat AS created_at,updatedat AS updated_at", From: "FROM im_fileuploads", DefaultSort: "ORDER BY id DESC", DetailKey: "id", TenantColumn: "tenant_id", Filters: []internalQueryFilter{{"file_id", "id", "=", "int"}, {"user_id", "userid", "=", "uid"}, {"status", "status", "=", "int"}, {"mime_type", "mimetype", "LIKE", "string"}, {"created_at_start", "createdat", ">=", "time"}, {"created_at_end", "createdat", "<=", "time"}}}, true
} }
if strings.HasPrefix(resource, "group_") { if strings.HasPrefix(resource, "group_") {
spec := internalGroupResourceSpecs[strings.TrimPrefix(resource, "group_")] spec := internalGroupResourceSpecs[strings.TrimPrefix(resource, "group_")]
@@ -331,10 +356,11 @@ func usersInternalQuerySpec() *internalQuerySpec {
p.signature,sec.auth_version,sec.password_updated_at,u.createdat AS created_at,u.updatedat AS updated_at, p.signature,sec.auth_version,sec.password_updated_at,u.createdat AS created_at,u.updatedat AS updated_at,
CASE WHEN EXISTS(SELECT 1 FROM im_registration_invite_uses AS invite_use WHERE invite_use.user_id=u.id) THEN 'invite_code' WHEN COALESCE(u.phone,'')<>'' THEN 'mobile' ELSE 'admin' END AS register_type, CASE WHEN EXISTS(SELECT 1 FROM im_registration_invite_uses AS invite_use WHERE invite_use.user_id=u.id) THEN 'invite_code' WHEN COALESCE(u.phone,'')<>'' THEN 'mobile' ELSE 'admin' END AS register_type,
(SELECT JSON_ARRAYAGG(JSON_OBJECT('method',c.method,'value',c.value,'done',c.done,'updated_at',c.updatedat)) FROM im_credentials AS c WHERE c.userid=u.id AND c.deletedat IS NULL) AS credentials`, (SELECT JSON_ARRAYAGG(JSON_OBJECT('method',c.method,'value',c.value,'done',c.done,'updated_at',c.updatedat)) FROM im_credentials AS c WHERE c.userid=u.id AND c.deletedat IS NULL) AS credentials`,
From: "FROM im_users AS u LEFT JOIN im_user_profiles AS p ON p.user_id=u.id LEFT JOIN im_user_security AS sec ON sec.user_id=u.id LEFT JOIN im_fileuploads AS avatar ON avatar.id=CAST(p.avatar_file_id AS UNSIGNED) AND p.avatar_file_id REGEXP '^[0-9]+$'", From: "FROM im_users AS u LEFT JOIN im_user_profiles AS p ON p.user_id=u.id LEFT JOIN im_user_security AS sec ON sec.user_id=u.id LEFT JOIN im_fileuploads AS avatar ON avatar.id=CAST(p.avatar_file_id AS UNSIGNED) AND p.avatar_file_id REGEXP '^[0-9]+$'",
DefaultSort: "ORDER BY u.id DESC", DefaultSort: "ORDER BY u.id DESC",
DetailKey: "u.id", DetailKey: "u.id",
Filters: []internalQueryFilter{{"user_id", "u.id", "=", "uid"}, {"user_ids", "u.id", "IN", "uid_list"}, {"account_no", "u.account_no", "=", "int"}, {"keyword", "CONCAT_WS(' ',u.username,p.nickname,u.phone,CAST(u.account_no AS CHAR))", "LIKE", "string"}, {"nickname", "p.nickname", "LIKE", "string"}, {"auth_name", "u.username", "LIKE", "string"}, {"state", "u.state", "=", "int"}, {"created_at_start", "u.createdat", ">=", "time"}, {"created_at_end", "u.createdat", "<=", "time"}, {"last_seen_start", "u.lastseen", ">=", "time"}, {"last_seen_end", "u.lastseen", "<=", "time"}}, TenantColumn: "u.tenant_id",
Filters: []internalQueryFilter{{"user_id", "u.id", "=", "uid"}, {"user_ids", "u.id", "IN", "uid_list"}, {"account_no", "u.account_no", "=", "int"}, {"keyword", "CONCAT_WS(' ',u.username,p.nickname,u.phone,CAST(u.account_no AS CHAR))", "LIKE", "string"}, {"nickname", "p.nickname", "LIKE", "string"}, {"auth_name", "u.username", "LIKE", "string"}, {"state", "u.state", "=", "int"}, {"created_at_start", "u.createdat", ">=", "time"}, {"created_at_end", "u.createdat", "<=", "time"}, {"last_seen_start", "u.lastseen", ">=", "time"}, {"last_seen_end", "u.lastseen", "<=", "time"}},
} }
} }
@@ -342,10 +368,11 @@ func groupsInternalQuerySpec() *internalQuerySpec {
return &internalQuerySpec{ return &internalQuerySpec{
Select: `SELECT t.name AS topic,t.state,t.owner AS owner_id,t.seqid AS seq_id,t.delid AS del_id,t.subcnt AS sub_count,t.touchedat AS touched_at,t.createdat AS created_at,t.updatedat AS updated_at,t.public,t.trusted,t.access,t.tags,t.aux, Select: `SELECT t.name AS topic,t.state,t.owner AS owner_id,t.seqid AS seq_id,t.delid AS del_id,t.subcnt AS sub_count,t.touchedat AS touched_at,t.createdat AS created_at,t.updatedat AS updated_at,t.public,t.trusted,t.access,t.tags,t.aux,
gs.name AS group_name,gs.avatar_file_id,gs.description,gs.group_status,gs.join_approval,gs.mute_all,gs.member_upload_files,gs.member_private_chat,gs.member_invite,gs.max_member_count,gs.max_admin_count,gs.member_count AS business_member_count,gs.version AS settings_version,gs.updated_at AS settings_updated_at`, gs.name AS group_name,gs.avatar_file_id,gs.description,gs.group_status,gs.join_approval,gs.mute_all,gs.member_upload_files,gs.member_private_chat,gs.member_invite,gs.max_member_count,gs.max_admin_count,gs.member_count AS business_member_count,gs.version AS settings_version,gs.updated_at AS settings_updated_at`,
From: "FROM (SELECT * FROM im_topics WHERE name LIKE 'grp%') AS t LEFT JOIN im_group_settings AS gs ON gs.topic=t.name", From: "FROM (SELECT * FROM im_topics WHERE name LIKE 'grp%') AS t LEFT JOIN im_group_settings AS gs ON gs.topic=t.name",
DefaultSort: "ORDER BY t.id DESC", DefaultSort: "ORDER BY t.id DESC",
DetailKey: "t.name", DetailKey: "t.name",
Filters: []internalQueryFilter{{"topic", "t.name", "LIKE", "string"}, {"owner_id", "t.owner", "=", "uid"}, {"state", "t.state", "=", "int"}, {"group_status", "gs.group_status", "=", "string"}, {"created_at_start", "t.createdat", ">=", "time"}, {"created_at_end", "t.createdat", "<=", "time"}, {"touched_at_start", "t.touchedat", ">=", "time"}, {"touched_at_end", "t.touchedat", "<=", "time"}}, TenantColumn: "t.tenant_id",
Filters: []internalQueryFilter{{"topic", "t.name", "LIKE", "string"}, {"owner_id", "t.owner", "=", "uid"}, {"state", "t.state", "=", "int"}, {"group_status", "gs.group_status", "=", "string"}, {"created_at_start", "t.createdat", ">=", "time"}, {"created_at_end", "t.createdat", "<=", "time"}, {"touched_at_start", "t.touchedat", ">=", "time"}, {"touched_at_end", "t.touchedat", "<=", "time"}},
} }
} }
@@ -367,8 +394,9 @@ func privateMessagesInternalQuerySpec() *internalQuerySpec {
LEFT JOIN im_users AS ru ON ru.id=rs.userid LEFT JOIN im_users AS ru ON ru.id=rs.userid
LEFT JOIN im_user_profiles AS rp ON rp.user_id=rs.userid LEFT JOIN im_user_profiles AS rp ON rp.user_id=rs.userid
LEFT JOIN im_message_admin_actions AS aa ON aa.id=(SELECT MAX(a2.id) FROM im_message_admin_actions AS a2 WHERE a2.conversation_type='private' AND a2.topic=m.topic AND a2.seqid=m.seqid AND a2.action_status='success')`, LEFT JOIN im_message_admin_actions AS aa ON aa.id=(SELECT MAX(a2.id) FROM im_message_admin_actions AS a2 WHERE a2.conversation_type='private' AND a2.topic=m.topic AND a2.seqid=m.seqid AND a2.action_status='success')`,
DefaultSort: "ORDER BY m.id DESC", DefaultSort: "ORDER BY m.id DESC",
Filters: []internalQueryFilter{{"topic", "m.topic", "LIKE", "string"}, {"from_user_id", "m.`from`", "=", "uid"}, {"to_user_id", "rs.userid", "=", "uid"}, {"content_type", contentType, "=", "string"}, {"status", status, "=", "string"}, {"keyword", "CAST(m.content AS CHAR)", "LIKE", "string"}, {"created_at_start", "m.createdat", ">=", "time"}, {"created_at_end", "m.createdat", "<=", "time"}}, TenantColumn: "m.tenant_id",
Filters: []internalQueryFilter{{"topic", "m.topic", "LIKE", "string"}, {"from_user_id", "m.`from`", "=", "uid"}, {"to_user_id", "rs.userid", "=", "uid"}, {"content_type", contentType, "=", "string"}, {"status", status, "=", "string"}, {"keyword", "CAST(m.content AS CHAR)", "LIKE", "string"}, {"created_at_start", "m.createdat", ">=", "time"}, {"created_at_end", "m.createdat", "<=", "time"}},
} }
} }
@@ -388,7 +416,8 @@ func groupMessagesInternalQuerySpec() *internalQuerySpec {
LEFT JOIN im_user_profiles AS p ON p.user_id=m.from_user LEFT JOIN im_user_profiles AS p ON p.user_id=m.from_user
LEFT JOIN im_group_settings AS gs ON gs.topic=m.topic COLLATE utf8mb4_0900_ai_ci LEFT JOIN im_group_settings AS gs ON gs.topic=m.topic COLLATE utf8mb4_0900_ai_ci
LEFT JOIN im_message_admin_actions AS aa ON aa.id=(SELECT MAX(a2.id) FROM im_message_admin_actions AS a2 WHERE a2.conversation_type='group' AND a2.topic=m.topic COLLATE utf8mb4_0900_ai_ci AND a2.seqid=m.seqid AND a2.action_status='success')`, LEFT JOIN im_message_admin_actions AS aa ON aa.id=(SELECT MAX(a2.id) FROM im_message_admin_actions AS a2 WHERE a2.conversation_type='group' AND a2.topic=m.topic COLLATE utf8mb4_0900_ai_ci AND a2.seqid=m.seqid AND a2.action_status='success')`,
DefaultSort: "ORDER BY m.id DESC", DefaultSort: "ORDER BY m.id DESC",
TenantColumn: "m.tenant_id",
Filters: []internalQueryFilter{ Filters: []internalQueryFilter{
{"topic", "m.topic", "LIKE", "string"}, {"topic", "m.topic", "LIKE", "string"},
{"seq_id", "m.seqid", "=", "int"}, {"seq_id", "m.seqid", "=", "int"},
@@ -417,9 +446,10 @@ func groupMessagesInternalQuerySpecForTenant(tenantID types.TenantID) (*internal
} }
func internalGroupMessageSource(tenantID types.TenantID) (string, error) { func internalGroupMessageSource(tenantID types.TenantID) (string, error) {
shardFilter := "status<>'archived' AND tenant_id=?"
result, err := store.InternalAdminQueries.Query(tenantID, result, err := store.InternalAdminQueries.Query(tenantID,
"SELECT table_name FROM im_group_message_shards WHERE status<>'archived' ORDER BY shard_month DESC", "SELECT table_name FROM im_group_message_shards WHERE "+shardFilter,
"SELECT COUNT(*) FROM im_group_message_shards WHERE status<>'archived'", nil, 1, 200) "SELECT COUNT(*) FROM im_group_message_shards WHERE "+shardFilter, []any{int64(tenantID)}, 1, 200)
if err != nil { if err != nil {
return "", err return "", err
} }
@@ -429,12 +459,15 @@ func internalGroupMessageSource(tenantID types.TenantID) (string, error) {
if !validInternalGroupMessageTable(table) { if !validInternalGroupMessageTable(table) {
continue continue
} }
selects = append(selects, fmt.Sprintf(`SELECT message_id AS id,topic,seqid,from_user,msg_type,status,content,head,reply_seqid,mentioned_user_ids,created_at,updated_at FROM %s`, table)) selects = append(selects, fmt.Sprintf(`SELECT message_id AS id,tenant_id,topic,seqid,from_user,msg_type,status,content,head,reply_seqid,mentioned_user_ids,created_at,updated_at FROM %s`, table))
} }
selects = append(selects, `SELECT id,topic COLLATE utf8mb4_0900_ai_ci AS topic,seqid,from_user, // legacy 行内联租户条件(tenantID 来自租户注册表数值 ID,非用户输入)
selects = append(selects, fmt.Sprintf(`SELECT id,tenant_id,topic COLLATE utf8mb4_0900_ai_ci AS topic,seqid,from_user,
msg_type COLLATE utf8mb4_0900_ai_ci AS msg_type,status COLLATE utf8mb4_0900_ai_ci AS status, msg_type COLLATE utf8mb4_0900_ai_ci AS msg_type,status COLLATE utf8mb4_0900_ai_ci AS status,
content,head,reply_seqid,mentioned_user_ids,created_at,updated_at content,head,reply_seqid,mentioned_user_ids,created_at,updated_at
FROM im_group_messages AS legacy`) FROM im_group_messages AS legacy WHERE legacy.tenant_id=%d AND NOT EXISTS (
SELECT 1 FROM im_group_message_topic_shards AS route
WHERE route.topic=legacy.topic COLLATE utf8mb4_0900_ai_ci AND legacy.seqid BETWEEN route.min_seq AND route.max_seq)`, int64(tenantID)))
return "(" + strings.Join(selects, " UNION ALL ") + ")", nil return "(" + strings.Join(selects, " UNION ALL ") + ")", nil
} }
@@ -498,11 +531,16 @@ func serveInternalDashboardTrends(wrt http.ResponseWriter, req *http.Request, te
writeIMSuccess(wrt, req, map[string]any{"items": normalizeInternalAdminRowsForAPI(result.Items), "total": result.Total, "page": result.Page, "per_page": result.PerPage}) writeIMSuccess(wrt, req, map[string]any{"items": normalizeInternalAdminRowsForAPI(result.Items), "total": result.Total, "page": result.Page, "per_page": result.PerPage})
} }
func buildInternalAdminListQuery(req *http.Request, spec *internalQuerySpec, scope string) (string, string, []any, int, int, error) { func buildInternalAdminListQuery(req *http.Request, spec *internalQuerySpec, scope string, tenantID types.TenantID) (string, string, []any, int, int, error) {
page := parseIMPositiveInt(req.URL.Query().Get("page"), 1) page := parseIMPositiveInt(req.URL.Query().Get("page"), 1)
perPage := parseIMLimit(firstNonEmpty(req.URL.Query().Get("per_page"), req.URL.Query().Get("limit")), 20, 200) perPage := parseIMLimit(firstNonEmpty(req.URL.Query().Get("per_page"), req.URL.Query().Get("limit")), 20, 200)
where := []string{"1=1"} where := []string{"1=1"}
args := []any{} args := []any{}
// 租户隔离必须先于 scope/filters 注入:共库模式下不注入即跨租户泄漏
if spec.TenantColumn != "" {
where = append(where, spec.TenantColumn+"=?")
args = append(args, int64(tenantID))
}
if spec.ScopeColumn != "" && strings.TrimSpace(scope) != "" { if spec.ScopeColumn != "" && strings.TrimSpace(scope) != "" {
value, err := internalAdminFilterValue("scope", scope, scopeKindForColumn(spec.ScopeColumn)) value, err := internalAdminFilterValue("scope", scope, scopeKindForColumn(spec.ScopeColumn))
if err != nil { if err != nil {
@@ -603,6 +641,40 @@ func normalizeInternalAdminRowForAPI(row map[string]any) map[string]any {
return row return row
} }
// internalAdminRowString 把 DB 行字段安全转为字符串(driver 可能返回 string 或 []byte)。
func internalAdminRowString(row map[string]any, key string) string {
switch v := row[key].(type) {
case string:
return strings.TrimSpace(v)
case []byte:
return strings.TrimSpace(string(v))
default:
return strings.TrimSpace(fmt.Sprint(v))
}
}
// attachLoginLockoutState 为登录失败日志的锁定行补充 Redis 实时锁定状态。
// 锁定记录存于 Redis(im_login_lockouts 表已废弃写入),日志行是历史快照不会更新,
// 管理端解锁按钮依赖 locked_now 判断"当前是否仍锁定中",解锁成功后按钮随刷新消失。
func attachLoginLockoutState(tenantID types.TenantID, items []map[string]any) {
now := time.Now()
for _, item := range items {
if item == nil || !strings.EqualFold(internalAdminRowString(item, "risk_action"), loginAuditRiskActionLocked) {
continue
}
accountHash := internalAdminRowString(item, "account_hash")
if accountHash == "" {
continue
}
locked := false
if lockout, err := store.LoginLockouts.Get(tenantID, accountHash); err == nil && lockout != nil &&
lockout.LockedUntil != nil && lockout.LockedUntil.After(now) {
locked = true
}
item["locked_now"] = locked
}
}
func parseInternalAdminDBID(raw string) (int64, bool) { func parseInternalAdminDBID(raw string) (int64, bool) {
raw = strings.TrimSpace(raw) raw = strings.TrimSpace(raw)
if raw == "" { if raw == "" {
+31 -8
View File
@@ -467,7 +467,12 @@ func serveInternalUserQuery(wrt http.ResponseWriter, req *http.Request, tenantID
} }
if spec, ok := internalAdminQuerySpec("users"); ok { if spec, ok := internalAdminQuerySpec("users"); ok {
query := spec.Select + " " + spec.From + " WHERE u.id=?" query := spec.Select + " " + spec.From + " WHERE u.id=?"
if row, err := store.InternalAdminQueries.QueryOne(tenantID, query, []any{userDBID}); err == nil { args := []any{userDBID}
if cond, extra := internalAdminTenantCondition(spec, tenantID); cond != "" {
query += cond
args = append(args, extra...)
}
if row, err := store.InternalAdminQueries.QueryOne(tenantID, query, args); err == nil {
writeIMSuccess(wrt, req, normalizeInternalAdminRowForAPI(row)) writeIMSuccess(wrt, req, normalizeInternalAdminRowForAPI(row))
return return
} else { } else {
@@ -508,7 +513,12 @@ func serveInternalGroupQuery(wrt http.ResponseWriter, req *http.Request, tenantI
} }
if spec, ok := internalAdminQuerySpec("groups"); ok { if spec, ok := internalAdminQuerySpec("groups"); ok {
query := spec.Select + " " + spec.From + " WHERE t.name=?" query := spec.Select + " " + spec.From + " WHERE t.name=?"
if row, err := store.InternalAdminQueries.QueryOne(tenantID, query, []any{topic}); err == nil { args := []any{topic}
if cond, extra := internalAdminTenantCondition(spec, tenantID); cond != "" {
query += cond
args = append(args, extra...)
}
if row, err := store.InternalAdminQueries.QueryOne(tenantID, query, args); err == nil {
writeIMSuccess(wrt, req, normalizeInternalAdminRowForAPI(row)) writeIMSuccess(wrt, req, normalizeInternalAdminRowForAPI(row))
return return
} }
@@ -529,9 +539,10 @@ func serveInternalGroupMembersQuery(wrt http.ResponseWriter, req *http.Request,
spec := &internalQuerySpec{ spec := &internalQuerySpec{
Select: `SELECT gm.id,gm.topic,gm.user_id,u.state,p.nickname,p.public_account,p.avatar_file_id,p.signature,p.gender,p.region,s.modewant AS mode_want,s.modegiven AS mode_given,s.recvseqid AS recv_seq_id,s.readseqid AS read_seq_id,s.delid AS del_id, Select: `SELECT gm.id,gm.topic,gm.user_id,u.state,p.nickname,p.public_account,p.avatar_file_id,p.signature,p.gender,p.region,s.modewant AS mode_want,s.modegiven AS mode_given,s.recvseqid AS recv_seq_id,s.readseqid AS read_seq_id,s.delid AS del_id,
gm.role,gm.member_status,gm.nickname AS group_nickname,gm.pinned,gm.mute_notifications,gm.join_source,gm.joined_by,gm.joined_at,gm.left_at,gm.removed_by,gm.version,gm.created_at,gm.updated_at`, gm.role,gm.member_status,gm.nickname AS group_nickname,gm.pinned,gm.mute_notifications,gm.join_source,gm.joined_by,gm.joined_at,gm.left_at,gm.removed_by,gm.version,gm.created_at,gm.updated_at`,
From: "FROM im_group_members AS gm LEFT JOIN im_users AS u ON u.id=gm.user_id LEFT JOIN im_user_profiles AS p ON p.user_id=gm.user_id LEFT JOIN im_subscriptions AS s ON s.topic=gm.topic AND s.userid=gm.user_id", From: "FROM im_group_members AS gm LEFT JOIN im_users AS u ON u.id=gm.user_id LEFT JOIN im_user_profiles AS p ON p.user_id=gm.user_id LEFT JOIN im_subscriptions AS s ON s.topic=gm.topic AND s.userid=gm.user_id",
DefaultSort: "ORDER BY gm.id DESC", DefaultSort: "ORDER BY gm.id DESC",
ScopeColumn: "gm.topic", ScopeColumn: "gm.topic",
TenantColumn: "gm.tenant_id",
Filters: []internalQueryFilter{ Filters: []internalQueryFilter{
{"user_id", "gm.user_id", "=", "uid"}, {"user_id", "gm.user_id", "=", "uid"},
{"role", "gm.role", "=", "string"}, {"role", "gm.role", "=", "string"},
@@ -540,7 +551,7 @@ func serveInternalGroupMembersQuery(wrt http.ResponseWriter, req *http.Request,
{"eligible_role", "gm.role", "=", "string"}, {"eligible_role", "gm.role", "=", "string"},
}, },
} }
query, countQuery, args, page, perPage, err := buildInternalAdminListQuery(req, spec, topic) query, countQuery, args, page, perPage, err := buildInternalAdminListQuery(req, spec, topic, tenantID)
if err == nil { if err == nil {
if result, queryErr := store.InternalAdminQueries.Query(tenantID, query, countQuery, args, page, perPage); queryErr == nil { if result, queryErr := store.InternalAdminQueries.Query(tenantID, query, countQuery, args, page, perPage); queryErr == nil {
writeIMSuccess(wrt, req, map[string]any{ writeIMSuccess(wrt, req, map[string]any{
@@ -606,7 +617,13 @@ func serveInternalMessageQuery(wrt http.ResponseWriter, req *http.Request, tenan
} }
} }
spec.Select += ",m.head,JSON_UNQUOTE(m.content) AS content" spec.Select += ",m.head,JSON_UNQUOTE(m.content) AS content"
row, err := store.InternalAdminQueries.QueryOne(tenantID, spec.Select+" "+spec.From+" WHERE "+where, []any{topic, seq}) query := spec.Select + " " + spec.From + " WHERE " + where
args := []any{topic, seq}
if cond, extra := internalAdminTenantCondition(spec, tenantID); cond != "" {
query += cond
args = append(args, extra...)
}
row, err := store.InternalAdminQueries.QueryOne(tenantID, query, args)
if err != nil { if err != nil {
writeInternalQueryError(wrt, req, err) writeInternalQueryError(wrt, req, err)
return return
@@ -633,7 +650,13 @@ func serveInternalFileQuery(wrt http.ResponseWriter, req *http.Request, tenantID
return return
} }
spec, _ := internalAdminQuerySpec("files") spec, _ := internalAdminQuerySpec("files")
row, err := store.InternalAdminQueries.QueryOne(tenantID, spec.Select+" "+spec.From+" WHERE id=?", []any{fileDBID}) query := spec.Select + " " + spec.From + " WHERE id=?"
args := []any{fileDBID}
if cond, extra := internalAdminTenantCondition(spec, tenantID); cond != "" {
query += cond
args = append(args, extra...)
}
row, err := store.InternalAdminQueries.QueryOne(tenantID, query, args)
if err != nil { if err != nil {
writeInternalQueryError(wrt, req, err) writeInternalQueryError(wrt, req, err)
return return
+1 -1
View File
@@ -81,7 +81,7 @@
"use_adapter": "mysql", "use_adapter": "mysql",
"adapters": { "adapters": {
"mysql": { "mysql": {
"dsn": "mysql://root:oWxz*2=sb),ouW%p@tcp(mysql.118im.com:30306)/tinode?parseTime=true&collation=utf8mb4_0900_ai_ci", "dsn": "mysql://root:oWxz*2=sb),ouW%p@tcp(mysql.118im.com:30306)/meeting_im?parseTime=true&collation=utf8mb4_0900_ai_ci",
"tenant_registry_db": "hotgo", "tenant_registry_db": "hotgo",
"tenant_db_routing": true, "tenant_db_routing": true,
"max_open_conns": 64, "max_open_conns": 64,
+1 -5
View File
@@ -1151,7 +1151,6 @@ func (s *Session) login(msg *ClientComMessage) {
handler := store.Store.GetLogicalAuthHandler(msg.Login.Scheme) handler := store.Store.GetLogicalAuthHandler(msg.Login.Scheme)
if handler == nil { if handler == nil {
recordSessionLoginFailure(s, msg.Login.Scheme, msg.Login.Secret, nil, msg.Id, types.ErrUnsupported, msg.Timestamp)
logs.Warn.Println("s.login: unknown authentication scheme", msg.Login.Scheme, s.sid) logs.Warn.Println("s.login: unknown authentication scheme", msg.Login.Scheme, s.sid)
s.queueOut(ErrAuthUnknownScheme(msg.Id, "", msg.Timestamp)) s.queueOut(ErrAuthUnknownScheme(msg.Id, "", msg.Timestamp))
return return
@@ -1180,7 +1179,7 @@ func (s *Session) login(msg *ClientComMessage) {
return return
} }
} }
recordSessionLoginFailure(s, msg.Login.Scheme, msg.Login.Secret, rec, msg.Id, err, msg.Timestamp) // 登录失败日志只记录触发锁定的事件(见上方 lockedNow 分支),普通失败不落库
resp := decodeStoreError(err, msg.Id, msg.Timestamp, nil) resp := decodeStoreError(err, msg.Id, msg.Timestamp, nil)
if resp.Ctrl.Code >= 500 { if resp.Ctrl.Code >= 500 {
// Log internal errors // Log internal errors
@@ -1191,7 +1190,6 @@ func (s *Session) login(msg *ClientComMessage) {
} }
if rec == nil || rec.TenantID != s.tenantID { if rec == nil || rec.TenantID != s.tenantID {
recordSessionLoginFailure(s, msg.Login.Scheme, msg.Login.Secret, rec, msg.Id, types.ErrFailed, msg.Timestamp)
logs.Warn.Println("s.login: authenticator returned invalid tenant", s.sid) logs.Warn.Println("s.login: authenticator returned invalid tenant", s.sid)
s.queueOut(decodeStoreError(types.ErrFailed, msg.Id, msg.Timestamp, nil)) s.queueOut(decodeStoreError(types.ErrFailed, msg.Id, msg.Timestamp, nil))
return return
@@ -1217,7 +1215,6 @@ func (s *Session) login(msg *ClientComMessage) {
} }
if err != nil { if err != nil {
recordSessionLoginFailure(s, msg.Login.Scheme, msg.Login.Secret, rec, msg.Id, err, msg.Timestamp)
logs.Warn.Println("s.login: user state check failed", rec.Uid, err, s.sid) logs.Warn.Println("s.login: user state check failed", rec.Uid, err, s.sid)
s.queueOut(decodeStoreError(err, msg.Id, msg.Timestamp, nil)) s.queueOut(decodeStoreError(err, msg.Id, msg.Timestamp, nil))
return return
@@ -1239,7 +1236,6 @@ func (s *Session) login(msg *ClientComMessage) {
} }
} }
if err != nil { if err != nil {
recordSessionLoginFailure(s, msg.Login.Scheme, msg.Login.Secret, rec, msg.Id, err, msg.Timestamp)
logs.Warn.Println("s.login: failed to validate credentials:", err, s.sid) logs.Warn.Println("s.login: failed to validate credentials:", err, s.sid)
s.queueOut(decodeStoreError(err, msg.Id, msg.Timestamp, nil)) s.queueOut(decodeStoreError(err, msg.Id, msg.Timestamp, nil))
} else { } else {
+3 -3
View File
@@ -282,16 +282,16 @@ func TestGroupMemberPageCacheRoundTripAndInvalidate(t *testing.T) {
t.Fatalf("cache key must be topic-prefixed for invalidation: %q", key) t.Fatalf("cache key must be topic-prefixed for invalidation: %q", key)
} }
CacheGroupMemberPage(7, key, groupMemberPageCache{ CacheGroupMemberPage(7, key, groupMemberPageCache{
Items: []types.GroupMember{{ Items: []groupMemberCache{groupMemberCacheFromType(&types.GroupMember{
ID: 43, ID: 43,
Topic: "grpAlpha", Topic: "grpAlpha",
UserID: types.Uid(100), UserID: types.Uid(100),
Role: types.GroupMemberRoleMember, Role: types.GroupMemberRoleMember,
}}, })},
NextCursor: "43", NextCursor: "43",
}) })
cached, ok := GetCachedGroupMemberPage(7, key) cached, ok := GetCachedGroupMemberPage(7, key)
if !ok || cached.NextCursor != "43" || len(cached.Items) != 1 || cached.Items[0].UserID != types.Uid(100) { if !ok || cached.NextCursor != "43" || len(cached.Items) != 1 || cached.Items[0].UserID != types.Uid(100).UserId() {
t.Fatalf("unexpected cached page: ok=%v page=%#v", ok, cached) t.Fatalf("unexpected cached page: ok=%v page=%#v", ok, cached)
} }
@@ -72,7 +72,7 @@ func TestLocalMySQLMessageCryptoFlow(t *testing.T) {
"max_results":1024, "max_results":1024,
"use_adapter":"mysql", "use_adapter":"mysql",
"adapters":{"mysql":{ "adapters":{"mysql":{
"dsn":"mysql://root:root@tcp(127.0.0.1:3306)/tinode?parseTime=true&collation=utf8mb4_0900_ai_ci", "dsn":"mysql://root:root@tcp(127.0.0.1:3306)/meeting_im?parseTime=true&collation=utf8mb4_0900_ai_ci",
"tenant_registry_db":"hotgo", "tenant_registry_db":"hotgo",
"tenant_db_routing":true, "tenant_db_routing":true,
"sql_timeout":10 "sql_timeout":10
@@ -86,7 +86,7 @@ func TestLocalMySQLMessageCryptoFlow(t *testing.T) {
store.SetAdminInternalConfigProvider(localCryptoProvider{key: base64.StdEncoding.EncodeToString([]byte("0123456789abcdef0123456789abcdef"))}) store.SetAdminInternalConfigProvider(localCryptoProvider{key: base64.StdEncoding.EncodeToString([]byte("0123456789abcdef0123456789abcdef"))})
store.InvalidateMessageEncryptionKeys(1) store.InvalidateMessageEncryptionKeys(1)
db, err := sql.Open("mysql", "root:root@tcp(127.0.0.1:3306)/tinode?parseTime=true&collation=utf8mb4_0900_ai_ci") db, err := sql.Open("mysql", "root:root@tcp(127.0.0.1:3306)/meeting_im?parseTime=true&collation=utf8mb4_0900_ai_ci")
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
+210 -24
View File
@@ -141,13 +141,13 @@ const (
cacheNamespaceTopic = "topic" cacheNamespaceTopic = "topic"
cacheNamespaceSub = "sub" cacheNamespaceSub = "sub"
cacheNamespaceGroupSettings = "group_settings" cacheNamespaceGroupSettings = "group_settings"
cacheNamespaceGroupMember = "group_member" cacheNamespaceGroupMember = "group_member:v2"
cacheNamespaceVerification = "verification" cacheNamespaceVerification = "verification"
cacheNamespaceActionToken = "action_token" cacheNamespaceActionToken = "action_token"
cacheNamespaceFriends = "friends" cacheNamespaceFriends = "friends"
cacheNamespaceBlocklist = "blocklist" cacheNamespaceBlocklist = "blocklist"
cacheNamespaceUnreadCount = "unread_count" cacheNamespaceUnreadCount = "unread_count"
cacheNamespaceGroupPage = "group_member_page" cacheNamespaceGroupPage = "group_member_page:v2"
) )
// TenantsPersistenceInterface defines tenant registry lookups used before authentication. // TenantsPersistenceInterface defines tenant registry lookups used before authentication.
@@ -1986,7 +1986,7 @@ func (groupBusinessMapper) UpsertMember(tenantID types.TenantID, member *types.G
} }
member, err = tenantAdp.TenantGroupMemberUpsert(tenantID, member) member, err = tenantAdp.TenantGroupMemberUpsert(tenantID, member)
if err == nil && member != nil { if err == nil && member != nil {
_ = CacheObject(tenantID, cacheNamespaceGroupMember, groupMemberCacheID(member.Topic, member.UserID), member) _ = CacheObject(tenantID, cacheNamespaceGroupMember, groupMemberCacheID(member.Topic, member.UserID), groupMemberCacheFromType(member))
} }
return member, err return member, err
} }
@@ -1997,11 +1997,11 @@ func (groupBusinessMapper) GetMember(tenantID types.TenantID, topic string, uid
} }
topic = strings.TrimSpace(topic) topic = strings.TrimSpace(topic)
cacheID := groupMemberCacheID(topic, uid) cacheID := groupMemberCacheID(topic, uid)
var cached types.GroupMember var cached groupMemberCache
if ok, err := GetCachedObject(tenantID, cacheNamespaceGroupMember, cacheID, &cached); err != nil { if ok, err := GetCachedObject(tenantID, cacheNamespaceGroupMember, cacheID, &cached); err != nil {
logs.Warn.Printf("cache group member[%s]: read failed: %v", cacheID, err) logs.Warn.Printf("cache group member[%s]: read failed: %v", cacheID, err)
} else if ok { } else if ok {
return &cached, nil return cached.toType(), nil
} }
tenantAdp, err := tenantBusinessAdapter(tenantID) tenantAdp, err := tenantBusinessAdapter(tenantID)
if err != nil { if err != nil {
@@ -2009,7 +2009,7 @@ func (groupBusinessMapper) GetMember(tenantID types.TenantID, topic string, uid
} }
member, err := tenantAdp.TenantGroupMemberGet(tenantID, topic, uid) member, err := tenantAdp.TenantGroupMemberGet(tenantID, topic, uid)
if err == nil && member != nil { if err == nil && member != nil {
if cacheErr := CacheObject(tenantID, cacheNamespaceGroupMember, cacheID, member); cacheErr != nil { if cacheErr := CacheObject(tenantID, cacheNamespaceGroupMember, cacheID, groupMemberCacheFromType(member)); cacheErr != nil {
logs.Warn.Printf("cache group member[%s]: write failed: %v", cacheID, cacheErr) logs.Warn.Printf("cache group member[%s]: write failed: %v", cacheID, cacheErr)
} }
} }
@@ -2030,7 +2030,7 @@ func (groupBusinessMapper) ListMembers(tenantID types.TenantID, topic string, op
opts.Cursor = strings.TrimSpace(opts.Cursor) opts.Cursor = strings.TrimSpace(opts.Cursor)
cacheKey := groupMemberPageCacheID(topic, opts) cacheKey := groupMemberPageCacheID(topic, opts)
if cached, ok := GetCachedGroupMemberPage(tenantID, cacheKey); ok { if cached, ok := GetCachedGroupMemberPage(tenantID, cacheKey); ok {
return cached.Items, cached.NextCursor, nil return groupMembersFromCaches(cached.Items), cached.NextCursor, nil
} }
tenantAdp, err := tenantBusinessAdapter(tenantID) tenantAdp, err := tenantBusinessAdapter(tenantID)
if err != nil { if err != nil {
@@ -2041,15 +2041,107 @@ func (groupBusinessMapper) ListMembers(tenantID types.TenantID, topic string, op
return nil, "", err return nil, "", err
} }
CacheGroupMemberPage(tenantID, cacheKey, groupMemberPageCache{ CacheGroupMemberPage(tenantID, cacheKey, groupMemberPageCache{
Items: members, Items: groupMemberCachesFromTypes(members),
NextCursor: nextCursor, NextCursor: nextCursor,
}) })
return members, nextCursor, nil return members, nextCursor, nil
} }
type groupMemberPageCache struct { type groupMemberPageCache struct {
Items []types.GroupMember `json:"items"` Items []groupMemberCache `json:"items"`
NextCursor string `json:"next_cursor"` NextCursor string `json:"next_cursor"`
}
type groupMemberCache struct {
ID int64 `json:"id"`
Topic string `json:"topic"`
UserID string `json:"user_id"`
Role string `json:"role"`
MemberStatus string `json:"member_status"`
Nickname string `json:"nickname,omitempty"`
ModeWant types.AccessMode `json:"mode_want"`
ModeGiven types.AccessMode `json:"mode_given"`
Public any `json:"public,omitempty"`
Trusted any `json:"trusted,omitempty"`
Pinned bool `json:"pinned"`
MuteNotifications bool `json:"mute_notifications"`
JoinSource string `json:"join_source,omitempty"`
JoinedBy string `json:"joined_by,omitempty"`
JoinedAt time.Time `json:"joined_at"`
LeftAt *time.Time `json:"left_at,omitempty"`
RemovedBy string `json:"removed_by,omitempty"`
Version int `json:"version"`
CreatedAt time.Time `json:"created_at,omitempty"`
UpdatedAt time.Time `json:"updated_at,omitempty"`
}
func groupMemberCachesFromTypes(members []types.GroupMember) []groupMemberCache {
items := make([]groupMemberCache, 0, len(members))
for i := range members {
items = append(items, groupMemberCacheFromType(&members[i]))
}
return items
}
func groupMembersFromCaches(items []groupMemberCache) []types.GroupMember {
members := make([]types.GroupMember, 0, len(items))
for i := range items {
members = append(members, *items[i].toType())
}
return members
}
func groupMemberCacheFromType(member *types.GroupMember) groupMemberCache {
if member == nil {
return groupMemberCache{}
}
return groupMemberCache{
ID: member.ID,
Topic: member.Topic,
UserID: member.UserID.UserId(),
Role: member.Role,
MemberStatus: member.MemberStatus,
Nickname: member.Nickname,
ModeWant: member.ModeWant,
ModeGiven: member.ModeGiven,
Public: member.Public,
Trusted: member.Trusted,
Pinned: member.Pinned,
MuteNotifications: member.MuteNotifications,
JoinSource: member.JoinSource,
JoinedBy: member.JoinedBy.UserId(),
JoinedAt: member.JoinedAt,
LeftAt: member.LeftAt,
RemovedBy: member.RemovedBy.UserId(),
Version: member.Version,
CreatedAt: member.CreatedAt,
UpdatedAt: member.UpdatedAt,
}
}
func (member groupMemberCache) toType() *types.GroupMember {
return &types.GroupMember{
ID: member.ID,
Topic: member.Topic,
UserID: types.ParseUserId(member.UserID),
Role: member.Role,
MemberStatus: member.MemberStatus,
Nickname: member.Nickname,
ModeWant: member.ModeWant,
ModeGiven: member.ModeGiven,
Public: member.Public,
Trusted: member.Trusted,
Pinned: member.Pinned,
MuteNotifications: member.MuteNotifications,
JoinSource: member.JoinSource,
JoinedBy: types.ParseUserId(member.JoinedBy),
JoinedAt: member.JoinedAt,
LeftAt: member.LeftAt,
RemovedBy: types.ParseUserId(member.RemovedBy),
Version: member.Version,
CreatedAt: member.CreatedAt,
UpdatedAt: member.UpdatedAt,
}
} }
func groupMemberPageCacheID(topic string, opts types.GroupMemberListOptions) string { func groupMemberPageCacheID(topic string, opts types.GroupMemberListOptions) string {
@@ -2526,6 +2618,24 @@ func (devicePushTokenMapper) Upsert(tenantID types.TenantID, token *types.Device
return tenantAdp.TenantDevicePushTokenUpsert(tenantID, token) return tenantAdp.TenantDevicePushTokenUpsert(tenantID, token)
} }
func (devicePushTokenMapper) Delete(tenantID types.TenantID, uid types.Uid, deviceID string) error {
deviceID = strings.TrimSpace(deviceID)
if uid.IsZero() || deviceID == "" {
return types.ErrMalformed
}
tenantAdp, err := tenantBusinessAdapter(tenantID)
if err != nil {
return err
}
deleter, ok := tenantAdp.(interface {
TenantDevicePushTokenDelete(types.TenantID, types.Uid, string) error
})
if !ok {
return nil
}
return deleter.TenantDevicePushTokenDelete(tenantID, uid, deviceID)
}
func (devicePushTokenMapper) GetAll(tenantID types.TenantID, uids ...types.Uid) (map[types.Uid][]types.DeviceDef, int, error) { func (devicePushTokenMapper) GetAll(tenantID types.TenantID, uids ...types.Uid) (map[types.Uid][]types.DeviceDef, int, error) {
tenantAdp, err := tenantBusinessAdapter(tenantID) tenantAdp, err := tenantBusinessAdapter(tenantID)
if err != nil { if err != nil {
@@ -2722,9 +2832,6 @@ func refreshUserProfileProjections(tenantID types.TenantID, uid types.Uid, profi
if types.GetTopicCat(sub.Topic) != types.TopicCatGrp { if types.GetTopicCat(sub.Topic) != types.TopicCatGrp {
continue continue
} }
if err := Subs.Update(tenantID, sub.Topic, uid, map[string]any{"Public": public}); err != nil {
logs.Warn.Printf("profile %s: refresh group subscription %s public failed: %v", uid, sub.Topic, err)
}
invalidateGroupMemberPages(tenantID, sub.Topic) invalidateGroupMemberPages(tenantID, sub.Topic)
} }
} }
@@ -3290,18 +3397,20 @@ func (messagesMapper) PreparePersist(tenantID types.TenantID, msg *types.Message
msg.InitTimes() msg.InitTimes()
fromUid := types.ParseUid(msg.From) fromUid := types.ParseUid(msg.From)
isGroupMessage := types.GetTopicCat(msg.Topic) == types.TopicCatGrp isGroupMessage := types.GetTopicCat(msg.Topic) == types.TopicCatGrp
if isGroupMessage && groupMsgRuntime != nil { if groupMsgRuntime != nil {
if clientMsgID != "" { if clientMsgID != "" {
claim, claimed, err := groupMsgRuntime.Claim(tenantID, msg.Topic, fromUid, clientMsgID) if isGroupMessage {
if err != nil { claim, claimed, err := groupMsgRuntime.Claim(tenantID, msg.Topic, fromUid, clientMsgID)
return nil, err if err != nil {
} return nil, err
if !claimed { }
if claim != nil && claim.Status == "done" && claim.Seq > 0 { if !claimed {
msg.SeqId = claim.Seq if claim != nil && claim.Status == "done" && claim.Seq > 0 {
return &types.MessageSaveResult{SeqId: claim.Seq, Duplicate: true}, nil msg.SeqId = claim.Seq
return &types.MessageSaveResult{SeqId: claim.Seq, Duplicate: true}, nil
}
return nil, types.ErrDuplicate
} }
return nil, types.ErrDuplicate
} }
} }
seq, shardMonth, err := nextMessageSeqWithFloor(tenantID, msg.Topic, msg.SeqId-1, msg.CreatedAt.UTC().Format("200601")) seq, shardMonth, err := nextMessageSeqWithFloor(tenantID, msg.Topic, msg.SeqId-1, msg.CreatedAt.UTC().Format("200601"))
@@ -3312,7 +3421,9 @@ func (messagesMapper) PreparePersist(tenantID types.TenantID, msg *types.Message
return nil, err return nil, err
} }
msg.SeqId = seq msg.SeqId = seq
msg.ShardMonth = shardMonth if isGroupMessage {
msg.ShardMonth = shardMonth
}
} }
if isGroupMessage && msg.ShardMonth == "" { if isGroupMessage && msg.ShardMonth == "" {
msg.ShardMonth = msg.CreatedAt.UTC().Format("200601") msg.ShardMonth = msg.CreatedAt.UTC().Format("200601")
@@ -3620,6 +3731,13 @@ func validateMessageHeaders(head types.KVMap) error {
return types.ErrMalformed return types.ErrMalformed
} }
} }
if rawReply, ok := head["x-im-reply"]; ok {
reply, err := normalizeIMReplyHeader(rawReply)
if err != nil {
return err
}
head["x-im-reply"] = reply
}
return nil return nil
} }
@@ -3672,6 +3790,74 @@ func messageHeaderString(head types.KVMap, key string) (string, bool) {
return text, text != "" return text, text != ""
} }
func normalizeIMReplyHeader(value any) (map[string]any, error) {
var reply map[string]any
switch typed := value.(type) {
case map[string]any:
reply = typed
case types.KVMap:
reply = map[string]any(typed)
case string:
if strings.TrimSpace(typed) == "" {
return nil, types.ErrMalformed
}
if err := json.Unmarshal([]byte(typed), &reply); err != nil {
return nil, types.ErrMalformed
}
default:
return nil, types.ErrMalformed
}
if reply == nil || !validIMReplySeq(reply["seq"]) {
return nil, types.ErrMalformed
}
if text, ok := reply["type"].(string); !ok || strings.TrimSpace(text) == "" {
return nil, types.ErrMalformed
}
if text, ok := reply["text"].(string); !ok || strings.TrimSpace(text) == "" {
return nil, types.ErrMalformed
}
if from, ok := reply["from"]; ok {
if text, ok := from.(string); !ok || strings.TrimSpace(text) == "" {
return nil, types.ErrMalformed
}
}
return reply, nil
}
func validIMReplySeq(value any) bool {
switch typed := value.(type) {
case json.Number:
_, err := typed.Int64()
return err == nil
case float64:
return typed > 0 && typed == float64(int64(typed))
case float32:
return typed > 0 && typed == float32(int64(typed))
case int:
return typed > 0
case int8:
return typed > 0
case int16:
return typed > 0
case int32:
return typed > 0
case int64:
return typed > 0
case uint:
return typed > 0
case uint8:
return typed > 0
case uint16:
return typed > 0
case uint32:
return typed > 0
case uint64:
return typed > 0
default:
return false
}
}
func validIMMessageHeaderToken(value string, max int) bool { func validIMMessageHeaderToken(value string, max int) bool {
value = strings.TrimSpace(value) value = strings.TrimSpace(value)
if value == "" || len(value) > max { if value == "" || len(value) > max {
+24
View File
@@ -23,6 +23,8 @@ func TestValidateMessageHeadersProductContract(t *testing.T) {
{"x-im-schema": 1, "type": "audio", "x-im-type": "voice"}, {"x-im-schema": 1, "type": "audio", "x-im-type": "voice"},
{"x-im-schema": 1, "x-im-type": "system_notice"}, {"x-im-schema": 1, "x-im-type": "system_notice"},
{"x-im-schema": 1, "x-im-type": "system_action"}, {"x-im-schema": 1, "x-im-type": "system_action"},
{"x-im-schema": 1, "x-im-type": "text", "x-im-reply": map[string]any{"seq": float64(128), "type": "text", "text": "hello", "from": "usr1"}},
{"x-im-schema": 1, "x-im-type": "text", "x-im-reply": `{"seq":128,"type":"text","text":"hello","from":"usr1"}`},
{"type": "system"}, {"type": "system"},
} }
for _, head := range valid { for _, head := range valid {
@@ -36,6 +38,10 @@ func TestValidateMessageHeadersProductContract(t *testing.T) {
{"x-im-schema": true, "x-im-type": "text"}, {"x-im-schema": true, "x-im-type": "text"},
{"x-im-schema": float64(1), "x-im-type": "unknown"}, {"x-im-schema": float64(1), "x-im-type": "unknown"},
{"type": "text", "x-im-type": "image"}, {"type": "text", "x-im-type": "image"},
{"x-im-schema": 1, "x-im-type": "text", "x-im-reply": map[string]any{"seq": 0, "type": "text", "text": "hello"}},
{"x-im-schema": 1, "x-im-type": "text", "x-im-reply": map[string]any{"seq": 1.5, "type": "text", "text": "hello"}},
{"x-im-schema": 1, "x-im-type": "text", "x-im-reply": map[string]any{"seq": 1, "type": "text"}},
{"x-im-schema": 1, "x-im-type": "text", "x-im-reply": "not-json"},
} }
for _, head := range invalid { for _, head := range invalid {
if err := validateMessageHeaders(head); err != types.ErrMalformed { if err := validateMessageHeaders(head); err != types.ErrMalformed {
@@ -145,3 +151,21 @@ func TestActionTokenCachePreservesInternalFields(t *testing.T) {
t.Fatalf("cache round trip lost fields: got %#v", got) t.Fatalf("cache round trip lost fields: got %#v", got)
} }
} }
func TestValidateMessageHeadersNormalizesReplyJSONString(t *testing.T) {
head := types.KVMap{
"x-im-schema": 1,
"x-im-type": "text",
"x-im-reply": `{"seq":128,"type":"text","text":"hello","from":"usr1"}`,
}
if err := validateMessageHeaders(head); err != nil {
t.Fatalf("validateMessageHeaders() = %v, want nil", err)
}
reply, ok := head["x-im-reply"].(map[string]any)
if !ok {
t.Fatalf("x-im-reply was not normalized to object: %#v", head["x-im-reply"])
}
if reply["seq"].(float64) != 128 || reply["type"] != "text" || reply["text"] != "hello" || reply["from"] != "usr1" {
t.Fatalf("unexpected normalized reply: %#v", reply)
}
}
+6
View File
@@ -29,6 +29,7 @@ const (
) )
type GroupSettings struct { type GroupSettings struct {
TenantID TenantID `db:"tenant_id"`
Topic string `db:"topic"` Topic string `db:"topic"`
OwnerUserID Uid `db:"-"` OwnerUserID Uid `db:"-"`
Name string `db:"name"` Name string `db:"name"`
@@ -117,6 +118,7 @@ type GroupMemberListOptions struct {
} }
type GroupInvitation struct { type GroupInvitation struct {
TenantID TenantID `db:"tenant_id"`
ID int64 `db:"id"` ID int64 `db:"id"`
InvitationID string `db:"invitation_id"` InvitationID string `db:"invitation_id"`
Topic string `db:"topic"` Topic string `db:"topic"`
@@ -136,6 +138,7 @@ type GroupInvitation struct {
} }
type GroupJoinRequest struct { type GroupJoinRequest struct {
TenantID TenantID `db:"tenant_id"`
ID int64 `db:"id"` ID int64 `db:"id"`
RequestID string `db:"request_id"` RequestID string `db:"request_id"`
Topic string `db:"topic"` Topic string `db:"topic"`
@@ -159,6 +162,7 @@ type GroupJoinRequest struct {
} }
type GroupAnnouncement struct { type GroupAnnouncement struct {
TenantID TenantID `db:"tenant_id"`
ID int64 `db:"id"` ID int64 `db:"id"`
AnnouncementID string `db:"announcement_id"` AnnouncementID string `db:"announcement_id"`
Topic string `db:"topic"` Topic string `db:"topic"`
@@ -176,6 +180,7 @@ type GroupAnnouncement struct {
} }
type GroupMute struct { type GroupMute struct {
TenantID TenantID `db:"tenant_id"`
Topic string `db:"topic"` Topic string `db:"topic"`
UserID Uid `db:"-"` UserID Uid `db:"-"`
MutedBy Uid `db:"-"` MutedBy Uid `db:"-"`
@@ -194,6 +199,7 @@ type GroupMute struct {
} }
type GroupFile struct { type GroupFile struct {
TenantID TenantID `db:"tenant_id"`
ID int64 `db:"id"` ID int64 `db:"id"`
GroupFileID string `db:"group_file_id"` GroupFileID string `db:"group_file_id"`
Topic string `db:"topic"` Topic string `db:"topic"`
+1 -1
View File
@@ -81,7 +81,7 @@
"use_adapter": "mysql", "use_adapter": "mysql",
"adapters": { "adapters": {
"mysql": { "mysql": {
"dsn": "mysql://root:oWxz*2=sb),ouW%p@tcp(mysql.118im.com:30306)/tinode?parseTime=true&collation=utf8mb4_0900_ai_ci", "dsn": "mysql://root:oWxz*2=sb),ouW%p@tcp(mysql.118im.com:30306)/meeting_im?parseTime=true&collation=utf8mb4_0900_ai_ci",
"tenant_registry_db": "hotgo", "tenant_registry_db": "hotgo",
"tenant_db_routing": true, "tenant_db_routing": true,
"max_open_conns": 64, "max_open_conns": 64,
+5 -1
View File
@@ -3374,7 +3374,11 @@ func (t *Topic) replyGetSub(sess *Session, asUid types.Uid, authLevel auth.Level
// Returning public and private only if they have changed since ifModified // Returning public and private only if they have changed since ifModified
if sendPubPriv { if sendPubPriv {
// 'sub' has nil 'public'/'trusted' in P2P topics which is OK. // 'sub' has nil 'public'/'trusted' in P2P topics which is OK.
mts.Public = sub.GetPublic() if t.cat == types.TopicCatGrp {
mts.Public = publicWithDefaultUserAvatar(sub.GetPublic(), uid.UserId())
} else {
mts.Public = sub.GetPublic()
}
mts.Trusted = sub.GetTrusted() mts.Trusted = sub.GetTrusted()
// Reporting 'private' only if it's user's own subscription. // Reporting 'private' only if it's user's own subscription.
if uid == asUid { if uid == asUid {
+3 -2
View File
@@ -7,6 +7,7 @@
package main package main
import ( import (
"errors"
"net/http" "net/http"
"time" "time"
@@ -101,7 +102,7 @@ func (t *Topic) runProxy(hub *Hub) {
s.detachSession(t.name) s.detachSession(t.name)
} }
if err := globals.cluster.topicProxyGone(t.tenantID, t.name); err != nil { if err := globals.cluster.topicProxyGone(t.tenantID, t.name); err != nil && !errors.Is(err, errClusterTopicOwnerUnavailable) {
logs.Warn.Printf("proxy topic[%s] shutdown: failed to notify master - %s", t.name, err) logs.Warn.Printf("proxy topic[%s] shutdown: failed to notify master - %s", t.name, err)
} }
@@ -164,7 +165,7 @@ func (t *Topic) handleProxyLeaveRequest(msg *ClientComMessage, killTimer *time.T
} }
} }
if err := globals.cluster.routeToTopicMaster(ProxyReqLeave, msg, t.name, msg.sess); err != nil { if err := globals.cluster.routeToTopicMaster(ProxyReqLeave, msg, t.name, msg.sess); err != nil && !errors.Is(err, errClusterTopicOwnerUnavailable) {
logs.Warn.Printf("proxy topic[%s]: route leave request from proxy to master failed - %s", t.name, err) logs.Warn.Printf("proxy topic[%s]: route leave request from proxy to master failed - %s", t.name, err)
} }
if len(t.sessions) == 0 { if len(t.sessions) == 0 {
+123
View File
@@ -302,6 +302,70 @@ func TestHandleBroadcastDataP2P(t *testing.T) {
} }
} }
func TestHandleBroadcastDataPreservesReplyHead(t *testing.T) {
numUsers := 2
helper := TopicTestHelper{}
helper.setUp(t, numUsers, types.TopicCatP2P, "p2p-test" /*attach=*/, true)
defer helper.tearDown()
reply := map[string]any{
"seq": int64(128),
"type": "text",
"text": "今天晚上一起吃饭吗?",
"from": helper.uids[1].UserId(),
}
helper.mm.EXPECT().Save(types.TenantID(1), gomock.Any(), gomock.Any(), gomock.Any()).
DoAndReturn(func(_ types.TenantID, msg *types.Message, _ []string, _ bool) (*types.MessageSaveResult, error) {
got, ok := msg.Head["x-im-reply"].(map[string]any)
if !ok {
t.Fatalf("stored x-im-reply: got %#v", msg.Head["x-im-reply"])
}
if got["seq"] != reply["seq"] || got["type"] != reply["type"] || got["text"] != reply["text"] || got["from"] != reply["from"] {
t.Fatalf("stored x-im-reply changed: got %#v want %#v", got, reply)
}
return &types.MessageSaveResult{SeqId: 1, MarkedReadBySender: true}, nil
})
from := helper.uids[0].UserId()
msg := &ClientComMessage{
AsUser: from,
Original: from,
Pub: &MsgClientPub{
Topic: "p2p",
Head: map[string]any{
"mime": "application/json",
"x-im-schema": 1,
"x-im-type": "text",
"x-im-client-mid": "mid-reply-1",
"x-im-reply": reply,
},
Content: map[string]any{"text": "好啊"},
NoEcho: true,
},
sess: helper.sessions[0],
}
helper.topic.handleClientMsg(msg)
helper.finish()
if errorMsgs, hasError := helper.hubMessages["__ERROR__"]; hasError {
t.Fatal(errorMsgs[0].Ctrl.Text)
}
if len(helper.results[1].messages) != 1 {
t.Fatalf("peer: expected 1 message, got %d", len(helper.results[1].messages))
}
data := helper.results[1].messages[0].(*ServerComMessage).Data
if data == nil {
t.Fatalf("peer response must have data")
}
got, ok := data.Head["x-im-reply"].(map[string]any)
if !ok {
t.Fatalf("broadcast x-im-reply: got %#v", data.Head["x-im-reply"])
}
if got["seq"] != reply["seq"] || got["type"] != reply["type"] || got["text"] != reply["text"] || got["from"] != reply["from"] {
t.Fatalf("broadcast x-im-reply changed: got %#v want %#v", got, reply)
}
}
func TestHandleBroadcastDataP2PRequiresRelationship(t *testing.T) { func TestHandleBroadcastDataP2PRequiresRelationship(t *testing.T) {
numUsers := 2 numUsers := 2
helper := TopicTestHelper{} helper := TopicTestHelper{}
@@ -1130,6 +1194,65 @@ func TestSaveMessageMaybeKafkaPublishesWithoutDBPersist(t *testing.T) {
} }
} }
func TestSaveMessageMaybeKafkaPreservesReplyHead(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
oldKafkaBus := kafkaBus
oldStore := store.Store
oldMessages := store.Messages
oldPublisher := publishKafkaMessagePersist
defer func() {
kafkaBus = oldKafkaBus
store.Store = oldStore
store.Messages = oldMessages
publishKafkaMessagePersist = oldPublisher
}()
kafkaBus = &kafkaRuntime{}
mockStore := mock_store.NewMockPersistentStorageInterface(ctrl)
mockStore.EXPECT().GetUid().Return(types.Uid(102))
store.Store = mockStore
store.Messages = mock_store.NewMockMessagesPersistenceInterface(ctrl)
var publishedPayload kafkaMessagePersistPayload
publishKafkaMessagePersist = func(_ context.Context, _, _ string, envelope kafkaEnvelope) error {
if err := json.Unmarshal(envelope.Payload, &publishedPayload); err != nil {
t.Fatalf("failed to unmarshal kafka payload: %v", err)
}
return nil
}
reply := map[string]any{
"seq": float64(12),
"type": "text",
"text": "quoted hello",
"from": types.Uid(2).UserId(),
}
msg := &types.Message{
TenantID: 1,
Topic: "grp-test",
SeqId: 7,
From: types.Uid(1).String(),
Head: types.KVMap{
"x-im-client-mid": "client-reply-1",
"x-im-type": "text",
"x-im-reply": reply,
},
Content: map[string]any{"text": "reply"},
}
if _, err := saveMessageMaybeKafka(context.Background(), 1, msg, nil, true); err != nil {
t.Fatalf("saveMessageMaybeKafka returned error: %v", err)
}
got, ok := publishedPayload.Message.Head["x-im-reply"].(map[string]any)
if !ok {
t.Fatalf("kafka payload x-im-reply: got %#v", publishedPayload.Message.Head["x-im-reply"])
}
if got["seq"] != reply["seq"] || got["type"] != reply["type"] || got["text"] != reply["text"] || got["from"] != reply["from"] {
t.Fatalf("kafka payload x-im-reply changed: got %#v want %#v", got, reply)
}
}
func TestShouldTryHotMessageFallback(t *testing.T) { func TestShouldTryHotMessageFallback(t *testing.T) {
if !shouldTryHotMessageFallback(types.TopicCatGrp, 159, &types.QueryOpt{Since: 159, Limit: 1}) { if !shouldTryHotMessageFallback(types.TopicCatGrp, 159, &types.QueryOpt{Since: 159, Limit: 1}) {
t.Fatal("expected fallback for latest group seq query") t.Fatal("expected fallback for latest group seq query")
+5 -1
View File
@@ -50,7 +50,11 @@ func replyCreateUser(s *Session, msg *ClientComMessage, rec *auth.Rec) {
} }
account, phone, secretAccount, phoneRegistration, err := resolveRegistrationAccount(msg.Acc.Username, msg.Acc.Phone) account, phone, secretAccount, phoneRegistration, err := resolveRegistrationAccount(msg.Acc.Username, msg.Acc.Phone)
if err != nil || !registrationSecretMatches(msg.Acc.Secret, secretAccount) { secretMatches := registrationSecretMatches(msg.Acc.Secret, secretAccount)
if !secretMatches && phoneRegistration && account != secretAccount {
secretMatches = registrationSecretMatches(msg.Acc.Secret, strings.TrimSpace(msg.Acc.Phone))
}
if err != nil || !secretMatches {
logs.Warn.Println("create user: invalid registration account fields, sid=", s.sid) logs.Warn.Println("create user: invalid registration account fields, sid=", s.sid)
s.queueOut(decodeStoreError(types.ErrPolicy, msg.Id, msg.Timestamp, s.queueOut(decodeStoreError(types.ErrPolicy, msg.Id, msg.Timestamp,
map[string]any{"what": "account", "reason": "REGISTRATION_ACCOUNT_INVALID"})) map[string]any{"what": "account", "reason": "REGISTRATION_ACCOUNT_INVALID"}))
+3
View File
@@ -84,6 +84,9 @@ func TestRegistrationSecretForAccount(t *testing.T) {
if got := string(registrationSecretForAccount([]byte("+8613800138000:secret"), "abcxyz123456")); got != "abcxyz123456:secret" { if got := string(registrationSecretForAccount([]byte("+8613800138000:secret"), "abcxyz123456")); got != "abcxyz123456:secret" {
t.Fatalf("got %q", got) t.Fatalf("got %q", got)
} }
if got := string(registrationSecretForAccount([]byte("13800138000:secret"), "abcxyz123456")); got != "abcxyz123456:secret" {
t.Fatalf("got %q", got)
}
} }
func TestValidateAccountRegistrationTenantDefaultsEnterpriseFromSession(t *testing.T) { func TestValidateAccountRegistrationTenantDefaultsEnterpriseFromSession(t *testing.T) {
+6 -6
View File
@@ -5,21 +5,21 @@
"use_adapter": "mysql", "use_adapter": "mysql",
"adapters": { "adapters": {
"postgres": { "postgres": {
"database": "tinode", "database": "meeting_im",
"dsn": "postgresql://postgres:postgres@localhost:5432/tinode?sslmode=disable" "dsn": "postgresql://postgres:postgres@localhost:5432/meeting_im?sslmode=disable"
}, },
"mysql": { "mysql": {
"database": "tinode", "database": "meeting_im",
"dsn": "mysql://root:oWxz*2=sb),ouW%p@tcp(mysql.118im.com:30306)/tinode?parseTime=true&collation=utf8mb4_0900_ai_ci", "dsn": "mysql://root:oWxz*2=sb),ouW%p@tcp(mysql.118im.com:30306)/meeting_im?parseTime=true&collation=utf8mb4_0900_ai_ci",
"tenant_registry_db": "hotgo", "tenant_registry_db": "hotgo",
"tenant_db_routing": true "tenant_db_routing": true
}, },
"rethinkdb": { "rethinkdb": {
"database": "tinode", "database": "meeting_im",
"addresses": "localhost:28015" "addresses": "localhost:28015"
}, },
"mongodb": { "mongodb": {
"database": "tinode", "database": "meeting_im",
"addresses": "localhost:27017", "addresses": "localhost:27017",
//"replica_set": "rs0", //"replica_set": "rs0",
//"auth_source": "admin", //"auth_source": "admin",