diff --git a/server/api/admin/imauditlog/im_audit_log.go b/server/api/admin/imauditlog/im_audit_log.go index 492f527..01b9b36 100644 --- a/server/api/admin/imauditlog/im_audit_log.go +++ b/server/api/admin/imauditlog/im_audit_log.go @@ -22,6 +22,14 @@ type ListRes struct { List []*sysin.ImAuditLogListModel `json:"list" dc:"数据列表"` } +// ExportReq 导出IM审计日志列表 +type ExportReq struct { + g.Meta `path:"/imAuditLog/export" method:"get" tags:"IM审计日志" summary:"导出IM审计日志列表"` + sysin.ImAuditLogListInp +} + +type ExportRes struct{} + // ViewReq 获取IM审计日志指定信息 type ViewReq struct { g.Meta `path:"/imAuditLog/view" method:"get" tags:"IM审计日志" summary:"获取IM审计日志指定信息"` diff --git a/server/api/admin/imtenant/im_tenant.go b/server/api/admin/imtenant/im_tenant.go index 01af2d3..a5eedb5 100644 --- a/server/api/admin/imtenant/im_tenant.go +++ b/server/api/admin/imtenant/im_tenant.go @@ -48,3 +48,35 @@ type StatusReq struct { } type StatusRes struct{} + +type UsageReq struct { + g.Meta `path:"/imTenant/usage" method:"get" tags:"IM租户" summary:"获取IM租户用量和健康状态"` + sysin.ImTenantUsageInp +} + +type UsageRes struct { + *sysin.ImTenantUsageModel +} + +type DeletionCheckReq struct { + g.Meta `path:"/imTenant/deletionCheck" method:"get" tags:"IM租户" summary:"IM租户删除前检查"` + sysin.ImTenantDeletionCheckInp +} + +type DeletionCheckRes struct { + *sysin.ImTenantDeletionCheckModel +} + +type EnterDeletingReq struct { + g.Meta `path:"/imTenant/enterDeleting" method:"post" tags:"IM租户" summary:"IM租户进入删除流程"` + sysin.ImTenantLifecycleInp +} + +type EnterDeletingRes struct{} + +type RestoreReq struct { + g.Meta `path:"/imTenant/restore" method:"post" tags:"IM租户" summary:"恢复IM租户"` + sysin.ImTenantLifecycleInp +} + +type RestoreRes struct{} diff --git a/server/api/admin/imuser/im_user.go b/server/api/admin/imuser/im_user.go index 03e565a..36ae4ed 100644 --- a/server/api/admin/imuser/im_user.go +++ b/server/api/admin/imuser/im_user.go @@ -39,3 +39,27 @@ type ResetPasswordReq struct { } type ResetPasswordRes struct{} + +type RevokeSessionsReq struct { + g.Meta `path:"/imUser/revokeSessions" method:"post" tags:"IM用户" summary:"撤销IM用户会话"` + sysin.ImUserRevokeSessionsInp +} + +type RevokeSessionsRes struct{} + +type StatusReq struct { + g.Meta `path:"/imUser/status" method:"post" tags:"IM用户" summary:"更新IM用户状态"` + sysin.ImUserStatusInp +} + +type StatusRes struct{} + +type DevicesReq struct { + g.Meta `path:"/imUser/devices" method:"get" tags:"IM用户" summary:"获取IM用户设备列表"` + sysin.ImUserDeviceListInp +} + +type DevicesRes struct { + form.PageRes + List []*sysin.ImUserDeviceListModel `json:"list" dc:"设备列表"` +} diff --git a/server/internal/controller/admin/sys/im_audit_log.go b/server/internal/controller/admin/sys/im_audit_log.go index 460eba1..45ba525 100644 --- a/server/internal/controller/admin/sys/im_audit_log.go +++ b/server/internal/controller/admin/sys/im_audit_log.go @@ -31,6 +31,12 @@ func (c *cImAuditLog) List(ctx context.Context, req *imauditlog.ListReq) (res *i return } +// Export 导出IM审计日志列表 +func (c *cImAuditLog) Export(ctx context.Context, req *imauditlog.ExportReq) (res *imauditlog.ExportRes, err error) { + err = service.SysImAuditLog().Export(ctx, &req.ImAuditLogListInp) + 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) diff --git a/server/internal/controller/admin/sys/im_tenant.go b/server/internal/controller/admin/sys/im_tenant.go index 3acd490..197fdd5 100644 --- a/server/internal/controller/admin/sys/im_tenant.go +++ b/server/internal/controller/admin/sys/im_tenant.go @@ -58,3 +58,35 @@ func (c *cImTenant) Status(ctx context.Context, req *imtenant.StatusReq) (res *i err = service.SysImTenant().Status(ctx, &req.ImTenantStatusInp) return } + +func (c *cImTenant) Usage(ctx context.Context, req *imtenant.UsageReq) (res *imtenant.UsageRes, err error) { + data, err := service.SysImTenant().Usage(ctx, &req.ImTenantUsageInp) + if err != nil { + return + } + + res = new(imtenant.UsageRes) + res.ImTenantUsageModel = data + return +} + +func (c *cImTenant) DeletionCheck(ctx context.Context, req *imtenant.DeletionCheckReq) (res *imtenant.DeletionCheckRes, err error) { + data, err := service.SysImTenant().DeletionCheck(ctx, &req.ImTenantDeletionCheckInp) + if err != nil { + return + } + + res = new(imtenant.DeletionCheckRes) + res.ImTenantDeletionCheckModel = data + return +} + +func (c *cImTenant) EnterDeleting(ctx context.Context, req *imtenant.EnterDeletingReq) (res *imtenant.EnterDeletingRes, err error) { + err = service.SysImTenant().EnterDeleting(ctx, &req.ImTenantLifecycleInp) + return +} + +func (c *cImTenant) Restore(ctx context.Context, req *imtenant.RestoreReq) (res *imtenant.RestoreRes, err error) { + err = service.SysImTenant().Restore(ctx, &req.ImTenantLifecycleInp) + return +} diff --git a/server/internal/controller/admin/sys/im_user.go b/server/internal/controller/admin/sys/im_user.go index 993d275..ce931ea 100644 --- a/server/internal/controller/admin/sys/im_user.go +++ b/server/internal/controller/admin/sys/im_user.go @@ -51,3 +51,29 @@ func (c *cImUser) ResetPassword(ctx context.Context, req *imuser.ResetPasswordRe err = service.SysImUser().ResetPassword(ctx, &req.ImUserPasswordResetInp) return } + +func (c *cImUser) RevokeSessions(ctx context.Context, req *imuser.RevokeSessionsReq) (res *imuser.RevokeSessionsRes, err error) { + err = service.SysImUser().RevokeSessions(ctx, &req.ImUserRevokeSessionsInp) + return +} + +func (c *cImUser) Status(ctx context.Context, req *imuser.StatusReq) (res *imuser.StatusRes, err error) { + err = service.SysImUser().Status(ctx, &req.ImUserStatusInp) + return +} + +func (c *cImUser) Devices(ctx context.Context, req *imuser.DevicesReq) (res *imuser.DevicesRes, err error) { + list, totalCount, err := service.SysImUser().Devices(ctx, &req.ImUserDeviceListInp) + if err != nil { + return + } + + if list == nil { + list = []*sysin.ImUserDeviceListModel{} + } + + res = new(imuser.DevicesRes) + res.List = list + res.PageRes.Pack(req, totalCount) + return +} diff --git a/server/internal/logic/sys/im_audit_log.go b/server/internal/logic/sys/im_audit_log.go index 943ef75..50b50a5 100644 --- a/server/internal/logic/sys/im_audit_log.go +++ b/server/internal/logic/sys/im_audit_log.go @@ -7,12 +7,18 @@ package sys import ( "context" + "fmt" "hotgo/internal/library/imtenant" + "hotgo/internal/model/input/form" "hotgo/internal/model/input/sysin" "hotgo/internal/service" + "hotgo/utility/convert" + "hotgo/utility/excel" "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/gconv" ) type sSysImAuditLog struct{} @@ -30,7 +36,11 @@ func (s *sSysImAuditLog) List(ctx context.Context, in *sysin.ImAuditLogListInp) if err = in.Filter(ctx); err != nil { return } - db, tenant, err := imtenant.DB(ctx, in.TenantId) + scope, err := resolveImAdminScope(ctx, in.TenantId) + if err != nil { + return + } + db, tenant, err := imtenant.DB(ctx, scope.TenantId) if err != nil { return } @@ -107,7 +117,11 @@ func (s *sSysImAuditLog) View(ctx context.Context, in *sysin.ImAuditLogViewInp) if err = in.Filter(ctx); err != nil { return } - db, tenant, err := imtenant.DB(ctx, in.TenantId) + scope, err := resolveImAdminScope(ctx, in.TenantId) + if err != nil { + return nil, err + } + db, tenant, err := imtenant.DB(ctx, scope.TenantId) if err != nil { return nil, err } @@ -123,3 +137,23 @@ func (s *sSysImAuditLog) View(ctx context.Context, in *sysin.ImAuditLogViewInp) res.TenantName = tenant.Name return } + +// Export 导出IM审计日志列表 +func (s *sSysImAuditLog) Export(ctx context.Context, in *sysin.ImAuditLogListInp) (err error) { + list, totalCount, err := s.List(ctx, in) + if err != nil { + return + } + tags, err := convert.GetEntityDescTags(sysin.ImAuditLogExportModel{}) + if err != nil { + return + } + + var exports []sysin.ImAuditLogExportModel + if err = gconv.Scan(list, &exports); err != nil { + return + } + fileName := "导出IM审计日志-" + gctx.CtxId(ctx) + sheetName := fmt.Sprintf("索引条件共%v行,共%v页,当前导出是第%v页,本页共%v行", totalCount, form.CalPageCount(totalCount, in.PerPage), in.Page, len(list)) + return excel.ExportByStructs(ctx, tags, exports, fileName, sheetName) +} diff --git a/server/internal/logic/sys/im_tenant.go b/server/internal/logic/sys/im_tenant.go index f9c8d02..e55308a 100644 --- a/server/internal/logic/sys/im_tenant.go +++ b/server/internal/logic/sys/im_tenant.go @@ -7,10 +7,13 @@ package sys import ( "context" + "fmt" + "hotgo/internal/consts" "hotgo/internal/dao" "hotgo/internal/library/contexts" "hotgo/internal/library/hgorm/handler" "hotgo/internal/library/hgorm/hook" + "hotgo/internal/library/imtenant" "hotgo/internal/model/entity" "hotgo/internal/model/input/sysin" "hotgo/internal/service" @@ -272,3 +275,137 @@ func (s *sSysImTenant) Status(ctx context.Context, in *sysin.ImTenantStatusInp) } return } + +func (s *sSysImTenant) Usage(ctx context.Context, in *sysin.ImTenantUsageInp) (res *sysin.ImTenantUsageModel, err error) { + if err = in.Filter(ctx); err != nil { + return + } + + var tenant *entity.ImTenant + if err = s.Model(ctx).WherePri(in.Id).Scan(&tenant); err != nil { + return nil, gerror.Wrap(err, "获取IM租户信息失败,请稍后重试!") + } + if tenant == nil || tenant.Id <= 0 { + return nil, gerror.New("IM租户不存在") + } + + res = &sysin.ImTenantUsageModel{ + Id: tenant.Id, + Code: tenant.Code, + Name: tenant.Name, + DbName: tenant.DbName, + DbDsnConfigured: tenant.DbDsn != "", + Warnings: []string{}, + } + + db, _, dbErr := imtenant.DB(ctx, tenant.Id) + if dbErr != nil { + res.DbReachable = false + res.DbCheckError = dbErr.Error() + return res, nil + } + if _, pingErr := db.GetValue(ctx, "SELECT 1"); pingErr != nil { + res.DbReachable = false + res.DbCheckError = pingErr.Error() + return res, nil + } + res.DbReachable = true + + res.SchemaVersion = s.queryScalarString(ctx, db, "kvmeta.version", "SELECT value FROM kvmeta WHERE key_='version' LIMIT 1", &res.Warnings) + res.UserCount = s.queryScalarInt64(ctx, db, "im_users.total", "SELECT COUNT(*) FROM im_users", &res.Warnings) + res.ActiveUserCount = s.queryScalarInt64(ctx, db, "im_users.active", "SELECT COUNT(*) FROM im_users WHERE state=0", &res.Warnings) + res.GroupCount = s.queryScalarInt64(ctx, db, "im_group_settings", "SELECT COUNT(*) FROM im_group_settings WHERE group_status<>'dissolved'", &res.Warnings) + res.DeviceCount = s.queryScalarInt64(ctx, db, "im_devices", "SELECT COUNT(*) FROM im_devices", &res.Warnings) + res.PushTokenCount = s.queryScalarInt64(ctx, db, "im_device_push_tokens", "SELECT COUNT(*) FROM im_device_push_tokens", &res.Warnings) + res.InviteUseCount = s.queryScalarInt64(ctx, db, "im_registration_invite_uses", "SELECT COUNT(*) FROM im_registration_invite_uses", &res.Warnings) + res.OutboxPendingCount = s.queryScalarInt64(ctx, db, "im_business_events_outbox", "SELECT COUNT(*) FROM im_business_events_outbox WHERE status='pending'", &res.Warnings) + return +} + +func (s *sSysImTenant) queryScalarInt64(ctx context.Context, db gdb.DB, label, sql string, warnings *[]string) int64 { + value, err := db.GetValue(ctx, sql) + if err != nil { + *warnings = append(*warnings, label+": "+err.Error()) + return 0 + } + return value.Int64() +} + +func (s *sSysImTenant) queryScalarString(ctx context.Context, db gdb.DB, label, sql string, warnings *[]string) string { + value, err := db.GetValue(ctx, sql) + if err != nil { + *warnings = append(*warnings, label+": "+err.Error()) + return "" + } + return value.String() +} + +func (s *sSysImTenant) DeletionCheck(ctx context.Context, in *sysin.ImTenantDeletionCheckInp) (res *sysin.ImTenantDeletionCheckModel, err error) { + if err = in.Filter(ctx); err != nil { + return + } + usage, err := s.Usage(ctx, &sysin.ImTenantUsageInp{Id: in.Id}) + if err != nil { + return nil, err + } + res = &sysin.ImTenantDeletionCheckModel{ + Id: usage.Id, + Code: usage.Code, + Name: usage.Name, + Blockers: []string{}, + Warnings: usage.Warnings, + UserCount: usage.UserCount, + GroupCount: usage.GroupCount, + PendingOutbox: usage.OutboxPendingCount, + } + if !usage.DbReachable { + res.Blockers = append(res.Blockers, "租户业务库不可达:"+usage.DbCheckError) + } + if usage.UserCount > 0 { + res.Blockers = append(res.Blockers, fmt.Sprintf("租户仍有 %d 个用户", usage.UserCount)) + } + if usage.GroupCount > 0 { + res.Blockers = append(res.Blockers, fmt.Sprintf("租户仍有 %d 个群", usage.GroupCount)) + } + if usage.OutboxPendingCount > 0 { + res.Blockers = append(res.Blockers, fmt.Sprintf("租户仍有 %d 条待处理 Outbox", usage.OutboxPendingCount)) + } + res.CanEnterDeleting = len(res.Blockers) == 0 + return +} + +func (s *sSysImTenant) EnterDeleting(ctx context.Context, in *sysin.ImTenantLifecycleInp) (err error) { + if err = in.Filter(ctx); err != nil { + return + } + check, err := s.DeletionCheck(ctx, &sysin.ImTenantDeletionCheckInp{Id: in.Id}) + if err != nil { + return err + } + if !check.CanEnterDeleting { + return gerror.New("租户未通过删除前检查,不能进入删除流程") + } + if _, err = s.Model(ctx).WherePri(in.Id).Data(g.Map{ + dao.ImTenant.Columns().State: consts.ImTenantStateDeleting, + dao.ImTenant.Columns().UpdatedBy: contexts.GetUserId(ctx), + }).Update(); err != nil { + return gerror.Wrap(err, "设置IM租户删除流程失败,请稍后重试!") + } + return +} + +func (s *sSysImTenant) Restore(ctx context.Context, in *sysin.ImTenantLifecycleInp) (err error) { + if err = in.Filter(ctx); err != nil { + return + } + if _, err = s.Model(ctx). + WherePri(in.Id). + WhereIn(dao.ImTenant.Columns().State, []int{consts.ImTenantStateSuspended, consts.ImTenantStateDeleting}). + Data(g.Map{ + dao.ImTenant.Columns().State: consts.ImTenantStateActive, + dao.ImTenant.Columns().UpdatedBy: contexts.GetUserId(ctx), + }).Update(); err != nil { + return gerror.Wrap(err, "恢复IM租户失败,请稍后重试!") + } + return +} diff --git a/server/internal/logic/sys/im_user.go b/server/internal/logic/sys/im_user.go index 924e525..30e2cf0 100644 --- a/server/internal/logic/sys/im_user.go +++ b/server/internal/logic/sys/im_user.go @@ -11,6 +11,7 @@ import ( "encoding/hex" "encoding/json" "fmt" + "hotgo/internal/consts" "hotgo/internal/library/imtenant" "hotgo/internal/model/input/sysin" "hotgo/internal/service" @@ -248,6 +249,213 @@ func (s *sSysImUser) ResetPassword(ctx context.Context, in *sysin.ImUserPassword return err } +func (s *sSysImUser) RevokeSessions(ctx context.Context, in *sysin.ImUserRevokeSessionsInp) (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 = revokeIMUserSessions(ctx, db, scope, userId, "security.sessions_revoked") + result := "success" + if err != nil { + result = "failed" + } + _ = writeImAdminAudit(ctx, scope.TenantId, "user_sessions_revoke", "user", userId, result, nil) + return err +} + +func (s *sSysImUser) Status(ctx context.Context, in *sysin.ImUserStatusInp) (err error) { + if err = in.Filter(ctx); err != nil { + return + } + if in.State != consts.ImUserStateOk && in.State != consts.ImUserStateSuspended { + return gerror.New("账号状态只能设置为正常或已停用") + } + 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 = updateIMUserStatus(ctx, db, scope, userId, in.State) + result := "success" + if err != nil { + result = "failed" + } + _ = writeImAdminAudit(ctx, scope.TenantId, "user_status_update", "user", userId, result, map[string]any{"state": in.State}) + return err +} + +func (s *sSysImUser) Devices(ctx context.Context, in *sysin.ImUserDeviceListInp) (list []*sysin.ImUserDeviceListModel, totalCount int, err error) { + if err = in.Filter(ctx); err != nil { + return + } + scope, err := resolveImAdminScope(ctx, in.TenantId) + if err != nil { + return + } + userId, err := sysin.ParseOptionalInt64(in.UserId) + if err != nil { + return + } + db, tenant, err := imtenant.DB(ctx, scope.TenantId) + if err != nil { + return + } + + mod := db.Model("im_devices d").Safe().Ctx(ctx). + LeftJoin("im_device_push_tokens p", "p.user_id=d.userid AND p.device_id=d.deviceid"). + Fields( + "CAST(d.userid AS CHAR) user_id", + "d.deviceid device_id", + "COALESCE(d.platform, '') platform", + "COALESCE(d.lang, '') lang", + "d.lastseen last_seen", + "COALESCE(p.provider, '') push_provider", + "COALESCE(p.platform, '') push_platform", + "COALESCE(p.app_version, '') push_app_version", + "(p.id IS NOT NULL) push_token_configured", + "p.updated_at push_updated_at", + ). + Where("d.userid", userId). + Page(in.Page, in.PerPage). + OrderDesc("d.lastseen") + + if err = mod.ScanAndCount(&list, &totalCount, false); err != nil { + return nil, 0, gerror.Wrap(err, "获取IM用户设备列表失败,请稍后重试!") + } + for _, item := range list { + item.TenantId = tenant.Id + item.TenantName = tenant.Name + } + return +} + +func updateIMUserStatus(ctx context.Context, db gdb.DB, scope imAdminScope, userId int64, state int) error { + 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("user-status:%d:%d:%d:%s", scope.TenantId, userId, state, requestId))) + eventKey := hex.EncodeToString(eventKeyBytes[:]) + return db.Transaction(ctx, func(ctx context.Context, tx gdb.TX) error { + current, txErr := tx.GetValue("SELECT state FROM im_users WHERE id=? LIMIT 1", userId) + if txErr != nil { + return txErr + } + if current.IsNil() { + return gerror.New("IM用户不存在") + } + if current.Int() == consts.ImUserStateDeleted { + return gerror.New("已删除账号不能变更状态") + } + if current.Int() == state { + return nil + } + if _, txErr = tx.Exec("UPDATE im_users SET state=?, updatedat=? WHERE id=?", state, now, userId); txErr != nil { + return txErr + } + if _, txErr = tx.Exec(`INSERT INTO im_user_security + (id,user_id,auth_version,created_at,updated_at) + VALUES(?,?,?,?,?) + ON DUPLICATE KEY UPDATE auth_version=auth_version+1,updated_at=VALUES(updated_at)`, + securityId, userId, 2, 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 + } + payload, _ := json.Marshal(map[string]any{ + "user_id": userId, "operator_id": scope.OperatorId, "request_id": requestId, "state": state, "auth_version": version.Int(), + }) + _, 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.user_status_changed", string(payload), now, now) + return txErr + }) +} + +func revokeIMUserSessions(ctx context.Context, db gdb.DB, scope imAdminScope, userId int64, eventType string) error { + 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("%s:%d:%d:%s", eventType, scope.TenantId, userId, requestId))) + eventKey := hex.EncodeToString(eventKeyBytes[:]) + 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用户不存在") + } + if _, txErr = tx.Exec(`INSERT INTO im_user_security + (id,user_id,auth_version,created_at,updated_at) + VALUES(?,?,?,?,?) + ON DUPLICATE KEY UPDATE auth_version=auth_version+1,updated_at=VALUES(updated_at)`, + securityId, userId, 2, 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 + } + payload, _ := json.Marshal(map[string]any{ + "user_id": userId, "operator_id": scope.OperatorId, "request_id": requestId, "auth_version": version.Int(), + }) + _, 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, eventType, string(payload), now, now) + return txErr + }) +} + 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 { diff --git a/server/internal/model/input/sysin/im_audit_log.go b/server/internal/model/input/sysin/im_audit_log.go index 3589a35..717c826 100644 --- a/server/internal/model/input/sysin/im_audit_log.go +++ b/server/internal/model/input/sysin/im_audit_log.go @@ -29,9 +29,6 @@ type ImAuditLogListInp struct { } func (in *ImAuditLogListInp) Filter(ctx context.Context) (err error) { - if in.TenantId <= 0 { - return gerror.New("请选择租户") - } return } @@ -61,9 +58,6 @@ type ImAuditLogViewInp struct { } func (in *ImAuditLogViewInp) Filter(ctx context.Context) (err error) { - if in.TenantId <= 0 { - return gerror.New("请选择租户") - } if in.Id <= 0 { return gerror.New("ID不能为空") } @@ -73,3 +67,18 @@ func (in *ImAuditLogViewInp) Filter(ctx context.Context) (err error) { type ImAuditLogViewModel struct { ImAuditLogListModel } + +type ImAuditLogExportModel struct { + Id int64 `json:"id" dc:"ID"` + TenantName string `json:"tenantName" dc:"租户名称"` + 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:"客户端"` + CreatedAt *gtime.Time `json:"createdAt" dc:"创建时间"` +} diff --git a/server/internal/model/input/sysin/im_tenant.go b/server/internal/model/input/sysin/im_tenant.go index 3b887f7..7bdd32d 100644 --- a/server/internal/model/input/sysin/im_tenant.go +++ b/server/internal/model/input/sysin/im_tenant.go @@ -155,3 +155,67 @@ func (in *ImTenantStatusInp) Filter(ctx context.Context) (err error) { } type ImTenantStatusModel struct{} + +type ImTenantUsageInp struct { + Id int64 `json:"id" v:"required#ID不能为空" dc:"ID"` +} + +func (in *ImTenantUsageInp) Filter(ctx context.Context) (err error) { + if in.Id <= 0 { + return gerror.New("ID不能为空") + } + return +} + +type ImTenantUsageModel struct { + Id int64 `json:"id" dc:"租户ID"` + Code string `json:"code" dc:"租户编码"` + Name string `json:"name" dc:"租户名称"` + DbName string `json:"dbName" dc:"业务库名"` + DbDsnConfigured bool `json:"dbDsnConfigured" dc:"是否配置独立连接串"` + DbReachable bool `json:"dbReachable" dc:"业务库是否可达"` + DbCheckError string `json:"dbCheckError" dc:"业务库检查错误"` + SchemaVersion string `json:"schemaVersion" dc:"Schema版本"` + UserCount int64 `json:"userCount" dc:"用户数"` + ActiveUserCount int64 `json:"activeUserCount" dc:"正常用户数"` + GroupCount int64 `json:"groupCount" dc:"群数"` + DeviceCount int64 `json:"deviceCount" dc:"设备数"` + PushTokenCount int64 `json:"pushTokenCount" dc:"Push Token数"` + InviteUseCount int64 `json:"inviteUseCount" dc:"邀请码使用次数"` + OutboxPendingCount int64 `json:"outboxPendingCount" dc:"待处理Outbox数"` + Warnings []string `json:"warnings" dc:"警告信息"` +} + +type ImTenantDeletionCheckInp struct { + Id int64 `json:"id" v:"required#ID不能为空" dc:"ID"` +} + +func (in *ImTenantDeletionCheckInp) Filter(ctx context.Context) (err error) { + if in.Id <= 0 { + return gerror.New("ID不能为空") + } + return +} + +type ImTenantDeletionCheckModel struct { + Id int64 `json:"id" dc:"租户ID"` + Code string `json:"code" dc:"租户编码"` + Name string `json:"name" dc:"租户名称"` + CanEnterDeleting bool `json:"canEnterDeleting" dc:"是否可进入删除流程"` + Blockers []string `json:"blockers" dc:"阻断原因"` + Warnings []string `json:"warnings" dc:"警告信息"` + UserCount int64 `json:"userCount" dc:"用户数"` + GroupCount int64 `json:"groupCount" dc:"群数"` + PendingOutbox int64 `json:"pendingOutbox" dc:"待处理Outbox"` +} + +type ImTenantLifecycleInp struct { + Id int64 `json:"id" v:"required#ID不能为空" dc:"ID"` +} + +func (in *ImTenantLifecycleInp) Filter(ctx context.Context) (err error) { + if in.Id <= 0 { + return gerror.New("ID不能为空") + } + return +} diff --git a/server/internal/model/input/sysin/im_user.go b/server/internal/model/input/sysin/im_user.go index a8c6b92..7a75886 100644 --- a/server/internal/model/input/sysin/im_user.go +++ b/server/internal/model/input/sysin/im_user.go @@ -87,6 +87,31 @@ func (in *ImUserPasswordResetInp) Filter(ctx context.Context) error { return nil } +type ImUserRevokeSessionsInp struct { + TenantId int64 `json:"tenantId" dc:"租户ID"` + UserId string `json:"userId" v:"required#用户ID不能为空" dc:"用户ID"` +} + +func (in *ImUserRevokeSessionsInp) Filter(ctx context.Context) error { + if _, err := ParseOptionalInt64(in.UserId); err != nil { + return err + } + return nil +} + +type ImUserStatusInp struct { + TenantId int64 `json:"tenantId" dc:"租户ID"` + UserId string `json:"userId" v:"required#用户ID不能为空" dc:"用户ID"` + State int `json:"state" dc:"账号状态"` +} + +func (in *ImUserStatusInp) Filter(ctx context.Context) error { + if _, err := ParseOptionalInt64(in.UserId); err != nil { + return err + } + return nil +} + type ImUserViewModel struct { ImUserListModel Signature string `json:"signature" dc:"个性签名"` @@ -97,3 +122,31 @@ type ImUserViewModel struct { AccessJson string `json:"accessJson" dc:"访问权限"` ProfileVersion int `json:"profileVersion" dc:"资料版本"` } + +type ImUserDeviceListInp struct { + form.PageReq + TenantId int64 `json:"tenantId" dc:"租户ID"` + UserId string `json:"userId" v:"required#用户ID不能为空" dc:"用户ID"` +} + +func (in *ImUserDeviceListInp) Filter(ctx context.Context) (err error) { + if _, err = ParseOptionalInt64(in.UserId); err != nil { + return gerror.New("用户ID不能为空") + } + return +} + +type ImUserDeviceListModel struct { + TenantId int64 `json:"tenantId" dc:"租户ID"` + TenantName string `json:"tenantName" dc:"租户名称"` + UserId string `json:"userId" dc:"用户ID"` + DeviceId string `json:"deviceId" dc:"设备ID"` + Platform string `json:"platform" dc:"平台"` + Lang string `json:"lang" dc:"语言"` + LastSeen *gtime.Time `json:"lastSeen" dc:"最后活跃时间"` + PushProvider string `json:"pushProvider" dc:"Push服务商"` + PushPlatform string `json:"pushPlatform" dc:"Push平台"` + PushAppVersion string `json:"pushAppVersion" dc:"App版本"` + PushTokenConfigured bool `json:"pushTokenConfigured" dc:"是否配置Push Token"` + PushUpdatedAt *gtime.Time `json:"pushUpdatedAt" dc:"Push更新时间"` +} diff --git a/server/internal/service/sys.go b/server/internal/service/sys.go index e8ace97..2f135ab 100644 --- a/server/internal/service/sys.go +++ b/server/internal/service/sys.go @@ -182,6 +182,14 @@ type ( View(ctx context.Context, in *sysin.ImTenantViewInp) (res *sysin.ImTenantViewModel, err error) // Status 更新IM租户状态 Status(ctx context.Context, in *sysin.ImTenantStatusInp) (err error) + // Usage 获取IM租户用量和健康状态 + Usage(ctx context.Context, in *sysin.ImTenantUsageInp) (res *sysin.ImTenantUsageModel, err error) + // DeletionCheck IM租户删除前检查 + DeletionCheck(ctx context.Context, in *sysin.ImTenantDeletionCheckInp) (res *sysin.ImTenantDeletionCheckModel, err error) + // EnterDeleting IM租户进入删除流程 + EnterDeleting(ctx context.Context, in *sysin.ImTenantLifecycleInp) (err error) + // Restore 恢复IM租户 + Restore(ctx context.Context, in *sysin.ImTenantLifecycleInp) (err error) } ISysImRegistration interface { // GetConfig 获取IM注册配置 @@ -212,6 +220,12 @@ type ( View(ctx context.Context, in *sysin.ImUserViewInp) (res *sysin.ImUserViewModel, err error) // ResetPassword 管理员重置IM用户密码 ResetPassword(ctx context.Context, in *sysin.ImUserPasswordResetInp) (err error) + // RevokeSessions 撤销IM用户会话 + RevokeSessions(ctx context.Context, in *sysin.ImUserRevokeSessionsInp) (err error) + // Status 更新IM用户状态 + Status(ctx context.Context, in *sysin.ImUserStatusInp) (err error) + // Devices 获取IM用户设备列表 + Devices(ctx context.Context, in *sysin.ImUserDeviceListInp) (list []*sysin.ImUserDeviceListModel, totalCount int, err error) } ISysImGroup interface { // List 获取IM群组列表 @@ -252,6 +266,8 @@ type ( ISysImAuditLog interface { // List 获取IM审计日志列表 List(ctx context.Context, in *sysin.ImAuditLogListInp) (list []*sysin.ImAuditLogListModel, totalCount int, err error) + // Export 导出IM审计日志列表 + Export(ctx context.Context, in *sysin.ImAuditLogListInp) (err error) // View 获取IM审计日志指定信息 View(ctx context.Context, in *sysin.ImAuditLogViewInp) (res *sysin.ImAuditLogViewModel, err error) } diff --git a/server/storage/data/generate/im_audit_log_menu.sql b/server/storage/data/generate/im_audit_log_menu.sql index c8be0e4..533c2c6 100644 --- a/server/storage/data/generate/im_audit_log_menu.sql +++ b/server/storage/data/generate/im_audit_log_menu.sql @@ -27,3 +27,7 @@ SELECT @auditListId := `id` FROM `hg_admin_menu` WHERE `name` = 'imAuditLogIndex 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'); + +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, '导出审计日志', 'imAuditLogExport', '', '', '3', '', '/imAuditLog/export', '', '', '1', '', '0', '0', '', '0', '1', '0', '4', CONCAT('tr_', @imRootId, ' tr_', @auditDirId, ' tr_', @auditListId, ' '), '20', '', '1', @now, @now +WHERE NOT EXISTS (SELECT 1 FROM `hg_admin_menu` WHERE `name` = 'imAuditLogExport'); diff --git a/server/storage/data/generate/im_tenant_menu.sql b/server/storage/data/generate/im_tenant_menu.sql index 835fed0..becfd21 100644 --- a/server/storage/data/generate/im_tenant_menu.sql +++ b/server/storage/data/generate/im_tenant_menu.sql @@ -35,3 +35,19 @@ 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'); + +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, '租户用量与健康', 'imTenantUsage', '', '', '3', '', '/imTenant/usage', '', '', '1', '', '0', '0', '', '0', '1', '0', '4', CONCAT('tr_', @imRootId, ' tr_', @tenantDirId, ' tr_', @tenantListId, ' '), '40', '', '1', @now, @now +WHERE NOT EXISTS (SELECT 1 FROM `hg_admin_menu` WHERE `name` = 'imTenantUsage'); + +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, '租户删除前检查', 'imTenantDeletionCheck', '', '', '3', '', '/imTenant/deletionCheck', '', '', '1', '', '0', '0', '', '0', '1', '0', '4', CONCAT('tr_', @imRootId, ' tr_', @tenantDirId, ' tr_', @tenantListId, ' '), '50', '', '1', @now, @now +WHERE NOT EXISTS (SELECT 1 FROM `hg_admin_menu` WHERE `name` = 'imTenantDeletionCheck'); + +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, '租户进入删除流程', 'imTenantEnterDeleting', '', '', '3', '', '/imTenant/enterDeleting', '', '', '1', '', '0', '0', '', '0', '1', '0', '4', CONCAT('tr_', @imRootId, ' tr_', @tenantDirId, ' tr_', @tenantListId, ' '), '60', '', '1', @now, @now +WHERE NOT EXISTS (SELECT 1 FROM `hg_admin_menu` WHERE `name` = 'imTenantEnterDeleting'); + +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, '恢复租户', 'imTenantRestore', '', '', '3', '', '/imTenant/restore', '', '', '1', '', '0', '0', '', '0', '1', '0', '4', CONCAT('tr_', @imRootId, ' tr_', @tenantDirId, ' tr_', @tenantListId, ' '), '70', '', '1', @now, @now +WHERE NOT EXISTS (SELECT 1 FROM `hg_admin_menu` WHERE `name` = 'imTenantRestore'); diff --git a/server/storage/data/generate/im_user_menu.sql b/server/storage/data/generate/im_user_menu.sql index d3ed726..4bf5e46 100644 --- a/server/storage/data/generate/im_user_menu.sql +++ b/server/storage/data/generate/im_user_menu.sql @@ -31,3 +31,15 @@ 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'); + +INSERT INTO `hg_admin_menu` (`id`, `pid`, `title`, `name`, `path`, `icon`, `type`, `redirect`, `permissions`, `permission_name`, `component`, `always_show`, `active_menu`, `is_root`, `is_frame`, `frame_src`, `keep_alive`, `hidden`, `affix`, `level`, `tree`, `sort`, `remark`, `status`, `created_at`, `updated_at`) +SELECT NULL, @userListId, '用户设备列表', 'imUserDevices', '', '', '3', '', '/imUser/devices', '', '', '1', '', '0', '0', '', '0', '1', '0', '4', CONCAT('tr_', @imRootId, ' tr_', @userDirId, ' tr_', @userListId, ' '), '30', '', '1', @now, @now +WHERE NOT EXISTS (SELECT 1 FROM `hg_admin_menu` WHERE `name` = 'imUserDevices'); + +INSERT INTO `hg_admin_menu` (`id`, `pid`, `title`, `name`, `path`, `icon`, `type`, `redirect`, `permissions`, `permission_name`, `component`, `always_show`, `active_menu`, `is_root`, `is_frame`, `frame_src`, `keep_alive`, `hidden`, `affix`, `level`, `tree`, `sort`, `remark`, `status`, `created_at`, `updated_at`) +SELECT NULL, @userListId, '撤销用户会话', 'imUserRevokeSessions', '', '', '3', '', '/imUser/revokeSessions', '', '', '1', '', '0', '0', '', '0', '1', '0', '4', CONCAT('tr_', @imRootId, ' tr_', @userDirId, ' tr_', @userListId, ' '), '40', '', '1', @now, @now +WHERE NOT EXISTS (SELECT 1 FROM `hg_admin_menu` WHERE `name` = 'imUserRevokeSessions'); + +INSERT INTO `hg_admin_menu` (`id`, `pid`, `title`, `name`, `path`, `icon`, `type`, `redirect`, `permissions`, `permission_name`, `component`, `always_show`, `active_menu`, `is_root`, `is_frame`, `frame_src`, `keep_alive`, `hidden`, `affix`, `level`, `tree`, `sort`, `remark`, `status`, `created_at`, `updated_at`) +SELECT NULL, @userListId, '更新用户状态', 'imUserStatus', '', '', '3', '', '/imUser/status', '', '', '1', '', '0', '0', '', '0', '1', '0', '4', CONCAT('tr_', @imRootId, ' tr_', @userDirId, ' tr_', @userListId, ' '), '50', '', '1', @now, @now +WHERE NOT EXISTS (SELECT 1 FROM `hg_admin_menu` WHERE `name` = 'imUserStatus'); diff --git a/server/storage/data/im_registration_invite_hotgo.sql b/server/storage/data/im_registration_invite_hotgo.sql index 0eace16..6301bfc 100644 --- a/server/storage/data/im_registration_invite_hotgo.sql +++ b/server/storage/data/im_registration_invite_hotgo.sql @@ -139,6 +139,25 @@ WHERE NOT EXISTS (SELECT 1 FROM hg_admin_menu WHERE name = 'imRegistrationInvite 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; +SET @imTenantPage := (SELECT id FROM hg_admin_menu WHERE name = 'imTenantIndex' 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 @imTenantPage, 4, CONCAT('tr_', @imTenantPage, ' '), '租户用量与健康', 'imTenantUsage', '', '', 3, + '/imTenant/usage', '', 1, 40, 1, NOW(), NOW() +WHERE @imTenantPage IS NOT NULL + AND NOT EXISTS (SELECT 1 FROM hg_admin_menu WHERE name = 'imTenantUsage'); + +INSERT INTO hg_admin_menu + (pid, level, tree, title, name, path, icon, type, permissions, component, hidden, sort, status, created_at, updated_at) +SELECT @imTenantPage, 4, CONCAT('tr_', @imTenantPage, ' '), title, name, '', '', 3, permissions, '', 1, sort, 1, NOW(), NOW() +FROM ( + SELECT '租户删除前检查' title, 'imTenantDeletionCheck' name, '/imTenant/deletionCheck' permissions, 50 sort UNION ALL + SELECT '租户进入删除流程', 'imTenantEnterDeleting', '/imTenant/enterDeleting', 60 UNION ALL + SELECT '恢复租户', 'imTenantRestore', '/imTenant/restore', 70 +) p +WHERE @imTenantPage IS NOT NULL + AND NOT EXISTS (SELECT 1 FROM hg_admin_menu m WHERE m.name = p.name); + 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() @@ -152,6 +171,17 @@ FROM ( ) p WHERE NOT EXISTS (SELECT 1 FROM hg_admin_menu m WHERE m.name = p.name); +SET @systemConfigPage := (SELECT id FROM hg_admin_menu WHERE name = 'system_config' 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 @systemConfigPage, 3, CONCAT('tr_', @systemConfigPage, ' '), title, name, '', '', 3, permissions, '', 1, sort, 1, NOW(), NOW() +FROM ( + SELECT '查看IM注册配置' title, 'imRegistrationConfigGet' name, '/imRegistration/config/get' permissions, 80 sort UNION ALL + SELECT '更新IM注册配置', 'imRegistrationConfigUpdate', '/imRegistration/config/update', 90 +) p +WHERE @systemConfigPage IS NOT NULL + AND 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) @@ -159,3 +189,24 @@ SELECT @imUserPage, 4, CONCAT('tr_', @imUserPage, ' '), '重置用户密码', 'i '/imUser/resetPassword', '', 1, 20, 1, NOW(), NOW() WHERE @imUserPage IS NOT NULL AND NOT EXISTS (SELECT 1 FROM hg_admin_menu WHERE name = 'imUserResetPassword'); + +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, ' '), '用户设备列表', 'imUserDevices', '', '', 3, + '/imUser/devices', '', 1, 30, 1, NOW(), NOW() +WHERE @imUserPage IS NOT NULL + AND NOT EXISTS (SELECT 1 FROM hg_admin_menu WHERE name = 'imUserDevices'); + +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, ' '), '撤销用户会话', 'imUserRevokeSessions', '', '', 3, + '/imUser/revokeSessions', '', 1, 40, 1, NOW(), NOW() +WHERE @imUserPage IS NOT NULL + AND NOT EXISTS (SELECT 1 FROM hg_admin_menu WHERE name = 'imUserRevokeSessions'); + +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, ' '), '更新用户状态', 'imUserStatus', '', '', 3, + '/imUser/status', '', 1, 50, 1, NOW(), NOW() +WHERE @imUserPage IS NOT NULL + AND NOT EXISTS (SELECT 1 FROM hg_admin_menu WHERE name = 'imUserStatus'); diff --git a/web/src/api/imAuditLog/index.ts b/web/src/api/imAuditLog/index.ts index 52bdfc8..1e43cba 100644 --- a/web/src/api/imAuditLog/index.ts +++ b/web/src/api/imAuditLog/index.ts @@ -1,4 +1,4 @@ -import { http } from '@/utils/http/axios'; +import { http, jumpExport } from '@/utils/http/axios'; // 获取IM审计日志列表 export function List(params) { @@ -17,3 +17,8 @@ export function View(params) { params, }); } + +// 导出IM审计日志 +export function Export(params) { + jumpExport('/imAuditLog/export', params); +} diff --git a/web/src/api/imTenant/index.ts b/web/src/api/imTenant/index.ts index bab11ec..4007456 100644 --- a/web/src/api/imTenant/index.ts +++ b/web/src/api/imTenant/index.ts @@ -35,3 +35,36 @@ export function View(params) { params, }); } + +// 获取IM租户用量和健康状态 +export function Usage(params) { + return http.request({ + url: '/imTenant/usage', + method: 'GET', + params, + }); +} + +export function DeletionCheck(params) { + return http.request({ + url: '/imTenant/deletionCheck', + method: 'GET', + params, + }); +} + +export function EnterDeleting(params) { + return http.request({ + url: '/imTenant/enterDeleting', + method: 'POST', + params, + }); +} + +export function Restore(params) { + return http.request({ + url: '/imTenant/restore', + method: 'POST', + params, + }); +} diff --git a/web/src/api/imUser/index.ts b/web/src/api/imUser/index.ts index 443d32a..e874962 100644 --- a/web/src/api/imUser/index.ts +++ b/web/src/api/imUser/index.ts @@ -26,3 +26,30 @@ export function ResetPassword(params) { params, }); } + +// 撤销IM用户会话 +export function RevokeSessions(params) { + return http.request({ + url: '/imUser/revokeSessions', + method: 'POST', + params, + }); +} + +// 更新IM用户状态 +export function Status(params) { + return http.request({ + url: '/imUser/status', + method: 'POST', + params, + }); +} + +// 获取IM用户设备列表 +export function Devices(params) { + return http.request({ + url: '/imUser/devices', + method: 'GET', + params, + }); +} diff --git a/web/src/views/im/registrationInvite/index.vue b/web/src/views/im/registrationInvite/index.vue index 9bef541..4fe11c2 100644 --- a/web/src/views/im/registrationInvite/index.vue +++ b/web/src/views/im/registrationInvite/index.vue @@ -1,7 +1,7 @@