From f7f8cc0ef0c6f6593631ee8fcedea3b6e4f43c11 Mon Sep 17 00:00:00 2001 From: richjohnrambo Date: Fri, 4 Sep 2026 11:57:38 +0800 Subject: [PATCH] feat: add im kafka admin integration --- docs/backstage/IM-Kafka接入方案.md | 904 ++++++++++++++++++ docs/backstage/IM-Kafka接入范围识别.md | 89 ++ .../api/admin/imconfiginternal/im_config.go | 59 ++ .../api/admin/imdeadletter/im_dead_letter.go | 57 ++ server/api/admin/imgroup/im_group.go | 80 ++ .../api/admin/imkafkaaudit/im_kafka_audit.go | 27 + .../admin/imkafkacommand/im_kafka_command.go | 50 + server/api/admin/imuser/im_user.go | 72 ++ server/go.mod | 3 + server/go.sum | 7 + server/internal/cmd/http.go | 3 + server/internal/consts/im_kafka.go | 88 ++ server/internal/consts/queue.go | 8 + .../controller/admin/sys/im_dead_letter.go | 66 ++ .../internal/controller/admin/sys/im_group.go | 88 ++ .../controller/admin/sys/im_kafka_audit.go | 36 + .../controller/admin/sys/im_kafka_command.go | 61 ++ .../internal/controller/admin/sys/im_user.go | 80 ++ .../internalapi/imconfig/im_config.go | 144 +++ server/internal/crons/im_admin_outbox.go | 38 + server/internal/library/imadmin/config.go | 34 +- server/internal/library/imadmin/internal.go | 101 ++ server/internal/library/iminternal/auth.go | 135 +++ server/internal/library/imkafka/command.go | 378 ++++++++ server/internal/library/imkafka/envelope.go | 90 ++ server/internal/library/queue/disk.go | 2 +- server/internal/library/queue/kafkamq.go | 101 +- server/internal/library/queue/queue.go | 58 +- server/internal/logic/sys/im_dead_letter.go | 203 ++++ server/internal/logic/sys/im_group.go | 222 +++++ server/internal/logic/sys/im_kafka_audit.go | 86 ++ server/internal/logic/sys/im_kafka_command.go | 187 ++++ server/internal/logic/sys/im_registration.go | 21 + server/internal/logic/sys/im_tenant.go | 39 +- server/internal/logic/sys/im_user.go | 101 ++ .../model/input/sysin/im_dead_letter.go | 130 +++ server/internal/model/input/sysin/im_group.go | 69 ++ .../model/input/sysin/im_kafka_audit.go | 65 ++ .../model/input/sysin/im_kafka_command.go | 126 +++ server/internal/model/input/sysin/im_user.go | 42 + .../queues/im_admin_command_result.go | 52 + server/internal/queues/im_audit_event.go | 54 ++ server/internal/queues/im_dead_letter.go | 74 ++ .../router/genrouter/im_dead_letter.go | 7 + .../router/genrouter/im_kafka_audit.go | 7 + .../router/genrouter/im_kafka_command.go | 7 + server/internal/router/internal.go | 18 + server/internal/service/sys.go | 96 ++ server/manifest/config/config.example.yaml | 18 +- .../data/generate/im_dead_letter_menu.sql | 35 + .../storage/data/generate/im_group_menu.sql | 32 + .../data/generate/im_kafka_admin_tables.sql | 57 ++ .../data/generate/im_kafka_audit_menu.sql | 23 + .../data/generate/im_kafka_command_menu.sql | 31 + .../data/generate/im_kafka_event_tables.sql | 50 + server/storage/data/generate/im_user_menu.sql | 32 + web/src/api/imDeadLetter/index.ts | 46 + web/src/api/imGroup/index.ts | 72 ++ web/src/api/imKafkaAudit/index.ts | 17 + web/src/api/imKafkaCommand/index.ts | 37 + web/src/api/imUser/index.ts | 32 + web/src/views/imDeadLetter/index.vue | 88 ++ web/src/views/imDeadLetter/model.ts | 122 +++ web/src/views/imDeadLetter/view.vue | 123 +++ web/src/views/imGroup/governance.vue | 138 ++- web/src/views/imGroup/members.vue | 118 ++- web/src/views/imKafkaAudit/index.vue | 72 ++ web/src/views/imKafkaAudit/model.ts | 106 ++ web/src/views/imKafkaAudit/view.vue | 79 ++ web/src/views/imKafkaCommand/index.vue | 127 +++ web/src/views/imKafkaCommand/model.ts | 139 +++ web/src/views/imKafkaCommand/view.vue | 86 ++ web/src/views/imUser/governance.vue | 142 +++ web/src/views/imUser/index.vue | 14 +- 74 files changed, 6254 insertions(+), 47 deletions(-) create mode 100644 docs/backstage/IM-Kafka接入方案.md create mode 100644 docs/backstage/IM-Kafka接入范围识别.md create mode 100644 server/api/admin/imconfiginternal/im_config.go create mode 100644 server/api/admin/imdeadletter/im_dead_letter.go create mode 100644 server/api/admin/imkafkaaudit/im_kafka_audit.go create mode 100644 server/api/admin/imkafkacommand/im_kafka_command.go create mode 100644 server/internal/consts/im_kafka.go create mode 100644 server/internal/controller/admin/sys/im_dead_letter.go create mode 100644 server/internal/controller/admin/sys/im_kafka_audit.go create mode 100644 server/internal/controller/admin/sys/im_kafka_command.go create mode 100644 server/internal/controller/internalapi/imconfig/im_config.go create mode 100644 server/internal/crons/im_admin_outbox.go create mode 100644 server/internal/library/imadmin/internal.go create mode 100644 server/internal/library/iminternal/auth.go create mode 100644 server/internal/library/imkafka/command.go create mode 100644 server/internal/library/imkafka/envelope.go create mode 100644 server/internal/logic/sys/im_dead_letter.go create mode 100644 server/internal/logic/sys/im_kafka_audit.go create mode 100644 server/internal/logic/sys/im_kafka_command.go create mode 100644 server/internal/model/input/sysin/im_dead_letter.go create mode 100644 server/internal/model/input/sysin/im_kafka_audit.go create mode 100644 server/internal/model/input/sysin/im_kafka_command.go create mode 100644 server/internal/queues/im_admin_command_result.go create mode 100644 server/internal/queues/im_audit_event.go create mode 100644 server/internal/queues/im_dead_letter.go create mode 100644 server/internal/router/genrouter/im_dead_letter.go create mode 100644 server/internal/router/genrouter/im_kafka_audit.go create mode 100644 server/internal/router/genrouter/im_kafka_command.go create mode 100644 server/internal/router/internal.go create mode 100644 server/storage/data/generate/im_dead_letter_menu.sql create mode 100644 server/storage/data/generate/im_kafka_admin_tables.sql create mode 100644 server/storage/data/generate/im_kafka_audit_menu.sql create mode 100644 server/storage/data/generate/im_kafka_command_menu.sql create mode 100644 server/storage/data/generate/im_kafka_event_tables.sql create mode 100644 web/src/api/imDeadLetter/index.ts create mode 100644 web/src/api/imKafkaAudit/index.ts create mode 100644 web/src/api/imKafkaCommand/index.ts create mode 100644 web/src/views/imDeadLetter/index.vue create mode 100644 web/src/views/imDeadLetter/model.ts create mode 100644 web/src/views/imDeadLetter/view.vue create mode 100644 web/src/views/imKafkaAudit/index.vue create mode 100644 web/src/views/imKafkaAudit/model.ts create mode 100644 web/src/views/imKafkaAudit/view.vue create mode 100644 web/src/views/imKafkaCommand/index.vue create mode 100644 web/src/views/imKafkaCommand/model.ts create mode 100644 web/src/views/imKafkaCommand/view.vue create mode 100644 web/src/views/imUser/governance.vue diff --git a/docs/backstage/IM-Kafka接入方案.md b/docs/backstage/IM-Kafka接入方案.md new file mode 100644 index 0000000..5f23e0c --- /dev/null +++ b/docs/backstage/IM-Kafka接入方案.md @@ -0,0 +1,904 @@ +# IM Kafka 接入方案 + +> 日期:2026-08-26 +> 关联文档:[IM Kafka 接入范围识别](./IM-Kafka接入范围识别.md)、[IM 前后端逐图对接协议](./IM-前后端对接文档.md) +> 目标:明确 Admin 后台、IM 服务、HotGo 控制库、租户业务库之间的 Kafka 接入边界、查询方式、鉴权方式和首批落地范围。 + +## 0. 与范围识别文档的关系 + +[IM Kafka 接入范围识别](./IM-Kafka接入范围识别.md) 是本方案的前置输入,负责按前后端对接文档逐章识别哪些业务适合 Kafka、哪些仍应走 WebSocket/Redis/MySQL/OSS。本方案在该识别结果基础上,进一步确定服务边界、库访问边界、前后台交互模型、内部查询接口、鉴权方式和首批落地步骤。 + +两份文档分工如下: + +| 文档 | 作用 | +| --- | --- | +| [IM Kafka 接入范围识别](./IM-Kafka接入范围识别.md) | 回答“哪些业务该用 Kafka,哪些不该用”。 | +| 本方案 | 回答“Kafka 怎样接入,Admin 与 IM 怎样交互,服务和库怎样隔离”。 | + +实施时以范围识别文档的逐章清单确定业务覆盖面,以本方案确定技术实现方式。若后续新增需求,先补充范围识别,再同步调整本方案的 Topic、Command、内部接口和鉴权规则。 + +## 1. 核心原则 + +系统按“服务和库一一对应”设计,不允许不同服务直连对方数据库。 + +| 服务 | 只能直接访问 | 说明 | +| --- | --- | --- | +| Admin/HotGo 服务 | `hotgo` 控制库 | 管理后台、租户主数据、后台配置、审核、操作单。 | +| IM 服务 | 当前租户业务库 | 用户、好友、群、消息、文件引用、工作台业务数据。 | +| Redis | IM 实时状态与缓存 | 在线状态、短状态、热点消息、缓存分层。 | +| Kafka | Admin 与 IM 的命令、事件、结果桥 | 后台写操作、异步任务、业务事件、审计、缓存失效。 | +| OSS | 文件对象存储 | 上传下载字节流不进入 Kafka。 | + +最终边界: + +1. Admin 不直连租户库。 +2. IM 不直连 `hotgo` 库。 +3. Admin 写租户业务操作统一通过 Kafka Command。 +4. IM 写后台业务状态不直写 `hotgo`,通过 Kafka Result/Event 返回 Admin。 +5. Admin 查询租户业务数据通过 IM 内部只读 API。 +6. IM 查询租户配置、字典、路由通过 Admin 内部只读 API,或消费 Kafka 配置事件维护本地副本。 +7. 在线实时投递继续走 Redis + WebSocket,Kafka 不替代 WS 主链路。 + +## 2. 总体架构 + +```text +Admin/HotGo 服务 + -> 写 hotgo 操作单 + -> 生产 Kafka Command + -> IM 服务消费 Command + -> 写租户业务库 + -> 生产 Kafka Result/Event + -> Admin 消费 Result/Event + -> 更新 hotgo 操作状态 + +Admin 查询租户业务数据 + -> 调 IM 内部只读 API + -> IM 查租户业务库 + -> 返回 Admin + +IM 查询租户配置/字典/路由 + -> 优先读本地缓存/本地副本 + -> 缓存缺失时调 Admin 内部只读配置 API + -> Admin 查 hotgo + -> 返回 IM +``` + +## 3. Kafka 使用范围 + +| 场景 | 是否使用 Kafka | 说明 | +| --- | --- | --- | +| Admin 后台群操作通知 C 端 | 是 | 后台写 hotgo 操作单,投 Kafka,IM 执行租户库变更并 WS 下发。 | +| Admin 后台用户封禁、解封、重置密码 | 是 | IM 消费后更新租户库、踢在线、通知客户端。 | +| Admin 后台租户配置、字典、功能开关变更 | 是 | Admin 写 hotgo 后投配置事件,IM 刷新缓存或本地副本。 | +| 企业注册、实名、举报、反馈审核结果 | 是 | Admin 审核后投 Kafka,IM 生成通知或更新租户侧状态。 | +| 工作台会议、日报、月报、信箱通知 | 是 | 状态变更、提醒、红点更新走 Kafka。 | +| 私聊/群聊消息异步落库 | 是 | WS 收消息后完成鉴权、幂等和 seq,再投 Kafka 异步持久化。 | +| 文件安全检测、恶意文件检测 | 是 | OSS 上传完成后投检测任务,消费者调用第三方并更新状态。 | +| 离线 Push | 是 | 消息或业务事件落地后投 Push 任务。 | +| 在线消息实时 fanout | 不作为主链路 | 继续 Redis Pub/Sub/Stream + WS。 | +| 文件上传下载字节流 | 否 | 走 OSS/文件服务;Kafka 只处理上传完成后的事件。 | +| 验证码、Action Token、限流、在线状态 | 否 | 继续 Redis。 | +| 普通查询接口 | 否 | 走内部只读 API 或本服务数据库。 | + +## 4. 前后台交互模型 + +### 4.1 后台写租户业务 + +Admin 后台所有会影响租户业务库或 C 端展示的写操作,统一走 Kafka Command。 + +```text +Admin + 1. 校验后台管理员权限 + 2. 写 hotgo.admin_command,状态 pending + 3. 发送 Kafka command + +IM + 4. 消费 command + 5. 校验租户、对象、版本、权限和幂等 + 6. 写租户业务库 + 7. 必要时通过 WS 通知在线 C 端 + 8. 发送 Kafka result + +Admin + 9. 消费 result + 10. 更新 hotgo.admin_command 状态 +``` + +典型操作: + +| 操作 | Command | IM 执行动作 | C 端通知 | +| --- | --- | --- | --- | +| 群资料修改 | `group.profile.update` | 更新群表和 Topic 资料 | `group.profile.updated` | +| 设置/删除管理员 | `group.admin.set` / `group.admin.remove` | 更新成员角色 | `group.role.updated` | +| 禁言/解禁 | `group.member.mute` / `group.member.unmute` | 更新禁言表 | `group.mute.updated` | +| 踢人 | `group.member.remove` | 更新成员状态和订阅 | `group.member.removed` | +| 解散群 | `group.delete` | 解散 Topic、撤销权限 | `group.deleted` | +| 用户封禁 | `user.disable` | 更新用户状态、踢在线 | `user.disabled` | +| 重置密码 | `user.password.reset` | 更新密码和 `auth_version` | 可选安全通知 | + +### 4.2 后台查询租户业务 + +Kafka 不适合做普通查询。Admin 需要实时查看租户业务数据时,调用 IM 提供的内部只读 API。 + +只读 API 只允许 `GET`,不提供通用 SQL 或万能查询接口。 + +| 接口 | 用途 | +| --- | --- | +| `GET /internal/admin-query/tenants/{tenant_code}/users/{user_id}` | 查询租户用户详情。 | +| `GET /internal/admin-query/tenants/{tenant_code}/groups/{topic}` | 查询群详情。 | +| `GET /internal/admin-query/tenants/{tenant_code}/groups/{topic}/members` | 查询群成员。 | +| `GET /internal/admin-query/tenants/{tenant_code}/messages/{topic}/{seq}` | 查询消息受控快照。 | +| `GET /internal/admin-query/tenants/{tenant_code}/files/{file_id}` | 查询文件引用和审核状态。 | +| `GET /internal/admin-query/tenants/{tenant_code}/reports/abuse/{report_id}/target` | 查询举报目标快照。 | +| `GET /internal/admin-query/tenants/{tenant_code}/kafka/dead-letters` | 查询 IM 租户库 Kafka 死信列表。 | +| `GET /internal/admin-query/tenants/{tenant_code}/kafka/dead-letters/{dead_id}` | 查询单条死信详情和原始 envelope。 | + +后台列表、统计、高频页面优先使用 `hotgo` 摘要表。摘要由 IM 通过 Kafka 事件同步给 Admin,Admin 查询自己的库。 + +推荐摘要表: + +| 摘要表 | 来源 | +| --- | --- | +| `admin_im_user_snapshot` | IM 用户资料变更事件。 | +| `admin_im_group_snapshot` | IM 群资料、成员数、状态变更事件。 | +| `admin_im_group_member_stat` | 群成员统计事件。 | +| `admin_im_file_audit_snapshot` | 文件审核和引用状态事件。 | +| `admin_im_workbench_snapshot` | 工作台红点、会议、日报/月报状态事件。 | + +### 4.3 IM 查询后台配置 + +IM 不直连 `hotgo`。IM 需要租户路由、租户状态、字典、第三方配置时,使用两种方式: + +1. 常态:Admin 修改配置后发送 Kafka 配置事件,IM 消费后更新进程内缓存或本地副本。 +2. 兜底:IM 缓存缺失或启动预热时,调用 Admin 内部只读配置 API。 + +| 接口 | 用途 | +| --- | --- | +| `GET /internal/im-config/tenants/{tenant_code}` | 查询租户状态、名称、路由标识。 | +| `GET /internal/im-config/tenants/{tenant_code}/limits` | 查询人数、群数、上传大小等限制。 | +| `GET /internal/im-config/tenants/{tenant_code}/features` | 查询功能开关。 | +| `GET /internal/im-config/dictionaries?types=oss,file_security,sms,jpush,agora` | 查询字典配置。 | +| `GET /internal/im-config/app/releases/latest` | 查询 App 版本配置。 | + +Admin 内部配置 API 只返回 IM 运行需要的字段,不返回后台管理员账号、菜单权限、审核私有字段。 + +## 5. Topic 规划 + +| Topic | 用途 | +| --- | --- | +| `im.admin.command` | Admin 发给 IM 的租户业务写命令。 | +| `im.admin.command.result` | IM 回传命令执行结果。 | +| `im.config.event` | Admin 发给 IM 的配置、字典、租户状态变更。 | +| `im.business.event` | IM 内部或 Admin 触发的 C 端业务事件。 | +| `im.message.persist` | 私聊、群聊消息异步落库。 | +| `im.message.push` | 离线 Push 任务。 | +| `im.file.security` | 文件内容安全、恶意文件检测任务。 | +| `im.file.lifecycle` | 文件引用绑定、释放、回收、合规删除。 | +| `im.workbench.event` | 会议、日报、月报、信箱、工作台红点事件。 | +| `im.cache.invalidate` | 跨节点缓存失效。 | +| `im.audit.event` | 审计事件。 | +| `im.dead.letter` | 消费失败死信。 | + +首版可合并部分 Topic,但事件体必须保留 `biz_type` 和 `event_type`,方便后续拆分。 + +## 6. 消息结构 + +统一 Kafka Envelope: + +```json +{ + "event_id": "evt_01K...", + "trace_id": "req_01K...", + "tenant_code": "default", + "source_service": "hotgo-admin", + "target_service": "im", + "event_type": "group.member.mute", + "biz_type": "group", + "biz_id": "grp...", + "idempotency_key": "idem_01K...", + "version": 3, + "occurred_at": "2026-08-26T10:00:00.000Z", + "actor": { + "type": "admin", + "id": "admin_1", + "display_name": "管理员" + }, + "payload": {} +} +``` + +Command Result: + +```json +{ + "event_id": "evt_result_01K...", + "trace_id": "req_01K...", + "tenant_code": "default", + "command_id": "cmd_01K...", + "command_event_id": "evt_01K...", + "status": "succeeded", + "reason": "", + "message": "", + "result": {}, + "occurred_at": "2026-08-26T10:00:01.000Z" +} +``` + +失败时 `status=failed`,`reason` 使用稳定错误码,例如 `GROUP_VERSION_CONFLICT`、`FORBIDDEN`、`RESOURCE_NOT_FOUND`、`COMMAND_DUPLICATED`。 + +## 7. 幂等、事务与重试 + +### 7.1 生产端 + +Admin 写操作必须先落 `hotgo.admin_command`,再发送 Kafka。推荐使用 outbox 模式: + +1. 同一事务写 `admin_command` 和 `admin_outbox`。 +2. outbox poller 发送 Kafka。 +3. Kafka 发送成功后标记 outbox 已发送。 +4. 发送失败按退避重试。 + +IM 内部业务事件也按同样方式写租户库 outbox,避免“数据库成功、事件丢失”。 + +### 7.2 消费端 + +每个消费者维护消费幂等记录: + +| 字段 | 说明 | +| --- | --- | +| `event_id` | Kafka 事件唯一 ID。 | +| `consumer_group` | 消费组。 | +| `status` | `processing/succeeded/failed/dead`。 | +| `retry_count` | 重试次数。 | +| `last_error` | 最近失败原因。 | +| `created_at/updated_at` | 时间。 | + +消费前先插入或锁定 `event_id + consumer_group`。已成功的事件直接跳过;处理中超时可重新抢占。业务表必须再用唯一键兜底,例如 `tenant + command_id`、`tenant + client_mid`、`tenant + topic + seq`。 + +### 7.3 重试与死信 + +| 类型 | 策略 | +| --- | --- | +| 临时失败 | 指数退避重试。 | +| 版本冲突 | 不自动重试,回传 failed,由 Admin 刷新后重新发命令。 | +| 权限失败 | 不重试。 | +| 对象不存在 | 不重试,除非明确是延迟同步。 | +| 第三方失败 | 可重试,超过次数写死信和失败表。 | + +### 7.4 消费失败处理流程 + +消费者收到消息后按固定流程处理: + +```text +收到 Kafka 消息 + -> 校验 Envelope 格式、签名字段、租户和事件类型 + -> 抢占 event_id + consumer_group 幂等记录 + -> 已 succeeded:提交 offset,跳过 + -> processing 超时:允许重新抢占 + -> 执行业务逻辑 + -> 成功:标记 succeeded,提交 offset + -> 可重试失败:retry_count + 1,按退避策略延迟重试 + -> 超过最大重试:写入死信,标记 dead,提交 offset + -> 不可重试失败:标记 failed 或 dead,必要时回传 command result,提交 offset +``` + +重试建议参数: + +| 参数 | 建议值 | 说明 | +| --- | --- | --- | +| `max_retry_count` | 5~10 | 第三方接口可取高值,权限/参数错误不重试。 | +| `retry_backoff` | 10s、30s、1m、5m、15m | 指数退避,可加随机抖动。 | +| `processing_timeout` | 5m~30m | 超过后允许其他消费者重新抢占。 | +| `dead_letter_retention` | 30~90 天 | 便于排障和人工补偿。 | + +### 7.5 死信队列处理 + +进入死信表示该事件已超过自动恢复能力,系统不能再无限重试。死信必须形成“可查看、可诊断、可重放、可关闭”的闭环。 + +死信写入两处: + +1. Kafka `im.dead.letter` Topic:保留原始事件,便于流式订阅、告警和后续自动补偿。 +2. 数据库失败表:便于 Admin 后台查询、人工处理和审计。 + +推荐失败表字段: + +| 字段 | 说明 | +| --- | --- | +| `dead_id` | 死信记录 ID。 | +| `event_id` | 原 Kafka 事件 ID。 | +| `source_topic` | 原 Topic。 | +| `consumer_group` | 失败消费组。 | +| `tenant_code` | 租户编码。 | +| `event_type` | 事件类型。 | +| `biz_type` / `biz_id` | 业务对象。 | +| `payload` | 原始 Envelope。 | +| `retry_count` | 已重试次数。 | +| `last_reason` | 稳定失败原因。 | +| `last_error` | 最近错误摘要,敏感字段脱敏。 | +| `status` | `dead/pending_replay/replayed/ignored/resolved`。 | +| `replay_count` | 人工重放次数。 | +| `created_at/updated_at` | 时间。 | +| `resolved_by/resolved_at` | 人工处理人和处理时间。 | +| `resolve_note` | 处理备注。 | + +死信来源分类: + +| 分类 | 示例 | 默认处理 | +| --- | --- | --- | +| 临时资源失败超过上限 | DB 短暂不可用、第三方超时 | 修复环境后允许重放。 | +| 业务状态冲突 | 群版本冲突、对象已被删除 | 不自动重放,Admin 刷新状态后重新发命令。 | +| 权限失败 | 管理员无权限、租户停用 | 不重放,标记 ignored 或 failed result。 | +| 参数错误 | Envelope 缺字段、payload 非法 | 不重放,修复生产方后关闭。 | +| 第三方永久失败 | 文件违规、Push token 无效 | 按业务结果落状态,不重放或仅重放特定步骤。 | + +Admin 后台需要提供死信处理页: + +| 操作 | 说明 | +| --- | --- | +| 查看详情 | 展示事件、租户、业务对象、错误原因、重试次数和 trace_id。 | +| 重放 | 将原事件或修正后的事件重新投递到原 Topic,生成新的 `event_id`,保留 `parent_event_id`。 | +| 忽略 | 确认该事件无需继续处理,状态改为 `ignored`。 | +| 标记已处理 | 线下补偿完成后改为 `resolved`。 | +| 重新生成命令 | 对版本冲突类命令,不直接重放旧事件,而是基于最新状态创建新 Command。 | + +重放规则: + +1. 重放必须生成新的 `event_id`,原 `event_id` 保留在 `parent_event_id`。 +2. 重放前重新校验租户状态、对象是否存在、操作者权限和业务版本。 +3. 参数错误、权限失败、版本冲突默认禁止直接重放。 +4. 文件安全检测、Push、审计写入等可重试任务允许重放。 +5. 每次重放都写审计,记录处理人、原因、原事件和新事件。 + +死信告警: + +| 指标 | 建议 | +| --- | --- | +| 单 Topic 死信数 | 5 分钟内超过阈值告警。 | +| 同一 `event_type` 连续失败 | 可能是代码或配置问题,立即告警。 | +| 同一租户大量失败 | 检查租户库、配置或权限状态。 | +| 死信积压时间 | 超过 24 小时未处理提醒。 | + +对 Admin Command 类事件,进入死信后必须回传 `im.admin.command.result`,状态为 `failed` 或 `dead`,让后台操作单结束等待状态。否则后台会长期显示处理中。 + +## 8. 内部接口鉴权 + +内部 API 鉴权非常关键,必须同时做网络层、服务层和业务层控制。 + +### 8.1 网络层 + +1. `/internal/*` 只允许内网网关或内网地址访问。 +2. 禁止公网直接暴露内部接口。 +3. 有条件时启用 mTLS。 + +### 8.2 服务签名 + +内部请求统一携带: + +| Header | 说明 | +| --- | --- | +| `X-Service-Name` | 调用方服务名,例如 `hotgo-admin` 或 `im-server`。 | +| `X-Timestamp` | Unix 秒或毫秒时间戳。 | +| `X-Nonce` | 随机串,短期内不可重复。 | +| `X-Request-ID` | 请求追踪 ID。 | +| `X-Signature` | HMAC 签名。 | + +签名内容: + +```text +METHOD + "\n" + +PATH_WITH_QUERY + "\n" + +BODY_SHA256 + "\n" + +X-Timestamp + "\n" + +X-Nonce +``` + +校验规则: + +1. 时间窗建议 5 分钟。 +2. `nonce` 写 Redis,TTL 5 分钟,防重放。 +3. 每个服务独立 `service_id/service_secret`。 +4. 每个服务配置允许访问的内部接口白名单。 + +### 8.3 业务授权 + +Admin 调 IM 查询时,签名 payload 或 Header 中必须包含后台操作者信息: + +| 字段 | 说明 | +| --- | --- | +| `admin_user_id` | 后台用户 ID。 | +| `admin_role` | 后台角色。 | +| `admin_permissions` | 本次已校验过的权限列表。 | +| `tenant_code` | 被查询租户。 | +| `reason` | 查询原因或工单号。 | + +IM 仍需根据 `tenant_code + permission + resource` 做二次校验,并写内部查询审计。 + +IM 调 Admin 配置接口时,Admin 只校验服务身份和接口白名单,不返回超出 IM 运行需要的字段。 + +## 9. 文件与工作台 + +### 9.1 文件 + +文件上传下载不走 Kafka,OSS 是文件字节流主链路。Kafka 只处理上传完成后的事件: + +1. `im.file.security`:内容安全、恶意文件检测。 +2. `im.file.lifecycle`:业务引用绑定、释放、临时文件回收、合规删除。 +3. `im.business.event`:文件审核失败、合规处置等 C 端通知。 + +### 9.2 工作台 + +工作台查询不走 Kafka,状态变化和提醒走 Kafka: + +1. 会议创建、更新、取消、提醒、响应:`im.workbench.event`。 +2. 日报/月报提交、撤回、重开、催办:`im.workbench.event`。 +3. 总经理信箱提交、处理结果:`im.workbench.event` + `im.audit.event`。 +4. 工作台模块配置变更:`im.config.event` 或 `im.cache.invalidate`。 + +## 10. 首批落地建议 + +| 阶段 | 内容 | +| --- | --- | +| 第一步 | 增加 Kafka 配置、生产者、消费者、健康检查、统一 Envelope。 | +| 第二步 | 增加消费幂等表、outbox 表、死信表或死信 Topic。 | +| 第三步 | 接入 `im.admin.command` 和 `im.admin.command.result`,先覆盖后台群治理操作。 | +| 第四步 | 增加 IM 内部只读查询 API,供 Admin 查用户、群、消息、文件快照。 | +| 第五步 | 增加 Admin 内部配置 API,供 IM 查询租户配置、字典、第三方参数。 | +| 第六步 | 接入文件安全检测、离线 Push、工作台事件。 | +| 第七步 | 消息异步落库接入 `im.message.persist`,完善幂等和重试。 | + +## 11. 分端任务拆分 + +### 11.1 Admin/HotGo 侧任务 + +Admin 侧只访问 `hotgo` 库,负责后台操作入口、配置主数据、命令生产、结果消费和死信处理。 + +| 模块 | 任务 | 产出 | +| --- | --- | --- | +| Kafka 基础 | 增加 Kafka producer/consumer 配置、健康检查、统一 Envelope 组装。 | 可发送 `im.admin.command`、消费 `im.admin.command.result`。 | +| 操作单 | 新增 `admin_command`、`admin_outbox` 表和状态机。 | `pending/running/succeeded/failed/dead` 操作状态。 | +| Outbox | 后台写操作单与 outbox 同事务落库,poller 发送 Kafka。 | 防止 hotgo 写成功但 Kafka 丢事件。 | +| Command 生产 | 群治理、用户封禁/解封、重置密码、审核结果、工作台配置等后台操作生成 Command。 | `im.admin.command` 事件。 | +| Result 消费 | 消费 IM 回传结果并更新操作单。 | 后台页面可展示最终成功/失败原因。 | +| 配置事件 | 租户配置、字典、功能开关、第三方配置变更后发送 `im.config.event`。 | IM 刷新缓存或本地副本。 | +| 配置查询 API | 提供 `/internal/im-config/*` 只读接口。 | IM 启动预热和缓存缺失兜底查询。 | +| 摘要表 | 建立用户、群、文件、工作台等后台摘要表。 | 后台列表统计优先查 hotgo。 | +| 死信后台 | 提供死信查看、重放、忽略、标记已处理、重新生成命令。 | 运维闭环。 | +| 内部鉴权 | 实现服务签名校验、nonce 防重放、接口白名单、审计。 | 保护 Admin 内部配置 API。 | +| 审计 | 记录后台操作者、租户、资源、Command、Result、死信处理。 | 可追踪后台操作链路。 | + +Admin 首批优先级: + +1. Kafka 基础配置、Envelope、producer。 +2. `admin_command` + `admin_outbox`。 +3. 群治理 Command 生产。 +4. `im.admin.command.result` 消费。 +5. `/internal/im-config/*` 配置查询 API。 +6. 死信管理页。 + +### 11.2 IM Server 侧任务 + +IM Server 侧只访问租户业务库,负责执行 Admin Command、维护租户业务状态、通过 WS 通知 C 端,并向 Admin 回传结果。 + +| 模块 | 任务 | 产出 | +| --- | --- | --- | +| Kafka 基础 | 增加 Kafka producer/consumer 配置、健康检查、统一 Envelope 解析。 | 可消费 Command、生产 Result/Event。 | +| 消费幂等 | 新增租户库消费幂等表或统一消费日志。 | `event_id + consumer_group` 幂等。 | +| Command 消费 | 消费 `im.admin.command`。 | 后台命令进入 IM 执行链路。 | +| 群治理执行器 | 执行群资料、管理员、禁言、踢人、解散、群主转让等命令。 | 更新租户库和 Topic 状态。 | +| 用户治理执行器 | 执行封禁、解封、重置密码、踢在线等命令。 | 更新用户状态和 `auth_version`。 | +| Result 生产 | 执行成功/失败后发送 `im.admin.command.result`。 | Admin 操作单闭环。 | +| WS 通知 | Command 成功后生产 C 端业务事件并投递在线客户端。 | `info.what="im|group"` 下发。 | +| 配置消费 | 消费 `im.config.event`,刷新进程内缓存或本地副本。 | 租户配置、字典、第三方配置生效。 | +| 配置兜底查询 | 调 Admin `/internal/im-config/*`。 | IM 启动预热和缓存缺失恢复。 | +| 内部只读 API | 提供 `/internal/admin-query/*`。 | Admin 实时查询租户业务数据。 | +| 文件任务 | 上传完成后生产/消费 `im.file.security`、`im.file.lifecycle`。 | 文件审核、引用绑定、回收。 | +| 消息异步落库 | 接入 `im.message.persist`。 | 私聊/群聊消息削峰落库。 | +| Push 任务 | 生产 `im.message.push`。 | 离线 Push 异步投递。 | +| 死信处理 | 写失败表、投 `im.dead.letter`、Command 死信回传 Result。 | 失败可诊断、可补偿。 | +| 内部鉴权 | 校验 Admin 调用 `/internal/admin-query/*` 的签名、权限和审计。 | 保护租户业务查询。 | + +IM Server 首批优先级: + +1. Kafka 基础配置、Envelope、consumer/producer。 +2. 消费幂等表和死信写入。 +3. `im.admin.command` 群治理消费执行。 +4. `im.admin.command.result` 回传。 +5. WS 业务事件下发。 +6. `/internal/admin-query/*` 只读查询 API。 + +### 11.3 双方联调契约 + +| 契约 | Admin 负责 | IM Server 负责 | +| --- | --- | --- | +| Envelope 字段 | 生产完整字段,保证 `event_id/trace_id/tenant_code/event_type/idempotency_key`。 | 校验字段完整性和事件类型白名单。 | +| Command 状态 | 创建操作单,展示状态。 | 回传成功、失败、死信结果。 | +| 幂等 | 同一后台操作复用 `command_id/idempotency_key`。 | 同一 Command 重复消费只执行一次。 | +| 错误码 | 展示 IM 返回的稳定 `reason`。 | 返回稳定 `reason`,不让 Admin 解析错误文本。 | +| 查询接口 | 带管理员身份、权限、原因和签名。 | 校验签名、权限和租户资源,写审计。 | +| 配置接口 | 提供最小必要配置字段。 | 不直连 hotgo,使用缓存、事件或内部 API。 | +| 死信 | 提供处理入口和人工操作审计。 | 写死信、回传 Command dead/failed 状态。 | +| Trace | 生成并传递 `trace_id`。 | 日志、Kafka、WS 下发沿用同一 `trace_id`。 | + +### 11.4 首批联调用例 + +| 用例 | 预期 | +| --- | --- | +| Admin 禁言群成员 | Admin 操作单 pending,IM 消费后更新租户库,C 端收到 `group.mute.updated`,Admin 状态 succeeded。 | +| Admin 重复提交同一禁言操作 | IM 只执行一次,Result 返回同一最终状态。 | +| Admin 删除管理员但群版本冲突 | IM 返回 `GROUP_VERSION_CONFLICT`,Admin 操作单 failed,不自动重试。 | +| IM 消费时临时 DB 失败 | 自动退避重试,未超限不进入死信。 | +| IM 消费超过重试上限 | 写 `im.dead.letter`,Admin 操作单进入 dead/failed。 | +| Admin 查询群成员 | Admin 调 `/internal/admin-query/*`,IM 校验签名和权限后返回只读数据。 | +| IM 启动加载租户配置 | IM 优先读缓存,缺失时调 `/internal/im-config/*`,Admin 只返回必要配置。 | + +### 11.5 IM Server 已接入的首批 Admin Command + +IM Server 首批消费 `im.admin.command`,执行后回传 `im.admin.command.result`。所有命令必须带 `tenant_code`,`payload.actor_user_id` 必须是租户内 IM 用户 ID,用于落群事件和 C 端通知。 + +| Command `event_type` | 说明 | 必填 payload | +| --- | --- | --- | +| `group.profile.update` | 修改群名称、头像、简介。 | `command_id,actor_user_id,topic,profile,version` | +| `group.governance.update` | 修改进群审批、全员禁言、成员邀请等治理开关。 | `command_id,actor_user_id,topic,governance,version` | +| `group.admin.set` | 设置一个或多个管理员。 | `command_id,actor_user_id,topic,user_ids` | +| `group.admin.remove` | 删除管理员,降为普通成员。 | `command_id,actor_user_id,topic,user_id` | +| `group.member.mute` | 禁言成员。 | `command_id,actor_user_id,topic,user_id,reason,expires_at` | +| `group.member.unmute` | 解除禁言。 | `command_id,actor_user_id,topic,user_id` | +| `group.member.remove` | 移出群成员。 | `command_id,actor_user_id,topic,user_id` | +| `group.owner.transfer` | 转让群主。 | `command_id,actor_user_id,topic,target_user_id,version` | + +当前 IM Server 消费 `im.admin.command` 时已经接入 `im_kafka_consume_log`: + +1. 按 `event_id + consumer_group` 抢占消费。 +2. 已成功事件直接跳过,不重复执行业务。 +3. 处理中且未超时的事件跳过,避免并发重复执行。 +4. 失败或处理超时的事件允许重新抢占。 +5. 不可重试错误或超过重试次数后写入 `im_kafka_dead_letters`,并投递 `im.dead.letter`。 +6. Admin Command 进入死信时回传 `im.admin.command.result`,`status=dead`。 + +### 11.6 IM Server 已接入的配置事件 + +IM Server 首批消费 `im.config.event`,用于 Admin 修改 HotGo 配置后通知 IM 刷新进程内缓存。当前实现以“缓存失效”为主,收到事件后清除对应缓存,下次业务读取时重新加载。 + +| Config `event_type` | 说明 | IM 行为 | +| --- | --- | --- | +| `tenant.updated` | 租户基础信息变更。 | 清理租户 code/id 缓存。 | +| `tenant.status.changed` | 租户启停、状态变化。 | 清理租户 code/id 缓存。 | +| `tenant.limits.updated` | 租户人数、群数、上传限制等变化。 | 清理租户 code/id 缓存。 | +| `tenant.features.updated` | 租户功能开关变化。 | 清理租户 code/id 缓存。 | +| `dictionary.updated` | 字典配置变化。 | 清理第三方/字典配置缓存。 | +| `third_party.updated` | OSS、文件安全、短信、极光、声网等第三方配置变化。 | 按 group 清理第三方配置缓存。 | +| `registration.config.updated` | 注册、短信开关变化。 | 清理注册配置缓存。 | + +事件示例: + +```json +{ + "event_id": "evt_01K_config", + "trace_id": "req_01K", + "tenant_code": "default", + "source_service": "hotgo-admin", + "target_service": "im-server", + "event_type": "third_party.updated", + "biz_type": "config", + "biz_id": "im_third_party", + "occurred_at": "2026-08-26T10:05:00.000Z", + "payload": { + "tenant_code": "default", + "group": "im_third_party", + "keys": ["oss.endpoint", "file_security.enabled"] + } +} +``` + +禁言命令示例: + +```json +{ + "event_id": "evt_01K_mute", + "trace_id": "req_01K", + "tenant_code": "default", + "source_service": "hotgo-admin", + "target_service": "im-server", + "event_type": "group.member.mute", + "biz_type": "group", + "biz_id": "grp...", + "idempotency_key": "cmd_01K_mute", + "occurred_at": "2026-08-26T10:00:00.000Z", + "payload": { + "command_id": "cmd_01K_mute", + "actor_user_id": "usr_admin", + "topic": "grp...", + "user_id": "usr_target", + "reason": "刷屏", + "expires_at": null + } +} +``` + +### 11.7 IM Server 已接入的内部只读查询 API + +为满足“Admin 不直连租户库”的边界,IM Server 提供内部只读查询接口给 HotGo/Admin 调用。接口默认关闭,通过 `internal_api.enabled=true` 开启。 + +请求统一鉴权头: + +| Header | 说明 | +| --- | --- | +| `X-Service-Name` | 调用方服务名,如 `hotgo-admin`。 | +| `X-Timestamp` | Unix 秒级时间戳,默认 300 秒时间窗。 | +| `X-Nonce` | 一次性随机串,用于防重放。 | +| `X-Request-ID` | 调用链路 ID。 | +| `X-Signature` | HMAC-SHA256 签名,支持 hex 或 base64,可带 `sha256=` 前缀。 | + +nonce 防重放优先使用 Redis 短状态 `SETNX + TTL`,Redis 未配置时退化为单节点进程内防重放;生产多节点必须配置 Redis。 + +签名明文: + +```text +METHOD + "\n" + +PATH_WITH_QUERY + "\n" + +SHA256_HEX(BODY) + "\n" + +X-Timestamp + "\n" + +X-Nonce +``` + +当前首批接口: + +| 接口 | 说明 | +| --- | --- | +| `GET /internal/admin-query/tenants/{tenant_code}/users/{user_id}` | 查询租户内用户基础资料。 | +| `GET /internal/admin-query/tenants/{tenant_code}/groups/{topic}` | 查询群资料和治理配置。 | +| `GET /internal/admin-query/tenants/{tenant_code}/groups/{topic}/members` | 查询群成员分页快照,支持 `limit/cursor/role/eligible_role/q`。 | +| `GET /internal/admin-query/tenants/{tenant_code}/messages/{topic}/{seq_id}` | 查询单条私聊或群聊消息,`seq_id` 为 `BIGINT`。 | +| `GET /internal/admin-query/tenants/{tenant_code}/files/{file_id}` | 查询文件元信息和存储位置。 | + +配置示例: + +```json +{ + "internal_api": { + "enabled": true, + "time_window_seconds": 300, + "services": { + "hotgo-admin": { + "secret": "replace-with-random-secret", + "allow": ["admin_query"] + } + } + } +} +``` + +Docker 环境变量: + +| 环境变量 | 说明 | +| --- | --- | +| `INTERNAL_API_ENABLED` | 是否开启内部接口,默认 `false`。 | +| `INTERNAL_API_TIME_WINDOW_SECONDS` | 签名时间窗。 | +| `INTERNAL_API_HOTGO_SERVICE_NAME` | Admin 服务名,默认 `hotgo-admin`。 | +| `INTERNAL_API_HOTGO_SECRET` | Admin 与 IM 共享签名密钥。 | + +### 11.8 IM 访问 Admin 配置查询接口 + +为满足“IM 服务不直连 hotgo 库”的目标,IM Server 增加 Admin 内部配置查询客户端。该客户端默认关闭,开启后 `store.Tenants`、注册短信开关、第三方配置读取会优先请求 Admin 内部接口,失败时仍可回落旧数据库适配器,便于灰度迁移。 + +配置示例: + +```json +{ + "admin_internal_api": { + "enabled": true, + "base_url": "http://hotgo-admin:8080", + "service_name": "im-server", + "secret": "replace-with-random-secret", + "timeout_ms": 3000 + } +} +``` + +接口约定: + +| 接口 | 用途 | +| --- | --- | +| `GET /internal/im-config/tenants/by-code/{tenant_code}` | 按企业码获取租户路由信息,需返回 `id/code/name/state/db_name/db_dsn`。 | +| `GET /internal/im-config/tenants/by-id/{tenant_id}` | 按租户 ID 获取租户路由信息。 | +| `GET /internal/im-config/tenants?state=active&limit=1000` | 获取活跃租户列表,用于 Kafka Outbox 扫描。 | +| `GET /internal/im-config/registration/sms` | 获取注册短信开关,返回 `{"enabled": true}`。 | +| `GET /internal/im-config/third-party/{group}?keys=a,b` | 获取第三方配置,返回 `{"values": {"a": "..."}}`。 | + +鉴权沿用内部接口 HMAC 方案,请求头为 `X-Service-Name`、`X-Timestamp`、`X-Nonce`、`X-Signature`;签名文本为 `METHOD + "\n" + RequestURI + "\n" + BodySHA256 + "\n" + Timestamp + "\n" + Nonce`。 + +Docker 环境变量: + +| 环境变量 | 说明 | +| --- | --- | +| `ADMIN_INTERNAL_API_ENABLED` | 是否启用 IM 到 Admin 的配置查询客户端,默认 `false`。 | +| `ADMIN_INTERNAL_API_BASE_URL` | Admin 内部接口根地址。 | +| `ADMIN_INTERNAL_API_SERVICE_NAME` | IM 服务名,默认 `im-server`。 | +| `ADMIN_INTERNAL_API_SECRET` | Admin 与 IM 共享签名密钥。 | +| `ADMIN_INTERNAL_API_TIMEOUT_MS` | 请求超时时间。 | + +### 11.9 IM Server 已接入的消息异步落库 + +IM Server 已接入 `im.message.persist`,用于把在线发送链路和最终消息落库解耦。 + +当前处理方式: + +1. WS/HTTP 发送入口先同步完成权限校验、业务幂等、`seq_id` 分配和 Topic 游标占用。 +2. Kafka 开启时,发送入口先写租户库 `im_kafka_outbox`,由 Outbox worker 可靠生产 `im.message.persist`,客户端仍立即拿到已分配的 `seq_id`。 +3. Kafka 关闭时,继续走原同步落库路径,兼容本地和未接 Kafka 环境。 +4. 消费者按 `message_kind` 区分私聊与群聊: + - `p2p` 写入 `im_messages`。 + - `group` 写入 `im_group_messages`。 +5. 消费侧复用 `im_kafka_consume_log` 做 `event_id + consumer_group` 幂等。 +6. 失败按统一退避重试,超过上限写入 `im_kafka_dead_letters` 并投递 `im.dead.letter`。 + +### 11.10 IM Server 已接入的缓存失效消费 + +IM Server 已订阅 `im.cache.invalidate`,用于接收 Admin 或其他节点发出的通用缓存失效事件。消费侧复用 `im_kafka_consume_log` 做幂等,失败按统一退避重试,超过上限进入死信。 + +事件体示例: + +```json +{ + "event_id": "cache:tenant:default:1720000000", + "event_type": "cache.tenant.invalidate", + "tenant_code": "default", + "payload": { + "tenant_code": "default", + "tenant_id": 1, + "namespace": "tenant" + } +} +``` + +支持的失效范围: + +| `event_type` / `namespace` | 处理方式 | +| --- | --- | +| `cache.tenant.invalidate` / `tenant` | 清理租户注册表与租户配置进程内缓存。 | +| `cache.registration.invalidate` / `registration` | 清理注册短信开关缓存。 | +| `cache.third_party.invalidate` / `third_party` | 按 `group` 清理第三方配置缓存。 | +| `cache.dictionary.invalidate` / `dictionary` | 按 `group` 清理字典/配置类缓存。 | +| `cache.redis_object.invalidate` / 任意 Redis `namespace` | 按 `keys` 删除用户、群、关系、权限、未读等 Redis 对象缓存。 | +| `cache.group_hot_messages.invalidate` / `group_hot_messages` | 按 `topic` 和可选 `ranges` 清理群热消息缓存。 | + +### 11.11 IM Server 已接入的死信处理闭环 + +IM Server 已将死信从“只写表”补齐为“可查询、可忽略、可解决、可重放”的处理闭环。Admin 不直连租户库,列表和详情通过 IM 内部只读 API 查询;写操作仍通过 `im.admin.command` 投递给 IM。 + +Admin 查询接口: + +| 接口 | 说明 | +| --- | --- | +| `GET /internal/admin-query/tenants/{tenant_code}/kafka/dead-letters?status=dead&limit=50&cursor=` | 死信分页列表,支持 `status/biz_type/biz_id/event_type` 过滤。 | +| `GET /internal/admin-query/tenants/{tenant_code}/kafka/dead-letters/{dead_id}` | 单条死信详情,包含原始 Kafka envelope。 | + +Admin 处理命令: + +| `event_type` | 说明 | Payload 关键字段 | +| --- | --- | --- | +| `kafka.dead_letter.replay` | 删除原消费日志并按原 `source_topic` 重投 envelope,成功后标记 `replayed`。 | `dead_id`、`actor_user_id`、`note` | +| `kafka.dead_letter.ignore` | 人工确认无需处理,标记 `ignored`。 | `dead_id`、`actor_user_id`、`note` | +| `kafka.dead_letter.resolve` | 线下修复后标记 `resolved`。 | `dead_id`、`actor_user_id`、`note` | + +重放规则: + +1. IM 先把 `im_kafka_dead_letters.status` 改为 `pending_replay`,并删除对应 `im_kafka_consume_log`。 +2. IM 按死信记录中的 `source_topic` 重新投递原 envelope。 +3. 投递成功后把死信标记为 `replayed`,并累计 `replay_count`。 +4. 如果重放后仍失败,普通消费者会再次写入新的 dead 记录,保留完整处理痕迹。 +7. 群消息消费成功后继续写入 Redis 热消息缓存,便于 24 小时内热点拉取。 + +事件示例: + +```json +{ + "event_id": "msg.persist.1.grpabc.10086", + "tenant_code": "default", + "source_service": "im-server", + "target_service": "im-server", + "event_type": "message.persist.group", + "biz_type": "message", + "biz_id": "grpabc:10086", + "idempotency_key": "client-mid-001", + "version": 10086, + "occurred_at": "2026-08-26T10:10:00.000Z", + "actor": { + "type": "user", + "id": "usr..." + }, + "payload": { + "message_kind": "group", + "read_by_sender": true, + "attachment_urls": [], + "message": { + "SeqId": 10086, + "Topic": "grpabc", + "From": "abcdef", + "Head": { + "x-im-client-mid": "client-mid-001" + }, + "Content": { + "txt": "hello" + } + } + } +} +``` + +### 11.9 IM Server 已接入的 Kafka Outbox Worker + +IM Server 已接入租户库 `im_kafka_outbox` 通用发送 worker,用于避免“业务库写成功但 Kafka 未发送”的事件丢失。 + +当前能力: + +1. 启动后按 active 租户扫描每个租户库的 `im_kafka_outbox`。 +2. 扫描 `pending/failed` 且已到 `next_retry_at` 的事件。 +3. 发送成功后标记 `sent`、写入 `sent_at`。 +4. 发送失败后标记 `failed`,按 Kafka 配置的 `retry_backoff_ms` 写入下一次重试时间。 +5. 首个接入方为 `im.message.persist`,后续文件生命周期、工作台事件、审计事件可复用同一 outbox。 + +本轮暂不实现 `im.message.push` 离线 Push Kafka 化,已记录到 [IM 第一至十一章后续增强项](./IM-未实现后续增强项.md)。 + +### 11.10 IM Server 已接入的文件生命周期事件 + +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 摘要、审计、回收策略复用。 | + +说明: + +1. Kafka 开启时,消息附件绑定从消息落库消费者中拆出,改由 `im.file.lifecycle` 消费者完成。 +2. Kafka 关闭时,仍沿用原同步绑定路径,兼容本地环境。 +3. 文件对象字节流仍走 OSS/文件服务,Kafka 不承载上传/下载内容。 +4. 合规删除、临时文件 TTL 回收、OSS 删除动作仍保留为后续深化项。 + +### 11.11 IM Server 已接入的工作台事件 + +IM Server 已接入 `im.workbench.event` 的首批生产与消费。 + +当前生产范围: + +| 模块 | 事件 | +| --- | --- | +| 会议 | `meeting.created`、`meeting.updated`、`meeting.canceled`、`meeting.reminded`、`meeting.response.updated` | +| 日报 | `daily_report.draft.created`、`daily_report.draft.saved`、`daily_report.submitted`、`daily_report.reminded` | +| 月报 | `monthly_report.draft.created`、`monthly_report.draft.saved`、`monthly_report.submitted`、`monthly_report.reopened` | +| 总经理信箱 | `mailbox.submitted` | + +当前消费行为: + +1. 消费 `im.workbench.event`。 +2. 按 `target_user_ids` 生成系统通知。 +3. 写入 `im_business_events_outbox`,由现有 WS 业务事件投递器下发在线客户端。 +4. 使用 `im_kafka_consume_log` 做消费幂等,失败按统一重试和死信规则处理。 + +### 11.12 IM Server 已接入的审计事件 + +IM Server 已接入 `im.audit.event` 的可靠生产,审计事件写入租户库 `im_kafka_outbox` 后由 Kafka Outbox worker 发送。 + +当前审计范围: + +| 类型 | 事件 | +| --- | --- | +| 工作台 | 会议、日报、月报、总经理信箱的状态变更。 | +| Admin Command | 后台群治理命令执行成功。 | +| Kafka 死信 | `kafka.dead_letter.created`。 | +| 文件生命周期 | 上传完成、附件绑定、群文件新增/删除。 | + +审计事件当前不在 IM 内部落审计表,预期由 Admin/HotGo 消费 `im.audit.event` 后写入 `hotgo` 审计库或审计表。 + +## 12. 风险与约束 + +1. Kafka 是异步链路,Admin 后台命令不能默认立即成功,只能显示 `pending/running/succeeded/failed`。 +2. 查询不能通过 Kafka 常规化实现,高频实时查询必须走内部只读 API 或 hotgo 摘要表。 +3. 内部 API 不能暴露公网,签名、nonce、防重放和审计必须首批完成。 +4. 配置事件可能丢失或延迟,IM 必须保留启动预热和缓存缺失时的 Admin 配置查询兜底。 +5. Kafka 消费可能重复,所有消费者和业务写入都必须幂等。 +6. Admin 和 IM 不跨库直连后,排障要依赖 `trace_id` 串联 hotgo 操作单、Kafka 事件、IM 执行日志和 WS 下发日志。 diff --git a/docs/backstage/IM-Kafka接入范围识别.md b/docs/backstage/IM-Kafka接入范围识别.md new file mode 100644 index 0000000..7e388a7 --- /dev/null +++ b/docs/backstage/IM-Kafka接入范围识别.md @@ -0,0 +1,89 @@ +# IM Kafka 接入范围识别 + +> 日期:2026-08-26 +> 依据:[IM 前后端逐图对接协议](./IM-前后端对接文档.md) +> 目标:从前后端对接文档逐章识别适合接入 Kafka 的链路,明确 Kafka 与 WebSocket、Redis、MySQL、OSS 的边界。 + +## 1. 总体边界 + +Kafka 主要用于跨系统异步事件、削峰落库、可靠重试、失败补偿、后台操作通知 C 端和审计流水;不替代 WebSocket 的在线实时连接,不替代 Redis 的短状态和热读缓存,也不替代 MySQL 的最终状态。 + +| 类型 | 建议承载 | 说明 | +| --- | --- | --- | +| 在线实时消息投递 | Redis Pub/Sub/Stream + WebSocket | 低延迟 fanout,节点收到后直接写 WS。 | +| 异步落库、重试、补偿 | Kafka | 适合消息持久化、通知、Push、审核、审计等可重放任务。 | +| 短状态、限流、验证码、在线状态 | Redis | TTL 明确,不需要 Kafka。 | +| 最终业务状态 | MySQL | Kafka 消费成功后写入或更新最终状态。 | +| 文件对象存储 | OSS | Kafka 只处理文件元数据、安全检测和生命周期事件。 | + +## 2. 推荐 Topic + +| Topic | 用途 | 典型生产方 | 典型消费方 | +| --- | --- | --- | --- | +| `im.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.dead.letter` | 失败事件归档 | 各消费者 | 运维补偿任务 | + +## 3. 幂等与重试 + +Kafka 消息统一携带 `event_id`、`trace_id`、`tenant_code`、`biz_type`、`biz_id`、`action`、`version`、`occurred_at` 和 `payload`。消费端按 `event_id + consumer_group` 做幂等记录,业务表也必须保留唯一键兜底。 + +消息发送链路使用 `x-im-client-mid` 或 HTTP `Idempotency-Key` 作为业务幂等来源;Kafka 只负责可靠投递,不能作为唯一幂等判断。消费者失败按指数退避重试,超过上限写 `im.dead.letter` 或失败表,由后台补偿。 + +## 4. 逐章识别 + +| 章节 | 适合 Kafka 的范围 | 不建议 Kafka 的范围 | +| --- | --- | --- | +| 0. 公共对接约定 | 产品业务事件 outbox 可改为 Kafka 投递;消息异步落库、系统通知、离线 Push、跨节点缓存失效使用 Kafka;`x-im-client-mid` 幂等结果可作为生产 Kafka 前置校验。 | WebSocket 建连、`hi/login/sub/get/note` 本身不走 Kafka;在线状态继续 Redis。 | +| 1. 账号注册、登录与身份安全 | 企业注册申请、企业入口反馈、实名提交、密码重置成功、账号封禁/解封、管理员重置密码、审核结果通知、审计日志可走 Kafka。营业执照和反馈截图上传完成后投 `im.file.security`。 | 登录鉴权、验证码校验、Action Token、限流不走 Kafka;这些必须同步返回或走 Redis。 | +| 2. 消息首页、全局导航与系统通知 | 系统通知创建、已读异步统计、入口红点变更、离线 Push 提醒、消息摘要更新可走 Kafka。 | 首页查询、会话列表拉取、客户端断线重连流程不走 Kafka。 | +| 3. 单聊会话、会话建立与消息状态 | 私聊消息异步落库、好友申请/通过/拒绝/删除事件、P2P 订阅建立后的通知、媒体消息文件绑定事件、离线 Push 可走 Kafka。 | 在线 P2P 实时投递主链路不建议 Kafka;关系判断和发送权限校验必须同步完成。 | +| 4. 消息输入、媒体内容与消息操作 | @提醒 Push、消息撤回/编辑事件、收藏批量创建、转发任务、forward bundle 生成、消息搜索索引更新、附件 ACL 绑定可走 Kafka。 | 输入框、草稿、录音状态、上传进度和客户端 pending 队列不走 Kafka。 | +| 5. 通讯录、好友关系与黑名单 | 好友申请事件、好友关系变更、黑名单变更、二维码使用审计、资料快照更新、搜索索引更新、系统通知生成可走 Kafka。 | 好友列表查询、用户搜索同步结果、二维码解析鉴权不走 Kafka。 | +| 6. 群聊创建、资料维护与成员治理 | 群创建、成员加入/退出、邀请/审批、公告更新、资料更新、角色变更、禁言、群主转让、解散、群文件登记/删除、Admin 后台群操作投递到 C 端都适合 Kafka。Admin 后台写库后生产 `im.group.admin.event`,IM 节点消费后用 WS 下发给在线客户端。 | 群内在线消息 fanout 主链路继续 Redis + WS;群成员权限校验、成员数上限校验必须同步事务完成。 | +| 7. 文件下载、收藏管理与聊天记录检索 | 文件安全检测、恶意文件扫描、文件生命周期、收藏快照异步补全、收藏转发、搜索索引更新、合规删除事件可走 Kafka。文件下载本身不走 Kafka。 | OSS 上传/下载字节流、Range 续传、下载进度不走 Kafka;这些走文件服务和客户端任务管理。 | +| 8. 实时语音与视频通话 | 通话记录落库、未接来电 Push、通话结束审计、后台计费或统计、异常通话风控可走 Kafka。 | WebRTC offer/answer/ICE、接听、挂断等实时信令不建议 Kafka 主链路;仍走 WS。 | +| 9. 企业工作台与办公协同 | 工作台红点更新、会议创建/更新/取消/提醒/响应、日报/月报提交和催办、总经理信箱提交、工作台系统通知、后台配置变更、审计都适合 Kafka。 | 工作台模块列表、组织架构查询、报表详情查询不走 Kafka;普通查询走 MySQL/Redis 缓存。 | +| 10. 个人资料、通用设置、隐私与青少年模式 | 资料变更事件、隐私配置变更、青少年模式开启/关闭、多设备退出、Push Token 变更审计、App 发布配置缓存失效可走 Kafka。头像上传后走文件安全检测。 | 本地清缓存、系统权限引导、密码验证、Action Token 不走 Kafka。 | +| 11. 反诈安全、举报与意见反馈 | 风控确认审计、举报受理、证据文件安全检测、意见反馈、诊断包处理、后台审核通知、合规处置事件可走 Kafka。 | 风险弹窗同步拦截结果、举报原因查询、反馈配置查询不走 Kafka。 | +| 12. 联调实施清单 | Kafka 需要覆盖消息幂等、系统事件可信生产、文件安全检测、后台群操作到 C 端、离线 Push、失败重试和死信补偿验收。 | 前端基础联调请求不直接依赖 Kafka,除非该接口声明为异步任务。 | + +## 5. 文件链路结论 + +文件上传、下载、Range、ETag、OSS 对象读写不走 Kafka。Kafka 只接入文件完成后的业务事件: + +1. 上传完成后投递 `im.file.security`,调用阿里云内容安全和恶意文件检测。 +2. 安全检测完成后更新文件审核状态,并通过 `im.business.event` 或系统通知告知相关用户。 +3. 消息发送、收藏、群文件登记、日报/月报/反馈/举报提交成功后投递 `im.file.lifecycle`,建立业务引用和 ACL。 +4. 业务撤回、删除、合规处置或临时文件 TTL 到期后投递 `im.file.lifecycle`,执行引用释放、禁用下载或 OSS 删除。 + +## 6. 工作台链路结论 + +工作台“查询类接口”不需要 Kafka,但工作台“状态变化和提醒类事件”适合 Kafka: + +1. 会议创建、更新、取消、提醒、参会响应生产 `im.workbench.event`,消费者生成系统通知、红点并通过 WS 通知在线客户端。 +2. 日报/月报提交、撤回、重开、管理员催办生产 `im.workbench.event`,用于红点、催办 Push 和审计。 +3. 总经理信箱提交生产 `im.workbench.event` 和 `im.audit.event`,后台处理结果再回推 C 端。 +4. 工作台模块配置变更生产 `im.cache.invalidate`,IM 节点刷新进程内配置缓存。 + +## 7. 已补充:缓存失效与 Admin 配置查询 + +1. `im.cache.invalidate` 已接入 IM 消费端,支持租户、注册短信、第三方配置、字典配置、Redis 对象缓存、群热消息缓存失效。 +2. IM 已增加 Admin 内部配置查询客户端,开启 `admin_internal_api.enabled=true` 后优先通过 Admin 查询 hotgo 侧配置,旧数据库读取作为灰度兜底。 +3. Admin 侧需要补齐 `/internal/im-config/*` 只读接口,并与 IM 使用同一套 HMAC 签名密钥。 +4. Kafka 死信已补齐查询、忽略、解决、重放闭环:查询走 IM 内部 API,处理走 `im.admin.command`。 + +## 8. 首批建议实现 + +1. 先实现 Kafka 基础生产者、消费者、配置、健康检查和消费幂等表。 +2. 接入 `im.group.admin.event`,解决 Admin 后台群操作通过 Kafka 投递到 C 端。 +3. 接入 `im.message.persist`,将消息异步落库和幂等重试闭环。 +4. 接入 `im.file.security`,把 OSS 上传完成后的文件安全检测异步化。 +5. 接入 `im.workbench.event`,先覆盖会议提醒、日报/月报催办、总经理信箱提交通知。 diff --git a/server/api/admin/imconfiginternal/im_config.go b/server/api/admin/imconfiginternal/im_config.go new file mode 100644 index 0000000..4b01c5c --- /dev/null +++ b/server/api/admin/imconfiginternal/im_config.go @@ -0,0 +1,59 @@ +package imconfiginternal + +import "github.com/gogf/gf/v2/frame/g" + +type TenantReq struct { + g.Meta `path:"/internal/im-config/tenants/by-code/{code}" method:"get" tags:"IM内部配置" summary:"按编码获取IM租户"` + Code string `json:"code" in:"path" dc:"租户编码"` +} + +type TenantByIDReq struct { + g.Meta `path:"/internal/im-config/tenants/by-id/{id}" method:"get" tags:"IM内部配置" summary:"按ID获取IM租户"` + Id int64 `json:"id" in:"path" dc:"租户ID"` +} + +type TenantListReq struct { + g.Meta `path:"/internal/im-config/tenants" method:"get" tags:"IM内部配置" summary:"获取IM租户列表"` + State string `json:"state" dc:"状态"` + Limit int `json:"limit" dc:"数量"` +} + +type TenantRes struct { + *TenantModel +} + +type TenantListRes struct { + Items []*TenantModel `json:"items" dc:"租户列表"` +} + +type TenantModel struct { + ID int64 `json:"id"` + Code string `json:"code"` + Name string `json:"name"` + TenantDesc *string `json:"tenant_desc,omitempty"` + DBName string `json:"db_name"` + DBDSN string `json:"db_dsn"` + State int `json:"state"` + CreatedAt string `json:"created_at"` + CreatedBy int64 `json:"created_by"` + UpdatedAt string `json:"updated_at"` + UpdatedBy int64 `json:"updated_by"` +} + +type RegistrationSmsReq struct { + g.Meta `path:"/internal/im-config/registration/sms" method:"get" tags:"IM内部配置" summary:"获取注册短信开关"` +} + +type RegistrationSmsRes struct { + Enabled bool `json:"enabled" dc:"是否开启"` +} + +type ThirdPartyReq struct { + g.Meta `path:"/internal/im-config/third-party/{group}" method:"get" tags:"IM内部配置" summary:"获取第三方配置"` + Group string `json:"group" in:"path" dc:"配置组"` + Keys string `json:"keys" dc:"配置键,逗号分隔"` +} + +type ThirdPartyRes struct { + Values map[string]string `json:"values" dc:"配置值"` +} diff --git a/server/api/admin/imdeadletter/im_dead_letter.go b/server/api/admin/imdeadletter/im_dead_letter.go new file mode 100644 index 0000000..8fccaaf --- /dev/null +++ b/server/api/admin/imdeadletter/im_dead_letter.go @@ -0,0 +1,57 @@ +// Package imdeadletter +// @Link https://github.com/bufanyun/hotgo +// @Copyright Copyright (c) 2026 HotGo +// @Author Ms <133814250@qq.com> +// @License https://github.com/bufanyun/hotgo/blob/master/LICENSE +package imdeadletter + +import ( + "hotgo/internal/model/input/sysin" + + "github.com/gogf/gf/v2/frame/g" +) + +type ListReq struct { + g.Meta `path:"/imDeadLetter/list" method:"get" tags:"IM Kafka死信" summary:"获取IM Kafka死信列表"` + sysin.ImDeadLetterListInp +} + +type ListRes struct { + *sysin.ImDeadLetterPageModel +} + +type ViewReq struct { + g.Meta `path:"/imDeadLetter/view" method:"get" tags:"IM Kafka死信" summary:"获取IM Kafka死信详情"` + sysin.ImDeadLetterViewInp +} + +type ViewRes struct { + *sysin.ImDeadLetterViewModel +} + +type ReplayReq struct { + g.Meta `path:"/imDeadLetter/replay" method:"post" tags:"IM Kafka死信" summary:"重放IM Kafka死信"` + sysin.ImDeadLetterActionInp +} + +type ReplayRes struct { + *sysin.ImGroupCommandModel +} + +type IgnoreReq struct { + g.Meta `path:"/imDeadLetter/ignore" method:"post" tags:"IM Kafka死信" summary:"忽略IM Kafka死信"` + sysin.ImDeadLetterActionInp +} + +type IgnoreRes struct { + *sysin.ImGroupCommandModel +} + +type ResolveReq struct { + g.Meta `path:"/imDeadLetter/resolve" method:"post" tags:"IM Kafka死信" summary:"标记IM Kafka死信已处理"` + sysin.ImDeadLetterActionInp +} + +type ResolveRes struct { + *sysin.ImGroupCommandModel +} diff --git a/server/api/admin/imgroup/im_group.go b/server/api/admin/imgroup/im_group.go index 4ec698f..8074034 100644 --- a/server/api/admin/imgroup/im_group.go +++ b/server/api/admin/imgroup/im_group.go @@ -108,3 +108,83 @@ type AdminLogListRes struct { form.PageRes List []*sysin.ImGroupAdminLogListModel `json:"list" dc:"数据列表"` } + +// ProfileUpdateReq 通过Kafka修改IM群资料 +type ProfileUpdateReq struct { + g.Meta `path:"/imGroup/profileUpdate" method:"post" tags:"IM群组" summary:"Kafka修改IM群资料"` + sysin.ImGroupProfileUpdateInp +} + +type ProfileUpdateRes struct { + *sysin.ImGroupCommandModel +} + +// GovernanceUpdateReq 通过Kafka修改IM群治理配置 +type GovernanceUpdateReq struct { + g.Meta `path:"/imGroup/governanceUpdate" method:"post" tags:"IM群组" summary:"Kafka修改IM群治理配置"` + sysin.ImGroupGovernanceUpdateInp +} + +type GovernanceUpdateRes struct { + *sysin.ImGroupCommandModel +} + +// AdminSetReq 通过Kafka设置IM群管理员 +type AdminSetReq struct { + g.Meta `path:"/imGroup/adminSet" method:"post" tags:"IM群组" summary:"Kafka设置IM群管理员"` + sysin.ImGroupAdminSetInp +} + +type AdminSetRes struct { + *sysin.ImGroupCommandModel +} + +// AdminRemoveReq 通过Kafka删除IM群管理员 +type AdminRemoveReq struct { + g.Meta `path:"/imGroup/adminRemove" method:"post" tags:"IM群组" summary:"Kafka删除IM群管理员"` + sysin.ImGroupAdminRemoveInp +} + +type AdminRemoveRes struct { + *sysin.ImGroupCommandModel +} + +// MemberMuteReq 通过Kafka禁言IM群成员 +type MemberMuteReq struct { + g.Meta `path:"/imGroup/memberMute" method:"post" tags:"IM群组" summary:"Kafka禁言IM群成员"` + sysin.ImGroupMemberMuteInp +} + +type MemberMuteRes struct { + *sysin.ImGroupCommandModel +} + +// MemberUnmuteReq 通过Kafka解除IM群成员禁言 +type MemberUnmuteReq struct { + g.Meta `path:"/imGroup/memberUnmute" method:"post" tags:"IM群组" summary:"Kafka解除IM群成员禁言"` + sysin.ImGroupMemberUnmuteInp +} + +type MemberUnmuteRes struct { + *sysin.ImGroupCommandModel +} + +// MemberRemoveReq 通过Kafka移出IM群成员 +type MemberRemoveReq struct { + g.Meta `path:"/imGroup/memberRemove" method:"post" tags:"IM群组" summary:"Kafka移出IM群成员"` + sysin.ImGroupMemberRemoveInp +} + +type MemberRemoveRes struct { + *sysin.ImGroupCommandModel +} + +// OwnerTransferReq 通过Kafka转让IM群主 +type OwnerTransferReq struct { + g.Meta `path:"/imGroup/ownerTransfer" method:"post" tags:"IM群组" summary:"Kafka转让IM群主"` + sysin.ImGroupOwnerTransferInp +} + +type OwnerTransferRes struct { + *sysin.ImGroupCommandModel +} diff --git a/server/api/admin/imkafkaaudit/im_kafka_audit.go b/server/api/admin/imkafkaaudit/im_kafka_audit.go new file mode 100644 index 0000000..c607639 --- /dev/null +++ b/server/api/admin/imkafkaaudit/im_kafka_audit.go @@ -0,0 +1,27 @@ +package imkafkaaudit + +import ( + "hotgo/internal/model/input/form" + "hotgo/internal/model/input/sysin" + + "github.com/gogf/gf/v2/frame/g" +) + +type ListReq struct { + g.Meta `path:"/imKafkaAudit/list" method:"get" tags:"IM Kafka审计事件" summary:"获取IM Kafka审计事件列表"` + sysin.ImKafkaAuditListInp +} + +type ListRes struct { + form.PageRes + List []*sysin.ImKafkaAuditListModel `json:"list" dc:"数据列表"` +} + +type ViewReq struct { + g.Meta `path:"/imKafkaAudit/view" method:"get" tags:"IM Kafka审计事件" summary:"获取IM Kafka审计事件详情"` + sysin.ImKafkaAuditViewInp +} + +type ViewRes struct { + *sysin.ImKafkaAuditViewModel +} diff --git a/server/api/admin/imkafkacommand/im_kafka_command.go b/server/api/admin/imkafkacommand/im_kafka_command.go new file mode 100644 index 0000000..99f0aa3 --- /dev/null +++ b/server/api/admin/imkafkacommand/im_kafka_command.go @@ -0,0 +1,50 @@ +// Package imkafkacommand +// @Link https://github.com/bufanyun/hotgo +// @Copyright Copyright (c) 2026 HotGo +// @Author Ms <133814250@qq.com> +// @License https://github.com/bufanyun/hotgo/blob/master/LICENSE +package imkafkacommand + +import ( + "hotgo/internal/model/input/form" + "hotgo/internal/model/input/sysin" + + "github.com/gogf/gf/v2/frame/g" +) + +type ListReq struct { + g.Meta `path:"/imKafkaCommand/list" method:"get" tags:"IM后台命令" summary:"获取IM后台命令列表"` + sysin.ImKafkaCommandListInp +} + +type ListRes struct { + form.PageRes + List []*sysin.ImKafkaCommandListModel `json:"list" dc:"数据列表"` +} + +type ViewReq struct { + g.Meta `path:"/imKafkaCommand/view" method:"get" tags:"IM后台命令" summary:"获取IM后台命令详情"` + sysin.ImKafkaCommandViewInp +} + +type ViewRes struct { + *sysin.ImKafkaCommandViewModel +} + +type CacheInvalidateReq struct { + g.Meta `path:"/imKafkaCommand/cacheInvalidate" method:"post" tags:"IM后台命令" summary:"发送IM缓存失效测试事件"` + sysin.ImKafkaCacheInvalidateInp +} + +type CacheInvalidateRes struct { + *sysin.ImKafkaEventModel +} + +type WorkbenchEventReq struct { + g.Meta `path:"/imKafkaCommand/workbenchEvent" method:"post" tags:"IM后台命令" summary:"发送IM工作台测试事件"` + sysin.ImKafkaWorkbenchEventInp +} + +type WorkbenchEventRes struct { + *sysin.ImKafkaEventModel +} diff --git a/server/api/admin/imuser/im_user.go b/server/api/admin/imuser/im_user.go index 0aa8481..c8edfbb 100644 --- a/server/api/admin/imuser/im_user.go +++ b/server/api/admin/imuser/im_user.go @@ -32,3 +32,75 @@ type ViewReq struct { type ViewRes struct { *sysin.ImUserViewModel } + +type BanReq struct { + g.Meta `path:"/imUser/ban" method:"post" tags:"IM用户管理" summary:"封禁IM用户"` + sysin.ImUserGovernanceInp +} + +type BanRes struct { + *sysin.ImUserCommandModel +} + +type UnbanReq struct { + g.Meta `path:"/imUser/unban" method:"post" tags:"IM用户管理" summary:"解封IM用户"` + sysin.ImUserGovernanceInp +} + +type UnbanRes struct { + *sysin.ImUserCommandModel +} + +type FreezeReq struct { + g.Meta `path:"/imUser/freeze" method:"post" tags:"IM用户管理" summary:"冻结IM用户"` + sysin.ImUserGovernanceInp +} + +type FreezeRes struct { + *sysin.ImUserCommandModel +} + +type UnfreezeReq struct { + g.Meta `path:"/imUser/unfreeze" method:"post" tags:"IM用户管理" summary:"解冻IM用户"` + sysin.ImUserGovernanceInp +} + +type UnfreezeRes struct { + *sysin.ImUserCommandModel +} + +type DeviceKickReq struct { + g.Meta `path:"/imUser/deviceKick" method:"post" tags:"IM用户管理" summary:"踢出IM用户设备"` + sysin.ImUserGovernanceInp +} + +type DeviceKickRes struct { + *sysin.ImUserCommandModel +} + +type LogoutAllReq struct { + g.Meta `path:"/imUser/logoutAll" method:"post" tags:"IM用户管理" summary:"踢出IM用户全部在线会话"` + sysin.ImUserGovernanceInp +} + +type LogoutAllRes struct { + *sysin.ImUserCommandModel +} + +type AccountCancelReq struct { + g.Meta `path:"/imUser/accountCancel" method:"post" tags:"IM用户管理" summary:"注销IM用户账号"` + sysin.ImUserGovernanceInp +} + +type AccountCancelRes struct { + *sysin.ImUserCommandModel +} + +type AccountDeleteReq struct { + g.Meta `path:"/imUser/accountDelete" method:"post" tags:"IM用户管理" summary:"删除IM用户账号"` + sysin.ImUserGovernanceInp +} + +type AccountDeleteRes struct { + *sysin.ImUserCommandModel +} diff --git a/server/go.mod b/server/go.mod index 25bab3f..09fb277 100644 --- a/server/go.mod +++ b/server/go.mod @@ -35,6 +35,7 @@ require ( github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/sms v1.0.1200 github.com/tencentyun/cos-go-sdk-v5 v0.7.66 github.com/ufilesdk-dev/ufile-gosdk v1.0.6 + github.com/xdg-go/scram v1.1.2 github.com/xuri/excelize/v2 v2.9.1 go.opentelemetry.io/otel v1.38.0 golang.org/x/mod v0.26.0 @@ -138,6 +139,8 @@ require ( github.com/tjfoc/gmsm v1.4.1 // indirect github.com/tklauser/go-sysconf v0.3.12 // indirect github.com/tklauser/numcpus v0.6.1 // indirect + github.com/xdg-go/pbkdf2 v1.0.0 // indirect + github.com/xdg-go/stringprep v1.0.4 // indirect github.com/xuri/efp v0.0.1 // indirect github.com/xuri/nfp v0.0.1 // indirect github.com/yusufpapurcu/wmi v1.2.4 // indirect diff --git a/server/go.sum b/server/go.sum index ce59b74..f60306f 100644 --- a/server/go.sum +++ b/server/go.sum @@ -446,6 +446,12 @@ github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+F github.com/tklauser/numcpus v0.6.1/go.mod h1:1XfjsgE2zo8GVw7POkMbHENHzVg3GzmoZ9fESEdAacY= github.com/ufilesdk-dev/ufile-gosdk v1.0.6 h1:YhTZfA0U5zjKUe6qYrIs72jjxSZryTMbS9wE76fIxMs= github.com/ufilesdk-dev/ufile-gosdk v1.0.6/go.mod h1:s1LIpscsgVNx0JRNbDElNNQhyNXJiumVpzvPzVNS/XI= +github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c= +github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI= +github.com/xdg-go/scram v1.1.2 h1:FHX5I5B4i4hKRVRBCFRxq1iQRej7WO3hhBuJf+UUySY= +github.com/xdg-go/scram v1.1.2/go.mod h1:RT/sEzTbU5y00aCK8UOx6R7YryM0iF1N2MOmC3kKLN4= +github.com/xdg-go/stringprep v1.0.4 h1:XLI/Ng3O1Atzq0oBs3TWm+5ZVgkq2aqdlvP9JtoZ6c8= +github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM= github.com/xuri/efp v0.0.1 h1:fws5Rv3myXyYni8uwj2qKjVaRP30PdjeYe2Y6FDsCL8= github.com/xuri/efp v0.0.1/go.mod h1:ybY/Jr0T0GTCnYjKqmdwxyxn2BQf2RcQIIvex5QldPI= github.com/xuri/excelize/v2 v2.9.1 h1:VdSGk+rraGmgLHGFaGG9/9IWu1nj4ufjJ7uwMDtj8Qw= @@ -618,6 +624,7 @@ golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= diff --git a/server/internal/cmd/http.go b/server/internal/cmd/http.go index 7a4cb3e..c00f2c6 100644 --- a/server/internal/cmd/http.go +++ b/server/internal/cmd/http.go @@ -45,6 +45,9 @@ var ( }...) s.Group("/", func(group *ghttp.RouterGroup) { + // 注册内部服务路由 + router.Internal(ctx, group) + // 注册后台路由 router.Admin(ctx, group) diff --git a/server/internal/consts/im_kafka.go b/server/internal/consts/im_kafka.go new file mode 100644 index 0000000..474b5c9 --- /dev/null +++ b/server/internal/consts/im_kafka.go @@ -0,0 +1,88 @@ +// Package consts +// @Link https://github.com/bufanyun/hotgo +// @Copyright Copyright (c) 2026 HotGo +// @Author Ms <133814250@qq.com> +// @License https://github.com/bufanyun/hotgo/blob/master/LICENSE +package consts + +import ( + "hotgo/internal/library/dict" + "hotgo/internal/model" +) + +func init() { + dict.RegisterEnums("ImAdminCommandStatusOptions", "IM后台命令状态选项", ImAdminCommandStatusOptions) + dict.RegisterEnums("ImAdminOutboxStatusOptions", "IM后台Outbox状态选项", ImAdminOutboxStatusOptions) + dict.RegisterEnums("ImKafkaDeadLetterStatusOptions", "IM Kafka死信状态选项", ImKafkaDeadLetterStatusOptions) +} + +const ( + ImKafkaSourceService = "hotgo-admin" + ImKafkaTargetService = "im-server" + + ImAdminCommandStatusPending = "pending" + ImAdminCommandStatusRunning = "running" + ImAdminCommandStatusSucceeded = "succeeded" + ImAdminCommandStatusFailed = "failed" + ImAdminCommandStatusDead = "dead" + + ImAdminOutboxStatusPending = "pending" + ImAdminOutboxStatusSent = "sent" + ImAdminOutboxStatusFailed = "failed" + + ImAdminCommandGroupProfileUpdate = "group.profile.update" + ImAdminCommandGroupGovernUpdate = "group.governance.update" + ImAdminCommandGroupAdminSet = "group.admin.set" + ImAdminCommandGroupAdminRemove = "group.admin.remove" + ImAdminCommandGroupMemberMute = "group.member.mute" + ImAdminCommandGroupMemberUnmute = "group.member.unmute" + ImAdminCommandGroupMemberRemove = "group.member.remove" + ImAdminCommandGroupOwnerTransfer = "group.owner.transfer" + ImAdminCommandUserBan = "user.ban" + ImAdminCommandUserUnban = "user.unban" + ImAdminCommandUserFreeze = "user.freeze" + ImAdminCommandUserUnfreeze = "user.unfreeze" + ImAdminCommandUserDeviceKick = "user.device.kick" + ImAdminCommandUserLogoutAll = "user.logout_all" + ImAdminCommandUserCancel = "user.account.cancel" + ImAdminCommandUserDelete = "user.account.delete" + ImAdminCommandKafkaDeadReplay = "kafka.dead_letter.replay" + ImAdminCommandKafkaDeadIgnore = "kafka.dead_letter.ignore" + ImAdminCommandKafkaDeadResolve = "kafka.dead_letter.resolve" + + ImKafkaDeadLetterStatusDead = "dead" + ImKafkaDeadLetterStatusPendingReplay = "pending_replay" + ImKafkaDeadLetterStatusReplayed = "replayed" + ImKafkaDeadLetterStatusIgnored = "ignored" + ImKafkaDeadLetterStatusResolved = "resolved" + + ImConfigEventTenantUpdated = "tenant.updated" + ImConfigEventTenantStatusChanged = "tenant.status.changed" + ImConfigEventTenantLimitsUpdated = "tenant.limits.updated" + ImConfigEventTenantFeaturesUpdated = "tenant.features.updated" + ImConfigEventDictionaryUpdated = "dictionary.updated" + ImConfigEventThirdPartyUpdated = "third_party.updated" + ImConfigEventRegistrationUpdated = "registration.config.updated" +) + +var ImAdminCommandStatusOptions = []*model.Option{ + dict.GenWarningOption(ImAdminCommandStatusPending, "待投递"), + dict.GenInfoOption(ImAdminCommandStatusRunning, "执行中"), + dict.GenSuccessOption(ImAdminCommandStatusSucceeded, "成功"), + dict.GenErrorOption(ImAdminCommandStatusFailed, "失败"), + dict.GenErrorOption(ImAdminCommandStatusDead, "死信"), +} + +var ImAdminOutboxStatusOptions = []*model.Option{ + dict.GenWarningOption(ImAdminOutboxStatusPending, "待投递"), + dict.GenSuccessOption(ImAdminOutboxStatusSent, "已投递"), + dict.GenErrorOption(ImAdminOutboxStatusFailed, "投递失败"), +} + +var ImKafkaDeadLetterStatusOptions = []*model.Option{ + dict.GenErrorOption(ImKafkaDeadLetterStatusDead, "死信"), + dict.GenWarningOption(ImKafkaDeadLetterStatusPendingReplay, "等待重放"), + dict.GenSuccessOption(ImKafkaDeadLetterStatusReplayed, "已重放"), + dict.GenDefaultOption(ImKafkaDeadLetterStatusIgnored, "已忽略"), + dict.GenSuccessOption(ImKafkaDeadLetterStatusResolved, "已处理"), +} diff --git a/server/internal/consts/queue.go b/server/internal/consts/queue.go index 0aa91a4..333630e 100644 --- a/server/internal/consts/queue.go +++ b/server/internal/consts/queue.go @@ -10,4 +10,12 @@ const ( QueueLogTopic = `request_log` // 访问日志 QueueLoginLogTopic = `login_log` // 登录日志 QueueServeLogTopic = `serve_log` // 服务日志 + + QueueImAdminCommandTopic = `im.admin.command` // IM后台命令 + QueueImAdminCommandResultTopic = `im.admin.command.result` // IM后台命令结果 + QueueImConfigEventTopic = `im.config.event` // IM配置事件 + QueueImCacheInvalidateTopic = `im.cache.invalidate` // IM缓存失效事件 + QueueImWorkbenchEventTopic = `im.workbench.event` // IM工作台事件 + QueueImDeadLetterTopic = `im.dead.letter` // IM死信事件 + QueueImAuditEventTopic = `im.audit.event` // IM审计事件 ) diff --git a/server/internal/controller/admin/sys/im_dead_letter.go b/server/internal/controller/admin/sys/im_dead_letter.go new file mode 100644 index 0000000..318d642 --- /dev/null +++ b/server/internal/controller/admin/sys/im_dead_letter.go @@ -0,0 +1,66 @@ +// Package sys +// @Link https://github.com/bufanyun/hotgo +// @Copyright Copyright (c) 2026 HotGo +// @Author Ms <133814250@qq.com> +// @License https://github.com/bufanyun/hotgo/blob/master/LICENSE +package sys + +import ( + "context" + "hotgo/api/admin/imdeadletter" + "hotgo/internal/service" +) + +var ImDeadLetter = cImDeadLetter{} + +type cImDeadLetter struct{} + +func (c *cImDeadLetter) List(ctx context.Context, req *imdeadletter.ListReq) (res *imdeadletter.ListRes, err error) { + data, err := service.SysImDeadLetter().List(ctx, &req.ImDeadLetterListInp) + if err != nil { + return + } + res = new(imdeadletter.ListRes) + res.ImDeadLetterPageModel = data + return +} + +func (c *cImDeadLetter) View(ctx context.Context, req *imdeadletter.ViewReq) (res *imdeadletter.ViewRes, err error) { + data, err := service.SysImDeadLetter().View(ctx, &req.ImDeadLetterViewInp) + if err != nil { + return + } + res = new(imdeadletter.ViewRes) + res.ImDeadLetterViewModel = data + return +} + +func (c *cImDeadLetter) Replay(ctx context.Context, req *imdeadletter.ReplayReq) (res *imdeadletter.ReplayRes, err error) { + data, err := service.SysImDeadLetter().Replay(ctx, &req.ImDeadLetterActionInp) + if err != nil { + return + } + res = new(imdeadletter.ReplayRes) + res.ImGroupCommandModel = data + return +} + +func (c *cImDeadLetter) Ignore(ctx context.Context, req *imdeadletter.IgnoreReq) (res *imdeadletter.IgnoreRes, err error) { + data, err := service.SysImDeadLetter().Ignore(ctx, &req.ImDeadLetterActionInp) + if err != nil { + return + } + res = new(imdeadletter.IgnoreRes) + res.ImGroupCommandModel = data + return +} + +func (c *cImDeadLetter) Resolve(ctx context.Context, req *imdeadletter.ResolveReq) (res *imdeadletter.ResolveRes, err error) { + data, err := service.SysImDeadLetter().Resolve(ctx, &req.ImDeadLetterActionInp) + if err != nil { + return + } + res = new(imdeadletter.ResolveRes) + res.ImGroupCommandModel = data + return +} diff --git a/server/internal/controller/admin/sys/im_group.go b/server/internal/controller/admin/sys/im_group.go index ca171fd..ab7a31e 100644 --- a/server/internal/controller/admin/sys/im_group.go +++ b/server/internal/controller/admin/sys/im_group.go @@ -149,3 +149,91 @@ func (c *cImGroup) AdminLogList(ctx context.Context, req *imgroup.AdminLogListRe res.PageRes.Pack(req, totalCount) return } + +// ProfileUpdate 通过Kafka修改IM群资料 +func (c *cImGroup) ProfileUpdate(ctx context.Context, req *imgroup.ProfileUpdateReq) (res *imgroup.ProfileUpdateRes, err error) { + data, err := service.SysImGroup().ProfileUpdate(ctx, &req.ImGroupProfileUpdateInp) + if err != nil { + return + } + res = new(imgroup.ProfileUpdateRes) + res.ImGroupCommandModel = data + return +} + +// GovernanceUpdate 通过Kafka修改IM群治理配置 +func (c *cImGroup) GovernanceUpdate(ctx context.Context, req *imgroup.GovernanceUpdateReq) (res *imgroup.GovernanceUpdateRes, err error) { + data, err := service.SysImGroup().GovernanceUpdate(ctx, &req.ImGroupGovernanceUpdateInp) + if err != nil { + return + } + res = new(imgroup.GovernanceUpdateRes) + res.ImGroupCommandModel = data + return +} + +// AdminSet 通过Kafka设置IM群管理员 +func (c *cImGroup) AdminSet(ctx context.Context, req *imgroup.AdminSetReq) (res *imgroup.AdminSetRes, err error) { + data, err := service.SysImGroup().AdminSet(ctx, &req.ImGroupAdminSetInp) + if err != nil { + return + } + res = new(imgroup.AdminSetRes) + res.ImGroupCommandModel = data + return +} + +// AdminRemove 通过Kafka删除IM群管理员 +func (c *cImGroup) AdminRemove(ctx context.Context, req *imgroup.AdminRemoveReq) (res *imgroup.AdminRemoveRes, err error) { + data, err := service.SysImGroup().AdminRemove(ctx, &req.ImGroupAdminRemoveInp) + if err != nil { + return + } + res = new(imgroup.AdminRemoveRes) + res.ImGroupCommandModel = data + return +} + +// MemberMute 通过Kafka禁言IM群成员 +func (c *cImGroup) MemberMute(ctx context.Context, req *imgroup.MemberMuteReq) (res *imgroup.MemberMuteRes, err error) { + data, err := service.SysImGroup().MemberMute(ctx, &req.ImGroupMemberMuteInp) + if err != nil { + return + } + res = new(imgroup.MemberMuteRes) + res.ImGroupCommandModel = data + return +} + +// MemberUnmute 通过Kafka解除IM群成员禁言 +func (c *cImGroup) MemberUnmute(ctx context.Context, req *imgroup.MemberUnmuteReq) (res *imgroup.MemberUnmuteRes, err error) { + data, err := service.SysImGroup().MemberUnmute(ctx, &req.ImGroupMemberUnmuteInp) + if err != nil { + return + } + res = new(imgroup.MemberUnmuteRes) + res.ImGroupCommandModel = data + return +} + +// MemberRemove 通过Kafka移出IM群成员 +func (c *cImGroup) MemberRemove(ctx context.Context, req *imgroup.MemberRemoveReq) (res *imgroup.MemberRemoveRes, err error) { + data, err := service.SysImGroup().MemberRemove(ctx, &req.ImGroupMemberRemoveInp) + if err != nil { + return + } + res = new(imgroup.MemberRemoveRes) + res.ImGroupCommandModel = data + return +} + +// OwnerTransfer 通过Kafka转让IM群主 +func (c *cImGroup) OwnerTransfer(ctx context.Context, req *imgroup.OwnerTransferReq) (res *imgroup.OwnerTransferRes, err error) { + data, err := service.SysImGroup().OwnerTransfer(ctx, &req.ImGroupOwnerTransferInp) + if err != nil { + return + } + res = new(imgroup.OwnerTransferRes) + res.ImGroupCommandModel = data + return +} diff --git a/server/internal/controller/admin/sys/im_kafka_audit.go b/server/internal/controller/admin/sys/im_kafka_audit.go new file mode 100644 index 0000000..3f74f0e --- /dev/null +++ b/server/internal/controller/admin/sys/im_kafka_audit.go @@ -0,0 +1,36 @@ +package sys + +import ( + "context" + "hotgo/api/admin/imkafkaaudit" + "hotgo/internal/model/input/sysin" + "hotgo/internal/service" +) + +var ImKafkaAudit = cImKafkaAudit{} + +type cImKafkaAudit struct{} + +func (c *cImKafkaAudit) List(ctx context.Context, req *imkafkaaudit.ListReq) (res *imkafkaaudit.ListRes, err error) { + list, totalCount, err := service.SysImKafkaAudit().List(ctx, &req.ImKafkaAuditListInp) + if err != nil { + return + } + if list == nil { + list = []*sysin.ImKafkaAuditListModel{} + } + res = new(imkafkaaudit.ListRes) + res.List = list + res.PageRes.Pack(req, totalCount) + return +} + +func (c *cImKafkaAudit) View(ctx context.Context, req *imkafkaaudit.ViewReq) (res *imkafkaaudit.ViewRes, err error) { + data, err := service.SysImKafkaAudit().View(ctx, &req.ImKafkaAuditViewInp) + if err != nil { + return + } + res = new(imkafkaaudit.ViewRes) + res.ImKafkaAuditViewModel = data + return +} diff --git a/server/internal/controller/admin/sys/im_kafka_command.go b/server/internal/controller/admin/sys/im_kafka_command.go new file mode 100644 index 0000000..b7fa7c4 --- /dev/null +++ b/server/internal/controller/admin/sys/im_kafka_command.go @@ -0,0 +1,61 @@ +// Package sys +// @Link https://github.com/bufanyun/hotgo +// @Copyright Copyright (c) 2026 HotGo +// @Author Ms <133814250@qq.com> +// @License https://github.com/bufanyun/hotgo/blob/master/LICENSE +package sys + +import ( + "context" + "hotgo/api/admin/imkafkacommand" + "hotgo/internal/model/input/sysin" + "hotgo/internal/service" +) + +var ImKafkaCommand = cImKafkaCommand{} + +type cImKafkaCommand struct{} + +func (c *cImKafkaCommand) List(ctx context.Context, req *imkafkacommand.ListReq) (res *imkafkacommand.ListRes, err error) { + list, totalCount, err := service.SysImKafkaCommand().List(ctx, &req.ImKafkaCommandListInp) + if err != nil { + return + } + if list == nil { + list = []*sysin.ImKafkaCommandListModel{} + } + res = new(imkafkacommand.ListRes) + res.List = list + res.PageRes.Pack(req, totalCount) + return +} + +func (c *cImKafkaCommand) View(ctx context.Context, req *imkafkacommand.ViewReq) (res *imkafkacommand.ViewRes, err error) { + data, err := service.SysImKafkaCommand().View(ctx, &req.ImKafkaCommandViewInp) + if err != nil { + return + } + res = new(imkafkacommand.ViewRes) + res.ImKafkaCommandViewModel = data + return +} + +func (c *cImKafkaCommand) CacheInvalidate(ctx context.Context, req *imkafkacommand.CacheInvalidateReq) (res *imkafkacommand.CacheInvalidateRes, err error) { + data, err := service.SysImKafkaCommand().CacheInvalidate(ctx, &req.ImKafkaCacheInvalidateInp) + if err != nil { + return + } + res = new(imkafkacommand.CacheInvalidateRes) + res.ImKafkaEventModel = data + return +} + +func (c *cImKafkaCommand) WorkbenchEvent(ctx context.Context, req *imkafkacommand.WorkbenchEventReq) (res *imkafkacommand.WorkbenchEventRes, err error) { + data, err := service.SysImKafkaCommand().WorkbenchEvent(ctx, &req.ImKafkaWorkbenchEventInp) + if err != nil { + return + } + res = new(imkafkacommand.WorkbenchEventRes) + res.ImKafkaEventModel = data + return +} diff --git a/server/internal/controller/admin/sys/im_user.go b/server/internal/controller/admin/sys/im_user.go index f350765..9159714 100644 --- a/server/internal/controller/admin/sys/im_user.go +++ b/server/internal/controller/admin/sys/im_user.go @@ -46,3 +46,83 @@ func (c *cImUser) View(ctx context.Context, req *imuser.ViewReq) (res *imuser.Vi res.ImUserViewModel = data return } + +func (c *cImUser) Ban(ctx context.Context, req *imuser.BanReq) (res *imuser.BanRes, err error) { + data, err := service.SysImUser().Ban(ctx, &req.ImUserGovernanceInp) + if err != nil { + return + } + res = new(imuser.BanRes) + res.ImUserCommandModel = data + return +} + +func (c *cImUser) Unban(ctx context.Context, req *imuser.UnbanReq) (res *imuser.UnbanRes, err error) { + data, err := service.SysImUser().Unban(ctx, &req.ImUserGovernanceInp) + if err != nil { + return + } + res = new(imuser.UnbanRes) + res.ImUserCommandModel = data + return +} + +func (c *cImUser) Freeze(ctx context.Context, req *imuser.FreezeReq) (res *imuser.FreezeRes, err error) { + data, err := service.SysImUser().Freeze(ctx, &req.ImUserGovernanceInp) + if err != nil { + return + } + res = new(imuser.FreezeRes) + res.ImUserCommandModel = data + return +} + +func (c *cImUser) Unfreeze(ctx context.Context, req *imuser.UnfreezeReq) (res *imuser.UnfreezeRes, err error) { + data, err := service.SysImUser().Unfreeze(ctx, &req.ImUserGovernanceInp) + if err != nil { + return + } + res = new(imuser.UnfreezeRes) + res.ImUserCommandModel = data + return +} + +func (c *cImUser) DeviceKick(ctx context.Context, req *imuser.DeviceKickReq) (res *imuser.DeviceKickRes, err error) { + data, err := service.SysImUser().DeviceKick(ctx, &req.ImUserGovernanceInp) + if err != nil { + return + } + res = new(imuser.DeviceKickRes) + res.ImUserCommandModel = data + return +} + +func (c *cImUser) LogoutAll(ctx context.Context, req *imuser.LogoutAllReq) (res *imuser.LogoutAllRes, err error) { + data, err := service.SysImUser().LogoutAll(ctx, &req.ImUserGovernanceInp) + if err != nil { + return + } + res = new(imuser.LogoutAllRes) + res.ImUserCommandModel = data + return +} + +func (c *cImUser) AccountCancel(ctx context.Context, req *imuser.AccountCancelReq) (res *imuser.AccountCancelRes, err error) { + data, err := service.SysImUser().AccountCancel(ctx, &req.ImUserGovernanceInp) + if err != nil { + return + } + res = new(imuser.AccountCancelRes) + res.ImUserCommandModel = data + return +} + +func (c *cImUser) AccountDelete(ctx context.Context, req *imuser.AccountDeleteReq) (res *imuser.AccountDeleteRes, err error) { + data, err := service.SysImUser().AccountDelete(ctx, &req.ImUserGovernanceInp) + if err != nil { + return + } + res = new(imuser.AccountDeleteRes) + res.ImUserCommandModel = data + return +} diff --git a/server/internal/controller/internalapi/imconfig/im_config.go b/server/internal/controller/internalapi/imconfig/im_config.go new file mode 100644 index 0000000..49ca3ed --- /dev/null +++ b/server/internal/controller/internalapi/imconfig/im_config.go @@ -0,0 +1,144 @@ +package imconfig + +import ( + "context" + api "hotgo/api/admin/imconfiginternal" + "hotgo/internal/dao" + "hotgo/internal/library/imtenant" + "hotgo/internal/model/entity" + "strings" + + "github.com/gogf/gf/v2/errors/gerror" + "github.com/gogf/gf/v2/frame/g" + "github.com/gogf/gf/v2/os/gtime" + "github.com/gogf/gf/v2/util/gconv" +) + +var Config = cConfig{} + +type cConfig struct{} + +func (c *cConfig) Tenant(ctx context.Context, req *api.TenantReq) (res *api.TenantRes, err error) { + tenant, err := getTenant(ctx, dao.ImTenant.Columns().Code, req.Code) + if err != nil { + return nil, err + } + return &api.TenantRes{TenantModel: tenantToModel(tenant)}, nil +} + +func (c *cConfig) TenantByID(ctx context.Context, req *api.TenantByIDReq) (res *api.TenantRes, err error) { + tenant, err := getTenant(ctx, dao.ImTenant.Columns().Id, req.Id) + if err != nil { + return nil, err + } + return &api.TenantRes{TenantModel: tenantToModel(tenant)}, nil +} + +func (c *cConfig) TenantList(ctx context.Context, req *api.TenantListReq) (res *api.TenantListRes, err error) { + limit := req.Limit + if limit <= 0 || limit > 1000 { + limit = 1000 + } + mod := imtenant.RegistryDB(ctx).Model(dao.ImTenant.Table()).Safe().Ctx(ctx).Limit(limit) + if req.State != "" { + if req.State == "active" { + mod = mod.Where(dao.ImTenant.Columns().State, 1) + } else { + mod = mod.Where(dao.ImTenant.Columns().State, req.State) + } + } + var tenants []*entity.ImTenant + if err = mod.OrderAsc(dao.ImTenant.Columns().Id).Scan(&tenants); err != nil { + return nil, gerror.Wrap(err, "获取IM租户配置失败") + } + res = &api.TenantListRes{Items: make([]*api.TenantModel, 0, len(tenants))} + for _, tenant := range tenants { + res.Items = append(res.Items, tenantToModel(tenant)) + } + return res, nil +} + +func (c *cConfig) RegistrationSms(ctx context.Context, req *api.RegistrationSmsReq) (res *api.RegistrationSmsRes, err error) { + var record g.Map + if err = dao.SysConfig.Ctx(ctx). + Where(dao.SysConfig.Columns().Group, "im"). + Where(dao.SysConfig.Columns().Key, "register_sms_enabled"). + Where(dao.SysConfig.Columns().Status, 1). + Scan(&record); err != nil { + return nil, gerror.Wrap(err, "获取IM注册短信配置失败") + } + enabled := true + if record != nil { + enabled = gconv.String(record[dao.SysConfig.Columns().Value]) != "0" + } + return &api.RegistrationSmsRes{Enabled: enabled}, nil +} + +func (c *cConfig) ThirdParty(ctx context.Context, req *api.ThirdPartyReq) (res *api.ThirdPartyRes, err error) { + values := map[string]string{} + keys := splitKeys(req.Keys) + mod := dao.SysConfig.Ctx(ctx).Where(dao.SysConfig.Columns().Group, req.Group).Where(dao.SysConfig.Columns().Status, 1) + if len(keys) > 0 { + mod = mod.WhereIn(dao.SysConfig.Columns().Key, keys) + } + var rows []*entity.SysConfig + if err = mod.Scan(&rows); err != nil { + return nil, gerror.Wrap(err, "获取IM第三方配置失败") + } + for _, row := range rows { + values[row.Key] = row.Value + } + return &api.ThirdPartyRes{Values: values}, nil +} + +func getTenant(ctx context.Context, field string, value any) (*entity.ImTenant, error) { + var tenant *entity.ImTenant + if err := imtenant.RegistryDB(ctx).Model(dao.ImTenant.Table()).Safe().Ctx(ctx).Where(field, value).Scan(&tenant); err != nil { + return nil, gerror.Wrap(err, "获取IM租户配置失败") + } + if tenant == nil || tenant.Id <= 0 { + return nil, gerror.New("IM租户不存在") + } + return tenant, nil +} + +func tenantToModel(tenant *entity.ImTenant) *api.TenantModel { + if tenant == nil { + return nil + } + desc := tenant.TenantDesc + return &api.TenantModel{ + ID: tenant.Id, + Code: tenant.Code, + Name: tenant.Name, + TenantDesc: &desc, + DBName: tenant.DbName, + DBDSN: tenant.DbDsn, + State: tenant.State, + CreatedAt: gtimeToRFC3339(tenant.CreatedAt), + CreatedBy: tenant.CreatedBy, + UpdatedAt: gtimeToRFC3339(tenant.UpdatedAt), + UpdatedBy: tenant.UpdatedBy, + } +} + +func gtimeToRFC3339(t *gtime.Time) string { + if t == nil { + return "" + } + return t.Time.Format("2006-01-02T15:04:05Z07:00") +} + +func splitKeys(raw string) []string { + if raw == "" { + return nil + } + parts := strings.Split(raw, ",") + keys := make([]string, 0, len(parts)) + for _, part := range parts { + if part = strings.TrimSpace(part); part != "" { + keys = append(keys, part) + } + } + return keys +} diff --git a/server/internal/crons/im_admin_outbox.go b/server/internal/crons/im_admin_outbox.go new file mode 100644 index 0000000..e3c5933 --- /dev/null +++ b/server/internal/crons/im_admin_outbox.go @@ -0,0 +1,38 @@ +// Package crons +// @Link https://github.com/bufanyun/hotgo +// @Copyright Copyright (c) 2026 HotGo +// @Author Ms <133814250@qq.com> +// @License https://github.com/bufanyun/hotgo/blob/master/LICENSE +package crons + +import ( + "context" + "hotgo/internal/library/cron" + "hotgo/internal/library/imkafka" +) + +func init() { + cron.Register(ImAdminOutbox) +} + +var ImAdminOutbox = &cImAdminOutbox{name: "im_admin_outbox"} + +type cImAdminOutbox struct { + name string +} + +func (c *cImAdminOutbox) GetName() string { + return c.name +} + +func (c *cImAdminOutbox) Execute(ctx context.Context, parser *cron.Parser) (err error) { + sent, err := imkafka.DispatchPendingOutbox(ctx, 100) + if err != nil { + parser.Logger.Warningf(ctx, "IM后台命令Outbox补偿投递失败 err:%+v", err) + return err + } + if sent > 0 { + parser.Logger.Infof(ctx, "IM后台命令Outbox补偿投递完成 count:%d", sent) + } + return nil +} diff --git a/server/internal/library/imadmin/config.go b/server/internal/library/imadmin/config.go index 4a78d4e..f6f62a2 100644 --- a/server/internal/library/imadmin/config.go +++ b/server/internal/library/imadmin/config.go @@ -10,10 +10,13 @@ import ( ) type Config struct { - BaseUrl string `json:"baseUrl"` - ServiceId string `json:"serviceId"` - ServiceSecret string `json:"serviceSecret"` - Timeout time.Duration `json:"timeout"` + BaseUrl string `json:"baseUrl"` + ServiceId string `json:"serviceId"` + ServiceSecret string `json:"serviceSecret"` + Timeout time.Duration `json:"timeout"` + InternalBaseUrl string `json:"internalBaseUrl"` + InternalServiceName string `json:"internalServiceName"` + InternalServiceSecret string `json:"internalServiceSecret"` } func LoadConfig(ctx context.Context) Config { @@ -28,10 +31,25 @@ func LoadConfig(ctx context.Context) Config { if serviceSecret == "" { serviceSecret = os.Getenv("IM_ADMIN_API_SECRET") } + internalSecret := g.Cfg().MustGet(ctx, "imInternalApi.serviceSecret").String() + if strings.HasPrefix(internalSecret, "${") && strings.HasSuffix(internalSecret, "}") { + internalSecret = os.Getenv(strings.TrimSuffix(strings.TrimPrefix(internalSecret, "${"), "}")) + } + if internalSecret == "" { + internalSecret = os.Getenv("IM_INTERNAL_API_SECRET") + } + if internalSecret == "" { + internalSecret = serviceSecret + } + baseUrl := g.Cfg().MustGet(ctx, "imAdminApi.baseUrl").String() + serviceId := g.Cfg().MustGet(ctx, "imAdminApi.serviceId", "hotgo-admin").String() return Config{ - BaseUrl: g.Cfg().MustGet(ctx, "imAdminApi.baseUrl").String(), - ServiceId: g.Cfg().MustGet(ctx, "imAdminApi.serviceId", "hotgo-admin").String(), - ServiceSecret: serviceSecret, - Timeout: timeout, + BaseUrl: baseUrl, + ServiceId: serviceId, + ServiceSecret: serviceSecret, + Timeout: timeout, + InternalBaseUrl: g.Cfg().MustGet(ctx, "imInternalApi.baseUrl", baseUrl).String(), + InternalServiceName: g.Cfg().MustGet(ctx, "imInternalApi.serviceName", serviceId).String(), + InternalServiceSecret: internalSecret, } } diff --git a/server/internal/library/imadmin/internal.go b/server/internal/library/imadmin/internal.go new file mode 100644 index 0000000..ee2293f --- /dev/null +++ b/server/internal/library/imadmin/internal.go @@ -0,0 +1,101 @@ +package imadmin + +import ( + "bytes" + "context" + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "io" + "net/http" + "strconv" + "strings" + "time" + + "github.com/gogf/gf/v2/errors/gerror" +) + +func (c *Client) InternalGet(ctx context.Context, path string, out any) error { + req, err := c.newInternalRequest(ctx, http.MethodGet, path, nil) + if err != nil { + return err + } + resp, err := c.http.Do(req) + if err != nil { + return gerror.New("IM内部查询服务暂不可用") + } + defer resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return c.parseError(resp) + } + if out == nil { + return nil + } + body, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseBody)) + if err != nil { + return gerror.Wrap(err, "读取IM内部查询响应失败") + } + var envelope struct { + Code int `json:"code"` + Message string `json:"message"` + Reason string `json:"reason"` + Data json.RawMessage `json:"data"` + } + if err = json.Unmarshal(body, &envelope); err == nil && len(envelope.Data) > 0 { + if envelope.Code != 0 { + return stableError(envelope.Message, envelope.Reason) + } + return json.Unmarshal(envelope.Data, out) + } + return json.Unmarshal(body, out) +} + +func (c *Client) newInternalRequest(ctx context.Context, method string, path string, bodyBytes []byte) (*http.Request, error) { + baseUrl := strings.TrimRight(c.conf.InternalBaseUrl, "/") + if baseUrl == "" { + baseUrl = strings.TrimRight(c.conf.BaseUrl, "/") + } + if baseUrl == "" { + return nil, gerror.New("IM内部查询API未配置") + } + serviceName := strings.TrimSpace(c.conf.InternalServiceName) + if serviceName == "" { + serviceName = strings.TrimSpace(c.conf.ServiceId) + } + if serviceName == "" { + return nil, gerror.New("IM内部查询服务名未配置") + } + secret := c.conf.InternalServiceSecret + if secret == "" { + secret = c.conf.ServiceSecret + } + if secret == "" { + return nil, gerror.New("IM内部查询API密钥未配置") + } + req, err := http.NewRequestWithContext(ctx, method, baseUrl+path, bytes.NewReader(bodyBytes)) + if err != nil { + return nil, gerror.Wrap(err, "创建IM内部查询请求失败") + } + timestamp := strconv.FormatInt(time.Now().UTC().Unix(), 10) + nonce, err := randomNonce() + if err != nil { + return nil, err + } + bodyHash := sha256.Sum256(bodyBytes) + signingText := strings.Join([]string{ + method, + requestTarget(req), + hex.EncodeToString(bodyHash[:]), + timestamp, + nonce, + }, "\n") + mac := hmac.New(sha256.New, []byte(secret)) + _, _ = mac.Write([]byte(signingText)) + req.Header.Set("Accept", "application/json") + req.Header.Set("X-Service-Name", serviceName) + req.Header.Set("X-Timestamp", timestamp) + req.Header.Set("X-Nonce", nonce) + req.Header.Set("X-Signature", hex.EncodeToString(mac.Sum(nil))) + return req, nil +} diff --git a/server/internal/library/iminternal/auth.go b/server/internal/library/iminternal/auth.go new file mode 100644 index 0000000..aed3d8d --- /dev/null +++ b/server/internal/library/iminternal/auth.go @@ -0,0 +1,135 @@ +package iminternal + +import ( + "bytes" + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "io" + "net/http" + "os" + "strconv" + "strings" + "sync" + "time" + + "github.com/gogf/gf/v2/frame/g" + "github.com/gogf/gf/v2/net/ghttp" +) + +const ( + headerService = "X-Service-Name" + headerTimestamp = "X-Timestamp" + headerNonce = "X-Nonce" + headerSignature = "X-Signature" +) + +var nonces sync.Map + +func Middleware(r *ghttp.Request) { + if !Authorize(r) { + return + } + r.Middleware.Next() +} + +func Authorize(r *ghttp.Request) bool { + ctx := r.GetCtx() + serviceName := strings.TrimSpace(r.Header.Get(headerService)) + timestampRaw := strings.TrimSpace(r.Header.Get(headerTimestamp)) + nonce := strings.TrimSpace(r.Header.Get(headerNonce)) + signature := strings.TrimSpace(r.Header.Get(headerSignature)) + expectedService := g.Cfg().MustGet(ctx, "imConfigApi.serviceName", "im-server").String() + secret := configuredSecret(r) + if serviceName == "" || timestampRaw == "" || nonce == "" || signature == "" || serviceName != expectedService || secret == "" { + writeAuthError(r, http.StatusUnauthorized, "internal signature required") + return false + } + timestamp, err := strconv.ParseInt(timestampRaw, 10, 64) + if err != nil { + writeAuthError(r, http.StatusUnauthorized, "invalid internal timestamp") + return false + } + window := g.Cfg().MustGet(ctx, "imConfigApi.timeWindow", "300s").Duration() + if window <= 0 { + window = 5 * time.Minute + } + delta := time.Since(time.Unix(timestamp, 0)) + if delta > window || delta < -window { + writeAuthError(r, http.StatusUnauthorized, "internal signature expired") + return false + } + body, err := readBody(r) + if err != nil { + writeAuthError(r, http.StatusInternalServerError, "read internal body failed") + return false + } + bodyHash := sha256.Sum256(body) + signingText := strings.Join([]string{ + r.Method, + r.URL.RequestURI(), + hex.EncodeToString(bodyHash[:]), + timestampRaw, + nonce, + }, "\n") + mac := hmac.New(sha256.New, []byte(secret)) + _, _ = mac.Write([]byte(signingText)) + if !hmac.Equal([]byte(strings.ToLower(signature)), []byte(hex.EncodeToString(mac.Sum(nil)))) { + writeAuthError(r, http.StatusUnauthorized, "invalid internal signature") + return false + } + if !claimNonce(serviceName, nonce, time.Now().Add(window)) { + writeAuthError(r, http.StatusUnauthorized, "internal nonce replayed") + return false + } + return true +} + +func configuredSecret(r *ghttp.Request) string { + ctx := r.GetCtx() + secret := g.Cfg().MustGet(ctx, "imConfigApi.serviceSecret").String() + if strings.HasPrefix(secret, "${") && strings.HasSuffix(secret, "}") { + secret = os.Getenv(strings.TrimSuffix(strings.TrimPrefix(secret, "${"), "}")) + } + if secret == "" { + secret = os.Getenv("IM_CONFIG_API_SECRET") + } + if secret == "" { + secret = g.Cfg().MustGet(ctx, "imInternalApi.serviceSecret").String() + } + return secret +} + +func readBody(r *ghttp.Request) ([]byte, error) { + if r.Body == nil { + return nil, nil + } + body, err := io.ReadAll(io.LimitReader(r.Body, 4<<20)) + if err != nil { + return nil, err + } + r.Body = io.NopCloser(bytes.NewReader(body)) + return body, nil +} + +func claimNonce(serviceName string, nonce string, expiresAt time.Time) bool { + now := time.Now() + nonces.Range(func(key, value any) bool { + if expires, ok := value.(time.Time); ok && expires.Before(now) { + nonces.Delete(key) + } + return true + }) + key := serviceName + ":" + nonce + _, loaded := nonces.LoadOrStore(key, expiresAt) + return !loaded +} + +func writeAuthError(r *ghttp.Request, status int, message string) { + r.Response.WriteStatus(status) + r.Response.WriteJson(g.Map{ + "code": status, + "message": message, + }) + r.Exit() +} diff --git a/server/internal/library/imkafka/command.go b/server/internal/library/imkafka/command.go new file mode 100644 index 0000000..fc38efd --- /dev/null +++ b/server/internal/library/imkafka/command.go @@ -0,0 +1,378 @@ +package imkafka + +import ( + "context" + "encoding/json" + "hotgo/internal/consts" + "hotgo/internal/library/queue" + "time" + + "github.com/gogf/gf/v2/database/gdb" + "github.com/gogf/gf/v2/errors/gerror" + "github.com/gogf/gf/v2/frame/g" + "github.com/gogf/gf/v2/os/gctx" + "github.com/gogf/gf/v2/os/gtime" + "github.com/gogf/gf/v2/util/gconv" + "github.com/gogf/gf/v2/util/guid" +) + +const ( + adminCommandTable = "im_admin_commands" + adminOutboxTable = "im_admin_outbox" +) + +func PublishAdminCommand(ctx context.Context, in AdminCommandInput) (res *AdminCommandModel, err error) { + if in.TenantID <= 0 || in.TenantCode == "" { + return nil, gerror.New("IM租户信息不完整") + } + if in.EventType == "" { + return nil, gerror.New("IM命令类型不能为空") + } + if in.IdempotencyKey == "" { + in.IdempotencyKey = "cmd_" + guid.S() + } + commandID := "cmd_" + guid.S() + eventID := "evt_" + guid.S() + now := time.Now().UTC().Round(time.Millisecond) + + payload := in.Payload + if payload == nil { + payload = map[string]any{} + } + payload["command_id"] = commandID + + payloadBytes, err := json.Marshal(payload) + if err != nil { + return nil, gerror.Wrap(err, "序列化IM命令Payload失败") + } + envelope := Envelope{ + EventID: eventID, + TraceID: gctx.CtxId(ctx), + TenantCode: in.TenantCode, + SourceService: consts.ImKafkaSourceService, + TargetService: consts.ImKafkaTargetService, + EventType: in.EventType, + BizType: in.BizType, + BizID: in.BizID, + IdempotencyKey: in.IdempotencyKey, + Version: in.Version, + OccurredAt: now, + Actor: in.Actor, + Payload: payloadBytes, + } + envelopeBytes, err := json.Marshal(envelope) + if err != nil { + return nil, gerror.Wrap(err, "序列化IM命令Envelope失败") + } + + db := g.DB() + err = db.Transaction(ctx, func(ctx context.Context, tx gdb.TX) error { + data := g.Map{ + "command_id": commandID, + "tenant_id": in.TenantID, + "tenant_code": in.TenantCode, + "event_id": eventID, + "event_type": in.EventType, + "biz_type": in.BizType, + "biz_id": in.BizID, + "idempotency_key": in.IdempotencyKey, + "status": consts.ImAdminCommandStatusPending, + "payload": string(payloadBytes), + "envelope": string(envelopeBytes), + "created_by": in.CreatedBy, + "created_at": gtime.Now(), + "updated_at": gtime.Now(), + } + if _, err := tx.Model(adminCommandTable).Safe().Ctx(ctx).Data(data).Insert(); err != nil { + return gerror.Wrap(err, "创建IM后台命令失败") + } + outbox := g.Map{ + "event_id": eventID, + "command_id": commandID, + "topic": consts.QueueImAdminCommandTopic, + "event_type": in.EventType, + "biz_type": in.BizType, + "biz_id": in.BizID, + "key_value": in.BizID, + "envelope": string(envelopeBytes), + "status": consts.ImAdminOutboxStatusPending, + "created_at": gtime.Now(), + "updated_at": gtime.Now(), + } + if _, err := tx.Model(adminOutboxTable).Safe().Ctx(ctx).Data(outbox).Insert(); err != nil { + return gerror.Wrap(err, "创建IM后台命令Outbox失败") + } + return nil + }) + if err != nil { + return nil, err + } + + res = &AdminCommandModel{ + CommandID: commandID, + EventID: eventID, + Status: consts.ImAdminCommandStatusPending, + TenantID: in.TenantID, + TenantCode: in.TenantCode, + EventType: in.EventType, + IdempotencyKey: in.IdempotencyKey, + } + if err = DispatchOutboxByEvent(ctx, eventID); err != nil { + return res, err + } + res.Status = consts.ImAdminCommandStatusRunning + return res, nil +} + +func DispatchOutboxByEvent(ctx context.Context, eventID string) error { + var row gdb.Record + if err := g.DB().Model(adminOutboxTable).Safe().Ctx(ctx). + Where("event_id", eventID). + Where("status", consts.ImAdminOutboxStatusPending). + Scan(&row); err != nil { + return gerror.Wrap(err, "读取IM命令Outbox失败") + } + if row.IsEmpty() { + return nil + } + return dispatchOutboxRecord(ctx, row) +} + +func PublishConfigEvent(ctx context.Context, in ConfigEventInput) error { + if in.EventType == "" { + return gerror.New("IM配置事件类型不能为空") + } + eventID := "evt_" + guid.S() + now := time.Now().UTC().Round(time.Millisecond) + payload := map[string]any{ + "tenant_code": in.TenantCode, + "tenant_id": in.TenantID, + "group": in.Group, + "keys": in.Keys, + } + payloadBytes, err := json.Marshal(payload) + if err != nil { + return gerror.Wrap(err, "序列化IM配置事件Payload失败") + } + envelope := Envelope{ + EventID: eventID, + TraceID: gctx.CtxId(ctx), + TenantCode: in.TenantCode, + SourceService: consts.ImKafkaSourceService, + TargetService: consts.ImKafkaTargetService, + EventType: in.EventType, + BizType: "config", + BizID: in.BizID, + OccurredAt: now, + Actor: in.Actor, + Payload: payloadBytes, + } + envelopeBytes, err := json.Marshal(envelope) + if err != nil { + return gerror.Wrap(err, "序列化IM配置事件Envelope失败") + } + _, err = g.DB().Model(adminOutboxTable).Safe().Ctx(ctx).Data(g.Map{ + "event_id": eventID, + "command_id": "", + "topic": consts.QueueImConfigEventTopic, + "event_type": in.EventType, + "biz_type": "config", + "biz_id": in.BizID, + "key_value": in.TenantCode, + "envelope": string(envelopeBytes), + "status": consts.ImAdminOutboxStatusPending, + "created_at": gtime.Now(), + "updated_at": gtime.Now(), + }).Insert() + if err != nil { + return gerror.Wrap(err, "创建IM配置事件Outbox失败") + } + return DispatchOutboxByEvent(ctx, eventID) +} + +func PublishEvent(ctx context.Context, in EventInput) (eventID string, err error) { + if in.Topic == "" { + return "", gerror.New("IM Kafka Topic不能为空") + } + if in.EventType == "" { + return "", gerror.New("IM Kafka事件类型不能为空") + } + eventID = "evt_" + guid.S() + now := time.Now().UTC().Round(time.Millisecond) + payload := in.Payload + if payload == nil { + payload = map[string]any{} + } + payloadBytes, err := json.Marshal(payload) + if err != nil { + return "", gerror.Wrap(err, "序列化IM Kafka事件Payload失败") + } + targetService := in.TargetService + if targetService == "" { + targetService = consts.ImKafkaTargetService + } + envelope := Envelope{ + EventID: eventID, + TraceID: gctx.CtxId(ctx), + TenantCode: in.TenantCode, + SourceService: consts.ImKafkaSourceService, + TargetService: targetService, + EventType: in.EventType, + BizType: in.BizType, + BizID: in.BizID, + IdempotencyKey: in.IdempotencyKey, + Version: in.Version, + OccurredAt: now, + Actor: in.Actor, + Payload: payloadBytes, + } + envelopeBytes, err := json.Marshal(envelope) + if err != nil { + return "", gerror.Wrap(err, "序列化IM Kafka事件Envelope失败") + } + _, err = g.DB().Model(adminOutboxTable).Safe().Ctx(ctx).Data(g.Map{ + "event_id": eventID, + "command_id": "", + "topic": in.Topic, + "event_type": in.EventType, + "biz_type": in.BizType, + "biz_id": in.BizID, + "key_value": in.BizID, + "envelope": string(envelopeBytes), + "status": consts.ImAdminOutboxStatusPending, + "created_at": gtime.Now(), + "updated_at": gtime.Now(), + }).Insert() + if err != nil { + return "", gerror.Wrap(err, "创建IM Kafka事件Outbox失败") + } + if err = DispatchOutboxByEvent(ctx, eventID); err != nil { + return eventID, err + } + return eventID, nil +} + +func DispatchPendingOutbox(ctx context.Context, limit int) (sent int, err error) { + if limit <= 0 { + limit = 100 + } + var rows []gdb.Record + err = g.DB().Model(adminOutboxTable).Safe().Ctx(ctx). + WhereIn("status", []string{consts.ImAdminOutboxStatusPending, consts.ImAdminOutboxStatusFailed}). + Where("next_retry_at IS NULL OR next_retry_at <= NOW()"). + OrderAsc("id"). + Limit(limit). + Scan(&rows) + if err != nil { + return 0, gerror.Wrap(err, "读取待投递IM命令Outbox失败") + } + for _, row := range rows { + if err = dispatchOutboxRecord(ctx, row); err != nil { + g.Log().Warningf(ctx, "IM命令Outbox投递失败 event:%s err:%+v", row["event_id"].String(), err) + continue + } + sent++ + } + return sent, nil +} + +func dispatchOutboxRecord(ctx context.Context, row gdb.Record) error { + eventID := row["event_id"].String() + commandID := row["command_id"].String() + topic := row["topic"].String() + body := row["envelope"].String() + if topic == "" || body == "" { + return markOutboxFailed(ctx, eventID, "MALFORMED_OUTBOX", "topic or envelope is empty") + } + if g.Cfg().MustGet(ctx, "queue.driver").String() != "kafka" { + err := gerror.New("IM Kafka未启用,请将queue.driver配置为kafka") + _ = markOutboxFailed(ctx, eventID, "KAFKA_DISABLED", err.Error()) + return err + } + if err := queue.Push(topic, body); err != nil { + _ = markOutboxFailed(ctx, eventID, "KAFKA_SEND_FAILED", err.Error()) + return err + } + now := gtime.Now() + _, err := g.DB().Model(adminOutboxTable).Safe().Ctx(ctx). + Where("event_id", eventID). + Data(g.Map{ + "status": consts.ImAdminOutboxStatusSent, + "attempts": gdb.Raw("attempts + 1"), + "sent_at": now, + "last_reason": "", + "last_error": "", + "updated_at": now, + }).Update() + if err != nil { + return gerror.Wrap(err, "更新IM命令Outbox投递状态失败") + } + if commandID != "" { + _, err = g.DB().Model(adminCommandTable).Safe().Ctx(ctx). + Where("command_id", commandID). + Data(g.Map{ + "status": consts.ImAdminCommandStatusRunning, + "sent_at": now, + "updated_at": now, + }).Update() + if err != nil { + return gerror.Wrap(err, "更新IM后台命令投递状态失败") + } + } + return nil +} + +func markOutboxFailed(ctx context.Context, eventID string, reason string, message string) error { + now := gtime.Now() + _, err := g.DB().Model(adminOutboxTable).Safe().Ctx(ctx). + Where("event_id", eventID). + Data(g.Map{ + "status": consts.ImAdminOutboxStatusFailed, + "attempts": gdb.Raw("attempts + 1"), + "next_retry_at": now.Add(30 * time.Second), + "last_reason": reason, + "last_error": message, + "updated_at": now, + }).Update() + if err != nil { + return gerror.Wrap(err, "更新IM命令Outbox失败状态失败") + } + return nil +} + +func ApplyCommandResult(ctx context.Context, result CommandResult) error { + commandID := result.CommandID + if commandID == "" { + commandID = result.CommandEventID + } + if commandID == "" && result.CommandEventID == "" { + return gerror.New("IM命令结果缺少command_id") + } + status := gconv.String(result.Status) + switch status { + case consts.ImAdminCommandStatusSucceeded, consts.ImAdminCommandStatusFailed, consts.ImAdminCommandStatusDead: + default: + status = consts.ImAdminCommandStatusFailed + } + data := g.Map{ + "status": status, + "reason": result.Reason, + "message": result.Message, + "completed_at": gtime.Now(), + "updated_at": gtime.Now(), + } + if len(result.Result) > 0 { + data["result"] = string(result.Result) + } + mod := g.DB().Model(adminCommandTable).Safe().Ctx(ctx) + if result.CommandID != "" { + mod = mod.Where("command_id", result.CommandID) + } else { + mod = mod.Where("event_id", result.CommandEventID) + } + _, err := mod.Data(data).Update() + if err != nil { + return gerror.Wrap(err, "更新IM后台命令结果失败") + } + return nil +} diff --git a/server/internal/library/imkafka/envelope.go b/server/internal/library/imkafka/envelope.go new file mode 100644 index 0000000..7834914 --- /dev/null +++ b/server/internal/library/imkafka/envelope.go @@ -0,0 +1,90 @@ +package imkafka + +import ( + "encoding/json" + "time" +) + +type Actor struct { + Type string `json:"type,omitempty"` + ID string `json:"id,omitempty"` + DisplayName string `json:"display_name,omitempty"` +} + +type Envelope struct { + EventID string `json:"event_id"` + TraceID string `json:"trace_id,omitempty"` + TenantCode string `json:"tenant_code,omitempty"` + SourceService string `json:"source_service,omitempty"` + TargetService string `json:"target_service,omitempty"` + EventType string `json:"event_type"` + BizType string `json:"biz_type,omitempty"` + BizID string `json:"biz_id,omitempty"` + IdempotencyKey string `json:"idempotency_key,omitempty"` + Version int64 `json:"version,omitempty"` + OccurredAt time.Time `json:"occurred_at"` + Actor *Actor `json:"actor,omitempty"` + Payload json.RawMessage `json:"payload,omitempty"` + ParentEventID string `json:"parent_event_id,omitempty"` +} + +type CommandResult struct { + EventID string `json:"event_id"` + TraceID string `json:"trace_id,omitempty"` + TenantCode string `json:"tenant_code,omitempty"` + CommandID string `json:"command_id"` + CommandEventID string `json:"command_event_id"` + Status string `json:"status"` + Reason string `json:"reason,omitempty"` + Message string `json:"message,omitempty"` + Result json.RawMessage `json:"result,omitempty"` + OccurredAt time.Time `json:"occurred_at"` +} + +type AdminCommandInput struct { + TenantID int64 + TenantCode string + EventType string + BizType string + BizID string + IdempotencyKey string + Version int64 + Actor *Actor + Payload map[string]any + CreatedBy int64 +} + +type ConfigEventInput struct { + TenantID int64 + TenantCode string + EventType string + BizID string + Group string + Keys []string + Actor *Actor + CreatedBy int64 +} + +type EventInput struct { + Topic string + TenantCode string + TargetService string + EventType string + BizType string + BizID string + IdempotencyKey string + Version int64 + Actor *Actor + Payload map[string]any + CreatedBy int64 +} + +type AdminCommandModel struct { + CommandID string `json:"commandId" dc:"命令ID"` + EventID string `json:"eventId" dc:"Kafka事件ID"` + Status string `json:"status" dc:"状态"` + TenantID int64 `json:"tenantId" dc:"租户ID"` + TenantCode string `json:"tenantCode" dc:"租户编码"` + EventType string `json:"eventType" dc:"事件类型"` + IdempotencyKey string `json:"idempotencyKey" dc:"幂等键"` +} diff --git a/server/internal/library/queue/disk.go b/server/internal/library/queue/disk.go index 7102af9..5b4fcd0 100644 --- a/server/internal/library/queue/disk.go +++ b/server/internal/library/queue/disk.go @@ -125,7 +125,7 @@ func (d *DiskProducerMq) getProducer(topic string) *disk.Queue { func NewDiskQueue(topic string, config *disk.Config) *disk.Queue { conf := &disk.Config{ - Path: fmt.Sprintf(config.Path + "/" + config.GroupName + "/" + topic), + Path: fmt.Sprintf("%s/%s/%s", config.Path, config.GroupName, topic), BatchSize: config.BatchSize, BatchTime: config.BatchTime * time.Second, SegmentSize: config.SegmentSize, diff --git a/server/internal/library/queue/kafkamq.go b/server/internal/library/queue/kafkamq.go index 88f0673..eeea59f 100644 --- a/server/internal/library/queue/kafkamq.go +++ b/server/internal/library/queue/kafkamq.go @@ -7,13 +7,16 @@ package queue import ( "context" - "fmt" + "crypto/tls" + "strings" + "time" + "github.com/IBM/sarama" "github.com/gogf/gf/v2/errors/gerror" "github.com/gogf/gf/v2/frame/g" + "github.com/xdg-go/scram" "hotgo/internal/consts" "hotgo/utility/simple" - "time" ) type KafkaMq struct { @@ -23,14 +26,17 @@ type KafkaMq struct { } type KafkaConfig struct { - ClientId string - Brokers []string - GroupID string - Partitions int32 - Replication int16 - Version string - UserName string - Password string + ClientId string + Brokers []string + GroupID string + Partitions int32 + Replication int16 + Version string + UserName string + Password string + SASLEnabled bool + SASLMechanism string + TLSEnabled bool } // SendMsg 按字符串类型生产数据 @@ -98,7 +104,7 @@ func (r *KafkaMq) ListenReceiveMsgDo(topic string, receiveDo func(mqMsg MqMsg)) } if consumerCtx.Err() != nil { - Logger().Debugf(ctx, fmt.Sprintf("kafka consoumer stop : %v", consumerCtx.Err())) + Logger().Debugf(ctx, "kafka consoumer stop : %v", consumerCtx.Err()) return } consumer.ready = make(chan bool) @@ -134,10 +140,8 @@ func RegisterKafkaMqConsumer(connOpt KafkaConfig) (client MqConsumer, err error) conf := sarama.NewConfig() conf.Consumer.Return.Errors = true conf.Version = kfkVersion - if connOpt.UserName != "" { - conf.Net.SASL.Enable = true - conf.Net.SASL.User = connOpt.UserName - conf.Net.SASL.Password = connOpt.Password + if err = configureKafkaSecurity(conf, connOpt); err != nil { + return } // 默认按随机方式消费 @@ -157,7 +161,9 @@ func RegisterKafkaMqConsumer(connOpt KafkaConfig) (client MqConsumer, err error) // RegisterKafkaProducer 注册并启动生产者接口实现 func RegisterKafkaProducer(connOpt KafkaConfig) (client MqProducer, err error) { mqIns := &KafkaMq{} - connOpt.ClientId = "HOTGO-Producer" + if connOpt.ClientId == "" { + connOpt.ClientId = "hotgo-producer" + } // 这里如果使用go程需要处理chan同步问题 if err = doRegisterKafkaProducer(connOpt, mqIns); err != nil { @@ -191,10 +197,8 @@ func doRegisterKafkaProducer(connOpt KafkaConfig, mqIns *KafkaMq) (err error) { conf.ClientID = connOpt.ClientId conf.Version = kfkVersion - if connOpt.UserName != "" { - conf.Net.SASL.Enable = true - conf.Net.SASL.User = connOpt.UserName - conf.Net.SASL.Password = connOpt.Password + if err = configureKafkaSecurity(conf, connOpt); err != nil { + return } mqIns.producerIns, err = sarama.NewAsyncProducer(brokers, conf) @@ -209,6 +213,63 @@ func doRegisterKafkaProducer(connOpt KafkaConfig, mqIns *KafkaMq) (err error) { return } +func configureKafkaSecurity(conf *sarama.Config, connOpt KafkaConfig) error { + if connOpt.TLSEnabled { + conf.Net.TLS.Enable = true + conf.Net.TLS.Config = &tls.Config{MinVersion: tls.VersionTLS12} + } + if !connOpt.SASLEnabled && connOpt.UserName == "" { + return nil + } + if connOpt.UserName == "" || connOpt.Password == "" { + return gerror.New("queue kafka sasl username or password is empty") + } + conf.Net.SASL.Enable = true + conf.Net.SASL.User = connOpt.UserName + conf.Net.SASL.Password = connOpt.Password + mechanism := strings.ToUpper(strings.ReplaceAll(connOpt.SASLMechanism, "_", "-")) + switch mechanism { + case "", "PLAIN": + conf.Net.SASL.Mechanism = sarama.SASLTypePlaintext + case "SCRAM-SHA-256": + conf.Net.SASL.Mechanism = sarama.SASLTypeSCRAMSHA256 + conf.Net.SASL.SCRAMClientGeneratorFunc = func() sarama.SCRAMClient { + return &xdgSCRAMClient{HashGeneratorFcn: scram.SHA256} + } + case "SCRAM-SHA-512": + conf.Net.SASL.Mechanism = sarama.SASLTypeSCRAMSHA512 + conf.Net.SASL.SCRAMClientGeneratorFunc = func() sarama.SCRAMClient { + return &xdgSCRAMClient{HashGeneratorFcn: scram.SHA512} + } + default: + return gerror.New("queue kafka sasl mechanism is not support") + } + return nil +} + +type xdgSCRAMClient struct { + *scram.Client + *scram.ClientConversation + scram.HashGeneratorFcn +} + +func (x *xdgSCRAMClient) Begin(userName, password, authzID string) (err error) { + x.Client, err = x.HashGeneratorFcn.NewClient(userName, password, authzID) + if err != nil { + return err + } + x.ClientConversation = x.Client.NewConversation() + return nil +} + +func (x *xdgSCRAMClient) Step(challenge string) (response string, err error) { + return x.ClientConversation.Step(challenge) +} + +func (x *xdgSCRAMClient) Done() bool { + return x.ClientConversation.Done() +} + // validateVersion 验证版本是否有效 func validateVersion(version sarama.KafkaVersion) bool { for _, item := range sarama.SupportedVersions { diff --git a/server/internal/library/queue/queue.go b/server/internal/library/queue/queue.go index fb850bf..d0c3653 100644 --- a/server/internal/library/queue/queue.go +++ b/server/internal/library/queue/queue.go @@ -11,6 +11,8 @@ import ( "github.com/gogf/gf/v2/os/gctx" "hotgo/internal/library/queue/disk" "hotgo/utility/charset" + "os" + "strings" "sync" "time" ) @@ -59,6 +61,12 @@ type KafkaConf struct { Version string `json:"version"` RandClient bool `json:"randClient"` MultiConsumer bool `json:"multiConsumer"` + ClientId string `json:"clientId"` + UserName string `json:"userName"` + Password string `json:"password"` + SASLEnabled bool `json:"saslEnabled"` + SASLMechanism string `json:"saslMechanism"` + TLSEnabled bool `json:"tlsEnabled"` } type MqMsg struct { @@ -85,6 +93,7 @@ func init() { if err := g.Cfg().MustGet(ctx, "queue").Scan(&config); err != nil { Logger().Warningf(ctx, "queue init err:%+v", err) } + normalizeKafkaConf() } // InstanceConsumer 实例化消费者 @@ -121,9 +130,15 @@ func NewProducer(groupName string) (mqClient MqProducer, err error) { return } mqClient, err = RegisterKafkaProducer(KafkaConfig{ - Brokers: config.Kafka.Address, - GroupID: groupName, - Version: config.Kafka.Version, + Brokers: config.Kafka.Address, + GroupID: groupName, + Version: config.Kafka.Version, + ClientId: firstKafkaClientId(config.Kafka.ClientId, "hotgo-producer"), + UserName: config.Kafka.UserName, + Password: config.Kafka.Password, + SASLEnabled: config.Kafka.SASLEnabled, + SASLMechanism: config.Kafka.SASLMechanism, + TLSEnabled: config.Kafka.TLSEnabled, }) case "redis": if _, err = g.Redis().Do(ctx, "ping"); err == nil { @@ -185,10 +200,15 @@ func NewConsumer(groupName string) (mqClient MqConsumer, err error) { } mqClient, err = RegisterKafkaMqConsumer(KafkaConfig{ - Brokers: config.Kafka.Address, - GroupID: groupName, - Version: config.Kafka.Version, - ClientId: clientId, + Brokers: config.Kafka.Address, + GroupID: groupName, + Version: config.Kafka.Version, + ClientId: clientId, + UserName: config.Kafka.UserName, + Password: config.Kafka.Password, + SASLEnabled: config.Kafka.SASLEnabled, + SASLMechanism: config.Kafka.SASLMechanism, + TLSEnabled: config.Kafka.TLSEnabled, }) case "redis": if _, err = g.Redis().Do(ctx, "ping"); err == nil { @@ -213,6 +233,30 @@ func NewConsumer(groupName string) (mqClient MqConsumer, err error) { return } +func normalizeKafkaConf() { + config.Kafka.UserName = resolveEnvPlaceholder(config.Kafka.UserName) + config.Kafka.Password = resolveEnvPlaceholder(config.Kafka.Password) + if config.Kafka.SASLMechanism == "" { + config.Kafka.SASLMechanism = "SCRAM-SHA-512" + } +} + +func resolveEnvPlaceholder(value string) string { + value = strings.TrimSpace(value) + if strings.HasPrefix(value, "${") && strings.HasSuffix(value, "}") { + return os.Getenv(strings.TrimSuffix(strings.TrimPrefix(value, "${"), "}")) + } + return value +} + +func firstKafkaClientId(value string, fallback string) string { + value = strings.TrimSpace(value) + if value != "" { + return value + } + return fallback +} + // BodyString 返回消息体 func (m *MqMsg) BodyString() string { return string(m.Body) diff --git a/server/internal/logic/sys/im_dead_letter.go b/server/internal/logic/sys/im_dead_letter.go new file mode 100644 index 0000000..ace1af7 --- /dev/null +++ b/server/internal/logic/sys/im_dead_letter.go @@ -0,0 +1,203 @@ +// Package sys +// @Link https://github.com/bufanyun/hotgo +// @Copyright Copyright (c) 2026 HotGo +// @Author Ms <133814250@qq.com> +// @License https://github.com/bufanyun/hotgo/blob/master/LICENSE +package sys + +import ( + "context" + "encoding/json" + "fmt" + "hotgo/internal/consts" + "hotgo/internal/dao" + "hotgo/internal/library/contexts" + "hotgo/internal/library/imadmin" + "hotgo/internal/library/imkafka" + "hotgo/internal/library/imtenant" + "hotgo/internal/model/entity" + "hotgo/internal/model/input/sysin" + "hotgo/internal/service" + "net/url" + "strconv" + + "github.com/gogf/gf/v2/errors/gerror" +) + +type sSysImDeadLetter struct{} + +func NewSysImDeadLetter() *sSysImDeadLetter { + return &sSysImDeadLetter{} +} + +func init() { + service.RegisterSysImDeadLetter(NewSysImDeadLetter()) +} + +// List 获取IM Kafka死信列表 +func (s *sSysImDeadLetter) List(ctx context.Context, in *sysin.ImDeadLetterListInp) (res *sysin.ImDeadLetterPageModel, err error) { + if err = in.Filter(ctx); err != nil { + return nil, err + } + tenant, err := s.getTenant(ctx, in.TenantId) + if err != nil { + return nil, err + } + values := url.Values{} + values.Set("limit", strconv.Itoa(in.Limit)) + if in.Status != "" { + values.Set("status", in.Status) + } + if in.BizType != "" { + values.Set("biz_type", in.BizType) + } + if in.BizId != "" { + values.Set("biz_id", in.BizId) + } + if in.EventType != "" { + values.Set("event_type", in.EventType) + } + if in.Cursor != "" { + values.Set("cursor", in.Cursor) + } + var raw sysin.ImDeadLetterPageRaw + path := fmt.Sprintf("/internal/admin-query/tenants/%s/kafka/dead-letters?%s", url.PathEscape(tenant.Code), values.Encode()) + if err = imadmin.DefaultClient(ctx).InternalGet(ctx, path, &raw); err != nil { + return nil, err + } + res = &sysin.ImDeadLetterPageModel{ + List: make([]*sysin.ImDeadLetterListModel, 0, len(raw.Items)), + NextCursor: raw.NextCursor, + HasMore: raw.HasMore, + } + for _, item := range raw.Items { + res.List = append(res.List, convertDeadLetterModel(item)) + } + return res, nil +} + +// View 获取IM Kafka死信详情 +func (s *sSysImDeadLetter) View(ctx context.Context, in *sysin.ImDeadLetterViewInp) (res *sysin.ImDeadLetterViewModel, err error) { + if err = in.Filter(ctx); err != nil { + return nil, err + } + tenant, err := s.getTenant(ctx, in.TenantId) + if err != nil { + return nil, err + } + var raw sysin.ImDeadLetterViewRaw + path := fmt.Sprintf("/internal/admin-query/tenants/%s/kafka/dead-letters/%s", url.PathEscape(tenant.Code), url.PathEscape(in.DeadId)) + if err = imadmin.DefaultClient(ctx).InternalGet(ctx, path, &raw); err != nil { + return nil, err + } + res = &sysin.ImDeadLetterViewModel{ + ImDeadLetterListModel: *convertDeadLetterModel(&raw.ImDeadLetterListModelRaw), + EnvelopeRaw: raw.EnvelopeRaw, + } + if raw.Envelope != nil { + b, _ := json.MarshalIndent(raw.Envelope, "", " ") + res.Envelope = string(b) + } + return res, nil +} + +// Replay 重放IM Kafka死信 +func (s *sSysImDeadLetter) Replay(ctx context.Context, in *sysin.ImDeadLetterActionInp) (res *sysin.ImGroupCommandModel, err error) { + return s.publishActionCommand(ctx, in, consts.ImAdminCommandKafkaDeadReplay) +} + +// Ignore 忽略IM Kafka死信 +func (s *sSysImDeadLetter) Ignore(ctx context.Context, in *sysin.ImDeadLetterActionInp) (res *sysin.ImGroupCommandModel, err error) { + return s.publishActionCommand(ctx, in, consts.ImAdminCommandKafkaDeadIgnore) +} + +// Resolve 标记IM Kafka死信已处理 +func (s *sSysImDeadLetter) Resolve(ctx context.Context, in *sysin.ImDeadLetterActionInp) (res *sysin.ImGroupCommandModel, err error) { + return s.publishActionCommand(ctx, in, consts.ImAdminCommandKafkaDeadResolve) +} + +func (s *sSysImDeadLetter) publishActionCommand(ctx context.Context, in *sysin.ImDeadLetterActionInp, eventType string) (*sysin.ImGroupCommandModel, error) { + if err := in.Filter(ctx); err != nil { + return nil, err + } + tenant, err := s.getTenant(ctx, in.TenantId) + if err != nil { + return nil, err + } + user := contexts.GetUser(ctx) + actorName := "" + if user != nil { + actorName = user.RealName + } + command, err := imkafka.PublishAdminCommand(ctx, imkafka.AdminCommandInput{ + TenantID: tenant.Id, + TenantCode: tenant.Code, + EventType: eventType, + BizType: "kafka_dead_letter", + BizID: in.DeadId, + Actor: &imkafka.Actor{ + Type: "admin", + ID: strconv.FormatInt(contexts.GetUserId(ctx), 10), + DisplayName: actorName, + }, + Payload: map[string]any{ + "command_id": "", + "actor_user_id": strconv.FormatInt(in.ActorUserId, 10), + "dead_id": in.DeadId, + "note": in.Note, + }, + CreatedBy: contexts.GetUserId(ctx), + }) + if err != nil { + return nil, err + } + return &sysin.ImGroupCommandModel{ + CommandID: command.CommandID, + EventID: command.EventID, + Status: command.Status, + TenantID: command.TenantID, + TenantCode: command.TenantCode, + EventType: command.EventType, + IdempotencyKey: command.IdempotencyKey, + }, nil +} + +func (s *sSysImDeadLetter) getTenant(ctx context.Context, tenantId int64) (tenant *entity.ImTenant, err error) { + if tenantId <= 0 { + return nil, gerror.New("请选择IM租户") + } + if err = imtenant.RegistryDB(ctx).Model(dao.ImTenant.Table()).Safe().Ctx(ctx).WherePri(tenantId).Scan(&tenant); err != nil { + return nil, gerror.Wrap(err, "获取IM租户失败") + } + if tenant == nil || tenant.Id <= 0 { + return nil, gerror.New("IM租户不存在") + } + if tenant.State != consts.ImTenantStateActive { + return nil, gerror.New("IM租户未启用") + } + return tenant, nil +} + +func convertDeadLetterModel(in *sysin.ImDeadLetterListModelRaw) *sysin.ImDeadLetterListModel { + if in == nil { + return &sysin.ImDeadLetterListModel{} + } + return &sysin.ImDeadLetterListModel{ + Id: in.Id, + DeadId: in.DeadId, + EventId: in.EventId, + ParentEventId: in.ParentEventId, + SourceTopic: in.SourceTopic, + ConsumerGroup: in.ConsumerGroup, + EventType: in.EventType, + BizType: in.BizType, + BizId: in.BizId, + RetryCount: in.RetryCount, + LastReason: in.LastReason, + LastError: in.LastError, + Status: in.Status, + ReplayCount: in.ReplayCount, + CreatedAt: in.CreatedAt, + UpdatedAt: in.UpdatedAt, + } +} diff --git a/server/internal/logic/sys/im_group.go b/server/internal/logic/sys/im_group.go index 26cffc6..ca3b9c3 100644 --- a/server/internal/logic/sys/im_group.go +++ b/server/internal/logic/sys/im_group.go @@ -7,9 +7,14 @@ package sys import ( "context" + "hotgo/internal/consts" + "hotgo/internal/library/contexts" + "hotgo/internal/library/imkafka" "hotgo/internal/library/imtenant" "hotgo/internal/model/input/sysin" "hotgo/internal/service" + "strconv" + "time" "github.com/gogf/gf/v2/database/gdb" "github.com/gogf/gf/v2/errors/gerror" @@ -454,3 +459,220 @@ func (s *sSysImGroup) AdminLogList(ctx context.Context, in *sysin.ImGroupAdminLo } return } + +// ProfileUpdate 通过Kafka修改IM群资料 +func (s *sSysImGroup) ProfileUpdate(ctx context.Context, in *sysin.ImGroupProfileUpdateInp) (res *sysin.ImGroupCommandModel, err error) { + if err = in.ImGroupTopicInp.Filter(ctx); err != nil { + return nil, err + } + if in.ActorUserId <= 0 { + return nil, gerror.New("操作IM用户ID不能为空") + } + payload := map[string]any{ + "actor_user_id": imUserId(in.ActorUserId), + "topic": in.Topic, + "profile": map[string]any{}, + "version": in.Version, + } + profile := payload["profile"].(map[string]any) + if in.Name != "" { + profile["name"] = in.Name + } + if in.AvatarFileId != "" { + profile["avatar_file_id"] = in.AvatarFileId + } + if in.Description != "" { + profile["description"] = in.Description + } + return s.publishGroupCommand(ctx, in.TenantId, consts.ImAdminCommandGroupProfileUpdate, in.Topic, int64(in.Version), payload) +} + +// GovernanceUpdate 通过Kafka修改IM群治理配置 +func (s *sSysImGroup) GovernanceUpdate(ctx context.Context, in *sysin.ImGroupGovernanceUpdateInp) (res *sysin.ImGroupCommandModel, err error) { + if err = in.ImGroupTopicInp.Filter(ctx); err != nil { + return nil, err + } + if in.ActorUserId <= 0 { + return nil, gerror.New("操作IM用户ID不能为空") + } + payload := map[string]any{ + "actor_user_id": imUserId(in.ActorUserId), + "topic": in.Topic, + "governance": map[string]any{}, + "version": in.Version, + } + governance := payload["governance"].(map[string]any) + if in.JoinApproval != nil { + governance["join_approval"] = *in.JoinApproval + } + if in.MuteAll != nil { + governance["mute_all"] = *in.MuteAll + } + if in.MemberUploadFiles != nil { + governance["member_upload_files"] = *in.MemberUploadFiles + } + if in.MemberPrivateChat != nil { + governance["member_private_chat"] = *in.MemberPrivateChat + } + if in.MemberInvite != nil { + governance["member_invite"] = *in.MemberInvite + } + if len(governance) == 0 { + return nil, gerror.New("请至少选择一个治理配置") + } + return s.publishGroupCommand(ctx, in.TenantId, consts.ImAdminCommandGroupGovernUpdate, in.Topic, int64(in.Version), payload) +} + +// AdminSet 通过Kafka设置IM群管理员 +func (s *sSysImGroup) AdminSet(ctx context.Context, in *sysin.ImGroupAdminSetInp) (res *sysin.ImGroupCommandModel, err error) { + if err = in.ImGroupTopicInp.Filter(ctx); err != nil { + return nil, err + } + if in.ActorUserId <= 0 || len(in.UserIds) == 0 { + return nil, gerror.New("操作IM用户ID和用户ID不能为空") + } + payload := map[string]any{ + "actor_user_id": imUserId(in.ActorUserId), + "topic": in.Topic, + "user_ids": imUserIds(in.UserIds), + } + return s.publishGroupCommand(ctx, in.TenantId, consts.ImAdminCommandGroupAdminSet, in.Topic, 0, payload) +} + +// AdminRemove 通过Kafka删除IM群管理员 +func (s *sSysImGroup) AdminRemove(ctx context.Context, in *sysin.ImGroupAdminRemoveInp) (res *sysin.ImGroupCommandModel, err error) { + if err = in.ImGroupTopicInp.Filter(ctx); err != nil { + return nil, err + } + if in.ActorUserId <= 0 || in.UserId <= 0 { + return nil, gerror.New("操作IM用户ID和用户ID不能为空") + } + payload := map[string]any{ + "actor_user_id": imUserId(in.ActorUserId), + "topic": in.Topic, + "user_id": imUserId(in.UserId), + } + return s.publishGroupCommand(ctx, in.TenantId, consts.ImAdminCommandGroupAdminRemove, in.Topic, 0, payload) +} + +// MemberMute 通过Kafka禁言IM群成员 +func (s *sSysImGroup) MemberMute(ctx context.Context, in *sysin.ImGroupMemberMuteInp) (res *sysin.ImGroupCommandModel, err error) { + if err = in.ImGroupTopicInp.Filter(ctx); err != nil { + return nil, err + } + if in.ActorUserId <= 0 || in.UserId <= 0 { + return nil, gerror.New("操作IM用户ID和用户ID不能为空") + } + payload := map[string]any{ + "actor_user_id": imUserId(in.ActorUserId), + "topic": in.Topic, + "user_id": imUserId(in.UserId), + "reason": in.Reason, + } + if in.ExpiresAt != nil { + payload["expires_at"] = in.ExpiresAt.Time.UTC().Format(time.RFC3339Nano) + } + return s.publishGroupCommand(ctx, in.TenantId, consts.ImAdminCommandGroupMemberMute, in.Topic, 0, payload) +} + +// MemberUnmute 通过Kafka解除IM群成员禁言 +func (s *sSysImGroup) MemberUnmute(ctx context.Context, in *sysin.ImGroupMemberUnmuteInp) (res *sysin.ImGroupCommandModel, err error) { + if err = in.ImGroupTopicInp.Filter(ctx); err != nil { + return nil, err + } + if in.ActorUserId <= 0 || in.UserId <= 0 { + return nil, gerror.New("操作IM用户ID和用户ID不能为空") + } + payload := map[string]any{ + "actor_user_id": imUserId(in.ActorUserId), + "topic": in.Topic, + "user_id": imUserId(in.UserId), + } + return s.publishGroupCommand(ctx, in.TenantId, consts.ImAdminCommandGroupMemberUnmute, in.Topic, 0, payload) +} + +// MemberRemove 通过Kafka移出IM群成员 +func (s *sSysImGroup) MemberRemove(ctx context.Context, in *sysin.ImGroupMemberRemoveInp) (res *sysin.ImGroupCommandModel, err error) { + if err = in.ImGroupTopicInp.Filter(ctx); err != nil { + return nil, err + } + if in.ActorUserId <= 0 || in.UserId <= 0 { + return nil, gerror.New("操作IM用户ID和用户ID不能为空") + } + payload := map[string]any{ + "actor_user_id": imUserId(in.ActorUserId), + "topic": in.Topic, + "user_id": imUserId(in.UserId), + } + return s.publishGroupCommand(ctx, in.TenantId, consts.ImAdminCommandGroupMemberRemove, in.Topic, 0, payload) +} + +// OwnerTransfer 通过Kafka转让IM群主 +func (s *sSysImGroup) OwnerTransfer(ctx context.Context, in *sysin.ImGroupOwnerTransferInp) (res *sysin.ImGroupCommandModel, err error) { + if err = in.ImGroupTopicInp.Filter(ctx); err != nil { + return nil, err + } + if in.ActorUserId <= 0 || in.TargetUserId <= 0 { + return nil, gerror.New("操作IM用户ID和新群主ID不能为空") + } + payload := map[string]any{ + "actor_user_id": imUserId(in.ActorUserId), + "topic": in.Topic, + "target_user_id": imUserId(in.TargetUserId), + "version": in.Version, + } + return s.publishGroupCommand(ctx, in.TenantId, consts.ImAdminCommandGroupOwnerTransfer, in.Topic, int64(in.Version), payload) +} + +func (s *sSysImGroup) publishGroupCommand(ctx context.Context, tenantId int64, eventType string, topic string, version int64, payload map[string]any) (*sysin.ImGroupCommandModel, error) { + _, tenant, err := imtenant.DB(ctx, tenantId) + if err != nil { + return nil, err + } + user := contexts.GetUser(ctx) + actorName := "" + if user != nil { + actorName = user.RealName + } + command, err := imkafka.PublishAdminCommand(ctx, imkafka.AdminCommandInput{ + TenantID: tenant.Id, + TenantCode: tenant.Code, + EventType: eventType, + BizType: "group", + BizID: topic, + Version: version, + Actor: &imkafka.Actor{ + Type: "admin", + ID: strconv.FormatInt(contexts.GetUserId(ctx), 10), + DisplayName: actorName, + }, + Payload: payload, + CreatedBy: contexts.GetUserId(ctx), + }) + if err != nil { + return nil, err + } + return &sysin.ImGroupCommandModel{ + CommandID: command.CommandID, + EventID: command.EventID, + Status: command.Status, + TenantID: command.TenantID, + TenantCode: command.TenantCode, + EventType: command.EventType, + IdempotencyKey: command.IdempotencyKey, + }, nil +} + +func imUserId(id int64) string { + return strconv.FormatInt(id, 10) +} + +func imUserIds(ids []int64) []string { + list := make([]string, 0, len(ids)) + for _, id := range ids { + if id > 0 { + list = append(list, imUserId(id)) + } + } + return list +} diff --git a/server/internal/logic/sys/im_kafka_audit.go b/server/internal/logic/sys/im_kafka_audit.go new file mode 100644 index 0000000..6656bf1 --- /dev/null +++ b/server/internal/logic/sys/im_kafka_audit.go @@ -0,0 +1,86 @@ +package sys + +import ( + "context" + "hotgo/internal/model/input/sysin" + "hotgo/internal/service" + + "github.com/gogf/gf/v2/errors/gerror" + "github.com/gogf/gf/v2/frame/g" +) + +type sSysImKafkaAudit struct{} + +func NewSysImKafkaAudit() *sSysImKafkaAudit { + return &sSysImKafkaAudit{} +} + +func init() { + service.RegisterSysImKafkaAudit(NewSysImKafkaAudit()) +} + +func (s *sSysImKafkaAudit) List(ctx context.Context, in *sysin.ImKafkaAuditListInp) (list []*sysin.ImKafkaAuditListModel, totalCount int, err error) { + if err = in.Filter(ctx); err != nil { + return + } + mod := g.DB().Model("im_audit_events").Safe().Ctx(ctx). + Fields("id", "event_id", "tenant_code", "event_type", "biz_type", "biz_id", "actor_type", "actor_id", "action", "resource", "resource_id", "result", "reason", "occurred_at", "received_at") + if in.TenantCode != "" { + mod = mod.Where("tenant_code", in.TenantCode) + } + if in.EventId != "" { + mod = mod.WhereLike("event_id", "%"+in.EventId+"%") + } + if in.EventType != "" { + mod = mod.WhereLike("event_type", "%"+in.EventType+"%") + } + if in.BizType != "" { + mod = mod.Where("biz_type", in.BizType) + } + if in.BizId != "" { + mod = mod.WhereLike("biz_id", "%"+in.BizId+"%") + } + if in.ActorType != "" { + mod = mod.Where("actor_type", in.ActorType) + } + if in.ActorId != "" { + mod = mod.WhereLike("actor_id", "%"+in.ActorId+"%") + } + if in.Action != "" { + mod = mod.WhereLike("action", "%"+in.Action+"%") + } + if in.Resource != "" { + mod = mod.Where("resource", in.Resource) + } + if in.ResourceId != "" { + mod = mod.WhereLike("resource_id", "%"+in.ResourceId+"%") + } + if in.Result != "" { + mod = mod.Where("result", in.Result) + } + if len(in.CreatedAt) == 2 { + mod = mod.WhereBetween("received_at", in.CreatedAt[0], in.CreatedAt[1]) + } + mod = mod.Page(in.Page, in.PerPage).OrderDesc("id") + if err = mod.ScanAndCount(&list, &totalCount, false); err != nil { + err = gerror.Wrap(err, "获取IM Kafka审计事件列表失败,请稍后重试!") + } + return +} + +func (s *sSysImKafkaAudit) View(ctx context.Context, in *sysin.ImKafkaAuditViewInp) (res *sysin.ImKafkaAuditViewModel, err error) { + if err = in.Filter(ctx); err != nil { + return nil, err + } + mod := g.DB().Model("im_audit_events").Safe().Ctx(ctx). + Fields("id", "event_id", "tenant_code", "event_type", "biz_type", "biz_id", "actor_type", "actor_id", "action", "resource", "resource_id", "result", "reason", "CAST(payload AS CHAR) payload", "CAST(envelope AS CHAR) envelope", "occurred_at", "received_at") + if in.Id > 0 { + mod = mod.Where("id", in.Id) + } else { + mod = mod.Where("event_id", in.EventId) + } + if err = mod.Scan(&res); err != nil { + return nil, gerror.Wrap(err, "获取IM Kafka审计事件详情失败,请稍后重试!") + } + return +} diff --git a/server/internal/logic/sys/im_kafka_command.go b/server/internal/logic/sys/im_kafka_command.go new file mode 100644 index 0000000..620eeea --- /dev/null +++ b/server/internal/logic/sys/im_kafka_command.go @@ -0,0 +1,187 @@ +// Package sys +// @Link https://github.com/bufanyun/hotgo +// @Copyright Copyright (c) 2026 HotGo +// @Author Ms <133814250@qq.com> +// @License https://github.com/bufanyun/hotgo/blob/master/LICENSE +package sys + +import ( + "context" + "hotgo/internal/consts" + "hotgo/internal/library/contexts" + "hotgo/internal/library/imkafka" + "hotgo/internal/model/input/sysin" + "hotgo/internal/service" + + "github.com/gogf/gf/v2/errors/gerror" + "github.com/gogf/gf/v2/frame/g" + "github.com/gogf/gf/v2/util/gconv" +) + +type sSysImKafkaCommand struct{} + +func NewSysImKafkaCommand() *sSysImKafkaCommand { + return &sSysImKafkaCommand{} +} + +func init() { + service.RegisterSysImKafkaCommand(NewSysImKafkaCommand()) +} + +func (s *sSysImKafkaCommand) List(ctx context.Context, in *sysin.ImKafkaCommandListInp) (list []*sysin.ImKafkaCommandListModel, totalCount int, err error) { + if err = in.Filter(ctx); err != nil { + return + } + mod := g.DB().Model("im_admin_commands").Safe().Ctx(ctx). + Fields( + "id", + "command_id", + "tenant_id", + "tenant_code", + "event_id", + "event_type", + "biz_type", + "biz_id", + "idempotency_key", + "status", + "reason", + "message", + "sent_at", + "completed_at", + "created_by", + "created_at", + "updated_at", + ) + if in.TenantId > 0 { + mod = mod.Where("tenant_id", in.TenantId) + } + if in.CommandId != "" { + mod = mod.WhereLike("command_id", "%"+in.CommandId+"%") + } + if in.EventId != "" { + mod = mod.WhereLike("event_id", "%"+in.EventId+"%") + } + if in.EventType != "" { + mod = mod.WhereLike("event_type", "%"+in.EventType+"%") + } + if in.BizType != "" { + mod = mod.Where("biz_type", in.BizType) + } + if in.BizId != "" { + mod = mod.WhereLike("biz_id", "%"+in.BizId+"%") + } + if in.Status != "" { + mod = mod.Where("status", in.Status) + } + if len(in.CreatedAt) == 2 { + mod = mod.WhereBetween("created_at", in.CreatedAt[0], in.CreatedAt[1]) + } + mod = mod.Page(in.Page, in.PerPage).OrderDesc("id") + if err = mod.ScanAndCount(&list, &totalCount, false); err != nil { + err = gerror.Wrap(err, "获取IM后台命令列表失败,请稍后重试!") + } + return +} + +func (s *sSysImKafkaCommand) View(ctx context.Context, in *sysin.ImKafkaCommandViewInp) (res *sysin.ImKafkaCommandViewModel, err error) { + if err = in.Filter(ctx); err != nil { + return nil, err + } + mod := g.DB().Model("im_admin_commands").Safe().Ctx(ctx). + Fields( + "id", + "command_id", + "tenant_id", + "tenant_code", + "event_id", + "event_type", + "biz_type", + "biz_id", + "idempotency_key", + "status", + "reason", + "message", + "CAST(payload AS CHAR) payload", + "CAST(envelope AS CHAR) envelope", + "CAST(result AS CHAR) result", + "sent_at", + "completed_at", + "created_by", + "created_at", + "updated_at", + ) + if in.Id > 0 { + mod = mod.Where("id", in.Id) + } else { + mod = mod.Where("command_id", in.CommandId) + } + if err = mod.Scan(&res); err != nil { + return nil, gerror.Wrap(err, "获取IM后台命令详情失败,请稍后重试!") + } + return +} + +func (s *sSysImKafkaCommand) CacheInvalidate(ctx context.Context, in *sysin.ImKafkaCacheInvalidateInp) (res *sysin.ImKafkaEventModel, err error) { + if err = in.Filter(ctx); err != nil { + return nil, err + } + user := contexts.GetUser(ctx) + actor := &imkafka.Actor{ + Type: "admin", + ID: gconv.String(contexts.GetUserId(ctx)), + } + if user != nil { + actor.DisplayName = user.RealName + } + payload := map[string]any{ + "tenant_code": in.TenantCode, + "namespace": in.Namespace, + "keys": in.Keys, + "reason": in.Reason, + } + eventID, err := imkafka.PublishEvent(ctx, imkafka.EventInput{ + Topic: consts.QueueImCacheInvalidateTopic, + TenantCode: in.TenantCode, + EventType: "cache.invalidate", + BizType: "cache", + BizID: in.Namespace, + Actor: actor, + Payload: payload, + CreatedBy: contexts.GetUserId(ctx), + }) + if err != nil { + return nil, err + } + return &sysin.ImKafkaEventModel{EventId: eventID}, nil +} + +func (s *sSysImKafkaCommand) WorkbenchEvent(ctx context.Context, in *sysin.ImKafkaWorkbenchEventInp) (res *sysin.ImKafkaEventModel, err error) { + if err = in.Filter(ctx); err != nil { + return nil, err + } + user := contexts.GetUser(ctx) + actor := &imkafka.Actor{ + Type: "admin", + ID: gconv.String(contexts.GetUserId(ctx)), + } + if user != nil { + actor.DisplayName = user.RealName + } + payload := in.Payload + payload["tenant_code"] = in.TenantCode + payload["source"] = "admin" + eventID, err := imkafka.PublishEvent(ctx, imkafka.EventInput{ + Topic: consts.QueueImWorkbenchEventTopic, + TenantCode: in.TenantCode, + EventType: in.EventType, + BizType: "workbench", + BizID: in.BizId, + Actor: actor, + Payload: payload, + CreatedBy: contexts.GetUserId(ctx), + }) + if err != nil { + return nil, err + } + return &sysin.ImKafkaEventModel{EventId: eventID}, nil +} diff --git a/server/internal/logic/sys/im_registration.go b/server/internal/logic/sys/im_registration.go index 688e54e..5304c71 100644 --- a/server/internal/logic/sys/im_registration.go +++ b/server/internal/logic/sys/im_registration.go @@ -2,8 +2,10 @@ package sys import ( "context" + "hotgo/internal/consts" "hotgo/internal/dao" "hotgo/internal/library/contexts" + "hotgo/internal/library/imkafka" "hotgo/internal/model/input/sysin" "hotgo/internal/service" @@ -11,6 +13,7 @@ import ( "github.com/gogf/gf/v2/errors/gerror" "github.com/gogf/gf/v2/frame/g" "github.com/gogf/gf/v2/os/gtime" + "github.com/gogf/gf/v2/util/gconv" ) const ( @@ -62,6 +65,24 @@ func (s *sSysImRegistration) UpdateConfig(ctx context.Context, in *sysin.ImRegis "old": old.RegisterSmsEnabled, "new": in.RegisterSmsEnabled, }) + actorName := "" + if user := contexts.GetUser(ctx); user != nil { + actorName = user.RealName + } + if eventErr := imkafka.PublishConfigEvent(ctx, imkafka.ConfigEventInput{ + EventType: consts.ImConfigEventRegistrationUpdated, + BizID: "registration", + Group: imRegistrationConfigGroup, + Keys: []string{imRegistrationSmsKey}, + Actor: &imkafka.Actor{ + Type: "admin", + ID: gconv.String(contexts.GetUserId(ctx)), + DisplayName: actorName, + }, + CreatedBy: contexts.GetUserId(ctx), + }); eventErr != nil { + g.Log().Warningf(ctx, "发送IM注册配置Kafka事件失败 err:%+v", eventErr) + } return } diff --git a/server/internal/logic/sys/im_tenant.go b/server/internal/logic/sys/im_tenant.go index 6d9978b..2b34bff 100644 --- a/server/internal/logic/sys/im_tenant.go +++ b/server/internal/logic/sys/im_tenant.go @@ -7,10 +7,12 @@ package sys import ( "context" + "hotgo/internal/consts" "hotgo/internal/dao" "hotgo/internal/library/contexts" "hotgo/internal/library/hgorm/handler" "hotgo/internal/library/hgorm/hook" + "hotgo/internal/library/imkafka" "hotgo/internal/library/imtenant" "hotgo/internal/model/entity" "hotgo/internal/model/input/sysin" @@ -19,6 +21,7 @@ import ( "github.com/gogf/gf/v2/database/gdb" "github.com/gogf/gf/v2/errors/gerror" "github.com/gogf/gf/v2/frame/g" + "github.com/gogf/gf/v2/util/gconv" ) type sSysImTenant struct{} @@ -142,7 +145,7 @@ func (s *sSysImTenant) Edit(ctx context.Context, in *sysin.ImTenantEditInp) (err return } - return imtenant.RegistryDB(ctx).Transaction(ctx, func(ctx context.Context, tx gdb.TX) (err error) { + err = imtenant.RegistryDB(ctx).Transaction(ctx, func(ctx context.Context, tx gdb.TX) (err error) { if err = s.checkCodeUnique(ctx, in.Id, in.Code); err != nil { return } @@ -167,6 +170,12 @@ func (s *sSysImTenant) Edit(ctx context.Context, in *sysin.ImTenantEditInp) (err } return s.saveConfig(ctx, id, in.MaxUsers, in.MaxGroups, in.MaxGroupMembers, userId) }) + if err != nil { + return err + } + s.publishTenantConfigEvent(ctx, in.Code, in.Id, consts.ImConfigEventTenantUpdated, "tenant") + s.publishTenantConfigEvent(ctx, in.Code, in.Id, consts.ImConfigEventTenantLimitsUpdated, "tenant_limits") + return nil } func (s *sSysImTenant) checkCodeUnique(ctx context.Context, id int64, code string) (err error) { @@ -269,5 +278,33 @@ func (s *sSysImTenant) Status(ctx context.Context, in *sysin.ImTenantStatusInp) err = gerror.Wrap(err, "更新IM租户状态失败,请稍后重试!") return } + var tenant *entity.ImTenant + if scanErr := s.Model(ctx).Fields(dao.ImTenant.Columns().Id, dao.ImTenant.Columns().Code).WherePri(in.Id).Scan(&tenant); scanErr == nil && tenant != nil { + s.publishTenantConfigEvent(ctx, tenant.Code, tenant.Id, consts.ImConfigEventTenantStatusChanged, "tenant_status") + } return } + +func (s *sSysImTenant) publishTenantConfigEvent(ctx context.Context, tenantCode string, tenantId int64, eventType string, bizId string) { + user := contexts.GetUser(ctx) + actorName := "" + if user != nil { + actorName = user.RealName + } + if err := imkafka.PublishConfigEvent(ctx, imkafka.ConfigEventInput{ + TenantID: tenantId, + TenantCode: tenantCode, + EventType: eventType, + BizID: bizId, + Group: "tenant", + Keys: []string{"tenant", "limits", "features"}, + Actor: &imkafka.Actor{ + Type: "admin", + ID: gconv.String(contexts.GetUserId(ctx)), + DisplayName: actorName, + }, + CreatedBy: contexts.GetUserId(ctx), + }); err != nil { + g.Log().Warningf(ctx, "发送IM租户配置Kafka事件失败 tenant:%s type:%s err:%+v", tenantCode, eventType, err) + } +} diff --git a/server/internal/logic/sys/im_user.go b/server/internal/logic/sys/im_user.go index d10ce8d..29020d9 100644 --- a/server/internal/logic/sys/im_user.go +++ b/server/internal/logic/sys/im_user.go @@ -7,9 +7,13 @@ package sys import ( "context" + "hotgo/internal/consts" + "hotgo/internal/library/contexts" + "hotgo/internal/library/imkafka" "hotgo/internal/library/imtenant" "hotgo/internal/model/input/sysin" "hotgo/internal/service" + "strconv" "strings" "github.com/gogf/gf/v2/database/gdb" @@ -187,6 +191,103 @@ func (s *sSysImUser) View(ctx context.Context, in *sysin.ImUserViewInp) (res *sy return } +// Ban 通过Kafka封禁IM用户 +func (s *sSysImUser) Ban(ctx context.Context, in *sysin.ImUserGovernanceInp) (res *sysin.ImUserCommandModel, err error) { + return s.publishGovernanceCommand(ctx, in, consts.ImAdminCommandUserBan) +} + +// Unban 通过Kafka解封IM用户 +func (s *sSysImUser) Unban(ctx context.Context, in *sysin.ImUserGovernanceInp) (res *sysin.ImUserCommandModel, err error) { + return s.publishGovernanceCommand(ctx, in, consts.ImAdminCommandUserUnban) +} + +// Freeze 通过Kafka冻结IM用户 +func (s *sSysImUser) Freeze(ctx context.Context, in *sysin.ImUserGovernanceInp) (res *sysin.ImUserCommandModel, err error) { + return s.publishGovernanceCommand(ctx, in, consts.ImAdminCommandUserFreeze) +} + +// Unfreeze 通过Kafka解冻IM用户 +func (s *sSysImUser) Unfreeze(ctx context.Context, in *sysin.ImUserGovernanceInp) (res *sysin.ImUserCommandModel, err error) { + return s.publishGovernanceCommand(ctx, in, consts.ImAdminCommandUserUnfreeze) +} + +// DeviceKick 通过Kafka踢出IM用户设备 +func (s *sSysImUser) DeviceKick(ctx context.Context, in *sysin.ImUserGovernanceInp) (res *sysin.ImUserCommandModel, err error) { + if err = in.FilterDevice(ctx); err != nil { + return nil, err + } + return s.publishGovernanceCommand(ctx, in, consts.ImAdminCommandUserDeviceKick) +} + +// LogoutAll 通过Kafka踢出IM用户全部在线会话 +func (s *sSysImUser) LogoutAll(ctx context.Context, in *sysin.ImUserGovernanceInp) (res *sysin.ImUserCommandModel, err error) { + return s.publishGovernanceCommand(ctx, in, consts.ImAdminCommandUserLogoutAll) +} + +// AccountCancel 通过Kafka注销IM用户账号 +func (s *sSysImUser) AccountCancel(ctx context.Context, in *sysin.ImUserGovernanceInp) (res *sysin.ImUserCommandModel, err error) { + return s.publishGovernanceCommand(ctx, in, consts.ImAdminCommandUserCancel) +} + +// AccountDelete 通过Kafka删除IM用户账号 +func (s *sSysImUser) AccountDelete(ctx context.Context, in *sysin.ImUserGovernanceInp) (res *sysin.ImUserCommandModel, err error) { + in.Hard = true + return s.publishGovernanceCommand(ctx, in, consts.ImAdminCommandUserDelete) +} + +func (s *sSysImUser) publishGovernanceCommand(ctx context.Context, in *sysin.ImUserGovernanceInp, eventType string) (*sysin.ImUserCommandModel, error) { + if err := in.Filter(ctx); err != nil { + return nil, err + } + _, tenant, err := imtenant.DB(ctx, in.TenantId) + if err != nil { + return nil, err + } + user := contexts.GetUser(ctx) + actorName := "" + if user != nil { + actorName = user.RealName + } + payload := map[string]any{ + "actor_user_id": strconv.FormatInt(in.ActorUserId, 10), + "user_id": strconv.FormatInt(in.UserId, 10), + "target_user_id": strconv.FormatInt(in.UserId, 10), + "reason": in.Reason, + } + if in.DeviceId != "" { + payload["device_id"] = in.DeviceId + } + if eventType == consts.ImAdminCommandUserCancel || eventType == consts.ImAdminCommandUserDelete { + payload["hard"] = in.Hard + } + command, err := imkafka.PublishAdminCommand(ctx, imkafka.AdminCommandInput{ + TenantID: tenant.Id, + TenantCode: tenant.Code, + EventType: eventType, + BizType: "user", + BizID: strconv.FormatInt(in.UserId, 10), + Actor: &imkafka.Actor{ + Type: "admin", + ID: strconv.FormatInt(contexts.GetUserId(ctx), 10), + DisplayName: actorName, + }, + Payload: payload, + CreatedBy: contexts.GetUserId(ctx), + }) + if err != nil { + return nil, err + } + return &sysin.ImUserCommandModel{ + CommandID: command.CommandID, + EventID: command.EventID, + Status: command.Status, + TenantID: command.TenantID, + TenantCode: command.TenantCode, + EventType: command.EventType, + IdempotencyKey: command.IdempotencyKey, + }, nil +} + func (s *sSysImUser) viewFields() []interface{} { return []interface{}{ "u.id user_id", diff --git a/server/internal/model/input/sysin/im_dead_letter.go b/server/internal/model/input/sysin/im_dead_letter.go new file mode 100644 index 0000000..d81e506 --- /dev/null +++ b/server/internal/model/input/sysin/im_dead_letter.go @@ -0,0 +1,130 @@ +// Package sysin +// @Link https://github.com/bufanyun/hotgo +// @Copyright Copyright (c) 2026 HotGo +// @Author Ms <133814250@qq.com> +// @License https://github.com/bufanyun/hotgo/blob/master/LICENSE +package sysin + +import ( + "context" + + "github.com/gogf/gf/v2/errors/gerror" + "github.com/gogf/gf/v2/os/gtime" +) + +type ImDeadLetterListInp struct { + TenantId int64 `json:"tenantId" dc:"租户ID"` + Status string `json:"status" dc:"状态"` + BizType string `json:"bizType" dc:"业务类型"` + BizId string `json:"bizId" dc:"业务ID"` + EventType string `json:"eventType" dc:"事件类型"` + Cursor string `json:"cursor" dc:"游标"` + Limit int `json:"limit" dc:"数量"` +} + +func (in *ImDeadLetterListInp) Filter(ctx context.Context) (err error) { + if in.TenantId <= 0 { + return gerror.New("请选择IM租户") + } + if in.Limit <= 0 || in.Limit > 200 { + in.Limit = 50 + } + return nil +} + +type ImDeadLetterListModel struct { + Id int64 `json:"id" dc:"ID"` + DeadId string `json:"deadId" dc:"死信ID"` + EventId string `json:"eventId" dc:"事件ID"` + ParentEventId string `json:"parentEventId" dc:"父事件ID"` + SourceTopic string `json:"sourceTopic" dc:"来源Topic"` + ConsumerGroup string `json:"consumerGroup" dc:"消费组"` + EventType string `json:"eventType" dc:"事件类型"` + BizType string `json:"bizType" dc:"业务类型"` + BizId string `json:"bizId" dc:"业务ID"` + RetryCount int `json:"retryCount" dc:"重试次数"` + LastReason string `json:"lastReason" dc:"最后原因"` + LastError string `json:"lastError" dc:"最后错误"` + Status string `json:"status" dc:"状态"` + ReplayCount int `json:"replayCount" dc:"重放次数"` + CreatedAt *gtime.Time `json:"createdAt" dc:"创建时间"` + UpdatedAt *gtime.Time `json:"updatedAt" dc:"更新时间"` +} + +type ImDeadLetterListModelRaw struct { + Id int64 `json:"id"` + DeadId string `json:"dead_id"` + EventId string `json:"event_id"` + ParentEventId string `json:"parent_event_id"` + SourceTopic string `json:"source_topic"` + ConsumerGroup string `json:"consumer_group"` + EventType string `json:"event_type"` + BizType string `json:"biz_type"` + BizId string `json:"biz_id"` + RetryCount int `json:"retry_count"` + LastReason string `json:"last_reason"` + LastError string `json:"last_error"` + Status string `json:"status"` + ReplayCount int `json:"replay_count"` + CreatedAt *gtime.Time `json:"created_at"` + UpdatedAt *gtime.Time `json:"updated_at"` +} + +type ImDeadLetterPageModel struct { + List []*ImDeadLetterListModel `json:"list" dc:"数据列表"` + NextCursor string `json:"nextCursor" dc:"下一页游标"` + HasMore bool `json:"hasMore" dc:"是否还有更多"` +} + +type ImDeadLetterPageRaw struct { + Items []*ImDeadLetterListModelRaw `json:"items"` + NextCursor string `json:"next_cursor"` + HasMore bool `json:"has_more"` +} + +type ImDeadLetterViewInp struct { + TenantId int64 `json:"tenantId" dc:"租户ID"` + DeadId string `json:"deadId" dc:"死信ID"` +} + +func (in *ImDeadLetterViewInp) Filter(ctx context.Context) (err error) { + if in.TenantId <= 0 { + return gerror.New("请选择IM租户") + } + if in.DeadId == "" { + return gerror.New("请选择Kafka死信") + } + return nil +} + +type ImDeadLetterViewModel struct { + ImDeadLetterListModel + Envelope string `json:"envelope" dc:"Envelope"` + EnvelopeRaw string `json:"envelopeRaw" dc:"Envelope Raw"` +} + +type ImDeadLetterViewRaw struct { + ImDeadLetterListModelRaw + Envelope any `json:"envelope"` + EnvelopeRaw string `json:"envelope_raw"` +} + +type ImDeadLetterActionInp struct { + TenantId int64 `json:"tenantId" dc:"租户ID"` + DeadId string `json:"deadId" dc:"死信ID"` + ActorUserId int64 `json:"actorUserId" dc:"操作IM用户ID"` + Note string `json:"note" dc:"备注"` +} + +func (in *ImDeadLetterActionInp) Filter(ctx context.Context) (err error) { + if in.TenantId <= 0 { + return gerror.New("请选择IM租户") + } + if in.DeadId == "" { + return gerror.New("请选择Kafka死信") + } + if in.ActorUserId <= 0 { + return gerror.New("操作IM用户ID不能为空") + } + return nil +} diff --git a/server/internal/model/input/sysin/im_group.go b/server/internal/model/input/sysin/im_group.go index d2e68b3..a37dba7 100644 --- a/server/internal/model/input/sysin/im_group.go +++ b/server/internal/model/input/sysin/im_group.go @@ -288,3 +288,72 @@ type ImGroupAdminLogListModel struct { IdempotencyKey string `json:"idempotencyKey" dc:"幂等键"` CreatedAt *gtime.Time `json:"createdAt" dc:"创建时间"` } + +type ImGroupCommandModel struct { + CommandID string `json:"commandId" dc:"命令ID"` + EventID string `json:"eventId" dc:"Kafka事件ID"` + Status string `json:"status" dc:"状态"` + TenantID int64 `json:"tenantId" dc:"租户ID"` + TenantCode string `json:"tenantCode" dc:"租户编码"` + EventType string `json:"eventType" dc:"事件类型"` + IdempotencyKey string `json:"idempotencyKey" dc:"幂等键"` +} + +type ImGroupProfileUpdateInp struct { + ImGroupTopicInp + ActorUserId int64 `json:"actorUserId" v:"required#操作IM用户ID不能为空" dc:"操作IM用户ID"` + Name string `json:"name" dc:"群名称"` + AvatarFileId string `json:"avatarFileId" dc:"头像文件ID"` + Description string `json:"description" dc:"简介"` + Version int `json:"version" dc:"版本"` +} + +type ImGroupGovernanceUpdateInp struct { + ImGroupTopicInp + ActorUserId int64 `json:"actorUserId" v:"required#操作IM用户ID不能为空" dc:"操作IM用户ID"` + JoinApproval *bool `json:"joinApproval" dc:"入群审批"` + MuteAll *bool `json:"muteAll" dc:"全员禁言"` + MemberUploadFiles *bool `json:"memberUploadFiles" dc:"成员上传文件"` + MemberPrivateChat *bool `json:"memberPrivateChat" dc:"成员私聊"` + MemberInvite *bool `json:"memberInvite" dc:"成员邀请"` + Version int `json:"version" dc:"版本"` +} + +type ImGroupAdminSetInp struct { + ImGroupTopicInp + ActorUserId int64 `json:"actorUserId" v:"required#操作IM用户ID不能为空" dc:"操作IM用户ID"` + UserIds []int64 `json:"userIds" v:"required#用户ID不能为空" dc:"用户ID列表"` +} + +type ImGroupAdminRemoveInp struct { + ImGroupTopicInp + ActorUserId int64 `json:"actorUserId" v:"required#操作IM用户ID不能为空" dc:"操作IM用户ID"` + UserId int64 `json:"userId" v:"required#用户ID不能为空" dc:"用户ID"` +} + +type ImGroupMemberMuteInp struct { + ImGroupTopicInp + ActorUserId int64 `json:"actorUserId" v:"required#操作IM用户ID不能为空" dc:"操作IM用户ID"` + UserId int64 `json:"userId" v:"required#用户ID不能为空" dc:"用户ID"` + Reason string `json:"reason" dc:"原因"` + ExpiresAt *gtime.Time `json:"expiresAt" dc:"过期时间"` +} + +type ImGroupMemberUnmuteInp struct { + ImGroupTopicInp + ActorUserId int64 `json:"actorUserId" v:"required#操作IM用户ID不能为空" dc:"操作IM用户ID"` + UserId int64 `json:"userId" v:"required#用户ID不能为空" dc:"用户ID"` +} + +type ImGroupMemberRemoveInp struct { + ImGroupTopicInp + ActorUserId int64 `json:"actorUserId" v:"required#操作IM用户ID不能为空" dc:"操作IM用户ID"` + UserId int64 `json:"userId" v:"required#用户ID不能为空" dc:"用户ID"` +} + +type ImGroupOwnerTransferInp struct { + ImGroupTopicInp + ActorUserId int64 `json:"actorUserId" v:"required#操作IM用户ID不能为空" dc:"操作IM用户ID"` + TargetUserId int64 `json:"targetUserId" v:"required#新群主ID不能为空" dc:"新群主ID"` + Version int `json:"version" dc:"版本"` +} diff --git a/server/internal/model/input/sysin/im_kafka_audit.go b/server/internal/model/input/sysin/im_kafka_audit.go new file mode 100644 index 0000000..caea3ae --- /dev/null +++ b/server/internal/model/input/sysin/im_kafka_audit.go @@ -0,0 +1,65 @@ +package sysin + +import ( + "context" + "hotgo/internal/model/input/form" + + "github.com/gogf/gf/v2/errors/gerror" + "github.com/gogf/gf/v2/os/gtime" +) + +type ImKafkaAuditListInp struct { + form.PageReq + TenantCode string `json:"tenantCode" dc:"租户编码"` + EventId string `json:"eventId" dc:"事件ID"` + EventType string `json:"eventType" dc:"事件类型"` + BizType string `json:"bizType" dc:"业务类型"` + BizId string `json:"bizId" dc:"业务ID"` + ActorType string `json:"actorType" dc:"操作者类型"` + ActorId string `json:"actorId" dc:"操作者ID"` + Action string `json:"action" dc:"动作"` + Resource string `json:"resource" dc:"资源"` + ResourceId string `json:"resourceId" dc:"资源ID"` + Result string `json:"result" dc:"结果"` + CreatedAt []*gtime.Time `json:"createdAt" dc:"接收时间"` +} + +func (in *ImKafkaAuditListInp) Filter(ctx context.Context) (err error) { + return nil +} + +type ImKafkaAuditListModel struct { + Id int64 `json:"id" dc:"ID"` + EventId string `json:"eventId" dc:"事件ID"` + TenantCode string `json:"tenantCode" dc:"租户编码"` + EventType string `json:"eventType" dc:"事件类型"` + BizType string `json:"bizType" dc:"业务类型"` + BizId string `json:"bizId" dc:"业务ID"` + ActorType string `json:"actorType" dc:"操作者类型"` + ActorId string `json:"actorId" dc:"操作者ID"` + Action string `json:"action" dc:"动作"` + Resource string `json:"resource" dc:"资源"` + ResourceId string `json:"resourceId" dc:"资源ID"` + Result string `json:"result" dc:"结果"` + Reason string `json:"reason" dc:"原因"` + Payload string `json:"payload" dc:"Payload"` + Envelope string `json:"envelope" dc:"Envelope"` + OccurredAt *gtime.Time `json:"occurredAt" dc:"发生时间"` + ReceivedAt *gtime.Time `json:"receivedAt" dc:"接收时间"` +} + +type ImKafkaAuditViewInp struct { + Id int64 `json:"id" dc:"ID"` + EventId string `json:"eventId" dc:"事件ID"` +} + +func (in *ImKafkaAuditViewInp) Filter(ctx context.Context) (err error) { + if in.Id <= 0 && in.EventId == "" { + return gerror.New("请选择IM Kafka审计事件") + } + return nil +} + +type ImKafkaAuditViewModel struct { + ImKafkaAuditListModel +} diff --git a/server/internal/model/input/sysin/im_kafka_command.go b/server/internal/model/input/sysin/im_kafka_command.go new file mode 100644 index 0000000..5b4180a --- /dev/null +++ b/server/internal/model/input/sysin/im_kafka_command.go @@ -0,0 +1,126 @@ +// Package sysin +// @Link https://github.com/bufanyun/hotgo +// @Copyright Copyright (c) 2026 HotGo +// @Author Ms <133814250@qq.com> +// @License https://github.com/bufanyun/hotgo/blob/master/LICENSE +package sysin + +import ( + "context" + "hotgo/internal/model/input/form" + "strings" + + "github.com/gogf/gf/v2/errors/gerror" + "github.com/gogf/gf/v2/os/gtime" +) + +type ImKafkaCommandListInp struct { + form.PageReq + TenantId int64 `json:"tenantId" dc:"租户ID"` + CommandId string `json:"commandId" dc:"命令ID"` + EventId string `json:"eventId" dc:"事件ID"` + EventType string `json:"eventType" dc:"事件类型"` + BizType string `json:"bizType" dc:"业务类型"` + BizId string `json:"bizId" dc:"业务ID"` + Status string `json:"status" dc:"状态"` + CreatedAt []*gtime.Time `json:"createdAt" dc:"创建时间"` +} + +func (in *ImKafkaCommandListInp) Filter(ctx context.Context) (err error) { + return nil +} + +type ImKafkaCommandListModel struct { + Id int64 `json:"id" dc:"ID"` + CommandId string `json:"commandId" dc:"命令ID"` + TenantId int64 `json:"tenantId" dc:"租户ID"` + TenantCode string `json:"tenantCode" dc:"租户编码"` + EventId string `json:"eventId" dc:"事件ID"` + EventType string `json:"eventType" dc:"事件类型"` + BizType string `json:"bizType" dc:"业务类型"` + BizId string `json:"bizId" dc:"业务ID"` + IdempotencyKey string `json:"idempotencyKey" dc:"幂等键"` + Status string `json:"status" dc:"状态"` + Reason string `json:"reason" dc:"错误码"` + Message string `json:"message" dc:"消息"` + SentAt *gtime.Time `json:"sentAt" dc:"投递时间"` + CompletedAt *gtime.Time `json:"completedAt" dc:"完成时间"` + CreatedBy int64 `json:"createdBy" dc:"创建人"` + CreatedAt *gtime.Time `json:"createdAt" dc:"创建时间"` + UpdatedAt *gtime.Time `json:"updatedAt" dc:"更新时间"` +} + +type ImKafkaCommandViewInp struct { + Id int64 `json:"id" dc:"ID"` + CommandId string `json:"commandId" dc:"命令ID"` +} + +func (in *ImKafkaCommandViewInp) Filter(ctx context.Context) (err error) { + if in.Id <= 0 && in.CommandId == "" { + return gerror.New("请选择IM后台命令") + } + return nil +} + +type ImKafkaCommandViewModel struct { + ImKafkaCommandListModel + Payload string `json:"payload" dc:"Payload"` + Envelope string `json:"envelope" dc:"Envelope"` + Result string `json:"result" dc:"Result"` +} + +type ImKafkaCacheInvalidateInp struct { + TenantCode string `json:"tenantCode" dc:"租户编码"` + Namespace string `json:"namespace" dc:"缓存命名空间"` + Keys []string `json:"keys" dc:"缓存Key"` + Reason string `json:"reason" dc:"原因"` +} + +func (in *ImKafkaCacheInvalidateInp) Filter(ctx context.Context) (err error) { + in.TenantCode = strings.TrimSpace(in.TenantCode) + in.Namespace = strings.TrimSpace(in.Namespace) + if in.TenantCode == "" { + return gerror.New("请选择租户") + } + if in.Namespace == "" { + return gerror.New("请输入缓存命名空间") + } + keys := make([]string, 0, len(in.Keys)) + for _, key := range in.Keys { + if key = strings.TrimSpace(key); key != "" { + keys = append(keys, key) + } + } + in.Keys = keys + return nil +} + +type ImKafkaEventModel struct { + EventId string `json:"eventId" dc:"Kafka事件ID"` +} + +type ImKafkaWorkbenchEventInp struct { + TenantCode string `json:"tenantCode" dc:"租户编码"` + EventType string `json:"eventType" dc:"事件类型"` + BizId string `json:"bizId" dc:"业务ID"` + Payload map[string]any `json:"payload" dc:"Payload"` +} + +func (in *ImKafkaWorkbenchEventInp) Filter(ctx context.Context) (err error) { + in.TenantCode = strings.TrimSpace(in.TenantCode) + in.EventType = strings.TrimSpace(in.EventType) + in.BizId = strings.TrimSpace(in.BizId) + if in.TenantCode == "" { + return gerror.New("请选择租户") + } + if in.EventType == "" { + in.EventType = "workbench.connectivity.test" + } + if in.BizId == "" { + in.BizId = "admin_connectivity_test" + } + if in.Payload == nil { + in.Payload = map[string]any{} + } + return nil +} diff --git a/server/internal/model/input/sysin/im_user.go b/server/internal/model/input/sysin/im_user.go index 9cac3b6..463d25b 100644 --- a/server/internal/model/input/sysin/im_user.go +++ b/server/internal/model/input/sysin/im_user.go @@ -78,3 +78,45 @@ type ImUserViewModel struct { AccessJson string `json:"accessJson" dc:"访问权限"` ProfileVersion int `json:"profileVersion" dc:"资料版本"` } + +type ImUserCommandModel struct { + CommandID string `json:"commandId" dc:"命令ID"` + EventID string `json:"eventId" dc:"事件ID"` + Status string `json:"status" dc:"状态"` + TenantID int64 `json:"tenantId" dc:"租户ID"` + TenantCode string `json:"tenantCode" dc:"租户编码"` + EventType string `json:"eventType" dc:"事件类型"` + IdempotencyKey string `json:"idempotencyKey" dc:"幂等键"` +} + +type ImUserGovernanceInp struct { + TenantId int64 `json:"tenantId" dc:"租户ID"` + UserId int64 `json:"userId" dc:"目标用户ID"` + ActorUserId int64 `json:"actorUserId" dc:"操作IM用户ID"` + DeviceId string `json:"deviceId" dc:"设备ID"` + Reason string `json:"reason" dc:"原因"` + Hard bool `json:"hard" dc:"硬删除"` +} + +func (in *ImUserGovernanceInp) Filter(ctx context.Context) (err error) { + if in.TenantId <= 0 { + return gerror.New("请选择租户") + } + if in.UserId <= 0 { + return gerror.New("用户ID不能为空") + } + if in.ActorUserId <= 0 { + return gerror.New("操作IM用户ID不能为空") + } + return nil +} + +func (in *ImUserGovernanceInp) FilterDevice(ctx context.Context) (err error) { + if err = in.Filter(ctx); err != nil { + return err + } + if in.DeviceId == "" { + return gerror.New("设备ID不能为空") + } + return nil +} diff --git a/server/internal/queues/im_admin_command_result.go b/server/internal/queues/im_admin_command_result.go new file mode 100644 index 0000000..ed58590 --- /dev/null +++ b/server/internal/queues/im_admin_command_result.go @@ -0,0 +1,52 @@ +// Package queues +// @Link https://github.com/bufanyun/hotgo +// @Copyright Copyright (c) 2026 HotGo +// @Author Ms <133814250@qq.com> +// @License https://github.com/bufanyun/hotgo/blob/master/LICENSE +package queues + +import ( + "context" + "encoding/json" + "hotgo/internal/consts" + "hotgo/internal/library/imkafka" + "hotgo/internal/library/queue" +) + +func init() { + queue.RegisterConsumer(ImAdminCommandResult) +} + +var ImAdminCommandResult = &qImAdminCommandResult{} + +type qImAdminCommandResult struct{} + +func (q *qImAdminCommandResult) GetTopic() string { + return consts.QueueImAdminCommandResultTopic +} + +func (q *qImAdminCommandResult) Handle(ctx context.Context, mqMsg queue.MqMsg) (err error) { + var envelope imkafka.Envelope + if err = json.Unmarshal(mqMsg.Body, &envelope); err == nil && envelope.EventID != "" && len(envelope.Payload) > 0 { + var result imkafka.CommandResult + if err = json.Unmarshal(envelope.Payload, &result); err != nil { + return err + } + if result.CommandEventID == "" { + result.CommandEventID = envelope.ParentEventID + } + if result.TenantCode == "" { + result.TenantCode = envelope.TenantCode + } + if result.EventID == "" { + result.EventID = envelope.EventID + } + return imkafka.ApplyCommandResult(ctx, result) + } + + var result imkafka.CommandResult + if err = json.Unmarshal(mqMsg.Body, &result); err != nil { + return err + } + return imkafka.ApplyCommandResult(ctx, result) +} diff --git a/server/internal/queues/im_audit_event.go b/server/internal/queues/im_audit_event.go new file mode 100644 index 0000000..8d122ff --- /dev/null +++ b/server/internal/queues/im_audit_event.go @@ -0,0 +1,54 @@ +package queues + +import ( + "context" + "encoding/json" + "hotgo/internal/consts" + "hotgo/internal/library/imkafka" + "hotgo/internal/library/queue" + + "github.com/gogf/gf/v2/frame/g" + "github.com/gogf/gf/v2/os/gtime" +) + +func init() { + queue.RegisterConsumer(ImAuditEvent) +} + +var ImAuditEvent = &qImAuditEvent{} + +type qImAuditEvent struct{} + +func (q *qImAuditEvent) GetTopic() string { + return consts.QueueImAuditEventTopic +} + +func (q *qImAuditEvent) Handle(ctx context.Context, mqMsg queue.MqMsg) (err error) { + var envelope imkafka.Envelope + if err = json.Unmarshal(mqMsg.Body, &envelope); err != nil { + return err + } + payload := map[string]any{} + if len(envelope.Payload) > 0 { + _ = json.Unmarshal(envelope.Payload, &payload) + } + _, err = g.DB().Model("im_audit_events").Safe().Ctx(ctx).Data(g.Map{ + "event_id": envelope.EventID, + "tenant_code": envelope.TenantCode, + "event_type": envelope.EventType, + "biz_type": envelope.BizType, + "biz_id": envelope.BizID, + "actor_type": firstString(payload["actor_type"]), + "actor_id": firstString(payload["actor_id"]), + "action": firstString(payload["action"]), + "resource": firstString(payload["resource"]), + "resource_id": firstString(payload["resource_id"]), + "result": firstString(payload["result"]), + "reason": firstString(payload["reason"]), + "payload": string(envelope.Payload), + "envelope": string(mqMsg.Body), + "occurred_at": envelope.OccurredAt, + "received_at": gtime.Now(), + }).InsertIgnore() + return err +} diff --git a/server/internal/queues/im_dead_letter.go b/server/internal/queues/im_dead_letter.go new file mode 100644 index 0000000..4651d69 --- /dev/null +++ b/server/internal/queues/im_dead_letter.go @@ -0,0 +1,74 @@ +package queues + +import ( + "context" + "encoding/json" + "hotgo/internal/consts" + "hotgo/internal/library/imkafka" + "hotgo/internal/library/queue" + + "github.com/gogf/gf/v2/frame/g" + "github.com/gogf/gf/v2/os/gtime" +) + +func init() { + queue.RegisterConsumer(ImDeadLetter) +} + +var ImDeadLetter = &qImDeadLetter{} + +type qImDeadLetter struct{} + +func (q *qImDeadLetter) GetTopic() string { + return consts.QueueImDeadLetterTopic +} + +func (q *qImDeadLetter) Handle(ctx context.Context, mqMsg queue.MqMsg) (err error) { + var envelope imkafka.Envelope + if err = json.Unmarshal(mqMsg.Body, &envelope); err != nil { + return err + } + payload := map[string]any{} + if len(envelope.Payload) > 0 { + _ = json.Unmarshal(envelope.Payload, &payload) + } + _, err = g.DB().Model("im_kafka_dead_letter_events").Safe().Ctx(ctx).Data(g.Map{ + "event_id": envelope.EventID, + "parent_event_id": envelope.ParentEventID, + "tenant_code": envelope.TenantCode, + "event_type": envelope.EventType, + "biz_type": envelope.BizType, + "biz_id": envelope.BizID, + "source_service": envelope.SourceService, + "target_service": envelope.TargetService, + "dead_id": firstString(payload["dead_id"], payload["deadId"], payload["DeadID"]), + "reason": firstString(payload["reason"], payload["Reason"], payload["last_reason"], payload["lastReason"], payload["LastReason"]), + "message": firstString(payload["message"], payload["Message"], payload["last_error"], payload["lastError"], payload["LastError"]), + "payload": string(envelope.Payload), + "envelope": string(mqMsg.Body), + "received_at": gtime.Now(), + }).InsertIgnore() + return err +} + +func stringValue(v any) string { + if v == nil { + return "" + } + switch value := v.(type) { + case string: + return value + default: + b, _ := json.Marshal(value) + return string(b) + } +} + +func firstString(values ...any) string { + for _, v := range values { + if text := stringValue(v); text != "" && text != "null" { + return text + } + } + return "" +} diff --git a/server/internal/router/genrouter/im_dead_letter.go b/server/internal/router/genrouter/im_dead_letter.go new file mode 100644 index 0000000..d08ee20 --- /dev/null +++ b/server/internal/router/genrouter/im_dead_letter.go @@ -0,0 +1,7 @@ +package genrouter + +import "hotgo/internal/controller/admin/sys" + +func init() { + LoginRequiredRouter = append(LoginRequiredRouter, sys.ImDeadLetter) // IM Kafka死信 +} diff --git a/server/internal/router/genrouter/im_kafka_audit.go b/server/internal/router/genrouter/im_kafka_audit.go new file mode 100644 index 0000000..ba6eeec --- /dev/null +++ b/server/internal/router/genrouter/im_kafka_audit.go @@ -0,0 +1,7 @@ +package genrouter + +import "hotgo/internal/controller/admin/sys" + +func init() { + LoginRequiredRouter = append(LoginRequiredRouter, sys.ImKafkaAudit) // IM Kafka审计事件 +} diff --git a/server/internal/router/genrouter/im_kafka_command.go b/server/internal/router/genrouter/im_kafka_command.go new file mode 100644 index 0000000..4bfcb6e --- /dev/null +++ b/server/internal/router/genrouter/im_kafka_command.go @@ -0,0 +1,7 @@ +package genrouter + +import "hotgo/internal/controller/admin/sys" + +func init() { + LoginRequiredRouter = append(LoginRequiredRouter, sys.ImKafkaCommand) // IM后台命令 +} diff --git a/server/internal/router/internal.go b/server/internal/router/internal.go new file mode 100644 index 0000000..1fe2908 --- /dev/null +++ b/server/internal/router/internal.go @@ -0,0 +1,18 @@ +package router + +import ( + "context" + "hotgo/internal/controller/internalapi/imconfig" + "hotgo/internal/library/iminternal" + + "github.com/gogf/gf/v2/net/ghttp" +) + +func Internal(ctx context.Context, group *ghttp.RouterGroup) { + group.Group("/", func(group *ghttp.RouterGroup) { + group.Middleware(iminternal.Middleware) + group.Bind( + imconfig.Config, + ) + }) +} diff --git a/server/internal/service/sys.go b/server/internal/service/sys.go index 2c7b9d4..2a002ff 100644 --- a/server/internal/service/sys.go +++ b/server/internal/service/sys.go @@ -210,6 +210,22 @@ type ( List(ctx context.Context, in *sysin.ImUserListInp) (list []*sysin.ImUserListModel, totalCount int, err error) // View 获取IM用户指定信息 View(ctx context.Context, in *sysin.ImUserViewInp) (res *sysin.ImUserViewModel, err error) + // Ban 通过Kafka封禁IM用户 + Ban(ctx context.Context, in *sysin.ImUserGovernanceInp) (res *sysin.ImUserCommandModel, err error) + // Unban 通过Kafka解封IM用户 + Unban(ctx context.Context, in *sysin.ImUserGovernanceInp) (res *sysin.ImUserCommandModel, err error) + // Freeze 通过Kafka冻结IM用户 + Freeze(ctx context.Context, in *sysin.ImUserGovernanceInp) (res *sysin.ImUserCommandModel, err error) + // Unfreeze 通过Kafka解冻IM用户 + Unfreeze(ctx context.Context, in *sysin.ImUserGovernanceInp) (res *sysin.ImUserCommandModel, err error) + // DeviceKick 通过Kafka踢出IM用户设备 + DeviceKick(ctx context.Context, in *sysin.ImUserGovernanceInp) (res *sysin.ImUserCommandModel, err error) + // LogoutAll 通过Kafka踢出IM用户全部在线会话 + LogoutAll(ctx context.Context, in *sysin.ImUserGovernanceInp) (res *sysin.ImUserCommandModel, err error) + // AccountCancel 通过Kafka注销IM用户账号 + AccountCancel(ctx context.Context, in *sysin.ImUserGovernanceInp) (res *sysin.ImUserCommandModel, err error) + // AccountDelete 通过Kafka删除IM用户账号 + AccountDelete(ctx context.Context, in *sysin.ImUserGovernanceInp) (res *sysin.ImUserCommandModel, err error) } ISysImGroup interface { // List 获取IM群组列表 @@ -230,6 +246,22 @@ type ( MuteList(ctx context.Context, in *sysin.ImGroupMuteListInp) (list []*sysin.ImGroupMuteListModel, totalCount int, err error) // AdminLogList 获取IM群管理日志列表 AdminLogList(ctx context.Context, in *sysin.ImGroupAdminLogListInp) (list []*sysin.ImGroupAdminLogListModel, totalCount int, err error) + // ProfileUpdate 通过Kafka修改IM群资料 + ProfileUpdate(ctx context.Context, in *sysin.ImGroupProfileUpdateInp) (res *sysin.ImGroupCommandModel, err error) + // GovernanceUpdate 通过Kafka修改IM群治理配置 + GovernanceUpdate(ctx context.Context, in *sysin.ImGroupGovernanceUpdateInp) (res *sysin.ImGroupCommandModel, err error) + // AdminSet 通过Kafka设置IM群管理员 + AdminSet(ctx context.Context, in *sysin.ImGroupAdminSetInp) (res *sysin.ImGroupCommandModel, err error) + // AdminRemove 通过Kafka删除IM群管理员 + AdminRemove(ctx context.Context, in *sysin.ImGroupAdminRemoveInp) (res *sysin.ImGroupCommandModel, err error) + // MemberMute 通过Kafka禁言IM群成员 + MemberMute(ctx context.Context, in *sysin.ImGroupMemberMuteInp) (res *sysin.ImGroupCommandModel, err error) + // MemberUnmute 通过Kafka解除IM群成员禁言 + MemberUnmute(ctx context.Context, in *sysin.ImGroupMemberUnmuteInp) (res *sysin.ImGroupCommandModel, err error) + // MemberRemove 通过Kafka移出IM群成员 + MemberRemove(ctx context.Context, in *sysin.ImGroupMemberRemoveInp) (res *sysin.ImGroupCommandModel, err error) + // OwnerTransfer 通过Kafka转让IM群主 + OwnerTransfer(ctx context.Context, in *sysin.ImGroupOwnerTransferInp) (res *sysin.ImGroupCommandModel, err error) } ISysImRealname interface { // List 获取IM实名审核列表 @@ -253,6 +285,34 @@ type ( // View 获取IM审计日志指定信息 View(ctx context.Context, in *sysin.ImAuditLogViewInp) (res *sysin.ImAuditLogViewModel, err error) } + ISysImKafkaAudit interface { + // List 获取IM Kafka审计事件列表 + List(ctx context.Context, in *sysin.ImKafkaAuditListInp) (list []*sysin.ImKafkaAuditListModel, totalCount int, err error) + // View 获取IM Kafka审计事件详情 + View(ctx context.Context, in *sysin.ImKafkaAuditViewInp) (res *sysin.ImKafkaAuditViewModel, err error) + } + ISysImKafkaCommand interface { + // List 获取IM后台命令列表 + List(ctx context.Context, in *sysin.ImKafkaCommandListInp) (list []*sysin.ImKafkaCommandListModel, totalCount int, err error) + // View 获取IM后台命令详情 + View(ctx context.Context, in *sysin.ImKafkaCommandViewInp) (res *sysin.ImKafkaCommandViewModel, err error) + // CacheInvalidate 发送IM缓存失效测试事件 + CacheInvalidate(ctx context.Context, in *sysin.ImKafkaCacheInvalidateInp) (res *sysin.ImKafkaEventModel, err error) + // WorkbenchEvent 发送IM工作台测试事件 + WorkbenchEvent(ctx context.Context, in *sysin.ImKafkaWorkbenchEventInp) (res *sysin.ImKafkaEventModel, err error) + } + ISysImDeadLetter interface { + // List 获取IM Kafka死信列表 + List(ctx context.Context, in *sysin.ImDeadLetterListInp) (res *sysin.ImDeadLetterPageModel, err error) + // View 获取IM Kafka死信详情 + View(ctx context.Context, in *sysin.ImDeadLetterViewInp) (res *sysin.ImDeadLetterViewModel, err error) + // Replay 重放IM Kafka死信 + Replay(ctx context.Context, in *sysin.ImDeadLetterActionInp) (res *sysin.ImGroupCommandModel, err error) + // Ignore 忽略IM Kafka死信 + Ignore(ctx context.Context, in *sysin.ImDeadLetterActionInp) (res *sysin.ImGroupCommandModel, err error) + // Resolve 标记IM Kafka死信已处理 + Resolve(ctx context.Context, in *sysin.ImDeadLetterActionInp) (res *sysin.ImGroupCommandModel, err error) + } ISysDictData interface { // Delete 删除 Delete(ctx context.Context, in *sysin.DictDataDeleteInp) error @@ -504,6 +564,9 @@ var ( localSysImRealname ISysImRealname localSysImWorkOrder ISysImWorkOrder localSysImAuditLog ISysImAuditLog + localSysImKafkaAudit ISysImKafkaAudit + localSysImKafkaCommand ISysImKafkaCommand + localSysImDeadLetter ISysImDeadLetter localSysDictData ISysDictData localSysDictType ISysDictType localSysEmsLog ISysEmsLog @@ -695,6 +758,39 @@ func RegisterSysImAuditLog(i ISysImAuditLog) { localSysImAuditLog = i } +func SysImKafkaAudit() ISysImKafkaAudit { + if localSysImKafkaAudit == nil { + panic("implement not found for interface ISysImKafkaAudit, forgot register?") + } + return localSysImKafkaAudit +} + +func RegisterSysImKafkaAudit(i ISysImKafkaAudit) { + localSysImKafkaAudit = i +} + +func SysImKafkaCommand() ISysImKafkaCommand { + if localSysImKafkaCommand == nil { + panic("implement not found for interface ISysImKafkaCommand, forgot register?") + } + return localSysImKafkaCommand +} + +func RegisterSysImKafkaCommand(i ISysImKafkaCommand) { + localSysImKafkaCommand = i +} + +func SysImDeadLetter() ISysImDeadLetter { + if localSysImDeadLetter == nil { + panic("implement not found for interface ISysImDeadLetter, forgot register?") + } + return localSysImDeadLetter +} + +func RegisterSysImDeadLetter(i ISysImDeadLetter) { + localSysImDeadLetter = i +} + func SysDictData() ISysDictData { if localSysDictData == nil { panic("implement not found for interface ISysDictData, forgot register?") diff --git a/server/manifest/config/config.example.yaml b/server/manifest/config/config.example.yaml index 9a493fd..076b3f4 100644 --- a/server/manifest/config/config.example.yaml +++ b/server/manifest/config/config.example.yaml @@ -212,10 +212,16 @@ queue: retry: 0 # 重试次数 logLevel: "info" # rocketmq日志输出级别,可选:all|close|debug|info|warn|error|fatal kafka: - address: "127.0.0.1:9092" # kafka地址+端口 + address: ["127.0.0.1:9092"] # kafka地址+端口,支持多个 version: "2.0.0.0" # kafka专属配置,默认2.0.0.0 randClient: true # 开启随机生成clientID,可以实现启动多实例同时一起消费相同topic,加速消费能力的特性,默认为true multiConsumer: true # 是否支持创建多个消费者 + clientId: "hotgo-admin" # Kafka客户端ID + userName: "${KAFKA_USERNAME}" # SASL用户名,未启用SASL可留空 + password: "${KAFKA_PASSWORD}" # SASL密码,未启用SASL可留空 + saslEnabled: false # 是否开启SASL + saslMechanism: "SCRAM-SHA-512" # SASL机制,可选:PLAIN|SCRAM-SHA-256|SCRAM-SHA-512 + tlsEnabled: false # 是否开启TLS # Redis. 配置参考:https://goframe.org/pages/viewpage.action?pageId=1114217 @@ -310,3 +316,13 @@ imAdminApi: serviceId: "hotgo-admin" serviceSecret: "${IM_ADMIN_API_SECRET}" timeout: "5s" + +imInternalApi: + baseUrl: "http://127.0.0.1:6060/v1" + serviceName: "hotgo-admin" + serviceSecret: "${IM_INTERNAL_API_SECRET}" + +imConfigApi: + serviceName: "im-server" + serviceSecret: "${IM_CONFIG_API_SECRET}" + timeWindow: "300s" diff --git a/server/storage/data/generate/im_dead_letter_menu.sql b/server/storage/data/generate/im_dead_letter_menu.sql new file mode 100644 index 0000000..af0d8dd --- /dev/null +++ b/server/storage/data/generate/im_dead_letter_menu.sql @@ -0,0 +1,35 @@ +SET NAMES utf8mb4; + +SET @now = NOW(); + +SELECT @imRootId := `id` FROM `hg_admin_menu` WHERE `name` = 'imManager' LIMIT 1; + +INSERT INTO `hg_admin_menu` (`id`, `pid`, `title`, `name`, `path`, `icon`, `type`, `redirect`, `permissions`, `permission_name`, `component`, `always_show`, `active_menu`, `is_root`, `is_frame`, `frame_src`, `keep_alive`, `hidden`, `affix`, `level`, `tree`, `sort`, `remark`, `status`, `created_at`, `updated_at`) +SELECT NULL, '0', 'IM管理', 'imManager', '/im', '', '1', '/im/tenant/index', '', '', 'LAYOUT', '1', '', '1', '0', '', '0', '0', '0', '1', '', '200', '', '1', @now, @now +WHERE @imRootId IS NULL; + +SELECT @imRootId := `id` FROM `hg_admin_menu` WHERE `name` = 'imManager' LIMIT 1; + +SELECT @deadLetterId := `id` FROM `hg_admin_menu` WHERE `name` = 'imDeadLetterIndex' LIMIT 1; + +INSERT INTO `hg_admin_menu` (`id`, `pid`, `title`, `name`, `path`, `icon`, `type`, `redirect`, `permissions`, `permission_name`, `component`, `always_show`, `active_menu`, `is_root`, `is_frame`, `frame_src`, `keep_alive`, `hidden`, `affix`, `level`, `tree`, `sort`, `remark`, `status`, `created_at`, `updated_at`) +SELECT NULL, @imRootId, 'Kafka死信', 'imDeadLetterIndex', 'dead-letter', '', '2', '', '/imDeadLetter/list', '', '/imDeadLetter/index', '1', 'imDeadLetterIndex', '0', '0', '', '0', '0', '0', '2', CONCAT('tr_', @imRootId, ' '), '91', '', '1', @now, @now +WHERE @deadLetterId IS NULL; + +SELECT @deadLetterId := `id` FROM `hg_admin_menu` WHERE `name` = 'imDeadLetterIndex' LIMIT 1; + +INSERT INTO `hg_admin_menu` (`id`, `pid`, `title`, `name`, `path`, `icon`, `type`, `redirect`, `permissions`, `permission_name`, `component`, `always_show`, `active_menu`, `is_root`, `is_frame`, `frame_src`, `keep_alive`, `hidden`, `affix`, `level`, `tree`, `sort`, `remark`, `status`, `created_at`, `updated_at`) +SELECT NULL, @deadLetterId, '死信详情', 'imDeadLetterView', '', '', '3', '', '/imDeadLetter/view', '', '', '1', '', '0', '0', '', '0', '1', '0', '3', CONCAT('tr_', @imRootId, ' tr_', @deadLetterId, ' '), '10', '', '1', @now, @now +WHERE NOT EXISTS (SELECT 1 FROM `hg_admin_menu` WHERE `name` = 'imDeadLetterView'); + +INSERT INTO `hg_admin_menu` (`id`, `pid`, `title`, `name`, `path`, `icon`, `type`, `redirect`, `permissions`, `permission_name`, `component`, `always_show`, `active_menu`, `is_root`, `is_frame`, `frame_src`, `keep_alive`, `hidden`, `affix`, `level`, `tree`, `sort`, `remark`, `status`, `created_at`, `updated_at`) +SELECT NULL, @deadLetterId, '重放死信', 'imDeadLetterReplay', '', '', '3', '', '/imDeadLetter/replay', '', '', '1', '', '0', '0', '', '0', '1', '0', '3', CONCAT('tr_', @imRootId, ' tr_', @deadLetterId, ' '), '20', '', '1', @now, @now +WHERE NOT EXISTS (SELECT 1 FROM `hg_admin_menu` WHERE `name` = 'imDeadLetterReplay'); + +INSERT INTO `hg_admin_menu` (`id`, `pid`, `title`, `name`, `path`, `icon`, `type`, `redirect`, `permissions`, `permission_name`, `component`, `always_show`, `active_menu`, `is_root`, `is_frame`, `frame_src`, `keep_alive`, `hidden`, `affix`, `level`, `tree`, `sort`, `remark`, `status`, `created_at`, `updated_at`) +SELECT NULL, @deadLetterId, '忽略死信', 'imDeadLetterIgnore', '', '', '3', '', '/imDeadLetter/ignore', '', '', '1', '', '0', '0', '', '0', '1', '0', '3', CONCAT('tr_', @imRootId, ' tr_', @deadLetterId, ' '), '30', '', '1', @now, @now +WHERE NOT EXISTS (SELECT 1 FROM `hg_admin_menu` WHERE `name` = 'imDeadLetterIgnore'); + +INSERT INTO `hg_admin_menu` (`id`, `pid`, `title`, `name`, `path`, `icon`, `type`, `redirect`, `permissions`, `permission_name`, `component`, `always_show`, `active_menu`, `is_root`, `is_frame`, `frame_src`, `keep_alive`, `hidden`, `affix`, `level`, `tree`, `sort`, `remark`, `status`, `created_at`, `updated_at`) +SELECT NULL, @deadLetterId, '标记已处理', 'imDeadLetterResolve', '', '', '3', '', '/imDeadLetter/resolve', '', '', '1', '', '0', '0', '', '0', '1', '0', '3', CONCAT('tr_', @imRootId, ' tr_', @deadLetterId, ' '), '40', '', '1', @now, @now +WHERE NOT EXISTS (SELECT 1 FROM `hg_admin_menu` WHERE `name` = 'imDeadLetterResolve'); diff --git a/server/storage/data/generate/im_group_menu.sql b/server/storage/data/generate/im_group_menu.sql index 74af8da..5424a19 100644 --- a/server/storage/data/generate/im_group_menu.sql +++ b/server/storage/data/generate/im_group_menu.sql @@ -57,3 +57,35 @@ WHERE NOT EXISTS (SELECT 1 FROM `hg_admin_menu` WHERE `name` = 'imGroupMuteList' INSERT INTO `hg_admin_menu` (`id`, `pid`, `title`, `name`, `path`, `icon`, `type`, `redirect`, `permissions`, `permission_name`, `component`, `always_show`, `active_menu`, `is_root`, `is_frame`, `frame_src`, `keep_alive`, `hidden`, `affix`, `level`, `tree`, `sort`, `remark`, `status`, `created_at`, `updated_at`) SELECT NULL, @groupListId, '群管理日志', 'imGroupAdminLogList', '', '', '3', '', '/imGroup/adminLogList', '', '', '1', '', '0', '0', '', '0', '1', '0', '4', CONCAT('tr_', @imRootId, ' tr_', @groupDirId, ' tr_', @groupListId, ' '), '80', '', '1', @now, @now WHERE NOT EXISTS (SELECT 1 FROM `hg_admin_menu` WHERE `name` = 'imGroupAdminLogList'); + +INSERT INTO `hg_admin_menu` (`id`, `pid`, `title`, `name`, `path`, `icon`, `type`, `redirect`, `permissions`, `permission_name`, `component`, `always_show`, `active_menu`, `is_root`, `is_frame`, `frame_src`, `keep_alive`, `hidden`, `affix`, `level`, `tree`, `sort`, `remark`, `status`, `created_at`, `updated_at`) +SELECT NULL, @groupListId, '修改群资料', 'imGroupProfileUpdate', '', '', '3', '', '/imGroup/profileUpdate', '', '', '1', '', '0', '0', '', '0', '1', '0', '4', CONCAT('tr_', @imRootId, ' tr_', @groupDirId, ' tr_', @groupListId, ' '), '90', '', '1', @now, @now +WHERE NOT EXISTS (SELECT 1 FROM `hg_admin_menu` WHERE `name` = 'imGroupProfileUpdate'); + +INSERT INTO `hg_admin_menu` (`id`, `pid`, `title`, `name`, `path`, `icon`, `type`, `redirect`, `permissions`, `permission_name`, `component`, `always_show`, `active_menu`, `is_root`, `is_frame`, `frame_src`, `keep_alive`, `hidden`, `affix`, `level`, `tree`, `sort`, `remark`, `status`, `created_at`, `updated_at`) +SELECT NULL, @groupListId, '修改群治理配置', 'imGroupGovernanceUpdate', '', '', '3', '', '/imGroup/governanceUpdate', '', '', '1', '', '0', '0', '', '0', '1', '0', '4', CONCAT('tr_', @imRootId, ' tr_', @groupDirId, ' tr_', @groupListId, ' '), '100', '', '1', @now, @now +WHERE NOT EXISTS (SELECT 1 FROM `hg_admin_menu` WHERE `name` = 'imGroupGovernanceUpdate'); + +INSERT INTO `hg_admin_menu` (`id`, `pid`, `title`, `name`, `path`, `icon`, `type`, `redirect`, `permissions`, `permission_name`, `component`, `always_show`, `active_menu`, `is_root`, `is_frame`, `frame_src`, `keep_alive`, `hidden`, `affix`, `level`, `tree`, `sort`, `remark`, `status`, `created_at`, `updated_at`) +SELECT NULL, @groupListId, '设置群管理员', 'imGroupAdminSet', '', '', '3', '', '/imGroup/adminSet', '', '', '1', '', '0', '0', '', '0', '1', '0', '4', CONCAT('tr_', @imRootId, ' tr_', @groupDirId, ' tr_', @groupListId, ' '), '110', '', '1', @now, @now +WHERE NOT EXISTS (SELECT 1 FROM `hg_admin_menu` WHERE `name` = 'imGroupAdminSet'); + +INSERT INTO `hg_admin_menu` (`id`, `pid`, `title`, `name`, `path`, `icon`, `type`, `redirect`, `permissions`, `permission_name`, `component`, `always_show`, `active_menu`, `is_root`, `is_frame`, `frame_src`, `keep_alive`, `hidden`, `affix`, `level`, `tree`, `sort`, `remark`, `status`, `created_at`, `updated_at`) +SELECT NULL, @groupListId, '删除群管理员', 'imGroupAdminRemove', '', '', '3', '', '/imGroup/adminRemove', '', '', '1', '', '0', '0', '', '0', '1', '0', '4', CONCAT('tr_', @imRootId, ' tr_', @groupDirId, ' tr_', @groupListId, ' '), '120', '', '1', @now, @now +WHERE NOT EXISTS (SELECT 1 FROM `hg_admin_menu` WHERE `name` = 'imGroupAdminRemove'); + +INSERT INTO `hg_admin_menu` (`id`, `pid`, `title`, `name`, `path`, `icon`, `type`, `redirect`, `permissions`, `permission_name`, `component`, `always_show`, `active_menu`, `is_root`, `is_frame`, `frame_src`, `keep_alive`, `hidden`, `affix`, `level`, `tree`, `sort`, `remark`, `status`, `created_at`, `updated_at`) +SELECT NULL, @groupListId, '禁言群成员', 'imGroupMemberMute', '', '', '3', '', '/imGroup/memberMute', '', '', '1', '', '0', '0', '', '0', '1', '0', '4', CONCAT('tr_', @imRootId, ' tr_', @groupDirId, ' tr_', @groupListId, ' '), '130', '', '1', @now, @now +WHERE NOT EXISTS (SELECT 1 FROM `hg_admin_menu` WHERE `name` = 'imGroupMemberMute'); + +INSERT INTO `hg_admin_menu` (`id`, `pid`, `title`, `name`, `path`, `icon`, `type`, `redirect`, `permissions`, `permission_name`, `component`, `always_show`, `active_menu`, `is_root`, `is_frame`, `frame_src`, `keep_alive`, `hidden`, `affix`, `level`, `tree`, `sort`, `remark`, `status`, `created_at`, `updated_at`) +SELECT NULL, @groupListId, '解除群成员禁言', 'imGroupMemberUnmute', '', '', '3', '', '/imGroup/memberUnmute', '', '', '1', '', '0', '0', '', '0', '1', '0', '4', CONCAT('tr_', @imRootId, ' tr_', @groupDirId, ' tr_', @groupListId, ' '), '140', '', '1', @now, @now +WHERE NOT EXISTS (SELECT 1 FROM `hg_admin_menu` WHERE `name` = 'imGroupMemberUnmute'); + +INSERT INTO `hg_admin_menu` (`id`, `pid`, `title`, `name`, `path`, `icon`, `type`, `redirect`, `permissions`, `permission_name`, `component`, `always_show`, `active_menu`, `is_root`, `is_frame`, `frame_src`, `keep_alive`, `hidden`, `affix`, `level`, `tree`, `sort`, `remark`, `status`, `created_at`, `updated_at`) +SELECT NULL, @groupListId, '移出群成员', 'imGroupMemberRemove', '', '', '3', '', '/imGroup/memberRemove', '', '', '1', '', '0', '0', '', '0', '1', '0', '4', CONCAT('tr_', @imRootId, ' tr_', @groupDirId, ' tr_', @groupListId, ' '), '150', '', '1', @now, @now +WHERE NOT EXISTS (SELECT 1 FROM `hg_admin_menu` WHERE `name` = 'imGroupMemberRemove'); + +INSERT INTO `hg_admin_menu` (`id`, `pid`, `title`, `name`, `path`, `icon`, `type`, `redirect`, `permissions`, `permission_name`, `component`, `always_show`, `active_menu`, `is_root`, `is_frame`, `frame_src`, `keep_alive`, `hidden`, `affix`, `level`, `tree`, `sort`, `remark`, `status`, `created_at`, `updated_at`) +SELECT NULL, @groupListId, '转让群主', 'imGroupOwnerTransfer', '', '', '3', '', '/imGroup/ownerTransfer', '', '', '1', '', '0', '0', '', '0', '1', '0', '4', CONCAT('tr_', @imRootId, ' tr_', @groupDirId, ' tr_', @groupListId, ' '), '160', '', '1', @now, @now +WHERE NOT EXISTS (SELECT 1 FROM `hg_admin_menu` WHERE `name` = 'imGroupOwnerTransfer'); diff --git a/server/storage/data/generate/im_kafka_admin_tables.sql b/server/storage/data/generate/im_kafka_admin_tables.sql new file mode 100644 index 0000000..61376d2 --- /dev/null +++ b/server/storage/data/generate/im_kafka_admin_tables.sql @@ -0,0 +1,57 @@ +SET NAMES utf8mb4; + +CREATE TABLE IF NOT EXISTS `im_admin_commands` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `command_id` varchar(96) NOT NULL COMMENT '命令ID', + `tenant_id` bigint NOT NULL DEFAULT 0 COMMENT 'IM租户ID', + `tenant_code` varchar(64) NOT NULL DEFAULT '' COMMENT 'IM租户编码', + `event_id` varchar(96) NOT NULL COMMENT 'Kafka事件ID', + `event_type` varchar(96) NOT NULL DEFAULT '' COMMENT '事件类型', + `biz_type` varchar(64) NOT NULL DEFAULT '' COMMENT '业务类型', + `biz_id` varchar(128) NOT NULL DEFAULT '' COMMENT '业务ID', + `idempotency_key` varchar(128) NOT NULL DEFAULT '' COMMENT '幂等键', + `status` varchar(24) NOT NULL DEFAULT 'pending' COMMENT '状态', + `payload` json NOT NULL COMMENT '命令Payload', + `envelope` json NOT NULL COMMENT 'Kafka Envelope', + `result` json DEFAULT NULL COMMENT '执行结果', + `reason` varchar(96) NOT NULL DEFAULT '' COMMENT '稳定错误码', + `message` varchar(1000) NOT NULL DEFAULT '' COMMENT '结果消息', + `sent_at` datetime(3) DEFAULT NULL COMMENT '投递时间', + `completed_at` datetime(3) DEFAULT NULL COMMENT '完成时间', + `created_by` bigint NOT NULL DEFAULT 0 COMMENT '创建人', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_im_admin_commands_command` (`command_id`), + UNIQUE KEY `uk_im_admin_commands_event` (`event_id`), + KEY `idx_im_admin_commands_tenant_status` (`tenant_id`, `status`, `id`), + KEY `idx_im_admin_commands_biz` (`biz_type`, `biz_id`), + KEY `idx_im_admin_commands_type` (`event_type`, `id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='IM后台Kafka命令'; + +CREATE TABLE IF NOT EXISTS `im_admin_outbox` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `event_id` varchar(96) NOT NULL COMMENT 'Kafka事件ID', + `command_id` varchar(96) NOT NULL DEFAULT '' COMMENT '命令ID', + `topic` varchar(128) NOT NULL COMMENT 'Kafka Topic', + `event_type` varchar(96) NOT NULL DEFAULT '' COMMENT '事件类型', + `biz_type` varchar(64) NOT NULL DEFAULT '' COMMENT '业务类型', + `biz_id` varchar(128) NOT NULL DEFAULT '' COMMENT '业务ID', + `key_value` varchar(256) NOT NULL DEFAULT '' COMMENT 'Kafka Key', + `envelope` json NOT NULL COMMENT 'Kafka Envelope', + `status` varchar(24) NOT NULL DEFAULT 'pending' COMMENT '状态', + `attempts` int NOT NULL DEFAULT 0 COMMENT '投递次数', + `next_retry_at` datetime(3) DEFAULT NULL COMMENT '下次重试时间', + `sent_at` datetime(3) DEFAULT NULL COMMENT '投递时间', + `last_reason` varchar(96) NOT NULL DEFAULT '' COMMENT '最近失败码', + `last_error` varchar(1000) NOT NULL DEFAULT '' COMMENT '最近失败', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_im_admin_outbox_event` (`event_id`), + KEY `idx_im_admin_outbox_pending` (`status`, `next_retry_at`, `id`), + KEY `idx_im_admin_outbox_command` (`command_id`), + KEY `idx_im_admin_outbox_biz` (`biz_type`, `biz_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='IM后台Kafka Outbox'; + +UPDATE `im_admin_commands` SET `status` = 'running' WHERE `status` = 'sent'; diff --git a/server/storage/data/generate/im_kafka_audit_menu.sql b/server/storage/data/generate/im_kafka_audit_menu.sql new file mode 100644 index 0000000..609dcf1 --- /dev/null +++ b/server/storage/data/generate/im_kafka_audit_menu.sql @@ -0,0 +1,23 @@ +SET NAMES utf8mb4; + +SET @now = NOW(); + +SELECT @imRootId := `id` FROM `hg_admin_menu` WHERE `name` = 'imManager' LIMIT 1; + +INSERT INTO `hg_admin_menu` (`id`, `pid`, `title`, `name`, `path`, `icon`, `type`, `redirect`, `permissions`, `permission_name`, `component`, `always_show`, `active_menu`, `is_root`, `is_frame`, `frame_src`, `keep_alive`, `hidden`, `affix`, `level`, `tree`, `sort`, `remark`, `status`, `created_at`, `updated_at`) +SELECT NULL, '0', 'IM管理', 'imManager', '/im', '', '1', '/im/tenant/index', '', '', 'LAYOUT', '1', '', '1', '0', '', '0', '0', '0', '1', '', '200', '', '1', @now, @now +WHERE @imRootId IS NULL; + +SELECT @imRootId := `id` FROM `hg_admin_menu` WHERE `name` = 'imManager' LIMIT 1; + +SELECT @auditId := `id` FROM `hg_admin_menu` WHERE `name` = 'imKafkaAuditIndex' LIMIT 1; + +INSERT INTO `hg_admin_menu` (`id`, `pid`, `title`, `name`, `path`, `icon`, `type`, `redirect`, `permissions`, `permission_name`, `component`, `always_show`, `active_menu`, `is_root`, `is_frame`, `frame_src`, `keep_alive`, `hidden`, `affix`, `level`, `tree`, `sort`, `remark`, `status`, `created_at`, `updated_at`) +SELECT NULL, @imRootId, 'Kafka审计事件', 'imKafkaAuditIndex', 'kafka-audit', '', '2', '', '/imKafkaAudit/list', '', '/imKafkaAudit/index', '1', 'imKafkaAuditIndex', '0', '0', '', '0', '0', '0', '2', CONCAT('tr_', @imRootId, ' '), '95', '', '1', @now, @now +WHERE @auditId IS NULL; + +SELECT @auditId := `id` FROM `hg_admin_menu` WHERE `name` = 'imKafkaAuditIndex' LIMIT 1; + +INSERT INTO `hg_admin_menu` (`id`, `pid`, `title`, `name`, `path`, `icon`, `type`, `redirect`, `permissions`, `permission_name`, `component`, `always_show`, `active_menu`, `is_root`, `is_frame`, `frame_src`, `keep_alive`, `hidden`, `affix`, `level`, `tree`, `sort`, `remark`, `status`, `created_at`, `updated_at`) +SELECT NULL, @auditId, '审计事件详情', 'imKafkaAuditView', '', '', '3', '', '/imKafkaAudit/view', '', '', '1', '', '0', '0', '', '0', '1', '0', '3', CONCAT('tr_', @imRootId, ' tr_', @auditId, ' '), '10', '', '1', @now, @now +WHERE NOT EXISTS (SELECT 1 FROM `hg_admin_menu` WHERE `name` = 'imKafkaAuditView'); diff --git a/server/storage/data/generate/im_kafka_command_menu.sql b/server/storage/data/generate/im_kafka_command_menu.sql new file mode 100644 index 0000000..c9a194a --- /dev/null +++ b/server/storage/data/generate/im_kafka_command_menu.sql @@ -0,0 +1,31 @@ +SET NAMES utf8mb4; + +SET @now = NOW(); + +SELECT @imRootId := `id` FROM `hg_admin_menu` WHERE `name` = 'imManager' LIMIT 1; + +INSERT INTO `hg_admin_menu` (`id`, `pid`, `title`, `name`, `path`, `icon`, `type`, `redirect`, `permissions`, `permission_name`, `component`, `always_show`, `active_menu`, `is_root`, `is_frame`, `frame_src`, `keep_alive`, `hidden`, `affix`, `level`, `tree`, `sort`, `remark`, `status`, `created_at`, `updated_at`) +SELECT NULL, '0', 'IM管理', 'imManager', '/im', '', '1', '/im/tenant/index', '', '', 'LAYOUT', '1', '', '1', '0', '', '0', '0', '0', '1', '', '200', '', '1', @now, @now +WHERE @imRootId IS NULL; + +SELECT @imRootId := `id` FROM `hg_admin_menu` WHERE `name` = 'imManager' LIMIT 1; + +SELECT @commandId := `id` FROM `hg_admin_menu` WHERE `name` = 'imKafkaCommandIndex' LIMIT 1; + +INSERT INTO `hg_admin_menu` (`id`, `pid`, `title`, `name`, `path`, `icon`, `type`, `redirect`, `permissions`, `permission_name`, `component`, `always_show`, `active_menu`, `is_root`, `is_frame`, `frame_src`, `keep_alive`, `hidden`, `affix`, `level`, `tree`, `sort`, `remark`, `status`, `created_at`, `updated_at`) +SELECT NULL, @imRootId, 'Kafka命令', 'imKafkaCommandIndex', 'kafka-command', '', '2', '', '/imKafkaCommand/list', '', '/imKafkaCommand/index', '1', 'imKafkaCommandIndex', '0', '0', '', '0', '0', '0', '2', CONCAT('tr_', @imRootId, ' '), '90', '', '1', @now, @now +WHERE @commandId IS NULL; + +SELECT @commandId := `id` FROM `hg_admin_menu` WHERE `name` = 'imKafkaCommandIndex' LIMIT 1; + +INSERT INTO `hg_admin_menu` (`id`, `pid`, `title`, `name`, `path`, `icon`, `type`, `redirect`, `permissions`, `permission_name`, `component`, `always_show`, `active_menu`, `is_root`, `is_frame`, `frame_src`, `keep_alive`, `hidden`, `affix`, `level`, `tree`, `sort`, `remark`, `status`, `created_at`, `updated_at`) +SELECT NULL, @commandId, '命令详情', 'imKafkaCommandView', '', '', '3', '', '/imKafkaCommand/view', '', '', '1', '', '0', '0', '', '0', '1', '0', '3', CONCAT('tr_', @imRootId, ' tr_', @commandId, ' '), '10', '', '1', @now, @now +WHERE NOT EXISTS (SELECT 1 FROM `hg_admin_menu` WHERE `name` = 'imKafkaCommandView'); + +INSERT INTO `hg_admin_menu` (`id`, `pid`, `title`, `name`, `path`, `icon`, `type`, `redirect`, `permissions`, `permission_name`, `component`, `always_show`, `active_menu`, `is_root`, `is_frame`, `frame_src`, `keep_alive`, `hidden`, `affix`, `level`, `tree`, `sort`, `remark`, `status`, `created_at`, `updated_at`) +SELECT NULL, @commandId, '发送缓存失效测试', 'imKafkaCommandCacheInvalidate', '', '', '3', '', '/imKafkaCommand/cacheInvalidate', '', '', '1', '', '0', '0', '', '0', '1', '0', '3', CONCAT('tr_', @imRootId, ' tr_', @commandId, ' '), '20', '', '1', @now, @now +WHERE NOT EXISTS (SELECT 1 FROM `hg_admin_menu` WHERE `name` = 'imKafkaCommandCacheInvalidate'); + +INSERT INTO `hg_admin_menu` (`id`, `pid`, `title`, `name`, `path`, `icon`, `type`, `redirect`, `permissions`, `permission_name`, `component`, `always_show`, `active_menu`, `is_root`, `is_frame`, `frame_src`, `keep_alive`, `hidden`, `affix`, `level`, `tree`, `sort`, `remark`, `status`, `created_at`, `updated_at`) +SELECT NULL, @commandId, '发送工作台测试事件', 'imKafkaCommandWorkbenchEvent', '', '', '3', '', '/imKafkaCommand/workbenchEvent', '', '', '1', '', '0', '0', '', '0', '1', '0', '3', CONCAT('tr_', @imRootId, ' tr_', @commandId, ' '), '30', '', '1', @now, @now +WHERE NOT EXISTS (SELECT 1 FROM `hg_admin_menu` WHERE `name` = 'imKafkaCommandWorkbenchEvent'); diff --git a/server/storage/data/generate/im_kafka_event_tables.sql b/server/storage/data/generate/im_kafka_event_tables.sql new file mode 100644 index 0000000..9a7d692 --- /dev/null +++ b/server/storage/data/generate/im_kafka_event_tables.sql @@ -0,0 +1,50 @@ +SET NAMES utf8mb4; + +CREATE TABLE IF NOT EXISTS `im_kafka_dead_letter_events` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `event_id` varchar(96) NOT NULL COMMENT '事件ID', + `parent_event_id` varchar(96) NOT NULL DEFAULT '' COMMENT '父事件ID', + `tenant_code` varchar(64) NOT NULL DEFAULT '' COMMENT '租户编码', + `event_type` varchar(96) NOT NULL DEFAULT '' COMMENT '事件类型', + `biz_type` varchar(64) NOT NULL DEFAULT '' COMMENT '业务类型', + `biz_id` varchar(128) NOT NULL DEFAULT '' COMMENT '业务ID', + `source_service` varchar(64) NOT NULL DEFAULT '' COMMENT '来源服务', + `target_service` varchar(64) NOT NULL DEFAULT '' COMMENT '目标服务', + `dead_id` varchar(128) NOT NULL DEFAULT '' COMMENT '死信ID', + `reason` varchar(128) NOT NULL DEFAULT '' COMMENT '原因码', + `message` varchar(1000) NOT NULL DEFAULT '' COMMENT '错误信息', + `payload` json DEFAULT NULL COMMENT 'Payload', + `envelope` json NOT NULL COMMENT 'Envelope', + `received_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '接收时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_im_kafka_dead_letter_events_event` (`event_id`), + KEY `idx_im_kafka_dead_letter_events_tenant` (`tenant_code`, `id`), + KEY `idx_im_kafka_dead_letter_events_dead` (`dead_id`), + KEY `idx_im_kafka_dead_letter_events_biz` (`biz_type`, `biz_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='IM Kafka死信事件'; + +CREATE TABLE IF NOT EXISTS `im_audit_events` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `event_id` varchar(96) NOT NULL COMMENT '事件ID', + `tenant_code` varchar(64) NOT NULL DEFAULT '' COMMENT '租户编码', + `event_type` varchar(96) NOT NULL DEFAULT '' COMMENT '事件类型', + `biz_type` varchar(64) NOT NULL DEFAULT '' COMMENT '业务类型', + `biz_id` varchar(128) NOT NULL DEFAULT '' COMMENT '业务ID', + `actor_type` varchar(32) NOT NULL DEFAULT '' COMMENT '操作者类型', + `actor_id` varchar(64) NOT NULL DEFAULT '' COMMENT '操作者ID', + `action` varchar(128) NOT NULL DEFAULT '' COMMENT '动作', + `resource` varchar(64) NOT NULL DEFAULT '' COMMENT '资源', + `resource_id` varchar(128) NOT NULL DEFAULT '' COMMENT '资源ID', + `result` varchar(32) NOT NULL DEFAULT '' COMMENT '结果', + `reason` varchar(128) NOT NULL DEFAULT '' COMMENT '原因', + `payload` json DEFAULT NULL COMMENT 'Payload', + `envelope` json NOT NULL COMMENT 'Envelope', + `occurred_at` datetime(3) DEFAULT NULL COMMENT '发生时间', + `received_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '接收时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_im_audit_events_event` (`event_id`), + KEY `idx_im_audit_events_tenant` (`tenant_code`, `id`), + KEY `idx_im_audit_events_actor` (`actor_type`, `actor_id`), + KEY `idx_im_audit_events_resource` (`resource`, `resource_id`), + KEY `idx_im_audit_events_action` (`action`, `id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='IM Kafka审计事件'; diff --git a/server/storage/data/generate/im_user_menu.sql b/server/storage/data/generate/im_user_menu.sql index d544db8..d59c8b7 100644 --- a/server/storage/data/generate/im_user_menu.sql +++ b/server/storage/data/generate/im_user_menu.sql @@ -29,3 +29,35 @@ SELECT @userListId := `id` FROM `hg_admin_menu` WHERE `name` = 'imUserIndex' LIM INSERT INTO `hg_admin_menu` (`id`, `pid`, `title`, `name`, `path`, `icon`, `type`, `redirect`, `permissions`, `permission_name`, `component`, `always_show`, `active_menu`, `is_root`, `is_frame`, `frame_src`, `keep_alive`, `hidden`, `affix`, `level`, `tree`, `sort`, `remark`, `status`, `created_at`, `updated_at`) SELECT NULL, @userListId, '用户详情', 'imUserView', '', '', '3', '', '/imUser/view', '', '', '1', '', '0', '0', '', '0', '1', '0', '4', CONCAT('tr_', @imRootId, ' tr_', @userDirId, ' tr_', @userListId, ' '), '10', '', '1', @now, @now WHERE NOT EXISTS (SELECT 1 FROM `hg_admin_menu` WHERE `name` = 'imUserView'); + +INSERT INTO `hg_admin_menu` (`id`, `pid`, `title`, `name`, `path`, `icon`, `type`, `redirect`, `permissions`, `permission_name`, `component`, `always_show`, `active_menu`, `is_root`, `is_frame`, `frame_src`, `keep_alive`, `hidden`, `affix`, `level`, `tree`, `sort`, `remark`, `status`, `created_at`, `updated_at`) +SELECT NULL, @userListId, '封禁用户', 'imUserBan', '', '', '3', '', '/imUser/ban', '', '', '1', '', '0', '0', '', '0', '1', '0', '4', CONCAT('tr_', @imRootId, ' tr_', @userDirId, ' tr_', @userListId, ' '), '20', '', '1', @now, @now +WHERE NOT EXISTS (SELECT 1 FROM `hg_admin_menu` WHERE `name` = 'imUserBan'); + +INSERT INTO `hg_admin_menu` (`id`, `pid`, `title`, `name`, `path`, `icon`, `type`, `redirect`, `permissions`, `permission_name`, `component`, `always_show`, `active_menu`, `is_root`, `is_frame`, `frame_src`, `keep_alive`, `hidden`, `affix`, `level`, `tree`, `sort`, `remark`, `status`, `created_at`, `updated_at`) +SELECT NULL, @userListId, '解封用户', 'imUserUnban', '', '', '3', '', '/imUser/unban', '', '', '1', '', '0', '0', '', '0', '1', '0', '4', CONCAT('tr_', @imRootId, ' tr_', @userDirId, ' tr_', @userListId, ' '), '30', '', '1', @now, @now +WHERE NOT EXISTS (SELECT 1 FROM `hg_admin_menu` WHERE `name` = 'imUserUnban'); + +INSERT INTO `hg_admin_menu` (`id`, `pid`, `title`, `name`, `path`, `icon`, `type`, `redirect`, `permissions`, `permission_name`, `component`, `always_show`, `active_menu`, `is_root`, `is_frame`, `frame_src`, `keep_alive`, `hidden`, `affix`, `level`, `tree`, `sort`, `remark`, `status`, `created_at`, `updated_at`) +SELECT NULL, @userListId, '冻结用户', 'imUserFreeze', '', '', '3', '', '/imUser/freeze', '', '', '1', '', '0', '0', '', '0', '1', '0', '4', CONCAT('tr_', @imRootId, ' tr_', @userDirId, ' tr_', @userListId, ' '), '40', '', '1', @now, @now +WHERE NOT EXISTS (SELECT 1 FROM `hg_admin_menu` WHERE `name` = 'imUserFreeze'); + +INSERT INTO `hg_admin_menu` (`id`, `pid`, `title`, `name`, `path`, `icon`, `type`, `redirect`, `permissions`, `permission_name`, `component`, `always_show`, `active_menu`, `is_root`, `is_frame`, `frame_src`, `keep_alive`, `hidden`, `affix`, `level`, `tree`, `sort`, `remark`, `status`, `created_at`, `updated_at`) +SELECT NULL, @userListId, '解冻用户', 'imUserUnfreeze', '', '', '3', '', '/imUser/unfreeze', '', '', '1', '', '0', '0', '', '0', '1', '0', '4', CONCAT('tr_', @imRootId, ' tr_', @userDirId, ' tr_', @userListId, ' '), '50', '', '1', @now, @now +WHERE NOT EXISTS (SELECT 1 FROM `hg_admin_menu` WHERE `name` = 'imUserUnfreeze'); + +INSERT INTO `hg_admin_menu` (`id`, `pid`, `title`, `name`, `path`, `icon`, `type`, `redirect`, `permissions`, `permission_name`, `component`, `always_show`, `active_menu`, `is_root`, `is_frame`, `frame_src`, `keep_alive`, `hidden`, `affix`, `level`, `tree`, `sort`, `remark`, `status`, `created_at`, `updated_at`) +SELECT NULL, @userListId, '踢出设备', 'imUserDeviceKick', '', '', '3', '', '/imUser/deviceKick', '', '', '1', '', '0', '0', '', '0', '1', '0', '4', CONCAT('tr_', @imRootId, ' tr_', @userDirId, ' tr_', @userListId, ' '), '60', '', '1', @now, @now +WHERE NOT EXISTS (SELECT 1 FROM `hg_admin_menu` WHERE `name` = 'imUserDeviceKick'); + +INSERT INTO `hg_admin_menu` (`id`, `pid`, `title`, `name`, `path`, `icon`, `type`, `redirect`, `permissions`, `permission_name`, `component`, `always_show`, `active_menu`, `is_root`, `is_frame`, `frame_src`, `keep_alive`, `hidden`, `affix`, `level`, `tree`, `sort`, `remark`, `status`, `created_at`, `updated_at`) +SELECT NULL, @userListId, '踢出全部会话', 'imUserLogoutAll', '', '', '3', '', '/imUser/logoutAll', '', '', '1', '', '0', '0', '', '0', '1', '0', '4', CONCAT('tr_', @imRootId, ' tr_', @userDirId, ' tr_', @userListId, ' '), '70', '', '1', @now, @now +WHERE NOT EXISTS (SELECT 1 FROM `hg_admin_menu` WHERE `name` = 'imUserLogoutAll'); + +INSERT INTO `hg_admin_menu` (`id`, `pid`, `title`, `name`, `path`, `icon`, `type`, `redirect`, `permissions`, `permission_name`, `component`, `always_show`, `active_menu`, `is_root`, `is_frame`, `frame_src`, `keep_alive`, `hidden`, `affix`, `level`, `tree`, `sort`, `remark`, `status`, `created_at`, `updated_at`) +SELECT NULL, @userListId, '注销账号', 'imUserAccountCancel', '', '', '3', '', '/imUser/accountCancel', '', '', '1', '', '0', '0', '', '0', '1', '0', '4', CONCAT('tr_', @imRootId, ' tr_', @userDirId, ' tr_', @userListId, ' '), '80', '', '1', @now, @now +WHERE NOT EXISTS (SELECT 1 FROM `hg_admin_menu` WHERE `name` = 'imUserAccountCancel'); + +INSERT INTO `hg_admin_menu` (`id`, `pid`, `title`, `name`, `path`, `icon`, `type`, `redirect`, `permissions`, `permission_name`, `component`, `always_show`, `active_menu`, `is_root`, `is_frame`, `frame_src`, `keep_alive`, `hidden`, `affix`, `level`, `tree`, `sort`, `remark`, `status`, `created_at`, `updated_at`) +SELECT NULL, @userListId, '删除账号', 'imUserAccountDelete', '', '', '3', '', '/imUser/accountDelete', '', '', '1', '', '0', '0', '', '0', '1', '0', '4', CONCAT('tr_', @imRootId, ' tr_', @userDirId, ' tr_', @userListId, ' '), '90', '', '1', @now, @now +WHERE NOT EXISTS (SELECT 1 FROM `hg_admin_menu` WHERE `name` = 'imUserAccountDelete'); diff --git a/web/src/api/imDeadLetter/index.ts b/web/src/api/imDeadLetter/index.ts new file mode 100644 index 0000000..4b8c73f --- /dev/null +++ b/web/src/api/imDeadLetter/index.ts @@ -0,0 +1,46 @@ +import { http } from '@/utils/http/axios'; + +// 获取IM Kafka死信列表 +export function List(params) { + return http.request({ + url: '/imDeadLetter/list', + method: 'get', + params, + }); +} + +// 获取IM Kafka死信详情 +export function View(params) { + return http.request({ + url: '/imDeadLetter/view', + method: 'GET', + params, + }); +} + +// 重放IM Kafka死信 +export function Replay(params) { + return http.request({ + url: '/imDeadLetter/replay', + method: 'POST', + params, + }); +} + +// 忽略IM Kafka死信 +export function Ignore(params) { + return http.request({ + url: '/imDeadLetter/ignore', + method: 'POST', + params, + }); +} + +// 标记IM Kafka死信已处理 +export function Resolve(params) { + return http.request({ + url: '/imDeadLetter/resolve', + method: 'POST', + params, + }); +} diff --git a/web/src/api/imGroup/index.ts b/web/src/api/imGroup/index.ts index 8f5cac2..3ba4912 100644 --- a/web/src/api/imGroup/index.ts +++ b/web/src/api/imGroup/index.ts @@ -80,3 +80,75 @@ export function AdminLogList(params) { params, }); } + +// Kafka修改群资料 +export function ProfileUpdate(params) { + return http.request({ + url: '/imGroup/profileUpdate', + method: 'POST', + params, + }); +} + +// Kafka修改群治理配置 +export function GovernanceUpdate(params) { + return http.request({ + url: '/imGroup/governanceUpdate', + method: 'POST', + params, + }); +} + +// Kafka设置群管理员 +export function AdminSet(params) { + return http.request({ + url: '/imGroup/adminSet', + method: 'POST', + params, + }); +} + +// Kafka删除群管理员 +export function AdminRemove(params) { + return http.request({ + url: '/imGroup/adminRemove', + method: 'POST', + params, + }); +} + +// Kafka禁言群成员 +export function MemberMute(params) { + return http.request({ + url: '/imGroup/memberMute', + method: 'POST', + params, + }); +} + +// Kafka解除群成员禁言 +export function MemberUnmute(params) { + return http.request({ + url: '/imGroup/memberUnmute', + method: 'POST', + params, + }); +} + +// Kafka移出群成员 +export function MemberRemove(params) { + return http.request({ + url: '/imGroup/memberRemove', + method: 'POST', + params, + }); +} + +// Kafka转让群主 +export function OwnerTransfer(params) { + return http.request({ + url: '/imGroup/ownerTransfer', + method: 'POST', + params, + }); +} diff --git a/web/src/api/imKafkaAudit/index.ts b/web/src/api/imKafkaAudit/index.ts new file mode 100644 index 0000000..9531e90 --- /dev/null +++ b/web/src/api/imKafkaAudit/index.ts @@ -0,0 +1,17 @@ +import { http } from '@/utils/http/axios'; + +export function List(params) { + return http.request({ + url: '/imKafkaAudit/list', + method: 'get', + params, + }); +} + +export function View(params) { + return http.request({ + url: '/imKafkaAudit/view', + method: 'GET', + params, + }); +} diff --git a/web/src/api/imKafkaCommand/index.ts b/web/src/api/imKafkaCommand/index.ts new file mode 100644 index 0000000..ef07f9a --- /dev/null +++ b/web/src/api/imKafkaCommand/index.ts @@ -0,0 +1,37 @@ +import { http } from '@/utils/http/axios'; + +// 获取IM后台命令列表 +export function List(params) { + return http.request({ + url: '/imKafkaCommand/list', + method: 'get', + params, + }); +} + +// 获取IM后台命令详情 +export function View(params) { + return http.request({ + url: '/imKafkaCommand/view', + method: 'GET', + params, + }); +} + +// 发送IM缓存失效测试事件 +export function CacheInvalidate(params) { + return http.request({ + url: '/imKafkaCommand/cacheInvalidate', + method: 'POST', + params, + }); +} + +// 发送IM工作台测试事件 +export function WorkbenchEvent(params) { + return http.request({ + url: '/imKafkaCommand/workbenchEvent', + method: 'POST', + params, + }); +} diff --git a/web/src/api/imUser/index.ts b/web/src/api/imUser/index.ts index e5a058d..621a5ac 100644 --- a/web/src/api/imUser/index.ts +++ b/web/src/api/imUser/index.ts @@ -17,3 +17,35 @@ export function View(params) { params, }); } + +export function Ban(params) { + return http.request({ url: '/imUser/ban', method: 'POST', params }); +} + +export function Unban(params) { + return http.request({ url: '/imUser/unban', method: 'POST', params }); +} + +export function Freeze(params) { + return http.request({ url: '/imUser/freeze', method: 'POST', params }); +} + +export function Unfreeze(params) { + return http.request({ url: '/imUser/unfreeze', method: 'POST', params }); +} + +export function DeviceKick(params) { + return http.request({ url: '/imUser/deviceKick', method: 'POST', params }); +} + +export function LogoutAll(params) { + return http.request({ url: '/imUser/logoutAll', method: 'POST', params }); +} + +export function AccountCancel(params) { + return http.request({ url: '/imUser/accountCancel', method: 'POST', params }); +} + +export function AccountDelete(params) { + return http.request({ url: '/imUser/accountDelete', method: 'POST', params }); +} diff --git a/web/src/views/imDeadLetter/index.vue b/web/src/views/imDeadLetter/index.vue new file mode 100644 index 0000000..d7775a1 --- /dev/null +++ b/web/src/views/imDeadLetter/index.vue @@ -0,0 +1,88 @@ + + + + + diff --git a/web/src/views/imDeadLetter/model.ts b/web/src/views/imDeadLetter/model.ts new file mode 100644 index 0000000..d4a2d09 --- /dev/null +++ b/web/src/views/imDeadLetter/model.ts @@ -0,0 +1,122 @@ +import { computed, ref } from 'vue'; +import { cloneDeep } from 'lodash-es'; +import { FormSchema } from '@/components/Form'; +import { useDictStore } from '@/store/modules/dict'; +import { List as TenantList } from '@/api/imTenant'; + +const dict = useDictStore(); + +export const tenantOptions = ref([]); + +export class State { + public id = 0; + public tenantId = 0; + public deadId = ''; + public eventId = ''; + public parentEventId = ''; + public sourceTopic = ''; + public consumerGroup = ''; + public eventType = ''; + public bizType = ''; + public bizId = ''; + public retryCount = 0; + public lastReason = ''; + public lastError = ''; + public status = ''; + public replayCount = 0; + public createdAt = ''; + public updatedAt = ''; + public envelope = ''; + public envelopeRaw = ''; + + constructor(state?: Partial) { + if (state) Object.assign(this, state); + } +} + +export function newState(state: State | Record | null): State { + if (state !== null) { + if (state instanceof State) return cloneDeep(state); + return new State(state); + } + return new State(); +} + +export const schemas = ref([ + { + field: 'tenantId', + component: 'NSelect', + label: '租户', + componentProps: { + clearable: true, + options: computed(() => tenantOptions.value), + placeholder: '请选择租户', + }, + }, + { + field: 'status', + component: 'NSelect', + label: '状态', + componentProps: { + clearable: true, + options: computed(() => dict.getOptions('ImKafkaDeadLetterStatusOptions')), + placeholder: '请选择状态', + }, + }, + { + field: 'eventType', + component: 'NInput', + label: '事件类型', + componentProps: { placeholder: '请输入事件类型' }, + }, + { + field: 'bizType', + component: 'NInput', + label: '业务类型', + componentProps: { placeholder: '请输入业务类型' }, + }, + { + field: 'bizId', + component: 'NInput', + label: '业务ID', + componentProps: { placeholder: '请输入业务ID' }, + }, +]); + +export const columns = [ + { title: 'ID', key: 'id', align: 'left', width: 90 }, + { title: '死信ID', key: 'deadId', align: 'left', width: 220 }, + { title: '事件ID', key: 'eventId', align: 'left', width: 220 }, + { title: '来源Topic', key: 'sourceTopic', align: 'left', width: 150 }, + { title: '消费组', key: 'consumerGroup', align: 'left', width: 160 }, + { title: '事件类型', key: 'eventType', align: 'left', width: 190 }, + { title: '业务类型', key: 'bizType', align: 'left', width: 130 }, + { title: '业务ID', key: 'bizId', align: 'left', width: 180 }, + { + title: '状态', + key: 'status', + align: 'left', + width: 120, + render(row: State) { + return dict.getLabel('ImKafkaDeadLetterStatusOptions', row.status); + }, + }, + { title: '重试', key: 'retryCount', align: 'left', width: 80 }, + { title: '重放', key: 'replayCount', align: 'left', width: 80 }, + { title: '原因', key: 'lastReason', align: 'left', width: 180 }, + { title: '创建时间', key: 'createdAt', align: 'left', width: 180 }, + { title: '更新时间', key: 'updatedAt', align: 'left', width: 180 }, +]; + +export function loadOptions() { + dict.loadOptions(['ImKafkaDeadLetterStatusOptions']); +} + +export async function loadTenantOptions() { + const res = await TenantList({ page: 1, perPage: 200 }); + tenantOptions.value = (res?.list || []).map((item) => ({ + label: `${item.name}(${item.code})`, + value: item.id, + })); + return tenantOptions.value; +} diff --git a/web/src/views/imDeadLetter/view.vue b/web/src/views/imDeadLetter/view.vue new file mode 100644 index 0000000..14c49ce --- /dev/null +++ b/web/src/views/imDeadLetter/view.vue @@ -0,0 +1,123 @@ + + + + + diff --git a/web/src/views/imGroup/governance.vue b/web/src/views/imGroup/governance.vue index 04c52d2..e7ac411 100644 --- a/web/src/views/imGroup/governance.vue +++ b/web/src/views/imGroup/governance.vue @@ -12,6 +12,44 @@ + + + + + + + + + + + + + + + + + + + + 入群审批 + 全员禁言 + 成员上传文件 + 成员私聊 + 成员邀请 + + + + + 提交资料命令 + 提交治理命令 + + {{ governance.name || '-' }} {{ groupStatusLabel }} @@ -95,15 +133,18 @@ - + diff --git a/web/src/views/imGroup/members.vue b/web/src/views/imGroup/members.vue index 5706d9d..4cc7b54 100644 --- a/web/src/views/imGroup/members.vue +++ b/web/src/views/imGroup/members.vue @@ -9,11 +9,17 @@ :title="'群成员 ' + current.topic" :style="{ width: dialogWidth }" > + + + + + @@ -27,17 +33,63 @@ - + diff --git a/web/src/views/imKafkaAudit/index.vue b/web/src/views/imKafkaAudit/index.vue new file mode 100644 index 0000000..3aa11ea --- /dev/null +++ b/web/src/views/imKafkaAudit/index.vue @@ -0,0 +1,72 @@ + + + + + diff --git a/web/src/views/imKafkaAudit/model.ts b/web/src/views/imKafkaAudit/model.ts new file mode 100644 index 0000000..12b4144 --- /dev/null +++ b/web/src/views/imKafkaAudit/model.ts @@ -0,0 +1,106 @@ +import { ref } from 'vue'; +import { cloneDeep } from 'lodash-es'; +import { FormSchema } from '@/components/Form'; +import { defRangeShortcuts } from '@/utils/dateUtil'; + +export class State { + public id = 0; + public eventId = ''; + public tenantCode = ''; + public eventType = ''; + public bizType = ''; + public bizId = ''; + public actorType = ''; + public actorId = ''; + public action = ''; + public resource = ''; + public resourceId = ''; + public result = ''; + public reason = ''; + public payload = ''; + public envelope = ''; + public occurredAt = ''; + public receivedAt = ''; + + constructor(state?: Partial) { + if (state) Object.assign(this, state); + } +} + +export function newState(state: State | Record | null): State { + if (state !== null) { + if (state instanceof State) return cloneDeep(state); + return new State(state); + } + return new State(); +} + +export const schemas = ref([ + { + field: 'tenantCode', + component: 'NInput', + label: '租户编码', + componentProps: { placeholder: '请输入租户编码' }, + }, + { + field: 'eventId', + component: 'NInput', + label: '事件ID', + componentProps: { placeholder: '请输入事件ID' }, + }, + { + field: 'eventType', + component: 'NInput', + label: '事件类型', + componentProps: { placeholder: '请输入事件类型' }, + }, + { + field: 'action', + component: 'NInput', + label: '动作', + componentProps: { placeholder: '请输入动作' }, + }, + { + field: 'resource', + component: 'NInput', + label: '资源', + componentProps: { placeholder: '请输入资源' }, + }, + { + field: 'resourceId', + component: 'NInput', + label: '资源ID', + componentProps: { placeholder: '请输入资源ID' }, + }, + { + field: 'result', + component: 'NInput', + label: '结果', + componentProps: { placeholder: '请输入结果' }, + }, + { + field: 'createdAt', + component: 'NDatePicker', + label: '接收时间', + componentProps: { + type: 'datetimerange', + clearable: true, + shortcuts: defRangeShortcuts(), + }, + }, +]); + +export const columns = [ + { title: 'ID', key: 'id', align: 'left', width: 90 }, + { title: '租户', key: 'tenantCode', align: 'left', width: 120 }, + { title: '事件ID', key: 'eventId', align: 'left', width: 220 }, + { title: '事件类型', key: 'eventType', align: 'left', width: 190 }, + { title: '动作', key: 'action', align: 'left', width: 170 }, + { title: '资源', key: 'resource', align: 'left', width: 120 }, + { title: '资源ID', key: 'resourceId', align: 'left', width: 170 }, + { title: '操作者', key: 'actorId', align: 'left', width: 140 }, + { title: '结果', key: 'result', align: 'left', width: 100 }, + { title: '原因', key: 'reason', align: 'left', width: 160 }, + { title: '发生时间', key: 'occurredAt', align: 'left', width: 180 }, + { title: '接收时间', key: 'receivedAt', align: 'left', width: 180 }, +]; diff --git a/web/src/views/imKafkaAudit/view.vue b/web/src/views/imKafkaAudit/view.vue new file mode 100644 index 0000000..0ff8d2c --- /dev/null +++ b/web/src/views/imKafkaAudit/view.vue @@ -0,0 +1,79 @@ + + + + + diff --git a/web/src/views/imKafkaCommand/index.vue b/web/src/views/imKafkaCommand/index.vue new file mode 100644 index 0000000..80b8de7 --- /dev/null +++ b/web/src/views/imKafkaCommand/index.vue @@ -0,0 +1,127 @@ + + + + + diff --git a/web/src/views/imKafkaCommand/model.ts b/web/src/views/imKafkaCommand/model.ts new file mode 100644 index 0000000..59b1840 --- /dev/null +++ b/web/src/views/imKafkaCommand/model.ts @@ -0,0 +1,139 @@ +import { computed, ref } from 'vue'; +import { cloneDeep } from 'lodash-es'; +import { FormSchema } from '@/components/Form'; +import { defRangeShortcuts } from '@/utils/dateUtil'; +import { useDictStore } from '@/store/modules/dict'; +import { List as TenantList } from '@/api/imTenant'; + +const dict = useDictStore(); + +export const tenantOptions = ref([]); + +export class State { + public id = 0; + public commandId = ''; + public tenantId = 0; + public tenantCode = ''; + public eventId = ''; + public eventType = ''; + public bizType = ''; + public bizId = ''; + public idempotencyKey = ''; + public status = ''; + public reason = ''; + public message = ''; + public payload = ''; + public envelope = ''; + public result = ''; + public sentAt = ''; + public completedAt = ''; + public createdBy = 0; + public createdAt = ''; + public updatedAt = ''; + + constructor(state?: Partial) { + if (state) Object.assign(this, state); + } +} + +export function newState(state: State | Record | null): State { + if (state !== null) { + if (state instanceof State) return cloneDeep(state); + return new State(state); + } + return new State(); +} + +export const schemas = ref([ + { + field: 'tenantId', + component: 'NSelect', + label: '租户', + componentProps: { + clearable: true, + options: computed(() => tenantOptions.value), + placeholder: '请选择租户', + }, + }, + { + field: 'commandId', + component: 'NInput', + label: '命令ID', + componentProps: { placeholder: '请输入命令ID' }, + }, + { + field: 'eventId', + component: 'NInput', + label: '事件ID', + componentProps: { placeholder: '请输入事件ID' }, + }, + { + field: 'eventType', + component: 'NInput', + label: '事件类型', + componentProps: { placeholder: '请输入事件类型' }, + }, + { + field: 'bizId', + component: 'NInput', + label: '业务ID', + componentProps: { placeholder: '请输入业务ID' }, + }, + { + field: 'status', + component: 'NSelect', + label: '状态', + componentProps: { + clearable: true, + options: computed(() => dict.getOptions('ImAdminCommandStatusOptions')), + placeholder: '请选择状态', + }, + }, + { + field: 'createdAt', + component: 'NDatePicker', + label: '创建时间', + componentProps: { + type: 'datetimerange', + clearable: true, + shortcuts: defRangeShortcuts(), + }, + }, +]); + +export const columns = [ + { title: 'ID', key: 'id', align: 'left', width: 90 }, + { title: '租户', key: 'tenantCode', align: 'left', width: 120 }, + { title: '命令ID', key: 'commandId', align: 'left', width: 210 }, + { title: '事件类型', key: 'eventType', align: 'left', width: 190 }, + { title: '业务类型', key: 'bizType', align: 'left', width: 110 }, + { title: '业务ID', key: 'bizId', align: 'left', width: 180 }, + { + title: '状态', + key: 'status', + align: 'left', + width: 110, + render(row: State) { + return dict.getLabel('ImAdminCommandStatusOptions', row.status); + }, + }, + { title: '错误码', key: 'reason', align: 'left', width: 170 }, + { title: '消息', key: 'message', align: 'left', width: 220 }, + { title: '投递时间', key: 'sentAt', align: 'left', width: 180 }, + { title: '完成时间', key: 'completedAt', align: 'left', width: 180 }, + { title: '创建时间', key: 'createdAt', align: 'left', width: 180 }, +]; + +export function loadOptions() { + dict.loadOptions(['ImAdminCommandStatusOptions']); +} + +export async function loadTenantOptions() { + const res = await TenantList({ page: 1, perPage: 20 }); + tenantOptions.value = (res?.list || []).map((item) => ({ + label: `${item.name}(${item.code})`, + value: item.id, + code: item.code, + })); + return tenantOptions.value; +} diff --git a/web/src/views/imKafkaCommand/view.vue b/web/src/views/imKafkaCommand/view.vue new file mode 100644 index 0000000..9efe8bc --- /dev/null +++ b/web/src/views/imKafkaCommand/view.vue @@ -0,0 +1,86 @@ + + + + + diff --git a/web/src/views/imUser/governance.vue b/web/src/views/imUser/governance.vue new file mode 100644 index 0000000..d1c4582 --- /dev/null +++ b/web/src/views/imUser/governance.vue @@ -0,0 +1,142 @@ + + + + + diff --git a/web/src/views/imUser/index.vue b/web/src/views/imUser/index.vue index e9636ba..3094f2c 100644 --- a/web/src/views/imUser/index.vue +++ b/web/src/views/imUser/index.vue @@ -22,6 +22,7 @@ /> + @@ -35,15 +36,17 @@ import { columns, loadOptions, loadTenantOptions, schemas, State, tenantOptions } from './model'; import { adaTableScrollX } from '@/utils/hotgo'; import ViewModal from './view.vue'; + import GovernanceModal from './governance.vue'; const message = useMessage(); const { hasPermission } = usePermission(); const actionRef = ref(); const searchFormRef = ref({}); const viewRef = ref(); + const governanceRef = ref(); const actionColumn = reactive({ - width: 120, + width: 170, title: '操作', key: 'action', fixed: 'right', @@ -56,6 +59,11 @@ onClick: handleView.bind(null, record), auth: ['/imUser/view'], }, + { + label: '治理', + onClick: handleGovernance.bind(null, record), + auth: ['/imUser/ban'], + }, ], }); }, @@ -90,6 +98,10 @@ viewRef.value.openModal(record); } + function handleGovernance(record: Recordable) { + governanceRef.value.openModal(record); + } + onMounted(async () => { loadOptions(); await loadTenantOptions();