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"
- />
+ >
+
+
+
+
+ 创建用户
+
+
+ 批量创建
+
+
+
+ 导入Excel
+
+
+
+ 下载模板
+
+
+
+
-
+
+
+
+ {{ 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;
+}