// 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/consts" "hotgo/internal/library/contexts" "hotgo/internal/library/imkafka" "hotgo/internal/library/imtenant" "hotgo/internal/model/input/sysin" "hotgo/internal/service" "net/url" "strconv" "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 } tenant, err := resolveImAdminTenant(ctx, in.TenantId) if err != nil { return } values := addPageQuery(url.Values{}, in.Page, in.PerPage) if in.UserId != "" { values.Set("user_id", in.UserId) } if in.Nickname != "" { values.Set("nickname", in.Nickname) } if in.AuthName != "" { values.Set("auth_name", in.AuthName) } if in.State != nil { values.Set("state", intToString(*in.State)) } addTimeRangeQuery(values, "created_at", in.CreatedAt) addTimeRangeQuery(values, "last_seen", in.LastSeen) return imAdminQueryList[sysin.ImUserListModel](ctx, tenant, "/internal/admin-query/tenants/"+url.PathEscape(tenant.Code)+"/users", values, map[string]string{ "public": "profileJson", "trusted": "trustedJson", "access": "accessJson", }, ) } 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 } if userId <= 0 { continue } ids = append(ids, userId) userMap[userId] = item } if len(ids) == 0 { return nil } 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 } tenant, err := resolveImAdminTenant(ctx, in.TenantId) if err != nil { return nil, nil } return imAdminQueryOne[sysin.ImUserViewModel](ctx, tenant, "/internal/admin-query/tenants/"+url.PathEscape(tenant.Code)+"/users/"+url.PathEscape(in.UserId), map[string]string{ "public": "profileJson", "trusted": "trustedJson", "access": "accessJson", }, ) } 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 (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 } tenant, err := resolveImAdminTenant(ctx, in.TenantId) if err != nil { return } return imAdminQueryList[sysin.ImUserDeviceListModel](ctx, tenant, "/internal/admin-query/tenants/"+url.PathEscape(tenant.Code)+"/users/"+url.PathEscape(in.UserId)+"/devices", addPageQuery(url.Values{}, in.Page, in.PerPage), nil, ) } func (s *sSysImUser) Ban(ctx context.Context, in *sysin.ImUserGovernanceInp) (res *sysin.ImUserCommandModel, err error) { return s.publishGovernanceCommand(ctx, in, consts.ImAdminCommandUserBan) } func (s *sSysImUser) Unban(ctx context.Context, in *sysin.ImUserGovernanceInp) (res *sysin.ImUserCommandModel, err error) { return s.publishGovernanceCommand(ctx, in, consts.ImAdminCommandUserUnban) } func (s *sSysImUser) Freeze(ctx context.Context, in *sysin.ImUserGovernanceInp) (res *sysin.ImUserCommandModel, err error) { return s.publishGovernanceCommand(ctx, in, consts.ImAdminCommandUserFreeze) } func (s *sSysImUser) Unfreeze(ctx context.Context, in *sysin.ImUserGovernanceInp) (res *sysin.ImUserCommandModel, err error) { return s.publishGovernanceCommand(ctx, in, consts.ImAdminCommandUserUnfreeze) } func (s *sSysImUser) DeviceKick(ctx context.Context, in *sysin.ImUserGovernanceInp) (res *sysin.ImUserCommandModel, err error) { if err = in.FilterDevice(ctx); err != nil { return nil, err } return s.publishGovernanceCommand(ctx, in, consts.ImAdminCommandUserDeviceKick) } func (s *sSysImUser) LogoutAll(ctx context.Context, in *sysin.ImUserGovernanceInp) (res *sysin.ImUserCommandModel, err error) { return s.publishGovernanceCommand(ctx, in, consts.ImAdminCommandUserLogoutAll) } func (s *sSysImUser) AccountCancel(ctx context.Context, in *sysin.ImUserGovernanceInp) (res *sysin.ImUserCommandModel, err error) { return s.publishGovernanceCommand(ctx, in, consts.ImAdminCommandUserCancel) } func (s *sSysImUser) AccountDelete(ctx context.Context, in *sysin.ImUserGovernanceInp) (res *sysin.ImUserCommandModel, err error) { in.Hard = true return s.publishGovernanceCommand(ctx, in, consts.ImAdminCommandUserDelete) } func (s *sSysImUser) publishGovernanceCommand(ctx context.Context, in *sysin.ImUserGovernanceInp, eventType string) (*sysin.ImUserCommandModel, error) { if err := in.Filter(ctx); err != nil { return nil, err } _, tenant, err := imtenant.DB(ctx, in.TenantId) if err != nil { return nil, err } user := contexts.GetUser(ctx) actorName := "" if user != nil { actorName = user.RealName } payload := map[string]any{ "actor_user_id": strconv.FormatInt(in.ActorUserId, 10), "user_id": strconv.FormatInt(in.UserId, 10), "target_user_id": strconv.FormatInt(in.UserId, 10), "reason": in.Reason, } if in.DeviceId != "" { payload["device_id"] = in.DeviceId } if eventType == consts.ImAdminCommandUserCancel || eventType == consts.ImAdminCommandUserDelete { payload["hard"] = in.Hard } command, err := imkafka.PublishAdminCommand(ctx, imkafka.AdminCommandInput{ TenantID: tenant.Id, TenantCode: tenant.Code, EventType: eventType, BizType: "user", BizID: strconv.FormatInt(in.UserId, 10), Actor: &imkafka.Actor{ Type: "admin", ID: strconv.FormatInt(contexts.GetUserId(ctx), 10), DisplayName: actorName, }, Payload: payload, CreatedBy: contexts.GetUserId(ctx), }) if err != nil { return nil, err } return &sysin.ImUserCommandModel{ CommandID: command.CommandID, EventID: command.EventID, Status: command.Status, TenantID: command.TenantID, TenantCode: command.TenantCode, EventType: command.EventType, IdempotencyKey: command.IdempotencyKey, }, nil } 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 { 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 }) }