diff --git a/Dockerfile b/Dockerfile index d124c3f..0362f4f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,6 +1,7 @@ # ---- Stage 1: build the Vue frontend ---- FROM node:22-alpine AS web-build WORKDIR /web +ENV CI=true RUN npm install -g pnpm COPY web/package.json web/pnpm-lock.yaml web/pnpm-workspace.yaml ./ RUN pnpm install --frozen-lockfile diff --git a/docs/backstage/IM-Admin内部查询接口缺口清单.md b/docs/backstage/IM-Admin内部查询接口缺口清单.md new file mode 100644 index 0000000..6831bc8 --- /dev/null +++ b/docs/backstage/IM-Admin内部查询接口缺口清单.md @@ -0,0 +1,582 @@ +# IM Admin 内部查询接口缺口清单 + +> 目标:梳理 HotGo/Admin 迁移到“Admin 只访问 hotgo,IM Server 只访问租户库”后,IM Server 需要补齐的 `/internal/admin-query/*` 只读接口。 +> 当前日期:2026-08-27 +> 适用项目:`im_manager` Admin 后台、`im_server` IM 服务端。 + +## 1. 背景 + +Kafka 写链路已经在 Admin 侧补齐:Admin 写操作落 `im_admin_commands`、`im_admin_outbox`,通过 Kafka 发给 IM Server,IM Server 执行业务后回传 `im.admin.command.result`。 + +查询链路还没有完全隔离。Admin 当前仍有不少页面通过 `imtenant.DB()` 或 `resolveImTenantDB()` 直连租户库。若要严格满足目标架构,Admin 查询租户业务数据时应调用 IM Server 的内部只读接口: + +```text +GET /internal/admin-query/tenants/{tenant_code}/... +``` + +IM Server 当前已经具备部分内部查询接口,但还不能覆盖 Admin 后台页面。 + +## 2. 已有接口 + +以下接口在 IM Server 当前实现中已经存在,可供 Admin 先迁移: + +| 接口 | 用途 | 备注 | +|---|---|---| +| `GET /internal/admin-query/tenants/{tenant_code}/users/{user_id}` | 用户详情 | 需要增强字段,见 P0。 | +| `GET /internal/admin-query/tenants/{tenant_code}/groups/{topic}` | 群详情和治理配置 | 需要增强 topic 基础字段,见 P0。 | +| `GET /internal/admin-query/tenants/{tenant_code}/groups/{topic}/members` | 群成员列表 | 当前偏 cursor 分页,Admin 表格还需要 total。 | +| `GET /internal/admin-query/tenants/{tenant_code}/messages/{topic}/{seq_id}` | 消息详情 | 当前 Admin 暂未完整迁移消息页面。 | +| `GET /internal/admin-query/tenants/{tenant_code}/files/{file_id}` | 文件详情 | 还缺文件列表和引用列表。 | +| `GET /internal/admin-query/tenants/{tenant_code}/kafka/dead-letters` | Kafka 死信列表 | Admin 已接入。 | +| `GET /internal/admin-query/tenants/{tenant_code}/kafka/dead-letters/{dead_id}` | Kafka 死信详情 | Admin 已接入。 | + +## 3. 统一约定 + +### 3.1 鉴权 + +沿用 IM Server 内部 API HMAC 鉴权: + +```text +X-Service-Name +X-Timestamp +X-Nonce +X-Signature +``` + +服务端需要校验: + +1. service 是否存在。 +2. service 是否包含 `admin_query` capability。 +3. timestamp 是否在允许窗口内。 +4. nonce 是否防重放。 +5. 签名是否匹配。 + +### 3.2 分页返回 + +Admin 的 `BasicTable` 更适合 page/per_page/total 格式。建议所有列表接口统一返回: + +```json +{ + "code": 0, + "data": { + "items": [], + "total": 0, + "page": 1, + "per_page": 20 + } +} +``` + +如果 IM Server 内部更适合 cursor,也建议同时返回: + +```json +{ + "code": 0, + "data": { + "items": [], + "total": 0, + "page": 1, + "per_page": 20, + "next_cursor": "", + "has_more": false + } +} +``` + +### 3.3 详情返回 + +详情接口统一返回: + +```json +{ + "code": 0, + "data": {} +} +``` + +### 3.4 时间与 ID + +1. 时间统一返回 RFC3339/RFC3339Nano 字符串。 +2. 雪花 ID、Tinode 用户 ID、19 位整数 ID 建议以字符串返回,避免前端丢精度。 +3. 入参同时兼容字符串 ID 和纯数字 ID 时,IM Server 内部自行解析。 + +## 4. P0:优先补齐 + +P0 是 Admin 当前核心页面迁移所需接口,补完后可以先迁移用户、群组、文件等高频页面。 + +### 4.1 用户列表 + +```text +GET /internal/admin-query/tenants/{tenant_code}/users +``` + +用途:IM 用户管理列表。 + +建议查询参数: + +| 参数 | 类型 | 说明 | +|---|---|---| +| `user_id` | string | 用户 ID,支持精确查询。 | +| `nickname` | string | 昵称模糊查询。 | +| `auth_name` | string | 登录标识,匹配 username/phone/email/凭证。 | +| `state` | int | 用户状态。 | +| `created_at_start` | string | 创建时间开始。 | +| `created_at_end` | string | 创建时间结束。 | +| `last_seen_start` | string | 最后在线开始。 | +| `last_seen_end` | string | 最后在线结束。 | +| `page` | int | 页码。 | +| `per_page` | int | 每页数量,最大建议 200。 | + +建议返回字段: + +| 字段 | 说明 | +|---|---| +| `user_id` | 字符串用户 ID。 | +| `username` | 用户名。 | +| `phone` | 手机号。 | +| `nickname` | 昵称。 | +| `public_account` | 公开账号。 | +| `state` | 用户状态。 | +| `gender` | 性别。 | +| `region` | 地区。 | +| `auth_names` | 登录标识摘要。 | +| `credentials` | 凭证摘要。 | +| `auth_version` | 鉴权版本。 | +| `password_updated_at` | 密码更新时间。 | +| `last_seen` | 最后在线时间。 | +| `user_agent` | 最后设备 UA。 | +| `created_at` | 创建时间。 | +| `updated_at` | 更新时间。 | + +当前 Admin 直连涉及表:`im_users`、`im_user_profiles`、`im_user_security`、`im_credentials`。 + +### 4.2 用户详情增强 + +现有接口: + +```text +GET /internal/admin-query/tenants/{tenant_code}/users/{user_id} +``` + +建议增强返回字段: + +| 字段 | 说明 | +|---|---| +| `profile` | 结构化用户资料。 | +| `public` | Tinode public JSON。 | +| `trusted` | Tinode trusted JSON。 | +| `access` | 访问权限 JSON。 | +| `tags` | 标签。 | +| `security` | `auth_version/password_updated_at` 等安全信息。 | +| `credentials` | 凭证列表,包含 method/value/done。 | + +### 4.3 用户设备列表 + +```text +GET /internal/admin-query/tenants/{tenant_code}/users/{user_id}/devices +``` + +用途:用户详情中的登录设备列表。 + +建议查询参数: + +| 参数 | 类型 | 说明 | +|---|---|---| +| `device_id` | string | 设备 ID。 | +| `platform` | string | 平台。 | +| `page` | int | 页码。 | +| `per_page` | int | 每页数量。 | + +建议返回字段: + +| 字段 | 说明 | +|---|---| +| `device_id` | 设备 ID。 | +| `platform` | 平台。 | +| `user_agent` | UA。 | +| `last_seen` | 最后在线。 | +| `created_at` | 创建时间。 | +| `updated_at` | 更新时间。 | + +当前 Admin 直连涉及表:`im_devices`。 + +### 4.4 群列表 + +```text +GET /internal/admin-query/tenants/{tenant_code}/groups +``` + +用途:IM 群组管理列表。 + +建议查询参数: + +| 参数 | 类型 | 说明 | +|---|---|---| +| `topic` | string | 群 Topic。 | +| `owner_id` | string | 群主 ID。 | +| `state` | int | Tinode topic 状态。 | +| `group_status` | string | 业务群状态。 | +| `created_at_start` | string | 创建时间开始。 | +| `created_at_end` | string | 创建时间结束。 | +| `touched_at_start` | string | 活跃时间开始。 | +| `touched_at_end` | string | 活跃时间结束。 | +| `page` | int | 页码。 | +| `per_page` | int | 每页数量。 | + +建议返回字段: + +| 字段 | 说明 | +|---|---| +| `topic` | 群 Topic。 | +| `state` | Tinode 状态。 | +| `owner_id` | 群主 ID。 | +| `seq_id` | 最新消息序号。 | +| `del_id` | 删除序号。 | +| `sub_count` | Tinode 订阅成员数。 | +| `touched_at` | 活跃时间。 | +| `created_at` | 创建时间。 | +| `updated_at` | 更新时间。 | +| `public` | Tinode public JSON。 | +| `trusted` | Tinode trusted JSON。 | +| `tags` | 标签。 | +| `aux` | 辅助 JSON。 | +| `group_name` | 群名称。 | +| `avatar_file_id` | 群头像文件 ID。 | +| `description` | 群简介。 | +| `group_status` | 业务群状态。 | +| `join_approval` | 入群审批开关。 | +| `mute_all` | 全员禁言。 | +| `member_upload_files` | 成员上传文件。 | +| `member_private_chat` | 成员私聊。 | +| `member_invite` | 成员邀请。 | +| `max_member_count` | 最大成员数。 | +| `max_admin_count` | 最大管理员数。 | +| `business_member_count` | 业务成员数。 | +| `settings_version` | 治理配置版本。 | +| `settings_updated_at` | 治理配置更新时间。 | + +当前 Admin 直连涉及表:`im_topics`、`im_group_settings`。 + +### 4.5 群详情增强 + +现有接口: + +```text +GET /internal/admin-query/tenants/{tenant_code}/groups/{topic} +``` + +当前接口主要返回 `im_group_settings`。建议增强 Tinode topic 基础字段: + +| 字段 | 说明 | +|---|---| +| `state` | Topic 状态。 | +| `seq_id` | 最新消息序号。 | +| `del_id` | 删除序号。 | +| `sub_count` | 订阅数量。 | +| `public` | public JSON。 | +| `trusted` | trusted JSON。 | +| `access` | access JSON。 | +| `tags` | tags JSON。 | +| `aux` | aux JSON。 | +| `touched_at` | 活跃时间。 | + +### 4.6 群成员列表增强 + +现有接口: + +```text +GET /internal/admin-query/tenants/{tenant_code}/groups/{topic}/members +``` + +建议增强查询参数: + +| 参数 | 类型 | 说明 | +|---|---|---| +| `user_id` | string | 用户 ID 精确过滤。 | +| `role` | string | `owner/admin/member`。 | +| `member_status` | string | 成员状态。 | +| `q` | string | 昵称、公开账号等关键词。 | +| `eligible_role` | string | 候选角色筛选。 | +| `page` | int | 页码。 | +| `per_page` | int | 每页数量。 | +| `cursor` | string | 兼容 cursor 分页。 | +| `limit` | int | 兼容 cursor 分页。 | + +建议增强返回: + +| 字段 | 说明 | +|---|---| +| `total` | 总数,Admin 表格需要。 | +| `items[].user_id` | 用户 ID。 | +| `items[].nickname` | 昵称。 | +| `items[].state` | 用户状态。 | +| `items[].mode_want` | 期望权限。 | +| `items[].mode_given` | 授予权限。 | +| `items[].recv_seq_id` | 接收序号。 | +| `items[].read_seq_id` | 已读序号。 | +| `items[].del_id` | 删除序号。 | +| `items[].role` | 业务角色。 | +| `items[].member_status` | 成员状态。 | +| `items[].group_nickname` | 群昵称。 | +| `items[].pinned` | 置顶。 | +| `items[].mute_notifications` | 免打扰。 | +| `items[].join_source` | 加入来源。 | +| `items[].joined_by` | 邀请或处理人。 | +| `items[].joined_at` | 加入时间。 | +| `items[].left_at` | 离开时间。 | +| `items[].removed_by` | 移除人。 | +| `items[].version` | 版本。 | + +当前 Admin 直连涉及表:`im_group_members`、`im_subscriptions`、`im_users`、`im_user_profiles`。 + +### 4.7 入群申请列表 + +```text +GET /internal/admin-query/tenants/{tenant_code}/groups/{topic}/join-requests +``` + +建议查询参数:`status`、`applicant_user_id`、`page`、`per_page`。 + +建议返回字段:`request_id`、`topic`、`applicant_user_id`、`inviter_user_id`、`source`、`message`、`status`、`reject_reason`、`handled_by`、`handled_at`、`expires_at`、`version`、`created_at`、`updated_at`。 + +当前 Admin 直连涉及表:`im_group_join_requests`。 + +### 4.8 群邀请列表 + +```text +GET /internal/admin-query/tenants/{tenant_code}/groups/{topic}/invitations +``` + +建议查询参数:`status`、`target_user_id`、`inviter_user_id`、`page`、`per_page`。 + +建议返回字段:`invitation_id`、`topic`、`inviter_user_id`、`target_user_id`、`message`、`status`、`result`、`join_request_id`、`expires_at`、`handled_at`、`created_at`、`updated_at`。 + +当前 Admin 直连涉及表:`im_group_invitations`。 + +### 4.9 群公告列表 + +```text +GET /internal/admin-query/tenants/{tenant_code}/groups/{topic}/announcements +``` + +建议查询参数:`status`、`page`、`per_page`。 + +建议返回字段:`announcement_id`、`topic`、`author_user_id`、`content`、`status`、`version`、`published_at`、`deleted_by`、`deleted_at`、`created_at`、`updated_at`。 + +当前 Admin 直连涉及表:`im_group_announcements`。 + +### 4.10 群禁言列表 + +```text +GET /internal/admin-query/tenants/{tenant_code}/groups/{topic}/mutes +``` + +建议查询参数:`status`、`user_id`、`page`、`per_page`。 + +建议返回字段:`topic`、`user_id`、`muted_by`、`muted_at`、`expires_at`、`reason`、`status`、`revoked_by`、`revoked_at`、`version`、`created_at`、`updated_at`。 + +当前 Admin 直连涉及表:`im_group_mutes`。 + +### 4.11 群管理日志 + +```text +GET /internal/admin-query/tenants/{tenant_code}/groups/{topic}/admin-logs +``` + +建议查询参数:`action`、`operator_id`、`page`、`per_page`。 + +建议返回字段:`log_id`、`topic`、`operator_id`、`target_user_id`、`action`、`object_type`、`object_id`、`before_data`、`after_data`、`request_id`、`idempotency_key`、`created_at`。 + +当前 Admin 直连涉及表:`im_group_admin_logs`。 + +### 4.12 群文件列表 + +```text +GET /internal/admin-query/tenants/{tenant_code}/groups/{topic}/files +``` + +用途:群治理页面中的群文件列表。不要和已有单文件详情接口混淆。 + +建议查询参数:`status`、`file_type`、`file_name`、`uploader_id`、`page`、`per_page`。 + +建议返回字段:`group_file_id`、`topic`、`file_id`、`source_topic`、`source_seq`、`uploader_id`、`file_name`、`mime_type`、`file_type`、`file_size`、`status`、`deleted_by`、`deleted_at`、`created_at`、`updated_at`。 + +当前 Admin 直连涉及表:`im_group_files`。 + +### 4.13 文件列表 + +```text +GET /internal/admin-query/tenants/{tenant_code}/files +``` + +用途:IM 文件管理列表。 + +建议查询参数:`file_id`、`user_id`、`status`、`mime_type`、`created_at_start`、`created_at_end`、`page`、`per_page`。 + +建议返回字段:`id`、`file_id`、`user_id`、`mime_type`、`file_size`、`file_name`、`location`、`etag`、`status`、`quarantine_reason`、`created_at`、`updated_at`。 + +当前 Admin 直连涉及表:`im_fileuploads`。 + +### 4.14 文件引用列表 + +```text +GET /internal/admin-query/tenants/{tenant_code}/files/{file_id}/links +``` + +用途:文件详情中展示消息引用。 + +建议返回字段:`topic`、`seq_id`、`user_id`、`created_at`。 + +当前 Admin 直连涉及表:`im_filemsglinks`。 + +## 5. P1:后台审核和运营页面 + +### 5.1 审计日志 + +```text +GET /internal/admin-query/tenants/{tenant_code}/audit-logs +GET /internal/admin-query/tenants/{tenant_code}/audit-logs/{id} +``` + +建议查询参数:`actor_user_id`、`action`、`target_type`、`target_id`、`created_at_start`、`created_at_end`、`page`、`per_page`。 + +建议返回字段:`id`、`request_id`、`actor_user_id`、`action`、`target_type`、`target_id`、`ip`、`user_agent`、`detail`、`created_at`。 + +当前 Admin 直连涉及表:`im_audit_logs`。 + +### 5.2 实名审核 + +```text +GET /internal/admin-query/tenants/{tenant_code}/realname-verifications +GET /internal/admin-query/tenants/{tenant_code}/realname-verifications/{id} +``` + +建议查询参数:`user_id`、`status`、`real_name`、`created_at_start`、`created_at_end`、`page`、`per_page`。 + +建议返回字段:`id`、`user_id`、`real_name`、`id_card_no_masked`、`front_file_id`、`back_file_id`、`status`、`reject_reason`、`reviewed_by`、`reviewed_at`、`created_at`、`updated_at`。 + +当前 Admin 直连涉及表:`im_realname_verifications`。 + +### 5.3 举报工单 + +```text +GET /internal/admin-query/tenants/{tenant_code}/abuse-reports +GET /internal/admin-query/tenants/{tenant_code}/abuse-reports/{id} +GET /internal/admin-query/tenants/{tenant_code}/reports/abuse/{report_id}/target +``` + +建议查询参数:`reporter_user_id`、`target_type`、`target_id`、`status`、`created_at_start`、`created_at_end`、`page`、`per_page`。 + +建议返回字段:`id`、`report_id`、`reporter_user_id`、`target_type`、`target_id`、`reason_code`、`description`、`evidence_file_ids`、`status`、`handler_id`、`handled_at`、`handle_result`、`created_at`、`updated_at`。 + +当前 Admin 直连涉及表:`im_abuse_reports`。 + +### 5.4 意见反馈 + +```text +GET /internal/admin-query/tenants/{tenant_code}/feedback-submissions +GET /internal/admin-query/tenants/{tenant_code}/feedback-submissions/{id} +``` + +建议查询参数:`user_id`、`category`、`status`、`created_at_start`、`created_at_end`、`page`、`per_page`。 + +建议返回字段:`id`、`feedback_id`、`user_id`、`category`、`content`、`contact`、`file_ids`、`status`、`handler_id`、`handled_at`、`reply`、`created_at`、`updated_at`。 + +当前 Admin 直连涉及表:`im_feedback_submissions`。 + +### 5.5 业务 Outbox + +```text +GET /internal/admin-query/tenants/{tenant_code}/outbox/business-events +GET /internal/admin-query/tenants/{tenant_code}/outbox/group-events +GET /internal/admin-query/tenants/{tenant_code}/outbox/business-events/{id} +GET /internal/admin-query/tenants/{tenant_code}/outbox/group-events/{id} +``` + +建议查询参数:`event_type`、`status`、`target_type`、`target`、`created_at_start`、`created_at_end`、`page`、`per_page`。 + +当前 Admin 直连涉及表:`im_business_events_outbox`、`im_group_events_outbox`。 + +## 6. P2:如果要完全消除租户库直连 + +这些不是 Kafka 联调第一优先级,但如果最终要求 Admin 完全不碰租户库,也需要 IM Server 提供内部查询或改为 hotgo 摘要表。 + +### 6.1 组织架构 + +```text +GET /internal/admin-query/tenants/{tenant_code}/org/departments +GET /internal/admin-query/tenants/{tenant_code}/org/members +``` + +当前 Admin 直连涉及表:`im_org_departments`、`im_org_members`、`im_users`。 + +### 6.2 工作台应用 + +```text +GET /internal/admin-query/tenants/{tenant_code}/workbench/apps +``` + +当前 Admin 直连涉及工作台相关租户表。 + +### 6.3 租户任务看板摘要 + +```text +GET /internal/admin-query/tenants/{tenant_code}/dashboard/summary +``` + +当前 Admin 会统计多个租户库中的待处理实名、举报、反馈、Outbox 等数量。 + +### 6.4 租户配置类查询 + +如果这些配置仍放在租户库,也需要补接口;如果后续迁到 hotgo 控制库,则不需要通过 IM Server 查询。 + +```text +GET /internal/admin-query/tenants/{tenant_code}/feedback-config +GET /internal/admin-query/tenants/{tenant_code}/mailbox-config +GET /internal/admin-query/tenants/{tenant_code}/abuse-reasons +GET /internal/admin-query/tenants/{tenant_code}/app-releases +``` + +涉及模块:反馈配置、总经理信箱配置、举报原因、App 发布策略。 + +## 7. Admin 侧迁移建议 + +### 7.1 第一批迁移 + +在 IM Server 补完 P0 后,Admin 优先迁移: + +1. 用户列表、用户详情、用户设备。 +2. 群列表、群详情、群成员。 +3. 群入群申请、邀请、公告、禁言、管理日志、群文件。 +4. 文件列表、文件详情、文件引用。 + +### 7.2 第二批迁移 + +在 IM Server 补完 P1 后,Admin 再迁移: + +1. 审计日志。 +2. 实名审核。 +3. 举报工单。 +4. 意见反馈。 +5. 业务 Outbox。 + +### 7.3 保留 hotgo 查询的范围 + +以下数据天然属于 hotgo 控制库,Admin 可继续查 hotgo,不需要走 IM Server: + +1. 租户表和租户绑定表。 +2. Admin 命令表:`im_admin_commands`。 +3. Admin Outbox 表:`im_admin_outbox`。 +4. Admin Kafka 审计事件表:`im_audit_events`。 +5. 菜单、角色、管理员、系统配置、字典等 HotGo 系统表。 + +## 8. 验收标准 + +1. Admin 查询租户业务数据时,不再出现新的 `imtenant.DB()` 调用。 +2. Admin 查询 IM Server 内部接口时,统一使用 HMAC 签名。 +3. 列表接口支持 `page/per_page/total`,兼容后台表格分页。 +4. 详情接口返回结构化 JSON,不返回数据库内部敏感字段。 +5. 用户 ID、雪花 ID 等大整数以字符串返回。 +6. IM Server 为内部查询写审计日志,至少包含 `tenant_code`、`service_name`、`path`、`query`、`trace_id`、`status`、`duration_ms`。 +7. 关闭 IM Server 内部查询能力时,Admin 应得到明确错误,不泄露租户库信息。 + diff --git a/docs/backstage/IM-Kafka接入方案.md b/docs/backstage/IM-Kafka接入方案.md new file mode 100644 index 0000000..c9dfa26 --- /dev/null +++ b/docs/backstage/IM-Kafka接入方案.md @@ -0,0 +1,953 @@ +# 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 记录,保留完整处理痕迹。 + +### 11.12 IM Server 已接入的用户治理命令 + +IM Server 已在 `im.admin.command` 中接入用户治理类后台动作。Admin 侧写 hotgo 操作单并发送 Kafka Command,IM 消费后修改租户库用户状态、踢在线连接、清理缓存并回传 `im.admin.command.result`。 + +支持命令: + +| `event_type` | 行为 | Payload 关键字段 | +| --- | --- | --- | +| `user.ban` | 封禁用户,设置 `StateSuspended` 并踢所有在线会话。 | `actor_user_id`、`user_id` 或 `target_user_id`、`reason` | +| `user.unban` | 解封用户,设置 `StateOK`。 | `actor_user_id`、`user_id` 或 `target_user_id`、`reason` | +| `user.freeze` | 冻结账号,设置 `StateSuspended` 并踢所有在线会话。 | `actor_user_id`、`user_id` 或 `target_user_id`、`reason` | +| `user.unfreeze` | 恢复账号,设置 `StateOK`。 | `actor_user_id`、`user_id` 或 `target_user_id`、`reason` | +| `user.device.kick` | 踢指定设备在线连接,并删除设备推送记录。 | `actor_user_id`、`user_id` 或 `target_user_id`、`device_id`、`reason` | +| `user.logout_all` | 踢用户所有在线连接,不修改账号状态。 | `actor_user_id`、`user_id` 或 `target_user_id`、`reason` | +| `user.account.cancel` | 注销账号,默认软删除;`hard=true` 时硬删除。 | `actor_user_id`、`user_id` 或 `target_user_id`、`hard`、`reason` | +| `user.account.delete` | 删除账号,按硬删除处理。 | `actor_user_id`、`user_id` 或 `target_user_id`、`reason` | + +命令示例: + +```json +{ + "event_id": "admin:user:ban:default:usrabc:001", + "tenant_code": "default", + "source_service": "hotgo-admin", + "target_service": "im-server", + "event_type": "user.ban", + "biz_type": "user_governance", + "biz_id": "usrabc", + "idempotency_key": "admin-operation-id", + "occurred_at": "2026-08-26T10:10:00.000Z", + "payload": { + "command_id": "admin-operation-id", + "actor_user_id": "usrAdmin", + "user_id": "usrabc", + "reason": "违规处理" + } +} +``` + +处理说明: + +1. 封禁/冻结共用 Tinode 用户状态 `StateSuspended`;解封/恢复共用 `StateOK`。 +2. 状态变更后 IM 会清理用户缓存,并向本节点 Hub 投递用户状态变更。 +3. 踢设备只影响匹配 `device_id` 的在线连接,同时删除该设备 Push token 记录。 +4. 全端登出只踢在线连接,不修改用户状态。 +5. 注销/删除会停止用户相关 Topic、清理缓存并调用 `store.Users.Delete`。 +6. 命令执行成功后统一回传 `im.admin.command.result`,并写入 `im.audit.event`。 + +7. 群消息消费成功后继续写入 Redis 热消息缓存,便于 24 小时内热点拉取。 + +事件示例: + +```json +{ + "event_id": "msg.persist.1.grpabc.10086", + "tenant_code": "default", + "source_service": "im-server", + "target_service": "im-server", + "event_type": "message.persist.group", + "biz_type": "message", + "biz_id": "grpabc:10086", + "idempotency_key": "client-mid-001", + "version": 10086, + "occurred_at": "2026-08-26T10:10:00.000Z", + "actor": { + "type": "user", + "id": "usr..." + }, + "payload": { + "message_kind": "group", + "read_by_sender": true, + "attachment_urls": [], + "message": { + "SeqId": 10086, + "Topic": "grpabc", + "From": "abcdef", + "Head": { + "x-im-client-mid": "client-mid-001" + }, + "Content": { + "txt": "hello" + } + } + } +} +``` + +### 11.9 IM Server 已接入的 Kafka Outbox Worker + +IM Server 已接入租户库 `im_kafka_outbox` 通用发送 worker,用于避免“业务库写成功但 Kafka 未发送”的事件丢失。 + +当前能力: + +1. 启动后按 active 租户扫描每个租户库的 `im_kafka_outbox`。 +2. 扫描 `pending/failed` 且已到 `next_retry_at` 的事件。 +3. 发送成功后标记 `sent`、写入 `sent_at`。 +4. 发送失败后标记 `failed`,按 Kafka 配置的 `retry_backoff_ms` 写入下一次重试时间。 +5. 首个接入方为 `im.message.persist`,后续文件生命周期、工作台事件、审计事件可复用同一 outbox。 + +本轮暂不实现 `im.message.push` 离线 Push Kafka 化,已记录到 [IM 第一至十一章后续增强项](./IM-未实现后续增强项.md)。 + +### 11.10 IM Server 已接入的文件生命周期事件 + +IM Server 已接入 `im.file.lifecycle` 的首批生产与消费,用于把文件上传、附件绑定、群文件登记/删除纳入可靠事件链路。 + +当前事件: + +| `event_type` | 生产时机 | 消费行为 | +| --- | --- | --- | +| `file.upload.completed` | HTTP/gRPC 上传成功并完成文件记录后。 | 当前作为可靠生命周期事实记录,文件安全任务仍由上传完成事务创建。 | +| `file.attachments.bind` | `im.message.persist` 消费成功后,如果消息携带附件。 | 私聊写 `im_filemsglinks`;群聊写 `im_group_files`。 | +| `group.file.created` | 群文件 API 登记成功后。 | 当前作为可靠生命周期事实记录,供后续 Admin 摘要、审计、回收策略复用。 | +| `group.file.deleted` | 群文件 API 删除成功后。 | 当前作为可靠生命周期事实记录,供后续 Admin 摘要、审计、回收策略复用。 | + +说明: + +1. Kafka 开启时,消息附件绑定从消息落库消费者中拆出,改由 `im.file.lifecycle` 消费者完成。 +2. Kafka 关闭时,仍沿用原同步绑定路径,兼容本地环境。 +3. 文件对象字节流仍走 OSS/文件服务,Kafka 不承载上传/下载内容。 +4. 合规删除、临时文件 TTL 回收、OSS 删除动作仍保留为后续深化项。 + +### 11.11 IM Server 已接入的工作台事件 + +IM Server 已接入 `im.workbench.event` 的首批生产与消费。 + +当前生产范围: + +| 模块 | 事件 | +| --- | --- | +| 会议 | `meeting.created`、`meeting.updated`、`meeting.canceled`、`meeting.reminded`、`meeting.response.updated` | +| 日报 | `daily_report.draft.created`、`daily_report.draft.saved`、`daily_report.submitted`、`daily_report.reminded` | +| 月报 | `monthly_report.draft.created`、`monthly_report.draft.saved`、`monthly_report.submitted`、`monthly_report.reopened` | +| 总经理信箱 | `mailbox.submitted` | + +当前消费行为: + +1. 消费 `im.workbench.event`。 +2. 按 `target_user_ids` 生成系统通知。 +3. 写入 `im_business_events_outbox`,由现有 WS 业务事件投递器下发在线客户端。 +4. 使用 `im_kafka_consume_log` 做消费幂等,失败按统一重试和死信规则处理。 + +### 11.12 IM Server 已接入的审计事件 + +IM Server 已接入 `im.audit.event` 的可靠生产,审计事件写入租户库 `im_kafka_outbox` 后由 Kafka Outbox worker 发送。 + +当前审计范围: + +| 类型 | 事件 | +| --- | --- | +| 工作台 | 会议、日报、月报、总经理信箱的状态变更。 | +| Admin Command | 后台群治理命令执行成功。 | +| Kafka 死信 | `kafka.dead_letter.created`。 | +| 文件生命周期 | 上传完成、附件绑定、群文件新增/删除。 | + +审计事件当前不在 IM 内部落审计表,预期由 Admin/HotGo 消费 `im.audit.event` 后写入 `hotgo` 审计库或审计表。 + +## 12. 风险与约束 + +1. Kafka 是异步链路,Admin 后台命令不能默认立即成功,只能显示 `pending/running/succeeded/failed`。 +2. 查询不能通过 Kafka 常规化实现,高频实时查询必须走内部只读 API 或 hotgo 摘要表。 +3. 内部 API 不能暴露公网,签名、nonce、防重放和审计必须首批完成。 +4. 配置事件可能丢失或延迟,IM 必须保留启动预热和缓存缺失时的 Admin 配置查询兜底。 +5. Kafka 消费可能重复,所有消费者和业务写入都必须幂等。 +6. Admin 和 IM 不跨库直连后,排障要依赖 `trace_id` 串联 hotgo 操作单、Kafka 事件、IM 执行日志和 WS 下发日志。 diff --git a/docs/backstage/IM-Kafka接入联调测试文档.md b/docs/backstage/IM-Kafka接入联调测试文档.md new file mode 100644 index 0000000..5fa0a54 --- /dev/null +++ b/docs/backstage/IM-Kafka接入联调测试文档.md @@ -0,0 +1,1144 @@ +# IM Kafka 接入联调测试文档 + +> 关联文档:[IM Kafka 接入方案](./IM-Kafka接入方案.md)、[IM Kafka 接入范围识别](./IM-Kafka接入范围识别.md)、[IM Kafka 基础表 SQL](./sql/IM-Kafka基础表SQL.sql) +> 适用范围:Admin/HotGo 与 IM Server 通过 Kafka 和内部只读 API 联调。 +> 当前约定:Admin 只访问 `hotgo`,IM Server 只访问租户库;跨系统写操作走 Kafka,跨系统查询走内部只读 API。 + +## 1. 联调目标 + +1. 验证 Kafka 基础生产、消费、幂等、重试、死信、Outbox 能正常工作。 +2. 验证 Admin 后台操作通过 `im.admin.command` 投递到 IM Server,并通过 `im.admin.command.result` 返回最终状态。 +3. 验证 IM Server 消息异步落库、文件生命周期、工作台事件、审计事件、缓存失效、死信处理、用户治理命令全部可用。 +4. 验证 Admin 与 IM Server 不跨库直连:Admin 查询租户业务数据走 IM 内部 API;IM 查询 hotgo 配置走 Admin 内部 API。 +5. 验证 Kafka 异常情况下不会产生重复业务结果,且失败可重试、可进入死信、可人工重放。 + +## 2. 环境准备 + +### 2.1 服务与依赖 + +| 组件 | 要求 | +| --- | --- | +| MySQL | `hotgo` 控制库、租户库如 `tinode` 已创建并可连接。 | +| Redis | IM Server 已配置 Redis,用于在线状态、短状态、热缓存和跨节点 fanout。 | +| Kafka | Topic 已创建或允许自动创建;建议测试环境显式创建。 | +| OSS | 文件上传下载仍走 OSS,Kafka 只处理文件元数据和生命周期事件。 | +| Admin/HotGo | 已接入 Kafka producer/consumer、outbox、内部配置查询接口。 | +| IM Server | 已启用 Kafka、内部 Admin Query API、Admin Internal Config Client。 | + +### 2.2 Kafka Topic + +| Topic | 方向 | 用途 | +| --- | --- | --- | +| `im.admin.command` | Admin -> IM | 后台群治理、用户治理、死信处理命令。 | +| `im.admin.command.result` | IM -> Admin | 后台命令执行结果。 | +| `im.config.event` | Admin -> IM | 租户配置、字典、第三方配置变更通知。 | +| `im.cache.invalidate` | Admin/IM -> IM | 跨节点缓存失效。 | +| `im.message.persist` | IM -> IM | 私聊、群聊消息异步落库。 | +| `im.file.lifecycle` | IM -> IM | 文件上传完成、附件绑定、群文件登记/删除。 | +| `im.workbench.event` | Admin/IM -> IM | 工作台事件、红点、系统通知。 | +| `im.audit.event` | IM -> Admin/审计消费者 | 审计事件。 | +| `im.dead.letter` | IM -> Admin/告警 | 消费失败死信。 | + +说明:`im.message.push` 已规划但本轮不联调,离线 Push Kafka 化仍在增强项中。 + +### 2.3 数据库准备 + +租户库必须执行: + +```sql +SOURCE docs/sql/IM-Kafka基础表SQL.sql; +``` + +重点确认租户库存在: + +| 表 | 用途 | +| --- | --- | +| `im_kafka_consume_log` | 消费幂等、重试状态。 | +| `im_kafka_outbox` | IM 侧可靠生产 Kafka 事件。 | +| `im_kafka_dead_letters` | 死信记录与人工处理状态。 | +| `im_messages` | 私聊消息存储。 | +| `im_group_messages` | 群聊消息存储。 | +| `im_filemsglinks` | 私聊附件引用。 | +| `im_group_files` | 群文件和群附件引用。 | +| `im_system_notifications` | 工作台等系统通知。 | +| `im_business_events_outbox` | WS 业务事件投递队列。 | + +Admin/HotGo 侧需具备: + +| 表/能力 | 用途 | +| --- | --- | +| `im_admin_commands` | Admin 操作单,记录后台操作 `pending/running/succeeded/failed/dead`。 | +| `im_admin_outbox` | Admin outbox,与操作单同事务写入,可靠发送 Kafka。 | +| `im_kafka_dead_letter_events` | Admin 消费 `im.dead.letter` 后落 HotGo,用于告警和排障。 | +| `im_audit_events` | Admin 消费 `im.audit.event` 后落 HotGo,用于 Kafka 审计事件列表。 | +| Admin 配置表/字典表 | 为 `/internal/im-config/*` 提供数据源。 | + +Admin/HotGo 侧需执行: + +```sql +SOURCE server/storage/data/generate/im_kafka_admin_tables.sql; +SOURCE server/storage/data/generate/im_kafka_event_tables.sql; +SOURCE server/storage/data/generate/im_kafka_command_menu.sql; +SOURCE server/storage/data/generate/im_dead_letter_menu.sql; +SOURCE server/storage/data/generate/im_kafka_audit_menu.sql; +``` + +### 2.4 IM Server 配置 + +`tinode.conf` 或 Docker 环境变量: + +```json +{ + "kafka": { + "enabled": true, + "brokers": ["127.0.0.1:9092"], + "client_id": "im-server", + "consumer_group": "im-server", + "allow_auto_topic_creation": false, + "max_retry_count": 5 + }, + "internal_api": { + "enabled": true, + "time_window_seconds": 300, + "services": { + "hotgo-admin": { + "secret": "same-secret", + "allow": ["admin_query"] + } + } + }, + "admin_internal_api": { + "enabled": true, + "base_url": "http://hotgo-admin:8080", + "service_name": "im-server", + "secret": "same-secret", + "timeout_ms": 3000 + } +} +``` + +### 2.5 Admin/HotGo 配置 + +Admin 需要: + +1. Kafka producer 发送 `im.admin.command`、`im.config.event`、`im.cache.invalidate`、`im.workbench.event`。 +2. Kafka consumer 消费 `im.admin.command.result`、`im.dead.letter`、`im.audit.event`。 +3. 内部接口客户端调用 IM `/internal/admin-query/*`。如果 IM API 前缀为 `/v1`,Admin 配置 `imInternalApi.baseUrl` 应包含 `/v1`,例如 `http://im-server:6060/v1`。 +4. 内部接口服务端提供 IM `/internal/im-config/*`。 +5. 内部接口签名与 IM 一致。 + +HMAC 签名请求头: + +| Header | 说明 | +| --- | --- | +| `X-Service-Name` | 调用方服务名,如 `hotgo-admin` 或 `im-server`。 | +| `X-Timestamp` | Unix 秒级时间戳。 | +| `X-Nonce` | 一次性随机串。 | +| `X-Signature` | HMAC-SHA256 hex 签名。 | + +签名文本: + +```text +METHOD + "\n" + RequestURI + "\n" + BodySHA256Hex + "\n" + Timestamp + "\n" + Nonce +``` + +### 2.6 Admin 与 IM Server 分工 + +| 模块 | Admin/HotGo 负责 | IM Server 负责 | +| --- | --- | --- | +| 库访问边界 | 只访问 `hotgo` 控制库,不直连租户库。 | 只访问租户库,不直连 `hotgo`。 | +| 后台写操作 | 写 Admin 操作单和 Admin outbox,生产 `im.admin.command`。 | 消费命令、执行租户业务、回传 `im.admin.command.result`。 | +| 后台查询 | 通过 IM `/internal/admin-query/*` 查询租户业务数据。 | 提供白名单只读查询接口,统一 HMAC 鉴权。 | +| IM 配置读取 | 提供 `/internal/im-config/*`。 | 通过 Admin 内部配置接口读取租户、注册、第三方配置。 | +| Kafka 死信 | 消费 `im.dead.letter`,展示告警和处理入口。 | 记录死信,提供查询接口,消费处理命令。 | +| 审计 | 消费 `im.audit.event` 并落 HotGo 审计表。 | 关键业务动作生产审计事件。 | + +### 2.7 IM 内部查询接口矩阵 + +Admin 调用以下接口时统一使用 `GET /v1/internal/admin-query/tenants/{tenant_code}/...`,如果部署未配置 `/v1` 前缀,则以实际 IM HTTP 前缀为准。 + +P0 核心页面: + +| 接口 | 用途 | +| --- | --- | +| `/users` | 用户列表。 | +| `/users/{user_id}` | 用户详情。 | +| `/users/{user_id}/devices` | 用户设备列表。 | +| `/groups` | 群列表。 | +| `/groups/{topic}` | 群详情和治理配置。 | +| `/groups/{topic}/members` | 群成员分页列表。 | +| `/groups/{topic}/join-requests` | 入群申请列表。 | +| `/groups/{topic}/invitations` | 群邀请列表。 | +| `/groups/{topic}/announcements` | 群公告列表。 | +| `/groups/{topic}/mutes` | 群禁言列表。 | +| `/groups/{topic}/admin-logs` | 群管理日志。 | +| `/groups/{topic}/files` | 群文件列表。 | +| `/files` | 文件列表。 | +| `/files/{file_id}` | 文件详情。 | +| `/files/{file_id}/links` | 文件引用列表。 | +| `/kafka/dead-letters` | Kafka 死信列表。 | +| `/kafka/dead-letters/{dead_id}` | Kafka 死信详情。 | + +P1/P2 运营和完整隔离页面: + +| 接口 | 用途 | +| --- | --- | +| `/audit-logs`、`/audit-logs/{id}` | 审计日志列表和详情。 | +| `/realname-verifications`、`/realname-verifications/{id}` | 实名审核列表和详情。 | +| `/abuse-reports`、`/abuse-reports/{id}` | 举报工单列表和详情。 | +| `/reports/abuse/{report_id}/target` | 举报目标快照。 | +| `/feedback-submissions`、`/feedback-submissions/{id}` | 意见反馈列表和详情。 | +| `/outbox/business-events`、`/outbox/group-events` | 业务 Outbox 和群 Outbox 列表。 | +| `/outbox/business-events/{id}`、`/outbox/group-events/{id}` | Outbox 详情。 | +| `/org/departments`、`/org/members` | 组织部门和组织成员。 | +| `/workbench/apps` | 工作台应用配置。 | +| `/dashboard/summary` | 租户任务看板摘要。 | +| `/feedback-config`、`/mailbox-config` | 反馈配置、总经理信箱配置。 | +| `/abuse-reasons`、`/app-releases` | 举报原因、App 发布策略。 | + +列表接口统一验收 `items/total/page/per_page`,详情接口统一验收 `code=0,data={...}`。用户 ID、雪花 ID、`seq_id`、`dead_id` 等大整数或长 ID 建议按字符串处理,避免前端丢精度。 + +### 2.8 Admin 内部配置接口矩阵 + +IM Server 调 Admin 时统一使用 HMAC 签名,Admin 需提供: + +| 接口 | 用途 | +| --- | --- | +| `GET /internal/im-config/tenants/by-code/{tenant_code}` | 按租户编码查询租户。 | +| `GET /internal/im-config/tenants/by-id/{tenant_id}` | 按租户 ID 查询租户。 | +| `GET /internal/im-config/tenants?state=active&limit=1000` | 拉取启用租户列表。 | +| `GET /internal/im-config/registration/sms` | 读取注册短信配置。 | +| `GET /internal/im-config/third-party/{group}?keys=a,b` | 读取 OSS、文件安全、声网、极光等第三方配置。 | + +联调时需要特别确认 OSS 和文件安全配置已经从 Admin 字典项返回,IM 不再依赖本地硬编码。 + +### 2.9 Admin Command 事件矩阵 + +| 事件类型 | 业务 | +| --- | --- | +| `group.profile.update` | 后台修改群资料。 | +| `group.governance.update` | 后台修改群治理配置。 | +| `group.admin.set`、`group.admin.remove` | 设置或取消群管理员。 | +| `group.member.mute`、`group.member.unmute` | 成员禁言或解禁。 | +| `group.member.remove` | 移除群成员。 | +| `group.owner.transfer` | 转让群主。 | +| `user.ban`、`user.unban` | 用户封禁和解封。 | +| `user.freeze`、`user.unfreeze` | 用户冻结和恢复。 | +| `user.device.kick` | 踢指定设备。 | +| `user.logout_all` | 全端登出。 | +| `user.account.cancel`、`user.account.delete` | 注销或删除账号。 | +| `kafka.dead_letter.ignore` | 忽略死信。 | +| `kafka.dead_letter.resolve` | 标记死信已解决。 | +| `kafka.dead_letter.replay` | 重放死信。 | + +所有 Admin Command 都必须带 `event_id`、`tenant_code`、`event_type`、`biz_type`、`biz_id`、`payload.command_id`、`payload.actor_user_id`。Admin 不应把 Kafka 投递成功当成业务成功,最终状态以 `im.admin.command.result` 为准。 + +## 3. 通用验收规则 + +每个 Kafka 用例都必须检查: + +1. Kafka 原始事件 `event_id` 全局唯一,同一事件重复投递不会重复产生业务副作用。 +2. `tenant_code` 能正确路由到目标租户库。 +3. 消费成功后 `im_kafka_consume_log.status=succeeded`。 +4. 消费失败后 `retry_count` 增加,并按 `retry_backoff_ms` 重试。 +5. 超过上限后写入 `im_kafka_dead_letters`,并投递 `im.dead.letter`。 +6. Admin Command 成功、失败、死信都必须回传 `im.admin.command.result`。 +7. 业务结果、审计事件、日志里的 `trace_id/event_id/biz_id` 可串联排查。 + +## 4. 联调用例 + +### TC-KAFKA-001 基础启动与 Topic 连通 + +目标:验证 Admin 和 IM 都能连接 Kafka,IM 消费者全部启动。 + +步骤: + +1. 启动 Kafka、Redis、MySQL。 +2. 启动 Admin/HotGo。 +3. 启动 IM Server。 +4. 查看 IM 日志应出现 Kafka enabled,且无 consumer 启动错误。 +5. Admin 在「IM管理 -> Kafka命令」点击「发送缓存失效测试」,或调用 `POST /admin/imKafkaCommand/cacheInvalidate`,发送一条无业务副作用的测试 `im.cache.invalidate` 事件。 + +预期: + +1. IM 成功消费测试事件。 +2. `im_kafka_consume_log` 写入一条 succeeded 记录。 +3. 无死信。 + +### TC-KAFKA-002 内部接口鉴权成功 + +目标:验证 Admin 调 IM 内部查询接口签名通过。 + +请求: + +```http +GET /v1/internal/admin-query/tenants/default/users/usrxxx +X-Service-Name: hotgo-admin +X-Timestamp: +X-Nonce: +X-Signature: +``` + +预期: + +1. HTTP 200。 +2. 响应 `code=0`。 +3. 返回用户基础资料。 + +### TC-KAFKA-003 内部接口鉴权失败 + +目标:验证内部接口拒绝错误签名、过期时间戳、重复 nonce。 + +步骤: + +1. 使用错误 secret 调用 `/internal/admin-query/*`。 +2. 使用超过时间窗的 timestamp 调用。 +3. 使用同一 nonce 重复调用两次。 + +预期: + +1. 错误 secret 返回 401。 +2. 过期 timestamp 返回 401。 +3. 重复 nonce 第二次返回 401。 +4. 业务数据不泄露。 + +### TC-KAFKA-004 IM 调 Admin 配置查询 + +目标:验证 IM 通过 Admin `/internal/im-config/*` 获取 hotgo 配置。 + +步骤: + +1. 开启 IM `admin_internal_api.enabled=true`。 +2. Admin 提供以下接口: + - `GET /internal/im-config/tenants/by-code/default` + - `GET /internal/im-config/tenants/by-id/1` + - `GET /internal/im-config/tenants?state=active&limit=1000` + - `GET /internal/im-config/registration/sms` + - `GET /internal/im-config/third-party/im_third_party?keys=...` +3. 重启 IM。 +4. 执行登录、上传、文件安全配置读取等动作。 + +预期: + +1. IM 优先请求 Admin 内部配置接口。 +2. Admin 验签通过并返回必要字段。 +3. IM 本地功能正常。 +4. Admin 接口异常时,IM 可回落旧 DB 适配器,日志有 warn。 + +说明:Admin `/internal/im-config/*` 是内部服务接口,不带 `/admin` 后台前缀。 + +### TC-KAFKA-005 Admin 群资料修改命令 + +目标:验证后台群资料修改通过 Kafka 到 IM。 + +Admin 发送: + +```json +{ + "event_id": "admin:group:profile:update:001", + "tenant_code": "default", + "source_service": "hotgo-admin", + "target_service": "im-server", + "event_type": "group.profile.update", + "biz_type": "group_admin", + "biz_id": "grpxxx", + "payload": { + "command_id": "cmd-group-profile-001", + "actor_user_id": "usrAdmin", + "topic": "grpxxx", + "version": 1, + "profile": { + "name": "新群名称", + "description": "新群描述", + "avatar_file_id": "file_xxx" + } + } +} +``` + +预期: + +1. IM 更新群业务表。 +2. `im.admin.command.result` 返回 `status=succeeded`。 +3. C 端在线群成员收到业务刷新事件或可重新拉取看到新资料。 +4. 消费日志 succeeded。 +5. 审计事件已投递。 + +### TC-KAFKA-006 Admin 群治理配置 + +目标:验证后台修改入群审批、全员禁言、成员上传、私聊、邀请开关。 + +事件类型:`group.governance.update` + +Payload: + +```json +{ + "command_id": "cmd-group-govern-001", + "actor_user_id": "usrAdmin", + "topic": "grpxxx", + "version": 2, + "governance": { + "join_approval": true, + "mute_all": true, + "member_upload_files": false, + "member_private_chat": false, + "member_invite": false + } +} +``` + +预期: + +1. 群设置版本递增或与业务规则一致。 +2. 新配置对成员发送、上传、邀请等入口生效。 +3. Admin 收到成功结果。 + +### TC-KAFKA-007 Admin 群成员治理 + +目标:验证管理员设置、取消、禁言、解禁、移除成员、转让群主。 + +事件类型: + +| 命令 | 预期 | +| --- | --- | +| `group.admin.set` | 成员角色变为 admin。 | +| `group.admin.remove` | 成员角色恢复 member。 | +| `group.member.mute` | 成员禁言生效。 | +| `group.member.unmute` | 成员禁言解除。 | +| `group.member.remove` | 成员离群,不能继续收发该群消息。 | +| `group.owner.transfer` | 群主变更,旧群主权限调整。 | + +每条命令都检查: + +1. 租户库业务表状态正确。 +2. 在线 C 端收到状态变化。 +3. Admin 操作单从 running 变为 succeeded。 +4. 重复发送同一 `event_id` 不重复执行。 + +### TC-KAFKA-008 用户封禁与解封 + +目标:验证 Admin 可通过 Kafka 封禁/解封用户。 + +封禁事件: + +```json +{ + "event_id": "admin:user:ban:001", + "tenant_code": "default", + "event_type": "user.ban", + "biz_type": "user_governance", + "biz_id": "usrxxx", + "payload": { + "command_id": "cmd-user-ban-001", + "actor_user_id": "usrAdmin", + "user_id": "usrxxx", + "reason": "违规" + } +} +``` + +步骤: + +1. 目标用户保持在线。 +2. Admin 发送 `user.ban`。 +3. 目标用户尝试继续发消息。 +4. 目标用户重新登录。 +5. Admin 发送 `user.unban`。 +6. 目标用户重新登录。 + +预期: + +1. 封禁后用户状态为 `susp`。 +2. 在线会话被踢下线。 +3. 封禁期间登录被拒绝。 +4. 解封后状态为 `ok`,可正常登录。 +5. 两条命令均回传 result。 + +### TC-KAFKA-009 用户冻结与恢复 + +目标:验证冻结/恢复账号命令。 + +事件类型:`user.freeze`、`user.unfreeze` + +预期: + +1. `user.freeze` 设置用户状态 `susp` 并踢下线。 +2. `user.unfreeze` 恢复用户状态 `ok`。 +3. 重复投递同一 `event_id` 不重复产生额外副作用。 + +### TC-KAFKA-010 踢指定设备 + +目标:验证只踢指定设备,不影响同账号其他设备。 + +前置: + +1. 同一用户使用两个设备登录,设备 ID 分别为 `device-a`、`device-b`。 + +事件: + +```json +{ + "event_id": "admin:user:device:kick:001", + "tenant_code": "default", + "event_type": "user.device.kick", + "biz_type": "user_governance", + "biz_id": "usrxxx:device-a", + "payload": { + "command_id": "cmd-device-kick-001", + "actor_user_id": "usrAdmin", + "user_id": "usrxxx", + "device_id": "device-a", + "reason": "后台踢设备" + } +} +``` + +预期: + +1. `device-a` 对应会话被踢。 +2. `device-b` 保持在线。 +3. `device-a` 推送设备记录被删除。 +4. Admin 收到 `evicted_sessions` 数量。 + +### TC-KAFKA-011 全端登出 + +目标:验证后台让用户所有设备退出登录。 + +事件类型:`user.logout_all` + +预期: + +1. 用户所有在线会话被踢。 +2. 用户状态不改变。 +3. 用户可重新登录。 + +### TC-KAFKA-012 注销与删除账号 + +目标:验证 Admin 可注销或删除租户内账号。 + +事件类型: + +| 命令 | 预期 | +| --- | --- | +| `user.account.cancel` | 默认软删除,`hard=true` 时硬删除。 | +| `user.account.delete` | 按硬删除处理。 | + +步骤: + +1. 目标用户在线。 +2. Admin 发送注销命令。 +3. 查询用户状态。 +4. 尝试重新登录。 + +预期: + +1. 用户会话被踢。 +2. 用户相关 Topic 被停止。 +3. 用户被删除或标记 `del`。 +4. 登录失败。 +5. Admin 收到 result。 + +### TC-KAFKA-013 私聊消息异步落库 + +目标:验证 Kafka 开启时私聊消息通过 `im.message.persist` 落库。 + +步骤: + +1. 用户 A 与用户 B 建立私聊。 +2. A 发送带 `x-im-client-mid` 的私聊消息。 +3. 查询 Kafka `im.message.persist`。 +4. 查询租户库 `im_messages`。 +5. A 使用同一 `x-im-client-mid` 重复发送。 + +预期: + +1. A 立即收到服务端 ack 和 `seq_id`。 +2. `seq_id` 为 BIGINT/int64。 +3. 消费成功后 `im_messages` 有一条记录。 +4. 重复发送不重复落库。 +5. 消费日志 succeeded。 + +### TC-KAFKA-014 群聊消息异步落库 + +目标:验证群消息写入 `im_group_messages`,不混入私聊表。 + +步骤: + +1. 创建群并加入至少两个成员。 +2. 成员发送群消息。 +3. 查询 `im_group_messages`。 +4. 查询 `im_messages`。 +5. 重连客户端按 Topic seq 拉取消息。 + +预期: + +1. 群消息只写 `im_group_messages`。 +2. `seq_id` 单调增长,类型为 BIGINT/int64。 +3. 客户端补偿拉取成功。 +4. 24 小时热消息可进入 Redis 热缓存。 + +### TC-KAFKA-015 消息消费失败重试 + +目标:验证 `im.message.persist` 消费失败可重试并最终成功。 + +步骤: + +1. 临时制造租户库写入失败,如锁表或停止库连接。 +2. 发送消息。 +3. 恢复数据库。 +4. 等待重试。 + +预期: + +1. `im_kafka_consume_log.retry_count` 增加。 +2. 未超过重试上限时不进入死信。 +3. 数据库恢复后消息最终落库。 +4. 状态变为 succeeded。 + +### TC-KAFKA-016 文件上传完成事件 + +目标:验证文件上传完成后产生 `im.file.lifecycle` 事实事件。 + +步骤: + +1. 客户端上传文件到 OSS。 +2. 上传完成后查询租户库文件记录。 +3. 检查 Kafka outbox 和 `im.file.lifecycle`。 + +预期: + +1. 文件字节流只进入 OSS。 +2. Kafka 只包含文件元数据,不包含文件内容。 +3. 上传完成事件可被消费并审计。 + +### TC-KAFKA-017 消息附件绑定 + +目标:验证消息落库后附件引用由 `im.file.lifecycle` 绑定。 + +步骤: + +1. 上传图片或文件。 +2. 发送带附件的私聊消息。 +3. 发送带附件的群聊消息。 +4. 查询附件引用表。 + +预期: + +1. 私聊附件写入 `im_filemsglinks`。 +2. 群聊附件写入 `im_group_files`。 +3. 重复消费不重复绑定。 +4. 文件 ACL 与业务引用可追踪。 + +### TC-KAFKA-018 群文件登记与删除 + +目标:验证群文件业务事件接入 `im.file.lifecycle`。 + +步骤: + +1. 群内登记文件。 +2. 查询 `im_group_files`。 +3. 删除群文件。 +4. 检查生命周期事件和审计事件。 + +预期: + +1. 群文件登记成功。 +2. 删除状态正确。 +3. 文件事件可用于后续回收/合规删除。 + +### TC-KAFKA-019 工作台会议事件 + +目标:验证会议创建、更新、取消、提醒、参会响应产生工作台事件。 + +步骤: + +1. 创建会议。 +2. 修改会议。 +3. 发送提醒。 +4. 参会人响应。 +5. 取消会议。 + +预期: + +1. 每个动作产生 `im.workbench.event`。 +2. 消费后写 `im_system_notifications`。 +3. 写 `im_business_events_outbox`。 +4. 在线 C 端收到系统通知或红点刷新。 +5. 审计事件已投递。 + +Admin 连通性补充:可在「IM管理 -> Kafka命令」点击「发送工作台测试事件」,或调用 `POST /admin/imKafkaCommand/workbenchEvent`,验证 Admin 具备生产 `im.workbench.event` 的能力。真实会议、日报/月报、信箱业务结果仍以 IM Server 对应业务用例为准。 + +### TC-KAFKA-020 日报/月报事件 + +目标:验证日报、月报提交和催办事件。 + +步骤: + +1. 创建日报草稿。 +2. 保存并提交日报。 +3. 管理员催办日报。 +4. 创建、提交、重开月报。 + +预期: + +1. 对应事件进入 `im.workbench.event`。 +2. 通知目标用户正确。 +3. 工作台红点或系统通知可被客户端看到。 + +### TC-KAFKA-021 总经理信箱事件 + +目标:验证总经理信箱提交事件和审计。 + +步骤: + +1. 用户提交信箱内容。 +2. 检查 `im.workbench.event`。 +3. 检查 `im.audit.event`。 +4. Admin 后台消费后生成处理单。 + +预期: + +1. 提交成功后有工作台事件。 +2. 审计事件包含用户、租户、业务 ID。 +3. Admin 可基于事件处理。 + +### TC-KAFKA-022 配置事件失效 + +目标:验证 Admin 配置变更后 IM 清理进程内缓存。 + +事件类型: + +| 事件 | 预期 | +| --- | --- | +| `tenant.updated` | 清租户缓存。 | +| `tenant.status.changed` | 清租户缓存。 | +| `tenant.limits.updated` | 清租户配置缓存。 | +| `tenant.features.updated` | 清租户功能缓存。 | +| `dictionary.updated` | 清字典/第三方配置缓存。 | +| `third_party.updated` | 清第三方配置缓存。 | +| `registration.config.updated` | 清注册短信配置缓存。 | + +步骤: + +1. Admin 修改配置。 +2. 发送 `im.config.event`。 +3. IM 再次读取对应配置。 + +预期: + +1. IM 消费成功。 +2. 下次读取不使用旧缓存。 +3. 可从 Admin 内部配置接口重新获取新值。 + +### TC-KAFKA-023 通用缓存失效 + +目标:验证 `im.cache.invalidate` 可按 namespace 删除缓存。 + +示例事件: + +```json +{ + "event_id": "cache:user:001", + "tenant_code": "default", + "event_type": "cache.redis_object.invalidate", + "biz_type": "cache", + "biz_id": "user:usrxxx", + "payload": { + "tenant_code": "default", + "tenant_id": 1, + "namespace": "user", + "keys": ["usrxxx"] + } +} +``` + +预期: + +1. 用户 Redis 对象缓存被删除。 +2. 后续查询重新读 MySQL 或 Admin 内部配置接口。 +3. 消费幂等。 + +### TC-KAFKA-024 群热消息缓存失效 + +目标:验证群热消息可按 Topic 或 seq range 失效。 + +事件: + +```json +{ + "event_id": "cache:group-hot:001", + "tenant_code": "default", + "event_type": "cache.group_hot_messages.invalidate", + "biz_type": "cache", + "biz_id": "grpxxx", + "payload": { + "tenant_id": 1, + "namespace": "group_hot_messages", + "topic": "grpxxx", + "ranges": [{"low": 100, "hi": 120}] + } +} +``` + +预期: + +1. 指定群热消息范围失效。 +2. 客户端再次拉取时能从最终存储补偿。 + +### TC-KAFKA-025 死信生成 + +目标:验证不可恢复错误进入死信。 + +步骤: + +1. Admin 发送不存在用户或非法 payload 的命令。 +2. 或临时制造持续失败直到超过最大重试。 +3. 查询 `im_kafka_dead_letters`。 +4. 消费 `im.dead.letter`。 + +预期: + +1. 死信表出现记录。 +2. 死信 Topic 有 `kafka.dead_letter.created`。 +3. Admin 操作单收到 `status=dead` 的 result。 +4. 死信记录包含原始 envelope、reason、error、retry_count。 + +### TC-KAFKA-026 死信查询 + +目标:验证 Admin 可通过 IM 内部 API 查询死信。 + +请求: + +```http +GET /v1/internal/admin-query/tenants/default/kafka/dead-letters?status=dead&limit=50 +GET /v1/internal/admin-query/tenants/default/kafka/dead-letters/{dead_id} +``` + +预期: + +1. 列表返回摘要,不返回超大 envelope。 +2. 详情返回原始 envelope。 +3. 支持 `status/biz_type/biz_id/event_type/cursor/limit` 过滤。 + +### TC-KAFKA-027 死信忽略与解决 + +目标:验证 Admin 可通过 Kafka 命令处理死信状态。 + +事件类型: + +```json +{ + "event_type": "kafka.dead_letter.ignore", + "payload": { + "command_id": "cmd-dead-ignore-001", + "actor_user_id": "usrAdmin", + "dead_id": "evt_dead_xxx", + "note": "测试死信忽略" + } +} +``` + +预期: + +1. `ignore` 后死信状态为 `ignored`。 +2. `resolve` 后死信状态为 `resolved`。 +3. Admin 收到 result。 +4. 审计事件已投递。 + +### TC-KAFKA-028 死信重放 + +目标:验证死信可人工重放。 + +步骤: + +1. 修复导致死信的业务数据或配置。 +2. Admin 发送 `kafka.dead_letter.replay`。 +3. 观察原 Topic 是否重新收到 envelope。 +4. 查询死信记录和消费日志。 + +预期: + +1. IM 将死信改为 `pending_replay`。 +2. 删除原 `im_kafka_consume_log`。 +3. 按原 `source_topic` 重投 envelope。 +4. 重投成功后死信状态为 `replayed`,`replay_count` 增加。 +5. 原业务消费者重新处理并成功。 + +### TC-KAFKA-029 审计事件 + +目标:验证关键后台和敏感操作都有审计事件。 + +覆盖: + +1. 群治理命令。 +2. 用户治理命令。 +3. 文件生命周期。 +4. 工作台状态变化。 +5. 死信生成和处理。 + +预期: + +1. `im.audit.event` 收到事件。 +2. 事件包含 `tenant_code`、`actor`、`biz_type`、`biz_id`、`event_type`、`occurred_at`。 +3. Admin 审计消费者可落 HotGo `im_audit_events`。 +4. Admin 可在「IM管理 -> Kafka审计事件」查看审计事件列表和详情。 + +### TC-KAFKA-030 Outbox 可靠投递 + +目标:验证 IM 侧 Kafka Outbox 防止业务成功但 Kafka 丢事件。 + +步骤: + +1. 临时停止 Kafka。 +2. 执行会产生 outbox 的操作,如发送消息、提交工作台事件。 +3. 查询 `im_kafka_outbox`。 +4. 恢复 Kafka。 +5. 等待 outbox worker 投递。 + +预期: + +1. Kafka 停止时业务 outbox 记录为 `pending/failed`。 +2. 恢复后记录变为 `sent`。 +3. 下游消费者最终收到事件。 +4. 不重复产生业务副作用。 + +## 5. Admin 侧联调检查清单 + +| 检查项 | 验收标准 | +| --- | --- | +| 操作单 | 每个后台写操作先写 hotgo 操作单。 | +| Admin outbox | 操作单与 outbox 同事务写入。 | +| Command 生产 | `event_id/idempotency_key/tenant_code/biz_id` 稳定可追踪。 | +| Result 消费 | `im.admin.command.result` 能更新操作单状态。 | +| 死信消费 | `im.dead.letter` 能生成告警或待处理记录。 | +| 审计消费 | `im.audit.event` 能落 HotGo `im_audit_events`,并可在 Kafka审计事件页面查看。 | +| 配置接口 | `/internal/im-config/*` 支持 IM HMAC 调用。 | +| 查询客户端 | 调 IM `/internal/admin-query/*` 带签名并处理 401/403/404/5xx。 | +| 页面状态 | 后台页面显示 pending/running/succeeded/failed/dead,不把 Kafka 发送成功当业务成功。 | + +## 6. IM Server 侧联调检查清单 + +| 检查项 | 验收标准 | +| --- | --- | +| Kafka 启动 | producer、consumer、outbox worker 正常启动。 | +| 租户路由 | `tenant_code` 能路由到正确租户库。 | +| 消费幂等 | 重复 `event_id` 不重复执行业务。 | +| 重试 | 可重试错误按退避时间重试。 | +| 死信 | 超过上限或不可重试错误进入死信并回 result。 | +| Admin Query API | 内部接口验签、鉴权、nonce 防重放生效。 | +| Admin Config Client | 可从 Admin 拉租户、注册、第三方配置。 | +| 用户治理 | 封禁/冻结踢下线,解封/恢复可登录,设备踢出只影响指定设备。 | +| 群治理 | 群资料、角色、禁言、移除、转让群主全部生效。 | +| 消息落库 | 私聊写 `im_messages`,群聊写 `im_group_messages`。 | +| 文件生命周期 | 附件引用绑定到正确业务表。 | +| 工作台事件 | 生成系统通知和 WS 业务事件。 | +| 审计 | 关键动作进入 `im.audit.event`。 | + +## 7. 回归用例 + +1. Kafka 关闭时,IM 基础登录、私聊、群聊、文件上传下载仍可用。 +2. Redis 异常时,Kafka 消费不应 panic;允许热缓存和 nonce 校验降级按配置表现。 +3. Admin 内部配置接口不可用时,IM 本地旧 DB 读取兜底可用。 +4. IM 内部查询接口关闭时,Admin 查询应收到 404,不泄露数据。 +5. 同一用户在多端登录,`user.device.kick` 只踢指定设备,`user.logout_all` 踢全部设备。 +6. 同一 Admin Command 重复发送 3 次,业务只成功一次,结果可重复查询。 +7. 群消息高并发发送时 `seq_id` 单调递增且无重复。 +8. 文件上传后未发送消息,不应产生错误附件引用。 +9. 工作台事件目标用户为空时,不应产生错误通知。 +10. 死信重放失败后应再次进入死信,不覆盖原处理痕迹。 + +## 8. 联调问题定位 + +| 现象 | 优先排查 | +| --- | --- | +| Admin 操作一直 pending | Admin outbox 是否成功投递;`queue.driver` 是否为 `kafka`;Kafka broker 是否可用。 | +| Admin 操作一直 running | Admin 是否消费 `im.admin.command.result`;IM 是否收到 command;`event_id` 是否重复卡在 processing。 | +| IM 消费失败 | 查 `im_kafka_consume_log.last_reason/last_error` 和 IM 日志 `event_id`。 | +| 进入死信 | 查 `im_kafka_dead_letters.envelope/last_error`,修复后用重放命令。 | +| 用户没有被踢下线 | 查目标 `user_id` 是否是 IM 用户 ID;是否连接在当前 IM 节点;跨节点踢人是否需要后续 Redis fanout 扩展。 | +| 群治理未生效 | 查 `topic/version/actor_user_id` 是否正确,目标用户是否为群成员。 | +| 消息未落库 | 查 `im_kafka_outbox` 是否 sent,`im.message.persist` 是否消费成功。 | +| 附件未绑定 | 查消息是否带附件、`im.file.lifecycle` 是否消费成功。 | +| 内部 API 401 | 查 secret、签名文本、RequestURI、timestamp、nonce 是否一致。 | +| 配置没有刷新 | 查是否发送 `im.config.event` 或 `im.cache.invalidate`,IM 是否清理了对应 namespace。 | + +## 9. 联调完成标准 + +1. 本文所有 P0/P1 用例通过:Kafka 基础、Admin Command、用户治理、群治理、消息异步落库、文件生命周期、工作台、缓存失效、死信。 +2. Admin 操作单状态闭环可视化:pending -> running -> succeeded/failed/dead。 +3. IM 租户库无重复业务记录,Kafka 消费日志无异常堆积。 +4. 死信可查询、可忽略、可解决、可重放。 +5. Admin 与 IM 均不跨库直连,内部查询接口鉴权通过。 +6. 离线 Push Kafka 化未纳入本次验收,继续按增强项跟进。 + +## 10. 本地 Docker 联调记录 + +### 10.1 2026-08-27 冒烟联调 + +本轮不是全量联调,只完成本地 Docker 环境启动和 Kafka/内部接口主链路冒烟。后续仍需按第 4 章逐条补齐。 + +联调环境: + +| 组件 | 地址/容器 | 结果 | +| --- | --- | --- | +| MySQL | `relaxed_cori:3306`,宿主机 `127.0.0.1:3306` | 已连接,本地库包含 `hotgo`、`tinode`。 | +| Redis | `brave_blackburn:6379`,宿主机 `127.0.0.1:6379` | 已连接。 | +| Kafka | `im-local-kafka:9092`,宿主机 `127.0.0.1:19092` | 已创建联调 Topic。 | +| IM Server | `im-local-im:6060`,宿主机 `127.0.0.1:6060` | healthy,Kafka consumer 已启动。 | +| Admin/HotGo | `im-local-admin:8000`,宿主机 `127.0.0.1:8000` | healthy,Kafka consumer 已启动。 | + +说明:本地已有 Kafka 容器 `kafka:9092` 的 `advertised.listeners=localhost:9092`,Docker 内服务会回连自身 localhost,故本轮单独启动 `im-local-kafka` 用于容器联调。 + +本轮已测: + +| 用例/链路 | 测试内容 | 结果 | 证据 | +| --- | --- | --- | --- | +| TC-KAFKA-001 | Kafka Topic 创建,IM consumer 启动。 | 通过 | IM 日志显示 `im.admin.command`、`im.config.event`、`im.cache.invalidate`、`im.message.persist`、`im.file.lifecycle`、`im.workbench.event` consumer started。 | +| TC-KAFKA-002 | Admin -> IM 内部查询 HMAC。 | 通过 | `GET /internal/admin-query/tenants/default/users?page=1&per_page=1` 返回 `code=0`,`total=29`。 | +| TC-KAFKA-004 | IM -> Admin 内部配置 HMAC。 | 通过 | `GET /internal/im-config/tenants?limit=1000&state=active` 返回 `code=0` 和 default 租户。 | +| TC-KAFKA-008/011 | `user.logout_all` 后台用户治理命令。 | 通过 | `tinode.im_kafka_consume_log` 中 `local-logout-fixed-*` 状态为 `succeeded`。 | +| TC-KAFKA-023 | `im.cache.invalidate` 通用缓存失效。 | 通过 | `local-cache-*` 写入 `im_kafka_consume_log`,状态 `succeeded`,IM 日志显示 `kafka cache invalidate applied`。 | +| TC-KAFKA-025 | 非法 `user.logout_all` 进入死信。 | 通过 | `local-logout-*` 状态为 `dead`,`im_kafka_dead_letters` 写入 `MALFORMED_COMMAND`。 | +| TC-KAFKA-029 | 用户治理审计事件。 | 通过 | Admin 消费 `im.audit.event` 链路已验证;正式落表应为 `hotgo.im_audit_events`。 | + +本轮发现并处理: + +| 问题 | 现象 | 处理 | +| --- | --- | --- | +| Admin Docker 前端构建失败 | `pnpm run build` 非 TTY 环境报 `ERR_PNPM_ABORTED_REMOVE_MODULES_DIR_NO_TTY`。 | Admin `Dockerfile` 增加 `ENV CI=true`。 | +| Admin Kafka 版本配置不兼容 | Admin 启动时报 `invalid version 2.0.0.0`。 | 本地联调配置临时改为 `version: "2.0.0"`。 | +| IM 启动缺少 UID key | IM 报 `crypto/xtea: invalid key size 0`。 | Docker 启动显式传入 `UID_ENCRYPTION_KEY`、`API_KEY_SALT`、`AUTH_TOKEN_KEY`。 | +| HotGo 表前缀不一致 | Admin 内部配置接口错误查询 `hg_im_tenant`,但 IM 业务/配置表正式表名应保持 `im_tenant`。 | 联调时暴露的是 Admin 查询/ORM 表名前缀问题;正式方案必须修 Admin 侧表名映射或 DAO 前缀逻辑,不能新增 `hg_im_*` 兼容表。 | +| Admin result 消费格式不兼容 | Admin 消费 `im.admin.command.result` 报 `IM命令结果缺少command_id`。 | Admin consumer 已兼容 Kafka Envelope + payload 格式。 | +| Admin 审计表前缀不一致 | Admin 消费 `im.audit.event` 错误查询 `hg_im_audit_events`,正式表名应保持 `im_audit_events`。 | 正式方案必须修 Admin 侧审计模型/DAO 表名映射,所有 `im_*` 业务表不加 `hg_` 前缀。 | + +本轮未覆盖,后续继续: + +| 范围 | 待测内容 | +| --- | --- | +| 群治理 | 群资料、治理配置、管理员设置/取消、成员禁言/解禁、移除成员、转让群主。 | +| 用户治理完整集 | `user.ban`、`user.unban`、`user.freeze`、`user.unfreeze`、`user.device.kick`、注销/删除账号。 | +| 消息异步落库 | 私聊 `im_messages`、群聊 `im_group_messages`、`seq_id` BIGINT、重复 `client_mid` 幂等。 | +| 文件生命周期 | 上传完成、附件绑定、群文件登记/删除、OSS 元数据和引用表。 | +| 工作台事件 | 会议、日报/月报、总经理信箱、红点/系统通知投递。 | +| 死信闭环 | 死信查询、忽略、解决、重放,以及重放后二次成功/失败。 | +| Outbox | Kafka 停止/恢复时 `im_kafka_outbox`、Admin outbox 的可靠投递。 | +| Admin 页面 | 操作单状态从 pending/running 到 succeeded/failed/dead 的页面联调。 | + +### 10.2 2026-08-27 完整后端链路联调 + +本轮在本地 Docker 环境继续补齐 Kafka 后端链路联调。覆盖方式以 Kafka 直接投递、内部 HTTP HMAC 调用、MySQL 业务表/消费日志核验为主;Admin 前端页面点击未做浏览器人工验收,页面层需要后续按菜单再走一遍。 + +联调环境延续 10.1: + +| 组件 | 地址/容器 | 结果 | +| --- | --- | --- | +| MySQL | `relaxed_cori:3306` | 正常。 | +| Redis | `brave_blackburn:6379` | 正常。 | +| Kafka | `im-local-kafka:9092`,宿主机 `127.0.0.1:19092` | 正常;Outbox 用例中曾停止并恢复。 | +| IM Server | `im-local-im:6060` | 已重建为包含本轮修复的 `im-local-server:dev`,当前 healthy。 | +| Admin/HotGo | `im-local-admin:8000` | 当前 healthy;Kafka 断开时曾 fatal 退出,见问题记录。 | + +本轮通过项: + +| 用例/范围 | 覆盖内容 | 结果 | 证据 | +| --- | --- | --- | --- | +| TC-KAFKA-003 | IM 内部查询接口未签名访问。 | 通过 | `GET /internal/admin-query/...` 未带签名返回 401 `AUTH_REQUIRED`。 | +| TC-KAFKA-004 | Admin 内部配置接口签名成功/失败。 | 通过 | 未签名返回 401;签名访问 `/internal/im-config/tenants?limit=1&state=active` 返回 default 租户。 | +| TC-KAFKA-005 | 后台群资料修改。 | 通过 | `group.profile.update` 消费 `succeeded`,`im_group_settings.name/description/version` 更新。 | +| TC-KAFKA-006 | 后台群治理配置。 | 通过 | `group.governance.update` 消费 `succeeded`,`join_approval/mute_all/member_*` 更新。 | +| TC-KAFKA-007 | 群管理员、禁言、移除、转让群主。 | 通过 | `group.admin.set/remove`、`group.member.mute/unmute/remove`、`group.owner.transfer` 均有成功消费记录;`im_group_members/im_group_mutes` 状态变更正确。 | +| TC-KAFKA-008 | 用户封禁与解封。 | 通过 | `user.ban/user.unban` 消费 `succeeded`,用户最终恢复 `state=0`。 | +| TC-KAFKA-009 | 用户冻结与恢复。 | 通过 | `user.freeze/user.unfreeze` 消费 `succeeded`,用户最终恢复 `state=0`。 | +| TC-KAFKA-010 | 踢指定设备。 | 通过 | `user.device.kick` 消费 `succeeded`,不存在设备按幂等成功处理。 | +| TC-KAFKA-011 | 全端登出。 | 通过 | `user.logout_all` 消费 `succeeded`。 | +| TC-KAFKA-012 | 注销与删除账号。 | 通过 | `user.account.cancel/user.account.delete` 使用本地测试账号验证,消费 `succeeded`。 | +| TC-KAFKA-013 | 私聊消息异步落库。 | 通过 | `message.persist.p2p` 消费 `succeeded`,`im_messages` 写入测试消息。 | +| TC-KAFKA-014 | 群聊消息异步落库。 | 通过 | `message.persist.group` 消费 `succeeded`,`im_group_messages` 写入测试群消息。 | +| TC-KAFKA-016 | 文件上传完成事件。 | 通过 | `file.upload.completed` 消费 `succeeded`。 | +| TC-KAFKA-017 | 消息附件绑定。 | 通过 | 群聊附件绑定写入 `im_group_files`;私聊附件绑定修复后写入 `im_filemsglinks`。 | +| TC-KAFKA-018 | 群文件登记与删除生命周期。 | 通过 | `group.file.created/group.file.deleted` 消费 `succeeded`。 | +| TC-KAFKA-019 | 工作台会议事件。 | 通过 | `workbench.meeting.created` 消费 `succeeded`,写入 `im_system_notifications` 和 `im_business_events_outbox`。 | +| TC-KAFKA-022 | 配置事件失效。 | 通过 | `dictionary.updated` 消费 `succeeded`。 | +| TC-KAFKA-023 | 通用缓存失效。 | 通过 | `cache.redis_object.invalidate` 消费 `succeeded`。 | +| TC-KAFKA-024 | 群热消息缓存失效。 | 通过 | `cache.group_hot_messages.invalidate` 消费 `succeeded`。 | +| TC-KAFKA-025 | 死信生成。 | 通过 | 故意发送非法 `user.logout_all`,消费状态 `dead`,写入 `im_kafka_dead_letters`。 | +| TC-KAFKA-026 | 死信查询。 | 通过 | `/internal/admin-query/tenants/default/kafka/dead-letters` 和详情接口返回 200。 | +| TC-KAFKA-027 | 死信忽略与解决。 | 通过 | `kafka.dead_letter.ignore/resolve` 消费 `succeeded`,死信状态变为 `ignored/resolved`。 | +| TC-KAFKA-028 | 死信重放。 | 通过 | `kafka.dead_letter.replay` 消费 `succeeded`,死信状态变为 `replayed`,`replay_count=1`。 | +| TC-KAFKA-029 | 审计事件。 | 通过 | 用户治理、群治理、死信、文件等事件投递 `im.audit.event`;正式落表应为 `hotgo.im_audit_events`。 | +| TC-KAFKA-030 | IM Kafka Outbox 可靠投递。 | 通过 | Kafka 停止时 outbox `pending -> failed`;Kafka 恢复并重启 IM 后 `failed -> sent`,下游消费成功。 | + +本轮消费统计: + +| 口径 | 数量 | +| --- | ---: | +| `codex_it_*` 和本轮修复验证事件总数 | 38 | +| 成功消费 | 35 | +| 故意制造死信 | 3 | +| 修复后仍卡 processing | 0 | + +代表性事件: + +| 类型 | Event ID | +| --- | --- | +| 用户治理批次 | `codex_it_110224_*` | +| 群成员移除复测 | `codex_it_remove_110302_271b2ff6` | +| 私聊/群聊消息落库 | `codex_it_msg_110432_*`、`codex_it_msg_110434_*` | +| 私聊附件修复验证 | `codex_it_fix_p2p_attach_b93a9a4b` | +| 工作台事件 | `codex_it_evt_110730_workbench_meeting_created_b764faa3` | +| Outbox 断 Kafka 恢复 | `codex_it_outbox_7f52e221` | +| 死信处理 | `codex_it_dead_111524_*`、`codex_it_dead_111528_*`、`codex_it_dead_111531_*` | + +本轮发现并处理: + +| 问题 | 影响 | 处理结果 | +| --- | --- | --- | +| 私聊附件绑定触发 IM panic | `file.attachments.bind` 对私聊消息调用 `store.Files.LinkAttachments(tenantID, "", msgId, attachments)` 时,空 topic 进入 `types.GetTopicCat(topic)` 导致 `slice bounds out of range`,IM 退出。 | 已修复 `server/store/store.go`:仅当 `topic != ""` 时判断 `TopicCatMe`;重建 IM 后复测私聊附件绑定成功,`im_filemsglinks` 写入。 | +| Kafka 停止时 Admin 进程 fatal 退出 | Outbox 断 Kafka 用例中停止 `im-local-kafka` 后,Admin consumer 报 `client has run out of available brokers` 并 `FATA` 退出。 | 已记录为 Admin 侧待修复问题:Kafka consumer 断 broker 不应 fatal,应退避重连。 | +| Kafka 停止/恢复后 IM consumer 自动恢复不稳定 | Kafka 恢复后,IM 仍 healthy,但消费端未立即消费新探针;重启 IM 后积压事件被补偿消费。 | 已记录为 IM 侧增强/修复项:Kafka reader 断 broker 后需要自动重建 reader 并继续消费。 | +| 备用群业务快照不一致 | 对 `grpEC7ddYMoI5s` 执行 `group.member.remove` 返回 `RESOURCE_NOT_FOUND` 并进入死信。 | 换用业务表已确认成员的 `grpgvTd1mFvM2M` 后移除成功;前一次作为死信生成用例保留。 | + +未做或需要人工补充: + +| 范围 | 说明 | +| --- | --- | +| Admin 前端页面点击 | 本轮验证了后端接口、Kafka 生产消费和数据库状态;未使用浏览器逐个菜单点选操作单页面、死信页面、审计页面。 | +| 真实客户端 WS 在线通知 | 后台命令会生成业务事件/系统通知;本轮未启动真实 C 端 WebSocket 客户端观察 UI 实时刷新。 | +| Admin 查询迁移状态 | 本轮验证 IM Server 内部查询接口可用,但 Admin 查询侧尚未全部切换到内部查询 API,页面查询隔离需要后续迁移。 | +| 离线 Push | 按既定范围仍放在未实现增强项,本轮不验收。 | + +### 10.3 2026-08-27 内部查询 API 双向联调 + +本轮在本地 Docker 环境重建 `im-local-server:dev` 和 `im-local-admin:dev` 后,对 Admin 查询 IM Server、IM Server 查询 Admin 两个方向做 HMAC 内部接口联调。IM Server 使用本地 MySQL `tinode`,Admin 使用本地 MySQL `hotgo`;Admin 本地联调容器为了避开队列消费者 Kafka 版本解析退出问题,临时使用 `disk` 队列驱动,不影响本节 HTTP 内部接口结论。 + +双向接口结果: + +| 方向 | 接口/范围 | 结果 | 证据 | +| --- | --- | --- | --- | +| Admin -> IM Server | `/internal/admin-query/tenants/default/users?page=1&per_page=3` | 通过 | 返回 200,首条 `user_id=1565659909440147456`,JSON 类型为 number。 | +| Admin -> IM Server | `/internal/admin-query/tenants/default/users/{user_id}` | 通过 | 使用数据库 BIGINT 用户 ID 查询详情返回 200。 | +| Admin -> IM Server | `/internal/admin-query/tenants/default/users?user_id={user_id}` | 通过 | 使用数据库 BIGINT 用户 ID 过滤列表返回 200。 | +| Admin -> IM Server | `/internal/admin-query/tenants/default/groups/grpgvTd1mFvM2M` | 通过 | 返回 200,`owner_id=1565659909045882880`,JSON 类型为 number。 | +| Admin -> IM Server | `/internal/admin-query/tenants/default/groups/grpgvTd1mFvM2M/members` | 通过 | 返回 200,成员 `user_id/joined_by` 均为数据库 BIGINT number。 | +| Admin -> IM Server | `/internal/admin-query/tenants/default/kafka/dead-letters?limit=1` | 通过 | 返回 200。 | +| Admin -> IM Server | 未签名请求 | 通过 | 返回 401,提示 `internal signature required`。 | +| IM Server -> Admin | `/internal/im-config/tenants?limit=1&state=active` | 通过 | 返回 200,租户 `default`,业务库 `tinode`。 | +| IM Server -> Admin | `/internal/im-config/tenants/by-code/default` | 通过 | 返回 200,租户 `default`,业务库 `tinode`。 | +| IM Server -> Admin | `/internal/im-config/registration/sms` | 通过 | 返回 200,`enabled=true`。 | +| IM Server -> Admin | `/internal/im-config/third-party/oss?keys=endpoint,bucket,access_key_id` | 通过 | 返回 200,配置 map 正常返回。 | +| IM Server -> Admin | 未签名请求 | 通过 | 返回 401。 | + +运行时链路也已确认:`im-local-im` 启动后持续通过 Docker 网络访问 `im-local-admin:8000/internal/im-config/tenants?limit=1000&state=active`,Admin 日志显示每 2 秒一次返回 200,说明不是只通过脚本单独打通。 + +本轮发现并处理: + +| 问题 | 影响 | 处理结果 | +| --- | --- | --- | +| Admin `/internal/im-config/registration/sms` HTTP 200 但业务 `code=53` | IM Server 查询注册短信开关时会拿到业务失败响应。 | 已修复 Admin 控制器扫描目标,将 `g.Map` 改为 `*entity.SysConfig`,重建 Admin 后复测返回 `code=0`。 | +| Admin 本地容器入口模板缺少新增内部接口配置 | 使用默认入口脚本会覆盖完整 `config.yaml`,导致内部接口密钥配置丢失。 | 本地联调改为挂载完整临时配置并直接启动 `./hotgo`;正式镜像建议同步补齐 `config.template`。 | +| Admin 本地 Kafka 队列版本解析退出 | Admin 启动队列消费者时 `invalid version 2.0.0.0`,进程 fatal。 | 本地 HTTP 联调临时切到 `disk` 队列驱动;Kafka consumer 退避重连和版本配置仍按增强项跟进。 | +| Admin 租户表查询仍出现 `hg_im_tenant` | 默认数据库前缀 `hg_` 会污染 `im_tenant/im_tenant_config` 这类 IM 公共表查询。 | 已修复 Admin `imtenant.RegistryDB()`:自动建立/使用 `Prefix` 为空的 `imRegistry` 连接;重建 Admin 后日志确认查询 `hotgo.im_tenant`,不再访问 `hg_im_tenant`。 | + +ID 口径结论:IM Server 外部聊天协议仍使用 `usr...`;Admin 内部管理/查询接口统一使用数据库 BIGINT/int64,查询返回和写命令入参不再混用 Tinode `usr...` 格式。 diff --git a/docs/backstage/IM-Kafka接入范围识别.md b/docs/backstage/IM-Kafka接入范围识别.md new file mode 100644 index 0000000..9316573 --- /dev/null +++ b/docs/backstage/IM-Kafka接入范围识别.md @@ -0,0 +1,82 @@ +# 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. 首批建议实现 + +1. 先实现 Kafka 基础生产者、消费者、配置、健康检查和消费幂等表。 +2. 接入 `im.group.admin.event`,解决 Admin 后台群操作通过 Kafka 投递到 C 端。 +3. 接入 `im.message.persist`,将消息异步落库和幂等重试闭环。 +4. 接入 `im.file.security`,把 OSS 上传完成后的文件安全检测异步化。 +5. 接入 `im.workbench.event`,先覆盖会议提醒、日报/月报催办、总经理信箱提交通知。 diff --git a/docs/im/IM-前后端对接文档.md b/docs/im/IM-前后端对接文档.md index b416f68..ba6ed67 100644 --- a/docs/im/IM-前后端对接文档.md +++ b/docs/im/IM-前后端对接文档.md @@ -2,8 +2,8 @@ > 版本:v2.0 > 日期:2026-08-11 -> 需求依据:[IM 产品原型需求文档](IM-产品原型需求文档.md) -> 设计依据:[IM 系统设计文档](IM-系统设计文档.md) +> 需求依据:[IM 产品原型需求文档](./IM-产品原型需求文档.md) +> 设计依据:[IM 系统设计文档](./IM-系统设计文档.md) > 说明:第 1 至第 11 章的章节编号、图片标题和顺序与产品原型完全一致。每张图均给出可直接开发、联调和验收的协议。 ## 0. 公共对接约定 @@ -58,7 +58,7 @@ API Key 可以被客户端应用提取,因此不能把它当成用户密码或 | --- | --- | --- | --- | | WebSocket(浏览器/通用方式) | `wss://{host}/{api_path}v0/channels?apikey={api_key}` | Upgrade 校验 API Key,`hi` 绑定租户,`login` 登录 | Topic、消息、回执、在线状态、通话信令 | | WebSocket(支持自定义 Header 的原生客户端) | `wss://{host}/{api_path}v0/channels` | Upgrade Header `X-Tinode-APIKey` | 同上 | -| 产品 HTTP | `https://{host}/{api_path}v1/im/*` | `Authorization: Bearer ` | 好友、群、收藏、搜索、工作台和设置 | +| 产品 HTTP | `https://{host}/{api_path}v1/im/*` | `Authorization: Bearer ` | 好友、群、收藏、搜索、工作台和设置 | | 文件 HTTP | `POST /v0/file/u/`、`GET /v0/file/s/{file_id}` | 当前用 `X-Tinode-Auth: token ` | 文件上传下载;禁止 Query 传 Token | HTTP 请求统一通过 `X-Tinode-APIKey` 携带。浏览器 WebSocket 无法设置自定义 Upgrade Header 时才使用 `apikey` Query;日志和监控必须对该参数脱敏。产品 HTTP 还必须带 `X-IM-Schema: 1` 和 `X-Request-ID`;写操作带 `Idempotency-Key`。生产环境只允许 HTTPS/WSS。Token、密码、验证码、证件号不得写入 URL、普通日志、埋点或崩溃报告。 @@ -135,6 +135,7 @@ WebSocket Upgrade 阶段由 `server/hdl_websock.go` 先调用 `checkAPIKey`。 ```text 读取客户端预置的 host 和普通 API Key -> 用户输入/客户端读取 tenant_code +-> 可选:POST /v1/im/tenants/resolve 校验企业码并取得公开配置 -> 带 API Key 完成 WebSocket Upgrade -> hi(tenant_code) -> 收到 hi 的 201 ctrl,确认 tenant 与服务端限制 @@ -146,7 +147,7 @@ WebSocket Upgrade 阶段由 `server/hdl_websock.go` 先调用 `checkAPIKey`。 -> 进入可发送状态 ``` -当前服务端没有 `/v1/im/tenants/resolve`,因此现阶段企业码是否有效是在第一帧 `{hi}` 中校验的。只有 WebSocket Upgrade 返回 `101 Switching Protocols` 后才能发送下面的 JSON 文本帧。 +当前服务端已提供 `/v1/im/tenants/resolve`,用于进入企业前校验企业码并返回公开配置;但它不能代替 WebSocket 第一帧 `{hi}`。即使 HTTP 解析成功,WebSocket Upgrade 返回 `101 Switching Protocols` 后仍必须先发送下面的 `{hi}` JSON 文本帧,并使用同一个 `tenant_code` 绑定当前 Session。 `hi` 请求: @@ -164,7 +165,9 @@ WebSocket Upgrade 阶段由 `server/hdl_websock.go` 先调用 `checkAPIKey`。 {"login":{"id":"login-2","scheme":"token","secret":"<上次登录响应原样返回的 token 字符串>"}} ``` -`basic.secret` 是 `base64(username + ":" + password)`。Token 登录必须把上次 `params.token` 的 JSON 字符串原样填入 `secret`,不能再次 Base64 编码。登录成功 `{ctrl}` 的 `params` 至少包含 `user`、`authlvl`、`token`、`expires`。Token 对客户端是不可解析的 opaque 字符串,只能存入系统安全存储。`id` 只用于匹配本次 Session 的命令响应,不是业务幂等键。 +`basic.secret` 是 `base64(username + ":" + password)`。Base64 只是协议编码,不是加密,Basic 注册和登录必须使用 HTTPS/WSS;服务端解析密码后使用 bcrypt 哈希,数据库不保存密码明文。Token 登录必须把上次 `params.token` 的 JSON 字符串原样填入 `secret`,不能再次 Base64 编码。登录成功 `{ctrl}` 的 `params` 至少包含 `user`、`authlvl`、`token`、`expires`。产品 HTTP 也使用同一个 `params.token`:`Authorization: Bearer `,客户端不要解码或二次编码。Token 对客户端是不可解析的 opaque 字符串,只能存入系统安全存储。`id` 只用于匹配本次 Session 的命令响应,不是业务幂等键。 + +当前登录 Token 默认有效期为 14 天,不单独设置 Refresh Token 或刷新接口。每次 Basic 登录或未过期 Token 登录成功后,服务端都会返回一个新的 14 天 Token;客户端必须原子覆盖安全存储中的旧 `token/expires`。Token 已过期或校验失败时不能续期,必须回到账号密码登录。 `hi` 成功示例: @@ -249,9 +252,9 @@ X-Tinode-APIKey: <预置的普通 API Key> `sid` 无效或过期时服务端返回 HTTP 403 `invalid or expired session`,客户端必须重新创建 Long Polling Session 并从 `hi` 开始,不能只重发 `login`。Long Polling 是兼容方案,移动端和 Web 端正常网络条件下优先使用 WebSocket。 -#### 0.5.3 目标产品流程 +#### 0.5.3 企业解析与产品流程 -第 1.1 节规划的 `POST /v1/im/tenants/resolve` 属于新增接口。实现后目标流程调整为: +第 1.1 节的 `POST /v1/im/tenants/resolve` 已实现,推荐产品入口流程为: ```text 读取预置 API Key @@ -261,7 +264,125 @@ X-Tinode-APIKey: <预置的普通 API Key> -> login -> sub me -> 恢复会话 ``` -企业解析接口只改善入口、服务发现和品牌配置,不能代替 `{hi}` 的租户绑定。即使 HTTP 解析成功,WebSocket 第一帧仍必须带相同 `tenant_code`。 +企业解析接口只改善入口、服务发现和品牌配置,不能代替 `{hi}` 的租户绑定。即使 HTTP 解析成功,WebSocket 第一帧仍必须带相同 `tenant_code`。如果解析响应中的 `endpoints` 不符合当前发布环境,客户端应优先使用内置的公网环境配置,并上报脱敏诊断。 + +#### 0.5.4 WSS 契约输入输出示例 + +本节用于 WebSocket 联调。除特别说明外: + +- WebSocket 地址为 `wss://{host}/{api_path}v0/channels?apikey={api_key}`;支持自定义 Header 的原生客户端也可用 `X-Tinode-APIKey`。 +- 连接建立后第一帧必须是 `{hi}`,`hi` 成功后才能 `{login}` 或 `{acc}`。 +- 每个上行命令都带客户端生成的 `id`,客户端用该 `id` 匹配 `{ctrl}`;同一连接内保持唯一即可。 +- 服务端下行可能乱序到达,客户端不能假设 `{ctrl}` 一定先于 `{meta}` 或 `{data}`。 +- 成功控制包一般为 `{ctrl.code=2xx}`;失败控制包一般为 `{ctrl.code=4xx/5xx}`,产品稳定错误优先读 `ctrl.params.reason`,没有时按 `code/text` 兜底。 + +##### 0.5.4.1 连接、握手、登录和注册 + +| 场景 | 客户端发送 | 服务端返回示例 | +| --- | --- | --- | +| WebSocket Upgrade | `GET /v0/channels?apikey=AQ...` | HTTP `101 Switching Protocols`;API Key 无效时 HTTP `403` | +| 租户握手 `hi` | `{"hi":{"id":"hi-1","ver":"0.25","ua":"Tinodios/1","platf":"ios","lang":"zh-CN","tenant":"default"}}` | `{"ctrl":{"id":"hi-1","code":201,"text":"created","params":{"ver":"0.25","tenant":{"code":"default","name":"Default Tenant"},"maxMessageSize":4194304,"maxFileUploadSize":33554432,"maxSubscriberCount":128}}}` | +| 账号密码登录 | `{"login":{"id":"login-1","scheme":"basic","secret":"base64(username:password)"}}` | `{"ctrl":{"id":"login-1","code":200,"text":"ok","params":{"user":"usr...","username":"alice","authlvl":"auth","token":"token...","expires":"2026-09-03T09:00:00Z"}}}` | +| Token 登录 | `{"login":{"id":"login-2","scheme":"token","secret":"上次登录返回的 token 原文"}}` | `{"ctrl":{"id":"login-2","code":200,"text":"ok","params":{"user":"usr...","authlvl":"auth","token":"new_token...","expires":"2026-09-03T09:00:00Z"}}}` | +| 用户名注册 | `{"acc":{"id":"acc-1","user":"new","scheme":"basic","username":"alice","phone":"","invite_code":"AbCdE","secret":"base64(alice:password)","login":true,"desc":{"public":{"fn":"alice"}}}}` | `{"ctrl":{"id":"acc-1","code":200,"text":"ok","params":{"user":"usr...","username":"alice","authlvl":"auth","token":"token...","expires":"2026-09-03T09:00:00Z"}}}` | +| 手机号注册 | `{"acc":{"id":"acc-2","user":"new","scheme":"basic","username":"alice","phone":"15500000000","secret":"base64(alice:password)","login":true,"cred":[{"meth":"tel","val":"15500000000","resp":"verification_token","params":{"challenge_id":"chg_123"}}],"desc":{"public":{"fn":"alice"}}}}` | `{"ctrl":{"id":"acc-2","code":200,"text":"ok","params":{"user":"usr...","username":"alice","authlvl":"auth","token":"token...","expires":"2026-09-03T09:00:00Z"}}}` | +| 登录失败 | `{"login":{"id":"login-bad","scheme":"basic","secret":"base64(alice:wrong)"}}` | `{"ctrl":{"id":"login-bad","code":401,"text":"authentication failed","params":{"reason":"AUTH_FAILED"}}}` | +| 租户不匹配 | `{"login":{"id":"login-tenant","scheme":"token","secret":"其他租户 token"}}` | `{"ctrl":{"id":"login-tenant","code":403,"text":"tenant mismatch","params":{"reason":"TENANT_MISMATCH"}}}` | + +##### 0.5.4.2 启动订阅、会话列表和增量恢复 + +| 场景 | 客户端发送 | 服务端返回示例 | +| --- | --- | --- | +| 订阅本人 `me` | `{"sub":{"id":"sub-me-1","topic":"me","get":{"what":"desc sub tags cred"}}}` | `{"ctrl":{"id":"sub-me-1","code":200,"text":"ok"}}`,随后可能收到 `{"meta":{"id":"sub-me-1","topic":"me","desc":{"public":{"fn":"alice"}},"sub":[{"topic":"grp...","public":{"name":"项目群"},"seq":12,"read":10,"recv":12,"updated":"2026-08-20T09:00:00Z"}]}}` | +| 订阅查找 `fnd` | `{"sub":{"id":"sub-fnd-1","topic":"fnd"}}` | `{"ctrl":{"id":"sub-fnd-1","code":200,"text":"ok"}}` | +| 订阅会话并拉历史 | `{"sub":{"id":"sub-topic-1","topic":"grp...","get":{"what":"desc sub data del","data":{"since":1,"limit":50},"del":{"since":1,"limit":50}}}}` | `{"ctrl":{"id":"sub-topic-1","code":200,"text":"ok"}}`,随后 `{"meta":{"id":"sub-topic-1","topic":"grp...","desc":{"public":{"name":"项目群"},"seq":12},"sub":[{"user":"usr...","mode":"JRWP"}]}}` 和多条 `{data}` | +| 断线恢复增量 | `{"sub":{"id":"resume-grp-1","topic":"grp...","get":{"what":"data del","data":{"since":125,"limit":50},"del":{"since":1,"limit":50}}}}` | `{"data":{"topic":"grp...","seq":125,"from":"usr...","ts":"2026-08-20T09:00:00Z","content":{"text":"恢复后的第一条"}}}` | +| 只拉元数据 | `{"get":{"id":"get-topic-1","topic":"grp...","what":"desc sub"}}` | `{"meta":{"id":"get-topic-1","topic":"grp...","desc":{"seq":12,"public":{"name":"项目群"}},"sub":[{"user":"usr...","read":10,"recv":12}]}}` | +| 离开/取消订阅 | `{"leave":{"id":"leave-1","topic":"grp...","unsub":false}}` | `{"ctrl":{"id":"leave-1","code":200,"text":"ok"}}` | + +##### 0.5.4.3 发送消息、媒体卡片、名片和转发卡片 + +| 类型 | 客户端发送 | 服务端返回/下行示例 | +| --- | --- | --- | +| 文本消息 | `{"pub":{"id":"pub-text-1","topic":"p2p...","noecho":false,"head":{"mime":"application/json","x-im-schema":1,"x-im-type":"text","x-im-client-mid":"mid_1"},"content":{"text":"你好"}}}` | 发送方收到 `{"ctrl":{"id":"pub-text-1","code":202,"text":"accepted","params":{"seq":12}}}`;订阅者收到 `{"data":{"topic":"p2p...","seq":12,"from":"usr...","head":{"x-im-type":"text","x-im-client-mid":"mid_1"},"content":{"text":"你好"}}}` | +| 图片消息 | `{"pub":{"id":"pub-img-1","topic":"grp...","head":{"mime":"application/json","x-im-schema":1,"x-im-type":"image","x-im-client-mid":"mid_img_1"},"content":{"file_id":"file_img","thumbnail_file_id":"file_thumb","name":"a.jpg","size":12345,"mime":"image/jpeg","width":1080,"height":720}}}` | `{"ctrl":{"id":"pub-img-1","code":202,"params":{"seq":13}}}` | +| 语音消息 | `{"pub":{"id":"pub-voice-1","topic":"grp...","head":{"mime":"application/json","x-im-schema":1,"x-im-type":"voice","x-im-client-mid":"mid_voice_1"},"content":{"file_id":"file_voice","duration_ms":8000,"size":4096,"mime":"audio/aac","waveform":[1,3,5,2]}}}` | `{"ctrl":{"id":"pub-voice-1","code":202,"params":{"seq":14}}}` | +| 文件消息 | `{"pub":{"id":"pub-file-1","topic":"grp...","head":{"mime":"application/json","x-im-schema":1,"x-im-type":"file","x-im-client-mid":"mid_file_1"},"content":{"file_id":"file_pdf","name":"方案.pdf","size":102400,"mime":"application/pdf","extension":"pdf","checksum":"sha256:..."}}}` | `{"ctrl":{"id":"pub-file-1","code":202,"params":{"seq":15}}}` | +| 名片消息 | `{"pub":{"id":"pub-card-1","topic":"p2p...","head":{"mime":"application/json","x-im-schema":1,"x-im-type":"contact_card","x-im-client-mid":"mid_card_1"},"content":{"user_id":"usr...","display_name":"张三","avatar_file_id":"","public_account":"zhangsan"}}}` | `{"ctrl":{"id":"pub-card-1","code":202,"params":{"seq":16}}}` | +| 合并转发卡片 | `{"pub":{"id":"pub-fwd-1","topic":"grp...","head":{"mime":"application/json","x-im-schema":1,"x-im-type":"forward_bundle","x-im-client-mid":"mid_fwd_1"},"content":{"bundle_id":"fbundle_1","title":"聊天记录","item_count":3,"preview":[{"from_display":"张三","summary":"你好"}]}}}` | `{"ctrl":{"id":"pub-fwd-1","code":202,"params":{"seq":17}}}` | +| @成员消息 | `{"pub":{"id":"pub-mention-1","topic":"grp...","head":{"mime":"application/json","x-im-schema":1,"x-im-type":"text","x-im-client-mid":"mid_at_1","mentions":[{"user_id":"usr...","offset":0,"length":3}]},"content":{"text":"@张三 看一下"}}}` | `{"ctrl":{"id":"pub-mention-1","code":202,"params":{"seq":18}}}` | +| @所有人 | `{"pub":{"id":"pub-all-1","topic":"grp...","head":{"mime":"application/json","x-im-schema":1,"x-im-type":"text","x-im-client-mid":"mid_all_1","mention_all":true},"content":{"text":"@所有人 开会"}}}` | 无权限时 `{"ctrl":{"id":"pub-all-1","code":403,"text":"permission denied","params":{"reason":"MENTION_ALL_FORBIDDEN"}}}` | +| 重复 `client_mid` 重试 | 与第一次发送完全相同,复用 `x-im-client-mid:"mid_1"` | 目标行为:返回第一次成功的同一 `seq`;不得落两条消息 | + +##### 0.5.4.4 回执、正在输入、删除和清空记录 + +| 场景 | 客户端发送 | 服务端返回/下行示例 | +| --- | --- | --- | +| 送达回执 | `{"note":{"topic":"p2p...","what":"recv","seq":12}}` | 对端收到 `{"info":{"topic":"p2p...","from":"usr...","what":"recv","seq":12}}` | +| 已读回执 | `{"note":{"topic":"p2p...","what":"read","seq":12}}` | 对端收到 `{"info":{"topic":"p2p...","from":"usr...","what":"read","seq":12}}`;客户端本地把该 topic `read` 更新为 12 | +| 正在输入 | `{"note":{"topic":"p2p...","what":"kp"}}` | 对端收到 `{"info":{"topic":"p2p...","from":"usr...","what":"kp"}}`,本地超时消失 | +| 正在录音可选 | `{"note":{"topic":"grp...","what":"kpa"}}` | 对端收到 `{"info":{"topic":"grp...","from":"usr...","what":"kpa"}}`,不落库 | +| 删除单条/多条消息 | `{"del":{"id":"del-1","topic":"grp...","what":"msg","delseq":[{"low":12,"hi":13}],"hard":false}}` | `{"ctrl":{"id":"del-1","code":200,"text":"ok","params":{"del":3}}}`;其他端可能收到 `{"pres":{"topic":"grp...","what":"del","seq":3}}` | +| 清空当前用户视图 | `{"del":{"id":"del-all-1","topic":"p2p...","what":"msg","delseq":[{"low":1,"hi":126}],"hard":false}}` | `{"ctrl":{"id":"del-all-1","code":200,"text":"ok","params":{"del":4}}}` | + +##### 0.5.4.5 Topic 资料、个人群设置和标签 + +| 场景 | 客户端发送 | 服务端返回/下行示例 | +| --- | --- | --- | +| 更新群公开资料 | `{"set":{"id":"set-grp-1","topic":"grp...","desc":{"public":{"name":"新群名","avatar_file_id":"file_avatar","description":"新简介","version":2}}}}` | `{"ctrl":{"id":"set-grp-1","code":200,"text":"ok"}}`;成员收到 `{"pres":{"topic":"grp...","what":"upd"}}` | +| 更新个人资料 | `{"set":{"id":"set-me-1","topic":"me","desc":{"public":{"fn":"新昵称","avatar_file_id":"file_avatar"}}}}` | `{"ctrl":{"id":"set-me-1","code":200,"text":"ok"}}` | +| 更新会话私有设置 | `{"set":{"id":"set-private-1","topic":"grp...","sub":{"user":"usr_me","private":{"pinned":true,"mute_notifications":false,"nickname":"群昵称","version":2}}}}` | `{"ctrl":{"id":"set-private-1","code":200,"text":"ok"}}` | +| 设置搜索标签 | `{"set":{"id":"set-tags-1","topic":"me","tags":["usr:alice"]}}` | 普通用户无权限时 `{"ctrl":{"id":"set-tags-1","code":403,"text":"permission denied","params":{"what":"tags"}}}` | + +##### 0.5.4.6 系统事件、好友和群业务下行 + +| 事件 | 服务端下行示例 | 客户端动作 | +| --- | --- | --- | +| 好友申请创建 | `{"data":{"topic":"sys","seq":101,"from":"usr_system","head":{"mime":"application/json","x-im-type":"system_event"},"content":{"event_type":"friend.request.created","request_id":"frq_1","applicant":{"user_id":"usr_a","display_name":"张三"},"message":"我是张三","action":{"type":"friend_request","request_id":"frq_1"}}}}` | 系统消息入库/展示,刷新 `friend-requests` 和 `bootstrap.entry_badges` | +| 好友关系变化 | `{"data":{"topic":"sys","seq":102,"head":{"x-im-type":"system_event"},"content":{"event_type":"friend_relationship_changed","peer_user_id":"usr_a","relationship":"friend","topic":"p2p..."}}}` | 刷新会话 context,解锁或锁定输入区 | +| 群邀请 | `{"data":{"topic":"sys","seq":103,"head":{"x-im-type":"system_event"},"content":{"event_type":"group_invitation","invitation_id":"ginv_1","group":{"topic":"grp...","name":"项目群"},"inviter":{"user_id":"usr_a","display_name":"张三"},"status":"pending","expires_at":"2026-08-27T09:00:00Z"}}}` | 展示邀请卡,点击后走邀请接受或入群申请接口 | +| 入群申请 | `{"data":{"topic":"grp...","seq":20,"head":{"x-im-type":"system_event"},"content":{"event_type":"group.join_request.created","request_id":"gjr_1","applicant":{"user_id":"usr_a","display_name":"张三"},"source":"qr"}}}` | 管理员刷新 `join-requests` 和 `summary.pending_join_request_count` | +| 群成员变化 | `{"data":{"topic":"grp...","seq":21,"head":{"x-im-type":"system_event"},"content":{"event_type":"group_membership_changed","operator":"usr_owner","target_users":["usr_a"],"action":"join","result":"joined"}}}` | 刷新群成员列表、群详情成员数和会话系统提示 | +| 工作台未读变化 | `{"data":{"topic":"sys","seq":104,"head":{"x-im-type":"system_event"},"content":{"event_type":"workbench.badge.updated","modules":[{"code":"meetings","unread_count":2}],"workbench_total_unread":2}}}` | 更新工作台 badge;必要时重新拉 `GET /v1/im/workbench` | + +##### 0.5.4.7 WebRTC 通话信令 + +| 场景 | 客户端发送 | 服务端返回/下行示例 | +| --- | --- | --- | +| 发起语音通话 | `{"pub":{"id":"call-pub-1","topic":"p2p...","head":{"mime":"application/json","webrtc":"started","x-im-type":"call","x-im-client-mid":"mid_call_1"},"content":{"callId":"call_1","callType":"audio","audio":true,"video":false}}}` | `{"ctrl":{"id":"call-pub-1","code":202,"text":"accepted","params":{"seq":30}}}`;被叫收到同一 `{data seq=30}` | +| 被叫响铃 | `{"note":{"topic":"p2p...","what":"call","event":"ringing","seq":30,"payload":{"callId":"call_1"}}}` | 主叫收到 `{"info":{"topic":"p2p...","what":"call","event":"ringing","seq":30,"payload":{"callId":"call_1"}}}` | +| 被叫接听 | `{"note":{"topic":"p2p...","what":"call","event":"accept","seq":30,"payload":{"callId":"call_1"}}}` | 双方收到通话状态 `accept`;原消息可被服务端替换为 `head.webrtc:"accepted"` | +| SDP offer | `{"note":{"topic":"p2p...","what":"call","event":"offer","seq":30,"payload":{"callId":"call_1","sdp":"v=0..."}}}` | 对端收到 `{"info":{"topic":"p2p...","what":"call","event":"offer","seq":30,"payload":{"callId":"call_1","sdp":"v=0..."}}}` | +| SDP answer | `{"note":{"topic":"p2p...","what":"call","event":"answer","seq":30,"payload":{"callId":"call_1","sdp":"v=0..."}}}` | 对端收到 `event:"answer"` | +| ICE candidate | `{"note":{"topic":"p2p...","what":"call","event":"ice-candidate","seq":30,"payload":{"callId":"call_1","candidate":{"candidate":"candidate:...","sdpMid":"0","sdpMLineIndex":0}}}}` | 对端收到 `event:"ice-candidate"` | +| 挂断/取消 | `{"note":{"topic":"p2p...","what":"call","event":"hang-up","seq":30,"payload":{"callId":"call_1","reason":"cancelled"}}}` | 服务端替换原通话消息:`{"data":{"topic":"p2p...","seq":30,"head":{"replace":":30","webrtc":"missed","webrtc-duration":0,"x-im-type":"call"},"content":{"callId":"call_1","callType":"audio","status":"missed"}}}` | +| 异常事件 | `{"note":{"topic":"p2p...","what":"call","event":"media-state","seq":30,"payload":{"callId":"call_1"}}}` | 当前未开放时返回 `{"ctrl":{"code":400,"text":"malformed","params":{"reason":"CALL_EVENT_UNSUPPORTED"}}}` 或直接忽略,客户端不要提前依赖 | + +##### 0.5.4.8 WSS 错误、重试和客户端处理 + +| 场景 | 服务端返回示例 | 客户端处理 | +| --- | --- | --- | +| API Key 无效 | Upgrade HTTP `403 {"ctrl":{"code":403,"text":"valid API key required"}}` | 停止重连,上报环境配置错误 | +| 未先 `hi` 就登录 | `{"ctrl":{"id":"login-1","code":400,"text":"tenant required","params":{"reason":"TENANT_REQUIRED"}}}` | 关闭连接,从 Upgrade + `hi` 重来 | +| 登录失败 | `{"ctrl":{"id":"login-1","code":401,"text":"authentication failed","params":{"reason":"AUTH_FAILED"}}}` | 清密码,保留账号,显示统一账号或密码错误 | +| Token 过期 | `{"ctrl":{"id":"login-token-1","code":401,"text":"authentication failed","params":{"reason":"TOKEN_EXPIRED"}}}` | 清当前租户 token,回登录页 | +| 无 Topic 权限 | `{"ctrl":{"id":"pub-1","code":403,"text":"permission denied","params":{"reason":"MESSAGE_NOT_ALLOWED"}}}` | 发送气泡置为失败,不伪装已发送 | +| 消息过大 | `{"ctrl":{"id":"pub-1","code":413,"text":"message too large","params":{"reason":"MESSAGE_TOO_LARGE","max_size":4194304}}}` | 禁止重试,提示压缩或拆分 | +| 断线 | WebSocket close | 指数退避重连,重新 `hi -> token login -> sub me -> sub topics`,按最后完整 `seq + 1` 增量拉取 | + + +#### 0.5.5 Long Polling 与文件基础示例 + +##### 0.5.5.1 WebSocket 与文件基础接口 + +| 接口 | 输入示例 | 成功输出示例 | +| --- | --- | --- | +| `GET /v0/channels?apikey={api_key}` | WebSocket Upgrade 后首帧 `{"hi":{"id":"hi-1","ver":"0.25","ua":"ios/1.0","lang":"zh-CN","tenant":"default"}}` | `{"ctrl":{"id":"hi-1","code":201,"text":"created","params":{"tenant":{"code":"default","name":"Default Tenant"},"ver":"0.25","maxMessageSize":4194304}}}` | +| WebSocket `login basic` | `{"login":{"id":"login-1","scheme":"basic","secret":"base64(username:password)"}}` | `{"ctrl":{"id":"login-1","code":200,"params":{"user":"usr...","token":"token...","expires":"2026-09-03T09:00:00Z","authlvl":"auth"}}}` | +| WebSocket `acc new` | `{"acc":{"id":"acc-1","user":"new","scheme":"basic","username":"alice","phone":"15500000000","secret":"base64(alice:password)","login":true,"cred":[{"meth":"tel","val":"15500000000","resp":"verification_token","params":{"challenge_id":"chg_123"}}]}}` | `{"ctrl":{"id":"acc-1","code":200,"params":{"user":"usr...","username":"alice","token":"token...","expires":"2026-09-03T09:00:00Z"}}}` | +| `POST /v0/channels/lp` | Long Polling 首包 `{"hi":{"id":"hi-1","ver":"0.25","tenant":"default"}}` | `{"ctrl":{"id":"hi-1","code":201,"params":{"sid":"lp_sid_123"}}}` | +| `POST /v0/file/u/` | Multipart 文件;Header `X-Tinode-Auth: token `;业务表单可带 `purpose=chat_file&topic=grp...` | `{"ctrl":{"code":200,"params":{"url":"/v0/file/s/file123","name":"a.pdf","size":1024,"mime":"application/pdf"}}}` | +| `GET /v0/file/s/{file_id}` | Header `X-Tinode-Auth: token `,可带 `Range: bytes=0-` | 返回文件字节流;失败返回 `FILE_FORBIDDEN` 或 `FILE_UNAVAILABLE` | ### 0.6 Topic 与产品消息 @@ -294,7 +415,7 @@ X-Tinode-APIKey: <预置的普通 API Key> 1. 创建、提交、同意、拒绝、删除、转让、收藏、转发等写请求必须携带 `Idempotency-Key`。 2. GET、断线同步和同幂等键写请求可自动重试;密码、验证码和风控确认只按接口返回的 `retry_after_seconds` 重试。 -3. `401 TOKEN_EXPIRED` 只允许一次静默刷新;刷新失败必须退出登录。`403` 不重试,`429` 按服务端退避。 +3. `401 TOKEN_EXPIRED` 不执行刷新接口,立即清理失效 Token 并返回账号密码登录;其他 `401` 也不得无限重试。`403` 不重试,`429` 按服务端退避。 4. 服务端必须在最终写入前再次校验好友关系、群角色、禁言、消息归属、文件 ACL、租户配额和风控,不能信任前端按钮状态。 5. 普通用户禁止向系统 Topic 发布;系统通知必须由服务端受控身份生成。 @@ -304,19 +425,19 @@ X-Tinode-APIKey: <预置的普通 API Key> 企业码登录 -**状态:新增企业解析接口;WebSocket 租户握手已实现。** +**状态:企业解析接口与 WebSocket 租户握手均已实现。** **当前系统的执行方式:** - 前端动作:输入企业码并点击“进入企业”。只去掉首尾空格并校验非空、最长 64 字符;当前服务端没有自动转小写,客户端不得擅自改变用户输入的大小写。 -- 客户端从构建/环境配置读取 `host` 和普通 API Key,使用 `wss://{host}/{api_path}v0/channels?apikey={api_key}` 建立 WebSocket。 +- 客户端从构建/环境配置读取 `host` 和普通 API Key,可先调用 `POST /v1/im/tenants/resolve` 获取企业公开信息,再使用 `wss://{host}/{api_path}v0/channels?apikey={api_key}` 建立 WebSocket。 - Upgrade 成功后第一帧发送 `{"hi":{"id":"hi-1","ver":"0.25","ua":"android/1.0.0","platf":"android","lang":"zh-CN","tenant":"用户输入的企业码"}}`。 - 服务端通过 `im_tenant.code` 查询企业;成功返回 `{ctrl code:201}` 和 `params.tenant{code,name}`,同时固定 Session 的内部 `tenant_id`。客户端保存服务端返回的标准 `tenant.code/name`,再进入 1.2 登录。 - 当前失败表现:企业码缺失/超过 64 字符返回 400,企业不存在按存储错误返回,企业停用返回 403。当前还没有统一 `TENANT_UNAVAILABLE` 产品错误码。 -**目标产品新增方式:** +**企业解析接口:** -- 新增 `POST /v1/im/tenants/resolve`,未登录但必须带 `X-Tinode-APIKey`,Header 传 `X-Tenant-Code: acme`;Body 为 `{"tenant_code":"acme","platform":"android","app_version":"1.0.0"}`。 +- `POST /v1/im/tenants/resolve` 未登录可调用,但必须带 `X-Tinode-APIKey` 和 `X-IM-Schema: 1`;Header 传 `X-Tenant-Code: acme`,Body 可传 `{"tenant_code":"acme","platform":"android","app_version":"1.0.0"}`。 - 成功数据:`tenant{code,name,tenant_desc,branding}`、`endpoints{websocket,api,file}`、`auth_methods`、`password_policy`、`app_update`。不得返回 `tenant_id` 或 `tenant_ticket`。 - 后续:保存公开企业信息,连接响应给出的 WebSocket 地址,仍然发送 0.5 节的 `hi(tenant)` 做最终租户绑定;`hi` 成功后才进入 1.2。 - 目标失败协议:企业不存在、停用或不允许登录统一返回 `TENANT_UNAVAILABLE`;客户端不得区分不存在和停用。网络失败保留输入并允许重试。 @@ -346,12 +467,14 @@ X-Tinode-APIKey: <预置的普通 API Key> 登录页面 -**状态:基本账号密码登录已实现;协议勾选和安全 Token 生命周期需改造。** +**状态:基本账号密码登录已实现;WS Token 可复用于产品 HTTP。** - 前端动作:账号、密码非空且已勾选协议后启用登录;密码仅驻留当前页面内存。 -- 协议:已完成 `hi` 的 WebSocket 发送 `{login scheme:"basic"}`,`secret=base64(username:password)`;不得先登录再传企业码。 -- 成功:读取 `{ctrl params.user/token/expires/authlvl}`,Token 存 Keychain/Keystore;立即 `sub me`,再加载会话。前端不解码 Token。 -- 待改造:登录成功后签发短期 access token 与可轮换 refresh token;Token 绑定 `tenant_id/user_id/auth_version/device_id`。修改密码、封禁账号后旧 Token 失效。 +- 协议:已完成 `hi` 的 WebSocket 发送 `{login scheme:"basic"}`,`secret=base64(username:password)`;Base64 不是加密,必须通过 WSS 传输,不得先登录再传企业码。 +- 成功:读取 `{ctrl params.user/token/expires/authlvl}`,Token 存 Keychain/Keystore;立即 `sub me`,再加载会话。前端不解码 Token,也不再次 Base64 编码。 +- 产品 HTTP:使用同一个 `params.token` 作为 `Authorization: Bearer `;所有 `/v1/im/*` HTTP 请求仍必须带 `X-Tinode-APIKey`、`X-IM-Schema: 1`,写请求再带 `Idempotency-Key`。 +- Token:当前复用 Tinode 单一登录 Token,默认有效期 14 天,不使用 Refresh Token。有效 Token 登录成功会签发新的 14 天 Token,客户端覆盖旧值;过期后重新账号密码登录。Token 已绑定 `tenant_id/user_id/auth_version`,修改密码、封禁账号后旧 Token 应失效。 +- 密码:服务端使用 `bcrypt.DefaultCost=10` 生成单向哈希,`im_auth.secret` 只保存哈希;客户端和服务端不得记录密码或完整 Basic `secret`。 - 错误:`AUTH_FAILED` 统一显示账号或密码错误;`TENANT_MISMATCH` 清 Token 并回企业码页;`ACCOUNT_DISABLED`、`REALNAME_REQUIRED`、`YOUTH_RESTRICTED` 按专用页面处理。 登录成功示例: @@ -389,17 +512,42 @@ X-Tinode-APIKey: <预置的普通 API Key> 注册页面 -**状态:Tinode `{acc}` 基础注册已实现;短信预校验与租户配额需改造。** +**状态:注册原型和服务端注册分流均已完成;用户名和手机号为独立字段,服务端已接入 username、邀请码和短信开关逻辑。** -- 获取验证码:调用 `POST /v1/im/auth/verification-codes`,Body `{"scene":"register","phone":"138****0000"}`;成功返回 `challenge_id`、`expires_in_seconds`、`resend_after_seconds`。 -- 提交注册:前端校验密码一致和协议后,先调用 `POST /v1/im/auth/verification-codes/{challenge_id}/verify`,Body `{"code":"123456"}`,获得一次性 `verification_token`。 -- WebSocket:把一次性验证结果放在 `{acc}.cred`,不得写入 `desc.private`。服务端必须在当前 Session 租户内消费该 Token。 +- 页面规则:用户名和手机号是独立输入。用户名非空时必须保存为 `im_users.username`;手机号非空时必须保存为规范化后的 `im_users.phone`。两个字段同时为空时拒绝注册。 +- 用户名格式:普通用户名必须匹配 `^[a-z0-9]{1,10}$`,即 1~10 位小写英文字母或数字;允许纯数字,不允许大写、中文、空格、点、下划线和其他符号。前端限制输入,服务端按原始值再次校验,失败返回 `USERNAME_INVALID_FORMAT`。 +- 邀请码格式:非空时必须匹配 `^[A-Za-z]{5}$`,固定 5 位大小写英文字母并区分大小写;客户端不得自动转换大小写。服务端在当前租户内按明文精确查询。 +- 邀请码可复用:同一有效邀请码可以供多个用户注册,使用后仍保持 `active`;服务端为每个注册用户记录邀请来源。只有到期或管理员撤销后才不可继续使用。 +- 邀请码主数据位于 HotGo 控制库并按 `tenant_id` 隔离;注册使用明细位于当前租户业务库。客户端契约不受分库影响,仍只提交完整 5 位邀请码。 +- 用户名方式:手机号为空时提交用户名、密码和邀请码,不发送短信。邀请码必须有效,注册后 `im_users.username` 保存用户名,`im_users.phone=NULL`。 +- 找回提示:用户名方式提交注册前,客户端明确提示“未绑定手机号时无法自助找回密码,忘记密码需联系企业管理员重置”。 +- 手机号方式:前端只接受 11 位数字。调用 `POST /v1/im/auth/verification-codes`,Body `{"scene":"register","phone":"138..."}`;后端补 `+86` 后发送,成功返回 `challenge_id`、`expires_in_seconds`、`resend_after_seconds`。当前测试环境短信供应商未接入,注册、找回密码和敏感操作验证码固定填写 `123456`,用它调用 verify 接口换取一次性 `verification_token` 或 `action_token`;生产接入短信供应商后不得继续使用固定验证码。 +- 短信开关开启:手机号非空时必须完成验证码验证,邀请码不能绕过短信验证;验证成功后 `username` 保存用户填写的用户名,`phone` 保存规范化的 E.164 手机号。若用户未填写用户名,则服务端随机生成 3 位小写字母 + 6 位数字的 username。 +- 短信开关关闭:发码接口仍返回正常倒计时但不调用短信服务商。手机号非空时提供有效邀请码后可无验证码注册;`username` 保存用户填写的用户名,`phone` 保存规范化手机号。若用户未填写用户名,则服务端随机生成 3 位小写字母 + 6 位数字的 username。不建立已完成的 `tel credential`。 +- 提交注册:`{acc}` 增加独立的 `username`、`phone` 和 `invite_code`。短信验证成功后调用 `POST /v1/im/auth/verification-codes/{challenge_id}/verify` 获得一次性 `verification_token`。`username` 非空时,`secret` 使用 `username:password`;仅手机号注册且 username 为空时,客户端仍使用规范化手机号与密码生成 `secret`,服务端创建后返回随机 username。 +- 服务端判定:`username` 非空时校验 `^[a-z0-9]{1,10}$` 并要求 `secret` 登录名与 username 一致;`username` 为空且 `phone` 非空时要求 `secret` 登录名与规范化手机号一致,随后生成随机 username。普通用户名允许纯数字,因此不能只根据 `secret` 是否为 11 位数字推断注册方式。 +- 账户昵称:注册创建资料时,昵称暂时默认等于最终 username。填写用户名时昵称等于该用户名;仅手机号注册时昵称等于服务端随机 username,不使用手机号作为昵称。 +- 禁止客户端提交 `tags`;手机号方式始终写入 `im_users.phone`,只有短信验证成功时才建立已完成的电话凭证。 + +用户名方式: ```json -{"acc":{"id":"acc-1","user":"new","scheme":"basic","secret":"base64(username:password)","login":true,"tags":["tel:+86138..."],"cred":[{"meth":"tel","val":"+86138...","resp":"verification_token","params":{"challenge_id":"challenge-id"}}],"desc":{"public":{"fn":"用户昵称"}}}} +{"acc":{"id":"acc-1","user":"new","scheme":"basic","username":"alice","phone":"","secret":"base64(alice:password)","invite_code":"AbCdE","login":true,"cred":[]}} ``` -- 成功:返回新 `user` 和登录 Token,自动 `sub me`。创建用户前原子校验 `im_tenant_config.max_user_count`,超限返回 `TENANT_USER_QUOTA_EXCEEDED`。 -- 安全:手机号在租户内唯一;验证码只保存摘要、单次消费;密码按服务端策略校验并使用强哈希保存。 + +手机号验证方式: + +```json +{"acc":{"id":"acc-1","user":"new","scheme":"basic","username":"alice","phone":"138...","secret":"base64(alice:password)","invite_code":"","login":true,"cred":[{"meth":"tel","val":"138...","resp":"verification_token","params":{"challenge_id":"challenge-id"}}]}} +``` + +仅手机号注册方式: + +```json +{"acc":{"id":"acc-1","user":"new","scheme":"basic","username":"","phone":"138...","secret":"base64(+86138...:password)","invite_code":"","login":true,"cred":[{"meth":"tel","val":"138...","resp":"verification_token","params":{"challenge_id":"challenge-id"}}]}} +``` +- 成功:返回新 `user`、最终 `username` 和登录 Token;客户端保存 `params.token`,随后执行 `sub me` 和会话恢复。创建用户前原子校验 `im_tenant_config.max_users`,超限返回 `TENANT_USER_QUOTA_EXCEEDED`。 +- 安全:用户名、手机号分别在租户内唯一;验证码票据单次消费,邀请码在有效期内可重复使用;密码按服务端策略校验并使用强哈希保存。 ### 1.5 注册页面-倒计时.png @@ -417,11 +565,14 @@ X-Tinode-APIKey: <预置的普通 API Key> **状态:新增。** +- 适用范围:仅 `im_users.phone` 非空的账号支持短信自助找回。`phone=NULL` 的用户名账号不提供自助找回,客户端提示联系当前企业管理员重置密码。 - 发码:`POST /v1/im/auth/verification-codes`,Body `{"scene":"reset_password","phone":"..."}`。无论账号是否存在都返回相同受理响应。 - 校验:`POST /v1/im/auth/password-reset/challenges/{challenge_id}/verify`,Body `{"code":"123456"}`,成功返回短时、单次使用的 `reset_token`。 - 重置:`POST /v1/im/auth/password-reset/confirm`,Body `{"reset_token":"...","new_password":"..."}`,带幂等键。 -- 成功:服务端更新密码、递增 `auth_version`、撤销该用户全部 access/refresh token 和在线 Session;客户端清理本租户凭证并返回登录页。 +- 凭证补建:只要账号的 `phone` 非空,成功完成 `reset_password` 短信挑战即可证明当前持有该号码;即使注册时未建立 `tel credential`,本次成功后也可补建完成状态的电话凭证。 +- 成功:服务端更新密码、递增 `auth_version`,使该用户全部旧登录 Token 失效并撤销在线 Session;客户端清理本租户凭证并返回登录页。 - 失败:`PASSWORD_POLICY_VIOLATION` 返回规则明细;验证码错误不返回账号存在性,达到上限后冻结 challenge。 +- 管理员路径:无手机号账号只能由当前租户管理员调用 `POST /admin/v1/tenant/users/{user_id}/reset-password` 重置;不得根据用户名跨租户查找或操作。 ### 1.7 身份验证(安卓).png @@ -476,6 +627,30 @@ X-Tinode-APIKey: <预置的普通 API Key> - 规则:四位数字、禁止明显连续/重复组合;连续错误返回 `TOO_MANY_ATTEMPTS` 和 `retry_after_seconds`。前端输入框禁截屏/自动填充,离开页面立即清空。 - 忘记密码:跳转 1.7 的短信身份验证,验证后重置,不提供明文找回。 +### 1.12 接口示例 + +以下示例用于本章 HTTP 联调快速查阅。除特别说明外: + +- 产品 HTTP 均使用 `https://{host}/{api_path}v1/im`,请求头包含 `X-Tinode-APIKey`、`X-IM-Schema: 1`、`X-Request-ID`;登录后接口再带 `Authorization: Bearer `。 +- 写接口必须带 `Idempotency-Key`。二次认证接口按正文说明额外带 `X-Action-Token` 或 `X-Risk-Action-Token`。 +- 成功响应统一包裹为 `{"code":0,"data":示例,"request_id":"...","server_time":"..."}`;下表只展示 `data`。 +- 失败响应统一包裹为 `{"code":30000,"reason":"MALFORMED_REQUEST","message":"...","details":{},"request_id":"...","server_time":"..."}`。 +- 标注“待实现/需修复”的接口是产品目标协议,前端可按示例准备,但联调前以后端发布状态为准。 + +#### 1.12.1 未登录、企业与账号安全 + +| 接口 | 输入示例 | 成功 `data` 示例 | +| --- | --- | --- | +| `POST /v1/im/tenants/resolve` | Header `X-Tenant-Code: default`;Body `{"tenant_code":"default","platform":"ios","app_version":"1.0.0"}` | `{"tenant":{"code":"default","name":"Default Tenant","tenant_desc":null,"branding":{}},"endpoints":{"websocket":"wss://api.118im.com/v0/channels","api":"https://api.118im.com/v1/im","file":"https://api.118im.com/v0/file"},"auth_methods":["password","sms"],"password_policy":{"min_length":8,"max_length":64},"app_update":{"type":"none"}}` | +| `POST /v1/im/auth/verification-codes` 注册发码 | `{"scene":"register","phone":"15500000000"}` | `{"challenge_id":"chg_123","phone_mask":"+861****000","expires_in_seconds":300,"resend_after_seconds":60}` | +| `POST /v1/im/auth/verification-codes/{challenge_id}/verify` | `{"code":"123456"}` | `{"verification_token":"verify_token_123","expires_in_seconds":300}` | +| `POST /v1/im/auth/verification-codes` 找回密码发码 | `{"scene":"reset_password","phone":"15500000000"}` | `{"challenge_id":"chg_reset_123","phone_mask":"+861****000","expires_in_seconds":300,"resend_after_seconds":60}` | +| `POST /v1/im/auth/password-reset/challenges/{challenge_id}/verify` | `{"code":"123456"}` | `{"reset_token":"reset_token_123","expires_in_seconds":300}` | +| `POST /v1/im/auth/password-reset/confirm` | `{"reset_token":"reset_token_123","new_password":"NewPass123"}` | `{"status":"ok"}` | +| `POST /admin/v1/tenant/users/{user_id}/reset-password` 管理后台 | `{"new_password":"AdminReset123","reason":"用户无手机号无法自助找回"}` | `{"user_id":"usr...","status":"reset","auth_version":2}` | +| `POST /v1/im/auth/logout` 待实现 | `{}` | `{"status":"ok"}` | +| `GET /v1/im/app/releases/latest?platform=ios\|android¤t_version=1.0.0&channel=stable` | Query only | `{"platform":"ios","channel":"stable","latest_version":"1.0.0","min_supported_version":"1.0.0","update_type":"none","package_url":"","store_url":"","release_notes":"当前已是最新版本。","remind_after_seconds":0}` | + ## 2. 消息首页、全局导航与系统通知 ### 2.1 消息.png @@ -486,7 +661,7 @@ X-Tinode-APIKey: <预置的普通 API Key> - 登录后发送 `sub me`,再通过 `get` 拉取 `sub` 元数据;每项使用 `topic`、`public`、`private`、`updated`、`seq/read/recv` 计算名称、摘要和未读数。 - 消息增量通过各 Topic 的 `{data}` 更新;未读数为 `max(seq-read,0)`,回执以服务端游标为准,不能只用本地计数。 -- `GET /v1/im/bootstrap` 返回固定入口、功能开关、权限和系统 Topic;搜索调用 `GET /v1/im/search/conversations?q=&cursor=`。 +- `GET /v1/im/bootstrap` 返回固定入口、功能开关、权限、系统 Topic、`entry_badges` 和 `entries`;搜索调用 `GET /v1/im/search/conversations?q=&cursor=`。 - 本地缓存按 `tenant_code + user_id` 隔离。切租户、退出或账号变化时不得复用另一身份的会话摘要。 ### 2.2 消息-无网络状态.png @@ -532,6 +707,25 @@ X-Tinode-APIKey: <预置的普通 API Key> - 已读:`POST /v1/im/system-notifications/{id}/read`,带幂等键;跳转目标只接受服务端白名单 `action.type` 和内部参数。 - 当前普通认证用户可向 `sys` Topic 发布的路径必须封禁。客户端不得渲染普通用户伪造的系统卡片为可信通知。 +### 2.6 接口示例 + +以下示例用于本章 HTTP 联调快速查阅。除特别说明外: + +- 产品 HTTP 均使用 `https://{host}/{api_path}v1/im`,请求头包含 `X-Tinode-APIKey`、`X-IM-Schema: 1`、`X-Request-ID`;登录后接口再带 `Authorization: Bearer `。 +- 写接口必须带 `Idempotency-Key`。二次认证接口按正文说明额外带 `X-Action-Token` 或 `X-Risk-Action-Token`。 +- 成功响应统一包裹为 `{"code":0,"data":示例,"request_id":"...","server_time":"..."}`;下表只展示 `data`。 +- 失败响应统一包裹为 `{"code":30000,"reason":"MALFORMED_REQUEST","message":"...","details":{},"request_id":"...","server_time":"..."}`。 +- 标注“待实现/需修复”的接口是产品目标协议,前端可按示例准备,但联调前以后端发布状态为准。 + +#### 2.6.1 登录后启动、消息首页与系统通知 + +| 接口 | 输入示例 | 成功 `data` 示例 | +| --- | --- | --- | +| `GET /v1/im/bootstrap` | Query only | `{"features":{"create_group":true,"user_search":true,"scan_qr":true,"system_notifications":true,"message_recall":true,"message_edit":true},"permissions":{"create_group":true,"user_search":true,"scan_qr":true},"entries":{"new_friends":{"enabled":true,"route":"/contacts/new-friends","unread_count":0},"groups":{"enabled":true,"route":"/contacts/groups","unread_count":0},"file_assistant":{"enabled":true,"route":"/chat/file-assistant","topic":"slf","unread_count":0},"system_messages":{"enabled":true,"route":"/chat/system","topic":"sys","unread_count":0}},"entry_badges":{"new_friends_pending_count":0,"groups_unread_count":0,"file_assistant_unread_count":0,"system_unread_count":0,"workbench_total_unread":0},"system_topic":{"topic":"sys","type":"system"},"message_policy":{"recall_window_seconds":120,"edit_window_seconds":300,"supported_actions":["copy","quote","delete","recall","edit"]},"security":{"realname":{"status":"unverified","required":false,"allow_skip":true,"remind_after":"24h"}}}` | +| `GET /v1/im/search/conversations?q=&cursor=` | Query only | `{"items":[{"topic":"grp...","public":{"name":"项目群"},"private":{"pinned":false,"mute_notifications":false},"seq":12,"read":10,"recv":12,"unread":2,"updated_at":"2026-08-20T09:00:00Z"}],"next_cursor":""}` | +| `GET /v1/im/system-notifications?cursor=&limit=20` | Query only | `{"items":[{"id":"evt_1","event_type":"friend.request.created","title":"新的好友申请","summary":"张三请求添加你为好友","action":{"type":"friend_request","request_id":"frq_1"},"read_at":null,"created_at":"2026-08-20T09:00:00Z"}],"next_cursor":""}` | +| `POST /v1/im/system-notifications/{id}/read` | `{"read_at":"2026-08-20T09:00:00Z"}` | `{"status":"ok","id":"evt_1","read_at":"2026-08-20T09:00:00Z"}` | + ## 3. 单聊会话、会话建立与消息状态 ### 3.1 个人对话框(对方的状态).png @@ -651,7 +845,7 @@ X-Tinode-APIKey: <预置的普通 API Key> - 选人:`GET /v1/im/friends?query=&cursor=`;选择后发送 `x-im-type:"contact_card"`。 - `content` 为 `{"user_id":"usr...","display_name":"张三","avatar_file_id":"...","public_account":"A1024"}`;服务端按目标用户当前公开资料生成/校正快照,禁止手机号、证件号等敏感字段。 -- 点击卡片调用 `GET /v1/im/users/{user_id}/profile`;用户不可见或已离职时显示失效卡片,不跨租户查询。 +- 点击卡片调用 `GET /v1/im/users/{identifier}/profile`;`identifier` 可传 `user_id`、`username` 或 `public_account`,用户不可见或已离职时显示失效卡片,不跨租户查询。 ## 4. 消息输入、媒体内容与消息操作 @@ -846,6 +1040,34 @@ X-Tinode-APIKey: <预置的普通 API Key> - 快照保留显示名、时间和内容摘要,对撤回/敏感字段按策略脱敏;不得允许接收端利用 Bundle 获取原会话额外权限。 - 多目标共享不可变 Bundle 内容但分别生成目标消息与授权记录。 +### 4.21 会话与消息接口示例 + +以下示例用于本章 HTTP 联调快速查阅。除特别说明外: + +- 产品 HTTP 均使用 `https://{host}/{api_path}v1/im`,请求头包含 `X-Tinode-APIKey`、`X-IM-Schema: 1`、`X-Request-ID`;登录后接口再带 `Authorization: Bearer `。 +- 写接口必须带 `Idempotency-Key`。二次认证接口按正文说明额外带 `X-Action-Token` 或 `X-Risk-Action-Token`。 +- 成功响应统一包裹为 `{"code":0,"data":示例,"request_id":"...","server_time":"..."}`;下表只展示 `data`。 +- 失败响应统一包裹为 `{"code":30000,"reason":"MALFORMED_REQUEST","message":"...","details":{},"request_id":"...","server_time":"..."}`。 +- 标注“待实现/需修复”的接口是产品目标协议,前端可按示例准备,但联调前以后端发布状态为准。 + +#### 4.21.1 会话上下文、消息操作与检索 + +| 接口 | 输入示例 | 成功 `data` 示例 | +| --- | --- | --- | +| `GET /v1/im/conversations/{topic}/context` | Path `p2p...` 或 `grp...` | `{"topic":"p2p...","type":"p2p","relationship":"friend","can_send":true,"capabilities":{"text":true,"image":true,"file":true,"voice":true,"can_call":true},"peer":{"user_id":"usr...","display_name":"张三","avatar_file_id":""}}` | +| `POST /v1/im/conversations/p2p` 待实现 | `{"peer_user_id":"usr..."}` | `{"topic":"p2p...","relationship":"friend","can_send":true}` | +| `GET /v1/im/conversations/{topic}/settings` 待实现 | Path topic | `{"pinned":false,"mute_notifications":false,"message_retention":"default","blocked":false,"remark":"","version":1,"permissions":{"can_update":true}}` | +| `PATCH /v1/im/conversations/{topic}/settings` 待实现 | `{"pinned":true,"mute_notifications":false,"version":1}` | `{"pinned":true,"mute_notifications":false,"message_retention":"default","blocked":false,"remark":"","version":2}` | +| `GET /v1/im/conversations/{topic}/messages` 待实现 | Query `?since=12&limit=20` | `{"topic":"p2p...","items":[{"seq":13,"from":"usr...","ts":"2026-08-20T09:00:00Z","head":{"x-im-type":"text"},"content":{"text":"你好"}}],"next_cursor":""}` | +| `PUT /v1/im/topics/{topic}/draft` 待实现 | `{"content":"未发送草稿","version":1}` | `{"topic":"grp...","content":"未发送草稿","version":2,"updated_at":"2026-08-20T09:00:00Z"}` | +| `POST /v1/im/topics/{topic}/messages/{seq}/recall` | `{"version":1,"reason":"mistake"}` | `{"topic":"grp...","seq":12,"status":"recalled","version":2,"updated_at":"2026-08-20T09:00:00Z"}` | +| `POST /v1/im/topics/{topic}/messages/{seq}/revisions` | `{"content":{"text":"修改后的文本"},"client_mid":"mid_1","version":1}` | `{"topic":"grp...","seq":12,"status":"edited","version":2,"updated_at":"2026-08-20T09:00:00Z"}` | +| `GET /v1/im/topics/{topic}/messages/{seq}/receipts?cursor=` | Query only | `{"topic":"grp...","seq":12,"items":[{"user_id":"usr...","read_at":"2026-08-20T09:00:00Z","recv_at":"2026-08-20T08:59:00Z"}],"next_cursor":""}` | +| `GET /v1/im/topics/{topic}/messages/search?q=&type=text&cursor=` | Query only | `{"topic":"grp...","items":[{"seq":12,"from":"usr...","ts":"2026-08-20T09:00:00Z","snippet":"命中的文本","matches":[{"start":0,"end":2}]}],"next_cursor":""}` | +| `GET /v1/im/topics/{topic}/messages/search?type=file&q=&cursor=` | Query only | `{"topic":"grp...","items":[{"seq":12,"from":"usr...","ts":"2026-08-20T09:00:00Z","file":{"file_id":"file_1","name":"方案.pdf","size":1024,"mime":"application/pdf"},"availability":"available"}],"next_cursor":""}` | +| `GET /v1/im/topics/{topic}/messages/{seq}/context?before=20&after=20` | Query only | `{"topic":"grp...","anchor_seq":12,"items":[{"seq":11,"content":{"text":"上一条"}},{"seq":12,"content":{"text":"命中消息"}}]}` | +| `GET /v1/im/topics/{topic}/media?type=image&cursor=` | Query only | `{"topic":"grp...","items":[{"seq":12,"thumbnail_file_id":"file_thumb","file_id":"file_img","width":1080,"height":720,"ts":"2026-08-20T09:00:00Z","availability":"available"}],"next_cursor":""}` | + ## 5. 通讯录、好友关系与黑名单 ### 5.1 通讯录.png @@ -855,8 +1077,9 @@ X-Tinode-APIKey: <预置的普通 API Key> **状态:新增产品通讯录接口。** - 好友列表:`GET /v1/im/friends?query=&initial=&cursor=`,返回 `user_id,display_name,remark,avatar_file_id,initial,online_visibility`;显示名优先级为备注名、昵称、账号。 +- 分页:`next_cursor` 当前按最后一条好友的 `peer_id` 生成,客户端必须作为不透明字符串原样回传,不要自行解析、排序或拼接。 - 组织成员:`GET /v1/im/organization/members?query=&department_id=&cursor=`;好友和组织成员是两种关系,前端不得混为一个可发消息权限。 -- 新朋友未处理数、群列表、文件助手和系统消息入口由 `GET /v1/im/bootstrap` 返回。列表缓存以租户和用户隔离。 +- 新朋友、群列表、文件助手和系统消息入口由 `GET /v1/im/bootstrap` 的 `entries` 控制显示,初始红点由 `entry_badges` 和各入口 `unread_count` 返回。新朋友待处理数使用业务统计;文件助手当前返回 `topic:"slf", unread_source:"topic"`;系统消息当前返回 `topic:"sys", unread_source:"business"`,未读来自系统通知业务表。列表缓存以租户和用户隔离。 ### 5.2 新朋友.png @@ -885,7 +1108,7 @@ X-Tinode-APIKey: <预置的普通 API Key> **状态:新增。** - 获取二维码:`POST /v1/im/me/qrcodes`,Body `{"purpose":"add_friend","expires_in_seconds":86400}`,返回 `qr_token,qr_payload,expires_at,display_profile`。 -- 二维码只含不透明、签名、带租户和用途约束的短期 Token,不直接放手机号、用户 ID 或 access token。 +- 二维码只含不透明、签名、带租户和用途约束的短期 Token,不直接放手机号、用户 ID 或登录 Token。 - 分享/保存图片是客户端动作;扫码解析始终调用 5.3,过期返回 `QR_CODE_EXPIRED`,篡改返回 `QR_CODE_INVALID`。 ### 5.5 添加好友-输入精准用户ID.png @@ -914,7 +1137,8 @@ X-Tinode-APIKey: <预置的普通 API Key> **状态:新增。** -- `GET /v1/im/users/{user_id}/profile` 返回按当前查看人裁剪后的 `display_name,remark,avatar_file_id,gender,region,public_account,signature,relationship,capabilities`。 +- `GET /v1/im/users/{identifier}/profile` 返回按当前查看人裁剪后的 `display_name,remark,avatar_file_id,gender,region,public_account,signature,relationship,capabilities`;`identifier` 可传 `user_id`、`username` 或 `public_account`。 +- 查看该用户二维码调用 `POST /v1/im/users/{identifier}/qrcodes`,Body `{"purpose":"add_friend","expires_in_seconds":86400}`;前端在个人信息页只有用户名时直接把用户名作为 `identifier` 传入即可。服务端解析到真实用户后生成二维码,并按资料可见性与 `add_me_methods.qr` 做权限校验。 - 发消息使用返回的 P2P `topic`;没有 Topic 时调用 `POST /v1/im/conversations/p2p`,Body `{"peer_user_id":"..."}` 并由服务端验证好友关系。 - 音视频按钮只按 `capabilities.can_call` 展示,真正呼叫时再次鉴权。隐藏字段必须从响应中省略,不能只由前端遮挡。 @@ -988,6 +1212,39 @@ X-Tinode-APIKey: <预置的普通 API Key> - 不解除好友、不影响对方历史、不删除收藏;附件仅在无任何有效引用且过保留期后回收。 - 前端需在确认文案明确“仅清除自己的聊天记录”,成功后清本地 Topic 消息缓存并保留会话关系。 +### 5.15 接口示例 + +以下示例用于本章 HTTP 联调快速查阅。除特别说明外: + +- 产品 HTTP 均使用 `https://{host}/{api_path}v1/im`,请求头包含 `X-Tinode-APIKey`、`X-IM-Schema: 1`、`X-Request-ID`;登录后接口再带 `Authorization: Bearer `。 +- 写接口必须带 `Idempotency-Key`。二次认证接口按正文说明额外带 `X-Action-Token` 或 `X-Risk-Action-Token`。 +- 成功响应统一包裹为 `{"code":0,"data":示例,"request_id":"...","server_time":"..."}`;下表只展示 `data`。 +- 失败响应统一包裹为 `{"code":30000,"reason":"MALFORMED_REQUEST","message":"...","details":{},"request_id":"...","server_time":"..."}`。 +- 标注“待实现/需修复”的接口是产品目标协议,前端可按示例准备,但联调前以后端发布状态为准。 + +#### 5.15.1 通讯录、好友、黑名单与二维码 + +| 接口 | 输入示例 | 成功 `data` 示例 | +| --- | --- | --- | +| `GET /v1/im/friends?query=&initial=&cursor=` | Query only | `{"items":[{"user_id":"usr...","display_name":"张三","remark":"","avatar_file_id":"","initial":"Z","topic":"p2p...","relationship":"friend","online_visibility":"unknown","capabilities":{"can_send":true,"can_call":true}}],"next_cursor":""}` | +| `GET /v1/im/friends?query=&cursor=` | Query only | `{"items":[{"user_id":"usr...","display_name":"张三","topic":"p2p...","relationship":"friend"}],"next_cursor":""}` | +| `GET /v1/im/friend-requests?direction=inbound&status=all&cursor=` | Query only | `{"items":[{"request_id":"frq_1","direction":"inbound","applicant_id":"usr_a","target_user_id":"usr_b","peer":{"user_id":"usr_a","display_name":"张三"},"message":"我是张三","source":"search","status":"pending","created_at":"2026-08-20T09:00:00Z"}],"next_cursor":""}` | +| `POST /v1/im/friend-requests` | `{"target_user_id":"usr...","message":"验证留言","source":"search","source_topic":""}` | `{"request_id":"frq_1","applicant_id":"usr_me","target_user_id":"usr...","message":"验证留言","source":"search","status":"pending","topic":"p2p...","created_at":"2026-08-20T09:00:00Z","expires_at":"2026-08-27T09:00:00Z"}` | +| `POST /v1/im/friend-requests/{request_id}/accept` | `{"version":1}` | `{"request_id":"frq_1","status":"accepted","topic":"p2p...","handled_at":"2026-08-20T09:00:00Z"}` | +| `POST /v1/im/friend-requests/{request_id}/reject` | `{"version":1}` | `{"request_id":"frq_1","status":"rejected","handled_at":"2026-08-20T09:00:00Z"}` | +| `GET /v1/im/users/search?q=&type=account&cursor=` | Query only | `{"items":[{"user_id":"usr...","display_name":"张三","avatar_file_id":"","public_account":"zhangsan","relationship":"none","can_add":true}],"next_cursor":""}` | +| `GET /v1/im/users/search?q=&type=exact_account` | Query only | `{"items":[{"user_id":"usr...","display_name":"张三","public_account":"zhangsan","relationship":"none","can_add":true}],"next_cursor":""}` | +| `GET /v1/im/users/{identifier}/profile` | Path `user_id`/`username`/`public_account` | `{"user_id":"usr...","display_name":"张三","remark":"","avatar_file_id":"","gender":"","region":"","public_account":"zhangsan","signature":"","relationship":"friend","capabilities":{"can_send":true,"can_call":true,"can_add":false},"topic":"p2p..."}` | +| `PATCH /v1/im/friends/{user_id}` | `{"remark":"老张"}` | `{"user_id":"usr...","remark":"老张","relationship":"friend","updated_at":"2026-08-20T09:00:00Z"}` | +| `DELETE /v1/im/friends/{user_id}` | Path user ID | `{"user_id":"usr...","relationship":"none","deleted":true}` | +| `GET /v1/im/blocks?cursor=` | Query only | `{"items":[{"user":{"user_id":"usr...","display_name":"张三","avatar_file_id":""},"blocked_at":"2026-08-20T09:00:00Z"}],"next_cursor":""}` | +| `PUT /v1/im/blocks/{user_id}` | `{}` | `{"user_id":"usr...","blocked":true,"blocked_at":"2026-08-20T09:00:00Z"}` | +| `DELETE /v1/im/blocks/{user_id}` | Path user ID | `{"user_id":"usr...","blocked":false}` | +| `POST /v1/im/blocks/batch-delete` | `{"user_ids":["usr_a","usr_b"]}` | `{"items":[{"user_id":"usr_a","blocked":false}],"failed":[]}` | +| `POST /v1/im/me/qrcodes` | `{"purpose":"add_friend","expires_in_seconds":86400}` | `{"qr_token":"token...","qr_payload":"im://qr?token=...&purpose=add_friend","expires_at":"2026-08-21T09:00:00Z","display_profile":{"user_id":"usr...","display_name":"张三"}}` | +| `POST /v1/im/users/{identifier}/qrcodes` | `{"purpose":"add_friend","expires_in_seconds":86400}` | `{"qr_token":"token...","qr_payload":"im://qr?token=...&purpose=add_friend","expires_at":"2026-08-21T09:00:00Z","display_profile":{"user_id":"usr...","display_name":"张三","public_account":"zhangsan"}}` | +| `POST /v1/im/qrcodes/resolve` | `{"token":"token...","purpose":"add_friend"}` | `{"purpose":"add_friend","user":{"user_id":"usr...","display_name":"张三"},"relationship":"none","can_add":true}` | + ## 6. 群聊创建、资料维护与成员治理 ### 6.1 群聊.png @@ -997,7 +1254,7 @@ X-Tinode-APIKey: <预置的普通 API Key> **状态:Tinode 群 Topic 已实现基础能力;产品群列表与配额需改造。** - 列表:`GET /v1/im/groups?category=owned|managed|joined&query=&cursor=`,返回 `topic,name,avatar_file_id,member_count,my_role,updated_at`。 -- 创建:`POST /v1/im/groups`,Body `{"name":"...","member_user_ids":[],"avatar_file_id":"..."}`;原子校验租户群数上限和群成员上限,返回 `topic`。 +- 创建:`POST /v1/im/groups`,Body `{"name":"...","description":"...","member_user_ids":[],"avatar_file_id":"..."}`;`description` 可省略或传空字符串,最长 200 字符;原子校验租户群数上限和群成员上限,返回 `topic,name,avatar_file_id,description,owner,member_count,version,permissions`。 - 分类由服务端 `my_role` 决定;创建成功或角色变化后通过系统事件刷新,不由前端自行移动分组。 ### 6.2 空白页.png @@ -1263,6 +1520,61 @@ X-Tinode-APIKey: <预置的普通 API Key> - 服务端校验 `member_upload_files`、单文件上限、租户总存储和群成员身份;成功返回完整群文件项。 - 上传失败可复用 `upload_id`;登记失败不得在列表显示,孤立文件按 TTL 回收。 +### 6.29 接口示例 + +以下示例用于本章 HTTP 联调快速查阅。除特别说明外: + +- 产品 HTTP 均使用 `https://{host}/{api_path}v1/im`,请求头包含 `X-Tinode-APIKey`、`X-IM-Schema: 1`、`X-Request-ID`;登录后接口再带 `Authorization: Bearer `。 +- 写接口必须带 `Idempotency-Key`。二次认证接口按正文说明额外带 `X-Action-Token` 或 `X-Risk-Action-Token`。 +- 成功响应统一包裹为 `{"code":0,"data":示例,"request_id":"...","server_time":"..."}`;下表只展示 `data`。 +- 失败响应统一包裹为 `{"code":30000,"reason":"MALFORMED_REQUEST","message":"...","details":{},"request_id":"...","server_time":"..."}`。 +- 标注“待实现/需修复”的接口是产品目标协议,前端可按示例准备,但联调前以后端发布状态为准。 + +#### 6.29.1 群聊、群成员与群治理 + +| 接口 | 输入示例 | 成功 `data` 示例 | +| --- | --- | --- | +| `GET /v1/im/groups?category=owned&query=&cursor=` | Query only | `{"items":[{"topic":"grp...","name":"项目群","avatar_file_id":"","member_count":3,"my_role":"owner","updated_at":"2026-08-20T09:00:00Z"}],"next_cursor":"","has_more":false}` | +| `GET /v1/im/groups?category=owned\|managed\|joined&query=&cursor=` | Query only | `{"items":[{"topic":"grp...","name":"项目群","avatar_file_id":"","member_count":3,"my_role":"admin","updated_at":"2026-08-20T09:00:00Z"}],"next_cursor":"","has_more":false}` | +| `POST /v1/im/groups` | `{"name":"项目群","description":"项目沟通","member_user_ids":["usr..."],"avatar_file_id":""}` | `{"topic":"grp...","name":"项目群","avatar_file_id":"","description":"项目沟通","owner":"usr_me","member_count":2,"version":1,"my_role":"owner","permissions":{"can_edit_profile":true,"can_invite_members":true,"can_manage_members":true,"can_manage_settings":true,"can_leave":false}}` | +| `GET /v1/im/groups/{topic}` | Path topic | `{"topic":"grp...","name":"项目群","avatar_file_id":"","description":"项目沟通","owner":"usr_me","member_count":2,"version":1,"my_role":"owner","permissions":{"can_edit_profile":true}}` | +| `PATCH /v1/im/groups/{topic}` | `{"name":"新群名","avatar_file_id":"file_avatar","description":"新简介","version":1}` | `{"topic":"grp...","name":"新群名","avatar_file_id":"file_avatar","description":"新简介","version":2,"permissions":{"can_edit_profile":true}}` | +| `DELETE /v1/im/groups/{topic}` 待实现 | Header `X-Action-Token: action_token`;Body `{}` | `{"topic":"grp...","deleted":true}` | +| `GET /v1/im/groups/{topic}/settings` | Path topic | `{"topic":"grp...","profile":{"name":"项目群","description":"项目沟通"},"pinned":false,"mute_notifications":false,"member_count":2,"permissions":{"can_manage_settings":true},"governance":{"join_approval":false,"mute_all":false,"member_invite":true}}` | +| `PATCH /v1/im/groups/{topic}/member-settings` | `{"pinned":true,"mute_notifications":false,"version":1}` | `{"topic":"grp...","pinned":true,"mute_notifications":false,"version":2,"updated_at":"2026-08-20T09:00:00Z"}` | +| `PUT /v1/im/groups/{topic}/members/me/profile` | `{"nickname":"我在群里的昵称","version":1}` | `{"nickname":"我在群里的昵称","version":2,"updated_at":"2026-08-20T09:00:00Z"}` | +| `POST /v1/im/groups/{topic}/leave` | `{}` | `{"topic":"grp...","status":"left"}` | +| `GET /v1/im/groups/{topic}/members?query=&cursor=&mentionable=true` | Query only | `{"topic":"grp...","items":[{"user_id":"usr...","display_name":"张三","avatar_file_id":"","role":"member","mentionable":true,"acs":{"want":"JRWP","given":"JRWP","mode":"JRWP"}}],"next_cursor":"","permissions":{"can_mention_all":true}}` | +| `GET /v1/im/groups/{topic}/members/{user_id}/profile` | Path topic/user | `{"topic":"grp...","user_id":"usr...","display_name":"张三","group_nickname":"小张","role":"member","relationship":"friend","capabilities":{"can_send":true,"can_call":true}}` | +| `POST /v1/im/groups/{topic}/qrcodes` | `{"expires_in_seconds":86400}` | `{"qr_token":"token...","qr_payload":"im://qr?token=...&purpose=join_group","expires_at":"2026-08-21T09:00:00Z","group_snapshot":{"topic":"grp...","name":"项目群","member_count":2}}` | +| `POST /v1/im/groups/{topic}/invitations` | `{"user_ids":["usr..."],"message":"进群一起讨论"}` | `{"topic":"grp...","items":[{"user_id":"usr...","status":"joined","invitation_id":"ginv_1"}]}` | +| `POST /v1/im/group-invitations/{invitation_id}/accept` 待实现 | `{}` | `{"invitation_id":"ginv_1","status":"joined","topic":"grp..."}` | +| `POST /v1/im/groups/{topic}/join-requests` | `{"message":"申请入群","source":"qr","qr_token":"token..."}` | `{"request_id":"gjr_1","topic":"grp...","status":"pending","created_at":"2026-08-20T09:00:00Z","version":1}` | +| `GET /v1/im/groups/{topic}/join-requests?status=pending&cursor=` | Query only | `{"topic":"grp...","items":[{"request_id":"gjr_1","applicant":{"user_id":"usr...","display_name":"张三"},"status":"pending","source":"qr","created_at":"2026-08-20T09:00:00Z","version":1}],"next_cursor":""}` | +| `POST /v1/im/groups/{topic}/join-requests/{id}/accept` | `{"version":1}` | `{"request_id":"gjr_1","topic":"grp...","status":"accepted","handled_at":"2026-08-20T09:00:00Z","version":2}` | +| `POST /v1/im/groups/{topic}/join-requests/{id}/reject` | `{"version":1,"reason":"人数已满"}` | `{"request_id":"gjr_1","topic":"grp...","status":"rejected","handled_at":"2026-08-20T09:00:00Z","version":2}` | +| `GET /v1/im/groups/{topic}/summary` | Path topic | `{"topic":"grp...","pending_join_request_count":0,"announcement":{"announcement_id":"ann_1","content":"公告"},"permissions":{"can_manage_members":true}}` | +| `GET /v1/im/groups/{topic}/announcements/latest` | Path topic | `{"announcement_id":"ann_1","topic":"grp...","content":"公告内容","version":1,"published_at":"2026-08-20T09:00:00Z","permissions":{"can_edit":true}}` | +| `PUT /v1/im/groups/{topic}/announcements/current` | `{"content":"新的公告","version":1}` | `{"announcement_id":"ann_1","topic":"grp...","content":"新的公告","version":2,"published_at":"2026-08-20T09:00:00Z"}` | +| `GET /v1/im/groups/{topic}/announcements/{announcement_id}` | Path announcement | `{"announcement_id":"ann_1","topic":"grp...","content":"公告内容","version":2,"published_at":"2026-08-20T09:00:00Z"}` | +| `POST /v1/im/groups/{topic}/announcements/{id}/read` | `{}` | `{"announcement_id":"ann_1","read_at":"2026-08-20T09:00:00Z"}` | +| `GET /v1/im/groups/{topic}/governance` | Path topic | `{"join_approval":false,"mute_all":false,"member_upload_files":true,"member_private_chat":true,"member_invite":true,"version":1}` | +| `PATCH /v1/im/groups/{topic}/governance` | `{"join_approval":true,"member_invite":false,"version":1}` | `{"join_approval":true,"member_invite":false,"version":2}` | +| `GET /v1/im/groups/{topic}/role-limits` | Path topic | `{"max_admin_count":10,"current_admin_count":1}` | +| `GET /v1/im/groups/{topic}/members?role=admin&cursor=` | Query only | `{"topic":"grp...","items":[{"user_id":"usr_admin","display_name":"管理员","role":"admin"}],"next_cursor":""}` | +| `GET /v1/im/groups/{topic}/members?eligible_role=admin&query=&cursor=` | Query only | `{"topic":"grp...","items":[{"user_id":"usr...","display_name":"张三","role":"member","eligible":true}],"next_cursor":""}` | +| `POST /v1/im/groups/{topic}/admins/batch` | `{"user_ids":["usr..."],"version":1}` | `{"succeeded":[{"user_id":"usr...","role":"admin"}],"admins":[{"user_id":"usr...","role":"admin"}],"version":2,"updated_at":"2026-08-20T09:00:00Z"}` | +| `DELETE /v1/im/groups/{topic}/admins/{user_id}` | Header `If-Match: 2` 或 Body version | `{"user_id":"usr...","role":"member","updated_at":"2026-08-20T09:00:00Z"}` | +| `GET /v1/im/groups/{topic}/members?mute_eligible=true&query=&cursor=` | Query only | `{"topic":"grp...","items":[{"user_id":"usr...","display_name":"张三","eligible":true}],"next_cursor":""}` | +| `POST /v1/im/groups/{topic}/mutes/batch` | `{"members":[{"user_id":"usr...","expires_at":null}],"reason":"刷屏"}` | `{"topic":"grp...","items":[{"user_id":"usr...","muted":true,"muted_at":"2026-08-20T09:00:00Z","expires_at":null}]}` | +| `GET /v1/im/groups/{topic}/mutes?cursor=` | Query only | `{"topic":"grp...","items":[{"user":{"user_id":"usr...","display_name":"张三"},"muted_by":"usr_admin","muted_at":"2026-08-20T09:00:00Z","expires_at":null}],"next_cursor":""}` | +| `DELETE /v1/im/groups/{topic}/mutes/{user_id}` | Path user | `{"user_id":"usr...","muted":false}` | +| `GET /v1/im/groups/{topic}/members?eligible_role=owner&query=&cursor=` | Query only | `{"topic":"grp...","items":[{"user_id":"usr...","display_name":"张三","eligible":true}],"next_cursor":""}` | +| `POST /v1/im/groups/{topic}/owner-transfer` | Header `X-Action-Token: action_token`;Body `{"target_user_id":"usr...","version":2}` | `{"owner":"usr...","previous_owner":"usr_old","version":3}` | +| `GET /v1/im/groups/{topic}/files?query=&type=&cursor=` 待修复 | Query only | `{"topic":"grp...","items":[{"group_file_id":"gfile_1","file_id":"file_1","name":"方案.pdf","size":1024,"mime":"application/pdf","uploader":{"user_id":"usr...","display_name":"张三"},"created_at":"2026-08-20T09:00:00Z","download_state":"available","permissions":{"can_delete":true}}],"next_cursor":""}` | +| `POST /v1/im/groups/{topic}/files` 待修复 | `{"file_id":"file_1"}` | `{"group_file_id":"gfile_1","file_id":"file_1","topic":"grp...","created_at":"2026-08-20T09:00:00Z"}` | +| `DELETE /v1/im/groups/{topic}/files/{group_file_id}` 待修复 | Path group file ID | `{"group_file_id":"gfile_1","deleted":true}` | + ## 7. 文件下载、收藏管理与聊天记录检索 ### 7.1 我的收藏.png @@ -1359,7 +1671,7 @@ X-Tinode-APIKey: <预置的普通 API Key> - 下载任务保存 `file_id,etag,total_bytes,downloaded_bytes,temp_path,state`;恢复时携带 `Range` 与 `If-Range: `。 - ETag 改变或服务端返回完整 200 时丢弃旧分片重新下载;取消时关闭连接并清理临时文件。 -- Token 过期先完成一次刷新后续传,不能把 Token 写入任务文件或日志。 +- 下载中断后,如果客户端通过有效 Token 登录取得了新 Token,则使用新 Token 和原 Range 状态续传;Token 已过期或失效时暂停任务并返回登录页。不能把 Token 写入任务文件或日志。 ### 7.11 文件-下载完成.png @@ -1416,6 +1728,32 @@ X-Tinode-APIKey: <预置的普通 API Key> - 调用 7.15 接口,`type=video`,额外返回 `duration_ms,mime,size`。 - 播放器使用支持 Range 的认证下载/流式读取;文件失效、Token 过期和网络中断分别映射可理解状态,返回列表保持游标与滚动位置。 +### 7.17 接口示例 + +以下示例用于本章 HTTP 联调快速查阅。除特别说明外: + +- 产品 HTTP 均使用 `https://{host}/{api_path}v1/im`,请求头包含 `X-Tinode-APIKey`、`X-IM-Schema: 1`、`X-Request-ID`;登录后接口再带 `Authorization: Bearer `。 +- 写接口必须带 `Idempotency-Key`。二次认证接口按正文说明额外带 `X-Action-Token` 或 `X-Risk-Action-Token`。 +- 成功响应统一包裹为 `{"code":0,"data":示例,"request_id":"...","server_time":"..."}`;下表只展示 `data`。 +- 失败响应统一包裹为 `{"code":30000,"reason":"MALFORMED_REQUEST","message":"...","details":{},"request_id":"...","server_time":"..."}`。 +- 标注“待实现/需修复”的接口是产品目标协议,前端可按示例准备,但联调前以后端发布状态为准。 + +#### 7.17.1 收藏、转发与文件安全 + +| 接口 | 输入示例 | 成功 `data` 示例 | +| --- | --- | --- | +| `GET /v1/im/favorites?type=all&q=&cursor=` | Query only | `{"items":[{"favorite_id":"fav_1","type":"text","snapshot":{"text":"收藏内容"},"source":{"topic":"grp...","seq":12},"collected_at":"2026-08-20T09:00:00Z","availability":"available"}],"next_cursor":""}` | +| `GET /v1/im/favorites?type=all\|text\|image\|video\|voice\|file\|link&q=&cursor=` | Query only | `{"items":[{"favorite_id":"fav_1","type":"image","snapshot":{"thumbnail_file_id":"file_thumb","file_id":"file_img"},"source":{"topic":"grp...","seq":12},"collected_at":"2026-08-20T09:00:00Z","availability":"available"}],"next_cursor":""}` | +| `POST /v1/im/favorites/batch` | `{"messages":[{"topic":"grp...","seq":"12"}]}` | `{"created_count":1,"existing_count":0,"failed":[]}` | +| `GET /v1/im/favorites/{favorite_id}` | Path favorite ID | `{"favorite_id":"fav_1","type":"file","snapshot":{"file_id":"file_1","name":"a.pdf"},"source":{"topic":"grp...","seq":12},"availability":"available"}` | +| `DELETE /v1/im/favorites/{favorite_id}` | Path favorite ID | `{"favorite_id":"fav_1","deleted":true}` | +| `POST /v1/im/favorites/batch-delete` | `{"favorite_ids":["fav_1","fav_2"]}` | `{"items":[{"favorite_id":"fav_1","deleted":true},{"favorite_id":"fav_2","deleted":true}],"failed":[]}` | +| `POST /v1/im/favorites/forward` | `{"favorite_ids":["fav_1"],"target_topics":["grp..."],"comment":"转给你"}` | `{"items":[{"favorite_id":"fav_1","target_topic":"grp...","status":"sent","seq":20}],"failed":[]}` | +| `GET /v1/im/forward-targets?query=&cursor=` | Query only | `{"items":[{"topic":"grp...","type":"group","display_name":"项目群","avatar_file_id":"","can_receive_forward":true},{"topic":"slf","type":"file_assistant","display_name":"文件助手","can_receive_forward":true}],"next_cursor":"","has_more":false}` | +| `POST /v1/im/messages/forward` 待实现 | `{"mode":"separate","sources":[{"topic":"grp...","seq":"12"}],"target_topics":["p2p..."],"comment":""}` | `{"items":[{"source":{"topic":"grp...","seq":"12"},"target_topic":"p2p...","client_mid":"mid_1","seq":30,"status":"sent"}],"failed":[]}` | +| `GET /v1/im/forward-bundles/{bundle_id}?cursor=` 待实现 | Query only | `{"bundle_id":"fbundle_1","title":"聊天记录","item_count":2,"items":[{"from_display":"张三","ts":"2026-08-20T09:00:00Z","summary":"文本摘要"}],"next_cursor":""}` | +| `POST /v1/im/security/urls/check` | `{"url":"https://example.com","source":"favorite"}` | `{"url":"https://example.com","decision":"allow","risk_level":"low","reason":"ok","source":"favorite"}` | + ## 8. 实时语音与视频通话 ### 8.1 各种通话提示状态.png @@ -1527,6 +1865,25 @@ X-Tinode-APIKey: <预置的普通 API Key> - 最小化不结束 WebRTC 或 WebSocket,会话仍以同一 `call_id` 运行;点击恢复全屏不发送新 invite。 - 系统悬浮窗权限、画中画和安全区域按平台实现。收到 `hang-up`、Token 失效或媒体会话结束后立即关闭悬浮窗并落正确结果态。 +### 8.12 接口示例 + +以下示例用于本章 HTTP 联调快速查阅。除特别说明外: + +- 产品 HTTP 均使用 `https://{host}/{api_path}v1/im`,请求头包含 `X-Tinode-APIKey`、`X-IM-Schema: 1`、`X-Request-ID`;登录后接口再带 `Authorization: Bearer `。 +- 写接口必须带 `Idempotency-Key`。二次认证接口按正文说明额外带 `X-Action-Token` 或 `X-Risk-Action-Token`。 +- 成功响应统一包裹为 `{"code":0,"data":示例,"request_id":"...","server_time":"..."}`;下表只展示 `data`。 +- 失败响应统一包裹为 `{"code":30000,"reason":"MALFORMED_REQUEST","message":"...","details":{},"request_id":"...","server_time":"..."}`。 +- 标注“待实现/需修复”的接口是产品目标协议,前端可按示例准备,但联调前以后端发布状态为准。 + +#### 8.12.1 实时通话与通话记录 + +| 接口 | 输入示例 | 成功输出示例 | +| --- | --- | --- | +| WebSocket 发起通话 | `{"pub":{"id":"call-1","topic":"p2p...","head":{"mime":"application/json","webrtc":"started","x-im-type":"call"},"content":{"callId":"call_1","callType":"audio","audio":true,"video":false}}}` | `{"ctrl":{"id":"call-1","code":202,"params":{"seq":12}}}` | +| WebSocket 通话事件 | `{"note":{"topic":"p2p...","what":"call","event":"accept","seq":12,"payload":{"callId":"call_1"}}}` | 对端收到 `{"info":{"topic":"p2p...","what":"call","event":"accept","seq":12,"payload":{"callId":"call_1"}}}` | +| `GET /v1/im/calls?limit=20&cursor=` | Query only | `{"items":[{"call_id":"call_1","topic":"p2p...","seq":12,"call_type":"audio","status":"finished","started_at":"2026-08-20T09:00:00Z","duration_ms":60000,"peer":{"user_id":"usr...","display_name":"张三"}}],"next_cursor":""}` | +| `GET /v1/im/calls/{topic}?cursor=` | Path topic | `{"topic":"p2p...","items":[{"call_id":"call_1","seq":12,"status":"missed","call_type":"video","created_at":"2026-08-20T09:00:00Z"}],"next_cursor":""}` | + ## 9. 企业工作台与办公协同 ### 9.1 工作台.png @@ -1536,6 +1893,7 @@ X-Tinode-APIKey: <预置的普通 API Key> **状态:新增。** - `GET /v1/im/workbench` 返回 `modules[{code,name,icon_file_id,route,unread_count,enabled,permission}]`、布局版本和企业配置。 +- `GET /v1/im/bootstrap` 返回 `entry_badges.workbench_total_unread`,用于工作台一级入口总红点;总数只汇总当前用户可见且 `enabled=true` 的工作台模块。 - 服务端按当前 Token 的租户、组织角色和功能开关裁剪;前端只接受白名单内部 `route`,禁止直接执行服务端任意 URL。 - 未读数通过 `workbench.badge.updated` 系统事件增量更新;切租户必须重新加载,不复用另一企业工作台。 @@ -1657,6 +2015,51 @@ X-Tinode-APIKey: <预置的普通 API Key> - 文案使用 9.12 配置中的 `notice{title,content,updated_at}`,由租户后台配置;客户端不得硬编码实名/匿名承诺。 - 提交按钮前必须展示当前版本文案;请求可携带 `notice_version`,服务端发现关键政策已变更时返回 `NOTICE_RECONFIRM_REQUIRED`。 +### 9.14 接口示例 + +以下示例用于本章 HTTP 联调快速查阅。除特别说明外: + +- 产品 HTTP 均使用 `https://{host}/{api_path}v1/im`,请求头包含 `X-Tinode-APIKey`、`X-IM-Schema: 1`、`X-Request-ID`;登录后接口再带 `Authorization: Bearer `。 +- 写接口必须带 `Idempotency-Key`。二次认证接口按正文说明额外带 `X-Action-Token` 或 `X-Risk-Action-Token`。 +- 成功响应统一包裹为 `{"code":0,"data":示例,"request_id":"...","server_time":"..."}`;下表只展示 `data`。 +- 失败响应统一包裹为 `{"code":30000,"reason":"MALFORMED_REQUEST","message":"...","details":{},"request_id":"...","server_time":"..."}`。 +- 标注“待实现/需修复”的接口是产品目标协议,前端可按示例准备,但联调前以后端发布状态为准。 + +#### 9.14.1 工作台、组织、会议、日报、月报、信箱 + +| 接口 | 输入示例 | 成功 `data` 示例 | +| --- | --- | --- | +| `GET /v1/im/workbench` | Query only | `{"layout_version":1,"modules":[{"code":"organization","name":"组织架构","icon_file_id":"","route":"/organization","unread_count":0,"enabled":true,"permission":"enabled"}],"enterprise":{"tenant_code":"default","feature_flags":{"organization":true,"meetings":true}}}` | +| `GET /v1/im/organization/departments?parent_id=root&cursor=` | Query only | `{"items":[{"department_id":"dep_1","name":"研发部","parent_id":"root","child_count":0,"member_count":12,"version":1}],"next_cursor":""}` | +| `GET /v1/im/organization/departments/{id}/members?cursor=` | Query only | `{"items":[{"user_id":"usr...","display_name":"张三","avatar_file_id":"","department_id":"dep_1","title":"工程师","can_contact":true}],"next_cursor":""}` | +| `GET /v1/im/organization/members?query=&department_id=&cursor=` 待实现/可由部门成员接口替代 | Query only | `{"items":[{"user_id":"usr...","display_name":"张三","department_id":"dep_1","department_name":"研发部","can_contact":true}],"next_cursor":""}` | +| `GET /v1/im/organization/search?q=&types=department,user&cursor=` | Query only | `{"items":[{"type":"user","user_id":"usr...","display_name":"张三","department_name":"研发部","can_contact":true}],"next_cursor":""}` | +| `GET /v1/im/meetings?cursor=` 待补列表 | Query only | `{"items":[{"meeting_id":"mtg_1","title":"周会","start_at":"2026-08-21T10:00:00Z","end_at":"2026-08-21T10:30:00Z","my_response":"pending","state":"scheduled"}],"next_cursor":""}` | +| `POST /v1/im/meetings` | `{"title":"周会","start_at":"2026-08-21T10:00:00Z","end_at":"2026-08-21T10:30:00Z","location":"线上","attendee_user_ids":["usr..."],"reminder_minutes":[10]}` | `{"meeting_id":"mtg_1","title":"周会","start_at":"2026-08-21T10:00:00Z","end_at":"2026-08-21T10:30:00Z","location":"线上","participants":[{"user_id":"usr_me","response":"accepted"},{"user_id":"usr...","response":"pending"}],"permissions":{"can_update":true,"can_cancel":true,"can_remind":true,"can_respond":false},"version":1}` | +| `GET /v1/im/meetings/{meeting_id}` | Path meeting ID | `{"meeting_id":"mtg_1","title":"周会","participants":[{"user_id":"usr...","response":"pending"}],"permissions":{"can_respond":true},"version":1}` | +| `PATCH /v1/im/meetings/{id}` | `{"title":"新标题","version":1}` | `{"meeting_id":"mtg_1","title":"新标题","version":2,"updated_at":"2026-08-20T09:00:00Z"}` | +| `POST /v1/im/meetings/{id}/cancel` | `{"version":2,"reason":"时间冲突"}` | `{"meeting_id":"mtg_1","state":"cancelled","version":3}` | +| `POST /v1/im/meetings/{id}/reminders` | `{}` | `{"meeting_id":"mtg_1","items":[{"user_id":"usr...","status":"sent"}]}` | +| `PUT /v1/im/meetings/{meeting_id}/my-response` | `{"response":"accepted","version":1}` | `{"meeting_id":"mtg_1","my_response":"accepted","version":2,"response_at":"2026-08-20T09:00:00Z"}` | +| `GET /v1/im/reports/daily?scope=mine&from=&to=&status=&cursor=` | Query only | `{"items":[{"report_id":"dr_1","report_date":"2026-08-20","status":"draft","draft_updated_at":"2026-08-20T09:00:00Z","submitted_at":null,"read_state":"unread"}],"next_cursor":"","permissions":{"can_create":true,"makeup_allowed_days":7}}` | +| `GET /v1/im/reports/daily?scope=managed&date=&status=submitted\|missing&department_id=&cursor=` | Query only | `{"items":[{"report_id":"dr_1","author":{"user_id":"usr...","display_name":"张三"},"report_date":"2026-08-20","status":"submitted","submitted_at":"2026-08-20T09:00:00Z","read_state":"unread"}],"next_cursor":"","permissions":{"can_remind_unsubmitted":true,"can_view_managed":true}}` | +| `POST /v1/im/reports/daily` | `{"report_date":"2026-08-20"}` | `{"report_id":"dr_1","report_date":"2026-08-20","content":"","attachments":[],"status":"draft","version":1,"permissions":{"can_edit":true,"can_submit":true,"can_view":true}}` | +| `PUT /v1/im/reports/daily/{id}/draft` | `{"content":"今日完成联调","attachments":[],"version":1}` | `{"report_id":"dr_1","content":"今日完成联调","attachments":[],"status":"draft","version":2,"draft_updated_at":"2026-08-20T09:00:00Z"}` | +| `POST /v1/im/reports/daily/{id}/submit` | `{"version":2}` | `{"report_id":"dr_1","status":"submitted","submitted_at":"2026-08-20T09:00:00Z","version":3}` | +| `GET /v1/im/reports/daily/statistics?date=&department_id=` | Query only | `{"expected_count":10,"submitted_count":8,"missing_count":2,"scope":{"department_id":"dep_1"}}` | +| `POST /v1/im/reports/daily/reminders` | `{"date":"2026-08-20","user_ids":["usr..."]}` | `{"date":"2026-08-20","items":[{"user_id":"usr...","status":"sent"}]}` | +| `GET /v1/im/reports/daily/{report_id}` | Path report ID | `{"report_id":"dr_1","author":{"user_id":"usr...","display_name":"张三"},"report_date":"2026-08-20","content":"日报内容","attachments":[],"status":"submitted","version":3,"permissions":{"can_view":true,"can_edit":false}}` | +| `GET /v1/im/reports/monthly?scope=mine&from_month=&to_month=&status=&cursor=` | Query only | `{"items":[{"report_id":"mr_1","month":"2026-08","author":{"user_id":"usr...","display_name":"张三"},"status":"draft","action":"edit","updated_at":"2026-08-20T09:00:00Z"}],"next_cursor":""}` | +| `GET /v1/im/reports/monthly?scope=mine\|managed&from_month=&to_month=&status=&cursor=` | Query only | `{"items":[{"report_id":"mr_1","month":"2026-08","author":{"user_id":"usr...","display_name":"张三"},"status":"submitted","action":"view","submitted_at":"2026-08-20T09:00:00Z"}],"next_cursor":""}` | +| `POST /v1/im/reports/monthly` | `{"month":"2026-08"}` | `{"report_id":"mr_1","month":"2026-08","work_summary":"","next_plan":"","attachments":[],"status":"draft","action":"edit","version":1,"validation":{"work_summary_required":true,"next_plan_required":true,"max_length":10000},"permissions":{"can_edit":true,"can_submit":true}}` | +| `PUT /v1/im/reports/monthly/{id}/draft` | `{"work_summary":"本月总结","next_plan":"下月计划","attachments":[],"version":1}` | `{"report_id":"mr_1","work_summary":"本月总结","next_plan":"下月计划","status":"draft","version":2}` | +| `POST /v1/im/reports/monthly/{id}/submit` | `{"version":2}` | `{"report_id":"mr_1","status":"submitted","submitted_at":"2026-08-20T09:00:00Z","version":3}` | +| `GET /v1/im/reports/monthly/statistics?month=&department_id=` | Query only | `{"expected_count":10,"submitted_count":8,"missing_count":2,"scope":{"department_id":"dep_1"}}` | +| `GET /v1/im/reports/monthly/{report_id}` | Path report ID | `{"report_id":"mr_1","month":"2026-08","author":{"user_id":"usr...","display_name":"张三"},"work_summary":"总结","next_plan":"计划","attachments":[],"status":"submitted","version":3,"permissions":{"can_view":true,"can_edit":false}}` | +| `POST /v1/im/reports/monthly/{id}/reopen` 待实现 | `{"version":3,"reason":"补充内容"}` | `{"report_id":"mr_1","status":"draft","version":4}` | +| `GET /v1/im/executive-mailbox/config` | Query only | `{"enabled":true,"allow_anonymous":true,"required_fields":["subject","content"],"max_length":1000,"attachment_policy":{"max_files":5,"purpose":"executive_mailbox"},"notice":{"title":"总经理信箱","content":"请如实反馈","updated_at":"2026-08-20T09:00:00Z"}}` | +| `POST /v1/im/executive-mailbox/submissions` | Header `Idempotency-Key`;Body `{"subject":"建议","content":"建议内容","anonymous":false,"attachment_file_ids":[]}` | `{"submission_id":"mail_1","status":"received","submitted_at":"2026-08-20T09:00:00Z"}` | + ## 10. 个人资料、通用设置、隐私与青少年模式 ### 10.1 我的.png @@ -1675,7 +2078,7 @@ X-Tinode-APIKey: <预置的普通 API Key> **状态:Tinode `me` 资料更新已有基础;目标产品接口需改造。** -- 查询 `GET /v1/im/me/profile` 返回 `avatar_file_id,nickname,birth_date,signature,gender,region,public_account,version,field_rules`。 +- 查询 `GET /v1/im/me/profile` 返回 `avatar_file_id,nickname,birth_date,signature,gender,region,email,public_account,version,field_rules`;`email` 当前仅为资料字段,不用于登录或找回密码。 - 更新统一使用 `PATCH /v1/im/me/profile`,仅提交变更字段并带 `version`;服务端更新后同步 Tinode `me.public` 并推送资料事件。 - 头像先以 `purpose=user_avatar` 上传再 PATCH;任何字段的可见性由隐私策略裁剪。 @@ -1723,7 +2126,7 @@ X-Tinode-APIKey: <预置的普通 API Key> **状态:菜单纯客户端;数据接口部分新增。** - 菜单来自 `bootstrap.settings_menu`,安全、通知、隐私、通用、关于按权限展示。系统通知权限状态由 iOS API 读取,不由服务端伪造。 -- 退出:`POST /v1/im/auth/logout` 撤销当前 refresh/access token 和设备会话,成功或 Token 已失效后关闭 WS、清安全存储和本租户用户缓存。 +- 退出:`POST /v1/im/auth/logout` 撤销当前登录 Token 对应的设备会话,成功或 Token 已失效后关闭 WS、清安全存储和本租户用户缓存。 - 多设备退出使用 `DELETE /v1/im/security/sessions/{session_id}`,需二次验证。 ### 10.8 设置(安卓).png @@ -1859,6 +2262,38 @@ X-Tinode-APIKey: <预置的普通 API Key> - 首次录音或通话前说明并申请系统权限;未授权时不得创建录音文件或发送 call invite。 - 永久拒绝后进入系统设置引导;恢复授权后重新执行 4.7 或第 8 章动作,不复用过期媒体会话。 +### 10.22 接口示例 + +以下示例用于本章 HTTP 联调快速查阅。除特别说明外: + +- 产品 HTTP 均使用 `https://{host}/{api_path}v1/im`,请求头包含 `X-Tinode-APIKey`、`X-IM-Schema: 1`、`X-Request-ID`;登录后接口再带 `Authorization: Bearer `。 +- 写接口必须带 `Idempotency-Key`。二次认证接口按正文说明额外带 `X-Action-Token` 或 `X-Risk-Action-Token`。 +- 成功响应统一包裹为 `{"code":0,"data":示例,"request_id":"...","server_time":"..."}`;下表只展示 `data`。 +- 失败响应统一包裹为 `{"code":30000,"reason":"MALFORMED_REQUEST","message":"...","details":{},"request_id":"...","server_time":"..."}`。 +- 标注“待实现/需修复”的接口是产品目标协议,前端可按示例准备,但联调前以后端发布状态为准。 + +#### 10.22.1 个人资料、设置、隐私、青少年模式与设备 + +| 接口 | 输入示例 | 成功 `data` 示例 | +| --- | --- | --- | +| `GET /v1/im/me` | Query only | `{"user_id":"usr...","profile":{"public_account":"usr...","nickname":"张三","avatar_file_id":"","signature":"","version":1},"profile_version":1,"realname":{"status":"unverified"},"youth_mode":{"enabled":false}}` | +| `GET /v1/im/me/profile` | Query only | `{"avatar_file_id":"","nickname":"张三","birth_date":"1990-01-01","signature":"签名","gender":"male","region":"上海","email":"a@example.com","public_account":"usr...","version":1,"field_rules":{"nickname":{"min_length":1,"max_length":32},"signature":{"max_length":120}}}` | +| `PATCH /v1/im/me/profile` | `{"nickname":"新昵称","signature":"新签名","version":1}` | `{"avatar_file_id":"","nickname":"新昵称","signature":"新签名","version":2,"updated_at":"2026-08-20T09:00:00Z"}` | +| `GET /v1/im/me/privacy` | Query only | `{"add_me_methods":{"account":true,"phone":true,"qr":true,"group":true},"profile_visibility":"friends","last_seen_visibility":"friends","read_receipts":true,"version":1,"editable":{"add_me_methods":true,"profile_visibility":true,"last_seen_visibility":true,"read_receipts":true}}` | +| `PATCH /v1/im/me/privacy` | `{"add_me_methods":{"phone":false},"read_receipts":false,"version":1}` | `{"add_me_methods":{"account":true,"phone":false,"qr":true,"group":true},"read_receipts":false,"version":2,"updated_at":"2026-08-20T09:00:00Z"}` | +| `GET /v1/im/settings/youth-mode` | Query only | `{"enabled":false,"policy_version":1,"restrictions":["night_quiet","content_filter","payment_guard"],"password_set":false,"can_enable":false,"updated_at":"2026-08-20T09:00:00Z"}` | +| `POST /v1/im/settings/independent-password` | `{"password":"1234","purpose":"youth_mode","action_token":"action_token"}` | `{"status":"ok","password_set":true,"purpose":"youth_mode"}` | +| `POST /v1/im/settings/independent-password/verify` | `{"password":"1234","purpose":"close_youth_mode"}` | `{"action_token":"action_token","action":"close_youth_mode","expires_at":"2026-08-20T09:05:00Z"}` | +| `POST /v1/im/settings/youth-mode/enable` | `{"policy_version":1,"action_token":"action_token"}` | `{"enabled":true,"policy_version":1,"password_set":true,"restrictions":["night_quiet","content_filter","payment_guard"],"updated_at":"2026-08-20T09:00:00Z"}` | +| `POST /v1/im/settings/youth-mode/disable` | Header `X-Action-Token: action_token`;Body `{}` | `{"enabled":false,"policy_version":1,"password_set":true,"updated_at":"2026-08-20T09:00:00Z"}` | +| `POST /v1/im/security/challenges` | Header `Idempotency-Key`;Body `{"scene":"close_youth_mode","channel":"sms"}` | `{"challenge_id":"sec_chg_1","phone_mask":"+861****000","expires_in_seconds":300,"resend_after_seconds":60}` | +| `POST /v1/im/security/challenges/{id}/verify` | `{"code":"123456"}` | `{"action_token":"action_token","action":"close_youth_mode","expires_at":"2026-08-20T09:05:00Z"}` | +| `GET /v1/im/security/realname` | Query only | `{"status":"unverified","can_submit":true}` | +| `POST /v1/im/security/realname/submissions` | `{"name":"张三","id_type":"cn_id","id_number":"110101199001011234"}` | `{"submission_id":"rn_1","status":"pending","can_submit":false}` | +| `POST /v1/im/security/realname/appeals` 待实现 | `{"reason":"证件变更","description":"需要更正实名信息","action_token":"action_token"}` | `{"appeal_id":"rna_1","status":"pending","submitted_at":"2026-08-20T09:00:00Z"}` | +| `PUT /v1/im/devices/{device_id}/push-token` | `{"provider":"fcm","token":"push_token","platform":"android","app_version":"1.0.0"}` | `{"device_id":"device_1","provider":"fcm","platform":"android","app_version":"1.0.0","updated_at":"2026-08-20T09:00:00Z"}` | +| `DELETE /v1/im/security/sessions/{session_id}` 待实现 | Header `X-Action-Token: action_token` | `{"session_id":"sess_1","revoked":true}` | + ## 11. 反诈安全、举报与意见反馈 ### 11.1 反诈弹窗(安卓).png @@ -1911,6 +2346,27 @@ X-Tinode-APIKey: <预置的普通 API Key> - 提交:`POST /v1/im/feedback`,Body `{"category":"bug","content":"...","contact":"...","attachment_file_ids":[],"client_context":{"platform":"android","app_version":"1.0.0","os_version":"..."},"include_diagnostics":false}`。 - 附件用 `purpose=feedback`;只有用户明确同意 `include_diagnostics=true` 才上传经过脱敏的诊断包。成功返回 `feedback_id,status:"received"`,重复提交由幂等键去重。 + +### 11.6 接口示例 + +以下示例用于本章 HTTP 联调快速查阅。除特别说明外: + +- 产品 HTTP 均使用 `https://{host}/{api_path}v1/im`,请求头包含 `X-Tinode-APIKey`、`X-IM-Schema: 1`、`X-Request-ID`;登录后接口再带 `Authorization: Bearer `。 +- 写接口必须带 `Idempotency-Key`。二次认证接口按正文说明额外带 `X-Action-Token` 或 `X-Risk-Action-Token`。 +- 成功响应统一包裹为 `{"code":0,"data":示例,"request_id":"...","server_time":"..."}`;下表只展示 `data`。 +- 失败响应统一包裹为 `{"code":30000,"reason":"MALFORMED_REQUEST","message":"...","details":{},"request_id":"...","server_time":"..."}`。 +- 标注“待实现/需修复”的接口是产品目标协议,前端可按示例准备,但联调前以后端发布状态为准。 + +#### 11.6.1 反诈、举报与意见反馈 + +| 接口 | 输入示例 | 成功 `data` 示例 | +| --- | --- | --- | +| `POST /v1/im/security/risk-challenges/{risk_challenge_id}/confirm` | `{"decision":"continue"}` | `{"risk_action_token":"risk_token","action":"send_message","expires_at":"2026-08-20T09:05:00Z"}` | +| `GET /v1/im/reports/abuse/reasons?target_type=user\|group\|message` 需修复 | Query only | `{"items":[{"reason_code":"fraud","label":"诈骗","requires_description":true,"allows_evidence":true},{"reason_code":"harassment","label":"骚扰","requires_description":false,"allows_evidence":true}]}` | +| `POST /v1/im/reports/abuse` | `{"target":{"type":"message","topic":"grp...","seq":"12"},"reason_code":"fraud","description":"疑似诈骗","evidence_file_ids":["file_1"]}` | `{"report_id":"abr_1","status":"received","submitted_at":"2026-08-20T09:00:00Z"}` | +| `GET /v1/im/feedback/config` | Query only | `{"categories":["bug","suggestion","complaint","other"],"max_length":1000,"allow_contact":true,"attachment_policy":{"max_files":5,"purpose":"feedback"},"diagnostics_notice":"仅在你同意时上传脱敏诊断信息。","updated_at":"2026-08-20T09:00:00Z"}` | +| `POST /v1/im/feedback` | `{"category":"bug","content":"页面异常","contact":"15500000000","attachment_file_ids":[],"client_context":{"platform":"android","app_version":"1.0.0","os_version":"14"},"include_diagnostics":false}` | `{"feedback_id":"fb_1","status":"received","created_at":"2026-08-20T09:00:00Z"}` | + ## 12. 联调实施清单 ### 12.1 当前可直接联调的基础能力 @@ -1926,7 +2382,7 @@ X-Tinode-APIKey: <预置的普通 API Key> | 优先级 | 改造项 | 阻塞图片章节 | | --- | --- | --- | | P0 | `/v1/im` 统一认证中间件、响应封装、幂等键、租户 Principal | 全部 HTTP 产品能力 | -| P0 | 企业码解析、验证码、密码重置、二次认证、Token `auth_version` 和刷新/撤销 | 1.1~1.11、10.7~10.8 | +| P0 | 企业码解析、验证码、密码重置、二次认证、Token `auth_version`、登录换新和撤销 | 1.1~1.11、10.7~10.8 | | P0 | 普通用户禁止发布系统 Topic;系统事件可信生产通道 | 2.5、3.1~3.5、6.4、11.1 | | P0 | 消息 `x-im-schema/type/client-mid` 校验和服务端幂等 | 2~4 章 | | P0 | 文件资源级 ACL、Range、ETag、用途绑定和租户存储配额 | 3.6、4.10~4.14、6.27~7.16、11.4~11.5 | @@ -1950,7 +2406,7 @@ X-Tinode-APIKey: <预置的普通 API Key> | T-07 | 普通群成员调用管理员、禁言、转让群主接口 | 返回 `FORBIDDEN`,群状态不变且写安全审计 | | T-08 | 群成员数已达租户配置上限时同意两个并发申请 | 至多一个成功,成员数不超限 | | T-09 | 非消息接收者持同租户 Token 下载文件 | 返回 `FILE_FORBIDDEN`,不能只通过 file_id 下载 | -| T-10 | 文件下载中 Token 过期后刷新并 Range 续传 | 文件完整、checksum 正确、不重复下载已完成分片 | +| T-10 | 文件下载中断后以有效 Token 登录取得新 Token 并 Range 续传 | 文件完整、checksum 正确、不重复下载已完成分片 | | T-11 | 风控二次确认后重复重放 risk token | 只有首次有效,其余返回 Token 已消费 | | T-12 | 切换企业后查看缓存、上传任务、收藏和搜索 | 不出现上一租户数据或任务 | diff --git a/docs/im/IM-注册、邀请码与认证数据模型改造需求文档.md b/docs/im/IM-注册、邀请码与认证数据模型改造需求文档.md new file mode 100644 index 0000000..cb0e7ca --- /dev/null +++ b/docs/im/IM-注册、邀请码与认证数据模型改造需求文档.md @@ -0,0 +1,539 @@ +# IM 注册、邀请码与认证数据模型改造需求文档 + +> 版本:V1.0 评审稿 +> 编写日期:2026-08-18 +> 适用范围:IM 客户端、IM 服务端、HotGo 管理后台、MySQL +> 关联文档:IM-前后端对接文档.md 1.4、IM-系统设计文档.md + +## 1. 文档目的 + +本文统一定义以下改造: + +1. 注册账号支持“用户名、手机号、用户名加手机号”三种输入组合。 +2. 手机号注册支持短信验证,后端固定补中国国家号 +86。 +3. HotGo 后台增加注册短信发送开关。 +4. 邀请码作为无短信验证时的注册放行凭证。 +5. 邀请码支持单个生成、批量生成、自定义有效期、撤销和使用审计。 +6. im_users 增加非空 username,phone 改为可空。 +7. im_auth 删除数据库字段 scheme 和 uname,只按 userid 保存 Basic 密码凭据。 + +## 2. 核心业务定义 + +| 名称 | 定义 | +| --- | --- | +| 用户名输入 | 注册页面独立的用户名输入框 | +| 手机号输入 | 注册页面独立的手机号输入框,只接受 11 位 ASCII 数字 | +| 放行优先级 | 手机号非空且短信开关开启时以验证码为注册放行依据;短信开关关闭或未填写手机号时以邀请码为注册放行依据 | +| 已验证手机号 | 已成功消费 register 场景 verification_token,并建立已完成 tel credential 的手机号 | +| 用户名注册 | 保存用户填写的 username;未填写手机号时依靠有效邀请码放行,phone 为 NULL | +| 手机号注册 | 手机号字段非空的注册;保存用户填写的 username 和规范化 phone,未填写 username 时由服务端随机生成 username;短信验证状态由 tel credential 区分 | +| 注册短信开关 | 控制服务端是否真正调用短信服务商的全局后台配置 | +| 邀请码 | 租户内生成的 5 位大小写英文字母明文码,区分大小写、不可重复,有效期内可供多人使用、可撤销 | + +## 3. 用户账号数据规则 + +### 3.1 im_users 字段 + +目标字段: + +| 字段 | 约束 | 说明 | +| --- | --- | --- | +| username | 非空、租户内唯一 | 主登录账号;用户填写的普通用户名必须匹配 `^[a-z0-9]{1,10}$`。未填写 username 但填写手机号时,由服务端随机生成 3 位小写字母 + 6 位数字的系统 username | +| phone | 可空、租户内唯一 | 用户填写手机号时的注册号码,统一保存 E.164;是否完成短信验证由 tel credential 判断 | + +落库规则: + +| 注册方式 | username | phone | +| --- | --- | --- | +| 用户名加手机号,验证码注册 | alice | +8613800138000 | +| 仅手机号,验证码注册 | abc123456 | +8613800138000 | +| 普通用户名加邀请码注册 | alice | NULL | +| 用户名加手机号,短信关闭邀请码注册 | alice | +8613800138000 | +| 仅手机号,短信关闭邀请码注册 | abc123456 | +8613800138000 | + +普通用户名注册规则: + +1. 必须匹配 `^[a-z0-9]{1,10}$`,长度为 1~10 位。 +2. 只允许 ASCII 小写英文字母 `a-z` 和数字 `0-9`,允许纯字母、字母数字组合或纯数字。 +3. 大写字母、中文、空格、点、下划线、冒号及其他符号均不允许;服务端校验原始输入,不通过静默转小写来放行不合规注册。 +4. 前端按相同规则限制输入并提示,服务端必须再次校验,不能只依赖客户端。 +5. 该规则只约束用户填写的普通用户名。服务端随机生成的系统 username 固定为 9 位 `^[a-z]{3}[0-9]{6}$`,属于系统值例外;存量迁移生成的内部保留账号也不适用此正则,不能作为普通用户名注册。 + +### 3.2 字段保存和手机号规范化 + +1. 页面分别展示用户名输入框和手机号输入框。 +2. 用户名字段非空时必须按原值校验并保存到 `im_users.username`,不能因为同时填写手机号而忽略用户名。 +3. 手机号字段非空时必须校验并保存到 `im_users.phone`;手机号字段为空时 `phone` 保持 NULL。 +4. 手机号字段使用 ^[0-9]{11}$ 校验;手机号非空但格式错误时直接拒绝,不能降级为用户名方式。 +5. 发送短信和落库前,后端把 11 位数字规范化为 +86xxxxxxxxxxx。 +6. 用户名和手机号同时填写时,`username` 保存用户填写的用户名,`phone` 保存规范化手机号。 +7. 仅手机号注册且 username 为空时,服务端随机生成 `^[a-z]{3}[0-9]{6}$` 格式的 username,检查租户内唯一,冲突时重新生成;`phone` 保存规范化手机号。 +8. 不提供国家和区号选择,本期固定中国 +86。 +9. 登录页面仍使用统一的“用户名/手机号”输入框;先精确查询 username,未命中时将 11 位输入补 +86 后查询 phone。 +10. 注册、绑定手机号时必须检查 username 和 phone 两个命名空间,防止同一输入命中两个用户。 +11. 普通用户名注册必须先校验 `^[a-z0-9]{1,10}$`;用户名字段非空且格式错误时必须拒绝,不能因手机号合法而跳过用户名校验。 +12. 注册创建公开资料时,账户昵称暂时默认等于最终保存的 username;客户端未传 `desc.public.fn` 或传入值为空时,服务端使用最终 username 填充昵称。 + +## 4. 注册放行规则 + +### 4.1 决策表 + +| 用户名字段 | 手机号字段 | 短信开关 | 有效验证票据 | 邀请码 | 服务端结果 | +| --- | --- | --- | --- | --- | --- | +| 已填 | 空 | 任意 | 不适用 | 有效 | 放行,保存 username,phone=NULL | +| 已填 | 空 | 任意 | 不适用 | 未填或无效 | 拒绝 | +| 已填 | 11 位 | 开 | 有 | 未填 | 放行,保存 username 和 phone | +| 已填 | 11 位 | 开 | 有 | 有效 | 放行,保存 username 和 phone,并记录邀请码使用关系 | +| 空 | 11 位 | 开 | 有 | 未填或有效 | 放行,随机生成 username,phone 写手机号 | +| 任意 | 11 位 | 开 | 无 | 任意 | 拒绝,邀请码不能绕过已开启的短信验证 | +| 已填 | 11 位 | 关 | 无 | 有效 | 放行,保存 username 和 phone | +| 空 | 11 位 | 关 | 无 | 有效 | 放行,随机生成 username,phone 写手机号 | +| 任意 | 11 位 | 关 | 无 | 未填或无效 | 拒绝 | +| 已填 | 格式错误 | 任意 | 任意 | 任意 | 拒绝,不能降级为用户名方式 | +| 空 | 空 | 任意 | 任意 | 任意 | 拒绝,必须填写用户名或手机号 | + +补充规则: + +1. 有效 verification_token 的优先级高于当前短信开关。开关关闭不能让已验证票据失效。 +2. 短信开关开启时,邀请码不能绕过手机号验证。 +3. 用户主动填写邀请码后,服务端必须校验;不能在邀请码无效时静默忽略。 +4. 手机号字段非空且短信开关开启时,以验证码为准,邀请码可不填;手机号字段为空或短信开关关闭时,必须填写有效邀请码。 +5. 短信关闭时邀请码替代短信验证作为注册授权,但不建立已完成的 tel credential。 +6. 用户创建、租户用户配额校验、邀请码使用记录、手机号票据消费应原子完成。 + +### 4.2 客户端页面行为 + +1. 页面分别显示“请输入用户名”和“请输入手机号”。 +2. 用户名和手机号是独立字段;两个都填写时都参与注册,用户名保存为主登录账号,手机号保存为绑定号码。 +3. 手机号为空时不发送短信,使用用户名加邀请码方式注册。 +4. 手机号为 11 位数字时允许点击“获取验证码”。 +5. 用户名方式不发送短信,验证码字段不作为提交项。 +6. 邀请码字段始终显示,文案为“请输入邀请码(非必填)”。 +7. 短信开关关闭时,发码接口仍返回正常倒计时,前端进入与开关开启时相同的倒计时状态。 +8. 前端不自行决定最终是否可以免验证码注册,最终放行完全由服务端判断。 +9. 用户名方式注册前提示:未绑定手机号时无法自助找回密码,只能联系企业管理员重置。 + +## 5. 注册短信发送开关 + +### 5.1 HotGo 配置 + +HotGo 后台增加全局配置: + +~~~text +group = im +key = register_sms_enabled +type = bool +value = 1 / 0 +~~~ + +实现约定: + +1. 管理界面的开关选项复用 HotGo 已有 sys_switch 字典。 +2. 当前开关值保存到 hg_sys_config;字典只负责提供开/关选项,不作为配置值真源。 +3. 只有平台管理员可以修改。 +4. 修改操作写 HotGo 后台审计日志。 +5. IM 服务端在可控缓存时间内读取最新值。 +6. 本期为全局开关;未来需要按企业控制时再迁移到 im_tenant_config。 + +### 5.2 开关开启 + +1. 服务端生成强随机验证码。 +2. 创建 register 场景挑战并执行限频。 +3. 调用短信服务商发送验证码。 +4. 短信服务商失败时返回稳定错误 SMS_DELIVERY_FAILED。 +5. 禁止继续使用固定测试码 123456。 + +### 5.3 开关关闭 + +1. 发码接口仍创建限频挑战。 +2. 返回 challenge_id、expires_in_seconds 和 resend_after_seconds。 +3. 不调用短信服务商。 +4. 不生成客户端可猜测或可验证的固定验证码。 +5. 前端仍正常显示倒计时,但用户不会收到短信。 +6. 记录内部 delivery_suppressed 审计事件,不向客户端暴露开关状态。 + +## 6. 邀请码管理 + +### 6.1 基本规则 + +1. 邀请码按租户隔离,不能跨企业使用。 +2. 邀请码固定为 5 位 ASCII 大小写英文字母,格式为 `^[A-Za-z]{5}$`,不包含数字和符号。 +3. 邀请码区分大小写,`AbCdE` 与 `abcde` 是两个不同的邀请码;客户端和服务端不得自动转换大小写。 +4. 邀请码由服务端使用密码学安全随机数从 `A-Z`、`a-z` 字符集中生成,不能使用递增数字、用户 ID 或批次 ID。 +5. 同一租户内邀请码不能重复,数据库唯一索引是最终约束;单个或批量生成发生冲突时必须重新生成,不得返回重复码。 +6. 邀请码以明文落库,并使用完整明文进行大小写敏感的精确查询和注册校验。 +7. 每个邀请码在有效期内可以成功注册多个账号,不设置使用次数上限。 +8. 管理员创建时自行设置 expires_at,必须晚于当前时间。 +9. 状态包括 active、expired、revoked,不存在 used 状态。 +10. expired、revoked 状态不可恢复为 active。 +11. 邀请码校验必须同时检查租户、完整明文、状态和到期时间,不因已有其他用户使用记录而失效。 + +### 6.2 单个生成 + +请求示例: + +~~~json +{ + "expires_at": "2026-09-01T00:00:00+08:00", + "remark": "新员工入职" +} +~~~ + +服务端返回一个 5 位邀请码。具备邀请码查看权限的租户管理员可在后续列表和详情中查看完整明文。 + +### 6.3 批量生成 + +请求示例: + +~~~json +{ + "quantity": 100, + "expires_at": "2026-09-01T00:00:00+08:00", + "remark": "2026年8月新员工入职" +} +~~~ + +规则: + +1. 一次批量生成共用 batch_id 和 expires_at。 +2. 每个邀请码均为独立生成的 5 位大小写英文字母,租户内唯一且区分大小写。 +3. 批量请求必须支持幂等键,重试不能生成重复批次。 +4. 单批数量由后台配置限制,建议默认上限 1000。 +5. 生成成功后支持明文导出;后续再次查询或导出必须校验邀请码查看/导出权限并写审计。 + +### 6.4 查询、撤销和审计 + +管理后台支持: + +1. 按完整邀请码进行大小写敏感的精确查询。 +2. 按状态、批次、创建人、创建时间、到期时间查询。 +3. 列表和详情返回邀请码明文,接口必须校验当前租户的邀请码查看权限。 +4. 单个撤销仍处于 active 的邀请码;撤销不删除历史使用记录。 +5. 按 batch_id 批量撤销仍处于 active 的邀请码。 +6. 查看邀请码的累计使用次数、使用用户、使用时间和请求 ID。 +7. 所有生成、查询、查看、导出、撤销操作写后台审计日志。 + +### 6.5 HotGo 管理接口 + +| 方法 | 路径 | 说明 | +| --- | --- | --- | +| POST | /imInvite/create | 单个生成 | +| POST | /imInvite/batchCreate | 批量生成 | +| POST | /imInvite/list | 分页或明文精确查询 | +| POST | /imInvite/revoke | 单个撤销 | +| POST | /imInvite/batchRevoke | 批量撤销 | +| GET | /imInvite/batchView | 批次详情 | +| POST | /imInvite/export | 按过滤条件导出 CSV | + +HotGo 根据可信登录上下文取得 `im_tenant.id`,通过 `im_tenant.db_dsn/db_name` 选择租户业务库并直接维护邀请码管理数据。浏览器不能提交可信 DSN 或决定实际租户库。 + +IM 不再提供 `/admin/v1/im/*` HMAC 管理接口。注册校验和 `im_registration_invite_uses` 写入仍由 IM 在用户注册事务中完成,HotGo 不参与注册事务。 + +管理员重置密码时,HotGo 在同一个租户库事务中更新 `im_auth.secret`、递增 `im_user_security.auth_version`,并写入 `im_business_events_outbox` 的 `security.password_reset` 事件。IM 当前轮询此事件并清退在线会话;后续迁移 Kafka 时保持事件类型和 payload 不变,只替换投递与消费通道。 + +## 7. 客户端注册协议 + +### 7.1 发码 + +~~~http +POST /v1/im/auth/verification-codes +~~~ + +~~~json +{ + "scene": "register", + "phone": "13800138000" +} +~~~ + +后端补 +86 后发送并保存手机号哈希。开关关闭时返回字段和倒计时保持一致。 + +### 7.2 提交注册 + +在现有 WebSocket acc 消息中增加 username、phone 和 invite_code: + +~~~json +{ + "acc": { + "id": "acc-1", + "user": "new", + "scheme": "basic", + "username": "alice", + "phone": "", + "secret": "base64(account:password)", + "invite_code": "AbCdE", + "login": true, + "cred": [] + } +} +~~~ + +用户名方式时,phone 为空,secret 使用 username:password,cred 为空且 invite_code 必须有效。 + +手机号字段非空时,短信开关开启则必须通过验证码放行,cred 传递一次性 verification_token;短信开关关闭则走邀请码放行,cred 可为空且 invite_code 必须有效。用户名字段非空时保存用户名并使用 username:password 生成 secret;用户名字段为空时由服务端随机生成 3 位小写字母 + 6 位数字的 username,客户端提交 secret 时仍使用 phone:password。短信关闭路径不建立已完成的 tel credential。 + +服务端必须校验 secret 中的登录名与客户端可确定的注册账号一致:username 非空时必须匹配 username;username 为空且 phone 非空时必须匹配规范化手机号。服务端生成的随机 username 在账号创建后返回给客户端,并作为后续登录主账号。username 和 phone 独立传递是注册路由信息,不能只根据 secret 是否为 11 位数字推断,因为普通用户名允许使用纯数字。 + +账户昵称暂时与最终 username 保持一致:服务端创建用户资料时以最终 username 写入 `desc.public.fn` 或资料表昵称;仅手机号注册时昵称等于随机生成的 username,不使用手机号作为昵称。 + +客户端不得提交 phone_account、sms_enabled、skip_verification 等服务端决策字段。 + +## 8. im_auth 改造 + +### 8.1 改造边界 + +当前只有 Basic 密码凭据写入 im_auth: + +1. token 是无状态签名,不写 im_auth。 +2. code 使用临时缓存,不写 im_auth。 +3. anonymous 不保存凭据。 +4. rest 调用外部认证服务。 + +因此数据库中的 scheme=basic 是冗余信息。登录账号真源迁移到 im_users 后,uname 和 basic: 前缀同样冗余。 + +协议层仍保留 acc.scheme 和 login.scheme,用于选择 basic 或 token 认证处理器。本次只删除数据库字段。 + +这里删除的 uname 仅指 `im_auth.uname` 数据库列。Basic 协议仍从 `secret` 中解析登录账号和密码,登录账号改为查询 `im_users.username` 或 `im_users.phone`;除非后续同时改造客户端和协议消息结构,否则 `account:password` 格式及用户名禁止冒号的技术约束继续保留。 + +### 8.2 目标表结构 + +~~~sql +CREATE TABLE im_auth ( + id INT NOT NULL AUTO_INCREMENT, + userid BIGINT NOT NULL, + authlvl INT NOT NULL, + secret VARCHAR(255) NOT NULL, + expires DATETIME, + PRIMARY KEY (id), + UNIQUE INDEX auth_userid (userid), + CONSTRAINT fk_im_auth_user + FOREIGN KEY (userid) REFERENCES im_users(id) +); +~~~ + +变更内容: + +1. 删除 scheme。 +2. 删除 uname。 +3. 删除 auth_uname 和 auth_userid_scheme。 +4. 增加 UNIQUE(userid)。 +5. 保留 authlvl,以兼容普通用户和 ROOT 账号。 +6. 保留 secret 和 expires。 + +### 8.3 登录和密码处理 + +1. Basic 登录先规范化账号输入。 +2. 先从 im_users.username 查询 userid。 +3. 未命中且输入为 11 位数字时,补 +86 后从 im_users.phone 查询 userid。 +4. 按 userid 从 im_auth 读取 bcrypt 密码摘要。 +5. 修改密码、重置密码、删除认证和 ROOT 提升都按 userid 操作。 +6. Store 和 Adapter 接口不再传递 scheme、unique 或 basic: 前缀。 + +### 8.4 密码找回 + +1. im_users.phone 非空的账号可以使用短信找回密码。 +2. 找回流程向 im_users.phone 发送 reset_password 场景验证码,成功消费验证码后才允许设置新密码。 +3. 如果该手机号注册时未建立完成状态的 tel credential,成功完成密码找回验证后可以补建该凭证。 +4. im_users.phone 为 NULL 的用户名账号不提供短信或其他自助找回方式。 +5. 无手机号账号忘记密码后,只能联系当前租户管理员执行后台密码重置。 +6. 管理员重置密码必须递增 auth_version、撤销旧 Token 和在线 Session,并写安全审计。 +7. 管理员只能重置当前租户用户,不能通过用户名跨租户查找或操作。 + +### 8.5 密码与登录 Token + +1. 客户端按现有 Basic 协议提交 `base64(account + ":" + password)`。Base64 只是协议编码,不是密码加密;账号注册和登录必须使用 HTTPS/WSS。 +2. 服务端解析出密码后使用 bcrypt 单向哈希,当前成本参数为 `bcrypt.DefaultCost=10`;`im_auth.secret` 只保存 bcrypt 哈希,不保存可逆密文或明文。 +3. 登录 Token 继续沿用现有 Tinode HMAC 签名 Token,默认有效期为 14 天,服务端不保存 Token 明文。 +4. 客户端保存登录响应中的 `params.token` 和 `params.expires`,Token 必须放入 iOS Keychain 或 Android Keystore,不得写入普通数据库、日志或埋点。 +5. 每次 Basic 登录或有效 Token 登录成功后,服务端都返回新的 14 天 Token;客户端必须用新 Token 覆盖旧 Token。 +6. 当前不增加 Refresh Token 和独立刷新接口。Token 未过期时可通过 `login.scheme=token` 登录并取得新 Token;Token 已过期或校验失败时必须重新进行账号密码登录。 +7. Token 内绑定 `tenant_id`、`uid`、`auth_version` 和过期时间。密码重置、管理员重置或账号安全状态变化后递增 `auth_version`,使旧 Token 失效。 + +## 9. 数据库设计 + +### 9.1 邀请码批次 + +~~~sql +CREATE TABLE im_registration_invite_batches ( + id BIGINT NOT NULL, + tenant_id BIGINT NOT NULL, + quantity INT UNSIGNED NOT NULL, + idempotency_key VARCHAR(128) NOT NULL, + expires_at DATETIME(3) NOT NULL, + created_by BIGINT NOT NULL, + remark VARCHAR(255) NOT NULL DEFAULT '', + created_at DATETIME(3) NOT NULL, + updated_at DATETIME(3) NOT NULL, + PRIMARY KEY (id), + UNIQUE INDEX uk_im_registration_invite_batch_tenant_idempotency (tenant_id, idempotency_key) +); +~~~ + +### 9.2 邀请码 + +~~~sql +CREATE TABLE im_registration_invite_codes ( + id BIGINT NOT NULL, + tenant_id BIGINT NOT NULL, + batch_id BIGINT, + code CHAR(5) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + status VARCHAR(16) NOT NULL, + expires_at DATETIME(3) NOT NULL, + usage_count INT UNSIGNED NOT NULL DEFAULT 0, + last_used_at DATETIME(3), + created_by BIGINT NOT NULL, + remark VARCHAR(255) NOT NULL DEFAULT '', + created_at DATETIME(3) NOT NULL, + updated_at DATETIME(3) NOT NULL, + PRIMARY KEY (id), + UNIQUE INDEX uk_im_registration_invite_code_tenant (tenant_id, code), + INDEX idx_im_registration_invite_tenant_status_expires (tenant_id, status, expires_at), + INDEX idx_im_registration_invite_tenant_batch (tenant_id, batch_id) +); +~~~ + +### 9.3 邀请码使用记录 + +~~~sql +CREATE TABLE im_registration_invite_uses ( + id BIGINT NOT NULL, + invite_code_id BIGINT NOT NULL, + user_id BIGINT NOT NULL, + request_id VARCHAR(64) NOT NULL, + used_at DATETIME(3) NOT NULL, + PRIMARY KEY (id), + INDEX idx_im_registration_invite_use_code (invite_code_id), + UNIQUE INDEX uk_im_registration_invite_use_user (user_id) +); +~~~ + +`im_registration_invite_batches` 和 `im_registration_invite_codes` 位于 HotGo 控制库,使用 `tenant_id` 隔离租户,便于平台统一查询、导出和管理。`usage_count`、`last_used_at` 是租户使用明细的查询投影,不是使用事实源。 + +`im_registration_invite_uses` 位于对应租户业务库,是邀请码使用事实源。注册成功时,IM 在同一租户库事务中写入使用明细和 `registration.invite_used` outbox 事件;当前由 IM 数据库轮询器按该邀请码的租户明细重新聚合并覆盖控制库统计,重试不会重复计数。后续切换 Kafka 时保持事件类型和 payload 不变。三张表的 BIGINT 主键均使用服务端雪花 ID。 + +邀请码以明文保存在 HotGo 控制库中。`ascii_bin` 保证查询和唯一索引区分大小写;所有校验必须同时使用 `tenant_id` 和完整 5 位邀请码精确匹配,禁止 `LOWER(code)`、`UPPER(code)` 或模糊查询。 + +## 10. 主要错误码 + +| 业务码 | 含义 | +| --- | --- | +| ACCOUNT_REQUIRED | 账号输入为空 | +| ACCOUNT_POLICY_VIOLATION | 账号违反最低技术约束 | +| ACCOUNT_ALREADY_EXISTS | 用户名或手机号命名空间冲突 | +| PHONE_VERIFICATION_REQUIRED | 手机号注册缺少有效验证票据 | +| INVITE_CODE_REQUIRED | 用户名注册缺少邀请码 | +| INVITE_CODE_INVALID | 邀请码不存在 | +| INVITE_CODE_EXPIRED | 邀请码已过期 | +| INVITE_CODE_REVOKED | 邀请码已撤销 | +| INVITE_CODE_RATE_LIMITED | 邀请码校验过于频繁 | +| SMS_DELIVERY_FAILED | 短信服务商发送失败 | +| USERNAME_INVALID_FORMAT | 用户名不是 1~10 位小写英文字母或数字 | + +邀请码无效、过期、已撤销可对客户端统一显示“邀请码无效”,详细原因仅写安全审计,降低枚举风险。 + +## 11. 存量迁移 + +采用“扩容、回填、切换、收缩”方式: + +1. 为 im_users 增加可空 username。 +2. 去除 im_auth.uname 中的 basic: 前缀后回填 im_users.username。 +3. 将现有 phone 统一规范化为 E.164。 +4. 对无 Basic 认证记录的内部、已删除或异常用户生成 reserved_,并输出迁移报告供人工复核。 +5. 检查重复 username、重复 phone 和跨字段冲突。 +6. 新版本短期双读旧 im_auth 和新 im_users 账号字段。 +7. 切换注册、登录、修改密码、密码重置和 ROOT 工具链。 +8. 将 username 改为非空并增加唯一索引。 +9. 观察期结束后删除 im_auth.scheme、im_auth.uname 和旧索引。 + +回滚时在观察期内继续保留旧列和旧读路径,不允许通过重新拼接 basic: 猜测恢复数据。 + +## 12. 安全与已知风险 + +1. 5 位邀请码属于短授权码,必须使用密码学安全随机数生成、设置有效期、可撤销并可审计;注册校验必须严格限频以降低枚举风险。邀请码允许多人使用意味着泄露后的影响面更大,应缩短有效期并及时撤销异常邀请码。 +2. 邀请码校验按租户、IP、设备和账号输入限频。 +3. 邀请码虽按业务要求明文落库和授权查询,但不得写入普通日志、埋点或错误响应;密码、短信验证码和 verification_token 同样禁止记录。 +4. 邀请码使用记录、用户创建、票据消费和租户配额扣减应在同一事务完成;邀请码本身不核销、不切换为 used。 +5. 短信开关关闭时不能保留固定测试验证码。 +6. 批量生成、批量导出和批量撤销必须限制数量并单独授权。 + +### 12.1 邀请码手机号注册风险 + +短信关闭时,有效邀请码可以在未完成短信验证的情况下把手机号写入 phone;如果用户同时填写 username,username 保存用户填写值,否则由服务端随机生成 3 位小写字母 + 6 位数字的 username。 + +为避免把“已填写手机号”和“已验证手机号”混为一谈: + +1. im_users.phone 保存手机号方式注册时的号码。 +2. 只有成功消费 verification_token 时才建立完成状态的 tel credential。 +3. 短信关闭加邀请码注册不建立完成状态的 tel credential。 +4. phone 非空账号可以通过 reset_password 短信挑战证明当前持有手机号后自助重置密码。 +5. phone=NULL 的账号不能自助找回密码,只能由当前租户管理员重置。 +6. 其他要求“已验证手机号”的敏感操作必须检查 tel credential,不能只检查 phone 非空。 +7. 后续完成短信验证后,可为同一用户补建 tel credential。 +8. 邀请码不证明手机号所有权,允许该路径意味着平台接受手机号被提前占用的业务风险。 + +## 13. 验收用例 + +### 13.1 注册 + +1. 只填写手机号,短信开启且验证成功,不填邀请码可以注册,username 为服务端随机生成的 3 位小写字母 + 6 位数字,phone 为规范化手机号。 +2. 只填写手机号,短信开启但没有验证票据,即使邀请码有效也不能注册。 +3. 只填写手机号,短信关闭且邀请码有效,无验证码可以注册,username 为服务端随机生成的 3 位小写字母 + 6 位数字,phone 为规范化手机号。 +4. 只填写手机号,短信关闭且不填邀请码不能注册。 +5. 只填写用户名并提供有效邀请码可以注册,phone=NULL。 +6. 只填写用户名但不填邀请码不能注册。 +7. 用户名和手机号同时填写且短信开启时,验证码验证成功后可以注册,username 保存用户填写的用户名,phone 保存规范化手机号。 +8. 用户名和手机号同时填写且短信关闭时,邀请码有效即可注册,username 保存用户填写的用户名,phone 保存规范化手机号。 +9. 手机号非空但格式错误时拒绝,不能回退到用户名方式。 +10. 用户名和手机号同时为空时拒绝注册。 +11. 过期、已撤销邀请码均不能注册。 +12. 同一有效邀请码并发注册时可以有多个不同用户成功,每个用户分别写入一条使用记录,邀请码保持 active。 +13. 用户名账号 phone=NULL 时,短信找回接口不能重置密码。 +14. 管理员可以重置 phone=NULL 用户的密码,重置后旧 Token 和 Session 全部失效。 +15. 普通用户名 `alice01`、`a`、`1234567890` 可以进入注册校验。 +16. 普通用户名 `Alice`、`alice_1`、`alice.1`、`中文`、空字符串或超过 10 位时返回 `USERNAME_INVALID_FORMAT`。 +17. 用户名格式不合法但手机号合法且非空时仍拒绝,不能忽略用户名输入。 +18. 仅手机号注册时,随机 username 必须在租户内唯一;发生唯一索引冲突时服务端重新生成并重试。 +19. 注册成功后的账户昵称等于最终 username:填写用户名时昵称等于该用户名;仅手机号注册时昵称等于随机 username。 + +### 13.2 短信开关 + +1. 开关关闭后发码接口不调用短信服务商。 +2. 开关关闭后发码接口仍返回正常倒计时。 +3. 开关从关闭改为开启后,新请求恢复调用短信服务商。 +4. 开关修改在规定缓存时间内对 IM 服务生效。 + +### 13.3 邀请码管理 + +1. 单个生成可设置有效期。 +2. 单个和批量生成的邀请码均匹配 `^[A-Za-z]{5}$`,不包含数字或符号。 +3. 批量生成数量正确,同一租户所有邀请码互不重复;并发生成冲突时由唯一索引拦截并重试。 +4. `AbCdE` 和 `abcde` 按两个不同邀请码精确查询和校验,不得自动转换大小写。 +5. 有权限的管理员可以按完整邀请码查询,并在列表、详情和导出中取得明文。 +6. 批量请求重试不会生成第二批数据。 +7. 单个和批量撤销立即生效,历史使用用户不受影响。 +8. 无权限用户不能查看、查询或导出邀请码明文。 +9. 管理员可以查看批次使用统计和单码使用记录。 + +### 13.4 认证回归 + +1. 存量 Basic 用户迁移后可使用原账号和密码登录。 +2. 登录、注册、修改密码、密码重置和 ROOT 提升不再依赖 im_auth.scheme、im_auth.uname 或 basic: 前缀。 +3. 同一 username 或 phone 在不同租户仍可独立注册。 +4. im_auth 每个 userid 最多保留一条记录。 + +## 14. 评审结论 + +本方案的核心决策为: + +1. 注册页的用户名和手机号是两个独立输入框,用户名非空时必须保存,手机号非空时必须保存。 +2. username 是所有用户非空的主登录账号。 +3. 用户名和手机号同时填写时,username 保存用户名,phone 保存规范化手机号;仅手机号注册时 username 由服务端随机生成 3 位小写字母 + 6 位数字。 +4. 手机号非空且短信开关开启时以验证码为注册放行依据;短信开关关闭时以邀请码为注册放行依据。 +5. 短信关闭时,手机号输入搭配有效邀请码可注册,但不建立已完成的 tel credential。 +6. 邀请码为 5 位大小写英文字母,区分大小写,以明文落库并在租户内唯一;有效期内可供多人使用,支持单个、批量、自定义有效期、明文查询/导出、撤销和使用审计。 +7. im_auth 只按 userid 保存 Basic 密码凭据,数据库删除 scheme、uname 和 basic: 前缀。 +8. 协议层仍保留 basic/token scheme,不与数据库结构混淆。 +9. phone=NULL 的用户名账号不能自助找回密码,只能由租户管理员重置。 +10. HotGo 与 IM 当前以共享租户业务库集成,不再使用内部 HMAC 管理 HTTP;跨进程安全事件先经数据库 outbox,后续迁移 Kafka。 + +评审通过后,实施工作拆分为数据库迁移、IM 注册与认证、HotGo 后台、客户端对接和全量回归五个任务。 diff --git a/docs/im/归档.zip b/docs/im/归档.zip new file mode 100644 index 0000000..459e0ea Binary files /dev/null and b/docs/im/归档.zip differ diff --git a/server/api/admin/imconfiginternal/im_config.go b/server/api/admin/imconfiginternal/im_config.go new file mode 100644 index 0000000..4b01c5c --- /dev/null +++ b/server/api/admin/imconfiginternal/im_config.go @@ -0,0 +1,59 @@ +package imconfiginternal + +import "github.com/gogf/gf/v2/frame/g" + +type TenantReq struct { + g.Meta `path:"/internal/im-config/tenants/by-code/{code}" method:"get" tags:"IM内部配置" summary:"按编码获取IM租户"` + Code string `json:"code" in:"path" dc:"租户编码"` +} + +type TenantByIDReq struct { + g.Meta `path:"/internal/im-config/tenants/by-id/{id}" method:"get" tags:"IM内部配置" summary:"按ID获取IM租户"` + Id int64 `json:"id" in:"path" dc:"租户ID"` +} + +type TenantListReq struct { + g.Meta `path:"/internal/im-config/tenants" method:"get" tags:"IM内部配置" summary:"获取IM租户列表"` + State string `json:"state" dc:"状态"` + Limit int `json:"limit" dc:"数量"` +} + +type TenantRes struct { + *TenantModel +} + +type TenantListRes struct { + Items []*TenantModel `json:"items" dc:"租户列表"` +} + +type TenantModel struct { + ID int64 `json:"id"` + Code string `json:"code"` + Name string `json:"name"` + TenantDesc *string `json:"tenant_desc,omitempty"` + DBName string `json:"db_name"` + DBDSN string `json:"db_dsn"` + State int `json:"state"` + CreatedAt string `json:"created_at"` + CreatedBy int64 `json:"created_by"` + UpdatedAt string `json:"updated_at"` + UpdatedBy int64 `json:"updated_by"` +} + +type RegistrationSmsReq struct { + g.Meta `path:"/internal/im-config/registration/sms" method:"get" tags:"IM内部配置" summary:"获取注册短信开关"` +} + +type RegistrationSmsRes struct { + Enabled bool `json:"enabled" dc:"是否开启"` +} + +type ThirdPartyReq struct { + g.Meta `path:"/internal/im-config/third-party/{group}" method:"get" tags:"IM内部配置" summary:"获取第三方配置"` + Group string `json:"group" in:"path" dc:"配置组"` + Keys string `json:"keys" dc:"配置键,逗号分隔"` +} + +type ThirdPartyRes struct { + Values map[string]string `json:"values" dc:"配置值"` +} diff --git a/server/api/admin/imdeadletter/im_dead_letter.go b/server/api/admin/imdeadletter/im_dead_letter.go new file mode 100644 index 0000000..8fccaaf --- /dev/null +++ b/server/api/admin/imdeadletter/im_dead_letter.go @@ -0,0 +1,57 @@ +// Package imdeadletter +// @Link https://github.com/bufanyun/hotgo +// @Copyright Copyright (c) 2026 HotGo +// @Author Ms <133814250@qq.com> +// @License https://github.com/bufanyun/hotgo/blob/master/LICENSE +package imdeadletter + +import ( + "hotgo/internal/model/input/sysin" + + "github.com/gogf/gf/v2/frame/g" +) + +type ListReq struct { + g.Meta `path:"/imDeadLetter/list" method:"get" tags:"IM Kafka死信" summary:"获取IM Kafka死信列表"` + sysin.ImDeadLetterListInp +} + +type ListRes struct { + *sysin.ImDeadLetterPageModel +} + +type ViewReq struct { + g.Meta `path:"/imDeadLetter/view" method:"get" tags:"IM Kafka死信" summary:"获取IM Kafka死信详情"` + sysin.ImDeadLetterViewInp +} + +type ViewRes struct { + *sysin.ImDeadLetterViewModel +} + +type ReplayReq struct { + g.Meta `path:"/imDeadLetter/replay" method:"post" tags:"IM Kafka死信" summary:"重放IM Kafka死信"` + sysin.ImDeadLetterActionInp +} + +type ReplayRes struct { + *sysin.ImGroupCommandModel +} + +type IgnoreReq struct { + g.Meta `path:"/imDeadLetter/ignore" method:"post" tags:"IM Kafka死信" summary:"忽略IM Kafka死信"` + sysin.ImDeadLetterActionInp +} + +type IgnoreRes struct { + *sysin.ImGroupCommandModel +} + +type ResolveReq struct { + g.Meta `path:"/imDeadLetter/resolve" method:"post" tags:"IM Kafka死信" summary:"标记IM Kafka死信已处理"` + sysin.ImDeadLetterActionInp +} + +type ResolveRes struct { + *sysin.ImGroupCommandModel +} diff --git a/server/api/admin/imgroup/im_group.go b/server/api/admin/imgroup/im_group.go index 44f4919..fc55a89 100644 --- a/server/api/admin/imgroup/im_group.go +++ b/server/api/admin/imgroup/im_group.go @@ -62,6 +62,16 @@ type GovernanceUpdateReq struct { type GovernanceUpdateRes struct{} +// ProfileUpdateReq 修改IM群资料 +type ProfileUpdateReq struct { + g.Meta `path:"/imGroup/profileUpdate" method:"post" tags:"IM群组" summary:"修改IM群资料"` + sysin.ImGroupProfileUpdateInp +} + +type ProfileUpdateRes struct { + *sysin.ImGroupCommandModel +} + // DissolveReq 解散IM群 type DissolveReq struct { g.Meta `path:"/imGroup/dissolve" method:"post" tags:"IM群组" summary:"解散IM群"` @@ -78,6 +88,16 @@ type MuteReq struct { type MuteRes struct{} +// MemberMuteReq Kafka禁言IM群成员 +type MemberMuteReq struct { + g.Meta `path:"/imGroup/memberMute" method:"post" tags:"IM群组" summary:"Kafka禁言IM群成员"` + sysin.ImGroupMuteInp +} + +type MemberMuteRes struct { + *sysin.ImGroupCommandModel +} + // UnmuteReq 解除IM群成员禁言 type UnmuteReq struct { g.Meta `path:"/imGroup/unmute" method:"post" tags:"IM群组" summary:"解除IM群成员禁言"` @@ -86,6 +106,26 @@ type UnmuteReq struct { type UnmuteRes struct{} +// MemberUnmuteReq Kafka解除IM群成员禁言 +type MemberUnmuteReq struct { + g.Meta `path:"/imGroup/memberUnmute" method:"post" tags:"IM群组" summary:"Kafka解除IM群成员禁言"` + sysin.ImGroupUnmuteInp +} + +type MemberUnmuteRes struct { + *sysin.ImGroupCommandModel +} + +// MemberRemoveReq 移出IM群成员 +type MemberRemoveReq struct { + g.Meta `path:"/imGroup/memberRemove" method:"post" tags:"IM群组" summary:"移出IM群成员"` + sysin.ImGroupMemberRemoveInp +} + +type MemberRemoveRes struct { + *sysin.ImGroupCommandModel +} + // JoinRequestApproveReq 通过IM入群申请 type JoinRequestApproveReq struct { g.Meta `path:"/imGroup/joinRequestApprove" method:"post" tags:"IM群组" summary:"通过IM入群申请"` @@ -110,6 +150,26 @@ type RoleUpdateReq struct { type RoleUpdateRes struct{} +// AdminSetReq 设置IM群管理员 +type AdminSetReq struct { + g.Meta `path:"/imGroup/adminSet" method:"post" tags:"IM群组" summary:"设置IM群管理员"` + sysin.ImGroupRoleUpdateInp +} + +type AdminSetRes struct { + *sysin.ImGroupCommandModel +} + +// AdminRemoveReq 删除IM群管理员 +type AdminRemoveReq struct { + g.Meta `path:"/imGroup/adminRemove" method:"post" tags:"IM群组" summary:"删除IM群管理员"` + sysin.ImGroupRoleUpdateInp +} + +type AdminRemoveRes struct { + *sysin.ImGroupCommandModel +} + // OwnerTransferReq 转让IM群主 type OwnerTransferReq struct { g.Meta `path:"/imGroup/ownerTransfer" method:"post" tags:"IM群组" summary:"转让IM群主"` diff --git a/server/api/admin/imkafkaaudit/im_kafka_audit.go b/server/api/admin/imkafkaaudit/im_kafka_audit.go new file mode 100644 index 0000000..c607639 --- /dev/null +++ b/server/api/admin/imkafkaaudit/im_kafka_audit.go @@ -0,0 +1,27 @@ +package imkafkaaudit + +import ( + "hotgo/internal/model/input/form" + "hotgo/internal/model/input/sysin" + + "github.com/gogf/gf/v2/frame/g" +) + +type ListReq struct { + g.Meta `path:"/imKafkaAudit/list" method:"get" tags:"IM Kafka审计事件" summary:"获取IM Kafka审计事件列表"` + sysin.ImKafkaAuditListInp +} + +type ListRes struct { + form.PageRes + List []*sysin.ImKafkaAuditListModel `json:"list" dc:"数据列表"` +} + +type ViewReq struct { + g.Meta `path:"/imKafkaAudit/view" method:"get" tags:"IM Kafka审计事件" summary:"获取IM Kafka审计事件详情"` + sysin.ImKafkaAuditViewInp +} + +type ViewRes struct { + *sysin.ImKafkaAuditViewModel +} diff --git a/server/api/admin/imkafkacommand/im_kafka_command.go b/server/api/admin/imkafkacommand/im_kafka_command.go new file mode 100644 index 0000000..99f0aa3 --- /dev/null +++ b/server/api/admin/imkafkacommand/im_kafka_command.go @@ -0,0 +1,50 @@ +// Package imkafkacommand +// @Link https://github.com/bufanyun/hotgo +// @Copyright Copyright (c) 2026 HotGo +// @Author Ms <133814250@qq.com> +// @License https://github.com/bufanyun/hotgo/blob/master/LICENSE +package imkafkacommand + +import ( + "hotgo/internal/model/input/form" + "hotgo/internal/model/input/sysin" + + "github.com/gogf/gf/v2/frame/g" +) + +type ListReq struct { + g.Meta `path:"/imKafkaCommand/list" method:"get" tags:"IM后台命令" summary:"获取IM后台命令列表"` + sysin.ImKafkaCommandListInp +} + +type ListRes struct { + form.PageRes + List []*sysin.ImKafkaCommandListModel `json:"list" dc:"数据列表"` +} + +type ViewReq struct { + g.Meta `path:"/imKafkaCommand/view" method:"get" tags:"IM后台命令" summary:"获取IM后台命令详情"` + sysin.ImKafkaCommandViewInp +} + +type ViewRes struct { + *sysin.ImKafkaCommandViewModel +} + +type CacheInvalidateReq struct { + g.Meta `path:"/imKafkaCommand/cacheInvalidate" method:"post" tags:"IM后台命令" summary:"发送IM缓存失效测试事件"` + sysin.ImKafkaCacheInvalidateInp +} + +type CacheInvalidateRes struct { + *sysin.ImKafkaEventModel +} + +type WorkbenchEventReq struct { + g.Meta `path:"/imKafkaCommand/workbenchEvent" method:"post" tags:"IM后台命令" summary:"发送IM工作台测试事件"` + sysin.ImKafkaWorkbenchEventInp +} + +type WorkbenchEventRes struct { + *sysin.ImKafkaEventModel +} diff --git a/server/api/admin/imuser/im_user.go b/server/api/admin/imuser/im_user.go index 36ae4ed..428d056 100644 --- a/server/api/admin/imuser/im_user.go +++ b/server/api/admin/imuser/im_user.go @@ -63,3 +63,75 @@ type DevicesRes struct { form.PageRes List []*sysin.ImUserDeviceListModel `json:"list" dc:"设备列表"` } + +type BanReq struct { + g.Meta `path:"/imUser/ban" method:"post" tags:"IM用户" summary:"封禁IM用户"` + sysin.ImUserGovernanceInp +} + +type BanRes struct { + *sysin.ImUserCommandModel +} + +type UnbanReq struct { + g.Meta `path:"/imUser/unban" method:"post" tags:"IM用户" summary:"解封IM用户"` + sysin.ImUserGovernanceInp +} + +type UnbanRes struct { + *sysin.ImUserCommandModel +} + +type FreezeReq struct { + g.Meta `path:"/imUser/freeze" method:"post" tags:"IM用户" summary:"冻结IM用户"` + sysin.ImUserGovernanceInp +} + +type FreezeRes struct { + *sysin.ImUserCommandModel +} + +type UnfreezeReq struct { + g.Meta `path:"/imUser/unfreeze" method:"post" tags:"IM用户" summary:"解冻IM用户"` + sysin.ImUserGovernanceInp +} + +type UnfreezeRes struct { + *sysin.ImUserCommandModel +} + +type DeviceKickReq struct { + g.Meta `path:"/imUser/deviceKick" method:"post" tags:"IM用户" summary:"踢出IM用户设备"` + sysin.ImUserGovernanceInp +} + +type DeviceKickRes struct { + *sysin.ImUserCommandModel +} + +type LogoutAllReq struct { + g.Meta `path:"/imUser/logoutAll" method:"post" tags:"IM用户" summary:"踢出IM用户全部会话"` + sysin.ImUserGovernanceInp +} + +type LogoutAllRes struct { + *sysin.ImUserCommandModel +} + +type AccountCancelReq struct { + g.Meta `path:"/imUser/accountCancel" method:"post" tags:"IM用户" summary:"注销IM用户账号"` + sysin.ImUserGovernanceInp +} + +type AccountCancelRes struct { + *sysin.ImUserCommandModel +} + +type AccountDeleteReq struct { + g.Meta `path:"/imUser/accountDelete" method:"post" tags:"IM用户" summary:"删除IM用户账号"` + sysin.ImUserGovernanceInp +} + +type AccountDeleteRes struct { + *sysin.ImUserCommandModel +} diff --git a/server/internal/cmd/http.go b/server/internal/cmd/http.go index 7a4cb3e..c00f2c6 100644 --- a/server/internal/cmd/http.go +++ b/server/internal/cmd/http.go @@ -45,6 +45,9 @@ var ( }...) s.Group("/", func(group *ghttp.RouterGroup) { + // 注册内部服务路由 + router.Internal(ctx, group) + // 注册后台路由 router.Admin(ctx, group) diff --git a/server/internal/consts/im_kafka.go b/server/internal/consts/im_kafka.go new file mode 100644 index 0000000..474b5c9 --- /dev/null +++ b/server/internal/consts/im_kafka.go @@ -0,0 +1,88 @@ +// Package consts +// @Link https://github.com/bufanyun/hotgo +// @Copyright Copyright (c) 2026 HotGo +// @Author Ms <133814250@qq.com> +// @License https://github.com/bufanyun/hotgo/blob/master/LICENSE +package consts + +import ( + "hotgo/internal/library/dict" + "hotgo/internal/model" +) + +func init() { + dict.RegisterEnums("ImAdminCommandStatusOptions", "IM后台命令状态选项", ImAdminCommandStatusOptions) + dict.RegisterEnums("ImAdminOutboxStatusOptions", "IM后台Outbox状态选项", ImAdminOutboxStatusOptions) + dict.RegisterEnums("ImKafkaDeadLetterStatusOptions", "IM Kafka死信状态选项", ImKafkaDeadLetterStatusOptions) +} + +const ( + ImKafkaSourceService = "hotgo-admin" + ImKafkaTargetService = "im-server" + + ImAdminCommandStatusPending = "pending" + ImAdminCommandStatusRunning = "running" + ImAdminCommandStatusSucceeded = "succeeded" + ImAdminCommandStatusFailed = "failed" + ImAdminCommandStatusDead = "dead" + + ImAdminOutboxStatusPending = "pending" + ImAdminOutboxStatusSent = "sent" + ImAdminOutboxStatusFailed = "failed" + + ImAdminCommandGroupProfileUpdate = "group.profile.update" + ImAdminCommandGroupGovernUpdate = "group.governance.update" + ImAdminCommandGroupAdminSet = "group.admin.set" + ImAdminCommandGroupAdminRemove = "group.admin.remove" + ImAdminCommandGroupMemberMute = "group.member.mute" + ImAdminCommandGroupMemberUnmute = "group.member.unmute" + ImAdminCommandGroupMemberRemove = "group.member.remove" + ImAdminCommandGroupOwnerTransfer = "group.owner.transfer" + ImAdminCommandUserBan = "user.ban" + ImAdminCommandUserUnban = "user.unban" + ImAdminCommandUserFreeze = "user.freeze" + ImAdminCommandUserUnfreeze = "user.unfreeze" + ImAdminCommandUserDeviceKick = "user.device.kick" + ImAdminCommandUserLogoutAll = "user.logout_all" + ImAdminCommandUserCancel = "user.account.cancel" + ImAdminCommandUserDelete = "user.account.delete" + ImAdminCommandKafkaDeadReplay = "kafka.dead_letter.replay" + ImAdminCommandKafkaDeadIgnore = "kafka.dead_letter.ignore" + ImAdminCommandKafkaDeadResolve = "kafka.dead_letter.resolve" + + ImKafkaDeadLetterStatusDead = "dead" + ImKafkaDeadLetterStatusPendingReplay = "pending_replay" + ImKafkaDeadLetterStatusReplayed = "replayed" + ImKafkaDeadLetterStatusIgnored = "ignored" + ImKafkaDeadLetterStatusResolved = "resolved" + + ImConfigEventTenantUpdated = "tenant.updated" + ImConfigEventTenantStatusChanged = "tenant.status.changed" + ImConfigEventTenantLimitsUpdated = "tenant.limits.updated" + ImConfigEventTenantFeaturesUpdated = "tenant.features.updated" + ImConfigEventDictionaryUpdated = "dictionary.updated" + ImConfigEventThirdPartyUpdated = "third_party.updated" + ImConfigEventRegistrationUpdated = "registration.config.updated" +) + +var ImAdminCommandStatusOptions = []*model.Option{ + dict.GenWarningOption(ImAdminCommandStatusPending, "待投递"), + dict.GenInfoOption(ImAdminCommandStatusRunning, "执行中"), + dict.GenSuccessOption(ImAdminCommandStatusSucceeded, "成功"), + dict.GenErrorOption(ImAdminCommandStatusFailed, "失败"), + dict.GenErrorOption(ImAdminCommandStatusDead, "死信"), +} + +var ImAdminOutboxStatusOptions = []*model.Option{ + dict.GenWarningOption(ImAdminOutboxStatusPending, "待投递"), + dict.GenSuccessOption(ImAdminOutboxStatusSent, "已投递"), + dict.GenErrorOption(ImAdminOutboxStatusFailed, "投递失败"), +} + +var ImKafkaDeadLetterStatusOptions = []*model.Option{ + dict.GenErrorOption(ImKafkaDeadLetterStatusDead, "死信"), + dict.GenWarningOption(ImKafkaDeadLetterStatusPendingReplay, "等待重放"), + dict.GenSuccessOption(ImKafkaDeadLetterStatusReplayed, "已重放"), + dict.GenDefaultOption(ImKafkaDeadLetterStatusIgnored, "已忽略"), + dict.GenSuccessOption(ImKafkaDeadLetterStatusResolved, "已处理"), +} diff --git a/server/internal/consts/queue.go b/server/internal/consts/queue.go index 0aa91a4..333630e 100644 --- a/server/internal/consts/queue.go +++ b/server/internal/consts/queue.go @@ -10,4 +10,12 @@ const ( QueueLogTopic = `request_log` // 访问日志 QueueLoginLogTopic = `login_log` // 登录日志 QueueServeLogTopic = `serve_log` // 服务日志 + + QueueImAdminCommandTopic = `im.admin.command` // IM后台命令 + QueueImAdminCommandResultTopic = `im.admin.command.result` // IM后台命令结果 + QueueImConfigEventTopic = `im.config.event` // IM配置事件 + QueueImCacheInvalidateTopic = `im.cache.invalidate` // IM缓存失效事件 + QueueImWorkbenchEventTopic = `im.workbench.event` // IM工作台事件 + QueueImDeadLetterTopic = `im.dead.letter` // IM死信事件 + QueueImAuditEventTopic = `im.audit.event` // IM审计事件 ) diff --git a/server/internal/controller/admin/sys/im_dead_letter.go b/server/internal/controller/admin/sys/im_dead_letter.go new file mode 100644 index 0000000..318d642 --- /dev/null +++ b/server/internal/controller/admin/sys/im_dead_letter.go @@ -0,0 +1,66 @@ +// Package sys +// @Link https://github.com/bufanyun/hotgo +// @Copyright Copyright (c) 2026 HotGo +// @Author Ms <133814250@qq.com> +// @License https://github.com/bufanyun/hotgo/blob/master/LICENSE +package sys + +import ( + "context" + "hotgo/api/admin/imdeadletter" + "hotgo/internal/service" +) + +var ImDeadLetter = cImDeadLetter{} + +type cImDeadLetter struct{} + +func (c *cImDeadLetter) List(ctx context.Context, req *imdeadletter.ListReq) (res *imdeadletter.ListRes, err error) { + data, err := service.SysImDeadLetter().List(ctx, &req.ImDeadLetterListInp) + if err != nil { + return + } + res = new(imdeadletter.ListRes) + res.ImDeadLetterPageModel = data + return +} + +func (c *cImDeadLetter) View(ctx context.Context, req *imdeadletter.ViewReq) (res *imdeadletter.ViewRes, err error) { + data, err := service.SysImDeadLetter().View(ctx, &req.ImDeadLetterViewInp) + if err != nil { + return + } + res = new(imdeadletter.ViewRes) + res.ImDeadLetterViewModel = data + return +} + +func (c *cImDeadLetter) Replay(ctx context.Context, req *imdeadletter.ReplayReq) (res *imdeadletter.ReplayRes, err error) { + data, err := service.SysImDeadLetter().Replay(ctx, &req.ImDeadLetterActionInp) + if err != nil { + return + } + res = new(imdeadletter.ReplayRes) + res.ImGroupCommandModel = data + return +} + +func (c *cImDeadLetter) Ignore(ctx context.Context, req *imdeadletter.IgnoreReq) (res *imdeadletter.IgnoreRes, err error) { + data, err := service.SysImDeadLetter().Ignore(ctx, &req.ImDeadLetterActionInp) + if err != nil { + return + } + res = new(imdeadletter.IgnoreRes) + res.ImGroupCommandModel = data + return +} + +func (c *cImDeadLetter) Resolve(ctx context.Context, req *imdeadletter.ResolveReq) (res *imdeadletter.ResolveRes, err error) { + data, err := service.SysImDeadLetter().Resolve(ctx, &req.ImDeadLetterActionInp) + if err != nil { + return + } + res = new(imdeadletter.ResolveRes) + res.ImGroupCommandModel = data + return +} diff --git a/server/internal/controller/admin/sys/im_group.go b/server/internal/controller/admin/sys/im_group.go index f9a39b4..fddde99 100644 --- a/server/internal/controller/admin/sys/im_group.go +++ b/server/internal/controller/admin/sys/im_group.go @@ -81,6 +81,17 @@ func (c *cImGroup) GovernanceUpdate(ctx context.Context, req *imgroup.Governance return } +// ProfileUpdate 修改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 +} + // Dissolve 解散IM群 func (c *cImGroup) Dissolve(ctx context.Context, req *imgroup.DissolveReq) (res *imgroup.DissolveRes, err error) { err = service.SysImGroup().Dissolve(ctx, &req.ImGroupDissolveInp) @@ -93,12 +104,45 @@ func (c *cImGroup) Mute(ctx context.Context, req *imgroup.MuteReq) (res *imgroup 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.ImGroupMuteInp) + if err != nil { + return + } + res = new(imgroup.MemberMuteRes) + res.ImGroupCommandModel = data + return +} + // Unmute 解除IM群成员禁言 func (c *cImGroup) Unmute(ctx context.Context, req *imgroup.UnmuteReq) (res *imgroup.UnmuteRes, err error) { err = service.SysImGroup().Unmute(ctx, &req.ImGroupUnmuteInp) 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.ImGroupUnmuteInp) + if err != nil { + return + } + res = new(imgroup.MemberUnmuteRes) + res.ImGroupCommandModel = data + return +} + +// MemberRemove 移出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 +} + // JoinRequestApprove 通过IM入群申请 func (c *cImGroup) JoinRequestApprove(ctx context.Context, req *imgroup.JoinRequestApproveReq) (res *imgroup.JoinRequestApproveRes, err error) { err = service.SysImGroup().JoinRequestApprove(ctx, &req.ImGroupJoinRequestResolveInp) @@ -117,6 +161,28 @@ func (c *cImGroup) RoleUpdate(ctx context.Context, req *imgroup.RoleUpdateReq) ( return } +// AdminSet 设置IM群管理员 +func (c *cImGroup) AdminSet(ctx context.Context, req *imgroup.AdminSetReq) (res *imgroup.AdminSetRes, err error) { + data, err := service.SysImGroup().AdminSet(ctx, &req.ImGroupRoleUpdateInp) + if err != nil { + return + } + res = new(imgroup.AdminSetRes) + res.ImGroupCommandModel = data + return +} + +// AdminRemove 删除IM群管理员 +func (c *cImGroup) AdminRemove(ctx context.Context, req *imgroup.AdminRemoveReq) (res *imgroup.AdminRemoveRes, err error) { + data, err := service.SysImGroup().AdminRemove(ctx, &req.ImGroupRoleUpdateInp) + if err != nil { + return + } + res = new(imgroup.AdminRemoveRes) + res.ImGroupCommandModel = data + return +} + // OwnerTransfer 转让IM群主 func (c *cImGroup) OwnerTransfer(ctx context.Context, req *imgroup.OwnerTransferReq) (res *imgroup.OwnerTransferRes, err error) { err = service.SysImGroup().OwnerTransfer(ctx, &req.ImGroupOwnerTransferInp) diff --git a/server/internal/controller/admin/sys/im_kafka_audit.go b/server/internal/controller/admin/sys/im_kafka_audit.go new file mode 100644 index 0000000..3f74f0e --- /dev/null +++ b/server/internal/controller/admin/sys/im_kafka_audit.go @@ -0,0 +1,36 @@ +package sys + +import ( + "context" + "hotgo/api/admin/imkafkaaudit" + "hotgo/internal/model/input/sysin" + "hotgo/internal/service" +) + +var ImKafkaAudit = cImKafkaAudit{} + +type cImKafkaAudit struct{} + +func (c *cImKafkaAudit) List(ctx context.Context, req *imkafkaaudit.ListReq) (res *imkafkaaudit.ListRes, err error) { + list, totalCount, err := service.SysImKafkaAudit().List(ctx, &req.ImKafkaAuditListInp) + if err != nil { + return + } + if list == nil { + list = []*sysin.ImKafkaAuditListModel{} + } + res = new(imkafkaaudit.ListRes) + res.List = list + res.PageRes.Pack(req, totalCount) + return +} + +func (c *cImKafkaAudit) View(ctx context.Context, req *imkafkaaudit.ViewReq) (res *imkafkaaudit.ViewRes, err error) { + data, err := service.SysImKafkaAudit().View(ctx, &req.ImKafkaAuditViewInp) + if err != nil { + return + } + res = new(imkafkaaudit.ViewRes) + res.ImKafkaAuditViewModel = data + return +} diff --git a/server/internal/controller/admin/sys/im_kafka_command.go b/server/internal/controller/admin/sys/im_kafka_command.go new file mode 100644 index 0000000..b7fa7c4 --- /dev/null +++ b/server/internal/controller/admin/sys/im_kafka_command.go @@ -0,0 +1,61 @@ +// Package sys +// @Link https://github.com/bufanyun/hotgo +// @Copyright Copyright (c) 2026 HotGo +// @Author Ms <133814250@qq.com> +// @License https://github.com/bufanyun/hotgo/blob/master/LICENSE +package sys + +import ( + "context" + "hotgo/api/admin/imkafkacommand" + "hotgo/internal/model/input/sysin" + "hotgo/internal/service" +) + +var ImKafkaCommand = cImKafkaCommand{} + +type cImKafkaCommand struct{} + +func (c *cImKafkaCommand) List(ctx context.Context, req *imkafkacommand.ListReq) (res *imkafkacommand.ListRes, err error) { + list, totalCount, err := service.SysImKafkaCommand().List(ctx, &req.ImKafkaCommandListInp) + if err != nil { + return + } + if list == nil { + list = []*sysin.ImKafkaCommandListModel{} + } + res = new(imkafkacommand.ListRes) + res.List = list + res.PageRes.Pack(req, totalCount) + return +} + +func (c *cImKafkaCommand) View(ctx context.Context, req *imkafkacommand.ViewReq) (res *imkafkacommand.ViewRes, err error) { + data, err := service.SysImKafkaCommand().View(ctx, &req.ImKafkaCommandViewInp) + if err != nil { + return + } + res = new(imkafkacommand.ViewRes) + res.ImKafkaCommandViewModel = data + return +} + +func (c *cImKafkaCommand) CacheInvalidate(ctx context.Context, req *imkafkacommand.CacheInvalidateReq) (res *imkafkacommand.CacheInvalidateRes, err error) { + data, err := service.SysImKafkaCommand().CacheInvalidate(ctx, &req.ImKafkaCacheInvalidateInp) + if err != nil { + return + } + res = new(imkafkacommand.CacheInvalidateRes) + res.ImKafkaEventModel = data + return +} + +func (c *cImKafkaCommand) WorkbenchEvent(ctx context.Context, req *imkafkacommand.WorkbenchEventReq) (res *imkafkacommand.WorkbenchEventRes, err error) { + data, err := service.SysImKafkaCommand().WorkbenchEvent(ctx, &req.ImKafkaWorkbenchEventInp) + if err != nil { + return + } + res = new(imkafkacommand.WorkbenchEventRes) + res.ImKafkaEventModel = data + return +} diff --git a/server/internal/controller/admin/sys/im_user.go b/server/internal/controller/admin/sys/im_user.go index ce931ea..88a1235 100644 --- a/server/internal/controller/admin/sys/im_user.go +++ b/server/internal/controller/admin/sys/im_user.go @@ -77,3 +77,83 @@ func (c *cImUser) Devices(ctx context.Context, req *imuser.DevicesReq) (res *imu res.PageRes.Pack(req, totalCount) return } + +func (c *cImUser) Ban(ctx context.Context, req *imuser.BanReq) (res *imuser.BanRes, err error) { + data, err := service.SysImUser().Ban(ctx, &req.ImUserGovernanceInp) + if err != nil { + return + } + res = new(imuser.BanRes) + res.ImUserCommandModel = data + return +} + +func (c *cImUser) Unban(ctx context.Context, req *imuser.UnbanReq) (res *imuser.UnbanRes, err error) { + data, err := service.SysImUser().Unban(ctx, &req.ImUserGovernanceInp) + if err != nil { + return + } + res = new(imuser.UnbanRes) + res.ImUserCommandModel = data + return +} + +func (c *cImUser) Freeze(ctx context.Context, req *imuser.FreezeReq) (res *imuser.FreezeRes, err error) { + data, err := service.SysImUser().Freeze(ctx, &req.ImUserGovernanceInp) + if err != nil { + return + } + res = new(imuser.FreezeRes) + res.ImUserCommandModel = data + return +} + +func (c *cImUser) Unfreeze(ctx context.Context, req *imuser.UnfreezeReq) (res *imuser.UnfreezeRes, err error) { + data, err := service.SysImUser().Unfreeze(ctx, &req.ImUserGovernanceInp) + if err != nil { + return + } + res = new(imuser.UnfreezeRes) + res.ImUserCommandModel = data + return +} + +func (c *cImUser) DeviceKick(ctx context.Context, req *imuser.DeviceKickReq) (res *imuser.DeviceKickRes, err error) { + data, err := service.SysImUser().DeviceKick(ctx, &req.ImUserGovernanceInp) + if err != nil { + return + } + res = new(imuser.DeviceKickRes) + res.ImUserCommandModel = data + return +} + +func (c *cImUser) LogoutAll(ctx context.Context, req *imuser.LogoutAllReq) (res *imuser.LogoutAllRes, err error) { + data, err := service.SysImUser().LogoutAll(ctx, &req.ImUserGovernanceInp) + if err != nil { + return + } + res = new(imuser.LogoutAllRes) + res.ImUserCommandModel = data + return +} + +func (c *cImUser) AccountCancel(ctx context.Context, req *imuser.AccountCancelReq) (res *imuser.AccountCancelRes, err error) { + data, err := service.SysImUser().AccountCancel(ctx, &req.ImUserGovernanceInp) + if err != nil { + return + } + res = new(imuser.AccountCancelRes) + res.ImUserCommandModel = data + return +} + +func (c *cImUser) AccountDelete(ctx context.Context, req *imuser.AccountDeleteReq) (res *imuser.AccountDeleteRes, err error) { + data, err := service.SysImUser().AccountDelete(ctx, &req.ImUserGovernanceInp) + if err != nil { + return + } + res = new(imuser.AccountDeleteRes) + res.ImUserCommandModel = data + return +} diff --git a/server/internal/controller/internalapi/imconfig/im_config.go b/server/internal/controller/internalapi/imconfig/im_config.go new file mode 100644 index 0000000..bbe9c1f --- /dev/null +++ b/server/internal/controller/internalapi/imconfig/im_config.go @@ -0,0 +1,143 @@ +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/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().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 *entity.SysConfig + 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.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().Model(dao.ImTenant.Table()).Safe().Ctx(ctx).Where(field, value).Scan(&tenant); err != nil { + return nil, gerror.Wrap(err, "获取IM租户配置失败") + } + if tenant == nil || tenant.Id <= 0 { + return nil, gerror.New("IM租户不存在") + } + return tenant, nil +} + +func tenantToModel(tenant *entity.ImTenant) *api.TenantModel { + if tenant == nil { + return nil + } + desc := tenant.TenantDesc + return &api.TenantModel{ + ID: tenant.Id, + Code: tenant.Code, + Name: tenant.Name, + TenantDesc: &desc, + DBName: tenant.DbName, + DBDSN: tenant.DbDsn, + State: tenant.State, + CreatedAt: gtimeToRFC3339(tenant.CreatedAt), + CreatedBy: tenant.CreatedBy, + UpdatedAt: gtimeToRFC3339(tenant.UpdatedAt), + UpdatedBy: tenant.UpdatedBy, + } +} + +func gtimeToRFC3339(t *gtime.Time) string { + if t == nil { + return "" + } + return t.Time.Format("2006-01-02T15:04:05Z07:00") +} + +func splitKeys(raw string) []string { + if raw == "" { + return nil + } + parts := strings.Split(raw, ",") + keys := make([]string, 0, len(parts)) + for _, part := range parts { + if part = strings.TrimSpace(part); part != "" { + keys = append(keys, part) + } + } + return keys +} diff --git a/server/internal/crons/im_admin_outbox.go b/server/internal/crons/im_admin_outbox.go new file mode 100644 index 0000000..e3c5933 --- /dev/null +++ b/server/internal/crons/im_admin_outbox.go @@ -0,0 +1,38 @@ +// Package crons +// @Link https://github.com/bufanyun/hotgo +// @Copyright Copyright (c) 2026 HotGo +// @Author Ms <133814250@qq.com> +// @License https://github.com/bufanyun/hotgo/blob/master/LICENSE +package crons + +import ( + "context" + "hotgo/internal/library/cron" + "hotgo/internal/library/imkafka" +) + +func init() { + cron.Register(ImAdminOutbox) +} + +var ImAdminOutbox = &cImAdminOutbox{name: "im_admin_outbox"} + +type cImAdminOutbox struct { + name string +} + +func (c *cImAdminOutbox) GetName() string { + return c.name +} + +func (c *cImAdminOutbox) Execute(ctx context.Context, parser *cron.Parser) (err error) { + sent, err := imkafka.DispatchPendingOutbox(ctx, 100) + if err != nil { + parser.Logger.Warningf(ctx, "IM后台命令Outbox补偿投递失败 err:%+v", err) + return err + } + if sent > 0 { + parser.Logger.Infof(ctx, "IM后台命令Outbox补偿投递完成 count:%d", sent) + } + return nil +} diff --git a/server/internal/library/imadmin/client.go b/server/internal/library/imadmin/client.go new file mode 100644 index 0000000..7b99cb3 --- /dev/null +++ b/server/internal/library/imadmin/client.go @@ -0,0 +1,268 @@ +package imadmin + +import ( + "bytes" + "context" + "crypto/hmac" + cryptorand "crypto/rand" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strconv" + "strings" + "time" + + "github.com/gogf/gf/v2/errors/gerror" + "github.com/gogf/gf/v2/os/gctx" +) + +const maxResponseBody = 10 << 20 + +type AdminScope struct { + TenantId int64 + OperatorId int64 + RoleKey string + RequestId string +} + +type Page[T any] struct { + Items []T `json:"items"` + Page int `json:"page"` + PerPage int `json:"per_page"` + Total int `json:"total"` +} + +type Client struct { + conf Config + http *http.Client +} + +func NewClient(conf Config) *Client { + return &Client{ + conf: conf, + http: &http.Client{Timeout: conf.Timeout}, + } +} + +func DefaultClient(ctx context.Context) *Client { + conf := LoadConfig(ctx) + return NewClient(conf) +} + +func (c *Client) CreateCode(ctx context.Context, scope AdminScope, in any, out any) error { + return c.doJSON(ctx, http.MethodPost, "/admin/v1/im/registration-invite-codes", scope, in, out) +} + +func (c *Client) CreateBatch(ctx context.Context, scope AdminScope, in any, out any) error { + return c.doJSON(ctx, http.MethodPost, "/admin/v1/im/registration-invite-code-batches", scope, in, out) +} + +func (c *Client) ListCodes(ctx context.Context, scope AdminScope, in any, out any) error { + if hasInviteCode(in) { + return c.doJSON(ctx, http.MethodPost, "/admin/v1/im/registration-invite-codes/search", scope, in, out) + } + path := "/admin/v1/im/registration-invite-codes" + if query := toQuery(in); query != "" { + path += "?" + query + } + return c.doJSON(ctx, http.MethodGet, path, scope, nil, out) +} + +func (c *Client) GetBatch(ctx context.Context, scope AdminScope, batchId int64, out any) error { + return c.doJSON(ctx, http.MethodGet, fmt.Sprintf("/admin/v1/im/registration-invite-code-batches/%d", batchId), scope, nil, out) +} + +func (c *Client) RevokeCode(ctx context.Context, scope AdminScope, codeId int64) error { + return c.doJSON(ctx, http.MethodPost, fmt.Sprintf("/admin/v1/im/registration-invite-codes/%d/revoke", codeId), scope, nil, nil) +} + +func (c *Client) RevokeBatch(ctx context.Context, scope AdminScope, batchId int64, out any) error { + return c.doJSON(ctx, http.MethodPost, fmt.Sprintf("/admin/v1/im/registration-invite-code-batches/%d/revoke", batchId), scope, nil, out) +} + +func (c *Client) ExportCodes(ctx context.Context, scope AdminScope, in any) (*http.Response, error) { + req, err := c.newRequest(ctx, http.MethodPost, "/admin/v1/im/registration-invite-codes/export", scope, in) + if err != nil { + return nil, err + } + resp, err := c.http.Do(req) + if err != nil { + return nil, gerror.New("IM服务暂不可用") + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + defer resp.Body.Close() + return nil, c.parseError(resp) + } + return resp, nil +} + +func (c *Client) doJSON(ctx context.Context, method string, path string, scope AdminScope, in any, out any) error { + req, err := c.newRequest(ctx, method, path, scope, in) + 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) newRequest(ctx context.Context, method string, path string, scope AdminScope, in any) (*http.Request, error) { + if c.conf.BaseUrl == "" { + return nil, gerror.New("IM管理API未配置") + } + if c.conf.ServiceSecret == "" { + return nil, gerror.New("IM管理API密钥未配置") + } + var body io.Reader + bodyBytes := []byte{} + if in != nil { + b, err := json.Marshal(in) + if err != nil { + return nil, gerror.Wrap(err, "序列化IM请求失败") + } + bodyBytes = b + body = bytes.NewReader(bodyBytes) + } + baseUrl := strings.TrimRight(c.conf.BaseUrl, "/") + req, err := http.NewRequestWithContext(ctx, method, baseUrl+path, body) + if err != nil { + return nil, gerror.Wrap(err, "创建IM请求失败") + } + if scope.RequestId == "" { + scope.RequestId = gctx.CtxId(ctx) + } + timestamp := strconv.FormatInt(time.Now().UTC().Unix(), 10) + nonce, err := randomNonce() + if err != nil { + return nil, err + } + bodyHash := sha256.Sum256(bodyBytes) + canonical := strings.Join([]string{ + method, + requestTarget(req), + c.conf.ServiceId, + fmt.Sprintf("%d", scope.TenantId), + fmt.Sprintf("%d", scope.OperatorId), + timestamp, + nonce, + scope.RequestId, + hex.EncodeToString(bodyHash[:]), + }, "\n") + mac := hmac.New(sha256.New, []byte(c.conf.ServiceSecret)) + _, _ = mac.Write([]byte(canonical)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "IM-HMAC "+hex.EncodeToString(mac.Sum(nil))) + req.Header.Set("X-IM-Service-ID", c.conf.ServiceId) + req.Header.Set("X-IM-Tenant-ID", fmt.Sprintf("%d", scope.TenantId)) + req.Header.Set("X-IM-Operator-ID", fmt.Sprintf("%d", scope.OperatorId)) + req.Header.Set("X-IM-Timestamp", timestamp) + req.Header.Set("X-IM-Nonce", nonce) + req.Header.Set("X-HotGo-Role-Key", scope.RoleKey) + req.Header.Set("X-Request-ID", scope.RequestId) + return req, nil +} + +func (c *Client) parseError(resp *http.Response) error { + body, _ := io.ReadAll(io.LimitReader(resp.Body, 64<<10)) + var envelope struct { + Message string `json:"message"` + Reason string `json:"reason"` + } + _ = json.Unmarshal(body, &envelope) + return stableError(envelope.Message, envelope.Reason) +} + +func stableError(message string, reason string) error { + if message != "" { + return gerror.New(message) + } + if reason != "" { + return gerror.New(reason) + } + return gerror.New("IM服务暂不可用") +} + +func hasInviteCode(in any) bool { + b, err := json.Marshal(in) + if err != nil { + return false + } + var m map[string]any + if err = json.Unmarshal(b, &m); err != nil { + return false + } + v, ok := m["inviteCode"] + if ok && strings.TrimSpace(fmt.Sprint(v)) != "" { + return true + } + v, ok = m["invite_code"] + return ok && strings.TrimSpace(fmt.Sprint(v)) != "" +} + +func requestTarget(req *http.Request) string { + target := req.URL.EscapedPath() + if req.URL.RawQuery != "" { + target += "?" + req.URL.RawQuery + } + return target +} + +func randomNonce() (string, error) { + buf := make([]byte, 16) + if _, err := cryptorand.Read(buf); err != nil { + return "", gerror.Wrap(err, "生成IM请求随机数失败") + } + return hex.EncodeToString(buf), nil +} + +func toQuery(in any) string { + b, err := json.Marshal(in) + if err != nil { + return "" + } + var m map[string]any + if err = json.Unmarshal(b, &m); err != nil { + return "" + } + values := url.Values{} + for key, value := range m { + if value == nil { + continue + } + text := strings.TrimSpace(fmt.Sprint(value)) + if text == "" || text == "0" || text == "" { + continue + } + values.Set(key, text) + } + return values.Encode() +} diff --git a/server/internal/library/imadmin/client_test.go b/server/internal/library/imadmin/client_test.go new file mode 100644 index 0000000..ef54b6e --- /dev/null +++ b/server/internal/library/imadmin/client_test.go @@ -0,0 +1,109 @@ +package imadmin + +import ( + "context" + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "fmt" + "io" + "net/http" + "net/http/httptest" + "strconv" + "strings" + "testing" + "time" +) + +type testInviteListRequest struct { + Page int `json:"page,omitempty"` + PerPage int `json:"per_page,omitempty"` + InviteCode string `json:"invite_code,omitempty"` + BatchID int64 `json:"batch_id,omitempty"` +} + +func TestListCodesUsesSignedGetWithSnakeQuery(t *testing.T) { + server := signedTestServer(t, http.MethodGet, "/admin/v1/im/registration-invite-codes", func(t *testing.T, r *http.Request, body []byte) { + if len(body) != 0 { + t.Fatalf("GET list should not send body, got %q", string(body)) + } + query := r.URL.Query() + if query.Get("page") != "2" || query.Get("per_page") != "20" || query.Get("batch_id") != "123" { + t.Fatalf("unexpected query: %s", r.URL.RawQuery) + } + }) + defer server.Close() + + client := NewClient(Config{BaseUrl: server.URL, ServiceId: "hotgo-admin", ServiceSecret: "secret", Timeout: time.Second}) + var out Page[struct{}] + err := client.ListCodes(context.Background(), AdminScope{TenantId: 9, OperatorId: 7, RequestId: "req-1"}, testInviteListRequest{Page: 2, PerPage: 20, BatchID: 123}, &out) + if err != nil { + t.Fatalf("ListCodes returned error: %v", err) + } +} + +func TestListCodesWithInviteCodeUsesSignedSearchPost(t *testing.T) { + server := signedTestServer(t, http.MethodPost, "/admin/v1/im/registration-invite-codes/search", func(t *testing.T, r *http.Request, body []byte) { + if !strings.Contains(string(body), `"invite_code":"Ab123"`) { + t.Fatalf("POST search should send snake_case invite code, got %s", string(body)) + } + }) + defer server.Close() + + client := NewClient(Config{BaseUrl: server.URL, ServiceId: "hotgo-admin", ServiceSecret: "secret", Timeout: time.Second}) + var out Page[struct{}] + err := client.ListCodes(context.Background(), AdminScope{TenantId: 9, OperatorId: 7, RequestId: "req-2"}, testInviteListRequest{Page: 1, PerPage: 20, InviteCode: "Ab123"}, &out) + if err != nil { + t.Fatalf("ListCodes returned error: %v", err) + } +} + +func signedTestServer(t *testing.T, method string, path string, inspect func(t *testing.T, r *http.Request, body []byte)) *httptest.Server { + t.Helper() + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != method { + t.Fatalf("method = %s, want %s", r.Method, method) + } + if r.URL.Path != path { + t.Fatalf("path = %s, want %s", r.URL.Path, path) + } + body, err := io.ReadAll(r.Body) + if err != nil { + t.Fatalf("read body: %v", err) + } + inspect(t, r, body) + assertSignedRequest(t, r, body, "secret") + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"data":{"items":[],"page":1,"per_page":20,"total":0}}`)) + })) +} + +func assertSignedRequest(t *testing.T, r *http.Request, body []byte, secret string) { + t.Helper() + for _, name := range []string{"Authorization", "X-IM-Service-ID", "X-IM-Tenant-ID", "X-IM-Operator-ID", "X-IM-Timestamp", "X-IM-Nonce", "X-Request-ID"} { + if r.Header.Get(name) == "" { + t.Fatalf("missing header %s", name) + } + } + if _, err := strconv.ParseInt(r.Header.Get("X-IM-Timestamp"), 10, 64); err != nil { + t.Fatalf("timestamp is not unix seconds: %v", err) + } + bodyHash := sha256.Sum256(body) + canonical := strings.Join([]string{ + r.Method, + r.URL.RequestURI(), + r.Header.Get("X-IM-Service-ID"), + r.Header.Get("X-IM-Tenant-ID"), + r.Header.Get("X-IM-Operator-ID"), + r.Header.Get("X-IM-Timestamp"), + r.Header.Get("X-IM-Nonce"), + r.Header.Get("X-Request-ID"), + hex.EncodeToString(bodyHash[:]), + }, "\n") + mac := hmac.New(sha256.New, []byte(secret)) + _, _ = mac.Write([]byte(canonical)) + expected := fmt.Sprintf("IM-HMAC %s", hex.EncodeToString(mac.Sum(nil))) + if r.Header.Get("Authorization") != expected { + t.Fatalf("bad signature\nwant: %s\n got: %s", expected, r.Header.Get("Authorization")) + } +} diff --git a/server/internal/library/imadmin/config.go b/server/internal/library/imadmin/config.go new file mode 100644 index 0000000..f6f62a2 --- /dev/null +++ b/server/internal/library/imadmin/config.go @@ -0,0 +1,55 @@ +package imadmin + +import ( + "context" + "os" + "strings" + "time" + + "github.com/gogf/gf/v2/frame/g" +) + +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"` +} + +func LoadConfig(ctx context.Context) Config { + timeout := g.Cfg().MustGet(ctx, "imAdminApi.timeout", "5s").Duration() + if timeout <= 0 { + timeout = 5 * time.Second + } + serviceSecret := g.Cfg().MustGet(ctx, "imAdminApi.serviceSecret").String() + if strings.HasPrefix(serviceSecret, "${") && strings.HasSuffix(serviceSecret, "}") { + serviceSecret = os.Getenv(strings.TrimSuffix(strings.TrimPrefix(serviceSecret, "${"), "}")) + } + 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, + } +} diff --git a/server/internal/library/imadmin/internal.go b/server/internal/library/imadmin/internal.go new file mode 100644 index 0000000..ee2293f --- /dev/null +++ b/server/internal/library/imadmin/internal.go @@ -0,0 +1,101 @@ +package imadmin + +import ( + "bytes" + "context" + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "io" + "net/http" + "strconv" + "strings" + "time" + + "github.com/gogf/gf/v2/errors/gerror" +) + +func (c *Client) InternalGet(ctx context.Context, path string, out any) error { + req, err := c.newInternalRequest(ctx, http.MethodGet, path, nil) + if err != nil { + return err + } + resp, err := c.http.Do(req) + if err != nil { + return gerror.New("IM内部查询服务暂不可用") + } + defer resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return c.parseError(resp) + } + if out == nil { + return nil + } + body, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseBody)) + if err != nil { + return gerror.Wrap(err, "读取IM内部查询响应失败") + } + var envelope struct { + Code int `json:"code"` + Message string `json:"message"` + Reason string `json:"reason"` + Data json.RawMessage `json:"data"` + } + if err = json.Unmarshal(body, &envelope); err == nil && len(envelope.Data) > 0 { + if envelope.Code != 0 { + return stableError(envelope.Message, envelope.Reason) + } + return json.Unmarshal(envelope.Data, out) + } + return json.Unmarshal(body, out) +} + +func (c *Client) newInternalRequest(ctx context.Context, method string, path string, bodyBytes []byte) (*http.Request, error) { + baseUrl := strings.TrimRight(c.conf.InternalBaseUrl, "/") + if baseUrl == "" { + baseUrl = strings.TrimRight(c.conf.BaseUrl, "/") + } + if baseUrl == "" { + return nil, gerror.New("IM内部查询API未配置") + } + serviceName := strings.TrimSpace(c.conf.InternalServiceName) + if serviceName == "" { + serviceName = strings.TrimSpace(c.conf.ServiceId) + } + if serviceName == "" { + return nil, gerror.New("IM内部查询服务名未配置") + } + secret := c.conf.InternalServiceSecret + if secret == "" { + secret = c.conf.ServiceSecret + } + if secret == "" { + return nil, gerror.New("IM内部查询API密钥未配置") + } + req, err := http.NewRequestWithContext(ctx, method, baseUrl+path, bytes.NewReader(bodyBytes)) + if err != nil { + return nil, gerror.Wrap(err, "创建IM内部查询请求失败") + } + timestamp := strconv.FormatInt(time.Now().UTC().Unix(), 10) + nonce, err := randomNonce() + if err != nil { + return nil, err + } + bodyHash := sha256.Sum256(bodyBytes) + signingText := strings.Join([]string{ + method, + requestTarget(req), + hex.EncodeToString(bodyHash[:]), + timestamp, + nonce, + }, "\n") + mac := hmac.New(sha256.New, []byte(secret)) + _, _ = mac.Write([]byte(signingText)) + req.Header.Set("Accept", "application/json") + req.Header.Set("X-Service-Name", serviceName) + req.Header.Set("X-Timestamp", timestamp) + req.Header.Set("X-Nonce", nonce) + req.Header.Set("X-Signature", hex.EncodeToString(mac.Sum(nil))) + return req, nil +} diff --git a/server/internal/library/iminternal/auth.go b/server/internal/library/iminternal/auth.go new file mode 100644 index 0000000..aed3d8d --- /dev/null +++ b/server/internal/library/iminternal/auth.go @@ -0,0 +1,135 @@ +package iminternal + +import ( + "bytes" + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "io" + "net/http" + "os" + "strconv" + "strings" + "sync" + "time" + + "github.com/gogf/gf/v2/frame/g" + "github.com/gogf/gf/v2/net/ghttp" +) + +const ( + headerService = "X-Service-Name" + headerTimestamp = "X-Timestamp" + headerNonce = "X-Nonce" + headerSignature = "X-Signature" +) + +var nonces sync.Map + +func Middleware(r *ghttp.Request) { + if !Authorize(r) { + return + } + r.Middleware.Next() +} + +func Authorize(r *ghttp.Request) bool { + ctx := r.GetCtx() + serviceName := strings.TrimSpace(r.Header.Get(headerService)) + timestampRaw := strings.TrimSpace(r.Header.Get(headerTimestamp)) + nonce := strings.TrimSpace(r.Header.Get(headerNonce)) + signature := strings.TrimSpace(r.Header.Get(headerSignature)) + expectedService := g.Cfg().MustGet(ctx, "imConfigApi.serviceName", "im-server").String() + secret := configuredSecret(r) + if serviceName == "" || timestampRaw == "" || nonce == "" || signature == "" || serviceName != expectedService || secret == "" { + writeAuthError(r, http.StatusUnauthorized, "internal signature required") + return false + } + timestamp, err := strconv.ParseInt(timestampRaw, 10, 64) + if err != nil { + writeAuthError(r, http.StatusUnauthorized, "invalid internal timestamp") + return false + } + window := g.Cfg().MustGet(ctx, "imConfigApi.timeWindow", "300s").Duration() + if window <= 0 { + window = 5 * time.Minute + } + delta := time.Since(time.Unix(timestamp, 0)) + if delta > window || delta < -window { + writeAuthError(r, http.StatusUnauthorized, "internal signature expired") + return false + } + body, err := readBody(r) + if err != nil { + writeAuthError(r, http.StatusInternalServerError, "read internal body failed") + return false + } + bodyHash := sha256.Sum256(body) + signingText := strings.Join([]string{ + r.Method, + r.URL.RequestURI(), + hex.EncodeToString(bodyHash[:]), + timestampRaw, + nonce, + }, "\n") + mac := hmac.New(sha256.New, []byte(secret)) + _, _ = mac.Write([]byte(signingText)) + if !hmac.Equal([]byte(strings.ToLower(signature)), []byte(hex.EncodeToString(mac.Sum(nil)))) { + writeAuthError(r, http.StatusUnauthorized, "invalid internal signature") + return false + } + if !claimNonce(serviceName, nonce, time.Now().Add(window)) { + writeAuthError(r, http.StatusUnauthorized, "internal nonce replayed") + return false + } + return true +} + +func configuredSecret(r *ghttp.Request) string { + ctx := r.GetCtx() + secret := g.Cfg().MustGet(ctx, "imConfigApi.serviceSecret").String() + if strings.HasPrefix(secret, "${") && strings.HasSuffix(secret, "}") { + secret = os.Getenv(strings.TrimSuffix(strings.TrimPrefix(secret, "${"), "}")) + } + if secret == "" { + secret = os.Getenv("IM_CONFIG_API_SECRET") + } + if secret == "" { + secret = g.Cfg().MustGet(ctx, "imInternalApi.serviceSecret").String() + } + return secret +} + +func readBody(r *ghttp.Request) ([]byte, error) { + if r.Body == nil { + return nil, nil + } + body, err := io.ReadAll(io.LimitReader(r.Body, 4<<20)) + if err != nil { + return nil, err + } + r.Body = io.NopCloser(bytes.NewReader(body)) + return body, nil +} + +func claimNonce(serviceName string, nonce string, expiresAt time.Time) bool { + now := time.Now() + nonces.Range(func(key, value any) bool { + if expires, ok := value.(time.Time); ok && expires.Before(now) { + nonces.Delete(key) + } + return true + }) + key := serviceName + ":" + nonce + _, loaded := nonces.LoadOrStore(key, expiresAt) + return !loaded +} + +func writeAuthError(r *ghttp.Request, status int, message string) { + r.Response.WriteStatus(status) + r.Response.WriteJson(g.Map{ + "code": status, + "message": message, + }) + r.Exit() +} diff --git a/server/internal/library/imkafka/command.go b/server/internal/library/imkafka/command.go new file mode 100644 index 0000000..fc38efd --- /dev/null +++ b/server/internal/library/imkafka/command.go @@ -0,0 +1,378 @@ +package imkafka + +import ( + "context" + "encoding/json" + "hotgo/internal/consts" + "hotgo/internal/library/queue" + "time" + + "github.com/gogf/gf/v2/database/gdb" + "github.com/gogf/gf/v2/errors/gerror" + "github.com/gogf/gf/v2/frame/g" + "github.com/gogf/gf/v2/os/gctx" + "github.com/gogf/gf/v2/os/gtime" + "github.com/gogf/gf/v2/util/gconv" + "github.com/gogf/gf/v2/util/guid" +) + +const ( + adminCommandTable = "im_admin_commands" + adminOutboxTable = "im_admin_outbox" +) + +func PublishAdminCommand(ctx context.Context, in AdminCommandInput) (res *AdminCommandModel, err error) { + if in.TenantID <= 0 || in.TenantCode == "" { + return nil, gerror.New("IM租户信息不完整") + } + if in.EventType == "" { + return nil, gerror.New("IM命令类型不能为空") + } + if in.IdempotencyKey == "" { + in.IdempotencyKey = "cmd_" + guid.S() + } + commandID := "cmd_" + guid.S() + eventID := "evt_" + guid.S() + now := time.Now().UTC().Round(time.Millisecond) + + payload := in.Payload + if payload == nil { + payload = map[string]any{} + } + payload["command_id"] = commandID + + payloadBytes, err := json.Marshal(payload) + if err != nil { + return nil, gerror.Wrap(err, "序列化IM命令Payload失败") + } + envelope := Envelope{ + EventID: eventID, + TraceID: gctx.CtxId(ctx), + TenantCode: in.TenantCode, + SourceService: consts.ImKafkaSourceService, + TargetService: consts.ImKafkaTargetService, + EventType: in.EventType, + BizType: in.BizType, + BizID: in.BizID, + IdempotencyKey: in.IdempotencyKey, + Version: in.Version, + OccurredAt: now, + Actor: in.Actor, + Payload: payloadBytes, + } + envelopeBytes, err := json.Marshal(envelope) + if err != nil { + return nil, gerror.Wrap(err, "序列化IM命令Envelope失败") + } + + db := g.DB() + err = db.Transaction(ctx, func(ctx context.Context, tx gdb.TX) error { + data := g.Map{ + "command_id": commandID, + "tenant_id": in.TenantID, + "tenant_code": in.TenantCode, + "event_id": eventID, + "event_type": in.EventType, + "biz_type": in.BizType, + "biz_id": in.BizID, + "idempotency_key": in.IdempotencyKey, + "status": consts.ImAdminCommandStatusPending, + "payload": string(payloadBytes), + "envelope": string(envelopeBytes), + "created_by": in.CreatedBy, + "created_at": gtime.Now(), + "updated_at": gtime.Now(), + } + if _, err := tx.Model(adminCommandTable).Safe().Ctx(ctx).Data(data).Insert(); err != nil { + return gerror.Wrap(err, "创建IM后台命令失败") + } + outbox := g.Map{ + "event_id": eventID, + "command_id": commandID, + "topic": consts.QueueImAdminCommandTopic, + "event_type": in.EventType, + "biz_type": in.BizType, + "biz_id": in.BizID, + "key_value": in.BizID, + "envelope": string(envelopeBytes), + "status": consts.ImAdminOutboxStatusPending, + "created_at": gtime.Now(), + "updated_at": gtime.Now(), + } + if _, err := tx.Model(adminOutboxTable).Safe().Ctx(ctx).Data(outbox).Insert(); err != nil { + return gerror.Wrap(err, "创建IM后台命令Outbox失败") + } + return nil + }) + if err != nil { + return nil, err + } + + res = &AdminCommandModel{ + CommandID: commandID, + EventID: eventID, + Status: consts.ImAdminCommandStatusPending, + TenantID: in.TenantID, + TenantCode: in.TenantCode, + EventType: in.EventType, + IdempotencyKey: in.IdempotencyKey, + } + if err = DispatchOutboxByEvent(ctx, eventID); err != nil { + return res, err + } + res.Status = consts.ImAdminCommandStatusRunning + return res, nil +} + +func DispatchOutboxByEvent(ctx context.Context, eventID string) error { + var row gdb.Record + if err := g.DB().Model(adminOutboxTable).Safe().Ctx(ctx). + Where("event_id", eventID). + Where("status", consts.ImAdminOutboxStatusPending). + Scan(&row); err != nil { + return gerror.Wrap(err, "读取IM命令Outbox失败") + } + if row.IsEmpty() { + return nil + } + return dispatchOutboxRecord(ctx, row) +} + +func PublishConfigEvent(ctx context.Context, in ConfigEventInput) error { + if in.EventType == "" { + return gerror.New("IM配置事件类型不能为空") + } + eventID := "evt_" + guid.S() + now := time.Now().UTC().Round(time.Millisecond) + payload := map[string]any{ + "tenant_code": in.TenantCode, + "tenant_id": in.TenantID, + "group": in.Group, + "keys": in.Keys, + } + payloadBytes, err := json.Marshal(payload) + if err != nil { + return gerror.Wrap(err, "序列化IM配置事件Payload失败") + } + envelope := Envelope{ + EventID: eventID, + TraceID: gctx.CtxId(ctx), + TenantCode: in.TenantCode, + SourceService: consts.ImKafkaSourceService, + TargetService: consts.ImKafkaTargetService, + EventType: in.EventType, + BizType: "config", + BizID: in.BizID, + OccurredAt: now, + Actor: in.Actor, + Payload: payloadBytes, + } + envelopeBytes, err := json.Marshal(envelope) + if err != nil { + return gerror.Wrap(err, "序列化IM配置事件Envelope失败") + } + _, err = g.DB().Model(adminOutboxTable).Safe().Ctx(ctx).Data(g.Map{ + "event_id": eventID, + "command_id": "", + "topic": consts.QueueImConfigEventTopic, + "event_type": in.EventType, + "biz_type": "config", + "biz_id": in.BizID, + "key_value": in.TenantCode, + "envelope": string(envelopeBytes), + "status": consts.ImAdminOutboxStatusPending, + "created_at": gtime.Now(), + "updated_at": gtime.Now(), + }).Insert() + if err != nil { + return gerror.Wrap(err, "创建IM配置事件Outbox失败") + } + return DispatchOutboxByEvent(ctx, eventID) +} + +func PublishEvent(ctx context.Context, in EventInput) (eventID string, err error) { + if in.Topic == "" { + return "", gerror.New("IM Kafka Topic不能为空") + } + if in.EventType == "" { + return "", gerror.New("IM Kafka事件类型不能为空") + } + eventID = "evt_" + guid.S() + now := time.Now().UTC().Round(time.Millisecond) + payload := in.Payload + if payload == nil { + payload = map[string]any{} + } + payloadBytes, err := json.Marshal(payload) + if err != nil { + return "", gerror.Wrap(err, "序列化IM Kafka事件Payload失败") + } + targetService := in.TargetService + if targetService == "" { + targetService = consts.ImKafkaTargetService + } + envelope := Envelope{ + EventID: eventID, + TraceID: gctx.CtxId(ctx), + TenantCode: in.TenantCode, + SourceService: consts.ImKafkaSourceService, + TargetService: targetService, + EventType: in.EventType, + BizType: in.BizType, + BizID: in.BizID, + IdempotencyKey: in.IdempotencyKey, + Version: in.Version, + OccurredAt: now, + Actor: in.Actor, + Payload: payloadBytes, + } + envelopeBytes, err := json.Marshal(envelope) + if err != nil { + return "", gerror.Wrap(err, "序列化IM Kafka事件Envelope失败") + } + _, err = g.DB().Model(adminOutboxTable).Safe().Ctx(ctx).Data(g.Map{ + "event_id": eventID, + "command_id": "", + "topic": in.Topic, + "event_type": in.EventType, + "biz_type": in.BizType, + "biz_id": in.BizID, + "key_value": in.BizID, + "envelope": string(envelopeBytes), + "status": consts.ImAdminOutboxStatusPending, + "created_at": gtime.Now(), + "updated_at": gtime.Now(), + }).Insert() + if err != nil { + return "", gerror.Wrap(err, "创建IM Kafka事件Outbox失败") + } + if err = DispatchOutboxByEvent(ctx, eventID); err != nil { + return eventID, err + } + return eventID, nil +} + +func DispatchPendingOutbox(ctx context.Context, limit int) (sent int, err error) { + if limit <= 0 { + limit = 100 + } + var rows []gdb.Record + err = g.DB().Model(adminOutboxTable).Safe().Ctx(ctx). + WhereIn("status", []string{consts.ImAdminOutboxStatusPending, consts.ImAdminOutboxStatusFailed}). + Where("next_retry_at IS NULL OR next_retry_at <= NOW()"). + OrderAsc("id"). + Limit(limit). + Scan(&rows) + if err != nil { + return 0, gerror.Wrap(err, "读取待投递IM命令Outbox失败") + } + for _, row := range rows { + if err = dispatchOutboxRecord(ctx, row); err != nil { + g.Log().Warningf(ctx, "IM命令Outbox投递失败 event:%s err:%+v", row["event_id"].String(), err) + continue + } + sent++ + } + return sent, nil +} + +func dispatchOutboxRecord(ctx context.Context, row gdb.Record) error { + eventID := row["event_id"].String() + commandID := row["command_id"].String() + topic := row["topic"].String() + body := row["envelope"].String() + if topic == "" || body == "" { + return markOutboxFailed(ctx, eventID, "MALFORMED_OUTBOX", "topic or envelope is empty") + } + if g.Cfg().MustGet(ctx, "queue.driver").String() != "kafka" { + err := gerror.New("IM Kafka未启用,请将queue.driver配置为kafka") + _ = markOutboxFailed(ctx, eventID, "KAFKA_DISABLED", err.Error()) + return err + } + if err := queue.Push(topic, body); err != nil { + _ = markOutboxFailed(ctx, eventID, "KAFKA_SEND_FAILED", err.Error()) + return err + } + now := gtime.Now() + _, err := g.DB().Model(adminOutboxTable).Safe().Ctx(ctx). + Where("event_id", eventID). + Data(g.Map{ + "status": consts.ImAdminOutboxStatusSent, + "attempts": gdb.Raw("attempts + 1"), + "sent_at": now, + "last_reason": "", + "last_error": "", + "updated_at": now, + }).Update() + if err != nil { + return gerror.Wrap(err, "更新IM命令Outbox投递状态失败") + } + if commandID != "" { + _, err = g.DB().Model(adminCommandTable).Safe().Ctx(ctx). + Where("command_id", commandID). + Data(g.Map{ + "status": consts.ImAdminCommandStatusRunning, + "sent_at": now, + "updated_at": now, + }).Update() + if err != nil { + return gerror.Wrap(err, "更新IM后台命令投递状态失败") + } + } + return nil +} + +func markOutboxFailed(ctx context.Context, eventID string, reason string, message string) error { + now := gtime.Now() + _, err := g.DB().Model(adminOutboxTable).Safe().Ctx(ctx). + Where("event_id", eventID). + Data(g.Map{ + "status": consts.ImAdminOutboxStatusFailed, + "attempts": gdb.Raw("attempts + 1"), + "next_retry_at": now.Add(30 * time.Second), + "last_reason": reason, + "last_error": message, + "updated_at": now, + }).Update() + if err != nil { + return gerror.Wrap(err, "更新IM命令Outbox失败状态失败") + } + return nil +} + +func ApplyCommandResult(ctx context.Context, result CommandResult) error { + commandID := result.CommandID + if commandID == "" { + commandID = result.CommandEventID + } + if commandID == "" && result.CommandEventID == "" { + return gerror.New("IM命令结果缺少command_id") + } + status := gconv.String(result.Status) + switch status { + case consts.ImAdminCommandStatusSucceeded, consts.ImAdminCommandStatusFailed, consts.ImAdminCommandStatusDead: + default: + status = consts.ImAdminCommandStatusFailed + } + data := g.Map{ + "status": status, + "reason": result.Reason, + "message": result.Message, + "completed_at": gtime.Now(), + "updated_at": gtime.Now(), + } + if len(result.Result) > 0 { + data["result"] = string(result.Result) + } + mod := g.DB().Model(adminCommandTable).Safe().Ctx(ctx) + if result.CommandID != "" { + mod = mod.Where("command_id", result.CommandID) + } else { + mod = mod.Where("event_id", result.CommandEventID) + } + _, err := mod.Data(data).Update() + if err != nil { + return gerror.Wrap(err, "更新IM后台命令结果失败") + } + return nil +} diff --git a/server/internal/library/imkafka/envelope.go b/server/internal/library/imkafka/envelope.go new file mode 100644 index 0000000..7834914 --- /dev/null +++ b/server/internal/library/imkafka/envelope.go @@ -0,0 +1,90 @@ +package imkafka + +import ( + "encoding/json" + "time" +) + +type Actor struct { + Type string `json:"type,omitempty"` + ID string `json:"id,omitempty"` + DisplayName string `json:"display_name,omitempty"` +} + +type Envelope struct { + EventID string `json:"event_id"` + TraceID string `json:"trace_id,omitempty"` + TenantCode string `json:"tenant_code,omitempty"` + SourceService string `json:"source_service,omitempty"` + TargetService string `json:"target_service,omitempty"` + EventType string `json:"event_type"` + BizType string `json:"biz_type,omitempty"` + BizID string `json:"biz_id,omitempty"` + IdempotencyKey string `json:"idempotency_key,omitempty"` + Version int64 `json:"version,omitempty"` + OccurredAt time.Time `json:"occurred_at"` + Actor *Actor `json:"actor,omitempty"` + Payload json.RawMessage `json:"payload,omitempty"` + ParentEventID string `json:"parent_event_id,omitempty"` +} + +type CommandResult struct { + EventID string `json:"event_id"` + TraceID string `json:"trace_id,omitempty"` + TenantCode string `json:"tenant_code,omitempty"` + CommandID string `json:"command_id"` + CommandEventID string `json:"command_event_id"` + Status string `json:"status"` + Reason string `json:"reason,omitempty"` + Message string `json:"message,omitempty"` + Result json.RawMessage `json:"result,omitempty"` + OccurredAt time.Time `json:"occurred_at"` +} + +type AdminCommandInput struct { + TenantID int64 + TenantCode string + EventType string + BizType string + BizID string + IdempotencyKey string + Version int64 + Actor *Actor + Payload map[string]any + CreatedBy int64 +} + +type ConfigEventInput struct { + TenantID int64 + TenantCode string + EventType string + BizID string + Group string + Keys []string + Actor *Actor + CreatedBy int64 +} + +type EventInput struct { + Topic string + TenantCode string + TargetService string + EventType string + BizType string + BizID string + IdempotencyKey string + Version int64 + Actor *Actor + Payload map[string]any + CreatedBy int64 +} + +type AdminCommandModel struct { + CommandID string `json:"commandId" dc:"命令ID"` + EventID string `json:"eventId" dc:"Kafka事件ID"` + Status string `json:"status" dc:"状态"` + TenantID int64 `json:"tenantId" dc:"租户ID"` + TenantCode string `json:"tenantCode" dc:"租户编码"` + EventType string `json:"eventType" dc:"事件类型"` + IdempotencyKey string `json:"idempotencyKey" dc:"幂等键"` +} diff --git a/server/internal/library/imtenant/db.go b/server/internal/library/imtenant/db.go index 55a779e..374d1f5 100644 --- a/server/internal/library/imtenant/db.go +++ b/server/internal/library/imtenant/db.go @@ -21,6 +21,8 @@ var tenantDbGroups sync.Map const registryDbGroup = "imRegistry" +var registryDbGroupOnce sync.Once + // DB resolves a tenant registry record and returns the corresponding business DB. func DB(ctx context.Context, tenantId int64) (db gdb.DB, tenant *entity.ImTenant, err error) { if tenantId <= 0 { @@ -60,12 +62,34 @@ func DB(ctx context.Context, tenantId int64) (db gdb.DB, tenant *entity.ImTenant // RegistryDB returns the dedicated tenant registry connection, or the default // HotGo database when a separate registry group is not configured. func RegistryDB() gdb.DB { + registryDbGroupOnce.Do(func() { + ensureRegistryDBGroup() + }) if configs, err := gdb.GetConfigGroup(registryDbGroup); err == nil && len(configs) > 0 { return g.DB(registryDbGroup) } return g.DB() } +func ensureRegistryDBGroup() { + if configs, err := gdb.GetConfigGroup(registryDbGroup); err == nil && len(configs) > 0 { + configs[0].Prefix = "" + _ = gdb.SetConfigGroup(registryDbGroup, configs) + return + } + + configs, err := gdb.GetConfigGroup("default") + if err != nil || len(configs) == 0 { + return + } + cloned := make(gdb.ConfigGroup, len(configs)) + copy(cloned, configs) + for i := range cloned { + cloned[i].Prefix = "" + } + _ = gdb.SetConfigGroup(registryDbGroup, cloned) +} + func tenantLink(ctx context.Context, tenant *entity.ImTenant) (string, error) { if tenant.DbDsn != "" { return normalizeMySQLTimeOptions(tenant.DbDsn) diff --git a/server/internal/logic/admin/member.go b/server/internal/logic/admin/member.go index 6e81cc6..06f5683 100644 --- a/server/internal/logic/admin/member.go +++ b/server/internal/logic/admin/member.go @@ -25,6 +25,7 @@ import ( "hotgo/internal/library/hgorm" "hotgo/internal/library/hgorm/handler" "hotgo/internal/library/hgorm/hook" + "hotgo/internal/library/imtenant" "hotgo/internal/model/entity" "hotgo/internal/model/input/adminin" "hotgo/internal/model/input/sysin" @@ -469,7 +470,7 @@ func (s *sAdminMember) Edit(ctx context.Context, in *adminin.MemberEditInp) (err if service.AdminMember().VerifySuperId(ctx, opMemberId) { if in.TenantId > 0 { - count, queryErr := g.DB("imRegistry").Model(dao.ImTenant.Table()).Safe().Ctx(ctx). + count, queryErr := imtenant.RegistryDB().Model(dao.ImTenant.Table()).Safe().Ctx(ctx). Where(dao.ImTenant.Columns().Id, in.TenantId). Where(dao.ImTenant.Columns().State, consts.ImTenantStateActive). Count() diff --git a/server/internal/logic/sys/im_admin_query_client.go b/server/internal/logic/sys/im_admin_query_client.go new file mode 100644 index 0000000..6913e4a --- /dev/null +++ b/server/internal/logic/sys/im_admin_query_client.go @@ -0,0 +1,239 @@ +package sys + +import ( + "context" + "encoding/json" + "hotgo/internal/dao" + "hotgo/internal/library/imadmin" + "hotgo/internal/library/imtenant" + "hotgo/internal/model/entity" + "net/url" + "strconv" + "strings" + "time" + + "github.com/gogf/gf/v2/errors/gerror" + "github.com/gogf/gf/v2/os/gtime" +) + +type imAdminQueryPage struct { + Items []map[string]any `json:"items"` + Total int `json:"total"` + Page int `json:"page"` + PerPage int `json:"per_page"` +} + +func resolveImAdminTenant(ctx context.Context, selectedTenantId int64) (tenant *entity.ImTenant, err error) { + scope, err := resolveImAdminScope(ctx, selectedTenantId) + if err != nil { + return nil, err + } + if err = imtenant.RegistryDB().Model(dao.ImTenant.Table()).Safe().Ctx(ctx).WherePri(scope.TenantId).Scan(&tenant); err != nil { + return nil, gerror.Wrap(err, "获取IM租户失败") + } + if tenant == nil || tenant.Id <= 0 || tenant.Code == "" { + return nil, gerror.New("IM租户不存在或编码为空") + } + return tenant, nil +} + +func imAdminQueryList[T any](ctx context.Context, tenant *entity.ImTenant, path string, values url.Values, aliases map[string]string) (list []*T, totalCount int, err error) { + if values == nil { + values = url.Values{} + } + query := values.Encode() + if query != "" { + path += "?" + query + } + var page imAdminQueryPage + if err = imadmin.DefaultClient(ctx).InternalGet(ctx, path, &page); err != nil { + return nil, 0, err + } + normalizeImAdminRows(page.Items, aliases) + if len(page.Items) == 0 { + return []*T{}, page.Total, nil + } + raw, err := json.Marshal(page.Items) + if err != nil { + return nil, 0, gerror.Wrap(err, "转换IM内部查询列表失败") + } + if err = json.Unmarshal(raw, &list); err != nil { + return nil, 0, gerror.Wrap(err, "解析IM内部查询列表失败") + } + fillImTenantFields(tenant, &list) + return list, page.Total, nil +} + +func imAdminQueryOne[T any](ctx context.Context, tenant *entity.ImTenant, path string, aliases map[string]string) (res *T, err error) { + row := map[string]any{} + if err = imadmin.DefaultClient(ctx).InternalGet(ctx, path, &row); err != nil { + return nil, err + } + normalizeImAdminRow(row, aliases) + raw, err := json.Marshal(row) + if err != nil { + return nil, gerror.Wrap(err, "转换IM内部查询详情失败") + } + if err = json.Unmarshal(raw, &res); err != nil { + return nil, gerror.Wrap(err, "解析IM内部查询详情失败") + } + fillImTenantFields(tenant, res) + return res, nil +} + +func normalizeImAdminRows(rows []map[string]any, aliases map[string]string) { + for _, row := range rows { + normalizeImAdminRow(row, aliases) + } +} + +func normalizeImAdminRow(row map[string]any, aliases map[string]string) { + for key, value := range row { + target := snakeToLowerCamel(key) + if aliases != nil { + if alias := aliases[key]; alias != "" { + target = alias + } + } + value = normalizeImAdminValue(target, value) + if target == key { + row[key] = value + continue + } + row[target] = value + delete(row, key) + } + if _, ok := row["authNames"]; !ok { + username, _ := row["username"].(string) + phone, _ := row["phone"].(string) + authNames := username + if phone != "" && phone != username { + if authNames != "" { + authNames += ", " + } + authNames += phone + } + if authNames != "" { + row["authNames"] = authNames + } + } +} + +func normalizeImAdminValue(target string, value any) any { + if target != "credentials" { + return value + } + items, ok := value.([]any) + if !ok { + return value + } + parts := make([]string, 0, len(items)) + for _, item := range items { + m, ok := item.(map[string]any) + if !ok { + continue + } + method, _ := m["method"].(string) + val, _ := m["value"].(string) + if method == "" && val == "" { + continue + } + text := method + if val != "" { + if text != "" { + text += ":" + } + text += val + } + if done, ok := m["done"].(bool); ok && !done { + text += "(未验证)" + } + parts = append(parts, text) + } + return strings.Join(parts, ", ") +} + +func snakeToLowerCamel(s string) string { + if !strings.Contains(s, "_") { + return s + } + parts := strings.Split(s, "_") + var b strings.Builder + for i, part := range parts { + if part == "" { + continue + } + if i == 0 { + b.WriteString(part) + continue + } + b.WriteString(strings.ToUpper(part[:1])) + if len(part) > 1 { + b.WriteString(part[1:]) + } + } + return b.String() +} + +func fillImTenantFields(tenant *entity.ImTenant, target any) { + raw, err := json.Marshal(target) + if err != nil { + return + } + var rows []map[string]any + if err = json.Unmarshal(raw, &rows); err == nil { + for _, row := range rows { + row["tenantId"] = tenant.Id + row["tenantName"] = tenant.Name + } + updated, _ := json.Marshal(rows) + _ = json.Unmarshal(updated, target) + return + } + var row map[string]any + if err = json.Unmarshal(raw, &row); err == nil { + row["tenantId"] = tenant.Id + row["tenantName"] = tenant.Name + updated, _ := json.Marshal(row) + _ = json.Unmarshal(updated, target) + } +} + +func addPageQuery(values url.Values, page int, perPage int) url.Values { + if values == nil { + values = url.Values{} + } + if page <= 0 { + page = 1 + } + if perPage <= 0 { + perPage = 20 + } + values.Set("page", intToString(page)) + values.Set("per_page", intToString(perPage)) + return values +} + +func addTimeRangeQuery(values url.Values, prefix string, times []*gtime.Time) { + if len(times) != 2 { + return + } + if times[0] != nil { + values.Set(prefix+"_start", times[0].Time.UTC().Format(time.RFC3339Nano)) + } + if times[1] != nil { + values.Set(prefix+"_end", times[1].Time.UTC().Format(time.RFC3339Nano)) + } +} + +func intToString(v int) string { + return strconvFormatInt(int64(v)) +} + +func int64ToString(v int64) string { + return strconvFormatInt(v) +} + +func strconvFormatInt(v int64) string { + return strconv.FormatInt(v, 10) +} diff --git a/server/internal/logic/sys/im_dead_letter.go b/server/internal/logic/sys/im_dead_letter.go new file mode 100644 index 0000000..11d16f9 --- /dev/null +++ b/server/internal/logic/sys/im_dead_letter.go @@ -0,0 +1,203 @@ +// Package sys +// @Link https://github.com/bufanyun/hotgo +// @Copyright Copyright (c) 2026 HotGo +// @Author Ms <133814250@qq.com> +// @License https://github.com/bufanyun/hotgo/blob/master/LICENSE +package sys + +import ( + "context" + "encoding/json" + "fmt" + "hotgo/internal/consts" + "hotgo/internal/dao" + "hotgo/internal/library/contexts" + "hotgo/internal/library/imadmin" + "hotgo/internal/library/imkafka" + "hotgo/internal/library/imtenant" + "hotgo/internal/model/entity" + "hotgo/internal/model/input/sysin" + "hotgo/internal/service" + "net/url" + "strconv" + + "github.com/gogf/gf/v2/errors/gerror" +) + +type sSysImDeadLetter struct{} + +func NewSysImDeadLetter() *sSysImDeadLetter { + return &sSysImDeadLetter{} +} + +func init() { + service.RegisterSysImDeadLetter(NewSysImDeadLetter()) +} + +// List 获取IM Kafka死信列表 +func (s *sSysImDeadLetter) List(ctx context.Context, in *sysin.ImDeadLetterListInp) (res *sysin.ImDeadLetterPageModel, err error) { + if err = in.Filter(ctx); err != nil { + return nil, err + } + tenant, err := s.getTenant(ctx, in.TenantId) + if err != nil { + return nil, err + } + values := url.Values{} + values.Set("limit", strconv.Itoa(in.Limit)) + if in.Status != "" { + values.Set("status", in.Status) + } + if in.BizType != "" { + values.Set("biz_type", in.BizType) + } + if in.BizId != "" { + values.Set("biz_id", in.BizId) + } + if in.EventType != "" { + values.Set("event_type", in.EventType) + } + if in.Cursor != "" { + values.Set("cursor", in.Cursor) + } + var raw sysin.ImDeadLetterPageRaw + path := fmt.Sprintf("/internal/admin-query/tenants/%s/kafka/dead-letters?%s", url.PathEscape(tenant.Code), values.Encode()) + if err = imadmin.DefaultClient(ctx).InternalGet(ctx, path, &raw); err != nil { + return nil, err + } + res = &sysin.ImDeadLetterPageModel{ + List: make([]*sysin.ImDeadLetterListModel, 0, len(raw.Items)), + NextCursor: raw.NextCursor, + HasMore: raw.HasMore, + } + for _, item := range raw.Items { + res.List = append(res.List, convertDeadLetterModel(item)) + } + return res, nil +} + +// View 获取IM Kafka死信详情 +func (s *sSysImDeadLetter) View(ctx context.Context, in *sysin.ImDeadLetterViewInp) (res *sysin.ImDeadLetterViewModel, err error) { + if err = in.Filter(ctx); err != nil { + return nil, err + } + tenant, err := s.getTenant(ctx, in.TenantId) + if err != nil { + return nil, err + } + var raw sysin.ImDeadLetterViewRaw + path := fmt.Sprintf("/internal/admin-query/tenants/%s/kafka/dead-letters/%s", url.PathEscape(tenant.Code), url.PathEscape(in.DeadId)) + if err = imadmin.DefaultClient(ctx).InternalGet(ctx, path, &raw); err != nil { + return nil, err + } + res = &sysin.ImDeadLetterViewModel{ + ImDeadLetterListModel: *convertDeadLetterModel(&raw.ImDeadLetterListModelRaw), + EnvelopeRaw: raw.EnvelopeRaw, + } + if raw.Envelope != nil { + b, _ := json.MarshalIndent(raw.Envelope, "", " ") + res.Envelope = string(b) + } + return res, nil +} + +// Replay 重放IM Kafka死信 +func (s *sSysImDeadLetter) Replay(ctx context.Context, in *sysin.ImDeadLetterActionInp) (res *sysin.ImGroupCommandModel, err error) { + return s.publishActionCommand(ctx, in, consts.ImAdminCommandKafkaDeadReplay) +} + +// Ignore 忽略IM Kafka死信 +func (s *sSysImDeadLetter) Ignore(ctx context.Context, in *sysin.ImDeadLetterActionInp) (res *sysin.ImGroupCommandModel, err error) { + return s.publishActionCommand(ctx, in, consts.ImAdminCommandKafkaDeadIgnore) +} + +// Resolve 标记IM Kafka死信已处理 +func (s *sSysImDeadLetter) Resolve(ctx context.Context, in *sysin.ImDeadLetterActionInp) (res *sysin.ImGroupCommandModel, err error) { + return s.publishActionCommand(ctx, in, consts.ImAdminCommandKafkaDeadResolve) +} + +func (s *sSysImDeadLetter) publishActionCommand(ctx context.Context, in *sysin.ImDeadLetterActionInp, eventType string) (*sysin.ImGroupCommandModel, error) { + if err := in.Filter(ctx); err != nil { + return nil, err + } + tenant, err := s.getTenant(ctx, in.TenantId) + if err != nil { + return nil, err + } + user := contexts.GetUser(ctx) + actorName := "" + if user != nil { + actorName = user.RealName + } + command, err := imkafka.PublishAdminCommand(ctx, imkafka.AdminCommandInput{ + TenantID: tenant.Id, + TenantCode: tenant.Code, + EventType: eventType, + BizType: "kafka_dead_letter", + BizID: in.DeadId, + Actor: &imkafka.Actor{ + Type: "admin", + ID: strconv.FormatInt(contexts.GetUserId(ctx), 10), + DisplayName: actorName, + }, + Payload: map[string]any{ + "command_id": "", + "actor_user_id": strconv.FormatInt(in.ActorUserId, 10), + "dead_id": in.DeadId, + "note": in.Note, + }, + CreatedBy: contexts.GetUserId(ctx), + }) + if err != nil { + return nil, err + } + return &sysin.ImGroupCommandModel{ + CommandID: command.CommandID, + EventID: command.EventID, + Status: command.Status, + TenantID: command.TenantID, + TenantCode: command.TenantCode, + EventType: command.EventType, + IdempotencyKey: command.IdempotencyKey, + }, nil +} + +func (s *sSysImDeadLetter) getTenant(ctx context.Context, tenantId int64) (tenant *entity.ImTenant, err error) { + if tenantId <= 0 { + return nil, gerror.New("请选择IM租户") + } + if err = imtenant.RegistryDB().Model(dao.ImTenant.Table()).Safe().Ctx(ctx).WherePri(tenantId).Scan(&tenant); err != nil { + return nil, gerror.Wrap(err, "获取IM租户失败") + } + if tenant == nil || tenant.Id <= 0 { + return nil, gerror.New("IM租户不存在") + } + if tenant.State != consts.ImTenantStateActive { + return nil, gerror.New("IM租户未启用") + } + return tenant, nil +} + +func convertDeadLetterModel(in *sysin.ImDeadLetterListModelRaw) *sysin.ImDeadLetterListModel { + if in == nil { + return &sysin.ImDeadLetterListModel{} + } + return &sysin.ImDeadLetterListModel{ + Id: in.Id, + DeadId: in.DeadId, + EventId: in.EventId, + ParentEventId: in.ParentEventId, + SourceTopic: in.SourceTopic, + ConsumerGroup: in.ConsumerGroup, + EventType: in.EventType, + BizType: in.BizType, + BizId: in.BizId, + RetryCount: in.RetryCount, + LastReason: in.LastReason, + LastError: in.LastError, + Status: in.Status, + ReplayCount: in.ReplayCount, + CreatedAt: in.CreatedAt, + UpdatedAt: in.UpdatedAt, + } +} diff --git a/server/internal/logic/sys/im_group.go b/server/internal/logic/sys/im_group.go index a8bb969..98e244d 100644 --- a/server/internal/logic/sys/im_group.go +++ b/server/internal/logic/sys/im_group.go @@ -11,9 +11,11 @@ import ( "fmt" "hotgo/internal/consts" "hotgo/internal/library/contexts" + "hotgo/internal/library/imkafka" "hotgo/internal/library/imtenant" "hotgo/internal/model/input/sysin" "hotgo/internal/service" + "strconv" "github.com/gogf/gf/v2/database/gdb" "github.com/gogf/gf/v2/errors/gerror" @@ -385,6 +387,22 @@ func (s *sSysImGroup) GovernanceUpdate(ctx context.Context, in *sysin.ImGroupGov }) } +func (s *sSysImGroup) ProfileUpdate(ctx context.Context, in *sysin.ImGroupProfileUpdateInp) (res *sysin.ImGroupCommandModel, err error) { + if err = in.Filter(ctx); err != nil { + return nil, err + } + payload := map[string]any{ + "topic": in.Topic, + "actor_user_id": strconv.FormatInt(contexts.GetUserId(ctx), 10), + "profile": map[string]any{ + "name": in.Name, + "avatar_file_id": in.AvatarFileId, + "description": in.Description, + }, + } + return s.publishGroupCommand(ctx, in.TenantId, in.Topic, consts.ImAdminCommandGroupProfileUpdate, payload, in.Version) +} + // Dissolve 解散IM群 func (s *sSysImGroup) Dissolve(ctx context.Context, in *sysin.ImGroupDissolveInp) (err error) { if err = in.Filter(ctx); err != nil { @@ -483,6 +501,22 @@ func (s *sSysImGroup) Mute(ctx context.Context, in *sysin.ImGroupMuteInp) (err e }) } +func (s *sSysImGroup) MemberMute(ctx context.Context, in *sysin.ImGroupMuteInp) (res *sysin.ImGroupCommandModel, err error) { + if err = in.Filter(ctx); err != nil { + return nil, err + } + payload := map[string]any{ + "topic": in.Topic, + "actor_user_id": strconv.FormatInt(contexts.GetUserId(ctx), 10), + "user_id": strconv.FormatInt(in.UserId, 10), + "reason": in.Reason, + } + if in.ExpiresAt != nil { + payload["expires_at"] = in.ExpiresAt.Time.Format("2006-01-02T15:04:05.000Z07:00") + } + return s.publishGroupCommand(ctx, in.TenantId, in.Topic, consts.ImAdminCommandGroupMemberMute, payload, 0) +} + // Unmute 解除IM群成员禁言 func (s *sSysImGroup) Unmute(ctx context.Context, in *sysin.ImGroupUnmuteInp) (err error) { if err = in.Filter(ctx); err != nil { @@ -529,6 +563,31 @@ func (s *sSysImGroup) Unmute(ctx context.Context, in *sysin.ImGroupUnmuteInp) (e }) } +func (s *sSysImGroup) MemberUnmute(ctx context.Context, in *sysin.ImGroupUnmuteInp) (res *sysin.ImGroupCommandModel, err error) { + if err = in.Filter(ctx); err != nil { + return nil, err + } + payload := map[string]any{ + "topic": in.Topic, + "actor_user_id": strconv.FormatInt(contexts.GetUserId(ctx), 10), + "user_id": strconv.FormatInt(in.UserId, 10), + } + return s.publishGroupCommand(ctx, in.TenantId, in.Topic, consts.ImAdminCommandGroupMemberUnmute, payload, 0) +} + +func (s *sSysImGroup) MemberRemove(ctx context.Context, in *sysin.ImGroupMemberRemoveInp) (res *sysin.ImGroupCommandModel, err error) { + if err = in.Filter(ctx); err != nil { + return nil, err + } + payload := map[string]any{ + "topic": in.Topic, + "actor_user_id": strconv.FormatInt(contexts.GetUserId(ctx), 10), + "user_id": strconv.FormatInt(in.UserId, 10), + "reason": in.Reason, + } + return s.publishGroupCommand(ctx, in.TenantId, in.Topic, consts.ImAdminCommandGroupMemberRemove, payload, 0) +} + // JoinRequestApprove 通过IM入群申请 func (s *sSysImGroup) JoinRequestApprove(ctx context.Context, in *sysin.ImGroupJoinRequestResolveInp) (err error) { if err = in.Filter(ctx); err != nil { @@ -683,6 +742,30 @@ func (s *sSysImGroup) RoleUpdate(ctx context.Context, in *sysin.ImGroupRoleUpdat }) } +func (s *sSysImGroup) AdminSet(ctx context.Context, in *sysin.ImGroupRoleUpdateInp) (res *sysin.ImGroupCommandModel, err error) { + if err = in.Filter(ctx); err != nil { + return nil, err + } + payload := map[string]any{ + "topic": in.Topic, + "actor_user_id": strconv.FormatInt(contexts.GetUserId(ctx), 10), + "user_ids": []string{strconv.FormatInt(in.UserId, 10)}, + } + return s.publishGroupCommand(ctx, in.TenantId, in.Topic, consts.ImAdminCommandGroupAdminSet, payload, 0) +} + +func (s *sSysImGroup) AdminRemove(ctx context.Context, in *sysin.ImGroupRoleUpdateInp) (res *sysin.ImGroupCommandModel, err error) { + if err = in.Filter(ctx); err != nil { + return nil, err + } + payload := map[string]any{ + "topic": in.Topic, + "actor_user_id": strconv.FormatInt(contexts.GetUserId(ctx), 10), + "user_id": strconv.FormatInt(in.UserId, 10), + } + return s.publishGroupCommand(ctx, in.TenantId, in.Topic, consts.ImAdminCommandGroupAdminRemove, payload, 0) +} + // OwnerTransfer 转让IM群主 func (s *sSysImGroup) OwnerTransfer(ctx context.Context, in *sysin.ImGroupOwnerTransferInp) (err error) { if err = in.Filter(ctx); err != nil { @@ -785,6 +868,45 @@ func (s *sSysImGroup) OwnerTransfer(ctx context.Context, in *sysin.ImGroupOwnerT }) } +func (s *sSysImGroup) publishGroupCommand(ctx context.Context, tenantId int64, topic string, eventType string, payload map[string]any, version int64) (*sysin.ImGroupCommandModel, error) { + _, tenant, err := resolveImTenantDB(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 (s *sSysImGroup) groupGovernanceSnapshot(ctx context.Context, tx gdb.TX, topic string) (res gdb.Record, err error) { return tx.Model("im_group_settings").Safe().Ctx(ctx). Fields( diff --git a/server/internal/logic/sys/im_kafka_audit.go b/server/internal/logic/sys/im_kafka_audit.go new file mode 100644 index 0000000..6656bf1 --- /dev/null +++ b/server/internal/logic/sys/im_kafka_audit.go @@ -0,0 +1,86 @@ +package sys + +import ( + "context" + "hotgo/internal/model/input/sysin" + "hotgo/internal/service" + + "github.com/gogf/gf/v2/errors/gerror" + "github.com/gogf/gf/v2/frame/g" +) + +type sSysImKafkaAudit struct{} + +func NewSysImKafkaAudit() *sSysImKafkaAudit { + return &sSysImKafkaAudit{} +} + +func init() { + service.RegisterSysImKafkaAudit(NewSysImKafkaAudit()) +} + +func (s *sSysImKafkaAudit) List(ctx context.Context, in *sysin.ImKafkaAuditListInp) (list []*sysin.ImKafkaAuditListModel, totalCount int, err error) { + if err = in.Filter(ctx); err != nil { + return + } + mod := g.DB().Model("im_audit_events").Safe().Ctx(ctx). + Fields("id", "event_id", "tenant_code", "event_type", "biz_type", "biz_id", "actor_type", "actor_id", "action", "resource", "resource_id", "result", "reason", "occurred_at", "received_at") + if in.TenantCode != "" { + mod = mod.Where("tenant_code", in.TenantCode) + } + if in.EventId != "" { + mod = mod.WhereLike("event_id", "%"+in.EventId+"%") + } + if in.EventType != "" { + mod = mod.WhereLike("event_type", "%"+in.EventType+"%") + } + if in.BizType != "" { + mod = mod.Where("biz_type", in.BizType) + } + if in.BizId != "" { + mod = mod.WhereLike("biz_id", "%"+in.BizId+"%") + } + if in.ActorType != "" { + mod = mod.Where("actor_type", in.ActorType) + } + if in.ActorId != "" { + mod = mod.WhereLike("actor_id", "%"+in.ActorId+"%") + } + if in.Action != "" { + mod = mod.WhereLike("action", "%"+in.Action+"%") + } + if in.Resource != "" { + mod = mod.Where("resource", in.Resource) + } + if in.ResourceId != "" { + mod = mod.WhereLike("resource_id", "%"+in.ResourceId+"%") + } + if in.Result != "" { + mod = mod.Where("result", in.Result) + } + if len(in.CreatedAt) == 2 { + mod = mod.WhereBetween("received_at", in.CreatedAt[0], in.CreatedAt[1]) + } + mod = mod.Page(in.Page, in.PerPage).OrderDesc("id") + if err = mod.ScanAndCount(&list, &totalCount, false); err != nil { + err = gerror.Wrap(err, "获取IM Kafka审计事件列表失败,请稍后重试!") + } + return +} + +func (s *sSysImKafkaAudit) View(ctx context.Context, in *sysin.ImKafkaAuditViewInp) (res *sysin.ImKafkaAuditViewModel, err error) { + if err = in.Filter(ctx); err != nil { + return nil, err + } + mod := g.DB().Model("im_audit_events").Safe().Ctx(ctx). + Fields("id", "event_id", "tenant_code", "event_type", "biz_type", "biz_id", "actor_type", "actor_id", "action", "resource", "resource_id", "result", "reason", "CAST(payload AS CHAR) payload", "CAST(envelope AS CHAR) envelope", "occurred_at", "received_at") + if in.Id > 0 { + mod = mod.Where("id", in.Id) + } else { + mod = mod.Where("event_id", in.EventId) + } + if err = mod.Scan(&res); err != nil { + return nil, gerror.Wrap(err, "获取IM Kafka审计事件详情失败,请稍后重试!") + } + return +} diff --git a/server/internal/logic/sys/im_kafka_command.go b/server/internal/logic/sys/im_kafka_command.go new file mode 100644 index 0000000..620eeea --- /dev/null +++ b/server/internal/logic/sys/im_kafka_command.go @@ -0,0 +1,187 @@ +// Package sys +// @Link https://github.com/bufanyun/hotgo +// @Copyright Copyright (c) 2026 HotGo +// @Author Ms <133814250@qq.com> +// @License https://github.com/bufanyun/hotgo/blob/master/LICENSE +package sys + +import ( + "context" + "hotgo/internal/consts" + "hotgo/internal/library/contexts" + "hotgo/internal/library/imkafka" + "hotgo/internal/model/input/sysin" + "hotgo/internal/service" + + "github.com/gogf/gf/v2/errors/gerror" + "github.com/gogf/gf/v2/frame/g" + "github.com/gogf/gf/v2/util/gconv" +) + +type sSysImKafkaCommand struct{} + +func NewSysImKafkaCommand() *sSysImKafkaCommand { + return &sSysImKafkaCommand{} +} + +func init() { + service.RegisterSysImKafkaCommand(NewSysImKafkaCommand()) +} + +func (s *sSysImKafkaCommand) List(ctx context.Context, in *sysin.ImKafkaCommandListInp) (list []*sysin.ImKafkaCommandListModel, totalCount int, err error) { + if err = in.Filter(ctx); err != nil { + return + } + mod := g.DB().Model("im_admin_commands").Safe().Ctx(ctx). + Fields( + "id", + "command_id", + "tenant_id", + "tenant_code", + "event_id", + "event_type", + "biz_type", + "biz_id", + "idempotency_key", + "status", + "reason", + "message", + "sent_at", + "completed_at", + "created_by", + "created_at", + "updated_at", + ) + if in.TenantId > 0 { + mod = mod.Where("tenant_id", in.TenantId) + } + if in.CommandId != "" { + mod = mod.WhereLike("command_id", "%"+in.CommandId+"%") + } + if in.EventId != "" { + mod = mod.WhereLike("event_id", "%"+in.EventId+"%") + } + if in.EventType != "" { + mod = mod.WhereLike("event_type", "%"+in.EventType+"%") + } + if in.BizType != "" { + mod = mod.Where("biz_type", in.BizType) + } + if in.BizId != "" { + mod = mod.WhereLike("biz_id", "%"+in.BizId+"%") + } + if in.Status != "" { + mod = mod.Where("status", in.Status) + } + if len(in.CreatedAt) == 2 { + mod = mod.WhereBetween("created_at", in.CreatedAt[0], in.CreatedAt[1]) + } + mod = mod.Page(in.Page, in.PerPage).OrderDesc("id") + if err = mod.ScanAndCount(&list, &totalCount, false); err != nil { + err = gerror.Wrap(err, "获取IM后台命令列表失败,请稍后重试!") + } + return +} + +func (s *sSysImKafkaCommand) View(ctx context.Context, in *sysin.ImKafkaCommandViewInp) (res *sysin.ImKafkaCommandViewModel, err error) { + if err = in.Filter(ctx); err != nil { + return nil, err + } + mod := g.DB().Model("im_admin_commands").Safe().Ctx(ctx). + Fields( + "id", + "command_id", + "tenant_id", + "tenant_code", + "event_id", + "event_type", + "biz_type", + "biz_id", + "idempotency_key", + "status", + "reason", + "message", + "CAST(payload AS CHAR) payload", + "CAST(envelope AS CHAR) envelope", + "CAST(result AS CHAR) result", + "sent_at", + "completed_at", + "created_by", + "created_at", + "updated_at", + ) + if in.Id > 0 { + mod = mod.Where("id", in.Id) + } else { + mod = mod.Where("command_id", in.CommandId) + } + if err = mod.Scan(&res); err != nil { + return nil, gerror.Wrap(err, "获取IM后台命令详情失败,请稍后重试!") + } + return +} + +func (s *sSysImKafkaCommand) CacheInvalidate(ctx context.Context, in *sysin.ImKafkaCacheInvalidateInp) (res *sysin.ImKafkaEventModel, err error) { + if err = in.Filter(ctx); err != nil { + return nil, err + } + user := contexts.GetUser(ctx) + actor := &imkafka.Actor{ + Type: "admin", + ID: gconv.String(contexts.GetUserId(ctx)), + } + if user != nil { + actor.DisplayName = user.RealName + } + payload := map[string]any{ + "tenant_code": in.TenantCode, + "namespace": in.Namespace, + "keys": in.Keys, + "reason": in.Reason, + } + eventID, err := imkafka.PublishEvent(ctx, imkafka.EventInput{ + Topic: consts.QueueImCacheInvalidateTopic, + TenantCode: in.TenantCode, + EventType: "cache.invalidate", + BizType: "cache", + BizID: in.Namespace, + Actor: actor, + Payload: payload, + CreatedBy: contexts.GetUserId(ctx), + }) + if err != nil { + return nil, err + } + return &sysin.ImKafkaEventModel{EventId: eventID}, nil +} + +func (s *sSysImKafkaCommand) WorkbenchEvent(ctx context.Context, in *sysin.ImKafkaWorkbenchEventInp) (res *sysin.ImKafkaEventModel, err error) { + if err = in.Filter(ctx); err != nil { + return nil, err + } + user := contexts.GetUser(ctx) + actor := &imkafka.Actor{ + Type: "admin", + ID: gconv.String(contexts.GetUserId(ctx)), + } + if user != nil { + actor.DisplayName = user.RealName + } + payload := in.Payload + payload["tenant_code"] = in.TenantCode + payload["source"] = "admin" + eventID, err := imkafka.PublishEvent(ctx, imkafka.EventInput{ + Topic: consts.QueueImWorkbenchEventTopic, + TenantCode: in.TenantCode, + EventType: in.EventType, + BizType: "workbench", + BizID: in.BizId, + Actor: actor, + Payload: payload, + CreatedBy: contexts.GetUserId(ctx), + }) + if err != nil { + return nil, err + } + return &sysin.ImKafkaEventModel{EventId: eventID}, nil +} diff --git a/server/internal/logic/sys/im_tenant.go b/server/internal/logic/sys/im_tenant.go index e55308a..17c479e 100644 --- a/server/internal/logic/sys/im_tenant.go +++ b/server/internal/logic/sys/im_tenant.go @@ -25,8 +25,6 @@ import ( type sSysImTenant struct{} -const imRegistryDbGroup = "imRegistry" - func NewSysImTenant() *sSysImTenant { return &sSysImTenant{} } @@ -37,11 +35,11 @@ func init() { // Model IM租户ORM模型 func (s *sSysImTenant) Model(ctx context.Context, option ...*handler.Option) *gdb.Model { - return handler.Model(g.DB(imRegistryDbGroup).Model(dao.ImTenant.Table()).Safe().Ctx(ctx), option...) + return handler.Model(imtenant.RegistryDB().Model(dao.ImTenant.Table()).Safe().Ctx(ctx), option...) } func (s *sSysImTenant) configModel(ctx context.Context) *gdb.Model { - return g.DB(imRegistryDbGroup).Model(dao.ImTenantConfig.Table()).Safe().Ctx(ctx) + return imtenant.RegistryDB().Model(dao.ImTenantConfig.Table()).Safe().Ctx(ctx) } // List 获取IM租户列表 @@ -146,7 +144,7 @@ func (s *sSysImTenant) Edit(ctx context.Context, in *sysin.ImTenantEditInp) (err return } - return g.DB(imRegistryDbGroup).Transaction(ctx, func(ctx context.Context, tx gdb.TX) (err error) { + return imtenant.RegistryDB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) (err error) { if err = s.checkCodeUnique(ctx, in.Id, in.Code); err != nil { return } diff --git a/server/internal/logic/sys/im_user.go b/server/internal/logic/sys/im_user.go index 30e2cf0..31f874a 100644 --- a/server/internal/logic/sys/im_user.go +++ b/server/internal/logic/sys/im_user.go @@ -12,9 +12,13 @@ import ( "encoding/json" "fmt" "hotgo/internal/consts" + "hotgo/internal/library/contexts" + "hotgo/internal/library/imkafka" "hotgo/internal/library/imtenant" "hotgo/internal/model/input/sysin" "hotgo/internal/service" + "net/url" + "strconv" "strings" "time" @@ -40,36 +44,34 @@ func (s *sSysImUser) List(ctx context.Context, in *sysin.ImUserListInp) (list [] if err = in.Filter(ctx); err != nil { return } - - scope, err := resolveImAdminScope(ctx, in.TenantId) + tenant, err := resolveImAdminTenant(ctx, in.TenantId) if err != nil { return } - db, tenant, err := imtenant.DB(ctx, scope.TenantId) - if err != nil { - return + values := addPageQuery(url.Values{}, in.Page, in.PerPage) + if in.UserId != "" { + values.Set("user_id", in.UserId) } - - mod := s.listModel(ctx, db) - mod = s.applyListWhere(mod, in) - mod = mod.Page(in.Page, in.PerPage).OrderDesc("u.id") - - if err = mod.ScanAndCount(&list, &totalCount, false); err != nil { - err = gerror.Wrap(err, "获取IM用户列表失败,请稍后重试!") - return + if in.Nickname != "" { + values.Set("nickname", in.Nickname) } - if len(list) == 0 { - return + if in.AuthName != "" { + values.Set("auth_name", in.AuthName) } - - for _, item := range list { - item.TenantId = tenant.Id - item.TenantName = tenant.Name + if in.State != nil { + values.Set("state", intToString(*in.State)) } - if err = s.fillAuthAndCredentials(ctx, db, list); err != nil { - return - } - return + addTimeRangeQuery(values, "created_at", in.CreatedAt) + addTimeRangeQuery(values, "last_seen", in.LastSeen) + return imAdminQueryList[sysin.ImUserListModel](ctx, tenant, + "/internal/admin-query/tenants/"+url.PathEscape(tenant.Code)+"/users", + values, + map[string]string{ + "public": "profileJson", + "trusted": "trustedJson", + "access": "accessJson", + }, + ) } func (s *sSysImUser) listModel(ctx context.Context, db gdb.DB) *gdb.Model { @@ -120,13 +122,16 @@ func (s *sSysImUser) fillAuthAndCredentials(ctx context.Context, db gdb.DB, list ids := make([]int64, 0, len(list)) userMap := make(map[int64]*sysin.ImUserListModel, len(list)) for _, item := range list { - userId, parseErr := sysin.ParseOptionalInt64(item.UserId) - if parseErr != nil { - return parseErr + userId := item.UserId + if userId <= 0 { + continue } ids = append(ids, userId) userMap[userId] = item } + if len(ids) == 0 { + return nil + } credentialRows, err := db.Model("im_credentials").Safe().Ctx(ctx). Fields("userid", "method", "value", "done"). @@ -164,41 +169,18 @@ func (s *sSysImUser) View(ctx context.Context, in *sysin.ImUserViewInp) (res *sy if err = in.Filter(ctx); err != nil { return } - - scope, err := resolveImAdminScope(ctx, in.TenantId) + tenant, err := resolveImAdminTenant(ctx, in.TenantId) if err != nil { - return - } - db, tenant, err := imtenant.DB(ctx, scope.TenantId) - if err != nil { - return - } - - userId, err := sysin.ParseOptionalInt64(in.UserId) - if err != nil { - return nil, err - } - fields := append(s.viewFields(), "u.access access_json", "u.public profile_json", "u.trusted trusted_json") - err = db.Model("im_users u").Safe().Ctx(ctx). - LeftJoin("im_user_profiles up", "up.user_id = u.id"). - LeftJoin("im_user_security us", "us.user_id = u.id"). - Fields(fields...). - Where("u.id", userId). - Scan(&res) - if err != nil { - return nil, gerror.Wrap(err, "获取IM用户信息失败,请稍后重试!") - } - if res == nil { return nil, nil } - - res.TenantId = tenant.Id - res.TenantName = tenant.Name - list := []*sysin.ImUserListModel{&res.ImUserListModel} - if err = s.fillAuthAndCredentials(ctx, db, list); err != nil { - return nil, err - } - return + return imAdminQueryOne[sysin.ImUserViewModel](ctx, tenant, + "/internal/admin-query/tenants/"+url.PathEscape(tenant.Code)+"/users/"+url.PathEscape(in.UserId), + map[string]string{ + "public": "profileJson", + "trusted": "trustedJson", + "access": "accessJson", + }, + ) } func (s *sSysImUser) viewFields() []interface{} { @@ -306,45 +288,104 @@ func (s *sSysImUser) Devices(ctx context.Context, in *sysin.ImUserDeviceListInp) if err = in.Filter(ctx); err != nil { return } - scope, err := resolveImAdminScope(ctx, in.TenantId) - if err != nil { - return - } - userId, err := sysin.ParseOptionalInt64(in.UserId) - if err != nil { - return - } - db, tenant, err := imtenant.DB(ctx, scope.TenantId) + tenant, err := resolveImAdminTenant(ctx, in.TenantId) if err != nil { return } + return imAdminQueryList[sysin.ImUserDeviceListModel](ctx, tenant, + "/internal/admin-query/tenants/"+url.PathEscape(tenant.Code)+"/users/"+url.PathEscape(in.UserId)+"/devices", + addPageQuery(url.Values{}, in.Page, in.PerPage), + nil, + ) +} - mod := db.Model("im_devices d").Safe().Ctx(ctx). - LeftJoin("im_device_push_tokens p", "p.user_id=d.userid AND p.device_id=d.deviceid"). - Fields( - "CAST(d.userid AS CHAR) user_id", - "d.deviceid device_id", - "COALESCE(d.platform, '') platform", - "COALESCE(d.lang, '') lang", - "d.lastseen last_seen", - "COALESCE(p.provider, '') push_provider", - "COALESCE(p.platform, '') push_platform", - "COALESCE(p.app_version, '') push_app_version", - "(p.id IS NOT NULL) push_token_configured", - "p.updated_at push_updated_at", - ). - Where("d.userid", userId). - Page(in.Page, in.PerPage). - OrderDesc("d.lastseen") +func (s *sSysImUser) Ban(ctx context.Context, in *sysin.ImUserGovernanceInp) (res *sysin.ImUserCommandModel, err error) { + return s.publishGovernanceCommand(ctx, in, consts.ImAdminCommandUserBan) +} - if err = mod.ScanAndCount(&list, &totalCount, false); err != nil { - return nil, 0, gerror.Wrap(err, "获取IM用户设备列表失败,请稍后重试!") +func (s *sSysImUser) Unban(ctx context.Context, in *sysin.ImUserGovernanceInp) (res *sysin.ImUserCommandModel, err error) { + return s.publishGovernanceCommand(ctx, in, consts.ImAdminCommandUserUnban) +} + +func (s *sSysImUser) Freeze(ctx context.Context, in *sysin.ImUserGovernanceInp) (res *sysin.ImUserCommandModel, err error) { + return s.publishGovernanceCommand(ctx, in, consts.ImAdminCommandUserFreeze) +} + +func (s *sSysImUser) Unfreeze(ctx context.Context, in *sysin.ImUserGovernanceInp) (res *sysin.ImUserCommandModel, err error) { + return s.publishGovernanceCommand(ctx, in, consts.ImAdminCommandUserUnfreeze) +} + +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 } - for _, item := range list { - item.TenantId = tenant.Id - item.TenantName = tenant.Name + return s.publishGovernanceCommand(ctx, in, consts.ImAdminCommandUserDeviceKick) +} + +func (s *sSysImUser) LogoutAll(ctx context.Context, in *sysin.ImUserGovernanceInp) (res *sysin.ImUserCommandModel, err error) { + return s.publishGovernanceCommand(ctx, in, consts.ImAdminCommandUserLogoutAll) +} + +func (s *sSysImUser) AccountCancel(ctx context.Context, in *sysin.ImUserGovernanceInp) (res *sysin.ImUserCommandModel, err error) { + return s.publishGovernanceCommand(ctx, in, consts.ImAdminCommandUserCancel) +} + +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 } - return + _, 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 updateIMUserStatus(ctx context.Context, db gdb.DB, scope imAdminScope, userId int64, state int) error { diff --git a/server/internal/model/input/sysin/im_dead_letter.go b/server/internal/model/input/sysin/im_dead_letter.go new file mode 100644 index 0000000..d81e506 --- /dev/null +++ b/server/internal/model/input/sysin/im_dead_letter.go @@ -0,0 +1,130 @@ +// Package sysin +// @Link https://github.com/bufanyun/hotgo +// @Copyright Copyright (c) 2026 HotGo +// @Author Ms <133814250@qq.com> +// @License https://github.com/bufanyun/hotgo/blob/master/LICENSE +package sysin + +import ( + "context" + + "github.com/gogf/gf/v2/errors/gerror" + "github.com/gogf/gf/v2/os/gtime" +) + +type ImDeadLetterListInp struct { + TenantId int64 `json:"tenantId" dc:"租户ID"` + Status string `json:"status" dc:"状态"` + BizType string `json:"bizType" dc:"业务类型"` + BizId string `json:"bizId" dc:"业务ID"` + EventType string `json:"eventType" dc:"事件类型"` + Cursor string `json:"cursor" dc:"游标"` + Limit int `json:"limit" dc:"数量"` +} + +func (in *ImDeadLetterListInp) Filter(ctx context.Context) (err error) { + if in.TenantId <= 0 { + return gerror.New("请选择IM租户") + } + if in.Limit <= 0 || in.Limit > 200 { + in.Limit = 50 + } + return nil +} + +type ImDeadLetterListModel struct { + Id int64 `json:"id" dc:"ID"` + DeadId string `json:"deadId" dc:"死信ID"` + EventId string `json:"eventId" dc:"事件ID"` + ParentEventId string `json:"parentEventId" dc:"父事件ID"` + SourceTopic string `json:"sourceTopic" dc:"来源Topic"` + ConsumerGroup string `json:"consumerGroup" dc:"消费组"` + EventType string `json:"eventType" dc:"事件类型"` + BizType string `json:"bizType" dc:"业务类型"` + BizId string `json:"bizId" dc:"业务ID"` + RetryCount int `json:"retryCount" dc:"重试次数"` + LastReason string `json:"lastReason" dc:"最后原因"` + LastError string `json:"lastError" dc:"最后错误"` + Status string `json:"status" dc:"状态"` + ReplayCount int `json:"replayCount" dc:"重放次数"` + CreatedAt *gtime.Time `json:"createdAt" dc:"创建时间"` + UpdatedAt *gtime.Time `json:"updatedAt" dc:"更新时间"` +} + +type ImDeadLetterListModelRaw struct { + Id int64 `json:"id"` + DeadId string `json:"dead_id"` + EventId string `json:"event_id"` + ParentEventId string `json:"parent_event_id"` + SourceTopic string `json:"source_topic"` + ConsumerGroup string `json:"consumer_group"` + EventType string `json:"event_type"` + BizType string `json:"biz_type"` + BizId string `json:"biz_id"` + RetryCount int `json:"retry_count"` + LastReason string `json:"last_reason"` + LastError string `json:"last_error"` + Status string `json:"status"` + ReplayCount int `json:"replay_count"` + CreatedAt *gtime.Time `json:"created_at"` + UpdatedAt *gtime.Time `json:"updated_at"` +} + +type ImDeadLetterPageModel struct { + List []*ImDeadLetterListModel `json:"list" dc:"数据列表"` + NextCursor string `json:"nextCursor" dc:"下一页游标"` + HasMore bool `json:"hasMore" dc:"是否还有更多"` +} + +type ImDeadLetterPageRaw struct { + Items []*ImDeadLetterListModelRaw `json:"items"` + NextCursor string `json:"next_cursor"` + HasMore bool `json:"has_more"` +} + +type ImDeadLetterViewInp struct { + TenantId int64 `json:"tenantId" dc:"租户ID"` + DeadId string `json:"deadId" dc:"死信ID"` +} + +func (in *ImDeadLetterViewInp) Filter(ctx context.Context) (err error) { + if in.TenantId <= 0 { + return gerror.New("请选择IM租户") + } + if in.DeadId == "" { + return gerror.New("请选择Kafka死信") + } + return nil +} + +type ImDeadLetterViewModel struct { + ImDeadLetterListModel + Envelope string `json:"envelope" dc:"Envelope"` + EnvelopeRaw string `json:"envelopeRaw" dc:"Envelope Raw"` +} + +type ImDeadLetterViewRaw struct { + ImDeadLetterListModelRaw + Envelope any `json:"envelope"` + EnvelopeRaw string `json:"envelope_raw"` +} + +type ImDeadLetterActionInp struct { + TenantId int64 `json:"tenantId" dc:"租户ID"` + DeadId string `json:"deadId" dc:"死信ID"` + ActorUserId int64 `json:"actorUserId" dc:"操作IM用户ID"` + Note string `json:"note" dc:"备注"` +} + +func (in *ImDeadLetterActionInp) Filter(ctx context.Context) (err error) { + if in.TenantId <= 0 { + return gerror.New("请选择IM租户") + } + if in.DeadId == "" { + return gerror.New("请选择Kafka死信") + } + if in.ActorUserId <= 0 { + return gerror.New("操作IM用户ID不能为空") + } + return nil +} diff --git a/server/internal/model/input/sysin/im_group.go b/server/internal/model/input/sysin/im_group.go index 6ce4c14..2ecbdd7 100644 --- a/server/internal/model/input/sysin/im_group.go +++ b/server/internal/model/input/sysin/im_group.go @@ -198,6 +198,30 @@ func (in *ImGroupGovernanceUpdateInp) Filter(ctx context.Context) (err error) { return } +type ImGroupProfileUpdateInp struct { + ImGroupTopicInp + Name string `json:"name" dc:"群名称"` + AvatarFileId string `json:"avatarFileId" dc:"头像文件ID"` + Description string `json:"description" dc:"简介"` + Version int64 `json:"version" dc:"版本"` +} + +func (in *ImGroupProfileUpdateInp) Filter(ctx context.Context) (err error) { + if err = in.ImGroupTopicInp.Filter(ctx); err != nil { + return + } + if in.Name == "" && in.AvatarFileId == "" && in.Description == "" { + return gerror.New("请填写需要修改的群资料") + } + if len([]rune(in.Name)) > 80 { + return gerror.New("群名称不能超过80个字符") + } + if len([]rune(in.Description)) > 500 { + return gerror.New("群简介不能超过500个字符") + } + return +} + type ImGroupDissolveInp struct { ImGroupTopicInp Reason string `json:"reason" dc:"解散原因"` @@ -458,3 +482,32 @@ type ImGroupAdminLogListModel struct { IdempotencyKey string `json:"idempotencyKey" dc:"幂等键"` CreatedAt *gtime.Time `json:"createdAt" dc:"创建时间"` } + +type ImGroupMemberRemoveInp struct { + ImGroupTopicInp + UserId int64 `json:"userId" v:"required#用户ID不能为空" dc:"用户ID"` + Reason string `json:"reason" dc:"原因"` +} + +func (in *ImGroupMemberRemoveInp) Filter(ctx context.Context) (err error) { + if err = in.ImGroupTopicInp.Filter(ctx); err != nil { + return + } + if in.UserId <= 0 { + return gerror.New("用户ID不能为空") + } + if len([]rune(in.Reason)) > 200 { + return gerror.New("原因不能超过200个字符") + } + return +} + +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:"幂等键"` +} diff --git a/server/internal/model/input/sysin/im_kafka_audit.go b/server/internal/model/input/sysin/im_kafka_audit.go new file mode 100644 index 0000000..caea3ae --- /dev/null +++ b/server/internal/model/input/sysin/im_kafka_audit.go @@ -0,0 +1,65 @@ +package sysin + +import ( + "context" + "hotgo/internal/model/input/form" + + "github.com/gogf/gf/v2/errors/gerror" + "github.com/gogf/gf/v2/os/gtime" +) + +type ImKafkaAuditListInp struct { + form.PageReq + TenantCode string `json:"tenantCode" dc:"租户编码"` + EventId string `json:"eventId" dc:"事件ID"` + EventType string `json:"eventType" dc:"事件类型"` + BizType string `json:"bizType" dc:"业务类型"` + BizId string `json:"bizId" dc:"业务ID"` + ActorType string `json:"actorType" dc:"操作者类型"` + ActorId string `json:"actorId" dc:"操作者ID"` + Action string `json:"action" dc:"动作"` + Resource string `json:"resource" dc:"资源"` + ResourceId string `json:"resourceId" dc:"资源ID"` + Result string `json:"result" dc:"结果"` + CreatedAt []*gtime.Time `json:"createdAt" dc:"接收时间"` +} + +func (in *ImKafkaAuditListInp) Filter(ctx context.Context) (err error) { + return nil +} + +type ImKafkaAuditListModel struct { + Id int64 `json:"id" dc:"ID"` + EventId string `json:"eventId" dc:"事件ID"` + TenantCode string `json:"tenantCode" dc:"租户编码"` + EventType string `json:"eventType" dc:"事件类型"` + BizType string `json:"bizType" dc:"业务类型"` + BizId string `json:"bizId" dc:"业务ID"` + ActorType string `json:"actorType" dc:"操作者类型"` + ActorId string `json:"actorId" dc:"操作者ID"` + Action string `json:"action" dc:"动作"` + Resource string `json:"resource" dc:"资源"` + ResourceId string `json:"resourceId" dc:"资源ID"` + Result string `json:"result" dc:"结果"` + Reason string `json:"reason" dc:"原因"` + Payload string `json:"payload" dc:"Payload"` + Envelope string `json:"envelope" dc:"Envelope"` + OccurredAt *gtime.Time `json:"occurredAt" dc:"发生时间"` + ReceivedAt *gtime.Time `json:"receivedAt" dc:"接收时间"` +} + +type ImKafkaAuditViewInp struct { + Id int64 `json:"id" dc:"ID"` + EventId string `json:"eventId" dc:"事件ID"` +} + +func (in *ImKafkaAuditViewInp) Filter(ctx context.Context) (err error) { + if in.Id <= 0 && in.EventId == "" { + return gerror.New("请选择IM Kafka审计事件") + } + return nil +} + +type ImKafkaAuditViewModel struct { + ImKafkaAuditListModel +} diff --git a/server/internal/model/input/sysin/im_kafka_command.go b/server/internal/model/input/sysin/im_kafka_command.go new file mode 100644 index 0000000..5b4180a --- /dev/null +++ b/server/internal/model/input/sysin/im_kafka_command.go @@ -0,0 +1,126 @@ +// Package sysin +// @Link https://github.com/bufanyun/hotgo +// @Copyright Copyright (c) 2026 HotGo +// @Author Ms <133814250@qq.com> +// @License https://github.com/bufanyun/hotgo/blob/master/LICENSE +package sysin + +import ( + "context" + "hotgo/internal/model/input/form" + "strings" + + "github.com/gogf/gf/v2/errors/gerror" + "github.com/gogf/gf/v2/os/gtime" +) + +type ImKafkaCommandListInp struct { + form.PageReq + TenantId int64 `json:"tenantId" dc:"租户ID"` + CommandId string `json:"commandId" dc:"命令ID"` + EventId string `json:"eventId" dc:"事件ID"` + EventType string `json:"eventType" dc:"事件类型"` + BizType string `json:"bizType" dc:"业务类型"` + BizId string `json:"bizId" dc:"业务ID"` + Status string `json:"status" dc:"状态"` + CreatedAt []*gtime.Time `json:"createdAt" dc:"创建时间"` +} + +func (in *ImKafkaCommandListInp) Filter(ctx context.Context) (err error) { + return nil +} + +type ImKafkaCommandListModel struct { + Id int64 `json:"id" dc:"ID"` + CommandId string `json:"commandId" dc:"命令ID"` + TenantId int64 `json:"tenantId" dc:"租户ID"` + TenantCode string `json:"tenantCode" dc:"租户编码"` + EventId string `json:"eventId" dc:"事件ID"` + EventType string `json:"eventType" dc:"事件类型"` + BizType string `json:"bizType" dc:"业务类型"` + BizId string `json:"bizId" dc:"业务ID"` + IdempotencyKey string `json:"idempotencyKey" dc:"幂等键"` + Status string `json:"status" dc:"状态"` + Reason string `json:"reason" dc:"错误码"` + Message string `json:"message" dc:"消息"` + SentAt *gtime.Time `json:"sentAt" dc:"投递时间"` + CompletedAt *gtime.Time `json:"completedAt" dc:"完成时间"` + CreatedBy int64 `json:"createdBy" dc:"创建人"` + CreatedAt *gtime.Time `json:"createdAt" dc:"创建时间"` + UpdatedAt *gtime.Time `json:"updatedAt" dc:"更新时间"` +} + +type ImKafkaCommandViewInp struct { + Id int64 `json:"id" dc:"ID"` + CommandId string `json:"commandId" dc:"命令ID"` +} + +func (in *ImKafkaCommandViewInp) Filter(ctx context.Context) (err error) { + if in.Id <= 0 && in.CommandId == "" { + return gerror.New("请选择IM后台命令") + } + return nil +} + +type ImKafkaCommandViewModel struct { + ImKafkaCommandListModel + Payload string `json:"payload" dc:"Payload"` + Envelope string `json:"envelope" dc:"Envelope"` + Result string `json:"result" dc:"Result"` +} + +type ImKafkaCacheInvalidateInp struct { + TenantCode string `json:"tenantCode" dc:"租户编码"` + Namespace string `json:"namespace" dc:"缓存命名空间"` + Keys []string `json:"keys" dc:"缓存Key"` + Reason string `json:"reason" dc:"原因"` +} + +func (in *ImKafkaCacheInvalidateInp) Filter(ctx context.Context) (err error) { + in.TenantCode = strings.TrimSpace(in.TenantCode) + in.Namespace = strings.TrimSpace(in.Namespace) + if in.TenantCode == "" { + return gerror.New("请选择租户") + } + if in.Namespace == "" { + return gerror.New("请输入缓存命名空间") + } + keys := make([]string, 0, len(in.Keys)) + for _, key := range in.Keys { + if key = strings.TrimSpace(key); key != "" { + keys = append(keys, key) + } + } + in.Keys = keys + return nil +} + +type ImKafkaEventModel struct { + EventId string `json:"eventId" dc:"Kafka事件ID"` +} + +type ImKafkaWorkbenchEventInp struct { + TenantCode string `json:"tenantCode" dc:"租户编码"` + EventType string `json:"eventType" dc:"事件类型"` + BizId string `json:"bizId" dc:"业务ID"` + Payload map[string]any `json:"payload" dc:"Payload"` +} + +func (in *ImKafkaWorkbenchEventInp) Filter(ctx context.Context) (err error) { + in.TenantCode = strings.TrimSpace(in.TenantCode) + in.EventType = strings.TrimSpace(in.EventType) + in.BizId = strings.TrimSpace(in.BizId) + if in.TenantCode == "" { + return gerror.New("请选择租户") + } + if in.EventType == "" { + in.EventType = "workbench.connectivity.test" + } + if in.BizId == "" { + in.BizId = "admin_connectivity_test" + } + if in.Payload == nil { + in.Payload = map[string]any{} + } + return nil +} diff --git a/server/internal/model/input/sysin/im_user.go b/server/internal/model/input/sysin/im_user.go index 7a75886..a0be265 100644 --- a/server/internal/model/input/sysin/im_user.go +++ b/server/internal/model/input/sysin/im_user.go @@ -37,7 +37,7 @@ func (in *ImUserListInp) Filter(ctx context.Context) (err error) { type ImUserListModel struct { TenantId int64 `json:"tenantId" dc:"租户ID"` TenantName string `json:"tenantName" dc:"租户名称"` - UserId string `json:"userId" dc:"用户ID"` + UserId int64 `json:"userId" dc:"用户ID"` Username string `json:"username" dc:"登录账号"` Phone string `json:"phone" dc:"手机号"` Nickname string `json:"nickname" dc:"昵称"` @@ -139,7 +139,7 @@ func (in *ImUserDeviceListInp) Filter(ctx context.Context) (err error) { type ImUserDeviceListModel struct { TenantId int64 `json:"tenantId" dc:"租户ID"` TenantName string `json:"tenantName" dc:"租户名称"` - UserId string `json:"userId" dc:"用户ID"` + UserId int64 `json:"userId" dc:"用户ID"` DeviceId string `json:"deviceId" dc:"设备ID"` Platform string `json:"platform" dc:"平台"` Lang string `json:"lang" dc:"语言"` @@ -150,3 +150,45 @@ type ImUserDeviceListModel struct { PushTokenConfigured bool `json:"pushTokenConfigured" dc:"是否配置Push Token"` PushUpdatedAt *gtime.Time `json:"pushUpdatedAt" dc:"Push更新时间"` } + +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 +} + +type ImUserCommandModel struct { + CommandID string `json:"commandId" dc:"命令ID"` + EventID string `json:"eventId" dc:"Kafka事件ID"` + Status string `json:"status" dc:"状态"` + TenantID int64 `json:"tenantId" dc:"租户ID"` + TenantCode string `json:"tenantCode" dc:"租户编码"` + EventType string `json:"eventType" dc:"事件类型"` + IdempotencyKey string `json:"idempotencyKey" dc:"幂等键"` +} diff --git a/server/internal/queues/im_admin_command_result.go b/server/internal/queues/im_admin_command_result.go new file mode 100644 index 0000000..f7b7b40 --- /dev/null +++ b/server/internal/queues/im_admin_command_result.go @@ -0,0 +1,49 @@ +// 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 result imkafka.CommandResult + if err = json.Unmarshal(mqMsg.Body, &result); err != nil { + return err + } + if result.CommandID == "" && result.CommandEventID == "" { + var envelope imkafka.Envelope + if err = json.Unmarshal(mqMsg.Body, &envelope); err != nil { + return err + } + if len(envelope.Payload) > 0 { + if err = json.Unmarshal(envelope.Payload, &result); err != nil { + return err + } + result.EventID = envelope.EventID + result.TraceID = envelope.TraceID + result.TenantCode = envelope.TenantCode + result.OccurredAt = envelope.OccurredAt + } + } + return imkafka.ApplyCommandResult(ctx, result) +} diff --git a/server/internal/queues/im_audit_event.go b/server/internal/queues/im_audit_event.go new file mode 100644 index 0000000..8d122ff --- /dev/null +++ b/server/internal/queues/im_audit_event.go @@ -0,0 +1,54 @@ +package queues + +import ( + "context" + "encoding/json" + "hotgo/internal/consts" + "hotgo/internal/library/imkafka" + "hotgo/internal/library/queue" + + "github.com/gogf/gf/v2/frame/g" + "github.com/gogf/gf/v2/os/gtime" +) + +func init() { + queue.RegisterConsumer(ImAuditEvent) +} + +var ImAuditEvent = &qImAuditEvent{} + +type qImAuditEvent struct{} + +func (q *qImAuditEvent) GetTopic() string { + return consts.QueueImAuditEventTopic +} + +func (q *qImAuditEvent) Handle(ctx context.Context, mqMsg queue.MqMsg) (err error) { + var envelope imkafka.Envelope + if err = json.Unmarshal(mqMsg.Body, &envelope); err != nil { + return err + } + payload := map[string]any{} + if len(envelope.Payload) > 0 { + _ = json.Unmarshal(envelope.Payload, &payload) + } + _, err = g.DB().Model("im_audit_events").Safe().Ctx(ctx).Data(g.Map{ + "event_id": envelope.EventID, + "tenant_code": envelope.TenantCode, + "event_type": envelope.EventType, + "biz_type": envelope.BizType, + "biz_id": envelope.BizID, + "actor_type": firstString(payload["actor_type"]), + "actor_id": firstString(payload["actor_id"]), + "action": firstString(payload["action"]), + "resource": firstString(payload["resource"]), + "resource_id": firstString(payload["resource_id"]), + "result": firstString(payload["result"]), + "reason": firstString(payload["reason"]), + "payload": string(envelope.Payload), + "envelope": string(mqMsg.Body), + "occurred_at": envelope.OccurredAt, + "received_at": gtime.Now(), + }).InsertIgnore() + return err +} diff --git a/server/internal/queues/im_dead_letter.go b/server/internal/queues/im_dead_letter.go new file mode 100644 index 0000000..e63729f --- /dev/null +++ b/server/internal/queues/im_dead_letter.go @@ -0,0 +1,74 @@ +package queues + +import ( + "context" + "encoding/json" + "hotgo/internal/consts" + "hotgo/internal/library/imkafka" + "hotgo/internal/library/queue" + + "github.com/gogf/gf/v2/frame/g" + "github.com/gogf/gf/v2/os/gtime" +) + +func init() { + queue.RegisterConsumer(ImDeadLetter) +} + +var ImDeadLetter = &qImDeadLetter{} + +type qImDeadLetter struct{} + +func (q *qImDeadLetter) GetTopic() string { + return consts.QueueImDeadLetterTopic +} + +func (q *qImDeadLetter) Handle(ctx context.Context, mqMsg queue.MqMsg) (err error) { + var envelope imkafka.Envelope + if err = json.Unmarshal(mqMsg.Body, &envelope); err != nil { + return err + } + payload := map[string]any{} + if len(envelope.Payload) > 0 { + _ = json.Unmarshal(envelope.Payload, &payload) + } + _, err = g.DB().Model("im_kafka_dead_letter_events").Safe().Ctx(ctx).Data(g.Map{ + "event_id": envelope.EventID, + "parent_event_id": envelope.ParentEventID, + "tenant_code": envelope.TenantCode, + "event_type": envelope.EventType, + "biz_type": envelope.BizType, + "biz_id": envelope.BizID, + "source_service": envelope.SourceService, + "target_service": envelope.TargetService, + "dead_id": stringValue(payload["dead_id"]), + "reason": firstString(payload["reason"], payload["last_reason"]), + "message": firstString(payload["message"], payload["last_error"]), + "payload": string(envelope.Payload), + "envelope": string(mqMsg.Body), + "received_at": gtime.Now(), + }).InsertIgnore() + return err +} + +func stringValue(v any) string { + if v == nil { + return "" + } + switch value := v.(type) { + case string: + return value + default: + b, _ := json.Marshal(value) + return string(b) + } +} + +func firstString(values ...any) string { + for _, v := range values { + if text := stringValue(v); text != "" && text != "null" { + return text + } + } + return "" +} diff --git a/server/internal/router/genrouter/im_dead_letter.go b/server/internal/router/genrouter/im_dead_letter.go new file mode 100644 index 0000000..d08ee20 --- /dev/null +++ b/server/internal/router/genrouter/im_dead_letter.go @@ -0,0 +1,7 @@ +package genrouter + +import "hotgo/internal/controller/admin/sys" + +func init() { + LoginRequiredRouter = append(LoginRequiredRouter, sys.ImDeadLetter) // IM Kafka死信 +} diff --git a/server/internal/router/genrouter/im_kafka_audit.go b/server/internal/router/genrouter/im_kafka_audit.go new file mode 100644 index 0000000..ba6eeec --- /dev/null +++ b/server/internal/router/genrouter/im_kafka_audit.go @@ -0,0 +1,7 @@ +package genrouter + +import "hotgo/internal/controller/admin/sys" + +func init() { + LoginRequiredRouter = append(LoginRequiredRouter, sys.ImKafkaAudit) // IM Kafka审计事件 +} diff --git a/server/internal/router/genrouter/im_kafka_command.go b/server/internal/router/genrouter/im_kafka_command.go new file mode 100644 index 0000000..4bfcb6e --- /dev/null +++ b/server/internal/router/genrouter/im_kafka_command.go @@ -0,0 +1,7 @@ +package genrouter + +import "hotgo/internal/controller/admin/sys" + +func init() { + LoginRequiredRouter = append(LoginRequiredRouter, sys.ImKafkaCommand) // IM后台命令 +} diff --git a/server/internal/router/internal.go b/server/internal/router/internal.go new file mode 100644 index 0000000..1fe2908 --- /dev/null +++ b/server/internal/router/internal.go @@ -0,0 +1,18 @@ +package router + +import ( + "context" + "hotgo/internal/controller/internalapi/imconfig" + "hotgo/internal/library/iminternal" + + "github.com/gogf/gf/v2/net/ghttp" +) + +func Internal(ctx context.Context, group *ghttp.RouterGroup) { + group.Group("/", func(group *ghttp.RouterGroup) { + group.Middleware(iminternal.Middleware) + group.Bind( + imconfig.Config, + ) + }) +} diff --git a/server/internal/service/sys.go b/server/internal/service/sys.go index 4b2f86c..88804fb 100644 --- a/server/internal/service/sys.go +++ b/server/internal/service/sys.go @@ -226,6 +226,22 @@ type ( Status(ctx context.Context, in *sysin.ImUserStatusInp) (err error) // Devices 获取IM用户设备列表 Devices(ctx context.Context, in *sysin.ImUserDeviceListInp) (list []*sysin.ImUserDeviceListModel, totalCount int, 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群组列表 @@ -238,18 +254,30 @@ type ( Governance(ctx context.Context, in *sysin.ImGroupTopicInp) (res *sysin.ImGroupGovernanceModel, err error) // GovernanceUpdate 更新IM群治理配置 GovernanceUpdate(ctx context.Context, in *sysin.ImGroupGovernanceUpdateInp) (err error) + // ProfileUpdate 修改IM群资料 + ProfileUpdate(ctx context.Context, in *sysin.ImGroupProfileUpdateInp) (res *sysin.ImGroupCommandModel, err error) // Dissolve 解散IM群 Dissolve(ctx context.Context, in *sysin.ImGroupDissolveInp) (err error) // Mute 禁言IM群成员 Mute(ctx context.Context, in *sysin.ImGroupMuteInp) (err error) + // MemberMute Kafka禁言IM群成员 + MemberMute(ctx context.Context, in *sysin.ImGroupMuteInp) (res *sysin.ImGroupCommandModel, err error) // Unmute 解除IM群成员禁言 Unmute(ctx context.Context, in *sysin.ImGroupUnmuteInp) (err error) + // MemberUnmute Kafka解除IM群成员禁言 + MemberUnmute(ctx context.Context, in *sysin.ImGroupUnmuteInp) (res *sysin.ImGroupCommandModel, err error) + // MemberRemove 移出IM群成员 + MemberRemove(ctx context.Context, in *sysin.ImGroupMemberRemoveInp) (res *sysin.ImGroupCommandModel, err error) // JoinRequestApprove 通过IM入群申请 JoinRequestApprove(ctx context.Context, in *sysin.ImGroupJoinRequestResolveInp) (err error) // JoinRequestReject 拒绝IM入群申请 JoinRequestReject(ctx context.Context, in *sysin.ImGroupJoinRequestResolveInp) (err error) // RoleUpdate 更新IM群成员角色 RoleUpdate(ctx context.Context, in *sysin.ImGroupRoleUpdateInp) (err error) + // AdminSet 设置IM群管理员 + AdminSet(ctx context.Context, in *sysin.ImGroupRoleUpdateInp) (res *sysin.ImGroupCommandModel, err error) + // AdminRemove 删除IM群管理员 + AdminRemove(ctx context.Context, in *sysin.ImGroupRoleUpdateInp) (res *sysin.ImGroupCommandModel, err error) // OwnerTransfer 转让IM群主 OwnerTransfer(ctx context.Context, in *sysin.ImGroupOwnerTransferInp) (err error) // JoinRequestList 获取IM入群申请列表 @@ -395,6 +423,34 @@ type ( // View 获取IM审计日志指定信息 View(ctx context.Context, in *sysin.ImAuditLogViewInp) (res *sysin.ImAuditLogViewModel, err error) } + ISysImKafkaAudit interface { + // List 获取IM Kafka审计事件列表 + List(ctx context.Context, in *sysin.ImKafkaAuditListInp) (list []*sysin.ImKafkaAuditListModel, totalCount int, err error) + // View 获取IM Kafka审计事件详情 + View(ctx context.Context, in *sysin.ImKafkaAuditViewInp) (res *sysin.ImKafkaAuditViewModel, err error) + } + ISysImKafkaCommand interface { + // List 获取IM后台命令列表 + List(ctx context.Context, in *sysin.ImKafkaCommandListInp) (list []*sysin.ImKafkaCommandListModel, totalCount int, err error) + // View 获取IM后台命令详情 + View(ctx context.Context, in *sysin.ImKafkaCommandViewInp) (res *sysin.ImKafkaCommandViewModel, err error) + // CacheInvalidate 发送IM缓存失效测试事件 + CacheInvalidate(ctx context.Context, in *sysin.ImKafkaCacheInvalidateInp) (res *sysin.ImKafkaEventModel, err error) + // WorkbenchEvent 发送IM工作台测试事件 + WorkbenchEvent(ctx context.Context, in *sysin.ImKafkaWorkbenchEventInp) (res *sysin.ImKafkaEventModel, err error) + } + ISysImDeadLetter interface { + // List 获取IM Kafka死信列表 + List(ctx context.Context, in *sysin.ImDeadLetterListInp) (res *sysin.ImDeadLetterPageModel, err error) + // View 获取IM Kafka死信详情 + View(ctx context.Context, in *sysin.ImDeadLetterViewInp) (res *sysin.ImDeadLetterViewModel, err error) + // Replay 重放IM Kafka死信 + Replay(ctx context.Context, in *sysin.ImDeadLetterActionInp) (res *sysin.ImGroupCommandModel, err error) + // Ignore 忽略IM Kafka死信 + Ignore(ctx context.Context, in *sysin.ImDeadLetterActionInp) (res *sysin.ImGroupCommandModel, err error) + // Resolve 标记IM Kafka死信已处理 + Resolve(ctx context.Context, in *sysin.ImDeadLetterActionInp) (res *sysin.ImGroupCommandModel, err error) + } ISysDictData interface { // Delete 删除 Delete(ctx context.Context, in *sysin.DictDataDeleteInp) error @@ -658,6 +714,9 @@ var ( localSysImWorkbench ISysImWorkbench localSysImTaskDashboard ISysImTaskDashboard localSysImAuditLog ISysImAuditLog + localSysImKafkaAudit ISysImKafkaAudit + localSysImKafkaCommand ISysImKafkaCommand + localSysImDeadLetter ISysImDeadLetter localSysDictData ISysDictData localSysDictType ISysDictType localSysEmsLog ISysEmsLog @@ -981,6 +1040,39 @@ func RegisterSysImAuditLog(i ISysImAuditLog) { localSysImAuditLog = i } +func SysImKafkaAudit() ISysImKafkaAudit { + if localSysImKafkaAudit == nil { + panic("implement not found for interface ISysImKafkaAudit, forgot register?") + } + return localSysImKafkaAudit +} + +func RegisterSysImKafkaAudit(i ISysImKafkaAudit) { + localSysImKafkaAudit = i +} + +func SysImKafkaCommand() ISysImKafkaCommand { + if localSysImKafkaCommand == nil { + panic("implement not found for interface ISysImKafkaCommand, forgot register?") + } + return localSysImKafkaCommand +} + +func RegisterSysImKafkaCommand(i ISysImKafkaCommand) { + localSysImKafkaCommand = i +} + +func SysImDeadLetter() ISysImDeadLetter { + if localSysImDeadLetter == nil { + panic("implement not found for interface ISysImDeadLetter, forgot register?") + } + return localSysImDeadLetter +} + +func RegisterSysImDeadLetter(i ISysImDeadLetter) { + localSysImDeadLetter = i +} + func SysDictData() ISysDictData { if localSysDictData == nil { panic("implement not found for interface ISysDictData, forgot register?") diff --git a/server/manifest/config/config.example.yaml b/server/manifest/config/config.example.yaml index 865f8f6..7044e0d 100644 --- a/server/manifest/config/config.example.yaml +++ b/server/manifest/config/config.example.yaml @@ -305,6 +305,18 @@ hggen: webApiPath: "../web/src/api/addons/{$name}" # webApi生成路径 webViewsPath: "../web/src/views/addons/{$name}" # web页面生成路径 -imDirectDb: - # Each HotGo writer must use a unique ID which does not overlap an IM node. - snowflakeWorkerId: 1023 +imAdminApi: + baseUrl: "http://127.0.0.1:6060" + serviceId: "hotgo-admin" + serviceSecret: "${IM_ADMIN_API_SECRET}" + timeout: "5s" + +imInternalApi: + baseUrl: "http://127.0.0.1:6060/v1" + serviceName: "hotgo-admin" + serviceSecret: "${IM_INTERNAL_API_SECRET}" + +imConfigApi: + serviceName: "im-server" + serviceSecret: "${IM_CONFIG_API_SECRET}" + timeWindow: "300s" diff --git a/server/storage/data/generate/im_dead_letter_menu.sql b/server/storage/data/generate/im_dead_letter_menu.sql new file mode 100644 index 0000000..af0d8dd --- /dev/null +++ b/server/storage/data/generate/im_dead_letter_menu.sql @@ -0,0 +1,35 @@ +SET NAMES utf8mb4; + +SET @now = NOW(); + +SELECT @imRootId := `id` FROM `hg_admin_menu` WHERE `name` = 'imManager' LIMIT 1; + +INSERT INTO `hg_admin_menu` (`id`, `pid`, `title`, `name`, `path`, `icon`, `type`, `redirect`, `permissions`, `permission_name`, `component`, `always_show`, `active_menu`, `is_root`, `is_frame`, `frame_src`, `keep_alive`, `hidden`, `affix`, `level`, `tree`, `sort`, `remark`, `status`, `created_at`, `updated_at`) +SELECT NULL, '0', 'IM管理', 'imManager', '/im', '', '1', '/im/tenant/index', '', '', 'LAYOUT', '1', '', '1', '0', '', '0', '0', '0', '1', '', '200', '', '1', @now, @now +WHERE @imRootId IS NULL; + +SELECT @imRootId := `id` FROM `hg_admin_menu` WHERE `name` = 'imManager' LIMIT 1; + +SELECT @deadLetterId := `id` FROM `hg_admin_menu` WHERE `name` = 'imDeadLetterIndex' LIMIT 1; + +INSERT INTO `hg_admin_menu` (`id`, `pid`, `title`, `name`, `path`, `icon`, `type`, `redirect`, `permissions`, `permission_name`, `component`, `always_show`, `active_menu`, `is_root`, `is_frame`, `frame_src`, `keep_alive`, `hidden`, `affix`, `level`, `tree`, `sort`, `remark`, `status`, `created_at`, `updated_at`) +SELECT NULL, @imRootId, 'Kafka死信', 'imDeadLetterIndex', 'dead-letter', '', '2', '', '/imDeadLetter/list', '', '/imDeadLetter/index', '1', 'imDeadLetterIndex', '0', '0', '', '0', '0', '0', '2', CONCAT('tr_', @imRootId, ' '), '91', '', '1', @now, @now +WHERE @deadLetterId IS NULL; + +SELECT @deadLetterId := `id` FROM `hg_admin_menu` WHERE `name` = 'imDeadLetterIndex' LIMIT 1; + +INSERT INTO `hg_admin_menu` (`id`, `pid`, `title`, `name`, `path`, `icon`, `type`, `redirect`, `permissions`, `permission_name`, `component`, `always_show`, `active_menu`, `is_root`, `is_frame`, `frame_src`, `keep_alive`, `hidden`, `affix`, `level`, `tree`, `sort`, `remark`, `status`, `created_at`, `updated_at`) +SELECT NULL, @deadLetterId, '死信详情', 'imDeadLetterView', '', '', '3', '', '/imDeadLetter/view', '', '', '1', '', '0', '0', '', '0', '1', '0', '3', CONCAT('tr_', @imRootId, ' tr_', @deadLetterId, ' '), '10', '', '1', @now, @now +WHERE NOT EXISTS (SELECT 1 FROM `hg_admin_menu` WHERE `name` = 'imDeadLetterView'); + +INSERT INTO `hg_admin_menu` (`id`, `pid`, `title`, `name`, `path`, `icon`, `type`, `redirect`, `permissions`, `permission_name`, `component`, `always_show`, `active_menu`, `is_root`, `is_frame`, `frame_src`, `keep_alive`, `hidden`, `affix`, `level`, `tree`, `sort`, `remark`, `status`, `created_at`, `updated_at`) +SELECT NULL, @deadLetterId, '重放死信', 'imDeadLetterReplay', '', '', '3', '', '/imDeadLetter/replay', '', '', '1', '', '0', '0', '', '0', '1', '0', '3', CONCAT('tr_', @imRootId, ' tr_', @deadLetterId, ' '), '20', '', '1', @now, @now +WHERE NOT EXISTS (SELECT 1 FROM `hg_admin_menu` WHERE `name` = 'imDeadLetterReplay'); + +INSERT INTO `hg_admin_menu` (`id`, `pid`, `title`, `name`, `path`, `icon`, `type`, `redirect`, `permissions`, `permission_name`, `component`, `always_show`, `active_menu`, `is_root`, `is_frame`, `frame_src`, `keep_alive`, `hidden`, `affix`, `level`, `tree`, `sort`, `remark`, `status`, `created_at`, `updated_at`) +SELECT NULL, @deadLetterId, '忽略死信', 'imDeadLetterIgnore', '', '', '3', '', '/imDeadLetter/ignore', '', '', '1', '', '0', '0', '', '0', '1', '0', '3', CONCAT('tr_', @imRootId, ' tr_', @deadLetterId, ' '), '30', '', '1', @now, @now +WHERE NOT EXISTS (SELECT 1 FROM `hg_admin_menu` WHERE `name` = 'imDeadLetterIgnore'); + +INSERT INTO `hg_admin_menu` (`id`, `pid`, `title`, `name`, `path`, `icon`, `type`, `redirect`, `permissions`, `permission_name`, `component`, `always_show`, `active_menu`, `is_root`, `is_frame`, `frame_src`, `keep_alive`, `hidden`, `affix`, `level`, `tree`, `sort`, `remark`, `status`, `created_at`, `updated_at`) +SELECT NULL, @deadLetterId, '标记已处理', 'imDeadLetterResolve', '', '', '3', '', '/imDeadLetter/resolve', '', '', '1', '', '0', '0', '', '0', '1', '0', '3', CONCAT('tr_', @imRootId, ' tr_', @deadLetterId, ' '), '40', '', '1', @now, @now +WHERE NOT EXISTS (SELECT 1 FROM `hg_admin_menu` WHERE `name` = 'imDeadLetterResolve'); diff --git a/server/storage/data/generate/im_group_menu.sql b/server/storage/data/generate/im_group_menu.sql index 7092d8b..5424a19 100644 --- a/server/storage/data/generate/im_group_menu.sql +++ b/server/storage/data/generate/im_group_menu.sql @@ -1,3 +1,5 @@ +SET NAMES utf8mb4; + SET @now = NOW(); SELECT @imRootId := `id` FROM `hg_admin_menu` WHERE `name` = 'imManager' LIMIT 1; @@ -36,50 +38,10 @@ INSERT INTO `hg_admin_menu` (`id`, `pid`, `title`, `name`, `path`, `icon`, `type SELECT NULL, @groupListId, '群治理配置', 'imGroupGovernance', '', '', '3', '', '/imGroup/governance', '', '', '1', '', '0', '0', '', '0', '1', '0', '4', CONCAT('tr_', @imRootId, ' tr_', @groupDirId, ' tr_', @groupListId, ' '), '30', '', '1', @now, @now WHERE NOT EXISTS (SELECT 1 FROM `hg_admin_menu` WHERE `name` = 'imGroupGovernance'); -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, ' '), '40', '', '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, '解散群', 'imGroupDissolve', '', '', '3', '', '/imGroup/dissolve', '', '', '1', '', '0', '0', '', '0', '1', '0', '4', CONCAT('tr_', @imRootId, ' tr_', @groupDirId, ' tr_', @groupListId, ' '), '50', '', '1', @now, @now -WHERE NOT EXISTS (SELECT 1 FROM `hg_admin_menu` WHERE `name` = 'imGroupDissolve'); - -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, '禁言群成员', 'imGroupMute', '', '', '3', '', '/imGroup/mute', '', '', '1', '', '0', '0', '', '0', '1', '0', '4', CONCAT('tr_', @imRootId, ' tr_', @groupDirId, ' tr_', @groupListId, ' '), '60', '', '1', @now, @now -WHERE NOT EXISTS (SELECT 1 FROM `hg_admin_menu` WHERE `name` = 'imGroupMute'); - -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, '解除成员禁言', 'imGroupUnmute', '', '', '3', '', '/imGroup/unmute', '', '', '1', '', '0', '0', '', '0', '1', '0', '4', CONCAT('tr_', @imRootId, ' tr_', @groupDirId, ' tr_', @groupListId, ' '), '70', '', '1', @now, @now -WHERE NOT EXISTS (SELECT 1 FROM `hg_admin_menu` WHERE `name` = 'imGroupUnmute'); - -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, '通过入群申请', 'imGroupJoinRequestApprove', '', '', '3', '', '/imGroup/joinRequestApprove', '', '', '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` = 'imGroupJoinRequestApprove'); - -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, '拒绝入群申请', 'imGroupJoinRequestReject', '', '', '3', '', '/imGroup/joinRequestReject', '', '', '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` = 'imGroupJoinRequestReject'); - -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, '更新群成员角色', 'imGroupRoleUpdate', '', '', '3', '', '/imGroup/roleUpdate', '', '', '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` = 'imGroupRoleUpdate'); - -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, ' '), '110', '', '1', @now, @now -WHERE NOT EXISTS (SELECT 1 FROM `hg_admin_menu` WHERE `name` = 'imGroupOwnerTransfer'); - 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, '入群申请列表', 'imGroupJoinRequestList', '', '', '3', '', '/imGroup/joinRequestList', '', '', '1', '', '0', '0', '', '0', '1', '0', '4', CONCAT('tr_', @imRootId, ' tr_', @groupDirId, ' tr_', @groupListId, ' '), '40', '', '1', @now, @now WHERE NOT EXISTS (SELECT 1 FROM `hg_admin_menu` WHERE `name` = 'imGroupJoinRequestList'); -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, '群文件列表', 'imGroupFileList', '', '', '3', '', '/imGroup/fileList', '', '', '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` = 'imGroupFileList'); - -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`, `keep_alive`, `hidden`, `affix`, `level`, `tree`, `sort`, `remark`, `status`, `created_at`, `updated_at`) -SELECT NULL, @groupListId, '删除群文件', 'imGroupFileDelete', '', '', '3', '', '/imGroup/fileDelete', '', '', '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` = 'imGroupFileDelete'); - 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, '群邀请列表', 'imGroupInvitationList', '', '', '3', '', '/imGroup/invitationList', '', '', '1', '', '0', '0', '', '0', '1', '0', '4', CONCAT('tr_', @imRootId, ' tr_', @groupDirId, ' tr_', @groupListId, ' '), '50', '', '1', @now, @now WHERE NOT EXISTS (SELECT 1 FROM `hg_admin_menu` WHERE `name` = 'imGroupInvitationList'); @@ -95,3 +57,35 @@ WHERE NOT EXISTS (SELECT 1 FROM `hg_admin_menu` WHERE `name` = 'imGroupMuteList' INSERT INTO `hg_admin_menu` (`id`, `pid`, `title`, `name`, `path`, `icon`, `type`, `redirect`, `permissions`, `permission_name`, `component`, `always_show`, `active_menu`, `is_root`, `is_frame`, `frame_src`, `keep_alive`, `hidden`, `affix`, `level`, `tree`, `sort`, `remark`, `status`, `created_at`, `updated_at`) SELECT NULL, @groupListId, '群管理日志', 'imGroupAdminLogList', '', '', '3', '', '/imGroup/adminLogList', '', '', '1', '', '0', '0', '', '0', '1', '0', '4', CONCAT('tr_', @imRootId, ' tr_', @groupDirId, ' tr_', @groupListId, ' '), '80', '', '1', @now, @now WHERE NOT EXISTS (SELECT 1 FROM `hg_admin_menu` WHERE `name` = 'imGroupAdminLogList'); + +INSERT INTO `hg_admin_menu` (`id`, `pid`, `title`, `name`, `path`, `icon`, `type`, `redirect`, `permissions`, `permission_name`, `component`, `always_show`, `active_menu`, `is_root`, `is_frame`, `frame_src`, `keep_alive`, `hidden`, `affix`, `level`, `tree`, `sort`, `remark`, `status`, `created_at`, `updated_at`) +SELECT NULL, @groupListId, '修改群资料', 'imGroupProfileUpdate', '', '', '3', '', '/imGroup/profileUpdate', '', '', '1', '', '0', '0', '', '0', '1', '0', '4', CONCAT('tr_', @imRootId, ' tr_', @groupDirId, ' tr_', @groupListId, ' '), '90', '', '1', @now, @now +WHERE NOT EXISTS (SELECT 1 FROM `hg_admin_menu` WHERE `name` = 'imGroupProfileUpdate'); + +INSERT INTO `hg_admin_menu` (`id`, `pid`, `title`, `name`, `path`, `icon`, `type`, `redirect`, `permissions`, `permission_name`, `component`, `always_show`, `active_menu`, `is_root`, `is_frame`, `frame_src`, `keep_alive`, `hidden`, `affix`, `level`, `tree`, `sort`, `remark`, `status`, `created_at`, `updated_at`) +SELECT NULL, @groupListId, '修改群治理配置', 'imGroupGovernanceUpdate', '', '', '3', '', '/imGroup/governanceUpdate', '', '', '1', '', '0', '0', '', '0', '1', '0', '4', CONCAT('tr_', @imRootId, ' tr_', @groupDirId, ' tr_', @groupListId, ' '), '100', '', '1', @now, @now +WHERE NOT EXISTS (SELECT 1 FROM `hg_admin_menu` WHERE `name` = 'imGroupGovernanceUpdate'); + +INSERT INTO `hg_admin_menu` (`id`, `pid`, `title`, `name`, `path`, `icon`, `type`, `redirect`, `permissions`, `permission_name`, `component`, `always_show`, `active_menu`, `is_root`, `is_frame`, `frame_src`, `keep_alive`, `hidden`, `affix`, `level`, `tree`, `sort`, `remark`, `status`, `created_at`, `updated_at`) +SELECT NULL, @groupListId, '设置群管理员', 'imGroupAdminSet', '', '', '3', '', '/imGroup/adminSet', '', '', '1', '', '0', '0', '', '0', '1', '0', '4', CONCAT('tr_', @imRootId, ' tr_', @groupDirId, ' tr_', @groupListId, ' '), '110', '', '1', @now, @now +WHERE NOT EXISTS (SELECT 1 FROM `hg_admin_menu` WHERE `name` = 'imGroupAdminSet'); + +INSERT INTO `hg_admin_menu` (`id`, `pid`, `title`, `name`, `path`, `icon`, `type`, `redirect`, `permissions`, `permission_name`, `component`, `always_show`, `active_menu`, `is_root`, `is_frame`, `frame_src`, `keep_alive`, `hidden`, `affix`, `level`, `tree`, `sort`, `remark`, `status`, `created_at`, `updated_at`) +SELECT NULL, @groupListId, '删除群管理员', 'imGroupAdminRemove', '', '', '3', '', '/imGroup/adminRemove', '', '', '1', '', '0', '0', '', '0', '1', '0', '4', CONCAT('tr_', @imRootId, ' tr_', @groupDirId, ' tr_', @groupListId, ' '), '120', '', '1', @now, @now +WHERE NOT EXISTS (SELECT 1 FROM `hg_admin_menu` WHERE `name` = 'imGroupAdminRemove'); + +INSERT INTO `hg_admin_menu` (`id`, `pid`, `title`, `name`, `path`, `icon`, `type`, `redirect`, `permissions`, `permission_name`, `component`, `always_show`, `active_menu`, `is_root`, `is_frame`, `frame_src`, `keep_alive`, `hidden`, `affix`, `level`, `tree`, `sort`, `remark`, `status`, `created_at`, `updated_at`) +SELECT NULL, @groupListId, '禁言群成员', 'imGroupMemberMute', '', '', '3', '', '/imGroup/memberMute', '', '', '1', '', '0', '0', '', '0', '1', '0', '4', CONCAT('tr_', @imRootId, ' tr_', @groupDirId, ' tr_', @groupListId, ' '), '130', '', '1', @now, @now +WHERE NOT EXISTS (SELECT 1 FROM `hg_admin_menu` WHERE `name` = 'imGroupMemberMute'); + +INSERT INTO `hg_admin_menu` (`id`, `pid`, `title`, `name`, `path`, `icon`, `type`, `redirect`, `permissions`, `permission_name`, `component`, `always_show`, `active_menu`, `is_root`, `is_frame`, `frame_src`, `keep_alive`, `hidden`, `affix`, `level`, `tree`, `sort`, `remark`, `status`, `created_at`, `updated_at`) +SELECT NULL, @groupListId, '解除群成员禁言', 'imGroupMemberUnmute', '', '', '3', '', '/imGroup/memberUnmute', '', '', '1', '', '0', '0', '', '0', '1', '0', '4', CONCAT('tr_', @imRootId, ' tr_', @groupDirId, ' tr_', @groupListId, ' '), '140', '', '1', @now, @now +WHERE NOT EXISTS (SELECT 1 FROM `hg_admin_menu` WHERE `name` = 'imGroupMemberUnmute'); + +INSERT INTO `hg_admin_menu` (`id`, `pid`, `title`, `name`, `path`, `icon`, `type`, `redirect`, `permissions`, `permission_name`, `component`, `always_show`, `active_menu`, `is_root`, `is_frame`, `frame_src`, `keep_alive`, `hidden`, `affix`, `level`, `tree`, `sort`, `remark`, `status`, `created_at`, `updated_at`) +SELECT NULL, @groupListId, '移出群成员', 'imGroupMemberRemove', '', '', '3', '', '/imGroup/memberRemove', '', '', '1', '', '0', '0', '', '0', '1', '0', '4', CONCAT('tr_', @imRootId, ' tr_', @groupDirId, ' tr_', @groupListId, ' '), '150', '', '1', @now, @now +WHERE NOT EXISTS (SELECT 1 FROM `hg_admin_menu` WHERE `name` = 'imGroupMemberRemove'); + +INSERT INTO `hg_admin_menu` (`id`, `pid`, `title`, `name`, `path`, `icon`, `type`, `redirect`, `permissions`, `permission_name`, `component`, `always_show`, `active_menu`, `is_root`, `is_frame`, `frame_src`, `keep_alive`, `hidden`, `affix`, `level`, `tree`, `sort`, `remark`, `status`, `created_at`, `updated_at`) +SELECT NULL, @groupListId, '转让群主', 'imGroupOwnerTransfer', '', '', '3', '', '/imGroup/ownerTransfer', '', '', '1', '', '0', '0', '', '0', '1', '0', '4', CONCAT('tr_', @imRootId, ' tr_', @groupDirId, ' tr_', @groupListId, ' '), '160', '', '1', @now, @now +WHERE NOT EXISTS (SELECT 1 FROM `hg_admin_menu` WHERE `name` = 'imGroupOwnerTransfer'); diff --git a/server/storage/data/generate/im_kafka_admin_tables.sql b/server/storage/data/generate/im_kafka_admin_tables.sql new file mode 100644 index 0000000..61376d2 --- /dev/null +++ b/server/storage/data/generate/im_kafka_admin_tables.sql @@ -0,0 +1,57 @@ +SET NAMES utf8mb4; + +CREATE TABLE IF NOT EXISTS `im_admin_commands` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `command_id` varchar(96) NOT NULL COMMENT '命令ID', + `tenant_id` bigint NOT NULL DEFAULT 0 COMMENT 'IM租户ID', + `tenant_code` varchar(64) NOT NULL DEFAULT '' COMMENT 'IM租户编码', + `event_id` varchar(96) NOT NULL COMMENT 'Kafka事件ID', + `event_type` varchar(96) NOT NULL DEFAULT '' COMMENT '事件类型', + `biz_type` varchar(64) NOT NULL DEFAULT '' COMMENT '业务类型', + `biz_id` varchar(128) NOT NULL DEFAULT '' COMMENT '业务ID', + `idempotency_key` varchar(128) NOT NULL DEFAULT '' COMMENT '幂等键', + `status` varchar(24) NOT NULL DEFAULT 'pending' COMMENT '状态', + `payload` json NOT NULL COMMENT '命令Payload', + `envelope` json NOT NULL COMMENT 'Kafka Envelope', + `result` json DEFAULT NULL COMMENT '执行结果', + `reason` varchar(96) NOT NULL DEFAULT '' COMMENT '稳定错误码', + `message` varchar(1000) NOT NULL DEFAULT '' COMMENT '结果消息', + `sent_at` datetime(3) DEFAULT NULL COMMENT '投递时间', + `completed_at` datetime(3) DEFAULT NULL COMMENT '完成时间', + `created_by` bigint NOT NULL DEFAULT 0 COMMENT '创建人', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_im_admin_commands_command` (`command_id`), + UNIQUE KEY `uk_im_admin_commands_event` (`event_id`), + KEY `idx_im_admin_commands_tenant_status` (`tenant_id`, `status`, `id`), + KEY `idx_im_admin_commands_biz` (`biz_type`, `biz_id`), + KEY `idx_im_admin_commands_type` (`event_type`, `id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='IM后台Kafka命令'; + +CREATE TABLE IF NOT EXISTS `im_admin_outbox` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `event_id` varchar(96) NOT NULL COMMENT 'Kafka事件ID', + `command_id` varchar(96) NOT NULL DEFAULT '' COMMENT '命令ID', + `topic` varchar(128) NOT NULL COMMENT 'Kafka Topic', + `event_type` varchar(96) NOT NULL DEFAULT '' COMMENT '事件类型', + `biz_type` varchar(64) NOT NULL DEFAULT '' COMMENT '业务类型', + `biz_id` varchar(128) NOT NULL DEFAULT '' COMMENT '业务ID', + `key_value` varchar(256) NOT NULL DEFAULT '' COMMENT 'Kafka Key', + `envelope` json NOT NULL COMMENT 'Kafka Envelope', + `status` varchar(24) NOT NULL DEFAULT 'pending' COMMENT '状态', + `attempts` int NOT NULL DEFAULT 0 COMMENT '投递次数', + `next_retry_at` datetime(3) DEFAULT NULL COMMENT '下次重试时间', + `sent_at` datetime(3) DEFAULT NULL COMMENT '投递时间', + `last_reason` varchar(96) NOT NULL DEFAULT '' COMMENT '最近失败码', + `last_error` varchar(1000) NOT NULL DEFAULT '' COMMENT '最近失败', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_im_admin_outbox_event` (`event_id`), + KEY `idx_im_admin_outbox_pending` (`status`, `next_retry_at`, `id`), + KEY `idx_im_admin_outbox_command` (`command_id`), + KEY `idx_im_admin_outbox_biz` (`biz_type`, `biz_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='IM后台Kafka Outbox'; + +UPDATE `im_admin_commands` SET `status` = 'running' WHERE `status` = 'sent'; diff --git a/server/storage/data/generate/im_kafka_audit_menu.sql b/server/storage/data/generate/im_kafka_audit_menu.sql new file mode 100644 index 0000000..609dcf1 --- /dev/null +++ b/server/storage/data/generate/im_kafka_audit_menu.sql @@ -0,0 +1,23 @@ +SET NAMES utf8mb4; + +SET @now = NOW(); + +SELECT @imRootId := `id` FROM `hg_admin_menu` WHERE `name` = 'imManager' LIMIT 1; + +INSERT INTO `hg_admin_menu` (`id`, `pid`, `title`, `name`, `path`, `icon`, `type`, `redirect`, `permissions`, `permission_name`, `component`, `always_show`, `active_menu`, `is_root`, `is_frame`, `frame_src`, `keep_alive`, `hidden`, `affix`, `level`, `tree`, `sort`, `remark`, `status`, `created_at`, `updated_at`) +SELECT NULL, '0', 'IM管理', 'imManager', '/im', '', '1', '/im/tenant/index', '', '', 'LAYOUT', '1', '', '1', '0', '', '0', '0', '0', '1', '', '200', '', '1', @now, @now +WHERE @imRootId IS NULL; + +SELECT @imRootId := `id` FROM `hg_admin_menu` WHERE `name` = 'imManager' LIMIT 1; + +SELECT @auditId := `id` FROM `hg_admin_menu` WHERE `name` = 'imKafkaAuditIndex' LIMIT 1; + +INSERT INTO `hg_admin_menu` (`id`, `pid`, `title`, `name`, `path`, `icon`, `type`, `redirect`, `permissions`, `permission_name`, `component`, `always_show`, `active_menu`, `is_root`, `is_frame`, `frame_src`, `keep_alive`, `hidden`, `affix`, `level`, `tree`, `sort`, `remark`, `status`, `created_at`, `updated_at`) +SELECT NULL, @imRootId, 'Kafka审计事件', 'imKafkaAuditIndex', 'kafka-audit', '', '2', '', '/imKafkaAudit/list', '', '/imKafkaAudit/index', '1', 'imKafkaAuditIndex', '0', '0', '', '0', '0', '0', '2', CONCAT('tr_', @imRootId, ' '), '95', '', '1', @now, @now +WHERE @auditId IS NULL; + +SELECT @auditId := `id` FROM `hg_admin_menu` WHERE `name` = 'imKafkaAuditIndex' LIMIT 1; + +INSERT INTO `hg_admin_menu` (`id`, `pid`, `title`, `name`, `path`, `icon`, `type`, `redirect`, `permissions`, `permission_name`, `component`, `always_show`, `active_menu`, `is_root`, `is_frame`, `frame_src`, `keep_alive`, `hidden`, `affix`, `level`, `tree`, `sort`, `remark`, `status`, `created_at`, `updated_at`) +SELECT NULL, @auditId, '审计事件详情', 'imKafkaAuditView', '', '', '3', '', '/imKafkaAudit/view', '', '', '1', '', '0', '0', '', '0', '1', '0', '3', CONCAT('tr_', @imRootId, ' tr_', @auditId, ' '), '10', '', '1', @now, @now +WHERE NOT EXISTS (SELECT 1 FROM `hg_admin_menu` WHERE `name` = 'imKafkaAuditView'); diff --git a/server/storage/data/generate/im_kafka_command_menu.sql b/server/storage/data/generate/im_kafka_command_menu.sql new file mode 100644 index 0000000..c9a194a --- /dev/null +++ b/server/storage/data/generate/im_kafka_command_menu.sql @@ -0,0 +1,31 @@ +SET NAMES utf8mb4; + +SET @now = NOW(); + +SELECT @imRootId := `id` FROM `hg_admin_menu` WHERE `name` = 'imManager' LIMIT 1; + +INSERT INTO `hg_admin_menu` (`id`, `pid`, `title`, `name`, `path`, `icon`, `type`, `redirect`, `permissions`, `permission_name`, `component`, `always_show`, `active_menu`, `is_root`, `is_frame`, `frame_src`, `keep_alive`, `hidden`, `affix`, `level`, `tree`, `sort`, `remark`, `status`, `created_at`, `updated_at`) +SELECT NULL, '0', 'IM管理', 'imManager', '/im', '', '1', '/im/tenant/index', '', '', 'LAYOUT', '1', '', '1', '0', '', '0', '0', '0', '1', '', '200', '', '1', @now, @now +WHERE @imRootId IS NULL; + +SELECT @imRootId := `id` FROM `hg_admin_menu` WHERE `name` = 'imManager' LIMIT 1; + +SELECT @commandId := `id` FROM `hg_admin_menu` WHERE `name` = 'imKafkaCommandIndex' LIMIT 1; + +INSERT INTO `hg_admin_menu` (`id`, `pid`, `title`, `name`, `path`, `icon`, `type`, `redirect`, `permissions`, `permission_name`, `component`, `always_show`, `active_menu`, `is_root`, `is_frame`, `frame_src`, `keep_alive`, `hidden`, `affix`, `level`, `tree`, `sort`, `remark`, `status`, `created_at`, `updated_at`) +SELECT NULL, @imRootId, 'Kafka命令', 'imKafkaCommandIndex', 'kafka-command', '', '2', '', '/imKafkaCommand/list', '', '/imKafkaCommand/index', '1', 'imKafkaCommandIndex', '0', '0', '', '0', '0', '0', '2', CONCAT('tr_', @imRootId, ' '), '90', '', '1', @now, @now +WHERE @commandId IS NULL; + +SELECT @commandId := `id` FROM `hg_admin_menu` WHERE `name` = 'imKafkaCommandIndex' LIMIT 1; + +INSERT INTO `hg_admin_menu` (`id`, `pid`, `title`, `name`, `path`, `icon`, `type`, `redirect`, `permissions`, `permission_name`, `component`, `always_show`, `active_menu`, `is_root`, `is_frame`, `frame_src`, `keep_alive`, `hidden`, `affix`, `level`, `tree`, `sort`, `remark`, `status`, `created_at`, `updated_at`) +SELECT NULL, @commandId, '命令详情', 'imKafkaCommandView', '', '', '3', '', '/imKafkaCommand/view', '', '', '1', '', '0', '0', '', '0', '1', '0', '3', CONCAT('tr_', @imRootId, ' tr_', @commandId, ' '), '10', '', '1', @now, @now +WHERE NOT EXISTS (SELECT 1 FROM `hg_admin_menu` WHERE `name` = 'imKafkaCommandView'); + +INSERT INTO `hg_admin_menu` (`id`, `pid`, `title`, `name`, `path`, `icon`, `type`, `redirect`, `permissions`, `permission_name`, `component`, `always_show`, `active_menu`, `is_root`, `is_frame`, `frame_src`, `keep_alive`, `hidden`, `affix`, `level`, `tree`, `sort`, `remark`, `status`, `created_at`, `updated_at`) +SELECT NULL, @commandId, '发送缓存失效测试', 'imKafkaCommandCacheInvalidate', '', '', '3', '', '/imKafkaCommand/cacheInvalidate', '', '', '1', '', '0', '0', '', '0', '1', '0', '3', CONCAT('tr_', @imRootId, ' tr_', @commandId, ' '), '20', '', '1', @now, @now +WHERE NOT EXISTS (SELECT 1 FROM `hg_admin_menu` WHERE `name` = 'imKafkaCommandCacheInvalidate'); + +INSERT INTO `hg_admin_menu` (`id`, `pid`, `title`, `name`, `path`, `icon`, `type`, `redirect`, `permissions`, `permission_name`, `component`, `always_show`, `active_menu`, `is_root`, `is_frame`, `frame_src`, `keep_alive`, `hidden`, `affix`, `level`, `tree`, `sort`, `remark`, `status`, `created_at`, `updated_at`) +SELECT NULL, @commandId, '发送工作台测试事件', 'imKafkaCommandWorkbenchEvent', '', '', '3', '', '/imKafkaCommand/workbenchEvent', '', '', '1', '', '0', '0', '', '0', '1', '0', '3', CONCAT('tr_', @imRootId, ' tr_', @commandId, ' '), '30', '', '1', @now, @now +WHERE NOT EXISTS (SELECT 1 FROM `hg_admin_menu` WHERE `name` = 'imKafkaCommandWorkbenchEvent'); diff --git a/server/storage/data/generate/im_kafka_event_tables.sql b/server/storage/data/generate/im_kafka_event_tables.sql new file mode 100644 index 0000000..9a7d692 --- /dev/null +++ b/server/storage/data/generate/im_kafka_event_tables.sql @@ -0,0 +1,50 @@ +SET NAMES utf8mb4; + +CREATE TABLE IF NOT EXISTS `im_kafka_dead_letter_events` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `event_id` varchar(96) NOT NULL COMMENT '事件ID', + `parent_event_id` varchar(96) NOT NULL DEFAULT '' COMMENT '父事件ID', + `tenant_code` varchar(64) NOT NULL DEFAULT '' COMMENT '租户编码', + `event_type` varchar(96) NOT NULL DEFAULT '' COMMENT '事件类型', + `biz_type` varchar(64) NOT NULL DEFAULT '' COMMENT '业务类型', + `biz_id` varchar(128) NOT NULL DEFAULT '' COMMENT '业务ID', + `source_service` varchar(64) NOT NULL DEFAULT '' COMMENT '来源服务', + `target_service` varchar(64) NOT NULL DEFAULT '' COMMENT '目标服务', + `dead_id` varchar(128) NOT NULL DEFAULT '' COMMENT '死信ID', + `reason` varchar(128) NOT NULL DEFAULT '' COMMENT '原因码', + `message` varchar(1000) NOT NULL DEFAULT '' COMMENT '错误信息', + `payload` json DEFAULT NULL COMMENT 'Payload', + `envelope` json NOT NULL COMMENT 'Envelope', + `received_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '接收时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_im_kafka_dead_letter_events_event` (`event_id`), + KEY `idx_im_kafka_dead_letter_events_tenant` (`tenant_code`, `id`), + KEY `idx_im_kafka_dead_letter_events_dead` (`dead_id`), + KEY `idx_im_kafka_dead_letter_events_biz` (`biz_type`, `biz_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='IM Kafka死信事件'; + +CREATE TABLE IF NOT EXISTS `im_audit_events` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `event_id` varchar(96) NOT NULL COMMENT '事件ID', + `tenant_code` varchar(64) NOT NULL DEFAULT '' COMMENT '租户编码', + `event_type` varchar(96) NOT NULL DEFAULT '' COMMENT '事件类型', + `biz_type` varchar(64) NOT NULL DEFAULT '' COMMENT '业务类型', + `biz_id` varchar(128) NOT NULL DEFAULT '' COMMENT '业务ID', + `actor_type` varchar(32) NOT NULL DEFAULT '' COMMENT '操作者类型', + `actor_id` varchar(64) NOT NULL DEFAULT '' COMMENT '操作者ID', + `action` varchar(128) NOT NULL DEFAULT '' COMMENT '动作', + `resource` varchar(64) NOT NULL DEFAULT '' COMMENT '资源', + `resource_id` varchar(128) NOT NULL DEFAULT '' COMMENT '资源ID', + `result` varchar(32) NOT NULL DEFAULT '' COMMENT '结果', + `reason` varchar(128) NOT NULL DEFAULT '' COMMENT '原因', + `payload` json DEFAULT NULL COMMENT 'Payload', + `envelope` json NOT NULL COMMENT 'Envelope', + `occurred_at` datetime(3) DEFAULT NULL COMMENT '发生时间', + `received_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '接收时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_im_audit_events_event` (`event_id`), + KEY `idx_im_audit_events_tenant` (`tenant_code`, `id`), + KEY `idx_im_audit_events_actor` (`actor_type`, `actor_id`), + KEY `idx_im_audit_events_resource` (`resource`, `resource_id`), + KEY `idx_im_audit_events_action` (`action`, `id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='IM Kafka审计事件'; diff --git a/server/storage/data/generate/im_user_menu.sql b/server/storage/data/generate/im_user_menu.sql index 4bf5e46..d59c8b7 100644 --- a/server/storage/data/generate/im_user_menu.sql +++ b/server/storage/data/generate/im_user_menu.sql @@ -1,3 +1,5 @@ +SET NAMES utf8mb4; + SET @now = NOW(); SELECT @imRootId := `id` FROM `hg_admin_menu` WHERE `name` = 'imManager' LIMIT 1; @@ -29,17 +31,33 @@ SELECT NULL, @userListId, '用户详情', 'imUserView', '', '', '3', '', '/imUse 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, '重置用户密码', 'imUserResetPassword', '', '', '3', '', '/imUser/resetPassword', '', '', '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` = 'imUserResetPassword'); +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, '用户设备列表', 'imUserDevices', '', '', '3', '', '/imUser/devices', '', '', '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` = 'imUserDevices'); +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, '撤销用户会话', 'imUserRevokeSessions', '', '', '3', '', '/imUser/revokeSessions', '', '', '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` = 'imUserRevokeSessions'); +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, '更新用户状态', 'imUserStatus', '', '', '3', '', '/imUser/status', '', '', '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` = 'imUserStatus'); +SELECT NULL, @userListId, '解冻用户', 'imUserUnfreeze', '', '', '3', '', '/imUser/unfreeze', '', '', '1', '', '0', '0', '', '0', '1', '0', '4', CONCAT('tr_', @imRootId, ' tr_', @userDirId, ' tr_', @userListId, ' '), '50', '', '1', @now, @now +WHERE NOT EXISTS (SELECT 1 FROM `hg_admin_menu` WHERE `name` = 'imUserUnfreeze'); + +INSERT INTO `hg_admin_menu` (`id`, `pid`, `title`, `name`, `path`, `icon`, `type`, `redirect`, `permissions`, `permission_name`, `component`, `always_show`, `active_menu`, `is_root`, `is_frame`, `frame_src`, `keep_alive`, `hidden`, `affix`, `level`, `tree`, `sort`, `remark`, `status`, `created_at`, `updated_at`) +SELECT NULL, @userListId, '踢出设备', 'imUserDeviceKick', '', '', '3', '', '/imUser/deviceKick', '', '', '1', '', '0', '0', '', '0', '1', '0', '4', CONCAT('tr_', @imRootId, ' tr_', @userDirId, ' tr_', @userListId, ' '), '60', '', '1', @now, @now +WHERE NOT EXISTS (SELECT 1 FROM `hg_admin_menu` WHERE `name` = 'imUserDeviceKick'); + +INSERT INTO `hg_admin_menu` (`id`, `pid`, `title`, `name`, `path`, `icon`, `type`, `redirect`, `permissions`, `permission_name`, `component`, `always_show`, `active_menu`, `is_root`, `is_frame`, `frame_src`, `keep_alive`, `hidden`, `affix`, `level`, `tree`, `sort`, `remark`, `status`, `created_at`, `updated_at`) +SELECT NULL, @userListId, '踢出全部会话', 'imUserLogoutAll', '', '', '3', '', '/imUser/logoutAll', '', '', '1', '', '0', '0', '', '0', '1', '0', '4', CONCAT('tr_', @imRootId, ' tr_', @userDirId, ' tr_', @userListId, ' '), '70', '', '1', @now, @now +WHERE NOT EXISTS (SELECT 1 FROM `hg_admin_menu` WHERE `name` = 'imUserLogoutAll'); + +INSERT INTO `hg_admin_menu` (`id`, `pid`, `title`, `name`, `path`, `icon`, `type`, `redirect`, `permissions`, `permission_name`, `component`, `always_show`, `active_menu`, `is_root`, `is_frame`, `frame_src`, `keep_alive`, `hidden`, `affix`, `level`, `tree`, `sort`, `remark`, `status`, `created_at`, `updated_at`) +SELECT NULL, @userListId, '注销账号', 'imUserAccountCancel', '', '', '3', '', '/imUser/accountCancel', '', '', '1', '', '0', '0', '', '0', '1', '0', '4', CONCAT('tr_', @imRootId, ' tr_', @userDirId, ' tr_', @userListId, ' '), '80', '', '1', @now, @now +WHERE NOT EXISTS (SELECT 1 FROM `hg_admin_menu` WHERE `name` = 'imUserAccountCancel'); + +INSERT INTO `hg_admin_menu` (`id`, `pid`, `title`, `name`, `path`, `icon`, `type`, `redirect`, `permissions`, `permission_name`, `component`, `always_show`, `active_menu`, `is_root`, `is_frame`, `frame_src`, `keep_alive`, `hidden`, `affix`, `level`, `tree`, `sort`, `remark`, `status`, `created_at`, `updated_at`) +SELECT NULL, @userListId, '删除账号', 'imUserAccountDelete', '', '', '3', '', '/imUser/accountDelete', '', '', '1', '', '0', '0', '', '0', '1', '0', '4', CONCAT('tr_', @imRootId, ' tr_', @userDirId, ' tr_', @userListId, ' '), '90', '', '1', @now, @now +WHERE NOT EXISTS (SELECT 1 FROM `hg_admin_menu` WHERE `name` = 'imUserAccountDelete'); diff --git a/web/src/api/imDeadLetter/index.ts b/web/src/api/imDeadLetter/index.ts new file mode 100644 index 0000000..4b8c73f --- /dev/null +++ b/web/src/api/imDeadLetter/index.ts @@ -0,0 +1,46 @@ +import { http } from '@/utils/http/axios'; + +// 获取IM Kafka死信列表 +export function List(params) { + return http.request({ + url: '/imDeadLetter/list', + method: 'get', + params, + }); +} + +// 获取IM Kafka死信详情 +export function View(params) { + return http.request({ + url: '/imDeadLetter/view', + method: 'GET', + params, + }); +} + +// 重放IM Kafka死信 +export function Replay(params) { + return http.request({ + url: '/imDeadLetter/replay', + method: 'POST', + params, + }); +} + +// 忽略IM Kafka死信 +export function Ignore(params) { + return http.request({ + url: '/imDeadLetter/ignore', + method: 'POST', + params, + }); +} + +// 标记IM Kafka死信已处理 +export function Resolve(params) { + return http.request({ + url: '/imDeadLetter/resolve', + method: 'POST', + params, + }); +} diff --git a/web/src/api/imKafkaAudit/index.ts b/web/src/api/imKafkaAudit/index.ts new file mode 100644 index 0000000..9531e90 --- /dev/null +++ b/web/src/api/imKafkaAudit/index.ts @@ -0,0 +1,17 @@ +import { http } from '@/utils/http/axios'; + +export function List(params) { + return http.request({ + url: '/imKafkaAudit/list', + method: 'get', + params, + }); +} + +export function View(params) { + return http.request({ + url: '/imKafkaAudit/view', + method: 'GET', + params, + }); +} diff --git a/web/src/api/imKafkaCommand/index.ts b/web/src/api/imKafkaCommand/index.ts new file mode 100644 index 0000000..ef07f9a --- /dev/null +++ b/web/src/api/imKafkaCommand/index.ts @@ -0,0 +1,37 @@ +import { http } from '@/utils/http/axios'; + +// 获取IM后台命令列表 +export function List(params) { + return http.request({ + url: '/imKafkaCommand/list', + method: 'get', + params, + }); +} + +// 获取IM后台命令详情 +export function View(params) { + return http.request({ + url: '/imKafkaCommand/view', + method: 'GET', + params, + }); +} + +// 发送IM缓存失效测试事件 +export function CacheInvalidate(params) { + return http.request({ + url: '/imKafkaCommand/cacheInvalidate', + method: 'POST', + params, + }); +} + +// 发送IM工作台测试事件 +export function WorkbenchEvent(params) { + return http.request({ + url: '/imKafkaCommand/workbenchEvent', + method: 'POST', + params, + }); +} diff --git a/web/src/api/imUser/index.ts b/web/src/api/imUser/index.ts index e874962..e2dbb93 100644 --- a/web/src/api/imUser/index.ts +++ b/web/src/api/imUser/index.ts @@ -53,3 +53,35 @@ export function Devices(params) { params, }); } + +export function Ban(params) { + return http.request({ url: '/imUser/ban', method: 'POST', params }); +} + +export function Unban(params) { + return http.request({ url: '/imUser/unban', method: 'POST', params }); +} + +export function Freeze(params) { + return http.request({ url: '/imUser/freeze', method: 'POST', params }); +} + +export function Unfreeze(params) { + return http.request({ url: '/imUser/unfreeze', method: 'POST', params }); +} + +export function DeviceKick(params) { + return http.request({ url: '/imUser/deviceKick', method: 'POST', params }); +} + +export function LogoutAll(params) { + return http.request({ url: '/imUser/logoutAll', method: 'POST', params }); +} + +export function AccountCancel(params) { + return http.request({ url: '/imUser/accountCancel', method: 'POST', params }); +} + +export function AccountDelete(params) { + return http.request({ url: '/imUser/accountDelete', method: 'POST', params }); +} diff --git a/web/src/views/imDeadLetter/index.vue b/web/src/views/imDeadLetter/index.vue new file mode 100644 index 0000000..d7775a1 --- /dev/null +++ b/web/src/views/imDeadLetter/index.vue @@ -0,0 +1,88 @@ + + + + + diff --git a/web/src/views/imDeadLetter/model.ts b/web/src/views/imDeadLetter/model.ts new file mode 100644 index 0000000..d4a2d09 --- /dev/null +++ b/web/src/views/imDeadLetter/model.ts @@ -0,0 +1,122 @@ +import { computed, ref } from 'vue'; +import { cloneDeep } from 'lodash-es'; +import { FormSchema } from '@/components/Form'; +import { useDictStore } from '@/store/modules/dict'; +import { List as TenantList } from '@/api/imTenant'; + +const dict = useDictStore(); + +export const tenantOptions = ref([]); + +export class State { + public id = 0; + public tenantId = 0; + public deadId = ''; + public eventId = ''; + public parentEventId = ''; + public sourceTopic = ''; + public consumerGroup = ''; + public eventType = ''; + public bizType = ''; + public bizId = ''; + public retryCount = 0; + public lastReason = ''; + public lastError = ''; + public status = ''; + public replayCount = 0; + public createdAt = ''; + public updatedAt = ''; + public envelope = ''; + public envelopeRaw = ''; + + constructor(state?: Partial) { + if (state) Object.assign(this, state); + } +} + +export function newState(state: State | Record | null): State { + if (state !== null) { + if (state instanceof State) return cloneDeep(state); + return new State(state); + } + return new State(); +} + +export const schemas = ref([ + { + field: 'tenantId', + component: 'NSelect', + label: '租户', + componentProps: { + clearable: true, + options: computed(() => tenantOptions.value), + placeholder: '请选择租户', + }, + }, + { + field: 'status', + component: 'NSelect', + label: '状态', + componentProps: { + clearable: true, + options: computed(() => dict.getOptions('ImKafkaDeadLetterStatusOptions')), + placeholder: '请选择状态', + }, + }, + { + field: 'eventType', + component: 'NInput', + label: '事件类型', + componentProps: { placeholder: '请输入事件类型' }, + }, + { + field: 'bizType', + component: 'NInput', + label: '业务类型', + componentProps: { placeholder: '请输入业务类型' }, + }, + { + field: 'bizId', + component: 'NInput', + label: '业务ID', + componentProps: { placeholder: '请输入业务ID' }, + }, +]); + +export const columns = [ + { title: 'ID', key: 'id', align: 'left', width: 90 }, + { title: '死信ID', key: 'deadId', align: 'left', width: 220 }, + { title: '事件ID', key: 'eventId', align: 'left', width: 220 }, + { title: '来源Topic', key: 'sourceTopic', align: 'left', width: 150 }, + { title: '消费组', key: 'consumerGroup', align: 'left', width: 160 }, + { title: '事件类型', key: 'eventType', align: 'left', width: 190 }, + { title: '业务类型', key: 'bizType', align: 'left', width: 130 }, + { title: '业务ID', key: 'bizId', align: 'left', width: 180 }, + { + title: '状态', + key: 'status', + align: 'left', + width: 120, + render(row: State) { + return dict.getLabel('ImKafkaDeadLetterStatusOptions', row.status); + }, + }, + { title: '重试', key: 'retryCount', align: 'left', width: 80 }, + { title: '重放', key: 'replayCount', align: 'left', width: 80 }, + { title: '原因', key: 'lastReason', align: 'left', width: 180 }, + { title: '创建时间', key: 'createdAt', align: 'left', width: 180 }, + { title: '更新时间', key: 'updatedAt', align: 'left', width: 180 }, +]; + +export function loadOptions() { + dict.loadOptions(['ImKafkaDeadLetterStatusOptions']); +} + +export async function loadTenantOptions() { + const res = await TenantList({ page: 1, perPage: 200 }); + tenantOptions.value = (res?.list || []).map((item) => ({ + label: `${item.name}(${item.code})`, + value: item.id, + })); + return tenantOptions.value; +} diff --git a/web/src/views/imDeadLetter/view.vue b/web/src/views/imDeadLetter/view.vue new file mode 100644 index 0000000..14c49ce --- /dev/null +++ b/web/src/views/imDeadLetter/view.vue @@ -0,0 +1,123 @@ + + + + + diff --git a/web/src/views/imKafkaAudit/index.vue b/web/src/views/imKafkaAudit/index.vue new file mode 100644 index 0000000..3aa11ea --- /dev/null +++ b/web/src/views/imKafkaAudit/index.vue @@ -0,0 +1,72 @@ + + + + + diff --git a/web/src/views/imKafkaAudit/model.ts b/web/src/views/imKafkaAudit/model.ts new file mode 100644 index 0000000..12b4144 --- /dev/null +++ b/web/src/views/imKafkaAudit/model.ts @@ -0,0 +1,106 @@ +import { ref } from 'vue'; +import { cloneDeep } from 'lodash-es'; +import { FormSchema } from '@/components/Form'; +import { defRangeShortcuts } from '@/utils/dateUtil'; + +export class State { + public id = 0; + public eventId = ''; + public tenantCode = ''; + public eventType = ''; + public bizType = ''; + public bizId = ''; + public actorType = ''; + public actorId = ''; + public action = ''; + public resource = ''; + public resourceId = ''; + public result = ''; + public reason = ''; + public payload = ''; + public envelope = ''; + public occurredAt = ''; + public receivedAt = ''; + + constructor(state?: Partial) { + if (state) Object.assign(this, state); + } +} + +export function newState(state: State | Record | null): State { + if (state !== null) { + if (state instanceof State) return cloneDeep(state); + return new State(state); + } + return new State(); +} + +export const schemas = ref([ + { + field: 'tenantCode', + component: 'NInput', + label: '租户编码', + componentProps: { placeholder: '请输入租户编码' }, + }, + { + field: 'eventId', + component: 'NInput', + label: '事件ID', + componentProps: { placeholder: '请输入事件ID' }, + }, + { + field: 'eventType', + component: 'NInput', + label: '事件类型', + componentProps: { placeholder: '请输入事件类型' }, + }, + { + field: 'action', + component: 'NInput', + label: '动作', + componentProps: { placeholder: '请输入动作' }, + }, + { + field: 'resource', + component: 'NInput', + label: '资源', + componentProps: { placeholder: '请输入资源' }, + }, + { + field: 'resourceId', + component: 'NInput', + label: '资源ID', + componentProps: { placeholder: '请输入资源ID' }, + }, + { + field: 'result', + component: 'NInput', + label: '结果', + componentProps: { placeholder: '请输入结果' }, + }, + { + field: 'createdAt', + component: 'NDatePicker', + label: '接收时间', + componentProps: { + type: 'datetimerange', + clearable: true, + shortcuts: defRangeShortcuts(), + }, + }, +]); + +export const columns = [ + { title: 'ID', key: 'id', align: 'left', width: 90 }, + { title: '租户', key: 'tenantCode', align: 'left', width: 120 }, + { title: '事件ID', key: 'eventId', align: 'left', width: 220 }, + { title: '事件类型', key: 'eventType', align: 'left', width: 190 }, + { title: '动作', key: 'action', align: 'left', width: 170 }, + { title: '资源', key: 'resource', align: 'left', width: 120 }, + { title: '资源ID', key: 'resourceId', align: 'left', width: 170 }, + { title: '操作者', key: 'actorId', align: 'left', width: 140 }, + { title: '结果', key: 'result', align: 'left', width: 100 }, + { title: '原因', key: 'reason', align: 'left', width: 160 }, + { title: '发生时间', key: 'occurredAt', align: 'left', width: 180 }, + { title: '接收时间', key: 'receivedAt', align: 'left', width: 180 }, +]; diff --git a/web/src/views/imKafkaAudit/view.vue b/web/src/views/imKafkaAudit/view.vue new file mode 100644 index 0000000..0ff8d2c --- /dev/null +++ b/web/src/views/imKafkaAudit/view.vue @@ -0,0 +1,79 @@ + + + + + diff --git a/web/src/views/imKafkaCommand/index.vue b/web/src/views/imKafkaCommand/index.vue new file mode 100644 index 0000000..80b8de7 --- /dev/null +++ b/web/src/views/imKafkaCommand/index.vue @@ -0,0 +1,127 @@ + + + + + diff --git a/web/src/views/imKafkaCommand/model.ts b/web/src/views/imKafkaCommand/model.ts new file mode 100644 index 0000000..59b1840 --- /dev/null +++ b/web/src/views/imKafkaCommand/model.ts @@ -0,0 +1,139 @@ +import { computed, ref } from 'vue'; +import { cloneDeep } from 'lodash-es'; +import { FormSchema } from '@/components/Form'; +import { defRangeShortcuts } from '@/utils/dateUtil'; +import { useDictStore } from '@/store/modules/dict'; +import { List as TenantList } from '@/api/imTenant'; + +const dict = useDictStore(); + +export const tenantOptions = ref([]); + +export class State { + public id = 0; + public commandId = ''; + public tenantId = 0; + public tenantCode = ''; + public eventId = ''; + public eventType = ''; + public bizType = ''; + public bizId = ''; + public idempotencyKey = ''; + public status = ''; + public reason = ''; + public message = ''; + public payload = ''; + public envelope = ''; + public result = ''; + public sentAt = ''; + public completedAt = ''; + public createdBy = 0; + public createdAt = ''; + public updatedAt = ''; + + constructor(state?: Partial) { + if (state) Object.assign(this, state); + } +} + +export function newState(state: State | Record | null): State { + if (state !== null) { + if (state instanceof State) return cloneDeep(state); + return new State(state); + } + return new State(); +} + +export const schemas = ref([ + { + field: 'tenantId', + component: 'NSelect', + label: '租户', + componentProps: { + clearable: true, + options: computed(() => tenantOptions.value), + placeholder: '请选择租户', + }, + }, + { + field: 'commandId', + component: 'NInput', + label: '命令ID', + componentProps: { placeholder: '请输入命令ID' }, + }, + { + field: 'eventId', + component: 'NInput', + label: '事件ID', + componentProps: { placeholder: '请输入事件ID' }, + }, + { + field: 'eventType', + component: 'NInput', + label: '事件类型', + componentProps: { placeholder: '请输入事件类型' }, + }, + { + field: 'bizId', + component: 'NInput', + label: '业务ID', + componentProps: { placeholder: '请输入业务ID' }, + }, + { + field: 'status', + component: 'NSelect', + label: '状态', + componentProps: { + clearable: true, + options: computed(() => dict.getOptions('ImAdminCommandStatusOptions')), + placeholder: '请选择状态', + }, + }, + { + field: 'createdAt', + component: 'NDatePicker', + label: '创建时间', + componentProps: { + type: 'datetimerange', + clearable: true, + shortcuts: defRangeShortcuts(), + }, + }, +]); + +export const columns = [ + { title: 'ID', key: 'id', align: 'left', width: 90 }, + { title: '租户', key: 'tenantCode', align: 'left', width: 120 }, + { title: '命令ID', key: 'commandId', align: 'left', width: 210 }, + { title: '事件类型', key: 'eventType', align: 'left', width: 190 }, + { title: '业务类型', key: 'bizType', align: 'left', width: 110 }, + { title: '业务ID', key: 'bizId', align: 'left', width: 180 }, + { + title: '状态', + key: 'status', + align: 'left', + width: 110, + render(row: State) { + return dict.getLabel('ImAdminCommandStatusOptions', row.status); + }, + }, + { title: '错误码', key: 'reason', align: 'left', width: 170 }, + { title: '消息', key: 'message', align: 'left', width: 220 }, + { title: '投递时间', key: 'sentAt', align: 'left', width: 180 }, + { title: '完成时间', key: 'completedAt', align: 'left', width: 180 }, + { title: '创建时间', key: 'createdAt', align: 'left', width: 180 }, +]; + +export function loadOptions() { + dict.loadOptions(['ImAdminCommandStatusOptions']); +} + +export async function loadTenantOptions() { + const res = await TenantList({ page: 1, perPage: 20 }); + tenantOptions.value = (res?.list || []).map((item) => ({ + label: `${item.name}(${item.code})`, + value: item.id, + code: item.code, + })); + return tenantOptions.value; +} diff --git a/web/src/views/imKafkaCommand/view.vue b/web/src/views/imKafkaCommand/view.vue new file mode 100644 index 0000000..9efe8bc --- /dev/null +++ b/web/src/views/imKafkaCommand/view.vue @@ -0,0 +1,86 @@ + + + + + diff --git a/web/src/views/imUser/governance.vue b/web/src/views/imUser/governance.vue new file mode 100644 index 0000000..d1c4582 --- /dev/null +++ b/web/src/views/imUser/governance.vue @@ -0,0 +1,142 @@ + + + + +