diff --git a/backend/app/http/super/dto/order.go b/backend/app/http/super/dto/order.go new file mode 100644 index 0000000..1c47c3e --- /dev/null +++ b/backend/app/http/super/dto/order.go @@ -0,0 +1,17 @@ +package dto + +import "quyun/v2/pkg/consts" + +type OrderStatisticsRow struct { + Status consts.OrderStatus `json:"status"` + StatusDescription string `json:"status_description"` + + Count int64 `json:"count"` + AmountPaidSum int64 `json:"amount_paid_sum"` +} + +type OrderStatisticsResponse struct { + 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 1122c70..a032c8b 100644 --- a/backend/app/http/super/dto/tenant.go +++ b/backend/app/http/super/dto/tenant.go @@ -25,6 +25,14 @@ type TenantItem struct { StatusDescription string `json:"status_description"` } +type TenantCreateForm struct { + Code string `json:"code" validate:"required,max=64"` + Name string `json:"name" validate:"required,max=128"` + AdminUserID int64 `json:"admin_user_id" validate:"required,gt=0"` + // Duration 租户有效期(天),从“创建时刻”起算;与续期接口保持一致。 + Duration int `json:"duration" validate:"required,oneof=7 30 90 180 365"` +} + type TenantExpireUpdateForm struct { Duration int `json:"duration" validate:"required,oneof=7 30 90 180 365"` } diff --git a/backend/app/http/super/order.go b/backend/app/http/super/order.go new file mode 100644 index 0000000..f4163ae --- /dev/null +++ b/backend/app/http/super/order.go @@ -0,0 +1,25 @@ +package super + +import ( + "quyun/v2/app/http/super/dto" + "quyun/v2/app/services" + + "github.com/gofiber/fiber/v3" +) + +// @provider +type order struct{} + +// statistics +// +// @Summary 订单统计信息 +// @Tags Super +// @Accept json +// @Produce json +// @Success 200 {object} dto.OrderStatisticsResponse +// +// @Router /super/v1/orders/statistics [get] +func (*order) statistics(ctx fiber.Ctx) (*dto.OrderStatisticsResponse, error) { + return services.Order.SuperStatistics(ctx) +} + diff --git a/backend/app/http/super/provider.gen.go b/backend/app/http/super/provider.gen.go index af27057..d11e6cd 100755 --- a/backend/app/http/super/provider.gen.go +++ b/backend/app/http/super/provider.gen.go @@ -28,12 +28,14 @@ func Provide(opts ...opt.Option) error { if err := container.Container.Provide(func( auth *auth, middlewares *middlewares.Middlewares, + order *order, tenant *tenant, user *user, ) (contracts.HttpRoute, error) { obj := &Routes{ auth: auth, middlewares: middlewares, + order: order, tenant: tenant, user: user, } @@ -52,6 +54,13 @@ func Provide(opts ...opt.Option) error { }); err != nil { return err } + if err := container.Container.Provide(func() (*order, error) { + obj := &order{} + + return obj, nil + }); err != nil { + return err + } if err := container.Container.Provide(func() (*tenant, error) { obj := &tenant{} diff --git a/backend/app/http/super/routes.gen.go b/backend/app/http/super/routes.gen.go index a42ed14..b83c282 100644 --- a/backend/app/http/super/routes.gen.go +++ b/backend/app/http/super/routes.gen.go @@ -24,6 +24,7 @@ type Routes struct { middlewares *middlewares.Middlewares // Controller instances auth *auth + order *order tenant *tenant user *user } @@ -53,12 +54,22 @@ func (r *Routes) Register(router fiber.Router) { r.auth.login, Body[dto.LoginForm]("form"), )) + // Register routes for controller: order + r.log.Debugf("Registering route: Get /super/v1/orders/statistics -> order.statistics") + router.Get("/super/v1/orders/statistics"[len(r.Path()):], DataFunc0( + r.order.statistics, + )) // Register routes for controller: tenant r.log.Debugf("Registering route: Get /super/v1/tenants -> tenant.list") router.Get("/super/v1/tenants"[len(r.Path()):], DataFunc1( r.tenant.list, Query[dto.TenantFilter]("filter"), )) + r.log.Debugf("Registering route: Post /super/v1/tenants -> tenant.create") + router.Post("/super/v1/tenants"[len(r.Path()):], DataFunc1( + r.tenant.create, + Body[dto.TenantCreateForm]("form"), + )) 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 54be804..d4ba54c 100644 --- a/backend/app/http/super/tenant.go +++ b/backend/app/http/super/tenant.go @@ -5,6 +5,7 @@ import ( "quyun/v2/app/http/super/dto" "quyun/v2/app/requests" "quyun/v2/app/services" + "quyun/v2/database/models" "quyun/v2/pkg/consts" "github.com/gofiber/fiber/v3" @@ -28,6 +29,21 @@ func (*tenant) list(ctx fiber.Ctx, filter *dto.TenantFilter) (*requests.Pager, e return services.Tenant.Pager(ctx, filter) } +// create +// +// @Summary 创建租户并设置租户管理员 +// @Tags Super +// @Accept json +// @Produce json +// @Param form body dto.TenantCreateForm true "Form" +// @Success 200 {object} models.Tenant +// +// @Router /super/v1/tenants [post] +// @Bind form body +func (*tenant) create(ctx fiber.Ctx, form *dto.TenantCreateForm) (*models.Tenant, error) { + return services.Tenant.SuperCreateTenant(ctx, form) +} + // updateExpire // // @Summary 更新过期时间 diff --git a/backend/app/services/order.go b/backend/app/services/order.go index 89bfc02..2131f48 100644 --- a/backend/app/services/order.go +++ b/backend/app/services/order.go @@ -11,6 +11,7 @@ import ( "time" "quyun/v2/app/errorx" + superdto "quyun/v2/app/http/super/dto" "quyun/v2/app/http/tenant/dto" jobs_args "quyun/v2/app/jobs/args" "quyun/v2/app/requests" @@ -233,6 +234,38 @@ type order struct { job *provider_job.Job } +// SuperStatistics 平台侧订单统计(不限定 tenant_id)。 +func (s *order) SuperStatistics(ctx context.Context) (*superdto.OrderStatisticsResponse, error) { + tbl, query := models.OrderQuery.QueryContext(ctx) + + var rows []*superdto.OrderStatisticsRow + err := query.Select( + tbl.Status, + tbl.ID.Count().As("count"), + tbl.AmountPaid.Sum().As("amount_paid_sum"), + ).Group(tbl.Status).Scan(&rows) + if err != nil { + return nil, err + } + + var totalCount int64 + var totalAmountPaidSum int64 + for _, row := range rows { + if row == nil { + continue + } + row.StatusDescription = row.Status.Description() + totalCount += row.Count + totalAmountPaidSum += row.AmountPaidSum + } + + return &superdto.OrderStatisticsResponse{ + TotalCount: totalCount, + TotalAmountPaidSum: totalAmountPaidSum, + ByStatus: rows, + }, nil +} + type ProcessRefundingOrderParams struct { // TenantID 租户ID。 TenantID int64 diff --git a/backend/app/services/tenant.go b/backend/app/services/tenant.go index 60d1c90..635fce4 100644 --- a/backend/app/services/tenant.go +++ b/backend/app/services/tenant.go @@ -25,6 +25,69 @@ import ( // @provider type tenant struct{} +// SuperCreateTenant 超级管理员创建租户,并将指定用户设为租户管理员。 +func (t *tenant) SuperCreateTenant(ctx context.Context, form *dto.TenantCreateForm) (*models.Tenant, error) { + if form == nil { + return nil, errors.New("form is nil") + } + + code := strings.ToLower(strings.TrimSpace(form.Code)) + if code == "" { + return nil, errors.New("code is empty") + } + name := strings.TrimSpace(form.Name) + if name == "" { + return nil, errors.New("name is empty") + } + if form.AdminUserID <= 0 { + return nil, errors.New("admin_user_id must be > 0") + } + duration, err := (&dto.TenantExpireUpdateForm{Duration: form.Duration}).ParseDuration() + if err != nil { + return nil, err + } + + // 确保管理员用户存在(同时可提前暴露“用户不存在”的错误,而不是等到外键/逻辑报错)。 + if _, err := User.FindByID(ctx, form.AdminUserID); err != nil { + return nil, err + } + + now := time.Now().UTC() + tenant := &models.Tenant{ + UserID: form.AdminUserID, + Code: code, + UUID: types.NewUUIDv4(), + Name: name, + Status: consts.TenantStatusVerified, + Config: types.JSON([]byte(`{}`)), + ExpiredAt: now.Add(duration), + } + + db := _db.WithContext(ctx) + err = db.Transaction(func(tx *gorm.DB) error { + if err := tx.Create(tenant).Error; err != nil { + return err + } + + tenantUser := &models.TenantUser{ + TenantID: tenant.ID, + UserID: form.AdminUserID, + Role: types.NewArray([]consts.TenantUserRole{consts.TenantUserRoleTenantAdmin}), + Status: consts.UserStatusVerified, + } + if err := tx.Create(tenantUser).Error; err != nil { + return err + } + + return tx.First(tenant, tenant.ID).Error + }) + if err != nil { + return nil, err + } + + return tenant, nil +} + // AdminTenantUsersPage 租户管理员分页查询成员列表(包含用户基础信息)。 func (t *tenant) AdminTenantUsersPage(ctx context.Context, tenantID int64, filter *tenantdto.AdminTenantUserListFilter) (*requests.Pager, error) { if tenantID <= 0 { diff --git a/frontend/superadmin/SUPERADMIN_PAGES.md b/frontend/superadmin/SUPERADMIN_PAGES.md index f1f1e2f..0afce17 100644 --- a/frontend/superadmin/SUPERADMIN_PAGES.md +++ b/frontend/superadmin/SUPERADMIN_PAGES.md @@ -49,6 +49,10 @@ 核心对象:`dto.TenantItem` +- 创建租户并关联租户管理员(新增) + - 表单字段:`code`、`name`、`admin_user_id`、`duration(7/30/90/180/365 天)` + - API:`POST /super/v1/tenants`(`dto.TenantCreateForm`) + - 行为:创建 `tenants` 记录,并创建 `tenant_users` 关系,角色为 `tenant_admin` - 租户列表(筛选/分页/排序) - 过滤:`name`、`status` - 排序:`asc/desc` @@ -87,4 +91,3 @@ - **swagger 不一致**:`dto.TenantStatusUpdateForm.status` / `dto.UserStatusUpdateForm.status` 在 swagger 里额外出现 `normal/disabled` enum,但 `consts.*Status` 与列表筛选 enum 为 `pending_verify/verified/banned`;前端应以 `/statuses` 接口返回为准,并推动后端修正 swagger。 - **分页 items 结构疑似不完整**:列表接口 swagger 中 `items` 被标成单个 object(`$ref dto.TenantItem`/`dto.UserItem`),实际应为数组;当前前端服务层已做兼容(`normalizeItems`),但建议后端修正 swagger。 - **租户/用户详情与更多运维能力缺失**:目前没有用户详情、租户详情、角色管理、密码重置等超管常见能力;如需要可扩展接口与页面。 - diff --git a/frontend/superadmin/dist/index.html b/frontend/superadmin/dist/index.html index d4677fc..47a50cc 100644 --- a/frontend/superadmin/dist/index.html +++ b/frontend/superadmin/dist/index.html @@ -7,7 +7,7 @@