// 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" "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/adminin" "hotgo/internal/model/input/sysin" "hotgo/internal/service" "hotgo/utility/tree" "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 ( tenantConfigAliyunModerationEnabled = "aliyun_moderation_enabled" tenantConfigLocalModerationEnabled = "local_moderation_enabled" tenantConfigMessageEncryptionEnabled = "message_encryption_enabled" tenantConfigRealnameReviewPolicy = "realname_review_policy" ) // imManageRoleKey 商户级IM管理员角色标识 const imManageRoleKey = "imManage" type imTenantConfigModeration struct { Id int64 `json:"id" orm:"id"` TenantId int64 `json:"tenant_id" orm:"tenant_id"` MaxUsers uint64 `json:"max_users" orm:"max_users"` MaxGroups uint64 `json:"max_groups" orm:"max_groups"` MaxGroupMembers uint `json:"max_group_members" orm:"max_group_members"` AliyunModerationEnabled int `json:"aliyun_moderation_enabled" orm:"aliyun_moderation_enabled"` LocalModerationEnabled int `json:"local_moderation_enabled" orm:"local_moderation_enabled"` MessageEncryptionEnabled int `json:"message_encryption_enabled" orm:"message_encryption_enabled"` RealnameReviewPolicy int `json:"realname_review_policy" orm:"realname_review_policy"` } func NewSysImTenant() *sSysImTenant { return &sSysImTenant{} } func init() { service.RegisterSysImTenant(NewSysImTenant()) } // Model IM租户ORM模型 func (s *sSysImTenant) Model(ctx context.Context, option ...*handler.Option) *gdb.Model { return handler.Model(imtenant.RegistryDB().Model(dao.ImTenant.Table()).Safe().Ctx(ctx), option...) } func (s *sSysImTenant) configModel(ctx context.Context) *gdb.Model { return imtenant.RegistryDB().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 !isImPlatformAdmin(ctx, contexts.GetUserId(ctx)) { tenantId := contexts.GetTenantId(ctx) if tenantId <= 0 { err = gerror.New("当前管理员未关联IM租户") return } mod = mod.Where(dao.ImTenant.Columns().Id, tenantId) } 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 != "" && isSuperTenantAdmin(ctx) { 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 } // 业务库名仅超管可见,非超管脱敏返回 if !isSuperTenantAdmin(ctx) { for _, item := range list { item.DbName = "" } } 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 []*imTenantConfigModeration if err = s.configModel(ctx). WhereIn(dao.ImTenantConfig.Columns().TenantId, ids). Scan(&configs); err != nil { return gerror.Wrap(err, "获取IM租户配置失败,请稍后重试!") } configMap := make(map[int64]*imTenantConfigModeration, 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.AliyunModerationEnabled = conf.AliyunModerationEnabled item.LocalModerationEnabled = conf.LocalModerationEnabled item.MessageEncryptionEnabled = conf.MessageEncryptionEnabled item.RealnameReviewPolicy = conf.RealnameReviewPolicy } item.DbDsnConfigured = dsnMap[item.Id] != "" } return } // Add 新增IM租户 func (s *sSysImTenant) Add(ctx context.Context, in *sysin.ImTenantEditInp) (err error) { if err = in.Filter(ctx); err != nil { return } if !isImPlatformAdmin(ctx, contexts.GetUserId(ctx)) { return gerror.New("只有平台管理员可以新增商户") } in.Id = 0 return s.saveTenant(ctx, in) } // Edit 修改IM租户 func (s *sSysImTenant) Edit(ctx context.Context, in *sysin.ImTenantEditInp) (err error) { if err = in.Filter(ctx); err != nil { return } if in.Id <= 0 { return gerror.New("商户ID不能为空") } if !isImPlatformAdmin(ctx, contexts.GetUserId(ctx)) { tenantId := contexts.GetTenantId(ctx) if tenantId <= 0 { return gerror.New("当前管理员未关联IM租户") } if in.Id != tenantId { return gerror.New("无权编辑其它商户") } } return s.saveTenant(ctx, in) } // isSuperTenantAdmin 当前操作者是否为admin超管 func isSuperTenantAdmin(ctx context.Context) bool { return service.AdminMember().VerifySuperId(ctx, contexts.GetUserId(ctx)) } func (s *sSysImTenant) saveTenant(ctx context.Context, in *sysin.ImTenantEditInp) (err error) { super := isSuperTenantAdmin(ctx) isCreate := in.Id == 0 tenantID := in.Id // 业务库名与独立连接串仅超管可维护,非超管提交一律强制忽略 if !super { in.DbName = "" in.DbDsn = "" } err = imtenant.RegistryDB().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 data := g.Map{ dao.ImTenant.Columns().Code: in.Code, dao.ImTenant.Columns().Name: in.Name, dao.ImTenant.Columns().TenantDesc: in.TenantDesc, dao.ImTenant.Columns().State: in.State, dao.ImTenant.Columns().UpdatedBy: userId, } if super { data[dao.ImTenant.Columns().DbName] = in.DbName data[dao.ImTenant.Columns().DbDsn] = in.DbDsn } if _, err = s.Model(ctx). WherePri(in.Id).Data(data).Update(); err != nil { return gerror.Wrap(err, "修改IM租户失败,请稍后重试!") } return s.saveConfig(ctx, in, userId) } in.CreatedBy = userId in.UpdatedBy = userId id, err := s.Model(ctx, &handler.Option{FilterAuth: false}). Fields(sysin.ImTenantInsertFields{}). Data(in).InsertAndGetId() if err != nil { return gerror.Wrap(err, "新增IM租户失败,请稍后重试!") } tenantID = id in.Id = id return s.saveConfig(ctx, in, userId) }) if err != nil { return err } // 新增商户时自动创建绑定的后台管理员账号,失败则补偿回滚商户数据 if isCreate { if err = s.createTenantAdminMember(ctx, super, in, tenantID); err != nil { s.rollbackTenantCreation(ctx, tenantID) return err } } _ = publishImPolicyEvent(ctx, tenantID, in.Code, "tenant.features.updated", "moderation", 0) return nil } // createTenantAdminMember 为新增商户自动创建后台管理员账号(默认绑定imManage角色) func (s *sSysImTenant) createTenantAdminMember(ctx context.Context, super bool, in *sysin.ImTenantEditInp, tenantId int64) (err error) { var role *entity.AdminRole if err = dao.AdminRole.Ctx(ctx). Fields(dao.AdminRole.Columns().Id). Where(dao.AdminRole.Columns().Key, imManageRoleKey). Where(dao.AdminRole.Columns().Status, consts.StatusEnabled). OrderAsc(dao.AdminRole.Columns().Id). Scan(&role); err != nil { return gerror.Wrap(err, "查询IM管理员角色失败,请稍后重试!") } if role == nil || role.Id <= 0 { return gerror.New("IM管理员角色(imManage)未配置,请联系运维") } // 部门挂载:超管挂公司部门;非超管受部门数据权限限制,只能从自己部门的下级中选取 var deptId int64 if super { companyDeptId, deptErr := dao.AdminDept.Ctx(ctx). Fields(dao.AdminDept.Columns().Id). Where(dao.AdminDept.Columns().Type, consts.DeptTypeCompany). Where(dao.AdminDept.Columns().Status, consts.StatusEnabled). OrderAsc(dao.AdminDept.Columns().Id). Limit(1). Value() if deptErr != nil { return gerror.Wrap(deptErr, "获取公司部门失败,请稍后重试!") } if companyDeptId.IsEmpty() { return gerror.New("请先配置公司部门") } deptId = companyDeptId.Int64() } else { mb := contexts.GetUser(ctx) if mb == nil || mb.DeptId <= 0 { return gerror.New("获取当前用户部门失败") } subDeptId, deptErr := dao.AdminDept.Ctx(ctx). Fields(dao.AdminDept.Columns().Id). Where(dao.AdminDept.Columns().Status, consts.StatusEnabled). WhereNot(dao.AdminDept.Columns().Id, mb.DeptId). WhereLike(dao.AdminDept.Columns().Tree, "%"+tree.GetIdLabel(mb.DeptId)+"%"). OrderAsc(dao.AdminDept.Columns().Id). Limit(1). Value() if deptErr != nil { return gerror.Wrap(deptErr, "获取下级部门失败,请稍后重试!") } if subDeptId.IsEmpty() { return gerror.New("当前部门下没有可用的下级部门来挂载商户管理员账号,请先配置下级部门") } deptId = subDeptId.Int64() } // 透传底层可读错误,便于前端定位 return service.AdminMember().Edit(ctx, &adminin.MemberEditInp{ TenantId: tenantId, RoleId: role.Id, Username: in.AdminUsername, Password: in.AdminPassword, RealName: in.Name + "管理员", DeptId: deptId, Status: consts.StatusEnabled, }) } // rollbackTenantCreation 商户创建补偿:删除已写入的商户及配置记录 func (s *sSysImTenant) rollbackTenantCreation(ctx context.Context, tenantId int64) { _ = imtenant.RegistryDB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) (err error) { if _, err = s.Model(ctx, &handler.Option{FilterAuth: false}). WherePri(tenantId).Delete(); err != nil { return } _, err = s.configModel(ctx). Where(dao.ImTenantConfig.Columns().TenantId, tenantId).Delete() return }) } 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, in *sysin.ImTenantEditInp, userId int64) (err error) { if in.MessageEncryptionEnabled == 1 { count, queryErr := imtenant.RegistryDB().Model(imMessageEncryptionKeyTable).Safe().Ctx(ctx). Where("tenant_id", in.Id). Where("status", consts.ImMessageKeyStatusActive). Count() if queryErr != nil { return gerror.Wrap(queryErr, "检查商户消息密钥失败") } if count == 0 { return gerror.New("启用消息加密前必须先在消息密钥管理中初始化有效密钥") } } var conf *entity.ImTenantConfig if err = s.configModel(ctx). Where(dao.ImTenantConfig.Columns().TenantId, in.Id). Scan(&conf); err != nil { return gerror.Wrap(err, "获取IM租户配置失败,请稍后重试!") } data := g.Map{ dao.ImTenantConfig.Columns().MaxUsers: in.MaxUsers, dao.ImTenantConfig.Columns().MaxGroups: in.MaxGroups, dao.ImTenantConfig.Columns().MaxGroupMembers: in.MaxGroupMembers, tenantConfigAliyunModerationEnabled: in.AliyunModerationEnabled, tenantConfigLocalModerationEnabled: in.LocalModerationEnabled, tenantConfigMessageEncryptionEnabled: in.MessageEncryptionEnabled, tenantConfigRealnameReviewPolicy: in.RealnameReviewPolicy, } 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, tenantConfigAliyunModerationEnabled, tenantConfigLocalModerationEnabled, tenantConfigMessageEncryptionEnabled, tenantConfigRealnameReviewPolicy, dao.ImTenantConfig.Columns().UpdatedBy, ). WherePri(conf.Id).Data(data).Update(); err != nil { return gerror.Wrap(err, "修改IM租户配置失败,请稍后重试!") } return } data[dao.ImTenantConfig.Columns().TenantId] = in.Id data[dao.ImTenantConfig.Columns().CreatedBy] = userId data[dao.ImTenantConfig.Columns().UpdatedBy] = userId if _, err = s.configModel(ctx). Fields( dao.ImTenantConfig.Columns().TenantId, dao.ImTenantConfig.Columns().MaxUsers, dao.ImTenantConfig.Columns().MaxGroups, dao.ImTenantConfig.Columns().MaxGroupMembers, tenantConfigAliyunModerationEnabled, tenantConfigLocalModerationEnabled, tenantConfigMessageEncryptionEnabled, tenantConfigRealnameReviewPolicy, dao.ImTenantConfig.Columns().CreatedBy, dao.ImTenantConfig.Columns().UpdatedBy, ). 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 *imTenantConfigModeration 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.AliyunModerationEnabled = conf.AliyunModerationEnabled res.LocalModerationEnabled = conf.LocalModerationEnabled res.MessageEncryptionEnabled = conf.MessageEncryptionEnabled res.RealnameReviewPolicy = conf.RealnameReviewPolicy } res.DbDsnConfigured = res.DbDsn != "" // 业务库名与独立连接串仅超管可见,非超管脱敏返回(保留是否已配置标记) if !isSuperTenantAdmin(ctx) { res.DbName = "" 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 } 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{}, } // 业务库名仅超管可见,非超管脱敏返回 if !isSuperTenantAdmin(ctx) { res.DbName = "" } 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 }