Compare commits
25
Commits
7c1fab5065
...
5cea4d1e2e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5cea4d1e2e | ||
|
|
5ce04bad05 | ||
|
|
48ca3ae684 | ||
|
|
75216c311d | ||
|
|
9893452817 | ||
|
|
a3df9d269c | ||
|
|
68a0228dff | ||
|
|
da02eae6a4 | ||
|
|
3e92021254 | ||
|
|
aa9f562e1e | ||
|
|
204043382c | ||
|
|
7d4bc4d049 | ||
|
|
134ac8580e | ||
|
|
40aba2a3f5 | ||
|
|
6bbe955e75 | ||
|
|
b8b21d3548 | ||
|
|
a0d0bf574e | ||
|
|
c4355ac236 | ||
|
|
ac20f1ed25 | ||
|
|
b413560455 | ||
|
|
6303d07580 | ||
|
|
7f0903cd31 | ||
|
|
35a70ce9ff | ||
|
|
6fa3009987 | ||
|
|
a968fd252e |
+2
-2
@@ -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"
|
||||||
},
|
},
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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?​parseTime=true​&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?​parseTime=true​&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@​localhost:5432/tinode?​sslmode=disable​&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@​localhost:5432/meeting_im?​sslmode=disable​&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". |
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 ----
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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."
|
||||||
|
|||||||
@@ -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 幂等、重试和安全
|
||||||
|
|||||||
@@ -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
@@ -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);
|
|
||||||
|
|||||||
@@ -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, "`", "``") + "`"
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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
@@ -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() {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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).
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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))"
|
||||||
|
|||||||
@@ -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":
|
||||||
|
|||||||
@@ -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(¤t)
|
err = tx.QueryRowxContext(ctx, "SELECT * FROM im_group_join_requests WHERE tenant_id=? AND topic=? AND request_id=? FOR UPDATE", id, topic, requestID).StructScan(¤t)
|
||||||
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(¤tVersion)
|
err = tx.QueryRowContext(ctx, "SELECT COALESCE(MAX(version),0) FROM im_group_announcements WHERE tenant_id=? AND topic=?", id, ann.Topic).Scan(¤tVersion)
|
||||||
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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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).
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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()
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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 {
|
||||||
|
|||||||
@@ -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
@@ -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 {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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 {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
@@ -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"}))
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user