524 lines
16 KiB
Go
524 lines
16 KiB
Go
// 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/imtenant"
|
|
"hotgo/internal/model/input/sysin"
|
|
"hotgo/internal/service"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/gogf/gf/v2/database/gdb"
|
|
"github.com/gogf/gf/v2/errors/gerror"
|
|
"github.com/gogf/gf/v2/os/gctx"
|
|
"github.com/gogf/gf/v2/util/guid"
|
|
"golang.org/x/crypto/bcrypt"
|
|
)
|
|
|
|
type sSysImUser struct{}
|
|
|
|
func NewSysImUser() *sSysImUser {
|
|
return &sSysImUser{}
|
|
}
|
|
|
|
func init() {
|
|
service.RegisterSysImUser(NewSysImUser())
|
|
}
|
|
|
|
// List 获取IM用户列表
|
|
func (s *sSysImUser) List(ctx context.Context, in *sysin.ImUserListInp) (list []*sysin.ImUserListModel, totalCount int, err error) {
|
|
if err = in.Filter(ctx); err != nil {
|
|
return
|
|
}
|
|
|
|
scope, err := resolveImAdminScope(ctx, in.TenantId)
|
|
if err != nil {
|
|
return
|
|
}
|
|
db, tenant, err := imtenant.DB(ctx, scope.TenantId)
|
|
if err != nil {
|
|
return
|
|
}
|
|
|
|
mod := s.listModel(ctx, db)
|
|
mod = s.applyListWhere(mod, in)
|
|
mod = mod.Page(in.Page, in.PerPage).OrderDesc("u.id")
|
|
|
|
if err = mod.ScanAndCount(&list, &totalCount, false); err != nil {
|
|
err = gerror.Wrap(err, "获取IM用户列表失败,请稍后重试!")
|
|
return
|
|
}
|
|
if len(list) == 0 {
|
|
return
|
|
}
|
|
|
|
for _, item := range list {
|
|
item.TenantId = tenant.Id
|
|
item.TenantName = tenant.Name
|
|
}
|
|
if err = s.fillAuthAndCredentials(ctx, db, list); err != nil {
|
|
return
|
|
}
|
|
return
|
|
}
|
|
|
|
func (s *sSysImUser) listModel(ctx context.Context, db gdb.DB) *gdb.Model {
|
|
return db.Model("im_users u").Safe().Ctx(ctx).
|
|
LeftJoin("im_user_profiles up", "up.user_id = u.id").
|
|
LeftJoin("im_user_security us", "us.user_id = u.id").
|
|
Fields(
|
|
"CAST(u.id AS CHAR) user_id",
|
|
"u.username",
|
|
"COALESCE(u.phone, '') phone",
|
|
"u.state",
|
|
"u.lastseen last_seen",
|
|
"u.useragent user_agent",
|
|
"u.createdat created_at",
|
|
"u.updatedat updated_at",
|
|
"up.nickname",
|
|
"up.public_account",
|
|
"up.gender",
|
|
"up.region",
|
|
"us.auth_version",
|
|
"us.password_updated_at",
|
|
)
|
|
}
|
|
|
|
func (s *sSysImUser) applyListWhere(mod *gdb.Model, in *sysin.ImUserListInp) *gdb.Model {
|
|
if userId, _ := sysin.ParseOptionalInt64(in.UserId); userId > 0 {
|
|
mod = mod.Where("u.id", userId)
|
|
}
|
|
if in.Nickname != "" {
|
|
mod = mod.WhereLike("up.nickname", "%"+in.Nickname+"%")
|
|
}
|
|
if in.AuthName != "" {
|
|
mod = mod.Where("u.username = ? OR u.phone = ? OR up.email = ?", in.AuthName, in.AuthName, in.AuthName)
|
|
}
|
|
if in.State != nil {
|
|
mod = mod.Where("u.state", *in.State)
|
|
}
|
|
if len(in.CreatedAt) == 2 {
|
|
mod = mod.WhereBetween("u.createdat", in.CreatedAt[0], in.CreatedAt[1])
|
|
}
|
|
if len(in.LastSeen) == 2 {
|
|
mod = mod.WhereBetween("u.lastseen", in.LastSeen[0], in.LastSeen[1])
|
|
}
|
|
return mod
|
|
}
|
|
|
|
func (s *sSysImUser) fillAuthAndCredentials(ctx context.Context, db gdb.DB, list []*sysin.ImUserListModel) (err error) {
|
|
ids := make([]int64, 0, len(list))
|
|
userMap := make(map[int64]*sysin.ImUserListModel, len(list))
|
|
for _, item := range list {
|
|
userId, parseErr := sysin.ParseOptionalInt64(item.UserId)
|
|
if parseErr != nil {
|
|
return parseErr
|
|
}
|
|
ids = append(ids, userId)
|
|
userMap[userId] = item
|
|
}
|
|
|
|
credentialRows, err := db.Model("im_credentials").Safe().Ctx(ctx).
|
|
Fields("userid", "method", "value", "done").
|
|
WhereIn("userid", ids).
|
|
WhereNull("deletedat").
|
|
OrderAsc("method").OrderAsc("value").
|
|
All()
|
|
if err != nil {
|
|
return gerror.Wrap(err, "获取IM用户凭证失败,请稍后重试!")
|
|
}
|
|
credentialMap := make(map[int64][]string)
|
|
for _, row := range credentialRows {
|
|
userId := row["userid"].Int64()
|
|
method := row["method"].String()
|
|
value := row["value"].String()
|
|
done := row["done"].Bool()
|
|
if !done {
|
|
value += "(未验证)"
|
|
}
|
|
credentialMap[userId] = append(credentialMap[userId], method+":"+value)
|
|
}
|
|
|
|
for userId, item := range userMap {
|
|
item.AuthNames = item.Username
|
|
if item.Phone != "" && item.Phone != item.Username {
|
|
item.AuthNames += ", " + item.Phone
|
|
}
|
|
item.Credentials = strings.Join(credentialMap[userId], ", ")
|
|
}
|
|
return
|
|
}
|
|
|
|
// View 获取IM用户指定信息
|
|
func (s *sSysImUser) View(ctx context.Context, in *sysin.ImUserViewInp) (res *sysin.ImUserViewModel, err error) {
|
|
if err = in.Filter(ctx); err != nil {
|
|
return
|
|
}
|
|
|
|
scope, err := resolveImAdminScope(ctx, in.TenantId)
|
|
if err != nil {
|
|
return
|
|
}
|
|
db, tenant, err := imtenant.DB(ctx, scope.TenantId)
|
|
if err != nil {
|
|
return
|
|
}
|
|
|
|
userId, err := sysin.ParseOptionalInt64(in.UserId)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
fields := append(s.viewFields(), "u.access access_json", "u.public profile_json", "u.trusted trusted_json")
|
|
err = db.Model("im_users u").Safe().Ctx(ctx).
|
|
LeftJoin("im_user_profiles up", "up.user_id = u.id").
|
|
LeftJoin("im_user_security us", "us.user_id = u.id").
|
|
Fields(fields...).
|
|
Where("u.id", userId).
|
|
Scan(&res)
|
|
if err != nil {
|
|
return nil, gerror.Wrap(err, "获取IM用户信息失败,请稍后重试!")
|
|
}
|
|
if res == nil {
|
|
return nil, nil
|
|
}
|
|
|
|
res.TenantId = tenant.Id
|
|
res.TenantName = tenant.Name
|
|
list := []*sysin.ImUserListModel{&res.ImUserListModel}
|
|
if err = s.fillAuthAndCredentials(ctx, db, list); err != nil {
|
|
return nil, err
|
|
}
|
|
return
|
|
}
|
|
|
|
func (s *sSysImUser) viewFields() []interface{} {
|
|
return []interface{}{
|
|
"CAST(u.id AS CHAR) user_id",
|
|
"u.username",
|
|
"COALESCE(u.phone, '') phone",
|
|
"u.state",
|
|
"u.lastseen last_seen",
|
|
"u.useragent user_agent",
|
|
"u.createdat created_at",
|
|
"u.updatedat updated_at",
|
|
"up.nickname",
|
|
"up.public_account",
|
|
"up.gender",
|
|
"up.region",
|
|
"up.signature",
|
|
"up.avatar_file_id",
|
|
"CAST(up.birth_date AS CHAR) birth_date",
|
|
"up.version profile_version",
|
|
"us.auth_version",
|
|
"us.password_updated_at",
|
|
}
|
|
}
|
|
|
|
func (s *sSysImUser) ResetPassword(ctx context.Context, in *sysin.ImUserPasswordResetInp) (err error) {
|
|
if err = in.Filter(ctx); err != nil {
|
|
return
|
|
}
|
|
scope, err := resolveImAdminScope(ctx, in.TenantId)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
userId, err := sysin.ParseOptionalInt64(in.UserId)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
db, _, err := imtenant.DB(ctx, scope.TenantId)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
err = resetIMUserPassword(ctx, db, scope, userId, in.NewPassword)
|
|
result := "success"
|
|
if err != nil {
|
|
result = "failed"
|
|
}
|
|
_ = writeImAdminAudit(ctx, scope.TenantId, "user_password_reset", "user", userId, result, nil)
|
|
return err
|
|
}
|
|
|
|
func (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 {
|
|
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
|
|
})
|
|
}
|