diff --git a/backend/app/http/super/auth.go b/backend/app/http/super/auth.go index dd63c8a..3423be6 100644 --- a/backend/app/http/super/auth.go +++ b/backend/app/http/super/auth.go @@ -60,8 +60,13 @@ func (ctl *auth) login(ctx fiber.Ctx, form *dto.LoginForm) (*dto.LoginResponse, // // @Router /super/v1/auth/token [get] func (ctl *auth) token(ctx fiber.Ctx) (*dto.LoginResponse, error) { + claims, ok := ctx.Locals(consts.CtxKeyClaims).(*jwt.Claims) + if !ok || claims == nil || claims.UserID <= 0 { + return nil, errorx.ErrTokenInvalid + } + token, err := ctl.jwt.CreateToken(ctl.jwt.CreateClaims(jwt.BaseClaims{ - UserID: 2, + UserID: claims.UserID, })) if err != nil { return nil, errorx.Wrap(err).WithMsg("登录凭证生成失败") diff --git a/backend/app/http/super/dto/order_detail.go b/backend/app/http/super/dto/order_detail.go new file mode 100644 index 0000000..395d31d --- /dev/null +++ b/backend/app/http/super/dto/order_detail.go @@ -0,0 +1,10 @@ +package dto + +import "quyun/v2/database/models" + +type SuperOrderDetail struct { + Order *models.Order `json:"order,omitempty"` + + Tenant *OrderTenantLite `json:"tenant,omitempty"` + Buyer *OrderBuyerLite `json:"buyer,omitempty"` +} diff --git a/backend/app/http/super/dto/order_refund.go b/backend/app/http/super/dto/order_refund.go new file mode 100644 index 0000000..a8cb36c --- /dev/null +++ b/backend/app/http/super/dto/order_refund.go @@ -0,0 +1,10 @@ +package dto + +type SuperOrderRefundForm struct { + // Force indicates bypassing the default refund window check (paid_at + 24h). + Force bool `json:"force,omitempty"` + // Reason is the human-readable refund reason used for audit. + Reason string `json:"reason,omitempty"` + // IdempotencyKey ensures refund request is processed at most once. + IdempotencyKey string `json:"idempotency_key,omitempty"` +} diff --git a/backend/app/http/super/dto/user_roles.go b/backend/app/http/super/dto/user_roles.go new file mode 100644 index 0000000..2edea28 --- /dev/null +++ b/backend/app/http/super/dto/user_roles.go @@ -0,0 +1,7 @@ +package dto + +import "quyun/v2/pkg/consts" + +type UserRolesUpdateForm struct { + Roles []consts.Role `json:"roles" validate:"required,min=1,dive,oneof=user super_admin"` +} diff --git a/backend/app/http/super/order.go b/backend/app/http/super/order.go index 14762c1..8982646 100644 --- a/backend/app/http/super/order.go +++ b/backend/app/http/super/order.go @@ -1,9 +1,15 @@ package super import ( + "time" + + "quyun/v2/app/errorx" "quyun/v2/app/http/super/dto" "quyun/v2/app/requests" "quyun/v2/app/services" + "quyun/v2/database/models" + "quyun/v2/pkg/consts" + "quyun/v2/providers/jwt" "github.com/gofiber/fiber/v3" ) @@ -26,6 +32,56 @@ func (*order) list(ctx fiber.Ctx, filter *dto.OrderPageFilter) (*requests.Pager, return services.Order.SuperOrderPage(ctx, filter) } +// detail +// +// @Summary 订单详情 +// @Tags Super +// @Accept json +// @Produce json +// @Param orderID path int64 true "OrderID" +// @Success 200 {object} dto.SuperOrderDetail +// +// @Router /super/v1/orders/:orderID [get] +// @Bind orderID path +func (*order) detail(ctx fiber.Ctx, orderID int64) (*dto.SuperOrderDetail, error) { + return services.Order.SuperOrderDetail(ctx, orderID) +} + +// refund +// +// @Summary 订单退款(平台) +// @Description 该接口只负责将订单从 paid 推进到 refunding,并提交异步退款任务;退款入账与权益回收由 worker 异步完成。 +// @Tags Super +// @Accept json +// @Produce json +// @Param orderID path int64 true "OrderID" +// @Param form body dto.SuperOrderRefundForm true "Form" +// @Success 200 {object} models.Order +// +// @Router /super/v1/orders/:orderID/refund [post] +// @Bind orderID path +// @Bind form body +func (*order) refund(ctx fiber.Ctx, orderID int64, form *dto.SuperOrderRefundForm) (*models.Order, error) { + if form == nil { + return nil, errorx.ErrInvalidParameter + } + + claims, ok := ctx.Locals(consts.CtxKeyClaims).(*jwt.Claims) + if !ok || claims == nil || claims.UserID <= 0 { + return nil, errorx.ErrTokenInvalid + } + + return services.Order.SuperRefundOrder( + ctx, + claims.UserID, + orderID, + form.Force, + form.Reason, + form.IdempotencyKey, + time.Now(), + ) +} + // statistics // // @Summary 订单统计信息 diff --git a/backend/app/http/super/routes.gen.go b/backend/app/http/super/routes.gen.go index cdb961e..45d1954 100644 --- a/backend/app/http/super/routes.gen.go +++ b/backend/app/http/super/routes.gen.go @@ -61,6 +61,17 @@ func (r *Routes) Register(router fiber.Router) { r.order.list, Query[dto.OrderPageFilter]("filter"), )) + r.log.Debugf("Registering route: Get /super/v1/orders/:orderID -> order.detail") + router.Get("/super/v1/orders/:orderID"[len(r.Path()):], DataFunc1( + r.order.detail, + PathParam[int64]("orderID"), + )) + r.log.Debugf("Registering route: Post /super/v1/orders/:orderID/refund -> order.refund") + router.Post("/super/v1/orders/:orderID/refund"[len(r.Path()):], DataFunc2( + r.order.refund, + PathParam[int64]("orderID"), + Body[dto.SuperOrderRefundForm]("form"), + )) 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, @@ -124,6 +135,12 @@ func (r *Routes) Register(router fiber.Router) { PathParam[int64]("userID"), Body[dto.UserStatusUpdateForm]("form"), )) + r.log.Debugf("Registering route: Patch /super/v1/users/:userID/roles -> user.updateRoles") + router.Patch("/super/v1/users/:userID/roles"[len(r.Path()):], Func2( + r.user.updateRoles, + PathParam[int64]("userID"), + Body[dto.UserRolesUpdateForm]("form"), + )) r.log.Info("Successfully registered all routes") } diff --git a/backend/app/http/super/routes.manual.go b/backend/app/http/super/routes.manual.go index 581130a..e8df529 100644 --- a/backend/app/http/super/routes.manual.go +++ b/backend/app/http/super/routes.manual.go @@ -5,5 +5,7 @@ func (r *Routes) Path() string { } func (r *Routes) Middlewares() []any { - return []any{} + return []any{ + r.middlewares.SuperAuth, + } } diff --git a/backend/app/http/super/user.go b/backend/app/http/super/user.go index 3bff175..6a38bd4 100644 --- a/backend/app/http/super/user.go +++ b/backend/app/http/super/user.go @@ -61,6 +61,22 @@ func (*user) updateStatus(ctx fiber.Ctx, userID int64, form *dto.UserStatusUpdat return services.User.UpdateStatus(ctx, userID, form.Status) } +// updateRoles +// +// @Summary 更新用户角色 +// @Tags Super +// @Accept json +// @Produce json +// @Param userID path int64 true "UserID" +// @Param form body dto.UserRolesUpdateForm true "Form" +// +// @Router /super/v1/users/:userID/roles [patch] +// @Bind userID path +// @Bind form body +func (*user) updateRoles(ctx fiber.Ctx, userID int64, form *dto.UserRolesUpdateForm) error { + return services.User.UpdateRoles(ctx, userID, form.Roles) +} + // statusList // // @Summary 用户状态列表 diff --git a/backend/app/middlewares/super.go b/backend/app/middlewares/super.go new file mode 100644 index 0000000..869a4f1 --- /dev/null +++ b/backend/app/middlewares/super.go @@ -0,0 +1,68 @@ +package middlewares + +import ( + "strings" + + "quyun/v2/app/errorx" + "quyun/v2/app/services" + "quyun/v2/pkg/consts" + "quyun/v2/providers/jwt" + + "github.com/gofiber/fiber/v3" +) + +func shouldSkipSuperJWTAuth(path string) bool { + // 登录接口允许匿名访问。 + return strings.Contains(path, "/super/v1/auth/login") +} + +// SuperAuth 平台侧超级管理员鉴权: +// - 校验 JWT 并写入 claims +// - 加载用户并校验包含 super_admin 角色 +func (f *Middlewares) SuperAuth(c fiber.Ctx) error { + if shouldSkipSuperJWTAuth(c.Path()) { + f.log.Debug("middlewares.super.auth.skipped") + return c.Next() + } + + authHeader := c.Get(jwt.HttpHeader) + if authHeader == "" { + f.log.Info("middlewares.super.auth.missing_token") + return errorx.ErrTokenMissing + } + + claims, err := f.jwt.Parse(authHeader) + if err != nil { + f.log.WithError(err).Warn("middlewares.super.auth.invalid_token") + switch err { + case jwt.TokenExpired: + return errorx.ErrTokenExpired + case jwt.TokenMalformed, jwt.TokenNotValidYet, jwt.TokenInvalid: + return errorx.ErrTokenInvalid + default: + return errorx.ErrTokenInvalid + } + } + if claims.UserID == 0 { + f.log.Warn("middlewares.super.auth.missing_user_id") + return errorx.ErrTokenInvalid + } + + userModel, err := services.User.FindByID(c, claims.UserID) + if err != nil { + f.log.WithField("user_id", claims.UserID).WithError(err).Warn("middlewares.super.auth.load_user_failed") + return err + } + if !userModel.Roles.Contains(consts.RoleSuperAdmin) { + f.log.WithField("user_id", claims.UserID).Warn("middlewares.super.auth.denied") + return errorx.ErrPermissionDenied.WithMsg("需要超级管理员权限") + } + + f.log.WithFields(map[string]any{ + "user_id": claims.UserID, + }).Info("middlewares.super.auth.ok") + + c.Locals(consts.CtxKeyClaims, claims) + c.Locals(consts.CtxKeyUser, userModel) + return c.Next() +} diff --git a/backend/app/services/order.go b/backend/app/services/order.go index c126eca..6051848 100644 --- a/backend/app/services/order.go +++ b/backend/app/services/order.go @@ -417,6 +417,66 @@ func (s *order) SuperOrderPage(ctx context.Context, filter *superdto.OrderPageFi }, nil } +// SuperOrderDetail 平台侧订单详情(跨租户)。 +func (s *order) SuperOrderDetail(ctx context.Context, orderID int64) (*superdto.SuperOrderDetail, error) { + if orderID <= 0 { + return nil, errorx.ErrInvalidParameter.WithMsg("order_id must be > 0") + } + + tbl, query := models.OrderQuery.QueryContext(ctx) + orderModel, err := query.Preload(tbl.Items).Where(tbl.ID.Eq(orderID)).First() + if err != nil { + return nil, err + } + + var tenantLite *superdto.OrderTenantLite + if orderModel.TenantID > 0 { + tTbl, tQuery := models.TenantQuery.QueryContext(ctx) + tenantModel, err := tQuery.Where(tTbl.ID.Eq(orderModel.TenantID)).First() + if err != nil { + return nil, err + } + tenantLite = &superdto.OrderTenantLite{ID: tenantModel.ID, Code: tenantModel.Code, Name: tenantModel.Name} + } + + var buyerLite *superdto.OrderBuyerLite + if orderModel.UserID > 0 { + uTbl, uQuery := models.UserQuery.QueryContext(ctx) + userModel, err := uQuery.Where(uTbl.ID.Eq(orderModel.UserID)).First() + if err != nil { + return nil, err + } + buyerLite = &superdto.OrderBuyerLite{ID: userModel.ID, Username: userModel.Username} + } + + return &superdto.SuperOrderDetail{ + Order: orderModel, + Tenant: tenantLite, + Buyer: buyerLite, + }, nil +} + +// SuperRefundOrder 平台侧发起退款(跨租户)。 +func (s *order) SuperRefundOrder( + ctx context.Context, + operatorUserID, orderID int64, + force bool, + reason, idempotencyKey string, + now time.Time, +) (*models.Order, error) { + if operatorUserID <= 0 || orderID <= 0 { + return nil, errorx.ErrInvalidParameter.WithMsg("operator_user_id/order_id must be > 0") + } + + tbl, query := models.OrderQuery.QueryContext(ctx) + orderModel, err := query.Where(tbl.ID.Eq(orderID)).First() + if err != nil { + return nil, err + } + + return s.AdminRefundOrder(ctx, orderModel.TenantID, operatorUserID, orderID, force, reason, idempotencyKey, now) +} + // PurchaseContentParams 定义“租户内使用余额购买内容”的入参。 type PurchaseContentParams struct { // TenantID 租户 ID(多租户隔离范围)。 diff --git a/backend/app/services/user.go b/backend/app/services/user.go index 22fac35..1547c31 100644 --- a/backend/app/services/user.go +++ b/backend/app/services/user.go @@ -276,6 +276,35 @@ func (t *user) UpdateStatus(ctx context.Context, userID int64, status consts.Use return nil } +// UpdateRoles 更新用户角色(超级管理员侧)。 +func (t *user) UpdateRoles(ctx context.Context, userID int64, roles []consts.Role) error { + if userID <= 0 { + return errors.New("user_id must be > 0") + } + + roles = lo.Uniq(lo.Filter(roles, func(r consts.Role, _ int) bool { + return r != "" + })) + if len(roles) == 0 { + return errors.New("roles is empty") + } + + // 约定:系统用户至少包含 user 角色。 + if !lo.Contains(roles, consts.RoleUser) { + roles = append(roles, consts.RoleUser) + } + roles = lo.Uniq(roles) + + m, err := t.FindByID(ctx, userID) + if err != nil { + return err + } + + m.Roles = types.NewArray(roles) + _, err = m.Update(ctx) + return err +} + // Statistics 按状态统计用户数量(超级管理员侧)。 func (t *user) Statistics(ctx context.Context) ([]*dto.UserStatistics, error) { tbl, query := models.UserQuery.QueryContext(ctx) diff --git a/frontend/superadmin/SUPERADMIN_PAGES.md b/frontend/superadmin/SUPERADMIN_PAGES.md index 0afce17..fe20756 100644 --- a/frontend/superadmin/SUPERADMIN_PAGES.md +++ b/frontend/superadmin/SUPERADMIN_PAGES.md @@ -21,6 +21,25 @@ - `/`:概览 Dashboard - `/superadmin/tenants`:租户管理 - `/superadmin/users`:用户管理 +- `/superadmin/orders`:订单管理 + +## 1.1 迭代路线(按优先级依次实现) + +1) **安全与鉴权** + - `/super/v1/*`(除 `/auth/login`)强制 JWT 校验与 `super_admin` 角色校验 + - `/super/v1/auth/token` 改为基于当前 token 的续期/校验(不再返回固定用户 token) +2) **订单管理** + - 订单列表(跨租户筛选/分页/排序) + - 订单详情(含 items / snapshot 展示) + - 平台侧退款(支持强制退款,记录操作人) +3) **租户管理增强** + - 租户详情页(基本信息、过期续期、状态变更、管理员/成员管理) +4) **用户管理增强** + - 用户详情页(角色、状态、余额/冻结、加入/拥有的租户、操作记录) + - 角色授予/回收(`super_admin`) +5) **审计与运维** + - 操作审计日志、关键行为告警 + - 任务队列/退款处理监控、健康检查面板 ## 2. 页面规格(页面 → 功能 → API) diff --git a/frontend/superadmin/dist/index.html b/frontend/superadmin/dist/index.html index 187cedc..80d8810 100644 --- a/frontend/superadmin/dist/index.html +++ b/frontend/superadmin/dist/index.html @@ -7,8 +7,8 @@