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 @@ Sakai Vue - - + + diff --git a/frontend/superadmin/src/service/OrderService.js b/frontend/superadmin/src/service/OrderService.js index 93d607c..46d9047 100644 --- a/frontend/superadmin/src/service/OrderService.js +++ b/frontend/superadmin/src/service/OrderService.js @@ -71,5 +71,20 @@ export const OrderService = { }, async getOrderStatistics() { return requestJson('/super/v1/orders/statistics'); + }, + async getOrderDetail(orderID) { + if (!orderID) throw new Error('orderID is required'); + return requestJson(`/super/v1/orders/${orderID}`); + }, + async refundOrder(orderID, { force, reason, idempotency_key } = {}) { + if (!orderID) throw new Error('orderID is required'); + return requestJson(`/super/v1/orders/${orderID}/refund`, { + method: 'POST', + body: { + force: Boolean(force), + reason, + idempotency_key + } + }); } }; diff --git a/frontend/superadmin/src/service/UserService.js b/frontend/superadmin/src/service/UserService.js index 7a774b4..97e05e3 100644 --- a/frontend/superadmin/src/service/UserService.js +++ b/frontend/superadmin/src/service/UserService.js @@ -77,6 +77,11 @@ export const UserService = { throw error; } }, + async updateUserRoles({ userID, roles }) { + if (!userID) throw new Error('userID is required'); + if (!Array.isArray(roles) || roles.length === 0) throw new Error('roles is required'); + return requestJson(`/super/v1/users/${userID}/roles`, { method: 'PATCH', body: { roles } }); + }, async getUserStatistics() { try { const data = await requestJson('/super/v1/users/statistics'); diff --git a/frontend/superadmin/src/views/superadmin/Orders.vue b/frontend/superadmin/src/views/superadmin/Orders.vue index 2950af3..6521e81 100644 --- a/frontend/superadmin/src/views/superadmin/Orders.vue +++ b/frontend/superadmin/src/views/superadmin/Orders.vue @@ -10,6 +10,16 @@ const toast = useToast(); const orders = ref([]); const loading = ref(false); +const detailDialogVisible = ref(false); +const detailLoading = ref(false); +const detail = ref(null); + +const refundDialogVisible = ref(false); +const refundLoading = ref(false); +const refundOrder = ref(null); +const refundForce = ref(false); +const refundReason = ref(''); + const totalRecords = ref(0); const page = ref(1); const rows = ref(10); @@ -77,6 +87,14 @@ function getOrderStatusSeverity(value) { } } +function safeJson(value) { + try { + return JSON.stringify(value ?? null, null, 2); + } catch { + return String(value ?? ''); + } +} + async function loadOrders() { loading.value = true; try { @@ -111,6 +129,51 @@ async function loadOrders() { } } +async function openDetailDialog(order) { + const id = order?.id; + if (!id) return; + + detailDialogVisible.value = true; + detailLoading.value = true; + detail.value = null; + + try { + detail.value = await OrderService.getOrderDetail(id); + } catch (error) { + toast.add({ severity: 'error', summary: '加载失败', detail: error?.message || '无法加载订单详情', life: 4000 }); + detailDialogVisible.value = false; + } finally { + detailLoading.value = false; + } +} + +function openRefundDialog(order) { + refundOrder.value = order; + refundDialogVisible.value = true; + refundForce.value = false; + refundReason.value = ''; +} + +async function confirmRefund() { + const id = refundOrder.value?.id; + if (!id) return; + + refundLoading.value = true; + try { + await OrderService.refundOrder(id, { force: refundForce.value, reason: refundReason.value }); + toast.add({ severity: 'success', summary: '已提交退款', detail: `订单ID: ${id}`, life: 3000 }); + refundDialogVisible.value = false; + await loadOrders(); + if (detailDialogVisible.value && detail.value?.order?.id === id) { + detail.value = await OrderService.getOrderDetail(id); + } + } catch (error) { + toast.add({ severity: 'error', summary: '退款失败', detail: error?.message || '无法发起退款', life: 4000 }); + } finally { + refundLoading.value = false; + } +} + function onSearch() { page.value = 1; loadOrders(); @@ -238,7 +301,12 @@ loadOrders(); scrollHeight="flex" responsiveLayout="scroll" > - + + + + + + - + + +
+ +
+
+
+
+
租户
+
{{ detail?.tenant?.name ?? '-' }}
+
{{ detail?.tenant?.code ?? '-' }} / {{ detail?.tenant?.id ?? '-' }}
+
+
+
买家
+
{{ detail?.buyer?.username ?? '-' }}
+
ID: {{ detail?.buyer?.id ?? '-' }}
+
+
+
状态
+ +
+
+
实付
+
{{ formatCny(detail?.order?.amount_paid) }}
+
+
+
创建时间
+
{{ formatDate(detail?.order?.created_at) }}
+
+
+
支付时间
+
{{ formatDate(detail?.order?.paid_at) }}
+
+
+ +
+
+
订单快照(snapshot)
+
{{ safeJson(detail?.order?.snapshot) }}
+
+
+
订单明细(items)
+ + + + + + + + + + +
+
+
+ +
+ + + +
+
+ 该操作会将订单从 paid 推进到 refunding 并提交异步退款任务。 +
+
+ + +
+
+ + +
+
+ +
+ diff --git a/frontend/superadmin/src/views/superadmin/Users.vue b/frontend/superadmin/src/views/superadmin/Users.vue index 7f0884d..871ca43 100644 --- a/frontend/superadmin/src/views/superadmin/Users.vue +++ b/frontend/superadmin/src/views/superadmin/Users.vue @@ -67,6 +67,11 @@ const statusValue = ref(null); const statusFilterOptions = computed(() => [{ label: '全部', value: '' }, ...(statusOptions.value || [])]); +const rolesDialogVisible = ref(false); +const rolesLoading = ref(false); +const rolesUser = ref(null); +const rolesSuperAdmin = ref(false); + const ownedTenantsDialogVisible = ref(false); const ownedTenantsLoading = ref(false); const ownedTenantsUser = ref(null); @@ -187,6 +192,37 @@ async function confirmUpdateStatus() { } } +function hasRole(user, role) { + const roles = user?.roles || []; + return Array.isArray(roles) && roles.includes(role); +} + +function openRolesDialog(user) { + rolesUser.value = user; + rolesSuperAdmin.value = hasRole(user, 'super_admin'); + rolesDialogVisible.value = true; +} + +async function confirmUpdateRoles() { + const userID = rolesUser.value?.id; + if (!userID) return; + + const roles = ['user']; + if (rolesSuperAdmin.value) roles.push('super_admin'); + + rolesLoading.value = true; + try { + await UserService.updateUserRoles({ userID, roles }); + toast.add({ severity: 'success', summary: '更新成功', detail: `用户ID: ${userID}`, life: 3000 }); + rolesDialogVisible.value = false; + await loadUsers(); + } catch (error) { + toast.add({ severity: 'error', summary: '更新失败', detail: error?.message || '无法更新用户角色', life: 4000 }); + } finally { + rolesLoading.value = false; + } +} + async function loadUsers() { loading.value = true; try { @@ -451,6 +487,18 @@ onMounted(() => {
+ + + + + +
+
+ + +
+
默认包含 user 角色。
+
+ +
+