993 lines
33 KiB
Go
993 lines
33 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/base64"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"fmt"
|
|
"hotgo/internal/consts"
|
|
"hotgo/internal/dao"
|
|
"hotgo/internal/library/contexts"
|
|
"hotgo/internal/library/imadmin"
|
|
"hotgo/internal/library/imkafka"
|
|
"hotgo/internal/library/imtenant"
|
|
"hotgo/internal/model/entity"
|
|
"hotgo/internal/model/input/sysin"
|
|
"hotgo/internal/service"
|
|
"hotgo/utility/excel"
|
|
"io"
|
|
"net"
|
|
"net/url"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/gogf/gf/v2/database/gdb"
|
|
"github.com/gogf/gf/v2/errors/gerror"
|
|
"github.com/gogf/gf/v2/frame/g"
|
|
"github.com/gogf/gf/v2/os/gctx"
|
|
"github.com/gogf/gf/v2/os/gtime"
|
|
"github.com/gogf/gf/v2/util/guid"
|
|
"github.com/xuri/excelize/v2"
|
|
"golang.org/x/crypto/bcrypt"
|
|
)
|
|
|
|
type sSysImUser struct{}
|
|
|
|
func NewSysImUser() *sSysImUser {
|
|
return &sSysImUser{}
|
|
}
|
|
|
|
func init() {
|
|
service.RegisterSysImUser(NewSysImUser())
|
|
}
|
|
|
|
func (s *sSysImUser) Create(ctx context.Context, in *sysin.ImUserCreateInp) (res *sysin.ImUserCreateModel, err error) {
|
|
if err = in.Filter(ctx); err != nil {
|
|
return nil, err
|
|
}
|
|
tenant, err := resolveImAdminTenant(ctx, in.TenantId)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if in.GroupId > 0 {
|
|
if err = validateImUserPolicyGroup(ctx, tenant.Id, in.GroupId); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
payload := map[string]any{
|
|
"username": in.Username,
|
|
"nickname": in.Nickname,
|
|
"phone": in.Phone,
|
|
"password": in.Password,
|
|
}
|
|
var wire struct {
|
|
UserId int64 `json:"user_id"`
|
|
Username string `json:"username"`
|
|
Nickname string `json:"nickname"`
|
|
Phone string `json:"phone"`
|
|
}
|
|
path := "/internal/admin-query/tenants/" + url.PathEscape(tenant.Code) + "/users"
|
|
if err = imadmin.DefaultClient(ctx).InternalPost(ctx, path, payload, &wire); err != nil {
|
|
return nil, err
|
|
}
|
|
res = &sysin.ImUserCreateModel{UserId: wire.UserId, Username: wire.Username, Nickname: wire.Nickname, Phone: wire.Phone}
|
|
if in.GroupId > 0 {
|
|
if err = service.SysImUserPolicyGroup().MemberAdd(ctx, &sysin.ImUserPolicyGroupMemberAddInp{GroupId: in.GroupId, UserId: res.UserId}); err != nil {
|
|
return nil, gerror.Wrap(err, "用户已创建,但加入用户分组失败")
|
|
}
|
|
}
|
|
_ = writeImAdminAudit(ctx, tenant.Id, "im_user_create", "user", res.UserId, "success", map[string]any{"username": res.Username, "group_id": in.GroupId})
|
|
return res, nil
|
|
}
|
|
|
|
func (s *sSysImUser) BatchCreate(ctx context.Context, in *sysin.ImUserBatchCreateInp) (res *sysin.ImUserBatchCreateModel, err error) {
|
|
if err = in.Filter(ctx); err != nil {
|
|
return nil, err
|
|
}
|
|
rows := make([]*sysin.ImUserCreateInp, 0, in.Count)
|
|
for i := 1; i <= in.Count; i++ {
|
|
nickname := in.NicknamePrefix
|
|
if nickname == "" {
|
|
nickname = "用户"
|
|
}
|
|
rows = append(rows, &sysin.ImUserCreateInp{TenantId: in.TenantId, Nickname: fmt.Sprintf("%s%d", nickname, i), Password: in.Password, GroupId: in.GroupId})
|
|
}
|
|
return s.Import(ctx, &sysin.ImUserImportInp{TenantId: in.TenantId, GroupId: in.GroupId, Rows: rows})
|
|
}
|
|
|
|
func (s *sSysImUser) Import(ctx context.Context, in *sysin.ImUserImportInp) (res *sysin.ImUserBatchCreateModel, err error) {
|
|
if len(in.Rows) == 0 {
|
|
return nil, gerror.New("没有可导入的用户数据")
|
|
}
|
|
if len(in.Rows) > 500 {
|
|
return nil, gerror.New("单次最多导入500个用户")
|
|
}
|
|
res = &sysin.ImUserBatchCreateModel{Items: make([]*sysin.ImUserBatchItemModel, 0, len(in.Rows))}
|
|
for index, row := range in.Rows {
|
|
row.TenantId = in.TenantId
|
|
if row.GroupId == 0 {
|
|
row.GroupId = in.GroupId
|
|
}
|
|
item := &sysin.ImUserBatchItemModel{Row: index + 2, Username: row.Username}
|
|
created, createErr := s.Create(ctx, row)
|
|
if createErr != nil {
|
|
item.Reason = createErr.Error()
|
|
res.FailedCount++
|
|
} else {
|
|
item.Success = true
|
|
item.UserId = created.UserId
|
|
item.Username = created.Username
|
|
res.SuccessCount++
|
|
}
|
|
res.Items = append(res.Items, item)
|
|
}
|
|
return res, nil
|
|
}
|
|
|
|
func (s *sSysImUser) ImportExcel(ctx context.Context, tenantId, groupId int64, reader io.Reader) (res *sysin.ImUserBatchCreateModel, err error) {
|
|
workbook, err := excelize.OpenReader(reader)
|
|
if err != nil {
|
|
return nil, gerror.New("Excel文件格式不正确")
|
|
}
|
|
defer workbook.Close()
|
|
sheets := workbook.GetSheetList()
|
|
if len(sheets) == 0 {
|
|
return nil, gerror.New("Excel文件没有工作表")
|
|
}
|
|
rows, err := workbook.GetRows(sheets[0])
|
|
if err != nil {
|
|
return nil, gerror.Wrap(err, "读取Excel文件失败")
|
|
}
|
|
input := &sysin.ImUserImportInp{TenantId: tenantId, GroupId: groupId}
|
|
for index, row := range rows {
|
|
if index == 0 || len(row) == 0 {
|
|
continue
|
|
}
|
|
cell := func(i int) string {
|
|
if i >= len(row) {
|
|
return ""
|
|
}
|
|
return strings.TrimSpace(row[i])
|
|
}
|
|
rowGroupId := groupId
|
|
if raw := cell(4); raw != "" {
|
|
if _, scanErr := fmt.Sscan(raw, &rowGroupId); scanErr != nil {
|
|
rowGroupId = groupId
|
|
}
|
|
}
|
|
input.Rows = append(input.Rows, &sysin.ImUserCreateInp{
|
|
TenantId: tenantId, Username: cell(0), Nickname: cell(1), Phone: cell(2), Password: cell(3), GroupId: rowGroupId,
|
|
})
|
|
}
|
|
return s.Import(ctx, input)
|
|
}
|
|
|
|
func (s *sSysImUser) DownloadTemplate(ctx context.Context) error {
|
|
type templateRow struct {
|
|
Username string
|
|
Nickname string
|
|
Phone string
|
|
Password string
|
|
GroupId string
|
|
}
|
|
return excel.ExportByStructs(ctx,
|
|
[]string{"用户名(可空,自动生成)", "昵称(必填)", "手机号", "密码(必填)", "用户分组ID"},
|
|
[]templateRow{{Username: "", Nickname: "示例用户", Phone: "", Password: "123456", GroupId: ""}},
|
|
"用户导入模板", "用户")
|
|
}
|
|
|
|
// 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.GroupId > 0 {
|
|
userIds, groupErr := imUserPolicyGroupUserIds(ctx, tenant.Id, in.GroupId)
|
|
if groupErr != nil {
|
|
return nil, 0, groupErr
|
|
}
|
|
if len(userIds) == 0 {
|
|
return []*sysin.ImUserListModel{}, 0, nil
|
|
}
|
|
values.Set("user_ids", strings.Join(userIds, ","))
|
|
}
|
|
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.Keyword != "" {
|
|
values.Set("keyword", in.Keyword)
|
|
}
|
|
if in.State != nil {
|
|
values.Set("state", intToString(*in.State))
|
|
}
|
|
addTimeRangeQuery(values, "created_at", in.CreatedAt)
|
|
addTimeRangeQuery(values, "last_seen", in.LastSeen)
|
|
list, totalCount, err = imAdminQueryList[sysin.ImUserListModel](ctx, tenant,
|
|
"/internal/admin-query/tenants/"+url.PathEscape(tenant.Code)+"/users",
|
|
values,
|
|
map[string]string{
|
|
"public": "profileJson",
|
|
"trusted": "trustedJson",
|
|
"access": "accessJson",
|
|
},
|
|
)
|
|
if err != nil {
|
|
return nil, 0, err
|
|
}
|
|
if err = fillImUserPolicyGroups(ctx, tenant.Id, list); err != nil {
|
|
return nil, 0, err
|
|
}
|
|
if err = fillImUserRegisterTypes(ctx, tenant.Id, list); err != nil {
|
|
return nil, 0, err
|
|
}
|
|
return list, totalCount, nil
|
|
}
|
|
|
|
func fillImUserRegisterTypes(ctx context.Context, tenantId int64, list []*sysin.ImUserListModel) error {
|
|
if len(list) == 0 {
|
|
return nil
|
|
}
|
|
ids := make([]int64, 0, len(list))
|
|
byID := make(map[int64]*sysin.ImUserListModel, len(list))
|
|
for _, item := range list {
|
|
id, err := strconv.ParseInt(item.UserId, 10, 64)
|
|
if err == nil {
|
|
ids = append(ids, id)
|
|
byID[id] = item
|
|
}
|
|
if item.RegisterType == "" {
|
|
item.RegisterType = consts.ImUserRegisterTypeAdmin
|
|
}
|
|
}
|
|
if len(ids) == 0 {
|
|
return nil
|
|
}
|
|
rows, err := imtenant.RegistryDB().Model(imAdminOperationAuditTable).Safe().Ctx(ctx).
|
|
Fields("target_id").Where("tenant_id", tenantId).Where("target_type", "user").
|
|
Where("action", "im_user_create").Where("result", "success").WhereIn("target_id", ids).All()
|
|
if err != nil {
|
|
return gerror.Wrap(err, "获取用户注册方式失败")
|
|
}
|
|
for _, row := range rows {
|
|
if item := byID[row["target_id"].Int64()]; item != nil {
|
|
item.RegisterType = consts.ImUserRegisterTypeAdmin
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func validateImUserPolicyGroup(ctx context.Context, tenantId, groupId int64) error {
|
|
c := dao.ImUserPolicyGroups.Columns()
|
|
count, err := imtenant.RegistryDB().Model(dao.ImUserPolicyGroups.Table()).Safe().Ctx(ctx).
|
|
Where(c.Id, groupId).Where(c.TenantId, tenantId).Where(c.Status, consts.StatusEnabled).WhereNull(c.DeletedAt).Count()
|
|
if err != nil {
|
|
return gerror.Wrap(err, "检查用户分组失败")
|
|
}
|
|
if count == 0 {
|
|
return gerror.New("用户分组不存在或已停用")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func imUserPolicyGroupUserIds(ctx context.Context, tenantId, groupId int64) ([]string, error) {
|
|
if err := validateImUserPolicyGroup(ctx, tenantId, groupId); err != nil {
|
|
return nil, err
|
|
}
|
|
c := dao.ImUserPolicyGroupMembers.Columns()
|
|
rows, err := imtenant.RegistryDB().Model(dao.ImUserPolicyGroupMembers.Table()).Safe().Ctx(ctx).
|
|
Fields(c.UserId).Where(c.TenantId, tenantId).Where(c.GroupId, groupId).Where(c.Status, consts.StatusEnabled).WhereNull(c.DeletedAt).All()
|
|
if err != nil {
|
|
return nil, gerror.Wrap(err, "获取用户分组成员失败")
|
|
}
|
|
ids := make([]string, 0, len(rows))
|
|
for _, row := range rows {
|
|
ids = append(ids, strconv.FormatInt(row[c.UserId].Int64(), 10))
|
|
}
|
|
return ids, nil
|
|
}
|
|
|
|
func fillImUserPolicyGroups(ctx context.Context, tenantId int64, list []*sysin.ImUserListModel) error {
|
|
if len(list) == 0 {
|
|
return nil
|
|
}
|
|
ids := make([]int64, 0, len(list))
|
|
byId := make(map[int64]*sysin.ImUserListModel, len(list))
|
|
for _, item := range list {
|
|
id, err := strconv.ParseInt(item.UserId, 10, 64)
|
|
if err == nil {
|
|
ids = append(ids, id)
|
|
byId[id] = item
|
|
}
|
|
}
|
|
if len(ids) == 0 {
|
|
return nil
|
|
}
|
|
mc := dao.ImUserPolicyGroupMembers.Columns()
|
|
gc := dao.ImUserPolicyGroups.Columns()
|
|
rows, err := imtenant.RegistryDB().Model(dao.ImUserPolicyGroupMembers.Table()+" m").Safe().Ctx(ctx).
|
|
LeftJoin(dao.ImUserPolicyGroups.Table()+" g", "g."+gc.Id+"=m."+mc.GroupId).
|
|
Fields("m."+mc.UserId, "g."+gc.Id+" policy_group_id", "g."+gc.GroupName+" policy_group_name").
|
|
Where("m."+mc.TenantId, tenantId).WhereIn("m."+mc.UserId, ids).
|
|
Where("m."+mc.Status, consts.StatusEnabled).Where("g."+gc.Status, consts.StatusEnabled).
|
|
WhereNull("m." + mc.DeletedAt).WhereNull("g." + gc.DeletedAt).OrderDesc("g." + gc.Priority).All()
|
|
if err != nil {
|
|
return gerror.Wrap(err, "获取用户分组信息失败")
|
|
}
|
|
for _, row := range rows {
|
|
item := byId[row[mc.UserId].Int64()]
|
|
if item != nil && item.PolicyGroupId == 0 {
|
|
item.PolicyGroupId = row["policy_group_id"].Int64()
|
|
item.PolicyGroupName = row["policy_group_name"].String()
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
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) (res *sysin.ImUserCommandModel, err error) {
|
|
if err = in.Filter(ctx); err != nil {
|
|
return nil, err
|
|
}
|
|
tenant, err := resolveImAdminTenant(ctx, in.TenantId)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
userId, err := sysin.ParseOptionalInt64(in.UserId)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
command, err := s.publishPasswordResetCommand(ctx, tenant, userId, in.NewPassword)
|
|
result := "success"
|
|
if err != nil {
|
|
result = "failed"
|
|
}
|
|
_ = writeImAdminAudit(ctx, tenant.Id, "user_password_reset", "user", userId, result, nil)
|
|
return command, 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) AdjustGroup(ctx context.Context, in *sysin.ImUserAdjustGroupInp) error {
|
|
if err := in.Filter(ctx); err != nil {
|
|
return err
|
|
}
|
|
scope, err := resolveImAdminScope(ctx, in.TenantId)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err = validateImUserPolicyGroup(ctx, scope.TenantId, in.GroupId); err != nil {
|
|
return err
|
|
}
|
|
db := imtenant.RegistryDB()
|
|
c := dao.ImUserPolicyGroupMembers.Columns()
|
|
now := gtime.Now()
|
|
operatorID := contexts.GetUserId(ctx)
|
|
err = db.Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
|
|
_, updateErr := tx.Model(dao.ImUserPolicyGroupMembers.Table()).Safe().Ctx(ctx).
|
|
Where(c.TenantId, scope.TenantId).Where(c.UserId, in.UserId).WhereNull(c.DeletedAt).
|
|
Data(g.Map{c.Status: consts.StatusDisable, c.DeletedAt: now, c.DeletedBy: operatorID, c.UpdatedAt: now, c.UpdatedBy: operatorID}).Update()
|
|
if updateErr != nil {
|
|
return updateErr
|
|
}
|
|
_, insertErr := tx.Exec(`INSERT INTO `+dao.ImUserPolicyGroupMembers.Table()+`
|
|
(tenant_id,group_id,user_id,status,valid_from,remark,created_at,created_by,updated_at,updated_by,deleted_at,deleted_by)
|
|
VALUES(?,?,?,1,?,'后台调整分组',?,?,?, ?,NULL,NULL)
|
|
ON DUPLICATE KEY UPDATE tenant_id=VALUES(tenant_id),status=1,valid_from=VALUES(valid_from),valid_until=NULL,
|
|
remark=VALUES(remark),updated_at=VALUES(updated_at),updated_by=VALUES(updated_by),deleted_at=NULL,deleted_by=NULL`,
|
|
scope.TenantId, in.GroupId, in.UserId, now, now, operatorID, now, operatorID)
|
|
return insertErr
|
|
})
|
|
if err != nil {
|
|
return gerror.Wrap(err, "调整用户分组失败")
|
|
}
|
|
_ = writeImAdminAudit(ctx, scope.TenantId, "im_user_adjust_group", "user", in.UserId, "success", g.Map{"group_id": in.GroupId})
|
|
return nil
|
|
}
|
|
|
|
func (s *sSysImUser) IPWhitelist(ctx context.Context, in *sysin.ImUserIPWhitelistInp) (*sysin.ImUserIPWhitelistModel, error) {
|
|
if err := in.Filter(ctx); err != nil {
|
|
return nil, err
|
|
}
|
|
scope, err := resolveImAdminScope(ctx, in.TenantId)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
db, _, err := imtenant.DB(ctx, scope.TenantId)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
rows, err := db.Model("im_user_ip_whitelists").Safe().Ctx(ctx).
|
|
Fields("ip_cidr").Where("user_id", in.UserId).
|
|
Where("status", consts.StatusEnabled).WhereNull("deleted_at").OrderAsc("id").All()
|
|
if err != nil {
|
|
return nil, gerror.Wrap(err, "获取用户IP白名单失败")
|
|
}
|
|
result := &sysin.ImUserIPWhitelistModel{IPList: make([]string, 0, len(rows))}
|
|
for _, row := range rows {
|
|
result.IPList = append(result.IPList, row["ip_cidr"].String())
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
func (s *sSysImUser) IPWhitelistSave(ctx context.Context, in *sysin.ImUserIPWhitelistInp) error {
|
|
if err := in.Filter(ctx); err != nil {
|
|
return err
|
|
}
|
|
scope, err := resolveImAdminScope(ctx, in.TenantId)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
unique := make([]string, 0, len(in.IPList))
|
|
seen := make(map[string]struct{}, len(in.IPList))
|
|
for _, raw := range in.IPList {
|
|
value := strings.TrimSpace(raw)
|
|
if value == "" {
|
|
continue
|
|
}
|
|
if net.ParseIP(value) == nil {
|
|
if _, _, parseErr := net.ParseCIDR(value); parseErr != nil {
|
|
return gerror.Newf("IP或CIDR格式不合法:%s", value)
|
|
}
|
|
}
|
|
if _, ok := seen[value]; !ok {
|
|
seen[value] = struct{}{}
|
|
unique = append(unique, value)
|
|
}
|
|
}
|
|
db, _, err := imtenant.DB(ctx, scope.TenantId)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
now := gtime.Now()
|
|
operatorID := contexts.GetUserId(ctx)
|
|
err = db.Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
|
|
if _, updateErr := tx.Model("im_user_ip_whitelists").Safe().Ctx(ctx).
|
|
Where("user_id", in.UserId).WhereNull("deleted_at").
|
|
Data(g.Map{"status": consts.StatusDisable, "deleted_at": now, "deleted_by": operatorID, "updated_at": now, "updated_by": operatorID}).Update(); updateErr != nil {
|
|
return updateErr
|
|
}
|
|
for _, value := range unique {
|
|
if _, insertErr := tx.Exec(`INSERT INTO im_user_ip_whitelists
|
|
(user_id,ip_cidr,status,created_at,created_by,updated_at,updated_by,deleted_at,deleted_by)
|
|
VALUES(?,?,1,?,?,?,?,NULL,NULL)
|
|
ON DUPLICATE KEY UPDATE status=1,updated_at=VALUES(updated_at),updated_by=VALUES(updated_by),deleted_at=NULL,deleted_by=NULL`,
|
|
in.UserId, value, now, operatorID, now, operatorID); insertErr != nil {
|
|
return insertErr
|
|
}
|
|
}
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return gerror.Wrap(err, "保存用户IP白名单失败")
|
|
}
|
|
_ = writeImAdminAudit(ctx, scope.TenantId, "im_user_ip_whitelist_save", "user", in.UserId, "success", g.Map{"ip_list": unique})
|
|
return nil
|
|
}
|
|
|
|
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 (s *sSysImUser) publishPasswordResetCommand(ctx context.Context, tenant *entity.ImTenant, userId int64, newPassword string) (*sysin.ImUserCommandModel, error) {
|
|
passwordHash, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcrypt.DefaultCost)
|
|
if err != nil {
|
|
return nil, gerror.Wrap(err, "生成密码摘要失败")
|
|
}
|
|
user := contexts.GetUser(ctx)
|
|
actorName := ""
|
|
if user != nil {
|
|
actorName = user.RealName
|
|
}
|
|
payload := map[string]any{
|
|
"operator_id": strconv.FormatInt(contexts.GetUserId(ctx), 10),
|
|
"user_id": strconv.FormatInt(userId, 10),
|
|
"target_user_id": strconv.FormatInt(userId, 10),
|
|
"password_hash": base64.StdEncoding.EncodeToString(passwordHash),
|
|
}
|
|
command, err := imkafka.PublishAdminCommand(ctx, imkafka.AdminCommandInput{
|
|
TenantID: tenant.Id,
|
|
TenantCode: tenant.Code,
|
|
EventType: consts.ImAdminCommandUserPasswordReset,
|
|
BizType: "user",
|
|
BizID: strconv.FormatInt(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
|
|
})
|
|
}
|