feat: expand IM admin controls

This commit is contained in:
richjohnrambo
2026-08-21 15:09:39 +08:00
parent c3676f9744
commit 543c7b624e
29 changed files with 1308 additions and 39 deletions
+36 -2
View File
@@ -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)
}
+137
View File
@@ -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
}
+208
View File
@@ -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 {