diff --git a/.gitignore b/.gitignore index d2a83a5..4aa21cc 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,12 @@ /.github/ /templ/ /templ/ + +# macOS metadata. +.DS_Store + +# IDE workspace files. +.idea/ + +# Local Go build output. +/server/server diff --git a/docs/IM-后台需求文档.md b/docs/IM-后台需求文档.md new file mode 100644 index 0000000..aebf01e --- /dev/null +++ b/docs/IM-后台需求文档.md @@ -0,0 +1,845 @@ +# IM 后台需求文档 + +> 编写日期:2026-08-12 +> 依据文档:`IM-产品需求服务端改造分析报告.md`、`IM-服务端多租户改造方案.md`、`IM-多租户第5至12章代码改造与测试方案.md`、`IM-前后端对接文档.md`、`IM-系统设计文档.md` +> 目标读者:后台产品、后端、管理端前端、测试 + +## 1. 文档目的 + +本文从现有 IM 产品与服务端设计文档中提取“后台管理端”需求,用于后续后台系统开发。本文只描述后台能力,不重复普通移动端聊天、通讯录、消息收发等客户端功能。 + +后台系统分为两类: + +| 后台 | 使用对象 | 数据范围 | +| --- | --- | --- | +| 平台运营后台 | 平台管理员、运维、安全负责人 | 可管理全部租户,但跨租户操作必须二次验证和审计 | +| 企业管理后台 | 租户负责人、企业管理员、安全审计员、组织管理员 | 只能管理当前租户内的数据 | + +所有后台能力必须建立在多租户隔离之上:后台 Token 必须包含可信 `tenant_id`、`uid`、角色、权限版本;平台后台的跨租户操作必须走独立后台 API,禁止通过普通 WebSocket `AsUser` 任意代操作用户。 + +## 2. 后台角色与权限 + +### 2.1 角色 + +| 角色 | 作用域 | 说明 | +| --- | --- | --- | +| `platform_admin` | 平台级 | 租户生命周期、平台配置、运维诊断、跨租户审计 | +| `tenant_owner` | 租户级 | 企业最高管理员,拥有本租户全部管理权限 | +| `tenant_admin` | 租户级 | 组织、用户、群、安全策略和工作台配置 | +| `security_auditor` | 租户级 | 实名、举报、反馈、审计读取和安全处置 | +| `org_admin` | 租户级或部门级 | 部门、成员、岗位、组织数据范围 | +| `report_admin` | 部门级 | 日报、月报、会议统计与提醒 | +| `group_admin` | 群级 | 群成员、公告、禁言、群文件等群治理 | + +### 2.2 权限原则 + +1. 平台管理员可以选择租户上下文进行运维,但所有跨租户读取、导出、代操作必须记录审计。 +2. 企业管理员只能访问自己租户的数据,不能通过 URL、Header、Body 指定其他 `tenant_id`。 +3. 后台菜单和按钮由服务端返回权限裁剪结果,前端隐藏只是体验优化,服务端仍必须二次鉴权。 +4. 敏感操作必须支持二次认证、幂等键、版本号或 `If-Match`,并写入审计。 +5. 普通租户管理员不能修改或删除审计日志。 + +## 3. 后台功能总览 + +| 模块 | 平台后台 | 企业后台 | 优先级 | +| --- | --- | --- | --- | +| 租户管理 | 是 | 否 | P0 | +| 租户配置与配额 | 是 | 部分只读或申请变更 | P0 | +| 管理员与角色权限 | 是 | 是 | P0 | +| 用户与账号安全 | 支持跨租户运维 | 是 | P0 | +| 组织架构 | 否 | 是 | P1 | +| 群治理 | 运维只读/处置 | 是 | P1 | +| 工作台应用配置 | 平台模板 | 租户配置 | P2 | +| 会议、日报、月报 | 否 | 是 | P2 | +| 总经理信箱 | 否 | 是 | P2 | +| 实名认证审核 | 平台可审计 | 是 | P2 | +| 反诈与内容审核 | 平台策略/插件 | 租户策略/处置 | P2 | +| 举报与反馈工单 | 平台可审计 | 是 | P2 | +| 文件与存储治理 | 平台运维 | 租户范围 | P1 | +| Push、短信、TURN、对象存储配置 | 是 | 租户专属配置 | P1 | +| 审计日志 | 是 | 只读本租户 | P0 | +| Outbox 与后台任务 | 是 | 租户任务可见 | P1 | + +## 4. 平台后台需求 + +### 4.1 租户管理 + +后台需要支持租户的创建、查看、编辑、停用、恢复、删除前置检查和删除归档。 + +租户状态: + +| 状态 | 登录 | 读消息 | 写业务 | 管理操作 | +| --- | --- | --- | --- | --- | +| `provisioning` | 禁止 | 禁止 | 禁止 | 平台管理员可配置 | +| `active` | 允许 | 允许 | 允许 | 按角色允许 | +| `suspended` | 禁止新登录 | 可配置只读 | 禁止 | 平台管理员可恢复或导出 | +| `deleting` | 禁止 | 禁止 | 禁止 | 只允许删除任务 | +| `deleted` | 禁止 | 禁止 | 禁止 | 只保留审计 | + +核心字段: + +| 字段 | 说明 | +| --- | --- | +| `id` | 内部租户 ID,普通客户端不返回 | +| `code` | 企业码,全局唯一,最长 64 字符 | +| `name` | 企业名称 | +| `tenant_desc` | 企业说明 | +| `state` | 租户状态 | +| `created_by/updated_by` | 操作人 | +| `created_at/updated_at` | 时间 | + +建议接口: + +| 方法 | 路径 | 说明 | +| --- | --- | --- | +| `GET` | `/admin/v1/tenants` | 租户列表,支持状态、企业码、名称过滤 | +| `POST` | `/admin/v1/tenants` | 创建租户 | +| `GET` | `/admin/v1/tenants/{tenant_id}` | 租户详情 | +| `PATCH` | `/admin/v1/tenants/{tenant_id}` | 修改名称、说明等基础资料 | +| `POST` | `/admin/v1/tenants/{tenant_id}/suspend` | 停用租户 | +| `POST` | `/admin/v1/tenants/{tenant_id}/restore` | 恢复租户 | +| `POST` | `/admin/v1/tenants/{tenant_id}/deletion-check` | 删除前检查 | +| `DELETE` | `/admin/v1/tenants/{tenant_id}` | 进入删除流程 | + +验收点: + +1. 租户 `code` 全局唯一。 +2. 停用后旧 Token、WS 登录、HTTP 产品接口均拒绝。 +3. 删除必须进入异步任务,不允许直接硬删业务表。 +4. 所有状态变更写平台审计。 + +### 4.2 租户配置与配额 + +首期配置真源是 `im_tenant_config`。 + +| 配置 | 说明 | 默认 | +| --- | --- | --- | +| `max_users` | 有效用户数上限 | `0` 表示不限 | +| `max_groups` | 有效群数量上限 | `0` 表示不限 | +| `max_group_members` | 单群有效成员上限 | `0` 表示不限 | + +后续可扩展配置: + +| 配置域 | 示例 | +| --- | --- | +| 功能开关 | 加好友、建群、用户搜索、音视频、群文件、日报月报 | +| 安全策略 | 实名要求、青少年限制、风控模式、数据保留期 | +| 消息策略 | 文本长度、媒体类型、消息频控、撤回时限、编辑时限 | +| 文件策略 | 单文件大小、总存储配额、允许 MIME、病毒扫描 | +| Push 配置 | APNS/FCM 凭证、模板、锁屏隐私 | +| 外部服务 | 短信、REST 认证、TURN、对象存储、审核插件 | +| 企业品牌 | Logo、主题色、企业描述、客户端下载地址 | + +建议接口: + +| 方法 | 路径 | 说明 | +| --- | --- | --- | +| `GET` | `/admin/v1/tenants/{tenant_id}/config` | 查询租户配置 | +| `PATCH` | `/admin/v1/tenants/{tenant_id}/config` | 修改配额和策略 | +| `GET` | `/admin/v1/tenants/{tenant_id}/usage` | 查询用户数、群数、文件容量、消息量 | +| `GET` | `/admin/v1/tenants/{tenant_id}/features` | 查询功能开关 | +| `PATCH` | `/admin/v1/tenants/{tenant_id}/features` | 修改功能开关 | + +验收点: + +1. 注册、创建群、入群审批必须在事务内校验配额。 +2. 超限返回稳定错误码,并记录当前用量、限制值、租户和操作。 +3. 修改配置只影响当前租户,不能污染全平台缓存。 + +### 4.3 平台管理员管理 + +需求: + +1. 创建、停用、恢复平台管理员。 +2. 分配平台角色和权限。 +3. 平台管理员登录、二次认证、Token 撤销。 +4. 查看平台管理员操作审计。 + +敏感操作: + +| 操作 | 要求 | +| --- | --- | +| 创建平台管理员 | 二次确认和审计 | +| 授权 `platform_admin` | 二次认证 | +| 跨租户代操作 | 必须填写原因,记录目标租户和目标对象 | +| 导出数据 | 审批或二次认证,记录导出范围 | + +### 4.4 平台运维与任务 + +需求: + +1. 查看运行节点、数据库版本、Adapter 类型和多租户能力。 +2. 非 MySQL 或未完成租户能力的 Adapter 在产品模式下必须拒绝启动。 +3. 查看 Outbox 事件积压、失败、死信和重试。 +4. 查看文件垃圾回收、搜索索引任务、Push 投递、外部审核调用状态。 +5. 支持按租户维度查看任务,但避免 Prometheus 高基数标签。 + +建议接口: + +| 方法 | 路径 | 说明 | +| --- | --- | --- | +| `GET` | `/admin/v1/system/health` | 系统健康 | +| `GET` | `/admin/v1/system/nodes` | 节点列表 | +| `GET` | `/admin/v1/system/outbox-events` | Outbox 查询 | +| `POST` | `/admin/v1/system/outbox-events/{id}/retry` | 重试事件 | +| `POST` | `/admin/v1/system/outbox-events/{id}/dead-letter` | 标记死信 | +| `GET` | `/admin/v1/system/audit-logs` | 平台审计 | + +## 5. 企业后台需求 + +### 5.1 企业管理员与角色权限 + +企业后台需要支持本租户内管理员管理。 + +角色: + +| 角色 | 权限 | +| --- | --- | +| `tenant_owner` | 全部租户管理权限 | +| `tenant_admin` | 用户、组织、群、安全、工作台配置 | +| `security_auditor` | 实名、举报、反馈、审计读取 | +| `org_admin` | 组织架构和成员维护 | +| `report_admin` | 日报、月报、会议统计 | + +建议接口: + +| 方法 | 路径 | 说明 | +| --- | --- | --- | +| `GET` | `/admin/v1/tenant/admins` | 企业管理员列表 | +| `POST` | `/admin/v1/tenant/admins` | 添加管理员 | +| `PATCH` | `/admin/v1/tenant/admins/{user_id}` | 修改角色 | +| `DELETE` | `/admin/v1/tenant/admins/{user_id}` | 移除管理员 | +| `GET` | `/admin/v1/tenant/roles` | 可用角色和权限点 | + +验收点: + +1. Tenant Admin 只能管理当前租户。 +2. 授权、移除、代操作必须审计。 +3. 不允许最后一个 `tenant_owner` 被删除或停用。 + +### 5.2 用户与账号安全管理 + +需求: + +1. 用户列表、搜索、详情。 +2. 创建、启用、停用、离职、恢复用户。 +3. 重置密码、撤销 Token、踢下线。 +4. 查看登录设备、登录 Session、安全事件。 +5. 查看用户实名状态、青少年状态、隐私设置摘要。 +6. 支持同手机号在不同租户独立存在,本租户后台只看到本租户用户。 + +建议接口: + +| 方法 | 路径 | 说明 | +| --- | --- | --- | +| `GET` | `/admin/v1/tenant/users` | 用户列表 | +| `POST` | `/admin/v1/tenant/users` | 创建用户 | +| `GET` | `/admin/v1/tenant/users/{user_id}` | 用户详情 | +| `PATCH` | `/admin/v1/tenant/users/{user_id}` | 修改资料或状态 | +| `POST` | `/admin/v1/tenant/users/{user_id}/reset-password` | 重置密码 | +| `POST` | `/admin/v1/tenant/users/{user_id}/revoke-sessions` | 撤销会话和 Token | +| `GET` | `/admin/v1/tenant/users/{user_id}/devices` | 登录设备 | +| `GET` | `/admin/v1/tenant/users/{user_id}/security-events` | 安全事件 | + +验收点: + +1. 停用用户不影响其他租户相同手机号或相同设备用户。 +2. 重置密码必须递增 `auth_version` 并撤销旧 Token。 +3. 查看敏感信息必须脱敏,证件号、手机号、Token 不写日志。 + +### 5.3 组织架构管理 + +需求: + +1. 部门树管理:新增、编辑、移动、删除部门。 +2. 部门成员管理:添加、移除、调岗、设置负责人。 +3. 组织搜索:按部门、用户、岗位搜索。 +4. 离职用户处理:不可联系或从结果移除。 +5. 组织数据权限:管理员只能管理权限范围内部门。 + +建议数据表: + +| 表 | 说明 | +| --- | --- | +| `im_org_units` | 部门树 | +| `im_org_members` | 部门成员、岗位、负责人 | + +建议接口: + +| 方法 | 路径 | 说明 | +| --- | --- | --- | +| `GET` | `/admin/v1/tenant/org/departments` | 部门树 | +| `POST` | `/admin/v1/tenant/org/departments` | 创建部门 | +| `PATCH` | `/admin/v1/tenant/org/departments/{id}` | 修改或移动部门 | +| `DELETE` | `/admin/v1/tenant/org/departments/{id}` | 删除部门 | +| `GET` | `/admin/v1/tenant/org/departments/{id}/members` | 部门成员 | +| `POST` | `/admin/v1/tenant/org/departments/{id}/members` | 添加成员 | +| `DELETE` | `/admin/v1/tenant/org/departments/{id}/members/{user_id}` | 移除成员 | +| `GET` | `/admin/v1/tenant/org/search` | 组织搜索 | + +验收点: + +1. 部门 `parent_id`、负责人、成员必须同租户。 +2. 移动部门不能形成环。 +3. 删除部门需要校验子部门和成员处理策略。 + +### 5.4 群治理管理 + +企业后台和群主/群管理员都需要群治理能力。后台侧侧重全租户范围查询、策略配置、违规处置和审计。 + +需求: + +1. 群列表、群详情、群成员列表。 +2. 群资料编辑:名称、头像、简介。 +3. 群治理配置:入群审批、全员禁言、成员上传文件、成员私聊、成员邀请。 +4. 群管理员管理:添加、删除管理员,管理员数量限制。 +5. 禁言管理:添加、解除、过期自动失效。 +6. 群主转让、解散群聊。 +7. 群二维码管理:生成、撤销、过期。 +8. 群文件管理:列表、搜索、删除、下载权限。 +9. 群操作日志:角色变化、禁言、公告、解散。 + +建议数据表: + +| 表 | 说明 | +| --- | --- | +| `im_group_settings` | 群治理配置 | +| `im_group_members` | 群成员和角色真源 | +| `im_group_join_requests` | 入群申请 | +| `im_group_announcements` | 群公告 | +| `im_group_mutes` | 禁言记录 | +| `im_group_admin_logs` | 群管理日志 | +| `im_group_files` | 群文件索引 | + +建议接口: + +| 方法 | 路径 | 说明 | +| --- | --- | --- | +| `GET` | `/admin/v1/tenant/groups` | 群列表 | +| `GET` | `/admin/v1/tenant/groups/{topic}` | 群详情 | +| `PATCH` | `/admin/v1/tenant/groups/{topic}` | 修改群资料 | +| `GET/PATCH` | `/admin/v1/tenant/groups/{topic}/governance` | 群治理配置 | +| `GET` | `/admin/v1/tenant/groups/{topic}/members` | 成员列表 | +| `POST` | `/admin/v1/tenant/groups/{topic}/admins/batch` | 添加管理员 | +| `DELETE` | `/admin/v1/tenant/groups/{topic}/admins/{user_id}` | 删除管理员 | +| `POST` | `/admin/v1/tenant/groups/{topic}/mutes/batch` | 添加禁言 | +| `DELETE` | `/admin/v1/tenant/groups/{topic}/mutes/{user_id}` | 解除禁言 | +| `POST` | `/admin/v1/tenant/groups/{topic}/owner-transfer` | 群主转让 | +| `DELETE` | `/admin/v1/tenant/groups/{topic}` | 解散群 | +| `GET` | `/admin/v1/tenant/groups/{topic}/files` | 群文件 | +| `DELETE` | `/admin/v1/tenant/groups/{topic}/files/{group_file_id}` | 删除群文件 | + +验收点: + +1. 群业务真源与 Topic/Subscription/ACL 投影必须在同一事务内更新。 +2. 管理员不能越级操作群主,普通管理员之间操作规则由治理策略决定。 +3. 解散群必须撤销 Topic、二维码、邀请和文件新增权限。 + +### 5.5 工作台应用配置 + +需求: + +1. 工作台模块配置:启用、禁用、排序、图标、名称、路由。 +2. 根据组织角色、权限和功能开关裁剪用户可见模块。 +3. 维护会议、日报、月报、总经理信箱等入口。 +4. 工作台未读角标由系统事件更新。 + +建议数据表: + +| 表 | 说明 | +| --- | --- | +| `im_workbench_apps` | 租户工作台应用配置 | +| `im_workbench_user_apps` | 用户或角色级工作台配置 | + +建议接口: + +| 方法 | 路径 | 说明 | +| --- | --- | --- | +| `GET` | `/admin/v1/tenant/workbench/apps` | 应用列表 | +| `POST` | `/admin/v1/tenant/workbench/apps` | 新增应用 | +| `PATCH` | `/admin/v1/tenant/workbench/apps/{code}` | 修改应用 | +| `DELETE` | `/admin/v1/tenant/workbench/apps/{code}` | 删除或禁用应用 | +| `POST` | `/admin/v1/tenant/workbench/apps/reorder` | 排序 | + +验收点: + +1. 客户端只接受白名单内部 `route`。 +2. 禁用模块后普通用户不可通过直接 URL 访问。 + +### 5.6 会议管理 + +需求: + +1. 创建、编辑、取消会议。 +2. 管理参会人、提醒未响应者。 +3. 查看参会响应:`pending`、`accepted`、`declined`、`tentative`。 +4. 会议过期后只读展示。 +5. 生成系统通知和 Push。 + +建议数据表: + +| 表 | 说明 | +| --- | --- | +| `im_meetings` | 会议主表 | +| `im_meeting_participants` | 参会人响应 | + +建议接口: + +| 方法 | 路径 | 说明 | +| --- | --- | --- | +| `GET` | `/admin/v1/tenant/meetings` | 会议列表 | +| `POST` | `/admin/v1/tenant/meetings` | 创建会议 | +| `GET` | `/admin/v1/tenant/meetings/{id}` | 会议详情 | +| `PATCH` | `/admin/v1/tenant/meetings/{id}` | 更新会议 | +| `POST` | `/admin/v1/tenant/meetings/{id}/cancel` | 取消会议 | +| `POST` | `/admin/v1/tenant/meetings/{id}/reminders` | 提醒未响应者 | + +### 5.7 日报与月报管理 + +需求: + +1. 日报/月报模板和校验规则配置。 +2. 普通用户草稿、提交、修订。 +3. 管理员按部门查看统计和明细。 +4. 提醒未提交人员。 +5. 查看详情时记录已读状态。 +6. 附件使用文件 ACL,不能通过旧 URL 越权下载。 + +建议数据表: + +| 表 | 说明 | +| --- | --- | +| `im_reports` | 日报/月报统一表 | +| `im_report_read_states` | 阅读状态 | + +建议接口: + +| 方法 | 路径 | 说明 | +| --- | --- | --- | +| `GET` | `/admin/v1/tenant/reports/daily/statistics` | 日报统计 | +| `GET` | `/admin/v1/tenant/reports/daily` | 日报列表 | +| `GET` | `/admin/v1/tenant/reports/daily/{id}` | 日报详情 | +| `POST` | `/admin/v1/tenant/reports/daily/reminders` | 日报提醒 | +| `GET` | `/admin/v1/tenant/reports/monthly/statistics` | 月报统计 | +| `GET` | `/admin/v1/tenant/reports/monthly` | 月报列表 | +| `GET` | `/admin/v1/tenant/reports/monthly/{id}` | 月报详情 | +| `POST` | `/admin/v1/tenant/reports/monthly/reminders` | 月报提醒 | + +验收点: + +1. 管理员只能看组织权限范围内成员。 +2. 并发保存必须使用版本号,冲突返回当前最新版本。 +3. 提醒接口需要限流并返回逐项结果。 + +### 5.8 总经理信箱 + +需求: + +1. 配置是否启用、是否允许匿名、必填字段、附件策略、提示文案。 +2. 用户提交意见或建议。 +3. 后台查看、分派、处理、回复和关闭。 +4. 匿名模式下业务接收方不可见提交人;安全审计是否保留内部追踪由企业政策决定。 +5. 文案变更后,客户端提交时可要求重新确认。 + +建议数据表: + +| 表 | 说明 | +| --- | --- | +| `im_manager_mailbox` | 信箱提交和处理记录 | + +建议接口: + +| 方法 | 路径 | 说明 | +| --- | --- | --- | +| `GET` | `/admin/v1/tenant/executive-mailbox/config` | 查询配置 | +| `PATCH` | `/admin/v1/tenant/executive-mailbox/config` | 修改配置 | +| `GET` | `/admin/v1/tenant/executive-mailbox/submissions` | 提交列表 | +| `GET` | `/admin/v1/tenant/executive-mailbox/submissions/{id}` | 提交详情 | +| `POST` | `/admin/v1/tenant/executive-mailbox/submissions/{id}/assign` | 分派 | +| `POST` | `/admin/v1/tenant/executive-mailbox/submissions/{id}/reply` | 回复 | +| `POST` | `/admin/v1/tenant/executive-mailbox/submissions/{id}/close` | 关闭 | + +## 6. 安全、审核与工单后台 + +### 6.1 实名认证审核 + +需求: + +1. 查看实名认证列表:未认证、待审核、已通过、已拒绝、申诉中。 +2. 查看脱敏信息,具备权限时按流程查看敏感原文。 +3. 审核通过、拒绝、要求补充材料。 +4. 实名策略配置:是否强制实名、允许跳过、提醒周期、重复身份策略。 +5. 审核结果通过系统事件通知用户。 + +建议数据表: + +| 表 | 说明 | +| --- | --- | +| `im_realname_verifications` | 实名信息、审核状态、脱敏展示 | + +建议接口: + +| 方法 | 路径 | 说明 | +| --- | --- | --- | +| `GET` | `/admin/v1/tenant/security/realname/submissions` | 实名审核列表 | +| `GET` | `/admin/v1/tenant/security/realname/submissions/{id}` | 审核详情 | +| `POST` | `/admin/v1/tenant/security/realname/submissions/{id}/approve` | 通过 | +| `POST` | `/admin/v1/tenant/security/realname/submissions/{id}/reject` | 拒绝 | +| `GET/PATCH` | `/admin/v1/tenant/security/realname/policy` | 实名策略 | + +验收点: + +1. 姓名、证件号加密存储,普通日志不出现原文。 +2. 审核操作必须审计。 +3. 普通管理员无权读取敏感原文。 + +### 6.2 青少年模式策略 + +需求: + +1. 配置青少年模式默认策略和限制范围。 +2. 查看用户青少年模式状态。 +3. 处理用户忘记独立密码后的重置流程。 + +限制范围首期建议包括: + +1. 添加好友。 +2. 加群和创建群。 +3. 文件传输。 +4. 音视频通话。 +5. 工作台外部链接。 + +建议接口: + +| 方法 | 路径 | 说明 | +| --- | --- | --- | +| `GET/PATCH` | `/admin/v1/tenant/security/youth-mode/policy` | 青少年策略 | +| `GET` | `/admin/v1/tenant/security/youth-mode/users` | 用户状态列表 | +| `POST` | `/admin/v1/tenant/security/youth-mode/users/{user_id}/reset-pin` | 重置独立密码 | + +### 6.3 反诈与内容审核 + +需求: + +1. 风控策略配置:高风险动作、提示文案、二次确认、阻断策略。 +2. 审核插件配置:平台统一审核服务或租户专属审核服务。 +3. 审核模式配置:`sync_block`、`async_hold`、`async_post_audit`。 +4. 查看风险事件、确认、取消、过期、阻断记录。 +5. 对已发布违规消息执行系统撤回。 + +建议接口: + +| 方法 | 路径 | 说明 | +| --- | --- | --- | +| `GET/PATCH` | `/admin/v1/tenant/security/risk-policy` | 风控策略 | +| `GET/PATCH` | `/admin/v1/tenant/security/moderation-policy` | 内容审核策略 | +| `GET` | `/admin/v1/tenant/security/risk-events` | 风险事件 | +| `GET` | `/admin/v1/tenant/security/moderation-tasks` | 审核任务 | +| `POST` | `/admin/v1/tenant/security/moderation-tasks/{id}/approve` | 审核通过 | +| `POST` | `/admin/v1/tenant/security/moderation-tasks/{id}/reject` | 审核拒绝 | +| `POST` | `/admin/v1/tenant/messages/{message_id}/recall-by-review` | 审核撤回 | + +验收点: + +1. 客户端提交的审核状态一律忽略。 +2. 插件返回 `REPLACE` 或 `RESPOND` 时不能修改 TenantID。 +3. 风控确认 Token 单次、短期、绑定原动作。 + +### 6.4 举报工单 + +需求: + +1. 配置举报原因,按目标类型区分用户、群、消息。 +2. 举报列表、详情、证据、目标快照。 +3. 工单状态:`received`、`processing`、`resolved`、`rejected`、`closed`。 +4. 处置动作:警告、禁言、封禁用户、解散群、撤回消息、标记无效。 +5. 重复举报合并策略。 +6. 处理结果通知举报人,必要时通知被处置对象。 + +建议数据表: + +| 表 | 说明 | +| --- | --- | +| `im_abuse_reports` | 举报工单 | +| `im_evidence_files` | 证据文件 | + +建议接口: + +| 方法 | 路径 | 说明 | +| --- | --- | --- | +| `GET/PATCH` | `/admin/v1/tenant/reports-abuse/reasons` | 举报原因配置 | +| `GET` | `/admin/v1/tenant/reports-abuse` | 举报列表 | +| `GET` | `/admin/v1/tenant/reports-abuse/{id}` | 举报详情 | +| `POST` | `/admin/v1/tenant/reports-abuse/{id}/assign` | 分派 | +| `POST` | `/admin/v1/tenant/reports-abuse/{id}/resolve` | 处置 | +| `POST` | `/admin/v1/tenant/reports-abuse/{id}/close` | 关闭 | + +验收点: + +1. 举报人必须能看到被举报目标,后台详情使用提交时固化快照。 +2. 证据文件使用独立 ACL 和保留期。 +3. 原消息删除或撤回后,举报证据仍可处理。 + +### 6.5 意见反馈工单 + +需求: + +1. 配置反馈类别、最大正文、附件规则、是否允许联系方式、日志授权说明。 +2. 反馈列表、详情、附件、设备和版本信息。 +3. 分派、回复、关闭、标记重复。 +4. 处理结果通过系统通知或客户端查询展示。 + +建议数据表: + +| 表 | 说明 | +| --- | --- | +| `im_feedbacks` | 反馈工单 | +| `im_evidence_files` | 反馈附件 | + +建议接口: + +| 方法 | 路径 | 说明 | +| --- | --- | --- | +| `GET/PATCH` | `/admin/v1/tenant/feedback/config` | 反馈配置 | +| `GET` | `/admin/v1/tenant/feedbacks` | 反馈列表 | +| `GET` | `/admin/v1/tenant/feedbacks/{id}` | 反馈详情 | +| `POST` | `/admin/v1/tenant/feedbacks/{id}/assign` | 分派 | +| `POST` | `/admin/v1/tenant/feedbacks/{id}/reply` | 回复 | +| `POST` | `/admin/v1/tenant/feedbacks/{id}/close` | 关闭 | + +## 7. 文件、搜索与数据治理后台 + +### 7.1 文件治理 + +需求: + +1. 按租户查看文件用量、文件列表、状态和业务用途。 +2. 支持群文件、收藏文件、举报证据、反馈附件、日报/月报附件分类管理。 +3. 文件下载审计。 +4. 清理未关联文件、过期文件和违规文件。 +5. 配置文件大小、MIME、总存储配额、保留期。 + +文件状态建议: + +```text +pending -> uploaded -> ready +pending/uploaded -> failed +uploaded(超时且无引用) -> orphaned -> deleted +``` + +建议接口: + +| 方法 | 路径 | 说明 | +| --- | --- | --- | +| `GET` | `/admin/v1/tenant/files` | 文件列表 | +| `GET` | `/admin/v1/tenant/files/{file_id}` | 文件详情 | +| `POST` | `/admin/v1/tenant/files/{file_id}/quarantine` | 隔离文件 | +| `DELETE` | `/admin/v1/tenant/files/{file_id}` | 删除文件 | +| `GET/PATCH` | `/admin/v1/tenant/files/policy` | 文件策略 | + +验收点: + +1. 文件下载不能只靠 FileID,必须校验上传者或关联消息/Topic 读取权限。 +2. 后台删除文件必须审计,并处理消息、收藏、举报等引用。 + +### 7.2 搜索治理 + +需求: + +1. 消息索引状态查看。 +2. 重建指定租户、Topic 或时间段索引。 +3. 搜索敏感内容审计。 +4. 查看搜索性能和失败任务。 + +建议接口: + +| 方法 | 路径 | 说明 | +| --- | --- | --- | +| `GET` | `/admin/v1/tenant/search/index-status` | 索引状态 | +| `POST` | `/admin/v1/tenant/search/reindex` | 重建索引 | +| `GET` | `/admin/v1/tenant/search/audit-logs` | 搜索审计 | + +验收点: + +1. 搜索索引不是授权来源,返回前仍需 Topic Read ACL 二次校验。 +2. 用户退群或权限变化后必须立即阻断搜索结果。 + +## 8. 通知、Push 与外部服务后台 + +### 8.1 系统消息模板 + +需求: + +1. 管理系统事件类型和模板版本。 +2. 配置账号安全、好友、群、文件、通话、工作台、安全等通知模板。 +3. Outbox 事件驱动系统消息、Push、索引和定时任务。 + +首期事件: + +| 业务 | 事件 | +| --- | --- | +| 账号安全 | 新设备登录、密码修改、实名提醒 | +| 好友 | 新申请、通过、拒绝、删除 | +| 群 | 邀请、申请、审批、公告、禁言、管理员变更、群主转让、解散 | +| 文件 | 群文件上传、文件失效 | +| 通话 | 未接、拒绝、取消、忙线、通话结束 | +| 工作台 | 会议通知、会议变更、日报提醒、月报提醒、信箱处理 | +| 安全 | 反诈提示、举报受理、反馈受理 | + +### 8.2 Push、短信、TURN、对象存储 + +需求: + +1. 租户级 Push 模板、App 包名、APNS/FCM 凭证引用。 +2. 短信服务配置和模板。 +3. TURN 配置。 +4. 对象存储 Bucket、Prefix、签名策略。 +5. 外部 REST 认证服务配置。 +6. 外部审核和风控插件配置。 + +验收点: + +1. 租户没有配置时按明确策略失败,不得静默使用其他租户配置。 +2. 密钥只保存引用,不把明文 Secret 放入普通业务 JSON。 +3. 外部回调必须校验服务端签名的 TenantContext 和 nonce。 + +## 9. 审计与日志后台 + +### 9.1 审计范围 + +以下操作必须写审计: + +1. 租户创建、停用、恢复、删除。 +2. 平台管理员和租户管理员授权、移除、代操作。 +3. 用户停用、离职、密码重置、数据导出。 +4. 群主转让、管理员变更、禁言、解散群。 +5. 实名审核、举报处置、反馈处理、匿名信箱读取。 +6. 文件下载、删除、批量导出、搜索敏感内容。 +7. 反诈二次确认、内容审核处置。 +8. 外部服务配置修改。 + +### 9.2 审计字段 + +| 字段 | 说明 | +| --- | --- | +| `tenant_id` | 租户 | +| `actor_uid` | 操作人 | +| `actor_role` | 操作角色 | +| `action` | 操作类型 | +| `target_type` | 目标类型 | +| `target_id` | 目标 ID | +| `before_snapshot` | 变更前摘要 | +| `after_snapshot` | 变更后摘要 | +| `reason` | 操作原因 | +| `request_id` | 请求 ID | +| `ip` | 操作 IP,必要时脱敏 | +| `user_agent` | 操作客户端 | +| `created_at` | 操作时间 | + +审计表只允许追加,不允许普通管理员修改或删除。 + +建议接口: + +| 方法 | 路径 | 说明 | +| --- | --- | --- | +| `GET` | `/admin/v1/tenant/audit-logs` | 租户审计 | +| `GET` | `/admin/v1/audit-logs` | 平台审计 | +| `POST` | `/admin/v1/audit-logs/export` | 审计导出 | + +## 10. 后台公共能力 + +### 10.1 认证与会话 + +1. 后台登录必须使用独立后台入口,不与普通 App 登录混用权限。 +2. 后台 Token 包含 `tenant_id`、`uid`、角色、权限版本、`auth_version`。 +3. 支持刷新、撤销、踢下线、登录设备管理。 +4. 高危操作需要二次认证或 `X-Action-Token`。 + +### 10.2 API 规范 + +后台 API 建议统一使用 `/admin/v1/*`。 + +成功响应: + +```json +{ + "code": 0, + "data": {}, + "request_id": "01K...", + "server_time": "2026-08-12T05:30:00.000Z" +} +``` + +失败响应: + +```json +{ + "code": 30001, + "reason": "FORBIDDEN", + "message": "permission denied", + "details": {}, + "request_id": "01K...", + "server_time": "2026-08-12T05:30:00.000Z" +} +``` + +公共要求: + +1. 写操作必须带 `Idempotency-Key`。 +2. 更新操作必须带版本号或 `If-Match`。 +3. 列表统一使用 `limit + cursor`。 +4. 请求必须带 `X-Request-ID`。 +5. 不能从请求 Body、URL Query 或普通 Header 信任客户端自报 `tenant_id`。 +6. 手机号、证件号、验证码、Token、设备 Token、文件签名不写日志。 + +### 10.3 数据表公共规则 + +1. 新业务表统一使用 `im_` 前缀。 +2. 租户业务表必须包含 `tenant_id BIGINT NOT NULL`。 +3. 唯一索引优先使用 `(tenant_id, business_key)`。 +4. 子表外键优先使用 `(tenant_id, parent_id)`。 +5. 更新、删除、聚合、清理任务必须带 `tenant_id`。 +6. 平台级表和租户根表例外,但必须在 DDL 评审中说明作用域。 + +## 11. 开发优先级建议 + +### P0:后台地基 + +1. `/admin/v1` 认证中间件、RBAC、租户 Principal。 +2. 平台租户管理:创建、停用、恢复、配置配额。 +3. 企业管理员与角色权限。 +4. 用户管理:停用、重置密码、撤销 Session。 +5. 审计日志基础表和查询。 +6. Outbox 查询与重试基础能力。 + +### P1:企业治理 + +1. 组织架构管理。 +2. 群治理后台:群列表、群成员、管理员、禁言、解散、群文件。 +3. 文件策略、文件列表、文件审计。 +4. 外部服务配置:Push、短信、对象存储、TURN。 +5. 功能开关和配额用量面板。 + +### P2:办公与安全业务 + +1. 工作台配置。 +2. 会议管理。 +3. 日报、月报统计和提醒。 +4. 总经理信箱。 +5. 实名认证审核。 +6. 青少年策略。 +7. 反诈、内容审核、举报、反馈工单。 + +## 12. 验收清单 + +- [ ] 平台后台与企业后台权限边界清晰,跨租户操作必须审计。 +- [ ] 所有后台列表、详情、更新、删除都按租户隔离。 +- [ ] 租户停用后普通用户登录和 HTTP 产品接口拒绝。 +- [ ] 用户停用、重置密码、撤销 Token 不影响其他租户同手机号用户。 +- [ ] 创建用户、建群、入群审批配额在事务内强制执行。 +- [ ] 群主转让、禁言、管理员变更、解散群写审计并同步系统消息。 +- [ ] 实名、举报、反馈、信箱等敏感数据脱敏展示并按权限读取。 +- [ ] 文件下载、删除、导出均有资源级授权和审计。 +- [ ] 外部服务密钥只保存引用,不写普通日志。 +- [ ] Outbox 事件可查询、重试、死信处理,重复执行不产生重复副作用。 +- [ ] 审计表只允许追加,普通管理员不可修改或删除。 diff --git a/docs/IM-服务端多租户改造方案.md b/docs/IM-服务端多租户改造方案.md index 6102464..2c37aa4 100644 --- a/docs/IM-服务端多租户改造方案.md +++ b/docs/IM-服务端多租户改造方案.md @@ -1,5 +1,9 @@ # IM 服务端多租户改造方案 +> **方案变更说明,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 章“企业工作台与办公协同” diff --git a/docs/IM-独立库多租户改造方案.md b/docs/IM-独立库多租户改造方案.md new file mode 100644 index 0000000..e4f1ebc --- /dev/null +++ b/docs/IM-独立库多租户改造方案.md @@ -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`;如后续需要升级老库,应单独重写迁移链。 diff --git a/docs/sql/mysql-multitenant-tenant-id-review.sql b/docs/sql/mysql-multitenant-tenant-id-review.sql index ce6ca6b..736ef05 100644 --- a/docs/sql/mysql-multitenant-tenant-id-review.sql +++ b/docs/sql/mysql-multitenant-tenant-id-review.sql @@ -1,3 +1,14 @@ +-- DEPRECATED. +-- +-- 2026-08-13: the multi-tenant technical direction changed to +-- "control database + one business database per tenant". Do not execute this +-- shared-table tenant_id migration and do not continue adding tenant_id to +-- tenant business tables. +-- +-- New design document: +-- docs/IM-独立库多租户改造方案.md +-- +-- Original note: -- MySQL multi-tenant schema migration draft. -- REVIEW ONLY: do not execute before the application is fully tenant-aware. -- Target: MySQL 8.0, final schema version 119. diff --git a/server/db/mysql/adapter.go b/server/db/mysql/adapter.go index 2a5e3ef..2e3984f 100644 --- a/server/db/mysql/adapter.go +++ b/server/db/mysql/adapter.go @@ -44,6 +44,7 @@ type adapter struct { baseConfig *configType tenantDBRouting bool + tenantScopedDB bool tenantRegistryDB string tenantAdapters map[t.TenantID]*adapter tenantMu sync.Mutex @@ -67,6 +68,10 @@ const ( const defaultTenantID = t.TenantID(1) +func quoteIdent(name string) string { + return "`" + strings.ReplaceAll(name, "`", "``") + "`" +} + type configType struct { // DB connection settings. // See https://pkg.go.dev/github.com/go-sql-driver/mysql#Config @@ -383,7 +388,7 @@ func (a *adapter) TenantGetByCode(code string) (*t.Tenant, error) { } func (a *adapter) TenantAdapterFor(tenantID t.TenantID) (dbadapter.TenantBusinessAdapter, dbadapter.TenantAuthAdapter, error) { - if !a.tenantDBRouting || tenantID == defaultTenantID { + if !a.tenantDBRouting { return a, a, nil } a.tenantMu.Lock() @@ -423,6 +428,8 @@ func (a *adapter) TenantAdapterFor(tenantID t.TenantID) (dbadapter.TenantBusines sqlTimeout: a.sqlTimeout, txTimeout: a.txTimeout, baseConfig: a.baseConfig, + tenantDBRouting: false, + tenantScopedDB: true, } a.tenantMu.Lock() if existing := a.tenantAdapters[tenantID]; existing != nil { @@ -466,6 +473,57 @@ func (a *adapter) tenantByID(tenantID t.TenantID) (*t.Tenant, error) { return &tenant, nil } +func createTenantRegistry(tx *sql.Tx, registryDB, businessDB string) error { + if _, err := tx.Exec("CREATE DATABASE IF NOT EXISTS " + quoteIdent(registryDB) + " CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci"); err != nil { + return err + } + if _, err := tx.Exec("USE " + quoteIdent(registryDB)); err != nil { + return err + } + if _, err := tx.Exec( + `CREATE TABLE IF NOT EXISTS im_tenant( + id BIGINT NOT NULL AUTO_INCREMENT, + code VARCHAR(64) NOT NULL, + name VARCHAR(128) NOT NULL, + tenant_desc VARCHAR(256), + db_name VARCHAR(128) NOT NULL DEFAULT '', + db_dsn VARCHAR(1024) NOT NULL DEFAULT '', + state SMALLINT NOT NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + created_by BIGINT NOT NULL, + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + updated_by BIGINT NOT NULL, + PRIMARY KEY(id), + UNIQUE INDEX uk_im_tenant_code(code), + INDEX idx_im_tenant_db_name(db_name) + )`); err != nil { + return err + } + if _, err := tx.Exec( + `CREATE TABLE IF NOT EXISTS im_tenant_config( + id BIGINT NOT NULL AUTO_INCREMENT, + tenant_id BIGINT NOT NULL, + max_users BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '0 means unlimited', + max_groups BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '0 means unlimited', + max_group_members INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '0 means unlimited', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + created_by BIGINT NOT NULL, + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + updated_by BIGINT NOT NULL, + PRIMARY KEY(id), + UNIQUE INDEX uk_im_tenant_config_tenant_id(tenant_id), + CONSTRAINT fk_im_tenant_config_tenant FOREIGN KEY(tenant_id) REFERENCES im_tenant(id) ON DELETE CASCADE + )`); err != nil { + return err + } + _, err := tx.Exec( + `INSERT INTO im_tenant(id,code,name,tenant_desc,db_name,db_dsn,state,created_by,updated_by) + VALUES(1,'default','Default Tenant',NULL,?,'',?,0,0) + ON DUPLICATE KEY UPDATE db_name=VALUES(db_name), state=VALUES(state), updated_by=VALUES(updated_by)`, + businessDB, t.TenantStateActive) + return err +} + // CreateDb initializes the storage. func (a *adapter) CreateDb(reset bool) error { var err error @@ -497,96 +555,38 @@ func (a *adapter) CreateDb(reset bool) error { } }() + registryDB := a.tenantRegistryDB + if registryDB == "" { + registryDB = "hotgo" + } + businessDB := a.dbName + if businessDB == "" { + businessDB = defaultDatabase + } if reset { - if _, err = tx.Exec("DROP DATABASE IF EXISTS " + a.dbName); err != nil { + if _, err = tx.Exec("DROP DATABASE IF EXISTS " + quoteIdent(businessDB)); err != nil { + return err + } + if _, err = tx.Exec("DROP DATABASE IF EXISTS " + quoteIdent(registryDB)); err != nil { return err } } - if _, err = tx.Exec("CREATE DATABASE " + a.dbName + " CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci"); err != nil { + if err = createTenantRegistry(tx, registryDB, businessDB); err != nil { return err } - if _, err = tx.Exec("USE " + a.dbName); err != nil { + if _, err = tx.Exec("CREATE DATABASE IF NOT EXISTS " + quoteIdent(businessDB) + " CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci"); err != nil { return err } - if _, err = tx.Exec( - `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 INDEX uk_im_tenant_code(code) - )`); err != nil { - return err - } - - if _, err = tx.Exec( - `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 INDEX 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 - )`); err != nil { - return err - } - - if _, err = tx.Exec( - `CREATE TABLE im_verification_codes( - id BIGINT NOT NULL AUTO_INCREMENT, - tenant_id BIGINT NOT NULL, - challenge_id VARCHAR(64) NOT NULL, - scene VARCHAR(32) NOT NULL, - action VARCHAR(64) NOT NULL DEFAULT '', - phone_hash CHAR(64) NOT NULL, - phone_mask VARCHAR(32) NOT NULL, - target_user BIGINT, - code_hash CHAR(64) NOT NULL, - verification_token_hash CHAR(64), - state SMALLINT NOT NULL, - attempts INT NOT NULL DEFAULT 0, - max_attempts INT NOT NULL, - expires_at DATETIME(3) NOT NULL, - resend_after DATETIME(3) NOT NULL, - verified_at DATETIME(3), - consumed_at DATETIME(3), - created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), - updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), - PRIMARY KEY(id), - UNIQUE INDEX uk_im_verification_tenant_challenge(tenant_id, challenge_id), - INDEX idx_im_verification_tenant_scene_phone_state(tenant_id, scene, phone_hash, state, expires_at), - CONSTRAINT fk_im_verification_tenant FOREIGN KEY(tenant_id) REFERENCES im_tenant(id) ON DELETE CASCADE - )`); err != nil { - return err - } - - if _, err = tx.Exec( - `INSERT INTO im_tenant(code,name,tenant_desc,db_name,db_dsn,state,created_by,updated_by) - VALUES('default','Default Tenant',NULL,'tinode','',?,0,0)`, t.TenantStateActive); err != nil { + if _, err = tx.Exec("USE " + quoteIdent(businessDB)); err != nil { return err } if _, err = tx.Exec( `CREATE TABLE users( id BIGINT NOT NULL, - tenant_id BIGINT NOT NULL DEFAULT 1, createdat DATETIME(3) NOT NULL, updatedat DATETIME(3) NOT NULL, state SMALLINT NOT NULL DEFAULT 0, @@ -598,7 +598,6 @@ func (a *adapter) CreateDb(reset bool) error { trusted JSON, tags JSON, PRIMARY KEY(id), - INDEX users_tenant_id_id(tenant_id, id), INDEX users_state_stateat(state, stateat), INDEX users_lastseen_updatedat(lastseen, updatedat) )`); err != nil { @@ -607,14 +606,12 @@ func (a *adapter) CreateDb(reset bool) error { if _, err = tx.Exec( `CREATE TABLE im_user_security( - tenant_id BIGINT NOT NULL, user_id BIGINT NOT NULL, auth_version INT NOT NULL DEFAULT 1, password_updated_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(tenant_id,user_id), - CONSTRAINT fk_im_user_security_tenant FOREIGN KEY(tenant_id) REFERENCES im_tenant(id) ON DELETE CASCADE + PRIMARY KEY(user_id) )`); err != nil { return err } @@ -630,13 +627,11 @@ func (a *adapter) CreateDb(reset bool) error { if _, err = tx.Exec( `CREATE TABLE usertags( id INT NOT NULL AUTO_INCREMENT, - tenant_id BIGINT NOT NULL DEFAULT 1, userid BIGINT NOT NULL, tag VARCHAR(96) NOT NULL, PRIMARY KEY(id), FOREIGN KEY(userid) REFERENCES users(id), INDEX usertags_tag(tag), - INDEX usertags_tenant_tag(tenant_id, tag), UNIQUE INDEX usertags_userid_tag(userid, tag) )`); err != nil { return err @@ -646,7 +641,6 @@ func (a *adapter) CreateDb(reset bool) error { if _, err = tx.Exec( `CREATE TABLE devices( id INT NOT NULL AUTO_INCREMENT, - tenant_id BIGINT NOT NULL DEFAULT 1, userid BIGINT NOT NULL, hash CHAR(16) NOT NULL, deviceid TEXT NOT NULL, @@ -655,7 +649,6 @@ func (a *adapter) CreateDb(reset bool) error { lang VARCHAR(8), PRIMARY KEY(id), FOREIGN KEY(userid) REFERENCES users(id), - INDEX devices_tenant_userid(tenant_id, userid), UNIQUE INDEX devices_hash(hash) )`); err != nil { return err @@ -665,7 +658,6 @@ func (a *adapter) CreateDb(reset bool) error { if _, err = tx.Exec( `CREATE TABLE auth( id INT NOT NULL AUTO_INCREMENT, - tenant_id BIGINT NOT NULL DEFAULT 1, uname VARCHAR(32) NOT NULL, userid BIGINT NOT NULL, scheme VARCHAR(16) NOT NULL, @@ -674,7 +666,7 @@ func (a *adapter) CreateDb(reset bool) error { expires DATETIME, PRIMARY KEY(id), FOREIGN KEY(userid) REFERENCES users(id), - UNIQUE INDEX auth_tenant_uname(tenant_id, uname), + UNIQUE INDEX auth_uname(uname), UNIQUE INDEX auth_userid_scheme(userid, scheme) )`); err != nil { return err @@ -684,7 +676,6 @@ func (a *adapter) CreateDb(reset bool) error { if _, err = tx.Exec( `CREATE TABLE topics( id INT NOT NULL AUTO_INCREMENT, - tenant_id BIGINT NOT NULL DEFAULT 1, createdat DATETIME(3) NOT NULL, updatedat DATETIME(3) NOT NULL, state SMALLINT NOT NULL DEFAULT 0, @@ -702,10 +693,8 @@ func (a *adapter) CreateDb(reset bool) error { tags JSON, aux JSON, PRIMARY KEY(id), - UNIQUE INDEX topics_tenant_name(tenant_id, name), UNIQUE INDEX topics_name(name), INDEX topics_owner(owner), - INDEX topics_tenant_owner(tenant_id, owner), INDEX topics_state_stateat(state, stateat), INDEX topics_name_state_seqid(name, state, seqid) )`); err != nil { @@ -721,13 +710,11 @@ func (a *adapter) CreateDb(reset bool) error { if _, err = tx.Exec( `CREATE TABLE topictags( id INT NOT NULL AUTO_INCREMENT, - tenant_id BIGINT NOT NULL DEFAULT 1, topic CHAR(25) NOT NULL, tag VARCHAR(96) NOT NULL, PRIMARY KEY(id), FOREIGN KEY(topic) REFERENCES topics(name), INDEX topictags_tag(tag), - INDEX topictags_tenant_tag(tenant_id, tag), UNIQUE INDEX topictags_topic_tag(topic, tag) )`); err != nil { return err @@ -737,7 +724,6 @@ func (a *adapter) CreateDb(reset bool) error { if _, err = tx.Exec( `CREATE TABLE subscriptions( id INT NOT NULL AUTO_INCREMENT, - tenant_id BIGINT NOT NULL DEFAULT 1, createdat DATETIME(3) NOT NULL, updatedat DATETIME(3) NOT NULL, deletedat DATETIME(3), @@ -751,8 +737,6 @@ func (a *adapter) CreateDb(reset bool) error { private JSON, PRIMARY KEY(id), FOREIGN KEY(userid) REFERENCES users(id), - INDEX subscriptions_tenant_userid(tenant_id, userid), - INDEX subscriptions_tenant_topic(tenant_id, topic), UNIQUE INDEX subscriptions_topic_userid(topic, userid), INDEX subscriptions_topic(topic), INDEX subscriptions_deletedat(deletedat), @@ -765,7 +749,6 @@ func (a *adapter) CreateDb(reset bool) error { if _, err = tx.Exec( `CREATE TABLE messages( id INT NOT NULL AUTO_INCREMENT, - tenant_id BIGINT NOT NULL DEFAULT 1, createdat DATETIME(3) NOT NULL, updatedat DATETIME(3) NOT NULL, deletedat DATETIME(3), @@ -781,8 +764,7 @@ func (a *adapter) CreateDb(reset bool) error { content JSON, PRIMARY KEY(id), FOREIGN KEY(topic) REFERENCES topics(name), - INDEX messages_tenant_topic(tenant_id, topic), - UNIQUE INDEX messages_sender_client_mid(tenant_id, ` + "`from`" + `, client_msg_id), + UNIQUE INDEX messages_sender_client_mid(` + "`from`" + `, client_msg_id), UNIQUE INDEX messages_topic_seqid(topic, seqid) )`); err != nil { return err @@ -791,7 +773,6 @@ func (a *adapter) CreateDb(reset bool) error { if _, err = tx.Exec( `CREATE TABLE im_message_revisions( id BIGINT NOT NULL AUTO_INCREMENT, - tenant_id BIGINT NOT NULL, createdat DATETIME(3) NOT NULL, updatedat DATETIME(3) NOT NULL, topic CHAR(25) NOT NULL, @@ -802,8 +783,7 @@ func (a *adapter) CreateDb(reset bool) error { content JSON, reason VARCHAR(256) NOT NULL DEFAULT '', PRIMARY KEY(id), - UNIQUE INDEX uk_im_message_revisions_version(tenant_id, topic, seqid, version), - CONSTRAINT fk_im_message_revisions_tenant FOREIGN KEY(tenant_id) REFERENCES im_tenant(id) ON DELETE CASCADE + UNIQUE INDEX uk_im_message_revisions_version(topic, seqid, version) )`); err != nil { return err } @@ -816,7 +796,6 @@ func (a *adapter) CreateDb(reset bool) error { if _, err = tx.Exec( `CREATE TABLE dellog( id INT NOT NULL AUTO_INCREMENT, - tenant_id BIGINT NOT NULL DEFAULT 1, topic CHAR(25) NOT NULL, deletedfor BIGINT NOT NULL DEFAULT 0, delid INT NOT NULL, @@ -824,7 +803,6 @@ func (a *adapter) CreateDb(reset bool) error { hi INT NOT NULL, PRIMARY KEY(id), FOREIGN KEY(topic) REFERENCES topics(name), - INDEX dellog_tenant_topic(tenant_id, topic), INDEX dellog_topic_delid_deletedfor(topic,delid,deletedfor), INDEX dellog_topic_deletedfor_low_hi(topic,deletedfor,low,hi), INDEX dellog_deletedfor(deletedfor) @@ -836,7 +814,6 @@ func (a *adapter) CreateDb(reset bool) error { if _, err = tx.Exec( `CREATE TABLE credentials( id INT NOT NULL AUTO_INCREMENT, - tenant_id BIGINT NOT NULL DEFAULT 1, createdat DATETIME(3) NOT NULL, updatedat DATETIME(3) NOT NULL, deletedat DATETIME(3), @@ -849,7 +826,7 @@ func (a *adapter) CreateDb(reset bool) error { retries INT NOT NULL DEFAULT 0, PRIMARY KEY(id), FOREIGN KEY(userid) REFERENCES users(id), - UNIQUE credentials_tenant_uniqueness(tenant_id, synthetic) + UNIQUE credentials_uniqueness(synthetic) )`); err != nil { return err } @@ -859,7 +836,6 @@ func (a *adapter) CreateDb(reset bool) error { if _, err = tx.Exec( `CREATE TABLE fileuploads( id BIGINT NOT NULL, - tenant_id BIGINT NOT NULL DEFAULT 1, createdat DATETIME(3) NOT NULL, updatedat DATETIME(3) NOT NULL, userid BIGINT, @@ -869,7 +845,6 @@ func (a *adapter) CreateDb(reset bool) error { etag VARCHAR(128), location VARCHAR(2048) NOT NULL, PRIMARY KEY(id), - INDEX fileuploads_tenant_status(tenant_id, status), INDEX fileuploads_status(status) )`); err != nil { return err @@ -879,14 +854,12 @@ func (a *adapter) CreateDb(reset bool) error { if _, err = tx.Exec( `CREATE TABLE filemsglinks( id INT NOT NULL AUTO_INCREMENT, - tenant_id BIGINT NOT NULL DEFAULT 1, createdat DATETIME(3) NOT NULL, fileid BIGINT NOT NULL, msgid INT, topic CHAR(25), userid BIGINT, PRIMARY KEY(id), - INDEX filemsglinks_tenant_fileid(tenant_id, fileid), FOREIGN KEY(fileid) REFERENCES fileuploads(id) ON DELETE CASCADE, FOREIGN KEY(msgid) REFERENCES messages(id) ON DELETE CASCADE, FOREIGN KEY(topic) REFERENCES topics(name) ON DELETE CASCADE, @@ -914,443 +887,22 @@ func (a *adapter) CreateDb(reset bool) error { // UpgradeDb upgrades the database, if necessary. func (a *adapter) UpgradeDb() error { - bumpVersion := func(a *adapter, x int) error { - if err := a.updateDbVersion(x); err != nil { - return err - } - _, err := a.GetDbVersion() - return err - } - if _, err := a.GetDbVersion(); err != nil { return err } - if a.version == 106 { - // Perform database upgrade from version 106 to version 107. - - if _, err := a.db.Exec("CREATE UNIQUE INDEX usertags_userid_tag ON usertags(userid, tag)"); err != nil { - return err - } - - if _, err := a.db.Exec("CREATE UNIQUE INDEX topictags_topic_tag ON topictags(topic, tag)"); err != nil { - return err - } - - if _, err := a.db.Exec("ALTER TABLE credentials ADD deletedat DATETIME(3) AFTER updatedat"); err != nil { - return err - } - - if err := bumpVersion(a, 107); err != nil { - return err - } + if a.version == adpVersion { + return nil } - if a.version == 107 { - // Perform database upgrade from version 107 to version 108. - - // Replace default user access JRWPA with JRWPAS. - if _, err := a.db.Exec(`UPDATE users SET access=JSON_REPLACE(access, '$.Auth', 'JRWPAS') - WHERE CAST(JSON_EXTRACT(access, '$.Auth') AS CHAR) LIKE '"JRWPA"'`); err != nil { - return err - } - - if err := bumpVersion(a, 108); err != nil { - return err - } - } - - if a.version == 108 { - // Perform database upgrade from version 108 to version 109. - - tx, err := a.db.Begin() - if err != nil { - return err - } - if err = createSystemTopic(tx); err != nil { - tx.Rollback() - return err - } - if err = tx.Commit(); err != nil { - return err - } - - if err = bumpVersion(a, 109); err != nil { - return err - } - } - - if a.version == 109 { - // Perform database upgrade from version 109 to version 110. - if _, err := a.db.Exec("UPDATE topics SET touchedat=updatedat WHERE touchedat IS NULL"); err != nil { - return err - } - - if err := bumpVersion(a, 110); err != nil { - return err - } - } - - if a.version == 110 { - // Users - if _, err := a.db.Exec("ALTER TABLE users MODIFY state SMALLINT NOT NULL DEFAULT 0 AFTER updatedat"); err != nil { - return err - } - - if _, err := a.db.Exec("ALTER TABLE users CHANGE deletedat stateat DATETIME(3)"); err != nil { - return err - } - - if _, err := a.db.Exec("ALTER TABLE users DROP INDEX users_deletedat"); err != nil { - return err - } - - // Add status to formerly soft-deleted users. - if _, err := a.db.Exec("UPDATE users SET state=? WHERE stateat IS NOT NULL", t.StateDeleted); err != nil { - return err - } - - if _, err := a.db.Exec("ALTER TABLE users ADD INDEX users_state(state)"); err != nil { - return err - } - - // Topics - if _, err := a.db.Exec("ALTER TABLE topics ADD state SMALLINT NOT NULL DEFAULT 0 AFTER updatedat"); err != nil { - return err - } - - if _, err := a.db.Exec("ALTER TABLE topics CHANGE deletedat stateat DATETIME(3)"); err != nil { - return err - } - - // Add status to formerly soft-deleted topics. - if _, err := a.db.Exec("UPDATE topics SET state=? WHERE stateat IS NOT NULL", t.StateDeleted); err != nil { - return err - } - - if _, err := a.db.Exec("ALTER TABLE topics ADD INDEX topics_state(state)"); err != nil { - return err - } - - // Subscriptions - if _, err := a.db.Exec("ALTER TABLE subscriptions ADD INDEX topics_deletedat(deletedat)"); err != nil { - return err - } - - if err := bumpVersion(a, 111); err != nil { - return err - } - } - - if a.version == 111 { - // Perform database upgrade from version 111 to version 112. - if _, err := a.db.Exec("ALTER TABLE users ADD trusted JSON AFTER public"); err != nil { - return err - } - - if _, err := a.db.Exec("ALTER TABLE topics ADD trusted JSON AFTER public"); err != nil { - return err - } - - // Remove NOT NULL constraint, so an avatar upload can be done at registration. - if _, err := a.db.Exec("ALTER TABLE fileuploads MODIFY userid BIGINT"); err != nil { - return err - } - - if _, err := a.db.Exec("ALTER TABLE fileuploads ADD INDEX fileuploads_status(status)"); err != nil { - return err - } - - // Remove NOT NULL constraint to enable links to users and topics. - if _, err := a.db.Exec("ALTER TABLE filemsglinks MODIFY msgid INT"); err != nil { - return err - } - - if _, err := a.db.Exec("ALTER TABLE filemsglinks ADD topic CHAR(25)"); err != nil { - return err - } - - if _, err := a.db.Exec("ALTER TABLE filemsglinks ADD userid BIGINT"); err != nil { - return err - } - - if _, err := a.db.Exec("ALTER TABLE filemsglinks ADD FOREIGN KEY(topic) REFERENCES topics(name) ON DELETE CASCADE"); err != nil { - return err - } - - if _, err := a.db.Exec("ALTER TABLE filemsglinks ADD FOREIGN KEY(userid) REFERENCES users(id) ON DELETE CASCADE"); err != nil { - return err - } - - if err := bumpVersion(a, 112); err != nil { - return err - } - } - - if a.version == 112 { - // Perform database upgrade from version 112 to version 113. - - // Index for deleting unvalidated accounts. - if _, err := a.db.Exec("ALTER TABLE users ADD INDEX users_lastseen_updatedat(lastseen,updatedat)"); err != nil { - return err - } - - // Add timestamp to kvmeta. - if _, err := a.db.Exec("ALTER TABLE kvmeta MODIFY `key` VARCHAR(64) NOT NULL"); err != nil { - return err - } - - // Add timestamp to kvmeta. - if _, err := a.db.Exec("ALTER TABLE kvmeta ADD createdat DATETIME(3) AFTER `key`"); err != nil { - return err - } - - // Add compound index on the new field and key (could be searched by key prefix). - if _, err := a.db.Exec("ALTER TABLE kvmeta ADD INDEX kvmeta_createdat_key(createdat, `key`)"); err != nil { - return err - } - - if err := bumpVersion(a, 113); err != nil { - return err - } - } - - if a.version == 113 { - // Perform database upgrade from version 113 to version 114. - - if _, err := a.db.Exec("ALTER TABLE topics ADD aux JSON"); err != nil { - return err - } - - if _, err := a.db.Exec("ALTER TABLE fileuploads ADD etag VARCHAR(128) AFTER size"); err != nil { - return err - } - - if err := bumpVersion(a, 114); err != nil { - return err - } - } - - if a.version == 114 { - // Perform database upgrade from version 114 to version 115. - - // Find relevant subscriptions for given users efficiently, and use the join key too. - if _, err := a.db.Exec("CREATE INDEX idx_subs_user_topic_del ON subscriptions(userid, topic, deletedat)"); err != nil { - return err - } - - // Optimizes join; state filters; seqid supports the SUM operation. - if _, err := a.db.Exec("CREATE INDEX idx_topics_name_state_seqid ON topics(name, state, seqid)"); err != nil { - return err - } - - if err := bumpVersion(a, 115); err != nil { - return err - } - } - - if a.version == 115 { - // Perform database upgrade from version 115 to version 116. - - // Add subscriber count column to the topics table. - if _, err := a.db.Exec("ALTER TABLE topics ADD COLUMN subcnt INT DEFAULT 0 AFTER delid"); err != nil { - return err - } - - if err := bumpVersion(a, 116); err != nil { - return err - } - } - - if a.version == 116 { - // Add tenant registry tables. Business tables become tenant-scoped in - // the following schema version. - if _, err := a.db.Exec( - `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 INDEX uk_im_tenant_code(code) - )`); err != nil { - return err - } - - if _, err := a.db.Exec( - `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 INDEX 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 - )`); err != nil { - return err - } - - if _, err := a.db.Exec( - `INSERT INTO im_tenant(code,name,tenant_desc,db_name,db_dsn,state,created_by,updated_by) - VALUES('default','Default Tenant',NULL,'tinode','',?,0,0)`, t.TenantStateActive); err != nil { - return err - } - - if err := bumpVersion(a, 117); err != nil { - return err - } - } - - if a.version == 117 { - if _, err := a.db.Exec( - `CREATE TABLE im_verification_codes( - id BIGINT NOT NULL AUTO_INCREMENT, - tenant_id BIGINT NOT NULL, - challenge_id VARCHAR(64) NOT NULL, - scene VARCHAR(32) NOT NULL, - action VARCHAR(64) NOT NULL DEFAULT '', - phone_hash CHAR(64) NOT NULL, - phone_mask VARCHAR(32) NOT NULL, - target_user BIGINT, - code_hash CHAR(64) NOT NULL, - verification_token_hash CHAR(64), - state SMALLINT NOT NULL, - attempts INT NOT NULL DEFAULT 0, - max_attempts INT NOT NULL, - expires_at DATETIME(3) NOT NULL, - resend_after DATETIME(3) NOT NULL, - verified_at DATETIME(3), - consumed_at DATETIME(3), - created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), - updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), - PRIMARY KEY(id), - UNIQUE INDEX uk_im_verification_tenant_challenge(tenant_id, challenge_id), - INDEX idx_im_verification_tenant_scene_phone_state(tenant_id, scene, phone_hash, state, expires_at), - CONSTRAINT fk_im_verification_tenant FOREIGN KEY(tenant_id) REFERENCES im_tenant(id) ON DELETE CASCADE - )`); err != nil { - return err - } - - if _, err := a.db.Exec( - `CREATE TABLE im_user_security( - tenant_id BIGINT NOT NULL, - user_id BIGINT NOT NULL, - auth_version INT NOT NULL DEFAULT 1, - password_updated_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(tenant_id,user_id), - CONSTRAINT fk_im_user_security_tenant FOREIGN KEY(tenant_id) REFERENCES im_tenant(id) ON DELETE CASCADE - )`); err != nil { - return err - } - if _, err := a.db.Exec( - `INSERT INTO im_user_security(tenant_id,user_id,auth_version,created_at,updated_at) - SELECT tenant_id,id,1,updatedat,updatedat FROM users - ON DUPLICATE KEY UPDATE auth_version=auth_version`); err != nil { - return err - } - - if err := bumpVersion(a, 118); err != nil { - return err - } - } - - if a.version == 118 { - if _, err := a.db.Exec("ALTER TABLE im_verification_codes ADD COLUMN action VARCHAR(64) NOT NULL DEFAULT '' AFTER scene"); err != nil { - return err - } - tx, err := a.db.Begin() - if err != nil { - return err - } - if err = createIMSecurityTables(tx); err != nil { - tx.Rollback() - return err - } - if err = tx.Commit(); err != nil { - return err - } - if err := bumpVersion(a, 119); err != nil { - return err - } - } - - if a.version == 119 { - tx, err := a.db.Begin() - if err != nil { - return err - } - if err = createIMNotificationTables(tx); err != nil { - tx.Rollback() - return err - } - if err = tx.Commit(); err != nil { - return err - } - if err := bumpVersion(a, 120); err != nil { - return err - } - } - - if a.version == 120 { - if _, err := a.db.Exec("ALTER TABLE messages ADD COLUMN client_msg_id VARCHAR(64) AFTER `from`"); err != nil { - return err - } - if _, err := a.db.Exec("CREATE UNIQUE INDEX messages_sender_client_mid ON messages(tenant_id, `from`, client_msg_id)"); err != nil { - return err - } - if err := bumpVersion(a, 121); err != nil { - return err - } - } - - if a.version == 121 { - if err := upgradeIMMessageActions(a); err != nil { - return err - } - if err := bumpVersion(a, 122); err != nil { - return err - } - } - - if a.version == 122 { - if err := createIMRelationTables(a.db.DB); err != nil { - return err - } - if err := bumpVersion(a, 123); err != nil { - return err - } - } - - if err := ensureTenantRoutingColumns(a); err != nil { - return err - } - - if a.version != adpVersion { - return errors.New("Failed to perform database upgrade to version " + strconv.Itoa(adpVersion) + - ". DB is still at " + strconv.Itoa(a.version)) - } - return nil + return errors.New("mysql independent-database multitenancy requires a fresh schema; automatic upgrade from version " + + strconv.Itoa(a.version) + " to " + strconv.Itoa(adpVersion) + " is not supported") } func createIMNotificationTables(tx *sql.Tx) error { _, err := tx.Exec( `CREATE TABLE im_system_notifications( id BIGINT NOT NULL AUTO_INCREMENT, - tenant_id BIGINT NOT NULL, user_id BIGINT NOT NULL, event_id VARCHAR(64) NOT NULL, event_type VARCHAR(64) NOT NULL, @@ -1361,9 +913,8 @@ func createIMNotificationTables(tx *sql.Tx) error { created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), PRIMARY KEY(id), - UNIQUE INDEX uk_im_system_notifications_event(tenant_id, user_id, event_id), - INDEX idx_im_system_notifications_inbox(tenant_id, user_id, id DESC), - CONSTRAINT fk_im_system_notifications_tenant FOREIGN KEY(tenant_id) REFERENCES im_tenant(id) ON DELETE CASCADE + UNIQUE INDEX uk_im_system_notifications_event(user_id, event_id), + INDEX idx_im_system_notifications_inbox(user_id, id DESC) )`) return err } @@ -1420,7 +971,6 @@ func createIMRelationTables(exec sqlExecutor) error { statements := []string{ `CREATE TABLE im_friend_requests( id BIGINT NOT NULL AUTO_INCREMENT, - tenant_id BIGINT NOT NULL, request_id VARCHAR(64) NOT NULL, applicant_id BIGINT NOT NULL, target_id BIGINT NOT NULL, @@ -1434,14 +984,12 @@ func createIMRelationTables(exec sqlExecutor) error { created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), PRIMARY KEY(id), - UNIQUE INDEX uk_im_friend_requests_request(tenant_id, request_id), - UNIQUE INDEX uk_im_friend_requests_idempotency(tenant_id, applicant_id, idempotency_key), - INDEX idx_im_friend_requests_pair(tenant_id, applicant_id, target_id, status, created_at), - INDEX idx_im_friend_requests_inbox(tenant_id, target_id, status, created_at), - CONSTRAINT fk_im_friend_requests_tenant FOREIGN KEY(tenant_id) REFERENCES im_tenant(id) ON DELETE CASCADE + UNIQUE INDEX uk_im_friend_requests_request(request_id), + UNIQUE INDEX uk_im_friend_requests_idempotency(applicant_id, idempotency_key), + INDEX idx_im_friend_requests_pair(applicant_id, target_id, status, created_at), + INDEX idx_im_friend_requests_inbox(target_id, status, created_at) )`, `CREATE TABLE im_friendships( - tenant_id BIGINT NOT NULL, user_id BIGINT NOT NULL, peer_id BIGINT NOT NULL, status VARCHAR(16) NOT NULL, @@ -1449,23 +997,19 @@ func createIMRelationTables(exec sqlExecutor) error { topic CHAR(25) NOT NULL DEFAULT '', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), - PRIMARY KEY(tenant_id, user_id, peer_id), - INDEX idx_im_friendships_peer(tenant_id, peer_id, status), - CONSTRAINT fk_im_friendships_tenant FOREIGN KEY(tenant_id) REFERENCES im_tenant(id) ON DELETE CASCADE + PRIMARY KEY(user_id, peer_id), + INDEX idx_im_friendships_peer(peer_id, status) )`, `CREATE TABLE im_user_blocks( - tenant_id BIGINT NOT NULL, blocker_id BIGINT NOT NULL, blocked_id BIGINT NOT NULL, created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), - PRIMARY KEY(tenant_id, blocker_id, blocked_id), - INDEX idx_im_user_blocks_blocked(tenant_id, blocked_id), - CONSTRAINT fk_im_user_blocks_tenant FOREIGN KEY(tenant_id) REFERENCES im_tenant(id) ON DELETE CASCADE + PRIMARY KEY(blocker_id, blocked_id), + INDEX idx_im_user_blocks_blocked(blocked_id) )`, `CREATE TABLE im_favorites( id BIGINT NOT NULL AUTO_INCREMENT, - tenant_id BIGINT NOT NULL, favorite_id VARCHAR(64) NOT NULL, user_id BIGINT NOT NULL, item_type VARCHAR(16) NOT NULL, @@ -1479,12 +1023,11 @@ func createIMRelationTables(exec sqlExecutor) error { created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), PRIMARY KEY(id), - UNIQUE INDEX uk_im_favorites_favorite(tenant_id, favorite_id), - UNIQUE INDEX uk_im_favorites_source(tenant_id, user_id, source_topic, source_seq), - UNIQUE INDEX uk_im_favorites_idempotency(tenant_id, user_id, idempotency_key), - INDEX idx_im_favorites_inbox(tenant_id, user_id, id DESC), - INDEX idx_im_favorites_type(tenant_id, user_id, item_type, id DESC), - CONSTRAINT fk_im_favorites_tenant FOREIGN KEY(tenant_id) REFERENCES im_tenant(id) ON DELETE CASCADE + UNIQUE INDEX uk_im_favorites_favorite(favorite_id), + UNIQUE INDEX uk_im_favorites_source(user_id, source_topic, source_seq), + UNIQUE INDEX uk_im_favorites_idempotency(user_id, idempotency_key), + INDEX idx_im_favorites_inbox(user_id, id DESC), + INDEX idx_im_favorites_type(user_id, item_type, id DESC) )`, } for _, stmt := range statements { @@ -1499,7 +1042,6 @@ func createIMSecurityTables(tx *sql.Tx) error { if _, err := tx.Exec( `CREATE TABLE im_realname_verifications( id BIGINT NOT NULL AUTO_INCREMENT, - tenant_id BIGINT NOT NULL, user_id BIGINT NOT NULL, submission_id VARCHAR(64) NOT NULL, status VARCHAR(16) NOT NULL, @@ -1514,17 +1056,15 @@ func createIMSecurityTables(tx *sql.Tx) error { created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), PRIMARY KEY(id), - UNIQUE INDEX uk_im_realname_submission(tenant_id, submission_id), - INDEX idx_im_realname_user_latest(tenant_id, user_id, created_at), - INDEX idx_im_realname_id_hash(tenant_id, id_number_hash), - CONSTRAINT fk_im_realname_tenant FOREIGN KEY(tenant_id) REFERENCES im_tenant(id) ON DELETE CASCADE + UNIQUE INDEX uk_im_realname_submission(submission_id), + INDEX idx_im_realname_user_latest(user_id, created_at), + INDEX idx_im_realname_id_hash(id_number_hash) )`); err != nil { return err } if _, err := tx.Exec( `CREATE TABLE im_action_tokens( id BIGINT NOT NULL AUTO_INCREMENT, - tenant_id BIGINT NOT NULL, user_id BIGINT NOT NULL, token_id VARCHAR(64) NOT NULL, action VARCHAR(64) NOT NULL, @@ -1536,15 +1076,13 @@ func createIMSecurityTables(tx *sql.Tx) error { created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), PRIMARY KEY(id), - UNIQUE INDEX uk_im_action_tokens_token(tenant_id, token_id), - INDEX idx_im_action_tokens_user_action(tenant_id, user_id, action, target, state, expires_at), - CONSTRAINT fk_im_action_tokens_tenant FOREIGN KEY(tenant_id) REFERENCES im_tenant(id) ON DELETE CASCADE + UNIQUE INDEX uk_im_action_tokens_token(token_id), + INDEX idx_im_action_tokens_user_action(user_id, action, target, state, expires_at) )`); err != nil { return err } _, err := tx.Exec( `CREATE TABLE im_youth_mode_settings( - tenant_id BIGINT NOT NULL, user_id BIGINT NOT NULL, purpose VARCHAR(64) NOT NULL, password_hash VARBINARY(255) NOT NULL, @@ -1552,8 +1090,7 @@ func createIMSecurityTables(tx *sql.Tx) error { locked_until DATETIME(3), created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), - PRIMARY KEY(tenant_id, user_id, purpose), - CONSTRAINT fk_im_youth_settings_tenant FOREIGN KEY(tenant_id) REFERENCES im_tenant(id) ON DELETE CASCADE + PRIMARY KEY(user_id, purpose) )`) return err } @@ -1640,9 +1177,6 @@ func (a *adapter) UserCreate(user *t.User) error { if user == nil { return t.ErrMalformed } - if user.TenantID.IsZero() { - user.TenantID = defaultTenantID - } ctx, cancel := a.getContextForTx() if cancel != nil { defer cancel() @@ -1659,9 +1193,8 @@ func (a *adapter) UserCreate(user *t.User) error { }() decoded_uid := store.DecodeUid(user.Uid()) - if _, err = tx.Exec("INSERT INTO users(id,tenant_id,createdat,updatedat,state,access,public,trusted,tags) VALUES(?,?,?,?,?,?,?,?,?)", + if _, err = tx.Exec("INSERT INTO users(id,createdat,updatedat,state,access,public,trusted,tags) VALUES(?,?,?,?,?,?,?,?)", decoded_uid, - user.TenantID, user.CreatedAt, user.UpdatedAt, user.State, @@ -1672,13 +1205,13 @@ func (a *adapter) UserCreate(user *t.User) error { return err } - if _, err = tx.Exec("INSERT INTO im_user_security(tenant_id,user_id,auth_version,created_at,updated_at) VALUES(?,?,?,?,?)", - user.TenantID, decoded_uid, 1, user.CreatedAt, user.UpdatedAt); err != nil { + if _, err = tx.Exec("INSERT INTO im_user_security(user_id,auth_version,created_at,updated_at) VALUES(?,?,?,?)", + decoded_uid, 1, user.CreatedAt, user.UpdatedAt); err != nil { return err } // Save user's tags to a separate table to make user findable. - if err = addTenantTags(tx, user.TenantID, "usertags", "userid", decoded_uid, user.Tags, false); err != nil { + if err = addTags(tx, "usertags", "userid", decoded_uid, user.Tags, false); err != nil { return err } @@ -1688,9 +1221,13 @@ func (a *adapter) UserCreate(user *t.User) error { // TenantAuthAddRecord adds an authentication record scoped to a tenant. func (a *adapter) TenantAuthAddRecord(tenantID t.TenantID, uid t.Uid, scheme, unique string, authLvl auth.Level, secret []byte, expires time.Time) error { - if tenantID.IsZero() { + tenantAdp, err := a.routedTenantAdapter(tenantID) + if err != nil { return t.ErrMalformed } + if tenantAdp != a { + return tenantAdp.AuthAddRecord(uid, scheme, unique, authLvl, secret, expires) + } var exp *time.Time if !expires.IsZero() { exp = &expires @@ -1699,7 +1236,7 @@ func (a *adapter) TenantAuthAddRecord(tenantID t.TenantID, uid t.Uid, scheme, un if cancel != nil { defer cancel() } - _, err := a.db.ExecContext(ctx, + _, err = a.db.ExecContext(ctx, "INSERT INTO auth(tenant_id,uname,userid,scheme,authlvl,secret,expires) VALUES(?,?,?,?,?,?,?)", tenantID, unique, store.DecodeUid(uid), scheme, authLvl, secret, exp) if isDupe(err) { @@ -1710,9 +1247,13 @@ func (a *adapter) TenantAuthAddRecord(tenantID t.TenantID, uid t.Uid, scheme, un // TenantAuthDelScheme deletes an authentication scheme only in the selected tenant. func (a *adapter) TenantAuthDelScheme(tenantID t.TenantID, uid t.Uid, scheme string) error { - if tenantID.IsZero() { + tenantAdp, err := a.routedTenantAdapter(tenantID) + if err != nil { return t.ErrMalformed } + if tenantAdp != a { + return tenantAdp.AuthDelScheme(uid, scheme) + } ctx, cancel := a.getContext() if cancel != nil { defer cancel() @@ -1725,9 +1266,13 @@ func (a *adapter) TenantAuthDelScheme(tenantID t.TenantID, uid t.Uid, scheme str // TenantAuthUpdRecord updates an authentication record only in the selected tenant. func (a *adapter) TenantAuthUpdRecord(tenantID t.TenantID, uid t.Uid, scheme, unique string, authLvl auth.Level, secret []byte, expires time.Time) error { - if tenantID.IsZero() { + tenantAdp, err := a.routedTenantAdapter(tenantID) + if err != nil { return t.ErrMalformed } + if tenantAdp != a { + return tenantAdp.AuthUpdRecord(uid, scheme, unique, authLvl, secret, expires) + } params := []string{"authlvl=?"} args := []any{authLvl} if unique != "" { @@ -1766,9 +1311,13 @@ func (a *adapter) TenantAuthUpdRecord(tenantID t.TenantID, uid t.Uid, scheme, un func (a *adapter) TenantAuthGetRecord(tenantID t.TenantID, uid t.Uid, scheme string) (string, auth.Level, []byte, time.Time, error) { var expires time.Time - if tenantID.IsZero() { + tenantAdp, err := a.routedTenantAdapter(tenantID) + if err != nil { return "", auth.LevelNone, nil, expires, t.ErrMalformed } + if tenantAdp != a { + return tenantAdp.AuthGetRecord(uid, scheme) + } var record struct { Uname string Authlvl auth.Level @@ -1779,7 +1328,7 @@ func (a *adapter) TenantAuthGetRecord(tenantID t.TenantID, uid t.Uid, scheme str if cancel != nil { defer cancel() } - err := a.db.GetContext(ctx, &record, + err = a.db.GetContext(ctx, &record, "SELECT uname,secret,expires,authlvl FROM auth WHERE tenant_id=? AND userid=? AND scheme=?", tenantID, store.DecodeUid(uid), scheme) if err == sql.ErrNoRows { @@ -1798,9 +1347,13 @@ func (a *adapter) TenantAuthGetRecord(tenantID t.TenantID, uid t.Uid, scheme str func (a *adapter) TenantAuthGetUniqueRecord(tenantID t.TenantID, unique string) (t.Uid, auth.Level, []byte, time.Time, error) { var expires time.Time - if tenantID.IsZero() { + tenantAdp, err := a.routedTenantAdapter(tenantID) + if err != nil { return t.ZeroUid, auth.LevelNone, nil, expires, t.ErrMalformed } + if tenantAdp != a { + return tenantAdp.AuthGetUniqueRecord(unique) + } var record struct { Userid int64 Authlvl auth.Level @@ -1811,7 +1364,7 @@ func (a *adapter) TenantAuthGetUniqueRecord(tenantID t.TenantID, unique string) if cancel != nil { defer cancel() } - err := a.db.GetContext(ctx, &record, + err = a.db.GetContext(ctx, &record, "SELECT userid,secret,expires,authlvl FROM auth WHERE tenant_id=? AND uname=?", tenantID, unique) if err == sql.ErrNoRows { return t.ZeroUid, auth.LevelNone, nil, expires, nil @@ -2343,15 +1896,19 @@ func (a *adapter) UserGetByCred(method, value string) (t.Uid, error) { // TenantUserGetByCred returns a user ID for a validated credential in one tenant. func (a *adapter) TenantUserGetByCred(tenantID t.TenantID, method, value string) (t.Uid, error) { - if tenantID.IsZero() { + tenantAdp, err := a.routedTenantAdapter(tenantID) + if err != nil { return t.ZeroUid, t.ErrMalformed } + if tenantAdp != a { + return tenantAdp.UserGetByCred(method, value) + } ctx, cancel := a.getContext() if cancel != nil { defer cancel() } var decodedUID int64 - err := a.db.GetContext(ctx, &decodedUID, + err = a.db.GetContext(ctx, &decodedUID, "SELECT userid FROM credentials WHERE tenant_id=? AND synthetic=?", tenantID, method+":"+value) if err == sql.ErrNoRows { return t.ZeroUid, nil @@ -2481,38 +2038,31 @@ func createSubscription(tx *sqlx.Tx, sub *t.Subscription, undelete bool) error { if sub == nil { return t.ErrMalformed } - tenantID := sub.TenantID - if tenantID.IsZero() { - tenantID = defaultTenantID - } - isOwner := (sub.ModeGiven & sub.ModeWant).IsOwner() jpriv := common.ToJSON(sub.Private) decoded_uid := store.DecodeUid(t.ParseUid(sub.User)) _, err := tx.Exec( - "INSERT INTO subscriptions(tenant_id,createdat,updatedat,deletedat,userid,topic,modeWant,modeGiven,private) "+ - "VALUES(?,?,?,NULL,?,?,?,?,?)", - tenantID, sub.CreatedAt, sub.UpdatedAt, decoded_uid, sub.Topic, sub.ModeWant.String(), sub.ModeGiven.String(), jpriv) + "INSERT INTO subscriptions(createdat,updatedat,deletedat,userid,topic,modeWant,modeGiven,private) "+ + "VALUES(?,?,NULL,?,?,?,?,?)", + sub.CreatedAt, sub.UpdatedAt, decoded_uid, sub.Topic, sub.ModeWant.String(), sub.ModeGiven.String(), jpriv) if err != nil && isDupe(err) { if undelete { _, err = tx.Exec("UPDATE subscriptions SET createdat=?,updatedat=?,deletedat=NULL,modeWant=?,modeGiven=?,"+ - "delid=0,recvseqid=0,readseqid=0 WHERE tenant_id=? AND topic=? AND userid=?", - sub.CreatedAt, sub.UpdatedAt, sub.ModeWant.String(), sub.ModeGiven.String(), tenantID, - sub.Topic, decoded_uid) + "delid=0,recvseqid=0,readseqid=0 WHERE topic=? AND userid=?", + sub.CreatedAt, sub.UpdatedAt, sub.ModeWant.String(), sub.ModeGiven.String(), sub.Topic, decoded_uid) } else { _, err = tx.Exec("UPDATE subscriptions SET createdat=?,updatedat=?,deletedat=NULL,modeWant=?,modeGiven=?,"+ - "delid=0,recvseqid=0,readseqid=0,private=? WHERE tenant_id=? AND topic=? AND userid=?", - sub.CreatedAt, sub.UpdatedAt, sub.ModeWant.String(), sub.ModeGiven.String(), jpriv, - tenantID, sub.Topic, decoded_uid) + "delid=0,recvseqid=0,readseqid=0,private=? WHERE topic=? AND userid=?", + sub.CreatedAt, sub.UpdatedAt, sub.ModeWant.String(), sub.ModeGiven.String(), jpriv, sub.Topic, decoded_uid) } } if err == nil && isOwner { // Update topic owner if the subscription is with owner rights. // Don't increment subscriber count here - it's done in TopicShare in bulk. - _, err = tx.Exec("UPDATE topics SET owner=? WHERE tenant_id=? AND name=?", decoded_uid, tenantID, sub.Topic) + _, err = tx.Exec("UPDATE topics SET owner=? WHERE name=?", decoded_uid, sub.Topic) } return err } @@ -4103,9 +3653,13 @@ func (a *adapter) CredUpsert(cred *t.Credential) (bool, error) { // TenantCredUpsert adds or updates a credential scoped to a tenant. func (a *adapter) TenantCredUpsert(tenantID t.TenantID, cred *t.Credential) (bool, error) { - if tenantID.IsZero() || cred == nil { + tenantAdp, err := a.routedTenantAdapter(tenantID) + if err != nil || cred == nil { return false, t.ErrMalformed } + if tenantAdp != a { + return tenantAdp.CredUpsert(cred) + } ctx, cancel := a.getContextForTx() if cancel != nil { defer cancel() @@ -4290,9 +3844,13 @@ func (a *adapter) CredDel(uid t.Uid, method, value string) error { // TenantCredDel deletes credentials only in the selected tenant. func (a *adapter) TenantCredDel(tenantID t.TenantID, uid t.Uid, method, value string) error { - if tenantID.IsZero() { + tenantAdp, err := a.routedTenantAdapter(tenantID) + if err != nil { return t.ErrMalformed } + if tenantAdp != a { + return tenantAdp.CredDel(uid, method, value) + } ctx, cancel := a.getContextForTx() if cancel != nil { defer cancel() @@ -4333,9 +3891,13 @@ func (a *adapter) CredConfirm(uid t.Uid, method string) error { // TenantCredConfirm confirms a credential only in the selected tenant. func (a *adapter) TenantCredConfirm(tenantID t.TenantID, uid t.Uid, method string) error { - if tenantID.IsZero() { + tenantAdp, err := a.routedTenantAdapter(tenantID) + if err != nil { return t.ErrMalformed } + if tenantAdp != a { + return tenantAdp.CredConfirm(uid, method) + } ctx, cancel := a.getContext() if cancel != nil { defer cancel() @@ -4369,9 +3931,13 @@ func (a *adapter) CredFail(uid t.Uid, method string) error { // TenantCredFail increments credential failures only in the selected tenant. func (a *adapter) TenantCredFail(tenantID t.TenantID, uid t.Uid, method string) error { - if tenantID.IsZero() { + tenantAdp, err := a.routedTenantAdapter(tenantID) + if err != nil { return t.ErrMalformed } + if tenantAdp != a { + return tenantAdp.CredFail(uid, method) + } ctx, cancel := a.getContext() if cancel != nil { defer cancel() @@ -4405,9 +3971,13 @@ func (a *adapter) CredGetActive(uid t.Uid, method string) (*t.Credential, error) // TenantCredGetActive returns the active credential in the selected tenant. func (a *adapter) TenantCredGetActive(tenantID t.TenantID, uid t.Uid, method string) (*t.Credential, error) { - if tenantID.IsZero() { + tenantAdp, err := a.routedTenantAdapter(tenantID) + if err != nil { return nil, t.ErrMalformed } + if tenantAdp != a { + return tenantAdp.CredGetActive(uid, method) + } ctx, cancel := a.getContext() if cancel != nil { defer cancel() @@ -4459,9 +4029,13 @@ func (a *adapter) CredGetAll(uid t.Uid, method string, validatedOnly bool) ([]t. // TenantCredGetAll returns credentials only from the selected tenant. func (a *adapter) TenantCredGetAll(tenantID t.TenantID, uid t.Uid, method string, validatedOnly bool) ([]t.Credential, error) { - if tenantID.IsZero() { + tenantAdp, err := a.routedTenantAdapter(tenantID) + if err != nil { return nil, t.ErrMalformed } + if tenantAdp != a { + return tenantAdp.CredGetAll(uid, method, validatedOnly) + } query := "SELECT createdat,updatedat,method,value,resp,done,retries FROM credentials " + "WHERE tenant_id=? AND userid=? AND deletedat IS NULL" args := []any{tenantID, store.DecodeUid(uid)} diff --git a/server/db/mysql/schema.sql b/server/db/mysql/schema.sql index 068dbe6..16cd8c2 100644 --- a/server/db/mysql/schema.sql +++ b/server/db/mysql/schema.sql @@ -1,512 +1,21 @@ -# THIS SCHEMA FILE IS FOR REFERENCE/DOCUMENTATION ONLY! +# THIS SCHEMA FILE IS FOR REFERENCE/DOCUMENTATION ONLY. # DO NOT USE IT TO INITIALIZE THE DATABASE. -# Read installation instructions first. - -# The following line will produce an intentional error. 'READ INSTALLATION INSTRUCTIONS!'; -# The actual schema is below. - -DROP DATABASE IF EXISTS tinode; - -CREATE DATABASE tinode CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci; - -USE tinode; - -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 INDEX uk_im_tenant_code(code) -); - -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 INDEX 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 -); - -CREATE TABLE im_verification_codes( - id BIGINT NOT NULL AUTO_INCREMENT, - tenant_id BIGINT NOT NULL, - challenge_id VARCHAR(64) NOT NULL, - scene VARCHAR(32) NOT NULL, - action VARCHAR(64) NOT NULL DEFAULT '', - phone_hash CHAR(64) NOT NULL, - phone_mask VARCHAR(32) NOT NULL, - target_user BIGINT, - code_hash CHAR(64) NOT NULL, - verification_token_hash CHAR(64), - state SMALLINT NOT NULL, - attempts INT NOT NULL DEFAULT 0, - max_attempts INT NOT NULL, - expires_at DATETIME(3) NOT NULL, - resend_after DATETIME(3) NOT NULL, - verified_at DATETIME(3), - consumed_at DATETIME(3), - created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), - updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), - - PRIMARY KEY(id), - UNIQUE INDEX uk_im_verification_tenant_challenge(tenant_id, challenge_id), - INDEX idx_im_verification_tenant_scene_phone_state(tenant_id, scene, phone_hash, state, expires_at), - CONSTRAINT fk_im_verification_tenant FOREIGN KEY(tenant_id) REFERENCES im_tenant(id) ON DELETE CASCADE -); - -INSERT INTO im_tenant(code,name,tenant_desc,db_name,db_dsn,state,created_by,updated_by) -VALUES('default','Default Tenant',NULL,'tinode','',1,0,0); - - -CREATE TABLE kvmeta( - `key` VARCHAR(64), - createdat DATETIME(3), - `value` TEXT, - PRIMARY KEY(`key`), - INDEX kvmeta_createdat_key(createdat, `key`) -); - -INSERT INTO kvmeta(`key`, `value`) VALUES("version", "123"); - -CREATE TABLE users( - id BIGINT NOT NULL, - tenant_id BIGINT NOT NULL DEFAULT 1, - createdat DATETIME(3) NOT NULL, - updatedat DATETIME(3) NOT NULL, - state SMALLINT NOT NULL DEFAULT 0, - stateat DATETIME(3), - access JSON, - lastseen DATETIME, - useragent VARCHAR(255) DEFAULT '', - public JSON, - trusted JSON, - tags JSON, -- Denormalized array of tags - - PRIMARY KEY(id), - INDEX users_tenant_id_id(tenant_id, id), - INDEX users_state_stateat(state, stateat), - INDEX users_lastseen_updatedat(lastseen, updatedat) -); - -CREATE TABLE im_user_security( - tenant_id BIGINT NOT NULL, - user_id BIGINT NOT NULL, - auth_version INT NOT NULL DEFAULT 1, - password_updated_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(tenant_id,user_id), - CONSTRAINT fk_im_user_security_tenant FOREIGN KEY(tenant_id) REFERENCES im_tenant(id) ON DELETE CASCADE -); - -CREATE TABLE im_realname_verifications( - id BIGINT NOT NULL AUTO_INCREMENT, - tenant_id BIGINT NOT NULL, - user_id BIGINT NOT NULL, - submission_id VARCHAR(64) NOT NULL, - status VARCHAR(16) NOT NULL, - name_cipher TEXT NOT NULL, - id_type VARCHAR(32) NOT NULL, - id_number_cipher TEXT NOT NULL, - id_number_hash CHAR(64) NOT NULL, - masked_name VARCHAR(64) NOT NULL, - masked_id_number VARCHAR(64) NOT NULL, - review_reason VARCHAR(256), - verified_at DATETIME(3), - created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), - updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), - - PRIMARY KEY(id), - UNIQUE INDEX uk_im_realname_submission(tenant_id, submission_id), - INDEX idx_im_realname_user_latest(tenant_id, user_id, created_at), - INDEX idx_im_realname_id_hash(tenant_id, id_number_hash), - CONSTRAINT fk_im_realname_tenant FOREIGN KEY(tenant_id) REFERENCES im_tenant(id) ON DELETE CASCADE -); - -CREATE TABLE im_action_tokens( - id BIGINT NOT NULL AUTO_INCREMENT, - tenant_id BIGINT NOT NULL, - user_id BIGINT NOT NULL, - token_id VARCHAR(64) NOT NULL, - action VARCHAR(64) NOT NULL, - target VARCHAR(128) NOT NULL DEFAULT '', - token_hash CHAR(64) NOT NULL, - state SMALLINT NOT NULL, - expires_at DATETIME(3) NOT NULL, - consumed_at DATETIME(3), - created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), - updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), - - PRIMARY KEY(id), - UNIQUE INDEX uk_im_action_tokens_token(tenant_id, token_id), - INDEX idx_im_action_tokens_user_action(tenant_id, user_id, action, target, state, expires_at), - CONSTRAINT fk_im_action_tokens_tenant FOREIGN KEY(tenant_id) REFERENCES im_tenant(id) ON DELETE CASCADE -); - -CREATE TABLE im_youth_mode_settings( - tenant_id BIGINT NOT NULL, - user_id BIGINT NOT NULL, - purpose VARCHAR(64) NOT NULL, - password_hash VARBINARY(255) NOT NULL, - failed_attempts INT NOT NULL DEFAULT 0, - locked_until DATETIME(3), - created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), - updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), - - PRIMARY KEY(tenant_id, user_id, purpose), - CONSTRAINT fk_im_youth_settings_tenant FOREIGN KEY(tenant_id) REFERENCES im_tenant(id) ON DELETE CASCADE -); - -CREATE TABLE im_system_notifications( - id BIGINT NOT NULL AUTO_INCREMENT, - tenant_id BIGINT NOT NULL, - user_id BIGINT NOT NULL, - event_id VARCHAR(64) NOT NULL, - event_type VARCHAR(64) NOT NULL, - title VARCHAR(128) NOT NULL, - summary VARCHAR(512) NOT NULL, - action JSON, - read_at DATETIME(3), - created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), - updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), - - PRIMARY KEY(id), - UNIQUE INDEX uk_im_system_notifications_event(tenant_id, user_id, event_id), - INDEX idx_im_system_notifications_inbox(tenant_id, user_id, id DESC), - CONSTRAINT fk_im_system_notifications_tenant FOREIGN KEY(tenant_id) REFERENCES im_tenant(id) ON DELETE CASCADE -); - -CREATE TABLE im_friend_requests( - id BIGINT NOT NULL AUTO_INCREMENT, - tenant_id BIGINT NOT NULL, - request_id VARCHAR(64) NOT NULL, - applicant_id BIGINT NOT NULL, - target_id BIGINT NOT NULL, - message VARCHAR(200) NOT NULL DEFAULT '', - source VARCHAR(32) NOT NULL DEFAULT '', - source_topic VARCHAR(64) NOT NULL DEFAULT '', - status VARCHAR(16) NOT NULL, - idempotency_key VARCHAR(128) NOT NULL DEFAULT '', - expires_at DATETIME(3) NOT NULL, - handled_at DATETIME(3), - created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), - updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), - - PRIMARY KEY(id), - UNIQUE INDEX uk_im_friend_requests_request(tenant_id, request_id), - UNIQUE INDEX uk_im_friend_requests_idempotency(tenant_id, applicant_id, idempotency_key), - INDEX idx_im_friend_requests_pair(tenant_id, applicant_id, target_id, status, created_at), - INDEX idx_im_friend_requests_inbox(tenant_id, target_id, status, created_at), - CONSTRAINT fk_im_friend_requests_tenant FOREIGN KEY(tenant_id) REFERENCES im_tenant(id) ON DELETE CASCADE -); - -CREATE TABLE im_friendships( - tenant_id BIGINT NOT NULL, - user_id BIGINT NOT NULL, - peer_id BIGINT NOT NULL, - status VARCHAR(16) NOT NULL, - remark VARCHAR(64) NOT NULL DEFAULT '', - topic CHAR(25) NOT NULL DEFAULT '', - created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), - updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), - - PRIMARY KEY(tenant_id, user_id, peer_id), - INDEX idx_im_friendships_peer(tenant_id, peer_id, status), - CONSTRAINT fk_im_friendships_tenant FOREIGN KEY(tenant_id) REFERENCES im_tenant(id) ON DELETE CASCADE -); - -CREATE TABLE im_user_blocks( - tenant_id BIGINT NOT NULL, - blocker_id BIGINT NOT NULL, - blocked_id BIGINT NOT NULL, - created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), - updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), - - PRIMARY KEY(tenant_id, blocker_id, blocked_id), - INDEX idx_im_user_blocks_blocked(tenant_id, blocked_id), - CONSTRAINT fk_im_user_blocks_tenant FOREIGN KEY(tenant_id) REFERENCES im_tenant(id) ON DELETE CASCADE -); - -CREATE TABLE im_favorites( - id BIGINT NOT NULL AUTO_INCREMENT, - tenant_id BIGINT NOT NULL, - favorite_id VARCHAR(64) NOT NULL, - user_id BIGINT NOT NULL, - item_type VARCHAR(16) NOT NULL, - source_topic CHAR(25) NOT NULL, - source_seq INT NOT NULL, - snapshot JSON, - attachment_refs JSON, - availability VARCHAR(16) NOT NULL DEFAULT 'available', - idempotency_key VARCHAR(128) NOT NULL DEFAULT '', - collected_at DATETIME(3) NOT NULL, - created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), - updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), - - PRIMARY KEY(id), - UNIQUE INDEX uk_im_favorites_favorite(tenant_id, favorite_id), - UNIQUE INDEX uk_im_favorites_source(tenant_id, user_id, source_topic, source_seq), - UNIQUE INDEX uk_im_favorites_idempotency(tenant_id, user_id, idempotency_key), - INDEX idx_im_favorites_inbox(tenant_id, user_id, id DESC), - INDEX idx_im_favorites_type(tenant_id, user_id, item_type, id DESC), - CONSTRAINT fk_im_favorites_tenant FOREIGN KEY(tenant_id) REFERENCES im_tenant(id) ON DELETE CASCADE -); - -# Indexed user tags. -CREATE TABLE usertags( - id INT NOT NULL AUTO_INCREMENT, - tenant_id BIGINT NOT NULL DEFAULT 1, - userid BIGINT NOT NULL, - tag VARCHAR(96) NOT NULL, - - PRIMARY KEY(id), - FOREIGN KEY(userid) REFERENCES users(id), - INDEX usertags_tag(tag), - INDEX usertags_tenant_tag(tenant_id, tag), - UNIQUE INDEX usertags_userid_tag(userid, tag) -); - -# Indexed devices. Normalized into a separate table. -CREATE TABLE devices( - id INT NOT NULL AUTO_INCREMENT, - tenant_id BIGINT NOT NULL DEFAULT 1, - userid BIGINT NOT NULL, - hash CHAR(16) NOT NULL, - deviceid TEXT NOT NULL, - platform VARCHAR(32), - lastseen DATETIME NOT NULL, - lang VARCHAR(8), - - PRIMARY KEY(id), - FOREIGN KEY(userid) REFERENCES users(id), - INDEX devices_tenant_userid(tenant_id, userid), - UNIQUE INDEX devices_hash(hash) -); - -# Authentication records for the basic authentication scheme. -CREATE TABLE auth( - id INT NOT NULL AUTO_INCREMENT, - tenant_id BIGINT NOT NULL DEFAULT 1, - uname VARCHAR(32) NOT NULL, - userid BIGINT NOT NULL, - scheme VARCHAR(16) NOT NULL, - authlvl SMALLINT NOT NULL, - secret VARCHAR(255) NOT NULL, - expires DATETIME, - - PRIMARY KEY(id), - FOREIGN KEY(userid) REFERENCES users(id), - UNIQUE INDEX auth_tenant_uname(tenant_id, uname), - UNIQUE INDEX auth_userid_scheme(userid, scheme) -); - - -# Topics -CREATE TABLE topics( - id INT NOT NULL AUTO_INCREMENT, - tenant_id BIGINT NOT NULL DEFAULT 1, - createdat DATETIME(3) NOT NULL, - updatedat DATETIME(3) NOT NULL, - touchedat DATETIME(3), - state SMALLINT NOT NULL DEFAULT 0, - stateat DATETIME(3), - name CHAR(25) NOT NULL, - usebt TINYINT DEFAULT 0, - owner BIGINT NOT NULL DEFAULT 0, - access JSON, - seqid INT NOT NULL DEFAULT 0, - delid INT DEFAULT 0, - subcnt INT DEFAULT 0, - public JSON, - trusted JSON, - tags JSON, -- Denormalized array of tags - aux JSON, - - PRIMARY KEY(id), - UNIQUE INDEX topics_tenant_name(tenant_id, name), - UNIQUE INDEX topics_name (name), - INDEX topics_owner(owner), - INDEX topics_tenant_owner(tenant_id, owner), - INDEX topics_state_stateat(state, stateat), - INDEX topics_name_state_seqid(name, state, seqid) -); - -# Indexed topic tags. -CREATE TABLE topictags( - id INT NOT NULL AUTO_INCREMENT, - tenant_id BIGINT NOT NULL DEFAULT 1, - topic CHAR(25) NOT NULL, - tag VARCHAR(96) NOT NULL, - - PRIMARY KEY(id), - FOREIGN KEY(topic) REFERENCES topics(name), - INDEX topictags_tag (tag), - INDEX topictags_tenant_tag(tenant_id, tag), - UNIQUE INDEX topictags_topic_tag(topic, tag) -); - -# Subscriptions -CREATE TABLE subscriptions( - id INT NOT NULL AUTO_INCREMENT, - tenant_id BIGINT NOT NULL DEFAULT 1, - createdat DATETIME(3) NOT NULL, - updatedat DATETIME(3) NOT NULL, - deletedat DATETIME(3), - userid BIGINT NOT NULL, - topic CHAR(25) NOT NULL, - delid INT DEFAULT 0, - recvseqid INT DEFAULT 0, - readseqid INT DEFAULT 0, - modewant CHAR(8), - modegiven CHAR(8), - private JSON, - - PRIMARY KEY(id) , - FOREIGN KEY(userid) REFERENCES users(id), - INDEX subscriptions_tenant_userid(tenant_id, userid), - INDEX subscriptions_tenant_topic(tenant_id, topic), - UNIQUE INDEX subscriptions_topic_userid(topic, userid), - INDEX subscriptions_topic(topic), - INDEX subscriptions_deletedat(deletedat), - INDEX subscriptions_userid_topic_deletedat(userid, topic, deletedat) -); - -# Messages -CREATE TABLE messages( - id INT NOT NULL AUTO_INCREMENT, - tenant_id BIGINT NOT NULL DEFAULT 1, - createdat DATETIME(3) NOT NULL, - updatedat DATETIME(3) NOT NULL, - deletedat DATETIME(3), - delid INT DEFAULT 0, - seqid INT NOT NULL, - topic CHAR(25) NOT NULL, - `from` BIGINT NOT NULL, - client_msg_id VARCHAR(64), - version INT NOT NULL DEFAULT 1, - recalled_at DATETIME(3), - recalled_by BIGINT, - head JSON, - content JSON, - - PRIMARY KEY(id), - FOREIGN KEY(topic) REFERENCES topics(name), - INDEX messages_tenant_topic(tenant_id, topic), - UNIQUE INDEX messages_sender_client_mid(tenant_id, `from`, client_msg_id), - UNIQUE INDEX messages_topic_seqid (topic, seqid) -); - -CREATE TABLE im_message_revisions( - id BIGINT NOT NULL AUTO_INCREMENT, - tenant_id BIGINT NOT NULL, - createdat DATETIME(3) NOT NULL, - updatedat DATETIME(3) NOT NULL, - topic CHAR(25) NOT NULL, - seqid INT NOT NULL, - version INT NOT NULL, - actor BIGINT NOT NULL, - head JSON, - content JSON, - reason VARCHAR(256) NOT NULL DEFAULT '', - PRIMARY KEY(id), - UNIQUE INDEX uk_im_message_revisions_version(tenant_id, topic, seqid, version), - CONSTRAINT fk_im_message_revisions_tenant FOREIGN KEY(tenant_id) REFERENCES im_tenant(id) ON DELETE CASCADE -); - -# Deletion log -CREATE TABLE dellog( - id INT NOT NULL AUTO_INCREMENT, - tenant_id BIGINT NOT NULL DEFAULT 1, - topic CHAR(25) NOT NULL, - deletedfor BIGINT NOT NULL DEFAULT 0, - delid INT NOT NULL, - low INT NOT NULL, - hi INT NOT NULL, - - PRIMARY KEY(id), - FOREIGN KEY(topic) REFERENCES topics(name), - INDEX dellog_tenant_topic(tenant_id, topic), - # For getting the list of deleted message ranges - INDEX dellog_topic_delid_deletedfor(topic,delid,deletedfor), - # Used when getting not-yet-deleted messages(messages LEFT JOIN dellog) - INDEX dellog_topic_deletedfor_low_hi(topic,deletedfor,low,hi), - # Used when deleting a user - INDEX dellog_deletedfor(deletedfor) -); - -# User credentials -CREATE TABLE credentials( - id INT NOT NULL AUTO_INCREMENT, - tenant_id BIGINT NOT NULL DEFAULT 1, - createdat DATETIME(3) NOT NULL, - updatedat DATETIME(3) NOT NULL, - deletedat DATETIME(3), - method VARCHAR(16) NOT NULL, - value VARCHAR(128) NOT NULL, - synthetic VARCHAR(192) NOT NULL, - userid BIGINT NOT NULL, - resp VARCHAR(255) NOT NULL, - done TINYINT NOT NULL DEFAULT 0, - retries INT NOT NULL DEFAULT 0, - - PRIMARY KEY(id), - FOREIGN KEY(userid) REFERENCES users(id), - UNIQUE credentials_tenant_uniqueness(tenant_id, synthetic) -); - -# Records of uploaded files. Files themselves are stored elsewhere. -CREATE TABLE fileuploads( - id BIGINT NOT NULL, - tenant_id BIGINT NOT NULL DEFAULT 1, - createdat DATETIME(3) NOT NULL, - updatedat DATETIME(3) NOT NULL, - userid BIGINT, - status INT NOT NULL, - mimetype VARCHAR(255) NOT NULL, - size BIGINT NOT NULL, - location VARCHAR(2048) NOT NULL, - etag VARCHAR(128), - - PRIMARY KEY(id), - INDEX fileuploads_tenant_status(tenant_id, status), - INDEX fileuploads_status(status) -); - -# Links between uploaded files and messages or topics. -CREATE TABLE filemsglinks( - id INT NOT NULL AUTO_INCREMENT, - tenant_id BIGINT NOT NULL DEFAULT 1, - createdat DATETIME(3) NOT NULL, - fileid BIGINT NOT NULL, - msgid INT, - topic CHAR(25), - userid BIGINT, - - PRIMARY KEY(id), - INDEX filemsglinks_tenant_fileid(tenant_id, fileid), - FOREIGN KEY(fileid) REFERENCES fileuploads(id) ON DELETE CASCADE, - FOREIGN KEY(msgid) REFERENCES messages(id) ON DELETE CASCADE, - FOREIGN KEY(topic) REFERENCES topics(name) ON DELETE CASCADE, - FOREIGN KEY(userid) REFERENCES users(id) ON DELETE CASCADE -); +# Current MySQL initialization is generated by server/db/mysql/adapter.go: +# +# - Control database: hotgo +# - im_tenant +# - im_tenant_config +# +# - Default tenant business database: tinode +# - users, topics, subscriptions, messages and extension tables +# - 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 +# avoid accidental execution. If standalone SQL initialization scripts are +# needed later, split them explicitly into: +# +# - registry_schema.sql for the hotgo control database. +# - tenant_schema.sql for every tenant business database. diff --git a/server/db/mysql/tenant_adapter.go b/server/db/mysql/tenant_adapter.go index d18f1f8..122fc88 100644 --- a/server/db/mysql/tenant_adapter.go +++ b/server/db/mysql/tenant_adapter.go @@ -21,6 +21,73 @@ import ( func (*adapter) TenantBusinessReady() bool { return true } +func (a *adapter) routedTenantAdapter(id t.TenantID) (*adapter, error) { + if err := validTenant(id); err != nil { + return nil, err + } + if !a.tenantDBRouting { + return a, nil + } + business, _, err := a.TenantAdapterFor(id) + if err != nil { + return nil, err + } + tenantAdp, ok := business.(*adapter) + if !ok { + return nil, t.ErrUnsupported + } + return tenantAdp, nil +} + +func stampTenantUser(id t.TenantID, user *t.User) *t.User { + if user != nil { + user.TenantID = id + } + return user +} + +func stampTenantUsers(id t.TenantID, users []t.User) []t.User { + for i := range users { + users[i].TenantID = id + } + return users +} + +func stampTenantTopic(id t.TenantID, topic *t.Topic) *t.Topic { + if topic != nil { + topic.TenantID = id + } + return topic +} + +func stampTenantSubs(id t.TenantID, subs []t.Subscription) []t.Subscription { + for i := range subs { + subs[i].TenantID = id + } + return subs +} + +func stampTenantMessages(id t.TenantID, msgs []t.Message) []t.Message { + for i := range msgs { + msgs[i].TenantID = id + } + return msgs +} + +func stampTenantMessage(id t.TenantID, msg *t.Message) *t.Message { + if msg != nil { + msg.TenantID = id + } + return msg +} + +func stampTenantFile(id t.TenantID, file *t.FileDef) *t.FileDef { + if file != nil { + file.TenantID = id + } + return file +} + func validTenant(id t.TenantID) error { if !id.IsValid() { return t.ErrMalformed @@ -56,16 +123,22 @@ func scanTenantTopic(topic *t.Topic) { } func (a *adapter) TenantUserCreate(id t.TenantID, user *t.User) error { - if err := validTenant(id); err != nil || user == nil || user.TenantID != id { + tenantAdp, err := a.routedTenantAdapter(id) + if err != nil || user == nil || user.TenantID != id { return t.ErrMalformed } - return a.UserCreate(user) + return tenantAdp.UserCreate(user) } func (a *adapter) TenantUserGet(id t.TenantID, uid t.Uid) (*t.User, error) { - if err := validTenant(id); err != nil || uid.IsZero() { + tenantAdp, err := a.routedTenantAdapter(id) + if err != nil || uid.IsZero() { return nil, t.ErrMalformed } + if tenantAdp != a { + user, err := tenantAdp.UserGet(uid) + return stampTenantUser(id, user), err + } ctx, cancel := a.getContext() if cancel != nil { defer cancel() @@ -84,9 +157,14 @@ func (a *adapter) TenantUserGet(id t.TenantID, uid t.Uid) (*t.User, error) { } func (a *adapter) TenantUserGetAll(id t.TenantID, uids ...t.Uid) ([]t.User, error) { - if err := validTenant(id); err != nil { + tenantAdp, err := a.routedTenantAdapter(id) + if err != nil { return nil, err } + if tenantAdp != a { + users, err := tenantAdp.UserGetAll(uids...) + return stampTenantUsers(id, users), err + } ctx, cancel := a.getContext() if cancel != nil { defer cancel() @@ -134,9 +212,13 @@ func (a *adapter) tenantUserGetAllTx(ctx context.Context, tx *sqlx.Tx, id t.Tena } func (a *adapter) TenantUserDelete(id t.TenantID, uid t.Uid, hard bool) error { - if err := validTenant(id); err != nil || uid.IsZero() { + tenantAdp, err := a.routedTenantAdapter(id) + if err != nil || uid.IsZero() { return t.ErrMalformed } + if tenantAdp != a { + return tenantAdp.UserDelete(uid, hard) + } decoded := decodeUID(uid) ctx, cancel := a.getContextForTx() if cancel != nil { @@ -262,9 +344,13 @@ func (a *adapter) tenantTopicStateForUser(tx *sqlx.Tx, tenantID t.TenantID, deco } func (a *adapter) TenantUserUpdate(id t.TenantID, uid t.Uid, update map[string]any) error { - if err := validTenant(id); err != nil || uid.IsZero() { + tenantAdp, err := a.routedTenantAdapter(id) + if err != nil || uid.IsZero() { return t.ErrMalformed } + if tenantAdp != a { + return tenantAdp.UserUpdate(uid, update) + } ctx, cancel := a.getContextForTx() if cancel != nil { defer cancel() @@ -306,9 +392,13 @@ func (a *adapter) TenantUserUpdate(id t.TenantID, uid t.Uid, update map[string]a } func (a *adapter) TenantUserUpdateTags(id t.TenantID, uid t.Uid, add, remove, reset []string) ([]string, error) { - if err := validTenant(id); err != nil || uid.IsZero() { + tenantAdp, err := a.routedTenantAdapter(id) + if err != nil || uid.IsZero() { return nil, t.ErrMalformed } + if tenantAdp != a { + return tenantAdp.UserUpdateTags(uid, add, remove, reset) + } ctx, cancel := a.getContextForTx() if cancel != nil { defer cancel() @@ -349,9 +439,13 @@ func (a *adapter) TenantUserUpdateTags(id t.TenantID, uid t.Uid, add, remove, re } func (a *adapter) TenantUserUnreadCount(id t.TenantID, uids ...t.Uid) (map[t.Uid]int, error) { - if err := validTenant(id); err != nil { + tenantAdp, err := a.routedTenantAdapter(id) + if err != nil { return nil, err } + if tenantAdp != a { + return tenantAdp.UserUnreadCount(uids...) + } counts := make(map[t.Uid]int, len(uids)) var decoded []any for _, uid := range uids { @@ -388,9 +482,13 @@ func (a *adapter) TenantUserUnreadCount(id t.TenantID, uids ...t.Uid) (map[t.Uid } func (a *adapter) TenantUserGetUnvalidated(id t.TenantID, before time.Time, limit int) ([]t.Uid, error) { - if err := validTenant(id); err != nil { + tenantAdp, err := a.routedTenantAdapter(id) + if err != nil { return nil, err } + if tenantAdp != a { + return tenantAdp.UserGetUnvalidated(before, limit) + } ctx, cancel := a.getContext() if cancel != nil { defer cancel() @@ -432,9 +530,13 @@ func tenantTopicCreate(tx *sqlx.Tx, topic *t.Topic) error { } func (a *adapter) TenantTopicCreate(id t.TenantID, topic *t.Topic) error { - if err := validTenant(id); err != nil || topic == nil || topic.TenantID != id { + tenantAdp, err := a.routedTenantAdapter(id) + if err != nil || topic == nil || topic.TenantID != id { return t.ErrMalformed } + if tenantAdp != a { + return tenantAdp.TopicCreate(topic) + } ctx, cancel := a.getContextForTx() if cancel != nil { defer cancel() @@ -455,9 +557,13 @@ func (a *adapter) TenantTopicCreate(id t.TenantID, topic *t.Topic) error { } func (a *adapter) TenantTopicCreateP2P(id t.TenantID, one, two *t.Subscription) error { - if err := validTenant(id); err != nil || one == nil || two == nil || one.TenantID != id || two.TenantID != id { + tenantAdp, err := a.routedTenantAdapter(id) + if err != nil || one == nil || two == nil || one.TenantID != id || two.TenantID != id { return t.ErrMalformed } + if tenantAdp != a { + return tenantAdp.TopicCreateP2P(one, two) + } ctx, cancel := a.getContextForTx() if cancel != nil { defer cancel() @@ -487,9 +593,14 @@ func (a *adapter) TenantTopicCreateP2P(id t.TenantID, one, two *t.Subscription) } func (a *adapter) TenantTopicGet(id t.TenantID, topic string) (*t.Topic, error) { - if err := validTenant(id); err != nil || topic == "" { + tenantAdp, err := a.routedTenantAdapter(id) + if err != nil || topic == "" { return nil, t.ErrMalformed } + if tenantAdp != a { + top, err := tenantAdp.TopicGet(topic) + return stampTenantTopic(id, top), err + } ctx, cancel := a.getContext() if cancel != nil { defer cancel() @@ -523,9 +634,14 @@ func (a *adapter) TenantTopicGet(id t.TenantID, topic string) (*t.Topic, error) } func (a *adapter) TenantTopicsForUser(id t.TenantID, uid t.Uid, keepDeleted bool, opts *t.QueryOpt) ([]t.Subscription, error) { - if err := validTenant(id); err != nil || uid.IsZero() { + tenantAdp, err := a.routedTenantAdapter(id) + if err != nil || uid.IsZero() { return nil, t.ErrMalformed } + if tenantAdp != a { + subs, err := tenantAdp.TopicsForUser(uid, keepDeleted, opts) + return stampTenantSubs(id, subs), err + } q := `SELECT tenant_id,createdat,updatedat,deletedat,topic,delid,recvseqid, readseqid,modewant,modegiven,private FROM subscriptions WHERE tenant_id=? AND userid=?` args := []any{id, decodeUID(uid)} @@ -681,9 +797,14 @@ func (a *adapter) TenantTopicsForUser(id t.TenantID, uid t.Uid, keepDeleted bool } func (a *adapter) TenantUsersForTopic(id t.TenantID, topic string, keepDeleted bool, opts *t.QueryOpt) ([]t.Subscription, error) { - if err := validTenant(id); err != nil || topic == "" { + tenantAdp, err := a.routedTenantAdapter(id) + if err != nil || topic == "" { return nil, t.ErrMalformed } + if tenantAdp != a { + subs, err := tenantAdp.UsersForTopic(topic, keepDeleted, opts) + return stampTenantSubs(id, subs), err + } tcat := t.GetTopicCat(topic) q := `SELECT s.tenant_id,s.createdat,s.updatedat,s.deletedat,s.userid,s.topic,s.delid,s.recvseqid, s.readseqid,s.modewant,s.modegiven,u.public,u.trusted,u.lastseen,u.useragent,s.private @@ -796,23 +917,32 @@ func (a *adapter) tenantTopicNames(query string, args ...any) ([]string, error) } func (a *adapter) TenantOwnTopics(id t.TenantID, uid t.Uid) ([]string, error) { - if err := validTenant(id); err != nil || uid.IsZero() { + tenantAdp, err := a.routedTenantAdapter(id) + if err != nil || uid.IsZero() { return nil, t.ErrMalformed } + if tenantAdp != a { + return tenantAdp.OwnTopics(uid) + } return a.tenantTopicNames("SELECT name FROM topics WHERE tenant_id=? AND owner=? AND state!=?", id, decodeUID(uid), t.StateDeleted) } func (a *adapter) TenantChannelsForUser(id t.TenantID, uid t.Uid) ([]string, error) { - if err := validTenant(id); err != nil || uid.IsZero() { + tenantAdp, err := a.routedTenantAdapter(id) + if err != nil || uid.IsZero() { return nil, t.ErrMalformed } + if tenantAdp != a { + return tenantAdp.ChannelsForUser(uid) + } return a.tenantTopicNames("SELECT topic FROM subscriptions WHERE tenant_id=? AND userid=? AND topic LIKE 'chn%' "+ "AND INSTR(modewant,'P')>0 AND INSTR(modegiven,'P')>0 AND deletedat IS NULL", id, decodeUID(uid)) } func (a *adapter) TenantTopicShare(id t.TenantID, topic string, subs []*t.Subscription) error { - if err := validTenant(id); err != nil { + tenantAdp, err := a.routedTenantAdapter(id) + if err != nil { return err } for _, sub := range subs { @@ -820,6 +950,9 @@ func (a *adapter) TenantTopicShare(id t.TenantID, topic string, subs []*t.Subscr return t.ErrMalformed } } + if tenantAdp != a { + return tenantAdp.TopicShare(topic, subs) + } ctx, cancel := a.getContextForTx() if cancel != nil { defer cancel() @@ -906,9 +1039,13 @@ func tenantMessageDeleteList(tx *sqlx.Tx, tenantID t.TenantID, topic string, toD } func (a *adapter) TenantTopicDelete(id t.TenantID, topic string, isChan, hard bool) error { - if err := validTenant(id); err != nil || topic == "" { + tenantAdp, err := a.routedTenantAdapter(id) + if err != nil || topic == "" { return t.ErrMalformed } + if tenantAdp != a { + return tenantAdp.TopicDelete(topic, isChan, hard) + } topics := []any{topic} if isChan { topics = append(topics, t.GrpToChn(topic)) @@ -955,9 +1092,13 @@ func (a *adapter) TenantTopicDelete(id t.TenantID, topic string, isChan, hard bo } func (a *adapter) TenantTopicUpdateOnMessage(id t.TenantID, topic string, msg *t.Message) error { - if err := validTenant(id); err != nil || msg == nil || msg.TenantID != id { + tenantAdp, err := a.routedTenantAdapter(id) + if err != nil || msg == nil || msg.TenantID != id { return t.ErrMalformed } + if tenantAdp != a { + return tenantAdp.TopicUpdateOnMessage(topic, msg) + } ctx, cancel := a.getContext() if cancel != nil { defer cancel() @@ -968,9 +1109,13 @@ func (a *adapter) TenantTopicUpdateOnMessage(id t.TenantID, topic string, msg *t } func (a *adapter) TenantTopicUpdateSubCnt(id t.TenantID, topic string) error { - if err := validTenant(id); err != nil || topic == "" { + tenantAdp, err := a.routedTenantAdapter(id) + if err != nil || topic == "" { return t.ErrMalformed } + if tenantAdp != a { + return tenantAdp.TopicUpdateSubCnt(topic) + } ctx, cancel := a.getContext() if cancel != nil { defer cancel() @@ -982,9 +1127,13 @@ func (a *adapter) TenantTopicUpdateSubCnt(id t.TenantID, topic string) error { } func (a *adapter) TenantTopicUpdate(id t.TenantID, topic string, update map[string]any) error { - if err := validTenant(id); err != nil || topic == "" { + tenantAdp, err := a.routedTenantAdapter(id) + if err != nil || topic == "" { return t.ErrMalformed } + if tenantAdp != a { + return tenantAdp.TopicUpdate(topic, update) + } ctx, cancel := a.getContextForTx() if cancel != nil { defer cancel() @@ -1022,9 +1171,13 @@ func (a *adapter) TenantTopicUpdate(id t.TenantID, topic string, update map[stri } func (a *adapter) TenantTopicOwnerChange(id t.TenantID, topic string, owner t.Uid) error { - if err := validTenant(id); err != nil || topic == "" { + tenantAdp, err := a.routedTenantAdapter(id) + if err != nil || topic == "" { return t.ErrMalformed } + if tenantAdp != a { + return tenantAdp.TopicOwnerChange(topic, owner) + } ctx, cancel := a.getContext() if cancel != nil { defer cancel() @@ -1034,9 +1187,17 @@ func (a *adapter) TenantTopicOwnerChange(id t.TenantID, topic string, owner t.Ui } func (a *adapter) TenantSubscriptionGet(id t.TenantID, topic string, uid t.Uid, keepDeleted bool) (*t.Subscription, error) { - if err := validTenant(id); err != nil || topic == "" || uid.IsZero() { + tenantAdp, err := a.routedTenantAdapter(id) + if err != nil || topic == "" || uid.IsZero() { return nil, t.ErrMalformed } + if tenantAdp != a { + sub, err := tenantAdp.SubscriptionGet(topic, uid, keepDeleted) + if sub != nil { + sub.TenantID = id + } + return sub, err + } query := `SELECT tenant_id,createdat,updatedat,deletedat,userid AS user,topic,delid,recvseqid, readseqid,modewant,modegiven,private FROM subscriptions WHERE tenant_id=? AND topic=? AND userid=?` if !keepDeleted { @@ -1060,9 +1221,14 @@ func (a *adapter) TenantSubscriptionGet(id t.TenantID, topic string, uid t.Uid, } func (a *adapter) TenantSubsForUser(id t.TenantID, uid t.Uid) ([]t.Subscription, error) { - if err := validTenant(id); err != nil || uid.IsZero() { + tenantAdp, err := a.routedTenantAdapter(id) + if err != nil || uid.IsZero() { return nil, t.ErrMalformed } + if tenantAdp != a { + subs, err := tenantAdp.SubsForUser(uid) + return stampTenantSubs(id, subs), err + } rows, err := a.db.Queryx(`SELECT tenant_id,createdat,updatedat,deletedat,userid AS user,topic,delid,recvseqid, readseqid,modewant,modegiven FROM subscriptions WHERE tenant_id=? AND userid=? AND deletedat IS NULL`, id, decodeUID(uid)) @@ -1083,9 +1249,14 @@ func (a *adapter) TenantSubsForUser(id t.TenantID, uid t.Uid) ([]t.Subscription, } func (a *adapter) TenantSubsForTopic(id t.TenantID, topic string, keepDeleted bool, opts *t.QueryOpt) ([]t.Subscription, error) { - if err := validTenant(id); err != nil || topic == "" { + tenantAdp, err := a.routedTenantAdapter(id) + if err != nil || topic == "" { return nil, t.ErrMalformed } + if tenantAdp != a { + subs, err := tenantAdp.SubsForTopic(topic, keepDeleted, opts) + return stampTenantSubs(id, subs), err + } q := `SELECT tenant_id,createdat,updatedat,deletedat,userid AS user,topic,delid,recvseqid, readseqid,modewant,modegiven,private FROM subscriptions WHERE tenant_id=? AND topic=?` args := []any{id, topic} @@ -1127,9 +1298,13 @@ func (a *adapter) TenantSubsForTopic(id t.TenantID, topic string, keepDeleted bo } func (a *adapter) TenantSubsUpdate(id t.TenantID, topic string, uid t.Uid, update map[string]any) error { - if err := validTenant(id); err != nil || topic == "" { + tenantAdp, err := a.routedTenantAdapter(id) + if err != nil || topic == "" { return t.ErrMalformed } + if tenantAdp != a { + return tenantAdp.SubsUpdate(topic, uid, update) + } cols, args := common.UpdateByMap(update) q := "UPDATE subscriptions SET " + strings.Join(cols, ",") + " WHERE tenant_id=? AND topic=?" args = append(args, id, topic) @@ -1146,9 +1321,13 @@ func (a *adapter) TenantSubsUpdate(id t.TenantID, topic string, uid t.Uid, updat } func (a *adapter) TenantSubsDelete(id t.TenantID, topic string, uid t.Uid) error { - if err := validTenant(id); err != nil || topic == "" || uid.IsZero() { + tenantAdp, err := a.routedTenantAdapter(id) + if err != nil || topic == "" || uid.IsZero() { return t.ErrMalformed } + if tenantAdp != a { + return tenantAdp.SubsDelete(topic, uid) + } ctx, cancel := a.getContextForTx() if cancel != nil { defer cancel() @@ -1224,9 +1403,14 @@ func tenantSubsDelForUser(tx *sqlx.Tx, tenantID t.TenantID, decodedUID int64, ha } func (a *adapter) TenantFind(id t.TenantID, caller, promoPrefix string, req [][]string, opt []string, activeOnly bool) ([]t.Subscription, error) { - if err := validTenant(id); err != nil { + tenantAdp, err := a.routedTenantAdapter(id) + if err != nil { return nil, err } + if tenantAdp != a { + subs, err := tenantAdp.Find(caller, promoPrefix, req, opt, activeOnly) + return stampTenantSubs(id, subs), err + } allReq := t.FlattenDoubleSlice(req) allTags := append(append([]string{}, allReq...), opt...) if len(allTags) == 0 { @@ -1322,9 +1506,13 @@ func (a *adapter) TenantFind(id t.TenantID, caller, promoPrefix string, req [][] } func (a *adapter) TenantFindOne(id t.TenantID, tag string) (string, error) { - if err := validTenant(id); err != nil || tag == "" { + tenantAdp, err := a.routedTenantAdapter(id) + if err != nil || tag == "" { return "", t.ErrMalformed } + if tenantAdp != a { + return tenantAdp.FindOne(tag) + } ctx, cancel := a.getContext() if cancel != nil { defer cancel() @@ -1351,9 +1539,13 @@ func (a *adapter) TenantFindOne(id t.TenantID, tag string) (string, error) { } func (a *adapter) TenantMessageSave(id t.TenantID, msg *t.Message) error { - if err := validTenant(id); err != nil || msg == nil || msg.TenantID != id { + tenantAdp, err := a.routedTenantAdapter(id) + if err != nil || msg == nil || msg.TenantID != id { return t.ErrMalformed } + if tenantAdp != a { + return tenantAdp.MessageSave(msg) + } ctx, cancel := a.getContext() if cancel != nil { defer cancel() @@ -1370,6 +1562,15 @@ func (a *adapter) TenantMessageSave(id t.TenantID, msg *t.Message) error { } func (a *adapter) TenantMessageGetByClientID(id t.TenantID, from t.Uid, clientMsgID string) (*t.Message, error) { + if !a.tenantScopedDB { + tenantAdp, err := a.routedTenantAdapter(id) + if err != nil { + return nil, err + } + if tenantAdp != a { + return tenantAdp.TenantMessageGetByClientID(id, from, clientMsgID) + } + } if err := validTenant(id); err != nil || from.IsZero() || clientMsgID == "" { return nil, t.ErrMalformed } @@ -1379,9 +1580,9 @@ func (a *adapter) TenantMessageGetByClientID(id t.TenantID, from t.Uid, clientMs } var msg t.Message err := a.db.QueryRowxContext(ctx, - "SELECT id,tenant_id,createdat,updatedat,deletedat,delid,seqid,topic,`from`,client_msg_id,version,recalled_at,recalled_by,head,content "+ - "FROM messages WHERE tenant_id=? AND `from`=? AND client_msg_id=?", - id, decodeUID(from), clientMsgID).StructScan(&msg) + "SELECT id,createdat,updatedat,deletedat,delid,seqid,topic,`from`,client_msg_id,version,recalled_at,recalled_by,head,content "+ + "FROM messages WHERE `from`=? AND client_msg_id=?", + decodeUID(from), clientMsgID).StructScan(&msg) if err == sql.ErrNoRows { return nil, nil } @@ -1390,10 +1591,20 @@ func (a *adapter) TenantMessageGetByClientID(id t.TenantID, from t.Uid, clientMs } msg.From = common.EncodeUidString(msg.From).String() msg.Content = common.FromJSON(msg.Content) + msg.TenantID = id return &msg, nil } func (a *adapter) TenantMessageGetOne(id t.TenantID, topic string, seq int) (*t.Message, error) { + if !a.tenantScopedDB { + tenantAdp, err := a.routedTenantAdapter(id) + if err != nil { + return nil, err + } + if tenantAdp != a { + return tenantAdp.TenantMessageGetOne(id, topic, seq) + } + } if err := validTenant(id); err != nil || topic == "" || seq <= 0 { return nil, t.ErrMalformed } @@ -1409,6 +1620,15 @@ func (a *adapter) TenantMessageGetOne(id t.TenantID, topic string, seq int) (*t. } func (a *adapter) TenantMessageRecall(id t.TenantID, topic string, seq int, actor t.Uid, expectedVersion int, recalledAt time.Time) (*t.Message, error) { + if !a.tenantScopedDB { + tenantAdp, err := a.routedTenantAdapter(id) + if err != nil { + return nil, err + } + if tenantAdp != a { + return tenantAdp.TenantMessageRecall(id, topic, seq, actor, expectedVersion, recalledAt) + } + } if err := validTenant(id); err != nil || topic == "" || seq <= 0 || actor.IsZero() || expectedVersion <= 0 { return nil, t.ErrMalformed } @@ -1436,8 +1656,8 @@ func (a *adapter) TenantMessageRecall(id t.TenantID, topic string, seq int, acto return nil, t.ErrDuplicate } _, err = tx.ExecContext(ctx, - "UPDATE messages SET updatedat=?,version=version+1,recalled_at=?,recalled_by=?,head=NULL,content=NULL WHERE tenant_id=? AND topic=? AND seqid=? AND version=?", - recalledAt, recalledAt, decodeUID(actor), id, topic, seq, expectedVersion) + "UPDATE messages SET updatedat=?,version=version+1,recalled_at=?,recalled_by=?,head=NULL,content=NULL WHERE topic=? AND seqid=? AND version=?", + recalledAt, recalledAt, decodeUID(actor), topic, seq, expectedVersion) if err != nil { return nil, err } @@ -1448,6 +1668,15 @@ func (a *adapter) TenantMessageRecall(id t.TenantID, topic string, seq int, acto } func (a *adapter) TenantMessageEdit(id t.TenantID, topic string, seq int, actor t.Uid, expectedVersion int, head t.KVMap, content any, reason string, editedAt time.Time) (*t.Message, error) { + if !a.tenantScopedDB { + tenantAdp, err := a.routedTenantAdapter(id) + if err != nil { + return nil, err + } + if tenantAdp != a { + return tenantAdp.TenantMessageEdit(id, topic, seq, actor, expectedVersion, head, content, reason, editedAt) + } + } if err := validTenant(id); err != nil || topic == "" || seq <= 0 || actor.IsZero() || expectedVersion <= 0 { return nil, t.ErrMalformed } @@ -1475,13 +1704,13 @@ func (a *adapter) TenantMessageEdit(id t.TenantID, topic string, seq int, actor return nil, t.ErrDuplicate } if _, err = tx.ExecContext(ctx, - "INSERT INTO im_message_revisions(tenant_id,createdat,updatedat,topic,seqid,version,actor,head,content,reason) VALUES(?,?,?,?,?,?,?,?,?,?)", - id, editedAt, editedAt, topic, seq, msg.Version, decodeUID(actor), msg.Head, common.ToJSON(msg.Content), strings.TrimSpace(reason)); err != nil { + "INSERT INTO im_message_revisions(createdat,updatedat,topic,seqid,version,actor,head,content,reason) VALUES(?,?,?,?,?,?,?,?,?)", + editedAt, editedAt, topic, seq, msg.Version, decodeUID(actor), msg.Head, common.ToJSON(msg.Content), strings.TrimSpace(reason)); err != nil { return nil, err } _, err = tx.ExecContext(ctx, - "UPDATE messages SET updatedat=?,version=version+1,head=?,content=? WHERE tenant_id=? AND topic=? AND seqid=? AND version=?", - editedAt, head, common.ToJSON(content), id, topic, seq, expectedVersion) + "UPDATE messages SET updatedat=?,version=version+1,head=?,content=? WHERE topic=? AND seqid=? AND version=?", + editedAt, head, common.ToJSON(content), topic, seq, expectedVersion) if err != nil { return nil, err } @@ -1492,9 +1721,14 @@ func (a *adapter) TenantMessageEdit(id t.TenantID, topic string, seq int, actor } func (a *adapter) TenantMessageGetAll(id t.TenantID, topic string, uid t.Uid, opts *t.QueryOpt) ([]t.Message, error) { - if err := validTenant(id); err != nil || topic == "" { + tenantAdp, err := a.routedTenantAdapter(id) + if err != nil || topic == "" { return nil, t.ErrMalformed } + if tenantAdp != a { + msgs, err := tenantAdp.MessageGetAll(topic, uid, opts) + return stampTenantMessages(id, msgs), err + } limit := a.maxMessageResults args := []any{id, topic, id, decodeUID(uid)} seq := "" @@ -1550,14 +1784,23 @@ func (a *adapter) TenantMessageGetAll(id t.TenantID, topic string, uid t.Uid, op } func (a *adapter) TenantMessageSearch(id t.TenantID, topic string, uid t.Uid, queryText, messageType, cursor string, limit int) ([]t.Message, string, error) { + if !a.tenantScopedDB { + tenantAdp, err := a.routedTenantAdapter(id) + if err != nil { + return nil, "", err + } + if tenantAdp != a { + return tenantAdp.TenantMessageSearch(id, topic, uid, queryText, messageType, cursor, limit) + } + } if err := validTenant(id); err != nil || topic == "" || uid.IsZero() { return nil, "", t.ErrMalformed } if limit <= 0 || limit > 100 { limit = 50 } - where := []string{"m.tenant_id=?", "m.topic=?", "m.delid=0"} - args := []any{id, topic} + where := []string{"m.topic=?", "m.delid=0"} + args := []any{topic} if cursor != "" { parsed, err := strconv.Atoi(cursor) if err != nil || parsed <= 0 { @@ -1582,8 +1825,8 @@ func (a *adapter) TenantMessageSearch(id t.TenantID, topic string, uid t.Uid, qu queryArgs := append([]any{decodeUID(uid)}, args...) queryArgs = append(queryArgs, limit+1) rows, err := a.db.QueryxContext(ctx, - "SELECT m.tenant_id,m.createdat,m.updatedat,m.deletedat,m.delid,m.seqid,m.topic,m.`from`,m.client_msg_id,m.version,m.recalled_at,m.recalled_by,m.head,m.content "+ - "FROM messages AS m LEFT JOIN dellog AS d ON d.tenant_id=m.tenant_id AND d.topic=m.topic "+ + "SELECT m.createdat,m.updatedat,m.deletedat,m.delid,m.seqid,m.topic,m.`from`,m.client_msg_id,m.version,m.recalled_at,m.recalled_by,m.head,m.content "+ + "FROM messages AS m LEFT JOIN dellog AS d ON d.topic=m.topic "+ "AND m.seqid BETWEEN d.low AND d.hi-1 AND d.deletedfor=? WHERE "+strings.Join(where, " AND ")+" AND d.deletedfor IS NULL ORDER BY m.seqid DESC LIMIT ?", queryArgs...) if err != nil { @@ -1600,6 +1843,7 @@ func (a *adapter) TenantMessageSearch(id t.TenantID, topic string, uid t.Uid, qu msg.From = common.EncodeUidString(msg.From).String() msg.Head = kvMapFromJSON(msg.Head) msg.Content = common.FromJSON(msg.Content) + msg.TenantID = id if len(msgs) == limit { nextCursor = strconv.Itoa(msg.SeqId) continue @@ -1618,13 +1862,14 @@ func tenantMessageGetOne(q sqlx.QueryerContext, ctx context.Context, tenantID t. RecalledBy sql.NullInt64 `db:"recalled_by"` }{} err := sqlx.GetContext(ctx, q, &row, - "SELECT id,tenant_id,createdat,updatedat,deletedat,delid,seqid,topic,`from`,client_msg_id,version,recalled_at,recalled_by,head,content "+ - "FROM messages WHERE tenant_id=? AND topic=? AND seqid=?"+suffix, - tenantID, topic, seq) + "SELECT id,createdat,updatedat,deletedat,delid,seqid,topic,`from`,client_msg_id,version,recalled_at,recalled_by,head,content "+ + "FROM messages WHERE topic=? AND seqid=?"+suffix, + topic, seq) if err != nil { return nil, err } msg := row.Message + msg.TenantID = tenantID msg.From = common.EncodeUidString(msg.From).String() if row.RecalledBy.Valid { msg.RecalledBy = common.EncodeUidString(strconv.FormatInt(row.RecalledBy.Int64, 10)).String() @@ -1652,6 +1897,15 @@ func kvMapFromJSON(value any) t.KVMap { } func (a *adapter) TenantFavoriteCreate(id t.TenantID, fav *t.FavoriteItem) (*t.FavoriteItem, error) { + if !a.tenantScopedDB { + tenantAdp, err := a.routedTenantAdapter(id) + if err != nil { + return nil, err + } + if tenantAdp != a { + return tenantAdp.TenantFavoriteCreate(id, fav) + } + } if err := validTenant(id); err != nil || fav == nil || fav.TenantID != id || fav.UserID.IsZero() || fav.FavoriteID == "" { return nil, t.ErrMalformed } @@ -1660,10 +1914,10 @@ func (a *adapter) TenantFavoriteCreate(id t.TenantID, fav *t.FavoriteItem) (*t.F defer cancel() } _, err := a.db.ExecContext(ctx, - `INSERT INTO im_favorites(tenant_id,favorite_id,user_id,item_type,source_topic,source_seq,snapshot,attachment_refs,availability,idempotency_key,collected_at,created_at,updated_at) - VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?) + `INSERT INTO im_favorites(favorite_id,user_id,item_type,source_topic,source_seq,snapshot,attachment_refs,availability,idempotency_key,collected_at,created_at,updated_at) + VALUES(?,?,?,?,?,?,?,?,?,?,?,?) ON DUPLICATE KEY UPDATE updated_at=updated_at`, - id, fav.FavoriteID, decodeUID(fav.UserID), fav.ItemType, fav.SourceTopic, fav.SourceSeq, common.ToJSON(fav.Snapshot), common.ToJSON(fav.AttachmentRefs), fav.Availability, fav.IdempotencyKey, fav.CollectedAt, fav.CreatedAt, fav.UpdatedAt) + fav.FavoriteID, decodeUID(fav.UserID), fav.ItemType, fav.SourceTopic, fav.SourceSeq, common.ToJSON(fav.Snapshot), common.ToJSON(fav.AttachmentRefs), fav.Availability, fav.IdempotencyKey, fav.CollectedAt, fav.CreatedAt, fav.UpdatedAt) if err != nil { return nil, err } @@ -1671,14 +1925,23 @@ func (a *adapter) TenantFavoriteCreate(id t.TenantID, fav *t.FavoriteItem) (*t.F } func (a *adapter) TenantFavoriteList(id t.TenantID, uid t.Uid, itemType, queryText, cursor string, limit int) ([]t.FavoriteItem, string, error) { + if !a.tenantScopedDB { + tenantAdp, err := a.routedTenantAdapter(id) + if err != nil { + return nil, "", err + } + if tenantAdp != a { + return tenantAdp.TenantFavoriteList(id, uid, itemType, queryText, cursor, limit) + } + } if err := validTenant(id); err != nil || uid.IsZero() { return nil, "", t.ErrMalformed } if limit <= 0 || limit > 100 { limit = 50 } - where := []string{"tenant_id=?", "user_id=?"} - args := []any{id, decodeUID(uid)} + where := []string{"user_id=?"} + args := []any{decodeUID(uid)} if cursor != "" { parsed, err := strconv.ParseInt(cursor, 10, 64) if err != nil || parsed <= 0 { @@ -1712,6 +1975,7 @@ func (a *adapter) TenantFavoriteList(id t.TenantID, uid t.Uid, itemType, queryTe if err != nil { return nil, "", err } + item.TenantID = id if len(items) == limit { nextCursor = strconv.FormatInt(item.ID, 10) continue @@ -1725,6 +1989,15 @@ func (a *adapter) TenantFavoriteList(id t.TenantID, uid t.Uid, itemType, queryTe } func (a *adapter) TenantFavoriteGet(id t.TenantID, uid t.Uid, favoriteID string) (*t.FavoriteItem, error) { + if !a.tenantScopedDB { + tenantAdp, err := a.routedTenantAdapter(id) + if err != nil { + return nil, err + } + if tenantAdp != a { + return tenantAdp.TenantFavoriteGet(id, uid, favoriteID) + } + } if err := validTenant(id); err != nil || uid.IsZero() || favoriteID == "" { return nil, t.ErrMalformed } @@ -1732,7 +2005,7 @@ func (a *adapter) TenantFavoriteGet(id t.TenantID, uid t.Uid, favoriteID string) if cancel != nil { defer cancel() } - rows, err := a.db.QueryxContext(ctx, "SELECT * FROM im_favorites WHERE tenant_id=? AND user_id=? AND favorite_id=?", id, decodeUID(uid), favoriteID) + rows, err := a.db.QueryxContext(ctx, "SELECT * FROM im_favorites WHERE user_id=? AND favorite_id=?", decodeUID(uid), favoriteID) if err != nil { return nil, err } @@ -1744,10 +2017,20 @@ func (a *adapter) TenantFavoriteGet(id t.TenantID, uid t.Uid, favoriteID string) if err != nil { return nil, err } + item.TenantID = id return &item, nil } func (a *adapter) TenantFavoriteDelete(id t.TenantID, uid t.Uid, favoriteID string) error { + if !a.tenantScopedDB { + tenantAdp, err := a.routedTenantAdapter(id) + if err != nil { + return err + } + if tenantAdp != a { + return tenantAdp.TenantFavoriteDelete(id, uid, favoriteID) + } + } if err := validTenant(id); err != nil || uid.IsZero() || favoriteID == "" { return t.ErrMalformed } @@ -1755,7 +2038,7 @@ func (a *adapter) TenantFavoriteDelete(id t.TenantID, uid t.Uid, favoriteID stri if cancel != nil { defer cancel() } - res, err := a.db.ExecContext(ctx, "DELETE FROM im_favorites WHERE tenant_id=? AND user_id=? AND favorite_id=?", id, decodeUID(uid), favoriteID) + res, err := a.db.ExecContext(ctx, "DELETE FROM im_favorites WHERE user_id=? AND favorite_id=?", decodeUID(uid), favoriteID) if err != nil { return err } @@ -1781,9 +2064,13 @@ func scanFavorite(rows *sqlx.Rows) (t.FavoriteItem, error) { } func (a *adapter) TenantMessageDeleteList(id t.TenantID, topic string, del *t.DelMessage) error { - if err := validTenant(id); err != nil || topic == "" || (del != nil && del.TenantID != id) { + tenantAdp, err := a.routedTenantAdapter(id) + if err != nil || topic == "" || (del != nil && del.TenantID != id) { return t.ErrMalformed } + if tenantAdp != a { + return tenantAdp.MessageDeleteList(topic, del) + } ctx, cancel := a.getContextForTx() if cancel != nil { defer cancel() @@ -1804,9 +2091,17 @@ func (a *adapter) TenantMessageDeleteList(id t.TenantID, topic string, del *t.De } func (a *adapter) TenantMessageGetDeleted(id t.TenantID, topic string, uid t.Uid, opts *t.QueryOpt) ([]t.DelMessage, error) { - if err := validTenant(id); err != nil || topic == "" { + tenantAdp, err := a.routedTenantAdapter(id) + if err != nil || topic == "" { return nil, t.ErrMalformed } + if tenantAdp != a { + deleted, err := tenantAdp.MessageGetDeleted(topic, uid, opts) + for i := range deleted { + deleted[i].TenantID = id + } + return deleted, err + } limit, lower, upper := a.maxResults, 0, 1<<31-1 if opts != nil { if opts.Since > 0 { @@ -1868,9 +2163,16 @@ func (a *adapter) TenantMessageGetDeleted(id t.TenantID, topic string, uid t.Uid } func (a *adapter) TenantDeviceUpsert(id t.TenantID, uid t.Uid, dev *t.DeviceDef) error { - if err := validTenant(id); err != nil || dev == nil || dev.TenantID != id || uid.IsZero() { + tenantAdp, err := a.routedTenantAdapter(id) + if err != nil || dev == nil || dev.TenantID != id || uid.IsZero() { return t.ErrMalformed } + if tenantAdp != a { + return tenantAdp.DeviceUpsert(uid, dev) + } + if a.tenantScopedDB { + return a.DeviceUpsert(uid, dev) + } hash := deviceHasher(dev.DeviceId) ctx, cancel := a.getContextForTx() if cancel != nil { @@ -1896,9 +2198,28 @@ func (a *adapter) TenantDeviceUpsert(id t.TenantID, uid t.Uid, dev *t.DeviceDef) } func (a *adapter) TenantDeviceGetAll(id t.TenantID, uids ...t.Uid) (map[t.Uid][]t.DeviceDef, int, error) { - if err := validTenant(id); err != nil { + tenantAdp, err := a.routedTenantAdapter(id) + if err != nil { return nil, 0, err } + if tenantAdp != a { + devs, count, err := tenantAdp.DeviceGetAll(uids...) + for uid := range devs { + for i := range devs[uid] { + devs[uid][i].TenantID = id + } + } + return devs, count, err + } + if a.tenantScopedDB { + devs, count, err := a.DeviceGetAll(uids...) + for uid := range devs { + for i := range devs[uid] { + devs[uid][i].TenantID = id + } + } + return devs, count, err + } var decoded []any for _, uid := range uids { if !uid.IsZero() { @@ -1942,9 +2263,16 @@ func (a *adapter) TenantDeviceGetAll(id t.TenantID, uids ...t.Uid) (map[t.Uid][] } func (a *adapter) TenantDeviceDelete(id t.TenantID, uid t.Uid, deviceID string) error { - if err := validTenant(id); err != nil || uid.IsZero() { + tenantAdp, err := a.routedTenantAdapter(id) + if err != nil || uid.IsZero() { return t.ErrMalformed } + if tenantAdp != a { + return tenantAdp.DeviceDelete(uid, deviceID) + } + if a.tenantScopedDB { + return a.DeviceDelete(uid, deviceID) + } q := "DELETE FROM devices WHERE tenant_id=? AND userid=?" args := []any{id, decodeUID(uid)} if deviceID != "" { @@ -1966,9 +2294,16 @@ func (a *adapter) TenantDeviceDelete(id t.TenantID, uid t.Uid, deviceID string) } func (a *adapter) TenantFileStartUpload(id t.TenantID, fd *t.FileDef) error { - if err := validTenant(id); err != nil || fd == nil || fd.TenantID != id { + tenantAdp, err := a.routedTenantAdapter(id) + if err != nil || fd == nil || fd.TenantID != id { return t.ErrMalformed } + if tenantAdp != a { + return tenantAdp.FileStartUpload(fd) + } + if a.tenantScopedDB { + return a.FileStartUpload(fd) + } var user any = 0 if fd.User != "" { user = decodeUID(t.ParseUid(fd.User)) @@ -1984,9 +2319,18 @@ func (a *adapter) TenantFileStartUpload(id t.TenantID, fd *t.FileDef) error { } func (a *adapter) TenantFileFinishUpload(id t.TenantID, fd *t.FileDef, success bool, size int64) (*t.FileDef, error) { - if err := validTenant(id); err != nil || fd == nil || fd.TenantID != id { + tenantAdp, err := a.routedTenantAdapter(id) + if err != nil || fd == nil || fd.TenantID != id { return nil, t.ErrMalformed } + if tenantAdp != a { + file, err := tenantAdp.FileFinishUpload(fd, success, size) + return stampTenantFile(id, file), err + } + if a.tenantScopedDB { + file, err := a.FileFinishUpload(fd, success, size) + return stampTenantFile(id, file), err + } ctx, cancel := a.getContextForTx() if cancel != nil { defer cancel() @@ -2019,9 +2363,18 @@ func (a *adapter) TenantFileFinishUpload(id t.TenantID, fd *t.FileDef, success b } func (a *adapter) TenantFileGet(id t.TenantID, fid string) (*t.FileDef, error) { - if err := validTenant(id); err != nil { + tenantAdp, err := a.routedTenantAdapter(id) + if err != nil { return nil, err } + if tenantAdp != a { + file, err := tenantAdp.FileGet(fid) + return stampTenantFile(id, file), err + } + if a.tenantScopedDB { + file, err := a.FileGet(fid) + return stampTenantFile(id, file), err + } fileID := t.ParseUid(fid) if fileID.IsZero() { return nil, t.ErrMalformed @@ -2046,9 +2399,16 @@ func (a *adapter) TenantFileGet(id t.TenantID, fid string) (*t.FileDef, error) { } func (a *adapter) TenantFileDeleteUnused(id t.TenantID, olderThan time.Time, limit int) ([]string, error) { - if err := validTenant(id); err != nil { + tenantAdp, err := a.routedTenantAdapter(id) + if err != nil { return nil, err } + if tenantAdp != a { + return tenantAdp.FileDeleteUnused(olderThan, limit) + } + if a.tenantScopedDB { + return a.FileDeleteUnused(olderThan, limit) + } ctx, cancel := a.getContextForTx() if cancel != nil { defer cancel() @@ -2105,9 +2465,16 @@ func (a *adapter) TenantFileDeleteUnused(id t.TenantID, olderThan time.Time, lim } func (a *adapter) TenantFileLinkAttachments(id t.TenantID, topic string, userID, msgID t.Uid, fids []string) error { - if err := validTenant(id); err != nil || len(fids) == 0 || (topic == "" && msgID.IsZero() && userID.IsZero()) { + tenantAdp, err := a.routedTenantAdapter(id) + if err != nil || len(fids) == 0 || (topic == "" && msgID.IsZero() && userID.IsZero()) { return t.ErrMalformed } + if tenantAdp != a { + return tenantAdp.FileLinkAttachments(topic, userID, msgID, fids) + } + if a.tenantScopedDB { + return a.FileLinkAttachments(topic, userID, msgID, fids) + } now := t.TimeNow() var linkID any var linkBy string @@ -2158,9 +2525,16 @@ func (a *adapter) TenantFileLinkAttachments(id t.TenantID, topic string, userID, } func (a *adapter) TenantFileUserHasAccess(id t.TenantID, fid string, uid t.Uid) (bool, error) { - if err := validTenant(id); err != nil || uid.IsZero() { + tenantAdp, err := a.routedTenantAdapter(id) + if err != nil || uid.IsZero() { return false, t.ErrMalformed } + if tenantAdp != a { + return tenantAdp.FileUserHasAccess(fid, uid) + } + if a.tenantScopedDB { + return a.FileUserHasAccess(fid, uid) + } fileID := t.ParseUid(fid) if fileID.IsZero() { return false, t.ErrMalformed @@ -2253,6 +2627,15 @@ func (a *adapter) TenantPCacheExpire(id t.TenantID, prefix string, before time.T } func (a *adapter) TenantVerificationCreate(id t.TenantID, vc *t.VerificationCode) error { + if !a.tenantScopedDB { + tenantAdp, err := a.routedTenantAdapter(id) + if err != nil { + return err + } + if tenantAdp != a { + return tenantAdp.TenantVerificationCreate(id, vc) + } + } if err := validTenant(id); err != nil || vc == nil || vc.TenantID != id || vc.ChallengeID == "" || vc.Scene == "" || vc.PhoneHash == "" || vc.CodeHash == "" { return t.ErrMalformed @@ -2276,8 +2659,8 @@ func (a *adapter) TenantVerificationCreate(id t.TenantID, vc *t.VerificationCode _, err = tx.ExecContext(ctx, `UPDATE im_verification_codes SET state=?,updated_at=? - WHERE tenant_id=? AND scene=? AND phone_hash=? AND state=?`, - t.VerificationStateExpired, now, id, vc.Scene, vc.PhoneHash, t.VerificationStatePending) + WHERE scene=? AND phone_hash=? AND state=?`, + t.VerificationStateExpired, now, vc.Scene, vc.PhoneHash, t.VerificationStatePending) if err != nil { return err } @@ -2286,10 +2669,10 @@ func (a *adapter) TenantVerificationCreate(id t.TenantID, vc *t.VerificationCode vc.UpdatedAt = now _, err = tx.ExecContext(ctx, `INSERT INTO im_verification_codes - (tenant_id,challenge_id,scene,action,phone_hash,phone_mask,target_user,code_hash,state,attempts,max_attempts, + (challenge_id,scene,action,phone_hash,phone_mask,target_user,code_hash,state,attempts,max_attempts, expires_at,resend_after,created_at,updated_at) - VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, - id, vc.ChallengeID, vc.Scene, vc.Action, vc.PhoneHash, vc.PhoneMask, nullableUID(vc.TargetUser), vc.CodeHash, vc.State, vc.Attempts, + VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, + vc.ChallengeID, vc.Scene, vc.Action, vc.PhoneHash, vc.PhoneMask, nullableUID(vc.TargetUser), vc.CodeHash, vc.State, vc.Attempts, vc.MaxAttempts, vc.ExpiresAt, vc.ResendAfter, vc.CreatedAt, vc.UpdatedAt) if isDupe(err) { return t.ErrDuplicate @@ -2301,6 +2684,15 @@ func (a *adapter) TenantVerificationCreate(id t.TenantID, vc *t.VerificationCode } func (a *adapter) TenantVerificationGet(id t.TenantID, challengeID string) (*t.VerificationCode, error) { + if !a.tenantScopedDB { + tenantAdp, err := a.routedTenantAdapter(id) + if err != nil { + return nil, err + } + if tenantAdp != a { + return tenantAdp.TenantVerificationGet(id, challengeID) + } + } if err := validTenant(id); err != nil || challengeID == "" { return nil, t.ErrMalformed } @@ -2310,20 +2702,30 @@ func (a *adapter) TenantVerificationGet(id t.TenantID, challengeID string) (*t.V } var vc t.VerificationCode err := a.db.GetContext(ctx, &vc, - `SELECT id,tenant_id,challenge_id,scene,action,phone_hash,phone_mask,IFNULL(target_user,0) AS target_user,code_hash,verification_token_hash, + `SELECT id,challenge_id,scene,action,phone_hash,phone_mask,IFNULL(target_user,0) AS target_user,code_hash,verification_token_hash, state,attempts,max_attempts,expires_at,resend_after,verified_at,consumed_at,created_at,updated_at FROM im_verification_codes - WHERE tenant_id=? AND challenge_id=? LIMIT 1`, id, challengeID) + WHERE challenge_id=? LIMIT 1`, challengeID) if err == sql.ErrNoRows { return nil, t.ErrNotFound } if err != nil { return nil, err } + vc.TenantID = id return &vc, nil } func (a *adapter) TenantVerificationGetActive(id t.TenantID, scene, phoneHash string, now time.Time) (*t.VerificationCode, error) { + if !a.tenantScopedDB { + tenantAdp, err := a.routedTenantAdapter(id) + if err != nil { + return nil, err + } + if tenantAdp != a { + return tenantAdp.TenantVerificationGetActive(id, scene, phoneHash, now) + } + } if err := validTenant(id); err != nil || scene == "" || phoneHash == "" { return nil, t.ErrMalformed } @@ -2333,21 +2735,31 @@ func (a *adapter) TenantVerificationGetActive(id t.TenantID, scene, phoneHash st } var vc t.VerificationCode err := a.db.GetContext(ctx, &vc, - `SELECT id,tenant_id,challenge_id,scene,action,phone_hash,phone_mask,IFNULL(target_user,0) AS target_user,code_hash,verification_token_hash, + `SELECT id,challenge_id,scene,action,phone_hash,phone_mask,IFNULL(target_user,0) AS target_user,code_hash,verification_token_hash, state,attempts,max_attempts,expires_at,resend_after,verified_at,consumed_at,created_at,updated_at FROM im_verification_codes - WHERE tenant_id=? AND scene=? AND phone_hash=? AND state=? AND expires_at>? - ORDER BY created_at DESC LIMIT 1`, id, scene, phoneHash, t.VerificationStatePending, now) + WHERE scene=? AND phone_hash=? AND state=? AND expires_at>? + ORDER BY created_at DESC LIMIT 1`, scene, phoneHash, t.VerificationStatePending, now) if err == sql.ErrNoRows { return nil, t.ErrNotFound } if err != nil { return nil, err } + vc.TenantID = id return &vc, nil } func (a *adapter) TenantVerificationFail(id t.TenantID, challengeID string, state t.VerificationState) error { + if !a.tenantScopedDB { + tenantAdp, err := a.routedTenantAdapter(id) + if err != nil { + return err + } + if tenantAdp != a { + return tenantAdp.TenantVerificationFail(id, challengeID, state) + } + } if err := validTenant(id); err != nil || challengeID == "" { return t.ErrMalformed } @@ -2359,12 +2771,21 @@ func (a *adapter) TenantVerificationFail(id t.TenantID, challengeID string, stat _, err := a.db.ExecContext(ctx, `UPDATE im_verification_codes SET attempts=attempts+1,state=?,updated_at=? - WHERE tenant_id=? AND challenge_id=?`, - state, now, id, challengeID) + WHERE challenge_id=?`, + state, now, challengeID) return err } func (a *adapter) TenantVerificationMarkVerified(id t.TenantID, challengeID, tokenHash string, verifiedAt time.Time) error { + if !a.tenantScopedDB { + tenantAdp, err := a.routedTenantAdapter(id) + if err != nil { + return err + } + if tenantAdp != a { + return tenantAdp.TenantVerificationMarkVerified(id, challengeID, tokenHash, verifiedAt) + } + } if err := validTenant(id); err != nil || challengeID == "" || tokenHash == "" { return t.ErrMalformed } @@ -2375,12 +2796,21 @@ func (a *adapter) TenantVerificationMarkVerified(id t.TenantID, challengeID, tok _, err := a.db.ExecContext(ctx, `UPDATE im_verification_codes SET state=?,verification_token_hash=?,verified_at=?,updated_at=? - WHERE tenant_id=? AND challenge_id=? AND state=?`, - t.VerificationStateVerified, tokenHash, verifiedAt, verifiedAt, id, challengeID, t.VerificationStatePending) + WHERE challenge_id=? AND state=?`, + t.VerificationStateVerified, tokenHash, verifiedAt, verifiedAt, challengeID, t.VerificationStatePending) return err } func (a *adapter) TenantVerificationConsume(id t.TenantID, challengeID, tokenHash string, consumedAt time.Time) error { + if !a.tenantScopedDB { + tenantAdp, err := a.routedTenantAdapter(id) + if err != nil { + return err + } + if tenantAdp != a { + return tenantAdp.TenantVerificationConsume(id, challengeID, tokenHash, consumedAt) + } + } if err := validTenant(id); err != nil || challengeID == "" || tokenHash == "" { return t.ErrMalformed } @@ -2391,8 +2821,8 @@ func (a *adapter) TenantVerificationConsume(id t.TenantID, challengeID, tokenHas res, err := a.db.ExecContext(ctx, `UPDATE im_verification_codes SET state=?,consumed_at=?,updated_at=? - WHERE tenant_id=? AND challenge_id=? AND verification_token_hash=? AND state=? AND expires_at>?`, - t.VerificationStateConsumed, consumedAt, consumedAt, id, challengeID, tokenHash, + WHERE challenge_id=? AND verification_token_hash=? AND state=? AND expires_at>?`, + t.VerificationStateConsumed, consumedAt, consumedAt, challengeID, tokenHash, t.VerificationStateVerified, consumedAt) if err != nil { return err @@ -2404,6 +2834,15 @@ func (a *adapter) TenantVerificationConsume(id t.TenantID, challengeID, tokenHas } func (a *adapter) TenantUserSecurityGetAuthVersion(id t.TenantID, uid t.Uid) (int, error) { + if !a.tenantScopedDB { + tenantAdp, err := a.routedTenantAdapter(id) + if err != nil { + return 0, err + } + if tenantAdp != a { + return tenantAdp.TenantUserSecurityGetAuthVersion(id, uid) + } + } if err := validTenant(id); err != nil || uid.IsZero() { return 0, t.ErrMalformed } @@ -2414,17 +2853,17 @@ func (a *adapter) TenantUserSecurityGetAuthVersion(id t.TenantID, uid t.Uid) (in decoded := decodeUID(uid) now := t.TimeNow() _, err := a.db.ExecContext(ctx, - `INSERT INTO im_user_security(tenant_id,user_id,auth_version,created_at,updated_at) - VALUES(?,?,?,?,?) + `INSERT INTO im_user_security(user_id,auth_version,created_at,updated_at) + VALUES(?,?,?,?) ON DUPLICATE KEY UPDATE auth_version=auth_version`, - id, decoded, 1, now, now) + decoded, 1, now, now) if err != nil { return 0, err } var version int err = a.db.GetContext(ctx, &version, - `SELECT auth_version FROM im_user_security WHERE tenant_id=? AND user_id=?`, - id, decoded) + `SELECT auth_version FROM im_user_security WHERE user_id=?`, + decoded) if err == sql.ErrNoRows { return 0, t.ErrNotFound } @@ -2435,6 +2874,15 @@ func (a *adapter) TenantUserSecurityGetAuthVersion(id t.TenantID, uid t.Uid) (in } func (a *adapter) TenantUserSecurityBumpAuthVersion(id t.TenantID, uid t.Uid, updatedAt time.Time) (int, error) { + if !a.tenantScopedDB { + tenantAdp, err := a.routedTenantAdapter(id) + if err != nil { + return 0, err + } + if tenantAdp != a { + return tenantAdp.TenantUserSecurityBumpAuthVersion(id, uid, updatedAt) + } + } if err := validTenant(id); err != nil || uid.IsZero() { return 0, t.ErrMalformed } @@ -2456,19 +2904,19 @@ func (a *adapter) TenantUserSecurityBumpAuthVersion(id t.TenantID, uid t.Uid, up }() decoded := decodeUID(uid) if _, err = tx.ExecContext(ctx, - `INSERT INTO im_user_security(tenant_id,user_id,auth_version,password_updated_at,created_at,updated_at) - VALUES(?,?,?,?,?,?) + `INSERT INTO im_user_security(user_id,auth_version,password_updated_at,created_at,updated_at) + VALUES(?,?,?,?,?) ON DUPLICATE KEY UPDATE auth_version=auth_version+1, password_updated_at=VALUES(password_updated_at), updated_at=VALUES(updated_at)`, - id, decoded, 2, updatedAt, updatedAt, updatedAt); err != nil { + decoded, 2, updatedAt, updatedAt, updatedAt); err != nil { return 0, err } var version int if err = tx.GetContext(ctx, &version, - `SELECT auth_version FROM im_user_security WHERE tenant_id=? AND user_id=?`, - id, decoded); err != nil { + `SELECT auth_version FROM im_user_security WHERE user_id=?`, + decoded); err != nil { return 0, err } if err = tx.Commit(); err != nil { @@ -2478,6 +2926,15 @@ func (a *adapter) TenantUserSecurityBumpAuthVersion(id t.TenantID, uid t.Uid, up } func (a *adapter) TenantRealNameGet(id t.TenantID, uid t.Uid) (*t.RealNameVerification, error) { + if !a.tenantScopedDB { + tenantAdp, err := a.routedTenantAdapter(id) + if err != nil { + return nil, err + } + if tenantAdp != a { + return tenantAdp.TenantRealNameGet(id, uid) + } + } if err := validTenant(id); err != nil || uid.IsZero() { return nil, t.ErrMalformed } @@ -2487,22 +2944,32 @@ func (a *adapter) TenantRealNameGet(id t.TenantID, uid t.Uid) (*t.RealNameVerifi } var rv t.RealNameVerification err := a.db.GetContext(ctx, &rv, - `SELECT id,tenant_id,user_id,submission_id,status,name_cipher,id_type,id_number_cipher,id_number_hash, + `SELECT id,user_id,submission_id,status,name_cipher,id_type,id_number_cipher,id_number_hash, masked_name,masked_id_number,review_reason,verified_at,created_at,updated_at FROM im_realname_verifications - WHERE tenant_id=? AND user_id=? + WHERE user_id=? ORDER BY created_at DESC,id DESC LIMIT 1`, - id, decodeUID(uid)) + decodeUID(uid)) if err == sql.ErrNoRows { return nil, t.ErrNotFound } if err != nil { return nil, err } + rv.TenantID = id return &rv, nil } func (a *adapter) TenantRealNameCreate(id t.TenantID, rv *t.RealNameVerification) error { + if !a.tenantScopedDB { + tenantAdp, err := a.routedTenantAdapter(id) + if err != nil { + return err + } + if tenantAdp != a { + return tenantAdp.TenantRealNameCreate(id, rv) + } + } if err := validTenant(id); err != nil || rv == nil || rv.TenantID != id || rv.UserID.IsZero() || rv.SubmissionID == "" || rv.Status == "" || rv.NameCipher == "" || rv.IDType == "" || rv.IDNumberCipher == "" || rv.IDNumberHash == "" || rv.MaskedName == "" || rv.MaskedIDNumber == "" { @@ -2517,10 +2984,10 @@ func (a *adapter) TenantRealNameCreate(id t.TenantID, rv *t.RealNameVerification rv.UpdatedAt = now _, err := a.db.ExecContext(ctx, `INSERT INTO im_realname_verifications - (tenant_id,user_id,submission_id,status,name_cipher,id_type,id_number_cipher,id_number_hash, + (user_id,submission_id,status,name_cipher,id_type,id_number_cipher,id_number_hash, masked_name,masked_id_number,review_reason,verified_at,created_at,updated_at) - VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, - id, decodeUID(rv.UserID), rv.SubmissionID, rv.Status, rv.NameCipher, rv.IDType, rv.IDNumberCipher, rv.IDNumberHash, + VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?)`, + decodeUID(rv.UserID), rv.SubmissionID, rv.Status, rv.NameCipher, rv.IDType, rv.IDNumberCipher, rv.IDNumberHash, rv.MaskedName, rv.MaskedIDNumber, rv.ReviewReason, rv.VerifiedAt, rv.CreatedAt, rv.UpdatedAt) if isDupe(err) { return t.ErrDuplicate @@ -2529,6 +2996,15 @@ func (a *adapter) TenantRealNameCreate(id t.TenantID, rv *t.RealNameVerification } func (a *adapter) TenantActionTokenCreate(id t.TenantID, token *t.ActionToken) error { + if !a.tenantScopedDB { + tenantAdp, err := a.routedTenantAdapter(id) + if err != nil { + return err + } + if tenantAdp != a { + return tenantAdp.TenantActionTokenCreate(id, token) + } + } if err := validTenant(id); err != nil || token == nil || token.TenantID != id || token.UserID.IsZero() || token.TokenID == "" || token.Action == "" || token.TokenHash == "" || token.ExpiresAt.IsZero() { return t.ErrMalformed @@ -2542,9 +3018,9 @@ func (a *adapter) TenantActionTokenCreate(id t.TenantID, token *t.ActionToken) e token.UpdatedAt = now _, err := a.db.ExecContext(ctx, `INSERT INTO im_action_tokens - (tenant_id,user_id,token_id,action,target,token_hash,state,expires_at,created_at,updated_at) - VALUES(?,?,?,?,?,?,?,?,?,?)`, - id, decodeUID(token.UserID), token.TokenID, token.Action, token.Target, token.TokenHash, token.State, + (user_id,token_id,action,target,token_hash,state,expires_at,created_at,updated_at) + VALUES(?,?,?,?,?,?,?,?,?)`, + decodeUID(token.UserID), token.TokenID, token.Action, token.Target, token.TokenHash, token.State, token.ExpiresAt, token.CreatedAt, token.UpdatedAt) if isDupe(err) { return t.ErrDuplicate @@ -2553,6 +3029,15 @@ func (a *adapter) TenantActionTokenCreate(id t.TenantID, token *t.ActionToken) e } func (a *adapter) TenantActionTokenConsume(id t.TenantID, uid t.Uid, token, action, target string, now time.Time) (*t.ActionToken, error) { + if !a.tenantScopedDB { + tenantAdp, err := a.routedTenantAdapter(id) + if err != nil { + return nil, err + } + if tenantAdp != a { + return tenantAdp.TenantActionTokenConsume(id, uid, token, action, target, now) + } + } if err := validTenant(id); err != nil || uid.IsZero() || token == "" || action == "" { return nil, t.ErrMalformed } @@ -2579,10 +3064,10 @@ func (a *adapter) TenantActionTokenConsume(id t.TenantID, uid t.Uid, token, acti }() var at t.ActionToken err = tx.GetContext(ctx, &at, - `SELECT id,tenant_id,user_id,token_id,action,target,token_hash,state,expires_at,consumed_at,created_at,updated_at + `SELECT id,user_id,token_id,action,target,token_hash,state,expires_at,consumed_at,created_at,updated_at FROM im_action_tokens - WHERE tenant_id=? AND user_id=? AND token_id=? AND action=? AND target=? LIMIT 1`, - id, decodeUID(uid), tokenID, action, target) + WHERE user_id=? AND token_id=? AND action=? AND target=? LIMIT 1`, + decodeUID(uid), tokenID, action, target) if err == sql.ErrNoRows { return nil, t.ErrNotFound } @@ -2594,8 +3079,8 @@ func (a *adapter) TenantActionTokenConsume(id t.TenantID, uid t.Uid, token, acti } res, err := tx.ExecContext(ctx, `UPDATE im_action_tokens SET state=1,consumed_at=?,updated_at=? - WHERE tenant_id=? AND user_id=? AND token_id=? AND action=? AND target=? AND state=0 AND expires_at>?`, - now, now, id, decodeUID(uid), tokenID, action, target, now) + WHERE user_id=? AND token_id=? AND action=? AND target=? AND state=0 AND expires_at>?`, + now, now, decodeUID(uid), tokenID, action, target, now) if err != nil { return nil, err } @@ -2605,10 +3090,20 @@ func (a *adapter) TenantActionTokenConsume(id t.TenantID, uid t.Uid, token, acti if err = tx.Commit(); err != nil { return nil, err } + at.TenantID = id return &at, nil } func (a *adapter) TenantIndependentPasswordGet(id t.TenantID, uid t.Uid, purpose string) (*t.IndependentPassword, error) { + if !a.tenantScopedDB { + tenantAdp, err := a.routedTenantAdapter(id) + if err != nil { + return nil, err + } + if tenantAdp != a { + return tenantAdp.TenantIndependentPasswordGet(id, uid, purpose) + } + } if err := validTenant(id); err != nil || uid.IsZero() || purpose == "" { return nil, t.ErrMalformed } @@ -2618,20 +3113,30 @@ func (a *adapter) TenantIndependentPasswordGet(id t.TenantID, uid t.Uid, purpose } var pass t.IndependentPassword err := a.db.GetContext(ctx, &pass, - `SELECT tenant_id,user_id,purpose,password_hash,failed_attempts,locked_until,created_at,updated_at + `SELECT user_id,purpose,password_hash,failed_attempts,locked_until,created_at,updated_at FROM im_youth_mode_settings - WHERE tenant_id=? AND user_id=? AND purpose=?`, - id, decodeUID(uid), purpose) + WHERE user_id=? AND purpose=?`, + decodeUID(uid), purpose) if err == sql.ErrNoRows { return nil, t.ErrNotFound } if err != nil { return nil, err } + pass.TenantID = id return &pass, nil } func (a *adapter) TenantIndependentPasswordUpsert(id t.TenantID, pass *t.IndependentPassword) error { + if !a.tenantScopedDB { + tenantAdp, err := a.routedTenantAdapter(id) + if err != nil { + return err + } + if tenantAdp != a { + return tenantAdp.TenantIndependentPasswordUpsert(id, pass) + } + } if err := validTenant(id); err != nil || pass == nil || pass.TenantID != id || pass.UserID.IsZero() || pass.Purpose == "" || len(pass.PasswordHash) == 0 { return t.ErrMalformed @@ -2645,14 +3150,23 @@ func (a *adapter) TenantIndependentPasswordUpsert(id t.TenantID, pass *t.Indepen pass.UpdatedAt = now _, err := a.db.ExecContext(ctx, `INSERT INTO im_youth_mode_settings - (tenant_id,user_id,purpose,password_hash,failed_attempts,locked_until,created_at,updated_at) - VALUES(?,?,?,?,0,NULL,?,?) + (user_id,purpose,password_hash,failed_attempts,locked_until,created_at,updated_at) + VALUES(?,?,?,0,NULL,?,?) ON DUPLICATE KEY UPDATE password_hash=VALUES(password_hash),failed_attempts=0,locked_until=NULL,updated_at=VALUES(updated_at)`, - id, decodeUID(pass.UserID), pass.Purpose, pass.PasswordHash, pass.CreatedAt, pass.UpdatedAt) + decodeUID(pass.UserID), pass.Purpose, pass.PasswordHash, pass.CreatedAt, pass.UpdatedAt) return err } func (a *adapter) TenantIndependentPasswordUpdateAttempts(id t.TenantID, uid t.Uid, purpose string, attempts int, lockedUntil *time.Time, updatedAt time.Time) error { + if !a.tenantScopedDB { + tenantAdp, err := a.routedTenantAdapter(id) + if err != nil { + return err + } + if tenantAdp != a { + return tenantAdp.TenantIndependentPasswordUpdateAttempts(id, uid, purpose, attempts, lockedUntil, updatedAt) + } + } if err := validTenant(id); err != nil || uid.IsZero() || purpose == "" || attempts < 0 { return t.ErrMalformed } @@ -2665,12 +3179,21 @@ func (a *adapter) TenantIndependentPasswordUpdateAttempts(id t.TenantID, uid t.U } _, err := a.db.ExecContext(ctx, `UPDATE im_youth_mode_settings SET failed_attempts=?,locked_until=?,updated_at=? - WHERE tenant_id=? AND user_id=? AND purpose=?`, - attempts, lockedUntil, updatedAt, id, decodeUID(uid), purpose) + WHERE user_id=? AND purpose=?`, + attempts, lockedUntil, updatedAt, decodeUID(uid), purpose) return err } func (a *adapter) TenantSystemNotificationCreate(id t.TenantID, notification *t.SystemNotification) error { + if !a.tenantScopedDB { + tenantAdp, err := a.routedTenantAdapter(id) + if err != nil { + return err + } + if tenantAdp != a { + return tenantAdp.TenantSystemNotificationCreate(id, notification) + } + } if err := validTenant(id); err != nil || notification == nil || notification.TenantID != id || notification.UserID.IsZero() || notification.EventID == "" || notification.EventType == "" || notification.Title == "" { @@ -2685,20 +3208,29 @@ func (a *adapter) TenantSystemNotificationCreate(id t.TenantID, notification *t. notification.UpdatedAt = now _, err := a.db.ExecContext(ctx, `INSERT INTO im_system_notifications - (tenant_id,user_id,event_id,event_type,title,summary,action,read_at,created_at,updated_at) - VALUES(?,?,?,?,?,?,?,?,?,?) + (user_id,event_id,event_type,title,summary,action,read_at,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)`, - id, decodeUID(notification.UserID), notification.EventID, notification.EventType, notification.Title, + decodeUID(notification.UserID), notification.EventID, notification.EventType, notification.Title, notification.Summary, common.ToJSON(notification.Action), notification.ReadAt, notification.CreatedAt, notification.UpdatedAt) return err } func (a *adapter) TenantSystemNotificationList(id t.TenantID, uid t.Uid, cursor string, limit int) ([]t.SystemNotification, string, error) { + if !a.tenantScopedDB { + tenantAdp, err := a.routedTenantAdapter(id) + if err != nil { + return nil, "", err + } + if tenantAdp != a { + return tenantAdp.TenantSystemNotificationList(id, uid, cursor, limit) + } + } if err := validTenant(id); err != nil || uid.IsZero() { return nil, "", t.ErrMalformed } @@ -2713,10 +3245,10 @@ func (a *adapter) TenantSystemNotificationList(id t.TenantID, uid t.Uid, cursor } lastID = parsed } - query := `SELECT id,tenant_id,user_id,event_id,event_type,title,summary,action,read_at,created_at,updated_at + query := `SELECT id,user_id,event_id,event_type,title,summary,action,read_at,created_at,updated_at FROM im_system_notifications - WHERE tenant_id=? AND user_id=?` - args := []any{id, decodeUID(uid)} + WHERE user_id=?` + args := []any{decodeUID(uid)} if lastID > 0 { query += " AND id 100 { limit = 20 } - where := []string{"fr.tenant_id=?"} - args := []any{id} + where := []string{} + args := []any{} decodedUID := decodeUID(uid) switch direction { case "", "inbound": @@ -2837,7 +3397,7 @@ func (a *adapter) TenantFriendRequestList(id t.TenantID, uid t.Uid, direction, s args = append(args, limit+1) query := `SELECT fr.*,u.public,u.trusted FROM im_friend_requests AS fr - JOIN users AS u ON u.tenant_id=fr.tenant_id AND u.id=IF(fr.applicant_id=?,fr.target_id,fr.applicant_id) + JOIN users AS u ON u.id=IF(fr.applicant_id=?,fr.target_id,fr.applicant_id) WHERE ` + strings.Join(where, " AND ") + ` ORDER BY fr.id DESC LIMIT ?` args = append([]any{decodedUID}, args...) @@ -2855,10 +3415,9 @@ func (a *adapter) TenantFriendRequestList(id t.TenantID, uid t.Uid, direction, s for rows.Next() { row := struct { ID int64 - TenantID t.TenantID `db:"tenant_id"` - RequestID string `db:"request_id"` - ApplicantID int64 `db:"applicant_id"` - TargetID int64 `db:"target_id"` + RequestID string `db:"request_id"` + ApplicantID int64 `db:"applicant_id"` + TargetID int64 `db:"target_id"` Message string Source string SourceTopic string `db:"source_topic"` @@ -2889,7 +3448,7 @@ func (a *adapter) TenantFriendRequestList(id t.TenantID, uid t.Uid, direction, s items = append(items, t.FriendRequestListItem{ Request: t.FriendRequest{ ID: row.ID, - TenantID: row.TenantID, + TenantID: id, RequestID: row.RequestID, ApplicantID: applicant, TargetID: target, @@ -2905,7 +3464,7 @@ func (a *adapter) TenantFriendRequestList(id t.TenantID, uid t.Uid, direction, s }, Peer: &t.User{ ObjHeader: t.ObjHeader{Id: peerID.String()}, - TenantID: row.TenantID, + TenantID: id, Public: common.FromJSON(row.Public), Trusted: common.FromJSON(row.Trusted), }, @@ -2919,6 +3478,15 @@ func (a *adapter) TenantFriendRequestList(id t.TenantID, uid t.Uid, direction, s } func (a *adapter) TenantFriendRequestLatestBetween(id t.TenantID, uid1, uid2 t.Uid) (*t.FriendRequest, error) { + if !a.tenantScopedDB { + tenantAdp, err := a.routedTenantAdapter(id) + if err != nil { + return nil, err + } + if tenantAdp != a { + return tenantAdp.TenantFriendRequestLatestBetween(id, uid1, uid2) + } + } if err := validTenant(id); err != nil || uid1.IsZero() || uid2.IsZero() { return nil, t.ErrMalformed } @@ -2927,11 +3495,21 @@ func (a *adapter) TenantFriendRequestLatestBetween(id t.TenantID, uid1, uid2 t.U defer cancel() } return a.friendRequestByQuery(ctx, - "SELECT * FROM im_friend_requests WHERE tenant_id=? AND ((applicant_id=? AND target_id=?) OR (applicant_id=? AND target_id=?)) ORDER BY created_at DESC,id DESC LIMIT 1", - id, decodeUID(uid1), decodeUID(uid2), decodeUID(uid2), decodeUID(uid1)) + id, + "SELECT * FROM im_friend_requests WHERE ((applicant_id=? AND target_id=?) OR (applicant_id=? AND target_id=?)) ORDER BY created_at DESC,id DESC LIMIT 1", + decodeUID(uid1), decodeUID(uid2), decodeUID(uid2), decodeUID(uid1)) } func (a *adapter) TenantFriendRequestAccept(id t.TenantID, requestID string, actor t.Uid, handledAt time.Time) (*t.FriendRequest, error) { + if !a.tenantScopedDB { + tenantAdp, err := a.routedTenantAdapter(id) + if err != nil { + return nil, err + } + if tenantAdp != a { + return tenantAdp.TenantFriendRequestAccept(id, requestID, actor, handledAt) + } + } if err := validTenant(id); err != nil || requestID == "" || actor.IsZero() { return nil, t.ErrMalformed } @@ -2950,10 +3528,9 @@ func (a *adapter) TenantFriendRequestAccept(id t.TenantID, requestID string, act }() row := struct { ID int64 - TenantID t.TenantID `db:"tenant_id"` - RequestID string `db:"request_id"` - ApplicantID int64 `db:"applicant_id"` - TargetID int64 `db:"target_id"` + RequestID string `db:"request_id"` + ApplicantID int64 `db:"applicant_id"` + TargetID int64 `db:"target_id"` Message string Source string SourceTopic string `db:"source_topic"` @@ -2964,7 +3541,7 @@ func (a *adapter) TenantFriendRequestAccept(id t.TenantID, requestID string, act CreatedAt time.Time `db:"created_at"` UpdatedAt time.Time `db:"updated_at"` }{} - err = tx.GetContext(ctx, &row, "SELECT * FROM im_friend_requests WHERE tenant_id=? AND request_id=? FOR UPDATE", id, requestID) + err = tx.GetContext(ctx, &row, "SELECT * FROM im_friend_requests WHERE request_id=? FOR UPDATE", requestID) if err == sql.ErrNoRows { return nil, t.ErrNotFound } @@ -2977,7 +3554,7 @@ func (a *adapter) TenantFriendRequestAccept(id t.TenantID, requestID string, act if row.Status != t.FriendRequestPending && row.Status != t.FriendRequestAccepted { return &t.FriendRequest{ ID: row.ID, - TenantID: row.TenantID, + TenantID: id, RequestID: row.RequestID, ApplicantID: store.EncodeUid(row.ApplicantID), TargetID: store.EncodeUid(row.TargetID), @@ -3000,8 +3577,8 @@ func (a *adapter) TenantFriendRequestAccept(id t.TenantID, requestID string, act } if row.Status == t.FriendRequestPending { if _, err = tx.ExecContext(ctx, - "UPDATE im_friend_requests SET status=?,handled_at=?,updated_at=? WHERE tenant_id=? AND request_id=? AND status=?", - t.FriendRequestAccepted, handledAt, handledAt, id, requestID, t.FriendRequestPending); err != nil { + "UPDATE im_friend_requests SET status=?,handled_at=?,updated_at=? WHERE request_id=? AND status=?", + t.FriendRequestAccepted, handledAt, handledAt, requestID, t.FriendRequestPending); err != nil { return nil, err } row.Status = t.FriendRequestAccepted @@ -3010,9 +3587,9 @@ func (a *adapter) TenantFriendRequestAccept(id t.TenantID, requestID string, act } for _, pair := range [][2]t.Uid{{applicant, target}, {target, applicant}} { if _, err = tx.ExecContext(ctx, - "INSERT INTO im_friendships(tenant_id,user_id,peer_id,status,topic,created_at,updated_at) VALUES(?,?,?,?,?,?,?) "+ + "INSERT INTO im_friendships(user_id,peer_id,status,topic,created_at,updated_at) VALUES(?,?,?,?,?,?) "+ "ON DUPLICATE KEY UPDATE status=VALUES(status),topic=VALUES(topic),updated_at=VALUES(updated_at)", - id, decodeUID(pair[0]), decodeUID(pair[1]), t.RelationshipFriend, topic, handledAt, handledAt); err != nil { + decodeUID(pair[0]), decodeUID(pair[1]), t.RelationshipFriend, topic, handledAt, handledAt); err != nil { return nil, err } } @@ -3065,8 +3642,8 @@ func (a *adapter) TenantFriendRequestAccept(id t.TenantID, requestID string, act if !isDupe(err) { return nil, err } - if _, err = tx.ExecContext(ctx, "UPDATE topics SET updatedat=?,touchedat=?,state=? WHERE tenant_id=? AND name=?", - handledAt, handledAt, t.StateOK, id, topic); err != nil { + if _, err = tx.ExecContext(ctx, "UPDATE topics SET updatedat=?,touchedat=?,state=? WHERE name=?", + handledAt, handledAt, t.StateOK, topic); err != nil { return nil, err } } @@ -3077,6 +3654,15 @@ func (a *adapter) TenantFriendRequestAccept(id t.TenantID, requestID string, act } func (a *adapter) TenantFriendRequestResolve(id t.TenantID, requestID string, actor t.Uid, status string, handledAt time.Time) (*t.FriendRequest, error) { + if !a.tenantScopedDB { + tenantAdp, err := a.routedTenantAdapter(id) + if err != nil { + return nil, err + } + if tenantAdp != a { + return tenantAdp.TenantFriendRequestResolve(id, requestID, actor, status, handledAt) + } + } if err := validTenant(id); err != nil || requestID == "" || actor.IsZero() { return nil, t.ErrMalformed } @@ -3095,14 +3681,23 @@ func (a *adapter) TenantFriendRequestResolve(id t.TenantID, requestID string, ac return req, nil } if _, err = a.db.ExecContext(ctx, - "UPDATE im_friend_requests SET status=?,handled_at=?,updated_at=? WHERE tenant_id=? AND request_id=? AND status=?", - status, handledAt, handledAt, id, requestID, t.FriendRequestPending); err != nil { + "UPDATE im_friend_requests SET status=?,handled_at=?,updated_at=? WHERE request_id=? AND status=?", + status, handledAt, handledAt, requestID, t.FriendRequestPending); err != nil { return nil, err } return a.friendRequestByRequestID(ctx, id, requestID) } func (a *adapter) TenantFriendshipUpsertPair(id t.TenantID, uid1, uid2 t.Uid, topic string, now time.Time) error { + if !a.tenantScopedDB { + tenantAdp, err := a.routedTenantAdapter(id) + if err != nil { + return err + } + if tenantAdp != a { + return tenantAdp.TenantFriendshipUpsertPair(id, uid1, uid2, topic, now) + } + } if err := validTenant(id); err != nil || uid1.IsZero() || uid2.IsZero() || uid1 == uid2 { return t.ErrMalformed } @@ -3115,9 +3710,9 @@ func (a *adapter) TenantFriendshipUpsertPair(id t.TenantID, uid1, uid2 t.Uid, to } for _, pair := range [][2]t.Uid{{uid1, uid2}, {uid2, uid1}} { if _, err := a.db.ExecContext(ctx, - "INSERT INTO im_friendships(tenant_id,user_id,peer_id,status,topic,created_at,updated_at) VALUES(?,?,?,?,?,?,?) "+ + "INSERT INTO im_friendships(user_id,peer_id,status,topic,created_at,updated_at) VALUES(?,?,?,?,?,?) "+ "ON DUPLICATE KEY UPDATE status=VALUES(status),topic=VALUES(topic),updated_at=VALUES(updated_at)", - id, decodeUID(pair[0]), decodeUID(pair[1]), t.RelationshipFriend, topic, now, now); err != nil { + decodeUID(pair[0]), decodeUID(pair[1]), t.RelationshipFriend, topic, now, now); err != nil { return err } } @@ -3125,14 +3720,23 @@ func (a *adapter) TenantFriendshipUpsertPair(id t.TenantID, uid1, uid2 t.Uid, to } func (a *adapter) TenantFriendshipList(id t.TenantID, uid t.Uid, queryText, initial, cursor string, limit int) ([]t.FriendListItem, string, error) { + if !a.tenantScopedDB { + tenantAdp, err := a.routedTenantAdapter(id) + if err != nil { + return nil, "", err + } + if tenantAdp != a { + return tenantAdp.TenantFriendshipList(id, uid, queryText, initial, cursor, limit) + } + } if err := validTenant(id); err != nil || uid.IsZero() { return nil, "", t.ErrMalformed } if limit <= 0 || limit > 100 { limit = 50 } - where := []string{"fs.tenant_id=?", "fs.user_id=?", "fs.status=?"} - args := []any{id, decodeUID(uid), t.RelationshipFriend} + where := []string{"fs.user_id=?", "fs.status=?"} + args := []any{decodeUID(uid), t.RelationshipFriend} if cursor != "" { parsed, err := strconv.ParseInt(cursor, 10, 64) if err != nil || parsed <= 0 { @@ -3158,7 +3762,7 @@ func (a *adapter) TenantFriendshipList(id t.TenantID, uid t.Uid, queryText, init } rows, err := a.db.QueryxContext(ctx, `SELECT fs.*,u.public,u.trusted FROM im_friendships AS fs - JOIN users AS u ON u.tenant_id=fs.tenant_id AND u.id=fs.peer_id + JOIN users AS u ON u.id=fs.peer_id WHERE `+strings.Join(where, " AND ")+` ORDER BY fs.id ASC LIMIT ?`, args...) if err != nil { @@ -3170,9 +3774,8 @@ func (a *adapter) TenantFriendshipList(id t.TenantID, uid t.Uid, queryText, init for rows.Next() { row := struct { ID int64 - TenantID t.TenantID `db:"tenant_id"` - UserID int64 `db:"user_id"` - PeerID int64 `db:"peer_id"` + UserID int64 `db:"user_id"` + PeerID int64 `db:"peer_id"` Status string Remark string Topic string @@ -3191,7 +3794,7 @@ func (a *adapter) TenantFriendshipList(id t.TenantID, uid t.Uid, queryText, init peerID := store.EncodeUid(row.PeerID) items = append(items, t.FriendListItem{ Friendship: t.Friendship{ - TenantID: row.TenantID, + TenantID: id, UserID: store.EncodeUid(row.UserID), PeerID: peerID, Status: row.Status, @@ -3202,7 +3805,7 @@ func (a *adapter) TenantFriendshipList(id t.TenantID, uid t.Uid, queryText, init }, Peer: &t.User{ ObjHeader: t.ObjHeader{Id: peerID.String()}, - TenantID: row.TenantID, + TenantID: id, Public: common.FromJSON(row.Public), Trusted: common.FromJSON(row.Trusted), }, @@ -3215,6 +3818,15 @@ func (a *adapter) TenantFriendshipList(id t.TenantID, uid t.Uid, queryText, init } func (a *adapter) TenantFriendshipGet(id t.TenantID, userID, peerID t.Uid) (*t.Friendship, error) { + if !a.tenantScopedDB { + tenantAdp, err := a.routedTenantAdapter(id) + if err != nil { + return nil, err + } + if tenantAdp != a { + return tenantAdp.TenantFriendshipGet(id, userID, peerID) + } + } if err := validTenant(id); err != nil || userID.IsZero() || peerID.IsZero() { return nil, t.ErrMalformed } @@ -3223,17 +3835,16 @@ func (a *adapter) TenantFriendshipGet(id t.TenantID, userID, peerID t.Uid) (*t.F defer cancel() } row := struct { - TenantID t.TenantID `db:"tenant_id"` - UserID int64 `db:"user_id"` - PeerID int64 `db:"peer_id"` + UserID int64 `db:"user_id"` + PeerID int64 `db:"peer_id"` Status string Remark string Topic string CreatedAt time.Time `db:"created_at"` UpdatedAt time.Time `db:"updated_at"` }{} - err := a.db.GetContext(ctx, &row, "SELECT * FROM im_friendships WHERE tenant_id=? AND user_id=? AND peer_id=?", - id, decodeUID(userID), decodeUID(peerID)) + err := a.db.GetContext(ctx, &row, "SELECT * FROM im_friendships WHERE user_id=? AND peer_id=?", + decodeUID(userID), decodeUID(peerID)) if err == sql.ErrNoRows { return nil, nil } @@ -3241,7 +3852,7 @@ func (a *adapter) TenantFriendshipGet(id t.TenantID, userID, peerID t.Uid) (*t.F return nil, err } return &t.Friendship{ - TenantID: row.TenantID, + TenantID: id, UserID: store.EncodeUid(row.UserID), PeerID: store.EncodeUid(row.PeerID), Status: row.Status, @@ -3253,6 +3864,15 @@ func (a *adapter) TenantFriendshipGet(id t.TenantID, userID, peerID t.Uid) (*t.F } func (a *adapter) TenantFriendshipUpdateRemark(id t.TenantID, userID, peerID t.Uid, remark string, now time.Time) (*t.Friendship, error) { + if !a.tenantScopedDB { + tenantAdp, err := a.routedTenantAdapter(id) + if err != nil { + return nil, err + } + if tenantAdp != a { + return tenantAdp.TenantFriendshipUpdateRemark(id, userID, peerID, remark, now) + } + } if err := validTenant(id); err != nil || userID.IsZero() || peerID.IsZero() || len([]rune(remark)) > 64 { return nil, t.ErrMalformed } @@ -3261,8 +3881,8 @@ func (a *adapter) TenantFriendshipUpdateRemark(id t.TenantID, userID, peerID t.U defer cancel() } res, err := a.db.ExecContext(ctx, - "UPDATE im_friendships SET remark=?,updated_at=? WHERE tenant_id=? AND user_id=? AND peer_id=? AND status=?", - remark, now, id, decodeUID(userID), decodeUID(peerID), t.RelationshipFriend) + "UPDATE im_friendships SET remark=?,updated_at=? WHERE user_id=? AND peer_id=? AND status=?", + remark, now, decodeUID(userID), decodeUID(peerID), t.RelationshipFriend) if err != nil { return nil, err } @@ -3273,6 +3893,15 @@ func (a *adapter) TenantFriendshipUpdateRemark(id t.TenantID, userID, peerID t.U } func (a *adapter) TenantFriendshipDeletePair(id t.TenantID, uid1, uid2 t.Uid, now time.Time) error { + if !a.tenantScopedDB { + tenantAdp, err := a.routedTenantAdapter(id) + if err != nil { + return err + } + if tenantAdp != a { + return tenantAdp.TenantFriendshipDeletePair(id, uid1, uid2, now) + } + } if err := validTenant(id); err != nil || uid1.IsZero() || uid2.IsZero() { return t.ErrMalformed } @@ -3281,12 +3910,21 @@ func (a *adapter) TenantFriendshipDeletePair(id t.TenantID, uid1, uid2 t.Uid, no defer cancel() } _, err := a.db.ExecContext(ctx, - "UPDATE im_friendships SET status=?,updated_at=? WHERE tenant_id=? AND ((user_id=? AND peer_id=?) OR (user_id=? AND peer_id=?))", - t.RelationshipNone, now, id, decodeUID(uid1), decodeUID(uid2), decodeUID(uid2), decodeUID(uid1)) + "UPDATE im_friendships SET status=?,updated_at=? WHERE (user_id=? AND peer_id=?) OR (user_id=? AND peer_id=?)", + t.RelationshipNone, now, decodeUID(uid1), decodeUID(uid2), decodeUID(uid2), decodeUID(uid1)) return err } func (a *adapter) TenantUserBlockGet(id t.TenantID, blockerID, blockedID t.Uid) (*t.UserBlock, error) { + if !a.tenantScopedDB { + tenantAdp, err := a.routedTenantAdapter(id) + if err != nil { + return nil, err + } + if tenantAdp != a { + return tenantAdp.TenantUserBlockGet(id, blockerID, blockedID) + } + } if err := validTenant(id); err != nil || blockerID.IsZero() || blockedID.IsZero() { return nil, t.ErrMalformed } @@ -3295,14 +3933,13 @@ func (a *adapter) TenantUserBlockGet(id t.TenantID, blockerID, blockedID t.Uid) defer cancel() } row := struct { - TenantID t.TenantID `db:"tenant_id"` - BlockerID int64 `db:"blocker_id"` - BlockedID int64 `db:"blocked_id"` - CreatedAt time.Time `db:"created_at"` - UpdatedAt time.Time `db:"updated_at"` + BlockerID int64 `db:"blocker_id"` + BlockedID int64 `db:"blocked_id"` + CreatedAt time.Time `db:"created_at"` + UpdatedAt time.Time `db:"updated_at"` }{} - err := a.db.GetContext(ctx, &row, "SELECT * FROM im_user_blocks WHERE tenant_id=? AND blocker_id=? AND blocked_id=?", - id, decodeUID(blockerID), decodeUID(blockedID)) + err := a.db.GetContext(ctx, &row, "SELECT * FROM im_user_blocks WHERE blocker_id=? AND blocked_id=?", + decodeUID(blockerID), decodeUID(blockedID)) if err == sql.ErrNoRows { return nil, nil } @@ -3310,7 +3947,7 @@ func (a *adapter) TenantUserBlockGet(id t.TenantID, blockerID, blockedID t.Uid) return nil, err } return &t.UserBlock{ - TenantID: row.TenantID, + TenantID: id, BlockerID: store.EncodeUid(row.BlockerID), BlockedID: store.EncodeUid(row.BlockedID), CreatedAt: row.CreatedAt, @@ -3319,14 +3956,23 @@ func (a *adapter) TenantUserBlockGet(id t.TenantID, blockerID, blockedID t.Uid) } func (a *adapter) TenantUserBlockList(id t.TenantID, blockerID t.Uid, cursor string, limit int) ([]t.BlockListItem, string, error) { + if !a.tenantScopedDB { + tenantAdp, err := a.routedTenantAdapter(id) + if err != nil { + return nil, "", err + } + if tenantAdp != a { + return tenantAdp.TenantUserBlockList(id, blockerID, cursor, limit) + } + } if err := validTenant(id); err != nil || blockerID.IsZero() { return nil, "", t.ErrMalformed } if limit <= 0 || limit > 100 { limit = 50 } - where := []string{"b.tenant_id=?", "b.blocker_id=?"} - args := []any{id, decodeUID(blockerID)} + where := []string{"b.blocker_id=?"} + args := []any{decodeUID(blockerID)} if cursor != "" { parsed, err := strconv.ParseInt(cursor, 10, 64) if err != nil || parsed <= 0 { @@ -3342,7 +3988,7 @@ func (a *adapter) TenantUserBlockList(id t.TenantID, blockerID t.Uid, cursor str } rows, err := a.db.QueryxContext(ctx, `SELECT b.*,u.public,u.trusted FROM im_user_blocks AS b - LEFT JOIN users AS u ON u.tenant_id=b.tenant_id AND u.id=b.blocked_id + LEFT JOIN users AS u ON u.id=b.blocked_id WHERE `+strings.Join(where, " AND ")+` ORDER BY b.blocked_id DESC LIMIT ?`, args...) if err != nil { @@ -3353,11 +3999,10 @@ func (a *adapter) TenantUserBlockList(id t.TenantID, blockerID t.Uid, cursor str nextCursor := "" for rows.Next() { row := struct { - TenantID t.TenantID `db:"tenant_id"` - BlockerID int64 `db:"blocker_id"` - BlockedID int64 `db:"blocked_id"` - CreatedAt time.Time `db:"created_at"` - UpdatedAt time.Time `db:"updated_at"` + BlockerID int64 `db:"blocker_id"` + BlockedID int64 `db:"blocked_id"` + CreatedAt time.Time `db:"created_at"` + UpdatedAt time.Time `db:"updated_at"` Public any Trusted any }{} @@ -3371,7 +4016,7 @@ func (a *adapter) TenantUserBlockList(id t.TenantID, blockerID t.Uid, cursor str blockedID := store.EncodeUid(row.BlockedID) items = append(items, t.BlockListItem{ Block: t.UserBlock{ - TenantID: row.TenantID, + TenantID: id, BlockerID: store.EncodeUid(row.BlockerID), BlockedID: blockedID, CreatedAt: row.CreatedAt, @@ -3379,7 +4024,7 @@ func (a *adapter) TenantUserBlockList(id t.TenantID, blockerID t.Uid, cursor str }, User: &t.User{ ObjHeader: t.ObjHeader{Id: blockedID.String()}, - TenantID: row.TenantID, + TenantID: id, Public: common.FromJSON(row.Public), Trusted: common.FromJSON(row.Trusted), }, @@ -3392,6 +4037,15 @@ func (a *adapter) TenantUserBlockList(id t.TenantID, blockerID t.Uid, cursor str } func (a *adapter) TenantUserBlockUpsert(id t.TenantID, blockerID, blockedID t.Uid, now time.Time) (*t.UserBlock, error) { + if !a.tenantScopedDB { + tenantAdp, err := a.routedTenantAdapter(id) + if err != nil { + return nil, err + } + if tenantAdp != a { + return tenantAdp.TenantUserBlockUpsert(id, blockerID, blockedID, now) + } + } if err := validTenant(id); err != nil || blockerID.IsZero() || blockedID.IsZero() { return nil, t.ErrMalformed } @@ -3400,9 +4054,9 @@ func (a *adapter) TenantUserBlockUpsert(id t.TenantID, blockerID, blockedID t.Ui defer cancel() } _, err := a.db.ExecContext(ctx, - "INSERT INTO im_user_blocks(tenant_id,blocker_id,blocked_id,created_at,updated_at) VALUES(?,?,?,?,?) "+ + "INSERT INTO im_user_blocks(blocker_id,blocked_id,created_at,updated_at) VALUES(?,?,?,?) "+ "ON DUPLICATE KEY UPDATE updated_at=VALUES(updated_at)", - id, decodeUID(blockerID), decodeUID(blockedID), now, now) + decodeUID(blockerID), decodeUID(blockedID), now, now) if err != nil { return nil, err } @@ -3410,6 +4064,15 @@ func (a *adapter) TenantUserBlockUpsert(id t.TenantID, blockerID, blockedID t.Ui } func (a *adapter) TenantUserBlockDelete(id t.TenantID, blockerID, blockedID t.Uid) error { + if !a.tenantScopedDB { + tenantAdp, err := a.routedTenantAdapter(id) + if err != nil { + return err + } + if tenantAdp != a { + return tenantAdp.TenantUserBlockDelete(id, blockerID, blockedID) + } + } if err := validTenant(id); err != nil || blockerID.IsZero() || blockedID.IsZero() { return t.ErrMalformed } @@ -3417,12 +4080,21 @@ func (a *adapter) TenantUserBlockDelete(id t.TenantID, blockerID, blockedID t.Ui if cancel != nil { defer cancel() } - _, err := a.db.ExecContext(ctx, "DELETE FROM im_user_blocks WHERE tenant_id=? AND blocker_id=? AND blocked_id=?", - id, decodeUID(blockerID), decodeUID(blockedID)) + _, err := a.db.ExecContext(ctx, "DELETE FROM im_user_blocks WHERE blocker_id=? AND blocked_id=?", + decodeUID(blockerID), decodeUID(blockedID)) return err } func (a *adapter) TenantUserSearch(id t.TenantID, viewer t.Uid, queryText, searchType, cursor string, limit int) ([]t.User, string, error) { + if !a.tenantScopedDB { + tenantAdp, err := a.routedTenantAdapter(id) + if err != nil { + return nil, "", err + } + if tenantAdp != a { + return tenantAdp.TenantUserSearch(id, viewer, queryText, searchType, cursor, limit) + } + } if err := validTenant(id); err != nil || viewer.IsZero() { return nil, "", t.ErrMalformed } @@ -3433,8 +4105,8 @@ func (a *adapter) TenantUserSearch(id t.TenantID, viewer t.Uid, queryText, searc if limit <= 0 || limit > 50 { limit = 20 } - where := []string{"u.tenant_id=?", "u.id<>?", "u.state=?"} - args := []any{id, decodeUID(viewer), t.StateOK} + where := []string{"u.id<>?", "u.state=?"} + args := []any{decodeUID(viewer), t.StateOK} if cursor != "" { parsed, err := strconv.ParseInt(cursor, 10, 64) if err != nil || parsed <= 0 { @@ -3447,17 +4119,17 @@ func (a *adapter) TenantUserSearch(id t.TenantID, viewer t.Uid, queryText, searc switch searchType { case "", "account": like := "%" + queryText + "%" - join = "LEFT JOIN usertags AS ut ON ut.tenant_id=u.tenant_id AND ut.userid=u.id" + join = "LEFT JOIN usertags AS ut ON ut.userid=u.id" where = append(where, "(ut.tag LIKE ? OR CAST(u.public AS CHAR) LIKE ? OR u.id=?)") args = append(args, like, like, userIDNumericArg(queryText)) case "exact_account": - join = "LEFT JOIN usertags AS ut ON ut.tenant_id=u.tenant_id AND ut.userid=u.id" + join = "LEFT JOIN usertags AS ut ON ut.userid=u.id" where = append(where, "(ut.tag=? OR u.id=?)") args = append(args, queryText, userIDNumericArg(queryText)) case "phone": where = append(where, `EXISTS ( SELECT 1 FROM credentials AS c - WHERE c.tenant_id=u.tenant_id AND c.userid=u.id AND c.method='tel' AND c.value=? AND c.done=true + WHERE c.userid=u.id AND c.method='tel' AND c.value=? AND c.done=true )`) args = append(args, queryText) default: @@ -3502,20 +4174,19 @@ func userIDNumericArg(value string) int64 { } func (a *adapter) friendRequestByRequestID(ctx context.Context, tenantID t.TenantID, requestID string) (*t.FriendRequest, error) { - return a.friendRequestByQuery(ctx, "SELECT * FROM im_friend_requests WHERE tenant_id=? AND request_id=?", tenantID, requestID) + return a.friendRequestByQuery(ctx, tenantID, "SELECT * FROM im_friend_requests WHERE request_id=?", requestID) } func (a *adapter) friendRequestByIdempotency(ctx context.Context, tenantID t.TenantID, applicantID t.Uid, key string) (*t.FriendRequest, error) { - return a.friendRequestByQuery(ctx, "SELECT * FROM im_friend_requests WHERE tenant_id=? AND applicant_id=? AND idempotency_key=?", tenantID, decodeUID(applicantID), key) + return a.friendRequestByQuery(ctx, tenantID, "SELECT * FROM im_friend_requests WHERE applicant_id=? AND idempotency_key=?", decodeUID(applicantID), key) } -func (a *adapter) friendRequestByQuery(ctx context.Context, query string, args ...any) (*t.FriendRequest, error) { +func (a *adapter) friendRequestByQuery(ctx context.Context, tenantID t.TenantID, query string, args ...any) (*t.FriendRequest, error) { row := struct { ID int64 - TenantID t.TenantID `db:"tenant_id"` - RequestID string `db:"request_id"` - ApplicantID int64 `db:"applicant_id"` - TargetID int64 `db:"target_id"` + RequestID string `db:"request_id"` + ApplicantID int64 `db:"applicant_id"` + TargetID int64 `db:"target_id"` Message string Source string SourceTopic string `db:"source_topic"` @@ -3535,7 +4206,7 @@ func (a *adapter) friendRequestByQuery(ctx context.Context, query string, args . } return &t.FriendRequest{ ID: row.ID, - TenantID: row.TenantID, + TenantID: tenantID, RequestID: row.RequestID, ApplicantID: store.EncodeUid(row.ApplicantID), TargetID: store.EncodeUid(row.TargetID),