From 0ae4af0c31ba193ff43ddeb801babca270ada767 Mon Sep 17 00:00:00 2001 From: richjohnrambo Date: Wed, 16 Sep 2026 22:06:25 +0800 Subject: [PATCH] fix: fan out receipts and align tenant schema --- docs/sql/IM-设备推送Token字段补齐SQL.sql | 96 ++++++++++++++ docs/sql/IM-隐私设置邀我入群验证SQL.sql | 24 +++- .../IM-system租户接口冒烟记录-20260916.md | 125 ++++++++++++++++++ server/realtime_dispatcher.go | 2 +- server/realtime_dispatcher_test.go | 69 ++++++++++ server/topic.go | 1 + 6 files changed, 314 insertions(+), 3 deletions(-) create mode 100644 docs/sql/IM-设备推送Token字段补齐SQL.sql create mode 100644 docs/test/IM-system租户接口冒烟记录-20260916.md diff --git a/docs/sql/IM-设备推送Token字段补齐SQL.sql b/docs/sql/IM-设备推送Token字段补齐SQL.sql new file mode 100644 index 0000000..b01c020 --- /dev/null +++ b/docs/sql/IM-设备推送Token字段补齐SQL.sql @@ -0,0 +1,96 @@ +-- Keep device push token schema aligned across tenant business databases. +-- The current test environment uses: +-- default -> tinode +-- system -> meeting_im + +SET @im_schema = 'tinode'; +SET @im_sql = ( + SELECT IF( + COUNT(*) = 0, + CONCAT('ALTER TABLE ', @im_schema, '.im_device_push_tokens ADD COLUMN sdk_version VARCHAR(64) NOT NULL DEFAULT '''' AFTER platform'), + 'SELECT 1' + ) + FROM information_schema.columns + WHERE table_schema = @im_schema + AND table_name = 'im_device_push_tokens' + AND column_name = 'sdk_version' +); +PREPARE im_stmt FROM @im_sql; +EXECUTE im_stmt; +DEALLOCATE PREPARE im_stmt; + +SET @im_sql = ( + SELECT IF( + COUNT(*) = 0, + CONCAT('ALTER TABLE ', @im_schema, '.im_device_push_tokens ADD COLUMN push_env VARCHAR(32) NOT NULL DEFAULT '''' AFTER sdk_version'), + 'SELECT 1' + ) + FROM information_schema.columns + WHERE table_schema = @im_schema + AND table_name = 'im_device_push_tokens' + AND column_name = 'push_env' +); +PREPARE im_stmt FROM @im_sql; +EXECUTE im_stmt; +DEALLOCATE PREPARE im_stmt; + +SET @im_sql = ( + SELECT IF( + COUNT(*) = 0, + CONCAT('ALTER TABLE ', @im_schema, '.im_device_push_tokens ADD COLUMN notification_permission VARCHAR(32) NOT NULL DEFAULT '''' AFTER push_env'), + 'SELECT 1' + ) + FROM information_schema.columns + WHERE table_schema = @im_schema + AND table_name = 'im_device_push_tokens' + AND column_name = 'notification_permission' +); +PREPARE im_stmt FROM @im_sql; +EXECUTE im_stmt; +DEALLOCATE PREPARE im_stmt; + +SET @im_schema = 'meeting_im'; +SET @im_sql = ( + SELECT IF( + COUNT(*) = 0, + CONCAT('ALTER TABLE ', @im_schema, '.im_device_push_tokens ADD COLUMN sdk_version VARCHAR(64) NOT NULL DEFAULT '''' AFTER platform'), + 'SELECT 1' + ) + FROM information_schema.columns + WHERE table_schema = @im_schema + AND table_name = 'im_device_push_tokens' + AND column_name = 'sdk_version' +); +PREPARE im_stmt FROM @im_sql; +EXECUTE im_stmt; +DEALLOCATE PREPARE im_stmt; + +SET @im_sql = ( + SELECT IF( + COUNT(*) = 0, + CONCAT('ALTER TABLE ', @im_schema, '.im_device_push_tokens ADD COLUMN push_env VARCHAR(32) NOT NULL DEFAULT '''' AFTER sdk_version'), + 'SELECT 1' + ) + FROM information_schema.columns + WHERE table_schema = @im_schema + AND table_name = 'im_device_push_tokens' + AND column_name = 'push_env' +); +PREPARE im_stmt FROM @im_sql; +EXECUTE im_stmt; +DEALLOCATE PREPARE im_stmt; + +SET @im_sql = ( + SELECT IF( + COUNT(*) = 0, + CONCAT('ALTER TABLE ', @im_schema, '.im_device_push_tokens ADD COLUMN notification_permission VARCHAR(32) NOT NULL DEFAULT '''' AFTER push_env'), + 'SELECT 1' + ) + FROM information_schema.columns + WHERE table_schema = @im_schema + AND table_name = 'im_device_push_tokens' + AND column_name = 'notification_permission' +); +PREPARE im_stmt FROM @im_sql; +EXECUTE im_stmt; +DEALLOCATE PREPARE im_stmt; diff --git a/docs/sql/IM-隐私设置邀我入群验证SQL.sql b/docs/sql/IM-隐私设置邀我入群验证SQL.sql index a629584..7c7f5fc 100644 --- a/docs/sql/IM-隐私设置邀我入群验证SQL.sql +++ b/docs/sql/IM-隐私设置邀我入群验证SQL.sql @@ -1,14 +1,34 @@ -- Add personal privacy switch for requiring approval when others invite the user into a group. -- Default is off to preserve existing behavior. +-- Run this for every tenant business database. The current test environment uses: +-- default -> tinode +-- system -> meeting_im +SET @im_schema = 'tinode'; SET @im_sql = ( SELECT IF( COUNT(*) = 0, - 'ALTER TABLE tinode.im_user_privacy_settings ADD COLUMN group_invite_verification TINYINT NOT NULL DEFAULT 0 AFTER read_receipts', + CONCAT('ALTER TABLE ', @im_schema, '.im_user_privacy_settings ADD COLUMN group_invite_verification TINYINT NOT NULL DEFAULT 0 AFTER read_receipts'), 'SELECT 1' ) FROM information_schema.columns - WHERE table_schema = 'tinode' + WHERE table_schema = @im_schema + AND table_name = 'im_user_privacy_settings' + AND column_name = 'group_invite_verification' +); +PREPARE im_stmt FROM @im_sql; +EXECUTE im_stmt; +DEALLOCATE PREPARE im_stmt; + +SET @im_schema = 'meeting_im'; +SET @im_sql = ( + SELECT IF( + COUNT(*) = 0, + CONCAT('ALTER TABLE ', @im_schema, '.im_user_privacy_settings ADD COLUMN group_invite_verification TINYINT NOT NULL DEFAULT 0 AFTER read_receipts'), + 'SELECT 1' + ) + FROM information_schema.columns + WHERE table_schema = @im_schema AND table_name = 'im_user_privacy_settings' AND column_name = 'group_invite_verification' ); diff --git a/docs/test/IM-system租户接口冒烟记录-20260916.md b/docs/test/IM-system租户接口冒烟记录-20260916.md new file mode 100644 index 0000000..54dff87 --- /dev/null +++ b/docs/test/IM-system租户接口冒烟记录-20260916.md @@ -0,0 +1,125 @@ +# IM system 租户接口冒烟记录 - 2026-09-16 + +## 环境 + +- Base URL: `https://api-test.118im.com` +- Tenant Code: `system` +- API Key: 新测试环境 Key,接口解析返回的 WebSocket 地址可用 +- 测试账号: 本次临时注册 `s832453991` / `17832453991` +- 测试时间: `2026-09-16 11:40 CST` + +## 结论 + +`system` 租户登录、注册、WebSocket 登录和大部分登录后接口可用;本轮确认 1 个确定后端问题: + +`GET /v1/im/me/privacy` 在 `system` 租户下返回 `500 INTERNAL_ERROR`。该问题已在 `2026-09-16 12:27 CST` 通过补齐 `meeting_im.im_user_privacy_settings.group_invite_verification` 字段修复,复测返回 200。 + +初步根因是 `system` 租户实际业务库 `meeting_im` 的 `im_user_privacy_settings` 表缺少 `group_invite_verification` 字段,而当前代码查询和更新都会使用该字段。 + +## 确认通过 + +| 接口 | 结果 | +| --- | --- | +| `POST /v1/im/tenants/resolve` | 200,返回 `tenant.code=system` | +| `POST /v1/im/auth/verification-codes` | 200 | +| `POST /v1/im/auth/verification-codes/{challenge_id}/verify` | 200,固定验证码 `123456` 可用 | +| WebSocket `hi` | 201 | +| WebSocket `acc user:new` | 200,新账号创建成功 | +| WebSocket `login scheme=token` | 200 | +| WebSocket `sub me` | 200 | +| `GET /v1/im/bootstrap` | 200 | +| `GET /v1/im/me` | 200 | +| `GET /v1/im/me/profile` | 200 | +| `GET /v1/im/security/realname` | 200 | +| `GET /v1/im/workbench` | 200,返回 `enterprise.tenant_code=system` | +| `GET /v1/im/organization/departments` | 200 | +| `GET /v1/im/organization/members?limit=20` | 200 | +| `GET /v1/im/organization/search?q=1&limit=20` | 200 | +| `GET /v1/im/attendance/config` | 200 | +| `GET /v1/im/attendance/records?limit=20` | 200 | +| `GET /v1/im/meeting-notes?limit=20` | 200 | +| `GET /v1/im/meetings?limit=20` | 200 | +| `GET /v1/im/reports/abuse/reasons?target_type=user` | 200 | +| `GET /v1/im/reports/abuse/reasons?target_type=group` | 200 | +| `GET /v1/im/reports/daily/statistics?date=2026-09-16` | 200 | +| `GET /v1/im/reports/daily?limit=20` | 200 | +| `GET /v1/im/reports/monthly/statistics?month=2026-09` | 200 | +| `GET /v1/im/reports/monthly?limit=20` | 200 | +| `GET /v1/im/executive-mailbox/config` | 200 | +| `GET /v1/im/feedback/config` | 200 | +| `GET /v1/im/app/releases/latest?platform=ios¤t_version=1.0.0&channel=stable` | 200 | +| `GET /v1/im/search/conversations?q=&limit=20` | 200 | +| `GET /v1/im/forward-targets?q=&limit=20` | 200 | +| `GET /v1/im/system-notifications?limit=20` | 200 | +| `GET /v1/im/favorites?limit=20` | 200 | +| `GET /v1/im/groups?limit=20` | 200 | +| `GET /v1/im/calls?limit=20` | 200 | + +## 问题记录 + +### P0: `GET /v1/im/me/privacy` 返回 500 + +请求: + +```http +GET /v1/im/me/privacy +X-Tenant-Code: system +X-IM-Schema: 1 +Authorization: Bearer +``` + +响应: + +```json +{ + "code": 90000, + "reason": "INTERNAL_ERROR", + "message": "internal error" +} +``` + +数据库检查: + +- `hotgo.im_tenant` 中 `system` 租户:`db_name=meeting_im`,`db_dsn=''`。 +- `meeting_im.im_user_privacy_settings` 当前字段包含: + - `id` + - `user_id` + - `add_me_methods` + - `profile_visibility` + - `last_seen_visibility` + - `read_receipts` + - `version` + - `created_at` + - `updated_at` +- `tinode.im_user_privacy_settings` 比 `meeting_im` 多一个字段: + - `group_invite_verification` + +代码使用点: + +- `/Users/youshunlai/go_workspace/im_server/server/db/mysql/tenant_adapter.go:6613` +- `/Users/youshunlai/go_workspace/im_server/server/db/mysql/tenant_adapter.go:7194` + +判断: + +`system` 租户库 `meeting_im` 未执行最新隐私设置表结构迁移。当前代码查询 `group_invite_verification` 字段时会在该库报 SQL 错误,最终 HTTP 统一包装成 `500 INTERNAL_ERROR`。 + +处理结果: + +- 测试库 `mysql.118im.com:30306/tinode` 已有该字段。 +- 测试库 `mysql.118im.com:30306/meeting_im` 已补字段。 +- 本地库 `127.0.0.1:3306/tinode` 已补字段。 +- 本地库 `127.0.0.1:3306/meeting_im` 已补字段。 +- 复测 `GET /v1/im/me/privacy` 返回 200,响应包含 `group_invite_verification:false`。 + +## 误测排除 + +以下接口第一次测试时返回 400/405,是因为请求参数或方法不完整,不属于本轮确认问题: + +- `GET /v1/im/reports/abuse/reasons`:需要 `target_type=user|group`。 +- `GET /v1/im/reports/daily/statistics`:需要 `date=YYYY-MM-DD`。 +- `GET /v1/im/reports/monthly/statistics`:需要 `month=YYYY-MM`。 +- `GET /v1/im/reports/daily/reminders`:实现为 `POST`,GET 返回 405 属正常方法限制。 + +## 额外注意 + +旧 API Key 访问 `system` 或 `default` 都返回 `403 API_KEY_REQUIRED`;新测试环境应使用租户解析接口返回的新 Key 和 WebSocket 地址。 diff --git a/server/realtime_dispatcher.go b/server/realtime_dispatcher.go index e917e68..fcf0ef9 100644 --- a/server/realtime_dispatcher.go +++ b/server/realtime_dispatcher.go @@ -300,7 +300,7 @@ func realtimeMessageDedupKey(target string, msg *ServerComMessage) string { return "data:" + topic + ":" + strconv.FormatInt(msg.Data.SeqId, 10) } if msg.Info != nil { - return "info:" + target + ":" + msg.Info.Event + ":" + strconv.FormatInt(msg.Info.SeqId, 10) + return "info:" + target + ":" + msg.Info.What + ":" + msg.Info.From + ":" + msg.Info.Event + ":" + strconv.FormatInt(msg.Info.SeqId, 10) } if msg.Pres != nil { return "pres:" + target + ":" + msg.Pres.What + ":" + strconv.FormatInt(msg.Pres.SeqId, 10) diff --git a/server/realtime_dispatcher_test.go b/server/realtime_dispatcher_test.go index 88cce72..1951273 100644 --- a/server/realtime_dispatcher_test.go +++ b/server/realtime_dispatcher_test.go @@ -149,6 +149,75 @@ func TestRealtimeSessionControlStaleEvictDropped(t *testing.T) { } } +func TestRealtimeP2PTopicFallbackRewritesVisibleInfoTopic(t *testing.T) { + oldStore := globals.sessionStore + oldHub := globals.hub + defer func() { + globals.sessionStore = oldStore + globals.hub = oldHub + }() + + tenantID := types.TenantID(1) + uid1 := types.Uid(1001) + uid2 := types.Uid(1002) + topic := uid1.P2PName(uid2) + if topic == "" { + t.Fatal("failed to create p2p topic") + } + globals.hub = nil + globals.sessionStore = realtimeTestSessionStore( + realtimeTestSession("sid-u1", tenantID, uid1, uid2.UserId()), + realtimeTestSession("sid-u2", tenantID, uid2, uid1.UserId()), + ) + msg := &ServerComMessage{ + Info: &MsgServerInfo{Topic: topic, From: uid1.UserId(), What: "read", SeqId: 13}, + } + raw, err := json.Marshal(msg) + if err != nil { + t.Fatal(err) + } + + deliverRealtimeFanoutEnvelope(&store.RealtimeFanoutEnvelope{ + TenantID: tenantID, + Kind: realtimeKindMessage, + TargetType: realtimeTargetTopic, + Target: topic, + Message: raw, + }) + + expectTopic := map[string]string{"sid-u1": uid2.UserId(), "sid-u2": uid1.UserId()} + for sid, visibleTopic := range expectTopic { + sess := globals.sessionStore.Get(sid) + select { + case got := <-sess.send: + info := got.(*ServerComMessage).Info + if info.Topic != visibleTopic { + t.Fatalf("%s got info topic %q, want %q", sid, info.Topic, visibleTopic) + } + if info.What != "read" || info.SeqId != 13 || info.From != uid1.UserId() { + t.Fatalf("%s got unexpected info %#v", sid, info) + } + default: + t.Fatalf("expected p2p info delivery to %s", sid) + } + } +} + +func TestRealtimeInfoDedupKeySeparatesReadRecv(t *testing.T) { + read := realtimeMessageDedupKey("p2pABC", &ServerComMessage{ + Info: &MsgServerInfo{From: "usrA", What: "read", SeqId: 12}, + }) + recv := realtimeMessageDedupKey("p2pABC", &ServerComMessage{ + Info: &MsgServerInfo{From: "usrA", What: "recv", SeqId: 12}, + }) + if read == recv { + t.Fatalf("read and recv info dedup keys must differ: %q", read) + } + if read != "info:p2pABC:read:usrA::12" { + t.Fatalf("unexpected read dedup key %q", read) + } +} + func realtimeTestSessionStore(sessions ...*Session) *SessionStore { ss := NewSessionStore(time.Minute) for _, sess := range sessions { diff --git a/server/topic.go b/server/topic.go index 2e161fd..5da320e 100644 --- a/server/topic.go +++ b/server/topic.go @@ -1852,6 +1852,7 @@ func (t *Topic) handleNoteBroadcast(msg *ClientComMessage) { } t.broadcastToSessions(info) + publishRealtimeTopicMessage(t.name, info) } // handlePresence fans out {pres} messages to recipients in topic.