Compare commits

..
5 Commits
Author SHA1 Message Date
richjohnrambo 0a21d7289e Merge remote-tracking branch 'origin/dev' into dev
# Conflicts:
#	server/api/admin/imgroup/im_group.go
#	server/api/admin/imuser/im_user.go
#	server/internal/controller/admin/sys/im_group.go
#	server/internal/controller/admin/sys/im_user.go
#	server/internal/library/imtenant/db.go
#	server/internal/logic/admin/member.go
#	server/internal/logic/sys/im_admin_scope.go
#	server/internal/logic/sys/im_group.go
#	server/internal/logic/sys/im_invite.go
#	server/internal/logic/sys/im_registration.go
#	server/internal/logic/sys/im_tenant.go
#	server/internal/logic/sys/im_user.go
#	server/internal/model/input/sysin/im_group.go
#	server/internal/model/input/sysin/im_invite.go
#	server/internal/model/input/sysin/im_user.go
#	server/internal/router/genrouter/im_invite.go
#	server/internal/router/genrouter/im_registration.go
#	server/internal/service/sys.go
#	server/manifest/config/config.example.yaml
#	server/storage/data/generate/im_audit_log_menu.sql
#	server/storage/data/generate/im_group_menu.sql
#	server/storage/data/generate/im_realname_menu.sql
#	server/storage/data/generate/im_tenant_menu.sql
#	server/storage/data/generate/im_user_menu.sql
#	server/storage/data/generate/im_work_order_menu.sql
#	server/storage/data/im_registration_invite_hotgo.sql
#	web/src/api/imGroup/index.ts
#	web/src/api/imUser/index.ts
#	web/src/views/im/registrationInvite/BatchViewModal.vue
#	web/src/views/im/registrationInvite/CreateModal.vue
#	web/src/views/im/registrationInvite/index.vue
#	web/src/views/im/registrationInvite/model.ts
#	web/src/views/imGroup/governance.vue
#	web/src/views/imGroup/members.vue
#	web/src/views/imUser/index.vue
#	web/src/views/imUser/model.ts
#	web/src/views/imUser/view.vue
#	web/src/views/system/config/ImRegistrationSetting.vue
2026-09-04 12:01:22 +08:00
richjohnrambo f7f8cc0ef0 feat: add im kafka admin integration 2026-09-04 11:57:38 +08:00
richjohnrambo a2abaef3a6 feat: add IM admin management 2026-08-21 01:06:19 +08:00
richjohnrambo 89ba6bcff8 feat: add tenant-aware IM management console 2026-08-19 00:31:09 +08:00
richjohnrambo 642b7ec5da im 管理后台 2026-08-17 09:52:02 +08:00
79 changed files with 7370 additions and 49 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,747 @@
# IM HotGo 注册短信开关与邀请码管理改造交接文档
> 文档状态:已按数据库直连方案实现,作为现状与后续 Kafka 演进说明
> 对应实施顺序:注册改造计划第 4 项(HotGo 后台)
> HotGo 项目:`/Users/youshunlai/go_workspace/im_manager`
> IM 服务项目:`/Users/youshunlai/go_workspace/im_server`
> 编写日期:2026-08-18
## 1. 文档用途
本文供新的开发会话直接接手 HotGo 项目使用。阅读本文后,应能明确:
1. 为什么要在 HotGo 中增加注册短信开关和邀请码管理。
2. 哪些数据属于 HotGo 控制库,哪些数据属于 IM 租户业务库。
3. HotGo 后端、前端、菜单权限、统一租户身份、审计和配置需要修改什么。
4. HotGo 与 IM 如何通过租户业务库协作,以及后续如何迁移到 Kafka。
5. 如何验证实现没有破坏租户隔离,也没有泄露邀请码明文。
本文指导 HotGo 项目改造。HotGo 负责邀请码生成和管理;IM 仍独占客户端注册、短信验证码和邀请码使用记录事务。
## 2. 开发前必须阅读
在修改 `/Users/youshunlai/go_workspace/im_manager` 前,先阅读以下文档:
1. `/Users/youshunlai/go_workspace/im_server/docs/IM-注册、邀请码与认证数据模型改造需求文档.md`
2. `/Users/youshunlai/go_workspace/im_server/docs/IM-前后端对接文档.md` 中 1.4、1.5 注册章节
3. `/Users/youshunlai/go_workspace/im_server/docs/IM-后台需求文档.md` 中租户权限、用户管理和审计章节
4. `/Users/youshunlai/go_workspace/im_server/docs/IM-独立库多租户改造方案.md`
规则冲突时按以下优先级处理:
1. 本文针对 HotGo 的落地边界和接口约定。
2. 《IM 注册、邀请码与认证数据模型改造需求文档》。
3. 《IM 独立库多租户改造方案》。
4. 其他较早的需求和设计文档。
`IM-服务端多租户改造方案.md` 已废弃,不得作为实现依据。当前多租户方案是“一套 IM 服务、一个 HotGo 控制库、每租户一个独立 IM 业务库”。
## 3. 需求前因后果
### 3.1 注册页面最终规则
客户端注册页面同时展示用户名、手机号、短信验证码、密码和邀请码,但用户名与手机号是二选一关系:
1. 手机号非空时,以手机号方式为准,忽略用户名输入。
2. 手机号方式注册后,`im_users.username` 和 `im_users.phone` 都保存规范化后的 `+86` 手机号。
3. 手机号为空时使用普通用户名,用户名必须匹配 `^[a-z0-9]{1,10}$`,此时 `phone=NULL`。
4. 普通用户名方式不能自助找回密码,只能由企业管理员重置。
5. 邀请码固定为 5 位 ASCII 大小写英文字母,区分大小写、租户内唯一,有效期内可供多人使用。
### 3.2 为什么需要短信开关
短信服务在不同部署环境中可能暂未启用,因此需要平台级开关:
| 场景 | 短信开关开启 | 短信开关关闭 |
| --- | --- | --- |
| 点击获取验证码 | IM 服务真实调用短信服务商 | IM 服务返回正常挑战和倒计时,但不调用服务商 |
| 手机号注册 | 必须验证短信;邀请码不能绕过 | 必须提供有效邀请码,可无验证码注册 |
| 用户名注册 | 不发送短信,必须提供有效邀请码 | 不发送短信,必须提供有效邀请码 |
| 客户端是否知道开关 | 不直接暴露 | 不直接暴露 |
HotGo 只负责管理开关值。是否调用短信服务商、是否允许注册,全部由 IM 服务端判定。
### 3.3 为什么当前改为 HotGo 直接访问租户库
邀请码属于 IM 租户业务数据:
1. 每个租户有独立业务库。
2. 邀请码批次和主表位于 `hotgo` 控制库,使用明细位于对应租户业务库。
3. HotGo 邀请码管理直接访问控制库;密码重置等租户业务功能仍通过 `im_tenant.id` 和服务端保存的 DSN 选择租户库。
4. 当前部署中 HotGo 到 IM 的内部 HTTP 地址不可达,继续维护 HMAC 管理 API 会增加一套没有必要的调用链。
因此当前采用“控制库主数据 + 租户库使用事实”的混合边界:HotGo 负责邀请码生成、查询、导出和撤销;IM 从控制库校验邀请码,并在租户库记录使用关系和 outbox 事件。两端各自只写自己负责的数据。
密码重置等需要 IM 立即感知的操作写入 `im_business_events_outbox`。当前 IM 轮询消费,后续改为 Kafka 时保留事件契约,只替换传输层。
## 4. 系统边界
```mermaid
flowchart LR
A["HotGo 管理后台浏览器"] -->|HotGo 登录 Token| B["HotGo 后端"]
B -->|读写全局开关| C[("hotgo.hg_sys_config")]
B -->|读取管理员 tenant_id| D[("hotgo.im_tenant")]
B -->|管理邀请码| G[("hotgo.im_registration_invite_batches")]
B -->|管理邀请码| H[("hotgo.im_registration_invite_codes")]
B -->|其他租户业务按服务端 DSN| F[("租户 IM 业务库")]
F --> I["im_registration_invite_uses"]
J["IM 注册与发码接口"] -->|短时缓存读取| C
J -->|按 tenant_id 校验| H
J -->|记录使用关系与事件| F
B -->|写 security.password_reset| K["im_business_events_outbox"]
F --> K
K -->|当前轮询,后续 Kafka| J
```
### 4.1 HotGo 负责
1. 在 `hg_sys_config` 保存全局注册短信开关。
2. 只允许平台超级管理员修改开关。
3. 为邀请码提供管理页面、表单、权限和审计入口。
4. 从当前登录管理员身份解析可信的 IM `tenant_id`。
5. 通过 `imtenant.DB()` 直连所选租户业务库,不接受浏览器传入 DSN。
6. 对邀请码明文查询、查看和导出执行独立权限校验。
### 4.2 IM 服务负责
1. 读取并缓存短信开关。
2. 实际发送或抑制短信。
3. 注册时大小写敏感地校验邀请码,并记录邀请码与新用户的使用关系。
4. 邀请码使用后不改变 active 状态。
5. 消费 `security.password_reset` 事件并清退在线会话。
6. 登录时以 `auth_version` 拒绝密码重置前签发的旧 Token。
7. 消费 `registration.invite_used`,将租户库使用统计幂等投影到控制库。
### 4.3 明确禁止
1. 浏览器直接连接租户数据库。
2. 浏览器通过 Header、Query 或 Body 自报可信 `tenant_id`。
3. HotGo 后端根据浏览器传入的 `db_name` 或 `db_dsn` 连接数据库。
4. HotGo 修改 `im_registration_invite_uses`;该表只允许 IM 注册事务写入。
5. HotGo 绕过 outbox 直接修改在线会话状态。
6. 在普通访问日志、错误日志、埋点或 URL 中记录完整邀请码。
## 5. 当前 HotGo 项目现状
目标仓库 `/Users/youshunlai/go_workspace/im_manager` 基于 GoFrame 2、Vue 3 和 Naive UI。
已确认的现状:
1. 通用配置后端位于 `server/api/admin/config`、`server/internal/controller/admin/sys/config.go`、`server/internal/logic/sys/config.go`。
2. 通用配置前端位于 `web/src/views/system/config` 和 `web/src/api/sys/config.ts`。
3. `hg_sys_config` 支持 `group/key/type/value/default_value`,前端不能通过通用接口新增不存在的配置项。
4. `sys_switch` 字典实际定义为“开启=`1`、关闭=`2`”。
5. HotGo 管理接口权限按“路由路径 + HTTP Method”使用 Casbin 校验,前端按钮权限也使用路由路径,例如 `hasPermission(['/notice/edit'])`。
6. `hotgo.im_tenant` 是全系统唯一租户注册表,由 HotGo 维护,不存在另一套需要映射的“IM 租户”。
7. 后台管理员通过 `hg_admin_member.tenant_id` 直接引用 `im_tenant.id`,登录身份必须携带该字段;不再使用独立租户绑定表。
8. `hg_sys_log` 会保存 GET Query 和 POST Body;现有逻辑只自动掩码名称包含 `password` 的 POST 字段,邀请码查询参数会泄漏,必须补充脱敏。
## 6. 建议开发顺序
### 阶段 A:先完成 HotGo 基础边界
1. 增加全局短信开关配置种子和专用读写接口。
2. 增加 `hg_admin_member.tenant_id` 外键与登录身份解析,统一使用 `im_tenant.id`。
3. 增加租户库直连、Snowflake ID 和数据库事件写入能力。
4. 增加邀请码相关菜单、路由权限和后台代理接口。
5. 增加邀请码管理页面。
6. 增加敏感字段日志脱敏和操作审计。
### 阶段 B:与 IM 服务联调
1. 核对 IM 租户库迁移已执行且表结构一致。
2. 核对服务凭证、租户上下文和操作人上下文可被 IM 验证。
3. 联调单个生成、批量生成、分页查询、撤销、详情和导出。
4. 联调短信开关在 IM 服务缓存时间内生效。
IM 管理接口未完成时,可以通过接口 Client 抽象和测试 Stub 开发页面,但不得用 HotGo 直写租户数据库作为临时方案。
## 7. 全局注册短信开关
### 7.1 数据定义
在 HotGo 控制库 `hg_sys_config` 中增加且只保留一条配置:
```text
group = im
name = 注册短信发送
type = bool
key = register_sms_enabled
value = 1
default_value = 1
status = 1
is_default = 1
```
建议的 MySQL 幂等种子:
```sql
INSERT INTO hg_sys_config
(`group`, `name`, `type`, `key`, `value`, `default_value`, `sort`, `tip`, `is_default`, `status`, `created_at`, `updated_at`)
SELECT
'im', '注册短信发送', 'bool', 'register_sms_enabled', '1', '1', 10,
'控制 IM 注册验证码是否真实发送', 1, 1, NOW(), NOW()
WHERE NOT EXISTS (
SELECT 1 FROM hg_sys_config
WHERE `group` = 'im' AND `key` = 'register_sms_enabled'
);
```
同时为 `(group, key)` 增加唯一约束。加索引前先检查并清理重复配置,不能让 IM 服务随机读到多条值。
```sql
ALTER TABLE hg_sys_config
ADD UNIQUE KEY uk_hg_sys_config_group_key (`group`, `key`);
```
新装数据库种子、实际部署迁移及项目保留的 MySQL/PostgreSQL/SQLite 初始化脚本应按项目发布策略同步,不能只改一份示例 SQL。
### 7.2 `sys_switch` 映射陷阱
`sys_switch` 字典值与真实配置值不相同:
| UI 字典值 | UI 含义 | `hg_sys_config.value` |
| --- | --- | --- |
| `1` | 开启 | `1` |
| `2` | 关闭 | `0` |
实现要求:
1. 页面加载时将后端 bool 值映射为字典值:`true -> 1`,`false -> 2`。
2. 页面提交时反向映射:`1 -> true`,`2 -> false`。
3. 不得把字典值 `2` 原样写入 bool 配置;GoFrame 的 bool 转换可能把非零值当成 true。
4. 数据库只允许保存可解释为 `1/0` 的值。
### 7.3 后端接口
不要直接让页面复用通用 `/config/update`。新增专用接口,以便强制执行平台管理员校验:
| 方法 | HotGo 路径 | 权限 |
| --- | --- | --- |
| GET | `/imRegistration/config/get` | 平台超级管理员 |
| POST | `/imRegistration/config/update` | 平台超级管理员 |
读取响应:
```json
{
"registerSmsEnabled": true,
"updatedAt": "2026-08-18T16:00:00+08:00"
}
```
更新请求:
```json
{
"registerSmsEnabled": false
}
```
后端必须在业务逻辑层再次调用 `service.AdminMember().VerifySuperId(ctx, contexts.GetUserId(ctx))`,不能只依赖菜单是否可见。更新时只允许修改固定的 `group=im/key=register_sms_enabled`,不接受前端传 group 或 key。
### 7.4 页面位置
建议在 `web/src/views/system/config/system.vue` 增加“IM 注册”设置项,并新增:
```text
web/src/views/system/config/ImRegistrationSetting.vue
```
页面只展示一个“注册短信发送”开关和保存按钮。选项通过 `dict.loadOptions(['sys_switch'])` 加载,并按 7.2 做显式映射。
### 7.5 IM 服务读取约定
HotGo 不需要额外暴露公开配置接口。IM 服务直接从控制库读取:
```sql
SELECT value
FROM hg_sys_config
WHERE `group` = 'im'
AND `key` = 'register_sms_enabled'
AND status = 1
LIMIT 1;
```
约定:
1. 种子默认值为开启。
2. IM 服务缓存建议为 30 秒,缓存时长可配置。
3. 临时读取失败时优先使用最近一次成功值。
4. 进程启动后无缓存且配置缺失、停用或非法时,按“开启”处理,防止邀请码绕过短信验证,并记录告警。
5. HotGo 更新后不承诺立即生效,验收标准为 30 秒内生效。
## 8. 统一租户身份
### 8.1 唯一租户主键
`hotgo.im_tenant.id` 是 HotGo 和 IM 服务共同使用的唯一 `tenant_id`。HotGo 负责维护租户注册信息和业务库路由,IM 服务根据该 ID 选择租户业务库。不得再创建 `im_admin_tenant_binding` 或其他租户映射表。
### 8.2 管理员租户归属
后台管理员记录直接引用统一租户:
```sql
ALTER TABLE hg_admin_member
ADD COLUMN tenant_id BIGINT NULL COMMENT '统一租户ID,引用 im_tenant.id',
ADD INDEX idx_hg_admin_member_tenant (tenant_id),
ADD CONSTRAINT fk_hg_admin_member_im_tenant
FOREIGN KEY (tenant_id) REFERENCES im_tenant(id);
```
平台账号的 `tenant_id` 可以为空。租户管理员及其创建的下级后台账号必须继承同一个 `im_tenant.id`。只有平台超级管理员可以为后台账号选择或修改租户,租户域管理员提交的 `tenantId` 必须被后端忽略并以当前登录身份为准。
### 8.3 解析规则
租户管理员请求邀请码接口时:
1. 从 HotGo 登录上下文取得 `member_id`、`tenant_id` 和部门类型。
2. 仅允许合法的租户级管理员角色进入;普通用户和商户角色不得管理邀请码。
3. `tenant_id` 必须大于 0,并且对应的 `im_tenant` 必须存在且为 active。
4. 将该 `tenant_id` 写入 IM 服务调用上下文。
5. 忽略并拒绝浏览器提交的 `tenantId`。
平台超级管理员可以跨租户查看邀请码;列表和导出未选择租户时查询全部。创建、批次查看、撤销及其他写操作必须先选择一个 `im_tenant`。此选择只对 `VerifySuperId` 通过的管理员开放,HotGo 后端仍要检查目标租户存在且 active。
后台用户管理应提供仅平台超级管理员可见的租户选择控件。租户管理员未关联 `im_tenant.id` 时返回明确错误“当前管理员未关联租户”,不能回退到默认租户。
## 9. 租户库直连与事件通道
### 9.1 数据库选择
HotGo 从可信登录上下文取得 `im_tenant.id`。邀请码批次和邀请码主表直接通过 `imtenant.RegistryDB()` 访问 HotGo 控制库;只有密码重置等租户业务操作才通过 `imtenant.DB()` 打开租户业务库。浏览器传入的 `tenantId` 只允许平台超级管理员选择,租户管理员始终使用登录关系中的租户 ID;任何请求都不能提交或覆盖 DSN。
若配置了 `database.imRegistry`,租户注册表从该连接读取;未配置时回退到 HotGo 默认库。业务操作始终使用按租户动态建立的连接组。
### 9.2 Snowflake 配置
HotGo 直接新增 IM 表记录时使用与 IM 相同位布局的 Snowflake ID:
```yaml
imDirectDb:
snowflakeWorkerId: 1023
```
也可由 `IM_DIRECT_DB_SNOWFLAKE_WORKER_ID` 覆盖。每个 HotGo 写库实例必须使用唯一 worker ID,并且不能与 IM 节点重叠。当前单实例默认预留 `1023`,多实例部署前必须分配 worker ID。
### 9.3 写入职责
HotGo 可以写:
1. HotGo 控制库中的 `im_registration_invite_batches`、`im_registration_invite_codes`。
2. 管理员重置密码时的 `im_auth.secret` 和 `im_user_security`。
3. 管理操作产生的 `im_business_events_outbox`。
IM 独占写入:
1. 客户端注册事务及用户创建。
2. 租户业务库中的 `im_registration_invite_uses` 以及同事务的 `registration.invite_used` outbox 事件。
3. 客户端登录、改密、短信找回等认证事务。
### 9.4 Kafka 演进
管理员重置密码在数据库事务内写入:
```json
{
"event_type": "security.password_reset",
"payload": {
"user_id": 198000000000000001,
"auth_version": 2,
"operator_id": 10001,
"request_id": "..."
}
}
```
当前 IM 在注册写入 outbox 后立即触发一次消费,并每秒按在线会话涉及的租户轮询 pending 事件。`security.password_reset` 消费后清退该用户会话;`registration.invite_used` 消费时从租户使用明细重新聚合 `COUNT/MAX(used_at)`,覆盖 HotGo 控制库的 `usage_count/last_used_at`,成功后标记 delivered。离线用户不依赖密码重置轮询,后续登录时由 `auth_version` 直接拒绝旧 Token。
邀请码使用事件契约:
```json
{
"event_type": "registration.invite_used",
"payload": {
"tenant_id": 1,
"invite_code_id": 198000000000000001,
"user_id": 198000000000000002,
"request_id": "acc-request-id",
"used_at": "2026-08-19T10:00:00.000Z"
}
}
```
迁移 Kafka 时,生产者继续在业务事务内写 outbox,由独立投递器发布同一事件;消费者保持事件类型与 payload 不变。Kafka 投递确认后再标记 outbox delivered,避免数据库提交成功但消息丢失。
## 10. 邀请码接口契约
### 10.1 数据表契约
HotGo 在控制库直接维护带 `tenant_id` 的 `im_registration_invite_batches` 和 `im_registration_invite_codes`。邀请码状态仅为 `active/expired/revoked`,使用后仍为 active;`im_registration_invite_uses` 只由 IM 注册事务写入租户库。控制库中的 `usage_count/last_used_at` 由 outbox 消费器幂等投影,HotGo 列表不跨租户库做联表聚合。
平台超级管理员在列表和导出接口中可不传 `tenantId` 以查询全部租户;创建、批次查看、撤销等写操作必须明确选择一个 active 租户。租户管理员只能查询和操作登录关系绑定的 `im_tenant.id`。
邀请码明文不得放入 URL。查询完整邀请码时必须使用 HotGo POST Body,普通请求日志需对 `inviteCode` 脱敏。
### 10.2 HotGo 对浏览器接口
沿用 HotGo 现有命名风格,建议增加:
| 方法 | HotGo 路径 | 说明 |
| --- | --- | --- |
| POST | `/imInvite/list` | 分页查询,过滤条件放在 JSON Body |
| POST | `/imInvite/create` | 单个生成 |
| POST | `/imInvite/batchCreate` | 批量生成 |
| GET | `/imInvite/batchView` | 批次详情 |
| POST | `/imInvite/revoke` | 单个撤销 |
| POST | `/imInvite/batchRevoke` | 批量撤销 |
| POST | `/imInvite/export` | 明文导出,过滤条件放在 JSON Body |
HotGo controller 只做 DTO 转换、调用 service 和包装响应。租户解析、平台管理员判断、权限复核、审计和租户库访问放在 logic/service 层。
### 10.3 单个生成
HotGo 请求:
```json
{
"expiresAt": "2026-09-01T00:00:00+08:00",
"remark": "新员工入职",
"idempotencyKey": "7be3de87-994f-46c6-86bd-844ffb54f83f"
}
```
规则:
1. `expiresAt` 必填,必须晚于服务端当前时间。
2. `remark` 最长 255 字符。
3. 推荐单个生成也传幂等键,避免提交成功但响应超时时重复创建。
4. 邀请码由 HotGo 使用 `crypto/rand` 生成并依靠租户库唯一索引兜底,HotGo 不接受用户自定义 code。
响应数据:
```json
{
"id": 198000000000000001,
"code": "AbCdE",
"status": "active",
"expiresAt": "2026-09-01T00:00:00+08:00",
"remark": "新员工入职",
"createdBy": 10001,
"createdAt": "2026-08-18T16:10:00+08:00"
}
```
### 10.4 批量生成
HotGo 请求:
```json
{
"quantity": 100,
"expiresAt": "2026-09-01T00:00:00+08:00",
"remark": "2026年8月新员工入职",
"idempotencyKey": "4dc7f13e-448c-4b49-b056-57a2fde18f95"
}
```
规则:
1. `quantity` 为 1 到 1000,由 HotGo 校验。
2. 同一个提交动作必须复用同一个幂等键,直到得到明确成功响应。
3. 前端可在打开弹窗时生成 UUID,失败重试时复用;成功或关闭弹窗后废弃。
4. HotGo 在批次表保存幂等键;相同参数重试返回原批次,不重复生成。
响应数据:
```json
{
"batchId": 198000000000000100,
"quantity": 100,
"activeCount": 100,
"usageCount": 0,
"revokedCount": 0,
"expiredCount": 0,
"expiresAt": "2026-09-01T00:00:00+08:00",
"createdAt": "2026-08-18T16:15:00+08:00"
}
```
### 10.5 分页查询
列表请求使用 POST JSON Body,支持字段:
```text
page, perPage, inviteCode, status, batchId, createdBy,
createdAtStart, createdAtEnd, expiresAtStart, expiresAtEnd
```
要求:
1. `inviteCode` 仅支持完整 5 位精确查询,不做模糊查询,不转换大小写。
2. `status` 为 `active/expired/revoked`,邀请码使用后仍保持 `active`。
3. HotGo 查询前将 `expires_at <= now` 的 active 记录更新为 expired,不依赖定时任务。
4. 默认按 `created_at DESC, id DESC` 排序。
5. `perPage` 设置合理上限,建议不超过 100。
列表字段至少包括:
```json
{
"id": 198000000000000001,
"batchId": 198000000000000100,
"code": "AbCdE",
"status": "active",
"expiresAt": "2026-09-01T00:00:00+08:00",
"usageCount": 12,
"lastUsedAt": "2026-08-20T09:30:00+08:00",
"createdBy": 10001,
"remark": "新员工入职",
"createdAt": "2026-08-18T16:10:00+08:00"
}
```
### 10.6 撤销
1. 只有 `active` 且未过期的邀请码可撤销;已有使用记录不影响撤销。
2. 已过期、已撤销的邀请码不能恢复为 active。
3. 批量撤销只撤销批次内仍可撤销的码,并返回各状态统计。
4. 页面操作前显示确认框,确认内容不必重复显示完整邀请码。
5. 最终状态由租户库条件更新结果决定,不以前端状态为准。
### 10.7 导出
1. 导出必须具备独立 `/imInvite/export` 权限。
2. 导出过滤条件与列表一致。
3. HotGo 后端分页读取租户库并流式写出,不把整份文件加载到内存。
4. 建议输出带 UTF-8 BOM 的 CSV,字段至少包含邀请码、状态、批次、到期时间、累计使用次数、最后使用时间、创建人、创建时间和备注。
5. 文件名建议为 `registration-invite-codes-YYYYMMDD-HHmmss.csv`。
6. 每次导出必须写审计,但审计详情不得包含邀请码明文列表。
## 11. 页面和交互
建议页面路径:
```text
web/src/views/im/registrationInvite/index.vue
web/src/views/im/registrationInvite/model.ts
web/src/views/im/registrationInvite/CreateModal.vue
web/src/views/im/registrationInvite/BatchCreateModal.vue
web/src/views/im/registrationInvite/BatchViewModal.vue
web/src/api/im/registrationInvite.ts
```
页面放在“IM 管理 / 注册邀请码”菜单下。使用现有 `BasicForm`、`BasicTable`、`TableAction`、`usePermission` 和 Naive UI 组件。
页面必须具备:
1. 完整邀请码、状态、批次 ID、创建人、创建时间和到期时间筛选。
2. 单个生成按钮。
3. 批量生成按钮。
4. 导出按钮。
5. 列表展示完整邀请码、状态、到期时间、批次、使用情况、创建信息和备注。
6. active 行提供撤销操作,其他状态不显示撤销操作。
7. 批次详情展示总数、active、used、expired、revoked 统计。
8. 所有日期时间明确按本地时区展示,提交时发送带时区的 RFC 3339 时间。
9. 加载、空数据、接口错误、重复提交和无权限状态处理完整。
邀请码区分大小写,任何输入组件、复制操作和查询参数都不得自动转为大写或小写。
## 12. 菜单与权限
HotGo 的权限标识使用接口路径。建议创建以下按钮权限:
```text
/imRegistration/config/get
/imRegistration/config/update
/imInvite/list
/imInvite/create
/imInvite/batchCreate
/imInvite/batchView
/imInvite/revoke
/imInvite/batchRevoke
/imInvite/export
```
权限分配:
| 能力 | 平台超级管理员 | 租户管理员 | 其他角色 |
| --- | --- | --- | --- |
| 查看/修改短信开关 | 允许 | 禁止 | 禁止 |
| 选择任意 IM 租户管理邀请码 | 允许 | 禁止 | 禁止 |
| 管理当前绑定租户邀请码 | 允许 | 按权限允许 | 禁止 |
| 查看邀请码明文 | 允许 | 需独立权限 | 禁止 |
| 导出邀请码明文 | 允许 | 需独立权限 | 禁止 |
服务端 Casbin、业务逻辑层租户范围校验、前端按钮隐藏三层都要实现。前端隐藏按钮不是安全边界。
## 13. 日志脱敏与审计
### 13.1 必须修复现有访问日志泄露
`server/internal/logic/sys/log.go` 当前会保存 Query 和 Body,且只掩码 password。至少增加以下字段名的大小写不敏感脱敏:
```text
inviteCode
invite_code
serviceSecret
service_secret
authorization
```
建议抽成统一的递归敏感字段过滤器,同时处理 Query、JSON Body、Form 和 Header。Header 中已有简化逻辑也必须确认不会保存内部服务凭证。
对 `/imInvite/list` 的明文精确查询,邀请码只能放在 POST Body,`hg_sys_log.post_data` 中只能出现 `******`。`hg_sys_log.get_data`、Nginx URL 和其他访问日志中都不能出现真实邀请码。HTTP Client 的 debug 日志同样不得打印完整 URL、Query、请求体或响应体。
### 13.2 业务审计
仅靠普通访问日志不够。建议在 HotGo 控制库增加独立审计表,或者接入项目已有的等价不可变审计设施。最低字段为:
```sql
CREATE TABLE im_admin_operation_audit (
id BIGINT NOT NULL AUTO_INCREMENT,
request_id VARCHAR(64) NOT NULL,
tenant_id BIGINT NULL,
operator_id BIGINT NOT NULL,
action VARCHAR(64) NOT NULL,
target_type VARCHAR(32) NOT NULL,
target_id BIGINT NULL,
result VARCHAR(16) NOT NULL,
ip VARCHAR(64) NOT NULL DEFAULT '',
detail_json JSON NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
PRIMARY KEY (id),
KEY idx_im_admin_audit_tenant_time (tenant_id, created_at),
KEY idx_im_admin_audit_operator_time (operator_id, created_at),
UNIQUE KEY uk_im_admin_audit_request_action (request_id, action)
);
```
必须审计:
1. 短信开关修改,记录旧值和新值。
2. 单个生成和批量生成,记录 code ID 或 batch ID、数量和到期时间,但不记录 code 明文。
3. 邀请码明文查询和详情查看,记录查询行为,但不记录搜索明文。
4. 单个撤销和批量撤销。
5. 明文导出,记录过滤条件摘要和导出数量,不记录明文内容。
6. 后台管理员租户归属变更。
IM 服务也应写自己的业务审计。HotGo 审计记录后台操作者,IM 审计记录实际租户库操作,两者通过 `request_id` 关联。
## 14. 错误处理
HotGo 应将数据库错误稳定映射为后台可理解的错误,不向浏览器返回 DSN、SQL、堆栈或内部配置。
至少处理:
| 场景 | HotGo 行为 |
| --- | --- |
| 当前管理员未关联租户 | 返回明确业务错误,不使用默认租户 |
| 目标租户停用 | 拒绝操作 |
| 无查看/导出权限 | 403 |
| 邀请码已过期或已撤销 | 显示不可撤销,不本地修改状态 |
| 幂等键重复且请求相同 | 返回原结果 |
| 幂等键重复但参数不同 | 返回幂等冲突 |
| 租户库连接失败 | 返回“租户业务库暂不可用”,保留 Request ID |
| 数据库写入失败 | 事务回滚,返回统一后台错误并记录脱敏日志 |
| 导出数据过大 | 使用服务端限制或异步导出,不耗尽内存 |
## 15. 建议代码落点
遵循现有 HotGo 分层,建议新增或修改:
```text
server/api/admin/imregistration/
server/api/admin/iminvite/
server/internal/controller/admin/admin/im_registration.go
server/internal/controller/admin/admin/im_invite.go
server/internal/model/input/adminin/im_registration.go
server/internal/model/input/adminin/im_invite.go
server/internal/service/im.go
server/internal/logic/admin/im_registration.go
server/internal/logic/admin/im_invite.go
server/internal/library/imtenant/db.go
server/internal/library/imtenant/snowflake.go
server/internal/router/admin.go
server/internal/logic/sys/log.go
web/src/api/im/registrationInvite.ts
web/src/views/im/registrationInvite/
web/src/views/system/config/ImRegistrationSetting.vue
web/src/views/system/config/system.vue
```
如果新增 `im_admin_operation_audit`,按 HotGo 现有 GoFrame DAO 生成流程生成 `dao/do/entity`,不要手工修改标有 “Code generated” 的文件。禁止新增 `im_admin_tenant_binding`。
同时提交:
1. 生产数据库幂等迁移。
2. 新装数据库初始化数据。
3. 菜单和按钮权限种子。
4. 默认角色授权或明确的部署授权步骤。
5. Snowflake worker 配置示例,但不提交真实 DSN。
## 16. 测试清单
### 16.1 短信开关
1. 无配置时 IM 按开启处理并告警。
2. 平台超级管理员可查看和修改。
3. 租户管理员即使手工调用接口也不能修改。
4. UI 选择“关闭”后数据库保存 `0`,不是 `2`。
5. UI 重新加载时 `0` 正确显示为“关闭”。
6. IM 服务在 30 秒内读取到新值。
7. 开关审计包含操作者、旧值、新值和 Request ID。
### 16.2 租户隔离
1. 租户 A 管理员只能看到租户 A 邀请码。
2. 修改 Query、Body 或 Header 中的 tenant ID 不能访问租户 B。
3. 未绑定管理员不能回退到 default 租户。
4. 停用租户不能生成、查询、撤销或导出。
5. 平台超级管理员选择租户后只能操作所选租户,切换时列表立即清空并重新请求。
### 16.3 邀请码
1. 单个生成返回恰好 5 位 `[A-Za-z]`。
2. 批量生成数量正确,最大值限制生效。
3. `AbCdE` 和 `abcde` 可作为不同邀请码精确查询。
4. 重复提交相同幂等键不产生第二个批次。
5. active 可撤销,expired/revoked 不可撤销。
6. 同一 active 邀请码可供多个不同用户注册,累计使用次数正确增长且状态保持 active。
7. 列表、详情和导出权限相互独立。
8. 导出文件数量、过滤条件和明文字段正确。
### 16.4 安全和日志
1. `hg_sys_log.get_data/post_data/header_data` 中找不到真实邀请码和服务 secret。
2. 应用日志中找不到邀请码明文、内部 Token 和 Authorization。
3. 每个敏感操作都有 HotGo 审计和 IM 审计,并可通过 Request ID 对应。
4. 无权限接口返回 403,不能只依赖前端按钮隐藏。
5. 数据库连接和 SQL 错误不泄露内部实现信息。
## 17. 完成标准
满足以下条件才能认为 HotGo 第 4 项完成:
1. 全局注册短信开关可由平台超级管理员配置,关闭值正确保存为 `0`。
2. IM 服务能在约定缓存时间内读取开关,缺失和异常时采用安全默认值。
3. HotGo 管理员身份能可靠映射到 `im_tenant.id`,不存在默认租户回退。
4. 邀请码单个、批量、查询、详情、撤销和导出全部通过所选租户库完成。
5. 平台与租户权限、跨租户隔离和服务端复核全部通过测试。
6. 邀请码明文只出现在授权页面和授权导出中,不进入普通日志。
7. 配置修改和邀请码敏感操作均有可关联的审计记录。
8. 后端 Go 测试、前端类型检查/构建和核心页面联调通过。
## 18. 新会话可直接使用的任务说明
在新的开发会话中可直接发送:
```text
请修改 HotGo 项目 /Users/youshunlai/go_workspace/im_manager。
先完整阅读 /Users/youshunlai/go_workspace/im_server/docs/IM-HotGo注册短信开关与邀请码管理改造交接文档.md,
再阅读其中列出的上游 IM 文档。按照交接文档的开发顺序完成 HotGo 的注册短信全局开关、
统一租户身份、租户库直连、邀请码管理后台、菜单权限、日志脱敏和审计。
只允许通过服务端 `im_tenant` 注册信息选择租户库,不要信任浏览器自报 tenant_id 或 DSN,
不要修改 `im_registration_invite_uses`,不要把邀请码明文写入普通日志。
开始前先检查 im_manager 当前工作区已有改动,并保留与本任务无关的用户修改。
```
+298
View File
@@ -0,0 +1,298 @@
# IM 第一至十一章后续增强项
> 日期:2026-08-13
> 范围:第一章登录注册与安全、第二章消息首页/导航/系统通知、第三章单聊关系、第四章群消息与 @、第五章通讯录/好友/黑名单、第六章群聊创建/资料/治理、第七章文件/收藏/聊天记录检索、第八章实时语音与视频通话、第九章企业工作台与组织架构、第十章个人设置与客户端能力、第十一章反诈安全/举报/反馈
> 用途:记录当前已完成基线之外的增强项,后续统一排期处理。
## 1. 第一章:登录、注册与安全
### 1.1 当前已完成基线
1. 企业码解析、租户状态校验和公开租户配置返回。
2. 注册、登录前验证码发送与验证。
3. 注册验证码 Token 消费,避免绕过验证码直接注册。
4. 忘记密码挑战验证与重置确认。
5. Token 绑定 `auth_version`,密码重置后旧 Token 失效。
6. 登录后敏感操作二次验证挑战。
7. 实名认证状态查询与提交,证件信息脱敏/保护存储。
8. 独立密码设置与验证,包含弱密码规则和失败锁定。
9. 登录后 bootstrap 返回实名、安全与菜单基础能力。
### 1.2 后续增强项
| 优先级 | 增强项 | 说明 | 建议验收点 |
| --- | --- | --- | --- |
| P0 | HTTP `Idempotency-Key` 统一存储与请求摘要冲突检测 | 当前部分接口要求幂等键,但还没有统一的 `tenant_id + uid + method + route + key + body_hash` 处理层。 | 相同键相同 body 返回同一结果;相同键不同 body 返回冲突。 |
| P0 | 验证码真实发送通道 | 当前验证码生成、校验和限频链路已具备,短信/邮件供应商发送仍需接入真实服务。 | 验证码可通过供应商下发;供应商失败有可观测错误和重试策略。 |
| P0 | 验证码限频维度补强 | 需要按租户、手机号、IP、设备、场景组合限频,并落库或接入 Redis。 | 高频请求返回 `VERIFICATION_RATE_LIMITED`,不同场景互不误伤。 |
| P0 | 安全事件通知联动 | 密码重置、实名提交/审核、独立密码变更、新设备登录等应写入系统通知。 | 操作成功后 `/v1/im/system-notifications` 能看到对应事件。 |
| P1 | 实名审核工作流 | 当前支持提交和状态查询,后续需要后台审核、驳回原因、通过后状态变更。 | 审核通过后 `realname.status=verified`;驳回可再次提交。 |
| P1 | 独立密码用途扩展 | 当前按 `purpose` 存储,可继续接入青少年模式关闭、敏感设置修改等业务入口。 | 不同 `purpose` 相互隔离;错误次数和锁定互不串扰。 |
| P1 | 设备与会话管理 | 需要登录设备列表、主动踢出设备、Token 刷新/撤销审计。 | 修改密码或踢设备后对应 Token 立即不可用。 |
| P1 | API Key 轮换策略 | 当前 API Key 校验兼容现有逻辑,尚未实现最低可接受序号或撤销表。 | 旧序号 Key 可按配置禁用,新 Key 不影响正常客户端。 |
| P2 | 风控与反诈策略接入 | 第一章安全动作后续应接入风险评分、反诈弹窗和二次确认策略。 | 高风险动作触发二次验证或反诈提示,低风险动作直通。 |
## 2. 第二章:消息首页、全局导航与系统通知
### 2.1 当前已完成基线
1. `GET /v1/im/bootstrap` 返回首页菜单、能力开关、权限和 `message_policy`。
2. `GET /v1/im/search/conversations` 支持会话搜索和基础分页。
3. WebSocket 发消息支持 `Head["x-im-client-mid"]` 幂等,重连重发不重复落库/广播。
4. 消息 Header 基础校验:`x-im-schema`、`type`、`x-im-client-mid`。
5. 普通用户禁止发布 `sys` Topic,且禁止伪造 `Head.type=system`。
6. `GET /v1/im/topics/{topic}/messages/{seq}/receipts` 返回群消息送达/已读基础列表。
7. `POST /v1/im/topics/{topic}/messages/{seq}/recall` 支持版本校验、权限校验和时限内撤回。
8. `POST /v1/im/topics/{topic}/messages/{seq}/revisions` 支持文本消息编辑、版本递增和修订记录。
9. `GET /v1/im/system-notifications` 和 `POST /v1/im/system-notifications/{id}/read`。
### 2.2 后续增强项
| 优先级 | 增强项 | 说明 | 建议验收点 |
| --- | --- | --- | --- |
| P0 | 编辑/撤回多端实时事件广播 | 当前 HTTP 更新 DB 并返回成功,后续需要向 Topic 在线成员广播编辑/撤回事件。 | A 端撤回/编辑后,B 端无需刷新即可收到事件并更新 UI。 |
| P0 | 编辑/撤回事件 Outbox | DB 更新和实时广播应通过事务事件或 Outbox 衔接,避免更新成功但事件丢失。 | 模拟广播失败后,后台任务可补发事件。 |
| P0 | 消息变更查询占位策略 | 已撤回消息在普通拉取中应返回撤回占位,而不是仅靠客户端本地事件。 | 新设备登录拉历史时能看到撤回占位和版本。 |
| P0 | `Idempotency-Key` 接入编辑/撤回 | 编辑/撤回目前依赖乐观版本控制,后续需要同时支持 HTTP 幂等键。 | 客户端超时重试同一请求不会重复写 revision。 |
| P1 | 群回执隐私和角色策略 | 当前返回基础 read/recv 列表,后续需按租户策略、群规模、角色决定是否展示具体成员。 | 普通成员/管理员在不同策略下看到不同粒度回执。 |
| P1 | 群回执 cursor 精确分页 | 当前 cursor 是基础位置分页,后续可升级为稳定游标,避免成员变动造成跳页。 | 成员新增/删除时分页不重复、不漏主要数据。 |
| P1 | `message_policy` 租户配置化 | 当前 recall/edit 窗口和菜单能力是固定值,后续需要来自租户配置。 | 不同租户可配置不同撤回/编辑时限和菜单能力。 |
| P1 | 系统通知可信生产通道 | 当前已提供通知存储和读取接口,后续需要统一服务端生产 API/事件消费者,禁止业务散落直写。 | 安全、群、组织等模块通过统一通道创建通知。 |
| P1 | 系统通知 action 白名单 | `action.type` 和参数需要统一注册、校验和版本化。 | 非白名单 action 写入失败;客户端只收到可识别跳转。 |
| P1 | 会话搜索排序与类型过滤 | 当前按现有订阅数据过滤,后续需支持会话类型、置顶、免打扰、最近活跃排序等。 | 搜索结果与消息首页排序、筛选规则一致。 |
| P2 | 离线重连恢复编排测试 | 需要端到端覆盖 `hi -> token login -> sub me -> 恢复 Topic -> get data`。 | 断网重连后未丢消息、未重复消息、未错乱回执。 |
| P2 | 消息 Header schema 版本演进 | 当前做基础格式/枚举校验,后续需要按 `x-im-schema` 版本维护兼容矩阵。 | 老客户端消息可兼容,新客户端非法字段被拒绝。 |
## 3. 第三章:单聊关系、权限与会话上下文
### 3.1 当前已完成基线
1. 单聊会话上下文接口返回双方关系、发送能力、对端公开资料和 P2P Topic。
2. P2P 发送路径接入好友关系校验,非好友、拉黑状态下禁止直接发送。
3. 好友申请、通过、拒绝、删除好友与单聊关系状态联动。
4. 拉黑/解除拉黑后关系上下文即时反映 `blocked_by_me`、`blocked_by_peer` 等状态。
5. 会话侧可根据 `can_send` 锁定输入框,并引导重新添加好友。
### 3.2 后续增强项
| 优先级 | 增强项 | 说明 | 建议验收点 |
| --- | --- | --- | --- |
| P0 | P2P 策略统一入口 | 当前核心路径已校验好友/黑名单,后续需要把发消息、通话、名片、文件、历史查看统一收敛到同一策略服务。 | 任一 P2P 业务入口对同一关系状态返回一致权限。 |
| P0 | 关系变更实时同步 | 好友通过、删除、拉黑、解除拉黑后,应向双方在线端推送关系和会话能力变化。 | A 拉黑 B 后,B 端输入框无需刷新自动锁定。 |
| P0 | 关系事件 Outbox | 关系 DB 状态变化与系统通知、会话事件、Push 需要事务级衔接。 | 模拟推送失败后后台可补发,不产生重复事件。 |
| P1 | Pending 会话体验增强 | 待处理好友申请对应的单聊页应携带申请摘要、来源、过期时间和快速处理入口。 | 进入 pending 会话可直接通过/拒绝,不需要跳转新朋友页。 |
| P1 | 黑名单历史消息策略 | 当前发送/通话阻断已具备,后续需明确拉黑后历史消息、资料、文件和已读回执可见范围。 | 被拉黑人在不同策略下看到一致的历史与资料裁剪结果。 |
| P1 | 非好友资料隐私裁剪 | 单聊上下文中的对端资料需要接入隐私设置、企业可见性和青少年模式。 | 非好友只能看到允许公开字段;好友看到扩展字段。 |
| P1 | 好友关系审计 | 删除、拉黑、解除拉黑、申请处理等敏感关系操作写审计日志。 | 后台可按租户、操作者、目标用户查询操作流水。 |
| P2 | 关系状态迁移测试矩阵 | 需要覆盖 none、pending_inbound、pending_outbound、friend、blocked_by_me、blocked_by_peer 的全迁移。 | 任意状态迁移后 DB、HTTP 上下文、WS 权限一致。 |
## 4. 第四章:群消息、群成员与 @ 能力
### 4.1 当前已完成基线
1. 群成员列表接口支持基础分页、搜索和 `mentionable` 过滤。
2. 返回成员角色、公开资料、ACL 和是否可被 @。
3. 群 @ 消息头部字段校验,支持 `mentioned_user_ids`、`mention_all` 等基础结构。
4. 群 @ 权限接入成员 ACL,管理员/群主才具备 @ 全体能力。
5. 群消息读取链路保留 @ 元数据,客户端可据此展示提醒。
### 4.2 后续增强项
| 优先级 | 增强项 | 说明 | 建议验收点 |
| --- | --- | --- | --- |
| P0 | @ 实时通知与离线 Push | 当前消息元数据已落库,后续需要对被 @ 成员生成高优先级提醒和离线 Push。 | 被 @ 用户在在线、离线、多端场景均收到一次提醒。 |
| P0 | @ 全体频控 | @ 全体需要按租户、群、角色、时间窗口限频,避免刷屏。 | 超限返回稳定错误码,管理员可配置限额。 |
| P0 | 群成员稳定游标分页 | 当前成员列表以基础 cursor 分页,后续应使用稳定游标避免成员变动跳页。 | 成员入群/退群时翻页不重复、不漏主要数据。 |
| P1 | 群成员资料隐私裁剪 | 群内非好友主页需要按群关系、好友关系、隐私设置裁剪资料字段。 | 同一成员从群内和好友主页进入时字段符合策略差异。 |
| P1 | 群昵称/角色变更事件 | 群昵称、角色、禁言、移除等变化需要实时推送给群成员并刷新成员列表缓存。 | 群主修改成员角色后在线端成员列表自动更新。 |
| P1 | @ 候选排序优化 | @ 面板候选应支持拼音首字母、最近互动、角色优先和本地缓存协同。 | 大群搜索响应稳定,候选排序符合产品规则。 |
| P1 | 群治理策略配置化 | 成员私聊、成员邀请、全员禁言、上传权限等应统一由群治理配置控制。 | 不同群可配置不同治理能力,HTTP 与 WS 权限一致。 |
| P2 | 大群性能压测 | 需要覆盖 500、2000、10000 成员下的成员列表、@ 搜索和发送性能。 | 达到目标延迟和资源占用阈值。 |
## 5. 第五章:通讯录、好友关系与黑名单
### 5.1 当前已完成基线
1. 好友列表支持查询、首字母、分页,返回备注、头像、显示名来源和会话 Topic。
2. 新朋友列表、好友申请、通过、拒绝链路已具备。
3. 用户搜索支持账号、精准账号和手机号类型,返回公开资料、关系和 `action`。
4. 添加好友二维码支持短期签名 Token 生成与解析。
5. 好友个人主页返回关系、资料和操作能力。
6. 群内非好友个人主页返回群角色、关系、资料和操作能力。
7. 好友备注更新、删除好友、拉黑/解除拉黑已具备。
8. 黑名单列表和批量移出黑名单已具备。
### 5.2 后续增强项
| 优先级 | 增强项 | 说明 | 建议验收点 |
| --- | --- | --- | --- |
| P0 | 好友/黑名单操作统一幂等 | 拉黑、解除、删除、批量移出、好友申请等需要统一接入 `Idempotency-Key` 存储和请求摘要。 | 客户端超时重试不会重复创建申请或重复触发事件。 |
| P0 | 好友关系实时事件 | 申请、通过、拒绝、删除、备注、拉黑等状态变化需要向相关在线端实时同步。 | 两端关系页、会话页和通讯录无需刷新自动更新。 |
| P0 | 用户搜索隐私策略 | 当前搜索基线可用,后续需要接入 `allow_user_search`、添加方式、手机号可搜索等租户和个人隐私设置。 | 禁止手机号添加的用户无法通过手机号被搜索到,且不泄露存在性。 |
| P0 | 二维码吊销与一次性策略 | 当前二维码是短期签名 Token,后续如需主动失效、刷新覆盖、扫码审计,需要落库或接入 Redis。 | 用户刷新二维码后旧码按策略失效;后台可查询扫码记录。 |
| P1 | 好友资料字段精细裁剪 | 个人主页需按好友、非好友、群内关系、黑名单、实名/青少年等规则返回不同字段。 | 不同入口返回字段符合隐私矩阵。 |
| P1 | 通讯录排序与拼音索引 | 当前支持基础 initial,后续需维护昵称/备注拼音、特殊字符分组和本地增量同步。 | 好友列表与客户端通讯录排序一致,中文首字母稳定。 |
| P1 | 黑名单占位资料 | 资料删除、离职或不可见时,黑名单列表仍需返回可解除的受控占位用户。 | 黑名单中失效用户可展示占位并成功移出。 |
| P1 | 好友申请防骚扰 | 需要按目标用户、申请人、IP、设备、来源和群来源限频,并支持拒收策略。 | 高频申请被限制;群来源申请校验双方仍在群内。 |
| P1 | 关系审计和安全通知 | 添加、删除、拉黑、解除、二维码解析等应写审计并按策略生成系统通知。 | 用户可在通知中看到关键关系事件,后台可追溯。 |
| P2 | 批量接口部分失败语义增强 | 批量移出黑名单目前返回逐项结果,后续需要统一批量接口错误码、重试建议和幂等结果缓存。 | 部分失败时客户端能只重试失败项。 |
## 6. 第六章:群聊创建、资料维护与成员治理
### 6.1 当前已完成基线
1. 群列表、创建群、群资料查询与更新、群头像和群简介保存接口已具备。
2. 群设置、个人群设置、退出群、群昵称接口已具备。
3. 群二维码生成与扫码解析支持 `join_group` 场景。
4. 群邀请、进群申请、申请列表、同意/拒绝、群 summary 申请计数已具备。
5. 群公告最新查询、发布/更新、详情和已读接口已具备。
6. 群治理配置 `GET/PATCH /governance` 已具备。
7. 管理员候选、管理员上限、批量添加管理员、删除管理员和群主转让接口已具备。
8. 禁言候选、批量禁言、禁言列表和解除禁言接口已具备。
9. 当前实现优先复用 Topic、Subscription 和 Topic Aux,把产品主链路接口跑通。
### 6.2 后续增强项
| 优先级 | 增强项 | 说明 | 建议验收点 |
| --- | --- | --- | --- |
| P0 | 群业务独立表拆分 | 当前群公告、进群申请、治理配置、禁言记录先存 Topic Aux,后续需要拆为独立表。 | 公告、申请、治理、禁言均有 tenant/topic 维度索引,可稳定分页和审计。 |
| P0 | 群治理强事务状态机 | 邀请、申请审批、管理员变更、群主转让、禁言需在事务内同步业务表、订阅 ACL、成员数和事件。 | 并发审批/转让/禁言不会出现 ACL 与业务状态不一致。 |
| P0 | 第六章幂等键落库 | 邀请、申请处理、公告发布、治理 PATCH、管理员批量、群主转让、禁言批量都需要统一幂等。 | 超时重试同一请求返回同一结果,不重复加人、升权或发事件。 |
| P0 | 系统事件、Push 与 Outbox | 第六章所有成员/公告/治理变化需要生成可信群系统事件,并通过 Outbox 保证推送。 | 在线端实时刷新;模拟推送失败后可补偿且不重复。 |
| P1 | 群公告已读独立记录 | 当前公告已读返回基础结果,后续需记录每个成员的公告已读时间。 | 新公告只提醒未读成员,已读状态跨端同步。 |
| P1 | 进群申请隐私与限频 | 申请入口需要按群治理、租户策略、用户状态、来源、IP/设备限频。 | 高频申请被限制;普通成员不可看到敏感申请人信息。 |
| P1 | 管理员/群主角色层级策略 | 管理员互相操作、管理员禁言、群主转让二次认证等需要完整策略矩阵。 | 群主、管理员、普通成员在每个治理动作上的权限稳定一致。 |
| P1 | 禁言发送链路强校验 | 当前禁言接口已具备,后续需要在 WebSocket 发消息路径校验 `GROUP_MUTED`。 | 被禁言成员发群消息被服务端拒绝,过期后自动恢复。 |
| P1 | 群文件索引 | 6.27-6.28 需要独立群文件表,绑定上传文件、群成员权限、下载状态和删除权限。 | 群文件列表可搜索/分页,退群后不可下载旧 URL。 |
| P1 | 群解散与二维码吊销 | 群解散、二维码撤销、邀请失效需要状态联动和权限回收。 | 解散后 Topic、二维码、邀请、文件新增权限全部失效。 |
| P2 | 大群性能与稳定分页 | 成员、申请、公告、禁言、文件列表都需要稳定游标和大群压测。 | 大群场景下分页不重复不漏,接口延迟达标。 |
## 7. 第七章:文件下载、收藏管理与聊天记录检索
### 7.1 当前已完成基线
1. 收藏创建、列表、详情和删除接口已具备,支持文本、文件、语音、链接等类型。
2. 收藏批量创建和基础分享前的数据结构已具备。
3. Topic 消息关键词搜索、文件/图片/视频等媒体类型检索接口已具备。
4. 消息上下文定位接口可围绕指定 seq 返回前后消息。
5. 文件访问已接入用户与 Topic 权限校验,下载前可判断资源可见性。
### 7.2 后续增强项
| 优先级 | 增强项 | 说明 | 建议验收点 |
| --- | --- | --- | --- |
| P0 | 收藏幂等与去重策略 | 收藏同一消息、同一文件或同一链接需要统一去重和幂等键处理。 | 重复点击收藏不会产生重复记录;超时重试返回同一收藏 ID。 |
| P0 | 收藏分享权限二次校验 | 收藏文件或消息分享给好友/群时,需要重新校验接收方是否可访问原资源或生成受控副本。 | 无权限接收方不能通过收藏分享拿到原会话附件。 |
| P0 | 文件下载短时签名 URL | 当前权限判断已具备,后续应生成短时下载 URL,并记录下载审计。 | URL 过期不可访问;退群/拉黑后旧 URL 失效或受策略限制。 |
| P0 | 文件安全扫描与 MIME 校验 | 上传后的文件需要扩展名、MIME、内容嗅探、病毒扫描和图片/视频解码安全检查。 | 高风险文件不可下载或转发,客户端收到稳定错误码。 |
| P1 | 收藏分类与全文索引 | 收藏列表需要按类型、来源、时间、关键词、发送人等维度检索。 | 大量收藏下搜索响应稳定,类型筛选结果准确。 |
| P1 | 聊天记录稳定游标 | 消息搜索和媒体列表需要稳定游标,避免新消息插入导致翻页重复或漏项。 | 搜索期间新增消息不影响当前翻页结果。 |
| P1 | 历史消息权限快照策略 | 用户退群、删除好友、拉黑后,历史消息和附件的可见范围需要统一规则。 | 各入口对同一历史资源权限判断一致。 |
| P1 | 下载状态与断点续传 | 大文件下载需要断点续传、失败重试、进度状态和下载完成事件。 | 网络中断后可续传;下载完成状态可跨端同步或按端隔离。 |
| P2 | 聊天记录高亮与命中片段 | 搜索结果可返回命中摘要、高亮范围和消息类型摘要。 | 客户端无需二次解析即可展示命中片段。 |
| P2 | 收藏导出与批量管理 | 后续可支持收藏批量删除、批量转发、导出和云端容量统计。 | 批量操作有部分失败语义和可重试结果。 |
## 8. 第八章:实时语音与视频通话
### 8.1 当前已完成基线
1. 通话 invite、accept、offer、answer、ice-candidate、hang-up 等事件沿用 Tinode call event 链路。
2. 通话记录表 `im_call_records` 已具备,记录 `call_id`、Topic、seq、主叫/被叫、类型、状态、时长和结束原因。
3. 通话记录创建、接听和结束状态更新已接入 store/adapter。
4. 通话记录查询接口已具备:全局 `GET /v1/im/calls` 与 Topic 内 `GET /v1/im/topics/{topic}/calls`。
5. 查询结果返回对端用户、方向、通话类型、状态、时长和关键时间点,客户端可先完成通话记录页对接。
### 8.2 后续增强项
| 优先级 | 增强项 | 说明 | 建议验收点 |
| --- | --- | --- | --- |
| P0 | 跨设备抢答状态机 | 同一账号多端收到来电时,需要一个设备接听后自动取消其他端待接状态。 | A 手机接听后,A 平板和 Web 端立即变为已在其他设备接听。 |
| P0 | 通话状态强一致与幂等 | invite、accept、hang-up 等事件需要幂等键或事件序号,避免乱序/重复导致状态回退。 | 重复 hang-up 不改变最终状态;迟到 accept 不会覆盖已结束通话。 |
| P0 | 通话事件 Outbox 与通知 | 来电、未接、挂断、忙线等需要可信事件和 Push,DB 状态与通知通过 Outbox 衔接。 | 离线用户收到一次来电/未接提醒,失败可补偿。 |
| P0 | TURN/STUN 配置与媒体准入 | 服务端应按租户/区域返回可用 ICE 配置,并限制未授权用户创建媒体会话。 | 客户端拉取到有效 TURN;无权限通话被服务端拒绝。 |
| P1 | 结束原因标准化 | 需要统一 `missed`、`declined`、`busy`、`timeout`、`network_error` 等原因和客户端展示文案。 | 不同端同一结束原因展示一致,统计口径一致。 |
| P1 | 通话质量指标 | 需要采集建立耗时、ICE 成功率、丢包、抖动、断连等指标。 | 后台可按租户和版本查看通话质量趋势。 |
| P1 | 群通话/会议房间拆分 | 群音视频不宜复用 P2P call,需要会议房间、参与人状态和 SFU 接入。 | 群通话有独立 room_id、成员列表和退出/重进状态。 |
| P1 | 通话权限统一策略 | 通话入口应复用 P2P/群关系策略、黑名单、禁言、青少年模式和企业策略。 | 被拉黑、非好友或受限制用户无法发起通话。 |
| P2 | 通话记录聚合与清理 | 通话记录可按会话、日期、未接分类聚合,并支持保留周期清理。 | 记录列表分页稳定,超过保留期按策略清理。 |
## 9. 第九章:企业工作台与办公协同
### 9.1 当前已完成基线
1. 工作台接口 `GET /v1/im/workbench` 已具备,返回模块、布局版本和企业功能开关。
2. 工作台模块表 `im_workbench_apps` 已具备,默认包含组织架构、会议通知、日报、月报、总经理信箱入口。
3. 服务端对工作台 route 做内部白名单过滤,避免客户端执行任意服务端 URL。
4. 组织架构部门表 `im_org_departments` 和成员表 `im_org_members` 已具备。
5. 部门列表 `GET /v1/im/organization/departments`、部门成员 `GET /v1/im/organization/departments/{id}/members`、组织搜索 `GET /v1/im/organization/search` 已具备。
6. 会议通知 `POST/GET/PATCH/CANCEL/REMIND/MY-RESPONSE` 已具备基线,包含参与人和版本冲突控制。
7. 日报/月报草稿、提交、详情、列表、统计与提醒基线已具备。
8. 总经理信箱配置与提交接口已具备,包含匿名策略、文案版本和幂等键。
### 9.2 后续增强项
| 优先级 | 增强项 | 说明 | 建议验收点 |
| --- | --- | --- | --- |
| P0 | 工作台权限与功能开关配置化 | 当前模块基线可返回,后续需要按租户、部门、角色、后台配置动态裁剪。 | 不同用户登录同一租户看到不同授权模块。 |
| P0 | 工作台未读角标事件 | 文档要求 `workbench.badge.updated`,后续需要模块未读聚合和实时增量事件。 | 会议、日报、信箱未读变化后工作台角标实时更新。 |
| P0 | 组织数据后台维护链路 | 当前前台读接口和表已具备,后续需要后台导入、增删改部门、调整成员和离职状态。 | 后台修改组织后前台接口版本变化,客户端可刷新。 |
| P0 | 组织权限与可见字段裁剪 | 成员手机号、邮箱、职位等字段需要按组织角色、隐私和企业策略裁剪。 | 普通员工和管理员看到的成员字段不同且不可绕过。 |
| P1 | 组织搜索索引优化 | 当前基础 LIKE 搜索可用,后续需要拼音、工号、手机号脱敏匹配和稳定排序。 | 大组织下搜索延迟达标,结果排序符合组织规则。 |
| P1 | 组织版本与增量同步 | 部门和成员返回版本,后续需要全局组织版本、变更事件和客户端增量刷新。 | 切换部门或人员变更后客户端无需全量重拉。 |
| P1 | 会议通知实时提醒 | 当前接口和表已具备,后续需要会议创建、更新、取消、响应后的在线事件和 Push。 | 参与人在线端无需刷新即可收到会议变化。 |
| P1 | 日报/月报后台统计口径 | 当前基础统计可用,后续需要按部门、周期、迟交、补交、未读等维度扩展。 | 管理员看板与业务口径一致。 |
| P1 | 总经理信箱后台流转 | 当前前台配置/提交已具备,后续需要后台处理、回复、转派、归档和通知。 | 信件全生命周期可追踪,匿名身份受保护。 |
| P1 | 办公协同审计 | 组织查询、会议变更、报告查看、信箱提交等敏感动作需要审计。 | 后台可按租户、用户、对象查询办公操作流水。 |
| P2 | 办公模块统一附件策略 | 会议、日报、月报、信箱附件需要复用第七章文件 ACL、用途和病毒扫描。 | 附件上传、查看、下载权限与业务状态一致。 |
## 10. 个人设置与客户端能力
### 10.1 当前已完成基线
1. 个人资料 `GET/PATCH /v1/im/me/profile`、个人页 `GET /v1/im/me` 已具备。
2. 隐私设置 `GET/PATCH /v1/im/me/privacy` 已具备,支持版本冲突控制。
3. 最新版本检查 `GET /v1/im/app/releases/latest` 已具备,支持未登录按租户解析调用。
4. 青年模式查询、开启、关闭接口已具备,复用敏感操作 `action_token` 和独立密码校验链路。
5. 设备 Push Token 覆盖写入 `PUT /v1/im/devices/{device_id}/push-token` 已具备。
### 10.2 后续增强项
| 优先级 | 增强项 | 说明 | 建议验收点 |
| --- | --- | --- | --- |
| P0 | 个人资料变更实时同步 | 资料、头像、隐私变更后需要向多端推送 `profile.updated`、`privacy.updated`。 | 同账号多端无需重登即可刷新设置。 |
| P0 | 精准登出与会话撤销 | 现有 token 主要靠 `auth_version` 批量失效,后续需要 access/refresh/session 表支持当前设备登出和指定 session 删除。 | 当前设备退出不影响其他设备;删除指定 session 后该设备 token 立即失效。 |
| P0 | 青年模式策略配置化 | 当前返回默认 restrictions,后续需要后台按租户配置可用时段、内容限制、消费保护等规则。 | 不同租户返回不同限制策略且能热更新。 |
| P0 | Push Token 生命周期 | 当前可覆盖写入,后续需要 token 失效回收、设备解绑、平台厂商错误码处理。 | 推送失败后自动禁用无效 token,重新登录可恢复。 |
| P1 | 版本灰度发布 | 当前按 platform/channel 查最新版本,后续需要按租户、用户、灰度比例、最低版本强制升级。 | 灰度用户收到新版本,非灰度用户保持稳定版本。 |
| P1 | 资料隐私裁剪落地 | 隐私设置需要接入用户搜索、组织架构、会话资料页等读路径。 | 非授权用户无法通过其他接口绕过隐私配置。 |
| P2 | 客户端能力上报 | 后续增加设备能力、通知权限、厂商通道状态和前台活跃状态上报。 | 后台可定位推送不可达和版本兼容问题。 |
## 11. 反诈安全、举报与意见反馈
### 11.1 当前已完成基线
1. URL 安全检查 `POST /v1/im/security/urls/check` 已接入风控 challenge 创建。
2. 风控确认 `POST /v1/im/security/risk-challenges/{id}/confirm` 已具备,`continue` 返回短时 `risk_action_token`。
3. 举报原因 `GET /v1/im/reports/abuse/reasons` 已具备,默认覆盖用户、群、消息目标。
4. 举报提交 `POST /v1/im/reports/abuse` 已具备,支持幂等键、证据文件 ID、目标基础可见性校验和审核快照。
5. 意见反馈配置 `GET /v1/im/feedback/config`、提交 `POST /v1/im/feedback` 已具备,支持附件规则、诊断授权和幂等键。
### 11.2 后续增强项
| 优先级 | 增强项 | 说明 | 建议验收点 |
| --- | --- | --- | --- |
| P0 | 风控策略引擎 | 当前 URL 检查仅基础规则,后续需要按动作、关系、内容、设备、频率和租户策略综合判定。 | 高风险发消息、加好友、打开链接等动作统一返回风控 challenge。 |
| P0 | 风控 token 消费接入业务接口 | 当前确认可签发 `risk_action_token`,后续需要原业务接口校验并单次消费该 token。 | 二次确认后只允许重试原动作一次,重复使用返回 Token 已消费。 |
| P0 | 举报目标完整权限校验 | 当前做基础可见性校验,后续需要结合用户侧删除、撤回、退群、黑名单、文件 ACL 和审核状态。 | 无权限目标无法提交举报,也不泄露目标存在性。 |
| P0 | 审核后台流转 | 举报和反馈当前仅 received,后续需要后台分派、处理、关闭、回复和通知。 | 处理状态可追踪,举报人按策略收到处理结果。 |
| P1 | 证据文件安全与保留期 | 证据文件需要 `abuse_evidence` 专用 ACL、病毒扫描、不可转发和保留期策略。 | 证据只能审核链路访问,过期按政策清理。 |
| P1 | 反诈系统事件可信生产 | 低风险提示需通过可信 `security.risk_notice` 系统事件发送,并限制普通消息伪装。 | 普通用户无法伪造反诈弹窗,客户端只信任系统事件。 |
| P1 | 举报合并与限频 | 重复举报需要合并、返回原受理号或限频错误,避免刷单。 | 短时间重复举报同一目标不会生成大量工单。 |
| P2 | 反馈诊断包脱敏 | 诊断包需统一脱敏、大小限制、客户端日志授权和服务端下载审计。 | 未同意时没有诊断包,同意后包内不含明文 token/手机号。 |
## 12. 统一处理建议
1. 先做 P0:统一幂等、实时事件/Outbox、验证码真实发送与限频、P2P/好友/搜索隐私策略、第六章群业务独立表与事务状态机、文件下载签名、通话强状态机、工作台/组织权限。
2. 再做 P1:租户策略配置、系统通知生产通道、实名审核、设备管理、资料隐私裁剪、关系审计、群禁言发送校验、群文件索引、通话质量指标、会议/日报/月报/信箱完整业务表。
3. 最后做 P2:端到端重连测试、schema 版本演进、大群压测、批量接口体验优化、收藏导出、通话记录聚合和办公模块附件统一策略。
4. 每个增强项落地前同步更新 `IM-系统设计文档.md` 和 `IM-前后端对接文档.md` 中对应章节状态。
@@ -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
}
+6 -2
View File
@@ -15,6 +15,7 @@ require (
github.com/go-pay/crypto v0.0.1 github.com/go-pay/crypto v0.0.1
github.com/go-pay/gopay v1.5.114 github.com/go-pay/gopay v1.5.114
github.com/go-pay/smap v0.0.2 github.com/go-pay/smap v0.0.2
github.com/go-sql-driver/mysql v1.7.1
github.com/gogf/gf/contrib/drivers/mysql/v2 v2.10.0 github.com/gogf/gf/contrib/drivers/mysql/v2 v2.10.0
github.com/gogf/gf/contrib/drivers/pgsql/v2 v2.10.0 github.com/gogf/gf/contrib/drivers/pgsql/v2 v2.10.0
github.com/gogf/gf/contrib/nosql/redis/v2 v2.10.0 github.com/gogf/gf/contrib/nosql/redis/v2 v2.10.0
@@ -34,9 +35,12 @@ require (
github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common v1.0.1202 github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common v1.0.1202
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/tinode/snowflake v1.0.0
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/crypto v0.41.0
golang.org/x/mod v0.26.0 golang.org/x/mod v0.26.0
golang.org/x/net v0.43.0 golang.org/x/net v0.43.0
golang.org/x/tools v0.35.0 golang.org/x/tools v0.35.0
@@ -80,7 +84,6 @@ require (
github.com/go-pay/xlog v0.0.3 // indirect github.com/go-pay/xlog v0.0.3 // indirect
github.com/go-pay/xtime v0.0.2 // indirect github.com/go-pay/xtime v0.0.2 // indirect
github.com/go-redis/redis/v8 v8.11.5 // indirect github.com/go-redis/redis/v8 v8.11.5 // indirect
github.com/go-sql-driver/mysql v1.7.1 // indirect
github.com/goccy/go-json v0.10.5 // indirect github.com/goccy/go-json v0.10.5 // indirect
github.com/gofrs/flock v0.8.1 // indirect github.com/gofrs/flock v0.8.1 // indirect
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect
@@ -138,6 +141,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
@@ -147,7 +152,6 @@ require (
go.opentelemetry.io/otel/sdk v1.38.0 // indirect go.opentelemetry.io/otel/sdk v1.38.0 // indirect
go.opentelemetry.io/otel/trace v1.38.0 // indirect go.opentelemetry.io/otel/trace v1.38.0 // indirect
go.uber.org/atomic v1.5.1 // indirect go.uber.org/atomic v1.5.1 // indirect
golang.org/x/crypto v0.41.0 // indirect
golang.org/x/image v0.25.0 // indirect golang.org/x/image v0.25.0 // indirect
golang.org/x/lint v0.0.0-20190930215403-16217165b5de // indirect golang.org/x/lint v0.0.0-20190930215403-16217165b5de // indirect
golang.org/x/sync v0.16.0 // indirect golang.org/x/sync v0.16.0 // indirect
+9
View File
@@ -435,6 +435,8 @@ github.com/tidwall/pretty v1.2.0 h1:RWIZEg2iJ8/g6fDDYzMpobmaoGh5OLl4AXtGUGPcqCs=
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/tiendc/go-deepcopy v1.6.0 h1:0UtfV/imoCwlLxVsyfUd4hNHnB3drXsfle+wzSCA5Wo= github.com/tiendc/go-deepcopy v1.6.0 h1:0UtfV/imoCwlLxVsyfUd4hNHnB3drXsfle+wzSCA5Wo=
github.com/tiendc/go-deepcopy v1.6.0/go.mod h1:toXoeQoUqXOOS/X4sKuiAoSk6elIdqc0pN7MTgOOo2I= github.com/tiendc/go-deepcopy v1.6.0/go.mod h1:toXoeQoUqXOOS/X4sKuiAoSk6elIdqc0pN7MTgOOo2I=
github.com/tinode/snowflake v1.0.0 h1:YciQ9ZKn1TrnvpS8yZErt044XJaxWVtR9aMO9rOZVOE=
github.com/tinode/snowflake v1.0.0/go.mod h1:5JiaCe3o7QdDeyRcAeZBGVghwRS+ygt2CF/hxmAoptQ=
github.com/tinylib/msgp v1.3.0 h1:ULuf7GPooDaIlbyvgAxBV/FI7ynli6LZ1/nVUNu+0ww= github.com/tinylib/msgp v1.3.0 h1:ULuf7GPooDaIlbyvgAxBV/FI7ynli6LZ1/nVUNu+0ww=
github.com/tinylib/msgp v1.3.0/go.mod h1:ykjzy2wzgrlvpDCRc4LA8UXy6D8bzMSuAF3WD57Gok0= github.com/tinylib/msgp v1.3.0/go.mod h1:ykjzy2wzgrlvpDCRc4LA8UXy6D8bzMSuAF3WD57Gok0=
github.com/tjfoc/gmsm v1.3.2/go.mod h1:HaUcFuY0auTiaHB9MHFGCPx5IaLhTUd2atbCFBQXn9w= github.com/tjfoc/gmsm v1.3.2/go.mod h1:HaUcFuY0auTiaHB9MHFGCPx5IaLhTUd2atbCFBQXn9w=
@@ -446,6 +448,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 +626,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:"幂等键"`
}
@@ -0,0 +1,50 @@
package imtenant
import (
"context"
"fmt"
"os"
"strconv"
"sync"
"github.com/gogf/gf/v2/frame/g"
sf "github.com/tinode/snowflake"
)
const defaultSnowflakeWorkerID = 1023
var (
snowflakeOnce sync.Once
snowflakeGen *sf.SnowFlake
snowflakeErr error
)
// NextSnowflakeID returns an unencrypted Snowflake ID suitable for IM BIGINT keys.
func NextSnowflakeID(ctx context.Context) (int64, error) {
snowflakeOnce.Do(func() {
workerID := g.Cfg().MustGet(ctx, "imDirectDb.snowflakeWorkerId", defaultSnowflakeWorkerID).Int()
if value := os.Getenv("IM_DIRECT_DB_SNOWFLAKE_WORKER_ID"); value != "" {
workerID, snowflakeErr = strconv.Atoi(value)
if snowflakeErr != nil {
snowflakeErr = fmt.Errorf("invalid IM_DIRECT_DB_SNOWFLAKE_WORKER_ID: %w", snowflakeErr)
return
}
}
if workerID < 0 || workerID > 1023 {
snowflakeErr = fmt.Errorf("IM Snowflake worker ID must be between 0 and 1023")
return
}
snowflakeGen, snowflakeErr = sf.NewSnowFlake(uint32(workerID))
})
if snowflakeErr != nil {
return 0, snowflakeErr
}
id, err := snowflakeGen.Next()
if err != nil {
return 0, err
}
if id == 0 || id > uint64(^uint64(0)>>1) {
return 0, fmt.Errorf("generated invalid IM Snowflake ID")
}
return int64(id), nil
}
+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');
+5
View File
@@ -0,0 +1,5 @@
allowBuilds:
'@swc/core': true
core-js: true
esbuild: true
vue-demi: true
+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();
+11
View File
@@ -87,6 +87,17 @@
class="py-4" class="py-4"
> >
<n-grid x-gap="24" :cols="2"> <n-grid x-gap="24" :cols="2">
<n-gi v-if="hasPermission(['/imTenant/list'])">
<n-form-item label="所属租户" path="tenantId">
<n-select
v-model:value="formParams.tenantId"
:options="tenantOptions"
clearable
filterable
placeholder="平台账号可不选择租户"
/>
</n-form-item>
</n-gi>
<n-gi> <n-gi>
<n-form-item label="用户名" path="username"> <n-form-item label="用户名" path="username">
<n-input placeholder="请输入登录用户名" v-model:value="formParams.username" /> <n-input placeholder="请输入登录用户名" v-model:value="formParams.username" />