Compare commits
23
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
688685c59f | ||
|
|
b65085b68e | ||
|
|
4ddf138422 | ||
|
|
3b67ce6713 | ||
|
|
31d2f337bf | ||
|
|
6668d4a6d1 | ||
|
|
20da0ad8fc | ||
|
|
f8ae4af970 | ||
|
|
de554a7fda | ||
|
|
10fdea377a | ||
|
|
f5cb3d78c0 | ||
|
|
ddd7fdadad | ||
|
|
52ebb3a278 | ||
|
|
2a98e550fd | ||
|
|
e7c763e9c0 | ||
|
|
0be340f99f | ||
|
|
6699b9bb1b | ||
|
|
9c783870ce | ||
|
|
21feb3a2ca | ||
|
|
5c1a469501 | ||
|
|
739cff26d6 | ||
|
|
4dde868c07 | ||
|
|
bcb7aadf15 |
@@ -1,904 +0,0 @@
|
||||
# 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 下发日志。
|
||||
@@ -1,89 +0,0 @@
|
||||
# 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`,先覆盖会议提醒、日报/月报催办、总经理信箱提交通知。
|
||||
@@ -1,747 +0,0 @@
|
||||
# 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 当前工作区已有改动,并保留与本任务无关的用户修改。
|
||||
```
|
||||
@@ -1,298 +0,0 @@
|
||||
# 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` 中对应章节状态。
|
||||
@@ -1,59 +0,0 @@
|
||||
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:"配置值"`
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
// 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
|
||||
}
|
||||
@@ -108,83 +108,3 @@ type AdminLogListRes struct {
|
||||
form.PageRes
|
||||
List []*sysin.ImGroupAdminLogListModel `json:"list" dc:"数据列表"`
|
||||
}
|
||||
|
||||
// ProfileUpdateReq 通过Kafka修改IM群资料
|
||||
type ProfileUpdateReq struct {
|
||||
g.Meta `path:"/imGroup/profileUpdate" method:"post" tags:"IM群组" summary:"Kafka修改IM群资料"`
|
||||
sysin.ImGroupProfileUpdateInp
|
||||
}
|
||||
|
||||
type ProfileUpdateRes struct {
|
||||
*sysin.ImGroupCommandModel
|
||||
}
|
||||
|
||||
// GovernanceUpdateReq 通过Kafka修改IM群治理配置
|
||||
type GovernanceUpdateReq struct {
|
||||
g.Meta `path:"/imGroup/governanceUpdate" method:"post" tags:"IM群组" summary:"Kafka修改IM群治理配置"`
|
||||
sysin.ImGroupGovernanceUpdateInp
|
||||
}
|
||||
|
||||
type GovernanceUpdateRes struct {
|
||||
*sysin.ImGroupCommandModel
|
||||
}
|
||||
|
||||
// AdminSetReq 通过Kafka设置IM群管理员
|
||||
type AdminSetReq struct {
|
||||
g.Meta `path:"/imGroup/adminSet" method:"post" tags:"IM群组" summary:"Kafka设置IM群管理员"`
|
||||
sysin.ImGroupAdminSetInp
|
||||
}
|
||||
|
||||
type AdminSetRes struct {
|
||||
*sysin.ImGroupCommandModel
|
||||
}
|
||||
|
||||
// AdminRemoveReq 通过Kafka删除IM群管理员
|
||||
type AdminRemoveReq struct {
|
||||
g.Meta `path:"/imGroup/adminRemove" method:"post" tags:"IM群组" summary:"Kafka删除IM群管理员"`
|
||||
sysin.ImGroupAdminRemoveInp
|
||||
}
|
||||
|
||||
type AdminRemoveRes struct {
|
||||
*sysin.ImGroupCommandModel
|
||||
}
|
||||
|
||||
// MemberMuteReq 通过Kafka禁言IM群成员
|
||||
type MemberMuteReq struct {
|
||||
g.Meta `path:"/imGroup/memberMute" method:"post" tags:"IM群组" summary:"Kafka禁言IM群成员"`
|
||||
sysin.ImGroupMemberMuteInp
|
||||
}
|
||||
|
||||
type MemberMuteRes struct {
|
||||
*sysin.ImGroupCommandModel
|
||||
}
|
||||
|
||||
// MemberUnmuteReq 通过Kafka解除IM群成员禁言
|
||||
type MemberUnmuteReq struct {
|
||||
g.Meta `path:"/imGroup/memberUnmute" method:"post" tags:"IM群组" summary:"Kafka解除IM群成员禁言"`
|
||||
sysin.ImGroupMemberUnmuteInp
|
||||
}
|
||||
|
||||
type MemberUnmuteRes struct {
|
||||
*sysin.ImGroupCommandModel
|
||||
}
|
||||
|
||||
// MemberRemoveReq 通过Kafka移出IM群成员
|
||||
type MemberRemoveReq struct {
|
||||
g.Meta `path:"/imGroup/memberRemove" method:"post" tags:"IM群组" summary:"Kafka移出IM群成员"`
|
||||
sysin.ImGroupMemberRemoveInp
|
||||
}
|
||||
|
||||
type MemberRemoveRes struct {
|
||||
*sysin.ImGroupCommandModel
|
||||
}
|
||||
|
||||
// OwnerTransferReq 通过Kafka转让IM群主
|
||||
type OwnerTransferReq struct {
|
||||
g.Meta `path:"/imGroup/ownerTransfer" method:"post" tags:"IM群组" summary:"Kafka转让IM群主"`
|
||||
sysin.ImGroupOwnerTransferInp
|
||||
}
|
||||
|
||||
type OwnerTransferRes struct {
|
||||
*sysin.ImGroupCommandModel
|
||||
}
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
// 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
|
||||
}
|
||||
@@ -32,75 +32,3 @@ type ViewReq struct {
|
||||
type ViewRes struct {
|
||||
*sysin.ImUserViewModel
|
||||
}
|
||||
|
||||
type BanReq struct {
|
||||
g.Meta `path:"/imUser/ban" method:"post" tags:"IM用户管理" summary:"封禁IM用户"`
|
||||
sysin.ImUserGovernanceInp
|
||||
}
|
||||
|
||||
type BanRes struct {
|
||||
*sysin.ImUserCommandModel
|
||||
}
|
||||
|
||||
type UnbanReq struct {
|
||||
g.Meta `path:"/imUser/unban" method:"post" tags:"IM用户管理" summary:"解封IM用户"`
|
||||
sysin.ImUserGovernanceInp
|
||||
}
|
||||
|
||||
type UnbanRes struct {
|
||||
*sysin.ImUserCommandModel
|
||||
}
|
||||
|
||||
type FreezeReq struct {
|
||||
g.Meta `path:"/imUser/freeze" method:"post" tags:"IM用户管理" summary:"冻结IM用户"`
|
||||
sysin.ImUserGovernanceInp
|
||||
}
|
||||
|
||||
type FreezeRes struct {
|
||||
*sysin.ImUserCommandModel
|
||||
}
|
||||
|
||||
type UnfreezeReq struct {
|
||||
g.Meta `path:"/imUser/unfreeze" method:"post" tags:"IM用户管理" summary:"解冻IM用户"`
|
||||
sysin.ImUserGovernanceInp
|
||||
}
|
||||
|
||||
type UnfreezeRes struct {
|
||||
*sysin.ImUserCommandModel
|
||||
}
|
||||
|
||||
type DeviceKickReq struct {
|
||||
g.Meta `path:"/imUser/deviceKick" method:"post" tags:"IM用户管理" summary:"踢出IM用户设备"`
|
||||
sysin.ImUserGovernanceInp
|
||||
}
|
||||
|
||||
type DeviceKickRes struct {
|
||||
*sysin.ImUserCommandModel
|
||||
}
|
||||
|
||||
type LogoutAllReq struct {
|
||||
g.Meta `path:"/imUser/logoutAll" method:"post" tags:"IM用户管理" summary:"踢出IM用户全部在线会话"`
|
||||
sysin.ImUserGovernanceInp
|
||||
}
|
||||
|
||||
type LogoutAllRes struct {
|
||||
*sysin.ImUserCommandModel
|
||||
}
|
||||
|
||||
type AccountCancelReq struct {
|
||||
g.Meta `path:"/imUser/accountCancel" method:"post" tags:"IM用户管理" summary:"注销IM用户账号"`
|
||||
sysin.ImUserGovernanceInp
|
||||
}
|
||||
|
||||
type AccountCancelRes struct {
|
||||
*sysin.ImUserCommandModel
|
||||
}
|
||||
|
||||
type AccountDeleteReq struct {
|
||||
g.Meta `path:"/imUser/accountDelete" method:"post" tags:"IM用户管理" summary:"删除IM用户账号"`
|
||||
sysin.ImUserGovernanceInp
|
||||
}
|
||||
|
||||
type AccountDeleteRes struct {
|
||||
*sysin.ImUserCommandModel
|
||||
}
|
||||
|
||||
+2
-6
@@ -15,7 +15,6 @@ require (
|
||||
github.com/go-pay/crypto v0.0.1
|
||||
github.com/go-pay/gopay v1.5.114
|
||||
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/pgsql/v2 v2.10.0
|
||||
github.com/gogf/gf/contrib/nosql/redis/v2 v2.10.0
|
||||
@@ -35,12 +34,9 @@ require (
|
||||
github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common v1.0.1202
|
||||
github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/sms v1.0.1200
|
||||
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/xdg-go/scram v1.1.2
|
||||
github.com/xuri/excelize/v2 v2.9.1
|
||||
go.opentelemetry.io/otel v1.38.0
|
||||
golang.org/x/crypto v0.41.0
|
||||
golang.org/x/mod v0.26.0
|
||||
golang.org/x/net v0.43.0
|
||||
golang.org/x/tools v0.35.0
|
||||
@@ -84,6 +80,7 @@ require (
|
||||
github.com/go-pay/xlog v0.0.3 // indirect
|
||||
github.com/go-pay/xtime v0.0.2 // 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/gofrs/flock v0.8.1 // indirect
|
||||
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect
|
||||
@@ -141,8 +138,6 @@ require (
|
||||
github.com/tjfoc/gmsm v1.4.1 // indirect
|
||||
github.com/tklauser/go-sysconf v0.3.12 // indirect
|
||||
github.com/tklauser/numcpus v0.6.1 // indirect
|
||||
github.com/xdg-go/pbkdf2 v1.0.0 // indirect
|
||||
github.com/xdg-go/stringprep v1.0.4 // indirect
|
||||
github.com/xuri/efp v0.0.1 // indirect
|
||||
github.com/xuri/nfp v0.0.1 // indirect
|
||||
github.com/yusufpapurcu/wmi v1.2.4 // indirect
|
||||
@@ -152,6 +147,7 @@ require (
|
||||
go.opentelemetry.io/otel/sdk v1.38.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.38.0 // 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/lint v0.0.0-20190930215403-16217165b5de // indirect
|
||||
golang.org/x/sync v0.16.0 // indirect
|
||||
|
||||
@@ -435,8 +435,6 @@ github.com/tidwall/pretty v1.2.0 h1:RWIZEg2iJ8/g6fDDYzMpobmaoGh5OLl4AXtGUGPcqCs=
|
||||
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/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/go.mod h1:ykjzy2wzgrlvpDCRc4LA8UXy6D8bzMSuAF3WD57Gok0=
|
||||
github.com/tjfoc/gmsm v1.3.2/go.mod h1:HaUcFuY0auTiaHB9MHFGCPx5IaLhTUd2atbCFBQXn9w=
|
||||
@@ -448,12 +446,6 @@ github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+F
|
||||
github.com/tklauser/numcpus v0.6.1/go.mod h1:1XfjsgE2zo8GVw7POkMbHENHzVg3GzmoZ9fESEdAacY=
|
||||
github.com/ufilesdk-dev/ufile-gosdk v1.0.6 h1:YhTZfA0U5zjKUe6qYrIs72jjxSZryTMbS9wE76fIxMs=
|
||||
github.com/ufilesdk-dev/ufile-gosdk v1.0.6/go.mod h1:s1LIpscsgVNx0JRNbDElNNQhyNXJiumVpzvPzVNS/XI=
|
||||
github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c=
|
||||
github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI=
|
||||
github.com/xdg-go/scram v1.1.2 h1:FHX5I5B4i4hKRVRBCFRxq1iQRej7WO3hhBuJf+UUySY=
|
||||
github.com/xdg-go/scram v1.1.2/go.mod h1:RT/sEzTbU5y00aCK8UOx6R7YryM0iF1N2MOmC3kKLN4=
|
||||
github.com/xdg-go/stringprep v1.0.4 h1:XLI/Ng3O1Atzq0oBs3TWm+5ZVgkq2aqdlvP9JtoZ6c8=
|
||||
github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM=
|
||||
github.com/xuri/efp v0.0.1 h1:fws5Rv3myXyYni8uwj2qKjVaRP30PdjeYe2Y6FDsCL8=
|
||||
github.com/xuri/efp v0.0.1/go.mod h1:ybY/Jr0T0GTCnYjKqmdwxyxn2BQf2RcQIIvex5QldPI=
|
||||
github.com/xuri/excelize/v2 v2.9.1 h1:VdSGk+rraGmgLHGFaGG9/9IWu1nj4ufjJ7uwMDtj8Qw=
|
||||
@@ -626,7 +618,6 @@ golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
|
||||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
||||
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
||||
|
||||
@@ -45,9 +45,6 @@ var (
|
||||
}...)
|
||||
|
||||
s.Group("/", func(group *ghttp.RouterGroup) {
|
||||
// 注册内部服务路由
|
||||
router.Internal(ctx, group)
|
||||
|
||||
// 注册后台路由
|
||||
router.Admin(ctx, group)
|
||||
|
||||
|
||||
@@ -1,88 +0,0 @@
|
||||
// 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, "已处理"),
|
||||
}
|
||||
@@ -10,12 +10,4 @@ const (
|
||||
QueueLogTopic = `request_log` // 访问日志
|
||||
QueueLoginLogTopic = `login_log` // 登录日志
|
||||
QueueServeLogTopic = `serve_log` // 服务日志
|
||||
|
||||
QueueImAdminCommandTopic = `im.admin.command` // IM后台命令
|
||||
QueueImAdminCommandResultTopic = `im.admin.command.result` // IM后台命令结果
|
||||
QueueImConfigEventTopic = `im.config.event` // IM配置事件
|
||||
QueueImCacheInvalidateTopic = `im.cache.invalidate` // IM缓存失效事件
|
||||
QueueImWorkbenchEventTopic = `im.workbench.event` // IM工作台事件
|
||||
QueueImDeadLetterTopic = `im.dead.letter` // IM死信事件
|
||||
QueueImAuditEventTopic = `im.audit.event` // IM审计事件
|
||||
)
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
// 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,91 +149,3 @@ func (c *cImGroup) AdminLogList(ctx context.Context, req *imgroup.AdminLogListRe
|
||||
res.PageRes.Pack(req, totalCount)
|
||||
return
|
||||
}
|
||||
|
||||
// ProfileUpdate 通过Kafka修改IM群资料
|
||||
func (c *cImGroup) ProfileUpdate(ctx context.Context, req *imgroup.ProfileUpdateReq) (res *imgroup.ProfileUpdateRes, err error) {
|
||||
data, err := service.SysImGroup().ProfileUpdate(ctx, &req.ImGroupProfileUpdateInp)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
res = new(imgroup.ProfileUpdateRes)
|
||||
res.ImGroupCommandModel = data
|
||||
return
|
||||
}
|
||||
|
||||
// GovernanceUpdate 通过Kafka修改IM群治理配置
|
||||
func (c *cImGroup) GovernanceUpdate(ctx context.Context, req *imgroup.GovernanceUpdateReq) (res *imgroup.GovernanceUpdateRes, err error) {
|
||||
data, err := service.SysImGroup().GovernanceUpdate(ctx, &req.ImGroupGovernanceUpdateInp)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
res = new(imgroup.GovernanceUpdateRes)
|
||||
res.ImGroupCommandModel = data
|
||||
return
|
||||
}
|
||||
|
||||
// AdminSet 通过Kafka设置IM群管理员
|
||||
func (c *cImGroup) AdminSet(ctx context.Context, req *imgroup.AdminSetReq) (res *imgroup.AdminSetRes, err error) {
|
||||
data, err := service.SysImGroup().AdminSet(ctx, &req.ImGroupAdminSetInp)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
res = new(imgroup.AdminSetRes)
|
||||
res.ImGroupCommandModel = data
|
||||
return
|
||||
}
|
||||
|
||||
// AdminRemove 通过Kafka删除IM群管理员
|
||||
func (c *cImGroup) AdminRemove(ctx context.Context, req *imgroup.AdminRemoveReq) (res *imgroup.AdminRemoveRes, err error) {
|
||||
data, err := service.SysImGroup().AdminRemove(ctx, &req.ImGroupAdminRemoveInp)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
res = new(imgroup.AdminRemoveRes)
|
||||
res.ImGroupCommandModel = data
|
||||
return
|
||||
}
|
||||
|
||||
// MemberMute 通过Kafka禁言IM群成员
|
||||
func (c *cImGroup) MemberMute(ctx context.Context, req *imgroup.MemberMuteReq) (res *imgroup.MemberMuteRes, err error) {
|
||||
data, err := service.SysImGroup().MemberMute(ctx, &req.ImGroupMemberMuteInp)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
res = new(imgroup.MemberMuteRes)
|
||||
res.ImGroupCommandModel = data
|
||||
return
|
||||
}
|
||||
|
||||
// MemberUnmute 通过Kafka解除IM群成员禁言
|
||||
func (c *cImGroup) MemberUnmute(ctx context.Context, req *imgroup.MemberUnmuteReq) (res *imgroup.MemberUnmuteRes, err error) {
|
||||
data, err := service.SysImGroup().MemberUnmute(ctx, &req.ImGroupMemberUnmuteInp)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
res = new(imgroup.MemberUnmuteRes)
|
||||
res.ImGroupCommandModel = data
|
||||
return
|
||||
}
|
||||
|
||||
// MemberRemove 通过Kafka移出IM群成员
|
||||
func (c *cImGroup) MemberRemove(ctx context.Context, req *imgroup.MemberRemoveReq) (res *imgroup.MemberRemoveRes, err error) {
|
||||
data, err := service.SysImGroup().MemberRemove(ctx, &req.ImGroupMemberRemoveInp)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
res = new(imgroup.MemberRemoveRes)
|
||||
res.ImGroupCommandModel = data
|
||||
return
|
||||
}
|
||||
|
||||
// OwnerTransfer 通过Kafka转让IM群主
|
||||
func (c *cImGroup) OwnerTransfer(ctx context.Context, req *imgroup.OwnerTransferReq) (res *imgroup.OwnerTransferRes, err error) {
|
||||
data, err := service.SysImGroup().OwnerTransfer(ctx, &req.ImGroupOwnerTransferInp)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
res = new(imgroup.OwnerTransferRes)
|
||||
res.ImGroupCommandModel = data
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
// 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,83 +46,3 @@ func (c *cImUser) View(ctx context.Context, req *imuser.ViewReq) (res *imuser.Vi
|
||||
res.ImUserViewModel = data
|
||||
return
|
||||
}
|
||||
|
||||
func (c *cImUser) Ban(ctx context.Context, req *imuser.BanReq) (res *imuser.BanRes, err error) {
|
||||
data, err := service.SysImUser().Ban(ctx, &req.ImUserGovernanceInp)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
res = new(imuser.BanRes)
|
||||
res.ImUserCommandModel = data
|
||||
return
|
||||
}
|
||||
|
||||
func (c *cImUser) Unban(ctx context.Context, req *imuser.UnbanReq) (res *imuser.UnbanRes, err error) {
|
||||
data, err := service.SysImUser().Unban(ctx, &req.ImUserGovernanceInp)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
res = new(imuser.UnbanRes)
|
||||
res.ImUserCommandModel = data
|
||||
return
|
||||
}
|
||||
|
||||
func (c *cImUser) Freeze(ctx context.Context, req *imuser.FreezeReq) (res *imuser.FreezeRes, err error) {
|
||||
data, err := service.SysImUser().Freeze(ctx, &req.ImUserGovernanceInp)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
res = new(imuser.FreezeRes)
|
||||
res.ImUserCommandModel = data
|
||||
return
|
||||
}
|
||||
|
||||
func (c *cImUser) Unfreeze(ctx context.Context, req *imuser.UnfreezeReq) (res *imuser.UnfreezeRes, err error) {
|
||||
data, err := service.SysImUser().Unfreeze(ctx, &req.ImUserGovernanceInp)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
res = new(imuser.UnfreezeRes)
|
||||
res.ImUserCommandModel = data
|
||||
return
|
||||
}
|
||||
|
||||
func (c *cImUser) DeviceKick(ctx context.Context, req *imuser.DeviceKickReq) (res *imuser.DeviceKickRes, err error) {
|
||||
data, err := service.SysImUser().DeviceKick(ctx, &req.ImUserGovernanceInp)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
res = new(imuser.DeviceKickRes)
|
||||
res.ImUserCommandModel = data
|
||||
return
|
||||
}
|
||||
|
||||
func (c *cImUser) LogoutAll(ctx context.Context, req *imuser.LogoutAllReq) (res *imuser.LogoutAllRes, err error) {
|
||||
data, err := service.SysImUser().LogoutAll(ctx, &req.ImUserGovernanceInp)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
res = new(imuser.LogoutAllRes)
|
||||
res.ImUserCommandModel = data
|
||||
return
|
||||
}
|
||||
|
||||
func (c *cImUser) AccountCancel(ctx context.Context, req *imuser.AccountCancelReq) (res *imuser.AccountCancelRes, err error) {
|
||||
data, err := service.SysImUser().AccountCancel(ctx, &req.ImUserGovernanceInp)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
res = new(imuser.AccountCancelRes)
|
||||
res.ImUserCommandModel = data
|
||||
return
|
||||
}
|
||||
|
||||
func (c *cImUser) AccountDelete(ctx context.Context, req *imuser.AccountDeleteReq) (res *imuser.AccountDeleteRes, err error) {
|
||||
data, err := service.SysImUser().AccountDelete(ctx, &req.ImUserGovernanceInp)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
res = new(imuser.AccountDeleteRes)
|
||||
res.ImUserCommandModel = data
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1,144 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
// 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
|
||||
}
|
||||
@@ -10,13 +10,10 @@ import (
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
BaseUrl string `json:"baseUrl"`
|
||||
ServiceId string `json:"serviceId"`
|
||||
ServiceSecret string `json:"serviceSecret"`
|
||||
Timeout time.Duration `json:"timeout"`
|
||||
InternalBaseUrl string `json:"internalBaseUrl"`
|
||||
InternalServiceName string `json:"internalServiceName"`
|
||||
InternalServiceSecret string `json:"internalServiceSecret"`
|
||||
BaseUrl string `json:"baseUrl"`
|
||||
ServiceId string `json:"serviceId"`
|
||||
ServiceSecret string `json:"serviceSecret"`
|
||||
Timeout time.Duration `json:"timeout"`
|
||||
}
|
||||
|
||||
func LoadConfig(ctx context.Context) Config {
|
||||
@@ -31,25 +28,10 @@ func LoadConfig(ctx context.Context) Config {
|
||||
if serviceSecret == "" {
|
||||
serviceSecret = os.Getenv("IM_ADMIN_API_SECRET")
|
||||
}
|
||||
internalSecret := g.Cfg().MustGet(ctx, "imInternalApi.serviceSecret").String()
|
||||
if strings.HasPrefix(internalSecret, "${") && strings.HasSuffix(internalSecret, "}") {
|
||||
internalSecret = os.Getenv(strings.TrimSuffix(strings.TrimPrefix(internalSecret, "${"), "}"))
|
||||
}
|
||||
if internalSecret == "" {
|
||||
internalSecret = os.Getenv("IM_INTERNAL_API_SECRET")
|
||||
}
|
||||
if internalSecret == "" {
|
||||
internalSecret = serviceSecret
|
||||
}
|
||||
baseUrl := g.Cfg().MustGet(ctx, "imAdminApi.baseUrl").String()
|
||||
serviceId := g.Cfg().MustGet(ctx, "imAdminApi.serviceId", "hotgo-admin").String()
|
||||
return Config{
|
||||
BaseUrl: baseUrl,
|
||||
ServiceId: serviceId,
|
||||
ServiceSecret: serviceSecret,
|
||||
Timeout: timeout,
|
||||
InternalBaseUrl: g.Cfg().MustGet(ctx, "imInternalApi.baseUrl", baseUrl).String(),
|
||||
InternalServiceName: g.Cfg().MustGet(ctx, "imInternalApi.serviceName", serviceId).String(),
|
||||
InternalServiceSecret: internalSecret,
|
||||
BaseUrl: g.Cfg().MustGet(ctx, "imAdminApi.baseUrl").String(),
|
||||
ServiceId: g.Cfg().MustGet(ctx, "imAdminApi.serviceId", "hotgo-admin").String(),
|
||||
ServiceSecret: serviceSecret,
|
||||
Timeout: timeout,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,101 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -1,135 +0,0 @@
|
||||
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()
|
||||
}
|
||||
@@ -1,378 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -1,90 +0,0 @@
|
||||
package imkafka
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Actor struct {
|
||||
Type string `json:"type,omitempty"`
|
||||
ID string `json:"id,omitempty"`
|
||||
DisplayName string `json:"display_name,omitempty"`
|
||||
}
|
||||
|
||||
type Envelope struct {
|
||||
EventID string `json:"event_id"`
|
||||
TraceID string `json:"trace_id,omitempty"`
|
||||
TenantCode string `json:"tenant_code,omitempty"`
|
||||
SourceService string `json:"source_service,omitempty"`
|
||||
TargetService string `json:"target_service,omitempty"`
|
||||
EventType string `json:"event_type"`
|
||||
BizType string `json:"biz_type,omitempty"`
|
||||
BizID string `json:"biz_id,omitempty"`
|
||||
IdempotencyKey string `json:"idempotency_key,omitempty"`
|
||||
Version int64 `json:"version,omitempty"`
|
||||
OccurredAt time.Time `json:"occurred_at"`
|
||||
Actor *Actor `json:"actor,omitempty"`
|
||||
Payload json.RawMessage `json:"payload,omitempty"`
|
||||
ParentEventID string `json:"parent_event_id,omitempty"`
|
||||
}
|
||||
|
||||
type CommandResult struct {
|
||||
EventID string `json:"event_id"`
|
||||
TraceID string `json:"trace_id,omitempty"`
|
||||
TenantCode string `json:"tenant_code,omitempty"`
|
||||
CommandID string `json:"command_id"`
|
||||
CommandEventID string `json:"command_event_id"`
|
||||
Status string `json:"status"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
Message string `json:"message,omitempty"`
|
||||
Result json.RawMessage `json:"result,omitempty"`
|
||||
OccurredAt time.Time `json:"occurred_at"`
|
||||
}
|
||||
|
||||
type AdminCommandInput struct {
|
||||
TenantID int64
|
||||
TenantCode string
|
||||
EventType string
|
||||
BizType string
|
||||
BizID string
|
||||
IdempotencyKey string
|
||||
Version int64
|
||||
Actor *Actor
|
||||
Payload map[string]any
|
||||
CreatedBy int64
|
||||
}
|
||||
|
||||
type ConfigEventInput struct {
|
||||
TenantID int64
|
||||
TenantCode string
|
||||
EventType string
|
||||
BizID string
|
||||
Group string
|
||||
Keys []string
|
||||
Actor *Actor
|
||||
CreatedBy int64
|
||||
}
|
||||
|
||||
type EventInput struct {
|
||||
Topic string
|
||||
TenantCode string
|
||||
TargetService string
|
||||
EventType string
|
||||
BizType string
|
||||
BizID string
|
||||
IdempotencyKey string
|
||||
Version int64
|
||||
Actor *Actor
|
||||
Payload map[string]any
|
||||
CreatedBy int64
|
||||
}
|
||||
|
||||
type AdminCommandModel struct {
|
||||
CommandID string `json:"commandId" dc:"命令ID"`
|
||||
EventID string `json:"eventId" dc:"Kafka事件ID"`
|
||||
Status string `json:"status" dc:"状态"`
|
||||
TenantID int64 `json:"tenantId" dc:"租户ID"`
|
||||
TenantCode string `json:"tenantCode" dc:"租户编码"`
|
||||
EventType string `json:"eventType" dc:"事件类型"`
|
||||
IdempotencyKey string `json:"idempotencyKey" dc:"幂等键"`
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -125,7 +125,7 @@ func (d *DiskProducerMq) getProducer(topic string) *disk.Queue {
|
||||
|
||||
func NewDiskQueue(topic string, config *disk.Config) *disk.Queue {
|
||||
conf := &disk.Config{
|
||||
Path: fmt.Sprintf("%s/%s/%s", config.Path, config.GroupName, topic),
|
||||
Path: fmt.Sprintf(config.Path + "/" + config.GroupName + "/" + topic),
|
||||
BatchSize: config.BatchSize,
|
||||
BatchTime: config.BatchTime * time.Second,
|
||||
SegmentSize: config.SegmentSize,
|
||||
|
||||
@@ -7,16 +7,13 @@ package queue
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"fmt"
|
||||
"github.com/IBM/sarama"
|
||||
"github.com/gogf/gf/v2/errors/gerror"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/xdg-go/scram"
|
||||
"hotgo/internal/consts"
|
||||
"hotgo/utility/simple"
|
||||
"time"
|
||||
)
|
||||
|
||||
type KafkaMq struct {
|
||||
@@ -26,17 +23,14 @@ type KafkaMq struct {
|
||||
}
|
||||
|
||||
type KafkaConfig struct {
|
||||
ClientId string
|
||||
Brokers []string
|
||||
GroupID string
|
||||
Partitions int32
|
||||
Replication int16
|
||||
Version string
|
||||
UserName string
|
||||
Password string
|
||||
SASLEnabled bool
|
||||
SASLMechanism string
|
||||
TLSEnabled bool
|
||||
ClientId string
|
||||
Brokers []string
|
||||
GroupID string
|
||||
Partitions int32
|
||||
Replication int16
|
||||
Version string
|
||||
UserName string
|
||||
Password string
|
||||
}
|
||||
|
||||
// SendMsg 按字符串类型生产数据
|
||||
@@ -104,7 +98,7 @@ func (r *KafkaMq) ListenReceiveMsgDo(topic string, receiveDo func(mqMsg MqMsg))
|
||||
}
|
||||
|
||||
if consumerCtx.Err() != nil {
|
||||
Logger().Debugf(ctx, "kafka consoumer stop : %v", consumerCtx.Err())
|
||||
Logger().Debugf(ctx, fmt.Sprintf("kafka consoumer stop : %v", consumerCtx.Err()))
|
||||
return
|
||||
}
|
||||
consumer.ready = make(chan bool)
|
||||
@@ -140,8 +134,10 @@ func RegisterKafkaMqConsumer(connOpt KafkaConfig) (client MqConsumer, err error)
|
||||
conf := sarama.NewConfig()
|
||||
conf.Consumer.Return.Errors = true
|
||||
conf.Version = kfkVersion
|
||||
if err = configureKafkaSecurity(conf, connOpt); err != nil {
|
||||
return
|
||||
if connOpt.UserName != "" {
|
||||
conf.Net.SASL.Enable = true
|
||||
conf.Net.SASL.User = connOpt.UserName
|
||||
conf.Net.SASL.Password = connOpt.Password
|
||||
}
|
||||
|
||||
// 默认按随机方式消费
|
||||
@@ -161,9 +157,7 @@ func RegisterKafkaMqConsumer(connOpt KafkaConfig) (client MqConsumer, err error)
|
||||
// RegisterKafkaProducer 注册并启动生产者接口实现
|
||||
func RegisterKafkaProducer(connOpt KafkaConfig) (client MqProducer, err error) {
|
||||
mqIns := &KafkaMq{}
|
||||
if connOpt.ClientId == "" {
|
||||
connOpt.ClientId = "hotgo-producer"
|
||||
}
|
||||
connOpt.ClientId = "HOTGO-Producer"
|
||||
|
||||
// 这里如果使用go程需要处理chan同步问题
|
||||
if err = doRegisterKafkaProducer(connOpt, mqIns); err != nil {
|
||||
@@ -197,8 +191,10 @@ func doRegisterKafkaProducer(connOpt KafkaConfig, mqIns *KafkaMq) (err error) {
|
||||
conf.ClientID = connOpt.ClientId
|
||||
|
||||
conf.Version = kfkVersion
|
||||
if err = configureKafkaSecurity(conf, connOpt); err != nil {
|
||||
return
|
||||
if connOpt.UserName != "" {
|
||||
conf.Net.SASL.Enable = true
|
||||
conf.Net.SASL.User = connOpt.UserName
|
||||
conf.Net.SASL.Password = connOpt.Password
|
||||
}
|
||||
|
||||
mqIns.producerIns, err = sarama.NewAsyncProducer(brokers, conf)
|
||||
@@ -213,63 +209,6 @@ func doRegisterKafkaProducer(connOpt KafkaConfig, mqIns *KafkaMq) (err error) {
|
||||
return
|
||||
}
|
||||
|
||||
func configureKafkaSecurity(conf *sarama.Config, connOpt KafkaConfig) error {
|
||||
if connOpt.TLSEnabled {
|
||||
conf.Net.TLS.Enable = true
|
||||
conf.Net.TLS.Config = &tls.Config{MinVersion: tls.VersionTLS12}
|
||||
}
|
||||
if !connOpt.SASLEnabled && connOpt.UserName == "" {
|
||||
return nil
|
||||
}
|
||||
if connOpt.UserName == "" || connOpt.Password == "" {
|
||||
return gerror.New("queue kafka sasl username or password is empty")
|
||||
}
|
||||
conf.Net.SASL.Enable = true
|
||||
conf.Net.SASL.User = connOpt.UserName
|
||||
conf.Net.SASL.Password = connOpt.Password
|
||||
mechanism := strings.ToUpper(strings.ReplaceAll(connOpt.SASLMechanism, "_", "-"))
|
||||
switch mechanism {
|
||||
case "", "PLAIN":
|
||||
conf.Net.SASL.Mechanism = sarama.SASLTypePlaintext
|
||||
case "SCRAM-SHA-256":
|
||||
conf.Net.SASL.Mechanism = sarama.SASLTypeSCRAMSHA256
|
||||
conf.Net.SASL.SCRAMClientGeneratorFunc = func() sarama.SCRAMClient {
|
||||
return &xdgSCRAMClient{HashGeneratorFcn: scram.SHA256}
|
||||
}
|
||||
case "SCRAM-SHA-512":
|
||||
conf.Net.SASL.Mechanism = sarama.SASLTypeSCRAMSHA512
|
||||
conf.Net.SASL.SCRAMClientGeneratorFunc = func() sarama.SCRAMClient {
|
||||
return &xdgSCRAMClient{HashGeneratorFcn: scram.SHA512}
|
||||
}
|
||||
default:
|
||||
return gerror.New("queue kafka sasl mechanism is not support")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type xdgSCRAMClient struct {
|
||||
*scram.Client
|
||||
*scram.ClientConversation
|
||||
scram.HashGeneratorFcn
|
||||
}
|
||||
|
||||
func (x *xdgSCRAMClient) Begin(userName, password, authzID string) (err error) {
|
||||
x.Client, err = x.HashGeneratorFcn.NewClient(userName, password, authzID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
x.ClientConversation = x.Client.NewConversation()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (x *xdgSCRAMClient) Step(challenge string) (response string, err error) {
|
||||
return x.ClientConversation.Step(challenge)
|
||||
}
|
||||
|
||||
func (x *xdgSCRAMClient) Done() bool {
|
||||
return x.ClientConversation.Done()
|
||||
}
|
||||
|
||||
// validateVersion 验证版本是否有效
|
||||
func validateVersion(version sarama.KafkaVersion) bool {
|
||||
for _, item := range sarama.SupportedVersions {
|
||||
|
||||
@@ -11,8 +11,6 @@ import (
|
||||
"github.com/gogf/gf/v2/os/gctx"
|
||||
"hotgo/internal/library/queue/disk"
|
||||
"hotgo/utility/charset"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
@@ -61,12 +59,6 @@ type KafkaConf struct {
|
||||
Version string `json:"version"`
|
||||
RandClient bool `json:"randClient"`
|
||||
MultiConsumer bool `json:"multiConsumer"`
|
||||
ClientId string `json:"clientId"`
|
||||
UserName string `json:"userName"`
|
||||
Password string `json:"password"`
|
||||
SASLEnabled bool `json:"saslEnabled"`
|
||||
SASLMechanism string `json:"saslMechanism"`
|
||||
TLSEnabled bool `json:"tlsEnabled"`
|
||||
}
|
||||
|
||||
type MqMsg struct {
|
||||
@@ -93,7 +85,6 @@ func init() {
|
||||
if err := g.Cfg().MustGet(ctx, "queue").Scan(&config); err != nil {
|
||||
Logger().Warningf(ctx, "queue init err:%+v", err)
|
||||
}
|
||||
normalizeKafkaConf()
|
||||
}
|
||||
|
||||
// InstanceConsumer 实例化消费者
|
||||
@@ -130,15 +121,9 @@ func NewProducer(groupName string) (mqClient MqProducer, err error) {
|
||||
return
|
||||
}
|
||||
mqClient, err = RegisterKafkaProducer(KafkaConfig{
|
||||
Brokers: config.Kafka.Address,
|
||||
GroupID: groupName,
|
||||
Version: config.Kafka.Version,
|
||||
ClientId: firstKafkaClientId(config.Kafka.ClientId, "hotgo-producer"),
|
||||
UserName: config.Kafka.UserName,
|
||||
Password: config.Kafka.Password,
|
||||
SASLEnabled: config.Kafka.SASLEnabled,
|
||||
SASLMechanism: config.Kafka.SASLMechanism,
|
||||
TLSEnabled: config.Kafka.TLSEnabled,
|
||||
Brokers: config.Kafka.Address,
|
||||
GroupID: groupName,
|
||||
Version: config.Kafka.Version,
|
||||
})
|
||||
case "redis":
|
||||
if _, err = g.Redis().Do(ctx, "ping"); err == nil {
|
||||
@@ -200,15 +185,10 @@ func NewConsumer(groupName string) (mqClient MqConsumer, err error) {
|
||||
}
|
||||
|
||||
mqClient, err = RegisterKafkaMqConsumer(KafkaConfig{
|
||||
Brokers: config.Kafka.Address,
|
||||
GroupID: groupName,
|
||||
Version: config.Kafka.Version,
|
||||
ClientId: clientId,
|
||||
UserName: config.Kafka.UserName,
|
||||
Password: config.Kafka.Password,
|
||||
SASLEnabled: config.Kafka.SASLEnabled,
|
||||
SASLMechanism: config.Kafka.SASLMechanism,
|
||||
TLSEnabled: config.Kafka.TLSEnabled,
|
||||
Brokers: config.Kafka.Address,
|
||||
GroupID: groupName,
|
||||
Version: config.Kafka.Version,
|
||||
ClientId: clientId,
|
||||
})
|
||||
case "redis":
|
||||
if _, err = g.Redis().Do(ctx, "ping"); err == nil {
|
||||
@@ -233,30 +213,6 @@ func NewConsumer(groupName string) (mqClient MqConsumer, err error) {
|
||||
return
|
||||
}
|
||||
|
||||
func normalizeKafkaConf() {
|
||||
config.Kafka.UserName = resolveEnvPlaceholder(config.Kafka.UserName)
|
||||
config.Kafka.Password = resolveEnvPlaceholder(config.Kafka.Password)
|
||||
if config.Kafka.SASLMechanism == "" {
|
||||
config.Kafka.SASLMechanism = "SCRAM-SHA-512"
|
||||
}
|
||||
}
|
||||
|
||||
func resolveEnvPlaceholder(value string) string {
|
||||
value = strings.TrimSpace(value)
|
||||
if strings.HasPrefix(value, "${") && strings.HasSuffix(value, "}") {
|
||||
return os.Getenv(strings.TrimSuffix(strings.TrimPrefix(value, "${"), "}"))
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func firstKafkaClientId(value string, fallback string) string {
|
||||
value = strings.TrimSpace(value)
|
||||
if value != "" {
|
||||
return value
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
// BodyString 返回消息体
|
||||
func (m *MqMsg) BodyString() string {
|
||||
return string(m.Body)
|
||||
|
||||
@@ -1,203 +0,0 @@
|
||||
// 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,
|
||||
}
|
||||
}
|
||||
@@ -7,14 +7,9 @@ package sys
|
||||
|
||||
import (
|
||||
"context"
|
||||
"hotgo/internal/consts"
|
||||
"hotgo/internal/library/contexts"
|
||||
"hotgo/internal/library/imkafka"
|
||||
"hotgo/internal/library/imtenant"
|
||||
"hotgo/internal/model/input/sysin"
|
||||
"hotgo/internal/service"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/gogf/gf/v2/database/gdb"
|
||||
"github.com/gogf/gf/v2/errors/gerror"
|
||||
@@ -459,220 +454,3 @@ func (s *sSysImGroup) AdminLogList(ctx context.Context, in *sysin.ImGroupAdminLo
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// ProfileUpdate 通过Kafka修改IM群资料
|
||||
func (s *sSysImGroup) ProfileUpdate(ctx context.Context, in *sysin.ImGroupProfileUpdateInp) (res *sysin.ImGroupCommandModel, err error) {
|
||||
if err = in.ImGroupTopicInp.Filter(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if in.ActorUserId <= 0 {
|
||||
return nil, gerror.New("操作IM用户ID不能为空")
|
||||
}
|
||||
payload := map[string]any{
|
||||
"actor_user_id": imUserId(in.ActorUserId),
|
||||
"topic": in.Topic,
|
||||
"profile": map[string]any{},
|
||||
"version": in.Version,
|
||||
}
|
||||
profile := payload["profile"].(map[string]any)
|
||||
if in.Name != "" {
|
||||
profile["name"] = in.Name
|
||||
}
|
||||
if in.AvatarFileId != "" {
|
||||
profile["avatar_file_id"] = in.AvatarFileId
|
||||
}
|
||||
if in.Description != "" {
|
||||
profile["description"] = in.Description
|
||||
}
|
||||
return s.publishGroupCommand(ctx, in.TenantId, consts.ImAdminCommandGroupProfileUpdate, in.Topic, int64(in.Version), payload)
|
||||
}
|
||||
|
||||
// GovernanceUpdate 通过Kafka修改IM群治理配置
|
||||
func (s *sSysImGroup) GovernanceUpdate(ctx context.Context, in *sysin.ImGroupGovernanceUpdateInp) (res *sysin.ImGroupCommandModel, err error) {
|
||||
if err = in.ImGroupTopicInp.Filter(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if in.ActorUserId <= 0 {
|
||||
return nil, gerror.New("操作IM用户ID不能为空")
|
||||
}
|
||||
payload := map[string]any{
|
||||
"actor_user_id": imUserId(in.ActorUserId),
|
||||
"topic": in.Topic,
|
||||
"governance": map[string]any{},
|
||||
"version": in.Version,
|
||||
}
|
||||
governance := payload["governance"].(map[string]any)
|
||||
if in.JoinApproval != nil {
|
||||
governance["join_approval"] = *in.JoinApproval
|
||||
}
|
||||
if in.MuteAll != nil {
|
||||
governance["mute_all"] = *in.MuteAll
|
||||
}
|
||||
if in.MemberUploadFiles != nil {
|
||||
governance["member_upload_files"] = *in.MemberUploadFiles
|
||||
}
|
||||
if in.MemberPrivateChat != nil {
|
||||
governance["member_private_chat"] = *in.MemberPrivateChat
|
||||
}
|
||||
if in.MemberInvite != nil {
|
||||
governance["member_invite"] = *in.MemberInvite
|
||||
}
|
||||
if len(governance) == 0 {
|
||||
return nil, gerror.New("请至少选择一个治理配置")
|
||||
}
|
||||
return s.publishGroupCommand(ctx, in.TenantId, consts.ImAdminCommandGroupGovernUpdate, in.Topic, int64(in.Version), payload)
|
||||
}
|
||||
|
||||
// AdminSet 通过Kafka设置IM群管理员
|
||||
func (s *sSysImGroup) AdminSet(ctx context.Context, in *sysin.ImGroupAdminSetInp) (res *sysin.ImGroupCommandModel, err error) {
|
||||
if err = in.ImGroupTopicInp.Filter(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if in.ActorUserId <= 0 || len(in.UserIds) == 0 {
|
||||
return nil, gerror.New("操作IM用户ID和用户ID不能为空")
|
||||
}
|
||||
payload := map[string]any{
|
||||
"actor_user_id": imUserId(in.ActorUserId),
|
||||
"topic": in.Topic,
|
||||
"user_ids": imUserIds(in.UserIds),
|
||||
}
|
||||
return s.publishGroupCommand(ctx, in.TenantId, consts.ImAdminCommandGroupAdminSet, in.Topic, 0, payload)
|
||||
}
|
||||
|
||||
// AdminRemove 通过Kafka删除IM群管理员
|
||||
func (s *sSysImGroup) AdminRemove(ctx context.Context, in *sysin.ImGroupAdminRemoveInp) (res *sysin.ImGroupCommandModel, err error) {
|
||||
if err = in.ImGroupTopicInp.Filter(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if in.ActorUserId <= 0 || in.UserId <= 0 {
|
||||
return nil, gerror.New("操作IM用户ID和用户ID不能为空")
|
||||
}
|
||||
payload := map[string]any{
|
||||
"actor_user_id": imUserId(in.ActorUserId),
|
||||
"topic": in.Topic,
|
||||
"user_id": imUserId(in.UserId),
|
||||
}
|
||||
return s.publishGroupCommand(ctx, in.TenantId, consts.ImAdminCommandGroupAdminRemove, in.Topic, 0, payload)
|
||||
}
|
||||
|
||||
// MemberMute 通过Kafka禁言IM群成员
|
||||
func (s *sSysImGroup) MemberMute(ctx context.Context, in *sysin.ImGroupMemberMuteInp) (res *sysin.ImGroupCommandModel, err error) {
|
||||
if err = in.ImGroupTopicInp.Filter(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if in.ActorUserId <= 0 || in.UserId <= 0 {
|
||||
return nil, gerror.New("操作IM用户ID和用户ID不能为空")
|
||||
}
|
||||
payload := map[string]any{
|
||||
"actor_user_id": imUserId(in.ActorUserId),
|
||||
"topic": in.Topic,
|
||||
"user_id": imUserId(in.UserId),
|
||||
"reason": in.Reason,
|
||||
}
|
||||
if in.ExpiresAt != nil {
|
||||
payload["expires_at"] = in.ExpiresAt.Time.UTC().Format(time.RFC3339Nano)
|
||||
}
|
||||
return s.publishGroupCommand(ctx, in.TenantId, consts.ImAdminCommandGroupMemberMute, in.Topic, 0, payload)
|
||||
}
|
||||
|
||||
// MemberUnmute 通过Kafka解除IM群成员禁言
|
||||
func (s *sSysImGroup) MemberUnmute(ctx context.Context, in *sysin.ImGroupMemberUnmuteInp) (res *sysin.ImGroupCommandModel, err error) {
|
||||
if err = in.ImGroupTopicInp.Filter(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if in.ActorUserId <= 0 || in.UserId <= 0 {
|
||||
return nil, gerror.New("操作IM用户ID和用户ID不能为空")
|
||||
}
|
||||
payload := map[string]any{
|
||||
"actor_user_id": imUserId(in.ActorUserId),
|
||||
"topic": in.Topic,
|
||||
"user_id": imUserId(in.UserId),
|
||||
}
|
||||
return s.publishGroupCommand(ctx, in.TenantId, consts.ImAdminCommandGroupMemberUnmute, in.Topic, 0, payload)
|
||||
}
|
||||
|
||||
// MemberRemove 通过Kafka移出IM群成员
|
||||
func (s *sSysImGroup) MemberRemove(ctx context.Context, in *sysin.ImGroupMemberRemoveInp) (res *sysin.ImGroupCommandModel, err error) {
|
||||
if err = in.ImGroupTopicInp.Filter(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if in.ActorUserId <= 0 || in.UserId <= 0 {
|
||||
return nil, gerror.New("操作IM用户ID和用户ID不能为空")
|
||||
}
|
||||
payload := map[string]any{
|
||||
"actor_user_id": imUserId(in.ActorUserId),
|
||||
"topic": in.Topic,
|
||||
"user_id": imUserId(in.UserId),
|
||||
}
|
||||
return s.publishGroupCommand(ctx, in.TenantId, consts.ImAdminCommandGroupMemberRemove, in.Topic, 0, payload)
|
||||
}
|
||||
|
||||
// OwnerTransfer 通过Kafka转让IM群主
|
||||
func (s *sSysImGroup) OwnerTransfer(ctx context.Context, in *sysin.ImGroupOwnerTransferInp) (res *sysin.ImGroupCommandModel, err error) {
|
||||
if err = in.ImGroupTopicInp.Filter(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if in.ActorUserId <= 0 || in.TargetUserId <= 0 {
|
||||
return nil, gerror.New("操作IM用户ID和新群主ID不能为空")
|
||||
}
|
||||
payload := map[string]any{
|
||||
"actor_user_id": imUserId(in.ActorUserId),
|
||||
"topic": in.Topic,
|
||||
"target_user_id": imUserId(in.TargetUserId),
|
||||
"version": in.Version,
|
||||
}
|
||||
return s.publishGroupCommand(ctx, in.TenantId, consts.ImAdminCommandGroupOwnerTransfer, in.Topic, int64(in.Version), payload)
|
||||
}
|
||||
|
||||
func (s *sSysImGroup) publishGroupCommand(ctx context.Context, tenantId int64, eventType string, topic string, version int64, payload map[string]any) (*sysin.ImGroupCommandModel, error) {
|
||||
_, tenant, err := imtenant.DB(ctx, tenantId)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
user := contexts.GetUser(ctx)
|
||||
actorName := ""
|
||||
if user != nil {
|
||||
actorName = user.RealName
|
||||
}
|
||||
command, err := imkafka.PublishAdminCommand(ctx, imkafka.AdminCommandInput{
|
||||
TenantID: tenant.Id,
|
||||
TenantCode: tenant.Code,
|
||||
EventType: eventType,
|
||||
BizType: "group",
|
||||
BizID: topic,
|
||||
Version: version,
|
||||
Actor: &imkafka.Actor{
|
||||
Type: "admin",
|
||||
ID: strconv.FormatInt(contexts.GetUserId(ctx), 10),
|
||||
DisplayName: actorName,
|
||||
},
|
||||
Payload: payload,
|
||||
CreatedBy: contexts.GetUserId(ctx),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &sysin.ImGroupCommandModel{
|
||||
CommandID: command.CommandID,
|
||||
EventID: command.EventID,
|
||||
Status: command.Status,
|
||||
TenantID: command.TenantID,
|
||||
TenantCode: command.TenantCode,
|
||||
EventType: command.EventType,
|
||||
IdempotencyKey: command.IdempotencyKey,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func imUserId(id int64) string {
|
||||
return strconv.FormatInt(id, 10)
|
||||
}
|
||||
|
||||
func imUserIds(ids []int64) []string {
|
||||
list := make([]string, 0, len(ids))
|
||||
for _, id := range ids {
|
||||
if id > 0 {
|
||||
list = append(list, imUserId(id))
|
||||
}
|
||||
}
|
||||
return list
|
||||
}
|
||||
|
||||
@@ -1,86 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -1,187 +0,0 @@
|
||||
// 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,10 +2,8 @@ package sys
|
||||
|
||||
import (
|
||||
"context"
|
||||
"hotgo/internal/consts"
|
||||
"hotgo/internal/dao"
|
||||
"hotgo/internal/library/contexts"
|
||||
"hotgo/internal/library/imkafka"
|
||||
"hotgo/internal/model/input/sysin"
|
||||
"hotgo/internal/service"
|
||||
|
||||
@@ -13,7 +11,6 @@ import (
|
||||
"github.com/gogf/gf/v2/errors/gerror"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/gogf/gf/v2/os/gtime"
|
||||
"github.com/gogf/gf/v2/util/gconv"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -65,24 +62,6 @@ func (s *sSysImRegistration) UpdateConfig(ctx context.Context, in *sysin.ImRegis
|
||||
"old": old.RegisterSmsEnabled,
|
||||
"new": in.RegisterSmsEnabled,
|
||||
})
|
||||
actorName := ""
|
||||
if user := contexts.GetUser(ctx); user != nil {
|
||||
actorName = user.RealName
|
||||
}
|
||||
if eventErr := imkafka.PublishConfigEvent(ctx, imkafka.ConfigEventInput{
|
||||
EventType: consts.ImConfigEventRegistrationUpdated,
|
||||
BizID: "registration",
|
||||
Group: imRegistrationConfigGroup,
|
||||
Keys: []string{imRegistrationSmsKey},
|
||||
Actor: &imkafka.Actor{
|
||||
Type: "admin",
|
||||
ID: gconv.String(contexts.GetUserId(ctx)),
|
||||
DisplayName: actorName,
|
||||
},
|
||||
CreatedBy: contexts.GetUserId(ctx),
|
||||
}); eventErr != nil {
|
||||
g.Log().Warningf(ctx, "发送IM注册配置Kafka事件失败 err:%+v", eventErr)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -7,12 +7,10 @@ package sys
|
||||
|
||||
import (
|
||||
"context"
|
||||
"hotgo/internal/consts"
|
||||
"hotgo/internal/dao"
|
||||
"hotgo/internal/library/contexts"
|
||||
"hotgo/internal/library/hgorm/handler"
|
||||
"hotgo/internal/library/hgorm/hook"
|
||||
"hotgo/internal/library/imkafka"
|
||||
"hotgo/internal/library/imtenant"
|
||||
"hotgo/internal/model/entity"
|
||||
"hotgo/internal/model/input/sysin"
|
||||
@@ -21,7 +19,6 @@ import (
|
||||
"github.com/gogf/gf/v2/database/gdb"
|
||||
"github.com/gogf/gf/v2/errors/gerror"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/gogf/gf/v2/util/gconv"
|
||||
)
|
||||
|
||||
type sSysImTenant struct{}
|
||||
@@ -145,7 +142,7 @@ func (s *sSysImTenant) Edit(ctx context.Context, in *sysin.ImTenantEditInp) (err
|
||||
return
|
||||
}
|
||||
|
||||
err = imtenant.RegistryDB(ctx).Transaction(ctx, func(ctx context.Context, tx gdb.TX) (err error) {
|
||||
return imtenant.RegistryDB(ctx).Transaction(ctx, func(ctx context.Context, tx gdb.TX) (err error) {
|
||||
if err = s.checkCodeUnique(ctx, in.Id, in.Code); err != nil {
|
||||
return
|
||||
}
|
||||
@@ -170,12 +167,6 @@ func (s *sSysImTenant) Edit(ctx context.Context, in *sysin.ImTenantEditInp) (err
|
||||
}
|
||||
return s.saveConfig(ctx, id, in.MaxUsers, in.MaxGroups, in.MaxGroupMembers, userId)
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
s.publishTenantConfigEvent(ctx, in.Code, in.Id, consts.ImConfigEventTenantUpdated, "tenant")
|
||||
s.publishTenantConfigEvent(ctx, in.Code, in.Id, consts.ImConfigEventTenantLimitsUpdated, "tenant_limits")
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *sSysImTenant) checkCodeUnique(ctx context.Context, id int64, code string) (err error) {
|
||||
@@ -278,33 +269,5 @@ func (s *sSysImTenant) Status(ctx context.Context, in *sysin.ImTenantStatusInp)
|
||||
err = gerror.Wrap(err, "更新IM租户状态失败,请稍后重试!")
|
||||
return
|
||||
}
|
||||
var tenant *entity.ImTenant
|
||||
if scanErr := s.Model(ctx).Fields(dao.ImTenant.Columns().Id, dao.ImTenant.Columns().Code).WherePri(in.Id).Scan(&tenant); scanErr == nil && tenant != nil {
|
||||
s.publishTenantConfigEvent(ctx, tenant.Code, tenant.Id, consts.ImConfigEventTenantStatusChanged, "tenant_status")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func (s *sSysImTenant) publishTenantConfigEvent(ctx context.Context, tenantCode string, tenantId int64, eventType string, bizId string) {
|
||||
user := contexts.GetUser(ctx)
|
||||
actorName := ""
|
||||
if user != nil {
|
||||
actorName = user.RealName
|
||||
}
|
||||
if err := imkafka.PublishConfigEvent(ctx, imkafka.ConfigEventInput{
|
||||
TenantID: tenantId,
|
||||
TenantCode: tenantCode,
|
||||
EventType: eventType,
|
||||
BizID: bizId,
|
||||
Group: "tenant",
|
||||
Keys: []string{"tenant", "limits", "features"},
|
||||
Actor: &imkafka.Actor{
|
||||
Type: "admin",
|
||||
ID: gconv.String(contexts.GetUserId(ctx)),
|
||||
DisplayName: actorName,
|
||||
},
|
||||
CreatedBy: contexts.GetUserId(ctx),
|
||||
}); err != nil {
|
||||
g.Log().Warningf(ctx, "发送IM租户配置Kafka事件失败 tenant:%s type:%s err:%+v", tenantCode, eventType, err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,13 +7,9 @@ package sys
|
||||
|
||||
import (
|
||||
"context"
|
||||
"hotgo/internal/consts"
|
||||
"hotgo/internal/library/contexts"
|
||||
"hotgo/internal/library/imkafka"
|
||||
"hotgo/internal/library/imtenant"
|
||||
"hotgo/internal/model/input/sysin"
|
||||
"hotgo/internal/service"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/gogf/gf/v2/database/gdb"
|
||||
@@ -191,103 +187,6 @@ func (s *sSysImUser) View(ctx context.Context, in *sysin.ImUserViewInp) (res *sy
|
||||
return
|
||||
}
|
||||
|
||||
// Ban 通过Kafka封禁IM用户
|
||||
func (s *sSysImUser) Ban(ctx context.Context, in *sysin.ImUserGovernanceInp) (res *sysin.ImUserCommandModel, err error) {
|
||||
return s.publishGovernanceCommand(ctx, in, consts.ImAdminCommandUserBan)
|
||||
}
|
||||
|
||||
// Unban 通过Kafka解封IM用户
|
||||
func (s *sSysImUser) Unban(ctx context.Context, in *sysin.ImUserGovernanceInp) (res *sysin.ImUserCommandModel, err error) {
|
||||
return s.publishGovernanceCommand(ctx, in, consts.ImAdminCommandUserUnban)
|
||||
}
|
||||
|
||||
// Freeze 通过Kafka冻结IM用户
|
||||
func (s *sSysImUser) Freeze(ctx context.Context, in *sysin.ImUserGovernanceInp) (res *sysin.ImUserCommandModel, err error) {
|
||||
return s.publishGovernanceCommand(ctx, in, consts.ImAdminCommandUserFreeze)
|
||||
}
|
||||
|
||||
// Unfreeze 通过Kafka解冻IM用户
|
||||
func (s *sSysImUser) Unfreeze(ctx context.Context, in *sysin.ImUserGovernanceInp) (res *sysin.ImUserCommandModel, err error) {
|
||||
return s.publishGovernanceCommand(ctx, in, consts.ImAdminCommandUserUnfreeze)
|
||||
}
|
||||
|
||||
// DeviceKick 通过Kafka踢出IM用户设备
|
||||
func (s *sSysImUser) DeviceKick(ctx context.Context, in *sysin.ImUserGovernanceInp) (res *sysin.ImUserCommandModel, err error) {
|
||||
if err = in.FilterDevice(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.publishGovernanceCommand(ctx, in, consts.ImAdminCommandUserDeviceKick)
|
||||
}
|
||||
|
||||
// LogoutAll 通过Kafka踢出IM用户全部在线会话
|
||||
func (s *sSysImUser) LogoutAll(ctx context.Context, in *sysin.ImUserGovernanceInp) (res *sysin.ImUserCommandModel, err error) {
|
||||
return s.publishGovernanceCommand(ctx, in, consts.ImAdminCommandUserLogoutAll)
|
||||
}
|
||||
|
||||
// AccountCancel 通过Kafka注销IM用户账号
|
||||
func (s *sSysImUser) AccountCancel(ctx context.Context, in *sysin.ImUserGovernanceInp) (res *sysin.ImUserCommandModel, err error) {
|
||||
return s.publishGovernanceCommand(ctx, in, consts.ImAdminCommandUserCancel)
|
||||
}
|
||||
|
||||
// AccountDelete 通过Kafka删除IM用户账号
|
||||
func (s *sSysImUser) AccountDelete(ctx context.Context, in *sysin.ImUserGovernanceInp) (res *sysin.ImUserCommandModel, err error) {
|
||||
in.Hard = true
|
||||
return s.publishGovernanceCommand(ctx, in, consts.ImAdminCommandUserDelete)
|
||||
}
|
||||
|
||||
func (s *sSysImUser) publishGovernanceCommand(ctx context.Context, in *sysin.ImUserGovernanceInp, eventType string) (*sysin.ImUserCommandModel, error) {
|
||||
if err := in.Filter(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_, tenant, err := imtenant.DB(ctx, in.TenantId)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
user := contexts.GetUser(ctx)
|
||||
actorName := ""
|
||||
if user != nil {
|
||||
actorName = user.RealName
|
||||
}
|
||||
payload := map[string]any{
|
||||
"actor_user_id": strconv.FormatInt(in.ActorUserId, 10),
|
||||
"user_id": strconv.FormatInt(in.UserId, 10),
|
||||
"target_user_id": strconv.FormatInt(in.UserId, 10),
|
||||
"reason": in.Reason,
|
||||
}
|
||||
if in.DeviceId != "" {
|
||||
payload["device_id"] = in.DeviceId
|
||||
}
|
||||
if eventType == consts.ImAdminCommandUserCancel || eventType == consts.ImAdminCommandUserDelete {
|
||||
payload["hard"] = in.Hard
|
||||
}
|
||||
command, err := imkafka.PublishAdminCommand(ctx, imkafka.AdminCommandInput{
|
||||
TenantID: tenant.Id,
|
||||
TenantCode: tenant.Code,
|
||||
EventType: eventType,
|
||||
BizType: "user",
|
||||
BizID: strconv.FormatInt(in.UserId, 10),
|
||||
Actor: &imkafka.Actor{
|
||||
Type: "admin",
|
||||
ID: strconv.FormatInt(contexts.GetUserId(ctx), 10),
|
||||
DisplayName: actorName,
|
||||
},
|
||||
Payload: payload,
|
||||
CreatedBy: contexts.GetUserId(ctx),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &sysin.ImUserCommandModel{
|
||||
CommandID: command.CommandID,
|
||||
EventID: command.EventID,
|
||||
Status: command.Status,
|
||||
TenantID: command.TenantID,
|
||||
TenantCode: command.TenantCode,
|
||||
EventType: command.EventType,
|
||||
IdempotencyKey: command.IdempotencyKey,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *sSysImUser) viewFields() []interface{} {
|
||||
return []interface{}{
|
||||
"u.id user_id",
|
||||
|
||||
@@ -1,130 +0,0 @@
|
||||
// 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,72 +288,3 @@ type ImGroupAdminLogListModel struct {
|
||||
IdempotencyKey string `json:"idempotencyKey" dc:"幂等键"`
|
||||
CreatedAt *gtime.Time `json:"createdAt" dc:"创建时间"`
|
||||
}
|
||||
|
||||
type ImGroupCommandModel struct {
|
||||
CommandID string `json:"commandId" dc:"命令ID"`
|
||||
EventID string `json:"eventId" dc:"Kafka事件ID"`
|
||||
Status string `json:"status" dc:"状态"`
|
||||
TenantID int64 `json:"tenantId" dc:"租户ID"`
|
||||
TenantCode string `json:"tenantCode" dc:"租户编码"`
|
||||
EventType string `json:"eventType" dc:"事件类型"`
|
||||
IdempotencyKey string `json:"idempotencyKey" dc:"幂等键"`
|
||||
}
|
||||
|
||||
type ImGroupProfileUpdateInp struct {
|
||||
ImGroupTopicInp
|
||||
ActorUserId int64 `json:"actorUserId" v:"required#操作IM用户ID不能为空" dc:"操作IM用户ID"`
|
||||
Name string `json:"name" dc:"群名称"`
|
||||
AvatarFileId string `json:"avatarFileId" dc:"头像文件ID"`
|
||||
Description string `json:"description" dc:"简介"`
|
||||
Version int `json:"version" dc:"版本"`
|
||||
}
|
||||
|
||||
type ImGroupGovernanceUpdateInp struct {
|
||||
ImGroupTopicInp
|
||||
ActorUserId int64 `json:"actorUserId" v:"required#操作IM用户ID不能为空" dc:"操作IM用户ID"`
|
||||
JoinApproval *bool `json:"joinApproval" dc:"入群审批"`
|
||||
MuteAll *bool `json:"muteAll" dc:"全员禁言"`
|
||||
MemberUploadFiles *bool `json:"memberUploadFiles" dc:"成员上传文件"`
|
||||
MemberPrivateChat *bool `json:"memberPrivateChat" dc:"成员私聊"`
|
||||
MemberInvite *bool `json:"memberInvite" dc:"成员邀请"`
|
||||
Version int `json:"version" dc:"版本"`
|
||||
}
|
||||
|
||||
type ImGroupAdminSetInp struct {
|
||||
ImGroupTopicInp
|
||||
ActorUserId int64 `json:"actorUserId" v:"required#操作IM用户ID不能为空" dc:"操作IM用户ID"`
|
||||
UserIds []int64 `json:"userIds" v:"required#用户ID不能为空" dc:"用户ID列表"`
|
||||
}
|
||||
|
||||
type ImGroupAdminRemoveInp struct {
|
||||
ImGroupTopicInp
|
||||
ActorUserId int64 `json:"actorUserId" v:"required#操作IM用户ID不能为空" dc:"操作IM用户ID"`
|
||||
UserId int64 `json:"userId" v:"required#用户ID不能为空" dc:"用户ID"`
|
||||
}
|
||||
|
||||
type ImGroupMemberMuteInp struct {
|
||||
ImGroupTopicInp
|
||||
ActorUserId int64 `json:"actorUserId" v:"required#操作IM用户ID不能为空" dc:"操作IM用户ID"`
|
||||
UserId int64 `json:"userId" v:"required#用户ID不能为空" dc:"用户ID"`
|
||||
Reason string `json:"reason" dc:"原因"`
|
||||
ExpiresAt *gtime.Time `json:"expiresAt" dc:"过期时间"`
|
||||
}
|
||||
|
||||
type ImGroupMemberUnmuteInp struct {
|
||||
ImGroupTopicInp
|
||||
ActorUserId int64 `json:"actorUserId" v:"required#操作IM用户ID不能为空" dc:"操作IM用户ID"`
|
||||
UserId int64 `json:"userId" v:"required#用户ID不能为空" dc:"用户ID"`
|
||||
}
|
||||
|
||||
type ImGroupMemberRemoveInp struct {
|
||||
ImGroupTopicInp
|
||||
ActorUserId int64 `json:"actorUserId" v:"required#操作IM用户ID不能为空" dc:"操作IM用户ID"`
|
||||
UserId int64 `json:"userId" v:"required#用户ID不能为空" dc:"用户ID"`
|
||||
}
|
||||
|
||||
type ImGroupOwnerTransferInp struct {
|
||||
ImGroupTopicInp
|
||||
ActorUserId int64 `json:"actorUserId" v:"required#操作IM用户ID不能为空" dc:"操作IM用户ID"`
|
||||
TargetUserId int64 `json:"targetUserId" v:"required#新群主ID不能为空" dc:"新群主ID"`
|
||||
Version int `json:"version" dc:"版本"`
|
||||
}
|
||||
|
||||
@@ -1,65 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -1,126 +0,0 @@
|
||||
// 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,45 +78,3 @@ type ImUserViewModel struct {
|
||||
AccessJson string `json:"accessJson" dc:"访问权限"`
|
||||
ProfileVersion int `json:"profileVersion" dc:"资料版本"`
|
||||
}
|
||||
|
||||
type ImUserCommandModel struct {
|
||||
CommandID string `json:"commandId" dc:"命令ID"`
|
||||
EventID string `json:"eventId" dc:"事件ID"`
|
||||
Status string `json:"status" dc:"状态"`
|
||||
TenantID int64 `json:"tenantId" dc:"租户ID"`
|
||||
TenantCode string `json:"tenantCode" dc:"租户编码"`
|
||||
EventType string `json:"eventType" dc:"事件类型"`
|
||||
IdempotencyKey string `json:"idempotencyKey" dc:"幂等键"`
|
||||
}
|
||||
|
||||
type ImUserGovernanceInp struct {
|
||||
TenantId int64 `json:"tenantId" dc:"租户ID"`
|
||||
UserId int64 `json:"userId" dc:"目标用户ID"`
|
||||
ActorUserId int64 `json:"actorUserId" dc:"操作IM用户ID"`
|
||||
DeviceId string `json:"deviceId" dc:"设备ID"`
|
||||
Reason string `json:"reason" dc:"原因"`
|
||||
Hard bool `json:"hard" dc:"硬删除"`
|
||||
}
|
||||
|
||||
func (in *ImUserGovernanceInp) Filter(ctx context.Context) (err error) {
|
||||
if in.TenantId <= 0 {
|
||||
return gerror.New("请选择租户")
|
||||
}
|
||||
if in.UserId <= 0 {
|
||||
return gerror.New("用户ID不能为空")
|
||||
}
|
||||
if in.ActorUserId <= 0 {
|
||||
return gerror.New("操作IM用户ID不能为空")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (in *ImUserGovernanceInp) FilterDevice(ctx context.Context) (err error) {
|
||||
if err = in.Filter(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
if in.DeviceId == "" {
|
||||
return gerror.New("设备ID不能为空")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
// 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)
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -1,74 +0,0 @@
|
||||
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 ""
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
package genrouter
|
||||
|
||||
import "hotgo/internal/controller/admin/sys"
|
||||
|
||||
func init() {
|
||||
LoginRequiredRouter = append(LoginRequiredRouter, sys.ImDeadLetter) // IM Kafka死信
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
package genrouter
|
||||
|
||||
import "hotgo/internal/controller/admin/sys"
|
||||
|
||||
func init() {
|
||||
LoginRequiredRouter = append(LoginRequiredRouter, sys.ImKafkaAudit) // IM Kafka审计事件
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
package genrouter
|
||||
|
||||
import "hotgo/internal/controller/admin/sys"
|
||||
|
||||
func init() {
|
||||
LoginRequiredRouter = append(LoginRequiredRouter, sys.ImKafkaCommand) // IM后台命令
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
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,
|
||||
)
|
||||
})
|
||||
}
|
||||
@@ -210,22 +210,6 @@ type (
|
||||
List(ctx context.Context, in *sysin.ImUserListInp) (list []*sysin.ImUserListModel, totalCount int, err error)
|
||||
// View 获取IM用户指定信息
|
||||
View(ctx context.Context, in *sysin.ImUserViewInp) (res *sysin.ImUserViewModel, err error)
|
||||
// Ban 通过Kafka封禁IM用户
|
||||
Ban(ctx context.Context, in *sysin.ImUserGovernanceInp) (res *sysin.ImUserCommandModel, err error)
|
||||
// Unban 通过Kafka解封IM用户
|
||||
Unban(ctx context.Context, in *sysin.ImUserGovernanceInp) (res *sysin.ImUserCommandModel, err error)
|
||||
// Freeze 通过Kafka冻结IM用户
|
||||
Freeze(ctx context.Context, in *sysin.ImUserGovernanceInp) (res *sysin.ImUserCommandModel, err error)
|
||||
// Unfreeze 通过Kafka解冻IM用户
|
||||
Unfreeze(ctx context.Context, in *sysin.ImUserGovernanceInp) (res *sysin.ImUserCommandModel, err error)
|
||||
// DeviceKick 通过Kafka踢出IM用户设备
|
||||
DeviceKick(ctx context.Context, in *sysin.ImUserGovernanceInp) (res *sysin.ImUserCommandModel, err error)
|
||||
// LogoutAll 通过Kafka踢出IM用户全部在线会话
|
||||
LogoutAll(ctx context.Context, in *sysin.ImUserGovernanceInp) (res *sysin.ImUserCommandModel, err error)
|
||||
// AccountCancel 通过Kafka注销IM用户账号
|
||||
AccountCancel(ctx context.Context, in *sysin.ImUserGovernanceInp) (res *sysin.ImUserCommandModel, err error)
|
||||
// AccountDelete 通过Kafka删除IM用户账号
|
||||
AccountDelete(ctx context.Context, in *sysin.ImUserGovernanceInp) (res *sysin.ImUserCommandModel, err error)
|
||||
}
|
||||
ISysImGroup interface {
|
||||
// List 获取IM群组列表
|
||||
@@ -246,22 +230,6 @@ type (
|
||||
MuteList(ctx context.Context, in *sysin.ImGroupMuteListInp) (list []*sysin.ImGroupMuteListModel, totalCount int, err error)
|
||||
// AdminLogList 获取IM群管理日志列表
|
||||
AdminLogList(ctx context.Context, in *sysin.ImGroupAdminLogListInp) (list []*sysin.ImGroupAdminLogListModel, totalCount int, err error)
|
||||
// ProfileUpdate 通过Kafka修改IM群资料
|
||||
ProfileUpdate(ctx context.Context, in *sysin.ImGroupProfileUpdateInp) (res *sysin.ImGroupCommandModel, err error)
|
||||
// GovernanceUpdate 通过Kafka修改IM群治理配置
|
||||
GovernanceUpdate(ctx context.Context, in *sysin.ImGroupGovernanceUpdateInp) (res *sysin.ImGroupCommandModel, err error)
|
||||
// AdminSet 通过Kafka设置IM群管理员
|
||||
AdminSet(ctx context.Context, in *sysin.ImGroupAdminSetInp) (res *sysin.ImGroupCommandModel, err error)
|
||||
// AdminRemove 通过Kafka删除IM群管理员
|
||||
AdminRemove(ctx context.Context, in *sysin.ImGroupAdminRemoveInp) (res *sysin.ImGroupCommandModel, err error)
|
||||
// MemberMute 通过Kafka禁言IM群成员
|
||||
MemberMute(ctx context.Context, in *sysin.ImGroupMemberMuteInp) (res *sysin.ImGroupCommandModel, err error)
|
||||
// MemberUnmute 通过Kafka解除IM群成员禁言
|
||||
MemberUnmute(ctx context.Context, in *sysin.ImGroupMemberUnmuteInp) (res *sysin.ImGroupCommandModel, err error)
|
||||
// MemberRemove 通过Kafka移出IM群成员
|
||||
MemberRemove(ctx context.Context, in *sysin.ImGroupMemberRemoveInp) (res *sysin.ImGroupCommandModel, err error)
|
||||
// OwnerTransfer 通过Kafka转让IM群主
|
||||
OwnerTransfer(ctx context.Context, in *sysin.ImGroupOwnerTransferInp) (res *sysin.ImGroupCommandModel, err error)
|
||||
}
|
||||
ISysImRealname interface {
|
||||
// List 获取IM实名审核列表
|
||||
@@ -285,34 +253,6 @@ type (
|
||||
// View 获取IM审计日志指定信息
|
||||
View(ctx context.Context, in *sysin.ImAuditLogViewInp) (res *sysin.ImAuditLogViewModel, err error)
|
||||
}
|
||||
ISysImKafkaAudit interface {
|
||||
// List 获取IM Kafka审计事件列表
|
||||
List(ctx context.Context, in *sysin.ImKafkaAuditListInp) (list []*sysin.ImKafkaAuditListModel, totalCount int, err error)
|
||||
// View 获取IM Kafka审计事件详情
|
||||
View(ctx context.Context, in *sysin.ImKafkaAuditViewInp) (res *sysin.ImKafkaAuditViewModel, err error)
|
||||
}
|
||||
ISysImKafkaCommand interface {
|
||||
// List 获取IM后台命令列表
|
||||
List(ctx context.Context, in *sysin.ImKafkaCommandListInp) (list []*sysin.ImKafkaCommandListModel, totalCount int, err error)
|
||||
// View 获取IM后台命令详情
|
||||
View(ctx context.Context, in *sysin.ImKafkaCommandViewInp) (res *sysin.ImKafkaCommandViewModel, err error)
|
||||
// CacheInvalidate 发送IM缓存失效测试事件
|
||||
CacheInvalidate(ctx context.Context, in *sysin.ImKafkaCacheInvalidateInp) (res *sysin.ImKafkaEventModel, err error)
|
||||
// WorkbenchEvent 发送IM工作台测试事件
|
||||
WorkbenchEvent(ctx context.Context, in *sysin.ImKafkaWorkbenchEventInp) (res *sysin.ImKafkaEventModel, err error)
|
||||
}
|
||||
ISysImDeadLetter interface {
|
||||
// List 获取IM Kafka死信列表
|
||||
List(ctx context.Context, in *sysin.ImDeadLetterListInp) (res *sysin.ImDeadLetterPageModel, err error)
|
||||
// View 获取IM Kafka死信详情
|
||||
View(ctx context.Context, in *sysin.ImDeadLetterViewInp) (res *sysin.ImDeadLetterViewModel, err error)
|
||||
// Replay 重放IM Kafka死信
|
||||
Replay(ctx context.Context, in *sysin.ImDeadLetterActionInp) (res *sysin.ImGroupCommandModel, err error)
|
||||
// Ignore 忽略IM Kafka死信
|
||||
Ignore(ctx context.Context, in *sysin.ImDeadLetterActionInp) (res *sysin.ImGroupCommandModel, err error)
|
||||
// Resolve 标记IM Kafka死信已处理
|
||||
Resolve(ctx context.Context, in *sysin.ImDeadLetterActionInp) (res *sysin.ImGroupCommandModel, err error)
|
||||
}
|
||||
ISysDictData interface {
|
||||
// Delete 删除
|
||||
Delete(ctx context.Context, in *sysin.DictDataDeleteInp) error
|
||||
@@ -564,9 +504,6 @@ var (
|
||||
localSysImRealname ISysImRealname
|
||||
localSysImWorkOrder ISysImWorkOrder
|
||||
localSysImAuditLog ISysImAuditLog
|
||||
localSysImKafkaAudit ISysImKafkaAudit
|
||||
localSysImKafkaCommand ISysImKafkaCommand
|
||||
localSysImDeadLetter ISysImDeadLetter
|
||||
localSysDictData ISysDictData
|
||||
localSysDictType ISysDictType
|
||||
localSysEmsLog ISysEmsLog
|
||||
@@ -758,39 +695,6 @@ func RegisterSysImAuditLog(i ISysImAuditLog) {
|
||||
localSysImAuditLog = i
|
||||
}
|
||||
|
||||
func SysImKafkaAudit() ISysImKafkaAudit {
|
||||
if localSysImKafkaAudit == nil {
|
||||
panic("implement not found for interface ISysImKafkaAudit, forgot register?")
|
||||
}
|
||||
return localSysImKafkaAudit
|
||||
}
|
||||
|
||||
func RegisterSysImKafkaAudit(i ISysImKafkaAudit) {
|
||||
localSysImKafkaAudit = i
|
||||
}
|
||||
|
||||
func SysImKafkaCommand() ISysImKafkaCommand {
|
||||
if localSysImKafkaCommand == nil {
|
||||
panic("implement not found for interface ISysImKafkaCommand, forgot register?")
|
||||
}
|
||||
return localSysImKafkaCommand
|
||||
}
|
||||
|
||||
func RegisterSysImKafkaCommand(i ISysImKafkaCommand) {
|
||||
localSysImKafkaCommand = i
|
||||
}
|
||||
|
||||
func SysImDeadLetter() ISysImDeadLetter {
|
||||
if localSysImDeadLetter == nil {
|
||||
panic("implement not found for interface ISysImDeadLetter, forgot register?")
|
||||
}
|
||||
return localSysImDeadLetter
|
||||
}
|
||||
|
||||
func RegisterSysImDeadLetter(i ISysImDeadLetter) {
|
||||
localSysImDeadLetter = i
|
||||
}
|
||||
|
||||
func SysDictData() ISysDictData {
|
||||
if localSysDictData == nil {
|
||||
panic("implement not found for interface ISysDictData, forgot register?")
|
||||
|
||||
@@ -212,16 +212,10 @@ queue:
|
||||
retry: 0 # 重试次数
|
||||
logLevel: "info" # rocketmq日志输出级别,可选:all|close|debug|info|warn|error|fatal
|
||||
kafka:
|
||||
address: ["127.0.0.1:9092"] # kafka地址+端口,支持多个
|
||||
address: "127.0.0.1:9092" # kafka地址+端口
|
||||
version: "2.0.0.0" # kafka专属配置,默认2.0.0.0
|
||||
randClient: true # 开启随机生成clientID,可以实现启动多实例同时一起消费相同topic,加速消费能力的特性,默认为true
|
||||
multiConsumer: true # 是否支持创建多个消费者
|
||||
clientId: "hotgo-admin" # Kafka客户端ID
|
||||
userName: "${KAFKA_USERNAME}" # SASL用户名,未启用SASL可留空
|
||||
password: "${KAFKA_PASSWORD}" # SASL密码,未启用SASL可留空
|
||||
saslEnabled: false # 是否开启SASL
|
||||
saslMechanism: "SCRAM-SHA-512" # SASL机制,可选:PLAIN|SCRAM-SHA-256|SCRAM-SHA-512
|
||||
tlsEnabled: false # 是否开启TLS
|
||||
|
||||
|
||||
# Redis. 配置参考:https://goframe.org/pages/viewpage.action?pageId=1114217
|
||||
@@ -316,13 +310,3 @@ imAdminApi:
|
||||
serviceId: "hotgo-admin"
|
||||
serviceSecret: "${IM_ADMIN_API_SECRET}"
|
||||
timeout: "5s"
|
||||
|
||||
imInternalApi:
|
||||
baseUrl: "http://127.0.0.1:6060/v1"
|
||||
serviceName: "hotgo-admin"
|
||||
serviceSecret: "${IM_INTERNAL_API_SECRET}"
|
||||
|
||||
imConfigApi:
|
||||
serviceName: "im-server"
|
||||
serviceSecret: "${IM_CONFIG_API_SECRET}"
|
||||
timeWindow: "300s"
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
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,35 +57,3 @@ WHERE NOT EXISTS (SELECT 1 FROM `hg_admin_menu` WHERE `name` = 'imGroupMuteList'
|
||||
INSERT INTO `hg_admin_menu` (`id`, `pid`, `title`, `name`, `path`, `icon`, `type`, `redirect`, `permissions`, `permission_name`, `component`, `always_show`, `active_menu`, `is_root`, `is_frame`, `frame_src`, `keep_alive`, `hidden`, `affix`, `level`, `tree`, `sort`, `remark`, `status`, `created_at`, `updated_at`)
|
||||
SELECT NULL, @groupListId, '群管理日志', 'imGroupAdminLogList', '', '', '3', '', '/imGroup/adminLogList', '', '', '1', '', '0', '0', '', '0', '1', '0', '4', CONCAT('tr_', @imRootId, ' tr_', @groupDirId, ' tr_', @groupListId, ' '), '80', '', '1', @now, @now
|
||||
WHERE NOT EXISTS (SELECT 1 FROM `hg_admin_menu` WHERE `name` = 'imGroupAdminLogList');
|
||||
|
||||
INSERT INTO `hg_admin_menu` (`id`, `pid`, `title`, `name`, `path`, `icon`, `type`, `redirect`, `permissions`, `permission_name`, `component`, `always_show`, `active_menu`, `is_root`, `is_frame`, `frame_src`, `keep_alive`, `hidden`, `affix`, `level`, `tree`, `sort`, `remark`, `status`, `created_at`, `updated_at`)
|
||||
SELECT NULL, @groupListId, '修改群资料', 'imGroupProfileUpdate', '', '', '3', '', '/imGroup/profileUpdate', '', '', '1', '', '0', '0', '', '0', '1', '0', '4', CONCAT('tr_', @imRootId, ' tr_', @groupDirId, ' tr_', @groupListId, ' '), '90', '', '1', @now, @now
|
||||
WHERE NOT EXISTS (SELECT 1 FROM `hg_admin_menu` WHERE `name` = 'imGroupProfileUpdate');
|
||||
|
||||
INSERT INTO `hg_admin_menu` (`id`, `pid`, `title`, `name`, `path`, `icon`, `type`, `redirect`, `permissions`, `permission_name`, `component`, `always_show`, `active_menu`, `is_root`, `is_frame`, `frame_src`, `keep_alive`, `hidden`, `affix`, `level`, `tree`, `sort`, `remark`, `status`, `created_at`, `updated_at`)
|
||||
SELECT NULL, @groupListId, '修改群治理配置', 'imGroupGovernanceUpdate', '', '', '3', '', '/imGroup/governanceUpdate', '', '', '1', '', '0', '0', '', '0', '1', '0', '4', CONCAT('tr_', @imRootId, ' tr_', @groupDirId, ' tr_', @groupListId, ' '), '100', '', '1', @now, @now
|
||||
WHERE NOT EXISTS (SELECT 1 FROM `hg_admin_menu` WHERE `name` = 'imGroupGovernanceUpdate');
|
||||
|
||||
INSERT INTO `hg_admin_menu` (`id`, `pid`, `title`, `name`, `path`, `icon`, `type`, `redirect`, `permissions`, `permission_name`, `component`, `always_show`, `active_menu`, `is_root`, `is_frame`, `frame_src`, `keep_alive`, `hidden`, `affix`, `level`, `tree`, `sort`, `remark`, `status`, `created_at`, `updated_at`)
|
||||
SELECT NULL, @groupListId, '设置群管理员', 'imGroupAdminSet', '', '', '3', '', '/imGroup/adminSet', '', '', '1', '', '0', '0', '', '0', '1', '0', '4', CONCAT('tr_', @imRootId, ' tr_', @groupDirId, ' tr_', @groupListId, ' '), '110', '', '1', @now, @now
|
||||
WHERE NOT EXISTS (SELECT 1 FROM `hg_admin_menu` WHERE `name` = 'imGroupAdminSet');
|
||||
|
||||
INSERT INTO `hg_admin_menu` (`id`, `pid`, `title`, `name`, `path`, `icon`, `type`, `redirect`, `permissions`, `permission_name`, `component`, `always_show`, `active_menu`, `is_root`, `is_frame`, `frame_src`, `keep_alive`, `hidden`, `affix`, `level`, `tree`, `sort`, `remark`, `status`, `created_at`, `updated_at`)
|
||||
SELECT NULL, @groupListId, '删除群管理员', 'imGroupAdminRemove', '', '', '3', '', '/imGroup/adminRemove', '', '', '1', '', '0', '0', '', '0', '1', '0', '4', CONCAT('tr_', @imRootId, ' tr_', @groupDirId, ' tr_', @groupListId, ' '), '120', '', '1', @now, @now
|
||||
WHERE NOT EXISTS (SELECT 1 FROM `hg_admin_menu` WHERE `name` = 'imGroupAdminRemove');
|
||||
|
||||
INSERT INTO `hg_admin_menu` (`id`, `pid`, `title`, `name`, `path`, `icon`, `type`, `redirect`, `permissions`, `permission_name`, `component`, `always_show`, `active_menu`, `is_root`, `is_frame`, `frame_src`, `keep_alive`, `hidden`, `affix`, `level`, `tree`, `sort`, `remark`, `status`, `created_at`, `updated_at`)
|
||||
SELECT NULL, @groupListId, '禁言群成员', 'imGroupMemberMute', '', '', '3', '', '/imGroup/memberMute', '', '', '1', '', '0', '0', '', '0', '1', '0', '4', CONCAT('tr_', @imRootId, ' tr_', @groupDirId, ' tr_', @groupListId, ' '), '130', '', '1', @now, @now
|
||||
WHERE NOT EXISTS (SELECT 1 FROM `hg_admin_menu` WHERE `name` = 'imGroupMemberMute');
|
||||
|
||||
INSERT INTO `hg_admin_menu` (`id`, `pid`, `title`, `name`, `path`, `icon`, `type`, `redirect`, `permissions`, `permission_name`, `component`, `always_show`, `active_menu`, `is_root`, `is_frame`, `frame_src`, `keep_alive`, `hidden`, `affix`, `level`, `tree`, `sort`, `remark`, `status`, `created_at`, `updated_at`)
|
||||
SELECT NULL, @groupListId, '解除群成员禁言', 'imGroupMemberUnmute', '', '', '3', '', '/imGroup/memberUnmute', '', '', '1', '', '0', '0', '', '0', '1', '0', '4', CONCAT('tr_', @imRootId, ' tr_', @groupDirId, ' tr_', @groupListId, ' '), '140', '', '1', @now, @now
|
||||
WHERE NOT EXISTS (SELECT 1 FROM `hg_admin_menu` WHERE `name` = 'imGroupMemberUnmute');
|
||||
|
||||
INSERT INTO `hg_admin_menu` (`id`, `pid`, `title`, `name`, `path`, `icon`, `type`, `redirect`, `permissions`, `permission_name`, `component`, `always_show`, `active_menu`, `is_root`, `is_frame`, `frame_src`, `keep_alive`, `hidden`, `affix`, `level`, `tree`, `sort`, `remark`, `status`, `created_at`, `updated_at`)
|
||||
SELECT NULL, @groupListId, '移出群成员', 'imGroupMemberRemove', '', '', '3', '', '/imGroup/memberRemove', '', '', '1', '', '0', '0', '', '0', '1', '0', '4', CONCAT('tr_', @imRootId, ' tr_', @groupDirId, ' tr_', @groupListId, ' '), '150', '', '1', @now, @now
|
||||
WHERE NOT EXISTS (SELECT 1 FROM `hg_admin_menu` WHERE `name` = 'imGroupMemberRemove');
|
||||
|
||||
INSERT INTO `hg_admin_menu` (`id`, `pid`, `title`, `name`, `path`, `icon`, `type`, `redirect`, `permissions`, `permission_name`, `component`, `always_show`, `active_menu`, `is_root`, `is_frame`, `frame_src`, `keep_alive`, `hidden`, `affix`, `level`, `tree`, `sort`, `remark`, `status`, `created_at`, `updated_at`)
|
||||
SELECT NULL, @groupListId, '转让群主', 'imGroupOwnerTransfer', '', '', '3', '', '/imGroup/ownerTransfer', '', '', '1', '', '0', '0', '', '0', '1', '0', '4', CONCAT('tr_', @imRootId, ' tr_', @groupDirId, ' tr_', @groupListId, ' '), '160', '', '1', @now, @now
|
||||
WHERE NOT EXISTS (SELECT 1 FROM `hg_admin_menu` WHERE `name` = 'imGroupOwnerTransfer');
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
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';
|
||||
@@ -1,23 +0,0 @@
|
||||
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');
|
||||
@@ -1,31 +0,0 @@
|
||||
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');
|
||||
@@ -1,50 +0,0 @@
|
||||
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,35 +29,3 @@ SELECT @userListId := `id` FROM `hg_admin_menu` WHERE `name` = 'imUserIndex' LIM
|
||||
INSERT INTO `hg_admin_menu` (`id`, `pid`, `title`, `name`, `path`, `icon`, `type`, `redirect`, `permissions`, `permission_name`, `component`, `always_show`, `active_menu`, `is_root`, `is_frame`, `frame_src`, `keep_alive`, `hidden`, `affix`, `level`, `tree`, `sort`, `remark`, `status`, `created_at`, `updated_at`)
|
||||
SELECT NULL, @userListId, '用户详情', 'imUserView', '', '', '3', '', '/imUser/view', '', '', '1', '', '0', '0', '', '0', '1', '0', '4', CONCAT('tr_', @imRootId, ' tr_', @userDirId, ' tr_', @userListId, ' '), '10', '', '1', @now, @now
|
||||
WHERE NOT EXISTS (SELECT 1 FROM `hg_admin_menu` WHERE `name` = 'imUserView');
|
||||
|
||||
INSERT INTO `hg_admin_menu` (`id`, `pid`, `title`, `name`, `path`, `icon`, `type`, `redirect`, `permissions`, `permission_name`, `component`, `always_show`, `active_menu`, `is_root`, `is_frame`, `frame_src`, `keep_alive`, `hidden`, `affix`, `level`, `tree`, `sort`, `remark`, `status`, `created_at`, `updated_at`)
|
||||
SELECT NULL, @userListId, '封禁用户', 'imUserBan', '', '', '3', '', '/imUser/ban', '', '', '1', '', '0', '0', '', '0', '1', '0', '4', CONCAT('tr_', @imRootId, ' tr_', @userDirId, ' tr_', @userListId, ' '), '20', '', '1', @now, @now
|
||||
WHERE NOT EXISTS (SELECT 1 FROM `hg_admin_menu` WHERE `name` = 'imUserBan');
|
||||
|
||||
INSERT INTO `hg_admin_menu` (`id`, `pid`, `title`, `name`, `path`, `icon`, `type`, `redirect`, `permissions`, `permission_name`, `component`, `always_show`, `active_menu`, `is_root`, `is_frame`, `frame_src`, `keep_alive`, `hidden`, `affix`, `level`, `tree`, `sort`, `remark`, `status`, `created_at`, `updated_at`)
|
||||
SELECT NULL, @userListId, '解封用户', 'imUserUnban', '', '', '3', '', '/imUser/unban', '', '', '1', '', '0', '0', '', '0', '1', '0', '4', CONCAT('tr_', @imRootId, ' tr_', @userDirId, ' tr_', @userListId, ' '), '30', '', '1', @now, @now
|
||||
WHERE NOT EXISTS (SELECT 1 FROM `hg_admin_menu` WHERE `name` = 'imUserUnban');
|
||||
|
||||
INSERT INTO `hg_admin_menu` (`id`, `pid`, `title`, `name`, `path`, `icon`, `type`, `redirect`, `permissions`, `permission_name`, `component`, `always_show`, `active_menu`, `is_root`, `is_frame`, `frame_src`, `keep_alive`, `hidden`, `affix`, `level`, `tree`, `sort`, `remark`, `status`, `created_at`, `updated_at`)
|
||||
SELECT NULL, @userListId, '冻结用户', 'imUserFreeze', '', '', '3', '', '/imUser/freeze', '', '', '1', '', '0', '0', '', '0', '1', '0', '4', CONCAT('tr_', @imRootId, ' tr_', @userDirId, ' tr_', @userListId, ' '), '40', '', '1', @now, @now
|
||||
WHERE NOT EXISTS (SELECT 1 FROM `hg_admin_menu` WHERE `name` = 'imUserFreeze');
|
||||
|
||||
INSERT INTO `hg_admin_menu` (`id`, `pid`, `title`, `name`, `path`, `icon`, `type`, `redirect`, `permissions`, `permission_name`, `component`, `always_show`, `active_menu`, `is_root`, `is_frame`, `frame_src`, `keep_alive`, `hidden`, `affix`, `level`, `tree`, `sort`, `remark`, `status`, `created_at`, `updated_at`)
|
||||
SELECT NULL, @userListId, '解冻用户', 'imUserUnfreeze', '', '', '3', '', '/imUser/unfreeze', '', '', '1', '', '0', '0', '', '0', '1', '0', '4', CONCAT('tr_', @imRootId, ' tr_', @userDirId, ' tr_', @userListId, ' '), '50', '', '1', @now, @now
|
||||
WHERE NOT EXISTS (SELECT 1 FROM `hg_admin_menu` WHERE `name` = 'imUserUnfreeze');
|
||||
|
||||
INSERT INTO `hg_admin_menu` (`id`, `pid`, `title`, `name`, `path`, `icon`, `type`, `redirect`, `permissions`, `permission_name`, `component`, `always_show`, `active_menu`, `is_root`, `is_frame`, `frame_src`, `keep_alive`, `hidden`, `affix`, `level`, `tree`, `sort`, `remark`, `status`, `created_at`, `updated_at`)
|
||||
SELECT NULL, @userListId, '踢出设备', 'imUserDeviceKick', '', '', '3', '', '/imUser/deviceKick', '', '', '1', '', '0', '0', '', '0', '1', '0', '4', CONCAT('tr_', @imRootId, ' tr_', @userDirId, ' tr_', @userListId, ' '), '60', '', '1', @now, @now
|
||||
WHERE NOT EXISTS (SELECT 1 FROM `hg_admin_menu` WHERE `name` = 'imUserDeviceKick');
|
||||
|
||||
INSERT INTO `hg_admin_menu` (`id`, `pid`, `title`, `name`, `path`, `icon`, `type`, `redirect`, `permissions`, `permission_name`, `component`, `always_show`, `active_menu`, `is_root`, `is_frame`, `frame_src`, `keep_alive`, `hidden`, `affix`, `level`, `tree`, `sort`, `remark`, `status`, `created_at`, `updated_at`)
|
||||
SELECT NULL, @userListId, '踢出全部会话', 'imUserLogoutAll', '', '', '3', '', '/imUser/logoutAll', '', '', '1', '', '0', '0', '', '0', '1', '0', '4', CONCAT('tr_', @imRootId, ' tr_', @userDirId, ' tr_', @userListId, ' '), '70', '', '1', @now, @now
|
||||
WHERE NOT EXISTS (SELECT 1 FROM `hg_admin_menu` WHERE `name` = 'imUserLogoutAll');
|
||||
|
||||
INSERT INTO `hg_admin_menu` (`id`, `pid`, `title`, `name`, `path`, `icon`, `type`, `redirect`, `permissions`, `permission_name`, `component`, `always_show`, `active_menu`, `is_root`, `is_frame`, `frame_src`, `keep_alive`, `hidden`, `affix`, `level`, `tree`, `sort`, `remark`, `status`, `created_at`, `updated_at`)
|
||||
SELECT NULL, @userListId, '注销账号', 'imUserAccountCancel', '', '', '3', '', '/imUser/accountCancel', '', '', '1', '', '0', '0', '', '0', '1', '0', '4', CONCAT('tr_', @imRootId, ' tr_', @userDirId, ' tr_', @userListId, ' '), '80', '', '1', @now, @now
|
||||
WHERE NOT EXISTS (SELECT 1 FROM `hg_admin_menu` WHERE `name` = 'imUserAccountCancel');
|
||||
|
||||
INSERT INTO `hg_admin_menu` (`id`, `pid`, `title`, `name`, `path`, `icon`, `type`, `redirect`, `permissions`, `permission_name`, `component`, `always_show`, `active_menu`, `is_root`, `is_frame`, `frame_src`, `keep_alive`, `hidden`, `affix`, `level`, `tree`, `sort`, `remark`, `status`, `created_at`, `updated_at`)
|
||||
SELECT NULL, @userListId, '删除账号', 'imUserAccountDelete', '', '', '3', '', '/imUser/accountDelete', '', '', '1', '', '0', '0', '', '0', '1', '0', '4', CONCAT('tr_', @imRootId, ' tr_', @userDirId, ' tr_', @userListId, ' '), '90', '', '1', @now, @now
|
||||
WHERE NOT EXISTS (SELECT 1 FROM `hg_admin_menu` WHERE `name` = 'imUserAccountDelete');
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
allowBuilds:
|
||||
'@swc/core': true
|
||||
core-js: true
|
||||
esbuild: true
|
||||
vue-demi: true
|
||||
@@ -1,46 +0,0 @@
|
||||
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,
|
||||
});
|
||||
}
|
||||
@@ -80,75 +80,3 @@ export function AdminLogList(params) {
|
||||
params,
|
||||
});
|
||||
}
|
||||
|
||||
// Kafka修改群资料
|
||||
export function ProfileUpdate(params) {
|
||||
return http.request({
|
||||
url: '/imGroup/profileUpdate',
|
||||
method: 'POST',
|
||||
params,
|
||||
});
|
||||
}
|
||||
|
||||
// Kafka修改群治理配置
|
||||
export function GovernanceUpdate(params) {
|
||||
return http.request({
|
||||
url: '/imGroup/governanceUpdate',
|
||||
method: 'POST',
|
||||
params,
|
||||
});
|
||||
}
|
||||
|
||||
// Kafka设置群管理员
|
||||
export function AdminSet(params) {
|
||||
return http.request({
|
||||
url: '/imGroup/adminSet',
|
||||
method: 'POST',
|
||||
params,
|
||||
});
|
||||
}
|
||||
|
||||
// Kafka删除群管理员
|
||||
export function AdminRemove(params) {
|
||||
return http.request({
|
||||
url: '/imGroup/adminRemove',
|
||||
method: 'POST',
|
||||
params,
|
||||
});
|
||||
}
|
||||
|
||||
// Kafka禁言群成员
|
||||
export function MemberMute(params) {
|
||||
return http.request({
|
||||
url: '/imGroup/memberMute',
|
||||
method: 'POST',
|
||||
params,
|
||||
});
|
||||
}
|
||||
|
||||
// Kafka解除群成员禁言
|
||||
export function MemberUnmute(params) {
|
||||
return http.request({
|
||||
url: '/imGroup/memberUnmute',
|
||||
method: 'POST',
|
||||
params,
|
||||
});
|
||||
}
|
||||
|
||||
// Kafka移出群成员
|
||||
export function MemberRemove(params) {
|
||||
return http.request({
|
||||
url: '/imGroup/memberRemove',
|
||||
method: 'POST',
|
||||
params,
|
||||
});
|
||||
}
|
||||
|
||||
// Kafka转让群主
|
||||
export function OwnerTransfer(params) {
|
||||
return http.request({
|
||||
url: '/imGroup/ownerTransfer',
|
||||
method: 'POST',
|
||||
params,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
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,
|
||||
});
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
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,
|
||||
});
|
||||
}
|
||||
@@ -17,35 +17,3 @@ export function View(params) {
|
||||
params,
|
||||
});
|
||||
}
|
||||
|
||||
export function Ban(params) {
|
||||
return http.request({ url: '/imUser/ban', method: 'POST', params });
|
||||
}
|
||||
|
||||
export function Unban(params) {
|
||||
return http.request({ url: '/imUser/unban', method: 'POST', params });
|
||||
}
|
||||
|
||||
export function Freeze(params) {
|
||||
return http.request({ url: '/imUser/freeze', method: 'POST', params });
|
||||
}
|
||||
|
||||
export function Unfreeze(params) {
|
||||
return http.request({ url: '/imUser/unfreeze', method: 'POST', params });
|
||||
}
|
||||
|
||||
export function DeviceKick(params) {
|
||||
return http.request({ url: '/imUser/deviceKick', method: 'POST', params });
|
||||
}
|
||||
|
||||
export function LogoutAll(params) {
|
||||
return http.request({ url: '/imUser/logoutAll', method: 'POST', params });
|
||||
}
|
||||
|
||||
export function AccountCancel(params) {
|
||||
return http.request({ url: '/imUser/accountCancel', method: 'POST', params });
|
||||
}
|
||||
|
||||
export function AccountDelete(params) {
|
||||
return http.request({ url: '/imUser/accountDelete', method: 'POST', params });
|
||||
}
|
||||
|
||||
@@ -1,88 +0,0 @@
|
||||
<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>
|
||||
@@ -1,122 +0,0 @@
|
||||
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;
|
||||
}
|
||||
@@ -1,123 +0,0 @@
|
||||
<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>
|
||||
@@ -12,44 +12,6 @@
|
||||
<n-tabs v-model:value="activeTab" type="line" animated>
|
||||
<n-tab-pane name="settings" tab="治理配置">
|
||||
<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-item label="群名称">{{ governance.name || '-' }}</n-descriptions-item>
|
||||
<n-descriptions-item label="业务状态">{{ groupStatusLabel }}</n-descriptions-item>
|
||||
@@ -133,18 +95,15 @@
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, nextTick, ref, watch } from 'vue';
|
||||
import { useDialog, useMessage } from 'naive-ui';
|
||||
import { useDictStore } from '@/store/modules/dict';
|
||||
import { BasicTable } from '@/components/Table';
|
||||
import {
|
||||
AdminLogList,
|
||||
AnnouncementList,
|
||||
Governance,
|
||||
GovernanceUpdate,
|
||||
InvitationList,
|
||||
JoinRequestList,
|
||||
MuteList,
|
||||
ProfileUpdate,
|
||||
} from '@/api/imGroup';
|
||||
import {
|
||||
adminLogColumns,
|
||||
@@ -161,27 +120,11 @@
|
||||
|
||||
const settingStore = useProjectSettingStore();
|
||||
const dict = useDictStore();
|
||||
const message = useMessage();
|
||||
const dialog = useDialog();
|
||||
const showModal = ref(false);
|
||||
const loading = ref(false);
|
||||
const submitting = ref(false);
|
||||
const activeTab = ref('settings');
|
||||
const current = ref<State>(newState(null));
|
||||
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 groupStatusLabel = computed(() => dict.getLabel('ImGroupStatusOptions', governance.value.groupStatus) || '-');
|
||||
|
||||
@@ -235,86 +178,11 @@
|
||||
try {
|
||||
const res = await Governance({ tenantId: current.value.tenantId, topic: current.value.topic });
|
||||
governance.value = Object.assign(new GovernanceState(), res || {});
|
||||
fillCommandForm();
|
||||
} finally {
|
||||
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() {
|
||||
const refs = {
|
||||
joinRequests: joinRequestRef,
|
||||
@@ -345,8 +213,4 @@
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.governance-command-form {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
</style>
|
||||
<style lang="less"></style>
|
||||
|
||||
@@ -9,17 +9,11 @@
|
||||
:title="'群成员 ' + current.topic"
|
||||
: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
|
||||
ref="actionRef"
|
||||
:columns="memberColumns"
|
||||
:request="loadDataTable"
|
||||
:row-key="(row) => row.userId"
|
||||
:actionColumn="actionColumn"
|
||||
:scroll-x="scrollX"
|
||||
:resizeHeightOffset="-10000"
|
||||
/>
|
||||
@@ -33,63 +27,17 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, h, reactive, ref } from 'vue';
|
||||
import { useDialog, useMessage } from 'naive-ui';
|
||||
import { BasicTable, TableAction } from '@/components/Table';
|
||||
import { AdminRemove, AdminSet, MemberList, MemberMute, MemberRemove, MemberUnmute } from '@/api/imGroup';
|
||||
import { MemberState, memberColumns, newState, State } from './model';
|
||||
import { computed, ref } from 'vue';
|
||||
import { BasicTable } from '@/components/Table';
|
||||
import { MemberList } from '@/api/imGroup';
|
||||
import { memberColumns, newState, State } from './model';
|
||||
import { adaModalWidth, adaTableScrollX } from '@/utils/hotgo';
|
||||
|
||||
const dialog = useDialog();
|
||||
const message = useMessage();
|
||||
const showModal = ref(false);
|
||||
const current = ref<State>(newState(null));
|
||||
const actorUserId = ref<number | null>(null);
|
||||
const actionRef = ref();
|
||||
const dialogWidth = computed(() => adaModalWidth(1120));
|
||||
|
||||
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 dialogWidth = computed(() => adaModalWidth(980));
|
||||
const scrollX = computed(() => adaTableScrollX(memberColumns, 0));
|
||||
|
||||
// 加载表格数据
|
||||
const loadDataTable = async (res) => {
|
||||
@@ -108,57 +56,9 @@
|
||||
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) {
|
||||
current.value = newState(state);
|
||||
actorUserId.value = current.value.ownerId || null;
|
||||
showModal.value = true;
|
||||
setTimeout(() => {
|
||||
actionRef.value?.reload();
|
||||
@@ -170,8 +70,4 @@
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.member-command-form {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
</style>
|
||||
<style lang="less"></style>
|
||||
|
||||
@@ -1,72 +0,0 @@
|
||||
<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>
|
||||
@@ -1,106 +0,0 @@
|
||||
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 },
|
||||
];
|
||||
@@ -1,79 +0,0 @@
|
||||
<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>
|
||||
@@ -1,127 +0,0 @@
|
||||
<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>
|
||||
@@ -1,139 +0,0 @@
|
||||
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;
|
||||
}
|
||||
@@ -1,86 +0,0 @@
|
||||
<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>
|
||||
@@ -1,142 +0,0 @@
|
||||
<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>
|
||||
@@ -22,7 +22,6 @@
|
||||
/>
|
||||
</n-card>
|
||||
<ViewModal ref="viewRef" />
|
||||
<GovernanceModal ref="governanceRef" @reload="reloadTable" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -36,17 +35,15 @@
|
||||
import { columns, loadOptions, loadTenantOptions, schemas, State, tenantOptions } from './model';
|
||||
import { adaTableScrollX } from '@/utils/hotgo';
|
||||
import ViewModal from './view.vue';
|
||||
import GovernanceModal from './governance.vue';
|
||||
|
||||
const message = useMessage();
|
||||
const { hasPermission } = usePermission();
|
||||
const actionRef = ref();
|
||||
const searchFormRef = ref<any>({});
|
||||
const viewRef = ref();
|
||||
const governanceRef = ref();
|
||||
|
||||
const actionColumn = reactive({
|
||||
width: 170,
|
||||
width: 120,
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
fixed: 'right',
|
||||
@@ -59,11 +56,6 @@
|
||||
onClick: handleView.bind(null, record),
|
||||
auth: ['/imUser/view'],
|
||||
},
|
||||
{
|
||||
label: '治理',
|
||||
onClick: handleGovernance.bind(null, record),
|
||||
auth: ['/imUser/ban'],
|
||||
},
|
||||
],
|
||||
});
|
||||
},
|
||||
@@ -98,10 +90,6 @@
|
||||
viewRef.value.openModal(record);
|
||||
}
|
||||
|
||||
function handleGovernance(record: Recordable) {
|
||||
governanceRef.value.openModal(record);
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
loadOptions();
|
||||
await loadTenantOptions();
|
||||
|
||||
@@ -87,17 +87,6 @@
|
||||
class="py-4"
|
||||
>
|
||||
<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-form-item label="用户名" path="username">
|
||||
<n-input placeholder="请输入登录用户名" v-model:value="formParams.username" />
|
||||
|
||||
Reference in New Issue
Block a user