feat: add tenant-aware IM management console

This commit is contained in:
richjohnrambo
2026-08-19 00:31:09 +08:00
parent 642b7ec5da
commit 89ba6bcff8
126 changed files with 11941 additions and 339 deletions
@@ -0,0 +1,747 @@
# IM HotGo 注册短信开关与邀请码管理改造交接文档
> 文档状态:已按数据库直连方案实现,作为现状与后续 Kafka 演进说明
> 对应实施顺序:注册改造计划第 4 项(HotGo 后台)
> HotGo 项目:`/Users/youshunlai/go_workspace/im_manager`
> IM 服务项目:`/Users/youshunlai/go_workspace/im_server`
> 编写日期:2026-08-18
## 1. 文档用途
本文供新的开发会话直接接手 HotGo 项目使用。阅读本文后,应能明确:
1. 为什么要在 HotGo 中增加注册短信开关和邀请码管理。
2. 哪些数据属于 HotGo 控制库,哪些数据属于 IM 租户业务库。
3. HotGo 后端、前端、菜单权限、统一租户身份、审计和配置需要修改什么。
4. HotGo 与 IM 如何通过租户业务库协作,以及后续如何迁移到 Kafka。
5. 如何验证实现没有破坏租户隔离,也没有泄露邀请码明文。
本文指导 HotGo 项目改造。HotGo 负责邀请码生成和管理;IM 仍独占客户端注册、短信验证码和邀请码使用记录事务。
## 2. 开发前必须阅读
在修改 `/Users/youshunlai/go_workspace/im_manager` 前,先阅读以下文档:
1. `/Users/youshunlai/go_workspace/im_server/docs/IM-注册、邀请码与认证数据模型改造需求文档.md`
2. `/Users/youshunlai/go_workspace/im_server/docs/IM-前后端对接文档.md` 中 1.4、1.5 注册章节
3. `/Users/youshunlai/go_workspace/im_server/docs/IM-后台需求文档.md` 中租户权限、用户管理和审计章节
4. `/Users/youshunlai/go_workspace/im_server/docs/IM-独立库多租户改造方案.md`
规则冲突时按以下优先级处理:
1. 本文针对 HotGo 的落地边界和接口约定。
2. 《IM 注册、邀请码与认证数据模型改造需求文档》。
3. 《IM 独立库多租户改造方案》。
4. 其他较早的需求和设计文档。
`IM-服务端多租户改造方案.md` 已废弃,不得作为实现依据。当前多租户方案是“一套 IM 服务、一个 HotGo 控制库、每租户一个独立 IM 业务库”。
## 3. 需求前因后果
### 3.1 注册页面最终规则
客户端注册页面同时展示用户名、手机号、短信验证码、密码和邀请码,但用户名与手机号是二选一关系:
1. 手机号非空时,以手机号方式为准,忽略用户名输入。
2. 手机号方式注册后,`im_users.username` 和 `im_users.phone` 都保存规范化后的 `+86` 手机号。
3. 手机号为空时使用普通用户名,用户名必须匹配 `^[a-z0-9]{1,10}$`,此时 `phone=NULL`。
4. 普通用户名方式不能自助找回密码,只能由企业管理员重置。
5. 邀请码固定为 5 位 ASCII 大小写英文字母,区分大小写、租户内唯一,有效期内可供多人使用。
### 3.2 为什么需要短信开关
短信服务在不同部署环境中可能暂未启用,因此需要平台级开关:
| 场景 | 短信开关开启 | 短信开关关闭 |
| --- | --- | --- |
| 点击获取验证码 | IM 服务真实调用短信服务商 | IM 服务返回正常挑战和倒计时,但不调用服务商 |
| 手机号注册 | 必须验证短信;邀请码不能绕过 | 必须提供有效邀请码,可无验证码注册 |
| 用户名注册 | 不发送短信,必须提供有效邀请码 | 不发送短信,必须提供有效邀请码 |
| 客户端是否知道开关 | 不直接暴露 | 不直接暴露 |
HotGo 只负责管理开关值。是否调用短信服务商、是否允许注册,全部由 IM 服务端判定。
### 3.3 为什么当前改为 HotGo 直接访问租户库
邀请码属于 IM 租户业务数据:
1. 每个租户有独立业务库。
2. 邀请码批次和主表位于 `hotgo` 控制库,使用明细位于对应租户业务库。
3. HotGo 邀请码管理直接访问控制库;密码重置等租户业务功能仍通过 `im_tenant.id` 和服务端保存的 DSN 选择租户库。
4. 当前部署中 HotGo 到 IM 的内部 HTTP 地址不可达,继续维护 HMAC 管理 API 会增加一套没有必要的调用链。
因此当前采用“控制库主数据 + 租户库使用事实”的混合边界:HotGo 负责邀请码生成、查询、导出和撤销;IM 从控制库校验邀请码,并在租户库记录使用关系和 outbox 事件。两端各自只写自己负责的数据。
密码重置等需要 IM 立即感知的操作写入 `im_business_events_outbox`。当前 IM 轮询消费,后续改为 Kafka 时保留事件契约,只替换传输层。
## 4. 系统边界
```mermaid
flowchart LR
A["HotGo 管理后台浏览器"] -->|HotGo 登录 Token| B["HotGo 后端"]
B -->|读写全局开关| C[("hotgo.hg_sys_config")]
B -->|读取管理员 tenant_id| D[("hotgo.im_tenant")]
B -->|管理邀请码| G[("hotgo.im_registration_invite_batches")]
B -->|管理邀请码| H[("hotgo.im_registration_invite_codes")]
B -->|其他租户业务按服务端 DSN| F[("租户 IM 业务库")]
F --> I["im_registration_invite_uses"]
J["IM 注册与发码接口"] -->|短时缓存读取| C
J -->|按 tenant_id 校验| H
J -->|记录使用关系与事件| F
B -->|写 security.password_reset| K["im_business_events_outbox"]
F --> K
K -->|当前轮询,后续 Kafka| J
```
### 4.1 HotGo 负责
1. 在 `hg_sys_config` 保存全局注册短信开关。
2. 只允许平台超级管理员修改开关。
3. 为邀请码提供管理页面、表单、权限和审计入口。
4. 从当前登录管理员身份解析可信的 IM `tenant_id`。
5. 通过 `imtenant.DB()` 直连所选租户业务库,不接受浏览器传入 DSN。
6. 对邀请码明文查询、查看和导出执行独立权限校验。
### 4.2 IM 服务负责
1. 读取并缓存短信开关。
2. 实际发送或抑制短信。
3. 注册时大小写敏感地校验邀请码,并记录邀请码与新用户的使用关系。
4. 邀请码使用后不改变 active 状态。
5. 消费 `security.password_reset` 事件并清退在线会话。
6. 登录时以 `auth_version` 拒绝密码重置前签发的旧 Token。
7. 消费 `registration.invite_used`,将租户库使用统计幂等投影到控制库。
### 4.3 明确禁止
1. 浏览器直接连接租户数据库。
2. 浏览器通过 Header、Query 或 Body 自报可信 `tenant_id`。
3. HotGo 后端根据浏览器传入的 `db_name` 或 `db_dsn` 连接数据库。
4. HotGo 修改 `im_registration_invite_uses`;该表只允许 IM 注册事务写入。
5. HotGo 绕过 outbox 直接修改在线会话状态。
6. 在普通访问日志、错误日志、埋点或 URL 中记录完整邀请码。
## 5. 当前 HotGo 项目现状
目标仓库 `/Users/youshunlai/go_workspace/im_manager` 基于 GoFrame 2、Vue 3 和 Naive UI。
已确认的现状:
1. 通用配置后端位于 `server/api/admin/config`、`server/internal/controller/admin/sys/config.go`、`server/internal/logic/sys/config.go`。
2. 通用配置前端位于 `web/src/views/system/config` 和 `web/src/api/sys/config.ts`。
3. `hg_sys_config` 支持 `group/key/type/value/default_value`,前端不能通过通用接口新增不存在的配置项。
4. `sys_switch` 字典实际定义为“开启=`1`、关闭=`2`”。
5. HotGo 管理接口权限按“路由路径 + HTTP Method”使用 Casbin 校验,前端按钮权限也使用路由路径,例如 `hasPermission(['/notice/edit'])`。
6. `hotgo.im_tenant` 是全系统唯一租户注册表,由 HotGo 维护,不存在另一套需要映射的“IM 租户”。
7. 后台管理员通过 `hg_admin_member.tenant_id` 直接引用 `im_tenant.id`,登录身份必须携带该字段;不再使用独立租户绑定表。
8. `hg_sys_log` 会保存 GET Query 和 POST Body;现有逻辑只自动掩码名称包含 `password` 的 POST 字段,邀请码查询参数会泄漏,必须补充脱敏。
## 6. 建议开发顺序
### 阶段 A:先完成 HotGo 基础边界
1. 增加全局短信开关配置种子和专用读写接口。
2. 增加 `hg_admin_member.tenant_id` 外键与登录身份解析,统一使用 `im_tenant.id`。
3. 增加租户库直连、Snowflake ID 和数据库事件写入能力。
4. 增加邀请码相关菜单、路由权限和后台代理接口。
5. 增加邀请码管理页面。
6. 增加敏感字段日志脱敏和操作审计。
### 阶段 B:与 IM 服务联调
1. 核对 IM 租户库迁移已执行且表结构一致。
2. 核对服务凭证、租户上下文和操作人上下文可被 IM 验证。
3. 联调单个生成、批量生成、分页查询、撤销、详情和导出。
4. 联调短信开关在 IM 服务缓存时间内生效。
IM 管理接口未完成时,可以通过接口 Client 抽象和测试 Stub 开发页面,但不得用 HotGo 直写租户数据库作为临时方案。
## 7. 全局注册短信开关
### 7.1 数据定义
在 HotGo 控制库 `hg_sys_config` 中增加且只保留一条配置:
```text
group = im
name = 注册短信发送
type = bool
key = register_sms_enabled
value = 1
default_value = 1
status = 1
is_default = 1
```
建议的 MySQL 幂等种子:
```sql
INSERT INTO hg_sys_config
(`group`, `name`, `type`, `key`, `value`, `default_value`, `sort`, `tip`, `is_default`, `status`, `created_at`, `updated_at`)
SELECT
'im', '注册短信发送', 'bool', 'register_sms_enabled', '1', '1', 10,
'控制 IM 注册验证码是否真实发送', 1, 1, NOW(), NOW()
WHERE NOT EXISTS (
SELECT 1 FROM hg_sys_config
WHERE `group` = 'im' AND `key` = 'register_sms_enabled'
);
```
同时为 `(group, key)` 增加唯一约束。加索引前先检查并清理重复配置,不能让 IM 服务随机读到多条值。
```sql
ALTER TABLE hg_sys_config
ADD UNIQUE KEY uk_hg_sys_config_group_key (`group`, `key`);
```
新装数据库种子、实际部署迁移及项目保留的 MySQL/PostgreSQL/SQLite 初始化脚本应按项目发布策略同步,不能只改一份示例 SQL。
### 7.2 `sys_switch` 映射陷阱
`sys_switch` 字典值与真实配置值不相同:
| UI 字典值 | UI 含义 | `hg_sys_config.value` |
| --- | --- | --- |
| `1` | 开启 | `1` |
| `2` | 关闭 | `0` |
实现要求:
1. 页面加载时将后端 bool 值映射为字典值:`true -> 1`,`false -> 2`。
2. 页面提交时反向映射:`1 -> true`,`2 -> false`。
3. 不得把字典值 `2` 原样写入 bool 配置;GoFrame 的 bool 转换可能把非零值当成 true。
4. 数据库只允许保存可解释为 `1/0` 的值。
### 7.3 后端接口
不要直接让页面复用通用 `/config/update`。新增专用接口,以便强制执行平台管理员校验:
| 方法 | HotGo 路径 | 权限 |
| --- | --- | --- |
| GET | `/imRegistration/config/get` | 平台超级管理员 |
| POST | `/imRegistration/config/update` | 平台超级管理员 |
读取响应:
```json
{
"registerSmsEnabled": true,
"updatedAt": "2026-08-18T16:00:00+08:00"
}
```
更新请求:
```json
{
"registerSmsEnabled": false
}
```
后端必须在业务逻辑层再次调用 `service.AdminMember().VerifySuperId(ctx, contexts.GetUserId(ctx))`,不能只依赖菜单是否可见。更新时只允许修改固定的 `group=im/key=register_sms_enabled`,不接受前端传 group 或 key。
### 7.4 页面位置
建议在 `web/src/views/system/config/system.vue` 增加“IM 注册”设置项,并新增:
```text
web/src/views/system/config/ImRegistrationSetting.vue
```
页面只展示一个“注册短信发送”开关和保存按钮。选项通过 `dict.loadOptions(['sys_switch'])` 加载,并按 7.2 做显式映射。
### 7.5 IM 服务读取约定
HotGo 不需要额外暴露公开配置接口。IM 服务直接从控制库读取:
```sql
SELECT value
FROM hg_sys_config
WHERE `group` = 'im'
AND `key` = 'register_sms_enabled'
AND status = 1
LIMIT 1;
```
约定:
1. 种子默认值为开启。
2. IM 服务缓存建议为 30 秒,缓存时长可配置。
3. 临时读取失败时优先使用最近一次成功值。
4. 进程启动后无缓存且配置缺失、停用或非法时,按“开启”处理,防止邀请码绕过短信验证,并记录告警。
5. HotGo 更新后不承诺立即生效,验收标准为 30 秒内生效。
## 8. 统一租户身份
### 8.1 唯一租户主键
`hotgo.im_tenant.id` 是 HotGo 和 IM 服务共同使用的唯一 `tenant_id`。HotGo 负责维护租户注册信息和业务库路由,IM 服务根据该 ID 选择租户业务库。不得再创建 `im_admin_tenant_binding` 或其他租户映射表。
### 8.2 管理员租户归属
后台管理员记录直接引用统一租户:
```sql
ALTER TABLE hg_admin_member
ADD COLUMN tenant_id BIGINT NULL COMMENT '统一租户ID,引用 im_tenant.id',
ADD INDEX idx_hg_admin_member_tenant (tenant_id),
ADD CONSTRAINT fk_hg_admin_member_im_tenant
FOREIGN KEY (tenant_id) REFERENCES im_tenant(id);
```
平台账号的 `tenant_id` 可以为空。租户管理员及其创建的下级后台账号必须继承同一个 `im_tenant.id`。只有平台超级管理员可以为后台账号选择或修改租户,租户域管理员提交的 `tenantId` 必须被后端忽略并以当前登录身份为准。
### 8.3 解析规则
租户管理员请求邀请码接口时:
1. 从 HotGo 登录上下文取得 `member_id`、`tenant_id` 和部门类型。
2. 仅允许合法的租户级管理员角色进入;普通用户和商户角色不得管理邀请码。
3. `tenant_id` 必须大于 0,并且对应的 `im_tenant` 必须存在且为 active。
4. 将该 `tenant_id` 写入 IM 服务调用上下文。
5. 忽略并拒绝浏览器提交的 `tenantId`。
平台超级管理员可以跨租户查看邀请码;列表和导出未选择租户时查询全部。创建、批次查看、撤销及其他写操作必须先选择一个 `im_tenant`。此选择只对 `VerifySuperId` 通过的管理员开放,HotGo 后端仍要检查目标租户存在且 active。
后台用户管理应提供仅平台超级管理员可见的租户选择控件。租户管理员未关联 `im_tenant.id` 时返回明确错误“当前管理员未关联租户”,不能回退到默认租户。
## 9. 租户库直连与事件通道
### 9.1 数据库选择
HotGo 从可信登录上下文取得 `im_tenant.id`。邀请码批次和邀请码主表直接通过 `imtenant.RegistryDB()` 访问 HotGo 控制库;只有密码重置等租户业务操作才通过 `imtenant.DB()` 打开租户业务库。浏览器传入的 `tenantId` 只允许平台超级管理员选择,租户管理员始终使用登录关系中的租户 ID;任何请求都不能提交或覆盖 DSN。
若配置了 `database.imRegistry`,租户注册表从该连接读取;未配置时回退到 HotGo 默认库。业务操作始终使用按租户动态建立的连接组。
### 9.2 Snowflake 配置
HotGo 直接新增 IM 表记录时使用与 IM 相同位布局的 Snowflake ID:
```yaml
imDirectDb:
snowflakeWorkerId: 1023
```
也可由 `IM_DIRECT_DB_SNOWFLAKE_WORKER_ID` 覆盖。每个 HotGo 写库实例必须使用唯一 worker ID,并且不能与 IM 节点重叠。当前单实例默认预留 `1023`,多实例部署前必须分配 worker ID。
### 9.3 写入职责
HotGo 可以写:
1. HotGo 控制库中的 `im_registration_invite_batches`、`im_registration_invite_codes`。
2. 管理员重置密码时的 `im_auth.secret` 和 `im_user_security`。
3. 管理操作产生的 `im_business_events_outbox`。
IM 独占写入:
1. 客户端注册事务及用户创建。
2. 租户业务库中的 `im_registration_invite_uses` 以及同事务的 `registration.invite_used` outbox 事件。
3. 客户端登录、改密、短信找回等认证事务。
### 9.4 Kafka 演进
管理员重置密码在数据库事务内写入:
```json
{
"event_type": "security.password_reset",
"payload": {
"user_id": 198000000000000001,
"auth_version": 2,
"operator_id": 10001,
"request_id": "..."
}
}
```
当前 IM 在注册写入 outbox 后立即触发一次消费,并每秒按在线会话涉及的租户轮询 pending 事件。`security.password_reset` 消费后清退该用户会话;`registration.invite_used` 消费时从租户使用明细重新聚合 `COUNT/MAX(used_at)`,覆盖 HotGo 控制库的 `usage_count/last_used_at`,成功后标记 delivered。离线用户不依赖密码重置轮询,后续登录时由 `auth_version` 直接拒绝旧 Token。
邀请码使用事件契约:
```json
{
"event_type": "registration.invite_used",
"payload": {
"tenant_id": 1,
"invite_code_id": 198000000000000001,
"user_id": 198000000000000002,
"request_id": "acc-request-id",
"used_at": "2026-08-19T10:00:00.000Z"
}
}
```
迁移 Kafka 时,生产者继续在业务事务内写 outbox,由独立投递器发布同一事件;消费者保持事件类型与 payload 不变。Kafka 投递确认后再标记 outbox delivered,避免数据库提交成功但消息丢失。
## 10. 邀请码接口契约
### 10.1 数据表契约
HotGo 在控制库直接维护带 `tenant_id` 的 `im_registration_invite_batches` 和 `im_registration_invite_codes`。邀请码状态仅为 `active/expired/revoked`,使用后仍为 active;`im_registration_invite_uses` 只由 IM 注册事务写入租户库。控制库中的 `usage_count/last_used_at` 由 outbox 消费器幂等投影,HotGo 列表不跨租户库做联表聚合。
平台超级管理员在列表和导出接口中可不传 `tenantId` 以查询全部租户;创建、批次查看、撤销等写操作必须明确选择一个 active 租户。租户管理员只能查询和操作登录关系绑定的 `im_tenant.id`。
邀请码明文不得放入 URL。查询完整邀请码时必须使用 HotGo POST Body,普通请求日志需对 `inviteCode` 脱敏。
### 10.2 HotGo 对浏览器接口
沿用 HotGo 现有命名风格,建议增加:
| 方法 | HotGo 路径 | 说明 |
| --- | --- | --- |
| POST | `/imInvite/list` | 分页查询,过滤条件放在 JSON Body |
| POST | `/imInvite/create` | 单个生成 |
| POST | `/imInvite/batchCreate` | 批量生成 |
| GET | `/imInvite/batchView` | 批次详情 |
| POST | `/imInvite/revoke` | 单个撤销 |
| POST | `/imInvite/batchRevoke` | 批量撤销 |
| POST | `/imInvite/export` | 明文导出,过滤条件放在 JSON Body |
HotGo controller 只做 DTO 转换、调用 service 和包装响应。租户解析、平台管理员判断、权限复核、审计和租户库访问放在 logic/service 层。
### 10.3 单个生成
HotGo 请求:
```json
{
"expiresAt": "2026-09-01T00:00:00+08:00",
"remark": "新员工入职",
"idempotencyKey": "7be3de87-994f-46c6-86bd-844ffb54f83f"
}
```
规则:
1. `expiresAt` 必填,必须晚于服务端当前时间。
2. `remark` 最长 255 字符。
3. 推荐单个生成也传幂等键,避免提交成功但响应超时时重复创建。
4. 邀请码由 HotGo 使用 `crypto/rand` 生成并依靠租户库唯一索引兜底,HotGo 不接受用户自定义 code。
响应数据:
```json
{
"id": 198000000000000001,
"code": "AbCdE",
"status": "active",
"expiresAt": "2026-09-01T00:00:00+08:00",
"remark": "新员工入职",
"createdBy": 10001,
"createdAt": "2026-08-18T16:10:00+08:00"
}
```
### 10.4 批量生成
HotGo 请求:
```json
{
"quantity": 100,
"expiresAt": "2026-09-01T00:00:00+08:00",
"remark": "2026年8月新员工入职",
"idempotencyKey": "4dc7f13e-448c-4b49-b056-57a2fde18f95"
}
```
规则:
1. `quantity` 为 1 到 1000,由 HotGo 校验。
2. 同一个提交动作必须复用同一个幂等键,直到得到明确成功响应。
3. 前端可在打开弹窗时生成 UUID,失败重试时复用;成功或关闭弹窗后废弃。
4. HotGo 在批次表保存幂等键;相同参数重试返回原批次,不重复生成。
响应数据:
```json
{
"batchId": 198000000000000100,
"quantity": 100,
"activeCount": 100,
"usageCount": 0,
"revokedCount": 0,
"expiredCount": 0,
"expiresAt": "2026-09-01T00:00:00+08:00",
"createdAt": "2026-08-18T16:15:00+08:00"
}
```
### 10.5 分页查询
列表请求使用 POST JSON Body,支持字段:
```text
page, perPage, inviteCode, status, batchId, createdBy,
createdAtStart, createdAtEnd, expiresAtStart, expiresAtEnd
```
要求:
1. `inviteCode` 仅支持完整 5 位精确查询,不做模糊查询,不转换大小写。
2. `status` 为 `active/expired/revoked`,邀请码使用后仍保持 `active`。
3. HotGo 查询前将 `expires_at <= now` 的 active 记录更新为 expired,不依赖定时任务。
4. 默认按 `created_at DESC, id DESC` 排序。
5. `perPage` 设置合理上限,建议不超过 100。
列表字段至少包括:
```json
{
"id": 198000000000000001,
"batchId": 198000000000000100,
"code": "AbCdE",
"status": "active",
"expiresAt": "2026-09-01T00:00:00+08:00",
"usageCount": 12,
"lastUsedAt": "2026-08-20T09:30:00+08:00",
"createdBy": 10001,
"remark": "新员工入职",
"createdAt": "2026-08-18T16:10:00+08:00"
}
```
### 10.6 撤销
1. 只有 `active` 且未过期的邀请码可撤销;已有使用记录不影响撤销。
2. 已过期、已撤销的邀请码不能恢复为 active。
3. 批量撤销只撤销批次内仍可撤销的码,并返回各状态统计。
4. 页面操作前显示确认框,确认内容不必重复显示完整邀请码。
5. 最终状态由租户库条件更新结果决定,不以前端状态为准。
### 10.7 导出
1. 导出必须具备独立 `/imInvite/export` 权限。
2. 导出过滤条件与列表一致。
3. HotGo 后端分页读取租户库并流式写出,不把整份文件加载到内存。
4. 建议输出带 UTF-8 BOM 的 CSV,字段至少包含邀请码、状态、批次、到期时间、累计使用次数、最后使用时间、创建人、创建时间和备注。
5. 文件名建议为 `registration-invite-codes-YYYYMMDD-HHmmss.csv`。
6. 每次导出必须写审计,但审计详情不得包含邀请码明文列表。
## 11. 页面和交互
建议页面路径:
```text
web/src/views/im/registrationInvite/index.vue
web/src/views/im/registrationInvite/model.ts
web/src/views/im/registrationInvite/CreateModal.vue
web/src/views/im/registrationInvite/BatchCreateModal.vue
web/src/views/im/registrationInvite/BatchViewModal.vue
web/src/api/im/registrationInvite.ts
```
页面放在“IM 管理 / 注册邀请码”菜单下。使用现有 `BasicForm`、`BasicTable`、`TableAction`、`usePermission` 和 Naive UI 组件。
页面必须具备:
1. 完整邀请码、状态、批次 ID、创建人、创建时间和到期时间筛选。
2. 单个生成按钮。
3. 批量生成按钮。
4. 导出按钮。
5. 列表展示完整邀请码、状态、到期时间、批次、使用情况、创建信息和备注。
6. active 行提供撤销操作,其他状态不显示撤销操作。
7. 批次详情展示总数、active、used、expired、revoked 统计。
8. 所有日期时间明确按本地时区展示,提交时发送带时区的 RFC 3339 时间。
9. 加载、空数据、接口错误、重复提交和无权限状态处理完整。
邀请码区分大小写,任何输入组件、复制操作和查询参数都不得自动转为大写或小写。
## 12. 菜单与权限
HotGo 的权限标识使用接口路径。建议创建以下按钮权限:
```text
/imRegistration/config/get
/imRegistration/config/update
/imInvite/list
/imInvite/create
/imInvite/batchCreate
/imInvite/batchView
/imInvite/revoke
/imInvite/batchRevoke
/imInvite/export
```
权限分配:
| 能力 | 平台超级管理员 | 租户管理员 | 其他角色 |
| --- | --- | --- | --- |
| 查看/修改短信开关 | 允许 | 禁止 | 禁止 |
| 选择任意 IM 租户管理邀请码 | 允许 | 禁止 | 禁止 |
| 管理当前绑定租户邀请码 | 允许 | 按权限允许 | 禁止 |
| 查看邀请码明文 | 允许 | 需独立权限 | 禁止 |
| 导出邀请码明文 | 允许 | 需独立权限 | 禁止 |
服务端 Casbin、业务逻辑层租户范围校验、前端按钮隐藏三层都要实现。前端隐藏按钮不是安全边界。
## 13. 日志脱敏与审计
### 13.1 必须修复现有访问日志泄露
`server/internal/logic/sys/log.go` 当前会保存 Query 和 Body,且只掩码 password。至少增加以下字段名的大小写不敏感脱敏:
```text
inviteCode
invite_code
serviceSecret
service_secret
authorization
```
建议抽成统一的递归敏感字段过滤器,同时处理 Query、JSON Body、Form 和 Header。Header 中已有简化逻辑也必须确认不会保存内部服务凭证。
对 `/imInvite/list` 的明文精确查询,邀请码只能放在 POST Body,`hg_sys_log.post_data` 中只能出现 `******`。`hg_sys_log.get_data`、Nginx URL 和其他访问日志中都不能出现真实邀请码。HTTP Client 的 debug 日志同样不得打印完整 URL、Query、请求体或响应体。
### 13.2 业务审计
仅靠普通访问日志不够。建议在 HotGo 控制库增加独立审计表,或者接入项目已有的等价不可变审计设施。最低字段为:
```sql
CREATE TABLE im_admin_operation_audit (
id BIGINT NOT NULL AUTO_INCREMENT,
request_id VARCHAR(64) NOT NULL,
tenant_id BIGINT NULL,
operator_id BIGINT NOT NULL,
action VARCHAR(64) NOT NULL,
target_type VARCHAR(32) NOT NULL,
target_id BIGINT NULL,
result VARCHAR(16) NOT NULL,
ip VARCHAR(64) NOT NULL DEFAULT '',
detail_json JSON NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
PRIMARY KEY (id),
KEY idx_im_admin_audit_tenant_time (tenant_id, created_at),
KEY idx_im_admin_audit_operator_time (operator_id, created_at),
UNIQUE KEY uk_im_admin_audit_request_action (request_id, action)
);
```
必须审计:
1. 短信开关修改,记录旧值和新值。
2. 单个生成和批量生成,记录 code ID 或 batch ID、数量和到期时间,但不记录 code 明文。
3. 邀请码明文查询和详情查看,记录查询行为,但不记录搜索明文。
4. 单个撤销和批量撤销。
5. 明文导出,记录过滤条件摘要和导出数量,不记录明文内容。
6. 后台管理员租户归属变更。
IM 服务也应写自己的业务审计。HotGo 审计记录后台操作者,IM 审计记录实际租户库操作,两者通过 `request_id` 关联。
## 14. 错误处理
HotGo 应将数据库错误稳定映射为后台可理解的错误,不向浏览器返回 DSN、SQL、堆栈或内部配置。
至少处理:
| 场景 | HotGo 行为 |
| --- | --- |
| 当前管理员未关联租户 | 返回明确业务错误,不使用默认租户 |
| 目标租户停用 | 拒绝操作 |
| 无查看/导出权限 | 403 |
| 邀请码已过期或已撤销 | 显示不可撤销,不本地修改状态 |
| 幂等键重复且请求相同 | 返回原结果 |
| 幂等键重复但参数不同 | 返回幂等冲突 |
| 租户库连接失败 | 返回“租户业务库暂不可用”,保留 Request ID |
| 数据库写入失败 | 事务回滚,返回统一后台错误并记录脱敏日志 |
| 导出数据过大 | 使用服务端限制或异步导出,不耗尽内存 |
## 15. 建议代码落点
遵循现有 HotGo 分层,建议新增或修改:
```text
server/api/admin/imregistration/
server/api/admin/iminvite/
server/internal/controller/admin/admin/im_registration.go
server/internal/controller/admin/admin/im_invite.go
server/internal/model/input/adminin/im_registration.go
server/internal/model/input/adminin/im_invite.go
server/internal/service/im.go
server/internal/logic/admin/im_registration.go
server/internal/logic/admin/im_invite.go
server/internal/library/imtenant/db.go
server/internal/library/imtenant/snowflake.go
server/internal/router/admin.go
server/internal/logic/sys/log.go
web/src/api/im/registrationInvite.ts
web/src/views/im/registrationInvite/
web/src/views/system/config/ImRegistrationSetting.vue
web/src/views/system/config/system.vue
```
如果新增 `im_admin_operation_audit`,按 HotGo 现有 GoFrame DAO 生成流程生成 `dao/do/entity`,不要手工修改标有 “Code generated” 的文件。禁止新增 `im_admin_tenant_binding`。
同时提交:
1. 生产数据库幂等迁移。
2. 新装数据库初始化数据。
3. 菜单和按钮权限种子。
4. 默认角色授权或明确的部署授权步骤。
5. Snowflake worker 配置示例,但不提交真实 DSN。
## 16. 测试清单
### 16.1 短信开关
1. 无配置时 IM 按开启处理并告警。
2. 平台超级管理员可查看和修改。
3. 租户管理员即使手工调用接口也不能修改。
4. UI 选择“关闭”后数据库保存 `0`,不是 `2`。
5. UI 重新加载时 `0` 正确显示为“关闭”。
6. IM 服务在 30 秒内读取到新值。
7. 开关审计包含操作者、旧值、新值和 Request ID。
### 16.2 租户隔离
1. 租户 A 管理员只能看到租户 A 邀请码。
2. 修改 Query、Body 或 Header 中的 tenant ID 不能访问租户 B。
3. 未绑定管理员不能回退到 default 租户。
4. 停用租户不能生成、查询、撤销或导出。
5. 平台超级管理员选择租户后只能操作所选租户,切换时列表立即清空并重新请求。
### 16.3 邀请码
1. 单个生成返回恰好 5 位 `[A-Za-z]`。
2. 批量生成数量正确,最大值限制生效。
3. `AbCdE` 和 `abcde` 可作为不同邀请码精确查询。
4. 重复提交相同幂等键不产生第二个批次。
5. active 可撤销,expired/revoked 不可撤销。
6. 同一 active 邀请码可供多个不同用户注册,累计使用次数正确增长且状态保持 active。
7. 列表、详情和导出权限相互独立。
8. 导出文件数量、过滤条件和明文字段正确。
### 16.4 安全和日志
1. `hg_sys_log.get_data/post_data/header_data` 中找不到真实邀请码和服务 secret。
2. 应用日志中找不到邀请码明文、内部 Token 和 Authorization。
3. 每个敏感操作都有 HotGo 审计和 IM 审计,并可通过 Request ID 对应。
4. 无权限接口返回 403,不能只依赖前端按钮隐藏。
5. 数据库连接和 SQL 错误不泄露内部实现信息。
## 17. 完成标准
满足以下条件才能认为 HotGo 第 4 项完成:
1. 全局注册短信开关可由平台超级管理员配置,关闭值正确保存为 `0`。
2. IM 服务能在约定缓存时间内读取开关,缺失和异常时采用安全默认值。
3. HotGo 管理员身份能可靠映射到 `im_tenant.id`,不存在默认租户回退。
4. 邀请码单个、批量、查询、详情、撤销和导出全部通过所选租户库完成。
5. 平台与租户权限、跨租户隔离和服务端复核全部通过测试。
6. 邀请码明文只出现在授权页面和授权导出中,不进入普通日志。
7. 配置修改和邀请码敏感操作均有可关联的审计记录。
8. 后端 Go 测试、前端类型检查/构建和核心页面联调通过。
## 18. 新会话可直接使用的任务说明
在新的开发会话中可直接发送:
```text
请修改 HotGo 项目 /Users/youshunlai/go_workspace/im_manager。
先完整阅读 /Users/youshunlai/go_workspace/im_server/docs/IM-HotGo注册短信开关与邀请码管理改造交接文档.md,
再阅读其中列出的上游 IM 文档。按照交接文档的开发顺序完成 HotGo 的注册短信全局开关、
统一租户身份、租户库直连、邀请码管理后台、菜单权限、日志脱敏和审计。
只允许通过服务端 `im_tenant` 注册信息选择租户库,不要信任浏览器自报 tenant_id 或 DSN,
不要修改 `im_registration_invite_uses`,不要把邀请码明文写入普通日志。
开始前先检查 im_manager 当前工作区已有改动,并保留与本任务无关的用户修改。
```
+713
View File
@@ -0,0 +1,713 @@
-- IM product database changes summary, chapters 1-11.
-- Target:
-- public backend database: hotgo
-- default tenant business database: tinode
-- Notes:
-- 1. Public tenant registry tables live in hotgo.
-- 2. Tenant-scoped business tables live in each tenant database and do not include tenant_id.
-- 3. This file is for review and manual execution. Existing legacy columns/indexes should be checked before ALTER/DROP.
-- 4. Table definitions are aligned with server/db/mysql/adapter.go plus im_verification_codes used by chapter 1 verification APIs.
SET NAMES utf8mb4;
CREATE DATABASE IF NOT EXISTS hotgo CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;
CREATE DATABASE IF NOT EXISTS tinode CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;
-- ============================================================
-- Public database: tenant registry.
-- ============================================================
CREATE TABLE IF NOT EXISTS hotgo.im_tenant (
id BIGINT NOT NULL AUTO_INCREMENT,
code VARCHAR(64) NOT NULL,
name VARCHAR(128) NOT NULL,
tenant_desc VARCHAR(256),
db_name VARCHAR(128) NOT NULL DEFAULT '',
db_dsn VARCHAR(1024) NOT NULL DEFAULT '',
state SMALLINT NOT NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
created_by BIGINT NOT NULL,
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
updated_by BIGINT NOT NULL,
PRIMARY KEY (id),
UNIQUE INDEX uk_im_tenant_code (code),
INDEX idx_im_tenant_db_name (db_name)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
CREATE TABLE IF NOT EXISTS hotgo.im_tenant_config (
id BIGINT NOT NULL AUTO_INCREMENT,
tenant_id BIGINT NOT NULL,
max_users BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '0 means unlimited',
max_groups BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '0 means unlimited',
max_group_members INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '0 means unlimited',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
created_by BIGINT NOT NULL,
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
updated_by BIGINT NOT NULL,
PRIMARY KEY (id),
UNIQUE INDEX uk_im_tenant_config_tenant_id (tenant_id),
CONSTRAINT fk_im_tenant_config_tenant FOREIGN KEY (tenant_id) REFERENCES hotgo.im_tenant(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
-- ============================================================
-- Tenant database: chapter 1, tenant-aware login, verification, security.
-- ============================================================
CREATE TABLE IF NOT EXISTS tinode.im_user_security (
user_id BIGINT NOT NULL,
auth_version INT NOT NULL DEFAULT 1,
password_updated_at DATETIME(3),
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (user_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
CREATE TABLE IF NOT EXISTS tinode.im_verification_codes (
id BIGINT NOT NULL AUTO_INCREMENT,
challenge_id VARCHAR(64) NOT NULL,
scene VARCHAR(32) NOT NULL,
action VARCHAR(64) NOT NULL DEFAULT '',
phone_hash CHAR(64) NOT NULL,
phone_mask VARCHAR(32) NOT NULL,
target_user BIGINT,
code_hash CHAR(64) NOT NULL,
verification_token_hash CHAR(64),
state SMALLINT NOT NULL,
attempts INT NOT NULL DEFAULT 0,
max_attempts INT NOT NULL,
expires_at DATETIME(3) NOT NULL,
resend_after DATETIME(3) NOT NULL,
verified_at DATETIME(3),
consumed_at DATETIME(3),
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (id),
UNIQUE INDEX uk_im_verification_challenge (challenge_id),
INDEX idx_im_verification_scene_phone_state (scene, phone_hash, state, expires_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
CREATE TABLE IF NOT EXISTS tinode.im_realname_verifications (
id BIGINT NOT NULL AUTO_INCREMENT,
user_id BIGINT NOT NULL,
submission_id VARCHAR(64) NOT NULL,
status VARCHAR(16) NOT NULL,
name_cipher TEXT NOT NULL,
id_type VARCHAR(32) NOT NULL,
id_number_cipher TEXT NOT NULL,
id_number_hash CHAR(64) NOT NULL,
masked_name VARCHAR(64) NOT NULL,
masked_id_number VARCHAR(64) NOT NULL,
review_reason VARCHAR(256),
verified_at DATETIME(3),
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (id),
UNIQUE INDEX uk_im_realname_submission (submission_id),
INDEX idx_im_realname_user_latest (user_id, created_at),
INDEX idx_im_realname_id_hash (id_number_hash)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
CREATE TABLE IF NOT EXISTS tinode.im_action_tokens (
id BIGINT NOT NULL AUTO_INCREMENT,
user_id BIGINT NOT NULL,
token_id VARCHAR(64) NOT NULL,
action VARCHAR(64) NOT NULL,
target VARCHAR(128) NOT NULL DEFAULT '',
token_hash CHAR(64) NOT NULL,
state SMALLINT NOT NULL,
expires_at DATETIME(3) NOT NULL,
consumed_at DATETIME(3),
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (id),
UNIQUE INDEX uk_im_action_tokens_token (token_id),
INDEX idx_im_action_tokens_user_action (user_id, action, target, state, expires_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
CREATE TABLE IF NOT EXISTS tinode.im_youth_mode_settings (
user_id BIGINT NOT NULL,
purpose VARCHAR(64) NOT NULL,
password_hash VARBINARY(255) NOT NULL,
enabled TINYINT NOT NULL DEFAULT 0,
policy_version INT NOT NULL DEFAULT 1,
restrictions JSON,
enabled_at DATETIME(3),
failed_attempts INT NOT NULL DEFAULT 0,
locked_until DATETIME(3),
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (user_id, purpose)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
-- ============================================================
-- Tenant database: chapters 2-7, notifications, messages, friends, files, favorites.
-- ============================================================
CREATE TABLE IF NOT EXISTS tinode.im_system_notifications (
id BIGINT NOT NULL AUTO_INCREMENT,
user_id BIGINT NOT NULL,
event_id VARCHAR(64) NOT NULL,
event_type VARCHAR(64) NOT NULL,
title VARCHAR(128) NOT NULL,
summary VARCHAR(512) NOT NULL,
action JSON,
read_at DATETIME(3),
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (id),
UNIQUE INDEX uk_im_system_notifications_event (user_id, event_id),
INDEX idx_im_system_notifications_inbox (user_id, id DESC)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
CREATE TABLE IF NOT EXISTS tinode.im_message_revisions (
id BIGINT NOT NULL AUTO_INCREMENT,
createdat DATETIME(3) NOT NULL,
updatedat DATETIME(3) NOT NULL,
topic CHAR(25) NOT NULL,
seqid INT NOT NULL,
version INT NOT NULL,
actor BIGINT NOT NULL,
head JSON,
content JSON,
reason VARCHAR(256) NOT NULL DEFAULT '',
PRIMARY KEY (id),
UNIQUE INDEX uk_im_message_revisions_version (topic, seqid, version)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
CREATE TABLE IF NOT EXISTS tinode.im_friend_requests (
id BIGINT NOT NULL AUTO_INCREMENT,
request_id VARCHAR(64) NOT NULL,
applicant_id BIGINT NOT NULL,
target_id BIGINT NOT NULL,
message VARCHAR(200) NOT NULL DEFAULT '',
source VARCHAR(32) NOT NULL DEFAULT '',
source_topic VARCHAR(64) NOT NULL DEFAULT '',
status VARCHAR(16) NOT NULL,
idempotency_key VARCHAR(128) NOT NULL DEFAULT '',
expires_at DATETIME(3) NOT NULL,
handled_at DATETIME(3),
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (id),
UNIQUE INDEX uk_im_friend_requests_request (request_id),
UNIQUE INDEX uk_im_friend_requests_idempotency (applicant_id, idempotency_key),
INDEX idx_im_friend_requests_pair (applicant_id, target_id, status, created_at),
INDEX idx_im_friend_requests_inbox (target_id, status, created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
CREATE TABLE IF NOT EXISTS tinode.im_friendships (
user_id BIGINT NOT NULL,
peer_id BIGINT NOT NULL,
status VARCHAR(16) NOT NULL,
remark VARCHAR(64) NOT NULL DEFAULT '',
topic CHAR(25) NOT NULL DEFAULT '',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (user_id, peer_id),
INDEX idx_im_friendships_peer (peer_id, status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
CREATE TABLE IF NOT EXISTS tinode.im_user_blocks (
blocker_id BIGINT NOT NULL,
blocked_id BIGINT NOT NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (blocker_id, blocked_id),
INDEX idx_im_user_blocks_blocked (blocked_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
CREATE TABLE IF NOT EXISTS tinode.im_favorites (
id BIGINT NOT NULL AUTO_INCREMENT,
favorite_id VARCHAR(64) NOT NULL,
user_id BIGINT NOT NULL,
item_type VARCHAR(16) NOT NULL,
source_topic CHAR(25) NOT NULL,
source_seq INT NOT NULL,
snapshot JSON,
attachment_refs JSON,
availability VARCHAR(16) NOT NULL DEFAULT 'available',
idempotency_key VARCHAR(128) NOT NULL DEFAULT '',
collected_at DATETIME(3) NOT NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (id),
UNIQUE INDEX uk_im_favorites_favorite (favorite_id),
UNIQUE INDEX uk_im_favorites_source (user_id, source_topic, source_seq),
UNIQUE INDEX uk_im_favorites_idempotency (user_id, ((NULLIF(idempotency_key, '')))),
INDEX idx_im_favorites_inbox (user_id, id DESC),
INDEX idx_im_favorites_type (user_id, item_type, id DESC)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
-- Existing Tinode tables must match the current schema for chapters 2-7:
-- topics.aux, fileuploads.etag, and filemsglinks.topic/userid are required by current code.
SET @im_sql = IF(
(SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = 'tinode' AND TABLE_NAME = 'messages' AND COLUMN_NAME = 'client_msg_id') = 0,
'ALTER TABLE tinode.messages ADD COLUMN client_msg_id VARCHAR(64) AFTER `from`',
'SELECT 1'
);
PREPARE im_stmt FROM @im_sql;
EXECUTE im_stmt;
DEALLOCATE PREPARE im_stmt;
SET @im_sql = IF(
(SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = 'tinode' AND TABLE_NAME = 'messages' AND COLUMN_NAME = 'version') = 0,
'ALTER TABLE tinode.messages ADD COLUMN version INT NOT NULL DEFAULT 1 AFTER client_msg_id',
'SELECT 1'
);
PREPARE im_stmt FROM @im_sql;
EXECUTE im_stmt;
DEALLOCATE PREPARE im_stmt;
SET @im_sql = IF(
(SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = 'tinode' AND TABLE_NAME = 'messages' AND COLUMN_NAME = 'recalled_at') = 0,
'ALTER TABLE tinode.messages ADD COLUMN recalled_at DATETIME(3) AFTER version',
'SELECT 1'
);
PREPARE im_stmt FROM @im_sql;
EXECUTE im_stmt;
DEALLOCATE PREPARE im_stmt;
SET @im_sql = IF(
(SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = 'tinode' AND TABLE_NAME = 'messages' AND COLUMN_NAME = 'recalled_by') = 0,
'ALTER TABLE tinode.messages ADD COLUMN recalled_by BIGINT AFTER recalled_at',
'SELECT 1'
);
PREPARE im_stmt FROM @im_sql;
EXECUTE im_stmt;
DEALLOCATE PREPARE im_stmt;
SET @im_sql = IF(
(SELECT COUNT(*) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = 'tinode' AND TABLE_NAME = 'messages' AND INDEX_NAME = 'messages_sender_client_mid') = 0,
'CREATE UNIQUE INDEX messages_sender_client_mid ON tinode.messages(`from`, client_msg_id)',
'SELECT 1'
);
PREPARE im_stmt FROM @im_sql;
EXECUTE im_stmt;
DEALLOCATE PREPARE im_stmt;
-- ============================================================
-- Tenant database: chapter 8, realtime call records.
-- ============================================================
CREATE TABLE IF NOT EXISTS tinode.im_call_records (
id BIGINT NOT NULL AUTO_INCREMENT,
call_id VARCHAR(64) NOT NULL,
topic CHAR(25) NOT NULL,
seq_id INT NOT NULL,
caller_id BIGINT NOT NULL,
callee_id BIGINT NOT NULL,
call_type VARCHAR(16) NOT NULL,
status VARCHAR(32) NOT NULL,
end_reason VARCHAR(64) NOT NULL DEFAULT '',
duration_ms BIGINT NOT NULL DEFAULT 0,
started_at DATETIME(3) NOT NULL,
accepted_at DATETIME(3),
finished_at DATETIME(3),
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (id),
UNIQUE INDEX uk_im_call_records_call (call_id),
UNIQUE INDEX uk_im_call_records_topic_seq (topic, seq_id),
INDEX idx_im_call_records_caller (caller_id, id DESC),
INDEX idx_im_call_records_callee (callee_id, id DESC),
INDEX idx_im_call_records_status (status, updated_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
-- ============================================================
-- Tenant database: chapter 9, workbench and office collaboration.
-- ============================================================
CREATE TABLE IF NOT EXISTS tinode.im_workbench_apps (
id BIGINT NOT NULL AUTO_INCREMENT,
code VARCHAR(64) NOT NULL,
name VARCHAR(128) NOT NULL,
icon_file_id VARCHAR(64) NOT NULL DEFAULT '',
route VARCHAR(128) NOT NULL,
sort_order INT NOT NULL DEFAULT 0,
unread_key VARCHAR(64) NOT NULL DEFAULT '',
enabled TINYINT NOT NULL DEFAULT 1,
permission VARCHAR(64) NOT NULL DEFAULT 'enabled',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (id),
UNIQUE INDEX uk_im_workbench_apps_code (code),
INDEX idx_im_workbench_apps_enabled_order (enabled, sort_order)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
CREATE TABLE IF NOT EXISTS tinode.im_org_departments (
id BIGINT NOT NULL AUTO_INCREMENT,
department_id VARCHAR(64) NOT NULL,
name VARCHAR(128) NOT NULL,
parent_id VARCHAR(64) NOT NULL DEFAULT 'root',
sort_order INT NOT NULL DEFAULT 0,
version INT NOT NULL DEFAULT 1,
state VARCHAR(16) NOT NULL DEFAULT 'active',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (id),
UNIQUE INDEX uk_im_org_departments_department (department_id),
INDEX idx_im_org_departments_parent (parent_id, state, sort_order, id),
INDEX idx_im_org_departments_name (name)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
CREATE TABLE IF NOT EXISTS tinode.im_org_members (
id BIGINT NOT NULL AUTO_INCREMENT,
department_id VARCHAR(64) NOT NULL,
user_id BIGINT NOT NULL,
display_name VARCHAR(128) NOT NULL DEFAULT '',
position VARCHAR(128) NOT NULL DEFAULT '',
avatar_file_id VARCHAR(64) NOT NULL DEFAULT '',
mobile VARCHAR(32) NOT NULL DEFAULT '',
email VARCHAR(128) NOT NULL DEFAULT '',
employment_state VARCHAR(16) NOT NULL DEFAULT 'active',
contactable TINYINT NOT NULL DEFAULT 1,
sort_order INT NOT NULL DEFAULT 0,
version INT NOT NULL DEFAULT 1,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (id),
UNIQUE INDEX uk_im_org_members_department_user (department_id, user_id),
INDEX idx_im_org_members_department (department_id, employment_state, sort_order, id),
INDEX idx_im_org_members_user (user_id),
INDEX idx_im_org_members_display_name (display_name),
CONSTRAINT fk_im_org_members_department FOREIGN KEY (department_id) REFERENCES tinode.im_org_departments(department_id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
CREATE TABLE IF NOT EXISTS tinode.im_meetings (
id BIGINT NOT NULL AUTO_INCREMENT,
meeting_id VARCHAR(64) NOT NULL,
organizer_id BIGINT NOT NULL,
title VARCHAR(200) NOT NULL,
start_at DATETIME(3) NOT NULL,
end_at DATETIME(3) NOT NULL,
location VARCHAR(256) NOT NULL DEFAULT '',
state VARCHAR(16) NOT NULL DEFAULT 'scheduled',
reminder_minutes JSON,
idempotency_key VARCHAR(128),
version INT NOT NULL DEFAULT 1,
canceled_at DATETIME(3),
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (id),
UNIQUE INDEX uk_im_meetings_meeting (meeting_id),
UNIQUE INDEX uk_im_meetings_idempotency (organizer_id, idempotency_key),
INDEX idx_im_meetings_organizer (organizer_id, start_at),
INDEX idx_im_meetings_state_time (state, start_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
CREATE TABLE IF NOT EXISTS tinode.im_meeting_participants (
id BIGINT NOT NULL AUTO_INCREMENT,
meeting_id VARCHAR(64) NOT NULL,
user_id BIGINT NOT NULL,
response VARCHAR(16) NOT NULL DEFAULT 'pending',
response_at DATETIME(3),
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (id),
UNIQUE INDEX uk_im_meeting_participants_user (meeting_id, user_id),
INDEX idx_im_meeting_participants_user_time (user_id, meeting_id),
INDEX idx_im_meeting_participants_response (meeting_id, response),
CONSTRAINT fk_im_meeting_participants_meeting FOREIGN KEY (meeting_id) REFERENCES tinode.im_meetings(meeting_id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
CREATE TABLE IF NOT EXISTS tinode.im_daily_reports (
id BIGINT NOT NULL AUTO_INCREMENT,
report_id VARCHAR(64) NOT NULL,
author_id BIGINT NOT NULL,
report_date DATE NOT NULL,
content TEXT NOT NULL,
attachments JSON,
status VARCHAR(16) NOT NULL DEFAULT 'draft',
read_state VARCHAR(16) NOT NULL DEFAULT 'unread',
version INT NOT NULL DEFAULT 1,
submitted_at DATETIME(3),
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (id),
UNIQUE INDEX uk_im_daily_reports_report (report_id),
UNIQUE INDEX uk_im_daily_reports_author_date (author_id, report_date),
INDEX idx_im_daily_reports_author_id (author_id, id DESC),
INDEX idx_im_daily_reports_date_status (report_date, status, id DESC)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
CREATE TABLE IF NOT EXISTS tinode.im_monthly_reports (
id BIGINT NOT NULL AUTO_INCREMENT,
report_id VARCHAR(64) NOT NULL,
author_id BIGINT NOT NULL,
report_month CHAR(7) NOT NULL,
work_summary TEXT NOT NULL,
next_plan TEXT NOT NULL,
attachments JSON,
status VARCHAR(16) NOT NULL DEFAULT 'draft',
read_state VARCHAR(16) NOT NULL DEFAULT 'unread',
version INT NOT NULL DEFAULT 1,
submitted_at DATETIME(3),
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (id),
UNIQUE INDEX uk_im_monthly_reports_report (report_id),
UNIQUE INDEX uk_im_monthly_reports_author_month (author_id, report_month),
INDEX idx_im_monthly_reports_author_id (author_id, id DESC),
INDEX idx_im_monthly_reports_month_status (report_month, status, id DESC)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
CREATE TABLE IF NOT EXISTS tinode.im_mailbox_config (
id BIGINT NOT NULL AUTO_INCREMENT,
enabled TINYINT NOT NULL DEFAULT 1,
allow_anonymous TINYINT NOT NULL DEFAULT 1,
required_fields JSON,
max_length INT NOT NULL DEFAULT 5000,
attachment_policy JSON,
notice_title VARCHAR(128) NOT NULL DEFAULT '',
notice_content TEXT NOT NULL,
notice_updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
CREATE TABLE IF NOT EXISTS tinode.im_mailbox_submissions (
id BIGINT NOT NULL AUTO_INCREMENT,
submission_id VARCHAR(64) NOT NULL,
sender_id BIGINT NOT NULL,
subject VARCHAR(200) NOT NULL,
content TEXT NOT NULL,
anonymous TINYINT NOT NULL DEFAULT 0,
attachment_file_ids JSON,
notice_version VARCHAR(64) NOT NULL DEFAULT '',
idempotency_key VARCHAR(128) NOT NULL,
status VARCHAR(32) NOT NULL DEFAULT 'received',
submitted_at DATETIME(3) NOT NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (id),
UNIQUE INDEX uk_im_mailbox_submissions_submission (submission_id),
UNIQUE INDEX uk_im_mailbox_submissions_idempotency (sender_id, idempotency_key),
INDEX idx_im_mailbox_submissions_sender (sender_id, id DESC),
INDEX idx_im_mailbox_submissions_status (status, id DESC)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
-- ============================================================
-- Tenant database: chapter 10, profile, privacy, app release, push, youth mode.
-- ============================================================
CREATE TABLE IF NOT EXISTS tinode.im_user_profiles (
user_id BIGINT NOT NULL,
avatar_file_id VARCHAR(64) NOT NULL DEFAULT '',
nickname VARCHAR(64) NOT NULL DEFAULT '',
birth_date DATE,
signature VARCHAR(256) NOT NULL DEFAULT '',
gender VARCHAR(16) NOT NULL DEFAULT '',
region VARCHAR(128) NOT NULL DEFAULT '',
public_account VARCHAR(64) NOT NULL DEFAULT '',
version INT NOT NULL DEFAULT 1,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (user_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
CREATE TABLE IF NOT EXISTS tinode.im_user_privacy_settings (
user_id BIGINT NOT NULL,
add_me_methods JSON,
profile_visibility VARCHAR(32) NOT NULL DEFAULT 'friends',
last_seen_visibility VARCHAR(32) NOT NULL DEFAULT 'friends',
read_receipts TINYINT NOT NULL DEFAULT 1,
version INT NOT NULL DEFAULT 1,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (user_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
CREATE TABLE IF NOT EXISTS tinode.im_app_releases (
id BIGINT NOT NULL AUTO_INCREMENT,
platform VARCHAR(32) NOT NULL,
channel VARCHAR(32) NOT NULL DEFAULT 'stable',
latest_version VARCHAR(64) NOT NULL,
min_supported_version VARCHAR(64) NOT NULL,
update_type VARCHAR(16) NOT NULL DEFAULT 'none',
release_notes TEXT NOT NULL,
store_url VARCHAR(512) NOT NULL DEFAULT '',
package_url VARCHAR(512) NOT NULL DEFAULT '',
checksum VARCHAR(128) NOT NULL DEFAULT '',
remind_after_seconds INT NOT NULL DEFAULT 0,
published_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (id),
UNIQUE INDEX uk_im_app_releases_platform_channel (platform, channel)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
CREATE TABLE IF NOT EXISTS tinode.im_device_push_tokens (
device_id VARCHAR(128) NOT NULL,
user_id BIGINT NOT NULL,
provider VARCHAR(32) NOT NULL,
token VARCHAR(512) NOT NULL,
app_version VARCHAR(64) NOT NULL DEFAULT '',
platform VARCHAR(32) NOT NULL DEFAULT '',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (device_id),
UNIQUE INDEX uk_im_device_push_tokens_provider_token (provider, token),
INDEX idx_im_device_push_tokens_user (user_id, updated_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
-- ============================================================
-- Tenant database: chapter 11, risk challenge, abuse report, feedback.
-- ============================================================
CREATE TABLE IF NOT EXISTS tinode.im_risk_challenges (
id BIGINT NOT NULL AUTO_INCREMENT,
challenge_id VARCHAR(64) NOT NULL,
user_id BIGINT NOT NULL,
action VARCHAR(64) NOT NULL,
level VARCHAR(16) NOT NULL,
notice_code VARCHAR(64) NOT NULL,
notice_params JSON,
state VARCHAR(16) NOT NULL DEFAULT 'pending',
expires_at DATETIME(3) NOT NULL,
decision VARCHAR(16) NOT NULL DEFAULT '',
action_token_id VARCHAR(64) NOT NULL DEFAULT '',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (id),
UNIQUE INDEX uk_im_risk_challenges_challenge (challenge_id),
INDEX idx_im_risk_challenges_user_state (user_id, state, expires_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
CREATE TABLE IF NOT EXISTS tinode.im_abuse_reasons (
id BIGINT NOT NULL AUTO_INCREMENT,
target_type VARCHAR(16) NOT NULL,
reason_code VARCHAR(64) NOT NULL,
label VARCHAR(128) NOT NULL,
requires_description TINYINT NOT NULL DEFAULT 0,
allows_evidence TINYINT NOT NULL DEFAULT 1,
sort_order INT NOT NULL DEFAULT 0,
enabled TINYINT NOT NULL DEFAULT 1,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (id),
UNIQUE INDEX uk_im_abuse_reasons_type_code (target_type, reason_code),
INDEX idx_im_abuse_reasons_type_order (target_type, enabled, sort_order)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
CREATE TABLE IF NOT EXISTS tinode.im_abuse_reports (
id BIGINT NOT NULL AUTO_INCREMENT,
report_id VARCHAR(64) NOT NULL,
reporter_id BIGINT NOT NULL,
target_type VARCHAR(16) NOT NULL,
target_user_id BIGINT NOT NULL DEFAULT 0,
target_topic VARCHAR(32) NOT NULL DEFAULT '',
target_seq INT NOT NULL DEFAULT 0,
reason_code VARCHAR(64) NOT NULL,
description TEXT NOT NULL,
evidence_file_ids JSON,
snapshot JSON,
idempotency_key VARCHAR(128) NOT NULL,
status VARCHAR(32) NOT NULL DEFAULT 'received',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (id),
UNIQUE INDEX uk_im_abuse_reports_report (report_id),
UNIQUE INDEX uk_im_abuse_reports_idempotency (reporter_id, idempotency_key),
INDEX idx_im_abuse_reports_target (target_type, target_topic, target_seq, target_user_id),
INDEX idx_im_abuse_reports_reporter (reporter_id, id DESC)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
CREATE TABLE IF NOT EXISTS tinode.im_feedback_config (
id BIGINT NOT NULL AUTO_INCREMENT,
categories JSON,
max_length INT NOT NULL DEFAULT 1000,
attachment_policy JSON,
allow_contact TINYINT NOT NULL DEFAULT 1,
diagnostics_notice TEXT NOT NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
CREATE TABLE IF NOT EXISTS tinode.im_feedback_submissions (
id BIGINT NOT NULL AUTO_INCREMENT,
feedback_id VARCHAR(64) NOT NULL,
user_id BIGINT NOT NULL,
category VARCHAR(64) NOT NULL,
content TEXT NOT NULL,
contact VARCHAR(128) NOT NULL DEFAULT '',
attachment_file_ids JSON,
client_context JSON,
include_diagnostics TINYINT NOT NULL DEFAULT 0,
idempotency_key VARCHAR(128) NOT NULL,
status VARCHAR(32) NOT NULL DEFAULT 'received',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (id),
UNIQUE INDEX uk_im_feedback_submissions_feedback (feedback_id),
UNIQUE INDEX uk_im_feedback_submissions_idempotency (user_id, idempotency_key),
INDEX idx_im_feedback_submissions_user (user_id, id DESC)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
-- ============================================================
-- Seed data.
-- ============================================================
INSERT INTO hotgo.im_tenant(id, code, name, tenant_desc, db_name, db_dsn, state, created_by, updated_by)
VALUES (1, 'default', 'Default Tenant', NULL, 'tinode', '', 1, 0, 0)
ON DUPLICATE KEY UPDATE
name = VALUES(name),
db_name = VALUES(db_name),
state = VALUES(state),
updated_at = CURRENT_TIMESTAMP(3);
INSERT INTO hotgo.im_tenant_config(tenant_id, max_users, max_groups, max_group_members, created_by, updated_by)
SELECT id, 0, 0, 0, 0, 0 FROM hotgo.im_tenant WHERE code = 'default'
ON DUPLICATE KEY UPDATE updated_at = CURRENT_TIMESTAMP(3);
INSERT INTO tinode.im_workbench_apps(code, name, route, sort_order, unread_key, enabled, permission)
VALUES
('organization', '组织架构', '/organization', 10, 'organization', 1, 'enabled'),
('meetings', '会议通知', '/meetings', 20, 'meetings', 1, 'enabled'),
('daily_reports', '日报', '/reports/daily', 30, 'daily_reports', 1, 'enabled'),
('monthly_reports', '月报', '/reports/monthly', 40, 'monthly_reports', 1, 'enabled'),
('executive_mailbox', '总经理信箱', '/executive-mailbox', 50, 'executive_mailbox', 1, 'enabled')
ON DUPLICATE KEY UPDATE
name = VALUES(name),
route = VALUES(route),
sort_order = VALUES(sort_order),
unread_key = VALUES(unread_key),
updated_at = CURRENT_TIMESTAMP(3);
INSERT INTO tinode.im_mailbox_config(id, enabled, allow_anonymous, required_fields, max_length, attachment_policy, notice_title, notice_content)
VALUES (1, 1, 1, '["subject","content"]', 5000, '{"max_files":5,"purpose":"executive_mailbox"}', '总经理信箱', '请如实填写诉求,匿名提交将按企业政策保护展示身份。')
ON DUPLICATE KEY UPDATE updated_at = updated_at;
INSERT INTO tinode.im_app_releases(platform, channel, latest_version, min_supported_version, update_type, release_notes, remind_after_seconds)
VALUES
('ios', 'stable', '1.0.0', '1.0.0', 'none', '当前已是最新版本。', 0),
('android', 'stable', '1.0.0', '1.0.0', 'none', '当前已是最新版本。', 0)
ON DUPLICATE KEY UPDATE updated_at = CURRENT_TIMESTAMP(3);
INSERT INTO tinode.im_abuse_reasons(target_type, reason_code, label, requires_description, allows_evidence, sort_order)
VALUES
('user', 'fraud', '诈骗欺诈', 0, 1, 10),
('user', 'harassment', '骚扰辱骂', 0, 1, 20),
('user', 'illegal', '违法违规', 1, 1, 30),
('group', 'fraud', '诈骗欺诈', 0, 1, 10),
('group', 'spam', '垃圾广告', 0, 1, 20),
('group', 'illegal', '违法违规', 1, 1, 30),
('message', 'fraud', '诈骗欺诈', 0, 1, 10),
('message', 'spam', '垃圾广告', 0, 1, 20),
('message', 'harassment', '骚扰辱骂', 0, 1, 30),
('message', 'illegal', '违法违规', 1, 1, 40)
ON DUPLICATE KEY UPDATE
label = VALUES(label),
requires_description = VALUES(requires_description),
allows_evidence = VALUES(allows_evidence),
sort_order = VALUES(sort_order),
updated_at = CURRENT_TIMESTAMP(3);
INSERT INTO tinode.im_feedback_config(id, categories, max_length, attachment_policy, allow_contact, diagnostics_notice)
VALUES (1, '["bug","suggestion","complaint","other"]', 1000, '{"max_files":5,"purpose":"feedback"}', 1, '仅在你同意时上传脱敏诊断信息。')
ON DUPLICATE KEY UPDATE updated_at = updated_at;
-- Current MySQL adapter schema version expected by server/db/mysql/adapter.go.
UPDATE tinode.kvmeta SET `value` = 123 WHERE `key` = 'version';
@@ -0,0 +1,33 @@
// =================================================================================
// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT.
// =================================================================================
package imauditlog
import (
"hotgo/internal/model/input/form"
"hotgo/internal/model/input/sysin"
"github.com/gogf/gf/v2/frame/g"
)
// ListReq 查询IM审计日志列表
type ListReq struct {
g.Meta `path:"/imAuditLog/list" method:"get" tags:"IM审计日志" summary:"获取IM审计日志列表"`
sysin.ImAuditLogListInp
}
type ListRes struct {
form.PageRes
List []*sysin.ImAuditLogListModel `json:"list" dc:"数据列表"`
}
// ViewReq 获取IM审计日志指定信息
type ViewReq struct {
g.Meta `path:"/imAuditLog/view" method:"get" tags:"IM审计日志" summary:"获取IM审计日志指定信息"`
sysin.ImAuditLogViewInp
}
type ViewRes struct {
*sysin.ImAuditLogViewModel
}
+110
View File
@@ -0,0 +1,110 @@
// Package imgroup
// @Link https://github.com/bufanyun/hotgo
// @Copyright Copyright (c) 2026 HotGo
// @Author Ms <133814250@qq.com>
// @License https://github.com/bufanyun/hotgo/blob/master/LICENSE
package imgroup
import (
"hotgo/internal/model/input/form"
"hotgo/internal/model/input/sysin"
"github.com/gogf/gf/v2/frame/g"
)
// ListReq 查询IM群组列表
type ListReq struct {
g.Meta `path:"/imGroup/list" method:"get" tags:"IM群组" summary:"获取IM群组列表"`
sysin.ImGroupListInp
}
type ListRes struct {
form.PageRes
List []*sysin.ImGroupListModel `json:"list" dc:"数据列表"`
}
// ViewReq 获取IM群组指定信息
type ViewReq struct {
g.Meta `path:"/imGroup/view" method:"get" tags:"IM群组" summary:"获取IM群组指定信息"`
sysin.ImGroupViewInp
}
type ViewRes struct {
*sysin.ImGroupViewModel
}
// MemberListReq 查询IM群成员列表
type MemberListReq struct {
g.Meta `path:"/imGroup/memberList" method:"get" tags:"IM群组" summary:"获取IM群成员列表"`
sysin.ImGroupMemberListInp
}
type MemberListRes struct {
form.PageRes
List []*sysin.ImGroupMemberListModel `json:"list" dc:"数据列表"`
}
// GovernanceReq 获取IM群治理配置
type GovernanceReq struct {
g.Meta `path:"/imGroup/governance" method:"get" tags:"IM群组" summary:"获取IM群治理配置"`
sysin.ImGroupTopicInp
}
type GovernanceRes struct {
*sysin.ImGroupGovernanceModel
}
// JoinRequestListReq 查询IM入群申请列表
type JoinRequestListReq struct {
g.Meta `path:"/imGroup/joinRequestList" method:"get" tags:"IM群组" summary:"获取IM入群申请列表"`
sysin.ImGroupJoinRequestListInp
}
type JoinRequestListRes struct {
form.PageRes
List []*sysin.ImGroupJoinRequestListModel `json:"list" dc:"数据列表"`
}
// InvitationListReq 查询IM群邀请列表
type InvitationListReq struct {
g.Meta `path:"/imGroup/invitationList" method:"get" tags:"IM群组" summary:"获取IM群邀请列表"`
sysin.ImGroupInvitationListInp
}
type InvitationListRes struct {
form.PageRes
List []*sysin.ImGroupInvitationListModel `json:"list" dc:"数据列表"`
}
// AnnouncementListReq 查询IM群公告列表
type AnnouncementListReq struct {
g.Meta `path:"/imGroup/announcementList" method:"get" tags:"IM群组" summary:"获取IM群公告列表"`
sysin.ImGroupAnnouncementListInp
}
type AnnouncementListRes struct {
form.PageRes
List []*sysin.ImGroupAnnouncementListModel `json:"list" dc:"数据列表"`
}
// MuteListReq 查询IM群禁言列表
type MuteListReq struct {
g.Meta `path:"/imGroup/muteList" method:"get" tags:"IM群组" summary:"获取IM群禁言列表"`
sysin.ImGroupMuteListInp
}
type MuteListRes struct {
form.PageRes
List []*sysin.ImGroupMuteListModel `json:"list" dc:"数据列表"`
}
// AdminLogListReq 查询IM群管理日志列表
type AdminLogListReq struct {
g.Meta `path:"/imGroup/adminLogList" method:"get" tags:"IM群组" summary:"获取IM群管理日志列表"`
sysin.ImGroupAdminLogListInp
}
type AdminLogListRes struct {
form.PageRes
List []*sysin.ImGroupAdminLogListModel `json:"list" dc:"数据列表"`
}
+68
View File
@@ -0,0 +1,68 @@
package iminvite
import (
"hotgo/internal/model/input/form"
"hotgo/internal/model/input/sysin"
"github.com/gogf/gf/v2/frame/g"
)
type ListReq struct {
g.Meta `path:"/imInvite/list" method:"post" tags:"IM邀请码" summary:"获取IM注册邀请码列表"`
sysin.ImInviteListInp
}
type ListRes struct {
form.PageRes
List []*sysin.ImInviteCodeModel `json:"list" dc:"数据列表"`
}
type CreateReq struct {
g.Meta `path:"/imInvite/create" method:"post" tags:"IM邀请码" summary:"生成IM注册邀请码"`
sysin.ImInviteCreateInp
}
type CreateRes struct {
*sysin.ImInviteCodeModel
}
type BatchCreateReq struct {
g.Meta `path:"/imInvite/batchCreate" method:"post" tags:"IM邀请码" summary:"批量生成IM注册邀请码"`
sysin.ImInviteBatchCreateInp
}
type BatchCreateRes struct {
*sysin.ImInviteBatchModel
}
type BatchViewReq struct {
g.Meta `path:"/imInvite/batchView" method:"get" tags:"IM邀请码" summary:"查看IM注册邀请码批次"`
sysin.ImInviteBatchViewInp
}
type BatchViewRes struct {
*sysin.ImInviteBatchDetailModel
}
type RevokeReq struct {
g.Meta `path:"/imInvite/revoke" method:"post" tags:"IM邀请码" summary:"撤销IM注册邀请码"`
sysin.ImInviteRevokeInp
}
type RevokeRes struct{}
type BatchRevokeReq struct {
g.Meta `path:"/imInvite/batchRevoke" method:"post" tags:"IM邀请码" summary:"批量撤销IM注册邀请码"`
sysin.ImInviteBatchRevokeInp
}
type BatchRevokeRes struct {
*sysin.ImInviteBatchModel
}
type ExportReq struct {
g.Meta `path:"/imInvite/export" method:"post" tags:"IM邀请码" summary:"导出IM注册邀请码"`
sysin.ImInviteListInp
}
type ExportRes struct{}
@@ -0,0 +1,42 @@
// Package imrealname
// @Link https://github.com/bufanyun/hotgo
// @Copyright Copyright (c) 2026 HotGo
// @Author Ms <133814250@qq.com>
// @License https://github.com/bufanyun/hotgo/blob/master/LICENSE
package imrealname
import (
"hotgo/internal/model/input/form"
"hotgo/internal/model/input/sysin"
"github.com/gogf/gf/v2/frame/g"
)
// ListReq 查询IM实名审核列表
type ListReq struct {
g.Meta `path:"/imRealname/list" method:"get" tags:"IM实名审核" summary:"获取IM实名审核列表"`
sysin.ImRealnameListInp
}
type ListRes struct {
form.PageRes
List []*sysin.ImRealnameListModel `json:"list" dc:"数据列表"`
}
// ViewReq 获取IM实名审核指定信息
type ViewReq struct {
g.Meta `path:"/imRealname/view" method:"get" tags:"IM实名审核" summary:"获取IM实名审核指定信息"`
sysin.ImRealnameViewInp
}
type ViewRes struct {
*sysin.ImRealnameViewModel
}
// ReviewReq 审核IM实名
type ReviewReq struct {
g.Meta `path:"/imRealname/review" method:"post" tags:"IM实名审核" summary:"审核IM实名"`
sysin.ImRealnameReviewInp
}
type ReviewRes struct{}
@@ -0,0 +1,22 @@
package imregistration
import (
"hotgo/internal/model/input/sysin"
"github.com/gogf/gf/v2/frame/g"
)
type GetReq struct {
g.Meta `path:"/imRegistration/config/get" method:"get" tags:"IM注册" summary:"获取IM注册配置"`
}
type GetRes struct {
*sysin.ImRegistrationConfigModel
}
type UpdateReq struct {
g.Meta `path:"/imRegistration/config/update" method:"post" tags:"IM注册" summary:"更新IM注册配置"`
sysin.ImRegistrationConfigUpdateInp
}
type UpdateRes struct{}
+50
View File
@@ -0,0 +1,50 @@
// Package imtenant
// @Link https://github.com/bufanyun/hotgo
// @Copyright Copyright (c) 2026 HotGo
// @Author Ms <133814250@qq.com>
// @License https://github.com/bufanyun/hotgo/blob/master/LICENSE
package imtenant
import (
"hotgo/internal/model/input/form"
"hotgo/internal/model/input/sysin"
"github.com/gogf/gf/v2/frame/g"
)
// ListReq 查询IM租户列表
type ListReq struct {
g.Meta `path:"/imTenant/list" method:"get" tags:"IM租户" summary:"获取IM租户列表"`
sysin.ImTenantListInp
}
type ListRes struct {
form.PageRes
List []*sysin.ImTenantListModel `json:"list" dc:"数据列表"`
}
// ViewReq 获取IM租户指定信息
type ViewReq struct {
g.Meta `path:"/imTenant/view" method:"get" tags:"IM租户" summary:"获取IM租户指定信息"`
sysin.ImTenantViewInp
}
type ViewRes struct {
*sysin.ImTenantViewModel
}
// EditReq 修改/新增IM租户
type EditReq struct {
g.Meta `path:"/imTenant/edit" method:"post" tags:"IM租户" summary:"修改/新增IM租户"`
sysin.ImTenantEditInp
}
type EditRes struct{}
// StatusReq 更新IM租户状态
type StatusReq struct {
g.Meta `path:"/imTenant/status" method:"post" tags:"IM租户" summary:"更新IM租户状态"`
sysin.ImTenantStatusInp
}
type StatusRes struct{}
+41
View File
@@ -0,0 +1,41 @@
// Package imuser
// @Link https://github.com/bufanyun/hotgo
// @Copyright Copyright (c) 2026 HotGo
// @Author Ms <133814250@qq.com>
// @License https://github.com/bufanyun/hotgo/blob/master/LICENSE
package imuser
import (
"hotgo/internal/model/input/form"
"hotgo/internal/model/input/sysin"
"github.com/gogf/gf/v2/frame/g"
)
// ListReq 查询IM用户列表
type ListReq struct {
g.Meta `path:"/imUser/list" method:"get" tags:"IM用户" summary:"获取IM用户列表"`
sysin.ImUserListInp
}
type ListRes struct {
form.PageRes
List []*sysin.ImUserListModel `json:"list" dc:"数据列表"`
}
// ViewReq 获取IM用户指定信息
type ViewReq struct {
g.Meta `path:"/imUser/view" method:"get" tags:"IM用户" summary:"获取IM用户指定信息"`
sysin.ImUserViewInp
}
type ViewRes struct {
*sysin.ImUserViewModel
}
type ResetPasswordReq struct {
g.Meta `path:"/imUser/resetPassword" method:"post" tags:"IM用户" summary:"重置IM用户密码"`
sysin.ImUserPasswordResetInp
}
type ResetPasswordRes struct{}
@@ -0,0 +1,42 @@
// Package imworkorder
// @Link https://github.com/bufanyun/hotgo
// @Copyright Copyright (c) 2026 HotGo
// @Author Ms <133814250@qq.com>
// @License https://github.com/bufanyun/hotgo/blob/master/LICENSE
package imworkorder
import (
"hotgo/internal/model/input/form"
"hotgo/internal/model/input/sysin"
"github.com/gogf/gf/v2/frame/g"
)
// ListReq 查询IM举报反馈工单列表
type ListReq struct {
g.Meta `path:"/imWorkOrder/list" method:"get" tags:"IM举报反馈" summary:"获取IM举报反馈工单列表"`
sysin.ImWorkOrderListInp
}
type ListRes struct {
form.PageRes
List []*sysin.ImWorkOrderListModel `json:"list" dc:"数据列表"`
}
// ViewReq 获取IM举报反馈工单指定信息
type ViewReq struct {
g.Meta `path:"/imWorkOrder/view" method:"get" tags:"IM举报反馈" summary:"获取IM举报反馈工单指定信息"`
sysin.ImWorkOrderViewInp
}
type ViewRes struct {
*sysin.ImWorkOrderViewModel
}
// StatusReq 更新IM举报反馈工单状态
type StatusReq struct {
g.Meta `path:"/imWorkOrder/status" method:"post" tags:"IM举报反馈" summary:"更新IM举报反馈工单状态"`
sysin.ImWorkOrderStatusInp
}
type StatusRes struct{}
+3 -2
View File
@@ -15,6 +15,7 @@ require (
github.com/go-pay/crypto v0.0.1
github.com/go-pay/gopay v1.5.114
github.com/go-pay/smap v0.0.2
github.com/go-sql-driver/mysql v1.7.1
github.com/gogf/gf/contrib/drivers/mysql/v2 v2.10.0
github.com/gogf/gf/contrib/drivers/pgsql/v2 v2.10.0
github.com/gogf/gf/contrib/nosql/redis/v2 v2.10.0
@@ -34,9 +35,11 @@ require (
github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common v1.0.1202
github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/sms v1.0.1200
github.com/tencentyun/cos-go-sdk-v5 v0.7.66
github.com/tinode/snowflake v1.0.0
github.com/ufilesdk-dev/ufile-gosdk v1.0.6
github.com/xuri/excelize/v2 v2.9.1
go.opentelemetry.io/otel v1.38.0
golang.org/x/crypto v0.41.0
golang.org/x/mod v0.26.0
golang.org/x/net v0.43.0
golang.org/x/tools v0.35.0
@@ -80,7 +83,6 @@ require (
github.com/go-pay/xlog v0.0.3 // indirect
github.com/go-pay/xtime v0.0.2 // indirect
github.com/go-redis/redis/v8 v8.11.5 // indirect
github.com/go-sql-driver/mysql v1.7.1 // indirect
github.com/goccy/go-json v0.10.5 // indirect
github.com/gofrs/flock v0.8.1 // indirect
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect
@@ -147,7 +149,6 @@ require (
go.opentelemetry.io/otel/sdk v1.38.0 // indirect
go.opentelemetry.io/otel/trace v1.38.0 // indirect
go.uber.org/atomic v1.5.1 // indirect
golang.org/x/crypto v0.41.0 // indirect
golang.org/x/image v0.25.0 // indirect
golang.org/x/lint v0.0.0-20190930215403-16217165b5de // indirect
golang.org/x/sync v0.16.0 // indirect
+2
View File
@@ -435,6 +435,8 @@ github.com/tidwall/pretty v1.2.0 h1:RWIZEg2iJ8/g6fDDYzMpobmaoGh5OLl4AXtGUGPcqCs=
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/tiendc/go-deepcopy v1.6.0 h1:0UtfV/imoCwlLxVsyfUd4hNHnB3drXsfle+wzSCA5Wo=
github.com/tiendc/go-deepcopy v1.6.0/go.mod h1:toXoeQoUqXOOS/X4sKuiAoSk6elIdqc0pN7MTgOOo2I=
github.com/tinode/snowflake v1.0.0 h1:YciQ9ZKn1TrnvpS8yZErt044XJaxWVtR9aMO9rOZVOE=
github.com/tinode/snowflake v1.0.0/go.mod h1:5JiaCe3o7QdDeyRcAeZBGVghwRS+ygt2CF/hxmAoptQ=
github.com/tinylib/msgp v1.3.0 h1:ULuf7GPooDaIlbyvgAxBV/FI7ynli6LZ1/nVUNu+0ww=
github.com/tinylib/msgp v1.3.0/go.mod h1:ykjzy2wzgrlvpDCRc4LA8UXy6D8bzMSuAF3WD57Gok0=
github.com/tjfoc/gmsm v1.3.2/go.mod h1:HaUcFuY0auTiaHB9MHFGCPx5IaLhTUd2atbCFBQXn9w=
+1 -1
View File
@@ -22,7 +22,7 @@ gfcli:
gen:
dao:
- link: "mysql:root:oWxz*2=sb),ouW%p@tcp(64.118.137.182:53306)/hotgo?loc=Local&parseTime=true&charset=utf8mb4"
- link: "${HGGEN_DAO_LINK}"
# - link: "pgsql:postgres:hg123456@tcp(127.0.0.1:5432)/hotgo"
group: "default" # 分组 使用hotgo代码生成功能时必须填
# tables: "" # 指定当前数据库中需要执行代码生成的数据表。如果为空,表示数据库的所有表都会生成。
+105
View File
@@ -0,0 +1,105 @@
// Package consts
// @Link https://github.com/bufanyun/hotgo
// @Copyright Copyright (c) 2026 HotGo
// @Author Ms <133814250@qq.com>
// @License https://github.com/bufanyun/hotgo/blob/master/LICENSE
package consts
import (
"hotgo/internal/library/dict"
"hotgo/internal/model"
)
func init() {
dict.RegisterEnums("ImGroupStateOptions", "IM群组状态选项", ImGroupStateOptions)
dict.RegisterEnums("ImGroupStatusOptions", "IM群业务状态选项", ImGroupStatusOptions)
dict.RegisterEnums("ImGroupRoleOptions", "IM群成员角色选项", ImGroupRoleOptions)
dict.RegisterEnums("ImGroupMemberStatusOptions", "IM群成员状态选项", ImGroupMemberStatusOptions)
dict.RegisterEnums("ImGroupRequestStatusOptions", "IM入群申请状态选项", ImGroupRequestStatusOptions)
dict.RegisterEnums("ImGroupMuteStatusOptions", "IM禁言状态选项", ImGroupMuteStatusOptions)
dict.RegisterEnums("ImGroupAnnouncementStatusOptions", "IM群公告状态选项", ImGroupAnnouncementStatusOptions)
dict.RegisterEnums("ImGroupOutboxStatusOptions", "IM群事件状态选项", ImGroupOutboxStatusOptions)
}
var ImGroupStateOptions = []*model.Option{
dict.GenSuccessOption(ImUserStateOk, "正常"),
dict.GenErrorOption(ImUserStateSuspended, "已停用"),
dict.GenDefaultOption(ImUserStateDeleted, "已删除"),
dict.GenWarningOption(ImUserStateUndefined, "未定义"),
}
const (
ImGroupStatusActive = "active"
ImGroupStatusDissolved = "dissolved"
ImGroupStatusDisabled = "disabled"
ImGroupRoleOwner = "owner"
ImGroupRoleAdmin = "admin"
ImGroupRoleMember = "member"
ImGroupMemberStatusActive = "active"
ImGroupMemberStatusRemoved = "removed"
ImGroupMemberStatusLeft = "left"
ImGroupRequestStatusPending = "pending"
ImGroupRequestStatusAccepted = "accepted"
ImGroupRequestStatusRejected = "rejected"
ImGroupRequestStatusExpired = "expired"
ImGroupRequestStatusCanceled = "canceled"
ImGroupMuteStatusActive = "active"
ImGroupMuteStatusRevoked = "revoked"
ImGroupMuteStatusExpired = "expired"
ImGroupAnnouncementStatusCurrent = "current"
ImGroupAnnouncementStatusHistory = "history"
ImGroupAnnouncementStatusDeleted = "deleted"
ImGroupOutboxStatusPending = "pending"
ImGroupOutboxStatusDelivered = "delivered"
ImGroupOutboxStatusFailed = "failed"
)
var ImGroupStatusOptions = []*model.Option{
dict.GenSuccessOption(ImGroupStatusActive, "正常"),
dict.GenDefaultOption(ImGroupStatusDissolved, "已解散"),
dict.GenErrorOption(ImGroupStatusDisabled, "已停用"),
}
var ImGroupRoleOptions = []*model.Option{
dict.GenPrimaryOption(ImGroupRoleOwner, "群主"),
dict.GenSuccessOption(ImGroupRoleAdmin, "管理员"),
dict.GenDefaultOption(ImGroupRoleMember, "成员"),
}
var ImGroupMemberStatusOptions = []*model.Option{
dict.GenSuccessOption(ImGroupMemberStatusActive, "正常"),
dict.GenDefaultOption(ImGroupMemberStatusRemoved, "已移除"),
dict.GenWarningOption(ImGroupMemberStatusLeft, "已退出"),
}
var ImGroupRequestStatusOptions = []*model.Option{
dict.GenWarningOption(ImGroupRequestStatusPending, "待处理"),
dict.GenSuccessOption(ImGroupRequestStatusAccepted, "已同意"),
dict.GenErrorOption(ImGroupRequestStatusRejected, "已拒绝"),
dict.GenDefaultOption(ImGroupRequestStatusExpired, "已过期"),
dict.GenDefaultOption(ImGroupRequestStatusCanceled, "已取消"),
}
var ImGroupMuteStatusOptions = []*model.Option{
dict.GenErrorOption(ImGroupMuteStatusActive, "禁言中"),
dict.GenDefaultOption(ImGroupMuteStatusRevoked, "已解除"),
dict.GenWarningOption(ImGroupMuteStatusExpired, "已过期"),
}
var ImGroupAnnouncementStatusOptions = []*model.Option{
dict.GenSuccessOption(ImGroupAnnouncementStatusCurrent, "当前"),
dict.GenDefaultOption(ImGroupAnnouncementStatusHistory, "历史"),
dict.GenErrorOption(ImGroupAnnouncementStatusDeleted, "已删除"),
}
var ImGroupOutboxStatusOptions = []*model.Option{
dict.GenWarningOption(ImGroupOutboxStatusPending, "待投递"),
dict.GenSuccessOption(ImGroupOutboxStatusDelivered, "已投递"),
dict.GenErrorOption(ImGroupOutboxStatusFailed, "投递失败"),
}
+44
View File
@@ -0,0 +1,44 @@
// Package consts
// @Link https://github.com/bufanyun/hotgo
// @Copyright Copyright (c) 2026 HotGo
// @Author Ms <133814250@qq.com>
// @License https://github.com/bufanyun/hotgo/blob/master/LICENSE
package consts
import (
"hotgo/internal/library/dict"
"hotgo/internal/model"
)
func init() {
dict.RegisterEnums("ImRealnameStatusOptions", "IM实名状态选项", ImRealnameStatusOptions)
dict.RegisterEnums("ImWorkOrderStatusOptions", "IM工单状态选项", ImWorkOrderStatusOptions)
}
const (
ImRealnameStatusPending = "pending"
ImRealnameStatusVerified = "verified"
ImRealnameStatusRejected = "rejected"
)
var ImRealnameStatusOptions = []*model.Option{
dict.GenWarningOption(ImRealnameStatusPending, "待审核"),
dict.GenSuccessOption(ImRealnameStatusVerified, "已通过"),
dict.GenErrorOption(ImRealnameStatusRejected, "已拒绝"),
}
const (
ImWorkOrderStatusReceived = "received"
ImWorkOrderStatusProcessing = "processing"
ImWorkOrderStatusResolved = "resolved"
ImWorkOrderStatusRejected = "rejected"
ImWorkOrderStatusClosed = "closed"
)
var ImWorkOrderStatusOptions = []*model.Option{
dict.GenWarningOption(ImWorkOrderStatusReceived, "待处理"),
dict.GenPrimaryOption(ImWorkOrderStatusProcessing, "处理中"),
dict.GenSuccessOption(ImWorkOrderStatusResolved, "已解决"),
dict.GenErrorOption(ImWorkOrderStatusRejected, "已驳回"),
dict.GenDefaultOption(ImWorkOrderStatusClosed, "已关闭"),
}
+31
View File
@@ -0,0 +1,31 @@
// Package consts
// @Link https://github.com/bufanyun/hotgo
// @Copyright Copyright (c) 2026 HotGo
// @Author Ms <133814250@qq.com>
// @License https://github.com/bufanyun/hotgo/blob/master/LICENSE
package consts
import (
"hotgo/internal/library/dict"
"hotgo/internal/model"
)
func init() {
dict.RegisterEnums("ImTenantStateOptions", "IM租户状态选项", ImTenantStateOptions)
}
const (
ImTenantStateProvisioning = 0 // 初始化中
ImTenantStateActive = 1 // 正常
ImTenantStateSuspended = 2 // 已停用
ImTenantStateDeleting = 3 // 删除中
ImTenantStateDeleted = 4 // 已删除
)
var ImTenantStateOptions = []*model.Option{
dict.GenWarningOption(ImTenantStateProvisioning, "初始化中"),
dict.GenSuccessOption(ImTenantStateActive, "正常"),
dict.GenErrorOption(ImTenantStateSuspended, "已停用"),
dict.GenWarningOption(ImTenantStateDeleting, "删除中"),
dict.GenDefaultOption(ImTenantStateDeleted, "已删除"),
}
+29
View File
@@ -0,0 +1,29 @@
// Package consts
// @Link https://github.com/bufanyun/hotgo
// @Copyright Copyright (c) 2026 HotGo
// @Author Ms <133814250@qq.com>
// @License https://github.com/bufanyun/hotgo/blob/master/LICENSE
package consts
import (
"hotgo/internal/library/dict"
"hotgo/internal/model"
)
func init() {
dict.RegisterEnums("ImUserStateOptions", "IM用户状态选项", ImUserStateOptions)
}
const (
ImUserStateOk = 0 // 正常
ImUserStateSuspended = 10 // 已停用
ImUserStateDeleted = 20 // 已删除
ImUserStateUndefined = 30 // 未定义
)
var ImUserStateOptions = []*model.Option{
dict.GenSuccessOption(ImUserStateOk, "正常"),
dict.GenErrorOption(ImUserStateSuspended, "已停用"),
dict.GenDefaultOption(ImUserStateDeleted, "已删除"),
dict.GenWarningOption(ImUserStateUndefined, "未定义"),
}
@@ -7,7 +7,6 @@ package common
import (
"context"
"github.com/gogf/gf/v2/i18n/gi18n"
"hotgo/api/admin/common"
"hotgo/internal/consts"
"hotgo/internal/library/captcha"
@@ -93,7 +92,7 @@ func (c *cSite) LoginConfig(ctx context.Context, _ *common.SiteLoginConfigReq) (
res.LoginConfig = login
res.I18nSwitch = g.Cfg().MustGet(ctx, "system.i18n.switch", true).Bool()
res.DefaultLanguage = g.Cfg().MustGet(ctx, "system.i18n.defaultLanguage", consts.SysDefaultLanguage).String()
res.ProjectName = gi18n.T(ctx, "HotGo管理系统")
res.ProjectName = "IM办公管理系统"
return
}
@@ -0,0 +1,43 @@
// Package sys
// @Link https://github.com/bufanyun/hotgo
// @Copyright Copyright (c) 2026 HotGo
// @Author Ms <133814250@qq.com>
// @License https://github.com/bufanyun/hotgo/blob/master/LICENSE
package sys
import (
"context"
"hotgo/api/admin/imauditlog"
"hotgo/internal/model/input/sysin"
"hotgo/internal/service"
)
var ImAuditLog = cImAuditLog{}
type cImAuditLog struct{}
// List 查看IM审计日志列表
func (c *cImAuditLog) List(ctx context.Context, req *imauditlog.ListReq) (res *imauditlog.ListRes, err error) {
list, totalCount, err := service.SysImAuditLog().List(ctx, &req.ImAuditLogListInp)
if err != nil {
return
}
if list == nil {
list = []*sysin.ImAuditLogListModel{}
}
res = new(imauditlog.ListRes)
res.List = list
res.PageRes.Pack(req, totalCount)
return
}
// View 获取指定IM审计日志信息
func (c *cImAuditLog) View(ctx context.Context, req *imauditlog.ViewReq) (res *imauditlog.ViewRes, err error) {
data, err := service.SysImAuditLog().View(ctx, &req.ImAuditLogViewInp)
if err != nil {
return
}
res = new(imauditlog.ViewRes)
res.ImAuditLogViewModel = data
return
}
@@ -0,0 +1,151 @@
// Package sys
// @Link https://github.com/bufanyun/hotgo
// @Copyright Copyright (c) 2026 HotGo
// @Author Ms <133814250@qq.com>
// @License https://github.com/bufanyun/hotgo/blob/master/LICENSE
package sys
import (
"context"
"hotgo/api/admin/imgroup"
"hotgo/internal/model/input/sysin"
"hotgo/internal/service"
)
var (
ImGroup = cImGroup{}
)
type cImGroup struct{}
// List 查看IM群组列表
func (c *cImGroup) List(ctx context.Context, req *imgroup.ListReq) (res *imgroup.ListRes, err error) {
list, totalCount, err := service.SysImGroup().List(ctx, &req.ImGroupListInp)
if err != nil {
return
}
if list == nil {
list = []*sysin.ImGroupListModel{}
}
res = new(imgroup.ListRes)
res.List = list
res.PageRes.Pack(req, totalCount)
return
}
// View 获取指定IM群组信息
func (c *cImGroup) View(ctx context.Context, req *imgroup.ViewReq) (res *imgroup.ViewRes, err error) {
data, err := service.SysImGroup().View(ctx, &req.ImGroupViewInp)
if err != nil {
return
}
res = new(imgroup.ViewRes)
res.ImGroupViewModel = data
return
}
// MemberList 查看IM群成员列表
func (c *cImGroup) MemberList(ctx context.Context, req *imgroup.MemberListReq) (res *imgroup.MemberListRes, err error) {
list, totalCount, err := service.SysImGroup().MemberList(ctx, &req.ImGroupMemberListInp)
if err != nil {
return
}
if list == nil {
list = []*sysin.ImGroupMemberListModel{}
}
res = new(imgroup.MemberListRes)
res.List = list
res.PageRes.Pack(req, totalCount)
return
}
// Governance 获取IM群治理配置
func (c *cImGroup) Governance(ctx context.Context, req *imgroup.GovernanceReq) (res *imgroup.GovernanceRes, err error) {
data, err := service.SysImGroup().Governance(ctx, &req.ImGroupTopicInp)
if err != nil {
return
}
res = new(imgroup.GovernanceRes)
res.ImGroupGovernanceModel = data
return
}
// JoinRequestList 查看IM入群申请列表
func (c *cImGroup) JoinRequestList(ctx context.Context, req *imgroup.JoinRequestListReq) (res *imgroup.JoinRequestListRes, err error) {
list, totalCount, err := service.SysImGroup().JoinRequestList(ctx, &req.ImGroupJoinRequestListInp)
if err != nil {
return
}
if list == nil {
list = []*sysin.ImGroupJoinRequestListModel{}
}
res = new(imgroup.JoinRequestListRes)
res.List = list
res.PageRes.Pack(req, totalCount)
return
}
// InvitationList 查看IM群邀请列表
func (c *cImGroup) InvitationList(ctx context.Context, req *imgroup.InvitationListReq) (res *imgroup.InvitationListRes, err error) {
list, totalCount, err := service.SysImGroup().InvitationList(ctx, &req.ImGroupInvitationListInp)
if err != nil {
return
}
if list == nil {
list = []*sysin.ImGroupInvitationListModel{}
}
res = new(imgroup.InvitationListRes)
res.List = list
res.PageRes.Pack(req, totalCount)
return
}
// AnnouncementList 查看IM群公告列表
func (c *cImGroup) AnnouncementList(ctx context.Context, req *imgroup.AnnouncementListReq) (res *imgroup.AnnouncementListRes, err error) {
list, totalCount, err := service.SysImGroup().AnnouncementList(ctx, &req.ImGroupAnnouncementListInp)
if err != nil {
return
}
if list == nil {
list = []*sysin.ImGroupAnnouncementListModel{}
}
res = new(imgroup.AnnouncementListRes)
res.List = list
res.PageRes.Pack(req, totalCount)
return
}
// MuteList 查看IM群禁言列表
func (c *cImGroup) MuteList(ctx context.Context, req *imgroup.MuteListReq) (res *imgroup.MuteListRes, err error) {
list, totalCount, err := service.SysImGroup().MuteList(ctx, &req.ImGroupMuteListInp)
if err != nil {
return
}
if list == nil {
list = []*sysin.ImGroupMuteListModel{}
}
res = new(imgroup.MuteListRes)
res.List = list
res.PageRes.Pack(req, totalCount)
return
}
// AdminLogList 查看IM群管理日志列表
func (c *cImGroup) AdminLogList(ctx context.Context, req *imgroup.AdminLogListReq) (res *imgroup.AdminLogListRes, err error) {
list, totalCount, err := service.SysImGroup().AdminLogList(ctx, &req.ImGroupAdminLogListInp)
if err != nil {
return
}
if list == nil {
list = []*sysin.ImGroupAdminLogListModel{}
}
res = new(imgroup.AdminLogListRes)
res.List = list
res.PageRes.Pack(req, totalCount)
return
}
@@ -0,0 +1,76 @@
package sys
import (
"context"
"hotgo/api/admin/iminvite"
"hotgo/internal/model/input/sysin"
"hotgo/internal/service"
)
var ImInvite = cImInvite{}
type cImInvite struct{}
func (c *cImInvite) List(ctx context.Context, req *iminvite.ListReq) (res *iminvite.ListRes, err error) {
list, totalCount, err := service.SysImInvite().List(ctx, &req.ImInviteListInp)
if err != nil {
return
}
if list == nil {
list = []*sysin.ImInviteCodeModel{}
}
res = new(iminvite.ListRes)
res.List = list
res.PageRes.Pack(req, totalCount)
return
}
func (c *cImInvite) Create(ctx context.Context, req *iminvite.CreateReq) (res *iminvite.CreateRes, err error) {
data, err := service.SysImInvite().Create(ctx, &req.ImInviteCreateInp)
if err != nil {
return
}
res = new(iminvite.CreateRes)
res.ImInviteCodeModel = data
return
}
func (c *cImInvite) BatchCreate(ctx context.Context, req *iminvite.BatchCreateReq) (res *iminvite.BatchCreateRes, err error) {
data, err := service.SysImInvite().BatchCreate(ctx, &req.ImInviteBatchCreateInp)
if err != nil {
return
}
res = new(iminvite.BatchCreateRes)
res.ImInviteBatchModel = data
return
}
func (c *cImInvite) BatchView(ctx context.Context, req *iminvite.BatchViewReq) (res *iminvite.BatchViewRes, err error) {
data, err := service.SysImInvite().BatchView(ctx, &req.ImInviteBatchViewInp)
if err != nil {
return
}
res = new(iminvite.BatchViewRes)
res.ImInviteBatchDetailModel = data
return
}
func (c *cImInvite) Revoke(ctx context.Context, req *iminvite.RevokeReq) (res *iminvite.RevokeRes, err error) {
err = service.SysImInvite().Revoke(ctx, &req.ImInviteRevokeInp)
return
}
func (c *cImInvite) BatchRevoke(ctx context.Context, req *iminvite.BatchRevokeReq) (res *iminvite.BatchRevokeRes, err error) {
data, err := service.SysImInvite().BatchRevoke(ctx, &req.ImInviteBatchRevokeInp)
if err != nil {
return
}
res = new(iminvite.BatchRevokeRes)
res.ImInviteBatchModel = data
return
}
func (c *cImInvite) Export(ctx context.Context, req *iminvite.ExportReq) (res *iminvite.ExportRes, err error) {
err = service.SysImInvite().Export(ctx, &req.ImInviteListInp)
return
}
@@ -0,0 +1,53 @@
// Package sys
// @Link https://github.com/bufanyun/hotgo
// @Copyright Copyright (c) 2026 HotGo
// @Author Ms <133814250@qq.com>
// @License https://github.com/bufanyun/hotgo/blob/master/LICENSE
package sys
import (
"context"
"hotgo/api/admin/imrealname"
"hotgo/internal/model/input/sysin"
"hotgo/internal/service"
)
var (
ImRealname = cImRealname{}
)
type cImRealname struct{}
// List 查看IM实名审核列表
func (c *cImRealname) List(ctx context.Context, req *imrealname.ListReq) (res *imrealname.ListRes, err error) {
list, totalCount, err := service.SysImRealname().List(ctx, &req.ImRealnameListInp)
if err != nil {
return
}
if list == nil {
list = []*sysin.ImRealnameListModel{}
}
res = new(imrealname.ListRes)
res.List = list
res.PageRes.Pack(req, totalCount)
return
}
// View 获取指定IM实名审核信息
func (c *cImRealname) View(ctx context.Context, req *imrealname.ViewReq) (res *imrealname.ViewRes, err error) {
data, err := service.SysImRealname().View(ctx, &req.ImRealnameViewInp)
if err != nil {
return
}
res = new(imrealname.ViewRes)
res.ImRealnameViewModel = data
return
}
// Review 审核IM实名
func (c *cImRealname) Review(ctx context.Context, req *imrealname.ReviewReq) (res *imrealname.ReviewRes, err error) {
err = service.SysImRealname().Review(ctx, &req.ImRealnameReviewInp)
return
}
@@ -0,0 +1,26 @@
package sys
import (
"context"
"hotgo/api/admin/imregistration"
"hotgo/internal/service"
)
var ImRegistration = cImRegistration{}
type cImRegistration struct{}
func (c *cImRegistration) Get(ctx context.Context, req *imregistration.GetReq) (res *imregistration.GetRes, err error) {
data, err := service.SysImRegistration().GetConfig(ctx)
if err != nil {
return
}
res = new(imregistration.GetRes)
res.ImRegistrationConfigModel = data
return
}
func (c *cImRegistration) Update(ctx context.Context, req *imregistration.UpdateReq) (res *imregistration.UpdateRes, err error) {
err = service.SysImRegistration().UpdateConfig(ctx, &req.ImRegistrationConfigUpdateInp)
return
}
@@ -0,0 +1,60 @@
// Package sys
// @Link https://github.com/bufanyun/hotgo
// @Copyright Copyright (c) 2026 HotGo
// @Author Ms <133814250@qq.com>
// @License https://github.com/bufanyun/hotgo/blob/master/LICENSE
package sys
import (
"context"
"hotgo/api/admin/imtenant"
"hotgo/internal/model/input/sysin"
"hotgo/internal/service"
)
var (
ImTenant = cImTenant{}
)
type cImTenant struct{}
// List 查看IM租户列表
func (c *cImTenant) List(ctx context.Context, req *imtenant.ListReq) (res *imtenant.ListRes, err error) {
list, totalCount, err := service.SysImTenant().List(ctx, &req.ImTenantListInp)
if err != nil {
return
}
if list == nil {
list = []*sysin.ImTenantListModel{}
}
res = new(imtenant.ListRes)
res.List = list
res.PageRes.Pack(req, totalCount)
return
}
// Edit 更新IM租户
func (c *cImTenant) Edit(ctx context.Context, req *imtenant.EditReq) (res *imtenant.EditRes, err error) {
err = service.SysImTenant().Edit(ctx, &req.ImTenantEditInp)
return
}
// View 获取指定IM租户信息
func (c *cImTenant) View(ctx context.Context, req *imtenant.ViewReq) (res *imtenant.ViewRes, err error) {
data, err := service.SysImTenant().View(ctx, &req.ImTenantViewInp)
if err != nil {
return
}
res = new(imtenant.ViewRes)
res.ImTenantViewModel = data
return
}
// Status 更新IM租户状态
func (c *cImTenant) Status(ctx context.Context, req *imtenant.StatusReq) (res *imtenant.StatusRes, err error) {
err = service.SysImTenant().Status(ctx, &req.ImTenantStatusInp)
return
}
@@ -0,0 +1,53 @@
// Package sys
// @Link https://github.com/bufanyun/hotgo
// @Copyright Copyright (c) 2026 HotGo
// @Author Ms <133814250@qq.com>
// @License https://github.com/bufanyun/hotgo/blob/master/LICENSE
package sys
import (
"context"
"hotgo/api/admin/imuser"
"hotgo/internal/model/input/sysin"
"hotgo/internal/service"
)
var (
ImUser = cImUser{}
)
type cImUser struct{}
// List 查看IM用户列表
func (c *cImUser) List(ctx context.Context, req *imuser.ListReq) (res *imuser.ListRes, err error) {
list, totalCount, err := service.SysImUser().List(ctx, &req.ImUserListInp)
if err != nil {
return
}
if list == nil {
list = []*sysin.ImUserListModel{}
}
res = new(imuser.ListRes)
res.List = list
res.PageRes.Pack(req, totalCount)
return
}
// View 获取指定IM用户信息
func (c *cImUser) View(ctx context.Context, req *imuser.ViewReq) (res *imuser.ViewRes, err error) {
data, err := service.SysImUser().View(ctx, &req.ImUserViewInp)
if err != nil {
return
}
res = new(imuser.ViewRes)
res.ImUserViewModel = data
return
}
func (c *cImUser) ResetPassword(ctx context.Context, req *imuser.ResetPasswordReq) (res *imuser.ResetPasswordRes, err error) {
err = service.SysImUser().ResetPassword(ctx, &req.ImUserPasswordResetInp)
return
}
@@ -0,0 +1,53 @@
// Package sys
// @Link https://github.com/bufanyun/hotgo
// @Copyright Copyright (c) 2026 HotGo
// @Author Ms <133814250@qq.com>
// @License https://github.com/bufanyun/hotgo/blob/master/LICENSE
package sys
import (
"context"
"hotgo/api/admin/imworkorder"
"hotgo/internal/model/input/sysin"
"hotgo/internal/service"
)
var (
ImWorkOrder = cImWorkOrder{}
)
type cImWorkOrder struct{}
// List 查看IM举报反馈工单列表
func (c *cImWorkOrder) List(ctx context.Context, req *imworkorder.ListReq) (res *imworkorder.ListRes, err error) {
list, totalCount, err := service.SysImWorkOrder().List(ctx, &req.ImWorkOrderListInp)
if err != nil {
return
}
if list == nil {
list = []*sysin.ImWorkOrderListModel{}
}
res = new(imworkorder.ListRes)
res.List = list
res.PageRes.Pack(req, totalCount)
return
}
// View 获取指定IM举报反馈工单信息
func (c *cImWorkOrder) View(ctx context.Context, req *imworkorder.ViewReq) (res *imworkorder.ViewRes, err error) {
data, err := service.SysImWorkOrder().View(ctx, &req.ImWorkOrderViewInp)
if err != nil {
return
}
res = new(imworkorder.ViewRes)
res.ImWorkOrderViewModel = data
return
}
// Status 更新IM举报反馈工单状态
func (c *cImWorkOrder) Status(ctx context.Context, req *imworkorder.StatusReq) (res *imworkorder.StatusRes, err error) {
err = service.SysImWorkOrder().Status(ctx, &req.ImWorkOrderStatusInp)
return
}
+22
View File
@@ -0,0 +1,22 @@
// =================================================================================
// This file is auto-generated by the GoFrame CLI tool. You may modify it as needed.
// =================================================================================
package dao
import (
"hotgo/internal/dao/internal"
)
// imTenantDao is the data access object for the table im_tenant.
// You can define custom methods on it to extend its functionality as needed.
type imTenantDao struct {
*internal.ImTenantDao
}
var (
// ImTenant is a globally accessible object for table im_tenant operations.
ImTenant = imTenantDao{internal.NewImTenantDao()}
)
// Add your custom methods and functionality below.
+22
View File
@@ -0,0 +1,22 @@
// =================================================================================
// This file is auto-generated by the GoFrame CLI tool. You may modify it as needed.
// =================================================================================
package dao
import (
"hotgo/internal/dao/internal"
)
// imTenantConfigDao is the data access object for the table im_tenant_config.
// You can define custom methods on it to extend its functionality as needed.
type imTenantConfigDao struct {
*internal.ImTenantConfigDao
}
var (
// ImTenantConfig is a globally accessible object for table im_tenant_config operations.
ImTenantConfig = imTenantConfigDao{internal.NewImTenantConfigDao()}
)
// Add your custom methods and functionality below.
@@ -22,6 +22,7 @@ type AdminMemberDao struct {
// AdminMemberColumns defines and stores column names for the table hg_admin_member.
type AdminMemberColumns struct {
Id string // 管理员ID
TenantId string // IM租户ID
DeptId string // 部门ID
RoleId string // 角色ID
RealName string // 真实姓名
@@ -54,6 +55,7 @@ type AdminMemberColumns struct {
// adminMemberColumns holds the columns for the table hg_admin_member.
var adminMemberColumns = AdminMemberColumns{
Id: "id",
TenantId: "tenant_id",
DeptId: "dept_id",
RoleId: "role_id",
RealName: "real_name",
+99
View File
@@ -0,0 +1,99 @@
// ==========================================================================
// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT.
// ==========================================================================
package internal
import (
"context"
"github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/frame/g"
)
// ImTenantDao is the data access object for the table im_tenant.
type ImTenantDao struct {
table string // table is the underlying table name of the DAO.
group string // group is the database configuration group name of the current DAO.
columns ImTenantColumns // columns contains all the column names of Table for convenient usage.
handlers []gdb.ModelHandler // handlers for customized model modification.
}
// ImTenantColumns defines and stores column names for the table im_tenant.
type ImTenantColumns struct {
Id string //
Code string //
Name string //
TenantDesc string //
DbName string //
DbDsn string //
State string //
CreatedAt string //
CreatedBy string //
UpdatedAt string //
UpdatedBy string //
}
// imTenantColumns holds the columns for the table im_tenant.
var imTenantColumns = ImTenantColumns{
Id: "id",
Code: "code",
Name: "name",
TenantDesc: "tenant_desc",
DbName: "db_name",
DbDsn: "db_dsn",
State: "state",
CreatedAt: "created_at",
CreatedBy: "created_by",
UpdatedAt: "updated_at",
UpdatedBy: "updated_by",
}
// NewImTenantDao creates and returns a new DAO object for table data access.
func NewImTenantDao(handlers ...gdb.ModelHandler) *ImTenantDao {
return &ImTenantDao{
group: "default",
table: "im_tenant",
columns: imTenantColumns,
handlers: handlers,
}
}
// DB retrieves and returns the underlying raw database management object of the current DAO.
func (dao *ImTenantDao) DB() gdb.DB {
return g.DB(dao.group)
}
// Table returns the table name of the current DAO.
func (dao *ImTenantDao) Table() string {
return dao.table
}
// Columns returns all column names of the current DAO.
func (dao *ImTenantDao) Columns() ImTenantColumns {
return dao.columns
}
// Group returns the database configuration group name of the current DAO.
func (dao *ImTenantDao) Group() string {
return dao.group
}
// Ctx creates and returns a Model for the current DAO. It automatically sets the context for the current operation.
func (dao *ImTenantDao) Ctx(ctx context.Context) *gdb.Model {
model := dao.DB().Model(dao.table)
for _, handler := range dao.handlers {
model = handler(model)
}
return model.Safe().Ctx(ctx)
}
// Transaction wraps the transaction logic using function f.
// It rolls back the transaction and returns the error if function f returns a non-nil error.
// It commits the transaction and returns nil if function f returns nil.
//
// Note: Do not commit or roll back the transaction in function f,
// as it is automatically handled by this function.
func (dao *ImTenantDao) Transaction(ctx context.Context, f func(ctx context.Context, tx gdb.TX) error) (err error) {
return dao.Ctx(ctx).Transaction(ctx, f)
}
@@ -0,0 +1,95 @@
// ==========================================================================
// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT.
// ==========================================================================
package internal
import (
"context"
"github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/frame/g"
)
// ImTenantConfigDao is the data access object for the table im_tenant_config.
type ImTenantConfigDao struct {
table string // table is the underlying table name of the DAO.
group string // group is the database configuration group name of the current DAO.
columns ImTenantConfigColumns // columns contains all the column names of Table for convenient usage.
handlers []gdb.ModelHandler // handlers for customized model modification.
}
// ImTenantConfigColumns defines and stores column names for the table im_tenant_config.
type ImTenantConfigColumns struct {
Id string //
TenantId string //
MaxUsers string // 0 means unlimited
MaxGroups string // 0 means unlimited
MaxGroupMembers string // 0 means unlimited
CreatedAt string //
CreatedBy string //
UpdatedAt string //
UpdatedBy string //
}
// imTenantConfigColumns holds the columns for the table im_tenant_config.
var imTenantConfigColumns = ImTenantConfigColumns{
Id: "id",
TenantId: "tenant_id",
MaxUsers: "max_users",
MaxGroups: "max_groups",
MaxGroupMembers: "max_group_members",
CreatedAt: "created_at",
CreatedBy: "created_by",
UpdatedAt: "updated_at",
UpdatedBy: "updated_by",
}
// NewImTenantConfigDao creates and returns a new DAO object for table data access.
func NewImTenantConfigDao(handlers ...gdb.ModelHandler) *ImTenantConfigDao {
return &ImTenantConfigDao{
group: "default",
table: "im_tenant_config",
columns: imTenantConfigColumns,
handlers: handlers,
}
}
// DB retrieves and returns the underlying raw database management object of the current DAO.
func (dao *ImTenantConfigDao) DB() gdb.DB {
return g.DB(dao.group)
}
// Table returns the table name of the current DAO.
func (dao *ImTenantConfigDao) Table() string {
return dao.table
}
// Columns returns all column names of the current DAO.
func (dao *ImTenantConfigDao) Columns() ImTenantConfigColumns {
return dao.columns
}
// Group returns the database configuration group name of the current DAO.
func (dao *ImTenantConfigDao) Group() string {
return dao.group
}
// Ctx creates and returns a Model for the current DAO. It automatically sets the context for the current operation.
func (dao *ImTenantConfigDao) Ctx(ctx context.Context) *gdb.Model {
model := dao.DB().Model(dao.table)
for _, handler := range dao.handlers {
model = handler(model)
}
return model.Safe().Ctx(ctx)
}
// Transaction wraps the transaction logic using function f.
// It rolls back the transaction and returns the error if function f returns a non-nil error.
// It commits the transaction and returns nil if function f returns nil.
//
// Note: Do not commit or roll back the transaction in function f,
// as it is automatically handled by this function.
func (dao *ImTenantConfigDao) Transaction(ctx context.Context, f func(ctx context.Context, tx gdb.TX) error) (err error) {
return dao.Ctx(ctx).Transaction(ctx, f)
}
@@ -78,6 +78,15 @@ func GetUserId(ctx context.Context) int64 {
return user.Id
}
// GetTenantId 获取当前身份关联的统一租户ID(im_tenant.id)。
func GetTenantId(ctx context.Context) int64 {
user := GetUser(ctx)
if user == nil {
return 0
}
return user.TenantId
}
// GetRoleId 获取用户角色ID
func GetRoleId(ctx context.Context) int64 {
user := GetUser(ctx)
+147
View File
@@ -0,0 +1,147 @@
// Package imtenant provides helpers for accessing tenant business databases.
package imtenant
import (
"context"
"fmt"
"hash/fnv"
"hotgo/internal/dao"
"hotgo/internal/model/entity"
"net/url"
"strings"
"sync"
_ "github.com/gogf/gf/contrib/drivers/mysql/v2"
"github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/errors/gerror"
"github.com/gogf/gf/v2/frame/g"
)
var tenantDbGroups sync.Map
const registryDbGroup = "imRegistry"
// DB resolves a tenant registry record and returns the corresponding business DB.
func DB(ctx context.Context, tenantId int64) (db gdb.DB, tenant *entity.ImTenant, err error) {
if tenantId <= 0 {
return nil, nil, gerror.New("租户ID不能为空")
}
if err = RegistryDB().Model(dao.ImTenant.Table()).Safe().Ctx(ctx).WherePri(tenantId).Scan(&tenant); err != nil {
return nil, nil, gerror.Wrap(err, "获取租户信息失败")
}
if tenant == nil || tenant.Id <= 0 {
return nil, nil, gerror.New("租户不存在")
}
link, err := tenantLink(ctx, tenant)
if err != nil {
return nil, nil, err
}
group := fmt.Sprintf("im_tenant_%d_%x", tenant.Id, hash(link))
if cached, ok := tenantDbGroups.Load(group); ok && cached == link {
db, err = gdb.Instance(group)
return db, tenant, err
}
if err = gdb.SetConfigGroup(group, gdb.ConfigGroup{{Link: link}}); err != nil {
return nil, nil, gerror.Wrap(err, "初始化租户业务库连接失败")
}
tenantDbGroups.Store(group, link)
db, err = gdb.Instance(group)
if err != nil {
return nil, nil, gerror.Wrap(err, "获取租户业务库连接失败")
}
return db, tenant, nil
}
// RegistryDB returns the dedicated tenant registry connection, or the default
// HotGo database when a separate registry group is not configured.
func RegistryDB() gdb.DB {
if configs, err := gdb.GetConfigGroup(registryDbGroup); err == nil && len(configs) > 0 {
return g.DB(registryDbGroup)
}
return g.DB()
}
func tenantLink(ctx context.Context, tenant *entity.ImTenant) (string, error) {
if tenant.DbDsn != "" {
return normalizeMySQLTimeOptions(tenant.DbDsn)
}
if tenant.DbName == "" {
return "", gerror.New("租户业务库未配置")
}
defaultLink := defaultDbLink(ctx)
if defaultLink == "" {
return "", gerror.New("默认数据库连接未配置,无法推导租户业务库连接")
}
link, err := replaceMySQLDatabase(defaultLink, tenant.DbName)
if err != nil {
return "", err
}
return link, nil
}
func defaultDbLink(ctx context.Context) string {
if configs, err := gdb.GetConfigGroup("default"); err == nil && len(configs) > 0 {
if configs[0].Link != "" {
return configs[0].Link
}
}
cfg := g.Cfg()
if !cfg.Available(ctx) {
return ""
}
defaultLink := cfg.MustGet(ctx, "database.default.0.link").String()
if defaultLink != "" {
return defaultLink
}
return cfg.MustGet(ctx, "database.default.link").String()
}
func replaceMySQLDatabase(link string, dbName string) (string, error) {
if !strings.HasPrefix(link, "mysql:") {
return "", gerror.New("仅支持从 MySQL 默认连接推导租户业务库连接")
}
dbStart := strings.LastIndex(link, ")/")
if dbStart < 0 {
return "", gerror.New("默认数据库连接格式不正确")
}
dbStart += 2
queryStart := strings.Index(link[dbStart:], "?")
if queryStart < 0 {
return normalizeMySQLTimeOptions(link[:dbStart] + dbName)
}
queryStart += dbStart
return normalizeMySQLTimeOptions(link[:dbStart] + dbName + link[queryStart:])
}
func normalizeMySQLTimeOptions(link string) (string, error) {
if !strings.HasPrefix(link, "mysql:") {
return "", gerror.New("租户业务库仅支持 MySQL")
}
parts := strings.SplitN(link, "?", 2)
values := make(url.Values)
if len(parts) == 2 {
var err error
values, err = url.ParseQuery(parts[1])
if err != nil {
return "", gerror.Wrap(err, "租户业务库连接参数格式不正确")
}
}
values.Set("loc", "UTC")
values.Set("parseTime", "true")
return parts[0] + "?" + values.Encode(), nil
}
func hash(s string) uint64 {
h := fnv.New64a()
_, _ = h.Write([]byte(s))
return h.Sum64()
}
@@ -0,0 +1,33 @@
package imtenant
import (
"context"
"strings"
"testing"
)
func TestReplaceMySQLDatabaseUsesUTC(t *testing.T) {
link, err := replaceMySQLDatabase("mysql:user:pass@tcp(127.0.0.1:3306)/hotgo?loc=Local&parseTime=false&charset=utf8mb4", "tinode")
if err != nil {
t.Fatal(err)
}
for _, expected := range []string{"/tinode?", "loc=UTC", "parseTime=true", "charset=utf8mb4"} {
if !strings.Contains(link, expected) {
t.Fatalf("link %q does not contain %q", link, expected)
}
}
}
func TestNextSnowflakeID(t *testing.T) {
first, err := NextSnowflakeID(context.Background())
if err != nil {
t.Fatal(err)
}
second, err := NextSnowflakeID(context.Background())
if err != nil {
t.Fatal(err)
}
if first <= 0 || second <= first {
t.Fatalf("Snowflake IDs are not increasing: %d, %d", first, second)
}
}
@@ -0,0 +1,50 @@
package imtenant
import (
"context"
"fmt"
"os"
"strconv"
"sync"
"github.com/gogf/gf/v2/frame/g"
sf "github.com/tinode/snowflake"
)
const defaultSnowflakeWorkerID = 1023
var (
snowflakeOnce sync.Once
snowflakeGen *sf.SnowFlake
snowflakeErr error
)
// NextSnowflakeID returns an unencrypted Snowflake ID suitable for IM BIGINT keys.
func NextSnowflakeID(ctx context.Context) (int64, error) {
snowflakeOnce.Do(func() {
workerID := g.Cfg().MustGet(ctx, "imDirectDb.snowflakeWorkerId", defaultSnowflakeWorkerID).Int()
if value := os.Getenv("IM_DIRECT_DB_SNOWFLAKE_WORKER_ID"); value != "" {
workerID, snowflakeErr = strconv.Atoi(value)
if snowflakeErr != nil {
snowflakeErr = fmt.Errorf("invalid IM_DIRECT_DB_SNOWFLAKE_WORKER_ID: %w", snowflakeErr)
return
}
}
if workerID < 0 || workerID > 1023 {
snowflakeErr = fmt.Errorf("IM Snowflake worker ID must be between 0 and 1023")
return
}
snowflakeGen, snowflakeErr = sf.NewSnowFlake(uint32(workerID))
})
if snowflakeErr != nil {
return 0, snowflakeErr
}
id, err := snowflakeGen.Next()
if err != nil {
return 0, err
}
if id == 0 || id > uint64(^uint64(0)>>1) {
return 0, fmt.Errorf("generated invalid IM Snowflake ID")
}
return int64(id), nil
}
+23
View File
@@ -467,6 +467,29 @@ func (s *sAdminMember) Edit(ctx context.Context, in *adminin.MemberEditInp) (err
return
}
if service.AdminMember().VerifySuperId(ctx, opMemberId) {
if in.TenantId > 0 {
count, queryErr := g.DB("imRegistry").Model(dao.ImTenant.Table()).Safe().Ctx(ctx).
Where(dao.ImTenant.Columns().Id, in.TenantId).
Where(dao.ImTenant.Columns().State, consts.ImTenantStateActive).
Count()
if queryErr != nil {
return gerror.Wrap(queryErr, "校验IM租户失败")
}
if count == 0 {
return gerror.New("IM租户不存在或未启用")
}
}
} else if deptType := contexts.GetDeptType(ctx); deptType == consts.DeptTypeTenant ||
deptType == consts.DeptTypeMerchant || deptType == consts.DeptTypeUser {
in.TenantId = contexts.GetTenantId(ctx)
if in.TenantId <= 0 {
return gerror.New("当前管理员未关联租户")
}
} else {
in.TenantId = 0
}
cols := dao.AdminMember.Columns()
err = s.VerifyUnique(ctx, &adminin.VerifyUniqueInp{
Id: in.Id,
+2
View File
@@ -228,6 +228,7 @@ func (s *sAdminSite) handleLogin(ctx context.Context, mb *entity.AdminMember) (r
user := &model.Identity{
Id: mb.Id,
TenantId: mb.TenantId,
Pid: mb.Pid,
DeptId: dept.Id,
DeptType: dept.Type,
@@ -319,6 +320,7 @@ func (s *sAdminSite) BindUserContext(ctx context.Context, claims *model.Identity
user := &model.Identity{
Id: mb.Id,
TenantId: mb.TenantId,
Pid: mb.Pid,
DeptId: dept.Id,
DeptType: dept.Type,
+108
View File
@@ -0,0 +1,108 @@
package sys
import (
"context"
"hotgo/internal/consts"
"hotgo/internal/dao"
"hotgo/internal/library/contexts"
"hotgo/internal/library/imtenant"
"hotgo/internal/model/entity"
"hotgo/internal/service"
"hotgo/utility/simple"
"github.com/gogf/gf/v2/encoding/gjson"
"github.com/gogf/gf/v2/errors/gerror"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/net/ghttp"
"github.com/gogf/gf/v2/os/gctx"
"github.com/gogf/gf/v2/os/gtime"
)
type imAdminScope struct {
TenantId int64
OperatorId int64
RoleKey string
RequestId string
}
const (
imAdminOperationAuditTable = "im_admin_operation_audit"
)
func resolveImAdminScope(ctx context.Context, selectedTenantId int64) (scope imAdminScope, err error) {
return resolveImAdminScopeWithAll(ctx, selectedTenantId, false)
}
func resolveImAdminScopeWithAll(ctx context.Context, selectedTenantId int64, allowAll bool) (scope imAdminScope, err error) {
operatorId := contexts.GetUserId(ctx)
scope.OperatorId = operatorId
scope.RoleKey = contexts.GetRoleKey(ctx)
scope.RequestId = gctx.CtxId(ctx)
if service.AdminMember().VerifySuperId(ctx, operatorId) {
if selectedTenantId <= 0 {
if allowAll {
return scope, nil
}
return scope, gerror.New("请选择IM租户")
}
if err = ensureImTenantActive(ctx, selectedTenantId); err != nil {
return scope, err
}
scope.TenantId = selectedTenantId
return scope, nil
}
if contexts.GetDeptType(ctx) != consts.DeptTypeTenant {
return scope, gerror.New("当前账号不是租户管理员,不能管理邀请码")
}
tenantId := contexts.GetTenantId(ctx)
if tenantId <= 0 {
return scope, gerror.New("当前管理员未关联租户")
}
if err = ensureImTenantActive(ctx, tenantId); err != nil {
return scope, err
}
scope.TenantId = tenantId
return scope, nil
}
func ensureImTenantActive(ctx context.Context, tenantId int64) (err error) {
var tenant *entity.ImTenant
if err = imtenant.RegistryDB().Model(dao.ImTenant.Table()).Safe().Ctx(ctx).WherePri(tenantId).Scan(&tenant); err != nil {
return gerror.Wrap(err, "获取IM租户失败")
}
if tenant == nil || tenant.Id <= 0 {
return gerror.New("IM租户不存在")
}
if tenant.State != consts.ImTenantStateActive {
return gerror.New("IM租户未启用")
}
return nil
}
func writeImAdminAudit(ctx context.Context, tenantId int64, action string, targetType string, targetId int64, result string, detail g.Map) error {
operatorId := contexts.GetUserId(ctx)
request := ghttp.RequestFromCtx(ctx)
ip := ""
if request != nil {
ip = request.GetClientIp()
}
detailJson := gjson.New(detail)
_, err := g.DB().Exec(ctx,
"INSERT INTO "+imAdminOperationAuditTable+" (request_id, tenant_id, operator_id, action, target_type, target_id, result, ip, detail_json, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, CAST(? AS JSON), ?)",
gctx.CtxId(ctx),
tenantId,
operatorId,
action,
targetType,
targetId,
result,
ip,
detailJson.String(),
gtime.Now(),
)
if err != nil && !simple.IsDemo(ctx) {
g.Log().Warningf(ctx, "写入IM后台审计失败 action:%s err:%+v", action, err)
}
return err
}
+125
View File
@@ -0,0 +1,125 @@
// Package sys
// @Link https://github.com/bufanyun/hotgo
// @Copyright Copyright (c) 2026 HotGo
// @Author Ms <133814250@qq.com>
// @License https://github.com/bufanyun/hotgo/blob/master/LICENSE
package sys
import (
"context"
"hotgo/internal/library/imtenant"
"hotgo/internal/model/input/sysin"
"hotgo/internal/service"
"github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/errors/gerror"
)
type sSysImAuditLog struct{}
func NewSysImAuditLog() *sSysImAuditLog {
return &sSysImAuditLog{}
}
func init() {
service.RegisterSysImAuditLog(NewSysImAuditLog())
}
// List 获取IM审计日志列表
func (s *sSysImAuditLog) List(ctx context.Context, in *sysin.ImAuditLogListInp) (list []*sysin.ImAuditLogListModel, totalCount int, err error) {
if err = in.Filter(ctx); err != nil {
return
}
db, tenant, err := imtenant.DB(ctx, in.TenantId)
if err != nil {
return
}
mod := s.model(ctx, db)
mod = s.applyWhere(mod, in)
mod = mod.Page(in.Page, in.PerPage).OrderDesc("id")
if err = mod.ScanAndCount(&list, &totalCount, false); err != nil {
err = gerror.Wrap(err, "获取IM审计日志列表失败,请稍后重试!")
return
}
for _, item := range list {
item.TenantId = tenant.Id
item.TenantName = tenant.Name
}
return
}
func (s *sSysImAuditLog) model(ctx context.Context, db gdb.DB) *gdb.Model {
return db.Model("im_audit_logs").Safe().Ctx(ctx).
Fields(
"id",
"actor_uid",
"actor_role",
"action",
"target_type",
"target_id",
"reason",
"request_id",
"ip",
"user_agent",
"CAST(before_snapshot AS CHAR) before_snapshot",
"CAST(after_snapshot AS CHAR) after_snapshot",
"CAST(metadata AS CHAR) metadata",
"created_at",
)
}
func (s *sSysImAuditLog) applyWhere(mod *gdb.Model, in *sysin.ImAuditLogListInp) *gdb.Model {
if in.ActorUid > 0 {
mod = mod.Where("actor_uid", in.ActorUid)
}
if in.ActorRole != "" {
mod = mod.WhereLike("actor_role", "%"+in.ActorRole+"%")
}
if in.Action != "" {
mod = mod.WhereLike("action", "%"+in.Action+"%")
}
if in.TargetType != "" {
mod = mod.Where("target_type", in.TargetType)
}
if in.TargetId != "" {
mod = mod.WhereLike("target_id", "%"+in.TargetId+"%")
}
if in.RequestId != "" {
mod = mod.WhereLike("request_id", "%"+in.RequestId+"%")
}
if in.Ip != "" {
mod = mod.WhereLike("ip", "%"+in.Ip+"%")
}
if in.Keyword != "" {
like := "%" + in.Keyword + "%"
mod = mod.Where("(action LIKE ? OR target_type LIKE ? OR target_id LIKE ? OR reason LIKE ? OR request_id LIKE ?)", like, like, like, like, like)
}
if len(in.CreatedAt) == 2 {
mod = mod.WhereBetween("created_at", in.CreatedAt[0], in.CreatedAt[1])
}
return mod
}
// View 获取IM审计日志指定信息
func (s *sSysImAuditLog) View(ctx context.Context, in *sysin.ImAuditLogViewInp) (res *sysin.ImAuditLogViewModel, err error) {
if err = in.Filter(ctx); err != nil {
return
}
db, tenant, err := imtenant.DB(ctx, in.TenantId)
if err != nil {
return nil, err
}
err = s.model(ctx, db).Where("id", in.Id).Scan(&res)
if err != nil {
return nil, gerror.Wrap(err, "获取IM审计日志信息失败,请稍后重试!")
}
if res == nil {
return nil, nil
}
res.TenantId = tenant.Id
res.TenantName = tenant.Name
return
}
+456
View File
@@ -0,0 +1,456 @@
// Package sys
// @Link https://github.com/bufanyun/hotgo
// @Copyright Copyright (c) 2026 HotGo
// @Author Ms <133814250@qq.com>
// @License https://github.com/bufanyun/hotgo/blob/master/LICENSE
package sys
import (
"context"
"hotgo/internal/library/imtenant"
"hotgo/internal/model/input/sysin"
"hotgo/internal/service"
"github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/errors/gerror"
)
type sSysImGroup struct{}
func NewSysImGroup() *sSysImGroup {
return &sSysImGroup{}
}
func init() {
service.RegisterSysImGroup(NewSysImGroup())
}
// List 获取IM群组列表
func (s *sSysImGroup) List(ctx context.Context, in *sysin.ImGroupListInp) (list []*sysin.ImGroupListModel, totalCount int, err error) {
if err = in.Filter(ctx); err != nil {
return
}
db, tenant, err := imtenant.DB(ctx, in.TenantId)
if err != nil {
return
}
mod := s.groupModel(ctx, db)
mod = s.applyGroupWhere(mod, in)
mod = mod.Page(in.Page, in.PerPage).OrderDesc("t.touchedat").OrderDesc("t.id")
if err = mod.ScanAndCount(&list, &totalCount, false); err != nil {
err = gerror.Wrap(err, "获取IM群组列表失败,请稍后重试!")
return
}
for _, item := range list {
item.TenantId = tenant.Id
item.TenantName = tenant.Name
}
return
}
func (s *sSysImGroup) groupModel(ctx context.Context, db gdb.DB) *gdb.Model {
return db.Model("im_topics t").Safe().Ctx(ctx).
LeftJoin("im_group_settings gs", "gs.topic = t.name").
Fields(
"t.name topic",
"t.state",
"COALESCE(gs.owner_user_id, t.owner) owner_id",
"t.seqid seq_id",
"t.delid del_id",
"t.subcnt sub_cnt",
"t.touchedat touched_at",
"t.createdat created_at",
"t.updatedat updated_at",
"t.public public_json",
"t.trusted trusted_json",
"t.tags tags_json",
"t.aux aux_json",
"COALESCE(gs.name, '') group_name",
"COALESCE(gs.avatar_file_id, '') avatar_file_id",
"COALESCE(gs.description, '') description",
"COALESCE(gs.group_status, '') group_status",
"COALESCE(gs.join_approval, 0) join_approval",
"COALESCE(gs.mute_all, 0) mute_all",
"COALESCE(gs.member_upload_files, 0) member_upload_files",
"COALESCE(gs.member_private_chat, 0) member_private_chat",
"COALESCE(gs.member_invite, 0) member_invite",
"COALESCE(gs.max_member_count, 0) max_member_count",
"COALESCE(gs.max_admin_count, 0) max_admin_count",
"COALESCE(gs.member_count, 0) business_member_count",
"COALESCE(gs.version, 0) settings_version",
"gs.updated_at settings_updated_at",
).
WhereLike("t.name", "grp%")
}
func (s *sSysImGroup) applyGroupWhere(mod *gdb.Model, in *sysin.ImGroupListInp) *gdb.Model {
if in.Topic != "" {
mod = mod.WhereLike("t.name", "%"+in.Topic+"%")
}
if in.OwnerId > 0 {
mod = mod.Where("COALESCE(gs.owner_user_id, t.owner)", in.OwnerId)
}
if in.State != nil {
mod = mod.Where("t.state", *in.State)
}
if in.GroupStatus != "" {
mod = mod.Where("gs.group_status", in.GroupStatus)
}
if len(in.CreatedAt) == 2 {
mod = mod.WhereBetween("t.createdat", in.CreatedAt[0], in.CreatedAt[1])
}
if len(in.TouchedAt) == 2 {
mod = mod.WhereBetween("t.touchedat", in.TouchedAt[0], in.TouchedAt[1])
}
return mod
}
// View 获取IM群组指定信息
func (s *sSysImGroup) View(ctx context.Context, in *sysin.ImGroupViewInp) (res *sysin.ImGroupViewModel, err error) {
if err = in.Filter(ctx); err != nil {
return
}
db, tenant, err := imtenant.DB(ctx, in.TenantId)
if err != nil {
return
}
err = db.Model("im_topics t").Safe().Ctx(ctx).
LeftJoin("im_group_settings gs", "gs.topic = t.name").
Fields(
"t.name topic",
"t.state",
"COALESCE(gs.owner_user_id, t.owner) owner_id",
"t.seqid seq_id",
"t.delid del_id",
"t.subcnt sub_cnt",
"t.touchedat touched_at",
"t.createdat created_at",
"t.updatedat updated_at",
"t.public public_json",
"t.trusted trusted_json",
"t.tags tags_json",
"t.aux aux_json",
"t.access access_json",
"t.usebt use_bt",
"COALESCE(gs.name, '') group_name",
"COALESCE(gs.avatar_file_id, '') avatar_file_id",
"COALESCE(gs.description, '') description",
"COALESCE(gs.group_status, '') group_status",
"COALESCE(gs.join_approval, 0) join_approval",
"COALESCE(gs.mute_all, 0) mute_all",
"COALESCE(gs.member_upload_files, 0) member_upload_files",
"COALESCE(gs.member_private_chat, 0) member_private_chat",
"COALESCE(gs.member_invite, 0) member_invite",
"COALESCE(gs.max_member_count, 0) max_member_count",
"COALESCE(gs.max_admin_count, 0) max_admin_count",
"COALESCE(gs.member_count, 0) business_member_count",
"COALESCE(gs.version, 0) settings_version",
"gs.updated_at settings_updated_at",
).
Where("t.name", in.Topic).
WhereLike("t.name", "grp%").
Scan(&res)
if err != nil {
return nil, gerror.Wrap(err, "获取IM群组信息失败,请稍后重试!")
}
if res == nil {
return nil, nil
}
res.TenantId = tenant.Id
res.TenantName = tenant.Name
return
}
// MemberList 获取IM群成员列表
func (s *sSysImGroup) MemberList(ctx context.Context, in *sysin.ImGroupMemberListInp) (list []*sysin.ImGroupMemberListModel, totalCount int, err error) {
if err = in.Filter(ctx); err != nil {
return
}
db, _, err := imtenant.DB(ctx, in.TenantId)
if err != nil {
return
}
if s.hasGroupMemberTruth(ctx, db, in.Topic) {
return s.memberListFromBusiness(ctx, db, in)
}
return s.memberListFromProjection(ctx, db, in)
}
func (s *sSysImGroup) hasGroupMemberTruth(ctx context.Context, db gdb.DB, topic string) bool {
count, err := db.Model("im_group_members").Safe().Ctx(ctx).Where("topic", topic).Count()
return err == nil && count > 0
}
func (s *sSysImGroup) memberListFromBusiness(ctx context.Context, db gdb.DB, in *sysin.ImGroupMemberListInp) (list []*sysin.ImGroupMemberListModel, totalCount int, err error) {
mod := db.Model("im_group_members gm").Safe().Ctx(ctx).
LeftJoin("im_users u", "u.id = gm.user_id").
LeftJoin("im_user_profiles up", "up.user_id = gm.user_id").
LeftJoin("im_subscriptions s", "s.topic = gm.topic AND s.userid = gm.user_id").
Fields(
"gm.user_id",
"up.nickname",
"u.state",
"s.modewant mode_want",
"s.modegiven mode_given",
"s.recvseqid recv_seq_id",
"s.readseqid read_seq_id",
"s.delid del_id",
"s.deletedat deleted_at",
"s.createdat created_at",
"s.updatedat updated_at",
"s.private private_json",
"gm.role",
"gm.member_status",
"gm.nickname group_nickname",
"gm.pinned",
"gm.mute_notifications",
"gm.join_source",
"gm.joined_by",
"gm.joined_at",
"gm.left_at",
"gm.removed_by",
"gm.version",
).
Where("gm.topic", in.Topic)
if in.UserId > 0 {
mod = mod.Where("gm.user_id", in.UserId)
}
if in.Role != "" {
mod = mod.Where("gm.role", in.Role)
}
if in.MemberStatus != "" {
mod = mod.Where("gm.member_status", in.MemberStatus)
}
mod = mod.Page(in.Page, in.PerPage).OrderAsc("gm.member_status").OrderAsc("gm.role").OrderAsc("gm.id")
if err = mod.ScanAndCount(&list, &totalCount, false); err != nil {
err = gerror.Wrap(err, "获取IM群成员列表失败,请稍后重试!")
return
}
return
}
func (s *sSysImGroup) memberListFromProjection(ctx context.Context, db gdb.DB, in *sysin.ImGroupMemberListInp) (list []*sysin.ImGroupMemberListModel, totalCount int, err error) {
mod := db.Model("im_subscriptions s").Safe().Ctx(ctx).
LeftJoin("im_users u", "u.id = s.userid").
LeftJoin("im_user_profiles up", "up.user_id = s.userid").
Fields(
"s.userid user_id",
"up.nickname",
"u.state",
"s.modewant mode_want",
"s.modegiven mode_given",
"s.recvseqid recv_seq_id",
"s.readseqid read_seq_id",
"s.delid del_id",
"s.deletedat deleted_at",
"s.createdat created_at",
"s.updatedat updated_at",
"s.private private_json",
"'member' role",
"IF(s.deletedat IS NULL, 'active', 'left') member_status",
"'' group_nickname",
"0 pinned",
"0 mute_notifications",
"'projection' join_source",
"0 joined_by",
"s.createdat joined_at",
"s.deletedat left_at",
"0 removed_by",
"0 version",
).
Where("s.topic", in.Topic)
if in.UserId > 0 {
mod = mod.Where("s.userid", in.UserId)
}
mod = mod.Page(in.Page, in.PerPage).OrderAsc("s.deletedat").OrderAsc("s.userid")
if err = mod.ScanAndCount(&list, &totalCount, false); err != nil {
err = gerror.Wrap(err, "获取IM群成员列表失败,请稍后重试!")
return
}
return
}
// Governance 获取IM群治理配置
func (s *sSysImGroup) Governance(ctx context.Context, in *sysin.ImGroupTopicInp) (res *sysin.ImGroupGovernanceModel, err error) {
if err = in.Filter(ctx); err != nil {
return
}
db, tenant, err := imtenant.DB(ctx, in.TenantId)
if err != nil {
return nil, err
}
err = db.Model("im_group_settings").Safe().Ctx(ctx).
Fields(
"topic",
"owner_user_id",
"name",
"avatar_file_id",
"description",
"group_status",
"join_approval",
"mute_all",
"member_upload_files",
"member_private_chat",
"member_invite",
"max_member_count",
"max_admin_count",
"member_count",
"version",
"created_by",
"dissolved_by",
"dissolved_at",
"created_at",
"updated_at",
).
Where("topic", in.Topic).
Scan(&res)
if err != nil {
return nil, gerror.Wrap(err, "获取IM群治理配置失败,请稍后重试!")
}
if res == nil {
return nil, nil
}
res.TenantId = tenant.Id
res.TenantName = tenant.Name
return
}
// JoinRequestList 获取IM入群申请列表
func (s *sSysImGroup) JoinRequestList(ctx context.Context, in *sysin.ImGroupJoinRequestListInp) (list []*sysin.ImGroupJoinRequestListModel, totalCount int, err error) {
if err = in.Filter(ctx); err != nil {
return
}
db, _, err := imtenant.DB(ctx, in.TenantId)
if err != nil {
return
}
mod := db.Model("im_group_join_requests").Safe().Ctx(ctx).Where("topic", in.Topic)
if in.Status != "" {
mod = mod.Where("status", in.Status)
}
if in.ApplicantUserId > 0 {
mod = mod.Where("applicant_user_id", in.ApplicantUserId)
}
mod = mod.Page(in.Page, in.PerPage).OrderDesc("id")
if err = mod.ScanAndCount(&list, &totalCount, false); err != nil {
err = gerror.Wrap(err, "获取IM入群申请列表失败,请稍后重试!")
}
return
}
// InvitationList 获取IM群邀请列表
func (s *sSysImGroup) InvitationList(ctx context.Context, in *sysin.ImGroupInvitationListInp) (list []*sysin.ImGroupInvitationListModel, totalCount int, err error) {
if err = in.Filter(ctx); err != nil {
return
}
db, _, err := imtenant.DB(ctx, in.TenantId)
if err != nil {
return
}
mod := db.Model("im_group_invitations").Safe().Ctx(ctx).Where("topic", in.Topic)
if in.Status != "" {
mod = mod.Where("status", in.Status)
}
if in.TargetUserId > 0 {
mod = mod.Where("target_user_id", in.TargetUserId)
}
if in.InviterUserId > 0 {
mod = mod.Where("inviter_user_id", in.InviterUserId)
}
mod = mod.Page(in.Page, in.PerPage).OrderDesc("id")
if err = mod.ScanAndCount(&list, &totalCount, false); err != nil {
err = gerror.Wrap(err, "获取IM群邀请列表失败,请稍后重试!")
}
return
}
// AnnouncementList 获取IM群公告列表
func (s *sSysImGroup) AnnouncementList(ctx context.Context, in *sysin.ImGroupAnnouncementListInp) (list []*sysin.ImGroupAnnouncementListModel, totalCount int, err error) {
if err = in.Filter(ctx); err != nil {
return
}
db, _, err := imtenant.DB(ctx, in.TenantId)
if err != nil {
return
}
mod := db.Model("im_group_announcements").Safe().Ctx(ctx).Where("topic", in.Topic)
if in.Status != "" {
mod = mod.Where("status", in.Status)
}
mod = mod.Page(in.Page, in.PerPage).OrderDesc("published_at").OrderDesc("id")
if err = mod.ScanAndCount(&list, &totalCount, false); err != nil {
err = gerror.Wrap(err, "获取IM群公告列表失败,请稍后重试!")
}
return
}
// MuteList 获取IM群禁言列表
func (s *sSysImGroup) MuteList(ctx context.Context, in *sysin.ImGroupMuteListInp) (list []*sysin.ImGroupMuteListModel, totalCount int, err error) {
if err = in.Filter(ctx); err != nil {
return
}
db, _, err := imtenant.DB(ctx, in.TenantId)
if err != nil {
return
}
mod := db.Model("im_group_mutes").Safe().Ctx(ctx).Where("topic", in.Topic)
if in.Status != "" {
mod = mod.Where("status", in.Status)
}
if in.UserId > 0 {
mod = mod.Where("user_id", in.UserId)
}
mod = mod.Page(in.Page, in.PerPage).OrderDesc("muted_at").OrderDesc("user_id")
if err = mod.ScanAndCount(&list, &totalCount, false); err != nil {
err = gerror.Wrap(err, "获取IM群禁言列表失败,请稍后重试!")
}
return
}
// AdminLogList 获取IM群管理日志列表
func (s *sSysImGroup) AdminLogList(ctx context.Context, in *sysin.ImGroupAdminLogListInp) (list []*sysin.ImGroupAdminLogListModel, totalCount int, err error) {
if err = in.Filter(ctx); err != nil {
return
}
db, _, err := imtenant.DB(ctx, in.TenantId)
if err != nil {
return
}
mod := db.Model("im_group_admin_logs").Safe().Ctx(ctx).
Fields(
"id",
"log_id",
"topic",
"operator_id",
"target_user_id",
"action",
"object_type",
"object_id",
"CAST(before_data AS CHAR) before_data",
"CAST(after_data AS CHAR) after_data",
"request_id",
"idempotency_key",
"created_at",
).
Where("topic", in.Topic)
if in.Action != "" {
mod = mod.WhereLike("action", "%"+in.Action+"%")
}
if in.OperatorId > 0 {
mod = mod.Where("operator_id", in.OperatorId)
}
mod = mod.Page(in.Page, in.PerPage).OrderDesc("id")
if err = mod.ScanAndCount(&list, &totalCount, false); err != nil {
err = gerror.Wrap(err, "获取IM群管理日志列表失败,请稍后重试!")
}
return
}
+572
View File
@@ -0,0 +1,572 @@
package sys
import (
"context"
cryptorand "crypto/rand"
"encoding/csv"
"errors"
"fmt"
"hotgo/internal/library/imtenant"
"hotgo/internal/model/input/sysin"
"hotgo/internal/service"
"strconv"
"strings"
"time"
mysqldriver "github.com/go-sql-driver/mysql"
"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/net/ghttp"
"github.com/gogf/gf/v2/os/gtime"
)
const inviteGenerateRetries = 10
var errInviteBatchNotFound = errors.New("invite batch not found")
type sSysImInvite struct{}
func NewSysImInvite() *sSysImInvite { return &sSysImInvite{} }
func init() { service.RegisterSysImInvite(NewSysImInvite()) }
type imInviteListRequest struct {
TenantID int64
InviteCode string
Status string
BatchID int64
CreatedBy int64
CreatedAtStart *time.Time
CreatedAtEnd *time.Time
ExpiresAtStart *time.Time
ExpiresAtEnd *time.Time
}
type imInviteCodeResponse struct {
ID int64 `json:"id"`
TenantID int64 `json:"tenant_id"`
TenantName string `json:"tenant_name"`
BatchID *int64 `json:"batch_id"`
Code string `json:"code"`
Status string `json:"status"`
ExpiresAt time.Time `json:"expires_at"`
UsageCount int `json:"usage_count"`
LastUsedAt *time.Time `json:"last_used_at"`
CreatedBy int64 `json:"created_by"`
Remark string `json:"remark"`
CreatedAt time.Time `json:"created_at"`
}
type imInviteBatchResponse struct {
BatchID int64 `json:"batch_id"`
TenantID int64 `json:"tenant_id"`
Quantity int `json:"quantity"`
IdempotencyKey string `json:"idempotency_key"`
Remark string `json:"remark"`
ActiveCount int `json:"active_count"`
UsageCount int `json:"usage_count"`
RevokedCount int `json:"revoked_count"`
ExpiredCount int `json:"expired_count"`
ExpiresAt time.Time `json:"expires_at"`
CreatedAt time.Time `json:"created_at"`
}
func (s *sSysImInvite) List(ctx context.Context, in *sysin.ImInviteListInp) (list []*sysin.ImInviteCodeModel, totalCount int, err error) {
if err = in.Filter(ctx); err != nil {
return
}
scope, db, err := inviteQueryDB(ctx, in.TenantId)
if err != nil {
return nil, 0, err
}
req, err := buildInviteListRequest(in)
if err != nil {
return nil, 0, err
}
if !validInviteStatus(req.Status) {
return nil, 0, gerror.New("邀请码状态不正确")
}
req.TenantID = scope.TenantId
items, total, err := queryInviteCodes(ctx, db, req, in.PerPage, (in.Page-1)*in.PerPage)
result := "success"
if err != nil {
result = "failed"
} else {
list = convertInviteCodes(items)
totalCount = total
}
_ = writeImAdminAudit(ctx, scope.TenantId, "invite_list", "invite_code", 0, result, g.Map{"hasInviteCode": in.InviteCode != ""})
return list, totalCount, err
}
func (s *sSysImInvite) Create(ctx context.Context, in *sysin.ImInviteCreateInp) (res *sysin.ImInviteCodeModel, err error) {
if err = in.Filter(ctx); err != nil {
return
}
scope, db, err := inviteDB(ctx, in.TenantId)
if err != nil {
return nil, err
}
expiresAt := in.ExpiresAt.Time.UTC().Round(time.Millisecond)
var item imInviteCodeResponse
if key := strings.TrimSpace(in.IdempotencyKey); key != "" {
batch, codes, createErr := createInviteBatch(ctx, db, scope.TenantId, scope.OperatorId, 1, expiresAt, in.Remark, key)
if createErr != nil {
err = createErr
} else if len(codes) > 0 {
item = codes[0]
} else {
codes, _, err = queryInviteCodes(ctx, db, imInviteListRequest{TenantID: scope.TenantId, BatchID: batch.BatchID}, 1, 0)
if err == nil && len(codes) > 0 {
item = codes[0]
} else if err == nil {
err = gerror.New("邀请码生成结果不存在")
}
}
} else {
item, err = createSingleInvite(ctx, db, scope.TenantId, scope.OperatorId, expiresAt, in.Remark)
}
result := "success"
if err != nil {
result = "failed"
} else {
res = convertInviteCode(item)
}
_ = writeImAdminAudit(ctx, scope.TenantId, "invite_create", "invite_code", item.ID, result, g.Map{"expiresAt": in.ExpiresAt, "remark": in.Remark})
return res, err
}
func (s *sSysImInvite) BatchCreate(ctx context.Context, in *sysin.ImInviteBatchCreateInp) (res *sysin.ImInviteBatchModel, err error) {
if err = in.Filter(ctx); err != nil {
return
}
scope, db, err := inviteDB(ctx, in.TenantId)
if err != nil {
return nil, err
}
key := strings.TrimSpace(in.IdempotencyKey)
batch, _, err := createInviteBatch(ctx, db, scope.TenantId, scope.OperatorId, in.Quantity, in.ExpiresAt.Time.UTC().Round(time.Millisecond), in.Remark, key)
result := "success"
if err != nil {
result = "failed"
} else {
res = convertInviteBatch(batch)
}
_ = writeImAdminAudit(ctx, scope.TenantId, "invite_batch_create", "invite_batch", batchID(batch), result, g.Map{"quantity": in.Quantity, "expiresAt": in.ExpiresAt, "remark": in.Remark})
return res, err
}
func (s *sSysImInvite) BatchView(ctx context.Context, in *sysin.ImInviteBatchViewInp) (res *sysin.ImInviteBatchDetailModel, err error) {
scope, db, err := inviteDB(ctx, in.TenantId)
if err != nil {
return nil, err
}
id, err := sysin.ParseOptionalInt64(in.BatchId)
if err != nil {
return nil, err
}
batch, err := getInviteBatch(ctx, db, scope.TenantId, "b.id=?", id)
if err != nil {
return nil, err
}
codes, _, err := queryInviteCodes(ctx, db, imInviteListRequest{TenantID: scope.TenantId, BatchID: id}, batch.Quantity, 0)
if err != nil {
return nil, err
}
return &sysin.ImInviteBatchDetailModel{ImInviteBatchModel: *convertInviteBatch(batch), List: convertInviteCodes(codes)}, nil
}
func (s *sSysImInvite) Revoke(ctx context.Context, in *sysin.ImInviteRevokeInp) (err error) {
scope, db, err := inviteDB(ctx, in.TenantId)
if err != nil {
return err
}
id, err := sysin.ParseOptionalInt64(in.Id)
if err != nil {
return err
}
now := time.Now().UTC().Round(time.Millisecond)
result, err := db.Exec(ctx, `UPDATE im_registration_invite_codes SET status='revoked',updated_at=?
WHERE tenant_id=? AND id=? AND status='active' AND expires_at>?`, now, scope.TenantId, id, now)
if err == nil {
if affected, _ := result.RowsAffected(); affected == 0 {
err = gerror.New("有效邀请码不存在")
}
}
auditResult := "success"
if err != nil {
auditResult = "failed"
}
_ = writeImAdminAudit(ctx, scope.TenantId, "invite_revoke", "invite_code", id, auditResult, nil)
return err
}
func (s *sSysImInvite) BatchRevoke(ctx context.Context, in *sysin.ImInviteBatchRevokeInp) (res *sysin.ImInviteBatchModel, err error) {
scope, db, err := inviteDB(ctx, in.TenantId)
if err != nil {
return nil, err
}
id, err := sysin.ParseOptionalInt64(in.BatchId)
if err != nil {
return nil, err
}
if _, err = getInviteBatch(ctx, db, scope.TenantId, "b.id=?", id); err == nil {
now := time.Now().UTC().Round(time.Millisecond)
_, err = db.Exec(ctx, `UPDATE im_registration_invite_codes SET status='revoked',updated_at=?
WHERE tenant_id=? AND batch_id=? AND status='active' AND expires_at>?`, now, scope.TenantId, id, now)
}
var batch *imInviteBatchResponse
if err == nil {
batch, err = getInviteBatch(ctx, db, scope.TenantId, "b.id=?", id)
}
auditResult := "success"
if err != nil {
auditResult = "failed"
} else {
res = convertInviteBatch(batch)
}
_ = writeImAdminAudit(ctx, scope.TenantId, "invite_batch_revoke", "invite_batch", id, auditResult, nil)
return res, err
}
func (s *sSysImInvite) Export(ctx context.Context, in *sysin.ImInviteListInp) (err error) {
if err = in.Filter(ctx); err != nil {
return err
}
scope, db, err := inviteQueryDB(ctx, in.TenantId)
if err != nil {
return err
}
req, err := buildInviteListRequest(in)
if err != nil || !validInviteStatus(req.Status) {
return gerror.New("导出条件不正确")
}
req.TenantID = scope.TenantId
request := ghttp.RequestFromCtx(ctx)
if request == nil {
return gerror.New("请求上下文不存在")
}
request.Response.Header().Set("Content-Type", "text/csv; charset=utf-8")
request.Response.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=registration-invite-codes-%s.csv", time.Now().Format("20060102-150405")))
_, _ = request.Response.Writer.Write([]byte{0xef, 0xbb, 0xbf})
writer := csv.NewWriter(request.Response.Writer)
_ = writer.Write([]string{"tenant_id", "tenant_name", "code", "status", "batch_id", "expires_at", "usage_count", "last_used_at", "created_by", "created_at", "remark"})
for offset := 0; ; offset += 100 {
items, total, queryErr := queryInviteCodes(ctx, db, req, 100, offset)
if queryErr != nil {
return gerror.Wrap(queryErr, "导出邀请码失败")
}
for _, item := range items {
_ = writer.Write([]string{strconv.FormatInt(item.TenantID, 10), item.TenantName, item.Code, item.Status, formatOptionalInt64(item.BatchID), item.ExpiresAt.Format(time.RFC3339), strconv.Itoa(item.UsageCount), formatTime(item.LastUsedAt), strconv.FormatInt(item.CreatedBy, 10), item.CreatedAt.Format(time.RFC3339), item.Remark})
}
writer.Flush()
if writer.Error() != nil {
return writer.Error()
}
if offset+len(items) >= total || len(items) == 0 {
break
}
}
_ = writeImAdminAudit(ctx, scope.TenantId, "invite_export", "invite_code", 0, "success", g.Map{"hasInviteCode": in.InviteCode != ""})
return nil
}
func inviteDB(ctx context.Context, selectedTenantID int64) (imAdminScope, gdb.DB, error) {
scope, err := resolveImAdminScope(ctx, selectedTenantID)
if err != nil {
return scope, nil, err
}
return scope, imtenant.RegistryDB(), nil
}
func inviteQueryDB(ctx context.Context, selectedTenantID int64) (imAdminScope, gdb.DB, error) {
scope, err := resolveImAdminScopeWithAll(ctx, selectedTenantID, true)
if err != nil {
return scope, nil, err
}
return scope, imtenant.RegistryDB(), nil
}
func createSingleInvite(ctx context.Context, db gdb.DB, tenantID, operatorID int64, expiresAt time.Time, remark string) (imInviteCodeResponse, error) {
for attempt := 0; attempt < inviteGenerateRetries; attempt++ {
id, err := imtenant.NextSnowflakeID(ctx)
if err != nil {
return imInviteCodeResponse{}, err
}
code, err := randomInviteCode()
if err != nil {
return imInviteCodeResponse{}, err
}
now := time.Now().UTC().Round(time.Millisecond)
_, err = db.Exec(ctx, `INSERT INTO im_registration_invite_codes
(id,tenant_id,batch_id,code,status,expires_at,usage_count,last_used_at,created_by,remark,created_at,updated_at)
VALUES(?,?,NULL,?,'active',?,0,NULL,?,?,?,?)`, id, tenantID, code, expiresAt, operatorID, remark, now, now)
if err == nil {
return imInviteCodeResponse{ID: id, TenantID: tenantID, Code: code, Status: "active", ExpiresAt: expiresAt, CreatedBy: operatorID, Remark: remark, CreatedAt: now}, nil
}
if !isDuplicateError(err) {
return imInviteCodeResponse{}, err
}
}
return imInviteCodeResponse{}, gerror.New("无法生成不重复的邀请码,请重试")
}
func createInviteBatch(ctx context.Context, db gdb.DB, tenantID, operatorID int64, quantity int, expiresAt time.Time, remark, key string) (*imInviteBatchResponse, []imInviteCodeResponse, error) {
if existing, err := getInviteBatch(ctx, db, tenantID, "b.idempotency_key=?", key); err == nil {
if !sameInviteBatch(existing, quantity, expiresAt, remark) {
return nil, nil, gerror.New("幂等键已被其他批量生成请求使用")
}
return existing, nil, nil
} else if !errors.Is(err, errInviteBatchNotFound) {
return nil, nil, err
}
for attempt := 0; attempt < inviteGenerateRetries; attempt++ {
batchID, err := imtenant.NextSnowflakeID(ctx)
if err != nil {
return nil, nil, err
}
codes := make([]imInviteCodeResponse, quantity)
seen := make(map[string]struct{}, quantity)
for i := range codes {
for {
code, randomErr := randomInviteCode()
if randomErr != nil {
return nil, nil, randomErr
}
if _, ok := seen[code]; ok {
continue
}
seen[code] = struct{}{}
codes[i].Code = code
break
}
codes[i].ID, err = imtenant.NextSnowflakeID(ctx)
if err != nil {
return nil, nil, err
}
codes[i].BatchID = &batchID
}
now := time.Now().UTC().Round(time.Millisecond)
err = db.Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
if _, txErr := tx.Exec(`INSERT INTO im_registration_invite_batches
(id,tenant_id,quantity,idempotency_key,expires_at,created_by,remark,created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?,?)`, batchID, tenantID, quantity, key, expiresAt, operatorID, remark, now, now); txErr != nil {
return txErr
}
for i := range codes {
if _, txErr := tx.Exec(`INSERT INTO im_registration_invite_codes
(id,tenant_id,batch_id,code,status,expires_at,usage_count,last_used_at,created_by,remark,created_at,updated_at)
VALUES(?,?,?,?,'active',?,0,NULL,?,?,?,?)`, codes[i].ID, tenantID, batchID, codes[i].Code, expiresAt, operatorID, remark, now, now); txErr != nil {
return txErr
}
codes[i].TenantID, codes[i].Status, codes[i].ExpiresAt, codes[i].CreatedBy, codes[i].Remark, codes[i].CreatedAt = tenantID, "active", expiresAt, operatorID, remark, now
}
return nil
})
if err == nil {
return &imInviteBatchResponse{BatchID: batchID, TenantID: tenantID, Quantity: quantity, IdempotencyKey: key, Remark: remark, ActiveCount: quantity, ExpiresAt: expiresAt, CreatedAt: now}, codes, nil
}
if !isDuplicateError(err) {
return nil, nil, err
}
if existing, lookupErr := getInviteBatch(ctx, db, tenantID, "b.idempotency_key=?", key); lookupErr == nil {
if !sameInviteBatch(existing, quantity, expiresAt, remark) {
return nil, nil, gerror.New("幂等键已被其他批量生成请求使用")
}
return existing, nil, nil
} else if !errors.Is(lookupErr, errInviteBatchNotFound) {
return nil, nil, lookupErr
}
}
return nil, nil, gerror.New("无法生成不重复的邀请码,请重试")
}
func queryInviteCodes(ctx context.Context, db gdb.DB, req imInviteListRequest, limit, offset int) ([]imInviteCodeResponse, int, error) {
now := time.Now().UTC().Round(time.Millisecond)
expireWhere := "status='active' AND expires_at<=?"
expireArgs := []any{now, now}
if req.TenantID > 0 {
expireWhere = "tenant_id=? AND " + expireWhere
expireArgs = []any{now, req.TenantID, now}
}
if _, err := db.Exec(ctx, `UPDATE im_registration_invite_codes SET status='expired',updated_at=? WHERE `+expireWhere, expireArgs...); err != nil {
return nil, 0, err
}
where, args := inviteWhere(req)
totalValue, err := db.GetValue(ctx, "SELECT COUNT(*) FROM im_registration_invite_codes c WHERE "+where, args...)
if err != nil {
return nil, 0, err
}
queryArgs := append(append([]any{}, args...), limit, offset)
items := make([]imInviteCodeResponse, 0)
err = db.Raw(`SELECT c.id,c.tenant_id,t.name tenant_name,c.batch_id,c.code,c.status,c.expires_at,c.created_by,c.remark,c.created_at,
c.usage_count,c.last_used_at
FROM im_registration_invite_codes c
LEFT JOIN im_tenant t ON t.id=c.tenant_id
WHERE `+where+` ORDER BY c.created_at DESC,c.id DESC LIMIT ? OFFSET ?`, queryArgs...).Ctx(ctx).Scan(&items)
return items, totalValue.Int(), err
}
func inviteWhere(req imInviteListRequest) (string, []any) {
where := []string{"1=1"}
args := make([]any, 0, 8)
if req.TenantID > 0 {
where, args = append(where, "c.tenant_id=?"), append(args, req.TenantID)
}
if req.InviteCode != "" {
where, args = append(where, "c.code=?"), append(args, req.InviteCode)
}
if req.Status != "" {
where, args = append(where, "c.status=?"), append(args, req.Status)
}
if req.BatchID > 0 {
where, args = append(where, "c.batch_id=?"), append(args, req.BatchID)
}
if req.CreatedBy > 0 {
where, args = append(where, "c.created_by=?"), append(args, req.CreatedBy)
}
if req.CreatedAtStart != nil {
where, args = append(where, "c.created_at>=?"), append(args, *req.CreatedAtStart)
}
if req.CreatedAtEnd != nil {
where, args = append(where, "c.created_at<=?"), append(args, *req.CreatedAtEnd)
}
if req.ExpiresAtStart != nil {
where, args = append(where, "c.expires_at>=?"), append(args, *req.ExpiresAtStart)
}
if req.ExpiresAtEnd != nil {
where, args = append(where, "c.expires_at<=?"), append(args, *req.ExpiresAtEnd)
}
return strings.Join(where, " AND "), args
}
func getInviteBatch(ctx context.Context, db gdb.DB, tenantID int64, where string, arg any) (*imInviteBatchResponse, error) {
items := make([]imInviteBatchResponse, 0, 1)
now := time.Now().UTC().Round(time.Millisecond)
err := db.Raw(`SELECT b.id batch_id,b.tenant_id,b.quantity,b.idempotency_key,b.expires_at,b.remark,b.created_at,
COALESCE(SUM(CASE WHEN c.status='active' AND c.expires_at>? THEN 1 ELSE 0 END),0) active_count,
COALESCE(SUM(c.usage_count),0) usage_count,
COALESCE(SUM(CASE WHEN (c.status='active' AND c.expires_at<=?) OR c.status='expired' THEN 1 ELSE 0 END),0) expired_count,
COALESCE(SUM(CASE WHEN c.status='revoked' THEN 1 ELSE 0 END),0) revoked_count
FROM im_registration_invite_batches b LEFT JOIN im_registration_invite_codes c ON c.tenant_id=b.tenant_id AND c.batch_id=b.id
WHERE b.tenant_id=? AND `+where+` GROUP BY b.id LIMIT 1`, now, now, tenantID, arg).Ctx(ctx).Scan(&items)
if err != nil {
return nil, err
}
if len(items) == 0 {
return nil, gerror.Wrap(errInviteBatchNotFound, "邀请码批次不存在")
}
return &items[0], nil
}
func buildInviteListRequest(in *sysin.ImInviteListInp) (imInviteListRequest, error) {
batchID, err := sysin.ParseOptionalInt64(in.BatchId)
if err != nil {
return imInviteListRequest{}, err
}
createdBy, err := sysin.ParseOptionalInt64(in.CreatedBy)
if err != nil {
return imInviteListRequest{}, err
}
return imInviteListRequest{InviteCode: in.InviteCode, Status: in.Status, BatchID: batchID, CreatedBy: createdBy,
CreatedAtStart: nativeTime(in.CreatedAtStart), CreatedAtEnd: nativeTime(in.CreatedAtEnd),
ExpiresAtStart: nativeTime(in.ExpiresAtStart), ExpiresAtEnd: nativeTime(in.ExpiresAtEnd)}, nil
}
func randomInviteCode() (string, error) {
const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
result := make([]byte, 5)
buffer := make([]byte, 10)
for index := 0; index < len(result); {
if _, err := cryptorand.Read(buffer); err != nil {
return "", err
}
for _, value := range buffer {
if value >= 208 {
continue
}
result[index] = alphabet[int(value)%len(alphabet)]
index++
if index == len(result) {
break
}
}
}
return string(result), nil
}
func isDuplicateError(err error) bool {
var mysqlErr *mysqldriver.MySQLError
return errors.As(err, &mysqlErr) && mysqlErr.Number == 1062
}
func validInviteStatus(status string) bool {
return status == "" || status == "active" || status == "expired" || status == "revoked"
}
func sameInviteBatch(batch *imInviteBatchResponse, quantity int, expiresAt time.Time, remark string) bool {
return batch != nil && batch.Quantity == quantity &&
batch.ExpiresAt.UTC().Round(time.Millisecond).Equal(expiresAt.UTC().Round(time.Millisecond)) && batch.Remark == remark
}
func convertInviteCodes(items []imInviteCodeResponse) []*sysin.ImInviteCodeModel {
list := make([]*sysin.ImInviteCodeModel, 0, len(items))
for _, item := range items {
list = append(list, convertInviteCode(item))
}
return list
}
func convertInviteCode(item imInviteCodeResponse) *sysin.ImInviteCodeModel {
return &sysin.ImInviteCodeModel{Id: strconv.FormatInt(item.ID, 10), BatchId: formatOptionalInt64(item.BatchID), Code: item.Code,
TenantId: strconv.FormatInt(item.TenantID, 10), TenantName: item.TenantName,
Status: item.Status, ExpiresAt: gtime.New(item.ExpiresAt), UsageCount: item.UsageCount, LastUsedAt: convertTime(item.LastUsedAt),
CreatedBy: strconv.FormatInt(item.CreatedBy, 10), Remark: item.Remark, CreatedAt: gtime.New(item.CreatedAt)}
}
func convertInviteBatch(item *imInviteBatchResponse) *sysin.ImInviteBatchModel {
if item == nil {
return nil
}
return &sysin.ImInviteBatchModel{BatchId: strconv.FormatInt(item.BatchID, 10), Quantity: item.Quantity, ActiveCount: item.ActiveCount,
TenantId: strconv.FormatInt(item.TenantID, 10),
UsageCount: item.UsageCount, RevokedCount: item.RevokedCount, ExpiredCount: item.ExpiredCount,
ExpiresAt: gtime.New(item.ExpiresAt), CreatedAt: gtime.New(item.CreatedAt)}
}
func nativeTime(value *gtime.Time) *time.Time {
if value == nil {
return nil
}
t := value.Time.UTC()
return &t
}
func convertTime(value *time.Time) *gtime.Time {
if value == nil {
return nil
}
return gtime.New(value)
}
func formatOptionalInt64(value *int64) string {
if value == nil || *value == 0 {
return ""
}
return strconv.FormatInt(*value, 10)
}
func formatTime(value *time.Time) string {
if value == nil {
return ""
}
return value.Format(time.RFC3339)
}
func batchID(batch *imInviteBatchResponse) int64 {
if batch == nil {
return 0
}
return batch.BatchID
}
+124
View File
@@ -0,0 +1,124 @@
package sys
import (
"context"
"os"
"regexp"
"testing"
"time"
"hotgo/internal/library/imtenant"
_ "github.com/gogf/gf/contrib/drivers/mysql/v2"
"github.com/gogf/gf/v2/database/gdb"
"golang.org/x/crypto/bcrypt"
)
func TestRandomInviteCode(t *testing.T) {
pattern := regexp.MustCompile(`^[A-Za-z]{5}$`)
for i := 0; i < 100; i++ {
code, err := randomInviteCode()
if err != nil {
t.Fatal(err)
}
if !pattern.MatchString(code) {
t.Fatalf("invalid invite code %q", code)
}
}
}
func TestInviteDirectDatabase(t *testing.T) {
link := os.Getenv("IM_MANAGER_INTEGRATION_DSN")
if link == "" {
t.Skip("IM_MANAGER_INTEGRATION_DSN is not set")
}
const group = "im_invite_integration"
if err := gdb.SetConfigGroup(group, gdb.ConfigGroup{{Link: link}}); err != nil {
t.Fatal(err)
}
db, err := gdb.Instance(group)
if err != nil {
t.Fatal(err)
}
ctx := context.Background()
key := "integration-" + time.Now().UTC().Format("20060102150405.000000000")
expiresAt := time.Now().UTC().Add(time.Hour).Round(time.Millisecond)
batch, codes, err := createInviteBatch(ctx, db, 1, 1, 3, expiresAt, "integration", key)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
_, _ = db.Exec(ctx, "DELETE FROM im_registration_invite_codes WHERE tenant_id=? AND batch_id=?", 1, batch.BatchID)
_, _ = db.Exec(ctx, "DELETE FROM im_registration_invite_batches WHERE tenant_id=? AND id=?", 1, batch.BatchID)
})
if batch.ActiveCount != 3 || len(codes) != 3 {
t.Fatalf("unexpected batch result: %#v codes=%d", batch, len(codes))
}
replayed, replayCodes, err := createInviteBatch(ctx, db, 1, 1, 3, expiresAt, "integration", key)
if err != nil {
t.Fatal(err)
}
if replayed.BatchID != batch.BatchID || len(replayCodes) != 0 {
t.Fatalf("idempotent replay created a new batch: %#v", replayed)
}
items, total, err := queryInviteCodes(ctx, db, imInviteListRequest{TenantID: 1, BatchID: batch.BatchID}, 10, 0)
if err != nil {
t.Fatal(err)
}
if total != 3 || len(items) != 3 {
t.Fatalf("unexpected query result total=%d items=%d", total, len(items))
}
}
func TestResetPasswordDirectDatabase(t *testing.T) {
link := os.Getenv("IM_MANAGER_INTEGRATION_DSN")
if link == "" {
t.Skip("IM_MANAGER_INTEGRATION_DSN is not set")
}
const group = "im_password_reset_integration"
if err := gdb.SetConfigGroup(group, gdb.ConfigGroup{{Link: link}}); err != nil {
t.Fatal(err)
}
db, err := gdb.Instance(group)
if err != nil {
t.Fatal(err)
}
ctx := context.Background()
userID, err := imtenant.NextSnowflakeID(ctx)
if err != nil {
t.Fatal(err)
}
now := time.Now().UTC().Round(time.Millisecond)
username := "integration" + time.Now().UTC().Format("150405000000")
if _, err = db.Exec(ctx, `INSERT INTO im_users(id,username,createdat,updatedat,state,useragent)
VALUES(?,?,?,?,0,'integration-test')`, userID, username, now, now); err != nil {
t.Fatal(err)
}
if _, err = db.Exec(ctx, "INSERT INTO im_auth(userid,authlvl,secret) VALUES(?,20,'old')", userID); err != nil {
_, _ = db.Exec(ctx, "DELETE FROM im_users WHERE id=?", userID)
t.Fatal(err)
}
t.Cleanup(func() {
_, _ = db.Exec(ctx, "DELETE FROM im_business_events_outbox WHERE target=? AND event_type='security.password_reset'", userID)
_, _ = db.Exec(ctx, "DELETE FROM im_user_security WHERE user_id=?", userID)
_, _ = db.Exec(ctx, "DELETE FROM im_auth WHERE userid=?", userID)
_, _ = db.Exec(ctx, "DELETE FROM im_users WHERE id=?", userID)
})
scope := imAdminScope{TenantId: 1, OperatorId: 99, RequestId: "integration-password-reset"}
if err = resetIMUserPassword(ctx, db, scope, userID, "new-password"); err != nil {
t.Fatal(err)
}
secret, err := db.GetValue(ctx, "SELECT secret FROM im_auth WHERE userid=?", userID)
if err != nil || bcrypt.CompareHashAndPassword(secret.Bytes(), []byte("new-password")) != nil {
t.Fatalf("password hash was not updated: %v", err)
}
version, err := db.GetValue(ctx, "SELECT auth_version FROM im_user_security WHERE user_id=?", userID)
if err != nil || version.Int() != 2 {
t.Fatalf("unexpected auth version %d: %v", version.Int(), err)
}
events, err := db.GetValue(ctx, `SELECT COUNT(*) FROM im_business_events_outbox
WHERE target=? AND event_type='security.password_reset' AND status='pending'`, userID)
if err != nil || events.Int() != 1 {
t.Fatalf("password reset event not written: count=%d err=%v", events.Int(), err)
}
}
+164
View File
@@ -0,0 +1,164 @@
// Package sys
// @Link https://github.com/bufanyun/hotgo
// @Copyright Copyright (c) 2026 HotGo
// @Author Ms <133814250@qq.com>
// @License https://github.com/bufanyun/hotgo/blob/master/LICENSE
package sys
import (
"context"
"hotgo/internal/consts"
"hotgo/internal/library/imtenant"
"hotgo/internal/model/input/sysin"
"hotgo/internal/service"
"github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/errors/gerror"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/os/gtime"
)
type sSysImRealname struct{}
func NewSysImRealname() *sSysImRealname {
return &sSysImRealname{}
}
func init() {
service.RegisterSysImRealname(NewSysImRealname())
}
// List 获取IM实名审核列表
func (s *sSysImRealname) List(ctx context.Context, in *sysin.ImRealnameListInp) (list []*sysin.ImRealnameListModel, totalCount int, err error) {
if err = in.Filter(ctx); err != nil {
return
}
db, tenant, err := imtenant.DB(ctx, in.TenantId)
if err != nil {
return
}
mod := s.model(ctx, db)
mod = s.applyWhere(mod, in)
mod = mod.Page(in.Page, in.PerPage).OrderDesc("id")
if err = mod.ScanAndCount(&list, &totalCount, false); err != nil {
err = gerror.Wrap(err, "获取IM实名审核列表失败,请稍后重试!")
return
}
for _, item := range list {
item.TenantId = tenant.Id
item.TenantName = tenant.Name
}
return
}
func (s *sSysImRealname) model(ctx context.Context, db gdb.DB) *gdb.Model {
return db.Model("im_realname_verifications").Safe().Ctx(ctx).
Fields(
"id",
"user_id",
"submission_id",
"status",
"id_type",
"masked_name",
"masked_id_number",
"review_reason",
"verified_at",
"created_at",
"updated_at",
)
}
func (s *sSysImRealname) applyWhere(mod *gdb.Model, in *sysin.ImRealnameListInp) *gdb.Model {
if in.UserId > 0 {
mod = mod.Where("user_id", in.UserId)
}
if in.SubmissionId != "" {
mod = mod.WhereLike("submission_id", "%"+in.SubmissionId+"%")
}
if in.Status != "" {
mod = mod.Where("status", in.Status)
}
if len(in.CreatedAt) == 2 {
mod = mod.WhereBetween("created_at", in.CreatedAt[0], in.CreatedAt[1])
}
return mod
}
// View 获取IM实名审核指定信息
func (s *sSysImRealname) View(ctx context.Context, in *sysin.ImRealnameViewInp) (res *sysin.ImRealnameViewModel, err error) {
if err = in.Filter(ctx); err != nil {
return
}
db, tenant, err := imtenant.DB(ctx, in.TenantId)
if err != nil {
return nil, err
}
err = db.Model("im_realname_verifications").Safe().Ctx(ctx).
Fields(
"id",
"user_id",
"submission_id",
"status",
"id_type",
"masked_name",
"masked_id_number",
"id_number_hash",
"review_reason",
"verified_at",
"created_at",
"updated_at",
).
Where("id", in.Id).
Scan(&res)
if err != nil {
return nil, gerror.Wrap(err, "获取IM实名审核信息失败,请稍后重试!")
}
if res == nil {
return nil, nil
}
res.TenantId = tenant.Id
res.TenantName = tenant.Name
return
}
// Review 审核IM实名
func (s *sSysImRealname) Review(ctx context.Context, in *sysin.ImRealnameReviewInp) (err error) {
if err = in.Filter(ctx); err != nil {
return
}
db, _, err := imtenant.DB(ctx, in.TenantId)
if err != nil {
return
}
data := g.Map{
"status": in.Status,
"review_reason": in.ReviewReason,
"updated_at": gtime.Now(),
}
if in.Status == consts.ImRealnameStatusVerified {
data["verified_at"] = gtime.Now()
} else {
data["verified_at"] = nil
}
result, err := db.Model("im_realname_verifications").Safe().Ctx(ctx).
Where("id", in.Id).
Where("status", consts.ImRealnameStatusPending).
Data(data).
Update()
if err != nil {
return gerror.Wrap(err, "审核IM实名失败,请稍后重试!")
}
affected, err := result.RowsAffected()
if err != nil {
return gerror.Wrap(err, "读取审核结果失败,请稍后重试!")
}
if affected == 0 {
return gerror.New("当前记录不是待审核状态,不能重复审核")
}
return
}
@@ -0,0 +1,116 @@
package sys
import (
"context"
"hotgo/internal/dao"
"hotgo/internal/library/contexts"
"hotgo/internal/model/input/sysin"
"hotgo/internal/service"
"github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/errors/gerror"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/os/gtime"
)
const (
imRegistrationConfigGroup = "im"
imRegistrationSmsKey = "register_sms_enabled"
)
type sSysImRegistration struct{}
func NewSysImRegistration() *sSysImRegistration {
return &sSysImRegistration{}
}
func init() {
service.RegisterSysImRegistration(NewSysImRegistration())
}
func (s *sSysImRegistration) GetConfig(ctx context.Context) (res *sysin.ImRegistrationConfigModel, err error) {
if err = s.verifySuper(ctx); err != nil {
return
}
return s.getConfig(ctx)
}
func (s *sSysImRegistration) UpdateConfig(ctx context.Context, in *sysin.ImRegistrationConfigUpdateInp) (err error) {
if err = s.verifySuper(ctx); err != nil {
return
}
old, err := s.getConfig(ctx)
if err != nil {
return
}
value := "0"
if in.RegisterSmsEnabled {
value = "1"
}
_, err = dao.SysConfig.Ctx(ctx).
Where(dao.SysConfig.Columns().Group, imRegistrationConfigGroup).
Where(dao.SysConfig.Columns().Key, imRegistrationSmsKey).
Data(g.Map{
dao.SysConfig.Columns().Value: value,
dao.SysConfig.Columns().UpdatedAt: gtime.Now(),
}).
Update()
if err != nil {
return gerror.Wrap(err, "更新IM注册短信开关失败")
}
_ = writeImAdminAudit(ctx, 0, "registration_sms_config_update", "config", 0, "success", g.Map{
"old": old.RegisterSmsEnabled,
"new": in.RegisterSmsEnabled,
})
return
}
func (s *sSysImRegistration) getConfig(ctx context.Context) (res *sysin.ImRegistrationConfigModel, err error) {
var record gdb.Record
record, err = dao.SysConfig.Ctx(ctx).
Where(dao.SysConfig.Columns().Group, imRegistrationConfigGroup).
Where(dao.SysConfig.Columns().Key, imRegistrationSmsKey).
Where(dao.SysConfig.Columns().Status, 1).
One()
if err != nil {
return nil, gerror.Wrap(err, "获取IM注册短信开关失败")
}
if record.IsEmpty() {
if err = s.seedConfig(ctx); err != nil {
return nil, err
}
return s.getConfig(ctx)
}
return &sysin.ImRegistrationConfigModel{
RegisterSmsEnabled: record[dao.SysConfig.Columns().Value].String() == "1",
UpdatedAt: record[dao.SysConfig.Columns().UpdatedAt].GTime(),
}, nil
}
func (s *sSysImRegistration) seedConfig(ctx context.Context) (err error) {
_, err = dao.SysConfig.Ctx(ctx).Data(g.Map{
dao.SysConfig.Columns().Group: imRegistrationConfigGroup,
dao.SysConfig.Columns().Name: "注册短信发送",
dao.SysConfig.Columns().Type: "bool",
dao.SysConfig.Columns().Key: imRegistrationSmsKey,
dao.SysConfig.Columns().Value: "1",
dao.SysConfig.Columns().DefaultValue: "1",
dao.SysConfig.Columns().Sort: 10,
dao.SysConfig.Columns().Tip: "控制 IM 注册验证码是否真实发送",
dao.SysConfig.Columns().IsDefault: 1,
dao.SysConfig.Columns().Status: 1,
dao.SysConfig.Columns().CreatedAt: gtime.Now(),
dao.SysConfig.Columns().UpdatedAt: gtime.Now(),
}).Insert()
if err != nil {
return gerror.Wrap(err, "初始化IM注册短信开关失败")
}
return
}
func (s *sSysImRegistration) verifySuper(ctx context.Context) error {
if !service.AdminMember().VerifySuperId(ctx, contexts.GetUserId(ctx)) {
return gerror.New("仅平台超级管理员可操作IM注册配置")
}
return nil
}
+274
View File
@@ -0,0 +1,274 @@
// Package sys
// @Link https://github.com/bufanyun/hotgo
// @Copyright Copyright (c) 2026 HotGo
// @Author Ms <133814250@qq.com>
// @License https://github.com/bufanyun/hotgo/blob/master/LICENSE
package sys
import (
"context"
"hotgo/internal/dao"
"hotgo/internal/library/contexts"
"hotgo/internal/library/hgorm/handler"
"hotgo/internal/library/hgorm/hook"
"hotgo/internal/model/entity"
"hotgo/internal/model/input/sysin"
"hotgo/internal/service"
"github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/errors/gerror"
"github.com/gogf/gf/v2/frame/g"
)
type sSysImTenant struct{}
const imRegistryDbGroup = "imRegistry"
func NewSysImTenant() *sSysImTenant {
return &sSysImTenant{}
}
func init() {
service.RegisterSysImTenant(NewSysImTenant())
}
// Model IM租户ORM模型
func (s *sSysImTenant) Model(ctx context.Context, option ...*handler.Option) *gdb.Model {
return handler.Model(g.DB(imRegistryDbGroup).Model(dao.ImTenant.Table()).Safe().Ctx(ctx), option...)
}
func (s *sSysImTenant) configModel(ctx context.Context) *gdb.Model {
return g.DB(imRegistryDbGroup).Model(dao.ImTenantConfig.Table()).Safe().Ctx(ctx)
}
// List 获取IM租户列表
func (s *sSysImTenant) List(ctx context.Context, in *sysin.ImTenantListInp) (list []*sysin.ImTenantListModel, totalCount int, err error) {
if err = in.Filter(ctx); err != nil {
return
}
mod := s.Model(ctx).Fields(
dao.ImTenant.Columns().Id,
dao.ImTenant.Columns().Code,
dao.ImTenant.Columns().Name,
dao.ImTenant.Columns().TenantDesc,
dao.ImTenant.Columns().DbName,
dao.ImTenant.Columns().State,
dao.ImTenant.Columns().CreatedBy,
dao.ImTenant.Columns().UpdatedBy,
dao.ImTenant.Columns().CreatedAt,
dao.ImTenant.Columns().UpdatedAt,
)
if in.Id > 0 {
mod = mod.Where(dao.ImTenant.Columns().Id, in.Id)
}
if in.Code != "" {
mod = mod.WhereLike(dao.ImTenant.Columns().Code, "%"+in.Code+"%")
}
if in.Name != "" {
mod = mod.WhereLike(dao.ImTenant.Columns().Name, "%"+in.Name+"%")
}
if in.DbName != "" {
mod = mod.WhereLike(dao.ImTenant.Columns().DbName, "%"+in.DbName+"%")
}
if in.State != nil {
mod = mod.Where(dao.ImTenant.Columns().State, *in.State)
}
if len(in.CreatedAt) == 2 {
mod = mod.WhereBetween(dao.ImTenant.Columns().CreatedAt, in.CreatedAt[0], in.CreatedAt[1])
}
mod = mod.Page(in.Page, in.PerPage).
OrderDesc(dao.ImTenant.Columns().Id).
Hook(hook.MemberSummary)
if err = mod.ScanAndCount(&list, &totalCount, false); err != nil {
err = gerror.Wrap(err, "获取IM租户列表失败,请稍后重试!")
return
}
if len(list) == 0 {
return
}
if err = s.fillListConfig(ctx, list); err != nil {
return
}
return
}
func (s *sSysImTenant) fillListConfig(ctx context.Context, list []*sysin.ImTenantListModel) (err error) {
ids := make([]int64, 0, len(list))
for _, item := range list {
ids = append(ids, item.Id)
}
var configs []*entity.ImTenantConfig
if err = s.configModel(ctx).
WhereIn(dao.ImTenantConfig.Columns().TenantId, ids).
Scan(&configs); err != nil {
return gerror.Wrap(err, "获取IM租户配置失败,请稍后重试!")
}
configMap := make(map[int64]*entity.ImTenantConfig, len(configs))
for _, item := range configs {
configMap[item.TenantId] = item
}
var tenants []*entity.ImTenant
if err = s.Model(ctx).
Fields(dao.ImTenant.Columns().Id, dao.ImTenant.Columns().DbDsn).
WhereIn(dao.ImTenant.Columns().Id, ids).
Scan(&tenants); err != nil {
return gerror.Wrap(err, "获取IM租户连接配置失败,请稍后重试!")
}
dsnMap := make(map[int64]string, len(tenants))
for _, item := range tenants {
dsnMap[item.Id] = item.DbDsn
}
for _, item := range list {
if conf, ok := configMap[item.Id]; ok {
item.MaxUsers = conf.MaxUsers
item.MaxGroups = conf.MaxGroups
item.MaxGroupMembers = conf.MaxGroupMembers
}
item.DbDsnConfigured = dsnMap[item.Id] != ""
}
return
}
// Edit 修改/新增IM租户
func (s *sSysImTenant) Edit(ctx context.Context, in *sysin.ImTenantEditInp) (err error) {
if err = in.Filter(ctx); err != nil {
return
}
return g.DB(imRegistryDbGroup).Transaction(ctx, func(ctx context.Context, tx gdb.TX) (err error) {
if err = s.checkCodeUnique(ctx, in.Id, in.Code); err != nil {
return
}
userId := contexts.GetUserId(ctx)
if in.Id > 0 {
in.UpdatedBy = userId
if _, err = s.Model(ctx).
Fields(sysin.ImTenantUpdateFields{}).
WherePri(in.Id).Data(in).Update(); err != nil {
return gerror.Wrap(err, "修改IM租户失败,请稍后重试!")
}
return s.saveConfig(ctx, in.Id, in.MaxUsers, in.MaxGroups, in.MaxGroupMembers, userId)
}
in.CreatedBy = userId
id, err := s.Model(ctx, &handler.Option{FilterAuth: false}).
Fields(sysin.ImTenantInsertFields{}).
Data(in).InsertAndGetId()
if err != nil {
return gerror.Wrap(err, "新增IM租户失败,请稍后重试!")
}
return s.saveConfig(ctx, id, in.MaxUsers, in.MaxGroups, in.MaxGroupMembers, userId)
})
}
func (s *sSysImTenant) checkCodeUnique(ctx context.Context, id int64, code string) (err error) {
mod := s.Model(ctx).Where(dao.ImTenant.Columns().Code, code)
if id > 0 {
mod = mod.Where(dao.ImTenant.Columns().Id+"<>?", id)
}
count, err := mod.Count()
if err != nil {
return gerror.Wrap(err, "检查IM租户编码失败,请稍后重试!")
}
if count > 0 {
return gerror.New("租户编码已存在")
}
return
}
func (s *sSysImTenant) saveConfig(ctx context.Context, tenantId int64, maxUsers uint64, maxGroups uint64, maxGroupMembers uint, userId int64) (err error) {
var conf *entity.ImTenantConfig
if err = s.configModel(ctx).
Where(dao.ImTenantConfig.Columns().TenantId, tenantId).
Scan(&conf); err != nil {
return gerror.Wrap(err, "获取IM租户配置失败,请稍后重试!")
}
data := g.Map{
dao.ImTenantConfig.Columns().MaxUsers: maxUsers,
dao.ImTenantConfig.Columns().MaxGroups: maxGroups,
dao.ImTenantConfig.Columns().MaxGroupMembers: maxGroupMembers,
}
if conf != nil && conf.Id > 0 {
data[dao.ImTenantConfig.Columns().UpdatedBy] = userId
if _, err = s.configModel(ctx).
Fields(
dao.ImTenantConfig.Columns().MaxUsers,
dao.ImTenantConfig.Columns().MaxGroups,
dao.ImTenantConfig.Columns().MaxGroupMembers,
dao.ImTenantConfig.Columns().UpdatedBy,
).
WherePri(conf.Id).Data(data).Update(); err != nil {
return gerror.Wrap(err, "修改IM租户配置失败,请稍后重试!")
}
return
}
data[dao.ImTenantConfig.Columns().TenantId] = tenantId
data[dao.ImTenantConfig.Columns().CreatedBy] = userId
if _, err = s.configModel(ctx).
Fields(
dao.ImTenantConfig.Columns().TenantId,
dao.ImTenantConfig.Columns().MaxUsers,
dao.ImTenantConfig.Columns().MaxGroups,
dao.ImTenantConfig.Columns().MaxGroupMembers,
dao.ImTenantConfig.Columns().CreatedBy,
).
Data(data).Insert(); err != nil {
return gerror.Wrap(err, "新增IM租户配置失败,请稍后重试!")
}
return
}
// View 获取IM租户指定信息
func (s *sSysImTenant) View(ctx context.Context, in *sysin.ImTenantViewInp) (res *sysin.ImTenantViewModel, err error) {
if err = in.Filter(ctx); err != nil {
return
}
if err = s.Model(ctx).WherePri(in.Id).Hook(hook.MemberSummary).Scan(&res); err != nil {
err = gerror.Wrap(err, "获取IM租户信息失败,请稍后重试!")
return
}
if res == nil {
return
}
var conf *entity.ImTenantConfig
if err = s.configModel(ctx).
Where(dao.ImTenantConfig.Columns().TenantId, in.Id).
Scan(&conf); err != nil {
err = gerror.Wrap(err, "获取IM租户配置失败,请稍后重试!")
return
}
if conf != nil {
res.MaxUsers = conf.MaxUsers
res.MaxGroups = conf.MaxGroups
res.MaxGroupMembers = conf.MaxGroupMembers
}
res.DbDsnConfigured = res.DbDsn != ""
return
}
// Status 更新IM租户状态
func (s *sSysImTenant) Status(ctx context.Context, in *sysin.ImTenantStatusInp) (err error) {
if err = in.Filter(ctx); err != nil {
return
}
if _, err = s.Model(ctx).WherePri(in.Id).Data(g.Map{
dao.ImTenant.Columns().State: in.State,
dao.ImTenant.Columns().UpdatedBy: contexts.GetUserId(ctx),
}).Update(); err != nil {
err = gerror.Wrap(err, "更新IM租户状态失败,请稍后重试!")
return
}
return
}
+315
View File
@@ -0,0 +1,315 @@
// 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"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"hotgo/internal/library/imtenant"
"hotgo/internal/model/input/sysin"
"hotgo/internal/service"
"strings"
"time"
"github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/errors/gerror"
"github.com/gogf/gf/v2/os/gctx"
"github.com/gogf/gf/v2/util/guid"
"golang.org/x/crypto/bcrypt"
)
type sSysImUser struct{}
func NewSysImUser() *sSysImUser {
return &sSysImUser{}
}
func init() {
service.RegisterSysImUser(NewSysImUser())
}
// List 获取IM用户列表
func (s *sSysImUser) List(ctx context.Context, in *sysin.ImUserListInp) (list []*sysin.ImUserListModel, totalCount int, err error) {
if err = in.Filter(ctx); err != nil {
return
}
scope, err := resolveImAdminScope(ctx, in.TenantId)
if err != nil {
return
}
db, tenant, err := imtenant.DB(ctx, scope.TenantId)
if err != nil {
return
}
mod := s.listModel(ctx, db)
mod = s.applyListWhere(mod, in)
mod = mod.Page(in.Page, in.PerPage).OrderDesc("u.id")
if err = mod.ScanAndCount(&list, &totalCount, false); err != nil {
err = gerror.Wrap(err, "获取IM用户列表失败,请稍后重试!")
return
}
if len(list) == 0 {
return
}
for _, item := range list {
item.TenantId = tenant.Id
item.TenantName = tenant.Name
}
if err = s.fillAuthAndCredentials(ctx, db, list); err != nil {
return
}
return
}
func (s *sSysImUser) listModel(ctx context.Context, db gdb.DB) *gdb.Model {
return db.Model("im_users u").Safe().Ctx(ctx).
LeftJoin("im_user_profiles up", "up.user_id = u.id").
LeftJoin("im_user_security us", "us.user_id = u.id").
Fields(
"CAST(u.id AS CHAR) user_id",
"u.username",
"COALESCE(u.phone, '') phone",
"u.state",
"u.lastseen last_seen",
"u.useragent user_agent",
"u.createdat created_at",
"u.updatedat updated_at",
"up.nickname",
"up.public_account",
"up.gender",
"up.region",
"us.auth_version",
"us.password_updated_at",
)
}
func (s *sSysImUser) applyListWhere(mod *gdb.Model, in *sysin.ImUserListInp) *gdb.Model {
if userId, _ := sysin.ParseOptionalInt64(in.UserId); userId > 0 {
mod = mod.Where("u.id", userId)
}
if in.Nickname != "" {
mod = mod.WhereLike("up.nickname", "%"+in.Nickname+"%")
}
if in.AuthName != "" {
mod = mod.Where("u.username = ? OR u.phone = ? OR up.email = ?", in.AuthName, in.AuthName, in.AuthName)
}
if in.State != nil {
mod = mod.Where("u.state", *in.State)
}
if len(in.CreatedAt) == 2 {
mod = mod.WhereBetween("u.createdat", in.CreatedAt[0], in.CreatedAt[1])
}
if len(in.LastSeen) == 2 {
mod = mod.WhereBetween("u.lastseen", in.LastSeen[0], in.LastSeen[1])
}
return mod
}
func (s *sSysImUser) fillAuthAndCredentials(ctx context.Context, db gdb.DB, list []*sysin.ImUserListModel) (err error) {
ids := make([]int64, 0, len(list))
userMap := make(map[int64]*sysin.ImUserListModel, len(list))
for _, item := range list {
userId, parseErr := sysin.ParseOptionalInt64(item.UserId)
if parseErr != nil {
return parseErr
}
ids = append(ids, userId)
userMap[userId] = item
}
credentialRows, err := db.Model("im_credentials").Safe().Ctx(ctx).
Fields("userid", "method", "value", "done").
WhereIn("userid", ids).
WhereNull("deletedat").
OrderAsc("method").OrderAsc("value").
All()
if err != nil {
return gerror.Wrap(err, "获取IM用户凭证失败,请稍后重试!")
}
credentialMap := make(map[int64][]string)
for _, row := range credentialRows {
userId := row["userid"].Int64()
method := row["method"].String()
value := row["value"].String()
done := row["done"].Bool()
if !done {
value += "(未验证)"
}
credentialMap[userId] = append(credentialMap[userId], method+":"+value)
}
for userId, item := range userMap {
item.AuthNames = item.Username
if item.Phone != "" && item.Phone != item.Username {
item.AuthNames += ", " + item.Phone
}
item.Credentials = strings.Join(credentialMap[userId], ", ")
}
return
}
// View 获取IM用户指定信息
func (s *sSysImUser) View(ctx context.Context, in *sysin.ImUserViewInp) (res *sysin.ImUserViewModel, err error) {
if err = in.Filter(ctx); err != nil {
return
}
scope, err := resolveImAdminScope(ctx, in.TenantId)
if err != nil {
return
}
db, tenant, err := imtenant.DB(ctx, scope.TenantId)
if err != nil {
return
}
userId, err := sysin.ParseOptionalInt64(in.UserId)
if err != nil {
return nil, err
}
fields := append(s.viewFields(), "u.access access_json", "u.public profile_json", "u.trusted trusted_json")
err = db.Model("im_users u").Safe().Ctx(ctx).
LeftJoin("im_user_profiles up", "up.user_id = u.id").
LeftJoin("im_user_security us", "us.user_id = u.id").
Fields(fields...).
Where("u.id", userId).
Scan(&res)
if err != nil {
return nil, gerror.Wrap(err, "获取IM用户信息失败,请稍后重试!")
}
if res == nil {
return nil, nil
}
res.TenantId = tenant.Id
res.TenantName = tenant.Name
list := []*sysin.ImUserListModel{&res.ImUserListModel}
if err = s.fillAuthAndCredentials(ctx, db, list); err != nil {
return nil, err
}
return
}
func (s *sSysImUser) viewFields() []interface{} {
return []interface{}{
"CAST(u.id AS CHAR) user_id",
"u.username",
"COALESCE(u.phone, '') phone",
"u.state",
"u.lastseen last_seen",
"u.useragent user_agent",
"u.createdat created_at",
"u.updatedat updated_at",
"up.nickname",
"up.public_account",
"up.gender",
"up.region",
"up.signature",
"up.avatar_file_id",
"CAST(up.birth_date AS CHAR) birth_date",
"up.version profile_version",
"us.auth_version",
"us.password_updated_at",
}
}
func (s *sSysImUser) ResetPassword(ctx context.Context, in *sysin.ImUserPasswordResetInp) (err error) {
if err = in.Filter(ctx); err != nil {
return
}
scope, err := resolveImAdminScope(ctx, in.TenantId)
if err != nil {
return err
}
userId, err := sysin.ParseOptionalInt64(in.UserId)
if err != nil {
return err
}
db, _, err := imtenant.DB(ctx, scope.TenantId)
if err != nil {
return err
}
err = resetIMUserPassword(ctx, db, scope, userId, in.NewPassword)
result := "success"
if err != nil {
result = "failed"
}
_ = writeImAdminAudit(ctx, scope.TenantId, "user_password_reset", "user", userId, result, nil)
return err
}
func resetIMUserPassword(ctx context.Context, db gdb.DB, scope imAdminScope, userId int64, newPassword string) error {
passwordHash, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcrypt.DefaultCost)
if err != nil {
return gerror.Wrap(err, "生成密码摘要失败")
}
securityId, err := imtenant.NextSnowflakeID(ctx)
if err != nil {
return gerror.Wrap(err, "生成安全记录ID失败")
}
eventId, err := imtenant.NextSnowflakeID(ctx)
if err != nil {
return gerror.Wrap(err, "生成安全事件ID失败")
}
now := time.Now().UTC().Round(time.Millisecond)
requestId := scope.RequestId
if requestId == "" {
requestId = gctx.CtxId(ctx)
}
if requestId == "" {
requestId = guid.S()
}
eventKeyBytes := sha256.Sum256([]byte(fmt.Sprintf("password-reset:%d:%d:%s", scope.TenantId, userId, requestId)))
eventKey := hex.EncodeToString(eventKeyBytes[:])
payload, _ := json.Marshal(map[string]any{
"user_id": userId, "operator_id": scope.OperatorId, "request_id": requestId,
})
return db.Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
exists, txErr := tx.GetValue("SELECT EXISTS(SELECT 1 FROM im_users WHERE id=? LIMIT 1)", userId)
if txErr != nil {
return txErr
}
if !exists.Bool() {
return gerror.New("IM用户不存在")
}
result, txErr := tx.Exec("UPDATE im_auth SET secret=? WHERE userid=?", passwordHash, userId)
if txErr != nil {
return txErr
}
if affected, _ := result.RowsAffected(); affected == 0 {
return gerror.New("用户认证记录不存在")
}
if _, txErr = tx.Exec(`INSERT INTO im_user_security
(id,user_id,auth_version,password_updated_at,created_at,updated_at)
VALUES(?,?,?,?,?,?)
ON DUPLICATE KEY UPDATE auth_version=auth_version+1,
password_updated_at=VALUES(password_updated_at),updated_at=VALUES(updated_at)`,
securityId, userId, 2, now, now, now); txErr != nil {
return txErr
}
version, txErr := tx.GetValue("SELECT auth_version FROM im_user_security WHERE user_id=?", userId)
if txErr != nil {
return txErr
}
var eventPayload map[string]any
_ = json.Unmarshal(payload, &eventPayload)
eventPayload["auth_version"] = version.Int()
payload, _ = json.Marshal(eventPayload)
_, txErr = tx.Exec(`INSERT INTO im_business_events_outbox
(id,event_id,target_type,target,actor_user_id,event_type,payload,status,created_at,updated_at)
VALUES(?,?,?,?,?,?,CAST(? AS JSON),'pending',?,?)
ON DUPLICATE KEY UPDATE event_id=VALUES(event_id)`, eventId, eventKey, "user",
fmt.Sprintf("%d", userId), 0, "security.password_reset", string(payload), now, now)
return txErr
})
}
+212
View File
@@ -0,0 +1,212 @@
// Package sys
// @Link https://github.com/bufanyun/hotgo
// @Copyright Copyright (c) 2026 HotGo
// @Author Ms <133814250@qq.com>
// @License https://github.com/bufanyun/hotgo/blob/master/LICENSE
package sys
import (
"context"
"hotgo/internal/library/imtenant"
"hotgo/internal/model/input/sysin"
"hotgo/internal/service"
"github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/errors/gerror"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/os/gtime"
)
type sSysImWorkOrder struct{}
func NewSysImWorkOrder() *sSysImWorkOrder {
return &sSysImWorkOrder{}
}
func init() {
service.RegisterSysImWorkOrder(NewSysImWorkOrder())
}
// List 获取IM举报反馈工单列表
func (s *sSysImWorkOrder) List(ctx context.Context, in *sysin.ImWorkOrderListInp) (list []*sysin.ImWorkOrderListModel, totalCount int, err error) {
if err = in.Filter(ctx); err != nil {
return
}
db, tenant, err := imtenant.DB(ctx, in.TenantId)
if err != nil {
return
}
mod := s.listModel(ctx, db, in.Type)
mod = s.applyWhere(mod, in)
mod = mod.Page(in.Page, in.PerPage).OrderDesc("created_at").OrderDesc("id")
if err = mod.ScanAndCount(&list, &totalCount, false); err != nil {
err = gerror.Wrap(err, "获取IM举报反馈工单列表失败,请稍后重试!")
return
}
for _, item := range list {
item.TenantId = tenant.Id
item.TenantName = tenant.Name
}
return
}
func (s *sSysImWorkOrder) listModel(ctx context.Context, db gdb.DB, typ string) *gdb.Model {
if typ == sysin.ImWorkOrderTypeAbuse {
return db.Model("im_abuse_reports").Safe().Ctx(ctx).Fields(s.abuseListFields()...)
}
return db.Model("im_feedback_submissions").Safe().Ctx(ctx).Fields(s.feedbackListFields()...)
}
func (s *sSysImWorkOrder) abuseListFields() []interface{} {
return []interface{}{
"'abuse' type",
"id",
"report_id order_id",
"reporter_id user_id",
"target_type",
"CONCAT(target_topic, IF(target_seq>0, CONCAT('#', target_seq), ''), IF(target_user_id>0, CONCAT(' user:', target_user_id), '')) target_summary",
"reason_code category",
"description content",
"status",
"created_at",
"updated_at",
}
}
func (s *sSysImWorkOrder) feedbackListFields() []interface{} {
return []interface{}{
"'feedback' type",
"id",
"feedback_id order_id",
"user_id",
"'' target_type",
"'' target_summary",
"category",
"content",
"status",
"created_at",
"updated_at",
}
}
func (s *sSysImWorkOrder) applyWhere(mod *gdb.Model, in *sysin.ImWorkOrderListInp) *gdb.Model {
if in.UserId > 0 {
mod = mod.Where("user_id", in.UserId)
}
if in.Status != "" {
mod = mod.Where("status", in.Status)
}
if in.Keyword != "" {
mod = mod.WhereLike("content", "%"+in.Keyword+"%")
}
if len(in.CreatedAt) == 2 {
mod = mod.WhereBetween("created_at", in.CreatedAt[0], in.CreatedAt[1])
}
return mod
}
// View 获取IM举报反馈工单指定信息
func (s *sSysImWorkOrder) View(ctx context.Context, in *sysin.ImWorkOrderViewInp) (res *sysin.ImWorkOrderViewModel, err error) {
if err = in.Filter(ctx); err != nil {
return
}
db, tenant, err := imtenant.DB(ctx, in.TenantId)
if err != nil {
return nil, err
}
if in.Type == sysin.ImWorkOrderTypeAbuse {
res, err = s.viewAbuse(ctx, db, in.Id)
} else {
res, err = s.viewFeedback(ctx, db, in.Id)
}
if err != nil || res == nil {
return
}
res.TenantId = tenant.Id
res.TenantName = tenant.Name
return
}
func (s *sSysImWorkOrder) viewAbuse(ctx context.Context, db gdb.DB, id int64) (res *sysin.ImWorkOrderViewModel, err error) {
err = db.Model("im_abuse_reports").Safe().Ctx(ctx).
Fields(
"'abuse' type",
"id",
"report_id order_id",
"reporter_id user_id",
"target_type",
"CONCAT(target_topic, IF(target_seq>0, CONCAT('#', target_seq), ''), IF(target_user_id>0, CONCAT(' user:', target_user_id), '')) target_summary",
"reason_code category",
"description content",
"description",
"evidence_file_ids evidence_json",
"snapshot snapshot_json",
"idempotency_key",
"status",
"created_at",
"updated_at",
).
Where("id", id).
Scan(&res)
if err != nil {
return nil, gerror.Wrap(err, "获取IM举报工单信息失败,请稍后重试!")
}
return
}
func (s *sSysImWorkOrder) viewFeedback(ctx context.Context, db gdb.DB, id int64) (res *sysin.ImWorkOrderViewModel, err error) {
err = db.Model("im_feedback_submissions").Safe().Ctx(ctx).
Fields(
"'feedback' type",
"id",
"feedback_id order_id",
"user_id",
"'' target_type",
"'' target_summary",
"category",
"content",
"contact",
"attachment_file_ids evidence_json",
"client_context client_context_json",
"include_diagnostics",
"idempotency_key",
"status",
"created_at",
"updated_at",
).
Where("id", id).
Scan(&res)
if err != nil {
return nil, gerror.Wrap(err, "获取IM反馈工单信息失败,请稍后重试!")
}
return
}
// Status 更新IM举报反馈工单状态
func (s *sSysImWorkOrder) Status(ctx context.Context, in *sysin.ImWorkOrderStatusInp) (err error) {
if err = in.Filter(ctx); err != nil {
return
}
db, _, err := imtenant.DB(ctx, in.TenantId)
if err != nil {
return
}
table := "im_abuse_reports"
if in.Type == sysin.ImWorkOrderTypeFeedback {
table = "im_feedback_submissions"
}
if _, err = db.Model(table).Safe().Ctx(ctx).
Where("id", in.Id).
Data(g.Map{
"status": in.Status,
"updated_at": gtime.Now(),
}).
Update(); err != nil {
return gerror.Wrap(err, "更新IM举报反馈工单状态失败,请稍后重试!")
}
return
}
+60 -9
View File
@@ -159,7 +159,7 @@ func (s *sSysLog) AnalysisLog(ctx context.Context) entity.SysLog {
module = mctx.Module
clientIp = location.GetClientIp(request)
postData = gjson.New(consts.NilJsonToString)
getData = gjson.New(request.URL.Query())
getData = s.MaskSensitiveJson(gjson.New(request.URL.Query()))
headerData = gjson.New(consts.NilJsonToString)
errorData = gjson.New(consts.NilJsonToString)
data entity.SysLog
@@ -194,7 +194,7 @@ func (s *sSysLog) AnalysisLog(ctx context.Context) entity.SysLog {
// post参数
if body, ok := mctx.Data["request.body"].(*gjson.Json); ok {
postData = body
postData = s.MaskSensitiveJson(body)
}
// post表单
@@ -208,12 +208,7 @@ func (s *sSysLog) AnalysisLog(ctx context.Context) entity.SysLog {
if postData.IsNil() || len(postData.Map()) == 0 {
postData = gjson.New(consts.NilJsonToString)
} else {
// 隐藏明文显示的密码
for k := range postData.Map() {
if gstr.ContainsI(k, "password") {
postData.MustSet(k, "******")
}
}
postData = s.MaskSensitiveJson(postData)
}
// 当前登录用户
@@ -247,7 +242,7 @@ func (s *sSysLog) AnalysisLog(ctx context.Context) entity.SysLog {
Url: request.URL.Path,
GetData: getData,
PostData: postData,
HeaderData: s.SimplifyHeaderParams(headerData),
HeaderData: s.SimplifyHeaderParams(s.MaskSensitiveJson(headerData)),
Ip: clientIp,
ProvinceId: ipData.ProvinceCode,
CityId: ipData.CityCode,
@@ -273,6 +268,10 @@ func (s *sSysLog) SimplifyHeaderParams(data *gjson.Json) *gjson.Json {
var filters = []string{"Accept", "Authorization", "Cookie"}
for _, filter := range filters {
v := data.Get(filter)
if isSensitiveLogField(filter) {
data.MustSet(filter, "******")
continue
}
if len(v.String()) > 128 {
data.MustRemove(filter)
}
@@ -280,6 +279,58 @@ func (s *sSysLog) SimplifyHeaderParams(data *gjson.Json) *gjson.Json {
return data
}
// MaskSensitiveJson masks sensitive request fields before access logs are stored.
func (s *sSysLog) MaskSensitiveJson(data *gjson.Json) *gjson.Json {
if data == nil || data.IsNil() {
return data
}
return gjson.New(maskSensitiveValue(data.Map()))
}
func maskSensitiveValue(v interface{}) interface{} {
switch value := v.(type) {
case map[string]interface{}:
res := make(map[string]interface{}, len(value))
for k, item := range value {
if isSensitiveLogField(k) {
res[k] = "******"
continue
}
res[k] = maskSensitiveValue(item)
}
return res
case map[string][]string:
res := make(map[string]interface{}, len(value))
for k, item := range value {
if isSensitiveLogField(k) {
res[k] = "******"
continue
}
res[k] = item
}
return res
case []interface{}:
for i, item := range value {
value[i] = maskSensitiveValue(item)
}
return value
default:
return v
}
}
func isSensitiveLogField(field string) bool {
sensitive := []string{"password", "invitecode", "invite_code", "servicesecret", "service_secret", "authorization", "x-service-secret"}
normalized := gstr.Replace(gstr.Replace(gstr.ToLower(field), "-", ""), "_", "")
for _, item := range sensitive {
item = gstr.Replace(gstr.Replace(item, "-", ""), "_", "")
if normalized == item || gstr.Contains(normalized, item) {
return true
}
}
return false
}
// View 获取指定请求日志信息
func (s *sSysLog) View(ctx context.Context, in *sysin.LogViewInp) (res *sysin.LogViewModel, err error) {
mod := s.Model(ctx)
+1
View File
@@ -22,6 +22,7 @@ type Context struct {
// Identity 通用身份模型
type Identity struct {
Id int64 `json:"id" description:"用户ID"`
TenantId int64 `json:"tenantId" description:"IM租户ID"`
Pid int64 `json:"pid" description:"上级ID"`
DeptId int64 `json:"deptId" description:"部门ID"`
DeptType string `json:"deptType" description:"部门类型"`
+1
View File
@@ -14,6 +14,7 @@ import (
type AdminMember struct {
g.Meta `orm:"table:hg_admin_member, do:true"`
Id any // 管理员ID
TenantId any // IM租户ID
DeptId any // 部门ID
RoleId any // 角色ID
RealName any // 真实姓名
+26
View File
@@ -0,0 +1,26 @@
// =================================================================================
// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT.
// =================================================================================
package do
import (
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/os/gtime"
)
// ImTenant is the golang structure of table im_tenant for DAO operations like Where/Data.
type ImTenant struct {
g.Meta `orm:"table:im_tenant, do:true"`
Id any //
Code any //
Name any //
TenantDesc any //
DbName any //
DbDsn any //
State any //
CreatedAt *gtime.Time //
CreatedBy any //
UpdatedAt *gtime.Time //
UpdatedBy any //
}
@@ -0,0 +1,24 @@
// =================================================================================
// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT.
// =================================================================================
package do
import (
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/os/gtime"
)
// ImTenantConfig is the golang structure of table im_tenant_config for DAO operations like Where/Data.
type ImTenantConfig struct {
g.Meta `orm:"table:im_tenant_config, do:true"`
Id any //
TenantId any //
MaxUsers any // 0 means unlimited
MaxGroups any // 0 means unlimited
MaxGroupMembers any // 0 means unlimited
CreatedAt *gtime.Time //
CreatedBy any //
UpdatedAt *gtime.Time //
UpdatedBy any //
}
@@ -12,6 +12,7 @@ import (
// AdminMember is the golang structure for table admin_member.
type AdminMember struct {
Id int64 `json:"id" orm:"id" description:"管理员ID"`
TenantId int64 `json:"tenantId" orm:"tenant_id" description:"IM租户ID"`
DeptId int64 `json:"deptId" orm:"dept_id" description:"部门ID"`
RoleId int64 `json:"roleId" orm:"role_id" description:"角色ID"`
RealName string `json:"realName" orm:"real_name" description:"真实姓名"`
+24
View File
@@ -0,0 +1,24 @@
// =================================================================================
// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT.
// =================================================================================
package entity
import (
"github.com/gogf/gf/v2/os/gtime"
)
// ImTenant is the golang structure for table im_tenant.
type ImTenant struct {
Id int64 `json:"id" orm:"id" description:""`
Code string `json:"code" orm:"code" description:""`
Name string `json:"name" orm:"name" description:""`
TenantDesc string `json:"tenantDesc" orm:"tenant_desc" description:""`
DbName string `json:"dbName" orm:"db_name" description:""`
DbDsn string `json:"dbDsn" orm:"db_dsn" description:""`
State int `json:"state" orm:"state" description:""`
CreatedAt *gtime.Time `json:"createdAt" orm:"created_at" description:""`
CreatedBy int64 `json:"createdBy" orm:"created_by" description:""`
UpdatedAt *gtime.Time `json:"updatedAt" orm:"updated_at" description:""`
UpdatedBy int64 `json:"updatedBy" orm:"updated_by" description:""`
}
@@ -0,0 +1,22 @@
// =================================================================================
// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT.
// =================================================================================
package entity
import (
"github.com/gogf/gf/v2/os/gtime"
)
// ImTenantConfig is the golang structure for table im_tenant_config.
type ImTenantConfig struct {
Id int64 `json:"id" orm:"id" description:""`
TenantId int64 `json:"tenantId" orm:"tenant_id" description:""`
MaxUsers uint64 `json:"maxUsers" orm:"max_users" description:"0 means unlimited"`
MaxGroups uint64 `json:"maxGroups" orm:"max_groups" description:"0 means unlimited"`
MaxGroupMembers uint `json:"maxGroupMembers" orm:"max_group_members" description:"0 means unlimited"`
CreatedAt *gtime.Time `json:"createdAt" orm:"created_at" description:""`
CreatedBy int64 `json:"createdBy" orm:"created_by" description:""`
UpdatedAt *gtime.Time `json:"updatedAt" orm:"updated_at" description:""`
UpdatedBy int64 `json:"updatedBy" orm:"updated_by" description:""`
}
@@ -115,6 +115,7 @@ type LoginMemberInfoModel struct {
// MemberEditInp 修改用户
type MemberEditInp struct {
Id int64 `json:"id" dc:"管理员ID"`
TenantId int64 `json:"tenantId" dc:"IM租户ID"`
RoleId int64 `json:"roleId" v:"required#角色不能为空" dc:"角色ID"`
PostIds []int64 `json:"postIds" dc:"岗位ID"`
DeptId int64 `json:"deptId" v:"required#部门不能为空" dc:"部门ID"`
@@ -0,0 +1,75 @@
// Package sysin
// @Link https://github.com/bufanyun/hotgo
// @Copyright Copyright (c) 2026 HotGo
// @Author Ms <133814250@qq.com>
// @License https://github.com/bufanyun/hotgo/blob/master/LICENSE
package sysin
import (
"context"
"hotgo/internal/model/input/form"
"github.com/gogf/gf/v2/errors/gerror"
"github.com/gogf/gf/v2/os/gtime"
)
// ImAuditLogListInp 获取IM审计日志列表
type ImAuditLogListInp struct {
form.PageReq
TenantId int64 `json:"tenantId" dc:"租户ID"`
ActorUid int64 `json:"actorUid" dc:"操作人UID"`
ActorRole string `json:"actorRole" dc:"操作角色"`
Action string `json:"action" dc:"操作类型"`
TargetType string `json:"targetType" dc:"目标类型"`
TargetId string `json:"targetId" dc:"目标ID"`
RequestId string `json:"requestId" dc:"请求ID"`
Ip string `json:"ip" dc:"IP"`
Keyword string `json:"keyword" dc:"关键词"`
CreatedAt []*gtime.Time `json:"createdAt" dc:"创建时间"`
}
func (in *ImAuditLogListInp) Filter(ctx context.Context) (err error) {
if in.TenantId <= 0 {
return gerror.New("请选择租户")
}
return
}
type ImAuditLogListModel struct {
TenantId int64 `json:"tenantId" dc:"租户ID"`
TenantName string `json:"tenantName" dc:"租户名称"`
Id int64 `json:"id" dc:"ID"`
ActorUid int64 `json:"actorUid" dc:"操作人UID"`
ActorRole string `json:"actorRole" dc:"操作角色"`
Action string `json:"action" dc:"操作类型"`
TargetType string `json:"targetType" dc:"目标类型"`
TargetId string `json:"targetId" dc:"目标ID"`
Reason string `json:"reason" dc:"操作原因"`
RequestId string `json:"requestId" dc:"请求ID"`
Ip string `json:"ip" dc:"IP"`
UserAgent string `json:"userAgent" dc:"客户端"`
BeforeSnapshot string `json:"beforeSnapshot" dc:"变更前快照"`
AfterSnapshot string `json:"afterSnapshot" dc:"变更后快照"`
Metadata string `json:"metadata" dc:"元数据"`
CreatedAt *gtime.Time `json:"createdAt" dc:"创建时间"`
}
// ImAuditLogViewInp 获取指定IM审计日志信息
type ImAuditLogViewInp struct {
TenantId int64 `json:"tenantId" v:"required#租户ID不能为空" dc:"租户ID"`
Id int64 `json:"id" v:"required#ID不能为空" dc:"ID"`
}
func (in *ImAuditLogViewInp) Filter(ctx context.Context) (err error) {
if in.TenantId <= 0 {
return gerror.New("请选择租户")
}
if in.Id <= 0 {
return gerror.New("ID不能为空")
}
return
}
type ImAuditLogViewModel struct {
ImAuditLogListModel
}
@@ -0,0 +1,290 @@
// Package sysin
// @Link https://github.com/bufanyun/hotgo
// @Copyright Copyright (c) 2026 HotGo
// @Author Ms <133814250@qq.com>
// @License https://github.com/bufanyun/hotgo/blob/master/LICENSE
package sysin
import (
"context"
"hotgo/internal/model/input/form"
"github.com/gogf/gf/v2/errors/gerror"
"github.com/gogf/gf/v2/os/gtime"
)
// ImGroupListInp 获取IM群组列表
type ImGroupListInp struct {
form.PageReq
TenantId int64 `json:"tenantId" dc:"租户ID"`
Topic string `json:"topic" dc:"Topic"`
OwnerId int64 `json:"ownerId" dc:"群主ID"`
State *int `json:"state" dc:"通信状态"`
GroupStatus string `json:"groupStatus" dc:"业务状态"`
CreatedAt []*gtime.Time `json:"createdAt" dc:"创建时间"`
TouchedAt []*gtime.Time `json:"touchedAt" dc:"活跃时间"`
}
func (in *ImGroupListInp) Filter(ctx context.Context) (err error) {
if in.TenantId <= 0 {
return gerror.New("请选择租户")
}
return
}
type ImGroupListModel struct {
TenantId int64 `json:"tenantId" dc:"租户ID"`
TenantName string `json:"tenantName" dc:"租户名称"`
Topic string `json:"topic" dc:"Topic"`
State int `json:"state" dc:"状态"`
OwnerId int64 `json:"ownerId" dc:"群主ID"`
SeqId int `json:"seqId" dc:"最新消息序号"`
DelId int `json:"delId" dc:"删除序号"`
SubCnt int `json:"subCnt" dc:"成员数"`
TouchedAt *gtime.Time `json:"touchedAt" dc:"活跃时间"`
CreatedAt *gtime.Time `json:"createdAt" dc:"创建时间"`
UpdatedAt *gtime.Time `json:"updatedAt" dc:"修改时间"`
PublicJson string `json:"publicJson" dc:"公开资料"`
TrustedJson string `json:"trustedJson" dc:"可信资料"`
TagsJson string `json:"tagsJson" dc:"标签"`
AuxJson string `json:"auxJson" dc:"辅助资料"`
GroupName string `json:"groupName" dc:"群名称"`
AvatarFileId string `json:"avatarFileId" dc:"头像文件ID"`
Description string `json:"description" dc:"简介"`
GroupStatus string `json:"groupStatus" dc:"业务状态"`
JoinApproval int `json:"joinApproval" dc:"入群审批"`
MuteAll int `json:"muteAll" dc:"全员禁言"`
MemberUploadFiles int `json:"memberUploadFiles" dc:"成员上传文件"`
MemberPrivateChat int `json:"memberPrivateChat" dc:"成员私聊"`
MemberInvite int `json:"memberInvite" dc:"成员邀请"`
MaxMemberCount int `json:"maxMemberCount" dc:"最大成员数"`
MaxAdminCount int `json:"maxAdminCount" dc:"最大管理员数"`
BusinessMemberCount int `json:"businessMemberCount" dc:"业务成员数"`
SettingsVersion int `json:"settingsVersion" dc:"治理配置版本"`
SettingsUpdatedAt *gtime.Time `json:"settingsUpdatedAt" dc:"治理配置更新时间"`
}
// ImGroupViewInp 获取指定IM群组信息
type ImGroupViewInp struct {
TenantId int64 `json:"tenantId" v:"required#租户ID不能为空" dc:"租户ID"`
Topic string `json:"topic" v:"required#Topic不能为空" dc:"Topic"`
}
func (in *ImGroupViewInp) Filter(ctx context.Context) (err error) {
if in.TenantId <= 0 {
return gerror.New("请选择租户")
}
if in.Topic == "" {
return gerror.New("Topic不能为空")
}
return
}
type ImGroupViewModel struct {
ImGroupListModel
AccessJson string `json:"accessJson" dc:"访问权限"`
UseBt int `json:"useBt" dc:"是否开启BT"`
}
// ImGroupMemberListInp 获取IM群成员列表
type ImGroupMemberListInp struct {
form.PageReq
TenantId int64 `json:"tenantId" v:"required#租户ID不能为空" dc:"租户ID"`
Topic string `json:"topic" v:"required#Topic不能为空" dc:"Topic"`
UserId int64 `json:"userId" dc:"用户ID"`
Role string `json:"role" dc:"角色"`
MemberStatus string `json:"memberStatus" dc:"成员状态"`
}
func (in *ImGroupMemberListInp) Filter(ctx context.Context) (err error) {
if in.TenantId <= 0 {
return gerror.New("请选择租户")
}
if in.Topic == "" {
return gerror.New("Topic不能为空")
}
return
}
type ImGroupMemberListModel struct {
UserId int64 `json:"userId" dc:"用户ID"`
Nickname string `json:"nickname" dc:"昵称"`
State int `json:"state" dc:"用户状态"`
ModeWant string `json:"modeWant" dc:"期望权限"`
ModeGiven string `json:"modeGiven" dc:"授予权限"`
RecvSeqId int `json:"recvSeqId" dc:"接收序号"`
ReadSeqId int `json:"readSeqId" dc:"已读序号"`
DelId int `json:"delId" dc:"删除序号"`
DeletedAt *gtime.Time `json:"deletedAt" dc:"删除时间"`
CreatedAt *gtime.Time `json:"createdAt" dc:"加入时间"`
UpdatedAt *gtime.Time `json:"updatedAt" dc:"修改时间"`
PrivateJson string `json:"privateJson" dc:"私有资料"`
Role string `json:"role" dc:"业务角色"`
MemberStatus string `json:"memberStatus" dc:"业务状态"`
GroupNickname string `json:"groupNickname" dc:"群昵称"`
Pinned int `json:"pinned" dc:"置顶"`
MuteNotifications int `json:"muteNotifications" dc:"免打扰"`
JoinSource string `json:"joinSource" dc:"加入来源"`
JoinedBy int64 `json:"joinedBy" dc:"邀请/处理人"`
JoinedAt *gtime.Time `json:"joinedAt" dc:"业务加入时间"`
LeftAt *gtime.Time `json:"leftAt" dc:"离开时间"`
RemovedBy int64 `json:"removedBy" dc:"移除人"`
Version int `json:"version" dc:"版本"`
}
// ImGroupTopicInp 群Topic通用入参
type ImGroupTopicInp struct {
TenantId int64 `json:"tenantId" v:"required#租户ID不能为空" dc:"租户ID"`
Topic string `json:"topic" v:"required#Topic不能为空" dc:"Topic"`
}
func (in *ImGroupTopicInp) Filter(ctx context.Context) (err error) {
if in.TenantId <= 0 {
return gerror.New("请选择租户")
}
if in.Topic == "" {
return gerror.New("Topic不能为空")
}
return
}
type ImGroupGovernanceModel struct {
TenantId int64 `json:"tenantId" dc:"租户ID"`
TenantName string `json:"tenantName" dc:"租户名称"`
Topic string `json:"topic" dc:"Topic"`
OwnerUserId int64 `json:"ownerUserId" dc:"群主ID"`
Name string `json:"name" dc:"群名称"`
AvatarFileId string `json:"avatarFileId" dc:"头像文件ID"`
Description string `json:"description" dc:"简介"`
GroupStatus string `json:"groupStatus" dc:"业务状态"`
JoinApproval int `json:"joinApproval" dc:"入群审批"`
MuteAll int `json:"muteAll" dc:"全员禁言"`
MemberUploadFiles int `json:"memberUploadFiles" dc:"成员上传文件"`
MemberPrivateChat int `json:"memberPrivateChat" dc:"成员私聊"`
MemberInvite int `json:"memberInvite" dc:"成员邀请"`
MaxMemberCount int `json:"maxMemberCount" dc:"最大成员数"`
MaxAdminCount int `json:"maxAdminCount" dc:"最大管理员数"`
MemberCount int `json:"memberCount" dc:"成员数"`
Version int `json:"version" dc:"版本"`
CreatedBy int64 `json:"createdBy" dc:"创建人"`
DissolvedBy int64 `json:"dissolvedBy" dc:"解散人"`
DissolvedAt *gtime.Time `json:"dissolvedAt" dc:"解散时间"`
CreatedAt *gtime.Time `json:"createdAt" dc:"创建时间"`
UpdatedAt *gtime.Time `json:"updatedAt" dc:"修改时间"`
}
type ImGroupJoinRequestListInp struct {
form.PageReq
ImGroupTopicInp
Status string `json:"status" dc:"状态"`
ApplicantUserId int64 `json:"applicantUserId" dc:"申请人ID"`
}
type ImGroupJoinRequestListModel struct {
Id int64 `json:"id" dc:"ID"`
RequestId string `json:"requestId" dc:"申请ID"`
Topic string `json:"topic" dc:"Topic"`
ApplicantUserId int64 `json:"applicantUserId" dc:"申请人ID"`
InviterUserId int64 `json:"inviterUserId" dc:"邀请人ID"`
Source string `json:"source" dc:"来源"`
Message string `json:"message" dc:"申请消息"`
Status string `json:"status" dc:"状态"`
RejectReason string `json:"rejectReason" dc:"拒绝原因"`
HandledBy int64 `json:"handledBy" dc:"处理人"`
HandledAt *gtime.Time `json:"handledAt" dc:"处理时间"`
ExpiresAt *gtime.Time `json:"expiresAt" dc:"过期时间"`
Version int `json:"version" dc:"版本"`
CreatedAt *gtime.Time `json:"createdAt" dc:"创建时间"`
UpdatedAt *gtime.Time `json:"updatedAt" dc:"修改时间"`
}
type ImGroupInvitationListInp struct {
form.PageReq
ImGroupTopicInp
Status string `json:"status" dc:"状态"`
TargetUserId int64 `json:"targetUserId" dc:"目标用户ID"`
InviterUserId int64 `json:"inviterUserId" dc:"邀请人ID"`
}
type ImGroupInvitationListModel struct {
Id int64 `json:"id" dc:"ID"`
InvitationId string `json:"invitationId" dc:"邀请ID"`
Topic string `json:"topic" dc:"Topic"`
InviterUserId int64 `json:"inviterUserId" dc:"邀请人ID"`
TargetUserId int64 `json:"targetUserId" dc:"目标用户ID"`
Message string `json:"message" dc:"邀请消息"`
Status string `json:"status" dc:"状态"`
Result string `json:"result" dc:"结果"`
JoinRequestId string `json:"joinRequestId" dc:"入群申请ID"`
ExpiresAt *gtime.Time `json:"expiresAt" dc:"过期时间"`
HandledAt *gtime.Time `json:"handledAt" dc:"处理时间"`
CreatedAt *gtime.Time `json:"createdAt" dc:"创建时间"`
UpdatedAt *gtime.Time `json:"updatedAt" dc:"修改时间"`
}
type ImGroupAnnouncementListInp struct {
form.PageReq
ImGroupTopicInp
Status string `json:"status" dc:"状态"`
}
type ImGroupAnnouncementListModel struct {
Id int64 `json:"id" dc:"ID"`
AnnouncementId string `json:"announcementId" dc:"公告ID"`
Topic string `json:"topic" dc:"Topic"`
AuthorUserId int64 `json:"authorUserId" dc:"发布人ID"`
Content string `json:"content" dc:"内容"`
Status string `json:"status" dc:"状态"`
Version int `json:"version" dc:"版本"`
PublishedAt *gtime.Time `json:"publishedAt" dc:"发布时间"`
DeletedBy int64 `json:"deletedBy" dc:"删除人"`
DeletedAt *gtime.Time `json:"deletedAt" dc:"删除时间"`
CreatedAt *gtime.Time `json:"createdAt" dc:"创建时间"`
UpdatedAt *gtime.Time `json:"updatedAt" dc:"修改时间"`
}
type ImGroupMuteListInp struct {
form.PageReq
ImGroupTopicInp
Status string `json:"status" dc:"状态"`
UserId int64 `json:"userId" dc:"用户ID"`
}
type ImGroupMuteListModel struct {
Topic string `json:"topic" dc:"Topic"`
UserId int64 `json:"userId" dc:"用户ID"`
MutedBy int64 `json:"mutedBy" dc:"禁言人"`
MutedAt *gtime.Time `json:"mutedAt" dc:"禁言时间"`
ExpiresAt *gtime.Time `json:"expiresAt" dc:"过期时间"`
Reason string `json:"reason" dc:"原因"`
Status string `json:"status" dc:"状态"`
RevokedBy int64 `json:"revokedBy" dc:"解除人"`
RevokedAt *gtime.Time `json:"revokedAt" dc:"解除时间"`
Version int `json:"version" dc:"版本"`
CreatedAt *gtime.Time `json:"createdAt" dc:"创建时间"`
UpdatedAt *gtime.Time `json:"updatedAt" dc:"修改时间"`
}
type ImGroupAdminLogListInp struct {
form.PageReq
ImGroupTopicInp
Action string `json:"action" dc:"动作"`
OperatorId int64 `json:"operatorId" dc:"操作人ID"`
}
type ImGroupAdminLogListModel struct {
Id int64 `json:"id" dc:"ID"`
LogId string `json:"logId" dc:"日志ID"`
Topic string `json:"topic" dc:"Topic"`
OperatorId int64 `json:"operatorId" dc:"操作人"`
TargetUserId int64 `json:"targetUserId" dc:"目标用户"`
Action string `json:"action" dc:"动作"`
ObjectType string `json:"objectType" dc:"对象类型"`
ObjectId string `json:"objectId" dc:"对象ID"`
BeforeData string `json:"beforeData" dc:"变更前"`
AfterData string `json:"afterData" dc:"变更后"`
RequestId string `json:"requestId" dc:"请求ID"`
IdempotencyKey string `json:"idempotencyKey" dc:"幂等键"`
CreatedAt *gtime.Time `json:"createdAt" dc:"创建时间"`
}
@@ -0,0 +1,154 @@
package sysin
import (
"context"
"hotgo/internal/model/input/form"
"strconv"
"strings"
"github.com/gogf/gf/v2/errors/gerror"
"github.com/gogf/gf/v2/os/gtime"
)
type ImInviteListInp struct {
form.PageReq
TenantId int64 `json:"tenantId" dc:"IM租户ID"`
InviteCode string `json:"inviteCode" dc:"完整邀请码"`
Status string `json:"status" dc:"状态"`
BatchId string `json:"batchId" dc:"批次ID"`
CreatedBy string `json:"createdBy" dc:"创建人"`
CreatedAtStart *gtime.Time `json:"createdAtStart" dc:"创建开始时间"`
CreatedAtEnd *gtime.Time `json:"createdAtEnd" dc:"创建结束时间"`
ExpiresAtStart *gtime.Time `json:"expiresAtStart" dc:"过期开始时间"`
ExpiresAtEnd *gtime.Time `json:"expiresAtEnd" dc:"过期结束时间"`
CreatedAt []*gtime.Time `json:"createdAt" dc:"创建时间"`
ExpiresAt []*gtime.Time `json:"expiresAt" dc:"到期时间"`
}
func (in *ImInviteListInp) Filter(ctx context.Context) (err error) {
if in.Page <= 0 {
in.Page = 1
}
if in.PerPage <= 0 {
in.PerPage = 20
}
if in.PerPage > 100 {
return gerror.New("每页数量最大值不能大于100")
}
if in.InviteCode != "" && len(in.InviteCode) != 5 {
return gerror.New("邀请码必须为完整5位")
}
if len(in.CreatedAt) == 2 {
in.CreatedAtStart = in.CreatedAt[0]
in.CreatedAtEnd = in.CreatedAt[1]
}
if len(in.ExpiresAt) == 2 {
in.ExpiresAtStart = in.ExpiresAt[0]
in.ExpiresAtEnd = in.ExpiresAt[1]
}
return
}
type ImInviteCreateInp struct {
TenantId int64 `json:"tenantId" dc:"IM租户ID"`
ExpiresAt *gtime.Time `json:"expiresAt" v:"required#到期时间不能为空" dc:"到期时间"`
Remark string `json:"remark" dc:"备注"`
IdempotencyKey string `json:"idempotencyKey" dc:"幂等键"`
}
func (in *ImInviteCreateInp) Filter(ctx context.Context) (err error) {
in.IdempotencyKey = strings.TrimSpace(in.IdempotencyKey)
if in.ExpiresAt == nil || in.ExpiresAt.Before(gtime.Now()) {
return gerror.New("到期时间必须晚于当前时间")
}
if len(in.Remark) > 255 {
return gerror.New("备注不能超过255个字符")
}
if len(in.IdempotencyKey) > 128 {
return gerror.New("幂等键不能超过128个字符")
}
return
}
type ImInviteBatchCreateInp struct {
TenantId int64 `json:"tenantId" dc:"IM租户ID"`
Quantity int `json:"quantity" dc:"数量"`
ExpiresAt *gtime.Time `json:"expiresAt" v:"required#到期时间不能为空" dc:"到期时间"`
Remark string `json:"remark" dc:"备注"`
IdempotencyKey string `json:"idempotencyKey" dc:"幂等键"`
}
func (in *ImInviteBatchCreateInp) Filter(ctx context.Context) (err error) {
in.IdempotencyKey = strings.TrimSpace(in.IdempotencyKey)
if in.Quantity < 1 || in.Quantity > 1000 {
return gerror.New("批量生成数量必须在1到1000之间")
}
if in.ExpiresAt == nil || in.ExpiresAt.Before(gtime.Now()) {
return gerror.New("到期时间必须晚于当前时间")
}
if len(in.Remark) > 255 {
return gerror.New("备注不能超过255个字符")
}
if len(in.IdempotencyKey) == 0 || len(in.IdempotencyKey) > 128 {
return gerror.New("批量生成必须提供不超过128个字符的幂等键")
}
return
}
type ImInviteBatchViewInp struct {
TenantId int64 `json:"tenantId" dc:"IM租户ID"`
BatchId string `json:"batchId" v:"required#批次ID不能为空" dc:"批次ID"`
}
type ImInviteRevokeInp struct {
TenantId int64 `json:"tenantId" dc:"IM租户ID"`
Id string `json:"id" v:"required#邀请码ID不能为空" dc:"邀请码ID"`
}
type ImInviteBatchRevokeInp struct {
TenantId int64 `json:"tenantId" dc:"IM租户ID"`
BatchId string `json:"batchId" v:"required#批次ID不能为空" dc:"批次ID"`
}
type ImInviteCodeModel struct {
Id string `json:"id" dc:"ID"`
TenantId string `json:"tenantId" dc:"IM租户ID"`
TenantName string `json:"tenantName" dc:"IM租户名称"`
BatchId string `json:"batchId" dc:"批次ID"`
Code string `json:"code" dc:"邀请码"`
Status string `json:"status" dc:"状态"`
ExpiresAt *gtime.Time `json:"expiresAt" dc:"到期时间"`
UsageCount int `json:"usageCount" dc:"累计使用次数"`
LastUsedAt *gtime.Time `json:"lastUsedAt" dc:"最后使用时间"`
CreatedBy string `json:"createdBy" dc:"创建人"`
Remark string `json:"remark" dc:"备注"`
CreatedAt *gtime.Time `json:"createdAt" dc:"创建时间"`
}
type ImInviteBatchModel struct {
BatchId string `json:"batchId" dc:"批次ID"`
TenantId string `json:"tenantId" dc:"IM租户ID"`
Quantity int `json:"quantity" dc:"数量"`
ActiveCount int `json:"activeCount" dc:"有效数量"`
UsageCount int `json:"usageCount" dc:"累计使用次数"`
RevokedCount int `json:"revokedCount" dc:"已撤销数量"`
ExpiredCount int `json:"expiredCount" dc:"已过期数量"`
ExpiresAt *gtime.Time `json:"expiresAt" dc:"到期时间"`
CreatedAt *gtime.Time `json:"createdAt" dc:"创建时间"`
}
type ImInviteBatchDetailModel struct {
ImInviteBatchModel
List []*ImInviteCodeModel `json:"list" dc:"批次邀请码"`
}
func ParseOptionalInt64(value string) (int64, error) {
if value == "" {
return 0, nil
}
parsed, err := strconv.ParseInt(value, 10, 64)
if err != nil || parsed <= 0 {
return 0, gerror.New("ID格式不正确")
}
return parsed, nil
}
@@ -0,0 +1,96 @@
// Package sysin
// @Link https://github.com/bufanyun/hotgo
// @Copyright Copyright (c) 2026 HotGo
// @Author Ms <133814250@qq.com>
// @License https://github.com/bufanyun/hotgo/blob/master/LICENSE
package sysin
import (
"context"
"hotgo/internal/consts"
"hotgo/internal/library/dict"
"hotgo/internal/model/input/form"
"github.com/gogf/gf/v2/errors/gerror"
"github.com/gogf/gf/v2/os/gtime"
)
// ImRealnameListInp 获取IM实名审核列表
type ImRealnameListInp struct {
form.PageReq
TenantId int64 `json:"tenantId" dc:"租户ID"`
UserId int64 `json:"userId" dc:"用户ID"`
SubmissionId string `json:"submissionId" dc:"提交ID"`
Status string `json:"status" dc:"状态"`
CreatedAt []*gtime.Time `json:"createdAt" dc:"创建时间"`
}
func (in *ImRealnameListInp) Filter(ctx context.Context) (err error) {
if in.TenantId <= 0 {
return gerror.New("请选择租户")
}
return
}
type ImRealnameListModel struct {
TenantId int64 `json:"tenantId" dc:"租户ID"`
TenantName string `json:"tenantName" dc:"租户名称"`
Id int64 `json:"id" dc:"ID"`
UserId int64 `json:"userId" dc:"用户ID"`
SubmissionId string `json:"submissionId" dc:"提交ID"`
Status string `json:"status" dc:"状态"`
MaskedName string `json:"maskedName" dc:"脱敏姓名"`
MaskedIdNumber string `json:"maskedIdNumber" dc:"脱敏证件号"`
IdType string `json:"idType" dc:"证件类型"`
ReviewReason string `json:"reviewReason" dc:"审核原因"`
VerifiedAt *gtime.Time `json:"verifiedAt" dc:"通过时间"`
CreatedAt *gtime.Time `json:"createdAt" dc:"创建时间"`
UpdatedAt *gtime.Time `json:"updatedAt" dc:"修改时间"`
}
// ImRealnameViewInp 获取指定IM实名审核信息
type ImRealnameViewInp struct {
TenantId int64 `json:"tenantId" v:"required#租户ID不能为空" dc:"租户ID"`
Id int64 `json:"id" v:"required#ID不能为空" dc:"ID"`
}
func (in *ImRealnameViewInp) Filter(ctx context.Context) (err error) {
if in.TenantId <= 0 {
return gerror.New("请选择租户")
}
if in.Id <= 0 {
return gerror.New("ID不能为空")
}
return
}
type ImRealnameViewModel struct {
ImRealnameListModel
IdNumberHash string `json:"idNumberHash" dc:"证件号哈希"`
}
// ImRealnameReviewInp 审核IM实名
type ImRealnameReviewInp struct {
TenantId int64 `json:"tenantId" v:"required#租户ID不能为空" dc:"租户ID"`
Id int64 `json:"id" v:"required#ID不能为空" dc:"ID"`
Status string `json:"status" dc:"审核状态"`
ReviewReason string `json:"reviewReason" dc:"审核原因"`
}
func (in *ImRealnameReviewInp) Filter(ctx context.Context) (err error) {
if in.TenantId <= 0 {
return gerror.New("请选择租户")
}
if in.Id <= 0 {
return gerror.New("ID不能为空")
}
if !dict.HasOptionKey(consts.ImRealnameStatusOptions, in.Status) {
return gerror.New("审核状态不正确")
}
if in.Status == consts.ImRealnameStatusRejected && in.ReviewReason == "" {
return gerror.New("拒绝原因不能为空")
}
return
}
type ImRealnameReviewModel struct{}
@@ -0,0 +1,12 @@
package sysin
import "github.com/gogf/gf/v2/os/gtime"
type ImRegistrationConfigModel struct {
RegisterSmsEnabled bool `json:"registerSmsEnabled" dc:"注册短信是否真实发送"`
UpdatedAt *gtime.Time `json:"updatedAt" dc:"更新时间"`
}
type ImRegistrationConfigUpdateInp struct {
RegisterSmsEnabled bool `json:"registerSmsEnabled" dc:"注册短信是否真实发送"`
}
@@ -0,0 +1,157 @@
// Package sysin
// @Link https://github.com/bufanyun/hotgo
// @Copyright Copyright (c) 2026 HotGo
// @Author Ms <133814250@qq.com>
// @License https://github.com/bufanyun/hotgo/blob/master/LICENSE
package sysin
import (
"context"
"hotgo/internal/consts"
"hotgo/internal/library/dict"
"hotgo/internal/library/hgorm/hook"
"hotgo/internal/model/entity"
"hotgo/internal/model/input/form"
"github.com/gogf/gf/v2/errors/gerror"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/os/gtime"
)
// ImTenantUpdateFields 修改IM租户字段过滤
type ImTenantUpdateFields struct {
Code string `json:"code" dc:"租户编码"`
Name string `json:"name" dc:"租户名称"`
TenantDesc string `json:"tenantDesc" dc:"租户描述"`
DbName string `json:"dbName" dc:"业务库名"`
DbDsn string `json:"dbDsn" dc:"独立连接串"`
State int `json:"state" dc:"状态"`
UpdatedBy int64 `json:"updatedBy" dc:"更新者"`
}
// ImTenantInsertFields 新增IM租户字段过滤
type ImTenantInsertFields struct {
Code string `json:"code" dc:"租户编码"`
Name string `json:"name" dc:"租户名称"`
TenantDesc string `json:"tenantDesc" dc:"租户描述"`
DbName string `json:"dbName" dc:"业务库名"`
DbDsn string `json:"dbDsn" dc:"独立连接串"`
State int `json:"state" dc:"状态"`
CreatedBy int64 `json:"createdBy" dc:"创建者"`
}
// ImTenantConfigUpdateFields 修改IM租户配置字段过滤
type ImTenantConfigUpdateFields struct {
MaxUsers uint64 `json:"maxUsers" dc:"最大用户数"`
MaxGroups uint64 `json:"maxGroups" dc:"最大群组数"`
MaxGroupMembers uint `json:"maxGroupMembers" dc:"最大群成员数"`
UpdatedBy int64 `json:"updatedBy" dc:"更新者"`
}
// ImTenantConfigInsertFields 新增IM租户配置字段过滤
type ImTenantConfigInsertFields struct {
TenantId int64 `json:"tenantId" dc:"租户ID"`
MaxUsers uint64 `json:"maxUsers" dc:"最大用户数"`
MaxGroups uint64 `json:"maxGroups" dc:"最大群组数"`
MaxGroupMembers uint `json:"maxGroupMembers" dc:"最大群成员数"`
CreatedBy int64 `json:"createdBy" dc:"创建者"`
}
// ImTenantEditInp 修改/新增IM租户
type ImTenantEditInp struct {
entity.ImTenant
MaxUsers uint64 `json:"maxUsers" dc:"最大用户数"`
MaxGroups uint64 `json:"maxGroups" dc:"最大群组数"`
MaxGroupMembers uint `json:"maxGroupMembers" dc:"最大群成员数"`
}
func (in *ImTenantEditInp) Filter(ctx context.Context) (err error) {
if e := g.Validator().Rules("required|length:2,64").Data(in.Code).Messages("租户编码不能为空|租户编码长度应为2到64个字符").Run(ctx); e != nil {
return e.Current()
}
if e := g.Validator().Rules("required|max-length:128").Data(in.Name).Messages("租户名称不能为空|租户名称不能超过128个字符").Run(ctx); e != nil {
return e.Current()
}
if in.DbName == "" && in.DbDsn == "" {
return gerror.New("业务库名和独立连接串至少填写一个")
}
if !dict.HasOptionKey(consts.ImTenantStateOptions, in.State) {
return gerror.New("租户状态不正确")
}
return
}
type ImTenantEditModel struct{}
// ImTenantViewInp 获取指定IM租户信息
type ImTenantViewInp struct {
Id int64 `json:"id" v:"required#ID不能为空" dc:"ID"`
}
func (in *ImTenantViewInp) Filter(ctx context.Context) (err error) {
return
}
type ImTenantViewModel struct {
entity.ImTenant
MaxUsers uint64 `json:"maxUsers" dc:"最大用户数"`
MaxGroups uint64 `json:"maxGroups" dc:"最大群组数"`
MaxGroupMembers uint `json:"maxGroupMembers" dc:"最大群成员数"`
CreatedBySumma *hook.MemberSumma `json:"createdBySumma" dc:"创建者摘要信息"`
UpdatedBySumma *hook.MemberSumma `json:"updatedBySumma" dc:"更新者摘要信息"`
DbDsnConfigured bool `json:"dbDsnConfigured" dc:"是否配置独立连接串"`
TenantDbReachable bool `json:"tenantDbReachable" dc:"业务库是否可达"`
TenantDbCheckError string `json:"tenantDbCheckError" dc:"业务库检查错误"`
}
// ImTenantListInp 获取IM租户列表
type ImTenantListInp struct {
form.PageReq
Id int64 `json:"id" dc:"ID"`
Code string `json:"code" dc:"租户编码"`
Name string `json:"name" dc:"租户名称"`
DbName string `json:"dbName" dc:"业务库名"`
State *int `json:"state" dc:"状态"`
CreatedAt []*gtime.Time `json:"createdAt" dc:"创建时间"`
}
func (in *ImTenantListInp) Filter(ctx context.Context) (err error) {
return
}
type ImTenantListModel struct {
Id int64 `json:"id" dc:"ID"`
Code string `json:"code" dc:"租户编码"`
Name string `json:"name" dc:"租户名称"`
TenantDesc string `json:"tenantDesc" dc:"租户描述"`
DbName string `json:"dbName" dc:"业务库名"`
State int `json:"state" dc:"状态"`
MaxUsers uint64 `json:"maxUsers" dc:"最大用户数"`
MaxGroups uint64 `json:"maxGroups" dc:"最大群组数"`
MaxGroupMembers uint `json:"maxGroupMembers" dc:"最大群成员数"`
DbDsnConfigured bool `json:"dbDsnConfigured" dc:"是否配置独立连接串"`
CreatedBy int64 `json:"createdBy" dc:"创建者"`
CreatedBySumma *hook.MemberSumma `json:"createdBySumma" dc:"创建者摘要信息"`
UpdatedBy int64 `json:"updatedBy" dc:"更新者"`
UpdatedBySumma *hook.MemberSumma `json:"updatedBySumma" dc:"更新者摘要信息"`
CreatedAt *gtime.Time `json:"createdAt" dc:"创建时间"`
UpdatedAt *gtime.Time `json:"updatedAt" dc:"修改时间"`
}
// ImTenantStatusInp 更新IM租户状态
type ImTenantStatusInp struct {
Id int64 `json:"id" v:"required#ID不能为空" dc:"ID"`
State int `json:"state" dc:"状态"`
}
func (in *ImTenantStatusInp) Filter(ctx context.Context) (err error) {
if in.Id <= 0 {
return gerror.New("ID不能为空")
}
if !dict.HasOptionKey(consts.ImTenantStateOptions, in.State) {
return gerror.New("租户状态不正确")
}
return
}
type ImTenantStatusModel struct{}
@@ -0,0 +1,99 @@
// Package sysin
// @Link https://github.com/bufanyun/hotgo
// @Copyright Copyright (c) 2026 HotGo
// @Author Ms <133814250@qq.com>
// @License https://github.com/bufanyun/hotgo/blob/master/LICENSE
package sysin
import (
"context"
"hotgo/internal/model/input/form"
"github.com/gogf/gf/v2/errors/gerror"
"github.com/gogf/gf/v2/os/gtime"
)
// ImUserListInp 获取IM用户列表
type ImUserListInp struct {
form.PageReq
TenantId int64 `json:"tenantId" dc:"租户ID"`
UserId string `json:"userId" dc:"用户ID"`
Nickname string `json:"nickname" dc:"昵称"`
AuthName string `json:"authName" dc:"登录标识"`
State *int `json:"state" dc:"账号状态"`
CreatedAt []*gtime.Time `json:"createdAt" dc:"创建时间"`
LastSeen []*gtime.Time `json:"lastSeen" dc:"最后在线时间"`
}
func (in *ImUserListInp) Filter(ctx context.Context) (err error) {
if in.UserId != "" {
if _, err = ParseOptionalInt64(in.UserId); err != nil {
return err
}
}
return
}
type ImUserListModel struct {
TenantId int64 `json:"tenantId" dc:"租户ID"`
TenantName string `json:"tenantName" dc:"租户名称"`
UserId string `json:"userId" dc:"用户ID"`
Username string `json:"username" dc:"登录账号"`
Phone string `json:"phone" dc:"手机号"`
Nickname string `json:"nickname" dc:"昵称"`
PublicAccount string `json:"publicAccount" dc:"公开账号"`
Gender string `json:"gender" dc:"性别"`
Region string `json:"region" dc:"地区"`
State int `json:"state" dc:"账号状态"`
AuthVersion int `json:"authVersion" dc:"认证版本"`
PasswordUpdatedAt *gtime.Time `json:"passwordUpdatedAt" dc:"密码更新时间"`
AuthNames string `json:"authNames" dc:"登录标识"`
Credentials string `json:"credentials" dc:"凭证"`
LastSeen *gtime.Time `json:"lastSeen" dc:"最后在线时间"`
UserAgent string `json:"userAgent" dc:"客户端"`
CreatedAt *gtime.Time `json:"createdAt" dc:"创建时间"`
UpdatedAt *gtime.Time `json:"updatedAt" dc:"修改时间"`
}
// ImUserViewInp 获取指定IM用户信息
type ImUserViewInp struct {
TenantId int64 `json:"tenantId" dc:"租户ID"`
UserId string `json:"userId" v:"required#用户ID不能为空" dc:"用户ID"`
}
func (in *ImUserViewInp) Filter(ctx context.Context) (err error) {
if _, err = ParseOptionalInt64(in.UserId); err != nil {
return gerror.New("用户ID不能为空")
}
return
}
type ImUserPasswordResetInp struct {
TenantId int64 `json:"tenantId" dc:"租户ID"`
UserId string `json:"userId" v:"required#用户ID不能为空" dc:"用户ID"`
NewPassword string `json:"newPassword" v:"required#新密码不能为空" dc:"新密码"`
}
func (in *ImUserPasswordResetInp) Filter(ctx context.Context) error {
if _, err := ParseOptionalInt64(in.UserId); err != nil {
return err
}
if len([]rune(in.NewPassword)) < 3 {
return gerror.New("新密码长度不能少于3位")
}
if len([]byte(in.NewPassword)) > 72 {
return gerror.New("新密码长度不能超过72字节")
}
return nil
}
type ImUserViewModel struct {
ImUserListModel
Signature string `json:"signature" dc:"个性签名"`
AvatarFileId string `json:"avatarFileId" dc:"头像文件ID"`
BirthDate string `json:"birthDate" dc:"出生日期"`
ProfileJson string `json:"profileJson" dc:"Tinode Public资料"`
TrustedJson string `json:"trustedJson" dc:"Tinode Trusted资料"`
AccessJson string `json:"accessJson" dc:"访问权限"`
ProfileVersion int `json:"profileVersion" dc:"资料版本"`
}
@@ -0,0 +1,118 @@
// Package sysin
// @Link https://github.com/bufanyun/hotgo
// @Copyright Copyright (c) 2026 HotGo
// @Author Ms <133814250@qq.com>
// @License https://github.com/bufanyun/hotgo/blob/master/LICENSE
package sysin
import (
"context"
"hotgo/internal/consts"
"hotgo/internal/library/dict"
"hotgo/internal/model/input/form"
"github.com/gogf/gf/v2/errors/gerror"
"github.com/gogf/gf/v2/os/gtime"
)
const (
ImWorkOrderTypeAbuse = "abuse"
ImWorkOrderTypeFeedback = "feedback"
)
// ImWorkOrderListInp 获取IM举报反馈工单列表
type ImWorkOrderListInp struct {
form.PageReq
TenantId int64 `json:"tenantId" dc:"租户ID"`
Type string `json:"type" dc:"工单类型"`
UserId int64 `json:"userId" dc:"提交人ID"`
Status string `json:"status" dc:"状态"`
Keyword string `json:"keyword" dc:"关键词"`
CreatedAt []*gtime.Time `json:"createdAt" dc:"创建时间"`
}
func (in *ImWorkOrderListInp) Filter(ctx context.Context) (err error) {
if in.TenantId <= 0 {
return gerror.New("请选择租户")
}
if in.Type == "" {
return gerror.New("请选择工单类型")
}
if in.Type != ImWorkOrderTypeAbuse && in.Type != ImWorkOrderTypeFeedback {
return gerror.New("工单类型不正确")
}
return
}
type ImWorkOrderListModel struct {
TenantId int64 `json:"tenantId" dc:"租户ID"`
TenantName string `json:"tenantName" dc:"租户名称"`
Type string `json:"type" dc:"工单类型"`
Id int64 `json:"id" dc:"ID"`
OrderId string `json:"orderId" dc:"工单ID"`
UserId int64 `json:"userId" dc:"提交人ID"`
TargetType string `json:"targetType" dc:"目标类型"`
TargetSummary string `json:"targetSummary" dc:"目标摘要"`
Category string `json:"category" dc:"分类"`
Content string `json:"content" dc:"内容"`
Status string `json:"status" dc:"状态"`
CreatedAt *gtime.Time `json:"createdAt" dc:"创建时间"`
UpdatedAt *gtime.Time `json:"updatedAt" dc:"修改时间"`
}
// ImWorkOrderViewInp 获取指定IM举报反馈工单信息
type ImWorkOrderViewInp struct {
TenantId int64 `json:"tenantId" v:"required#租户ID不能为空" dc:"租户ID"`
Type string `json:"type" v:"required#工单类型不能为空" dc:"工单类型"`
Id int64 `json:"id" v:"required#ID不能为空" dc:"ID"`
}
func (in *ImWorkOrderViewInp) Filter(ctx context.Context) (err error) {
if in.TenantId <= 0 {
return gerror.New("请选择租户")
}
if in.Type != ImWorkOrderTypeAbuse && in.Type != ImWorkOrderTypeFeedback {
return gerror.New("工单类型不正确")
}
if in.Id <= 0 {
return gerror.New("ID不能为空")
}
return
}
type ImWorkOrderViewModel struct {
ImWorkOrderListModel
Description string `json:"description" dc:"描述"`
Contact string `json:"contact" dc:"联系方式"`
EvidenceJson string `json:"evidenceJson" dc:"证据/附件"`
SnapshotJson string `json:"snapshotJson" dc:"快照"`
ClientContextJson string `json:"clientContextJson" dc:"客户端上下文"`
IncludeDiagnostics int `json:"includeDiagnostics" dc:"是否包含诊断"`
IdempotencyKey string `json:"idempotencyKey" dc:"幂等键"`
}
// ImWorkOrderStatusInp 更新IM举报反馈工单状态
type ImWorkOrderStatusInp struct {
TenantId int64 `json:"tenantId" v:"required#租户ID不能为空" dc:"租户ID"`
Type string `json:"type" v:"required#工单类型不能为空" dc:"工单类型"`
Id int64 `json:"id" v:"required#ID不能为空" dc:"ID"`
Status string `json:"status" dc:"状态"`
}
func (in *ImWorkOrderStatusInp) Filter(ctx context.Context) (err error) {
if in.TenantId <= 0 {
return gerror.New("请选择租户")
}
if in.Type != ImWorkOrderTypeAbuse && in.Type != ImWorkOrderTypeFeedback {
return gerror.New("工单类型不正确")
}
if in.Id <= 0 {
return gerror.New("ID不能为空")
}
if !dict.HasOptionKey(consts.ImWorkOrderStatusOptions, in.Status) {
return gerror.New("工单状态不正确")
}
return
}
type ImWorkOrderStatusModel struct{}
@@ -0,0 +1,12 @@
// Package genrouter
// @Link https://github.com/bufanyun/hotgo
// @Copyright Copyright (c) 2026 HotGo
// @Author Ms <133814250@qq.com>
// @License https://github.com/bufanyun/hotgo/blob/master/LICENSE
package genrouter
import "hotgo/internal/controller/admin/sys"
func init() {
LoginRequiredRouter = append(LoginRequiredRouter, sys.ImAuditLog) // IM审计日志
}
@@ -0,0 +1,7 @@
package genrouter
import "hotgo/internal/controller/admin/sys"
func init() {
LoginRequiredRouter = append(LoginRequiredRouter, sys.ImGroup) // IM群组
}
@@ -0,0 +1,7 @@
package genrouter
import "hotgo/internal/controller/admin/sys"
func init() {
LoginRequiredRouter = append(LoginRequiredRouter, sys.ImInvite) // IM注册邀请码
}
@@ -0,0 +1,7 @@
package genrouter
import "hotgo/internal/controller/admin/sys"
func init() {
LoginRequiredRouter = append(LoginRequiredRouter, sys.ImRealname) // IM实名审核
}
@@ -0,0 +1,7 @@
package genrouter
import "hotgo/internal/controller/admin/sys"
func init() {
LoginRequiredRouter = append(LoginRequiredRouter, sys.ImRegistration) // IM注册配置
}
@@ -0,0 +1,7 @@
package genrouter
import "hotgo/internal/controller/admin/sys"
func init() {
LoginRequiredRouter = append(LoginRequiredRouter, sys.ImTenant) // IM租户
}
@@ -0,0 +1,7 @@
package genrouter
import "hotgo/internal/controller/admin/sys"
func init() {
LoginRequiredRouter = append(LoginRequiredRouter, sys.ImUser) // IM用户
}
@@ -0,0 +1,7 @@
package genrouter
import "hotgo/internal/controller/admin/sys"
func init() {
LoginRequiredRouter = append(LoginRequiredRouter, sys.ImWorkOrder) // IM举报反馈
}
+180
View File
@@ -171,6 +171,90 @@ type (
// Switch 更新CURD列表开关
Switch(ctx context.Context, in *sysin.CurdDemoSwitchInp) (err error)
}
ISysImTenant interface {
// Model IM租户ORM模型
Model(ctx context.Context, option ...*handler.Option) *gdb.Model
// List 获取IM租户列表
List(ctx context.Context, in *sysin.ImTenantListInp) (list []*sysin.ImTenantListModel, totalCount int, err error)
// Edit 修改/新增IM租户
Edit(ctx context.Context, in *sysin.ImTenantEditInp) (err error)
// View 获取IM租户指定信息
View(ctx context.Context, in *sysin.ImTenantViewInp) (res *sysin.ImTenantViewModel, err error)
// Status 更新IM租户状态
Status(ctx context.Context, in *sysin.ImTenantStatusInp) (err error)
}
ISysImRegistration interface {
// GetConfig 获取IM注册配置
GetConfig(ctx context.Context) (res *sysin.ImRegistrationConfigModel, err error)
// UpdateConfig 更新IM注册配置
UpdateConfig(ctx context.Context, in *sysin.ImRegistrationConfigUpdateInp) (err error)
}
ISysImInvite interface {
// List 查询IM注册邀请码
List(ctx context.Context, in *sysin.ImInviteListInp) (list []*sysin.ImInviteCodeModel, totalCount int, err error)
// Create 单个生成IM注册邀请码
Create(ctx context.Context, in *sysin.ImInviteCreateInp) (res *sysin.ImInviteCodeModel, err error)
// BatchCreate 批量生成IM注册邀请码
BatchCreate(ctx context.Context, in *sysin.ImInviteBatchCreateInp) (res *sysin.ImInviteBatchModel, err error)
// BatchView 查看批次详情
BatchView(ctx context.Context, in *sysin.ImInviteBatchViewInp) (res *sysin.ImInviteBatchDetailModel, err error)
// Revoke 撤销邀请码
Revoke(ctx context.Context, in *sysin.ImInviteRevokeInp) (err error)
// BatchRevoke 批量撤销邀请码
BatchRevoke(ctx context.Context, in *sysin.ImInviteBatchRevokeInp) (res *sysin.ImInviteBatchModel, err error)
// Export 导出邀请码
Export(ctx context.Context, in *sysin.ImInviteListInp) (err error)
}
ISysImUser interface {
// List 获取IM用户列表
List(ctx context.Context, in *sysin.ImUserListInp) (list []*sysin.ImUserListModel, totalCount int, err error)
// View 获取IM用户指定信息
View(ctx context.Context, in *sysin.ImUserViewInp) (res *sysin.ImUserViewModel, err error)
// ResetPassword 管理员重置IM用户密码
ResetPassword(ctx context.Context, in *sysin.ImUserPasswordResetInp) (err error)
}
ISysImGroup interface {
// List 获取IM群组列表
List(ctx context.Context, in *sysin.ImGroupListInp) (list []*sysin.ImGroupListModel, totalCount int, err error)
// View 获取IM群组指定信息
View(ctx context.Context, in *sysin.ImGroupViewInp) (res *sysin.ImGroupViewModel, err error)
// MemberList 获取IM群成员列表
MemberList(ctx context.Context, in *sysin.ImGroupMemberListInp) (list []*sysin.ImGroupMemberListModel, totalCount int, err error)
// Governance 获取IM群治理配置
Governance(ctx context.Context, in *sysin.ImGroupTopicInp) (res *sysin.ImGroupGovernanceModel, err error)
// JoinRequestList 获取IM入群申请列表
JoinRequestList(ctx context.Context, in *sysin.ImGroupJoinRequestListInp) (list []*sysin.ImGroupJoinRequestListModel, totalCount int, err error)
// InvitationList 获取IM群邀请列表
InvitationList(ctx context.Context, in *sysin.ImGroupInvitationListInp) (list []*sysin.ImGroupInvitationListModel, totalCount int, err error)
// AnnouncementList 获取IM群公告列表
AnnouncementList(ctx context.Context, in *sysin.ImGroupAnnouncementListInp) (list []*sysin.ImGroupAnnouncementListModel, totalCount int, err error)
// MuteList 获取IM群禁言列表
MuteList(ctx context.Context, in *sysin.ImGroupMuteListInp) (list []*sysin.ImGroupMuteListModel, totalCount int, err error)
// AdminLogList 获取IM群管理日志列表
AdminLogList(ctx context.Context, in *sysin.ImGroupAdminLogListInp) (list []*sysin.ImGroupAdminLogListModel, totalCount int, err error)
}
ISysImRealname interface {
// List 获取IM实名审核列表
List(ctx context.Context, in *sysin.ImRealnameListInp) (list []*sysin.ImRealnameListModel, totalCount int, err error)
// View 获取IM实名审核指定信息
View(ctx context.Context, in *sysin.ImRealnameViewInp) (res *sysin.ImRealnameViewModel, err error)
// Review 审核IM实名
Review(ctx context.Context, in *sysin.ImRealnameReviewInp) (err error)
}
ISysImWorkOrder interface {
// List 获取IM举报反馈工单列表
List(ctx context.Context, in *sysin.ImWorkOrderListInp) (list []*sysin.ImWorkOrderListModel, totalCount int, err error)
// View 获取IM举报反馈工单指定信息
View(ctx context.Context, in *sysin.ImWorkOrderViewInp) (res *sysin.ImWorkOrderViewModel, err error)
// Status 更新IM举报反馈工单状态
Status(ctx context.Context, in *sysin.ImWorkOrderStatusInp) (err error)
}
ISysImAuditLog interface {
// List 获取IM审计日志列表
List(ctx context.Context, in *sysin.ImAuditLogListInp) (list []*sysin.ImAuditLogListModel, totalCount int, err error)
// View 获取IM审计日志指定信息
View(ctx context.Context, in *sysin.ImAuditLogViewInp) (res *sysin.ImAuditLogViewModel, err error)
}
ISysDictData interface {
// Delete 删除
Delete(ctx context.Context, in *sysin.DictDataDeleteInp) error
@@ -414,6 +498,14 @@ var (
localSysCron ISysCron
localSysCronGroup ISysCronGroup
localSysCurdDemo ISysCurdDemo
localSysImTenant ISysImTenant
localSysImRegistration ISysImRegistration
localSysImInvite ISysImInvite
localSysImUser ISysImUser
localSysImGroup ISysImGroup
localSysImRealname ISysImRealname
localSysImWorkOrder ISysImWorkOrder
localSysImAuditLog ISysImAuditLog
localSysDictData ISysDictData
localSysDictType ISysDictType
localSysEmsLog ISysEmsLog
@@ -517,6 +609,94 @@ func RegisterSysCurdDemo(i ISysCurdDemo) {
localSysCurdDemo = i
}
func SysImTenant() ISysImTenant {
if localSysImTenant == nil {
panic("implement not found for interface ISysImTenant, forgot register?")
}
return localSysImTenant
}
func RegisterSysImTenant(i ISysImTenant) {
localSysImTenant = i
}
func SysImRegistration() ISysImRegistration {
if localSysImRegistration == nil {
panic("implement not found for interface ISysImRegistration, forgot register?")
}
return localSysImRegistration
}
func RegisterSysImRegistration(i ISysImRegistration) {
localSysImRegistration = i
}
func SysImInvite() ISysImInvite {
if localSysImInvite == nil {
panic("implement not found for interface ISysImInvite, forgot register?")
}
return localSysImInvite
}
func RegisterSysImInvite(i ISysImInvite) {
localSysImInvite = i
}
func SysImUser() ISysImUser {
if localSysImUser == nil {
panic("implement not found for interface ISysImUser, forgot register?")
}
return localSysImUser
}
func RegisterSysImUser(i ISysImUser) {
localSysImUser = i
}
func SysImGroup() ISysImGroup {
if localSysImGroup == nil {
panic("implement not found for interface ISysImGroup, forgot register?")
}
return localSysImGroup
}
func RegisterSysImGroup(i ISysImGroup) {
localSysImGroup = i
}
func SysImRealname() ISysImRealname {
if localSysImRealname == nil {
panic("implement not found for interface ISysImRealname, forgot register?")
}
return localSysImRealname
}
func RegisterSysImRealname(i ISysImRealname) {
localSysImRealname = i
}
func SysImWorkOrder() ISysImWorkOrder {
if localSysImWorkOrder == nil {
panic("implement not found for interface ISysImWorkOrder, forgot register?")
}
return localSysImWorkOrder
}
func RegisterSysImWorkOrder(i ISysImWorkOrder) {
localSysImWorkOrder = i
}
func SysImAuditLog() ISysImAuditLog {
if localSysImAuditLog == nil {
panic("implement not found for interface ISysImAuditLog, forgot register?")
}
return localSysImAuditLog
}
func RegisterSysImAuditLog(i ISysImAuditLog) {
localSysImAuditLog = i
}
func SysDictData() ISysDictData {
if localSysDictData == nil {
panic("implement not found for interface ISysDictData, forgot register?")
+1
View File
@@ -7,6 +7,7 @@ package main
import (
_ "hotgo/internal/packed"
_ "time/tzdata"
_ "github.com/gogf/gf/contrib/drivers/mysql/v2"
_ "github.com/gogf/gf/contrib/drivers/pgsql/v2"
+8 -1
View File
@@ -237,6 +237,9 @@ database:
link: "mysql:hotgo:hg123456.@tcp(127.0.0.1:3306)/hotgo?loc=Local&parseTime=true&charset=utf8mb4"
debug: true
Prefix: "hg_"
imRegistry:
link: "mysql:hotgo:hg123456.@tcp(127.0.0.1:3306)/hotgo?loc=Local&parseTime=true&charset=utf8mb4"
debug: true
# 链路追踪
@@ -300,4 +303,8 @@ hggen:
addon:
srcPath: "./resource/generate/default/addon" # 生成模板路径
webApiPath: "../web/src/api/addons/{$name}" # webApi生成路径
webViewsPath: "../web/src/views/addons/{$name}" # web页面生成路径
webViewsPath: "../web/src/views/addons/{$name}" # web页面生成路径
imDirectDb:
# Each HotGo writer must use a unique ID which does not overlap an IM node.
snowflakeWorkerId: 1023
+15
View File
@@ -0,0 +1,15 @@
FROM alpine:3.20
WORKDIR /app
RUN apk add --no-cache ca-certificates tzdata
COPY temp/linux_arm64/hotgo /app/hotgo
COPY hack /app/hack
COPY manifest/config /app/manifest/config
COPY resource /app/resource
COPY storage /app/storage
EXPOSE 8000
CMD ["/app/hotgo"]
@@ -0,0 +1,29 @@
SET @now = NOW();
SELECT @imRootId := `id` FROM `hg_admin_menu` WHERE `name` = 'imManager' LIMIT 1;
INSERT INTO `hg_admin_menu` (`id`, `pid`, `title`, `name`, `path`, `icon`, `type`, `redirect`, `permissions`, `permission_name`, `component`, `always_show`, `active_menu`, `is_root`, `is_frame`, `frame_src`, `keep_alive`, `hidden`, `affix`, `level`, `tree`, `sort`, `remark`, `status`, `created_at`, `updated_at`)
SELECT NULL, '0', 'IM管理', 'imManager', '/im', '', '1', '/im/tenant/index', '', '', 'LAYOUT', '1', '', '1', '0', '', '0', '0', '0', '1', '', '200', '', '1', @now, @now
WHERE @imRootId IS NULL;
SELECT @imRootId := `id` FROM `hg_admin_menu` WHERE `name` = 'imManager' LIMIT 1;
SELECT @auditDirId := `id` FROM `hg_admin_menu` WHERE `name` = 'imAuditLog' LIMIT 1;
INSERT INTO `hg_admin_menu` (`id`, `pid`, `title`, `name`, `path`, `icon`, `type`, `redirect`, `permissions`, `permission_name`, `component`, `always_show`, `active_menu`, `is_root`, `is_frame`, `frame_src`, `keep_alive`, `hidden`, `affix`, `level`, `tree`, `sort`, `remark`, `status`, `created_at`, `updated_at`)
SELECT NULL, @imRootId, '审计日志', 'imAuditLog', 'audit-log', '', '1', '/im/audit-log/index', '', '', 'ParentLayout', '1', '', '0', '0', '', '0', '0', '0', '2', CONCAT('tr_', @imRootId, ' '), '60', '', '1', @now, @now
WHERE @auditDirId IS NULL;
SELECT @auditDirId := `id` FROM `hg_admin_menu` WHERE `name` = 'imAuditLog' LIMIT 1;
SELECT @auditListId := `id` FROM `hg_admin_menu` WHERE `name` = 'imAuditLogIndex' LIMIT 1;
INSERT INTO `hg_admin_menu` (`id`, `pid`, `title`, `name`, `path`, `icon`, `type`, `redirect`, `permissions`, `permission_name`, `component`, `always_show`, `active_menu`, `is_root`, `is_frame`, `frame_src`, `keep_alive`, `hidden`, `affix`, `level`, `tree`, `sort`, `remark`, `status`, `created_at`, `updated_at`)
SELECT NULL, @auditDirId, '审计列表', 'imAuditLogIndex', 'index', '', '2', '', '/imAuditLog/list', '', '/imAuditLog/index', '1', 'imAuditLog', '0', '0', '', '0', '0', '0', '3', CONCAT('tr_', @imRootId, ' tr_', @auditDirId, ' '), '10', '', '1', @now, @now
WHERE @auditListId IS NULL;
SELECT @auditListId := `id` FROM `hg_admin_menu` WHERE `name` = 'imAuditLogIndex' LIMIT 1;
INSERT INTO `hg_admin_menu` (`id`, `pid`, `title`, `name`, `path`, `icon`, `type`, `redirect`, `permissions`, `permission_name`, `component`, `always_show`, `active_menu`, `is_root`, `is_frame`, `frame_src`, `keep_alive`, `hidden`, `affix`, `level`, `tree`, `sort`, `remark`, `status`, `created_at`, `updated_at`)
SELECT NULL, @auditListId, '审计详情', 'imAuditLogView', '', '', '3', '', '/imAuditLog/view', '', '', '1', '', '0', '0', '', '0', '1', '0', '4', CONCAT('tr_', @imRootId, ' tr_', @auditDirId, ' tr_', @auditListId, ' '), '10', '', '1', @now, @now
WHERE NOT EXISTS (SELECT 1 FROM `hg_admin_menu` WHERE `name` = 'imAuditLogView');
@@ -0,0 +1,22 @@
CREATE TABLE IF NOT EXISTS im_audit_logs (
id BIGINT NOT NULL AUTO_INCREMENT,
actor_uid BIGINT NOT NULL DEFAULT 0,
actor_role VARCHAR(64) NOT NULL DEFAULT '',
action VARCHAR(128) NOT NULL,
target_type VARCHAR(64) NOT NULL DEFAULT '',
target_id VARCHAR(128) NOT NULL DEFAULT '',
before_snapshot JSON,
after_snapshot JSON,
reason VARCHAR(512) NOT NULL DEFAULT '',
request_id VARCHAR(64) NOT NULL DEFAULT '',
ip VARCHAR(64) NOT NULL DEFAULT '',
user_agent VARCHAR(512) NOT NULL DEFAULT '',
metadata JSON,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
PRIMARY KEY (id),
INDEX idx_im_audit_logs_actor (actor_uid, id DESC),
INDEX idx_im_audit_logs_action (action, id DESC),
INDEX idx_im_audit_logs_target (target_type, target_id, id DESC),
INDEX idx_im_audit_logs_request (request_id),
INDEX idx_im_audit_logs_created (created_at, id DESC)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='IM租户审计日志';
@@ -0,0 +1,57 @@
SET @now = NOW();
SELECT @imRootId := `id` FROM `hg_admin_menu` WHERE `name` = 'imManager' LIMIT 1;
INSERT INTO `hg_admin_menu` (`id`, `pid`, `title`, `name`, `path`, `icon`, `type`, `redirect`, `permissions`, `permission_name`, `component`, `always_show`, `active_menu`, `is_root`, `is_frame`, `frame_src`, `keep_alive`, `hidden`, `affix`, `level`, `tree`, `sort`, `remark`, `status`, `created_at`, `updated_at`)
SELECT NULL, '0', 'IM管理', 'imManager', '/im', '', '1', '/im/tenant/index', '', '', 'LAYOUT', '1', '', '1', '0', '', '0', '0', '0', '1', '', '200', '', '1', @now, @now
WHERE @imRootId IS NULL;
SELECT @imRootId := `id` FROM `hg_admin_menu` WHERE `name` = 'imManager' LIMIT 1;
SELECT @groupDirId := `id` FROM `hg_admin_menu` WHERE `name` = 'imGroup' LIMIT 1;
INSERT INTO `hg_admin_menu` (`id`, `pid`, `title`, `name`, `path`, `icon`, `type`, `redirect`, `permissions`, `permission_name`, `component`, `always_show`, `active_menu`, `is_root`, `is_frame`, `frame_src`, `keep_alive`, `hidden`, `affix`, `level`, `tree`, `sort`, `remark`, `status`, `created_at`, `updated_at`)
SELECT NULL, @imRootId, '群组管理', 'imGroup', 'group', '', '1', '/im/group/index', '', '', 'ParentLayout', '1', '', '0', '0', '', '0', '0', '0', '2', CONCAT('tr_', @imRootId, ' '), '30', '', '1', @now, @now
WHERE @groupDirId IS NULL;
SELECT @groupDirId := `id` FROM `hg_admin_menu` WHERE `name` = 'imGroup' LIMIT 1;
SELECT @groupListId := `id` FROM `hg_admin_menu` WHERE `name` = 'imGroupIndex' LIMIT 1;
INSERT INTO `hg_admin_menu` (`id`, `pid`, `title`, `name`, `path`, `icon`, `type`, `redirect`, `permissions`, `permission_name`, `component`, `always_show`, `active_menu`, `is_root`, `is_frame`, `frame_src`, `keep_alive`, `hidden`, `affix`, `level`, `tree`, `sort`, `remark`, `status`, `created_at`, `updated_at`)
SELECT NULL, @groupDirId, '群组列表', 'imGroupIndex', 'index', '', '2', '', '/imGroup/list', '', '/imGroup/index', '1', 'imGroup', '0', '0', '', '0', '0', '0', '3', CONCAT('tr_', @imRootId, ' tr_', @groupDirId, ' '), '10', '', '1', @now, @now
WHERE @groupListId IS NULL;
SELECT @groupListId := `id` FROM `hg_admin_menu` WHERE `name` = 'imGroupIndex' LIMIT 1;
INSERT INTO `hg_admin_menu` (`id`, `pid`, `title`, `name`, `path`, `icon`, `type`, `redirect`, `permissions`, `permission_name`, `component`, `always_show`, `active_menu`, `is_root`, `is_frame`, `frame_src`, `keep_alive`, `hidden`, `affix`, `level`, `tree`, `sort`, `remark`, `status`, `created_at`, `updated_at`)
SELECT NULL, @groupListId, '群组详情', 'imGroupView', '', '', '3', '', '/imGroup/view', '', '', '1', '', '0', '0', '', '0', '1', '0', '4', CONCAT('tr_', @imRootId, ' tr_', @groupDirId, ' tr_', @groupListId, ' '), '10', '', '1', @now, @now
WHERE NOT EXISTS (SELECT 1 FROM `hg_admin_menu` WHERE `name` = 'imGroupView');
INSERT INTO `hg_admin_menu` (`id`, `pid`, `title`, `name`, `path`, `icon`, `type`, `redirect`, `permissions`, `permission_name`, `component`, `always_show`, `active_menu`, `is_root`, `is_frame`, `frame_src`, `keep_alive`, `hidden`, `affix`, `level`, `tree`, `sort`, `remark`, `status`, `created_at`, `updated_at`)
SELECT NULL, @groupListId, '群成员列表', 'imGroupMemberList', '', '', '3', '', '/imGroup/memberList', '', '', '1', '', '0', '0', '', '0', '1', '0', '4', CONCAT('tr_', @imRootId, ' tr_', @groupDirId, ' tr_', @groupListId, ' '), '20', '', '1', @now, @now
WHERE NOT EXISTS (SELECT 1 FROM `hg_admin_menu` WHERE `name` = 'imGroupMemberList');
INSERT INTO `hg_admin_menu` (`id`, `pid`, `title`, `name`, `path`, `icon`, `type`, `redirect`, `permissions`, `permission_name`, `component`, `always_show`, `active_menu`, `is_root`, `is_frame`, `frame_src`, `keep_alive`, `hidden`, `affix`, `level`, `tree`, `sort`, `remark`, `status`, `created_at`, `updated_at`)
SELECT NULL, @groupListId, '群治理配置', 'imGroupGovernance', '', '', '3', '', '/imGroup/governance', '', '', '1', '', '0', '0', '', '0', '1', '0', '4', CONCAT('tr_', @imRootId, ' tr_', @groupDirId, ' tr_', @groupListId, ' '), '30', '', '1', @now, @now
WHERE NOT EXISTS (SELECT 1 FROM `hg_admin_menu` WHERE `name` = 'imGroupGovernance');
INSERT INTO `hg_admin_menu` (`id`, `pid`, `title`, `name`, `path`, `icon`, `type`, `redirect`, `permissions`, `permission_name`, `component`, `always_show`, `active_menu`, `is_root`, `is_frame`, `frame_src`, `keep_alive`, `hidden`, `affix`, `level`, `tree`, `sort`, `remark`, `status`, `created_at`, `updated_at`)
SELECT NULL, @groupListId, '入群申请列表', 'imGroupJoinRequestList', '', '', '3', '', '/imGroup/joinRequestList', '', '', '1', '', '0', '0', '', '0', '1', '0', '4', CONCAT('tr_', @imRootId, ' tr_', @groupDirId, ' tr_', @groupListId, ' '), '40', '', '1', @now, @now
WHERE NOT EXISTS (SELECT 1 FROM `hg_admin_menu` WHERE `name` = 'imGroupJoinRequestList');
INSERT INTO `hg_admin_menu` (`id`, `pid`, `title`, `name`, `path`, `icon`, `type`, `redirect`, `permissions`, `permission_name`, `component`, `always_show`, `active_menu`, `is_root`, `is_frame`, `frame_src`, `keep_alive`, `hidden`, `affix`, `level`, `tree`, `sort`, `remark`, `status`, `created_at`, `updated_at`)
SELECT NULL, @groupListId, '群邀请列表', 'imGroupInvitationList', '', '', '3', '', '/imGroup/invitationList', '', '', '1', '', '0', '0', '', '0', '1', '0', '4', CONCAT('tr_', @imRootId, ' tr_', @groupDirId, ' tr_', @groupListId, ' '), '50', '', '1', @now, @now
WHERE NOT EXISTS (SELECT 1 FROM `hg_admin_menu` WHERE `name` = 'imGroupInvitationList');
INSERT INTO `hg_admin_menu` (`id`, `pid`, `title`, `name`, `path`, `icon`, `type`, `redirect`, `permissions`, `permission_name`, `component`, `always_show`, `active_menu`, `is_root`, `is_frame`, `frame_src`, `keep_alive`, `hidden`, `affix`, `level`, `tree`, `sort`, `remark`, `status`, `created_at`, `updated_at`)
SELECT NULL, @groupListId, '群公告列表', 'imGroupAnnouncementList', '', '', '3', '', '/imGroup/announcementList', '', '', '1', '', '0', '0', '', '0', '1', '0', '4', CONCAT('tr_', @imRootId, ' tr_', @groupDirId, ' tr_', @groupListId, ' '), '60', '', '1', @now, @now
WHERE NOT EXISTS (SELECT 1 FROM `hg_admin_menu` WHERE `name` = 'imGroupAnnouncementList');
INSERT INTO `hg_admin_menu` (`id`, `pid`, `title`, `name`, `path`, `icon`, `type`, `redirect`, `permissions`, `permission_name`, `component`, `always_show`, `active_menu`, `is_root`, `is_frame`, `frame_src`, `keep_alive`, `hidden`, `affix`, `level`, `tree`, `sort`, `remark`, `status`, `created_at`, `updated_at`)
SELECT NULL, @groupListId, '群禁言列表', 'imGroupMuteList', '', '', '3', '', '/imGroup/muteList', '', '', '1', '', '0', '0', '', '0', '1', '0', '4', CONCAT('tr_', @imRootId, ' tr_', @groupDirId, ' tr_', @groupListId, ' '), '70', '', '1', @now, @now
WHERE NOT EXISTS (SELECT 1 FROM `hg_admin_menu` WHERE `name` = 'imGroupMuteList');
INSERT INTO `hg_admin_menu` (`id`, `pid`, `title`, `name`, `path`, `icon`, `type`, `redirect`, `permissions`, `permission_name`, `component`, `always_show`, `active_menu`, `is_root`, `is_frame`, `frame_src`, `keep_alive`, `hidden`, `affix`, `level`, `tree`, `sort`, `remark`, `status`, `created_at`, `updated_at`)
SELECT NULL, @groupListId, '群管理日志', 'imGroupAdminLogList', '', '', '3', '', '/imGroup/adminLogList', '', '', '1', '', '0', '0', '', '0', '1', '0', '4', CONCAT('tr_', @imRootId, ' tr_', @groupDirId, ' tr_', @groupListId, ' '), '80', '', '1', @now, @now
WHERE NOT EXISTS (SELECT 1 FROM `hg_admin_menu` WHERE `name` = 'imGroupAdminLogList');
@@ -0,0 +1,33 @@
SET @now = NOW();
SELECT @imRootId := `id` FROM `hg_admin_menu` WHERE `name` = 'imManager' LIMIT 1;
INSERT INTO `hg_admin_menu` (`id`, `pid`, `title`, `name`, `path`, `icon`, `type`, `redirect`, `permissions`, `permission_name`, `component`, `always_show`, `active_menu`, `is_root`, `is_frame`, `frame_src`, `keep_alive`, `hidden`, `affix`, `level`, `tree`, `sort`, `remark`, `status`, `created_at`, `updated_at`)
SELECT NULL, '0', 'IM管理', 'imManager', '/im', '', '1', '/im/tenant/index', '', '', 'LAYOUT', '1', '', '1', '0', '', '0', '0', '0', '1', '', '200', '', '1', @now, @now
WHERE @imRootId IS NULL;
SELECT @imRootId := `id` FROM `hg_admin_menu` WHERE `name` = 'imManager' LIMIT 1;
SELECT @realnameDirId := `id` FROM `hg_admin_menu` WHERE `name` = 'imRealname' LIMIT 1;
INSERT INTO `hg_admin_menu` (`id`, `pid`, `title`, `name`, `path`, `icon`, `type`, `redirect`, `permissions`, `permission_name`, `component`, `always_show`, `active_menu`, `is_root`, `is_frame`, `frame_src`, `keep_alive`, `hidden`, `affix`, `level`, `tree`, `sort`, `remark`, `status`, `created_at`, `updated_at`)
SELECT NULL, @imRootId, '实名审核', 'imRealname', 'realname', '', '1', '/im/realname/index', '', '', 'ParentLayout', '1', '', '0', '0', '', '0', '0', '0', '2', CONCAT('tr_', @imRootId, ' '), '40', '', '1', @now, @now
WHERE @realnameDirId IS NULL;
SELECT @realnameDirId := `id` FROM `hg_admin_menu` WHERE `name` = 'imRealname' LIMIT 1;
SELECT @realnameListId := `id` FROM `hg_admin_menu` WHERE `name` = 'imRealnameIndex' LIMIT 1;
INSERT INTO `hg_admin_menu` (`id`, `pid`, `title`, `name`, `path`, `icon`, `type`, `redirect`, `permissions`, `permission_name`, `component`, `always_show`, `active_menu`, `is_root`, `is_frame`, `frame_src`, `keep_alive`, `hidden`, `affix`, `level`, `tree`, `sort`, `remark`, `status`, `created_at`, `updated_at`)
SELECT NULL, @realnameDirId, '审核列表', 'imRealnameIndex', 'index', '', '2', '', '/imRealname/list', '', '/imRealname/index', '1', 'imRealname', '0', '0', '', '0', '0', '0', '3', CONCAT('tr_', @imRootId, ' tr_', @realnameDirId, ' '), '10', '', '1', @now, @now
WHERE @realnameListId IS NULL;
SELECT @realnameListId := `id` FROM `hg_admin_menu` WHERE `name` = 'imRealnameIndex' LIMIT 1;
INSERT INTO `hg_admin_menu` (`id`, `pid`, `title`, `name`, `path`, `icon`, `type`, `redirect`, `permissions`, `permission_name`, `component`, `always_show`, `active_menu`, `is_root`, `is_frame`, `frame_src`, `keep_alive`, `hidden`, `affix`, `level`, `tree`, `sort`, `remark`, `status`, `created_at`, `updated_at`)
SELECT NULL, @realnameListId, '实名详情', 'imRealnameView', '', '', '3', '', '/imRealname/view', '', '', '1', '', '0', '0', '', '0', '1', '0', '4', CONCAT('tr_', @imRootId, ' tr_', @realnameDirId, ' tr_', @realnameListId, ' '), '10', '', '1', @now, @now
WHERE NOT EXISTS (SELECT 1 FROM `hg_admin_menu` WHERE `name` = 'imRealnameView');
INSERT INTO `hg_admin_menu` (`id`, `pid`, `title`, `name`, `path`, `icon`, `type`, `redirect`, `permissions`, `permission_name`, `component`, `always_show`, `active_menu`, `is_root`, `is_frame`, `frame_src`, `keep_alive`, `hidden`, `affix`, `level`, `tree`, `sort`, `remark`, `status`, `created_at`, `updated_at`)
SELECT NULL, @realnameListId, '实名审核操作', 'imRealnameReview', '', '', '3', '', '/imRealname/review', '', '', '1', '', '0', '0', '', '0', '1', '0', '4', CONCAT('tr_', @imRootId, ' tr_', @realnameDirId, ' tr_', @realnameListId, ' '), '20', '', '1', @now, @now
WHERE NOT EXISTS (SELECT 1 FROM `hg_admin_menu` WHERE `name` = 'imRealnameReview');
@@ -0,0 +1,37 @@
SET @now = NOW();
SELECT @imRootId := `id` FROM `hg_admin_menu` WHERE `name` = 'imManager' LIMIT 1;
INSERT INTO `hg_admin_menu` (`id`, `pid`, `title`, `name`, `path`, `icon`, `type`, `redirect`, `permissions`, `permission_name`, `component`, `always_show`, `active_menu`, `is_root`, `is_frame`, `frame_src`, `keep_alive`, `hidden`, `affix`, `level`, `tree`, `sort`, `remark`, `status`, `created_at`, `updated_at`)
SELECT NULL, '0', 'IM管理', 'imManager', '/im', '', '1', '/im/tenant/index', '', '', 'LAYOUT', '1', '', '1', '0', '', '0', '0', '0', '1', '', '200', '', '1', @now, @now
WHERE @imRootId IS NULL;
SELECT @imRootId := `id` FROM `hg_admin_menu` WHERE `name` = 'imManager' LIMIT 1;
SELECT @tenantDirId := `id` FROM `hg_admin_menu` WHERE `name` = 'imTenant' LIMIT 1;
INSERT INTO `hg_admin_menu` (`id`, `pid`, `title`, `name`, `path`, `icon`, `type`, `redirect`, `permissions`, `permission_name`, `component`, `always_show`, `active_menu`, `is_root`, `is_frame`, `frame_src`, `keep_alive`, `hidden`, `affix`, `level`, `tree`, `sort`, `remark`, `status`, `created_at`, `updated_at`)
SELECT NULL, @imRootId, '租户管理', 'imTenant', 'tenant', '', '1', '/im/tenant/index', '', '', 'ParentLayout', '1', '', '0', '0', '', '0', '0', '0', '2', CONCAT('tr_', @imRootId, ' '), '10', '', '1', @now, @now
WHERE @tenantDirId IS NULL;
SELECT @tenantDirId := `id` FROM `hg_admin_menu` WHERE `name` = 'imTenant' LIMIT 1;
SELECT @tenantListId := `id` FROM `hg_admin_menu` WHERE `name` = 'imTenantIndex' LIMIT 1;
INSERT INTO `hg_admin_menu` (`id`, `pid`, `title`, `name`, `path`, `icon`, `type`, `redirect`, `permissions`, `permission_name`, `component`, `always_show`, `active_menu`, `is_root`, `is_frame`, `frame_src`, `keep_alive`, `hidden`, `affix`, `level`, `tree`, `sort`, `remark`, `status`, `created_at`, `updated_at`)
SELECT NULL, @tenantDirId, '租户列表', 'imTenantIndex', 'index', '', '2', '', '/imTenant/list', '', '/imTenant/index', '1', 'imTenant', '0', '0', '', '0', '0', '0', '3', CONCAT('tr_', @imRootId, ' tr_', @tenantDirId, ' '), '10', '', '1', @now, @now
WHERE @tenantListId IS NULL;
SELECT @tenantListId := `id` FROM `hg_admin_menu` WHERE `name` = 'imTenantIndex' LIMIT 1;
INSERT INTO `hg_admin_menu` (`id`, `pid`, `title`, `name`, `path`, `icon`, `type`, `redirect`, `permissions`, `permission_name`, `component`, `always_show`, `active_menu`, `is_root`, `is_frame`, `frame_src`, `keep_alive`, `hidden`, `affix`, `level`, `tree`, `sort`, `remark`, `status`, `created_at`, `updated_at`)
SELECT NULL, @tenantListId, '租户详情', 'imTenantView', '', '', '3', '', '/imTenant/view', '', '', '1', '', '0', '0', '', '0', '1', '0', '4', CONCAT('tr_', @imRootId, ' tr_', @tenantDirId, ' tr_', @tenantListId, ' '), '10', '', '1', @now, @now
WHERE NOT EXISTS (SELECT 1 FROM `hg_admin_menu` WHERE `name` = 'imTenantView');
INSERT INTO `hg_admin_menu` (`id`, `pid`, `title`, `name`, `path`, `icon`, `type`, `redirect`, `permissions`, `permission_name`, `component`, `always_show`, `active_menu`, `is_root`, `is_frame`, `frame_src`, `keep_alive`, `hidden`, `affix`, `level`, `tree`, `sort`, `remark`, `status`, `created_at`, `updated_at`)
SELECT NULL, @tenantListId, '编辑/新增租户', 'imTenantEdit', '', '', '3', '', '/imTenant/edit', '', '', '1', '', '0', '0', '', '0', '1', '0', '4', CONCAT('tr_', @imRootId, ' tr_', @tenantDirId, ' tr_', @tenantListId, ' '), '20', '', '1', @now, @now
WHERE NOT EXISTS (SELECT 1 FROM `hg_admin_menu` WHERE `name` = 'imTenantEdit');
INSERT INTO `hg_admin_menu` (`id`, `pid`, `title`, `name`, `path`, `icon`, `type`, `redirect`, `permissions`, `permission_name`, `component`, `always_show`, `active_menu`, `is_root`, `is_frame`, `frame_src`, `keep_alive`, `hidden`, `affix`, `level`, `tree`, `sort`, `remark`, `status`, `created_at`, `updated_at`)
SELECT NULL, @tenantListId, '修改租户状态', 'imTenantStatus', '', '', '3', '', '/imTenant/status', '', '', '1', '', '0', '0', '', '0', '1', '0', '4', CONCAT('tr_', @imRootId, ' tr_', @tenantDirId, ' tr_', @tenantListId, ' '), '30', '', '1', @now, @now
WHERE NOT EXISTS (SELECT 1 FROM `hg_admin_menu` WHERE `name` = 'imTenantStatus');
@@ -0,0 +1,33 @@
SET @now = NOW();
SELECT @imRootId := `id` FROM `hg_admin_menu` WHERE `name` = 'imManager' LIMIT 1;
INSERT INTO `hg_admin_menu` (`id`, `pid`, `title`, `name`, `path`, `icon`, `type`, `redirect`, `permissions`, `permission_name`, `component`, `always_show`, `active_menu`, `is_root`, `is_frame`, `frame_src`, `keep_alive`, `hidden`, `affix`, `level`, `tree`, `sort`, `remark`, `status`, `created_at`, `updated_at`)
SELECT NULL, '0', 'IM管理', 'imManager', '/im', '', '1', '/im/tenant/index', '', '', 'LAYOUT', '1', '', '1', '0', '', '0', '0', '0', '1', '', '200', '', '1', @now, @now
WHERE @imRootId IS NULL;
SELECT @imRootId := `id` FROM `hg_admin_menu` WHERE `name` = 'imManager' LIMIT 1;
SELECT @userDirId := `id` FROM `hg_admin_menu` WHERE `name` = 'imUser' LIMIT 1;
INSERT INTO `hg_admin_menu` (`id`, `pid`, `title`, `name`, `path`, `icon`, `type`, `redirect`, `permissions`, `permission_name`, `component`, `always_show`, `active_menu`, `is_root`, `is_frame`, `frame_src`, `keep_alive`, `hidden`, `affix`, `level`, `tree`, `sort`, `remark`, `status`, `created_at`, `updated_at`)
SELECT NULL, @imRootId, '用户管理', 'imUser', 'user', '', '1', '/im/user/index', '', '', 'ParentLayout', '1', '', '0', '0', '', '0', '0', '0', '2', CONCAT('tr_', @imRootId, ' '), '20', '', '1', @now, @now
WHERE @userDirId IS NULL;
SELECT @userDirId := `id` FROM `hg_admin_menu` WHERE `name` = 'imUser' LIMIT 1;
SELECT @userListId := `id` FROM `hg_admin_menu` WHERE `name` = 'imUserIndex' LIMIT 1;
INSERT INTO `hg_admin_menu` (`id`, `pid`, `title`, `name`, `path`, `icon`, `type`, `redirect`, `permissions`, `permission_name`, `component`, `always_show`, `active_menu`, `is_root`, `is_frame`, `frame_src`, `keep_alive`, `hidden`, `affix`, `level`, `tree`, `sort`, `remark`, `status`, `created_at`, `updated_at`)
SELECT NULL, @userDirId, '用户列表', 'imUserIndex', 'index', '', '2', '', '/imUser/list', '', '/imUser/index', '1', 'imUser', '0', '0', '', '0', '0', '0', '3', CONCAT('tr_', @imRootId, ' tr_', @userDirId, ' '), '10', '', '1', @now, @now
WHERE @userListId IS NULL;
SELECT @userListId := `id` FROM `hg_admin_menu` WHERE `name` = 'imUserIndex' LIMIT 1;
INSERT INTO `hg_admin_menu` (`id`, `pid`, `title`, `name`, `path`, `icon`, `type`, `redirect`, `permissions`, `permission_name`, `component`, `always_show`, `active_menu`, `is_root`, `is_frame`, `frame_src`, `keep_alive`, `hidden`, `affix`, `level`, `tree`, `sort`, `remark`, `status`, `created_at`, `updated_at`)
SELECT NULL, @userListId, '用户详情', 'imUserView', '', '', '3', '', '/imUser/view', '', '', '1', '', '0', '0', '', '0', '1', '0', '4', CONCAT('tr_', @imRootId, ' tr_', @userDirId, ' tr_', @userListId, ' '), '10', '', '1', @now, @now
WHERE NOT EXISTS (SELECT 1 FROM `hg_admin_menu` WHERE `name` = 'imUserView');
INSERT INTO `hg_admin_menu` (`id`, `pid`, `title`, `name`, `path`, `icon`, `type`, `redirect`, `permissions`, `permission_name`, `component`, `always_show`, `active_menu`, `is_root`, `is_frame`, `frame_src`, `keep_alive`, `hidden`, `affix`, `level`, `tree`, `sort`, `remark`, `status`, `created_at`, `updated_at`)
SELECT NULL, @userListId, '重置用户密码', 'imUserResetPassword', '', '', '3', '', '/imUser/resetPassword', '', '', '1', '', '0', '0', '', '0', '1', '0', '4', CONCAT('tr_', @imRootId, ' tr_', @userDirId, ' tr_', @userListId, ' '), '20', '', '1', @now, @now
WHERE NOT EXISTS (SELECT 1 FROM `hg_admin_menu` WHERE `name` = 'imUserResetPassword');
@@ -0,0 +1,33 @@
SET @now = NOW();
SELECT @imRootId := `id` FROM `hg_admin_menu` WHERE `name` = 'imManager' LIMIT 1;
INSERT INTO `hg_admin_menu` (`id`, `pid`, `title`, `name`, `path`, `icon`, `type`, `redirect`, `permissions`, `permission_name`, `component`, `always_show`, `active_menu`, `is_root`, `is_frame`, `frame_src`, `keep_alive`, `hidden`, `affix`, `level`, `tree`, `sort`, `remark`, `status`, `created_at`, `updated_at`)
SELECT NULL, '0', 'IM管理', 'imManager', '/im', '', '1', '/im/tenant/index', '', '', 'LAYOUT', '1', '', '1', '0', '', '0', '0', '0', '1', '', '200', '', '1', @now, @now
WHERE @imRootId IS NULL;
SELECT @imRootId := `id` FROM `hg_admin_menu` WHERE `name` = 'imManager' LIMIT 1;
SELECT @workOrderDirId := `id` FROM `hg_admin_menu` WHERE `name` = 'imWorkOrder' LIMIT 1;
INSERT INTO `hg_admin_menu` (`id`, `pid`, `title`, `name`, `path`, `icon`, `type`, `redirect`, `permissions`, `permission_name`, `component`, `always_show`, `active_menu`, `is_root`, `is_frame`, `frame_src`, `keep_alive`, `hidden`, `affix`, `level`, `tree`, `sort`, `remark`, `status`, `created_at`, `updated_at`)
SELECT NULL, @imRootId, '举报反馈', 'imWorkOrder', 'work-order', '', '1', '/im/work-order/index', '', '', 'ParentLayout', '1', '', '0', '0', '', '0', '0', '0', '2', CONCAT('tr_', @imRootId, ' '), '50', '', '1', @now, @now
WHERE @workOrderDirId IS NULL;
SELECT @workOrderDirId := `id` FROM `hg_admin_menu` WHERE `name` = 'imWorkOrder' LIMIT 1;
SELECT @workOrderListId := `id` FROM `hg_admin_menu` WHERE `name` = 'imWorkOrderIndex' LIMIT 1;
INSERT INTO `hg_admin_menu` (`id`, `pid`, `title`, `name`, `path`, `icon`, `type`, `redirect`, `permissions`, `permission_name`, `component`, `always_show`, `active_menu`, `is_root`, `is_frame`, `frame_src`, `keep_alive`, `hidden`, `affix`, `level`, `tree`, `sort`, `remark`, `status`, `created_at`, `updated_at`)
SELECT NULL, @workOrderDirId, '工单列表', 'imWorkOrderIndex', 'index', '', '2', '', '/imWorkOrder/list', '', '/imWorkOrder/index', '1', 'imWorkOrder', '0', '0', '', '0', '0', '0', '3', CONCAT('tr_', @imRootId, ' tr_', @workOrderDirId, ' '), '10', '', '1', @now, @now
WHERE @workOrderListId IS NULL;
SELECT @workOrderListId := `id` FROM `hg_admin_menu` WHERE `name` = 'imWorkOrderIndex' LIMIT 1;
INSERT INTO `hg_admin_menu` (`id`, `pid`, `title`, `name`, `path`, `icon`, `type`, `redirect`, `permissions`, `permission_name`, `component`, `always_show`, `active_menu`, `is_root`, `is_frame`, `frame_src`, `keep_alive`, `hidden`, `affix`, `level`, `tree`, `sort`, `remark`, `status`, `created_at`, `updated_at`)
SELECT NULL, @workOrderListId, '工单详情', 'imWorkOrderView', '', '', '3', '', '/imWorkOrder/view', '', '', '1', '', '0', '0', '', '0', '1', '0', '4', CONCAT('tr_', @imRootId, ' tr_', @workOrderDirId, ' tr_', @workOrderListId, ' '), '10', '', '1', @now, @now
WHERE NOT EXISTS (SELECT 1 FROM `hg_admin_menu` WHERE `name` = 'imWorkOrderView');
INSERT INTO `hg_admin_menu` (`id`, `pid`, `title`, `name`, `path`, `icon`, `type`, `redirect`, `permissions`, `permission_name`, `component`, `always_show`, `active_menu`, `is_root`, `is_frame`, `frame_src`, `keep_alive`, `hidden`, `affix`, `level`, `tree`, `sort`, `remark`, `status`, `created_at`, `updated_at`)
SELECT NULL, @workOrderListId, '更新工单状态', 'imWorkOrderStatus', '', '', '3', '', '/imWorkOrder/status', '', '', '1', '', '0', '0', '', '0', '1', '0', '4', CONCAT('tr_', @imRootId, ' tr_', @workOrderDirId, ' tr_', @workOrderListId, ' '), '20', '', '1', @now, @now
WHERE NOT EXISTS (SELECT 1 FROM `hg_admin_menu` WHERE `name` = 'imWorkOrderStatus');
+1
View File
@@ -223,6 +223,7 @@ COMMENT ON COLUMN hg_admin_dept.updated_at IS '更新时间';
CREATE TABLE IF NOT EXISTS hg_admin_member (
id BIGSERIAL PRIMARY KEY,
tenant_id BIGINT,
dept_id BIGINT DEFAULT 0,
role_id BIGINT DEFAULT 10,
real_name VARCHAR(32) DEFAULT '',
+2
View File
@@ -213,6 +213,7 @@ INSERT INTO `hg_admin_dept` (`id`, `pid`, `name`, `code`, `type`, `leader`, `pho
CREATE TABLE IF NOT EXISTS `hg_admin_member` (
`id` bigint(20) NOT NULL COMMENT '管理员ID',
`tenant_id` bigint(20) DEFAULT NULL COMMENT 'IM租户ID',
`dept_id` bigint(20) DEFAULT '0' COMMENT '部门ID',
`role_id` bigint(20) DEFAULT '10' COMMENT '角色ID',
`real_name` varchar(32) DEFAULT '' COMMENT '真实姓名',
@@ -5789,6 +5790,7 @@ ALTER TABLE `hg_admin_dept`
ALTER TABLE `hg_admin_member`
ADD PRIMARY KEY (`id`),
ADD UNIQUE KEY `invite_code` (`invite_code`),
ADD KEY `idx_hg_admin_member_tenant` (`tenant_id`),
ADD KEY `dept_id` (`dept_id`),
ADD KEY `pid` (`pid`);
@@ -0,0 +1,161 @@
-- IM registration SMS switch and registration invite admin migration.
-- This file is idempotent for MySQL deployments using HotGo's default hg_ prefix.
-- im_tenant is the only tenant registry. Admin identities reference it directly.
SET @tenant_column_exists := (
SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'hg_admin_member' AND COLUMN_NAME = 'tenant_id'
);
SET @ddl := IF(
@tenant_column_exists = 0,
'ALTER TABLE hg_admin_member ADD COLUMN tenant_id BIGINT NULL COMMENT ''IM tenant ID'' AFTER id',
'SELECT 1'
);
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
-- Preserve an existing deployment's old mapping before removing the redundant table.
SET @binding_table_exists := (
SELECT COUNT(*) FROM information_schema.TABLES
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'im_admin_tenant_binding'
);
SET @dml := IF(
@binding_table_exists > 0,
'UPDATE hg_admin_member AS m JOIN im_admin_tenant_binding AS b ON b.hotgo_tenant_id = m.id SET m.tenant_id = b.im_tenant_id WHERE b.status = 1 AND m.tenant_id IS NULL',
'SELECT 1'
);
PREPARE stmt FROM @dml;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
DROP TABLE IF EXISTS im_admin_tenant_binding;
SET @tenant_index_exists := (
SELECT COUNT(*) FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'hg_admin_member' AND INDEX_NAME = 'idx_hg_admin_member_tenant'
);
SET @ddl := IF(
@tenant_index_exists = 0,
'ALTER TABLE hg_admin_member ADD INDEX idx_hg_admin_member_tenant (tenant_id)',
'SELECT 1'
);
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @tenant_fk_exists := (
SELECT COUNT(*) FROM information_schema.REFERENTIAL_CONSTRAINTS
WHERE CONSTRAINT_SCHEMA = DATABASE() AND TABLE_NAME = 'hg_admin_member' AND CONSTRAINT_NAME = 'fk_hg_admin_member_im_tenant'
);
SET @ddl := IF(
@tenant_fk_exists = 0,
'ALTER TABLE hg_admin_member ADD CONSTRAINT fk_hg_admin_member_im_tenant FOREIGN KEY (tenant_id) REFERENCES im_tenant(id)',
'SELECT 1'
);
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
CREATE TABLE IF NOT EXISTS im_admin_operation_audit (
id BIGINT NOT NULL AUTO_INCREMENT,
request_id VARCHAR(64) NOT NULL,
tenant_id BIGINT NULL,
operator_id BIGINT NOT NULL,
action VARCHAR(64) NOT NULL,
target_type VARCHAR(32) NOT NULL,
target_id BIGINT NULL,
result VARCHAR(16) NOT NULL,
ip VARCHAR(64) NOT NULL DEFAULT '',
detail_json JSON NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
PRIMARY KEY (id),
KEY idx_im_admin_audit_tenant_time (tenant_id, created_at),
KEY idx_im_admin_audit_operator_time (operator_id, created_at),
UNIQUE KEY uk_im_admin_audit_request_action (request_id, action)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Invitation definitions belong to the HotGo control database. Tenant
-- databases retain only im_registration_invite_uses.
CREATE TABLE IF NOT EXISTS im_registration_invite_batches (
id BIGINT NOT NULL,
tenant_id BIGINT NOT NULL,
quantity INT UNSIGNED NOT NULL,
idempotency_key VARCHAR(128) NOT NULL,
expires_at DATETIME(3) NOT NULL,
created_by BIGINT NOT NULL,
remark VARCHAR(255) NOT NULL DEFAULT '',
created_at DATETIME(3) NOT NULL,
updated_at DATETIME(3) NOT NULL,
PRIMARY KEY (id),
UNIQUE KEY uk_im_registration_invite_batch_tenant_idempotency (tenant_id, idempotency_key),
KEY idx_im_registration_invite_batch_tenant_created (tenant_id, created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS im_registration_invite_codes (
id BIGINT NOT NULL,
tenant_id BIGINT NOT NULL,
batch_id BIGINT NULL,
code CHAR(5) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
status VARCHAR(16) NOT NULL,
expires_at DATETIME(3) NOT NULL,
usage_count INT UNSIGNED NOT NULL DEFAULT 0,
last_used_at DATETIME(3) NULL,
created_by BIGINT NOT NULL,
remark VARCHAR(255) NOT NULL DEFAULT '',
created_at DATETIME(3) NOT NULL,
updated_at DATETIME(3) NOT NULL,
PRIMARY KEY (id),
UNIQUE KEY uk_im_registration_invite_code_tenant (tenant_id, code),
KEY idx_im_registration_invite_tenant_status_expires (tenant_id, status, expires_at),
KEY idx_im_registration_invite_tenant_batch (tenant_id, batch_id),
KEY idx_im_registration_invite_tenant_created (tenant_id, created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
INSERT INTO hg_sys_config
(`group`, `name`, `type`, `key`, `value`, `default_value`, `sort`, `tip`, `is_default`, `status`, `created_at`, `updated_at`)
SELECT
'im', '注册短信发送', 'bool', 'register_sms_enabled', '1', '1', 10,
'控制 IM 注册验证码是否真实发送', 1, 1, NOW(), NOW()
WHERE NOT EXISTS (
SELECT 1 FROM hg_sys_config WHERE `group` = 'im' AND `key` = 'register_sms_enabled'
);
INSERT INTO hg_admin_menu
(pid, level, tree, title, name, path, icon, type, redirect, permissions, component, always_show, is_root, is_frame, keep_alive, hidden, affix, sort, status, created_at, updated_at)
SELECT
0, 1, '', 'IM管理', 'imManager', '/im', 'AppstoreOutlined', 1, '', '', 'LAYOUT', 0, 0, 1, 0, 0, 0, 125, 1, NOW(), NOW()
WHERE NOT EXISTS (SELECT 1 FROM hg_admin_menu WHERE name = 'imManager');
SET @imRoot := (SELECT id FROM hg_admin_menu WHERE name = 'imManager' LIMIT 1);
UPDATE hg_admin_menu SET tree = CONCAT('tr_', id, ' '), component = 'LAYOUT' WHERE id = @imRoot;
INSERT INTO hg_admin_menu
(pid, level, tree, title, name, path, icon, type, redirect, permissions, component, always_show, is_root, is_frame, keep_alive, hidden, affix, sort, status, created_at, updated_at)
SELECT
@imRoot, 2, '', '注册邀请码', 'imRegistrationInviteIndex', 'registrationInvite', '', 2, '', '/imInvite/list', '/im/registrationInvite/index', 0, 0, 1, 0, 0, 0, 70, 1, NOW(), NOW()
WHERE NOT EXISTS (SELECT 1 FROM hg_admin_menu WHERE name = 'imRegistrationInviteIndex');
SET @invitePage := (SELECT id FROM hg_admin_menu WHERE name = 'imRegistrationInviteIndex' LIMIT 1);
UPDATE hg_admin_menu SET tree = CONCAT('tr_', id, ' tr_', @imRoot, ' ') WHERE id = @invitePage;
INSERT INTO hg_admin_menu
(pid, level, tree, title, name, path, icon, type, permissions, component, sort, status, created_at, updated_at)
SELECT @invitePage, 3, CONCAT('tr_', @invitePage, ' tr_', @imRoot, ' '), title, name, '', '', 3, permissions, '', sort, 1, NOW(), NOW()
FROM (
SELECT '生成邀请码' title, 'imInviteCreate' name, '/imInvite/create' permissions, 10 sort UNION ALL
SELECT '批量生成邀请码', 'imInviteBatchCreate', '/imInvite/batchCreate', 20 UNION ALL
SELECT '批次详情', 'imInviteBatchView', '/imInvite/batchView', 30 UNION ALL
SELECT '撤销邀请码', 'imInviteRevoke', '/imInvite/revoke', 40 UNION ALL
SELECT '批量撤销邀请码', 'imInviteBatchRevoke', '/imInvite/batchRevoke', 50 UNION ALL
SELECT '导出邀请码', 'imInviteExport', '/imInvite/export', 60
) p
WHERE NOT EXISTS (SELECT 1 FROM hg_admin_menu m WHERE m.name = p.name);
SET @imUserPage := (SELECT id FROM hg_admin_menu WHERE name = 'imUserIndex' LIMIT 1);
INSERT INTO hg_admin_menu
(pid, level, tree, title, name, path, icon, type, permissions, component, hidden, sort, status, created_at, updated_at)
SELECT @imUserPage, 4, CONCAT('tr_', @imUserPage, ' '), '重置用户密码', 'imUserResetPassword', '', '', 3,
'/imUser/resetPassword', '', 1, 20, 1, NOW(), NOW()
WHERE @imUserPage IS NOT NULL
AND NOT EXISTS (SELECT 1 FROM hg_admin_menu WHERE name = 'imUserResetPassword');
+2 -1
View File
@@ -100,6 +100,7 @@ CREATE TABLE `hg_admin_dept` ( -- 管理员_部门
);
CREATE TABLE `hg_admin_member` ( -- 管理员_用户表
`id` INTEGER NOT NULL PRIMARY KEY, -- 管理员ID
`tenant_id` INTEGER DEFAULT NULL, -- IM租户ID
`dept_id` INTEGER DEFAULT 0, -- 部门ID
`role_id` INTEGER DEFAULT 10, -- 角色ID
`real_name` TEXT DEFAULT '', -- 真实姓名
@@ -653,4 +654,4 @@ CREATE INDEX `hg_sys_provinces_pid` ON `hg_sys_provinces` (`pid`);
CREATE UNIQUE INDEX `hg_sys_serve_license_appid` ON `hg_sys_serve_license` (`appid`);
CREATE INDEX `hg_sys_serve_log_member_id` ON `hg_sys_serve_log` (`level_format`);
CREATE INDEX `hg_sys_serve_log_traceid` ON `hg_sys_serve_log` (`trace_id`);
CREATE INDEX `hg_sys_sms_log_mobile` ON `hg_sys_sms_log` (`mobile`);
CREATE INDEX `hg_sys_sms_log_mobile` ON `hg_sys_sms_log` (`mobile`);
+629 -322
View File
File diff suppressed because it is too large Load Diff
+5
View File
@@ -0,0 +1,5 @@
allowBuilds:
'@swc/core': set this to true or false
core-js: set this to true or false
esbuild: set this to true or false
vue-demi: set this to true or false
+77
View File
@@ -0,0 +1,77 @@
import { http } from '@/utils/http/axios';
export function List(params) {
return http.request({
url: '/imInvite/list',
method: 'POST',
params,
});
}
export function Create(params) {
return http.request({
url: '/imInvite/create',
method: 'POST',
params,
});
}
export function BatchCreate(params) {
return http.request({
url: '/imInvite/batchCreate',
method: 'POST',
params,
});
}
export function BatchView(params) {
return http.request({
url: '/imInvite/batchView',
method: 'GET',
params,
});
}
export function Revoke(params) {
return http.request({
url: '/imInvite/revoke',
method: 'POST',
params,
});
}
export function BatchRevoke(params) {
return http.request({
url: '/imInvite/batchRevoke',
method: 'POST',
params,
});
}
export function Export(params) {
return http.request({
url: '/imInvite/export',
method: 'POST',
params,
responseType: 'blob',
requestOptions: {
isTransformResponse: false,
isReturnNativeResponse: true,
},
});
}
export function GetRegistrationConfig() {
return http.request({
url: '/imRegistration/config/get',
method: 'GET',
});
}
export function UpdateRegistrationConfig(params) {
return http.request({
url: '/imRegistration/config/update',
method: 'POST',
params,
});
}
+19
View File
@@ -0,0 +1,19 @@
import { http } from '@/utils/http/axios';
// 获取IM审计日志列表
export function List(params) {
return http.request({
url: '/imAuditLog/list',
method: 'get',
params,
});
}
// 获取IM审计日志详情
export function View(params) {
return http.request({
url: '/imAuditLog/view',
method: 'GET',
params,
});
}
+82
View File
@@ -0,0 +1,82 @@
import { http } from '@/utils/http/axios';
// 获取IM群组列表
export function List(params) {
return http.request({
url: '/imGroup/list',
method: 'get',
params,
});
}
// 获取IM群组详情
export function View(params) {
return http.request({
url: '/imGroup/view',
method: 'GET',
params,
});
}
// 获取IM群成员列表
export function MemberList(params) {
return http.request({
url: '/imGroup/memberList',
method: 'GET',
params,
});
}
// 获取IM群治理配置
export function Governance(params) {
return http.request({
url: '/imGroup/governance',
method: 'GET',
params,
});
}
// 获取IM入群申请列表
export function JoinRequestList(params) {
return http.request({
url: '/imGroup/joinRequestList',
method: 'GET',
params,
});
}
// 获取IM群邀请列表
export function InvitationList(params) {
return http.request({
url: '/imGroup/invitationList',
method: 'GET',
params,
});
}
// 获取IM群公告列表
export function AnnouncementList(params) {
return http.request({
url: '/imGroup/announcementList',
method: 'GET',
params,
});
}
// 获取IM群禁言列表
export function MuteList(params) {
return http.request({
url: '/imGroup/muteList',
method: 'GET',
params,
});
}
// 获取IM群管理日志列表
export function AdminLogList(params) {
return http.request({
url: '/imGroup/adminLogList',
method: 'GET',
params,
});
}
+25
View File
@@ -0,0 +1,25 @@
import { http } from '@/utils/http/axios';
export function List(params) {
return http.request({
url: '/imRealname/list',
method: 'get',
params,
});
}
export function View(params) {
return http.request({
url: '/imRealname/view',
method: 'GET',
params,
});
}
export function Review(params) {
return http.request({
url: '/imRealname/review',
method: 'POST',
params,
});
}
+37
View File
@@ -0,0 +1,37 @@
import { http } from '@/utils/http/axios';
// 获取IM租户列表
export function List(params) {
return http.request({
url: '/imTenant/list',
method: 'get',
params,
});
}
// 添加/编辑IM租户
export function Edit(params) {
return http.request({
url: '/imTenant/edit',
method: 'POST',
params,
});
}
// 修改IM租户状态
export function Status(params) {
return http.request({
url: '/imTenant/status',
method: 'POST',
params,
});
}
// 获取IM租户指定详情
export function View(params) {
return http.request({
url: '/imTenant/view',
method: 'GET',
params,
});
}
+28
View File
@@ -0,0 +1,28 @@
import { http } from '@/utils/http/axios';
// 获取IM用户列表
export function List(params) {
return http.request({
url: '/imUser/list',
method: 'get',
params,
});
}
// 获取IM用户指定详情
export function View(params) {
return http.request({
url: '/imUser/view',
method: 'GET',
params,
});
}
// 管理员重置IM用户密码
export function ResetPassword(params) {
return http.request({
url: '/imUser/resetPassword',
method: 'POST',
params,
});
}
+28
View File
@@ -0,0 +1,28 @@
import { http } from '@/utils/http/axios';
// 获取IM举报反馈工单列表
export function List(params) {
return http.request({
url: '/imWorkOrder/list',
method: 'get',
params,
});
}
// 获取IM举报反馈工单详情
export function View(params) {
return http.request({
url: '/imWorkOrder/view',
method: 'GET',
params,
});
}
// 更新IM举报反馈工单状态
export function Status(params) {
return http.request({
url: '/imWorkOrder/status',
method: 'POST',
params,
});
}
@@ -0,0 +1,49 @@
<template>
<n-modal v-model:show="showModal" preset="card" title="批次详情" class="batch-view-modal">
<n-descriptions :column="4" bordered size="small">
<n-descriptions-item label="批次ID">{{ detail.batchId || '-' }}</n-descriptions-item>
<n-descriptions-item label="总数">{{ detail.quantity || 0 }}</n-descriptions-item>
<n-descriptions-item label="有效">{{ detail.activeCount || 0 }}</n-descriptions-item>
<n-descriptions-item label="累计注册次数">{{ detail.usageCount || 0 }}</n-descriptions-item>
<n-descriptions-item label="已撤销">{{ detail.revokedCount || 0 }}</n-descriptions-item>
<n-descriptions-item label="已过期">{{ detail.expiredCount || 0 }}</n-descriptions-item>
<n-descriptions-item label="到期时间">{{ detail.expiresAt || '-' }}</n-descriptions-item>
<n-descriptions-item label="创建时间">{{ detail.createdAt || '-' }}</n-descriptions-item>
</n-descriptions>
<n-data-table :columns="columns" :data="detail.list || []" :pagination="{ pageSize: 10 }" />
</n-modal>
</template>
<script lang="ts" setup>
import { ref } from 'vue';
import { useMessage } from 'naive-ui';
import { BatchView } from '@/api/im/registrationInvite';
const message = useMessage();
const showModal = ref(false);
const detail = ref<any>({});
const columns = [
{ title: 'ID', key: 'id' },
{ title: '邀请码', key: 'code' },
{ title: '状态', key: 'status' },
{ title: '累计使用次数', key: 'usageCount' },
{ title: '最后使用时间', key: 'lastUsedAt' },
];
async function openModal(tenantId: number, batchId: number) {
showModal.value = true;
try {
detail.value = await BatchView({ tenantId, batchId });
} catch (error: any) {
message.error(error.toString());
}
}
defineExpose({ openModal });
</script>
<style lang="less" scoped>
.batch-view-modal {
width: min(1000px, 92vw);
}
</style>
@@ -0,0 +1,88 @@
<template>
<n-modal v-model:show="showModal" preset="dialog" :title="isBatch ? '批量生成邀请码' : '生成邀请码'">
<n-form ref="formRef" :model="formValue" :rules="rules" label-placement="left" :label-width="90">
<n-form-item v-if="isBatch" label="生成数量" path="quantity">
<n-input-number v-model:value="formValue.quantity" :min="1" :max="1000" />
</n-form-item>
<n-form-item label="到期时间" path="expiresAt">
<n-date-picker v-model:value="formValue.expiresAt" type="datetime" clearable />
</n-form-item>
<n-form-item label="备注" path="remark">
<n-input v-model:value="formValue.remark" type="textarea" maxlength="255" show-count />
</n-form-item>
</n-form>
<template #action>
<n-space justify="end">
<n-button @click="closeModal">取消</n-button>
<n-button type="primary" :loading="loading" @click="submit">确认</n-button>
</n-space>
</template>
</n-modal>
</template>
<script lang="ts" setup>
import { reactive, ref } from 'vue';
import { useMessage } from 'naive-ui';
import { BatchCreate, Create } from '@/api/im/registrationInvite';
import { localRfc3339 } from './model';
const emit = defineEmits(['reload']);
const message = useMessage();
const showModal = ref(false);
const loading = ref(false);
const formRef = ref();
const tenantId = ref(0);
const isBatch = ref(false);
const formValue = reactive({
quantity: 100,
expiresAt: Date.now() + 7 * 24 * 3600 * 1000,
remark: '',
idempotencyKey: '',
});
const rules = {
quantity: { required: true, type: 'number', min: 1, max: 1000, message: '数量必须在1到1000之间' },
expiresAt: { required: true, type: 'number', message: '请选择到期时间' },
};
function openModal(id: number, batch = false) {
tenantId.value = id;
isBatch.value = batch;
formValue.quantity = 100;
formValue.expiresAt = Date.now() + 7 * 24 * 3600 * 1000;
formValue.remark = '';
formValue.idempotencyKey = crypto.randomUUID();
showModal.value = true;
}
function closeModal() {
showModal.value = false;
}
async function submit() {
await formRef.value?.validate();
loading.value = true;
try {
const params = {
tenantId: tenantId.value,
expiresAt: localRfc3339(formValue.expiresAt),
remark: formValue.remark,
idempotencyKey: formValue.idempotencyKey,
};
if (isBatch.value) {
await BatchCreate({ ...params, quantity: formValue.quantity });
} else {
await Create(params);
}
message.success('生成成功');
closeModal();
emit('reload');
} catch (error: any) {
message.error(error.toString());
} finally {
loading.value = false;
}
}
defineExpose({ openModal });
</script>
@@ -0,0 +1,193 @@
<template>
<div>
<div class="n-layout-page-header">
<n-card :bordered="false" title="IM注册邀请码"></n-card>
</div>
<n-card :bordered="false" class="proCard">
<BasicForm
ref="searchFormRef"
@register="register"
@submit="reloadTable"
@reset="reloadTable"
@keyup.enter="reloadTable"
/>
<BasicTable
ref="actionRef"
:columns="tableColumns"
:request="loadDataTable"
:row-key="(row) => row.id"
:actionColumn="actionColumn"
:scroll-x="scrollX"
:resizeHeightOffset="-10000"
>
<template #tableTitle>
<n-space>
<n-button type="primary" @click="handleCreate(false)" v-if="hasPermission(['/imInvite/create'])">
添加
</n-button>
<n-button type="primary" @click="handleCreate(true)" v-if="hasPermission(['/imInvite/batchCreate'])">
批量生成
</n-button>
<n-button @click="handleExport" v-if="hasPermission(['/imInvite/export'])">导出</n-button>
</n-space>
</template>
</BasicTable>
</n-card>
<CreateModal ref="createRef" @reload="reloadTable" />
<BatchViewModal ref="batchViewRef" />
</div>
</template>
<script lang="ts" setup>
import { computed, h, onMounted, reactive, ref } from 'vue';
import { NTag, 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 { Export, List, Revoke } from '@/api/im/registrationInvite';
import {
columns,
loadOptions,
loadTenantOptions,
schemas,
State,
statusLabel,
statusOptions,
tenantOptions,
} from './model';
import { adaTableScrollX } from '@/utils/hotgo';
import CreateModal from './CreateModal.vue';
import BatchViewModal from './BatchViewModal.vue';
const message = useMessage();
const dialog = useDialog();
const { hasPermission } = usePermission();
const userStore = useUserStore();
const actionRef = ref();
const searchFormRef = ref<any>({});
const createRef = ref();
const batchViewRef = ref();
const isPlatformAdmin = computed(() => userStore.isCompanyDept);
const tableColumns = computed(() =>
columns.map((item: any) => {
if (item.key !== 'status') {
return item;
}
return {
...item,
render(row: State) {
const option: any = statusOptions.find((status) => status.value === row.status);
return h(NTag as any, { type: option?.type || 'default', bordered: false }, () =>
statusLabel(row.status)
);
},
};
})
);
const actionColumn = reactive({
width: 220,
title: '操作',
key: 'action',
fixed: 'right',
render(record: State) {
return h(TableAction as any, {
style: 'button',
actions: [
{
label: '批次',
onClick: handleBatchView.bind(null, record),
auth: ['/imInvite/batchView'],
ifShow: () => !!record.batchId,
},
{
label: '撤销',
type: 'error',
onClick: handleRevoke.bind(null, record),
auth: ['/imInvite/revoke'],
ifShow: () => record.status === 'active',
},
],
});
},
});
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 };
return await List(params);
};
function reloadTable() {
actionRef.value?.reload();
}
function selectedTenantId() {
if (!isPlatformAdmin.value) {
return 0;
}
return Number(searchFormRef.value?.formModel?.tenantId || 0);
}
function handleCreate(batch: boolean) {
const tenantId = selectedTenantId();
if (isPlatformAdmin.value && !tenantId) {
message.warning('请先选择租户');
return;
}
createRef.value.openModal(tenantId, batch);
}
function handleBatchView(record: State) {
batchViewRef.value.openModal(Number(record.tenantId || selectedTenantId()), record.batchId);
}
function handleRevoke(record: State) {
dialog.warning({
title: '确认撤销',
content: '撤销后该邀请码不能继续使用,是否确认?',
positiveText: '确认',
negativeText: '取消',
onPositiveClick: async () => {
await Revoke({ tenantId: Number(record.tenantId || selectedTenantId()), id: record.id });
message.success('撤销成功');
reloadTable();
},
});
}
async function handleExport() {
const params = { ...searchFormRef.value?.formModel };
const response: any = await Export(params);
const blob = response?.data || response;
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = `registration-invite-codes-${Date.now()}.csv`;
link.click();
URL.revokeObjectURL(url);
}
onMounted(async () => {
loadOptions();
if (isPlatformAdmin.value) {
await loadTenantOptions();
await setFieldsValue({ tenantId: null });
if (tenantOptions.value.length === 0) {
message.warning('暂无可用租户');
}
}
reloadTable();
});
</script>
<style lang="less" scoped></style>

Some files were not shown because too many files have changed in this diff Show More