From c0cebb6fb974a1f34dc788832dba4e9722f5d861 Mon Sep 17 00:00:00 2001 From: Rogee Date: Fri, 9 Jan 2026 09:52:23 +0800 Subject: [PATCH] feat: complete superadmin management endpoints --- backend/app/http/super/v1/contents.go | 22 + backend/app/http/super/v1/dto/super.go | 76 +++ backend/app/http/super/v1/routes.gen.go | 18 + backend/app/http/super/v1/tenants.go | 18 + backend/app/http/super/v1/users.go | 18 + backend/app/services/super.go | 758 ++++++++++++++++++++++-- 6 files changed, 877 insertions(+), 33 deletions(-) diff --git a/backend/app/http/super/v1/contents.go b/backend/app/http/super/v1/contents.go index 744d781..2310750 100644 --- a/backend/app/http/super/v1/contents.go +++ b/backend/app/http/super/v1/contents.go @@ -27,6 +27,28 @@ func (c *contents) List(ctx fiber.Ctx, filter *dto.SuperContentListFilter) (*req return services.Super.ListContents(ctx, filter) } +// List tenant contents +// +// @Router /super/v1/tenants/:tenantID/contents [get] +// @Summary List tenant contents +// @Description List contents by tenant +// @Tags Content +// @Accept json +// @Produce json +// @Param tenantID path int64 true "Tenant ID" +// @Param page query int false "Page number" +// @Param limit query int false "Page size" +// @Success 200 {object} requests.Pager{items=[]dto.AdminContentItem} +// @Bind tenantID path +// @Bind filter query +func (c *contents) ListTenantContents(ctx fiber.Ctx, tenantID int64, filter *dto.SuperContentListFilter) (*requests.Pager, error) { + if filter == nil { + filter = &dto.SuperContentListFilter{} + } + filter.TenantID = &tenantID + return services.Super.ListContents(ctx, filter) +} + // Update content status // // @Router /super/v1/tenants/:tenantID/contents/:contentID/status [patch] diff --git a/backend/app/http/super/v1/dto/super.go b/backend/app/http/super/v1/dto/super.go index 1e73e89..d6f5574 100644 --- a/backend/app/http/super/v1/dto/super.go +++ b/backend/app/http/super/v1/dto/super.go @@ -9,12 +9,88 @@ import ( // Filters type UserListFilter struct { requests.Pagination + // ID 用户ID,精确匹配。 + ID *int64 `query:"id"` + // TenantID 租户ID,筛选加入该租户的用户。 + TenantID *int64 `query:"tenant_id"` + // Username 用户名/昵称,模糊匹配。 Username *string `query:"username"` + // Status 用户状态过滤。 + Status *consts.UserStatus `query:"status"` + // Role 角色过滤(roles 包含该角色)。 + Role *consts.Role `query:"role"` + // CreatedAtFrom 创建时间起始(RFC3339)。 + CreatedAtFrom *string `query:"created_at_from"` + // CreatedAtTo 创建时间结束(RFC3339)。 + CreatedAtTo *string `query:"created_at_to"` + // VerifiedAtFrom 认证时间起始(RFC3339)。 + VerifiedAtFrom *string `query:"verified_at_from"` + // VerifiedAtTo 认证时间结束(RFC3339)。 + VerifiedAtTo *string `query:"verified_at_to"` + // Asc 升序字段(id/username/status/created_at/verified_at)。 + Asc *string `query:"asc"` + // Desc 降序字段(id/username/status/created_at/verified_at)。 + Desc *string `query:"desc"` } type TenantListFilter struct { requests.Pagination + // ID 租户ID,精确匹配。 + ID *int64 `query:"id"` + // UserID 租户所有者用户ID,精确匹配。 + UserID *int64 `query:"user_id"` + // Name 租户名称,模糊匹配。 Name *string `query:"name"` + // Code 租户编码,模糊匹配。 + Code *string `query:"code"` + // Status 租户状态过滤。 + Status *consts.TenantStatus `query:"status"` + // ExpiredAtFrom 过期时间起始(RFC3339)。 + ExpiredAtFrom *string `query:"expired_at_from"` + // ExpiredAtTo 过期时间结束(RFC3339)。 + ExpiredAtTo *string `query:"expired_at_to"` + // CreatedAtFrom 创建时间起始(RFC3339)。 + CreatedAtFrom *string `query:"created_at_from"` + // CreatedAtTo 创建时间结束(RFC3339)。 + CreatedAtTo *string `query:"created_at_to"` + // Asc 升序字段(id/name/code/status/expired_at/created_at)。 + Asc *string `query:"asc"` + // Desc 降序字段(id/name/code/status/expired_at/created_at)。 + Desc *string `query:"desc"` +} + +type SuperTenantUserListFilter struct { + requests.Pagination + // UserID 用户ID,精确匹配。 + UserID *int64 `query:"user_id"` + // Username 用户名/昵称,模糊匹配。 + Username *string `query:"username"` + // Role 成员角色过滤(role 包含该角色)。 + Role *consts.TenantUserRole `query:"role"` + // Status 成员状态过滤。 + Status *consts.UserStatus `query:"status"` +} + +type SuperUserTenantListFilter struct { + requests.Pagination + // TenantID 租户ID,精确匹配。 + TenantID *int64 `query:"tenant_id"` + // Code 租户编码,模糊匹配。 + Code *string `query:"code"` + // Name 租户名称,模糊匹配。 + Name *string `query:"name"` + // Role 成员角色过滤(role 包含该角色)。 + Role *consts.TenantUserRole `query:"role"` + // Status 成员状态过滤。 + Status *consts.UserStatus `query:"status"` + // CreatedAtFrom 加入时间起始(RFC3339)。 + CreatedAtFrom *string `query:"created_at_from"` + // CreatedAtTo 加入时间结束(RFC3339)。 + CreatedAtTo *string `query:"created_at_to"` + // Asc 升序字段(tenant_id/created_at)。 + Asc *string `query:"asc"` + // Desc 降序字段(tenant_id/created_at)。 + Desc *string `query:"desc"` } type SuperContentListFilter struct { diff --git a/backend/app/http/super/v1/routes.gen.go b/backend/app/http/super/v1/routes.gen.go index c1a6fb8..bfb6d2f 100644 --- a/backend/app/http/super/v1/routes.gen.go +++ b/backend/app/http/super/v1/routes.gen.go @@ -50,6 +50,12 @@ func (r *Routes) Register(router fiber.Router) { r.contents.List, Query[dto.SuperContentListFilter]("filter"), )) + r.log.Debugf("Registering route: Get /super/v1/tenants/:tenantID/contents -> contents.ListTenantContents") + router.Get("/super/v1/tenants/:tenantID/contents"[len(r.Path()):], DataFunc2( + r.contents.ListTenantContents, + PathParam[int64]("tenantID"), + Query[dto.SuperContentListFilter]("filter"), + )) r.log.Debugf("Registering route: Patch /super/v1/tenants/:tenantID/contents/:contentID/status -> contents.UpdateStatus") router.Patch("/super/v1/tenants/:tenantID/contents/:contentID/status"[len(r.Path()):], Func3( r.contents.UpdateStatus, @@ -89,6 +95,12 @@ func (r *Routes) Register(router fiber.Router) { r.tenants.Get, PathParam[int64]("id"), )) + r.log.Debugf("Registering route: Get /super/v1/tenants/:tenantID/users -> tenants.ListUsers") + router.Get("/super/v1/tenants/:tenantID/users"[len(r.Path()):], DataFunc2( + r.tenants.ListUsers, + PathParam[int64]("tenantID"), + Query[dto.SuperTenantUserListFilter]("filter"), + )) r.log.Debugf("Registering route: Get /super/v1/tenants/statuses -> tenants.Statuses") router.Get("/super/v1/tenants/statuses"[len(r.Path()):], DataFunc0( r.tenants.Statuses, @@ -121,6 +133,12 @@ func (r *Routes) Register(router fiber.Router) { r.users.Get, PathParam[int64]("id"), )) + r.log.Debugf("Registering route: Get /super/v1/users/:id/tenants -> users.ListTenants") + router.Get("/super/v1/users/:id/tenants"[len(r.Path()):], DataFunc2( + r.users.ListTenants, + PathParam[int64]("id"), + Query[dto.SuperUserTenantListFilter]("filter"), + )) r.log.Debugf("Registering route: Get /super/v1/users/statistics -> users.Statistics") router.Get("/super/v1/users/statistics"[len(r.Path()):], DataFunc0( r.users.Statistics, diff --git a/backend/app/http/super/v1/tenants.go b/backend/app/http/super/v1/tenants.go index 35e4783..3423fd9 100644 --- a/backend/app/http/super/v1/tenants.go +++ b/backend/app/http/super/v1/tenants.go @@ -28,6 +28,24 @@ func (c *tenants) List(ctx fiber.Ctx, filter *dto.TenantListFilter) (*requests.P return services.Super.ListTenants(ctx, filter) } +// List tenant users +// +// @Router /super/v1/tenants/:tenantID/users [get] +// @Summary List tenant users +// @Description List tenant users +// @Tags Tenant +// @Accept json +// @Produce json +// @Param tenantID path int64 true "Tenant ID" +// @Param page query int false "Page number" +// @Param limit query int false "Page size" +// @Success 200 {object} requests.Pager{items=[]dto.SuperTenantUserItem} +// @Bind tenantID path +// @Bind filter query +func (c *tenants) ListUsers(ctx fiber.Ctx, tenantID int64, filter *dto.SuperTenantUserListFilter) (*requests.Pager, error) { + return services.Super.ListTenantUsers(ctx, tenantID, filter) +} + // Create tenant // // @Router /super/v1/tenants [post] diff --git a/backend/app/http/super/v1/users.go b/backend/app/http/super/v1/users.go index bbe36fc..85c5602 100644 --- a/backend/app/http/super/v1/users.go +++ b/backend/app/http/super/v1/users.go @@ -43,6 +43,24 @@ func (c *users) Get(ctx fiber.Ctx, id int64) (*dto.UserItem, error) { return services.Super.GetUser(ctx, id) } +// List user tenants +// +// @Router /super/v1/users/:id/tenants [get] +// @Summary List user tenants +// @Description List tenants joined by user +// @Tags User +// @Accept json +// @Produce json +// @Param id path int64 true "User ID" +// @Param page query int false "Page number" +// @Param limit query int false "Page size" +// @Success 200 {object} requests.Pager{items=[]dto.UserTenantItem} +// @Bind id path +// @Bind filter query +func (c *users) ListTenants(ctx fiber.Ctx, id int64, filter *dto.SuperUserTenantListFilter) (*requests.Pager, error) { + return services.Super.ListUserTenants(ctx, id, filter) +} + // Update user status // // @Router /super/v1/users/:id/status [patch] diff --git a/backend/app/services/super.go b/backend/app/services/super.go index 33c91c9..28293b0 100644 --- a/backend/app/services/super.go +++ b/backend/app/services/super.go @@ -88,8 +88,118 @@ func (s *super) CheckToken(ctx context.Context, token string) (*super_dto.LoginR func (s *super) ListUsers(ctx context.Context, filter *super_dto.UserListFilter) (*requests.Pager, error) { tbl, q := models.UserQuery.QueryContext(ctx) - if filter.Username != nil && *filter.Username != "" { - q = q.Where(tbl.Username.Like("%" + *filter.Username + "%")).Or(tbl.Nickname.Like("%" + *filter.Username + "%")) + if filter.ID != nil && *filter.ID > 0 { + q = q.Where(tbl.ID.Eq(*filter.ID)) + } + if filter.Username != nil && strings.TrimSpace(*filter.Username) != "" { + keyword := "%" + strings.TrimSpace(*filter.Username) + "%" + q = q.Where(tbl.Username.Like(keyword)).Or(tbl.Nickname.Like(keyword)) + } + if filter.Status != nil && *filter.Status != "" { + q = q.Where(tbl.Status.Eq(*filter.Status)) + } + if filter.Role != nil && *filter.Role != "" { + q = q.Where(tbl.Roles.Contains(types.Array[consts.Role]{*filter.Role})) + } + if filter.TenantID != nil && *filter.TenantID > 0 { + // 按租户成员过滤用户,需要先定位租户成员关系。 + tblTu, qTu := models.TenantUserQuery.QueryContext(ctx) + userIDs, err := qTu.Where(tblTu.TenantID.Eq(*filter.TenantID)).Select(tblTu.UserID).Find() + if err != nil { + return nil, errorx.ErrDatabaseError.WithCause(err) + } + ids := make([]int64, 0, len(userIDs)) + for _, row := range userIDs { + ids = append(ids, row.UserID) + } + if len(ids) == 0 { + q = q.Where(tbl.ID.Eq(-1)) + } else { + q = q.Where(tbl.ID.In(ids...)) + } + } + + if filter.CreatedAtFrom != nil { + from, err := s.parseFilterTime(filter.CreatedAtFrom) + if err != nil { + return nil, err + } + if from != nil { + q = q.Where(tbl.CreatedAt.Gte(*from)) + } + } + if filter.CreatedAtTo != nil { + to, err := s.parseFilterTime(filter.CreatedAtTo) + if err != nil { + return nil, err + } + if to != nil { + q = q.Where(tbl.CreatedAt.Lte(*to)) + } + } + if filter.VerifiedAtFrom != nil { + from, err := s.parseFilterTime(filter.VerifiedAtFrom) + if err != nil { + return nil, err + } + if from != nil { + q = q.Where(tbl.VerifiedAt.Gte(*from)) + } + } + if filter.VerifiedAtTo != nil { + to, err := s.parseFilterTime(filter.VerifiedAtTo) + if err != nil { + return nil, err + } + if to != nil { + q = q.Where(tbl.VerifiedAt.Lte(*to)) + } + } + + orderApplied := false + if filter.Desc != nil && strings.TrimSpace(*filter.Desc) != "" { + switch strings.TrimSpace(*filter.Desc) { + case "id": + q = q.Order(tbl.ID.Desc()) + case "username": + q = q.Order(tbl.Username.Desc()) + case "status": + q = q.Order(tbl.Status.Desc()) + case "verified_at": + q = q.Order(tbl.VerifiedAt.Desc()) + case "created_at": + q = q.Order(tbl.CreatedAt.Desc()) + case "updated_at": + q = q.Order(tbl.UpdatedAt.Desc()) + case "balance": + q = q.Order(tbl.Balance.Desc()) + case "balance_frozen": + q = q.Order(tbl.BalanceFrozen.Desc()) + } + orderApplied = true + } else if filter.Asc != nil && strings.TrimSpace(*filter.Asc) != "" { + switch strings.TrimSpace(*filter.Asc) { + case "id": + q = q.Order(tbl.ID) + case "username": + q = q.Order(tbl.Username) + case "status": + q = q.Order(tbl.Status) + case "verified_at": + q = q.Order(tbl.VerifiedAt) + case "created_at": + q = q.Order(tbl.CreatedAt) + case "updated_at": + q = q.Order(tbl.UpdatedAt) + case "balance": + q = q.Order(tbl.Balance) + case "balance_frozen": + q = q.Order(tbl.BalanceFrozen) + } + orderApplied = true + } + if !orderApplied { + q = q.Order(tbl.ID.Desc()) } filter.Pagination.Format() @@ -103,6 +213,20 @@ func (s *super) ListUsers(ctx context.Context, filter *super_dto.UserListFilter) return nil, errorx.ErrDatabaseError.WithCause(err) } + userIDs := make([]int64, 0, len(list)) + for _, u := range list { + userIDs = append(userIDs, u.ID) + } + + ownedCountMap, err := s.userOwnedTenantCount(ctx, userIDs) + if err != nil { + return nil, err + } + joinedCountMap, err := s.userJoinedTenantCount(ctx, userIDs) + if err != nil { + return nil, err + } + var data []super_dto.UserItem for _, u := range list { data = append(data, super_dto.UserItem{ @@ -112,11 +236,14 @@ func (s *super) ListUsers(ctx context.Context, filter *super_dto.UserListFilter) Roles: u.Roles, Status: u.Status, StatusDescription: u.Status.Description(), - CreatedAt: u.CreatedAt.Format(time.RFC3339), - UpdatedAt: u.UpdatedAt.Format(time.RFC3339), + VerifiedAt: s.formatTime(u.VerifiedAt), + CreatedAt: s.formatTime(u.CreatedAt), + UpdatedAt: s.formatTime(u.UpdatedAt), }, - Balance: u.Balance, - BalanceFrozen: u.BalanceFrozen, + Balance: u.Balance, + BalanceFrozen: u.BalanceFrozen, + OwnedTenantCount: ownedCountMap[u.ID], + JoinedTenantCount: joinedCountMap[u.ID], }) } @@ -175,8 +302,102 @@ func (s *super) UpdateUserRoles(ctx context.Context, id int64, form *super_dto.U func (s *super) ListTenants(ctx context.Context, filter *super_dto.TenantListFilter) (*requests.Pager, error) { tbl, q := models.TenantQuery.QueryContext(ctx) - if filter.Name != nil && *filter.Name != "" { - q = q.Where(tbl.Name.Like("%" + *filter.Name + "%")) + if filter.ID != nil && *filter.ID > 0 { + q = q.Where(tbl.ID.Eq(*filter.ID)) + } + if filter.UserID != nil && *filter.UserID > 0 { + q = q.Where(tbl.UserID.Eq(*filter.UserID)) + } + if filter.Name != nil && strings.TrimSpace(*filter.Name) != "" { + q = q.Where(tbl.Name.Like("%" + strings.TrimSpace(*filter.Name) + "%")) + } + if filter.Code != nil && strings.TrimSpace(*filter.Code) != "" { + q = q.Where(tbl.Code.Like("%" + strings.TrimSpace(*filter.Code) + "%")) + } + if filter.Status != nil && *filter.Status != "" { + q = q.Where(tbl.Status.Eq(*filter.Status)) + } + if filter.ExpiredAtFrom != nil { + from, err := s.parseFilterTime(filter.ExpiredAtFrom) + if err != nil { + return nil, err + } + if from != nil { + q = q.Where(tbl.ExpiredAt.Gte(*from)) + } + } + if filter.ExpiredAtTo != nil { + to, err := s.parseFilterTime(filter.ExpiredAtTo) + if err != nil { + return nil, err + } + if to != nil { + q = q.Where(tbl.ExpiredAt.Lte(*to)) + } + } + if filter.CreatedAtFrom != nil { + from, err := s.parseFilterTime(filter.CreatedAtFrom) + if err != nil { + return nil, err + } + if from != nil { + q = q.Where(tbl.CreatedAt.Gte(*from)) + } + } + if filter.CreatedAtTo != nil { + to, err := s.parseFilterTime(filter.CreatedAtTo) + if err != nil { + return nil, err + } + if to != nil { + q = q.Where(tbl.CreatedAt.Lte(*to)) + } + } + + orderApplied := false + if filter.Desc != nil && strings.TrimSpace(*filter.Desc) != "" { + switch strings.TrimSpace(*filter.Desc) { + case "id": + q = q.Order(tbl.ID.Desc()) + case "name": + q = q.Order(tbl.Name.Desc()) + case "code": + q = q.Order(tbl.Code.Desc()) + case "status": + q = q.Order(tbl.Status.Desc()) + case "expired_at": + q = q.Order(tbl.ExpiredAt.Desc()) + case "created_at": + q = q.Order(tbl.CreatedAt.Desc()) + case "updated_at": + q = q.Order(tbl.UpdatedAt.Desc()) + case "user_id": + q = q.Order(tbl.UserID.Desc()) + } + orderApplied = true + } else if filter.Asc != nil && strings.TrimSpace(*filter.Asc) != "" { + switch strings.TrimSpace(*filter.Asc) { + case "id": + q = q.Order(tbl.ID) + case "name": + q = q.Order(tbl.Name) + case "code": + q = q.Order(tbl.Code) + case "status": + q = q.Order(tbl.Status) + case "expired_at": + q = q.Order(tbl.ExpiredAt) + case "created_at": + q = q.Order(tbl.CreatedAt) + case "updated_at": + q = q.Order(tbl.UpdatedAt) + case "user_id": + q = q.Order(tbl.UserID) + } + orderApplied = true + } + if !orderApplied { + q = q.Order(tbl.ID.Desc()) } filter.Pagination.Format() @@ -185,24 +406,14 @@ func (s *super) ListTenants(ctx context.Context, filter *super_dto.TenantListFil return nil, errorx.ErrDatabaseError.WithCause(err) } - list, err := q.Offset(int(filter.Pagination.Offset())).Limit(int(filter.Pagination.Limit)).Order(tbl.ID.Desc()).Find() + list, err := q.Offset(int(filter.Pagination.Offset())).Limit(int(filter.Pagination.Limit)).Find() if err != nil { return nil, errorx.ErrDatabaseError.WithCause(err) } - var data []super_dto.TenantItem - for _, t := range list { - data = append(data, super_dto.TenantItem{ - ID: t.ID, - UUID: t.UUID.String(), - Name: t.Name, - Code: t.Code, - Status: t.Status, - StatusDescription: t.Status.Description(), - UserID: t.UserID, - CreatedAt: t.CreatedAt.Format(time.RFC3339), - UpdatedAt: t.UpdatedAt.Format(time.RFC3339), - }) + data, err := s.buildTenantItems(ctx, list) + if err != nil { + return nil, err } return &requests.Pager{ @@ -240,17 +451,14 @@ func (s *super) GetTenant(ctx context.Context, id int64) (*super_dto.TenantItem, } return nil, errorx.ErrDatabaseError.WithCause(err) } - return &super_dto.TenantItem{ - ID: t.ID, - UUID: t.UUID.String(), - Name: t.Name, - Code: t.Code, - Status: t.Status, - StatusDescription: t.Status.Description(), - UserID: t.UserID, - CreatedAt: t.CreatedAt.Format(time.RFC3339), - UpdatedAt: t.UpdatedAt.Format(time.RFC3339), - }, nil + items, err := s.buildTenantItems(ctx, []*models.Tenant{t}) + if err != nil { + return nil, err + } + if len(items) == 0 { + return nil, errorx.ErrRecordNotFound + } + return &items[0], nil } func (s *super) UpdateTenantStatus(ctx context.Context, id int64, form *super_dto.TenantStatusUpdateForm) error { @@ -272,6 +480,177 @@ func (s *super) UpdateTenantExpire(ctx context.Context, id int64, form *super_dt return nil } +func (s *super) ListTenantUsers(ctx context.Context, tenantID int64, filter *super_dto.SuperTenantUserListFilter) (*requests.Pager, error) { + tbl, q := models.TenantUserQuery.QueryContext(ctx) + q = q.Where(tbl.TenantID.Eq(tenantID)) + if filter.UserID != nil && *filter.UserID > 0 { + q = q.Where(tbl.UserID.Eq(*filter.UserID)) + } + if filter.Status != nil && *filter.Status != "" { + q = q.Where(tbl.Status.Eq(*filter.Status)) + } + if filter.Role != nil && *filter.Role != "" { + q = q.Where(tbl.Role.Contains(types.Array[consts.TenantUserRole]{*filter.Role})) + } + + userIDs, userFilter, err := s.lookupUserIDs(ctx, filter.Username) + if err != nil { + return nil, err + } + if userFilter { + if len(userIDs) == 0 { + q = q.Where(tbl.ID.Eq(-1)) + } else { + q = q.Where(tbl.UserID.In(userIDs...)) + } + } + + filter.Pagination.Format() + total, err := q.Count() + if err != nil { + return nil, errorx.ErrDatabaseError.WithCause(err) + } + list, err := q.Offset(int(filter.Pagination.Offset())).Limit(int(filter.Pagination.Limit)).Order(tbl.CreatedAt.Desc()).Find() + if err != nil { + return nil, errorx.ErrDatabaseError.WithCause(err) + } + + userMap, err := s.userMapByTenantUsers(ctx, list) + if err != nil { + return nil, err + } + + items := make([]super_dto.SuperTenantUserItem, 0, len(list)) + for _, tu := range list { + items = append(items, super_dto.SuperTenantUserItem{ + User: s.toSuperUserLite(userMap[tu.UserID]), + TenantUser: s.toSuperTenantUserDTO(tu), + }) + } + + return &requests.Pager{ + Pagination: filter.Pagination, + Total: total, + Items: items, + }, nil +} + +func (s *super) ListUserTenants(ctx context.Context, userID int64, filter *super_dto.SuperUserTenantListFilter) (*requests.Pager, error) { + tbl, q := models.TenantUserQuery.QueryContext(ctx) + q = q.Where(tbl.UserID.Eq(userID)) + + if filter.TenantID != nil && *filter.TenantID > 0 { + q = q.Where(tbl.TenantID.Eq(*filter.TenantID)) + } + if filter.Status != nil && *filter.Status != "" { + q = q.Where(tbl.Status.Eq(*filter.Status)) + } + if filter.Role != nil && *filter.Role != "" { + q = q.Where(tbl.Role.Contains(types.Array[consts.TenantUserRole]{*filter.Role})) + } + + tenantIDs, tenantFilter, err := s.lookupTenantIDs(ctx, filter.Code, filter.Name) + if err != nil { + return nil, err + } + if tenantFilter { + if len(tenantIDs) == 0 { + q = q.Where(tbl.ID.Eq(-1)) + } else { + q = q.Where(tbl.TenantID.In(tenantIDs...)) + } + } + + if filter.CreatedAtFrom != nil { + from, err := s.parseFilterTime(filter.CreatedAtFrom) + if err != nil { + return nil, err + } + if from != nil { + q = q.Where(tbl.CreatedAt.Gte(*from)) + } + } + if filter.CreatedAtTo != nil { + to, err := s.parseFilterTime(filter.CreatedAtTo) + if err != nil { + return nil, err + } + if to != nil { + q = q.Where(tbl.CreatedAt.Lte(*to)) + } + } + + orderApplied := false + if filter.Desc != nil && strings.TrimSpace(*filter.Desc) != "" { + switch strings.TrimSpace(*filter.Desc) { + case "tenant_id": + q = q.Order(tbl.TenantID.Desc()) + case "created_at": + q = q.Order(tbl.CreatedAt.Desc()) + } + orderApplied = true + } else if filter.Asc != nil && strings.TrimSpace(*filter.Asc) != "" { + switch strings.TrimSpace(*filter.Asc) { + case "tenant_id": + q = q.Order(tbl.TenantID) + case "created_at": + q = q.Order(tbl.CreatedAt) + } + orderApplied = true + } + if !orderApplied { + q = q.Order(tbl.CreatedAt.Desc()) + } + + filter.Pagination.Format() + total, err := q.Count() + if err != nil { + return nil, errorx.ErrDatabaseError.WithCause(err) + } + list, err := q.Offset(int(filter.Pagination.Offset())).Limit(int(filter.Pagination.Limit)).Find() + if err != nil { + return nil, errorx.ErrDatabaseError.WithCause(err) + } + + tenantMap, ownerMap, err := s.tenantMapsForTenantUsers(ctx, list) + if err != nil { + return nil, err + } + + items := make([]super_dto.UserTenantItem, 0, len(list)) + for _, tu := range list { + tenant := tenantMap[tu.TenantID] + owner := ownerMap[tu.TenantID] + item := super_dto.UserTenantItem{ + TenantID: tu.TenantID, + Role: tu.Role, + MemberStatus: tu.Status, + MemberStatusDescription: tu.Status.Description(), + JoinedAt: s.formatTime(tu.CreatedAt), + } + if tenant != nil { + item.TenantStatus = tenant.Status + item.TenantStatusDescription = tenant.Status.Description() + item.Name = tenant.Name + item.Code = tenant.Code + item.ExpiredAt = s.formatTime(tenant.ExpiredAt) + } + if owner != nil { + item.Owner = &super_dto.TenantOwnerUserLite{ + ID: owner.ID, + Username: owner.Username, + } + } + items = append(items, item) + } + + return &requests.Pager{ + Pagination: filter.Pagination, + Total: total, + Items: items, + }, nil +} + func (s *super) ListContents(ctx context.Context, filter *super_dto.SuperContentListFilter) (*requests.Pager, error) { tbl, q := models.ContentQuery.QueryContext(ctx) @@ -804,6 +1183,22 @@ func (s *super) toSuperUserDTO(u *models.User) *super_dto.User { } } +func (s *super) toSuperUserLite(u *models.User) *super_dto.SuperUserLite { + if u == nil { + return nil + } + return &super_dto.SuperUserLite{ + ID: u.ID, + Username: u.Username, + Roles: u.Roles, + Status: u.Status, + StatusDescription: u.Status.Description(), + VerifiedAt: s.formatTime(u.VerifiedAt), + CreatedAt: s.formatTime(u.CreatedAt), + UpdatedAt: s.formatTime(u.UpdatedAt), + } +} + func hasRole(roles types.Array[consts.Role], role consts.Role) bool { for _, r := range roles { if r == role { @@ -813,6 +1208,15 @@ func hasRole(roles types.Array[consts.Role], role consts.Role) bool { return false } +func hasTenantRole(roles types.Array[consts.TenantUserRole], role consts.TenantUserRole) bool { + for _, r := range roles { + if r == role { + return true + } + } + return false +} + func (s *super) buildSuperOrderItems(ctx context.Context, orders []*models.Order) ([]super_dto.SuperOrderItem, error) { if len(orders) == 0 { return []super_dto.SuperOrderItem{}, nil @@ -1212,6 +1616,139 @@ func (s *super) toSuperDiscountValue(price *models.ContentPrice) float64 { return float64(price.DiscountValue) } +func (s *super) buildTenantItems(ctx context.Context, list []*models.Tenant) ([]super_dto.TenantItem, error) { + if len(list) == 0 { + return []super_dto.TenantItem{}, nil + } + + tenantIDs := make([]int64, 0, len(list)) + tenantOwnerIDs := make(map[int64]struct{}, len(list)) + for _, t := range list { + tenantIDs = append(tenantIDs, t.ID) + tenantOwnerIDs[t.UserID] = struct{}{} + } + + // 统计租户成员数与管理员列表(基于 tenant_users)。 + userCountMap := make(map[int64]int64, len(list)) + adminSet := make(map[int64]map[int64]struct{}, len(list)) + tblTu, qTu := models.TenantUserQuery.QueryContext(ctx) + tenantUsers, err := qTu.Where(tblTu.TenantID.In(tenantIDs...)).Find() + if err != nil { + return nil, errorx.ErrDatabaseError.WithCause(err) + } + for _, tu := range tenantUsers { + userCountMap[tu.TenantID]++ + if hasTenantRole(tu.Role, consts.TenantUserRoleTenantAdmin) { + if _, ok := adminSet[tu.TenantID]; !ok { + adminSet[tu.TenantID] = make(map[int64]struct{}) + } + adminSet[tu.TenantID][tu.UserID] = struct{}{} + } + } + + adminIDs := make(map[int64]struct{}) + for _, ids := range adminSet { + for id := range ids { + adminIDs[id] = struct{}{} + } + } + + userIDs := make([]int64, 0, len(adminIDs)+len(tenantOwnerIDs)) + seen := make(map[int64]struct{}) + for id := range adminIDs { + seen[id] = struct{}{} + userIDs = append(userIDs, id) + } + for id := range tenantOwnerIDs { + if _, ok := seen[id]; ok { + continue + } + seen[id] = struct{}{} + userIDs = append(userIDs, id) + } + + userMap := make(map[int64]*models.User, len(userIDs)) + if len(userIDs) > 0 { + tblUser, qUser := models.UserQuery.QueryContext(ctx) + users, err := qUser.Where(tblUser.ID.In(userIDs...)).Find() + if err != nil { + return nil, errorx.ErrDatabaseError.WithCause(err) + } + for _, u := range users { + userMap[u.ID] = u + } + } + + // 汇总租户收入(按已支付/退款中/已退款订单统计实付金额)。 + incomeMap := make(map[int64]int64, len(list)) + if len(tenantIDs) > 0 { + var rows []struct { + TenantID int64 `gorm:"column:tenant_id"` + AmountPaidSum int64 `gorm:"column:amount_paid_sum"` + } + err := models.OrderQuery.WithContext(ctx). + UnderlyingDB(). + Model(&models.Order{}). + Select("tenant_id, coalesce(sum(amount_paid), 0) as amount_paid_sum"). + Where("tenant_id IN ?", tenantIDs). + Where("status IN ?", []consts.OrderStatus{ + consts.OrderStatusPaid, + consts.OrderStatusRefunding, + consts.OrderStatusRefunded, + }). + Group("tenant_id"). + Scan(&rows).Error + if err != nil { + return nil, errorx.ErrDatabaseError.WithCause(err) + } + for _, row := range rows { + incomeMap[row.TenantID] = row.AmountPaidSum + } + } + + items := make([]super_dto.TenantItem, 0, len(list)) + for _, t := range list { + item := super_dto.TenantItem{ + ID: t.ID, + UUID: t.UUID.String(), + Name: t.Name, + Code: t.Code, + Status: t.Status, + StatusDescription: t.Status.Description(), + UserID: t.UserID, + UserCount: userCountMap[t.ID], + IncomeAmountPaidSum: incomeMap[t.ID], + ExpiredAt: s.formatTime(t.ExpiredAt), + CreatedAt: s.formatTime(t.CreatedAt), + UpdatedAt: s.formatTime(t.UpdatedAt), + } + + if owner := userMap[t.UserID]; owner != nil { + item.Owner = &super_dto.TenantOwnerUserLite{ + ID: owner.ID, + Username: owner.Username, + } + } + + if adminSet[t.ID] != nil { + admins := make([]*super_dto.TenantAdminUserLite, 0, len(adminSet[t.ID])) + for adminID := range adminSet[t.ID] { + if u := userMap[adminID]; u != nil { + admins = append(admins, &super_dto.TenantAdminUserLite{ + ID: u.ID, + Username: u.Username, + }) + } + } + item.AdminUsers = admins + } + + items = append(items, item) + } + + return items, nil +} + func (s *super) ListWithdrawals(ctx context.Context, filter *super_dto.SuperOrderListFilter) (*requests.Pager, error) { tbl, q := models.OrderQuery.QueryContext(ctx) q = q.Where(tbl.Type.Eq(consts.OrderTypeWithdrawal)) @@ -1259,6 +1796,161 @@ func (s *super) ApproveWithdrawal(ctx context.Context, id int64) error { return err } +func (s *super) userOwnedTenantCount(ctx context.Context, userIDs []int64) (map[int64]int64, error) { + result := make(map[int64]int64, len(userIDs)) + if len(userIDs) == 0 { + return result, nil + } + var rows []struct { + UserID int64 `gorm:"column:user_id"` + Count int64 `gorm:"column:count"` + } + err := models.TenantQuery.WithContext(ctx). + UnderlyingDB(). + Model(&models.Tenant{}). + Select("user_id, count(*) as count"). + Where("user_id IN ?", userIDs). + Group("user_id"). + Scan(&rows).Error + if err != nil { + return nil, errorx.ErrDatabaseError.WithCause(err) + } + for _, row := range rows { + result[row.UserID] = row.Count + } + return result, nil +} + +func (s *super) userJoinedTenantCount(ctx context.Context, userIDs []int64) (map[int64]int64, error) { + result := make(map[int64]int64, len(userIDs)) + if len(userIDs) == 0 { + return result, nil + } + var rows []struct { + UserID int64 `gorm:"column:user_id"` + Count int64 `gorm:"column:count"` + } + err := models.TenantUserQuery.WithContext(ctx). + UnderlyingDB(). + Model(&models.TenantUser{}). + Select("user_id, count(*) as count"). + Where("user_id IN ?", userIDs). + Group("user_id"). + Scan(&rows).Error + if err != nil { + return nil, errorx.ErrDatabaseError.WithCause(err) + } + for _, row := range rows { + result[row.UserID] = row.Count + } + return result, nil +} + +func (s *super) userMapByTenantUsers(ctx context.Context, list []*models.TenantUser) (map[int64]*models.User, error) { + userIDs := make([]int64, 0, len(list)) + seen := make(map[int64]struct{}, len(list)) + for _, tu := range list { + if _, ok := seen[tu.UserID]; ok { + continue + } + seen[tu.UserID] = struct{}{} + userIDs = append(userIDs, tu.UserID) + } + userMap := make(map[int64]*models.User, len(userIDs)) + if len(userIDs) == 0 { + return userMap, nil + } + tblUser, qUser := models.UserQuery.QueryContext(ctx) + users, err := qUser.Where(tblUser.ID.In(userIDs...)).Find() + if err != nil { + return nil, errorx.ErrDatabaseError.WithCause(err) + } + for _, u := range users { + userMap[u.ID] = u + } + return userMap, nil +} + +func (s *super) toSuperTenantUserDTO(tu *models.TenantUser) *super_dto.TenantUser { + if tu == nil { + return nil + } + return &super_dto.TenantUser{ + ID: tu.ID, + TenantID: tu.TenantID, + UserID: tu.UserID, + Role: tu.Role, + Status: tu.Status, + CreatedAt: s.formatTime(tu.CreatedAt), + UpdatedAt: s.formatTime(tu.UpdatedAt), + } +} + +func (s *super) tenantMapsForTenantUsers(ctx context.Context, list []*models.TenantUser) (map[int64]*models.Tenant, map[int64]*models.User, error) { + tenantIDs := make([]int64, 0, len(list)) + seen := make(map[int64]struct{}, len(list)) + for _, tu := range list { + if _, ok := seen[tu.TenantID]; ok { + continue + } + seen[tu.TenantID] = struct{}{} + tenantIDs = append(tenantIDs, tu.TenantID) + } + + tenantMap := make(map[int64]*models.Tenant, len(tenantIDs)) + ownerMap := make(map[int64]*models.User, len(tenantIDs)) + if len(tenantIDs) == 0 { + return tenantMap, ownerMap, nil + } + + tblTenant, qTenant := models.TenantQuery.QueryContext(ctx) + tenants, err := qTenant.Where(tblTenant.ID.In(tenantIDs...)).Find() + if err != nil { + return nil, nil, errorx.ErrDatabaseError.WithCause(err) + } + + ownerIDs := make([]int64, 0, len(tenants)) + ownerSeen := make(map[int64]struct{}, len(tenants)) + for _, t := range tenants { + tenantMap[t.ID] = t + if _, ok := ownerSeen[t.UserID]; ok { + continue + } + ownerSeen[t.UserID] = struct{}{} + ownerIDs = append(ownerIDs, t.UserID) + } + + userMap := make(map[int64]*models.User, len(ownerIDs)) + if len(ownerIDs) > 0 { + tblUser, qUser := models.UserQuery.QueryContext(ctx) + users, err := qUser.Where(tblUser.ID.In(ownerIDs...)).Find() + if err != nil { + return nil, nil, errorx.ErrDatabaseError.WithCause(err) + } + for _, u := range users { + userMap[u.ID] = u + } + } + + for tenantID, tenant := range tenantMap { + if tenant == nil { + continue + } + if owner := userMap[tenant.UserID]; owner != nil { + ownerMap[tenantID] = owner + } + } + + return tenantMap, ownerMap, nil +} + +func (s *super) formatTime(t time.Time) string { + if t.IsZero() { + return "" + } + return t.Format(time.RFC3339) +} + func (s *super) RejectWithdrawal(ctx context.Context, id int64, reason string) error { err := models.Q.Transaction(func(tx *models.Query) error { o, err := tx.Order.WithContext(ctx).Where(tx.Order.ID.Eq(id)).First()