diff --git a/docs/backstage/IM-后台未实现功能清单.md b/docs/backstage/IM-后台未实现功能清单.md new file mode 100644 index 0000000..6ab02ee --- /dev/null +++ b/docs/backstage/IM-后台未实现功能清单.md @@ -0,0 +1,240 @@ +# IM 后台未实现功能清单 + +> 编写日期:2026-08-16 +> 目的:记录当前 HotGo IM 后台已经完成的能力、尚未实现的后台功能,以及后续应优先通过 `im_server` 管理 API 完成的写操作。 + +## 1. 当前已实现 + +### 1.1 平台控制库 + +已实现: + +| 模块 | 当前能力 | +| --- | --- | +| IM 租户管理 | 租户列表、详情、新增/编辑、状态切换 | +| IM 租户配额配置 | `im_tenant_config` 随租户编辑维护 | +| 租户业务库路由 | 后台按 `im_tenant.db_dsn` 或 `db_name` 动态连接租户库 | +| 后台菜单 | 已导入 IM 管理根菜单及各模块权限点 | + +未完成: + +| 功能 | 说明 | +| --- | --- | +| 租户 provisioning 流程 | 创建租户业务库、执行 schema、初始化系统账号和默认 Topic 尚未实现 | +| 租户删除流程 | 删除前检查、异步归档、业务库删除/保留策略未实现 | +| 平台级审计写入 | 租户创建、停用、恢复、删除等平台操作还未写平台审计 | +| 租户用量统计 | 用户数、群数、文件容量、消息量统计接口未实现 | + +### 1.2 租户业务库只读后台 + +已实现: + +| 模块 | 当前能力 | +| --- | --- | +| IM 用户管理 | 按租户查看用户列表、用户详情 | +| IM 群组管理 | 群列表、群详情、群成员列表 | +| 群治理只读视图 | 查看 `im_group_settings`、入群申请、群邀请、群公告、禁言记录、群管理日志 | +| 实名审核 | 实名提交列表、详情、通过、拒绝 | +| 举报反馈工单 | 举报/反馈列表、详情、状态更新 | +| 租户审计日志 | `im_audit_logs` 列表、详情,只读 | + +注意: + +1. 群成员列表优先读取 `im_group_members`,如果租户库尚未回填业务真源,会回退读取 `im_subscriptions`。 +2. 群治理写操作暂未在 HotGo 后台直接实现,避免绕过 `im_server` 的 Topic、Subscription、ACL、审计和 Outbox 状态机。 +3. 举报反馈当前只更新工单状态,尚未实现“警告、禁言、封禁、撤回消息、通知用户”等处置动作。 + +## 2. P0 未实现 + +### 2.1 后台认证、租户 Principal 与数据权限 + +未实现: + +| 功能 | 说明 | +| --- | --- | +| 租户级 Principal | 后台 Token 中可信 `tenant_id`、角色、权限版本尚未完整建模 | +| 企业后台权限边界 | 普通租户管理员只能访问本租户数据的服务端强校验还未系统化 | +| 跨租户操作二次验证 | 平台管理员切换租户、导出、代操作缺少二次确认 | +| 数据范围权限 | 部门级、群级、安全审计员的数据范围过滤未实现 | + +### 2.2 审计写入 + +未实现: + +| 功能 | 说明 | +| --- | --- | +| 平台级审计表 | 控制库中的平台审计表和查询页面尚未实现 | +| 租户级审计写入 | 用户、群、实名、举报、反馈、文件等敏感操作尚未统一写 `im_audit_logs` | +| 审计导出 | 审计导出、导出审计、脱敏策略未实现 | +| 审计不可篡改策略 | 追加写、防删除、防修改、保留周期尚未实现 | + +### 2.3 Outbox 与后台任务 + +未实现: + +| 功能 | 说明 | +| --- | --- | +| 租户级 Outbox 查询 | `im_outbox_events`、`im_group_events_outbox` 后台查看未实现 | +| Outbox 重试/死信 | 人工重放、死信处理、幂等检查未实现 | +| 任务状态监控 | 文件 GC、索引重建、Push、外部审核任务状态未实现 | + +## 3. P1 未实现 + +### 3.1 群治理写操作 + +这些操作必须优先通过 `im_server` 管理 API 实现,HotGo 后台只调用 API,不直接改业务表: + +| 功能 | 涉及表 | 说明 | +| --- | --- | --- | +| 修改群资料 | `im_group_settings`、`im_topics` | 群名、头像、简介,需同步通信投影 | +| 修改治理配置 | `im_group_settings` | 入群审批、全员禁言、上传文件、成员私聊、成员邀请 | +| 入群审批 | `im_group_join_requests`、`im_group_members`、`im_subscriptions` | 同意/拒绝,需版本号、幂等、配额校验 | +| 邀请处理 | `im_group_invitations`、`im_group_join_requests` | 接受邀请后可能直接入群或进入审批 | +| 群公告发布/更新/删除 | `im_group_announcements`、`im_group_announcement_reads` | 需要乐观锁、系统消息、Outbox | +| 成员禁言/解除 | `im_group_mutes` | 需写管理日志、审计和群系统事件 | +| 管理员管理 | `im_group_members`、`im_subscriptions` | 添加/移除管理员并同步 ACL | +| 群主转让 | `im_group_settings`、`im_group_members`、`im_topics` | 二次确认,同步 Owner 和 ACL | +| 解散群 | `im_group_settings`、`im_topics`、邀请、二维码、文件入口 | 需撤销入口、写审计、发系统事件 | +| 群二维码 | `im_group_qrcodes` | 生成、撤销、过期、扫码解析管理 | + +当前 HotGo 后台只读查看这些表,不做上述写操作。 + +### 3.2 文件与存储治理 + +未实现: + +| 功能 | 说明 | +| --- | --- | +| 文件列表 | 按租户、用途、状态、MIME、上传人查询 | +| 文件详情 | 文件元数据、引用消息、引用群、ACL、扫描状态 | +| 文件下载审计 | 后台下载必须写审计 | +| 隔离/删除文件 | 必须通过 `im_server` API 处理引用、消息、收藏、举报证据 | +| 文件策略 | 单文件大小、总容量、允许 MIME、保留期、病毒扫描策略 | +| 群文件治理 | `im_group_files` 列表、删除、下载权限未实现 | + +### 3.3 组织架构与企业通讯录 + +未实现: + +| 功能 | 说明 | +| --- | --- | +| 部门管理 | 部门树、移动部门、防环、删除校验 | +| 成员归属 | 用户与部门、岗位、负责人关系 | +| 组织数据权限 | 按部门/下级部门限制日报、会议、通讯录可见范围 | + +### 3.4 用户与账号安全写操作 + +未实现: + +| 功能 | 说明 | +| --- | --- | +| 停用/恢复用户 | 需同步登录、Token、WS、审计 | +| 重置密码/安全项 | 必须二次确认并写审计 | +| 设备管理 | 查看、踢下线、撤销设备 Token | +| 用户导出 | 需要导出审计和字段脱敏 | +| 黑名单/好友关系后台 | 查询、处置、修复关系未实现 | + +## 4. P2 未实现 + +### 4.1 反诈与内容审核 + +未实现: + +| 功能 | 说明 | +| --- | --- | +| 风控策略配置 | 高风险动作、提示文案、二次确认、阻断策略 | +| 内容审核策略 | `sync_block`、`async_hold`、`async_post_audit` 配置 | +| 审核插件配置 | 平台统一审核服务、租户专属审核服务 | +| 风险事件列表 | 确认、取消、过期、阻断记录 | +| 审核任务列表 | 待审核消息/文件/图片/视频任务 | +| 审核通过/拒绝 | 必须通过 `im_server` 推进状态机 | +| 审核撤回消息 | 已发布违规消息系统撤回、审计、Outbox 未实现 | + +### 4.2 举报反馈完整处置 + +当前已实现列表、详情、状态更新。未实现: + +| 功能 | 说明 | +| --- | --- | +| 举报原因配置 | 按用户、群、消息目标类型配置原因 | +| 工单分派 | 分派给处理人或安全团队 | +| 工单回复 | 处理结果回复举报人/反馈人 | +| 标记重复 | 合并重复举报或反馈 | +| 处置动作 | 警告、禁言、封禁用户、解散群、撤回消息 | +| 结果通知 | 通过系统通知或客户端查询展示处理结果 | +| 证据文件管理 | `im_evidence_files` 或附件引用的 ACL、保留期、下载审计 | + +### 4.3 实名审核完善 + +当前已实现列表、详情、通过、拒绝。未实现: + +| 功能 | 说明 | +| --- | --- | +| 实名策略配置 | 是否强制实名、允许跳过、提醒周期、重复身份策略 | +| 要求补充材料 | 当前只有通过/拒绝,没有补充材料状态 | +| 敏感原文查看流程 | 需要权限、二次验证、审计,普通管理员不可查看 | +| 审核结果通知 | 审核后通过系统事件通知用户 | +| 审核操作审计 | 当前未统一写租户审计 | + +### 4.4 会议、日报、月报 + +未实现: + +| 功能 | 说明 | +| --- | --- | +| 会议管理 | 会议列表、详情、参与人响应、提醒 | +| 日报/月报列表 | 按部门、成员、状态、时间查询 | +| 统计 | 日报/月报提交率、阅读率、部门统计 | +| 提醒 | 未提交、未读提醒,需限流和逐项结果 | + +### 4.5 总经理信箱 + +未实现: + +| 功能 | 说明 | +| --- | --- | +| 信箱配置 | 是否启用、匿名、必填字段、附件策略、提示文案 | +| 提交列表/详情 | 匿名模式下处理人不可见提交人 | +| 分派/回复/关闭 | 需要审计和通知 | + +### 4.6 工作台应用配置 + +未实现: + +| 功能 | 说明 | +| --- | --- | +| 应用模板 | 平台应用模板、默认权限、分类 | +| 租户应用配置 | 启用/停用、排序、权限、外链安全 | +| 外部链接审计 | 工作台外链访问和安全策略 | + +### 4.7 Push、短信、TURN、对象存储配置 + +未实现: + +| 功能 | 说明 | +| --- | --- | +| Push 配置 | 租户级 Push 通道和测试 | +| 短信配置 | 租户短信模板、签名、发送日志 | +| TURN 配置 | 音视频 TURN 服务配置和连通性检查 | +| 对象存储配置 | 租户桶、路径、回源、签名、上传策略 | +| 外部服务变更审计 | 修改配置必须二次确认和审计 | + +## 5. 后续开发建议顺序 + +建议按以下顺序继续: + +1. **审计写入基础设施**:先实现统一 `im_audit_logs` 写入工具和平台审计表。 +2. **im_server 管理 API 对接层**:HotGo 后台新增统一 client,所有群治理、文件治理、用户处置写操作都走它。 +3. **群治理写操作**:入群审批、治理配置、公告、禁言、管理员、群主转让、解散。 +4. **文件治理**:文件列表、群文件、下载审计、隔离/删除、文件策略。 +5. **Outbox 与任务后台**:事件查看、重试、死信、任务监控。 +6. **内容审核与风险处置**:审核任务、风险事件、违规消息撤回。 +7. **组织/会议/日报/月报/工作台**:企业协同类功能。 + +## 6. 开发边界提醒 + +1. 租户业务库是“一个租户一个库”,业务表不带 `tenant_id`。 +2. HotGo 后台直连租户库适合只读查询、诊断和报表。 +3. 会影响用户、群、消息、文件、`im_topics`、`im_subscriptions`、ACL、Outbox 的写操作,应优先通过 `im_server` 管理 API。 +4. 敏感操作必须具备:权限校验、二次确认、幂等键或版本号、审计、Outbox 通知。 +5. 后台前端隐藏按钮只是体验优化,后端接口仍必须做权限和租户边界校验。 diff --git a/docs/backstage/IM-后台需求文档.md b/docs/backstage/IM-后台需求文档.md new file mode 100644 index 0000000..bd4e598 --- /dev/null +++ b/docs/backstage/IM-后台需求文档.md @@ -0,0 +1,883 @@ +# IM 后台需求文档 + +> 编写日期:2026-08-12 +> 依据文档:`IM-产品需求服务端改造分析报告.md`、`IM-独立库多租户改造方案.md`、`IM-多租户第5至12章代码改造与测试方案.md`、`IM-前后端对接文档.md`、`IM-系统设计文档.md` +> 目标读者:后台产品、后端、管理端前端、测试 + +## 1. 文档目的 + +本文从现有 IM 产品与服务端设计文档中提取“后台管理端”需求,用于后续后台系统开发。本文只描述后台能力,不重复普通移动端聊天、通讯录、消息收发等客户端功能。 + +后台系统分为两类: + +| 后台 | 使用对象 | 数据范围 | +| --- | --- | --- | +| 平台运营后台 | 平台管理员、运维、安全负责人 | 可管理全部租户,但跨租户操作必须二次验证和审计 | +| 企业管理后台 | 租户负责人、企业管理员、安全审计员、组织管理员 | 只能管理当前租户内的数据 | + +所有后台能力必须建立在多租户隔离之上:当前多租户采用“控制库 + 每租户独立业务库”方案。后台 Token 必须包含可信 `tenant_id`、`uid`、角色、权限版本;其中 `tenant_id` 只作为运行时租户上下文和选库依据,不要求租户业务库内业务表保存 `tenant_id` 字段。平台后台的跨租户操作必须走独立后台 API,禁止通过普通 WebSocket `AsUser` 任意代操作用户。 + +后台系统与 `im_server` 的职责边界如下: + +1. HotGo 后台负责管理平台控制库、后台页面、菜单权限、平台运营流程和必要的数据展示。 +2. 租户内用户、群、文件、实名、举报、反馈、Outbox 等业务动作优先通过 `im_server` 管理 API 执行,避免绕过 `im_server` 的状态机、审计、Outbox、权限校验和租户选库逻辑。 +3. HotGo 后台如需直连租户业务库,只能用于只读报表、诊断或经过评审的维护任务;写操作必须明确说明为什么不能调用 `im_server` API。 + +## 2. 后台角色与权限 + +### 2.1 角色 + +| 角色 | 作用域 | 说明 | +| --- | --- | --- | +| `platform_admin` | 平台级 | 租户生命周期、平台配置、运维诊断、跨租户审计 | +| `tenant_owner` | 租户级 | 企业最高管理员,拥有本租户全部管理权限 | +| `tenant_admin` | 租户级 | 组织、用户、群、安全策略和工作台配置 | +| `security_auditor` | 租户级 | 实名、举报、反馈、审计读取和安全处置 | +| `org_admin` | 租户级或部门级 | 部门、成员、岗位、组织数据范围 | +| `report_admin` | 部门级 | 日报、月报、会议统计与提醒 | +| `group_admin` | 群级 | 群成员、公告、禁言、群文件等群治理 | + +### 2.2 权限原则 + +1. 平台管理员可以选择租户上下文进行运维,但所有跨租户读取、导出、代操作必须记录审计。 +2. 企业管理员只能访问自己租户的数据,不能通过 URL、Header、Body 指定其他 `tenant_id`、`db_name` 或 `db_dsn`。 +3. 后台菜单和按钮由服务端返回权限裁剪结果,前端隐藏只是体验优化,服务端仍必须二次鉴权。 +4. 敏感操作必须支持二次认证、幂等键、版本号或 `If-Match`,并写入审计。 +5. 普通租户管理员不能修改或删除审计日志。 + +## 3. 后台功能总览 + +| 模块 | 平台后台 | 企业后台 | 优先级 | +| --- | --- | --- | --- | +| 租户管理 | 是 | 否 | P0 | +| 租户配置与配额 | 是 | 部分只读或申请变更 | P0 | +| 管理员与角色权限 | 是 | 是 | P0 | +| 用户与账号安全 | 支持跨租户运维 | 是 | P0 | +| 组织架构 | 否 | 是 | P1 | +| 群治理 | 运维只读/处置 | 是 | P1 | +| 工作台应用配置 | 平台模板 | 租户配置 | P2 | +| 会议、日报、月报 | 否 | 是 | P2 | +| 总经理信箱 | 否 | 是 | P2 | +| 实名认证审核 | 平台可审计 | 是 | P2 | +| 反诈与内容审核 | 平台策略/插件 | 租户策略/处置 | P2 | +| 举报与反馈工单 | 平台可审计 | 是 | P2 | +| 文件与存储治理 | 平台运维 | 租户范围 | P1 | +| Push、短信、TURN、对象存储配置 | 是 | 租户专属配置 | P1 | +| 审计日志 | 是 | 只读本租户 | P0 | +| Outbox 与后台任务 | 是 | 租户任务可见 | P1 | + +## 4. 平台后台需求 + +### 4.1 租户管理 + +后台需要支持租户的创建、查看、编辑、停用、恢复、删除前置检查和删除归档。 + +租户状态: + +| 状态 | 登录 | 读消息 | 写业务 | 管理操作 | +| --- | --- | --- | --- | --- | +| `provisioning` | 禁止 | 禁止 | 禁止 | 平台管理员可配置 | +| `active` | 允许 | 允许 | 允许 | 按角色允许 | +| `suspended` | 禁止新登录 | 可配置只读 | 禁止 | 平台管理员可恢复或导出 | +| `deleting` | 禁止 | 禁止 | 禁止 | 只允许删除任务 | +| `deleted` | 禁止 | 禁止 | 禁止 | 只保留审计 | + +核心字段: + +| 字段 | 说明 | +| --- | --- | +| `id` | 内部租户 ID,普通客户端不返回 | +| `code` | 企业码,全局唯一,最长 64 字符 | +| `name` | 企业名称 | +| `tenant_desc` | 企业说明 | +| `db_name` | 租户业务库名称;适合同 MySQL 实例下按库隔离,如 `im_tenant_1001` | +| `db_dsn` | 租户业务库完整 DSN;适合独立 MySQL 实例或特殊部署,优先级高于 `db_name` | +| `state` | 租户状态 | +| `created_by/updated_by` | 操作人 | +| `created_at/updated_at` | 时间 | + +建议接口: + +| 方法 | 路径 | 说明 | +| --- | --- | --- | +| `GET` | `/admin/v1/tenants` | 租户列表,支持状态、企业码、名称过滤 | +| `POST` | `/admin/v1/tenants` | 创建租户 | +| `GET` | `/admin/v1/tenants/{tenant_id}` | 租户详情 | +| `PATCH` | `/admin/v1/tenants/{tenant_id}` | 修改名称、说明等基础资料 | +| `POST` | `/admin/v1/tenants/{tenant_id}/suspend` | 停用租户 | +| `POST` | `/admin/v1/tenants/{tenant_id}/restore` | 恢复租户 | +| `POST` | `/admin/v1/tenants/{tenant_id}/deletion-check` | 删除前检查 | +| `DELETE` | `/admin/v1/tenants/{tenant_id}` | 进入删除流程 | + +独立库模式下,创建租户必须按 provisioning 流程执行: + +1. 在控制库创建 `im_tenant` 记录,初始状态为 `provisioning`。 +2. 生成或填写租户业务库路由信息,优先使用 `db_dsn`,否则使用主 DSN + `db_name`。 +3. 创建租户业务库并执行 `tenant_schema.sql`。 +4. 初始化默认系统数据,例如 `kvmeta.version`、系统账号、`sys` Topic 和必要的默认配置。 +5. 回写 `im_tenant.db_name/db_dsn`,状态改为 `active`。 +6. 任一步失败时保留 `provisioning` 或进入明确失败状态,后台提供错误详情和重试入口;禁止创建失败的租户进入登录流程。 + +验收点: + +1. 租户 `code` 全局唯一。 +2. 停用后旧 Token、WS 登录、HTTP 产品接口均拒绝。 +3. 删除必须进入异步任务,不允许直接硬删租户业务库。 +4. 所有状态变更写平台审计。 + +### 4.2 租户配置与配额 + +首期配置真源是 `im_tenant_config`。 + +| 配置 | 说明 | 默认 | +| --- | --- | --- | +| `max_users` | 有效用户数上限 | `0` 表示不限 | +| `max_groups` | 有效群数量上限 | `0` 表示不限 | +| `max_group_members` | 单群有效成员上限 | `0` 表示不限 | + +后续可扩展配置: + +| 配置域 | 示例 | +| --- | --- | +| 功能开关 | 加好友、建群、用户搜索、音视频、群文件、日报月报 | +| 安全策略 | 实名要求、青少年限制、风控模式、数据保留期 | +| 消息策略 | 文本长度、媒体类型、消息频控、撤回时限、编辑时限 | +| 文件策略 | 单文件大小、总存储配额、允许 MIME、病毒扫描 | +| Push 配置 | APNS/FCM 凭证、模板、锁屏隐私 | +| 外部服务 | 短信、REST 认证、TURN、对象存储、审核插件 | +| 企业品牌 | Logo、主题色、企业描述、客户端下载地址 | + +建议接口: + +| 方法 | 路径 | 说明 | +| --- | --- | --- | +| `GET` | `/admin/v1/tenants/{tenant_id}/config` | 查询租户配置 | +| `PATCH` | `/admin/v1/tenants/{tenant_id}/config` | 修改配额和策略 | +| `GET` | `/admin/v1/tenants/{tenant_id}/usage` | 查询用户数、群数、文件容量、消息量 | +| `GET` | `/admin/v1/tenants/{tenant_id}/features` | 查询功能开关 | +| `PATCH` | `/admin/v1/tenants/{tenant_id}/features` | 修改功能开关 | + +验收点: + +1. 注册、创建群、入群审批必须在 `im_server` 对应租户业务库事务内校验配额。 +2. 超限返回稳定错误码,并记录当前用量、限制值、租户和操作。 +3. 修改配置只影响当前租户,不能污染全平台缓存。 + +### 4.3 平台管理员管理 + +需求: + +1. 创建、停用、恢复平台管理员。 +2. 分配平台角色和权限。 +3. 平台管理员登录、二次认证、Token 撤销。 +4. 查看平台管理员操作审计。 + +敏感操作: + +| 操作 | 要求 | +| --- | --- | +| 创建平台管理员 | 二次确认和审计 | +| 授权 `platform_admin` | 二次认证 | +| 跨租户代操作 | 必须填写原因,记录目标租户和目标对象 | +| 导出数据 | 审批或二次认证,记录导出范围 | + +### 4.4 平台运维与任务 + +需求: + +1. 查看运行节点、数据库版本、Adapter 类型和多租户能力。 +2. 非 MySQL 或未完成独立库租户路由能力的 Adapter 在产品模式下必须拒绝启动。 +3. 查看 Outbox 事件积压、失败、死信和重试。 +4. 查看文件垃圾回收、搜索索引任务、Push 投递、外部审核调用状态。 +5. 支持按租户维度查看任务,但避免 Prometheus 高基数标签。 + +建议接口: + +| 方法 | 路径 | 说明 | +| --- | --- | --- | +| `GET` | `/admin/v1/system/health` | 系统健康 | +| `GET` | `/admin/v1/system/nodes` | 节点列表 | +| `GET` | `/admin/v1/system/outbox-events` | Outbox 查询 | +| `POST` | `/admin/v1/system/outbox-events/{id}/retry` | 重试事件 | +| `POST` | `/admin/v1/system/outbox-events/{id}/dead-letter` | 标记死信 | +| `GET` | `/admin/v1/system/audit-logs` | 平台审计 | + +## 5. 企业后台需求 + +### 5.1 企业管理员与角色权限 + +企业后台需要支持本租户内管理员管理。 + +角色: + +| 角色 | 权限 | +| --- | --- | +| `tenant_owner` | 全部租户管理权限 | +| `tenant_admin` | 用户、组织、群、安全、工作台配置 | +| `security_auditor` | 实名、举报、反馈、审计读取 | +| `org_admin` | 组织架构和成员维护 | +| `report_admin` | 日报、月报、会议统计 | + +建议接口: + +| 方法 | 路径 | 说明 | +| --- | --- | --- | +| `GET` | `/admin/v1/tenant/admins` | 企业管理员列表 | +| `POST` | `/admin/v1/tenant/admins` | 添加管理员 | +| `PATCH` | `/admin/v1/tenant/admins/{user_id}` | 修改角色 | +| `DELETE` | `/admin/v1/tenant/admins/{user_id}` | 移除管理员 | +| `GET` | `/admin/v1/tenant/roles` | 可用角色和权限点 | + +验收点: + +1. Tenant Admin 只能管理当前租户。 +2. 授权、移除、代操作必须审计。 +3. 不允许最后一个 `tenant_owner` 被删除或停用。 + +### 5.2 用户与账号安全管理 + +需求: + +1. 用户列表、搜索、详情。 +2. 创建、启用、停用、离职、恢复用户。 +3. 重置密码、撤销 Token、踢下线。 +4. 查看登录设备、登录 Session、安全事件。 +5. 查看用户实名状态、青少年状态、隐私设置摘要。 +6. 支持同手机号在不同租户独立存在,本租户后台只看到本租户用户。 + +建议接口: + +| 方法 | 路径 | 说明 | +| --- | --- | --- | +| `GET` | `/admin/v1/tenant/users` | 用户列表 | +| `POST` | `/admin/v1/tenant/users` | 创建用户 | +| `GET` | `/admin/v1/tenant/users/{user_id}` | 用户详情 | +| `PATCH` | `/admin/v1/tenant/users/{user_id}` | 修改资料或状态 | +| `POST` | `/admin/v1/tenant/users/{user_id}/reset-password` | 重置密码 | +| `POST` | `/admin/v1/tenant/users/{user_id}/revoke-sessions` | 撤销会话和 Token | +| `GET` | `/admin/v1/tenant/users/{user_id}/devices` | 登录设备 | +| `GET` | `/admin/v1/tenant/users/{user_id}/security-events` | 安全事件 | + +验收点: + +1. 停用用户不影响其他租户相同手机号或相同设备用户。 +2. 重置密码必须递增 `auth_version` 并撤销旧 Token。 +3. 查看敏感信息必须脱敏,证件号、手机号、Token 不写日志。 +4. 用户管理接口必须通过 `tenant_id` 选择对应租户业务库;不得在控制库中查找租户内用户主数据。 + +### 5.3 组织架构管理 + +需求: + +1. 部门树管理:新增、编辑、移动、删除部门。 +2. 部门成员管理:添加、移除、调岗、设置负责人。 +3. 组织搜索:按部门、用户、岗位搜索。 +4. 离职用户处理:不可联系或从结果移除。 +5. 组织数据权限:管理员只能管理权限范围内部门。 + +建议数据表: + +| 表 | 说明 | +| --- | --- | +| `im_org_units` | 部门树 | +| `im_org_members` | 部门成员、岗位、负责人 | + +建议接口: + +| 方法 | 路径 | 说明 | +| --- | --- | --- | +| `GET` | `/admin/v1/tenant/org/departments` | 部门树 | +| `POST` | `/admin/v1/tenant/org/departments` | 创建部门 | +| `PATCH` | `/admin/v1/tenant/org/departments/{id}` | 修改或移动部门 | +| `DELETE` | `/admin/v1/tenant/org/departments/{id}` | 删除部门 | +| `GET` | `/admin/v1/tenant/org/departments/{id}/members` | 部门成员 | +| `POST` | `/admin/v1/tenant/org/departments/{id}/members` | 添加成员 | +| `DELETE` | `/admin/v1/tenant/org/departments/{id}/members/{user_id}` | 移除成员 | +| `GET` | `/admin/v1/tenant/org/search` | 组织搜索 | + +验收点: + +1. 部门 `parent_id`、负责人、成员必须来自当前租户业务库。 +2. 移动部门不能形成环。 +3. 删除部门需要校验子部门和成员处理策略。 + +### 5.4 群治理管理 + +企业后台和群主/群管理员都需要群治理能力。后台侧侧重全租户范围查询、策略配置、违规处置和审计。 + +需求: + +1. 群列表、群详情、群成员列表。 +2. 群资料编辑:名称、头像、简介。 +3. 群治理配置:入群审批、全员禁言、成员上传文件、成员私聊、成员邀请。 +4. 群管理员管理:添加、删除管理员,管理员数量限制。 +5. 禁言管理:添加、解除、过期自动失效。 +6. 群主转让、解散群聊。 +7. 群二维码管理:生成、撤销、过期。 +8. 群文件管理:列表、搜索、删除、下载权限。 +9. 群操作日志:角色变化、禁言、公告、解散。 + +建议数据表: + +| 表 | 说明 | +| --- | --- | +| `im_group_settings` | 群治理配置 | +| `im_group_members` | 群成员和角色真源 | +| `im_group_join_requests` | 入群申请 | +| `im_group_announcements` | 群公告 | +| `im_group_mutes` | 禁言记录 | +| `im_group_admin_logs` | 群管理日志 | +| `im_group_files` | 群文件索引 | + +建议接口: + +| 方法 | 路径 | 说明 | +| --- | --- | --- | +| `GET` | `/admin/v1/tenant/groups` | 群列表 | +| `GET` | `/admin/v1/tenant/groups/{topic}` | 群详情 | +| `PATCH` | `/admin/v1/tenant/groups/{topic}` | 修改群资料 | +| `GET/PATCH` | `/admin/v1/tenant/groups/{topic}/governance` | 群治理配置 | +| `GET` | `/admin/v1/tenant/groups/{topic}/members` | 成员列表 | +| `POST` | `/admin/v1/tenant/groups/{topic}/admins/batch` | 添加管理员 | +| `DELETE` | `/admin/v1/tenant/groups/{topic}/admins/{user_id}` | 删除管理员 | +| `POST` | `/admin/v1/tenant/groups/{topic}/mutes/batch` | 添加禁言 | +| `DELETE` | `/admin/v1/tenant/groups/{topic}/mutes/{user_id}` | 解除禁言 | +| `POST` | `/admin/v1/tenant/groups/{topic}/owner-transfer` | 群主转让 | +| `DELETE` | `/admin/v1/tenant/groups/{topic}` | 解散群 | +| `GET` | `/admin/v1/tenant/groups/{topic}/files` | 群文件 | +| `DELETE` | `/admin/v1/tenant/groups/{topic}/files/{group_file_id}` | 删除群文件 | + +验收点: + +1. 群业务真源与 Topic/Subscription/ACL 投影必须在同一事务内更新。 +2. 管理员不能越级操作群主,普通管理员之间操作规则由治理策略决定。 +3. 解散群必须撤销 Topic、二维码、邀请和文件新增权限。 +4. 群治理写操作必须由 `im_server` 在目标租户业务库内完成,HotGo 后台不能直接改 Topic、Subscription 或 ACL 绕过业务真源。 + +### 5.5 工作台应用配置 + +需求: + +1. 工作台模块配置:启用、禁用、排序、图标、名称、路由。 +2. 根据组织角色、权限和功能开关裁剪用户可见模块。 +3. 维护会议、日报、月报、总经理信箱等入口。 +4. 工作台未读角标由系统事件更新。 + +建议数据表: + +| 表 | 说明 | +| --- | --- | +| `im_workbench_apps` | 租户工作台应用配置 | +| `im_workbench_user_apps` | 用户或角色级工作台配置 | + +建议接口: + +| 方法 | 路径 | 说明 | +| --- | --- | --- | +| `GET` | `/admin/v1/tenant/workbench/apps` | 应用列表 | +| `POST` | `/admin/v1/tenant/workbench/apps` | 新增应用 | +| `PATCH` | `/admin/v1/tenant/workbench/apps/{code}` | 修改应用 | +| `DELETE` | `/admin/v1/tenant/workbench/apps/{code}` | 删除或禁用应用 | +| `POST` | `/admin/v1/tenant/workbench/apps/reorder` | 排序 | + +验收点: + +1. 客户端只接受白名单内部 `route`。 +2. 禁用模块后普通用户不可通过直接 URL 访问。 + +### 5.6 会议管理 + +需求: + +1. 创建、编辑、取消会议。 +2. 管理参会人、提醒未响应者。 +3. 查看参会响应:`pending`、`accepted`、`declined`、`tentative`。 +4. 会议过期后只读展示。 +5. 生成系统通知和 Push。 + +建议数据表: + +| 表 | 说明 | +| --- | --- | +| `im_meetings` | 会议主表 | +| `im_meeting_participants` | 参会人响应 | + +建议接口: + +| 方法 | 路径 | 说明 | +| --- | --- | --- | +| `GET` | `/admin/v1/tenant/meetings` | 会议列表 | +| `POST` | `/admin/v1/tenant/meetings` | 创建会议 | +| `GET` | `/admin/v1/tenant/meetings/{id}` | 会议详情 | +| `PATCH` | `/admin/v1/tenant/meetings/{id}` | 更新会议 | +| `POST` | `/admin/v1/tenant/meetings/{id}/cancel` | 取消会议 | +| `POST` | `/admin/v1/tenant/meetings/{id}/reminders` | 提醒未响应者 | + +### 5.7 日报与月报管理 + +需求: + +1. 日报/月报模板和校验规则配置。 +2. 普通用户草稿、提交、修订。 +3. 管理员按部门查看统计和明细。 +4. 提醒未提交人员。 +5. 查看详情时记录已读状态。 +6. 附件使用文件 ACL,不能通过旧 URL 越权下载。 + +建议数据表: + +| 表 | 说明 | +| --- | --- | +| `im_reports` | 日报/月报统一表 | +| `im_report_read_states` | 阅读状态 | + +建议接口: + +| 方法 | 路径 | 说明 | +| --- | --- | --- | +| `GET` | `/admin/v1/tenant/reports/daily/statistics` | 日报统计 | +| `GET` | `/admin/v1/tenant/reports/daily` | 日报列表 | +| `GET` | `/admin/v1/tenant/reports/daily/{id}` | 日报详情 | +| `POST` | `/admin/v1/tenant/reports/daily/reminders` | 日报提醒 | +| `GET` | `/admin/v1/tenant/reports/monthly/statistics` | 月报统计 | +| `GET` | `/admin/v1/tenant/reports/monthly` | 月报列表 | +| `GET` | `/admin/v1/tenant/reports/monthly/{id}` | 月报详情 | +| `POST` | `/admin/v1/tenant/reports/monthly/reminders` | 月报提醒 | + +验收点: + +1. 管理员只能看组织权限范围内成员。 +2. 并发保存必须使用版本号,冲突返回当前最新版本。 +3. 提醒接口需要限流并返回逐项结果。 + +### 5.8 总经理信箱 + +需求: + +1. 配置是否启用、是否允许匿名、必填字段、附件策略、提示文案。 +2. 用户提交意见或建议。 +3. 后台查看、分派、处理、回复和关闭。 +4. 匿名模式下业务接收方不可见提交人;安全审计是否保留内部追踪由企业政策决定。 +5. 文案变更后,客户端提交时可要求重新确认。 + +建议数据表: + +| 表 | 说明 | +| --- | --- | +| `im_manager_mailbox` | 信箱提交和处理记录 | + +建议接口: + +| 方法 | 路径 | 说明 | +| --- | --- | --- | +| `GET` | `/admin/v1/tenant/executive-mailbox/config` | 查询配置 | +| `PATCH` | `/admin/v1/tenant/executive-mailbox/config` | 修改配置 | +| `GET` | `/admin/v1/tenant/executive-mailbox/submissions` | 提交列表 | +| `GET` | `/admin/v1/tenant/executive-mailbox/submissions/{id}` | 提交详情 | +| `POST` | `/admin/v1/tenant/executive-mailbox/submissions/{id}/assign` | 分派 | +| `POST` | `/admin/v1/tenant/executive-mailbox/submissions/{id}/reply` | 回复 | +| `POST` | `/admin/v1/tenant/executive-mailbox/submissions/{id}/close` | 关闭 | + +## 6. 安全、审核与工单后台 + +### 6.1 实名认证审核 + +需求: + +1. 查看实名认证列表:未认证、待审核、已通过、已拒绝、申诉中。 +2. 查看脱敏信息,具备权限时按流程查看敏感原文。 +3. 审核通过、拒绝、要求补充材料。 +4. 实名策略配置:是否强制实名、允许跳过、提醒周期、重复身份策略。 +5. 审核结果通过系统事件通知用户。 + +建议数据表: + +| 表 | 说明 | +| --- | --- | +| `im_realname_verifications` | 实名信息、审核状态、脱敏展示 | + +建议接口: + +| 方法 | 路径 | 说明 | +| --- | --- | --- | +| `GET` | `/admin/v1/tenant/security/realname/submissions` | 实名审核列表 | +| `GET` | `/admin/v1/tenant/security/realname/submissions/{id}` | 审核详情 | +| `POST` | `/admin/v1/tenant/security/realname/submissions/{id}/approve` | 通过 | +| `POST` | `/admin/v1/tenant/security/realname/submissions/{id}/reject` | 拒绝 | +| `GET/PATCH` | `/admin/v1/tenant/security/realname/policy` | 实名策略 | + +验收点: + +1. 姓名、证件号加密存储,普通日志不出现原文。 +2. 审核操作必须审计。 +3. 普通管理员无权读取敏感原文。 + +### 6.2 青少年模式策略 + +需求: + +1. 配置青少年模式默认策略和限制范围。 +2. 查看用户青少年模式状态。 +3. 处理用户忘记独立密码后的重置流程。 + +限制范围首期建议包括: + +1. 添加好友。 +2. 加群和创建群。 +3. 文件传输。 +4. 音视频通话。 +5. 工作台外部链接。 + +建议接口: + +| 方法 | 路径 | 说明 | +| --- | --- | --- | +| `GET/PATCH` | `/admin/v1/tenant/security/youth-mode/policy` | 青少年策略 | +| `GET` | `/admin/v1/tenant/security/youth-mode/users` | 用户状态列表 | +| `POST` | `/admin/v1/tenant/security/youth-mode/users/{user_id}/reset-pin` | 重置独立密码 | + +### 6.3 反诈与内容审核 + +需求: + +1. 风控策略配置:高风险动作、提示文案、二次确认、阻断策略。 +2. 审核插件配置:平台统一审核服务或租户专属审核服务。 +3. 审核模式配置:`sync_block`、`async_hold`、`async_post_audit`。 +4. 查看风险事件、确认、取消、过期、阻断记录。 +5. 对已发布违规消息执行系统撤回。 + +建议接口: + +| 方法 | 路径 | 说明 | +| --- | --- | --- | +| `GET/PATCH` | `/admin/v1/tenant/security/risk-policy` | 风控策略 | +| `GET/PATCH` | `/admin/v1/tenant/security/moderation-policy` | 内容审核策略 | +| `GET` | `/admin/v1/tenant/security/risk-events` | 风险事件 | +| `GET` | `/admin/v1/tenant/security/moderation-tasks` | 审核任务 | +| `POST` | `/admin/v1/tenant/security/moderation-tasks/{id}/approve` | 审核通过 | +| `POST` | `/admin/v1/tenant/security/moderation-tasks/{id}/reject` | 审核拒绝 | +| `POST` | `/admin/v1/tenant/messages/{message_id}/recall-by-review` | 审核撤回 | + +验收点: + +1. 客户端提交的审核状态一律忽略。 +2. 插件返回 `REPLACE` 或 `RESPOND` 时不能修改 TenantID。 +3. 风控确认 Token 单次、短期、绑定原动作。 + +### 6.4 举报工单 + +需求: + +1. 配置举报原因,按目标类型区分用户、群、消息。 +2. 举报列表、详情、证据、目标快照。 +3. 工单状态:`received`、`processing`、`resolved`、`rejected`、`closed`。 +4. 处置动作:警告、禁言、封禁用户、解散群、撤回消息、标记无效。 +5. 重复举报合并策略。 +6. 处理结果通知举报人,必要时通知被处置对象。 + +建议数据表: + +| 表 | 说明 | +| --- | --- | +| `im_abuse_reports` | 举报工单 | +| `im_evidence_files` | 证据文件 | + +建议接口: + +| 方法 | 路径 | 说明 | +| --- | --- | --- | +| `GET/PATCH` | `/admin/v1/tenant/reports-abuse/reasons` | 举报原因配置 | +| `GET` | `/admin/v1/tenant/reports-abuse` | 举报列表 | +| `GET` | `/admin/v1/tenant/reports-abuse/{id}` | 举报详情 | +| `POST` | `/admin/v1/tenant/reports-abuse/{id}/assign` | 分派 | +| `POST` | `/admin/v1/tenant/reports-abuse/{id}/resolve` | 处置 | +| `POST` | `/admin/v1/tenant/reports-abuse/{id}/close` | 关闭 | + +验收点: + +1. 举报人必须能看到被举报目标,后台详情使用提交时固化快照。 +2. 证据文件使用独立 ACL 和保留期。 +3. 原消息删除或撤回后,举报证据仍可处理。 + +### 6.5 意见反馈工单 + +需求: + +1. 配置反馈类别、最大正文、附件规则、是否允许联系方式、日志授权说明。 +2. 反馈列表、详情、附件、设备和版本信息。 +3. 分派、回复、关闭、标记重复。 +4. 处理结果通过系统通知或客户端查询展示。 + +建议数据表: + +| 表 | 说明 | +| --- | --- | +| `im_feedbacks` | 反馈工单 | +| `im_evidence_files` | 反馈附件 | + +建议接口: + +| 方法 | 路径 | 说明 | +| --- | --- | --- | +| `GET/PATCH` | `/admin/v1/tenant/feedback/config` | 反馈配置 | +| `GET` | `/admin/v1/tenant/feedbacks` | 反馈列表 | +| `GET` | `/admin/v1/tenant/feedbacks/{id}` | 反馈详情 | +| `POST` | `/admin/v1/tenant/feedbacks/{id}/assign` | 分派 | +| `POST` | `/admin/v1/tenant/feedbacks/{id}/reply` | 回复 | +| `POST` | `/admin/v1/tenant/feedbacks/{id}/close` | 关闭 | + +## 7. 文件、搜索与数据治理后台 + +### 7.1 文件治理 + +需求: + +1. 按租户查看文件用量、文件列表、状态和业务用途。 +2. 支持群文件、收藏文件、举报证据、反馈附件、日报/月报附件分类管理。 +3. 文件下载审计。 +4. 清理未关联文件、过期文件和违规文件。 +5. 配置文件大小、MIME、总存储配额、保留期。 + +文件状态建议: + +```text +pending -> uploaded -> ready +pending/uploaded -> failed +uploaded(超时且无引用) -> orphaned -> deleted +``` + +建议接口: + +| 方法 | 路径 | 说明 | +| --- | --- | --- | +| `GET` | `/admin/v1/tenant/files` | 文件列表 | +| `GET` | `/admin/v1/tenant/files/{file_id}` | 文件详情 | +| `POST` | `/admin/v1/tenant/files/{file_id}/quarantine` | 隔离文件 | +| `DELETE` | `/admin/v1/tenant/files/{file_id}` | 删除文件 | +| `GET/PATCH` | `/admin/v1/tenant/files/policy` | 文件策略 | + +验收点: + +1. 文件下载不能只靠 FileID,必须校验上传者或关联消息/Topic 读取权限。 +2. 后台删除文件必须审计,并处理消息、收藏、举报等引用。 + +### 7.2 搜索治理 + +需求: + +1. 消息索引状态查看。 +2. 重建指定租户、Topic 或时间段索引。 +3. 搜索敏感内容审计。 +4. 查看搜索性能和失败任务。 + +建议接口: + +| 方法 | 路径 | 说明 | +| --- | --- | --- | +| `GET` | `/admin/v1/tenant/search/index-status` | 索引状态 | +| `POST` | `/admin/v1/tenant/search/reindex` | 重建索引 | +| `GET` | `/admin/v1/tenant/search/audit-logs` | 搜索审计 | + +验收点: + +1. 搜索索引不是授权来源,返回前仍需 Topic Read ACL 二次校验。 +2. 用户退群或权限变化后必须立即阻断搜索结果。 + +## 8. 通知、Push 与外部服务后台 + +### 8.1 系统消息模板 + +需求: + +1. 管理系统事件类型和模板版本。 +2. 配置账号安全、好友、群、文件、通话、工作台、安全等通知模板。 +3. Outbox 事件驱动系统消息、Push、索引和定时任务。 + +首期事件: + +| 业务 | 事件 | +| --- | --- | +| 账号安全 | 新设备登录、密码修改、实名提醒 | +| 好友 | 新申请、通过、拒绝、删除 | +| 群 | 邀请、申请、审批、公告、禁言、管理员变更、群主转让、解散 | +| 文件 | 群文件上传、文件失效 | +| 通话 | 未接、拒绝、取消、忙线、通话结束 | +| 工作台 | 会议通知、会议变更、日报提醒、月报提醒、信箱处理 | +| 安全 | 反诈提示、举报受理、反馈受理 | + +### 8.2 Push、短信、TURN、对象存储 + +需求: + +1. 租户级 Push 模板、App 包名、APNS/FCM 凭证引用。 +2. 短信服务配置和模板。 +3. TURN 配置。 +4. 对象存储 Bucket、Prefix、签名策略。 +5. 外部 REST 认证服务配置。 +6. 外部审核和风控插件配置。 + +验收点: + +1. 租户没有配置时按明确策略失败,不得静默使用其他租户配置。 +2. 密钥只保存引用,不把明文 Secret 放入普通业务 JSON。 +3. 外部回调必须校验服务端签名的 TenantContext 和 nonce。 + +## 9. 审计与日志后台 + +### 9.1 审计范围 + +以下操作必须写审计: + +1. 租户创建、停用、恢复、删除。 +2. 平台管理员和租户管理员授权、移除、代操作。 +3. 用户停用、离职、密码重置、数据导出。 +4. 群主转让、管理员变更、禁言、解散群。 +5. 实名审核、举报处置、反馈处理、匿名信箱读取。 +6. 文件下载、删除、批量导出、搜索敏感内容。 +7. 反诈二次确认、内容审核处置。 +8. 外部服务配置修改。 + +独立库模式下审计存储分为两类: + +1. 平台级审计存控制库,覆盖租户生命周期、平台管理员、跨租户运维、租户业务库创建/删除/导出等操作。 +2. 租户级业务审计优先存对应租户业务库,覆盖用户、群、文件、实名、举报、反馈、办公协同等操作;后台查询时通过 `tenant_id` 选择租户业务库。 + +平台后台可汇总查询租户级审计,但跨租户聚合、导出和代操作必须记录平台级审计。 + +### 9.2 审计字段 + +| 字段 | 说明 | +| --- | --- | +| `tenant_id` | 租户 | +| `actor_uid` | 操作人 | +| `actor_role` | 操作角色 | +| `action` | 操作类型 | +| `target_type` | 目标类型 | +| `target_id` | 目标 ID | +| `before_snapshot` | 变更前摘要 | +| `after_snapshot` | 变更后摘要 | +| `reason` | 操作原因 | +| `request_id` | 请求 ID | +| `ip` | 操作 IP,必要时脱敏 | +| `user_agent` | 操作客户端 | +| `created_at` | 操作时间 | + +审计表只允许追加,不允许普通管理员修改或删除。 + +建议接口: + +| 方法 | 路径 | 说明 | +| --- | --- | --- | +| `GET` | `/admin/v1/tenant/audit-logs` | 租户审计 | +| `GET` | `/admin/v1/audit-logs` | 平台审计 | +| `POST` | `/admin/v1/audit-logs/export` | 审计导出 | + +## 10. 后台公共能力 + +### 10.1 认证与会话 + +1. 后台登录必须使用独立后台入口,不与普通 App 登录混用权限。 +2. 后台 Token 包含 `tenant_id`、`uid`、角色、权限版本、`auth_version`。 +3. 支持刷新、撤销、踢下线、登录设备管理。 +4. 高危操作需要二次认证或 `X-Action-Token`。 + +### 10.2 API 规范 + +后台 API 建议统一使用 `/admin/v1/*`。该路径是 `im_server` 管理 API 的目标契约;在 HotGo 中落地页面和接口时,可以按 HotGo 现有 `/admin/...` 路由规范做适配,但必须保持权限语义、请求字段、幂等、审计和错误码一致。 + +成功响应: + +```json +{ + "code": 0, + "data": {}, + "request_id": "01K...", + "server_time": "2026-08-12T05:30:00.000Z" +} +``` + +失败响应: + +```json +{ + "code": 30001, + "reason": "FORBIDDEN", + "message": "permission denied", + "details": {}, + "request_id": "01K...", + "server_time": "2026-08-12T05:30:00.000Z" +} +``` + +公共要求: + +1. 写操作必须带 `Idempotency-Key`。 +2. 更新操作必须带版本号或 `If-Match`。 +3. 列表统一使用 `limit + cursor`。 +4. 请求必须带 `X-Request-ID`。 +5. 不能从请求 Body、URL Query 或普通 Header 信任客户端自报 `tenant_id`。 +6. 手机号、证件号、验证码、Token、设备 Token、文件签名不写日志。 + +### 10.3 数据表公共规则 + +1. 新业务表统一使用 `im_` 前缀。 +2. 控制库只保存平台级租户元数据、平台级审计、平台任务和全局配置,例如 `im_tenant`、`im_tenant_config`。 +3. 租户业务库保存租户内业务表,例如用户安全、好友、群治理、收藏、实名、举报、反馈、组织、会议、日报月报等;这些表不包含 `tenant_id` 字段。 +4. 租户业务表唯一索引按租户库内业务键设计,例如 `business_key`、`user_id + peer_id`、`topic + seqid`,不使用 `(tenant_id, business_key)`。 +5. 子表外键按租户库内父子关系设计,不使用 `(tenant_id, parent_id)` 复合外键。 +6. 更新、删除、聚合、清理任务必须先从可信 Principal 或平台运维上下文取得 `tenant_id`,再路由到对应租户业务库执行。 +7. 禁止通过 URL、Header、Body 传入的 `db_name`、`db_dsn` 或 `tenant_id` 决定业务库;业务库路由只能来自控制库和服务端可信上下文。 + +### 10.4 Outbox 与后台任务存储 + +Outbox 和后台任务同样按作用域拆分: + +1. 平台级 Outbox 存控制库,用于租户创建、租户停用、建库初始化、跨租户导出、平台通知等。 +2. 租户级 Outbox 存租户业务库,用于好友、群、文件、实名、举报、反馈、会议、日报月报等业务事件。 +3. 后台查询 Outbox 时,平台后台可以先按租户筛选,再路由到对应业务库;企业后台只能查询当前租户业务库内事件。 +4. 重试、死信和人工重放必须幂等,并写入对应作用域的审计。 + +## 11. 开发优先级建议 + +### P0:后台地基 + +1. `/admin/v1` 认证中间件、RBAC、租户 Principal。 +2. 平台租户管理:创建、停用、恢复、配置配额。 +3. 租户业务库 provisioning:建库、执行 schema、初始化系统数据、回写 `db_name/db_dsn`、失败重试。 +4. `im_server` 管理 API 对接和租户选库校验。 +5. 企业管理员与角色权限。 +6. 用户管理:停用、重置密码、撤销 Session。 +7. 审计日志基础表和查询。 +8. Outbox 查询与重试基础能力。 + +### P1:企业治理 + +1. 组织架构管理。 +2. 群治理后台:群列表、群成员、管理员、禁言、解散、群文件。 +3. 文件策略、文件列表、文件审计。 +4. 外部服务配置:Push、短信、对象存储、TURN。 +5. 功能开关和配额用量面板。 + +### P2:办公与安全业务 + +1. 工作台配置。 +2. 会议管理。 +3. 日报、月报统计和提醒。 +4. 总经理信箱。 +5. 实名认证审核。 +6. 青少年策略。 +7. 反诈、内容审核、举报、反馈工单。 + +## 12. 验收清单 + +- [ ] 平台后台与企业后台权限边界清晰,跨租户操作必须审计。 +- [ ] 所有后台列表、详情、更新、删除都按租户隔离。 +- [ ] 租户停用后普通用户登录和 HTTP 产品接口拒绝。 +- [ ] 用户停用、重置密码、撤销 Token 不影响其他租户同手机号用户。 +- [ ] 创建用户、建群、入群审批配额在事务内强制执行。 +- [ ] 群主转让、禁言、管理员变更、解散群写审计并同步系统消息。 +- [ ] 实名、举报、反馈、信箱等敏感数据脱敏展示并按权限读取。 +- [ ] 文件下载、删除、导出均有资源级授权和审计。 +- [ ] 外部服务密钥只保存引用,不写普通日志。 +- [ ] Outbox 事件可查询、重试、死信处理,重复执行不产生重复副作用。 +- [ ] 审计表只允许追加,普通管理员不可修改或删除。 diff --git a/docs/im/IM-产品原型需求文档.md b/docs/im/IM-产品原型需求文档.md new file mode 100644 index 0000000..22a7c7c --- /dev/null +++ b/docs/im/IM-产品原型需求文档.md @@ -0,0 +1,1287 @@ +# IM 产品原型需求文档 + +> 本文档根据 `/Users/superysh/im/第二项目/` 中的 156 张产品原型图整理。功能按业务域归类,每张图以原文件名作为标题,图片下方给出功能说明与界面内容描述。 + +## 产品总体定位 + +该产品是一套面向企业内部沟通与协作的即时通讯软件。核心能力包括账号与身份认证、单聊与群聊、通讯录、文件与收藏、音视频通话、企业工作台、日报月报、安全风险提示以及个人设置。系统需要同时兼顾 iOS 和 Android 端的交互差异,并保证消息、权限、文件和通话状态可在多端间一致同步。 + +## 1. 账号注册、登录与身份安全 + +本组功能覆盖企业入口、账号密码登录、短信验证码注册、找回密码、身份校验、实名认证与独立密码。需求重点是明确的输入校验、验证码倒计时、错误反馈、协议确认与认证状态展示。 + +### 1.1 企业码登录.png + +企业码登录 + +功能说明: + +提供企业租户入口。页面展示产品标识、企业码输入框和“进入企业”按钮;提交后应校验企业码是否存在、是否启用,并将用户导向对应企业的登录环境。 + +### 1.2 登录页面.png + +登录页面 + +功能说明: + +展示用户名/手机号和密码登录表单,支持密码显隐、忘记密码、注册跳转以及用户协议/隐私政策确认。登录按钮在必填信息和协议条件满足后才可有效提交。 + +### 1.3 登录页面-密码错误.png + +登录页面-密码错误 + +功能说明: + +表现登录失败状态。当账号或密码错误时,在表单上方以 Toast 或明确错误文案告知“账号或密码错误”,保留当前账号并允许用户重新输入密码。 + +### 1.4 注册页面.png + +注册页面 + +功能说明: + +提供用户名/手机号、短信验证码、密码和确认密码输入。需支持获取验证码、密码显隐、两次密码一致性校验、已有账号返回登录以及协议勾选。 + +### 1.5 注册页面-倒计时.png + +注册页面-倒计时 + +功能说明: + +展示验证码发送后的倒计时状态。倒计时期间禁止重复发送,归零后恢复“获取验证码”;后端需同时执行发送频控和验证码有效期校验。 + +### 1.6 忘记密码.png + +忘记密码 + +功能说明: + +通过手机号、验证码、新密码和再次确认完成密码重置。应校验账号归属、验证码正确性及新密码规则,成功后应废弃旧令牌并引导用户重新登录。 + +### 1.7 身份验证(安卓).png + +身份验证(安卓) + +功能说明: + +用于敏感操作前的二次身份校验。用户输入手机号和短信验证码后确认;应对获取验证码、校验失败、超时和重试状态给出明确反馈。 + +### 1.8 实名认证.png + +实名认证 + +功能说明: + +提供姓名与证件号码输入,用于提交实名认证。页面需明确说明信息用途和隐私保护,提交前校验姓名及证件号格式,并防止重复提交。 + +### 1.9 实名认证-已实名.png + +实名认证-已实名 + +功能说明: + +展示认证通过后的只读状态,姓名和证件号做脱敏处理,提交按钮置灰并显示“已实名”。如支持更正信息,应进入独立的申诉审核流程,不允许在本页直接修改。 + +### 1.10 实名认证(安卓).png + +实名认证(安卓) + +功能说明: + +安卓端在消息首页上以弹窗提醒用户完成实名认证。弹窗应说明认证原因,提供“去实名认证”主操作,并根据平台策略控制是否允许暂后处理。 + +### 1.11 设置独立密码(安卓).png + +设置独立密码(安卓) + +功能说明: + +提供四位独立密码输入框,用于青少年模式或隐私功能的二次保护。应支持连续输入、错误次数限制、忘记密码处理和安全存储,不得以明文保存。 + +## 2. 消息首页、全局导航与系统通知 + +本组功能提供即时通讯的全局入口,包括会话列表、网络异常、快捷菜单、长按操作和系统消息。用户应能快速识别未读数、最新消息、联系人在线状态以及当前网络状态。 + +### 2.1 消息.png + +消息 + +功能说明: + +会话首页包含最近联系人、系统消息、文件助手和普通会话列表。每个会话项展示头像、名称、最新摘要、时间和未读数;页面提供搜索和右上角快捷功能入口。 + +### 2.2 消息-无网络状态.png + +消息-无网络状态 + +功能说明: + +当网络断开或重连时,顶部显示“加载中”及进度状态,会话缓存内容仍可查看。系统应自动重连,恢复后增量同步消息、未读数和回执,不应造成重复消息。 + +### 2.3 消息-更多.png + +消息-更多 + +功能说明: + +右上角菜单提供“朋友”、“我的”、“创建群聊”、“添加好友”和“扫一扫”等快捷入口。菜单应与触发图标对齐,点击外部区域关闭,并根据用户权限显示可用项。 + +### 2.4 消息页面—长按弹出.png + +消息页面—长按弹出 + +功能说明: + +长按消息后弹出操作菜单,包括引用、复制、收藏、编辑、转发、撤回、删除、多选和群回执等操作。菜单项需根据消息类型、发送人、撤回时限和群权限动态裁剪。 + +### 2.5 系统消息.png + +系统消息 + +功能说明: + +系统消息页按时间展示新设备登录等安全通知,包含设备、IP 地址、时间和保障建议。此类消息应由可信系统账号发送,支持跳转安全设置,且不允许普通用户伪造。 + +## 3. 单聊会话、会话建立与消息状态 + +本组功能覆盖好友申请前后的单聊页、双方消息显示、已读/未读、删除、拉黑以及文件与名片消息。界面需能清楚区分“我的消息”与“对方消息”,并将发送、失败、已读、撤回等状态准确反映到消息气泡。 + +### 3.1 个人对话框(对方的状态).png + +个人对话框(对方的状态) + +功能说明: + +展示对方发来的好友申请或首条消息,消息位于左侧并显示对方头像。页面应在尚未建立可聊权限时限制消息输入,并展示申请或通过状态。 + +### 3.2 个人对话框(对方的状态)(1).png + +个人对话框(对方的状态)(1) + +功能说明: + +展示对方在会话中的状态文案与单条对方消息。会话头部需展示昵称和在线状态,消息时间轴需保留系统提示,用于表达好友关系变化。 + +### 3.3 个人对话框(我的状态).png + +个人对话框(我的状态) + +功能说明: + +展示当前用户发出的好友申请或首条消息,气泡位于右侧并配置本人头像。发送成功后应展示业务状态文案,避免用户在等待审批时重复发起。 + +### 3.4 个人对话框(我的状态)(1).png + +个人对话框(我的状态)(1) + +功能说明: + +表现我方已发送单条消息及其回执状态。消息气泡应在发送中、已发送、已送达、已读和失败之间平滑更新,且不因状态文字改变气泡主体位置。 + +### 3.5 开启沟通页面.png + +开启沟通页面 + +功能说明: + +展示好友关系建立后的沟通开始页,包含系统提示“已成为好友”及首条正常消息。审批通过后应自动解锁输入区和媒体发送能力,同时保留申请上下文。 + +### 3.6 双方沟通状态.png + +双方沟通状态 + +功能说明: + +展示双方在会话中发送语音、图片或视频消息时的状态,包含加载/上传进度与时长。媒体消息应使用稳定占位区,上传失败时支持重试,成功后可直接预览或播放。 + +### 3.7 个人对话—拉黑状态/已读状态.png + +个人对话—拉黑状态/已读状态 + +功能说明: + +展示消息已读回执以及因拉黑导致的发送失败提示。被拉黑后的消息应标记红色失败图标,不进入对方会话;界面需为用户提供可理解的失败原因。 + +### 3.8 个人对话—删除状态.png + +个人对话—删除状态 + +功能说明: + +表现删除好友或关系解除后的发送失败。系统应阻止继续发送,将已失败消息保留为本地状态,并提示需重新添加好友或发起验证。 + +### 3.9 个人对话-键盘收起状态.png + +个人对话-键盘收起状态 + +功能说明: + +展示输入键盘和附加面板都收起时的标准单聊布局。底部保留语音、文本输入、表情和更多功能入口,且页面高度变化不应影响用户当前阅读位置。 + +### 3.10 个人对话-发各种文件状态.png + +个人对话-发各种文件状态 + +功能说明: + +展示图片、视频、音频和文件附加面板。用户选择类型后进入对应系统选择器或采集界面,应在上传前校验格式、大小和会话发送权限。 + +### 3.11 个人对话-发各种文件状态(1).png + +个人对话-发各种文件状态(1) + +功能说明: + +展示文本、名片和文件消息混合存在的会话效果。消息列表应根据消息类型使用不同卡片,但统一时间、头像、发送状态和长按操作规则。 + +### 3.12 个人对话-发送名片状态.png + +个人对话-发送名片状态 + +功能说明: + +展示从通讯录选择用户并发送名片的结果。名片卡需包含头像、昵称和可识别账号信息,点击后进入用户主页;敏感字段不应在名片中直接暴露。 + +## 4. 消息输入、媒体内容与消息操作 + +本组功能覆盖键盘、表情、语音录制、图片/视频/文件发送、@ 提醒、多选、收藏和转发。发送链路需具备本地占位、上传进度、失败重试、消息去重和多端一致性。 + +### 4.1 键盘展开.png + +键盘展开 + +功能说明: + +展示文本输入框获得焦点后的系统键盘状态。键盘弹出时消息列表应上推并保持最新消息可见,输入草稿在切换会话或应用前后台时应保留。 + +### 4.2 @人员—选择提醒人.png + +@人员—选择提醒人 + +功能说明: + +在群聊中输入 `@` 后打开成员选择页,提供“所有人”、成员列表、搜索和多选入口。列表需按拼音/字母分组,并根据群规则控制普通成员是否可使用 `@所有人`。 + +### 4.3 @人员—完成.png + +@人员—完成 + +功能说明: + +展示多个提醒人已勾选的状态,右上角“完成”提交选择。完成后应将昵称插入输入框,并在消息结构中同步保存被提醒用户 ID,避免仅依赖昵称匹配。 + +### 4.4 @人员状态.png + +@人员状态 + +功能说明: + +展示群聊中已发送的 `@`消息,输入框上方可展示已选的提醒人。被提醒用户应获得特殊未读标记和推送文案,退群成员的历史提醒仍需正常渲染。 + +### 4.5 表情包展开(发送按钮默认状态).png + +表情包展开(发送按钮默认状态) + +功能说明: + +表情面板展开但未选中内容时,发送按钮保持禁用状态。面板应支持表情分类、最近使用、删除键和文本输入并存,切换面板不应清空未发送内容。 + +### 4.6 表情包发送页面.png + +表情包发送页面 + +功能说明: + +展示表情面板与消息列表联动状态。用户选中表情后“发送”按钮高亮,点击可作为文本表情或独立表情消息发送,消息应支持多端一致显示。 + +### 4.7 语音—按住说话.png + +语音—按住说话 + +功能说明: + +切换至语音输入后,底部显示“按住说话”按钮。按下开始录音,松开发送,上滑取消;应处理麦克风权限被拒绝、录音太短、超时和来电打断。 + +### 4.8 语音按住说话.png + +语音按住说话 + +功能说明: + +展示按住录音时的浮层反馈,包含麦克风图标和“手指上滑,取消发送”指引。浮层应实时反映音量幅度,进入取消区时更换颜色和文案。 + +### 4.9 内容沟通对话3-语音录音中.png + +内容沟通对话3-语音录音中 + +功能说明: + +在群聊背景中展示语音正在录制的中央浮层。录制期间应阻止重复录音或发送其他媒体,并向话题成员发送可选的“正在录音”临时状态。 + +### 4.10 内容沟通对话3-图片和语音发送中.png + +内容沟通对话3-图片和语音发送中 + +功能说明: + +展示图片与语音消息上传中的加载指示。每条消息应独立维护上传进度,并允许多个上传任务有序执行;离开会话后任务仍应可恢复或给出失败提示。 + +### 4.11 内容沟通对话3-语音/视频/图片状态.png + +内容沟通对话3-语音/视频/图片状态 + +功能说明: + +展示群聊中语音时长卡片、视频缩略图和图片预览等多媒体消息。语音支持播放进度与未听标记,视频支持封面和播放按钮,图片支持大图预览。 + +### 4.12 语音图片发送状态.png + +语音图片发送状态 + +功能说明: + +表现单条图片正在上传、同时存在语音消息的会话状态。本地缩略图应立即显示,上传完成后无闪烁替换为远程资源,失败时提供点击重试。 + +### 4.13 上传文件.png + +上传文件 + +功能说明: + +该图为“上传”图标素材。系统在文件助手、群文件和意见反馈等入口应使用统一的上传语义和视觉图标,并配套可访问性文本。 + +### 4.14 文件发送状态.png + +文件发送状态 + +功能说明: + +会话中的文件卡片展示文件名、扩展类型图标、大小和发送状态。多个文件可连续发送,各自维护上传/下载状态;点击卡片进入文件详情或预览。 + +### 4.15 多选-选中对话框.png + +多选-选中对话框 + +功能说明: + +进入消息多选模式后,每条消息左侧显示选择框,底部显示转发、收藏、删除等批量操作。应支持跨屏滚动选择、显示已选数量,并根据内容类型校验批量操作可用性。 + +### 4.16 多选-选中对话内容-收藏成功.png + +多选-选中对话内容-收藏成功 + +功能说明: + +批量收藏成功后以 Toast 明确反馈,并保留当前多选上下文或按交互约定退出多选。后端需防止同一条消息被重复收藏,并保留原会话、发送人和时间引用。 + +### 4.17 转发-单选.png + +转发-单选 + +功能说明: + +转发目标页默认支持单选最近会话,包含搜索、文件助手和联系人列表。选中目标后直接进入发送确认,应避免将原消息的私有权限或失效附件错误带入新会话。 + +### 4.18 转发-多选.png + +转发-多选 + +功能说明: + +切换到多选后可同时选择多个会话目标,页面以复选框显示选中状态。应限制一次转发的最大目标数,并在发送前展示目标汇总与风险确认。 + +### 4.19 转发—逐条转发.png + +转发—逐条转发 + +功能说明: + +逐条转发将选中的每条消息作为独立消息发送。底部确认面板展示接收人、附加留言和发送按钮;原始顺序、文件引用和消息类型需保持不变。 + +### 4.20 转发—合并转发—多选状态.png + +转发—合并转发—多选状态 + +功能说明: + +合并转发将多条消息封装成一张聊天记录卡片。确认面板展示多个接收人和留言输入;合并内容需保留发送人、时间和内容摘要,同时遵循删除和敏感信息脱敏规则。 + +## 5. 通讯录、好友关系与黑名单 + +本组功能覆盖组织通讯录、新好友、精准查找、二维码添加、申请验证、好友主页、备注、拉黑、删除和黑名单管理。好友关系应具备明确的申请、同意、拒绝、删除和拉黑状态机。 + +### 5.1 通讯录.png + +通讯录 + +功能说明: + +通讯录页提供搜索、新朋友、群聊、文件助手、系统消息和按字母分组的好友列表。应支持字母索引快速定位、备注名优先显示和企业组织成员同步。 + +### 5.2 新朋友.png + +新朋友 + +功能说明: + +新朋友列表展示申请人头像、昵称、验证留言、时间和状态,可执行“拒绝”或“通过验证”。已处理申请应显示已通过/已拒绝,后续刷新不得重复处理。 + +### 5.3 添加好友.png + +添加好友 + +功能说明: + +添加好友首页提供账号搜索、扫一扫和分享好友入口,同时可查看本人二维码。需对搜索可见性执行用户隐私设置,不可搜索的用户应返回统一的隐私保护结果。 + +### 5.4 添加好友(1).png + +添加好友(1) + +功能说明: + +展示个人二维码名片,包含头像、昵称、账号 ID、二维码、“分享好友”和“保存到相册”。二维码应包含带签名或有效期的用户标识,防止被篡改或长期滥用。 + +### 5.5 添加好友-输入精准用户ID.png + +添加好友-输入精准用户ID + +功能说明: + +输入精确手机号或用户 ID 后返回匹配用户,列表显示头像、昵称和“添加”按钮。应区分未找到、已是好友、已发送申请和不允许被搜索等结果。 + +### 5.6 添加好友验证.png + +添加好友验证 + +功能说明: + +在目标用户主页上弹出验证留言输入框,支持最多 50 字并提供取消/发送操作。验证消息应同申请状态一起保存,发送成功后按钮转为“已申请”。 + +### 5.7 好友个人主页.png + +好友个人主页 + +功能说明: + +好友主页展示头像、昵称、性别、地区、用户 ID、二维码和个性签名,提供发消息和音视频通话入口。展示字段需受隐私设置控制,备注名优先于原昵称显示。 + +### 5.8 群聊—非好友个人主页.png + +群聊—非好友个人主页 + +功能说明: + +从群成员列表进入非好友主页,显示可公开的基本资料和“添加好友”按钮。不应展示手机号等私密数据;如群策略或用户隐私禁止通过群添加,则隐藏或禁用该按钮。 + +### 5.9 好友—聊天设置—未拉黑状态.png + +好友—聊天设置—未拉黑状态 + +功能说明: + +单聊设置包含查看聊天记录、消息保存方式、置顶、清空记录、备注、拉黑、投诉/举报和删除好友。未拉黑时开关关闭,删除好友以红色危险操作呈现。 + +### 5.10 好友—聊天设置—拉黑状态.png + +好友—聊天设置—拉黑状态 + +功能说明: + +拉黑开关开启后,对方不得继续向当前用户发送消息或发起通话。拉黑状态需在多端同步,取消拉黑时恢复通信权限,但不自动恢复已删除的好友关系。 + +### 5.11 删除好友提示.png + +删除好友提示 + +功能说明: + +执行删除好友前弹出底部确认面板。该操作将解除好友关系并影响后续消息权限,应使用清晰的危险文案、取消和确认按钮;是否保留本地聊天记录需明确约定。 + +### 5.12 黑名单.png + +黑名单 + +功能说明: + +黑名单页展示已拉黑用户的头像、昵称和“移出”按钮。支持分页/滚动加载与批量解除;从黑名单移出后应即时同步聊天设置开关。 + +### 5.13 黑名单提示(1).png + +黑名单提示(1) + +功能说明: + +开启拉黑时弹出确认对话框,说明将不再收到对方消息。确认后更新黑名单和会话 ACL,取消则不改变任何状态;重复操作应具备幂等性。 + +### 5.14 黑名单提示.png + +黑名单提示 + +功能说明: + +原图文案为“清除聊天记录”确认,属于聊天设置的危险操作弹窗。确认后应仅清除当前用户可见的记录,不影响对方历史;文件名与画面语义不一致,实施时应以画面交互为准。 + +## 6. 群聊创建、资料维护与成员治理 + +### 6.1 群聊.png + +群聊 + +功能说明: + +群聊列表按“我创建的群、我管理的群、我加入的群”分类展示,列表项包含群头像、群名称及成员数量。页面提供搜索与创建群聊入口,切换分类时应保留各自的列表状态。 + +### 6.2 空白页.png + +空白页 + +功能说明: + +当所选群聊分类没有数据时展示统一空状态,避免出现无内容的纯白页面。空状态应与当前分类绑定,后续加入或创建群聊后自动刷新。 + +### 6.3 加入群聊.png + +加入群聊 + +功能说明: + +用户收到群邀请时,会话内展示包含群头像、群名称和邀请人的群聊卡片,并提供“立即加入”操作。加入前应校验群状态、邀请有效期及是否需要管理员审核,处理完成后更新卡片状态。 + +### 6.4 内容沟通对话3-开启进群验证与拉人状态.png + +内容沟通对话3-开启进群验证与拉人状态 + +功能说明: + +开启进群验证后,群聊以系统消息展示成员邀请、申请通过和加入群聊等事件。系统消息需明确操作者、目标成员与处理结果,并保证所有群成员看到一致的时间线。 + +### 6.5 邀请进群申请.png + +邀请进群申请 + +功能说明: + +进群申请列表展示申请人、邀请人、申请时间和当前状态,管理员可执行“拒绝”或“确定”。处理后按钮应切换为已同意/已拒绝等只读状态,并防止重复审批。 + +### 6.6 新邀请进群申请.png + +新邀请进群申请 + +功能说明: + +群聊顶部展示新的进群申请提醒,便于管理员从会话直接进入审批列表;聊天区同时保留申请处理结果。提醒数量应随审批操作实时更新,全部处理后自动消失。 + +### 6.7 群公告状态.png + +群公告状态 + +功能说明: + +群聊顶部固定展示最新群公告摘要,点击可查看完整内容。新公告发布时会话中同步生成系统提示,并记录发布人和时间;公告更新后顶部摘要应立即替换。 + +### 6.8 群公告状态/邀请进群申请同时存在.png + +群公告状态/邀请进群申请同时存在 + +功能说明: + +群公告和进群申请同时存在时,两个通知入口需要在群聊顶部有序展示,且不遮挡聊天内容。进群申请处理后只移除对应提醒,群公告继续保留。 + +### 6.9 群公告(对方查看状态).png + +群公告(对方查看状态) + +功能说明: + +普通群成员以只读形式查看群公告详情,页面展示公告正文、发布人和发布时间。成员无编辑入口,返回后可记录已读状态,用于控制会话中的新公告提示。 + +### 6.10 群聊设置.png + +群聊设置 + +功能说明: + +群聊设置集中提供成员查看与邀请、群资料、群公告、聊天记录、群文件、置顶、消息免打扰、清空记录、举报和退出群聊等功能。菜单权限应依据群主、管理员和普通成员身份动态展示。 + +### 6.11 群资料.png + +群资料 + +功能说明: + +群资料页展示并维护群头像、群名称和群简介。可编辑项需受角色权限控制,修改成功后应同步更新群列表、会话标题和群二维码等关联页面。 + +### 6.12 设置群头像.png + +设置群头像 + +功能说明: + +修改群头像时从底部弹出“从相册选择”和“拍照”两种来源。选择或拍摄后应支持裁剪、上传进度、失败重试和格式/大小校验,取消操作不保留临时图片。 + +### 6.13 设置昵称(1).png + +设置昵称(1) + +功能说明: + +该页面用于设置用户在当前群内的昵称,提供文本输入、字数计数和完成按钮。群昵称只影响当前群的成员列表和消息发送者名称,不应修改账号全局昵称。 + +### 6.14 群简介.png + +群简介 + +功能说明: + +群简介采用多行文本编辑并展示最大字数限制。保存前需过滤全空白内容、校验长度和敏感内容,保存成功后返回群资料页并即时刷新。 + +### 6.15 群标识.png + +群标识 + +功能说明: + +群聊消息发送者昵称旁展示“群主”“管理员”等身份标识,帮助成员识别管理角色。标识应随角色变更实时更新,并与普通消息文字保持清晰区分。 + +### 6.16 群二维码页面.png + +群二维码页面 + +功能说明: + +群二维码页以卡片形式展示群头像、群名称和可扫码加入的二维码,并提供分享或保存入口。二维码应包含有效期与群状态校验,过期或群已解散时不可继续入群。 + +### 6.17 群组管理.png + +群组管理 + +功能说明: + +群组管理包含进群验证、全员禁言、群文件上传权限、成员私聊权限、管理员设置、邀请管理、转让群主和解散群聊等配置。危险操作需二次确认,开关变化要记录操作者并同步到服务端。 + +### 6.18 设置群管理员.png + +设置群管理员 + +功能说明: + +页面说明管理员可协助管理群聊,并展示现有管理员列表及添加入口。群主可新增或移除管理员,系统应限制管理员数量且不得将群主本人作为普通管理员处理。 + +### 6.19 添加管理员.png + +添加管理员 + +功能说明: + +从群成员中搜索并勾选要添加的管理员,底部/顶部操作区显示已选数量并提交。已是管理员、群主或不具备资格的成员应禁选,提交前需再次校验权限和数量上限。 + +### 6.20 添加管理员-设置成功.png + +添加管理员-设置成功 + +功能说明: + +管理员添加成功后返回管理员列表并展示成功提示,新管理员立即出现在列表中。角色变更应同步影响群聊身份标识和管理菜单权限。 + +### 6.21 添加管理员-设置成功(1).png + +添加管理员-设置成功(1) + +功能说明: + +该状态展示从成员选择页完成管理员设置后的结果反馈。提交期间应防止重复点击;部分成员设置失败时需给出明确原因,并仅回滚失败对象。 + +### 6.22 删除管理员-设置成功.png + +删除管理员-设置成功 + +功能说明: + +群主移除管理员后,页面展示设置成功提示并刷新管理员列表。被移除成员应立即失去管理权限,但仍保留其普通群成员身份。 + +### 6.23 添加禁言成员.png + +添加禁言成员 + +功能说明: + +管理员可搜索并选择需要禁言的群成员,已禁言成员应禁选或明确标识。提交后服务端记录禁言状态,目标成员发送消息时需收到清晰的禁言提示。 + +### 6.24 禁言的群成员.png + +禁言的群成员 + +功能说明: + +页面集中展示当前被禁言的成员,支持新增和解除禁言。列表需反映最新成员状态,成员退群或群角色变化时应同步清理无效记录。 + +### 6.25 选择新群主.png + +选择新群主 + +功能说明: + +转让群主时从群成员列表中搜索并单选新群主。当前群主、已退出或受限账号不可选择,确认前需保证目标成员仍在群内且账号状态正常。 + +### 6.26 选择新群主提示.png + +选择新群主提示 + +功能说明: + +选定新群主后弹出二次确认,明确转让对象及操作不可随意撤销。确认成功后双方权限即时互换,并在群聊中生成可追溯的系统消息。 + +### 6.27 群文件.png + +群文件 + +功能说明: + +群文件页展示群内共享文件,可按名称搜索,并显示文件类型、名称、大小、上传者、时间及下载状态。文件可见性和删除/上传权限应遵循群组管理配置。 + +### 6.28 群文件-上传状态.png + +群文件-上传状态 + +功能说明: + +点击上传后提供文件、照片和视频等来源,选择完成后显示上传进度与结果。需限制单文件大小和格式,支持失败重试,并在上传成功后自动刷新群文件列表。 + +## 7. 文件下载、收藏管理与聊天记录检索 + +### 7.1 我的收藏.png + +我的收藏 + +功能说明: + +我的收藏汇总用户保存的聊天内容,支持按关键词搜索,并按全部、文本、图片、视频、语音、文件、链接等类型筛选。收藏项需展示来源会话、收藏时间和内容摘要。 + +### 7.2 我的收藏(1).png + +我的收藏(1) + +功能说明: + +该状态展示收藏内容列表及多种内容形态,用户可进入详情或对收藏项执行转发、删除等操作。原消息被删除后,已成功收藏的内容仍应按收藏数据策略可访问。 + +### 7.3 我的收藏-文件.png + +我的收藏-文件 + +功能说明: + +文件分类仅展示已收藏的文件,列表包含文件名、类型、大小、来源和时间。点击后进入文件预览或下载页,失效文件需展示明确状态而非直接报错。 + +### 7.4 我的收藏-文本.png + +我的收藏-文本 + +功能说明: + +文本分类展示收藏的文字消息及其来源信息,支持长文本折叠、展开和复制。搜索结果应对匹配关键词进行高亮,并可跳转到收藏详情。 + +### 7.5 我的收藏-语音.png + +我的收藏-语音 + +功能说明: + +语音分类展示语音时长、来源和收藏时间,点击可播放或暂停。列表中同时只能有一条语音播放,离开页面或切换内容时应正确释放播放状态。 + +### 7.6 我的收藏—链接.png + +我的收藏—链接 + +功能说明: + +链接分类以卡片形式展示标题、摘要、缩略图和来源地址。打开外链前需进行安全校验,无法访问或被拦截时显示风险/失效提示。 + +### 7.7 收藏—文件(多选).png + +收藏—文件(多选) + +功能说明: + +收藏列表进入多选模式后,可勾选多个文件并执行批量转发或删除。操作区需显示选择数量,跨页选择应保持状态,删除操作需要二次确认。 + +### 7.8 收藏文件—分享给好友页面.png + +收藏文件—分享给好友页面 + +功能说明: + +分享收藏文件时进入会话选择页,可搜索联系人或群聊并选择接收方。发送前应展示文件和目标会话确认信息,成功后保留收藏原件并生成新的聊天消息。 + +### 7.9 文件-下载.png + +文件-下载 + +功能说明: + +未下载文件展示名称、类型、大小及下载按钮。点击下载前需检查文件有效性、网络和本地空间,下载目标应使用受控存储路径。 + +### 7.10 文件-下载中.png + +文件-下载中 + +功能说明: + +下载中状态展示实时进度,并允许取消或在失败后重试。应用切换前后台、网络变化或页面重进时,应能恢复准确的下载任务状态。 + +### 7.11 文件-下载完成.png + +文件-下载完成 + +功能说明: + +下载完成后提供打开或使用其他应用查看的入口,并显示本地可用状态。若本地文件被清理,页面应恢复为可下载状态,不能继续显示已完成。 + +### 7.12 查找聊天记录-搜索关键词选中状态(点击后跳出对应聊天内容).png + +查找聊天记录-搜索关键词选中状态(点击后跳出对应聊天内容) + +功能说明: + +聊天记录搜索支持输入关键词并展示命中的消息摘要、发送者和时间,关键词高亮显示。点击结果后返回会话并定位到对应消息,同时加载其上下文内容。 + +### 7.13 查找聊天记录-文件.png + +查找聊天记录-文件 + +功能说明: + +文件记录按时间列出会话中发送过的文件,包含文件名、大小、发送者和发送时间。用户可搜索、预览或下载文件,已撤回/失效项需明确标记。 + +### 7.14 查找聊天记录-链接.png + +查找聊天记录-链接 + +功能说明: + +链接记录聚合会话中的网页卡片和网址消息,展示标题、摘要与时间。点击时执行外链安全策略,并允许用户定位回原始消息。 + +### 7.15 查看聊天记录-图片.png + +查看聊天记录-图片 + +功能说明: + +图片记录以按时间分组的缩略图网格展示,支持多选和进入大图预览。缩略图需懒加载并保留原图加载状态,点击图片可定位其消息来源。 + +### 7.16 查看聊天记录-视频.png + +查看聊天记录-视频 + +功能说明: + +视频记录以缩略图展示时长、日期等信息,点击进入播放器。播放前需处理下载/缓冲、文件失效和网络异常,返回后保持原列表位置。 + +## 8. 实时语音与视频通话 + +### 8.1 各种通话提示状态.png + +各种通话提示状态 + +功能说明: + +聊天时间线展示语音/视频通话的取消、拒绝、未接听、忙线和通话时长等结果消息。状态文案应由统一通话结果码生成,并在双方会话中保持语义一致。 + +### 8.2 对方状态.png + +对方状态 + +功能说明: + +收到视频通话邀请时,全屏展示来电人头像、昵称和通话类型,用户可接听或拒绝。锁屏、后台及系统权限受限时也应通过系统通知提供等价操作。 + +### 8.3 语音通话-对方待接受状态.png + +语音通话-对方待接受状态 + +功能说明: + +发起语音通话后进入等待接听状态,展示对方信息、呼叫提示和取消按钮。超时、对方忙线、拒绝或网络失败时应结束呼叫并生成对应会话记录。 + +### 8.4 语音通话-双方语音沟通中.png + +语音通话-双方语音沟通中 + +功能说明: + +接通后展示实时通话时长及静音、扬声器和挂断控制。通话需持续反馈网络/音频状态,异常断开后准确记录实际通话时长。 + +### 8.5 语音通话-麦克风与扬声器已关.png + +语音通话-麦克风与扬声器已关 + +功能说明: + +麦克风与扬声器关闭时,控制按钮呈现明确的关闭状态,并即时停止本地采集或切回听筒。权限失效或设备切换时界面状态必须与真实音频路由一致。 + +### 8.6 语音通话-麦克风与扬声器已开.png + +语音通话-麦克风与扬声器已开 + +功能说明: + +麦克风与扬声器开启时显示选中状态,用户可在通话过程中独立切换。切换应即时生效,并处理蓝牙耳机、系统音量和音频焦点变化。 + +### 8.7 视频通话—等待对方接受接受邀请中.png + +视频通话—等待对方接受接受邀请中 + +功能说明: + +视频呼叫等待页展示本地摄像头预览、对方信息及取消、切换摄像头、关闭摄像头等控制。正式接通前不应向对方传输非必要的视频数据。 + +### 8.8 视频通话—等待对方接受接受邀请-摄像头关闭.png + +视频通话—等待对方接受接受邀请-摄像头关闭 + +功能说明: + +等待接听期间关闭摄像头后,本地预览替换为头像/占位画面,摄像头按钮同步显示关闭状态。对方接听后应沿用当前设置,除非用户主动开启。 + +### 8.9 双方视频通话中.png + +双方视频通话中 + +功能说明: + +视频通话接通后以对方画面为主、本地画面为小窗,展示时长和静音、摄像头切换、挂断等控制。小窗应支持拖动且不能遮挡关键按钮,横竖屏切换时保持画面比例。 + +### 8.10 双方视频通话中-对方关闭摄像头状态.png + +双方视频通话中-对方关闭摄像头状态 + +功能说明: + +对方关闭摄像头时,主画面展示对方头像和状态提示,同时保留语音通话能力。对方重新开启后应自动恢复视频,不需要本端重新接听。 + +### 8.11 视频与语音通话悬浮状态中.png + +视频与语音通话悬浮状态中 + +功能说明: + +通话最小化后以可拖动悬浮窗继续展示视频画面或语音通话状态,用户可点击返回全屏通话。悬浮窗需遵循系统权限和安全区域,并在挂断后立即关闭。 + +## 9. 企业工作台与办公协同 + +### 9.1 工作台.png + +工作台 + +功能说明: + +工作台聚合组织内部的常用办公应用和业务入口,包括组织架构、日报/月报、会议通知、总经理信箱等模块。应用入口应根据企业配置和用户权限动态展示,并支持未读数量提示。 + +### 9.2 部门组织框架.png + +部门组织框架 + +功能说明: + +组织架构按部门层级展示企业成员,支持展开下级部门、查看成员和搜索。部门与人员数据应由企业通讯录统一维护,离职、调岗或组织调整后及时同步。 + +### 9.3 会议通知-发起人状态.png + +会议通知-发起人状态 + +功能说明: + +会议发起人可查看会议主题、时间、地点、参会人及各成员的确认状态,并执行提醒或管理操作。会议变更或取消后应向所有参会人推送更新,并保留操作记录。 + +### 9.4 会议通知-接收人状态.png + +会议通知-接收人状态 + +功能说明: + +会议接收人查看完整会议信息并反馈参加状态。响应结果需实时同步给发起人,会议开始前可按配置发送提醒,过期会议进入只读历史状态。 + +### 9.5 日报.png + +日报 + +功能说明: + +日报入口展示用户的日报记录,并提供新建或提交日报操作。列表应区分日期、提交状态和查看状态,支持按时间查找历史日报。 + +### 9.6 日报-普通用户页面.png + +日报-普通用户页面 + +功能说明: + +普通用户页面以个人填报为主,展示已提交和待填写的日报,用户可查看自己的历史记录。超过允许修改期限后,已提交日报应转为只读或走补交/修改流程。 + +### 9.7 日报-管理员页面.png + +日报-管理员页面 + +功能说明: + +管理员页面汇总团队日报提交情况,可查看已提交、未提交及成员明细。统计口径需与组织权限一致,并支持提醒未提交成员和进入日报详情。 + +### 9.8 日报-查看详情.png + +日报-查看详情 + +功能说明: + +日报详情展示填报人、日期、工作内容及相关附加信息。查看权限由组织关系决定,内容较长时支持完整滚动,已撤回或被修改的日报需标记最新状态。 + +### 9.9 月报(1).png + +月报(1) + +功能说明: + +月报填写页用于录入本月工作总结、下月计划等内容,并提供提交操作。输入项需具备必填和长度校验,可在正式提交前保存草稿,避免页面退出导致内容丢失。 + +### 9.10 月报.png + +月报 + +功能说明: + +月报列表按月份展示个人或权限范围内的月报记录,区分未提交、已提交等状态。用户可进入填写或详情页,管理员可查看团队提交进度。 + +### 9.11 月报-详情页.png + +月报-详情页 + +功能说明: + +月报详情页展示填报人、所属月份、工作总结和计划等完整信息。已提交内容默认只读,具备修改权限时需在规定周期内开放编辑并记录更新时间。 + +### 9.12 总经理信箱.png + +总经理信箱 + +功能说明: + +总经理信箱为员工提供向管理层提交意见或问题的入口,支持填写主题与正文并提交。内容应按企业隐私策略控制可见范围,提交成功后提供明确反馈,防止重复发送。 + +### 9.13 总经理信箱-文案提示.png + +总经理信箱-文案提示 + +功能说明: + +页面通过提示文案说明信箱用途、处理方式及隐私边界。提示内容应由企业后台可配置,并在用户提交前清晰告知实名/匿名规则和信息接收方。 + +## 10. 个人资料、通用设置、隐私与青少年模式 + +### 10.1 我的.png + +我的 + +功能说明: + +“我的”页面展示头像、昵称、账号信息,并提供个人资料、收藏、设置等个人功能入口。账号数据更新后应在此处即时同步,菜单可根据平台或企业策略动态配置。 + +### 10.2 个人信息.png + +个人信息 + +功能说明: + +个人信息页展示并编辑头像、昵称、出生日期、个性签名等资料,同时提供个人二维码入口。不同字段按各自规则校验,修改后同步到联系人资料和聊天界面。 + +### 10.3 设置昵称.png + +设置昵称 + +功能说明: + +账号昵称编辑页提供单行输入、字数计数和完成按钮。昵称需校验长度、空白字符和敏感内容,保存成功后更新全局个人资料,但不覆盖用户在各群设置的群昵称。 + +### 10.4 出生日期.png + +出生日期 + +功能说明: + +出生日期通过日期选择器设置,用户确认后保存到个人资料。日期不得晚于当前日期,展示格式需适配本地化设置,隐私可见性遵循个人资料策略。 + +### 10.5 个性签名.png + +个性签名 + +功能说明: + +个性签名采用多行文本编辑并显示长度限制,支持保存和清空。保存前需处理全空白和敏感内容,修改后在个人主页等引用位置同步展示。 + +### 10.6 二维码.png + +二维码 + +功能说明: + +该原图是个人二维码相关的视觉资源/入口标识,用于从个人资料进入二维码名片。实际页面应生成与当前账号绑定的可扫描二维码,并支持刷新失效凭证,避免静态码泄露长期有效信息。 + +### 10.7 设置(iOS).png + +设置(iOS) + +功能说明: + +iOS 设置页提供账号安全、消息通知、隐私、通用、关于与退出登录等入口。页面应遵循 iOS 导航和权限跳转规范,系统级配置需引导用户前往系统设置。 + +### 10.8 设置(安卓).png + +设置(安卓) + +功能说明: + +Android 设置页覆盖账号安全、通知、隐私、通用、关于及退出登录等功能。与 iOS 共享业务能力,但权限说明、系统设置路径和版本更新流程按 Android 平台实现。 + +### 10.9 清空缓存的提示.png + +清空缓存的提示 + +功能说明: + +清空缓存前弹出确认提示,说明将移除可重新下载的临时数据。操作不应删除账号、聊天记录或收藏等业务数据;完成后刷新缓存占用数值并提示清理结果。 + +### 10.10 版本更新页面.png + +版本更新页面 + +功能说明: + +版本更新页展示当前版本、最新版本和更新说明,并提供检查/立即更新入口。需区分可选更新与强制更新,下载、安装及应用商店跳转方式按操作系统能力处理。 + +### 10.11 发现新版本.png + +发现新版本 + +功能说明: + +检测到新版本时弹出更新提示,展示版本号、更新内容和稍后/更新操作。强制更新不得提供绕过入口,普通更新关闭后应按策略控制再次提醒频率。 + +### 10.12 隐私设置.png + +隐私设置 + +功能说明: + +隐私设置集中管理添加好友方式、黑名单及其他账号可见性选项。开关和选项修改应即时保存并在服务端生效,涉及联系人关系的设置需要跨设备同步。 + +### 10.13 隐私设置-添加我的方式选项.png + +隐私设置-添加我的方式选项 + +功能说明: + +用户可分别控制他人是否能通过账号、手机号、二维码或群聊等渠道添加自己。关闭某渠道后,相关搜索或添加入口应统一阻断,并向发起方展示不泄露隐私的提示。 + +### 10.14 青少年模式未开启(安卓).png + +青少年模式未开启(安卓) + +功能说明: + +未开启页面说明青少年模式的适用范围与限制,并提供开启入口。开启前需由监护人/用户确认并设置独立密码,相关规则由服务端配置并跨设备同步。 + +### 10.15 青少年模式提示(安卓).png + +青少年模式提示(安卓) + +功能说明: + +开启青少年模式过程中展示确认提示,明确功能限制和密码用途。用户确认后进入密码设置或验证流程,取消则保持原模式不变。 + +### 10.16 青少年模式已开启(安卓).png + +青少年模式已开启(安卓) + +功能说明: + +已开启页面展示当前生效状态及关闭入口,受限功能应按策略隐藏或拦截。应用重启、重新登录或切换设备后仍需保持模式状态,不能仅依赖本地标记。 + +### 10.17 关闭青少年模式(安卓).png + +关闭青少年模式(安卓) + +功能说明: + +关闭青少年模式前要求输入独立密码进行验证。连续输错需触发频率限制或找回流程,验证成功后解除限制并清理敏感页面的输入状态。 + +### 10.18 获取推送权限(安卓).png + +获取推送权限(安卓) + +功能说明: + +应用通过说明页解释开启通知权限的用途,并引导用户进入系统授权。拒绝后不应反复打扰,可在消息通知设置中再次触发引导;最终状态以系统权限为准。 + +### 10.19 提醒(安卓).png + +提醒(安卓) + +功能说明: + +Android 系统提醒弹窗用于请求必要权限或告知受限能力,提供取消与前往设置操作。提示只在功能确实受权限影响时出现,并根据系统版本显示正确路径。 + +### 10.20 相机说明(安卓).png + +相机说明(安卓) + +功能说明: + +首次使用拍照、扫码或视频通话前说明相机权限用途,再触发系统授权。拒绝时保留替代入口;永久拒绝后引导至系统设置,不应循环弹出系统权限框。 + +### 10.21 麦克风说明(安卓).png + +麦克风说明(安卓) + +功能说明: + +首次发送语音或发起通话前说明麦克风权限用途,并在用户确认后请求系统授权。权限缺失时禁止开始录音,同时提供清晰的授权恢复路径。 + +## 11. 反诈安全、举报与意见反馈 + +### 11.1 反诈弹窗(安卓).png + +反诈弹窗(安卓) + +功能说明: + +在疑似风险会话或敏感操作前展示反诈提醒,提示用户警惕陌生人、转账和隐私信息风险。弹窗触发规则应由风控策略控制,并避免泄露具体判定逻辑。 + +### 11.2 反诈弹窗(二次提示安卓).png + +反诈弹窗(二次提示安卓) + +功能说明: + +高风险场景继续操作时展示二次确认,强化风险文案并要求用户明确选择。确认/取消结果应记录用于审计和策略评估,必要时可直接阻断操作而非仅提示。 + +### 11.3 举报.png + +举报 + +功能说明: + +举报页面提供色情、诈骗、骚扰等举报原因选项,用户选择后进入补充材料流程或直接提交。举报对象、会话上下文和选择原因需随请求传递,并保护举报人身份。 + +### 11.4 举报(1).png + +举报(1) + +功能说明: + +举报补充页支持填写问题描述并上传图片等证据,提交前需校验必填项、文件数量、格式和大小。提交成功后返回受理结果,重复举报应按平台规则合并或限流。 + +### 11.5 意见反馈.png + +意见反馈 + +功能说明: + +意见反馈页支持输入问题或建议、上传截图,并可按产品设计提供联系方式。提交时附带必要的应用版本和设备信息,敏感日志需征得用户同意;成功后展示反馈已受理状态。 diff --git a/docs/im/IM-产品需求服务端改造分析报告.md b/docs/im/IM-产品需求服务端改造分析报告.md new file mode 100644 index 0000000..53aa63d --- /dev/null +++ b/docs/im/IM-产品需求服务端改造分析报告.md @@ -0,0 +1,852 @@ +# IM 产品需求服务端改造分析报告 + +> 分析日期:2026-08-09 +> 分析对象:`chat-0.25.3` 当前工作区源码 +> 需求依据:[IM-产品原型需求文档](IM-产品原型需求文档.md) +> 报告范围:服务端能力、数据模型、协议/API、安全、实施计划和验收建议 + +## 1. 报告结论 + +当前项目是一个以 Tinode 为核心的 Go 即时通讯服务端,已经具备稳定的实时通信底座:账号登录、单聊、群聊、订阅与访问控制、消息收发、已读/送达回执、输入状态、消息删除、文件上传、推送、点对点 WebRTC 信令,以及可扩展的 gRPC 插件机制。 + +原型中的基础聊天能力不需要推倒重做,建议继续复用现有 Topic、Subscription、Message 和 Session 模型。在此基础上增加产品业务层,负责好友关系、群治理、内容检索、企业组织、会议/日报/月报、实名认证、青少年模式、举报与安全审计等业务。 + +本次分析得到以下关键结论: + +1. **现有服务端可以承担实时通信内核,但不能直接满足完整产品需求。** 当前代码主要解决消息路由、会话和 ACL,不包含完整的社交关系、企业协同和安全治理领域模型。 +2. **应将“实时消息能力”和“产品业务能力”分层。** 聊天消息继续走 WebSocket;需要事务、查询、审批和管理的业务走 HTTP API;状态变化通过结构化系统消息回推 WebSocket。 +3. **必须先确定企业租户模式。** 当前 User、Topic、Subscription、Message 和 FileDef 均没有 `tenant_id`。如果多个企业共用一套数据库,租户隔离必须作为所有功能之前的基础改造。 +4. **消息幂等和文件授权是上线前必须修复的基础问题。** 发布消息处仍有“重复消息 ID 检查”的 TODO;文件下载只校验登录身份,没有验证用户是否有权访问对应消息或话题。 +5. **仓库部分旧文档与当前源码存在漂移。** 旧文档提到的 `x-im-*` 扩展、`im_*` 业务表、数据库版本 119 和 `tools/wsmock`,在当前源码中均未发现。本报告分析时各数据库适配器版本为 116;多租户第一阶段已把 MySQL 提升到 118,其他适配器仍为 116。本报告以可执行源码为准,不把文档设想视为已实现能力。 + +建议按“基础安全与协议 → 好友和群治理 → 搜索与文件 → 通话与设置 → 企业工作台”的顺序实施。若团队和测试资源稳定,完整服务端改造建议拆成 4 个产品阶段、6 至 9 个迭代交付。 + +## 2. 分析范围与判定口径 + +### 2.1 需求范围 + +本报告覆盖原型文档的 11 个功能域: + +1. 账号注册、登录与身份安全; +2. 消息首页、全局导航与系统通知; +3. 单聊会话、会话建立与消息状态; +4. 消息输入、媒体内容与消息操作; +5. 通讯录、好友关系与黑名单; +6. 群聊创建、资料维护与成员治理; +7. 文件下载、收藏管理与聊天记录检索; +8. 实时语音与视频通话; +9. 企业工作台与办公协同; +10. 个人资料、通用设置、隐私与青少年模式; +11. 反诈安全、举报与意见反馈。 + +### 2.2 能力分类 + +文中的需求按以下四类判定: + +| 分类 | 含义 | 处理方式 | +| --- | --- | --- | +| 已支持 | 当前代码已有可用服务端能力 | 直接复用并补验收测试 | +| 扩展支持 | 现有核心模型可承载,但缺少业务字段、校验或接口 | 在现有链路中增加明确的领域逻辑 | +| 全新建设 | 当前没有对应模型和服务 | 新增业务模块、表、API 和通知桥接 | +| 客户端为主 | 服务端只需提供数据或开关,交互由 App 完成 | 避免在服务端重复实现 UI 状态 | + +### 2.3 源码核查范围 + +重点核查了以下目录与文件: + +- `server/session.go`:客户端消息分发、发布、订阅和元数据处理; +- `server/topic.go`:Topic 生命周期、ACL、消息广播、回执和元数据读写; +- `server/datamodel.go`:WebSocket 客户端/服务端协议模型; +- `server/calls.go`:WebRTC 呼叫信令; +- `server/hdl_files.go`:文件上传与下载; +- `server/plugins.go`:gRPC 插件和消息拦截; +- `server/store/store.go`、`server/store/types/types.go`:存储接口和核心实体; +- `server/db/mysql/adapter.go`、`server/db/mysql/schema.sql`:当前 MySQL 实现; +- `server/auth`、`server/validate`、`server/push`:认证、验证码和推送; +- 根目录与 `docs` 目录的 Markdown 文档。 + +## 3. 当前服务端真实能力 + +### 3.1 核心架构 + +当前服务端采用“Session 接收协议包 → Hub/Topic 路由 → Store 持久化 → Topic 广播/Push”的模型。 + +```mermaid +flowchart LR + C["移动端 / Web 客户端"] -->|"WebSocket / Long Poll / gRPC"| S["Session"] + S --> P["Plugin Firehose"] + P --> H["Hub"] + H --> T["Topic Actor"] + T --> ST["Store Adapter"] + ST --> DB["MySQL / PostgreSQL / MongoDB / RethinkDB"] + T --> PU["Push Handlers"] + T --> C + C -->|"HTTP"| F["File Handler"] + F --> ST +``` + +Topic 以串行事件循环处理同一话题内的状态和消息,适合维持顺序号、订阅状态和广播一致性。扩展时不应在 Topic 事件循环中执行耗时的远程 RPC、全文搜索或复杂审批事务,否则会阻塞该话题的全部消息。 + +### 3.2 已有能力清单 + +| 能力 | 当前实现 | 主要源码位置 | 复用判断 | +| --- | --- | --- | --- | +| 账号创建与登录 | `acc`、`login` 协议;basic、token、REST 等认证方式 | `server/session.go`、`server/auth` | 已支持基础能力 | +| 手机验证码 | 电话号码校验、验证码、重试控制,可配置 Twilio | `server/validate/tel/validate.go` | 可用于注册/找回密码,需补产品流程 | +| 单聊与群聊 | P2P、group、channel Topic | `server/topic.go` | 已支持 | +| 会话成员与 ACL | `ModeWant`、`ModeGiven`,包含 Join、Read、Write、Admin、Owner 等权限 | `server/topic.go`、`server/store/types` | 已支持底层权限 | +| 消息收发 | 消息持久化、Topic 内递增序号、广播、离线推送 | `server/topic.go:997`、`server/store/store.go` | 已支持 | +| 已读/送达/输入中 | `note` 的 `read`、`recv`、`kp` 等事件 | `server/datamodel.go:309`、`server/topic.go:1137` | 已支持 | +| 消息扩展内容 | `head` 和 `content` 为通用 JSON;支持 Drafty | `server/datamodel.go:265`、`server/store/types/types.go:1213` | 能承载,缺少产品级规范与校验 | +| 用户/群资料扩展 | Public、Trusted、Private、Aux、Tags | `server/datamodel.go`、`server/topic.go:2202` | 能承载少量扩展信息 | +| 收藏话题 | 存在 `slf` Topic 类别 | `server/topic.go`、`server/store/types` | 可复用,但缺少完整收藏模型与检索 | +| 文件 | HTTP 上传、元数据保存、附件与消息关联 | `server/hdl_files.go`、`server/store/store.go` | 基础可用,下载授权需修复 | +| 音视频信令 | 邀请、接听、Offer、Answer、ICE、挂断;单聊中维护一个当前呼叫 | `server/calls.go` | 可复用,需产品化扩展 | +| 推送 | 可插拔 Push Handler,包含消息和通话字段 | `server/push.go`、`server/push` | 已支持基础能力 | +| 外部风控/审核 | gRPC 插件可继续、丢弃、替换或直接响应协议包 | `server/plugins.go:353` | 适合接外部审核,不替代内部授权 | + +### 3.3 当前核心数据模型 + +当前模型聚焦通信内核: + +| 实体 | 关键字段 | 缺少的产品概念 | +| --- | --- | --- | +| User | 状态、访问权限、Public、Trusted、Tags、设备 | 企业、部门、好友关系、实名认证状态、青少年策略 | +| Topic | Owner、默认 ACL、Public、Trusted、Tags、Aux | 入群策略、群公告、禁言规则、群二维码、企业归属 | +| Subscription | Topic、User、ModeWant、ModeGiven、Private | 备注、置顶、通知策略、单独禁言、好友状态 | +| Message | Topic、SeqId、From、Head、Content | 客户端幂等 ID、消息类型索引、全文检索字段、审核状态 | +| FileDef | 文件位置、状态、上传用户等 | 资源级访问策略、业务用途、病毒扫描状态 | + +MySQL 当前消息表只对 `(topic, seqid)` 建唯一索引,没有消息类型索引、客户端消息 ID 唯一索引或全文检索索引。分析基线中各适配器均为 schema 116;当前 MySQL 已因租户主表升级到 118,PostgreSQL、MongoDB、RethinkDB 仍为 116。 + +### 3.4 当前主要缺口 + +1. `MsgGetQuery` 只支持 desc、sub、data、del,消息查询只有序号范围和数量,没有关键词、媒体类型、发送人、时间区间等检索条件。 +2. 发布消息时没有客户端消息幂等校验,`server/session.go:691` 仍保留重复消息 ID 检查 TODO。 +3. 当前群成员模型能表达 ACL,但不能完整表达申请加入、审批记录、过期状态和审批理由。直接用 `ModeGiven` 表示待审批会与当前订阅逻辑冲突。 +4. 当前群“静音”更接近通知/Presence 控制,不等同于禁止指定成员发言;服务端缺少群禁言策略。 +5. 文件下载只校验 API Key 和登录用户,没有根据文件关联消息、Topic ACL、发送者或接收者判断访问权限。 +6. WebRTC 只维护 P2P Topic 的一个当前呼叫,缺少稳定的业务 `call_id`、跨设备抢答、通话详单、失败原因规范和群通话能力。 +7. 没有企业、组织、部门、会议、日报、月报、信箱等领域模型。 +8. 没有实名认证审批、反诈策略、举报工单、反馈工单、青少年模式的服务端强制规则。 +9. 没有租户字段;共享数据库的企业 SaaS 部署会有数据越界风险。 + +## 4. 总体改造原则 + +### 4.1 保留通信内核,新增产品业务层 + +不建议将会议、日报、实名认证等业务硬塞进 Topic 的 Public/Aux JSON。Aux 适合兼容性元数据,不适合作为需要索引、唯一约束、状态机、审批记录和审计日志的主数据。 + +推荐分为四层: + +1. **Tinode 实时内核**:Session、Hub、Topic、ACL、Message、Push、WebRTC 信令; +2. **IM 产品策略层**:结构化消息解析、发消息前鉴权、好友/黑名单/群禁言/青少年模式校验; +3. **业务服务层**:好友申请、群审批、组织、会议、报告、实名认证、举报等 HTTP API; +4. **业务存储与事件层**:业务表、事务、Outbox、审计日志、搜索索引。 + +### 4.2 目标架构 + +```mermaid +flowchart TB + APP["iOS / Android / Web"] + + subgraph Gateway["同一 Go 服务进程或同域网关"] + WS["现有实时协议入口"] + API["新增 /v1/im/* 业务 API"] + FILE["文件上传/下载"] + end + + subgraph Core["通信与产品策略"] + SESSION["Session / Hub / Topic"] + POLICY["IM Policy Engine"] + CALL["Call Signaling"] + NOTIFY["Notification Bridge"] + end + + subgraph Business["业务模块"] + REL["好友与黑名单"] + GROUP["群治理"] + ORG["组织与工作台"] + SAFETY["实名 / 青少年 / 举报 / 风控"] + SEARCH["消息与文件索引"] + end + + subgraph Infra["存储与外部系统"] + DB["核心表 + im_* 业务表"] + OUTBOX["Outbox / 异步任务"] + MOD["外部审核与反诈服务"] + PUSH["Push / SMS / 邮件"] + MEDIA["对象存储 / 转码 / 病毒扫描"] + end + + APP --> WS + APP --> API + APP --> FILE + WS --> SESSION + SESSION --> POLICY + POLICY --> DB + API --> Business + Business --> DB + Business --> OUTBOX + OUTBOX --> NOTIFY + NOTIFY --> SESSION + SESSION --> CALL + POLICY --> MOD + FILE --> MEDIA + SESSION --> PUSH +``` + +### 4.3 WebSocket 与 HTTP 的职责边界 + +| 场景 | 推荐通道 | 原因 | +| --- | --- | --- | +| 发消息、回执、输入中、在线事件、通话信令 | 现有 WebSocket | 低延迟、已有 Topic 路由和广播 | +| 好友申请/审批、群申请/审批 | HTTP 写操作 + WS 通知 | 需要幂等、状态机、审计和列表查询 | +| 组织架构、日报/月报、会议管理 | HTTP API | 强查询和事务型业务,不应阻塞 Topic | +| 消息关键词/类型搜索 | HTTP 或扩展后的 `get` | 需要独立索引、分页游标和查询权限 | +| 实名认证、举报、反馈、版本配置 | HTTP API | 涉及附件、敏感字段、工单和后台管理 | +| 文件上传/下载 | HTTP | 沿用当前链路,但补资源级授权 | +| 业务状态变化提醒 | 结构化系统消息/系统 Topic | 复用实时和推送能力 | + +## 5. 11 类原型需求的服务端适配方案 + +### 5.1 账号注册、登录与身份安全 + +| 原型能力 | 现状 | 改造方案 | 优先级 | +| --- | --- | --- | --- | +| 企业码登录 | 无企业模型 | 新增企业码解析接口;企业码映射到企业、部署地址和登录策略 | P0 | +| 手机号/密码登录 | 认证框架已有 | 固化账号标识规范、失败次数、锁定时长、设备记录和安全审计 | P0 | +| 注册和验证码倒计时 | 电话验证器已有 | 新增注册编排接口;倒计时由客户端显示,服务端返回 `retry_after`、过期时间和重试上限 | P0 | +| 忘记密码 | 有认证与验证器,无完整流程 | 增加发送验证码、校验票据、重置密码三个阶段;票据一次性、短时有效 | P0 | +| 设置独立密码 | 可更新账号,但无产品流程 | 增加二次验证和密码策略;写审计日志并撤销旧 Token | P1 | +| 实名认证 | 无 | 新建实名申请、状态查询、审核回调;敏感字段加密,客户端仅返回脱敏结果 | P1 | + +企业码有两种实现方式: + +- **推荐第一阶段:一企一实例/一企一库。** 企业码只负责发现该企业的服务地址和认证方式。现有核心表无需立即加入 `tenant_id`,隔离边界清晰。 +- **共享 SaaS:多企业共用服务和数据库。** 必须先完成第 6 节的全链路租户隔离,再开放企业码登录。 + +建议新增接口: + +```text +POST /v1/im/auth/enterprise/resolve +POST /v1/im/auth/sms/send +POST /v1/im/auth/password/reset-ticket +POST /v1/im/auth/password/reset +POST /v1/im/identity/verifications +GET /v1/im/identity/verifications/current +``` + +不要把身份证号、证件照片地址写入 User.Public、User.Trusted 或消息内容。实名数据应单独加密存储,并限制后台角色、访问理由和留痕。 + +### 5.2 消息首页、全局导航与系统通知 + +| 原型能力 | 现状 | 改造方案 | 优先级 | +| --- | --- | --- | --- | +| 会话列表 | Subscription 已有未读、时间等信息 | 扩展每用户会话设置:置顶、免打扰、草稿、隐藏;草稿可优先本地保存 | P1 | +| 离线/重连 | Session 支持连接和队列 | 客户端指数退避;服务端增加连接状态指标、消息同步游标和限流 | P0 | +| 长按置顶/删除/免打扰 | 部分可由 Private/订阅状态承载 | 明确 `im_conversation_settings`,避免 Private JSON 无法查询和约束 | P1 | +| 系统消息 | 可创建系统 Topic/系统账号 | 建立系统消息类型字典、模板版本、跳转参数和去重键 | P1 | + +系统消息应使用正常的 Message 存储和 Push 链路,而不是只发临时 `info` 包。这样用户离线后仍可拉取、已读和跨设备同步。 + +### 5.3 单聊会话、会话建立与消息状态 + +| 原型能力 | 现状 | 改造方案 | 优先级 | +| --- | --- | --- | --- | +| 单聊建立 | P2P Topic 已支持 | 在创建/订阅前检查好友、黑名单和隐私策略 | P0 | +| 在线、输入中 | Presence、`kp` 已支持 | 统一客户端节流和过期时间;按隐私设置决定是否透出 | P1 | +| 已发送、已送达、已读 | Topic 序号和 `recv/read` 已支持 | 定义产品状态映射;群聊展示规则单独定义 | P0 | +| 拉黑后限制沟通 | 无完整关系表 | 关系表作为真源;发布、建会话、呼叫、文件访问均执行同一策略 | P0 | +| 删除消息/撤回 | 有消息删除能力 | 区分“仅自己删除”和“全员撤回”;增加撤回时限、操作者和审计事件 | P1 | +| 名片消息 | 通用 JSON 可承载 | 定义 `contact_card` 类型,只保存稳定用户 ID 和展示快照,打开时重新鉴权 | P1 | + +“拉黑”不能只修改客户端按钮或 Topic 私有字段。策略层至少要拦截:新建单聊、发送消息、发送好友请求、音视频呼叫、读取对方敏感资料。 + +### 5.4 消息输入、媒体内容与消息操作 + +当前 `head`、`content` 接受通用 JSON,技术上可以传文本、图片、语音、视频、文件、名片、合并转发等内容,但服务端没有类型白名单、字段校验、幂等、审核和索引。建议增加明确的消息信封: + +```json +{ + "pub": { + "id": "client-request-id", + "topic": "grp...", + "head": { + "x-im-schema": 1, + "x-im-type": "image", + "x-im-client-mid": "01J...", + "x-im-reply-to": 128 + }, + "content": { + "file_id": "f...", + "width": 1080, + "height": 1440, + "thumbnail_file_id": "f..." + } + } +} +``` + +服务端改造要求: + +1. 在 `server/datamodel.go` 定义扩展字段常量和允许类型,不再由各客户端自由命名。 +2. 在 `server/session.go` 发布入口解析 `x-im-client-mid`,按“租户 + 发送者 + client_mid”去重;重复请求返回第一次成功的 Topic/SeqId。 +3. 在 `server/topic.go` 持久化前执行类型、大小、引用消息、附件所有权、好友/黑名单、禁言、青少年和风控策略。 +4. 文本继续使用 Drafty;图片、视频、语音、文件只在消息里保存 `file_id` 和必要元数据,不信任客户端提交的任意下载 URL。 +5. `@成员` 保存稳定用户 ID 列表;服务端校验被提醒者确实是当前群成员,并生成定向推送。 +6. 合并转发生成不可变快照,保留原作者展示名、时间和内容类型,但不得泄露原会话 ID 或无权访问的附件。 +7. 多选、键盘、表情面板、录音进度、上传进度属于客户端交互;服务端只负责资源、消息和失败状态。 + +推荐消息类型首批固定为: + +```text +text, image, voice, video, file, contact_card, +location, merged_forward, system_notice, call_record +``` + +转发应区分两种语义: + +- **逐条转发**:为每条内容创建新消息,原附件通过授权后重新建立引用; +- **合并转发**:创建一个 `merged_forward` 快照,子项限制数量和总大小,服务端清理敏感字段。 + +### 5.5 通讯录、好友关系与黑名单 + +当前 Subscription 表示“用户订阅了哪个 Topic”,不能替代双边好友关系和申请状态机。建议新增: + +- `im_user_relations`:双方关系、备注、黑名单、免打扰等当前状态; +- `im_friend_requests`:申请、处理人、验证信息、状态、过期时间和幂等键; +- `im_privacy_settings`:允许通过手机号/ID/二维码查找、加好友验证方式等。 + +好友流程: + +```mermaid +stateDiagram-v2 + [*] --> Pending: 发起申请 + Pending --> Accepted: 接受 + Pending --> Rejected: 拒绝 + Pending --> Cancelled: 发起人撤回 + Pending --> Expired: 超时 + Accepted --> Deleted: 任一方删除 + Accepted --> Blocked: 任一方拉黑 + Blocked --> Accepted: 取消拉黑且关系仍保留 +``` + +实现要点: + +1. 精准用户 ID 搜索必须受隐私设置、租户和账号状态限制;手机号搜索应使用规范化哈希进行匹配,禁止模糊枚举。 +2. 接受好友申请和建立双方关系必须在同一数据库事务内完成,并通过 Outbox 异步创建/激活 P2P Topic、发送系统通知。 +3. 删除好友是否删除 P2P 历史需产品确认。建议只解除关系,不物理删除历史;双方分别控制会话是否可见。 +4. 拉黑属于单方策略,服务端立即生效。是否同时解除好友关系应作为明确产品规则,不由客户端猜测。 +5. 关系变化必须使 ACL/策略缓存失效,避免短时间内继续发消息。 + +建议接口: + +```text +GET /v1/im/contacts +GET /v1/im/users/exact-search?id=... +POST /v1/im/friend-requests +GET /v1/im/friend-requests +POST /v1/im/friend-requests/{id}/accept +POST /v1/im/friend-requests/{id}/reject +DELETE /v1/im/contacts/{user_id} +PUT /v1/im/contacts/{user_id}/block +DELETE /v1/im/contacts/{user_id}/block +GET /v1/im/blocks +``` + +### 5.6 群聊创建、资料维护与成员治理 + +现有群 Topic、Owner/Admin ACL 和所有者转移可以复用。需要新增群业务规则和审批数据。 + +| 原型能力 | 可复用基础 | 新增服务端能力 | +| --- | --- | --- | +| 创建群、群资料 | Group Topic、Public/Trusted/Aux | 资料字段规范、头像资源校验、敏感词审核 | +| 加入群聊 | Subscription/Join ACL | 入群方式、申请表、审批状态机、人数和频率限制 | +| 邀请进群 | Admin/Share ACL | 邀请记录、邀请权限、被邀请人确认、批量幂等 | +| 群公告 | 消息可承载 | 公告主表、版本、置顶、已读回执、发布权限 | +| 管理员 | Admin ACL | 管理员人数上限、操作审计、角色显示 | +| 禁言成员 | 无对应强制策略 | 单人/全员禁言表和发布前校验,管理员豁免规则 | +| 转让群主 | 已有 Owner 转移逻辑 | 二次确认、风险校验、审计和通知 | +| 群二维码 | 无 | 签名短 Token、有效期、次数限制、撤销和入群策略 | +| 群文件 | 文件与消息已有关联 | 群文件索引、上传者/类型/时间筛选、删除权限 | + +当前订阅代码在新订阅者没有 Join 权限时会拒绝加入,因此不能仅用“`ModeGiven` 不含 Join”来保存待审批申请。正确做法是先写 `im_group_join_requests`,审批通过后再创建或更新 Subscription。 + +群禁言必须在消息持久化前检查,建议规则优先级为: + +```text +账号停用 > 黑名单/关系限制 > 全员禁言 > 单人禁言 > Topic Write ACL > 允许发送 +``` + +群二维码只应包含服务端生成的短 Token,不应直接暴露可永久加入的 Topic ID。Token 至少绑定群、创建人、过期时间、最大使用次数和当前入群策略,并使用服务端密钥签名或只保存其哈希。 + +### 5.7 文件下载、收藏管理与聊天记录检索 + +#### 文件访问安全 + +这是当前代码中最明确的安全缺口。`server/hdl_files.go` 的下载流程验证 API Key 和已登录用户后即可读取 URL 对应文件,没有验证该用户是否是上传者、是否属于关联消息的 Topic、该消息是否已撤回,或文件是否为公开资源。 + +建议新增统一的 `FileCanAccess(uid, fileID, action)`: + +1. 查询 FileDef 和 file-message links; +2. 上传未完成的文件只允许上传者访问; +3. 已关联消息的文件要求用户对至少一个关联 Topic 具有 Read 权限; +4. 群文件删除要求上传者、群管理员或群主权限; +5. 转发附件时重新建立授权关联,不暴露底层存储永久地址; +6. 使用短时签名下载 URL,记录下载审计; +7. 对上传文件执行 MIME 嗅探、扩展名一致性、病毒扫描、图片/视频解码安全检查。 + +#### 收藏 + +`slf` Topic 可以继续承担“收藏内容跨设备同步”的消息容器,但建议增加 `im_favorites` 索引表,保存源消息、收藏消息、类型、创建时间和已清理的展示摘要。源消息删除后,收藏快照是否保留需按合规策略处理。 + +#### 聊天记录检索 + +当前 `MessageGetAll` 仅支持 Topic、序号范围和数量,不支持关键词或类型。建议新增 `im_message_index`: + +| 字段 | 用途 | +| --- | --- | +| tenant_id / topic / seq_id | 权限过滤和定位原消息 | +| sender_id / message_type | 发送人和类型筛选 | +| sent_at | 时间范围和排序 | +| searchable_text | 文本、文件名、链接标题等可搜索文本 | +| file_id / mime_group | 文件、图片、视频分类 | +| deleted / moderation_state | 排除已删除或不可见内容 | + +若仅支持 MySQL,可使用 InnoDB FULLTEXT 配合 `ngram` 分词;若需要更强中文检索、跨话题检索和高数据量,建议使用 OpenSearch/Elasticsearch。无论使用哪种索引,查询结果都必须再次以当前 Topic Read ACL、消息删除范围和租户进行过滤,不能把搜索索引当作授权来源。 + +需要提供全量回填任务和增量 Outbox 消费者,避免上线后只能搜索新消息。 + +### 5.8 实时语音与视频通话 + +现有 `server/calls.go` 已支持 P2P 呼叫的邀请、接听、Offer、Answer、ICE Candidate 和挂断,可作为首期基础。改造建议: + +1. 为每次呼叫生成不可复用的 `call_id`,不要只用 Topic 和消息 SeqId 表示一次通话; +2. 增加 `voice/video` 类型、主叫/被叫设备、创建/响铃/接听/结束时间、结束方、标准结束原因; +3. 增加跨设备抢答:一台设备接听后,其他设备收到 `answered_elsewhere`; +4. 呼叫前执行好友、黑名单、隐私、账号状态和青少年策略; +5. 呼叫超时、拒接、忙线、取消分别生成一致的通话记录消息和推送; +6. 配置可用的 STUN/TURN,监控 ICE 成功率和呼叫建立耗时; +7. 麦克风、扬声器、摄像头开关和悬浮窗主要由客户端管理,服务端只同步必要的媒体状态事件; +8. 群音视频不应扩展当前单个 `videoCall` 结构硬做,建议另建会议房间并接入 SFU。 + +### 5.9 企业工作台与办公协同 + +该部分属于全新业务域,当前服务端没有组织、部门、会议、日报、月报或信箱代码。建议它们作为独立业务模块,通过 Tinode 系统消息发送提醒,而不是修改聊天核心协议承载所有 CRUD。 + +#### 组织架构 + +- `im_org_units`:企业、部门、层级、排序、负责人、状态; +- `im_org_members`:用户、部门、职位、主部门、在职状态和可见范围; +- 支持树形查询、按姓名/部门搜索、部门成员分页; +- 组织同步应有版本号,客户端可增量刷新; +- 离职用户需在一个事务编排中禁用账号、退出企业群、撤销 Token 和处理数据归属。 + +#### 会议通知 + +- `im_meetings`:主题、时间、地点/会议链接、发起人、提醒策略、状态; +- `im_meeting_participants`:参与人、回复状态、回复时间; +- 创建/变更/取消写 Outbox,由通知桥接器向参与者系统 Topic 发结构化消息; +- 定时提醒由任务调度器执行,任务必须使用幂等键避免重复推送; +- 若未来增加在线视频会议,会议业务 ID 与通话房间 ID 分离。 + +#### 日报与月报 + +- 统一使用 `im_work_reports`,字段包含类型、周期、作者、部门、正文、附件、状态、提交时间; +- 管理员列表以权限范围过滤,不以客户端传入部门为准; +- 提交后是否可编辑、补交和撤回需形成明确状态机; +- 汇总统计异步生成,不在请求中扫描所有报告。 + +#### 总经理信箱 + +- 使用 `im_mailbox_messages`,支持实名/匿名展示策略、正文、附件、处理状态和回复; +- “匿名”只能对业务查看者隐藏,安全审计角色仍可在合法授权下追溯; +- 附件走统一私有文件授权; +- 每次查看和导出敏感信件都写审计日志。 + +### 5.10 个人资料、通用设置、隐私与青少年模式 + +| 能力 | 服务端责任 | 客户端责任 | +| --- | --- | --- | +| 昵称、生日、签名、头像 | 校验、敏感词、可见范围、版本和审计 | 编辑和展示 | +| 个人二维码 | 生成短时签名 Token、隐私校验 | 展示、扫码 | +| 清空缓存 | 无需删除服务端消息 | 清理本地缓存和下载文件 | +| 版本更新 | 返回平台、渠道、最低版本、推荐版本、下载地址和更新文案 | 下载、安装、商店跳转 | +| 隐私设置 | 作为服务端授权条件强制执行 | 提供开关界面 | +| 青少年模式 | 服务端保存状态、PIN 哈希、策略版本并强制限制 | UI 切换和本地内容隐藏 | +| 推送/相机/麦克风权限 | 保存推送 Token 和提醒偏好 | 调用系统权限 API | + +青少年模式不能只是客户端开关,否则卸载重装或换设备即可绕过。建议 `im_teen_mode` 保存启用状态、监护 PIN 的强哈希、失败锁定、允许时段、功能策略版本和更新时间。服务端在登录、搜索、加好友、发消息、通话和工作台入口统一检查策略。 + +版本接口示例: + +```text +GET /v1/im/config/app-version?platform=android&channel=official&version=1.4.0 +``` + +响应需区分 `latest`、`recommended`、`minimum_supported` 和 `force_update`。客户端系统权限文案和弹窗不需要服务端逐页参与,但可以通过配置中心提供版本化文案。 + +### 5.11 反诈安全、举报与意见反馈 + +建议形成三层控制: + +1. **同步硬规则**:账号状态、黑名单、限频、群禁言、文件大小/类型等,必须在服务端本地快速判断; +2. **内容与风险审核**:通过现有 gRPC Plugin Firehose 或独立审核服务检查文本、图片和链接; +3. **事后处置**:举报工单、证据固化、审核、处罚、申诉和审计。 + +现有插件在协议分发之前同步执行,严格审核会直接增加消息延迟。外部审核调用必须设置较短超时、熔断、指标和明确的 fail-open/fail-closed 策略。高风险场景可以先将消息标为 `pending_review`,审核通过后再广播;低风险场景可先发后审并支持撤回。 + +新增数据模型: + +- `im_reports`:举报对象、原因、描述、提交人、状态、处理人、结论; +- `im_report_evidence`:消息快照、文件引用、校验哈希; +- `im_feedback`:意见类型、正文、联系方式、附件、处理状态; +- `im_risk_events`:命中规则、风险等级、处置、模型/规则版本; +- `im_audit_logs`:操作者、动作、对象、结果、IP、设备和关联请求 ID。 + +举报消息时不要只保存可变的 Topic/SeqId,应在用户仍有读取权限时由服务端固化必要证据快照和内容哈希。后台读取证据需要单独权限和完整审计。 + +## 6. 企业租户隔离决策 + +### 6.1 当前事实 + +当前 User、Topic、Subscription、Message 和 FileDef 没有 `tenant_id`。用户标签和 Topic 标签也没有企业隔离维度。仅在登录入口识别企业码,不能保证后续搜索、消息、文件和后台查询不会跨企业。 + +### 6.2 推荐方案:第一阶段一企一实例/一企一库 + +企业码网关返回企业专属服务地址。每个实例使用独立数据库、对象存储前缀、推送配置和审核配置。这种方案最贴合当前架构,改造量和误配风险最低,也便于企业级数据迁移与删除。 + +需要新增: + +- 企业码目录服务; +- 企业到服务地址/认证策略的映射; +- 配置和密钥隔离; +- 统一运维、监控和版本发布能力。 + +### 6.3 共享 SaaS 的必要改造 + +如果明确要求多个企业共用一套服务和数据库,则以下工作必须先于产品功能: + +1. 为核心表和所有业务表增加 `tenant_id`; +2. 所有唯一索引包含租户维度,例如用户登录标识、Topic、客户端消息 ID; +3. Session 完成认证后固定 tenant,禁止客户端包覆盖; +4. Topic 查找、用户查找、订阅、消息读写、文件访问和搜索全部强制带 tenant; +5. 内存缓存、Hub 路由键、集群路由和 Push 任务均包含 tenant; +6. 系统账号和系统 Topic 按企业创建; +7. 后台管理账号采用企业范围和数据范围授权; +8. 增加跨租户攻击自动化测试,覆盖枚举 ID、文件 URL、搜索和转发。 + +这不是加一个字段的局部修改,而是横跨认证、存储接口、全部数据库适配器、缓存、集群路由和测试的基础工程。 + +## 7. 协议与 API 改造 + +### 7.1 保持现有协议兼容 + +继续支持现有 `hi`、`acc`、`login`、`sub`、`leave`、`pub`、`get`、`set`、`del`、`note`。新客户端通过 `x-im-schema` 声明产品扩展版本;旧客户端没有扩展字段时仍按现有路径工作。 + +推荐新增固定消息头: + +| 字段 | 必填条件 | 说明 | +| --- | --- | --- | +| `x-im-schema` | 产品消息必填 | 扩展协议版本 | +| `x-im-type` | 产品消息必填 | 消息类型枚举 | +| `x-im-client-mid` | 普通消息必填 | 客户端生成的幂等 ID | +| `x-im-reply-to` | 回复消息时 | 被回复消息 SeqId | +| `x-im-forward-mode` | 转发时 | `single` 或 `merged` | +| `x-im-risk-state` | 仅服务端写 | 审核状态,客户端提交值必须忽略 | + +### 7.2 消息幂等 + +新增唯一约束: + +```text +(tenant_id, sender_id, client_mid) UNIQUE +``` + +一企一库时可省略物理 `tenant_id`,但代码接口仍建议预留租户上下文。幂等记录需要保存最终 Topic、SeqId 和响应摘要。消息保存、Topic 最新序号、订阅更新目前通过多次 Store 调用完成;新增幂等时应明确事务边界,避免数据库写成功但回复丢失后重试产生两条消息。 + +### 7.3 查询扩展 + +不建议继续把搜索条件塞进未定义的任意 JSON。可在 `MsgGetQuery` 中增加明确字段: + +```go +type MsgSearchQuery struct { + Query string `json:"query,omitempty"` + Types []string `json:"types,omitempty"` + From string `json:"from,omitempty"` + SinceTime string `json:"since_time,omitempty"` + BeforeTime string `json:"before_time,omitempty"` + Cursor string `json:"cursor,omitempty"` + Limit int `json:"limit,omitempty"` +} +``` + +如果搜索由独立服务承担,则提供 `/v1/im/search/messages`,返回 Topic、SeqId 和安全摘要,客户端再通过原生 `get data` 拉取上下文。任何方案都必须在服务端重新做 Topic ACL 检查。 + +### 7.4 业务 API 约定 + +新增 `/v1/im/*` 路由,复用 Tinode Token 的认证结果。应把 `hdl_files.go` 中现有的请求认证逻辑抽取成通用 HTTP 中间件,而不是让每个 Handler 自行解析 Token。 + +所有写接口统一支持: + +- `X-Request-ID`:链路追踪; +- `Idempotency-Key`:创建和审批的幂等控制; +- 统一错误结构:`code`、`message`、`details`、`request_id`; +- 游标分页,不使用数据增长时不稳定的大 Offset; +- 服务端从认证上下文取用户和租户,不接受正文伪造; +- 状态变更写审计日志和 Outbox。 + +## 8. 建议新增的数据模型 + +以下是逻辑模型,正式建表前需补充字段长度、外键策略、冷热分层和数据保留周期。 + +| 表 | 核心字段 | 关键约束/索引 | 作用 | +| --- | --- | --- | --- | +| `im_user_relations` | user_id、peer_id、state、remark、blocked、muted | 双向记录唯一;peer/state 索引 | 好友、备注、拉黑和通知关系 | +| `im_friend_requests` | id、from、to、message、state、expires_at | 待处理申请去重;to/state/time | 好友申请状态机 | +| `im_conversation_settings` | user_id、topic、pinned、muted、hidden | user/topic 唯一 | 会话个性化设置 | +| `im_privacy_settings` | user_id、search flags、friend policy | user 唯一 | 精准搜索和加好友授权 | +| `im_group_settings` | topic、join_policy、invite_policy、mute_all | topic 唯一 | 群业务规则 | +| `im_group_join_requests` | id、topic、applicant、inviter、state | topic/applicant/pending 去重 | 入群申请与审批 | +| `im_group_mutes` | topic、user、expires_at、operator | topic/user 唯一;expires 索引 | 单人禁言 | +| `im_group_announcements` | id、topic、version、content、publisher | topic/status/time | 群公告版本 | +| `im_group_announcement_reads` | announcement_id、user、read_at | announcement/user 唯一 | 公告已读 | +| `im_group_invite_tokens` | token_hash、topic、expires、max_uses | token_hash 唯一;expires | 群二维码/邀请链接 | +| `im_message_idempotency` | sender、client_mid、topic、seq_id | sender/client_mid 唯一 | 消息去重 | +| `im_message_index` | topic、seq_id、type、text、file_id、sent_at | topic/seq 唯一;搜索索引 | 消息、文件和媒体检索 | +| `im_favorites` | user、favorite_seq、source topic/seq、type | user/time;源消息索引 | 收藏筛选与分享 | +| `im_identity_verifications` | user、encrypted PII、state、review | user/current-state;request id | 实名认证和审计 | +| `im_org_units` | id、parent_id、name、path、state | parent/sort;path | 企业组织树 | +| `im_org_members` | user、org_unit、title、primary | unit/state;user | 部门成员关系 | +| `im_meetings` | id、organizer、title、start/end、state | organizer/time;state/time | 会议通知 | +| `im_meeting_participants` | meeting、user、response | meeting/user 唯一 | 参会状态 | +| `im_work_reports` | type、period、author、department、content、state | author/type/period;dept/time | 日报/月报 | +| `im_mailbox_messages` | id、sender、content、anonymous、state | state/time;sender/time | 总经理信箱 | +| `im_user_settings` | user、setting key/value、version | user/key 唯一 | 跨设备设置 | +| `im_teen_mode` | user、enabled、pin_hash、policy_version | user 唯一 | 青少年策略 | +| `im_reports` | reporter、target_type/id、reason、state | state/time;target | 举报工单 | +| `im_report_evidence` | report、snapshot、file、hash | report | 证据固化 | +| `im_feedback` | user、category、content、state | state/time;user/time | 意见反馈 | +| `im_risk_events` | actor、object、rule、level、action | actor/time;object | 风控记录 | +| `im_audit_logs` | actor、action、object、result、request_id | actor/time;object/time | 管理和敏感操作审计 | +| `im_outbox` | aggregate、event_type、payload、state | state/next_retry;dedupe key | 事务后可靠通知 | + +所有表在共享 SaaS 模式下都必须包含 `tenant_id`,并将它放入唯一约束和查询前缀。软删除字段不能代替状态机和审计记录。 + +## 9. 代码级改造清单 + +### 9.1 现有文件修改 + +| 文件 | 建议修改 | +| --- | --- | +| `server/datamodel.go` | 定义产品消息头、消息类型、查询过滤和错误码;保持旧协议兼容 | +| `server/session.go` | 发布前幂等校验;调用产品策略层;统一映射业务错误;认证后绑定租户上下文 | +| `server/topic.go` | 发布前关系/禁言/青少年策略;结构化系统消息;群管理状态变化后的缓存失效 | +| `server/calls.go` | 引入 call_id、设备维度、结束原因、抢答与通话详单;群通话走独立模块 | +| `server/push.go` | 按消息类型生成 Push;支持 @提醒、审批、会议和审核状态;避免敏感内容出现在锁屏 | +| `server/hdl_files.go` | 抽取通用 HTTP 认证;下载增加 FileCanAccess;签名 URL;扫描状态检查 | +| `server/plugins.go` | 为外部审核增加超时、指标、熔断和场景化失败策略 | +| `server/main.go` | 注册 `/v1/im/*` 路由、业务模块生命周期、健康检查和后台任务 | +| `server/store/store.go` | 增加幂等、文件授权和搜索所需的核心接口,明确事务边界 | +| `server/store/types/types.go` | 增加明确的扩展类型;若共享 SaaS,核心实体加入 TenantId | +| `server/db/mysql/adapter.go` | schema 升级、索引、幂等和核心查询实现 | +| `server/db/mysql/schema.sql` | 与 adapter 中建表 SQL 同步,禁止只改其中一份 | +| `tinode.conf` | 产品模块、租户、搜索、审核、TURN、文件扫描和限流配置 | + +### 9.2 建议新增包 + +```text +server/imapi/ HTTP 路由、请求校验、响应和认证中间件 +server/impolicy/ 好友、黑名单、群禁言、隐私、青少年等同步策略 +server/immessage/ 消息类型注册、结构校验、摘要与转发净化 +server/imbiz/ 好友、群治理、组织、会议、报告、实名、举报服务 +server/imstore/ 产品业务存储接口和 MySQL 实现 +server/imnotify/ Outbox 消费、系统消息和 Push 桥接 +server/imsearch/ 索引写入、回填、检索和 ACL 二次过滤 +server/imjobs/ 过期申请、会议提醒、清理、索引回填等后台任务 +``` + +### 9.3 数据库适配策略 + +当前项目同时包含 MySQL、PostgreSQL、MongoDB 和 RethinkDB 适配器。新增所有产品能力到公共 Store 接口会要求四套实现同步完成,改造面很大。 + +建议先明确产品正式支持的数据库: + +- 如果生产只使用 MySQL,则产品业务 Store 首期实现 MySQL,并在启用 `im_product` 配置且底层不支持时拒绝启动,不能静默降级; +- 核心消息幂等、文件授权等通信安全能力应进入公共接口,或明确产品版只支持 MySQL; +- schema 版本从 116 升级时,四个适配器必须分别维护自己的版本,不能把 MySQL 已升级误写成所有数据库都支持; +- `adapter.go` 中的建表 SQL 与 `schema.sql` 必须通过测试保持一致。 + +## 10. 事务、一致性与异步通知 + +好友审批、入群审批、会议创建等操作通常同时涉及业务表、Topic/Subscription 和通知。若直接依次调用,任何一步失败都会出现“状态已接受但没有会话”或“会议创建了但没人收到通知”。 + +建议采用本地事务 + Outbox: + +```mermaid +sequenceDiagram + participant A as App + participant API as IM Business API + participant DB as Database + participant W as Outbox Worker + participant T as Tinode Topic + + A->>API: 接受好友/入群申请(Idempotency-Key) + API->>DB: 开启事务 + API->>DB: 更新申请与关系/成员状态 + API->>DB: 写入 Outbox 事件 + API->>DB: 提交事务 + API-->>A: 返回确定的业务状态 + W->>DB: 锁定未处理事件 + W->>T: 创建/更新订阅并发送系统通知 + W->>DB: 标记完成或计划重试 +``` + +Outbox 消费需要事件去重;状态变更 API 自身也需要 Idempotency-Key。对于必须与核心 Topic 数据原子提交的操作,应把它们放到同一数据库事务接口,而不是依赖最终一致性掩盖授权窗口。 + +## 11. 安全与合规基线 + +服务端改造至少应包含: + +1. **认证安全**:密码强哈希、验证码限频、设备和 IP 风险、Token 撤销、失败锁定; +2. **授权安全**:所有对象查询从服务端认证上下文获得租户和用户;文件、搜索、转发必须二次校验 ACL; +3. **数据安全**:身份证等 PII 字段加密、密钥轮换、响应脱敏、日志禁止明文; +4. **内容安全**:文本、图片、链接和文件审核;病毒扫描;风险策略版本化; +5. **审计安全**:管理员、群主、实名审核、举报处置、匿名信箱访问全部留痕; +6. **接口安全**:限流、请求大小、游标签名、幂等、防重放、统一输入校验; +7. **隐私删除**:明确账号注销、聊天记录、收藏、举报证据和办公数据的不同保留周期; +8. **推送隐私**:锁屏 Push 默认不携带敏感正文,由用户设置控制预览; +9. **可观测性**:请求 ID、用户/租户匿名化标识、消息处理延迟、拒绝原因和插件超时指标。 + +## 12. 分阶段实施建议 + +### 阶段 P0:基线和关键决策 + +- 确定一企一实例还是共享 SaaS; +- 确定产品版支持的数据库; +- 清理旧设计文档与源码的版本漂移,建立 schema/协议单一事实来源; +- 建立数据库迁移、回滚、CI 和环境配置基线; +- 修复文件下载授权; +- 实现消息客户端 ID 幂等; +- 定义消息类型、错误码和兼容策略。 + +完成标准:安全缺口关闭;重复重试不会产生重复消息;旧客户端仍可通信;开发和测试环境可重复升级/回滚。 + +### 阶段 P1:账号、关系和基础聊天产品化 + +- 企业码发现、注册、验证码和忘记密码流程; +- 实名认证基础流程; +- 好友申请、联系人、黑名单和隐私设置; +- 结构化文本/图片/语音/视频/文件/名片消息; +- 会话置顶、免打扰、系统消息; +- @提醒、回复、逐条转发、合并转发; +- 基础风控和审计。 + +完成标准:原型第 1 至 5 类主流程可由真实客户端端到端完成。 + +### 阶段 P2:群治理、收藏、搜索和文件 + +- 入群申请、邀请审批、群公告; +- 管理员、群主转让、全员/成员禁言; +- 群二维码和群文件; +- 收藏索引; +- 消息关键词、文件、链接、图片和视频检索; +- 历史数据回填和搜索 ACL 验证。 + +完成标准:原型第 6、7 类全部服务端流程可验收,搜索不会返回无权消息或附件。 + +### 阶段 P3:通话、设置和安全治理 + +- call_id、通话记录、跨设备抢答、TURN 指标; +- 个人资料和二维码; +- 版本配置、隐私设置、青少年模式强制策略; +- 反诈提示策略、举报、证据、反馈和后台处置; +- 内容审核超时/降级策略演练。 + +完成标准:原型第 8、10、11 类服务端能力可验收,关键安全策略无法通过换设备绕过。 + +### 阶段 P4:企业工作台 + +- 组织与部门; +- 会议通知和提醒; +- 日报、月报和管理视图; +- 总经理信箱; +- 离职编排、数据权限和敏感操作审计。 + +完成标准:原型第 9 类完成业务闭环,组织数据权限和通知可靠性通过测试。 + +## 13. 测试与验收方案 + +### 13.1 自动化测试层次 + +| 测试层 | 重点 | +| --- | --- | +| 单元测试 | 消息类型校验、状态机、权限矩阵、Token/二维码、错误映射 | +| Store 集成测试 | schema 升降级、唯一约束、事务、并发审批、Outbox 重试 | +| WebSocket 端到端 | 登录、订阅、消息、重复发送、回执、禁言、拉黑、通话信令 | +| HTTP API 测试 | 好友/群审批、组织、报告、实名、举报、文件授权 | +| 搜索测试 | 中文关键词、类型筛选、删除同步、权限变化、历史回填 | +| 安全测试 | 跨用户/跨租户、文件 URL 猜测、ID 枚举、重放、越权审批 | +| 故障测试 | 审核超时、Push 失败、Outbox 重试、数据库瞬断、服务重启 | +| 真实设备测试 | Push、相机/麦克风、弱网重连、WebRTC NAT/TURN、跨设备抢答 | + +### 13.2 必须覆盖的关键用例 + +1. 同一个 `x-im-client-mid` 并发重试 10 次,只产生一条消息并返回同一个 SeqId; +2. 拉黑后已有长连接不能继续发消息或呼叫; +3. 被禁言成员通过旧客户端或直接构造协议包仍被服务端拒绝; +4. 非群成员持有文件 URL 时无法下载,退出群后访问策略符合产品约定; +5. 搜索结果不包含已撤回、仅对本人删除、无 Read ACL 或其他租户的消息; +6. 入群审批重复点击不会重复创建 Subscription; +7. 群主转让、管理员变更和禁言都有审计记录且权限立即生效; +8. 实名信息不会出现在日志、Push、Public/Trusted 和普通后台响应; +9. 青少年模式换设备、重装或使用旧客户端仍受服务端限制; +10. 外部审核服务超时后,系统严格按配置的降级策略处理并产生告警; +11. Outbox 消费进程崩溃重启后,通知不丢失且业务副作用不重复; +12. 多租户模式下所有 API、消息、文件、搜索和后台查询均完成负向越权测试。 + +### 13.3 当前基线验证 + +本次分析执行了以下不依赖外部数据库的测试: + +```bash +go test -count=1 ./server ./server/drafty ./server/media ./server/store/types ./server/db/common +``` + +上述包当前全部通过。MySQL/PostgreSQL/MongoDB/RethinkDB 的真实数据库集成测试未在本次报告中执行,改造前应在 CI 中配置正式支持的数据库并固定运行。 + +## 14. 风险与待确认事项 + +| 决策 | 不确认的风险 | 建议 | +| --- | --- | --- | +| 企业隔离模式 | 后期补 tenant 会重写大量查询和数据 | P0 首先决策,优先一企一实例 | +| 正式支持的数据库 | 四种适配器同步开发显著拖慢交付 | 产品业务首期明确 MySQL | +| 删除好友后的历史 | 客户端与服务端理解不同导致数据争议 | 默认保留历史、禁止新消息 | +| 收藏是否为独立副本 | 源消息删除后的合规行为不明确 | 明确快照和保留策略 | +| 全员撤回时限 | 无规则会导致历史任意篡改 | 配置时限、角色和审计 | +| 匿名信箱的匿名边界 | 绝对匿名与合规追溯冲突 | 业务匿名、受控审计可追溯 | +| 审核失败策略 | 失败开放有风险,失败关闭影响可用性 | 按场景分级并可观测 | +| 群音视频范围 | 当前 P2P 模型无法自然扩展 | 若需要多人通话,独立评估 SFU | +| 搜索技术选型 | MySQL 与独立搜索引擎成本差异大 | 依据消息量、中文检索和运维能力压测决定 | + +## 15. 建议的第一批工程任务 + +建议立刻拆分以下可执行任务: + +1. 建立“源码真实基线”文档,删除或标记未实现的 `x-im-*`、schema 119 和测试工具说明; +2. 编写消息扩展协议 v1,包括类型、字段、大小、错误码和兼容矩阵; +3. 设计并实现消息幂等,补并发和断线重试测试; +4. 修复文件下载授权,补上传者、群成员、退群用户和泄露 URL 测试; +5. 决定租户和数据库支持范围,冻结第一版 schema; +6. 新建 `imapi`、`impolicy`、`imbiz`、`imstore`、`imnotify` 的最小骨架; +7. 以“好友申请 → 接受 → 创建会话 → 发消息 → 拉黑”为第一条端到端业务链; +8. 以“群申请 → 审批 → 禁言 → 公告”为第二条端到端业务链; +9. 引入 Outbox 和审计日志,所有后续业务复用; +10. 在 CI 中加入服务端单元测试、MySQL 集成测试和 WebSocket 协议回归测试。 + +## 16. 最终建议 + +本项目具备可继续演进的实时通信核心,最合理的路线不是重写聊天服务,而是把 Tinode 明确定位为通信内核,在它外部增加可事务、可检索、可审计的产品业务层,并只在发布、订阅、文件和通话等关键入口加入同步策略校验。 + +实施时应优先解决租户、幂等和文件授权,再做好友与群业务。企业工作台与通信内核保持模块边界,通过 HTTP API 管理业务状态、通过 Outbox 和系统 Topic 可靠通知。这样既能复用现有 Topic/ACL/Push/WebRTC 能力,也能避免把大量办公和审批逻辑耦合进消息路由主链路。 diff --git a/docs/im/IM-前后端对接文档.md b/docs/im/IM-前后端对接文档.md new file mode 100644 index 0000000..b416f68 --- /dev/null +++ b/docs/im/IM-前后端对接文档.md @@ -0,0 +1,1965 @@ +# IM 前后端逐图对接协议 + +> 版本:v2.0 +> 日期:2026-08-11 +> 需求依据:[IM 产品原型需求文档](IM-产品原型需求文档.md) +> 设计依据:[IM 系统设计文档](IM-系统设计文档.md) +> 说明:第 1 至第 11 章的章节编号、图片标题和顺序与产品原型完全一致。每张图均给出可直接开发、联调和验收的协议。 + +## 0. 公共对接约定 + +### 0.1 实现状态 + +| 状态 | 含义 | +| --- | --- | +| `已实现` | 当前服务端已有能力,可按本文联调 | +| `需改造` | 已有基础能力,但字段、鉴权或业务规则需要调整 | +| `新增` | 当前服务端没有该产品接口或数据模型 | +| `纯客户端` | 界面或系统能力,不调用服务端;关联的提交协议会单独指出 | + +文档中所有 `新增`、`需改造` 接口都是目标契约,不代表当前代码已经可用。前后端必须以本文字段为准实施,接口完成前可使用同结构 Mock。 + +### 0.2 API Key、启动入口与通道 + +#### 0.2.1 现有系统的 API Key 从哪里来 + +**API Key 不是客户端启动后向 IM 服务端申请得到的值。现有代码没有“获取 API Key”的 HTTP 或 WebSocket 接口。** + +API Key 是应用级接入标识,由部署人员在发布客户端前生成并配置: + +1. 新部署同时生成 API Key 和 HMAC Salt:在项目根目录执行 `go run ./keygen`。 +2. 已有服务端继续使用当前 `api_key_salt` 时,执行 `go run ./keygen -sequence <新序号> -salt '<服务端 api_key_salt>'` 生成兼容的普通应用 Key。 +3. 输出中的 `HMAC salt` 配置到服务端 `server/tinode.conf` 的 `api_key_salt`;输出中的 `API key` 配置到 iOS、Android 或 Web 客户端构建配置/受控环境配置。 +4. 客户端启动时从自己的应用配置读取 API Key,不调用 IM 接口获取。禁止把 `-isroot 1` 生成的 Root Key 放进客户端。 + +生成示意: + +```text +$ go run ./keygen +API key v1 seq1 [ordinary]: +HMAC salt: +``` + +API Key、企业码和用户 Token 是三个不同概念: + +| 值 | 取得时间 | 作用 | 是否代表用户身份 | +| --- | --- | --- | --- | +| `api_key` | 应用发布/环境配置时预置 | 允许应用访问 Tinode 入口 | 否 | +| `tenant_code` | 用户输入企业码 | 将 Session 路由并绑定到企业 | 否 | +| `token` | 用户登录成功后由服务端返回 | 标识当前租户内的登录用户 | 是 | + +API Key 可以被客户端应用提取,因此不能把它当成用户密码或租户授权。现有 `checkAPIKey` 只校验 Key 的格式和 HMAC 签名;最终权限仍必须依赖租户 Session、用户 Token 和 Topic/业务 ACL。 + +当前 `checkAPIKey` 没有按 `sequence` 吊销旧 Key 的逻辑。生成新序号不会自动让旧 Key 失效;若需要精细轮换,服务端必须增加最低可接受序号或撤销表。直接更换 `api_key_salt` 会让所有旧 Key 同时失效,必须与客户端升级协调。 + +#### 0.2.2 客户端怎样携带 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 | `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、普通日志、埋点或崩溃报告。 + +WebSocket Upgrade 阶段由 `server/hdl_websock.go` 先调用 `checkAPIKey`。缺少或无效时不会建立 WebSocket,直接返回 HTTP 403: + +```json +{"ctrl":{"code":403,"text":"valid API key required","ts":"2026-08-11T08:30:15.123Z"}} +``` + +客户端应把该错误视为应用配置/环境配置错误,停止自动重连并上报脱敏诊断;它不是账号密码错误,也不能通过重新登录解决。 + +两种连接方式的第一个请求对照如下: + +| 连接方式 | 发起第一个网络请求前已有的值 | 第一个网络请求 | 第一个响应得到的值 | 下一步 | +| --- | --- | --- | --- | --- | +| WebSocket(推荐) | `host + api_key + tenant_code` | 带 API Key Upgrade `/v0/channels` | HTTP 101 和已建立的 WebSocket;不会返回新 API Key 或 sid | 发送 `{hi tenant}` | +| Long Polling(兼容) | `host + api_key + tenant_code` | `POST /v0/channels/lp` | HTTP 201 和 `ctrl.params.sid` | 带 `sid` POST `{hi}`,再 GET 等待响应 | + +现有实现对应关系: + +| 源码 | 作用 | +| --- | --- | +| `keygen/keygen.go` | 生成普通/Root API Key 和服务端 HMAC Salt | +| `server/api_key.go` | `checkAPIKey` 校验 API Key 格式与签名 | +| `server/http.go` | 按 Header、Query、Form、Cookie 顺序读取 API Key | +| `server/hdl_websock.go` | WebSocket Upgrade 前校验 API Key,无效直接 403 | +| `server/hdl_longpoll.go` | 创建 Long Polling Session 并返回 `sid` | +| `server/session.go` | 处理 `{hi}` 租户绑定、`{login}` 和登录 Token 返回 | + +现有 API Key 是应用级、非租户级,同一个普通应用 Key 可以进入不同企业;具体租户只能由后续 `{hi}.tenant` 决定。若将来要求每个租户独立 API Key,需要新增 Key 与租户绑定关系,不能只修改客户端配置。 + +### 0.3 多租户强制规则 + +1. 未登录阶段只传公开的 `tenant_code`;客户端永远不接收、不提交数据库 `tenant_id`。 +2. WebSocket 第一条消息必须是 `{hi}`,其中 `tenant` 为企业码。服务端解析后把租户固定到 Session。 +3. Token 内部绑定租户。Token 租户与当前 Session 租户不一致时返回 `TENANT_MISMATCH`,不得自动切换。 +4. 切换企业必须退出登录、关闭 WebSocket、清除该租户的 Token/缓存,再重新解析企业码和握手。 +5. 已登录 HTTP 请求的租户和用户只从 Token Principal 获取。请求体中的 `tenant_id`、`user_id` 不能覆盖身份。 +6. 跨租户对象统一返回 `RESOURCE_NOT_FOUND` 或 `FORBIDDEN`,不得暴露对象是否存在。 + +### 0.4 HTTP 请求与响应 + +成功响应统一为: + +```json +{ + "code": 0, + "data": {}, + "request_id": "01K...", + "server_time": "2026-08-11T08:30:15.123Z" +} +``` + +失败响应统一为: + +```json +{ + "code": 30001, + "reason": "GROUP_MUTED", + "message": "permission denied", + "details": {}, + "request_id": "01K...", + "server_time": "2026-08-11T08:30:15.123Z" +} +``` + +客户端只使用稳定的 `reason` 分支和本地化文案,不解析 `message`。ID、Topic、Seq 游标在客户端均按字符串处理;时间统一为 UTC RFC3339 毫秒。分页统一使用 `limit` 与不透明 `cursor`,返回 `items`、`next_cursor`、`has_more`。 + +### 0.5 WebSocket 建连、登录和恢复顺序 + +#### 0.5.1 现有服务端可直接执行的冷启动流程 + +```text +读取客户端预置的 host 和普通 API Key +-> 用户输入/客户端读取 tenant_code +-> 带 API Key 完成 WebSocket Upgrade +-> hi(tenant_code) +-> 收到 hi 的 201 ctrl,确认 tenant 与服务端限制 +-> 有历史 Token 时 token login,否则 basic login/acc +-> 收到 login 的 200 ctrl,保存 user/token/expires +-> sub(me) 拉取本人资料和会话订阅 +-> sub(fnd) 建立搜索 Topic +-> 订阅需要在线的会话并按 seq 增量拉取 +-> 进入可发送状态 +``` + +当前服务端没有 `/v1/im/tenants/resolve`,因此现阶段企业码是否有效是在第一帧 `{hi}` 中校验的。只有 WebSocket Upgrade 返回 `101 Switching Protocols` 后才能发送下面的 JSON 文本帧。 + +`hi` 请求: + +```json +{"hi":{"id":"hi-1","ver":"0.25","ua":"android/1.0.0","dev":"device-id","lang":"zh-CN","tenant":"acme"}} +``` + +`login` 请求使用 `basic` 或 `token`: + +```json +{"login":{"id":"login-1","scheme":"basic","secret":"base64(username:password)"}} +``` + +```json +{"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 的命令响应,不是业务幂等键。 + +`hi` 成功示例: + +```json +{ + "ctrl": { + "id": "hi-1", + "code": 201, + "text": "created", + "params": { + "ver": "0.25", + "tenant": {"code": "acme", "name": "示例企业"}, + "maxFileUploadSize": 104857600, + "callTimeout": 30, + "iceServers": [] + } + } +} +``` + +客户端只使用实际响应中存在的限制字段;未知字段忽略。`hi` 返回非 2xx 时必须关闭连接,不得继续发送 `login`。 + +登录成功后发送: + +```json +{"sub":{"id":"sub-me-1","topic":"me","get":{"what":"desc sub tags cred"}}} +``` + +客户端按相同 `id` 接收 `{ctrl}` 和一个或多个 `{meta}`,从 `meta.desc` 取得本人资料和读写游标,从 `meta.sub` 取得会话 Topic、访问权限、`seq/read/recv` 与会话摘要。不要依赖 `{ctrl}`、`{meta}` 的固定先后顺序。 + +随后建立查找 Topic: + +```json +{"sub":{"id":"sub-fnd-1","topic":"fnd"}} +``` + +进入具体会话时订阅并增量拉取,`since` 使用客户端已完整持久化的最后 `seq + 1`: + +```json +{"sub":{"id":"sub-topic-1","topic":"grp...","get":{"what":"desc sub data del","data":{"since":125,"limit":50},"del":{"since":1,"limit":50}}}} +``` + +订阅成功后才允许 `{pub}`、`note read/recv` 等会话操作。断线恢复必须重新执行 `WebSocket Upgrade -> hi -> token login -> sub me -> 恢复 Topic`,同一连接内不得跳过 `hi` 或切换企业码。 + +#### 0.5.2 Long Polling 兼容流程:先取得的是 sid + +如果客户端无法使用 WebSocket,可以使用现有 `/v0/channels/lp`。该模式确实需要“先请求取得一个值”,但取得的是短期 Session ID `sid`,API Key 仍然必须由客户端预先持有。 + +第一步,创建 Long Polling Session: + +```http +POST /v0/channels/lp HTTP/1.1 +Host: im.example.com +X-Tinode-APIKey: <预置的普通 API Key> +Content-Length: 0 +``` + +服务端返回 HTTP 201: + +```json +{"ctrl":{"code":201,"text":"created","params":{"sid":"session-id"},"ts":"2026-08-11T08:30:15.123Z"}} +``` + +第二步开始,所有 Long Polling 请求都同时携带 API Key 和 `sid`。发送 `hi`: + +```http +POST /v0/channels/lp?sid=session-id HTTP/1.1 +X-Tinode-APIKey: <预置的普通 API Key> +Content-Type: application/json + +{"hi":{"id":"hi-1","ver":"0.25","ua":"android/1.0.0","platf":"android","lang":"zh-CN","tenant":"acme"}} +``` + +POST 只负责把命令送入 Session。客户端再保持一个不带 Body 的 GET 来接收下一条 `{ctrl}`、`{meta}`、`{data}`、`{pres}` 或 `{info}`: + +```http +GET /v0/channels/lp?sid=session-id HTTP/1.1 +X-Tinode-APIKey: <预置的普通 API Key> +``` + +收到 `hi` 成功后,以相同方式 POST `{login}`,再 GET 登录结果;登录成功后依次 POST `{sub me}`、`{sub fnd}` 和会话订阅,并持续发起下一次 GET 等待下行消息。 + +`sid` 无效或过期时服务端返回 HTTP 403 `invalid or expired session`,客户端必须重新创建 Long Polling Session 并从 `hi` 开始,不能只重发 `login`。Long Polling 是兼容方案,移动端和 Web 端正常网络条件下优先使用 WebSocket。 + +#### 0.5.3 目标产品流程 + +第 1.1 节规划的 `POST /v1/im/tenants/resolve` 属于新增接口。实现后目标流程调整为: + +```text +读取预置 API Key +-> POST /v1/im/tenants/resolve 校验企业码并取得服务地址/品牌配置 +-> 带同一 API Key 建立返回地址的 WebSocket +-> hi 再次绑定并校验 tenant_code +-> login -> sub me -> 恢复会话 +``` + +企业解析接口只改善入口、服务发现和品牌配置,不能代替 `{hi}` 的租户绑定。即使 HTTP 解析成功,WebSocket 第一帧仍必须带相同 `tenant_code`。 + +### 0.6 Topic 与产品消息 + +核心命令:`sub` 订阅、`pub` 发送、`get` 拉取、`set` 更新 Topic、`leave` 离开、`del` 删除、`note` 发送回执/临时状态。发送成功为 `{ctrl code:202, params:{seq}}`,服务端下行消息为 `{data}`。 + +所有产品消息在 `pub.head` 中携带: + +```json +{ + "pub": { + "id": "pub-1", + "topic": "p2p...", + "noecho": false, + "head": { + "x-im-schema": 1, + "x-im-type": "text", + "x-im-client-mid": "01K...", + "mentions": [] + }, + "content": {"text":"你好"} + } +} +``` + +`x-im-client-mid` 由客户端生成 ULID/UUID,作用域为“租户 + 发送人”,断线重试必须复用;服务端目标实现应返回第一次写入的同一 `seq`。未知 `x-im-type` 显示“暂不支持的消息”,不得崩溃或删除消息。 + +消息类型首版固定为:`text`、`image`、`video`、`voice`、`file`、`contact_card`、`link`、`forward_bundle`、`call`、`system_event`。自定义表情启用后追加 `sticker`。媒体内容只保存 `file_id`、文件元数据和缩略图 ID,不保存可长期绕过鉴权的公共 URL。 + +### 0.7 HTTP 幂等、重试和安全 + +1. 创建、提交、同意、拒绝、删除、转让、收藏、转发等写请求必须携带 `Idempotency-Key`。 +2. GET、断线同步和同幂等键写请求可自动重试;密码、验证码和风控确认只按接口返回的 `retry_after_seconds` 重试。 +3. `401 TOKEN_EXPIRED` 只允许一次静默刷新;刷新失败必须退出登录。`403` 不重试,`429` 按服务端退避。 +4. 服务端必须在最终写入前再次校验好友关系、群角色、禁言、消息归属、文件 ACL、租户配额和风控,不能信任前端按钮状态。 +5. 普通用户禁止向系统 Topic 发布;系统通知必须由服务端受控身份生成。 + +## 1. 账号注册、登录与身份安全 + +### 1.1 企业码登录.png + +企业码登录 + +**状态:新增企业解析接口;WebSocket 租户握手已实现。** + +**当前系统的执行方式:** + +- 前端动作:输入企业码并点击“进入企业”。只去掉首尾空格并校验非空、最长 64 字符;当前服务端没有自动转小写,客户端不得擅自改变用户输入的大小写。 +- 客户端从构建/环境配置读取 `host` 和普通 API Key,使用 `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"}`。 +- 成功数据:`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`;客户端不得区分不存在和停用。网络失败保留输入并允许重试。 + +成功响应示例: + +```json +{ + "code": 0, + "data": { + "tenant": {"code": "acme", "name": "示例企业", "tenant_desc": null, "branding": {}}, + "endpoints": { + "websocket": "wss://im.example.com/v0/channels", + "api": "https://im.example.com/v1/im", + "file": "https://im.example.com/v0/file" + }, + "auth_methods": ["password", "sms"], + "password_policy": {"min_length": 8, "max_length": 64}, + "app_update": {"type": "none"} + }, + "request_id": "01K...", + "server_time": "2026-08-11T08:30:15.123Z" +} +``` + +### 1.2 登录页面.png + +登录页面 + +**状态:基本账号密码登录已实现;协议勾选和安全 Token 生命周期需改造。** + +- 前端动作:账号、密码非空且已勾选协议后启用登录;密码仅驻留当前页面内存。 +- 协议:已完成 `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 失效。 +- 错误:`AUTH_FAILED` 统一显示账号或密码错误;`TENANT_MISMATCH` 清 Token 并回企业码页;`ACCOUNT_DISABLED`、`REALNAME_REQUIRED`、`YOUTH_RESTRICTED` 按专用页面处理。 + +登录成功示例: + +```json +{ + "ctrl": { + "id": "login-1", + "code": 200, + "text": "ok", + "params": { + "user": "usr...", + "authlvl": "auth", + "token": "base64-opaque-token", + "expires": "2026-08-11T10:30:15.123Z" + } + } +} +``` + +失败仍是同一 `{ctrl}` 结构,使用 `code` 和改造后的稳定 `params.reason` 分支;客户端不得用 `text` 做程序判断。 + +### 1.3 登录页面-密码错误.png + +登录页面-密码错误 + +**状态:已实现基础错误,错误码和限流需改造。** + +- 触发:`login` 返回 `{ctrl code:401}` 或产品错误 `AUTH_FAILED`。 +- 前端:保留账号、清空密码、聚焦密码框;统一显示“账号或密码错误”,不得显示账号是否存在。 +- 服务端:按 `tenant + account + device/IP` 限流,连续失败返回 `AUTH_RATE_LIMITED` 和 `retry_after_seconds`;日志仅记录账号哈希和 Request ID。 +- 验收:同一不存在账号与错误密码在响应码、耗时区间、前端文案上保持一致。 + +### 1.4 注册页面.png + +注册页面 + +**状态:Tinode `{acc}` 基础注册已实现;短信预校验与租户配额需改造。** + +- 获取验证码:调用 `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。 + +```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":"用户昵称"}}}} +``` +- 成功:返回新 `user` 和登录 Token,自动 `sub me`。创建用户前原子校验 `im_tenant_config.max_user_count`,超限返回 `TENANT_USER_QUOTA_EXCEEDED`。 +- 安全:手机号在租户内唯一;验证码只保存摘要、单次消费;密码按服务端策略校验并使用强哈希保存。 + +### 1.5 注册页面-倒计时.png + +注册页面-倒计时 + +**状态:新增。** + +- 倒计时以 1.4 返回的 `resend_after_seconds` 为准,并结合 `server_time` 计算,不能只依赖本地固定 60 秒。 +- 倒计时内前端禁用按钮;即使绕过按钮重复调用,服务端返回 `VERIFICATION_RATE_LIMITED` 和剩余秒数。 +- App 重进页面可使用内存/安全临时存储中的 `challenge_id` 恢复;超过 `expires_in_seconds` 必须重新发送,旧验证码失效。 + +### 1.6 忘记密码.png + +忘记密码 + +**状态:新增。** + +- 发码:`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;客户端清理本租户凭证并返回登录页。 +- 失败:`PASSWORD_POLICY_VIOLATION` 返回规则明细;验证码错误不返回账号存在性,达到上限后冻结 challenge。 + +### 1.7 身份验证(安卓).png + +身份验证(安卓) + +**状态:新增。** + +- 敏感操作先调用 `POST /v1/im/security/challenges`,Body `{"scene":"close_youth_mode","channel":"sms"}`;手机号由登录身份读取,客户端不提交目标手机号。 +- 发码/校验:服务端返回 `challenge_id` 和脱敏手机号;提交 `POST /v1/im/security/challenges/{id}/verify`,Body `{"code":"..."}`。 +- 成功返回 `action_token`、`action`、`expires_at`;后续敏感接口以 `X-Action-Token` 携带,Token 只能用于指定动作且单次消费。 +- 错误:`CHALLENGE_EXPIRED`、`CODE_INVALID`、`TOO_MANY_ATTEMPTS`;前端不得把短信验证码或 action token 落普通存储。 + +### 1.8 实名认证.png + +实名认证 + +**状态:新增。** + +- 查询:`GET /v1/im/security/realname` 返回 `status=unverified|pending|verified|rejected`、脱敏姓名/证件号和 `can_submit`。 +- 提交:`POST /v1/im/security/realname/submissions`,Body `{"name":"张三","id_type":"cn_id","id_number":"..."}`,必须带幂等键。 +- 成功:返回 `submission_id`、`status:"pending"`;前端禁用重复提交,通过 `GET /v1/im/security/realname` 或 `security.realname.updated` 系统事件更新状态。 +- 安全:证件号传输和存储加密、展示脱敏;普通日志、搜索索引和消息中禁止出现原值;重复身份由租户策略决定并返回稳定错误。 + +### 1.9 实名认证-已实名.png + +实名认证-已实名 + +**状态:新增。** + +- 页面只调用 `GET /v1/im/security/realname`。`verified` 时返回如 `masked_name:"张*"`、`masked_id_number:"110***********1234"`、`verified_at`。 +- 前端只读展示,不缓存原始证件信息,不提供直接修改接口。 +- 更正信息使用独立申诉 `POST /v1/im/security/realname/appeals`,需二次身份验证;不复用本页保存按钮。 + +### 1.10 实名认证(安卓).png + +实名认证(安卓) + +**状态:新增。** + +- 登录后 `GET /v1/im/bootstrap` 返回 `security.realname{status,required,allow_skip,remind_after}`;只有 `required=true` 且未认证时显示弹窗。 +- 点击“去认证”进入 1.8;点击稍后仅在 `allow_skip=true` 时本地关闭,并按 `remind_after` 控制再次提示。 +- 服务端对必须实名的受限写操作仍返回 `REALNAME_REQUIRED`,前端弹窗不能作为授权依据。 + +### 1.11 设置独立密码(安卓).png + +设置独立密码(安卓) + +**状态:新增。** + +- 设置:`POST /v1/im/settings/independent-password`,Body `{"password":"1234","purpose":"youth_mode","action_token":"..."}`;服务端只存强哈希,不返回密码。 +- 验证:`POST /v1/im/settings/independent-password/verify` 返回短时 `action_token`,用于关闭青少年模式或修改密码。 +- 规则:四位数字、禁止明显连续/重复组合;连续错误返回 `TOO_MANY_ATTEMPTS` 和 `retry_after_seconds`。前端输入框禁截屏/自动填充,离开页面立即清空。 +- 忘记密码:跳转 1.7 的短信身份验证,验证后重置,不提供明文找回。 + +## 2. 消息首页、全局导航与系统通知 + +### 2.1 消息.png + +消息 + +**状态:会话订阅已实现;产品摘要和系统入口需改造。** + +- 登录后发送 `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=`。 +- 本地缓存按 `tenant_code + user_id` 隔离。切租户、退出或账号变化时不得复用另一身份的会话摘要。 + +### 2.2 消息-无网络状态.png + +消息-无网络状态 + +**状态:需改造客户端恢复流程和消息幂等。** + +- 纯网络状态由客户端监听;断网时展示缓存并禁止需要在线确认的写操作,未发送消息进入本地 `pending` 队列。 +- 使用指数退避加随机抖动重连;恢复后严格执行 `hi -> token login -> sub me -> 恢复 Topic -> get data`。 +- 每个 Topic 按最后持久化的 `seq/read/recv` 增量拉取。重发消息复用 `x-im-client-mid`,服务端需实现去重并返回原 `seq`。 +- Token 失效转登录页;租户停用返回企业不可用页;禁止无限快速重连。 + +### 2.3 消息-更多.png + +消息-更多 + +**状态:菜单为纯客户端;权限数据新增。** + +- 菜单打开/关闭不调用服务端。菜单项来自登录后 `GET /v1/im/bootstrap` 的 `features` 与 `permissions`。 +- `create_group=false` 时隐藏“创建群聊”;`user_search=false` 时隐藏“添加好友”;扫码权限只影响实际扫码,不影响服务端授权。 +- 各入口协议分别见 5.3、6.1、10.1;服务端在实际写接口再次鉴权,不能只依赖隐藏按钮。 + +### 2.4 消息页面—长按弹出.png + +消息页面—长按弹出 + +**状态:基础消息操作部分已实现;产品权限矩阵需改造。** + +- 长按本身不请求。客户端根据消息的 `type/from/seq/created_at/status`、当前 Topic 权限和服务端下发的 `message_policy` 预裁剪菜单。 +- 引用/复制为本地动作;收藏见 4.16;转发见 4.17~4.20;删除使用 Tinode `{del what:"msg",topic,delseq}`;撤回需新增 `POST /v1/im/topics/{topic}/messages/{seq}/recall`。 +- 编辑使用 `POST /v1/im/topics/{topic}/messages/{seq}/revisions`,Body `{"content":...,"client_mid":"..."}`;仅文本且在时限内允许。 +- 群回执调用 `GET /v1/im/topics/{topic}/messages/{seq}/receipts?cursor=`。所有最终操作由服务端验证消息归属、时限、群角色和租户。 + +### 2.5 系统消息.png + +系统消息 + +**状态:需改造。** + +- `GET /v1/im/system-notifications?cursor=` 返回 `id,event_type,title,summary,created_at,read_at,action`;新通知通过服务端专用系统 Topic 的 `system_event` 消息推送。 +- 新设备登录事件内容为 `device_name,platform,ip_masked,login_at,security_action`;IP 只下发必要脱敏值。 +- 已读:`POST /v1/im/system-notifications/{id}/read`,带幂等键;跳转目标只接受服务端白名单 `action.type` 和内部参数。 +- 当前普通认证用户可向 `sys` Topic 发布的路径必须封禁。客户端不得渲染普通用户伪造的系统卡片为可信通知。 + +## 3. 单聊会话、会话建立与消息状态 + +### 3.1 个人对话框(对方的状态).png + +个人对话框(对方的状态) + +**状态:需改造好友关系状态。** + +- 进入会话先 `GET /v1/im/conversations/{topic}/context`,返回 `relationship=pending_inbound`、`can_send=false`、对方公开资料和申请 ID。 +- 历史申请提示由 `relationship_event` 系统消息渲染;普通消息仍通过 Topic `{data}` 下行。 +- 输入区锁定;“通过/拒绝”调用 5.2 的好友申请接口。前端不可通过直接 `pub` 绕过,服务端发布路径必须校验好友关系。 + +### 3.2 个人对话框(对方的状态)(1).png + +个人对话框(对方的状态)(1) + +**状态:在线状态已具备基础能力;关系文案新增。** + +- 订阅 P2P Topic 后用 `{pres}` 更新在线/离线,`last_seen` 仅在对方隐私允许时展示。 +- `GET /v1/im/conversations/{topic}/context` 返回 `display_name,avatar_file_id,online_visibility,relationship`。 +- 好友关系变化由服务端生成 `system_event{event_type:"friend_relationship_changed"}`,客户端不得根据本地动作自行永久插入系统记录。 + +### 3.3 个人对话框(我的状态).png + +个人对话框(我的状态) + +**状态:新增好友申请状态。** + +- 发起申请使用 `POST /v1/im/friend-requests`,Body `{"target_user_id":"usr...","message":"我是...","source":"search"}`。 +- 返回 `request_id,status:"pending",created_at`;页面以该服务端状态显示“已发送”,重复点击用同一幂等键返回原申请。 +- 申请待处理时 `context.relationship=pending_outbound`、`can_send=false`,禁止重复创建和普通消息发送。 + +### 3.4 个人对话框(我的状态)(1).png + +个人对话框(我的状态)(1) + +**状态:发送和读回执已实现基础能力;送达状态映射需统一。** + +- 本地生成 `client_mid` 后显示 `sending`;`pub` 收到 `202 + seq` 更新为 `sent`;失败保留同一 `client_mid` 重试。 +- 对方 `note what:"recv"` 更新 `delivered`,`note what:"read"` 更新 `read`;多设备取服务端最大游标。 +- 状态只附着在气泡元数据,不修改消息正文。服务端拒绝时映射为 `failed(reason)`,不得伪装成已发送。 + +### 3.5 开启沟通页面.png + +开启沟通页面 + +**状态:新增好友审批与 Topic 联动。** + +- 好友申请通过后,服务端在同一事务内建立双方关系和 P2P 订阅,并生成 `friend_relationship_changed:accepted` 系统事件。 +- 客户端收到事件或重新查询 `context.relationship=friend` 后解锁输入区;不得仅因本地“通过”按钮成功动画解锁。 +- 首次进入订阅 P2P Topic,再按 `since` 拉取申请上下文和正常消息。 + +### 3.6 双方沟通状态.png + +双方沟通状态 + +**状态:上传基础已实现;媒体消息 Schema、ACL 和恢复需改造。** + +- 选择媒体后本地生成 `client_mid` 和稳定占位;调用文件上传,得到 `file_id` 后再 `pub x-im-type=image|video|voice`。 +- `content` 至少含 `file_id,name,size,mime,width,height,duration_ms,thumbnail_file_id` 中适用字段;上传进度是客户端任务状态,不写成聊天消息。 +- 服务端发布成功后关联文件与 Topic/消息;文件下载必须校验消息可见性。上传失败只重试上传,成功但 `pub` 失败时复用已上传 `file_id` 和 `client_mid`。 + +### 3.7 个人对话—拉黑状态/已读状态.png + +个人对话—拉黑状态/已读状态 + +**状态:黑名单产品规则新增。** + +- 拉黑后 `context.relationship=blocked_by_me|blocked_by_peer`、`can_send=false`;服务端 `pub` 返回统一 `MESSAGE_NOT_ALLOWED`,不得向发送者泄露具体是对方拉黑。 +- 失败消息只保存在发送端本地并显示失败,不下发给对方、不写入正常消息表。 +- 已读仍按历史最大 `read` 渲染;拉黑不能回滚既有回执。解除拉黑见 5.10、5.12。 + +### 3.8 个人对话—删除状态.png + +个人对话—删除状态 + +**状态:新增好友删除后的发送约束。** + +- 删除好友使用 `DELETE /v1/im/friends/{user_id}`;成功后双方关系变为 `none`,服务端推送关系变化事件。 +- 后续 `pub` 返回 `FRIEND_RELATION_REQUIRED`;前端保留失败草稿,提供“重新添加好友”入口,不自动发申请。 +- 历史聊天是否保留由 Topic 权限决定,删除好友不等于删除双方历史消息。 + +### 3.9 个人对话-键盘收起状态.png + +个人对话-键盘收起状态 + +**状态:纯客户端。** + +- 键盘、表情和附加面板收起不调用服务端;保持列表锚点和未发送草稿。 +- 草稿默认按 `tenant + user + topic` 本地加密保存;若实现多端草稿,再单独调用 `PUT /v1/im/topics/{topic}/draft`,不得混入聊天消息。 + +### 3.10 个人对话-发各种文件状态.png + +个人对话-发各种文件状态 + +**状态:面板纯客户端;上传需改造。** + +- 打开面板不请求。选择前依据 `context.capabilities` 判断图片、视频、语音、文件是否允许,调用系统选择器/相机。 +- 上传前校验 `mime,size` 和 `bootstrap.limits.max_file_size`;服务端仍必须重新校验租户配额、文件类型和会话权限。 +- 具体上传/发消息使用 3.6 协议;相机和麦克风授权见 10.20、10.21。 + +### 3.11 个人对话-发各种文件状态(1).png + +个人对话-发各种文件状态(1) + +**状态:需改造产品消息 Schema。** + +- 客户端按 `x-im-type` 选择文本、名片、文件等卡片,公共字段统一使用 `from,topic,seq,ts,head,content`。 +- 每种卡片必须实现未知字段忽略、未知类型占位、发送/失败/回执状态和长按动作。 +- 重新安装或换设备后仅凭服务端消息即可还原卡片,不能依赖发送端本地路径或临时 URL。 + +### 3.12 个人对话-发送名片状态.png + +个人对话-发送名片状态 + +**状态:新增产品消息类型。** + +- 选人:`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`;用户不可见或已离职时显示失效卡片,不跨租户查询。 + +## 4. 消息输入、媒体内容与消息操作 + +### 4.1 键盘展开.png + +键盘展开 + +**状态:纯客户端。** + +- 键盘展开、列表上推和光标变化不调用服务端;草稿按 `tenant + user + topic` 保存。 +- 点击发送时才校验 `context.can_send` 并执行 `pub text`;正文去首尾策略、最大长度从 `bootstrap.limits.max_text_length` 获取,服务端再次校验。 + +### 4.2 @人员—选择提醒人.png + +@人员—选择提醒人 + +**状态:新增群成员查询。** + +- 输入 `@` 后调用 `GET /v1/im/groups/{topic}/members?query=&cursor=&mentionable=true`,返回 `user_id,display_name,avatar_file_id,role,mentionable`。 +- `@所有人` 是虚拟项,只有响应 `permissions.can_mention_all=true` 时展示,不以特殊用户 ID 表示。 +- 搜索做 300ms 防抖并取消旧请求;服务端只返回当前租户且仍在群内的成员。 + +### 4.3 @人员—完成.png + +@人员—完成 + +**状态:纯客户端选择;发送协议需改造。** + +- 完成后在编辑器插入带范围的 Mention 节点,内部保存 `user_id`,显示昵称只是快照。 +- 发送时 `pub.head.mentions=[{"user_id":"usr...","offset":0,"length":3}]`;`@所有人` 使用 `mention_all:true`,不得伪造用户 ID。 +- 服务端验证成员身份和 `mention_all` 权限;无效成员可删除提醒语义但保留可读文本,越权 `@所有人` 返回 `MENTION_ALL_FORBIDDEN`。 + +### 4.4 @人员状态.png + +@人员状态 + +**状态:需改造提醒和 Push。** + +- 消息仍通过 `{data}` 下行,客户端使用结构化 `mentions` 高亮,禁止通过字符串扫描昵称判断。 +- 当前用户被提及时会话摘要标记 `mentioned=true`;读到对应 `seq` 后随 `note read` 清除。 +- Push 服务按结构化目标生成“有人@你”文案;已退群用户不能收到新提醒,但历史消息仍按快照渲染。 + +### 4.5 表情包展开(发送按钮默认状态).png + +表情包展开(发送按钮默认状态) + +**状态:纯客户端。** + +- 面板、分类、最近使用和删除键均本地处理;未选择内容且文本为空时禁用发送。 +- Unicode 表情作为普通 `text` 内容发送。自定义表情资源首版未定义,不得把本地资源路径直接发送。 + +### 4.6 表情包发送页面.png + +表情包发送页面 + +**状态:Unicode 表情使用已实现文本消息;独立表情包新增。** + +- 文本表情按 `pub x-im-type:text` 发送。独立自定义表情若启用,使用 `x-im-type:"sticker"`,`content={pack_id,sticker_id,file_id,width,height}`。 +- 自定义表情发送前服务端校验表情包属于当前租户且可用;未知表情显示占位,不使用任意外链 URL。 + +### 4.7 语音—按住说话.png + +语音—按住说话 + +**状态:录音纯客户端;上传和语音消息需改造。** + +- 按下前检查麦克风权限和 `context.capabilities.voice`;权限不足进入 10.21,不发请求。 +- 松开发送:编码为服务端允许的 MIME,校验最短/最长时长,上传后 `pub x-im-type:voice`,内容含 `file_id,duration_ms,size,mime,waveform`。 +- 上滑取消、过短、系统来电打断时删除临时文件,不调用上传接口。 + +### 4.8 语音按住说话.png + +语音按住说话 + +**状态:纯客户端。** + +- 音量幅度、上滑取消区域和浮层颜色来自本地录音会话,不上传实时音量。 +- 进入取消区只改变本地状态;最终抬手决定删除或执行 4.7 的上传发送。 + +### 4.9 内容沟通对话3-语音录音中.png + +内容沟通对话3-语音录音中 + +**状态:录音纯客户端;临时状态可选改造。** + +- 录音期间客户端锁定其他媒体入口。默认不向群成员广播“正在录音”,以减少隐私暴露和状态风暴。 +- 若产品确认需要,可发送不落库的 `{note what:"kpa",topic}`;服务端限频,其他端超时自动消失,不能作为聊天记录。 + +### 4.10 内容沟通对话3-图片和语音发送中.png + +内容沟通对话3-图片和语音发送中 + +**状态:客户端任务管理新增;服务端上传需改造。** + +- 每个待发送项保存 `client_mid,local_uri,type,upload_id,progress,state`;同一文件可分片/断点续传时使用稳定 `upload_id`。 +- 上传完成后再发布消息;多任务可并发但必须各自保持原排序号。离开页面不取消,退出账号必须暂停并隔离任务。 +- 服务端返回 `FILE_TOO_LARGE`、`FILE_TYPE_NOT_ALLOWED`、`STORAGE_QUOTA_EXCEEDED` 时不可自动无限重试。 + +### 4.11 内容沟通对话3-语音/视频/图片状态.png + +内容沟通对话3-语音/视频/图片状态 + +**状态:需改造媒体 Schema 和受权下载。** + +- 语音渲染 `duration_ms,waveform`,未听状态保存在客户端并可选跨端同步;视频渲染 `thumbnail_file_id,duration_ms,width,height`;图片渲染缩略图和原图 ID。 +- 预览前使用认证文件接口获取,服务端校验当前用户仍可访问引用消息;附件撤回或过期返回 `FILE_UNAVAILABLE`。 +- 缩略图和原文件分别授权,客户端缓存键必须包含租户与用户。 + +### 4.12 语音图片发送状态.png + +语音图片发送状态 + +**状态:需改造。** + +- 图片选择后立即显示本地缩略图,上传成功后只替换资源来源,不改变 `client_mid` 或列表位置。 +- 失败展示重试按钮;重试复用同一上传任务和消息幂等 ID。语音播放与图片上传状态相互独立。 + +### 4.13 上传文件.png + +上传文件 + +**状态:纯客户端视觉资源。** + +- 该图片仅定义统一上传图标和无障碍名称“上传文件”,不产生网络请求。 +- 文件助手、群文件、会话附件、反馈证据实际上传时统一走 0.2 文件接口,并额外提交业务 `purpose` 以便服务端执行权限和保留期策略。 + +### 4.14 文件发送状态.png + +文件发送状态 + +**状态:上传已实现基础能力;消息引用和 ACL 需改造。** + +- 上传成功后发送 `x-im-type:file`,`content={file_id,name,size,mime,extension,checksum}`;文件名做长度和控制字符清理。 +- 服务端在消息落库事务中关联文件;未关联的临时上传按 TTL 清理。卡片下载见 7.9~7.11。 +- 多文件逐个生成 `client_mid`,某个失败不能改变其他文件状态。 + +### 4.15 多选-选中对话框.png + +多选-选中对话框 + +**状态:选择纯客户端;批量动作新增。** + +- 客户端按 `(topic,seq)` 保存已选项并显示数量;默认最多 100 条,超限在本地阻止。 +- 转发、收藏、删除前把选中列表提交对应接口;服务端逐条校验仍可见、类型支持和权限,返回 `succeeded[]` 与 `failed[{seq,reason}]`。 +- 不允许只因第一条可操作就假定整批可操作。 + +### 4.16 多选-选中对话内容-收藏成功.png + +多选-选中对话内容-收藏成功 + +**状态:新增。** + +- `POST /v1/im/favorites/batch`,Body `{"messages":[{"topic":"grp...","seq":"123"}]}`,带幂等键。 +- 返回 `created_count,existing_count,failed[]`;同一用户对同一 `topic+seq` 重复收藏不新增记录。 +- 收藏保存原消息引用、不可变展示快照和附件授权关系;成功 Toast 只在至少一项成功时展示,部分失败需给出汇总。 + +### 4.17 转发-单选.png + +转发-单选 + +**状态:新增。** + +- 目标列表:`GET /v1/im/forward-targets?query=&cursor=`,只返回 `can_receive_forward=true` 的会话和文件助手。 +- 选中后本地进入确认页,不立即发送;服务端在最终转发接口再次校验目标 Topic、原消息和附件权限。 + +### 4.18 转发-多选.png + +转发-多选 + +**状态:新增。** + +- 目标查询同 4.17;多选最多取 `bootstrap.limits.max_forward_targets`,首版建议 9。 +- 确认前只保存目标 Topic 字符串;不得缓存或提交 `tenant_id`。跨租户 Topic 在服务端统一拒绝。 + +### 4.19 转发—逐条转发.png + +转发—逐条转发 + +**状态:新增。** + +- `POST /v1/im/messages/forward`,Body `{"mode":"separate","sources":[{"topic":"p2p...","seq":"12"}],"target_topics":["grp..."],"comment":"..."}`。 +- 返回每个目标和源消息的 `client_mid/seq/status`;服务端保持源顺序,重新生成目标消息,不能复用原 `seq`。 +- 附件必须建立新的授权引用;原消息已撤回、过期或不可见时该项失败。留言作为每个目标的一条独立文本消息发送。 + +### 4.20 转发—合并转发—多选状态.png + +转发—合并转发—多选状态 + +**状态:新增。** + +- 调用 4.19 接口,`mode:"bundle"`;服务端生成 `forward_bundle`,内容含 `bundle_id,title,item_count,preview[]`,完整内容通过 `GET /v1/im/forward-bundles/{bundle_id}?cursor=` 获取。 +- 快照保留显示名、时间和内容摘要,对撤回/敏感字段按策略脱敏;不得允许接收端利用 Bundle 获取原会话额外权限。 +- 多目标共享不可变 Bundle 内容但分别生成目标消息与授权记录。 + +## 5. 通讯录、好友关系与黑名单 + +### 5.1 通讯录.png + +通讯录 + +**状态:新增产品通讯录接口。** + +- 好友列表:`GET /v1/im/friends?query=&initial=&cursor=`,返回 `user_id,display_name,remark,avatar_file_id,initial,online_visibility`;显示名优先级为备注名、昵称、账号。 +- 组织成员:`GET /v1/im/organization/members?query=&department_id=&cursor=`;好友和组织成员是两种关系,前端不得混为一个可发消息权限。 +- 新朋友未处理数、群列表、文件助手和系统消息入口由 `GET /v1/im/bootstrap` 返回。列表缓存以租户和用户隔离。 + +### 5.2 新朋友.png + +新朋友 + +**状态:新增。** + +- 列表:`GET /v1/im/friend-requests?direction=inbound&status=all&cursor=`,返回 `request_id,applicant,message,source,status,created_at,handled_at`。 +- 同意:`POST /v1/im/friend-requests/{request_id}/accept`;拒绝:`POST /v1/im/friend-requests/{request_id}/reject`,均带幂等键和可选 `version`。 +- 成功返回最终 `status` 和 P2P `topic`。重复审批返回原结果;并发冲突返回 `REQUEST_ALREADY_HANDLED` 并附当前状态,前端刷新该项。 + +### 5.3 添加好友.png + +添加好友 + +**状态:新增。** + +- 账号搜索:`GET /v1/im/users/search?q=&type=account&cursor=`;扫码后调用 `POST /v1/im/qrcodes/resolve`,Body `{"token":"...","purpose":"add_friend"}`。 +- 结果统一返回 `relationship=none|friend|pending_inbound|pending_outbound` 和 `can_add`;隐私不允许或未找到统一为 `USER_NOT_DISCOVERABLE`。 +- 分享/二维码见 5.4;发申请见 5.6。服务端按租户配置 `allow_user_search` 和用户隐私再次拦截。 + +### 5.4 添加好友(1).png + +添加好友(1) + +**状态:新增。** + +- 获取二维码:`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。 +- 分享/保存图片是客户端动作;扫码解析始终调用 5.3,过期返回 `QR_CODE_EXPIRED`,篡改返回 `QR_CODE_INVALID`。 + +### 5.5 添加好友-输入精准用户ID.png + +添加好友-输入精准用户ID + +**状态:新增。** + +- 精准查询仍调用 `GET /v1/im/users/search?q=&type=exact_account`;手机号查询仅当租户和目标隐私同时允许时使用 `type=phone`。 +- 每项返回公开资料和 `action=add|chat|pending|none`。前端不得根据是否返回手机号推断账户存在。 +- 搜索限流返回 `SEARCH_RATE_LIMITED`;禁止客户端批量枚举 ID。 + +### 5.6 添加好友验证.png + +添加好友验证 + +**状态:新增。** + +- `POST /v1/im/friend-requests`,Body `{"target_user_id":"usr...","message":"验证留言","source":"search|qr|group","source_topic":"grp..."}`。 +- 留言最多 50 个 Unicode 字符,服务端做敏感内容审核;`source_topic` 只能是双方当前可见的同租户群。 +- 成功返回 `request_id,status:"pending"`;重复有效申请返回原记录,目标隐私关闭时返回统一 `FRIEND_REQUEST_NOT_ALLOWED`。 + +### 5.7 好友个人主页.png + +好友个人主页 + +**状态:新增。** + +- `GET /v1/im/users/{user_id}/profile` 返回按当前查看人裁剪后的 `display_name,remark,avatar_file_id,gender,region,public_account,signature,relationship,capabilities`。 +- 发消息使用返回的 P2P `topic`;没有 Topic 时调用 `POST /v1/im/conversations/p2p`,Body `{"peer_user_id":"..."}` 并由服务端验证好友关系。 +- 音视频按钮只按 `capabilities.can_call` 展示,真正呼叫时再次鉴权。隐藏字段必须从响应中省略,不能只由前端遮挡。 + +### 5.8 群聊—非好友个人主页.png + +群聊—非好友个人主页 + +**状态:新增。** + +- `GET /v1/im/groups/{topic}/members/{user_id}/profile` 返回群内昵称、角色和允许公开的账号资料。 +- `can_add_friend` 同时受群 `allow_member_add_friend`、目标隐私和租户策略控制;为 false 时隐藏添加入口。 +- 从该页申请时 5.6 传 `source:"group"` 和当前 Topic;服务端校验双方仍在群内。 + +### 5.9 好友—聊天设置—未拉黑状态.png + +好友—聊天设置—未拉黑状态 + +**状态:部分 Topic 设置已实现;产品设置新增。** + +- 查询:`GET /v1/im/conversations/{topic}/settings` 返回 `pinned,mute_notifications,message_retention,blocked,remark,permissions`。 +- 更新置顶/免打扰/保存方式:`PATCH /v1/im/conversations/{topic}/settings`,Body 只提交变更字段并带 `version`;返回最新完整设置。 +- 备注:`PATCH /v1/im/friends/{user_id}`,Body `{"remark":"..."}`;查记录见第 7 章,举报见第 11 章,删除见 5.11。 + +### 5.10 好友—聊天设置—拉黑状态.png + +好友—聊天设置—拉黑状态 + +**状态:新增。** + +- 开启拉黑:`PUT /v1/im/blocks/{user_id}`;解除:`DELETE /v1/im/blocks/{user_id}`,均带幂等键。 +- 成功返回 `blocked,relationship,updated_at` 并向当前用户其他设备推送 `privacy.block.updated`。拉黑后服务端拦截对方消息和呼叫。 +- 解除拉黑只恢复黑名单 ACL;若双方已非好友,`relationship` 仍为 `none`,不能自动恢复好友。 + +### 5.11 删除好友提示.png + +删除好友提示 + +**状态:确认框纯客户端;删除接口新增。** + +- 确认调用 `DELETE /v1/im/friends/{user_id}`,带幂等键。是否清除本地历史由客户端在成功后独立处理,不放入删除好友请求。 +- 服务端解除双向好友关系但不替对方删除历史;返回 `relationship:"none",topic`。当前用户是否删除本地/服务端可见记录是独立选择。 +- 取消不请求;确认后清理页面缓存并等待关系事件校正其他设备。 + +### 5.12 黑名单.png + +黑名单 + +**状态:新增。** + +- `GET /v1/im/blocks?cursor=` 返回 `user,blocked_at`;移出调用 5.10 的 DELETE。 +- 批量解除:`POST /v1/im/blocks/batch-delete`,Body `{"user_ids":[...]}`,返回逐项结果;最多 100 人。 +- 用户资料不可见时仍用受控占位显示黑名单记录,不能因资料删除而无法解除。 + +### 5.13 黑名单提示(1).png + +黑名单提示(1) + +**状态:确认框纯客户端;写接口新增。** + +- 确认后调用 `PUT /v1/im/blocks/{user_id}`,相同幂等键重复提交返回当前 `blocked=true`。 +- 服务端在同一事务内更新黑名单和通信 ACL,并写审计;取消不调用接口。 +- 若对象跨租户或不存在统一返回 `RESOURCE_NOT_FOUND`。 + +### 5.14 黑名单提示.png + +黑名单提示 + +**状态:图片文件名与画面语义不一致;按“清除聊天记录”实施,需改造。** + +- 确认调用 Tinode `{del what:"msg",topic,delseq:[{"low":1,"hi":last_seq+1}],hard:false}`,仅删除当前用户视图;或封装 `DELETE /v1/im/conversations/{topic}/messages`。 +- 不解除好友、不影响对方历史、不删除收藏;附件仅在无任何有效引用且过保留期后回收。 +- 前端需在确认文案明确“仅清除自己的聊天记录”,成功后清本地 Topic 消息缓存并保留会话关系。 + +## 6. 群聊创建、资料维护与成员治理 + +### 6.1 群聊.png + +群聊 + +**状态: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`。 +- 分类由服务端 `my_role` 决定;创建成功或角色变化后通过系统事件刷新,不由前端自行移动分组。 + +### 6.2 空白页.png + +空白页 + +**状态:纯客户端。** + +- 对 6.1 任一分类返回 `items=[]` 且 `has_more=false` 时显示对应空状态,不追加空白占位数据。 +- 收到 `group.membership.updated` 或创建成功后重新拉取当前分类。 + +### 6.3 加入群聊.png + +加入群聊 + +**状态:新增邀请卡片和加入接口。** + +- 邀请卡下行 `system_event{event_type:"group_invitation",invitation_id,group,inviter,status,expires_at}`。 +- 点击加入:`POST /v1/im/group-invitations/{invitation_id}/accept`,带幂等键;返回 `status=joined|pending_approval` 和 `topic`。 +- 服务端校验邀请租户、有效期、群状态、成员资格和成员数上限;重复加入返回当前状态,过期返回 `INVITATION_EXPIRED`。 + +### 6.4 内容沟通对话3-开启进群验证与拉人状态.png + +内容沟通对话3-开启进群验证与拉人状态 + +**状态:新增群成员事件。** + +- 邀请:`POST /v1/im/groups/{topic}/invitations`,Body `{"user_ids":[],"message":""}`;按群策略直接加入或创建待审批申请。 +- 加入、审批、拒绝等结果由服务端生成 `system_event{event_type:"group_membership_changed",operator,target_users,action,result}` 并写入群时间线。 +- 客户端只渲染可信系统事件;服务端同时更新成员表、Topic 订阅和成员数,不允许三者不一致。 + +### 6.5 邀请进群申请.png + +邀请进群申请 + +**状态:新增。** + +- `GET /v1/im/groups/{topic}/join-requests?status=pending&cursor=` 返回申请人、邀请人、来源、时间、状态和 `version`。 +- 同意:`POST /v1/im/groups/{topic}/join-requests/{id}/accept`;拒绝:`POST /v1/im/groups/{topic}/join-requests/{id}/reject`,带幂等键与 `version`。 +- 仅群主/管理员可处理;同意时原子校验群成员上限。并发处理返回 `REQUEST_ALREADY_HANDLED` 和最终状态。 + +### 6.6 新邀请进群申请.png + +新邀请进群申请 + +**状态:新增。** + +- `GET /v1/im/groups/{topic}/summary` 返回 `pending_join_request_count`、最新公告和权限;新申请通过 `group.join_request.created` 推送给有审批权限的成员。 +- 审批完成后服务端广播新数量;前端归零时移除入口。普通成员响应中不得得到申请人敏感信息或数量。 + +### 6.7 群公告状态.png + +群公告状态 + +**状态:新增。** + +- 查询最新公告:`GET /v1/im/groups/{topic}/announcements/latest`;发布/更新:`PUT /v1/im/groups/{topic}/announcements/current`,Body `{"content":"...","version":1}`。 +- 返回 `announcement_id,content,author,version,published_at`。仅有权限角色可写,使用乐观锁防覆盖。 +- 发布成功后服务端生成群系统事件;顶部摘要使用接口返回数据,不解析聊天文本猜测。 + +### 6.8 群公告状态/邀请进群申请同时存在.png + +群公告状态/邀请进群申请同时存在 + +**状态:纯客户端组合;数据接口新增。** + +- 页面一次调用 `GET /v1/im/groups/{topic}/summary`,分别读取 `announcement` 和 `pending_join_request_count`,按产品固定顺序渲染两个入口。 +- 处理申请只刷新计数,不清公告;更新公告只替换公告对象,不影响申请入口。 + +### 6.9 群公告(对方查看状态).png + +群公告(对方查看状态) + +**状态:新增。** + +- 详情:`GET /v1/im/groups/{topic}/announcements/{announcement_id}`;普通成员响应不含编辑权限。 +- 已读:`POST /v1/im/groups/{topic}/announcements/{id}/read`,带幂等键,返回 `read_at`;用于清除新公告标记。 +- 服务端校验当前群成员资格,退群后不可借历史 ID 读取新公告。 + +### 6.10 群聊设置.png + +群聊设置 + +**状态:部分订阅设置已实现;产品菜单新增。** + +- `GET /v1/im/groups/{topic}/settings` 返回 `my_role,permissions,profile,announcement,member_count,pinned,mute_notifications` 和群治理开关。 +- 个人置顶/免打扰通过 `PATCH /v1/im/groups/{topic}/member-settings`;群级设置必须使用 6.17,不混在个人设置里。 +- 退出:`POST /v1/im/groups/{topic}/leave`;群主必须先转让或解散。清记录、举报分别见 5.14、11.3。 + +### 6.11 群资料.png + +群资料 + +**状态:需改造。** + +- `GET /v1/im/groups/{topic}` 返回 `name,avatar_file_id,description,owner,member_count,version,permissions`。 +- 更新:`PATCH /v1/im/groups/{topic}`,Body 可含 `name,avatar_file_id,description,version`;成功返回最新对象并推送 `group.profile.updated`。 +- 服务端做角色、长度、敏感内容、文件归属校验;前端只展示 `permissions.can_edit_profile` 允许的入口。 + +### 6.12 设置群头像.png + +设置群头像 + +**状态:上传基础已实现;用途绑定需改造。** + +- 来源选择、裁剪是客户端动作。上传使用 `purpose=group_avatar`,服务端校验图片 MIME、尺寸、大小并返回 `file_id`。 +- 上传后调用 6.11 PATCH `{"avatar_file_id":"...","version":n}`;只有 PATCH 成功才替换正式头像。 +- 取消或 PATCH 失败的临时文件按 TTL 回收,不能污染群文件列表。 + +### 6.13 设置昵称(1).png + +设置昵称(1) + +**状态:新增。** + +- `PUT /v1/im/groups/{topic}/members/me/profile`,Body `{"nickname":"...","version":n}`。 +- 服务端校验成员资格、长度和敏感词,返回 `nickname,version,updated_at` 并推送成员资料变化。 +- 该昵称仅作用当前群,不能修改全局用户昵称或其他群资料。 + +### 6.14 群简介.png + +群简介 + +**状态:同 6.11,需改造。** + +- 保存调用 `PATCH /v1/im/groups/{topic}`,Body `{"description":"...","version":n}`;空字符串表示清空,省略表示不修改。 +- 服务端返回 `VALIDATION_FAILED` 的 `details.field/max_length` 或 `CONTENT_REJECTED`;前端保留草稿供修正。 + +### 6.15 群标识.png + +群标识 + +**状态:角色已有基础数据;产品渲染需统一。** + +- 群消息下行增加服务端确认的 `sender_snapshot{display_name,role}`,角色枚举固定 `owner|admin|member`。 +- 成员角色变化事件到达后刷新成员缓存;历史消息可以保留发送时角色快照,详情以当前角色接口为准。 +- 客户端不得根据本地管理员列表给消息自行加可信标识。 + +### 6.16 群二维码页面.png + +群二维码页面 + +**状态:新增。** + +- `POST /v1/im/groups/{topic}/qrcodes`,Body `{"expires_in_seconds":86400}`,返回签名 `qr_token,qr_payload,expires_at,group_snapshot`。 +- 扫码调用 `POST /v1/im/qrcodes/resolve`,`purpose:"join_group"`;随后 `POST /v1/im/groups/{topic}/join-requests` 并携带 `qr_token`。 +- 群解散、二维码过期或管理员撤销后必须失效;二维码不得直接承载内部群权限。 + +### 6.17 群组管理.png + +群组管理 + +**状态:新增。** + +- 查询/更新:`GET/PATCH /v1/im/groups/{topic}/governance`,字段为 `join_approval,mute_all,member_upload_files,member_private_chat,member_invite` 和 `version`。 +- 每个字段返回 `editable`;PATCH 仅群主或被授权管理员可用,记录操作者、旧值、新值和 Request ID。 +- 解散:`DELETE /v1/im/groups/{topic}`,需二次 `X-Action-Token` 和幂等键;成功后撤销 Topic、二维码、邀请和文件新增权限。 + +### 6.18 设置群管理员.png + +设置群管理员 + +**状态:新增。** + +- `GET /v1/im/groups/{topic}/members?role=admin&cursor=` 返回管理员列表;`GET /v1/im/groups/{topic}/role-limits` 返回 `max_admin_count,current_admin_count`。 +- 只有群主可进入。群主本人以 `owner` 单独展示,不计入普通管理员列表和名额。 + +### 6.19 添加管理员.png + +添加管理员 + +**状态:新增。** + +- 候选:`GET /v1/im/groups/{topic}/members?eligible_role=admin&query=&cursor=`,返回 `eligible` 和不可选原因。 +- 提交:`POST /v1/im/groups/{topic}/admins/batch`,Body `{"user_ids":[],"version":n}`;服务端原子校验群主身份、成员资格和管理员上限。 +- 超限返回 `GROUP_ADMIN_LIMIT_EXCEEDED`,并附 `max/current/requested`。 + +### 6.20 添加管理员-设置成功.png + +添加管理员-设置成功 + +**状态:新增。** + +- 6.19 成功返回 `admins,version,updated_at`;前端用响应替换列表并显示成功,不在请求完成前自行修改角色。 +- 服务端推送 `group.role.updated`,新管理员收到后刷新菜单权限和群标识。 + +### 6.21 添加管理员-设置成功(1).png + +添加管理员-设置成功(1) + +**状态:新增。** + +- 提交期间按幂等键禁重复点击。批量接口返回 `succeeded[]`、`failed[{user_id,reason}]` 和最新 `admins`。 +- 部分失败时保留成功角色,不做整批客户端回滚;前端展示失败原因并以服务端列表为准。 + +### 6.22 删除管理员-设置成功.png + +删除管理员-设置成功 + +**状态:新增。** + +- `DELETE /v1/im/groups/{topic}/admins/{user_id}`,带幂等键和 `If-Match`/`version`。 +- 成功后目标角色变为 `member`,保留群成员关系;服务端立即撤销管理权限并广播角色事件。 +- 删除群主、非成员或最后状态冲突分别返回稳定错误,不允许前端拼接通用成功。 + +### 6.23 添加禁言成员.png + +添加禁言成员 + +**状态:新增。** + +- 候选:`GET /v1/im/groups/{topic}/members?mute_eligible=true&query=&cursor=`。 +- 提交:`POST /v1/im/groups/{topic}/mutes/batch`,Body `{"members":[{"user_id":"usr...","expires_at":null}],"reason":""}`。 +- 服务端校验操作者角色层级;管理员不能禁言群主,普通管理员互相操作规则由治理策略确定。 + +### 6.24 禁言的群成员.png + +禁言的群成员 + +**状态:新增。** + +- `GET /v1/im/groups/{topic}/mutes?cursor=` 返回 `user,muted_by,muted_at,expires_at`;解除 `DELETE /v1/im/groups/{topic}/mutes/{user_id}`。 +- 禁言成员发送消息时服务端返回 `GROUP_MUTED` 和可选 `expires_at`;不能只禁用前端输入框。 +- 退群时清理禁言记录;过期由服务端自动失效并推送更新。 + +### 6.25 选择新群主.png + +选择新群主 + +**状态:新增。** + +- `GET /v1/im/groups/{topic}/members?eligible_role=owner&query=&cursor=`,只返回状态正常的当前成员。 +- 前端单选并保存 `target_user_id`;当前群主、已退出、停用账号返回 `eligible=false`。 +- 此步骤不修改权限,真正转让仅在 6.26 确认后执行。 + +### 6.26 选择新群主提示.png + +选择新群主提示 + +**状态:新增。** + +- `POST /v1/im/groups/{topic}/owner-transfer`,Body `{"target_user_id":"usr...","version":n}`,必须带幂等键和二次认证 `X-Action-Token`。 +- 服务端事务内把旧群主降为管理员或成员、目标升为群主、更新权限和审计,再生成系统事件。 +- 成功返回 `owner,previous_owner,roles,version`;并发成员变化返回 `GROUP_VERSION_CONFLICT`,前端重新确认,不自动重试。 + +### 6.27 群文件.png + +群文件 + +**状态:新增群文件索引;底层文件存储已实现。** + +- `GET /v1/im/groups/{topic}/files?query=&type=&cursor=` 返回 `group_file_id,file_id,name,size,mime,uploader,created_at,download_state,permissions`。 +- 下载使用认证文件接口,服务端校验当前群成员和文件状态;删除 `DELETE /v1/im/groups/{topic}/files/{group_file_id}` 需上传者或管理员权限。 +- 退群后不可继续使用旧 URL 下载;缓存文件是否保留由客户端隐私策略处理。 + +### 6.28 群文件-上传状态.png + +群文件-上传状态 + +**状态:需改造。** + +- 先用 `purpose=group_file,topic=...` 上传,再 `POST /v1/im/groups/{topic}/files`,Body `{"file_id":"..."}` 建立群文件记录。 +- 服务端校验 `member_upload_files`、单文件上限、租户总存储和群成员身份;成功返回完整群文件项。 +- 上传失败可复用 `upload_id`;登记失败不得在列表显示,孤立文件按 TTL 回收。 + +## 7. 文件下载、收藏管理与聊天记录检索 + +### 7.1 我的收藏.png + +我的收藏 + +**状态:新增。** + +- `GET /v1/im/favorites?type=all|text|image|video|voice|file|link&q=&cursor=`,返回 `favorite_id,type,snapshot,source,collected_at,availability`。 +- 类型与关键词由服务端过滤,客户端不下载全部收藏再筛选。`source` 仅下发用户仍可知晓的会话摘要。 +- 收藏数据属于当前租户当前用户;跨租户登录不得合并列表。 + +### 7.2 我的收藏(1).png + +我的收藏(1) + +**状态:新增。** + +- 详情:`GET /v1/im/favorites/{favorite_id}`;删除:`DELETE /v1/im/favorites/{favorite_id}`;转发使用 7.8。 +- 收藏创建时保存受控快照和附件引用,因此原消息对当前用户删除后仍可按保留策略展示;原资源因合规删除时返回 `availability:"removed"`。 +- 删除收藏不删除原消息或已转发消息。 + +### 7.3 我的收藏-文件.png + +我的收藏-文件 + +**状态:新增。** + +- 调用 7.1,固定 `type=file`;项中使用 `snapshot.file{name,size,mime,file_id}` 和 `availability`。 +- 点击前调用收藏详情获得当前授权,下载仍走 7.9 文件协议;失效显示原因,不反复请求公共 URL。 + +### 7.4 我的收藏-文本.png + +我的收藏-文本 + +**状态:新增。** + +- 调用 7.1,固定 `type=text`;服务端返回纯文本/结构化 Drafty 快照和安全高亮区间 `matches[]`。 +- 展开、折叠、复制为本地动作;复制前遵守企业 DLP 策略 `permissions.can_copy`。 + +### 7.5 我的收藏-语音.png + +我的收藏-语音 + +**状态:新增。** + +- 调用 7.1,固定 `type=voice`;播放前获取认证文件,返回 `duration_ms,waveform,availability`。 +- 播放/暂停和单实例播放器是客户端状态;服务端不接收逐秒播放进度,除非后续定义跨端收听状态。 + +### 7.6 我的收藏—链接.png + +我的收藏—链接 + +**状态:新增。** + +- 调用 7.1,固定 `type=link`;服务端快照返回 `url,title,summary,thumbnail_file_id,risk_level`。 +- 点击前 `POST /v1/im/security/urls/check`,Body `{"url":"...","source":"favorite"}`;返回 `allow|warn|block` 和 `risk_challenge_id`。 +- 客户端不得在未确认时自动打开或预加载高风险外链。 + +### 7.7 收藏—文件(多选).png + +收藏—文件(多选) + +**状态:新增。** + +- 选择状态本地维护;批量删除 `POST /v1/im/favorites/batch-delete`,Body `{"favorite_ids":[]}`;批量转发见 7.8。 +- 返回逐项成功/失败;删除必须二次确认并带幂等键。跨页选择只保存 ID,不保留失效的临时下载地址。 + +### 7.8 收藏文件—分享给好友页面.png + +收藏文件—分享给好友页面 + +**状态:新增。** + +- 目标列表使用 4.17;提交 `POST /v1/im/favorites/forward`,Body `{"favorite_ids":[],"target_topics":[],"comment":""}`。 +- 服务端从收藏的受控附件引用生成新消息和新 ACL,返回每个目标的消息结果;收藏原件不变。 +- 文件失效、目标禁言或无发送权限按目标逐项失败,不允许客户端直接复用旧下载 URL 发送。 + +### 7.9 文件-下载.png + +文件-下载 + +**状态:底层下载已实现租户校验;资源 ACL 需改造。** + +- 客户端使用 `GET /v0/file/s/{file_id}`,Header 传 API Key 与 `X-Tinode-Auth: token `;禁止 `?auth=&secret=`。 +- 服务端除 Token 租户外,还必须校验该用户通过消息、群文件或收藏拥有有效引用,并支持 `Range`、`ETag`、内容长度和安全文件名。 +- 下载前客户端校验本地空间和网络策略;`FILE_FORBIDDEN` 不自动重试,`FILE_UNAVAILABLE` 显示失效。 + +### 7.10 文件-下载中.png + +文件-下载中 + +**状态:客户端任务管理;服务端需支持 Range。** + +- 下载任务保存 `file_id,etag,total_bytes,downloaded_bytes,temp_path,state`;恢复时携带 `Range` 与 `If-Range: `。 +- ETag 改变或服务端返回完整 200 时丢弃旧分片重新下载;取消时关闭连接并清理临时文件。 +- Token 过期先完成一次刷新后续传,不能把 Token 写入任务文件或日志。 + +### 7.11 文件-下载完成.png + +文件-下载完成 + +**状态:纯客户端完成态。** + +- 完成后校验长度和服务端提供的 checksum,再原子移动到受控目录;校验失败删除并标记重试。 +- 打开/分享遵守系统沙箱和企业 DLP。每次展示页面检查本地文件存在性,不存在则恢复“可下载”。 + +### 7.12 查找聊天记录-搜索关键词选中状态(点击后跳出对应聊天内容).png + +查找聊天记录-搜索关键词选中状态(点击后跳出对应聊天内容) + +**状态:新增服务端搜索。** + +- `GET /v1/im/topics/{topic}/messages/search?q=&type=text&cursor=` 返回 `seq,from,ts,snippet,matches[]`。 +- 点击结果后 `GET /v1/im/topics/{topic}/messages/{seq}/context?before=20&after=20`,返回锚点及上下文;客户端定位 `seq` 并高亮。 +- 服务端只搜索当前用户可见且未硬删除的消息,关键词做长度/频率限制,搜索日志不得记录原文。 + +### 7.13 查找聊天记录-文件.png + +查找聊天记录-文件 + +**状态:新增。** + +- 调用 `GET /v1/im/topics/{topic}/messages/search?type=file&q=&cursor=`,返回消息 `seq` 与文件快照、发送者、时间、`availability`。 +- 预览/下载使用 7.9;撤回或无授权时显示失效。点击来源用 7.12 上下文接口定位。 + +### 7.14 查找聊天记录-链接.png + +查找聊天记录-链接 + +**状态:新增。** + +- 调用搜索接口 `type=link`,返回 `url,title,summary,seq,ts,risk_level`;打开前执行 7.6 URL 安全检查。 +- 链接预览由服务端受控抓取和缓存,客户端不直接抓取内网地址,防止 SSRF 和隐私泄露。 + +### 7.15 查看聊天记录-图片.png + +查看聊天记录-图片 + +**状态:新增。** + +- `GET /v1/im/topics/{topic}/media?type=image&cursor=` 返回 `seq,thumbnail_file_id,file_id,width,height,ts,availability`。 +- 缩略图懒加载使用认证文件请求;大图前再次授权。多选仅保存 `(topic,seq)`,操作使用第 4 章批量接口。 + +### 7.16 查看聊天记录-视频.png + +查看聊天记录-视频 + +**状态:新增。** + +- 调用 7.15 接口,`type=video`,额外返回 `duration_ms,mime,size`。 +- 播放器使用支持 Range 的认证下载/流式读取;文件失效、Token 过期和网络中断分别映射可理解状态,返回列表保持游标与滚动位置。 + +## 8. 实时语音与视频通话 + +### 8.1 各种通话提示状态.png + +各种通话提示状态 + +**状态:已实现基础通话记录替换;产品文案映射需统一。** + +- 发起呼叫时的持久消息就是通话记录。服务端以相同 `seq` 写 replacement `{data}`,`head.replace=":{seq}"`,并把 `head.webrtc` 更新为 `accepted|finished|disconnected|missed|declined`。 +- 已接通结束时 `head.webrtc-duration` 为服务端计算的毫秒数。客户端映射:`finished=已结束`、`missed=未接听/已取消`、`declined=已拒绝`、`disconnected=异常结束`。 +- 双方使用原消息 `content.callId/callType` 和同一 `seq`;客户端不得另发一条“通话结果”消息或相信本地传入的 duration。 + +### 8.2 对方状态.png + +对方状态 + +**状态:在线邀请已实现;Push 与产品鉴权需改造。** + +- 来电是 Topic 下行的初始 `{data}`:`head.webrtc="started"`、`head.x-im-type="call"`,`content` 至少含 `callId,callType,audio,video`。客户端保存该消息 `seq` 作为后续所有 call note 的关联 ID。 +- 来电端开始响铃后发送 `{note:{topic,what:"call",event:"ringing",seq,payload:{"callId":"..."}}}`;接听发送 `event:"accept"`;拒绝发送 `event:"hang-up"`。 +- 后台 Push 只携带 `callId,topic,seq,caller_display,callType,expires_at`,不含 SDP、ICE 或 Token;用户操作后必须回 WebSocket 鉴权。 + +### 8.3 语音通话-对方待接受状态.png + +语音通话-对方待接受状态 + +**状态:发起与超时已实现基础;好友/拉黑/租户开关校验需改造。** + +- 发起前查询 `capabilities.can_call`,再发布持久邀请消息: + +```json +{"pub":{"id":"call-1","topic":"p2p...","head":{"mime":"application/json","webrtc":"started","x-im-type":"call"},"content":{"callId":"01K...","callType":"audio","audio":true,"video":false}}} +``` + +- `{ctrl code:202,params.seq}` 的 `seq` 是后续 `ringing/accept/offer/answer/ice-candidate/hang-up` 必填关联值。服务端只允许 P2P 双方参与,并校验好友、拉黑、租户通话开关和当前 Topic 无并发呼叫。 +- 发起方取消发送 `{note event:"hang-up",seq,payload:{"callId":"...","reason":"cancelled"}}`;等待超时由服务端结束并把原消息替换为 `missed`。 + +### 8.4 语音通话-双方语音沟通中.png + +语音通话-双方语音沟通中 + +**状态:WebRTC 客户端能力;信令和服务端计时已实现。** + +- 被叫发送 `accept` 后,服务端把原消息替换为 `webrtc:"accepted"` 并向主叫发送 `{info what:"call",event:"accept",seq}`。 +- 双方随后用 `{note what:"call",event:"offer|answer|ice-candidate",seq,payload:{"callId":"...",...}}` 交换 SDP/ICE。ICE 配置使用 `hi.ctrl.params.iceServers`,不得在 App 中硬编码 TURN 长期凭证。 +- 任一端发送 `hang-up`,服务端按接受时间计算 duration 并替换原通话消息。异常断线后按原 `topic+seq+callId` 恢复或结束,不新建第二通呼叫。 + +### 8.5 语音通话-麦克风与扬声器已关.png + +语音通话-麦克风与扬声器已关 + +**状态:纯客户端设备控制。** + +- 麦克风关闭通过禁用本地音轨,扬声器关闭/听筒切换通过系统音频路由完成,不调用业务 HTTP。 +- UI 必须读取实际音轨和路由状态;权限撤销时强制更新。是否告知对方静音可发送临时 `media-state`,不落聊天记录。 + +### 8.6 语音通话-麦克风与扬声器已开.png + +语音通话-麦克风与扬声器已开 + +**状态:纯客户端设备控制。** + +- 开启麦克风恢复本地音轨,开启扬声器选择实际输出设备;蓝牙/耳机变化以操作系统回调为准。 +- 不把设备名称、系统音量上传服务端。切换失败恢复真实按钮状态并给本地提示。 + +### 8.7 视频通话—等待对方接受接受邀请中.png + +视频通话—等待对方接受接受邀请中 + +**状态:基础通话信令已实现;隐私约束需落实。** + +- 发起协议同 8.3,`media_type:"video"`。本地预览只在设备显示,接听前媒体轨默认不发给对方,SDP 只声明能力。 +- 切换摄像头纯客户端;取消发送 `hang-up:cancelled`。相机权限不足时改为音频呼叫或取消,不伪装视频已开启。 + +### 8.8 视频通话—等待对方接受接受邀请-摄像头关闭.png + +视频通话—等待对方接受接受邀请-摄像头关闭 + +**状态:纯客户端状态;媒体状态事件需改造。** + +- 关闭本地视频轨并显示头像;接听后沿用 `video_enabled=false`。 +- 当前服务端只允许 `ringing/accept/offer/answer/ice-candidate/hang-up`;首版应直接通过 WebRTC 轨道 mute 状态呈现。若后续新增 `media-state`,必须先修改 `server/calls.go` 事件白名单、参与者校验和限频,客户端不能提前发送未知事件。 + +### 8.9 双方视频通话中.png + +双方视频通话中 + +**状态:WebRTC 客户端能力;服务端信令已具备基础。** + +- 主/小窗布局、拖动和横竖屏纯客户端;WebRTC offer/answer/ICE 与 8.2~8.4 相同。 +- 服务端只做身份鉴权、信令路由、TURN 配置和通话审计,不接收前端伪造的通话时长。 +- 网络恶化可本地降码率或关闭视频轨;最终结束必须发 `hang-up`。服务端未支持 `media-state` 前不得发送该事件。 + +### 8.10 双方视频通话中-对方关闭摄像头状态.png + +双方视频通话中-对方关闭摄像头状态 + +**状态:现阶段纯客户端;可选媒体状态事件需改造。** + +- 以对端 WebRTC 视频轨的 mute/unmute 作为真实信号;关闭时显示资料缓存中的对方头像并保留音频,轨道恢复后自动显示视频。 +- 不需要业务 HTTP。可选 `media-state` 必须等服务端扩展事件白名单后另行启用,且不持久化、不改变通话结果。 + +### 8.11 视频与语音通话悬浮状态中.png + +视频与语音通话悬浮状态中 + +**状态:纯客户端。** + +- 最小化不结束 WebRTC 或 WebSocket,会话仍以同一 `call_id` 运行;点击恢复全屏不发送新 invite。 +- 系统悬浮窗权限、画中画和安全区域按平台实现。收到 `hang-up`、Token 失效或媒体会话结束后立即关闭悬浮窗并落正确结果态。 + +## 9. 企业工作台与办公协同 + +### 9.1 工作台.png + +工作台 + +**状态:新增。** + +- `GET /v1/im/workbench` 返回 `modules[{code,name,icon_file_id,route,unread_count,enabled,permission}]`、布局版本和企业配置。 +- 服务端按当前 Token 的租户、组织角色和功能开关裁剪;前端只接受白名单内部 `route`,禁止直接执行服务端任意 URL。 +- 未读数通过 `workbench.badge.updated` 系统事件增量更新;切租户必须重新加载,不复用另一企业工作台。 + +### 9.2 部门组织框架.png + +部门组织框架 + +**状态:新增。** + +- 根节点:`GET /v1/im/organization/departments?parent_id=root&cursor=`;子部门同接口传 `parent_id`;成员 `GET /v1/im/organization/departments/{id}/members?cursor=`。 +- 部门返回 `department_id,name,parent_id,child_count,member_count,version`;成员只返回当前查看人可见字段。 +- 全局搜索:`GET /v1/im/organization/search?q=&types=department,user&cursor=`。服务端执行组织范围授权,离职用户标记不可联系或从结果移除。 + +### 9.3 会议通知-发起人状态.png + +会议通知-发起人状态 + +**状态:新增。** + +- 创建:`POST /v1/im/meetings`,Body `{"title":"...","start_at":"...","end_at":"...","location":"...","attendee_user_ids":[],"reminder_minutes":[]}`。 +- 详情:`GET /v1/im/meetings/{meeting_id}` 返回会议、参会人的 `response=pending|accepted|declined|tentative`、权限和 `version`。 +- 更新/取消:`PATCH /v1/im/meetings/{id}`、`POST /v1/im/meetings/{id}/cancel`;提醒未响应者 `POST /v1/im/meetings/{id}/reminders`。所有写操作带幂等键并生成通知和审计。 + +### 9.4 会议通知-接收人状态.png + +会议通知-接收人状态 + +**状态:新增。** + +- 详情同 9.3;响应:`PUT /v1/im/meetings/{meeting_id}/my-response`,Body `{"response":"accepted|declined|tentative","version":n}`。 +- 返回我的最终响应和会议版本,并实时通知发起人;重复提交同状态幂等。 +- 会议过期返回 `MEETING_CLOSED` 并只读展示;客户端本地日历写入是可选系统动作,不替代服务端响应。 + +### 9.5 日报.png + +日报 + +**状态:新增。** + +- `GET /v1/im/reports/daily?scope=mine&from=&to=&status=&cursor=` 返回 `report_id,report_date,status,draft_updated_at,submitted_at,read_state`。 +- 新建草稿 `POST /v1/im/reports/daily`,Body `{"report_date":"2026-08-11"}`;同一用户同一天唯一,重复创建返回原草稿。 +- `permissions.can_create` 和可补交日期由服务端返回,前端不自行判断工作日规则。 + +### 9.6 日报-普通用户页面.png + +日报-普通用户页面 + +**状态:新增。** + +- 编辑:`PUT /v1/im/reports/daily/{id}/draft`,Body `{"content":"...","attachments":[],"version":n}`;提交 `POST /v1/im/reports/daily/{id}/submit`。 +- 服务端返回自动保存版本,客户端用 2~5 秒防抖且串行保存;版本冲突返回 `REPORT_VERSION_CONFLICT` 并给最新版本。 +- 提交后按 `editable_until` 决定是否可修改;附件使用 `purpose=daily_report` 上传并做权限绑定。 + +### 9.7 日报-管理员页面.png + +日报-管理员页面 + +**状态:新增。** + +- `GET /v1/im/reports/daily/statistics?date=&department_id=` 返回 `expected_count,submitted_count,missing_count` 和组织权限范围。 +- 明细:`GET /v1/im/reports/daily?scope=managed&date=&status=submitted|missing&department_id=&cursor=`。 +- 提醒:`POST /v1/im/reports/daily/reminders`,Body `{"date":"...","user_ids":[]}`;服务端过滤无管理权限对象并限流,返回逐项结果。 + +### 9.8 日报-查看详情.png + +日报-查看详情 + +**状态:新增。** + +- `GET /v1/im/reports/daily/{report_id}` 返回填报人、日期、内容、附件、状态、版本、修改时间和 `permissions`。 +- 管理员查看时服务端记录必要的已读状态;附件下载复用第 7 章 ACL。 +- 撤回或修订使用专用状态和版本,客户端必须显示最新版本与 `revised_at`,不能混合旧缓存。 + +### 9.9 月报(1).png + +月报(1) + +**状态:新增。** + +- 创建/取得当月草稿:`POST /v1/im/reports/monthly`,Body `{"month":"2026-08"}`。 +- 保存:`PUT /v1/im/reports/monthly/{id}/draft`,Body `{"work_summary":"...","next_plan":"...","attachments":[],"version":n}`;提交 `POST /v1/im/reports/monthly/{id}/submit`。 +- 字段必填、最大长度、提交截止时间由响应 `validation` 提供;自动保存和冲突处理同 9.6。 + +### 9.10 月报.png + +月报 + +**状态:新增。** + +- `GET /v1/im/reports/monthly?scope=mine|managed&from_month=&to_month=&status=&cursor=` 返回月份、填报人、状态和时间。 +- 管理员统计 `GET /v1/im/reports/monthly/statistics?month=&department_id=`;普通用户响应中不下发团队统计入口权限。 +- 未提交项进入 9.9,已提交项进入 9.11,路由由返回 `action=edit|view` 决定。 + +### 9.11 月报-详情页.png + +月报-详情页 + +**状态:新增。** + +- `GET /v1/im/reports/monthly/{report_id}` 返回 `month,author,work_summary,next_plan,attachments,status,submitted_at,updated_at,version,permissions`。 +- `permissions.can_edit=true` 时进入草稿更新;否则只读。修改已提交月报需专用 `POST /v1/im/reports/monthly/{id}/reopen` 或审核流程,不直接覆盖。 + +### 9.12 总经理信箱.png + +总经理信箱 + +**状态:新增。** + +- 页面配置:`GET /v1/im/executive-mailbox/config` 返回 `enabled,allow_anonymous,required_fields,max_length,attachment_policy,notice`。 +- 提交:`POST /v1/im/executive-mailbox/submissions`,Body `{"subject":"...","content":"...","anonymous":false,"attachment_file_ids":[]}`,带幂等键。 +- 成功仅返回 `submission_id,status:"received",submitted_at`;匿名模式下业务接收方不见身份,但安全审计是否保留内部追踪按企业政策执行并提前告知。 + +### 9.13 总经理信箱-文案提示.png + +总经理信箱-文案提示 + +**状态:新增配置接口。** + +- 文案使用 9.12 配置中的 `notice{title,content,updated_at}`,由租户后台配置;客户端不得硬编码实名/匿名承诺。 +- 提交按钮前必须展示当前版本文案;请求可携带 `notice_version`,服务端发现关键政策已变更时返回 `NOTICE_RECONFIRM_REQUIRED`。 + +## 10. 个人资料、通用设置、隐私与青少年模式 + +### 10.1 我的.png + +我的 + +**状态:基础用户资料已有;产品菜单新增。** + +- `GET /v1/im/me` 返回公开账号资料、实名/青少年模式摘要和资料版本;`GET /v1/im/bootstrap` 返回个人菜单与权限。 +- 头像、昵称更新事件到达后刷新头部;收藏和设置入口分别进入第 7、10 章。 +- 账号 ID 按字符串展示,客户端不得展示内部数据库 ID 或 tenant_id。 + +### 10.2 个人信息.png + +个人信息 + +**状态:Tinode `me` 资料更新已有基础;目标产品接口需改造。** + +- 查询 `GET /v1/im/me/profile` 返回 `avatar_file_id,nickname,birth_date,signature,gender,region,public_account,version,field_rules`。 +- 更新统一使用 `PATCH /v1/im/me/profile`,仅提交变更字段并带 `version`;服务端更新后同步 Tinode `me.public` 并推送资料事件。 +- 头像先以 `purpose=user_avatar` 上传再 PATCH;任何字段的可见性由隐私策略裁剪。 + +### 10.3 设置昵称.png + +设置昵称 + +**状态:需改造。** + +- `PATCH /v1/im/me/profile`,Body `{"nickname":"新昵称","version":n}`。 +- 服务端校验 Unicode 长度、全空白、控制字符和敏感内容;返回更新后的 `nickname,version,updated_at`。 +- 成功刷新全局资料和联系人快照,但不得覆盖各群昵称。版本冲突先拉最新资料再由用户确认。 + +### 10.4 出生日期.png + +出生日期 + +**状态:新增产品字段。** + +- 保存同 10.3,Body `{"birth_date":"2000-01-02","version":n}`;仅传 ISO 日期,不传本地化文本或时区时间戳。 +- 服务端校验不晚于当前租户日期及合理范围,返回 `PROFILE_FIELD_INVALID`。展示格式由客户端本地化。 + +### 10.5 个性签名.png + +个性签名 + +**状态:需改造。** + +- 保存同 10.3,Body `{"signature":"...","version":n}`;空字符串表示清空。 +- 服务端按 `field_rules.signature.max_length` 和内容审核处理;失败保留本地草稿,成功刷新引用页面。 + +### 10.6 二维码.png + +二维码 + +**状态:图标纯客户端;二维码协议新增。** + +- 点击入口进入 5.4 并创建短期个人二维码。每次主动刷新调用 `POST /v1/im/me/qrcodes` 获取新 Token。 +- 二维码不能使用静态 `user_id`、手机号或登录 Token;过期、撤销和切租户后必须重新生成。 + +### 10.7 设置(iOS).png + +设置(iOS) + +**状态:菜单纯客户端;数据接口部分新增。** + +- 菜单来自 `bootstrap.settings_menu`,安全、通知、隐私、通用、关于按权限展示。系统通知权限状态由 iOS API 读取,不由服务端伪造。 +- 退出:`POST /v1/im/auth/logout` 撤销当前 refresh/access token 和设备会话,成功或 Token 已失效后关闭 WS、清安全存储和本租户用户缓存。 +- 多设备退出使用 `DELETE /v1/im/security/sessions/{session_id}`,需二次验证。 + +### 10.8 设置(安卓).png + +设置(安卓) + +**状态:菜单纯客户端;业务协议同 10.7。** + +- 使用相同 `bootstrap.settings_menu` 与退出协议;Android 系统权限、通知渠道和应用更新使用平台实现。 +- 服务端只保存应用内通知偏好与设备 Push Token,不把系统设置页面是否打开当作已授权。 + +### 10.9 清空缓存的提示.png + +清空缓存的提示 + +**状态:纯客户端。** + +- 确认后只删除缩略图、可重下文件、临时上传下载和搜索缓存;不调用服务端删除消息、收藏或账号接口。 +- 不删除安全存储中的登录凭证,除非用户选择退出登录。完成后重新计算本地占用并显示结果。 + +### 10.10 版本更新页面.png + +版本更新页面 + +**状态:新增 App 配置接口。** + +- `GET /v1/im/app/releases/latest?platform=ios|android¤t_version=1.0.0&channel=stable`,未登录可在企业解析后调用。 +- 返回 `latest_version,min_supported_version,update_type=none|optional|required,release_notes,store_url,package_url,checksum,published_at`。 +- 客户端按平台只打开受信任商店/签名下载地址;Android 包必须校验签名和 checksum,服务端 URL 使用允许域名。 + +### 10.11 发现新版本.png + +发现新版本 + +**状态:纯客户端弹窗;数据来自 10.10。** + +- `optional` 显示稍后/更新,记录本地已忽略版本并按服务端 `remind_after` 再提示;`required` 不显示绕过入口。 +- 下载和安装状态由平台管理;接口异常时不得误判为强制更新,除非本地已有有效签名的最低版本策略缓存。 + +### 10.12 隐私设置.png + +隐私设置 + +**状态:新增。** + +- `GET /v1/im/me/privacy` 返回 `add_me_methods,profile_visibility,last_seen_visibility,read_receipts,version` 和各字段 `editable`。 +- `PATCH /v1/im/me/privacy` 只提交变更字段和 `version`;成功返回完整最新配置并向其他设备推送 `privacy.updated`。 +- 黑名单入口使用 5.12。服务端在搜索、主页、回执等实际读写路径执行隐私规则,不能只保存开关。 + +### 10.13 隐私设置-添加我的方式选项.png + +隐私设置-添加我的方式选项 + +**状态:新增。** + +- `add_me_methods` 固定字段 `account,phone,qr,group`,每项为布尔值;更新用 10.12 PATCH,如 `{"add_me_methods":{"phone":false},"version":n}`。 +- 关闭后对应搜索/二维码/群来源申请由服务端统一返回 `FRIEND_REQUEST_NOT_ALLOWED` 或 `USER_NOT_DISCOVERABLE`,不泄露具体关闭项。 +- 企业强制策略可令字段 `editable=false`,前端禁用并显示服务端提供的通用说明。 + +### 10.14 青少年模式未开启(安卓).png + +青少年模式未开启(安卓) + +**状态:新增。** + +- `GET /v1/im/settings/youth-mode` 返回 `enabled,policy_version,restrictions,password_set,can_enable`。 +- 点击开启先展示 10.15,再按 1.7 完成身份验证和 1.11 设置独立密码;最后调用 `POST /v1/im/settings/youth-mode/enable`,Body `{"policy_version":n,"action_token":"..."}`。 +- 服务端持久化到用户租户范围并推送所有设备,不能仅设置本地标志。 + +### 10.15 青少年模式提示(安卓).png + +青少年模式提示(安卓) + +**状态:弹窗纯客户端;文案由服务端配置。** + +- 使用 10.14 返回的 `restrictions` 与 `policy_version` 渲染;取消不请求,确认进入密码设置/验证。 +- 最终 enable 请求携带用户确认的 `policy_version`;若策略更新返回 `POLICY_RECONFIRM_REQUIRED`,重新展示新内容。 + +### 10.16 青少年模式已开启(安卓).png + +青少年模式已开启(安卓) + +**状态:新增。** + +- 页面调用 10.14,`enabled=true` 时展示 `enabled_at,restrictions`。`bootstrap` 同时返回受限模块和动作。 +- 受限接口服务端返回 `YOUTH_MODE_RESTRICTED`;前端隐藏只是体验优化,不能作为安全控制。 +- 登录、App 重启和另一设备均从服务端恢复状态;收到 `youth_mode.updated` 即刷新。 + +### 10.17 关闭青少年模式(安卓).png + +关闭青少年模式(安卓) + +**状态:新增。** + +- 先调用 1.11 的独立密码验证,获得 `action_token`;再 `POST /v1/im/settings/youth-mode/disable`,Header `X-Action-Token`,带幂等键。 +- 成功返回 `enabled:false,updated_at` 并广播;连续密码错误按服务端 `retry_after_seconds` 锁定。 +- 忘记密码走 1.7 身份验证后重置,不能绕过密码直接修改本地状态。 + +### 10.18 获取推送权限(安卓).png + +获取推送权限(安卓) + +**状态:权限引导纯客户端;设备注册需改造。** + +- 用户确认后调用 Android 系统权限;拒绝状态本地记录,最终以系统回调为准。 +- 获取 Push Token 后 `PUT /v1/im/devices/{device_id}/push-token`,Body `{"provider":"fcm","token":"...","app_version":"..."}`;Token 轮换时覆盖。 +- 退出登录删除/解绑该用户设备 Token。Push Token 属敏感凭证,不写日志。 + +### 10.19 提醒(安卓).png + +提醒(安卓) + +**状态:纯客户端。** + +- 仅在功能因系统权限不可用时显示;取消和“前往设置”不调用业务服务端。 +- 回到应用后重新读取真实权限,不能因用户打开过设置页就假定授权成功。 + +### 10.20 相机说明(安卓).png + +相机说明(安卓) + +**状态:纯客户端。** + +- 首次拍照、扫码或视频通话前展示用途说明,确认后再触发系统权限;拒绝不发媒体/扫码请求。 +- 永久拒绝时提供系统设置入口;允许从相册选择等替代能力。客户端不得上传系统权限状态作为服务端授权依据。 + +### 10.21 麦克风说明(安卓).png + +麦克风说明(安卓) + +**状态:纯客户端。** + +- 首次录音或通话前说明并申请系统权限;未授权时不得创建录音文件或发送 call invite。 +- 永久拒绝后进入系统设置引导;恢复授权后重新执行 4.7 或第 8 章动作,不复用过期媒体会话。 + +## 11. 反诈安全、举报与意见反馈 + +### 11.1 反诈弹窗(安卓).png + +反诈弹窗(安卓) + +**状态:新增风控决策协议。** + +- 高风险动作在原业务接口返回 `409 RISK_CONFIRMATION_REQUIRED`,`details={risk_challenge_id,level,notice_code,notice_params,expires_at,action}`。 +- 客户端用 `notice_code` 映射经过审核的本地文案,不显示服务端规则、分数或命中特征;取消不重试原操作。 +- 低风险提示也可由可信 `security.risk_notice` 系统事件触发。普通消息不得伪装成系统反诈弹窗。 + +### 11.2 反诈弹窗(二次提示安卓).png + +反诈弹窗(二次提示安卓) + +**状态:新增。** + +- 确认:`POST /v1/im/security/risk-challenges/{risk_challenge_id}/confirm`,Body `{"decision":"continue|cancel"}`,带幂等键。 +- `continue` 成功返回单次、短期 `risk_action_token`;客户端在重试原业务接口时以 `X-Risk-Action-Token` 携带,原请求幂等键保持不变。 +- 服务端可返回 `RISK_ACTION_BLOCKED` 直接阻断;确认、取消、过期均写审计,客户端不得无限创建 challenge 绕过。 + +### 11.3 举报.png + +举报 + +**状态:新增。** + +- 原因:`GET /v1/im/reports/abuse/reasons?target_type=user|group|message` 返回 `reason_code,label,requires_description,allows_evidence`。 +- 进入页面携带受控目标:用户 `user_id`、群 `topic` 或消息 `(topic,seq)`;客户端不提交 tenant_id 或被举报者任意文本身份。 +- 服务端先验证举报人能够看到目标,再返回适用原因;举报人身份不下发给被举报方。 + +### 11.4 举报(1).png + +举报(1) + +**状态:新增。** + +- 证据使用 `purpose=abuse_evidence` 上传;提交 `POST /v1/im/reports/abuse`,Body `{"target":{"type":"message","topic":"...","seq":"12"},"reason_code":"fraud","description":"...","evidence_file_ids":[]}`。 +- 服务端从目标消息保存必要、受控的审核快照,校验证据数量/MIME/大小,返回 `report_id,status:"received"`。 +- 重复举报按策略合并并返回原受理号或 `REPORT_RATE_LIMITED`;证据使用独立严格 ACL 和保留期。 + +### 11.5 意见反馈.png + +意见反馈 + +**状态:新增。** + +- 配置:`GET /v1/im/feedback/config` 返回类别、最大正文、附件规则、是否允许联系方式和日志授权说明。 +- 提交:`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"`,重复提交由幂等键去重。 + +## 12. 联调实施清单 + +### 12.1 当前可直接联调的基础能力 + +1. WebSocket `/v0/channels`、Wire Version `0.25` 和最低版本 `0.20`。 +2. 第一条 `hi` 携带企业码并固定 Session 租户;响应包含公开租户信息、限制、ICE 配置和呼叫超时。 +3. `basic`/`token` 登录,`sub/get/set/pub/leave/del/note` 基础 Tinode 协议。 +4. `pub` 成功返回 `202 + seq`,Topic 消息与基础回执同步。 +5. `/v0/file/u/` 和 `/v0/file/s/{file_id}` 的基础上传下载及租户存储隔离。 + +### 12.2 必须优先完成的服务端阻塞项 + +| 优先级 | 改造项 | 阻塞图片章节 | +| --- | --- | --- | +| P0 | `/v1/im` 统一认证中间件、响应封装、幂等键、租户 Principal | 全部 HTTP 产品能力 | +| 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 | +| P1 | 好友申请、好友关系、黑名单状态机 | 第 3、5 章 | +| P1 | 群成员、角色、禁言、公告、邀请审批和配额 | 第 6 章 | +| P1 | 收藏、转发和聊天记录搜索 | 第 4、7 章 | +| P1 | 通话媒体状态、结果消息、Push 来电 | 第 8 章 | +| P2 | 工作台、组织、会议、日报、月报、信箱 | 第 9 章 | +| P2 | 隐私、青少年模式、反诈、举报、反馈 | 第 10、11 章 | + +### 12.3 前后端联调最低验收用例 + +| 编号 | 场景 | 预期结果 | +| --- | --- | --- | +| T-01 | A 企业 Token 连接 B 企业 Session | 登录失败 `TENANT_MISMATCH`,不产生用户/Topic 数据 | +| T-02 | 企业停用后使用旧 Token 登录和请求 HTTP | WS/HTTP 均拒绝,客户端清除凭证并回企业入口 | +| T-03 | 同 `client_mid` 断线重发同一消息 | 只落一条消息并返回同一 `seq` | +| T-04 | 相同 HTTP 幂等键、相同请求体重复提交 | 返回第一次结果;不同请求体返回 `IDEMPOTENCY_CONFLICT` | +| T-05 | 被拉黑或删除好友后绕过按钮直接 `pub` | 服务端拒绝,对方不收到消息 | +| T-06 | 普通用户构造 `system_event` 或向系统 Topic 发布 | 服务端拒绝,系统消息页不展示可信样式 | +| T-07 | 普通群成员调用管理员、禁言、转让群主接口 | 返回 `FORBIDDEN`,群状态不变且写安全审计 | +| T-08 | 群成员数已达租户配置上限时同意两个并发申请 | 至多一个成功,成员数不超限 | +| T-09 | 非消息接收者持同租户 Token 下载文件 | 返回 `FILE_FORBIDDEN`,不能只通过 file_id 下载 | +| T-10 | 文件下载中 Token 过期后刷新并 Range 续传 | 文件完整、checksum 正确、不重复下载已完成分片 | +| T-11 | 风控二次确认后重复重放 risk token | 只有首次有效,其余返回 Token 已消费 | +| T-12 | 切换企业后查看缓存、上传任务、收藏和搜索 | 不出现上一租户数据或任务 | + +### 12.4 统一错误 reason + +首版客户端至少实现:`TENANT_UNAVAILABLE`、`TENANT_MISMATCH`、`AUTH_FAILED`、`TOKEN_EXPIRED`、`ACCOUNT_DISABLED`、`RATE_LIMITED`、`VALIDATION_FAILED`、`IDEMPOTENCY_CONFLICT`、`RESOURCE_NOT_FOUND`、`FORBIDDEN`、`FRIEND_RELATION_REQUIRED`、`MESSAGE_NOT_ALLOWED`、`GROUP_MUTED`、`GROUP_MEMBER_LIMIT_EXCEEDED`、`TENANT_USER_QUOTA_EXCEEDED`、`TENANT_GROUP_QUOTA_EXCEEDED`、`FILE_TOO_LARGE`、`FILE_FORBIDDEN`、`FILE_UNAVAILABLE`、`STORAGE_QUOTA_EXCEEDED`、`REALNAME_REQUIRED`、`YOUTH_MODE_RESTRICTED`、`RISK_CONFIRMATION_REQUIRED`、`RISK_ACTION_BLOCKED`。 + +未知 `reason` 使用通用失败文案并记录 `request_id`,不得把服务端 `message` 原样展示给用户。 + +### 12.5 完成定义 + +每个图片小节进入联调前,服务端须提供 OpenAPI/JSON Schema、错误码、权限规则和可用测试租户;前端须提供请求日志脱敏、断线/重试行为和四态页面(加载、空、成功、失败)。联调完成必须逐图勾选对应协议、至少覆盖成功、权限拒绝、重复提交、断网恢复和跨租户五类场景。 diff --git a/docs/im/IM-多租户第5至12章代码改造与测试方案.md b/docs/im/IM-多租户第5至12章代码改造与测试方案.md new file mode 100644 index 0000000..860920e --- /dev/null +++ b/docs/im/IM-多租户第5至12章代码改造与测试方案.md @@ -0,0 +1,659 @@ +# IM 服务端多租户第 5 至 12 章代码改造与测试方案 + +## 1. 文档目的 + +本文是《IM-服务端多租户改造方案》第 5 章至第 12 章的代码实施方案,覆盖租户识别与认证、Session 与 Topic、Store 与 Adapter、MySQL 数据访问、缓存与集群、文件、搜索,以及 Push、插件和外部服务。 + +本文只规划代码改造和测试,不包含数据库建表、数据迁移、历史数据回填、双写、灰度迁移或旧 Token 兼容。实施前提是本地 MySQL 已经更新为最终多租户表结构,所有业务表的 `tenant_id`、复合索引和复合外键均已就绪。 + +## 2. 实施范围与原则 + +### 2.1 本期范围 + +- WebSocket 握手解析企业码并固定 Session TenantID; +- 注册、登录、Token、验证码、REST 认证和密码找回租户化; +- User、Topic、Subscription、Message、Device、File、Cache 的 Store 与 MySQL 查询租户化; +- Hub、本地缓存、Session Store 和集群路由键租户化; +- 文件上传、下载、关联、删除和对象存储路径租户化; +- 用户、Topic、Tags、消息搜索和组织数据租户化; +- Push、Plugin Firehose 及外部服务调用携带可信 TenantID; +- 单元测试、MySQL 集成测试、双租户隔离测试和集群测试。 + +### 2.2 明确不做 + +- 不实现 `tenant_ticket`,首次 `hi` 继续直接提交公开企业码; +- 不兼容无 TenantID 的旧 Token; +- 不设置默认租户; +- 不提供无租户业务 Store; +- 不处理历史数据; +- 本期产品运行时只支持 MySQL。MongoDB、PostgreSQL 和 RethinkDB 不得以未隔离模式启动。 + +### 2.3 核心安全不变量 + +以下规则应通过类型、统一入口和测试共同保证: + +```text +tenant_id 只能由服务端根据 hi.tenant、已签名 Token 或已认证 Session 得到 +已认证 Session 的 tenant_id != 0、uid != 0 +认证结果 tenant_id == Session tenant_id +业务对象 tenant_id == Session tenant_id +内部消息、缓存键、集群路由和外部调用均携带同一个 tenant_id +任何跨租户访问统一返回 NotFound/Denied,不泄露目标对象是否存在 +``` + +禁止从 Topic Tags、请求 Body、URL Query 或普通 Header 中读取客户端自报的内部 `tenant_id` 作为授权依据。 + +## 3. 当前实现基线 + +| 能力 | 当前状态 | 本方案处理 | +| --- | --- | --- | +| `TenantID`、租户实体和状态 | 已实现 | 保留并补充通用 TenantKey 类型 | +| `hi.tenant` 企业码 | 已实现 | 保留;补充后续链路测试 | +| Session 固定 TenantID | 已实现 | 补充认证后用户归属校验 | +| MySQL `TenantGetByCode` | 已实现 | 保留 | +| `ClientComMessage.TenantID` | 已实现 | 扩展到服务端消息、集群和后台任务 | +| protobuf `ClientHi.tenant` | 已实现 | 补充内部 TenantID 字段或 RPC 元数据 | +| `AuthContext`、`auth.Rec.TenantID` | 未实现 | 第 5 章实施 | +| Token 携带 TenantID | 未实现 | 升级为 Token v2,拒绝旧格式 | +| Store/Adapter 业务接口租户化 | 未实现 | 第 7 章实施 | +| MySQL 业务 SQL 带租户条件 | 未实现 | 第 8 章实施 | +| Hub/Cluster/Cache 路由键租户化 | 未实现 | 第 9 章实施 | +| 文件、搜索、Push、插件租户化 | 未实现 | 第 10 至 12 章实施 | + +## 4. 总体代码结构 + +### 4.1 租户作用域类型 + +在 `server/store/types` 增加统一键类型,所有内部路由和缓存只通过这些类型生成 Key: + +```go +type TopicKey struct { + TenantID TenantID + Topic string +} + +func (k TopicKey) RoutingKey() (string, error) + +type UserKey struct { + TenantID TenantID + UID Uid +} + +func (k UserKey) CacheKey() (string, error) +``` + +`TenantID == ZeroTenantID`、Topic 为空或 UID 为零时生成 Key 必须报错。业务代码不得自行拼接 `tenantID + ":" + topic`。 + +### 4.2 租户作用域 Store + +推荐在 `server/store/store.go` 增加: + +```go +type TenantStore struct { + TenantID types.TenantID + Users TenantUsersStore + Topics TenantTopicsStore + Subs TenantSubsStore + Messages TenantMessagesStore + Devices TenantDevicesStore + Files TenantFilesStore + PCache TenantCacheStore +} + +func ForTenant(tenantID types.TenantID) (*TenantStore, error) +``` + +Session、Topic、HTTP Handler 和后台任务先取得 `TenantStore`,再访问业务数据。`ForTenant(0)` 必须返回错误。租户注册表和平台管理操作继续使用独立的 `Tenants`/`PlatformStore`,不能混入业务 Store。 + +底层 MySQL Adapter 的业务方法显式接收 `tenantID`。为了避免未完成的数据库 Adapter 继续工作,启动时检查配置的 Adapter 是否完整实现租户能力;非 MySQL Adapter 返回 `ErrUnsupportedTenant` 并终止启动。 + +### 4.3 TenantID 的传播方向 + +```mermaid +flowchart LR + HI["hi.tenant 企业码"] --> S["Session.tenantID"] + S --> A["AuthContext"] + A --> R["auth.Rec.TenantID"] + S --> C["Client/Server 内部消息"] + C --> T["TopicKey / UserKey"] + T --> DB["TenantStore / MySQL"] + T --> CL["Cluster RPC"] + C --> F["File / Search / Push / Plugin"] +``` + +传播只允许从左向右。认证器、插件、数据库结果和外部回调只能验证 TenantID,不能替换 Session 已绑定的 TenantID。 + +## 5. 第 5 章:租户识别与认证流程 + +### 5.1 修改目标 + +已完成的 `hi.tenant` 解析继续作为连接租户入口。本章补齐认证上下文、认证记录、Token v2、凭证查询和管理员边界。 + +### 5.2 代码修改 + +#### 5.2.1 认证接口 + +修改 `server/auth/auth.go`: + +```go +type AuthContext struct { + TenantID types.TenantID + RemoteAddr string +} + +type Rec struct { + TenantID types.TenantID `json:"tenant_id,omitempty"` + // 原有字段保持不变 +} +``` + +将 `AuthHandler` 的以下方法改为接收 `AuthContext` 或 TenantID: + +```go +AddRecord(ctx AuthContext, rec *Rec, secret []byte) +UpdateRecord(ctx AuthContext, rec *Rec, secret []byte) +Authenticate(ctx AuthContext, secret []byte) +IsUnique(ctx AuthContext, secret []byte) +DelRecords(tenantID types.TenantID, uid types.Uid) +GetResetParams(tenantID types.TenantID, uid types.Uid) +``` + +同步修改: + +- `server/auth/basic/auth_basic.go`; +- `server/auth/code/auth_code.go`; +- `server/auth/rest/auth_rest.go`; +- `server/auth/token/auth_token.go`; +- `server/auth/anon/auth_anon.go`; +- `server/auth/mock_auth`; +- `server/session.go` 中注册、登录、临时认证和凭证校验调用点; +- HTTP 文件认证调用点。 + +所有认证器入口先校验 `ctx.TenantID != 0`。认证结果没有 TenantID,或 `rec.TenantID != ctx.TenantID`,统一返回认证失败并记录安全日志。 + +#### 5.2.2 Basic、Code、REST 认证 + +- Basic 登录名按 `(tenant_id, scheme, unique)` 查询; +- 注册时唯一性检查只在当前租户执行; +- Code 认证的验证码记录、次数和过期时间按租户隔离; +- REST 认证请求携带服务端签名的 TenantID,响应必须原样返回 TenantID; +- 密码找回先从 Session/签名上下文得到 TenantID,再查询 Credential,禁止全库按手机号或邮箱查找。 + +#### 5.2.3 Token v2 + +修改 `server/auth/token/auth_token.go`。当前 Token 是 50 字节且没有版本和 TenantID,替换为明确版本的 v2 布局: + +```text +[1:version][8:tenant_id][8:uid][4:expires][2:auth_level] +[2:serial][2:features][32:HMAC-SHA256] +``` + +实施规则: + +- `version` 固定为 2; +- `tenant_id` 和其余 Token 字段一起参与 HMAC; +- `GenSecret` 要求 `rec.TenantID != 0`; +- `Authenticate` 校验版本、精确长度、签名、租户、过期时间和 serial; +- v1/当前 50 字节 Token 直接返回 `ErrMalformed` 或 `ErrFailed`; +- Token TenantID 与 `AuthContext.TenantID` 不一致时返回认证失败; +- 认证成功返回包含 TenantID 的 `auth.Rec`。 + +本项目没有历史数据,因此不增加双版本解析和兼容开关。 + +#### 5.2.4 用户归属和管理员 + +登录取得 UID 后,使用当前 `TenantStore.Users.Get(uid)` 再确认 User 属于相同租户,然后才写入 `Session.uid`。Tenant Admin 角色通过租户内角色/权限数据判断,不能用全局 `AuthLevelRoot` 代替。普通 WebSocket 请求中的 `AsUser` 必须同时验证角色和目标 User 的 TenantID。 + +### 5.3 测试用例 + +| 编号 | 层级 | 场景与操作 | 预期结果 | +| --- | --- | --- | --- | +| T5-HI-001 | 单元 | 使用有效且 active 的 `acme` 执行首次 `hi` | Session 固定 Tenant A,响应只含 code/name | +| T5-HI-002 | 单元 | `hi` 不传 tenant、传空白或超过 64 字符 | 握手失败,Session 仍未绑定 | +| T5-HI-003 | 单元 | 使用不存在或 suspended 租户 | 分别返回 NotFound/Denied,不绑定 Session | +| T5-HI-004 | 单元 | 已绑定 A 的 Session 再传 B | 拒绝且 Session 仍为 A | +| T5-AUTH-001 | MySQL 集成 | A、B 分别创建相同登录名 `alice` | 两条记录均成功,分别登录到各自用户 | +| T5-AUTH-002 | MySQL 集成 | A 使用 B 的 `alice` 密码登录 | 认证失败,不泄露 B 用户存在性 | +| T5-AUTH-003 | 单元 | Authenticator 返回 B 的 `auth.Rec` 给 A Session | 登录失败并产生安全日志 | +| T5-AUTH-004 | 单元 | 为 A 用户生成 Token v2,再在 A Session 登录 | 成功,TenantID/UID 完整恢复 | +| T5-AUTH-005 | 单元 | A Token 在 B Session 使用 | 认证失败,B Session 不写入 UID | +| T5-AUTH-006 | 单元 | 修改 Token 中 TenantID 或版本字节 | HMAC/版本校验失败 | +| T5-AUTH-007 | 单元 | 使用当前 50 字节旧 Token | 明确拒绝,不进入兼容分支 | +| T5-AUTH-008 | MySQL 集成 | A、B 使用相同手机号发起密码找回 | 只命中当前租户用户和验证码 | +| T5-AUTH-009 | 权限 | A 的 tenant_admin 代操作 B 用户 | 拒绝;平台管理 API 不受普通 WS 入口影响 | + +## 6. 第 6 章:Session、消息和 Topic 改造 + +### 6.1 修改目标 + +TenantID 从 Session 贯穿到内部双向消息、Topic 生命周期和所有 P2P/群聊权限检查。客户端 Topic 名和对外协议保持不变。 + +### 6.2 代码修改 + +#### 6.2.1 内部消息和 Session + +- 保留 `Session.tenantID` 和 `ClientComMessage.TenantID`; +- 给 `ServerComMessage` 增加不序列化给普通客户端的 `TenantID`; +- Session 收到每条客户端消息时覆盖内部 TenantID,忽略反序列化对象中可能存在的值; +- 所有 Topic、Hub、Cluster 和后台生成消息必须显式填写 TenantID; +- 登录成功前验证 `User.tenant_id == Session.tenantID`; +- PROXY、MULTIPLEX Session 同样必须有 TenantID。 + +#### 6.2.2 Topic 对象和路由键 + +修改 `server/topic.go`、`server/hub.go`、`server/session.go` 及相关辅助类型: + +- `Topic` 增加初始化后不可变的 `tenantID`; +- Hub `topicGet/topicPut/topicDel` 参数从裸字符串改为 `types.TopicKey`; +- Session 订阅表、Topic Proxy、Multiplex Session 使用 `RoutingKey()`; +- 调试输出可显示 TenantID 和外部 Topic 名,但不能把内部前缀回传客户端; +- 数据库中的 Topic 名仍保存原始名称,不保存 `tenantID:` 前缀。 + +#### 6.2.3 特殊 Topic、P2P 和群聊 + +- `sys` 按 `(tenant_id, "sys")` 懒加载,不再创建全局唯一实例; +- `me`、`fnd`、`slf` 使用 Session TenantStore 加载; +- 创建 P2P 前加载双方 User,并验证双方与 Session 同租户; +- 创建群、邀请、审批、踢人、转让群主、发送系统通知时验证 Topic、操作者和目标成员同租户; +- Subscription、Outbox、系统消息和附件关联全部沿用 Topic TenantID。 + +### 6.3 测试用例 + +| 编号 | 层级 | 场景与操作 | 预期结果 | +| --- | --- | --- | --- | +| T6-MSG-001 | 单元 | A Session 发送普通消息 | `ClientComMessage.TenantID` 被服务端写为 A | +| T6-MSG-002 | 单元 | 构造内部消息自带 B TenantID 后交给 A Session | 值被覆盖或请求被拒绝,不能进入 B | +| T6-MSG-003 | 单元 | Topic 产生服务端响应/通知 | `ServerComMessage.TenantID` 等于 Topic TenantID | +| T6-TOPIC-001 | 单元 | A、B 同时加载裸名称 `sys` | Hub 中存在两个不同 Topic 实例 | +| T6-TOPIC-002 | 单元 | A、B 使用相同 Group Topic 名 | 内部 RoutingKey 不同,对外名称不变 | +| T6-P2P-001 | 集成 | A 用户邀请 A 用户创建 P2P | 创建成功,Topic/Subs 均为 A | +| T6-P2P-002 | 集成 | A 用户邀请 B 用户创建 P2P | 返回通用 NotFound/Denied,不产生 Topic/Subs | +| T6-GRP-001 | 集成 | A 管理员邀请 B 用户加入 A 群 | 拒绝且没有部分写入 | +| T6-GRP-002 | 集成 | A 用户用已知 B Topic 名订阅、读、写、删 | 所有操作均失败,B 数据不变化 | +| T6-SYS-001 | 集成 | A 发布系统通知 | 只有 A 的 `sys` 订阅者收到 | + +## 7. 第 7 章:Store 与 Adapter 改造 + +### 7.1 修改目标 + +消除业务代码中的无租户持久化入口,使漏传 TenantID 在编译期或统一 Store 入口立即失败,而不是依赖开发者记得追加 SQL 条件。 + +### 7.2 代码修改 + +#### 7.2.1 Store 接口 + +修改 `server/store/store.go`,为以下域建立 TenantStore Mapper: + +- Users:Create/Get/GetAll/Update/Delete/State/Tags/Topics/Subs/Unread; +- Auth:Add/Get/Update/Delete/Unique lookup; +- Credentials:Upsert/Get/Confirm/Fail/Delete/Reset lookup; +- Topics:Create/Get/Share/Delete/Update/Owner/Find; +- Subscriptions:Create/Get/Update/Delete/按 User 或 Topic 查询; +- Messages:Save/Get/Delete/GetDeleted/Topic SeqId; +- Devices:Upsert/Get/Delete/Push Channel; +- Files:Start/Finish/Get/Link/DeleteUnused/Access; +- Persistent Cache:Get/Upsert/Delete/Expire。 + +现有 `store.Users`、`store.Topics` 等全局 Mapper 不再供 Session/Topic/Handler 使用。完成调用点替换后删除或限制为测试期私有入口,避免新代码绕过 TenantStore。 + +#### 7.2.2 Adapter 接口 + +修改 `server/db/adapter.go`,所有租户业务方法显式接收 `types.TenantID`,例如: + +```go +UserGet(tenantID types.TenantID, uid types.Uid) +AuthGetUniqueRecord(tenantID types.TenantID, unique string) +TopicGet(tenantID types.TenantID, topic string) +SubscriptionGet(tenantID types.TenantID, topic string, uid types.Uid, keepDeleted bool) +MessageGetAll(tenantID types.TenantID, topic string, uid types.Uid, opts *types.QueryOpt) +FileGet(tenantID types.TenantID, fileID string) +``` + +数据对象 `User`、`Topic`、`Subscription`、`Message`、`DelMessage`、`Credential`、`DeviceDef` 和 `FileDef` 增加内部 TenantID。Create/Save 时同时校验“方法参数 TenantID、父对象 TenantID、待写对象 TenantID”一致。 + +#### 7.2.3 非 MySQL Adapter + +公共接口变化会影响 `server/db/postgres`、`server/db/mongodb`、`server/db/rethinkdb` 和 Mock。处理方式: + +- Mock 完整实现租户参数,用于单元测试; +- MySQL 完整实现业务逻辑; +- 其他 Adapter 实现相同签名但返回 `ErrUnsupportedTenant`; +- 服务启动阶段检测并拒绝使用不支持租户业务接口的 Adapter; +- 不允许退回旧的无租户查询。 + +### 7.3 测试用例 + +| 编号 | 层级 | 场景与操作 | 预期结果 | +| --- | --- | --- | --- | +| T7-STORE-001 | 单元 | 调用 `store.ForTenant(0)` | 返回错误,不创建 Mapper | +| T7-STORE-002 | 单元 | A TenantStore 查询 B UID | 返回 NotFound/Denied | +| T7-STORE-003 | 单元 | Create 参数为 A、对象 TenantID 为 B | 写入前失败,Adapter 不被调用 | +| T7-STORE-004 | 单元 | Mock 断言 Session 所有 Store 调用 | 每次调用都收到 Session TenantID | +| T7-STORE-005 | 构建 | 搜索生产代码中的全局 `store.Users/Topics/...` | Session/Topic/Handler 路径不存在绕过入口 | +| T7-ADP-001 | 启动 | 配置 PostgreSQL/MongoDB/RethinkDB | 启动失败并明确提示不支持多租户 | +| T7-ADP-002 | 单元 | Adapter 收到零 TenantID | 统一返回 `ErrInvalidTenant`,不执行 SQL | + +## 8. 第 8 章:MySQL 代码改造 + +### 8.1 前提 + +本地数据库已经更新,本章不执行任何 schema 修改。代码直接以最终多租户表结构为准,测试负责验证 SQL 使用 `tenant_id`、唯一约束和复合外键符合预期。 + +### 8.2 代码修改 + +修改 `server/db/mysql/adapter.go` 中所有业务 SQL: + +- `SELECT` 必须包含 `WHERE tenant_id = ?`; +- `UPDATE`、`DELETE` 必须同时包含 TenantID 和业务主键; +- `INSERT` 明确写入 `tenant_id`,不能依赖数据库默认值; +- JOIN 同时关联父子表 TenantID,例如 `child.tenant_id = parent.tenant_id`; +- 唯一查询按复合业务键执行,如 `(tenant_id, uname)`; +- 聚合、未读数、序号、清理任务和批处理同样按租户过滤; +- 受影响行数为 0 时直接返回 NotFound/Denied,不再发起无租户探测查询; +- 租户级垃圾清理采用 `tenantID + limit`,由平台调度器遍历 active 租户。 + +建议按域逐段改造并提交,每段同时更新对应 MySQL 测试: + +1. User、Auth、Credential; +2. Topic、Subscription、Tags; +3. Message、DelLog、未读数; +4. Device; +5. FileUpload、FileMessageLink; +6. Persistent Cache。 + +### 8.3 SQL 审计要求 + +新增测试辅助函数或代码审查清单,对 `adapter.go` 中业务 SQL 做静态检查。以下情况必须阻止合并: + +- 业务表查询完全没有 `tenant_id`; +- JOIN 只按 ID 关联,没有 TenantID; +- Update/Delete 只按全局 ID; +- `IN (...)` 批量查询没有 TenantID 前置条件; +- 后台清理一次扫描全部租户业务数据; +- 为判断“其他租户是否存在该对象”而执行无租户查询。 + +### 8.4 测试用例 + +| 编号 | 层级 | 场景与操作 | 预期结果 | +| --- | --- | --- | --- | +| T8-DB-001 | MySQL 集成 | A、B 插入相同 `auth.uname` | 均成功;同租户重复失败 | +| T8-DB-002 | MySQL 集成 | A、B 插入相同 Topic name/tag | 均成功;查询只返回当前租户 | +| T8-DB-003 | MySQL 集成 | A 查询 B 的 UID、Topic、Message、File | 均返回空/NotFound | +| T8-DB-004 | MySQL 集成 | A 更新/删除 B 对象的业务 ID | 影响行数为 0,B 数据不变 | +| T8-DB-005 | MySQL 集成 | 尝试写入 A Subscription 关联 B Topic/User | 代码校验或复合外键拒绝,事务回滚 | +| T8-DB-006 | MySQL 集成 | A 消息关联 B 文件 | 代码校验或复合外键拒绝 | +| T8-DB-007 | MySQL 集成 | A、B 分别统计未读数和设备数 | 结果只包含本租户数据 | +| T8-DB-008 | MySQL 集成 | A 的批量删除、过期和垃圾清理 | B 的 Message/File/Cache 不变化 | +| T8-DB-009 | 静态审计 | 扫描全部业务 SQL | 无缺失 TenantID 的读写和 JOIN | + +## 9. 第 9 章:缓存与集群改造 + +### 9.1 代码修改 + +#### 9.1.1 Hub 与 User Cache + +修改 `server/hub.go`: + +- `topicGet/topicPut/topicDel` 使用 `TopicKey.RoutingKey()`; +- 放入 Hub 前验证请求、Topic 对象和数据库行 TenantID 一致; +- `topicUnreg`、rehash、stats 和 debug 结构增加 TenantID。 + +修改 User Cache、未读数、Pending Push 和 EvictUser 相关代码: + +- 使用 `UserKey{TenantID, UID}`; +- `EvictUser(tenantID, uid, skipSID)`; +- 用户状态、设备和未读数更新都携带 TenantID。 + +Session ID 保持全局随机,但通过 SID 取 Session 后,使用方必须读取并验证 Session TenantID,不能再只取 UID。 + +#### 9.1.2 集群 + +修改 `server/cluster.go`: + +- `ClusterSess`、`ClusterSessUpdate`、`ClusterReq`、`ClusterRoute`、`ClusterResp` 增加 TenantID; +- `nodeForTopic`、`isRemoteTopic` 接收 `TopicKey` 并按 RoutingKey 做一致性哈希; +- Proxy/Master 两端重算 RoutingKey,并验证 RPC、Session、Client/Server Message TenantID 一致; +- User Cache RPC 和 Topic rehash 携带 TenantID; +- Cluster 协议版本增加多租户能力位,旧节点拒绝加入。 + +#### 9.1.3 持久化缓存 + +增加统一 `TenantCacheKey(tenantID, namespace, key)`。租户配置、验证码、未读数和业务临时状态均使用该函数。平台级 schema/version 缓存使用独立 Platform Cache 接口。 + +### 9.2 测试用例 + +| 编号 | 层级 | 场景与操作 | 预期结果 | +| --- | --- | --- | --- | +| T9-HUB-001 | 单元 | A、B Put/Get 相同 Topic 名 | 分别命中各自对象,无覆盖 | +| T9-HUB-002 | 单元 | 将 B Topic 放入 A TopicKey | 拒绝并记录安全告警 | +| T9-USER-001 | 单元 | Evict A 的同值 UID | 只断开 A Session,B Session 保持 | +| T9-CACHE-001 | MySQL 集成 | A、B 写同 namespace/key | 值独立;过期 A 不删除 B | +| T9-CL-001 | 单元 | A、B 相同 Topic 计算节点 | 使用不同 RoutingKey;结果按各自 Key 稳定 | +| T9-CL-002 | 集群集成 | RPC TenantID 与消息 TenantID 不一致 | 接收端拒绝,不创建 Proxy Topic | +| T9-CL-003 | 集群集成 | A Session 伪造 B RcptTo | Master 重算路由后拒绝 | +| T9-CL-004 | 集群集成 | 节点重哈希 | A、B 同名 Topic 均完整迁移且不合并 | +| T9-CL-005 | 启动 | 多租户节点与旧协议节点组网 | 握手失败,不能形成混合版本集群 | + +## 10. 第 10 章:文件与对象存储改造 + +### 10.1 代码修改 + +在 `server/hdl_files.go` 增加: + +```go +type RequestPrincipal struct { + TenantID types.TenantID + UID types.Uid + Roles []string +} +``` + +`authFileRequest` 改为返回 `RequestPrincipal`: + +- Token 路径从 Token v2 得到 TenantID; +- SID 路径从 Session 得到 TenantID; +- 两条路径都要求 TenantID 和 UID 非零; +- API Key 只做应用准入,不推导 TenantID。 + +Store/Adapter 增加 `FileCanAccess(tenantID, uid, fileID, action)`,并改造上传、下载、断点续传、关联、删除和垃圾回收: + +- 先使用 TenantID 加载 File; +- 未完成上传只允许同租户上传者访问; +- 已关联消息时校验同租户 Subscription 和 Read 权限; +- 删除验证上传者、群主或租户管理员角色; +- 转发时验证源文件、源消息和目标 Topic 同租户; +- 签名 URL 覆盖 TenantID、FileID、Action 和 Expires; +- 新对象路径使用 `tenants/{tenant_id}/yyyy/mm/{file_id}`; +- Location 仅供服务端使用,不直接返回客户端。 + +### 10.2 测试用例 + +| 编号 | 层级 | 场景与操作 | 预期结果 | +| --- | --- | --- | --- | +| T10-AUTH-001 | 单元 | Token 和 SID 分别认证同一用户 | 得到相同 TenantID/UID Principal | +| T10-AUTH-002 | 单元 | 无租户旧 Token、无效 SID 或零 UID | 认证失败,不进入 File Store | +| T10-UP-001 | 集成 | A 上传文件 | DB TenantID 为 A,路径以 `tenants/A/` 开头 | +| T10-DL-001 | 集成 | A 用户下载 A 群内有权限文件 | 成功 | +| T10-DL-002 | 集成 | A 使用已知 B FileID 下载 | 通用 NotFound/Denied,不返回元数据/Location | +| T10-LINK-001 | 集成 | A 消息关联 B FileID | 失败,文件 use_count 不变化 | +| T10-FWD-001 | 集成 | A 转发 B 文件到 A Topic | 失败且不创建消息 | +| T10-SIGN-001 | 单元 | 修改签名 URL 中 TenantID/FileID/Expires | 验签失败 | +| T10-GC-001 | MySQL 集成 | 清理 A 未使用文件 | B 文件记录和对象不被删除 | + +## 11. 第 11 章:搜索、Tags 与组织数据 + +### 11.1 代码修改 + +修改 `server/store/store.go`、`server/db/adapter.go` 和 `server/db/mysql/adapter.go`: + +- `Find`、`FindOne`、User/Topic Tag 增删改查显式接收 TenantID; +- 精确账号、手机号、邮箱查询只在当前租户执行; +- 结果对象再次验证 TenantID 后才返回; +- 无结果和命中其他租户统一表现为空/NotFound。 + +消息搜索索引文档必须包含 TenantID,文档键或路由键使用 `tenant_id + topic + seq_id`。查询时先强制加 TenantID,再按当前用户在同租户 Topic 的 Read ACL 二次过滤。TenantID 来自认证上下文,不接受 Query 参数覆盖。 + +组织表 Repository 均以 TenantID 为首参数。新增/移动部门、设置负责人、添加成员时加载父部门和 User,验证它们属于同一租户。涉及多表写入时使用事务,任何归属不一致都整体回滚。 + +### 11.2 测试用例 + +| 编号 | 层级 | 场景与操作 | 预期结果 | +| --- | --- | --- | --- | +| T11-FIND-001 | MySQL 集成 | A、B 用户具有相同 `email:`/`tel:` Tag | 各租户只返回自己的用户 | +| T11-FIND-002 | MySQL 集成 | A 搜索只有 B 存在的 Tag | 返回空结果,不提示跨租户命中 | +| T11-FIND-003 | MySQL 集成 | A、B 使用相同 Topic Tag | 排名和结果集合互不影响 | +| T11-MSG-001 | 搜索集成 | A、B 消息正文相同 | A 查询结果中不出现 B 文档 | +| T11-MSG-002 | 搜索集成 | A 用户搜索 A 内无 Read 权限 Topic | ACL 二次过滤移除结果 | +| T11-MSG-003 | 安全 | Query 参数提交 B TenantID | 参数被忽略或请求被拒绝,仍使用 Token TenantID | +| T11-ORG-001 | MySQL 集成 | A 部门添加 A 用户 | 成功,组织关系为 A | +| T11-ORG-002 | MySQL 集成 | A 部门添加 B 用户/负责人 | 失败,事务无部分写入 | +| T11-ORG-003 | MySQL 集成 | A 部门设置 B 部门为 parent | 失败,部门树不变化 | + +## 12. 第 12 章:Push、插件和外部服务 + +### 12.1 Push + +修改 `server/push/push.go`: + +```go +type Receipt struct { TenantID types.TenantID /* ... */ } +type ChannelReq struct { TenantID types.TenantID /* ... */ } +type Payload struct { TenantID types.TenantID `json:"-"` /* ... */ } +``` + +- Push 构造入口要求 TenantID 非零; +- 设备按 `(tenant_id, uid)` 查询; +- Channel 使用统一函数生成租户前缀,不能直接拼接客户端值; +- Push Handler 根据 TenantID 选择模板、App 包和密钥引用; +- 未读数使用 UserKey; +- 日志和指标带 TenantID,但不得输出完整设备 Token。 + +### 12.2 Plugin Firehose + +修改 `server/plugins.go` 和 protobuf: + +- `pluginGenerateClientReq` 写入 Session TenantID; +- Plugin 请求包含 TenantID 和可选租户策略版本; +- `REPLACE` 响应反序列化后,服务端强制恢复原 TenantID; +- 替换后的目标 Topic 重新执行同租户校验; +- `RESPOND` 生成的服务端消息也强制使用原 TenantID; +- 插件超时、失败开放/关闭、审计和指标按租户配置执行。 + +### 12.3 REST、短信、TURN 和对象存储 + +- 外部请求使用服务端签名的 TenantContext,至少覆盖 TenantID、请求用途、过期时间和 nonce; +- 回调响应必须返回 TenantID/nonce,服务端验证与原请求一致; +- 秘钥管理只保存租户配置中的 Secret 引用,业务 JSON 不保存明文 Secret; +- 短信模板、Push 凭证、对象存储 Bucket/Prefix 和 TURN 配置按 TenantID 选择; +- 租户没有配置时按明确策略失败,不得静默使用其他租户配置。 + +### 12.4 测试用例 + +| 编号 | 层级 | 场景与操作 | 预期结果 | +| --- | --- | --- | --- | +| T12-PUSH-001 | 单元 | A 消息生成 Push Receipt | Receipt/Payload/Channel 均为 A | +| T12-PUSH-002 | 集成 | A、B 有同值 UID 和不同设备 | A Push 只读取并投递 A 设备 | +| T12-PUSH-003 | 单元 | A、B 使用相同逻辑 Channel | 生成的物理 Channel 不碰撞 | +| T12-PUSH-004 | 单元 | TenantID 为零或找不到租户凭证 | 明确失败,不回退到其他租户凭证 | +| T12-PLG-001 | 单元 | A 请求发送到 Firehose | Plugin Request TenantID 为 A | +| T12-PLG-002 | 安全 | Plugin `REPLACE` 返回 B TenantID | 服务端拒绝或恢复 A,并重新校验 Topic | +| T12-PLG-003 | 安全 | Plugin `RESPOND` 返回 B TenantID | 响应不能进入 B 路由 | +| T12-EXT-001 | 单元 | 篡改外部请求 TenantContext | 签名校验失败 | +| T12-EXT-002 | 集成 | A 请求收到带 B TenantID/错误 nonce 的回调 | 回调拒绝,不更新任何业务状态 | +| T12-EXT-003 | 配置 | A 未配置短信/TURN/对象存储 | 按 A 策略失败,不使用 B 或全局 Secret | + +## 13. 实施顺序与提交边界 + +### 阶段 A:基础类型和认证 + +1. 增加 TopicKey、UserKey、AuthContext 和 `auth.Rec.TenantID`; +2. 修改全部认证器和 Mock; +3. 实现 Token v2; +4. 完成第 5 章测试。 + +完成条件:注册、登录、Token 和密码找回全部绑定租户,旧 Token 明确拒绝。 + +### 阶段 B:Store、MySQL 与单机消息链路 + +1. 增加 TenantStore; +2. 改造 Adapter 接口和 MySQL User/Auth/Credential; +3. 改造 Topic/Subscription/Message/Device/File/Cache; +4. 改造 Session、Topic 和 Hub 调用点; +5. 完成第 6 至 8 章测试。 + +完成条件:单机模式下两个租户可使用同名登录名、Topic 和 Tag,所有交叉读写失败。 + +### 阶段 C:缓存、集群和文件 + +1. Hub/User/PCache Key 租户化; +2. Cluster RPC 和一致性哈希租户化; +3. 文件 Principal、ACL、路径和签名租户化; +4. 完成第 9、10 章测试。 + +完成条件:双节点路由和文件链路不存在跨租户缓存命中或资源访问。 + +### 阶段 D:搜索、Push、插件和外部服务 + +1. Find/Tags/消息索引/组织 Repository; +2. Push 数据结构、设备查询和凭证选择; +3. Plugin protobuf 与 REPLACE/RESPOND 校验; +4. 外部 TenantContext 签名与回调验证; +5. 完成第 11、12 章测试。 + +完成条件:所有出站和异步链路均可按 TenantID 追踪,并通过反向篡改测试。 + +## 14. 测试环境与公共数据 + +### 14.1 测试租户 + +在专用测试库中准备以下数据,不使用开发人员的日常业务数据: + +| 数据 | Tenant A | Tenant B | +| --- | --- | --- | +| tenant_id | 1001 | 1002 | +| code | acme | beta | +| state | active | active | +| 登录名 | alice | alice | +| 手机号 | 13800000000 | 13800000000 | +| 逻辑 Topic | sys、grpShared | sys、grpShared | +| Tag | email:alice@example.com | email:alice@example.com | +| 文件 | file-shared-name | file-shared-name | + +另外准备一个 `suspended` 租户用于握手和已签 Token 的停用测试。测试数据必须由测试事务或 fixture 创建并清理,不能依赖固定自增 ID。 + +### 14.2 测试层次 + +- 单元测试:Key、AuthContext、Token、Session、Hub、Cluster RPC、Plugin 和签名; +- MySQL 集成测试:每个 Adapter 方法至少包含“同租户成功 + 跨租户失败”; +- 服务集成测试:两个真实 WebSocket Session 完成握手、登录、建群、发消息和文件访问; +- 双节点集群测试:同名 Topic 路由、Proxy、重哈希和节点版本检查; +- 安全测试:篡改 TenantID、Token、RPC、Plugin 响应和外部回调; +- 并发测试:A、B 同时创建同名业务键,验证唯一约束只在租户内生效。 + +## 15. 验证命令 + +具体数据库连接参数沿用仓库现有 MySQL 测试配置,不在文档中写死账号密码。实施过程中至少执行: + +```bash +go test ./server/auth/... ./server/store/... ./pbx +go test ./server +go test -tags mysql ./server/db/mysql/tests +go test -race ./server ./server/auth/... ./server/store/... +go build -tags mysql ./server/... +``` + +如果仓库现有 MySQL 测试要求特定工作目录或配置文件,应沿用现有测试入口。集群、对象存储、Push 和外部服务使用本地 fake/mock,另在测试环境执行真实双节点和真实供应商沙箱验收。 + +## 16. 完成标准 + +- [ ] 第 5 至 12 章所有生产调用链都能追踪到可信 TenantID 来源; +- [ ] 已认证 Session、User、Topic、消息和认证结果 TenantID 始终一致; +- [ ] 业务 Store 不存在可被生产代码调用的无租户入口; +- [ ] MySQL 所有业务读写、JOIN、批处理和清理均包含 TenantID; +- [ ] Hub、User Cache、PCache 和 Cluster 路由均使用租户 Key; +- [ ] Token、文件签名、Plugin 和外部上下文覆盖 TenantID 并通过篡改测试; +- [ ] A/B 双租户同名数据测试全部通过; +- [ ] 所有跨租户读、写、邀请、关联、删除和回调测试全部失败且不泄露对象存在性; +- [ ] 非 MySQL Adapter 无法以未隔离模式启动; +- [ ] 单元测试、MySQL 集成测试、race test 和 MySQL build 全部通过; +- [ ] 日志、指标和安全审计含 TenantID,但不输出密码、Token、设备 Token 或租户 Secret。 diff --git a/docs/im/IM-数据库变更汇总.sql b/docs/im/IM-数据库变更汇总.sql new file mode 100644 index 0000000..e05dfec --- /dev/null +++ b/docs/im/IM-数据库变更汇总.sql @@ -0,0 +1,713 @@ +-- IM product database changes summary, chapters 1-11. +-- Target: +-- public backend database: hotgo +-- default tenant business database: tinode +-- Notes: +-- 1. Public tenant registry tables live in hotgo. +-- 2. Tenant-scoped business tables live in each tenant database and do not include tenant_id. +-- 3. This file is for review and manual execution. Existing legacy columns/indexes should be checked before ALTER/DROP. +-- 4. Table definitions are aligned with server/db/mysql/adapter.go plus im_verification_codes used by chapter 1 verification APIs. + +SET NAMES utf8mb4; + +CREATE DATABASE IF NOT EXISTS hotgo CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci; +CREATE DATABASE IF NOT EXISTS tinode CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci; + +-- ============================================================ +-- Public database: tenant registry. +-- ============================================================ + +CREATE TABLE IF NOT EXISTS hotgo.im_tenant ( + id BIGINT NOT NULL AUTO_INCREMENT, + code VARCHAR(64) NOT NULL, + name VARCHAR(128) NOT NULL, + tenant_desc VARCHAR(256), + db_name VARCHAR(128) NOT NULL DEFAULT '', + db_dsn VARCHAR(1024) NOT NULL DEFAULT '', + state SMALLINT NOT NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + created_by BIGINT NOT NULL, + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + updated_by BIGINT NOT NULL, + PRIMARY KEY (id), + UNIQUE INDEX uk_im_tenant_code (code), + INDEX idx_im_tenant_db_name (db_name) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS hotgo.im_tenant_config ( + id BIGINT NOT NULL AUTO_INCREMENT, + tenant_id BIGINT NOT NULL, + max_users BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '0 means unlimited', + max_groups BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '0 means unlimited', + max_group_members INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '0 means unlimited', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + created_by BIGINT NOT NULL, + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + updated_by BIGINT NOT NULL, + PRIMARY KEY (id), + UNIQUE INDEX uk_im_tenant_config_tenant_id (tenant_id), + CONSTRAINT fk_im_tenant_config_tenant FOREIGN KEY (tenant_id) REFERENCES hotgo.im_tenant(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +-- ============================================================ +-- Tenant database: chapter 1, tenant-aware login, verification, security. +-- ============================================================ + +CREATE TABLE IF NOT EXISTS tinode.im_user_security ( + user_id BIGINT NOT NULL, + auth_version INT NOT NULL DEFAULT 1, + password_updated_at DATETIME(3), + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (user_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS tinode.im_verification_codes ( + id BIGINT NOT NULL AUTO_INCREMENT, + challenge_id VARCHAR(64) NOT NULL, + scene VARCHAR(32) NOT NULL, + action VARCHAR(64) NOT NULL DEFAULT '', + phone_hash CHAR(64) NOT NULL, + phone_mask VARCHAR(32) NOT NULL, + target_user BIGINT, + code_hash CHAR(64) NOT NULL, + verification_token_hash CHAR(64), + state SMALLINT NOT NULL, + attempts INT NOT NULL DEFAULT 0, + max_attempts INT NOT NULL, + expires_at DATETIME(3) NOT NULL, + resend_after DATETIME(3) NOT NULL, + verified_at DATETIME(3), + consumed_at DATETIME(3), + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + UNIQUE INDEX uk_im_verification_challenge (challenge_id), + INDEX idx_im_verification_scene_phone_state (scene, phone_hash, state, expires_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS tinode.im_realname_verifications ( + id BIGINT NOT NULL AUTO_INCREMENT, + user_id BIGINT NOT NULL, + submission_id VARCHAR(64) NOT NULL, + status VARCHAR(16) NOT NULL, + name_cipher TEXT NOT NULL, + id_type VARCHAR(32) NOT NULL, + id_number_cipher TEXT NOT NULL, + id_number_hash CHAR(64) NOT NULL, + masked_name VARCHAR(64) NOT NULL, + masked_id_number VARCHAR(64) NOT NULL, + review_reason VARCHAR(256), + verified_at DATETIME(3), + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + UNIQUE INDEX uk_im_realname_submission (submission_id), + INDEX idx_im_realname_user_latest (user_id, created_at), + INDEX idx_im_realname_id_hash (id_number_hash) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS tinode.im_action_tokens ( + id BIGINT NOT NULL AUTO_INCREMENT, + user_id BIGINT NOT NULL, + token_id VARCHAR(64) NOT NULL, + action VARCHAR(64) NOT NULL, + target VARCHAR(128) NOT NULL DEFAULT '', + token_hash CHAR(64) NOT NULL, + state SMALLINT NOT NULL, + expires_at DATETIME(3) NOT NULL, + consumed_at DATETIME(3), + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + UNIQUE INDEX uk_im_action_tokens_token (token_id), + INDEX idx_im_action_tokens_user_action (user_id, action, target, state, expires_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS tinode.im_youth_mode_settings ( + user_id BIGINT NOT NULL, + purpose VARCHAR(64) NOT NULL, + password_hash VARBINARY(255) NOT NULL, + enabled TINYINT NOT NULL DEFAULT 0, + policy_version INT NOT NULL DEFAULT 1, + restrictions JSON, + enabled_at DATETIME(3), + failed_attempts INT NOT NULL DEFAULT 0, + locked_until DATETIME(3), + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (user_id, purpose) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +-- ============================================================ +-- Tenant database: chapters 2-7, notifications, messages, friends, files, favorites. +-- ============================================================ + +CREATE TABLE IF NOT EXISTS tinode.im_system_notifications ( + id BIGINT NOT NULL AUTO_INCREMENT, + user_id BIGINT NOT NULL, + event_id VARCHAR(64) NOT NULL, + event_type VARCHAR(64) NOT NULL, + title VARCHAR(128) NOT NULL, + summary VARCHAR(512) NOT NULL, + action JSON, + read_at DATETIME(3), + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + UNIQUE INDEX uk_im_system_notifications_event (user_id, event_id), + INDEX idx_im_system_notifications_inbox (user_id, id DESC) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS tinode.im_message_revisions ( + id BIGINT NOT NULL AUTO_INCREMENT, + createdat DATETIME(3) NOT NULL, + updatedat DATETIME(3) NOT NULL, + topic CHAR(25) NOT NULL, + seqid INT NOT NULL, + version INT NOT NULL, + actor BIGINT NOT NULL, + head JSON, + content JSON, + reason VARCHAR(256) NOT NULL DEFAULT '', + PRIMARY KEY (id), + UNIQUE INDEX uk_im_message_revisions_version (topic, seqid, version) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS tinode.im_friend_requests ( + id BIGINT NOT NULL AUTO_INCREMENT, + request_id VARCHAR(64) NOT NULL, + applicant_id BIGINT NOT NULL, + target_id BIGINT NOT NULL, + message VARCHAR(200) NOT NULL DEFAULT '', + source VARCHAR(32) NOT NULL DEFAULT '', + source_topic VARCHAR(64) NOT NULL DEFAULT '', + status VARCHAR(16) NOT NULL, + idempotency_key VARCHAR(128) NOT NULL DEFAULT '', + expires_at DATETIME(3) NOT NULL, + handled_at DATETIME(3), + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + UNIQUE INDEX uk_im_friend_requests_request (request_id), + UNIQUE INDEX uk_im_friend_requests_idempotency (applicant_id, idempotency_key), + INDEX idx_im_friend_requests_pair (applicant_id, target_id, status, created_at), + INDEX idx_im_friend_requests_inbox (target_id, status, created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS tinode.im_friendships ( + user_id BIGINT NOT NULL, + peer_id BIGINT NOT NULL, + status VARCHAR(16) NOT NULL, + remark VARCHAR(64) NOT NULL DEFAULT '', + topic CHAR(25) NOT NULL DEFAULT '', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (user_id, peer_id), + INDEX idx_im_friendships_peer (peer_id, status) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS tinode.im_user_blocks ( + blocker_id BIGINT NOT NULL, + blocked_id BIGINT NOT NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (blocker_id, blocked_id), + INDEX idx_im_user_blocks_blocked (blocked_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS tinode.im_favorites ( + id BIGINT NOT NULL AUTO_INCREMENT, + favorite_id VARCHAR(64) NOT NULL, + user_id BIGINT NOT NULL, + item_type VARCHAR(16) NOT NULL, + source_topic CHAR(25) NOT NULL, + source_seq INT NOT NULL, + snapshot JSON, + attachment_refs JSON, + availability VARCHAR(16) NOT NULL DEFAULT 'available', + idempotency_key VARCHAR(128) NOT NULL DEFAULT '', + collected_at DATETIME(3) NOT NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + UNIQUE INDEX uk_im_favorites_favorite (favorite_id), + UNIQUE INDEX uk_im_favorites_source (user_id, source_topic, source_seq), + UNIQUE INDEX uk_im_favorites_idempotency (user_id, ((NULLIF(idempotency_key, '')))), + INDEX idx_im_favorites_inbox (user_id, id DESC), + INDEX idx_im_favorites_type (user_id, item_type, id DESC) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +-- Existing Tinode tables must match the current schema for chapters 2-7: +-- topics.aux, fileuploads.etag, and filemsglinks.topic/userid are required by current code. + +SET @im_sql = IF( + (SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = 'tinode' AND TABLE_NAME = 'messages' AND COLUMN_NAME = 'client_msg_id') = 0, + 'ALTER TABLE tinode.messages ADD COLUMN client_msg_id VARCHAR(64) AFTER `from`', + 'SELECT 1' +); +PREPARE im_stmt FROM @im_sql; +EXECUTE im_stmt; +DEALLOCATE PREPARE im_stmt; + +SET @im_sql = IF( + (SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = 'tinode' AND TABLE_NAME = 'messages' AND COLUMN_NAME = 'version') = 0, + 'ALTER TABLE tinode.messages ADD COLUMN version INT NOT NULL DEFAULT 1 AFTER client_msg_id', + 'SELECT 1' +); +PREPARE im_stmt FROM @im_sql; +EXECUTE im_stmt; +DEALLOCATE PREPARE im_stmt; + +SET @im_sql = IF( + (SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = 'tinode' AND TABLE_NAME = 'messages' AND COLUMN_NAME = 'recalled_at') = 0, + 'ALTER TABLE tinode.messages ADD COLUMN recalled_at DATETIME(3) AFTER version', + 'SELECT 1' +); +PREPARE im_stmt FROM @im_sql; +EXECUTE im_stmt; +DEALLOCATE PREPARE im_stmt; + +SET @im_sql = IF( + (SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = 'tinode' AND TABLE_NAME = 'messages' AND COLUMN_NAME = 'recalled_by') = 0, + 'ALTER TABLE tinode.messages ADD COLUMN recalled_by BIGINT AFTER recalled_at', + 'SELECT 1' +); +PREPARE im_stmt FROM @im_sql; +EXECUTE im_stmt; +DEALLOCATE PREPARE im_stmt; + +SET @im_sql = IF( + (SELECT COUNT(*) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = 'tinode' AND TABLE_NAME = 'messages' AND INDEX_NAME = 'messages_sender_client_mid') = 0, + 'CREATE UNIQUE INDEX messages_sender_client_mid ON tinode.messages(`from`, client_msg_id)', + 'SELECT 1' +); +PREPARE im_stmt FROM @im_sql; +EXECUTE im_stmt; +DEALLOCATE PREPARE im_stmt; + +-- ============================================================ +-- Tenant database: chapter 8, realtime call records. +-- ============================================================ + +CREATE TABLE IF NOT EXISTS tinode.im_call_records ( + id BIGINT NOT NULL AUTO_INCREMENT, + call_id VARCHAR(64) NOT NULL, + topic CHAR(25) NOT NULL, + seq_id INT NOT NULL, + caller_id BIGINT NOT NULL, + callee_id BIGINT NOT NULL, + call_type VARCHAR(16) NOT NULL, + status VARCHAR(32) NOT NULL, + end_reason VARCHAR(64) NOT NULL DEFAULT '', + duration_ms BIGINT NOT NULL DEFAULT 0, + started_at DATETIME(3) NOT NULL, + accepted_at DATETIME(3), + finished_at DATETIME(3), + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + UNIQUE INDEX uk_im_call_records_call (call_id), + UNIQUE INDEX uk_im_call_records_topic_seq (topic, seq_id), + INDEX idx_im_call_records_caller (caller_id, id DESC), + INDEX idx_im_call_records_callee (callee_id, id DESC), + INDEX idx_im_call_records_status (status, updated_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +-- ============================================================ +-- Tenant database: chapter 9, workbench and office collaboration. +-- ============================================================ + +CREATE TABLE IF NOT EXISTS tinode.im_workbench_apps ( + id BIGINT NOT NULL AUTO_INCREMENT, + code VARCHAR(64) NOT NULL, + name VARCHAR(128) NOT NULL, + icon_file_id VARCHAR(64) NOT NULL DEFAULT '', + route VARCHAR(128) NOT NULL, + sort_order INT NOT NULL DEFAULT 0, + unread_key VARCHAR(64) NOT NULL DEFAULT '', + enabled TINYINT NOT NULL DEFAULT 1, + permission VARCHAR(64) NOT NULL DEFAULT 'enabled', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + UNIQUE INDEX uk_im_workbench_apps_code (code), + INDEX idx_im_workbench_apps_enabled_order (enabled, sort_order) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS tinode.im_org_departments ( + id BIGINT NOT NULL AUTO_INCREMENT, + department_id VARCHAR(64) NOT NULL, + name VARCHAR(128) NOT NULL, + parent_id VARCHAR(64) NOT NULL DEFAULT 'root', + sort_order INT NOT NULL DEFAULT 0, + version INT NOT NULL DEFAULT 1, + state VARCHAR(16) NOT NULL DEFAULT 'active', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + UNIQUE INDEX uk_im_org_departments_department (department_id), + INDEX idx_im_org_departments_parent (parent_id, state, sort_order, id), + INDEX idx_im_org_departments_name (name) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS tinode.im_org_members ( + id BIGINT NOT NULL AUTO_INCREMENT, + department_id VARCHAR(64) NOT NULL, + user_id BIGINT NOT NULL, + display_name VARCHAR(128) NOT NULL DEFAULT '', + position VARCHAR(128) NOT NULL DEFAULT '', + avatar_file_id VARCHAR(64) NOT NULL DEFAULT '', + mobile VARCHAR(32) NOT NULL DEFAULT '', + email VARCHAR(128) NOT NULL DEFAULT '', + employment_state VARCHAR(16) NOT NULL DEFAULT 'active', + contactable TINYINT NOT NULL DEFAULT 1, + sort_order INT NOT NULL DEFAULT 0, + version INT NOT NULL DEFAULT 1, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + UNIQUE INDEX uk_im_org_members_department_user (department_id, user_id), + INDEX idx_im_org_members_department (department_id, employment_state, sort_order, id), + INDEX idx_im_org_members_user (user_id), + INDEX idx_im_org_members_display_name (display_name), + CONSTRAINT fk_im_org_members_department FOREIGN KEY (department_id) REFERENCES tinode.im_org_departments(department_id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS tinode.im_meetings ( + id BIGINT NOT NULL AUTO_INCREMENT, + meeting_id VARCHAR(64) NOT NULL, + organizer_id BIGINT NOT NULL, + title VARCHAR(200) NOT NULL, + start_at DATETIME(3) NOT NULL, + end_at DATETIME(3) NOT NULL, + location VARCHAR(256) NOT NULL DEFAULT '', + state VARCHAR(16) NOT NULL DEFAULT 'scheduled', + reminder_minutes JSON, + idempotency_key VARCHAR(128), + version INT NOT NULL DEFAULT 1, + canceled_at DATETIME(3), + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + UNIQUE INDEX uk_im_meetings_meeting (meeting_id), + UNIQUE INDEX uk_im_meetings_idempotency (organizer_id, idempotency_key), + INDEX idx_im_meetings_organizer (organizer_id, start_at), + INDEX idx_im_meetings_state_time (state, start_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS tinode.im_meeting_participants ( + id BIGINT NOT NULL AUTO_INCREMENT, + meeting_id VARCHAR(64) NOT NULL, + user_id BIGINT NOT NULL, + response VARCHAR(16) NOT NULL DEFAULT 'pending', + response_at DATETIME(3), + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + UNIQUE INDEX uk_im_meeting_participants_user (meeting_id, user_id), + INDEX idx_im_meeting_participants_user_time (user_id, meeting_id), + INDEX idx_im_meeting_participants_response (meeting_id, response), + CONSTRAINT fk_im_meeting_participants_meeting FOREIGN KEY (meeting_id) REFERENCES tinode.im_meetings(meeting_id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS tinode.im_daily_reports ( + id BIGINT NOT NULL AUTO_INCREMENT, + report_id VARCHAR(64) NOT NULL, + author_id BIGINT NOT NULL, + report_date DATE NOT NULL, + content TEXT NOT NULL, + attachments JSON, + status VARCHAR(16) NOT NULL DEFAULT 'draft', + read_state VARCHAR(16) NOT NULL DEFAULT 'unread', + version INT NOT NULL DEFAULT 1, + submitted_at DATETIME(3), + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + UNIQUE INDEX uk_im_daily_reports_report (report_id), + UNIQUE INDEX uk_im_daily_reports_author_date (author_id, report_date), + INDEX idx_im_daily_reports_author_id (author_id, id DESC), + INDEX idx_im_daily_reports_date_status (report_date, status, id DESC) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS tinode.im_monthly_reports ( + id BIGINT NOT NULL AUTO_INCREMENT, + report_id VARCHAR(64) NOT NULL, + author_id BIGINT NOT NULL, + report_month CHAR(7) NOT NULL, + work_summary TEXT NOT NULL, + next_plan TEXT NOT NULL, + attachments JSON, + status VARCHAR(16) NOT NULL DEFAULT 'draft', + read_state VARCHAR(16) NOT NULL DEFAULT 'unread', + version INT NOT NULL DEFAULT 1, + submitted_at DATETIME(3), + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + UNIQUE INDEX uk_im_monthly_reports_report (report_id), + UNIQUE INDEX uk_im_monthly_reports_author_month (author_id, report_month), + INDEX idx_im_monthly_reports_author_id (author_id, id DESC), + INDEX idx_im_monthly_reports_month_status (report_month, status, id DESC) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS tinode.im_mailbox_config ( + id BIGINT NOT NULL AUTO_INCREMENT, + enabled TINYINT NOT NULL DEFAULT 1, + allow_anonymous TINYINT NOT NULL DEFAULT 1, + required_fields JSON, + max_length INT NOT NULL DEFAULT 5000, + attachment_policy JSON, + notice_title VARCHAR(128) NOT NULL DEFAULT '', + notice_content TEXT NOT NULL, + notice_updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS tinode.im_mailbox_submissions ( + id BIGINT NOT NULL AUTO_INCREMENT, + submission_id VARCHAR(64) NOT NULL, + sender_id BIGINT NOT NULL, + subject VARCHAR(200) NOT NULL, + content TEXT NOT NULL, + anonymous TINYINT NOT NULL DEFAULT 0, + attachment_file_ids JSON, + notice_version VARCHAR(64) NOT NULL DEFAULT '', + idempotency_key VARCHAR(128) NOT NULL, + status VARCHAR(32) NOT NULL DEFAULT 'received', + submitted_at DATETIME(3) NOT NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + UNIQUE INDEX uk_im_mailbox_submissions_submission (submission_id), + UNIQUE INDEX uk_im_mailbox_submissions_idempotency (sender_id, idempotency_key), + INDEX idx_im_mailbox_submissions_sender (sender_id, id DESC), + INDEX idx_im_mailbox_submissions_status (status, id DESC) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +-- ============================================================ +-- Tenant database: chapter 10, profile, privacy, app release, push, youth mode. +-- ============================================================ + +CREATE TABLE IF NOT EXISTS tinode.im_user_profiles ( + user_id BIGINT NOT NULL, + avatar_file_id VARCHAR(64) NOT NULL DEFAULT '', + nickname VARCHAR(64) NOT NULL DEFAULT '', + birth_date DATE, + signature VARCHAR(256) NOT NULL DEFAULT '', + gender VARCHAR(16) NOT NULL DEFAULT '', + region VARCHAR(128) NOT NULL DEFAULT '', + public_account VARCHAR(64) NOT NULL DEFAULT '', + version INT NOT NULL DEFAULT 1, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (user_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS tinode.im_user_privacy_settings ( + user_id BIGINT NOT NULL, + add_me_methods JSON, + profile_visibility VARCHAR(32) NOT NULL DEFAULT 'friends', + last_seen_visibility VARCHAR(32) NOT NULL DEFAULT 'friends', + read_receipts TINYINT NOT NULL DEFAULT 1, + version INT NOT NULL DEFAULT 1, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (user_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS tinode.im_app_releases ( + id BIGINT NOT NULL AUTO_INCREMENT, + platform VARCHAR(32) NOT NULL, + channel VARCHAR(32) NOT NULL DEFAULT 'stable', + latest_version VARCHAR(64) NOT NULL, + min_supported_version VARCHAR(64) NOT NULL, + update_type VARCHAR(16) NOT NULL DEFAULT 'none', + release_notes TEXT NOT NULL, + store_url VARCHAR(512) NOT NULL DEFAULT '', + package_url VARCHAR(512) NOT NULL DEFAULT '', + checksum VARCHAR(128) NOT NULL DEFAULT '', + remind_after_seconds INT NOT NULL DEFAULT 0, + published_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + UNIQUE INDEX uk_im_app_releases_platform_channel (platform, channel) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS tinode.im_device_push_tokens ( + device_id VARCHAR(128) NOT NULL, + user_id BIGINT NOT NULL, + provider VARCHAR(32) NOT NULL, + token VARCHAR(512) NOT NULL, + app_version VARCHAR(64) NOT NULL DEFAULT '', + platform VARCHAR(32) NOT NULL DEFAULT '', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (device_id), + UNIQUE INDEX uk_im_device_push_tokens_provider_token (provider, token), + INDEX idx_im_device_push_tokens_user (user_id, updated_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +-- ============================================================ +-- Tenant database: chapter 11, risk challenge, abuse report, feedback. +-- ============================================================ + +CREATE TABLE IF NOT EXISTS tinode.im_risk_challenges ( + id BIGINT NOT NULL AUTO_INCREMENT, + challenge_id VARCHAR(64) NOT NULL, + user_id BIGINT NOT NULL, + action VARCHAR(64) NOT NULL, + level VARCHAR(16) NOT NULL, + notice_code VARCHAR(64) NOT NULL, + notice_params JSON, + state VARCHAR(16) NOT NULL DEFAULT 'pending', + expires_at DATETIME(3) NOT NULL, + decision VARCHAR(16) NOT NULL DEFAULT '', + action_token_id VARCHAR(64) NOT NULL DEFAULT '', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + UNIQUE INDEX uk_im_risk_challenges_challenge (challenge_id), + INDEX idx_im_risk_challenges_user_state (user_id, state, expires_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS tinode.im_abuse_reasons ( + id BIGINT NOT NULL AUTO_INCREMENT, + target_type VARCHAR(16) NOT NULL, + reason_code VARCHAR(64) NOT NULL, + label VARCHAR(128) NOT NULL, + requires_description TINYINT NOT NULL DEFAULT 0, + allows_evidence TINYINT NOT NULL DEFAULT 1, + sort_order INT NOT NULL DEFAULT 0, + enabled TINYINT NOT NULL DEFAULT 1, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + UNIQUE INDEX uk_im_abuse_reasons_type_code (target_type, reason_code), + INDEX idx_im_abuse_reasons_type_order (target_type, enabled, sort_order) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS tinode.im_abuse_reports ( + id BIGINT NOT NULL AUTO_INCREMENT, + report_id VARCHAR(64) NOT NULL, + reporter_id BIGINT NOT NULL, + target_type VARCHAR(16) NOT NULL, + target_user_id BIGINT NOT NULL DEFAULT 0, + target_topic VARCHAR(32) NOT NULL DEFAULT '', + target_seq INT NOT NULL DEFAULT 0, + reason_code VARCHAR(64) NOT NULL, + description TEXT NOT NULL, + evidence_file_ids JSON, + snapshot JSON, + idempotency_key VARCHAR(128) NOT NULL, + status VARCHAR(32) NOT NULL DEFAULT 'received', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + UNIQUE INDEX uk_im_abuse_reports_report (report_id), + UNIQUE INDEX uk_im_abuse_reports_idempotency (reporter_id, idempotency_key), + INDEX idx_im_abuse_reports_target (target_type, target_topic, target_seq, target_user_id), + INDEX idx_im_abuse_reports_reporter (reporter_id, id DESC) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS tinode.im_feedback_config ( + id BIGINT NOT NULL AUTO_INCREMENT, + categories JSON, + max_length INT NOT NULL DEFAULT 1000, + attachment_policy JSON, + allow_contact TINYINT NOT NULL DEFAULT 1, + diagnostics_notice TEXT NOT NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS tinode.im_feedback_submissions ( + id BIGINT NOT NULL AUTO_INCREMENT, + feedback_id VARCHAR(64) NOT NULL, + user_id BIGINT NOT NULL, + category VARCHAR(64) NOT NULL, + content TEXT NOT NULL, + contact VARCHAR(128) NOT NULL DEFAULT '', + attachment_file_ids JSON, + client_context JSON, + include_diagnostics TINYINT NOT NULL DEFAULT 0, + idempotency_key VARCHAR(128) NOT NULL, + status VARCHAR(32) NOT NULL DEFAULT 'received', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + UNIQUE INDEX uk_im_feedback_submissions_feedback (feedback_id), + UNIQUE INDEX uk_im_feedback_submissions_idempotency (user_id, idempotency_key), + INDEX idx_im_feedback_submissions_user (user_id, id DESC) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +-- ============================================================ +-- Seed data. +-- ============================================================ + +INSERT INTO hotgo.im_tenant(id, code, name, tenant_desc, db_name, db_dsn, state, created_by, updated_by) +VALUES (1, 'default', 'Default Tenant', NULL, 'tinode', '', 1, 0, 0) +ON DUPLICATE KEY UPDATE + name = VALUES(name), + db_name = VALUES(db_name), + state = VALUES(state), + updated_at = CURRENT_TIMESTAMP(3); + +INSERT INTO hotgo.im_tenant_config(tenant_id, max_users, max_groups, max_group_members, created_by, updated_by) +SELECT id, 0, 0, 0, 0, 0 FROM hotgo.im_tenant WHERE code = 'default' +ON DUPLICATE KEY UPDATE updated_at = CURRENT_TIMESTAMP(3); + +INSERT INTO tinode.im_workbench_apps(code, name, route, sort_order, unread_key, enabled, permission) +VALUES + ('organization', '组织架构', '/organization', 10, 'organization', 1, 'enabled'), + ('meetings', '会议通知', '/meetings', 20, 'meetings', 1, 'enabled'), + ('daily_reports', '日报', '/reports/daily', 30, 'daily_reports', 1, 'enabled'), + ('monthly_reports', '月报', '/reports/monthly', 40, 'monthly_reports', 1, 'enabled'), + ('executive_mailbox', '总经理信箱', '/executive-mailbox', 50, 'executive_mailbox', 1, 'enabled') +ON DUPLICATE KEY UPDATE + name = VALUES(name), + route = VALUES(route), + sort_order = VALUES(sort_order), + unread_key = VALUES(unread_key), + updated_at = CURRENT_TIMESTAMP(3); + +INSERT INTO tinode.im_mailbox_config(id, enabled, allow_anonymous, required_fields, max_length, attachment_policy, notice_title, notice_content) +VALUES (1, 1, 1, '["subject","content"]', 5000, '{"max_files":5,"purpose":"executive_mailbox"}', '总经理信箱', '请如实填写诉求,匿名提交将按企业政策保护展示身份。') +ON DUPLICATE KEY UPDATE updated_at = updated_at; + +INSERT INTO tinode.im_app_releases(platform, channel, latest_version, min_supported_version, update_type, release_notes, remind_after_seconds) +VALUES + ('ios', 'stable', '1.0.0', '1.0.0', 'none', '当前已是最新版本。', 0), + ('android', 'stable', '1.0.0', '1.0.0', 'none', '当前已是最新版本。', 0) +ON DUPLICATE KEY UPDATE updated_at = CURRENT_TIMESTAMP(3); + +INSERT INTO tinode.im_abuse_reasons(target_type, reason_code, label, requires_description, allows_evidence, sort_order) +VALUES + ('user', 'fraud', '诈骗欺诈', 0, 1, 10), + ('user', 'harassment', '骚扰辱骂', 0, 1, 20), + ('user', 'illegal', '违法违规', 1, 1, 30), + ('group', 'fraud', '诈骗欺诈', 0, 1, 10), + ('group', 'spam', '垃圾广告', 0, 1, 20), + ('group', 'illegal', '违法违规', 1, 1, 30), + ('message', 'fraud', '诈骗欺诈', 0, 1, 10), + ('message', 'spam', '垃圾广告', 0, 1, 20), + ('message', 'harassment', '骚扰辱骂', 0, 1, 30), + ('message', 'illegal', '违法违规', 1, 1, 40) +ON DUPLICATE KEY UPDATE + label = VALUES(label), + requires_description = VALUES(requires_description), + allows_evidence = VALUES(allows_evidence), + sort_order = VALUES(sort_order), + updated_at = CURRENT_TIMESTAMP(3); + +INSERT INTO tinode.im_feedback_config(id, categories, max_length, attachment_policy, allow_contact, diagnostics_notice) +VALUES (1, '["bug","suggestion","complaint","other"]', 1000, '{"max_files":5,"purpose":"feedback"}', 1, '仅在你同意时上传脱敏诊断信息。') +ON DUPLICATE KEY UPDATE updated_at = updated_at; + +-- Current MySQL adapter schema version expected by server/db/mysql/adapter.go. +UPDATE tinode.kvmeta SET `value` = 123 WHERE `key` = 'version'; diff --git a/docs/im/IM-服务端多租户改造方案.md b/docs/im/IM-服务端多租户改造方案.md new file mode 100644 index 0000000..eabd8ac --- /dev/null +++ b/docs/im/IM-服务端多租户改造方案.md @@ -0,0 +1,932 @@ +# IM 服务端多租户改造方案 + +> **方案变更说明,2026-08-13:当前多租户技术路线已调整为“控制库 + 每租户独立业务库”。本文原主体内容按“共享库共享表 + tenant_id”编写,只作为旧方案参考,不再作为后续开发准绳。新的落地方案见 [IM 独立库多租户改造方案](IM-独立库多租户改造方案.md)。** +> +> 新方案下,业务库中的 `users`、`auth`、`topics`、`messages`、`fileuploads` 和后续产品业务表不再增加 `tenant_id` 字段;`TenantID` 仍保留在 Session、Token、Hub、缓存、Push、文件授权、插件上下文和 Store 选库流程中,用于定位租户业务库和运行时隔离。 + +> 编写日期:2026-08-10 +> 适用项目:`im_server` 当前服务端源码 +> 需求背景:[IM 产品原型需求文档](IM-产品原型需求文档.md) 第 1.1 节“企业码登录”及第 9 章“企业工作台与办公协同” +> 关联文档:[IM 产品需求服务端改造分析报告](IM-产品需求服务端改造分析报告.md) + +> 实施状态:第一阶段租户识别已落地。MySQL Adapter schema 117 创建 `im_tenant`、`im_tenant_config` 和默认租户;首次 `hi` 使用企业码解析并固定 Session TenantID。核心业务表的 `tenant_id`、租户化认证和路由隔离尚未完成。 + +## 1. 方案目标 + +当前项目是单租户 Tinode 服务端。用户、认证记录、Topic、Subscription、Message、File、缓存、集群路由和 Push 都没有租户维度。本方案目标是在一套服务集群和一套 MySQL 数据库中安全承载多个企业租户,并满足以下约束: + +1. 一个企业的数据默认只能被本企业用户和管理员访问; +2. 同一手机号、用户名可以在不同企业分别注册; +3. 单聊、群聊、搜索、文件、Push、系统消息和后台任务均不可跨租户; +4. 租户身份由服务端在握手/认证阶段确定,客户端后续消息不能修改; +5. 现有 `usr...`、`grp...`、`p2p...` Topic 格式尽量保持兼容; +6. 多租户启用后,可以按租户配置功能、配额、认证方式、数据保留和安全策略。 + +## 2. 当前实现为什么不支持多租户 + +### 2.1 认证和 Session 没有租户 + +- `auth.Rec` 只有 `Uid`、`AuthLevel`、有效期、状态等字段,没有 `TenantID`; +- `Session` 只保存 `uid` 和 `authLvl`,登录成功后直接把认证记录写入 Session; +- `MsgClientHi`、`MsgClientLogin` 和 Token 中均没有租户; +- basic 登录名在数据库中全局唯一,手机号/邮箱凭证也全局唯一; +- 密码找回按 `method:value` 全局查找账号; +- root 用户可以通过 `Extra.AsUser` 代表任意用户操作,没有租户范围。 + +这意味着同一手机号无法在两个企业分别注册,也无法证明当前连接属于哪个企业。 + +### 2.2 数据库全部按全局键查询 + +改造前 MySQL schema 版本为 116;第一阶段建立租户主表和握手解析后为 117。除租户主数据表外,核心表仍没有 `tenant_id`: + +| 表 | 当前关键唯一约束 | 多租户问题 | +| --- | --- | --- | +| `users` | `id` | 无租户归属 | +| `auth` | `uname` 全局唯一 | 不同企业不能使用相同登录名 | +| `credentials` | `synthetic` 全局唯一 | 同一手机号不能加入多个企业 | +| `usertags` | 用户/Tag | 搜索可能返回其他企业用户 | +| `devices` | 设备 Hash 全局唯一 | 同一设备上的多企业账号会冲突 | +| `topics` | `name` 全局唯一 | 没有 Topic 租户边界 | +| `subscriptions` | Topic/User | 不能证明用户与 Topic 属于同一租户 | +| `messages` | Topic/SeqId | 查询只按 Topic,没有租户条件 | +| `dellog` | Topic/User | 删除记录没有租户条件 | +| `fileuploads` | File ID | 文件查询和清理没有租户范围 | +| `filemsglinks` | File/Message/Topic/User | 附件关联不能强制同租户 | + +Store/Adapter 的方法也只接收 UID 或 Topic,例如: + +```go +UserGet(uid) +AuthGetUniqueRecord(unique) +TopicGet(topic) +SubscriptionGet(topic, uid, keepDeleted) +MessageGetAll(topic, uid, opts) +FileGet(fileID) +``` + +只在 SQL 表加字段但不修改这些接口,后续开发很容易漏写 `WHERE tenant_id = ?`,仍然会发生越权。 + +### 2.3 内存、集群和系统 Topic 没有租户 + +- Hub 的 `topics` 使用裸 Topic 名作为 `sync.Map` Key; +- 集群一致性哈希只对裸 Topic 名计算; +- `ClusterSess` 和集群 RPC 没有租户字段; +- User Cache 仅以 UID 为 Key; +- Push Receipt、FCM Channel 只有 UID/Topic,没有租户; +- Hub 启动时只初始化一个字面值为 `sys` 的全局系统 Topic; +- 持久化缓存 Key 没有租户命名空间。 + +如果两个租户都使用 `sys` 等逻辑 Topic,同一个内存对象会被共享。即使普通 Topic 名当前由全局 UID 生成,也不能依赖“ID 不碰撞”代替租户授权。 + +### 2.4 文件接口只获得 UID + +`authFileRequest` 当前只返回 UID。文件下载查询只按 File ID,无法把文件访问限制在当前租户。对象存储 Location 也没有统一的租户目录规则。 + +## 3. 关键设计决策 + +### 3.1 目标模式 + +本方案采用: + +> **共享服务集群 + 共享 MySQL 数据库 + 共享表 + 每行 `tenant_id` 强隔离** + +该模式最符合“多企业共用一套平台”的目标,但也是改造面最大的模式。若实际只需要企业数据物理隔离,采用“一企一实例/一企一库”会更简单;它不属于本文的共享多租户实现。 + +### 3.2 用户模型:租户内账号 + +推荐同一个自然人在不同企业中对应不同的 Tinode User: + +```text +手机号 138****0000 + ├─ 租户 A -> user A1 -> 独立好友、群、消息和设置 + └─ 租户 B -> user B1 -> 独立好友、群、消息和设置 +``` + +这样可以最大程度复用当前 User/Topic/Subscription 模型,离职、停用、数据导出和删除也能按企业处理。 + +如果以后需要一个账号切换多个企业,可新增 `global_identities` 和 `tenant_memberships`,把全局身份映射到各租户的本地 User。不要在第一阶段直接把当前 User 改造成跨租户共享主体,否则所有 P2P、资料、设备、未读数和隐私规则都会变得更复杂。 + +### 3.3 ID 策略:保持全局唯一,额外校验租户 + +继续使用现有 Snowflake/加密 UID 生成器,User ID、File ID、普通 Group Topic 名保持全局唯一。外部协议继续使用现有 `usr...`、`grp...`、`p2p...` 格式,不把租户编码暴露在这些 ID 中。 + +所有数据库查询仍必须带 `tenant_id`,原因是: + +- 全局唯一只防止碰撞,不能证明调用者有权访问; +- 已知或泄露的其他租户 ID 仍然可能被直接请求; +- 使用复合租户条件可以让越权请求稳定返回不存在。 + +### 3.4 跨租户通信默认禁止 + +第一阶段规则: + +- 用户搜索只在当前租户; +- P2P 双方必须属于同一租户; +- Group Topic 及全部成员必须属于同一租户; +- 文件、收藏、搜索结果和系统通知不跨租户; +- Tenant Admin 只能管理本租户; +- Platform Admin 的跨租户操作只能走独立后台 API,禁止使用普通客户端协议随意 `AsUser`。 + +未来若需要跨企业协作,应作为“外部联系人/联邦通信”独立设计,使用明确授权、双边租户策略和审计,不能绕过本方案的同租户不变量。 + +## 4. 租户领域模型 + +### 4.1 TenantID 与租户上下文 + +新增强类型,不使用裸字符串在各层传递: + +```go +package types + +type TenantID int64 + +const ZeroTenantID TenantID = 0 + +type TenantContext struct { + TenantID TenantID + ActorUID Uid +} + +type TopicKey struct { + TenantID TenantID + Topic string +} + +type UserKey struct { + TenantID TenantID + UID Uid +} +``` + +`TenantID = 0` 仅允许出现在“尚未完成租户握手”的连接和平台级任务中。任何租户业务 Store 调用发现 `ZeroTenantID` 应立即返回错误,不能自动降级为全局查询。 + +`context.Context` 继续用于超时和取消;租户 ID 应作为显式的强类型参数或租户作用域对象,不能只隐藏在 `context.Value` 中。 + +### 4.2 租户表 + +建议新增: + +```sql +CREATE TABLE im_tenant ( + id BIGINT NOT NULL AUTO_INCREMENT, + code VARCHAR(64) NOT NULL, + name VARCHAR(128) NOT NULL, + tenant_desc VARCHAR(256), + state SMALLINT NOT NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + created_by BIGINT NOT NULL, + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + updated_by BIGINT NOT NULL, + PRIMARY KEY (id), + UNIQUE KEY tenants_code (code) +); +``` + +`created_by` 和 `updated_by` 保存操作人用户 ID;系统初始化默认租户时使用 `0` 表示系统操作。 + +当前阶段不在租户主表预留 `config_version` 和 `data_region`。真正建设租户配置中心或多地域路由时,再通过独立的 `tenant_settings` 表增加相应版本/地域字段,避免提前维护没有实际使用场景的列。 + +租户状态建议包括: + +| 状态 | 登录 | 读消息 | 发消息/写业务 | 管理操作 | +| --- | --- | --- | --- | --- | +| `provisioning` | 禁止 | 禁止 | 禁止 | 平台管理员可配置 | +| `active` | 允许 | 允许 | 允许 | 按角色允许 | +| `suspended` | 禁止新登录 | 可配置只读 | 禁止 | 平台管理员可恢复/导出 | +| `deleting` | 禁止 | 禁止 | 禁止 | 仅执行删除任务 | +| `deleted` | 禁止 | 禁止 | 禁止 | 只保留必要审计 | + +### 4.3 租户配置与配额 + +第一阶段只建立 `im_tenant_config`,保存用户数、群数和单群成员数上限。每个租户最多一条配置;三个上限字段为 `0` 时表示不限制。 + +```sql +CREATE TABLE im_tenant_config ( + id BIGINT NOT NULL AUTO_INCREMENT, + tenant_id BIGINT NOT NULL, + max_users BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '0 means unlimited', + max_groups BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '0 means unlimited', + max_group_members INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '0 means unlimited', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + created_by BIGINT NOT NULL, + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) + ON UPDATE CURRENT_TIMESTAMP(3), + updated_by BIGINT NOT NULL, + PRIMARY KEY (id), + UNIQUE KEY uk_im_tenant_config_tenant_id (tenant_id), + CONSTRAINT fk_im_tenant_config_tenant + FOREIGN KEY (tenant_id) REFERENCES im_tenant(id) + ON DELETE CASCADE +); +``` + +`created_by` 和 `updated_by` 保存操作人用户 ID。系统初始化时可以使用约定的系统操作人 ID;在平台管理员账号模型确定前,暂不为这两个字段建立用户外键。 + +认证、文件、消息频控、功能开关、保留期、合规策略、第三方服务配置及企业品牌等配置本阶段不建字段,待有明确业务需求后再增加。 + +## 5. 租户识别与认证流程 + +### 5.1 推荐流程 + +```mermaid +sequenceDiagram + participant A as App + participant S as Session + participant DB as MySQL + + A->>S: 建立 WebSocket + A->>S: hi(tenant_code) + S->>DB: 按 code 查询租户 + DB-->>S: tenant + S->>S: 校验 active 并固定 TenantID + S-->>A: hi success + 公开租户信息 + A->>S: login(scheme, secret) +``` + +第一阶段不引入 Tenant Resolve API 和 `tenant_ticket`。企业码不是密码,客户端可直接提交;服务端必须查询数据库并只接受 `active` 租户,客户端不能直接提交数据库 TenantID。 + +`tenant_ticket` 的价值在于把一次企业码解析结果变成服务端签名的短期凭证,防止客户端伪造内部 TenantID,并减少 WebSocket 节点的重复查询;它还可以携带目标集群、过期时间和随机数。当前是单集群且刚开始建设,直接查询更简单。以后出现多集群路由或租户解析查询成为性能瓶颈时,可以再增加 ticket,不影响 Session 固定 TenantID 的核心约束。 + +### 5.2 协议修改 + +在 `MsgClientHi` 增加: + +```go +Tenant string `json:"tenant,omitempty"` +``` + +该字段当前直接传企业码,不传数据库 TenantID。规则如下: + +1. 首次 `hi` 必须设置 Tenant; +2. Session 一旦绑定租户,后续 `hi/login/acc/pub/get/set/del/note` 都不能修改; +3. 注册、登录和密码找回前必须已绑定租户; +4. Tenant 缺失、为空或超过 64 个字符时拒绝握手,不做默认租户兼容; +5. 租户不存在时返回 404,租户不是 `active` 状态时返回 403; +6. 服务端返回的租户信息仅包含 `code` 和 `name`,不返回内部 ID、状态或密钥。 + +当前已实现 `MsgClientHi.Tenant`、MySQL 按企业码查询、Session TenantID 固定、租户公开信息响应,以及未绑定租户时拒绝后续命令。认证器和业务表尚未带 TenantID,因此当前代码只是租户识别基础,不能视为已经完成数据隔离。 + +后续 HTTP API 的 TenantID 必须从租户化签名 Token 中获得。禁止只信任 URL、Header 或请求 Body 中客户端自报的 `tenant_id`。 + +### 5.3 认证接口修改 + +当前认证器接口: + +```go +Authenticate(secret []byte, remoteAddr string) +``` + +建议改为: + +```go +type AuthContext struct { + TenantID types.TenantID + RemoteAddr string +} + +Authenticate(ctx AuthContext, secret []byte) +``` + +`auth.Rec` 增加: + +```go +TenantID types.TenantID `json:"tenant_id,omitempty"` +``` + +basic、REST、code、token 等认证器和所有 Mock 必须同步修改。认证器返回 TenantID 与 Session 不一致时,统一按认证失败处理,并写安全审计。 + +### 5.4 登录名和凭证的租户唯一性 + +数据库约束修改为: + +```text +auth: UNIQUE (tenant_id, uname) +auth: UNIQUE (tenant_id, userid, scheme) +credentials: UNIQUE (tenant_id, synthetic) +devices: UNIQUE (tenant_id, hash) +``` + +这样同一手机号可以分别加入多个企业。密码找回必须先有 TenantContext,再按 `(tenant_id, method, value)` 查找,不能全库搜索手机号后选择第一个用户。 + +### 5.5 Token v2 + +当前 HMAC Token 固定包含 UID、过期时间、认证级别、序列号和特性位,没有 TenantID。需要新增带版本的 Token v2: + +```text +[version][tenant_id][uid][expires][auth_level][serial][features][signature] +``` + +Token 规则: + +1. v2 Token 的签名覆盖 TenantID; +2. Token 认证返回 `TenantID + UID`; +3. Session 已绑定租户时必须与 Token 一致; +4. 不包含 TenantID 的 Token 一律拒绝; +5. 租户停用时应支持按租户 Token 版本或撤销时间拒绝已签发 Token,不能只能全平台提升 serial。 + +### 5.6 管理员模型 + +当前 `AuthLevel` 只有 NONE、ANON、AUTH、ROOT,不足以表达租户管理员。建议保留认证级别,同时增加业务角色: + +- `platform_admin`:平台级租户生命周期和运维; +- `tenant_owner`:本租户最高管理员; +- `tenant_admin`:组织、用户、群和安全管理; +- `security_auditor`:实名、举报、审计读取; +- 普通用户。 + +Tenant Admin 的权限必须绑定 TenantID。普通 WebSocket 的 `Extra.AsUser` 只能在同租户且有明确授权时使用;平台管理员跨租户代操作应走隔离的后台 API、二次验证和完整审计。 + +## 6. Session、消息和 Topic 改造 + +### 6.1 Session + +`Session`、`ClusterSess` 增加: + +```go +tenantID types.TenantID +``` + +需要建立以下不变量: + +```text +已认证 Session: +session.tenantID != 0 +session.uid != 0 +User(session.uid).tenant_id == session.tenantID +``` + +所有内部 `ClientComMessage` 和 `ServerComMessage` 应携带服务端写入的 TenantID,序列化给普通客户端时不必暴露。插件、集群和后台任务可以读取,但不得覆盖。 + +### 6.2 内部路由键 + +外部 Topic 名保持不变,内部统一使用: + +```go +func (k TopicKey) RoutingKey() string { + return strconv.FormatUint(uint64(k.TenantID), 10) + ":" + k.Topic +} +``` + +需要修改: + +- Hub `topicGet/topicPut/topicDel`; +- Session 的订阅 Map; +- Topic Proxy 和 Multiplex Session Key; +- 集群一致性哈希 `nodeForTopic/isRemoteTopic`; +- ClusterReq、ClusterRoute、ClusterResp; +- Topic 重哈希和代理失效; +- 调试端点中显示的 Topic 标识。 + +客户端仍看到原始 Topic 名,只有服务端内部使用 RoutingKey。不要直接把 `tenantID:` 前缀写入数据库 Topic 名或回传客户端,否则会破坏 Topic 类别解析、P2P 解析和现有客户端。 + +### 6.3 `sys`、`me`、`fnd`、`slf` + +- `me`、`fnd`、`slf` 虽由全局 UID 派生,仍必须使用 Session TenantID 加载; +- 全局字面值 `sys` 必须改为“租户作用域内的逻辑别名”; +- Hub 不再启动一个全局 `sys` Topic;应在租户首次使用或租户创建时初始化 `(tenant_id, sys)`; +- 系统消息生产者必须指定 TenantID,不能广播到全平台 `sys`; +- `fnd` 搜索只读取当前租户 Tags; +- `slf` 收藏不能引用其他租户的源消息或文件。 + +### 6.4 P2P + +P2P Topic 名仍由两个全局 UID 生成,但创建前必须执行: + +```text +initiator.tenant_id == invited.tenant_id == session.tenant_id +``` + +订阅、发送、读取、删除和通话时都继续带 TenantID 查询,避免通过已知 P2P 名访问其他企业。 + +### 6.5 群聊 + +创建 Group Topic 时由 Session 注入 TenantID。邀请、审批、管理员变更和群主转让必须验证: + +- Topic 属于当前租户; +- 操作者属于当前租户; +- 目标成员属于同一租户; +- 所有写入的 Subscription 使用同一 TenantID; +- Outbox 和系统通知也使用相同 TenantID。 + +不要接受客户端在 `set.desc`、Aux 或 Tags 中提交的 tenant 字段作为授权依据。 + +## 7. Store 与 Adapter 改造 + +### 7.1 不推荐只靠 `context.Value` + +租户条件应进入编译期可见的接口。推荐增加租户作用域 Store: + +```go +tenantStore := store.ForTenant(session.tenantID) + +user, err := tenantStore.Users.Get(uid) +topic, err := tenantStore.Topics.Get(topicName) +msgs, err := tenantStore.Messages.GetAll(topicName, uid, opts) +``` + +底层 Adapter 接口显式接收 TenantID: + +```go +UserGet(tenantID, uid) +AuthGetUniqueRecord(tenantID, unique) +TopicGet(tenantID, topic) +SubscriptionGet(tenantID, topic, uid, keepDeleted) +MessageGetAll(tenantID, topic, uid, opts) +FileGet(tenantID, fileID) +``` + +`store.ForTenant(ZeroTenantID)` 必须失败。平台级扫描和租户生命周期操作使用单独的 `PlatformStore` 接口,不能给业务代码一个可省略 TenantID 的全局 Store。 + +### 7.2 需要修改的 Store 接口范围 + +| 接口域 | 必须租户化的操作 | +| --- | --- | +| Users | Create/Get/GetAll/Update/Delete/State/Tags/Topics/Subs/Unread | +| Auth | Add/Get/Update/Delete/Unique lookup | +| Credentials | Upsert/Get/Confirm/Fail/Delete/Reset lookup | +| Topics | Create/Get/Share/Delete/Update/Owner/Find | +| Subscriptions | Create/Get/Update/Delete/按 User/Topic 查询 | +| Messages | Save/Get/Delete/GetDeleted/更新 Topic SeqId | +| Devices | Upsert/Get/Delete/Push Channel | +| Files | Start/Finish/Get/Link/DeleteUnused/Access check | +| Persistent Cache | Get/Upsert/Delete/Expire | + +这会修改 `server/store/store.go`、`server/db/adapter.go`、MySQL Adapter、Mock Store 以及大量调用点。不要为了减少改动只改少数查询;任何保留的无租户业务接口都会成为后续越权入口。 + +### 7.3 数据库支持范围 + +当前仓库还有 PostgreSQL、MongoDB、RethinkDB Adapter。多租户改造会使公共 Adapter 接口整体变化。 + +推荐产品版第一阶段明确只支持 MySQL: + +- MySQL 完成多租户 schema 和全部集成测试; +- 其他 Adapter 编译时可使用明确的 `ErrUnsupportedTenant` 或从产品构建中排除; +- 不能让未实现多租户过滤的 Adapter 在配置错误时继续启动; +- 如果必须继续支持四种数据库,应将工作量视为四套独立实现和隔离验收。 + +## 8. MySQL Schema 改造 + +### 8.1 统一规则 + +1. 所有租户业务表增加 `tenant_id BIGINT NOT NULL`; +2. 唯一索引和高频查询索引以 `tenant_id` 作为第一列; +3. 子表使用 `(tenant_id, parent_id)` 复合外键,防止跨租户关联; +4. `kvmeta` 中数据库 schema 版本属于平台级元数据,不加 TenantID; +5. 持久化业务缓存使用 TenantID 列或强制租户 Key 前缀; +6. 所有删除、更新、聚合和后台批处理同样带 TenantID; +7. MySQL 没有可直接依赖的应用级 RLS,隔离责任主要在 Adapter 和复合约束。 + +### 8.2 核心表与索引 + +| 表 | 新增列 | 主要索引调整 | +| --- | --- | --- | +| `users` | `tenant_id` | `UNIQUE(tenant_id,id)`;状态/最近登录索引前加 tenant | +| `usertags` | `tenant_id` | `INDEX(tenant_id,tag)`;`UNIQUE(tenant_id,userid,tag)` | +| `devices` | `tenant_id` | `UNIQUE(tenant_id,hash)`;复合外键到 users | +| `auth` | `tenant_id` | `UNIQUE(tenant_id,uname)`;`UNIQUE(tenant_id,userid,scheme)` | +| `topics` | `tenant_id` | `UNIQUE(tenant_id,name)`;Owner/State 索引前加 tenant | +| `topictags` | `tenant_id` | `INDEX(tenant_id,tag)`;`UNIQUE(tenant_id,topic,tag)` | +| `subscriptions` | `tenant_id` | `UNIQUE(tenant_id,topic,userid)`;User/Topic 查询前加 tenant | +| `messages` | `tenant_id` | `UNIQUE(tenant_id,topic,seqid)`;历史查询索引前加 tenant | +| `dellog` | `tenant_id` | Topic/User/DelId 索引前加 tenant | +| `credentials` | `tenant_id` | `UNIQUE(tenant_id,synthetic)`;User/Method 索引前加 tenant | +| `fileuploads` | `tenant_id` | `INDEX(tenant_id,status,updatedat)`;复合用户归属 | +| `filemsglinks` | `tenant_id` | File/Message/Topic/User 索引及复合外键全部带 tenant | + +Topic 名当前通常全局唯一,但仍把数据库唯一约束改为 `(tenant_id, name)`,这样各租户可以拥有自己的逻辑 `sys`,也让 schema 明确表达隔离边界。 + +### 8.3 业务表 + +后续新增的所有 `im_*` 表,包括好友、群审批、组织、会议、日报、举报、Outbox、审计和消息索引,必须从第一版就包含 TenantID。禁止先以全局表上线再补租户。 + +推荐所有 Repository 方法使用如下形态: + +```text +Get(tenantID, id) +List(tenantID, filter, cursor) +Create(tenantID, actorID, data) +Update(tenantID, id, expectedVersion, data) +Delete(tenantID, id) +``` + +### 8.4 SQL 示例 + +认证查询: + +```sql +SELECT userid, secret, expires, authlvl +FROM auth +WHERE tenant_id = ? AND uname = ?; +``` + +Topic 查询: + +```sql +SELECT ... +FROM topics +WHERE tenant_id = ? AND name = ? AND state <> ?; +``` + +消息查询: + +```sql +SELECT ... +FROM messages +WHERE tenant_id = ? AND topic = ? AND seqid < ? +ORDER BY seqid DESC +LIMIT ?; +``` + +所有 `UPDATE` 和 `DELETE` 必须同时带业务主键和 `tenant_id`。如果受影响行数为 0,统一返回 NotFound/Denied,不能再用无租户查询判断对象是否属于其他企业,以免泄露对象存在性。 + +## 9. 缓存与集群改造 + +### 9.1 Hub Topic Cache + +当前 Hub 使用裸 Topic 名作为 Key,应改为 `TopicKey.RoutingKey()`。Topic 对象自身增加 TenantID,初始化后不可修改。 + +加载 Topic 时同时检查: + +```text +requested tenant == loaded topic tenant == database row tenant +``` + +如果不一致,记录安全告警并拒绝把 Topic 放入缓存。 + +### 9.2 User Cache + +现有 UID 在全平台全局唯一,理论上可以继续只按 UID 缓存。但为避免导入数据、测试固定 UID 或代码误用造成污染,建议改为 `UserKey{TenantID, UID}`。 + +`UserCacheReq`、未读数 IO、Pending Push、EvictUser、集群 UserCacheUpdate 全部增加 TenantID。设备注销或账号停用只能影响指定租户的 User。 + +### 9.3 Session Store + +Session ID 继续全局随机即可,但以下操作必须带租户断言: + +- 通过 SID 完成文件认证; +- `EvictUser`; +- Cluster Proxy 恢复 Session; +- root/管理员代操作; +- 调试页面查看 Session。 + +建议新增 `EvictUser(tenantID, uid, skipSID)`,防止固定/导入 UID 冲突时误踢其他企业用户。 + +### 9.4 集群路由 + +一致性哈希由: + +```text +hash(topic) +``` + +改为: + +```text +hash(tenant_id + ":" + topic) +``` + +`ClusterSess`、`ClusterReq`、`ClusterRoute`、`ClusterResp` 和 User Cache RPC 增加 TenantID。接收端验证: + +- RPC 声明租户与消息内部租户一致; +- Session 租户与请求租户一致; +- RoutingKey 重新计算结果与目标节点一致; +- Plugin 返回的替换消息不能修改租户。 + +集群节点加入时必须校验协议版本,不理解 Tenant 字段的节点应拒绝加入集群。 + +### 9.5 持久化缓存 + +缓存 Key 统一通过函数生成: + +```go +TenantCacheKey(tenantID, namespace, key) +``` + +禁止业务代码自行字符串拼接。租户配置变化时只失效该租户 Key。平台级 schema/version Key 与租户业务 Cache 使用不同接口。 + +## 10. 文件与对象存储改造 + +### 10.1 HTTP 认证结果 + +`authFileRequest` 从只返回 UID 改为: + +```go +type RequestPrincipal struct { + TenantID types.TenantID + UID types.Uid + Roles []string +} +``` + +Token 认证和 SID 认证都必须得到相同 Principal。API Key 只表示合法客户端应用,不能替代 Tenant 身份和资源授权。 + +### 10.2 文件访问 + +新增: + +```go +FileCanAccess(tenantID, uid, fileID, action) +``` + +访问流程: + +1. `FileGet(tenantID, fileID)`; +2. 上传未完成时只允许同租户上传者访问; +3. 已关联消息时,Subscription 必须为同租户且有 Topic Read 权限; +4. 群文件删除检查同租户上传者/管理员/群主; +5. 转发时源文件、源消息和目标 Topic 全部属于同租户; +6. 生成短时签名 URL,签名上下文包含 TenantID/FileID/过期时间; +7. 审计日志记录 TenantID、UID、FileID 和结果。 + +### 10.3 存储路径 + +新文件统一使用: + +```text +tenants/{tenant_id}/yyyy/mm/{file_id} +``` + +Location 不直接返回客户端。垃圾回收任务按 TenantID 分批并设置公平配额,避免大租户占满清理队列。 + +## 11. 搜索、Tags 与组织数据 + +### 11.1 用户和 Topic 搜索 + +`Find`、`FindOne`、Tag 更新和精确账号搜索都必须带 TenantID。相同手机号/用户名在不同租户生成相同 Tag 没有问题,因为索引和唯一约束均以 TenantID 为前缀。 + +跨租户查询统一返回空结果或通用 NotFound,不能返回“该用户属于其他企业”。 + +### 11.2 消息搜索 + +消息索引记录必须包含 TenantID,搜索引擎索引名或文档路由也包含 TenantID: + +```text +tenant_id + topic + seq_id +``` + +查询至少经过两层过滤: + +1. 搜索请求强制加入 TenantID; +2. 返回结果按当前用户的同租户 Topic Read ACL 二次校验。 + +如果使用 OpenSearch/Elasticsearch,可以共享索引并按 TenantID 路由,也可以按大租户独立索引。无论哪种方式,TenantID 必须来自 Token,而不是客户端 Query 参数。 + +### 11.3 组织架构 + +`im_org_units`、`im_org_members` 等第 9 章业务表以 TenantID 为根。部门树的 `parent_id`、负责人、成员和数据权限都必须使用复合租户约束,禁止把其他租户 User 设置为部门负责人。 + +## 12. Push、插件和外部服务 + +### 12.1 Push + +`push.Receipt`、`ChannelReq` 和 `Payload` 内部增加 TenantID。要求: + +- 设备查询使用 `(tenant_id, uid)`; +- FCM Topic/Channel 名加入不可碰撞的租户前缀; +- 租户级 Push 模板、应用包和凭证由服务端配置选择; +- Push Handler 日志不可输出其他租户的设备 Token; +- 未读数 Cache 使用 Tenant User Key; +- 锁屏正文按租户安全策略生成。 + +如果不同租户使用不同品牌 App,Push Handler 需要根据 TenantID 选择 FCM/APNS 凭证,不能继续假定一套全局凭证。 + +### 12.2 Plugin Firehose + +Plugin 请求增加 TenantID、租户策略版本和不可伪造的上下文。插件返回 `REPLACE` 时服务端必须保留原 TenantID,禁止插件把消息换到其他租户/Topic。 + +插件配置分两类: + +- 平台级统一审核服务; +- 租户级专属审核服务。 + +无论哪种,调用超时、熔断、失败开放/关闭策略都按租户配置,审计和指标带 TenantID。 + +### 12.3 REST 认证、短信、TURN 和对象存储 + +外部请求必须携带服务端签名的租户上下文,并对回调结果校验 TenantID。租户自带密钥时,通过密钥管理系统按 TenantID 选择,不把 Secret 缓存在普通 Topic/User JSON 中。 + +## 13. API Key、限流和配额 + +当前 `X-Tinode-APIKey` 是应用级准入凭证。多租户后建议保持以下职责: + +- API Key:识别客户端应用、平台、版本和是否允许连接; +- Tenant Ticket/Token:确定租户; +- User Token:确定租户内用户; +- Role/Policy:确定具体操作权限。 + +不要仅通过 API Key 推导 Tenant,因为同一个 App 可能登录多个企业。若某企业使用专属 App,可额外把 API Key 限定到一个 Tenant,但仍需验证 Token Tenant 一致。 + +限流 Key 至少包含: + +```text +tenant + endpoint/action +tenant + uid + action +tenant + ip + action +``` + +配额计数包括用户、Topic、消息速率、文件大小、总存储、会议/报告等。超限返回稳定业务错误并记录租户审计,不能让一个大租户耗尽全平台队列。 + +## 14. 日志、指标与审计 + +### 14.1 日志和 Trace + +认证后所有请求日志增加: + +```text +request_id, tenant_id, uid, sid, topic, action, result +``` + +手机号、证件号、Token、企业码密钥和文件签名不写日志。TenantID 可写内部数值或不可逆短标识。 + +### 14.2 指标 + +租户 ID 直接作为 Prometheus Label 可能造成高基数。建议: + +- 平台指标默认不带 Tenant Label; +- 关键配额和 SLA 写入租户用量表或专用统计系统; +- 仅对白名单大租户暴露租户级指标; +- 错误日志和 Trace 保留 TenantID 供定位。 + +### 14.3 审计 + +以下操作必须记录 TenantID: + +- 租户创建、停用、恢复和删除; +- Tenant Admin 授权和代操作; +- 用户停用、离职和数据导出; +- 群主/管理员变更; +- 实名审核、举报处置、匿名信箱读取; +- 文件下载、批量导出和搜索敏感内容; +- 平台管理员跨租户操作。 + +审计表只允许追加,普通租户管理员不能修改或删除。 + +## 15. 测试方案 + +### 15.1 单元和接口测试 + +- TenantContext 不可为空; +- Session 只能绑定一次 Tenant; +- Token Tenant 与 Session Tenant 不一致时登录失败; +- Store/Adapter 不提供无 Tenant 的业务查询; +- TopicKey、UserKey、CacheKey 和 Push Channel 正确命名空间化; +- Plugin 不能替换 Tenant; +- Platform Admin 与 Tenant Admin 权限边界正确。 + +### 15.2 MySQL 集成测试 + +创建 Tenant A、Tenant B,并使用相同用户名、手机号、群显示名和逻辑 `sys`。至少覆盖: + +| 场景 | 预期 | +| --- | --- | +| 两租户注册相同用户名/手机号 | 均成功,各自登录到不同 UID | +| A 使用 B 的账号密码登录 | 统一认证失败 | +| A 已知 B 的 UID 执行 Get/Set/Delete | NotFound/Denied,不返回存在性 | +| A 已知 B 的 Group Topic 订阅或发布 | 拒绝且不加载到 A 的 Hub Cache | +| A 邀请 B 用户进入 A 群 | 拒绝,无 Subscription 写入 | +| A 已知 B 的 Message Topic/SeqId | 不能读取、搜索、删除或收藏 | +| A 已知 B 的 File ID/URL | 不能下载、关联或转发 | +| A/B 同时使用 `sys` | 只收到本租户系统消息 | +| A 停用用户 | 不影响相同设备上的 B 用户 | +| A Tenant Admin `AsUser` B 用户 | 拒绝并审计 | +| 同一设备在 A/B 注册 Push | 设备记录和 Push 不串租户 | +| A 大量任务积压 | B 的通知和文件 GC 不被长期饿死 | + +### 15.3 集群测试 + +- 相同 Topic 别名在不同 Tenant 生成不同 RoutingKey; +- Proxy/Master 传输后 Tenant 不丢失; +- 节点重启、重哈希和故障转移不串 Topic; +- 混入旧节点时启动检查直接失败; +- User Cache、未读数和 Push 在远程节点保持租户一致。 + +### 15.4 安全测试 + +- 篡改 `hi.tenant`、Token Tenant、HTTP Header 和 Body Tenant; +- 枚举其他租户 UID、Topic、File ID、企业码; +- SQL 查询遗漏 Tenant 的代码扫描与集成测试; +- root/OBO 越权; +- 搜索索引、缓存、导出、日志和审计的跨租户泄露; +- 对象存储签名 URL 重放和跨租户替换; +- Plugin/回调伪造 Tenant。 + +## 16. 代码修改清单 + +| 文件/目录 | 主要修改 | +| --- | --- | +| `server/store/types/tenant.go`、`types.go` | TenantID、TenantContext、TopicKey、UserKey;核心实体 TenantID | +| `server/auth/auth.go` | AuthContext、`auth.Rec.TenantID`、认证接口 | +| `server/auth/basic` | 租户内登录名查询和唯一性 | +| `server/auth/token` | Token v2、TenantID 和租户撤销版本 | +| `server/auth/rest`、`server/auth/code` | 传递并校验租户上下文 | +| `server/datamodel.go` | `MsgClientHi.Tenant`;内部消息 TenantID | +| `server/session.go` | Tenant 握手、固定 Session Tenant、登录/注册/发布断言 | +| `server/user.go` | User Cache Key、租户级未读/踢出/状态更新 | +| `server/sessionstore.go` | `EvictUser(tenant, uid)`、SID 认证租户断言 | +| `server/hub.go` | TopicKey 缓存、租户 `sys`、租户路由 | +| `server/init_topic.go` | Topic Tenant 初始化和同租户检查 | +| `server/topic.go` | Topic/成员/消息 Tenant 不变量 | +| `server/cluster.go`、`server/topic_proxy.go` | Cluster RPC Tenant、复合 RoutingKey、协议版本检查 | +| `server/store/store.go` | `ForTenant` 作用域和所有 Mapper 租户化 | +| `server/db/adapter.go` | Adapter 全接口 Tenant 参数 | +| `server/db/mysql/adapter.go` | 所有 SQL Tenant 条件和复合约束 | +| `server/db/mysql/schema.sql` | im_tenant、tenant_id、索引、外键和新 schema 版本 | +| `server/hdl_files.go` | RequestPrincipal、FileCanAccess、Tenant 文件路径 | +| `server/push.go`、`server/push` | Receipt/Channel/设备查询 Tenant 化 | +| `server/plugins.go`、`pbx` | 插件协议 Tenant 上下文和不可变校验 | +| `server/main.go` | 租户配置缓存和启动自检 | +| `server/tinode.conf` | 租户服务和配额配置 | +| `server/store/mock_store` | 重新生成所有租户化 Mock | +| `server/*_test.go`、`server/db/mysql/tests` | 双租户正向、负向和隔离测试 | + +## 17. 实施阶段与交付物 + +### 阶段 0:决策和基线 + +- 确认共享表模式; +- 确认租户内独立 User; +- 确认跨租户通信首期禁止; +- 确认产品版数据库支持范围; +- 冻结 TenantID、TopicKey 和认证协议设计。 + +交付物:架构决策记录、数据字典和接口清单。 + +### 阶段 1:Schema 与租户基础服务 + +- `im_tenant`、`im_tenant_config`; +- 核心表 `tenant_id BIGINT NOT NULL`; +- 默认租户; +- `hi.tenant` 企业码解析和 Session 绑定; +- 租户配置缓存。 + +交付物:全新数据库初始化 Schema、租户基础服务和 Schema 约束测试。 + +### 阶段 2:认证和 Store + +- Session Tenant; +- AuthContext 和 Token v2; +- `store.ForTenant`; +- MySQL 全查询租户化; +- Mock 和单元测试更新。 + +交付物:租户内注册、登录和 Store 隔离能力。 + +### 阶段 3:实时路由与集群 + +- Hub TopicKey; +- 租户 `sys/me/fnd/slf`; +- Cluster RPC 和一致性哈希; +- User Cache 和 Session Evict; +- Plugin Tenant。 + +交付物:双租户集群隔离测试通过,不兼容节点无法加入集群。 + +### 阶段 4:文件、Push、搜索和后台任务 + +- 文件 Principal、ACL 和租户路径; +- Push Receipt/设备/凭证租户化; +- 搜索索引和二次 ACL; +- Outbox、审计、GC、配额和限流租户化。 + +交付物:资源和异步链路跨租户负向测试通过。 + +### 阶段 5:上线验收 + +- 内部第二租户; +- 安全测试、压测和故障演练; +- 上线检查和真实第二企业启用。 + +交付物:测试报告、应急预案和多租户上线审批。 + +## 18. 主要风险 + +| 风险 | 影响 | 控制措施 | +| --- | --- | --- | +| 某个 SQL 漏 Tenant 条件 | 严重数据泄露 | 显式 Store 接口、复合约束、双租户负向测试和代码检查 | +| 只改数据库,缓存/集群未改 | 实时消息串租户 | TopicKey/UserKey、Cluster Tenant 不变量和集群测试 | +| 全局 `sys` 未拆分 | 系统通知泄露 | 租户逻辑 sys、Hub 复合 Key、通知生产者强制 Tenant | +| 文件 URL 泄露 | 跨租户下载 | FileGet Tenant 条件、Topic ACL、短时签名和审计 | +| root/OBO 权限过大 | 管理员跨租户操作 | Platform/Tenant Admin 分离、后台 API、二次验证和审计 | +| 混跑旧集群节点 | Tenant 字段丢失 | 集群协议版本检查,不兼容节点拒绝加入 | +| 指标 Tenant Label 高基数 | 监控系统压力 | 指标聚合,Tenant 主要进入日志/Trace/用量表 | + +## 19. 上线前必须满足的条件 + +1. 业务代码不存在无 Tenant 的 User/Topic/Message/File 查询接口; +2. 核心表和全部业务表 TenantID 为 NOT NULL; +3. Auth、Credential、Device、Topic 和 Subscription 唯一约束已租户化; +4. Token v2、Session Tenant 和 HTTP Principal 全链路一致; +5. Hub、User Cache、Cluster、Push、Plugin 和 Persistent Cache 均携带 Tenant; +6. `sys` 已按租户隔离; +7. 文件下载和搜索完成租户过滤及 ACL 二次校验; +8. 双租户自动化测试、集群测试和安全测试全部通过; +9. 所有运行节点和后台任务均为租户感知版本; +10. 内部第二租户稳定运行后,才允许创建真实第二企业。 + +## 20. 最终建议 + +多租户改造必须被视为服务端基础架构改造,而不是“给 users/topics/messages 加一列”。当前代码的认证、Store 接口、Hub Cache、Cluster、系统 Topic、文件和 Push 都使用全局 UID/Topic 语义,任何一处遗漏都可能造成跨企业数据泄露。 + +建议采用租户内独立 User、全局唯一 UID、共享表 `tenant_id` 强隔离的方案。全新数据库从创建时就使用最终多租户 Schema,并在 Store、认证、Token、路由、缓存、文件和 Push 全链路强制 TenantID;所有负向测试通过后,再开启真实租户。 + +工程上最重要的控制手段是:让 TenantID 进入类型和方法签名,使遗漏租户条件尽量在编译或测试阶段暴露;平台级全局查询使用隔离接口,普通业务代码永远只能获得 `store.ForTenant(...)`。这样才能让后续好友、群治理、企业工作台、搜索和安全业务从一开始就在正确的隔离边界内建设。 diff --git a/docs/im/IM-独立库多租户改造方案.md b/docs/im/IM-独立库多租户改造方案.md new file mode 100644 index 0000000..e4f1ebc --- /dev/null +++ b/docs/im/IM-独立库多租户改造方案.md @@ -0,0 +1,313 @@ +# IM 独立库多租户改造方案 + +> 编写日期:2026-08-13 +> 适用项目:`im_server` +> 方案状态:替代原“共享库共享表 + tenant_id”方案,后续多租户开发以本文为准。 + +## 1. 方案结论 + +多租户调整为: + +> 共享服务集群 + 控制库保存租户元数据 + 每个租户独立业务库 + +业务库中的 `users`、`auth`、`credentials`、`topics`、`subscriptions`、`messages`、`fileuploads` 以及后续产品业务表都不再增加 `tenant_id` 字段。租户隔离通过运行时选库实现:客户端先用企业码解析租户,服务端得到 `tenant_id` 后,根据控制库中的 `db_name` 或 `db_dsn` 选择对应租户业务库。 + +`TenantID` 仍然要保留,但它的职责从“业务表行级隔离字段”调整为“运行时租户上下文”: + +1. 登录前企业码解析; +2. Token 签名和校验; +3. Session 当前租户固定; +4. Hub、缓存、集群路由、Push、文件路径和插件上下文隔离; +5. 选择当前请求使用哪个租户业务库; +6. 后台平台侧管理租户、配置配额、停启租户。 + +## 2. 为什么独立库仍需要 TenantID + +独立库解决的是数据库物理隔离,不代表服务端运行时没有租户。服务进程仍然同时服务多个企业,如果内存 Topic、用户缓存、系统 Topic、Push、文件下载授权、Token 校验不带租户上下文,仍可能出现: + +1. 两个租户都使用逻辑 Topic `sys` 时共用同一个内存 Topic; +2. HTTP 文件下载只拿到 `file_id`,无法判断该去哪个租户库查授权; +3. Token 只包含 UID 时,无法知道该 UID 属于哪个租户库; +4. 定时任务、插件调用、Push 回调无法定位租户库; +5. 后台接口可能把平台租户 ID 和业务租户数据混在一起。 + +所以新方案的边界是: + +```text +TenantID 负责运行时路由和授权上下文 +tenant_id 字段不进入租户业务库表结构 +``` + +## 3. 数据库拆分 + +### 3.1 控制库 + +控制库可以使用现有 `hotgo`,只保存平台级租户元数据和配额配置。控制库表需要保留 `tenant_id`,因为这些表本身是在管理所有租户;这里的 `tenant_id` 不是业务库行级隔离字段。 + +```sql +CREATE TABLE im_tenant ( + id BIGINT NOT NULL AUTO_INCREMENT, + code VARCHAR(64) NOT NULL, + name VARCHAR(128) NOT NULL, + tenant_desc VARCHAR(256), + db_name VARCHAR(128) NOT NULL DEFAULT '', + db_dsn VARCHAR(1024) NOT NULL DEFAULT '', + state SMALLINT NOT NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + created_by BIGINT NOT NULL, + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + updated_by BIGINT NOT NULL, + PRIMARY KEY (id), + UNIQUE KEY uk_im_tenant_code (code), + KEY idx_im_tenant_db_name (db_name) +); + +CREATE TABLE im_tenant_config ( + id BIGINT NOT NULL AUTO_INCREMENT, + tenant_id BIGINT NOT NULL, + max_users BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '0 means unlimited', + max_groups BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '0 means unlimited', + max_group_members INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '0 means unlimited', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + created_by BIGINT NOT NULL, + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + updated_by BIGINT NOT NULL, + PRIMARY KEY (id), + UNIQUE KEY uk_im_tenant_config_tenant_id (tenant_id), + CONSTRAINT fk_im_tenant_config_tenant + FOREIGN KEY (tenant_id) REFERENCES im_tenant(id) + ON DELETE CASCADE +); +``` + +`db_name` 适合所有租户共用同一个 MySQL 实例时使用,例如 `im_tenant_1001`。`db_dsn` 适合某些租户需要独立 MySQL 实例时使用。优先级建议为:`db_dsn` 非空时使用完整 DSN,否则使用主 DSN + `db_name`。 + +如果要求 `db_name` 不能重复,建议在租户创建服务里校验“非空 db_name 唯一”。MySQL 普通唯一索引不能表达“只对非空字符串唯一”,直接给 `db_name` 建唯一索引会导致多个使用独立 `db_dsn` 的租户因为空 `db_name` 冲突。 + +### 3.2 租户业务库 + +每个租户一个业务库,例如: + +```text +hotgo + ├─ im_tenant + └─ im_tenant_config + +im_tenant_1001 + ├─ users + ├─ auth + ├─ credentials + ├─ topics + ├─ subscriptions + ├─ messages + ├─ fileuploads + └─ 后续产品业务表 +``` + +租户业务库表结构恢复为租户内唯一约束,不加 `tenant_id`。同一个手机号、用户名可以在两个租户库中各自存在,不会冲突。 + +## 4. 当前代码如何调整 + +当前代码已经有一部分独立库路由雏形,可以继续利用: + +1. `server/store/store.go` 已经预留 `TenantRoutedAdapter` 分支; +2. `server/db/adapter.go` 已定义 `TenantAdapterFor(tenantID)`; +3. `server/db/mysql/adapter.go` 已支持 `tenant_registry_db`、`tenant_db_routing`、`db_name`、`db_dsn`; +4. Token、Session、Hub TopicKey、UserKey、插件上下文已经带 `TenantID`。 + +需要调整的重点是: + +| 模块 | 旧共享表方案 | 新独立库方案 | +| --- | --- | --- | +| 控制库 | 与业务表混在一个库 | 单独保存 `im_tenant`、`im_tenant_config` | +| 业务库 schema | 所有表加 `tenant_id` | 所有业务表不加 `tenant_id` | +| MySQL Adapter | SQL 使用 `WHERE tenant_id=?` | 先根据 TenantID 选库,再执行租户库内 SQL | +| Store 接口 | TenantID 作为 SQL 条件 | TenantID 作为选库参数 | +| 唯一索引 | `(tenant_id, business_key)` | 租户库内 `business_key` 唯一 | +| 外键 | 复合外键带 `tenant_id` | 普通外键 | +| 数据迁移 | 共享库内补列回填 | 新租户直接建独立库,无历史迁移 | + +## 5. 代码落地步骤 + +### 5.1 拆分 schema + +把 MySQL schema 拆成两个层次: + +1. `registry_schema.sql`:只创建控制库表 `im_tenant`、`im_tenant_config`; +2. `tenant_schema.sql`:创建 Tinode 核心表和产品业务表,不包含 `tenant_id`; +3. `schema.sql` 可以作为文档索引,说明不能直接混用两套 schema。 + +`CreateDb(reset)` 也要拆分: + +1. 初始化控制库; +2. 初始化默认租户业务库; +3. 根据 `im_tenant.db_name/db_dsn` 初始化指定租户库; +4. 禁止在同一个事务中同时创建控制库和多个租户业务库,避免失败后状态难恢复。 + +### 5.2 改 MySQL Adapter 路由 + +保留 `TenantAdapterFor(tenantID)`,但租户 Adapter 内执行的 SQL 应该是无 `tenant_id` 版本。 + +推荐结构: + +```text +mysql.adapter + ├─ registryDB: 连接控制库 hotgo + ├─ defaultDB: 默认租户库 + ├─ tenantAdapters: map[TenantID]*adapter + └─ TenantAdapterFor(tenantID): 从 registryDB 读取 db_name/db_dsn 并返回租户库 adapter +``` + +租户业务方法仍保留 `TenantUserGet(tenantID, uid)` 这类签名,但实现改为“先选库,后执行租户库内原 SQL”。实际写代码时要避免递归,可以把“无租户 SQL”的方法放在租户库 adapter 上,把“路由包装方法”放在根 adapter 上。 + +### 5.3 删除业务表 tenant_id 依赖 + +需要回退或重写这些位置: + +1. `server/db/mysql/schema.sql` 中核心业务表的 `tenant_id` 字段; +2. `server/db/mysql/tenant_adapter.go` 中所有 `WHERE tenant_id=?`、`INSERT tenant_id`、复合 JOIN; +3. `server/db/mysql/tests/mysql_test.go` 中依赖共享表隔离的测试; +4. `docs/sql/mysql-multitenant-tenant-id-review.sql` 这份共享表迁移脚本应标记为废弃; +5. 后续产品业务表设计也不再带 `tenant_id`。 + +不建议删除 Store、Session、Token、Hub、Push 和插件里的 `TenantID`,这些属于运行时隔离边界。 + +### 5.4 租户创建流程 + +后台创建租户时建议按以下顺序: + +1. 在控制库 `im_tenant` 创建记录,状态为 `provisioning`; +2. 生成业务库名,例如 `im_tenant_{id}`; +3. 创建业务库并执行 `tenant_schema.sql`; +4. 写入默认系统数据,例如 `kvmeta.version`、系统账号、`sys` Topic; +5. 回写 `im_tenant.db_name`,状态改为 `active`; +6. 创建失败时租户保持不可登录状态,并记录错误供后台重试。 + +第一阶段可以不做自动建库接口,先由运维执行建库 SQL,再在控制库写入 `im_tenant`。 + +### 5.5 认证与登录 + +登录入口仍然是: + +1. 客户端携带 `X-Tinode-APIKey` 请求准入; +2. `hi` 或登录前 HTTP 接口提交企业码; +3. 服务端在控制库查 `im_tenant.code`; +4. 租户状态必须是 `active`; +5. Session 固定 `TenantID`; +6. 登录认证根据 `TenantID` 选择租户业务库,然后查该库内 `auth`、`credentials`、`users`; +7. Token 签名内容继续包含 `TenantID`,HTTP 后续请求可从 Token 反推出该使用哪个租户库。 + +客户端不能通过 URL、Header、Body 直接指定业务库名或 `tenant_id`。 + +## 6. 产品业务表如何设计 + +后续根据《IM 产品原型需求文档》补充的业务表也按“租户库内表”设计,不加 `tenant_id`。所有新业务表仍统一要求: + +```sql +created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), +updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) +``` + +例如好友申请: + +```sql +CREATE TABLE im_friend_requests ( + id BIGINT NOT NULL AUTO_INCREMENT, + request_id CHAR(26) NOT NULL, + applicant_id BIGINT NOT NULL, + target_id BIGINT NOT NULL, + message VARCHAR(256), + status SMALLINT NOT NULL, + handled_by BIGINT, + handled_at DATETIME(3), + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + UNIQUE KEY uk_im_friend_requests_request_id (request_id), + KEY idx_im_friend_requests_target_status (target_id, status, created_at), + KEY idx_im_friend_requests_applicant_status (applicant_id, status, created_at) +); +``` + +## 7. 文件和对象存储 + +数据库独立库后,文件元数据在租户库内隔离,但对象存储路径仍建议带租户目录: + +```text +tenants/{tenant_id}/yyyy/mm/{file_id} +``` + +对象存储通常仍是共享 Bucket。带租户目录便于权限策略、容量统计、清理和迁移。 + +文件下载流程: + +1. HTTP Token 解析得到 `tenant_id` 和 `uid`; +2. 根据 `tenant_id` 选择租户业务库; +3. 在该库查 `fileuploads` 和 `filemsglinks`; +4. 校验当前用户是否有 Topic 或消息访问权; +5. 返回文件或预签名 URL。 + +## 8. 测试策略 + +需要把旧共享表测试改成独立库测试: + +| 用例 | 准备 | 预期 | +| --- | --- | --- | +| 企业码解析 | 控制库有 A、B 两个租户 | A、B 返回不同 `tenant_id` 和不同 `db_name` | +| 同手机号注册 | A、B 租户库分别注册同手机号 | 两边都成功 | +| 登录选库 | A 用户用 B 企业码登录 | B 库查不到该用户,登录失败 | +| Token 选库 | A Token 请求文件接口 | 只访问 A 租户库 | +| 内存 Topic 隔离 | A、B 都使用 `sys` | Hub 中是两个 TopicKey | +| 租户停用 | 控制库把 A 设为 suspended | 新登录失败 | +| 租户库不可用 | A 的 DSN 错误或库不存在 | A 请求失败,B 不受影响 | +| 连接池复用 | 多次访问 A | 只创建一个 A 租户 adapter,后续复用 | +| 配额检查 | A 配置 max_users=1 | A 超额注册失败,B 不受影响 | + +## 9. 对旧方案的处理 + +以下内容停止继续开发: + +1. 给核心业务表增加 `tenant_id`; +2. 把唯一索引改成 `(tenant_id, xxx)`; +3. 所有业务 SQL 加 `WHERE tenant_id=?`; +4. 共享库内的数据迁移、回填、复合外键改造; +5. 产品业务表中的 `tenant_id` 字段设计。 + +以下内容继续保留: + +1. 企业码识别; +2. 控制库 `im_tenant`、`im_tenant_config`; +3. `TenantID` 类型; +4. Session TenantID; +5. Token TenantID; +6. Hub、缓存、集群、Push、插件上下文中的 TenantID; +7. `TenantAdapterFor` 选库机制。 + +## 10. 推荐下一步 + +下一步代码改造建议从 MySQL 层开始: + +1. 把 `server/db/mysql/schema.sql` 拆为控制库 schema 和租户业务库 schema; +2. 把 `tenant_adapter.go` 改成路由包装,不再写 `tenant_id` SQL; +3. 恢复或复用原 `adapter.go` 中无租户 SQL 作为租户库真实执行逻辑; +4. 修改 MySQL 测试,创建两个独立业务库验证隔离; +5. 再继续补产品业务表,所有业务表按租户库内表设计。 + +## 11. 当前代码落地状态 + +2026-08-13 已开始按独立库方案改造 MySQL 底座: + +1. 默认业务库为 `tinode`; +2. 控制库为 `hotgo`; +3. `CreateDb` 会在 `hotgo` 创建 `im_tenant`、`im_tenant_config`,默认租户 `id=1/code=default/db_name=tinode`; +4. `CreateDb` 会在 `tinode` 创建核心业务表,核心表不再包含 `tenant_id`; +5. `TenantAdapterFor` 开启路由后默认租户也会从控制库解析,不再直接退回根连接; +6. 租户库 adapter 禁用二次路由,避免递归; +7. 用户、认证、凭证、Topic、Subscription、消息、设备、文件等核心 Tenant 方法已改为“先选库,再调用租户库内无 tenant_id 方法”; +8. 旧共享表 `tenant_id` 迁移 SQL 已标记废弃。 + +剩余项: + +1. 产品扩展表和 CRUD,例如好友、收藏、实名、青少年、系统通知,还需要继续从 `tenant_id` SQL 改成租户库内无 `tenant_id` SQL; +2. `server/db/mysql/schema.sql` 仍是参考文件,已标注正在从旧共享表草稿拆分,后续应拆成 `registry_schema.sql` 和 `tenant_schema.sql`; +3. 旧版本 `UpgradeDb` 迁移链仍包含共享表历史步骤。因为当前项目不考虑历史数据,优先使用新建库 `CreateDb`;如后续需要升级老库,应单独重写迁移链。 diff --git a/docs/im/IM-现有实现与新需求差异对照总结.md b/docs/im/IM-现有实现与新需求差异对照总结.md new file mode 100644 index 0000000..efcdf27 --- /dev/null +++ b/docs/im/IM-现有实现与新需求差异对照总结.md @@ -0,0 +1,377 @@ +# IM 现有实现与新需求差异对照总结 + +> 对照日期:2026-08-10 +> 新需求来源:[IM 产品原型需求文档](IM-产品原型需求文档.md) +> 详细改造分析:[IM 产品需求服务端改造分析报告](IM-产品需求服务端改造分析报告.md) +> 对照对象:`chat-0.25.3` 当前服务端源码 + +## 1. 文档目的 + +本文从“现有服务端已经实现了什么”出发,对照新产品需求逐项给出处理结论: + +- **保留**:现有实现已基本满足需求,只补配置或测试; +- **需修改**:现有能力可复用,但协议、数据模型、权限或流程不完整; +- **新增**:现有系统没有对应实现,需要新增模块; +- **客户端为主**:主要是移动端交互,服务端只提供状态、配置或资源; +- **废弃**:现有产品能力或接入方式在新需求中没有对应章节,不再作为新产品能力继续建设。 + +“新需求无直接章节”的数据库、对象存储、集群、监控等运行基础设施不按产品功能废弃,而标记为“保留基础设施”。真正需要废弃的能力单列在第 5 节。 + +## 2. 总体结论 + +当前系统可以继续作为新产品的即时通讯内核,不需要重写。现有 P2P Topic、群 Topic、Subscription ACL、消息序号、消息广播、回执、文件上传、Push 和 WebRTC 信令均可复用。 + +新需求与现状的主要关系如下: + +| 新需求章节 | 功能域 | 当前覆盖程度 | 结论 | +| --- | --- | --- | --- | +| 第 1 章 | 账号、企业码、短信、实名、独立密码 | 部分覆盖 | 认证内核保留,产品流程需修改,企业码/实名需新增 | +| 第 2 章 | 会话首页、网络状态、消息操作、系统消息 | 部分覆盖 | 会话与连接保留,产品状态和系统通知需修改 | +| 第 3 章 | 单聊、好友状态、回执、媒体和名片 | 部分覆盖 | P2P/回执保留,好友约束和结构化消息需修改 | +| 第 4 章 | 输入、@、语音、文件、收藏、转发 | 少量覆盖 | 文件和通用消息可复用,大部分产品语义需新增 | +| 第 5 章 | 通讯录、好友申请、主页、黑名单 | 少量覆盖 | 用户搜索可改造,关系状态机需新增 | +| 第 6 章 | 群创建、审批、公告、管理、禁言、群文件 | 部分覆盖 | 群和 ACL 保留,群治理业务需新增 | +| 第 7 章 | 收藏、下载、聊天记录搜索 | 少量覆盖 | `slf` 和文件可复用,授权和索引必须修改 | +| 第 8 章 | 语音/视频通话 | 部分覆盖 | P2P WebRTC 信令保留,通话业务状态需修改 | +| 第 9 章 | 组织、会议、日报、月报、信箱 | 未覆盖 | 全部新增业务模块 | +| 第 10 章 | 资料、设置、版本、隐私、青少年模式 | 少量覆盖 | 通用资料可改造,其余多数新增或客户端实现 | +| 第 11 章 | 反诈、举报、反馈 | 未形成业务闭环 | 插件机制保留,业务模型和处置流程新增 | + +必须优先处理三个基础问题: + +1. 消息发布缺少客户端消息 ID 幂等校验,断线重试可能产生重复消息; +2. 文件下载只校验登录,没有校验用户对文件关联 Topic/消息的读取权限; +3. 当前核心表没有 `tenant_id`,共享数据库部署多个企业前必须完成全链路租户隔离。 + +## 3. 现有核心实现与新需求关系 + +| 现有实现 | 源码位置 | 对应新需求章节 | 结论 | 修改说明 | +| --- | --- | --- | --- | --- | +| basic 用户名/密码认证 | `server/auth/basic` | 1.2、1.3、1.4、1.6 | 需修改 | 保留认证器,增加协议确认、密码策略、错误次数、锁定、找回密码编排和旧 Token 撤销 | +| HMAC Token 登录 | `server/auth/token` | 1.2、1.6;2.2 | 保留 | 继续作为登录态;补设备会话、单设备撤销和密码重置后的 Token 失效 | +| 电话号码及短信验证器 | `server/validate/tel` | 1.4、1.5、1.6、1.7 | 需修改 | 保留验证码能力,增加用途隔离、发送频控、`retry_after`、一次性验证票据和审计 | +| REST 外部认证器 | `server/auth/rest` | 1.1、1.2 | 需修改 | 可连接企业账号中心;企业码解析仍需新增独立入口,认证后绑定企业上下文 | +| Session/WebSocket 协议分发 | `server/session.go` | 2.2;第 3、4、8 章 | 保留 | 作为 App 的主实时入口;补幂等、产品策略和结构化错误 | +| Topic 消息顺序、保存和广播 | `server/topic.go` | 第 2、3、4、6、7 章 | 需修改 | 保留核心循环;持久化前增加好友、黑名单、禁言、青少年和内容风险校验 | +| P2P Topic | `server/topic.go` | 3.1-3.12;5.2-5.14 | 需修改 | 保留单聊容器;是否允许创建、发送和呼叫改由好友/黑名单/隐私策略控制 | +| Group Topic | `server/topic.go` | 6.1-6.28;4.2-4.4 | 需修改 | 保留群消息和成员基础;增加审批、公告、二维码、禁言和群文件业务层 | +| Subscription ACL | `server/store/types/types.go`、`server/topic.go` | 3.1-3.8;6.4-6.26 | 需修改 | 保留 Join/Read/Write/Admin/Owner;不能用 ACL 代替好友申请、待审批和禁言记录 | +| Presence、输入状态 | `server/topic.go`、`server/datamodel.go` | 2.1;3.1-3.4;4.9 | 需修改 | 在线/输入状态可复用;增加隐私控制、节流和过期清理 | +| `recv`、`read` 回执 | `server/topic.go` | 3.4、3.7 | 保留 | 定义客户端状态映射;群聊是否显示具体已读人由产品规则控制 | +| 通用 JSON `head/content` 与 Drafty | `server/datamodel.go`、`server/drafty` | 3.6、3.10-3.12;4.1-4.20 | 需修改 | 增加消息类型、schema 版本、字段白名单、引用、@、转发和审核状态,不再允许各端自定义语义 | +| 消息删除 | `server/session.go`、`server/topic.go` | 2.4;3.8;4.15 | 需修改 | 明确仅自己删除、全员撤回、撤回期限、管理员权限和审计,不直接复用模糊的硬删除语义 | +| `slf` 自收藏 Topic | `server/store/types/types.go`、`server/topic.go` | 4.16;7.1-7.8 | 需修改 | 继续保存收藏消息,同时增加收藏索引、来源、去重、类型筛选和源消息删除策略 | +| 文件上传与消息附件关联 | `server/hdl_files.go`、`server/store/store.go` | 3.6、3.10-3.12;4.10-4.20;6.27-6.28;7.3、7.7-7.11 | 需修改 | 保留上传;增加附件所有权、病毒扫描、群文件索引、短时下载 URL 和资源级授权 | +| Push Handler | `server/push.go`、`server/push` | 2.1、2.5;4.4;8.1-8.11;9.3-9.4;10.18 | 需修改 | 保留 Push;扩展 @、审批、会议、通话和安全通知,增加锁屏内容隐私设置 | +| P2P WebRTC 信令 | `server/calls.go` | 8.1-8.11 | 需修改 | 保留 Invite/Offer/Answer/ICE/Hang-up;新增 `call_id`、设备抢答、结束原因、详单和 TURN 指标 | +| Plugin Firehose | `server/plugins.go` | 11.1-11.4;2.5 | 需修改 | 用于外部内容审核和反诈;增加超时、熔断、失败策略和审核结果落库 | +| User Public/Trusted/Tags | `server/store/types/types.go` | 5.1、5.5-5.8;10.1-10.6 | 需修改 | 可保存基础展示资料;生日、可见性、实名和组织关系应使用明确字段/业务表 | +| Topic Public/Trusted/Aux/Tags | `server/store/types/types.go` | 6.10-6.16 | 需修改 | 可保存群基础展示资料;公告、审批、二维码和禁言不能只存任意 JSON | +| `fnd` 用户/Topic 查找 | `server/topic.go` | 5.1、5.3-5.5 | 需修改 | 改成租户内精准 ID/受控搜索;执行隐私策略并防止手机号、用户 ID 枚举 | +| `sys` 系统 Topic | `server/store/types/types.go`、`server/topic.go` | 2.5;第 9、11 章通知 | 需修改 | 作为可信系统消息通道;定义消息类型、模板版本、跳转参数和去重键 | +| MySQL 核心表和 Adapter | `server/db/mysql` | 所有服务端需求的存储基础 | 需修改 | schema 118 已建立 `im_tenant`;仍需为核心表增加 tenant_id,并新增产品表、幂等、搜索索引和文件授权查询 | +| 文件系统/S3 媒体后端 | `server/media/fs`、`server/media/s3` | 第 3、4、6、7、11 章附件 | 保留基础设施 | 生产使用 S3/兼容对象存储,本地文件系统仅开发;补扫描和私有签名地址 | + +## 4. 按新需求章节逐项对照 + +### 4.1 第 1 章:账号注册、登录与身份安全 + +| 新需求章节 | 新功能 | 当前实现 | 是否改 | 如何修改 | +| --- | --- | --- | --- | --- | +| 1.1 | 企业码登录 | 无企业/租户模型 | 新增 | 增加企业码解析 API、企业状态、服务地址和认证策略;共享库模式需先加 `tenant_id` | +| 1.2-1.3 | 账号密码登录及错误提示 | basic + token 已有 | 需修改 | 增加协议确认、统一错误码、失败计数、账号锁定、设备登录记录和安全通知 | +| 1.4-1.5 | 短信注册和倒计时 | 电话验证器已有 | 需修改 | 新增注册流程 API;验证码按用途隔离,返回有效期和重发时间,服务端强制限频 | +| 1.6 | 忘记密码 | 验证器与密码更新能力分散 | 需修改 | 建立“发码 → 临时票据 → 重置”状态机;成功后撤销旧 Token | +| 1.7 | 敏感操作二次验证 | 无统一业务流程 | 新增 | 增加验证挑战、一次性票据、有效期、错误次数和操作绑定 | +| 1.8-1.10 | 实名认证及状态提醒 | 无 | 新增 | 独立实名表、字段加密、审核状态、脱敏查询、审核回调和提醒消息 | +| 1.11 | 四位独立密码 | 无 | 新增 | 保存强哈希、失败锁定和找回流程;禁止写入普通设置 JSON 或明文日志 | + +### 4.2 第 2 章:消息首页、全局导航与系统通知 + +| 新需求章节 | 新功能 | 当前实现 | 是否改 | 如何修改 | +| --- | --- | --- | --- | --- | +| 2.1 | 会话列表、摘要、未读数 | Subscription/Topic 已有基础 | 需修改 | 增加置顶、免打扰、隐藏、草稿和结构化消息摘要;文件助手使用固定系统 Topic | +| 2.2 | 断网、重连、增量同步 | Session 与序号拉取已有 | 需修改 | 明确同步游标、重连退避、重复消息幂等和连接状态指标 | +| 2.3 | 快捷菜单 | 无服务端页面逻辑 | 客户端为主 | 服务端只返回能力/权限开关,菜单和关闭交互由客户端实现 | +| 2.4 | 引用、复制、收藏、编辑、转发、撤回、删除、多选、群回执 | 仅删除、通用消息、回执基础 | 需修改 | 增加引用/编辑/转发协议,撤回规则和批量操作校验;复制/多选 UI 在客户端 | +| 2.5 | 新设备登录等系统消息 | `sys` Topic 可用,无完整事件生产 | 需修改 | 建立可信系统账号、设备/IP 审计事件、模板、跳转参数和防伪造规则 | + +### 4.3 第 3 章:单聊会话、会话建立与消息状态 + +| 新需求章节 | 新功能 | 当前实现 | 是否改 | 如何修改 | +| --- | --- | --- | --- | --- | +| 3.1-3.5 | 好友申请前后单聊状态 | P2P Topic 有,无好友状态机 | 新增/修改 | 新增好友申请和关系表;申请通过后再开放 Topic Write 与媒体能力 | +| 3.4、3.7 | 发送中、送达、已读、失败 | 消息序号和 read/recv 已有 | 需修改 | 增加客户端消息 ID、稳定状态映射和业务失败原因;拉黑消息不得持久化给对方 | +| 3.6 | 图片/语音/视频进度 | 文件上传与消息可用 | 需修改 | 统一 `file_id`、消息类型和上传完成确认;进度主要由客户端维护 | +| 3.7-3.8 | 拉黑、删除好友后的发送限制 | 无关系策略 | 新增 | 在建会话、发布、通话、名片和文件入口统一执行关系策略 | +| 3.9 | 键盘收起布局 | 无服务端职责 | 客户端为主 | 服务端无需修改 | +| 3.10-3.11 | 多类型文件/混合消息 | 通用 JSON + 文件已有 | 需修改 | 定义 text/image/voice/video/file 类型和字段校验,禁止任意 URL | +| 3.12 | 名片消息 | 无标准类型 | 新增 | 增加 `contact_card`,只传用户 ID 与安全快照,打开时重新检查资料权限 | + +### 4.4 第 4 章:消息输入、媒体内容与消息操作 + +| 新需求章节 | 新功能 | 当前实现 | 是否改 | 如何修改 | +| --- | --- | --- | --- | --- | +| 4.1 | 键盘、草稿 | 无专用服务端草稿 | 客户端为主 | 草稿默认本地;如需多端同步,增加每用户每 Topic 的草稿设置 | +| 4.2-4.4 | @成员、@所有人 | 无结构化 @语义 | 新增 | 消息保存稳定用户 ID;校验群成员和 @所有人权限,生成定向未读与 Push | +| 4.5-4.6 | 表情面板和表情消息 | Drafty 可保存文本 | 保留/客户端为主 | Unicode 表情按文本发送;独立表情包需要资源 ID 和版本,不保存本地路径 | +| 4.7-4.12 | 录音、图片/语音/视频上传与展示 | 文件和通用消息基础已有 | 需修改 | 增加媒体类型、时长/尺寸/缩略图、扫描状态;录音交互和播放进度由客户端实现 | +| 4.13-4.14 | 文件上传和文件卡片 | 已有上传与附件关联 | 需修改 | 补格式/大小策略、所有权、病毒扫描、可预览信息和安全下载 | +| 4.15 | 消息多选批量操作 | 无服务端批量语义 | 需修改 | 客户端选择;服务端逐条鉴权,限制批量数量并返回逐项结果 | +| 4.16 | 批量收藏 | `slf` 可承载 | 需修改 | 增加收藏唯一约束、来源索引、类型和批量幂等 | +| 4.17-4.19 | 单选/多目标逐条转发 | 无标准转发模型 | 新增 | 校验每个目标发送权和附件权;限制目标数;每条新消息使用独立幂等 ID | +| 4.20 | 合并转发 | 无 | 新增 | 创建不可变 `merged_forward` 快照,限制子项和总大小,清理会话 ID 与敏感字段 | + +### 4.5 第 5 章:通讯录、好友关系与黑名单 + +| 新需求章节 | 新功能 | 当前实现 | 是否改 | 如何修改 | +| --- | --- | --- | --- | --- | +| 5.1 | 好友通讯录和企业成员 | `fnd`/Tags 仅提供基础查找 | 需修改 | 新增好友关系和组织成员查询;备注优先;支持字母/拼音索引 | +| 5.2 | 新朋友列表与审批 | 无 | 新增 | 新增好友申请状态机、幂等接受/拒绝、过期和系统通知 | +| 5.3-5.5 | ID、扫码添加好友 | 基础查找可部分复用 | 需修改 | 限定租户内精准搜索;二维码使用签名短 Token;执行隐私和反枚举策略 | +| 5.6 | 好友验证留言 | 无明确模型 | 新增 | 好友申请表保存验证信息、来源、过期时间和风控结果 | +| 5.7-5.8 | 好友/非好友个人主页 | Public/Trusted 可显示基础资料 | 需修改 | 根据关系、企业和隐私设置返回不同字段,不让客户端自行裁剪敏感资料 | +| 5.9-5.10 | 拉黑开关 | 无完整黑名单模型 | 新增 | 关系表记录单方 block;立即影响消息、搜索、呼叫和资料访问 | +| 5.11 | 删除好友 | 无好友关系真源 | 新增 | 事务更新双方关系;默认保留历史但禁止继续发送,规则需产品确认 | +| 5.12-5.14 | 黑名单列表及提示 | 无 | 新增 | 提供黑名单查询、解除接口和统一业务错误,不向被拉黑人泄露过多信息 | + +### 4.6 第 6 章:群聊创建、资料维护与成员治理 + +| 新需求章节 | 新功能 | 当前实现 | 是否改 | 如何修改 | +| --- | --- | --- | --- | --- | +| 6.1-6.2 | 群列表和空状态 | Group Topic 已有 | 需修改 | 复用订阅列表;增加群业务状态、群头像摘要和已解散处理 | +| 6.3-6.6 | 加群、邀请与审批 | ACL 可邀请成员,但无申请状态机 | 新增/修改 | 新增入群申请/邀请表;审批通过后才更新 Subscription,写 Outbox 通知 | +| 6.7-6.9 | 群公告和已读 | 无公告模型 | 新增 | 公告主表、版本、发布权限、置顶和成员已读表;变更通过群系统消息通知 | +| 6.10-6.16 | 群设置、资料、头像、昵称、简介、标识、二维码 | Public/Aux/Private 可部分承载 | 需修改 | 定义可索引字段和修改权限;二维码新增签名 Token、有效期、使用次数和撤销 | +| 6.17-6.22 | 管理员、群主转让 | Admin/Owner ACL 已有 | 需修改 | 复用权限位;补管理员上限、二次确认、操作审计和可靠通知 | +| 6.23-6.24 | 成员禁言 | 当前通知静音不等于发言禁用 | 新增 | 新增全员/成员禁言规则,在消息持久化前强制执行,定义管理员豁免 | +| 6.25-6.26 | 选择新群主及确认 | 已有 Owner 转移逻辑 | 需修改 | 增加风险校验、幂等、候选人有效性、二次确认和审计 | +| 6.27-6.28 | 群文件及上传 | 文件消息可用,无群文件目录 | 新增/修改 | 建群文件索引,支持类型/时间/上传者查询和管理员删除,复用安全上传链路 | + +### 4.7 第 7 章:文件下载、收藏管理与聊天记录检索 + +| 新需求章节 | 新功能 | 当前实现 | 是否改 | 如何修改 | +| --- | --- | --- | --- | --- | +| 7.1-7.8 | 收藏分类、查看和分享 | `slf` Topic 只有基础容器 | 需修改 | 增加收藏索引、类型、去重和源消息引用;分享时重新执行附件和目标会话权限 | +| 7.9-7.11 | 文件下载状态 | 已有 HTTP 下载 | 必须修改 | 下载前检查上传者或关联 Topic Read ACL;使用短时签名 URL;进度/完成状态在客户端 | +| 7.12 | 关键词搜索并定位上下文 | 现有查询只按序号范围 | 新增 | 建消息搜索索引,返回 Topic/SeqId 后重新检查 ACL,并支持上下文拉取 | +| 7.13-7.16 | 文件、链接、图片、视频分类检索 | 无类型索引 | 新增 | 建 `message_type`、`mime_group`、file/link 字段和游标分页,支持历史回填 | + +### 4.8 第 8 章:实时语音与视频通话 + +| 新需求章节 | 新功能 | 当前实现 | 是否改 | 如何修改 | +| --- | --- | --- | --- | --- | +| 8.1-8.4 | 邀请、等待、接听、语音通话 | P2P WebRTC 信令已有 | 需修改 | 增加独立 `call_id`、呼叫类型、响铃/接听时间、忙线/拒接/超时原因和通话记录 | +| 8.5-8.6 | 麦克风与扬声器状态 | 信令可传临时事件 | 客户端为主 | 本地媒体控制由客户端负责,仅在对端需要展示时同步状态 | +| 8.7-8.10 | 视频等待、摄像头关闭、视频中 | WebRTC Offer/Answer/ICE 已有 | 需修改 | 增加视频类型和摄像头状态事件;配置 TURN,监控 ICE 成功率 | +| 8.11 | 通话悬浮状态 | 无服务端职责 | 客户端为主 | 服务端保持呼叫会话和重连状态,悬浮窗由客户端实现 | + +当前实现仅适合一对一通话。新需求没有群音视频章节,因此首期不新增 SFU 或群会议通话。 + +### 4.9 第 9 章:企业工作台与办公协同 + +| 新需求章节 | 新功能 | 当前实现 | 是否改 | 如何修改 | +| --- | --- | --- | --- | --- | +| 9.1 | 工作台入口 | 无业务模块 | 新增 | 新增工作台应用配置和用户可见权限;页面布局由客户端实现 | +| 9.2 | 部门组织架构 | 无 | 新增 | 新建组织单元和成员表,支持树、搜索、主部门、职位、在职状态和数据范围 | +| 9.3-9.4 | 会议通知 | 仅能发送普通消息 | 新增 | 新建会议和参与人模型;创建/变更/取消通过 Outbox 发送系统消息和定时提醒 | +| 9.5-9.8 | 日报及普通/管理员视图 | 无 | 新增 | 建报告表和提交状态机;管理员查询按组织数据权限过滤 | +| 9.9-9.11 | 月报及详情 | 无 | 新增 | 复用报告模型,以类型/周期区分;增加汇总和附件能力 | +| 9.12-9.13 | 总经理信箱 | 无 | 新增 | 建信箱工单、匿名展示、附件、回复和审计;匿名对业务角色隐藏但保留受控追溯 | + +工作台 CRUD 建议走 `/v1/im/*` HTTP API,状态提醒走系统 Topic,不把报告和组织树直接塞进聊天消息协议。 + +### 4.10 第 10 章:个人资料、通用设置、隐私与青少年模式 + +| 新需求章节 | 新功能 | 当前实现 | 是否改 | 如何修改 | +| --- | --- | --- | --- | --- | +| 10.1-10.5 | 我的、个人信息、昵称、生日、签名 | Public/Trusted 有通用字段 | 需修改 | 定义资料字段、格式、可见性、敏感词、变更版本和审计;实名信息单独存储 | +| 10.6 | 个人二维码 | 无标准 Token | 新增 | 生成短时签名 Token,扫码后仍执行租户、隐私和账号状态校验 | +| 10.7-10.8 | iOS/Android 设置页 | 无服务端页面逻辑 | 客户端为主 | 服务端提供用户设置和能力开关 | +| 10.9 | 清空缓存 | 不属于服务端数据删除 | 客户端为主 | 只清理设备缓存,不删除云端消息;无需服务端接口 | +| 10.10-10.11 | 版本更新 | 无版本配置服务 | 新增 | 提供平台/渠道最低版本、推荐版本、强更开关、下载地址和文案 API | +| 10.12-10.13 | 隐私设置及添加方式 | 无统一隐私模型 | 新增 | 建隐私设置表,并在搜索、加好友、资料和在线状态接口中强制执行 | +| 10.14-10.17 | 青少年模式 | 无 | 新增 | 服务端保存启用状态、PIN 强哈希、失败锁定和策略版本;所有关键入口强制检查 | +| 10.18 | 推送权限 | Push Token 能力已有 | 需修改/客户端为主 | 系统授权由客户端申请;服务端保存 Token、开关、免打扰和预览策略 | +| 10.19-10.21 | 提醒、相机、麦克风说明 | 无服务端职责 | 客户端为主 | 权限说明和系统授权由客户端实现,可由配置服务下发版本化文案 | + +### 4.11 第 11 章:反诈安全、举报与意见反馈 + +| 新需求章节 | 新功能 | 当前实现 | 是否改 | 如何修改 | +| --- | --- | --- | --- | --- | +| 11.1-11.2 | 反诈一次/二次提示 | Plugin 可拦截消息,无产品策略 | 新增/修改 | 建风险事件和策略版本;同步硬规则本地执行,外部审核设置超时/熔断;客户端按等级弹窗 | +| 11.3-11.4 | 举报提交和分类 | 无举报工单 | 新增 | 建举报、证据、处理状态和审计;服务端在有权限时固化消息快照与哈希 | +| 11.5 | 意见反馈 | 无 | 新增 | 建反馈工单、分类、附件、联系方式和处理状态;附件复用私有文件授权 | + +## 5. 新需求未覆盖的现有能力:废弃清单 + +以下能力在新需求文档中没有对应章节。按新产品范围标注为“废弃”,但应采用“停止新接入 → 观察存量 → 迁移 → 删除”的方式处理,不能在未确认线上使用情况时直接删除源码或数据。 + +| 现有能力 | 源码/配置 | 新需求章节 | 处理结论 | 废弃方式 | +| --- | --- | --- | --- | --- | +| 匿名登录 | `server/auth/anon` | 无 | **废弃** | 新环境不配置、不向客户端公开;确认无匿名会话后移除注册和文档 | +| 邮箱注册/邮箱验证码/邮箱找回 | `server/validate/email` | 无,新需求只定义手机号短信 | **废弃** | 产品 API 不再暴露 email credential;存量账号先绑定手机号再停用 | +| Channel 只读广播 Topic | `TopicCatGrp` 的 channel 模式 | 无 | **废弃** | 禁止新建 Channel;如有存量,迁移为普通群或公告业务后再移除入口 | +| Long Poll 客户端接入 | `/v0/channels/lp` | 无,新移动端按 WebSocket 设计 | **废弃** | 先记录调用量并公告;无流量后关闭路由,WebSocket 保留 | +| 客户端 gRPC 接入 | `grpc_listen`、客户端 gRPC 服务 | 无 | **废弃** | App 不再接入;注意保留插件所需的 gRPC 能力,两者不能一起误删 | +| 示例 Python Chat Bot | `tinode.conf` 中 `python_chat_bot` | 无 | **废弃** | 从生产配置删除;插件框架因第 11 章风控继续保留 | +| 静态网页托管 | `main.go` 静态文件路由/配置 | 无 Web 产品章节 | **废弃** | 新产品服务端不托管 Web UI;确认运维页面用途后关闭 | +| stdout Push Handler | `server/push/stdout` | 无生产需求 | **废弃(生产)** | 仅允许本地测试使用,生产配置删除;FCM/TNPG 等真实 Push 保留 | +| PostgreSQL Adapter | `server/db/postgres` | 无数据库兼容要求 | **废弃(产品版候选)** | 若确认生产统一 MySQL,则停止产品功能适配和验收;删除前核查部署 | +| MongoDB Adapter | `server/db/mongodb` | 无数据库兼容要求 | **废弃(产品版候选)** | 同上,不继续为新增 `im_*` 业务表实现双写逻辑 | +| RethinkDB Adapter | `server/db/rethinkdb` | 无数据库兼容要求 | **废弃(产品版候选)** | 同上;保留上游代码还是物理删除由仓库维护策略决定 | + +以下能力虽然没有独立的新需求章节,但属于满足新需求所需的技术底座,不废弃: + +| 基础能力 | 保留原因 | +| --- | --- | +| `me` Topic 和设备同步 | 会话列表、未读数、多端状态需要 | +| `fnd` 查找 Topic | 第 5 章精准用户搜索可在其基础上收紧 | +| `sys` Topic | 第 2、9、11 章的可信系统通知需要 | +| `slf` Topic | 第 4、7 章收藏同步可复用 | +| gRPC Plugin/Firehose | 第 11 章内容审核和反诈外部服务需要 | +| FCM/TNPG Push | 第 2、4、8、9、10 章离线提醒需要,生产只选择实际使用的 Handler | +| 文件系统/S3 Media Adapter | 第 3、4、6、7、11 章附件需要,按环境选择 | +| 集群、缓存、监控、TLS | 属于可用性和安全基础,不是产品页面功能 | +| root/后台管理能力 | 实名审核、举报处置、组织管理和审计需要,但必须收紧权限 | + +## 6. 需要新增的服务端模块 + +| 新模块 | 对应新需求章节 | 主要职责 | +| --- | --- | --- | +| `imapi` | 第 1、5、6、7、9、10、11 章 | HTTP API、认证中间件、输入校验、统一错误和游标分页 | +| `impolicy` | 3.7-3.8;5.9-5.14;6.23-6.24;10.12-10.17 | 好友、黑名单、禁言、隐私和青少年同步授权 | +| `immessage` | 2.4;3.6、3.10-3.12;第 4 章 | 消息类型、schema 校验、@、引用、编辑、转发和摘要 | +| `imbiz/relationship` | 第 5 章 | 好友申请、联系人、备注、拉黑和删除状态机 | +| `imbiz/group` | 6.3-6.28 | 群审批、公告、二维码、角色、禁言和群文件 | +| `imbiz/identity` | 1.7-1.10 | 二次验证、实名认证、审核和脱敏 | +| `imbiz/workbench` | 第 9 章 | 组织、会议、日报、月报和信箱 | +| `imbiz/safety` | 第 11 章 | 风险事件、举报证据、处置、反馈和审计 | +| `imsearch` | 7.12-7.16 | 消息索引、历史回填、检索和 ACL 二次过滤 | +| `imnotify` | 2.5;第 5、6、9、11 章 | Outbox 消费、系统消息和 Push 通知 | +| `imjobs` | 1.5;5.2;6.3-6.6;9.3-9.4 | 申请过期、会议提醒、重试、清理和索引回填 | + +## 7. 主要数据修改 + +### 7.1 核心表必须修改 + +| 对象 | 对应新需求章节 | 修改内容 | +| --- | --- | --- | +| Message | 2.2、2.4;第 3、4、7 章 | 增加或关联客户端幂等 ID、类型、审核状态、搜索索引;重复发送返回同一 SeqId | +| FileDef/File links | 第 3、4、6、7、11 章 | 增加扫描状态、业务用途和访问查询;下载按消息/Topic ACL 授权 | +| User | 1.1-1.10;5.1;9.2;第 10 章 | 共享 SaaS 时加 tenant;基础资料字段规范化,敏感身份数据独立存储 | +| Topic/Subscription | 第 2、3、5、6 章 | 共享 SaaS 时加 tenant;会话个性设置、群策略和关系状态使用独立业务表 | + +### 7.2 新增业务表 + +| 数据域 | 建议表 | 对应新需求章节 | +| --- | --- | --- | +| 好友与会话 | `im_user_relations`、`im_friend_requests`、`im_conversation_settings`、`im_privacy_settings` | 第 2、3、5 章;10.12-10.13 | +| 群治理 | `im_group_settings`、`im_group_join_requests`、`im_group_mutes`、`im_group_announcements`、`im_group_announcement_reads`、`im_group_invite_tokens` | 第 6 章 | +| 消息与收藏 | `im_message_idempotency`、`im_message_index`、`im_favorites` | 2.2、2.4;第 4、7 章 | +| 身份与设置 | `im_identity_verifications`、`im_user_settings`、`im_teen_mode` | 第 1、10 章 | +| 企业办公 | `im_org_units`、`im_org_members`、`im_meetings`、`im_meeting_participants`、`im_work_reports`、`im_mailbox_messages` | 第 9 章 | +| 安全治理 | `im_reports`、`im_report_evidence`、`im_feedback`、`im_risk_events`、`im_audit_logs` | 第 11 章 | +| 可靠事件 | `im_outbox`、`im_idempotency_keys` | 第 5、6、9、11 章的审批和通知 | + +共享数据库的多企业模式下,核心表和全部 `im_*` 表都必须包含 `tenant_id`,并把租户放入唯一索引、查询条件、缓存键和文件路径。若采用一企一实例/一企一库,则企业码负责服务发现,物理隔离优先。 + +## 8. 主要代码修改位置 + +| 文件/目录 | 对应新需求章节 | 修改内容 | +| --- | --- | --- | +| `server/datamodel.go` | 第 2、3、4、7、8 章 | 定义 `x-im-schema`、消息类型、幂等 ID、引用、转发、搜索和通话扩展 | +| `server/session.go` | 第 2、3、4、5、8、10、11 章 | 消息幂等、策略调用、租户上下文和统一业务错误 | +| `server/topic.go` | 第 2、3、4、6、7 章 | 保存前执行好友/黑名单/禁言/青少年/风险规则,发送结构化系统消息 | +| `server/calls.go` | 第 8 章 | call_id、设备抢答、标准结束原因、通话记录和指标 | +| `server/hdl_files.go` | 第 3、4、6、7、11 章 | 抽取 HTTP 认证、资源 ACL、签名下载 URL 和扫描状态检查 | +| `server/plugins.go` | 第 11 章 | 审核超时、熔断、失败开放/关闭策略和可观测性 | +| `server/push.go` | 2.5;4.4;第 8、9、10、11 章 | 产品消息类型、@、会议、审批、通话和隐私 Push | +| `server/main.go` | 第 1、5、6、7、9、10、11 章 | 注册 `/v1/im/*` API、业务服务和后台任务;逐步下线废弃路由 | +| `server/store` | 所有服务端章节 | 新增核心事务、幂等、文件授权、搜索接口;明确产品版支持的数据库 | +| `server/db/mysql` | 所有服务端章节 | 从当前 schema 118 继续迁移核心 tenant_id、业务表、索引和 MySQL 实现 | +| `server/tinode.conf` | 所有章节 | 企业、产品模块、搜索、审核、TURN、文件扫描、限流和废弃能力开关 | + +## 9. 修改优先级 + +### P0:先保证新系统不会产生安全和数据问题 + +| 工作 | 对应新需求章节 | +| --- | --- | +| 确定一企一实例或共享 SaaS | 1.1、5.1、9.2 | +| 确定产品版只支持 MySQL 还是继续支持多数据库 | 所有服务端章节的存储基础 | +| 消息客户端 ID 幂等 | 2.2;3.4、3.7;第 4 章 | +| 文件下载资源级授权 | 3.10-3.12;4.13-4.20;6.27-6.28;7.3、7.7-7.11 | +| 消息类型、错误码和兼容协议 | 2.4;第 3、4、7、8 章 | +| 停止新客户端使用匿名、Long Poll、Channel 和客户端 gRPC | 新需求无对应章节,废弃项 | + +### P1:完成即时通讯产品主链路 + +| 工作 | 对应新需求章节 | +| --- | --- | +| 企业码、短信注册/找回、二次验证、实名 | 第 1 章 | +| 好友申请、联系人、隐私、黑名单 | 第 5 章;3.1-3.8 | +| 结构化消息、@、引用、转发、收藏 | 2.4;第 3、4 章 | +| 会话设置和可信系统通知 | 第 2 章 | + +### P2:完成群、文件和搜索 + +| 工作 | 对应新需求章节 | +| --- | --- | +| 群申请、公告、管理员、禁言、二维码、群文件 | 第 6 章 | +| 收藏分类和分享 | 7.1-7.8 | +| 关键词和媒体类型搜索、历史回填 | 7.12-7.16 | + +### P3:完成通话、设置和安全 + +| 工作 | 对应新需求章节 | +| --- | --- | +| 通话 ID、跨设备状态、TURN 和详单 | 第 8 章 | +| 资料、版本、隐私和青少年模式 | 第 10 章 | +| 反诈、举报、证据、反馈和后台处置 | 第 11 章 | + +### P4:完成企业办公 + +| 工作 | 对应新需求章节 | +| --- | --- | +| 组织与部门 | 9.2 | +| 会议通知 | 9.3-9.4 | +| 日报/月报 | 9.5-9.11 | +| 总经理信箱 | 9.12-9.13 | + +## 10. 废弃实施规则 + +废弃项不要直接删除,应按以下顺序执行: + +1. 在配置和对接文档中标记 deprecated,禁止新客户端接入; +2. 为废弃入口增加调用指标,至少观察一个完整客户端版本周期; +3. 对匿名账号、邮箱账号、Channel 等有数据的能力先做存量扫描; +4. 给存量用户提供迁移方案,例如邮箱账号绑定手机号、Channel 迁移普通群; +5. 新版本服务端默认关闭,保留临时回退开关; +6. 确认无调用和无未迁移数据后,再删除路由、配置、代码和测试; +7. 数据库删除列或表必须单独发布,保留备份和回滚窗口。 + +客户端 gRPC 和 Plugin gRPC 必须分开处理:新需求没有客户端 gRPC,因此前者废弃;第 11 章需要外部审核插件,因此 Plugin Firehose 及其 gRPC 通道保留。 + +## 11. 验收口径 + +每项改造只有同时满足以下条件才算完成: + +1. 文档中的新需求章节有对应 API/协议、数据表和权限规则; +2. 旧客户端兼容行为有明确结论,不依赖未定义 JSON 字段; +3. 重试、并发、断网恢复不会产生重复业务数据; +4. 用户不能通过直接构造协议绕过好友、黑名单、禁言、隐私和青少年策略; +5. 文件、搜索、转发和收藏均重新执行当前用户的资源权限; +6. 共享 SaaS 模式下通过跨租户负向测试; +7. 审批、实名、举报、群管理和匿名信箱敏感操作有审计记录; +8. 废弃入口有调用量证据和存量迁移结果,不能仅凭新原型没有页面就直接删除; +9. MySQL schema、升级脚本、回滚脚本和代码中的建表定义保持一致; +10. 服务端单元测试、MySQL 集成测试、WebSocket/HTTP 端到端测试和真实设备通话测试全部通过。 + +## 12. 最终处理建议 + +新系统应保留 Tinode 的实时消息内核,重点修改认证流程、消息协议、权限策略、文件授权和通话状态;好友、群治理、搜索、企业工作台、实名、青少年和举报作为新的产品业务层建设。 + +新需求未覆盖的匿名登录、邮箱注册验证、Channel、Long Poll、客户端 gRPC、示例聊天机器人和静态网页托管标记为废弃。多数据库 Adapter 是否物理删除取决于产品数据库决策;若产品版确定只支持 MySQL,则 PostgreSQL、MongoDB 和 RethinkDB 不再承担新业务适配与验收。 + +改造顺序应先处理租户、消息幂等和文件授权,再完成好友与基础消息,随后完成群治理、搜索、通话与安全,最后建设企业工作台。这样可以最大限度复用现有实现,同时避免继续维护新产品不需要的入口和功能。 diff --git a/docs/im/IM-第一二章后续增强项.md b/docs/im/IM-第一二章后续增强项.md new file mode 100644 index 0000000..b58999b --- /dev/null +++ b/docs/im/IM-第一二章后续增强项.md @@ -0,0 +1,298 @@ +# IM 第一至十一章后续增强项 + +> 日期:2026-08-13 +> 范围:第一章登录注册与安全、第二章消息首页/导航/系统通知、第三章单聊关系、第四章群消息与 @、第五章通讯录/好友/黑名单、第六章群聊创建/资料/治理、第七章文件/收藏/聊天记录检索、第八章实时语音与视频通话、第九章企业工作台与组织架构、第十章个人设置与客户端能力、第十一章反诈安全/举报/反馈 +> 用途:记录当前已完成基线之外的增强项,后续统一排期处理。 + +## 1. 第一章:登录、注册与安全 + +### 1.1 当前已完成基线 + +1. 企业码解析、租户状态校验和公开租户配置返回。 +2. 注册、登录前验证码发送与验证。 +3. 注册验证码 Token 消费,避免绕过验证码直接注册。 +4. 忘记密码挑战验证与重置确认。 +5. Token 绑定 `auth_version`,密码重置后旧 Token 失效。 +6. 登录后敏感操作二次验证挑战。 +7. 实名认证状态查询与提交,证件信息脱敏/保护存储。 +8. 独立密码设置与验证,包含弱密码规则和失败锁定。 +9. 登录后 bootstrap 返回实名、安全与菜单基础能力。 + +### 1.2 后续增强项 + +| 优先级 | 增强项 | 说明 | 建议验收点 | +| --- | --- | --- | --- | +| P0 | HTTP `Idempotency-Key` 统一存储与请求摘要冲突检测 | 当前部分接口要求幂等键,但还没有统一的 `tenant_id + uid + method + route + key + body_hash` 处理层。 | 相同键相同 body 返回同一结果;相同键不同 body 返回冲突。 | +| P0 | 验证码真实发送通道 | 当前验证码生成、校验和限频链路已具备,短信/邮件供应商发送仍需接入真实服务。 | 验证码可通过供应商下发;供应商失败有可观测错误和重试策略。 | +| P0 | 验证码限频维度补强 | 需要按租户、手机号、IP、设备、场景组合限频,并落库或接入 Redis。 | 高频请求返回 `VERIFICATION_RATE_LIMITED`,不同场景互不误伤。 | +| P0 | 安全事件通知联动 | 密码重置、实名提交/审核、独立密码变更、新设备登录等应写入系统通知。 | 操作成功后 `/v1/im/system-notifications` 能看到对应事件。 | +| P1 | 实名审核工作流 | 当前支持提交和状态查询,后续需要后台审核、驳回原因、通过后状态变更。 | 审核通过后 `realname.status=verified`;驳回可再次提交。 | +| P1 | 独立密码用途扩展 | 当前按 `purpose` 存储,可继续接入青少年模式关闭、敏感设置修改等业务入口。 | 不同 `purpose` 相互隔离;错误次数和锁定互不串扰。 | +| P1 | 设备与会话管理 | 需要登录设备列表、主动踢出设备、Token 刷新/撤销审计。 | 修改密码或踢设备后对应 Token 立即不可用。 | +| P1 | API Key 轮换策略 | 当前 API Key 校验兼容现有逻辑,尚未实现最低可接受序号或撤销表。 | 旧序号 Key 可按配置禁用,新 Key 不影响正常客户端。 | +| P2 | 风控与反诈策略接入 | 第一章安全动作后续应接入风险评分、反诈弹窗和二次确认策略。 | 高风险动作触发二次验证或反诈提示,低风险动作直通。 | + +## 2. 第二章:消息首页、全局导航与系统通知 + +### 2.1 当前已完成基线 + +1. `GET /v1/im/bootstrap` 返回首页菜单、能力开关、权限和 `message_policy`。 +2. `GET /v1/im/search/conversations` 支持会话搜索和基础分页。 +3. WebSocket 发消息支持 `Head["x-im-client-mid"]` 幂等,重连重发不重复落库/广播。 +4. 消息 Header 基础校验:`x-im-schema`、`type`、`x-im-client-mid`。 +5. 普通用户禁止发布 `sys` Topic,且禁止伪造 `Head.type=system`。 +6. `GET /v1/im/topics/{topic}/messages/{seq}/receipts` 返回群消息送达/已读基础列表。 +7. `POST /v1/im/topics/{topic}/messages/{seq}/recall` 支持版本校验、权限校验和时限内撤回。 +8. `POST /v1/im/topics/{topic}/messages/{seq}/revisions` 支持文本消息编辑、版本递增和修订记录。 +9. `GET /v1/im/system-notifications` 和 `POST /v1/im/system-notifications/{id}/read`。 + +### 2.2 后续增强项 + +| 优先级 | 增强项 | 说明 | 建议验收点 | +| --- | --- | --- | --- | +| P0 | 编辑/撤回多端实时事件广播 | 当前 HTTP 更新 DB 并返回成功,后续需要向 Topic 在线成员广播编辑/撤回事件。 | A 端撤回/编辑后,B 端无需刷新即可收到事件并更新 UI。 | +| P0 | 编辑/撤回事件 Outbox | DB 更新和实时广播应通过事务事件或 Outbox 衔接,避免更新成功但事件丢失。 | 模拟广播失败后,后台任务可补发事件。 | +| P0 | 消息变更查询占位策略 | 已撤回消息在普通拉取中应返回撤回占位,而不是仅靠客户端本地事件。 | 新设备登录拉历史时能看到撤回占位和版本。 | +| P0 | `Idempotency-Key` 接入编辑/撤回 | 编辑/撤回目前依赖乐观版本控制,后续需要同时支持 HTTP 幂等键。 | 客户端超时重试同一请求不会重复写 revision。 | +| P1 | 群回执隐私和角色策略 | 当前返回基础 read/recv 列表,后续需按租户策略、群规模、角色决定是否展示具体成员。 | 普通成员/管理员在不同策略下看到不同粒度回执。 | +| P1 | 群回执 cursor 精确分页 | 当前 cursor 是基础位置分页,后续可升级为稳定游标,避免成员变动造成跳页。 | 成员新增/删除时分页不重复、不漏主要数据。 | +| P1 | `message_policy` 租户配置化 | 当前 recall/edit 窗口和菜单能力是固定值,后续需要来自租户配置。 | 不同租户可配置不同撤回/编辑时限和菜单能力。 | +| P1 | 系统通知可信生产通道 | 当前已提供通知存储和读取接口,后续需要统一服务端生产 API/事件消费者,禁止业务散落直写。 | 安全、群、组织等模块通过统一通道创建通知。 | +| P1 | 系统通知 action 白名单 | `action.type` 和参数需要统一注册、校验和版本化。 | 非白名单 action 写入失败;客户端只收到可识别跳转。 | +| P1 | 会话搜索排序与类型过滤 | 当前按现有订阅数据过滤,后续需支持会话类型、置顶、免打扰、最近活跃排序等。 | 搜索结果与消息首页排序、筛选规则一致。 | +| P2 | 离线重连恢复编排测试 | 需要端到端覆盖 `hi -> token login -> sub me -> 恢复 Topic -> get data`。 | 断网重连后未丢消息、未重复消息、未错乱回执。 | +| P2 | 消息 Header schema 版本演进 | 当前做基础格式/枚举校验,后续需要按 `x-im-schema` 版本维护兼容矩阵。 | 老客户端消息可兼容,新客户端非法字段被拒绝。 | + +## 3. 第三章:单聊关系、权限与会话上下文 + +### 3.1 当前已完成基线 + +1. 单聊会话上下文接口返回双方关系、发送能力、对端公开资料和 P2P Topic。 +2. P2P 发送路径接入好友关系校验,非好友、拉黑状态下禁止直接发送。 +3. 好友申请、通过、拒绝、删除好友与单聊关系状态联动。 +4. 拉黑/解除拉黑后关系上下文即时反映 `blocked_by_me`、`blocked_by_peer` 等状态。 +5. 会话侧可根据 `can_send` 锁定输入框,并引导重新添加好友。 + +### 3.2 后续增强项 + +| 优先级 | 增强项 | 说明 | 建议验收点 | +| --- | --- | --- | --- | +| P0 | P2P 策略统一入口 | 当前核心路径已校验好友/黑名单,后续需要把发消息、通话、名片、文件、历史查看统一收敛到同一策略服务。 | 任一 P2P 业务入口对同一关系状态返回一致权限。 | +| P0 | 关系变更实时同步 | 好友通过、删除、拉黑、解除拉黑后,应向双方在线端推送关系和会话能力变化。 | A 拉黑 B 后,B 端输入框无需刷新自动锁定。 | +| P0 | 关系事件 Outbox | 关系 DB 状态变化与系统通知、会话事件、Push 需要事务级衔接。 | 模拟推送失败后后台可补发,不产生重复事件。 | +| P1 | Pending 会话体验增强 | 待处理好友申请对应的单聊页应携带申请摘要、来源、过期时间和快速处理入口。 | 进入 pending 会话可直接通过/拒绝,不需要跳转新朋友页。 | +| P1 | 黑名单历史消息策略 | 当前发送/通话阻断已具备,后续需明确拉黑后历史消息、资料、文件和已读回执可见范围。 | 被拉黑人在不同策略下看到一致的历史与资料裁剪结果。 | +| P1 | 非好友资料隐私裁剪 | 单聊上下文中的对端资料需要接入隐私设置、企业可见性和青少年模式。 | 非好友只能看到允许公开字段;好友看到扩展字段。 | +| P1 | 好友关系审计 | 删除、拉黑、解除拉黑、申请处理等敏感关系操作写审计日志。 | 后台可按租户、操作者、目标用户查询操作流水。 | +| P2 | 关系状态迁移测试矩阵 | 需要覆盖 none、pending_inbound、pending_outbound、friend、blocked_by_me、blocked_by_peer 的全迁移。 | 任意状态迁移后 DB、HTTP 上下文、WS 权限一致。 | + +## 4. 第四章:群消息、群成员与 @ 能力 + +### 4.1 当前已完成基线 + +1. 群成员列表接口支持基础分页、搜索和 `mentionable` 过滤。 +2. 返回成员角色、公开资料、ACL 和是否可被 @。 +3. 群 @ 消息头部字段校验,支持 `mentioned_user_ids`、`mention_all` 等基础结构。 +4. 群 @ 权限接入成员 ACL,管理员/群主才具备 @ 全体能力。 +5. 群消息读取链路保留 @ 元数据,客户端可据此展示提醒。 + +### 4.2 后续增强项 + +| 优先级 | 增强项 | 说明 | 建议验收点 | +| --- | --- | --- | --- | +| P0 | @ 实时通知与离线 Push | 当前消息元数据已落库,后续需要对被 @ 成员生成高优先级提醒和离线 Push。 | 被 @ 用户在在线、离线、多端场景均收到一次提醒。 | +| P0 | @ 全体频控 | @ 全体需要按租户、群、角色、时间窗口限频,避免刷屏。 | 超限返回稳定错误码,管理员可配置限额。 | +| P0 | 群成员稳定游标分页 | 当前成员列表以基础 cursor 分页,后续应使用稳定游标避免成员变动跳页。 | 成员入群/退群时翻页不重复、不漏主要数据。 | +| P1 | 群成员资料隐私裁剪 | 群内非好友主页需要按群关系、好友关系、隐私设置裁剪资料字段。 | 同一成员从群内和好友主页进入时字段符合策略差异。 | +| P1 | 群昵称/角色变更事件 | 群昵称、角色、禁言、移除等变化需要实时推送给群成员并刷新成员列表缓存。 | 群主修改成员角色后在线端成员列表自动更新。 | +| P1 | @ 候选排序优化 | @ 面板候选应支持拼音首字母、最近互动、角色优先和本地缓存协同。 | 大群搜索响应稳定,候选排序符合产品规则。 | +| P1 | 群治理策略配置化 | 成员私聊、成员邀请、全员禁言、上传权限等应统一由群治理配置控制。 | 不同群可配置不同治理能力,HTTP 与 WS 权限一致。 | +| P2 | 大群性能压测 | 需要覆盖 500、2000、10000 成员下的成员列表、@ 搜索和发送性能。 | 达到目标延迟和资源占用阈值。 | + +## 5. 第五章:通讯录、好友关系与黑名单 + +### 5.1 当前已完成基线 + +1. 好友列表支持查询、首字母、分页,返回备注、头像、显示名来源和会话 Topic。 +2. 新朋友列表、好友申请、通过、拒绝链路已具备。 +3. 用户搜索支持账号、精准账号和手机号类型,返回公开资料、关系和 `action`。 +4. 添加好友二维码支持短期签名 Token 生成与解析。 +5. 好友个人主页返回关系、资料和操作能力。 +6. 群内非好友个人主页返回群角色、关系、资料和操作能力。 +7. 好友备注更新、删除好友、拉黑/解除拉黑已具备。 +8. 黑名单列表和批量移出黑名单已具备。 + +### 5.2 后续增强项 + +| 优先级 | 增强项 | 说明 | 建议验收点 | +| --- | --- | --- | --- | +| P0 | 好友/黑名单操作统一幂等 | 拉黑、解除、删除、批量移出、好友申请等需要统一接入 `Idempotency-Key` 存储和请求摘要。 | 客户端超时重试不会重复创建申请或重复触发事件。 | +| P0 | 好友关系实时事件 | 申请、通过、拒绝、删除、备注、拉黑等状态变化需要向相关在线端实时同步。 | 两端关系页、会话页和通讯录无需刷新自动更新。 | +| P0 | 用户搜索隐私策略 | 当前搜索基线可用,后续需要接入 `allow_user_search`、添加方式、手机号可搜索等租户和个人隐私设置。 | 禁止手机号添加的用户无法通过手机号被搜索到,且不泄露存在性。 | +| P0 | 二维码吊销与一次性策略 | 当前二维码是短期签名 Token,后续如需主动失效、刷新覆盖、扫码审计,需要落库或接入 Redis。 | 用户刷新二维码后旧码按策略失效;后台可查询扫码记录。 | +| P1 | 好友资料字段精细裁剪 | 个人主页需按好友、非好友、群内关系、黑名单、实名/青少年等规则返回不同字段。 | 不同入口返回字段符合隐私矩阵。 | +| P1 | 通讯录排序与拼音索引 | 当前支持基础 initial,后续需维护昵称/备注拼音、特殊字符分组和本地增量同步。 | 好友列表与客户端通讯录排序一致,中文首字母稳定。 | +| P1 | 黑名单占位资料 | 资料删除、离职或不可见时,黑名单列表仍需返回可解除的受控占位用户。 | 黑名单中失效用户可展示占位并成功移出。 | +| P1 | 好友申请防骚扰 | 需要按目标用户、申请人、IP、设备、来源和群来源限频,并支持拒收策略。 | 高频申请被限制;群来源申请校验双方仍在群内。 | +| P1 | 关系审计和安全通知 | 添加、删除、拉黑、解除、二维码解析等应写审计并按策略生成系统通知。 | 用户可在通知中看到关键关系事件,后台可追溯。 | +| P2 | 批量接口部分失败语义增强 | 批量移出黑名单目前返回逐项结果,后续需要统一批量接口错误码、重试建议和幂等结果缓存。 | 部分失败时客户端能只重试失败项。 | + +## 6. 第六章:群聊创建、资料维护与成员治理 + +### 6.1 当前已完成基线 + +1. 群列表、创建群、群资料查询与更新、群头像和群简介保存接口已具备。 +2. 群设置、个人群设置、退出群、群昵称接口已具备。 +3. 群二维码生成与扫码解析支持 `join_group` 场景。 +4. 群邀请、进群申请、申请列表、同意/拒绝、群 summary 申请计数已具备。 +5. 群公告最新查询、发布/更新、详情和已读接口已具备。 +6. 群治理配置 `GET/PATCH /governance` 已具备。 +7. 管理员候选、管理员上限、批量添加管理员、删除管理员和群主转让接口已具备。 +8. 禁言候选、批量禁言、禁言列表和解除禁言接口已具备。 +9. 当前实现优先复用 Topic、Subscription 和 Topic Aux,把产品主链路接口跑通。 + +### 6.2 后续增强项 + +| 优先级 | 增强项 | 说明 | 建议验收点 | +| --- | --- | --- | --- | +| P0 | 群业务独立表拆分 | 当前群公告、进群申请、治理配置、禁言记录先存 Topic Aux,后续需要拆为独立表。 | 公告、申请、治理、禁言均有 tenant/topic 维度索引,可稳定分页和审计。 | +| P0 | 群治理强事务状态机 | 邀请、申请审批、管理员变更、群主转让、禁言需在事务内同步业务表、订阅 ACL、成员数和事件。 | 并发审批/转让/禁言不会出现 ACL 与业务状态不一致。 | +| P0 | 第六章幂等键落库 | 邀请、申请处理、公告发布、治理 PATCH、管理员批量、群主转让、禁言批量都需要统一幂等。 | 超时重试同一请求返回同一结果,不重复加人、升权或发事件。 | +| P0 | 系统事件、Push 与 Outbox | 第六章所有成员/公告/治理变化需要生成可信群系统事件,并通过 Outbox 保证推送。 | 在线端实时刷新;模拟推送失败后可补偿且不重复。 | +| P1 | 群公告已读独立记录 | 当前公告已读返回基础结果,后续需记录每个成员的公告已读时间。 | 新公告只提醒未读成员,已读状态跨端同步。 | +| P1 | 进群申请隐私与限频 | 申请入口需要按群治理、租户策略、用户状态、来源、IP/设备限频。 | 高频申请被限制;普通成员不可看到敏感申请人信息。 | +| P1 | 管理员/群主角色层级策略 | 管理员互相操作、管理员禁言、群主转让二次认证等需要完整策略矩阵。 | 群主、管理员、普通成员在每个治理动作上的权限稳定一致。 | +| P1 | 禁言发送链路强校验 | 当前禁言接口已具备,后续需要在 WebSocket 发消息路径校验 `GROUP_MUTED`。 | 被禁言成员发群消息被服务端拒绝,过期后自动恢复。 | +| P1 | 群文件索引 | 6.27-6.28 需要独立群文件表,绑定上传文件、群成员权限、下载状态和删除权限。 | 群文件列表可搜索/分页,退群后不可下载旧 URL。 | +| P1 | 群解散与二维码吊销 | 群解散、二维码撤销、邀请失效需要状态联动和权限回收。 | 解散后 Topic、二维码、邀请、文件新增权限全部失效。 | +| P2 | 大群性能与稳定分页 | 成员、申请、公告、禁言、文件列表都需要稳定游标和大群压测。 | 大群场景下分页不重复不漏,接口延迟达标。 | + +## 7. 第七章:文件下载、收藏管理与聊天记录检索 + +### 7.1 当前已完成基线 + +1. 收藏创建、列表、详情和删除接口已具备,支持文本、文件、语音、链接等类型。 +2. 收藏批量创建和基础分享前的数据结构已具备。 +3. Topic 消息关键词搜索、文件/图片/视频等媒体类型检索接口已具备。 +4. 消息上下文定位接口可围绕指定 seq 返回前后消息。 +5. 文件访问已接入用户与 Topic 权限校验,下载前可判断资源可见性。 + +### 7.2 后续增强项 + +| 优先级 | 增强项 | 说明 | 建议验收点 | +| --- | --- | --- | --- | +| P0 | 收藏幂等与去重策略 | 收藏同一消息、同一文件或同一链接需要统一去重和幂等键处理。 | 重复点击收藏不会产生重复记录;超时重试返回同一收藏 ID。 | +| P0 | 收藏分享权限二次校验 | 收藏文件或消息分享给好友/群时,需要重新校验接收方是否可访问原资源或生成受控副本。 | 无权限接收方不能通过收藏分享拿到原会话附件。 | +| P0 | 文件下载短时签名 URL | 当前权限判断已具备,后续应生成短时下载 URL,并记录下载审计。 | URL 过期不可访问;退群/拉黑后旧 URL 失效或受策略限制。 | +| P0 | 文件安全扫描与 MIME 校验 | 上传后的文件需要扩展名、MIME、内容嗅探、病毒扫描和图片/视频解码安全检查。 | 高风险文件不可下载或转发,客户端收到稳定错误码。 | +| P1 | 收藏分类与全文索引 | 收藏列表需要按类型、来源、时间、关键词、发送人等维度检索。 | 大量收藏下搜索响应稳定,类型筛选结果准确。 | +| P1 | 聊天记录稳定游标 | 消息搜索和媒体列表需要稳定游标,避免新消息插入导致翻页重复或漏项。 | 搜索期间新增消息不影响当前翻页结果。 | +| P1 | 历史消息权限快照策略 | 用户退群、删除好友、拉黑后,历史消息和附件的可见范围需要统一规则。 | 各入口对同一历史资源权限判断一致。 | +| P1 | 下载状态与断点续传 | 大文件下载需要断点续传、失败重试、进度状态和下载完成事件。 | 网络中断后可续传;下载完成状态可跨端同步或按端隔离。 | +| P2 | 聊天记录高亮与命中片段 | 搜索结果可返回命中摘要、高亮范围和消息类型摘要。 | 客户端无需二次解析即可展示命中片段。 | +| P2 | 收藏导出与批量管理 | 后续可支持收藏批量删除、批量转发、导出和云端容量统计。 | 批量操作有部分失败语义和可重试结果。 | + +## 8. 第八章:实时语音与视频通话 + +### 8.1 当前已完成基线 + +1. 通话 invite、accept、offer、answer、ice-candidate、hang-up 等事件沿用 Tinode call event 链路。 +2. 通话记录表 `im_call_records` 已具备,记录 `call_id`、Topic、seq、主叫/被叫、类型、状态、时长和结束原因。 +3. 通话记录创建、接听和结束状态更新已接入 store/adapter。 +4. 通话记录查询接口已具备:全局 `GET /v1/im/calls` 与 Topic 内 `GET /v1/im/topics/{topic}/calls`。 +5. 查询结果返回对端用户、方向、通话类型、状态、时长和关键时间点,客户端可先完成通话记录页对接。 + +### 8.2 后续增强项 + +| 优先级 | 增强项 | 说明 | 建议验收点 | +| --- | --- | --- | --- | +| P0 | 跨设备抢答状态机 | 同一账号多端收到来电时,需要一个设备接听后自动取消其他端待接状态。 | A 手机接听后,A 平板和 Web 端立即变为已在其他设备接听。 | +| P0 | 通话状态强一致与幂等 | invite、accept、hang-up 等事件需要幂等键或事件序号,避免乱序/重复导致状态回退。 | 重复 hang-up 不改变最终状态;迟到 accept 不会覆盖已结束通话。 | +| P0 | 通话事件 Outbox 与通知 | 来电、未接、挂断、忙线等需要可信事件和 Push,DB 状态与通知通过 Outbox 衔接。 | 离线用户收到一次来电/未接提醒,失败可补偿。 | +| P0 | TURN/STUN 配置与媒体准入 | 服务端应按租户/区域返回可用 ICE 配置,并限制未授权用户创建媒体会话。 | 客户端拉取到有效 TURN;无权限通话被服务端拒绝。 | +| P1 | 结束原因标准化 | 需要统一 `missed`、`declined`、`busy`、`timeout`、`network_error` 等原因和客户端展示文案。 | 不同端同一结束原因展示一致,统计口径一致。 | +| P1 | 通话质量指标 | 需要采集建立耗时、ICE 成功率、丢包、抖动、断连等指标。 | 后台可按租户和版本查看通话质量趋势。 | +| P1 | 群通话/会议房间拆分 | 群音视频不宜复用 P2P call,需要会议房间、参与人状态和 SFU 接入。 | 群通话有独立 room_id、成员列表和退出/重进状态。 | +| P1 | 通话权限统一策略 | 通话入口应复用 P2P/群关系策略、黑名单、禁言、青少年模式和企业策略。 | 被拉黑、非好友或受限制用户无法发起通话。 | +| P2 | 通话记录聚合与清理 | 通话记录可按会话、日期、未接分类聚合,并支持保留周期清理。 | 记录列表分页稳定,超过保留期按策略清理。 | + +## 9. 第九章:企业工作台与办公协同 + +### 9.1 当前已完成基线 + +1. 工作台接口 `GET /v1/im/workbench` 已具备,返回模块、布局版本和企业功能开关。 +2. 工作台模块表 `im_workbench_apps` 已具备,默认包含组织架构、会议通知、日报、月报、总经理信箱入口。 +3. 服务端对工作台 route 做内部白名单过滤,避免客户端执行任意服务端 URL。 +4. 组织架构部门表 `im_org_departments` 和成员表 `im_org_members` 已具备。 +5. 部门列表 `GET /v1/im/organization/departments`、部门成员 `GET /v1/im/organization/departments/{id}/members`、组织搜索 `GET /v1/im/organization/search` 已具备。 +6. 会议通知 `POST/GET/PATCH/CANCEL/REMIND/MY-RESPONSE` 已具备基线,包含参与人和版本冲突控制。 +7. 日报/月报草稿、提交、详情、列表、统计与提醒基线已具备。 +8. 总经理信箱配置与提交接口已具备,包含匿名策略、文案版本和幂等键。 + +### 9.2 后续增强项 + +| 优先级 | 增强项 | 说明 | 建议验收点 | +| --- | --- | --- | --- | +| P0 | 工作台权限与功能开关配置化 | 当前模块基线可返回,后续需要按租户、部门、角色、后台配置动态裁剪。 | 不同用户登录同一租户看到不同授权模块。 | +| P0 | 工作台未读角标事件 | 文档要求 `workbench.badge.updated`,后续需要模块未读聚合和实时增量事件。 | 会议、日报、信箱未读变化后工作台角标实时更新。 | +| P0 | 组织数据后台维护链路 | 当前前台读接口和表已具备,后续需要后台导入、增删改部门、调整成员和离职状态。 | 后台修改组织后前台接口版本变化,客户端可刷新。 | +| P0 | 组织权限与可见字段裁剪 | 成员手机号、邮箱、职位等字段需要按组织角色、隐私和企业策略裁剪。 | 普通员工和管理员看到的成员字段不同且不可绕过。 | +| P1 | 组织搜索索引优化 | 当前基础 LIKE 搜索可用,后续需要拼音、工号、手机号脱敏匹配和稳定排序。 | 大组织下搜索延迟达标,结果排序符合组织规则。 | +| P1 | 组织版本与增量同步 | 部门和成员返回版本,后续需要全局组织版本、变更事件和客户端增量刷新。 | 切换部门或人员变更后客户端无需全量重拉。 | +| P1 | 会议通知实时提醒 | 当前接口和表已具备,后续需要会议创建、更新、取消、响应后的在线事件和 Push。 | 参与人在线端无需刷新即可收到会议变化。 | +| P1 | 日报/月报后台统计口径 | 当前基础统计可用,后续需要按部门、周期、迟交、补交、未读等维度扩展。 | 管理员看板与业务口径一致。 | +| P1 | 总经理信箱后台流转 | 当前前台配置/提交已具备,后续需要后台处理、回复、转派、归档和通知。 | 信件全生命周期可追踪,匿名身份受保护。 | +| P1 | 办公协同审计 | 组织查询、会议变更、报告查看、信箱提交等敏感动作需要审计。 | 后台可按租户、用户、对象查询办公操作流水。 | +| P2 | 办公模块统一附件策略 | 会议、日报、月报、信箱附件需要复用第七章文件 ACL、用途和病毒扫描。 | 附件上传、查看、下载权限与业务状态一致。 | + +## 10. 个人设置与客户端能力 + +### 10.1 当前已完成基线 + +1. 个人资料 `GET/PATCH /v1/im/me/profile`、个人页 `GET /v1/im/me` 已具备。 +2. 隐私设置 `GET/PATCH /v1/im/me/privacy` 已具备,支持版本冲突控制。 +3. 最新版本检查 `GET /v1/im/app/releases/latest` 已具备,支持未登录按租户解析调用。 +4. 青年模式查询、开启、关闭接口已具备,复用敏感操作 `action_token` 和独立密码校验链路。 +5. 设备 Push Token 覆盖写入 `PUT /v1/im/devices/{device_id}/push-token` 已具备。 + +### 10.2 后续增强项 + +| 优先级 | 增强项 | 说明 | 建议验收点 | +| --- | --- | --- | --- | +| P0 | 个人资料变更实时同步 | 资料、头像、隐私变更后需要向多端推送 `profile.updated`、`privacy.updated`。 | 同账号多端无需重登即可刷新设置。 | +| P0 | 精准登出与会话撤销 | 现有 token 主要靠 `auth_version` 批量失效,后续需要 access/refresh/session 表支持当前设备登出和指定 session 删除。 | 当前设备退出不影响其他设备;删除指定 session 后该设备 token 立即失效。 | +| P0 | 青年模式策略配置化 | 当前返回默认 restrictions,后续需要后台按租户配置可用时段、内容限制、消费保护等规则。 | 不同租户返回不同限制策略且能热更新。 | +| P0 | Push Token 生命周期 | 当前可覆盖写入,后续需要 token 失效回收、设备解绑、平台厂商错误码处理。 | 推送失败后自动禁用无效 token,重新登录可恢复。 | +| P1 | 版本灰度发布 | 当前按 platform/channel 查最新版本,后续需要按租户、用户、灰度比例、最低版本强制升级。 | 灰度用户收到新版本,非灰度用户保持稳定版本。 | +| P1 | 资料隐私裁剪落地 | 隐私设置需要接入用户搜索、组织架构、会话资料页等读路径。 | 非授权用户无法通过其他接口绕过隐私配置。 | +| P2 | 客户端能力上报 | 后续增加设备能力、通知权限、厂商通道状态和前台活跃状态上报。 | 后台可定位推送不可达和版本兼容问题。 | + +## 11. 反诈安全、举报与意见反馈 + +### 11.1 当前已完成基线 + +1. URL 安全检查 `POST /v1/im/security/urls/check` 已接入风控 challenge 创建。 +2. 风控确认 `POST /v1/im/security/risk-challenges/{id}/confirm` 已具备,`continue` 返回短时 `risk_action_token`。 +3. 举报原因 `GET /v1/im/reports/abuse/reasons` 已具备,默认覆盖用户、群、消息目标。 +4. 举报提交 `POST /v1/im/reports/abuse` 已具备,支持幂等键、证据文件 ID、目标基础可见性校验和审核快照。 +5. 意见反馈配置 `GET /v1/im/feedback/config`、提交 `POST /v1/im/feedback` 已具备,支持附件规则、诊断授权和幂等键。 + +### 11.2 后续增强项 + +| 优先级 | 增强项 | 说明 | 建议验收点 | +| --- | --- | --- | --- | +| P0 | 风控策略引擎 | 当前 URL 检查仅基础规则,后续需要按动作、关系、内容、设备、频率和租户策略综合判定。 | 高风险发消息、加好友、打开链接等动作统一返回风控 challenge。 | +| P0 | 风控 token 消费接入业务接口 | 当前确认可签发 `risk_action_token`,后续需要原业务接口校验并单次消费该 token。 | 二次确认后只允许重试原动作一次,重复使用返回 Token 已消费。 | +| P0 | 举报目标完整权限校验 | 当前做基础可见性校验,后续需要结合用户侧删除、撤回、退群、黑名单、文件 ACL 和审核状态。 | 无权限目标无法提交举报,也不泄露目标存在性。 | +| P0 | 审核后台流转 | 举报和反馈当前仅 received,后续需要后台分派、处理、关闭、回复和通知。 | 处理状态可追踪,举报人按策略收到处理结果。 | +| P1 | 证据文件安全与保留期 | 证据文件需要 `abuse_evidence` 专用 ACL、病毒扫描、不可转发和保留期策略。 | 证据只能审核链路访问,过期按政策清理。 | +| P1 | 反诈系统事件可信生产 | 低风险提示需通过可信 `security.risk_notice` 系统事件发送,并限制普通消息伪装。 | 普通用户无法伪造反诈弹窗,客户端只信任系统事件。 | +| P1 | 举报合并与限频 | 重复举报需要合并、返回原受理号或限频错误,避免刷单。 | 短时间重复举报同一目标不会生成大量工单。 | +| P2 | 反馈诊断包脱敏 | 诊断包需统一脱敏、大小限制、客户端日志授权和服务端下载审计。 | 未同意时没有诊断包,同意后包内不含明文 token/手机号。 | + +## 12. 统一处理建议 + +1. 先做 P0:统一幂等、实时事件/Outbox、验证码真实发送与限频、P2P/好友/搜索隐私策略、第六章群业务独立表与事务状态机、文件下载签名、通话强状态机、工作台/组织权限。 +2. 再做 P1:租户策略配置、系统通知生产通道、实名审核、设备管理、资料隐私裁剪、关系审计、群禁言发送校验、群文件索引、通话质量指标、会议/日报/月报/信箱完整业务表。 +3. 最后做 P2:端到端重连测试、schema 版本演进、大群压测、批量接口体验优化、收藏导出、通话记录聚合和办公模块附件统一策略。 +4. 每个增强项落地前同步更新 `IM-系统设计文档.md` 和 `IM-前后端对接文档.md` 中对应章节状态。 diff --git a/docs/im/IM-第一至七章数据库增量审核.sql b/docs/im/IM-第一至七章数据库增量审核.sql new file mode 100644 index 0000000..bb6c62b --- /dev/null +++ b/docs/im/IM-第一至七章数据库增量审核.sql @@ -0,0 +1,316 @@ +-- IM 第一至七章数据库增量审核稿 +-- 仅供审核,不要直接在生产库盲执行。 +-- 适用目标:已有 Tinode MySQL 库,补齐本项目 1-7 章业务层依赖表与关键字段。 +-- 注意:本稿使用 MySQL 8 functional index 处理空 idempotency_key 唯一性。 +-- 第一阶段多库方案:本 SQL 的 im_tenant 放 hotgo 公共库;tinode/各租户业务库表暂时保留 tenant_id 字段。 + +SET NAMES utf8mb4; + +-- hotgo 公共后台库需要先存在;如果你审核通过,可先执行: +-- CREATE DATABASE IF NOT EXISTS hotgo CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci; +-- USE hotgo; +-- +-- 租户业务库 tinode 也需要存在并完成 Tinode 基础表初始化: +-- CREATE DATABASE IF NOT EXISTS tinode CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci; + +-- ============================================================ +-- 1. 租户、认证、安全基础表 +-- ============================================================ + +CREATE TABLE IF NOT EXISTS im_tenant( + id BIGINT NOT NULL AUTO_INCREMENT, + code VARCHAR(64) NOT NULL, + name VARCHAR(128) NOT NULL, + tenant_desc VARCHAR(256), + db_name VARCHAR(128) NOT NULL DEFAULT '' COMMENT 'tenant business database name, e.g. tinode_acme', + db_dsn VARCHAR(1024) NOT NULL DEFAULT '' COMMENT 'optional full tenant DB DSN, empty means reuse base MySQL config', + state SMALLINT NOT NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + created_by BIGINT NOT NULL, + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + updated_by BIGINT NOT NULL, + PRIMARY KEY(id), + UNIQUE INDEX uk_im_tenant_code(code) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +INSERT INTO im_tenant(code,name,tenant_desc,db_name,db_dsn,state,created_by,updated_by) +VALUES('default','Default Tenant',NULL,'tinode','',1,0,0) +ON DUPLICATE KEY UPDATE name=VALUES(name), db_name=VALUES(db_name), updated_at=CURRENT_TIMESTAMP(3); + +CREATE TABLE IF NOT EXISTS im_tenant_config( + id BIGINT NOT NULL AUTO_INCREMENT, + tenant_id BIGINT NOT NULL, + max_users BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '0 means unlimited', + max_groups BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '0 means unlimited', + max_group_members INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '0 means unlimited', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + created_by BIGINT NOT NULL, + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + updated_by BIGINT NOT NULL, + PRIMARY KEY(id), + UNIQUE INDEX uk_im_tenant_config_tenant_id(tenant_id), + CONSTRAINT fk_im_tenant_config_tenant FOREIGN KEY(tenant_id) REFERENCES im_tenant(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS im_verification_codes( + id BIGINT NOT NULL AUTO_INCREMENT, + tenant_id BIGINT NOT NULL, + challenge_id VARCHAR(64) NOT NULL, + scene VARCHAR(32) NOT NULL, + action VARCHAR(64) NOT NULL DEFAULT '', + phone_hash CHAR(64) NOT NULL, + phone_mask VARCHAR(32) NOT NULL, + target_user BIGINT, + code_hash CHAR(64) NOT NULL, + verification_token_hash CHAR(64), + state SMALLINT NOT NULL, + attempts INT NOT NULL DEFAULT 0, + max_attempts INT NOT NULL, + expires_at DATETIME(3) NOT NULL, + resend_after DATETIME(3) NOT NULL, + verified_at DATETIME(3), + consumed_at DATETIME(3), + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY(id), + UNIQUE INDEX uk_im_verification_tenant_challenge(tenant_id, challenge_id), + INDEX idx_im_verification_tenant_scene_phone_state(tenant_id, scene, phone_hash, state, expires_at), + CONSTRAINT fk_im_verification_tenant FOREIGN KEY(tenant_id) REFERENCES im_tenant(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS im_user_security( + tenant_id BIGINT NOT NULL, + user_id BIGINT NOT NULL, + auth_version INT NOT NULL DEFAULT 1, + password_updated_at DATETIME(3), + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY(tenant_id,user_id), + CONSTRAINT fk_im_user_security_tenant FOREIGN KEY(tenant_id) REFERENCES im_tenant(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS im_realname_verifications( + id BIGINT NOT NULL AUTO_INCREMENT, + tenant_id BIGINT NOT NULL, + user_id BIGINT NOT NULL, + submission_id VARCHAR(64) NOT NULL, + status VARCHAR(16) NOT NULL, + name_cipher TEXT NOT NULL, + id_type VARCHAR(32) NOT NULL, + id_number_cipher TEXT NOT NULL, + id_number_hash CHAR(64) NOT NULL, + masked_name VARCHAR(64) NOT NULL, + masked_id_number VARCHAR(64) NOT NULL, + review_reason VARCHAR(256), + verified_at DATETIME(3), + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY(id), + UNIQUE INDEX uk_im_realname_submission(tenant_id, submission_id), + INDEX idx_im_realname_user_latest(tenant_id, user_id, created_at), + INDEX idx_im_realname_id_hash(tenant_id, id_number_hash), + CONSTRAINT fk_im_realname_tenant FOREIGN KEY(tenant_id) REFERENCES im_tenant(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS im_action_tokens( + id BIGINT NOT NULL AUTO_INCREMENT, + tenant_id BIGINT NOT NULL, + user_id BIGINT NOT NULL, + token_id VARCHAR(64) NOT NULL, + action VARCHAR(64) NOT NULL, + target VARCHAR(128) NOT NULL DEFAULT '', + token_hash CHAR(64) NOT NULL, + state SMALLINT NOT NULL, + expires_at DATETIME(3) NOT NULL, + consumed_at DATETIME(3), + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY(id), + UNIQUE INDEX uk_im_action_tokens_token(tenant_id, token_id), + INDEX idx_im_action_tokens_user_action(tenant_id, user_id, action, target, state, expires_at), + CONSTRAINT fk_im_action_tokens_tenant FOREIGN KEY(tenant_id) REFERENCES im_tenant(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS im_youth_mode_settings( + tenant_id BIGINT NOT NULL, + user_id BIGINT NOT NULL, + purpose VARCHAR(64) NOT NULL, + password_hash VARBINARY(255) NOT NULL, + failed_attempts INT NOT NULL DEFAULT 0, + locked_until DATETIME(3), + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY(tenant_id, user_id, purpose), + CONSTRAINT fk_im_youth_settings_tenant FOREIGN KEY(tenant_id) REFERENCES im_tenant(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS im_system_notifications( + id BIGINT NOT NULL AUTO_INCREMENT, + tenant_id BIGINT NOT NULL, + user_id BIGINT NOT NULL, + event_id VARCHAR(64) NOT NULL, + event_type VARCHAR(64) NOT NULL, + title VARCHAR(128) NOT NULL, + summary VARCHAR(512) NOT NULL, + action JSON, + read_at DATETIME(3), + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY(id), + UNIQUE INDEX uk_im_system_notifications_event(tenant_id, user_id, event_id), + INDEX idx_im_system_notifications_inbox(tenant_id, user_id, id DESC), + CONSTRAINT fk_im_system_notifications_tenant FOREIGN KEY(tenant_id) REFERENCES im_tenant(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +-- ============================================================ +-- 2. 好友、黑名单 +-- ============================================================ + +CREATE TABLE IF NOT EXISTS im_friend_requests( + id BIGINT NOT NULL AUTO_INCREMENT, + tenant_id BIGINT NOT NULL, + request_id VARCHAR(64) NOT NULL, + applicant_id BIGINT NOT NULL, + target_id BIGINT NOT NULL, + message VARCHAR(200) NOT NULL DEFAULT '', + source VARCHAR(32) NOT NULL DEFAULT '', + source_topic VARCHAR(64) NOT NULL DEFAULT '', + status VARCHAR(16) NOT NULL, + idempotency_key VARCHAR(128) NOT NULL DEFAULT '', + expires_at DATETIME(3) NOT NULL, + handled_at DATETIME(3), + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY(id), + UNIQUE INDEX uk_im_friend_requests_request(tenant_id, request_id), + INDEX idx_im_friend_requests_pair(tenant_id, applicant_id, target_id, status, created_at), + INDEX idx_im_friend_requests_inbox(tenant_id, target_id, status, created_at), + CONSTRAINT fk_im_friend_requests_tenant FOREIGN KEY(tenant_id) REFERENCES im_tenant(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE UNIQUE INDEX uk_im_friend_requests_idempotency + ON im_friend_requests(tenant_id, applicant_id, (NULLIF(idempotency_key, ''))); + +CREATE TABLE IF NOT EXISTS im_friendships( + tenant_id BIGINT NOT NULL, + user_id BIGINT NOT NULL, + peer_id BIGINT NOT NULL, + status VARCHAR(16) NOT NULL, + remark VARCHAR(64) NOT NULL DEFAULT '', + topic CHAR(25) NOT NULL DEFAULT '', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY(tenant_id, user_id, peer_id), + INDEX idx_im_friendships_peer(tenant_id, peer_id, status), + CONSTRAINT fk_im_friendships_tenant FOREIGN KEY(tenant_id) REFERENCES im_tenant(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS im_user_blocks( + tenant_id BIGINT NOT NULL, + blocker_id BIGINT NOT NULL, + blocked_id BIGINT NOT NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY(tenant_id, blocker_id, blocked_id), + INDEX idx_im_user_blocks_blocked(tenant_id, blocked_id), + CONSTRAINT fk_im_user_blocks_tenant FOREIGN KEY(tenant_id) REFERENCES im_tenant(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +-- ============================================================ +-- 3. 收藏、转发、消息增强 +-- ============================================================ + +CREATE TABLE IF NOT EXISTS im_favorites( + id BIGINT NOT NULL AUTO_INCREMENT, + tenant_id BIGINT NOT NULL, + favorite_id VARCHAR(64) NOT NULL, + user_id BIGINT NOT NULL, + item_type VARCHAR(16) NOT NULL, + source_topic CHAR(25) NOT NULL, + source_seq INT NOT NULL, + snapshot JSON, + attachment_refs JSON, + availability VARCHAR(16) NOT NULL DEFAULT 'available', + idempotency_key VARCHAR(128) NOT NULL DEFAULT '', + collected_at DATETIME(3) NOT NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY(id), + UNIQUE INDEX uk_im_favorites_favorite(tenant_id, favorite_id), + UNIQUE INDEX uk_im_favorites_source(tenant_id, user_id, source_topic, source_seq), + INDEX idx_im_favorites_inbox(tenant_id, user_id, id DESC), + INDEX idx_im_favorites_type(tenant_id, user_id, item_type, id DESC), + CONSTRAINT fk_im_favorites_tenant FOREIGN KEY(tenant_id) REFERENCES im_tenant(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE UNIQUE INDEX uk_im_favorites_idempotency + ON im_favorites(tenant_id, user_id, (NULLIF(idempotency_key, ''))); + +CREATE TABLE IF NOT EXISTS im_message_revisions( + id BIGINT NOT NULL AUTO_INCREMENT, + tenant_id BIGINT NOT NULL, + createdat DATETIME(3) NOT NULL, + updatedat DATETIME(3) NOT NULL, + topic CHAR(25) NOT NULL, + seqid INT NOT NULL, + version INT NOT NULL, + actor BIGINT NOT NULL, + head JSON, + content JSON, + reason VARCHAR(256) NOT NULL DEFAULT '', + PRIMARY KEY(id), + UNIQUE INDEX uk_im_message_revisions_version(tenant_id, topic, seqid, version), + CONSTRAINT fk_im_message_revisions_tenant FOREIGN KEY(tenant_id) REFERENCES im_tenant(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +-- ============================================================ +-- 4. Tinode 基础表需补齐的字段/索引 +-- 如果你的库是用当前 schema.sql 初始化,这些已经存在。 +-- 如果是旧库,需要逐项核对后再 ALTER。 +-- ============================================================ + +-- users:租户隔离字段和索引。 +-- ALTER TABLE users ADD COLUMN tenant_id BIGINT NOT NULL DEFAULT 1 AFTER id; +-- CREATE INDEX users_tenant_id_id ON users(tenant_id, id); + +-- topics:租户隔离、群治理 Aux、租户维度唯一索引。 +-- 第六章群邀请、入群审批、群公告、群治理配置、成员禁言目前存放在 topics.aux JSON。 +-- 后续若要强状态机和审计,应拆 im_group_join_requests、im_group_announcements、im_group_mutes 等表。 +-- ALTER TABLE topics ADD COLUMN tenant_id BIGINT NOT NULL DEFAULT 1 AFTER id; +-- ALTER TABLE topics ADD COLUMN aux JSON AFTER tags; +-- CREATE UNIQUE INDEX topics_tenant_name ON topics(tenant_id, name); +-- CREATE INDEX topics_tenant_owner ON topics(tenant_id, owner); + +-- subscriptions:租户字段和按租户查成员索引。 +-- ALTER TABLE subscriptions ADD COLUMN tenant_id BIGINT NOT NULL DEFAULT 1 AFTER id; +-- CREATE INDEX subscriptions_tenant_userid ON subscriptions(tenant_id, userid); +-- CREATE INDEX subscriptions_tenant_topic ON subscriptions(tenant_id, topic); +-- CREATE INDEX subscriptions_userid_topic_deletedat ON subscriptions(userid, topic, deletedat); + +-- messages:租户、客户端消息幂等、编辑撤回字段、搜索/文件授权需要的索引。 +-- ALTER TABLE messages ADD COLUMN tenant_id BIGINT NOT NULL DEFAULT 1 AFTER id; +-- ALTER TABLE messages ADD COLUMN client_msg_id VARCHAR(64) AFTER `from`; +-- ALTER TABLE messages ADD COLUMN version INT NOT NULL DEFAULT 1 AFTER client_msg_id; +-- ALTER TABLE messages ADD COLUMN recalled_at DATETIME(3) AFTER version; +-- ALTER TABLE messages ADD COLUMN recalled_by BIGINT AFTER recalled_at; +-- CREATE INDEX messages_tenant_topic ON messages(tenant_id, topic); +-- CREATE UNIQUE INDEX messages_sender_client_mid ON messages(tenant_id, `from`, client_msg_id); + +-- dellog:用户侧删除过滤需要 tenant_id。 +-- ALTER TABLE dellog ADD COLUMN tenant_id BIGINT NOT NULL DEFAULT 1 AFTER id; +-- CREATE INDEX dellog_tenant_topic ON dellog(tenant_id, topic); +-- CREATE INDEX dellog_topic_deletedfor_low_hi ON dellog(topic, deletedfor, low, hi); + +-- fileuploads:文件 ACL、租户隔离、ETag。 +-- ALTER TABLE fileuploads ADD COLUMN tenant_id BIGINT NOT NULL DEFAULT 1 AFTER id; +-- ALTER TABLE fileuploads ADD COLUMN etag VARCHAR(128) AFTER size; +-- CREATE INDEX fileuploads_tenant_status ON fileuploads(tenant_id, status); + +-- filemsglinks:文件资源级 ACL 需要租户、topic 和 userid 引用。 +-- ALTER TABLE filemsglinks ADD COLUMN tenant_id BIGINT NOT NULL DEFAULT 1 AFTER id; +-- ALTER TABLE filemsglinks ADD COLUMN topic CHAR(25) AFTER msgid; +-- ALTER TABLE filemsglinks ADD COLUMN userid BIGINT AFTER topic; +-- CREATE INDEX filemsglinks_tenant_fileid ON filemsglinks(tenant_id, fileid); + +-- usertags/topictags/devices/auth/credentials 也已按租户增加 tenant_id; +-- 若旧库缺字段,需要参照当前 server/db/mysql/schema.sql 逐项补齐。 diff --git a/docs/im/IM-系统设计文档.md b/docs/im/IM-系统设计文档.md new file mode 100644 index 0000000..7c53433 --- /dev/null +++ b/docs/im/IM-系统设计文档.md @@ -0,0 +1,898 @@ +# IM 系统设计文档 + +> 版本:v1.1
+> 日期:2026-08-11
+> 状态:后续开发基线
+> 需求依据:[IM 产品原型需求文档](IM-产品原型需求文档.md)
+> 关联文档:[IM 产品需求服务端改造分析报告](IM-产品需求服务端改造分析报告.md)、[IM 现有实现与新需求差异对照总结](IM-现有实现与新需求差异对照总结.md)、[IM 服务端多租户改造方案](IM-服务端多租户改造方案.md) + +| 版本 | 日期 | 变更 | +| --- | --- | --- | +| v1.0 | 2026-08-11 | 建立总体架构和业务模块设计 | +| v1.1 | 2026-08-11 | 冻结 MySQL 范围;补齐认证、配额、消息变更、好友/群真源、审核、事务、Outbox、搜索和测试设计 | + +## 1. 文档目的 + +本文作为后续 IM 产品开发的系统设计指导文件,用于统一服务端、客户端和测试团队对产品架构、模块边界、数据模型、接口风格、权限规则、消息事件和开发顺序的理解。 + +本文不替代详细接口文档和数据库 DDL。后续每个业务模块进入开发前,应在本文定义的边界内补充独立的 API 文档、状态机文档、表结构 SQL 和测试用例。 + +本文是产品业务开发的设计基线。关联分析报告用于记录调研过程;表名、唯一约束、状态机或技术选择与本文冲突时,以本文为准。设计发生变化时,必须先修改本文并记录版本,再修改接口和代码。 + +## 2. 设计目标 + +系统面向企业内部沟通与协作场景,核心目标如下: + +1. 支持企业码进入租户,所有账号、好友、群、消息、文件、组织和办公数据均按 `tenant_id` 强隔离。 +2. 复用现有 Tinode 实时通信内核,保留 WebSocket 消息、Topic、Subscription、回执、文件、Push 和 WebRTC 信令能力。 +3. 在实时内核外新增产品业务层,承载好友关系、群治理、收藏、搜索、组织、会议、日报、月报、实名、青少年、举报和反诈等业务。 +4. 服务端必须成为权限和状态机的唯一可信执行方,客户端只负责交互展示和本地体验优化。 +5. 所有敏感动作具备幂等、防重、审计和可测试的错误码。 +6. 支持 iOS、Android 多端一致同步,网络断开后可增量恢复。 + +### 2.1 首期技术范围 + +1. 产品版首期只支持 MySQL 8,共享服务、共享数据库,通过 `tenant_id` 隔离租户。 +2. PostgreSQL、MongoDB 和 RethinkDB 不在首期产品业务支持范围内。启用产品模块但 Adapter 未实现完整租户业务接口时,服务必须拒绝启动,不能静默退回无租户接口。 +3. 实时消息继续使用现有 Tinode WebSocket 协议;事务型产品业务使用 HTTP API。 +4. 搜索首期使用 MySQL,达到第 11.3 节定义的切换条件后再引入独立搜索引擎。 + +## 3. 总体架构 + +系统采用“实时通信内核 + 产品业务服务 + 异步事件”的分层架构。 + +```mermaid +flowchart TB + APP["iOS / Android 客户端"] + + subgraph Gateway["接入层"] + WS["WebSocket 实时协议"] + API["HTTP /v1/im/* 业务 API"] + FILE["HTTP 文件上传下载"] + end + + subgraph Core["通信内核"] + SESSION["Session"] + HUB["Hub / Topic Actor"] + STORE["Tenant Store"] + CALL["WebRTC Call Signaling"] + end + + subgraph Product["产品业务层"] + POLICY["策略引擎"] + REL["好友与黑名单"] + GROUP["群治理"] + FAV["收藏与转发"] + SEARCH["搜索索引"] + ORG["组织与工作台"] + SAFE["实名 / 青少年 / 举报 / 反诈"] + end + + subgraph Infra["基础设施"] + DB["MySQL"] + CACHE["内存缓存 / 持久化缓存"] + OUTBOX["Outbox 异步事件"] + PUSH["Push / 短信"] + MEDIA["对象存储"] + PLUGIN["审核 / 风控插件"] + end + + APP --> WS + APP --> API + APP --> FILE + WS --> SESSION --> HUB --> STORE --> DB + API --> Product --> DB + FILE --> MEDIA + HUB --> CALL + HUB --> POLICY + POLICY --> REL + POLICY --> GROUP + POLICY --> SAFE + Product --> OUTBOX --> PUSH + OUTBOX --> HUB + POLICY --> PLUGIN +``` + +## 4. 核心分层职责 + +| 层 | 职责 | 禁止事项 | +| --- | --- | --- | +| 接入层 | 解析 Token、Session、文件认证和租户上下文;做基础参数校验 | 不从客户端自报字段信任内部 `tenant_id` | +| 通信内核 | 维护连接、Topic 路由、消息顺序、广播、回执、Push 触发和通话信令 | 不承载复杂审批、报表、组织树等事务业务 | +| 产品策略层 | 发消息前校验好友、黑名单、群禁言、青少年、文件权限和内容风险 | 不把策略只放客户端 | +| 业务服务层 | 处理好友申请、群审批、公告、收藏、组织、会议、日报、举报等 CRUD 和状态机 | 不把主数据塞进 Topic Public/Aux 任意 JSON | +| 存储层 | 所有业务表带 `tenant_id`,更新删除必须带租户条件 | 不保留无租户业务查询入口 | +| 异步层 | 系统消息、Push、搜索索引、审核任务、定时提醒和审计导出 | 不在 Topic 事件循环中执行耗时远程调用;核心审计记录仍在业务事务内落库 | + +## 5. 多租户基础规则 + +多租户是本系统的底座,后续所有业务模块必须遵守: + +1. 首次 `hi.tenant` 使用企业码解析租户,Session 固定 `TenantID`。 +2. Token、认证记录、设备、Push、文件和插件调用均携带服务端可信 `TenantID`。 +3. 普通业务 Store 只能通过租户作用域访问,禁止全局查用户、Topic、消息、文件。 +4. 新增租户业务表必须包含 `tenant_id BIGINT NOT NULL`。平台级表和租户根表 `im_tenant` 例外;例外表必须在 DDL 评审中明确说明作用域。 +5. 唯一索引优先使用 `(tenant_id, business_key)`。 +6. 子表外键优先使用 `(tenant_id, parent_id)` 复合约束。 +7. 跨租户通信首期禁止;搜索、加好友、入群、转发和文件访问均限制在当前租户。 +8. 越权访问统一返回 NotFound/Denied,不泄露目标对象是否存在于其他租户。 + +### 5.1 租户配置与配额 + +`im_tenant_config` 是租户配额的配置真源,首期使用以下字段: + +| 字段 | 含义 | `0` 的含义 | +| --- | --- | --- | +| `max_users` | 租户有效用户数上限 | 不限制 | +| `max_groups` | 租户有效群数上限 | 不限制 | +| `max_group_members` | 单群有效成员数上限 | 不限制 | + +配额必须由服务端在事务内强制执行: + +1. 注册或启用用户时锁定租户配置记录,统计有效用户后再创建账号。 +2. 创建群时锁定租户配置记录,统计有效群后再创建 Topic 和群业务数据。 +3. 邀请、扫码入群或审批入群时锁定目标群业务记录,统计有效成员并校验 `max_group_members` 后再写成员关系和 Subscription。 +4. 配额检查和资源创建必须在同一事务中完成,禁止先查询、提交后再创建。 +5. 超限返回稳定错误码,并记录 `tenant_id`、当前用量、限制值和操作类型;响应不得泄露其他租户信息。 + +## 6. 通道与接口风格 + +### 6.1 WebSocket 实时协议 + +继续使用现有 Tinode 协议承载以下实时能力: + +| 能力 | 协议方向 | 说明 | +| --- | --- | --- | +| 登录、注册、Token 登录 | client -> server | 已租户化,继续复用 | +| 发消息 | `pub` | 增加产品策略校验、消息类型和客户端幂等 ID | +| 拉消息、拉元数据 | `get` | 保留原序号查询;复杂搜索走 HTTP | +| 已读、送达、输入中 | `note` | 保留并增加隐私和节流规则 | +| 订阅/退订 Topic | `sub` / `leave` | 由好友、群审批等业务控制是否允许 | +| 通话信令 | `note` 或现有 call 信令 | 增加 `call_id`、通话类型和结束原因 | +| 系统消息 | 服务端发布的 `system` 类型消息 | 个人事件进入服务端管理的系统通知 Topic;群内事件进入对应群 Topic;普通用户不能发布该类型 | + +### 6.2 HTTP 业务 API + +新增业务 API 使用 `/v1/im/*` 前缀,按模块划分: + +```text +/v1/im/friends/* +/v1/im/groups/* +/v1/im/favorites/* +/v1/im/search/* +/v1/im/org/* +/v1/im/meetings/* +/v1/im/reports/* +/v1/im/profile/* +/v1/im/settings/* +/v1/im/security/* +/v1/im/reports-abuse/* +/v1/im/feedback/* +``` + +HTTP API 通用规则: + +1. HTTP 请求使用 `Authorization: Bearer `;统一认证中间件校验 Token 后生成只读 Principal,并从中获取 `tenant_id` 和 `uid`。请求参数中的同名字段不参与授权。 +2. 写接口必须支持幂等,使用 `Idempotency-Key` 或业务唯一键。HTTP 幂等键作用域固定为 `tenant_id + uid + method + route + key`;相同键但请求体摘要不同返回冲突错误。 +3. 列表接口统一使用游标分页,不使用不稳定的大 offset。 +4. 错误响应使用稳定业务错误码,客户端不解析数据库错误。 +5. 敏感接口记录审计日志。 + +### 6.3 文件 API + +继续复用现有文件上传下载入口,但必须满足: + +1. 文件记录带 `tenant_id`。 +2. 上传路径使用 `tenants/{tenant_id}/yyyy/mm/{file_id}`。 +3. 下载前校验文件上传者或关联消息/Topic 读取权限。 +4. 群文件、收藏文件、举报证据和反馈附件复用同一文件基础设施,但业务用途单独记录。 +5. 客户端不可见内部 `location`。 + +## 7. 核心领域与认证设计 + +### 7.1 已有通信模型 + +| 模型 | 继续复用 | 新增约束 | +| --- | --- | --- | +| User | 用户账号和基础资料 | 租户归属、实名状态、隐私设置、青少年状态 | +| Topic | 单聊、群聊、系统 Topic、收藏 Topic | 租户归属、产品业务类型、群治理策略 | +| Subscription | 用户与 Topic 的关系和 ACL | 好友/群状态不能只靠 ACL 表达 | +| Message | 话题内顺序消息 | 消息类型、幂等 ID、审核状态、搜索索引 | +| FileDef | 上传文件元数据 | 租户归属、业务用途、访问授权、扫描状态 | + +### 7.2 新增产品模型 + +| 模块 | 目标表 | 主要职责 | +| --- | --- | --- | +| 好友关系 | `im_friendships`、`im_friend_requests`、`im_user_blocks` | 好友申请、同意、拒绝、删除、拉黑 | +| 用户资料 | `im_user_profiles`、`im_user_privacy_settings` | 昵称、生日、签名、可见性和添加方式 | +| 群治理 | `im_group_settings`、`im_group_join_requests`、`im_group_announcements`、`im_group_mutes`、`im_group_admin_logs` | 入群审批、公告、禁言、管理员、转让群主 | +| 群文件 | `im_group_files` | 群文件目录、搜索、删除权限 | +| 收藏 | `im_favorites` | 收藏去重、分类、来源、搜索和分享 | +| 搜索 | `im_message_index` 或外部搜索索引 | 关键词、类型、附件、链接、图片、视频检索 | +| 通话 | `im_call_records`、`im_call_participants` | 通话记录、状态、时长、结束原因 | +| 组织 | `im_org_units`、`im_org_members` | 部门树、成员、岗位、数据范围 | +| 工作台 | `im_workbench_apps`、`im_workbench_user_apps` | 企业应用入口和权限 | +| 会议 | `im_meetings`、`im_meeting_participants` | 会议通知、确认状态、提醒和取消 | +| 日报月报 | `im_reports`、`im_report_read_states` | 日报/月报填写、提交、查看和统计 | +| 总经理信箱 | `im_manager_mailbox` | 意见提交、匿名规则、处理状态 | +| 实名认证 | `im_realname_verifications` | 实名信息加密、审核状态、脱敏展示 | +| 青少年模式 | `im_youth_mode_settings` | 启用状态、PIN 哈希、策略版本 | +| 举报反馈 | `im_abuse_reports`、`im_feedbacks`、`im_evidence_files` | 举报、证据、反馈和处理状态 | +| 审计 | `im_audit_logs` | 敏感操作追加日志 | +| 异步事件 | `im_outbox_events` | 系统消息、Push、搜索索引、定时任务 | + +新增以下基础模型,避免各业务模块重复实现认证、会话设置和消息变更: + +| 模块 | 表 | 主要职责 | +| --- | --- | --- | +| 账号安全 | `im_user_security` | `auth_version`、密码更新时间、登录失败和锁定状态 | +| 短信验证 | `im_verification_codes` | 验证码用途、目标号码哈希、有效期、错误次数和发送频控 | +| 二次验证 | `im_auth_challenges` | 敏感操作挑战、一次性票据哈希、绑定操作和过期时间 | +| 登录设备 | `im_login_sessions` | 设备会话、Token 标识、最近活动和撤销状态 | +| 会话设置 | `im_conversation_settings` | 置顶、免打扰、隐藏和用户侧会话状态 | +| 群成员 | `im_group_members` | 群成员业务状态、角色、入群来源和操作者 | +| 消息版本 | `im_message_revisions` | 编辑前内容、版本、操作者和变更原因 | + +### 7.3 企业码与登录流程 + +1. 未认证客户端先通过 `hi.tenant` 提交企业码;服务端只按 `im_tenant.code` 查找启用租户并把 `TenantID` 固定到 Session。 +2. 企业不存在、已停用或不允许登录时返回统一企业不可用错误,不能暴露内部状态和数据库信息。 +3. 后续注册、登录、验证码和找回密码全部使用该服务端租户上下文;不接受客户端在业务请求中重新指定租户。 +4. HTTP 场景使用 `/v1/im/tenants/resolve` 完成企业码解析,只返回公开品牌信息和服务地址。首期不设计 `tenant_ticket`;注册、登录等未认证请求由服务端根据企业码重新解析租户,认证成功后只信任正式用户 Token。 +5. 用户 Token 必须包含可信 `tenant_id`、`uid`、`auth_version`、签发时间和过期时间。Token 租户与当前接入租户不一致时拒绝认证。 + +### 7.4 验证码、密码重置和二次验证 + +验证码按 `register`、`login`、`reset_password`、`sensitive_action` 用途隔离,相同验证码不能跨用途使用。数据库只保存验证码强哈希和手机号规范化哈希,明文只在发送时短暂存在。 + +密码重置必须在同一事务中完成: + +1. 消费有效验证码或二次验证挑战。 +2. 更新密码强哈希和密码更新时间。 +3. 递增 `im_user_security.auth_version`。 +4. 撤销该用户现有 `im_login_sessions`。 +5. 写安全审计和 Outbox 通知。 + +二次验证成功后签发一次性操作票据。票据必须绑定 `tenant_id + uid + action + target + expires_at`,只保存票据哈希,成功使用后立即失效。青少年模式独立密码不与登录密码共用,哈希保存在 `im_youth_mode_settings`。 + +### 7.5 认证安全约束 + +1. 用户名和手机号唯一约束均包含 `tenant_id`。 +2. 验证码发送和验证按租户、手机号、IP、设备分别限频。 +3. 登录错误使用统一提示;服务端仍按账号、IP 和设备记录失败次数并执行指数退避或临时锁定。 +4. 新设备登录、密码修改、会话撤销和异常登录通过 Outbox 发送安全通知。 +5. 手机号、验证码、密码、票据、Token 和证件信息不得写入普通日志或消息内容。 + +## 8. 消息体系设计 + +### 8.1 消息类型 + +服务端需要定义稳定的消息类型,不允许各端随意约定 JSON 字段。 + +产品消息协议 v1 使用现有 `pub`,固定字段放在 `head`: + +| 字段 | 规则 | +| --- | --- | +| `x-im-schema` | 产品消息必填,首版固定为 `1` | +| `x-im-type` | 产品消息必填,值必须在下表白名单中 | +| `x-im-client-mid` | 普通客户端消息必填,对应服务端 `client_msg_id` | +| `x-im-reply-to` | 引用消息时填写同 Topic 的 SeqId | +| `x-im-forward-mode` | 转发时为 `single` 或 `merged` | +| `x-im-risk-state` | 仅服务端生成;客户端提交时删除并记录安全日志 | + +`content` 只保存对应消息类型的业务内容。每种类型进入开发前必须冻结字段、长度、必填条件和兼容规则;客户端未知字段应忽略,未知消息类型显示安全占位而不能崩溃。 + +| 类型 | 用途 | 关键字段 | +| --- | --- | --- | +| `text` | 普通文本和 Unicode 表情 | `text`、`mentions` | +| `image` | 图片消息 | `file_id`、`width`、`height`、`thumb_file_id` | +| `voice` | 语音消息 | `file_id`、`duration_ms`、`waveform` | +| `video` | 视频消息 | `file_id`、`duration_ms`、`thumb_file_id` | +| `file` | 普通文件 | `file_id`、`name`、`size`、`mime` | +| `contact_card` | 名片 | `user_id`、`display_name_snapshot`、`avatar_snapshot` | +| `call` | 通话结果消息 | `call_id`、`call_type`、`result`、`duration_sec` | +| `system` | 系统提示 | `event_type`、`actor_id`、`target_ids`、`params` | +| `forward_single` | 单条转发 | `source_message_ref`、`snapshot` | +| `forward_merged` | 合并转发 | `summary`、`items`、`source_refs` | + +### 8.2 消息幂等 + +客户端发消息必须带不超过 64 字符的 `client_msg_id`,推荐使用 UUID/ULID。服务端在当前租户和发送人范围内保证唯一,不把 Topic 放入唯一键,避免客户端误把同一个发送请求投递到多个会话: + +```text +UNIQUE(tenant_id, from_uid, client_msg_id) +``` + +断线重试时,如果服务端发现重复 `client_msg_id`,返回原消息的 Topic、`seq_id`、状态和响应摘要,不能再次插入消息。服务端生成的系统消息也必须生成稳定幂等 ID,并以 Outbox 事件 ID 作为去重来源。 + +### 8.3 发消息前策略校验 + +每条消息持久化前执行统一策略链: + +1. Session 租户和 Topic 租户一致。 +2. 发送人是同租户有效用户。 +3. Topic 存在且未删除。 +4. Subscription 具备写权限。 +5. 单聊检查好友关系、黑名单、删除状态和隐私策略。 +6. 群聊检查禁言、全员禁言、群状态和成员身份。 +7. 文件消息检查文件归属、扫描状态和目标 Topic 权限。 +8. 青少年模式和反诈策略检查。 +9. 内容审核通过或进入待审核策略。 + +### 8.4 消息编辑、引用、撤回与删除 + +消息在 Topic 中的 `seq_id` 和原发送人不可变。产品消息增加 `message_type`、`client_msg_id`、`version`、`moderation_state`、`recalled_at` 和 `recalled_by` 等明确字段,不能依赖客户端任意 JSON 决定服务端状态。 + +| 操作 | 服务端规则 | 多端同步 | +| --- | --- | --- | +| 引用 | 保存 `topic + seq_id` 引用和发送时的安全摘要;禁止跨租户引用 | 新消息正常广播;原消息删除后仍显示受控摘要 | +| 编辑 | 仅原发送人、允许类型、规定时限内可编辑;事务内递增 `version` 并写 `im_message_revisions` | 广播带版本号的编辑事件,旧版本事件不得覆盖新版本 | +| 全员撤回 | 校验发送人或群管理员权限及撤回时限;保留受保护审计内容,普通查询只返回撤回占位 | 广播撤回事件,收藏、搜索和引用按保留策略更新 | +| 仅自己删除 | 使用现有用户侧删除日志,不改变其他成员可见性 | 只同步当前用户的其他设备 | +| 批量删除/撤回 | 限制单次数量,逐项授权,返回逐项结果 | 每个成功项产生独立事件和审计记录 | + +编辑、撤回与索引更新必须使用乐观版本控制。请求携带期望 `version`,版本不一致返回冲突错误,客户端重新拉取当前消息状态。 + +### 8.5 内容审核时序 + +发送链路先执行本地权限、限频和硬规则,再按租户及消息类型选择以下一种审核模式: + +| 模式 | 处理方式 | 适用场景 | +| --- | --- | --- | +| `sync_block` | 在短超时内同步审核,通过后持久化和广播;超时按租户配置拒绝或降级 | 高风险文本、小请求 | +| `async_hold` | 以 `pending_review` 持久化但不广播;Outbox 审核通过后发布,拒绝后标记 `rejected` | 文件、图片和高风险内容 | +| `async_post_audit` | 先发布,异步审核;命中后执行系统撤回并审计 | 租户明确允许的低风险内容 | + +状态转换固定为: + +```text +pending_review -> approved -> published +pending_review -> rejected +published -> recalled_by_review +``` + +客户端提交的审核状态一律忽略。审核 Worker 必须幂等,同一消息只能完成一次终态转换。 + +## 9. 好友与黑名单设计 + +好友关系是单聊产品状态的真源,不直接用 P2P Topic 是否存在表示好友。 + +### 9.1 好友申请与关系状态 + +```mermaid +stateDiagram-v2 + [*] --> none + none --> requested: 发起申请 + requested --> accepted: 通过 + requested --> rejected: 拒绝 + requested --> expired: 过期 + accepted --> friends: 事务创建好友关系 + friends --> deleted: 删除好友关系 + deleted --> requested: 重新申请 + rejected --> requested: 重新申请 +``` + +`im_friend_requests` 记录申请状态,`im_friendships` 记录双方好友关系,`im_user_blocks` 记录单向拉黑。拉黑不修改好友关系状态;取消拉黑只删除当前用户的黑名单记录,不自动恢复已经删除的好友关系。 + +### 9.2 服务端规则 + +1. 申请、同意、拒绝、删除、拉黑必须幂等。 +2. 好友申请保存验证留言、来源、过期时间和处理人。 +3. 拉黑是单向行为,唯一约束为 `(tenant_id, blocker_id, blocked_id)`;被拉黑人发消息、通话、名片、查看资料时按策略阻断。 +4. 删除好友后历史消息默认保留,但双方不能继续发送好友消息。 +5. 好友资料字段由关系和隐私设置共同决定。 +6. 接受申请必须在一个事务内更新申请、创建好友关系、创建或激活 P2P Topic/Subscription,并写入 Outbox。 +7. 单聊策略按“双方账号有效 -> 好友关系有效 -> 任一方向黑名单 -> 隐私/青少年/风控”的顺序判定。 + +## 10. 群治理设计 + +现有 Group Topic 继续作为群消息容器。`im_group_settings` 与 `im_group_members` 是群业务状态真源;Topic、Subscription 和 ACL 是通信内核投影。所有成员和角色变化必须通过产品业务接口完成,并在同一 MySQL 事务内同步业务真源与通信投影。 + +### 10.1 群角色 + +| 角色 | 业务真源 | 通信投影 | 权限 | +| --- | --- | --- | --- | +| 群主 | `im_group_settings.owner_user_id` + `im_group_members.role=owner` | Topic Owner + Owner ACL | 全部群管理、转让、解散 | +| 管理员 | `im_group_members.role=admin` | Admin ACL | 审批、公告、禁言、成员管理 | +| 普通成员 | `im_group_members.role=member` | 有效 Subscription | 收发消息、查看公告和群文件 | +| 游客/待审批 | `im_group_join_requests` | 无 Subscription | 不具备群消息读写权限 | + +禁止客户端直接修改群 Topic Owner 或管理员 ACL。服务启动或定时一致性任务发现业务真源与通信投影不一致时,记录告警并以业务真源修复投影。 + +### 10.2 群能力 + +| 能力 | 设计 | +| --- | --- | +| 创建群 | 同一事务创建 Topic、群设置、群主成员记录、Subscription、ACL、审计和 Outbox | +| 入群邀请 | 写邀请/申请记录,审批通过后在同一事务写成员记录和 Subscription | +| 入群验证 | 群设置控制是否需要审批 | +| 群公告 | 公告表保存版本,群内发送系统消息 | +| 群二维码 | 签名短 Token,包含群 ID、租户、过期时间和用途 | +| 管理员 | 更新 `im_group_members.role`,同一事务同步 ACL | +| 禁言 | 全员禁言和成员禁言都在发消息前强制检查 | +| 群文件 | 文件消息成功后进入群文件索引,按群权限查询 | +| 转让群主 | 二次确认;同一事务更新 owner、双方角色、Topic Owner/ACL、审计和 Outbox | +| 解散群聊 | 标记群状态、踢出成员、停用二维码和审批入口 | + +## 11. 收藏、转发与搜索设计 + +### 11.1 收藏 + +收藏使用独立 `im_favorites` 表作为查询和去重真源,不仅依赖 `slf` Topic。 + +收藏记录需要保存: + +1. 租户、收藏人、来源 Topic、来源 SeqId、来源消息 ID。 +2. 消息类型、摘要、文件 ID、发送人、发送时间。 +3. 收藏时间、去重键和删除状态。 +4. 原消息删除后的可见策略。 + +### 11.2 转发 + +逐条转发和合并转发都必须重新执行目标会话权限和文件权限。 + +| 转发类型 | 规则 | +| --- | --- | +| 逐条转发 | 每条源消息生成一条新消息;保持顺序;每条使用新 `client_msg_id` | +| 合并转发 | 生成不可变快照;限制条数、总大小和敏感字段;支持点击查看详情 | +| 文件转发 | 源文件和目标 Topic 必须同租户;必要时创建新的引用关系 | + +### 11.3 搜索 + +搜索分三类: + +1. 用户和群搜索:使用租户内规范化 Tags,并执行隐私策略。 +2. 聊天记录搜索:`im_message_index` 保存 `tenant_id`、Topic、SeqId、消息类型、发送人、时间、规范化检索文本和资源元数据,返回前二次校验 ACL。 +3. 文件/图片/视频/链接搜索:依赖消息类型索引和文件元数据索引。 + +首期固定使用 MySQL 8:结构化条件使用 B-Tree 复合索引,消息正文使用独立 `im_message_index.search_text` 和适合中文的 FULLTEXT/ngram 索引。分页游标使用稳定的 `created_at + message_id`,不能使用大 Offset。 + +搜索索引不是授权来源。查询必须先限定 `tenant_id` 和当前用户可读 Topic 集合,返回前再次校验 Topic Read ACL、成员状态、用户侧删除、全员撤回和审核状态。退群、拉黑或权限变更后必须立即阻断查询,即使异步索引尚未删除。 + +出现以下任一情况时重新评估 OpenSearch/Elasticsearch:单租户可检索消息达到千万级、MySQL 搜索 P95 超过 500ms、需要复杂中文相关性/高亮,或索引写入明显影响消息主库。切换时保持 HTTP 搜索契约不变。 + +## 12. 通话设计 + +现有 P2P WebRTC 信令继续复用,新增业务通话记录。 + +### 12.1 通话状态 + +```mermaid +stateDiagram-v2 + [*] --> ringing: 发起呼叫 + ringing --> accepted: 接听 + ringing --> canceled: 发起方取消 + ringing --> rejected: 接收方拒绝 + ringing --> timeout: 超时 + ringing --> busy: 对方忙线 + accepted --> ended: 正常挂断 + accepted --> interrupted: 异常断开 +``` + +### 12.2 关键字段 + +`im_call_records` 至少包含: + +```text +tenant_id, call_id, topic, caller_id, callee_id, call_type, +state, start_at, accepted_at, ended_at, duration_sec, +end_reason, created_at, updated_at +``` + +通话结束后生成 `call` 类型消息,双方看到的文案由统一结果码生成。 + +## 13. 企业工作台设计 + +工作台是独立业务域,不能依赖聊天 Topic 保存主数据。 + +| 模块 | 入口 | 通知方式 | +| --- | --- | --- | +| 组织架构 | `/v1/im/org/*` | 组织变更一般不发聊天消息,必要时发系统通知 | +| 会议通知 | `/v1/im/meetings/*` | 创建、变更、取消和提醒通过系统消息 + Push | +| 日报/月报 | `/v1/im/reports/*` | 未提交提醒、提交通知和管理员提醒通过 Outbox | +| 总经理信箱 | `/v1/im/manager-mailbox/*` | 提交成功给本人通知;处理结果按匿名规则通知 | +| 工作台应用 | `/v1/im/workbench/apps` | 返回租户配置和用户权限范围内的入口 | + +组织数据权限用于日报、月报、会议统计和通讯录查询。管理员只能查看权限范围内成员数据。 + +## 14. 个人资料、隐私与青少年模式 + +### 14.1 个人资料 + +基础展示资料可以同步到 User Public,但可索引、可审计或敏感字段应进入独立表。 + +| 字段 | 存储建议 | +| --- | --- | +| 头像、昵称、签名 | User Public + `im_user_profiles` | +| 生日、地区、性别 | `im_user_profiles`,按隐私设置返回 | +| 实名姓名、证件号 | 实名表加密存储,不进入 Public | +| 群昵称 | `im_group_members`,按需投影到 Subscription Private | + +### 14.2 隐私设置 + +隐私设置影响以下入口: + +1. 账号/手机号/二维码/群聊添加好友。 +2. 个人主页字段展示。 +3. 在线状态和最后在线时间展示。 +4. 非好友消息和通话发起。 + +客户端可以隐藏入口,但最终阻断必须在服务端执行。 + +### 14.3 青少年模式 + +青少年模式状态保存在服务端,PIN 使用强哈希,不保存明文。 + +受限能力由租户策略决定,首期建议覆盖: + +1. 添加好友。 +2. 加群和创建群。 +3. 文件传输。 +4. 音视频通话。 +5. 工作台外部链接。 + +### 14.4 会话与通知设置 + +`im_conversation_settings` 以 `(tenant_id, user_id, topic)` 唯一,保存置顶、免打扰、隐藏等用户侧设置。它不改变其他成员的会话状态,也不能替代 Topic ACL。 + +Push 发送前同时检查租户通知策略、用户全局通知设置、会话免打扰、青少年模式和系统权限登记状态。客户端系统通知权限只用于展示引导,服务端保存的是用户偏好和最近一次上报状态,不把客户端上报当作可靠授权依据。 + +## 15. 安全、实名、举报与反诈 + +### 15.1 实名认证 + +实名认证采用独立状态机: + +```text +none -> pending -> approved +none -> pending -> rejected +approved -> appeal_pending -> approved/rejected +``` + +证件号、姓名等敏感字段加密存储,普通查询只返回脱敏状态。 + +### 15.2 反诈策略 + +反诈由“本地规则 + 外部风控插件”共同组成: + +1. 本地规则用于稳定阻断和提示,例如陌生人、敏感词、频繁加好友、异常文件。 +2. 外部插件用于内容审核、风险评分和策略建议。 +3. 插件调用遵守第 8.5 节的三种审核模式、超时和降级规则,不能在 Topic Actor 中执行无界远程等待。 +4. 风险提示和二次确认结果写审计。 + +### 15.3 举报与反馈 + +举报和反馈都按工单处理。 + +| 类型 | 特点 | +| --- | --- | +| 举报 | 绑定被举报对象、会话、消息、原因、证据和处理状态 | +| 意见反馈 | 绑定提交人、设备、版本、问题描述、附件和处理状态 | + +举报证据应固化消息快照和文件引用,防止原消息删除后无法处理。 + +## 16. 系统消息与通知设计 + +需要产生异步副作用的业务状态变化,在同一数据库事务中写入 Outbox,由 Worker 生成系统消息、Push、索引或定时任务。没有异步副作用的更新不要求创建空事件。 + +### 16.1 系统消息格式 + +系统消息固定使用以下结构;业务模块只能登记 `event_type` 和参数 Schema,不能自行定义另一套消息信封: + +```json +{ + "type": "system", + "event_type": "group_join_approved", + "template_version": 1, + "actor_id": "usr...", + "target_ids": ["usr..."], + "params": { + "group_id": "grp...", + "group_name": "项目群" + }, + "dedupe_key": "tenant:1:group:grpxxx:join:request123" +} +``` + +### 16.2 事件类型 + +首期至少定义: + +| 业务 | 事件 | +| --- | --- | +| 账号安全 | 新设备登录、密码修改、实名提醒 | +| 好友 | 新申请、通过、拒绝、删除 | +| 群 | 邀请、申请、审批、公告、禁言、管理员变更、群主转让、解散 | +| 文件 | 群文件上传、文件失效 | +| 通话 | 未接、拒绝、取消、忙线、通话结束 | +| 工作台 | 会议通知、会议变更、日报提醒、月报提醒、信箱处理 | +| 安全 | 反诈提示、举报受理、反馈受理 | + +## 17. 权限模型 + +### 17.1 角色层级 + +| 层级 | 角色 | +| --- | --- | +| 平台 | `platform_admin` | +| 租户 | `tenant_owner`、`tenant_admin` | +| 组织 | 部门负责人、报表管理员 | +| 群 | 群主、管理员、成员 | +| 关系 | 好友、非好友、黑名单 | +| 账号 | 普通用户、实名用户、青少年模式用户 | + +### 17.2 权限判定顺序 + +```text +租户状态 -> 用户状态 -> 业务角色 -> Topic ACL -> 产品策略 -> 风控策略 +``` + +任何一步失败,写操作不得继续。 + +## 18. 数据一致性与异步任务 + +### 18.1 事务边界 + +必须在同一事务中完成: + +1. 好友申请处理和关系状态更新。 +2. 群审批通过和 Subscription 写入。 +3. 群主转让和权限更新。 +4. 消息保存和附件引用建立。 +5. 文件记录从 `uploaded` 切换为 `ready`、附件引用和业务索引写入。 +6. 举报提交和证据引用。 + +业务层不能在 Handler 中连续调用多个独立 Store 方法来模拟事务。`imstore` 必须提供按用例划分的事务方法,例如 `AcceptFriendRequest`、`ApproveGroupJoin`、`TransferGroupOwner` 和 `SaveMessageWithAttachments`,方法内部同时更新产品业务表、Tinode 核心表、审计记录和 Outbox。 + +对象存储不参与 MySQL 事务,文件采用以下状态机: + +```text +pending -> uploaded -> ready +pending/uploaded -> failed +uploaded(超时且无引用) -> orphaned -> deleted +``` + +对象上传成功后,再在 MySQL 事务中把文件切换为 `ready` 并建立业务引用。若数据库事务失败,文件保持 `uploaded`,由带宽受控的租户级垃圾回收任务清理;消息只能引用 `ready` 文件。 + +### 18.2 Outbox + +需要通知、Push、索引或审计的业务写操作,在事务内写入 `im_outbox_events`。异步 Worker 负责: + +1. 发送系统消息。 +2. 推送离线通知。 +3. 更新搜索索引。 +4. 调用外部审核/反诈。 +5. 发送会议、日报、月报定时提醒。 +6. 记录可观测指标。 + +`im_outbox_events` 至少包含: + +```text +id, tenant_id, aggregate_type, aggregate_id, event_type, +payload, dedupe_key, state, available_at, attempts, +locked_by, locked_at, last_error, created_at, processed_at +``` + +Outbox 处理规则: + +1. `dedupe_key` 在租户内唯一,生产事件和消费副作用都必须幂等。 +2. Worker 使用 `SELECT ... FOR UPDATE SKIP LOCKED` 分批领取事件,设置最大批量和租户公平调度,避免单个租户占满队列。 +3. 失败采用有上限的指数退避;超过上限进入 `dead_letter`,触发告警并支持人工重放。 +4. 系统消息通过集群安全的内部发布接口进入目标 Topic,不能直接假设 Outbox Worker 与目标 Topic 位于同一进程。 +5. 标记事件成功前必须确认目标副作用已获得稳定幂等结果;进程崩溃后的重复执行不得产生重复消息或 Push。 +6. 外部审核事件必须按第 8.5 节推进消息审核状态,不能绕过状态机直接广播。 + +## 19. API 与错误码规范 + +### 19.1 成功响应 + +```json +{ + "code": 0, + "data": {}, + "request_id": "..." +} +``` + +### 19.2 错误响应 + +```json +{ + "code": 30001, + "message": "permission denied", + "reason": "GROUP_MUTED", + "request_id": "..." +} +``` + +错误码按模块分段: + +| 范围 | 模块 | +| --- | --- | +| 10000-19999 | 通用、认证、租户 | +| 20000-29999 | 好友、黑名单、隐私 | +| 30000-39999 | 群、公告、群文件 | +| 40000-49999 | 消息、收藏、搜索、转发 | +| 50000-59999 | 文件、媒体、通话 | +| 60000-69999 | 组织、会议、日报、月报 | +| 70000-79999 | 实名、青少年、安全、举报、反馈 | + +HTTP 状态码表达协议层结果,业务 `code` 表达稳定失败原因:参数错误使用 400,未认证使用 401,无权限使用 403,不可见资源使用 404,版本或幂等冲突使用 409,限频使用 429,服务端故障使用 5xx。客户端不得只判断 HTTP 状态而忽略业务 `code`。 + +## 20. 可观测性与审计 + +### 20.1 日志字段 + +认证后的请求日志至少包含: + +```text +request_id, tenant_id, uid, sid, topic, action, result, latency_ms +``` + +手机号、证件号、Token、验证码、文件签名和设备 Push Token 不写明文日志。 + +### 20.2 审计事件 + +必须审计: + +1. 租户、组织和管理员变更。 +2. 登录、密码修改、实名审核和二次验证。 +3. 好友删除、拉黑、黑名单解除。 +4. 群主转让、管理员变更、禁言、解散群。 +5. 文件下载、批量转发、收藏导出。 +6. 举报处理、反馈处理和反诈二次确认。 +7. 平台管理员代操作。 + +## 21. 开发阶段规划 + +### 阶段 0:基础底座收口 + +目标:让后续业务都站在正确地基上。 + +交付: + +1. 多租户核心链路完成并通过双租户隔离测试。 +2. 消息 `client_msg_id` 幂等。 +3. 文件下载资源级授权。 +4. 系统消息结构和 Outbox 基础。 +5. 统一错误码和请求日志。 +6. 产品版明确只支持 MySQL 8,不支持的 Adapter 在启动时快速失败。 +7. `im_tenant_config` 三项配额在注册、建群和入群入口强制生效。 +8. 建立专用业务事务 Store,禁止跨多个 Store 调用拼接关键事务。 + +### 阶段 1:账号、资料、好友和黑名单 + +交付: + +1. 注册、登录、找回密码、二次验证。 +2. `auth_version`、登录设备、Token 撤销和安全通知。 +3. 个人资料、会话设置和隐私设置。 +4. 好友申请、通讯录、主页、删除好友。 +5. 独立黑名单和单聊发送/通话策略。 + +### 阶段 2:群聊产品化 + +交付: + +1. 群列表、创建群、群资料、群头像。 +2. 入群审批、邀请、群二维码。 +3. 群公告、管理员、群主转让。 +4. 全员禁言、成员禁言、群文件。 +5. 群业务真源与 Topic/Subscription/ACL 投影一致性检查。 + +### 阶段 3:消息增强、收藏、搜索和转发 + +交付: + +1. 结构化消息类型。 +2. @成员、引用、编辑、撤回、批量删除及消息版本事件。 +3. 收藏分类和分享。 +4. 聊天记录、文件、图片、视频和链接搜索。 +5. 逐条转发和合并转发。 +6. `sync_block`、`async_hold` 和 `async_post_audit` 审核状态机。 + +### 阶段 4:通话和通知体验 + +交付: + +1. `call_id` 和通话记录。 +2. 通话状态消息。 +3. 忙线、拒绝、未接、超时和异常断开。 +4. Push 文案、免打扰、锁屏隐私。 + +### 阶段 5:企业工作台 + +交付: + +1. 工作台应用配置。 +2. 组织架构。 +3. 会议通知和参会确认。 +4. 日报、月报。 +5. 总经理信箱。 + +### 阶段 6:安全合规 + +交付: + +1. 实名认证。 +2. 青少年模式。 +3. 反诈策略。 +4. 举报和意见反馈。 +5. 审计后台和处置流程。 + +## 22. 测试策略 + +### 22.1 自动化测试 + +每个模块至少包含: + +1. 单元测试:状态机、权限判断、错误码。 +2. Store 测试:租户隔离、唯一约束、分页、软删除。 +3. API 测试:参数校验、幂等、越权、并发。 +4. WebSocket 测试:消息顺序、回执、离线重连、系统消息。 +5. 安全测试:跨租户、跨用户、跨群、文件越权和枚举。 +6. 事务测试:在每个数据库写入点注入失败,验证业务真源、通信投影、审计和 Outbox 不出现部分提交。 +7. Worker 测试:重复消费、锁超时、进程崩溃、指数退避、死信和人工重放。 +8. Schema 测试:MySQL 初始化 DDL、升级 DDL 和 Adapter SQL 保持一致,唯一约束与外键真实生效。 +9. 性能测试:大群广播、并发发消息、重连风暴、文件上传下载、中文搜索和 Outbox 积压恢复。 + +性能测试不能只记录平均值。每次发布前根据部署规模给出 P95/P99 延迟、错误率、吞吐、数据库连接和队列积压报告;未确定正式 SLO 前,以当前稳定版本基线为回归门槛。 + +### 22.2 重点验收场景 + +| 场景 | 预期 | +| --- | --- | +| 两个租户使用相同手机号注册 | 均成功,互不影响 | +| A 租户尝试搜索 B 租户用户 | 返回空或通用 NotFound | +| 被拉黑用户发送消息 | 不进入对方会话,返回稳定失败原因 | +| 禁言成员发群消息 | 服务端拒绝,其他成员不可见 | +| 文件 URL 被其他用户重放 | 无权限返回 403/404 | +| 断线重试同一消息 | 不重复入库,返回原消息状态 | +| 同一发送人把相同 `client_msg_id` 投递到不同 Topic | 第二次返回首次投递结果,不写入另一 Topic | +| 并发注册超过 `max_users` | 成功数不超过配额,失败请求返回同一超限错误 | +| 并发审批入群超过 `max_group_members` | 最终有效成员数不超过配额 | +| 群主转让并发提交 | 最终只有一个成功,审计完整 | +| 群主转让在任一写入点失败 | owner、成员角色和 Topic ACL 全部回滚 | +| `async_hold` 消息等待审核 | 审核前其他成员不可见,通过后只广播一次 | +| Outbox 发送成功但标记前进程崩溃 | 重启重放不产生重复系统消息或 Push | +| 用户退群后搜索旧群消息 | 即使索引尚在也不返回结果 | +| 举报消息后原消息被撤回 | 举报证据仍可处理 | + +## 23. 开发约束 + +1. 新业务表一律以 `im_` 前缀命名。 +2. 所有租户业务表必须带 `tenant_id`;平台级表和 `im_tenant` 等根表例外,例外必须经过 DDL 评审。 +3. 新接口不使用无租户 Store。 +4. 不在 Topic Actor 中执行慢查询、外部 HTTP 或复杂报表统计。 +5. 不把敏感字段放入 User Public、Topic Public、普通消息 JSON 或日志。 +6. 新增消息类型必须先定义 schema,再实现客户端渲染。 +7. 所有危险操作必须有二次确认、幂等和审计。 +8. 所有批量操作必须限制数量并返回逐项结果。 +9. 所有列表必须分页。 +10. 所有外部服务调用必须有超时、重试上限和降级策略。 +11. 关键业务状态变化必须通过专用事务 Store 完成,不允许 Handler 直接修改 Topic ACL 绕过业务真源。 +12. 产品模块只允许在 `TenantBusinessReady()` 为真且数据库版本满足要求时启动。 + +## 24. 待产品确认事项 + +以下问题会影响接口和表结构,开发前需要产品或业务方确认: + +对应阶段冻结接口和 DDL 前必须确认本节事项。在产品未给出结论时,可以按本文其他章节声明的默认行为开发内部能力,但不得将未确认交互作为正式验收口径。 + +1. 删除好友后是否同时删除双方会话,还是仅禁止继续发送。 +2. 拉黑后是否允许被拉黑人看到历史消息和个人资料。 +3. 群聊已读回执是否展示具体已读成员,是否只对管理员开放。 +4. 消息撤回时限和管理员撤回范围。 +5. 合并转发是否允许包含已撤回、已删除或涉敏消息。 +6. 群文件是否等同于群内所有文件消息,还是独立上传目录。 +7. 实名认证是否强制,未实名能否继续登录、聊天、加好友、入群。 +8. 青少年模式限制范围和默认策略。 +9. 总经理信箱是否匿名,以及哪些角色可以查看真实提交人。 +10. 日报/月报是否支持草稿、补交、撤回和修改。 + +## 25. 总结 + +后续开发应以现有 Tinode 服务端作为实时通信内核,在其外建设清晰的产品业务层。核心原则是:聊天实时链路保持轻,业务状态机走 HTTP 和数据库事务,状态变化通过系统消息和 Push 回到聊天体验中。 + +多租户、消息幂等、文件授权、系统消息和审计是所有功能之前的基础工程。好友、群治理、收藏搜索、通话、工作台和安全合规应按阶段推进,每个阶段都必须同时完成数据模型、接口、权限、通知和测试闭环。 diff --git a/docs/im/images/@人员—完成.png b/docs/im/images/@人员—完成.png new file mode 100644 index 0000000..ff0cf07 Binary files /dev/null and b/docs/im/images/@人员—完成.png differ diff --git a/docs/im/images/@人员—选择提醒人.png b/docs/im/images/@人员—选择提醒人.png new file mode 100644 index 0000000..e15a9e2 Binary files /dev/null and b/docs/im/images/@人员—选择提醒人.png differ diff --git a/docs/im/images/@人员状态.png b/docs/im/images/@人员状态.png new file mode 100644 index 0000000..d0491d4 Binary files /dev/null and b/docs/im/images/@人员状态.png differ diff --git a/docs/im/images/上传文件.png b/docs/im/images/上传文件.png new file mode 100644 index 0000000..81ccb2d Binary files /dev/null and b/docs/im/images/上传文件.png differ diff --git a/docs/im/images/个人信息.png b/docs/im/images/个人信息.png new file mode 100644 index 0000000..e8a9b96 Binary files /dev/null and b/docs/im/images/个人信息.png differ diff --git a/docs/im/images/个人对话-发各种文件状态(1).png b/docs/im/images/个人对话-发各种文件状态(1).png new file mode 100644 index 0000000..0314844 Binary files /dev/null and b/docs/im/images/个人对话-发各种文件状态(1).png differ diff --git a/docs/im/images/个人对话-发各种文件状态.png b/docs/im/images/个人对话-发各种文件状态.png new file mode 100644 index 0000000..113156d Binary files /dev/null and b/docs/im/images/个人对话-发各种文件状态.png differ diff --git a/docs/im/images/个人对话-发送名片状态.png b/docs/im/images/个人对话-发送名片状态.png new file mode 100644 index 0000000..240a068 Binary files /dev/null and b/docs/im/images/个人对话-发送名片状态.png differ diff --git a/docs/im/images/个人对话-键盘收起状态.png b/docs/im/images/个人对话-键盘收起状态.png new file mode 100644 index 0000000..b97492a Binary files /dev/null and b/docs/im/images/个人对话-键盘收起状态.png differ diff --git a/docs/im/images/个人对话—删除状态.png b/docs/im/images/个人对话—删除状态.png new file mode 100644 index 0000000..ae1a644 Binary files /dev/null and b/docs/im/images/个人对话—删除状态.png differ diff --git a/docs/im/images/个人对话—拉黑状态/已读状态.png b/docs/im/images/个人对话—拉黑状态/已读状态.png new file mode 100644 index 0000000..7305031 Binary files /dev/null and b/docs/im/images/个人对话—拉黑状态/已读状态.png differ diff --git a/docs/im/images/个人对话框(对方的状态)(1).png b/docs/im/images/个人对话框(对方的状态)(1).png new file mode 100644 index 0000000..989c8c0 Binary files /dev/null and b/docs/im/images/个人对话框(对方的状态)(1).png differ diff --git a/docs/im/images/个人对话框(对方的状态).png b/docs/im/images/个人对话框(对方的状态).png new file mode 100644 index 0000000..e63317e Binary files /dev/null and b/docs/im/images/个人对话框(对方的状态).png differ diff --git a/docs/im/images/个人对话框(我的状态)(1).png b/docs/im/images/个人对话框(我的状态)(1).png new file mode 100644 index 0000000..544c09c Binary files /dev/null and b/docs/im/images/个人对话框(我的状态)(1).png differ diff --git a/docs/im/images/个人对话框(我的状态).png b/docs/im/images/个人对话框(我的状态).png new file mode 100644 index 0000000..ece3a36 Binary files /dev/null and b/docs/im/images/个人对话框(我的状态).png differ diff --git a/docs/im/images/个性签名.png b/docs/im/images/个性签名.png new file mode 100644 index 0000000..6001349 Binary files /dev/null and b/docs/im/images/个性签名.png differ diff --git a/docs/im/images/举报(1).png b/docs/im/images/举报(1).png new file mode 100644 index 0000000..6e02403 Binary files /dev/null and b/docs/im/images/举报(1).png differ diff --git a/docs/im/images/举报.png b/docs/im/images/举报.png new file mode 100644 index 0000000..8a81202 Binary files /dev/null and b/docs/im/images/举报.png differ diff --git a/docs/im/images/二维码.png b/docs/im/images/二维码.png new file mode 100644 index 0000000..5575573 Binary files /dev/null and b/docs/im/images/二维码.png differ diff --git a/docs/im/images/企业码登录.png b/docs/im/images/企业码登录.png new file mode 100644 index 0000000..ff61c9c Binary files /dev/null and b/docs/im/images/企业码登录.png differ diff --git a/docs/im/images/会议通知-发起人状态.png b/docs/im/images/会议通知-发起人状态.png new file mode 100644 index 0000000..5ca94a2 Binary files /dev/null and b/docs/im/images/会议通知-发起人状态.png differ diff --git a/docs/im/images/会议通知-接收人状态.png b/docs/im/images/会议通知-接收人状态.png new file mode 100644 index 0000000..9ba0166 Binary files /dev/null and b/docs/im/images/会议通知-接收人状态.png differ diff --git a/docs/im/images/关闭青少年模式(安卓).png b/docs/im/images/关闭青少年模式(安卓).png new file mode 100644 index 0000000..f2d1f1d Binary files /dev/null and b/docs/im/images/关闭青少年模式(安卓).png differ diff --git a/docs/im/images/内容沟通对话3-图片和语音发送中.png b/docs/im/images/内容沟通对话3-图片和语音发送中.png new file mode 100644 index 0000000..443258f Binary files /dev/null and b/docs/im/images/内容沟通对话3-图片和语音发送中.png differ diff --git a/docs/im/images/内容沟通对话3-开启进群验证与拉人状态.png b/docs/im/images/内容沟通对话3-开启进群验证与拉人状态.png new file mode 100644 index 0000000..dc99738 Binary files /dev/null and b/docs/im/images/内容沟通对话3-开启进群验证与拉人状态.png differ diff --git a/docs/im/images/内容沟通对话3-语音录音中.png b/docs/im/images/内容沟通对话3-语音录音中.png new file mode 100644 index 0000000..45a25c8 Binary files /dev/null and b/docs/im/images/内容沟通对话3-语音录音中.png differ diff --git a/docs/im/images/内容沟通对话3-语音/视频/图片状态.png b/docs/im/images/内容沟通对话3-语音/视频/图片状态.png new file mode 100644 index 0000000..36c9544 Binary files /dev/null and b/docs/im/images/内容沟通对话3-语音/视频/图片状态.png differ diff --git a/docs/im/images/出生日期.png b/docs/im/images/出生日期.png new file mode 100644 index 0000000..23f27a2 Binary files /dev/null and b/docs/im/images/出生日期.png differ diff --git a/docs/im/images/删除好友提示.png b/docs/im/images/删除好友提示.png new file mode 100644 index 0000000..dfe594a Binary files /dev/null and b/docs/im/images/删除好友提示.png differ diff --git a/docs/im/images/删除管理员-设置成功.png b/docs/im/images/删除管理员-设置成功.png new file mode 100644 index 0000000..3510b5a Binary files /dev/null and b/docs/im/images/删除管理员-设置成功.png differ diff --git a/docs/im/images/加入群聊.png b/docs/im/images/加入群聊.png new file mode 100644 index 0000000..b2023cf Binary files /dev/null and b/docs/im/images/加入群聊.png differ diff --git a/docs/im/images/双方沟通状态.png b/docs/im/images/双方沟通状态.png new file mode 100644 index 0000000..cc6726a Binary files /dev/null and b/docs/im/images/双方沟通状态.png differ diff --git a/docs/im/images/双方视频通话中-对方关闭摄像头状态.png b/docs/im/images/双方视频通话中-对方关闭摄像头状态.png new file mode 100644 index 0000000..22af72a Binary files /dev/null and b/docs/im/images/双方视频通话中-对方关闭摄像头状态.png differ diff --git a/docs/im/images/双方视频通话中.png b/docs/im/images/双方视频通话中.png new file mode 100644 index 0000000..b782809 Binary files /dev/null and b/docs/im/images/双方视频通话中.png differ diff --git a/docs/im/images/反诈弹窗(二次提示安卓).png b/docs/im/images/反诈弹窗(二次提示安卓).png new file mode 100644 index 0000000..08b698e Binary files /dev/null and b/docs/im/images/反诈弹窗(二次提示安卓).png differ diff --git a/docs/im/images/反诈弹窗(安卓).png b/docs/im/images/反诈弹窗(安卓).png new file mode 100644 index 0000000..151b3ef Binary files /dev/null and b/docs/im/images/反诈弹窗(安卓).png differ diff --git a/docs/im/images/发现新版本.png b/docs/im/images/发现新版本.png new file mode 100644 index 0000000..8cb8280 Binary files /dev/null and b/docs/im/images/发现新版本.png differ diff --git a/docs/im/images/各种通话提示状态.png b/docs/im/images/各种通话提示状态.png new file mode 100644 index 0000000..3dace39 Binary files /dev/null and b/docs/im/images/各种通话提示状态.png differ diff --git a/docs/im/images/多选-选中对话内容-收藏成功.png b/docs/im/images/多选-选中对话内容-收藏成功.png new file mode 100644 index 0000000..36bbd79 Binary files /dev/null and b/docs/im/images/多选-选中对话内容-收藏成功.png differ diff --git a/docs/im/images/多选-选中对话框.png b/docs/im/images/多选-选中对话框.png new file mode 100644 index 0000000..955431f Binary files /dev/null and b/docs/im/images/多选-选中对话框.png differ diff --git a/docs/im/images/好友—聊天设置—拉黑状态.png b/docs/im/images/好友—聊天设置—拉黑状态.png new file mode 100644 index 0000000..6253363 Binary files /dev/null and b/docs/im/images/好友—聊天设置—拉黑状态.png differ diff --git a/docs/im/images/好友—聊天设置—未拉黑状态.png b/docs/im/images/好友—聊天设置—未拉黑状态.png new file mode 100644 index 0000000..26255a7 Binary files /dev/null and b/docs/im/images/好友—聊天设置—未拉黑状态.png differ diff --git a/docs/im/images/好友个人主页.png b/docs/im/images/好友个人主页.png new file mode 100644 index 0000000..c0e4a75 Binary files /dev/null and b/docs/im/images/好友个人主页.png differ diff --git a/docs/im/images/实名认证-已实名.png b/docs/im/images/实名认证-已实名.png new file mode 100644 index 0000000..2b4a170 Binary files /dev/null and b/docs/im/images/实名认证-已实名.png differ diff --git a/docs/im/images/实名认证.png b/docs/im/images/实名认证.png new file mode 100644 index 0000000..61a06bd Binary files /dev/null and b/docs/im/images/实名认证.png differ diff --git a/docs/im/images/实名认证(安卓).png b/docs/im/images/实名认证(安卓).png new file mode 100644 index 0000000..b60db3c Binary files /dev/null and b/docs/im/images/实名认证(安卓).png differ diff --git a/docs/im/images/对方状态.png b/docs/im/images/对方状态.png new file mode 100644 index 0000000..de2713e Binary files /dev/null and b/docs/im/images/对方状态.png differ diff --git a/docs/im/images/工作台.png b/docs/im/images/工作台.png new file mode 100644 index 0000000..117abed Binary files /dev/null and b/docs/im/images/工作台.png differ diff --git a/docs/im/images/开启沟通页面.png b/docs/im/images/开启沟通页面.png new file mode 100644 index 0000000..67e928e Binary files /dev/null and b/docs/im/images/开启沟通页面.png differ diff --git a/docs/im/images/忘记密码.png b/docs/im/images/忘记密码.png new file mode 100644 index 0000000..9723c0e Binary files /dev/null and b/docs/im/images/忘记密码.png differ diff --git a/docs/im/images/总经理信箱-文案提示.png b/docs/im/images/总经理信箱-文案提示.png new file mode 100644 index 0000000..edd2039 Binary files /dev/null and b/docs/im/images/总经理信箱-文案提示.png differ diff --git a/docs/im/images/总经理信箱.png b/docs/im/images/总经理信箱.png new file mode 100644 index 0000000..b5e1cb2 Binary files /dev/null and b/docs/im/images/总经理信箱.png differ diff --git a/docs/im/images/意见反馈.png b/docs/im/images/意见反馈.png new file mode 100644 index 0000000..93c9516 Binary files /dev/null and b/docs/im/images/意见反馈.png differ diff --git a/docs/im/images/我的.png b/docs/im/images/我的.png new file mode 100644 index 0000000..028458d Binary files /dev/null and b/docs/im/images/我的.png differ diff --git a/docs/im/images/我的收藏(1).png b/docs/im/images/我的收藏(1).png new file mode 100644 index 0000000..2a2f224 Binary files /dev/null and b/docs/im/images/我的收藏(1).png differ diff --git a/docs/im/images/我的收藏-文件.png b/docs/im/images/我的收藏-文件.png new file mode 100644 index 0000000..c37ef78 Binary files /dev/null and b/docs/im/images/我的收藏-文件.png differ diff --git a/docs/im/images/我的收藏-文本.png b/docs/im/images/我的收藏-文本.png new file mode 100644 index 0000000..a56dd94 Binary files /dev/null and b/docs/im/images/我的收藏-文本.png differ diff --git a/docs/im/images/我的收藏-语音.png b/docs/im/images/我的收藏-语音.png new file mode 100644 index 0000000..03d4da7 Binary files /dev/null and b/docs/im/images/我的收藏-语音.png differ diff --git a/docs/im/images/我的收藏.png b/docs/im/images/我的收藏.png new file mode 100644 index 0000000..065e26a Binary files /dev/null and b/docs/im/images/我的收藏.png differ diff --git a/docs/im/images/我的收藏—链接.png b/docs/im/images/我的收藏—链接.png new file mode 100644 index 0000000..534d26a Binary files /dev/null and b/docs/im/images/我的收藏—链接.png differ diff --git a/docs/im/images/提醒(安卓).png b/docs/im/images/提醒(安卓).png new file mode 100644 index 0000000..d6211ba Binary files /dev/null and b/docs/im/images/提醒(安卓).png differ diff --git a/docs/im/images/收藏—文件(多选).png b/docs/im/images/收藏—文件(多选).png new file mode 100644 index 0000000..0e3578a Binary files /dev/null and b/docs/im/images/收藏—文件(多选).png differ diff --git a/docs/im/images/收藏文件—分享给好友页面.png b/docs/im/images/收藏文件—分享给好友页面.png new file mode 100644 index 0000000..c509808 Binary files /dev/null and b/docs/im/images/收藏文件—分享给好友页面.png differ diff --git a/docs/im/images/文件-下载.png b/docs/im/images/文件-下载.png new file mode 100644 index 0000000..d89fdc5 Binary files /dev/null and b/docs/im/images/文件-下载.png differ diff --git a/docs/im/images/文件-下载中.png b/docs/im/images/文件-下载中.png new file mode 100644 index 0000000..80331da Binary files /dev/null and b/docs/im/images/文件-下载中.png differ diff --git a/docs/im/images/文件-下载完成.png b/docs/im/images/文件-下载完成.png new file mode 100644 index 0000000..ded18bc Binary files /dev/null and b/docs/im/images/文件-下载完成.png differ diff --git a/docs/im/images/文件发送状态.png b/docs/im/images/文件发送状态.png new file mode 100644 index 0000000..20f083b Binary files /dev/null and b/docs/im/images/文件发送状态.png differ diff --git a/docs/im/images/新朋友.png b/docs/im/images/新朋友.png new file mode 100644 index 0000000..0e6a27c Binary files /dev/null and b/docs/im/images/新朋友.png differ diff --git a/docs/im/images/新邀请进群申请.png b/docs/im/images/新邀请进群申请.png new file mode 100644 index 0000000..ccee33e Binary files /dev/null and b/docs/im/images/新邀请进群申请.png differ diff --git a/docs/im/images/日报-普通用户页面.png b/docs/im/images/日报-普通用户页面.png new file mode 100644 index 0000000..9f44fa6 Binary files /dev/null and b/docs/im/images/日报-普通用户页面.png differ diff --git a/docs/im/images/日报-查看详情.png b/docs/im/images/日报-查看详情.png new file mode 100644 index 0000000..ac9fb29 Binary files /dev/null and b/docs/im/images/日报-查看详情.png differ diff --git a/docs/im/images/日报-管理员页面.png b/docs/im/images/日报-管理员页面.png new file mode 100644 index 0000000..dcd573e Binary files /dev/null and b/docs/im/images/日报-管理员页面.png differ diff --git a/docs/im/images/日报.png b/docs/im/images/日报.png new file mode 100644 index 0000000..682dc57 Binary files /dev/null and b/docs/im/images/日报.png differ diff --git a/docs/im/images/月报(1).png b/docs/im/images/月报(1).png new file mode 100644 index 0000000..354b821 Binary files /dev/null and b/docs/im/images/月报(1).png differ diff --git a/docs/im/images/月报-详情页.png b/docs/im/images/月报-详情页.png new file mode 100644 index 0000000..8f90197 Binary files /dev/null and b/docs/im/images/月报-详情页.png differ diff --git a/docs/im/images/月报.png b/docs/im/images/月报.png new file mode 100644 index 0000000..8a97e3e Binary files /dev/null and b/docs/im/images/月报.png differ diff --git a/docs/im/images/查找聊天记录-搜索关键词选中状态(点击后跳出对应聊天内容).png b/docs/im/images/查找聊天记录-搜索关键词选中状态(点击后跳出对应聊天内容).png new file mode 100644 index 0000000..eb8042d Binary files /dev/null and b/docs/im/images/查找聊天记录-搜索关键词选中状态(点击后跳出对应聊天内容).png differ diff --git a/docs/im/images/查找聊天记录-文件.png b/docs/im/images/查找聊天记录-文件.png new file mode 100644 index 0000000..e1f3d61 Binary files /dev/null and b/docs/im/images/查找聊天记录-文件.png differ diff --git a/docs/im/images/查找聊天记录-链接.png b/docs/im/images/查找聊天记录-链接.png new file mode 100644 index 0000000..715c78a Binary files /dev/null and b/docs/im/images/查找聊天记录-链接.png differ diff --git a/docs/im/images/查看聊天记录-图片.png b/docs/im/images/查看聊天记录-图片.png new file mode 100644 index 0000000..0b5ca2e Binary files /dev/null and b/docs/im/images/查看聊天记录-图片.png differ diff --git a/docs/im/images/查看聊天记录-视频.png b/docs/im/images/查看聊天记录-视频.png new file mode 100644 index 0000000..55c4762 Binary files /dev/null and b/docs/im/images/查看聊天记录-视频.png differ diff --git a/docs/im/images/注册页面-倒计时.png b/docs/im/images/注册页面-倒计时.png new file mode 100644 index 0000000..d622985 Binary files /dev/null and b/docs/im/images/注册页面-倒计时.png differ diff --git a/docs/im/images/注册页面.png b/docs/im/images/注册页面.png new file mode 100644 index 0000000..025a6a0 Binary files /dev/null and b/docs/im/images/注册页面.png differ diff --git a/docs/im/images/消息-无网络状态.png b/docs/im/images/消息-无网络状态.png new file mode 100644 index 0000000..d03c948 Binary files /dev/null and b/docs/im/images/消息-无网络状态.png differ diff --git a/docs/im/images/消息-更多.png b/docs/im/images/消息-更多.png new file mode 100644 index 0000000..db5e6b4 Binary files /dev/null and b/docs/im/images/消息-更多.png differ diff --git a/docs/im/images/消息.png b/docs/im/images/消息.png new file mode 100644 index 0000000..24675b2 Binary files /dev/null and b/docs/im/images/消息.png differ diff --git a/docs/im/images/消息页面—长按弹出.png b/docs/im/images/消息页面—长按弹出.png new file mode 100644 index 0000000..84e3e72 Binary files /dev/null and b/docs/im/images/消息页面—长按弹出.png differ diff --git a/docs/im/images/添加好友(1).png b/docs/im/images/添加好友(1).png new file mode 100644 index 0000000..ce60f2b Binary files /dev/null and b/docs/im/images/添加好友(1).png differ diff --git a/docs/im/images/添加好友-输入精准用户ID.png b/docs/im/images/添加好友-输入精准用户ID.png new file mode 100644 index 0000000..e367d8a Binary files /dev/null and b/docs/im/images/添加好友-输入精准用户ID.png differ diff --git a/docs/im/images/添加好友.png b/docs/im/images/添加好友.png new file mode 100644 index 0000000..be2c4bd Binary files /dev/null and b/docs/im/images/添加好友.png differ diff --git a/docs/im/images/添加好友验证.png b/docs/im/images/添加好友验证.png new file mode 100644 index 0000000..ca22198 Binary files /dev/null and b/docs/im/images/添加好友验证.png differ diff --git a/docs/im/images/添加禁言成员.png b/docs/im/images/添加禁言成员.png new file mode 100644 index 0000000..bcab63f Binary files /dev/null and b/docs/im/images/添加禁言成员.png differ diff --git a/docs/im/images/添加管理员-设置成功(1).png b/docs/im/images/添加管理员-设置成功(1).png new file mode 100644 index 0000000..effc341 Binary files /dev/null and b/docs/im/images/添加管理员-设置成功(1).png differ diff --git a/docs/im/images/添加管理员-设置成功.png b/docs/im/images/添加管理员-设置成功.png new file mode 100644 index 0000000..4efc2d9 Binary files /dev/null and b/docs/im/images/添加管理员-设置成功.png differ diff --git a/docs/im/images/添加管理员.png b/docs/im/images/添加管理员.png new file mode 100644 index 0000000..69614b8 Binary files /dev/null and b/docs/im/images/添加管理员.png differ diff --git a/docs/im/images/清空缓存的提示.png b/docs/im/images/清空缓存的提示.png new file mode 100644 index 0000000..a38f82d Binary files /dev/null and b/docs/im/images/清空缓存的提示.png differ diff --git a/docs/im/images/版本更新页面.png b/docs/im/images/版本更新页面.png new file mode 100644 index 0000000..e3402cb Binary files /dev/null and b/docs/im/images/版本更新页面.png differ diff --git a/docs/im/images/登录页面-密码错误.png b/docs/im/images/登录页面-密码错误.png new file mode 100644 index 0000000..61e14c7 Binary files /dev/null and b/docs/im/images/登录页面-密码错误.png differ diff --git a/docs/im/images/登录页面.png b/docs/im/images/登录页面.png new file mode 100644 index 0000000..c0e0a1a Binary files /dev/null and b/docs/im/images/登录页面.png differ diff --git a/docs/im/images/相机说明(安卓).png b/docs/im/images/相机说明(安卓).png new file mode 100644 index 0000000..cae8942 Binary files /dev/null and b/docs/im/images/相机说明(安卓).png differ diff --git a/docs/im/images/禁言的群成员.png b/docs/im/images/禁言的群成员.png new file mode 100644 index 0000000..21415ba Binary files /dev/null and b/docs/im/images/禁言的群成员.png differ diff --git a/docs/im/images/空白页.png b/docs/im/images/空白页.png new file mode 100644 index 0000000..f466fd7 Binary files /dev/null and b/docs/im/images/空白页.png differ diff --git a/docs/im/images/系统消息.png b/docs/im/images/系统消息.png new file mode 100644 index 0000000..7ad46f4 Binary files /dev/null and b/docs/im/images/系统消息.png differ diff --git a/docs/im/images/群二维码页面.png b/docs/im/images/群二维码页面.png new file mode 100644 index 0000000..24cc5d2 Binary files /dev/null and b/docs/im/images/群二维码页面.png differ diff --git a/docs/im/images/群公告状态.png b/docs/im/images/群公告状态.png new file mode 100644 index 0000000..4fae17a Binary files /dev/null and b/docs/im/images/群公告状态.png differ diff --git a/docs/im/images/群公告状态/邀请进群申请同时存在.png b/docs/im/images/群公告状态/邀请进群申请同时存在.png new file mode 100644 index 0000000..5aaea9c Binary files /dev/null and b/docs/im/images/群公告状态/邀请进群申请同时存在.png differ diff --git a/docs/im/images/群公告(对方查看状态).png b/docs/im/images/群公告(对方查看状态).png new file mode 100644 index 0000000..78d1dff Binary files /dev/null and b/docs/im/images/群公告(对方查看状态).png differ diff --git a/docs/im/images/群文件-上传状态.png b/docs/im/images/群文件-上传状态.png new file mode 100644 index 0000000..ff9ce39 Binary files /dev/null and b/docs/im/images/群文件-上传状态.png differ diff --git a/docs/im/images/群文件.png b/docs/im/images/群文件.png new file mode 100644 index 0000000..2cef984 Binary files /dev/null and b/docs/im/images/群文件.png differ diff --git a/docs/im/images/群标识.png b/docs/im/images/群标识.png new file mode 100644 index 0000000..472a5a4 Binary files /dev/null and b/docs/im/images/群标识.png differ diff --git a/docs/im/images/群简介.png b/docs/im/images/群简介.png new file mode 100644 index 0000000..89a5871 Binary files /dev/null and b/docs/im/images/群简介.png differ diff --git a/docs/im/images/群组管理.png b/docs/im/images/群组管理.png new file mode 100644 index 0000000..9c1246e Binary files /dev/null and b/docs/im/images/群组管理.png differ diff --git a/docs/im/images/群聊.png b/docs/im/images/群聊.png new file mode 100644 index 0000000..cf2c9ad Binary files /dev/null and b/docs/im/images/群聊.png differ diff --git a/docs/im/images/群聊—非好友个人主页.png b/docs/im/images/群聊—非好友个人主页.png new file mode 100644 index 0000000..345c7a1 Binary files /dev/null and b/docs/im/images/群聊—非好友个人主页.png differ diff --git a/docs/im/images/群聊设置.png b/docs/im/images/群聊设置.png new file mode 100644 index 0000000..d4fa914 Binary files /dev/null and b/docs/im/images/群聊设置.png differ diff --git a/docs/im/images/群资料.png b/docs/im/images/群资料.png new file mode 100644 index 0000000..9409bd6 Binary files /dev/null and b/docs/im/images/群资料.png differ diff --git a/docs/im/images/获取推送权限(安卓).png b/docs/im/images/获取推送权限(安卓).png new file mode 100644 index 0000000..c342b5d Binary files /dev/null and b/docs/im/images/获取推送权限(安卓).png differ diff --git a/docs/im/images/表情包发送页面.png b/docs/im/images/表情包发送页面.png new file mode 100644 index 0000000..c0206e8 Binary files /dev/null and b/docs/im/images/表情包发送页面.png differ diff --git a/docs/im/images/表情包展开(发送按钮默认状态).png b/docs/im/images/表情包展开(发送按钮默认状态).png new file mode 100644 index 0000000..d77f5dc Binary files /dev/null and b/docs/im/images/表情包展开(发送按钮默认状态).png differ diff --git a/docs/im/images/视频与语音通话悬浮状态中.png b/docs/im/images/视频与语音通话悬浮状态中.png new file mode 100644 index 0000000..d15ab1d Binary files /dev/null and b/docs/im/images/视频与语音通话悬浮状态中.png differ diff --git a/docs/im/images/视频通话—等待对方接受接受邀请-摄像头关闭.png b/docs/im/images/视频通话—等待对方接受接受邀请-摄像头关闭.png new file mode 100644 index 0000000..1018148 Binary files /dev/null and b/docs/im/images/视频通话—等待对方接受接受邀请-摄像头关闭.png differ diff --git a/docs/im/images/视频通话—等待对方接受接受邀请中.png b/docs/im/images/视频通话—等待对方接受接受邀请中.png new file mode 100644 index 0000000..42f307f Binary files /dev/null and b/docs/im/images/视频通话—等待对方接受接受邀请中.png differ diff --git a/docs/im/images/设置昵称(1).png b/docs/im/images/设置昵称(1).png new file mode 100644 index 0000000..2729912 Binary files /dev/null and b/docs/im/images/设置昵称(1).png differ diff --git a/docs/im/images/设置昵称.png b/docs/im/images/设置昵称.png new file mode 100644 index 0000000..0e2322d Binary files /dev/null and b/docs/im/images/设置昵称.png differ diff --git a/docs/im/images/设置独立密码(安卓).png b/docs/im/images/设置独立密码(安卓).png new file mode 100644 index 0000000..1e6c668 Binary files /dev/null and b/docs/im/images/设置独立密码(安卓).png differ diff --git a/docs/im/images/设置群头像.png b/docs/im/images/设置群头像.png new file mode 100644 index 0000000..49137b1 Binary files /dev/null and b/docs/im/images/设置群头像.png differ diff --git a/docs/im/images/设置群管理员.png b/docs/im/images/设置群管理员.png new file mode 100644 index 0000000..2f3aae5 Binary files /dev/null and b/docs/im/images/设置群管理员.png differ diff --git a/docs/im/images/设置(iOS).png b/docs/im/images/设置(iOS).png new file mode 100644 index 0000000..f8d7536 Binary files /dev/null and b/docs/im/images/设置(iOS).png differ diff --git a/docs/im/images/设置(安卓).png b/docs/im/images/设置(安卓).png new file mode 100644 index 0000000..3aff453 Binary files /dev/null and b/docs/im/images/设置(安卓).png differ diff --git a/docs/im/images/语音—按住说话.png b/docs/im/images/语音—按住说话.png new file mode 100644 index 0000000..1e7304e Binary files /dev/null and b/docs/im/images/语音—按住说话.png differ diff --git a/docs/im/images/语音图片发送状态.png b/docs/im/images/语音图片发送状态.png new file mode 100644 index 0000000..193b4c7 Binary files /dev/null and b/docs/im/images/语音图片发送状态.png differ diff --git a/docs/im/images/语音按住说话.png b/docs/im/images/语音按住说话.png new file mode 100644 index 0000000..3715035 Binary files /dev/null and b/docs/im/images/语音按住说话.png differ diff --git a/docs/im/images/语音通话-双方语音沟通中.png b/docs/im/images/语音通话-双方语音沟通中.png new file mode 100644 index 0000000..b8b061d Binary files /dev/null and b/docs/im/images/语音通话-双方语音沟通中.png differ diff --git a/docs/im/images/语音通话-对方待接受状态.png b/docs/im/images/语音通话-对方待接受状态.png new file mode 100644 index 0000000..b638fdf Binary files /dev/null and b/docs/im/images/语音通话-对方待接受状态.png differ diff --git a/docs/im/images/语音通话-麦克风与扬声器已关.png b/docs/im/images/语音通话-麦克风与扬声器已关.png new file mode 100644 index 0000000..fcc5fc7 Binary files /dev/null and b/docs/im/images/语音通话-麦克风与扬声器已关.png differ diff --git a/docs/im/images/语音通话-麦克风与扬声器已开.png b/docs/im/images/语音通话-麦克风与扬声器已开.png new file mode 100644 index 0000000..a8efae5 Binary files /dev/null and b/docs/im/images/语音通话-麦克风与扬声器已开.png differ diff --git a/docs/im/images/身份验证(安卓).png b/docs/im/images/身份验证(安卓).png new file mode 100644 index 0000000..01b2216 Binary files /dev/null and b/docs/im/images/身份验证(安卓).png differ diff --git a/docs/im/images/转发-单选.png b/docs/im/images/转发-单选.png new file mode 100644 index 0000000..f2bc973 Binary files /dev/null and b/docs/im/images/转发-单选.png differ diff --git a/docs/im/images/转发-多选.png b/docs/im/images/转发-多选.png new file mode 100644 index 0000000..271692b Binary files /dev/null and b/docs/im/images/转发-多选.png differ diff --git a/docs/im/images/转发—合并转发—多选状态.png b/docs/im/images/转发—合并转发—多选状态.png new file mode 100644 index 0000000..1832fa7 Binary files /dev/null and b/docs/im/images/转发—合并转发—多选状态.png differ diff --git a/docs/im/images/转发—逐条转发.png b/docs/im/images/转发—逐条转发.png new file mode 100644 index 0000000..0d125f0 Binary files /dev/null and b/docs/im/images/转发—逐条转发.png differ diff --git a/docs/im/images/选择新群主.png b/docs/im/images/选择新群主.png new file mode 100644 index 0000000..8e096fc Binary files /dev/null and b/docs/im/images/选择新群主.png differ diff --git a/docs/im/images/选择新群主提示.png b/docs/im/images/选择新群主提示.png new file mode 100644 index 0000000..53303c6 Binary files /dev/null and b/docs/im/images/选择新群主提示.png differ diff --git a/docs/im/images/通讯录.png b/docs/im/images/通讯录.png new file mode 100644 index 0000000..bdbf5ca Binary files /dev/null and b/docs/im/images/通讯录.png differ diff --git a/docs/im/images/邀请进群申请.png b/docs/im/images/邀请进群申请.png new file mode 100644 index 0000000..05812d0 Binary files /dev/null and b/docs/im/images/邀请进群申请.png differ diff --git a/docs/im/images/部门组织框架.png b/docs/im/images/部门组织框架.png new file mode 100644 index 0000000..94f08e3 Binary files /dev/null and b/docs/im/images/部门组织框架.png differ diff --git a/docs/im/images/键盘展开.png b/docs/im/images/键盘展开.png new file mode 100644 index 0000000..a1173fb Binary files /dev/null and b/docs/im/images/键盘展开.png differ diff --git a/docs/im/images/隐私设置-添加我的方式选项.png b/docs/im/images/隐私设置-添加我的方式选项.png new file mode 100644 index 0000000..b2dbfe4 Binary files /dev/null and b/docs/im/images/隐私设置-添加我的方式选项.png differ diff --git a/docs/im/images/隐私设置.png b/docs/im/images/隐私设置.png new file mode 100644 index 0000000..d97a7e1 Binary files /dev/null and b/docs/im/images/隐私设置.png differ diff --git a/docs/im/images/青少年模式已开启(安卓).png b/docs/im/images/青少年模式已开启(安卓).png new file mode 100644 index 0000000..e4b2c09 Binary files /dev/null and b/docs/im/images/青少年模式已开启(安卓).png differ diff --git a/docs/im/images/青少年模式提示(安卓).png b/docs/im/images/青少年模式提示(安卓).png new file mode 100644 index 0000000..913652d Binary files /dev/null and b/docs/im/images/青少年模式提示(安卓).png differ diff --git a/docs/im/images/青少年模式未开启(安卓).png b/docs/im/images/青少年模式未开启(安卓).png new file mode 100644 index 0000000..c12c878 Binary files /dev/null and b/docs/im/images/青少年模式未开启(安卓).png differ diff --git a/docs/im/images/麦克风说明(安卓).png b/docs/im/images/麦克风说明(安卓).png new file mode 100644 index 0000000..162d588 Binary files /dev/null and b/docs/im/images/麦克风说明(安卓).png differ diff --git a/docs/im/images/黑名单.png b/docs/im/images/黑名单.png new file mode 100644 index 0000000..d452875 Binary files /dev/null and b/docs/im/images/黑名单.png differ diff --git a/docs/im/images/黑名单提示(1).png b/docs/im/images/黑名单提示(1).png new file mode 100644 index 0000000..865510f Binary files /dev/null and b/docs/im/images/黑名单提示(1).png differ diff --git a/docs/im/images/黑名单提示.png b/docs/im/images/黑名单提示.png new file mode 100644 index 0000000..0d0d184 Binary files /dev/null and b/docs/im/images/黑名单提示.png differ diff --git a/server/api/admin/imauditlog/im_audit_log.go b/server/api/admin/imauditlog/im_audit_log.go new file mode 100644 index 0000000..492f527 --- /dev/null +++ b/server/api/admin/imauditlog/im_audit_log.go @@ -0,0 +1,33 @@ +// ================================================================================= +// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT. +// ================================================================================= + +package imauditlog + +import ( + "hotgo/internal/model/input/form" + "hotgo/internal/model/input/sysin" + + "github.com/gogf/gf/v2/frame/g" +) + +// ListReq 查询IM审计日志列表 +type ListReq struct { + g.Meta `path:"/imAuditLog/list" method:"get" tags:"IM审计日志" summary:"获取IM审计日志列表"` + sysin.ImAuditLogListInp +} + +type ListRes struct { + form.PageRes + List []*sysin.ImAuditLogListModel `json:"list" dc:"数据列表"` +} + +// ViewReq 获取IM审计日志指定信息 +type ViewReq struct { + g.Meta `path:"/imAuditLog/view" method:"get" tags:"IM审计日志" summary:"获取IM审计日志指定信息"` + sysin.ImAuditLogViewInp +} + +type ViewRes struct { + *sysin.ImAuditLogViewModel +} diff --git a/server/api/admin/imgroup/im_group.go b/server/api/admin/imgroup/im_group.go new file mode 100644 index 0000000..4ec698f --- /dev/null +++ b/server/api/admin/imgroup/im_group.go @@ -0,0 +1,110 @@ +// Package imgroup +// @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 imgroup + +import ( + "hotgo/internal/model/input/form" + "hotgo/internal/model/input/sysin" + + "github.com/gogf/gf/v2/frame/g" +) + +// ListReq 查询IM群组列表 +type ListReq struct { + g.Meta `path:"/imGroup/list" method:"get" tags:"IM群组" summary:"获取IM群组列表"` + sysin.ImGroupListInp +} + +type ListRes struct { + form.PageRes + List []*sysin.ImGroupListModel `json:"list" dc:"数据列表"` +} + +// ViewReq 获取IM群组指定信息 +type ViewReq struct { + g.Meta `path:"/imGroup/view" method:"get" tags:"IM群组" summary:"获取IM群组指定信息"` + sysin.ImGroupViewInp +} + +type ViewRes struct { + *sysin.ImGroupViewModel +} + +// MemberListReq 查询IM群成员列表 +type MemberListReq struct { + g.Meta `path:"/imGroup/memberList" method:"get" tags:"IM群组" summary:"获取IM群成员列表"` + sysin.ImGroupMemberListInp +} + +type MemberListRes struct { + form.PageRes + List []*sysin.ImGroupMemberListModel `json:"list" dc:"数据列表"` +} + +// GovernanceReq 获取IM群治理配置 +type GovernanceReq struct { + g.Meta `path:"/imGroup/governance" method:"get" tags:"IM群组" summary:"获取IM群治理配置"` + sysin.ImGroupTopicInp +} + +type GovernanceRes struct { + *sysin.ImGroupGovernanceModel +} + +// JoinRequestListReq 查询IM入群申请列表 +type JoinRequestListReq struct { + g.Meta `path:"/imGroup/joinRequestList" method:"get" tags:"IM群组" summary:"获取IM入群申请列表"` + sysin.ImGroupJoinRequestListInp +} + +type JoinRequestListRes struct { + form.PageRes + List []*sysin.ImGroupJoinRequestListModel `json:"list" dc:"数据列表"` +} + +// InvitationListReq 查询IM群邀请列表 +type InvitationListReq struct { + g.Meta `path:"/imGroup/invitationList" method:"get" tags:"IM群组" summary:"获取IM群邀请列表"` + sysin.ImGroupInvitationListInp +} + +type InvitationListRes struct { + form.PageRes + List []*sysin.ImGroupInvitationListModel `json:"list" dc:"数据列表"` +} + +// AnnouncementListReq 查询IM群公告列表 +type AnnouncementListReq struct { + g.Meta `path:"/imGroup/announcementList" method:"get" tags:"IM群组" summary:"获取IM群公告列表"` + sysin.ImGroupAnnouncementListInp +} + +type AnnouncementListRes struct { + form.PageRes + List []*sysin.ImGroupAnnouncementListModel `json:"list" dc:"数据列表"` +} + +// MuteListReq 查询IM群禁言列表 +type MuteListReq struct { + g.Meta `path:"/imGroup/muteList" method:"get" tags:"IM群组" summary:"获取IM群禁言列表"` + sysin.ImGroupMuteListInp +} + +type MuteListRes struct { + form.PageRes + List []*sysin.ImGroupMuteListModel `json:"list" dc:"数据列表"` +} + +// AdminLogListReq 查询IM群管理日志列表 +type AdminLogListReq struct { + g.Meta `path:"/imGroup/adminLogList" method:"get" tags:"IM群组" summary:"获取IM群管理日志列表"` + sysin.ImGroupAdminLogListInp +} + +type AdminLogListRes struct { + form.PageRes + List []*sysin.ImGroupAdminLogListModel `json:"list" dc:"数据列表"` +} diff --git a/server/api/admin/iminvite/im_invite.go b/server/api/admin/iminvite/im_invite.go new file mode 100644 index 0000000..13116c5 --- /dev/null +++ b/server/api/admin/iminvite/im_invite.go @@ -0,0 +1,68 @@ +package iminvite + +import ( + "hotgo/internal/model/input/form" + "hotgo/internal/model/input/sysin" + + "github.com/gogf/gf/v2/frame/g" +) + +type ListReq struct { + g.Meta `path:"/imInvite/list" method:"post" tags:"IM邀请码" summary:"获取IM注册邀请码列表"` + sysin.ImInviteListInp +} + +type ListRes struct { + form.PageRes + List []*sysin.ImInviteCodeModel `json:"list" dc:"数据列表"` +} + +type CreateReq struct { + g.Meta `path:"/imInvite/create" method:"post" tags:"IM邀请码" summary:"生成IM注册邀请码"` + sysin.ImInviteCreateInp +} + +type CreateRes struct { + *sysin.ImInviteCodeModel +} + +type BatchCreateReq struct { + g.Meta `path:"/imInvite/batchCreate" method:"post" tags:"IM邀请码" summary:"批量生成IM注册邀请码"` + sysin.ImInviteBatchCreateInp +} + +type BatchCreateRes struct { + *sysin.ImInviteBatchModel +} + +type BatchViewReq struct { + g.Meta `path:"/imInvite/batchView" method:"get" tags:"IM邀请码" summary:"查看IM注册邀请码批次"` + sysin.ImInviteBatchViewInp +} + +type BatchViewRes struct { + *sysin.ImInviteBatchDetailModel +} + +type RevokeReq struct { + g.Meta `path:"/imInvite/revoke" method:"post" tags:"IM邀请码" summary:"撤销IM注册邀请码"` + sysin.ImInviteRevokeInp +} + +type RevokeRes struct{} + +type BatchRevokeReq struct { + g.Meta `path:"/imInvite/batchRevoke" method:"post" tags:"IM邀请码" summary:"批量撤销IM注册邀请码"` + sysin.ImInviteBatchRevokeInp +} + +type BatchRevokeRes struct { + *sysin.ImInviteBatchModel +} + +type ExportReq struct { + g.Meta `path:"/imInvite/export" method:"post" tags:"IM邀请码" summary:"导出IM注册邀请码"` + sysin.ImInviteListInp +} + +type ExportRes struct{} diff --git a/server/api/admin/imrealname/im_realname.go b/server/api/admin/imrealname/im_realname.go new file mode 100644 index 0000000..23b2c95 --- /dev/null +++ b/server/api/admin/imrealname/im_realname.go @@ -0,0 +1,42 @@ +// Package imrealname +// @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 imrealname + +import ( + "hotgo/internal/model/input/form" + "hotgo/internal/model/input/sysin" + + "github.com/gogf/gf/v2/frame/g" +) + +// ListReq 查询IM实名审核列表 +type ListReq struct { + g.Meta `path:"/imRealname/list" method:"get" tags:"IM实名审核" summary:"获取IM实名审核列表"` + sysin.ImRealnameListInp +} + +type ListRes struct { + form.PageRes + List []*sysin.ImRealnameListModel `json:"list" dc:"数据列表"` +} + +// ViewReq 获取IM实名审核指定信息 +type ViewReq struct { + g.Meta `path:"/imRealname/view" method:"get" tags:"IM实名审核" summary:"获取IM实名审核指定信息"` + sysin.ImRealnameViewInp +} + +type ViewRes struct { + *sysin.ImRealnameViewModel +} + +// ReviewReq 审核IM实名 +type ReviewReq struct { + g.Meta `path:"/imRealname/review" method:"post" tags:"IM实名审核" summary:"审核IM实名"` + sysin.ImRealnameReviewInp +} + +type ReviewRes struct{} diff --git a/server/api/admin/imregistration/im_registration.go b/server/api/admin/imregistration/im_registration.go new file mode 100644 index 0000000..11cf040 --- /dev/null +++ b/server/api/admin/imregistration/im_registration.go @@ -0,0 +1,22 @@ +package imregistration + +import ( + "hotgo/internal/model/input/sysin" + + "github.com/gogf/gf/v2/frame/g" +) + +type GetReq struct { + g.Meta `path:"/imRegistration/config/get" method:"get" tags:"IM注册" summary:"获取IM注册配置"` +} + +type GetRes struct { + *sysin.ImRegistrationConfigModel +} + +type UpdateReq struct { + g.Meta `path:"/imRegistration/config/update" method:"post" tags:"IM注册" summary:"更新IM注册配置"` + sysin.ImRegistrationConfigUpdateInp +} + +type UpdateRes struct{} diff --git a/server/api/admin/imtenant/im_tenant.go b/server/api/admin/imtenant/im_tenant.go new file mode 100644 index 0000000..01af2d3 --- /dev/null +++ b/server/api/admin/imtenant/im_tenant.go @@ -0,0 +1,50 @@ +// Package imtenant +// @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 imtenant + +import ( + "hotgo/internal/model/input/form" + "hotgo/internal/model/input/sysin" + + "github.com/gogf/gf/v2/frame/g" +) + +// ListReq 查询IM租户列表 +type ListReq struct { + g.Meta `path:"/imTenant/list" method:"get" tags:"IM租户" summary:"获取IM租户列表"` + sysin.ImTenantListInp +} + +type ListRes struct { + form.PageRes + List []*sysin.ImTenantListModel `json:"list" dc:"数据列表"` +} + +// ViewReq 获取IM租户指定信息 +type ViewReq struct { + g.Meta `path:"/imTenant/view" method:"get" tags:"IM租户" summary:"获取IM租户指定信息"` + sysin.ImTenantViewInp +} + +type ViewRes struct { + *sysin.ImTenantViewModel +} + +// EditReq 修改/新增IM租户 +type EditReq struct { + g.Meta `path:"/imTenant/edit" method:"post" tags:"IM租户" summary:"修改/新增IM租户"` + sysin.ImTenantEditInp +} + +type EditRes struct{} + +// StatusReq 更新IM租户状态 +type StatusReq struct { + g.Meta `path:"/imTenant/status" method:"post" tags:"IM租户" summary:"更新IM租户状态"` + sysin.ImTenantStatusInp +} + +type StatusRes struct{} diff --git a/server/api/admin/imuser/im_user.go b/server/api/admin/imuser/im_user.go new file mode 100644 index 0000000..0aa8481 --- /dev/null +++ b/server/api/admin/imuser/im_user.go @@ -0,0 +1,34 @@ +// Package imuser +// @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 imuser + +import ( + "hotgo/internal/model/input/form" + "hotgo/internal/model/input/sysin" + + "github.com/gogf/gf/v2/frame/g" +) + +// ListReq 查询IM用户列表 +type ListReq struct { + g.Meta `path:"/imUser/list" method:"get" tags:"IM用户" summary:"获取IM用户列表"` + sysin.ImUserListInp +} + +type ListRes struct { + form.PageRes + List []*sysin.ImUserListModel `json:"list" dc:"数据列表"` +} + +// ViewReq 获取IM用户指定信息 +type ViewReq struct { + g.Meta `path:"/imUser/view" method:"get" tags:"IM用户" summary:"获取IM用户指定信息"` + sysin.ImUserViewInp +} + +type ViewRes struct { + *sysin.ImUserViewModel +} diff --git a/server/api/admin/imworkorder/im_work_order.go b/server/api/admin/imworkorder/im_work_order.go new file mode 100644 index 0000000..9a1488d --- /dev/null +++ b/server/api/admin/imworkorder/im_work_order.go @@ -0,0 +1,42 @@ +// Package imworkorder +// @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 imworkorder + +import ( + "hotgo/internal/model/input/form" + "hotgo/internal/model/input/sysin" + + "github.com/gogf/gf/v2/frame/g" +) + +// ListReq 查询IM举报反馈工单列表 +type ListReq struct { + g.Meta `path:"/imWorkOrder/list" method:"get" tags:"IM举报反馈" summary:"获取IM举报反馈工单列表"` + sysin.ImWorkOrderListInp +} + +type ListRes struct { + form.PageRes + List []*sysin.ImWorkOrderListModel `json:"list" dc:"数据列表"` +} + +// ViewReq 获取IM举报反馈工单指定信息 +type ViewReq struct { + g.Meta `path:"/imWorkOrder/view" method:"get" tags:"IM举报反馈" summary:"获取IM举报反馈工单指定信息"` + sysin.ImWorkOrderViewInp +} + +type ViewRes struct { + *sysin.ImWorkOrderViewModel +} + +// StatusReq 更新IM举报反馈工单状态 +type StatusReq struct { + g.Meta `path:"/imWorkOrder/status" method:"post" tags:"IM举报反馈" summary:"更新IM举报反馈工单状态"` + sysin.ImWorkOrderStatusInp +} + +type StatusRes struct{} diff --git a/server/hack/config.yaml b/server/hack/config.yaml index f779bf5..d0e5c50 100644 --- a/server/hack/config.yaml +++ b/server/hack/config.yaml @@ -22,7 +22,7 @@ gfcli: gen: dao: - - link: "mysql:hotgo:hg123456.@tcp(127.0.0.1:3306)/hotgo?loc=Local&parseTime=true&charset=utf8mb4" + - link: "${HGGEN_DAO_LINK}" # - link: "pgsql:postgres:hg123456@tcp(127.0.0.1:5432)/hotgo" group: "default" # 分组 使用hotgo代码生成功能时必须填 # tables: "" # 指定当前数据库中需要执行代码生成的数据表。如果为空,表示数据库的所有表都会生成。 diff --git a/server/internal/consts/im_group.go b/server/internal/consts/im_group.go new file mode 100644 index 0000000..1649dd9 --- /dev/null +++ b/server/internal/consts/im_group.go @@ -0,0 +1,105 @@ +// 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("ImGroupStateOptions", "IM群组状态选项", ImGroupStateOptions) + dict.RegisterEnums("ImGroupStatusOptions", "IM群业务状态选项", ImGroupStatusOptions) + dict.RegisterEnums("ImGroupRoleOptions", "IM群成员角色选项", ImGroupRoleOptions) + dict.RegisterEnums("ImGroupMemberStatusOptions", "IM群成员状态选项", ImGroupMemberStatusOptions) + dict.RegisterEnums("ImGroupRequestStatusOptions", "IM入群申请状态选项", ImGroupRequestStatusOptions) + dict.RegisterEnums("ImGroupMuteStatusOptions", "IM禁言状态选项", ImGroupMuteStatusOptions) + dict.RegisterEnums("ImGroupAnnouncementStatusOptions", "IM群公告状态选项", ImGroupAnnouncementStatusOptions) + dict.RegisterEnums("ImGroupOutboxStatusOptions", "IM群事件状态选项", ImGroupOutboxStatusOptions) +} + +var ImGroupStateOptions = []*model.Option{ + dict.GenSuccessOption(ImUserStateOk, "正常"), + dict.GenErrorOption(ImUserStateSuspended, "已停用"), + dict.GenDefaultOption(ImUserStateDeleted, "已删除"), + dict.GenWarningOption(ImUserStateUndefined, "未定义"), +} + +const ( + ImGroupStatusActive = "active" + ImGroupStatusDissolved = "dissolved" + ImGroupStatusDisabled = "disabled" + + ImGroupRoleOwner = "owner" + ImGroupRoleAdmin = "admin" + ImGroupRoleMember = "member" + + ImGroupMemberStatusActive = "active" + ImGroupMemberStatusRemoved = "removed" + ImGroupMemberStatusLeft = "left" + + ImGroupRequestStatusPending = "pending" + ImGroupRequestStatusAccepted = "accepted" + ImGroupRequestStatusRejected = "rejected" + ImGroupRequestStatusExpired = "expired" + ImGroupRequestStatusCanceled = "canceled" + + ImGroupMuteStatusActive = "active" + ImGroupMuteStatusRevoked = "revoked" + ImGroupMuteStatusExpired = "expired" + + ImGroupAnnouncementStatusCurrent = "current" + ImGroupAnnouncementStatusHistory = "history" + ImGroupAnnouncementStatusDeleted = "deleted" + + ImGroupOutboxStatusPending = "pending" + ImGroupOutboxStatusDelivered = "delivered" + ImGroupOutboxStatusFailed = "failed" +) + +var ImGroupStatusOptions = []*model.Option{ + dict.GenSuccessOption(ImGroupStatusActive, "正常"), + dict.GenDefaultOption(ImGroupStatusDissolved, "已解散"), + dict.GenErrorOption(ImGroupStatusDisabled, "已停用"), +} + +var ImGroupRoleOptions = []*model.Option{ + dict.GenPrimaryOption(ImGroupRoleOwner, "群主"), + dict.GenSuccessOption(ImGroupRoleAdmin, "管理员"), + dict.GenDefaultOption(ImGroupRoleMember, "成员"), +} + +var ImGroupMemberStatusOptions = []*model.Option{ + dict.GenSuccessOption(ImGroupMemberStatusActive, "正常"), + dict.GenDefaultOption(ImGroupMemberStatusRemoved, "已移除"), + dict.GenWarningOption(ImGroupMemberStatusLeft, "已退出"), +} + +var ImGroupRequestStatusOptions = []*model.Option{ + dict.GenWarningOption(ImGroupRequestStatusPending, "待处理"), + dict.GenSuccessOption(ImGroupRequestStatusAccepted, "已同意"), + dict.GenErrorOption(ImGroupRequestStatusRejected, "已拒绝"), + dict.GenDefaultOption(ImGroupRequestStatusExpired, "已过期"), + dict.GenDefaultOption(ImGroupRequestStatusCanceled, "已取消"), +} + +var ImGroupMuteStatusOptions = []*model.Option{ + dict.GenErrorOption(ImGroupMuteStatusActive, "禁言中"), + dict.GenDefaultOption(ImGroupMuteStatusRevoked, "已解除"), + dict.GenWarningOption(ImGroupMuteStatusExpired, "已过期"), +} + +var ImGroupAnnouncementStatusOptions = []*model.Option{ + dict.GenSuccessOption(ImGroupAnnouncementStatusCurrent, "当前"), + dict.GenDefaultOption(ImGroupAnnouncementStatusHistory, "历史"), + dict.GenErrorOption(ImGroupAnnouncementStatusDeleted, "已删除"), +} + +var ImGroupOutboxStatusOptions = []*model.Option{ + dict.GenWarningOption(ImGroupOutboxStatusPending, "待投递"), + dict.GenSuccessOption(ImGroupOutboxStatusDelivered, "已投递"), + dict.GenErrorOption(ImGroupOutboxStatusFailed, "投递失败"), +} diff --git a/server/internal/consts/im_security.go b/server/internal/consts/im_security.go new file mode 100644 index 0000000..94ca8f6 --- /dev/null +++ b/server/internal/consts/im_security.go @@ -0,0 +1,44 @@ +// 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("ImRealnameStatusOptions", "IM实名状态选项", ImRealnameStatusOptions) + dict.RegisterEnums("ImWorkOrderStatusOptions", "IM工单状态选项", ImWorkOrderStatusOptions) +} + +const ( + ImRealnameStatusPending = "pending" + ImRealnameStatusVerified = "verified" + ImRealnameStatusRejected = "rejected" +) + +var ImRealnameStatusOptions = []*model.Option{ + dict.GenWarningOption(ImRealnameStatusPending, "待审核"), + dict.GenSuccessOption(ImRealnameStatusVerified, "已通过"), + dict.GenErrorOption(ImRealnameStatusRejected, "已拒绝"), +} + +const ( + ImWorkOrderStatusReceived = "received" + ImWorkOrderStatusProcessing = "processing" + ImWorkOrderStatusResolved = "resolved" + ImWorkOrderStatusRejected = "rejected" + ImWorkOrderStatusClosed = "closed" +) + +var ImWorkOrderStatusOptions = []*model.Option{ + dict.GenWarningOption(ImWorkOrderStatusReceived, "待处理"), + dict.GenPrimaryOption(ImWorkOrderStatusProcessing, "处理中"), + dict.GenSuccessOption(ImWorkOrderStatusResolved, "已解决"), + dict.GenErrorOption(ImWorkOrderStatusRejected, "已驳回"), + dict.GenDefaultOption(ImWorkOrderStatusClosed, "已关闭"), +} diff --git a/server/internal/consts/im_tenant.go b/server/internal/consts/im_tenant.go new file mode 100644 index 0000000..464182a --- /dev/null +++ b/server/internal/consts/im_tenant.go @@ -0,0 +1,31 @@ +// 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("ImTenantStateOptions", "IM租户状态选项", ImTenantStateOptions) +} + +const ( + ImTenantStateProvisioning = 0 // 初始化中 + ImTenantStateActive = 1 // 正常 + ImTenantStateSuspended = 2 // 已停用 + ImTenantStateDeleting = 3 // 删除中 + ImTenantStateDeleted = 4 // 已删除 +) + +var ImTenantStateOptions = []*model.Option{ + dict.GenWarningOption(ImTenantStateProvisioning, "初始化中"), + dict.GenSuccessOption(ImTenantStateActive, "正常"), + dict.GenErrorOption(ImTenantStateSuspended, "已停用"), + dict.GenWarningOption(ImTenantStateDeleting, "删除中"), + dict.GenDefaultOption(ImTenantStateDeleted, "已删除"), +} diff --git a/server/internal/consts/im_user.go b/server/internal/consts/im_user.go new file mode 100644 index 0000000..c6b7aa4 --- /dev/null +++ b/server/internal/consts/im_user.go @@ -0,0 +1,29 @@ +// 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("ImUserStateOptions", "IM用户状态选项", ImUserStateOptions) +} + +const ( + ImUserStateOk = 0 // 正常 + ImUserStateSuspended = 10 // 已停用 + ImUserStateDeleted = 20 // 已删除 + ImUserStateUndefined = 30 // 未定义 +) + +var ImUserStateOptions = []*model.Option{ + dict.GenSuccessOption(ImUserStateOk, "正常"), + dict.GenErrorOption(ImUserStateSuspended, "已停用"), + dict.GenDefaultOption(ImUserStateDeleted, "已删除"), + dict.GenWarningOption(ImUserStateUndefined, "未定义"), +} diff --git a/server/internal/controller/admin/common/site.go b/server/internal/controller/admin/common/site.go index 6dc1a31..25c267f 100644 --- a/server/internal/controller/admin/common/site.go +++ b/server/internal/controller/admin/common/site.go @@ -7,7 +7,6 @@ package common import ( "context" - "github.com/gogf/gf/v2/i18n/gi18n" "hotgo/api/admin/common" "hotgo/internal/consts" "hotgo/internal/library/captcha" @@ -93,7 +92,7 @@ func (c *cSite) LoginConfig(ctx context.Context, _ *common.SiteLoginConfigReq) ( res.LoginConfig = login res.I18nSwitch = g.Cfg().MustGet(ctx, "system.i18n.switch", true).Bool() res.DefaultLanguage = g.Cfg().MustGet(ctx, "system.i18n.defaultLanguage", consts.SysDefaultLanguage).String() - res.ProjectName = gi18n.T(ctx, "HotGo管理系统") + res.ProjectName = "IM办公管理系统" return } diff --git a/server/internal/controller/admin/sys/im_audit_log.go b/server/internal/controller/admin/sys/im_audit_log.go new file mode 100644 index 0000000..460eba1 --- /dev/null +++ b/server/internal/controller/admin/sys/im_audit_log.go @@ -0,0 +1,43 @@ +// 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/imauditlog" + "hotgo/internal/model/input/sysin" + "hotgo/internal/service" +) + +var ImAuditLog = cImAuditLog{} + +type cImAuditLog struct{} + +// List 查看IM审计日志列表 +func (c *cImAuditLog) List(ctx context.Context, req *imauditlog.ListReq) (res *imauditlog.ListRes, err error) { + list, totalCount, err := service.SysImAuditLog().List(ctx, &req.ImAuditLogListInp) + if err != nil { + return + } + if list == nil { + list = []*sysin.ImAuditLogListModel{} + } + res = new(imauditlog.ListRes) + res.List = list + res.PageRes.Pack(req, totalCount) + return +} + +// View 获取指定IM审计日志信息 +func (c *cImAuditLog) View(ctx context.Context, req *imauditlog.ViewReq) (res *imauditlog.ViewRes, err error) { + data, err := service.SysImAuditLog().View(ctx, &req.ImAuditLogViewInp) + if err != nil { + return + } + res = new(imauditlog.ViewRes) + res.ImAuditLogViewModel = data + return +} diff --git a/server/internal/controller/admin/sys/im_group.go b/server/internal/controller/admin/sys/im_group.go new file mode 100644 index 0000000..ca171fd --- /dev/null +++ b/server/internal/controller/admin/sys/im_group.go @@ -0,0 +1,151 @@ +// 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/imgroup" + "hotgo/internal/model/input/sysin" + "hotgo/internal/service" +) + +var ( + ImGroup = cImGroup{} +) + +type cImGroup struct{} + +// List 查看IM群组列表 +func (c *cImGroup) List(ctx context.Context, req *imgroup.ListReq) (res *imgroup.ListRes, err error) { + list, totalCount, err := service.SysImGroup().List(ctx, &req.ImGroupListInp) + if err != nil { + return + } + + if list == nil { + list = []*sysin.ImGroupListModel{} + } + + res = new(imgroup.ListRes) + res.List = list + res.PageRes.Pack(req, totalCount) + return +} + +// View 获取指定IM群组信息 +func (c *cImGroup) View(ctx context.Context, req *imgroup.ViewReq) (res *imgroup.ViewRes, err error) { + data, err := service.SysImGroup().View(ctx, &req.ImGroupViewInp) + if err != nil { + return + } + + res = new(imgroup.ViewRes) + res.ImGroupViewModel = data + return +} + +// MemberList 查看IM群成员列表 +func (c *cImGroup) MemberList(ctx context.Context, req *imgroup.MemberListReq) (res *imgroup.MemberListRes, err error) { + list, totalCount, err := service.SysImGroup().MemberList(ctx, &req.ImGroupMemberListInp) + if err != nil { + return + } + + if list == nil { + list = []*sysin.ImGroupMemberListModel{} + } + + res = new(imgroup.MemberListRes) + res.List = list + res.PageRes.Pack(req, totalCount) + return +} + +// Governance 获取IM群治理配置 +func (c *cImGroup) Governance(ctx context.Context, req *imgroup.GovernanceReq) (res *imgroup.GovernanceRes, err error) { + data, err := service.SysImGroup().Governance(ctx, &req.ImGroupTopicInp) + if err != nil { + return + } + res = new(imgroup.GovernanceRes) + res.ImGroupGovernanceModel = data + return +} + +// JoinRequestList 查看IM入群申请列表 +func (c *cImGroup) JoinRequestList(ctx context.Context, req *imgroup.JoinRequestListReq) (res *imgroup.JoinRequestListRes, err error) { + list, totalCount, err := service.SysImGroup().JoinRequestList(ctx, &req.ImGroupJoinRequestListInp) + if err != nil { + return + } + if list == nil { + list = []*sysin.ImGroupJoinRequestListModel{} + } + res = new(imgroup.JoinRequestListRes) + res.List = list + res.PageRes.Pack(req, totalCount) + return +} + +// InvitationList 查看IM群邀请列表 +func (c *cImGroup) InvitationList(ctx context.Context, req *imgroup.InvitationListReq) (res *imgroup.InvitationListRes, err error) { + list, totalCount, err := service.SysImGroup().InvitationList(ctx, &req.ImGroupInvitationListInp) + if err != nil { + return + } + if list == nil { + list = []*sysin.ImGroupInvitationListModel{} + } + res = new(imgroup.InvitationListRes) + res.List = list + res.PageRes.Pack(req, totalCount) + return +} + +// AnnouncementList 查看IM群公告列表 +func (c *cImGroup) AnnouncementList(ctx context.Context, req *imgroup.AnnouncementListReq) (res *imgroup.AnnouncementListRes, err error) { + list, totalCount, err := service.SysImGroup().AnnouncementList(ctx, &req.ImGroupAnnouncementListInp) + if err != nil { + return + } + if list == nil { + list = []*sysin.ImGroupAnnouncementListModel{} + } + res = new(imgroup.AnnouncementListRes) + res.List = list + res.PageRes.Pack(req, totalCount) + return +} + +// MuteList 查看IM群禁言列表 +func (c *cImGroup) MuteList(ctx context.Context, req *imgroup.MuteListReq) (res *imgroup.MuteListRes, err error) { + list, totalCount, err := service.SysImGroup().MuteList(ctx, &req.ImGroupMuteListInp) + if err != nil { + return + } + if list == nil { + list = []*sysin.ImGroupMuteListModel{} + } + res = new(imgroup.MuteListRes) + res.List = list + res.PageRes.Pack(req, totalCount) + return +} + +// AdminLogList 查看IM群管理日志列表 +func (c *cImGroup) AdminLogList(ctx context.Context, req *imgroup.AdminLogListReq) (res *imgroup.AdminLogListRes, err error) { + list, totalCount, err := service.SysImGroup().AdminLogList(ctx, &req.ImGroupAdminLogListInp) + if err != nil { + return + } + if list == nil { + list = []*sysin.ImGroupAdminLogListModel{} + } + res = new(imgroup.AdminLogListRes) + res.List = list + res.PageRes.Pack(req, totalCount) + return +} diff --git a/server/internal/controller/admin/sys/im_invite.go b/server/internal/controller/admin/sys/im_invite.go new file mode 100644 index 0000000..f9761dc --- /dev/null +++ b/server/internal/controller/admin/sys/im_invite.go @@ -0,0 +1,76 @@ +package sys + +import ( + "context" + "hotgo/api/admin/iminvite" + "hotgo/internal/model/input/sysin" + "hotgo/internal/service" +) + +var ImInvite = cImInvite{} + +type cImInvite struct{} + +func (c *cImInvite) List(ctx context.Context, req *iminvite.ListReq) (res *iminvite.ListRes, err error) { + list, totalCount, err := service.SysImInvite().List(ctx, &req.ImInviteListInp) + if err != nil { + return + } + if list == nil { + list = []*sysin.ImInviteCodeModel{} + } + res = new(iminvite.ListRes) + res.List = list + res.PageRes.Pack(req, totalCount) + return +} + +func (c *cImInvite) Create(ctx context.Context, req *iminvite.CreateReq) (res *iminvite.CreateRes, err error) { + data, err := service.SysImInvite().Create(ctx, &req.ImInviteCreateInp) + if err != nil { + return + } + res = new(iminvite.CreateRes) + res.ImInviteCodeModel = data + return +} + +func (c *cImInvite) BatchCreate(ctx context.Context, req *iminvite.BatchCreateReq) (res *iminvite.BatchCreateRes, err error) { + data, err := service.SysImInvite().BatchCreate(ctx, &req.ImInviteBatchCreateInp) + if err != nil { + return + } + res = new(iminvite.BatchCreateRes) + res.ImInviteBatchModel = data + return +} + +func (c *cImInvite) BatchView(ctx context.Context, req *iminvite.BatchViewReq) (res *iminvite.BatchViewRes, err error) { + data, err := service.SysImInvite().BatchView(ctx, &req.ImInviteBatchViewInp) + if err != nil { + return + } + res = new(iminvite.BatchViewRes) + res.ImInviteBatchDetailModel = data + return +} + +func (c *cImInvite) Revoke(ctx context.Context, req *iminvite.RevokeReq) (res *iminvite.RevokeRes, err error) { + err = service.SysImInvite().Revoke(ctx, &req.ImInviteRevokeInp) + return +} + +func (c *cImInvite) BatchRevoke(ctx context.Context, req *iminvite.BatchRevokeReq) (res *iminvite.BatchRevokeRes, err error) { + data, err := service.SysImInvite().BatchRevoke(ctx, &req.ImInviteBatchRevokeInp) + if err != nil { + return + } + res = new(iminvite.BatchRevokeRes) + res.ImInviteBatchModel = data + return +} + +func (c *cImInvite) Export(ctx context.Context, req *iminvite.ExportReq) (res *iminvite.ExportRes, err error) { + err = service.SysImInvite().Export(ctx, &req.ImInviteListInp) + return +} diff --git a/server/internal/controller/admin/sys/im_realname.go b/server/internal/controller/admin/sys/im_realname.go new file mode 100644 index 0000000..f9619ff --- /dev/null +++ b/server/internal/controller/admin/sys/im_realname.go @@ -0,0 +1,53 @@ +// 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/imrealname" + "hotgo/internal/model/input/sysin" + "hotgo/internal/service" +) + +var ( + ImRealname = cImRealname{} +) + +type cImRealname struct{} + +// List 查看IM实名审核列表 +func (c *cImRealname) List(ctx context.Context, req *imrealname.ListReq) (res *imrealname.ListRes, err error) { + list, totalCount, err := service.SysImRealname().List(ctx, &req.ImRealnameListInp) + if err != nil { + return + } + if list == nil { + list = []*sysin.ImRealnameListModel{} + } + + res = new(imrealname.ListRes) + res.List = list + res.PageRes.Pack(req, totalCount) + return +} + +// View 获取指定IM实名审核信息 +func (c *cImRealname) View(ctx context.Context, req *imrealname.ViewReq) (res *imrealname.ViewRes, err error) { + data, err := service.SysImRealname().View(ctx, &req.ImRealnameViewInp) + if err != nil { + return + } + + res = new(imrealname.ViewRes) + res.ImRealnameViewModel = data + return +} + +// Review 审核IM实名 +func (c *cImRealname) Review(ctx context.Context, req *imrealname.ReviewReq) (res *imrealname.ReviewRes, err error) { + err = service.SysImRealname().Review(ctx, &req.ImRealnameReviewInp) + return +} diff --git a/server/internal/controller/admin/sys/im_registration.go b/server/internal/controller/admin/sys/im_registration.go new file mode 100644 index 0000000..834e701 --- /dev/null +++ b/server/internal/controller/admin/sys/im_registration.go @@ -0,0 +1,26 @@ +package sys + +import ( + "context" + "hotgo/api/admin/imregistration" + "hotgo/internal/service" +) + +var ImRegistration = cImRegistration{} + +type cImRegistration struct{} + +func (c *cImRegistration) Get(ctx context.Context, req *imregistration.GetReq) (res *imregistration.GetRes, err error) { + data, err := service.SysImRegistration().GetConfig(ctx) + if err != nil { + return + } + res = new(imregistration.GetRes) + res.ImRegistrationConfigModel = data + return +} + +func (c *cImRegistration) Update(ctx context.Context, req *imregistration.UpdateReq) (res *imregistration.UpdateRes, err error) { + err = service.SysImRegistration().UpdateConfig(ctx, &req.ImRegistrationConfigUpdateInp) + return +} diff --git a/server/internal/controller/admin/sys/im_tenant.go b/server/internal/controller/admin/sys/im_tenant.go new file mode 100644 index 0000000..3acd490 --- /dev/null +++ b/server/internal/controller/admin/sys/im_tenant.go @@ -0,0 +1,60 @@ +// 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/imtenant" + "hotgo/internal/model/input/sysin" + "hotgo/internal/service" +) + +var ( + ImTenant = cImTenant{} +) + +type cImTenant struct{} + +// List 查看IM租户列表 +func (c *cImTenant) List(ctx context.Context, req *imtenant.ListReq) (res *imtenant.ListRes, err error) { + list, totalCount, err := service.SysImTenant().List(ctx, &req.ImTenantListInp) + if err != nil { + return + } + + if list == nil { + list = []*sysin.ImTenantListModel{} + } + + res = new(imtenant.ListRes) + res.List = list + res.PageRes.Pack(req, totalCount) + return +} + +// Edit 更新IM租户 +func (c *cImTenant) Edit(ctx context.Context, req *imtenant.EditReq) (res *imtenant.EditRes, err error) { + err = service.SysImTenant().Edit(ctx, &req.ImTenantEditInp) + return +} + +// View 获取指定IM租户信息 +func (c *cImTenant) View(ctx context.Context, req *imtenant.ViewReq) (res *imtenant.ViewRes, err error) { + data, err := service.SysImTenant().View(ctx, &req.ImTenantViewInp) + if err != nil { + return + } + + res = new(imtenant.ViewRes) + res.ImTenantViewModel = data + return +} + +// Status 更新IM租户状态 +func (c *cImTenant) Status(ctx context.Context, req *imtenant.StatusReq) (res *imtenant.StatusRes, err error) { + err = service.SysImTenant().Status(ctx, &req.ImTenantStatusInp) + return +} diff --git a/server/internal/controller/admin/sys/im_user.go b/server/internal/controller/admin/sys/im_user.go new file mode 100644 index 0000000..f350765 --- /dev/null +++ b/server/internal/controller/admin/sys/im_user.go @@ -0,0 +1,48 @@ +// 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/imuser" + "hotgo/internal/model/input/sysin" + "hotgo/internal/service" +) + +var ( + ImUser = cImUser{} +) + +type cImUser struct{} + +// List 查看IM用户列表 +func (c *cImUser) List(ctx context.Context, req *imuser.ListReq) (res *imuser.ListRes, err error) { + list, totalCount, err := service.SysImUser().List(ctx, &req.ImUserListInp) + if err != nil { + return + } + + if list == nil { + list = []*sysin.ImUserListModel{} + } + + res = new(imuser.ListRes) + res.List = list + res.PageRes.Pack(req, totalCount) + return +} + +// View 获取指定IM用户信息 +func (c *cImUser) View(ctx context.Context, req *imuser.ViewReq) (res *imuser.ViewRes, err error) { + data, err := service.SysImUser().View(ctx, &req.ImUserViewInp) + if err != nil { + return + } + + res = new(imuser.ViewRes) + res.ImUserViewModel = data + return +} diff --git a/server/internal/controller/admin/sys/im_work_order.go b/server/internal/controller/admin/sys/im_work_order.go new file mode 100644 index 0000000..b5d1eb5 --- /dev/null +++ b/server/internal/controller/admin/sys/im_work_order.go @@ -0,0 +1,53 @@ +// 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/imworkorder" + "hotgo/internal/model/input/sysin" + "hotgo/internal/service" +) + +var ( + ImWorkOrder = cImWorkOrder{} +) + +type cImWorkOrder struct{} + +// List 查看IM举报反馈工单列表 +func (c *cImWorkOrder) List(ctx context.Context, req *imworkorder.ListReq) (res *imworkorder.ListRes, err error) { + list, totalCount, err := service.SysImWorkOrder().List(ctx, &req.ImWorkOrderListInp) + if err != nil { + return + } + if list == nil { + list = []*sysin.ImWorkOrderListModel{} + } + + res = new(imworkorder.ListRes) + res.List = list + res.PageRes.Pack(req, totalCount) + return +} + +// View 获取指定IM举报反馈工单信息 +func (c *cImWorkOrder) View(ctx context.Context, req *imworkorder.ViewReq) (res *imworkorder.ViewRes, err error) { + data, err := service.SysImWorkOrder().View(ctx, &req.ImWorkOrderViewInp) + if err != nil { + return + } + + res = new(imworkorder.ViewRes) + res.ImWorkOrderViewModel = data + return +} + +// Status 更新IM举报反馈工单状态 +func (c *cImWorkOrder) Status(ctx context.Context, req *imworkorder.StatusReq) (res *imworkorder.StatusRes, err error) { + err = service.SysImWorkOrder().Status(ctx, &req.ImWorkOrderStatusInp) + return +} diff --git a/server/internal/dao/im_tenant.go b/server/internal/dao/im_tenant.go new file mode 100644 index 0000000..a39dc63 --- /dev/null +++ b/server/internal/dao/im_tenant.go @@ -0,0 +1,22 @@ +// ================================================================================= +// This file is auto-generated by the GoFrame CLI tool. You may modify it as needed. +// ================================================================================= + +package dao + +import ( + "hotgo/internal/dao/internal" +) + +// imTenantDao is the data access object for the table im_tenant. +// You can define custom methods on it to extend its functionality as needed. +type imTenantDao struct { + *internal.ImTenantDao +} + +var ( + // ImTenant is a globally accessible object for table im_tenant operations. + ImTenant = imTenantDao{internal.NewImTenantDao()} +) + +// Add your custom methods and functionality below. diff --git a/server/internal/dao/im_tenant_config.go b/server/internal/dao/im_tenant_config.go new file mode 100644 index 0000000..bb54153 --- /dev/null +++ b/server/internal/dao/im_tenant_config.go @@ -0,0 +1,22 @@ +// ================================================================================= +// This file is auto-generated by the GoFrame CLI tool. You may modify it as needed. +// ================================================================================= + +package dao + +import ( + "hotgo/internal/dao/internal" +) + +// imTenantConfigDao is the data access object for the table im_tenant_config. +// You can define custom methods on it to extend its functionality as needed. +type imTenantConfigDao struct { + *internal.ImTenantConfigDao +} + +var ( + // ImTenantConfig is a globally accessible object for table im_tenant_config operations. + ImTenantConfig = imTenantConfigDao{internal.NewImTenantConfigDao()} +) + +// Add your custom methods and functionality below. diff --git a/server/internal/dao/internal/admin_member.go b/server/internal/dao/internal/admin_member.go index 93ebaa7..0d62e56 100755 --- a/server/internal/dao/internal/admin_member.go +++ b/server/internal/dao/internal/admin_member.go @@ -22,6 +22,7 @@ type AdminMemberDao struct { // AdminMemberColumns defines and stores column names for the table hg_admin_member. type AdminMemberColumns struct { Id string // 管理员ID + TenantId string // IM租户ID DeptId string // 部门ID RoleId string // 角色ID RealName string // 真实姓名 @@ -54,6 +55,7 @@ type AdminMemberColumns struct { // adminMemberColumns holds the columns for the table hg_admin_member. var adminMemberColumns = AdminMemberColumns{ Id: "id", + TenantId: "tenant_id", DeptId: "dept_id", RoleId: "role_id", RealName: "real_name", diff --git a/server/internal/dao/internal/im_tenant.go b/server/internal/dao/internal/im_tenant.go new file mode 100644 index 0000000..f86ea9a --- /dev/null +++ b/server/internal/dao/internal/im_tenant.go @@ -0,0 +1,99 @@ +// ========================================================================== +// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT. +// ========================================================================== + +package internal + +import ( + "context" + + "github.com/gogf/gf/v2/database/gdb" + "github.com/gogf/gf/v2/frame/g" +) + +// ImTenantDao is the data access object for the table im_tenant. +type ImTenantDao struct { + table string // table is the underlying table name of the DAO. + group string // group is the database configuration group name of the current DAO. + columns ImTenantColumns // columns contains all the column names of Table for convenient usage. + handlers []gdb.ModelHandler // handlers for customized model modification. +} + +// ImTenantColumns defines and stores column names for the table im_tenant. +type ImTenantColumns struct { + Id string // + Code string // + Name string // + TenantDesc string // + DbName string // + DbDsn string // + State string // + CreatedAt string // + CreatedBy string // + UpdatedAt string // + UpdatedBy string // +} + +// imTenantColumns holds the columns for the table im_tenant. +var imTenantColumns = ImTenantColumns{ + Id: "id", + Code: "code", + Name: "name", + TenantDesc: "tenant_desc", + DbName: "db_name", + DbDsn: "db_dsn", + State: "state", + CreatedAt: "created_at", + CreatedBy: "created_by", + UpdatedAt: "updated_at", + UpdatedBy: "updated_by", +} + +// NewImTenantDao creates and returns a new DAO object for table data access. +func NewImTenantDao(handlers ...gdb.ModelHandler) *ImTenantDao { + return &ImTenantDao{ + group: "default", + table: "im_tenant", + columns: imTenantColumns, + handlers: handlers, + } +} + +// DB retrieves and returns the underlying raw database management object of the current DAO. +func (dao *ImTenantDao) DB() gdb.DB { + return g.DB(dao.group) +} + +// Table returns the table name of the current DAO. +func (dao *ImTenantDao) Table() string { + return dao.table +} + +// Columns returns all column names of the current DAO. +func (dao *ImTenantDao) Columns() ImTenantColumns { + return dao.columns +} + +// Group returns the database configuration group name of the current DAO. +func (dao *ImTenantDao) Group() string { + return dao.group +} + +// Ctx creates and returns a Model for the current DAO. It automatically sets the context for the current operation. +func (dao *ImTenantDao) Ctx(ctx context.Context) *gdb.Model { + model := dao.DB().Model(dao.table) + for _, handler := range dao.handlers { + model = handler(model) + } + return model.Safe().Ctx(ctx) +} + +// Transaction wraps the transaction logic using function f. +// It rolls back the transaction and returns the error if function f returns a non-nil error. +// It commits the transaction and returns nil if function f returns nil. +// +// Note: Do not commit or roll back the transaction in function f, +// as it is automatically handled by this function. +func (dao *ImTenantDao) Transaction(ctx context.Context, f func(ctx context.Context, tx gdb.TX) error) (err error) { + return dao.Ctx(ctx).Transaction(ctx, f) +} diff --git a/server/internal/dao/internal/im_tenant_config.go b/server/internal/dao/internal/im_tenant_config.go new file mode 100644 index 0000000..3e60943 --- /dev/null +++ b/server/internal/dao/internal/im_tenant_config.go @@ -0,0 +1,95 @@ +// ========================================================================== +// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT. +// ========================================================================== + +package internal + +import ( + "context" + + "github.com/gogf/gf/v2/database/gdb" + "github.com/gogf/gf/v2/frame/g" +) + +// ImTenantConfigDao is the data access object for the table im_tenant_config. +type ImTenantConfigDao struct { + table string // table is the underlying table name of the DAO. + group string // group is the database configuration group name of the current DAO. + columns ImTenantConfigColumns // columns contains all the column names of Table for convenient usage. + handlers []gdb.ModelHandler // handlers for customized model modification. +} + +// ImTenantConfigColumns defines and stores column names for the table im_tenant_config. +type ImTenantConfigColumns struct { + Id string // + TenantId string // + MaxUsers string // 0 means unlimited + MaxGroups string // 0 means unlimited + MaxGroupMembers string // 0 means unlimited + CreatedAt string // + CreatedBy string // + UpdatedAt string // + UpdatedBy string // +} + +// imTenantConfigColumns holds the columns for the table im_tenant_config. +var imTenantConfigColumns = ImTenantConfigColumns{ + Id: "id", + TenantId: "tenant_id", + MaxUsers: "max_users", + MaxGroups: "max_groups", + MaxGroupMembers: "max_group_members", + CreatedAt: "created_at", + CreatedBy: "created_by", + UpdatedAt: "updated_at", + UpdatedBy: "updated_by", +} + +// NewImTenantConfigDao creates and returns a new DAO object for table data access. +func NewImTenantConfigDao(handlers ...gdb.ModelHandler) *ImTenantConfigDao { + return &ImTenantConfigDao{ + group: "default", + table: "im_tenant_config", + columns: imTenantConfigColumns, + handlers: handlers, + } +} + +// DB retrieves and returns the underlying raw database management object of the current DAO. +func (dao *ImTenantConfigDao) DB() gdb.DB { + return g.DB(dao.group) +} + +// Table returns the table name of the current DAO. +func (dao *ImTenantConfigDao) Table() string { + return dao.table +} + +// Columns returns all column names of the current DAO. +func (dao *ImTenantConfigDao) Columns() ImTenantConfigColumns { + return dao.columns +} + +// Group returns the database configuration group name of the current DAO. +func (dao *ImTenantConfigDao) Group() string { + return dao.group +} + +// Ctx creates and returns a Model for the current DAO. It automatically sets the context for the current operation. +func (dao *ImTenantConfigDao) Ctx(ctx context.Context) *gdb.Model { + model := dao.DB().Model(dao.table) + for _, handler := range dao.handlers { + model = handler(model) + } + return model.Safe().Ctx(ctx) +} + +// Transaction wraps the transaction logic using function f. +// It rolls back the transaction and returns the error if function f returns a non-nil error. +// It commits the transaction and returns nil if function f returns nil. +// +// Note: Do not commit or roll back the transaction in function f, +// as it is automatically handled by this function. +func (dao *ImTenantConfigDao) Transaction(ctx context.Context, f func(ctx context.Context, tx gdb.TX) error) (err error) { + return dao.Ctx(ctx).Transaction(ctx, f) +} diff --git a/server/internal/library/contexts/context.go b/server/internal/library/contexts/context.go index b9cd6bd..9962e17 100644 --- a/server/internal/library/contexts/context.go +++ b/server/internal/library/contexts/context.go @@ -78,6 +78,15 @@ func GetUserId(ctx context.Context) int64 { return user.Id } +// GetTenantId 获取当前身份关联的统一租户ID(im_tenant.id)。 +func GetTenantId(ctx context.Context) int64 { + user := GetUser(ctx) + if user == nil { + return 0 + } + return user.TenantId +} + // GetRoleId 获取用户角色ID func GetRoleId(ctx context.Context) int64 { user := GetUser(ctx) 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..4a78d4e --- /dev/null +++ b/server/internal/library/imadmin/config.go @@ -0,0 +1,37 @@ +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"` +} + +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") + } + return Config{ + BaseUrl: g.Cfg().MustGet(ctx, "imAdminApi.baseUrl").String(), + ServiceId: g.Cfg().MustGet(ctx, "imAdminApi.serviceId", "hotgo-admin").String(), + ServiceSecret: serviceSecret, + Timeout: timeout, + } +} diff --git a/server/internal/library/imtenant/db.go b/server/internal/library/imtenant/db.go new file mode 100644 index 0000000..4541f17 --- /dev/null +++ b/server/internal/library/imtenant/db.go @@ -0,0 +1,128 @@ +// Package imtenant provides helpers for accessing tenant business databases. +package imtenant + +import ( + "context" + "fmt" + "hash/fnv" + "hotgo/internal/dao" + "hotgo/internal/model/entity" + "strings" + "sync" + + _ "github.com/gogf/gf/contrib/drivers/mysql/v2" + "github.com/gogf/gf/v2/database/gdb" + "github.com/gogf/gf/v2/errors/gerror" + "github.com/gogf/gf/v2/frame/g" +) + +var tenantDbGroups sync.Map + +const registryDbGroup = "imRegistry" + +// RegistryDB returns the tenant registry DB. It falls back to the default DB for +// single-database deployments where database.imRegistry is not configured. +func RegistryDB(ctx context.Context) gdb.DB { + if configs, err := gdb.GetConfigGroup(registryDbGroup); err == nil && len(configs) > 0 { + return g.DB(registryDbGroup) + } + return g.DB() +} + +// 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 { + return nil, nil, gerror.New("租户ID不能为空") + } + + if err = RegistryDB(ctx).Model(dao.ImTenant.Table()).Safe().Ctx(ctx).WherePri(tenantId).Scan(&tenant); err != nil { + return nil, nil, gerror.Wrap(err, "获取租户信息失败") + } + if tenant == nil || tenant.Id <= 0 { + return nil, nil, gerror.New("租户不存在") + } + + link, err := tenantLink(ctx, tenant) + if err != nil { + return nil, nil, err + } + + group := fmt.Sprintf("im_tenant_%d_%x", tenant.Id, hash(link)) + if cached, ok := tenantDbGroups.Load(group); ok && cached == link { + db, err = gdb.Instance(group) + return db, tenant, err + } + + if err = gdb.SetConfigGroup(group, gdb.ConfigGroup{{Link: link}}); err != nil { + return nil, nil, gerror.Wrap(err, "初始化租户业务库连接失败") + } + tenantDbGroups.Store(group, link) + + db, err = gdb.Instance(group) + if err != nil { + return nil, nil, gerror.Wrap(err, "获取租户业务库连接失败") + } + return db, tenant, nil +} + +func tenantLink(ctx context.Context, tenant *entity.ImTenant) (string, error) { + if tenant.DbDsn != "" { + return tenant.DbDsn, nil + } + if tenant.DbName == "" { + return "", gerror.New("租户业务库未配置") + } + + defaultLink := defaultDbLink(ctx) + if defaultLink == "" { + return "", gerror.New("默认数据库连接未配置,无法推导租户业务库连接") + } + + link, err := replaceMySQLDatabase(defaultLink, tenant.DbName) + if err != nil { + return "", err + } + return link, nil +} + +func defaultDbLink(ctx context.Context) string { + if configs, err := gdb.GetConfigGroup("default"); err == nil && len(configs) > 0 { + if configs[0].Link != "" { + return configs[0].Link + } + } + + cfg := g.Cfg() + if !cfg.Available(ctx) { + return "" + } + defaultLink := cfg.MustGet(ctx, "database.default.0.link").String() + if defaultLink != "" { + return defaultLink + } + return cfg.MustGet(ctx, "database.default.link").String() +} + +func replaceMySQLDatabase(link string, dbName string) (string, error) { + if !strings.HasPrefix(link, "mysql:") { + return "", gerror.New("仅支持从 MySQL 默认连接推导租户业务库连接") + } + + dbStart := strings.LastIndex(link, ")/") + if dbStart < 0 { + return "", gerror.New("默认数据库连接格式不正确") + } + dbStart += 2 + queryStart := strings.Index(link[dbStart:], "?") + if queryStart < 0 { + return link[:dbStart] + dbName, nil + } + queryStart += dbStart + return link[:dbStart] + dbName + link[queryStart:], nil +} + +func hash(s string) uint64 { + h := fnv.New64a() + _, _ = h.Write([]byte(s)) + return h.Sum64() +} diff --git a/server/internal/logic/admin/member.go b/server/internal/logic/admin/member.go index 1d384fa..c572f21 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" @@ -467,6 +468,30 @@ func (s *sAdminMember) Edit(ctx context.Context, in *adminin.MemberEditInp) (err return } + if service.AdminMember().VerifySuperId(ctx, opMemberId) { + if in.TenantId > 0 { + count, queryErr := imtenant.RegistryDB(ctx).Model(dao.ImTenant.Table()).Safe().Ctx(ctx). + Where(dao.ImTenant.Columns().Id, in.TenantId). + Where(dao.ImTenant.Columns().State, consts.ImTenantStateActive). + Count() + if queryErr != nil { + return gerror.Wrap(queryErr, "校验IM租户失败") + } + if count == 0 { + return gerror.New("IM租户不存在或未启用") + } + } + } else if deptType := contexts.GetDeptType(ctx); deptType == consts.DeptTypeTenant || + deptType == consts.DeptTypeMerchant || deptType == consts.DeptTypeUser { + in.TenantId = contexts.GetTenantId(ctx) + if in.TenantId <= 0 { + return gerror.New("当前管理员未关联租户") + } + } else { + // 非租户域管理员不能通过浏览器为账号指定租户。 + in.TenantId = 0 + } + cols := dao.AdminMember.Columns() err = s.VerifyUnique(ctx, &adminin.VerifyUniqueInp{ Id: in.Id, diff --git a/server/internal/logic/admin/site.go b/server/internal/logic/admin/site.go index 0409e1c..3edadd3 100644 --- a/server/internal/logic/admin/site.go +++ b/server/internal/logic/admin/site.go @@ -228,6 +228,7 @@ func (s *sAdminSite) handleLogin(ctx context.Context, mb *entity.AdminMember) (r user := &model.Identity{ Id: mb.Id, + TenantId: mb.TenantId, Pid: mb.Pid, DeptId: dept.Id, DeptType: dept.Type, @@ -319,6 +320,7 @@ func (s *sAdminSite) BindUserContext(ctx context.Context, claims *model.Identity user := &model.Identity{ Id: mb.Id, + TenantId: mb.TenantId, Pid: mb.Pid, DeptId: dept.Id, DeptType: dept.Type, diff --git a/server/internal/logic/sys/im_admin_scope.go b/server/internal/logic/sys/im_admin_scope.go new file mode 100644 index 0000000..df36557 --- /dev/null +++ b/server/internal/logic/sys/im_admin_scope.go @@ -0,0 +1,95 @@ +package sys + +import ( + "context" + "hotgo/internal/consts" + "hotgo/internal/dao" + "hotgo/internal/library/contexts" + "hotgo/internal/library/imadmin" + "hotgo/internal/library/imtenant" + "hotgo/internal/model/entity" + "hotgo/internal/service" + "hotgo/utility/simple" + + "github.com/gogf/gf/v2/encoding/gjson" + "github.com/gogf/gf/v2/errors/gerror" + "github.com/gogf/gf/v2/frame/g" + "github.com/gogf/gf/v2/net/ghttp" + "github.com/gogf/gf/v2/os/gctx" + "github.com/gogf/gf/v2/os/gtime" +) + +const ( + imAdminOperationAuditTable = "im_admin_operation_audit" +) + +func resolveImAdminScope(ctx context.Context, selectedTenantId int64) (scope imadmin.AdminScope, err error) { + operatorId := contexts.GetUserId(ctx) + scope.OperatorId = operatorId + scope.RoleKey = contexts.GetRoleKey(ctx) + scope.RequestId = gctx.CtxId(ctx) + if service.AdminMember().VerifySuperId(ctx, operatorId) { + if selectedTenantId <= 0 { + return scope, gerror.New("请选择IM租户") + } + if err = ensureImTenantActive(ctx, selectedTenantId); err != nil { + return scope, err + } + scope.TenantId = selectedTenantId + return scope, nil + } + + if contexts.GetDeptType(ctx) != consts.DeptTypeTenant { + return scope, gerror.New("当前账号不是租户管理员,不能管理邀请码") + } + tenantId := contexts.GetTenantId(ctx) + if tenantId <= 0 { + return scope, gerror.New("当前管理员未关联租户") + } + if err = ensureImTenantActive(ctx, tenantId); err != nil { + return scope, err + } + scope.TenantId = tenantId + return scope, nil +} + +func ensureImTenantActive(ctx context.Context, tenantId int64) (err error) { + var tenant *entity.ImTenant + if err = imtenant.RegistryDB(ctx).Model(dao.ImTenant.Table()).Safe().Ctx(ctx).WherePri(tenantId).Scan(&tenant); err != nil { + return gerror.Wrap(err, "获取IM租户失败") + } + if tenant == nil || tenant.Id <= 0 { + return gerror.New("IM租户不存在") + } + if tenant.State != consts.ImTenantStateActive { + return gerror.New("IM租户未启用") + } + return nil +} + +func writeImAdminAudit(ctx context.Context, tenantId int64, action string, targetType string, targetId int64, result string, detail g.Map) error { + operatorId := contexts.GetUserId(ctx) + request := ghttp.RequestFromCtx(ctx) + ip := "" + if request != nil { + ip = request.GetClientIp() + } + detailJson := gjson.New(detail) + _, err := g.DB().Exec(ctx, + "INSERT INTO "+imAdminOperationAuditTable+" (request_id, tenant_id, operator_id, action, target_type, target_id, result, ip, detail_json, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, CAST(? AS JSON), ?)", + gctx.CtxId(ctx), + tenantId, + operatorId, + action, + targetType, + targetId, + result, + ip, + detailJson.String(), + gtime.Now(), + ) + if err != nil && !simple.IsDemo(ctx) { + g.Log().Warningf(ctx, "写入IM后台审计失败 action:%s err:%+v", action, err) + } + return err +} diff --git a/server/internal/logic/sys/im_audit_log.go b/server/internal/logic/sys/im_audit_log.go new file mode 100644 index 0000000..943ef75 --- /dev/null +++ b/server/internal/logic/sys/im_audit_log.go @@ -0,0 +1,125 @@ +// 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/library/imtenant" + "hotgo/internal/model/input/sysin" + "hotgo/internal/service" + + "github.com/gogf/gf/v2/database/gdb" + "github.com/gogf/gf/v2/errors/gerror" +) + +type sSysImAuditLog struct{} + +func NewSysImAuditLog() *sSysImAuditLog { + return &sSysImAuditLog{} +} + +func init() { + service.RegisterSysImAuditLog(NewSysImAuditLog()) +} + +// List 获取IM审计日志列表 +func (s *sSysImAuditLog) List(ctx context.Context, in *sysin.ImAuditLogListInp) (list []*sysin.ImAuditLogListModel, totalCount int, err error) { + if err = in.Filter(ctx); err != nil { + return + } + db, tenant, err := imtenant.DB(ctx, in.TenantId) + if err != nil { + return + } + + mod := s.model(ctx, db) + mod = s.applyWhere(mod, in) + mod = mod.Page(in.Page, in.PerPage).OrderDesc("id") + + if err = mod.ScanAndCount(&list, &totalCount, false); err != nil { + err = gerror.Wrap(err, "获取IM审计日志列表失败,请稍后重试!") + return + } + for _, item := range list { + item.TenantId = tenant.Id + item.TenantName = tenant.Name + } + return +} + +func (s *sSysImAuditLog) model(ctx context.Context, db gdb.DB) *gdb.Model { + return db.Model("im_audit_logs").Safe().Ctx(ctx). + Fields( + "id", + "actor_uid", + "actor_role", + "action", + "target_type", + "target_id", + "reason", + "request_id", + "ip", + "user_agent", + "CAST(before_snapshot AS CHAR) before_snapshot", + "CAST(after_snapshot AS CHAR) after_snapshot", + "CAST(metadata AS CHAR) metadata", + "created_at", + ) +} + +func (s *sSysImAuditLog) applyWhere(mod *gdb.Model, in *sysin.ImAuditLogListInp) *gdb.Model { + if in.ActorUid > 0 { + mod = mod.Where("actor_uid", in.ActorUid) + } + if in.ActorRole != "" { + mod = mod.WhereLike("actor_role", "%"+in.ActorRole+"%") + } + if in.Action != "" { + mod = mod.WhereLike("action", "%"+in.Action+"%") + } + if in.TargetType != "" { + mod = mod.Where("target_type", in.TargetType) + } + if in.TargetId != "" { + mod = mod.WhereLike("target_id", "%"+in.TargetId+"%") + } + if in.RequestId != "" { + mod = mod.WhereLike("request_id", "%"+in.RequestId+"%") + } + if in.Ip != "" { + mod = mod.WhereLike("ip", "%"+in.Ip+"%") + } + if in.Keyword != "" { + like := "%" + in.Keyword + "%" + mod = mod.Where("(action LIKE ? OR target_type LIKE ? OR target_id LIKE ? OR reason LIKE ? OR request_id LIKE ?)", like, like, like, like, like) + } + if len(in.CreatedAt) == 2 { + mod = mod.WhereBetween("created_at", in.CreatedAt[0], in.CreatedAt[1]) + } + return mod +} + +// View 获取IM审计日志指定信息 +func (s *sSysImAuditLog) View(ctx context.Context, in *sysin.ImAuditLogViewInp) (res *sysin.ImAuditLogViewModel, err error) { + if err = in.Filter(ctx); err != nil { + return + } + db, tenant, err := imtenant.DB(ctx, in.TenantId) + if err != nil { + return nil, err + } + + err = s.model(ctx, db).Where("id", in.Id).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 + return +} diff --git a/server/internal/logic/sys/im_group.go b/server/internal/logic/sys/im_group.go new file mode 100644 index 0000000..26cffc6 --- /dev/null +++ b/server/internal/logic/sys/im_group.go @@ -0,0 +1,456 @@ +// 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/library/imtenant" + "hotgo/internal/model/input/sysin" + "hotgo/internal/service" + + "github.com/gogf/gf/v2/database/gdb" + "github.com/gogf/gf/v2/errors/gerror" +) + +type sSysImGroup struct{} + +func NewSysImGroup() *sSysImGroup { + return &sSysImGroup{} +} + +func init() { + service.RegisterSysImGroup(NewSysImGroup()) +} + +// List 获取IM群组列表 +func (s *sSysImGroup) List(ctx context.Context, in *sysin.ImGroupListInp) (list []*sysin.ImGroupListModel, totalCount int, err error) { + if err = in.Filter(ctx); err != nil { + return + } + + db, tenant, err := imtenant.DB(ctx, in.TenantId) + if err != nil { + return + } + + mod := s.groupModel(ctx, db) + mod = s.applyGroupWhere(mod, in) + mod = mod.Page(in.Page, in.PerPage).OrderDesc("t.touchedat").OrderDesc("t.id") + + if err = mod.ScanAndCount(&list, &totalCount, false); err != nil { + err = gerror.Wrap(err, "获取IM群组列表失败,请稍后重试!") + return + } + for _, item := range list { + item.TenantId = tenant.Id + item.TenantName = tenant.Name + } + return +} + +func (s *sSysImGroup) groupModel(ctx context.Context, db gdb.DB) *gdb.Model { + return db.Model("im_topics t").Safe().Ctx(ctx). + LeftJoin("im_group_settings gs", "gs.topic = t.name"). + Fields( + "t.name topic", + "t.state", + "COALESCE(gs.owner_user_id, t.owner) owner_id", + "t.seqid seq_id", + "t.delid del_id", + "t.subcnt sub_cnt", + "t.touchedat touched_at", + "t.createdat created_at", + "t.updatedat updated_at", + "t.public public_json", + "t.trusted trusted_json", + "t.tags tags_json", + "t.aux aux_json", + "COALESCE(gs.name, '') group_name", + "COALESCE(gs.avatar_file_id, '') avatar_file_id", + "COALESCE(gs.description, '') description", + "COALESCE(gs.group_status, '') group_status", + "COALESCE(gs.join_approval, 0) join_approval", + "COALESCE(gs.mute_all, 0) mute_all", + "COALESCE(gs.member_upload_files, 0) member_upload_files", + "COALESCE(gs.member_private_chat, 0) member_private_chat", + "COALESCE(gs.member_invite, 0) member_invite", + "COALESCE(gs.max_member_count, 0) max_member_count", + "COALESCE(gs.max_admin_count, 0) max_admin_count", + "COALESCE(gs.member_count, 0) business_member_count", + "COALESCE(gs.version, 0) settings_version", + "gs.updated_at settings_updated_at", + ). + WhereLike("t.name", "grp%") +} + +func (s *sSysImGroup) applyGroupWhere(mod *gdb.Model, in *sysin.ImGroupListInp) *gdb.Model { + if in.Topic != "" { + mod = mod.WhereLike("t.name", "%"+in.Topic+"%") + } + if in.OwnerId > 0 { + mod = mod.Where("COALESCE(gs.owner_user_id, t.owner)", in.OwnerId) + } + if in.State != nil { + mod = mod.Where("t.state", *in.State) + } + if in.GroupStatus != "" { + mod = mod.Where("gs.group_status", in.GroupStatus) + } + if len(in.CreatedAt) == 2 { + mod = mod.WhereBetween("t.createdat", in.CreatedAt[0], in.CreatedAt[1]) + } + if len(in.TouchedAt) == 2 { + mod = mod.WhereBetween("t.touchedat", in.TouchedAt[0], in.TouchedAt[1]) + } + return mod +} + +// View 获取IM群组指定信息 +func (s *sSysImGroup) View(ctx context.Context, in *sysin.ImGroupViewInp) (res *sysin.ImGroupViewModel, err error) { + if err = in.Filter(ctx); err != nil { + return + } + + db, tenant, err := imtenant.DB(ctx, in.TenantId) + if err != nil { + return + } + + err = db.Model("im_topics t").Safe().Ctx(ctx). + LeftJoin("im_group_settings gs", "gs.topic = t.name"). + Fields( + "t.name topic", + "t.state", + "COALESCE(gs.owner_user_id, t.owner) owner_id", + "t.seqid seq_id", + "t.delid del_id", + "t.subcnt sub_cnt", + "t.touchedat touched_at", + "t.createdat created_at", + "t.updatedat updated_at", + "t.public public_json", + "t.trusted trusted_json", + "t.tags tags_json", + "t.aux aux_json", + "t.access access_json", + "t.usebt use_bt", + "COALESCE(gs.name, '') group_name", + "COALESCE(gs.avatar_file_id, '') avatar_file_id", + "COALESCE(gs.description, '') description", + "COALESCE(gs.group_status, '') group_status", + "COALESCE(gs.join_approval, 0) join_approval", + "COALESCE(gs.mute_all, 0) mute_all", + "COALESCE(gs.member_upload_files, 0) member_upload_files", + "COALESCE(gs.member_private_chat, 0) member_private_chat", + "COALESCE(gs.member_invite, 0) member_invite", + "COALESCE(gs.max_member_count, 0) max_member_count", + "COALESCE(gs.max_admin_count, 0) max_admin_count", + "COALESCE(gs.member_count, 0) business_member_count", + "COALESCE(gs.version, 0) settings_version", + "gs.updated_at settings_updated_at", + ). + Where("t.name", in.Topic). + WhereLike("t.name", "grp%"). + 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 + return +} + +// MemberList 获取IM群成员列表 +func (s *sSysImGroup) MemberList(ctx context.Context, in *sysin.ImGroupMemberListInp) (list []*sysin.ImGroupMemberListModel, totalCount int, err error) { + if err = in.Filter(ctx); err != nil { + return + } + + db, _, err := imtenant.DB(ctx, in.TenantId) + if err != nil { + return + } + + if s.hasGroupMemberTruth(ctx, db, in.Topic) { + return s.memberListFromBusiness(ctx, db, in) + } + return s.memberListFromProjection(ctx, db, in) +} + +func (s *sSysImGroup) hasGroupMemberTruth(ctx context.Context, db gdb.DB, topic string) bool { + count, err := db.Model("im_group_members").Safe().Ctx(ctx).Where("topic", topic).Count() + return err == nil && count > 0 +} + +func (s *sSysImGroup) memberListFromBusiness(ctx context.Context, db gdb.DB, in *sysin.ImGroupMemberListInp) (list []*sysin.ImGroupMemberListModel, totalCount int, err error) { + mod := db.Model("im_group_members gm").Safe().Ctx(ctx). + LeftJoin("im_users u", "u.id = gm.user_id"). + LeftJoin("im_user_profiles up", "up.user_id = gm.user_id"). + LeftJoin("im_subscriptions s", "s.topic = gm.topic AND s.userid = gm.user_id"). + Fields( + "gm.user_id", + "up.nickname", + "u.state", + "s.modewant mode_want", + "s.modegiven mode_given", + "s.recvseqid recv_seq_id", + "s.readseqid read_seq_id", + "s.delid del_id", + "s.deletedat deleted_at", + "s.createdat created_at", + "s.updatedat updated_at", + "s.private private_json", + "gm.role", + "gm.member_status", + "gm.nickname group_nickname", + "gm.pinned", + "gm.mute_notifications", + "gm.join_source", + "gm.joined_by", + "gm.joined_at", + "gm.left_at", + "gm.removed_by", + "gm.version", + ). + Where("gm.topic", in.Topic) + if in.UserId > 0 { + mod = mod.Where("gm.user_id", in.UserId) + } + if in.Role != "" { + mod = mod.Where("gm.role", in.Role) + } + if in.MemberStatus != "" { + mod = mod.Where("gm.member_status", in.MemberStatus) + } + mod = mod.Page(in.Page, in.PerPage).OrderAsc("gm.member_status").OrderAsc("gm.role").OrderAsc("gm.id") + if err = mod.ScanAndCount(&list, &totalCount, false); err != nil { + err = gerror.Wrap(err, "获取IM群成员列表失败,请稍后重试!") + return + } + return +} + +func (s *sSysImGroup) memberListFromProjection(ctx context.Context, db gdb.DB, in *sysin.ImGroupMemberListInp) (list []*sysin.ImGroupMemberListModel, totalCount int, err error) { + mod := db.Model("im_subscriptions s").Safe().Ctx(ctx). + LeftJoin("im_users u", "u.id = s.userid"). + LeftJoin("im_user_profiles up", "up.user_id = s.userid"). + Fields( + "s.userid user_id", + "up.nickname", + "u.state", + "s.modewant mode_want", + "s.modegiven mode_given", + "s.recvseqid recv_seq_id", + "s.readseqid read_seq_id", + "s.delid del_id", + "s.deletedat deleted_at", + "s.createdat created_at", + "s.updatedat updated_at", + "s.private private_json", + "'member' role", + "IF(s.deletedat IS NULL, 'active', 'left') member_status", + "'' group_nickname", + "0 pinned", + "0 mute_notifications", + "'projection' join_source", + "0 joined_by", + "s.createdat joined_at", + "s.deletedat left_at", + "0 removed_by", + "0 version", + ). + Where("s.topic", in.Topic) + + if in.UserId > 0 { + mod = mod.Where("s.userid", in.UserId) + } + + mod = mod.Page(in.Page, in.PerPage).OrderAsc("s.deletedat").OrderAsc("s.userid") + if err = mod.ScanAndCount(&list, &totalCount, false); err != nil { + err = gerror.Wrap(err, "获取IM群成员列表失败,请稍后重试!") + return + } + return +} + +// Governance 获取IM群治理配置 +func (s *sSysImGroup) Governance(ctx context.Context, in *sysin.ImGroupTopicInp) (res *sysin.ImGroupGovernanceModel, err error) { + if err = in.Filter(ctx); err != nil { + return + } + db, tenant, err := imtenant.DB(ctx, in.TenantId) + if err != nil { + return nil, err + } + err = db.Model("im_group_settings").Safe().Ctx(ctx). + Fields( + "topic", + "owner_user_id", + "name", + "avatar_file_id", + "description", + "group_status", + "join_approval", + "mute_all", + "member_upload_files", + "member_private_chat", + "member_invite", + "max_member_count", + "max_admin_count", + "member_count", + "version", + "created_by", + "dissolved_by", + "dissolved_at", + "created_at", + "updated_at", + ). + Where("topic", in.Topic). + 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 + return +} + +// JoinRequestList 获取IM入群申请列表 +func (s *sSysImGroup) JoinRequestList(ctx context.Context, in *sysin.ImGroupJoinRequestListInp) (list []*sysin.ImGroupJoinRequestListModel, totalCount int, err error) { + if err = in.Filter(ctx); err != nil { + return + } + db, _, err := imtenant.DB(ctx, in.TenantId) + if err != nil { + return + } + mod := db.Model("im_group_join_requests").Safe().Ctx(ctx).Where("topic", in.Topic) + if in.Status != "" { + mod = mod.Where("status", in.Status) + } + if in.ApplicantUserId > 0 { + mod = mod.Where("applicant_user_id", in.ApplicantUserId) + } + mod = mod.Page(in.Page, in.PerPage).OrderDesc("id") + if err = mod.ScanAndCount(&list, &totalCount, false); err != nil { + err = gerror.Wrap(err, "获取IM入群申请列表失败,请稍后重试!") + } + return +} + +// InvitationList 获取IM群邀请列表 +func (s *sSysImGroup) InvitationList(ctx context.Context, in *sysin.ImGroupInvitationListInp) (list []*sysin.ImGroupInvitationListModel, totalCount int, err error) { + if err = in.Filter(ctx); err != nil { + return + } + db, _, err := imtenant.DB(ctx, in.TenantId) + if err != nil { + return + } + mod := db.Model("im_group_invitations").Safe().Ctx(ctx).Where("topic", in.Topic) + if in.Status != "" { + mod = mod.Where("status", in.Status) + } + if in.TargetUserId > 0 { + mod = mod.Where("target_user_id", in.TargetUserId) + } + if in.InviterUserId > 0 { + mod = mod.Where("inviter_user_id", in.InviterUserId) + } + mod = mod.Page(in.Page, in.PerPage).OrderDesc("id") + if err = mod.ScanAndCount(&list, &totalCount, false); err != nil { + err = gerror.Wrap(err, "获取IM群邀请列表失败,请稍后重试!") + } + return +} + +// AnnouncementList 获取IM群公告列表 +func (s *sSysImGroup) AnnouncementList(ctx context.Context, in *sysin.ImGroupAnnouncementListInp) (list []*sysin.ImGroupAnnouncementListModel, totalCount int, err error) { + if err = in.Filter(ctx); err != nil { + return + } + db, _, err := imtenant.DB(ctx, in.TenantId) + if err != nil { + return + } + mod := db.Model("im_group_announcements").Safe().Ctx(ctx).Where("topic", in.Topic) + if in.Status != "" { + mod = mod.Where("status", in.Status) + } + mod = mod.Page(in.Page, in.PerPage).OrderDesc("published_at").OrderDesc("id") + if err = mod.ScanAndCount(&list, &totalCount, false); err != nil { + err = gerror.Wrap(err, "获取IM群公告列表失败,请稍后重试!") + } + return +} + +// MuteList 获取IM群禁言列表 +func (s *sSysImGroup) MuteList(ctx context.Context, in *sysin.ImGroupMuteListInp) (list []*sysin.ImGroupMuteListModel, totalCount int, err error) { + if err = in.Filter(ctx); err != nil { + return + } + db, _, err := imtenant.DB(ctx, in.TenantId) + if err != nil { + return + } + mod := db.Model("im_group_mutes").Safe().Ctx(ctx).Where("topic", in.Topic) + if in.Status != "" { + mod = mod.Where("status", in.Status) + } + if in.UserId > 0 { + mod = mod.Where("user_id", in.UserId) + } + mod = mod.Page(in.Page, in.PerPage).OrderDesc("muted_at").OrderDesc("user_id") + if err = mod.ScanAndCount(&list, &totalCount, false); err != nil { + err = gerror.Wrap(err, "获取IM群禁言列表失败,请稍后重试!") + } + return +} + +// AdminLogList 获取IM群管理日志列表 +func (s *sSysImGroup) AdminLogList(ctx context.Context, in *sysin.ImGroupAdminLogListInp) (list []*sysin.ImGroupAdminLogListModel, totalCount int, err error) { + if err = in.Filter(ctx); err != nil { + return + } + db, _, err := imtenant.DB(ctx, in.TenantId) + if err != nil { + return + } + mod := db.Model("im_group_admin_logs").Safe().Ctx(ctx). + Fields( + "id", + "log_id", + "topic", + "operator_id", + "target_user_id", + "action", + "object_type", + "object_id", + "CAST(before_data AS CHAR) before_data", + "CAST(after_data AS CHAR) after_data", + "request_id", + "idempotency_key", + "created_at", + ). + Where("topic", in.Topic) + if in.Action != "" { + mod = mod.WhereLike("action", "%"+in.Action+"%") + } + if in.OperatorId > 0 { + mod = mod.Where("operator_id", in.OperatorId) + } + mod = mod.Page(in.Page, in.PerPage).OrderDesc("id") + if err = mod.ScanAndCount(&list, &totalCount, false); err != nil { + err = gerror.Wrap(err, "获取IM群管理日志列表失败,请稍后重试!") + } + return +} diff --git a/server/internal/logic/sys/im_invite.go b/server/internal/logic/sys/im_invite.go new file mode 100644 index 0000000..13bdfd0 --- /dev/null +++ b/server/internal/logic/sys/im_invite.go @@ -0,0 +1,315 @@ +package sys + +import ( + "context" + "fmt" + "hotgo/internal/library/imadmin" + "hotgo/internal/model/input/sysin" + "hotgo/internal/service" + "io" + "strconv" + "time" + + "github.com/gogf/gf/v2/errors/gerror" + "github.com/gogf/gf/v2/frame/g" + "github.com/gogf/gf/v2/net/ghttp" + "github.com/gogf/gf/v2/os/gtime" +) + +type sSysImInvite struct{} + +func NewSysImInvite() *sSysImInvite { + return &sSysImInvite{} +} + +func init() { + service.RegisterSysImInvite(NewSysImInvite()) +} + +type imInviteListRequest struct { + Page int `json:"page,omitempty"` + PerPage int `json:"per_page,omitempty"` + InviteCode string `json:"invite_code,omitempty"` + Status string `json:"status,omitempty"` + BatchID int64 `json:"batch_id,omitempty"` + CreatedBy int64 `json:"created_by,omitempty"` + CreatedAtStart string `json:"created_at_start,omitempty"` + CreatedAtEnd string `json:"created_at_end,omitempty"` + ExpiresAtStart string `json:"expires_at_start,omitempty"` + ExpiresAtEnd string `json:"expires_at_end,omitempty"` +} + +type imInviteCreateRequest struct { + ExpiresAt string `json:"expires_at"` + Remark string `json:"remark,omitempty"` + IdempotencyKey string `json:"idempotency_key,omitempty"` +} + +type imInviteBatchCreateRequest struct { + Quantity int `json:"quantity"` + ExpiresAt string `json:"expires_at"` + Remark string `json:"remark,omitempty"` + IdempotencyKey string `json:"idempotency_key,omitempty"` +} + +type imInviteCodeResponse struct { + ID int64 `json:"id"` + BatchID *int64 `json:"batch_id"` + Code string `json:"code"` + Status string `json:"status"` + ExpiresAt *time.Time `json:"expires_at"` + UsedBy *int64 `json:"used_by"` + UsedAt *time.Time `json:"used_at"` + RequestID string `json:"request_id"` + CreatedBy int64 `json:"created_by"` + Remark string `json:"remark"` + CreatedAt *time.Time `json:"created_at"` +} + +type imInviteBatchResponse struct { + BatchID int64 `json:"batch_id"` + Quantity int `json:"quantity"` + ActiveCount int `json:"active_count"` + UsedCount int `json:"used_count"` + RevokedCount int `json:"revoked_count"` + ExpiredCount int `json:"expired_count"` + ExpiresAt *time.Time `json:"expires_at"` + CreatedAt *time.Time `json:"created_at"` +} + +func (s *sSysImInvite) List(ctx context.Context, in *sysin.ImInviteListInp) (list []*sysin.ImInviteCodeModel, totalCount int, err error) { + if err = in.Filter(ctx); err != nil { + return + } + scope, err := resolveImAdminScope(ctx, in.TenantId) + if err != nil { + return + } + req, err := buildInviteListRequest(in) + if err != nil { + return + } + var page imadmin.Page[imInviteCodeResponse] + if err = imadmin.DefaultClient(ctx).ListCodes(ctx, scope, req, &page); err != nil { + _ = writeImAdminAudit(ctx, scope.TenantId, "invite_list", "invite_code", 0, "failed", g.Map{"hasInviteCode": in.InviteCode != ""}) + return + } + _ = writeImAdminAudit(ctx, scope.TenantId, "invite_list", "invite_code", 0, "success", g.Map{"hasInviteCode": in.InviteCode != ""}) + list = make([]*sysin.ImInviteCodeModel, 0, len(page.Items)) + for i := range page.Items { + list = append(list, convertInviteCode(page.Items[i])) + } + totalCount = page.Total + return +} + +func (s *sSysImInvite) Create(ctx context.Context, in *sysin.ImInviteCreateInp) (res *sysin.ImInviteCodeModel, err error) { + if err = in.Filter(ctx); err != nil { + return + } + scope, err := resolveImAdminScope(ctx, in.TenantId) + if err != nil { + return + } + req := imInviteCreateRequest{ExpiresAt: formatRFC3339(in.ExpiresAt), Remark: in.Remark, IdempotencyKey: in.IdempotencyKey} + var upstream imInviteCodeResponse + err = imadmin.DefaultClient(ctx).CreateCode(ctx, scope, req, &upstream) + res = convertInviteCode(upstream) + result := "success" + if err != nil { + result = "failed" + } + targetId, _ := strconv.ParseInt(res.Id, 10, 64) + _ = writeImAdminAudit(ctx, scope.TenantId, "invite_create", "invite_code", targetId, result, g.Map{"expiresAt": in.ExpiresAt, "remark": in.Remark}) + return +} + +func (s *sSysImInvite) BatchCreate(ctx context.Context, in *sysin.ImInviteBatchCreateInp) (res *sysin.ImInviteBatchModel, err error) { + if err = in.Filter(ctx); err != nil { + return + } + scope, err := resolveImAdminScope(ctx, in.TenantId) + if err != nil { + return + } + req := imInviteBatchCreateRequest{Quantity: in.Quantity, ExpiresAt: formatRFC3339(in.ExpiresAt), Remark: in.Remark, IdempotencyKey: in.IdempotencyKey} + var upstream imInviteBatchResponse + err = imadmin.DefaultClient(ctx).CreateBatch(ctx, scope, req, &upstream) + res = convertInviteBatch(upstream) + result := "success" + if err != nil { + result = "failed" + } + targetId, _ := strconv.ParseInt(res.BatchId, 10, 64) + _ = writeImAdminAudit(ctx, scope.TenantId, "invite_batch_create", "invite_batch", targetId, result, g.Map{"quantity": in.Quantity, "expiresAt": in.ExpiresAt, "remark": in.Remark}) + return +} + +func (s *sSysImInvite) BatchView(ctx context.Context, in *sysin.ImInviteBatchViewInp) (res *sysin.ImInviteBatchDetailModel, err error) { + scope, err := resolveImAdminScope(ctx, in.TenantId) + if err != nil { + return + } + batchId, err := sysin.ParseOptionalInt64(in.BatchId) + if err != nil { + return + } + var upstream imInviteBatchResponse + err = imadmin.DefaultClient(ctx).GetBatch(ctx, scope, batchId, &upstream) + res = &sysin.ImInviteBatchDetailModel{ImInviteBatchModel: *convertInviteBatch(upstream)} + return +} + +func (s *sSysImInvite) Revoke(ctx context.Context, in *sysin.ImInviteRevokeInp) (err error) { + scope, err := resolveImAdminScope(ctx, in.TenantId) + if err != nil { + return + } + id, err := sysin.ParseOptionalInt64(in.Id) + if err != nil { + return + } + err = imadmin.DefaultClient(ctx).RevokeCode(ctx, scope, id) + result := "success" + if err != nil { + result = "failed" + } + _ = writeImAdminAudit(ctx, scope.TenantId, "invite_revoke", "invite_code", id, result, nil) + return +} + +func (s *sSysImInvite) BatchRevoke(ctx context.Context, in *sysin.ImInviteBatchRevokeInp) (res *sysin.ImInviteBatchModel, err error) { + scope, err := resolveImAdminScope(ctx, in.TenantId) + if err != nil { + return + } + batchId, err := sysin.ParseOptionalInt64(in.BatchId) + if err != nil { + return + } + var upstream struct { + Batch *imInviteBatchResponse `json:"batch"` + RevokedCount int `json:"revoked_count"` + } + err = imadmin.DefaultClient(ctx).RevokeBatch(ctx, scope, batchId, &upstream) + if upstream.Batch != nil { + res = convertInviteBatch(*upstream.Batch) + } else { + res = &sysin.ImInviteBatchModel{BatchId: strconv.FormatInt(batchId, 10), RevokedCount: upstream.RevokedCount} + } + result := "success" + if err != nil { + result = "failed" + } + _ = writeImAdminAudit(ctx, scope.TenantId, "invite_batch_revoke", "invite_batch", batchId, result, nil) + return +} + +func (s *sSysImInvite) Export(ctx context.Context, in *sysin.ImInviteListInp) (err error) { + if err = in.Filter(ctx); err != nil { + return + } + scope, err := resolveImAdminScope(ctx, in.TenantId) + if err != nil { + return + } + req, err := buildInviteListRequest(in) + if err != nil { + return + } + resp, err := imadmin.DefaultClient(ctx).ExportCodes(ctx, scope, req) + if err != nil { + _ = writeImAdminAudit(ctx, scope.TenantId, "invite_export", "invite_code", 0, "failed", g.Map{"hasInviteCode": in.InviteCode != ""}) + return + } + defer resp.Body.Close() + request := ghttp.RequestFromCtx(ctx) + if request == nil { + return gerror.New("请求上下文不存在") + } + filename := resp.Header.Get("Content-Disposition") + if filename == "" { + filename = `attachment; filename="registration-invite-codes.csv"` + } + request.Response.Header().Set("Content-Type", resp.Header.Get("Content-Type")) + request.Response.Header().Set("Content-Disposition", filename) + if _, err = io.Copy(request.Response.Writer, resp.Body); err != nil { + return gerror.Wrap(err, "转发导出文件失败") + } + _ = writeImAdminAudit(ctx, scope.TenantId, "invite_export", "invite_code", 0, "success", g.Map{"hasInviteCode": in.InviteCode != ""}) + fmt.Fprint(request.Response.Writer, "") + return +} + +func buildInviteListRequest(in *sysin.ImInviteListInp) (imInviteListRequest, error) { + batchId, err := sysin.ParseOptionalInt64(in.BatchId) + if err != nil { + return imInviteListRequest{}, err + } + createdBy, err := sysin.ParseOptionalInt64(in.CreatedBy) + if err != nil { + return imInviteListRequest{}, err + } + return imInviteListRequest{ + Page: in.Page, + PerPage: in.PerPage, + InviteCode: in.InviteCode, + Status: in.Status, + BatchID: batchId, + CreatedBy: createdBy, + CreatedAtStart: formatRFC3339(in.CreatedAtStart), + CreatedAtEnd: formatRFC3339(in.CreatedAtEnd), + ExpiresAtStart: formatRFC3339(in.ExpiresAtStart), + ExpiresAtEnd: formatRFC3339(in.ExpiresAtEnd), + }, nil +} + +func formatRFC3339(t *gtime.Time) string { + if t == nil { + return "" + } + return t.Time.UTC().Format(time.RFC3339) +} + +func convertTime(t *time.Time) *gtime.Time { + if t == nil { + return nil + } + return gtime.New(t) +} + +func formatOptionalInt64(value *int64) string { + if value == nil || *value == 0 { + return "" + } + return strconv.FormatInt(*value, 10) +} + +func convertInviteCode(item imInviteCodeResponse) *sysin.ImInviteCodeModel { + return &sysin.ImInviteCodeModel{ + Id: strconv.FormatInt(item.ID, 10), + BatchId: formatOptionalInt64(item.BatchID), + Code: item.Code, + Status: item.Status, + ExpiresAt: convertTime(item.ExpiresAt), + UsedBy: formatOptionalInt64(item.UsedBy), + UsedAt: convertTime(item.UsedAt), + RequestId: item.RequestID, + CreatedBy: strconv.FormatInt(item.CreatedBy, 10), + Remark: item.Remark, + CreatedAt: convertTime(item.CreatedAt), + } +} + +func convertInviteBatch(item imInviteBatchResponse) *sysin.ImInviteBatchModel { + return &sysin.ImInviteBatchModel{ + BatchId: strconv.FormatInt(item.BatchID, 10), + Quantity: item.Quantity, + ActiveCount: item.ActiveCount, + UsedCount: item.UsedCount, + RevokedCount: item.RevokedCount, + ExpiredCount: item.ExpiredCount, + ExpiresAt: convertTime(item.ExpiresAt), + CreatedAt: convertTime(item.CreatedAt), + } +} diff --git a/server/internal/logic/sys/im_realname.go b/server/internal/logic/sys/im_realname.go new file mode 100644 index 0000000..a718926 --- /dev/null +++ b/server/internal/logic/sys/im_realname.go @@ -0,0 +1,164 @@ +// 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/imtenant" + "hotgo/internal/model/input/sysin" + "hotgo/internal/service" + + "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/gtime" +) + +type sSysImRealname struct{} + +func NewSysImRealname() *sSysImRealname { + return &sSysImRealname{} +} + +func init() { + service.RegisterSysImRealname(NewSysImRealname()) +} + +// List 获取IM实名审核列表 +func (s *sSysImRealname) List(ctx context.Context, in *sysin.ImRealnameListInp) (list []*sysin.ImRealnameListModel, totalCount int, err error) { + if err = in.Filter(ctx); err != nil { + return + } + db, tenant, err := imtenant.DB(ctx, in.TenantId) + if err != nil { + return + } + + mod := s.model(ctx, db) + mod = s.applyWhere(mod, in) + mod = mod.Page(in.Page, in.PerPage).OrderDesc("id") + + if err = mod.ScanAndCount(&list, &totalCount, false); err != nil { + err = gerror.Wrap(err, "获取IM实名审核列表失败,请稍后重试!") + return + } + for _, item := range list { + item.TenantId = tenant.Id + item.TenantName = tenant.Name + } + return +} + +func (s *sSysImRealname) model(ctx context.Context, db gdb.DB) *gdb.Model { + return db.Model("im_realname_verifications").Safe().Ctx(ctx). + Fields( + "id", + "user_id", + "submission_id", + "status", + "id_type", + "masked_name", + "masked_id_number", + "review_reason", + "verified_at", + "created_at", + "updated_at", + ) +} + +func (s *sSysImRealname) applyWhere(mod *gdb.Model, in *sysin.ImRealnameListInp) *gdb.Model { + if in.UserId > 0 { + mod = mod.Where("user_id", in.UserId) + } + if in.SubmissionId != "" { + mod = mod.WhereLike("submission_id", "%"+in.SubmissionId+"%") + } + 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]) + } + return mod +} + +// View 获取IM实名审核指定信息 +func (s *sSysImRealname) View(ctx context.Context, in *sysin.ImRealnameViewInp) (res *sysin.ImRealnameViewModel, err error) { + if err = in.Filter(ctx); err != nil { + return + } + db, tenant, err := imtenant.DB(ctx, in.TenantId) + if err != nil { + return nil, err + } + + err = db.Model("im_realname_verifications").Safe().Ctx(ctx). + Fields( + "id", + "user_id", + "submission_id", + "status", + "id_type", + "masked_name", + "masked_id_number", + "id_number_hash", + "review_reason", + "verified_at", + "created_at", + "updated_at", + ). + Where("id", in.Id). + 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 + return +} + +// Review 审核IM实名 +func (s *sSysImRealname) Review(ctx context.Context, in *sysin.ImRealnameReviewInp) (err error) { + if err = in.Filter(ctx); err != nil { + return + } + db, _, err := imtenant.DB(ctx, in.TenantId) + if err != nil { + return + } + + data := g.Map{ + "status": in.Status, + "review_reason": in.ReviewReason, + "updated_at": gtime.Now(), + } + if in.Status == consts.ImRealnameStatusVerified { + data["verified_at"] = gtime.Now() + } else { + data["verified_at"] = nil + } + + result, err := db.Model("im_realname_verifications").Safe().Ctx(ctx). + Where("id", in.Id). + Where("status", consts.ImRealnameStatusPending). + Data(data). + Update() + if err != nil { + return gerror.Wrap(err, "审核IM实名失败,请稍后重试!") + } + affected, err := result.RowsAffected() + if err != nil { + return gerror.Wrap(err, "读取审核结果失败,请稍后重试!") + } + if affected == 0 { + return gerror.New("当前记录不是待审核状态,不能重复审核") + } + return +} diff --git a/server/internal/logic/sys/im_registration.go b/server/internal/logic/sys/im_registration.go new file mode 100644 index 0000000..688e54e --- /dev/null +++ b/server/internal/logic/sys/im_registration.go @@ -0,0 +1,116 @@ +package sys + +import ( + "context" + "hotgo/internal/dao" + "hotgo/internal/library/contexts" + "hotgo/internal/model/input/sysin" + "hotgo/internal/service" + + "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/gtime" +) + +const ( + imRegistrationConfigGroup = "im" + imRegistrationSmsKey = "register_sms_enabled" +) + +type sSysImRegistration struct{} + +func NewSysImRegistration() *sSysImRegistration { + return &sSysImRegistration{} +} + +func init() { + service.RegisterSysImRegistration(NewSysImRegistration()) +} + +func (s *sSysImRegistration) GetConfig(ctx context.Context) (res *sysin.ImRegistrationConfigModel, err error) { + if err = s.verifySuper(ctx); err != nil { + return + } + return s.getConfig(ctx) +} + +func (s *sSysImRegistration) UpdateConfig(ctx context.Context, in *sysin.ImRegistrationConfigUpdateInp) (err error) { + if err = s.verifySuper(ctx); err != nil { + return + } + old, err := s.getConfig(ctx) + if err != nil { + return + } + value := "0" + if in.RegisterSmsEnabled { + value = "1" + } + _, err = dao.SysConfig.Ctx(ctx). + Where(dao.SysConfig.Columns().Group, imRegistrationConfigGroup). + Where(dao.SysConfig.Columns().Key, imRegistrationSmsKey). + Data(g.Map{ + dao.SysConfig.Columns().Value: value, + dao.SysConfig.Columns().UpdatedAt: gtime.Now(), + }). + Update() + if err != nil { + return gerror.Wrap(err, "更新IM注册短信开关失败") + } + _ = writeImAdminAudit(ctx, 0, "registration_sms_config_update", "config", 0, "success", g.Map{ + "old": old.RegisterSmsEnabled, + "new": in.RegisterSmsEnabled, + }) + return +} + +func (s *sSysImRegistration) getConfig(ctx context.Context) (res *sysin.ImRegistrationConfigModel, err error) { + var record gdb.Record + record, err = dao.SysConfig.Ctx(ctx). + Where(dao.SysConfig.Columns().Group, imRegistrationConfigGroup). + Where(dao.SysConfig.Columns().Key, imRegistrationSmsKey). + Where(dao.SysConfig.Columns().Status, 1). + One() + if err != nil { + return nil, gerror.Wrap(err, "获取IM注册短信开关失败") + } + if record.IsEmpty() { + if err = s.seedConfig(ctx); err != nil { + return nil, err + } + return s.getConfig(ctx) + } + return &sysin.ImRegistrationConfigModel{ + RegisterSmsEnabled: record[dao.SysConfig.Columns().Value].String() == "1", + UpdatedAt: record[dao.SysConfig.Columns().UpdatedAt].GTime(), + }, nil +} + +func (s *sSysImRegistration) seedConfig(ctx context.Context) (err error) { + _, err = dao.SysConfig.Ctx(ctx).Data(g.Map{ + dao.SysConfig.Columns().Group: imRegistrationConfigGroup, + dao.SysConfig.Columns().Name: "注册短信发送", + dao.SysConfig.Columns().Type: "bool", + dao.SysConfig.Columns().Key: imRegistrationSmsKey, + dao.SysConfig.Columns().Value: "1", + dao.SysConfig.Columns().DefaultValue: "1", + dao.SysConfig.Columns().Sort: 10, + dao.SysConfig.Columns().Tip: "控制 IM 注册验证码是否真实发送", + dao.SysConfig.Columns().IsDefault: 1, + dao.SysConfig.Columns().Status: 1, + dao.SysConfig.Columns().CreatedAt: gtime.Now(), + dao.SysConfig.Columns().UpdatedAt: gtime.Now(), + }).Insert() + if err != nil { + return gerror.Wrap(err, "初始化IM注册短信开关失败") + } + return +} + +func (s *sSysImRegistration) verifySuper(ctx context.Context) error { + if !service.AdminMember().VerifySuperId(ctx, contexts.GetUserId(ctx)) { + return gerror.New("仅平台超级管理员可操作IM注册配置") + } + return nil +} diff --git a/server/internal/logic/sys/im_tenant.go b/server/internal/logic/sys/im_tenant.go new file mode 100644 index 0000000..6d9978b --- /dev/null +++ b/server/internal/logic/sys/im_tenant.go @@ -0,0 +1,273 @@ +// 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/dao" + "hotgo/internal/library/contexts" + "hotgo/internal/library/hgorm/handler" + "hotgo/internal/library/hgorm/hook" + "hotgo/internal/library/imtenant" + "hotgo/internal/model/entity" + "hotgo/internal/model/input/sysin" + "hotgo/internal/service" + + "github.com/gogf/gf/v2/database/gdb" + "github.com/gogf/gf/v2/errors/gerror" + "github.com/gogf/gf/v2/frame/g" +) + +type sSysImTenant struct{} + +func NewSysImTenant() *sSysImTenant { + return &sSysImTenant{} +} + +func init() { + service.RegisterSysImTenant(NewSysImTenant()) +} + +// Model IM租户ORM模型 +func (s *sSysImTenant) Model(ctx context.Context, option ...*handler.Option) *gdb.Model { + return handler.Model(imtenant.RegistryDB(ctx).Model(dao.ImTenant.Table()).Safe().Ctx(ctx), option...) +} + +func (s *sSysImTenant) configModel(ctx context.Context) *gdb.Model { + return imtenant.RegistryDB(ctx).Model(dao.ImTenantConfig.Table()).Safe().Ctx(ctx) +} + +// List 获取IM租户列表 +func (s *sSysImTenant) List(ctx context.Context, in *sysin.ImTenantListInp) (list []*sysin.ImTenantListModel, totalCount int, err error) { + if err = in.Filter(ctx); err != nil { + return + } + + mod := s.Model(ctx).Fields( + dao.ImTenant.Columns().Id, + dao.ImTenant.Columns().Code, + dao.ImTenant.Columns().Name, + dao.ImTenant.Columns().TenantDesc, + dao.ImTenant.Columns().DbName, + dao.ImTenant.Columns().State, + dao.ImTenant.Columns().CreatedBy, + dao.ImTenant.Columns().UpdatedBy, + dao.ImTenant.Columns().CreatedAt, + dao.ImTenant.Columns().UpdatedAt, + ) + + if in.Id > 0 { + mod = mod.Where(dao.ImTenant.Columns().Id, in.Id) + } + if in.Code != "" { + mod = mod.WhereLike(dao.ImTenant.Columns().Code, "%"+in.Code+"%") + } + if in.Name != "" { + mod = mod.WhereLike(dao.ImTenant.Columns().Name, "%"+in.Name+"%") + } + if in.DbName != "" { + mod = mod.WhereLike(dao.ImTenant.Columns().DbName, "%"+in.DbName+"%") + } + if in.State != nil { + mod = mod.Where(dao.ImTenant.Columns().State, *in.State) + } + if len(in.CreatedAt) == 2 { + mod = mod.WhereBetween(dao.ImTenant.Columns().CreatedAt, in.CreatedAt[0], in.CreatedAt[1]) + } + + mod = mod.Page(in.Page, in.PerPage). + OrderDesc(dao.ImTenant.Columns().Id). + Hook(hook.MemberSummary) + + if err = mod.ScanAndCount(&list, &totalCount, false); err != nil { + err = gerror.Wrap(err, "获取IM租户列表失败,请稍后重试!") + return + } + if len(list) == 0 { + return + } + + if err = s.fillListConfig(ctx, list); err != nil { + return + } + return +} + +func (s *sSysImTenant) fillListConfig(ctx context.Context, list []*sysin.ImTenantListModel) (err error) { + ids := make([]int64, 0, len(list)) + for _, item := range list { + ids = append(ids, item.Id) + } + + var configs []*entity.ImTenantConfig + if err = s.configModel(ctx). + WhereIn(dao.ImTenantConfig.Columns().TenantId, ids). + Scan(&configs); err != nil { + return gerror.Wrap(err, "获取IM租户配置失败,请稍后重试!") + } + configMap := make(map[int64]*entity.ImTenantConfig, len(configs)) + for _, item := range configs { + configMap[item.TenantId] = item + } + + var tenants []*entity.ImTenant + if err = s.Model(ctx). + Fields(dao.ImTenant.Columns().Id, dao.ImTenant.Columns().DbDsn). + WhereIn(dao.ImTenant.Columns().Id, ids). + Scan(&tenants); err != nil { + return gerror.Wrap(err, "获取IM租户连接配置失败,请稍后重试!") + } + dsnMap := make(map[int64]string, len(tenants)) + for _, item := range tenants { + dsnMap[item.Id] = item.DbDsn + } + + for _, item := range list { + if conf, ok := configMap[item.Id]; ok { + item.MaxUsers = conf.MaxUsers + item.MaxGroups = conf.MaxGroups + item.MaxGroupMembers = conf.MaxGroupMembers + } + item.DbDsnConfigured = dsnMap[item.Id] != "" + } + return +} + +// Edit 修改/新增IM租户 +func (s *sSysImTenant) Edit(ctx context.Context, in *sysin.ImTenantEditInp) (err error) { + if err = in.Filter(ctx); err != nil { + return + } + + return imtenant.RegistryDB(ctx).Transaction(ctx, func(ctx context.Context, tx gdb.TX) (err error) { + if err = s.checkCodeUnique(ctx, in.Id, in.Code); err != nil { + return + } + + userId := contexts.GetUserId(ctx) + if in.Id > 0 { + in.UpdatedBy = userId + if _, err = s.Model(ctx). + Fields(sysin.ImTenantUpdateFields{}). + WherePri(in.Id).Data(in).Update(); err != nil { + return gerror.Wrap(err, "修改IM租户失败,请稍后重试!") + } + return s.saveConfig(ctx, in.Id, in.MaxUsers, in.MaxGroups, in.MaxGroupMembers, userId) + } + + in.CreatedBy = userId + id, err := s.Model(ctx, &handler.Option{FilterAuth: false}). + Fields(sysin.ImTenantInsertFields{}). + Data(in).InsertAndGetId() + if err != nil { + return gerror.Wrap(err, "新增IM租户失败,请稍后重试!") + } + return s.saveConfig(ctx, id, in.MaxUsers, in.MaxGroups, in.MaxGroupMembers, userId) + }) +} + +func (s *sSysImTenant) checkCodeUnique(ctx context.Context, id int64, code string) (err error) { + mod := s.Model(ctx).Where(dao.ImTenant.Columns().Code, code) + if id > 0 { + mod = mod.Where(dao.ImTenant.Columns().Id+"<>?", id) + } + count, err := mod.Count() + if err != nil { + return gerror.Wrap(err, "检查IM租户编码失败,请稍后重试!") + } + if count > 0 { + return gerror.New("租户编码已存在") + } + return +} + +func (s *sSysImTenant) saveConfig(ctx context.Context, tenantId int64, maxUsers uint64, maxGroups uint64, maxGroupMembers uint, userId int64) (err error) { + var conf *entity.ImTenantConfig + if err = s.configModel(ctx). + Where(dao.ImTenantConfig.Columns().TenantId, tenantId). + Scan(&conf); err != nil { + return gerror.Wrap(err, "获取IM租户配置失败,请稍后重试!") + } + + data := g.Map{ + dao.ImTenantConfig.Columns().MaxUsers: maxUsers, + dao.ImTenantConfig.Columns().MaxGroups: maxGroups, + dao.ImTenantConfig.Columns().MaxGroupMembers: maxGroupMembers, + } + if conf != nil && conf.Id > 0 { + data[dao.ImTenantConfig.Columns().UpdatedBy] = userId + if _, err = s.configModel(ctx). + Fields( + dao.ImTenantConfig.Columns().MaxUsers, + dao.ImTenantConfig.Columns().MaxGroups, + dao.ImTenantConfig.Columns().MaxGroupMembers, + dao.ImTenantConfig.Columns().UpdatedBy, + ). + WherePri(conf.Id).Data(data).Update(); err != nil { + return gerror.Wrap(err, "修改IM租户配置失败,请稍后重试!") + } + return + } + + data[dao.ImTenantConfig.Columns().TenantId] = tenantId + data[dao.ImTenantConfig.Columns().CreatedBy] = userId + if _, err = s.configModel(ctx). + Fields( + dao.ImTenantConfig.Columns().TenantId, + dao.ImTenantConfig.Columns().MaxUsers, + dao.ImTenantConfig.Columns().MaxGroups, + dao.ImTenantConfig.Columns().MaxGroupMembers, + dao.ImTenantConfig.Columns().CreatedBy, + ). + Data(data).Insert(); err != nil { + return gerror.Wrap(err, "新增IM租户配置失败,请稍后重试!") + } + return +} + +// View 获取IM租户指定信息 +func (s *sSysImTenant) View(ctx context.Context, in *sysin.ImTenantViewInp) (res *sysin.ImTenantViewModel, err error) { + if err = in.Filter(ctx); err != nil { + return + } + if err = s.Model(ctx).WherePri(in.Id).Hook(hook.MemberSummary).Scan(&res); err != nil { + err = gerror.Wrap(err, "获取IM租户信息失败,请稍后重试!") + return + } + if res == nil { + return + } + + var conf *entity.ImTenantConfig + if err = s.configModel(ctx). + Where(dao.ImTenantConfig.Columns().TenantId, in.Id). + Scan(&conf); err != nil { + err = gerror.Wrap(err, "获取IM租户配置失败,请稍后重试!") + return + } + if conf != nil { + res.MaxUsers = conf.MaxUsers + res.MaxGroups = conf.MaxGroups + res.MaxGroupMembers = conf.MaxGroupMembers + } + res.DbDsnConfigured = res.DbDsn != "" + return +} + +// Status 更新IM租户状态 +func (s *sSysImTenant) Status(ctx context.Context, in *sysin.ImTenantStatusInp) (err error) { + if err = in.Filter(ctx); err != nil { + return + } + if _, err = s.Model(ctx).WherePri(in.Id).Data(g.Map{ + dao.ImTenant.Columns().State: in.State, + dao.ImTenant.Columns().UpdatedBy: contexts.GetUserId(ctx), + }).Update(); err != nil { + err = gerror.Wrap(err, "更新IM租户状态失败,请稍后重试!") + return + } + return +} diff --git a/server/internal/logic/sys/im_user.go b/server/internal/logic/sys/im_user.go new file mode 100644 index 0000000..d10ce8d --- /dev/null +++ b/server/internal/logic/sys/im_user.go @@ -0,0 +1,209 @@ +// 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/library/imtenant" + "hotgo/internal/model/input/sysin" + "hotgo/internal/service" + "strings" + + "github.com/gogf/gf/v2/database/gdb" + "github.com/gogf/gf/v2/errors/gerror" +) + +type sSysImUser struct{} + +func NewSysImUser() *sSysImUser { + return &sSysImUser{} +} + +func init() { + service.RegisterSysImUser(NewSysImUser()) +} + +// List 获取IM用户列表 +func (s *sSysImUser) List(ctx context.Context, in *sysin.ImUserListInp) (list []*sysin.ImUserListModel, totalCount int, err error) { + if err = in.Filter(ctx); err != nil { + return + } + + db, tenant, err := imtenant.DB(ctx, in.TenantId) + if err != nil { + return + } + + 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 len(list) == 0 { + return + } + + for _, item := range list { + item.TenantId = tenant.Id + item.TenantName = tenant.Name + } + if err = s.fillAuthAndCredentials(ctx, db, list); err != nil { + return + } + return +} + +func (s *sSysImUser) listModel(ctx context.Context, db gdb.DB) *gdb.Model { + return 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( + "u.id user_id", + "u.state", + "u.lastseen last_seen", + "u.useragent user_agent", + "u.createdat created_at", + "u.updatedat updated_at", + "up.nickname", + "up.public_account", + "up.gender", + "up.region", + "us.auth_version", + "us.password_updated_at", + ) +} + +func (s *sSysImUser) applyListWhere(mod *gdb.Model, in *sysin.ImUserListInp) *gdb.Model { + if in.UserId > 0 { + mod = mod.Where("u.id", in.UserId) + } + if in.Nickname != "" { + mod = mod.WhereLike("up.nickname", "%"+in.Nickname+"%") + } + if in.AuthName != "" { + mod = mod.Where("EXISTS (SELECT 1 FROM im_auth a WHERE a.userid = u.id AND a.uname LIKE ?)", "%"+in.AuthName+"%") + } + if in.State != nil { + mod = mod.Where("u.state", *in.State) + } + if len(in.CreatedAt) == 2 { + mod = mod.WhereBetween("u.createdat", in.CreatedAt[0], in.CreatedAt[1]) + } + if len(in.LastSeen) == 2 { + mod = mod.WhereBetween("u.lastseen", in.LastSeen[0], in.LastSeen[1]) + } + return mod +} + +func (s *sSysImUser) fillAuthAndCredentials(ctx context.Context, db gdb.DB, list []*sysin.ImUserListModel) (err error) { + ids := make([]int64, 0, len(list)) + userMap := make(map[int64]*sysin.ImUserListModel, len(list)) + for _, item := range list { + ids = append(ids, item.UserId) + userMap[item.UserId] = item + } + + authRows, err := db.Model("im_auth").Safe().Ctx(ctx). + Fields("userid", "scheme", "uname"). + WhereIn("userid", ids). + OrderAsc("scheme").OrderAsc("uname"). + All() + if err != nil { + return gerror.Wrap(err, "获取IM用户认证标识失败,请稍后重试!") + } + authMap := make(map[int64][]string) + for _, row := range authRows { + userId := row["userid"].Int64() + scheme := row["scheme"].String() + uname := row["uname"].String() + authMap[userId] = append(authMap[userId], scheme+":"+uname) + } + + credentialRows, err := db.Model("im_credentials").Safe().Ctx(ctx). + Fields("userid", "method", "value", "done"). + WhereIn("userid", ids). + WhereNull("deletedat"). + OrderAsc("method").OrderAsc("value"). + All() + if err != nil { + return gerror.Wrap(err, "获取IM用户凭证失败,请稍后重试!") + } + credentialMap := make(map[int64][]string) + for _, row := range credentialRows { + userId := row["userid"].Int64() + method := row["method"].String() + value := row["value"].String() + done := row["done"].Bool() + if !done { + value += "(未验证)" + } + credentialMap[userId] = append(credentialMap[userId], method+":"+value) + } + + for userId, item := range userMap { + item.AuthNames = strings.Join(authMap[userId], ", ") + item.Credentials = strings.Join(credentialMap[userId], ", ") + } + return +} + +// View 获取IM用户指定信息 +func (s *sSysImUser) View(ctx context.Context, in *sysin.ImUserViewInp) (res *sysin.ImUserViewModel, err error) { + if err = in.Filter(ctx); err != nil { + return + } + + db, tenant, err := imtenant.DB(ctx, in.TenantId) + if err != nil { + return + } + + fields := append(s.viewFields(), "u.access access_json", "u.public profile_json", "u.trusted trusted_json", "u.tags tags_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", in.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 +} + +func (s *sSysImUser) viewFields() []interface{} { + return []interface{}{ + "u.id user_id", + "u.state", + "u.lastseen last_seen", + "u.useragent user_agent", + "u.createdat created_at", + "u.updatedat updated_at", + "up.nickname", + "up.public_account", + "up.gender", + "up.region", + "up.signature", + "up.avatar_file_id", + "CAST(up.birth_date AS CHAR) birth_date", + "up.version profile_version", + "us.auth_version", + "us.password_updated_at", + } +} diff --git a/server/internal/logic/sys/im_work_order.go b/server/internal/logic/sys/im_work_order.go new file mode 100644 index 0000000..0e9b6fc --- /dev/null +++ b/server/internal/logic/sys/im_work_order.go @@ -0,0 +1,212 @@ +// 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/library/imtenant" + "hotgo/internal/model/input/sysin" + "hotgo/internal/service" + + "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/gtime" +) + +type sSysImWorkOrder struct{} + +func NewSysImWorkOrder() *sSysImWorkOrder { + return &sSysImWorkOrder{} +} + +func init() { + service.RegisterSysImWorkOrder(NewSysImWorkOrder()) +} + +// List 获取IM举报反馈工单列表 +func (s *sSysImWorkOrder) List(ctx context.Context, in *sysin.ImWorkOrderListInp) (list []*sysin.ImWorkOrderListModel, totalCount int, err error) { + if err = in.Filter(ctx); err != nil { + return + } + db, tenant, err := imtenant.DB(ctx, in.TenantId) + if err != nil { + return + } + + mod := s.listModel(ctx, db, in.Type) + mod = s.applyWhere(mod, in) + mod = mod.Page(in.Page, in.PerPage).OrderDesc("created_at").OrderDesc("id") + + if err = mod.ScanAndCount(&list, &totalCount, false); err != nil { + err = gerror.Wrap(err, "获取IM举报反馈工单列表失败,请稍后重试!") + return + } + for _, item := range list { + item.TenantId = tenant.Id + item.TenantName = tenant.Name + } + return +} + +func (s *sSysImWorkOrder) listModel(ctx context.Context, db gdb.DB, typ string) *gdb.Model { + if typ == sysin.ImWorkOrderTypeAbuse { + return db.Model("im_abuse_reports").Safe().Ctx(ctx).Fields(s.abuseListFields()...) + } + return db.Model("im_feedback_submissions").Safe().Ctx(ctx).Fields(s.feedbackListFields()...) +} + +func (s *sSysImWorkOrder) abuseListFields() []interface{} { + return []interface{}{ + "'abuse' type", + "id", + "report_id order_id", + "reporter_id user_id", + "target_type", + "CONCAT(target_topic, IF(target_seq>0, CONCAT('#', target_seq), ''), IF(target_user_id>0, CONCAT(' user:', target_user_id), '')) target_summary", + "reason_code category", + "description content", + "status", + "created_at", + "updated_at", + } +} + +func (s *sSysImWorkOrder) feedbackListFields() []interface{} { + return []interface{}{ + "'feedback' type", + "id", + "feedback_id order_id", + "user_id", + "'' target_type", + "'' target_summary", + "category", + "content", + "status", + "created_at", + "updated_at", + } +} + +func (s *sSysImWorkOrder) applyWhere(mod *gdb.Model, in *sysin.ImWorkOrderListInp) *gdb.Model { + if in.UserId > 0 { + mod = mod.Where("user_id", in.UserId) + } + if in.Status != "" { + mod = mod.Where("status", in.Status) + } + if in.Keyword != "" { + mod = mod.WhereLike("content", "%"+in.Keyword+"%") + } + if len(in.CreatedAt) == 2 { + mod = mod.WhereBetween("created_at", in.CreatedAt[0], in.CreatedAt[1]) + } + return mod +} + +// View 获取IM举报反馈工单指定信息 +func (s *sSysImWorkOrder) View(ctx context.Context, in *sysin.ImWorkOrderViewInp) (res *sysin.ImWorkOrderViewModel, err error) { + if err = in.Filter(ctx); err != nil { + return + } + db, tenant, err := imtenant.DB(ctx, in.TenantId) + if err != nil { + return nil, err + } + + if in.Type == sysin.ImWorkOrderTypeAbuse { + res, err = s.viewAbuse(ctx, db, in.Id) + } else { + res, err = s.viewFeedback(ctx, db, in.Id) + } + if err != nil || res == nil { + return + } + res.TenantId = tenant.Id + res.TenantName = tenant.Name + return +} + +func (s *sSysImWorkOrder) viewAbuse(ctx context.Context, db gdb.DB, id int64) (res *sysin.ImWorkOrderViewModel, err error) { + err = db.Model("im_abuse_reports").Safe().Ctx(ctx). + Fields( + "'abuse' type", + "id", + "report_id order_id", + "reporter_id user_id", + "target_type", + "CONCAT(target_topic, IF(target_seq>0, CONCAT('#', target_seq), ''), IF(target_user_id>0, CONCAT(' user:', target_user_id), '')) target_summary", + "reason_code category", + "description content", + "description", + "evidence_file_ids evidence_json", + "snapshot snapshot_json", + "idempotency_key", + "status", + "created_at", + "updated_at", + ). + Where("id", id). + Scan(&res) + if err != nil { + return nil, gerror.Wrap(err, "获取IM举报工单信息失败,请稍后重试!") + } + return +} + +func (s *sSysImWorkOrder) viewFeedback(ctx context.Context, db gdb.DB, id int64) (res *sysin.ImWorkOrderViewModel, err error) { + err = db.Model("im_feedback_submissions").Safe().Ctx(ctx). + Fields( + "'feedback' type", + "id", + "feedback_id order_id", + "user_id", + "'' target_type", + "'' target_summary", + "category", + "content", + "contact", + "attachment_file_ids evidence_json", + "client_context client_context_json", + "include_diagnostics", + "idempotency_key", + "status", + "created_at", + "updated_at", + ). + Where("id", id). + Scan(&res) + if err != nil { + return nil, gerror.Wrap(err, "获取IM反馈工单信息失败,请稍后重试!") + } + return +} + +// Status 更新IM举报反馈工单状态 +func (s *sSysImWorkOrder) Status(ctx context.Context, in *sysin.ImWorkOrderStatusInp) (err error) { + if err = in.Filter(ctx); err != nil { + return + } + db, _, err := imtenant.DB(ctx, in.TenantId) + if err != nil { + return + } + + table := "im_abuse_reports" + if in.Type == sysin.ImWorkOrderTypeFeedback { + table = "im_feedback_submissions" + } + if _, err = db.Model(table).Safe().Ctx(ctx). + Where("id", in.Id). + Data(g.Map{ + "status": in.Status, + "updated_at": gtime.Now(), + }). + Update(); err != nil { + return gerror.Wrap(err, "更新IM举报反馈工单状态失败,请稍后重试!") + } + return +} diff --git a/server/internal/logic/sys/log.go b/server/internal/logic/sys/log.go index 147508e..17a0161 100644 --- a/server/internal/logic/sys/log.go +++ b/server/internal/logic/sys/log.go @@ -159,7 +159,7 @@ func (s *sSysLog) AnalysisLog(ctx context.Context) entity.SysLog { module = mctx.Module clientIp = location.GetClientIp(request) postData = gjson.New(consts.NilJsonToString) - getData = gjson.New(request.URL.Query()) + getData = s.MaskSensitiveJson(gjson.New(request.URL.Query())) headerData = gjson.New(consts.NilJsonToString) errorData = gjson.New(consts.NilJsonToString) data entity.SysLog @@ -194,7 +194,7 @@ func (s *sSysLog) AnalysisLog(ctx context.Context) entity.SysLog { // post参数 if body, ok := mctx.Data["request.body"].(*gjson.Json); ok { - postData = body + postData = s.MaskSensitiveJson(body) } // post表单 @@ -208,12 +208,7 @@ func (s *sSysLog) AnalysisLog(ctx context.Context) entity.SysLog { if postData.IsNil() || len(postData.Map()) == 0 { postData = gjson.New(consts.NilJsonToString) } else { - // 隐藏明文显示的密码 - for k := range postData.Map() { - if gstr.ContainsI(k, "password") { - postData.MustSet(k, "******") - } - } + postData = s.MaskSensitiveJson(postData) } // 当前登录用户 @@ -247,7 +242,7 @@ func (s *sSysLog) AnalysisLog(ctx context.Context) entity.SysLog { Url: request.URL.Path, GetData: getData, PostData: postData, - HeaderData: s.SimplifyHeaderParams(headerData), + HeaderData: s.SimplifyHeaderParams(s.MaskSensitiveJson(headerData)), Ip: clientIp, ProvinceId: ipData.ProvinceCode, CityId: ipData.CityCode, @@ -273,6 +268,10 @@ func (s *sSysLog) SimplifyHeaderParams(data *gjson.Json) *gjson.Json { var filters = []string{"Accept", "Authorization", "Cookie"} for _, filter := range filters { v := data.Get(filter) + if isSensitiveLogField(filter) { + data.MustSet(filter, "******") + continue + } if len(v.String()) > 128 { data.MustRemove(filter) } @@ -280,6 +279,58 @@ func (s *sSysLog) SimplifyHeaderParams(data *gjson.Json) *gjson.Json { return data } +// MaskSensitiveJson masks sensitive request fields before access logs are stored. +func (s *sSysLog) MaskSensitiveJson(data *gjson.Json) *gjson.Json { + if data == nil || data.IsNil() { + return data + } + return gjson.New(maskSensitiveValue(data.Map())) +} + +func maskSensitiveValue(v interface{}) interface{} { + switch value := v.(type) { + case map[string]interface{}: + res := make(map[string]interface{}, len(value)) + for k, item := range value { + if isSensitiveLogField(k) { + res[k] = "******" + continue + } + res[k] = maskSensitiveValue(item) + } + return res + case map[string][]string: + res := make(map[string]interface{}, len(value)) + for k, item := range value { + if isSensitiveLogField(k) { + res[k] = "******" + continue + } + res[k] = item + } + return res + case []interface{}: + for i, item := range value { + value[i] = maskSensitiveValue(item) + } + return value + default: + return v + } +} + +func isSensitiveLogField(field string) bool { + sensitive := []string{"password", "invitecode", "invite_code", "servicesecret", "service_secret", "authorization", "x-service-secret"} + normalized := gstr.Replace(gstr.Replace(gstr.ToLower(field), "-", ""), "_", "") + for _, item := range sensitive { + item = gstr.Replace(gstr.Replace(item, "-", ""), "_", "") + if normalized == item || gstr.Contains(normalized, item) { + return true + } + } + return false +} + // View 获取指定请求日志信息 func (s *sSysLog) View(ctx context.Context, in *sysin.LogViewInp) (res *sysin.LogViewModel, err error) { mod := s.Model(ctx) diff --git a/server/internal/model/context.go b/server/internal/model/context.go index 03f5abd..766cc83 100644 --- a/server/internal/model/context.go +++ b/server/internal/model/context.go @@ -22,6 +22,7 @@ type Context struct { // Identity 通用身份模型 type Identity struct { Id int64 `json:"id" description:"用户ID"` + TenantId int64 `json:"tenantId" description:"IM租户ID"` Pid int64 `json:"pid" description:"上级ID"` DeptId int64 `json:"deptId" description:"部门ID"` DeptType string `json:"deptType" description:"部门类型"` diff --git a/server/internal/model/do/admin_member.go b/server/internal/model/do/admin_member.go index 7bfc722..570c656 100755 --- a/server/internal/model/do/admin_member.go +++ b/server/internal/model/do/admin_member.go @@ -14,6 +14,7 @@ import ( type AdminMember struct { g.Meta `orm:"table:hg_admin_member, do:true"` Id any // 管理员ID + TenantId any // IM租户ID DeptId any // 部门ID RoleId any // 角色ID RealName any // 真实姓名 diff --git a/server/internal/model/do/im_tenant.go b/server/internal/model/do/im_tenant.go new file mode 100644 index 0000000..1600c02 --- /dev/null +++ b/server/internal/model/do/im_tenant.go @@ -0,0 +1,26 @@ +// ================================================================================= +// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT. +// ================================================================================= + +package do + +import ( + "github.com/gogf/gf/v2/frame/g" + "github.com/gogf/gf/v2/os/gtime" +) + +// ImTenant is the golang structure of table im_tenant for DAO operations like Where/Data. +type ImTenant struct { + g.Meta `orm:"table:im_tenant, do:true"` + Id any // + Code any // + Name any // + TenantDesc any // + DbName any // + DbDsn any // + State any // + CreatedAt *gtime.Time // + CreatedBy any // + UpdatedAt *gtime.Time // + UpdatedBy any // +} diff --git a/server/internal/model/do/im_tenant_config.go b/server/internal/model/do/im_tenant_config.go new file mode 100644 index 0000000..35ac912 --- /dev/null +++ b/server/internal/model/do/im_tenant_config.go @@ -0,0 +1,24 @@ +// ================================================================================= +// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT. +// ================================================================================= + +package do + +import ( + "github.com/gogf/gf/v2/frame/g" + "github.com/gogf/gf/v2/os/gtime" +) + +// ImTenantConfig is the golang structure of table im_tenant_config for DAO operations like Where/Data. +type ImTenantConfig struct { + g.Meta `orm:"table:im_tenant_config, do:true"` + Id any // + TenantId any // + MaxUsers any // 0 means unlimited + MaxGroups any // 0 means unlimited + MaxGroupMembers any // 0 means unlimited + CreatedAt *gtime.Time // + CreatedBy any // + UpdatedAt *gtime.Time // + UpdatedBy any // +} diff --git a/server/internal/model/entity/admin_member.go b/server/internal/model/entity/admin_member.go index 6ba5eff..43ab68d 100755 --- a/server/internal/model/entity/admin_member.go +++ b/server/internal/model/entity/admin_member.go @@ -12,6 +12,7 @@ import ( // AdminMember is the golang structure for table admin_member. type AdminMember struct { Id int64 `json:"id" orm:"id" description:"管理员ID"` + TenantId int64 `json:"tenantId" orm:"tenant_id" description:"IM租户ID"` DeptId int64 `json:"deptId" orm:"dept_id" description:"部门ID"` RoleId int64 `json:"roleId" orm:"role_id" description:"角色ID"` RealName string `json:"realName" orm:"real_name" description:"真实姓名"` diff --git a/server/internal/model/entity/im_tenant.go b/server/internal/model/entity/im_tenant.go new file mode 100644 index 0000000..dc12c0c --- /dev/null +++ b/server/internal/model/entity/im_tenant.go @@ -0,0 +1,24 @@ +// ================================================================================= +// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT. +// ================================================================================= + +package entity + +import ( + "github.com/gogf/gf/v2/os/gtime" +) + +// ImTenant is the golang structure for table im_tenant. +type ImTenant struct { + Id int64 `json:"id" orm:"id" description:""` + Code string `json:"code" orm:"code" description:""` + Name string `json:"name" orm:"name" description:""` + TenantDesc string `json:"tenantDesc" orm:"tenant_desc" description:""` + DbName string `json:"dbName" orm:"db_name" description:""` + DbDsn string `json:"dbDsn" orm:"db_dsn" description:""` + State int `json:"state" orm:"state" description:""` + CreatedAt *gtime.Time `json:"createdAt" orm:"created_at" description:""` + CreatedBy int64 `json:"createdBy" orm:"created_by" description:""` + UpdatedAt *gtime.Time `json:"updatedAt" orm:"updated_at" description:""` + UpdatedBy int64 `json:"updatedBy" orm:"updated_by" description:""` +} diff --git a/server/internal/model/entity/im_tenant_config.go b/server/internal/model/entity/im_tenant_config.go new file mode 100644 index 0000000..a28e460 --- /dev/null +++ b/server/internal/model/entity/im_tenant_config.go @@ -0,0 +1,22 @@ +// ================================================================================= +// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT. +// ================================================================================= + +package entity + +import ( + "github.com/gogf/gf/v2/os/gtime" +) + +// ImTenantConfig is the golang structure for table im_tenant_config. +type ImTenantConfig struct { + Id int64 `json:"id" orm:"id" description:""` + TenantId int64 `json:"tenantId" orm:"tenant_id" description:""` + MaxUsers uint64 `json:"maxUsers" orm:"max_users" description:"0 means unlimited"` + MaxGroups uint64 `json:"maxGroups" orm:"max_groups" description:"0 means unlimited"` + MaxGroupMembers uint `json:"maxGroupMembers" orm:"max_group_members" description:"0 means unlimited"` + CreatedAt *gtime.Time `json:"createdAt" orm:"created_at" description:""` + CreatedBy int64 `json:"createdBy" orm:"created_by" description:""` + UpdatedAt *gtime.Time `json:"updatedAt" orm:"updated_at" description:""` + UpdatedBy int64 `json:"updatedBy" orm:"updated_by" description:""` +} diff --git a/server/internal/model/input/adminin/member.go b/server/internal/model/input/adminin/member.go index 72a512d..a851b44 100644 --- a/server/internal/model/input/adminin/member.go +++ b/server/internal/model/input/adminin/member.go @@ -115,6 +115,7 @@ type LoginMemberInfoModel struct { // MemberEditInp 修改用户 type MemberEditInp struct { Id int64 `json:"id" dc:"管理员ID"` + TenantId int64 `json:"tenantId" dc:"IM租户ID"` RoleId int64 `json:"roleId" v:"required#角色不能为空" dc:"角色ID"` PostIds []int64 `json:"postIds" dc:"岗位ID"` DeptId int64 `json:"deptId" v:"required#部门不能为空" dc:"部门ID"` diff --git a/server/internal/model/input/sysin/im_audit_log.go b/server/internal/model/input/sysin/im_audit_log.go new file mode 100644 index 0000000..3589a35 --- /dev/null +++ b/server/internal/model/input/sysin/im_audit_log.go @@ -0,0 +1,75 @@ +// 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" + + "github.com/gogf/gf/v2/errors/gerror" + "github.com/gogf/gf/v2/os/gtime" +) + +// ImAuditLogListInp 获取IM审计日志列表 +type ImAuditLogListInp struct { + form.PageReq + TenantId int64 `json:"tenantId" dc:"租户ID"` + ActorUid int64 `json:"actorUid" dc:"操作人UID"` + ActorRole string `json:"actorRole" dc:"操作角色"` + Action string `json:"action" dc:"操作类型"` + TargetType string `json:"targetType" dc:"目标类型"` + TargetId string `json:"targetId" dc:"目标ID"` + RequestId string `json:"requestId" dc:"请求ID"` + Ip string `json:"ip" dc:"IP"` + Keyword string `json:"keyword" dc:"关键词"` + CreatedAt []*gtime.Time `json:"createdAt" dc:"创建时间"` +} + +func (in *ImAuditLogListInp) Filter(ctx context.Context) (err error) { + if in.TenantId <= 0 { + return gerror.New("请选择租户") + } + return +} + +type ImAuditLogListModel struct { + TenantId int64 `json:"tenantId" dc:"租户ID"` + TenantName string `json:"tenantName" dc:"租户名称"` + Id int64 `json:"id" dc:"ID"` + ActorUid int64 `json:"actorUid" dc:"操作人UID"` + ActorRole string `json:"actorRole" dc:"操作角色"` + Action string `json:"action" dc:"操作类型"` + TargetType string `json:"targetType" dc:"目标类型"` + TargetId string `json:"targetId" dc:"目标ID"` + Reason string `json:"reason" dc:"操作原因"` + RequestId string `json:"requestId" dc:"请求ID"` + Ip string `json:"ip" dc:"IP"` + UserAgent string `json:"userAgent" dc:"客户端"` + BeforeSnapshot string `json:"beforeSnapshot" dc:"变更前快照"` + AfterSnapshot string `json:"afterSnapshot" dc:"变更后快照"` + Metadata string `json:"metadata" dc:"元数据"` + CreatedAt *gtime.Time `json:"createdAt" dc:"创建时间"` +} + +// ImAuditLogViewInp 获取指定IM审计日志信息 +type ImAuditLogViewInp struct { + TenantId int64 `json:"tenantId" v:"required#租户ID不能为空" dc:"租户ID"` + Id int64 `json:"id" v:"required#ID不能为空" dc:"ID"` +} + +func (in *ImAuditLogViewInp) Filter(ctx context.Context) (err error) { + if in.TenantId <= 0 { + return gerror.New("请选择租户") + } + if in.Id <= 0 { + return gerror.New("ID不能为空") + } + return +} + +type ImAuditLogViewModel struct { + ImAuditLogListModel +} diff --git a/server/internal/model/input/sysin/im_group.go b/server/internal/model/input/sysin/im_group.go new file mode 100644 index 0000000..d2e68b3 --- /dev/null +++ b/server/internal/model/input/sysin/im_group.go @@ -0,0 +1,290 @@ +// 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" + + "github.com/gogf/gf/v2/errors/gerror" + "github.com/gogf/gf/v2/os/gtime" +) + +// ImGroupListInp 获取IM群组列表 +type ImGroupListInp struct { + form.PageReq + TenantId int64 `json:"tenantId" dc:"租户ID"` + Topic string `json:"topic" dc:"Topic"` + OwnerId int64 `json:"ownerId" dc:"群主ID"` + State *int `json:"state" dc:"通信状态"` + GroupStatus string `json:"groupStatus" dc:"业务状态"` + CreatedAt []*gtime.Time `json:"createdAt" dc:"创建时间"` + TouchedAt []*gtime.Time `json:"touchedAt" dc:"活跃时间"` +} + +func (in *ImGroupListInp) Filter(ctx context.Context) (err error) { + if in.TenantId <= 0 { + return gerror.New("请选择租户") + } + return +} + +type ImGroupListModel struct { + TenantId int64 `json:"tenantId" dc:"租户ID"` + TenantName string `json:"tenantName" dc:"租户名称"` + Topic string `json:"topic" dc:"Topic"` + State int `json:"state" dc:"状态"` + OwnerId int64 `json:"ownerId" dc:"群主ID"` + SeqId int `json:"seqId" dc:"最新消息序号"` + DelId int `json:"delId" dc:"删除序号"` + SubCnt int `json:"subCnt" dc:"成员数"` + TouchedAt *gtime.Time `json:"touchedAt" dc:"活跃时间"` + CreatedAt *gtime.Time `json:"createdAt" dc:"创建时间"` + UpdatedAt *gtime.Time `json:"updatedAt" dc:"修改时间"` + PublicJson string `json:"publicJson" dc:"公开资料"` + TrustedJson string `json:"trustedJson" dc:"可信资料"` + TagsJson string `json:"tagsJson" dc:"标签"` + AuxJson string `json:"auxJson" dc:"辅助资料"` + + GroupName string `json:"groupName" dc:"群名称"` + AvatarFileId string `json:"avatarFileId" dc:"头像文件ID"` + Description string `json:"description" dc:"简介"` + GroupStatus string `json:"groupStatus" dc:"业务状态"` + JoinApproval int `json:"joinApproval" dc:"入群审批"` + MuteAll int `json:"muteAll" dc:"全员禁言"` + MemberUploadFiles int `json:"memberUploadFiles" dc:"成员上传文件"` + MemberPrivateChat int `json:"memberPrivateChat" dc:"成员私聊"` + MemberInvite int `json:"memberInvite" dc:"成员邀请"` + MaxMemberCount int `json:"maxMemberCount" dc:"最大成员数"` + MaxAdminCount int `json:"maxAdminCount" dc:"最大管理员数"` + BusinessMemberCount int `json:"businessMemberCount" dc:"业务成员数"` + SettingsVersion int `json:"settingsVersion" dc:"治理配置版本"` + SettingsUpdatedAt *gtime.Time `json:"settingsUpdatedAt" dc:"治理配置更新时间"` +} + +// ImGroupViewInp 获取指定IM群组信息 +type ImGroupViewInp struct { + TenantId int64 `json:"tenantId" v:"required#租户ID不能为空" dc:"租户ID"` + Topic string `json:"topic" v:"required#Topic不能为空" dc:"Topic"` +} + +func (in *ImGroupViewInp) Filter(ctx context.Context) (err error) { + if in.TenantId <= 0 { + return gerror.New("请选择租户") + } + if in.Topic == "" { + return gerror.New("Topic不能为空") + } + return +} + +type ImGroupViewModel struct { + ImGroupListModel + AccessJson string `json:"accessJson" dc:"访问权限"` + UseBt int `json:"useBt" dc:"是否开启BT"` +} + +// ImGroupMemberListInp 获取IM群成员列表 +type ImGroupMemberListInp struct { + form.PageReq + TenantId int64 `json:"tenantId" v:"required#租户ID不能为空" dc:"租户ID"` + Topic string `json:"topic" v:"required#Topic不能为空" dc:"Topic"` + UserId int64 `json:"userId" dc:"用户ID"` + Role string `json:"role" dc:"角色"` + MemberStatus string `json:"memberStatus" dc:"成员状态"` +} + +func (in *ImGroupMemberListInp) Filter(ctx context.Context) (err error) { + if in.TenantId <= 0 { + return gerror.New("请选择租户") + } + if in.Topic == "" { + return gerror.New("Topic不能为空") + } + return +} + +type ImGroupMemberListModel struct { + UserId int64 `json:"userId" dc:"用户ID"` + Nickname string `json:"nickname" dc:"昵称"` + State int `json:"state" dc:"用户状态"` + ModeWant string `json:"modeWant" dc:"期望权限"` + ModeGiven string `json:"modeGiven" dc:"授予权限"` + RecvSeqId int `json:"recvSeqId" dc:"接收序号"` + ReadSeqId int `json:"readSeqId" dc:"已读序号"` + DelId int `json:"delId" dc:"删除序号"` + DeletedAt *gtime.Time `json:"deletedAt" dc:"删除时间"` + CreatedAt *gtime.Time `json:"createdAt" dc:"加入时间"` + UpdatedAt *gtime.Time `json:"updatedAt" dc:"修改时间"` + PrivateJson string `json:"privateJson" dc:"私有资料"` + Role string `json:"role" dc:"业务角色"` + MemberStatus string `json:"memberStatus" dc:"业务状态"` + GroupNickname string `json:"groupNickname" dc:"群昵称"` + Pinned int `json:"pinned" dc:"置顶"` + MuteNotifications int `json:"muteNotifications" dc:"免打扰"` + JoinSource string `json:"joinSource" dc:"加入来源"` + JoinedBy int64 `json:"joinedBy" dc:"邀请/处理人"` + JoinedAt *gtime.Time `json:"joinedAt" dc:"业务加入时间"` + LeftAt *gtime.Time `json:"leftAt" dc:"离开时间"` + RemovedBy int64 `json:"removedBy" dc:"移除人"` + Version int `json:"version" dc:"版本"` +} + +// ImGroupTopicInp 群Topic通用入参 +type ImGroupTopicInp struct { + TenantId int64 `json:"tenantId" v:"required#租户ID不能为空" dc:"租户ID"` + Topic string `json:"topic" v:"required#Topic不能为空" dc:"Topic"` +} + +func (in *ImGroupTopicInp) Filter(ctx context.Context) (err error) { + if in.TenantId <= 0 { + return gerror.New("请选择租户") + } + if in.Topic == "" { + return gerror.New("Topic不能为空") + } + return +} + +type ImGroupGovernanceModel struct { + TenantId int64 `json:"tenantId" dc:"租户ID"` + TenantName string `json:"tenantName" dc:"租户名称"` + Topic string `json:"topic" dc:"Topic"` + OwnerUserId int64 `json:"ownerUserId" dc:"群主ID"` + Name string `json:"name" dc:"群名称"` + AvatarFileId string `json:"avatarFileId" dc:"头像文件ID"` + Description string `json:"description" dc:"简介"` + GroupStatus string `json:"groupStatus" dc:"业务状态"` + JoinApproval int `json:"joinApproval" dc:"入群审批"` + MuteAll int `json:"muteAll" dc:"全员禁言"` + MemberUploadFiles int `json:"memberUploadFiles" dc:"成员上传文件"` + MemberPrivateChat int `json:"memberPrivateChat" dc:"成员私聊"` + MemberInvite int `json:"memberInvite" dc:"成员邀请"` + MaxMemberCount int `json:"maxMemberCount" dc:"最大成员数"` + MaxAdminCount int `json:"maxAdminCount" dc:"最大管理员数"` + MemberCount int `json:"memberCount" dc:"成员数"` + Version int `json:"version" dc:"版本"` + CreatedBy int64 `json:"createdBy" dc:"创建人"` + DissolvedBy int64 `json:"dissolvedBy" dc:"解散人"` + DissolvedAt *gtime.Time `json:"dissolvedAt" dc:"解散时间"` + CreatedAt *gtime.Time `json:"createdAt" dc:"创建时间"` + UpdatedAt *gtime.Time `json:"updatedAt" dc:"修改时间"` +} + +type ImGroupJoinRequestListInp struct { + form.PageReq + ImGroupTopicInp + Status string `json:"status" dc:"状态"` + ApplicantUserId int64 `json:"applicantUserId" dc:"申请人ID"` +} + +type ImGroupJoinRequestListModel struct { + Id int64 `json:"id" dc:"ID"` + RequestId string `json:"requestId" dc:"申请ID"` + Topic string `json:"topic" dc:"Topic"` + ApplicantUserId int64 `json:"applicantUserId" dc:"申请人ID"` + InviterUserId int64 `json:"inviterUserId" dc:"邀请人ID"` + Source string `json:"source" dc:"来源"` + Message string `json:"message" dc:"申请消息"` + Status string `json:"status" dc:"状态"` + RejectReason string `json:"rejectReason" dc:"拒绝原因"` + HandledBy int64 `json:"handledBy" dc:"处理人"` + HandledAt *gtime.Time `json:"handledAt" dc:"处理时间"` + ExpiresAt *gtime.Time `json:"expiresAt" dc:"过期时间"` + Version int `json:"version" dc:"版本"` + CreatedAt *gtime.Time `json:"createdAt" dc:"创建时间"` + UpdatedAt *gtime.Time `json:"updatedAt" dc:"修改时间"` +} + +type ImGroupInvitationListInp struct { + form.PageReq + ImGroupTopicInp + Status string `json:"status" dc:"状态"` + TargetUserId int64 `json:"targetUserId" dc:"目标用户ID"` + InviterUserId int64 `json:"inviterUserId" dc:"邀请人ID"` +} + +type ImGroupInvitationListModel struct { + Id int64 `json:"id" dc:"ID"` + InvitationId string `json:"invitationId" dc:"邀请ID"` + Topic string `json:"topic" dc:"Topic"` + InviterUserId int64 `json:"inviterUserId" dc:"邀请人ID"` + TargetUserId int64 `json:"targetUserId" dc:"目标用户ID"` + Message string `json:"message" dc:"邀请消息"` + Status string `json:"status" dc:"状态"` + Result string `json:"result" dc:"结果"` + JoinRequestId string `json:"joinRequestId" dc:"入群申请ID"` + ExpiresAt *gtime.Time `json:"expiresAt" dc:"过期时间"` + HandledAt *gtime.Time `json:"handledAt" dc:"处理时间"` + CreatedAt *gtime.Time `json:"createdAt" dc:"创建时间"` + UpdatedAt *gtime.Time `json:"updatedAt" dc:"修改时间"` +} + +type ImGroupAnnouncementListInp struct { + form.PageReq + ImGroupTopicInp + Status string `json:"status" dc:"状态"` +} + +type ImGroupAnnouncementListModel struct { + Id int64 `json:"id" dc:"ID"` + AnnouncementId string `json:"announcementId" dc:"公告ID"` + Topic string `json:"topic" dc:"Topic"` + AuthorUserId int64 `json:"authorUserId" dc:"发布人ID"` + Content string `json:"content" dc:"内容"` + Status string `json:"status" dc:"状态"` + Version int `json:"version" dc:"版本"` + PublishedAt *gtime.Time `json:"publishedAt" dc:"发布时间"` + DeletedBy int64 `json:"deletedBy" dc:"删除人"` + DeletedAt *gtime.Time `json:"deletedAt" dc:"删除时间"` + CreatedAt *gtime.Time `json:"createdAt" dc:"创建时间"` + UpdatedAt *gtime.Time `json:"updatedAt" dc:"修改时间"` +} + +type ImGroupMuteListInp struct { + form.PageReq + ImGroupTopicInp + Status string `json:"status" dc:"状态"` + UserId int64 `json:"userId" dc:"用户ID"` +} + +type ImGroupMuteListModel struct { + Topic string `json:"topic" dc:"Topic"` + UserId int64 `json:"userId" dc:"用户ID"` + MutedBy int64 `json:"mutedBy" dc:"禁言人"` + MutedAt *gtime.Time `json:"mutedAt" dc:"禁言时间"` + ExpiresAt *gtime.Time `json:"expiresAt" dc:"过期时间"` + Reason string `json:"reason" dc:"原因"` + Status string `json:"status" dc:"状态"` + RevokedBy int64 `json:"revokedBy" dc:"解除人"` + RevokedAt *gtime.Time `json:"revokedAt" dc:"解除时间"` + Version int `json:"version" dc:"版本"` + CreatedAt *gtime.Time `json:"createdAt" dc:"创建时间"` + UpdatedAt *gtime.Time `json:"updatedAt" dc:"修改时间"` +} + +type ImGroupAdminLogListInp struct { + form.PageReq + ImGroupTopicInp + Action string `json:"action" dc:"动作"` + OperatorId int64 `json:"operatorId" dc:"操作人ID"` +} + +type ImGroupAdminLogListModel struct { + Id int64 `json:"id" dc:"ID"` + LogId string `json:"logId" dc:"日志ID"` + Topic string `json:"topic" dc:"Topic"` + OperatorId int64 `json:"operatorId" dc:"操作人"` + TargetUserId int64 `json:"targetUserId" dc:"目标用户"` + Action string `json:"action" dc:"动作"` + ObjectType string `json:"objectType" dc:"对象类型"` + ObjectId string `json:"objectId" dc:"对象ID"` + BeforeData string `json:"beforeData" dc:"变更前"` + AfterData string `json:"afterData" dc:"变更后"` + RequestId string `json:"requestId" dc:"请求ID"` + IdempotencyKey string `json:"idempotencyKey" dc:"幂等键"` + CreatedAt *gtime.Time `json:"createdAt" dc:"创建时间"` +} diff --git a/server/internal/model/input/sysin/im_invite.go b/server/internal/model/input/sysin/im_invite.go new file mode 100644 index 0000000..713e6dc --- /dev/null +++ b/server/internal/model/input/sysin/im_invite.go @@ -0,0 +1,140 @@ +package sysin + +import ( + "context" + "hotgo/internal/model/input/form" + "strconv" + + "github.com/gogf/gf/v2/errors/gerror" + "github.com/gogf/gf/v2/os/gtime" +) + +type ImInviteListInp struct { + form.PageReq + TenantId int64 `json:"tenantId" dc:"IM租户ID"` + InviteCode string `json:"inviteCode" dc:"完整邀请码"` + Status string `json:"status" dc:"状态"` + BatchId string `json:"batchId" dc:"批次ID"` + CreatedBy string `json:"createdBy" dc:"创建人"` + CreatedAtStart *gtime.Time `json:"createdAtStart" dc:"创建开始时间"` + CreatedAtEnd *gtime.Time `json:"createdAtEnd" dc:"创建结束时间"` + ExpiresAtStart *gtime.Time `json:"expiresAtStart" dc:"过期开始时间"` + ExpiresAtEnd *gtime.Time `json:"expiresAtEnd" dc:"过期结束时间"` + CreatedAt []*gtime.Time `json:"createdAt" dc:"创建时间"` + ExpiresAt []*gtime.Time `json:"expiresAt" dc:"到期时间"` +} + +func (in *ImInviteListInp) Filter(ctx context.Context) (err error) { + if in.PerPage <= 0 { + in.PerPage = 20 + } + if in.PerPage > 100 { + return gerror.New("每页数量最大值不能大于100") + } + if in.InviteCode != "" && len(in.InviteCode) != 5 { + return gerror.New("邀请码必须为完整5位") + } + if len(in.CreatedAt) == 2 { + in.CreatedAtStart = in.CreatedAt[0] + in.CreatedAtEnd = in.CreatedAt[1] + } + if len(in.ExpiresAt) == 2 { + in.ExpiresAtStart = in.ExpiresAt[0] + in.ExpiresAtEnd = in.ExpiresAt[1] + } + return +} + +type ImInviteCreateInp struct { + TenantId int64 `json:"tenantId" dc:"IM租户ID"` + ExpiresAt *gtime.Time `json:"expiresAt" v:"required#到期时间不能为空" dc:"到期时间"` + Remark string `json:"remark" dc:"备注"` + IdempotencyKey string `json:"idempotencyKey" dc:"幂等键"` +} + +func (in *ImInviteCreateInp) Filter(ctx context.Context) (err error) { + if in.ExpiresAt == nil || in.ExpiresAt.Before(gtime.Now()) { + return gerror.New("到期时间必须晚于当前时间") + } + if len(in.Remark) > 255 { + return gerror.New("备注不能超过255个字符") + } + return +} + +type ImInviteBatchCreateInp struct { + TenantId int64 `json:"tenantId" dc:"IM租户ID"` + Quantity int `json:"quantity" dc:"数量"` + ExpiresAt *gtime.Time `json:"expiresAt" v:"required#到期时间不能为空" dc:"到期时间"` + Remark string `json:"remark" dc:"备注"` + IdempotencyKey string `json:"idempotencyKey" dc:"幂等键"` +} + +func (in *ImInviteBatchCreateInp) Filter(ctx context.Context) (err error) { + if in.Quantity < 1 || in.Quantity > 1000 { + return gerror.New("批量生成数量必须在1到1000之间") + } + if in.ExpiresAt == nil || in.ExpiresAt.Before(gtime.Now()) { + return gerror.New("到期时间必须晚于当前时间") + } + if len(in.Remark) > 255 { + return gerror.New("备注不能超过255个字符") + } + return +} + +type ImInviteBatchViewInp struct { + TenantId int64 `json:"tenantId" dc:"IM租户ID"` + BatchId string `json:"batchId" v:"required#批次ID不能为空" dc:"批次ID"` +} + +type ImInviteRevokeInp struct { + TenantId int64 `json:"tenantId" dc:"IM租户ID"` + Id string `json:"id" v:"required#邀请码ID不能为空" dc:"邀请码ID"` +} + +type ImInviteBatchRevokeInp struct { + TenantId int64 `json:"tenantId" dc:"IM租户ID"` + BatchId string `json:"batchId" v:"required#批次ID不能为空" dc:"批次ID"` +} + +type ImInviteCodeModel struct { + Id string `json:"id" dc:"ID"` + BatchId string `json:"batchId" dc:"批次ID"` + Code string `json:"code" dc:"邀请码"` + Status string `json:"status" dc:"状态"` + ExpiresAt *gtime.Time `json:"expiresAt" dc:"到期时间"` + UsedBy string `json:"usedBy" dc:"使用用户"` + UsedAt *gtime.Time `json:"usedAt" dc:"使用时间"` + RequestId string `json:"requestId" dc:"请求ID"` + CreatedBy string `json:"createdBy" dc:"创建人"` + Remark string `json:"remark" dc:"备注"` + CreatedAt *gtime.Time `json:"createdAt" dc:"创建时间"` +} + +type ImInviteBatchModel struct { + BatchId string `json:"batchId" dc:"批次ID"` + Quantity int `json:"quantity" dc:"数量"` + ActiveCount int `json:"activeCount" dc:"有效数量"` + UsedCount int `json:"usedCount" dc:"已使用数量"` + RevokedCount int `json:"revokedCount" dc:"已撤销数量"` + ExpiredCount int `json:"expiredCount" dc:"已过期数量"` + ExpiresAt *gtime.Time `json:"expiresAt" dc:"到期时间"` + CreatedAt *gtime.Time `json:"createdAt" dc:"创建时间"` +} + +type ImInviteBatchDetailModel struct { + ImInviteBatchModel + List []*ImInviteCodeModel `json:"list" dc:"批次邀请码"` +} + +func ParseOptionalInt64(value string) (int64, error) { + if value == "" { + return 0, nil + } + parsed, err := strconv.ParseInt(value, 10, 64) + if err != nil || parsed <= 0 { + return 0, gerror.New("ID格式不正确") + } + return parsed, nil +} diff --git a/server/internal/model/input/sysin/im_realname.go b/server/internal/model/input/sysin/im_realname.go new file mode 100644 index 0000000..d22d1cb --- /dev/null +++ b/server/internal/model/input/sysin/im_realname.go @@ -0,0 +1,96 @@ +// 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/consts" + "hotgo/internal/library/dict" + "hotgo/internal/model/input/form" + + "github.com/gogf/gf/v2/errors/gerror" + "github.com/gogf/gf/v2/os/gtime" +) + +// ImRealnameListInp 获取IM实名审核列表 +type ImRealnameListInp struct { + form.PageReq + TenantId int64 `json:"tenantId" dc:"租户ID"` + UserId int64 `json:"userId" dc:"用户ID"` + SubmissionId string `json:"submissionId" dc:"提交ID"` + Status string `json:"status" dc:"状态"` + CreatedAt []*gtime.Time `json:"createdAt" dc:"创建时间"` +} + +func (in *ImRealnameListInp) Filter(ctx context.Context) (err error) { + if in.TenantId <= 0 { + return gerror.New("请选择租户") + } + return +} + +type ImRealnameListModel struct { + TenantId int64 `json:"tenantId" dc:"租户ID"` + TenantName string `json:"tenantName" dc:"租户名称"` + Id int64 `json:"id" dc:"ID"` + UserId int64 `json:"userId" dc:"用户ID"` + SubmissionId string `json:"submissionId" dc:"提交ID"` + Status string `json:"status" dc:"状态"` + MaskedName string `json:"maskedName" dc:"脱敏姓名"` + MaskedIdNumber string `json:"maskedIdNumber" dc:"脱敏证件号"` + IdType string `json:"idType" dc:"证件类型"` + ReviewReason string `json:"reviewReason" dc:"审核原因"` + VerifiedAt *gtime.Time `json:"verifiedAt" dc:"通过时间"` + CreatedAt *gtime.Time `json:"createdAt" dc:"创建时间"` + UpdatedAt *gtime.Time `json:"updatedAt" dc:"修改时间"` +} + +// ImRealnameViewInp 获取指定IM实名审核信息 +type ImRealnameViewInp struct { + TenantId int64 `json:"tenantId" v:"required#租户ID不能为空" dc:"租户ID"` + Id int64 `json:"id" v:"required#ID不能为空" dc:"ID"` +} + +func (in *ImRealnameViewInp) Filter(ctx context.Context) (err error) { + if in.TenantId <= 0 { + return gerror.New("请选择租户") + } + if in.Id <= 0 { + return gerror.New("ID不能为空") + } + return +} + +type ImRealnameViewModel struct { + ImRealnameListModel + IdNumberHash string `json:"idNumberHash" dc:"证件号哈希"` +} + +// ImRealnameReviewInp 审核IM实名 +type ImRealnameReviewInp struct { + TenantId int64 `json:"tenantId" v:"required#租户ID不能为空" dc:"租户ID"` + Id int64 `json:"id" v:"required#ID不能为空" dc:"ID"` + Status string `json:"status" dc:"审核状态"` + ReviewReason string `json:"reviewReason" dc:"审核原因"` +} + +func (in *ImRealnameReviewInp) Filter(ctx context.Context) (err error) { + if in.TenantId <= 0 { + return gerror.New("请选择租户") + } + if in.Id <= 0 { + return gerror.New("ID不能为空") + } + if !dict.HasOptionKey(consts.ImRealnameStatusOptions, in.Status) { + return gerror.New("审核状态不正确") + } + if in.Status == consts.ImRealnameStatusRejected && in.ReviewReason == "" { + return gerror.New("拒绝原因不能为空") + } + return +} + +type ImRealnameReviewModel struct{} diff --git a/server/internal/model/input/sysin/im_registration.go b/server/internal/model/input/sysin/im_registration.go new file mode 100644 index 0000000..f7c7449 --- /dev/null +++ b/server/internal/model/input/sysin/im_registration.go @@ -0,0 +1,12 @@ +package sysin + +import "github.com/gogf/gf/v2/os/gtime" + +type ImRegistrationConfigModel struct { + RegisterSmsEnabled bool `json:"registerSmsEnabled" dc:"注册短信是否真实发送"` + UpdatedAt *gtime.Time `json:"updatedAt" dc:"更新时间"` +} + +type ImRegistrationConfigUpdateInp struct { + RegisterSmsEnabled bool `json:"registerSmsEnabled" dc:"注册短信是否真实发送"` +} diff --git a/server/internal/model/input/sysin/im_tenant.go b/server/internal/model/input/sysin/im_tenant.go new file mode 100644 index 0000000..3b887f7 --- /dev/null +++ b/server/internal/model/input/sysin/im_tenant.go @@ -0,0 +1,157 @@ +// 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/consts" + "hotgo/internal/library/dict" + "hotgo/internal/library/hgorm/hook" + "hotgo/internal/model/entity" + "hotgo/internal/model/input/form" + + "github.com/gogf/gf/v2/errors/gerror" + "github.com/gogf/gf/v2/frame/g" + "github.com/gogf/gf/v2/os/gtime" +) + +// ImTenantUpdateFields 修改IM租户字段过滤 +type ImTenantUpdateFields struct { + Code string `json:"code" dc:"租户编码"` + Name string `json:"name" dc:"租户名称"` + TenantDesc string `json:"tenantDesc" dc:"租户描述"` + DbName string `json:"dbName" dc:"业务库名"` + DbDsn string `json:"dbDsn" dc:"独立连接串"` + State int `json:"state" dc:"状态"` + UpdatedBy int64 `json:"updatedBy" dc:"更新者"` +} + +// ImTenantInsertFields 新增IM租户字段过滤 +type ImTenantInsertFields struct { + Code string `json:"code" dc:"租户编码"` + Name string `json:"name" dc:"租户名称"` + TenantDesc string `json:"tenantDesc" dc:"租户描述"` + DbName string `json:"dbName" dc:"业务库名"` + DbDsn string `json:"dbDsn" dc:"独立连接串"` + State int `json:"state" dc:"状态"` + CreatedBy int64 `json:"createdBy" dc:"创建者"` +} + +// ImTenantConfigUpdateFields 修改IM租户配置字段过滤 +type ImTenantConfigUpdateFields struct { + MaxUsers uint64 `json:"maxUsers" dc:"最大用户数"` + MaxGroups uint64 `json:"maxGroups" dc:"最大群组数"` + MaxGroupMembers uint `json:"maxGroupMembers" dc:"最大群成员数"` + UpdatedBy int64 `json:"updatedBy" dc:"更新者"` +} + +// ImTenantConfigInsertFields 新增IM租户配置字段过滤 +type ImTenantConfigInsertFields struct { + TenantId int64 `json:"tenantId" dc:"租户ID"` + MaxUsers uint64 `json:"maxUsers" dc:"最大用户数"` + MaxGroups uint64 `json:"maxGroups" dc:"最大群组数"` + MaxGroupMembers uint `json:"maxGroupMembers" dc:"最大群成员数"` + CreatedBy int64 `json:"createdBy" dc:"创建者"` +} + +// ImTenantEditInp 修改/新增IM租户 +type ImTenantEditInp struct { + entity.ImTenant + MaxUsers uint64 `json:"maxUsers" dc:"最大用户数"` + MaxGroups uint64 `json:"maxGroups" dc:"最大群组数"` + MaxGroupMembers uint `json:"maxGroupMembers" dc:"最大群成员数"` +} + +func (in *ImTenantEditInp) Filter(ctx context.Context) (err error) { + if e := g.Validator().Rules("required|length:2,64").Data(in.Code).Messages("租户编码不能为空|租户编码长度应为2到64个字符").Run(ctx); e != nil { + return e.Current() + } + if e := g.Validator().Rules("required|max-length:128").Data(in.Name).Messages("租户名称不能为空|租户名称不能超过128个字符").Run(ctx); e != nil { + return e.Current() + } + if in.DbName == "" && in.DbDsn == "" { + return gerror.New("业务库名和独立连接串至少填写一个") + } + if !dict.HasOptionKey(consts.ImTenantStateOptions, in.State) { + return gerror.New("租户状态不正确") + } + return +} + +type ImTenantEditModel struct{} + +// ImTenantViewInp 获取指定IM租户信息 +type ImTenantViewInp struct { + Id int64 `json:"id" v:"required#ID不能为空" dc:"ID"` +} + +func (in *ImTenantViewInp) Filter(ctx context.Context) (err error) { + return +} + +type ImTenantViewModel struct { + entity.ImTenant + MaxUsers uint64 `json:"maxUsers" dc:"最大用户数"` + MaxGroups uint64 `json:"maxGroups" dc:"最大群组数"` + MaxGroupMembers uint `json:"maxGroupMembers" dc:"最大群成员数"` + CreatedBySumma *hook.MemberSumma `json:"createdBySumma" dc:"创建者摘要信息"` + UpdatedBySumma *hook.MemberSumma `json:"updatedBySumma" dc:"更新者摘要信息"` + DbDsnConfigured bool `json:"dbDsnConfigured" dc:"是否配置独立连接串"` + TenantDbReachable bool `json:"tenantDbReachable" dc:"业务库是否可达"` + TenantDbCheckError string `json:"tenantDbCheckError" dc:"业务库检查错误"` +} + +// ImTenantListInp 获取IM租户列表 +type ImTenantListInp struct { + form.PageReq + Id int64 `json:"id" dc:"ID"` + Code string `json:"code" dc:"租户编码"` + Name string `json:"name" dc:"租户名称"` + DbName string `json:"dbName" dc:"业务库名"` + State *int `json:"state" dc:"状态"` + CreatedAt []*gtime.Time `json:"createdAt" dc:"创建时间"` +} + +func (in *ImTenantListInp) Filter(ctx context.Context) (err error) { + return +} + +type ImTenantListModel struct { + Id int64 `json:"id" dc:"ID"` + Code string `json:"code" dc:"租户编码"` + Name string `json:"name" dc:"租户名称"` + TenantDesc string `json:"tenantDesc" dc:"租户描述"` + DbName string `json:"dbName" dc:"业务库名"` + State int `json:"state" dc:"状态"` + MaxUsers uint64 `json:"maxUsers" dc:"最大用户数"` + MaxGroups uint64 `json:"maxGroups" dc:"最大群组数"` + MaxGroupMembers uint `json:"maxGroupMembers" dc:"最大群成员数"` + DbDsnConfigured bool `json:"dbDsnConfigured" dc:"是否配置独立连接串"` + CreatedBy int64 `json:"createdBy" dc:"创建者"` + CreatedBySumma *hook.MemberSumma `json:"createdBySumma" dc:"创建者摘要信息"` + UpdatedBy int64 `json:"updatedBy" dc:"更新者"` + UpdatedBySumma *hook.MemberSumma `json:"updatedBySumma" dc:"更新者摘要信息"` + CreatedAt *gtime.Time `json:"createdAt" dc:"创建时间"` + UpdatedAt *gtime.Time `json:"updatedAt" dc:"修改时间"` +} + +// ImTenantStatusInp 更新IM租户状态 +type ImTenantStatusInp struct { + Id int64 `json:"id" v:"required#ID不能为空" dc:"ID"` + State int `json:"state" dc:"状态"` +} + +func (in *ImTenantStatusInp) Filter(ctx context.Context) (err error) { + if in.Id <= 0 { + return gerror.New("ID不能为空") + } + if !dict.HasOptionKey(consts.ImTenantStateOptions, in.State) { + return gerror.New("租户状态不正确") + } + return +} + +type ImTenantStatusModel struct{} diff --git a/server/internal/model/input/sysin/im_user.go b/server/internal/model/input/sysin/im_user.go new file mode 100644 index 0000000..9cac3b6 --- /dev/null +++ b/server/internal/model/input/sysin/im_user.go @@ -0,0 +1,80 @@ +// 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" + + "github.com/gogf/gf/v2/errors/gerror" + "github.com/gogf/gf/v2/os/gtime" +) + +// ImUserListInp 获取IM用户列表 +type ImUserListInp struct { + form.PageReq + TenantId int64 `json:"tenantId" dc:"租户ID"` + UserId int64 `json:"userId" dc:"用户ID"` + Nickname string `json:"nickname" dc:"昵称"` + AuthName string `json:"authName" dc:"登录标识"` + State *int `json:"state" dc:"账号状态"` + CreatedAt []*gtime.Time `json:"createdAt" dc:"创建时间"` + LastSeen []*gtime.Time `json:"lastSeen" dc:"最后在线时间"` +} + +func (in *ImUserListInp) Filter(ctx context.Context) (err error) { + if in.TenantId <= 0 { + return gerror.New("请选择租户") + } + return +} + +type ImUserListModel struct { + TenantId int64 `json:"tenantId" dc:"租户ID"` + TenantName string `json:"tenantName" dc:"租户名称"` + UserId int64 `json:"userId" dc:"用户ID"` + Nickname string `json:"nickname" dc:"昵称"` + PublicAccount string `json:"publicAccount" dc:"公开账号"` + Gender string `json:"gender" dc:"性别"` + Region string `json:"region" dc:"地区"` + State int `json:"state" dc:"账号状态"` + AuthVersion int `json:"authVersion" dc:"认证版本"` + PasswordUpdatedAt *gtime.Time `json:"passwordUpdatedAt" dc:"密码更新时间"` + AuthNames string `json:"authNames" dc:"登录标识"` + Credentials string `json:"credentials" dc:"凭证"` + LastSeen *gtime.Time `json:"lastSeen" dc:"最后在线时间"` + UserAgent string `json:"userAgent" dc:"客户端"` + CreatedAt *gtime.Time `json:"createdAt" dc:"创建时间"` + UpdatedAt *gtime.Time `json:"updatedAt" dc:"修改时间"` +} + +// ImUserViewInp 获取指定IM用户信息 +type ImUserViewInp struct { + TenantId int64 `json:"tenantId" v:"required#租户ID不能为空" dc:"租户ID"` + UserId int64 `json:"userId" v:"required#用户ID不能为空" dc:"用户ID"` +} + +func (in *ImUserViewInp) Filter(ctx context.Context) (err error) { + if in.TenantId <= 0 { + return gerror.New("请选择租户") + } + if in.UserId <= 0 { + return gerror.New("用户ID不能为空") + } + return +} + +type ImUserViewModel struct { + ImUserListModel + Signature string `json:"signature" dc:"个性签名"` + AvatarFileId string `json:"avatarFileId" dc:"头像文件ID"` + BirthDate string `json:"birthDate" dc:"出生日期"` + ProfileJson string `json:"profileJson" dc:"Tinode Public资料"` + TrustedJson string `json:"trustedJson" dc:"Tinode Trusted资料"` + TagsJson string `json:"tagsJson" dc:"Tinode标签"` + AccessJson string `json:"accessJson" dc:"访问权限"` + ProfileVersion int `json:"profileVersion" dc:"资料版本"` +} diff --git a/server/internal/model/input/sysin/im_work_order.go b/server/internal/model/input/sysin/im_work_order.go new file mode 100644 index 0000000..c6d1fe9 --- /dev/null +++ b/server/internal/model/input/sysin/im_work_order.go @@ -0,0 +1,118 @@ +// 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/consts" + "hotgo/internal/library/dict" + "hotgo/internal/model/input/form" + + "github.com/gogf/gf/v2/errors/gerror" + "github.com/gogf/gf/v2/os/gtime" +) + +const ( + ImWorkOrderTypeAbuse = "abuse" + ImWorkOrderTypeFeedback = "feedback" +) + +// ImWorkOrderListInp 获取IM举报反馈工单列表 +type ImWorkOrderListInp struct { + form.PageReq + TenantId int64 `json:"tenantId" dc:"租户ID"` + Type string `json:"type" dc:"工单类型"` + UserId int64 `json:"userId" dc:"提交人ID"` + Status string `json:"status" dc:"状态"` + Keyword string `json:"keyword" dc:"关键词"` + CreatedAt []*gtime.Time `json:"createdAt" dc:"创建时间"` +} + +func (in *ImWorkOrderListInp) Filter(ctx context.Context) (err error) { + if in.TenantId <= 0 { + return gerror.New("请选择租户") + } + if in.Type == "" { + return gerror.New("请选择工单类型") + } + if in.Type != ImWorkOrderTypeAbuse && in.Type != ImWorkOrderTypeFeedback { + return gerror.New("工单类型不正确") + } + return +} + +type ImWorkOrderListModel struct { + TenantId int64 `json:"tenantId" dc:"租户ID"` + TenantName string `json:"tenantName" dc:"租户名称"` + Type string `json:"type" dc:"工单类型"` + Id int64 `json:"id" dc:"ID"` + OrderId string `json:"orderId" dc:"工单ID"` + UserId int64 `json:"userId" dc:"提交人ID"` + TargetType string `json:"targetType" dc:"目标类型"` + TargetSummary string `json:"targetSummary" dc:"目标摘要"` + Category string `json:"category" dc:"分类"` + Content string `json:"content" dc:"内容"` + Status string `json:"status" dc:"状态"` + CreatedAt *gtime.Time `json:"createdAt" dc:"创建时间"` + UpdatedAt *gtime.Time `json:"updatedAt" dc:"修改时间"` +} + +// ImWorkOrderViewInp 获取指定IM举报反馈工单信息 +type ImWorkOrderViewInp struct { + TenantId int64 `json:"tenantId" v:"required#租户ID不能为空" dc:"租户ID"` + Type string `json:"type" v:"required#工单类型不能为空" dc:"工单类型"` + Id int64 `json:"id" v:"required#ID不能为空" dc:"ID"` +} + +func (in *ImWorkOrderViewInp) Filter(ctx context.Context) (err error) { + if in.TenantId <= 0 { + return gerror.New("请选择租户") + } + if in.Type != ImWorkOrderTypeAbuse && in.Type != ImWorkOrderTypeFeedback { + return gerror.New("工单类型不正确") + } + if in.Id <= 0 { + return gerror.New("ID不能为空") + } + return +} + +type ImWorkOrderViewModel struct { + ImWorkOrderListModel + Description string `json:"description" dc:"描述"` + Contact string `json:"contact" dc:"联系方式"` + EvidenceJson string `json:"evidenceJson" dc:"证据/附件"` + SnapshotJson string `json:"snapshotJson" dc:"快照"` + ClientContextJson string `json:"clientContextJson" dc:"客户端上下文"` + IncludeDiagnostics int `json:"includeDiagnostics" dc:"是否包含诊断"` + IdempotencyKey string `json:"idempotencyKey" dc:"幂等键"` +} + +// ImWorkOrderStatusInp 更新IM举报反馈工单状态 +type ImWorkOrderStatusInp struct { + TenantId int64 `json:"tenantId" v:"required#租户ID不能为空" dc:"租户ID"` + Type string `json:"type" v:"required#工单类型不能为空" dc:"工单类型"` + Id int64 `json:"id" v:"required#ID不能为空" dc:"ID"` + Status string `json:"status" dc:"状态"` +} + +func (in *ImWorkOrderStatusInp) Filter(ctx context.Context) (err error) { + if in.TenantId <= 0 { + return gerror.New("请选择租户") + } + if in.Type != ImWorkOrderTypeAbuse && in.Type != ImWorkOrderTypeFeedback { + return gerror.New("工单类型不正确") + } + if in.Id <= 0 { + return gerror.New("ID不能为空") + } + if !dict.HasOptionKey(consts.ImWorkOrderStatusOptions, in.Status) { + return gerror.New("工单状态不正确") + } + return +} + +type ImWorkOrderStatusModel struct{} diff --git a/server/internal/router/genrouter/im_audit_log.go b/server/internal/router/genrouter/im_audit_log.go new file mode 100644 index 0000000..a0d04f3 --- /dev/null +++ b/server/internal/router/genrouter/im_audit_log.go @@ -0,0 +1,12 @@ +// Package genrouter +// @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 genrouter + +import "hotgo/internal/controller/admin/sys" + +func init() { + LoginRequiredRouter = append(LoginRequiredRouter, sys.ImAuditLog) // IM审计日志 +} diff --git a/server/internal/router/genrouter/im_group.go b/server/internal/router/genrouter/im_group.go new file mode 100644 index 0000000..23ec785 --- /dev/null +++ b/server/internal/router/genrouter/im_group.go @@ -0,0 +1,7 @@ +package genrouter + +import "hotgo/internal/controller/admin/sys" + +func init() { + LoginRequiredRouter = append(LoginRequiredRouter, sys.ImGroup) // IM群组 +} diff --git a/server/internal/router/genrouter/im_invite.go b/server/internal/router/genrouter/im_invite.go new file mode 100644 index 0000000..165faa7 --- /dev/null +++ b/server/internal/router/genrouter/im_invite.go @@ -0,0 +1,8 @@ +package genrouter + +import "hotgo/internal/controller/admin/sys" + +func init() { + LoginRequiredRouter = append(LoginRequiredRouter, sys.ImInvite) // IM注册邀请码 +} + diff --git a/server/internal/router/genrouter/im_realname.go b/server/internal/router/genrouter/im_realname.go new file mode 100644 index 0000000..7d2299d --- /dev/null +++ b/server/internal/router/genrouter/im_realname.go @@ -0,0 +1,7 @@ +package genrouter + +import "hotgo/internal/controller/admin/sys" + +func init() { + LoginRequiredRouter = append(LoginRequiredRouter, sys.ImRealname) // IM实名审核 +} diff --git a/server/internal/router/genrouter/im_registration.go b/server/internal/router/genrouter/im_registration.go new file mode 100644 index 0000000..a6a0c26 --- /dev/null +++ b/server/internal/router/genrouter/im_registration.go @@ -0,0 +1,8 @@ +package genrouter + +import "hotgo/internal/controller/admin/sys" + +func init() { + LoginRequiredRouter = append(LoginRequiredRouter, sys.ImRegistration) // IM注册配置 +} + diff --git a/server/internal/router/genrouter/im_tenant.go b/server/internal/router/genrouter/im_tenant.go new file mode 100644 index 0000000..d6a76d7 --- /dev/null +++ b/server/internal/router/genrouter/im_tenant.go @@ -0,0 +1,7 @@ +package genrouter + +import "hotgo/internal/controller/admin/sys" + +func init() { + LoginRequiredRouter = append(LoginRequiredRouter, sys.ImTenant) // IM租户 +} diff --git a/server/internal/router/genrouter/im_user.go b/server/internal/router/genrouter/im_user.go new file mode 100644 index 0000000..14174da --- /dev/null +++ b/server/internal/router/genrouter/im_user.go @@ -0,0 +1,7 @@ +package genrouter + +import "hotgo/internal/controller/admin/sys" + +func init() { + LoginRequiredRouter = append(LoginRequiredRouter, sys.ImUser) // IM用户 +} diff --git a/server/internal/router/genrouter/im_work_order.go b/server/internal/router/genrouter/im_work_order.go new file mode 100644 index 0000000..099905f --- /dev/null +++ b/server/internal/router/genrouter/im_work_order.go @@ -0,0 +1,7 @@ +package genrouter + +import "hotgo/internal/controller/admin/sys" + +func init() { + LoginRequiredRouter = append(LoginRequiredRouter, sys.ImWorkOrder) // IM举报反馈 +} diff --git a/server/internal/service/sys.go b/server/internal/service/sys.go index 1b94874..2c7b9d4 100644 --- a/server/internal/service/sys.go +++ b/server/internal/service/sys.go @@ -171,6 +171,88 @@ type ( // Switch 更新CURD列表开关 Switch(ctx context.Context, in *sysin.CurdDemoSwitchInp) (err error) } + ISysImTenant interface { + // Model IM租户ORM模型 + Model(ctx context.Context, option ...*handler.Option) *gdb.Model + // List 获取IM租户列表 + List(ctx context.Context, in *sysin.ImTenantListInp) (list []*sysin.ImTenantListModel, totalCount int, err error) + // Edit 修改/新增IM租户 + Edit(ctx context.Context, in *sysin.ImTenantEditInp) (err error) + // View 获取IM租户指定信息 + View(ctx context.Context, in *sysin.ImTenantViewInp) (res *sysin.ImTenantViewModel, err error) + // Status 更新IM租户状态 + Status(ctx context.Context, in *sysin.ImTenantStatusInp) (err error) + } + ISysImRegistration interface { + // GetConfig 获取IM注册配置 + GetConfig(ctx context.Context) (res *sysin.ImRegistrationConfigModel, err error) + // UpdateConfig 更新IM注册配置 + UpdateConfig(ctx context.Context, in *sysin.ImRegistrationConfigUpdateInp) (err error) + } + ISysImInvite interface { + // List 查询IM注册邀请码 + List(ctx context.Context, in *sysin.ImInviteListInp) (list []*sysin.ImInviteCodeModel, totalCount int, err error) + // Create 单个生成IM注册邀请码 + Create(ctx context.Context, in *sysin.ImInviteCreateInp) (res *sysin.ImInviteCodeModel, err error) + // BatchCreate 批量生成IM注册邀请码 + BatchCreate(ctx context.Context, in *sysin.ImInviteBatchCreateInp) (res *sysin.ImInviteBatchModel, err error) + // BatchView 查看批次详情 + BatchView(ctx context.Context, in *sysin.ImInviteBatchViewInp) (res *sysin.ImInviteBatchDetailModel, err error) + // Revoke 撤销邀请码 + Revoke(ctx context.Context, in *sysin.ImInviteRevokeInp) (err error) + // BatchRevoke 批量撤销邀请码 + BatchRevoke(ctx context.Context, in *sysin.ImInviteBatchRevokeInp) (res *sysin.ImInviteBatchModel, err error) + // Export 导出邀请码 + Export(ctx context.Context, in *sysin.ImInviteListInp) (err error) + } + ISysImUser interface { + // List 获取IM用户列表 + List(ctx context.Context, in *sysin.ImUserListInp) (list []*sysin.ImUserListModel, totalCount int, err error) + // View 获取IM用户指定信息 + View(ctx context.Context, in *sysin.ImUserViewInp) (res *sysin.ImUserViewModel, err error) + } + ISysImGroup interface { + // List 获取IM群组列表 + List(ctx context.Context, in *sysin.ImGroupListInp) (list []*sysin.ImGroupListModel, totalCount int, err error) + // View 获取IM群组指定信息 + View(ctx context.Context, in *sysin.ImGroupViewInp) (res *sysin.ImGroupViewModel, err error) + // MemberList 获取IM群成员列表 + MemberList(ctx context.Context, in *sysin.ImGroupMemberListInp) (list []*sysin.ImGroupMemberListModel, totalCount int, err error) + // Governance 获取IM群治理配置 + Governance(ctx context.Context, in *sysin.ImGroupTopicInp) (res *sysin.ImGroupGovernanceModel, err error) + // JoinRequestList 获取IM入群申请列表 + JoinRequestList(ctx context.Context, in *sysin.ImGroupJoinRequestListInp) (list []*sysin.ImGroupJoinRequestListModel, totalCount int, err error) + // InvitationList 获取IM群邀请列表 + InvitationList(ctx context.Context, in *sysin.ImGroupInvitationListInp) (list []*sysin.ImGroupInvitationListModel, totalCount int, err error) + // AnnouncementList 获取IM群公告列表 + AnnouncementList(ctx context.Context, in *sysin.ImGroupAnnouncementListInp) (list []*sysin.ImGroupAnnouncementListModel, totalCount int, err error) + // MuteList 获取IM群禁言列表 + MuteList(ctx context.Context, in *sysin.ImGroupMuteListInp) (list []*sysin.ImGroupMuteListModel, totalCount int, err error) + // AdminLogList 获取IM群管理日志列表 + AdminLogList(ctx context.Context, in *sysin.ImGroupAdminLogListInp) (list []*sysin.ImGroupAdminLogListModel, totalCount int, err error) + } + ISysImRealname interface { + // List 获取IM实名审核列表 + List(ctx context.Context, in *sysin.ImRealnameListInp) (list []*sysin.ImRealnameListModel, totalCount int, err error) + // View 获取IM实名审核指定信息 + View(ctx context.Context, in *sysin.ImRealnameViewInp) (res *sysin.ImRealnameViewModel, err error) + // Review 审核IM实名 + Review(ctx context.Context, in *sysin.ImRealnameReviewInp) (err error) + } + ISysImWorkOrder interface { + // List 获取IM举报反馈工单列表 + List(ctx context.Context, in *sysin.ImWorkOrderListInp) (list []*sysin.ImWorkOrderListModel, totalCount int, err error) + // View 获取IM举报反馈工单指定信息 + View(ctx context.Context, in *sysin.ImWorkOrderViewInp) (res *sysin.ImWorkOrderViewModel, err error) + // Status 更新IM举报反馈工单状态 + Status(ctx context.Context, in *sysin.ImWorkOrderStatusInp) (err error) + } + ISysImAuditLog interface { + // List 获取IM审计日志列表 + List(ctx context.Context, in *sysin.ImAuditLogListInp) (list []*sysin.ImAuditLogListModel, totalCount int, err error) + // View 获取IM审计日志指定信息 + View(ctx context.Context, in *sysin.ImAuditLogViewInp) (res *sysin.ImAuditLogViewModel, err error) + } ISysDictData interface { // Delete 删除 Delete(ctx context.Context, in *sysin.DictDataDeleteInp) error @@ -414,6 +496,14 @@ var ( localSysCron ISysCron localSysCronGroup ISysCronGroup localSysCurdDemo ISysCurdDemo + localSysImTenant ISysImTenant + localSysImRegistration ISysImRegistration + localSysImInvite ISysImInvite + localSysImUser ISysImUser + localSysImGroup ISysImGroup + localSysImRealname ISysImRealname + localSysImWorkOrder ISysImWorkOrder + localSysImAuditLog ISysImAuditLog localSysDictData ISysDictData localSysDictType ISysDictType localSysEmsLog ISysEmsLog @@ -517,6 +607,94 @@ func RegisterSysCurdDemo(i ISysCurdDemo) { localSysCurdDemo = i } +func SysImTenant() ISysImTenant { + if localSysImTenant == nil { + panic("implement not found for interface ISysImTenant, forgot register?") + } + return localSysImTenant +} + +func RegisterSysImTenant(i ISysImTenant) { + localSysImTenant = i +} + +func SysImRegistration() ISysImRegistration { + if localSysImRegistration == nil { + panic("implement not found for interface ISysImRegistration, forgot register?") + } + return localSysImRegistration +} + +func RegisterSysImRegistration(i ISysImRegistration) { + localSysImRegistration = i +} + +func SysImInvite() ISysImInvite { + if localSysImInvite == nil { + panic("implement not found for interface ISysImInvite, forgot register?") + } + return localSysImInvite +} + +func RegisterSysImInvite(i ISysImInvite) { + localSysImInvite = i +} + +func SysImUser() ISysImUser { + if localSysImUser == nil { + panic("implement not found for interface ISysImUser, forgot register?") + } + return localSysImUser +} + +func RegisterSysImUser(i ISysImUser) { + localSysImUser = i +} + +func SysImGroup() ISysImGroup { + if localSysImGroup == nil { + panic("implement not found for interface ISysImGroup, forgot register?") + } + return localSysImGroup +} + +func RegisterSysImGroup(i ISysImGroup) { + localSysImGroup = i +} + +func SysImRealname() ISysImRealname { + if localSysImRealname == nil { + panic("implement not found for interface ISysImRealname, forgot register?") + } + return localSysImRealname +} + +func RegisterSysImRealname(i ISysImRealname) { + localSysImRealname = i +} + +func SysImWorkOrder() ISysImWorkOrder { + if localSysImWorkOrder == nil { + panic("implement not found for interface ISysImWorkOrder, forgot register?") + } + return localSysImWorkOrder +} + +func RegisterSysImWorkOrder(i ISysImWorkOrder) { + localSysImWorkOrder = i +} + +func SysImAuditLog() ISysImAuditLog { + if localSysImAuditLog == nil { + panic("implement not found for interface ISysImAuditLog, forgot register?") + } + return localSysImAuditLog +} + +func RegisterSysImAuditLog(i ISysImAuditLog) { + localSysImAuditLog = i +} + func SysDictData() ISysDictData { if localSysDictData == nil { panic("implement not found for interface ISysDictData, forgot register?") diff --git a/server/main.go b/server/main.go index 22299b7..405dfd6 100644 --- a/server/main.go +++ b/server/main.go @@ -7,6 +7,7 @@ package main import ( _ "hotgo/internal/packed" + _ "time/tzdata" _ "github.com/gogf/gf/contrib/drivers/mysql/v2" _ "github.com/gogf/gf/contrib/drivers/pgsql/v2" diff --git a/server/manifest/config/config.example.yaml b/server/manifest/config/config.example.yaml index b965641..9a493fd 100644 --- a/server/manifest/config/config.example.yaml +++ b/server/manifest/config/config.example.yaml @@ -237,6 +237,9 @@ database: link: "mysql:hotgo:hg123456.@tcp(127.0.0.1:3306)/hotgo?loc=Local&parseTime=true&charset=utf8mb4" debug: true Prefix: "hg_" + imRegistry: + link: "mysql:hotgo:hg123456.@tcp(127.0.0.1:3306)/hotgo?loc=Local&parseTime=true&charset=utf8mb4" + debug: true # 链路追踪 @@ -300,4 +303,10 @@ hggen: addon: srcPath: "./resource/generate/default/addon" # 生成模板路径 webApiPath: "../web/src/api/addons/{$name}" # webApi生成路径 - webViewsPath: "../web/src/views/addons/{$name}" # web页面生成路径 \ No newline at end of file + webViewsPath: "../web/src/views/addons/{$name}" # web页面生成路径 + +imAdminApi: + baseUrl: "http://127.0.0.1:6060" + serviceId: "hotgo-admin" + serviceSecret: "${IM_ADMIN_API_SECRET}" + timeout: "5s" diff --git a/server/manifest/docker/Dockerfile.local b/server/manifest/docker/Dockerfile.local new file mode 100644 index 0000000..d81f6bf --- /dev/null +++ b/server/manifest/docker/Dockerfile.local @@ -0,0 +1,15 @@ +FROM alpine:3.20 + +WORKDIR /app + +RUN apk add --no-cache ca-certificates tzdata + +COPY temp/linux_arm64/hotgo /app/hotgo +COPY hack /app/hack +COPY manifest/config /app/manifest/config +COPY resource /app/resource +COPY storage /app/storage + +EXPOSE 8000 + +CMD ["/app/hotgo"] diff --git a/server/storage/data/generate/im_audit_log_menu.sql b/server/storage/data/generate/im_audit_log_menu.sql new file mode 100644 index 0000000..fcc6e8b --- /dev/null +++ b/server/storage/data/generate/im_audit_log_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 @auditDirId := `id` FROM `hg_admin_menu` WHERE `name` = 'imAuditLog' 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, '审计日志', 'imAuditLog', 'audit-log', '', '1', '/im/audit-log/index', '', '', 'ParentLayout', '1', '', '0', '0', '', '0', '0', '0', '2', CONCAT('tr_', @imRootId, ' '), '60', '', '1', @now, @now +WHERE @auditDirId IS NULL; + +SELECT @auditDirId := `id` FROM `hg_admin_menu` WHERE `name` = 'imAuditLog' LIMIT 1; + +SELECT @auditListId := `id` FROM `hg_admin_menu` WHERE `name` = 'imAuditLogIndex' 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, @auditDirId, '审计列表', 'imAuditLogIndex', 'index', '', '2', '', '/imAuditLog/list', '', '/imAuditLog/index', '1', 'imAuditLog', '0', '0', '', '0', '0', '0', '3', CONCAT('tr_', @imRootId, ' tr_', @auditDirId, ' '), '10', '', '1', @now, @now +WHERE @auditListId IS NULL; + +SELECT @auditListId := `id` FROM `hg_admin_menu` WHERE `name` = 'imAuditLogIndex' 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, @auditListId, '审计详情', 'imAuditLogView', '', '', '3', '', '/imAuditLog/view', '', '', '1', '', '0', '0', '', '0', '1', '0', '4', CONCAT('tr_', @imRootId, ' tr_', @auditDirId, ' tr_', @auditListId, ' '), '10', '', '1', @now, @now +WHERE NOT EXISTS (SELECT 1 FROM `hg_admin_menu` WHERE `name` = 'imAuditLogView'); diff --git a/server/storage/data/generate/im_audit_logs.sql b/server/storage/data/generate/im_audit_logs.sql new file mode 100644 index 0000000..399e06a --- /dev/null +++ b/server/storage/data/generate/im_audit_logs.sql @@ -0,0 +1,22 @@ +CREATE TABLE IF NOT EXISTS im_audit_logs ( + id BIGINT NOT NULL AUTO_INCREMENT, + actor_uid BIGINT NOT NULL DEFAULT 0, + actor_role VARCHAR(64) NOT NULL DEFAULT '', + action VARCHAR(128) NOT NULL, + target_type VARCHAR(64) NOT NULL DEFAULT '', + target_id VARCHAR(128) NOT NULL DEFAULT '', + before_snapshot JSON, + after_snapshot JSON, + reason VARCHAR(512) NOT NULL DEFAULT '', + request_id VARCHAR(64) NOT NULL DEFAULT '', + ip VARCHAR(64) NOT NULL DEFAULT '', + user_agent VARCHAR(512) NOT NULL DEFAULT '', + metadata JSON, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + INDEX idx_im_audit_logs_actor (actor_uid, id DESC), + INDEX idx_im_audit_logs_action (action, id DESC), + INDEX idx_im_audit_logs_target (target_type, target_id, id DESC), + INDEX idx_im_audit_logs_request (request_id), + INDEX idx_im_audit_logs_created (created_at, id DESC) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='IM租户审计日志'; diff --git a/server/storage/data/generate/im_group_menu.sql b/server/storage/data/generate/im_group_menu.sql new file mode 100644 index 0000000..74af8da --- /dev/null +++ b/server/storage/data/generate/im_group_menu.sql @@ -0,0 +1,59 @@ +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 @groupDirId := `id` FROM `hg_admin_menu` WHERE `name` = 'imGroup' 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, '群组管理', 'imGroup', 'group', '', '1', '/im/group/index', '', '', 'ParentLayout', '1', '', '0', '0', '', '0', '0', '0', '2', CONCAT('tr_', @imRootId, ' '), '30', '', '1', @now, @now +WHERE @groupDirId IS NULL; + +SELECT @groupDirId := `id` FROM `hg_admin_menu` WHERE `name` = 'imGroup' LIMIT 1; + +SELECT @groupListId := `id` FROM `hg_admin_menu` WHERE `name` = 'imGroupIndex' 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, @groupDirId, '群组列表', 'imGroupIndex', 'index', '', '2', '', '/imGroup/list', '', '/imGroup/index', '1', 'imGroup', '0', '0', '', '0', '0', '0', '3', CONCAT('tr_', @imRootId, ' tr_', @groupDirId, ' '), '10', '', '1', @now, @now +WHERE @groupListId IS NULL; + +SELECT @groupListId := `id` FROM `hg_admin_menu` WHERE `name` = 'imGroupIndex' 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, @groupListId, '群组详情', 'imGroupView', '', '', '3', '', '/imGroup/view', '', '', '1', '', '0', '0', '', '0', '1', '0', '4', CONCAT('tr_', @imRootId, ' tr_', @groupDirId, ' tr_', @groupListId, ' '), '10', '', '1', @now, @now +WHERE NOT EXISTS (SELECT 1 FROM `hg_admin_menu` WHERE `name` = 'imGroupView'); + +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, '群成员列表', 'imGroupMemberList', '', '', '3', '', '/imGroup/memberList', '', '', '1', '', '0', '0', '', '0', '1', '0', '4', CONCAT('tr_', @imRootId, ' tr_', @groupDirId, ' tr_', @groupListId, ' '), '20', '', '1', @now, @now +WHERE NOT EXISTS (SELECT 1 FROM `hg_admin_menu` WHERE `name` = 'imGroupMemberList'); + +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, '群治理配置', '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, '入群申请列表', '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, '群邀请列表', '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'); + +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, '群公告列表', 'imGroupAnnouncementList', '', '', '3', '', '/imGroup/announcementList', '', '', '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` = 'imGroupAnnouncementList'); + +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, '群禁言列表', 'imGroupMuteList', '', '', '3', '', '/imGroup/muteList', '', '', '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` = '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'); diff --git a/server/storage/data/generate/im_realname_menu.sql b/server/storage/data/generate/im_realname_menu.sql new file mode 100644 index 0000000..64a7933 --- /dev/null +++ b/server/storage/data/generate/im_realname_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 @realnameDirId := `id` FROM `hg_admin_menu` WHERE `name` = 'imRealname' 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, '实名审核', 'imRealname', 'realname', '', '1', '/im/realname/index', '', '', 'ParentLayout', '1', '', '0', '0', '', '0', '0', '0', '2', CONCAT('tr_', @imRootId, ' '), '40', '', '1', @now, @now +WHERE @realnameDirId IS NULL; + +SELECT @realnameDirId := `id` FROM `hg_admin_menu` WHERE `name` = 'imRealname' LIMIT 1; + +SELECT @realnameListId := `id` FROM `hg_admin_menu` WHERE `name` = 'imRealnameIndex' 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, @realnameDirId, '审核列表', 'imRealnameIndex', 'index', '', '2', '', '/imRealname/list', '', '/imRealname/index', '1', 'imRealname', '0', '0', '', '0', '0', '0', '3', CONCAT('tr_', @imRootId, ' tr_', @realnameDirId, ' '), '10', '', '1', @now, @now +WHERE @realnameListId IS NULL; + +SELECT @realnameListId := `id` FROM `hg_admin_menu` WHERE `name` = 'imRealnameIndex' 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, @realnameListId, '实名详情', 'imRealnameView', '', '', '3', '', '/imRealname/view', '', '', '1', '', '0', '0', '', '0', '1', '0', '4', CONCAT('tr_', @imRootId, ' tr_', @realnameDirId, ' tr_', @realnameListId, ' '), '10', '', '1', @now, @now +WHERE NOT EXISTS (SELECT 1 FROM `hg_admin_menu` WHERE `name` = 'imRealnameView'); + +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, @realnameListId, '实名审核操作', 'imRealnameReview', '', '', '3', '', '/imRealname/review', '', '', '1', '', '0', '0', '', '0', '1', '0', '4', CONCAT('tr_', @imRootId, ' tr_', @realnameDirId, ' tr_', @realnameListId, ' '), '20', '', '1', @now, @now +WHERE NOT EXISTS (SELECT 1 FROM `hg_admin_menu` WHERE `name` = 'imRealnameReview'); diff --git a/server/storage/data/generate/im_registration_invite_menu.sql b/server/storage/data/generate/im_registration_invite_menu.sql new file mode 100644 index 0000000..eb74f6a --- /dev/null +++ b/server/storage/data/generate/im_registration_invite_menu.sql @@ -0,0 +1,43 @@ +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 @invitePageId := `id` FROM `hg_admin_menu` WHERE `name` = 'imRegistrationInviteIndex' 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, '注册邀请码', 'imRegistrationInviteIndex', 'registrationInvite', '', '2', '', '/imInvite/list', '', '/im/registrationInvite/index', '1', '', '0', '0', '', '0', '0', '0', '2', CONCAT('tr_', @imRootId, ' '), '70', '', '1', @now, @now +WHERE @invitePageId IS NULL; + +SELECT @invitePageId := `id` FROM `hg_admin_menu` WHERE `name` = 'imRegistrationInviteIndex' 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, @invitePageId, '生成邀请码', 'imInviteCreate', '', '', '3', '', '/imInvite/create', '', '', '1', '', '0', '0', '', '0', '1', '0', '3', CONCAT('tr_', @imRootId, ' tr_', @invitePageId, ' '), '10', '', '1', @now, @now +WHERE NOT EXISTS (SELECT 1 FROM `hg_admin_menu` WHERE `name` = 'imInviteCreate'); + +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, @invitePageId, '批量生成邀请码', 'imInviteBatchCreate', '', '', '3', '', '/imInvite/batchCreate', '', '', '1', '', '0', '0', '', '0', '1', '0', '3', CONCAT('tr_', @imRootId, ' tr_', @invitePageId, ' '), '20', '', '1', @now, @now +WHERE NOT EXISTS (SELECT 1 FROM `hg_admin_menu` WHERE `name` = 'imInviteBatchCreate'); + +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, @invitePageId, '批次详情', 'imInviteBatchView', '', '', '3', '', '/imInvite/batchView', '', '', '1', '', '0', '0', '', '0', '1', '0', '3', CONCAT('tr_', @imRootId, ' tr_', @invitePageId, ' '), '30', '', '1', @now, @now +WHERE NOT EXISTS (SELECT 1 FROM `hg_admin_menu` WHERE `name` = 'imInviteBatchView'); + +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, @invitePageId, '撤销邀请码', 'imInviteRevoke', '', '', '3', '', '/imInvite/revoke', '', '', '1', '', '0', '0', '', '0', '1', '0', '3', CONCAT('tr_', @imRootId, ' tr_', @invitePageId, ' '), '40', '', '1', @now, @now +WHERE NOT EXISTS (SELECT 1 FROM `hg_admin_menu` WHERE `name` = 'imInviteRevoke'); + +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, @invitePageId, '批量撤销邀请码', 'imInviteBatchRevoke', '', '', '3', '', '/imInvite/batchRevoke', '', '', '1', '', '0', '0', '', '0', '1', '0', '3', CONCAT('tr_', @imRootId, ' tr_', @invitePageId, ' '), '50', '', '1', @now, @now +WHERE NOT EXISTS (SELECT 1 FROM `hg_admin_menu` WHERE `name` = 'imInviteBatchRevoke'); + +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, @invitePageId, '导出邀请码', 'imInviteExport', '', '', '3', '', '/imInvite/export', '', '', '1', '', '0', '0', '', '0', '1', '0', '3', CONCAT('tr_', @imRootId, ' tr_', @invitePageId, ' '), '60', '', '1', @now, @now +WHERE NOT EXISTS (SELECT 1 FROM `hg_admin_menu` WHERE `name` = 'imInviteExport'); diff --git a/server/storage/data/generate/im_tenant_menu.sql b/server/storage/data/generate/im_tenant_menu.sql new file mode 100644 index 0000000..d748a02 --- /dev/null +++ b/server/storage/data/generate/im_tenant_menu.sql @@ -0,0 +1,39 @@ +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 @tenantDirId := `id` FROM `hg_admin_menu` WHERE `name` = 'imTenant' 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, '租户管理', 'imTenant', 'tenant', '', '1', '/im/tenant/index', '', '', 'ParentLayout', '1', '', '0', '0', '', '0', '0', '0', '2', CONCAT('tr_', @imRootId, ' '), '10', '', '1', @now, @now +WHERE @tenantDirId IS NULL; + +SELECT @tenantDirId := `id` FROM `hg_admin_menu` WHERE `name` = 'imTenant' LIMIT 1; + +SELECT @tenantListId := `id` FROM `hg_admin_menu` WHERE `name` = 'imTenantIndex' 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, @tenantDirId, '租户列表', 'imTenantIndex', 'index', '', '2', '', '/imTenant/list', '', '/imTenant/index', '1', 'imTenant', '0', '0', '', '0', '0', '0', '3', CONCAT('tr_', @imRootId, ' tr_', @tenantDirId, ' '), '10', '', '1', @now, @now +WHERE @tenantListId IS NULL; + +SELECT @tenantListId := `id` FROM `hg_admin_menu` WHERE `name` = 'imTenantIndex' 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, @tenantListId, '租户详情', 'imTenantView', '', '', '3', '', '/imTenant/view', '', '', '1', '', '0', '0', '', '0', '1', '0', '4', CONCAT('tr_', @imRootId, ' tr_', @tenantDirId, ' tr_', @tenantListId, ' '), '10', '', '1', @now, @now +WHERE NOT EXISTS (SELECT 1 FROM `hg_admin_menu` WHERE `name` = 'imTenantView'); + +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, @tenantListId, '编辑/新增租户', 'imTenantEdit', '', '', '3', '', '/imTenant/edit', '', '', '1', '', '0', '0', '', '0', '1', '0', '4', CONCAT('tr_', @imRootId, ' tr_', @tenantDirId, ' tr_', @tenantListId, ' '), '20', '', '1', @now, @now +WHERE NOT EXISTS (SELECT 1 FROM `hg_admin_menu` WHERE `name` = 'imTenantEdit'); + +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, @tenantListId, '修改租户状态', 'imTenantStatus', '', '', '3', '', '/imTenant/status', '', '', '1', '', '0', '0', '', '0', '1', '0', '4', CONCAT('tr_', @imRootId, ' tr_', @tenantDirId, ' tr_', @tenantListId, ' '), '30', '', '1', @now, @now +WHERE NOT EXISTS (SELECT 1 FROM `hg_admin_menu` WHERE `name` = 'imTenantStatus'); diff --git a/server/storage/data/generate/im_user_menu.sql b/server/storage/data/generate/im_user_menu.sql new file mode 100644 index 0000000..d544db8 --- /dev/null +++ b/server/storage/data/generate/im_user_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 @userDirId := `id` FROM `hg_admin_menu` WHERE `name` = 'imUser' 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, '用户管理', 'imUser', 'user', '', '1', '/im/user/index', '', '', 'ParentLayout', '1', '', '0', '0', '', '0', '0', '0', '2', CONCAT('tr_', @imRootId, ' '), '20', '', '1', @now, @now +WHERE @userDirId IS NULL; + +SELECT @userDirId := `id` FROM `hg_admin_menu` WHERE `name` = 'imUser' LIMIT 1; + +SELECT @userListId := `id` FROM `hg_admin_menu` WHERE `name` = 'imUserIndex' 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, @userDirId, '用户列表', 'imUserIndex', 'index', '', '2', '', '/imUser/list', '', '/imUser/index', '1', 'imUser', '0', '0', '', '0', '0', '0', '3', CONCAT('tr_', @imRootId, ' tr_', @userDirId, ' '), '10', '', '1', @now, @now +WHERE @userListId IS NULL; + +SELECT @userListId := `id` FROM `hg_admin_menu` WHERE `name` = 'imUserIndex' 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, @userListId, '用户详情', 'imUserView', '', '', '3', '', '/imUser/view', '', '', '1', '', '0', '0', '', '0', '1', '0', '4', CONCAT('tr_', @imRootId, ' tr_', @userDirId, ' tr_', @userListId, ' '), '10', '', '1', @now, @now +WHERE NOT EXISTS (SELECT 1 FROM `hg_admin_menu` WHERE `name` = 'imUserView'); diff --git a/server/storage/data/generate/im_work_order_menu.sql b/server/storage/data/generate/im_work_order_menu.sql new file mode 100644 index 0000000..0e408d1 --- /dev/null +++ b/server/storage/data/generate/im_work_order_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 @workOrderDirId := `id` FROM `hg_admin_menu` WHERE `name` = 'imWorkOrder' 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, '举报反馈', 'imWorkOrder', 'work-order', '', '1', '/im/work-order/index', '', '', 'ParentLayout', '1', '', '0', '0', '', '0', '0', '0', '2', CONCAT('tr_', @imRootId, ' '), '50', '', '1', @now, @now +WHERE @workOrderDirId IS NULL; + +SELECT @workOrderDirId := `id` FROM `hg_admin_menu` WHERE `name` = 'imWorkOrder' LIMIT 1; + +SELECT @workOrderListId := `id` FROM `hg_admin_menu` WHERE `name` = 'imWorkOrderIndex' 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, @workOrderDirId, '工单列表', 'imWorkOrderIndex', 'index', '', '2', '', '/imWorkOrder/list', '', '/imWorkOrder/index', '1', 'imWorkOrder', '0', '0', '', '0', '0', '0', '3', CONCAT('tr_', @imRootId, ' tr_', @workOrderDirId, ' '), '10', '', '1', @now, @now +WHERE @workOrderListId IS NULL; + +SELECT @workOrderListId := `id` FROM `hg_admin_menu` WHERE `name` = 'imWorkOrderIndex' 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, @workOrderListId, '工单详情', 'imWorkOrderView', '', '', '3', '', '/imWorkOrder/view', '', '', '1', '', '0', '0', '', '0', '1', '0', '4', CONCAT('tr_', @imRootId, ' tr_', @workOrderDirId, ' tr_', @workOrderListId, ' '), '10', '', '1', @now, @now +WHERE NOT EXISTS (SELECT 1 FROM `hg_admin_menu` WHERE `name` = 'imWorkOrderView'); + +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, @workOrderListId, '更新工单状态', 'imWorkOrderStatus', '', '', '3', '', '/imWorkOrder/status', '', '', '1', '', '0', '0', '', '0', '1', '0', '4', CONCAT('tr_', @imRootId, ' tr_', @workOrderDirId, ' tr_', @workOrderListId, ' '), '20', '', '1', @now, @now +WHERE NOT EXISTS (SELECT 1 FROM `hg_admin_menu` WHERE `name` = 'imWorkOrderStatus'); diff --git a/server/storage/data/hotgo-pg.sql b/server/storage/data/hotgo-pg.sql index bf34441..955a959 100644 --- a/server/storage/data/hotgo-pg.sql +++ b/server/storage/data/hotgo-pg.sql @@ -223,6 +223,7 @@ COMMENT ON COLUMN hg_admin_dept.updated_at IS '更新时间'; CREATE TABLE IF NOT EXISTS hg_admin_member ( id BIGSERIAL PRIMARY KEY, + tenant_id BIGINT, dept_id BIGINT DEFAULT 0, role_id BIGINT DEFAULT 10, real_name VARCHAR(32) DEFAULT '', diff --git a/server/storage/data/hotgo.sql b/server/storage/data/hotgo.sql index 57bd4d4..7f48473 100644 --- a/server/storage/data/hotgo.sql +++ b/server/storage/data/hotgo.sql @@ -213,6 +213,7 @@ INSERT INTO `hg_admin_dept` (`id`, `pid`, `name`, `code`, `type`, `leader`, `pho CREATE TABLE IF NOT EXISTS `hg_admin_member` ( `id` bigint(20) NOT NULL COMMENT '管理员ID', + `tenant_id` bigint(20) DEFAULT NULL COMMENT 'IM租户ID', `dept_id` bigint(20) DEFAULT '0' COMMENT '部门ID', `role_id` bigint(20) DEFAULT '10' COMMENT '角色ID', `real_name` varchar(32) DEFAULT '' COMMENT '真实姓名', @@ -5789,6 +5790,7 @@ ALTER TABLE `hg_admin_dept` ALTER TABLE `hg_admin_member` ADD PRIMARY KEY (`id`), ADD UNIQUE KEY `invite_code` (`invite_code`), + ADD KEY `idx_hg_admin_member_tenant` (`tenant_id`), ADD KEY `dept_id` (`dept_id`), ADD KEY `pid` (`pid`); diff --git a/server/storage/data/im_menu_repair.sql b/server/storage/data/im_menu_repair.sql new file mode 100644 index 0000000..5db5413 --- /dev/null +++ b/server/storage/data/im_menu_repair.sql @@ -0,0 +1,61 @@ +-- Repair IM admin menu titles if they were imported with a wrong client charset. +-- Safe to run repeatedly on HotGo's default hg_ prefixed backend database. + +SET NAMES utf8mb4; + +UPDATE hg_admin_menu +SET title = CASE name + WHEN 'imManager' THEN 'IM管理' + WHEN 'imTenant' THEN '租户管理' + WHEN 'imTenantIndex' THEN '租户列表' + WHEN 'imTenantView' THEN '租户详情' + WHEN 'imTenantEdit' THEN '编辑/新增租户' + WHEN 'imTenantStatus' THEN '修改租户状态' + WHEN 'imUser' THEN '用户管理' + WHEN 'imUserIndex' THEN '用户列表' + WHEN 'imUserView' THEN '用户详情' + WHEN 'imUserResetPassword' THEN '重置用户密码' + WHEN 'imGroup' THEN '群组管理' + WHEN 'imGroupIndex' THEN '群组列表' + WHEN 'imGroupView' THEN '群组详情' + WHEN 'imGroupMemberList' THEN '群成员列表' + WHEN 'imGroupGovernance' THEN '群治理配置' + WHEN 'imGroupJoinRequestList' THEN '入群申请列表' + WHEN 'imGroupInvitationList' THEN '群邀请列表' + WHEN 'imGroupAnnouncementList' THEN '群公告列表' + WHEN 'imGroupMuteList' THEN '群禁言列表' + WHEN 'imGroupAdminLogList' THEN '群管理日志' + WHEN 'imRealname' THEN '实名审核' + WHEN 'imRealnameIndex' THEN '审核列表' + WHEN 'imRealnameView' THEN '实名详情' + WHEN 'imRealnameReview' THEN '实名审核操作' + WHEN 'imWorkOrder' THEN '举报反馈' + WHEN 'imWorkOrderIndex' THEN '工单列表' + WHEN 'imWorkOrderView' THEN '工单详情' + WHEN 'imWorkOrderStatus' THEN '更新工单状态' + WHEN 'imAuditLog' THEN '审计日志' + WHEN 'imAuditLogIndex' THEN '审计列表' + WHEN 'imAuditLogView' THEN '审计详情' + WHEN 'imRegistrationInviteIndex' THEN '注册邀请码' + WHEN 'imInviteCreate' THEN '生成邀请码' + WHEN 'imInviteBatchCreate' THEN '批量生成邀请码' + WHEN 'imInviteBatchView' THEN '批次详情' + WHEN 'imInviteRevoke' THEN '撤销邀请码' + WHEN 'imInviteBatchRevoke' THEN '批量撤销邀请码' + WHEN 'imInviteExport' THEN '导出邀请码' + ELSE title +END +WHERE name IN ( + 'imManager', + 'imTenant', 'imTenantIndex', 'imTenantView', 'imTenantEdit', 'imTenantStatus', + 'imUser', 'imUserIndex', 'imUserView', 'imUserResetPassword', + 'imGroup', 'imGroupIndex', 'imGroupView', 'imGroupMemberList', 'imGroupGovernance', + 'imGroupJoinRequestList', 'imGroupInvitationList', 'imGroupAnnouncementList', + 'imGroupMuteList', 'imGroupAdminLogList', + 'imRealname', 'imRealnameIndex', 'imRealnameView', 'imRealnameReview', + 'imWorkOrder', 'imWorkOrderIndex', 'imWorkOrderView', 'imWorkOrderStatus', + 'imAuditLog', 'imAuditLogIndex', 'imAuditLogView', + 'imRegistrationInviteIndex', + 'imInviteCreate', 'imInviteBatchCreate', 'imInviteBatchView', 'imInviteRevoke', + 'imInviteBatchRevoke', 'imInviteExport' +); diff --git a/server/storage/data/im_registration_invite_hotgo.sql b/server/storage/data/im_registration_invite_hotgo.sql new file mode 100644 index 0000000..14b1dd9 --- /dev/null +++ b/server/storage/data/im_registration_invite_hotgo.sql @@ -0,0 +1,118 @@ +-- IM registration SMS switch and registration invite admin migration. +-- This file is idempotent for MySQL deployments using HotGo's default hg_ prefix. + +SET NAMES utf8mb4; + +-- im_tenant is the only tenant registry. Admin identities reference it directly. +SET @tenant_column_exists := ( + SELECT COUNT(*) FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'hg_admin_member' AND COLUMN_NAME = 'tenant_id' +); +SET @ddl := IF( + @tenant_column_exists = 0, + 'ALTER TABLE hg_admin_member ADD COLUMN tenant_id BIGINT NULL COMMENT ''IM tenant ID'' AFTER id', + 'SELECT 1' +); +PREPARE stmt FROM @ddl; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +-- Preserve an existing deployment's old mapping before removing the redundant table. +SET @binding_table_exists := ( + SELECT COUNT(*) FROM information_schema.TABLES + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'im_admin_tenant_binding' +); +SET @dml := IF( + @binding_table_exists > 0, + 'UPDATE hg_admin_member AS m JOIN im_admin_tenant_binding AS b ON b.hotgo_tenant_id = m.id SET m.tenant_id = b.im_tenant_id WHERE b.status = 1 AND m.tenant_id IS NULL', + 'SELECT 1' +); +PREPARE stmt FROM @dml; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +DROP TABLE IF EXISTS im_admin_tenant_binding; + +SET @tenant_index_exists := ( + SELECT COUNT(*) FROM information_schema.STATISTICS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'hg_admin_member' AND INDEX_NAME = 'idx_hg_admin_member_tenant' +); +SET @ddl := IF( + @tenant_index_exists = 0, + 'ALTER TABLE hg_admin_member ADD INDEX idx_hg_admin_member_tenant (tenant_id)', + 'SELECT 1' +); +PREPARE stmt FROM @ddl; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +SET @tenant_fk_exists := ( + SELECT COUNT(*) FROM information_schema.REFERENTIAL_CONSTRAINTS + WHERE CONSTRAINT_SCHEMA = DATABASE() AND TABLE_NAME = 'hg_admin_member' AND CONSTRAINT_NAME = 'fk_hg_admin_member_im_tenant' +); +SET @ddl := IF( + @tenant_fk_exists = 0, + 'ALTER TABLE hg_admin_member ADD CONSTRAINT fk_hg_admin_member_im_tenant FOREIGN KEY (tenant_id) REFERENCES im_tenant(id)', + 'SELECT 1' +); +PREPARE stmt FROM @ddl; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +CREATE TABLE IF NOT EXISTS im_admin_operation_audit ( + id BIGINT NOT NULL AUTO_INCREMENT, + request_id VARCHAR(64) NOT NULL, + tenant_id BIGINT NULL, + operator_id BIGINT NOT NULL, + action VARCHAR(64) NOT NULL, + target_type VARCHAR(32) NOT NULL, + target_id BIGINT NULL, + result VARCHAR(16) NOT NULL, + ip VARCHAR(64) NOT NULL DEFAULT '', + detail_json JSON NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + KEY idx_im_admin_audit_tenant_time (tenant_id, created_at), + KEY idx_im_admin_audit_operator_time (operator_id, created_at), + UNIQUE KEY uk_im_admin_audit_request_action (request_id, action) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +INSERT INTO hg_sys_config + (`group`, `name`, `type`, `key`, `value`, `default_value`, `sort`, `tip`, `is_default`, `status`, `created_at`, `updated_at`) +SELECT + 'im', '注册短信发送', 'bool', 'register_sms_enabled', '1', '1', 10, + '控制 IM 注册验证码是否真实发送', 1, 1, NOW(), NOW() +WHERE NOT EXISTS ( + SELECT 1 FROM hg_sys_config WHERE `group` = 'im' AND `key` = 'register_sms_enabled' +); + +INSERT INTO hg_admin_menu + (pid, level, tree, title, name, path, icon, type, redirect, permissions, component, always_show, is_root, is_frame, keep_alive, hidden, affix, sort, status, created_at, updated_at) +SELECT + 0, 1, '', 'IM管理', 'imManager', '/im', 'AppstoreOutlined', 1, '', '', 'LAYOUT', 0, 0, 1, 0, 0, 0, 125, 1, NOW(), NOW() +WHERE NOT EXISTS (SELECT 1 FROM hg_admin_menu WHERE name = 'imManager'); + +SET @imRoot := (SELECT id FROM hg_admin_menu WHERE name = 'imManager' LIMIT 1); +UPDATE hg_admin_menu SET tree = CONCAT('tr_', id, ' '), component = 'LAYOUT' WHERE id = @imRoot; + +INSERT INTO hg_admin_menu + (pid, level, tree, title, name, path, icon, type, redirect, permissions, component, always_show, is_root, is_frame, keep_alive, hidden, affix, sort, status, created_at, updated_at) +SELECT + @imRoot, 2, '', '注册邀请码', 'imRegistrationInviteIndex', 'registrationInvite', '', 2, '', '/imInvite/list', '/im/registrationInvite/index', 0, 0, 1, 0, 0, 0, 70, 1, NOW(), NOW() +WHERE NOT EXISTS (SELECT 1 FROM hg_admin_menu WHERE name = 'imRegistrationInviteIndex'); + +SET @invitePage := (SELECT id FROM hg_admin_menu WHERE name = 'imRegistrationInviteIndex' LIMIT 1); +UPDATE hg_admin_menu SET tree = CONCAT('tr_', id, ' tr_', @imRoot, ' ') WHERE id = @invitePage; + +INSERT INTO hg_admin_menu + (pid, level, tree, title, name, path, icon, type, permissions, component, sort, status, created_at, updated_at) +SELECT @invitePage, 3, CONCAT('tr_', @invitePage, ' tr_', @imRoot, ' '), title, name, '', '', 3, permissions, '', sort, 1, NOW(), NOW() +FROM ( + SELECT '生成邀请码' title, 'imInviteCreate' name, '/imInvite/create' permissions, 10 sort UNION ALL + SELECT '批量生成邀请码', 'imInviteBatchCreate', '/imInvite/batchCreate', 20 UNION ALL + SELECT '批次详情', 'imInviteBatchView', '/imInvite/batchView', 30 UNION ALL + SELECT '撤销邀请码', 'imInviteRevoke', '/imInvite/revoke', 40 UNION ALL + SELECT '批量撤销邀请码', 'imInviteBatchRevoke', '/imInvite/batchRevoke', 50 UNION ALL + SELECT '导出邀请码', 'imInviteExport', '/imInvite/export', 60 +) p +WHERE NOT EXISTS (SELECT 1 FROM hg_admin_menu m WHERE m.name = p.name); diff --git a/server/storage/data/sqlite/tables.sql b/server/storage/data/sqlite/tables.sql index e8bfff1..b43abef 100644 --- a/server/storage/data/sqlite/tables.sql +++ b/server/storage/data/sqlite/tables.sql @@ -100,6 +100,7 @@ CREATE TABLE `hg_admin_dept` ( -- 管理员_部门 ); CREATE TABLE `hg_admin_member` ( -- 管理员_用户表 `id` INTEGER NOT NULL PRIMARY KEY, -- 管理员ID + `tenant_id` INTEGER DEFAULT NULL, -- IM租户ID `dept_id` INTEGER DEFAULT 0, -- 部门ID `role_id` INTEGER DEFAULT 10, -- 角色ID `real_name` TEXT DEFAULT '', -- 真实姓名 @@ -653,4 +654,4 @@ CREATE INDEX `hg_sys_provinces_pid` ON `hg_sys_provinces` (`pid`); CREATE UNIQUE INDEX `hg_sys_serve_license_appid` ON `hg_sys_serve_license` (`appid`); CREATE INDEX `hg_sys_serve_log_member_id` ON `hg_sys_serve_log` (`level_format`); CREATE INDEX `hg_sys_serve_log_traceid` ON `hg_sys_serve_log` (`trace_id`); -CREATE INDEX `hg_sys_sms_log_mobile` ON `hg_sys_sms_log` (`mobile`); \ No newline at end of file +CREATE INDEX `hg_sys_sms_log_mobile` ON `hg_sys_sms_log` (`mobile`); diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index ec069a7..c7820e0 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -40,7 +40,7 @@ importers: version: 11.0.3(vue@3.4.38(typescript@5.5.4)) axios: specifier: ^1.7.7 - version: 1.7.7 + version: 1.7.7(debug@4.3.6(supports-color@8.1.1)) blueimp-md5: specifier: ^2.19.0 version: 2.19.0 @@ -75,8 +75,8 @@ importers: specifier: ^3.0.1 version: 3.0.1 naive-ui: - specifier: ^2.41.0 - version: 2.42.0(vue@3.4.38(typescript@5.5.4)) + specifier: ^2.44.1 + version: 2.44.1(vue@3.4.38(typescript@5.5.4)) pinia: specifier: ^2.2.2 version: 2.2.2(typescript@5.5.4)(vue@3.4.38(typescript@5.5.4)) @@ -110,6 +110,9 @@ importers: vue: specifier: ^3.4.38 version: 3.4.38(typescript@5.5.4) + vue-i18n: + specifier: ^11.1.11 + version: 11.4.8(vue@3.4.38(typescript@5.5.4)) vue-router: specifier: ^4.4.3 version: 4.4.3(vue@3.4.38(typescript@5.5.4)) @@ -120,8 +123,8 @@ importers: specifier: ^2.6.0 version: 2.6.0 vue3-json-viewer: - specifier: ^2.2.2 - version: 2.2.2(vue@3.4.38(typescript@5.5.4)) + specifier: ^2.4.1 + version: 2.4.1(vue@3.4.38(typescript@5.5.4)) vuedraggable: specifier: ^4.1.0 version: 4.1.0(vue@3.4.38(typescript@5.5.4)) @@ -137,7 +140,7 @@ importers: version: 19.4.1 '@eslint/eslintrc': specifier: ^3.1.0 - version: 3.1.0 + version: 3.1.0(supports-color@8.1.1) '@types/fs-extra': specifier: ^11.0.4 version: 11.0.4 @@ -152,22 +155,22 @@ importers: version: 22.5.2 '@typescript-eslint/eslint-plugin': specifier: ^8.4.0 - version: 8.4.0(@typescript-eslint/parser@8.4.0(eslint@9.9.1(jiti@1.21.6))(typescript@5.5.4))(eslint@9.9.1(jiti@1.21.6))(typescript@5.5.4) + version: 8.4.0(@typescript-eslint/parser@8.4.0(eslint@9.9.1(jiti@1.21.6)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@5.5.4))(eslint@9.9.1(jiti@1.21.6)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@5.5.4) '@typescript-eslint/parser': specifier: ^8.4.0 - version: 8.4.0(eslint@9.9.1(jiti@1.21.6))(typescript@5.5.4) + version: 8.4.0(eslint@9.9.1(jiti@1.21.6)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@5.5.4) '@vitejs/plugin-vue': specifier: ^5.1.3 version: 5.1.3(vite@5.4.2(@types/node@22.5.2)(less@4.2.0)(terser@5.31.6))(vue@3.4.38(typescript@5.5.4)) '@vitejs/plugin-vue-jsx': specifier: ^4.0.1 - version: 4.0.1(vite@5.4.2(@types/node@22.5.2)(less@4.2.0)(terser@5.31.6))(vue@3.4.38(typescript@5.5.4)) + version: 4.0.1(supports-color@8.1.1)(vite@5.4.2(@types/node@22.5.2)(less@4.2.0)(terser@5.31.6))(vue@3.4.38(typescript@5.5.4)) '@vue/compiler-sfc': specifier: ^3.4.38 version: 3.4.38 '@vue/eslint-config-typescript': specifier: ^13.0.0 - version: 13.0.0(eslint-plugin-vue@9.28.0(eslint@9.9.1(jiti@1.21.6)))(eslint@9.9.1(jiti@1.21.6))(typescript@5.5.4) + version: 13.0.0(eslint-plugin-vue@9.28.0(eslint@9.9.1(jiti@1.21.6)(supports-color@8.1.1))(supports-color@8.1.1))(eslint@9.9.1(jiti@1.21.6)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@5.5.4) autoprefixer: specifier: ^10.4.20 version: 10.4.20(postcss@8.4.44) @@ -191,22 +194,22 @@ importers: version: 16.4.5 eslint: specifier: ^9.9.1 - version: 9.9.1(jiti@1.21.6) + version: 9.9.1(jiti@1.21.6)(supports-color@8.1.1) eslint-config-prettier: specifier: ^9.1.0 - version: 9.1.0(eslint@9.9.1(jiti@1.21.6)) + version: 9.1.0(eslint@9.9.1(jiti@1.21.6)(supports-color@8.1.1)) eslint-define-config: specifier: 2.1.0 version: 2.1.0 eslint-plugin-jest: specifier: ^28.8.2 - version: 28.8.2(@typescript-eslint/eslint-plugin@8.4.0(@typescript-eslint/parser@8.4.0(eslint@9.9.1(jiti@1.21.6))(typescript@5.5.4))(eslint@9.9.1(jiti@1.21.6))(typescript@5.5.4))(eslint@9.9.1(jiti@1.21.6))(jest@29.7.0(@types/node@22.5.2)(ts-node@10.9.2(@swc/core@1.7.23)(@types/node@22.5.2)(typescript@5.5.4)))(typescript@5.5.4) + version: 28.8.2(@typescript-eslint/eslint-plugin@8.4.0(@typescript-eslint/parser@8.4.0(eslint@9.9.1(jiti@1.21.6)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@5.5.4))(eslint@9.9.1(jiti@1.21.6)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@5.5.4))(eslint@9.9.1(jiti@1.21.6)(supports-color@8.1.1))(jest@29.7.0(@types/node@22.5.2)(supports-color@8.1.1)(ts-node@10.9.2(@swc/core@1.7.23)(@types/node@22.5.2)(typescript@5.5.4)))(supports-color@8.1.1)(typescript@5.5.4) eslint-plugin-prettier: specifier: ^5.2.1 - version: 5.2.1(eslint-config-prettier@9.1.0(eslint@9.9.1(jiti@1.21.6)))(eslint@9.9.1(jiti@1.21.6))(prettier@3.3.3) + version: 5.2.1(eslint-config-prettier@9.1.0(eslint@9.9.1(jiti@1.21.6)(supports-color@8.1.1)))(eslint@9.9.1(jiti@1.21.6)(supports-color@8.1.1))(prettier@3.3.3) eslint-plugin-vue: specifier: ^9.28.0 - version: 9.28.0(eslint@9.9.1(jiti@1.21.6)) + version: 9.28.0(eslint@9.9.1(jiti@1.21.6)(supports-color@8.1.1))(supports-color@8.1.1) esno: specifier: ^4.7.0 version: 4.7.0 @@ -221,7 +224,7 @@ importers: version: 9.1.5 jest: specifier: ^29.7.0 - version: 29.7.0(@types/node@22.5.2)(ts-node@10.9.2(@swc/core@1.7.23)(@types/node@22.5.2)(typescript@5.5.4)) + version: 29.7.0(@types/node@22.5.2)(supports-color@8.1.1)(ts-node@10.9.2(@swc/core@1.7.23)(@types/node@22.5.2)(typescript@5.5.4)) less: specifier: ^4.2.0 version: 4.2.0 @@ -230,7 +233,7 @@ importers: version: 12.2.0(less@4.2.0)(webpack@5.94.0(@swc/core@1.7.23)(esbuild@0.23.1)) lint-staged: specifier: ^15.2.10 - version: 15.2.10 + version: 15.2.10(supports-color@8.1.1) mockjs: specifier: ^1.1.0 version: 1.1.0 @@ -251,43 +254,46 @@ importers: version: 6.0.1 stylelint: specifier: ^16.9.0 - version: 16.9.0(typescript@5.5.4) + version: 16.9.0(supports-color@8.1.1)(typescript@5.5.4) stylelint-config-prettier: specifier: ^9.0.5 - version: 9.0.5(stylelint@16.9.0(typescript@5.5.4)) + version: 9.0.5(stylelint@16.9.0(supports-color@8.1.1)(typescript@5.5.4)) stylelint-config-standard: specifier: ^36.0.1 - version: 36.0.1(stylelint@16.9.0(typescript@5.5.4)) + version: 36.0.1(stylelint@16.9.0(supports-color@8.1.1)(typescript@5.5.4)) stylelint-order: specifier: ^6.0.4 - version: 6.0.4(stylelint@16.9.0(typescript@5.5.4)) + version: 6.0.4(stylelint@16.9.0(supports-color@8.1.1)(typescript@5.5.4)) stylelint-scss: specifier: ^6.5.1 - version: 6.5.1(stylelint@16.9.0(typescript@5.5.4)) + version: 6.5.1(stylelint@16.9.0(supports-color@8.1.1)(typescript@5.5.4)) tailwindcss: specifier: ^3.4.10 version: 3.4.10(ts-node@10.9.2(@swc/core@1.7.23)(@types/node@22.5.2)(typescript@5.5.4)) typescript: specifier: ^5.5.4 version: 5.5.4 + unplugin-auto-import: + specifier: ^20.2.0 + version: 20.3.0(@vueuse/core@11.0.3(vue@3.4.38(typescript@5.5.4))) unplugin-vue-components: specifier: ^0.27.4 - version: 0.27.4(@babel/parser@7.25.6)(rollup@4.21.2)(vue@3.4.38(typescript@5.5.4)) + version: 0.27.4(@babel/parser@7.25.6)(rollup@4.21.2)(supports-color@8.1.1)(vue@3.4.38(typescript@5.5.4)) vite: specifier: ^5.4.2 version: 5.4.2(@types/node@22.5.2)(less@4.2.0)(terser@5.31.6) vite-plugin-compression: specifier: ^0.5.1 - version: 0.5.1(vite@5.4.2(@types/node@22.5.2)(less@4.2.0)(terser@5.31.6)) + version: 0.5.1(supports-color@8.1.1)(vite@5.4.2(@types/node@22.5.2)(less@4.2.0)(terser@5.31.6)) vite-plugin-html: specifier: ^3.2.2 version: 3.2.2(vite@5.4.2(@types/node@22.5.2)(less@4.2.0)(terser@5.31.6)) vite-plugin-mock: specifier: ^3.0.2 - version: 3.0.2(esbuild@0.23.1)(mockjs@1.1.0)(vite@5.4.2(@types/node@22.5.2)(less@4.2.0)(terser@5.31.6)) + version: 3.0.2(esbuild@0.23.1)(mockjs@1.1.0)(supports-color@8.1.1)(vite@5.4.2(@types/node@22.5.2)(less@4.2.0)(terser@5.31.6)) vite-plugin-require-transform: specifier: ^1.0.21 - version: 1.0.21 + version: 1.0.21(supports-color@8.1.1) vite-plugin-style-import: specifier: ^2.0.0 version: 2.0.0(vite@5.4.2(@types/node@22.5.2)(less@4.2.0)(terser@5.31.6)) @@ -305,7 +311,7 @@ importers: version: 2.2.1(sortablejs@1.14.0)(vue@3.4.38(typescript@5.5.4)) vue-eslint-parser: specifier: ^9.4.3 - version: 9.4.3(eslint@9.9.1(jiti@1.21.6)) + version: 9.4.3(eslint@9.9.1(jiti@1.21.6)(supports-color@8.1.1))(supports-color@8.1.1) packages: @@ -1007,6 +1013,22 @@ packages: resolution: {integrity: sha512-d2CGZR2o7fS6sWB7DG/3a95bGKQyHMACZ5aW8qGkkqQpUoZV6C0X7Pc7l4ZNMZkfNBf4VWNe9E1jRsf0G146Ew==} engines: {node: '>=18.18'} + '@intlify/core-base@11.4.8': + resolution: {integrity: sha512-A+Q7SKm5oEcy1E/cghqd7n/St4XjTqLhiiyDuieNcMrJcrHlkY5n0jp7Q9dD3txvVHzvsmBVV5M9wD5/s1zfzw==} + engines: {node: '>= 22'} + + '@intlify/devtools-types@11.4.8': + resolution: {integrity: sha512-MGpID+rlfzGUbNcnC20bm5NMSBHPrvx0atLTfv9dftn3kjXw1hGKDcIcwrO99tSrZEc2i+hczRL7ks8qXsHPkQ==} + engines: {node: '>= 22'} + + '@intlify/message-compiler@11.4.8': + resolution: {integrity: sha512-vbzk17dYwduYiv52EK61+FDCyhfVg1uPUtPmiD/d45W99uJIcXywrweOBcHv7n9/iEqmXiMGT52bgJbZDQqK3w==} + engines: {node: '>= 22'} + + '@intlify/shared@11.4.8': + resolution: {integrity: sha512-XbRgrv+XEuvDr7UCY55oibVrh+o4u+A0VB6nSL0F5Z8LcZxE/8j573LYG6bCrOigIcHdGpSNI7Rh5UpC5/B/eg==} + engines: {node: '>= 22'} + '@isaacs/cliui@8.0.2': resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} engines: {node: '>=12'} @@ -1089,6 +1111,9 @@ packages: resolution: {integrity: sha512-IzL8ZoEDIBRWEzlCcRhOaCupYyN5gdIK+Q6fbFdPDg6HqX6jpkItn7DFIpW9LQzXG6Df9sA7+OKnq0qlz/GaQg==} engines: {node: '>=6.0.0'} + '@jridgewell/remapping@2.3.5': + resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} + '@jridgewell/resolve-uri@3.1.2': resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} engines: {node: '>=6.0.0'} @@ -1103,6 +1128,9 @@ packages: '@jridgewell/sourcemap-codec@1.5.0': resolution: {integrity: sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==} + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + '@jridgewell/trace-mapping@0.3.25': resolution: {integrity: sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==} @@ -1202,46 +1230,55 @@ packages: resolution: {integrity: sha512-ztRJJMiE8nnU1YFcdbd9BcH6bGWG1z+jP+IPW2oDUAPxPjo9dverIOyXz76m6IPA6udEL12reYeLojzW2cYL7w==} cpu: [arm] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm-musleabihf@4.21.2': resolution: {integrity: sha512-flOcGHDZajGKYpLV0JNc0VFH361M7rnV1ee+NTeC/BQQ1/0pllYcFmxpagltANYt8FYf9+kL6RSk80Ziwyhr7w==} cpu: [arm] os: [linux] + libc: [musl] '@rollup/rollup-linux-arm64-gnu@4.21.2': resolution: {integrity: sha512-69CF19Kp3TdMopyteO/LJbWufOzqqXzkrv4L2sP8kfMaAQ6iwky7NoXTp7bD6/irKgknDKM0P9E/1l5XxVQAhw==} cpu: [arm64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm64-musl@4.21.2': resolution: {integrity: sha512-48pD/fJkTiHAZTnZwR0VzHrao70/4MlzJrq0ZsILjLW/Ab/1XlVUStYyGt7tdyIiVSlGZbnliqmult/QGA2O2w==} cpu: [arm64] os: [linux] + libc: [musl] '@rollup/rollup-linux-powerpc64le-gnu@4.21.2': resolution: {integrity: sha512-cZdyuInj0ofc7mAQpKcPR2a2iu4YM4FQfuUzCVA2u4HI95lCwzjoPtdWjdpDKyHxI0UO82bLDoOaLfpZ/wviyQ==} cpu: [ppc64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-riscv64-gnu@4.21.2': resolution: {integrity: sha512-RL56JMT6NwQ0lXIQmMIWr1SW28z4E4pOhRRNqwWZeXpRlykRIlEpSWdsgNWJbYBEWD84eocjSGDu/XxbYeCmwg==} cpu: [riscv64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-s390x-gnu@4.21.2': resolution: {integrity: sha512-PMxkrWS9z38bCr3rWvDFVGD6sFeZJw4iQlhrup7ReGmfn7Oukrr/zweLhYX6v2/8J6Cep9IEA/SmjXjCmSbrMQ==} cpu: [s390x] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-gnu@4.21.2': resolution: {integrity: sha512-B90tYAUoLhU22olrafY3JQCFLnT3NglazdwkHyxNDYF/zAxJt5fJUB/yBoWFoIQ7SQj+KLe3iL4BhOMa9fzgpw==} cpu: [x64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-musl@4.21.2': resolution: {integrity: sha512-7twFizNXudESmC9oneLGIUmoHiiLppz/Xs5uJQ4ShvE6234K0VB1/aJYU3f/4g7PhssLGKBVCC37uRkkOi8wjg==} cpu: [x64] os: [linux] + libc: [musl] '@rollup/rollup-win32-arm64-msvc@4.21.2': resolution: {integrity: sha512-9rRero0E7qTeYf6+rFh3AErTNU1VCQg2mn7CQcI44vNUWM9Ze7MSRS/9RFuSsox+vstRt97+x3sOhEey024FRQ==} @@ -1290,24 +1327,28 @@ packages: engines: {node: '>=10'} cpu: [arm64] os: [linux] + libc: [glibc] '@swc/core-linux-arm64-musl@1.7.23': resolution: {integrity: sha512-JEdtwdthazKq4PBz53KSubwwK8MvqODAihGSAzc8u3Unq4ojcvaS8b0CwLBeD+kTQ78HpxOXTt3DsFIxpgaCAA==} engines: {node: '>=10'} cpu: [arm64] os: [linux] + libc: [musl] '@swc/core-linux-x64-gnu@1.7.23': resolution: {integrity: sha512-V51gFPWaVAHbI1yg9ahsoya3aB4uawye3SZ5uQWgcP7wdCdiv60dw4F5nuPJf5Z1oXD3U/BslXuamv8Oh9vXqQ==} engines: {node: '>=10'} cpu: [x64] os: [linux] + libc: [glibc] '@swc/core-linux-x64-musl@1.7.23': resolution: {integrity: sha512-BBqQi4+UdeRqag3yM4IJjaHG4yc1o3l9ksENHToE0o/u2DT0FY5+K/DiYGZLC1JHbSFzNqRCYsa7DIzRtZ0A1A==} engines: {node: '>=10'} cpu: [x64] os: [linux] + libc: [musl] '@swc/core-win32-arm64-msvc@1.7.23': resolution: {integrity: sha512-JPk6pvCKncL6bXG7p+NLZf8PWx4FakVvKNdwGeMrYunb+yk1IZf7qf9LJk8+GDGF5QviDXPs8opZrTrfsW80fA==} @@ -1396,12 +1437,12 @@ packages: '@types/jsonfile@6.1.4': resolution: {integrity: sha512-D5qGUYwjvnNNextdU59/+fI+spnwtTFmyQP0h+PfIOSkNfpU6AOICUOkm4i0OnSk+NyjdPJrxCDro0sJsWlRpQ==} - '@types/katex@0.16.7': - resolution: {integrity: sha512-HMwFiRujE5PjrgwHQ25+bsLJgowjGjm5Z8FVSf0N6PwgJrwxH0QxzHYDcKsTfV3wva0vzrpqMTJS2jXPr5BMEQ==} - '@types/lodash-es@4.17.12': resolution: {integrity: sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==} + '@types/lodash@4.17.25': + resolution: {integrity: sha512-+K1NIO8I+F9/wNulfVvu23QYd0Pe9/OCqRrim4NoYIf1VoEDL90Ve4ClzpyqBLc7NpGGWRvYNCKZ1BE/Jpf8dQ==} + '@types/lodash@4.17.7': resolution: {integrity: sha512-8wTvZawATi/lsmNu10/j2hk1KEP0IvjubqPE3cu1Xz7xfXXt5oCq3SNUz4fMIP4XGF9Ky+Ue2tBA3hcS7LSBlA==} @@ -1705,6 +1746,11 @@ packages: engines: {node: '>=0.4.0'} hasBin: true + acorn@8.18.0: + resolution: {integrity: sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==} + engines: {node: '>=0.4.0'} + hasBin: true + ajv-keywords@3.5.2: resolution: {integrity: sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==} peerDependencies: @@ -2067,6 +2113,12 @@ packages: confbox@0.1.7: resolution: {integrity: sha512-uJcB/FKZtBMCJpK8MQji6bJHgu1tixKPxRLeGkNzBoOZzpnZUJm0jm2/sBDWcuBx1dYgxV4JU+g5hmNxCyAmdA==} + confbox@0.1.8: + resolution: {integrity: sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==} + + confbox@0.2.4: + resolution: {integrity: sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==} + connect-history-api-fallback@1.6.0: resolution: {integrity: sha512-e54B99q/OUoH64zYYRf3HBP5z24G38h5D3qXu23JGRoigpX5Ss4r9ZnDk3g0Z8uQC2x2lPaJ+UlWBc1ZWBWdLg==} engines: {node: '>=0.8'} @@ -2148,6 +2200,7 @@ packages: crypto-js@4.2.0: resolution: {integrity: sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==} + deprecated: Active development of CryptoJS has been discontinued. This library is no longer maintained. css-functions-list@3.2.2: resolution: {integrity: sha512-c+N0v6wbKVxTu5gOBBFkr9BEdBWaqqjQeiJ8QvSRIJOf+UxlJh930m8e6/WNeODIK0mYLFkoONrnj16i2EcvfQ==} @@ -2194,6 +2247,9 @@ packages: date-fns@3.6.0: resolution: {integrity: sha512-fRHTG8g/Gif+kSh50gaGEdToemgfj74aRX3swtiouboip5JDLAyDE9F11nHMIcvOaXeOC6D7SpNhi7uFyB7Uww==} + date-fns@4.4.0: + resolution: {integrity: sha512-+1UMbeh68lH1SegH83CGWwpb6OHHbpSgr3+s5Eww5M4CAgswBpoWS0AjTOfEJ33HiYKz1hdj/KTFprzXHmq/6w==} + debug@2.6.9: resolution: {integrity: sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==} peerDependencies: @@ -2418,6 +2474,10 @@ packages: resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} engines: {node: '>=10'} + escape-string-regexp@5.0.0: + resolution: {integrity: sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==} + engines: {node: '>=12'} + eslint-config-prettier@9.1.0: resolution: {integrity: sha512-NSWl5BFQWEPi1j4TjVNItzYV7dZXZ+wP6I6ZhrBGpChQhZRUaElihE9uRRkcbRnNb76UMKDF3r+WTmNcGPKsqw==} hasBin: true @@ -2427,6 +2487,7 @@ packages: eslint-define-config@2.1.0: resolution: {integrity: sha512-QUp6pM9pjKEVannNAbSJNeRuYwW3LshejfyBBpjeMGaJjaDUpVps4C6KVR8R7dWZnD3i0synmrE36znjTkJvdQ==} engines: {node: '>=18.0.0', npm: '>=9.0.0', pnpm: '>=8.6.0'} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. eslint-plugin-jest@28.8.2: resolution: {integrity: sha512-mC3OyklHmS5i7wYU1rGId9EnxRI8TVlnFG56AE+8U9iRy6zwaNygZR+DsdZuCL0gRG0wVeyzq+uWcPt6yJrrMA==} @@ -2527,6 +2588,9 @@ packages: estree-walker@2.0.2: resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==} + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + esutils@2.0.3: resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} engines: {node: '>=0.10.0'} @@ -2564,6 +2628,9 @@ packages: resolution: {integrity: sha512-2Zks0hf1VLFYI1kbh0I5jP3KHHyCHpkfyHBzsSXRFgl/Bg9mWYfMW8oD+PdMPlEwy5HNsR9JutYy6pMeOh61nw==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + exsolve@1.1.1: + resolution: {integrity: sha512-9U/jZUgjnSGyntRr6y5Muu1MJcwFl6kPu7k8qLF0IMNfLqvw0NZ4nnVDq0RVoZ0RvCyumib4Ez3KYrVfilrw+g==} + extend@3.0.2: resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} @@ -2606,6 +2673,15 @@ packages: fb-watchman@2.0.2: resolution: {integrity: sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA==} + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + figures@3.2.0: resolution: {integrity: sha512-yaduQFRKLXYOGgEn6AZau90j3ggSOyiqXU0F9JZfeXYhNa+Jk4X+s45A2zg5jns87GAFa34BBm2kXw4XpNcbdg==} engines: {node: '>=8'} @@ -2763,6 +2839,7 @@ packages: git-raw-commits@4.0.0: resolution: {integrity: sha512-ICsMM1Wk8xSGMowkOmPrzo2Fgmfo4bMHLNX6ytHjajRJUqvHOw/TFapQ+QG75c3X/tTDDhOSRPGC52dDbNM8FQ==} engines: {node: '>=16'} + deprecated: Deprecated and no longer maintained. Use @conventional-changelog/git-client instead. hasBin: true glob-parent@5.1.2: @@ -2778,16 +2855,18 @@ packages: glob@10.4.5: resolution: {integrity: sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me hasBin: true glob@11.0.0: resolution: {integrity: sha512-9UiX/Bl6J2yaBbxKoEBRm4Cipxgok8kQYcOPEhScPwebu2I0HoQOuYdIO6S3hLuWoZgpDpwQZMzTFxgpkyT76g==} engines: {node: 20 || >=22} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me hasBin: true glob@7.2.3: resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} - deprecated: Glob versions prior to v9 are no longer supported + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me global-directory@4.0.1: resolution: {integrity: sha512-wHTUcDUoZ1H5/0iVqEudYW4/kAlN5cZ3j/bXn0Dpbizl9iaUVeWSHqiOjsgk6OW2bkLclbBjzewBz6weQ1zA2Q==} @@ -3237,6 +3316,9 @@ packages: js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + js-tokens@9.0.1: + resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==} + js-yaml@3.14.1: resolution: {integrity: sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==} hasBin: true @@ -3349,6 +3431,10 @@ packages: resolution: {integrity: sha512-ok6z3qlYyCDS4ZEU27HaU6x/xZa9Whf8jD4ptH5UZTQYZVYeb9bnZ3ojVhiJNLiXK1Hfc0GNbLXcmZ5plLDDBg==} engines: {node: '>=14'} + local-pkg@1.2.1: + resolution: {integrity: sha512-++gUqRDEvcnN6Zhqrr+y/CkVEHhlrR96vZn3nZZPYzMcBUyBtTKzB9NadClFIsIVSsu+3i9tfk/erqy9kAmt7Q==} + engines: {node: '>=14'} + locate-path@5.0.0: resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==} engines: {node: '>=8'} @@ -3372,6 +3458,7 @@ packages: lodash.isequal@4.5.0: resolution: {integrity: sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==} + deprecated: This package is deprecated. Use require('node:util').isDeepStrictEqual instead. lodash.isplainobject@4.0.6: resolution: {integrity: sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==} @@ -3437,6 +3524,9 @@ packages: magic-string@0.30.11: resolution: {integrity: sha512-+Wri9p0QHMy+545hKww7YAu5NyzF8iomPL/RQazugQ9+Ez4Ic3mERMd8ZTX5rfK944j+560ZJi8iAwgak1Ac7A==} + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + make-dir@2.1.0: resolution: {integrity: sha512-LS9X+dc8KLxXCb8dni79fLIIUA5VyZoyjSMCwTluaXA0o27cCK0bhXkpgw+sTXVpPy/lSO57ilRixqk0vDmtRA==} engines: {node: '>=6'} @@ -3542,6 +3632,9 @@ packages: mlly@1.7.1: resolution: {integrity: sha512-rrVRZRELyQzrIUAVMHxP97kv+G786pHmOKzuFII8zDYahFBS7qnHh2AlYSl1GAHhaMPCz6/oHjVMcfFYgFYHgA==} + mlly@1.8.2: + resolution: {integrity: sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==} + mockjs@1.1.0: resolution: {integrity: sha512-eQsKcWzIaZzEZ07NuEyO4Nw65g0hdWAyurVol1IPl1gahRwY+svqzfgfey8U8dahLwG44d6/RwEzuK52rSa/JQ==} hasBin: true @@ -3562,8 +3655,9 @@ packages: mz@2.7.0: resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==} - naive-ui@2.42.0: - resolution: {integrity: sha512-c7cXR2YgOjgtBadXHwiWL4Y0tpGLAI5W5QzzHksOi22iuHXoSGMAzdkVTGVPE/PM0MSGQ/JtUIzCx2Y0hU0vTQ==} + naive-ui@2.44.1: + resolution: {integrity: sha512-reo8Esw0p58liZwbUutC7meW24Xbn3EwNv91zReWKm2W4JPu+zfgJRn/F7aO0BFmvN+h2brA2M5lRvYqLq4kuA==} + engines: {node: '>=20'} peerDependencies: vue: ^3.0.0 @@ -3781,6 +3875,9 @@ packages: pathe@1.1.2: resolution: {integrity: sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==} + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + picocolors@1.1.0: resolution: {integrity: sha512-TQ92mBOW0l3LeMeyLV6mzy/kWr8lkd/hp3mTg7wYK7zJhuBStmGMBG0BdeDZS/dZx1IukaX6Bk11zcln25o1Aw==} @@ -3792,6 +3889,10 @@ packages: resolution: {integrity: sha512-I3EurrIQMlRc9IaAZnqRR044Phh2DXY+55o7uJ0V+hYZAcQYSuFWsc9q5PvyDHUSCe1Qxn/iBz+78s86zWnGag==} engines: {node: '>=10'} + picomatch@4.0.5: + resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==} + engines: {node: '>=12'} + pidtree@0.6.0: resolution: {integrity: sha512-eG2dWTVw5bzqGRztnHExczNxt5VGsE6OwTeCG3fdUf9KBsZzO3R5OIIIzWR+iZA0NtZ+RDVdaoE2dK1cn6jH4g==} engines: {node: '>=0.10'} @@ -3839,6 +3940,12 @@ packages: pkg-types@1.2.0: resolution: {integrity: sha512-+ifYuSSqOQ8CqP4MbZA5hDpb97n3E8SVWdJe+Wms9kj745lmd3b7EZJiqvmLwAlmRfjrI7Hi5z3kdBJ93lFNPA==} + pkg-types@1.3.1: + resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==} + + pkg-types@2.3.1: + resolution: {integrity: sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg==} + postcss-import@15.1.0: resolution: {integrity: sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==} engines: {node: '>=14.0.0'} @@ -3950,6 +4057,9 @@ packages: resolution: {integrity: sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==} engines: {node: '>=0.6'} + quansync@0.2.11: + resolution: {integrity: sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==} + queue-microtask@1.2.3: resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} @@ -4083,6 +4193,12 @@ packages: resolution: {integrity: sha512-pN/yOAvcC+5rQ5nERGuwrjLlYvLTbCibnZ1I7B1LaiAz9BRBlE9GMgE/eqV30P7aJQUf7Ddimy/RsbYO/GrVGg==} engines: {node: '>= 10.13.0'} + scule@1.3.0: + resolution: {integrity: sha512-6FtHJEvt+pVMIB9IBY+IcCJ6Z5f1iQnytgyfKMhDKgmzYG+TeH/wx1y3l27rshSbLiSanrR9ffZDrEsmjlQF2g==} + + seemly@0.3.10: + resolution: {integrity: sha512-2+SMxtG1PcsL0uyhkumlOU6Qo9TAQ/WyH7tthnPIOQB05/12jz9naq6GZ6iZ6ApVsO3rr2gsnTf3++OV63kE1Q==} + seemly@0.3.8: resolution: {integrity: sha512-MW8Qs6vbzo0pHmDpFSYPna+lwpZ6Zk1ancbajw/7E8TKtHdV+1DfZZD+kKJEhG/cAoB/i+LiT+5msZOqj0DwRA==} @@ -4243,6 +4359,9 @@ packages: resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} engines: {node: '>=8'} + strip-literal@3.1.0: + resolution: {integrity: sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==} + strip-outer@1.0.1: resolution: {integrity: sha512-k55yxKHwaXnpYGsOzg4Vl8+tDrWylxDEpknGjhTiZB8dFRU5rTo9CAzeycivxV3s+zlTKwrs6WxMxR95n26kwg==} engines: {node: '>=0.10.0'} @@ -4379,6 +4498,10 @@ packages: tiny-emitter@2.1.0: resolution: {integrity: sha512-NB6Dk1A9xgQPMoGqC5CVXn123gWyte215ONT5Pp5a0yt4nlEoO1ZWeCwpncaekPHXO60i47ihFnZPiRPjRMq4Q==} + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + tmp@0.0.33: resolution: {integrity: sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw==} engines: {node: '>=0.6.0'} @@ -4459,6 +4582,9 @@ packages: ufo@1.5.4: resolution: {integrity: sha512-UsUk3byDzKd04EyoZ7U4DOlxQaD14JUKQl6/P7wiX4FNvUfm3XL246n9W5AmqwW5RSFJ27NAuM0iLscAOYUiGQ==} + ufo@1.6.4: + resolution: {integrity: sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==} + undici-types@6.19.8: resolution: {integrity: sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==} @@ -4466,6 +4592,10 @@ packages: resolution: {integrity: sha512-lRfVq8fE8gz6QMBuDM6a+LO3IAzTi05H6gCVaUpir2E1Rwpo4ZUog45KpNXKC/Mn3Yb9UDuHumeFTo9iV/D9FQ==} engines: {node: '>=18'} + unimport@5.7.0: + resolution: {integrity: sha512-njnL6sp8lEA8QQbZrt+52p/g4X0rw3bnGGmUcJnt1jeG8+iiqO779aGz0PirCtydAIVcuTBRlJ52F0u46z309Q==} + engines: {node: '>=18.12.0'} + universalify@2.0.1: resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==} engines: {node: '>= 10.0.0'} @@ -4474,6 +4604,22 @@ packages: resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} engines: {node: '>= 0.8'} + unplugin-auto-import@20.3.0: + resolution: {integrity: sha512-RcSEQiVv7g0mLMMXibYVKk8mpteKxvyffGuDKqZZiFr7Oq3PB1HwgHdK5O7H4AzbhzHoVKG0NnMnsk/1HIVYzQ==} + engines: {node: '>=14'} + peerDependencies: + '@nuxt/kit': ^4.0.0 + '@vueuse/core': '*' + peerDependenciesMeta: + '@nuxt/kit': + optional: true + '@vueuse/core': + optional: true + + unplugin-utils@0.3.2: + resolution: {integrity: sha512-xVToRh2CTmLk2HnEG7ac4rl1MJTT3RFkpS8B++/SnB0kXvuaavD+n3m/vrzyWQOdJNSZQACnbz01pnppbwV5BA==} + engines: {node: '>=20.19.0'} + unplugin-vue-components@0.27.4: resolution: {integrity: sha512-1XVl5iXG7P1UrOMnaj2ogYa5YTq8aoh5jwDPQhemwO/OrXW+lPQKDXd1hMz15qxQPxgb/XXlbgo3HQ2rLEbmXQ==} engines: {node: '>=14'} @@ -4491,6 +4637,10 @@ packages: resolution: {integrity: sha512-my8DH0/T/Kx33KO+6QXAqdeMYgyy0GktlOpdQjpagfHKw5DrD0ctPr7SHUyOT3g4ZVpzCQGt/qcpuoKJ/pniHA==} engines: {node: '>=14.0.0'} + unplugin@2.3.11: + resolution: {integrity: sha512-5uKD0nqiYVzlmCRs01Fhs2BdkEgBS3SAVP6ndrBsuK42iC2+JHyxM05Rm9G8+5mkmRtzMZGY8Ct5+mliZxU/Ww==} + engines: {node: '>=18.12.0'} + update-browserslist-db@1.1.0: resolution: {integrity: sha512-EdRAaAyk2cUE1wOf2DkEhzxqOQvFOoRJFNS6NeyJ01Gp2beMRpBAINjM2iDXE3KCuKhwnvHIQCJm6ThL2Z+HzQ==} hasBin: true @@ -4515,6 +4665,7 @@ packages: uuid@10.0.0: resolution: {integrity: sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==} + deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028). hasBin: true v8-compile-cache-lib@3.0.1: @@ -4627,6 +4778,12 @@ packages: peerDependencies: eslint: '>=6.0.0' + vue-i18n@11.4.8: + resolution: {integrity: sha512-0ULeHP6Z9CGvAm67S77ZEp41cfGXIREGL8qfhos2BMgcQQewtQcDKuojt6jjasAD/S8GwfTp2ySPmDSpwvrCMQ==} + engines: {node: '>= 22'} + peerDependencies: + vue: ^3.0.0 + vue-router@4.4.3: resolution: {integrity: sha512-sv6wmNKx2j3aqJQDMxLFzs/u/mjA9Z5LCgy6BE0f7yFWMjrPLnS/sPNn8ARY/FXw6byV18EFutn5lTO6+UsV5A==} peerDependencies: @@ -4644,10 +4801,10 @@ packages: vue-waterfall-plugin-next@2.6.0: resolution: {integrity: sha512-GgovHXWkEP0flI/60GPeTC9vQAnYXLfhohAe8BudIljlcpBS+UCFXOS+S7FCsoujhvdcUY1heKxZglf13KGl9g==} - vue3-json-viewer@2.2.2: - resolution: {integrity: sha512-56l3XDGggnpwEqZieXsSMhNT4NhtO6d7zuSAxHo4i0UVxymyY2jRb7UMQOU1ztChKALZCAzX7DlgrsnEhxu77A==} + vue3-json-viewer@2.4.1: + resolution: {integrity: sha512-Z1sunvS58lJ3ZcpNhl3jYQapBVw2wjnXbemigfMWm3QnjCeg3CPMq8R6pxHUYahxMfPKLvrbGve6mUXqhWyLaQ==} peerDependencies: - vue: ^3.2.0 + vue: ^3.5.16 vue@3.4.38: resolution: {integrity: sha512-f0ZgN+mZ5KFgVv9wz0f4OgVKukoXtS3nwET4c2vLBGQR50aI8G0cqbFtLlX9Yiyg3LFGBitruPHt2PxwTduJEw==} @@ -4662,8 +4819,8 @@ packages: peerDependencies: vue: ^3.0.1 - vueuc@0.4.64: - resolution: {integrity: sha512-wlJQj7fIwKK2pOEoOq4Aro8JdPOGpX8aWQhV8YkTW9OgWD2uj2O8ANzvSsIGjx7LTOc7QbS7sXdxHi6XvRnHPA==} + vueuc@0.4.65: + resolution: {integrity: sha512-lXuMl+8gsBmruudfxnMF9HW4be8rFziylXFu1VHVNbLVhRTXXV4njvpRuJapD/8q+oFEMSfQMH16E/85VoWRyQ==} peerDependencies: vue: ^3.0.11 @@ -4793,20 +4950,20 @@ snapshots: '@babel/compat-data@7.25.4': {} - '@babel/core@7.25.2': + '@babel/core@7.25.2(supports-color@8.1.1)': dependencies: '@ampproject/remapping': 2.3.0 '@babel/code-frame': 7.24.7 '@babel/generator': 7.25.6 '@babel/helper-compilation-targets': 7.25.2 - '@babel/helper-module-transforms': 7.25.2(@babel/core@7.25.2) + '@babel/helper-module-transforms': 7.25.2(@babel/core@7.25.2(supports-color@8.1.1))(supports-color@8.1.1) '@babel/helpers': 7.25.6 '@babel/parser': 7.25.6 '@babel/template': 7.25.0 - '@babel/traverse': 7.25.6 + '@babel/traverse': 7.25.6(supports-color@8.1.1) '@babel/types': 7.25.6 convert-source-map: 2.0.0 - debug: 4.3.6 + debug: 4.3.6(supports-color@8.1.1) gensync: 1.0.0-beta.2 json5: 2.2.3 semver: 6.3.1 @@ -4832,22 +4989,22 @@ snapshots: lru-cache: 5.1.1 semver: 6.3.1 - '@babel/helper-create-class-features-plugin@7.25.4(@babel/core@7.25.2)': + '@babel/helper-create-class-features-plugin@7.25.4(@babel/core@7.25.2(supports-color@8.1.1))(supports-color@8.1.1)': dependencies: - '@babel/core': 7.25.2 + '@babel/core': 7.25.2(supports-color@8.1.1) '@babel/helper-annotate-as-pure': 7.24.7 - '@babel/helper-member-expression-to-functions': 7.24.8 + '@babel/helper-member-expression-to-functions': 7.24.8(supports-color@8.1.1) '@babel/helper-optimise-call-expression': 7.24.7 - '@babel/helper-replace-supers': 7.25.0(@babel/core@7.25.2) - '@babel/helper-skip-transparent-expression-wrappers': 7.24.7 - '@babel/traverse': 7.25.6 + '@babel/helper-replace-supers': 7.25.0(@babel/core@7.25.2(supports-color@8.1.1))(supports-color@8.1.1) + '@babel/helper-skip-transparent-expression-wrappers': 7.24.7(supports-color@8.1.1) + '@babel/traverse': 7.25.6(supports-color@8.1.1) semver: 6.3.1 transitivePeerDependencies: - supports-color - '@babel/helper-member-expression-to-functions@7.24.8': + '@babel/helper-member-expression-to-functions@7.24.8(supports-color@8.1.1)': dependencies: - '@babel/traverse': 7.25.6 + '@babel/traverse': 7.25.6(supports-color@8.1.1) '@babel/types': 7.25.6 transitivePeerDependencies: - supports-color @@ -4856,20 +5013,20 @@ snapshots: dependencies: '@babel/types': 7.25.6 - '@babel/helper-module-imports@7.24.7': + '@babel/helper-module-imports@7.24.7(supports-color@8.1.1)': dependencies: - '@babel/traverse': 7.25.6 + '@babel/traverse': 7.25.6(supports-color@8.1.1) '@babel/types': 7.25.6 transitivePeerDependencies: - supports-color - '@babel/helper-module-transforms@7.25.2(@babel/core@7.25.2)': + '@babel/helper-module-transforms@7.25.2(@babel/core@7.25.2(supports-color@8.1.1))(supports-color@8.1.1)': dependencies: - '@babel/core': 7.25.2 - '@babel/helper-module-imports': 7.24.7 - '@babel/helper-simple-access': 7.24.7 + '@babel/core': 7.25.2(supports-color@8.1.1) + '@babel/helper-module-imports': 7.24.7(supports-color@8.1.1) + '@babel/helper-simple-access': 7.24.7(supports-color@8.1.1) '@babel/helper-validator-identifier': 7.24.7 - '@babel/traverse': 7.25.6 + '@babel/traverse': 7.25.6(supports-color@8.1.1) transitivePeerDependencies: - supports-color @@ -4879,25 +5036,25 @@ snapshots: '@babel/helper-plugin-utils@7.24.8': {} - '@babel/helper-replace-supers@7.25.0(@babel/core@7.25.2)': + '@babel/helper-replace-supers@7.25.0(@babel/core@7.25.2(supports-color@8.1.1))(supports-color@8.1.1)': dependencies: - '@babel/core': 7.25.2 - '@babel/helper-member-expression-to-functions': 7.24.8 + '@babel/core': 7.25.2(supports-color@8.1.1) + '@babel/helper-member-expression-to-functions': 7.24.8(supports-color@8.1.1) '@babel/helper-optimise-call-expression': 7.24.7 - '@babel/traverse': 7.25.6 + '@babel/traverse': 7.25.6(supports-color@8.1.1) transitivePeerDependencies: - supports-color - '@babel/helper-simple-access@7.24.7': + '@babel/helper-simple-access@7.24.7(supports-color@8.1.1)': dependencies: - '@babel/traverse': 7.25.6 + '@babel/traverse': 7.25.6(supports-color@8.1.1) '@babel/types': 7.25.6 transitivePeerDependencies: - supports-color - '@babel/helper-skip-transparent-expression-wrappers@7.24.7': + '@babel/helper-skip-transparent-expression-wrappers@7.24.7(supports-color@8.1.1)': dependencies: - '@babel/traverse': 7.25.6 + '@babel/traverse': 7.25.6(supports-color@8.1.1) '@babel/types': 7.25.6 transitivePeerDependencies: - supports-color @@ -4924,99 +5081,99 @@ snapshots: dependencies: '@babel/types': 7.25.6 - '@babel/plugin-syntax-async-generators@7.8.4(@babel/core@7.25.2)': + '@babel/plugin-syntax-async-generators@7.8.4(@babel/core@7.25.2(supports-color@8.1.1))': dependencies: - '@babel/core': 7.25.2 + '@babel/core': 7.25.2(supports-color@8.1.1) '@babel/helper-plugin-utils': 7.24.8 - '@babel/plugin-syntax-bigint@7.8.3(@babel/core@7.25.2)': + '@babel/plugin-syntax-bigint@7.8.3(@babel/core@7.25.2(supports-color@8.1.1))': dependencies: - '@babel/core': 7.25.2 + '@babel/core': 7.25.2(supports-color@8.1.1) '@babel/helper-plugin-utils': 7.24.8 - '@babel/plugin-syntax-class-properties@7.12.13(@babel/core@7.25.2)': + '@babel/plugin-syntax-class-properties@7.12.13(@babel/core@7.25.2(supports-color@8.1.1))': dependencies: - '@babel/core': 7.25.2 + '@babel/core': 7.25.2(supports-color@8.1.1) '@babel/helper-plugin-utils': 7.24.8 - '@babel/plugin-syntax-class-static-block@7.14.5(@babel/core@7.25.2)': + '@babel/plugin-syntax-class-static-block@7.14.5(@babel/core@7.25.2(supports-color@8.1.1))': dependencies: - '@babel/core': 7.25.2 + '@babel/core': 7.25.2(supports-color@8.1.1) '@babel/helper-plugin-utils': 7.24.8 - '@babel/plugin-syntax-import-attributes@7.25.6(@babel/core@7.25.2)': + '@babel/plugin-syntax-import-attributes@7.25.6(@babel/core@7.25.2(supports-color@8.1.1))': dependencies: - '@babel/core': 7.25.2 + '@babel/core': 7.25.2(supports-color@8.1.1) '@babel/helper-plugin-utils': 7.24.8 - '@babel/plugin-syntax-import-meta@7.10.4(@babel/core@7.25.2)': + '@babel/plugin-syntax-import-meta@7.10.4(@babel/core@7.25.2(supports-color@8.1.1))': dependencies: - '@babel/core': 7.25.2 + '@babel/core': 7.25.2(supports-color@8.1.1) '@babel/helper-plugin-utils': 7.24.8 - '@babel/plugin-syntax-json-strings@7.8.3(@babel/core@7.25.2)': + '@babel/plugin-syntax-json-strings@7.8.3(@babel/core@7.25.2(supports-color@8.1.1))': dependencies: - '@babel/core': 7.25.2 + '@babel/core': 7.25.2(supports-color@8.1.1) '@babel/helper-plugin-utils': 7.24.8 - '@babel/plugin-syntax-jsx@7.24.7(@babel/core@7.25.2)': + '@babel/plugin-syntax-jsx@7.24.7(@babel/core@7.25.2(supports-color@8.1.1))': dependencies: - '@babel/core': 7.25.2 + '@babel/core': 7.25.2(supports-color@8.1.1) '@babel/helper-plugin-utils': 7.24.8 - '@babel/plugin-syntax-logical-assignment-operators@7.10.4(@babel/core@7.25.2)': + '@babel/plugin-syntax-logical-assignment-operators@7.10.4(@babel/core@7.25.2(supports-color@8.1.1))': dependencies: - '@babel/core': 7.25.2 + '@babel/core': 7.25.2(supports-color@8.1.1) '@babel/helper-plugin-utils': 7.24.8 - '@babel/plugin-syntax-nullish-coalescing-operator@7.8.3(@babel/core@7.25.2)': + '@babel/plugin-syntax-nullish-coalescing-operator@7.8.3(@babel/core@7.25.2(supports-color@8.1.1))': dependencies: - '@babel/core': 7.25.2 + '@babel/core': 7.25.2(supports-color@8.1.1) '@babel/helper-plugin-utils': 7.24.8 - '@babel/plugin-syntax-numeric-separator@7.10.4(@babel/core@7.25.2)': + '@babel/plugin-syntax-numeric-separator@7.10.4(@babel/core@7.25.2(supports-color@8.1.1))': dependencies: - '@babel/core': 7.25.2 + '@babel/core': 7.25.2(supports-color@8.1.1) '@babel/helper-plugin-utils': 7.24.8 - '@babel/plugin-syntax-object-rest-spread@7.8.3(@babel/core@7.25.2)': + '@babel/plugin-syntax-object-rest-spread@7.8.3(@babel/core@7.25.2(supports-color@8.1.1))': dependencies: - '@babel/core': 7.25.2 + '@babel/core': 7.25.2(supports-color@8.1.1) '@babel/helper-plugin-utils': 7.24.8 - '@babel/plugin-syntax-optional-catch-binding@7.8.3(@babel/core@7.25.2)': + '@babel/plugin-syntax-optional-catch-binding@7.8.3(@babel/core@7.25.2(supports-color@8.1.1))': dependencies: - '@babel/core': 7.25.2 + '@babel/core': 7.25.2(supports-color@8.1.1) '@babel/helper-plugin-utils': 7.24.8 - '@babel/plugin-syntax-optional-chaining@7.8.3(@babel/core@7.25.2)': + '@babel/plugin-syntax-optional-chaining@7.8.3(@babel/core@7.25.2(supports-color@8.1.1))': dependencies: - '@babel/core': 7.25.2 + '@babel/core': 7.25.2(supports-color@8.1.1) '@babel/helper-plugin-utils': 7.24.8 - '@babel/plugin-syntax-private-property-in-object@7.14.5(@babel/core@7.25.2)': + '@babel/plugin-syntax-private-property-in-object@7.14.5(@babel/core@7.25.2(supports-color@8.1.1))': dependencies: - '@babel/core': 7.25.2 + '@babel/core': 7.25.2(supports-color@8.1.1) '@babel/helper-plugin-utils': 7.24.8 - '@babel/plugin-syntax-top-level-await@7.14.5(@babel/core@7.25.2)': + '@babel/plugin-syntax-top-level-await@7.14.5(@babel/core@7.25.2(supports-color@8.1.1))': dependencies: - '@babel/core': 7.25.2 + '@babel/core': 7.25.2(supports-color@8.1.1) '@babel/helper-plugin-utils': 7.24.8 - '@babel/plugin-syntax-typescript@7.25.4(@babel/core@7.25.2)': + '@babel/plugin-syntax-typescript@7.25.4(@babel/core@7.25.2(supports-color@8.1.1))': dependencies: - '@babel/core': 7.25.2 + '@babel/core': 7.25.2(supports-color@8.1.1) '@babel/helper-plugin-utils': 7.24.8 - '@babel/plugin-transform-typescript@7.25.2(@babel/core@7.25.2)': + '@babel/plugin-transform-typescript@7.25.2(@babel/core@7.25.2(supports-color@8.1.1))(supports-color@8.1.1)': dependencies: - '@babel/core': 7.25.2 + '@babel/core': 7.25.2(supports-color@8.1.1) '@babel/helper-annotate-as-pure': 7.24.7 - '@babel/helper-create-class-features-plugin': 7.25.4(@babel/core@7.25.2) + '@babel/helper-create-class-features-plugin': 7.25.4(@babel/core@7.25.2(supports-color@8.1.1))(supports-color@8.1.1) '@babel/helper-plugin-utils': 7.24.8 - '@babel/helper-skip-transparent-expression-wrappers': 7.24.7 - '@babel/plugin-syntax-typescript': 7.25.4(@babel/core@7.25.2) + '@babel/helper-skip-transparent-expression-wrappers': 7.24.7(supports-color@8.1.1) + '@babel/plugin-syntax-typescript': 7.25.4(@babel/core@7.25.2(supports-color@8.1.1)) transitivePeerDependencies: - supports-color @@ -5026,14 +5183,14 @@ snapshots: '@babel/parser': 7.25.6 '@babel/types': 7.25.6 - '@babel/traverse@7.25.6': + '@babel/traverse@7.25.6(supports-color@8.1.1)': dependencies: '@babel/code-frame': 7.24.7 '@babel/generator': 7.25.6 '@babel/parser': 7.25.6 '@babel/template': 7.25.0 '@babel/types': 7.25.6 - debug: 4.3.6 + debug: 4.3.6(supports-color@8.1.1) globals: 11.12.0 transitivePeerDependencies: - supports-color @@ -5446,25 +5603,25 @@ snapshots: '@esbuild/win32-x64@0.23.1': optional: true - '@eslint-community/eslint-utils@4.4.0(eslint@9.9.1(jiti@1.21.6))': + '@eslint-community/eslint-utils@4.4.0(eslint@9.9.1(jiti@1.21.6)(supports-color@8.1.1))': dependencies: - eslint: 9.9.1(jiti@1.21.6) + eslint: 9.9.1(jiti@1.21.6)(supports-color@8.1.1) eslint-visitor-keys: 3.4.3 '@eslint-community/regexpp@4.11.0': {} - '@eslint/config-array@0.18.0': + '@eslint/config-array@0.18.0(supports-color@8.1.1)': dependencies: '@eslint/object-schema': 2.1.4 - debug: 4.3.6 + debug: 4.3.6(supports-color@8.1.1) minimatch: 3.1.2 transitivePeerDependencies: - supports-color - '@eslint/eslintrc@3.1.0': + '@eslint/eslintrc@3.1.0(supports-color@8.1.1)': dependencies: ajv: 6.12.6 - debug: 4.3.6 + debug: 4.3.6(supports-color@8.1.1) espree: 10.1.0 globals: 14.0.0 ignore: 5.3.2 @@ -5483,6 +5640,24 @@ snapshots: '@humanwhocodes/retry@0.3.0': {} + '@intlify/core-base@11.4.8': + dependencies: + '@intlify/devtools-types': 11.4.8 + '@intlify/message-compiler': 11.4.8 + '@intlify/shared': 11.4.8 + + '@intlify/devtools-types@11.4.8': + dependencies: + '@intlify/core-base': 11.4.8 + '@intlify/shared': 11.4.8 + + '@intlify/message-compiler@11.4.8': + dependencies: + '@intlify/shared': 11.4.8 + source-map-js: 1.2.0 + + '@intlify/shared@11.4.8': {} + '@isaacs/cliui@8.0.2': dependencies: string-width: 5.1.2 @@ -5511,12 +5686,12 @@ snapshots: jest-util: 29.7.0 slash: 3.0.0 - '@jest/core@29.7.0(ts-node@10.9.2(@swc/core@1.7.23)(@types/node@22.5.2)(typescript@5.5.4))': + '@jest/core@29.7.0(supports-color@8.1.1)(ts-node@10.9.2(@swc/core@1.7.23)(@types/node@22.5.2)(typescript@5.5.4))': dependencies: '@jest/console': 29.7.0 - '@jest/reporters': 29.7.0 + '@jest/reporters': 29.7.0(supports-color@8.1.1) '@jest/test-result': 29.7.0 - '@jest/transform': 29.7.0 + '@jest/transform': 29.7.0(supports-color@8.1.1) '@jest/types': 29.6.3 '@types/node': 22.5.2 ansi-escapes: 4.3.2 @@ -5525,15 +5700,15 @@ snapshots: exit: 0.1.2 graceful-fs: 4.2.11 jest-changed-files: 29.7.0 - jest-config: 29.7.0(@types/node@22.5.2)(ts-node@10.9.2(@swc/core@1.7.23)(@types/node@22.5.2)(typescript@5.5.4)) + jest-config: 29.7.0(@types/node@22.5.2)(supports-color@8.1.1)(ts-node@10.9.2(@swc/core@1.7.23)(@types/node@22.5.2)(typescript@5.5.4)) jest-haste-map: 29.7.0 jest-message-util: 29.7.0 jest-regex-util: 29.6.3 jest-resolve: 29.7.0 - jest-resolve-dependencies: 29.7.0 - jest-runner: 29.7.0 - jest-runtime: 29.7.0 - jest-snapshot: 29.7.0 + jest-resolve-dependencies: 29.7.0(supports-color@8.1.1) + jest-runner: 29.7.0(supports-color@8.1.1) + jest-runtime: 29.7.0(supports-color@8.1.1) + jest-snapshot: 29.7.0(supports-color@8.1.1) jest-util: 29.7.0 jest-validate: 29.7.0 jest-watcher: 29.7.0 @@ -5557,10 +5732,10 @@ snapshots: dependencies: jest-get-type: 29.6.3 - '@jest/expect@29.7.0': + '@jest/expect@29.7.0(supports-color@8.1.1)': dependencies: expect: 29.7.0 - jest-snapshot: 29.7.0 + jest-snapshot: 29.7.0(supports-color@8.1.1) transitivePeerDependencies: - supports-color @@ -5573,21 +5748,21 @@ snapshots: jest-mock: 29.7.0 jest-util: 29.7.0 - '@jest/globals@29.7.0': + '@jest/globals@29.7.0(supports-color@8.1.1)': dependencies: '@jest/environment': 29.7.0 - '@jest/expect': 29.7.0 + '@jest/expect': 29.7.0(supports-color@8.1.1) '@jest/types': 29.6.3 jest-mock: 29.7.0 transitivePeerDependencies: - supports-color - '@jest/reporters@29.7.0': + '@jest/reporters@29.7.0(supports-color@8.1.1)': dependencies: '@bcoe/v8-coverage': 0.2.3 '@jest/console': 29.7.0 '@jest/test-result': 29.7.0 - '@jest/transform': 29.7.0 + '@jest/transform': 29.7.0(supports-color@8.1.1) '@jest/types': 29.6.3 '@jridgewell/trace-mapping': 0.3.25 '@types/node': 22.5.2 @@ -5597,9 +5772,9 @@ snapshots: glob: 7.2.3 graceful-fs: 4.2.11 istanbul-lib-coverage: 3.2.2 - istanbul-lib-instrument: 6.0.3 + istanbul-lib-instrument: 6.0.3(supports-color@8.1.1) istanbul-lib-report: 3.0.1 - istanbul-lib-source-maps: 4.0.1 + istanbul-lib-source-maps: 4.0.1(supports-color@8.1.1) istanbul-reports: 3.1.7 jest-message-util: 29.7.0 jest-util: 29.7.0 @@ -5635,12 +5810,12 @@ snapshots: jest-haste-map: 29.7.0 slash: 3.0.0 - '@jest/transform@29.7.0': + '@jest/transform@29.7.0(supports-color@8.1.1)': dependencies: - '@babel/core': 7.25.2 + '@babel/core': 7.25.2(supports-color@8.1.1) '@jest/types': 29.6.3 '@jridgewell/trace-mapping': 0.3.25 - babel-plugin-istanbul: 6.1.1 + babel-plugin-istanbul: 6.1.1(supports-color@8.1.1) chalk: 4.1.2 convert-source-map: 2.0.0 fast-json-stable-stringify: 2.1.0 @@ -5670,6 +5845,11 @@ snapshots: '@jridgewell/sourcemap-codec': 1.5.0 '@jridgewell/trace-mapping': 0.3.25 + '@jridgewell/remapping@2.3.5': + dependencies: + '@jridgewell/gen-mapping': 0.3.5 + '@jridgewell/trace-mapping': 0.3.25 + '@jridgewell/resolve-uri@3.1.2': {} '@jridgewell/set-array@1.2.1': {} @@ -5681,6 +5861,8 @@ snapshots: '@jridgewell/sourcemap-codec@1.5.0': {} + '@jridgewell/sourcemap-codec@1.5.5': {} + '@jridgewell/trace-mapping@0.3.25': dependencies: '@jridgewell/resolve-uri': 3.1.2 @@ -5933,12 +6115,12 @@ snapshots: dependencies: '@types/node': 22.5.2 - '@types/katex@0.16.7': {} - '@types/lodash-es@4.17.12': dependencies: '@types/lodash': 4.17.7 + '@types/lodash@4.17.25': {} + '@types/lodash@4.17.7': {} '@types/node@22.5.2': @@ -5960,15 +6142,15 @@ snapshots: dependencies: '@types/yargs-parser': 21.0.3 - '@typescript-eslint/eslint-plugin@7.18.0(@typescript-eslint/parser@7.18.0(eslint@9.9.1(jiti@1.21.6))(typescript@5.5.4))(eslint@9.9.1(jiti@1.21.6))(typescript@5.5.4)': + '@typescript-eslint/eslint-plugin@7.18.0(@typescript-eslint/parser@7.18.0(eslint@9.9.1(jiti@1.21.6)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@5.5.4))(eslint@9.9.1(jiti@1.21.6)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@5.5.4)': dependencies: '@eslint-community/regexpp': 4.11.0 - '@typescript-eslint/parser': 7.18.0(eslint@9.9.1(jiti@1.21.6))(typescript@5.5.4) + '@typescript-eslint/parser': 7.18.0(eslint@9.9.1(jiti@1.21.6)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@5.5.4) '@typescript-eslint/scope-manager': 7.18.0 - '@typescript-eslint/type-utils': 7.18.0(eslint@9.9.1(jiti@1.21.6))(typescript@5.5.4) - '@typescript-eslint/utils': 7.18.0(eslint@9.9.1(jiti@1.21.6))(typescript@5.5.4) + '@typescript-eslint/type-utils': 7.18.0(eslint@9.9.1(jiti@1.21.6)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@5.5.4) + '@typescript-eslint/utils': 7.18.0(eslint@9.9.1(jiti@1.21.6)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@5.5.4) '@typescript-eslint/visitor-keys': 7.18.0 - eslint: 9.9.1(jiti@1.21.6) + eslint: 9.9.1(jiti@1.21.6)(supports-color@8.1.1) graphemer: 1.4.0 ignore: 5.3.2 natural-compare: 1.4.0 @@ -5978,15 +6160,15 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/eslint-plugin@8.4.0(@typescript-eslint/parser@8.4.0(eslint@9.9.1(jiti@1.21.6))(typescript@5.5.4))(eslint@9.9.1(jiti@1.21.6))(typescript@5.5.4)': + '@typescript-eslint/eslint-plugin@8.4.0(@typescript-eslint/parser@8.4.0(eslint@9.9.1(jiti@1.21.6)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@5.5.4))(eslint@9.9.1(jiti@1.21.6)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@5.5.4)': dependencies: '@eslint-community/regexpp': 4.11.0 - '@typescript-eslint/parser': 8.4.0(eslint@9.9.1(jiti@1.21.6))(typescript@5.5.4) + '@typescript-eslint/parser': 8.4.0(eslint@9.9.1(jiti@1.21.6)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@5.5.4) '@typescript-eslint/scope-manager': 8.4.0 - '@typescript-eslint/type-utils': 8.4.0(eslint@9.9.1(jiti@1.21.6))(typescript@5.5.4) - '@typescript-eslint/utils': 8.4.0(eslint@9.9.1(jiti@1.21.6))(typescript@5.5.4) + '@typescript-eslint/type-utils': 8.4.0(eslint@9.9.1(jiti@1.21.6)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@5.5.4) + '@typescript-eslint/utils': 8.4.0(eslint@9.9.1(jiti@1.21.6)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@5.5.4) '@typescript-eslint/visitor-keys': 8.4.0 - eslint: 9.9.1(jiti@1.21.6) + eslint: 9.9.1(jiti@1.21.6)(supports-color@8.1.1) graphemer: 1.4.0 ignore: 5.3.2 natural-compare: 1.4.0 @@ -5996,27 +6178,27 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/parser@7.18.0(eslint@9.9.1(jiti@1.21.6))(typescript@5.5.4)': + '@typescript-eslint/parser@7.18.0(eslint@9.9.1(jiti@1.21.6)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@5.5.4)': dependencies: '@typescript-eslint/scope-manager': 7.18.0 '@typescript-eslint/types': 7.18.0 - '@typescript-eslint/typescript-estree': 7.18.0(typescript@5.5.4) + '@typescript-eslint/typescript-estree': 7.18.0(supports-color@8.1.1)(typescript@5.5.4) '@typescript-eslint/visitor-keys': 7.18.0 - debug: 4.3.6 - eslint: 9.9.1(jiti@1.21.6) + debug: 4.3.6(supports-color@8.1.1) + eslint: 9.9.1(jiti@1.21.6)(supports-color@8.1.1) optionalDependencies: typescript: 5.5.4 transitivePeerDependencies: - supports-color - '@typescript-eslint/parser@8.4.0(eslint@9.9.1(jiti@1.21.6))(typescript@5.5.4)': + '@typescript-eslint/parser@8.4.0(eslint@9.9.1(jiti@1.21.6)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@5.5.4)': dependencies: '@typescript-eslint/scope-manager': 8.4.0 '@typescript-eslint/types': 8.4.0 - '@typescript-eslint/typescript-estree': 8.4.0(typescript@5.5.4) + '@typescript-eslint/typescript-estree': 8.4.0(supports-color@8.1.1)(typescript@5.5.4) '@typescript-eslint/visitor-keys': 8.4.0 - debug: 4.3.6 - eslint: 9.9.1(jiti@1.21.6) + debug: 4.3.6(supports-color@8.1.1) + eslint: 9.9.1(jiti@1.21.6)(supports-color@8.1.1) optionalDependencies: typescript: 5.5.4 transitivePeerDependencies: @@ -6032,23 +6214,23 @@ snapshots: '@typescript-eslint/types': 8.4.0 '@typescript-eslint/visitor-keys': 8.4.0 - '@typescript-eslint/type-utils@7.18.0(eslint@9.9.1(jiti@1.21.6))(typescript@5.5.4)': + '@typescript-eslint/type-utils@7.18.0(eslint@9.9.1(jiti@1.21.6)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@5.5.4)': dependencies: - '@typescript-eslint/typescript-estree': 7.18.0(typescript@5.5.4) - '@typescript-eslint/utils': 7.18.0(eslint@9.9.1(jiti@1.21.6))(typescript@5.5.4) - debug: 4.3.6 - eslint: 9.9.1(jiti@1.21.6) + '@typescript-eslint/typescript-estree': 7.18.0(supports-color@8.1.1)(typescript@5.5.4) + '@typescript-eslint/utils': 7.18.0(eslint@9.9.1(jiti@1.21.6)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@5.5.4) + debug: 4.3.6(supports-color@8.1.1) + eslint: 9.9.1(jiti@1.21.6)(supports-color@8.1.1) ts-api-utils: 1.3.0(typescript@5.5.4) optionalDependencies: typescript: 5.5.4 transitivePeerDependencies: - supports-color - '@typescript-eslint/type-utils@8.4.0(eslint@9.9.1(jiti@1.21.6))(typescript@5.5.4)': + '@typescript-eslint/type-utils@8.4.0(eslint@9.9.1(jiti@1.21.6)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@5.5.4)': dependencies: - '@typescript-eslint/typescript-estree': 8.4.0(typescript@5.5.4) - '@typescript-eslint/utils': 8.4.0(eslint@9.9.1(jiti@1.21.6))(typescript@5.5.4) - debug: 4.3.6 + '@typescript-eslint/typescript-estree': 8.4.0(supports-color@8.1.1)(typescript@5.5.4) + '@typescript-eslint/utils': 8.4.0(eslint@9.9.1(jiti@1.21.6)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@5.5.4) + debug: 4.3.6(supports-color@8.1.1) ts-api-utils: 1.3.0(typescript@5.5.4) optionalDependencies: typescript: 5.5.4 @@ -6060,11 +6242,11 @@ snapshots: '@typescript-eslint/types@8.4.0': {} - '@typescript-eslint/typescript-estree@7.18.0(typescript@5.5.4)': + '@typescript-eslint/typescript-estree@7.18.0(supports-color@8.1.1)(typescript@5.5.4)': dependencies: '@typescript-eslint/types': 7.18.0 '@typescript-eslint/visitor-keys': 7.18.0 - debug: 4.3.6 + debug: 4.3.6(supports-color@8.1.1) globby: 11.1.0 is-glob: 4.0.3 minimatch: 9.0.5 @@ -6075,11 +6257,11 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/typescript-estree@8.4.0(typescript@5.5.4)': + '@typescript-eslint/typescript-estree@8.4.0(supports-color@8.1.1)(typescript@5.5.4)': dependencies: '@typescript-eslint/types': 8.4.0 '@typescript-eslint/visitor-keys': 8.4.0 - debug: 4.3.6 + debug: 4.3.6(supports-color@8.1.1) fast-glob: 3.3.2 is-glob: 4.0.3 minimatch: 9.0.5 @@ -6090,24 +6272,24 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/utils@7.18.0(eslint@9.9.1(jiti@1.21.6))(typescript@5.5.4)': + '@typescript-eslint/utils@7.18.0(eslint@9.9.1(jiti@1.21.6)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@5.5.4)': dependencies: - '@eslint-community/eslint-utils': 4.4.0(eslint@9.9.1(jiti@1.21.6)) + '@eslint-community/eslint-utils': 4.4.0(eslint@9.9.1(jiti@1.21.6)(supports-color@8.1.1)) '@typescript-eslint/scope-manager': 7.18.0 '@typescript-eslint/types': 7.18.0 - '@typescript-eslint/typescript-estree': 7.18.0(typescript@5.5.4) - eslint: 9.9.1(jiti@1.21.6) + '@typescript-eslint/typescript-estree': 7.18.0(supports-color@8.1.1)(typescript@5.5.4) + eslint: 9.9.1(jiti@1.21.6)(supports-color@8.1.1) transitivePeerDependencies: - supports-color - typescript - '@typescript-eslint/utils@8.4.0(eslint@9.9.1(jiti@1.21.6))(typescript@5.5.4)': + '@typescript-eslint/utils@8.4.0(eslint@9.9.1(jiti@1.21.6)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@5.5.4)': dependencies: - '@eslint-community/eslint-utils': 4.4.0(eslint@9.9.1(jiti@1.21.6)) + '@eslint-community/eslint-utils': 4.4.0(eslint@9.9.1(jiti@1.21.6)(supports-color@8.1.1)) '@typescript-eslint/scope-manager': 8.4.0 '@typescript-eslint/types': 8.4.0 - '@typescript-eslint/typescript-estree': 8.4.0(typescript@5.5.4) - eslint: 9.9.1(jiti@1.21.6) + '@typescript-eslint/typescript-estree': 8.4.0(supports-color@8.1.1)(typescript@5.5.4) + eslint: 9.9.1(jiti@1.21.6)(supports-color@8.1.1) transitivePeerDependencies: - supports-color - typescript @@ -6126,11 +6308,11 @@ snapshots: '@vicons/ionicons5@0.12.0': {} - '@vitejs/plugin-vue-jsx@4.0.1(vite@5.4.2(@types/node@22.5.2)(less@4.2.0)(terser@5.31.6))(vue@3.4.38(typescript@5.5.4))': + '@vitejs/plugin-vue-jsx@4.0.1(supports-color@8.1.1)(vite@5.4.2(@types/node@22.5.2)(less@4.2.0)(terser@5.31.6))(vue@3.4.38(typescript@5.5.4))': dependencies: - '@babel/core': 7.25.2 - '@babel/plugin-transform-typescript': 7.25.2(@babel/core@7.25.2) - '@vue/babel-plugin-jsx': 1.2.2(@babel/core@7.25.2) + '@babel/core': 7.25.2(supports-color@8.1.1) + '@babel/plugin-transform-typescript': 7.25.2(@babel/core@7.25.2(supports-color@8.1.1))(supports-color@8.1.1) + '@vue/babel-plugin-jsx': 1.2.2(@babel/core@7.25.2(supports-color@8.1.1))(supports-color@8.1.1) vite: 5.4.2(@types/node@22.5.2)(less@4.2.0)(terser@5.31.6) vue: 3.4.38(typescript@5.5.4) transitivePeerDependencies: @@ -6143,28 +6325,28 @@ snapshots: '@vue/babel-helper-vue-transform-on@1.2.2': {} - '@vue/babel-plugin-jsx@1.2.2(@babel/core@7.25.2)': + '@vue/babel-plugin-jsx@1.2.2(@babel/core@7.25.2(supports-color@8.1.1))(supports-color@8.1.1)': dependencies: '@babel/helper-module-imports': 7.22.15 '@babel/helper-plugin-utils': 7.24.8 - '@babel/plugin-syntax-jsx': 7.24.7(@babel/core@7.25.2) + '@babel/plugin-syntax-jsx': 7.24.7(@babel/core@7.25.2(supports-color@8.1.1)) '@babel/template': 7.25.0 - '@babel/traverse': 7.25.6 + '@babel/traverse': 7.25.6(supports-color@8.1.1) '@babel/types': 7.25.6 '@vue/babel-helper-vue-transform-on': 1.2.2 - '@vue/babel-plugin-resolve-type': 1.2.2(@babel/core@7.25.2) + '@vue/babel-plugin-resolve-type': 1.2.2(@babel/core@7.25.2(supports-color@8.1.1)) camelcase: 6.3.0 html-tags: 3.3.1 svg-tags: 1.0.0 optionalDependencies: - '@babel/core': 7.25.2 + '@babel/core': 7.25.2(supports-color@8.1.1) transitivePeerDependencies: - supports-color - '@vue/babel-plugin-resolve-type@1.2.2(@babel/core@7.25.2)': + '@vue/babel-plugin-resolve-type@1.2.2(@babel/core@7.25.2(supports-color@8.1.1))': dependencies: '@babel/code-frame': 7.24.7 - '@babel/core': 7.25.2 + '@babel/core': 7.25.2(supports-color@8.1.1) '@babel/helper-module-imports': 7.22.15 '@babel/helper-plugin-utils': 7.24.8 '@babel/parser': 7.25.6 @@ -6202,13 +6384,13 @@ snapshots: '@vue/devtools-api@6.6.3': {} - '@vue/eslint-config-typescript@13.0.0(eslint-plugin-vue@9.28.0(eslint@9.9.1(jiti@1.21.6)))(eslint@9.9.1(jiti@1.21.6))(typescript@5.5.4)': + '@vue/eslint-config-typescript@13.0.0(eslint-plugin-vue@9.28.0(eslint@9.9.1(jiti@1.21.6)(supports-color@8.1.1))(supports-color@8.1.1))(eslint@9.9.1(jiti@1.21.6)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@5.5.4)': dependencies: - '@typescript-eslint/eslint-plugin': 7.18.0(@typescript-eslint/parser@7.18.0(eslint@9.9.1(jiti@1.21.6))(typescript@5.5.4))(eslint@9.9.1(jiti@1.21.6))(typescript@5.5.4) - '@typescript-eslint/parser': 7.18.0(eslint@9.9.1(jiti@1.21.6))(typescript@5.5.4) - eslint: 9.9.1(jiti@1.21.6) - eslint-plugin-vue: 9.28.0(eslint@9.9.1(jiti@1.21.6)) - vue-eslint-parser: 9.4.3(eslint@9.9.1(jiti@1.21.6)) + '@typescript-eslint/eslint-plugin': 7.18.0(@typescript-eslint/parser@7.18.0(eslint@9.9.1(jiti@1.21.6)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@5.5.4))(eslint@9.9.1(jiti@1.21.6)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@5.5.4) + '@typescript-eslint/parser': 7.18.0(eslint@9.9.1(jiti@1.21.6)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@5.5.4) + eslint: 9.9.1(jiti@1.21.6)(supports-color@8.1.1) + eslint-plugin-vue: 9.28.0(eslint@9.9.1(jiti@1.21.6)(supports-color@8.1.1))(supports-color@8.1.1) + vue-eslint-parser: 9.4.3(eslint@9.9.1(jiti@1.21.6)(supports-color@8.1.1))(supports-color@8.1.1) optionalDependencies: typescript: 5.5.4 transitivePeerDependencies: @@ -6381,6 +6563,8 @@ snapshots: acorn@8.12.1: {} + acorn@8.18.0: {} + ajv-keywords@3.5.2(ajv@6.12.6): dependencies: ajv: 6.12.6 @@ -6472,33 +6656,33 @@ snapshots: postcss: 8.4.44 postcss-value-parser: 4.2.0 - axios@1.7.7: + axios@1.7.7(debug@4.3.6(supports-color@8.1.1)): dependencies: - follow-redirects: 1.15.6 + follow-redirects: 1.15.6(debug@4.3.6(supports-color@8.1.1)) form-data: 4.0.0 proxy-from-env: 1.1.0 transitivePeerDependencies: - debug - babel-jest@29.7.0(@babel/core@7.25.2): + babel-jest@29.7.0(@babel/core@7.25.2(supports-color@8.1.1))(supports-color@8.1.1): dependencies: - '@babel/core': 7.25.2 - '@jest/transform': 29.7.0 + '@babel/core': 7.25.2(supports-color@8.1.1) + '@jest/transform': 29.7.0(supports-color@8.1.1) '@types/babel__core': 7.20.5 - babel-plugin-istanbul: 6.1.1 - babel-preset-jest: 29.6.3(@babel/core@7.25.2) + babel-plugin-istanbul: 6.1.1(supports-color@8.1.1) + babel-preset-jest: 29.6.3(@babel/core@7.25.2(supports-color@8.1.1)) chalk: 4.1.2 graceful-fs: 4.2.11 slash: 3.0.0 transitivePeerDependencies: - supports-color - babel-plugin-istanbul@6.1.1: + babel-plugin-istanbul@6.1.1(supports-color@8.1.1): dependencies: '@babel/helper-plugin-utils': 7.24.8 '@istanbuljs/load-nyc-config': 1.1.0 '@istanbuljs/schema': 0.1.3 - istanbul-lib-instrument: 5.2.1 + istanbul-lib-instrument: 5.2.1(supports-color@8.1.1) test-exclude: 6.0.0 transitivePeerDependencies: - supports-color @@ -6510,30 +6694,30 @@ snapshots: '@types/babel__core': 7.20.5 '@types/babel__traverse': 7.20.6 - babel-preset-current-node-syntax@1.1.0(@babel/core@7.25.2): + babel-preset-current-node-syntax@1.1.0(@babel/core@7.25.2(supports-color@8.1.1)): dependencies: - '@babel/core': 7.25.2 - '@babel/plugin-syntax-async-generators': 7.8.4(@babel/core@7.25.2) - '@babel/plugin-syntax-bigint': 7.8.3(@babel/core@7.25.2) - '@babel/plugin-syntax-class-properties': 7.12.13(@babel/core@7.25.2) - '@babel/plugin-syntax-class-static-block': 7.14.5(@babel/core@7.25.2) - '@babel/plugin-syntax-import-attributes': 7.25.6(@babel/core@7.25.2) - '@babel/plugin-syntax-import-meta': 7.10.4(@babel/core@7.25.2) - '@babel/plugin-syntax-json-strings': 7.8.3(@babel/core@7.25.2) - '@babel/plugin-syntax-logical-assignment-operators': 7.10.4(@babel/core@7.25.2) - '@babel/plugin-syntax-nullish-coalescing-operator': 7.8.3(@babel/core@7.25.2) - '@babel/plugin-syntax-numeric-separator': 7.10.4(@babel/core@7.25.2) - '@babel/plugin-syntax-object-rest-spread': 7.8.3(@babel/core@7.25.2) - '@babel/plugin-syntax-optional-catch-binding': 7.8.3(@babel/core@7.25.2) - '@babel/plugin-syntax-optional-chaining': 7.8.3(@babel/core@7.25.2) - '@babel/plugin-syntax-private-property-in-object': 7.14.5(@babel/core@7.25.2) - '@babel/plugin-syntax-top-level-await': 7.14.5(@babel/core@7.25.2) + '@babel/core': 7.25.2(supports-color@8.1.1) + '@babel/plugin-syntax-async-generators': 7.8.4(@babel/core@7.25.2(supports-color@8.1.1)) + '@babel/plugin-syntax-bigint': 7.8.3(@babel/core@7.25.2(supports-color@8.1.1)) + '@babel/plugin-syntax-class-properties': 7.12.13(@babel/core@7.25.2(supports-color@8.1.1)) + '@babel/plugin-syntax-class-static-block': 7.14.5(@babel/core@7.25.2(supports-color@8.1.1)) + '@babel/plugin-syntax-import-attributes': 7.25.6(@babel/core@7.25.2(supports-color@8.1.1)) + '@babel/plugin-syntax-import-meta': 7.10.4(@babel/core@7.25.2(supports-color@8.1.1)) + '@babel/plugin-syntax-json-strings': 7.8.3(@babel/core@7.25.2(supports-color@8.1.1)) + '@babel/plugin-syntax-logical-assignment-operators': 7.10.4(@babel/core@7.25.2(supports-color@8.1.1)) + '@babel/plugin-syntax-nullish-coalescing-operator': 7.8.3(@babel/core@7.25.2(supports-color@8.1.1)) + '@babel/plugin-syntax-numeric-separator': 7.10.4(@babel/core@7.25.2(supports-color@8.1.1)) + '@babel/plugin-syntax-object-rest-spread': 7.8.3(@babel/core@7.25.2(supports-color@8.1.1)) + '@babel/plugin-syntax-optional-catch-binding': 7.8.3(@babel/core@7.25.2(supports-color@8.1.1)) + '@babel/plugin-syntax-optional-chaining': 7.8.3(@babel/core@7.25.2(supports-color@8.1.1)) + '@babel/plugin-syntax-private-property-in-object': 7.14.5(@babel/core@7.25.2(supports-color@8.1.1)) + '@babel/plugin-syntax-top-level-await': 7.14.5(@babel/core@7.25.2(supports-color@8.1.1)) - babel-preset-jest@29.6.3(@babel/core@7.25.2): + babel-preset-jest@29.6.3(@babel/core@7.25.2(supports-color@8.1.1)): dependencies: - '@babel/core': 7.25.2 + '@babel/core': 7.25.2(supports-color@8.1.1) babel-plugin-jest-hoist: 29.6.3 - babel-preset-current-node-syntax: 1.1.0(@babel/core@7.25.2) + babel-preset-current-node-syntax: 1.1.0(@babel/core@7.25.2(supports-color@8.1.1)) balanced-match@1.0.2: {} @@ -6785,12 +6969,16 @@ snapshots: confbox@0.1.7: {} + confbox@0.1.8: {} + + confbox@0.2.4: {} + connect-history-api-fallback@1.6.0: {} - connect@3.7.0: + connect@3.7.0(supports-color@8.1.1): dependencies: - debug: 2.6.9 - finalhandler: 1.1.2 + debug: 2.6.9(supports-color@8.1.1) + finalhandler: 1.1.2(supports-color@8.1.1) parseurl: 1.3.3 utils-merge: 1.0.1 transitivePeerDependencies: @@ -6847,13 +7035,13 @@ snapshots: optionalDependencies: typescript: 5.5.4 - create-jest@29.7.0(@types/node@22.5.2)(ts-node@10.9.2(@swc/core@1.7.23)(@types/node@22.5.2)(typescript@5.5.4)): + create-jest@29.7.0(@types/node@22.5.2)(supports-color@8.1.1)(ts-node@10.9.2(@swc/core@1.7.23)(@types/node@22.5.2)(typescript@5.5.4)): dependencies: '@jest/types': 29.6.3 chalk: 4.1.2 exit: 0.1.2 graceful-fs: 4.2.11 - jest-config: 29.7.0(@types/node@22.5.2)(ts-node@10.9.2(@swc/core@1.7.23)(@types/node@22.5.2)(typescript@5.5.4)) + jest-config: 29.7.0(@types/node@22.5.2)(supports-color@8.1.1)(ts-node@10.9.2(@swc/core@1.7.23)(@types/node@22.5.2)(typescript@5.5.4)) jest-util: 29.7.0 prompts: 2.4.2 transitivePeerDependencies: @@ -6923,19 +7111,25 @@ snapshots: dargs@8.1.0: {} - date-fns-tz@3.2.0(date-fns@3.6.0): + date-fns-tz@3.2.0(date-fns@4.4.0): dependencies: - date-fns: 3.6.0 + date-fns: 4.4.0 date-fns@3.6.0: {} - debug@2.6.9: + date-fns@4.4.0: {} + + debug@2.6.9(supports-color@8.1.1): dependencies: ms: 2.0.0 + optionalDependencies: + supports-color: 8.1.1 - debug@4.3.6: + debug@4.3.6(supports-color@8.1.1): dependencies: ms: 2.1.2 + optionalDependencies: + supports-color: 8.1.1 dedent@0.7.0: {} @@ -7152,42 +7346,44 @@ snapshots: escape-string-regexp@4.0.0: {} - eslint-config-prettier@9.1.0(eslint@9.9.1(jiti@1.21.6)): + escape-string-regexp@5.0.0: {} + + eslint-config-prettier@9.1.0(eslint@9.9.1(jiti@1.21.6)(supports-color@8.1.1)): dependencies: - eslint: 9.9.1(jiti@1.21.6) + eslint: 9.9.1(jiti@1.21.6)(supports-color@8.1.1) eslint-define-config@2.1.0: {} - eslint-plugin-jest@28.8.2(@typescript-eslint/eslint-plugin@8.4.0(@typescript-eslint/parser@8.4.0(eslint@9.9.1(jiti@1.21.6))(typescript@5.5.4))(eslint@9.9.1(jiti@1.21.6))(typescript@5.5.4))(eslint@9.9.1(jiti@1.21.6))(jest@29.7.0(@types/node@22.5.2)(ts-node@10.9.2(@swc/core@1.7.23)(@types/node@22.5.2)(typescript@5.5.4)))(typescript@5.5.4): + eslint-plugin-jest@28.8.2(@typescript-eslint/eslint-plugin@8.4.0(@typescript-eslint/parser@8.4.0(eslint@9.9.1(jiti@1.21.6)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@5.5.4))(eslint@9.9.1(jiti@1.21.6)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@5.5.4))(eslint@9.9.1(jiti@1.21.6)(supports-color@8.1.1))(jest@29.7.0(@types/node@22.5.2)(supports-color@8.1.1)(ts-node@10.9.2(@swc/core@1.7.23)(@types/node@22.5.2)(typescript@5.5.4)))(supports-color@8.1.1)(typescript@5.5.4): dependencies: - '@typescript-eslint/utils': 8.4.0(eslint@9.9.1(jiti@1.21.6))(typescript@5.5.4) - eslint: 9.9.1(jiti@1.21.6) + '@typescript-eslint/utils': 8.4.0(eslint@9.9.1(jiti@1.21.6)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@5.5.4) + eslint: 9.9.1(jiti@1.21.6)(supports-color@8.1.1) optionalDependencies: - '@typescript-eslint/eslint-plugin': 8.4.0(@typescript-eslint/parser@8.4.0(eslint@9.9.1(jiti@1.21.6))(typescript@5.5.4))(eslint@9.9.1(jiti@1.21.6))(typescript@5.5.4) - jest: 29.7.0(@types/node@22.5.2)(ts-node@10.9.2(@swc/core@1.7.23)(@types/node@22.5.2)(typescript@5.5.4)) + '@typescript-eslint/eslint-plugin': 8.4.0(@typescript-eslint/parser@8.4.0(eslint@9.9.1(jiti@1.21.6)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@5.5.4))(eslint@9.9.1(jiti@1.21.6)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@5.5.4) + jest: 29.7.0(@types/node@22.5.2)(supports-color@8.1.1)(ts-node@10.9.2(@swc/core@1.7.23)(@types/node@22.5.2)(typescript@5.5.4)) transitivePeerDependencies: - supports-color - typescript - eslint-plugin-prettier@5.2.1(eslint-config-prettier@9.1.0(eslint@9.9.1(jiti@1.21.6)))(eslint@9.9.1(jiti@1.21.6))(prettier@3.3.3): + eslint-plugin-prettier@5.2.1(eslint-config-prettier@9.1.0(eslint@9.9.1(jiti@1.21.6)(supports-color@8.1.1)))(eslint@9.9.1(jiti@1.21.6)(supports-color@8.1.1))(prettier@3.3.3): dependencies: - eslint: 9.9.1(jiti@1.21.6) + eslint: 9.9.1(jiti@1.21.6)(supports-color@8.1.1) prettier: 3.3.3 prettier-linter-helpers: 1.0.0 synckit: 0.9.1 optionalDependencies: - eslint-config-prettier: 9.1.0(eslint@9.9.1(jiti@1.21.6)) + eslint-config-prettier: 9.1.0(eslint@9.9.1(jiti@1.21.6)(supports-color@8.1.1)) - eslint-plugin-vue@9.28.0(eslint@9.9.1(jiti@1.21.6)): + eslint-plugin-vue@9.28.0(eslint@9.9.1(jiti@1.21.6)(supports-color@8.1.1))(supports-color@8.1.1): dependencies: - '@eslint-community/eslint-utils': 4.4.0(eslint@9.9.1(jiti@1.21.6)) - eslint: 9.9.1(jiti@1.21.6) + '@eslint-community/eslint-utils': 4.4.0(eslint@9.9.1(jiti@1.21.6)(supports-color@8.1.1)) + eslint: 9.9.1(jiti@1.21.6)(supports-color@8.1.1) globals: 13.24.0 natural-compare: 1.4.0 nth-check: 2.1.1 postcss-selector-parser: 6.1.2 semver: 7.6.3 - vue-eslint-parser: 9.4.3(eslint@9.9.1(jiti@1.21.6)) + vue-eslint-parser: 9.4.3(eslint@9.9.1(jiti@1.21.6)(supports-color@8.1.1))(supports-color@8.1.1) xml-name-validator: 4.0.0 transitivePeerDependencies: - supports-color @@ -7212,12 +7408,12 @@ snapshots: eslint-visitor-keys@4.0.0: {} - eslint@9.9.1(jiti@1.21.6): + eslint@9.9.1(jiti@1.21.6)(supports-color@8.1.1): dependencies: - '@eslint-community/eslint-utils': 4.4.0(eslint@9.9.1(jiti@1.21.6)) + '@eslint-community/eslint-utils': 4.4.0(eslint@9.9.1(jiti@1.21.6)(supports-color@8.1.1)) '@eslint-community/regexpp': 4.11.0 - '@eslint/config-array': 0.18.0 - '@eslint/eslintrc': 3.1.0 + '@eslint/config-array': 0.18.0(supports-color@8.1.1) + '@eslint/eslintrc': 3.1.0(supports-color@8.1.1) '@eslint/js': 9.9.1 '@humanwhocodes/module-importer': 1.0.1 '@humanwhocodes/retry': 0.3.0 @@ -7225,7 +7421,7 @@ snapshots: ajv: 6.12.6 chalk: 4.1.2 cross-spawn: 7.0.3 - debug: 4.3.6 + debug: 4.3.6(supports-color@8.1.1) escape-string-regexp: 4.0.0 eslint-scope: 8.0.2 eslint-visitor-keys: 4.0.0 @@ -7286,6 +7482,10 @@ snapshots: estree-walker@2.0.2: {} + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.5 + esutils@2.0.3: {} eventemitter3@2.0.3: {} @@ -7335,6 +7535,8 @@ snapshots: jest-message-util: 29.7.0 jest-util: 29.7.0 + exsolve@1.1.1: {} + extend@3.0.2: {} external-editor@3.1.0: @@ -7375,6 +7577,10 @@ snapshots: dependencies: bser: 2.1.1 + fdir@6.5.0(picomatch@4.0.5): + optionalDependencies: + picomatch: 4.0.5 + figures@3.2.0: dependencies: escape-string-regexp: 1.0.5 @@ -7403,9 +7609,9 @@ snapshots: dependencies: to-regex-range: 5.0.1 - finalhandler@1.1.2: + finalhandler@1.1.2(supports-color@8.1.1): dependencies: - debug: 2.6.9 + debug: 2.6.9(supports-color@8.1.1) encodeurl: 1.0.2 escape-html: 1.0.3 on-finished: 2.3.0 @@ -7465,7 +7671,9 @@ snapshots: flatted@3.3.1: {} - follow-redirects@1.15.6: {} + follow-redirects@1.15.6(debug@4.3.6(supports-color@8.1.1)): + optionalDependencies: + debug: 4.3.6(supports-color@8.1.1) foreground-child@3.3.0: dependencies: @@ -7835,9 +8043,9 @@ snapshots: istanbul-lib-coverage@3.2.2: {} - istanbul-lib-instrument@5.2.1: + istanbul-lib-instrument@5.2.1(supports-color@8.1.1): dependencies: - '@babel/core': 7.25.2 + '@babel/core': 7.25.2(supports-color@8.1.1) '@babel/parser': 7.25.6 '@istanbuljs/schema': 0.1.3 istanbul-lib-coverage: 3.2.2 @@ -7845,9 +8053,9 @@ snapshots: transitivePeerDependencies: - supports-color - istanbul-lib-instrument@6.0.3: + istanbul-lib-instrument@6.0.3(supports-color@8.1.1): dependencies: - '@babel/core': 7.25.2 + '@babel/core': 7.25.2(supports-color@8.1.1) '@babel/parser': 7.25.6 '@istanbuljs/schema': 0.1.3 istanbul-lib-coverage: 3.2.2 @@ -7861,9 +8069,9 @@ snapshots: make-dir: 4.0.0 supports-color: 7.2.0 - istanbul-lib-source-maps@4.0.1: + istanbul-lib-source-maps@4.0.1(supports-color@8.1.1): dependencies: - debug: 4.3.6 + debug: 4.3.6(supports-color@8.1.1) istanbul-lib-coverage: 3.2.2 source-map: 0.6.1 transitivePeerDependencies: @@ -7899,10 +8107,10 @@ snapshots: jest-util: 29.7.0 p-limit: 3.1.0 - jest-circus@29.7.0: + jest-circus@29.7.0(supports-color@8.1.1): dependencies: '@jest/environment': 29.7.0 - '@jest/expect': 29.7.0 + '@jest/expect': 29.7.0(supports-color@8.1.1) '@jest/test-result': 29.7.0 '@jest/types': 29.6.3 '@types/node': 22.5.2 @@ -7913,8 +8121,8 @@ snapshots: jest-each: 29.7.0 jest-matcher-utils: 29.7.0 jest-message-util: 29.7.0 - jest-runtime: 29.7.0 - jest-snapshot: 29.7.0 + jest-runtime: 29.7.0(supports-color@8.1.1) + jest-snapshot: 29.7.0(supports-color@8.1.1) jest-util: 29.7.0 p-limit: 3.1.0 pretty-format: 29.7.0 @@ -7925,16 +8133,16 @@ snapshots: - babel-plugin-macros - supports-color - jest-cli@29.7.0(@types/node@22.5.2)(ts-node@10.9.2(@swc/core@1.7.23)(@types/node@22.5.2)(typescript@5.5.4)): + jest-cli@29.7.0(@types/node@22.5.2)(supports-color@8.1.1)(ts-node@10.9.2(@swc/core@1.7.23)(@types/node@22.5.2)(typescript@5.5.4)): dependencies: - '@jest/core': 29.7.0(ts-node@10.9.2(@swc/core@1.7.23)(@types/node@22.5.2)(typescript@5.5.4)) + '@jest/core': 29.7.0(supports-color@8.1.1)(ts-node@10.9.2(@swc/core@1.7.23)(@types/node@22.5.2)(typescript@5.5.4)) '@jest/test-result': 29.7.0 '@jest/types': 29.6.3 chalk: 4.1.2 - create-jest: 29.7.0(@types/node@22.5.2)(ts-node@10.9.2(@swc/core@1.7.23)(@types/node@22.5.2)(typescript@5.5.4)) + create-jest: 29.7.0(@types/node@22.5.2)(supports-color@8.1.1)(ts-node@10.9.2(@swc/core@1.7.23)(@types/node@22.5.2)(typescript@5.5.4)) exit: 0.1.2 import-local: 3.2.0 - jest-config: 29.7.0(@types/node@22.5.2)(ts-node@10.9.2(@swc/core@1.7.23)(@types/node@22.5.2)(typescript@5.5.4)) + jest-config: 29.7.0(@types/node@22.5.2)(supports-color@8.1.1)(ts-node@10.9.2(@swc/core@1.7.23)(@types/node@22.5.2)(typescript@5.5.4)) jest-util: 29.7.0 jest-validate: 29.7.0 yargs: 17.7.2 @@ -7944,23 +8152,23 @@ snapshots: - supports-color - ts-node - jest-config@29.7.0(@types/node@22.5.2)(ts-node@10.9.2(@swc/core@1.7.23)(@types/node@22.5.2)(typescript@5.5.4)): + jest-config@29.7.0(@types/node@22.5.2)(supports-color@8.1.1)(ts-node@10.9.2(@swc/core@1.7.23)(@types/node@22.5.2)(typescript@5.5.4)): dependencies: - '@babel/core': 7.25.2 + '@babel/core': 7.25.2(supports-color@8.1.1) '@jest/test-sequencer': 29.7.0 '@jest/types': 29.6.3 - babel-jest: 29.7.0(@babel/core@7.25.2) + babel-jest: 29.7.0(@babel/core@7.25.2(supports-color@8.1.1))(supports-color@8.1.1) chalk: 4.1.2 ci-info: 3.9.0 deepmerge: 4.3.1 glob: 7.2.3 graceful-fs: 4.2.11 - jest-circus: 29.7.0 + jest-circus: 29.7.0(supports-color@8.1.1) jest-environment-node: 29.7.0 jest-get-type: 29.6.3 jest-regex-util: 29.6.3 jest-resolve: 29.7.0 - jest-runner: 29.7.0 + jest-runner: 29.7.0(supports-color@8.1.1) jest-util: 29.7.0 jest-validate: 29.7.0 micromatch: 4.0.8 @@ -8057,10 +8265,10 @@ snapshots: jest-regex-util@29.6.3: {} - jest-resolve-dependencies@29.7.0: + jest-resolve-dependencies@29.7.0(supports-color@8.1.1): dependencies: jest-regex-util: 29.6.3 - jest-snapshot: 29.7.0 + jest-snapshot: 29.7.0(supports-color@8.1.1) transitivePeerDependencies: - supports-color @@ -8076,12 +8284,12 @@ snapshots: resolve.exports: 2.0.2 slash: 3.0.0 - jest-runner@29.7.0: + jest-runner@29.7.0(supports-color@8.1.1): dependencies: '@jest/console': 29.7.0 '@jest/environment': 29.7.0 '@jest/test-result': 29.7.0 - '@jest/transform': 29.7.0 + '@jest/transform': 29.7.0(supports-color@8.1.1) '@jest/types': 29.6.3 '@types/node': 22.5.2 chalk: 4.1.2 @@ -8093,7 +8301,7 @@ snapshots: jest-leak-detector: 29.7.0 jest-message-util: 29.7.0 jest-resolve: 29.7.0 - jest-runtime: 29.7.0 + jest-runtime: 29.7.0(supports-color@8.1.1) jest-util: 29.7.0 jest-watcher: 29.7.0 jest-worker: 29.7.0 @@ -8102,14 +8310,14 @@ snapshots: transitivePeerDependencies: - supports-color - jest-runtime@29.7.0: + jest-runtime@29.7.0(supports-color@8.1.1): dependencies: '@jest/environment': 29.7.0 '@jest/fake-timers': 29.7.0 - '@jest/globals': 29.7.0 + '@jest/globals': 29.7.0(supports-color@8.1.1) '@jest/source-map': 29.6.3 '@jest/test-result': 29.7.0 - '@jest/transform': 29.7.0 + '@jest/transform': 29.7.0(supports-color@8.1.1) '@jest/types': 29.6.3 '@types/node': 22.5.2 chalk: 4.1.2 @@ -8122,24 +8330,24 @@ snapshots: jest-mock: 29.7.0 jest-regex-util: 29.6.3 jest-resolve: 29.7.0 - jest-snapshot: 29.7.0 + jest-snapshot: 29.7.0(supports-color@8.1.1) jest-util: 29.7.0 slash: 3.0.0 strip-bom: 4.0.0 transitivePeerDependencies: - supports-color - jest-snapshot@29.7.0: + jest-snapshot@29.7.0(supports-color@8.1.1): dependencies: - '@babel/core': 7.25.2 + '@babel/core': 7.25.2(supports-color@8.1.1) '@babel/generator': 7.25.6 - '@babel/plugin-syntax-jsx': 7.24.7(@babel/core@7.25.2) - '@babel/plugin-syntax-typescript': 7.25.4(@babel/core@7.25.2) + '@babel/plugin-syntax-jsx': 7.24.7(@babel/core@7.25.2(supports-color@8.1.1)) + '@babel/plugin-syntax-typescript': 7.25.4(@babel/core@7.25.2(supports-color@8.1.1)) '@babel/types': 7.25.6 '@jest/expect-utils': 29.7.0 - '@jest/transform': 29.7.0 + '@jest/transform': 29.7.0(supports-color@8.1.1) '@jest/types': 29.6.3 - babel-preset-current-node-syntax: 1.1.0(@babel/core@7.25.2) + babel-preset-current-node-syntax: 1.1.0(@babel/core@7.25.2(supports-color@8.1.1)) chalk: 4.1.2 expect: 29.7.0 graceful-fs: 4.2.11 @@ -8197,12 +8405,12 @@ snapshots: merge-stream: 2.0.0 supports-color: 8.1.1 - jest@29.7.0(@types/node@22.5.2)(ts-node@10.9.2(@swc/core@1.7.23)(@types/node@22.5.2)(typescript@5.5.4)): + jest@29.7.0(@types/node@22.5.2)(supports-color@8.1.1)(ts-node@10.9.2(@swc/core@1.7.23)(@types/node@22.5.2)(typescript@5.5.4)): dependencies: - '@jest/core': 29.7.0(ts-node@10.9.2(@swc/core@1.7.23)(@types/node@22.5.2)(typescript@5.5.4)) + '@jest/core': 29.7.0(supports-color@8.1.1)(ts-node@10.9.2(@swc/core@1.7.23)(@types/node@22.5.2)(typescript@5.5.4)) '@jest/types': 29.6.3 import-local: 3.2.0 - jest-cli: 29.7.0(@types/node@22.5.2)(ts-node@10.9.2(@swc/core@1.7.23)(@types/node@22.5.2)(typescript@5.5.4)) + jest-cli: 29.7.0(@types/node@22.5.2)(supports-color@8.1.1)(ts-node@10.9.2(@swc/core@1.7.23)(@types/node@22.5.2)(typescript@5.5.4)) transitivePeerDependencies: - '@types/node' - babel-plugin-macros @@ -8213,6 +8421,8 @@ snapshots: js-tokens@4.0.0: {} + js-tokens@9.0.1: {} + js-yaml@3.14.1: dependencies: argparse: 1.0.10 @@ -8287,11 +8497,11 @@ snapshots: lines-and-columns@1.2.4: {} - lint-staged@15.2.10: + lint-staged@15.2.10(supports-color@8.1.1): dependencies: chalk: 5.3.0 commander: 12.1.0 - debug: 4.3.6 + debug: 4.3.6(supports-color@8.1.1) execa: 8.0.1 lilconfig: 3.1.2 listr2: 8.2.4 @@ -8321,6 +8531,12 @@ snapshots: mlly: 1.7.1 pkg-types: 1.2.0 + local-pkg@1.2.1: + dependencies: + mlly: 1.8.2 + pkg-types: 2.3.1 + quansync: 0.2.11 + locate-path@5.0.0: dependencies: p-locate: 4.1.0 @@ -8398,6 +8614,10 @@ snapshots: dependencies: '@jridgewell/sourcemap-codec': 1.5.0 + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + make-dir@2.1.0: dependencies: pify: 4.0.1 @@ -8486,6 +8706,13 @@ snapshots: pkg-types: 1.2.0 ufo: 1.5.4 + mlly@1.8.2: + dependencies: + acorn: 8.18.0 + pathe: 2.0.3 + pkg-types: 1.3.1 + ufo: 1.6.4 + mockjs@1.1.0: dependencies: commander: 12.1.0 @@ -8504,28 +8731,27 @@ snapshots: object-assign: 4.1.1 thenify-all: 1.6.0 - naive-ui@2.42.0(vue@3.4.38(typescript@5.5.4)): + naive-ui@2.44.1(vue@3.4.38(typescript@5.5.4)): dependencies: '@css-render/plugin-bem': 0.15.14(css-render@0.15.14) '@css-render/vue3-ssr': 0.15.14(vue@3.4.38(typescript@5.5.4)) - '@types/katex': 0.16.7 - '@types/lodash': 4.17.7 + '@types/lodash': 4.17.25 '@types/lodash-es': 4.17.12 async-validator: 4.2.5 css-render: 0.15.14 csstype: 3.1.3 - date-fns: 3.6.0 - date-fns-tz: 3.2.0(date-fns@3.6.0) + date-fns: 4.4.0 + date-fns-tz: 3.2.0(date-fns@4.4.0) evtd: 0.2.4 highlight.js: 11.10.0 lodash: 4.17.21 lodash-es: 4.17.21 - seemly: 0.3.8 + seemly: 0.3.10 treemate: 0.3.11 vdirs: 0.1.8(vue@3.4.38(typescript@5.5.4)) vooks: 0.2.12(vue@3.4.38(typescript@5.5.4)) vue: 3.4.38(typescript@5.5.4) - vueuc: 0.4.64(vue@3.4.38(typescript@5.5.4)) + vueuc: 0.4.65(vue@3.4.38(typescript@5.5.4)) nanoid@3.3.7: {} @@ -8722,12 +8948,16 @@ snapshots: pathe@1.1.2: {} + pathe@2.0.3: {} + picocolors@1.1.0: {} picomatch@2.3.1: {} picomatch@3.0.1: {} + picomatch@4.0.5: {} + pidtree@0.6.0: {} pify@2.3.0: {} @@ -8763,6 +8993,18 @@ snapshots: mlly: 1.7.1 pathe: 1.1.2 + pkg-types@1.3.1: + dependencies: + confbox: 0.1.8 + mlly: 1.8.2 + pathe: 2.0.3 + + pkg-types@2.3.1: + dependencies: + confbox: 0.2.4 + exsolve: 1.1.1 + pathe: 2.0.3 + postcss-import@15.1.0(postcss@8.4.44): dependencies: postcss: 8.4.44 @@ -8862,6 +9104,8 @@ snapshots: dependencies: side-channel: 1.0.6 + quansync@0.2.11: {} + queue-microtask@1.2.3: {} quill-delta@3.6.3: @@ -9023,6 +9267,10 @@ snapshots: ajv-keywords: 3.5.2(ajv@6.12.6) optional: true + scule@1.3.0: {} + + seemly@0.3.10: {} + seemly@0.3.8: {} select@1.1.2: {} @@ -9178,32 +9426,36 @@ snapshots: strip-json-comments@3.1.1: {} + strip-literal@3.1.0: + dependencies: + js-tokens: 9.0.1 + strip-outer@1.0.1: dependencies: escape-string-regexp: 1.0.5 style-mod@4.1.2: {} - stylelint-config-prettier@9.0.5(stylelint@16.9.0(typescript@5.5.4)): + stylelint-config-prettier@9.0.5(stylelint@16.9.0(supports-color@8.1.1)(typescript@5.5.4)): dependencies: - stylelint: 16.9.0(typescript@5.5.4) + stylelint: 16.9.0(supports-color@8.1.1)(typescript@5.5.4) - stylelint-config-recommended@14.0.1(stylelint@16.9.0(typescript@5.5.4)): + stylelint-config-recommended@14.0.1(stylelint@16.9.0(supports-color@8.1.1)(typescript@5.5.4)): dependencies: - stylelint: 16.9.0(typescript@5.5.4) + stylelint: 16.9.0(supports-color@8.1.1)(typescript@5.5.4) - stylelint-config-standard@36.0.1(stylelint@16.9.0(typescript@5.5.4)): + stylelint-config-standard@36.0.1(stylelint@16.9.0(supports-color@8.1.1)(typescript@5.5.4)): dependencies: - stylelint: 16.9.0(typescript@5.5.4) - stylelint-config-recommended: 14.0.1(stylelint@16.9.0(typescript@5.5.4)) + stylelint: 16.9.0(supports-color@8.1.1)(typescript@5.5.4) + stylelint-config-recommended: 14.0.1(stylelint@16.9.0(supports-color@8.1.1)(typescript@5.5.4)) - stylelint-order@6.0.4(stylelint@16.9.0(typescript@5.5.4)): + stylelint-order@6.0.4(stylelint@16.9.0(supports-color@8.1.1)(typescript@5.5.4)): dependencies: postcss: 8.4.44 postcss-sorting: 8.0.2(postcss@8.4.44) - stylelint: 16.9.0(typescript@5.5.4) + stylelint: 16.9.0(supports-color@8.1.1)(typescript@5.5.4) - stylelint-scss@6.5.1(stylelint@16.9.0(typescript@5.5.4)): + stylelint-scss@6.5.1(stylelint@16.9.0(supports-color@8.1.1)(typescript@5.5.4)): dependencies: css-tree: 2.3.1 is-plain-object: 5.0.0 @@ -9212,9 +9464,9 @@ snapshots: postcss-resolve-nested-selector: 0.1.6 postcss-selector-parser: 6.1.2 postcss-value-parser: 4.2.0 - stylelint: 16.9.0(typescript@5.5.4) + stylelint: 16.9.0(supports-color@8.1.1)(typescript@5.5.4) - stylelint@16.9.0(typescript@5.5.4): + stylelint@16.9.0(supports-color@8.1.1)(typescript@5.5.4): dependencies: '@csstools/css-parser-algorithms': 3.0.1(@csstools/css-tokenizer@3.0.1) '@csstools/css-tokenizer': 3.0.1 @@ -9226,7 +9478,7 @@ snapshots: cosmiconfig: 9.0.0(typescript@5.5.4) css-functions-list: 3.2.2 css-tree: 2.3.1 - debug: 4.3.6 + debug: 4.3.6(supports-color@8.1.1) fast-glob: 3.3.2 fastest-levenshtein: 1.0.16 file-entry-cache: 9.1.0 @@ -9377,6 +9629,11 @@ snapshots: tiny-emitter@2.1.0: {} + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.5) + picomatch: 4.0.5 + tmp@0.0.33: dependencies: os-tmpdir: 1.0.2 @@ -9447,20 +9704,55 @@ snapshots: ufo@1.5.4: {} + ufo@1.6.4: {} + undici-types@6.19.8: {} unicorn-magic@0.1.0: {} + unimport@5.7.0: + dependencies: + acorn: 8.18.0 + escape-string-regexp: 5.0.0 + estree-walker: 3.0.3 + local-pkg: 1.2.1 + magic-string: 0.30.21 + mlly: 1.8.2 + pathe: 2.0.3 + picomatch: 4.0.5 + pkg-types: 2.3.1 + scule: 1.3.0 + strip-literal: 3.1.0 + tinyglobby: 0.2.17 + unplugin: 2.3.11 + unplugin-utils: 0.3.2 + universalify@2.0.1: {} unpipe@1.0.0: {} - unplugin-vue-components@0.27.4(@babel/parser@7.25.6)(rollup@4.21.2)(vue@3.4.38(typescript@5.5.4)): + unplugin-auto-import@20.3.0(@vueuse/core@11.0.3(vue@3.4.38(typescript@5.5.4))): + dependencies: + local-pkg: 1.2.1 + magic-string: 0.30.21 + picomatch: 4.0.5 + unimport: 5.7.0 + unplugin: 2.3.11 + unplugin-utils: 0.3.2 + optionalDependencies: + '@vueuse/core': 11.0.3(vue@3.4.38(typescript@5.5.4)) + + unplugin-utils@0.3.2: + dependencies: + pathe: 2.0.3 + picomatch: 4.0.5 + + unplugin-vue-components@0.27.4(@babel/parser@7.25.6)(rollup@4.21.2)(supports-color@8.1.1)(vue@3.4.38(typescript@5.5.4)): dependencies: '@antfu/utils': 0.7.10 '@rollup/pluginutils': 5.1.0(rollup@4.21.2) chokidar: 3.6.0 - debug: 4.3.6 + debug: 4.3.6(supports-color@8.1.1) fast-glob: 3.3.2 local-pkg: 0.5.0 magic-string: 0.30.11 @@ -9480,6 +9772,13 @@ snapshots: webpack-sources: 3.2.3 webpack-virtual-modules: 0.6.2 + unplugin@2.3.11: + dependencies: + '@jridgewell/remapping': 2.3.5 + acorn: 8.18.0 + picomatch: 4.0.5 + webpack-virtual-modules: 0.6.2 + update-browserslist-db@1.1.0(browserslist@4.23.3): dependencies: browserslist: 4.23.3 @@ -9520,10 +9819,10 @@ snapshots: vfonts@0.0.3: {} - vite-plugin-compression@0.5.1(vite@5.4.2(@types/node@22.5.2)(less@4.2.0)(terser@5.31.6)): + vite-plugin-compression@0.5.1(supports-color@8.1.1)(vite@5.4.2(@types/node@22.5.2)(less@4.2.0)(terser@5.31.6)): dependencies: chalk: 4.1.2 - debug: 4.3.6 + debug: 4.3.6(supports-color@8.1.1) fs-extra: 10.1.0 vite: 5.4.2(@types/node@22.5.2)(less@4.2.0)(terser@5.31.6) transitivePeerDependencies: @@ -9545,12 +9844,12 @@ snapshots: pathe: 0.2.0 vite: 5.4.2(@types/node@22.5.2)(less@4.2.0)(terser@5.31.6) - vite-plugin-mock@3.0.2(esbuild@0.23.1)(mockjs@1.1.0)(vite@5.4.2(@types/node@22.5.2)(less@4.2.0)(terser@5.31.6)): + vite-plugin-mock@3.0.2(esbuild@0.23.1)(mockjs@1.1.0)(supports-color@8.1.1)(vite@5.4.2(@types/node@22.5.2)(less@4.2.0)(terser@5.31.6)): dependencies: bundle-require: 4.2.1(esbuild@0.23.1) chokidar: 3.6.0 - connect: 3.7.0 - debug: 4.3.6 + connect: 3.7.0(supports-color@8.1.1) + debug: 4.3.6(supports-color@8.1.1) esbuild: 0.23.1 fast-glob: 3.3.2 mockjs: 1.1.0 @@ -9560,11 +9859,11 @@ snapshots: transitivePeerDependencies: - supports-color - vite-plugin-require-transform@1.0.21: + vite-plugin-require-transform@1.0.21(supports-color@8.1.1): dependencies: '@babel/generator': 7.25.6 '@babel/parser': 7.25.6 - '@babel/traverse': 7.25.6 + '@babel/traverse': 7.25.6(supports-color@8.1.1) '@babel/types': 7.25.6 transitivePeerDependencies: - supports-color @@ -9621,10 +9920,10 @@ snapshots: sortablejs: 1.14.0 vue: 3.4.38(typescript@5.5.4) - vue-eslint-parser@9.4.3(eslint@9.9.1(jiti@1.21.6)): + vue-eslint-parser@9.4.3(eslint@9.9.1(jiti@1.21.6)(supports-color@8.1.1))(supports-color@8.1.1): dependencies: - debug: 4.3.6 - eslint: 9.9.1(jiti@1.21.6) + debug: 4.3.6(supports-color@8.1.1) + eslint: 9.9.1(jiti@1.21.6)(supports-color@8.1.1) eslint-scope: 7.2.2 eslint-visitor-keys: 3.4.3 espree: 9.6.1 @@ -9634,6 +9933,14 @@ snapshots: transitivePeerDependencies: - supports-color + vue-i18n@11.4.8(vue@3.4.38(typescript@5.5.4)): + dependencies: + '@intlify/core-base': 11.4.8 + '@intlify/devtools-types': 11.4.8 + '@intlify/shared': 11.4.8 + '@vue/devtools-api': 6.6.3 + vue: 3.4.38(typescript@5.5.4) + vue-router@4.4.3(vue@3.4.38(typescript@5.5.4)): dependencies: '@vue/devtools-api': 6.6.3 @@ -9647,7 +9954,7 @@ snapshots: vue-waterfall-plugin-next@2.6.0: {} - vue3-json-viewer@2.2.2(vue@3.4.38(typescript@5.5.4)): + vue3-json-viewer@2.4.1(vue@3.4.38(typescript@5.5.4)): dependencies: clipboard: 2.0.11 vue: 3.4.38(typescript@5.5.4) @@ -9667,7 +9974,7 @@ snapshots: sortablejs: 1.14.0 vue: 3.4.38(typescript@5.5.4) - vueuc@0.4.64(vue@3.4.38(typescript@5.5.4)): + vueuc@0.4.65(vue@3.4.38(typescript@5.5.4)): dependencies: '@css-render/vue3-ssr': 0.15.14(vue@3.4.38(typescript@5.5.4)) '@juggle/resize-observer': 3.4.0 diff --git a/web/src/api/im/registrationInvite.ts b/web/src/api/im/registrationInvite.ts new file mode 100644 index 0000000..3bc8c48 --- /dev/null +++ b/web/src/api/im/registrationInvite.ts @@ -0,0 +1,77 @@ +import { http } from '@/utils/http/axios'; + +export function List(params) { + return http.request({ + url: '/imInvite/list', + method: 'POST', + params, + }); +} + +export function Create(params) { + return http.request({ + url: '/imInvite/create', + method: 'POST', + params, + }); +} + +export function BatchCreate(params) { + return http.request({ + url: '/imInvite/batchCreate', + method: 'POST', + params, + }); +} + +export function BatchView(params) { + return http.request({ + url: '/imInvite/batchView', + method: 'GET', + params, + }); +} + +export function Revoke(params) { + return http.request({ + url: '/imInvite/revoke', + method: 'POST', + params, + }); +} + +export function BatchRevoke(params) { + return http.request({ + url: '/imInvite/batchRevoke', + method: 'POST', + params, + }); +} + +export function Export(params) { + return http.request({ + url: '/imInvite/export', + method: 'POST', + params, + responseType: 'blob', + requestOptions: { + isTransformResponse: false, + isReturnNativeResponse: true, + }, + }); +} + +export function GetRegistrationConfig() { + return http.request({ + url: '/imRegistration/config/get', + method: 'GET', + }); +} + +export function UpdateRegistrationConfig(params) { + return http.request({ + url: '/imRegistration/config/update', + method: 'POST', + params, + }); +} diff --git a/web/src/api/imAuditLog/index.ts b/web/src/api/imAuditLog/index.ts new file mode 100644 index 0000000..52bdfc8 --- /dev/null +++ b/web/src/api/imAuditLog/index.ts @@ -0,0 +1,19 @@ +import { http } from '@/utils/http/axios'; + +// 获取IM审计日志列表 +export function List(params) { + return http.request({ + url: '/imAuditLog/list', + method: 'get', + params, + }); +} + +// 获取IM审计日志详情 +export function View(params) { + return http.request({ + url: '/imAuditLog/view', + method: 'GET', + params, + }); +} diff --git a/web/src/api/imGroup/index.ts b/web/src/api/imGroup/index.ts new file mode 100644 index 0000000..8f5cac2 --- /dev/null +++ b/web/src/api/imGroup/index.ts @@ -0,0 +1,82 @@ +import { http } from '@/utils/http/axios'; + +// 获取IM群组列表 +export function List(params) { + return http.request({ + url: '/imGroup/list', + method: 'get', + params, + }); +} + +// 获取IM群组详情 +export function View(params) { + return http.request({ + url: '/imGroup/view', + method: 'GET', + params, + }); +} + +// 获取IM群成员列表 +export function MemberList(params) { + return http.request({ + url: '/imGroup/memberList', + method: 'GET', + params, + }); +} + +// 获取IM群治理配置 +export function Governance(params) { + return http.request({ + url: '/imGroup/governance', + method: 'GET', + params, + }); +} + +// 获取IM入群申请列表 +export function JoinRequestList(params) { + return http.request({ + url: '/imGroup/joinRequestList', + method: 'GET', + params, + }); +} + +// 获取IM群邀请列表 +export function InvitationList(params) { + return http.request({ + url: '/imGroup/invitationList', + method: 'GET', + params, + }); +} + +// 获取IM群公告列表 +export function AnnouncementList(params) { + return http.request({ + url: '/imGroup/announcementList', + method: 'GET', + params, + }); +} + +// 获取IM群禁言列表 +export function MuteList(params) { + return http.request({ + url: '/imGroup/muteList', + method: 'GET', + params, + }); +} + +// 获取IM群管理日志列表 +export function AdminLogList(params) { + return http.request({ + url: '/imGroup/adminLogList', + method: 'GET', + params, + }); +} diff --git a/web/src/api/imRealname/index.ts b/web/src/api/imRealname/index.ts new file mode 100644 index 0000000..b62b897 --- /dev/null +++ b/web/src/api/imRealname/index.ts @@ -0,0 +1,25 @@ +import { http } from '@/utils/http/axios'; + +export function List(params) { + return http.request({ + url: '/imRealname/list', + method: 'get', + params, + }); +} + +export function View(params) { + return http.request({ + url: '/imRealname/view', + method: 'GET', + params, + }); +} + +export function Review(params) { + return http.request({ + url: '/imRealname/review', + method: 'POST', + params, + }); +} diff --git a/web/src/api/imTenant/index.ts b/web/src/api/imTenant/index.ts new file mode 100644 index 0000000..bab11ec --- /dev/null +++ b/web/src/api/imTenant/index.ts @@ -0,0 +1,37 @@ +import { http } from '@/utils/http/axios'; + +// 获取IM租户列表 +export function List(params) { + return http.request({ + url: '/imTenant/list', + method: 'get', + params, + }); +} + +// 添加/编辑IM租户 +export function Edit(params) { + return http.request({ + url: '/imTenant/edit', + method: 'POST', + params, + }); +} + +// 修改IM租户状态 +export function Status(params) { + return http.request({ + url: '/imTenant/status', + method: 'POST', + params, + }); +} + +// 获取IM租户指定详情 +export function View(params) { + return http.request({ + url: '/imTenant/view', + method: 'GET', + params, + }); +} diff --git a/web/src/api/imUser/index.ts b/web/src/api/imUser/index.ts new file mode 100644 index 0000000..e5a058d --- /dev/null +++ b/web/src/api/imUser/index.ts @@ -0,0 +1,19 @@ +import { http } from '@/utils/http/axios'; + +// 获取IM用户列表 +export function List(params) { + return http.request({ + url: '/imUser/list', + method: 'get', + params, + }); +} + +// 获取IM用户指定详情 +export function View(params) { + return http.request({ + url: '/imUser/view', + method: 'GET', + params, + }); +} diff --git a/web/src/api/imWorkOrder/index.ts b/web/src/api/imWorkOrder/index.ts new file mode 100644 index 0000000..fa6b4e2 --- /dev/null +++ b/web/src/api/imWorkOrder/index.ts @@ -0,0 +1,28 @@ +import { http } from '@/utils/http/axios'; + +// 获取IM举报反馈工单列表 +export function List(params) { + return http.request({ + url: '/imWorkOrder/list', + method: 'get', + params, + }); +} + +// 获取IM举报反馈工单详情 +export function View(params) { + return http.request({ + url: '/imWorkOrder/view', + method: 'GET', + params, + }); +} + +// 更新IM举报反馈工单状态 +export function Status(params) { + return http.request({ + url: '/imWorkOrder/status', + method: 'POST', + params, + }); +} diff --git a/web/src/views/im/registrationInvite/BatchViewModal.vue b/web/src/views/im/registrationInvite/BatchViewModal.vue new file mode 100644 index 0000000..c81cdb8 --- /dev/null +++ b/web/src/views/im/registrationInvite/BatchViewModal.vue @@ -0,0 +1,50 @@ + + + + + + diff --git a/web/src/views/im/registrationInvite/CreateModal.vue b/web/src/views/im/registrationInvite/CreateModal.vue new file mode 100644 index 0000000..45708ec --- /dev/null +++ b/web/src/views/im/registrationInvite/CreateModal.vue @@ -0,0 +1,89 @@ + + + + diff --git a/web/src/views/im/registrationInvite/index.vue b/web/src/views/im/registrationInvite/index.vue new file mode 100644 index 0000000..4ee676c --- /dev/null +++ b/web/src/views/im/registrationInvite/index.vue @@ -0,0 +1,201 @@ + + + + + diff --git a/web/src/views/im/registrationInvite/model.ts b/web/src/views/im/registrationInvite/model.ts new file mode 100644 index 0000000..dc1701b --- /dev/null +++ b/web/src/views/im/registrationInvite/model.ts @@ -0,0 +1,144 @@ +import { computed, ref } from 'vue'; +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 = ''; + public batchId = ''; + public code = ''; + public status = ''; + public expiresAt = ''; + public usedBy = ''; + public usedAt = ''; + public requestId = ''; + public createdBy = ''; + public remark = ''; + public createdAt = ''; +} + +export const statusOptions = [ + { label: '有效', value: 'active', type: 'success' }, + { label: '已使用', value: 'used', type: 'info' }, + { label: '已过期', value: 'expired', type: 'warning' }, + { label: '已撤销', value: 'revoked', type: 'error' }, +]; + +export const schemas = ref([ + { + field: 'tenantId', + component: 'NSelect', + label: '租户', + componentProps: { + clearable: false, + options: computed(() => tenantOptions.value), + placeholder: '请选择租户', + }, + }, + { + field: 'inviteCode', + component: 'NInput', + label: '邀请码', + componentProps: { + placeholder: '完整5位,区分大小写', + maxlength: 5, + }, + }, + { + field: 'status', + component: 'NSelect', + label: '状态', + componentProps: { + clearable: true, + options: statusOptions, + placeholder: '请选择状态', + }, + }, + { + field: 'batchId', + component: 'NInput', + label: '批次ID', + componentProps: { + placeholder: '请输入批次ID', + }, + }, + { + field: 'createdBy', + component: 'NInput', + label: '创建人', + componentProps: { + placeholder: '请输入创建人ID', + }, + }, + { + field: 'createdAt', + component: 'NDatePicker', + label: '创建时间', + componentProps: { + type: 'datetimerange', + clearable: true, + shortcuts: defRangeShortcuts(), + }, + }, + { + field: 'expiresAt', + component: 'NDatePicker', + label: '到期时间', + componentProps: { + type: 'datetimerange', + clearable: true, + shortcuts: defRangeShortcuts(), + }, + }, +]); + +export const columns = [ + { title: 'ID', key: 'id', align: 'left', width: 150 }, + { title: '邀请码', key: 'code', align: 'left', width: 120 }, + { title: '状态', key: 'status', align: 'left', width: 110 }, + { title: '批次ID', key: 'batchId', align: 'left', width: 150 }, + { title: '到期时间', key: 'expiresAt', align: 'left', width: 190 }, + { title: '使用用户', key: 'usedBy', align: 'left', width: 120 }, + { title: '使用时间', key: 'usedAt', align: 'left', width: 190 }, + { title: '创建人', key: 'createdBy', align: 'left', width: 110 }, + { title: '创建时间', key: 'createdAt', align: 'left', width: 190 }, + { title: '备注', key: 'remark', align: 'left', width: 220 }, + { title: '请求ID', key: 'requestId', align: 'left', width: 220 }, +]; + +export function loadOptions() { + dict.loadOptions(['sys_switch']); +} + +export async function loadTenantOptions() { + const res = await TenantList({ page: 1, perPage: 20, state: 1 }); + tenantOptions.value = (res?.list || []).map((item) => ({ + label: `${item.name}(${item.code})`, + value: item.id, + })); + return tenantOptions.value; +} + +export function statusLabel(status: string) { + return statusOptions.find((item) => item.value === status)?.label || status; +} + +export function localRfc3339(value: number | null) { + if (!value) { + return ''; + } + const date = new Date(value); + const pad = (n: number) => `${n}`.padStart(2, '0'); + const offset = -date.getTimezoneOffset(); + const sign = offset >= 0 ? '+' : '-'; + const hours = pad(Math.floor(Math.abs(offset) / 60)); + const minutes = pad(Math.abs(offset) % 60); + return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad( + date.getHours() + )}:${pad(date.getMinutes())}:${pad(date.getSeconds())}${sign}${hours}:${minutes}`; +} diff --git a/web/src/views/imAuditLog/index.vue b/web/src/views/imAuditLog/index.vue new file mode 100644 index 0000000..b2fe921 --- /dev/null +++ b/web/src/views/imAuditLog/index.vue @@ -0,0 +1,92 @@ + + + + + diff --git a/web/src/views/imAuditLog/model.ts b/web/src/views/imAuditLog/model.ts new file mode 100644 index 0000000..5593494 --- /dev/null +++ b/web/src/views/imAuditLog/model.ts @@ -0,0 +1,119 @@ +import { computed, ref } from 'vue'; +import { cloneDeep } from 'lodash-es'; +import { FormSchema } from '@/components/Form'; +import { defRangeShortcuts } from '@/utils/dateUtil'; +import { List as TenantList } from '@/api/imTenant'; + +export const tenantOptions = ref([]); + +export class State { + public tenantId = 0; + public tenantName = ''; + public id = 0; + public actorUid = 0; + public actorRole = ''; + public action = ''; + public targetType = ''; + public targetId = ''; + public reason = ''; + public requestId = ''; + public ip = ''; + public userAgent = ''; + public beforeSnapshot = ''; + public afterSnapshot = ''; + public metadata = ''; + public createdAt = ''; + + 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: false, + options: computed(() => tenantOptions.value), + placeholder: '请选择租户', + }, + }, + { + field: 'actorUid', + component: 'NInput', + label: '操作人UID', + componentProps: { placeholder: '请输入操作人UID' }, + }, + { + field: 'action', + component: 'NInput', + label: '动作', + componentProps: { placeholder: '请输入操作类型' }, + }, + { + field: 'targetType', + component: 'NInput', + label: '目标类型', + componentProps: { placeholder: '请输入目标类型' }, + }, + { + field: 'targetId', + component: 'NInput', + label: '目标ID', + componentProps: { placeholder: '请输入目标ID' }, + }, + { + field: 'requestId', + component: 'NInput', + label: '请求ID', + componentProps: { placeholder: '请输入请求ID' }, + }, + { + field: 'keyword', + 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: '操作人UID', key: 'actorUid', align: 'left', width: 120 }, + { title: '角色', key: 'actorRole', align: 'left', width: 120 }, + { title: '动作', key: 'action', align: 'left', width: 190 }, + { title: '目标类型', key: 'targetType', align: 'left', width: 120 }, + { title: '目标ID', key: 'targetId', align: 'left', width: 170 }, + { title: '原因', key: 'reason', align: 'left', width: 220 }, + { title: '请求ID', key: 'requestId', align: 'left', width: 190 }, + { title: 'IP', key: 'ip', align: 'left', width: 130 }, + { title: '操作时间', key: 'createdAt', align: 'left', width: 180 }, +]; + +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, + })); + return tenantOptions.value; +} diff --git a/web/src/views/imAuditLog/view.vue b/web/src/views/imAuditLog/view.vue new file mode 100644 index 0000000..0b45c3b --- /dev/null +++ b/web/src/views/imAuditLog/view.vue @@ -0,0 +1,82 @@ + + + + + diff --git a/web/src/views/imGroup/governance.vue b/web/src/views/imGroup/governance.vue new file mode 100644 index 0000000..04c52d2 --- /dev/null +++ b/web/src/views/imGroup/governance.vue @@ -0,0 +1,216 @@ + + + + + diff --git a/web/src/views/imGroup/index.vue b/web/src/views/imGroup/index.vue new file mode 100644 index 0000000..0895caf --- /dev/null +++ b/web/src/views/imGroup/index.vue @@ -0,0 +1,129 @@ + + + + + diff --git a/web/src/views/imGroup/members.vue b/web/src/views/imGroup/members.vue new file mode 100644 index 0000000..5706d9d --- /dev/null +++ b/web/src/views/imGroup/members.vue @@ -0,0 +1,73 @@ + + + + + diff --git a/web/src/views/imGroup/model.ts b/web/src/views/imGroup/model.ts new file mode 100644 index 0000000..0c12098 --- /dev/null +++ b/web/src/views/imGroup/model.ts @@ -0,0 +1,460 @@ +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 tenantId = 0; // 租户ID + public tenantName = ''; // 租户名称 + public topic = ''; // Topic + public state = 0; // 状态 + public ownerId = 0; // 群主ID + public seqId = 0; // 最新消息序号 + public delId = 0; // 删除序号 + public subCnt = 0; // 成员数 + public businessMemberCount = 0; // 业务成员数 + public touchedAt = ''; // 活跃时间 + public createdAt = ''; // 创建时间 + public updatedAt = ''; // 修改时间 + public publicJson = ''; // 公开资料 + public trustedJson = ''; // 可信资料 + public tagsJson = ''; // 标签 + public auxJson = ''; // 辅助资料 + public accessJson = ''; // 访问权限 + public useBt = 0; // 是否开启BT + public groupName = ''; // 群名称 + public avatarFileId = ''; // 头像文件ID + public description = ''; // 简介 + public groupStatus = ''; // 业务状态 + public joinApproval = 0; // 入群审批 + public muteAll = 0; // 全员禁言 + public memberUploadFiles = 0; // 成员上传文件 + public memberPrivateChat = 0; // 成员私聊 + public memberInvite = 0; // 成员邀请 + public maxMemberCount = 0; // 最大成员数 + public maxAdminCount = 0; // 最大管理员数 + public settingsVersion = 0; // 治理版本 + public settingsUpdatedAt = ''; // 治理更新时间 + + constructor(state?: Partial) { + if (state) { + Object.assign(this, state); + } + } +} + +export class MemberState { + public userId = 0; // 用户ID + public nickname = ''; // 昵称 + public state = 0; // 用户状态 + public modeWant = ''; // 期望权限 + public modeGiven = ''; // 授予权限 + public recvSeqId = 0; // 接收序号 + public readSeqId = 0; // 已读序号 + public delId = 0; // 删除序号 + public deletedAt = ''; // 删除时间 + public createdAt = ''; // 加入时间 + public updatedAt = ''; // 修改时间 + public privateJson = ''; // 私有资料 + public role = ''; // 业务角色 + public memberStatus = ''; // 业务状态 + public groupNickname = ''; // 群昵称 + public pinned = 0; // 置顶 + public muteNotifications = 0; // 免打扰 + public joinSource = ''; // 加入来源 + public joinedBy = 0; // 邀请/处理人 + public joinedAt = ''; // 业务加入时间 + public leftAt = ''; // 离开时间 + public removedBy = 0; // 移除人 + public version = 0; // 版本 +} + +export class GovernanceState { + public tenantId = 0; + public tenantName = ''; + public topic = ''; + public ownerUserId = 0; + public name = ''; + public avatarFileId = ''; + public description = ''; + public groupStatus = ''; + public joinApproval = 0; + public muteAll = 0; + public memberUploadFiles = 0; + public memberPrivateChat = 0; + public memberInvite = 0; + public maxMemberCount = 0; + public maxAdminCount = 0; + public memberCount = 0; + public version = 0; + public createdBy = 0; + public dissolvedBy = 0; + public dissolvedAt = ''; + public createdAt = ''; + public updatedAt = ''; +} + +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: false, + options: computed(() => tenantOptions.value), + placeholder: '请选择租户', + }, + }, + { + field: 'topic', + component: 'NInput', + label: 'Topic', + componentProps: { + placeholder: '请输入Topic', + }, + }, + { + field: 'ownerId', + component: 'NInput', + label: '群主ID', + componentProps: { + placeholder: '请输入群主ID', + }, + }, + { + field: 'state', + component: 'NSelect', + label: '状态', + componentProps: { + clearable: true, + options: computed(() => dict.getOptions('ImGroupStateOptions')), + placeholder: '请选择状态', + }, + }, + { + field: 'groupStatus', + component: 'NSelect', + label: '业务状态', + componentProps: { + clearable: true, + options: computed(() => dict.getOptions('ImGroupStatusOptions')), + placeholder: '请选择业务状态', + }, + }, + { + field: 'createdAt', + component: 'NDatePicker', + label: '创建时间', + componentProps: { + type: 'datetimerange', + clearable: true, + shortcuts: defRangeShortcuts(), + }, + }, + { + field: 'touchedAt', + component: 'NDatePicker', + label: '活跃时间', + componentProps: { + type: 'datetimerange', + clearable: true, + shortcuts: defRangeShortcuts(), + }, + }, +]); + +// 表格列 +export const columns = [ + { + title: 'Topic', + key: 'topic', + align: 'left', + width: 220, + }, + { + title: '状态', + key: 'state', + align: 'left', + width: 110, + render(row: State) { + return dict.getLabel('ImGroupStateOptions', row.state); + }, + }, + { + title: '业务状态', + key: 'groupStatus', + align: 'left', + width: 110, + render(row: State) { + return dict.getLabel('ImGroupStatusOptions', row.groupStatus) || '-'; + }, + }, + { + title: '群名称', + key: 'groupName', + align: 'left', + width: 160, + }, + { + title: '群主ID', + key: 'ownerId', + align: 'left', + width: 120, + }, + { + title: '成员数', + key: 'businessMemberCount', + align: 'left', + width: 100, + }, + { + title: '入群审批', + key: 'joinApproval', + align: 'left', + width: 100, + render(row: State) { + return row.joinApproval ? '开启' : '关闭'; + }, + }, + { + title: '全员禁言', + key: 'muteAll', + align: 'left', + width: 100, + render(row: State) { + return row.muteAll ? '开启' : '关闭'; + }, + }, + { + title: '最新序号', + key: 'seqId', + align: 'left', + width: 110, + }, + { + title: '活跃时间', + key: 'touchedAt', + align: 'left', + width: 180, + }, + { + title: '创建时间', + key: 'createdAt', + align: 'left', + width: 180, + }, + { + title: '修改时间', + key: 'updatedAt', + align: 'left', + width: 180, + }, +]; + +export const memberColumns = [ + { + title: '用户ID', + key: 'userId', + align: 'left', + width: 120, + }, + { + title: '昵称', + key: 'nickname', + align: 'left', + width: 160, + }, + { + title: '状态', + key: 'memberStatus', + align: 'left', + width: 110, + render(row: MemberState) { + return dict.getLabel('ImGroupMemberStatusOptions', row.memberStatus) || dict.getLabel('ImUserStateOptions', row.state); + }, + }, + { + title: '角色', + key: 'role', + align: 'left', + width: 100, + render(row: MemberState) { + return dict.getLabel('ImGroupRoleOptions', row.role); + }, + }, + { + title: '群昵称', + key: 'groupNickname', + align: 'left', + width: 140, + }, + { + title: '授予权限', + key: 'modeGiven', + align: 'left', + width: 110, + }, + { + title: '期望权限', + key: 'modeWant', + align: 'left', + width: 110, + }, + { + title: '已读序号', + key: 'readSeqId', + align: 'left', + width: 110, + }, + { + title: '接收序号', + key: 'recvSeqId', + align: 'left', + width: 110, + }, + { + title: '删除时间', + key: 'deletedAt', + align: 'left', + width: 180, + }, + { + title: '业务加入时间', + key: 'joinedAt', + align: 'left', + width: 180, + }, +]; + +export const joinRequestColumns = [ + { title: '申请ID', key: 'requestId', align: 'left', width: 180 }, + { title: '申请人ID', key: 'applicantUserId', align: 'left', width: 120 }, + { title: '邀请人ID', key: 'inviterUserId', align: 'left', width: 120 }, + { title: '来源', key: 'source', align: 'left', width: 100 }, + { title: '申请消息', key: 'message', align: 'left', width: 220 }, + { + title: '状态', + key: 'status', + align: 'left', + width: 110, + render(row) { + return dict.getLabel('ImGroupRequestStatusOptions', row.status); + }, + }, + { title: '拒绝原因', key: 'rejectReason', align: 'left', width: 180 }, + { title: '处理人', key: 'handledBy', align: 'left', width: 110 }, + { title: '处理时间', key: 'handledAt', align: 'left', width: 180 }, + { title: '过期时间', key: 'expiresAt', align: 'left', width: 180 }, + { title: '创建时间', key: 'createdAt', align: 'left', width: 180 }, +]; + +export const invitationColumns = [ + { title: '邀请ID', key: 'invitationId', align: 'left', width: 180 }, + { title: '邀请人ID', key: 'inviterUserId', align: 'left', width: 120 }, + { title: '目标用户ID', key: 'targetUserId', align: 'left', width: 120 }, + { title: '邀请消息', key: 'message', align: 'left', width: 220 }, + { + title: '状态', + key: 'status', + align: 'left', + width: 110, + render(row) { + return dict.getLabel('ImGroupRequestStatusOptions', row.status); + }, + }, + { title: '结果', key: 'result', align: 'left', width: 110 }, + { title: '入群申请ID', key: 'joinRequestId', align: 'left', width: 180 }, + { title: '过期时间', key: 'expiresAt', align: 'left', width: 180 }, + { title: '处理时间', key: 'handledAt', align: 'left', width: 180 }, + { title: '创建时间', key: 'createdAt', align: 'left', width: 180 }, +]; + +export const announcementColumns = [ + { title: '公告ID', key: 'announcementId', align: 'left', width: 180 }, + { title: '发布人ID', key: 'authorUserId', align: 'left', width: 120 }, + { title: '内容', key: 'content', align: 'left', width: 320 }, + { + title: '状态', + key: 'status', + align: 'left', + width: 110, + render(row) { + return dict.getLabel('ImGroupAnnouncementStatusOptions', row.status); + }, + }, + { title: '版本', key: 'version', align: 'left', width: 90 }, + { title: '发布时间', key: 'publishedAt', align: 'left', width: 180 }, + { title: '删除人', key: 'deletedBy', align: 'left', width: 110 }, + { title: '删除时间', key: 'deletedAt', align: 'left', width: 180 }, +]; + +export const muteColumns = [ + { title: '用户ID', key: 'userId', align: 'left', width: 120 }, + { title: '禁言人', key: 'mutedBy', align: 'left', width: 120 }, + { title: '禁言时间', key: 'mutedAt', align: 'left', width: 180 }, + { title: '过期时间', key: 'expiresAt', align: 'left', width: 180 }, + { title: '原因', key: 'reason', align: 'left', width: 220 }, + { + title: '状态', + key: 'status', + align: 'left', + width: 110, + render(row) { + return dict.getLabel('ImGroupMuteStatusOptions', row.status); + }, + }, + { title: '解除人', key: 'revokedBy', align: 'left', width: 120 }, + { title: '解除时间', key: 'revokedAt', align: 'left', width: 180 }, +]; + +export const adminLogColumns = [ + { title: '日志ID', key: 'logId', align: 'left', width: 180 }, + { title: '操作人', key: 'operatorId', align: 'left', width: 120 }, + { title: '目标用户', key: 'targetUserId', align: 'left', width: 120 }, + { title: '动作', key: 'action', align: 'left', width: 160 }, + { title: '对象类型', key: 'objectType', align: 'left', width: 110 }, + { title: '对象ID', key: 'objectId', align: 'left', width: 160 }, + { title: '请求ID', key: 'requestId', align: 'left', width: 180 }, + { title: '创建时间', key: 'createdAt', align: 'left', width: 180 }, +]; + +// 加载字典数据选项 +export function loadOptions() { + dict.loadOptions([ + 'ImGroupStateOptions', + 'ImUserStateOptions', + 'ImGroupStatusOptions', + 'ImGroupRoleOptions', + 'ImGroupMemberStatusOptions', + 'ImGroupRequestStatusOptions', + 'ImGroupMuteStatusOptions', + 'ImGroupAnnouncementStatusOptions', + 'ImGroupOutboxStatusOptions', + ]); +} + +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, + })); + return tenantOptions.value; +} diff --git a/web/src/views/imGroup/view.vue b/web/src/views/imGroup/view.vue new file mode 100644 index 0000000..b70c143 --- /dev/null +++ b/web/src/views/imGroup/view.vue @@ -0,0 +1,106 @@ + + + + + diff --git a/web/src/views/imRealname/index.vue b/web/src/views/imRealname/index.vue new file mode 100644 index 0000000..187d249 --- /dev/null +++ b/web/src/views/imRealname/index.vue @@ -0,0 +1,107 @@ + + + diff --git a/web/src/views/imRealname/model.ts b/web/src/views/imRealname/model.ts new file mode 100644 index 0000000..b2ab6f9 --- /dev/null +++ b/web/src/views/imRealname/model.ts @@ -0,0 +1,118 @@ +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 tenantId = 0; + public tenantName = ''; + public id = 0; + public userId = 0; + public submissionId = ''; + public status = ''; + public maskedName = ''; + public maskedIdNumber = ''; + public idType = ''; + public idNumberHash = ''; + public reviewReason = ''; + public verifiedAt = ''; + 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: false, + options: computed(() => tenantOptions.value), + placeholder: '请选择租户', + }, + }, + { + field: 'userId', + component: 'NInput', + label: '用户ID', + componentProps: { placeholder: '请输入用户ID' }, + }, + { + field: 'submissionId', + component: 'NInput', + label: '提交ID', + componentProps: { placeholder: '请输入提交ID' }, + }, + { + field: 'status', + component: 'NSelect', + label: '状态', + componentProps: { + clearable: true, + options: computed(() => dict.getOptions('ImRealnameStatusOptions')), + placeholder: '请选择状态', + }, + }, + { + field: 'createdAt', + component: 'NDatePicker', + label: '创建时间', + componentProps: { + type: 'datetimerange', + clearable: true, + shortcuts: defRangeShortcuts(), + }, + }, +]); + +export const columns = [ + { title: 'ID', key: 'id', align: 'left', width: 80 }, + { title: '用户ID', key: 'userId', align: 'left', width: 120 }, + { title: '提交ID', key: 'submissionId', align: 'left', width: 180 }, + { + title: '状态', + key: 'status', + align: 'left', + width: 110, + render(row: State) { + return dict.getLabel('ImRealnameStatusOptions', row.status); + }, + }, + { title: '脱敏姓名', key: 'maskedName', align: 'left', width: 120 }, + { title: '脱敏证件号', key: 'maskedIdNumber', align: 'left', width: 180 }, + { title: '证件类型', key: 'idType', align: 'left', width: 120 }, + { title: '审核原因', key: 'reviewReason', align: 'left', width: 220 }, + { title: '通过时间', key: 'verifiedAt', align: 'left', width: 180 }, + { title: '创建时间', key: 'createdAt', align: 'left', width: 180 }, +]; + +export function loadOptions() { + dict.loadOptions(['ImRealnameStatusOptions']); +} + +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, + })); + return tenantOptions.value; +} diff --git a/web/src/views/imRealname/view.vue b/web/src/views/imRealname/view.vue new file mode 100644 index 0000000..a78cbe3 --- /dev/null +++ b/web/src/views/imRealname/view.vue @@ -0,0 +1,53 @@ + + + diff --git a/web/src/views/imTenant/edit.vue b/web/src/views/imTenant/edit.vue new file mode 100644 index 0000000..9382033 --- /dev/null +++ b/web/src/views/imTenant/edit.vue @@ -0,0 +1,182 @@ + + + + + diff --git a/web/src/views/imTenant/index.vue b/web/src/views/imTenant/index.vue new file mode 100644 index 0000000..8a433db --- /dev/null +++ b/web/src/views/imTenant/index.vue @@ -0,0 +1,138 @@ + + + + + diff --git a/web/src/views/imTenant/model.ts b/web/src/views/imTenant/model.ts new file mode 100644 index 0000000..e70ace1 --- /dev/null +++ b/web/src/views/imTenant/model.ts @@ -0,0 +1,218 @@ +import { computed, h, ref } from 'vue'; +import { NTag } from 'naive-ui'; +import { cloneDeep } from 'lodash-es'; +import { FormSchema } from '@/components/Form'; +import { defRangeShortcuts } from '@/utils/dateUtil'; +import { renderPopoverMemberSumma, MemberSumma } from '@/utils'; +import { useDictStore } from '@/store/modules/dict'; + +const dict = useDictStore(); + +export class State { + public id = 0; // ID + public code = ''; // 租户编码 + public name = ''; // 租户名称 + public tenantDesc = ''; // 租户描述 + public dbName = ''; // 业务库名 + public dbDsn = ''; // 独立连接串 + public state = 1; // 状态 + public maxUsers = 0; // 最大用户数 + public maxGroups = 0; // 最大群组数 + public maxGroupMembers = 0; // 最大群成员数 + public dbDsnConfigured = false; // 是否配置独立连接串 + public createdBy = 0; // 创建者 + public createdBySumma?: null | MemberSumma = null; // 创建者摘要信息 + public updatedBy = 0; // 更新者 + public updatedBySumma?: null | MemberSumma = null; // 更新者摘要信息 + 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 rules = { + code: { + required: true, + trigger: ['blur', 'input'], + type: 'string', + message: '请输入租户编码', + }, + name: { + required: true, + trigger: ['blur', 'input'], + type: 'string', + message: '请输入租户名称', + }, + state: { + required: true, + trigger: ['blur', 'change'], + type: 'number', + message: '请选择租户状态', + }, +}; + +// 表格搜索表单 +export const schemas = ref([ + { + field: 'code', + component: 'NInput', + label: '租户编码', + componentProps: { + placeholder: '请输入租户编码', + }, + }, + { + field: 'name', + component: 'NInput', + label: '租户名称', + componentProps: { + placeholder: '请输入租户名称', + }, + }, + { + field: 'dbName', + component: 'NInput', + label: '业务库名', + componentProps: { + placeholder: '请输入业务库名', + }, + }, + { + field: 'state', + component: 'NSelect', + label: '状态', + componentProps: { + clearable: true, + options: computed(() => dict.getOptions('ImTenantStateOptions')), + placeholder: '请选择状态', + }, + }, + { + field: 'createdAt', + component: 'NDatePicker', + label: '创建时间', + componentProps: { + type: 'datetimerange', + clearable: true, + shortcuts: defRangeShortcuts(), + }, + }, +]); + +// 表格列 +export const columns = [ + { + title: 'ID', + key: 'id', + align: 'left', + width: 70, + }, + { + title: '租户编码', + key: 'code', + align: 'left', + width: 150, + }, + { + title: '租户名称', + key: 'name', + align: 'left', + width: 180, + }, + { + title: '状态', + key: 'state', + align: 'left', + width: 120, + render(row: State) { + return dict.getLabel('ImTenantStateOptions', row.state); + }, + }, + { + title: '业务库名', + key: 'dbName', + align: 'left', + width: 160, + }, + { + title: '独立连接串', + key: 'dbDsnConfigured', + align: 'left', + width: 120, + render(row: State) { + return h( + NTag, + { type: row.dbDsnConfigured ? 'success' : 'default', bordered: false }, + { default: () => (row.dbDsnConfigured ? '已配置' : '未配置') } + ); + }, + }, + { + title: '最大用户', + key: 'maxUsers', + align: 'left', + width: 110, + render(row: State) { + return row.maxUsers > 0 ? row.maxUsers : '不限'; + }, + }, + { + title: '最大群组', + key: 'maxGroups', + align: 'left', + width: 110, + render(row: State) { + return row.maxGroups > 0 ? row.maxGroups : '不限'; + }, + }, + { + title: '群成员上限', + key: 'maxGroupMembers', + align: 'left', + width: 120, + render(row: State) { + return row.maxGroupMembers > 0 ? row.maxGroupMembers : '不限'; + }, + }, + { + title: '创建者', + key: 'createdBy', + align: 'left', + width: 150, + render(row: State) { + return renderPopoverMemberSumma(row.createdBySumma); + }, + }, + { + title: '创建时间', + key: 'createdAt', + align: 'left', + width: 180, + }, + { + title: '修改时间', + key: 'updatedAt', + align: 'left', + width: 180, + }, +]; + +// 加载字典数据选项 +export function loadOptions() { + dict.loadOptions(['ImTenantStateOptions']); +} diff --git a/web/src/views/imUser/index.vue b/web/src/views/imUser/index.vue new file mode 100644 index 0000000..e9636ba --- /dev/null +++ b/web/src/views/imUser/index.vue @@ -0,0 +1,105 @@ + + + + + diff --git a/web/src/views/imUser/model.ts b/web/src/views/imUser/model.ts new file mode 100644 index 0000000..d40a0a0 --- /dev/null +++ b/web/src/views/imUser/model.ts @@ -0,0 +1,202 @@ +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 tenantId = 0; // 租户ID + public tenantName = ''; // 租户名称 + public userId = 0; // 用户ID + public nickname = ''; // 昵称 + public publicAccount = ''; // 公开账号 + public gender = ''; // 性别 + public region = ''; // 地区 + public state = 0; // 账号状态 + public authVersion = 0; // 认证版本 + public passwordUpdatedAt = ''; // 密码更新时间 + public authNames = ''; // 登录标识 + public credentials = ''; // 凭证 + public lastSeen = ''; // 最后在线时间 + public userAgent = ''; // 客户端 + public createdAt = ''; // 创建时间 + public updatedAt = ''; // 修改时间 + public signature = ''; // 个性签名 + public avatarFileId = ''; // 头像文件ID + public birthDate = ''; // 出生日期 + public profileJson = ''; // Tinode Public资料 + public trustedJson = ''; // Tinode Trusted资料 + public tagsJson = ''; // Tinode标签 + public accessJson = ''; // 访问权限 + public profileVersion = 0; // 资料版本 + + 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: false, + options: computed(() => tenantOptions.value), + placeholder: '请选择租户', + }, + }, + { + field: 'userId', + component: 'NInput', + label: '用户ID', + componentProps: { + placeholder: '请输入用户ID', + }, + }, + { + field: 'nickname', + component: 'NInput', + label: '昵称', + componentProps: { + placeholder: '请输入昵称', + }, + }, + { + field: 'authName', + component: 'NInput', + label: '登录标识', + componentProps: { + placeholder: '请输入登录名/手机号/邮箱', + }, + }, + { + field: 'state', + component: 'NSelect', + label: '状态', + componentProps: { + clearable: true, + options: computed(() => dict.getOptions('ImUserStateOptions')), + placeholder: '请选择状态', + }, + }, + { + field: 'createdAt', + component: 'NDatePicker', + label: '创建时间', + componentProps: { + type: 'datetimerange', + clearable: true, + shortcuts: defRangeShortcuts(), + }, + }, + { + field: 'lastSeen', + component: 'NDatePicker', + label: '最后在线', + componentProps: { + type: 'datetimerange', + clearable: true, + shortcuts: defRangeShortcuts(), + }, + }, +]); + +// 表格列 +export const columns = [ + { + title: '用户ID', + key: 'userId', + align: 'left', + width: 120, + }, + { + title: '昵称', + key: 'nickname', + align: 'left', + width: 160, + }, + { + title: '公开账号', + key: 'publicAccount', + align: 'left', + width: 150, + }, + { + title: '状态', + key: 'state', + align: 'left', + width: 110, + render(row: State) { + return dict.getLabel('ImUserStateOptions', row.state); + }, + }, + { + title: '登录标识', + key: 'authNames', + align: 'left', + width: 240, + }, + { + title: '凭证', + key: 'credentials', + align: 'left', + width: 220, + }, + { + title: '最后在线', + key: 'lastSeen', + align: 'left', + width: 180, + }, + { + title: '客户端', + key: 'userAgent', + align: 'left', + width: 240, + }, + { + title: '创建时间', + key: 'createdAt', + align: 'left', + width: 180, + }, + { + title: '修改时间', + key: 'updatedAt', + align: 'left', + width: 180, + }, +]; + +// 加载字典数据选项 +export function loadOptions() { + dict.loadOptions(['ImUserStateOptions']); +} + +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, + })); + return tenantOptions.value; +} diff --git a/web/src/views/imUser/view.vue b/web/src/views/imUser/view.vue new file mode 100644 index 0000000..be64064 --- /dev/null +++ b/web/src/views/imUser/view.vue @@ -0,0 +1,110 @@ + + + + + diff --git a/web/src/views/imWorkOrder/index.vue b/web/src/views/imWorkOrder/index.vue new file mode 100644 index 0000000..5d53875 --- /dev/null +++ b/web/src/views/imWorkOrder/index.vue @@ -0,0 +1,135 @@ + + + + + diff --git a/web/src/views/imWorkOrder/model.ts b/web/src/views/imWorkOrder/model.ts new file mode 100644 index 0000000..72b89c7 --- /dev/null +++ b/web/src/views/imWorkOrder/model.ts @@ -0,0 +1,149 @@ +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 const workOrderTypeOptions = [ + { label: '举报', value: 'abuse' }, + { label: '反馈', value: 'feedback' }, +]; + +export class State { + public tenantId = 0; + public tenantName = ''; + public type = 'abuse'; + public id = 0; + public orderId = ''; + public userId = 0; + public targetType = ''; + public targetSummary = ''; + public category = ''; + public content = ''; + public status = ''; + public description = ''; + public contact = ''; + public evidenceJson = ''; + public snapshotJson = ''; + public clientContextJson = ''; + public includeDiagnostics = 0; + public idempotencyKey = ''; + 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: false, + options: computed(() => tenantOptions.value), + placeholder: '请选择租户', + }, + }, + { + field: 'type', + component: 'NSelect', + label: '类型', + defaultValue: 'abuse', + componentProps: { + clearable: false, + options: workOrderTypeOptions, + placeholder: '请选择类型', + }, + }, + { + field: 'userId', + component: 'NInput', + label: '提交人ID', + componentProps: { placeholder: '请输入提交人ID' }, + }, + { + field: 'status', + component: 'NSelect', + label: '状态', + componentProps: { + clearable: true, + options: computed(() => dict.getOptions('ImWorkOrderStatusOptions')), + placeholder: '请选择状态', + }, + }, + { + field: 'keyword', + component: 'NInput', + label: '关键词', + componentProps: { placeholder: '请输入内容关键词' }, + }, + { + field: 'createdAt', + component: 'NDatePicker', + label: '创建时间', + componentProps: { + type: 'datetimerange', + clearable: true, + shortcuts: defRangeShortcuts(), + }, + }, +]); + +export const columns = [ + { + title: '类型', + key: 'type', + align: 'left', + width: 90, + render(row: State) { + return row.type === 'abuse' ? '举报' : '反馈'; + }, + }, + { title: 'ID', key: 'id', align: 'left', width: 80 }, + { title: '工单ID', key: 'orderId', align: 'left', width: 180 }, + { title: '提交人ID', key: 'userId', align: 'left', width: 120 }, + { title: '目标类型', key: 'targetType', align: 'left', width: 110 }, + { title: '目标摘要', key: 'targetSummary', align: 'left', width: 220 }, + { title: '分类', key: 'category', align: 'left', width: 130 }, + { title: '内容', key: 'content', align: 'left', width: 260 }, + { + title: '状态', + key: 'status', + align: 'left', + width: 110, + render(row: State) { + return dict.getLabel('ImWorkOrderStatusOptions', row.status); + }, + }, + { title: '创建时间', key: 'createdAt', align: 'left', width: 180 }, + { title: '修改时间', key: 'updatedAt', align: 'left', width: 180 }, +]; + +export function loadOptions() { + dict.loadOptions(['ImWorkOrderStatusOptions']); +} + +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, + })); + return tenantOptions.value; +} diff --git a/web/src/views/imWorkOrder/view.vue b/web/src/views/imWorkOrder/view.vue new file mode 100644 index 0000000..d9b29a5 --- /dev/null +++ b/web/src/views/imWorkOrder/view.vue @@ -0,0 +1,89 @@ + + + + + diff --git a/web/src/views/org/user/list.vue b/web/src/views/org/user/list.vue index 22336e5..fc4bf12 100644 --- a/web/src/views/org/user/list.vue +++ b/web/src/views/org/user/list.vue @@ -104,6 +104,17 @@ + + + + + + diff --git a/web/src/views/system/config/system.vue b/web/src/views/system/config/system.vue index 81b8451..78f0da5 100644 --- a/web/src/views/system/config/system.vue +++ b/web/src/views/system/config/system.vue @@ -28,6 +28,7 @@ + @@ -70,6 +71,9 @@ }), LoginSetting = defineAsyncComponent(() => { return import('./LoginSetting.vue'); + }), + ImRegistrationSetting = defineAsyncComponent(() => { + return import('./ImRegistrationSetting.vue'); }); const typeTabList = [ { @@ -127,6 +131,11 @@ desc: '公众号/开放平台/小程序配置等', key: 11, }, + { + name: 'IM注册', + desc: 'IM注册短信发送控制', + key: 12, + }, ]; export default defineComponent({ components: { @@ -141,6 +150,7 @@ PaySetting, WechatSetting, LoginSetting, + ImRegistrationSetting, }, setup() { const router = useRouter();