chore: disable runtime mysql schema creation
This commit is contained in:
@@ -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 鉴权、链路追踪、配置和部署是否顺畅。
|
||||
@@ -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
|
||||
|
||||
@@ -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')), '') = ''
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user