From 7d54bf785a5a8ec8cf9a6c4f1e198897921d12e7 Mon Sep 17 00:00:00 2001 From: richjohnrambo Date: Tue, 8 Sep 2026 10:54:01 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=8C=E5=96=84IM=E7=94=A8=E6=88=B7?= =?UTF-8?q?=E7=AE=A1=E7=90=86=E4=B8=8E=E6=B2=BB=E7=90=86=E6=93=8D=E4=BD=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/api/admin/imuser/im_user.go | 59 +++ server/internal/consts/im_user.go | 13 + .../internal/controller/admin/sys/im_user.go | 54 ++ .../internal/library/imadmin/client_test.go | 33 ++ server/internal/library/imadmin/internal.go | 20 +- server/internal/logic/sys/im_user.go | 385 +++++++++++++- server/internal/model/input/sysin/im_user.go | 108 +++- .../model/input/sysin/im_user_test.go | 27 + server/internal/service/sys.go | 9 + server/storage/data/generate/im_user_menu.sql | 47 ++ web/src/api/imUser/index.ts | 31 +- web/src/views/imUser/index.vue | 488 ++++++++++++++++-- web/src/views/imUser/model.ts | 176 +++---- 13 files changed, 1319 insertions(+), 131 deletions(-) create mode 100644 server/internal/model/input/sysin/im_user_test.go diff --git a/server/api/admin/imuser/im_user.go b/server/api/admin/imuser/im_user.go index 7db8213..ec9f5a2 100644 --- a/server/api/admin/imuser/im_user.go +++ b/server/api/admin/imuser/im_user.go @@ -10,6 +10,7 @@ import ( "hotgo/internal/model/input/sysin" "github.com/gogf/gf/v2/frame/g" + "github.com/gogf/gf/v2/net/ghttp" ) // ListReq 查询IM用户列表 @@ -23,6 +24,41 @@ type ListRes struct { List []*sysin.ImUserListModel `json:"list" dc:"数据列表"` } +type CreateReq struct { + g.Meta `path:"/imUser/create" method:"post" tags:"IM用户" summary:"后台创建IM用户"` + sysin.ImUserCreateInp +} + +type CreateRes struct { + *sysin.ImUserCreateModel +} + +type BatchCreateReq struct { + g.Meta `path:"/imUser/batchCreate" method:"post" tags:"IM用户" summary:"批量创建IM用户"` + sysin.ImUserBatchCreateInp +} + +type BatchCreateRes struct { + *sysin.ImUserBatchCreateModel +} + +type ImportReq struct { + g.Meta `path:"/imUser/import" method:"post" tags:"IM用户" summary:"Excel导入IM用户"` + TenantId int64 `json:"tenantId" dc:"租户ID"` + GroupId int64 `json:"groupId" dc:"默认用户策略分组ID"` + File *ghttp.UploadFile `json:"file" type:"file" v:"required#请选择Excel文件" dc:"Excel文件"` +} + +type ImportRes struct { + *sysin.ImUserBatchCreateModel +} + +type DownloadTemplateReq struct { + g.Meta `path:"/imUser/downloadTemplate" method:"get" tags:"IM用户" summary:"下载IM用户导入模板"` +} + +type DownloadTemplateRes struct{} + // ViewReq 获取IM用户指定信息 type ViewReq struct { g.Meta `path:"/imUser/view" method:"get" tags:"IM用户" summary:"获取IM用户指定信息"` @@ -56,6 +92,29 @@ type StatusReq struct { type StatusRes struct{} +type AdjustGroupReq struct { + g.Meta `path:"/imUser/adjustGroup" method:"post" tags:"IM用户" summary:"调整IM用户分组"` + sysin.ImUserAdjustGroupInp +} + +type AdjustGroupRes struct{} + +type IPWhitelistReq struct { + g.Meta `path:"/imUser/ipWhitelist" method:"get" tags:"IM用户" summary:"获取IM用户IP白名单"` + sysin.ImUserIPWhitelistInp +} + +type IPWhitelistRes struct { + *sysin.ImUserIPWhitelistModel +} + +type IPWhitelistSaveReq struct { + g.Meta `path:"/imUser/ipWhitelistSave" method:"post" tags:"IM用户" summary:"保存IM用户IP白名单"` + sysin.ImUserIPWhitelistInp +} + +type IPWhitelistSaveRes struct{} + type DevicesReq struct { g.Meta `path:"/imUser/devices" method:"get" tags:"IM用户" summary:"获取IM用户设备列表"` sysin.ImUserDeviceListInp diff --git a/server/internal/consts/im_user.go b/server/internal/consts/im_user.go index c6b7aa4..75206aa 100644 --- a/server/internal/consts/im_user.go +++ b/server/internal/consts/im_user.go @@ -12,6 +12,19 @@ import ( func init() { dict.RegisterEnums("ImUserStateOptions", "IM用户状态选项", ImUserStateOptions) + dict.RegisterEnums("ImUserRegisterTypeOptions", "IM用户注册方式选项", ImUserRegisterTypeOptions) +} + +const ( + ImUserRegisterTypeAdmin = "admin" + ImUserRegisterTypeMobile = "mobile" + ImUserRegisterTypeInviteCode = "invite_code" +) + +var ImUserRegisterTypeOptions = []*model.Option{ + dict.GenPrimaryOption(ImUserRegisterTypeAdmin, "后台创建"), + dict.GenSuccessOption(ImUserRegisterTypeMobile, "手机号注册"), + dict.GenInfoOption(ImUserRegisterTypeInviteCode, "邀请码注册"), } const ( diff --git a/server/internal/controller/admin/sys/im_user.go b/server/internal/controller/admin/sys/im_user.go index fb5d61d..d66289e 100644 --- a/server/internal/controller/admin/sys/im_user.go +++ b/server/internal/controller/admin/sys/im_user.go @@ -10,6 +10,8 @@ import ( "hotgo/api/admin/imuser" "hotgo/internal/model/input/sysin" "hotgo/internal/service" + + "github.com/gogf/gf/v2/errors/gerror" ) var ( @@ -18,6 +20,40 @@ var ( type cImUser struct{} +func (c *cImUser) Create(ctx context.Context, req *imuser.CreateReq) (res *imuser.CreateRes, err error) { + data, err := service.SysImUser().Create(ctx, &req.ImUserCreateInp) + if err != nil { + return nil, err + } + return &imuser.CreateRes{ImUserCreateModel: data}, nil +} + +func (c *cImUser) BatchCreate(ctx context.Context, req *imuser.BatchCreateReq) (res *imuser.BatchCreateRes, err error) { + data, err := service.SysImUser().BatchCreate(ctx, &req.ImUserBatchCreateInp) + if err != nil { + return nil, err + } + return &imuser.BatchCreateRes{ImUserBatchCreateModel: data}, nil +} + +func (c *cImUser) Import(ctx context.Context, req *imuser.ImportReq) (res *imuser.ImportRes, err error) { + file, err := req.File.Open() + if err != nil { + return nil, gerror.Wrap(err, "打开Excel文件失败") + } + defer file.Close() + data, err := service.SysImUser().ImportExcel(ctx, req.TenantId, req.GroupId, file) + if err != nil { + return nil, err + } + return &imuser.ImportRes{ImUserBatchCreateModel: data}, nil +} + +func (c *cImUser) DownloadTemplate(ctx context.Context, req *imuser.DownloadTemplateReq) (res *imuser.DownloadTemplateRes, err error) { + err = service.SysImUser().DownloadTemplate(ctx) + return +} + // List 查看IM用户列表 func (c *cImUser) List(ctx context.Context, req *imuser.ListReq) (res *imuser.ListRes, err error) { list, totalCount, err := service.SysImUser().List(ctx, &req.ImUserListInp) @@ -67,6 +103,24 @@ func (c *cImUser) Status(ctx context.Context, req *imuser.StatusReq) (res *imuse return } +func (c *cImUser) AdjustGroup(ctx context.Context, req *imuser.AdjustGroupReq) (res *imuser.AdjustGroupRes, err error) { + err = service.SysImUser().AdjustGroup(ctx, &req.ImUserAdjustGroupInp) + return +} + +func (c *cImUser) IPWhitelist(ctx context.Context, req *imuser.IPWhitelistReq) (res *imuser.IPWhitelistRes, err error) { + data, err := service.SysImUser().IPWhitelist(ctx, &req.ImUserIPWhitelistInp) + if err != nil { + return nil, err + } + return &imuser.IPWhitelistRes{ImUserIPWhitelistModel: data}, nil +} + +func (c *cImUser) IPWhitelistSave(ctx context.Context, req *imuser.IPWhitelistSaveReq) (res *imuser.IPWhitelistSaveRes, err error) { + err = service.SysImUser().IPWhitelistSave(ctx, &req.ImUserIPWhitelistInp) + return +} + func (c *cImUser) Devices(ctx context.Context, req *imuser.DevicesReq) (res *imuser.DevicesRes, err error) { list, totalCount, err := service.SysImUser().Devices(ctx, &req.ImUserDeviceListInp) if err != nil { diff --git a/server/internal/library/imadmin/client_test.go b/server/internal/library/imadmin/client_test.go index ef54b6e..e46d45d 100644 --- a/server/internal/library/imadmin/client_test.go +++ b/server/internal/library/imadmin/client_test.go @@ -58,6 +58,39 @@ func TestListCodesWithInviteCodeUsesSignedSearchPost(t *testing.T) { } } +func TestInternalPostUsesInternalSignatureAndBody(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost || r.URL.Path != "/internal/admin-query/tenants/default/users" { + t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path) + } + body, err := io.ReadAll(r.Body) + if err != nil { + t.Fatalf("read body: %v", err) + } + if string(body) != `{"username":"alice"}` { + t.Fatalf("unexpected body %s", body) + } + if r.Header.Get("X-Service-Name") != "hotgo-admin" || r.Header.Get("X-Signature") == "" { + t.Fatalf("missing internal authentication headers") + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"code":0,"data":{"user_id":123,"username":"alice"}}`)) + })) + defer server.Close() + + client := NewClient(Config{InternalBaseUrl: server.URL, InternalServiceName: "hotgo-admin", InternalServiceSecret: "secret", Timeout: time.Second}) + var out struct { + UserID int64 `json:"user_id"` + Username string `json:"username"` + } + if err := client.InternalPost(context.Background(), "/internal/admin-query/tenants/default/users", map[string]string{"username": "alice"}, &out); err != nil { + t.Fatalf("InternalPost returned error: %v", err) + } + if out.UserID != 123 || out.Username != "alice" { + t.Fatalf("unexpected response: %+v", out) + } +} + func signedTestServer(t *testing.T, method string, path string, inspect func(t *testing.T, r *http.Request, body []byte)) *httptest.Server { t.Helper() return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { diff --git a/server/internal/library/imadmin/internal.go b/server/internal/library/imadmin/internal.go index 17ca0f3..99267cb 100644 --- a/server/internal/library/imadmin/internal.go +++ b/server/internal/library/imadmin/internal.go @@ -18,7 +18,19 @@ import ( ) func (c *Client) InternalGet(ctx context.Context, path string, out any) error { - req, err := c.newInternalRequest(ctx, http.MethodGet, path, nil) + return c.internalJSON(ctx, http.MethodGet, path, nil, out) +} + +func (c *Client) InternalPost(ctx context.Context, path string, in any, out any) error { + body, err := json.Marshal(in) + if err != nil { + return gerror.Wrap(err, "序列化IM内部请求失败") + } + return c.internalJSON(ctx, http.MethodPost, path, body, out) +} + +func (c *Client) internalJSON(ctx context.Context, method, path string, body []byte, out any) error { + req, err := c.newInternalRequest(ctx, method, path, body) if err != nil { return err } @@ -34,7 +46,7 @@ func (c *Client) InternalGet(ctx context.Context, path string, out any) error { if out == nil { return nil } - body, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseBody)) + responseBody, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseBody)) if err != nil { return gerror.Wrap(err, "读取IM内部查询响应失败") } @@ -44,13 +56,13 @@ func (c *Client) InternalGet(ctx context.Context, path string, out any) error { Reason string `json:"reason"` Data json.RawMessage `json:"data"` } - if err = json.Unmarshal(body, &envelope); err == nil && len(envelope.Data) > 0 { + if err = json.Unmarshal(responseBody, &envelope); err == nil && len(envelope.Data) > 0 { if envelope.Code != 0 { return stableError(envelope.Message, envelope.Reason) } return json.Unmarshal(envelope.Data, out) } - return json.Unmarshal(body, out) + return json.Unmarshal(responseBody, out) } func (c *Client) newInternalRequest(ctx context.Context, method string, path string, bodyBytes []byte) (*http.Request, error) { diff --git a/server/internal/logic/sys/im_user.go b/server/internal/logic/sys/im_user.go index 9c42657..36acb1e 100644 --- a/server/internal/logic/sys/im_user.go +++ b/server/internal/logic/sys/im_user.go @@ -13,12 +13,17 @@ import ( "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" @@ -26,8 +31,11 @@ import ( "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" ) @@ -41,6 +49,141 @@ 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 { @@ -51,6 +194,16 @@ func (s *sSysImUser) List(ctx context.Context, in *sysin.ImUserListInp) (list [] 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) } @@ -60,12 +213,15 @@ func (s *sSysImUser) List(ctx context.Context, in *sysin.ImUserListInp) (list [] 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) - return imAdminQueryList[sysin.ImUserListModel](ctx, tenant, + list, totalCount, err = imAdminQueryList[sysin.ImUserListModel](ctx, tenant, "/internal/admin-query/tenants/"+url.PathEscape(tenant.Code)+"/users", values, map[string]string{ @@ -74,6 +230,116 @@ func (s *sSysImUser) List(ctx context.Context, in *sysin.ImUserListInp) (list [] "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 { @@ -285,6 +551,123 @@ func (s *sSysImUser) Status(ctx context.Context, in *sysin.ImUserStatusInp) (err 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 diff --git a/server/internal/model/input/sysin/im_user.go b/server/internal/model/input/sysin/im_user.go index a3cd7b2..cdcd48c 100644 --- a/server/internal/model/input/sysin/im_user.go +++ b/server/internal/model/input/sysin/im_user.go @@ -8,6 +8,7 @@ package sysin import ( "context" "hotgo/internal/model/input/form" + "strings" "github.com/gogf/gf/v2/errors/gerror" "github.com/gogf/gf/v2/os/gtime" @@ -18,6 +19,8 @@ type ImUserListInp struct { form.PageReq TenantId int64 `json:"tenantId" dc:"租户ID"` UserId string `json:"userId" dc:"用户ID"` + GroupId int64 `json:"groupId" dc:"用户策略分组ID"` + Keyword string `json:"keyword" dc:"用户名、昵称或手机号"` Nickname string `json:"nickname" dc:"昵称"` AuthName string `json:"authName" dc:"登录标识"` State *int `json:"state" dc:"账号状态"` @@ -26,6 +29,7 @@ type ImUserListInp struct { } func (in *ImUserListInp) Filter(ctx context.Context) (err error) { + in.Keyword = strings.TrimSpace(in.Keyword) if in.UserId != "" { if _, err = ParseOptionalInt64(in.UserId); err != nil { return err @@ -53,6 +57,79 @@ type ImUserListModel struct { UserAgent string `json:"userAgent" dc:"客户端"` CreatedAt *gtime.Time `json:"createdAt" dc:"创建时间"` UpdatedAt *gtime.Time `json:"updatedAt" dc:"修改时间"` + AvatarFileId string `json:"avatarFileId" dc:"头像文件ID"` + Avatar string `json:"avatar" dc:"头像地址"` + PolicyGroupId int64 `json:"policyGroupId" dc:"用户策略分组ID"` + PolicyGroupName string `json:"policyGroupName" dc:"用户策略分组名称"` + RegisterType string `json:"registerType" dc:"注册方式"` +} + +type ImUserCreateInp struct { + TenantId int64 `json:"tenantId" dc:"租户ID"` + Username string `json:"username" dc:"用户名"` + Nickname string `json:"nickname" v:"required#昵称不能为空" dc:"昵称"` + Phone string `json:"phone" dc:"手机号"` + Password string `json:"password" v:"required#密码不能为空" dc:"密码"` + GroupId int64 `json:"groupId" dc:"用户策略分组ID"` +} + +func (in *ImUserCreateInp) Filter(ctx context.Context) error { + in.Username = strings.ToLower(strings.TrimSpace(in.Username)) + in.Nickname = strings.TrimSpace(in.Nickname) + in.Phone = strings.TrimSpace(in.Phone) + if in.Nickname == "" { + return gerror.New("昵称不能为空") + } + if len([]rune(in.Password)) < 3 || len([]byte(in.Password)) > 72 { + return gerror.New("密码长度需为3至72字节") + } + return nil +} + +type ImUserCreateModel struct { + UserId int64 `json:"userId" dc:"用户ID"` + Username string `json:"username" dc:"用户名"` + Nickname string `json:"nickname" dc:"昵称"` + Phone string `json:"phone" dc:"手机号"` +} + +type ImUserBatchCreateInp struct { + TenantId int64 `json:"tenantId" dc:"租户ID"` + Count int `json:"count" v:"required|min:1|max:100#创建数量不能为空|创建数量最少为1|单次最多创建100个用户" dc:"创建数量"` + Password string `json:"password" v:"required#默认密码不能为空" dc:"默认密码"` + NicknamePrefix string `json:"nicknamePrefix" dc:"昵称前缀"` + GroupId int64 `json:"groupId" dc:"用户策略分组ID"` +} + +func (in *ImUserBatchCreateInp) Filter(ctx context.Context) error { + in.NicknamePrefix = strings.TrimSpace(in.NicknamePrefix) + if in.Count < 1 || in.Count > 100 { + return gerror.New("单次创建数量需为1至100") + } + if len([]rune(in.Password)) < 3 || len([]byte(in.Password)) > 72 { + return gerror.New("密码长度需为3至72字节") + } + return nil +} + +type ImUserBatchItemModel struct { + Row int `json:"row" dc:"行号"` + Username string `json:"username" dc:"用户名"` + UserId int64 `json:"userId" dc:"用户ID"` + Success bool `json:"success" dc:"是否成功"` + Reason string `json:"reason" dc:"失败原因"` +} + +type ImUserBatchCreateModel struct { + SuccessCount int `json:"successCount" dc:"成功数量"` + FailedCount int `json:"failedCount" dc:"失败数量"` + Items []*ImUserBatchItemModel `json:"items" dc:"处理明细"` +} + +type ImUserImportInp struct { + TenantId int64 `json:"tenantId" dc:"租户ID"` + GroupId int64 `json:"groupId" dc:"默认用户策略分组ID"` + Rows []*ImUserCreateInp `json:"rows" dc:"导入用户"` } // ImUserViewInp 获取指定IM用户信息 @@ -167,12 +244,39 @@ func (in *ImUserGovernanceInp) Filter(ctx context.Context) (err error) { if in.UserId <= 0 { return gerror.New("用户ID不能为空") } - if in.ActorUserId <= 0 { - return gerror.New("操作IM用户ID不能为空") + return nil +} + +type ImUserAdjustGroupInp struct { + TenantId int64 `json:"tenantId" dc:"租户ID"` + UserId int64 `json:"userId" v:"required|min:1#用户ID不能为空|用户ID不合法" dc:"用户ID"` + GroupId int64 `json:"groupId" v:"required|min:1#用户分组不能为空|用户分组不合法" dc:"用户策略分组ID"` +} + +func (in *ImUserAdjustGroupInp) Filter(ctx context.Context) error { + if in.UserId <= 0 || in.GroupId <= 0 { + return gerror.New("用户和用户分组不能为空") } return nil } +type ImUserIPWhitelistInp struct { + TenantId int64 `json:"tenantId" dc:"租户ID"` + UserId int64 `json:"userId" v:"required|min:1#用户ID不能为空|用户ID不合法" dc:"用户ID"` + IPList []string `json:"ipList" dc:"IP或CIDR列表"` +} + +func (in *ImUserIPWhitelistInp) Filter(ctx context.Context) error { + if in.UserId <= 0 { + return gerror.New("用户ID不能为空") + } + return nil +} + +type ImUserIPWhitelistModel struct { + IPList []string `json:"ipList" dc:"IP或CIDR列表"` +} + func (in *ImUserGovernanceInp) FilterDevice(ctx context.Context) (err error) { if err = in.Filter(ctx); err != nil { return err diff --git a/server/internal/model/input/sysin/im_user_test.go b/server/internal/model/input/sysin/im_user_test.go new file mode 100644 index 0000000..a5ec00b --- /dev/null +++ b/server/internal/model/input/sysin/im_user_test.go @@ -0,0 +1,27 @@ +package sysin + +import ( + "testing" + + "github.com/gogf/gf/v2/os/gctx" +) + +func TestImUserGovernanceAllowsAdminActor(t *testing.T) { + in := &ImUserGovernanceInp{TenantId: 1, UserId: 2} + if err := in.Filter(gctx.New()); err != nil { + t.Fatalf("admin governance should not require an IM actor: %v", err) + } +} + +func TestImUserActionInputs(t *testing.T) { + ctx := gctx.New() + if err := (&ImUserAdjustGroupInp{UserId: 2, GroupId: 3}).Filter(ctx); err != nil { + t.Fatalf("valid group adjustment rejected: %v", err) + } + if err := (&ImUserAdjustGroupInp{UserId: 2}).Filter(ctx); err == nil { + t.Fatal("missing group must be rejected") + } + if err := (&ImUserIPWhitelistInp{UserId: 2}).Filter(ctx); err != nil { + t.Fatalf("valid whitelist input rejected: %v", err) + } +} diff --git a/server/internal/service/sys.go b/server/internal/service/sys.go index 6f55bbc..fc01d35 100644 --- a/server/internal/service/sys.go +++ b/server/internal/service/sys.go @@ -12,6 +12,7 @@ import ( "hotgo/internal/model/entity" "hotgo/internal/model/input/sysin" "hotgo/utility/tree" + "io" "github.com/gogf/gf/v2/database/gdb" "github.com/gogf/gf/v2/database/gredis" @@ -214,6 +215,11 @@ type ( Export(ctx context.Context, in *sysin.ImInviteListInp) (err error) } ISysImUser interface { + Create(ctx context.Context, in *sysin.ImUserCreateInp) (res *sysin.ImUserCreateModel, err error) + BatchCreate(ctx context.Context, in *sysin.ImUserBatchCreateInp) (res *sysin.ImUserBatchCreateModel, err error) + Import(ctx context.Context, in *sysin.ImUserImportInp) (res *sysin.ImUserBatchCreateModel, err error) + ImportExcel(ctx context.Context, tenantId, groupId int64, reader io.Reader) (res *sysin.ImUserBatchCreateModel, err error) + DownloadTemplate(ctx context.Context) (err error) // List 获取IM用户列表 List(ctx context.Context, in *sysin.ImUserListInp) (list []*sysin.ImUserListModel, totalCount int, err error) // View 获取IM用户指定信息 @@ -224,6 +230,9 @@ type ( RevokeSessions(ctx context.Context, in *sysin.ImUserRevokeSessionsInp) (err error) // Status 更新IM用户状态 Status(ctx context.Context, in *sysin.ImUserStatusInp) (err error) + AdjustGroup(ctx context.Context, in *sysin.ImUserAdjustGroupInp) (err error) + IPWhitelist(ctx context.Context, in *sysin.ImUserIPWhitelistInp) (res *sysin.ImUserIPWhitelistModel, err error) + IPWhitelistSave(ctx context.Context, in *sysin.ImUserIPWhitelistInp) (err error) // Devices 获取IM用户设备列表 Devices(ctx context.Context, in *sysin.ImUserDeviceListInp) (list []*sysin.ImUserDeviceListModel, totalCount int, err error) // Ban 通过Kafka封禁IM用户 diff --git a/server/storage/data/generate/im_user_menu.sql b/server/storage/data/generate/im_user_menu.sql index d59c8b7..e2e74b6 100644 --- a/server/storage/data/generate/im_user_menu.sql +++ b/server/storage/data/generate/im_user_menu.sql @@ -26,10 +26,48 @@ WHERE @userListId IS NULL; SELECT @userListId := `id` FROM `hg_admin_menu` WHERE `name` = 'imUserIndex' LIMIT 1; +INSERT INTO `hg_admin_menu` (`id`, `pid`, `title`, `name`, `path`, `icon`, `type`, `redirect`, `permissions`, `permission_name`, `component`, `always_show`, `active_menu`, `is_root`, `is_frame`, `frame_src`, `keep_alive`, `hidden`, `affix`, `level`, `tree`, `sort`, `remark`, `status`, `created_at`, `updated_at`) +SELECT NULL, @userListId, '创建用户', 'imUserCreate', '', '', '3', '', '/imUser/create', '', '', '1', '', '0', '0', '', '0', '1', '0', '4', CONCAT('tr_', @imRootId, ' tr_', @userDirId, ' tr_', @userListId, ' '), '1', '', '1', @now, @now +WHERE NOT EXISTS (SELECT 1 FROM `hg_admin_menu` WHERE `name` = 'imUserCreate'); + +INSERT INTO `hg_admin_menu` (`id`, `pid`, `title`, `name`, `path`, `icon`, `type`, `redirect`, `permissions`, `permission_name`, `component`, `always_show`, `active_menu`, `is_root`, `is_frame`, `frame_src`, `keep_alive`, `hidden`, `affix`, `level`, `tree`, `sort`, `remark`, `status`, `created_at`, `updated_at`) +SELECT NULL, @userListId, '批量创建用户', 'imUserBatchCreate', '', '', '3', '', '/imUser/batchCreate', '', '', '1', '', '0', '0', '', '0', '1', '0', '4', CONCAT('tr_', @imRootId, ' tr_', @userDirId, ' tr_', @userListId, ' '), '2', '', '1', @now, @now +WHERE NOT EXISTS (SELECT 1 FROM `hg_admin_menu` WHERE `name` = 'imUserBatchCreate'); + +INSERT INTO `hg_admin_menu` (`id`, `pid`, `title`, `name`, `path`, `icon`, `type`, `redirect`, `permissions`, `permission_name`, `component`, `always_show`, `active_menu`, `is_root`, `is_frame`, `frame_src`, `keep_alive`, `hidden`, `affix`, `level`, `tree`, `sort`, `remark`, `status`, `created_at`, `updated_at`) +SELECT NULL, @userListId, '导入用户', 'imUserImport', '', '', '3', '', '/imUser/import', '', '', '1', '', '0', '0', '', '0', '1', '0', '4', CONCAT('tr_', @imRootId, ' tr_', @userDirId, ' tr_', @userListId, ' '), '3', '', '1', @now, @now +WHERE NOT EXISTS (SELECT 1 FROM `hg_admin_menu` WHERE `name` = 'imUserImport'); + +INSERT INTO `hg_admin_menu` (`id`, `pid`, `title`, `name`, `path`, `icon`, `type`, `redirect`, `permissions`, `permission_name`, `component`, `always_show`, `active_menu`, `is_root`, `is_frame`, `frame_src`, `keep_alive`, `hidden`, `affix`, `level`, `tree`, `sort`, `remark`, `status`, `created_at`, `updated_at`) +SELECT NULL, @userListId, '下载用户模板', 'imUserDownloadTemplate', '', '', '3', '', '/imUser/downloadTemplate', '', '', '1', '', '0', '0', '', '0', '1', '0', '4', CONCAT('tr_', @imRootId, ' tr_', @userDirId, ' tr_', @userListId, ' '), '4', '', '1', @now, @now +WHERE NOT EXISTS (SELECT 1 FROM `hg_admin_menu` WHERE `name` = 'imUserDownloadTemplate'); + +INSERT INTO `hg_admin_menu` (`id`, `pid`, `title`, `name`, `path`, `icon`, `type`, `redirect`, `permissions`, `permission_name`, `component`, `always_show`, `active_menu`, `is_root`, `is_frame`, `frame_src`, `keep_alive`, `hidden`, `affix`, `level`, `tree`, `sort`, `remark`, `status`, `created_at`, `updated_at`) +SELECT NULL, @userDirId, '用户列表', 'imUserIndex', 'index', '', '2', '', '/imUser/list', '', '/imUser/index', '1', 'imUser', '0', '0', '', '0', '0', '0', '3', CONCAT('tr_', @imRootId, ' tr_', @userDirId, ' '), '10', '', '1', @now, @now +WHERE @userListId IS NULL; + +SELECT @userListId := `id` FROM `hg_admin_menu` WHERE `name` = 'imUserIndex' LIMIT 1; + INSERT INTO `hg_admin_menu` (`id`, `pid`, `title`, `name`, `path`, `icon`, `type`, `redirect`, `permissions`, `permission_name`, `component`, `always_show`, `active_menu`, `is_root`, `is_frame`, `frame_src`, `keep_alive`, `hidden`, `affix`, `level`, `tree`, `sort`, `remark`, `status`, `created_at`, `updated_at`) SELECT NULL, @userListId, '用户详情', 'imUserView', '', '', '3', '', '/imUser/view', '', '', '1', '', '0', '0', '', '0', '1', '0', '4', CONCAT('tr_', @imRootId, ' tr_', @userDirId, ' tr_', @userListId, ' '), '10', '', '1', @now, @now WHERE NOT EXISTS (SELECT 1 FROM `hg_admin_menu` WHERE `name` = 'imUserView'); +INSERT INTO `hg_admin_menu` (`id`, `pid`, `title`, `name`, `path`, `icon`, `type`, `redirect`, `permissions`, `permission_name`, `component`, `always_show`, `active_menu`, `is_root`, `is_frame`, `frame_src`, `keep_alive`, `hidden`, `affix`, `level`, `tree`, `sort`, `remark`, `status`, `created_at`, `updated_at`) +SELECT NULL, @userListId, '重置用户密码', 'imUserResetPassword', '', '', '3', '', '/imUser/resetPassword', '', '', '1', '', '0', '0', '', '0', '1', '0', '4', CONCAT('tr_', @imRootId, ' tr_', @userDirId, ' tr_', @userListId, ' '), '11', '', '1', @now, @now +WHERE NOT EXISTS (SELECT 1 FROM `hg_admin_menu` WHERE `name` = 'imUserResetPassword'); + +INSERT INTO `hg_admin_menu` (`id`, `pid`, `title`, `name`, `path`, `icon`, `type`, `redirect`, `permissions`, `permission_name`, `component`, `always_show`, `active_menu`, `is_root`, `is_frame`, `frame_src`, `keep_alive`, `hidden`, `affix`, `level`, `tree`, `sort`, `remark`, `status`, `created_at`, `updated_at`) +SELECT NULL, @userListId, '查看用户IP白名单', 'imUserIPWhitelist', '', '', '3', '', '/imUser/ipWhitelist', '', '', '1', '', '0', '0', '', '0', '1', '0', '4', CONCAT('tr_', @imRootId, ' tr_', @userDirId, ' tr_', @userListId, ' '), '12', '', '1', @now, @now +WHERE NOT EXISTS (SELECT 1 FROM `hg_admin_menu` WHERE `name` = 'imUserIPWhitelist'); + +INSERT INTO `hg_admin_menu` (`id`, `pid`, `title`, `name`, `path`, `icon`, `type`, `redirect`, `permissions`, `permission_name`, `component`, `always_show`, `active_menu`, `is_root`, `is_frame`, `frame_src`, `keep_alive`, `hidden`, `affix`, `level`, `tree`, `sort`, `remark`, `status`, `created_at`, `updated_at`) +SELECT NULL, @userListId, '保存用户IP白名单', 'imUserIPWhitelistSave', '', '', '3', '', '/imUser/ipWhitelistSave', '', '', '1', '', '0', '0', '', '0', '1', '0', '4', CONCAT('tr_', @imRootId, ' tr_', @userDirId, ' tr_', @userListId, ' '), '13', '', '1', @now, @now +WHERE NOT EXISTS (SELECT 1 FROM `hg_admin_menu` WHERE `name` = 'imUserIPWhitelistSave'); + +INSERT INTO `hg_admin_menu` (`id`, `pid`, `title`, `name`, `path`, `icon`, `type`, `redirect`, `permissions`, `permission_name`, `component`, `always_show`, `active_menu`, `is_root`, `is_frame`, `frame_src`, `keep_alive`, `hidden`, `affix`, `level`, `tree`, `sort`, `remark`, `status`, `created_at`, `updated_at`) +SELECT NULL, @userListId, '调整用户分组', 'imUserAdjustGroup', '', '', '3', '', '/imUser/adjustGroup', '', '', '1', '', '0', '0', '', '0', '1', '0', '4', CONCAT('tr_', @imRootId, ' tr_', @userDirId, ' tr_', @userListId, ' '), '14', '', '1', @now, @now +WHERE NOT EXISTS (SELECT 1 FROM `hg_admin_menu` WHERE `name` = 'imUserAdjustGroup'); + INSERT INTO `hg_admin_menu` (`id`, `pid`, `title`, `name`, `path`, `icon`, `type`, `redirect`, `permissions`, `permission_name`, `component`, `always_show`, `active_menu`, `is_root`, `is_frame`, `frame_src`, `keep_alive`, `hidden`, `affix`, `level`, `tree`, `sort`, `remark`, `status`, `created_at`, `updated_at`) SELECT NULL, @userListId, '封禁用户', 'imUserBan', '', '', '3', '', '/imUser/ban', '', '', '1', '', '0', '0', '', '0', '1', '0', '4', CONCAT('tr_', @imRootId, ' tr_', @userDirId, ' tr_', @userListId, ' '), '20', '', '1', @now, @now WHERE NOT EXISTS (SELECT 1 FROM `hg_admin_menu` WHERE `name` = 'imUserBan'); @@ -61,3 +99,12 @@ WHERE NOT EXISTS (SELECT 1 FROM `hg_admin_menu` WHERE `name` = 'imUserAccountCan INSERT INTO `hg_admin_menu` (`id`, `pid`, `title`, `name`, `path`, `icon`, `type`, `redirect`, `permissions`, `permission_name`, `component`, `always_show`, `active_menu`, `is_root`, `is_frame`, `frame_src`, `keep_alive`, `hidden`, `affix`, `level`, `tree`, `sort`, `remark`, `status`, `created_at`, `updated_at`) SELECT NULL, @userListId, '删除账号', 'imUserAccountDelete', '', '', '3', '', '/imUser/accountDelete', '', '', '1', '', '0', '0', '', '0', '1', '0', '4', CONCAT('tr_', @imRootId, ' tr_', @userDirId, ' tr_', @userListId, ' '), '90', '', '1', @now, @now WHERE NOT EXISTS (SELECT 1 FROM `hg_admin_menu` WHERE `name` = 'imUserAccountDelete'); + +INSERT IGNORE INTO `hg_admin_role_menu` (`role_id`, `menu_id`) +SELECT parent_role.`role_id`, child.`id` +FROM `hg_admin_role_menu` parent_role +JOIN `hg_admin_menu` parent ON parent.`id` = parent_role.`menu_id` AND parent.`name` = 'imUserIndex' +JOIN `hg_admin_menu` child ON child.`pid` = parent.`id` +WHERE child.`name` IN ('imUserCreate', 'imUserBatchCreate', 'imUserImport', 'imUserDownloadTemplate', + 'imUserView', 'imUserResetPassword', 'imUserIPWhitelist', 'imUserIPWhitelistSave', 'imUserAdjustGroup', + 'imUserBan', 'imUserUnban', 'imUserLogoutAll', 'imUserAccountCancel'); diff --git a/web/src/api/imUser/index.ts b/web/src/api/imUser/index.ts index e2dbb93..bd76c7e 100644 --- a/web/src/api/imUser/index.ts +++ b/web/src/api/imUser/index.ts @@ -1,4 +1,21 @@ -import { http } from '@/utils/http/axios'; +import { http, jumpExport } from '@/utils/http/axios'; +import type { UploadFileParams } from '@/utils/http/axios/types'; + +export function Create(params) { + return http.request({ url: '/imUser/create', method: 'POST', params }); +} + +export function BatchCreate(params) { + return http.request({ url: '/imUser/batchCreate', method: 'POST', params }); +} + +export function ImportUsers(params: UploadFileParams) { + return http.uploadFile({ url: '/imUser/import', method: 'POST' }, params); +} + +export function DownloadTemplate() { + jumpExport('/imUser/downloadTemplate', {}); +} // 获取IM用户列表 export function List(params) { @@ -45,6 +62,18 @@ export function Status(params) { }); } +export function AdjustGroup(params) { + return http.request({ url: '/imUser/adjustGroup', method: 'POST', params }); +} + +export function IPWhitelist(params) { + return http.request({ url: '/imUser/ipWhitelist', method: 'GET', params }); +} + +export function IPWhitelistSave(params) { + return http.request({ url: '/imUser/ipWhitelistSave', method: 'POST', params }); +} + // 获取IM用户设备列表 export function Devices(params) { return http.request({ diff --git a/web/src/views/imUser/index.vue b/web/src/views/imUser/index.vue index 6a63fe2..ce99325 100644 --- a/web/src/views/imUser/index.vue +++ b/web/src/views/imUser/index.vue @@ -19,26 +19,226 @@ :actionColumn="actionColumn" :scroll-x="scrollX" :resizeHeightOffset="-10000" - /> + > + + - + + + + {{ operationUser?.username }}({{ operationUser?.userId }}) + + + + + + + + + + {{ operationUser?.username }}({{ operationUser?.userId }}) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 点击或拖动 .xlsx 文件到此处 + + + + - + diff --git a/web/src/views/imUser/model.ts b/web/src/views/imUser/model.ts index fb40332..6790cb0 100644 --- a/web/src/views/imUser/model.ts +++ b/web/src/views/imUser/model.ts @@ -1,18 +1,15 @@ import { computed, h, ref } from 'vue'; -import { NSwitch } from 'naive-ui'; +import { NAvatar, NImage, NTag } from 'naive-ui'; import { cloneDeep } from 'lodash-es'; import { FormSchema } from '@/components/Form'; -import { defRangeShortcuts } from '@/utils/dateUtil'; import { useDictStore } from '@/store/modules/dict'; import { List as TenantList } from '@/api/imTenant'; -import { Status } from '@/api/imUser'; -import { usePermission } from '@/hooks/web/usePermission'; +import { List as PolicyGroupList } from '@/api/imUserPolicyGroup'; const dict = useDictStore(); -const { hasPermission } = usePermission(); -const $message = window['$message']; export const tenantOptions = ref([]); +export const policyGroupOptions = ref([]); export class State { public tenantId = 0; // 租户ID @@ -25,6 +22,8 @@ export class State { public gender = ''; // 性别 public region = ''; // 地区 public state = 0; // 账号状态 + public policyGroupId = 0; // 用户策略分组ID + public policyGroupName = ''; // 用户策略分组名称 public authVersion = 0; // 认证版本 public passwordUpdatedAt = ''; // 密码更新时间 public authNames = ''; // 登录标识 @@ -35,6 +34,8 @@ export class State { public updatedAt = ''; // 修改时间 public signature = ''; // 个性签名 public avatarFileId = ''; // 头像文件ID + public avatar = ''; // 头像地址 + public registerType = ''; // 注册方式 public birthDate = ''; // 出生日期 public profileJson = ''; // Tinode Public资料 public trustedJson = ''; // Tinode Trusted资料 @@ -88,25 +89,19 @@ export const schemas = ref([ { field: 'userId', component: 'NInput', - label: '用户ID', + label: '编号', componentProps: { - placeholder: '请输入用户ID', + placeholder: '用户编号', }, }, { - field: 'nickname', - component: 'NInput', - label: '昵称', + field: 'groupId', + component: 'NSelect', + label: '用户分组', componentProps: { - placeholder: '请输入昵称', - }, - }, - { - field: 'authName', - component: 'NInput', - label: '登录标识', - componentProps: { - placeholder: '请输入登录名/手机号/邮箱', + clearable: true, + options: computed(() => policyGroupOptions.value), + placeholder: '全部', }, }, { @@ -116,27 +111,16 @@ export const schemas = ref([ componentProps: { clearable: true, options: dict.getOption('ImUserStateOptions'), - placeholder: '请选择状态', + placeholder: '全部', }, }, { - field: 'createdAt', - component: 'NDatePicker', - label: '创建时间', + field: 'keyword', + component: 'NInput', + label: '关键词', componentProps: { - type: 'datetimerange', clearable: true, - shortcuts: defRangeShortcuts(), - }, - }, - { - field: 'lastSeen', - component: 'NDatePicker', - label: '最后在线', - componentProps: { - type: 'datetimerange', - clearable: true, - shortcuts: defRangeShortcuts(), + placeholder: '用户名/昵称/手机号', }, }, ]); @@ -144,85 +128,90 @@ export const schemas = ref([ // 表格列 export const columns = [ { - title: '用户ID', + title: '编号', key: 'userId', align: 'left', - width: 120, + width: 90, + }, + { + title: '头像', + key: 'avatar', + align: 'left', + width: 80, + render(row: State) { + if (row.avatar) { + return h(NImage, { width: 40, height: 40, src: row.avatar, objectFit: 'cover' }); + } + return h( + NAvatar, + { size: 40 }, + { default: () => (row.nickname || row.username || '?').slice(0, 1) } + ); + }, + }, + { + title: '用户名', + key: 'username', + align: 'left', + width: 150, }, { title: '昵称', key: 'nickname', align: 'left', - width: 160, - }, - { - title: '登录账号', - key: 'username', - align: 'left', - width: 180, + width: 150, + render(row: State) { + return row.nickname || '--'; + }, }, { title: '手机号', key: 'phone', align: 'left', - width: 180, + width: 140, + render(row: State) { + return row.phone || '--'; + }, }, { - title: '公开账号', - key: 'publicAccount', + title: '注册方式', + key: 'registerType', align: 'left', - width: 150, + width: 110, + render(row: State) { + return dict.getLabel('ImUserRegisterTypeOptions', row.registerType) || '--'; + }, }, { title: '状态', key: 'state', align: 'left', - width: 110, + width: 90, render(row: State) { - if (row.state === 20) { - return dict.getLabel('ImUserStateOptions', row.state); - } - return h(NSwitch, { - value: row.state === 0, - checked: '正常', - unchecked: '停用', - disabled: !hasPermission(['/imUser/status']), - onUpdateValue: async (value) => { - const oldState = row.state; - row.state = value ? 0 : 10; - try { - await Status({ tenantId: row.tenantId, userId: row.userId, state: row.state }); - $message.success('操作成功'); - } catch { - row.state = oldState; - } - }, - }); + return h( + NTag, + { type: row.state === 0 ? 'success' : row.state === 10 ? 'error' : 'default' }, + { default: () => dict.getLabel('ImUserStateOptions', row.state) || '--' } + ); }, }, { - title: '登录标识', - key: 'authNames', + title: '用户分组', + key: 'policyGroupName', align: 'left', - width: 240, + width: 130, + render(row: State) { + return row.policyGroupName || '--'; + }, }, { - title: '凭证', - key: 'credentials', - align: 'left', - width: 220, - }, - { - title: '最后在线', + title: '最近登录', key: 'lastSeen', align: 'left', width: 180, - }, - { - title: '客户端', - key: 'userAgent', - align: 'left', - width: 240, + render(row: State) { + return row.lastSeen || '--'; + }, }, { title: '创建时间', @@ -230,12 +219,6 @@ export const columns = [ align: 'left', width: 180, }, - { - title: '修改时间', - key: 'updatedAt', - align: 'left', - width: 180, - }, ]; export const deviceColumns = [ @@ -260,7 +243,7 @@ export const deviceColumns = [ // 加载字典数据选项 export function loadOptions() { - dict.loadOptions(['ImUserStateOptions']); + dict.loadOptions(['ImUserStateOptions', 'ImUserRegisterTypeOptions']); } export async function loadTenantOptions() { @@ -271,3 +254,12 @@ export async function loadTenantOptions() { })); return tenantOptions.value; } + +export async function loadPolicyGroupOptions(tenantId: number) { + const res = await PolicyGroupList({ tenantId, page: 1, pageSize: 200 }); + policyGroupOptions.value = (res?.list || []).map((item) => ({ + label: item.groupName, + value: item.id, + })); + return policyGroupOptions.value; +}