diff --git a/backend/app/http/super/dto/order.go b/backend/app/http/super/dto/order.go index 1c47c3e..69b7c04 100644 --- a/backend/app/http/super/dto/order.go +++ b/backend/app/http/super/dto/order.go @@ -11,7 +11,7 @@ type OrderStatisticsRow struct { } type OrderStatisticsResponse struct { - TotalCount int64 `json:"total_count"` - TotalAmountPaidSum int64 `json:"total_amount_paid_sum"` + TotalCount int64 `json:"total_count"` + TotalAmountPaidSum int64 `json:"total_amount_paid_sum"` ByStatus []*OrderStatisticsRow `json:"by_status"` } diff --git a/backend/app/http/super/dto/tenant.go b/backend/app/http/super/dto/tenant.go index a032c8b..f99a120 100644 --- a/backend/app/http/super/dto/tenant.go +++ b/backend/app/http/super/dto/tenant.go @@ -2,6 +2,7 @@ package dto import ( "errors" + "strings" "time" "quyun/v2/app/requests" @@ -10,19 +11,59 @@ import ( ) type TenantFilter struct { - requests.Pagination - requests.SortQueryFilter + // Pagination page/limit. + requests.Pagination `json:",inline" query:",inline"` + + // SortQueryFilter defines asc/desc ordering. + requests.SortQueryFilter `json:",inline" query:",inline"` Name *string `json:"name,omitempty" query:"name"` + Code *string `json:"code,omitempty" query:"code"` + ID *int64 `json:"id,omitempty" query:"id"` + UserID *int64 `json:"user_id,omitempty" query:"user_id"` Status *consts.TenantStatus `json:"status,omitempty" query:"status"` + + ExpiredAtFrom *time.Time `json:"expired_at_from,omitempty" query:"expired_at_from"` + ExpiredAtTo *time.Time `json:"expired_at_to,omitempty" query:"expired_at_to"` + + 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 *TenantFilter) NameTrimmed() string { + if f == nil || f.Name == nil { + return "" + } + return strings.TrimSpace(*f.Name) +} + +func (f *TenantFilter) CodeTrimmed() string { + if f == nil || f.Code == nil { + return "" + } + return strings.ToLower(strings.TrimSpace(*f.Code)) } type TenantItem struct { *models.Tenant - UserCount int64 `json:"user_count"` - UserBalance int64 `json:"user_balance"` - StatusDescription string `json:"status_description"` + UserCount int64 `json:"user_count"` + // IncomeAmountPaidSum 累计收入金额(单位:分,CNY):按 orders 聚合得到的已支付净收入(不含退款中/已退款订单)。 + IncomeAmountPaidSum int64 `json:"income_amount_paid_sum"` + StatusDescription string `json:"status_description"` + + Owner *TenantOwnerUserLite `json:"owner,omitempty"` + AdminUsers []*TenantAdminUserLite `json:"admin_users,omitempty"` +} + +type TenantOwnerUserLite struct { + ID int64 `json:"id"` + Username string `json:"username"` +} + +type TenantAdminUserLite struct { + ID int64 `json:"id"` + Username string `json:"username"` } type TenantCreateForm struct { diff --git a/backend/app/http/super/dto/tenant_user.go b/backend/app/http/super/dto/tenant_user.go new file mode 100644 index 0000000..6a3d52c --- /dev/null +++ b/backend/app/http/super/dto/tenant_user.go @@ -0,0 +1,26 @@ +package dto + +import ( + "time" + + "quyun/v2/database/models" + "quyun/v2/pkg/consts" + + "go.ipao.vip/gen/types" +) + +type SuperUserLite struct { + ID int64 `json:"id"` + Username string `json:"username"` + Status consts.UserStatus `json:"status"` + Roles types.Array[consts.Role] `json:"roles"` + VerifiedAt time.Time `json:"verified_at"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + StatusDescription string `json:"status_description,omitempty"` +} + +type SuperTenantUserItem struct { + TenantUser *models.TenantUser `json:"tenant_user,omitempty"` + User *SuperUserLite `json:"user,omitempty"` +} diff --git a/backend/app/http/super/order.go b/backend/app/http/super/order.go index f4163ae..2c3bd3b 100644 --- a/backend/app/http/super/order.go +++ b/backend/app/http/super/order.go @@ -22,4 +22,3 @@ type order struct{} func (*order) statistics(ctx fiber.Ctx) (*dto.OrderStatisticsResponse, error) { return services.Order.SuperStatistics(ctx) } - diff --git a/backend/app/http/super/routes.gen.go b/backend/app/http/super/routes.gen.go index b83c282..470d049 100644 --- a/backend/app/http/super/routes.gen.go +++ b/backend/app/http/super/routes.gen.go @@ -6,6 +6,7 @@ package super import ( "quyun/v2/app/http/super/dto" + tenantdto "quyun/v2/app/http/tenant/dto" "quyun/v2/app/middlewares" "github.com/gofiber/fiber/v3" @@ -70,6 +71,12 @@ func (r *Routes) Register(router fiber.Router) { r.tenant.create, Body[dto.TenantCreateForm]("form"), )) + r.log.Debugf("Registering route: Get /super/v1/tenants/:tenantID/users -> tenant.users") + router.Get("/super/v1/tenants/:tenantID/users"[len(r.Path()):], DataFunc2( + r.tenant.users, + PathParam[int64]("tenantID"), + Query[tenantdto.AdminTenantUserListFilter]("filter"), + )) r.log.Debugf("Registering route: Get /super/v1/tenants/statuses -> tenant.statusList") router.Get("/super/v1/tenants/statuses"[len(r.Path()):], DataFunc0( r.tenant.statusList, diff --git a/backend/app/http/super/tenant.go b/backend/app/http/super/tenant.go index d4ba54c..fde1b0e 100644 --- a/backend/app/http/super/tenant.go +++ b/backend/app/http/super/tenant.go @@ -3,6 +3,7 @@ package super import ( "quyun/v2/app/errorx" "quyun/v2/app/http/super/dto" + tenantdto "quyun/v2/app/http/tenant/dto" "quyun/v2/app/requests" "quyun/v2/app/services" "quyun/v2/database/models" @@ -44,6 +45,23 @@ func (*tenant) create(ctx fiber.Ctx, form *dto.TenantCreateForm) (*models.Tenant return services.Tenant.SuperCreateTenant(ctx, form) } +// users +// +// @Summary 租户成员列表(平台侧) +// @Tags Super +// @Accept json +// @Produce json +// @Param tenantID path int64 true "TenantID" +// @Param filter query tenantdto.AdminTenantUserListFilter true "Filter" +// @Success 200 {object} requests.Pager{items=dto.SuperTenantUserItem} +// +// @Router /super/v1/tenants/:tenantID/users [get] +// @Bind tenantID path +// @Bind filter query +func (*tenant) users(ctx fiber.Ctx, tenantID int64, filter *tenantdto.AdminTenantUserListFilter) (*requests.Pager, error) { + return services.Tenant.SuperTenantUsersPage(ctx, tenantID, filter) +} + // updateExpire // // @Summary 更新过期时间 diff --git a/backend/app/services/tenant.go b/backend/app/services/tenant.go index 635fce4..c0bd841 100644 --- a/backend/app/services/tenant.go +++ b/backend/app/services/tenant.go @@ -5,7 +5,7 @@ import ( "strings" "time" - "quyun/v2/app/http/super/dto" + superdto "quyun/v2/app/http/super/dto" tenantdto "quyun/v2/app/http/tenant/dto" "quyun/v2/app/requests" "quyun/v2/database" @@ -16,6 +16,7 @@ import ( "github.com/samber/lo" "github.com/sirupsen/logrus" "go.ipao.vip/gen" + "go.ipao.vip/gen/field" "go.ipao.vip/gen/types" "gorm.io/gorm" ) @@ -26,7 +27,7 @@ import ( type tenant struct{} // SuperCreateTenant 超级管理员创建租户,并将指定用户设为租户管理员。 -func (t *tenant) SuperCreateTenant(ctx context.Context, form *dto.TenantCreateForm) (*models.Tenant, error) { +func (t *tenant) SuperCreateTenant(ctx context.Context, form *superdto.TenantCreateForm) (*models.Tenant, error) { if form == nil { return nil, errors.New("form is nil") } @@ -42,7 +43,7 @@ func (t *tenant) SuperCreateTenant(ctx context.Context, form *dto.TenantCreateFo if form.AdminUserID <= 0 { return nil, errors.New("admin_user_id must be > 0") } - duration, err := (&dto.TenantExpireUpdateForm{Duration: form.Duration}).ParseDuration() + duration, err := (&superdto.TenantExpireUpdateForm{Duration: form.Duration}).ParseDuration() if err != nil { return nil, err } @@ -164,6 +165,95 @@ func (t *tenant) AdminTenantUsersPage(ctx context.Context, tenantID int64, filte }, nil } +// SuperTenantUsersPage 超级管理员分页查询租户成员(脱敏 user 字段,避免泄露 password)。 +func (t *tenant) SuperTenantUsersPage(ctx context.Context, tenantID int64, filter *tenantdto.AdminTenantUserListFilter) (*requests.Pager, error) { + if tenantID <= 0 { + return nil, errors.New("tenant_id must be > 0") + } + if filter == nil { + filter = &tenantdto.AdminTenantUserListFilter{} + } + + filter.Pagination.Format() + + tbl, query := models.TenantUserQuery.QueryContext(ctx) + conds := []gen.Condition{tbl.TenantID.Eq(tenantID)} + if filter.UserID != nil && *filter.UserID > 0 { + conds = append(conds, tbl.UserID.Eq(*filter.UserID)) + } + if filter.Role != nil && *filter.Role != "" { + conds = append(conds, tbl.Role.Contains(types.NewArray([]consts.TenantUserRole{*filter.Role}))) + } + if filter.Status != nil && *filter.Status != "" { + conds = append(conds, tbl.Status.Eq(*filter.Status)) + } + if username := filter.UsernameTrimmed(); username != "" { + uTbl, _ := models.UserQuery.QueryContext(ctx) + query = query.LeftJoin(uTbl, uTbl.ID.EqCol(tbl.UserID)) + conds = append(conds, uTbl.Username.Like(database.WrapLike(username))) + } + + items, total, err := query.Where(conds...).Order(tbl.ID.Desc()).FindByPage(int(filter.Offset()), int(filter.Limit)) + if err != nil { + return nil, err + } + + userIDs := make([]int64, 0, len(items)) + for _, tu := range items { + if tu == nil { + continue + } + userIDs = append(userIDs, tu.UserID) + } + + var users []*models.User + if len(userIDs) > 0 { + uTbl, uQuery := models.UserQuery.QueryContext(ctx) + users, err = uQuery.Where(uTbl.ID.In(userIDs...)).Find() + if err != nil { + return nil, err + } + } + userMap := make(map[int64]*models.User, len(users)) + for _, u := range users { + if u == nil { + continue + } + userMap[u.ID] = u + } + + out := make([]*superdto.SuperTenantUserItem, 0, len(items)) + for _, tu := range items { + if tu == nil { + continue + } + u := userMap[tu.UserID] + var lite *superdto.SuperUserLite + if u != nil { + lite = &superdto.SuperUserLite{ + ID: u.ID, + Username: u.Username, + Status: u.Status, + StatusDescription: u.Status.Description(), + Roles: u.Roles, + VerifiedAt: u.VerifiedAt, + CreatedAt: u.CreatedAt, + UpdatedAt: u.UpdatedAt, + } + } + out = append(out, &superdto.SuperTenantUserItem{ + TenantUser: tu, + User: lite, + }) + } + + return &requests.Pager{ + Pagination: filter.Pagination, + Total: total, + Items: out, + }, nil +} + func (t *tenant) ContainsUserID(ctx context.Context, tenantID, userID int64) (*models.User, error) { tbl, query := models.TenantUserQuery.QueryContext(ctx) @@ -242,12 +332,28 @@ func (t *tenant) SetUserRole(ctx context.Context, tenantID, userID int64, role . } // Pager -func (t *tenant) Pager(ctx context.Context, filter *dto.TenantFilter) (*requests.Pager, error) { +func (t *tenant) Pager(ctx context.Context, filter *superdto.TenantFilter) (*requests.Pager, error) { tbl, query := models.TenantQuery.QueryContext(ctx) conds := []gen.Condition{} - if filter.Name != nil { - conds = append(conds, tbl.Name.Like(database.WrapLike(*filter.Name))) + if filter == nil { + filter = &superdto.TenantFilter{} + } + + if filter.ID != nil && *filter.ID > 0 { + conds = append(conds, tbl.ID.Eq(*filter.ID)) + } + if filter.UserID != nil && *filter.UserID > 0 { + conds = append(conds, tbl.UserID.Eq(*filter.UserID)) + } + + if name := filter.NameTrimmed(); name != "" { + conds = append(conds, tbl.Name.Like(database.WrapLike(name))) + } + + if code := filter.CodeTrimmed(); code != "" { + // code 在库内按约定存储为 lower-case;这里统一转小写后做 like。 + conds = append(conds, tbl.Code.Like(database.WrapLike(code))) } if filter.Status != nil { @@ -255,7 +361,65 @@ func (t *tenant) Pager(ctx context.Context, filter *dto.TenantFilter) (*requests } filter.Pagination.Format() - mm, total, err := query.Where(conds...).Order(tbl.ID.Desc()).FindByPage(int(filter.Offset()), int(filter.Limit)) + + if filter.ExpiredAtFrom != nil { + conds = append(conds, tbl.ExpiredAt.Gte(*filter.ExpiredAtFrom)) + } + if filter.ExpiredAtTo != nil { + conds = append(conds, tbl.ExpiredAt.Lte(*filter.ExpiredAtTo)) + } + if filter.CreatedAtFrom != nil { + conds = append(conds, tbl.CreatedAt.Gte(*filter.CreatedAtFrom)) + } + if filter.CreatedAtTo != nil { + conds = append(conds, tbl.CreatedAt.Lte(*filter.CreatedAtTo)) + } + + // 排序白名单:避免把任意字符串拼进 SQL 导致注入或慢查询。 + orderBys := make([]field.Expr, 0, 6) + allowedAsc := map[string]field.Expr{ + "id": tbl.ID.Asc(), + "code": tbl.Code.Asc(), + "name": tbl.Name.Asc(), + "status": tbl.Status.Asc(), + "expired_at": tbl.ExpiredAt.Asc(), + "created_at": tbl.CreatedAt.Asc(), + "updated_at": tbl.UpdatedAt.Asc(), + } + allowedDesc := map[string]field.Expr{ + "id": tbl.ID.Desc(), + "code": tbl.Code.Desc(), + "name": tbl.Name.Desc(), + "status": tbl.Status.Desc(), + "expired_at": tbl.ExpiredAt.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()) + } + + mm, total, err := query.Where(conds...).Order(orderBys...).FindByPage(int(filter.Offset()), int(filter.Limit)) if err != nil { return nil, err } @@ -267,20 +431,42 @@ func (t *tenant) Pager(ctx context.Context, filter *dto.TenantFilter) (*requests return nil, err } - userBalanceMapping, err := t.TenantUserBalanceMapping(ctx, tenantIds) + incomeMapping, err := t.TenantIncomePaidMapping(ctx, tenantIds) if err != nil { return nil, err } - items := lo.Map(mm, func(model *models.Tenant, _ int) *dto.TenantItem { - return &dto.TenantItem{ - Tenant: model, - UserCount: lo.ValueOr(userCountMapping, model.ID, 0), - UserBalance: lo.ValueOr(userBalanceMapping, model.ID, 0), - StatusDescription: model.Status.Description(), + items := lo.Map(mm, func(model *models.Tenant, _ int) *superdto.TenantItem { + return &superdto.TenantItem{ + Tenant: model, + UserCount: lo.ValueOr(userCountMapping, model.ID, 0), + IncomeAmountPaidSum: lo.ValueOr(incomeMapping, model.ID, 0), + StatusDescription: model.Status.Description(), } }) + ownerMapping, err := t.TenantOwnerUserMapping(ctx, mm) + if err != nil { + return nil, err + } + for _, it := range items { + if it == nil || it.Tenant == nil { + continue + } + it.Owner = ownerMapping[it.Tenant.ID] + } + + adminUsersMapping, err := t.TenantAdminUsersMapping(ctx, tenantIds) + if err != nil { + return nil, err + } + for _, it := range items { + if it == nil || it.Tenant == nil { + continue + } + it.AdminUsers = adminUsersMapping[it.Tenant.ID] + } + return &requests.Pager{ Pagination: filter.Pagination, Total: total, @@ -288,6 +474,124 @@ func (t *tenant) Pager(ctx context.Context, filter *dto.TenantFilter) (*requests }, nil } +func (t *tenant) TenantOwnerUserMapping(ctx context.Context, tenants []*models.Tenant) (map[int64]*superdto.TenantOwnerUserLite, error) { + result := make(map[int64]*superdto.TenantOwnerUserLite, len(tenants)) + + userIDs := make([]int64, 0, len(tenants)) + tenantIDs := make([]int64, 0, len(tenants)) + for _, te := range tenants { + if te == nil || te.ID <= 0 { + continue + } + tenantIDs = append(tenantIDs, te.ID) + if te.UserID > 0 { + userIDs = append(userIDs, te.UserID) + } + } + for _, tenantID := range tenantIDs { + result[tenantID] = nil + } + userIDs = lo.Uniq(userIDs) + if len(userIDs) == 0 { + return result, nil + } + + uTbl, uQuery := models.UserQuery.QueryContext(ctx) + users, err := uQuery.Where(uTbl.ID.In(userIDs...)).Find() + if err != nil { + return nil, err + } + userMap := make(map[int64]*models.User, len(users)) + for _, u := range users { + if u == nil { + continue + } + userMap[u.ID] = u + } + + for _, te := range tenants { + if te == nil || te.ID <= 0 || te.UserID <= 0 { + continue + } + u := userMap[te.UserID] + if u == nil { + continue + } + result[te.ID] = &superdto.TenantOwnerUserLite{ + ID: u.ID, + Username: u.Username, + } + } + + return result, nil +} + +// TenantAdminUsersMapping 返回每个租户的管理员用户(用于 superadmin 租户列表展示)。 +func (t *tenant) TenantAdminUsersMapping(ctx context.Context, tenantIDs []int64) (map[int64][]*superdto.TenantAdminUserLite, error) { + result := make(map[int64][]*superdto.TenantAdminUserLite, len(tenantIDs)) + for _, id := range tenantIDs { + if id <= 0 { + continue + } + result[id] = nil + } + if len(result) == 0 { + return result, nil + } + + tuTbl, tuQuery := models.TenantUserQuery.QueryContext(ctx) + tus, err := tuQuery.Where( + tuTbl.TenantID.In(tenantIDs...), + tuTbl.Role.Contains(types.NewArray([]consts.TenantUserRole{consts.TenantUserRoleTenantAdmin})), + ).Find() + if err != nil { + return nil, err + } + + userIDs := make([]int64, 0, len(tus)) + type pair struct { + tenantID int64 + userID int64 + } + pairs := make([]pair, 0, len(tus)) + for _, tu := range tus { + if tu == nil || tu.TenantID <= 0 || tu.UserID <= 0 { + continue + } + userIDs = append(userIDs, tu.UserID) + pairs = append(pairs, pair{tenantID: tu.TenantID, userID: tu.UserID}) + } + userIDs = lo.Uniq(userIDs) + + userMap := map[int64]*models.User{} + if len(userIDs) > 0 { + uTbl, uQuery := models.UserQuery.QueryContext(ctx) + users, err := uQuery.Where(uTbl.ID.In(userIDs...)).Find() + if err != nil { + return nil, err + } + for _, u := range users { + if u == nil { + continue + } + userMap[u.ID] = u + } + } + + for _, p := range pairs { + u := userMap[p.userID] + if u == nil { + continue + } + result[p.tenantID] = append(result[p.tenantID], &superdto.TenantAdminUserLite{ + ID: u.ID, + Username: u.Username, + }) + } + + return result, nil +} + func (t *tenant) TenantUserCountMapping(ctx context.Context, tenantIds []int64) (map[int64]int64, error) { // 关键语义:返回值必须包含入参中的所有 tenant_id。 // 即便该租户当前没有成员,也应返回 count=0,便于调用方直接取值而无需额外补全逻辑。 @@ -368,6 +672,41 @@ func (t *tenant) TenantUserBalanceMapping(ctx context.Context, tenantIds []int64 return result, nil } +// TenantIncomePaidMapping 按租户维度统计“已支付订单”的累计收入(单位:分,CNY)。 +// 说明: +// - 仅统计 orders.status = paid 的订单金额; +// - refunding/refunded 不计入收入(避免把已退/退款中的金额当作收入)。 +func (t *tenant) TenantIncomePaidMapping(ctx context.Context, tenantIDs []int64) (map[int64]int64, error) { + result := make(map[int64]int64, len(tenantIDs)) + for _, id := range tenantIDs { + if id <= 0 { + continue + } + result[id] = 0 + } + if len(result) == 0 { + return result, nil + } + + oTbl, oQuery := models.OrderQuery.QueryContext(ctx) + var rows []struct { + TenantID int64 + Income int64 + } + err := oQuery. + Select(oTbl.TenantID, oTbl.AmountPaid.Sum().As("income")). + Where(oTbl.TenantID.In(tenantIDs...), oTbl.Status.Eq(consts.OrderStatusPaid)). + Group(oTbl.TenantID). + Scan(&rows) + if err != nil { + return nil, err + } + for _, row := range rows { + result[row.TenantID] = row.Income + } + return result, nil +} + // FindByID func (t *tenant) FindByID(ctx context.Context, id int64) (*models.Tenant, error) { tbl, query := models.TenantQuery.QueryContext(ctx) diff --git a/frontend/superadmin/dist/index.html b/frontend/superadmin/dist/index.html index 47a50cc..0ab5f2b 100644 --- a/frontend/superadmin/dist/index.html +++ b/frontend/superadmin/dist/index.html @@ -7,8 +7,8 @@