feat: complete IM P0 and P1 reliability work

This commit is contained in:
richjohnrambo
2026-08-17 13:43:27 +08:00
parent ddf0f7a55e
commit f2c229135b
43 changed files with 4512 additions and 1181 deletions
+33
View File
@@ -0,0 +1,33 @@
这些图片中的内容是关于IM即时通讯的软件,你识别图片中的内容,整理为需求文档,包含图片,功能说明
上面是一张图片,下面就是说明,功能的标题就是图片的名称,说明的时候将功能相关的放一起,我希望是这样的
1.相关功能图片的总体功能(需要你总结一个标题)
1.1 图片名称
图片
功能说明:
图片内容的描述
写成一个md文档,要在md文档中可以看到图片
密码aes 加解密 密码能够加解密
聊天内容加密
聊天内容的监控
用户的类别
后台生成的有标识
登录设备做标识 移动
登基日志 连续N天设备登录
多通过配置的方式处理 前端不要写死
一定要考虑好群聊的推送问题
离线和在线状态存到redis里面
离线的不推
离线的我们的服务不推 走极光的推送
多用开关
admin后台群操作通过kafka投递到c端 c端通过ws传到客户端
记录先放到Kafka、 再异步落库。用考虑重复落库的可能
1. 有 IM 即时通讯项目开发经验优先;
2. 熟悉消息可靠性、消息时序、ACK 机制、消息去重、离线消息处理;
3. 熟悉群消息扩散策略,如读扩散、写扩散;
4. 理解多端同步、消息漫游、消息推送等 IM 核心机制;
5. 有 OpenIM 或类似 IM 项目经验优先。
+54 -16
View File
@@ -159,6 +159,44 @@ CREATE TABLE IF NOT EXISTS tinode.im_system_notifications (
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_http_idempotency (
id BIGINT NOT NULL AUTO_INCREMENT,
user_id BIGINT NOT NULL,
method VARCHAR(8) NOT NULL,
route VARCHAR(512) NOT NULL,
idempotency_key VARCHAR(128) NOT NULL,
body_hash CHAR(64) NOT NULL,
state VARCHAR(16) NOT NULL DEFAULT 'processing',
response_status INT NOT NULL DEFAULT 0,
response_body MEDIUMBLOB,
expires_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_http_idempotency_scope (user_id, method, route, idempotency_key),
INDEX idx_im_http_idempotency_expiry (expires_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
CREATE TABLE IF NOT EXISTS tinode.im_business_events_outbox (
id BIGINT NOT NULL AUTO_INCREMENT,
event_id VARCHAR(64) NOT NULL,
target_type VARCHAR(16) NOT NULL,
target VARCHAR(128) NOT NULL,
actor_user_id BIGINT NOT NULL DEFAULT 0,
event_type VARCHAR(64) NOT NULL,
payload JSON,
status VARCHAR(16) NOT NULL DEFAULT 'pending',
attempts INT NOT NULL DEFAULT 0,
next_retry_at DATETIME(3),
delivered_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_business_events_outbox_event (event_id),
INDEX idx_im_business_events_outbox_pending (status, next_retry_at, id),
INDEX idx_im_business_events_outbox_target (target_type, target, id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
CREATE TABLE IF NOT EXISTS tinode.im_message_revisions (
id BIGINT NOT NULL AUTO_INCREMENT,
createdat DATETIME(3) NOT NULL,
@@ -239,11 +277,11 @@ CREATE TABLE IF NOT EXISTS tinode.im_favorites (
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
-- Existing Tinode tables must match the current schema for chapters 2-7:
-- topics.aux, fileuploads.etag, and filemsglinks.topic/userid are required by current code.
-- im_topics.aux, im_fileuploads.etag, and im_filemsglinks.topic/userid are required by current code.
SET @im_sql = IF(
(SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = 'tinode' AND TABLE_NAME = 'messages' AND COLUMN_NAME = 'client_msg_id') = 0,
'ALTER TABLE tinode.messages ADD COLUMN client_msg_id VARCHAR(64) AFTER `from`',
(SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = 'tinode' AND TABLE_NAME = 'im_messages' AND COLUMN_NAME = 'client_msg_id') = 0,
'ALTER TABLE tinode.im_messages ADD COLUMN client_msg_id VARCHAR(64) AFTER `from`',
'SELECT 1'
);
PREPARE im_stmt FROM @im_sql;
@@ -251,8 +289,8 @@ EXECUTE im_stmt;
DEALLOCATE PREPARE im_stmt;
SET @im_sql = IF(
(SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = 'tinode' AND TABLE_NAME = 'messages' AND COLUMN_NAME = 'version') = 0,
'ALTER TABLE tinode.messages ADD COLUMN version INT NOT NULL DEFAULT 1 AFTER client_msg_id',
(SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = 'tinode' AND TABLE_NAME = 'im_messages' AND COLUMN_NAME = 'version') = 0,
'ALTER TABLE tinode.im_messages ADD COLUMN version INT NOT NULL DEFAULT 1 AFTER client_msg_id',
'SELECT 1'
);
PREPARE im_stmt FROM @im_sql;
@@ -260,8 +298,8 @@ EXECUTE im_stmt;
DEALLOCATE PREPARE im_stmt;
SET @im_sql = IF(
(SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = 'tinode' AND TABLE_NAME = 'messages' AND COLUMN_NAME = 'recalled_at') = 0,
'ALTER TABLE tinode.messages ADD COLUMN recalled_at DATETIME(3) AFTER version',
(SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = 'tinode' AND TABLE_NAME = 'im_messages' AND COLUMN_NAME = 'recalled_at') = 0,
'ALTER TABLE tinode.im_messages ADD COLUMN recalled_at DATETIME(3) AFTER version',
'SELECT 1'
);
PREPARE im_stmt FROM @im_sql;
@@ -269,8 +307,8 @@ EXECUTE im_stmt;
DEALLOCATE PREPARE im_stmt;
SET @im_sql = IF(
(SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = 'tinode' AND TABLE_NAME = 'messages' AND COLUMN_NAME = 'recalled_by') = 0,
'ALTER TABLE tinode.messages ADD COLUMN recalled_by BIGINT AFTER recalled_at',
(SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = 'tinode' AND TABLE_NAME = 'im_messages' AND COLUMN_NAME = 'recalled_by') = 0,
'ALTER TABLE tinode.im_messages ADD COLUMN recalled_by BIGINT AFTER recalled_at',
'SELECT 1'
);
PREPARE im_stmt FROM @im_sql;
@@ -278,8 +316,8 @@ EXECUTE im_stmt;
DEALLOCATE PREPARE im_stmt;
SET @im_sql = IF(
(SELECT COUNT(*) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = 'tinode' AND TABLE_NAME = 'messages' AND INDEX_NAME = 'messages_sender_client_mid') = 0,
'CREATE UNIQUE INDEX messages_sender_client_mid ON tinode.messages(`from`, client_msg_id)',
(SELECT COUNT(*) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = 'tinode' AND TABLE_NAME = 'im_messages' AND INDEX_NAME = 'messages_sender_client_mid') = 0,
'CREATE UNIQUE INDEX messages_sender_client_mid ON tinode.im_messages(`from`, client_msg_id)',
'SELECT 1'
);
PREPARE im_stmt FROM @im_sql;
@@ -291,8 +329,8 @@ DROP TABLE IF EXISTS tinode.im_tenant_config;
DROP TABLE IF EXISTS tinode.im_tenant;
SET @im_sql = IF(
(SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = 'tinode' AND TABLE_NAME = 'auth' AND COLUMN_NAME = 'tenant_id') > 0,
'ALTER TABLE tinode.auth DROP COLUMN tenant_id',
(SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = 'tinode' AND TABLE_NAME = 'im_auth' AND COLUMN_NAME = 'tenant_id') > 0,
'ALTER TABLE tinode.im_auth DROP COLUMN tenant_id',
'SELECT 1'
);
PREPARE im_stmt FROM @im_sql;
@@ -300,8 +338,8 @@ EXECUTE im_stmt;
DEALLOCATE PREPARE im_stmt;
SET @im_sql = IF(
(SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = 'tinode' AND TABLE_NAME = 'credentials' AND COLUMN_NAME = 'tenant_id') > 0,
'ALTER TABLE tinode.credentials DROP COLUMN tenant_id',
(SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = 'tinode' AND TABLE_NAME = 'im_credentials' AND COLUMN_NAME = 'tenant_id') > 0,
'ALTER TABLE tinode.im_credentials DROP COLUMN tenant_id',
'SELECT 1'
);
PREPARE im_stmt FROM @im_sql;
@@ -732,4 +770,4 @@ VALUES (1, '["bug","suggestion","complaint","other"]', 1000, '{"max_files":5,"pu
ON DUPLICATE KEY UPDATE updated_at = updated_at;
-- Current MySQL adapter schema version expected by server/db/mysql/adapter.go.
UPDATE tinode.kvmeta SET `value` = 123 WHERE `key` = 'version';
UPDATE tinode.im_kvmeta SET `value` = 123 WHERE `key` = 'version';
@@ -1,8 +1,9 @@
# IM 第一至十一章后续增强项
> 日期:2026-08-13
> 更新日期:2026-08-17
> 范围:第一章登录注册与安全、第二章消息首页/导航/系统通知、第三章单聊关系、第四章群消息与 @、第五章通讯录/好友/黑名单、第六章群聊创建/资料/治理、第七章文件/收藏/聊天记录检索、第八章实时语音与视频通话、第九章企业工作台与组织架构、第十章个人设置与客户端能力、第十一章反诈安全/举报/反馈
> 用途:记录当前已完成基线之外的增强项,后续统一排期处理。
> 状态说明:2026-08-17 已完成的 P0/P1 已移入各章“当前已完成基线”,不再作为后续待办。完整修复记录见 `IM-需求实现情况审核报告.md`。
## 1. 第一章:登录、注册与安全
@@ -17,15 +18,17 @@
7. 实名认证状态查询与提交,证件信息脱敏/保护存储。
8. 独立密码设置与验证,包含弱密码规则和失败锁定。
9. 登录后 bootstrap 返回实名、安全与菜单基础能力。
10. 产品 HTTP 已统一校验 API Key、Schema 和 Request ID。
11. 登录态写接口已接入租户库幂等记录,支持请求摘要冲突检测和首次响应重放。
12. 风险确认已生成可重试安全事件和用户系统通知。
### 1.2 后续增强项
| 优先级 | 增强项 | 说明 | 建议验收点 |
| --- | --- | --- | --- |
| P0 | HTTP `Idempotency-Key` 统一存储与请求摘要冲突检测 | 当前部分接口要求幂等键,但还没有统一的 `tenant_id + uid + method + route + key + body_hash` 处理层。 | 相同键相同 body 返回同一结果;相同键不同 body 返回冲突。 |
| P0 | 验证码真实发送通道 | 当前验证码生成、校验和限频链路已具备,短信/邮件供应商发送仍需接入真实服务。 | 验证码可通过供应商下发;供应商失败有可观测错误和重试策略。 |
| P0 | 验证码限频维度补强 | 需要按租户、手机号、IP、设备、场景组合限频,并落库或接入 Redis。 | 高频请求返回 `VERIFICATION_RATE_LIMITED`,不同场景互不误伤。 |
| P0 | 安全事件通知联动 | 密码重置、实名提交/审核、独立密码变更、新设备登录等应写入系统通知。 | 操作成功后 `/v1/im/system-notifications` 能看到对应事件。 |
| P1 | 安全事件通知覆盖扩展 | 风险确认已接入统一事件和系统通知;密码重置、实名审核、独立密码变更、新设备登录仍需补齐。 | 操作成功后 `/v1/im/system-notifications` 能看到对应事件且重试不重复入箱。 |
| P1 | 实名审核工作流 | 当前支持提交和状态查询,后续需要后台审核、驳回原因、通过后状态变更。 | 审核通过后 `realname.status=verified`;驳回可再次提交。 |
| P1 | 独立密码用途扩展 | 当前按 `purpose` 存储,可继续接入青少年模式关闭、敏感设置修改等业务入口。 | 不同 `purpose` 相互隔离;错误次数和锁定互不串扰。 |
| P1 | 设备与会话管理 | 需要登录设备列表、主动踢出设备、Token 刷新/撤销审计。 | 修改密码或踢设备后对应 Token 立即不可用。 |
@@ -39,25 +42,24 @@
1. `GET /v1/im/bootstrap` 返回首页菜单、能力开关、权限和 `message_policy`。
2. `GET /v1/im/search/conversations` 支持会话搜索和基础分页。
3. WebSocket 发消息支持 `Head["x-im-client-mid"]` 幂等,重连重发不重复落库/广播。
4. 消息 Header 基础校验:`x-im-schema`、`type`、`x-im-client-mid`。
5. 普通用户禁止发布 `sys` Topic,且禁止伪造 `Head.type=system`。
4. 消息 Header 已按产品协议校验 `x-im-schema`、`x-im-type`、`x-im-client-mid`,并兼容旧 `type`。
5. 普通用户禁止发布 `sys` Topic,且禁止伪造 `system`/`system_event`。
6. `GET /v1/im/topics/{topic}/messages/{seq}/receipts` 返回群消息送达/已读基础列表。
7. `POST /v1/im/topics/{topic}/messages/{seq}/recall` 支持版本校验、权限校验和时限内撤回。
8. `POST /v1/im/topics/{topic}/messages/{seq}/revisions` 支持文本消息编辑、版本递增和修订记录。
9. `GET /v1/im/system-notifications` 和 `POST /v1/im/system-notifications/{id}/read`。
10. 消息编辑/撤回已在事务内写入通用 Outbox,并向 Topic 在线成员派发可去重事件。
11. 编辑/撤回已接入全局 HTTP 幂等和响应重放。
12. 好友、群、会议和安全模块已可信生产系统通知。
### 2.2 后续增强项
| 优先级 | 增强项 | 说明 | 建议验收点 |
| --- | --- | --- | --- |
| P0 | 编辑/撤回多端实时事件广播 | 当前 HTTP 更新 DB 并返回成功,后续需要向 Topic 在线成员广播编辑/撤回事件。 | A 端撤回/编辑后,B 端无需刷新即可收到事件并更新 UI。 |
| P0 | 编辑/撤回事件 Outbox | DB 更新和实时广播应通过事务事件或 Outbox 衔接,避免更新成功但事件丢失。 | 模拟广播失败后,后台任务可补发事件。 |
| P0 | 消息变更查询占位策略 | 已撤回消息在普通拉取中应返回撤回占位,而不是仅靠客户端本地事件。 | 新设备登录拉历史时能看到撤回占位和版本。 |
| P0 | `Idempotency-Key` 接入编辑/撤回 | 编辑/撤回目前依赖乐观版本控制,后续需要同时支持 HTTP 幂等键。 | 客户端超时重试同一请求不会重复写 revision。 |
| P1 | 群回执隐私和角色策略 | 当前返回基础 read/recv 列表,后续需按租户策略、群规模、角色决定是否展示具体成员。 | 普通成员/管理员在不同策略下看到不同粒度回执。 |
| P1 | 群回执 cursor 精确分页 | 当前 cursor 是基础位置分页,后续可升级为稳定游标,避免成员变动造成跳页。 | 成员新增/删除时分页不重复、不漏主要数据。 |
| P1 | `message_policy` 租户配置化 | 当前 recall/edit 窗口和菜单能力是固定值,后续需要来自租户配置。 | 不同租户可配置不同撤回/编辑时限和菜单能力。 |
| P1 | 系统通知可信生产通道 | 当前已提供通知存储和读取接口,后续需要统一服务端生产 API/事件消费者,禁止业务散落直写。 | 安全、群、组织等模块通过统一通道创建通知。 |
| P1 | 系统通知 action 白名单 | `action.type` 和参数需要统一注册、校验和版本化。 | 非白名单 action 写入失败;客户端只收到可识别跳转。 |
| P1 | 会话搜索排序与类型过滤 | 当前按现有订阅数据过滤,后续需支持会话类型、置顶、免打扰、最近活跃排序等。 | 搜索结果与消息首页排序、筛选规则一致。 |
| P2 | 离线重连恢复编排测试 | 需要端到端覆盖 `hi -> token login -> sub me -> 恢复 Topic -> get data`。 | 断网重连后未丢消息、未重复消息、未错乱回执。 |
@@ -72,17 +74,18 @@
3. 好友申请、通过、拒绝、删除好友与单聊关系状态联动。
4. 拉黑/解除拉黑后关系上下文即时反映 `blocked_by_me`、`blocked_by_peer` 等状态。
5. 会话侧可根据 `can_send` 锁定输入框,并引导重新添加好友。
6. 好友申请、通过/拒绝、删除、拉黑/解除和备注已生成稳定关系事件,相关对端通知可去重。
7. 好友申请/审批关键路径已在业务事务内写入通知和 Outbox,其他可重复关系写由全局 HTTP 幂等保证可补写。
8. 非好友资料查询已执行 `profile_visibility`。
### 3.2 后续增强项
| 优先级 | 增强项 | 说明 | 建议验收点 |
| --- | --- | --- | --- |
| P0 | P2P 策略统一入口 | 当前核心路径已校验好友/黑名单,后续需要把发消息、通话、名片、文件、历史查看统一收敛到同一策略服务。 | 任一 P2P 业务入口对同一关系状态返回一致权限。 |
| P0 | 关系变更实时同步 | 好友通过、删除、拉黑、解除拉黑后,应向双方在线端推送关系和会话能力变化。 | A 拉黑 B 后,B 端输入框无需刷新自动锁定。 |
| P0 | 关系事件 Outbox | 关系 DB 状态变化与系统通知、会话事件、Push 需要事务级衔接。 | 模拟推送失败后后台可补发,不产生重复事件。 |
| P1 | Pending 会话体验增强 | 待处理好友申请对应的单聊页应携带申请摘要、来源、过期时间和快速处理入口。 | 进入 pending 会话可直接通过/拒绝,不需要跳转新朋友页。 |
| P1 | 黑名单历史消息策略 | 当前发送/通话阻断已具备,后续需明确拉黑后历史消息、资料、文件和已读回执可见范围。 | 被拉黑人在不同策略下看到一致的历史与资料裁剪结果。 |
| P1 | 非好友资料隐私裁剪 | 单聊上下文中的对端资料需要接入隐私设置、企业可见性和青少年模式。 | 非好友只能看到允许公开字段;好友看到扩展字段。 |
| P1 | 资料策略继续扩展 | 好友/非好友可见性已执行,企业字段级可见性和青少年模式仍需纳入同一策略。 | 企业受限字段与青少年用户无法从其他入口绕过策略。 |
| P1 | 好友关系审计 | 删除、拉黑、解除拉黑、申请处理等敏感关系操作写审计日志。 | 后台可按租户、操作者、目标用户查询操作流水。 |
| P2 | 关系状态迁移测试矩阵 | 需要覆盖 none、pending_inbound、pending_outbound、friend、blocked_by_me、blocked_by_peer 的全迁移。 | 任意状态迁移后 DB、HTTP 上下文、WS 权限一致。 |
@@ -95,6 +98,7 @@
3. 群 @ 消息头部字段校验,支持 `mentioned_user_ids`、`mention_all` 等基础结构。
4. 群 @ 权限接入成员 ACL,管理员/群主才具备 @ 全体能力。
5. 群消息读取链路保留 @ 元数据,客户端可据此展示提醒。
6. 群昵称、角色、禁言、成员和公告等群业务变更已写入群 Outbox,并生成在线事件与系统通知。
### 4.2 后续增强项
@@ -104,7 +108,6 @@
| P0 | @ 全体频控 | @ 全体需要按租户、群、角色、时间窗口限频,避免刷屏。 | 超限返回稳定错误码,管理员可配置限额。 |
| P0 | 群成员稳定游标分页 | 当前成员列表以基础 cursor 分页,后续应使用稳定游标避免成员变动跳页。 | 成员入群/退群时翻页不重复、不漏主要数据。 |
| P1 | 群成员资料隐私裁剪 | 群内非好友主页需要按群关系、好友关系、隐私设置裁剪资料字段。 | 同一成员从群内和好友主页进入时字段符合策略差异。 |
| P1 | 群昵称/角色变更事件 | 群昵称、角色、禁言、移除等变化需要实时推送给群成员并刷新成员列表缓存。 | 群主修改成员角色后在线端成员列表自动更新。 |
| P1 | @ 候选排序优化 | @ 面板候选应支持拼音首字母、最近互动、角色优先和本地缓存协同。 | 大群搜索响应稳定,候选排序符合产品规则。 |
| P1 | 群治理策略配置化 | 成员私聊、成员邀请、全员禁言、上传权限等应统一由群治理配置控制。 | 不同群可配置不同治理能力,HTTP 与 WS 权限一致。 |
| P2 | 大群性能压测 | 需要覆盖 500、2000、10000 成员下的成员列表、@ 搜索和发送性能。 | 达到目标延迟和资源占用阈值。 |
@@ -121,20 +124,21 @@
6. 群内非好友个人主页返回群角色、关系、资料和操作能力。
7. 好友备注更新、删除好友、拉黑/解除拉黑已具备。
8. 黑名单列表和批量移出黑名单已具备。
9. 好友/黑名单写操作已统一接入 HTTP 幂等、关系事件和必要的对端系统通知。
10. 用户搜索和好友申请已执行 `add_me_methods`,手机号搜索不会返回禁止该方式的非好友。
11. 好友个人主页已执行 `profile_visibility`。
### 5.2 后续增强项
| 优先级 | 增强项 | 说明 | 建议验收点 |
| --- | --- | --- | --- |
| P0 | 好友/黑名单操作统一幂等 | 拉黑、解除、删除、批量移出、好友申请等需要统一接入 `Idempotency-Key` 存储和请求摘要。 | 客户端超时重试不会重复创建申请或重复触发事件。 |
| P0 | 好友关系实时事件 | 申请、通过、拒绝、删除、备注、拉黑等状态变化需要向相关在线端实时同步。 | 两端关系页、会话页和通讯录无需刷新自动更新。 |
| P0 | 用户搜索隐私策略 | 当前搜索基线可用,后续需要接入 `allow_user_search`、添加方式、手机号可搜索等租户和个人隐私设置。 | 禁止手机号添加的用户无法通过手机号被搜索到,且不泄露存在性。 |
| P1 | 用户搜索租户策略扩展 | 个人添加方式已执行,后续还需增加租户级 `allow_user_search`、通讯录授权和存在性防探测策略。 | 租户关闭搜索或未授权通讯录时,客户端无法绕过且不泄露用户存在性。 |
| P0 | 二维码吊销与一次性策略 | 当前二维码是短期签名 Token,后续如需主动失效、刷新覆盖、扫码审计,需要落库或接入 Redis。 | 用户刷新二维码后旧码按策略失效;后台可查询扫码记录。 |
| P1 | 好友资料字段精细裁剪 | 个人主页需按好友、非好友、群内关系、黑名单、实名/青少年等规则返回不同字段。 | 不同入口返回字段符合隐私矩阵。 |
| P1 | 好友资料字段精细裁剪 | 基础 `everyone/friends/nobody` 可见性已执行,后续需按群内关系、黑名单、实名/青少年等规则裁剪到字段级。 | 不同入口返回字段符合隐私矩阵。 |
| P1 | 通讯录排序与拼音索引 | 当前支持基础 initial,后续需维护昵称/备注拼音、特殊字符分组和本地增量同步。 | 好友列表与客户端通讯录排序一致,中文首字母稳定。 |
| P1 | 黑名单占位资料 | 资料删除、离职或不可见时,黑名单列表仍需返回可解除的受控占位用户。 | 黑名单中失效用户可展示占位并成功移出。 |
| P1 | 好友申请防骚扰 | 需要按目标用户、申请人、IP、设备、来源和群来源限频,并支持拒收策略。 | 高频申请被限制;群来源申请校验双方仍在群内。 |
| P1 | 关系审计和安全通知 | 添加、删除、拉黑、解除、二维码解析等应写审计并按策略生成系统通知。 | 用户可在通知中看到关键关系事件,后台可追溯。 |
| P1 | 关系操作审计 | 关键关系系统通知已接入,添加、删除、拉黑、解除和二维码解析仍需独立审计流水。 | 后台可按租户、操作者和目标用户追溯。 |
| P2 | 批量接口部分失败语义增强 | 批量移出黑名单目前返回逐项结果,后续需要统一批量接口错误码、重试建议和幂等结果缓存。 | 部分失败时客户端能只重试失败项。 |
## 6. 第六章:群聊创建、资料维护与成员治理
@@ -149,21 +153,18 @@
6. 群治理配置 `GET/PATCH /governance` 已具备。
7. 管理员候选、管理员上限、批量添加管理员、删除管理员和群主转让接口已具备。
8. 禁言候选、批量禁言、禁言列表和解除禁言接口已具备。
9. 当前实现优先复用 Topic、Subscription 和 Topic Aux,把产品主链路接口跑通。
9. 12 张 `im_group_*` 业务表已纳入 MySQL 租户库自动初始化,独立租户库模式不保存 `tenant_id`。
10. 群邀请、入群申请/审批、成员变更、角色变更、群主转让和退群已收口到 MySQL 事务。
11. 群写操作已使用 `im_group_idempotency` 和 `im_group_events_outbox`,提交后通过 Hub 派发,失败保留 pending 重试。
12. WebSocket 群消息发布已校验成员禁言和全员禁言,支持过期恢复及管理员豁免。
13. 群公告已使用独立表并记录成员已读时间。
### 6.2 后续增强项
| 优先级 | 增强项 | 说明 | 建议验收点 |
| --- | --- | --- | --- |
| P0 | 群业务独立表拆分 | 当前群公告、进群申请、治理配置、禁言记录先存 Topic Aux,后续需要拆为独立表。 | 公告、申请、治理、禁言均有 tenant/topic 维度索引,可稳定分页和审计。 |
| P0 | 群业务表设计落地 | 已整理 `docs/IM-群聊业务表设计.sql`,后续需要按该设计接入迁移脚本和 store/adapter。 | 表结构审核通过后,群接口从 Topic Aux 切到业务表。 |
| P0 | 群治理强事务状态机 | 邀请、申请审批、管理员变更、群主转让、禁言需在事务内同步业务表、订阅 ACL、成员数和事件。 | 并发审批/转让/禁言不会出现 ACL 与业务状态不一致。 |
| P0 | 第六章幂等键落库 | 邀请、申请处理、公告发布、治理 PATCH、管理员批量、群主转让、禁言批量都需要统一幂等。 | 超时重试同一请求返回同一结果,不重复加人、升权或发事件。 |
| P0 | 系统事件、Push 与 Outbox | 第六章所有成员/公告/治理变化需要生成可信群系统事件,并通过 Outbox 保证推送。 | 在线端实时刷新;模拟推送失败后可补偿且不重复。 |
| P1 | 群公告已读独立记录 | 当前公告已读返回基础结果,后续需记录每个成员的公告已读时间。 | 新公告只提醒未读成员,已读状态跨端同步。 |
| P1 | 进群申请隐私与限频 | 申请入口需要按群治理、租户策略、用户状态、来源、IP/设备限频。 | 高频申请被限制;普通成员不可看到敏感申请人信息。 |
| P1 | 管理员/群主角色层级策略 | 管理员互相操作、管理员禁言、群主转让二次认证等需要完整策略矩阵。 | 群主、管理员、普通成员在每个治理动作上的权限稳定一致。 |
| P1 | 禁言发送链路强校验 | 当前禁言接口已具备,后续需要在 WebSocket 发消息路径校验 `GROUP_MUTED`。 | 被禁言成员发群消息被服务端拒绝,过期后自动恢复。 |
| P1 | 群文件索引 | 6.27-6.28 需要独立群文件表,绑定上传文件、群成员权限、下载状态和删除权限。 | 群文件列表可搜索/分页,退群后不可下载旧 URL。 |
| P1 | 群解散与二维码吊销 | 群解散、二维码撤销、邀请失效需要状态联动和权限回收。 | 解散后 Topic、二维码、邀请、文件新增权限全部失效。 |
| P2 | 大群性能与稳定分页 | 成员、申请、公告、禁言、文件列表都需要稳定游标和大群压测。 | 大群场景下分页不重复不漏,接口延迟达标。 |
@@ -177,12 +178,13 @@
3. Topic 消息关键词搜索、文件/图片/视频等媒体类型检索接口已具备。
4. 消息上下文定位接口可围绕指定 seq 返回前后消息。
5. 文件访问已接入用户与 Topic 权限校验,下载前可判断资源可见性。
6. 收藏等登录态 HTTP 写接口已接入全局幂等响应重放。
### 7.2 后续增强项
| 优先级 | 增强项 | 说明 | 建议验收点 |
| --- | --- | --- | --- |
| P0 | 收藏幂等与去重策略 | 收藏同一消息、同一文件或同一链接需要统一去重和幂等键处理。 | 重复点击收藏不会产生重复记录;超时重试返回同一收藏 ID。 |
| P1 | 收藏业务去重策略 | 同一 HTTP 请求的超时重试已由全局幂等保证;使用不同幂等键重复收藏同一消息、文件或链接时,仍需业务唯一性策略。 | 重复点击收藏不会产生重复记录,并返回已有收藏 ID。 |
| P0 | 收藏分享权限二次校验 | 收藏文件或消息分享给好友/群时,需要重新校验接收方是否可访问原资源或生成受控副本。 | 无权限接收方不能通过收藏分享拿到原会话附件。 |
| P0 | 文件下载短时签名 URL | 当前权限判断已具备,后续应生成短时下载 URL,并记录下载审计。 | URL 过期不可访问;退群/拉黑后旧 URL 失效或受策略限制。 |
| P0 | 文件安全扫描与 MIME 校验 | 上传后的文件需要扩展名、MIME、内容嗅探、病毒扫描和图片/视频解码安全检查。 | 高风险文件不可下载或转发,客户端收到稳定错误码。 |
@@ -229,6 +231,8 @@
6. 会议通知 `POST/GET/PATCH/CANCEL/REMIND/MY-RESPONSE` 已具备基线,包含参与人和版本冲突控制。
7. 日报/月报草稿、提交、详情、列表、统计与提醒基线已具备。
8. 总经理信箱配置与提交接口已具备,包含匿名策略、文案版本和幂等键。
9. 工作台 `tenant_code` 已返回租户注册表的公开编码,不再暴露内部 ID。
10. 会议创建、变更、取消、提醒和参会回应已生成在线事件与系统通知,关键变更在事务内写 Outbox。
### 9.2 后续增强项
@@ -240,7 +244,7 @@
| P0 | 组织权限与可见字段裁剪 | 成员手机号、邮箱、职位等字段需要按组织角色、隐私和企业策略裁剪。 | 普通员工和管理员看到的成员字段不同且不可绕过。 |
| P1 | 组织搜索索引优化 | 当前基础 LIKE 搜索可用,后续需要拼音、工号、手机号脱敏匹配和稳定排序。 | 大组织下搜索延迟达标,结果排序符合组织规则。 |
| P1 | 组织版本与增量同步 | 部门和成员返回版本,后续需要全局组织版本、变更事件和客户端增量刷新。 | 切换部门或人员变更后客户端无需全量重拉。 |
| P1 | 会议通知实时提醒 | 当前接口和表已具备,后续需要会议创建、更新、取消、响应后的在线事件和 Push。 | 参与人在线端无需刷新即可收到会议变化。 |
| P1 | 会议离线 Push 联调 | 会议在线事件、系统通知和 Outbox 已完成,后续需使用真实 Firebase 凭据和移动端验证离线通知。 | 参与人离线时收到一次提醒,点击可跳转至正确会议。 |
| P1 | 日报/月报后台统计口径 | 当前基础统计可用,后续需要按部门、周期、迟交、补交、未读等维度扩展。 | 管理员看板与业务口径一致。 |
| P1 | 总经理信箱后台流转 | 当前前台配置/提交已具备,后续需要后台处理、回复、转派、归档和通知。 | 信件全生命周期可追踪,匿名身份受保护。 |
| P1 | 办公协同审计 | 组织查询、会议变更、报告查看、信箱提交等敏感动作需要审计。 | 后台可按租户、用户、对象查询办公操作流水。 |
@@ -255,6 +259,8 @@
3. 最新版本检查 `GET /v1/im/app/releases/latest` 已具备,支持未登录按租户解析调用。
4. 青年模式查询、开启、关闭接口已具备,复用敏感操作 `action_token` 和独立密码校验链路。
5. 设备 Push Token 覆盖写入 `PUT /v1/im/devices/{device_id}/push-token` 已具备。
6. FCM 已优先使用产品 `im_device_push_tokens` 数据源,并支持通过环境变量注入 Firebase 凭据。
7. 用户搜索/加好友、个人资料、消息回执及 Tinode P2P 最后在线输出已执行对应隐私设置。
### 10.2 后续增强项
@@ -265,7 +271,7 @@
| P0 | 青年模式策略配置化 | 当前返回默认 restrictions,后续需要后台按租户配置可用时段、内容限制、消费保护等规则。 | 不同租户返回不同限制策略且能热更新。 |
| P0 | Push Token 生命周期 | 当前可覆盖写入,后续需要 token 失效回收、设备解绑、平台厂商错误码处理。 | 推送失败后自动禁用无效 token,重新登录可恢复。 |
| P1 | 版本灰度发布 | 当前按 platform/channel 查最新版本,后续需要按租户、用户、灰度比例、最低版本强制升级。 | 灰度用户收到新版本,非灰度用户保持稳定版本。 |
| P1 | 资料隐私裁剪落地 | 隐私设置需要接入用户搜索、组织架构、会话资料页等读路径。 | 非授权用户无法通过其他接口绕过隐私配置。 |
| P1 | 隐私策略入口扩展 | 产品主要搜索、资料、回执和最后在线入口已执行隐私设置,组织架构字段、文件、通话和青少年模式仍需纳入统一策略。 | 非授权用户无法通过任一业务入口绕过隐私配置。 |
| P2 | 客户端能力上报 | 后续增加设备能力、通知权限、厂商通道状态和前台活跃状态上报。 | 后台可定位推送不可达和版本兼容问题。 |
## 11. 反诈安全、举报与意见反馈
@@ -277,6 +283,7 @@
3. 举报原因 `GET /v1/im/reports/abuse/reasons` 已具备,默认覆盖用户、群、消息目标。
4. 举报提交 `POST /v1/im/reports/abuse` 已具备,支持幂等键、证据文件 ID、目标基础可见性校验和审核快照。
5. 意见反馈配置 `GET /v1/im/feedback/config`、提交 `POST /v1/im/feedback` 已具备,支持附件规则、诊断授权和幂等键。
6. 风险确认结果已生成稳定 `security.risk.*` 事件和用户可见系统通知。
### 11.2 后续增强项
@@ -287,13 +294,13 @@
| P0 | 举报目标完整权限校验 | 当前做基础可见性校验,后续需要结合用户侧删除、撤回、退群、黑名单、文件 ACL 和审核状态。 | 无权限目标无法提交举报,也不泄露目标存在性。 |
| P0 | 审核后台流转 | 举报和反馈当前仅 received,后续需要后台分派、处理、关闭、回复和通知。 | 处理状态可追踪,举报人按策略收到处理结果。 |
| P1 | 证据文件安全与保留期 | 证据文件需要 `abuse_evidence` 专用 ACL、病毒扫描、不可转发和保留期策略。 | 证据只能审核链路访问,过期按政策清理。 |
| P1 | 反诈系统事件可信生产 | 低风险提示需通过可信 `security.risk_notice` 系统事件发送,并限制普通消息伪装。 | 普通用户无法伪造反诈弹窗,客户端只信任系统事件。 |
| P1 | 主动反诈风险提示 | 风险确认通知已由服务端可信生产;低风险主动提示、通知文案策略和 Push 优先级仍需扩展。 | 普通用户无法伪造反诈弹窗,客户端只信任系统事件。 |
| P1 | 举报合并与限频 | 重复举报需要合并、返回原受理号或限频错误,避免刷单。 | 短时间重复举报同一目标不会生成大量工单。 |
| P2 | 反馈诊断包脱敏 | 诊断包需统一脱敏、大小限制、客户端日志授权和服务端下载审计。 | 未同意时没有诊断包,同意后包内不含明文 token/手机号。 |
## 12. 统一处理建议
1. 先做 P0:统一幂等、实时事件/Outbox、验证码真实发送与限频、P2P/好友/搜索隐私策略、第六章群业务独立表与事务状态机、文件下载签名、通话强状态机、工作台/组织权限。
2. 再做 P1:租户策略配置、系统通知生产通道、实名审核、设备管理、资料隐私裁剪、关系审计、群禁言发送校验、群文件索引、通话质量指标、会议/日报/月报/信箱完整业务表。
1. 优先剩余 P0:验证码真实发送与多维限频、消息撤回历史占位、P2P 文件/通话策略统一、@通知与频控、文件安全与短时授权、通话强状态机、工作台/组织权限和风控 token 业务消费。
2. 再做剩余 P1:租户策略配置、实名审核、设备/会话管理、字段级隐私矩阵、关系审计、群文件与解散状态机、通话质量指标、报表/信箱后台流转和真实 FCM 离线联调。
3. 最后做 P2:端到端重连测试、schema 版本演进、大群压测、批量接口体验优化、收藏导出、通话记录聚合和办公模块附件统一策略。
4. 每个增强项落地前同步更新 `IM-系统设计文档.md` 和 `IM-前后端对接文档.md` 中对应章节状态。
4. 每个增强项落地后同步更新本文档、`IM-系统设计文档.md`、`IM-前后端对接文档.md` 和 `IM-需求实现情况审核报告.md`,避免已完成项继续出现在待办清单。
@@ -269,48 +269,48 @@ CREATE TABLE IF NOT EXISTS im_message_revisions(
-- 如果是旧库,需要逐项核对后再 ALTER。
-- ============================================================
-- users:租户隔离字段和索引。
-- ALTER TABLE users ADD COLUMN tenant_id BIGINT NOT NULL DEFAULT 1 AFTER id;
-- CREATE INDEX users_tenant_id_id ON users(tenant_id, id);
-- im_users:租户隔离字段和索引。
-- ALTER TABLE im_users ADD COLUMN tenant_id BIGINT NOT NULL DEFAULT 1 AFTER id;
-- CREATE INDEX im_users_tenant_id_id ON im_users(tenant_id, id);
-- topics:租户隔离、群治理 Aux、租户维度唯一索引。
-- 第六章群邀请、入群审批、群公告、群治理配置、成员禁言目前存放在 topics.aux JSON。
-- im_topics:租户隔离、群治理 Aux、租户维度唯一索引。
-- 第六章群邀请、入群审批、群公告、群治理配置、成员禁言目前存放在 im_topics.aux JSON。
-- 后续若要强状态机和审计,应拆 im_group_join_requests、im_group_announcements、im_group_mutes 等表。
-- ALTER TABLE topics ADD COLUMN tenant_id BIGINT NOT NULL DEFAULT 1 AFTER id;
-- ALTER TABLE topics ADD COLUMN aux JSON AFTER tags;
-- CREATE UNIQUE INDEX topics_tenant_name ON topics(tenant_id, name);
-- CREATE INDEX topics_tenant_owner ON topics(tenant_id, owner);
-- ALTER TABLE im_topics ADD COLUMN tenant_id BIGINT NOT NULL DEFAULT 1 AFTER id;
-- ALTER TABLE im_topics ADD COLUMN aux JSON AFTER tags;
-- CREATE UNIQUE INDEX im_topics_tenant_name ON im_topics(tenant_id, name);
-- CREATE INDEX im_topics_tenant_owner ON im_topics(tenant_id, owner);
-- subscriptions:租户字段和按租户查成员索引。
-- ALTER TABLE subscriptions ADD COLUMN tenant_id BIGINT NOT NULL DEFAULT 1 AFTER id;
-- CREATE INDEX subscriptions_tenant_userid ON subscriptions(tenant_id, userid);
-- CREATE INDEX subscriptions_tenant_topic ON subscriptions(tenant_id, topic);
-- CREATE INDEX subscriptions_userid_topic_deletedat ON subscriptions(userid, topic, deletedat);
-- im_subscriptions:租户字段和按租户查成员索引。
-- ALTER TABLE im_subscriptions ADD COLUMN tenant_id BIGINT NOT NULL DEFAULT 1 AFTER id;
-- CREATE INDEX im_subscriptions_tenant_userid ON im_subscriptions(tenant_id, userid);
-- CREATE INDEX im_subscriptions_tenant_topic ON im_subscriptions(tenant_id, topic);
-- CREATE INDEX im_subscriptions_userid_topic_deletedat ON im_subscriptions(userid, topic, deletedat);
-- messages:租户、客户端消息幂等、编辑撤回字段、搜索/文件授权需要的索引。
-- ALTER TABLE messages ADD COLUMN tenant_id BIGINT NOT NULL DEFAULT 1 AFTER id;
-- ALTER TABLE messages ADD COLUMN client_msg_id VARCHAR(64) AFTER `from`;
-- ALTER TABLE messages ADD COLUMN version INT NOT NULL DEFAULT 1 AFTER client_msg_id;
-- ALTER TABLE messages ADD COLUMN recalled_at DATETIME(3) AFTER version;
-- ALTER TABLE messages ADD COLUMN recalled_by BIGINT AFTER recalled_at;
-- CREATE INDEX messages_tenant_topic ON messages(tenant_id, topic);
-- CREATE UNIQUE INDEX messages_sender_client_mid ON messages(tenant_id, `from`, client_msg_id);
-- im_messages:租户、客户端消息幂等、编辑撤回字段、搜索/文件授权需要的索引。
-- ALTER TABLE im_messages ADD COLUMN tenant_id BIGINT NOT NULL DEFAULT 1 AFTER id;
-- ALTER TABLE im_messages ADD COLUMN client_msg_id VARCHAR(64) AFTER `from`;
-- ALTER TABLE im_messages ADD COLUMN version INT NOT NULL DEFAULT 1 AFTER client_msg_id;
-- ALTER TABLE im_messages ADD COLUMN recalled_at DATETIME(3) AFTER version;
-- ALTER TABLE im_messages ADD COLUMN recalled_by BIGINT AFTER recalled_at;
-- CREATE INDEX im_messages_tenant_topic ON im_messages(tenant_id, topic);
-- CREATE UNIQUE INDEX messages_sender_client_mid ON im_messages(tenant_id, `from`, client_msg_id);
-- dellog:用户侧删除过滤需要 tenant_id。
-- ALTER TABLE dellog ADD COLUMN tenant_id BIGINT NOT NULL DEFAULT 1 AFTER id;
-- CREATE INDEX dellog_tenant_topic ON dellog(tenant_id, topic);
-- CREATE INDEX dellog_topic_deletedfor_low_hi ON dellog(topic, deletedfor, low, hi);
-- im_dellog:用户侧删除过滤需要 tenant_id。
-- ALTER TABLE im_dellog ADD COLUMN tenant_id BIGINT NOT NULL DEFAULT 1 AFTER id;
-- CREATE INDEX im_dellog_tenant_topic ON im_dellog(tenant_id, topic);
-- CREATE INDEX im_dellog_topic_deletedfor_low_hi ON im_dellog(topic, deletedfor, low, hi);
-- fileuploads:文件 ACL、租户隔离、ETag。
-- ALTER TABLE fileuploads ADD COLUMN tenant_id BIGINT NOT NULL DEFAULT 1 AFTER id;
-- ALTER TABLE fileuploads ADD COLUMN etag VARCHAR(128) AFTER size;
-- CREATE INDEX fileuploads_tenant_status ON fileuploads(tenant_id, status);
-- im_fileuploads:文件 ACL、租户隔离、ETag。
-- ALTER TABLE im_fileuploads ADD COLUMN tenant_id BIGINT NOT NULL DEFAULT 1 AFTER id;
-- ALTER TABLE im_fileuploads ADD COLUMN etag VARCHAR(128) AFTER size;
-- CREATE INDEX im_fileuploads_tenant_status ON im_fileuploads(tenant_id, status);
-- filemsglinks:文件资源级 ACL 需要租户、topic 和 userid 引用。
-- ALTER TABLE filemsglinks ADD COLUMN tenant_id BIGINT NOT NULL DEFAULT 1 AFTER id;
-- ALTER TABLE filemsglinks ADD COLUMN topic CHAR(25) AFTER msgid;
-- ALTER TABLE filemsglinks ADD COLUMN userid BIGINT AFTER topic;
-- CREATE INDEX filemsglinks_tenant_fileid ON filemsglinks(tenant_id, fileid);
-- im_filemsglinks:文件资源级 ACL 需要租户、topic 和 userid 引用。
-- ALTER TABLE im_filemsglinks ADD COLUMN tenant_id BIGINT NOT NULL DEFAULT 1 AFTER id;
-- ALTER TABLE im_filemsglinks ADD COLUMN topic CHAR(25) AFTER msgid;
-- ALTER TABLE im_filemsglinks ADD COLUMN userid BIGINT AFTER topic;
-- CREATE INDEX im_filemsglinks_tenant_fileid ON im_filemsglinks(tenant_id, fileid);
-- usertags/topictags/devices/auth/credentials 也已按租户增加 tenant_id;
-- im_usertags/im_topictags/im_devices/im_auth/im_credentials 也已按租户增加 tenant_id;
-- 若旧库缺字段,需要参照当前 server/db/mysql/schema.sql 逐项补齐。
+1 -1
View File
@@ -4,7 +4,7 @@
> 日期:2026-08-11<br>
> 状态:后续开发基线<br>
> 需求依据:[IM 产品原型需求文档](./IM-产品原型需求文档.md)<br>
> 关联文档:[IM 产品需求服务端改造分析报告](./IM-产品需求服务端改造分析报告.md)、[IM 现有实现与新需求差异对照总结](./IM-现有实现与新需求差异对照总结.md)、[IM 服务端多租户改造方案](./IM-服务端多租户改造方案.md)
> 关联文档:[IM 产品需求服务端改造分析报告](./IM-产品需求服务端改造分析报告.md)、[IM 现有实现与新需求差异对照总结](./IM-现有实现与新需求差异对照总结.md)、[IM 服务端多租户改造方案](obsolete/IM-服务端多租户改造方案.md)
| 版本 | 日期 | 变更 |
| --- | --- | --- |
+13 -15
View File
@@ -1,14 +1,12 @@
-- IM group business tables design.
-- Scope: chapter 6 group chat business truth tables.
-- Database: tenant database, default tinode.
-- Database: the currently selected tenant database.
-- Important: one tenant owns one database, so these tables intentionally do not contain tenant_id.
-- This SQL is for review first. Do not execute directly in production before confirming indexes and migration order.
CREATE DATABASE IF NOT EXISTS tinode CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;
-- 1. Group profile and governance truth.
-- Topic/Subscription remains the communication projection; this table is the product business truth.
CREATE TABLE IF NOT EXISTS tinode.im_group_settings (
CREATE TABLE IF NOT EXISTS im_group_settings (
topic VARCHAR(32) NOT NULL,
owner_user_id BIGINT NOT NULL,
name VARCHAR(128) NOT NULL DEFAULT '',
@@ -36,7 +34,7 @@ CREATE TABLE IF NOT EXISTS tinode.im_group_settings (
-- 2. Group members and role truth.
-- Role changes must update this table and the Tinode Subscription ACL in the same transaction.
CREATE TABLE IF NOT EXISTS tinode.im_group_members (
CREATE TABLE IF NOT EXISTS im_group_members (
id BIGINT NOT NULL AUTO_INCREMENT,
topic VARCHAR(32) NOT NULL,
user_id BIGINT NOT NULL,
@@ -63,7 +61,7 @@ CREATE TABLE IF NOT EXISTS tinode.im_group_members (
-- 3. Direct invitations.
-- If join_approval is off, accepting an invitation can directly create im_group_members + Subscription.
-- If join_approval is on, accepting may create a pending im_group_join_requests record.
CREATE TABLE IF NOT EXISTS tinode.im_group_invitations (
CREATE TABLE IF NOT EXISTS im_group_invitations (
id BIGINT NOT NULL AUTO_INCREMENT,
invitation_id VARCHAR(64) NOT NULL,
topic VARCHAR(32) NOT NULL,
@@ -87,7 +85,7 @@ CREATE TABLE IF NOT EXISTS tinode.im_group_invitations (
-- 4. Join requests.
-- Pending users must not have a readable Subscription before approval.
CREATE TABLE IF NOT EXISTS tinode.im_group_join_requests (
CREATE TABLE IF NOT EXISTS im_group_join_requests (
id BIGINT NOT NULL AUTO_INCREMENT,
request_id VARCHAR(64) NOT NULL,
topic VARCHAR(32) NOT NULL,
@@ -114,7 +112,7 @@ CREATE TABLE IF NOT EXISTS tinode.im_group_join_requests (
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
-- 5. Group announcements.
CREATE TABLE IF NOT EXISTS tinode.im_group_announcements (
CREATE TABLE IF NOT EXISTS im_group_announcements (
id BIGINT NOT NULL AUTO_INCREMENT,
announcement_id VARCHAR(64) NOT NULL,
topic VARCHAR(32) NOT NULL,
@@ -136,7 +134,7 @@ CREATE TABLE IF NOT EXISTS tinode.im_group_announcements (
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
-- 6. Announcement read receipts.
CREATE TABLE IF NOT EXISTS tinode.im_group_announcement_reads (
CREATE TABLE IF NOT EXISTS im_group_announcement_reads (
announcement_id VARCHAR(64) NOT NULL,
topic VARCHAR(32) NOT NULL,
user_id BIGINT NOT NULL,
@@ -149,7 +147,7 @@ CREATE TABLE IF NOT EXISTS tinode.im_group_announcement_reads (
-- 7. Current member mute state.
-- Historical changes are stored in im_group_admin_logs.
CREATE TABLE IF NOT EXISTS tinode.im_group_mutes (
CREATE TABLE IF NOT EXISTS im_group_mutes (
topic VARCHAR(32) NOT NULL,
user_id BIGINT NOT NULL,
muted_by BIGINT NOT NULL,
@@ -169,7 +167,7 @@ CREATE TABLE IF NOT EXISTS tinode.im_group_mutes (
-- 8. Group QR codes / invite tokens.
-- qr_token should remain opaque to clients. Store only token hash.
CREATE TABLE IF NOT EXISTS tinode.im_group_qrcodes (
CREATE TABLE IF NOT EXISTS im_group_qrcodes (
id BIGINT NOT NULL AUTO_INCREMENT,
token_id VARCHAR(64) NOT NULL,
token_hash CHAR(64) NOT NULL,
@@ -189,7 +187,7 @@ CREATE TABLE IF NOT EXISTS tinode.im_group_qrcodes (
-- 9. Group file directory.
-- File binary metadata still lives in the existing file table/storage. This is the group-visible index.
CREATE TABLE IF NOT EXISTS tinode.im_group_files (
CREATE TABLE IF NOT EXISTS im_group_files (
id BIGINT NOT NULL AUTO_INCREMENT,
group_file_id VARCHAR(64) NOT NULL,
topic VARCHAR(32) NOT NULL,
@@ -218,7 +216,7 @@ CREATE TABLE IF NOT EXISTS tinode.im_group_files (
-- 10. Group operation audit logs.
-- Store role changes, governance changes, mutes, announcements, owner transfer, dissolve, file delete, etc.
CREATE TABLE IF NOT EXISTS tinode.im_group_admin_logs (
CREATE TABLE IF NOT EXISTS im_group_admin_logs (
id BIGINT NOT NULL AUTO_INCREMENT,
log_id VARCHAR(64) NOT NULL,
topic VARCHAR(32) NOT NULL,
@@ -241,7 +239,7 @@ CREATE TABLE IF NOT EXISTS tinode.im_group_admin_logs (
-- 11. Group operation idempotency.
-- This can later be replaced by a global HTTP idempotency table.
CREATE TABLE IF NOT EXISTS tinode.im_group_idempotency (
CREATE TABLE IF NOT EXISTS im_group_idempotency (
id BIGINT NOT NULL AUTO_INCREMENT,
user_id BIGINT NOT NULL,
method VARCHAR(8) NOT NULL,
@@ -260,7 +258,7 @@ CREATE TABLE IF NOT EXISTS tinode.im_group_idempotency (
-- 12. Group outbox events.
-- Transactional bridge for group.membership.updated, group.join_request.created,
-- group.announcement.updated, group.role.updated, group.mute.updated, group.dissolved, etc.
CREATE TABLE IF NOT EXISTS tinode.im_group_events_outbox (
CREATE TABLE IF NOT EXISTS im_group_events_outbox (
id BIGINT NOT NULL AUTO_INCREMENT,
event_id VARCHAR(64) NOT NULL,
topic VARCHAR(32) NOT NULL,
+434
View File
@@ -0,0 +1,434 @@
# IM 产品需求实现情况审核报告
> 审核日期:2026-08-17
> 审核对象:当前仓库代码、数据库脚本及运行配置
> 主要依据:`IM-产品原型需求文档.md`、`IM-前后端对接文档.md`
> 多租户依据:`IM-独立库多租户改造方案.md`
> 明确排除:已废弃的 `IM-服务端多租户改造方案.md`
## 1. 审核说明
本报告以代码和数据库脚本的实际状态为准,不直接沿用前后端对接文档中的“现状/目标”状态。对接文档中的状态是设计阶段快照,部分“新增”接口目前已经实现,部分已注册接口仍缺少业务闭环。
判定口径:
| 状态 | 含义 |
| --- | --- |
| 已实现 | 主流程、存储和必要权限校验已落地,可进入联调 |
| 部分实现 | 已有接口或数据模型,但缺少关键策略、通知、推送、事务或异常闭环 |
| 阻断缺陷 | 代码已存在,但当前实现会导致主流程失败、权限绕过或数据不一致 |
| 未实现 | 未找到对应路由、业务逻辑或持久化实现 |
本次为代码审核并辅以 Go 单元测试和真实 MySQL 适配器集成测试,不包含多节点 WebSocket、移动端、短信通道和真实 FCM 凭据的端到端联调。
## 2. 总体结论
当前版本已经不是初始 Tinode 的简单 topic 模型。好友关系、群业务扩展表、认证安全、收藏搜索、工作台、会议、日报月报、反馈举报等模块均已出现较完整的 HTTP 路由和存储接口,产品功能骨架已经形成。
首轮审核发现的 P0 和本报告第 4 节的 6 项 P1 已于 2026-08-17 完成代码修复。项目仍不应按“全部完成”交付,以下部署和 P2 闭环仍需处理:
1. FCM 已接通产品 Token 表,但真实环境凭据和移动端联调仍待部署完成。
2. 真实短信、多节点 WebSocket、通话竞态和后台处置工作流仍未闭环。
3. 青少年模式、文件安全、群文件和会话设备管理等 P2 能力仍待开发。
### 2.1 分章完成度
| 章节 | 模块 | 当前判断 | 摘要 |
| --- | --- | --- | --- |
| 0 | 公共协议与基础约定 | 已实现主流程 | 统一响应、公共头、消息协议、Bearer 认证和全局幂等已落地 |
| 1 | 租户、认证与安全 | 部分实现 | 租户解析、验证码、密码重置、实名提交、安全挑战已实现;真实短信、会话管理等缺失 |
| 2 | 首页、会话与消息基础 | 部分实现 | 会话、回执、撤回、编辑、能力开关、实时事件和系统通知已落地;稳定游标等仍待增强 |
| 3 | 单聊与好友关系 | 部分实现 | 好友、黑名单、隐私策略、关系事件和通知已落地;历史文件/通话组合规则仍待验收 |
| 4 | 消息能力 | 部分实现 | @、媒体、转发、收藏及协议类型兼容已有;@通知和贴纸等缺失 |
| 5 | 联系人与关系管理 | 部分实现 | 基础功能、全局幂等和实时事件已落地;审计和二维码风控仍需增强 |
| 6 | 群聊 | P0 已修复,仍部分实现 | 初始化、邀请、申请、禁言、事务和 Outbox 已修复;群文件、解散等仍缺失 |
| 7 | 收藏、搜索与文件 | 部分实现 | 收藏、上下文、媒体搜索、文件 ACL/Range/ETag 已有;群文件、安全扫描等缺失 |
| 8 | 音视频通话 | 部分实现 | Tinode 通话事件和记录已存在;离线呼叫、跨设备竞态、群通话等未闭环 |
| 9 | 工作台与办公协同 | 部分实现 | 工作台、公开租户编码、组织查询、会议通知与报表已实现;权限仍需增强 |
| 10 | 设置与设备 | 部分实现 | 资料、隐私及主要策略执行、青少年模式、版本、Push Token 已有;设备会话与青少年统一策略仍待增强 |
| 11 | 安全、举报与反馈 | 部分实现 | URL 检查、挑战、举报和反馈提交已有;风险令牌消费、处置工作流和安全通知缺失 |
## 3. 发布阻断问题
> 修复状态更新(2026-08-17):本节列出的 7 项 P0 代码问题均已处理。FCM 代码链路已经接通,部署环境仍必须提供真实 Firebase 服务账号凭据。
### P0-01 消息头协议与对接文档不一致(已修复)
对接文档约定消息头使用:
```json
{
"x-im-schema": 1,
"x-im-type": "text"
}
```
修复前的校验在 `server/store/store.go` 中存在两处不兼容:
- `x-im-schema` 被要求为字符串,文档示例中的数字 `1` 会被判为 malformed。
- 消息类型主要校验 `head["type"]`,而不是协议约定的 `head["x-im-type"]`。
- 服务端允许的 `type` 值与文档的 `voice`、`contact_card`、`forward_bundle`、`system_event` 等枚举不一致。
- `server/topic.go:1050` 仅通过 `head["type"] == "system"` 防止客户端伪造系统消息,无法覆盖 `x-im-type=system_event`。
修复结果:现已接受数字或字符串形式的 schema 1,以 `x-im-type` 为产品协议字段并兼容旧 `type`;增加类型别名兼容、双字段冲突校验和 `system_event` 防伪测试。
### P0-02 群业务表未按租户业务库自动初始化(已修复)
修复前群表脚本存在以下问题:
- SQL 在第 7 行硬编码 `CREATE DATABASE tinode`,所有表也写死在 `tinode.im_group_*`。
- `scripts/check_group_business_tables.go:44` 写死 `TABLE_SCHEMA='tinode'`。
- `server/db/mysql/adapter.go` 的业务库创建流程没有创建这些群业务表。
修复结果:12 张群表已进入 MySQL 统一初始化;服务启动和首次打开独立租户库时会执行幂等建表;SQL 与检查脚本改为使用 DSN 当前选中的租户库,不再硬编码 `tinode`。
### P0-03 群邀请会把非成员误判为已入群(已修复)
原逻辑只判断 `store.Subs.Get(...)` 的 `err == nil`,因此会把 `(nil, nil)` 的非成员判为已入群。
修复结果:邀请改为 MySQL 原子操作,在事务内判断有效 Subscription、恢复删除订阅、写邀请和成员记录、更新人数并写 Outbox;已补充非成员邀请回归测试。
### P0-04 入群申请主流程和状态一致性有问题(已修复)
原入群申请入口复用了 `requireIMGroupMember`,群外用户无法提交申请;审批状态和成员写入也不在同一事务。
修复结果:POST 入群申请只校验登录用户、群状态及当前非成员状态;审批在一个事务内锁定申请,先完成 Subscription/成员写入,再更新申请状态和 Outbox,任何一步失败都会回滚。
### P0-05 群禁言未接入消息发布权限(已修复)
原 WebSocket 发布路径没有查询成员禁言和全员禁言状态。
修复结果:消息持久化前调用群发言策略,覆盖成员禁言、全员禁言、禁言过期、管理员豁免和 Root 系统消息;已补充被禁言成员发布返回 403 的测试。
### P0-06 产品 Push Token 与 FCM 数据源断开(代码已修复)
- 产品 Push Token 接口写入 `im_device_push_tokens`,原 FCM 只读取旧 `im_devices`。
- 仓库默认配置没有内置 Firebase 凭据。
修复结果:FCM 现优先读取产品 Token 表,并保留旧设备表回退;支持通过 `IM_FCM_CREDENTIALS_FILE` 或 `IM_FCM_CREDENTIALS_JSON` 注入凭据并自动启用。真实 Android/iOS 推送仍需部署方提供有效凭据完成联调。
### P0-07 群写操作缺少事务和可靠事件机制(已修复)
原建群、成员变更、审批、退群、角色变更和群主转让分别修改 Topic、Subscription 与群扩展表,并存在忽略错误的情况。
修复结果:上述关键写操作已收口到租户 MySQL 事务,角色 ACL 与业务角色同步更新;同一事务写入 `im_group_events_outbox`,提交后通过 Hub 向在线 Topic 派发,派发失败保留 pending 状态并在后续群请求重试。其余双写路径不再忽略错误,并提供回滚补偿。
## 4. 跨模块公共问题
> 修复状态更新(2026-08-17):本节 6 项 P1 代码问题均已处理。
### P1-01 HTTP 公共请求头未统一执行(已修复)
对接文档要求产品 HTTP 请求携带 `X-Tinode-APIKey`、`X-IM-Schema` 和 `X-Request-ID`。修复前各 Handler 主要在 `authenticateIMHTTPBearer` 中校验 Bearer Token:
- `/v1/im` 认证接口未统一校验 API Key。
- 未找到统一的 HTTP `X-IM-Schema` 版本校验。
- `X-Request-ID` 仅回显,缺失时不会拒绝或生成服务端 Request ID。
修复结果:`/v1/im/` 已统一经过协议中间件,强制校验 Header 中的 `X-Tinode-APIKey` 和 `X-IM-Schema: 1`;`X-Request-ID` 缺失时服务端生成并在响应中回传。
### P1-02 幂等只检查 Key 是否存在,没有实现幂等语义(已修复)
修复前 `requireIMIdempotencyKey` 主要检查请求头非空,没有统一持久化以下信息:
- `tenant + uid + method + route + idempotency_key`
- 请求体哈希
- 首次响应状态码和响应体
修复结果:新增租户库 `im_http_idempotency` 表和统一中间件,按用户、方法、路由和 Key 建立唯一作用域,持久化 Query/Body 哈希及首次状态码/响应体;支持完整响应重放、同 Key 不同请求冲突和处理中冲突。
### P1-03 HTTP 写操作缺少在线事件与 Outbox(已修复)
修复前,消息编辑/撤回、好友申请/通过/删除/拉黑、群资料与成员变更等 HTTP 操作大多只修改数据库并返回 HTTP 响应,没有统一广播到在线会话,也没有可靠 Outbox 供重试。
修复结果:新增 `im_business_events_outbox`,通过 Hub 向 Topic 或用户其他设备发布 `im` 事件,队列满或 Hub 不可用时保留 pending 以便重试。已覆盖消息编辑/撤回、好友关系、群业务、会议和安全事件;消息、好友申请/审批、群与会议的关键写路径在业务事务内写 Outbox,其他可重复关系写由全局幂等机制保证失败后可补写。
### P1-04 系统通知只有查询能力,缺少业务生产者(已修复)
修复前,系统通知表、列表和已读接口已经存在,但好友、安全、群聊、会议等模块没有完整生产链路。
修复结果:好友申请/处理/删除、群成员与群设置、会议变更/提醒/回应和安全确认均已生产用户系统通知,通知 `event_id` 可重复执行且不会重复入箱。
### P1-05 隐私配置已存储但未全面执行(已修复)
修复前,资料可见性、加好友方式、最后在线时间、已读回执等配置已经有表和接口,但主要读写路径没有执行这些策略。
修复结果:搜索与好友申请已执行 `add_me_methods`,用户资料执行 `profile_visibility`,HTTP 回执列表执行 `read_receipts`,Tinode P2P `get.desc/get.sub` 输出执行 `last_seen_visibility`;非法隐私枚举也会在存储入口被拒绝。
### P1-06 返回了内部租户 ID(已修复)
修复前工作台数据把数字 `TenantID` 格式化后放入 `tenant_code` 返回。对接约定要求客户端只使用公开租户编码,不感知内部 `tenant_id`。
修复结果:工作台会根据当前 `TenantID` 查询租户注册表,`tenant_code` 仅返回公开 `code`,不再暴露内部数字 ID。
## 5. 按需求章节审核
### 第 1 章:租户、认证与安全
**已实现**
- 租户解析、状态检查和 WebSocket `hi` 阶段租户绑定。
- Basic/Token 登录和 Token 中的租户、`auth_version` 校验。
- 注册验证码创建/校验、注册前验证消费、密码重置。
- 独立密码设置与验证、安全挑战、实名提交与状态查询。
- 用户安全状态变化后的在线 Session 驱逐能力。
**问题与缺失**
- 验证码目前主要完成生成、哈希和记录,未接入可验证的真实短信发送通道。
- 限流以租户、场景、手机号和重发窗口为主,缺少 IP、设备、账号等组合风控。
- 未找到完整的登录失败次数、账号临时锁定和解锁闭环。
- 实名认证只有用户提交和状态查询,缺少审核/第三方认证结果回写流程。
- 缺少 Refresh Token、登录设备列表、单设备下线、全部设备下线等会话管理 API。
- 缺少 API Key 轮换、吊销和灰度兼容机制。
### 第 2 章:首页、会话与消息基础
**已实现**
- Tinode `me/sub/pres` 会话基础能力。
- Bootstrap、会话搜索、未读/已读回执、消息撤回和编辑接口。
- 系统通知列表、未读数和已读接口。
- 撤回/编辑能力开关、事务内 Outbox 和在线事件已开启。
- 好友、群、会议和安全模块可向系统通知箱生产通知。
**问题与缺失**
- 回执游标依赖当前成员集合和整数偏移,成员变化后不属于稳定游标。
### 第 3 章:单聊与好友关系
**已实现**
- 好友申请、申请列表、同意、拒绝、好友列表和删除。
- 好友备注、黑名单、关系上下文和用户资料查询。
- WebSocket P2P 发消息前调用 `CanSendP2P`,可阻止非好友或拉黑关系发送。
- 单聊消息、回执、文件上传和历史消息基础能力。
- 好友申请、处理、删除、拉黑和备注会生成可重试关系事件,需要提醒对端的操作同时生成系统通知。
- 搜索、好友申请、资料、已读回执和最后在线时间已执行隐私设置。
**问题与缺失**
- 删除好友或拉黑后的历史消息、文件下载、通话权限缺少统一规则和验收测试。
### 第 4 章:消息能力
**已实现**
- @成员和 `@all` 解析,`@all` 有管理员权限限制。
- 文本、媒体、文件等基础消息与附件上传。
- 消息转发、收藏和历史上下文查询。
- 通话消息会写入 `x-im-type=call`。
**问题与缺失**
- @消息没有形成专门通知、Push 优先级和频控策略。
- 未找到贴纸包/表情商店相关数据模型与接口。
- 媒体上传进度、失败恢复主要依赖客户端,服务端缺少分片/断点上传状态机。
- 消息 `client_mid` 的幂等写入和 topic seq 增长不是完整事务,并发重复请求可能产生序号空洞或冲突错误。
### 第 5 章:联系人和关系管理
**已实现**
- 用户搜索、好友、申请、黑名单、关系上下文等核心接口已经注册。
- 关系表和 P2P 发送权限已纳入 MySQL 租户业务存储。
- 关系写操作已纳入全局幂等、系统通知和实时事件链路。
**问题与缺失**
- 手机号/通讯录匹配的授权、脱敏和隐私规则未形成完整闭环。
- 关系独立审计记录仍需补充。
- 二维码关系入口主要依赖签名令牌,缺少服务端可吊销记录、使用次数和风险控制。
### 第 6 章:群聊
**已实现**
- 群创建、资料、成员、成员资料、邀请、入群申请、公告、角色、转让群主、退群、禁言、二维码等路由已增加。
- 已持久化群设置、群成员、邀请、入群申请、公告、公告已读和禁言数据。
- `docs/IM-群聊业务表设计.sql` 设计了 12 张群业务扩展表。
**已修复的阻断项与剩余缺失**
- 群业务表自动初始化已修复,见 P0-02。
- 群邀请误判非成员已修复,见 P0-03。
- 群外申请和审批一致性已修复,见 P0-04。
- WebSocket 禁言校验已修复,见 P0-05。
- 建群、成员、角色和群主变更事务及 Outbox 已落地,见 P0-07。
- `im_group_qrcodes`、`im_group_files`、`im_group_admin_logs` 目前仍主要是表设计;`im_group_idempotency` 和 `im_group_events_outbox` 已有生产代码。
- 未找到群解散接口和完整状态清理流程。
- 未找到群文件列表、分类、搜索和文件消息自动索引能力。
- 群二维码主要使用 HMAC Token,没有持久化,无法单码吊销、限制使用次数或查看使用记录。
- 群公告、成员、角色、禁言等变更已接入 Outbox 与系统通知,真实多端与离线 Push 仍需联调验收。
### 第 7 章:收藏、搜索与文件
**已实现**
- 收藏新增、列表、详情、删除和转发。
- 消息搜索、上下文定位和媒体消息筛选。
- 文件资源访问会校验用户权限,响应支持 ETag;本地存储通过 `ServeContent` 支持 Range,S3 支持预签名 URL。
**问题与缺失**
- 未接入病毒扫描、恶意文件识别、图片/视频内容安全和隔离状态。
- 群文件索引表没有写入及查询实现。
- 搜索和列表游标的稳定性、权限快照和数据删除后的结果一致性需要专项测试。
- 文件下载审计、敏感文件水印和生命周期清理未实现。
### 第 8 章:音视频通话
**已实现**
- Tinode 通话邀请、接听、挂断等实时事件基础能力。
- 通话记录创建、接听、结束和列表接口。
- P2P 消息入口的好友关系校验可覆盖在线通话邀请的基础关系限制。
**问题与缺失**
- FCM 代码已使用产品 Token 数据源,但离线来电仍需真实凭据和移动端联调验收。
- 多设备同时接听缺少服务端唯一接听者仲裁和状态序列控制。
- 通话状态写入、实时事件和 Push 之间没有幂等/Outbox 保证。
- 未实现通话质量指标、失败原因标准化和质量上报。
- 未找到群语音/群视频通话的完整参与者状态模型。
- 好友、黑名单、青少年模式等策略没有通过统一服务覆盖所有通话入口。
### 第 9 章:工作台与办公协同
**已实现**
- 工作台聚合接口、组织树和成员搜索。
- 会议创建、修改、取消、提醒、参会响应和查询。
- 会议变更与参会响应在同一事务内写入 Outbox 和系统通知。
- 工作台 `tenant_code` 返回注册表的公开租户编码。
- 日报/月报草稿、提交、列表、统计和提醒。
- 邮箱配置查询和问题提交。
**问题与缺失**
- 工作台功能项与权限没有完全由服务端动态下发和裁剪。
- 组织、会议和报表的数据范围/角色权限需要更严格的统一校验和跨租户测试。
- 会议提醒已有实时事件和系统通知;报表审批/提交结果、角标和真实 Push 仍需闭环。
- 邮箱提交更接近收件记录,缺少分派、处理、回复、关闭等工作流。
### 第 10 章:设置、隐私与设备
**已实现**
- 个人资料读写、隐私设置读写。
- 加好友方式、资料可见性、最后在线时间和已读回执已在主要业务入口执行。
- 青少年模式启用、关闭和查询。
- 最新版本查询和设备 Push Token 注册接口。
**问题与缺失**
- `GET /me` 的实名状态和青少年模式字段存在硬编码默认值,与独立查询接口的数据可能不一致。
- 缺少登录设备/会话列表、设备改名、单设备退出、全端退出等完整设备管理。
- 青少年模式目前以设置存储为主,夜间限制、内容过滤、通话/加好友限制没有统一策略执行层。
- Push Token 注册与 FCM 发送数据源已统一,真实 Firebase 凭据和移动端仍待联调。
- 版本接口缺少灰度发布、渠道、强更分组及回滚策略。
### 第 11 章:安全、举报与反馈
**已实现**
- URL 安全检查和基础规则判定。
- 风险挑战继续/取消流程及风险动作令牌签发。
- 举报原因列表、举报提交、反馈配置和反馈提交。
**问题与缺失**
- `risk_action_token` 签发后,未找到原始高风险业务操作对该令牌进行统一消费和一次性校验的链路。
- URL 检查主要是基础规则,缺少信誉库、重定向链分析和异步复检。
- 举报和反馈以接收为主,缺少审核、分派、处置、申诉、回复和 SLA 状态流转。
- 风险确认已写入用户可见的系统安全通知,其他安全操作仍需按场景补齐。
- 举报证据文件没有明确接入专门 ACL、安全扫描和保留/删除策略。
## 6. 数据库与迁移审核
### 已具备
- 核心 Tinode 表已统一使用 `im_` 前缀。
- 好友关系、认证安全、隐私、收藏、通知、会议、报表等业务表已在 MySQL 适配器中出现对应存储实现。
- 群业务从 Topic/Subscription 扩展为独立业务表,方向符合复杂群管理需求。
- `im_http_idempotency` 和 `im_business_events_outbox` 已纳入应用自动建表与数据库变更汇总脚本。
### 需要修正
1. 建立唯一 schema 版本和迁移入口,禁止“应用启动建部分表 + 手工 SQL 建另一部分表”长期并存。
2. 群业务 SQL 必须参数化目标租户库,禁止硬编码 `tinode`。
3. 新租户建库和存量租户升级必须执行同一套 migration。
4. 为 Topic、Subscription、群业务表之间的复合写操作提供事务边界。
5. 对已设计但未使用的群表明确去留,避免表结构被误认为功能已经完成。
6. 补充索引、唯一键和外键/逻辑引用的一致性检查,尤其是邀请、申请、成员和幂等键。
## 7. 测试与验证结果
### 本次执行
```bash
go test -tags mysql ./server/db/mysql ./server/store ./server/push/fcm ./server -count=1
```
结果:全部通过。覆盖 HTTP 协议头、幂等响应重放/冲突、消息协议、隐私策略、群业务、FCM Token 数据源和 MySQL 建表语句。
```bash
go test -tags mysql ./server/db/mysql/tests -count=1
```
结果:使用 `root/root@127.0.0.1:3306` 和临时数据库完整通过;包含租户库创建、群表、P1 基础设施表、无 `tenant_id` 约束和多租户隔离测试。测试后已恢复 `test.conf` 并删除临时数据库。
### 测试缺口
- 缺少 HTTP 写入后 WebSocket 多端同步的集成测试。
- 缺少真实 FCM 凭据和移动端设备的离线推送测试。
- 隐私策略的基础分支已有测试,但黑名单、青少年模式、文件和通话的组合权限仍缺专项矩阵。
- 幂等重放和同 Key 不同 Body 已有单测,多进程高并发压测仍未执行。
## 8. 文档一致性问题
1. `IM-服务端多租户改造方案.md` 已明确废弃,本报告未把它作为实现依据。
2. `IM-系统设计文档.md` 中仍有共享业务库加 `tenant_id` 的描述,与当前独立租户业务库方向不一致,需要更新或标记历史版本。
3. `IM-前后端对接文档.md` 的接口状态是设计阶段快照,已有多项“新增”路由落地,应按本报告和实际联调结果刷新。
4. `IM-未实现后续增强项.md` 已于 2026-08-17 按最新 P0/P1 修复状态同步:已完成项已移入各章基线,待办表仅保留未实现或待环境联调的增强项。
5. 群表设计文档与独立库多租户方案冲突,脚本中的 `tinode` 库名必须移除。
## 9. 建议开发顺序
### 第一阶段:解除主流程阻断(已完成)
1. 统一消息协议字段、类型枚举和兼容策略。
2. 把群表纳入租户库 migration,并完成存量租户升级。
3. 修复群邀请、群外申请、审批一致性和禁言绕过。
4. 为建群、成员变更、群主转让建立事务和明确错误处理。
5. 统一 Push Token 数据源并启用 FCM 测试环境。
### 第二阶段:完成生产闭环(P1 代码已完成)
1. 建立统一 HTTP Middleware:API Key、Schema、Request ID、Bearer、限流。
2. 建立统一幂等存储和响应重放机制。
3. 落地 Outbox,补齐关系、群、消息编辑/撤回、会议等实时事件。
4. 建立系统通知生产服务,并与 Push、角标联动。
5. 建立统一隐私/关系/青少年模式 Policy Service。
### 第三阶段:补齐增强能力
1. 群文件、群解散、二维码管理、管理员日志。
2. 多设备会话管理、Refresh Token、API Key 生命周期。
3. 通话多设备仲裁、质量指标及群通话。
4. 举报、反馈、实名和邮箱的后台处置工作流。
5. 文件安全扫描、版本灰度和运营配置能力。
## 10. 建议验收门槛
在声明产品需求“已完成”前,至少满足:
- 新建租户无需手工 SQL 即可完成全部模块初始化。
- 文档示例消息可直接发送、存储、搜索、转发并被旧客户端兼容处理。
- 所有写接口具备可验证的幂等语义。
- HTTP 状态变更能可靠同步到 WebSocket 在线端,并能通过 Outbox 重试。
- FCM 使用产品注册的 Token 完成真实离线推送。
- 群邀请、申请、禁言、退群、转让和解散通过真实 MySQL 集成测试。
- 好友、黑名单、隐私、青少年模式在消息、文件、资料、通话入口保持一致。
- `go test ./...` 能完整编译并通过;关键链路另有端到端测试。
@@ -1,13 +1,13 @@
# IM 服务端多租户改造方案
> **方案变更说明,2026-08-13:当前多租户技术路线已调整为“控制库 + 每租户独立业务库”。本文原主体内容按“共享库共享表 + tenant_id”编写,只作为旧方案参考,不再作为后续开发准绳。新的落地方案见 [IM 独立库多租户改造方案](./IM-独立库多租户改造方案.md)。**
> **方案变更说明,2026-08-13:当前多租户技术路线已调整为“控制库 + 每租户独立业务库”。本文原主体内容按“共享库共享表 + tenant_id”编写,只作为旧方案参考,不再作为后续开发准绳。新的落地方案见 [IM 独立库多租户改造方案](../IM-独立库多租户改造方案.md)。**
>
> 新方案下,业务库中的 `users`、`auth`、`topics`、`messages`、`fileuploads` 和后续产品业务表不再增加 `tenant_id` 字段;`TenantID` 仍保留在 Session、Token、Hub、缓存、Push、文件授权、插件上下文和 Store 选库流程中,用于定位租户业务库和运行时隔离。
> 编写日期:2026-08-10
> 适用项目:`im_server` 当前服务端源码
> 需求背景:[IM 产品原型需求文档](./IM-产品原型需求文档.md) 第 1.1 节“企业码登录”及第 9 章“企业工作台与办公协同”
> 关联文档:[IM 产品需求服务端改造分析报告](./IM-产品需求服务端改造分析报告.md)
> 需求背景:[IM 产品原型需求文档](../IM-产品原型需求文档.md) 第 1.1 节“企业码登录”及第 9 章“企业工作台与办公协同”
> 关联文档:[IM 产品需求服务端改造分析报告](../IM-产品需求服务端改造分析报告.md)
> 实施状态:第一阶段租户识别已落地。MySQL Adapter schema 117 创建 `im_tenant`、`im_tenant_config` 和默认租户;首次 `hi` 使用企业码解析并固定 Session TenantID。核心业务表的 `tenant_id`、租户化认证和路由隔离尚未完成。
+81 -81
View File
@@ -68,75 +68,75 @@ DROP PROCEDURE assert_default_tenant;
-- 2. Add nullable tenant_id columns first.
-- ---------------------------------------------------------------------------
ALTER TABLE users
ALTER TABLE im_users
ADD COLUMN tenant_id BIGINT NULL AFTER id;
ALTER TABLE usertags
ALTER TABLE im_usertags
ADD COLUMN tenant_id BIGINT NULL AFTER id;
ALTER TABLE devices
ALTER TABLE im_devices
ADD COLUMN tenant_id BIGINT NULL AFTER id;
ALTER TABLE auth
ALTER TABLE im_auth
ADD COLUMN tenant_id BIGINT NULL AFTER id;
ALTER TABLE topics
ALTER TABLE im_topics
ADD COLUMN tenant_id BIGINT NULL AFTER id;
ALTER TABLE topictags
ALTER TABLE im_topictags
ADD COLUMN tenant_id BIGINT NULL AFTER id;
ALTER TABLE subscriptions
ALTER TABLE im_subscriptions
ADD COLUMN tenant_id BIGINT NULL AFTER id;
ALTER TABLE messages
ALTER TABLE im_messages
ADD COLUMN tenant_id BIGINT NULL AFTER id;
ALTER TABLE dellog
ALTER TABLE im_dellog
ADD COLUMN tenant_id BIGINT NULL AFTER id;
ALTER TABLE credentials
ALTER TABLE im_credentials
ADD COLUMN tenant_id BIGINT NULL AFTER id;
ALTER TABLE fileuploads
ALTER TABLE im_fileuploads
ADD COLUMN tenant_id BIGINT NULL AFTER id;
ALTER TABLE filemsglinks
ALTER TABLE im_filemsglinks
ADD COLUMN tenant_id BIGINT NULL AFTER id;
-- ---------------------------------------------------------------------------
-- 3. Backfill all existing rows into the default tenant.
-- ---------------------------------------------------------------------------
UPDATE users SET tenant_id = @default_tenant_id WHERE tenant_id IS NULL;
UPDATE usertags SET tenant_id = @default_tenant_id WHERE tenant_id IS NULL;
UPDATE devices SET tenant_id = @default_tenant_id WHERE tenant_id IS NULL;
UPDATE auth SET tenant_id = @default_tenant_id WHERE tenant_id IS NULL;
UPDATE topics SET tenant_id = @default_tenant_id WHERE tenant_id IS NULL;
UPDATE topictags SET tenant_id = @default_tenant_id WHERE tenant_id IS NULL;
UPDATE subscriptions SET tenant_id = @default_tenant_id WHERE tenant_id IS NULL;
UPDATE messages SET tenant_id = @default_tenant_id WHERE tenant_id IS NULL;
UPDATE dellog SET tenant_id = @default_tenant_id WHERE tenant_id IS NULL;
UPDATE credentials SET tenant_id = @default_tenant_id WHERE tenant_id IS NULL;
UPDATE fileuploads SET tenant_id = @default_tenant_id WHERE tenant_id IS NULL;
UPDATE filemsglinks SET tenant_id = @default_tenant_id WHERE tenant_id IS NULL;
UPDATE im_users SET tenant_id = @default_tenant_id WHERE tenant_id IS NULL;
UPDATE im_usertags SET tenant_id = @default_tenant_id WHERE tenant_id IS NULL;
UPDATE im_devices SET tenant_id = @default_tenant_id WHERE tenant_id IS NULL;
UPDATE im_auth SET tenant_id = @default_tenant_id WHERE tenant_id IS NULL;
UPDATE im_topics SET tenant_id = @default_tenant_id WHERE tenant_id IS NULL;
UPDATE im_topictags SET tenant_id = @default_tenant_id WHERE tenant_id IS NULL;
UPDATE im_subscriptions SET tenant_id = @default_tenant_id WHERE tenant_id IS NULL;
UPDATE im_messages SET tenant_id = @default_tenant_id WHERE tenant_id IS NULL;
UPDATE im_dellog SET tenant_id = @default_tenant_id WHERE tenant_id IS NULL;
UPDATE im_credentials SET tenant_id = @default_tenant_id WHERE tenant_id IS NULL;
UPDATE im_fileuploads SET tenant_id = @default_tenant_id WHERE tenant_id IS NULL;
UPDATE im_filemsglinks SET tenant_id = @default_tenant_id WHERE tenant_id IS NULL;
-- Validate the backfill before adding NOT NULL constraints.
DELIMITER $$
CREATE PROCEDURE assert_no_null_tenant_id()
BEGIN
IF EXISTS (SELECT 1 FROM users WHERE tenant_id IS NULL LIMIT 1)
OR EXISTS (SELECT 1 FROM usertags WHERE tenant_id IS NULL LIMIT 1)
OR EXISTS (SELECT 1 FROM devices WHERE tenant_id IS NULL LIMIT 1)
OR EXISTS (SELECT 1 FROM auth WHERE tenant_id IS NULL LIMIT 1)
OR EXISTS (SELECT 1 FROM topics WHERE tenant_id IS NULL LIMIT 1)
OR EXISTS (SELECT 1 FROM topictags WHERE tenant_id IS NULL LIMIT 1)
OR EXISTS (SELECT 1 FROM subscriptions WHERE tenant_id IS NULL LIMIT 1)
OR EXISTS (SELECT 1 FROM messages WHERE tenant_id IS NULL LIMIT 1)
OR EXISTS (SELECT 1 FROM dellog WHERE tenant_id IS NULL LIMIT 1)
OR EXISTS (SELECT 1 FROM credentials WHERE tenant_id IS NULL LIMIT 1)
OR EXISTS (SELECT 1 FROM fileuploads WHERE tenant_id IS NULL LIMIT 1)
OR EXISTS (SELECT 1 FROM filemsglinks WHERE tenant_id IS NULL LIMIT 1)
IF EXISTS (SELECT 1 FROM im_users WHERE tenant_id IS NULL LIMIT 1)
OR EXISTS (SELECT 1 FROM im_usertags WHERE tenant_id IS NULL LIMIT 1)
OR EXISTS (SELECT 1 FROM im_devices WHERE tenant_id IS NULL LIMIT 1)
OR EXISTS (SELECT 1 FROM im_auth WHERE tenant_id IS NULL LIMIT 1)
OR EXISTS (SELECT 1 FROM im_topics WHERE tenant_id IS NULL LIMIT 1)
OR EXISTS (SELECT 1 FROM im_topictags WHERE tenant_id IS NULL LIMIT 1)
OR EXISTS (SELECT 1 FROM im_subscriptions WHERE tenant_id IS NULL LIMIT 1)
OR EXISTS (SELECT 1 FROM im_messages WHERE tenant_id IS NULL LIMIT 1)
OR EXISTS (SELECT 1 FROM im_dellog WHERE tenant_id IS NULL LIMIT 1)
OR EXISTS (SELECT 1 FROM im_credentials WHERE tenant_id IS NULL LIMIT 1)
OR EXISTS (SELECT 1 FROM im_fileuploads WHERE tenant_id IS NULL LIMIT 1)
OR EXISTS (SELECT 1 FROM im_filemsglinks WHERE tenant_id IS NULL LIMIT 1)
THEN
SIGNAL SQLSTATE '45000'
SET MESSAGE_TEXT = 'tenant_id backfill is incomplete';
@@ -152,18 +152,18 @@ DROP PROCEDURE assert_no_null_tenant_id;
-- No DEFAULT is retained: new code must always provide a tenant explicitly.
-- ---------------------------------------------------------------------------
ALTER TABLE users MODIFY COLUMN tenant_id BIGINT NOT NULL;
ALTER TABLE usertags MODIFY COLUMN tenant_id BIGINT NOT NULL;
ALTER TABLE devices MODIFY COLUMN tenant_id BIGINT NOT NULL;
ALTER TABLE auth MODIFY COLUMN tenant_id BIGINT NOT NULL;
ALTER TABLE topics MODIFY COLUMN tenant_id BIGINT NOT NULL;
ALTER TABLE topictags MODIFY COLUMN tenant_id BIGINT NOT NULL;
ALTER TABLE subscriptions MODIFY COLUMN tenant_id BIGINT NOT NULL;
ALTER TABLE messages MODIFY COLUMN tenant_id BIGINT NOT NULL;
ALTER TABLE dellog MODIFY COLUMN tenant_id BIGINT NOT NULL;
ALTER TABLE credentials MODIFY COLUMN tenant_id BIGINT NOT NULL;
ALTER TABLE fileuploads MODIFY COLUMN tenant_id BIGINT NOT NULL;
ALTER TABLE filemsglinks MODIFY COLUMN tenant_id BIGINT NOT NULL;
ALTER TABLE im_users MODIFY COLUMN tenant_id BIGINT NOT NULL;
ALTER TABLE im_usertags MODIFY COLUMN tenant_id BIGINT NOT NULL;
ALTER TABLE im_devices MODIFY COLUMN tenant_id BIGINT NOT NULL;
ALTER TABLE im_auth MODIFY COLUMN tenant_id BIGINT NOT NULL;
ALTER TABLE im_topics MODIFY COLUMN tenant_id BIGINT NOT NULL;
ALTER TABLE im_topictags MODIFY COLUMN tenant_id BIGINT NOT NULL;
ALTER TABLE im_subscriptions MODIFY COLUMN tenant_id BIGINT NOT NULL;
ALTER TABLE im_messages MODIFY COLUMN tenant_id BIGINT NOT NULL;
ALTER TABLE im_dellog MODIFY COLUMN tenant_id BIGINT NOT NULL;
ALTER TABLE im_credentials MODIFY COLUMN tenant_id BIGINT NOT NULL;
ALTER TABLE im_fileuploads MODIFY COLUMN tenant_id BIGINT NOT NULL;
ALTER TABLE im_filemsglinks MODIFY COLUMN tenant_id BIGINT NOT NULL;
-- ---------------------------------------------------------------------------
-- 5. Drop existing single-column foreign keys.
@@ -197,15 +197,15 @@ BEGIN
END$$
DELIMITER ;
CALL drop_table_foreign_keys('usertags');
CALL drop_table_foreign_keys('devices');
CALL drop_table_foreign_keys('auth');
CALL drop_table_foreign_keys('topictags');
CALL drop_table_foreign_keys('subscriptions');
CALL drop_table_foreign_keys('messages');
CALL drop_table_foreign_keys('dellog');
CALL drop_table_foreign_keys('credentials');
CALL drop_table_foreign_keys('filemsglinks');
CALL drop_table_foreign_keys('im_usertags');
CALL drop_table_foreign_keys('im_devices');
CALL drop_table_foreign_keys('im_auth');
CALL drop_table_foreign_keys('im_topictags');
CALL drop_table_foreign_keys('im_subscriptions');
CALL drop_table_foreign_keys('im_messages');
CALL drop_table_foreign_keys('im_dellog');
CALL drop_table_foreign_keys('im_credentials');
CALL drop_table_foreign_keys('im_filemsglinks');
DROP PROCEDURE drop_table_foreign_keys;
@@ -214,40 +214,40 @@ DROP PROCEDURE drop_table_foreign_keys;
-- Parent identity indexes are also added for composite foreign keys.
-- ---------------------------------------------------------------------------
ALTER TABLE users
ALTER TABLE im_users
ADD UNIQUE KEY uk_users_tenant_id_id (tenant_id, id),
ADD KEY idx_users_tenant_state_stateat (tenant_id, state, stateat),
ADD KEY idx_users_tenant_lastseen_updatedat (tenant_id, lastseen, updatedat);
ALTER TABLE usertags
ALTER TABLE im_usertags
ADD UNIQUE KEY uk_usertags_tenant_user_tag (tenant_id, userid, tag),
ADD KEY idx_usertags_tenant_tag (tenant_id, tag),
DROP INDEX usertags_userid_tag;
ALTER TABLE devices
ALTER TABLE im_devices
ADD UNIQUE KEY uk_devices_tenant_hash (tenant_id, hash),
ADD KEY idx_devices_tenant_user (tenant_id, userid),
DROP INDEX devices_hash;
ALTER TABLE auth
ALTER TABLE im_auth
ADD UNIQUE KEY uk_auth_tenant_user_scheme (tenant_id, userid, scheme),
ADD UNIQUE KEY uk_auth_tenant_uname (tenant_id, uname),
DROP INDEX auth_userid_scheme,
DROP INDEX auth_uname;
ALTER TABLE topics
ALTER TABLE im_topics
ADD UNIQUE KEY uk_topics_tenant_name (tenant_id, name),
ADD KEY idx_topics_tenant_owner (tenant_id, owner),
ADD KEY idx_topics_tenant_state_stateat (tenant_id, state, stateat),
ADD KEY idx_topics_tenant_name_state_seqid (tenant_id, name, state, seqid),
DROP INDEX topics_name;
ALTER TABLE topictags
ALTER TABLE im_topictags
ADD UNIQUE KEY uk_topictags_tenant_topic_tag (tenant_id, topic, tag),
ADD KEY idx_topictags_tenant_tag (tenant_id, tag),
DROP INDEX topictags_topic_tag;
ALTER TABLE subscriptions
ALTER TABLE im_subscriptions
ADD UNIQUE KEY uk_subscriptions_tenant_topic_user (tenant_id, topic, userid),
ADD KEY idx_subscriptions_tenant_user_topic_deleted
(tenant_id, userid, topic, deletedat),
@@ -255,30 +255,30 @@ ALTER TABLE subscriptions
(tenant_id, topic, deletedat),
DROP INDEX subscriptions_topic_userid;
ALTER TABLE messages
ALTER TABLE im_messages
ADD UNIQUE KEY uk_messages_tenant_id (tenant_id, id),
ADD UNIQUE KEY uk_messages_tenant_topic_seqid (tenant_id, topic, seqid),
DROP INDEX messages_topic_seqid;
ALTER TABLE dellog
ALTER TABLE im_dellog
ADD KEY idx_dellog_tenant_topic_delid_user
(tenant_id, topic, delid, deletedfor),
ADD KEY idx_dellog_tenant_topic_user_low_hi
(tenant_id, topic, deletedfor, low, hi),
ADD KEY idx_dellog_tenant_deletedfor (tenant_id, deletedfor);
ALTER TABLE credentials
ALTER TABLE im_credentials
ADD UNIQUE KEY uk_credentials_tenant_synthetic (tenant_id, synthetic),
ADD KEY idx_credentials_tenant_user_method
(tenant_id, userid, method, deletedat),
DROP INDEX credentials_uniqueness;
ALTER TABLE fileuploads
ALTER TABLE im_fileuploads
ADD UNIQUE KEY uk_fileuploads_tenant_id (tenant_id, id),
ADD KEY idx_fileuploads_tenant_status_updated
(tenant_id, status, updatedat);
ALTER TABLE filemsglinks
ALTER TABLE im_filemsglinks
ADD KEY idx_filemsglinks_tenant_file (tenant_id, fileid),
ADD KEY idx_filemsglinks_tenant_message (tenant_id, msgid),
ADD KEY idx_filemsglinks_tenant_topic (tenant_id, topic),
@@ -295,32 +295,32 @@ ALTER TABLE filemsglinks
-- ---------------------------------------------------------------------------
SELECT 'user_tags_without_same_tenant_user' AS check_name, COUNT(*) AS invalid_count
FROM usertags child
LEFT JOIN users parent
FROM im_usertags child
LEFT JOIN im_users parent
ON parent.tenant_id = child.tenant_id AND parent.id = child.userid
WHERE parent.id IS NULL
UNION ALL
SELECT 'subscriptions_without_same_tenant_user', COUNT(*)
FROM subscriptions child
LEFT JOIN users parent
FROM im_subscriptions child
LEFT JOIN im_users parent
ON parent.tenant_id = child.tenant_id AND parent.id = child.userid
WHERE parent.id IS NULL
UNION ALL
SELECT 'subscriptions_without_same_tenant_topic', COUNT(*)
FROM subscriptions child
LEFT JOIN topics parent
FROM im_subscriptions child
LEFT JOIN im_topics parent
ON parent.tenant_id = child.tenant_id AND parent.name = child.topic
WHERE parent.name IS NULL
UNION ALL
SELECT 'messages_without_same_tenant_topic', COUNT(*)
FROM messages child
LEFT JOIN topics parent
FROM im_messages child
LEFT JOIN im_topics parent
ON parent.tenant_id = child.tenant_id AND parent.name = child.topic
WHERE parent.name IS NULL
UNION ALL
SELECT 'file_links_without_same_tenant_file', COUNT(*)
FROM filemsglinks child
LEFT JOIN fileuploads parent
FROM im_filemsglinks child
LEFT JOIN im_fileuploads parent
ON parent.tenant_id = child.tenant_id AND parent.id = child.fileid
WHERE parent.id IS NULL;
@@ -328,11 +328,11 @@ WHERE parent.id IS NULL;
-- 9. Update the schema version only after all validation succeeds.
-- ---------------------------------------------------------------------------
UPDATE kvmeta
UPDATE im_kvmeta
SET `value` = '119'
WHERE `key` = 'version';
-- Intentionally unchanged:
-- * im_tenant: it is the tenant registry itself.
-- * kvmeta: it stores platform-level schema metadata. Tenant-scoped cache
-- * im_kvmeta: it stores platform-level schema metadata. Tenant-scoped cache
-- data must use a separate table or a mandatory tenant key namespace.
+8 -1
View File
@@ -24,6 +24,13 @@ func main() {
if err = db.Ping(); err != nil {
fatal(err)
}
var schema string
if err = db.QueryRow("SELECT DATABASE()").Scan(&schema); err != nil {
fatal(err)
}
if strings.TrimSpace(schema) == "" {
fatal("IM_MYSQL_DSN must select a tenant database")
}
raw, err := os.ReadFile("docs/IM-群聊业务表设计.sql")
if err != nil {
@@ -39,7 +46,7 @@ func main() {
fatal(fmt.Errorf("exec failed: %s: %w", stmt, err))
}
}
fmt.Println("group business tables applied")
fmt.Printf("group business tables applied to %s\n", schema)
}
func stripSQLLineComments(text string) string {
+8 -1
View File
@@ -24,6 +24,13 @@ func main() {
if err = db.Ping(); err != nil {
fatal(err)
}
var schema string
if err = db.QueryRow("SELECT DATABASE()").Scan(&schema); err != nil {
fatal(err)
}
if strings.TrimSpace(schema) == "" {
fatal("IM_MYSQL_DSN must select a tenant database")
}
tables := []string{
"im_group_settings",
@@ -41,7 +48,7 @@ func main() {
}
for _, table := range tables {
var count int
err = db.QueryRow(`SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA='tinode' AND TABLE_NAME=?`, table).Scan(&count)
err = db.QueryRow(`SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA=? AND TABLE_NAME=?`, schema, table).Scan(&count)
if err != nil {
fatal(err)
}
+21 -1
View File
@@ -213,6 +213,7 @@ type Adapter interface {
// points fail closed when the configured adapter does not support tenant resolution.
type TenantAdapter interface {
TenantGetByCode(code string) (*t.Tenant, error)
TenantGetByID(id t.TenantID) (*t.Tenant, error)
}
// TenantRoutedAdapter can return a tenant-specific adapter, e.g. for the
@@ -221,6 +222,17 @@ type TenantRoutedAdapter interface {
TenantAdapterFor(t.TenantID) (TenantBusinessAdapter, TenantAuthAdapter, error)
}
// TenantHTTPInfrastructureAdapter is the optional durable infrastructure used
// by product HTTP middleware and online business-event delivery.
type TenantHTTPInfrastructureAdapter interface {
TenantHTTPIdempotencyBegin(t.TenantID, *t.HTTPIdempotencyRecord) (*t.HTTPIdempotencyRecord, bool, error)
TenantHTTPIdempotencyComplete(t.TenantID, *t.HTTPIdempotencyRecord) error
TenantHTTPIdempotencyAbort(t.TenantID, *t.HTTPIdempotencyRecord) error
TenantBusinessEventCreate(t.TenantID, *t.BusinessEvent) error
TenantBusinessEventListPending(t.TenantID, int, time.Time) ([]t.BusinessEvent, error)
TenantBusinessEventMarkDelivered(t.TenantID, string, time.Time) error
}
// TenantAuthAdapter is the tenant-aware authentication and credential capability.
// It is separate from Adapter while the remaining persistence domains are migrated.
type TenantAuthAdapter interface {
@@ -304,13 +316,21 @@ type TenantBusinessAdapter interface {
TenantGroupInvitationCreate(t.TenantID, *t.GroupInvitation) (*t.GroupInvitation, error)
TenantGroupJoinRequestCreate(t.TenantID, *t.GroupJoinRequest) (*t.GroupJoinRequest, error)
TenantGroupJoinRequestList(t.TenantID, string, string, string, int) ([]t.GroupJoinRequest, string, error)
TenantGroupJoinRequestResolve(t.TenantID, string, string, t.Uid, string, string, int, time.Time) (*t.GroupJoinRequest, error)
TenantGroupJoinRequestResolve(t.TenantID, string, string, t.Uid, string, string, int, any, time.Time) (*t.GroupJoinRequest, error)
TenantGroupAnnouncementLatest(t.TenantID, string) (*t.GroupAnnouncement, error)
TenantGroupAnnouncementPut(t.TenantID, *t.GroupAnnouncement, int) (*t.GroupAnnouncement, error)
TenantGroupAnnouncementRead(t.TenantID, string, string, t.Uid, time.Time) error
TenantGroupMuteList(t.TenantID, string, string, int) ([]t.GroupMute, string, error)
TenantGroupMuteUpsert(t.TenantID, *t.GroupMute) (*t.GroupMute, error)
TenantGroupMuteDelete(t.TenantID, string, t.Uid, t.Uid, time.Time) error
TenantGroupCreate(t.TenantID, *t.Topic, t.Uid, any, *t.GroupSettings, []t.Uid, any, time.Time) error
TenantGroupInviteMember(t.TenantID, string, t.Uid, t.Uid, string, string, any, time.Time) (bool, error)
TenantGroupLeave(t.TenantID, string, t.Uid, time.Time) error
TenantGroupRoleUpdate(t.TenantID, string, t.Uid, t.Uid, string, t.AccessMode, time.Time) error
TenantGroupOwnerTransfer(t.TenantID, string, t.Uid, t.Uid, int, time.Time) error
TenantGroupCanPost(t.TenantID, string, t.Uid, bool, time.Time) (bool, error)
TenantGroupEventListPending(t.TenantID, int, time.Time) ([]t.GroupEvent, error)
TenantGroupEventMarkDelivered(t.TenantID, string, time.Time) error
TenantWorkbenchModules(t.TenantID, t.Uid) ([]t.WorkbenchModule, int, error)
TenantOrganizationDepartments(t.TenantID, t.Uid, string, string, int) ([]t.OrganizationDepartment, string, error)
+210 -179
View File
File diff suppressed because it is too large Load Diff
+154
View File
@@ -0,0 +1,154 @@
//go:build mysql
// +build mysql
package mysql
// ensureIMGroupBusinessTables installs the product group-chat schema in the
// currently selected tenant database. Tenant databases intentionally do not
// carry tenant_id columns.
func ensureIMGroupBusinessTables(exec sqlExecutor) error {
statements := []string{
`CREATE TABLE IF NOT EXISTS im_group_settings (
topic VARCHAR(32) NOT NULL, owner_user_id BIGINT NOT NULL,
name VARCHAR(128) NOT NULL DEFAULT '', avatar_file_id VARCHAR(64) NOT NULL DEFAULT '',
description VARCHAR(512) NOT NULL DEFAULT '', group_status VARCHAR(16) NOT NULL DEFAULT 'active',
join_approval TINYINT NOT NULL DEFAULT 0, mute_all TINYINT NOT NULL DEFAULT 0,
member_upload_files TINYINT NOT NULL DEFAULT 1, member_private_chat TINYINT NOT NULL DEFAULT 1,
member_invite TINYINT NOT NULL DEFAULT 1, max_member_count INT NOT NULL DEFAULT 0,
max_admin_count INT NOT NULL DEFAULT 10, member_count INT NOT NULL DEFAULT 0,
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),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY(topic), INDEX idx_im_group_settings_owner(owner_user_id, group_status),
INDEX idx_im_group_settings_status_updated(group_status, updated_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
`CREATE TABLE IF NOT EXISTS im_group_members (
id BIGINT NOT NULL AUTO_INCREMENT, topic VARCHAR(32) NOT NULL, user_id BIGINT NOT NULL,
role VARCHAR(16) NOT NULL DEFAULT 'member', member_status VARCHAR(16) NOT NULL DEFAULT 'active',
nickname VARCHAR(64) NOT NULL DEFAULT '', pinned TINYINT NOT NULL DEFAULT 0,
mute_notifications TINYINT NOT NULL DEFAULT 0, join_source VARCHAR(32) NOT NULL DEFAULT 'manual',
joined_by BIGINT, joined_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), left_at DATETIME(3),
removed_by BIGINT, 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_group_members_topic_user(topic, user_id),
INDEX idx_im_group_members_user_status(user_id, member_status, updated_at),
INDEX idx_im_group_members_topic_role(topic, role, member_status, id),
INDEX idx_im_group_members_topic_status(topic, member_status, id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
`CREATE TABLE IF NOT EXISTS im_group_invitations (
id BIGINT NOT NULL AUTO_INCREMENT, invitation_id VARCHAR(64) NOT NULL, topic VARCHAR(32) NOT NULL,
inviter_user_id BIGINT NOT NULL, target_user_id BIGINT NOT NULL, message VARCHAR(256) NOT NULL DEFAULT '',
status VARCHAR(16) NOT NULL DEFAULT 'pending', result VARCHAR(32) NOT NULL DEFAULT '',
join_request_id VARCHAR(64) NOT NULL DEFAULT '', 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_group_invitations_invitation(invitation_id),
UNIQUE INDEX uk_im_group_invitations_idempotency(inviter_user_id, idempotency_key),
INDEX idx_im_group_invitations_target(target_user_id, status, expires_at),
INDEX idx_im_group_invitations_topic_status(topic, status, id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
`CREATE TABLE IF NOT EXISTS im_group_join_requests (
id BIGINT NOT NULL AUTO_INCREMENT, request_id VARCHAR(64) NOT NULL, topic VARCHAR(32) NOT NULL,
applicant_user_id BIGINT NOT NULL, inviter_user_id BIGINT, source VARCHAR(32) NOT NULL DEFAULT 'manual',
qr_token_hash CHAR(64) NOT NULL DEFAULT '', message VARCHAR(256) NOT NULL DEFAULT '',
status VARCHAR(16) NOT NULL DEFAULT 'pending', reject_reason VARCHAR(256) NOT NULL DEFAULT '',
handled_by BIGINT, handled_at DATETIME(3), expires_at DATETIME(3),
idempotency_key VARCHAR(128) 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_group_join_requests_request(request_id),
UNIQUE INDEX uk_im_group_join_requests_idempotency(applicant_user_id, idempotency_key),
INDEX idx_im_group_join_requests_topic_status(topic, status, id),
INDEX idx_im_group_join_requests_applicant(applicant_user_id, topic, status, id),
INDEX idx_im_group_join_requests_expire(status, expires_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
`CREATE TABLE IF NOT EXISTS im_group_announcements (
id BIGINT NOT NULL AUTO_INCREMENT, announcement_id VARCHAR(64) NOT NULL, topic VARCHAR(32) NOT NULL,
author_user_id BIGINT NOT NULL, content TEXT NOT NULL, status VARCHAR(16) NOT NULL DEFAULT 'current',
version INT NOT NULL DEFAULT 1, published_at DATETIME(3) NOT NULL, deleted_by BIGINT, deleted_at DATETIME(3),
idempotency_key VARCHAR(128) 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_group_announcements_announcement(announcement_id),
UNIQUE INDEX uk_im_group_announcements_idempotency(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_time(topic, published_at DESC, id DESC)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
`CREATE TABLE IF NOT EXISTS im_group_announcement_reads (
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),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY(announcement_id, user_id), INDEX idx_im_group_announcement_reads_topic_user(topic, user_id, read_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
`CREATE TABLE IF NOT EXISTS im_group_mutes (
topic VARCHAR(32) NOT NULL, user_id BIGINT NOT NULL, muted_by BIGINT NOT NULL,
muted_at DATETIME(3) NOT NULL, expires_at DATETIME(3), reason VARCHAR(256) NOT NULL DEFAULT '',
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),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY(topic, user_id), INDEX idx_im_group_mutes_topic_status(topic, status, expires_at),
INDEX idx_im_group_mutes_user_status(user_id, status, expires_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
`CREATE TABLE IF NOT EXISTS im_group_qrcodes (
id BIGINT NOT NULL AUTO_INCREMENT, token_id VARCHAR(64) NOT NULL, token_hash CHAR(64) NOT NULL,
topic VARCHAR(32) NOT NULL, created_by BIGINT NOT NULL, status VARCHAR(16) NOT NULL DEFAULT 'active',
expires_at DATETIME(3) NOT NULL, revoked_by BIGINT, revoked_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_group_qrcodes_token(token_id),
UNIQUE INDEX uk_im_group_qrcodes_hash(token_hash), INDEX idx_im_group_qrcodes_topic_status(topic, status, expires_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
`CREATE TABLE IF NOT EXISTS im_group_files (
id BIGINT NOT NULL AUTO_INCREMENT, group_file_id VARCHAR(64) NOT NULL, topic VARCHAR(32) NOT NULL,
file_id VARCHAR(64) NOT NULL, source_topic VARCHAR(32) NOT NULL DEFAULT '', source_seq INT NOT NULL DEFAULT 0,
uploader_id BIGINT NOT NULL, file_name VARCHAR(255) NOT NULL DEFAULT '', mime_type VARCHAR(128) NOT NULL DEFAULT '',
file_type VARCHAR(32) NOT NULL DEFAULT 'file', file_size BIGINT NOT NULL DEFAULT 0,
status VARCHAR(16) NOT NULL DEFAULT 'active', deleted_by BIGINT, deleted_at DATETIME(3),
idempotency_key VARCHAR(128) 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_group_files_group_file(group_file_id),
UNIQUE INDEX uk_im_group_files_topic_file(topic, file_id),
UNIQUE INDEX uk_im_group_files_idempotency(topic, uploader_id, idempotency_key),
INDEX idx_im_group_files_topic_type(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)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
`CREATE TABLE IF NOT EXISTS im_group_admin_logs (
id BIGINT NOT NULL AUTO_INCREMENT, log_id VARCHAR(64) NOT NULL, topic VARCHAR(32) NOT NULL,
operator_id BIGINT NOT NULL, target_user_id BIGINT, action VARCHAR(64) NOT NULL,
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 '',
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),
INDEX idx_im_group_admin_logs_topic_time(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)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
`CREATE TABLE IF NOT EXISTS im_group_idempotency (
id BIGINT NOT NULL AUTO_INCREMENT, user_id BIGINT NOT NULL, method VARCHAR(8) NOT NULL,
route VARCHAR(256) NOT NULL, idempotency_key VARCHAR(128) NOT NULL, body_hash CHAR(64) NOT NULL,
result_code INT NOT NULL DEFAULT 0, result_body JSON,
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_group_idempotency_request(user_id, method, route, idempotency_key),
INDEX idx_im_group_idempotency_created(created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
`CREATE TABLE IF NOT EXISTS im_group_events_outbox (
id BIGINT NOT NULL AUTO_INCREMENT, event_id VARCHAR(64) NOT NULL, topic VARCHAR(32) NOT NULL,
actor_user_id BIGINT NOT NULL DEFAULT 0, event_type VARCHAR(64) NOT NULL, payload JSON,
status VARCHAR(16) NOT NULL DEFAULT 'pending', attempts INT NOT NULL DEFAULT 0,
next_retry_at DATETIME(3), delivered_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_group_events_outbox_event(event_id),
INDEX idx_im_group_events_outbox_pending(status, next_retry_at, id), INDEX idx_im_group_events_outbox_topic(topic, id DESC)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
}
for _, statement := range statements {
if _, err := exec.Exec(statement); err != nil {
return err
}
}
return nil
}
+33
View File
@@ -0,0 +1,33 @@
//go:build mysql
package mysql
import (
"database/sql"
"strings"
"testing"
)
type recordingGroupSchemaExecutor struct {
statements []string
}
func (e *recordingGroupSchemaExecutor) Exec(query string, _ ...any) (sql.Result, error) {
e.statements = append(e.statements, query)
return nil, nil
}
func TestEnsureIMGroupBusinessTablesUsesSelectedTenantDatabase(t *testing.T) {
exec := &recordingGroupSchemaExecutor{}
if err := ensureIMGroupBusinessTables(exec); err != nil {
t.Fatal(err)
}
if len(exec.statements) != 12 {
t.Fatalf("created %d group tables, want 12", len(exec.statements))
}
for _, statement := range exec.statements {
if strings.Contains(strings.ToLower(statement), "tinode.") || strings.Contains(strings.ToLower(statement), "create database") {
t.Fatalf("group schema must use the selected tenant database: %s", statement)
}
}
}
+39
View File
@@ -0,0 +1,39 @@
//go:build mysql
// +build mysql
package mysql
func ensureIMHTTPInfrastructureTables(exec sqlExecutor) error {
statements := []string{
`CREATE TABLE IF NOT EXISTS im_http_idempotency (
id BIGINT NOT NULL AUTO_INCREMENT, user_id BIGINT NOT NULL,
method VARCHAR(8) NOT NULL, route VARCHAR(512) NOT NULL,
idempotency_key VARCHAR(128) NOT NULL, body_hash CHAR(64) NOT NULL,
state VARCHAR(16) NOT NULL DEFAULT 'processing', response_status INT NOT NULL DEFAULT 0,
response_body MEDIUMBLOB, expires_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_http_idempotency_scope(user_id, method, route, idempotency_key),
INDEX idx_im_http_idempotency_expiry(expires_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
`CREATE TABLE IF NOT EXISTS im_business_events_outbox (
id BIGINT NOT NULL AUTO_INCREMENT, event_id VARCHAR(64) NOT NULL,
target_type VARCHAR(16) NOT NULL, target VARCHAR(128) NOT NULL,
actor_user_id BIGINT NOT NULL DEFAULT 0, event_type VARCHAR(64) NOT NULL,
payload JSON, status VARCHAR(16) NOT NULL DEFAULT 'pending', attempts INT NOT NULL DEFAULT 0,
next_retry_at DATETIME(3), delivered_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_business_events_outbox_event(event_id),
INDEX idx_im_business_events_outbox_pending(status, next_retry_at, id),
INDEX idx_im_business_events_outbox_target(target_type, target, id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
}
for _, statement := range statements {
if _, err := exec.Exec(statement); err != nil {
return err
}
}
return nil
}
@@ -0,0 +1,27 @@
//go:build mysql
package mysql
import (
"strings"
"testing"
)
func TestEnsureIMHTTPInfrastructureTablesUsesSelectedTenantDatabase(t *testing.T) {
exec := &recordingGroupSchemaExecutor{}
if err := ensureIMHTTPInfrastructureTables(exec); err != nil {
t.Fatal(err)
}
if len(exec.statements) != 2 {
t.Fatalf("created %d infrastructure tables, want 2", len(exec.statements))
}
joined := strings.ToLower(strings.Join(exec.statements, "\n"))
for _, table := range []string{"im_http_idempotency", "im_business_events_outbox"} {
if !strings.Contains(joined, table) {
t.Fatalf("missing table %s", table)
}
}
if strings.Contains(joined, "tinode.") || strings.Contains(joined, "create database") || strings.Contains(joined, "tenant_id") {
t.Fatalf("infrastructure schema must stay inside the selected tenant database: %s", joined)
}
}
+2 -1
View File
@@ -10,7 +10,8 @@
# - im_tenant_config
#
# - Default tenant business database: tinode
# - users, topics, subscriptions, messages and extension tables
# - im_users, im_topics, im_subscriptions, im_messages and extension tables
# - im_http_idempotency and im_business_events_outbox for reliable HTTP writes
# - business tables do not store tenant_id in independent-database mode
#
# The previous shared-table tenant_id draft has been removed from this file to
File diff suppressed because it is too large Load Diff
+514 -26
View File
@@ -10,6 +10,8 @@ import (
"strings"
"time"
"github.com/jmoiron/sqlx"
"github.com/tinode/chat/server/db/common"
"github.com/tinode/chat/server/store"
t "github.com/tinode/chat/server/store/types"
)
@@ -130,11 +132,16 @@ func (a *adapter) TenantGroupSettingsUpdate(id t.TenantID, topic string, patch t
where += " AND version=?"
args = append(args, patch.Version)
}
ctx, cancel := a.getContext()
ctx, cancel := a.getContextForTx()
if cancel != nil {
defer cancel()
}
res, err := a.db.ExecContext(ctx, "UPDATE im_group_settings SET "+strings.Join(set, ",")+" WHERE "+where, args...)
tx, err := a.db.BeginTxx(ctx, nil)
if err != nil {
return nil, err
}
defer tx.Rollback()
res, err := tx.ExecContext(ctx, "UPDATE im_group_settings SET "+strings.Join(set, ",")+" WHERE "+where, args...)
if err != nil {
return nil, err
}
@@ -144,6 +151,12 @@ func (a *adapter) TenantGroupSettingsUpdate(id t.TenantID, topic string, patch t
}
return nil, t.ErrNotFound
}
if err = insertGroupEventTx(tx, topic, t.ZeroUid, "group.settings.updated", map[string]any{"topic": topic}); err != nil {
return nil, err
}
if err = tx.Commit(); err != nil {
return nil, err
}
return a.groupSettingsByTopic(ctx, topic)
}
@@ -310,11 +323,16 @@ func (a *adapter) TenantGroupMemberUpdate(id t.TenantID, topic string, uid t.Uid
where += " AND version=?"
args = append(args, patch.Version)
}
ctx, cancel := a.getContext()
ctx, cancel := a.getContextForTx()
if cancel != nil {
defer cancel()
}
res, err := a.db.ExecContext(ctx, "UPDATE im_group_members SET "+strings.Join(set, ",")+" WHERE "+where, args...)
tx, err := a.db.BeginTxx(ctx, nil)
if err != nil {
return nil, err
}
defer tx.Rollback()
res, err := tx.ExecContext(ctx, "UPDATE im_group_members SET "+strings.Join(set, ",")+" WHERE "+where, args...)
if err != nil {
return nil, err
}
@@ -324,7 +342,15 @@ func (a *adapter) TenantGroupMemberUpdate(id t.TenantID, topic string, uid t.Uid
}
return nil, t.ErrNotFound
}
_ = a.recountGroupMembers(ctx, topic)
if err = recountGroupMembersTx(tx, topic); err != nil {
return nil, err
}
if err = insertGroupEventTx(tx, topic, uid, "group.member.updated", map[string]any{"user_id": uid.UserId()}); err != nil {
return nil, err
}
if err = tx.Commit(); err != nil {
return nil, err
}
return a.groupMemberByQuery(ctx, "SELECT * FROM im_group_members WHERE topic=? AND user_id=?", topic, decodeUID(uid))
}
@@ -379,16 +405,29 @@ func (a *adapter) TenantGroupJoinRequestCreate(id t.TenantID, req *t.GroupJoinRe
if req.IdempotencyKey == "" {
req.IdempotencyKey = req.RequestID
}
ctx, cancel := a.getContext()
ctx, cancel := a.getContextForTx()
if cancel != nil {
defer cancel()
}
_, err := a.db.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)
tx, err := a.db.BeginTxx(ctx, nil)
if err != nil {
return nil, err
}
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)
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)
if err != nil {
return nil, err
}
if err = insertGroupEventTx(tx, req.Topic, req.ApplicantUserID, "group.join_request.created", map[string]any{
"request_id": req.RequestID, "applicant_user_id": req.ApplicantUserID.UserId(),
}); err != nil {
return nil, err
}
if err = tx.Commit(); err != nil {
return nil, err
}
return a.groupJoinRequestByQuery(ctx, "SELECT * FROM im_group_join_requests WHERE request_id=?", req.RequestID)
}
@@ -439,36 +478,72 @@ func (a *adapter) TenantGroupJoinRequestList(id t.TenantID, topic, status, curso
return items, next, rows.Err()
}
func (a *adapter) TenantGroupJoinRequestResolve(id t.TenantID, topic, requestID string, actor t.Uid, action, rejectReason string, version int, now time.Time) (*t.GroupJoinRequest, error) {
func (a *adapter) TenantGroupJoinRequestResolve(id t.TenantID, topic, requestID string, actor t.Uid, action, rejectReason string, version int, memberPrivate any, now time.Time) (*t.GroupJoinRequest, error) {
if tenantAdp, err := a.routeGroupTenant(id); err != nil {
return nil, err
} else if tenantAdp != a {
return tenantAdp.TenantGroupJoinRequestResolve(id, topic, requestID, actor, action, rejectReason, version, now)
return tenantAdp.TenantGroupJoinRequestResolve(id, topic, requestID, actor, action, rejectReason, version, memberPrivate, now)
}
status := t.GroupJoinRequestStatusRejected
if action == "accept" {
status = t.GroupJoinRequestStatusAccepted
}
args := []any{status, rejectReason, decodeUID(actor), now, now, topic, requestID, t.GroupJoinRequestStatusPending}
where := "topic=? AND request_id=? AND status=?"
if version > 0 {
where += " AND version=?"
args = append(args, version)
}
ctx, cancel := a.getContext()
ctx, cancel := a.getContextForTx()
if cancel != nil {
defer cancel()
}
res, err := a.db.ExecContext(ctx, "UPDATE im_group_join_requests SET status=?,reject_reason=?,handled_by=?,handled_at=?,updated_at=?,version=version+1 WHERE "+where, args...)
tx, err := a.db.BeginTxx(ctx, nil)
if err != nil {
return nil, err
}
if affected, _ := res.RowsAffected(); affected == 0 {
if version > 0 {
return nil, t.ErrInvalidResponse
}
defer tx.Rollback()
var current t.GroupJoinRequest
err = tx.QueryRowxContext(ctx, "SELECT * FROM im_group_join_requests WHERE topic=? AND request_id=? FOR UPDATE", topic, requestID).StructScan(&current)
if err == sql.ErrNoRows {
return nil, t.ErrNotFound
}
if err != nil {
return nil, err
}
scanGroupJoinRequest(&current)
if current.Status != t.GroupJoinRequestStatusPending {
return nil, t.ErrInvalidResponse
}
if version > 0 && current.Version != version {
return nil, t.ErrInvalidResponse
}
if action == "accept" {
sub := &t.Subscription{TenantID: id, User: current.ApplicantUserID.String(), Topic: topic,
ModeWant: t.ModeCPublic, ModeGiven: t.ModeCPublic, Private: memberPrivate}
sub.InitTimes()
if err = createSubscription(tx, sub, false); err != nil {
return nil, err
}
member := &t.GroupMember{Topic: topic, UserID: current.ApplicantUserID, Role: t.GroupMemberRoleMember,
MemberStatus: t.GroupMemberStatusActive, JoinSource: "approval", JoinedBy: actor, JoinedAt: now,
Version: 1, CreatedAt: now, UpdatedAt: now}
if err = upsertGroupMemberTx(tx, member); err != nil {
return nil, err
}
}
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 {
return nil, err
}
if err = recountGroupMembersTx(tx, topic); err != nil {
return nil, err
}
if err = updateGroupTopicSubCountTx(tx, topic); err != nil {
return nil, err
}
if err = insertGroupEventTx(tx, topic, actor, "group.join_request."+status, map[string]any{
"request_id": requestID, "applicant_user_id": current.ApplicantUserID.UserId(), "status": status,
}); err != nil {
return nil, err
}
if err = tx.Commit(); err != nil {
return nil, err
}
return a.groupJoinRequestByQuery(ctx, "SELECT * FROM im_group_join_requests WHERE topic=? AND request_id=?", topic, requestID)
}
@@ -530,6 +605,11 @@ func (a *adapter) TenantGroupAnnouncementPut(id t.TenantID, ann *t.GroupAnnounce
VALUES(?,?,?,?,?,?,?,?,?,?)`, ann.AnnouncementID, ann.Topic, decodeUID(ann.AuthorUserID), ann.Content, t.GroupAnnouncementStatusCurrent, version, now, ann.IdempotencyKey, now, now); err != nil {
return nil, err
}
if err = insertGroupEventTx(tx, ann.Topic, ann.AuthorUserID, "group.announcement.updated", map[string]any{
"announcement_id": ann.AnnouncementID, "version": version,
}); err != nil {
return nil, err
}
if err = tx.Commit(); err != nil {
return nil, err
}
@@ -607,17 +687,30 @@ func (a *adapter) TenantGroupMuteUpsert(id t.TenantID, mute *t.GroupMute) (*t.Gr
if mute.Status == "" {
mute.Status = t.GroupMuteStatusActive
}
ctx, cancel := a.getContext()
ctx, cancel := a.getContextForTx()
if cancel != nil {
defer cancel()
}
_, err := a.db.ExecContext(ctx, `INSERT INTO im_group_mutes(topic,user_id,muted_by,muted_at,expires_at,reason,status,version)
tx, err := a.db.BeginTxx(ctx, nil)
if err != nil {
return nil, err
}
defer tx.Rollback()
_, err = tx.ExecContext(ctx, `INSERT INTO im_group_mutes(topic,user_id,muted_by,muted_at,expires_at,reason,status,version)
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)`,
mute.Topic, decodeUID(mute.UserID), decodeUID(mute.MutedBy), now, mute.ExpiresAt, mute.Reason, mute.Status)
if err != nil {
return nil, err
}
if err = insertGroupEventTx(tx, mute.Topic, mute.MutedBy, "group.mute.updated", map[string]any{
"user_id": mute.UserID.UserId(), "muted": true, "expires_at": mute.ExpiresAt,
}); err != nil {
return nil, err
}
if err = tx.Commit(); err != nil {
return nil, err
}
return a.groupMuteByQuery(ctx, "SELECT * FROM im_group_mutes WHERE topic=? AND user_id=?", mute.Topic, decodeUID(mute.UserID))
}
@@ -627,11 +720,16 @@ func (a *adapter) TenantGroupMuteDelete(id t.TenantID, topic string, uid, actor
} else if tenantAdp != a {
return tenantAdp.TenantGroupMuteDelete(id, topic, uid, actor, now)
}
ctx, cancel := a.getContext()
ctx, cancel := a.getContextForTx()
if cancel != nil {
defer cancel()
}
res, err := a.db.ExecContext(ctx, "UPDATE im_group_mutes SET status=?,revoked_by=?,revoked_at=?,version=version+1,updated_at=? WHERE topic=? AND user_id=? AND status=?",
tx, err := a.db.BeginTxx(ctx, nil)
if err != nil {
return err
}
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=?",
t.GroupMuteStatusRevoked, decodeUID(actor), now, now, topic, decodeUID(uid), t.GroupMuteStatusActive)
if err != nil {
return err
@@ -639,7 +737,397 @@ func (a *adapter) TenantGroupMuteDelete(id t.TenantID, topic string, uid, actor
if affected, _ := res.RowsAffected(); affected == 0 {
return t.ErrNotFound
}
return nil
if err = insertGroupEventTx(tx, topic, actor, "group.mute.updated", map[string]any{
"user_id": uid.UserId(), "muted": false,
}); err != nil {
return err
}
return tx.Commit()
}
func (a *adapter) TenantGroupCreate(id t.TenantID, topic *t.Topic, owner t.Uid, ownerPrivate any,
settings *t.GroupSettings, members []t.Uid, memberPrivate any, now time.Time) error {
if tenantAdp, err := a.routeGroupTenant(id); err != nil {
return err
} else if tenantAdp != a {
return tenantAdp.TenantGroupCreate(id, topic, owner, ownerPrivate, settings, members, memberPrivate, now)
}
if topic == nil || settings == nil || topic.Id == "" || settings.Topic != topic.Id || owner.IsZero() {
return t.ErrMalformed
}
ctx, cancel := a.getContextForTx()
if cancel != nil {
defer cancel()
}
tx, err := a.db.BeginTxx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
if err = a.topicCreate(tx, topic); err != nil {
return err
}
ownerSub := &t.Subscription{TenantID: id, ObjHeader: t.ObjHeader{CreatedAt: topic.CreatedAt}, User: owner.String(),
Topic: topic.Id, ModeGiven: t.ModeCFull, ModeWant: topic.GetAccess(owner), Private: ownerPrivate}
ownerSub.InitTimes()
if err = createSubscription(tx, ownerSub, false); err != nil {
return err
}
settings.MemberCount = 1
if err = insertGroupSettingsTx(tx, settings); err != nil {
return err
}
ownerMember := &t.GroupMember{Topic: topic.Id, UserID: owner, Role: t.GroupMemberRoleOwner,
MemberStatus: t.GroupMemberStatusActive, JoinSource: "create", JoinedBy: owner, JoinedAt: now,
Version: 1, CreatedAt: now, UpdatedAt: now}
if err = upsertGroupMemberTx(tx, ownerMember); err != nil {
return err
}
seen := map[t.Uid]bool{owner: true}
for _, uid := range members {
if uid.IsZero() || seen[uid] {
continue
}
seen[uid] = true
sub := &t.Subscription{TenantID: id, User: uid.String(), Topic: topic.Id,
ModeWant: t.ModeCPublic, ModeGiven: t.ModeCPublic, Private: memberPrivate}
sub.InitTimes()
if err = createSubscription(tx, sub, false); err != nil {
return err
}
member := &t.GroupMember{Topic: topic.Id, UserID: uid, Role: t.GroupMemberRoleMember,
MemberStatus: t.GroupMemberStatusActive, JoinSource: "create", JoinedBy: owner, JoinedAt: now,
Version: 1, CreatedAt: now, UpdatedAt: now}
if err = upsertGroupMemberTx(tx, member); err != nil {
return err
}
}
if err = recountGroupMembersTx(tx, topic.Id); err != nil {
return err
}
if err = updateGroupTopicSubCountTx(tx, topic.Id); err != nil {
return err
}
if err = insertGroupEventTx(tx, topic.Id, owner, "group.created", map[string]any{
"owner_user_id": owner.UserId(), "member_count": len(seen),
}); err != nil {
return err
}
return tx.Commit()
}
func (a *adapter) TenantGroupInviteMember(id t.TenantID, topic string, inviter, target t.Uid,
message, idempotencyKey string, private any, now time.Time) (bool, error) {
if tenantAdp, err := a.routeGroupTenant(id); err != nil {
return false, err
} else if tenantAdp != a {
return tenantAdp.TenantGroupInviteMember(id, topic, inviter, target, message, idempotencyKey, private, now)
}
ctx, cancel := a.getContextForTx()
if cancel != nil {
defer cancel()
}
tx, err := a.db.BeginTxx(ctx, nil)
if err != nil {
return false, err
}
defer tx.Rollback()
var deletedAt sql.NullTime
err = tx.QueryRowContext(ctx, "SELECT deletedat FROM im_subscriptions WHERE topic=? AND userid=? FOR UPDATE", topic, decodeUID(target)).Scan(&deletedAt)
if err != nil && err != sql.ErrNoRows {
return false, err
}
if err == nil && !deletedAt.Valid {
return false, nil
}
sub := &t.Subscription{TenantID: id, User: target.String(), Topic: topic,
ModeWant: t.ModeCPublic, ModeGiven: t.ModeCPublic, Private: private}
sub.InitTimes()
if err = createSubscription(tx, sub, false); err != nil {
return false, err
}
invitationID := "ginv_" + store.Store.GetUidString()
if idempotencyKey == "" {
idempotencyKey = invitationID
} else {
idempotencyKey += ":" + target.UserId()
}
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)
VALUES(?,?,?,?,?,?,?,?,?,?,?,?)`, invitationID, topic, decodeUID(inviter), decodeUID(target), message,
t.GroupInvitationStatusJoined, "joined", idempotencyKey, now.Add(7*24*time.Hour), now, now, now); err != nil {
return false, err
}
member := &t.GroupMember{Topic: topic, UserID: target, Role: t.GroupMemberRoleMember,
MemberStatus: t.GroupMemberStatusActive, JoinSource: "invitation", JoinedBy: inviter, JoinedAt: now,
Version: 1, CreatedAt: now, UpdatedAt: now}
if err = upsertGroupMemberTx(tx, member); err != nil {
return false, err
}
if err = recountGroupMembersTx(tx, topic); err != nil {
return false, err
}
if err = updateGroupTopicSubCountTx(tx, topic); err != nil {
return false, err
}
if err = insertGroupEventTx(tx, topic, inviter, "group.member.joined", map[string]any{
"user_id": target.UserId(), "source": "invitation",
}); err != nil {
return false, err
}
if err = tx.Commit(); err != nil {
return false, err
}
return true, nil
}
func (a *adapter) TenantGroupLeave(id t.TenantID, topic string, uid t.Uid, now time.Time) error {
if tenantAdp, err := a.routeGroupTenant(id); err != nil {
return err
} else if tenantAdp != a {
return tenantAdp.TenantGroupLeave(id, topic, uid, now)
}
ctx, cancel := a.getContextForTx()
if cancel != nil {
defer cancel()
}
tx, err := a.db.BeginTxx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
res, err := tx.ExecContext(ctx, "UPDATE im_subscriptions SET updatedat=?,deletedat=? WHERE topic=? AND userid=? AND deletedat IS NULL", now, now, topic, decodeUID(uid))
if err != nil {
return err
}
if affected, _ := res.RowsAffected(); affected == 0 {
return t.ErrNotFound
}
if _, err = tx.ExecContext(ctx, `UPDATE im_group_members SET member_status=?,left_at=?,version=version+1,updated_at=?
WHERE topic=? AND user_id=? AND member_status=?`, t.GroupMemberStatusLeft, now, now, topic, decodeUID(uid), t.GroupMemberStatusActive); err != nil {
return err
}
if err = recountGroupMembersTx(tx, topic); err != nil {
return err
}
if err = updateGroupTopicSubCountTx(tx, topic); err != nil {
return err
}
if err = insertGroupEventTx(tx, topic, uid, "group.member.left", map[string]any{"user_id": uid.UserId()}); err != nil {
return err
}
return tx.Commit()
}
func (a *adapter) TenantGroupRoleUpdate(id t.TenantID, topic string, actor, target t.Uid,
role string, modeGiven t.AccessMode, now time.Time) error {
if tenantAdp, err := a.routeGroupTenant(id); err != nil {
return err
} else if tenantAdp != a {
return tenantAdp.TenantGroupRoleUpdate(id, topic, actor, target, role, modeGiven, now)
}
ctx, cancel := a.getContextForTx()
if cancel != nil {
defer cancel()
}
tx, err := a.db.BeginTxx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
res, err := tx.ExecContext(ctx, "UPDATE im_subscriptions SET modegiven=?,updatedat=? WHERE topic=? AND userid=? AND deletedat IS NULL",
modeGiven.String(), now, topic, decodeUID(target))
if err != nil {
return err
}
if affected, _ := res.RowsAffected(); affected == 0 {
return t.ErrNotFound
}
res, err = tx.ExecContext(ctx, "UPDATE im_group_members SET role=?,version=version+1,updated_at=? WHERE topic=? AND user_id=? AND member_status=?",
role, now, topic, decodeUID(target), t.GroupMemberStatusActive)
if err != nil {
return err
}
if affected, _ := res.RowsAffected(); affected == 0 {
return t.ErrNotFound
}
if err = insertGroupEventTx(tx, topic, actor, "group.role.updated", map[string]any{
"user_id": target.UserId(), "role": role,
}); err != nil {
return err
}
return tx.Commit()
}
func (a *adapter) TenantGroupOwnerTransfer(id t.TenantID, topic string, previousOwner, newOwner t.Uid,
version int, now time.Time) error {
if tenantAdp, err := a.routeGroupTenant(id); err != nil {
return err
} else if tenantAdp != a {
return tenantAdp.TenantGroupOwnerTransfer(id, topic, previousOwner, newOwner, version, now)
}
ctx, cancel := a.getContextForTx()
if cancel != nil {
defer cancel()
}
tx, err := a.db.BeginTxx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
updates := []struct {
uid t.Uid
mode t.AccessMode
role string
}{{previousOwner, t.ModeCAdmin | t.ModeCPublic, t.GroupMemberRoleAdmin}, {newOwner, t.ModeCFull, t.GroupMemberRoleOwner}}
for _, update := range updates {
res, execErr := tx.ExecContext(ctx, "UPDATE im_subscriptions SET modegiven=?,updatedat=? WHERE topic=? AND userid=? AND deletedat IS NULL",
update.mode.String(), now, topic, decodeUID(update.uid))
if execErr != nil {
return execErr
}
if affected, _ := res.RowsAffected(); affected == 0 {
return t.ErrNotFound
}
res, execErr = tx.ExecContext(ctx, "UPDATE im_group_members SET role=?,version=version+1,updated_at=? WHERE topic=? AND user_id=? AND member_status=?",
update.role, now, topic, decodeUID(update.uid), t.GroupMemberStatusActive)
if execErr != nil {
return execErr
}
if affected, _ := res.RowsAffected(); affected == 0 {
return t.ErrNotFound
}
}
if _, err = tx.ExecContext(ctx, "UPDATE im_topics SET owner=?,updatedat=? WHERE name=?", decodeUID(newOwner), now, topic); err != nil {
return err
}
args := []any{decodeUID(newOwner), now, topic}
query := "UPDATE im_group_settings SET owner_user_id=?,version=version+1,updated_at=? WHERE topic=?"
if version > 0 {
query += " AND version=?"
args = append(args, version)
}
res, err := tx.ExecContext(ctx, query, args...)
if err != nil {
return err
}
if affected, _ := res.RowsAffected(); affected == 0 {
return t.ErrInvalidResponse
}
if err = insertGroupEventTx(tx, topic, previousOwner, "group.owner.transferred", map[string]any{
"previous_owner": previousOwner.UserId(), "owner": newOwner.UserId(),
}); err != nil {
return err
}
return tx.Commit()
}
func (a *adapter) TenantGroupCanPost(id t.TenantID, topic string, uid t.Uid, admin bool, now time.Time) (bool, error) {
if tenantAdp, err := a.routeGroupTenant(id); err != nil {
return false, err
} else if tenantAdp != a {
return tenantAdp.TenantGroupCanPost(id, topic, uid, admin, now)
}
if admin {
return true, nil
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
var muteAll, memberMuted bool
err := a.db.QueryRowContext(ctx, `SELECT
COALESCE((SELECT mute_all FROM im_group_settings WHERE 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>?))`,
topic, t.GroupStatusActive, topic, decodeUID(uid), t.GroupMuteStatusActive, now).Scan(&muteAll, &memberMuted)
if err != nil {
return false, err
}
return !muteAll && !memberMuted, nil
}
func (a *adapter) TenantGroupEventListPending(id t.TenantID, limit int, now time.Time) ([]t.GroupEvent, error) {
if tenantAdp, err := a.routeGroupTenant(id); err != nil {
return nil, err
} else if tenantAdp != a {
return tenantAdp.TenantGroupEventListPending(id, limit, now)
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
rows, err := a.db.QueryxContext(ctx, `SELECT * FROM im_group_events_outbox
WHERE status='pending' AND (next_retry_at IS NULL OR next_retry_at<=?) ORDER BY id LIMIT ?`, now, limit)
if err != nil {
return nil, err
}
defer rows.Close()
events := make([]t.GroupEvent, 0, limit)
for rows.Next() {
var event t.GroupEvent
if err = rows.StructScan(&event); err != nil {
return nil, err
}
event.ActorUserID = store.EncodeUid(event.ActorRaw)
events = append(events, event)
}
return events, rows.Err()
}
func (a *adapter) TenantGroupEventMarkDelivered(id t.TenantID, eventID string, now time.Time) error {
if tenantAdp, err := a.routeGroupTenant(id); err != nil {
return err
} else if tenantAdp != a {
return tenantAdp.TenantGroupEventMarkDelivered(id, eventID, now)
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
_, err := a.db.ExecContext(ctx, `UPDATE im_group_events_outbox SET status='delivered',attempts=attempts+1,
delivered_at=?,updated_at=? WHERE event_id=? AND status='pending'`, now, now, eventID)
return err
}
func insertGroupSettingsTx(tx *sqlx.Tx, settings *t.GroupSettings) error {
_, err := tx.Exec(`INSERT INTO im_group_settings
(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(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, settings.Topic, decodeUID(settings.OwnerUserID), settings.Name,
settings.AvatarFileID, settings.Description, settings.GroupStatus, 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)
return err
}
func upsertGroupMemberTx(tx *sqlx.Tx, member *t.GroupMember) error {
_, err := tx.Exec(`INSERT INTO im_group_members
(topic,user_id,role,member_status,nickname,pinned,mute_notifications,join_source,joined_by,joined_at,version,created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?)
ON DUPLICATE KEY UPDATE role=VALUES(role),member_status=VALUES(member_status),nickname=VALUES(nickname),
pinned=VALUES(pinned),mute_notifications=VALUES(mute_notifications),join_source=VALUES(join_source),
joined_by=VALUES(joined_by),joined_at=VALUES(joined_at),left_at=NULL,removed_by=NULL,version=version+1,updated_at=VALUES(updated_at)`,
member.Topic, decodeUID(member.UserID), member.Role, member.MemberStatus, member.Nickname, member.Pinned,
member.MuteNotifications, member.JoinSource, nullableUID(member.JoinedBy), member.JoinedAt, member.Version,
member.CreatedAt, member.UpdatedAt)
return err
}
func recountGroupMembersTx(tx *sqlx.Tx, topic string) error {
_, err := tx.Exec(`UPDATE im_group_settings SET member_count=(SELECT COUNT(*) FROM im_group_members
WHERE topic=? AND member_status=?),updated_at=CURRENT_TIMESTAMP(3) WHERE topic=?`, topic, t.GroupMemberStatusActive, topic)
return err
}
func updateGroupTopicSubCountTx(tx *sqlx.Tx, topic string) error {
_, err := tx.Exec(`UPDATE im_topics SET subcnt=(SELECT COUNT(*) FROM im_subscriptions
WHERE topic IN (?,?) AND deletedat IS NULL) WHERE name=?`, topic, t.GrpToChn(topic), topic)
return err
}
func insertGroupEventTx(tx *sqlx.Tx, topic string, actor t.Uid, eventType string, payload any) error {
_, err := tx.Exec(`INSERT INTO im_group_events_outbox(event_id,topic,actor_user_id,event_type,payload,status)
VALUES(?,?,?,?,?,'pending')`, "gev_"+store.Store.GetUidString(), topic, decodeUID(actor), eventType, common.ToJSON(payload))
return err
}
func (a *adapter) routeGroupTenant(id t.TenantID) (*adapter, error) {
+253
View File
@@ -0,0 +1,253 @@
//go:build mysql
// +build mysql
package mysql
import (
"database/sql"
"encoding/json"
"time"
"github.com/jmoiron/sqlx"
"github.com/tinode/chat/server/store"
t "github.com/tinode/chat/server/store/types"
)
func (a *adapter) infrastructureAdapter(id t.TenantID) (*adapter, error) {
if !a.tenantScopedDB {
return a.routedTenantAdapter(id)
}
if err := validTenant(id); err != nil {
return nil, err
}
return a, nil
}
func (a *adapter) TenantHTTPIdempotencyBegin(id t.TenantID, record *t.HTTPIdempotencyRecord) (*t.HTTPIdempotencyRecord, bool, error) {
tenantAdp, err := a.infrastructureAdapter(id)
if err != nil {
return nil, false, err
}
if record == nil || record.UserID.IsZero() || record.Method == "" || record.Route == "" || record.Key == "" || record.BodyHash == "" {
return nil, false, t.ErrMalformed
}
if tenantAdp != a {
return tenantAdp.TenantHTTPIdempotencyBegin(id, record)
}
now := t.TimeNow()
if record.ExpiresAt.IsZero() {
record.ExpiresAt = now.Add(24 * time.Hour)
}
_, err = a.db.Exec(`INSERT INTO im_http_idempotency
(user_id,method,route,idempotency_key,body_hash,state,response_status,response_body,expires_at,created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?,?,?,?)`, decodeUID(record.UserID), record.Method, record.Route, record.Key,
record.BodyHash, t.HTTPIdempotencyProcessing, 0, nil, record.ExpiresAt, now, now)
if err == nil {
record.State = t.HTTPIdempotencyProcessing
record.CreatedAt = now
record.UpdatedAt = now
return record, true, nil
}
if !isDupe(err) {
return nil, false, err
}
existing, err := a.httpIdempotencyGet(record.UserID, record.Method, record.Route, record.Key)
if err == nil && !existing.ExpiresAt.After(now) {
res, deleteErr := a.db.Exec(`DELETE FROM im_http_idempotency
WHERE user_id=? AND method=? AND route=? AND idempotency_key=? AND expires_at<=?`,
decodeUID(record.UserID), record.Method, record.Route, record.Key, now)
if deleteErr != nil {
return nil, false, deleteErr
}
if deleted, _ := res.RowsAffected(); deleted > 0 {
return a.TenantHTTPIdempotencyBegin(id, record)
}
current, getErr := a.httpIdempotencyGet(record.UserID, record.Method, record.Route, record.Key)
return current, false, getErr
}
return existing, false, err
}
func (a *adapter) TenantHTTPIdempotencyComplete(id t.TenantID, record *t.HTTPIdempotencyRecord) error {
tenantAdp, err := a.infrastructureAdapter(id)
if err != nil {
return err
}
if record == nil || record.UserID.IsZero() {
return t.ErrMalformed
}
if tenantAdp != a {
return tenantAdp.TenantHTTPIdempotencyComplete(id, record)
}
now := t.TimeNow()
res, err := a.db.Exec(`UPDATE im_http_idempotency SET state=?,response_status=?,response_body=?,updated_at=?
WHERE user_id=? AND method=? AND route=? AND idempotency_key=? AND body_hash=? AND state=?`,
t.HTTPIdempotencyCompleted, record.ResponseStatus, record.ResponseBody, now, decodeUID(record.UserID),
record.Method, record.Route, record.Key, record.BodyHash, t.HTTPIdempotencyProcessing)
if err != nil {
return err
}
if count, _ := res.RowsAffected(); count == 0 {
return t.ErrNotFound
}
return nil
}
func (a *adapter) TenantHTTPIdempotencyAbort(id t.TenantID, record *t.HTTPIdempotencyRecord) error {
tenantAdp, err := a.infrastructureAdapter(id)
if err != nil {
return err
}
if record == nil || record.UserID.IsZero() {
return t.ErrMalformed
}
if tenantAdp != a {
return tenantAdp.TenantHTTPIdempotencyAbort(id, record)
}
_, err = a.db.Exec(`DELETE FROM im_http_idempotency
WHERE user_id=? AND method=? AND route=? AND idempotency_key=? AND body_hash=? AND state=?`,
decodeUID(record.UserID), record.Method, record.Route, record.Key, record.BodyHash, t.HTTPIdempotencyProcessing)
return err
}
func (a *adapter) httpIdempotencyGet(uid t.Uid, method, route, key string) (*t.HTTPIdempotencyRecord, error) {
row := struct {
UserID int64 `db:"user_id"`
Method string
Route string
Key string `db:"idempotency_key"`
BodyHash string `db:"body_hash"`
State string
ResponseStatus int `db:"response_status"`
ResponseBody []byte `db:"response_body"`
CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"`
ExpiresAt time.Time `db:"expires_at"`
}{}
err := a.db.Get(&row, `SELECT user_id,method,route,idempotency_key,body_hash,state,response_status,response_body,created_at,updated_at,expires_at
FROM im_http_idempotency WHERE user_id=? AND method=? AND route=? AND idempotency_key=?`, decodeUID(uid), method, route, key)
if err == sql.ErrNoRows {
return nil, t.ErrNotFound
}
if err != nil {
return nil, err
}
return &t.HTTPIdempotencyRecord{UserID: store.EncodeUid(row.UserID), Method: row.Method, Route: row.Route,
Key: row.Key, BodyHash: row.BodyHash, State: row.State, ResponseStatus: row.ResponseStatus,
ResponseBody: row.ResponseBody, CreatedAt: row.CreatedAt, UpdatedAt: row.UpdatedAt, ExpiresAt: row.ExpiresAt}, nil
}
func (a *adapter) TenantBusinessEventCreate(id t.TenantID, event *t.BusinessEvent) error {
tenantAdp, err := a.infrastructureAdapter(id)
if err != nil {
return err
}
if event == nil || event.EventID == "" || event.Target == "" || event.EventType == "" ||
(event.TargetType != "user" && event.TargetType != "topic") {
return t.ErrMalformed
}
if tenantAdp != a {
return tenantAdp.TenantBusinessEventCreate(id, event)
}
now := t.TimeNow()
_, err = a.db.Exec(`INSERT INTO im_business_events_outbox
(event_id,target_type,target,actor_user_id,event_type,payload,status,created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?,?) ON DUPLICATE KEY UPDATE event_id=VALUES(event_id)`, event.EventID,
event.TargetType, event.Target, decodeUID(event.ActorUserID), event.EventType, event.Payload,
t.BusinessEventPending, now, now)
return err
}
func (a *adapter) TenantBusinessEventListPending(id t.TenantID, limit int, now time.Time) ([]t.BusinessEvent, error) {
tenantAdp, err := a.infrastructureAdapter(id)
if err != nil {
return nil, err
}
if tenantAdp != a {
return tenantAdp.TenantBusinessEventListPending(id, limit, now)
}
if limit <= 0 || limit > 500 {
limit = 100
}
rows, err := a.db.Queryx(`SELECT * FROM im_business_events_outbox
WHERE status=? AND (next_retry_at IS NULL OR next_retry_at<=?) ORDER BY id LIMIT ?`, t.BusinessEventPending, now, limit)
if err != nil {
return nil, err
}
defer rows.Close()
events := make([]t.BusinessEvent, 0, limit)
for rows.Next() {
var row struct {
ID int64
EventID string `db:"event_id"`
TargetType string `db:"target_type"`
Target string
ActorUserID int64 `db:"actor_user_id"`
EventType string `db:"event_type"`
Payload []byte
Status string
Attempts int
NextRetryAt *time.Time `db:"next_retry_at"`
DeliveredAt *time.Time `db:"delivered_at"`
CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"`
}
if err = rows.StructScan(&row); err != nil {
return nil, err
}
events = append(events, t.BusinessEvent{ID: row.ID, EventID: row.EventID, TargetType: row.TargetType,
Target: row.Target, ActorUserID: store.EncodeUid(row.ActorUserID), EventType: row.EventType,
Payload: row.Payload, Status: row.Status, Attempts: row.Attempts, NextRetryAt: row.NextRetryAt,
DeliveredAt: row.DeliveredAt, CreatedAt: row.CreatedAt, UpdatedAt: row.UpdatedAt})
}
return events, rows.Err()
}
func (a *adapter) TenantBusinessEventMarkDelivered(id t.TenantID, eventID string, now time.Time) error {
tenantAdp, err := a.infrastructureAdapter(id)
if err != nil {
return err
}
if eventID == "" {
return t.ErrMalformed
}
if tenantAdp != a {
return tenantAdp.TenantBusinessEventMarkDelivered(id, eventID, now)
}
res, err := a.db.Exec(`UPDATE im_business_events_outbox SET status=?,delivered_at=?,attempts=attempts+1,updated_at=?
WHERE event_id=? AND status=?`, t.BusinessEventDelivered, now, now, eventID, t.BusinessEventPending)
if err != nil {
return err
}
if count, _ := res.RowsAffected(); count == 0 {
return t.ErrNotFound
}
return nil
}
func insertBusinessEventTx(tx *sqlx.Tx, eventID, targetType, target string, actor t.Uid, eventType string, payload any) error {
encoded, err := json.Marshal(payload)
if err != nil {
return err
}
now := t.TimeNow()
_, err = tx.Exec(`INSERT INTO im_business_events_outbox
(event_id,target_type,target,actor_user_id,event_type,payload,status,created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?,?) ON DUPLICATE KEY UPDATE event_id=VALUES(event_id)`, eventID, targetType,
target, decodeUID(actor), eventType, encoded, t.BusinessEventPending, now, now)
return err
}
func insertSystemNotificationTx(tx *sqlx.Tx, uid t.Uid, eventID, eventType, title, summary string, action any) error {
encoded, err := json.Marshal(action)
if err != nil {
return err
}
now := t.TimeNow()
_, err = tx.Exec(`INSERT INTO im_system_notifications
(user_id,event_id,event_type,title,summary,action,created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?) ON DUPLICATE KEY UPDATE event_type=VALUES(event_type),title=VALUES(title),
summary=VALUES(summary),action=VALUES(action),updated_at=VALUES(updated_at)`, decodeUID(uid), eventID,
eventType, title, summary, encoded, now, now)
return err
}
+165 -66
View File
@@ -15,6 +15,7 @@ import (
"log"
"os"
"reflect"
"strings"
"testing"
"time"
@@ -48,17 +49,126 @@ var testData *test_data.TestData
var dummyUid1 = types.Uid(12345)
var dummyUid2 = types.Uid(54321)
func TestCreateDb(t *testing.T) {
if err := adp.CreateDb(config.Reset); err != nil {
t.Fatal(err)
}
// Saved db is closed, get a fresh one.
db = adp.GetTestDB().(*sqlx.DB)
}
func TestGroupBusinessTablesCreated(t *testing.T) {
tables := []string{
"im_group_settings", "im_group_members", "im_group_invitations", "im_group_join_requests",
"im_group_announcements", "im_group_announcement_reads", "im_group_mutes", "im_group_qrcodes",
"im_group_files", "im_group_admin_logs", "im_group_idempotency", "im_group_events_outbox",
}
args := make([]any, len(tables))
for i, table := range tables {
args[i] = table
}
placeholders := strings.TrimSuffix(strings.Repeat("?,", len(tables)), ",")
var count int
query := "SELECT COUNT(*) FROM information_schema.tables " +
"WHERE table_schema=DATABASE() AND table_name IN (" + placeholders + ")"
if err := db.Get(&count, query, args...); err != nil {
t.Fatal(err)
}
if count != len(tables) {
t.Fatalf("group business table count: got %d, want %d", count, len(tables))
}
query = "SELECT COUNT(*) FROM information_schema.columns " +
"WHERE table_schema=DATABASE() AND table_name IN (" + placeholders + ") AND column_name='tenant_id'"
if err := db.Get(&count, query, args...); err != nil {
t.Fatal(err)
}
if count != 0 {
t.Fatalf("tenant business tables contain %d tenant_id columns", count)
}
}
func TestHTTPInfrastructureTablesCreated(t *testing.T) {
tables := []string{"im_http_idempotency", "im_business_events_outbox"}
args := make([]any, len(tables))
for i, table := range tables {
args[i] = table
}
placeholders := strings.TrimSuffix(strings.Repeat("?,", len(tables)), ",")
var count int
query := "SELECT COUNT(*) FROM information_schema.tables " +
"WHERE table_schema=DATABASE() AND table_name IN (" + placeholders + ")"
if err := db.Get(&count, query, args...); err != nil {
t.Fatal(err)
}
if count != len(tables) {
t.Fatalf("HTTP infrastructure table count: got %d, want %d", count, len(tables))
}
query = "SELECT COUNT(*) FROM information_schema.columns " +
"WHERE table_schema=DATABASE() AND table_name IN (" + placeholders + ") AND column_name='tenant_id'"
if err := db.Get(&count, query, args...); err != nil {
t.Fatal(err)
}
if count != 0 {
t.Fatalf("HTTP infrastructure tables contain %d tenant_id columns", count)
}
}
func TestTenantAuthIsolation(t *testing.T) {
tenantAdp, ok := adp.(adapter.TenantAuthAdapter)
if !ok {
t.Fatal("MySQL adapter does not implement TenantAuthAdapter")
}
var routingConfig struct {
Enabled bool `json:"tenant_db_routing"`
}
if err := json.Unmarshal(config.Adapters[adp.GetName()], &routingConfig); err != nil {
t.Fatal(err)
}
if !routingConfig.Enabled {
t.Skip("tenant_db_routing is disabled in the MySQL test configuration")
}
router, ok := adp.(adapter.TenantRoutedAdapter)
if !ok {
t.Fatal("MySQL adapter does not implement TenantRoutedAdapter")
}
now := types.TimeNow()
suffix := fmt.Sprintf("%d", now.UnixNano())
createTenant := func(code string) types.TenantID {
res, err := db.Exec("INSERT INTO im_tenant(code,name,state,created_by,updated_by) VALUES(?,?,?,?,?)",
code+suffix, code, types.TenantStateActive, 0, 0)
dbNames := []string{"im_tenant_auth_a_" + suffix, "im_tenant_auth_b_" + suffix}
quoteIdent := func(value string) string {
return "`" + strings.ReplaceAll(value, "`", "``") + "`"
}
var baseDB string
if err := db.Get(&baseDB, "SELECT DATABASE()"); err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
db.Exec("DELETE FROM im_tenant WHERE code IN (?,?)", "test-im_auth-a-"+suffix, "test-im_auth-b-"+suffix)
for _, dbName := range dbNames {
db.Exec("DROP DATABASE IF EXISTS " + quoteIdent(dbName))
}
})
for _, dbName := range dbNames {
if _, err := db.Exec("CREATE DATABASE " + quoteIdent(dbName) +
" CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci"); err != nil {
t.Fatal(err)
}
for _, table := range []string{"im_users", "im_auth", "im_credentials"} {
query := "CREATE TABLE " + quoteIdent(dbName) + "." + quoteIdent(table) +
" LIKE " + quoteIdent(baseDB) + "." + quoteIdent(table)
if _, err := db.Exec(query); err != nil {
t.Fatal(err)
}
}
}
createTenant := func(code, dbName string) types.TenantID {
res, err := db.Exec("INSERT INTO im_tenant(code,name,db_name,state,created_by,updated_by) VALUES(?,?,?,?,?,?)",
code+suffix, code, dbName, types.TenantStateActive, 0, 0)
if err != nil {
t.Fatal(err)
}
@@ -68,44 +178,41 @@ func TestTenantAuthIsolation(t *testing.T) {
}
return types.TenantID(id)
}
tenantA := createTenant("test-im_auth-a-", dbNames[0])
tenantB := createTenant("test-im_auth-b-", dbNames[1])
businessA, _, err := router.TenantAdapterFor(tenantA)
if err != nil {
t.Fatal(err)
}
if reflect.ValueOf(businessA).Pointer() == reflect.ValueOf(adp).Pointer() {
t.Skip("tenant_db_routing is disabled in the MySQL test configuration")
}
tenantA := createTenant("test-auth-a-")
tenantB := createTenant("test-auth-b-")
userAID := now.UnixNano() & 0x3fffffffffffffff
userBID := userAID + 1
t.Cleanup(func() {
db.Exec("DELETE FROM credentials WHERE tenant_id IN (?,?)", tenantA, tenantB)
db.Exec("DELETE FROM auth WHERE tenant_id IN (?,?)", tenantA, tenantB)
db.Exec("DELETE FROM users WHERE tenant_id IN (?,?)", tenantA, tenantB)
db.Exec("DELETE FROM im_tenant WHERE id IN (?,?)", tenantA, tenantB)
})
for tenantID, userID := range map[types.TenantID]int64{tenantA: userAID, tenantB: userBID} {
_, err := db.Exec("INSERT INTO users(id,tenant_id,createdat,updatedat,state,access,public,trusted,tags) "+
"VALUES(?,?,?,?,?,?,?,?,?)", userID, tenantID, now, now, types.StateOK, `{}`, nil, nil, nil)
if err != nil {
for index, userID := range []int64{userAID, userBID} {
tenantDB := quoteIdent(dbNames[index])
if _, err := db.Exec("INSERT INTO "+tenantDB+".im_users(id,createdat,updatedat,state,access,public,trusted,tags) "+
"VALUES(?,?,?,?,?,?,?,?)", userID, now, now, types.StateOK, `{}`, nil, nil, nil); err != nil {
t.Fatal(err)
}
_, err = db.Exec("INSERT INTO auth(tenant_id,uname,userid,scheme,authlvl,secret) VALUES(?,?,?,?,?,?)",
tenantID, "basic:alice", userID, "basic", auth.LevelAuth, []byte("hash"))
if err != nil {
if _, err := db.Exec("INSERT INTO "+tenantDB+".im_auth(uname,userid,scheme,authlvl,secret) VALUES(?,?,?,?,?)",
"basic:alice", userID, "basic", auth.LevelAuth, []byte("hash")); err != nil {
t.Fatal(err)
}
_, err = db.Exec("INSERT INTO credentials(tenant_id,createdat,updatedat,method,value,synthetic,userid,resp,done) "+
"VALUES(?,?,?,?,?,?,?,?,?)", tenantID, now, now, "tel", "13800000000", "tel:13800000000",
userID, "", true)
if err != nil {
if _, err := db.Exec("INSERT INTO "+tenantDB+".im_credentials(createdat,updatedat,method,value,synthetic,userid,resp,done) "+
"VALUES(?,?,?,?,?,?,?,?)", now, now, "tel", "13800000000", "tel:13800000000", userID, "", true); err != nil {
t.Fatal(err)
}
}
uidA, _, _, _, err := tenantAdp.TenantAuthGetUniqueRecord(tenantA, "basic:alice")
if err != nil || uidA != store.EncodeUid(userAID) {
t.Fatalf("tenant A auth lookup: uid=%v err=%v", uidA, err)
t.Fatalf("tenant A im_auth lookup: uid=%v err=%v", uidA, err)
}
uidB, _, _, _, err := tenantAdp.TenantAuthGetUniqueRecord(tenantB, "basic:alice")
if err != nil || uidB != store.EncodeUid(userBID) {
t.Fatalf("tenant B auth lookup: uid=%v err=%v", uidB, err)
t.Fatalf("tenant B im_auth lookup: uid=%v err=%v", uidB, err)
}
credA, err := tenantAdp.TenantUserGetByCred(tenantA, "tel", "13800000000")
if err != nil || credA != uidA {
@@ -118,18 +225,10 @@ func TestTenantAuthIsolation(t *testing.T) {
if err = tenantAdp.TenantAuthUpdRecord(tenantA, uidB, "basic", "basic:changed",
auth.LevelAuth, nil, time.Time{}); err != types.ErrNotFound {
t.Fatalf("cross-tenant auth update: got %v, want %v", err, types.ErrNotFound)
t.Fatalf("cross-tenant im_auth update: got %v, want %v", err, types.ErrNotFound)
}
}
func TestCreateDb(t *testing.T) {
if err := adp.CreateDb(config.Reset); err != nil {
t.Fatal(err)
}
// Saved db is closed, get a fresh one.
db = adp.GetTestDB().(*sqlx.DB)
}
// ================== Create tests ================================
func TestUserCreate(t *testing.T) {
for _, user := range testData.Users {
@@ -142,12 +241,12 @@ func TestUserCreate(t *testing.T) {
if err := db.Ping(); err != nil {
logs.Err.Println("Database ping failed:", err)
}
err := db.QueryRow("SELECT COUNT(*) FROM users").Scan(&count)
err := db.QueryRow("SELECT COUNT(*) FROM im_users").Scan(&count)
if err != nil {
t.Error(err)
}
if count == 0 {
t.Error("No users created!")
t.Error("No im_users created!")
}
}
@@ -245,7 +344,7 @@ func TestTopicCreateP2P(t *testing.T) {
}
var got types.Subscription
err = db.QueryRow("SELECT createdat,updatedat,deletedat,userid,topic,delid,recvseqid,readseqid,modewant,modegiven,private FROM subscriptions WHERE topic=? AND userid=?",
err = db.QueryRow("SELECT createdat,updatedat,deletedat,userid,topic,delid,recvseqid,readseqid,modewant,modegiven,private FROM im_subscriptions WHERE topic=? AND userid=?",
testData.Subs[2].Topic, decodeUid(testData.Subs[2].User)).Scan(&got.CreatedAt,
&got.UpdatedAt, &got.DeletedAt, &got.User, &got.Topic, &got.DelId, &got.RecvSeqId, &got.ReadSeqId, &got.ModeWant, &got.ModeGiven, &got.Private)
if err != nil {
@@ -346,7 +445,7 @@ func TestUserGetAll(t *testing.T) {
t.Fatal(err)
}
if len(got) > 0 {
t.Error("result users should be zero length, got", len(got))
t.Error("result im_users should be zero length, got", len(got))
}
got, err = adp.UserGetAll(types.ParseUserId("usr"+testData.Users[0].Id), types.ParseUserId("usr"+testData.Users[1].Id))
@@ -698,7 +797,7 @@ func TestUserUpdate(t *testing.T) {
UpdatedAt time.Time
CreatedAt time.Time
}
err = db.QueryRow("SELECT useragent, updatedat, createdat FROM users WHERE id=?", decodeUid(testData.Users[0].Id)).
err = db.QueryRow("SELECT useragent, updatedat, createdat FROM im_users WHERE id=?", decodeUid(testData.Users[0].Id)).
Scan(&got.UserAgent, &got.UpdatedAt, &got.CreatedAt)
if err != nil {
t.Fatal(err)
@@ -775,7 +874,7 @@ func TestCredFail(t *testing.T) {
UpdatedAt time.Time
CreatedAt time.Time
}
err = db.QueryRow("SELECT retries, updatedat, createdat FROM credentials WHERE userid=? AND method=? AND value=?",
err = db.QueryRow("SELECT retries, updatedat, createdat FROM im_credentials WHERE userid=? AND method=? AND value=?",
decodeUid(testData.Creds[3].User), "tel", testData.Creds[3].Value).Scan(&got.Retries, &got.UpdatedAt, &got.CreatedAt)
if err != nil {
t.Fatal(err)
@@ -800,7 +899,7 @@ func TestCredConfirm(t *testing.T) {
CreatedAt time.Time
Done bool
}
err = db.QueryRow("SELECT updatedat, createdat, done FROM credentials WHERE userid=? AND method=? AND value=?",
err = db.QueryRow("SELECT updatedat, createdat, done FROM im_credentials WHERE userid=? AND method=? AND value=?",
decodeUid(testData.Creds[3].User), "tel", testData.Creds[3].Value).Scan(&got.UpdatedAt, &got.CreatedAt, &got.Done)
if err != nil {
t.Fatal(err)
@@ -822,7 +921,7 @@ func TestAuthUpdRecord(t *testing.T) {
t.Fatal(err)
}
var got []byte
err = db.QueryRow("SELECT secret FROM auth WHERE uname=?", rec.Unique).Scan(&got)
err = db.QueryRow("SELECT secret FROM im_auth WHERE uname=?", rec.Unique).Scan(&got)
if err != nil {
t.Fatal(err)
}
@@ -839,12 +938,12 @@ func TestAuthUpdRecord(t *testing.T) {
}
// Test if old ID deleted
var count int
err = db.QueryRow("SELECT COUNT(*) FROM auth WHERE uname=?", rec.Unique).Scan(&count)
err = db.QueryRow("SELECT COUNT(*) FROM im_auth WHERE uname=?", rec.Unique).Scan(&count)
if err != nil {
t.Fatal(err)
}
if count != 0 {
t.Error("Old auth record not deleted")
t.Error("Old im_auth record not deleted")
}
}
@@ -863,7 +962,7 @@ func TestTopicUpdateOnMessage(t *testing.T) {
TouchedAt time.Time
SeqId int
}
err = db.QueryRow("SELECT touchedat, seqid FROM topics WHERE name=?", testData.Topics[2].Id).
err = db.QueryRow("SELECT touchedat, seqid FROM im_topics WHERE name=?", testData.Topics[2].Id).
Scan(&got.TouchedAt, &got.SeqId)
if err != nil {
t.Fatal(err)
@@ -883,7 +982,7 @@ func TestTopicUpdate(t *testing.T) {
t.Fatal(err)
}
var got time.Time
err = db.QueryRow("SELECT updatedat FROM topics WHERE name=?", testData.Topics[0].Id).Scan(&got)
err = db.QueryRow("SELECT updatedat FROM im_topics WHERE name=?", testData.Topics[0].Id).Scan(&got)
if err != nil {
t.Fatal(err)
}
@@ -898,7 +997,7 @@ func TestTopicOwnerChange(t *testing.T) {
t.Fatal(err)
}
var got int64
err = db.QueryRow("SELECT owner FROM topics WHERE name=?", testData.Topics[0].Id).Scan(&got)
err = db.QueryRow("SELECT owner FROM im_topics WHERE name=?", testData.Topics[0].Id).Scan(&got)
if err != nil {
t.Fatal(err)
}
@@ -917,7 +1016,7 @@ func TestSubsUpdate(t *testing.T) {
t.Fatal(err)
}
var got time.Time
err = db.QueryRow("SELECT updatedat FROM subscriptions WHERE topic=? AND userid=?",
err = db.QueryRow("SELECT updatedat FROM im_subscriptions WHERE topic=? AND userid=?",
testData.Topics[0].Id, decodeUid(testData.Users[0].Id)).Scan(&got)
if err != nil {
t.Fatal(err)
@@ -930,7 +1029,7 @@ func TestSubsUpdate(t *testing.T) {
if err != nil {
t.Fatal(err)
}
err = db.QueryRow("SELECT updatedat FROM subscriptions WHERE topic=? LIMIT 1",
err = db.QueryRow("SELECT updatedat FROM im_subscriptions WHERE topic=? LIMIT 1",
testData.Topics[1].Id).Scan(&got)
if err != nil {
t.Fatal(err)
@@ -946,7 +1045,7 @@ func TestSubsDelete(t *testing.T) {
t.Fatal(err)
}
var deletedat sql.NullTime
err = db.QueryRow("SELECT deletedat FROM subscriptions WHERE topic=? AND userid=?",
err = db.QueryRow("SELECT deletedat FROM im_subscriptions WHERE topic=? AND userid=?",
testData.Topics[1].Id, decodeUid(testData.Users[0].Id)).Scan(&deletedat)
if err != nil {
t.Fatal(err)
@@ -965,7 +1064,7 @@ func TestDeviceUpsert(t *testing.T) {
DeviceId string
Platform string
}
err = db.QueryRow("SELECT deviceid, platform FROM devices WHERE userid=? LIMIT 1",
err = db.QueryRow("SELECT deviceid, platform FROM im_devices WHERE userid=? LIMIT 1",
decodeUid(testData.Users[0].Id)).Scan(&got.DeviceId, &got.Platform)
if err != nil {
t.Fatal(err)
@@ -980,7 +1079,7 @@ func TestDeviceUpsert(t *testing.T) {
if err != nil {
t.Fatal(err)
}
err = db.QueryRow("SELECT platform FROM devices WHERE userid=? AND deviceid=?",
err = db.QueryRow("SELECT platform FROM im_devices WHERE userid=? AND deviceid=?",
decodeUid(testData.Users[0].Id), testData.Devs[0].DeviceId).Scan(&got.Platform)
if err != nil {
t.Fatal(err)
@@ -994,7 +1093,7 @@ func TestDeviceUpsert(t *testing.T) {
if err != nil {
t.Fatal(err)
}
err = db.QueryRow("SELECT platform FROM devices WHERE userid=? AND deviceid=?",
err = db.QueryRow("SELECT platform FROM im_devices WHERE userid=? AND deviceid=?",
decodeUid(testData.Users[1].Id), testData.Devs[0].DeviceId).Scan(&got.Platform)
if err != nil {
t.Fatal(err)
@@ -1030,7 +1129,7 @@ func TestMessageAttachments(t *testing.T) {
}
// Check if attachments were linked (this would require checking filemsglinks table)
var count int
if err = db.QueryRow("SELECT COUNT(*) FROM filemsglinks WHERE msgid=?",
if err = db.QueryRow("SELECT COUNT(*) FROM im_filemsglinks WHERE msgid=?",
types.ParseUid(testData.Msgs[1].Id)).Scan(&count); err != nil {
t.Fatal(err)
}
@@ -1066,7 +1165,7 @@ func TestDeviceDelete(t *testing.T) {
t.Fatal(err)
}
var count int
err = db.QueryRow("SELECT COUNT(*) FROM devices WHERE userid=?", testData.Users[1].Id).Scan(&count)
err = db.QueryRow("SELECT COUNT(*) FROM im_devices WHERE userid=?", testData.Users[1].Id).Scan(&count)
if err != nil {
t.Fatal(err)
}
@@ -1078,7 +1177,7 @@ func TestDeviceDelete(t *testing.T) {
if err != nil {
t.Fatal(err)
}
err = db.QueryRow("SELECT COUNT(*) FROM devices WHERE userid=?", testData.Users[2].Id).Scan(&count)
err = db.QueryRow("SELECT COUNT(*) FROM im_devices WHERE userid=?", testData.Users[2].Id).Scan(&count)
if err != nil {
t.Fatal(err)
}
@@ -1154,7 +1253,7 @@ func TestCredDel(t *testing.T) {
t.Fatal(err)
}
var count int
err = db.QueryRow("SELECT COUNT(*) FROM credentials WHERE method='email' AND value='alice@test.example.com'").Scan(&count)
err = db.QueryRow("SELECT COUNT(*) FROM im_credentials WHERE method='email' AND value='alice@test.example.com'").Scan(&count)
if err != nil {
t.Fatal(err)
}
@@ -1166,7 +1265,7 @@ func TestCredDel(t *testing.T) {
if err != nil {
t.Fatal(err)
}
err = db.QueryRow("SELECT COUNT(*) FROM credentials WHERE userid=?", testData.Users[1].Id).Scan(&count)
err = db.QueryRow("SELECT COUNT(*) FROM im_credentials WHERE userid=?", testData.Users[1].Id).Scan(&count)
if err != nil {
t.Fatal(err)
}
@@ -1218,13 +1317,13 @@ func TestMessageDeleteList(t *testing.T) {
// Check messages in dellog
var count int
err = db.QueryRow("SELECT COUNT(*) FROM dellog WHERE topic=? AND deletedfor=?",
err = db.QueryRow("SELECT COUNT(*) FROM im_dellog WHERE topic=? AND deletedfor=?",
toDel.Topic, decodeUid(toDel.DeletedFor)).Scan(&count)
if err != nil {
t.Fatal(err)
}
if count == 0 {
t.Error("No dellog entries created")
t.Error("No im_dellog entries created")
}
// Hard delete test
@@ -1244,7 +1343,7 @@ func TestMessageDeleteList(t *testing.T) {
}
// Check if messages content was cleared
err = db.QueryRow("SELECT COUNT(*) FROM messages WHERE topic=? AND content IS NOT NULL",
err = db.QueryRow("SELECT COUNT(*) FROM im_messages WHERE topic=? AND content IS NOT NULL",
toDel.Topic).Scan(&count)
if err != nil {
t.Fatal(err)
@@ -1257,7 +1356,7 @@ func TestMessageDeleteList(t *testing.T) {
if err != nil {
t.Fatal(err)
}
err = db.QueryRow("SELECT COUNT(*) FROM messages WHERE topic=?", testData.Topics[0].Id).Scan(&count)
err = db.QueryRow("SELECT COUNT(*) FROM im_messages WHERE topic=?", testData.Topics[0].Id).Scan(&count)
if err != nil {
t.Fatal(err)
}
@@ -1272,7 +1371,7 @@ func TestTopicDelete(t *testing.T) {
t.Fatal(err)
}
var state int
err = db.QueryRow("SELECT state FROM topics WHERE name=?", testData.Topics[1].Id).Scan(&state)
err = db.QueryRow("SELECT state FROM im_topics WHERE name=?", testData.Topics[1].Id).Scan(&state)
if err != nil {
t.Fatal(err)
}
@@ -1286,7 +1385,7 @@ func TestTopicDelete(t *testing.T) {
}
var count int
err = db.QueryRow("SELECT COUNT(*) FROM topics WHERE name=?", testData.Topics[0].Id).Scan(&count)
err = db.QueryRow("SELECT COUNT(*) FROM im_topics WHERE name=?", testData.Topics[0].Id).Scan(&count)
if err != nil {
t.Fatal(err)
}
@@ -1311,7 +1410,7 @@ func TestUserDelete(t *testing.T) {
t.Fatal(err)
}
var state int
err = db.QueryRow("SELECT state FROM users WHERE id=?",
err = db.QueryRow("SELECT state FROM im_users WHERE id=?",
decodeUid(testData.Users[0].Id)).Scan(&state)
if err != nil {
t.Fatal(err)
@@ -1325,7 +1424,7 @@ func TestUserDelete(t *testing.T) {
t.Fatal(err)
}
var count int
err = db.QueryRow("SELECT COUNT(*) FROM users WHERE id=?", testData.Users[1].Id).Scan(&count)
err = db.QueryRow("SELECT COUNT(*) FROM im_users WHERE id=?", testData.Users[1].Id).Scan(&count)
if err != nil {
t.Fatal(err)
}
File diff suppressed because it is too large Load Diff
+45 -45
View File
@@ -66,12 +66,12 @@ func TestUserCreate(t *testing.T) {
}
var count int
err := db.QueryRow(ctx, "SELECT COUNT(*) FROM users").Scan(&count)
err := db.QueryRow(ctx, "SELECT COUNT(*) FROM im_users").Scan(&count)
if err != nil {
t.Error(err)
}
if count == 0 {
t.Error("No users created!")
t.Error("No im_users created!")
}
}
@@ -97,7 +97,7 @@ func TestCredUpsert(t *testing.T) {
t.Error("Should return duplicate error but got", err)
}
// Test add new unvalidated credentials
// Test add new unvalidated im_credentials
inserted, err := adp.CredUpsert(testData.Creds[3])
if err != nil {
t.Fatal(err)
@@ -183,7 +183,7 @@ func TestTopicCreateP2P(t *testing.T) {
var got types.Subscription
var userId int64
var modeWant, modeGiven []byte
err = db.QueryRow(ctx, "SELECT createdat,updatedat,deletedat,userid,topic,delid,recvseqid,readseqid,modewant,modegiven,private FROM subscriptions WHERE topic=$1 AND userid=$2",
err = db.QueryRow(ctx, "SELECT createdat,updatedat,deletedat,userid,topic,delid,recvseqid,readseqid,modewant,modegiven,private FROM im_subscriptions WHERE topic=$1 AND userid=$2",
testData.Subs[2].Topic, decodeUid(testData.Subs[2].User)).Scan(&got.CreatedAt,
&got.UpdatedAt, &got.DeletedAt, &userId, &got.Topic, &got.DelId, &got.RecvSeqId, &got.ReadSeqId,
&modeWant, &modeGiven, &got.Private)
@@ -231,7 +231,7 @@ func TestMessageSave(t *testing.T) {
}
}
// Some messages are soft deleted, but it's ignored by adp.MessageSave
// Some im_messages are soft deleted, but it's ignored by adp.MessageSave
for _, msg := range testData.Msgs {
if len(msg.DeletedFor) > 0 {
for _, del := range msg.DeletedFor {
@@ -284,7 +284,7 @@ func TestUserGetAll(t *testing.T) {
t.Fatal(err)
}
if len(got) > 0 {
t.Error("result users should be zero length, got", len(got))
t.Error("result im_users should be zero length, got", len(got))
}
got, err = adp.UserGetAll(types.ParseUserId("usr"+testData.Users[0].Id), types.ParseUserId("usr"+testData.Users[1].Id))
@@ -516,7 +516,7 @@ func TestChannelsForUser(t *testing.T) {
if err != nil {
t.Fatal(err)
}
// Should return empty slice since we don't have channel subscriptions in test data
// Should return empty slice since we don't have channel im_subscriptions in test data
if len(channels) != 0 {
t.Error(mismatchErrorString("Channels length", len(channels), 0))
}
@@ -663,7 +663,7 @@ func TestUserUpdate(t *testing.T) {
UpdatedAt time.Time
CreatedAt time.Time
}
err = db.QueryRow(ctx, "SELECT useragent, updatedat, createdat FROM users WHERE id=$1",
err = db.QueryRow(ctx, "SELECT useragent, updatedat, createdat FROM im_users WHERE id=$1",
decodeUid(testData.Users[0].Id)).Scan(&got.UserAgent, &got.UpdatedAt, &got.CreatedAt)
if err != nil {
t.Fatal(err)
@@ -726,9 +726,9 @@ func TestUserGetUnvalidated(t *testing.T) {
if err != nil {
t.Error(err)
}
// Should return empty slice since all test users are considered validated
// Should return empty slice since all test im_users are considered validated
if len(uids) > 0 {
t.Error("Expected no unvalidated users in test data")
t.Error("Expected no unvalidated im_users in test data")
}
}
@@ -744,7 +744,7 @@ func TestCredFail(t *testing.T) {
UpdatedAt time.Time
CreatedAt time.Time
}
err = db.QueryRow(ctx, "SELECT retries, updatedat, createdat FROM credentials WHERE userid=$1 AND method=$2 AND value=$3",
err = db.QueryRow(ctx, "SELECT retries, updatedat, createdat FROM im_credentials WHERE userid=$1 AND method=$2 AND value=$3",
decodeUid(testData.Creds[3].User), "tel", testData.Creds[3].Value).Scan(&got.Retries, &got.UpdatedAt, &got.CreatedAt)
if err != nil {
t.Fatal(err)
@@ -769,7 +769,7 @@ func TestCredConfirm(t *testing.T) {
CreatedAt time.Time
Done bool
}
err = db.QueryRow(ctx, "SELECT updatedat, createdat, done FROM credentials WHERE userid=$1 AND method=$2 AND value=$3",
err = db.QueryRow(ctx, "SELECT updatedat, createdat, done FROM im_credentials WHERE userid=$1 AND method=$2 AND value=$3",
decodeUid(testData.Creds[3].User), "tel", testData.Creds[3].Value).Scan(&got.UpdatedAt, &got.CreatedAt, &got.Done)
if err != nil {
t.Fatal(err)
@@ -791,7 +791,7 @@ func TestAuthUpdRecord(t *testing.T) {
t.Fatal(err)
}
var got []byte
err = db.QueryRow(ctx, "SELECT secret FROM auth WHERE uname=$1", rec.Unique).Scan(&got)
err = db.QueryRow(ctx, "SELECT secret FROM im_auth WHERE uname=$1", rec.Unique).Scan(&got)
if err != nil {
t.Fatal(err)
}
@@ -799,7 +799,7 @@ func TestAuthUpdRecord(t *testing.T) {
t.Error(mismatchErrorString("Secret", got, rec.Secret))
}
// Test with auth ID (unique) change
// Test with im_auth ID (unique) change
newId := "basic:bob12345"
err = adp.AuthUpdRecord(types.ParseUserId("usr"+rec.UserId), rec.Scheme, newId,
rec.AuthLvl, newSecret, rec.Expires)
@@ -808,12 +808,12 @@ func TestAuthUpdRecord(t *testing.T) {
}
// Test if old ID deleted
var count int
err = db.QueryRow(ctx, "SELECT COUNT(*) FROM auth WHERE uname=$1", rec.Unique).Scan(&count)
err = db.QueryRow(ctx, "SELECT COUNT(*) FROM im_auth WHERE uname=$1", rec.Unique).Scan(&count)
if err != nil {
t.Fatal(err)
}
if count != 0 {
t.Error("Old auth record not deleted")
t.Error("Old im_auth record not deleted")
}
}
@@ -832,7 +832,7 @@ func TestTopicUpdateOnMessage(t *testing.T) {
TouchedAt time.Time
SeqId int
}
err = db.QueryRow(ctx, "SELECT touchedat, seqid FROM topics WHERE name=$1", testData.Topics[2].Id).
err = db.QueryRow(ctx, "SELECT touchedat, seqid FROM im_topics WHERE name=$1", testData.Topics[2].Id).
Scan(&got.TouchedAt, &got.SeqId)
if err != nil {
t.Fatal(err)
@@ -852,7 +852,7 @@ func TestTopicUpdate(t *testing.T) {
t.Fatal(err)
}
var got time.Time
err = db.QueryRow(ctx, "SELECT updatedat FROM topics WHERE name=$1", testData.Topics[0].Id).Scan(&got)
err = db.QueryRow(ctx, "SELECT updatedat FROM im_topics WHERE name=$1", testData.Topics[0].Id).Scan(&got)
if err != nil {
t.Fatal(err)
}
@@ -870,11 +870,11 @@ func TestTopicUpdateSubCnt(t *testing.T) {
// Verify the subscription count was updated correctly
var subcnt int
err = db.QueryRow(ctx, "SELECT subcnt FROM topics WHERE name=$1", testData.Topics[0].Id).Scan(&subcnt)
err = db.QueryRow(ctx, "SELECT subcnt FROM im_topics WHERE name=$1", testData.Topics[0].Id).Scan(&subcnt)
if err != nil {
t.Fatal(err)
}
// Should match the number of active subscriptions
// Should match the number of active im_subscriptions
if subcnt < 0 {
t.Error("Subscription count should be non-negative")
}
@@ -886,7 +886,7 @@ func TestTopicOwnerChange(t *testing.T) {
t.Fatal(err)
}
var got int64
err = db.QueryRow(ctx, "SELECT owner FROM topics WHERE name=$1", testData.Topics[0].Id).Scan(&got)
err = db.QueryRow(ctx, "SELECT owner FROM im_topics WHERE name=$1", testData.Topics[0].Id).Scan(&got)
if err != nil {
t.Fatal(err)
}
@@ -905,7 +905,7 @@ func TestSubsUpdate(t *testing.T) {
t.Fatal(err)
}
var got time.Time
err = db.QueryRow(ctx, "SELECT updatedat FROM subscriptions WHERE topic=$1 AND userid=$2",
err = db.QueryRow(ctx, "SELECT updatedat FROM im_subscriptions WHERE topic=$1 AND userid=$2",
testData.Topics[0].Id, decodeUid(testData.Users[0].Id)).Scan(&got)
if err != nil {
t.Fatal(err)
@@ -918,7 +918,7 @@ func TestSubsUpdate(t *testing.T) {
if err != nil {
t.Fatal(err)
}
err = db.QueryRow(ctx, "SELECT updatedat FROM subscriptions WHERE topic=$1 LIMIT 1",
err = db.QueryRow(ctx, "SELECT updatedat FROM im_subscriptions WHERE topic=$1 LIMIT 1",
testData.Topics[1].Id).Scan(&got)
if err != nil {
t.Fatal(err)
@@ -934,7 +934,7 @@ func TestSubsDelete(t *testing.T) {
t.Fatal(err)
}
var deletedat sql.NullTime
err = db.QueryRow(ctx, "SELECT deletedat FROM subscriptions WHERE topic=$1 AND userid=$2",
err = db.QueryRow(ctx, "SELECT deletedat FROM im_subscriptions WHERE topic=$1 AND userid=$2",
testData.Topics[1].Id, decodeUid(testData.Users[0].Id)).Scan(&deletedat)
if err != nil {
t.Fatal(err)
@@ -957,7 +957,7 @@ func TestDeviceUpsert(t *testing.T) {
DeviceId string
Platform string
}
err = db.QueryRow(ctx, "SELECT deviceid, platform FROM devices WHERE userid=$1 LIMIT 1",
err = db.QueryRow(ctx, "SELECT deviceid, platform FROM im_devices WHERE userid=$1 LIMIT 1",
decodeUid(testData.Users[0].Id)).Scan(&got.DeviceId, &got.Platform)
if err != nil {
t.Fatal(err)
@@ -972,7 +972,7 @@ func TestDeviceUpsert(t *testing.T) {
if err != nil {
t.Fatal(err)
}
err = db.QueryRow(ctx, "SELECT platform FROM devices WHERE userid=$1 AND deviceid=$2",
err = db.QueryRow(ctx, "SELECT platform FROM im_devices WHERE userid=$1 AND deviceid=$2",
decodeUid(testData.Users[0].Id), testData.Devs[0].DeviceId).Scan(&got.Platform)
if err != nil {
t.Fatal(err)
@@ -1001,7 +1001,7 @@ func TestMessageAttachments(t *testing.T) {
}
// Check if attachments were linked
var count int
err = db.QueryRow(ctx, "SELECT COUNT(*) FROM filemsglinks WHERE msgid=$1",
err = db.QueryRow(ctx, "SELECT COUNT(*) FROM im_filemsglinks WHERE msgid=$1",
int64(types.ParseUid(testData.Msgs[1].Id))).Scan(&count)
if err != nil {
t.Fatal(err)
@@ -1036,9 +1036,9 @@ func TestDeviceGetAll(t *testing.T) {
if count < 1 {
t.Fatal(mismatchErrorString("count", count, ">=1"))
}
// Test that devices exist for the users
// Test that im_devices exist for the im_users
if len(gotDevs) == 0 {
t.Error("Expected devices for users")
t.Error("Expected im_devices for im_users")
}
}
@@ -1048,7 +1048,7 @@ func TestDeviceDelete(t *testing.T) {
t.Fatal(err)
}
var count int
err = db.QueryRow(ctx, "SELECT COUNT(*) FROM devices WHERE userid=$1 AND deviceid=$2",
err = db.QueryRow(ctx, "SELECT COUNT(*) FROM im_devices WHERE userid=$1 AND deviceid=$2",
decodeUid(testData.Users[1].Id), testData.Devs[0].DeviceId).Scan(&count)
if err != nil {
t.Fatal(err)
@@ -1061,13 +1061,13 @@ func TestDeviceDelete(t *testing.T) {
if err != nil {
t.Fatal(err)
}
err = db.QueryRow(ctx, "SELECT COUNT(*) FROM devices WHERE userid=$1",
err = db.QueryRow(ctx, "SELECT COUNT(*) FROM im_devices WHERE userid=$1",
decodeUid(testData.Users[2].Id)).Scan(&count)
if err != nil {
t.Fatal(err)
}
if count != 0 {
t.Error("All devices not deleted:", count)
t.Error("All im_devices not deleted:", count)
}
}
@@ -1138,7 +1138,7 @@ func TestCredDel(t *testing.T) {
t.Fatal(err)
}
var count int
err = db.QueryRow(ctx, "SELECT COUNT(*) FROM credentials WHERE method='email' AND value='alice@test.example.com'").Scan(&count)
err = db.QueryRow(ctx, "SELECT COUNT(*) FROM im_credentials WHERE method='email' AND value='alice@test.example.com'").Scan(&count)
if err != nil {
t.Fatal(err)
}
@@ -1150,7 +1150,7 @@ func TestCredDel(t *testing.T) {
if err != nil {
t.Fatal(err)
}
err = db.QueryRow(ctx, "SELECT COUNT(*) FROM credentials WHERE userid=$1",
err = db.QueryRow(ctx, "SELECT COUNT(*) FROM im_credentials WHERE userid=$1",
decodeUid(testData.Users[1].Id)).Scan(&count)
if err != nil {
t.Fatal(err)
@@ -1161,7 +1161,7 @@ func TestCredDel(t *testing.T) {
}
func TestAuthDelScheme(t *testing.T) {
// Test deleting auth scheme
// Test deleting im_auth scheme
err := adp.AuthDelScheme(types.ParseUserId("usr"+testData.Recs[1].UserId), testData.Recs[1].Scheme)
if err != nil {
t.Fatal(err)
@@ -1207,15 +1207,15 @@ func TestMessageDeleteList(t *testing.T) {
t.Fatal(err)
}
// Check messages in dellog
// Check im_messages in im_dellog
var count int
err = db.QueryRow(ctx, "SELECT COUNT(*) FROM dellog WHERE topic=$1 AND deletedfor=$2",
err = db.QueryRow(ctx, "SELECT COUNT(*) FROM im_dellog WHERE topic=$1 AND deletedfor=$2",
toDel.Topic, decodeUid(toDel.DeletedFor)).Scan(&count)
if err != nil {
t.Fatal(err)
}
if count == 0 {
t.Error("No dellog entries created")
t.Error("No im_dellog entries created")
}
// Hard delete test
@@ -1234,8 +1234,8 @@ func TestMessageDeleteList(t *testing.T) {
t.Fatal(err)
}
// Check if messages content was cleared
err = db.QueryRow(ctx, "SELECT COUNT(*) FROM messages WHERE topic=$1 AND content IS NOT NULL",
// Check if im_messages content was cleared
err = db.QueryRow(ctx, "SELECT COUNT(*) FROM im_messages WHERE topic=$1 AND content IS NOT NULL",
toDel.Topic).Scan(&count)
if err != nil {
t.Fatal(err)
@@ -1248,7 +1248,7 @@ func TestMessageDeleteList(t *testing.T) {
if err != nil {
t.Fatal(err)
}
err = db.QueryRow(ctx, "SELECT COUNT(*) FROM messages WHERE topic=$1", testData.Topics[0].Id).Scan(&count)
err = db.QueryRow(ctx, "SELECT COUNT(*) FROM im_messages WHERE topic=$1", testData.Topics[0].Id).Scan(&count)
if err != nil {
t.Fatal(err)
}
@@ -1263,7 +1263,7 @@ func TestTopicDelete(t *testing.T) {
t.Fatal(err)
}
var state int
err = db.QueryRow(ctx, "SELECT state FROM topics WHERE name=$1", testData.Topics[1].Id).Scan(&state)
err = db.QueryRow(ctx, "SELECT state FROM im_topics WHERE name=$1", testData.Topics[1].Id).Scan(&state)
if err != nil {
t.Fatal(err)
}
@@ -1277,7 +1277,7 @@ func TestTopicDelete(t *testing.T) {
}
var count int
err = db.QueryRow(ctx, "SELECT COUNT(*) FROM topics WHERE name=$1", testData.Topics[0].Id).Scan(&count)
err = db.QueryRow(ctx, "SELECT COUNT(*) FROM im_topics WHERE name=$1", testData.Topics[0].Id).Scan(&count)
if err != nil {
t.Fatal(err)
}
@@ -1302,7 +1302,7 @@ func TestUserDelete(t *testing.T) {
t.Fatal(err)
}
var state int
err = db.QueryRow(ctx, "SELECT state FROM users WHERE id=$1",
err = db.QueryRow(ctx, "SELECT state FROM im_users WHERE id=$1",
decodeUid(testData.Users[0].Id)).Scan(&state)
if err != nil {
t.Fatal(err)
@@ -1316,7 +1316,7 @@ func TestUserDelete(t *testing.T) {
t.Fatal(err)
}
var count int
err = db.QueryRow(ctx, "SELECT COUNT(*) FROM users WHERE id=$1",
err = db.QueryRow(ctx, "SELECT COUNT(*) FROM im_users WHERE id=$1",
decodeUid(testData.Users[1].Id)).Scan(&count)
if err != nil {
t.Fatal(err)
+2 -2
View File
@@ -28,13 +28,13 @@ import (
"github.com/tinode/chat/server/store/types"
)
func listenAndServe(addr string, mux *http.ServeMux, tlfConf *tls.Config, stop <-chan bool) error {
func listenAndServe(addr string, handler http.Handler, tlfConf *tls.Config, stop <-chan bool) error {
globals.shuttingDown = false
httpdone := make(chan bool)
server := &http.Server{
Handler: mux,
Handler: handler,
ReadHeaderTimeout: 10 * time.Second,
IdleTimeout: 30 * time.Second,
WriteTimeout: 90 * time.Second,
+120
View File
@@ -0,0 +1,120 @@
package main
import (
"encoding/json"
"strings"
"time"
"github.com/tinode/chat/server/logs"
"github.com/tinode/chat/server/store"
"github.com/tinode/chat/server/store/types"
)
func stableIMEventID(parts ...any) string {
return types.StableEventID(parts...)
}
func enqueueIMBusinessEvent(tenantID types.TenantID, eventID, targetType, target string,
actor types.Uid, eventType string, payload any) error {
if tenantID.IsZero() || eventID == "" || target == "" || eventType == "" {
return types.ErrMalformed
}
encoded, err := json.Marshal(payload)
if err != nil {
return err
}
err = store.HTTPInfrastructure.CreateEvent(tenantID, &types.BusinessEvent{
EventID: eventID, TargetType: targetType, Target: target, ActorUserID: actor,
EventType: eventType, Payload: encoded,
})
if err == nil {
dispatchIMBusinessEvents(tenantID)
}
return err
}
func createIMSystemNotification(tenantID types.TenantID, uid types.Uid, eventID, eventType,
title, summary string, action types.KVMap) error {
if uid.IsZero() {
return types.ErrMalformed
}
return store.Security.CreateSystemNotification(tenantID, &types.SystemNotification{
TenantID: tenantID, UserID: uid, EventID: eventID, EventType: eventType,
Title: title, Summary: summary, Action: action,
})
}
func publishIMMeetingEvent(tenantID types.TenantID, actor types.Uid, meeting *types.Meeting, eventType string) error {
if meeting == nil {
return types.ErrMalformed
}
for _, participant := range meeting.Participants {
uid := participant.UserID
if uid.IsZero() {
continue
}
eventID := stableIMEventID("meeting", meeting.MeetingID, meeting.Version, eventType, uid.UserId())
if uid != actor {
if err := createIMSystemNotification(tenantID, uid, stableIMEventID(eventID, "notification"), eventType,
"会议通知", meeting.Title, types.KVMap{"meeting_id": meeting.MeetingID}); err != nil {
return err
}
}
if err := enqueueIMBusinessEvent(tenantID, eventID, "user", uid.UserId(), actor, eventType,
map[string]any{"meeting_id": meeting.MeetingID, "version": meeting.Version, "state": meeting.State}); err != nil {
return err
}
}
return nil
}
func publishIMRelationEvent(tenantID types.TenantID, actor, target types.Uid, stableID, eventType, title, summary string) error {
if target.IsZero() {
return types.ErrMalformed
}
eventID := stableIMEventID("relation", stableID, eventType, target.UserId())
if target != actor && title != "" {
if err := createIMSystemNotification(tenantID, target, stableIMEventID(eventID, "notification"), eventType,
title, summary, types.KVMap{"user_id": actor.UserId()}); err != nil {
return err
}
}
return enqueueIMBusinessEvent(tenantID, eventID, "user", target.UserId(), actor, eventType,
map[string]any{"user_id": actor.UserId()})
}
func dispatchIMBusinessEvents(tenantID types.TenantID) {
if tenantID.IsZero() || globals.hub == nil || store.HTTPInfrastructure == nil {
return
}
now := time.Now().UTC().Round(time.Millisecond)
events, err := store.HTTPInfrastructure.ListPendingEvents(tenantID, 100, now)
if err != nil {
logs.Warn.Printf("business outbox: list tenant=%d: %v", tenantID, err)
return
}
for _, event := range events {
from := ""
if !event.ActorUserID.IsZero() {
from = event.ActorUserID.UserId()
}
payload := json.RawMessage(event.Payload)
if len(payload) == 0 {
payload = json.RawMessage(`{}`)
}
message := &ServerComMessage{
TenantID: tenantID, RcptTo: event.Target, AsUser: from, Timestamp: event.CreatedAt,
Info: &MsgServerInfo{Topic: event.Target, From: from, What: "im", Event: event.EventType, Payload: payload},
}
select {
case globals.hub.routeSrv <- message:
if err = store.HTTPInfrastructure.MarkEventDelivered(tenantID, event.EventID, now); err != nil {
logs.Warn.Printf("business outbox: mark delivered event=%s: %v", event.EventID, err)
return
}
default:
logs.Warn.Printf("business outbox: hub queue full event=%s target=%s", event.EventID, strings.TrimSpace(event.Target))
return
}
}
}
+81
View File
@@ -0,0 +1,81 @@
package main
import (
"encoding/json"
"strconv"
"time"
"github.com/tinode/chat/server/logs"
"github.com/tinode/chat/server/store"
"github.com/tinode/chat/server/store/types"
)
// dispatchIMGroupEvents delivers committed outbox events to online topic
// sessions. Events remain pending when the hub is unavailable or its queue is
// full, so the next successful group mutation retries them.
func dispatchIMGroupEvents(tenantID types.TenantID) {
if tenantID.IsZero() || globals.hub == nil || store.GroupBusiness == nil {
return
}
now := time.Now().UTC().Round(time.Millisecond)
events, err := store.GroupBusiness.ListPendingEvents(tenantID, 100, now)
if err != nil {
logs.Warn.Printf("group outbox: list tenant=%d: %v", tenantID, err)
return
}
for _, event := range events {
members, memberErr := store.Topics.GetUsers(tenantID, event.Topic, &types.QueryOpt{Limit: 1000})
if memberErr != nil {
logs.Warn.Printf("group outbox: list members event=%s: %v", event.EventID, memberErr)
return
}
for _, member := range members {
uid := types.ParseUid(member.User)
if uid.IsZero() {
if decoded, parseErr := strconv.ParseInt(member.User, 10, 64); parseErr == nil && decoded > 0 {
uid = store.EncodeUid(decoded)
}
}
if uid.IsZero() || uid == event.ActorUserID {
continue
}
if err = createIMSystemNotification(tenantID, uid,
stableIMEventID(event.EventID, uid.UserId(), "notification"), event.EventType,
"群聊动态", event.EventType, types.KVMap{"topic": event.Topic}); err != nil {
logs.Warn.Printf("group outbox: create notification event=%s user=%s: %v", event.EventID, uid.UserId(), err)
return
}
}
payload := json.RawMessage(event.Payload)
if len(payload) == 0 {
payload = json.RawMessage(`{}`)
}
from := ""
if !event.ActorUserID.IsZero() {
from = event.ActorUserID.UserId()
}
message := &ServerComMessage{
TenantID: tenantID,
RcptTo: event.Topic,
AsUser: from,
Timestamp: event.CreatedAt,
Info: &MsgServerInfo{
Topic: event.Topic,
From: from,
What: "group",
Event: event.EventType,
Payload: payload,
},
}
select {
case globals.hub.routeSrv <- message:
if err = store.GroupBusiness.MarkEventDelivered(tenantID, event.EventID, now); err != nil {
logs.Warn.Printf("group outbox: mark delivered event=%s: %v", event.EventID, err)
return
}
default:
logs.Warn.Printf("group outbox: hub queue full event=%s", event.EventID)
return
}
}
}
+556 -225
View File
File diff suppressed because it is too large Load Diff
+302 -19
View File
@@ -7,6 +7,7 @@ import (
"encoding/base64"
"encoding/binary"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
@@ -33,6 +34,57 @@ type testVerificationStore struct {
tokenHash string
}
type testHTTPInfrastructureStore struct {
records map[string]*types.HTTPIdempotencyRecord
events []types.BusinessEvent
}
func (s *testHTTPInfrastructureStore) recordKey(record *types.HTTPIdempotencyRecord) string {
return record.UserID.String() + "|" + record.Method + "|" + record.Route + "|" + record.Key
}
func (s *testHTTPInfrastructureStore) BeginIdempotency(_ types.TenantID, record *types.HTTPIdempotencyRecord) (*types.HTTPIdempotencyRecord, bool, error) {
if s.records == nil {
s.records = make(map[string]*types.HTTPIdempotencyRecord)
}
key := s.recordKey(record)
if existing := s.records[key]; existing != nil {
copy := *existing
copy.ResponseBody = append([]byte(nil), existing.ResponseBody...)
return &copy, false, nil
}
copy := *record
copy.State = types.HTTPIdempotencyProcessing
s.records[key] = &copy
return &copy, true, nil
}
func (s *testHTTPInfrastructureStore) CompleteIdempotency(_ types.TenantID, record *types.HTTPIdempotencyRecord) error {
copy := *record
copy.State = types.HTTPIdempotencyCompleted
copy.ResponseBody = append([]byte(nil), record.ResponseBody...)
s.records[s.recordKey(record)] = &copy
return nil
}
func (s *testHTTPInfrastructureStore) AbortIdempotency(_ types.TenantID, record *types.HTTPIdempotencyRecord) error {
delete(s.records, s.recordKey(record))
return nil
}
func (s *testHTTPInfrastructureStore) CreateEvent(_ types.TenantID, event *types.BusinessEvent) error {
s.events = append(s.events, *event)
return nil
}
func (s *testHTTPInfrastructureStore) ListPendingEvents(_ types.TenantID, _ int, _ time.Time) ([]types.BusinessEvent, error) {
return append([]types.BusinessEvent(nil), s.events...), nil
}
func (s *testHTTPInfrastructureStore) MarkEventDelivered(_ types.TenantID, _ string, _ time.Time) error {
return nil
}
type testRelationsStore struct {
context *types.RelationshipContext
created *types.FriendRequest
@@ -149,6 +201,12 @@ type testGroupBusinessStore struct {
updatedMembers []types.GroupMemberPatch
}
func (s *testGroupBusinessStore) CreateGroup(_ types.TenantID, _ *types.Topic, _ types.Uid, _ any,
settings *types.GroupSettings, _ []types.Uid, _ any, _ time.Time) error {
s.settings = settings
return nil
}
func (s *testGroupBusinessStore) CreateSettings(_ types.TenantID, settings *types.GroupSettings) (*types.GroupSettings, error) {
s.settings = settings
return settings, nil
@@ -225,7 +283,7 @@ func (s *testGroupBusinessStore) ListJoinRequests(_ types.TenantID, _ string, st
return out, "", nil
}
func (s *testGroupBusinessStore) ResolveJoinRequest(_ types.TenantID, topic, requestID string, actor types.Uid, action, _ string, _ int, now time.Time) (*types.GroupJoinRequest, error) {
func (s *testGroupBusinessStore) ResolveJoinRequest(_ types.TenantID, topic, requestID string, actor types.Uid, action, _ string, _ int, _ any, now time.Time) (*types.GroupJoinRequest, error) {
status := types.GroupJoinRequestStatusRejected
if action == "accept" {
status = types.GroupJoinRequestStatusAccepted
@@ -267,6 +325,61 @@ func (s *testGroupBusinessStore) DeleteMute(_ types.TenantID, _ string, _ types.
return nil
}
func (s *testGroupBusinessStore) InviteMember(_ types.TenantID, topic string, inviter, target types.Uid,
message, idempotencyKey string, _ any, now time.Time) (bool, error) {
for _, invitation := range s.invitations {
if invitation.Topic == topic && invitation.TargetUserID == target {
return false, nil
}
}
s.invitations = append(s.invitations, types.GroupInvitation{Topic: topic, InviterUserID: inviter,
TargetUserID: target, Message: message, IdempotencyKey: idempotencyKey,
Status: types.GroupInvitationStatusJoined, CreatedAt: now, UpdatedAt: now})
return true, nil
}
func (s *testGroupBusinessStore) Leave(_ types.TenantID, _ string, _ types.Uid, _ time.Time) error {
return nil
}
func (s *testGroupBusinessStore) UpdateRole(_ types.TenantID, topic string, _ types.Uid, target types.Uid,
role string, _ types.AccessMode, _ time.Time) error {
s.updatedMembers = append(s.updatedMembers, types.GroupMemberPatch{Role: &role})
s.member = &types.GroupMember{Topic: topic, UserID: target, Role: role, MemberStatus: types.GroupMemberStatusActive}
return nil
}
func (s *testGroupBusinessStore) TransferOwner(_ types.TenantID, topic string, _, target types.Uid, _ int, _ time.Time) error {
settings, _ := s.GetSettings(0, topic)
settings.OwnerUserID = target
s.settings = settings
return nil
}
func (s *testGroupBusinessStore) CanPost(_ types.TenantID, topic string, uid types.Uid, admin bool, now time.Time) (bool, error) {
if admin {
return true, nil
}
if s.settings != nil && s.settings.Topic == topic && s.settings.MuteAll {
return false, nil
}
for _, mute := range s.mutes {
if mute.Topic == topic && mute.UserID == uid && mute.Status == types.GroupMuteStatusActive &&
(mute.ExpiresAt == nil || mute.ExpiresAt.After(now)) {
return false, nil
}
}
return true, nil
}
func (s *testGroupBusinessStore) ListPendingEvents(_ types.TenantID, _ int, _ time.Time) ([]types.GroupEvent, error) {
return nil, nil
}
func (s *testGroupBusinessStore) MarkEventDelivered(_ types.TenantID, _ string, _ time.Time) error {
return nil
}
func defaultStringPtr(value *string, fallback string) string {
if value == nil {
return fallback
@@ -501,6 +614,12 @@ func (s *testProfilesStore) UpdatePrivacy(_ types.TenantID, uid types.Uid, patch
if patch.AddMeMethods != nil {
privacy.AddMeMethods = patch.AddMeMethods
}
if patch.ProfileVisibility != nil {
privacy.ProfileVisibility = *patch.ProfileVisibility
}
if patch.LastSeenVisibility != nil {
privacy.LastSeenVisibility = *patch.LastSeenVisibility
}
if patch.ReadReceipts != nil {
privacy.ReadReceipts = *patch.ReadReceipts
}
@@ -821,6 +940,84 @@ func expectIMBearer(t *testing.T, ctrl *gomock.Controller, token string, tenantI
return mockStore
}
func TestIMHTTPProtocolHeaders(t *testing.T) {
apiKey := makeTestAPIKey(t)
called := 0
handler := withIMHTTPProtocol("/v1/im/", http.HandlerFunc(func(wrt http.ResponseWriter, req *http.Request) {
called++
writeIMSuccess(wrt, req, map[string]any{"ok": true})
}))
req := httptest.NewRequest(http.MethodGet, "/v1/im/bootstrap", nil)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusForbidden || called != 0 {
t.Fatalf("missing API key: status=%d called=%d", rec.Code, called)
}
if rec.Header().Get("X-Request-ID") == "" {
t.Fatal("server did not generate X-Request-ID")
}
req = httptest.NewRequest(http.MethodGet, "/v1/im/bootstrap", nil)
req.Header.Set("X-Tinode-APIKey", apiKey)
rec = httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusBadRequest || decodeIMHTTPResponse(t, rec).Reason != imReasonSchemaUnsupported {
t.Fatalf("missing schema: status=%d body=%s", rec.Code, rec.Body.String())
}
req = httptest.NewRequest(http.MethodGet, "/v1/im/bootstrap", nil)
req.Header.Set("X-Tinode-APIKey", apiKey)
req.Header.Set("X-IM-Schema", "1")
req.Header.Set("X-Request-ID", "request-1")
rec = httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK || called != 1 || rec.Header().Get("X-Request-ID") != "request-1" {
t.Fatalf("valid protocol request: status=%d called=%d request_id=%q", rec.Code, called, rec.Header().Get("X-Request-ID"))
}
}
func TestIMHTTPIdempotencyReplayAndConflict(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
expectIMBearer(t, ctrl, "token-1", 7, 42)
oldInfrastructure := store.HTTPInfrastructure
infrastructure := &testHTTPInfrastructureStore{}
store.HTTPInfrastructure = infrastructure
defer func() { store.HTTPInfrastructure = oldInfrastructure }()
called := 0
handler := withIMHTTPProtocol("/v1/im/", http.HandlerFunc(func(wrt http.ResponseWriter, req *http.Request) {
called++
body, _ := io.ReadAll(req.Body)
writeIMSuccess(wrt, req, map[string]any{"body": string(body), "call": called})
}))
apiKey := makeTestAPIKey(t)
request := func(body string) *httptest.ResponseRecorder {
req := httptest.NewRequest(http.MethodPost, "/v1/im/test", strings.NewReader(body))
req.Header.Set("Authorization", "Bearer token-1")
req.Header.Set("X-Tinode-APIKey", apiKey)
req.Header.Set("X-IM-Schema", "1")
req.Header.Set("Idempotency-Key", "same-key")
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
return rec
}
first := request(`{"value":1}`)
second := request(`{"value":1}`)
if first.Code != http.StatusOK || second.Code != http.StatusOK || called != 1 {
t.Fatalf("replay failed: first=%d second=%d called=%d", first.Code, second.Code, called)
}
if second.Header().Get("X-Idempotent-Replay") != "true" || first.Body.String() != second.Body.String() {
t.Fatalf("response was not replayed: first=%s second=%s", first.Body.String(), second.Body.String())
}
conflict := request(`{"value":2}`)
if conflict.Code != http.StatusConflict || decodeIMHTTPResponse(t, conflict).Reason != imReasonIdempotencyConflict || called != 1 {
t.Fatalf("conflict failed: status=%d called=%d body=%s", conflict.Code, called, conflict.Body.String())
}
}
func TestServeIMTenantResolveSuccessFromHeader(t *testing.T) {
oldTenants := store.Tenants
store.Tenants = testTenantStore{tenant: &types.Tenant{
@@ -1364,11 +1561,16 @@ func TestServeIMWorkbench(t *testing.T) {
defer ctrl.Finish()
expectIMBearer(t, ctrl, "token-1", 7, 42)
oldWorkbench := store.Workbench
oldTenants := store.Tenants
store.Workbench = &testWorkbenchStore{version: 3, modules: []types.WorkbenchModule{
{Code: "organization", Name: "组织架构", Route: "/organization", Enabled: true, Permission: "enabled"},
{Code: "external", Name: "外部链接", Route: "https://example.com", Enabled: true, Permission: "enabled"},
}}
defer func() { store.Workbench = oldWorkbench }()
store.Tenants = testTenantStore{tenant: &types.Tenant{ID: 7, Code: "acme", State: types.TenantStateActive}}
defer func() {
store.Workbench = oldWorkbench
store.Tenants = oldTenants
}()
req := httptest.NewRequest(http.MethodGet, "/v1/im/workbench", nil)
req.Header.Set("Authorization", "Bearer token-1")
@@ -1391,6 +1593,10 @@ func TestServeIMWorkbench(t *testing.T) {
if item["code"] != "organization" || item["route"] != "/organization" {
t.Fatalf("unexpected module: %#v", item)
}
enterprise := data["enterprise"].(map[string]any)
if enterprise["tenant_code"] != "acme" {
t.Fatalf("tenant_code: got %#v, want acme", enterprise["tenant_code"])
}
}
func TestServeIMOrganizationDepartments(t *testing.T) {
@@ -1838,6 +2044,43 @@ func TestServeIMMePrivacy(t *testing.T) {
}
}
func TestIMCanViewLastSeen(t *testing.T) {
oldProfiles := store.Profiles
oldRelations := store.Relations
defer func() {
store.Profiles = oldProfiles
store.Relations = oldRelations
}()
viewer, target := types.Uid(1), types.Uid(2)
profiles := &testProfilesStore{privacy: &types.UserPrivacySettings{
UserID: target, LastSeenVisibility: "everyone",
}}
store.Profiles = profiles
store.Relations = &testRelationsStore{context: &types.RelationshipContext{
TenantID: 7, UserID: viewer, PeerID: target, Relationship: types.RelationshipNone,
}}
if !imCanViewLastSeen(7, viewer, target) {
t.Fatal("everyone visibility must expose last seen")
}
profiles.privacy.LastSeenVisibility = "nobody"
if imCanViewLastSeen(7, viewer, target) {
t.Fatal("nobody visibility must hide last seen")
}
profiles.privacy.LastSeenVisibility = "friends"
if imCanViewLastSeen(7, viewer, target) {
t.Fatal("non-friend must not see friends-only last seen")
}
store.Relations = &testRelationsStore{context: &types.RelationshipContext{
TenantID: 7, UserID: viewer, PeerID: target, Relationship: types.RelationshipFriend,
}}
if !imCanViewLastSeen(7, viewer, target) {
t.Fatal("friend must see friends-only last seen")
}
}
func TestServeIMAppReleaseLatest(t *testing.T) {
oldTenants := store.Tenants
oldReleases := store.AppReleases
@@ -2118,13 +2361,16 @@ func TestServeIMMessageReceipts(t *testing.T) {
expectIMBearer(t, ctrl, "token-1", 7, 42)
oldSubs := store.Subs
oldTopics := store.Topics
oldProfiles := store.Profiles
subs := mock_store.NewMockSubsPersistenceInterface(ctrl)
topics := mock_store.NewMockTopicsPersistenceInterface(ctrl)
store.Subs = subs
store.Topics = topics
store.Profiles = &testProfilesStore{}
defer func() {
store.Subs = oldSubs
store.Topics = oldTopics
store.Profiles = oldProfiles
}()
mySub := &types.Subscription{
@@ -2326,15 +2572,6 @@ func TestServeIMGroupCreate(t *testing.T) {
store.GroupBusiness = oldGroupBusiness
}()
topics.EXPECT().Create(types.TenantID(7), gomock.Any(), types.Uid(42), gomock.Any()).DoAndReturn(
func(_ types.TenantID, topic *types.Topic, _ types.Uid, _ any) error {
if topic.Id != "grpnewgroup" {
t.Fatalf("unexpected topic id: %s", topic.Id)
}
return nil
})
subs.EXPECT().Create(types.TenantID(7), gomock.Any()).Return(nil)
topics.EXPECT().UpdateSubCnt(types.TenantID(7), "grpnewgroup").Return(nil)
topics.EXPECT().Get(types.TenantID(7), "grpnewgroup").Return(&types.Topic{
ObjHeader: types.ObjHeader{Id: "grpnewgroup"},
TenantID: 7,
@@ -2475,9 +2712,6 @@ func TestServeIMGroupLeave(t *testing.T) {
ModeWant: types.ModeCPublic,
ModeGiven: types.ModeCPublic,
}, nil)
subs.EXPECT().Delete(types.TenantID(7), "grpAlpha", types.Uid(42)).Return(nil)
topics.EXPECT().UpdateSubCnt(types.TenantID(7), "grpAlpha").Return(nil)
req := httptest.NewRequest(http.MethodPost, "/v1/im/groups/grpAlpha/leave", nil)
req.Header.Set("Authorization", "Bearer token-1")
rec := httptest.NewRecorder()
@@ -2493,6 +2727,46 @@ func TestServeIMGroupLeave(t *testing.T) {
}
}
func TestServeIMGroupInvitationAddsNonMember(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
expectIMBearer(t, ctrl, "token-1", 7, 42)
oldSubs := store.Subs
oldGroupBusiness := store.GroupBusiness
subs := mock_store.NewMockSubsPersistenceInterface(ctrl)
groupStore := &testGroupBusinessStore{}
store.Subs = subs
store.GroupBusiness = groupStore
defer func() {
store.Subs = oldSubs
store.GroupBusiness = oldGroupBusiness
}()
owner := types.Uid(42)
target := types.Uid(43)
subs.EXPECT().Get(types.TenantID(7), "grpAlpha", owner, false).Return(&types.Subscription{
TenantID: 7, User: owner.String(), Topic: "grpAlpha", ModeWant: types.ModeCFull, ModeGiven: types.ModeCFull,
}, nil)
req := httptest.NewRequest(http.MethodPost, "/v1/im/groups/grpAlpha/invitations",
bytes.NewBufferString(`{"user_ids":["`+target.UserId()+`"],"message":"welcome"}`))
req.Header.Set("Authorization", "Bearer token-1")
req.Header.Set("Idempotency-Key", "invite-1")
rec := httptest.NewRecorder()
serveIMGroupResource(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status: got %d body=%s", rec.Code, rec.Body.String())
}
data := decodeIMHTTPResponse(t, rec).Data.(map[string]any)
items := data["items"].([]any)
if len(items) != 1 || items[0].(map[string]any)["status"] != "joined" {
t.Fatalf("unexpected invitation response: %#v", data)
}
if len(groupStore.invitations) != 1 || groupStore.invitations[0].TargetUserID != target {
t.Fatalf("invitation was not persisted: %#v", groupStore.invitations)
}
}
func TestServeIMGroupQRCodesAndResolve(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
@@ -2595,7 +2869,8 @@ func TestServeIMGroupAnnouncementGovernanceJoinRequest(t *testing.T) {
store.GroupBusiness = oldGroupBusiness
}()
selfSub := &types.Subscription{TenantID: 7, User: types.Uid(42).String(), Topic: "grpAlpha", ModeWant: types.ModeCFull, ModeGiven: types.ModeCFull}
subs.EXPECT().Get(types.TenantID(7), "grpAlpha", types.Uid(42), false).Return(selfSub, nil).Times(3)
subs.EXPECT().Get(types.TenantID(7), "grpAlpha", types.Uid(42), false).Return(selfSub, nil).Times(2)
subs.EXPECT().Get(types.TenantID(7), "grpAlpha", types.Uid(42), false).Return(nil, nil)
annReq := httptest.NewRequest(http.MethodPut, "/v1/im/groups/grpAlpha/announcements/current", bytes.NewBufferString(`{"content":"公告","version":1}`))
annReq.Header.Set("Authorization", "Bearer token-1")
@@ -2663,10 +2938,7 @@ func TestServeIMGroupAdminsMutesAndOwnerTransfer(t *testing.T) {
members := []types.Subscription{*ownerSub, member}
subs.EXPECT().Get(types.TenantID(7), "grpAlpha", owner, false).Return(ownerSub, nil).Times(3)
topics.EXPECT().GetUsers(types.TenantID(7), "grpAlpha", gomock.Any()).Return(members, nil).Times(2)
subs.EXPECT().Update(types.TenantID(7), "grpAlpha", target, gomock.Any()).Return(nil).Times(2)
subs.EXPECT().Get(types.TenantID(7), "grpAlpha", target, false).Return(&member, nil)
subs.EXPECT().Update(types.TenantID(7), "grpAlpha", owner, gomock.Any()).Return(nil)
topics.EXPECT().OwnerChange(types.TenantID(7), "grpAlpha", target).Return(nil)
adminReq := httptest.NewRequest(http.MethodPost, "/v1/im/groups/grpAlpha/admins/batch", bytes.NewBufferString(`{"user_ids":["`+target.UserId()+`"],"version":1}`))
adminReq.Header.Set("Authorization", "Bearer token-1")
@@ -3236,6 +3508,7 @@ func TestServeIMMeQRCodesAndResolve(t *testing.T) {
store.Store = mockStore
oldUsers := store.Users
oldRelations := store.Relations
oldProfiles := store.Profiles
users := mock_store.NewMockUsersPersistenceInterface(ctrl)
store.Users = users
owner := types.Uid(1)
@@ -3258,10 +3531,12 @@ func TestServeIMMeQRCodesAndResolve(t *testing.T) {
Relationship: types.RelationshipNone,
CanSend: false,
}}
store.Profiles = &testProfilesStore{}
defer func() {
store.Store = oldStore
store.Users = oldUsers
store.Relations = oldRelations
store.Profiles = oldProfiles
}()
req := httptest.NewRequest(http.MethodPost, "/v1/im/me/qrcodes", bytes.NewBufferString(`{"purpose":"add_friend","expires_in_seconds":60}`))
@@ -3447,9 +3722,14 @@ func TestServeIMFriendRequestCreate(t *testing.T) {
mockStore := expectIMBearer(t, ctrl, "token-1", 7, 1)
mockStore.EXPECT().GetUidString().Return("request-1")
oldRelations := store.Relations
oldProfiles := store.Profiles
relations := &testRelationsStore{}
store.Relations = relations
defer func() { store.Relations = oldRelations }()
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":"search"}`))
@@ -3643,6 +3923,7 @@ func TestServeIMUserProfile(t *testing.T) {
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{
@@ -3659,9 +3940,11 @@ func TestServeIMUserProfile(t *testing.T) {
Relationship: types.RelationshipFriend,
CanSend: true,
}}
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)
+1 -1
View File
@@ -853,7 +853,7 @@ func main() {
mux.HandleFunc("/", serve404)
}
if err = listenAndServe(config.Listen, mux, tlsConfig, signalHandler()); err != nil {
if err = listenAndServe(config.Listen, withIMHTTPProtocol(config.ApiPath+"v1/im/", mux), tlsConfig, signalHandler()); err != nil {
logs.Err.Fatal(err)
}
}
+7
View File
@@ -17,6 +17,13 @@ This adapter requires you to obtain your own credentials from Goole Firebase. If
Remove the other entry. I.e. if you have updated `"credentials_file"`, remove `"credentials"` and vice versa.
4. Update [TinodeWeb](/tinode/webapp/) config [`firebase-init.js`](https://github.com/tinode/webapp/blob/master/firebase-init.js): update `apiKey`, `messagingSenderId`, `projectId`, `appId`, `messagingVapidKey`. See more info at https://github.com/tinode/webapp/#push_notifications
The server may also enable FCM without storing credentials in `tinode.conf`:
* `IM_FCM_CREDENTIALS_FILE` points to the service-account JSON file.
* `IM_FCM_CREDENTIALS_JSON` contains the service-account JSON directly.
Either variable enables the adapter even when the checked-in configuration has `enabled: false`.
### iOS and Android
1. If you are using an Android client, add `google-services.json` to [Tindroid](/tinode/tindroid/) by following instructions at https://developers.google.com/android/guides/google-services-plugin and recompile the client. You may also optionally submit it to Google Play Store.
+15 -2
View File
@@ -123,7 +123,7 @@ func PrepareV1Notifications(rcpt *push.Receipt, config *configType) ([]*fcmv1.Me
skipDevices[deviceID] = struct{}{}
}
}
devices, count, err = store.Devices.GetAll(rcpt.TenantID, uids...)
devices, count, err = pushDevicesForUsers(rcpt.TenantID, uids...)
if err != nil {
logs.Warn.Println("fcm push: db error", err)
return nil, nil
@@ -218,7 +218,7 @@ func TenantChannelName(tenantID t.TenantID, channel string) string {
// DevicesForUser loads device IDs of the given user.
func DevicesForUser(tenantID t.TenantID, uid t.Uid) []string {
ddef, count, err := store.Devices.GetAll(tenantID, uid)
ddef, count, err := pushDevicesForUsers(tenantID, uid)
if err != nil {
logs.Warn.Println("fcm devices for user: db error", err)
return nil
@@ -235,6 +235,19 @@ func DevicesForUser(tenantID t.TenantID, uid t.Uid) []string {
return devices
}
func pushDevicesForUsers(tenantID t.TenantID, uids ...t.Uid) (map[t.Uid][]t.DeviceDef, int, error) {
if tokens, ok := store.DevicePushTokens.(store.DevicePushTokenQueryInterface); ok {
devices, count, err := tokens.GetAll(tenantID, uids...)
if err != nil {
return nil, 0, err
}
if count > 0 {
return devices, count, nil
}
}
return store.Devices.GetAll(tenantID, uids...)
}
// ChannelsForUser loads user's channel subscriptions with P permission.
func ChannelsForUser(tenantID t.TenantID, uid t.Uid) []string {
channels, err := store.Users.GetChannels(tenantID, uid)
+65
View File
@@ -0,0 +1,65 @@
package fcm
import (
"testing"
"github.com/tinode/chat/server/store"
"github.com/tinode/chat/server/store/types"
)
type testProductPushTokens struct {
devices map[types.Uid][]types.DeviceDef
}
func (s *testProductPushTokens) Upsert(types.TenantID, *types.DevicePushToken) (*types.DevicePushToken, error) {
return nil, nil
}
func (s *testProductPushTokens) GetAll(_ types.TenantID, _ ...types.Uid) (map[types.Uid][]types.DeviceDef, int, error) {
count := 0
for _, devices := range s.devices {
count += len(devices)
}
return s.devices, count, nil
}
type testLegacyPushDevices struct {
called bool
}
func (*testLegacyPushDevices) Update(types.TenantID, types.Uid, string, *types.DeviceDef) error {
return nil
}
func (s *testLegacyPushDevices) GetAll(types.TenantID, ...types.Uid) (map[types.Uid][]types.DeviceDef, int, error) {
s.called = true
return nil, 0, nil
}
func (*testLegacyPushDevices) Delete(types.TenantID, types.Uid, string) error { return nil }
func TestPushDevicesForUsersPrefersProductTokens(t *testing.T) {
oldTokens := store.DevicePushTokens
oldDevices := store.Devices
defer func() {
store.DevicePushTokens = oldTokens
store.Devices = oldDevices
}()
uid := types.Uid(42)
legacy := &testLegacyPushDevices{}
store.DevicePushTokens = &testProductPushTokens{devices: map[types.Uid][]types.DeviceDef{
uid: {{TenantID: 7, DeviceId: "product-token", Platform: "android"}},
}}
store.Devices = legacy
devices, count, err := pushDevicesForUsers(7, uid)
if err != nil {
t.Fatal(err)
}
if count != 1 || devices[uid][0].DeviceId != "product-token" {
t.Fatalf("unexpected devices: %#v count=%d", devices, count)
}
if legacy.called {
t.Fatal("legacy device store must not be read when product tokens exist")
}
}
+8
View File
@@ -70,6 +70,14 @@ func (Handler) Init(jsonconf json.RawMessage) (bool, error) {
return false, errors.New("failed to parse config: " + err.Error())
}
if raw := strings.TrimSpace(os.Getenv("IM_FCM_CREDENTIALS_JSON")); raw != "" {
config.Credentials = json.RawMessage(raw)
config.Enabled = true
}
if path := strings.TrimSpace(os.Getenv("IM_FCM_CREDENTIALS_FILE")); path != "" {
config.CredentialsFile = path
config.Enabled = true
}
if !config.Enabled {
return false, nil
}
+4
View File
@@ -23,6 +23,10 @@ func (s testTenantStore) GetByCode(string) (*types.Tenant, error) {
return s.tenant, s.err
}
func (s testTenantStore) GetByID(types.TenantID) (*types.Tenant, error) {
return s.tenant, s.err
}
func test_makeSession(uid types.Uid) *Session {
return &Session{
send: make(chan any, 10),
+81
View File
@@ -0,0 +1,81 @@
package store
import (
"time"
adapter "github.com/tinode/chat/server/db"
"github.com/tinode/chat/server/store/types"
)
type HTTPInfrastructurePersistenceInterface interface {
BeginIdempotency(types.TenantID, *types.HTTPIdempotencyRecord) (*types.HTTPIdempotencyRecord, bool, error)
CompleteIdempotency(types.TenantID, *types.HTTPIdempotencyRecord) error
AbortIdempotency(types.TenantID, *types.HTTPIdempotencyRecord) error
CreateEvent(types.TenantID, *types.BusinessEvent) error
ListPendingEvents(types.TenantID, int, time.Time) ([]types.BusinessEvent, error)
MarkEventDelivered(types.TenantID, string, time.Time) error
}
type httpInfrastructureMapper struct{}
var HTTPInfrastructure HTTPInfrastructurePersistenceInterface = httpInfrastructureMapper{}
func tenantHTTPInfrastructureAdapter(tenantID types.TenantID) (adapter.TenantHTTPInfrastructureAdapter, error) {
business, err := tenantBusinessAdapter(tenantID)
if err != nil {
return nil, err
}
infrastructure, ok := business.(adapter.TenantHTTPInfrastructureAdapter)
if !ok {
return nil, types.ErrUnsupported
}
return infrastructure, nil
}
func (httpInfrastructureMapper) BeginIdempotency(tenantID types.TenantID, record *types.HTTPIdempotencyRecord) (*types.HTTPIdempotencyRecord, bool, error) {
infrastructure, err := tenantHTTPInfrastructureAdapter(tenantID)
if err != nil {
return nil, false, err
}
return infrastructure.TenantHTTPIdempotencyBegin(tenantID, record)
}
func (httpInfrastructureMapper) CompleteIdempotency(tenantID types.TenantID, record *types.HTTPIdempotencyRecord) error {
infrastructure, err := tenantHTTPInfrastructureAdapter(tenantID)
if err != nil {
return err
}
return infrastructure.TenantHTTPIdempotencyComplete(tenantID, record)
}
func (httpInfrastructureMapper) AbortIdempotency(tenantID types.TenantID, record *types.HTTPIdempotencyRecord) error {
infrastructure, err := tenantHTTPInfrastructureAdapter(tenantID)
if err != nil {
return err
}
return infrastructure.TenantHTTPIdempotencyAbort(tenantID, record)
}
func (httpInfrastructureMapper) CreateEvent(tenantID types.TenantID, event *types.BusinessEvent) error {
infrastructure, err := tenantHTTPInfrastructureAdapter(tenantID)
if err != nil {
return err
}
return infrastructure.TenantBusinessEventCreate(tenantID, event)
}
func (httpInfrastructureMapper) ListPendingEvents(tenantID types.TenantID, limit int, now time.Time) ([]types.BusinessEvent, error) {
infrastructure, err := tenantHTTPInfrastructureAdapter(tenantID)
if err != nil {
return nil, err
}
return infrastructure.TenantBusinessEventListPending(tenantID, limit, now)
}
func (httpInfrastructureMapper) MarkEventDelivered(tenantID types.TenantID, eventID string, now time.Time) error {
infrastructure, err := tenantHTTPInfrastructureAdapter(tenantID)
if err != nil {
return err
}
return infrastructure.TenantBusinessEventMarkDelivered(tenantID, eventID, now)
}
+241 -10
View File
@@ -129,6 +129,7 @@ type storeObj struct{}
// TenantsPersistenceInterface defines tenant registry lookups used before authentication.
type TenantsPersistenceInterface interface {
GetByCode(code string) (*types.Tenant, error)
GetByID(id types.TenantID) (*types.Tenant, error)
}
type tenantsMapper struct{}
@@ -145,6 +146,18 @@ func (tenantsMapper) GetByCode(code string) (*types.Tenant, error) {
return tenantAdp.TenantGetByCode(code)
}
// GetByID resolves an internal tenant ID to its registry record.
func (tenantsMapper) GetByID(id types.TenantID) (*types.Tenant, error) {
if !id.IsValid() {
return nil, types.ErrMalformed
}
tenantAdp, ok := adp.(adapter.TenantAdapter)
if !ok {
return nil, types.ErrUnsupported
}
return tenantAdp.TenantGetByID(id)
}
func tenantAuthAdapter(tenantID types.TenantID) (adapter.TenantAuthAdapter, error) {
if !tenantID.IsValid() {
return nil, types.ErrMalformed
@@ -1080,6 +1093,7 @@ func (callsMapper) List(tenantID types.TenantID, uid types.Uid, topic, cursor st
}
type GroupBusinessPersistenceInterface interface {
CreateGroup(types.TenantID, *types.Topic, types.Uid, any, *types.GroupSettings, []types.Uid, any, time.Time) error
CreateSettings(types.TenantID, *types.GroupSettings) (*types.GroupSettings, error)
GetSettings(types.TenantID, string) (*types.GroupSettings, error)
UpdateSettings(types.TenantID, string, types.GroupSettingsPatch) (*types.GroupSettings, error)
@@ -1090,19 +1104,41 @@ type GroupBusinessPersistenceInterface interface {
CreateInvitation(types.TenantID, *types.GroupInvitation) (*types.GroupInvitation, error)
CreateJoinRequest(types.TenantID, *types.GroupJoinRequest) (*types.GroupJoinRequest, error)
ListJoinRequests(types.TenantID, string, string, string, int) ([]types.GroupJoinRequest, string, error)
ResolveJoinRequest(types.TenantID, string, string, types.Uid, string, string, int, time.Time) (*types.GroupJoinRequest, error)
ResolveJoinRequest(types.TenantID, string, string, types.Uid, string, string, int, any, time.Time) (*types.GroupJoinRequest, error)
LatestAnnouncement(types.TenantID, string) (*types.GroupAnnouncement, error)
PutAnnouncement(types.TenantID, *types.GroupAnnouncement, int) (*types.GroupAnnouncement, error)
ReadAnnouncement(types.TenantID, string, string, types.Uid, time.Time) error
ListMutes(types.TenantID, string, string, int) ([]types.GroupMute, string, error)
UpsertMute(types.TenantID, *types.GroupMute) (*types.GroupMute, error)
DeleteMute(types.TenantID, string, types.Uid, types.Uid, time.Time) error
InviteMember(types.TenantID, string, types.Uid, types.Uid, string, string, any, time.Time) (bool, error)
Leave(types.TenantID, string, types.Uid, time.Time) error
UpdateRole(types.TenantID, string, types.Uid, types.Uid, string, types.AccessMode, time.Time) error
TransferOwner(types.TenantID, string, types.Uid, types.Uid, int, time.Time) error
CanPost(types.TenantID, string, types.Uid, bool, time.Time) (bool, error)
ListPendingEvents(types.TenantID, int, time.Time) ([]types.GroupEvent, error)
MarkEventDelivered(types.TenantID, string, time.Time) error
}
type groupBusinessMapper struct{}
var GroupBusiness GroupBusinessPersistenceInterface
func (groupBusinessMapper) CreateGroup(tenantID types.TenantID, topic *types.Topic, owner types.Uid, ownerPrivate any,
settings *types.GroupSettings, members []types.Uid, memberPrivate any, now time.Time) error {
if topic == nil || topic.TenantID != tenantID || owner.IsZero() || settings == nil || settings.Topic != topic.Id {
return types.ErrMalformed
}
topic.InitTimes()
topic.TouchedAt = topic.CreatedAt
topic.Owner = owner.String()
tenantAdp, err := tenantBusinessAdapter(tenantID)
if err != nil {
return err
}
return tenantAdp.TenantGroupCreate(tenantID, topic, owner, ownerPrivate, settings, members, memberPrivate, now)
}
func (groupBusinessMapper) CreateSettings(tenantID types.TenantID, settings *types.GroupSettings) (*types.GroupSettings, error) {
if settings == nil || settings.Topic == "" || settings.OwnerUserID.IsZero() || settings.CreatedBy.IsZero() {
return nil, types.ErrMalformed
@@ -1219,7 +1255,7 @@ func (groupBusinessMapper) ListJoinRequests(tenantID types.TenantID, topic, stat
return tenantAdp.TenantGroupJoinRequestList(tenantID, strings.TrimSpace(topic), strings.TrimSpace(status), strings.TrimSpace(cursor), limit)
}
func (groupBusinessMapper) ResolveJoinRequest(tenantID types.TenantID, topic, requestID string, actor types.Uid, action, rejectReason string, version int, now time.Time) (*types.GroupJoinRequest, error) {
func (groupBusinessMapper) ResolveJoinRequest(tenantID types.TenantID, topic, requestID string, actor types.Uid, action, rejectReason string, version int, memberPrivate any, now time.Time) (*types.GroupJoinRequest, error) {
if strings.TrimSpace(topic) == "" || strings.TrimSpace(requestID) == "" || actor.IsZero() || now.IsZero() {
return nil, types.ErrMalformed
}
@@ -1227,7 +1263,7 @@ func (groupBusinessMapper) ResolveJoinRequest(tenantID types.TenantID, topic, re
if err != nil {
return nil, err
}
return tenantAdp.TenantGroupJoinRequestResolve(tenantID, strings.TrimSpace(topic), strings.TrimSpace(requestID), actor, strings.TrimSpace(action), strings.TrimSpace(rejectReason), version, now)
return tenantAdp.TenantGroupJoinRequestResolve(tenantID, strings.TrimSpace(topic), strings.TrimSpace(requestID), actor, strings.TrimSpace(action), strings.TrimSpace(rejectReason), version, memberPrivate, now)
}
func (groupBusinessMapper) LatestAnnouncement(tenantID types.TenantID, topic string) (*types.GroupAnnouncement, error) {
@@ -1299,6 +1335,87 @@ func (groupBusinessMapper) DeleteMute(tenantID types.TenantID, topic string, uid
return tenantAdp.TenantGroupMuteDelete(tenantID, strings.TrimSpace(topic), uid, actor, now)
}
func (groupBusinessMapper) InviteMember(tenantID types.TenantID, topic string, inviter, target types.Uid,
message, idempotencyKey string, private any, now time.Time) (bool, error) {
if strings.TrimSpace(topic) == "" || inviter.IsZero() || target.IsZero() || now.IsZero() {
return false, types.ErrMalformed
}
tenantAdp, err := tenantBusinessAdapter(tenantID)
if err != nil {
return false, err
}
return tenantAdp.TenantGroupInviteMember(tenantID, strings.TrimSpace(topic), inviter, target,
strings.TrimSpace(message), strings.TrimSpace(idempotencyKey), private, now)
}
func (groupBusinessMapper) Leave(tenantID types.TenantID, topic string, uid types.Uid, now time.Time) error {
if strings.TrimSpace(topic) == "" || uid.IsZero() || now.IsZero() {
return types.ErrMalformed
}
tenantAdp, err := tenantBusinessAdapter(tenantID)
if err != nil {
return err
}
return tenantAdp.TenantGroupLeave(tenantID, strings.TrimSpace(topic), uid, now)
}
func (groupBusinessMapper) UpdateRole(tenantID types.TenantID, topic string, actor, target types.Uid,
role string, modeGiven types.AccessMode, now time.Time) error {
if strings.TrimSpace(topic) == "" || actor.IsZero() || target.IsZero() || now.IsZero() {
return types.ErrMalformed
}
tenantAdp, err := tenantBusinessAdapter(tenantID)
if err != nil {
return err
}
return tenantAdp.TenantGroupRoleUpdate(tenantID, strings.TrimSpace(topic), actor, target, strings.TrimSpace(role), modeGiven, now)
}
func (groupBusinessMapper) TransferOwner(tenantID types.TenantID, topic string, previousOwner, newOwner types.Uid,
version int, now time.Time) error {
if strings.TrimSpace(topic) == "" || previousOwner.IsZero() || newOwner.IsZero() || now.IsZero() {
return types.ErrMalformed
}
tenantAdp, err := tenantBusinessAdapter(tenantID)
if err != nil {
return err
}
return tenantAdp.TenantGroupOwnerTransfer(tenantID, strings.TrimSpace(topic), previousOwner, newOwner, version, now)
}
func (groupBusinessMapper) CanPost(tenantID types.TenantID, topic string, uid types.Uid, admin bool, now time.Time) (bool, error) {
if strings.TrimSpace(topic) == "" || uid.IsZero() || now.IsZero() {
return false, types.ErrMalformed
}
tenantAdp, err := tenantBusinessAdapter(tenantID)
if err != nil {
return false, err
}
return tenantAdp.TenantGroupCanPost(tenantID, strings.TrimSpace(topic), uid, admin, now)
}
func (groupBusinessMapper) ListPendingEvents(tenantID types.TenantID, limit int, now time.Time) ([]types.GroupEvent, error) {
if limit <= 0 || limit > 100 {
limit = 50
}
tenantAdp, err := tenantBusinessAdapter(tenantID)
if err != nil {
return nil, err
}
return tenantAdp.TenantGroupEventListPending(tenantID, limit, now)
}
func (groupBusinessMapper) MarkEventDelivered(tenantID types.TenantID, eventID string, now time.Time) error {
if strings.TrimSpace(eventID) == "" || now.IsZero() {
return types.ErrMalformed
}
tenantAdp, err := tenantBusinessAdapter(tenantID)
if err != nil {
return err
}
return tenantAdp.TenantGroupEventMarkDelivered(tenantID, strings.TrimSpace(eventID), now)
}
type WorkbenchPersistenceInterface interface {
Get(tenantID types.TenantID, uid types.Uid) ([]types.WorkbenchModule, int, error)
}
@@ -1724,6 +1841,10 @@ type DevicePushTokenPersistenceInterface interface {
Upsert(tenantID types.TenantID, token *types.DevicePushToken) (*types.DevicePushToken, error)
}
type DevicePushTokenQueryInterface interface {
GetAll(tenantID types.TenantID, uid ...types.Uid) (map[types.Uid][]types.DeviceDef, int, error)
}
type devicePushTokenMapper struct{}
var DevicePushTokens DevicePushTokenPersistenceInterface
@@ -1740,6 +1861,20 @@ func (devicePushTokenMapper) Upsert(tenantID types.TenantID, token *types.Device
return tenantAdp.TenantDevicePushTokenUpsert(tenantID, token)
}
func (devicePushTokenMapper) GetAll(tenantID types.TenantID, uids ...types.Uid) (map[types.Uid][]types.DeviceDef, int, error) {
tenantAdp, err := tenantBusinessAdapter(tenantID)
if err != nil {
return nil, 0, err
}
reader, ok := tenantAdp.(interface {
TenantDevicePushTokenGetAll(types.TenantID, ...types.Uid) (map[types.Uid][]types.DeviceDef, int, error)
})
if !ok {
return map[types.Uid][]types.DeviceDef{}, 0, nil
}
return reader.TenantDevicePushTokenGetAll(tenantID, uids...)
}
type SafetyPersistenceInterface interface {
CreateRiskChallenge(tenantID types.TenantID, challenge *types.RiskChallenge) (*types.RiskChallenge, error)
ConfirmRiskChallenge(tenantID types.TenantID, uid types.Uid, challengeID, decision, actionTokenID string, now time.Time) (*types.RiskChallenge, error)
@@ -1863,7 +1998,7 @@ func (profileMapper) GetPrivacy(tenantID types.TenantID, uid types.Uid) (*types.
}
func (profileMapper) UpdatePrivacy(tenantID types.TenantID, uid types.Uid, patch types.UserPrivacyPatch) (*types.UserPrivacySettings, error) {
if uid.IsZero() || patch.Version <= 0 {
if uid.IsZero() || patch.Version <= 0 || !validUserPrivacyPatch(patch) {
return nil, types.ErrMalformed
}
tenantAdp, err := tenantBusinessAdapter(tenantID)
@@ -1873,6 +2008,31 @@ func (profileMapper) UpdatePrivacy(tenantID types.TenantID, uid types.Uid, patch
return tenantAdp.TenantUserPrivacyUpdate(tenantID, uid, patch)
}
func validUserPrivacyPatch(patch types.UserPrivacyPatch) bool {
if patch.AddMeMethods == nil && patch.ProfileVisibility == nil && patch.LastSeenVisibility == nil && patch.ReadReceipts == nil {
return false
}
for method := range patch.AddMeMethods {
switch method {
case "account", "phone", "qr", "group":
default:
return false
}
}
validVisibility := func(value *string) bool {
if value == nil {
return true
}
switch strings.TrimSpace(*value) {
case "everyone", "friends", "nobody":
return true
default:
return false
}
}
return validVisibility(patch.ProfileVisibility) && validVisibility(patch.LastSeenVisibility)
}
func validUserProfilePatch(patch types.UserProfilePatch) bool {
if patch.AvatarFileID == nil && patch.Nickname == nil && patch.BirthDate == nil && patch.Signature == nil && patch.Gender == nil && patch.Region == nil {
return false
@@ -2321,20 +2481,77 @@ func normalizeClientMsgID(head types.KVMap) (string, error) {
func validateMessageHeaders(head types.KVMap) error {
if schema, ok := head["x-im-schema"]; ok {
value, ok := schema.(string)
if !ok || !validIMMessageHeaderToken(value, 32) {
if !validIMMessageSchema(schema) {
return types.ErrMalformed
}
}
if msgType, ok := head["type"]; ok {
value, ok := msgType.(string)
if !ok || !validIMMessageType(value) {
legacyType, hasLegacyType := messageHeaderString(head, "type")
productType, hasProductType := messageHeaderString(head, "x-im-type")
if raw, ok := head["type"]; ok && (!hasLegacyType || !validIMMessageType(legacyType)) {
_ = raw
return types.ErrMalformed
}
if raw, ok := head["x-im-type"]; ok && (!hasProductType || !validIMMessageType(productType)) {
_ = raw
return types.ErrMalformed
}
if hasLegacyType && hasProductType {
if canonicalIMMessageType(legacyType) != canonicalIMMessageType(productType) {
return types.ErrMalformed
}
}
return nil
}
func validIMMessageSchema(value any) bool {
switch typed := value.(type) {
case string:
return strings.TrimSpace(typed) == "1"
case json.Number:
parsed, err := typed.Int64()
return err == nil && parsed == 1
case float64:
return typed == 1
case float32:
return typed == 1
case int:
return typed == 1
case int8:
return typed == 1
case int16:
return typed == 1
case int32:
return typed == 1
case int64:
return typed == 1
case uint:
return typed == 1
case uint8:
return typed == 1
case uint16:
return typed == 1
case uint32:
return typed == 1
case uint64:
return typed == 1
default:
return false
}
}
func messageHeaderString(head types.KVMap, key string) (string, bool) {
value, ok := head[key]
if !ok {
return "", false
}
text, ok := value.(string)
if !ok {
return "", false
}
text = strings.TrimSpace(text)
return text, text != ""
}
func validIMMessageHeaderToken(value string, max int) bool {
value = strings.TrimSpace(value)
if value == "" || len(value) > max {
@@ -2351,13 +2568,27 @@ func validIMMessageHeaderToken(value string, max int) bool {
func validIMMessageType(value string) bool {
switch strings.TrimSpace(value) {
case "text", "image", "file", "audio", "video", "location", "contact", "card", "call", "system":
case "text", "image", "file", "audio", "voice", "video", "location", "contact", "contact_card",
"card", "link", "forward_bundle", "sticker", "call", "system", "system_event":
return true
default:
return false
}
}
func canonicalIMMessageType(value string) string {
switch strings.TrimSpace(value) {
case "audio":
return "voice"
case "contact":
return "contact_card"
case "system":
return "system_event"
default:
return strings.TrimSpace(value)
}
}
// DeleteList deletes multiple messages defined by a list of ranges.
func (messagesMapper) DeleteList(tenantID types.TenantID, topic string, delID int, forUser types.Uid, msgDelAge time.Duration, ranges []types.Range) error {
tenantAdp, err := tenantBusinessAdapter(tenantID)
+28
View File
@@ -1,6 +1,7 @@
package store
import (
"encoding/json"
"testing"
"github.com/tinode/chat/server/store/types"
@@ -11,3 +12,30 @@ func TestForTenantRejectsZero(t *testing.T) {
t.Fatalf("ForTenant(0) = (%v, %v), want (nil, malformed)", scoped, err)
}
}
func TestValidateMessageHeadersProductContract(t *testing.T) {
valid := []types.KVMap{
{"x-im-schema": float64(1), "x-im-type": "text"},
{"x-im-schema": json.Number("1"), "x-im-type": "voice"},
{"x-im-schema": "1", "x-im-type": "contact_card"},
{"x-im-schema": 1, "type": "audio", "x-im-type": "voice"},
{"type": "system"},
}
for _, head := range valid {
if err := validateMessageHeaders(head); err != nil {
t.Fatalf("validateMessageHeaders(%v) = %v, want nil", head, err)
}
}
invalid := []types.KVMap{
{"x-im-schema": float64(2), "x-im-type": "text"},
{"x-im-schema": true, "x-im-type": "text"},
{"x-im-schema": float64(1), "x-im-type": "unknown"},
{"type": "text", "x-im-type": "image"},
}
for _, head := range invalid {
if err := validateMessageHeaders(head); err != types.ErrMalformed {
t.Fatalf("validateMessageHeaders(%v) = %v, want malformed", head, err)
}
}
}
+16
View File
@@ -180,3 +180,19 @@ type GroupMute struct {
MutedByRaw int64 `db:"muted_by"`
RevokedRaw *int64 `db:"revoked_by"`
}
type GroupEvent struct {
ID int64 `db:"id"`
EventID string `db:"event_id"`
Topic string `db:"topic"`
ActorUserID Uid `db:"-"`
EventType string `db:"event_type"`
Payload []byte `db:"payload"`
Status string `db:"status"`
Attempts int `db:"attempts"`
NextRetryAt *time.Time `db:"next_retry_at"`
DeliveredAt *time.Time `db:"delivered_at"`
CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"`
ActorRaw int64 `db:"actor_user_id"`
}
+56
View File
@@ -0,0 +1,56 @@
package types
import (
"crypto/sha256"
"fmt"
"time"
)
const (
HTTPIdempotencyProcessing = "processing"
HTTPIdempotencyCompleted = "completed"
BusinessEventPending = "pending"
BusinessEventDelivered = "delivered"
)
// HTTPIdempotencyRecord stores the first response for one authenticated write.
type HTTPIdempotencyRecord struct {
UserID Uid
Method string
Route string
Key string
BodyHash string
State string
ResponseStatus int
ResponseBody []byte
CreatedAt time.Time
UpdatedAt time.Time
ExpiresAt time.Time
}
// BusinessEvent is a durable event destined for an online user or topic.
type BusinessEvent struct {
ID int64
EventID string
TargetType string
Target string
ActorUserID Uid
EventType string
Payload []byte
Status string
Attempts int
NextRetryAt *time.Time
DeliveredAt *time.Time
CreatedAt time.Time
UpdatedAt time.Time
}
// StableEventID derives an idempotent event identifier from domain identity.
func StableEventID(parts ...any) string {
hash := sha256.New()
for _, part := range parts {
_, _ = fmt.Fprint(hash, part, "\x00")
}
return fmt.Sprintf("evt_%x", hash.Sum(nil)[:24])
}
+22 -3
View File
@@ -1047,7 +1047,10 @@ func (t *Topic) saveAndBroadcastMessage(msg *ClientComMessage, asUid types.Uid,
delete(head, "sender")
}
if msg.sess != nil && msg.sess.authLvl != auth.LevelRoot {
if msgType, _ := head["type"].(string); msgType == "system" {
legacyType, _ := head["type"].(string)
productType, _ := head["x-im-type"].(string)
if legacyType == "system" || legacyType == "system_event" ||
productType == "system" || productType == "system_event" {
msg.sess.queueOut(ErrPermissionDenied(msg.Id, t.original(asUid), msg.Timestamp))
return types.ErrPermissionDenied
}
@@ -1060,6 +1063,20 @@ func (t *Topic) saveAndBroadcastMessage(msg *ClientComMessage, asUid types.Uid,
}
head = normalized
}
if t.cat == types.TopicCatGrp && msg.sess != nil && msg.sess.authLvl != auth.LevelRoot &&
store.GroupBusiness != nil && store.Store != nil && store.Store.IsOpen() {
access := pud.modeWant & pud.modeGiven
canPost, err := store.GroupBusiness.CanPost(t.tenantID, t.name, asUid, access.IsAdmin(), msg.Timestamp)
if err != nil {
logs.Warn.Printf("topic[%s]: failed to check group mute policy: %v", t.name, err)
msg.sess.queueOut(ErrUnknown(msg.Id, t.original(asUid), msg.Timestamp))
return err
}
if !canPost {
msg.sess.queueOut(ErrPermissionDenied(msg.Id, t.original(asUid), msg.Timestamp))
return types.ErrPermissionDenied
}
}
if t.cat == types.TopicCatP2P && msg.sess != nil && msg.sess.authLvl != auth.LevelRoot &&
store.Relations != nil && store.Store != nil && store.Store.IsOpen() {
peer := t.p2pOtherUser(asUid)
@@ -2301,7 +2318,7 @@ func (t *Topic) replyGetDesc(sess *Session, asUid types.Uid, _ bool, opts *MsgGe
// because to stay in memory at least one of the users must be connected to topic.
// FIXME(gene): it breaks when user A stays active in one session and connects-disconnects
// from another session. The second session will not see correct LastSeen time and UserAgent.
if pud.lastSeen != nil {
if pud.lastSeen != nil && imCanViewLastSeen(t.tenantID, asUid, t.p2pOtherUser(asUid)) {
desc.LastSeen = &MsgLastSeenInfo{
When: pud.lastSeen,
UserAgent: pud.lastUA,
@@ -2733,8 +2750,10 @@ func (t *Topic) replyGetSub(sess *Session, asUid types.Uid, authLevel auth.Level
// Reporting user's subscriptions to other topics. P2P topic name is the
// UID of the other user.
with := sub.GetWith()
canViewLastSeen := true
if with != "" {
mts.Topic = with
canViewLastSeen = imCanViewLastSeen(t.tenantID, asUid, types.ParseUserId(with))
mts.Online = t.perSubs[with].online && !deleted && presencer
} else if strings.HasPrefix(sub.Topic, "slf") {
mts.Topic = "slf"
@@ -2759,7 +2778,7 @@ func (t *Topic) replyGetSub(sess *Session, asUid types.Uid, authLevel auth.Level
}
lastSeen := sub.GetLastSeen()
if lastSeen != nil && !mts.Online {
if lastSeen != nil && !mts.Online && canViewLastSeen {
mts.LastSeen = &MsgLastSeenInfo{
When: lastSeen,
UserAgent: sub.GetUserAgent(),
+35 -1
View File
@@ -334,6 +334,40 @@ func TestHandleBroadcastDataP2PRequiresRelationship(t *testing.T) {
}
}
func TestHandleBroadcastDataGroupRejectsMutedMember(t *testing.T) {
helper := TopicTestHelper{}
helper.setUp(t, 2, types.TopicCatGrp, "grp-test", true)
defer helper.tearDown()
oldStore := store.Store
oldGroupBusiness := store.GroupBusiness
mockStore := mock_store.NewMockPersistentStorageInterface(helper.ctrl)
mockStore.EXPECT().IsOpen().Return(true)
store.Store = mockStore
store.GroupBusiness = &testGroupBusinessStore{settings: &types.GroupSettings{Topic: "grp-test", MuteAll: true}}
memberState := helper.topic.perUser[helper.uids[1]]
memberState.modeWant = types.ModeCPublic
memberState.modeGiven = types.ModeCPublic
helper.topic.perUser[helper.uids[1]] = memberState
defer func() {
store.Store = oldStore
store.GroupBusiness = oldGroupBusiness
}()
msg := &ClientComMessage{
Id: "muted-1", AsUser: helper.uids[1].UserId(), Original: "grp-test",
Pub: &MsgClientPub{Topic: "grp-test", Content: "blocked"},
sess: helper.sessions[1],
}
helper.topic.handleClientMsg(msg)
helper.finish()
verifyResponseCodes(helper.results[1], []int{http.StatusForbidden}, t)
if helper.topic.lastID != 0 {
t.Fatalf("muted publish changed topic sequence: %d", helper.topic.lastID)
}
}
func TestHandleBroadcastCall(t *testing.T) {
numUsers := 2
helper := TopicTestHelper{}
@@ -766,7 +800,7 @@ func TestHandleBroadcastDataRejectsUserSystemType(t *testing.T) {
AsUser: from,
Pub: &MsgClientPub{
Topic: "p2p",
Head: map[string]any{"type": "system"},
Head: map[string]any{"x-im-schema": float64(1), "x-im-type": "system_event"},
Content: "test",
},
sess: helper.sessions[0],