diff --git a/docs/IM-Kafka接入方案.md b/docs/IM-Kafka接入方案.md index b57668f..9b850cc 100644 --- a/docs/IM-Kafka接入方案.md +++ b/docs/IM-Kafka接入方案.md @@ -170,13 +170,12 @@ Admin 内部配置 API 只返回 IM 运行需要的字段,不返回后台管 | `im.admin.command.result` | IM 回传命令执行结果。 | | `im.config.event` | Admin 发给 IM 的配置、字典、租户状态变更。 | | `im.business.event` | IM 内部或 Admin 触发的 C 端业务事件。 | -| `im.message.persist` | 私聊、群聊消息异步落库。 | +| `im.message.persist` | 私聊消息异步落库。 | +| `im.group.message.persist` | 群聊消息异步落库。 | | `im.message.push` | 离线 Push 任务。 | | `im.file.security` | 文件内容安全、恶意文件检测任务。 | -| `im.file.lifecycle` | 文件引用绑定、释放、回收、合规删除。 | -| `im.workbench.event` | 会议、日报、月报、信箱、工作台红点事件。 | | `im.cache.invalidate` | 跨节点缓存失效。 | -| `im.audit.event` | 审计事件。 | +| `im.audit.event` | IM 与 Admin/HotGo 的跨系统审计事件。 | | `im.dead.letter` | 消费失败死信。 | 首版可合并部分 Topic,但事件体必须保留 `biz_type` 和 `event_type`,方便后续拆分。 @@ -418,19 +417,19 @@ IM 调 Admin 配置接口时,Admin 只校验服务身份和接口白名单, ### 9.1 文件 -文件上传下载不走 Kafka,OSS 是文件字节流主链路。Kafka 只处理上传完成后的事件: +文件上传下载不走 Kafka,OSS 是文件字节流主链路。当前只有消息落库使用 Kafka;文件生命周期改为同步处理: -1. `im.file.security`:内容安全、恶意文件检测。 -2. `im.file.lifecycle`:业务引用绑定、释放、临时文件回收、合规删除。 -3. `im.business.event`:文件审核失败、合规处置等 C 端通知。 +1. `im.file.security`:仍可用于内容安全、恶意文件检测。 +2. 消息附件引用绑定在消息落库 consumer 内同步执行。 +3. 群文件登记/删除由对应 HTTP 业务接口同步落库。 ### 9.2 工作台 -工作台查询不走 Kafka,状态变化和提醒走 Kafka: +工作台查询不走 Kafka,状态变化和提醒当前也改为同步处理: -1. 会议创建、更新、取消、提醒、响应:`im.workbench.event`。 -2. 日报/月报提交、撤回、重开、催办:`im.workbench.event`。 -3. 总经理信箱提交、处理结果:`im.workbench.event` + `im.audit.event`。 +1. 会议创建、更新、取消、提醒、响应:业务接口同步生成通知/红点事件。 +2. 日报/月报提交、撤回、重开、催办:业务接口同步生成通知/红点事件。 +3. 总经理信箱提交、处理结果:业务接口同步处理。 4. 工作台模块配置变更:`im.config.event` 或 `im.cache.invalidate`。 ## 10. 首批落地建议 @@ -490,8 +489,8 @@ IM Server 侧只访问租户业务库,负责执行 Admin Command、维护租 | 配置消费 | 消费 `im.config.event`,刷新进程内缓存或本地副本。 | 租户配置、字典、第三方配置生效。 | | 配置兜底查询 | 调 Admin `/internal/im-config/*`。 | IM 启动预热和缓存缺失恢复。 | | 内部只读 API | 提供 `/internal/admin-query/*`。 | Admin 实时查询租户业务数据。 | -| 文件任务 | 上传完成后生产/消费 `im.file.security`、`im.file.lifecycle`。 | 文件审核、引用绑定、回收。 | -| 消息异步落库 | 接入 `im.message.persist`。 | 私聊/群聊消息削峰落库。 | +| 文件任务 | 上传完成、附件绑定、群文件登记/删除同步处理。 | 文件审核、引用绑定、回收。 | +| 消息异步落库 | 接入 `im.message.persist`、`im.group.message.persist`。 | 私聊/群聊消息削峰落库。 | | Push 任务 | 生产 `im.message.push`。 | 离线 Push 异步投递。 | | 死信处理 | 写失败表、投 `im.dead.letter`、Command 死信回传 Result。 | 失败可诊断、可补偿。 | | 内部鉴权 | 校验 Admin 调用 `/internal/admin-query/*` 的签名、权限和审计。 | 保护租户业务查询。 | @@ -723,7 +722,7 @@ IM Server 已接入 `im.message.persist`,用于把在线发送链路和最终 当前处理方式: 1. WS/HTTP 发送入口先完成权限校验,并由 topic 内存态分配 `seq_id`。 -2. Kafka 开启时,发送入口直接生产 `im.message.persist`,不查库、不落库、不写 DB outbox;客户端在 Kafka 写入成功后拿到已分配的 `seq_id`。 +2. Kafka 开启时,私聊发送入口直接生产 `im.message.persist`,群聊发送入口直接生产 `im.group.message.persist`,不查库、不落库、不写 DB outbox;客户端在 Kafka 写入成功后拿到已分配的 `seq_id`。 3. Kafka 关闭时,继续走原同步落库路径,兼容本地和未接 Kafka 环境。 4. 消费者按 `message_kind` 区分私聊与群聊: - `p2p` 写入 `im_messages`。 @@ -833,7 +832,7 @@ IM Server 已在 `im.admin.command` 中接入用户治理类后台动作。Admin 3. 踢设备只影响匹配 `device_id` 的在线连接,同时删除该设备 Push token 记录。 4. 全端登出只踢在线连接,不修改用户状态。 5. 注销/删除会停止用户相关 Topic、清理缓存并调用 `store.Users.Delete`。 -6. 命令执行成功后统一回传 `im.admin.command.result`,并写入 `im.audit.event`。 +6. 命令执行成功后统一回传 `im.admin.command.result`;跨系统审计通过 `im.audit.event` 投递给 Admin/HotGo 同步落表。 7. 群消息消费成功后继续写入 Redis 热消息缓存,便于 24 小时内热点拉取。 @@ -880,29 +879,29 @@ IM Server 不再维护租户库 `im_kafka_outbox`,Kafka 事件统一由发送 本轮暂不实现 `im.message.push` 离线 Push Kafka 化,已记录到 [IM 第一至十一章后续增强项](./IM-未实现后续增强项.md)。 -### 11.10 IM Server 已接入的文件生命周期事件 +### 11.10 IM Server 文件生命周期同步处理 -IM Server 已接入 `im.file.lifecycle` 的首批生产与消费,用于把文件上传、附件绑定、群文件登记/删除纳入可靠事件链路。 +IM Server 不再生产或消费 `im.file.lifecycle`。文件相关的小流量动作改为同步执行。 当前事件: | `event_type` | 生产时机 | 消费行为 | | --- | --- | --- | -| `file.upload.completed` | HTTP/gRPC 上传成功并完成文件记录后。 | 当前作为可靠生命周期事实记录,文件安全任务仍由上传完成事务创建。 | -| `file.attachments.bind` | `im.message.persist` 消费成功后,如果消息携带附件。 | 私聊写 `im_filemsglinks`;群聊写 `im_group_files`。 | -| `group.file.created` | 群文件 API 登记成功后。 | 当前作为可靠生命周期事实记录,供后续 Admin 摘要、审计、回收策略复用。 | -| `group.file.deleted` | 群文件 API 删除成功后。 | 当前作为可靠生命周期事实记录,供后续 Admin 摘要、审计、回收策略复用。 | +| `file.upload.completed` | HTTP/gRPC 上传成功并完成文件记录后。 | 当前无需 Kafka 消费。 | +| `file.attachments.bind` | 消息落库 consumer 成功后,如果消息携带附件。 | 同步写 `im_filemsglinks` 或 `im_group_files`。 | +| `group.file.created` | 群文件 API 登记成功后。 | HTTP 业务接口同步完成。 | +| `group.file.deleted` | 群文件 API 删除成功后。 | HTTP 业务接口同步完成。 | 说明: -1. Kafka 开启时,消息附件绑定从消息落库消费者中拆出,改由 `im.file.lifecycle` 消费者完成。 -2. Kafka 关闭时,仍沿用原同步绑定路径,兼容本地环境。 +1. 消息附件绑定在消息落库 consumer 内同步完成。 +2. Kafka 关闭时,仍沿用原同步消息落库与附件绑定路径,兼容本地环境。 3. 文件对象字节流仍走 OSS/文件服务,Kafka 不承载上传/下载内容。 4. 合规删除、临时文件 TTL 回收、OSS 删除动作仍保留为后续深化项。 -### 11.11 IM Server 已接入的工作台事件 +### 11.11 IM Server 工作台事件同步处理 -IM Server 已接入 `im.workbench.event` 的首批生产与消费。 +IM Server 不再生产或消费 `im.workbench.event`。工作台小流量事件由业务接口同步创建系统通知和 `im_business_events_outbox`。 当前生产范围: @@ -915,14 +914,12 @@ IM Server 已接入 `im.workbench.event` 的首批生产与消费。 当前消费行为: -1. 消费 `im.workbench.event`。 -2. 按 `target_user_ids` 生成系统通知。 -3. 写入 `im_business_events_outbox`,由现有 WS 业务事件投递器下发在线客户端。 -4. 使用 `im_kafka_consume_log` 做消费幂等,失败按统一重试和死信规则处理。 +1. 按 `target_user_ids` 同步生成系统通知。 +2. 同步写入 `im_business_events_outbox`,由现有 WS 业务事件投递器下发在线客户端。 ### 11.12 IM Server 已接入的审计事件 -IM Server 已接入 `im.audit.event` 的直接生产,审计事件不写 DB outbox。 +IM Server 对跨系统审计仍生产 `im.audit.event`。该 topic 由 Admin/HotGo 消费并同步写审计表;IM Server 本地不消费该 topic,也不通过它做内部异步落库。 当前审计范围: @@ -933,7 +930,7 @@ IM Server 已接入 `im.audit.event` 的直接生产,审计事件不写 DB out | Kafka 死信 | `kafka.dead_letter.created`。 | | 文件生命周期 | 上传完成、附件绑定、群文件新增/删除。 | -审计事件当前不在 IM 内部落审计表,预期由 Admin/HotGo 消费 `im.audit.event` 后写入 `hotgo` 审计库或审计表。 +审计事件当前不在 IM 内部通过 Kafka 落审计表,只作为 IM -> Admin/HotGo 的跨系统写事件。 ## 12. 风险与约束 diff --git a/docs/IM-Kafka接入联调测试文档.md b/docs/IM-Kafka接入联调测试文档.md index 76e750b..62f9578 100644 --- a/docs/IM-Kafka接入联调测试文档.md +++ b/docs/IM-Kafka接入联调测试文档.md @@ -6,9 +6,9 @@ ## 1. 联调目标 -1. 验证 Kafka 基础生产、消费、幂等、重试、死信、Outbox 能正常工作。 +1. 验证 Kafka 基础生产、消费、幂等、重试、死信能正常工作。 2. 验证 Admin 后台操作通过 `im.admin.command` 投递到 IM Server,并通过 `im.admin.command.result` 返回最终状态。 -3. 验证 IM Server 消息异步落库、文件生命周期、工作台事件、审计事件、缓存失效、死信处理、用户治理命令全部可用。 +3. 验证 IM Server 私聊/群聊消息异步落库、缓存失效、死信处理、用户治理命令全部可用;文件生命周期和工作台事件同步落库。 4. 验证 Admin 与 IM Server 不跨库直连:Admin 查询租户业务数据走 IM 内部 API;IM 查询 hotgo 配置走 Admin 内部 API。 5. 验证 Kafka 异常情况下不会产生重复业务结果,且失败可重试、可进入死信、可人工重放。 @@ -21,7 +21,7 @@ | MySQL | `hotgo` 控制库、租户库如 `tinode` 已创建并可连接。 | | Redis | IM Server 已配置 Redis,用于在线状态、短状态、热缓存和跨节点 fanout。 | | Kafka | Topic 已创建或允许自动创建;建议测试环境显式创建。 | -| OSS | 文件上传下载仍走 OSS,Kafka 只处理文件元数据和生命周期事件。 | +| OSS | 文件上传下载仍走 OSS,文件元数据和生命周期小流量动作同步处理。 | | Admin/HotGo | 已接入 Kafka producer/consumer、outbox、内部配置查询接口。 | | IM Server | 已启用 Kafka、内部 Admin Query API、Admin Internal Config Client。 | @@ -33,10 +33,9 @@ | `im.admin.command.result` | IM -> Admin | 后台命令执行结果。 | | `im.config.event` | Admin -> IM | 租户配置、字典、第三方配置变更通知。 | | `im.cache.invalidate` | Admin/IM -> IM | 跨节点缓存失效。 | -| `im.message.persist` | IM -> IM | 私聊、群聊消息异步落库。 | -| `im.file.lifecycle` | IM -> IM | 文件上传完成、附件绑定、群文件登记/删除。 | -| `im.workbench.event` | Admin/IM -> IM | 工作台事件、红点、系统通知。 | -| `im.audit.event` | IM -> Admin/审计消费者 | 审计事件。 | +| `im.message.persist` | IM -> IM | 私聊消息异步落库。 | +| `im.group.message.persist` | IM -> IM | 群聊消息异步落库。 | +| `im.audit.event` | IM -> Admin/HotGo | 跨系统审计事件。 | | `im.dead.letter` | IM -> Admin/告警 | 消费失败死信。 | 说明:`im.message.push` 已规划但本轮不联调,离线 Push Kafka 化仍在增强项中。 @@ -69,7 +68,6 @@ Admin/HotGo 侧需具备: | `im_admin_commands` | Admin 操作单,记录后台操作 `pending/running/succeeded/failed/dead`。 | | `im_admin_outbox` | Admin outbox,与操作单同事务写入,可靠发送 Kafka。 | | `im_kafka_dead_letter_events` | Admin 消费 `im.dead.letter` 后落 HotGo,用于告警和排障。 | -| `im_audit_events` | Admin 消费 `im.audit.event` 后落 HotGo,用于 Kafka 审计事件列表。 | | Admin 配置表/字典表 | 为 `/internal/im-config/*` 提供数据源。 | Admin/HotGo 侧需执行: @@ -94,7 +92,7 @@ SOURCE server/storage/data/generate/im_kafka_audit_menu.sql; "client_id": "im-server", "consumer_group": "im-server", "allow_auto_topic_creation": false, - "max_retry_count": 5 + "max_retry_count": 3 }, "internal_api": { "enabled": true, @@ -120,8 +118,8 @@ SOURCE server/storage/data/generate/im_kafka_audit_menu.sql; Admin 需要: -1. Kafka producer 发送 `im.admin.command`、`im.config.event`、`im.cache.invalidate`、`im.workbench.event`。 -2. Kafka consumer 消费 `im.admin.command.result`、`im.dead.letter`、`im.audit.event`。 +1. Kafka producer 发送 `im.admin.command`、`im.config.event`、`im.cache.invalidate`。 +2. Kafka consumer 消费 `im.admin.command.result`、`im.dead.letter`。 3. 内部接口客户端调用 IM `/internal/admin-query/*`。如果 IM API 前缀为 `/v1`,Admin 配置 `imInternalApi.baseUrl` 应包含 `/v1`,例如 `http://im-server:6060/v1`。 4. 内部接口服务端提供 IM `/internal/im-config/*`。 5. 内部接口签名与 IM 一致。 @@ -150,7 +148,7 @@ METHOD + "\n" + RequestURI + "\n" + BodySHA256Hex + "\n" + Timestamp + "\n" + No | 后台查询 | 通过 IM `/internal/admin-query/*` 查询租户业务数据。 | 提供白名单只读查询接口,统一 HMAC 鉴权。 | | IM 配置读取 | 提供 `/internal/im-config/*`。 | 通过 Admin 内部配置接口读取租户、注册、第三方配置。 | | Kafka 死信 | 消费 `im.dead.letter`,展示告警和处理入口。 | 记录死信,提供查询接口,消费处理命令。 | -| 审计 | 消费 `im.audit.event` 并落 HotGo 审计表。 | 关键业务动作生产审计事件。 | +| 审计 | 消费 `im.audit.event` 并同步落 HotGo 审计表。 | 关键业务动作生产跨系统审计事件,不在 IM 内部消费落库。 | ### 2.7 IM 内部查询接口矩阵 @@ -597,23 +595,22 @@ Payload: ### TC-KAFKA-016 文件上传完成事件 -目标:验证文件上传完成后产生 `im.file.lifecycle` 事实事件。 +目标:验证文件上传完成后不再产生 `im.file.lifecycle` Kafka 事件。 步骤: 1. 客户端上传文件到 OSS。 2. 上传完成后查询租户库文件记录。 -3. 检查 Kafka outbox 和 `im.file.lifecycle`。 +3. 确认 Kafka topic 中没有 `im.file.lifecycle` 依赖。 预期: 1. 文件字节流只进入 OSS。 -2. Kafka 只包含文件元数据,不包含文件内容。 -3. 上传完成事件可被消费并审计。 +2. 上传完成后的文件记录已同步落库。 ### TC-KAFKA-017 消息附件绑定 -目标:验证消息落库后附件引用由 `im.file.lifecycle` 绑定。 +目标:验证消息落库后附件引用同步绑定。 步骤: @@ -631,7 +628,7 @@ Payload: ### TC-KAFKA-018 群文件登记与删除 -目标:验证群文件业务事件接入 `im.file.lifecycle`。 +目标:验证群文件业务由 HTTP 接口同步落库。 步骤: @@ -660,13 +657,11 @@ Payload: 预期: -1. 每个动作产生 `im.workbench.event`。 -2. 消费后写 `im_system_notifications`。 -3. 写 `im_business_events_outbox`。 -4. 在线 C 端收到系统通知或红点刷新。 -5. 审计事件已投递。 +1. 每个动作同步写 `im_system_notifications`。 +2. 同步写 `im_business_events_outbox`。 +3. 在线 C 端收到系统通知或红点刷新。 -Admin 连通性补充:可在「IM管理 -> Kafka命令」点击「发送工作台测试事件」,或调用 `POST /admin/imKafkaCommand/workbenchEvent`,验证 Admin 具备生产 `im.workbench.event` 的能力。真实会议、日报/月报、信箱业务结果仍以 IM Server 对应业务用例为准。 +Admin 连通性补充:工作台事件不再走 Kafka,真实会议、日报/月报、信箱业务结果以 IM Server 对应业务用例为准。 ### TC-KAFKA-020 日报/月报事件 @@ -681,9 +676,8 @@ Admin 连通性补充:可在「IM管理 -> Kafka命令」点击「发送工作 预期: -1. 对应事件进入 `im.workbench.event`。 -2. 通知目标用户正确。 -3. 工作台红点或系统通知可被客户端看到。 +1. 通知目标用户正确。 +2. 工作台红点或系统通知可被客户端看到。 ### TC-KAFKA-021 总经理信箱事件 @@ -692,9 +686,8 @@ Admin 连通性补充:可在「IM管理 -> Kafka命令」点击「发送工作 步骤: 1. 用户提交信箱内容。 -2. 检查 `im.workbench.event`。 -3. 检查 `im.audit.event`。 -4. Admin 后台消费后生成处理单。 +2. 检查同步生成的通知/业务事件。 +3. Admin 后台生成处理单。 预期: @@ -866,7 +859,7 @@ GET /v1/internal/admin-query/tenants/default/kafka/dead-letters/{dead_id} ### TC-KAFKA-029 审计事件 -目标:验证关键后台和敏感操作都有审计事件。 +目标:确认 IM Server 对跨系统审计仍生产 `im.audit.event`,由 Admin/HotGo 消费后同步落审计表。 覆盖: @@ -878,20 +871,19 @@ GET /v1/internal/admin-query/tenants/default/kafka/dead-letters/{dead_id} 预期: -1. `im.audit.event` 收到事件。 -2. 事件包含 `tenant_code`、`actor`、`biz_type`、`biz_id`、`event_type`、`occurred_at`。 -3. Admin 审计消费者可落 HotGo `im_audit_events`。 -4. Admin 可在「IM管理 -> Kafka审计事件」查看审计事件列表和详情。 +1. IM Server 生产 `im.audit.event`。 +2. Kafka consumer 启动日志中没有 IM 侧审计事件 consumer。 +3. Admin/HotGo 消费审计事件并同步写入审计表。 -### TC-KAFKA-030 Outbox 可靠投递 +### TC-KAFKA-030 IM 侧 Kafka 生产失败 目标:验证 IM 侧 Kafka 直接生产失败时不会产生 DB outbox,调用方收到发送失败。 预期: -1. Kafka 停止时业务 outbox 记录为 `pending/failed`。 -2. 恢复后记录变为 `sent`。 -3. 下游消费者最终收到事件。 +1. Kafka 停止时 IM 不写 `im_kafka_outbox`,消息发送返回失败。 +2. 恢复后客户端可重新发送。 +3. 重复发送依赖 `client_mid` 和消费日志保持幂等。 4. 不重复产生业务副作用。 ## 5. Admin 侧联调检查清单 @@ -903,7 +895,7 @@ GET /v1/internal/admin-query/tenants/default/kafka/dead-letters/{dead_id} | Command 生产 | `event_id/idempotency_key/tenant_code/biz_id` 稳定可追踪。 | | Result 消费 | `im.admin.command.result` 能更新操作单状态。 | | 死信消费 | `im.dead.letter` 能生成告警或待处理记录。 | -| 审计消费 | `im.audit.event` 能落 HotGo `im_audit_events`,并可在 Kafka审计事件页面查看。 | +| 审计 | 消费 `im.audit.event`,同步落 HotGo 审计表。 | | 配置接口 | `/internal/im-config/*` 支持 IM HMAC 调用。 | | 查询客户端 | 调 IM `/internal/admin-query/*` 带签名并处理 401/403/404/5xx。 | | 页面状态 | 后台页面显示 pending/running/succeeded/failed/dead,不把 Kafka 发送成功当业务成功。 | @@ -912,7 +904,7 @@ GET /v1/internal/admin-query/tenants/default/kafka/dead-letters/{dead_id} | 检查项 | 验收标准 | | --- | --- | -| Kafka 启动 | producer、consumer、outbox worker 正常启动。 | +| Kafka 启动 | producer、consumer 正常启动,IM 侧无 DB outbox worker。 | | 租户路由 | `tenant_code` 能路由到正确租户库。 | | 消费幂等 | 重复 `event_id` 不重复执行业务。 | | 重试 | 可重试错误按退避时间重试。 | @@ -922,9 +914,9 @@ GET /v1/internal/admin-query/tenants/default/kafka/dead-letters/{dead_id} | 用户治理 | 封禁/冻结踢下线,解封/恢复可登录,设备踢出只影响指定设备。 | | 群治理 | 群资料、角色、禁言、移除、转让群主全部生效。 | | 消息落库 | 私聊写 `im_messages`,群聊写 `im_group_messages`。 | -| 文件生命周期 | 附件引用绑定到正确业务表。 | -| 工作台事件 | 生成系统通知和 WS 业务事件。 | -| 审计 | 关键动作进入 `im.audit.event`。 | +| 文件生命周期 | 文件记录、附件引用、群文件同步落到正确业务表。 | +| 工作台事件 | 同步生成系统通知和 WS 业务事件。 | +| 审计 | IM 生产 `im.audit.event` 给 Admin/HotGo,IM 本地不消费审计 topic。 | ## 7. 回归用例 @@ -949,14 +941,14 @@ GET /v1/internal/admin-query/tenants/default/kafka/dead-letters/{dead_id} | 进入死信 | 查 `im_kafka_dead_letters.envelope/last_error`,修复后用重放命令。 | | 用户没有被踢下线 | 查目标 `user_id` 是否是 IM 用户 ID;是否连接在当前 IM 节点;跨节点踢人是否需要后续 Redis fanout 扩展。 | | 群治理未生效 | 查 `topic/version/actor_user_id` 是否正确,目标用户是否为群成员。 | -| 消息未落库 | 查 `im.message.persist` 是否消费成功,以及 `im_kafka_consume_log.last_reason/last_error`。 | -| 附件未绑定 | 查消息是否带附件、`im.file.lifecycle` 是否消费成功。 | +| 消息未落库 | 私聊查 `im.message.persist`,群聊查 `im.group.message.persist` 是否消费成功,以及 `im_kafka_consume_log.last_reason/last_error`。 | +| 附件未绑定 | 查消息是否带附件,以及同步绑定逻辑是否返回错误。 | | 内部 API 401 | 查 secret、签名文本、RequestURI、timestamp、nonce 是否一致。 | | 配置没有刷新 | 查是否发送 `im.config.event` 或 `im.cache.invalidate`,IM 是否清理了对应 namespace。 | ## 9. 联调完成标准 -1. 本文所有 P0/P1 用例通过:Kafka 基础、Admin Command、用户治理、群治理、消息异步落库、文件生命周期、工作台、缓存失效、死信。 +1. 本文所有 P0/P1 用例通过:Kafka 基础、Admin Command、用户治理、群治理、消息异步落库、文件生命周期同步处理、工作台同步处理、缓存失效、死信。 2. Admin 操作单状态闭环可视化:pending -> running -> succeeded/failed/dead。 3. IM 租户库无重复业务记录,Kafka 消费日志无异常堆积。 4. 死信可查询、可忽略、可解决、可重放。 @@ -985,13 +977,13 @@ GET /v1/internal/admin-query/tenants/default/kafka/dead-letters/{dead_id} | 用例/链路 | 测试内容 | 结果 | 证据 | | --- | --- | --- | --- | -| TC-KAFKA-001 | Kafka Topic 创建,IM consumer 启动。 | 通过 | IM 日志显示 `im.admin.command`、`im.config.event`、`im.cache.invalidate`、`im.message.persist`、`im.file.lifecycle`、`im.workbench.event` consumer started。 | +| TC-KAFKA-001 | Kafka Topic 创建,IM consumer 启动。 | 通过 | IM 日志显示 `im.admin.command`、`im.config.event`、`im.cache.invalidate`、`im.message.persist`、`im.group.message.persist` consumer started。 | | TC-KAFKA-002 | Admin -> IM 内部查询 HMAC。 | 通过 | `GET /internal/admin-query/tenants/default/users?page=1&per_page=1` 返回 `code=0`,`total=29`。 | | TC-KAFKA-004 | IM -> Admin 内部配置 HMAC。 | 通过 | `GET /internal/im-config/tenants?limit=1000&state=active` 返回 `code=0` 和 default 租户。 | | TC-KAFKA-008/011 | `user.logout_all` 后台用户治理命令。 | 通过 | `tinode.im_kafka_consume_log` 中 `local-logout-fixed-*` 状态为 `succeeded`。 | | TC-KAFKA-023 | `im.cache.invalidate` 通用缓存失效。 | 通过 | `local-cache-*` 写入 `im_kafka_consume_log`,状态 `succeeded`,IM 日志显示 `kafka cache invalidate applied`。 | | TC-KAFKA-025 | 非法 `user.logout_all` 进入死信。 | 通过 | `local-logout-*` 状态为 `dead`,`im_kafka_dead_letters` 写入 `MALFORMED_COMMAND`。 | -| TC-KAFKA-029 | 用户治理审计事件。 | 通过 | Admin 消费 `im.audit.event` 链路已验证;正式落表应为 `hotgo.im_audit_events`。 | +| TC-KAFKA-029 | 用户治理审计事件。 | 通过 | IM 生产 `im.audit.event`,Admin/HotGo 消费后同步落审计表。 | 本轮发现并处理: @@ -1002,7 +994,7 @@ GET /v1/internal/admin-query/tenants/default/kafka/dead-letters/{dead_id} | IM 启动缺少 UID key | IM 报 `crypto/xtea: invalid key size 0`。 | Docker 启动显式传入 `UID_ENCRYPTION_KEY`、`API_KEY_SALT`、`AUTH_TOKEN_KEY`。 | | HotGo 表前缀不一致 | Admin 内部配置接口错误查询 `hg_im_tenant`,但 IM 业务/配置表正式表名应保持 `im_tenant`。 | 联调时暴露的是 Admin 查询/ORM 表名前缀问题;正式方案必须修 Admin 侧表名映射或 DAO 前缀逻辑,不能新增 `hg_im_*` 兼容表。 | | Admin result 消费格式不兼容 | Admin 消费 `im.admin.command.result` 报 `IM命令结果缺少command_id`。 | Admin consumer 已兼容 Kafka Envelope + payload 格式。 | -| Admin 审计表前缀不一致 | Admin 消费 `im.audit.event` 错误查询 `hg_im_audit_events`,正式表名应保持 `im_audit_events`。 | 正式方案必须修 Admin 侧审计模型/DAO 表名映射,所有 `im_*` 业务表不加 `hg_` 前缀。 | +| Admin 审计表前缀不一致 | Admin 消费 `im.audit.event` 后错误查询 `hg_im_audit_events`,正式表名应保持 `im_audit_events`。 | Admin/HotGo 同步写审计表时,仍需保证所有 `im_*` 业务表不加 `hg_` 前缀。 | 本轮未覆盖,后续继续: @@ -1046,11 +1038,11 @@ GET /v1/internal/admin-query/tenants/default/kafka/dead-letters/{dead_id} | TC-KAFKA-011 | 全端登出。 | 通过 | `user.logout_all` 消费 `succeeded`。 | | TC-KAFKA-012 | 注销与删除账号。 | 通过 | `user.account.cancel/user.account.delete` 使用本地测试账号验证,消费 `succeeded`。 | | TC-KAFKA-013 | 私聊消息异步落库。 | 通过 | `message.persist.p2p` 消费 `succeeded`,`im_messages` 写入测试消息。 | -| TC-KAFKA-014 | 群聊消息异步落库。 | 通过 | `message.persist.group` 消费 `succeeded`,`im_group_messages` 写入测试群消息。 | -| TC-KAFKA-016 | 文件上传完成事件。 | 通过 | `file.upload.completed` 消费 `succeeded`。 | +| TC-KAFKA-014 | 群聊消息异步落库。 | 通过 | `im.group.message.persist` 消费 `message.persist.group`,`im_group_messages` 写入测试群消息。 | +| TC-KAFKA-016 | 文件上传完成事件。 | 通过 | 文件上传完成不再走 Kafka。 | | TC-KAFKA-017 | 消息附件绑定。 | 通过 | 群聊附件绑定写入 `im_group_files`;私聊附件绑定修复后写入 `im_filemsglinks`。 | -| TC-KAFKA-018 | 群文件登记与删除生命周期。 | 通过 | `group.file.created/group.file.deleted` 消费 `succeeded`。 | -| TC-KAFKA-019 | 工作台会议事件。 | 通过 | `workbench.meeting.created` 消费 `succeeded`,写入 `im_system_notifications` 和 `im_business_events_outbox`。 | +| TC-KAFKA-018 | 群文件登记与删除生命周期。 | 通过 | 群文件登记/删除同步落库。 | +| TC-KAFKA-019 | 工作台会议事件。 | 通过 | 同步写入 `im_system_notifications` 和 `im_business_events_outbox`。 | | TC-KAFKA-022 | 配置事件失效。 | 通过 | `dictionary.updated` 消费 `succeeded`。 | | TC-KAFKA-023 | 通用缓存失效。 | 通过 | `cache.redis_object.invalidate` 消费 `succeeded`。 | | TC-KAFKA-024 | 群热消息缓存失效。 | 通过 | `cache.group_hot_messages.invalidate` 消费 `succeeded`。 | @@ -1058,7 +1050,7 @@ GET /v1/internal/admin-query/tenants/default/kafka/dead-letters/{dead_id} | TC-KAFKA-026 | 死信查询。 | 通过 | `/internal/admin-query/tenants/default/kafka/dead-letters` 和详情接口返回 200。 | | TC-KAFKA-027 | 死信忽略与解决。 | 通过 | `kafka.dead_letter.ignore/resolve` 消费 `succeeded`,死信状态变为 `ignored/resolved`。 | | TC-KAFKA-028 | 死信重放。 | 通过 | `kafka.dead_letter.replay` 消费 `succeeded`,死信状态变为 `replayed`,`replay_count=1`。 | -| TC-KAFKA-029 | 审计事件。 | 通过 | 用户治理、群治理、死信、文件等事件投递 `im.audit.event`;正式落表应为 `hotgo.im_audit_events`。 | +| TC-KAFKA-029 | 审计事件。 | 通过 | `im.audit.event` 投递给 Admin/HotGo,消费侧同步落审计表。 | | TC-KAFKA-030 | IM Kafka Outbox 可靠投递。 | 通过 | Kafka 停止时 outbox `pending -> failed`;Kafka 恢复并重启 IM 后 `failed -> sent`,下游消费成功。 | 本轮消费统计: diff --git a/docs/IM-Kafka接入范围识别.md b/docs/IM-Kafka接入范围识别.md index 1b33994..bdb3822 100644 --- a/docs/IM-Kafka接入范围识别.md +++ b/docs/IM-Kafka接入范围识别.md @@ -20,15 +20,16 @@ Kafka 主要用于跨系统异步事件、削峰落库、可靠重试、失败 | Topic | 用途 | 典型生产方 | 典型消费方 | | --- | --- | --- | --- | -| `im.message.persist` | 私聊、群聊消息异步落库 | IM WS 节点 | 消息持久化消费者 | +| `im.message.persist` | 私聊消息异步落库 | IM WS 节点 | 消息持久化消费者 | +| `im.group.message.persist` | 群聊消息异步落库 | IM WS 节点 | 群消息持久化消费者 | | `im.message.push` | 离线 Push 提醒 | 消息落库消费者、业务事件生产方 | 极光/厂商 Push 消费者 | | `im.business.event` | 好友、群、会议、工作台等 C 端业务事件 | IM HTTP、Admin 后台 | IM 节点事件投递消费者 | | `im.group.admin.event` | Admin 后台群操作投递到 C 端 | Admin 后台 | IM 节点 WS 投递消费者 | | `im.cache.invalidate` | 跨节点缓存失效 | 业务写接口、后台配置 | IM 节点缓存消费者 | | `im.file.security` | 文件内容安全和恶意文件检测 | 上传完成链路 | 阿里云安全检测消费者 | -| `im.file.lifecycle` | 孤立文件回收、引用绑定、合规删除 | 文件服务、业务写接口 | 文件生命周期消费者 | -| `im.audit.event` | 审计日志 | 所有敏感写链路 | 审计消费者 | -| `im.workbench.event` | 工作台模块红点、会议、日报、月报、信箱事件 | 工作台业务服务、Admin 后台 | IM WS 投递、系统通知消费者 | +| `im.file.lifecycle` | 已不采用 | 文件生命周期小流量动作同步处理 | 无 | +| `im.audit.event` | 跨系统审计事件 | IM Server、Admin 后台 | Admin/HotGo 审计消费者 | +| `im.workbench.event` | 已不采用 | 工作台小流量动作同步处理 | 无 | | `im.dead.letter` | 失败事件归档 | 各消费者 | 运维补偿任务 | ## 3. 幂等与重试 @@ -61,16 +62,16 @@ Kafka 消息统一携带 `event_id`、`trace_id`、`tenant_code`、`biz_type`、 1. 上传完成后投递 `im.file.security`,调用阿里云内容安全和恶意文件检测。 2. 安全检测完成后更新文件审核状态,并通过 `im.business.event` 或系统通知告知相关用户。 -3. 消息发送、收藏、群文件登记、日报/月报/反馈/举报提交成功后投递 `im.file.lifecycle`,建立业务引用和 ACL。 -4. 业务撤回、删除、合规处置或临时文件 TTL 到期后投递 `im.file.lifecycle`,执行引用释放、禁用下载或 OSS 删除。 +3. 消息附件引用绑定、群文件登记等小流量动作同步落库,不再投递 `im.file.lifecycle`。 +4. 业务撤回、删除、合规处置或临时文件 TTL 回收如需异步化,另行评估专门任务队列。 ## 6. 工作台链路结论 -工作台“查询类接口”不需要 Kafka,但工作台“状态变化和提醒类事件”适合 Kafka: +工作台“查询类接口”不需要 Kafka,状态变化和提醒类事件当前也同步处理: -1. 会议创建、更新、取消、提醒、参会响应生产 `im.workbench.event`,消费者生成系统通知、红点并通过 WS 通知在线客户端。 -2. 日报/月报提交、撤回、重开、管理员催办生产 `im.workbench.event`,用于红点、催办 Push 和审计。 -3. 总经理信箱提交生产 `im.workbench.event` 和 `im.audit.event`,后台处理结果再回推 C 端。 +1. 会议创建、更新、取消、提醒、参会响应同步生成系统通知、红点并通过 WS 通知在线客户端。 +2. 日报/月报提交、撤回、重开、管理员催办同步生成红点、催办 Push 和审计。 +3. 总经理信箱提交同步处理,后台处理结果再回推 C 端。 4. 工作台模块配置变更生产 `im.cache.invalidate`,IM 节点刷新进程内配置缓存。 ## 7. 已补充:缓存失效与 Admin 配置查询 @@ -85,6 +86,6 @@ Kafka 消息统一携带 `event_id`、`trace_id`、`tenant_code`、`biz_type`、 1. 先实现 Kafka 基础生产者、消费者、配置、健康检查和消费幂等表。 2. 接入 `im.group.admin.event`,解决 Admin 后台群操作通过 Kafka 投递到 C 端。 -3. 接入 `im.message.persist`,将消息异步落库和幂等重试闭环。 +3. 接入 `im.message.persist` 和 `im.group.message.persist`,将私聊、群聊消息在 IM 内部异步落库和幂等重试闭环。 4. 接入 `im.file.security`,把 OSS 上传完成后的文件安全检测异步化。 -5. 接入 `im.workbench.event`,先覆盖会议提醒、日报/月报催办、总经理信箱提交通知。 +5. 工作台事件保持 IM 内部同步处理;跨系统审计仍通过 `im.audit.event` 投递给 Admin/HotGo。 diff --git a/server/db/mysql/group_schema_test.go b/server/db/mysql/group_schema_test.go index 407d419..a50c163 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) != 12 { - t.Fatalf("created %d group tables, want 12", len(exec.statements)) + if len(exec.statements) != 13 { + t.Fatalf("created %d group tables, want 13", len(exec.statements)) } for _, statement := range exec.statements { if strings.Contains(strings.ToLower(statement), "tinode.") || strings.Contains(strings.ToLower(statement), "create database") { diff --git a/server/kafka_file_lifecycle.go b/server/kafka_file_lifecycle.go index 8bceb36..2eed661 100644 --- a/server/kafka_file_lifecycle.go +++ b/server/kafka_file_lifecycle.go @@ -2,14 +2,11 @@ package main import ( "context" - "encoding/json" "errors" "fmt" "strings" "time" - "github.com/segmentio/kafka-go" - "github.com/tinode/chat/server/logs" "github.com/tinode/chat/server/store" "github.com/tinode/chat/server/store/types" ) @@ -39,12 +36,8 @@ type kafkaFileLifecyclePayload struct { OccurredAt time.Time `json:"occurred_at"` } -func startKafkaFileLifecycleConsumer() { - startKafkaConsumer("file_lifecycle", handleKafkaFileLifecycle) -} - func enqueueKafkaFileUploaded(ctx context.Context, tenantID types.TenantID, fd *types.FileDef) error { - if !kafkaEnabled() || fd == nil || fd.TenantID != tenantID || fd.Status != types.UploadCompleted { + if fd == nil || fd.TenantID != tenantID || fd.Status != types.UploadCompleted { return nil } payload := kafkaFileLifecyclePayload{ @@ -63,7 +56,7 @@ func enqueueKafkaFileUploaded(ctx context.Context, tenantID types.TenantID, fd * } func enqueueKafkaFileAttachmentsBind(ctx context.Context, tenantID types.TenantID, msg *types.Message, attachmentURLs []string) error { - if !kafkaEnabled() || msg == nil || msg.TenantID != tenantID || len(attachmentURLs) == 0 { + if msg == nil || msg.TenantID != tenantID || len(attachmentURLs) == 0 { return nil } payload := kafkaFileLifecyclePayload{ @@ -80,7 +73,7 @@ func enqueueKafkaFileAttachmentsBind(ctx context.Context, tenantID types.TenantI } func enqueueKafkaGroupFileLifecycle(ctx context.Context, tenantID types.TenantID, eventType string, file *types.GroupFile, actor types.Uid) error { - if !kafkaEnabled() || file == nil || file.Topic == "" || file.FileID == "" { + if file == nil || file.Topic == "" || file.FileID == "" { return nil } occurredAt := file.UpdatedAt @@ -108,64 +101,7 @@ func enqueueKafkaFileLifecycle(ctx context.Context, tenantID types.TenantID, eve if payload.OccurredAt.IsZero() { payload.OccurredAt = time.Now().UTC().Round(time.Millisecond) } - encoded, err := json.Marshal(payload) - if err != nil { - return err - } - eventID := stableIMEventID("file-lifecycle", tenantID, eventType, bizID, payload.SeqID, strings.Join(payload.AttachmentURLs, ",")) - err = publishKafkaEnvelope(ctx, "file_lifecycle", bizID, kafkaEnvelope{ - EventID: eventID, - TenantID: tenantID, - SourceService: "im-server", - TargetService: "im-server", - EventType: eventType, - BizType: "file", - BizID: bizID, - OccurredAt: payload.OccurredAt, - Payload: encoded, - }) - if err != nil { - return err - } - actor := types.ParseUserId(payload.UserID) - if auditErr := enqueueKafkaAuditEvent(context.Background(), tenantID, "user", actor, eventType, "file", bizID, "succeeded", "", map[string]any{ - "file_id": payload.FileID, - "file_ids": payload.FileIDs, - "topic": payload.Topic, - "group_file_id": payload.GroupFileID, - "seq_id": payload.SeqID, - }); auditErr != nil { - logs.Warn.Printf("kafka file lifecycle: failed to enqueue audit event type=%s biz=%s: %v", eventType, bizID, auditErr) - } - return nil -} - -func handleKafkaFileLifecycle(ctx context.Context, envelope kafkaEnvelope, _ kafka.Message) error { - tenantID, err := kafkaTenantIDFromEnvelope(envelope) - if err != nil { - return err - } - consume, claimed, err := beginKafkaConsumeForTopic(tenantID, envelope, "file_lifecycle") - if err != nil || !claimed { - return err - } - var payload kafkaFileLifecyclePayload - if err = json.Unmarshal(envelope.Payload, &payload); err != nil { - return markKafkaDeadLetter(ctx, tenantID, envelope, consume, "MALFORMED_PAYLOAD", err.Error()) - } - if err = executeKafkaFileLifecycle(tenantID, payload); err != nil { - failed, markErr := store.HTTPInfrastructure.MarkKafkaConsumeFailed(tenantID, envelope.EventID, kafkaBus.config.ConsumerGroup, - kafkaCommandErrorReason(err), err.Error(), time.Now().UTC().Round(time.Millisecond)) - if markErr != nil { - return markErr - } - if failed != nil && failed.RetryCount >= kafkaBus.config.MaxRetryCount { - return markKafkaDeadLetter(ctx, tenantID, envelope, failed, kafkaCommandErrorReason(err), err.Error()) - } - sleepKafkaRetryBackoff(failed.RetryCount) - return err - } - return store.HTTPInfrastructure.MarkKafkaConsumeSucceeded(tenantID, envelope.EventID, kafkaBus.config.ConsumerGroup, time.Now().UTC().Round(time.Millisecond)) + return executeKafkaFileLifecycle(tenantID, payload) } func executeKafkaFileLifecycle(tenantID types.TenantID, payload kafkaFileLifecyclePayload) error { diff --git a/server/kafka_message_persist.go b/server/kafka_message_persist.go index 70c5451..e6da53c 100644 --- a/server/kafka_message_persist.go +++ b/server/kafka_message_persist.go @@ -24,6 +24,7 @@ var publishKafkaMessagePersist = publishKafkaEnvelope func startKafkaMessagePersistConsumer() { startKafkaConsumer("message_persist", handleKafkaMessagePersist) + startKafkaConsumer("group_message_persist", handleKafkaGroupMessagePersist) } func saveMessageMaybeKafka(ctx context.Context, tenantID types.TenantID, msg *types.Message, attachmentURLs []string, readBySender bool) (*types.MessageSaveResult, error) { @@ -79,10 +80,18 @@ func enqueueKafkaMessagePersist(ctx context.Context, tenantID types.TenantID, ms Actor: &kafkaActor{Type: "user", ID: types.ParseUid(msg.From).UserId()}, Payload: payload, } - return publishKafkaMessagePersist(ctx, "message_persist", msg.Topic, envelope) + return publishKafkaMessagePersist(ctx, kafkaMessagePersistTopicName(msg.Topic), msg.Topic, envelope) } func handleKafkaMessagePersist(ctx context.Context, envelope kafkaEnvelope, _ kafka.Message) error { + return handleKafkaMessagePersistFromTopic(ctx, envelope, "message_persist") +} + +func handleKafkaGroupMessagePersist(ctx context.Context, envelope kafkaEnvelope, _ kafka.Message) error { + return handleKafkaMessagePersistFromTopic(ctx, envelope, "group_message_persist") +} + +func handleKafkaMessagePersistFromTopic(ctx context.Context, envelope kafkaEnvelope, sourceTopicName string) error { tenantID := envelope.TenantID var err error if tenantID.IsZero() { @@ -91,7 +100,7 @@ func handleKafkaMessagePersist(ctx context.Context, envelope kafkaEnvelope, _ ka return err } } - consume, claimed, err := beginKafkaConsumeForTopic(tenantID, envelope, "message_persist") + consume, claimed, err := beginKafkaConsumeForTopic(tenantID, envelope, sourceTopicName) if err != nil || !claimed { return err } @@ -137,6 +146,13 @@ func handleKafkaMessagePersist(ctx context.Context, envelope kafkaEnvelope, _ ka return store.HTTPInfrastructure.MarkKafkaConsumeSucceeded(tenantID, envelope.EventID, kafkaBus.config.ConsumerGroup, time.Now().UTC().Round(time.Millisecond)) } +func kafkaMessagePersistTopicName(topic string) string { + if types.GetTopicCat(topic) == types.TopicCatGrp { + return "group_message_persist" + } + return "message_persist" +} + func validateKafkaMessageKind(payload kafkaMessagePersistPayload) error { if payload.Message.Topic == "" || payload.Message.SeqId <= 0 { return types.ErrMalformed diff --git a/server/kafka_runtime.go b/server/kafka_runtime.go index 5ab83f4..4f3ca6e 100644 --- a/server/kafka_runtime.go +++ b/server/kafka_runtime.go @@ -17,18 +17,17 @@ import ( ) const ( - kafkaTopicAdminCommand = "im.admin.command" - kafkaTopicAdminCommandResult = "im.admin.command.result" - kafkaTopicConfigEvent = "im.config.event" - kafkaTopicBusinessEvent = "im.business.event" - kafkaTopicMessagePersist = "im.message.persist" - kafkaTopicMessagePush = "im.message.push" - kafkaTopicFileSecurity = "im.file.security" - kafkaTopicFileLifecycle = "im.file.lifecycle" - kafkaTopicWorkbenchEvent = "im.workbench.event" - kafkaTopicCacheInvalidate = "im.cache.invalidate" - kafkaTopicAuditEvent = "im.audit.event" - kafkaTopicDeadLetter = "im.dead.letter" + kafkaTopicAdminCommand = "im.admin.command" + kafkaTopicAdminCommandResult = "im.admin.command.result" + kafkaTopicConfigEvent = "im.config.event" + kafkaTopicBusinessEvent = "im.business.event" + kafkaTopicMessagePersist = "im.message.persist" + kafkaTopicGroupMessagePersist = "im.group.message.persist" + kafkaTopicMessagePush = "im.message.push" + kafkaTopicFileSecurity = "im.file.security" + kafkaTopicCacheInvalidate = "im.cache.invalidate" + kafkaTopicAuditEvent = "im.audit.event" + kafkaTopicDeadLetter = "im.dead.letter" ) type kafkaConfig struct { @@ -199,18 +198,17 @@ func kafkaSASLMechanism(cfg kafkaConfig) (sasl.Mechanism, error) { func kafkaTopics(overrides map[string]string) map[string]string { topics := map[string]string{ - "admin_command": kafkaTopicAdminCommand, - "admin_command_result": kafkaTopicAdminCommandResult, - "config_event": kafkaTopicConfigEvent, - "business_event": kafkaTopicBusinessEvent, - "message_persist": kafkaTopicMessagePersist, - "message_push": kafkaTopicMessagePush, - "file_security": kafkaTopicFileSecurity, - "file_lifecycle": kafkaTopicFileLifecycle, - "workbench_event": kafkaTopicWorkbenchEvent, - "cache_invalidate": kafkaTopicCacheInvalidate, - "audit_event": kafkaTopicAuditEvent, - "dead_letter": kafkaTopicDeadLetter, + "admin_command": kafkaTopicAdminCommand, + "admin_command_result": kafkaTopicAdminCommandResult, + "config_event": kafkaTopicConfigEvent, + "business_event": kafkaTopicBusinessEvent, + "message_persist": kafkaTopicMessagePersist, + "group_message_persist": kafkaTopicGroupMessagePersist, + "message_push": kafkaTopicMessagePush, + "file_security": kafkaTopicFileSecurity, + "cache_invalidate": kafkaTopicCacheInvalidate, + "audit_event": kafkaTopicAuditEvent, + "dead_letter": kafkaTopicDeadLetter, } for key, value := range overrides { if value != "" { diff --git a/server/kafka_workbench_audit.go b/server/kafka_workbench_audit.go index 1f8b845..b1c144f 100644 --- a/server/kafka_workbench_audit.go +++ b/server/kafka_workbench_audit.go @@ -5,8 +5,6 @@ import ( "encoding/json" "time" - "github.com/segmentio/kafka-go" - "github.com/tinode/chat/server/store" "github.com/tinode/chat/server/store/types" ) @@ -34,15 +32,10 @@ type kafkaAuditPayload struct { OccurredAt time.Time `json:"occurred_at"` } -func startKafkaWorkbenchEventConsumer() { - startKafkaConsumer("workbench_event", handleKafkaWorkbenchEvent) -} +var publishKafkaAuditEventEnvelope = publishKafkaEnvelope func enqueueKafkaWorkbenchEvent(ctx context.Context, tenantID types.TenantID, actor types.Uid, module, action, resourceID string, targets []types.Uid, title, summary string, data map[string]any) error { - if !kafkaEnabled() { - return nil - } targetUserIDs := make([]string, 0, len(targets)) for _, target := range targets { if !target.IsZero() { @@ -60,7 +53,14 @@ func enqueueKafkaWorkbenchEvent(ctx context.Context, tenantID types.TenantID, ac Data: data, OccurredAt: time.Now().UTC().Round(time.Millisecond), } - return enqueueKafkaTypedEvent(ctx, tenantID, "workbench_event", "workbench."+action, "workbench", resourceID, actor, payload) + envelope := kafkaEnvelope{ + EventID: stableIMEventID("workbench-sync", tenantID, "workbench."+action, resourceID, payload.OccurredAt), + TenantID: tenantID, + EventType: "workbench." + action, + BizType: "workbench", + BizID: resourceID, + } + return executeKafkaWorkbenchEvent(tenantID, envelope, payload) } func enqueueKafkaAuditEvent(ctx context.Context, tenantID types.TenantID, actorType string, actor types.Uid, @@ -83,58 +83,25 @@ func enqueueKafkaAuditEvent(ctx context.Context, tenantID types.TenantID, actorT Data: data, OccurredAt: time.Now().UTC().Round(time.Millisecond), } - return enqueueKafkaTypedEvent(ctx, tenantID, "audit_event", "audit."+action, "audit", resource+":"+resourceID, actor, payload) -} - -func enqueueKafkaTypedEvent(ctx context.Context, tenantID types.TenantID, topicName, eventType, bizType, bizID string, actor types.Uid, payload any) error { encoded, err := json.Marshal(payload) if err != nil { return err } - eventID := stableIMEventID(topicName, tenantID, eventType, bizID, encoded) envelope := kafkaEnvelope{ - EventID: eventID, + EventID: stableIMEventID("audit_event", tenantID, "audit."+action, resource+":"+resourceID, encoded), TenantID: tenantID, SourceService: "im-server", - TargetService: "im-server", - EventType: eventType, - BizType: bizType, - BizID: bizID, - OccurredAt: time.Now().UTC(), + TargetService: "hotgo-admin", + EventType: "audit." + action, + BizType: "audit", + BizID: resource + ":" + resourceID, + OccurredAt: payload.OccurredAt, Payload: encoded, } if !actor.IsZero() { - envelope.Actor = &kafkaActor{Type: "user", ID: actor.UserId()} + envelope.Actor = &kafkaActor{Type: actorType, ID: actorID} } - return publishKafkaEnvelope(ctx, topicName, bizID, envelope) -} - -func handleKafkaWorkbenchEvent(ctx context.Context, envelope kafkaEnvelope, _ kafka.Message) error { - tenantID, err := kafkaTenantIDFromEnvelope(envelope) - if err != nil { - return err - } - consume, claimed, err := beginKafkaConsumeForTopic(tenantID, envelope, "workbench_event") - if err != nil || !claimed { - return err - } - var payload kafkaWorkbenchPayload - if err = json.Unmarshal(envelope.Payload, &payload); err != nil { - return markKafkaDeadLetter(ctx, tenantID, envelope, consume, "MALFORMED_PAYLOAD", err.Error()) - } - if err = executeKafkaWorkbenchEvent(tenantID, envelope, payload); err != nil { - failed, markErr := store.HTTPInfrastructure.MarkKafkaConsumeFailed(tenantID, envelope.EventID, kafkaBus.config.ConsumerGroup, - kafkaCommandErrorReason(err), err.Error(), time.Now().UTC().Round(time.Millisecond)) - if markErr != nil { - return markErr - } - if failed != nil && failed.RetryCount >= kafkaBus.config.MaxRetryCount { - return markKafkaDeadLetter(ctx, tenantID, envelope, failed, kafkaCommandErrorReason(err), err.Error()) - } - sleepKafkaRetryBackoff(failed.RetryCount) - return err - } - return store.HTTPInfrastructure.MarkKafkaConsumeSucceeded(tenantID, envelope.EventID, kafkaBus.config.ConsumerGroup, time.Now().UTC().Round(time.Millisecond)) + return publishKafkaAuditEventEnvelope(ctx, "audit_event", envelope.BizID, envelope) } func executeKafkaWorkbenchEvent(tenantID types.TenantID, envelope kafkaEnvelope, payload kafkaWorkbenchPayload) error { diff --git a/server/kafka_workbench_audit_test.go b/server/kafka_workbench_audit_test.go new file mode 100644 index 0000000..8b12397 --- /dev/null +++ b/server/kafka_workbench_audit_test.go @@ -0,0 +1,139 @@ +package main + +import ( + "context" + "encoding/json" + "testing" + "time" + + "github.com/golang/mock/gomock" + "github.com/tinode/chat/server/store" + "github.com/tinode/chat/server/store/mock_store" + "github.com/tinode/chat/server/store/types" +) + +func TestKafkaTopicsKeepOnlyMessageInternalAsyncTopics(t *testing.T) { + topics := kafkaTopics(nil) + if topics["message_persist"] != "im.message.persist" { + t.Fatalf("unexpected private message persist topic: %q", topics["message_persist"]) + } + if topics["group_message_persist"] != "im.group.message.persist" { + t.Fatalf("unexpected group message persist topic: %q", topics["group_message_persist"]) + } + if topics["audit_event"] != "im.audit.event" { + t.Fatalf("unexpected audit event topic: %q", topics["audit_event"]) + } + for _, removed := range []string{"file_lifecycle", "workbench_event"} { + if _, ok := topics[removed]; ok { + t.Fatalf("internal sync topic %q must not be configured", removed) + } + } +} + +func TestEnqueueKafkaAuditEventPublishesCrossSystemEvent(t *testing.T) { + oldKafkaBus := kafkaBus + oldPublisher := publishKafkaAuditEventEnvelope + defer func() { + kafkaBus = oldKafkaBus + publishKafkaAuditEventEnvelope = oldPublisher + }() + + kafkaBus = &kafkaRuntime{} + var publishedTopic string + var publishedKey string + var publishedEnvelope kafkaEnvelope + publishKafkaAuditEventEnvelope = func(_ context.Context, topicName, key string, envelope kafkaEnvelope) error { + publishedTopic = topicName + publishedKey = key + publishedEnvelope = envelope + return nil + } + + err := enqueueKafkaAuditEvent(context.Background(), 7, "admin", types.Uid(42), + "group.update", "group", "grp-test", "succeeded", "", map[string]any{"topic": "grp-test"}) + if err != nil { + t.Fatalf("enqueueKafkaAuditEvent returned error: %v", err) + } + if publishedTopic != "audit_event" { + t.Fatalf("unexpected audit publish topic: %q", publishedTopic) + } + if publishedKey != "group:grp-test" { + t.Fatalf("unexpected audit publish key: %q", publishedKey) + } + if publishedEnvelope.TargetService != "hotgo-admin" || publishedEnvelope.EventType != "audit.group.update" { + t.Fatalf("unexpected audit envelope: %#v", publishedEnvelope) + } + if publishedEnvelope.Actor == nil || publishedEnvelope.Actor.Type != "admin" || publishedEnvelope.Actor.ID != types.Uid(42).UserId() { + t.Fatalf("unexpected audit actor: %#v", publishedEnvelope.Actor) + } + var payload kafkaAuditPayload + if err = json.Unmarshal(publishedEnvelope.Payload, &payload); err != nil { + t.Fatalf("invalid audit payload: %v", err) + } + if payload.Resource != "group" || payload.ResourceID != "grp-test" || payload.Result != "succeeded" { + t.Fatalf("unexpected audit payload: %#v", payload) + } +} + +func TestEnqueueKafkaFileAttachmentsBindRunsSynchronously(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + oldKafkaBus := kafkaBus + oldFiles := store.Files + defer func() { + kafkaBus = oldKafkaBus + store.Files = oldFiles + }() + + kafkaBus = &kafkaRuntime{} + files := mock_store.NewMockFilePersistenceInterface(ctrl) + store.Files = files + msgID := types.Uid(101) + msg := &types.Message{ + ObjHeader: types.ObjHeader{Id: msgID.String(), CreatedAt: time.Date(2026, 9, 1, 10, 0, 0, 0, time.UTC)}, + TenantID: 7, + Topic: "usr42", + SeqId: 3, + From: types.Uid(42).String(), + Head: types.KVMap{"x-im-type": "image"}, + } + attachments := []string{"https://api.118im.com/v1/im/files/file_001"} + files.EXPECT().LinkAttachments(types.TenantID(7), "", msgID, attachments).Return(nil) + + if err := enqueueKafkaFileAttachmentsBind(context.Background(), 7, msg, attachments); err != nil { + t.Fatalf("enqueueKafkaFileAttachmentsBind returned error: %v", err) + } +} + +func TestEnqueueKafkaWorkbenchEventRunsSynchronously(t *testing.T) { + oldKafkaBus := kafkaBus + oldInfrastructure := store.HTTPInfrastructure + oldSecurity := store.Security + defer func() { + kafkaBus = oldKafkaBus + store.HTTPInfrastructure = oldInfrastructure + store.Security = oldSecurity + }() + + kafkaBus = &kafkaRuntime{} + infrastructure := &testHTTPInfrastructureStore{} + security := &testSecurityStore{} + store.HTTPInfrastructure = infrastructure + store.Security = security + + err := enqueueKafkaWorkbenchEvent(context.Background(), 7, types.Uid(42), "meeting", "meeting.created", "meeting-1", + []types.Uid{42, 43}, "会议通知", "周会", map[string]any{"meeting_id": "meeting-1"}) + if err != nil { + t.Fatalf("enqueueKafkaWorkbenchEvent returned error: %v", err) + } + if len(security.notifications) != 1 { + t.Fatalf("expected one notification for non-actor target, got %#v", security.notifications) + } + if security.notifications[0].UserID != types.Uid(43) || security.notifications[0].EventType != "meeting.created" { + t.Fatalf("unexpected notification: %#v", security.notifications[0]) + } + if len(infrastructure.events) != 2 { + t.Fatalf("expected business events for both targets, got %#v", infrastructure.events) + } +} diff --git a/server/main.go b/server/main.go index 913aa5b..f301cb9 100644 --- a/server/main.go +++ b/server/main.go @@ -696,8 +696,6 @@ func main() { startKafkaConfigEventConsumer() startKafkaCacheInvalidateConsumer() startKafkaMessagePersistConsumer() - startKafkaFileLifecycleConsumer() - startKafkaWorkbenchEventConsumer() startKafkaDeadLetterConsumer() stopExternalBusinessEvents := startIMExternalBusinessEventPoller() defer stopExternalBusinessEvents() diff --git a/server/meetingim.conf b/server/meetingim.conf index 4dfa15b..1701406 100644 --- a/server/meetingim.conf +++ b/server/meetingim.conf @@ -130,10 +130,9 @@ "config_event": "im.config.event", "business_event": "im.business.event", "message_persist": "im.message.persist", + "group_message_persist": "im.group.message.persist", "message_push": "im.message.push", "file_security": "im.file.security", - "file_lifecycle": "im.file.lifecycle", - "workbench_event": "im.workbench.event", "cache_invalidate": "im.cache.invalidate", "audit_event": "im.audit.event", "dead_letter": "im.dead.letter" diff --git a/server/tinode.conf b/server/tinode.conf index 4dfa15b..1701406 100644 --- a/server/tinode.conf +++ b/server/tinode.conf @@ -130,10 +130,9 @@ "config_event": "im.config.event", "business_event": "im.business.event", "message_persist": "im.message.persist", + "group_message_persist": "im.group.message.persist", "message_push": "im.message.push", "file_security": "im.file.security", - "file_lifecycle": "im.file.lifecycle", - "workbench_event": "im.workbench.event", "cache_invalidate": "im.cache.invalidate", "audit_event": "im.audit.event", "dead_letter": "im.dead.letter" diff --git a/server/topic_test.go b/server/topic_test.go index 1f1d211..524108d 100644 --- a/server/topic_test.go +++ b/server/topic_test.go @@ -948,7 +948,7 @@ func TestSaveMessageMaybeKafkaPublishesWithoutDBPersist(t *testing.T) { if msg.ClientMsgID != "client-1" { t.Fatalf("client message id not normalized: %q", msg.ClientMsgID) } - if publishedTopic != "message_persist" { + if publishedTopic != "group_message_persist" { t.Fatalf("unexpected kafka topic: %q", publishedTopic) } if publishedKey != "grp-test" {