fix(admin): 收紧 isSuper、plan-prices RBAC 与封禁状态机
避免 roles.write 绕过全部 can();定价读写挂 membership.plans 权限;去掉快捷封禁双路径并让 ban/unban 走 lifecycle。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -148,8 +148,6 @@ export const adminApi = {
|
|||||||
users: (q = '') =>
|
users: (q = '') =>
|
||||||
request<{ items: UserListItem[] }>('GET', `/users?q=${encodeURIComponent(q)}`),
|
request<{ items: UserListItem[] }>('GET', `/users?q=${encodeURIComponent(q)}`),
|
||||||
user: (id: string) => request<UserDetail>('GET', `/users/${id}`),
|
user: (id: string) => request<UserDetail>('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) =>
|
setUserStatus: (id: string, status: string, reason: string) =>
|
||||||
request<UserDetail>('POST', `/users/${id}/status`, { status, reason }),
|
request<UserDetail>('POST', `/users/${id}/status`, { status, reason }),
|
||||||
statusTransitions: (id: string) =>
|
statusTransitions: (id: string) =>
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ async function onLogout() {
|
|||||||
<RouterLink to="/cms">CMS</RouterLink>
|
<RouterLink to="/cms">CMS</RouterLink>
|
||||||
<RouterLink to="/catalogs">目录仓</RouterLink>
|
<RouterLink to="/catalogs">目录仓</RouterLink>
|
||||||
<RouterLink to="/orders">订单</RouterLink>
|
<RouterLink to="/orders">订单</RouterLink>
|
||||||
<RouterLink v-if="auth.isSuper" to="/pricing">定价</RouterLink>
|
<RouterLink v-if="auth.can('admin.membership.plans.read')" to="/pricing">定价</RouterLink>
|
||||||
<RouterLink to="/push">推送</RouterLink>
|
<RouterLink to="/push">推送</RouterLink>
|
||||||
<RouterLink v-if="auth.isSuper" to="/admins">管理员</RouterLink>
|
<RouterLink v-if="auth.isSuper" to="/admins">管理员</RouterLink>
|
||||||
<RouterLink to="/audit">审计</RouterLink>
|
<RouterLink to="/audit">审计</RouterLink>
|
||||||
|
|||||||
@@ -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) {
|
function fmtTime(iso?: string | null) {
|
||||||
if (!iso) return '—'
|
if (!iso) return '—'
|
||||||
try {
|
try {
|
||||||
@@ -194,9 +178,6 @@ onMounted(load)
|
|||||||
</select>
|
</select>
|
||||||
<input v-model="statusReason" class="reason" type="text" placeholder="原因(必填)" />
|
<input v-model="statusReason" class="reason" type="text" placeholder="原因(必填)" />
|
||||||
<button class="btn" type="button" @click="changeStatus">变更状态</button>
|
<button class="btn" type="button" @click="changeStatus">变更状态</button>
|
||||||
<button class="btn ghost" type="button" @click="toggleBan">
|
|
||||||
{{ detail.status === 'banned' ? '快捷解封' : '快捷封禁' }}
|
|
||||||
</button>
|
|
||||||
<span v-if="statusMsg" class="muted">{{ statusMsg }}</span>
|
<span v-if="statusMsg" class="muted">{{ statusMsg }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="transitions.length" class="trans">
|
<div v-if="transitions.length" class="trans">
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ const router = createRouter({
|
|||||||
{ path: 'orders', name: 'orders', component: () => import('@/pages/OrdersPage.vue') },
|
{ path: 'orders', name: 'orders', component: () => import('@/pages/OrdersPage.vue') },
|
||||||
{ path: 'plans', name: 'plans', component: () => import('@/pages/MembershipPlansPage.vue') },
|
{ path: 'plans', name: 'plans', component: () => import('@/pages/MembershipPlansPage.vue') },
|
||||||
{ path: 'codes', name: 'codes', component: () => import('@/pages/RedemptionPage.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: 'ask', name: 'ask', component: () => import('@/pages/AskPage.vue') },
|
||||||
{ path: 'safety', name: 'safety', component: () => import('@/pages/SafetyPage.vue') },
|
{ path: 'safety', name: 'safety', component: () => import('@/pages/SafetyPage.vue') },
|
||||||
{ path: 'ai', name: 'ai', component: () => import('@/pages/AIConfigPage.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.username) await auth.hydrate()
|
||||||
if (!auth.isSuper) return { name: 'dashboard' }
|
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
|
return true
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -9,10 +9,7 @@ export const useAuthStore = defineStore('auth', () => {
|
|||||||
const permissions = ref<string[]>([])
|
const permissions = ref<string[]>([])
|
||||||
|
|
||||||
const isSuper = computed(
|
const isSuper = computed(
|
||||||
() =>
|
() => role.value === 'super' || role.value === 'super_admin',
|
||||||
role.value === 'super' ||
|
|
||||||
role.value === 'super_admin' ||
|
|
||||||
permissions.value.includes('admin.roles.write'),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
function applyMe(me: AdminMe) {
|
function applyMe(me: AdminMe) {
|
||||||
|
|||||||
@@ -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/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.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("/orders", middleware.RequireAdminPermission(h.Svc, admin.PermOrdersRead), h.ListOrders)
|
||||||
authed.GET("/membership/plan-prices", h.ListPlanPrices)
|
authed.GET("/membership/plan-prices", middleware.RequireAdminPermission(h.Svc, admin.PermMembershipPlansRead), h.ListPlanPrices)
|
||||||
authed.PUT("/membership/plan-prices", h.PutPlanPrices)
|
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("/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/overview", middleware.RequireAdminPermission(h.Svc, admin.PermAnalyticsRead), h.AnalyticsOverview)
|
||||||
authed.GET("/analytics/pages", middleware.RequireAdminPermission(h.Svc, admin.PermAnalyticsRead), h.AnalyticsPages)
|
authed.GET("/analytics/pages", middleware.RequireAdminPermission(h.Svc, admin.PermAnalyticsRead), h.AnalyticsPages)
|
||||||
|
|||||||
@@ -14,8 +14,8 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func (h *AdminHandler) registerSystem(authed *gin.RouterGroup) {
|
func (h *AdminHandler) registerSystem(authed *gin.RouterGroup) {
|
||||||
authed.POST("/users/:id/ban", h.BanUser)
|
authed.POST("/users/:id/ban", middleware.RequireAdminPermission(h.Svc, admin.PermUsersStatusWrite), h.BanUser)
|
||||||
authed.POST("/users/:id/unban", h.UnbanUser)
|
authed.POST("/users/:id/unban", middleware.RequireAdminPermission(h.Svc, admin.PermUsersStatusWrite), h.UnbanUser)
|
||||||
authed.GET("/admins", h.ListAdmins)
|
authed.GET("/admins", h.ListAdmins)
|
||||||
authed.PATCH("/admins/:id", h.PatchAdmin)
|
authed.PATCH("/admins/:id", h.PatchAdmin)
|
||||||
authed.GET("/push-jobs", h.ListPushJobs)
|
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")
|
response.Fail(c, http.StatusNotFound, 40401, "user not found")
|
||||||
return
|
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")
|
response.Fail(c, http.StatusInternalServerError, 50040, "ban failed")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -61,6 +65,10 @@ func (h *AdminHandler) UnbanUser(c *gin.Context) {
|
|||||||
response.Fail(c, http.StatusNotFound, 40401, "user not found")
|
response.Fail(c, http.StatusNotFound, 40401, "user not found")
|
||||||
return
|
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")
|
response.Fail(c, http.StatusInternalServerError, 50041, "unban failed")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -188,9 +188,26 @@ func insertOpsAdmin(t *testing.T, username, password string) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("hash: %v", err)
|
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, `
|
_, err = pool.Exec(ctx, `
|
||||||
INSERT INTO admin_accounts(username, password_hash, role, status)
|
INSERT INTO admin_role_permissions(role_id, code) VALUES
|
||||||
VALUES ($1,$2,'ops','active')`, username, string(hash))
|
($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 {
|
if err != nil {
|
||||||
t.Fatalf("insert ops admin: %v", err)
|
t.Fatalf("insert ops admin: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -234,10 +234,8 @@ func (s *Service) ListPlanPrices(ctx context.Context) ([]repository.PlanPrice, e
|
|||||||
}
|
}
|
||||||
|
|
||||||
// UpsertPlanPrices updates display prices (not historical order amounts).
|
// 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 {
|
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 {
|
if len(items) == 0 {
|
||||||
return ErrInvalidPlan
|
return ErrInvalidPlan
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,38 +39,14 @@ func (s *Service) RequireSuper(ctx context.Context, adminID uuid.UUID) error {
|
|||||||
return nil
|
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 {
|
func (s *Service) BanUser(ctx context.Context, adminID, userID uuid.UUID) error {
|
||||||
ok, err := s.Repo.UserExists(ctx, userID)
|
return s.TransitionUserStatus(ctx, adminID, userID, "banned", "admin ban")
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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 {
|
func (s *Service) UnbanUser(ctx context.Context, adminID, userID uuid.UUID) error {
|
||||||
ok, err := s.Repo.UserExists(ctx, userID)
|
return s.TransitionUserStatus(ctx, adminID, userID, "active", "admin unban")
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ListAdmins returns admin accounts (super only caller).
|
// ListAdmins returns admin accounts (super only caller).
|
||||||
|
|||||||
Reference in New Issue
Block a user