feat: add IM governance admin enhancements
This commit is contained in:
@@ -0,0 +1,570 @@
|
||||
# IM 后台全量需求文档
|
||||
|
||||
> 版本:V1.0 草案
|
||||
> 编写日期:2026-08-21
|
||||
> 适用范围:HotGo 管理后台、IM 服务端、IM 客户端动态配置、控制库、租户业务库
|
||||
> 输入来源:`docs/im/IM-前后端对接文档.md`、`docs/im/IM-注册、邀请码与认证数据模型改造需求文档.md`、`docs/im/IM-HotGo注册短信开关与邀请码管理改造交接文档.md`、`docs/backstage/IM-后台需求文档.md`
|
||||
|
||||
## 1. 文档目标
|
||||
|
||||
本文从前后端对接协议、注册/邀请码/认证改造文档中,完整识别 IM 项目需要由后台管理系统承载的能力,包括:
|
||||
|
||||
1. 后台页面和业务管理模块。
|
||||
2. 平台级、租户级开关和配置。
|
||||
3. 后端统一维护、前端动态加载的字典枚举。
|
||||
4. 定时任务、Outbox、异步投递、重试和补偿任务。
|
||||
5. 客户端通过 `bootstrap`、配置接口、字典接口动态渲染的 UI 能力。
|
||||
6. 权限、审计、日志脱敏、导出和安全边界。
|
||||
|
||||
本文是后台全量需求盘点,不替代具体接口设计和数据库迁移 SQL。后续开发时,应按 HotGo 项目规范拆成独立 CRUD 模块、配置模块、任务模块和权限菜单。
|
||||
|
||||
## 2. 基础原则
|
||||
|
||||
### 2.1 多租户边界
|
||||
|
||||
当前多租户采用“一个 HotGo 控制库 + 每租户一个 IM 独立业务库”方案。
|
||||
|
||||
| 数据类型 | 存储位置 | 是否带 `tenant_id` | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| 租户元数据 | HotGo 控制库 | 是 | `im_tenant`、租户配置、平台审计、邀请码主数据 |
|
||||
| 平台全局配置 | HotGo 控制库 | 否或按配置域区分 | 例如 `hg_sys_config.group=im` |
|
||||
| 邀请码批次和主表 | HotGo 控制库 | 是 | 后台生成、查询、撤销、导出 |
|
||||
| 租户用户、群、消息、文件、组织、工单 | 租户 IM 业务库 | 否 | 通过登录态租户上下文选择库,不在业务表里保存 `tenant_id` |
|
||||
| 租户内使用事实和业务 Outbox | 租户 IM 业务库 | 否 | 例如 `im_registration_invite_uses`、`im_business_events_outbox` |
|
||||
|
||||
浏览器不能传入可信 `tenant_id`、`db_name`、`db_dsn`。平台管理员跨租户操作时,由后端基于登录身份和权限选择租户;租户管理员只能使用自身绑定的租户上下文。
|
||||
|
||||
### 2.2 HotGo 与 IM 服务职责
|
||||
|
||||
| 系统 | 职责 |
|
||||
| --- | --- |
|
||||
| HotGo 后台 | 菜单权限、后台页面、平台控制库、租户元数据、平台配置、邀请码管理、运维诊断、后台审计 |
|
||||
| IM 服务端 | 客户端注册登录、实时消息、业务状态机、租户业务库事务、WebSocket、Outbox 消费、客户端动态配置下发 |
|
||||
| 客户端 | 根据服务端返回的配置、权限、字典和策略动态渲染,不硬编码业务开关和枚举 |
|
||||
|
||||
原则上,租户内用户、群、文件、实名、举报、反馈等业务状态变更优先通过 IM 服务端管理 API 或受控业务通道完成,避免绕过 IM 的状态机和审计。当前注册邀请码和密码重置存在明确的 HotGo 直连数据库边界,必须严格按交接文档执行。
|
||||
|
||||
### 2.3 配置分层
|
||||
|
||||
| 层级 | 示例 | 管理入口 |
|
||||
| --- | --- | --- |
|
||||
| 平台全局配置 | 注册短信发送开关、短信服务商、对象存储默认配置、通用风控模板 | 平台后台 |
|
||||
| 租户基础配置 | 租户状态、企业码、名称、数据库、配额、品牌、终端地址 | 平台后台,部分租户只读 |
|
||||
| 租户业务策略 | 消息策略、文件策略、群治理、隐私默认值、工作台模块 | 企业后台 |
|
||||
| 用户个人配置 | 隐私开关、青少年模式状态、设备推送 Token | 客户端为主,后台只读或安全处置 |
|
||||
| 前端动态 UI 配置 | `bootstrap.features`、`settings_menu`、`workbench.modules`、原因字典 | IM 服务端返回,来源于后台配置和权限 |
|
||||
|
||||
## 3. 后台全量模块总览
|
||||
|
||||
| 模块 | 平台后台 | 企业后台 | 类型 | 优先级 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| 租户生命周期 | 是 | 否 | 管理页面、任务、审计 | P0 |
|
||||
| 租户配置与配额 | 是 | 部分只读或申请变更 | 配置、开关、动态 UI | P0 |
|
||||
| 注册短信开关 | 是 | 否 | 全局开关、审计 | P0 |
|
||||
| 邀请码管理 | 平台可代管 | 是 | CRUD、导出、审计 | P0 |
|
||||
| 管理员、角色、权限 | 是 | 是 | 权限、字典、审计 | P0 |
|
||||
| 用户与账号安全 | 平台可运维 | 是 | 管理页面、Outbox | P0 |
|
||||
| 组织架构 | 否 | 是 | CRUD、导入导出、动态 UI | P1 |
|
||||
| 群治理 | 平台可审计 | 是 | 配置、管理页面、字典 | P1 |
|
||||
| 消息策略与系统消息 | 平台模板 | 租户配置 | 配置、模板、任务 | P1 |
|
||||
| 文件、收藏、搜索治理 | 平台运维 | 是 | 配置、任务、审计 | P1 |
|
||||
| Push、短信、TURN、对象存储 | 是 | 租户专属配置 | 敏感配置、状态检查 | P1 |
|
||||
| 工作台应用 | 平台模板 | 是 | 动态 UI 配置 | P2 |
|
||||
| 会议 | 平台审计 | 是 | 管理页面、提醒任务 | P2 |
|
||||
| 日报、月报 | 平台审计 | 是 | 模板、统计、提醒任务 | P2 |
|
||||
| 总经理信箱 | 平台审计 | 是 | 配置、工单、动态文案 | P2 |
|
||||
| 实名认证 | 平台审计 | 是 | 审核、策略、字典 | P2 |
|
||||
| 青少年模式 | 平台模板 | 是 | 策略、动态文案、限制 | P2 |
|
||||
| 反诈与内容审核 | 平台策略 | 租户策略与处置 | 风控、字典、审计 | P2 |
|
||||
| 举报与反馈 | 平台可审计 | 是 | 原因字典、工单、导出 | P2 |
|
||||
| App 版本更新 | 是 | 可按租户覆盖 | 配置、动态 UI | P2 |
|
||||
| Outbox 与后台任务 | 是 | 只读本租户 | 运维、重试、死信 | P0 |
|
||||
| 审计与日志脱敏 | 是 | 只读本租户 | 安全基础能力 | P0 |
|
||||
|
||||
## 4. 开关与配置清单
|
||||
|
||||
### 4.1 平台全局开关
|
||||
|
||||
| 配置键 | 类型 | 默认 | 管理权限 | 使用方 | 说明 |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| `im.register_sms_enabled` | bool | true | 平台超级管理员 | IM 注册发码接口 | 控制注册验证码是否真实发送;关闭时仍返回倒计时,不暴露给客户端 |
|
||||
| `im.invite_batch_max_quantity` | int | 1000 | 平台管理员 | HotGo 邀请码管理 | 单批邀请码生成数量上限 |
|
||||
| `im.invite_export_max_rows` | int | 待定 | 平台管理员 | HotGo 导出 | 防止明文邀请码大批量泄露 |
|
||||
| `im.sms_provider_enabled` | bool | true | 平台管理员 | IM 发码服务 | 是否启用短信服务商,不等同于注册短信业务开关 |
|
||||
| `im.audit_retention_days` | int | 待定 | 平台管理员 | 审计任务 | 平台审计保留期 |
|
||||
| `im.outbox_retry_enabled` | bool | true | 平台运维 | Outbox 任务 | 是否自动重试平台/租户 Outbox |
|
||||
|
||||
`register_sms_enabled` 存在 HotGo `hg_sys_config`,真实值必须保存为 `1/0`。前端复用 `sys_switch` 时要显式映射:UI `1=开启`、`2=关闭`,不能把 `2` 直接写成 bool。
|
||||
|
||||
### 4.2 租户基础配置
|
||||
|
||||
| 配置 | 类型 | 是否动态下发 | 管理入口 | 说明 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| 租户状态 | enum | 是 | 平台后台 | `provisioning/active/suspended/deleting/deleted` |
|
||||
| 企业码 | string | 登录前解析 | 平台后台 | 客户端 `tenants/resolve` 使用 |
|
||||
| 租户名称、Logo、品牌色 | string/file | 是 | 平台后台 | 登录页、工作台、关于页展示 |
|
||||
| `db_name/db_dsn` | string | 否 | 平台后台 | 只允许后端使用,不给浏览器 |
|
||||
| 用户配额 | int | 是 | 平台后台 | 注册、导入用户、邀请加入时强校验 |
|
||||
| 群数量配额 | int | 是 | 平台后台 | 建群时校验 |
|
||||
| 群成员上限 | int | 是 | 平台/企业后台 | 建群、邀请、审批通过时校验 |
|
||||
| 文件存储配额 | int64 | 是 | 平台/企业后台 | 上传、转发、收藏、群文件共用 |
|
||||
| 终端 API 地址 | url | 登录前解析 | 平台后台 | HTTP、WebSocket、文件服务地址 |
|
||||
| 可用登录方式 | array enum | 登录前解析 | 平台后台 | 密码、手机号、企业码等 |
|
||||
| 密码策略 | json | 登录/注册/改密 | 平台/企业后台 | 长度、复杂度、历史密码、过期策略 |
|
||||
| App 更新策略 | json | 是 | 平台后台 | platform/channel 维度版本策略 |
|
||||
|
||||
### 4.3 客户端 Bootstrap 动态配置
|
||||
|
||||
`GET /v1/im/bootstrap` 是客户端登录后的 UI 和能力裁剪总入口,后台必须为以下字段提供配置来源。
|
||||
|
||||
| 字段 | 后台配置项 | 客户端影响 |
|
||||
| --- | --- | --- |
|
||||
| `features.create_group` | 是否允许用户建群 | 隐藏或禁用建群入口 |
|
||||
| `features.user_search` | 是否允许搜索用户 | 隐藏添加好友搜索入口 |
|
||||
| `features.scan_qr` | 是否启用扫码 | 隐藏二维码/扫码入口 |
|
||||
| `features.system_notifications` | 系统通知开关 | 展示系统通知 Topic |
|
||||
| `features.message_recall` | 消息撤回开关 | 长按菜单是否显示撤回 |
|
||||
| `features.message_edit` | 消息编辑开关 | 长按菜单是否显示编辑 |
|
||||
| `features.organization` | 组织架构开关 | 工作台/通讯录组织入口 |
|
||||
| `features.meetings` | 会议开关 | 工作台会议入口 |
|
||||
| `features.daily_report/monthly_report` | 日报/月报开关 | 工作台报表入口 |
|
||||
| `features.executive_mailbox` | 总经理信箱开关 | 工作台信箱入口 |
|
||||
| `message_policy.recall_window_seconds` | 撤回时间窗口 | 超时隐藏操作 |
|
||||
| `message_policy.edit_window_seconds` | 编辑时间窗口 | 超时隐藏操作 |
|
||||
| `message_policy.supported_actions` | 消息操作集合 | 转发、收藏、复制、删除等 |
|
||||
| `limits.max_file_size` | 单文件大小 | 上传前校验 |
|
||||
| `limits.max_text_length` | 文本最大长度 | 输入框限制 |
|
||||
| `limits.max_forward_targets` | 转发目标上限 | 多选转发限制 |
|
||||
| `settings_menu` | 设置菜单配置 | iOS/Android 设置页动态入口 |
|
||||
| `workbench.modules` | 工作台模块配置 | 工作台排序、图标、路由、角标 |
|
||||
|
||||
### 4.4 注册、认证与邀请码配置
|
||||
|
||||
| 配置 | 作用 | 后台入口 | 备注 |
|
||||
| --- | --- | --- | --- |
|
||||
| 注册短信发送 | 是否真实调用短信服务商 | 平台后台“IM 注册” | 全局开关 |
|
||||
| 用户名正则 | 普通用户名规则 | 平台只读配置或代码常量 | 当前固定 `^[a-z0-9]{1,10}$` |
|
||||
| 固定国家号 | 手机号补 `+86` | 平台只读配置 | 本期固定中国 |
|
||||
| 邀请码长度和字符集 | 生成规则 | 平台只读配置 | 当前固定 5 位 `[A-Za-z]` |
|
||||
| 邀请码批量上限 | 批量生成限制 | 平台后台 | 默认 1000 |
|
||||
| 邀请码有效期 | 单批/单码字段 | 企业后台 | 创建时必填,必须晚于当前时间 |
|
||||
| 邀请码明文查看权限 | 控制列表/详情是否显示明文 | 菜单权限 | 查看、查询、导出需独立审计 |
|
||||
| 密码重置策略 | 管理员重置密码规则 | 企业后台 | 写 `security.password_reset` Outbox |
|
||||
| 登录 Token 失效策略 | `auth_version` 校验 | 后台只读/运维 | 重置密码后清退旧会话 |
|
||||
|
||||
### 4.5 消息与会话策略
|
||||
|
||||
| 配置 | 类型 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| 文本最大长度 | int | 输入框、服务端发送校验 |
|
||||
| 消息撤回开关和时间窗 | bool/int | P2P、群聊均需服务端校验 |
|
||||
| 消息编辑开关和时间窗 | bool/int | 编辑后产生版本或审计 |
|
||||
| 多选转发目标上限 | int | 单选、多选、合并转发共用 |
|
||||
| 支持消息操作 | array enum | copy、forward、favorite、recall、edit、delete |
|
||||
| 已读回执策略 | enum/bool | 可受隐私和企业强制策略影响 |
|
||||
| 链接识别与风险检测 | bool/json | URL 发送前后检查,触发反诈挑战 |
|
||||
| 系统通知 Topic | string/json | 系统消息列表和角标 |
|
||||
| 消息频控 | json | 防刷、风控、举报联动 |
|
||||
|
||||
### 4.6 通讯录、好友与隐私策略
|
||||
|
||||
| 配置 | 类型 | 客户端影响 |
|
||||
| --- | --- | --- |
|
||||
| 是否允许添加好友 | bool | 添加好友入口 |
|
||||
| 是否允许精准账号搜索 | bool | 用户 ID/用户名/手机号搜索 |
|
||||
| 添加我的方式 | enum array | 账号、手机号、二维码、群聊 |
|
||||
| 好友申请验证策略 | enum | 直接添加、需要验证、禁止 |
|
||||
| 黑名单能力 | bool | 黑名单入口和服务端拦截 |
|
||||
| 资料可见性默认值 | enum | 主页字段裁剪 |
|
||||
| 最近在线可见性 | enum | 会话状态展示 |
|
||||
| 已读回执默认值 | bool | 会话回执 |
|
||||
| 企业强制隐私字段 | json | `editable=false` 时前端禁用 |
|
||||
|
||||
### 4.7 群治理配置
|
||||
|
||||
| 配置 | 类型 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| 是否允许建群 | bool | bootstrap 和建群接口 |
|
||||
| 群成员上限 | int | 加群、邀请、审批事务内校验 |
|
||||
| 入群策略 | enum | free、approval、invite_only |
|
||||
| 成员邀请权限 | enum/bool | 群成员是否可邀请别人 |
|
||||
| 成员上传文件 | bool | 群文件上传入口 |
|
||||
| 成员私聊 | bool | 群成员主页是否允许发起会话 |
|
||||
| 成员互加好友 | bool | 群资料页添加好友入口 |
|
||||
| 全员禁言 | bool | 输入框禁用 |
|
||||
| 成员禁言时长 | enum/int | 后台和群管理员使用 |
|
||||
| 管理员数量上限 | int | 添加管理员时校验 |
|
||||
| 群二维码有效期 | int | 二维码页和失效任务 |
|
||||
| 群公告策略 | json | 发布、置顶、已读统计 |
|
||||
|
||||
### 4.8 文件、收藏与搜索策略
|
||||
|
||||
| 配置 | 类型 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| 单文件大小上限 | int64 | 上传前后校验 |
|
||||
| 允许 MIME/扩展名 | array | 图片、视频、语音、普通文件按 purpose 区分 |
|
||||
| 文件 purpose 策略 | json | avatar、chat、group_file、report、abuse_evidence、feedback |
|
||||
| 文件保留期 | int | 过期清理任务 |
|
||||
| 孤儿文件清理时间窗 | int | 上传未绑定业务时清理 |
|
||||
| 文件病毒扫描 | bool/json | 同步拦截或异步隔离 |
|
||||
| 收藏总量/容量上限 | int/int64 | 收藏入口和服务端校验 |
|
||||
| 搜索开关 | bool | 聊天记录、文件、链接搜索入口 |
|
||||
| 搜索索引策略 | json | 重建、增量、失败重试 |
|
||||
|
||||
### 4.9 通话、Push 和外部服务配置
|
||||
|
||||
| 配置 | 类型 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| 音视频通话开关 | bool | 呼叫入口 |
|
||||
| 通话超时 | int | invite 超时时间 |
|
||||
| TURN/STUN 服务器 | secret/json | WebRTC ICE 配置 |
|
||||
| 通话记录保留期 | int | 后台查询和清理 |
|
||||
| APNS/FCM 配置 | secret/json | 推送服务 |
|
||||
| Push 锁屏隐私 | enum/bool | 通知内容是否显示明文 |
|
||||
| Push 模板 | json | 系统消息、好友申请、会议提醒等 |
|
||||
| 短信服务商配置 | secret/json | 验证码、通知短信 |
|
||||
| 对象存储配置 | secret/json | 文件服务 |
|
||||
| 外部 REST 插件配置 | secret/json | 扩展业务调用 |
|
||||
|
||||
### 4.10 办公协同配置
|
||||
|
||||
| 模块 | 配置 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| 工作台 | 模块 code、名称、图标、路由、排序、启用、权限 | `GET /v1/im/workbench` 动态返回 |
|
||||
| 组织架构 | 组织可见范围、是否允许跨部门搜索 | 通讯录和工作台组织入口 |
|
||||
| 会议 | 是否启用、提醒提前量、可见范围、取消策略 | 会议通知和提醒任务 |
|
||||
| 日报 | 模板、必填规则、最大长度、补交天数、提醒时间 | 普通用户和管理员统计 |
|
||||
| 月报 | 模板、必填规则、最大长度、重开流程 | 月报统计和详情 |
|
||||
| 总经理信箱 | 是否启用、匿名、必填字段、最大长度、附件策略、提示文案 | 文案必须服务端下发,不由客户端硬编码 |
|
||||
|
||||
### 4.11 安全、审核、举报与反馈配置
|
||||
|
||||
| 模块 | 配置 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| 实名认证 | 是否强制、允许跳过、提醒周期、重复身份策略、审核字段 | 影响 `bootstrap.security.realname` |
|
||||
| 青少年模式 | 策略版本、限制集合、提示文案、是否可启用 | 客户端确认时携带 `policy_version` |
|
||||
| 反诈风控 | 高风险动作、提示文案、二次确认、阻断策略、Token TTL | 返回 `RISK_CONFIRMATION_REQUIRED` |
|
||||
| 内容审核 | 审核模式、词库、插件、异步处置 | sync_block、async_hold、async_post_audit |
|
||||
| 举报 | target_type 原因字典、证据规则、合并策略、限频 | `GET /v1/im/reports/abuse/reasons` |
|
||||
| 反馈 | 类别、最大长度、联系方式、附件策略、诊断授权文案 | `GET /v1/im/feedback/config` |
|
||||
|
||||
### 4.12 App 更新配置
|
||||
|
||||
| 配置 | 类型 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| platform | enum | ios、android、desktop 等 |
|
||||
| channel | string/enum | stable、beta、enterprise |
|
||||
| latest_version | string | 最新版本 |
|
||||
| min_supported | string | 最低支持版本 |
|
||||
| update_type | enum | none、optional、recommended、force |
|
||||
| package_url/store_url | url | 安装来源 |
|
||||
| release_notes | text | 更新说明 |
|
||||
| remind_after_seconds | int | 非强制更新再次提醒间隔 |
|
||||
|
||||
## 5. 字典与枚举清单
|
||||
|
||||
后台新增枚举必须由后端统一注册字典,前端通过字典接口加载,不能在页面硬编码文本。
|
||||
|
||||
### 5.1 平台与租户
|
||||
|
||||
| 字典键建议 | 枚举 |
|
||||
| --- | --- |
|
||||
| `im_tenant_status` | provisioning、active、suspended、deleting、deleted |
|
||||
| `im_tenant_plan` | free、standard、enterprise、custom |
|
||||
| `im_tenant_config_scope` | platform、tenant、user |
|
||||
| `im_operation_result` | success、failed、partial |
|
||||
|
||||
### 5.2 注册、认证、邀请码
|
||||
|
||||
| 字典键建议 | 枚举 |
|
||||
| --- | --- |
|
||||
| `im_register_sms_status` | enabled、disabled |
|
||||
| `im_invite_status` | active、expired、revoked |
|
||||
| `im_invite_action` | create、batch_create、view、query_exact、revoke、batch_revoke、export |
|
||||
| `im_auth_scene` | register、login、forgot_password、bind_phone、close_youth_mode |
|
||||
| `im_auth_channel` | sms、password、independent_password |
|
||||
| `im_challenge_status` | pending、verified、expired、consumed、failed |
|
||||
| `im_user_status` | normal、disabled、locked、deleted |
|
||||
| `im_auth_level` | anonymous、authenticated、admin |
|
||||
|
||||
### 5.3 好友、群与消息
|
||||
|
||||
| 字典键建议 | 枚举 |
|
||||
| --- | --- |
|
||||
| `im_friend_request_status` | pending、accepted、rejected、expired、cancelled |
|
||||
| `im_friend_add_method` | account、phone、qr、group |
|
||||
| `im_profile_visibility` | public、friends、none |
|
||||
| `im_group_role` | owner、admin、member |
|
||||
| `im_group_join_policy` | free、approval、invite_only |
|
||||
| `im_group_request_status` | pending、approved、rejected、expired |
|
||||
| `im_group_mute_type` | all、member |
|
||||
| `im_message_action` | copy、forward、favorite、recall、edit、delete、multi_select |
|
||||
| `im_message_content_type` | text、image、audio、video、file、card、link、system |
|
||||
| `im_read_state` | unread、read |
|
||||
|
||||
### 5.4 文件、搜索、通话
|
||||
|
||||
| 字典键建议 | 枚举 |
|
||||
| --- | --- |
|
||||
| `im_file_purpose` | chat、user_avatar、group_avatar、group_file、daily_report、monthly_report、abuse_evidence、feedback |
|
||||
| `im_file_status` | uploaded、bound、quarantined、deleted、expired |
|
||||
| `im_file_scan_status` | pending、clean、risky、failed |
|
||||
| `im_search_index_status` | pending、indexed、failed、deleted |
|
||||
| `im_call_type` | audio、video |
|
||||
| `im_call_status` | ringing、accepted、declined、missed、cancelled、ended、timeout |
|
||||
|
||||
### 5.5 办公协同
|
||||
|
||||
| 字典键建议 | 枚举 |
|
||||
| --- | --- |
|
||||
| `im_workbench_module_status` | enabled、disabled |
|
||||
| `im_meeting_status` | scheduled、cancelled、finished |
|
||||
| `im_meeting_response` | pending、accepted、declined、tentative |
|
||||
| `im_report_type` | daily、monthly |
|
||||
| `im_report_status` | draft、submitted、reopened、archived |
|
||||
| `im_mailbox_submission_status` | received、assigned、replied、closed |
|
||||
|
||||
### 5.6 安全、审核、举报、反馈
|
||||
|
||||
| 字典键建议 | 枚举 |
|
||||
| --- | --- |
|
||||
| `im_realname_status` | unverified、pending、approved、rejected |
|
||||
| `im_youth_restriction` | night_quiet、content_filter、payment_guard、contact_limit |
|
||||
| `im_risk_level` | low、medium、high |
|
||||
| `im_risk_decision` | continue、cancel |
|
||||
| `im_moderation_mode` | off、sync_block、async_hold、async_post_audit |
|
||||
| `im_moderation_status` | pending、passed、blocked、removed |
|
||||
| `im_abuse_target_type` | user、group、message |
|
||||
| `im_abuse_report_status` | received、processing、resolved、rejected、merged |
|
||||
| `im_abuse_reason` | fraud、harassment、pornography、violence、spam、other |
|
||||
| `im_feedback_category` | bug、suggestion、complaint、other |
|
||||
| `im_feedback_status` | received、processing、resolved、closed |
|
||||
|
||||
### 5.7 终端、外部服务与错误
|
||||
|
||||
| 字典键建议 | 枚举 |
|
||||
| --- | --- |
|
||||
| `im_app_platform` | ios、android、desktop、web |
|
||||
| `im_app_channel` | stable、beta、enterprise |
|
||||
| `im_update_type` | none、optional、recommended、force |
|
||||
| `im_push_provider` | apns、fcm、vendor |
|
||||
| `im_outbox_status` | pending、processing、done、failed、dead |
|
||||
| `im_error_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、REPORT_RATE_LIMITED |
|
||||
|
||||
## 6. 定时任务与异步任务
|
||||
|
||||
### 6.1 P0 基础任务
|
||||
|
||||
| 任务 | 触发方式 | 作用 | 后台能力 |
|
||||
| --- | --- | --- | --- |
|
||||
| 租户业务库初始化 | 创建租户后异步执行 | 创建库、执行 schema、初始化系统数据 | 进度、失败原因、重试 |
|
||||
| 租户健康检查 | 定时 | 检查 DB 连接、schema version、基础 Topic | 状态看板、告警 |
|
||||
| Outbox 消费状态巡检 | 定时 | 检查积压、失败、死信 | 列表、重试、死信 |
|
||||
| `security.password_reset` 消费 | IM 轮询或 Kafka | 清退会话、拒绝旧 Token | 事件查询、重试 |
|
||||
| 注册邀请码使用投影 | IM Outbox 或投影任务 | 将租户库 `invite_uses` 汇总到控制库主表 | 使用次数、最后使用时间、幂等补偿 |
|
||||
| 审计日志脱敏与落库 | 同步/异步 | 保存操作审计,避免明文泄露 | 查询、导出、保留期 |
|
||||
|
||||
### 6.2 业务任务
|
||||
|
||||
| 任务 | 说明 |
|
||||
| --- | --- |
|
||||
| 邀请码过期状态刷新 | 邀请码可查询时实时判断过期;可增加定时任务做状态冗余和统计,不作为注册校验唯一依据 |
|
||||
| 邀请码批量生成 | 大批量生成应可异步化,支持进度、失败重试、幂等键 |
|
||||
| 文件孤儿清理 | 上传后未绑定业务的文件定期清理 |
|
||||
| 文件过期清理 | 按 purpose 和保留期清理 |
|
||||
| 病毒扫描和隔离处理 | 异步扫描结果更新文件状态并通知用户 |
|
||||
| 搜索索引重建 | 消息、文件、链接索引的全量/增量重建 |
|
||||
| 系统通知投递 | 系统消息、公告、风控提醒、会议提醒的 Push 和站内信投递 |
|
||||
| Push 重试和死信 | 第三方推送失败后的重试、死信、凭证异常告警 |
|
||||
| 会议提醒 | 按会议配置提醒参会人,支持提醒未响应者 |
|
||||
| 日报/月报提醒 | 按租户配置提醒未提交者,生成统计 |
|
||||
| 总经理信箱通知 | 新提交、分派、回复、关闭通知 |
|
||||
| 实名审核超时提醒 | 提醒审核员处理 |
|
||||
| 风控 challenge 过期 | 清理过期 challenge,记录取消/过期审计 |
|
||||
| 举报和反馈 SLA 提醒 | 超时未处理提醒,支持工单状态流转 |
|
||||
| App 更新配置缓存刷新 | 版本策略变更后刷新缓存 |
|
||||
|
||||
### 6.3 任务后台需求
|
||||
|
||||
1. 任务列表:任务名、租户、状态、进度、最近执行时间、耗时、错误原因。
|
||||
2. 任务详情:输入参数、幂等键、关联审计、关联 Outbox、重试次数。
|
||||
3. 操作:手动重试、暂停、恢复、标记死信、导出错误明细。
|
||||
4. 安全:租户管理员只能看本租户任务;平台运维可跨租户查看但必须审计。
|
||||
|
||||
## 7. 前端 UI 动态配置需求
|
||||
|
||||
### 7.1 客户端动态 UI
|
||||
|
||||
客户端不能硬编码以下 UI 能力,必须由服务端返回:
|
||||
|
||||
1. 登录前:租户品牌、企业码解析结果、终端地址、认证方式、密码策略、App 更新策略。
|
||||
2. 登录后:首页入口、`features`、`permissions`、`settings_menu`、工作台模块、系统通知 Topic。
|
||||
3. 聊天页:可执行消息操作、撤回/编辑时间窗、上传限制、转发上限、已读回执策略。
|
||||
4. 通讯录:组织架构入口、用户搜索入口、添加我的方式、黑名单入口。
|
||||
5. 群聊:入群策略、群文件入口、群成员私聊、成员邀请、禁言状态、管理员入口。
|
||||
6. 工作台:模块名称、图标、路由、排序、角标、启用状态和权限。
|
||||
7. 日报/月报:模板、校验、补交天数、是否展示管理员统计。
|
||||
8. 总经理信箱:提示文案、匿名说明、必填字段、附件规则。
|
||||
9. 实名、青少年模式、反诈:策略版本、提示文案、限制集合、确认 Token。
|
||||
10. 举报与反馈:原因、类别、最大长度、附件规则、诊断授权文案。
|
||||
|
||||
### 7.2 HotGo 管理后台动态 UI
|
||||
|
||||
HotGo 后台自身也需要动态化:
|
||||
|
||||
1. 平台管理员显示租户选择器;租户管理员隐藏租户选择器并固定当前租户。
|
||||
2. 按权限显示按钮:邀请码明文查看、导出、撤销、密码重置、跨租户查看必须独立权限。
|
||||
3. 表格状态、筛选下拉、表单选项必须从后端字典加载。
|
||||
4. 敏感配置字段只允许写入,不默认回显明文;回显使用脱敏值和“已配置”状态。
|
||||
5. 导出前必须展示范围、数量、敏感字段提示,二次确认并写审计。
|
||||
6. 表单提交不接受浏览器传入 `tenant_id/db_name/db_dsn` 作为可信路由依据。
|
||||
|
||||
## 8. 后台页面需求
|
||||
|
||||
### 8.1 平台后台
|
||||
|
||||
| 页面 | 核心能力 |
|
||||
| --- | --- |
|
||||
| 租户管理 | 创建、初始化、启停、删除流程、配置 DB、查看用量 |
|
||||
| 租户配置 | 配额、品牌、终端地址、登录方式、密码策略、功能开关 |
|
||||
| IM 注册设置 | 注册短信发送开关、批量邀请码上限、导出限制 |
|
||||
| 平台管理员 | 平台账号、角色、权限、二次认证 |
|
||||
| 外部服务配置 | SMS、Push、TURN、对象存储、审核插件 |
|
||||
| App 版本 | 按 platform/channel 配置版本更新策略 |
|
||||
| 运维看板 | 租户健康、Outbox、任务、缓存、连接状态 |
|
||||
| 平台审计 | 跨租户操作、敏感配置变更、导出记录 |
|
||||
|
||||
### 8.2 企业后台
|
||||
|
||||
| 页面 | 核心能力 |
|
||||
| --- | --- |
|
||||
| 邀请码管理 | 单个生成、批量生成、列表、精确查询、批次详情、撤销、导出、使用记录 |
|
||||
| 企业管理员 | 添加管理员、设置角色、移除管理员、权限范围 |
|
||||
| 用户管理 | 用户列表、状态、资料、重置密码、撤销会话、设备、安全事件 |
|
||||
| 组织架构 | 部门树、成员、移动、导入导出、组织权限 |
|
||||
| 群管理 | 群列表、详情、成员、管理员、禁言、转让群主、解散、群文件 |
|
||||
| 消息与系统通知 | 模板、公告、系统通知、投递记录 |
|
||||
| 文件治理 | 文件列表、用途、大小、状态、隔离、删除、下载审计 |
|
||||
| 搜索治理 | 索引状态、重建、失败明细 |
|
||||
| 工作台 | 模块配置、排序、图标、角标来源、权限绑定 |
|
||||
| 会议 | 列表、详情、取消、提醒未响应者、统计 |
|
||||
| 日报/月报 | 模板、统计、明细、提醒、重开流程 |
|
||||
| 总经理信箱 | 配置、提交列表、分派、回复、关闭 |
|
||||
| 实名审核 | 提交列表、详情、通过、拒绝、策略 |
|
||||
| 青少年模式 | 策略、文案、用户状态、独立密码重置 |
|
||||
| 反诈与审核 | 风控策略、challenge、内容审核队列、处置 |
|
||||
| 举报工单 | 原因配置、举报列表、证据、处置、合并 |
|
||||
| 意见反馈 | 类别配置、反馈列表、诊断附件、处理状态 |
|
||||
| 租户审计 | 本租户后台操作、敏感查看、导出、业务处置 |
|
||||
|
||||
## 9. 数据与接口边界
|
||||
|
||||
### 9.1 控制库建议承载
|
||||
|
||||
| 表或配置 | 说明 |
|
||||
| --- | --- |
|
||||
| `im_tenant` | 租户元数据、状态、企业码、DB 路由 |
|
||||
| `im_tenant_config` | 租户配置、配额、功能开关、品牌 |
|
||||
| `hg_sys_config` | 平台全局配置,例如注册短信发送 |
|
||||
| `im_registration_invite_batches` | 邀请码批次 |
|
||||
| `im_registration_invite_codes` | 邀请码主表、明文码、状态、使用统计投影 |
|
||||
| `im_admin_operation_audit` | IM 后台业务审计 |
|
||||
| `hg_admin_member.tenant_id` | 后台管理员绑定 IM 租户 |
|
||||
| 平台 Outbox/任务表 | 平台任务、投递、重试、死信 |
|
||||
|
||||
### 9.2 租户业务库建议承载
|
||||
|
||||
| 表 | 说明 |
|
||||
| --- | --- |
|
||||
| `im_users` | 用户主数据,目标包含 `username` 非空唯一、`phone` 可空唯一 |
|
||||
| `im_auth` | 只按 `userid` 保存 Basic 密码凭据,删除 `scheme/uname` |
|
||||
| `im_user_security` | `auth_version`、安全状态 |
|
||||
| `im_registration_invite_uses` | 邀请码使用事实,只由 IM 注册事务写 |
|
||||
| `im_business_events_outbox` | 租户内业务事件,例如密码重置、邀请码使用 |
|
||||
| 用户、组织、群、文件、会议、报表、信箱、实名、举报、反馈业务表 | 不带 `tenant_id`,由选库隔离 |
|
||||
|
||||
### 9.3 注册认证迁移后台需求
|
||||
|
||||
1. 增加迁移状态看板:每个租户当前 schema version、迁移批次、成功/失败数。
|
||||
2. 存量用户 username 回填报告:冲突、非法字符、保留账号、自动生成结果。
|
||||
3. phone 规范化报告:可转为 `+86`、重复冲突、格式异常。
|
||||
4. `im_auth` 双读/切换/清理阶段状态展示。
|
||||
5. 迁移前后登录回归数据:用户名登录、手机号登录、忘记密码、管理员重置密码。
|
||||
6. 失败租户支持重试和导出错误明细。
|
||||
|
||||
## 10. 权限、审计与日志脱敏
|
||||
|
||||
### 10.1 权限点
|
||||
|
||||
P0 必须独立拆分以下权限:
|
||||
|
||||
1. 注册短信开关读取、修改。
|
||||
2. 邀请码列表、创建、批量创建、明文查看、精确查询、撤销、批量撤销、导出。
|
||||
3. 租户创建、启停、DB 配置查看、DB 配置修改。
|
||||
4. 用户查看、状态修改、重置密码、撤销会话、设备查看。
|
||||
5. 审计查看、审计导出。
|
||||
6. Outbox 查看、重试、标记死信。
|
||||
7. 外部服务敏感配置查看状态、修改。
|
||||
|
||||
### 10.2 审计范围
|
||||
|
||||
必须审计:
|
||||
|
||||
1. 注册短信开关变更。
|
||||
2. 邀请码生成、批量生成、明文查询/查看、导出、撤销。
|
||||
3. 管理员重置密码、撤销会话、禁用用户。
|
||||
4. 租户创建、启停、删除流程、DB 路由配置变更。
|
||||
5. 群管理员变更、禁言、转让群主、解散群。
|
||||
6. 文件下载敏感证据、隔离、删除。
|
||||
7. 实名审核、举报处置、反馈处理。
|
||||
8. 风控挑战确认、取消、阻断。
|
||||
9. 任何跨租户查看、导出和代操作。
|
||||
|
||||
审计字段至少包含:`request_id`、`tenant_id`、`operator_id`、`operator_role`、`action`、`target_type`、`target_id`、`result`、`ip`、`user_agent`、`detail_json`、`created_at`。
|
||||
|
||||
### 10.3 日志脱敏
|
||||
|
||||
访问日志、错误日志、审计摘要、导出任务日志不得记录以下明文:
|
||||
|
||||
1. 邀请码、验证码、密码、独立密码。
|
||||
2. `Authorization`、`serviceSecret`、短信/Push/对象存储密钥。
|
||||
3. `db_dsn`、数据库密码。
|
||||
4. 身份证件、实名原始图片、敏感证据文件临时 URL。
|
||||
|
||||
字段名至少覆盖:`inviteCode`、`invite_code`、`code`、`verification_code`、`password`、`secret`、`serviceSecret`、`service_secret`、`authorization`、`db_dsn`。
|
||||
|
||||
## 11. 验收清单
|
||||
|
||||
### 11.1 P0
|
||||
|
||||
1. 平台管理员可管理租户生命周期、配置和配额,租户管理员无法越权。
|
||||
2. 每个租户业务库独立,租户业务表不依赖 `tenant_id` 过滤。
|
||||
3. 注册短信开关只有平台超级管理员可改,值保存为 `1/0`,IM 服务缓存期内生效。
|
||||
4. 邀请码支持单个、批量、列表、精确查询、撤销、导出和使用统计。
|
||||
5. 邀请码明文查看、查询、导出都有独立权限和审计。
|
||||
6. 管理员重置密码写入 `im_auth`、递增 `auth_version`、写 `security.password_reset` Outbox。
|
||||
7. `bootstrap` 能返回功能开关、消息策略、限制、设置菜单、工作台基础配置。
|
||||
8. `hg_sys_log` 和业务日志不会泄露邀请码、密码、验证码、Token、密钥和 DSN。
|
||||
|
||||
### 11.2 P1
|
||||
|
||||
1. 用户、组织、群治理、文件治理、系统通知、Push/SMS/TURN/对象存储配置可后台管理。
|
||||
2. 群成员上限、用户配额、文件配额、消息策略在并发事务中强校验。
|
||||
3. 文件清理、Outbox 重试、Push 重试、搜索索引任务可观察、可重试。
|
||||
4. 前端所有状态、下拉、按钮权限从字典和权限接口动态加载。
|
||||
|
||||
### 11.3 P2
|
||||
|
||||
1. 工作台、会议、日报、月报、总经理信箱配置能动态影响客户端。
|
||||
2. 实名、青少年模式、反诈、举报、反馈具备策略配置、工单处理、审计。
|
||||
3. App 更新策略按 platform/channel 下发,异常时不误触发强制更新。
|
||||
4. 敏感证据、实名资料、诊断包具备权限、保留期和下载审计。
|
||||
|
||||
## 12. 已发现冲突与待确认项
|
||||
|
||||
1. 注册规则存在文档冲突:`IM-注册、邀请码与认证数据模型改造需求文档.md` 定义用户名和手机号是独立字段,二者同时填写时都保存;`IM-HotGo注册短信开关与邀请码管理改造交接文档.md` 的 3.1 仍写“手机号非空时忽略用户名”。建议以新的注册模型文档为准,并同步修正交接文档。
|
||||
2. 邀请码批次和主表目前按交接文档放在 HotGo 控制库,带 `tenant_id`;邀请码使用事实放在租户业务库。该设计不违反“租户业务表不带 tenant_id”,但需要在实现中明确控制库和业务库边界。
|
||||
3. 邀请码过期是否需要物理状态刷新任务待定。注册校验必须实时判断 `expires_at`,定时任务只能做冗余状态和统计。
|
||||
4. 注册短信开关当前是平台全局配置;未来若需要企业级差异,需迁移到 `im_tenant_config` 并定义优先级。
|
||||
5. 当前交接文档允许 HotGo 对部分租户库直连操作;长期是否统一收敛到 IM 管理 API 或 Kafka,需要在运维网络条件稳定后再评审。
|
||||
6. 开发/测试环境固定验证码 `123456` 不能进入生产;生产必须使用强随机验证码和真实短信服务商响应。
|
||||
@@ -0,0 +1,211 @@
|
||||
# IM 未实现增强项
|
||||
|
||||
> 更新时间:2026-08-21
|
||||
> 范围:HotGo IM 后台、IM 服务端配合项、客户端动态配置来源
|
||||
|
||||
本文用于记录当前后台已识别但尚未完成的增强项,作为后续开发排期和验收依据。当前多租户方案为“一个 HotGo 控制库 + 每租户一个独立 IM 业务库”,租户业务表不带 `tenant_id`,后台必须通过登录态解析租户并选择租户库。
|
||||
|
||||
## 已完成基线
|
||||
|
||||
1. 邀请码管理:单个生成、批量生成、列表、批次详情、撤销、批量撤销、导出、注册短信开关。
|
||||
2. 租户管理:列表、详情、编辑、状态、用量/健康、删除前检查、进入删除流程、恢复。
|
||||
3. 用户管理:列表、详情、设备、重置密码、撤销会话、账号状态停用/恢复。
|
||||
4. 审计日志:列表、详情、导出。
|
||||
5. Outbox 运维:列表、详情、重试、标记死信,覆盖 `im_business_events_outbox` 和 `im_group_events_outbox`。
|
||||
6. 租户边界:用户、审计、群、实名、工单、Outbox 等后台接口已按登录态解析租户范围。
|
||||
7. 群治理处置:治理配置更新、解散群、成员禁言、解除禁言。
|
||||
|
||||
## 剩余 P0
|
||||
|
||||
### P0-1 Bootstrap 动态配置来源
|
||||
|
||||
状态:未实现。
|
||||
|
||||
目标:
|
||||
|
||||
1. 后台提供 `bootstrap` 所需配置来源,包括 `features`、`message_policy`、`limits`、`settings_menu`、`workbench.modules`。
|
||||
2. IM 服务端从后台配置或控制库读取后,下发给客户端。
|
||||
3. 客户端不硬编码功能开关、消息策略、设置菜单和工作台基础入口。
|
||||
|
||||
建议实现:
|
||||
|
||||
1. 扩展 `im_tenant_config` 或新增独立配置表。
|
||||
2. 使用 GoFrame CLI 重新生成 DAO/entity/do,禁止手改生成文件。
|
||||
3. 后台提供租户配置页面,敏感字段脱敏回显。
|
||||
4. IM 服务端实现配置读取和缓存,缓存失效策略单独定义。
|
||||
|
||||
依赖:
|
||||
|
||||
1. 控制库迁移 SQL。
|
||||
2. IM 服务端 `bootstrap` 接口对接。
|
||||
3. 客户端按返回配置裁剪 UI。
|
||||
|
||||
### P0-2 完整租户配置与配额
|
||||
|
||||
状态:部分实现。
|
||||
|
||||
已实现:
|
||||
|
||||
1. `max_users`
|
||||
2. `max_groups`
|
||||
3. `max_group_members`
|
||||
4. 租户状态、企业码、名称、DB 路由
|
||||
|
||||
未实现:
|
||||
|
||||
1. 品牌 Logo、品牌色、登录页展示配置。
|
||||
2. HTTP API、WebSocket、文件服务等终端地址。
|
||||
3. 可用登录方式。
|
||||
4. 密码策略。
|
||||
5. 文件存储配额。
|
||||
6. 功能开关配置。
|
||||
7. App 更新策略基础配置。
|
||||
|
||||
建议实现:
|
||||
|
||||
1. 将基础配额、品牌、终端地址、登录方式、密码策略拆成清晰字段或 JSON 配置。
|
||||
2. 对 `db_dsn`、密钥类字段只保存不明文回显。
|
||||
3. 平台管理员可编辑,租户管理员按权限只读或申请变更。
|
||||
|
||||
### P0-3 日志脱敏统一治理
|
||||
|
||||
状态:未实现。
|
||||
|
||||
目标:
|
||||
|
||||
1. 访问日志、错误日志、审计摘要、导出任务日志不得泄露敏感字段。
|
||||
2. 覆盖邀请码、验证码、密码、Token、服务密钥、数据库 DSN、实名证件信息。
|
||||
|
||||
至少覆盖字段:
|
||||
|
||||
1. `inviteCode`
|
||||
2. `invite_code`
|
||||
3. `code`
|
||||
4. `verification_code`
|
||||
5. `password`
|
||||
6. `secret`
|
||||
7. `serviceSecret`
|
||||
8. `service_secret`
|
||||
9. `authorization`
|
||||
10. `db_dsn`
|
||||
11. `id_number`
|
||||
12. `token`
|
||||
|
||||
建议实现:
|
||||
|
||||
1. 在统一响应/日志中间件或日志写入前增加脱敏工具。
|
||||
2. 对业务审计 `detail_json` 做字段级脱敏。
|
||||
3. 补充单元测试,覆盖嵌套 JSON、大小写字段、数组对象。
|
||||
|
||||
### P0-4 邀请码明文查看与精确查询审计拆分
|
||||
|
||||
状态:部分实现。
|
||||
|
||||
已实现:
|
||||
|
||||
1. 邀请码生成、批量生成、列表、批次详情、撤销、批量撤销、导出。
|
||||
2. 基础菜单权限。
|
||||
|
||||
待确认/增强:
|
||||
|
||||
1. 明文查看是否需要独立权限点。
|
||||
2. 精确查询邀请码是否需要独立权限点。
|
||||
3. 导出明文邀请码是否需要二次确认和独立审计。
|
||||
4. 查询/查看/导出操作是否全部写入业务审计。
|
||||
|
||||
建议实现:
|
||||
|
||||
1. 增加 `imInvitePlainView` 或等价权限。
|
||||
2. 列表默认脱敏,只有具备权限时显示明文。
|
||||
3. 精确查询、明文查看、导出记录审计,审计中不写邀请码明文。
|
||||
|
||||
## 后续 P1
|
||||
|
||||
### P1-1 群治理处置动作
|
||||
|
||||
状态:部分实现。
|
||||
|
||||
已实现:
|
||||
|
||||
1. 群列表、详情。
|
||||
2. 成员列表。
|
||||
3. 治理配置查看。
|
||||
4. 入群申请、邀请、公告、禁言、管理日志列表。
|
||||
|
||||
未实现:
|
||||
|
||||
1. 入群审批通过/拒绝。
|
||||
2. 设置/取消管理员。
|
||||
3. 转让群主。
|
||||
4. 群文件处置。
|
||||
|
||||
已增强:
|
||||
|
||||
1. 治理配置更新。
|
||||
2. 全员禁言开关。
|
||||
3. 单人禁言/解禁。
|
||||
4. 解散群。
|
||||
|
||||
### P1-2 文件治理
|
||||
|
||||
状态:未实现。
|
||||
|
||||
能力:
|
||||
|
||||
1. 文件列表。
|
||||
2. 文件用途、大小、状态查询。
|
||||
3. 隔离、删除。
|
||||
4. 下载审计。
|
||||
5. 孤儿文件和过期文件清理任务。
|
||||
|
||||
### P1-3 外部服务配置
|
||||
|
||||
状态:未实现 IM 专属配置。
|
||||
|
||||
范围:
|
||||
|
||||
1. SMS 服务商。
|
||||
2. Push/APNS/FCM。
|
||||
3. TURN/STUN。
|
||||
4. 对象存储。
|
||||
5. 审核插件。
|
||||
|
||||
要求:
|
||||
|
||||
1. 敏感字段只写入,不明文回显。
|
||||
2. 提供“已配置/未配置”和连通性检查。
|
||||
3. 配置变更写审计。
|
||||
|
||||
### P1-4 任务看板
|
||||
|
||||
状态:未实现统一任务后台。
|
||||
|
||||
范围:
|
||||
|
||||
1. 租户健康巡检。
|
||||
2. Outbox 巡检。
|
||||
3. 文件清理。
|
||||
4. Push 重试。
|
||||
5. 搜索索引重建。
|
||||
6. 邀请码投影补偿。
|
||||
|
||||
## 后续 P2
|
||||
|
||||
1. 工作台模块配置。
|
||||
2. 会议。
|
||||
3. 日报/月报。
|
||||
4. 总经理信箱。
|
||||
5. App 版本更新策略。
|
||||
6. 青少年模式。
|
||||
7. 反诈与内容审核策略。
|
||||
8. 举报/反馈原因配置、SLA、合并、导出。
|
||||
9. 实名审核策略配置。
|
||||
|
||||
## 验收提示
|
||||
|
||||
1. 所有后台租户业务操作必须通过登录态解析租户,不能信任浏览器传入的 `tenantId`。
|
||||
2. 平台管理员跨租户操作必须选择租户并写审计。
|
||||
3. 租户管理员隐藏租户选择器,后端使用绑定租户。
|
||||
4. 所有新增按钮必须有独立菜单权限。
|
||||
5. 前端字典、状态、下拉选项必须从后端字典加载。
|
||||
6. 敏感字段不得在日志、审计、导出文件中明文出现。
|
||||
@@ -54,6 +54,38 @@ type GovernanceRes struct {
|
||||
*sysin.ImGroupGovernanceModel
|
||||
}
|
||||
|
||||
// GovernanceUpdateReq 更新IM群治理配置
|
||||
type GovernanceUpdateReq struct {
|
||||
g.Meta `path:"/imGroup/governanceUpdate" method:"post" tags:"IM群组" summary:"更新IM群治理配置"`
|
||||
sysin.ImGroupGovernanceUpdateInp
|
||||
}
|
||||
|
||||
type GovernanceUpdateRes struct{}
|
||||
|
||||
// DissolveReq 解散IM群
|
||||
type DissolveReq struct {
|
||||
g.Meta `path:"/imGroup/dissolve" method:"post" tags:"IM群组" summary:"解散IM群"`
|
||||
sysin.ImGroupDissolveInp
|
||||
}
|
||||
|
||||
type DissolveRes struct{}
|
||||
|
||||
// MuteReq 禁言IM群成员
|
||||
type MuteReq struct {
|
||||
g.Meta `path:"/imGroup/mute" method:"post" tags:"IM群组" summary:"禁言IM群成员"`
|
||||
sysin.ImGroupMuteInp
|
||||
}
|
||||
|
||||
type MuteRes struct{}
|
||||
|
||||
// UnmuteReq 解除IM群成员禁言
|
||||
type UnmuteReq struct {
|
||||
g.Meta `path:"/imGroup/unmute" method:"post" tags:"IM群组" summary:"解除IM群成员禁言"`
|
||||
sysin.ImGroupUnmuteInp
|
||||
}
|
||||
|
||||
type UnmuteRes struct{}
|
||||
|
||||
// JoinRequestListReq 查询IM入群申请列表
|
||||
type JoinRequestListReq struct {
|
||||
g.Meta `path:"/imGroup/joinRequestList" method:"get" tags:"IM群组" summary:"获取IM入群申请列表"`
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
// Package imoutbox
|
||||
// @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 imoutbox
|
||||
|
||||
import (
|
||||
"hotgo/internal/model/input/form"
|
||||
"hotgo/internal/model/input/sysin"
|
||||
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
)
|
||||
|
||||
type ListReq struct {
|
||||
g.Meta `path:"/imOutbox/list" method:"get" tags:"IM Outbox" summary:"获取IM Outbox列表"`
|
||||
sysin.ImOutboxListInp
|
||||
}
|
||||
|
||||
type ListRes struct {
|
||||
form.PageRes
|
||||
List []*sysin.ImOutboxListModel `json:"list" dc:"数据列表"`
|
||||
}
|
||||
|
||||
type ViewReq struct {
|
||||
g.Meta `path:"/imOutbox/view" method:"get" tags:"IM Outbox" summary:"获取IM Outbox详情"`
|
||||
sysin.ImOutboxViewInp
|
||||
}
|
||||
|
||||
type ViewRes struct {
|
||||
*sysin.ImOutboxViewModel
|
||||
}
|
||||
|
||||
type RetryReq struct {
|
||||
g.Meta `path:"/imOutbox/retry" method:"post" tags:"IM Outbox" summary:"重试IM Outbox事件"`
|
||||
sysin.ImOutboxActionInp
|
||||
}
|
||||
|
||||
type RetryRes struct{}
|
||||
|
||||
type DeadReq struct {
|
||||
g.Meta `path:"/imOutbox/dead" method:"post" tags:"IM Outbox" summary:"标记IM Outbox死信"`
|
||||
sysin.ImOutboxActionInp
|
||||
}
|
||||
|
||||
type DeadRes struct{}
|
||||
@@ -0,0 +1,38 @@
|
||||
// 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("ImOutboxSourceOptions", "IM Outbox来源选项", ImOutboxSourceOptions)
|
||||
dict.RegisterEnums("ImOutboxStatusOptions", "IM Outbox状态选项", ImOutboxStatusOptions)
|
||||
}
|
||||
|
||||
const (
|
||||
ImOutboxSourceBusiness = "business"
|
||||
ImOutboxSourceGroup = "group"
|
||||
|
||||
ImOutboxStatusPending = "pending"
|
||||
ImOutboxStatusDelivered = "delivered"
|
||||
ImOutboxStatusFailed = "failed"
|
||||
ImOutboxStatusDead = "dead"
|
||||
)
|
||||
|
||||
var ImOutboxSourceOptions = []*model.Option{
|
||||
dict.GenPrimaryOption(ImOutboxSourceBusiness, "业务事件"),
|
||||
dict.GenInfoOption(ImOutboxSourceGroup, "群事件"),
|
||||
}
|
||||
|
||||
var ImOutboxStatusOptions = []*model.Option{
|
||||
dict.GenWarningOption(ImOutboxStatusPending, "待投递"),
|
||||
dict.GenSuccessOption(ImOutboxStatusDelivered, "已投递"),
|
||||
dict.GenErrorOption(ImOutboxStatusFailed, "失败"),
|
||||
dict.GenDefaultOption(ImOutboxStatusDead, "死信"),
|
||||
}
|
||||
@@ -75,6 +75,30 @@ func (c *cImGroup) Governance(ctx context.Context, req *imgroup.GovernanceReq) (
|
||||
return
|
||||
}
|
||||
|
||||
// GovernanceUpdate 更新IM群治理配置
|
||||
func (c *cImGroup) GovernanceUpdate(ctx context.Context, req *imgroup.GovernanceUpdateReq) (res *imgroup.GovernanceUpdateRes, err error) {
|
||||
err = service.SysImGroup().GovernanceUpdate(ctx, &req.ImGroupGovernanceUpdateInp)
|
||||
return
|
||||
}
|
||||
|
||||
// Dissolve 解散IM群
|
||||
func (c *cImGroup) Dissolve(ctx context.Context, req *imgroup.DissolveReq) (res *imgroup.DissolveRes, err error) {
|
||||
err = service.SysImGroup().Dissolve(ctx, &req.ImGroupDissolveInp)
|
||||
return
|
||||
}
|
||||
|
||||
// Mute 禁言IM群成员
|
||||
func (c *cImGroup) Mute(ctx context.Context, req *imgroup.MuteReq) (res *imgroup.MuteRes, err error) {
|
||||
err = service.SysImGroup().Mute(ctx, &req.ImGroupMuteInp)
|
||||
return
|
||||
}
|
||||
|
||||
// Unmute 解除IM群成员禁言
|
||||
func (c *cImGroup) Unmute(ctx context.Context, req *imgroup.UnmuteReq) (res *imgroup.UnmuteRes, err error) {
|
||||
err = service.SysImGroup().Unmute(ctx, &req.ImGroupUnmuteInp)
|
||||
return
|
||||
}
|
||||
|
||||
// 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)
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
// 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/imoutbox"
|
||||
"hotgo/internal/model/input/sysin"
|
||||
"hotgo/internal/service"
|
||||
)
|
||||
|
||||
var (
|
||||
ImOutbox = cImOutbox{}
|
||||
)
|
||||
|
||||
type cImOutbox struct{}
|
||||
|
||||
func (c *cImOutbox) List(ctx context.Context, req *imoutbox.ListReq) (res *imoutbox.ListRes, err error) {
|
||||
list, totalCount, err := service.SysImOutbox().List(ctx, &req.ImOutboxListInp)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if list == nil {
|
||||
list = []*sysin.ImOutboxListModel{}
|
||||
}
|
||||
|
||||
res = new(imoutbox.ListRes)
|
||||
res.List = list
|
||||
res.PageRes.Pack(req, totalCount)
|
||||
return
|
||||
}
|
||||
|
||||
func (c *cImOutbox) View(ctx context.Context, req *imoutbox.ViewReq) (res *imoutbox.ViewRes, err error) {
|
||||
data, err := service.SysImOutbox().View(ctx, &req.ImOutboxViewInp)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
res = new(imoutbox.ViewRes)
|
||||
res.ImOutboxViewModel = data
|
||||
return
|
||||
}
|
||||
|
||||
func (c *cImOutbox) Retry(ctx context.Context, req *imoutbox.RetryReq) (res *imoutbox.RetryRes, err error) {
|
||||
err = service.SysImOutbox().Retry(ctx, &req.ImOutboxActionInp)
|
||||
return
|
||||
}
|
||||
|
||||
func (c *cImOutbox) Dead(ctx context.Context, req *imoutbox.DeadReq) (res *imoutbox.DeadRes, err error) {
|
||||
err = service.SysImOutbox().Dead(ctx, &req.ImOutboxActionInp)
|
||||
return
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"hotgo/internal/service"
|
||||
"hotgo/utility/simple"
|
||||
|
||||
"github.com/gogf/gf/v2/database/gdb"
|
||||
"github.com/gogf/gf/v2/encoding/gjson"
|
||||
"github.com/gogf/gf/v2/errors/gerror"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
@@ -80,6 +81,14 @@ func ensureImTenantActive(ctx context.Context, tenantId int64) (err error) {
|
||||
return nil
|
||||
}
|
||||
|
||||
func resolveImTenantDB(ctx context.Context, selectedTenantId int64) (db gdb.DB, tenant *entity.ImTenant, err error) {
|
||||
scope, err := resolveImAdminScope(ctx, selectedTenantId)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return imtenant.DB(ctx, scope.TenantId)
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
@@ -7,12 +7,20 @@ package sys
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"hotgo/internal/consts"
|
||||
"hotgo/internal/library/contexts"
|
||||
"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/gctx"
|
||||
"github.com/gogf/gf/v2/os/gtime"
|
||||
"github.com/gogf/gf/v2/util/guid"
|
||||
)
|
||||
|
||||
type sSysImGroup struct{}
|
||||
@@ -31,7 +39,7 @@ func (s *sSysImGroup) List(ctx context.Context, in *sysin.ImGroupListInp) (list
|
||||
return
|
||||
}
|
||||
|
||||
db, tenant, err := imtenant.DB(ctx, in.TenantId)
|
||||
db, tenant, err := resolveImTenantDB(ctx, in.TenantId)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
@@ -114,7 +122,7 @@ func (s *sSysImGroup) View(ctx context.Context, in *sysin.ImGroupViewInp) (res *
|
||||
return
|
||||
}
|
||||
|
||||
db, tenant, err := imtenant.DB(ctx, in.TenantId)
|
||||
db, tenant, err := resolveImTenantDB(ctx, in.TenantId)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
@@ -172,7 +180,7 @@ func (s *sSysImGroup) MemberList(ctx context.Context, in *sysin.ImGroupMemberLis
|
||||
return
|
||||
}
|
||||
|
||||
db, _, err := imtenant.DB(ctx, in.TenantId)
|
||||
db, _, err := resolveImTenantDB(ctx, in.TenantId)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
@@ -284,7 +292,7 @@ func (s *sSysImGroup) Governance(ctx context.Context, in *sysin.ImGroupTopicInp)
|
||||
if err = in.Filter(ctx); err != nil {
|
||||
return
|
||||
}
|
||||
db, tenant, err := imtenant.DB(ctx, in.TenantId)
|
||||
db, tenant, err := resolveImTenantDB(ctx, in.TenantId)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -324,12 +332,276 @@ func (s *sSysImGroup) Governance(ctx context.Context, in *sysin.ImGroupTopicInp)
|
||||
return
|
||||
}
|
||||
|
||||
// GovernanceUpdate 更新IM群治理配置
|
||||
func (s *sSysImGroup) GovernanceUpdate(ctx context.Context, in *sysin.ImGroupGovernanceUpdateInp) (err error) {
|
||||
if err = in.Filter(ctx); err != nil {
|
||||
return
|
||||
}
|
||||
db, _, err := resolveImTenantDB(ctx, in.TenantId)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
actor := contexts.GetUserId(ctx)
|
||||
requestId := gctx.CtxId(ctx)
|
||||
if requestId == "" {
|
||||
requestId = guid.S()
|
||||
}
|
||||
now := gtime.Now()
|
||||
return db.Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
|
||||
before, txErr := s.groupGovernanceSnapshot(ctx, tx, in.Topic)
|
||||
if txErr != nil {
|
||||
return txErr
|
||||
}
|
||||
if before == nil {
|
||||
return gerror.New("群治理配置不存在")
|
||||
}
|
||||
if before["group_status"].String() == consts.ImGroupStatusDissolved {
|
||||
return gerror.New("已解散群不能修改治理配置")
|
||||
}
|
||||
if _, txErr = tx.Exec(`UPDATE im_group_settings SET
|
||||
join_approval=?,mute_all=?,member_upload_files=?,member_private_chat=?,member_invite=?,
|
||||
max_member_count=?,max_admin_count=?,version=version+1,updated_at=?
|
||||
WHERE topic=?`,
|
||||
in.JoinApproval, in.MuteAll, in.MemberUploadFiles, in.MemberPrivateChat, in.MemberInvite,
|
||||
in.MaxMemberCount, in.MaxAdminCount, now, in.Topic); txErr != nil {
|
||||
return txErr
|
||||
}
|
||||
after, txErr := s.groupGovernanceSnapshot(ctx, tx, in.Topic)
|
||||
if txErr != nil {
|
||||
return txErr
|
||||
}
|
||||
if txErr = s.insertGroupAdminLog(ctx, tx, in.Topic, actor, 0, "governance_update", "group_settings", in.Topic, before.Map(), after.Map(), requestId); txErr != nil {
|
||||
return txErr
|
||||
}
|
||||
return s.insertGroupEvent(ctx, tx, in.Topic, actor, "group.governance_updated", g.Map{
|
||||
"topic": in.Topic, "operator_id": actor, "request_id": requestId,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// Dissolve 解散IM群
|
||||
func (s *sSysImGroup) Dissolve(ctx context.Context, in *sysin.ImGroupDissolveInp) (err error) {
|
||||
if err = in.Filter(ctx); err != nil {
|
||||
return
|
||||
}
|
||||
db, _, err := resolveImTenantDB(ctx, in.TenantId)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
actor := contexts.GetUserId(ctx)
|
||||
requestId := gctx.CtxId(ctx)
|
||||
if requestId == "" {
|
||||
requestId = guid.S()
|
||||
}
|
||||
now := gtime.Now()
|
||||
return db.Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
|
||||
before, txErr := s.groupGovernanceSnapshot(ctx, tx, in.Topic)
|
||||
if txErr != nil {
|
||||
return txErr
|
||||
}
|
||||
if before == nil {
|
||||
return gerror.New("群治理配置不存在")
|
||||
}
|
||||
if before["group_status"].String() == consts.ImGroupStatusDissolved {
|
||||
return gerror.New("群已解散")
|
||||
}
|
||||
result, txErr := tx.Exec(`UPDATE im_group_settings
|
||||
SET group_status=?,dissolved_by=?,dissolved_at=?,version=version+1,updated_at=?
|
||||
WHERE topic=? AND group_status<>?`,
|
||||
consts.ImGroupStatusDissolved, actor, now, now, in.Topic, consts.ImGroupStatusDissolved)
|
||||
if txErr != nil {
|
||||
return txErr
|
||||
}
|
||||
if affected, _ := result.RowsAffected(); affected == 0 {
|
||||
return gerror.New("群已解散")
|
||||
}
|
||||
after, txErr := s.groupGovernanceSnapshot(ctx, tx, in.Topic)
|
||||
if txErr != nil {
|
||||
return txErr
|
||||
}
|
||||
if txErr = s.insertGroupAdminLog(ctx, tx, in.Topic, actor, 0, "group_dissolve", "group", in.Topic, before.Map(), after.Map(), requestId); txErr != nil {
|
||||
return txErr
|
||||
}
|
||||
return s.insertGroupEvent(ctx, tx, in.Topic, actor, "group.dissolved", g.Map{
|
||||
"topic": in.Topic, "operator_id": actor, "request_id": requestId, "reason": in.Reason,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// Mute 禁言IM群成员
|
||||
func (s *sSysImGroup) Mute(ctx context.Context, in *sysin.ImGroupMuteInp) (err error) {
|
||||
if err = in.Filter(ctx); err != nil {
|
||||
return
|
||||
}
|
||||
db, _, err := resolveImTenantDB(ctx, in.TenantId)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
actor := contexts.GetUserId(ctx)
|
||||
requestId := gctx.CtxId(ctx)
|
||||
if requestId == "" {
|
||||
requestId = guid.S()
|
||||
}
|
||||
now := gtime.Now()
|
||||
return db.Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
|
||||
if txErr := s.ensureActiveGroupMember(ctx, tx, in.Topic, in.UserId); txErr != nil {
|
||||
return txErr
|
||||
}
|
||||
before, txErr := s.groupMuteSnapshot(ctx, tx, in.Topic, in.UserId)
|
||||
if txErr != nil {
|
||||
return txErr
|
||||
}
|
||||
muteId, txErr := imtenant.NextSnowflakeID(ctx)
|
||||
if txErr != nil {
|
||||
return gerror.Wrap(txErr, "生成禁言ID失败")
|
||||
}
|
||||
if _, txErr = tx.Exec(`INSERT INTO im_group_mutes
|
||||
(id,topic,user_id,muted_by,muted_at,expires_at,reason,status,version)
|
||||
VALUES(?,?,?,?,?,?,?, ?,1)
|
||||
ON DUPLICATE KEY UPDATE muted_by=VALUES(muted_by),muted_at=VALUES(muted_at),
|
||||
expires_at=VALUES(expires_at),reason=VALUES(reason),status=VALUES(status),
|
||||
revoked_by=NULL,revoked_at=NULL,version=version+1,updated_at=CURRENT_TIMESTAMP(3)`,
|
||||
muteId, in.Topic, in.UserId, actor, now, in.ExpiresAt, in.Reason, consts.ImGroupMuteStatusActive); txErr != nil {
|
||||
return txErr
|
||||
}
|
||||
after, txErr := s.groupMuteSnapshot(ctx, tx, in.Topic, in.UserId)
|
||||
if txErr != nil {
|
||||
return txErr
|
||||
}
|
||||
if txErr = s.insertGroupAdminLog(ctx, tx, in.Topic, actor, in.UserId, "member_mute", "member", fmt.Sprintf("%d", in.UserId), recordMap(before), recordMap(after), requestId); txErr != nil {
|
||||
return txErr
|
||||
}
|
||||
return s.insertGroupEvent(ctx, tx, in.Topic, actor, "group.mute.updated", g.Map{
|
||||
"topic": in.Topic, "user_id": in.UserId, "muted": true, "expires_at": in.ExpiresAt, "request_id": requestId,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// Unmute 解除IM群成员禁言
|
||||
func (s *sSysImGroup) Unmute(ctx context.Context, in *sysin.ImGroupUnmuteInp) (err error) {
|
||||
if err = in.Filter(ctx); err != nil {
|
||||
return
|
||||
}
|
||||
db, _, err := resolveImTenantDB(ctx, in.TenantId)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
actor := contexts.GetUserId(ctx)
|
||||
requestId := gctx.CtxId(ctx)
|
||||
if requestId == "" {
|
||||
requestId = guid.S()
|
||||
}
|
||||
now := gtime.Now()
|
||||
return db.Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
|
||||
before, txErr := s.groupMuteSnapshot(ctx, tx, in.Topic, in.UserId)
|
||||
if txErr != nil {
|
||||
return txErr
|
||||
}
|
||||
if before == nil || before["status"].String() != consts.ImGroupMuteStatusActive {
|
||||
return gerror.New("成员未处于禁言状态")
|
||||
}
|
||||
result, txErr := tx.Exec(`UPDATE im_group_mutes
|
||||
SET status=?,revoked_by=?,revoked_at=?,version=version+1,updated_at=?
|
||||
WHERE topic=? AND user_id=? AND status=?`,
|
||||
consts.ImGroupMuteStatusRevoked, actor, now, now, in.Topic, in.UserId, consts.ImGroupMuteStatusActive)
|
||||
if txErr != nil {
|
||||
return txErr
|
||||
}
|
||||
if affected, _ := result.RowsAffected(); affected == 0 {
|
||||
return gerror.New("成员未处于禁言状态")
|
||||
}
|
||||
after, txErr := s.groupMuteSnapshot(ctx, tx, in.Topic, in.UserId)
|
||||
if txErr != nil {
|
||||
return txErr
|
||||
}
|
||||
if txErr = s.insertGroupAdminLog(ctx, tx, in.Topic, actor, in.UserId, "member_unmute", "member", fmt.Sprintf("%d", in.UserId), recordMap(before), recordMap(after), requestId); txErr != nil {
|
||||
return txErr
|
||||
}
|
||||
return s.insertGroupEvent(ctx, tx, in.Topic, actor, "group.mute.updated", g.Map{
|
||||
"topic": in.Topic, "user_id": in.UserId, "muted": false, "request_id": requestId,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func (s *sSysImGroup) groupGovernanceSnapshot(ctx context.Context, tx gdb.TX, topic string) (res gdb.Record, err error) {
|
||||
return tx.Model("im_group_settings").Safe().Ctx(ctx).
|
||||
Fields(
|
||||
"topic", "owner_user_id", "name", "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", topic).
|
||||
One()
|
||||
}
|
||||
|
||||
func (s *sSysImGroup) groupMuteSnapshot(ctx context.Context, tx gdb.TX, topic string, userId int64) (res gdb.Record, err error) {
|
||||
return tx.Model("im_group_mutes").Safe().Ctx(ctx).
|
||||
Fields(
|
||||
"topic", "user_id", "muted_by", "muted_at", "expires_at", "reason", "status",
|
||||
"revoked_by", "revoked_at", "version", "created_at", "updated_at",
|
||||
).
|
||||
Where("topic", topic).
|
||||
Where("user_id", userId).
|
||||
One()
|
||||
}
|
||||
|
||||
func (s *sSysImGroup) ensureActiveGroupMember(ctx context.Context, tx gdb.TX, topic string, userId int64) error {
|
||||
count, err := tx.Model("im_group_members").Safe().Ctx(ctx).
|
||||
Where("topic", topic).
|
||||
Where("user_id", userId).
|
||||
Where("member_status", consts.ImGroupMemberStatusActive).
|
||||
Count()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if count == 0 {
|
||||
return gerror.New("成员不在群内或不是正常状态")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func recordMap(record gdb.Record) any {
|
||||
if record == nil {
|
||||
return nil
|
||||
}
|
||||
return record.Map()
|
||||
}
|
||||
|
||||
func (s *sSysImGroup) insertGroupAdminLog(ctx context.Context, tx gdb.TX, topic string, operatorId int64, targetUserId int64, action string, objectType string, objectId string, beforeData any, afterData any, requestId string) error {
|
||||
id, err := imtenant.NextSnowflakeID(ctx)
|
||||
if err != nil {
|
||||
return gerror.Wrap(err, "生成群管理日志ID失败")
|
||||
}
|
||||
logId := fmt.Sprintf("gal_%d", id)
|
||||
beforeJson, _ := json.Marshal(beforeData)
|
||||
afterJson, _ := json.Marshal(afterData)
|
||||
_, err = tx.Exec(`INSERT INTO im_group_admin_logs
|
||||
(log_id,topic,operator_id,target_user_id,action,object_type,object_id,before_data,after_data,request_id,idempotency_key)
|
||||
VALUES(?,?,?,?,?,?,?,CAST(? AS JSON),CAST(? AS JSON),?,?)`,
|
||||
logId, topic, operatorId, targetUserId, action, objectType, objectId, string(beforeJson), string(afterJson), requestId, requestId)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *sSysImGroup) insertGroupEvent(ctx context.Context, tx gdb.TX, topic string, actorId int64, eventType string, payload any) error {
|
||||
id, err := imtenant.NextSnowflakeID(ctx)
|
||||
if err != nil {
|
||||
return gerror.Wrap(err, "生成群事件ID失败")
|
||||
}
|
||||
payloadJson, _ := json.Marshal(payload)
|
||||
_, err = tx.Exec(`INSERT INTO im_group_events_outbox
|
||||
(event_id,topic,actor_user_id,event_type,payload,status)
|
||||
VALUES(?,?,?,?,CAST(? AS JSON),'pending')`,
|
||||
fmt.Sprintf("gev_%d", id), topic, actorId, eventType, string(payloadJson))
|
||||
return err
|
||||
}
|
||||
|
||||
// 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)
|
||||
db, _, err := resolveImTenantDB(ctx, in.TenantId)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
@@ -352,7 +624,7 @@ func (s *sSysImGroup) InvitationList(ctx context.Context, in *sysin.ImGroupInvit
|
||||
if err = in.Filter(ctx); err != nil {
|
||||
return
|
||||
}
|
||||
db, _, err := imtenant.DB(ctx, in.TenantId)
|
||||
db, _, err := resolveImTenantDB(ctx, in.TenantId)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
@@ -378,7 +650,7 @@ func (s *sSysImGroup) AnnouncementList(ctx context.Context, in *sysin.ImGroupAnn
|
||||
if err = in.Filter(ctx); err != nil {
|
||||
return
|
||||
}
|
||||
db, _, err := imtenant.DB(ctx, in.TenantId)
|
||||
db, _, err := resolveImTenantDB(ctx, in.TenantId)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
@@ -398,7 +670,7 @@ func (s *sSysImGroup) MuteList(ctx context.Context, in *sysin.ImGroupMuteListInp
|
||||
if err = in.Filter(ctx); err != nil {
|
||||
return
|
||||
}
|
||||
db, _, err := imtenant.DB(ctx, in.TenantId)
|
||||
db, _, err := resolveImTenantDB(ctx, in.TenantId)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
@@ -421,7 +693,7 @@ func (s *sSysImGroup) AdminLogList(ctx context.Context, in *sysin.ImGroupAdminLo
|
||||
if err = in.Filter(ctx); err != nil {
|
||||
return
|
||||
}
|
||||
db, _, err := imtenant.DB(ctx, in.TenantId)
|
||||
db, _, err := resolveImTenantDB(ctx, in.TenantId)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
// 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/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"
|
||||
"github.com/gogf/gf/v2/os/gtime"
|
||||
)
|
||||
|
||||
type sSysImOutbox struct{}
|
||||
|
||||
func NewSysImOutbox() *sSysImOutbox {
|
||||
return &sSysImOutbox{}
|
||||
}
|
||||
|
||||
func init() {
|
||||
service.RegisterSysImOutbox(NewSysImOutbox())
|
||||
}
|
||||
|
||||
func (s *sSysImOutbox) List(ctx context.Context, in *sysin.ImOutboxListInp) (list []*sysin.ImOutboxListModel, totalCount int, err error) {
|
||||
if err = in.Filter(ctx); err != nil {
|
||||
return
|
||||
}
|
||||
db, tenant, err := s.tenantDB(ctx, in.TenantId)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
mod := s.model(ctx, db, in.Source)
|
||||
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 Outbox列表失败,请稍后重试!")
|
||||
return
|
||||
}
|
||||
for _, item := range list {
|
||||
item.TenantId = tenant.Id
|
||||
item.TenantName = tenant.Name
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func (s *sSysImOutbox) View(ctx context.Context, in *sysin.ImOutboxViewInp) (res *sysin.ImOutboxViewModel, err error) {
|
||||
if err = in.Filter(ctx); err != nil {
|
||||
return
|
||||
}
|
||||
db, tenant, err := s.tenantDB(ctx, in.TenantId)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err = s.model(ctx, db, in.Source).Where("id", in.Id).Scan(&res); err != nil {
|
||||
return nil, gerror.Wrap(err, "获取IM Outbox详情失败,请稍后重试!")
|
||||
}
|
||||
if res == nil {
|
||||
return nil, nil
|
||||
}
|
||||
res.TenantId = tenant.Id
|
||||
res.TenantName = tenant.Name
|
||||
return
|
||||
}
|
||||
|
||||
func (s *sSysImOutbox) Retry(ctx context.Context, in *sysin.ImOutboxActionInp) (err error) {
|
||||
if err = in.Filter(ctx); err != nil {
|
||||
return
|
||||
}
|
||||
return s.updateStatus(ctx, in, sysin.ImOutboxStatusPending)
|
||||
}
|
||||
|
||||
func (s *sSysImOutbox) Dead(ctx context.Context, in *sysin.ImOutboxActionInp) (err error) {
|
||||
if err = in.Filter(ctx); err != nil {
|
||||
return
|
||||
}
|
||||
return s.updateStatus(ctx, in, sysin.ImOutboxStatusDead)
|
||||
}
|
||||
|
||||
func (s *sSysImOutbox) tenantDB(ctx context.Context, selectedTenantId int64) (db gdb.DB, tenant *entity.ImTenant, err error) {
|
||||
scope, err := resolveImAdminScope(ctx, selectedTenantId)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return imtenant.DB(ctx, scope.TenantId)
|
||||
}
|
||||
|
||||
func (s *sSysImOutbox) model(ctx context.Context, db gdb.DB, source string) *gdb.Model {
|
||||
if source == sysin.ImOutboxSourceGroup {
|
||||
return db.Model("im_group_events_outbox").Safe().Ctx(ctx).Fields(
|
||||
"'group' source",
|
||||
"id",
|
||||
"event_id",
|
||||
"'topic' target_type",
|
||||
"topic target",
|
||||
"actor_user_id",
|
||||
"event_type",
|
||||
"CAST(payload AS CHAR) payload",
|
||||
"status",
|
||||
"attempts",
|
||||
"next_retry_at",
|
||||
"delivered_at",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
)
|
||||
}
|
||||
return db.Model("im_business_events_outbox").Safe().Ctx(ctx).Fields(
|
||||
"'business' source",
|
||||
"id",
|
||||
"event_id",
|
||||
"target_type",
|
||||
"target",
|
||||
"actor_user_id",
|
||||
"event_type",
|
||||
"CAST(payload AS CHAR) payload",
|
||||
"status",
|
||||
"attempts",
|
||||
"next_retry_at",
|
||||
"delivered_at",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
)
|
||||
}
|
||||
|
||||
func (s *sSysImOutbox) applyWhere(mod *gdb.Model, in *sysin.ImOutboxListInp) *gdb.Model {
|
||||
if in.EventId != "" {
|
||||
mod = mod.WhereLike("event_id", "%"+in.EventId+"%")
|
||||
}
|
||||
if in.EventType != "" {
|
||||
mod = mod.Where("event_type", in.EventType)
|
||||
}
|
||||
if in.TargetType != "" && in.Source == sysin.ImOutboxSourceBusiness {
|
||||
mod = mod.Where("target_type", in.TargetType)
|
||||
}
|
||||
if in.Target != "" {
|
||||
if in.Source == sysin.ImOutboxSourceGroup {
|
||||
mod = mod.WhereLike("topic", "%"+in.Target+"%")
|
||||
} else {
|
||||
mod = mod.WhereLike("target", "%"+in.Target+"%")
|
||||
}
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
func (s *sSysImOutbox) updateStatus(ctx context.Context, in *sysin.ImOutboxActionInp, status string) (err error) {
|
||||
db, _, err := s.tenantDB(ctx, in.TenantId)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
table := "im_business_events_outbox"
|
||||
if in.Source == sysin.ImOutboxSourceGroup {
|
||||
table = "im_group_events_outbox"
|
||||
}
|
||||
current, err := db.Model(table).Safe().Ctx(ctx).Fields("status").Where("id", in.Id).Value()
|
||||
if err != nil {
|
||||
return gerror.Wrap(err, "获取IM Outbox事件失败,请稍后重试!")
|
||||
}
|
||||
if current.IsNil() {
|
||||
return gerror.New("IM Outbox事件不存在")
|
||||
}
|
||||
if current.String() == sysin.ImOutboxStatusDelivered {
|
||||
return gerror.New("已投递事件不能重新处置")
|
||||
}
|
||||
|
||||
data := g.Map{
|
||||
"status": status,
|
||||
"updated_at": gtime.Now(),
|
||||
}
|
||||
if status == sysin.ImOutboxStatusPending {
|
||||
data["next_retry_at"] = nil
|
||||
data["delivered_at"] = nil
|
||||
}
|
||||
if _, err = db.Model(table).Safe().Ctx(ctx).Where("id", in.Id).Data(data).Update(); err != nil {
|
||||
return gerror.Wrap(err, "更新IM Outbox事件失败,请稍后重试!")
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -8,7 +8,6 @@ package sys
|
||||
import (
|
||||
"context"
|
||||
"hotgo/internal/consts"
|
||||
"hotgo/internal/library/imtenant"
|
||||
"hotgo/internal/model/input/sysin"
|
||||
"hotgo/internal/service"
|
||||
|
||||
@@ -33,7 +32,7 @@ func (s *sSysImRealname) List(ctx context.Context, in *sysin.ImRealnameListInp)
|
||||
if err = in.Filter(ctx); err != nil {
|
||||
return
|
||||
}
|
||||
db, tenant, err := imtenant.DB(ctx, in.TenantId)
|
||||
db, tenant, err := resolveImTenantDB(ctx, in.TenantId)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
@@ -91,7 +90,7 @@ func (s *sSysImRealname) View(ctx context.Context, in *sysin.ImRealnameViewInp)
|
||||
if err = in.Filter(ctx); err != nil {
|
||||
return
|
||||
}
|
||||
db, tenant, err := imtenant.DB(ctx, in.TenantId)
|
||||
db, tenant, err := resolveImTenantDB(ctx, in.TenantId)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -129,7 +128,7 @@ func (s *sSysImRealname) Review(ctx context.Context, in *sysin.ImRealnameReviewI
|
||||
if err = in.Filter(ctx); err != nil {
|
||||
return
|
||||
}
|
||||
db, _, err := imtenant.DB(ctx, in.TenantId)
|
||||
db, _, err := resolveImTenantDB(ctx, in.TenantId)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@ package sys
|
||||
|
||||
import (
|
||||
"context"
|
||||
"hotgo/internal/library/imtenant"
|
||||
"hotgo/internal/model/input/sysin"
|
||||
"hotgo/internal/service"
|
||||
|
||||
@@ -32,7 +31,7 @@ func (s *sSysImWorkOrder) List(ctx context.Context, in *sysin.ImWorkOrderListInp
|
||||
if err = in.Filter(ctx); err != nil {
|
||||
return
|
||||
}
|
||||
db, tenant, err := imtenant.DB(ctx, in.TenantId)
|
||||
db, tenant, err := resolveImTenantDB(ctx, in.TenantId)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
@@ -112,7 +111,7 @@ func (s *sSysImWorkOrder) View(ctx context.Context, in *sysin.ImWorkOrderViewInp
|
||||
if err = in.Filter(ctx); err != nil {
|
||||
return
|
||||
}
|
||||
db, tenant, err := imtenant.DB(ctx, in.TenantId)
|
||||
db, tenant, err := resolveImTenantDB(ctx, in.TenantId)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -190,7 +189,7 @@ func (s *sSysImWorkOrder) Status(ctx context.Context, in *sysin.ImWorkOrderStatu
|
||||
if err = in.Filter(ctx); err != nil {
|
||||
return
|
||||
}
|
||||
db, _, err := imtenant.DB(ctx, in.TenantId)
|
||||
db, _, err := resolveImTenantDB(ctx, in.TenantId)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -26,9 +26,6 @@ type ImGroupListInp struct {
|
||||
}
|
||||
|
||||
func (in *ImGroupListInp) Filter(ctx context.Context) (err error) {
|
||||
if in.TenantId <= 0 {
|
||||
return gerror.New("请选择租户")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
@@ -72,9 +69,6 @@ type ImGroupViewInp struct {
|
||||
}
|
||||
|
||||
func (in *ImGroupViewInp) Filter(ctx context.Context) (err error) {
|
||||
if in.TenantId <= 0 {
|
||||
return gerror.New("请选择租户")
|
||||
}
|
||||
if in.Topic == "" {
|
||||
return gerror.New("Topic不能为空")
|
||||
}
|
||||
@@ -98,9 +92,6 @@ type ImGroupMemberListInp struct {
|
||||
}
|
||||
|
||||
func (in *ImGroupMemberListInp) Filter(ctx context.Context) (err error) {
|
||||
if in.TenantId <= 0 {
|
||||
return gerror.New("请选择租户")
|
||||
}
|
||||
if in.Topic == "" {
|
||||
return gerror.New("Topic不能为空")
|
||||
}
|
||||
@@ -140,9 +131,6 @@ type ImGroupTopicInp struct {
|
||||
}
|
||||
|
||||
func (in *ImGroupTopicInp) Filter(ctx context.Context) (err error) {
|
||||
if in.TenantId <= 0 {
|
||||
return gerror.New("请选择租户")
|
||||
}
|
||||
if in.Topic == "" {
|
||||
return gerror.New("Topic不能为空")
|
||||
}
|
||||
@@ -174,6 +162,92 @@ type ImGroupGovernanceModel struct {
|
||||
UpdatedAt *gtime.Time `json:"updatedAt" dc:"修改时间"`
|
||||
}
|
||||
|
||||
type ImGroupGovernanceUpdateInp struct {
|
||||
ImGroupTopicInp
|
||||
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:"最大管理员数"`
|
||||
}
|
||||
|
||||
func (in *ImGroupGovernanceUpdateInp) Filter(ctx context.Context) (err error) {
|
||||
if err = in.ImGroupTopicInp.Filter(ctx); err != nil {
|
||||
return
|
||||
}
|
||||
if in.JoinApproval != 0 && in.JoinApproval != 1 {
|
||||
return gerror.New("入群审批值不正确")
|
||||
}
|
||||
if in.MuteAll != 0 && in.MuteAll != 1 {
|
||||
return gerror.New("全员禁言值不正确")
|
||||
}
|
||||
if in.MemberUploadFiles != 0 && in.MemberUploadFiles != 1 {
|
||||
return gerror.New("成员上传文件值不正确")
|
||||
}
|
||||
if in.MemberPrivateChat != 0 && in.MemberPrivateChat != 1 {
|
||||
return gerror.New("成员私聊值不正确")
|
||||
}
|
||||
if in.MemberInvite != 0 && in.MemberInvite != 1 {
|
||||
return gerror.New("成员邀请值不正确")
|
||||
}
|
||||
if in.MaxMemberCount < 0 || in.MaxAdminCount < 0 {
|
||||
return gerror.New("人数上限不能小于0")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
type ImGroupDissolveInp struct {
|
||||
ImGroupTopicInp
|
||||
Reason string `json:"reason" dc:"解散原因"`
|
||||
}
|
||||
|
||||
func (in *ImGroupDissolveInp) Filter(ctx context.Context) (err error) {
|
||||
if err = in.ImGroupTopicInp.Filter(ctx); err != nil {
|
||||
return
|
||||
}
|
||||
if len([]rune(in.Reason)) > 200 {
|
||||
return gerror.New("解散原因不能超过200个字符")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
type ImGroupMuteInp struct {
|
||||
ImGroupTopicInp
|
||||
UserId int64 `json:"userId" v:"required#用户ID不能为空" dc:"用户ID"`
|
||||
ExpiresAt *gtime.Time `json:"expiresAt" dc:"过期时间"`
|
||||
Reason string `json:"reason" dc:"禁言原因"`
|
||||
}
|
||||
|
||||
func (in *ImGroupMuteInp) Filter(ctx context.Context) (err error) {
|
||||
if err = in.ImGroupTopicInp.Filter(ctx); err != nil {
|
||||
return
|
||||
}
|
||||
if in.UserId <= 0 {
|
||||
return gerror.New("用户ID不能为空")
|
||||
}
|
||||
if len([]rune(in.Reason)) > 200 {
|
||||
return gerror.New("禁言原因不能超过200个字符")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
type ImGroupUnmuteInp struct {
|
||||
ImGroupTopicInp
|
||||
UserId int64 `json:"userId" v:"required#用户ID不能为空" dc:"用户ID"`
|
||||
}
|
||||
|
||||
func (in *ImGroupUnmuteInp) Filter(ctx context.Context) (err error) {
|
||||
if err = in.ImGroupTopicInp.Filter(ctx); err != nil {
|
||||
return
|
||||
}
|
||||
if in.UserId <= 0 {
|
||||
return gerror.New("用户ID不能为空")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
type ImGroupJoinRequestListInp struct {
|
||||
form.PageReq
|
||||
ImGroupTopicInp
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
// 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"
|
||||
)
|
||||
|
||||
const (
|
||||
ImOutboxSourceBusiness = "business"
|
||||
ImOutboxSourceGroup = "group"
|
||||
|
||||
ImOutboxStatusPending = "pending"
|
||||
ImOutboxStatusDelivered = "delivered"
|
||||
ImOutboxStatusFailed = "failed"
|
||||
ImOutboxStatusDead = "dead"
|
||||
)
|
||||
|
||||
type ImOutboxListInp struct {
|
||||
form.PageReq
|
||||
TenantId int64 `json:"tenantId" dc:"租户ID"`
|
||||
Source string `json:"source" dc:"事件来源"`
|
||||
EventId string `json:"eventId" dc:"事件ID"`
|
||||
EventType string `json:"eventType" dc:"事件类型"`
|
||||
TargetType string `json:"targetType" dc:"目标类型"`
|
||||
Target string `json:"target" dc:"目标"`
|
||||
Status string `json:"status" dc:"状态"`
|
||||
CreatedAt []*gtime.Time `json:"createdAt" dc:"创建时间"`
|
||||
}
|
||||
|
||||
func (in *ImOutboxListInp) Filter(ctx context.Context) (err error) {
|
||||
if in.Source == "" {
|
||||
in.Source = ImOutboxSourceBusiness
|
||||
}
|
||||
if in.Source != ImOutboxSourceBusiness && in.Source != ImOutboxSourceGroup {
|
||||
return gerror.New("Outbox来源不正确")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
type ImOutboxListModel struct {
|
||||
TenantId int64 `json:"tenantId" dc:"租户ID"`
|
||||
TenantName string `json:"tenantName" dc:"租户名称"`
|
||||
Source string `json:"source" dc:"事件来源"`
|
||||
Id int64 `json:"id" dc:"ID"`
|
||||
EventId string `json:"eventId" dc:"事件ID"`
|
||||
TargetType string `json:"targetType" dc:"目标类型"`
|
||||
Target string `json:"target" dc:"目标"`
|
||||
ActorUserId int64 `json:"actorUserId" dc:"操作用户ID"`
|
||||
EventType string `json:"eventType" dc:"事件类型"`
|
||||
Payload string `json:"payload" dc:"事件负载"`
|
||||
Status string `json:"status" dc:"状态"`
|
||||
Attempts int `json:"attempts" dc:"尝试次数"`
|
||||
NextRetryAt *gtime.Time `json:"nextRetryAt" dc:"下次重试时间"`
|
||||
DeliveredAt *gtime.Time `json:"deliveredAt" dc:"投递时间"`
|
||||
CreatedAt *gtime.Time `json:"createdAt" dc:"创建时间"`
|
||||
UpdatedAt *gtime.Time `json:"updatedAt" dc:"更新时间"`
|
||||
}
|
||||
|
||||
type ImOutboxViewInp struct {
|
||||
TenantId int64 `json:"tenantId" dc:"租户ID"`
|
||||
Source string `json:"source" v:"required#事件来源不能为空" dc:"事件来源"`
|
||||
Id int64 `json:"id" v:"required#ID不能为空" dc:"ID"`
|
||||
}
|
||||
|
||||
func (in *ImOutboxViewInp) Filter(ctx context.Context) (err error) {
|
||||
if in.Source != ImOutboxSourceBusiness && in.Source != ImOutboxSourceGroup {
|
||||
return gerror.New("Outbox来源不正确")
|
||||
}
|
||||
if in.Id <= 0 {
|
||||
return gerror.New("ID不能为空")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
type ImOutboxViewModel struct {
|
||||
ImOutboxListModel
|
||||
}
|
||||
|
||||
type ImOutboxActionInp struct {
|
||||
TenantId int64 `json:"tenantId" dc:"租户ID"`
|
||||
Source string `json:"source" v:"required#事件来源不能为空" dc:"事件来源"`
|
||||
Id int64 `json:"id" v:"required#ID不能为空" dc:"ID"`
|
||||
}
|
||||
|
||||
func (in *ImOutboxActionInp) Filter(ctx context.Context) (err error) {
|
||||
if in.Source != ImOutboxSourceBusiness && in.Source != ImOutboxSourceGroup {
|
||||
return gerror.New("Outbox来源不正确")
|
||||
}
|
||||
if in.Id <= 0 {
|
||||
return gerror.New("ID不能为空")
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -26,9 +26,6 @@ type ImRealnameListInp struct {
|
||||
}
|
||||
|
||||
func (in *ImRealnameListInp) Filter(ctx context.Context) (err error) {
|
||||
if in.TenantId <= 0 {
|
||||
return gerror.New("请选择租户")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
@@ -55,9 +52,6 @@ type ImRealnameViewInp struct {
|
||||
}
|
||||
|
||||
func (in *ImRealnameViewInp) Filter(ctx context.Context) (err error) {
|
||||
if in.TenantId <= 0 {
|
||||
return gerror.New("请选择租户")
|
||||
}
|
||||
if in.Id <= 0 {
|
||||
return gerror.New("ID不能为空")
|
||||
}
|
||||
@@ -78,9 +72,6 @@ type ImRealnameReviewInp struct {
|
||||
}
|
||||
|
||||
func (in *ImRealnameReviewInp) Filter(ctx context.Context) (err error) {
|
||||
if in.TenantId <= 0 {
|
||||
return gerror.New("请选择租户")
|
||||
}
|
||||
if in.Id <= 0 {
|
||||
return gerror.New("ID不能为空")
|
||||
}
|
||||
|
||||
@@ -32,9 +32,6 @@ type ImWorkOrderListInp struct {
|
||||
}
|
||||
|
||||
func (in *ImWorkOrderListInp) Filter(ctx context.Context) (err error) {
|
||||
if in.TenantId <= 0 {
|
||||
return gerror.New("请选择租户")
|
||||
}
|
||||
if in.Type == "" {
|
||||
return gerror.New("请选择工单类型")
|
||||
}
|
||||
@@ -68,9 +65,6 @@ type ImWorkOrderViewInp struct {
|
||||
}
|
||||
|
||||
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("工单类型不正确")
|
||||
}
|
||||
@@ -100,9 +94,6 @@ type ImWorkOrderStatusInp struct {
|
||||
}
|
||||
|
||||
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("工单类型不正确")
|
||||
}
|
||||
|
||||
@@ -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.ImOutbox) // IM Outbox
|
||||
}
|
||||
@@ -236,6 +236,14 @@ type (
|
||||
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)
|
||||
// GovernanceUpdate 更新IM群治理配置
|
||||
GovernanceUpdate(ctx context.Context, in *sysin.ImGroupGovernanceUpdateInp) (err error)
|
||||
// Dissolve 解散IM群
|
||||
Dissolve(ctx context.Context, in *sysin.ImGroupDissolveInp) (err error)
|
||||
// Mute 禁言IM群成员
|
||||
Mute(ctx context.Context, in *sysin.ImGroupMuteInp) (err error)
|
||||
// Unmute 解除IM群成员禁言
|
||||
Unmute(ctx context.Context, in *sysin.ImGroupUnmuteInp) (err error)
|
||||
// JoinRequestList 获取IM入群申请列表
|
||||
JoinRequestList(ctx context.Context, in *sysin.ImGroupJoinRequestListInp) (list []*sysin.ImGroupJoinRequestListModel, totalCount int, err error)
|
||||
// InvitationList 获取IM群邀请列表
|
||||
@@ -263,6 +271,16 @@ type (
|
||||
// Status 更新IM举报反馈工单状态
|
||||
Status(ctx context.Context, in *sysin.ImWorkOrderStatusInp) (err error)
|
||||
}
|
||||
ISysImOutbox interface {
|
||||
// List 获取IM Outbox列表
|
||||
List(ctx context.Context, in *sysin.ImOutboxListInp) (list []*sysin.ImOutboxListModel, totalCount int, err error)
|
||||
// View 获取IM Outbox详情
|
||||
View(ctx context.Context, in *sysin.ImOutboxViewInp) (res *sysin.ImOutboxViewModel, err error)
|
||||
// Retry 重试IM Outbox事件
|
||||
Retry(ctx context.Context, in *sysin.ImOutboxActionInp) (err error)
|
||||
// Dead 标记IM Outbox死信
|
||||
Dead(ctx context.Context, in *sysin.ImOutboxActionInp) (err error)
|
||||
}
|
||||
ISysImAuditLog interface {
|
||||
// List 获取IM审计日志列表
|
||||
List(ctx context.Context, in *sysin.ImAuditLogListInp) (list []*sysin.ImAuditLogListModel, totalCount int, err error)
|
||||
@@ -521,6 +539,7 @@ var (
|
||||
localSysImGroup ISysImGroup
|
||||
localSysImRealname ISysImRealname
|
||||
localSysImWorkOrder ISysImWorkOrder
|
||||
localSysImOutbox ISysImOutbox
|
||||
localSysImAuditLog ISysImAuditLog
|
||||
localSysDictData ISysDictData
|
||||
localSysDictType ISysDictType
|
||||
@@ -702,6 +721,17 @@ func RegisterSysImWorkOrder(i ISysImWorkOrder) {
|
||||
localSysImWorkOrder = i
|
||||
}
|
||||
|
||||
func SysImOutbox() ISysImOutbox {
|
||||
if localSysImOutbox == nil {
|
||||
panic("implement not found for interface ISysImOutbox, forgot register?")
|
||||
}
|
||||
return localSysImOutbox
|
||||
}
|
||||
|
||||
func RegisterSysImOutbox(i ISysImOutbox) {
|
||||
localSysImOutbox = i
|
||||
}
|
||||
|
||||
func SysImAuditLog() ISysImAuditLog {
|
||||
if localSysImAuditLog == nil {
|
||||
panic("implement not found for interface ISysImAuditLog, forgot register?")
|
||||
|
||||
@@ -36,6 +36,22 @@ INSERT INTO `hg_admin_menu` (`id`, `pid`, `title`, `name`, `path`, `icon`, `type
|
||||
SELECT NULL, @groupListId, '群治理配置', 'imGroupGovernance', '', '', '3', '', '/imGroup/governance', '', '', '1', '', '0', '0', '', '0', '1', '0', '4', CONCAT('tr_', @imRootId, ' tr_', @groupDirId, ' tr_', @groupListId, ' '), '30', '', '1', @now, @now
|
||||
WHERE NOT EXISTS (SELECT 1 FROM `hg_admin_menu` WHERE `name` = 'imGroupGovernance');
|
||||
|
||||
INSERT INTO `hg_admin_menu` (`id`, `pid`, `title`, `name`, `path`, `icon`, `type`, `redirect`, `permissions`, `permission_name`, `component`, `always_show`, `active_menu`, `is_root`, `is_frame`, `frame_src`, `keep_alive`, `hidden`, `affix`, `level`, `tree`, `sort`, `remark`, `status`, `created_at`, `updated_at`)
|
||||
SELECT NULL, @groupListId, '更新群治理配置', 'imGroupGovernanceUpdate', '', '', '3', '', '/imGroup/governanceUpdate', '', '', '1', '', '0', '0', '', '0', '1', '0', '4', CONCAT('tr_', @imRootId, ' tr_', @groupDirId, ' tr_', @groupListId, ' '), '40', '', '1', @now, @now
|
||||
WHERE NOT EXISTS (SELECT 1 FROM `hg_admin_menu` WHERE `name` = 'imGroupGovernanceUpdate');
|
||||
|
||||
INSERT INTO `hg_admin_menu` (`id`, `pid`, `title`, `name`, `path`, `icon`, `type`, `redirect`, `permissions`, `permission_name`, `component`, `always_show`, `active_menu`, `is_root`, `is_frame`, `frame_src`, `keep_alive`, `hidden`, `affix`, `level`, `tree`, `sort`, `remark`, `status`, `created_at`, `updated_at`)
|
||||
SELECT NULL, @groupListId, '解散群', 'imGroupDissolve', '', '', '3', '', '/imGroup/dissolve', '', '', '1', '', '0', '0', '', '0', '1', '0', '4', CONCAT('tr_', @imRootId, ' tr_', @groupDirId, ' tr_', @groupListId, ' '), '50', '', '1', @now, @now
|
||||
WHERE NOT EXISTS (SELECT 1 FROM `hg_admin_menu` WHERE `name` = 'imGroupDissolve');
|
||||
|
||||
INSERT INTO `hg_admin_menu` (`id`, `pid`, `title`, `name`, `path`, `icon`, `type`, `redirect`, `permissions`, `permission_name`, `component`, `always_show`, `active_menu`, `is_root`, `is_frame`, `frame_src`, `keep_alive`, `hidden`, `affix`, `level`, `tree`, `sort`, `remark`, `status`, `created_at`, `updated_at`)
|
||||
SELECT NULL, @groupListId, '禁言群成员', 'imGroupMute', '', '', '3', '', '/imGroup/mute', '', '', '1', '', '0', '0', '', '0', '1', '0', '4', CONCAT('tr_', @imRootId, ' tr_', @groupDirId, ' tr_', @groupListId, ' '), '60', '', '1', @now, @now
|
||||
WHERE NOT EXISTS (SELECT 1 FROM `hg_admin_menu` WHERE `name` = 'imGroupMute');
|
||||
|
||||
INSERT INTO `hg_admin_menu` (`id`, `pid`, `title`, `name`, `path`, `icon`, `type`, `redirect`, `permissions`, `permission_name`, `component`, `always_show`, `active_menu`, `is_root`, `is_frame`, `frame_src`, `keep_alive`, `hidden`, `affix`, `level`, `tree`, `sort`, `remark`, `status`, `created_at`, `updated_at`)
|
||||
SELECT NULL, @groupListId, '解除成员禁言', 'imGroupUnmute', '', '', '3', '', '/imGroup/unmute', '', '', '1', '', '0', '0', '', '0', '1', '0', '4', CONCAT('tr_', @imRootId, ' tr_', @groupDirId, ' tr_', @groupListId, ' '), '70', '', '1', @now, @now
|
||||
WHERE NOT EXISTS (SELECT 1 FROM `hg_admin_menu` WHERE `name` = 'imGroupUnmute');
|
||||
|
||||
INSERT INTO `hg_admin_menu` (`id`, `pid`, `title`, `name`, `path`, `icon`, `type`, `redirect`, `permissions`, `permission_name`, `component`, `always_show`, `active_menu`, `is_root`, `is_frame`, `frame_src`, `keep_alive`, `hidden`, `affix`, `level`, `tree`, `sort`, `remark`, `status`, `created_at`, `updated_at`)
|
||||
SELECT NULL, @groupListId, '入群申请列表', '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');
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
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 @outboxDirId := `id` FROM `hg_admin_menu` WHERE `name` = 'imOutbox' 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, 'Outbox运维', 'imOutbox', 'outbox', '', '1', '/im/outbox/index', '', '', 'ParentLayout', '1', '', '0', '0', '', '0', '0', '0', '2', CONCAT('tr_', @imRootId, ' '), '65', '', '1', @now, @now
|
||||
WHERE @outboxDirId IS NULL;
|
||||
|
||||
SELECT @outboxDirId := `id` FROM `hg_admin_menu` WHERE `name` = 'imOutbox' LIMIT 1;
|
||||
|
||||
SELECT @outboxListId := `id` FROM `hg_admin_menu` WHERE `name` = 'imOutboxIndex' 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, @outboxDirId, 'Outbox列表', 'imOutboxIndex', 'index', '', '2', '', '/imOutbox/list', '', '/imOutbox/index', '1', 'imOutbox', '0', '0', '', '0', '0', '0', '3', CONCAT('tr_', @imRootId, ' tr_', @outboxDirId, ' '), '10', '', '1', @now, @now
|
||||
WHERE @outboxListId IS NULL;
|
||||
|
||||
SELECT @outboxListId := `id` FROM `hg_admin_menu` WHERE `name` = 'imOutboxIndex' 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, @outboxListId, title, name, '', '', '3', '', permissions, '', '', '1', '', '0', '0', '', '0', '1', '0', '4', CONCAT('tr_', @imRootId, ' tr_', @outboxDirId, ' tr_', @outboxListId, ' '), sort, '', '1', @now, @now
|
||||
FROM (
|
||||
SELECT 'Outbox详情' title, 'imOutboxView' name, '/imOutbox/view' permissions, 10 sort UNION ALL
|
||||
SELECT '重试Outbox事件', 'imOutboxRetry', '/imOutbox/retry', 20 UNION ALL
|
||||
SELECT '标记Outbox死信', 'imOutboxDead', '/imOutbox/dead', 30
|
||||
) p
|
||||
WHERE @outboxListId IS NOT NULL
|
||||
AND NOT EXISTS (SELECT 1 FROM `hg_admin_menu` WHERE `name` = p.name);
|
||||
@@ -36,6 +36,42 @@ export function Governance(params) {
|
||||
});
|
||||
}
|
||||
|
||||
// 更新IM群治理配置
|
||||
export function GovernanceUpdate(params) {
|
||||
return http.request({
|
||||
url: '/imGroup/governanceUpdate',
|
||||
method: 'POST',
|
||||
params,
|
||||
});
|
||||
}
|
||||
|
||||
// 解散IM群
|
||||
export function Dissolve(params) {
|
||||
return http.request({
|
||||
url: '/imGroup/dissolve',
|
||||
method: 'POST',
|
||||
params,
|
||||
});
|
||||
}
|
||||
|
||||
// 禁言IM群成员
|
||||
export function Mute(params) {
|
||||
return http.request({
|
||||
url: '/imGroup/mute',
|
||||
method: 'POST',
|
||||
params,
|
||||
});
|
||||
}
|
||||
|
||||
// 解除IM群成员禁言
|
||||
export function Unmute(params) {
|
||||
return http.request({
|
||||
url: '/imGroup/unmute',
|
||||
method: 'POST',
|
||||
params,
|
||||
});
|
||||
}
|
||||
|
||||
// 获取IM入群申请列表
|
||||
export function JoinRequestList(params) {
|
||||
return http.request({
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { http } from '@/utils/http/axios';
|
||||
|
||||
export function List(params) {
|
||||
return http.request({
|
||||
url: '/imOutbox/list',
|
||||
method: 'get',
|
||||
params,
|
||||
});
|
||||
}
|
||||
|
||||
export function View(params) {
|
||||
return http.request({
|
||||
url: '/imOutbox/view',
|
||||
method: 'GET',
|
||||
params,
|
||||
});
|
||||
}
|
||||
|
||||
export function Retry(params) {
|
||||
return http.request({
|
||||
url: '/imOutbox/retry',
|
||||
method: 'POST',
|
||||
params,
|
||||
});
|
||||
}
|
||||
|
||||
export function Dead(params) {
|
||||
return http.request({
|
||||
url: '/imOutbox/dead',
|
||||
method: 'POST',
|
||||
params,
|
||||
});
|
||||
}
|
||||
@@ -15,21 +15,84 @@
|
||||
<n-descriptions label-placement="left" bordered :column="settingStore.isMobile ? 1 : 2">
|
||||
<n-descriptions-item label="群名称">{{ governance.name || '-' }}</n-descriptions-item>
|
||||
<n-descriptions-item label="业务状态">{{ groupStatusLabel }}</n-descriptions-item>
|
||||
<n-descriptions-item label="群主ID">{{ governance.ownerUserId || '-' }}</n-descriptions-item>
|
||||
<n-descriptions-item label="成员数">{{ governance.memberCount || '-' }}</n-descriptions-item>
|
||||
<n-descriptions-item label="入群审批">{{ governance.joinApproval ? '开启' : '关闭' }}</n-descriptions-item>
|
||||
<n-descriptions-item label="全员禁言">{{ governance.muteAll ? '开启' : '关闭' }}</n-descriptions-item>
|
||||
<n-descriptions-item label="成员上传文件">{{ governance.memberUploadFiles ? '允许' : '禁止' }}</n-descriptions-item>
|
||||
<n-descriptions-item label="成员私聊">{{ governance.memberPrivateChat ? '允许' : '禁止' }}</n-descriptions-item>
|
||||
<n-descriptions-item label="成员邀请">{{ governance.memberInvite ? '允许' : '禁止' }}</n-descriptions-item>
|
||||
<n-descriptions-item label="最大成员数">{{ governance.maxMemberCount || '-' }}</n-descriptions-item>
|
||||
<n-descriptions-item label="最大管理员数">{{ governance.maxAdminCount || '-' }}</n-descriptions-item>
|
||||
<n-descriptions-item label="版本">{{ governance.version || '-' }}</n-descriptions-item>
|
||||
<n-descriptions-item label="创建人">{{ governance.createdBy || '-' }}</n-descriptions-item>
|
||||
<n-descriptions-item label="创建时间">{{ governance.createdAt || '-' }}</n-descriptions-item>
|
||||
<n-descriptions-item label="解散人">{{ governance.dissolvedBy || '-' }}</n-descriptions-item>
|
||||
<n-descriptions-item label="解散时间">{{ governance.dissolvedAt || '-' }}</n-descriptions-item>
|
||||
<n-descriptions-item label="简介" :span="2">{{ governance.description || '-' }}</n-descriptions-item>
|
||||
<n-descriptions-item label="群主ID">{{
|
||||
governance.ownerUserId || '-'
|
||||
}}</n-descriptions-item>
|
||||
<n-descriptions-item label="成员数">{{
|
||||
governance.memberCount || '-'
|
||||
}}</n-descriptions-item>
|
||||
<n-descriptions-item label="入群审批">
|
||||
<n-switch
|
||||
v-model:value="governance.joinApproval"
|
||||
:checked-value="1"
|
||||
:unchecked-value="0"
|
||||
:disabled="!canUpdateGovernance"
|
||||
/>
|
||||
</n-descriptions-item>
|
||||
<n-descriptions-item label="全员禁言">
|
||||
<n-switch
|
||||
v-model:value="governance.muteAll"
|
||||
:checked-value="1"
|
||||
:unchecked-value="0"
|
||||
:disabled="!canUpdateGovernance"
|
||||
/>
|
||||
</n-descriptions-item>
|
||||
<n-descriptions-item label="成员上传文件">
|
||||
<n-switch
|
||||
v-model:value="governance.memberUploadFiles"
|
||||
:checked-value="1"
|
||||
:unchecked-value="0"
|
||||
:disabled="!canUpdateGovernance"
|
||||
/>
|
||||
</n-descriptions-item>
|
||||
<n-descriptions-item label="成员私聊">
|
||||
<n-switch
|
||||
v-model:value="governance.memberPrivateChat"
|
||||
:checked-value="1"
|
||||
:unchecked-value="0"
|
||||
:disabled="!canUpdateGovernance"
|
||||
/>
|
||||
</n-descriptions-item>
|
||||
<n-descriptions-item label="成员邀请">
|
||||
<n-switch
|
||||
v-model:value="governance.memberInvite"
|
||||
:checked-value="1"
|
||||
:unchecked-value="0"
|
||||
:disabled="!canUpdateGovernance"
|
||||
/>
|
||||
</n-descriptions-item>
|
||||
<n-descriptions-item label="最大成员数">
|
||||
<n-input-number
|
||||
v-model:value="governance.maxMemberCount"
|
||||
:min="0"
|
||||
:disabled="!canUpdateGovernance"
|
||||
/>
|
||||
</n-descriptions-item>
|
||||
<n-descriptions-item label="最大管理员数">
|
||||
<n-input-number
|
||||
v-model:value="governance.maxAdminCount"
|
||||
:min="0"
|
||||
:disabled="!canUpdateGovernance"
|
||||
/>
|
||||
</n-descriptions-item>
|
||||
<n-descriptions-item label="版本">{{
|
||||
governance.version || '-'
|
||||
}}</n-descriptions-item>
|
||||
<n-descriptions-item label="创建人">{{
|
||||
governance.createdBy || '-'
|
||||
}}</n-descriptions-item>
|
||||
<n-descriptions-item label="创建时间">{{
|
||||
governance.createdAt || '-'
|
||||
}}</n-descriptions-item>
|
||||
<n-descriptions-item label="解散人">{{
|
||||
governance.dissolvedBy || '-'
|
||||
}}</n-descriptions-item>
|
||||
<n-descriptions-item label="解散时间">{{
|
||||
governance.dissolvedAt || '-'
|
||||
}}</n-descriptions-item>
|
||||
<n-descriptions-item label="简介" :span="2">{{
|
||||
governance.description || '-'
|
||||
}}</n-descriptions-item>
|
||||
</n-descriptions>
|
||||
</n-spin>
|
||||
</n-tab-pane>
|
||||
@@ -69,6 +132,7 @@
|
||||
:columns="muteColumns"
|
||||
:request="muteData"
|
||||
:row-key="(row) => row.topic + '-' + row.userId"
|
||||
:actionColumn="muteActionColumn"
|
||||
:scroll-x="muteScrollX"
|
||||
:resizeHeightOffset="-10000"
|
||||
/>
|
||||
@@ -86,6 +150,22 @@
|
||||
</n-tabs>
|
||||
<template #action>
|
||||
<n-space>
|
||||
<n-button
|
||||
v-if="activeTab === 'settings' && canUpdateGovernance"
|
||||
type="primary"
|
||||
:loading="submitLoading"
|
||||
@click="handleSaveGovernance"
|
||||
>
|
||||
保存治理配置
|
||||
</n-button>
|
||||
<n-button
|
||||
v-if="activeTab === 'settings' && canDissolve && governance.groupStatus !== 'dissolved'"
|
||||
type="error"
|
||||
:loading="submitLoading"
|
||||
@click="handleDissolve"
|
||||
>
|
||||
解散群
|
||||
</n-button>
|
||||
<n-button @click="closeForm">关闭</n-button>
|
||||
</n-space>
|
||||
</template>
|
||||
@@ -94,16 +174,21 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, nextTick, ref, watch } from 'vue';
|
||||
import { computed, h, nextTick, reactive, ref, watch } from 'vue';
|
||||
import { useDialog, useMessage } from 'naive-ui';
|
||||
import { useDictStore } from '@/store/modules/dict';
|
||||
import { BasicTable } from '@/components/Table';
|
||||
import { usePermission } from '@/hooks/web/usePermission';
|
||||
import { BasicTable, TableAction } from '@/components/Table';
|
||||
import {
|
||||
AdminLogList,
|
||||
AnnouncementList,
|
||||
Dissolve,
|
||||
Governance,
|
||||
GovernanceUpdate,
|
||||
InvitationList,
|
||||
JoinRequestList,
|
||||
MuteList,
|
||||
Unmute,
|
||||
} from '@/api/imGroup';
|
||||
import {
|
||||
adminLogColumns,
|
||||
@@ -120,24 +205,52 @@
|
||||
|
||||
const settingStore = useProjectSettingStore();
|
||||
const dict = useDictStore();
|
||||
const message = useMessage();
|
||||
const dialog = useDialog();
|
||||
const { hasPermission } = usePermission();
|
||||
const showModal = ref(false);
|
||||
const loading = ref(false);
|
||||
const submitLoading = ref(false);
|
||||
const activeTab = ref('settings');
|
||||
const current = ref<State>(newState(null));
|
||||
const governance = ref<GovernanceState>(new GovernanceState());
|
||||
const dialogWidth = computed(() => adaModalWidth(1120));
|
||||
const groupStatusLabel = computed(() => dict.getLabel('ImGroupStatusOptions', governance.value.groupStatus) || '-');
|
||||
const canUpdateGovernance = computed(() => hasPermission(['/imGroup/governanceUpdate']));
|
||||
const canDissolve = computed(() => hasPermission(['/imGroup/dissolve']));
|
||||
const groupStatusLabel = computed(
|
||||
() => dict.getLabel('ImGroupStatusOptions', governance.value.groupStatus) || '-'
|
||||
);
|
||||
|
||||
const joinRequestRef = ref();
|
||||
const invitationRef = ref();
|
||||
const announcementRef = ref();
|
||||
const muteRef = ref();
|
||||
const adminLogRef = ref();
|
||||
const muteActionColumn = reactive({
|
||||
width: 110,
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
fixed: 'right',
|
||||
render(record) {
|
||||
return h(TableAction as any, {
|
||||
style: 'button',
|
||||
actions: [
|
||||
{
|
||||
label: '解除',
|
||||
type: 'warning',
|
||||
ifShow: () => record.status === 'active',
|
||||
auth: ['/imGroup/unmute'],
|
||||
onClick: () => handleUnmute(record),
|
||||
},
|
||||
],
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const joinRequestScrollX = computed(() => adaTableScrollX(joinRequestColumns, 0));
|
||||
const invitationScrollX = computed(() => adaTableScrollX(invitationColumns, 0));
|
||||
const announcementScrollX = computed(() => adaTableScrollX(announcementColumns, 0));
|
||||
const muteScrollX = computed(() => adaTableScrollX(muteColumns, 0));
|
||||
const muteScrollX = computed(() => adaTableScrollX(muteColumns, muteActionColumn.width));
|
||||
const adminLogScrollX = computed(() => adaTableScrollX(adminLogColumns, 0));
|
||||
|
||||
function baseParams(res) {
|
||||
@@ -176,13 +289,78 @@
|
||||
async function loadGovernance() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const res = await Governance({ tenantId: current.value.tenantId, topic: current.value.topic });
|
||||
const res = await Governance({
|
||||
tenantId: current.value.tenantId,
|
||||
topic: current.value.topic,
|
||||
});
|
||||
governance.value = Object.assign(new GovernanceState(), res || {});
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSaveGovernance() {
|
||||
submitLoading.value = true;
|
||||
try {
|
||||
await GovernanceUpdate({
|
||||
tenantId: current.value.tenantId,
|
||||
topic: current.value.topic,
|
||||
joinApproval: governance.value.joinApproval,
|
||||
muteAll: governance.value.muteAll,
|
||||
memberUploadFiles: governance.value.memberUploadFiles,
|
||||
memberPrivateChat: governance.value.memberPrivateChat,
|
||||
memberInvite: governance.value.memberInvite,
|
||||
maxMemberCount: governance.value.maxMemberCount,
|
||||
maxAdminCount: governance.value.maxAdminCount,
|
||||
});
|
||||
message.success('操作成功');
|
||||
await loadGovernance();
|
||||
} finally {
|
||||
submitLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleDissolve() {
|
||||
dialog.error({
|
||||
title: '解散群',
|
||||
content: '解散后该群将进入不可用状态,并写入群事件等待 IM 服务端同步,是否确认?',
|
||||
positiveText: '确认',
|
||||
negativeText: '取消',
|
||||
onPositiveClick: async () => {
|
||||
submitLoading.value = true;
|
||||
try {
|
||||
await Dissolve({
|
||||
tenantId: current.value.tenantId,
|
||||
topic: current.value.topic,
|
||||
reason: '后台管理员解散',
|
||||
});
|
||||
message.success('操作成功');
|
||||
await loadGovernance();
|
||||
} finally {
|
||||
submitLoading.value = false;
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function handleUnmute(record) {
|
||||
dialog.warning({
|
||||
title: '解除禁言',
|
||||
content: '确定解除该成员禁言?',
|
||||
positiveText: '确认',
|
||||
negativeText: '取消',
|
||||
onPositiveClick: async () => {
|
||||
await Unmute({
|
||||
tenantId: current.value.tenantId,
|
||||
topic: current.value.topic,
|
||||
userId: record.userId,
|
||||
});
|
||||
message.success('操作成功');
|
||||
muteRef.value?.reload();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function reloadActiveTable() {
|
||||
const refs = {
|
||||
joinRequests: joinRequestRef,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="n-layout-page-header">
|
||||
<n-card :bordered="false" title="IM群组管理"></n-card>
|
||||
<n-card :bordered="false" title="IM群组管理" />
|
||||
</div>
|
||||
<n-card :bordered="false" class="proCard">
|
||||
<BasicForm
|
||||
@@ -32,6 +32,7 @@
|
||||
import { useMessage } from 'naive-ui';
|
||||
import { BasicTable, TableAction } from '@/components/Table';
|
||||
import { BasicForm, useForm } from '@/components/Form/index';
|
||||
import { useUserStore } from '@/store/modules/user';
|
||||
import { List } from '@/api/imGroup';
|
||||
import { columns, loadOptions, loadTenantOptions, schemas, State, tenantOptions } from './model';
|
||||
import { adaTableScrollX } from '@/utils/hotgo';
|
||||
@@ -40,12 +41,19 @@
|
||||
import GovernanceModal from './governance.vue';
|
||||
|
||||
const message = useMessage();
|
||||
const userStore = useUserStore();
|
||||
const isPlatformAdmin = computed(() => userStore.isCompanyDept);
|
||||
const actionRef = ref();
|
||||
const searchFormRef = ref<any>({});
|
||||
const viewRef = ref();
|
||||
const membersRef = ref();
|
||||
const governanceRef = ref();
|
||||
|
||||
const tenantSchema = schemas.value.find((item) => item.field === 'tenantId');
|
||||
if (tenantSchema) {
|
||||
tenantSchema.ifShow = () => isPlatformAdmin.value;
|
||||
}
|
||||
|
||||
const actionColumn = reactive({
|
||||
width: 220,
|
||||
title: '操作',
|
||||
@@ -88,7 +96,7 @@
|
||||
// 加载表格数据
|
||||
const loadDataTable = async (res) => {
|
||||
const params = { ...searchFormRef.value?.formModel, ...res };
|
||||
if (!params.tenantId) {
|
||||
if (isPlatformAdmin.value && !params.tenantId) {
|
||||
return { list: [], page: 1, pageCount: 0, totalCount: 0 };
|
||||
}
|
||||
return await List(params);
|
||||
@@ -116,13 +124,15 @@
|
||||
|
||||
onMounted(async () => {
|
||||
loadOptions();
|
||||
await loadTenantOptions();
|
||||
if (tenantOptions.value.length > 0) {
|
||||
await setFieldsValue({ tenantId: tenantOptions.value[0].value });
|
||||
reloadTable();
|
||||
} else {
|
||||
message.warning('暂无可用租户');
|
||||
if (isPlatformAdmin.value) {
|
||||
await loadTenantOptions();
|
||||
if (tenantOptions.value.length > 0) {
|
||||
await setFieldsValue({ tenantId: tenantOptions.value[0].value });
|
||||
} else {
|
||||
message.warning('暂无可用租户');
|
||||
}
|
||||
}
|
||||
reloadTable();
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
:columns="memberColumns"
|
||||
:request="loadDataTable"
|
||||
:row-key="(row) => row.userId"
|
||||
:actionColumn="actionColumn"
|
||||
:scroll-x="scrollX"
|
||||
:resizeHeightOffset="-10000"
|
||||
/>
|
||||
@@ -27,17 +28,42 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, ref } from 'vue';
|
||||
import { BasicTable } from '@/components/Table';
|
||||
import { MemberList } from '@/api/imGroup';
|
||||
import { computed, h, reactive, ref } from 'vue';
|
||||
import { useDialog, useMessage } from 'naive-ui';
|
||||
import { BasicTable, TableAction } from '@/components/Table';
|
||||
import { usePermission } from '@/hooks/web/usePermission';
|
||||
import { MemberList, Mute } from '@/api/imGroup';
|
||||
import { memberColumns, newState, State } from './model';
|
||||
import { adaModalWidth, adaTableScrollX } from '@/utils/hotgo';
|
||||
|
||||
const message = useMessage();
|
||||
const dialog = useDialog();
|
||||
const { hasPermission } = usePermission();
|
||||
const showModal = ref(false);
|
||||
const current = ref<State>(newState(null));
|
||||
const actionRef = ref();
|
||||
const dialogWidth = computed(() => adaModalWidth(980));
|
||||
const scrollX = computed(() => adaTableScrollX(memberColumns, 0));
|
||||
const actionColumn = reactive({
|
||||
width: 100,
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
fixed: 'right',
|
||||
render(record) {
|
||||
return h(TableAction as any, {
|
||||
style: 'button',
|
||||
actions: [
|
||||
{
|
||||
label: '禁言',
|
||||
type: 'warning',
|
||||
ifShow: () => record.memberStatus === 'active',
|
||||
disabled: !hasPermission(['/imGroup/mute']),
|
||||
onClick: () => handleMute(record),
|
||||
},
|
||||
],
|
||||
});
|
||||
},
|
||||
});
|
||||
const scrollX = computed(() => adaTableScrollX(memberColumns, actionColumn.width));
|
||||
|
||||
// 加载表格数据
|
||||
const loadDataTable = async (res) => {
|
||||
@@ -56,6 +82,26 @@
|
||||
showModal.value = false;
|
||||
}
|
||||
|
||||
function handleMute(record) {
|
||||
dialog.warning({
|
||||
title: '禁言成员',
|
||||
content: '将该成员禁言 24 小时,是否确认?',
|
||||
positiveText: '确认',
|
||||
negativeText: '取消',
|
||||
onPositiveClick: async () => {
|
||||
await Mute({
|
||||
tenantId: current.value.tenantId,
|
||||
topic: current.value.topic,
|
||||
userId: record.userId,
|
||||
expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(),
|
||||
reason: '后台管理员禁言',
|
||||
});
|
||||
message.success('操作成功');
|
||||
actionRef.value?.reload();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// 打开弹窗
|
||||
function openModal(state: State) {
|
||||
current.value = newState(state);
|
||||
|
||||
@@ -287,7 +287,10 @@ export const memberColumns = [
|
||||
align: 'left',
|
||||
width: 110,
|
||||
render(row: MemberState) {
|
||||
return dict.getLabel('ImGroupMemberStatusOptions', row.memberStatus) || dict.getLabel('ImUserStateOptions', row.state);
|
||||
return (
|
||||
dict.getLabel('ImGroupMemberStatusOptions', row.memberStatus) ||
|
||||
dict.getLabel('ImUserStateOptions', row.state)
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -17,24 +17,56 @@
|
||||
<n-descriptions-item label="状态">{{ stateLabel }}</n-descriptions-item>
|
||||
<n-descriptions-item label="群主ID">{{ formValue.ownerId }}</n-descriptions-item>
|
||||
<n-descriptions-item label="业务状态">{{ groupStatusLabel }}</n-descriptions-item>
|
||||
<n-descriptions-item label="群名称">{{ formValue.groupName || '-' }}</n-descriptions-item>
|
||||
<n-descriptions-item label="成员数">{{ formValue.businessMemberCount || formValue.subCnt }}</n-descriptions-item>
|
||||
<n-descriptions-item label="入群审批">{{ formValue.joinApproval ? '开启' : '关闭' }}</n-descriptions-item>
|
||||
<n-descriptions-item label="全员禁言">{{ formValue.muteAll ? '开启' : '关闭' }}</n-descriptions-item>
|
||||
<n-descriptions-item label="成员上传文件">{{ formValue.memberUploadFiles ? '允许' : '禁止' }}</n-descriptions-item>
|
||||
<n-descriptions-item label="成员私聊">{{ formValue.memberPrivateChat ? '允许' : '禁止' }}</n-descriptions-item>
|
||||
<n-descriptions-item label="成员邀请">{{ formValue.memberInvite ? '允许' : '禁止' }}</n-descriptions-item>
|
||||
<n-descriptions-item label="最大成员数">{{ formValue.maxMemberCount || '-' }}</n-descriptions-item>
|
||||
<n-descriptions-item label="最大管理员数">{{ formValue.maxAdminCount || '-' }}</n-descriptions-item>
|
||||
<n-descriptions-item label="治理版本">{{ formValue.settingsVersion || '-' }}</n-descriptions-item>
|
||||
<n-descriptions-item label="群名称">{{
|
||||
formValue.groupName || '-'
|
||||
}}</n-descriptions-item>
|
||||
<n-descriptions-item label="成员数">{{
|
||||
formValue.businessMemberCount || formValue.subCnt
|
||||
}}</n-descriptions-item>
|
||||
<n-descriptions-item label="入群审批">{{
|
||||
formValue.joinApproval ? '开启' : '关闭'
|
||||
}}</n-descriptions-item>
|
||||
<n-descriptions-item label="全员禁言">{{
|
||||
formValue.muteAll ? '开启' : '关闭'
|
||||
}}</n-descriptions-item>
|
||||
<n-descriptions-item label="成员上传文件">{{
|
||||
formValue.memberUploadFiles ? '允许' : '禁止'
|
||||
}}</n-descriptions-item>
|
||||
<n-descriptions-item label="成员私聊">{{
|
||||
formValue.memberPrivateChat ? '允许' : '禁止'
|
||||
}}</n-descriptions-item>
|
||||
<n-descriptions-item label="成员邀请">{{
|
||||
formValue.memberInvite ? '允许' : '禁止'
|
||||
}}</n-descriptions-item>
|
||||
<n-descriptions-item label="最大成员数">{{
|
||||
formValue.maxMemberCount || '-'
|
||||
}}</n-descriptions-item>
|
||||
<n-descriptions-item label="最大管理员数">{{
|
||||
formValue.maxAdminCount || '-'
|
||||
}}</n-descriptions-item>
|
||||
<n-descriptions-item label="治理版本">{{
|
||||
formValue.settingsVersion || '-'
|
||||
}}</n-descriptions-item>
|
||||
<n-descriptions-item label="最新序号">{{ formValue.seqId }}</n-descriptions-item>
|
||||
<n-descriptions-item label="删除序号">{{ formValue.delId }}</n-descriptions-item>
|
||||
<n-descriptions-item label="BT">{{ formValue.useBt ? '开启' : '关闭' }}</n-descriptions-item>
|
||||
<n-descriptions-item label="活跃时间">{{ formValue.touchedAt || '-' }}</n-descriptions-item>
|
||||
<n-descriptions-item label="创建时间">{{ formValue.createdAt || '-' }}</n-descriptions-item>
|
||||
<n-descriptions-item label="修改时间">{{ formValue.updatedAt || '-' }}</n-descriptions-item>
|
||||
<n-descriptions-item label="治理更新时间">{{ formValue.settingsUpdatedAt || '-' }}</n-descriptions-item>
|
||||
<n-descriptions-item label="简介" :span="2">{{ formValue.description || '-' }}</n-descriptions-item>
|
||||
<n-descriptions-item label="BT">{{
|
||||
formValue.useBt ? '开启' : '关闭'
|
||||
}}</n-descriptions-item>
|
||||
<n-descriptions-item label="活跃时间">{{
|
||||
formValue.touchedAt || '-'
|
||||
}}</n-descriptions-item>
|
||||
<n-descriptions-item label="创建时间">{{
|
||||
formValue.createdAt || '-'
|
||||
}}</n-descriptions-item>
|
||||
<n-descriptions-item label="修改时间">{{
|
||||
formValue.updatedAt || '-'
|
||||
}}</n-descriptions-item>
|
||||
<n-descriptions-item label="治理更新时间">{{
|
||||
formValue.settingsUpdatedAt || '-'
|
||||
}}</n-descriptions-item>
|
||||
<n-descriptions-item label="简介" :span="2">{{
|
||||
formValue.description || '-'
|
||||
}}</n-descriptions-item>
|
||||
<n-descriptions-item label="访问权限" :span="2">
|
||||
<n-code :code="formValue.accessJson || '-'" language="json" word-wrap />
|
||||
</n-descriptions-item>
|
||||
@@ -77,7 +109,9 @@
|
||||
const formValue = ref<State>(newState(null));
|
||||
const dialogWidth = computed(() => adaModalWidth(960));
|
||||
const stateLabel = computed(() => dict.getLabel('ImGroupStateOptions', formValue.value.state));
|
||||
const groupStatusLabel = computed(() => dict.getLabel('ImGroupStatusOptions', formValue.value.groupStatus) || '-');
|
||||
const groupStatusLabel = computed(
|
||||
() => dict.getLabel('ImGroupStatusOptions', formValue.value.groupStatus) || '-'
|
||||
);
|
||||
|
||||
// 关闭弹窗
|
||||
function closeForm() {
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="n-layout-page-header">
|
||||
<n-card :bordered="false" title="IM Outbox运维" />
|
||||
</div>
|
||||
<n-card :bordered="false" class="proCard">
|
||||
<BasicForm
|
||||
ref="searchFormRef"
|
||||
@register="register"
|
||||
@submit="reloadTable"
|
||||
@reset="handleReset"
|
||||
@keyup.enter="reloadTable"
|
||||
/>
|
||||
<BasicTable
|
||||
ref="actionRef"
|
||||
:columns="columns"
|
||||
:request="loadDataTable"
|
||||
:row-key="(row) => row.tenantId + '-' + row.source + '-' + row.id"
|
||||
:actionColumn="actionColumn"
|
||||
:scroll-x="scrollX"
|
||||
:resizeHeightOffset="-10000"
|
||||
/>
|
||||
</n-card>
|
||||
<ViewModal ref="viewRef" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, h, onMounted, reactive, ref } from 'vue';
|
||||
import { useDialog, useMessage } from 'naive-ui';
|
||||
import { BasicTable, TableAction } from '@/components/Table';
|
||||
import { BasicForm, useForm } from '@/components/Form/index';
|
||||
import { usePermission } from '@/hooks/web/usePermission';
|
||||
import { useUserStore } from '@/store/modules/user';
|
||||
import { Dead, List, Retry } from '@/api/imOutbox';
|
||||
import { columns, loadOptions, loadTenantOptions, schemas, State, tenantOptions } from './model';
|
||||
import { adaTableScrollX } from '@/utils/hotgo';
|
||||
import ViewModal from './view.vue';
|
||||
|
||||
const message = useMessage();
|
||||
const dialog = useDialog();
|
||||
const { hasPermission } = usePermission();
|
||||
const userStore = useUserStore();
|
||||
const isPlatformAdmin = computed(() => userStore.isCompanyDept);
|
||||
const actionRef = ref();
|
||||
const searchFormRef = ref<any>({});
|
||||
const viewRef = ref();
|
||||
|
||||
const tenantSchema = schemas.value.find((item) => item.field === 'tenantId');
|
||||
if (tenantSchema) {
|
||||
tenantSchema.ifShow = () => isPlatformAdmin.value;
|
||||
}
|
||||
|
||||
const actionColumn = reactive({
|
||||
width: 210,
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
fixed: 'right',
|
||||
render(record: State) {
|
||||
return h(TableAction as any, {
|
||||
style: 'button',
|
||||
actions: [
|
||||
{
|
||||
label: '详情',
|
||||
onClick: () => viewRef.value.openModal(record),
|
||||
auth: ['/imOutbox/view'],
|
||||
},
|
||||
{
|
||||
label: '重试',
|
||||
type: 'warning',
|
||||
ifShow: () => record.status !== 'delivered',
|
||||
onClick: () => handleRetry(record),
|
||||
auth: ['/imOutbox/retry'],
|
||||
},
|
||||
{
|
||||
label: '死信',
|
||||
type: 'error',
|
||||
ifShow: () => !['delivered', 'dead'].includes(record.status),
|
||||
onClick: () => handleDead(record),
|
||||
auth: ['/imOutbox/dead'],
|
||||
},
|
||||
],
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const scrollX = computed(() => adaTableScrollX(columns, actionColumn.width));
|
||||
|
||||
const [register, { setFieldsValue }] = useForm({
|
||||
gridProps: { cols: '1 s:1 m:2 l:3 xl:4 2xl:4' },
|
||||
labelWidth: 90,
|
||||
schemas,
|
||||
});
|
||||
|
||||
const loadDataTable = async (res) => {
|
||||
const params = { ...searchFormRef.value?.formModel, ...res };
|
||||
if (isPlatformAdmin.value && !params.tenantId)
|
||||
return { list: [], page: 1, pageCount: 0, totalCount: 0 };
|
||||
if (!params.source) params.source = 'business';
|
||||
return await List(params);
|
||||
};
|
||||
|
||||
function reloadTable() {
|
||||
actionRef.value?.reload();
|
||||
}
|
||||
|
||||
async function handleReset() {
|
||||
const defaults: Recordable = { source: 'business' };
|
||||
if (isPlatformAdmin.value && tenantOptions.value.length > 0)
|
||||
defaults.tenantId = tenantOptions.value[0].value;
|
||||
await setFieldsValue(defaults);
|
||||
reloadTable();
|
||||
}
|
||||
|
||||
function handleRetry(record: State) {
|
||||
dialog.warning({
|
||||
title: '重试Outbox事件',
|
||||
content: '将事件状态改回待投递,并清空下次重试时间,等待 IM 服务端消费,是否确认?',
|
||||
positiveText: '确认',
|
||||
negativeText: '取消',
|
||||
onPositiveClick: async () => {
|
||||
await Retry({ tenantId: record.tenantId, source: record.source, id: record.id });
|
||||
message.success('操作成功');
|
||||
reloadTable();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function handleDead(record: State) {
|
||||
dialog.error({
|
||||
title: '标记死信',
|
||||
content: '标记死信后 IM 服务端不会自动重试该事件,是否确认?',
|
||||
positiveText: '确认',
|
||||
negativeText: '取消',
|
||||
onPositiveClick: async () => {
|
||||
await Dead({ tenantId: record.tenantId, source: record.source, id: record.id });
|
||||
message.success('操作成功');
|
||||
reloadTable();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
loadOptions();
|
||||
await setFieldsValue({ source: 'business' });
|
||||
if (isPlatformAdmin.value) {
|
||||
await loadTenantOptions();
|
||||
if (tenantOptions.value.length > 0) {
|
||||
await setFieldsValue({ tenantId: tenantOptions.value[0].value });
|
||||
} else {
|
||||
message.warning('暂无可用租户');
|
||||
}
|
||||
}
|
||||
reloadTable();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped></style>
|
||||
@@ -0,0 +1,148 @@
|
||||
import { computed, ref } from 'vue';
|
||||
import { cloneDeep } from 'lodash-es';
|
||||
import { FormSchema } from '@/components/Form';
|
||||
import { defRangeShortcuts } from '@/utils/dateUtil';
|
||||
import { useDictStore } from '@/store/modules/dict';
|
||||
import { List as TenantList } from '@/api/imTenant';
|
||||
|
||||
const dict = useDictStore();
|
||||
|
||||
export const tenantOptions = ref<any[]>([]);
|
||||
|
||||
export class State {
|
||||
public tenantId = 0;
|
||||
public tenantName = '';
|
||||
public source = 'business';
|
||||
public id = 0;
|
||||
public eventId = '';
|
||||
public targetType = '';
|
||||
public target = '';
|
||||
public actorUserId = 0;
|
||||
public eventType = '';
|
||||
public payload = '';
|
||||
public status = '';
|
||||
public attempts = 0;
|
||||
public nextRetryAt = '';
|
||||
public deliveredAt = '';
|
||||
public createdAt = '';
|
||||
public updatedAt = '';
|
||||
|
||||
constructor(state?: Partial<State>) {
|
||||
if (state) Object.assign(this, state);
|
||||
}
|
||||
}
|
||||
|
||||
export function newState(state: State | Record<string, any> | null): State {
|
||||
if (state !== null) {
|
||||
if (state instanceof State) return cloneDeep(state);
|
||||
return new State(state);
|
||||
}
|
||||
return new State();
|
||||
}
|
||||
|
||||
export const schemas = ref<FormSchema[]>([
|
||||
{
|
||||
field: 'tenantId',
|
||||
component: 'NSelect',
|
||||
label: '租户',
|
||||
componentProps: {
|
||||
clearable: false,
|
||||
options: computed(() => tenantOptions.value),
|
||||
placeholder: '请选择租户',
|
||||
},
|
||||
},
|
||||
{
|
||||
field: 'source',
|
||||
component: 'NSelect',
|
||||
label: '来源',
|
||||
defaultValue: 'business',
|
||||
componentProps: {
|
||||
clearable: false,
|
||||
options: computed(() => dict.getOptions('ImOutboxSourceOptions')),
|
||||
placeholder: '请选择来源',
|
||||
},
|
||||
},
|
||||
{
|
||||
field: 'status',
|
||||
component: 'NSelect',
|
||||
label: '状态',
|
||||
componentProps: {
|
||||
clearable: true,
|
||||
options: computed(() => dict.getOptions('ImOutboxStatusOptions')),
|
||||
placeholder: '请选择状态',
|
||||
},
|
||||
},
|
||||
{
|
||||
field: 'eventType',
|
||||
component: 'NInput',
|
||||
label: '事件类型',
|
||||
componentProps: { placeholder: '请输入事件类型' },
|
||||
},
|
||||
{
|
||||
field: 'eventId',
|
||||
component: 'NInput',
|
||||
label: '事件ID',
|
||||
componentProps: { placeholder: '请输入事件ID' },
|
||||
},
|
||||
{
|
||||
field: 'target',
|
||||
component: 'NInput',
|
||||
label: '目标',
|
||||
componentProps: { placeholder: '请输入目标/Topic' },
|
||||
},
|
||||
{
|
||||
field: 'createdAt',
|
||||
component: 'NDatePicker',
|
||||
label: '创建时间',
|
||||
componentProps: {
|
||||
type: 'datetimerange',
|
||||
clearable: true,
|
||||
shortcuts: defRangeShortcuts(),
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
export const columns = [
|
||||
{ title: 'ID', key: 'id', align: 'left', width: 90 },
|
||||
{
|
||||
title: '来源',
|
||||
key: 'source',
|
||||
align: 'left',
|
||||
width: 110,
|
||||
render(row: State) {
|
||||
return dict.getLabel('ImOutboxSourceOptions', row.source);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
key: 'status',
|
||||
align: 'left',
|
||||
width: 110,
|
||||
render(row: State) {
|
||||
return dict.getLabel('ImOutboxStatusOptions', row.status);
|
||||
},
|
||||
},
|
||||
{ title: '事件类型', key: 'eventType', align: 'left', width: 220 },
|
||||
{ title: '事件ID', key: 'eventId', align: 'left', width: 240 },
|
||||
{ title: '目标类型', key: 'targetType', align: 'left', width: 120 },
|
||||
{ title: '目标', key: 'target', align: 'left', width: 180 },
|
||||
{ title: '操作用户', key: 'actorUserId', align: 'left', width: 120 },
|
||||
{ title: '尝试次数', key: 'attempts', align: 'left', width: 100 },
|
||||
{ title: '下次重试', key: 'nextRetryAt', align: 'left', width: 180 },
|
||||
{ title: '投递时间', key: 'deliveredAt', align: 'left', width: 180 },
|
||||
{ title: '创建时间', key: 'createdAt', align: 'left', width: 180 },
|
||||
{ title: '更新时间', key: 'updatedAt', align: 'left', width: 180 },
|
||||
];
|
||||
|
||||
export function loadOptions() {
|
||||
dict.loadOptions(['ImOutboxSourceOptions', 'ImOutboxStatusOptions']);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
<template>
|
||||
<n-modal
|
||||
v-model:show="showModal"
|
||||
preset="dialog"
|
||||
:show-icon="false"
|
||||
:title="'Outbox详情 #' + formValue.id"
|
||||
:style="{ width: dialogWidth }"
|
||||
>
|
||||
<n-spin :show="loading" description="请稍候...">
|
||||
<n-descriptions label-placement="left" bordered :column="settingStore.isMobile ? 1 : 2">
|
||||
<n-descriptions-item label="租户">{{ formValue.tenantName }}</n-descriptions-item>
|
||||
<n-descriptions-item label="来源">{{ sourceLabel }}</n-descriptions-item>
|
||||
<n-descriptions-item label="事件ID">{{ formValue.eventId }}</n-descriptions-item>
|
||||
<n-descriptions-item label="事件类型">{{ formValue.eventType }}</n-descriptions-item>
|
||||
<n-descriptions-item label="状态">{{ statusLabel }}</n-descriptions-item>
|
||||
<n-descriptions-item label="尝试次数">{{ formValue.attempts }}</n-descriptions-item>
|
||||
<n-descriptions-item label="目标类型">{{ formValue.targetType }}</n-descriptions-item>
|
||||
<n-descriptions-item label="目标">{{ formValue.target }}</n-descriptions-item>
|
||||
<n-descriptions-item label="操作用户">{{ formValue.actorUserId }}</n-descriptions-item>
|
||||
<n-descriptions-item label="下次重试">
|
||||
{{ formValue.nextRetryAt || '-' }}
|
||||
</n-descriptions-item>
|
||||
<n-descriptions-item label="投递时间">
|
||||
{{ formValue.deliveredAt || '-' }}
|
||||
</n-descriptions-item>
|
||||
<n-descriptions-item label="创建时间">{{ formValue.createdAt }}</n-descriptions-item>
|
||||
<n-descriptions-item label="Payload" :span="2">
|
||||
<n-code :code="formValue.payload || '{}'" language="json" word-wrap />
|
||||
</n-descriptions-item>
|
||||
</n-descriptions>
|
||||
</n-spin>
|
||||
</n-modal>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, ref } from 'vue';
|
||||
import { useDictStore } from '@/store/modules/dict';
|
||||
import { useProjectSettingStore } from '@/store/modules/projectSetting';
|
||||
import { adaModalWidth } from '@/utils/hotgo';
|
||||
import { newState, State } from './model';
|
||||
import { View } from '@/api/imOutbox';
|
||||
|
||||
const dict = useDictStore();
|
||||
const settingStore = useProjectSettingStore();
|
||||
const showModal = ref(false);
|
||||
const loading = ref(false);
|
||||
const formValue = ref<State>(newState(null));
|
||||
const dialogWidth = computed(() => adaModalWidth(920));
|
||||
const sourceLabel = computed(() =>
|
||||
dict.getLabel('ImOutboxSourceOptions', formValue.value.source)
|
||||
);
|
||||
const statusLabel = computed(() =>
|
||||
dict.getLabel('ImOutboxStatusOptions', formValue.value.status)
|
||||
);
|
||||
|
||||
function openModal(state: State) {
|
||||
showModal.value = true;
|
||||
formValue.value = newState(state);
|
||||
loading.value = true;
|
||||
View({ tenantId: state.tenantId, source: state.source, id: state.id })
|
||||
.then((res) => {
|
||||
formValue.value = newState(res);
|
||||
})
|
||||
.finally(() => {
|
||||
loading.value = false;
|
||||
});
|
||||
}
|
||||
|
||||
defineExpose({ openModal });
|
||||
</script>
|
||||
@@ -1,10 +1,15 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="n-layout-page-header">
|
||||
<n-card :bordered="false" title="IM实名审核"></n-card>
|
||||
<n-card :bordered="false" title="IM实名审核" />
|
||||
</div>
|
||||
<n-card :bordered="false" class="proCard">
|
||||
<BasicForm ref="searchFormRef" @register="register" @submit="reloadTable" @reset="reloadTable" />
|
||||
<BasicForm
|
||||
ref="searchFormRef"
|
||||
@register="register"
|
||||
@submit="reloadTable"
|
||||
@reset="reloadTable"
|
||||
/>
|
||||
<BasicTable
|
||||
ref="actionRef"
|
||||
:columns="columns"
|
||||
@@ -24,6 +29,7 @@
|
||||
import { useDialog, useMessage } from 'naive-ui';
|
||||
import { BasicTable, TableAction } from '@/components/Table';
|
||||
import { BasicForm, useForm } from '@/components/Form/index';
|
||||
import { useUserStore } from '@/store/modules/user';
|
||||
import { List, Review } from '@/api/imRealname';
|
||||
import { columns, loadOptions, loadTenantOptions, schemas, State, tenantOptions } from './model';
|
||||
import { adaTableScrollX } from '@/utils/hotgo';
|
||||
@@ -31,10 +37,17 @@
|
||||
|
||||
const message = useMessage();
|
||||
const dialog = useDialog();
|
||||
const userStore = useUserStore();
|
||||
const isPlatformAdmin = computed(() => userStore.isCompanyDept);
|
||||
const actionRef = ref();
|
||||
const searchFormRef = ref<any>({});
|
||||
const viewRef = ref();
|
||||
|
||||
const tenantSchema = schemas.value.find((item) => item.field === 'tenantId');
|
||||
if (tenantSchema) {
|
||||
tenantSchema.ifShow = () => isPlatformAdmin.value;
|
||||
}
|
||||
|
||||
const actionColumn = reactive({
|
||||
width: 200,
|
||||
title: '操作',
|
||||
@@ -44,7 +57,11 @@
|
||||
return h(TableAction as any, {
|
||||
style: 'button',
|
||||
actions: [
|
||||
{ label: '详情', onClick: () => viewRef.value.openModal(record), auth: ['/imRealname/view'] },
|
||||
{
|
||||
label: '详情',
|
||||
onClick: () => viewRef.value.openModal(record),
|
||||
auth: ['/imRealname/view'],
|
||||
},
|
||||
{
|
||||
label: '通过',
|
||||
onClick: () => handleReview(record, 'verified'),
|
||||
@@ -71,7 +88,8 @@
|
||||
|
||||
const loadDataTable = async (res) => {
|
||||
const params = { ...searchFormRef.value?.formModel, ...res };
|
||||
if (!params.tenantId) return { list: [], page: 1, pageCount: 0, totalCount: 0 };
|
||||
if (isPlatformAdmin.value && !params.tenantId)
|
||||
return { list: [], page: 1, pageCount: 0, totalCount: 0 };
|
||||
return await List(params);
|
||||
};
|
||||
|
||||
@@ -98,10 +116,12 @@
|
||||
|
||||
onMounted(async () => {
|
||||
loadOptions();
|
||||
await loadTenantOptions();
|
||||
if (tenantOptions.value.length > 0) {
|
||||
await setFieldsValue({ tenantId: tenantOptions.value[0].value });
|
||||
reloadTable();
|
||||
if (isPlatformAdmin.value) {
|
||||
await loadTenantOptions();
|
||||
if (tenantOptions.value.length > 0) {
|
||||
await setFieldsValue({ tenantId: tenantOptions.value[0].value });
|
||||
}
|
||||
}
|
||||
reloadTable();
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -1,18 +1,34 @@
|
||||
<template>
|
||||
<n-modal v-model:show="showModal" preset="dialog" :show-icon="false" :title="'实名审核详情 #' + formValue.id" :style="{ width: dialogWidth }">
|
||||
<n-modal
|
||||
v-model:show="showModal"
|
||||
preset="dialog"
|
||||
:show-icon="false"
|
||||
:title="'实名审核详情 #' + formValue.id"
|
||||
:style="{ width: dialogWidth }"
|
||||
>
|
||||
<n-spin :show="loading">
|
||||
<n-descriptions label-placement="left" bordered :column="settingStore.isMobile ? 1 : 2">
|
||||
<n-descriptions-item label="租户">{{ formValue.tenantName }}</n-descriptions-item>
|
||||
<n-descriptions-item label="用户ID">{{ formValue.userId }}</n-descriptions-item>
|
||||
<n-descriptions-item label="提交ID">{{ formValue.submissionId }}</n-descriptions-item>
|
||||
<n-descriptions-item label="状态">{{ statusLabel }}</n-descriptions-item>
|
||||
<n-descriptions-item label="脱敏姓名">{{ formValue.maskedName || '-' }}</n-descriptions-item>
|
||||
<n-descriptions-item label="脱敏证件">{{ formValue.maskedIdNumber || '-' }}</n-descriptions-item>
|
||||
<n-descriptions-item label="脱敏姓名">{{
|
||||
formValue.maskedName || '-'
|
||||
}}</n-descriptions-item>
|
||||
<n-descriptions-item label="脱敏证件">{{
|
||||
formValue.maskedIdNumber || '-'
|
||||
}}</n-descriptions-item>
|
||||
<n-descriptions-item label="证件类型">{{ formValue.idType || '-' }}</n-descriptions-item>
|
||||
<n-descriptions-item label="证件哈希">{{ formValue.idNumberHash || '-' }}</n-descriptions-item>
|
||||
<n-descriptions-item label="通过时间">{{ formValue.verifiedAt || '-' }}</n-descriptions-item>
|
||||
<n-descriptions-item label="证件哈希">{{
|
||||
formValue.idNumberHash || '-'
|
||||
}}</n-descriptions-item>
|
||||
<n-descriptions-item label="通过时间">{{
|
||||
formValue.verifiedAt || '-'
|
||||
}}</n-descriptions-item>
|
||||
<n-descriptions-item label="创建时间">{{ formValue.createdAt || '-' }}</n-descriptions-item>
|
||||
<n-descriptions-item label="审核原因" :span="2">{{ formValue.reviewReason || '-' }}</n-descriptions-item>
|
||||
<n-descriptions-item label="审核原因" :span="2">{{
|
||||
formValue.reviewReason || '-'
|
||||
}}</n-descriptions-item>
|
||||
</n-descriptions>
|
||||
</n-spin>
|
||||
<template #action>
|
||||
@@ -35,7 +51,9 @@
|
||||
const loading = ref(false);
|
||||
const formValue = ref<State>(newState(null));
|
||||
const dialogWidth = computed(() => adaModalWidth(820));
|
||||
const statusLabel = computed(() => dict.getLabel('ImRealnameStatusOptions', formValue.value.status));
|
||||
const statusLabel = computed(() =>
|
||||
dict.getLabel('ImRealnameStatusOptions', formValue.value.status)
|
||||
);
|
||||
|
||||
function openModal(state: State) {
|
||||
showModal.value = true;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="n-layout-page-header">
|
||||
<n-card :bordered="false" title="IM举报反馈"></n-card>
|
||||
<n-card :bordered="false" title="IM举报反馈" />
|
||||
</div>
|
||||
<n-card :bordered="false" class="proCard">
|
||||
<BasicForm
|
||||
@@ -30,6 +30,7 @@
|
||||
import { useDialog, useMessage } from 'naive-ui';
|
||||
import { BasicTable, TableAction } from '@/components/Table';
|
||||
import { BasicForm, useForm } from '@/components/Form/index';
|
||||
import { useUserStore } from '@/store/modules/user';
|
||||
import { List, Status } from '@/api/imWorkOrder';
|
||||
import { columns, loadOptions, loadTenantOptions, schemas, State, tenantOptions } from './model';
|
||||
import { adaTableScrollX } from '@/utils/hotgo';
|
||||
@@ -37,10 +38,17 @@
|
||||
|
||||
const message = useMessage();
|
||||
const dialog = useDialog();
|
||||
const userStore = useUserStore();
|
||||
const isPlatformAdmin = computed(() => userStore.isCompanyDept);
|
||||
const actionRef = ref();
|
||||
const searchFormRef = ref<any>({});
|
||||
const viewRef = ref();
|
||||
|
||||
const tenantSchema = schemas.value.find((item) => item.field === 'tenantId');
|
||||
if (tenantSchema) {
|
||||
tenantSchema.ifShow = () => isPlatformAdmin.value;
|
||||
}
|
||||
|
||||
const actionColumn = reactive({
|
||||
width: 240,
|
||||
title: '操作',
|
||||
@@ -50,7 +58,11 @@
|
||||
return h(TableAction as any, {
|
||||
style: 'button',
|
||||
actions: [
|
||||
{ label: '详情', onClick: () => viewRef.value.openModal(record), auth: ['/imWorkOrder/view'] },
|
||||
{
|
||||
label: '详情',
|
||||
onClick: () => viewRef.value.openModal(record),
|
||||
auth: ['/imWorkOrder/view'],
|
||||
},
|
||||
{
|
||||
label: '处理中',
|
||||
onClick: () => handleStatus(record, 'processing'),
|
||||
@@ -90,7 +102,8 @@
|
||||
|
||||
const loadDataTable = async (res) => {
|
||||
const params = { ...searchFormRef.value?.formModel, ...res };
|
||||
if (!params.tenantId || !params.type) return { list: [], page: 1, pageCount: 0, totalCount: 0 };
|
||||
if ((isPlatformAdmin.value && !params.tenantId) || !params.type)
|
||||
return { list: [], page: 1, pageCount: 0, totalCount: 0 };
|
||||
return await List(params);
|
||||
};
|
||||
|
||||
@@ -100,7 +113,8 @@
|
||||
|
||||
async function handleReset() {
|
||||
const defaults: Recordable = { type: 'abuse' };
|
||||
if (tenantOptions.value.length > 0) defaults.tenantId = tenantOptions.value[0].value;
|
||||
if (isPlatformAdmin.value && tenantOptions.value.length > 0)
|
||||
defaults.tenantId = tenantOptions.value[0].value;
|
||||
await setFieldsValue(defaults);
|
||||
reloadTable();
|
||||
}
|
||||
@@ -112,23 +126,28 @@
|
||||
positiveText: '确定',
|
||||
negativeText: '取消',
|
||||
onPositiveClick: () => {
|
||||
return Status({ tenantId: record.tenantId, type: record.type, id: record.id, status }).then(() => {
|
||||
message.success('操作成功');
|
||||
reloadTable();
|
||||
});
|
||||
return Status({ tenantId: record.tenantId, type: record.type, id: record.id, status }).then(
|
||||
() => {
|
||||
message.success('操作成功');
|
||||
reloadTable();
|
||||
}
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
loadOptions();
|
||||
await loadTenantOptions();
|
||||
if (tenantOptions.value.length > 0) {
|
||||
await setFieldsValue({ tenantId: tenantOptions.value[0].value, type: 'abuse' });
|
||||
reloadTable();
|
||||
} else {
|
||||
message.warning('暂无可用租户');
|
||||
await setFieldsValue({ type: 'abuse' });
|
||||
if (isPlatformAdmin.value) {
|
||||
await loadTenantOptions();
|
||||
if (tenantOptions.value.length > 0) {
|
||||
await setFieldsValue({ tenantId: tenantOptions.value[0].value, type: 'abuse' });
|
||||
} else {
|
||||
message.warning('暂无可用租户');
|
||||
}
|
||||
}
|
||||
reloadTable();
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
@@ -18,13 +18,27 @@
|
||||
<n-descriptions-item label="状态">{{ statusLabel }}</n-descriptions-item>
|
||||
<n-descriptions-item label="提交人ID">{{ formValue.userId }}</n-descriptions-item>
|
||||
<n-descriptions-item label="分类">{{ formValue.category || '-' }}</n-descriptions-item>
|
||||
<n-descriptions-item label="目标类型">{{ formValue.targetType || '-' }}</n-descriptions-item>
|
||||
<n-descriptions-item label="目标摘要">{{ formValue.targetSummary || '-' }}</n-descriptions-item>
|
||||
<n-descriptions-item label="联系方式">{{ formValue.contact || '-' }}</n-descriptions-item>
|
||||
<n-descriptions-item label="包含诊断">{{ formValue.includeDiagnostics ? '是' : '否' }}</n-descriptions-item>
|
||||
<n-descriptions-item label="创建时间">{{ formValue.createdAt || '-' }}</n-descriptions-item>
|
||||
<n-descriptions-item label="修改时间">{{ formValue.updatedAt || '-' }}</n-descriptions-item>
|
||||
<n-descriptions-item label="内容" :span="2">{{ formValue.content || formValue.description || '-' }}</n-descriptions-item>
|
||||
<n-descriptions-item label="目标类型">{{
|
||||
formValue.targetType || '-'
|
||||
}}</n-descriptions-item>
|
||||
<n-descriptions-item label="目标摘要">{{
|
||||
formValue.targetSummary || '-'
|
||||
}}</n-descriptions-item>
|
||||
<n-descriptions-item label="联系方式">{{
|
||||
formValue.contact || '-'
|
||||
}}</n-descriptions-item>
|
||||
<n-descriptions-item label="包含诊断">{{
|
||||
formValue.includeDiagnostics ? '是' : '否'
|
||||
}}</n-descriptions-item>
|
||||
<n-descriptions-item label="创建时间">{{
|
||||
formValue.createdAt || '-'
|
||||
}}</n-descriptions-item>
|
||||
<n-descriptions-item label="修改时间">{{
|
||||
formValue.updatedAt || '-'
|
||||
}}</n-descriptions-item>
|
||||
<n-descriptions-item label="内容" :span="2">{{
|
||||
formValue.content || formValue.description || '-'
|
||||
}}</n-descriptions-item>
|
||||
<n-descriptions-item label="证据/附件" :span="2">
|
||||
<n-code :code="formValue.evidenceJson || '-'" language="json" word-wrap />
|
||||
</n-descriptions-item>
|
||||
@@ -34,7 +48,9 @@
|
||||
<n-descriptions-item label="客户端上下文" :span="2">
|
||||
<n-code :code="formValue.clientContextJson || '-'" language="json" word-wrap />
|
||||
</n-descriptions-item>
|
||||
<n-descriptions-item label="幂等键" :span="2">{{ formValue.idempotencyKey || '-' }}</n-descriptions-item>
|
||||
<n-descriptions-item label="幂等键" :span="2">{{
|
||||
formValue.idempotencyKey || '-'
|
||||
}}</n-descriptions-item>
|
||||
</n-descriptions>
|
||||
</n-spin>
|
||||
</n-scrollbar>
|
||||
@@ -61,7 +77,9 @@
|
||||
const showModal = ref(false);
|
||||
const formValue = ref<State>(newState(null));
|
||||
const dialogWidth = computed(() => adaModalWidth(980));
|
||||
const statusLabel = computed(() => dict.getLabel('ImWorkOrderStatusOptions', formValue.value.status));
|
||||
const statusLabel = computed(() =>
|
||||
dict.getLabel('ImWorkOrderStatusOptions', formValue.value.status)
|
||||
);
|
||||
const typeLabel = computed(() => (formValue.value.type === 'abuse' ? '举报' : '反馈'));
|
||||
|
||||
function closeForm() {
|
||||
|
||||
Reference in New Issue
Block a user