diff --git a/docs/IM-工作台服务拆分迁移稳定方案.md b/docs/IM-工作台服务拆分迁移稳定方案.md new file mode 100644 index 0000000..18facd5 --- /dev/null +++ b/docs/IM-工作台服务拆分迁移稳定方案.md @@ -0,0 +1,482 @@ +# IM 工作台服务拆分迁移稳定方案 + +> 目标:将 `im_server` 中的工作台业务拆到独立仓库 `/Users/youshunlai/go_workspace/im_workbench`,先形成独立服务和独立发布边界;数据库暂不迁移,等接口全部稳定后再单独做工作台库迁移。 + +## 1. 结论 + +建议采用“独立仓库 + 分阶段迁移 + Nginx 分流 + Redis 统一 Token 鉴权”的方式: + +1. `im_server` 保留 IM 核心能力:登录态校验、聊天协议、单聊/群聊、消息存储、长连接、在线状态、Redis 实时 fanout、Kafka 消息消费。 +2. `im_workbench` 承接工作台能力:工作台入口、组织架构、考勤、会议、会议笔记、日报、月报、总经理信箱、工作台红点。 +3. 当前阶段不迁移数据库,`im_workbench` 先连接现有租户库读取和写入工作台相关表。 +4. 等 `im_workbench` 接口、鉴权、Kafka、监控全部稳定后,再单独启动数据库迁移专项。 +5. 工作台接口直接切换为 `/v1/workbench/*`,由 Nginx/API Gateway 按路径分发到 `im_workbench`;`im_server` 不再代理工作台请求。 +6. 迁移采用“先复制迁出、旧代码保留”的方式;只有 `im_workbench` 全部接口、数据库、Kafka、监控联调通过后,才删除 `im_server` 中的工作台旧代码。 + +一句话:网关分流,Redis 鉴权,先拆服务和接口,数据库后迁,最后清理 `im_server` 中的工作台旧代码。 + +## 2. 拆分范围 + +### 2.1 迁出到 im_workbench + +以下业务从 `im_server` 迁移到 `im_workbench`: + +| 模块 | 当前接口示例 | 迁移归属 | +|---|---|---| +| 工作台入口 | `GET /v1/workbench` | im_workbench | +| 组织架构 | `/v1/workbench/organization/*` | im_workbench | +| 考勤打卡 | `/v1/workbench/attendance/*` | im_workbench | +| 会议通知 | `/v1/workbench/meetings*` | im_workbench | +| 会议笔记 | `/v1/workbench/meeting-notes`、`/v1/workbench/meetings/{id}/notes*` | im_workbench | +| 日报 | `/v1/workbench/reports/daily*` | im_workbench | +| 月报 | `/v1/workbench/reports/monthly*` | im_workbench | +| 总经理信箱 | `/v1/workbench/mailbox*` | im_workbench | +| 工作台红点 | `workbench_total_unread`、模块未读 | im_workbench 计算,im_server 聚合透出 | + +### 2.2 留在 im_server + +以下能力继续留在 `im_server`: + +| 模块 | 说明 | +|---|---| +| 登录与 Token 签发 | C 端登录仍由 IM 负责,登录成功后把 Token 会话写入 Redis。 | +| 聊天消息 | 单聊、群聊、消息 seq、消息落库、Redis fanout、WS 下发。 | +| 群治理 | 群资料、群成员、禁言、进群审批、群公告、群文件。 | +| 文件上传入口 | `POST /v0/file/u/` 暂留 im_server,工作台只保存业务引用;后续可单独拆文件服务。 | +| 系统通知投递 | 最终给 C 端的在线通知仍由 im_server 通过 WS 下发。 | +| Kafka 消费到 C 端 | Admin 或 Workbench 产生的业务事件,由 im_server 消费后找在线用户下发。 | + +## 3. 目标架构 + +```text +C 端 App + | + | HTTPS + v +Nginx / API Gateway + |-- /v1/im/*、/v0/channels、/v0/file/u/ -> im_server + |-- /v1/workbench/* -> im_workbench + +im_workbench + | + | MySQL + v +现有租户库中的工作台表 + +im_workbench + | + | Kafka: workbench 业务事件 + v +im_server + | + | WebSocket + v +C 端在线用户 +``` + +稳定边界: + +1. `im_workbench` 不直接操作 IM 消息表、在线连接、WebSocket。 +2. `im_server` 不直接写工作台业务表,也不代理工作台 HTTP 请求。 +3. `im_server` 登录成功后将 Token 会话写入 Redis,`im_workbench` 每次请求从 Redis 校验 Token。 +4. 两边共享用户 ID 口径:内部管理和工作台接口使用数据库 `BIGINT/int64`;聊天协议层仍可使用 `usr...`。 +5. 工作台需要通知 C 端时投 Kafka 事件,由 `im_server` 统一下发。 + +## 4. 数据库处理方案 + +### 4.0 当前阶段原则 + +当前阶段数据库先不迁移。`im_workbench` 独立服务启动后,仍访问现有租户库中的工作台相关表,先保证新服务接口、Redis Token 鉴权、Nginx 路由、Kafka 事件和监控全部跑通。 + +代码层面禁止服务启动时自动建库建表。`im_server` 和 `im_workbench` 都只负责连接已有数据库和执行正常业务 SQL;所有 `CREATE DATABASE`、`CREATE TABLE`、`ALTER TABLE`、索引变更、初始化字典和种子数据,统一整理为 SQL 文件,经审核后由人工或运维发布流程执行。 + +数据库迁移作为后置专项处理,触发条件是: + +1. `/v1/workbench/*` 全部接口在本地、测试环境、前端联调中稳定。 +2. `im_server` 中工作台旧接口确认没有客户端继续访问。 +3. 工作台事件、通知、红点、文件引用链路全部验收通过。 +4. 运维确认新库命名、备份、监控、账号权限和迁移窗口。 + +### 4.1 当前工作台相关表 + +当前这些表仍保留在租户库 `tinode` 中,暂不迁移;后续数据库专项时再纳入迁移范围: + +| 表 | 用途 | 迁移建议 | +|---|---|---| +| `im_workbench_apps` | 工作台模块配置、排序、入口 | 暂留租户库;后续迁到工作台库或由 Admin 配置后同步。 | +| `im_org_departments` | 组织部门树 | 暂留租户库;后续迁到工作台库。 | +| `im_org_members` | 组织成员、岗位、联系方式 | 暂留租户库;保留 `user_id BIGINT` 关联 IM 用户。 | +| `im_org_visibility_overrides` | 组织可见范围覆盖 | 暂留租户库。 | +| `im_attendance_rules` | 考勤规则 | 暂留租户库。 | +| `im_attendance_shifts` | 考勤班次 | 暂留租户库。 | +| `im_attendance_records` | 考勤日记录 | 暂留租户库。 | +| `im_attendance_checkins` | 打卡流水 | 暂留租户库。 | +| `im_attendance_appeals` | 补卡/异常申诉 | 暂留租户库。 | +| `im_meetings` | 会议主表 | 暂留租户库。 | +| `im_meeting_participants` | 会议参与人 | 暂留租户库。 | +| `im_meeting_note_files` | 会议笔记文件引用 | 暂留租户库;文件实体仍在文件表/OSS。 | +| `im_daily_reports` | 日报 | 暂留租户库。 | +| `im_monthly_reports` | 月报 | 暂留租户库。 | +| `im_mailbox_config` | 总经理信箱配置 | 暂留租户库;后续可迁到工作台库或 Admin 公共库配置后同步。 | +| `im_mailbox_submissions` | 总经理信箱提交 | 暂留租户库。 | + +### 4.2 不迁出的表 + +| 表/数据 | 原因 | +|---|---| +| `users`、认证、会话、设备 | 属于 IM 登录和账号基础能力。 | +| `topics`、`subscriptions`、`messages`、`im_group_messages` | 属于聊天主链路。 | +| 好友、黑名单、群成员、群治理表 | 属于 IM 社交关系和群聊能力。 | +| 文件实体表、OSS 元数据 | 暂不迁移,工作台只保存 `file_id` 引用。 | +| `im_system_notifications` | 建议先留在 im_server,作为统一 C 端通知中心。后续如要拆,可单独设计通知服务。 | +| `im_business_events_outbox` | 已废弃,不纳入迁移;当前代码不再自动建表,工作台拆分后事件统一走 Kafka 或实时投递链路。 | + +### 4.3 后续目标库建议 + +后续接口稳定后,再给工作台单独库,命名按环境区分: + +```text +本地:im_workbench +测试:im_workbench_test 或按运维规范命名 +生产:im_workbench +``` + +如果继续保持“一个租户一个库”,则工作台也应支持租户库路由: + +```text +hotgo.im_tenant + - code + - im_db_name + - workbench_db_name + - im_internal_base_url + - workbench_internal_base_url +``` + +正式方向建议在 `hotgo.im_tenant` 或租户配置表里增加工作台库配置,而不是让 `im_server` 读取工作台库 DSN。 + +### 4.4 后续数据迁移顺序 + +迁库等接口全部稳定后再做,不要和本轮服务拆分混在一起。后续建议分四步: + +1. 建新库新表:在 `im_workbench` 库手动执行审核后的 SQL,创建同结构表,先不接线上流量。 +2. 全量复制:从租户库复制历史工作台表数据到工作台库。 +3. 增量同步:迁移窗口内对工作台写操作做双写,或由旧库 binlog/脚本同步到新库。 +4. 切读切写:先灰度读新库,再切写新库,最后关闭旧库工作台写入。 + +迁移校验至少包括: + +| 校验项 | 说明 | +|---|---| +| 行数校验 | 每张表按租户统计行数。 | +| 主键/业务唯一键校验 | `meeting_id`、`report_id`、`submission_id`、`department_id` 等必须一致。 | +| 更新时间校验 | 对比 `updated_at` 最大值,确认增量无遗漏。 | +| 抽样内容校验 | 抽样会议、日报、月报、考勤记录详情。 | +| 外键引用校验 | 会议参与人、会议笔记文件、组织成员部门引用不能断。 | + +### 4.5 回滚原则 + +当前阶段没有数据库迁移,回滚以服务和路由为主。后续真正迁库时,旧库数据至少保留一个发版周期,不删除旧表。 + +回滚策略: + +1. 如果服务拆分异常:关闭 `/v1/workbench/*` 路由,前端回退旧路径或旧版本。 +2. 如果 `im_workbench` 业务异常:保留 `im_server` 旧工作台代码作为临时回退能力。 +3. 如果 Redis 鉴权异常:停止工作台新路由,优先恢复登录态校验。 +4. 如果 Kafka 通知异常:业务写入不回滚,只降级为客户端主动拉取和后台补偿推送。 +5. 后续如果新库读写异常:再按迁库专项方案切回旧库。 + +## 5. 路由与鉴权迁移方案 + +### 5.1 Nginx 分流规则 + +工作台接口直接使用新路径 `/v1/workbench/*`,Nginx/API Gateway 按路径分流: + +```text +/v1/im/* -> im_server +/v0/channels -> im_server +/v0/file/u/ -> im_server +/v1/workbench/* -> im_workbench +``` + +建议工作台目标路由: + +| 原 IM 路径 | 新 Workbench 路径 | +|---|---| +| `GET /v1/im/workbench` | `GET /v1/workbench` | +| `/v1/im/organization/*` | `/v1/workbench/organization/*` | +| `/v1/im/attendance/*` | `/v1/workbench/attendance/*` | +| `/v1/im/meetings*` | `/v1/workbench/meetings*` | +| `/v1/im/meeting-notes*` | `/v1/workbench/meeting-notes*` | +| `/v1/im/meetings/{id}/notes*` | `/v1/workbench/meetings/{id}/notes*` | +| `/v1/im/reports/daily*` | `/v1/workbench/reports/daily*` | +| `/v1/im/reports/monthly*` | `/v1/workbench/reports/monthly*` | +| `/v1/im/mailbox*` | `/v1/workbench/mailbox*` | + +不建议让 `im_server` 继续代理工作台请求。路由由 Nginx 分流,服务各自鉴权、各自访问自己的数据库。 + +### 5.2 Redis Token 鉴权 + +登录仍由 `im_server` 负责。登录成功后,`im_server` 将 Token 会话写入 Redis,`im_workbench` 直接从 Redis 校验登录态。 + +Redis Key 建议: + +```text +im:session:token:{token_hash} +``` + +Value 建议使用 JSON: + +```json +{ + "user_id": 42, + "tenant_id": 1, + "tenant_code": "default", + "device_id": "ios-device-1", + "auth_level": "auth", + "state": "active", + "expires_at": "2026-09-13T18:00:00+08:00", + "issued_at": "2026-09-13T10:00:00+08:00" +} +``` + +请求 Header 保持 C 端统一: + +| Header | 说明 | +|---|---| +| `Authorization` | `Bearer {token}`,两个服务都从 Redis 校验。 | +| `X-MeetingIM-APIKey` | 应用 API Key。 | +| `X-Tenant-Code` | 租户编码。 | +| `X-IM-Schema` | 协议版本。 | +| `X-Request-ID` | 链路追踪 ID。 | + +校验流程: + +1. 从 `Authorization` 取 Bearer Token,先做格式校验。 +2. 对 Token 做 hash 后查 Redis,避免 Redis 明文 Key 暴露原始 Token。 +3. 校验会话存在、未过期、`state=active`。 +4. 校验 `X-Tenant-Code` 与 Redis 会话中的 `tenant_code` 一致。 +5. 校验 API Key 是否可用于当前租户和客户端类型。 +6. 将 `tenant_id`、`tenant_code`、`user_id`、`device_id` 写入请求上下文。 + +Token 失效场景: + +| 场景 | 处理 | +|---|---| +| 用户主动退出 | 删除当前 Token Key。 | +| 踢设备 | 删除指定 `device_id` 对应 Token Key。 | +| 冻结/封禁账号 | 删除用户全部 Token Key,并通过 Kafka 通知在线连接断开。 | +| 修改密码 | 删除用户全部 Token Key。 | +| Token 过期 | Redis TTL 自动过期。 | + +### 5.3 共享鉴权代码 + +建议抽一个轻量公共包,两个服务复用: + +```text +meetingim_common/auth/session +``` + +公共包只放稳定协议: + +1. Redis Key 生成。 +2. Token hash 规则。 +3. Session JSON 结构。 +4. Header 解析。 +5. 错误码定义。 + +不要把数据库访问、HTTP handler、业务权限放进公共包,避免公共包变成新的耦合点。 + +## 6. 事件与通知 + +工作台拆出后,工作台业务变更不再直接调用 `im_server` 代码函数,而是投 Kafka。 + +建议 Topic: + +| Topic | 生产者 | 消费者 | 用途 | +|---|---|---|---| +| `im.workbench.event` | im_workbench | im_server | 工作台事件、红点、系统通知、WS 下发。 | +| `im.cache.invalidate` | im_workbench/Admin | im_server/im_workbench | 组织、工作台模块、配置变更后的缓存失效。 | +| `im.audit.event` | im_workbench | Admin/审计消费者 | 工作台敏感操作审计。 | + +`im.workbench.event` 消息建议字段: + +```json +{ + "event_id": "wb_default_meeting_mtg_1_created_v1", + "tenant_id": 1, + "tenant_code": "default", + "actor_user_id": 42, + "module": "meeting", + "event_type": "meeting.created", + "resource_id": "mtg_1", + "target_user_ids": [42, 43], + "title": "会议通知", + "content": "项目周会", + "badge_delta": 1, + "payload": { + "meeting_id": "mtg_1", + "version": 1 + }, + "occurred_at": "2026-09-13T13:30:00+08:00" +} +``` + +消费原则: + +1. `event_id` 全局幂等,消费端记录处理日志。 +2. 系统通知写入失败可重试,超过次数进入死信。 +3. WS 下发失败不影响业务写入,客户端重连后通过工作台接口和 bootstrap 补偿。 +4. 工作台红点以服务端查询为准,Kafka 只做实时增量提醒。 + +## 7. 代码迁移步骤 + +### 7.1 第一阶段:服务骨架 + +在 `/Users/youshunlai/go_workspace/im_workbench` 新建 Go 服务: + +```text +im_workbench/ + cmd/workbench/ + internal/http/ + internal/store/ + internal/kafka/ + internal/config/ + docs/ + sql/ +``` + +第一阶段只做: + +1. 配置文件、日志、MySQL、Redis、Kafka 初始化。 +2. 健康检查:`GET /healthz`、`GET /readyz`。 +3. Redis Token 鉴权中间件。 +4. 租户上下文解析。 +5. 复制工作台接口 DTO 和响应结构。 + +### 7.2 第二阶段:迁移业务代码 + +从 `im_server` 迁出以下代码: + +| 当前位置 | 迁移内容 | +|---|---| +| `server/im_http.go` | `serveIMWorkbench`、Organization、Attendance、Meetings、MeetingNotes、DailyReports、MonthlyReports、Mailbox 相关 handler。 | +| `server/store/store.go` | 工作台相关 Persistence Interface 和 mapper。 | +| `server/store/types/*.go` | `workbench.go`、`organization.go`、`attendance.go`、`meeting.go`、`reports.go`、`mailbox.go` 等类型。 | +| `server/db/mysql/tenant_adapter.go` | 工作台相关 SQL 实现。 | +| `server/im_business_events.go` | 工作台事件生产逻辑拆到 `im_workbench`,只保留 IM 消费下发。 | +| `server/kafka_workbench_audit.go` | 工作台事件生产侧迁出,IM 侧保留消费或下发处理。 | + +迁移时要保留测试: + +1. 原 handler 单测迁到 `im_workbench`。 +2. `im_server` 原有工作台测试暂时保留,作为旧链路回归保护。 +3. `im_workbench` 新增同等接口测试,确认新路径行为一致。 +4. Kafka 事件用例拆为 Workbench 生产端测试和 IM 消费端测试。 + +### 7.3 第三阶段:Nginx 路由切换 + +`im_server` 中原工作台 handler 暂时不删除,不做主链路代理。新请求由 Nginx/API Gateway 直接分流到 `im_workbench`: + +```text +客户端请求 /v1/workbench + -> Nginx/API Gateway + -> im_workbench Redis Token 鉴权 + -> return response +``` + +路由层必须支持: + +1. `/v1/workbench/*` 全量转发到 `im_workbench`。 +2. `/v1/im/*`、`/v0/channels`、`/v0/file/u/` 继续转发到 `im_server`。 +3. 透传 `Authorization`、`X-MeetingIM-APIKey`、`X-Tenant-Code`、`X-IM-Schema`、`X-Request-ID`。 +4. 工作台服务超时建议 3 到 5 秒。 +5. 工作台不可用时返回明确 `WORKBENCH_UNAVAILABLE` 或网关 502/504,不影响聊天主链路。 + +旧代码保留策略: + +1. `im_server` 工作台旧 handler、store、SQL 先保留。 +2. 前端切新路径后,测试环境持续观察一个发版周期。 +3. `im_workbench` 全部接口、数据库、Kafka、监控确认稳定后,再从 `im_server` 删除旧工作台代码。 +4. 删除前要确保没有客户端继续访问 `/v1/im/workbench`、`/v1/im/meetings*` 等旧路径。 + +### 7.4 第四阶段:接口稳定验收 + +当前阶段只做接口稳定验收,不做数据库切换。建议保留以下服务开关: + +| 配置 | 说明 | +|---|---| +| `workbench.service.enabled` | 是否启用工作台独立服务。 | +| `workbench.route.enabled` | Nginx/API Gateway 是否启用 `/v1/workbench/*` 路由。 | +| `workbench.kafka.enabled` | 是否启用工作台事件 Kafka。 | + +后续迁库专项再增加数据库切换开关: + +```text +workbench.db.read_from=old|new|shadow +workbench.db.write_to=old|dual|new +``` + +## 8. 发布与回归 + +### 8.1 发布前检查 + +1. `im_workbench` 健康检查通过。 +2. `im_workbench` 可连接现有租户库,并只访问工作台相关表。 +3. Redis Token 鉴权与 `im_server` 登录态一致。 +4. Nginx/API Gateway 工作台路由灰度开关默认关闭或仅测试环境开放。 +5. Kafka Topic、消费组、死信队列已建。 +6. 监控已覆盖 HTTP 成功率、耗时、Kafka 积压、DB 错误、网关转发失败。 +7. 本阶段不要求新工作台库建表和数据复制。 + +### 8.2 必测用例 + +| 模块 | 用例 | +|---|---| +| 工作台入口 | 登录后返回模块排序、开关、未读角标。 | +| 组织架构 | 普通用户、手机号注册用户、邀请用户、测试账号可见范围正确。 | +| 考勤 | 配置查询、打卡、重复幂等、记录列表、申诉创建。 | +| 会议 | 创建、列表、详情、修改、取消、提醒、参会响应。 | +| 会议笔记 | 上传文件、绑定文件、详情、替换、删除、权限校验。 | +| 日报 | 创建草稿、保存、提交、列表、统计、提醒。 | +| 月报 | 创建、保存、提交、reopen、详情、统计。 | +| 总经理信箱 | 配置查询、匿名/实名提交、附件引用。 | +| 通知 | 工作台事件生成系统通知,在线用户收到 WS,离线用户重登可补偿。 | +| 回滚 | 关闭 `/v1/workbench/*` 路由后,前端回退旧版本或网关明确降级;旧代码未删前可切回旧路径。 | + +## 9. 风险点 + +| 风险 | 处理 | +|---|---| +| 一次性拆代码和迁库导致回归面过大 | 本阶段不迁库,只拆服务和接口;数据库等接口稳定后单独迁移。 | +| 工作台依赖 IM 用户资料 | 通过 IM 内部查询 API 获取用户摘要,禁止直连 IM 库。 | +| IM 需要工作台红点 | 通过工作台内部接口或 Kafka 事件聚合,禁止 im_server 查工作台库。 | +| 文件上传仍在 im_server | 工作台保存 `file_id` 引用,文件服务后续再独立拆。 | +| Kafka 事件丢失 | 生产端直接投 Kafka,必要时使用业务失败表补偿;消费端使用幂等和死信队列。 | +| 前端路径切换风险 | 前端切到 `/v1/workbench/*` 前必须完成接口对照、灰度和回归。 | +| 数据不一致 | 当前阶段继续读写同一套租户库,避免双写不一致;后续迁库再做影子读和校验。 | + +## 10. 推荐落地顺序 + +1. 在 `im_workbench` 建服务骨架、配置、健康检查、Redis Token 鉴权。 +2. 迁移工作台类型、store interface、MySQL 实现和单测。 +3. 迁移 `/v1/workbench`、组织架构、考勤接口。 +4. 迁移会议、会议笔记、日报、月报、总经理信箱接口。 +5. Nginx/API Gateway 增加 `/v1/workbench/*` 路由,默认关闭或只对测试环境开放。 +6. 本地环境用新路径跑通全部工作台接口。 +7. 测试环境启用新路由灰度,只切少量测试账号。 +8. 全量接口稳定后,确认没有客户端访问旧 `/v1/im/*` 工作台路径。 +9. 清理 `im_server` 内工作台业务实现,只保留 IM 主链路。 +10. 单独启动数据库迁移专项,新建工作台库、复制数据、影子读、切读写。 + +## 11. 当前建议先做的第一步 + +第一步不要急着删 `im_server` 代码,建议先在 `im_workbench` 完成最小可运行服务: + +1. 监听端口建议:`7070`。 +2. 支持 `GET /healthz`、`GET /readyz`。 +3. 支持 Redis Token 鉴权和统一 Header 校验。 +4. 接入本地 MySQL、Redis、Kafka 配置。 +5. 先迁 `GET /v1/workbench` 一个读接口做样板。 +6. Nginx/API Gateway 加 `/v1/workbench` 测试路由,只开放样板接口。 + +这样第一步上线风险最低,也能验证服务拆分、Redis 鉴权、链路追踪、配置和部署是否顺畅。 diff --git a/server/db/mysql/adapter.go b/server/db/mysql/adapter.go index 096acf4..e633dcc 100644 --- a/server/db/mysql/adapter.go +++ b/server/db/mysql/adapter.go @@ -290,50 +290,6 @@ func (a *adapter) Open(jsonconfig json.RawMessage) error { return errors.New("mysql adapter failed to open tenant registry database: " + err.Error()) } } - if a.registryDB != nil || !databaseMissing { - controlDB := a.db - if a.registryDB != nil { - controlDB = a.registryDB - } - if err = ensureIMRegistrationInviteControlTables(controlDB); err != nil { - return errors.New("mysql adapter failed to ensure registration invite control schema: " + err.Error()) - } - if err = ensureIMTenantEntryControlTables(controlDB); err != nil { - return errors.New("mysql adapter failed to ensure tenant entry control schema: " + err.Error()) - } - } - if !databaseMissing { - if err = ensureIMGroupBusinessTables(a.db); err != nil { - return errors.New("mysql adapter failed to ensure group business schema: " + err.Error()) - } - if err = ensureIMHTTPInfrastructureTables(a.db); err != nil { - return errors.New("mysql adapter failed to ensure HTTP infrastructure schema: " + err.Error()) - } - if err = ensureIMAdminGovernanceTables(a.db); err != nil { - return errors.New("mysql adapter failed to ensure admin governance schema: " + err.Error()) - } - if err = ensureIMFileReviewTables(a.db); err != nil { - return errors.New("mysql adapter failed to ensure file review schema: " + err.Error()) - } - if err = ensureIMOrganizationVisibilityTables(a.db); err != nil { - return errors.New("mysql adapter failed to ensure organization visibility schema: " + err.Error()) - } - if err = ensureIMAttendanceTables(a.db); err != nil { - return errors.New("mysql adapter failed to ensure attendance schema: " + err.Error()) - } - if err = ensureIMMeetingNotesTables(a.db); err != nil { - return errors.New("mysql adapter failed to ensure meeting notes schema: " + err.Error()) - } - if err = ensureSnowflakePrimaryKeys(a.db); err != nil { - return errors.New("mysql adapter failed to ensure snowflake primary keys: " + err.Error()) - } - if err = ensureUserIdentitySchema(a.db); err != nil { - return errors.New("mysql adapter failed to ensure user identity schema: " + err.Error()) - } - if err = ensureIMRegistrationInviteUseTable(a.db); err != nil { - return errors.New("mysql adapter failed to ensure registration invite usage schema: " + err.Error()) - } - } } return err } @@ -544,46 +500,6 @@ func (a *adapter) TenantAdapterFor(tenantID t.TenantID) (dbadapter.TenantBusines db.Close() return nil, nil, err } - if err = ensureIMGroupBusinessTables(db); err != nil { - db.Close() - return nil, nil, err - } - if err = ensureIMHTTPInfrastructureTables(db); err != nil { - db.Close() - return nil, nil, err - } - if err = ensureIMAdminGovernanceTables(db); err != nil { - db.Close() - return nil, nil, err - } - if err = ensureIMFileReviewTables(db); err != nil { - db.Close() - return nil, nil, err - } - if err = ensureIMOrganizationVisibilityTables(db); err != nil { - db.Close() - return nil, nil, err - } - if err = ensureIMAttendanceTables(db); err != nil { - db.Close() - return nil, nil, err - } - if err = ensureIMMeetingNotesTables(db); err != nil { - db.Close() - return nil, nil, err - } - if err = ensureSnowflakePrimaryKeys(db); err != nil { - db.Close() - return nil, nil, err - } - if err = ensureUserIdentitySchema(db); err != nil { - db.Close() - return nil, nil, err - } - if err = ensureIMRegistrationInviteUseTable(db); err != nil { - db.Close() - return nil, nil, err - } registryDB := a.registryDB if registryDB == nil { registryDB = a.db diff --git a/server/db/mysql/group_schema.go b/server/db/mysql/group_schema.go index 54c60a6..8e94e3f 100644 --- a/server/db/mysql/group_schema.go +++ b/server/db/mysql/group_schema.go @@ -157,16 +157,6 @@ func ensureIMGroupBusinessTables(exec sqlExecutor) error { PRIMARY KEY(id), UNIQUE INDEX uk_im_group_idempotency_request(user_id, method, route, idempotency_key), INDEX idx_im_group_idempotency_created(created_at) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`, - `CREATE TABLE IF NOT EXISTS im_group_events_outbox ( - id BIGINT NOT NULL AUTO_INCREMENT, event_id VARCHAR(64) NOT NULL, topic VARCHAR(32) NOT NULL, - actor_user_id BIGINT NOT NULL DEFAULT 0, event_type VARCHAR(64) NOT NULL, payload JSON, - status VARCHAR(16) NOT NULL DEFAULT 'pending', attempts INT NOT NULL DEFAULT 0, - next_retry_at DATETIME(3), delivered_at DATETIME(3), - created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), - updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), - PRIMARY KEY(id), UNIQUE INDEX uk_im_group_events_outbox_event(event_id), - INDEX idx_im_group_events_outbox_pending(status, next_retry_at, id), INDEX idx_im_group_events_outbox_topic(topic, id DESC) - ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`, `UPDATE im_topics SET public=JSON_SET(public, '$.fn', JSON_UNQUOTE(JSON_EXTRACT(public, '$.name'))) WHERE name LIKE 'grp%' AND JSON_VALID(public) AND COALESCE(JSON_UNQUOTE(JSON_EXTRACT(public, '$.fn')), '') = '' diff --git a/server/db/mysql/group_schema_test.go b/server/db/mysql/group_schema_test.go index 21b61a3..3b684d1 100644 --- a/server/db/mysql/group_schema_test.go +++ b/server/db/mysql/group_schema_test.go @@ -22,8 +22,8 @@ func TestEnsureIMGroupBusinessTablesUsesSelectedTenantDatabase(t *testing.T) { if err := ensureIMGroupBusinessTables(exec); err != nil { t.Fatal(err) } - if len(exec.statements) != 14 { - t.Fatalf("executed %d group schema statements, want 14", len(exec.statements)) + if len(exec.statements) != 13 { + t.Fatalf("executed %d group schema statements, want 13", len(exec.statements)) } for _, statement := range exec.statements { if strings.Contains(strings.ToLower(statement), "tinode.") || strings.Contains(strings.ToLower(statement), "create database") { @@ -31,6 +31,9 @@ func TestEnsureIMGroupBusinessTablesUsesSelectedTenantDatabase(t *testing.T) { } } joined := strings.ToLower(strings.Join(exec.statements, "\n")) + if strings.Contains(joined, "im_group_events_outbox") { + t.Fatal("group event outbox table must not be created") + } for _, expected := range []string{ "primary key(id), unique index uk_im_group_settings_topic(topic)", "primary key(id), unique index uk_im_group_announcement_reads_announcement_user(announcement_id, user_id)", diff --git a/server/db/mysql/report_group_regression_test.go b/server/db/mysql/report_group_regression_test.go index 9002107..4b6a76b 100644 --- a/server/db/mysql/report_group_regression_test.go +++ b/server/db/mysql/report_group_regression_test.go @@ -55,6 +55,66 @@ func TestMessageSearchDoesNotClearScannedHead(t *testing.T) { } } +func TestRuntimeOpenDoesNotAutoCreateOrMigrateTables(t *testing.T) { + source, err := os.ReadFile("adapter.go") + if err != nil { + t.Fatal(err) + } + body := string(source) + for _, tc := range []struct { + name string + function string + end string + }{ + { + name: "Open", + function: "func (a *adapter) Open", + end: "\nfunc (a *adapter) Close", + }, + { + name: "TenantAdapterFor", + function: "func (a *adapter) TenantAdapterFor", + end: "\nfunc (a *adapter) tenantByID", + }, + } { + fn := sourceBetween(t, body, tc.function, tc.end) + for _, forbidden := range []string{ + "ensureIMRegistrationInviteControlTables", + "ensureIMTenantEntryControlTables", + "ensureIMGroupBusinessTables", + "ensureIMHTTPInfrastructureTables", + "ensureIMAdminGovernanceTables", + "ensureIMFileReviewTables", + "ensureIMOrganizationVisibilityTables", + "ensureIMAttendanceTables", + "ensureIMMeetingNotesTables", + "ensureSnowflakePrimaryKeys", + "ensureUserIdentitySchema", + "ensureIMRegistrationInviteUseTable", + "CREATE DATABASE", + "CREATE TABLE", + "ALTER TABLE", + } { + if strings.Contains(fn, forbidden) { + t.Fatalf("%s must not auto-create or auto-migrate schema; found %q", tc.name, forbidden) + } + } + } +} + +func sourceBetween(t *testing.T, source, startMarker, endMarker string) string { + t.Helper() + start := strings.Index(source, startMarker) + if start < 0 { + t.Fatalf("missing source marker %q", startMarker) + } + end := strings.Index(source[start:], endMarker) + if end < 0 { + t.Fatalf("missing source marker %q after %q", endMarker, startMarker) + } + return source[start : start+end] +} + func TestSearchPaginationCursorUsesLastReturnedRow(t *testing.T) { source, err := os.ReadFile("tenant_adapter.go") if err != nil {