diff --git a/backend/app/http/super/dto/user.go b/backend/app/http/super/dto/user.go index 1b87139..3f1dbf4 100644 --- a/backend/app/http/super/dto/user.go +++ b/backend/app/http/super/dto/user.go @@ -1,24 +1,57 @@ package dto import ( + "strings" + "time" + "quyun/v2/app/requests" - "quyun/v2/database/models" "quyun/v2/pkg/consts" + + "go.ipao.vip/gen/types" ) type UserPageFilter struct { - requests.Pagination - requests.SortQueryFilter + requests.Pagination `json:",inline" query:",inline"` + requests.SortQueryFilter `json:",inline" query:",inline"` - Username *string `query:"username"` - Status *consts.UserStatus `query:"status"` - TenantID *int64 `query:"tenant_id"` + ID *int64 `json:"id,omitempty" query:"id"` + Username *string `json:"username,omitempty" query:"username"` + Status *consts.UserStatus `json:"status,omitempty" query:"status"` + + // TenantID filters users by membership in the given tenant. + TenantID *int64 `json:"tenant_id,omitempty" query:"tenant_id"` + + // Role filters users containing a role (user/super_admin). + Role *consts.Role `json:"role,omitempty" query:"role"` + + CreatedAtFrom *time.Time `json:"created_at_from,omitempty" query:"created_at_from"` + CreatedAtTo *time.Time `json:"created_at_to,omitempty" query:"created_at_to"` + VerifiedAtFrom *time.Time `json:"verified_at_from,omitempty" query:"verified_at_from"` + VerifiedAtTo *time.Time `json:"verified_at_to,omitempty" query:"verified_at_to"` +} + +func (f *UserPageFilter) UsernameTrimmed() string { + if f == nil || f.Username == nil { + return "" + } + return strings.TrimSpace(*f.Username) } type UserItem struct { - *models.User + ID int64 `json:"id"` + Username string `json:"username"` + Roles types.Array[consts.Role] `json:"roles"` + Status consts.UserStatus `json:"status"` + StatusDescription string `json:"status_description,omitempty"` - StatusDescription string `json:"status_description,omitempty"` + Balance int64 `json:"balance"` + BalanceFrozen int64 `json:"balance_frozen"` + VerifiedAt time.Time `json:"verified_at"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + + OwnedTenantCount int64 `json:"owned_tenant_count"` + JoinedTenantCount int64 `json:"joined_tenant_count"` } type UserStatusUpdateForm struct { diff --git a/backend/app/http/super/dto/user_tenant.go b/backend/app/http/super/dto/user_tenant.go new file mode 100644 index 0000000..9af44f5 --- /dev/null +++ b/backend/app/http/super/dto/user_tenant.go @@ -0,0 +1,60 @@ +package dto + +import ( + "strings" + "time" + + "quyun/v2/app/requests" + "quyun/v2/pkg/consts" + + "go.ipao.vip/gen/types" +) + +type UserTenantPageFilter struct { + requests.Pagination `json:",inline" query:",inline"` + + TenantID *int64 `json:"tenant_id,omitempty" query:"tenant_id"` + Code *string `json:"code,omitempty" query:"code"` + Name *string `json:"name,omitempty" query:"name"` + + // Role filters tenant_users.role containing a role (tenant_admin/member). + Role *consts.TenantUserRole `json:"role,omitempty" query:"role"` + // Status filters tenant_users.status. + Status *consts.UserStatus `json:"status,omitempty" query:"status"` + + CreatedAtFrom *time.Time `json:"created_at_from,omitempty" query:"created_at_from"` + CreatedAtTo *time.Time `json:"created_at_to,omitempty" query:"created_at_to"` +} + +func (f *UserTenantPageFilter) CodeTrimmed() string { + if f == nil || f.Code == nil { + return "" + } + return strings.ToLower(strings.TrimSpace(*f.Code)) +} + +func (f *UserTenantPageFilter) NameTrimmed() string { + if f == nil || f.Name == nil { + return "" + } + return strings.TrimSpace(*f.Name) +} + +type UserTenantItem struct { + TenantID int64 `json:"tenant_id"` + Code string `json:"code"` + Name string `json:"name"` + + TenantStatus consts.TenantStatus `json:"tenant_status"` + TenantStatusDescription string `json:"tenant_status_description,omitempty"` + ExpiredAt time.Time `json:"expired_at"` + + Owner *TenantOwnerUserLite `json:"owner,omitempty"` + + Role types.Array[consts.TenantUserRole] `json:"role"` + + MemberStatus consts.UserStatus `json:"member_status"` + MemberStatusDescription string `json:"member_status_description,omitempty"` + + JoinedAt time.Time `json:"joined_at"` +} diff --git a/backend/app/http/super/routes.gen.go b/backend/app/http/super/routes.gen.go index 470d049..c76ae20 100644 --- a/backend/app/http/super/routes.gen.go +++ b/backend/app/http/super/routes.gen.go @@ -99,6 +99,12 @@ func (r *Routes) Register(router fiber.Router) { r.user.list, Query[dto.UserPageFilter]("filter"), )) + r.log.Debugf("Registering route: Get /super/v1/users/:userID/tenants -> user.tenants") + router.Get("/super/v1/users/:userID/tenants"[len(r.Path()):], DataFunc2( + r.user.tenants, + PathParam[int64]("userID"), + Query[dto.UserTenantPageFilter]("filter"), + )) r.log.Debugf("Registering route: Get /super/v1/users/statistics -> user.statistics") router.Get("/super/v1/users/statistics"[len(r.Path()):], DataFunc0( r.user.statistics, diff --git a/backend/app/http/super/user.go b/backend/app/http/super/user.go index e54b3b6..3bff175 100644 --- a/backend/app/http/super/user.go +++ b/backend/app/http/super/user.go @@ -15,7 +15,7 @@ type user struct{} // list // -// @Summary 租户列表 +// @Summary 用户列表 // @Tags Super // @Accept json // @Produce json @@ -28,6 +28,23 @@ func (*user) list(ctx fiber.Ctx, filter *dto.UserPageFilter) (*requests.Pager, e return services.User.Page(ctx, filter) } +// tenants +// +// @Summary 用户加入的租户列表 +// @Tags Super +// @Accept json +// @Produce json +// @Param userID path int64 true "UserID" +// @Param filter query dto.UserTenantPageFilter true "Filter" +// @Success 200 {object} requests.Pager{items=dto.UserTenantItem} +// +// @Router /super/v1/users/:userID/tenants [get] +// @Bind userID path +// @Bind filter query +func (*user) tenants(ctx fiber.Ctx, userID int64, filter *dto.UserTenantPageFilter) (*requests.Pager, error) { + return services.User.TenantsPage(ctx, userID, filter) +} + // updateStatus // // @Summary 更新用户状态 diff --git a/backend/app/services/user.go b/backend/app/services/user.go index 984713c..22fac35 100644 --- a/backend/app/services/user.go +++ b/backend/app/services/user.go @@ -2,9 +2,11 @@ package services import ( "context" + "strings" "quyun/v2/app/http/super/dto" "quyun/v2/app/requests" + "quyun/v2/database" "quyun/v2/database/models" "quyun/v2/pkg/consts" @@ -12,6 +14,8 @@ import ( "github.com/samber/lo" "github.com/sirupsen/logrus" "go.ipao.vip/gen" + "go.ipao.vip/gen/field" + "go.ipao.vip/gen/types" ) // @provider @@ -59,9 +63,19 @@ func (t *user) SetStatus(ctx context.Context, userID int64, status consts.UserSt func (t *user) Page(ctx context.Context, filter *dto.UserPageFilter) (*requests.Pager, error) { tbl, query := models.UserQuery.QueryContext(ctx) + if filter == nil { + filter = &dto.UserPageFilter{} + } + conds := []gen.Condition{} - if filter.Username != nil { - conds = append(conds, tbl.Username.Like("%"+*filter.Username+"%")) + if filter.ID != nil && *filter.ID > 0 { + conds = append(conds, tbl.ID.Eq(*filter.ID)) + } + if username := filter.UsernameTrimmed(); username != "" { + conds = append(conds, tbl.Username.Like(database.WrapLike(username))) + } + if filter.Role != nil && *filter.Role != "" { + conds = append(conds, tbl.Roles.Contains(types.NewArray([]consts.Role{*filter.Role}))) } if filter.TenantID != nil { @@ -75,15 +89,103 @@ func (t *user) Page(ctx context.Context, filter *dto.UserPageFilter) (*requests. } filter.Pagination.Format() - users, total, err := query.Where(conds...).Order(tbl.ID.Desc()).FindByPage(int(filter.Offset()), int(filter.Limit)) + + if filter.CreatedAtFrom != nil { + conds = append(conds, tbl.CreatedAt.Gte(*filter.CreatedAtFrom)) + } + if filter.CreatedAtTo != nil { + conds = append(conds, tbl.CreatedAt.Lte(*filter.CreatedAtTo)) + } + if filter.VerifiedAtFrom != nil { + conds = append(conds, tbl.VerifiedAt.Gte(*filter.VerifiedAtFrom)) + } + if filter.VerifiedAtTo != nil { + conds = append(conds, tbl.VerifiedAt.Lte(*filter.VerifiedAtTo)) + } + + // 排序白名单:避免把任意字段拼进 SQL 导致注入或慢查询。 + orderBys := make([]field.Expr, 0, 6) + allowedAsc := map[string]field.Expr{ + "id": tbl.ID.Asc(), + "username": tbl.Username.Asc(), + "status": tbl.Status.Asc(), + "balance": tbl.Balance.Asc(), + "verified_at": tbl.VerifiedAt.Asc(), + "created_at": tbl.CreatedAt.Asc(), + "updated_at": tbl.UpdatedAt.Asc(), + } + allowedDesc := map[string]field.Expr{ + "id": tbl.ID.Desc(), + "username": tbl.Username.Desc(), + "status": tbl.Status.Desc(), + "balance": tbl.Balance.Desc(), + "verified_at": tbl.VerifiedAt.Desc(), + "created_at": tbl.CreatedAt.Desc(), + "updated_at": tbl.UpdatedAt.Desc(), + } + for _, f := range filter.AscFields() { + f = strings.TrimSpace(f) + if f == "" { + continue + } + if ob, ok := allowedAsc[f]; ok { + orderBys = append(orderBys, ob) + } + } + for _, f := range filter.DescFields() { + f = strings.TrimSpace(f) + if f == "" { + continue + } + if ob, ok := allowedDesc[f]; ok { + orderBys = append(orderBys, ob) + } + } + if len(orderBys) == 0 { + orderBys = append(orderBys, tbl.ID.Desc()) + } else { + orderBys = append(orderBys, tbl.ID.Desc()) + } + + users, total, err := query.Where(conds...).Order(orderBys...).FindByPage(int(filter.Offset()), int(filter.Limit)) + if err != nil { + return nil, err + } + + userIDs := make([]int64, 0, len(users)) + for _, u := range users { + if u == nil { + continue + } + userIDs = append(userIDs, u.ID) + } + + ownedTenantCounts, err := t.UserOwnedTenantCountMapping(ctx, userIDs) + if err != nil { + return nil, err + } + joinedTenantCounts, err := t.UserJoinedTenantCountMapping(ctx, userIDs) if err != nil { return nil, err } items := lo.Map(users, func(model *models.User, _ int) *dto.UserItem { + if model == nil { + return &dto.UserItem{} + } return &dto.UserItem{ - User: model, + ID: model.ID, + Username: model.Username, + Roles: model.Roles, + Status: model.Status, StatusDescription: model.Status.Description(), + Balance: model.Balance, + BalanceFrozen: model.BalanceFrozen, + VerifiedAt: model.VerifiedAt, + CreatedAt: model.CreatedAt, + UpdatedAt: model.UpdatedAt, + OwnedTenantCount: ownedTenantCounts[model.ID], + JoinedTenantCount: joinedTenantCounts[model.ID], } }) @@ -94,6 +196,68 @@ func (t *user) Page(ctx context.Context, filter *dto.UserPageFilter) (*requests. }, nil } +func (t *user) UserOwnedTenantCountMapping(ctx context.Context, userIDs []int64) (map[int64]int64, error) { + result := make(map[int64]int64, len(userIDs)) + for _, id := range userIDs { + if id <= 0 { + continue + } + result[id] = 0 + } + if len(result) == 0 { + return result, nil + } + + ttbl, tquery := models.TenantQuery.QueryContext(ctx) + var rows []struct { + UserID int64 + Count int64 + } + err := tquery. + Select(ttbl.UserID, ttbl.ID.Count().As("count")). + Where(ttbl.UserID.In(userIDs...)). + Group(ttbl.UserID). + Scan(&rows) + if err != nil { + return nil, err + } + for _, row := range rows { + result[row.UserID] = row.Count + } + return result, nil +} + +func (t *user) UserJoinedTenantCountMapping(ctx context.Context, userIDs []int64) (map[int64]int64, error) { + result := make(map[int64]int64, len(userIDs)) + for _, id := range userIDs { + if id <= 0 { + continue + } + result[id] = 0 + } + if len(result) == 0 { + return result, nil + } + + tutbl, tuquery := models.TenantUserQuery.QueryContext(ctx) + var rows []struct { + UserID int64 + Count int64 + } + err := tuquery. + Select(tutbl.UserID, tutbl.TenantID.Count().As("count")). + Where(tutbl.UserID.In(userIDs...)). + Group(tutbl.UserID). + Scan(&rows) + if err != nil { + return nil, err + } + for _, row := range rows { + result[row.UserID] = row.Count + } + return result, nil +} + // UpdateStatus 更新用户状态(超级管理员侧)。 func (t *user) UpdateStatus(ctx context.Context, userID int64, status consts.UserStatus) error { logrus.WithField("user_id", userID).WithField("status", status).Info("update user status") @@ -127,3 +291,114 @@ func (t *user) Statistics(ctx context.Context) ([]*dto.UserStatistics, error) { return item }), nil } + +// TenantsPage 分页查询“用户加入的租户”(通过 tenant_users 关联)。 +func (t *user) TenantsPage(ctx context.Context, userID int64, filter *dto.UserTenantPageFilter) (*requests.Pager, error) { + if userID <= 0 { + return nil, errors.New("user_id must be > 0") + } + if filter == nil { + filter = &dto.UserTenantPageFilter{} + } + + filter.Pagination.Format() + + tuTbl, query := models.TenantUserQuery.QueryContext(ctx) + conds := []gen.Condition{tuTbl.UserID.Eq(userID)} + + if filter.TenantID != nil && *filter.TenantID > 0 { + conds = append(conds, tuTbl.TenantID.Eq(*filter.TenantID)) + } + if filter.Role != nil && *filter.Role != "" { + conds = append(conds, tuTbl.Role.Contains(types.NewArray([]consts.TenantUserRole{*filter.Role}))) + } + if filter.Status != nil && *filter.Status != "" { + conds = append(conds, tuTbl.Status.Eq(*filter.Status)) + } + if filter.CreatedAtFrom != nil { + conds = append(conds, tuTbl.CreatedAt.Gte(*filter.CreatedAtFrom)) + } + if filter.CreatedAtTo != nil { + conds = append(conds, tuTbl.CreatedAt.Lte(*filter.CreatedAtTo)) + } + + code := filter.CodeTrimmed() + name := filter.NameTrimmed() + if code != "" || name != "" { + tTbl, _ := models.TenantQuery.QueryContext(ctx) + query = query.LeftJoin(tTbl, tTbl.ID.EqCol(tuTbl.TenantID)) + if code != "" { + conds = append(conds, tTbl.Code.Like(database.WrapLike(code))) + } + if name != "" { + conds = append(conds, tTbl.Name.Like(database.WrapLike(name))) + } + } + + rows, total, err := query.Where(conds...).Order(tuTbl.ID.Desc()).FindByPage(int(filter.Offset()), int(filter.Limit)) + if err != nil { + return nil, err + } + + tenantIDs := make([]int64, 0, len(rows)) + for _, tu := range rows { + if tu == nil { + continue + } + tenantIDs = append(tenantIDs, tu.TenantID) + } + tenantIDs = lo.Uniq(tenantIDs) + + tenants := make(map[int64]*models.Tenant, len(tenantIDs)) + tenantList := make([]*models.Tenant, 0, len(tenantIDs)) + if len(tenantIDs) > 0 { + tTbl, tQuery := models.TenantQuery.QueryContext(ctx) + ts, err := tQuery.Where(tTbl.ID.In(tenantIDs...)).Find() + if err != nil { + return nil, err + } + for _, te := range ts { + if te == nil { + continue + } + tenants[te.ID] = te + tenantList = append(tenantList, te) + } + } + + ownerMap, err := Tenant.TenantOwnerUserMapping(ctx, tenantList) + if err != nil { + return nil, err + } + + items := make([]*dto.UserTenantItem, 0, len(rows)) + for _, tu := range rows { + if tu == nil { + continue + } + te := tenants[tu.TenantID] + if te == nil { + continue + } + + items = append(items, &dto.UserTenantItem{ + TenantID: te.ID, + Code: te.Code, + Name: te.Name, + TenantStatus: te.Status, + TenantStatusDescription: te.Status.Description(), + ExpiredAt: te.ExpiredAt, + Owner: ownerMap[te.ID], + Role: tu.Role, + MemberStatus: tu.Status, + MemberStatusDescription: tu.Status.Description(), + JoinedAt: tu.CreatedAt, + }) + } + + return &requests.Pager{ + Pagination: filter.Pagination, + Total: total, + Items: items, + }, nil +} diff --git a/frontend/superadmin/dist/index.html b/frontend/superadmin/dist/index.html index 0ab5f2b..b26c0ab 100644 --- a/frontend/superadmin/dist/index.html +++ b/frontend/superadmin/dist/index.html @@ -7,8 +7,8 @@