diff --git a/apps/admin-h5/src/api/client.ts b/apps/admin-h5/src/api/client.ts index 56504ac..ad44e0c 100644 --- a/apps/admin-h5/src/api/client.ts +++ b/apps/admin-h5/src/api/client.ts @@ -148,8 +148,6 @@ export const adminApi = { users: (q = '') => request<{ items: UserListItem[] }>('GET', `/users?q=${encodeURIComponent(q)}`), user: (id: string) => request('GET', `/users/${id}`), - banUser: (id: string) => request<{ ok: boolean }>('POST', `/users/${id}/ban`), - unbanUser: (id: string) => request<{ ok: boolean }>('POST', `/users/${id}/unban`), setUserStatus: (id: string, status: string, reason: string) => request('POST', `/users/${id}/status`, { status, reason }), statusTransitions: (id: string) => diff --git a/apps/admin-h5/src/layouts/AdminShell.vue b/apps/admin-h5/src/layouts/AdminShell.vue index 904ef71..9d42f81 100644 --- a/apps/admin-h5/src/layouts/AdminShell.vue +++ b/apps/admin-h5/src/layouts/AdminShell.vue @@ -37,7 +37,7 @@ async function onLogout() { CMS 目录仓 订单 - 定价 + 定价 推送 管理员 审计 diff --git a/apps/admin-h5/src/pages/UserDetailPage.vue b/apps/admin-h5/src/pages/UserDetailPage.vue index 5062882..a28f6b4 100644 --- a/apps/admin-h5/src/pages/UserDetailPage.vue +++ b/apps/admin-h5/src/pages/UserDetailPage.vue @@ -125,22 +125,6 @@ async function changeStatus() { } } -async function toggleBan() { - statusMsg.value = '' - try { - if (detail.value?.status === 'banned') { - await adminApi.unbanUser(String(route.params.id)) - statusMsg.value = '已解封' - } else { - await adminApi.banUser(String(route.params.id)) - statusMsg.value = '已封禁' - } - await load() - } catch (e) { - statusMsg.value = e instanceof Error ? e.message : '操作失败' - } -} - function fmtTime(iso?: string | null) { if (!iso) return '—' try { @@ -194,9 +178,6 @@ onMounted(load) - {{ statusMsg }}
diff --git a/apps/admin-h5/src/router/index.ts b/apps/admin-h5/src/router/index.ts index ee60949..87cfb78 100644 --- a/apps/admin-h5/src/router/index.ts +++ b/apps/admin-h5/src/router/index.ts @@ -18,7 +18,7 @@ const router = createRouter({ { path: 'orders', name: 'orders', component: () => import('@/pages/OrdersPage.vue') }, { path: 'plans', name: 'plans', component: () => import('@/pages/MembershipPlansPage.vue') }, { path: 'codes', name: 'codes', component: () => import('@/pages/RedemptionPage.vue') }, - { path: 'pricing', name: 'pricing', component: () => import('@/pages/PricingPage.vue'), meta: { superOnly: true } }, + { path: 'pricing', name: 'pricing', component: () => import('@/pages/PricingPage.vue'), meta: { permission: 'admin.membership.plans.read' } }, { path: 'ask', name: 'ask', component: () => import('@/pages/AskPage.vue') }, { path: 'safety', name: 'safety', component: () => import('@/pages/SafetyPage.vue') }, { path: 'ai', name: 'ai', component: () => import('@/pages/AIConfigPage.vue') }, @@ -41,6 +41,11 @@ router.beforeEach(async (to) => { if (!auth.username) await auth.hydrate() if (!auth.isSuper) return { name: 'dashboard' } } + if (typeof to.meta.permission === 'string') { + const auth = useAuthStore() + if (!auth.username) await auth.hydrate() + if (!auth.can(to.meta.permission)) return { name: 'dashboard' } + } return true }) diff --git a/apps/admin-h5/src/stores/auth.ts b/apps/admin-h5/src/stores/auth.ts index 99f4c91..7e396ab 100644 --- a/apps/admin-h5/src/stores/auth.ts +++ b/apps/admin-h5/src/stores/auth.ts @@ -9,10 +9,7 @@ export const useAuthStore = defineStore('auth', () => { const permissions = ref([]) const isSuper = computed( - () => - role.value === 'super' || - role.value === 'super_admin' || - permissions.value.includes('admin.roles.write'), + () => role.value === 'super' || role.value === 'super_admin', ) function applyMe(me: AdminMe) { diff --git a/apps/api/internal/handler/admin.go b/apps/api/internal/handler/admin.go index 921185e..d7a1a39 100644 --- a/apps/api/internal/handler/admin.go +++ b/apps/api/internal/handler/admin.go @@ -36,8 +36,8 @@ func (h *AdminHandler) Register(api *gin.RouterGroup) { authed.POST("/users/:id/membership/grant", middleware.RequireAdminPermission(h.Svc, admin.PermMembershipGrant), h.GrantMembership) authed.POST("/users/:id/ask-quota/grant", middleware.RequireAdminPermission(h.Svc, admin.PermAskQuotaGrant), h.GrantAskQuota) authed.GET("/orders", middleware.RequireAdminPermission(h.Svc, admin.PermOrdersRead), h.ListOrders) - authed.GET("/membership/plan-prices", h.ListPlanPrices) - authed.PUT("/membership/plan-prices", h.PutPlanPrices) + authed.GET("/membership/plan-prices", middleware.RequireAdminPermission(h.Svc, admin.PermMembershipPlansRead), h.ListPlanPrices) + authed.PUT("/membership/plan-prices", middleware.RequireAdminPermission(h.Svc, admin.PermMembershipPlansWrite), h.PutPlanPrices) authed.GET("/audit-logs", middleware.RequireAdminPermission(h.Svc, admin.PermAuditRead), h.ListAudit) authed.GET("/analytics/overview", middleware.RequireAdminPermission(h.Svc, admin.PermAnalyticsRead), h.AnalyticsOverview) authed.GET("/analytics/pages", middleware.RequireAdminPermission(h.Svc, admin.PermAnalyticsRead), h.AnalyticsPages) diff --git a/apps/api/internal/handler/admin_system.go b/apps/api/internal/handler/admin_system.go index 624a3e3..cccf4c0 100644 --- a/apps/api/internal/handler/admin_system.go +++ b/apps/api/internal/handler/admin_system.go @@ -14,8 +14,8 @@ import ( ) func (h *AdminHandler) registerSystem(authed *gin.RouterGroup) { - authed.POST("/users/:id/ban", h.BanUser) - authed.POST("/users/:id/unban", h.UnbanUser) + authed.POST("/users/:id/ban", middleware.RequireAdminPermission(h.Svc, admin.PermUsersStatusWrite), h.BanUser) + authed.POST("/users/:id/unban", middleware.RequireAdminPermission(h.Svc, admin.PermUsersStatusWrite), h.UnbanUser) authed.GET("/admins", h.ListAdmins) authed.PATCH("/admins/:id", h.PatchAdmin) authed.GET("/push-jobs", h.ListPushJobs) @@ -39,6 +39,10 @@ func (h *AdminHandler) BanUser(c *gin.Context) { response.Fail(c, http.StatusNotFound, 40401, "user not found") return } + if errors.Is(err, admin.ErrReasonRequired) || errors.Is(err, admin.ErrInvalidStatusEdge) { + response.Fail(c, http.StatusBadRequest, 40000, err.Error()) + return + } response.Fail(c, http.StatusInternalServerError, 50040, "ban failed") return } @@ -61,6 +65,10 @@ func (h *AdminHandler) UnbanUser(c *gin.Context) { response.Fail(c, http.StatusNotFound, 40401, "user not found") return } + if errors.Is(err, admin.ErrReasonRequired) || errors.Is(err, admin.ErrInvalidStatusEdge) { + response.Fail(c, http.StatusBadRequest, 40000, err.Error()) + return + } response.Fail(c, http.StatusInternalServerError, 50041, "unban failed") return } diff --git a/apps/api/internal/integration/ops_system_test.go b/apps/api/internal/integration/ops_system_test.go index c4adf18..90576d2 100644 --- a/apps/api/internal/integration/ops_system_test.go +++ b/apps/api/internal/integration/ops_system_test.go @@ -188,9 +188,26 @@ func insertOpsAdmin(t *testing.T, username, password string) { if err != nil { t.Fatalf("hash: %v", err) } + var roleID uuid.UUID + err = pool.QueryRow(ctx, ` + INSERT INTO admin_roles(name, system) + VALUES ('ops', false) + ON CONFLICT (name) DO UPDATE SET name = EXCLUDED.name + RETURNING id`).Scan(&roleID) + if err != nil { + t.Fatalf("ensure ops role: %v", err) + } _, err = pool.Exec(ctx, ` - INSERT INTO admin_accounts(username, password_hash, role, status) - VALUES ($1,$2,'ops','active')`, username, string(hash)) + INSERT INTO admin_role_permissions(role_id, code) VALUES + ($1, 'admin.users.read'), + ($1, 'admin.users.status.write') + ON CONFLICT DO NOTHING`, roleID) + if err != nil { + t.Fatalf("ops role perms: %v", err) + } + _, err = pool.Exec(ctx, ` + INSERT INTO admin_accounts(username, password_hash, role, status, role_id) + VALUES ($1,$2,'ops','active',$3)`, username, string(hash), roleID) if err != nil { t.Fatalf("insert ops admin: %v", err) } diff --git a/apps/api/internal/service/admin/service.go b/apps/api/internal/service/admin/service.go index a1e1b53..c1563bb 100644 --- a/apps/api/internal/service/admin/service.go +++ b/apps/api/internal/service/admin/service.go @@ -234,10 +234,8 @@ func (s *Service) ListPlanPrices(ctx context.Context) ([]repository.PlanPrice, e } // UpsertPlanPrices updates display prices (not historical order amounts). +// Caller must enforce admin.membership.plans.write. func (s *Service) UpsertPlanPrices(ctx context.Context, adminID uuid.UUID, items []repository.PlanPrice) error { - if err := s.RequireSuper(ctx, adminID); err != nil { - return err - } if len(items) == 0 { return ErrInvalidPlan } diff --git a/apps/api/internal/service/admin/system.go b/apps/api/internal/service/admin/system.go index fb25054..9d95bdb 100644 --- a/apps/api/internal/service/admin/system.go +++ b/apps/api/internal/service/admin/system.go @@ -39,38 +39,14 @@ func (s *Service) RequireSuper(ctx context.Context, adminID uuid.UUID) error { return nil } -// BanUser sets users.status=banned. +// BanUser transitions user status to banned via the lifecycle state machine. func (s *Service) BanUser(ctx context.Context, adminID, userID uuid.UUID) error { - ok, err := s.Repo.UserExists(ctx, userID) - if err != nil { - return err - } - if !ok { - return ErrUserNotFound - } - meta, _ := json.Marshal(map[string]any{"status": "banned"}) - err = s.Repo.SetUserStatusWithAudit(ctx, adminID, userID, "banned", "user.ban", meta) - if errors.Is(err, repository.ErrUserStatusNotFound) { - return ErrUserNotFound - } - return err + return s.TransitionUserStatus(ctx, adminID, userID, "banned", "admin ban") } -// UnbanUser sets users.status=active. +// UnbanUser transitions user status to active via the lifecycle state machine. func (s *Service) UnbanUser(ctx context.Context, adminID, userID uuid.UUID) error { - ok, err := s.Repo.UserExists(ctx, userID) - if err != nil { - return err - } - if !ok { - return ErrUserNotFound - } - meta, _ := json.Marshal(map[string]any{"status": "active"}) - err = s.Repo.SetUserStatusWithAudit(ctx, adminID, userID, "active", "user.unban", meta) - if errors.Is(err, repository.ErrUserStatusNotFound) { - return ErrUserNotFound - } - return err + return s.TransitionUserStatus(ctx, adminID, userID, "active", "admin unban") } // ListAdmins returns admin accounts (super only caller).