im 管理后台

This commit is contained in:
richjohnrambo
2026-08-17 09:52:02 +08:00
parent 331a55751d
commit 642b7ec5da
168 changed files with 8781 additions and 1 deletions
@@ -0,0 +1,313 @@
# IM 独立库多租户改造方案
> 编写日期:2026-08-13
> 适用项目:`im_server`
> 方案状态:替代原“共享库共享表 + tenant_id”方案,后续多租户开发以本文为准。
## 1. 方案结论
多租户调整为:
> 共享服务集群 + 控制库保存租户元数据 + 每个租户独立业务库
业务库中的 `users`、`auth`、`credentials`、`topics`、`subscriptions`、`messages`、`fileuploads` 以及后续产品业务表都不再增加 `tenant_id` 字段。租户隔离通过运行时选库实现:客户端先用企业码解析租户,服务端得到 `tenant_id` 后,根据控制库中的 `db_name` 或 `db_dsn` 选择对应租户业务库。
`TenantID` 仍然要保留,但它的职责从“业务表行级隔离字段”调整为“运行时租户上下文”:
1. 登录前企业码解析;
2. Token 签名和校验;
3. Session 当前租户固定;
4. Hub、缓存、集群路由、Push、文件路径和插件上下文隔离;
5. 选择当前请求使用哪个租户业务库;
6. 后台平台侧管理租户、配置配额、停启租户。
## 2. 为什么独立库仍需要 TenantID
独立库解决的是数据库物理隔离,不代表服务端运行时没有租户。服务进程仍然同时服务多个企业,如果内存 Topic、用户缓存、系统 Topic、Push、文件下载授权、Token 校验不带租户上下文,仍可能出现:
1. 两个租户都使用逻辑 Topic `sys` 时共用同一个内存 Topic;
2. HTTP 文件下载只拿到 `file_id`,无法判断该去哪个租户库查授权;
3. Token 只包含 UID 时,无法知道该 UID 属于哪个租户库;
4. 定时任务、插件调用、Push 回调无法定位租户库;
5. 后台接口可能把平台租户 ID 和业务租户数据混在一起。
所以新方案的边界是:
```text
TenantID 负责运行时路由和授权上下文
tenant_id 字段不进入租户业务库表结构
```
## 3. 数据库拆分
### 3.1 控制库
控制库可以使用现有 `hotgo`,只保存平台级租户元数据和配额配置。控制库表需要保留 `tenant_id`,因为这些表本身是在管理所有租户;这里的 `tenant_id` 不是业务库行级隔离字段。
```sql
CREATE TABLE im_tenant (
id BIGINT NOT NULL AUTO_INCREMENT,
code VARCHAR(64) NOT NULL,
name VARCHAR(128) NOT NULL,
tenant_desc VARCHAR(256),
db_name VARCHAR(128) NOT NULL DEFAULT '',
db_dsn VARCHAR(1024) NOT NULL DEFAULT '',
state SMALLINT NOT NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
created_by BIGINT NOT NULL,
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
updated_by BIGINT NOT NULL,
PRIMARY KEY (id),
UNIQUE KEY uk_im_tenant_code (code),
KEY idx_im_tenant_db_name (db_name)
);
CREATE TABLE im_tenant_config (
id BIGINT NOT NULL AUTO_INCREMENT,
tenant_id BIGINT NOT NULL,
max_users BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '0 means unlimited',
max_groups BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '0 means unlimited',
max_group_members INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '0 means unlimited',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
created_by BIGINT NOT NULL,
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
updated_by BIGINT NOT NULL,
PRIMARY KEY (id),
UNIQUE KEY uk_im_tenant_config_tenant_id (tenant_id),
CONSTRAINT fk_im_tenant_config_tenant
FOREIGN KEY (tenant_id) REFERENCES im_tenant(id)
ON DELETE CASCADE
);
```
`db_name` 适合所有租户共用同一个 MySQL 实例时使用,例如 `im_tenant_1001`。`db_dsn` 适合某些租户需要独立 MySQL 实例时使用。优先级建议为:`db_dsn` 非空时使用完整 DSN,否则使用主 DSN + `db_name`。
如果要求 `db_name` 不能重复,建议在租户创建服务里校验“非空 db_name 唯一”。MySQL 普通唯一索引不能表达“只对非空字符串唯一”,直接给 `db_name` 建唯一索引会导致多个使用独立 `db_dsn` 的租户因为空 `db_name` 冲突。
### 3.2 租户业务库
每个租户一个业务库,例如:
```text
hotgo
├─ im_tenant
└─ im_tenant_config
im_tenant_1001
├─ users
├─ auth
├─ credentials
├─ topics
├─ subscriptions
├─ messages
├─ fileuploads
└─ 后续产品业务表
```
租户业务库表结构恢复为租户内唯一约束,不加 `tenant_id`。同一个手机号、用户名可以在两个租户库中各自存在,不会冲突。
## 4. 当前代码如何调整
当前代码已经有一部分独立库路由雏形,可以继续利用:
1. `server/store/store.go` 已经预留 `TenantRoutedAdapter` 分支;
2. `server/db/adapter.go` 已定义 `TenantAdapterFor(tenantID)`;
3. `server/db/mysql/adapter.go` 已支持 `tenant_registry_db`、`tenant_db_routing`、`db_name`、`db_dsn`;
4. Token、Session、Hub TopicKey、UserKey、插件上下文已经带 `TenantID`。
需要调整的重点是:
| 模块 | 旧共享表方案 | 新独立库方案 |
| --- | --- | --- |
| 控制库 | 与业务表混在一个库 | 单独保存 `im_tenant`、`im_tenant_config` |
| 业务库 schema | 所有表加 `tenant_id` | 所有业务表不加 `tenant_id` |
| MySQL Adapter | SQL 使用 `WHERE tenant_id=?` | 先根据 TenantID 选库,再执行租户库内 SQL |
| Store 接口 | TenantID 作为 SQL 条件 | TenantID 作为选库参数 |
| 唯一索引 | `(tenant_id, business_key)` | 租户库内 `business_key` 唯一 |
| 外键 | 复合外键带 `tenant_id` | 普通外键 |
| 数据迁移 | 共享库内补列回填 | 新租户直接建独立库,无历史迁移 |
## 5. 代码落地步骤
### 5.1 拆分 schema
把 MySQL schema 拆成两个层次:
1. `registry_schema.sql`:只创建控制库表 `im_tenant`、`im_tenant_config`;
2. `tenant_schema.sql`:创建 Tinode 核心表和产品业务表,不包含 `tenant_id`;
3. `schema.sql` 可以作为文档索引,说明不能直接混用两套 schema。
`CreateDb(reset)` 也要拆分:
1. 初始化控制库;
2. 初始化默认租户业务库;
3. 根据 `im_tenant.db_name/db_dsn` 初始化指定租户库;
4. 禁止在同一个事务中同时创建控制库和多个租户业务库,避免失败后状态难恢复。
### 5.2 改 MySQL Adapter 路由
保留 `TenantAdapterFor(tenantID)`,但租户 Adapter 内执行的 SQL 应该是无 `tenant_id` 版本。
推荐结构:
```text
mysql.adapter
├─ registryDB: 连接控制库 hotgo
├─ defaultDB: 默认租户库
├─ tenantAdapters: map[TenantID]*adapter
└─ TenantAdapterFor(tenantID): 从 registryDB 读取 db_name/db_dsn 并返回租户库 adapter
```
租户业务方法仍保留 `TenantUserGet(tenantID, uid)` 这类签名,但实现改为“先选库,后执行租户库内原 SQL”。实际写代码时要避免递归,可以把“无租户 SQL”的方法放在租户库 adapter 上,把“路由包装方法”放在根 adapter 上。
### 5.3 删除业务表 tenant_id 依赖
需要回退或重写这些位置:
1. `server/db/mysql/schema.sql` 中核心业务表的 `tenant_id` 字段;
2. `server/db/mysql/tenant_adapter.go` 中所有 `WHERE tenant_id=?`、`INSERT tenant_id`、复合 JOIN;
3. `server/db/mysql/tests/mysql_test.go` 中依赖共享表隔离的测试;
4. `docs/sql/mysql-multitenant-tenant-id-review.sql` 这份共享表迁移脚本应标记为废弃;
5. 后续产品业务表设计也不再带 `tenant_id`。
不建议删除 Store、Session、Token、Hub、Push 和插件里的 `TenantID`,这些属于运行时隔离边界。
### 5.4 租户创建流程
后台创建租户时建议按以下顺序:
1. 在控制库 `im_tenant` 创建记录,状态为 `provisioning`;
2. 生成业务库名,例如 `im_tenant_{id}`;
3. 创建业务库并执行 `tenant_schema.sql`;
4. 写入默认系统数据,例如 `kvmeta.version`、系统账号、`sys` Topic;
5. 回写 `im_tenant.db_name`,状态改为 `active`;
6. 创建失败时租户保持不可登录状态,并记录错误供后台重试。
第一阶段可以不做自动建库接口,先由运维执行建库 SQL,再在控制库写入 `im_tenant`。
### 5.5 认证与登录
登录入口仍然是:
1. 客户端携带 `X-Tinode-APIKey` 请求准入;
2. `hi` 或登录前 HTTP 接口提交企业码;
3. 服务端在控制库查 `im_tenant.code`;
4. 租户状态必须是 `active`;
5. Session 固定 `TenantID`;
6. 登录认证根据 `TenantID` 选择租户业务库,然后查该库内 `auth`、`credentials`、`users`;
7. Token 签名内容继续包含 `TenantID`,HTTP 后续请求可从 Token 反推出该使用哪个租户库。
客户端不能通过 URL、Header、Body 直接指定业务库名或 `tenant_id`。
## 6. 产品业务表如何设计
后续根据《IM 产品原型需求文档》补充的业务表也按“租户库内表”设计,不加 `tenant_id`。所有新业务表仍统一要求:
```sql
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)
```
例如好友申请:
```sql
CREATE TABLE im_friend_requests (
id BIGINT NOT NULL AUTO_INCREMENT,
request_id CHAR(26) NOT NULL,
applicant_id BIGINT NOT NULL,
target_id BIGINT NOT NULL,
message VARCHAR(256),
status SMALLINT NOT NULL,
handled_by BIGINT,
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 KEY uk_im_friend_requests_request_id (request_id),
KEY idx_im_friend_requests_target_status (target_id, status, created_at),
KEY idx_im_friend_requests_applicant_status (applicant_id, status, created_at)
);
```
## 7. 文件和对象存储
数据库独立库后,文件元数据在租户库内隔离,但对象存储路径仍建议带租户目录:
```text
tenants/{tenant_id}/yyyy/mm/{file_id}
```
对象存储通常仍是共享 Bucket。带租户目录便于权限策略、容量统计、清理和迁移。
文件下载流程:
1. HTTP Token 解析得到 `tenant_id` 和 `uid`;
2. 根据 `tenant_id` 选择租户业务库;
3. 在该库查 `fileuploads` 和 `filemsglinks`;
4. 校验当前用户是否有 Topic 或消息访问权;
5. 返回文件或预签名 URL。
## 8. 测试策略
需要把旧共享表测试改成独立库测试:
| 用例 | 准备 | 预期 |
| --- | --- | --- |
| 企业码解析 | 控制库有 A、B 两个租户 | A、B 返回不同 `tenant_id` 和不同 `db_name` |
| 同手机号注册 | A、B 租户库分别注册同手机号 | 两边都成功 |
| 登录选库 | A 用户用 B 企业码登录 | B 库查不到该用户,登录失败 |
| Token 选库 | A Token 请求文件接口 | 只访问 A 租户库 |
| 内存 Topic 隔离 | A、B 都使用 `sys` | Hub 中是两个 TopicKey |
| 租户停用 | 控制库把 A 设为 suspended | 新登录失败 |
| 租户库不可用 | A 的 DSN 错误或库不存在 | A 请求失败,B 不受影响 |
| 连接池复用 | 多次访问 A | 只创建一个 A 租户 adapter,后续复用 |
| 配额检查 | A 配置 max_users=1 | A 超额注册失败,B 不受影响 |
## 9. 对旧方案的处理
以下内容停止继续开发:
1. 给核心业务表增加 `tenant_id`;
2. 把唯一索引改成 `(tenant_id, xxx)`;
3. 所有业务 SQL 加 `WHERE tenant_id=?`;
4. 共享库内的数据迁移、回填、复合外键改造;
5. 产品业务表中的 `tenant_id` 字段设计。
以下内容继续保留:
1. 企业码识别;
2. 控制库 `im_tenant`、`im_tenant_config`;
3. `TenantID` 类型;
4. Session TenantID;
5. Token TenantID;
6. Hub、缓存、集群、Push、插件上下文中的 TenantID;
7. `TenantAdapterFor` 选库机制。
## 10. 推荐下一步
下一步代码改造建议从 MySQL 层开始:
1. 把 `server/db/mysql/schema.sql` 拆为控制库 schema 和租户业务库 schema;
2. 把 `tenant_adapter.go` 改成路由包装,不再写 `tenant_id` SQL;
3. 恢复或复用原 `adapter.go` 中无租户 SQL 作为租户库真实执行逻辑;
4. 修改 MySQL 测试,创建两个独立业务库验证隔离;
5. 再继续补产品业务表,所有业务表按租户库内表设计。
## 11. 当前代码落地状态
2026-08-13 已开始按独立库方案改造 MySQL 底座:
1. 默认业务库为 `tinode`;
2. 控制库为 `hotgo`;
3. `CreateDb` 会在 `hotgo` 创建 `im_tenant`、`im_tenant_config`,默认租户 `id=1/code=default/db_name=tinode`;
4. `CreateDb` 会在 `tinode` 创建核心业务表,核心表不再包含 `tenant_id`;
5. `TenantAdapterFor` 开启路由后默认租户也会从控制库解析,不再直接退回根连接;
6. 租户库 adapter 禁用二次路由,避免递归;
7. 用户、认证、凭证、Topic、Subscription、消息、设备、文件等核心 Tenant 方法已改为“先选库,再调用租户库内无 tenant_id 方法”;
8. 旧共享表 `tenant_id` 迁移 SQL 已标记废弃。
剩余项:
1. 产品扩展表和 CRUD,例如好友、收藏、实名、青少年、系统通知,还需要继续从 `tenant_id` SQL 改成租户库内无 `tenant_id` SQL;
2. `server/db/mysql/schema.sql` 仍是参考文件,已标注正在从旧共享表草稿拆分,后续应拆成 `registry_schema.sql` 和 `tenant_schema.sql`;
3. 旧版本 `UpgradeDb` 迁移链仍包含共享表历史步骤。因为当前项目不考虑历史数据,优先使用新建库 `CreateDb`;如后续需要升级老库,应单独重写迁移链。