feat: 添加订单详情和退款功能,更新用户角色管理,增强超级管理员鉴权
This commit is contained in:
@@ -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("登录凭证生成失败")
|
||||
|
||||
10
backend/app/http/super/dto/order_detail.go
Normal file
10
backend/app/http/super/dto/order_detail.go
Normal file
@@ -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"`
|
||||
}
|
||||
10
backend/app/http/super/dto/order_refund.go
Normal file
10
backend/app/http/super/dto/order_refund.go
Normal file
@@ -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"`
|
||||
}
|
||||
7
backend/app/http/super/dto/user_roles.go
Normal file
7
backend/app/http/super/dto/user_roles.go
Normal file
@@ -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"`
|
||||
}
|
||||
@@ -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 订单统计信息
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -5,5 +5,7 @@ func (r *Routes) Path() string {
|
||||
}
|
||||
|
||||
func (r *Routes) Middlewares() []any {
|
||||
return []any{}
|
||||
return []any{
|
||||
r.middlewares.SuperAuth,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 用户状态列表
|
||||
|
||||
68
backend/app/middlewares/super.go
Normal file
68
backend/app/middlewares/super.go
Normal file
@@ -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()
|
||||
}
|
||||
@@ -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(多租户隔离范围)。
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user