feat: add im kafka admin integration

This commit is contained in:
richjohnrambo
2026-09-04 11:57:38 +08:00
parent a2abaef3a6
commit f7f8cc0ef0
74 changed files with 6254 additions and 47 deletions
+904
View File
@@ -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 下发日志。
@@ -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`,先覆盖会议提醒、日报/月报催办、总经理信箱提交通知。
@@ -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:"配置值"`
}
@@ -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
}
+80
View File
@@ -108,3 +108,83 @@ type AdminLogListRes struct {
form.PageRes form.PageRes
List []*sysin.ImGroupAdminLogListModel `json:"list" dc:"数据列表"` 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
}
@@ -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
}
@@ -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
}
+72
View File
@@ -32,3 +32,75 @@ type ViewReq struct {
type ViewRes struct { type ViewRes struct {
*sysin.ImUserViewModel *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
}
+3
View File
@@ -35,6 +35,7 @@ require (
github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/sms v1.0.1200 github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/sms v1.0.1200
github.com/tencentyun/cos-go-sdk-v5 v0.7.66 github.com/tencentyun/cos-go-sdk-v5 v0.7.66
github.com/ufilesdk-dev/ufile-gosdk v1.0.6 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 github.com/xuri/excelize/v2 v2.9.1
go.opentelemetry.io/otel v1.38.0 go.opentelemetry.io/otel v1.38.0
golang.org/x/mod v0.26.0 golang.org/x/mod v0.26.0
@@ -138,6 +139,8 @@ require (
github.com/tjfoc/gmsm v1.4.1 // indirect github.com/tjfoc/gmsm v1.4.1 // indirect
github.com/tklauser/go-sysconf v0.3.12 // indirect github.com/tklauser/go-sysconf v0.3.12 // indirect
github.com/tklauser/numcpus v0.6.1 // 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/efp v0.0.1 // indirect
github.com/xuri/nfp v0.0.1 // indirect github.com/xuri/nfp v0.0.1 // indirect
github.com/yusufpapurcu/wmi v1.2.4 // indirect github.com/yusufpapurcu/wmi v1.2.4 // indirect
+7
View File
@@ -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/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 h1:YhTZfA0U5zjKUe6qYrIs72jjxSZryTMbS9wE76fIxMs=
github.com/ufilesdk-dev/ufile-gosdk v1.0.6/go.mod h1:s1LIpscsgVNx0JRNbDElNNQhyNXJiumVpzvPzVNS/XI= 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 h1:fws5Rv3myXyYni8uwj2qKjVaRP30PdjeYe2Y6FDsCL8=
github.com/xuri/efp v0.0.1/go.mod h1:ybY/Jr0T0GTCnYjKqmdwxyxn2BQf2RcQIIvex5QldPI= github.com/xuri/efp v0.0.1/go.mod h1:ybY/Jr0T0GTCnYjKqmdwxyxn2BQf2RcQIIvex5QldPI=
github.com/xuri/excelize/v2 v2.9.1 h1:VdSGk+rraGmgLHGFaGG9/9IWu1nj4ufjJ7uwMDtj8Qw= 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.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/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.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.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.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
+3
View File
@@ -45,6 +45,9 @@ var (
}...) }...)
s.Group("/", func(group *ghttp.RouterGroup) { s.Group("/", func(group *ghttp.RouterGroup) {
// 注册内部服务路由
router.Internal(ctx, group)
// 注册后台路由 // 注册后台路由
router.Admin(ctx, group) router.Admin(ctx, group)
+88
View File
@@ -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, "已处理"),
}
+8
View File
@@ -10,4 +10,12 @@ const (
QueueLogTopic = `request_log` // 访问日志 QueueLogTopic = `request_log` // 访问日志
QueueLoginLogTopic = `login_log` // 登录日志 QueueLoginLogTopic = `login_log` // 登录日志
QueueServeLogTopic = `serve_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审计事件
) )
@@ -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
}
@@ -149,3 +149,91 @@ func (c *cImGroup) AdminLogList(ctx context.Context, req *imgroup.AdminLogListRe
res.PageRes.Pack(req, totalCount) res.PageRes.Pack(req, totalCount)
return 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
}
@@ -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
}
@@ -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
}
@@ -46,3 +46,83 @@ func (c *cImUser) View(ctx context.Context, req *imuser.ViewReq) (res *imuser.Vi
res.ImUserViewModel = data res.ImUserViewModel = data
return 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
}
@@ -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
}
+38
View File
@@ -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
}
+26 -8
View File
@@ -10,10 +10,13 @@ import (
) )
type Config struct { type Config struct {
BaseUrl string `json:"baseUrl"` BaseUrl string `json:"baseUrl"`
ServiceId string `json:"serviceId"` ServiceId string `json:"serviceId"`
ServiceSecret string `json:"serviceSecret"` ServiceSecret string `json:"serviceSecret"`
Timeout time.Duration `json:"timeout"` Timeout time.Duration `json:"timeout"`
InternalBaseUrl string `json:"internalBaseUrl"`
InternalServiceName string `json:"internalServiceName"`
InternalServiceSecret string `json:"internalServiceSecret"`
} }
func LoadConfig(ctx context.Context) Config { func LoadConfig(ctx context.Context) Config {
@@ -28,10 +31,25 @@ func LoadConfig(ctx context.Context) Config {
if serviceSecret == "" { if serviceSecret == "" {
serviceSecret = os.Getenv("IM_ADMIN_API_SECRET") 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{ return Config{
BaseUrl: g.Cfg().MustGet(ctx, "imAdminApi.baseUrl").String(), BaseUrl: baseUrl,
ServiceId: g.Cfg().MustGet(ctx, "imAdminApi.serviceId", "hotgo-admin").String(), ServiceId: serviceId,
ServiceSecret: serviceSecret, ServiceSecret: serviceSecret,
Timeout: timeout, Timeout: timeout,
InternalBaseUrl: g.Cfg().MustGet(ctx, "imInternalApi.baseUrl", baseUrl).String(),
InternalServiceName: g.Cfg().MustGet(ctx, "imInternalApi.serviceName", serviceId).String(),
InternalServiceSecret: internalSecret,
} }
} }
+101
View File
@@ -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
}
+135
View File
@@ -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()
}
+378
View File
@@ -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
}
@@ -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:"幂等键"`
}
+1 -1
View File
@@ -125,7 +125,7 @@ func (d *DiskProducerMq) getProducer(topic string) *disk.Queue {
func NewDiskQueue(topic string, config *disk.Config) *disk.Queue { func NewDiskQueue(topic string, config *disk.Config) *disk.Queue {
conf := &disk.Config{ 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, BatchSize: config.BatchSize,
BatchTime: config.BatchTime * time.Second, BatchTime: config.BatchTime * time.Second,
SegmentSize: config.SegmentSize, SegmentSize: config.SegmentSize,
+81 -20
View File
@@ -7,13 +7,16 @@ package queue
import ( import (
"context" "context"
"fmt" "crypto/tls"
"strings"
"time"
"github.com/IBM/sarama" "github.com/IBM/sarama"
"github.com/gogf/gf/v2/errors/gerror" "github.com/gogf/gf/v2/errors/gerror"
"github.com/gogf/gf/v2/frame/g" "github.com/gogf/gf/v2/frame/g"
"github.com/xdg-go/scram"
"hotgo/internal/consts" "hotgo/internal/consts"
"hotgo/utility/simple" "hotgo/utility/simple"
"time"
) )
type KafkaMq struct { type KafkaMq struct {
@@ -23,14 +26,17 @@ type KafkaMq struct {
} }
type KafkaConfig struct { type KafkaConfig struct {
ClientId string ClientId string
Brokers []string Brokers []string
GroupID string GroupID string
Partitions int32 Partitions int32
Replication int16 Replication int16
Version string Version string
UserName string UserName string
Password string Password string
SASLEnabled bool
SASLMechanism string
TLSEnabled bool
} }
// SendMsg 按字符串类型生产数据 // SendMsg 按字符串类型生产数据
@@ -98,7 +104,7 @@ func (r *KafkaMq) ListenReceiveMsgDo(topic string, receiveDo func(mqMsg MqMsg))
} }
if consumerCtx.Err() != nil { 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 return
} }
consumer.ready = make(chan bool) consumer.ready = make(chan bool)
@@ -134,10 +140,8 @@ func RegisterKafkaMqConsumer(connOpt KafkaConfig) (client MqConsumer, err error)
conf := sarama.NewConfig() conf := sarama.NewConfig()
conf.Consumer.Return.Errors = true conf.Consumer.Return.Errors = true
conf.Version = kfkVersion conf.Version = kfkVersion
if connOpt.UserName != "" { if err = configureKafkaSecurity(conf, connOpt); err != nil {
conf.Net.SASL.Enable = true return
conf.Net.SASL.User = connOpt.UserName
conf.Net.SASL.Password = connOpt.Password
} }
// 默认按随机方式消费 // 默认按随机方式消费
@@ -157,7 +161,9 @@ func RegisterKafkaMqConsumer(connOpt KafkaConfig) (client MqConsumer, err error)
// RegisterKafkaProducer 注册并启动生产者接口实现 // RegisterKafkaProducer 注册并启动生产者接口实现
func RegisterKafkaProducer(connOpt KafkaConfig) (client MqProducer, err error) { func RegisterKafkaProducer(connOpt KafkaConfig) (client MqProducer, err error) {
mqIns := &KafkaMq{} mqIns := &KafkaMq{}
connOpt.ClientId = "HOTGO-Producer" if connOpt.ClientId == "" {
connOpt.ClientId = "hotgo-producer"
}
// 这里如果使用go程需要处理chan同步问题 // 这里如果使用go程需要处理chan同步问题
if err = doRegisterKafkaProducer(connOpt, mqIns); err != nil { if err = doRegisterKafkaProducer(connOpt, mqIns); err != nil {
@@ -191,10 +197,8 @@ func doRegisterKafkaProducer(connOpt KafkaConfig, mqIns *KafkaMq) (err error) {
conf.ClientID = connOpt.ClientId conf.ClientID = connOpt.ClientId
conf.Version = kfkVersion conf.Version = kfkVersion
if connOpt.UserName != "" { if err = configureKafkaSecurity(conf, connOpt); err != nil {
conf.Net.SASL.Enable = true return
conf.Net.SASL.User = connOpt.UserName
conf.Net.SASL.Password = connOpt.Password
} }
mqIns.producerIns, err = sarama.NewAsyncProducer(brokers, conf) mqIns.producerIns, err = sarama.NewAsyncProducer(brokers, conf)
@@ -209,6 +213,63 @@ func doRegisterKafkaProducer(connOpt KafkaConfig, mqIns *KafkaMq) (err error) {
return 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 验证版本是否有效 // validateVersion 验证版本是否有效
func validateVersion(version sarama.KafkaVersion) bool { func validateVersion(version sarama.KafkaVersion) bool {
for _, item := range sarama.SupportedVersions { for _, item := range sarama.SupportedVersions {
+51 -7
View File
@@ -11,6 +11,8 @@ import (
"github.com/gogf/gf/v2/os/gctx" "github.com/gogf/gf/v2/os/gctx"
"hotgo/internal/library/queue/disk" "hotgo/internal/library/queue/disk"
"hotgo/utility/charset" "hotgo/utility/charset"
"os"
"strings"
"sync" "sync"
"time" "time"
) )
@@ -59,6 +61,12 @@ type KafkaConf struct {
Version string `json:"version"` Version string `json:"version"`
RandClient bool `json:"randClient"` RandClient bool `json:"randClient"`
MultiConsumer bool `json:"multiConsumer"` 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 { type MqMsg struct {
@@ -85,6 +93,7 @@ func init() {
if err := g.Cfg().MustGet(ctx, "queue").Scan(&config); err != nil { if err := g.Cfg().MustGet(ctx, "queue").Scan(&config); err != nil {
Logger().Warningf(ctx, "queue init err:%+v", err) Logger().Warningf(ctx, "queue init err:%+v", err)
} }
normalizeKafkaConf()
} }
// InstanceConsumer 实例化消费者 // InstanceConsumer 实例化消费者
@@ -121,9 +130,15 @@ func NewProducer(groupName string) (mqClient MqProducer, err error) {
return return
} }
mqClient, err = RegisterKafkaProducer(KafkaConfig{ mqClient, err = RegisterKafkaProducer(KafkaConfig{
Brokers: config.Kafka.Address, Brokers: config.Kafka.Address,
GroupID: groupName, GroupID: groupName,
Version: config.Kafka.Version, 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": case "redis":
if _, err = g.Redis().Do(ctx, "ping"); err == nil { 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{ mqClient, err = RegisterKafkaMqConsumer(KafkaConfig{
Brokers: config.Kafka.Address, Brokers: config.Kafka.Address,
GroupID: groupName, GroupID: groupName,
Version: config.Kafka.Version, Version: config.Kafka.Version,
ClientId: clientId, ClientId: clientId,
UserName: config.Kafka.UserName,
Password: config.Kafka.Password,
SASLEnabled: config.Kafka.SASLEnabled,
SASLMechanism: config.Kafka.SASLMechanism,
TLSEnabled: config.Kafka.TLSEnabled,
}) })
case "redis": case "redis":
if _, err = g.Redis().Do(ctx, "ping"); err == nil { if _, err = g.Redis().Do(ctx, "ping"); err == nil {
@@ -213,6 +233,30 @@ func NewConsumer(groupName string) (mqClient MqConsumer, err error) {
return 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 返回消息体 // BodyString 返回消息体
func (m *MqMsg) BodyString() string { func (m *MqMsg) BodyString() string {
return string(m.Body) return string(m.Body)
+203
View File
@@ -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,
}
}
+222
View File
@@ -7,9 +7,14 @@ package sys
import ( import (
"context" "context"
"hotgo/internal/consts"
"hotgo/internal/library/contexts"
"hotgo/internal/library/imkafka"
"hotgo/internal/library/imtenant" "hotgo/internal/library/imtenant"
"hotgo/internal/model/input/sysin" "hotgo/internal/model/input/sysin"
"hotgo/internal/service" "hotgo/internal/service"
"strconv"
"time"
"github.com/gogf/gf/v2/database/gdb" "github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/errors/gerror" "github.com/gogf/gf/v2/errors/gerror"
@@ -454,3 +459,220 @@ func (s *sSysImGroup) AdminLogList(ctx context.Context, in *sysin.ImGroupAdminLo
} }
return 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
}
@@ -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
}
@@ -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
}
@@ -2,8 +2,10 @@ package sys
import ( import (
"context" "context"
"hotgo/internal/consts"
"hotgo/internal/dao" "hotgo/internal/dao"
"hotgo/internal/library/contexts" "hotgo/internal/library/contexts"
"hotgo/internal/library/imkafka"
"hotgo/internal/model/input/sysin" "hotgo/internal/model/input/sysin"
"hotgo/internal/service" "hotgo/internal/service"
@@ -11,6 +13,7 @@ import (
"github.com/gogf/gf/v2/errors/gerror" "github.com/gogf/gf/v2/errors/gerror"
"github.com/gogf/gf/v2/frame/g" "github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/os/gtime" "github.com/gogf/gf/v2/os/gtime"
"github.com/gogf/gf/v2/util/gconv"
) )
const ( const (
@@ -62,6 +65,24 @@ func (s *sSysImRegistration) UpdateConfig(ctx context.Context, in *sysin.ImRegis
"old": old.RegisterSmsEnabled, "old": old.RegisterSmsEnabled,
"new": in.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 return
} }
+38 -1
View File
@@ -7,10 +7,12 @@ package sys
import ( import (
"context" "context"
"hotgo/internal/consts"
"hotgo/internal/dao" "hotgo/internal/dao"
"hotgo/internal/library/contexts" "hotgo/internal/library/contexts"
"hotgo/internal/library/hgorm/handler" "hotgo/internal/library/hgorm/handler"
"hotgo/internal/library/hgorm/hook" "hotgo/internal/library/hgorm/hook"
"hotgo/internal/library/imkafka"
"hotgo/internal/library/imtenant" "hotgo/internal/library/imtenant"
"hotgo/internal/model/entity" "hotgo/internal/model/entity"
"hotgo/internal/model/input/sysin" "hotgo/internal/model/input/sysin"
@@ -19,6 +21,7 @@ import (
"github.com/gogf/gf/v2/database/gdb" "github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/errors/gerror" "github.com/gogf/gf/v2/errors/gerror"
"github.com/gogf/gf/v2/frame/g" "github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/util/gconv"
) )
type sSysImTenant struct{} type sSysImTenant struct{}
@@ -142,7 +145,7 @@ func (s *sSysImTenant) Edit(ctx context.Context, in *sysin.ImTenantEditInp) (err
return 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 { if err = s.checkCodeUnique(ctx, in.Id, in.Code); err != nil {
return 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) 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) { 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租户状态失败,请稍后重试!") err = gerror.Wrap(err, "更新IM租户状态失败,请稍后重试!")
return 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 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)
}
}
+101
View File
@@ -7,9 +7,13 @@ package sys
import ( import (
"context" "context"
"hotgo/internal/consts"
"hotgo/internal/library/contexts"
"hotgo/internal/library/imkafka"
"hotgo/internal/library/imtenant" "hotgo/internal/library/imtenant"
"hotgo/internal/model/input/sysin" "hotgo/internal/model/input/sysin"
"hotgo/internal/service" "hotgo/internal/service"
"strconv"
"strings" "strings"
"github.com/gogf/gf/v2/database/gdb" "github.com/gogf/gf/v2/database/gdb"
@@ -187,6 +191,103 @@ func (s *sSysImUser) View(ctx context.Context, in *sysin.ImUserViewInp) (res *sy
return 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{} { func (s *sSysImUser) viewFields() []interface{} {
return []interface{}{ return []interface{}{
"u.id user_id", "u.id user_id",
@@ -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
}
@@ -288,3 +288,72 @@ type ImGroupAdminLogListModel struct {
IdempotencyKey string `json:"idempotencyKey" dc:"幂等键"` IdempotencyKey string `json:"idempotencyKey" dc:"幂等键"`
CreatedAt *gtime.Time `json:"createdAt" 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:"版本"`
}
@@ -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
}
@@ -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
}
@@ -78,3 +78,45 @@ type ImUserViewModel struct {
AccessJson string `json:"accessJson" dc:"访问权限"` AccessJson string `json:"accessJson" dc:"访问权限"`
ProfileVersion int `json:"profileVersion" 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
}
@@ -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)
}
+54
View File
@@ -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
}
+74
View File
@@ -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 ""
}
@@ -0,0 +1,7 @@
package genrouter
import "hotgo/internal/controller/admin/sys"
func init() {
LoginRequiredRouter = append(LoginRequiredRouter, sys.ImDeadLetter) // IM Kafka死信
}
@@ -0,0 +1,7 @@
package genrouter
import "hotgo/internal/controller/admin/sys"
func init() {
LoginRequiredRouter = append(LoginRequiredRouter, sys.ImKafkaAudit) // IM Kafka审计事件
}
@@ -0,0 +1,7 @@
package genrouter
import "hotgo/internal/controller/admin/sys"
func init() {
LoginRequiredRouter = append(LoginRequiredRouter, sys.ImKafkaCommand) // IM后台命令
}
+18
View File
@@ -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,
)
})
}
+96
View File
@@ -210,6 +210,22 @@ type (
List(ctx context.Context, in *sysin.ImUserListInp) (list []*sysin.ImUserListModel, totalCount int, err error) List(ctx context.Context, in *sysin.ImUserListInp) (list []*sysin.ImUserListModel, totalCount int, err error)
// View 获取IM用户指定信息 // View 获取IM用户指定信息
View(ctx context.Context, in *sysin.ImUserViewInp) (res *sysin.ImUserViewModel, err error) 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 { ISysImGroup interface {
// List 获取IM群组列表 // List 获取IM群组列表
@@ -230,6 +246,22 @@ type (
MuteList(ctx context.Context, in *sysin.ImGroupMuteListInp) (list []*sysin.ImGroupMuteListModel, totalCount int, err error) MuteList(ctx context.Context, in *sysin.ImGroupMuteListInp) (list []*sysin.ImGroupMuteListModel, totalCount int, err error)
// AdminLogList 获取IM群管理日志列表 // AdminLogList 获取IM群管理日志列表
AdminLogList(ctx context.Context, in *sysin.ImGroupAdminLogListInp) (list []*sysin.ImGroupAdminLogListModel, totalCount int, err error) 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 { ISysImRealname interface {
// List 获取IM实名审核列表 // List 获取IM实名审核列表
@@ -253,6 +285,34 @@ type (
// View 获取IM审计日志指定信息 // View 获取IM审计日志指定信息
View(ctx context.Context, in *sysin.ImAuditLogViewInp) (res *sysin.ImAuditLogViewModel, err error) 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 { ISysDictData interface {
// Delete 删除 // Delete 删除
Delete(ctx context.Context, in *sysin.DictDataDeleteInp) error Delete(ctx context.Context, in *sysin.DictDataDeleteInp) error
@@ -504,6 +564,9 @@ var (
localSysImRealname ISysImRealname localSysImRealname ISysImRealname
localSysImWorkOrder ISysImWorkOrder localSysImWorkOrder ISysImWorkOrder
localSysImAuditLog ISysImAuditLog localSysImAuditLog ISysImAuditLog
localSysImKafkaAudit ISysImKafkaAudit
localSysImKafkaCommand ISysImKafkaCommand
localSysImDeadLetter ISysImDeadLetter
localSysDictData ISysDictData localSysDictData ISysDictData
localSysDictType ISysDictType localSysDictType ISysDictType
localSysEmsLog ISysEmsLog localSysEmsLog ISysEmsLog
@@ -695,6 +758,39 @@ func RegisterSysImAuditLog(i ISysImAuditLog) {
localSysImAuditLog = i 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 { func SysDictData() ISysDictData {
if localSysDictData == nil { if localSysDictData == nil {
panic("implement not found for interface ISysDictData, forgot register?") panic("implement not found for interface ISysDictData, forgot register?")
+17 -1
View File
@@ -212,10 +212,16 @@ queue:
retry: 0 # 重试次数 retry: 0 # 重试次数
logLevel: "info" # rocketmq日志输出级别,可选:all|close|debug|info|warn|error|fatal logLevel: "info" # rocketmq日志输出级别,可选:all|close|debug|info|warn|error|fatal
kafka: 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 version: "2.0.0.0" # kafka专属配置,默认2.0.0.0
randClient: true # 开启随机生成clientID,可以实现启动多实例同时一起消费相同topic,加速消费能力的特性,默认为true randClient: true # 开启随机生成clientID,可以实现启动多实例同时一起消费相同topic,加速消费能力的特性,默认为true
multiConsumer: 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 # Redis. 配置参考:https://goframe.org/pages/viewpage.action?pageId=1114217
@@ -310,3 +316,13 @@ imAdminApi:
serviceId: "hotgo-admin" serviceId: "hotgo-admin"
serviceSecret: "${IM_ADMIN_API_SECRET}" serviceSecret: "${IM_ADMIN_API_SECRET}"
timeout: "5s" 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"
@@ -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');
@@ -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`) 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 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'); 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');
@@ -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';
@@ -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');
@@ -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');
@@ -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审计事件';
@@ -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`) 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 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'); 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');
+46
View File
@@ -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,
});
}
+72
View File
@@ -80,3 +80,75 @@ export function AdminLogList(params) {
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,
});
}
+17
View File
@@ -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,
});
}
+37
View File
@@ -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,
});
}
+32
View File
@@ -17,3 +17,35 @@ export function View(params) {
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 });
}
+88
View File
@@ -0,0 +1,88 @@
<template>
<div>
<div class="n-layout-page-header">
<n-card :bordered="false" title="IM Kafka死信"></n-card>
</div>
<n-card :bordered="false" class="proCard">
<BasicForm
ref="searchFormRef"
@register="register"
@submit="reloadTable"
@reset="reloadTable"
@keyup.enter="reloadTable"
/>
<BasicTable
ref="actionRef"
:columns="columns"
:request="loadDataTable"
:row-key="(row) => row.deadId"
:actionColumn="actionColumn"
:scroll-x="scrollX"
:resizeHeightOffset="-10000"
/>
</n-card>
<ViewModal ref="viewRef" @reload="reloadTable" />
</div>
</template>
<script lang="ts" setup>
import { computed, h, onMounted, reactive, ref } from 'vue';
import { BasicTable, TableAction } from '@/components/Table';
import { BasicForm, useForm } from '@/components/Form/index';
import { List } from '@/api/imDeadLetter';
import { columns, loadOptions, loadTenantOptions, schemas, State } from './model';
import { adaTableScrollX } from '@/utils/hotgo';
import ViewModal from './view.vue';
const actionRef = ref();
const searchFormRef = ref<any>({});
const viewRef = ref();
const cursor = ref('');
const hasMore = ref(false);
const actionColumn = reactive({
width: 110,
title: '操作',
key: 'action',
fixed: 'right',
render(record: State) {
return h(TableAction as any, {
style: 'button',
actions: [{ label: '详情', onClick: () => viewRef.value.openModal(record, currentTenantId.value), auth: ['/imDeadLetter/view'] }],
});
},
});
const scrollX = computed(() => adaTableScrollX(columns, actionColumn.width));
const currentTenantId = computed(() => searchFormRef.value?.formModel?.tenantId || 0);
const [register] = useForm({
gridProps: { cols: '1 s:1 m:2 l:3 xl:4 2xl:4' },
labelWidth: 90,
schemas,
});
const loadDataTable = async (res) => {
if (!currentTenantId.value) {
return { list: [], totalCount: 0 };
}
const params = { ...searchFormRef.value?.formModel, limit: res.pageSize || res.perPage || 50, cursor: cursor.value };
const data = await List(params);
hasMore.value = !!data?.hasMore;
cursor.value = data?.nextCursor || '';
return { list: data?.list || [], totalCount: data?.list?.length || 0 };
};
function reloadTable() {
cursor.value = '';
hasMore.value = false;
actionRef.value?.reload();
}
onMounted(async () => {
loadOptions();
await loadTenantOptions();
});
</script>
<style lang="less" scoped></style>
+122
View File
@@ -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<any[]>([]);
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<State>) {
if (state) Object.assign(this, state);
}
}
export function newState(state: State | Record<string, any> | null): State {
if (state !== null) {
if (state instanceof State) return cloneDeep(state);
return new State(state);
}
return new State();
}
export const schemas = ref<FormSchema[]>([
{
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;
}
+123
View File
@@ -0,0 +1,123 @@
<template>
<div>
<n-modal
v-model:show="showModal"
:mask-closable="false"
:show-icon="false"
preset="dialog"
transform-origin="center"
:title="'死信详情 #' + formValue.id"
:style="{ width: dialogWidth }"
>
<n-scrollbar style="max-height: 87vh" class="pr-5">
<n-spin :show="loading" description="请稍候...">
<n-descriptions label-placement="left" bordered :column="settingStore.isMobile ? 1 : 2">
<n-descriptions-item label="死信ID">{{ formValue.deadId }}</n-descriptions-item>
<n-descriptions-item label="状态">{{ statusLabel }}</n-descriptions-item>
<n-descriptions-item label="事件ID">{{ formValue.eventId }}</n-descriptions-item>
<n-descriptions-item label="父事件ID">{{ formValue.parentEventId || '-' }}</n-descriptions-item>
<n-descriptions-item label="来源Topic">{{ formValue.sourceTopic || '-' }}</n-descriptions-item>
<n-descriptions-item label="消费组">{{ formValue.consumerGroup || '-' }}</n-descriptions-item>
<n-descriptions-item label="事件类型">{{ formValue.eventType || '-' }}</n-descriptions-item>
<n-descriptions-item label="业务对象">{{ formValue.bizType }} / {{ formValue.bizId }}</n-descriptions-item>
<n-descriptions-item label="重试次数">{{ formValue.retryCount }}</n-descriptions-item>
<n-descriptions-item label="重放次数">{{ formValue.replayCount }}</n-descriptions-item>
<n-descriptions-item label="最后原因">{{ formValue.lastReason || '-' }}</n-descriptions-item>
<n-descriptions-item label="最后错误" :span="2">{{ formValue.lastError || '-' }}</n-descriptions-item>
<n-descriptions-item label="Envelope" :span="2">
<n-code :code="formValue.envelope || formValue.envelopeRaw || '-'" language="json" word-wrap />
</n-descriptions-item>
</n-descriptions>
<n-form class="mt-4" label-placement="left" :label-width="110">
<n-form-item label="操作IM用户ID">
<n-input-number v-model:value="actionForm.actorUserId" clearable class="w-full" />
</n-form-item>
<n-form-item label="处置备注">
<n-input v-model:value="actionForm.note" type="textarea" clearable placeholder="请输入备注" />
</n-form-item>
</n-form>
</n-spin>
</n-scrollbar>
<template #action>
<n-space>
<n-button @click="closeForm">关闭</n-button>
<n-button type="default" :loading="submitting" @click="submitAction('ignore')">忽略</n-button>
<n-button type="default" :loading="submitting" @click="submitAction('resolve')">标记已处理</n-button>
<n-button type="primary" :loading="submitting" @click="submitAction('replay')">重放</n-button>
</n-space>
</template>
</n-modal>
</div>
</template>
<script lang="ts" setup>
import { computed, reactive, ref } from 'vue';
import { useDictStore } from '@/store/modules/dict';
import { Ignore, Replay, Resolve, View } from '@/api/imDeadLetter';
import { newState, State } from './model';
import { useProjectSettingStore } from '@/store/modules/projectSetting';
import { adaModalWidth } from '@/utils/hotgo';
import { useMessage } from 'naive-ui';
const emit = defineEmits(['reload']);
const settingStore = useProjectSettingStore();
const dict = useDictStore();
const message = useMessage();
const loading = ref(false);
const submitting = ref(false);
const showModal = ref(false);
const formValue = ref<State>(newState(null));
const tenantId = ref(0);
const actionForm = reactive({
actorUserId: null as number | null,
note: '',
});
const dialogWidth = computed(() => adaModalWidth(980));
const statusLabel = computed(() => dict.getLabel('ImKafkaDeadLetterStatusOptions', formValue.value.status) || '-');
function closeForm() {
showModal.value = false;
loading.value = false;
submitting.value = false;
}
function openModal(state: State, currentTenantId: number) {
tenantId.value = currentTenantId;
showModal.value = true;
loading.value = true;
actionForm.actorUserId = null;
actionForm.note = '';
View({ tenantId: currentTenantId, deadId: state.deadId })
.then((res) => {
formValue.value = newState(res);
})
.finally(() => {
loading.value = false;
});
}
async function submitAction(type: 'replay' | 'ignore' | 'resolve') {
const params = {
tenantId: tenantId.value,
deadId: formValue.value.deadId,
actorUserId: actionForm.actorUserId,
note: actionForm.note,
};
submitting.value = true;
try {
const api = type === 'replay' ? Replay : type === 'ignore' ? Ignore : Resolve;
const res = await api(params);
message.success(`命令已提交:${res?.commandId || '-'}`);
emit('reload');
closeForm();
} finally {
submitting.value = false;
}
}
defineExpose({
openModal,
});
</script>
<style lang="less"></style>
+137 -1
View File
@@ -12,6 +12,44 @@
<n-tabs v-model:value="activeTab" type="line" animated> <n-tabs v-model:value="activeTab" type="line" animated>
<n-tab-pane name="settings" tab="治理配置"> <n-tab-pane name="settings" tab="治理配置">
<n-spin :show="loading" description="请稍候..."> <n-spin :show="loading" description="请稍候...">
<n-form
ref="commandFormRef"
:model="commandForm"
label-placement="left"
:label-width="110"
class="governance-command-form"
>
<n-grid :cols="settingStore.isMobile ? 1 : 2" :x-gap="16">
<n-form-item-gi label="操作IM用户ID" path="actorUserId">
<n-input-number v-model:value="commandForm.actorUserId" clearable />
</n-form-item-gi>
<n-form-item-gi label="版本" path="version">
<n-input-number v-model:value="commandForm.version" disabled />
</n-form-item-gi>
<n-form-item-gi label="群名称" path="name">
<n-input v-model:value="commandForm.name" clearable />
</n-form-item-gi>
<n-form-item-gi label="头像文件ID" path="avatarFileId">
<n-input v-model:value="commandForm.avatarFileId" clearable />
</n-form-item-gi>
<n-form-item-gi label="简介" path="description">
<n-input v-model:value="commandForm.description" type="textarea" clearable />
</n-form-item-gi>
<n-form-item-gi label="治理开关">
<n-space>
<n-checkbox v-model:checked="commandForm.joinApproval">入群审批</n-checkbox>
<n-checkbox v-model:checked="commandForm.muteAll">全员禁言</n-checkbox>
<n-checkbox v-model:checked="commandForm.memberUploadFiles">成员上传文件</n-checkbox>
<n-checkbox v-model:checked="commandForm.memberPrivateChat">成员私聊</n-checkbox>
<n-checkbox v-model:checked="commandForm.memberInvite">成员邀请</n-checkbox>
</n-space>
</n-form-item-gi>
</n-grid>
<n-space>
<n-button type="primary" :loading="submitting" @click="submitProfile">提交资料命令</n-button>
<n-button type="primary" :loading="submitting" @click="submitGovernance">提交治理命令</n-button>
</n-space>
</n-form>
<n-descriptions label-placement="left" bordered :column="settingStore.isMobile ? 1 : 2"> <n-descriptions label-placement="left" bordered :column="settingStore.isMobile ? 1 : 2">
<n-descriptions-item label="群名称">{{ governance.name || '-' }}</n-descriptions-item> <n-descriptions-item label="群名称">{{ governance.name || '-' }}</n-descriptions-item>
<n-descriptions-item label="业务状态">{{ groupStatusLabel }}</n-descriptions-item> <n-descriptions-item label="业务状态">{{ groupStatusLabel }}</n-descriptions-item>
@@ -95,15 +133,18 @@
<script lang="ts" setup> <script lang="ts" setup>
import { computed, nextTick, ref, watch } from 'vue'; import { computed, nextTick, ref, watch } from 'vue';
import { useDialog, useMessage } from 'naive-ui';
import { useDictStore } from '@/store/modules/dict'; import { useDictStore } from '@/store/modules/dict';
import { BasicTable } from '@/components/Table'; import { BasicTable } from '@/components/Table';
import { import {
AdminLogList, AdminLogList,
AnnouncementList, AnnouncementList,
Governance, Governance,
GovernanceUpdate,
InvitationList, InvitationList,
JoinRequestList, JoinRequestList,
MuteList, MuteList,
ProfileUpdate,
} from '@/api/imGroup'; } from '@/api/imGroup';
import { import {
adminLogColumns, adminLogColumns,
@@ -120,11 +161,27 @@
const settingStore = useProjectSettingStore(); const settingStore = useProjectSettingStore();
const dict = useDictStore(); const dict = useDictStore();
const message = useMessage();
const dialog = useDialog();
const showModal = ref(false); const showModal = ref(false);
const loading = ref(false); const loading = ref(false);
const submitting = ref(false);
const activeTab = ref('settings'); const activeTab = ref('settings');
const current = ref<State>(newState(null)); const current = ref<State>(newState(null));
const governance = ref<GovernanceState>(new GovernanceState()); const governance = ref<GovernanceState>(new GovernanceState());
const commandFormRef = ref();
const commandForm = ref({
actorUserId: 0,
version: 0,
name: '',
avatarFileId: '',
description: '',
joinApproval: false,
muteAll: false,
memberUploadFiles: false,
memberPrivateChat: false,
memberInvite: false,
});
const dialogWidth = computed(() => adaModalWidth(1120)); const dialogWidth = computed(() => adaModalWidth(1120));
const groupStatusLabel = computed(() => dict.getLabel('ImGroupStatusOptions', governance.value.groupStatus) || '-'); const groupStatusLabel = computed(() => dict.getLabel('ImGroupStatusOptions', governance.value.groupStatus) || '-');
@@ -178,11 +235,86 @@
try { try {
const res = await Governance({ tenantId: current.value.tenantId, topic: current.value.topic }); const res = await Governance({ tenantId: current.value.tenantId, topic: current.value.topic });
governance.value = Object.assign(new GovernanceState(), res || {}); governance.value = Object.assign(new GovernanceState(), res || {});
fillCommandForm();
} finally { } finally {
loading.value = false; loading.value = false;
} }
} }
function fillCommandForm() {
commandForm.value = {
actorUserId: governance.value.ownerUserId || current.value.ownerId || 0,
version: governance.value.version || current.value.settingsVersion || 0,
name: governance.value.name || current.value.groupName || '',
avatarFileId: governance.value.avatarFileId || current.value.avatarFileId || '',
description: governance.value.description || current.value.description || '',
joinApproval: !!governance.value.joinApproval,
muteAll: !!governance.value.muteAll,
memberUploadFiles: !!governance.value.memberUploadFiles,
memberPrivateChat: !!governance.value.memberPrivateChat,
memberInvite: !!governance.value.memberInvite,
};
}
function commandBase() {
if (!commandForm.value.actorUserId) {
message.warning('请填写操作IM用户ID');
return null;
}
return {
tenantId: current.value.tenantId,
topic: current.value.topic,
actorUserId: String(commandForm.value.actorUserId),
version: commandForm.value.version,
};
}
async function submitProfile() {
const base = commandBase();
if (!base) return;
submitting.value = true;
try {
const res = await ProfileUpdate({
...base,
name: commandForm.value.name,
avatarFileId: commandForm.value.avatarFileId,
description: commandForm.value.description,
});
showCommandResult(res);
} finally {
submitting.value = false;
}
}
async function submitGovernance() {
const base = commandBase();
if (!base) return;
submitting.value = true;
try {
const res = await GovernanceUpdate({
...base,
joinApproval: commandForm.value.joinApproval,
muteAll: commandForm.value.muteAll,
memberUploadFiles: commandForm.value.memberUploadFiles,
memberPrivateChat: commandForm.value.memberPrivateChat,
memberInvite: commandForm.value.memberInvite,
});
showCommandResult(res);
} finally {
submitting.value = false;
}
}
function showCommandResult(res) {
dialog.success({
title: '命令已发送',
content: `CommandID:${res?.commandId || '-'},状态:${res?.status || '-'}`,
positiveText: '知道了',
});
loadGovernance();
reloadActiveTable();
}
function reloadActiveTable() { function reloadActiveTable() {
const refs = { const refs = {
joinRequests: joinRequestRef, joinRequests: joinRequestRef,
@@ -213,4 +345,8 @@
}); });
</script> </script>
<style lang="less"></style> <style lang="less" scoped>
.governance-command-form {
margin-bottom: 16px;
}
</style>
+111 -7
View File
@@ -9,11 +9,17 @@
:title="'群成员 ' + current.topic" :title="'群成员 ' + current.topic"
:style="{ width: dialogWidth }" :style="{ width: dialogWidth }"
> >
<n-form label-placement="left" :label-width="110" class="member-command-form">
<n-form-item label="操作IM用户ID">
<n-input-number v-model:value="actorUserId" clearable />
</n-form-item>
</n-form>
<BasicTable <BasicTable
ref="actionRef" ref="actionRef"
:columns="memberColumns" :columns="memberColumns"
:request="loadDataTable" :request="loadDataTable"
:row-key="(row) => row.userId" :row-key="(row) => row.userId"
:actionColumn="actionColumn"
:scroll-x="scrollX" :scroll-x="scrollX"
:resizeHeightOffset="-10000" :resizeHeightOffset="-10000"
/> />
@@ -27,17 +33,63 @@
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { computed, ref } from 'vue'; import { computed, h, reactive, ref } from 'vue';
import { BasicTable } from '@/components/Table'; import { useDialog, useMessage } from 'naive-ui';
import { MemberList } from '@/api/imGroup'; import { BasicTable, TableAction } from '@/components/Table';
import { memberColumns, newState, State } from './model'; import { AdminRemove, AdminSet, MemberList, MemberMute, MemberRemove, MemberUnmute } from '@/api/imGroup';
import { MemberState, memberColumns, newState, State } from './model';
import { adaModalWidth, adaTableScrollX } from '@/utils/hotgo'; import { adaModalWidth, adaTableScrollX } from '@/utils/hotgo';
const dialog = useDialog();
const message = useMessage();
const showModal = ref(false); const showModal = ref(false);
const current = ref<State>(newState(null)); const current = ref<State>(newState(null));
const actorUserId = ref<number | null>(null);
const actionRef = ref(); const actionRef = ref();
const dialogWidth = computed(() => adaModalWidth(980)); const dialogWidth = computed(() => adaModalWidth(1120));
const scrollX = computed(() => adaTableScrollX(memberColumns, 0));
const actionColumn = reactive({
width: 300,
title: '操作',
key: 'action',
fixed: 'right',
render(record: MemberState) {
return h(TableAction as any, {
style: 'button',
actions: [
{
label: '设管理员',
onClick: () => submitMemberCommand('setAdmin', record),
auth: ['/imGroup/adminSet'],
ifShow: record.role !== 'admin' && record.role !== 'owner',
},
{
label: '删管理员',
onClick: () => submitMemberCommand('removeAdmin', record),
auth: ['/imGroup/adminRemove'],
ifShow: record.role === 'admin',
},
{
label: '禁言',
onClick: () => submitMemberCommand('mute', record),
auth: ['/imGroup/memberMute'],
},
{
label: '解禁',
onClick: () => submitMemberCommand('unmute', record),
auth: ['/imGroup/memberUnmute'],
},
{
label: '移出',
onClick: () => submitMemberCommand('remove', record),
auth: ['/imGroup/memberRemove'],
},
],
});
},
});
const scrollX = computed(() => adaTableScrollX(memberColumns, actionColumn.width));
// 加载表格数据 // 加载表格数据
const loadDataTable = async (res) => { const loadDataTable = async (res) => {
@@ -56,9 +108,57 @@
showModal.value = false; showModal.value = false;
} }
function baseCommand(record: MemberState) {
if (!actorUserId.value) {
message.warning('请先填写操作IM用户ID');
return null;
}
return {
tenantId: current.value.tenantId,
topic: current.value.topic,
actorUserId: String(actorUserId.value),
userId: String(record.userId),
};
}
function submitMemberCommand(type: string, record: MemberState) {
const labels = {
setAdmin: '设置管理员',
removeAdmin: '删除管理员',
mute: '禁言成员',
unmute: '解除禁言',
remove: '移出群成员',
};
dialog.warning({
title: labels[type],
content: `确认对用户 ${record.userId} 执行该操作?`,
positiveText: '确认',
negativeText: '取消',
onPositiveClick: async () => {
const params = baseCommand(record);
if (!params) return false;
let res: any;
if (type === 'setAdmin') {
res = await AdminSet({ ...params, userIds: [String(record.userId)] });
} else if (type === 'removeAdmin') {
res = await AdminRemove(params);
} else if (type === 'mute') {
res = await MemberMute(params);
} else if (type === 'unmute') {
res = await MemberUnmute(params);
} else {
res = await MemberRemove(params);
}
message.success(`命令已发送:${res?.commandId || '-'}`);
actionRef.value?.reload();
},
});
}
// 打开弹窗 // 打开弹窗
function openModal(state: State) { function openModal(state: State) {
current.value = newState(state); current.value = newState(state);
actorUserId.value = current.value.ownerId || null;
showModal.value = true; showModal.value = true;
setTimeout(() => { setTimeout(() => {
actionRef.value?.reload(); actionRef.value?.reload();
@@ -70,4 +170,8 @@
}); });
</script> </script>
<style lang="less"></style> <style lang="less" scoped>
.member-command-form {
margin-bottom: 12px;
}
</style>
+72
View File
@@ -0,0 +1,72 @@
<template>
<div>
<div class="n-layout-page-header">
<n-card :bordered="false" title="IM Kafka审计事件"></n-card>
</div>
<n-card :bordered="false" class="proCard">
<BasicForm
ref="searchFormRef"
@register="register"
@submit="reloadTable"
@reset="reloadTable"
@keyup.enter="reloadTable"
/>
<BasicTable
ref="actionRef"
:columns="columns"
:request="loadDataTable"
:row-key="(row) => row.id"
:actionColumn="actionColumn"
:scroll-x="scrollX"
:resizeHeightOffset="-10000"
/>
</n-card>
<ViewModal ref="viewRef" />
</div>
</template>
<script lang="ts" setup>
import { computed, h, reactive, ref } from 'vue';
import { BasicTable, TableAction } from '@/components/Table';
import { BasicForm, useForm } from '@/components/Form/index';
import { List } from '@/api/imKafkaAudit';
import { columns, schemas, State } from './model';
import { adaTableScrollX } from '@/utils/hotgo';
import ViewModal from './view.vue';
const actionRef = ref();
const searchFormRef = ref<any>({});
const viewRef = ref();
const actionColumn = reactive({
width: 100,
title: '操作',
key: 'action',
fixed: 'right',
render(record: State) {
return h(TableAction as any, {
style: 'button',
actions: [{ label: '详情', onClick: () => viewRef.value.openModal(record), auth: ['/imKafkaAudit/view'] }],
});
},
});
const scrollX = computed(() => adaTableScrollX(columns, actionColumn.width));
const [register] = useForm({
gridProps: { cols: '1 s:1 m:2 l:3 xl:4 2xl:4' },
labelWidth: 90,
schemas,
});
const loadDataTable = async (res) => {
const params = { ...searchFormRef.value?.formModel, ...res };
return await List(params);
};
function reloadTable() {
actionRef.value?.reload();
}
</script>
<style lang="less" scoped></style>
+106
View File
@@ -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<State>) {
if (state) Object.assign(this, state);
}
}
export function newState(state: State | Record<string, any> | null): State {
if (state !== null) {
if (state instanceof State) return cloneDeep(state);
return new State(state);
}
return new State();
}
export const schemas = ref<FormSchema[]>([
{
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 },
];
+79
View File
@@ -0,0 +1,79 @@
<template>
<div>
<n-modal
v-model:show="showModal"
:mask-closable="false"
:show-icon="false"
preset="dialog"
transform-origin="center"
:title="'Kafka审计事件 #' + formValue.id"
:style="{ width: dialogWidth }"
>
<n-scrollbar style="max-height: 87vh" class="pr-5">
<n-spin :show="loading" description="请稍候...">
<n-descriptions label-placement="left" bordered :column="settingStore.isMobile ? 1 : 2">
<n-descriptions-item label="租户">{{ formValue.tenantCode || '-' }}</n-descriptions-item>
<n-descriptions-item label="事件类型">{{ formValue.eventType || '-' }}</n-descriptions-item>
<n-descriptions-item label="事件ID">{{ formValue.eventId || '-' }}</n-descriptions-item>
<n-descriptions-item label="业务ID">{{ formValue.bizId || '-' }}</n-descriptions-item>
<n-descriptions-item label="操作者">{{ formValue.actorType || '-' }} / {{ formValue.actorId || '-' }}</n-descriptions-item>
<n-descriptions-item label="动作">{{ formValue.action || '-' }}</n-descriptions-item>
<n-descriptions-item label="资源">{{ formValue.resource || '-' }} / {{ formValue.resourceId || '-' }}</n-descriptions-item>
<n-descriptions-item label="结果">{{ formValue.result || '-' }}</n-descriptions-item>
<n-descriptions-item label="原因" :span="2">{{ formValue.reason || '-' }}</n-descriptions-item>
<n-descriptions-item label="发生时间">{{ formValue.occurredAt || '-' }}</n-descriptions-item>
<n-descriptions-item label="接收时间">{{ formValue.receivedAt || '-' }}</n-descriptions-item>
<n-descriptions-item label="Payload" :span="2">
<n-code :code="formValue.payload || '-'" language="json" word-wrap />
</n-descriptions-item>
<n-descriptions-item label="Envelope" :span="2">
<n-code :code="formValue.envelope || '-'" language="json" word-wrap />
</n-descriptions-item>
</n-descriptions>
</n-spin>
</n-scrollbar>
<template #action>
<n-space>
<n-button @click="closeForm">关闭</n-button>
</n-space>
</template>
</n-modal>
</div>
</template>
<script lang="ts" setup>
import { computed, ref } from 'vue';
import { View } from '@/api/imKafkaAudit';
import { newState, State } from './model';
import { useProjectSettingStore } from '@/store/modules/projectSetting';
import { adaModalWidth } from '@/utils/hotgo';
const settingStore = useProjectSettingStore();
const loading = ref(false);
const showModal = ref(false);
const formValue = ref<State>(newState(null));
const dialogWidth = computed(() => adaModalWidth(980));
function closeForm() {
showModal.value = false;
loading.value = false;
}
function openModal(state: State) {
showModal.value = true;
loading.value = true;
View({ id: state.id })
.then((res) => {
formValue.value = newState(res);
})
.finally(() => {
loading.value = false;
});
}
defineExpose({
openModal,
});
</script>
<style lang="less"></style>
+127
View File
@@ -0,0 +1,127 @@
<template>
<div>
<div class="n-layout-page-header">
<n-card :bordered="false" title="IM后台命令"></n-card>
</div>
<n-card :bordered="false" class="proCard">
<BasicForm
ref="searchFormRef"
@register="register"
@submit="reloadTable"
@reset="reloadTable"
@keyup.enter="reloadTable"
/>
<n-space class="table-toolbar">
<n-button type="primary" @click="sendCacheInvalidate">发送缓存失效测试</n-button>
<n-button @click="sendWorkbenchEvent">发送工作台测试事件</n-button>
</n-space>
<BasicTable
ref="actionRef"
:columns="columns"
:request="loadDataTable"
:row-key="(row) => row.id"
:actionColumn="actionColumn"
:scroll-x="scrollX"
:resizeHeightOffset="-10000"
/>
</n-card>
<ViewModal ref="viewRef" />
</div>
</template>
<script lang="ts" setup>
import { computed, h, onMounted, reactive, ref } from 'vue';
import { useMessage } from 'naive-ui';
import { BasicTable, TableAction } from '@/components/Table';
import { BasicForm, useForm } from '@/components/Form/index';
import { CacheInvalidate, List, WorkbenchEvent } from '@/api/imKafkaCommand';
import { columns, loadOptions, loadTenantOptions, schemas, State, tenantOptions } from './model';
import { adaTableScrollX } from '@/utils/hotgo';
import ViewModal from './view.vue';
const actionRef = ref();
const searchFormRef = ref<any>({});
const viewRef = ref();
const message = useMessage();
const actionColumn = reactive({
width: 100,
title: '操作',
key: 'action',
fixed: 'right',
render(record: State) {
return h(TableAction as any, {
style: 'button',
actions: [{ label: '详情', onClick: () => viewRef.value.openModal(record), auth: ['/imKafkaCommand/view'] }],
});
},
});
const scrollX = computed(() => adaTableScrollX(columns, actionColumn.width));
const [register] = useForm({
gridProps: { cols: '1 s:1 m:2 l:3 xl:4 2xl:4' },
labelWidth: 90,
schemas,
});
const loadDataTable = async (res) => {
const params = { ...searchFormRef.value?.formModel, ...res };
return await List(params);
};
function reloadTable() {
actionRef.value?.reload();
}
async function sendCacheInvalidate() {
const tenant = getFirstTenant();
if (!tenant) {
message.warning('暂无可用租户');
return;
}
const res = await CacheInvalidate({
tenantCode: tenant.code,
namespace: 'kafka_connectivity',
keys: ['healthcheck'],
reason: 'admin_connectivity_test',
});
message.success(`已发送缓存失效测试事件:${res?.eventId || ''}`);
reloadTable();
}
async function sendWorkbenchEvent() {
const tenant = getFirstTenant();
if (!tenant) {
message.warning('暂无可用租户');
return;
}
const res = await WorkbenchEvent({
tenantCode: tenant.code,
eventType: 'workbench.connectivity.test',
bizId: 'admin_connectivity_test',
payload: { message: 'admin workbench event connectivity test' },
});
message.success(`已发送工作台测试事件:${res?.eventId || ''}`);
}
function getFirstTenant() {
const tenant = tenantOptions.value[0];
if (!tenant) return null;
return {
...tenant,
code: tenant.code || String(tenant.label || '').match(/((.+))/)?.[1] || 'default',
};
}
onMounted(async () => {
loadOptions();
await loadTenantOptions();
});
</script>
<style lang="less" scoped>
.table-toolbar {
margin-bottom: 16px;
}
</style>
+139
View File
@@ -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<any[]>([]);
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<State>) {
if (state) Object.assign(this, state);
}
}
export function newState(state: State | Record<string, any> | null): State {
if (state !== null) {
if (state instanceof State) return cloneDeep(state);
return new State(state);
}
return new State();
}
export const schemas = ref<FormSchema[]>([
{
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;
}
+86
View File
@@ -0,0 +1,86 @@
<template>
<div>
<n-modal
v-model:show="showModal"
:mask-closable="false"
:show-icon="false"
preset="dialog"
transform-origin="center"
:title="'命令详情 #' + formValue.id"
:style="{ width: dialogWidth }"
>
<n-scrollbar style="max-height: 87vh" class="pr-5">
<n-spin :show="loading" description="请稍候...">
<n-descriptions label-placement="left" bordered :column="settingStore.isMobile ? 1 : 2">
<n-descriptions-item label="租户">{{ formValue.tenantCode || '-' }}</n-descriptions-item>
<n-descriptions-item label="状态">{{ statusLabel }}</n-descriptions-item>
<n-descriptions-item label="命令ID">{{ formValue.commandId }}</n-descriptions-item>
<n-descriptions-item label="事件ID">{{ formValue.eventId }}</n-descriptions-item>
<n-descriptions-item label="事件类型">{{ formValue.eventType }}</n-descriptions-item>
<n-descriptions-item label="业务对象">{{ formValue.bizType }} / {{ formValue.bizId }}</n-descriptions-item>
<n-descriptions-item label="幂等键">{{ formValue.idempotencyKey || '-' }}</n-descriptions-item>
<n-descriptions-item label="创建人">{{ formValue.createdBy || '-' }}</n-descriptions-item>
<n-descriptions-item label="投递时间">{{ formValue.sentAt || '-' }}</n-descriptions-item>
<n-descriptions-item label="完成时间">{{ formValue.completedAt || '-' }}</n-descriptions-item>
<n-descriptions-item label="错误码">{{ formValue.reason || '-' }}</n-descriptions-item>
<n-descriptions-item label="消息">{{ formValue.message || '-' }}</n-descriptions-item>
<n-descriptions-item label="Payload" :span="2">
<n-code :code="formValue.payload || '-'" language="json" word-wrap />
</n-descriptions-item>
<n-descriptions-item label="Envelope" :span="2">
<n-code :code="formValue.envelope || '-'" language="json" word-wrap />
</n-descriptions-item>
<n-descriptions-item label="Result" :span="2">
<n-code :code="formValue.result || '-'" language="json" word-wrap />
</n-descriptions-item>
</n-descriptions>
</n-spin>
</n-scrollbar>
<template #action>
<n-space>
<n-button @click="closeForm">关闭</n-button>
</n-space>
</template>
</n-modal>
</div>
</template>
<script lang="ts" setup>
import { computed, ref } from 'vue';
import { useDictStore } from '@/store/modules/dict';
import { View } from '@/api/imKafkaCommand';
import { newState, State } from './model';
import { useProjectSettingStore } from '@/store/modules/projectSetting';
import { adaModalWidth } from '@/utils/hotgo';
const settingStore = useProjectSettingStore();
const dict = useDictStore();
const loading = ref(false);
const showModal = ref(false);
const formValue = ref<State>(newState(null));
const dialogWidth = computed(() => adaModalWidth(980));
const statusLabel = computed(() => dict.getLabel('ImAdminCommandStatusOptions', formValue.value.status) || '-');
function closeForm() {
showModal.value = false;
loading.value = false;
}
function openModal(state: State) {
showModal.value = true;
loading.value = true;
View({ id: state.id })
.then((res) => {
formValue.value = newState(res);
})
.finally(() => {
loading.value = false;
});
}
defineExpose({
openModal,
});
</script>
<style lang="less"></style>
+142
View File
@@ -0,0 +1,142 @@
<template>
<n-modal
v-model:show="showModal"
:mask-closable="false"
:show-icon="false"
preset="dialog"
transform-origin="center"
:title="'用户治理 #' + formValue.userId"
:style="{ width: dialogWidth }"
>
<n-spin :show="loading" description="请稍候...">
<n-descriptions label-placement="left" bordered :column="settingStore.isMobile ? 1 : 2">
<n-descriptions-item label="租户">{{ formValue.tenantName || '-' }}</n-descriptions-item>
<n-descriptions-item label="用户ID">{{ formValue.userId }}</n-descriptions-item>
<n-descriptions-item label="昵称">{{ formValue.nickname || '-' }}</n-descriptions-item>
<n-descriptions-item label="状态">{{ stateLabel }}</n-descriptions-item>
</n-descriptions>
<n-form class="mt-4" label-placement="left" :label-width="110">
<n-form-item label="治理动作">
<n-select v-model:value="actionForm.action" :options="actionOptions" />
</n-form-item>
<n-form-item label="操作IM用户ID">
<n-input-number v-model:value="actionForm.actorUserId" clearable class="w-full" />
</n-form-item>
<n-form-item v-if="actionForm.action === 'deviceKick'" label="设备ID">
<n-input v-model:value="actionForm.deviceId" clearable placeholder="请输入 device_id" />
</n-form-item>
<n-form-item v-if="actionForm.action === 'accountCancel'" label="硬删除">
<n-switch v-model:value="actionForm.hard" />
</n-form-item>
<n-form-item label="原因">
<n-input v-model:value="actionForm.reason" type="textarea" clearable placeholder="请输入操作原因" />
</n-form-item>
</n-form>
</n-spin>
<template #action>
<n-space>
<n-button @click="closeForm">关闭</n-button>
<n-button type="primary" :loading="loading" @click="submitAction">提交命令</n-button>
</n-space>
</template>
</n-modal>
</template>
<script lang="ts" setup>
import { computed, reactive, ref } from 'vue';
import { useDictStore } from '@/store/modules/dict';
import {
AccountCancel,
AccountDelete,
Ban,
DeviceKick,
Freeze,
LogoutAll,
Unban,
Unfreeze,
} from '@/api/imUser';
import { newState, State } from './model';
import { useProjectSettingStore } from '@/store/modules/projectSetting';
import { adaModalWidth } from '@/utils/hotgo';
import { useMessage } from 'naive-ui';
const emit = defineEmits(['reload']);
const settingStore = useProjectSettingStore();
const dict = useDictStore();
const message = useMessage();
const loading = ref(false);
const showModal = ref(false);
const formValue = ref<State>(newState(null));
const dialogWidth = computed(() => adaModalWidth(720));
const stateLabel = computed(() => dict.getLabel('ImUserStateOptions', formValue.value.state) || '-');
const actionOptions = [
{ label: '封禁用户', value: 'ban' },
{ label: '解封用户', value: 'unban' },
{ label: '冻结用户', value: 'freeze' },
{ label: '解冻用户', value: 'unfreeze' },
{ label: '踢出指定设备', value: 'deviceKick' },
{ label: '踢出全部会话', value: 'logoutAll' },
{ label: '注销账号', value: 'accountCancel' },
{ label: '删除账号', value: 'accountDelete' },
];
const actionForm = reactive({
action: 'ban',
actorUserId: null as number | null,
deviceId: '',
reason: '',
hard: false,
});
function closeForm() {
showModal.value = false;
loading.value = false;
}
function openModal(state: State) {
formValue.value = newState(state);
actionForm.action = 'ban';
actionForm.actorUserId = state.userId || null;
actionForm.deviceId = '';
actionForm.reason = '';
actionForm.hard = false;
showModal.value = true;
}
async function submitAction() {
const params = {
tenantId: formValue.value.tenantId,
userId: formValue.value.userId,
actorUserId: actionForm.actorUserId,
deviceId: actionForm.deviceId,
reason: actionForm.reason,
hard: actionForm.hard,
};
const apiMap = {
ban: Ban,
unban: Unban,
freeze: Freeze,
unfreeze: Unfreeze,
deviceKick: DeviceKick,
logoutAll: LogoutAll,
accountCancel: AccountCancel,
accountDelete: AccountDelete,
};
loading.value = true;
try {
const res = await apiMap[actionForm.action](params);
message.success(`命令已提交:${res?.commandId || '-'}`);
emit('reload');
closeForm();
} finally {
loading.value = false;
}
}
defineExpose({
openModal,
});
</script>
<style lang="less"></style>
+13 -1
View File
@@ -22,6 +22,7 @@
/> />
</n-card> </n-card>
<ViewModal ref="viewRef" /> <ViewModal ref="viewRef" />
<GovernanceModal ref="governanceRef" @reload="reloadTable" />
</div> </div>
</template> </template>
@@ -35,15 +36,17 @@
import { columns, loadOptions, loadTenantOptions, schemas, State, tenantOptions } from './model'; import { columns, loadOptions, loadTenantOptions, schemas, State, tenantOptions } from './model';
import { adaTableScrollX } from '@/utils/hotgo'; import { adaTableScrollX } from '@/utils/hotgo';
import ViewModal from './view.vue'; import ViewModal from './view.vue';
import GovernanceModal from './governance.vue';
const message = useMessage(); const message = useMessage();
const { hasPermission } = usePermission(); const { hasPermission } = usePermission();
const actionRef = ref(); const actionRef = ref();
const searchFormRef = ref<any>({}); const searchFormRef = ref<any>({});
const viewRef = ref(); const viewRef = ref();
const governanceRef = ref();
const actionColumn = reactive({ const actionColumn = reactive({
width: 120, width: 170,
title: '操作', title: '操作',
key: 'action', key: 'action',
fixed: 'right', fixed: 'right',
@@ -56,6 +59,11 @@
onClick: handleView.bind(null, record), onClick: handleView.bind(null, record),
auth: ['/imUser/view'], auth: ['/imUser/view'],
}, },
{
label: '治理',
onClick: handleGovernance.bind(null, record),
auth: ['/imUser/ban'],
},
], ],
}); });
}, },
@@ -90,6 +98,10 @@
viewRef.value.openModal(record); viewRef.value.openModal(record);
} }
function handleGovernance(record: Recordable) {
governanceRef.value.openModal(record);
}
onMounted(async () => { onMounted(async () => {
loadOptions(); loadOptions();
await loadTenantOptions(); await loadTenantOptions();