diff --git a/apps/backend/src/db/migrations/059_admin_rbac.sql b/apps/backend/src/db/migrations/059_admin_rbac.sql new file mode 100644 index 00000000..7e4dc9d0 --- /dev/null +++ b/apps/backend/src/db/migrations/059_admin_rbac.sql @@ -0,0 +1,87 @@ +-- 后台 RBAC 权限模型。保留 admin_users.role 兼容旧账号,权限通过角色键动态解析。 +CREATE TABLE IF NOT EXISTS admin_roles ( + role_key TEXT PRIMARY KEY, + role_name TEXT NOT NULL, + description TEXT NOT NULL DEFAULT '', + is_system BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS admin_permissions ( + permission_key TEXT PRIMARY KEY, + permission_name TEXT NOT NULL, + permission_group TEXT NOT NULL DEFAULT '', + description TEXT NOT NULL DEFAULT '', + is_system BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS admin_role_permissions ( + role_key TEXT NOT NULL REFERENCES admin_roles(role_key) ON DELETE CASCADE, + permission_key TEXT NOT NULL REFERENCES admin_permissions(permission_key) ON DELETE CASCADE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (role_key, permission_key) +); + +INSERT INTO admin_roles (role_key, role_name, description) +VALUES + ('admin', '管理员', '拥有全部后台权限'), + ('operator', '普通运营', '负责接单平台和订单运营'), + ('support', '客服', '负责客服处理和售后协助') +ON CONFLICT (role_key) DO UPDATE SET + role_name = EXCLUDED.role_name, + description = EXCLUDED.description, + updated_at = NOW(); + +INSERT INTO admin_permissions (permission_key, permission_name, permission_group, description) +VALUES + ('worker_order.view', '查看接单订单', '接单管理', '查看接单平台订单及详情'), + ('worker_order.edit', '编辑接单订单', '接单管理', '修改接单订单基础资料'), + ('worker_order.configure_share', '配置拼单', '接单管理', '配置接单订单拼单规则'), + ('worker_order.assign', '指派打手', '接单管理', '指派或取消指派打手'), + ('worker_order.return_to_hall', '撤单并退回大厅', '接单管理', '将进行中的订单撤回抢单大厅'), + ('worker_order.cancel', '撤单并取消订单', '接单管理', '取消订单并终止后续履约'), + ('worker_order.cancel_share', '撤销拼单参与者', '接单管理', '撤销拼单中的单个参与者'), + ('worker_order.delete', '删除接单订单', '接单管理', '删除尚未履约的接单订单'), + ('worker_order.publish', '发布接单订单', '接单管理', '发布或下架接单订单'), + ('worker_order.accept', '验收接单订单', '接单管理', '验收接单订单和拼单份额'), + ('worker_order.reopen', '重新启用订单', '接单管理', '重新启用已取消的接单订单'), + ('worker_order.update_material', '更新订单资料', '接单管理', '补充或更新订单资料'), + ('worker_order.manage_problem', '处理问题单', '接单管理', '标记和处置问题单'), + ('worker_order.deduct_deposit', '扣除押金', '接单管理', '处理待解冻押金'), + ('worker_cancel_request.review', '审核撤单申请', '接单管理', '审核打手提交的撤单申请') +ON CONFLICT (permission_key) DO UPDATE SET + permission_name = EXCLUDED.permission_name, + permission_group = EXCLUDED.permission_group, + description = EXCLUDED.description, + updated_at = NOW(); + +-- 管理员默认拥有全部已登记权限。 +INSERT INTO admin_role_permissions (role_key, permission_key) +SELECT 'admin', permission_key FROM admin_permissions +ON CONFLICT DO NOTHING; + +-- 普通运营保持现有完整接单操作;客服默认不拥有高风险直接撤单权限。 +INSERT INTO admin_role_permissions (role_key, permission_key) +SELECT 'operator', permission_key +FROM admin_permissions +ON CONFLICT DO NOTHING; + +INSERT INTO admin_role_permissions (role_key, permission_key) +SELECT 'support', permission_key +FROM admin_permissions +WHERE permission_key IN ( + 'worker_order.view', + 'worker_order.edit', + 'worker_order.assign', + 'worker_order.publish', + 'worker_order.update_material', + 'worker_order.manage_problem', + 'worker_cancel_request.review' +) +ON CONFLICT DO NOTHING; + +CREATE INDEX IF NOT EXISTS idx_admin_role_permissions_permission + ON admin_role_permissions(permission_key, role_key); diff --git a/apps/backend/src/routes/admin.ts b/apps/backend/src/routes/admin.ts index d26bde61..0f4af9cd 100644 --- a/apps/backend/src/routes/admin.ts +++ b/apps/backend/src/routes/admin.ts @@ -10,6 +10,7 @@ import kuaishouIndustryRouter from './admin/kuaishou-industry.js' import ordersRouter from './admin/orders.js' import notificationsRouter from './admin/notifications.js' import realtimeRouter from './admin/realtime.js' +import rbacRouter from './admin/rbac.js' import platformConfigRouter from './admin/platform-config.js' import { requireAdminSession } from './admin/session.js' import tasksRouter from './admin/tasks.js' @@ -24,6 +25,7 @@ router.use(requireAdminSession) router.use(filesRouter) router.use(dashboardRouter) router.use(usersRouter) +router.use(rbacRouter) router.use(auditLogsRouter) router.use(platformConfigRouter) router.use(kuaishouIndustryRouter) diff --git a/apps/backend/src/routes/admin/rbac.ts b/apps/backend/src/routes/admin/rbac.ts new file mode 100644 index 00000000..9cc05dc7 --- /dev/null +++ b/apps/backend/src/routes/admin/rbac.ts @@ -0,0 +1,40 @@ +import { Router } from 'express' + +import { + listAdminRolesWithPermissions, + updateAdminRolePermissions, +} from '../../services/admin/admin-rbac-service.js' +import { createJsonHandler, requireAdminRoles } from './session.js' + +const router = Router() + +router.use('/rbac', requireAdminRoles(['admin'])) + +router.get( + '/rbac', + createJsonHandler(() => listAdminRolesWithPermissions(), { + successMessage: 'ok', + errorMessage: '读取角色权限失败', + scope: '[admin/rbac]', + }), +) + +router.put( + '/rbac/roles/:roleKey/permissions', + createJsonHandler( + (req) => updateAdminRolePermissions(req.params.roleKey, req.body?.permissions), + { + successMessage: '角色权限已更新', + errorMessage: '更新角色权限失败', + scope: '[admin/rbac/roles/:roleKey/permissions]', + audit: (req, data) => ({ + action: 'admin_role_permissions_updated', + targetType: 'admin_role', + targetId: String(req.params.roleKey || ''), + data: data && typeof data === 'object' ? (data as Record) : {}, + }), + }, + ), +) + +export default router diff --git a/apps/backend/src/routes/admin/session.ts b/apps/backend/src/routes/admin/session.ts index 0ed47a01..8745cc32 100644 --- a/apps/backend/src/routes/admin/session.ts +++ b/apps/backend/src/routes/admin/session.ts @@ -80,6 +80,45 @@ export function requireAdminRoles(allowedRoles: string[]) { } } +export function requireAdminPermission(permission: string) { + return (req: Request, res: Response, next: NextFunction): void => { + try { + const session = getRequiredAdminSession(req) + if (!session.permissions.includes(String(permission || '').trim())) { + throw createHttpError('当前账号没有此操作权限', { + statusCode: 403, + errorCode: 'admin_permission_denied', + }) + } + next() + } catch (error) { + sendRouteError(res, error, '后台鉴权失败', '[admin/auth/permission]') + } + } +} + +export function requireAnyAdminPermission(permissions: string[]) { + return (req: Request, res: Response, next: NextFunction): void => { + try { + const session = getRequiredAdminSession(req) + if ( + permissions.some((permission) => + session.permissions.includes(String(permission || '').trim()), + ) + ) { + next() + return + } + throw createHttpError('当前账号没有此操作权限', { + statusCode: 403, + errorCode: 'admin_permission_denied', + }) + } catch (error) { + sendRouteError(res, error, '后台鉴权失败', '[admin/auth/permission]') + } + } +} + export function getRequiredAdminSession(req: Request): AdminSession { if (req.adminSession) { return req.adminSession diff --git a/apps/backend/src/routes/admin/worker-platform/acceptance.ts b/apps/backend/src/routes/admin/worker-platform/acceptance.ts index 2f99b324..3ffb5faa 100644 --- a/apps/backend/src/routes/admin/worker-platform/acceptance.ts +++ b/apps/backend/src/routes/admin/worker-platform/acceptance.ts @@ -8,13 +8,13 @@ import { markAdminWorkOrderProblem, resolveAdminProblemWorkOrder, } from '../../../services/worker-platform/index.js' -import { createJsonHandler, requireAdminRoles } from '../session.js' +import { createJsonHandler, requireAdminPermission } from '../session.js' const router = Router() router.post( '/worker-platform/orders/:workOrderId/problem', - requireAdminRoles(['admin', 'operator', 'support']), + requireAdminPermission('worker_order.manage_problem'), createJsonHandler( (req) => markAdminWorkOrderProblem( @@ -38,7 +38,7 @@ router.post( router.post( '/worker-platform/orders/:workOrderId/problem/resolve', - requireAdminRoles(['admin', 'operator', 'support']), + requireAdminPermission('worker_order.manage_problem'), createJsonHandler( (req) => resolveAdminProblemWorkOrder( @@ -62,7 +62,7 @@ router.post( router.post( '/worker-platform/orders/:workOrderId/accept', - requireAdminRoles(['admin', 'operator']), + requireAdminPermission('worker_order.accept'), createJsonHandler( (req) => acceptAdminWorkOrder(String(req.params.workOrderId || ''), req.adminSession?.username || ''), @@ -82,7 +82,7 @@ router.post( router.post( '/worker-platform/orders/:workOrderId/shares/:shareId/accept', - requireAdminRoles(['admin', 'operator']), + requireAdminPermission('worker_order.accept'), createJsonHandler( (req) => acceptAdminWorkOrderShare( @@ -106,7 +106,7 @@ router.post( router.post( '/worker-platform/orders/:workOrderId/shares/:shareId/cancel', - requireAdminRoles(['admin', 'operator', 'support']), + requireAdminPermission('worker_order.cancel_share'), createJsonHandler( (req) => cancelAdminWorkOrderShare( @@ -131,7 +131,7 @@ router.post( router.post( '/worker-platform/orders/:workOrderId/deduct-pending-deposit', - requireAdminRoles(['admin', 'operator']), + requireAdminPermission('worker_order.deduct_deposit'), createJsonHandler( (req) => deductAdminWorkOrderPendingDeposit( diff --git a/apps/backend/src/routes/admin/worker-platform/orders.ts b/apps/backend/src/routes/admin/worker-platform/orders.ts index 5c860624..83c4de8d 100644 --- a/apps/backend/src/routes/admin/worker-platform/orders.ts +++ b/apps/backend/src/routes/admin/worker-platform/orders.ts @@ -25,13 +25,18 @@ import { updateAdminWorkOrder, updateAdminWorkOrderSharing, } from '../../../services/worker-platform/index.js' -import { createJsonHandler, requireAdminRoles } from '../session.js' +import { + createJsonHandler, + requireAdminPermission, + requireAnyAdminPermission, + requireAdminRoles, +} from '../session.js' const router = Router() router.get( '/worker-platform/orders', - requireAdminRoles(['admin', 'operator', 'support']), + requireAdminPermission('worker_order.view'), createJsonHandler((req) => listAdminWorkOrders(req.query), { successMessage: 'ok', errorMessage: '读取接单订单失败', @@ -138,7 +143,7 @@ router.post( router.post( '/worker-platform/orders/:workOrderId/material', - requireAdminRoles(['admin', 'operator', 'support']), + requireAdminPermission('worker_order.update_material'), createJsonHandler( (req) => submitAdminWorkOrderMaterial( @@ -162,7 +167,7 @@ router.post( router.put( '/worker-platform/orders/:workOrderId', - requireAdminRoles(['admin', 'operator', 'support']), + requireAdminPermission('worker_order.edit'), createJsonHandler( (req) => updateAdminWorkOrder(String(req.params.workOrderId || ''), req.body || {}, { @@ -185,7 +190,7 @@ router.put( router.post( '/worker-platform/orders/:workOrderId/assign', - requireAdminRoles(['admin', 'operator', 'support']), + requireAdminPermission('worker_order.assign'), createJsonHandler( (req) => assignAdminWorkOrderToWorker( @@ -209,7 +214,7 @@ router.post( router.post( '/worker-platform/orders/:workOrderId/unassign', - requireAdminRoles(['admin', 'operator', 'support']), + requireAdminPermission('worker_order.assign'), createJsonHandler( (req) => unassignAdminWorkOrder( @@ -232,13 +237,14 @@ router.post( router.post( '/worker-platform/orders/:workOrderId/cancel', - requireAdminRoles(['admin', 'operator', 'support']), + requireAnyAdminPermission(['worker_order.return_to_hall', 'worker_order.cancel']), createJsonHandler( (req) => cancelAdminWorkOrder( String(req.params.workOrderId || ''), req.body || {}, req.adminSession?.username || '', + req.adminSession?.permissions || [], ), { successMessage: '撤单处理完成,打手额度已恢复', @@ -259,7 +265,7 @@ router.post( router.delete( '/worker-platform/orders/:workOrderId', - requireAdminRoles(['admin', 'operator']), + requireAdminPermission('worker_order.delete'), createJsonHandler((req) => deleteAdminWorkOrder(String(req.params.workOrderId || '')), { successMessage: '工单已删除', errorMessage: '删除工单失败', @@ -275,7 +281,7 @@ router.delete( router.post( '/worker-platform/orders/:workOrderId/reopen', - requireAdminRoles(['admin', 'operator']), + requireAdminPermission('worker_order.reopen'), createJsonHandler( (req) => reopenAdminCancelledWorkOrder( @@ -298,7 +304,7 @@ router.post( router.post( '/worker-platform/orders/:workOrderId/publish', - requireAdminRoles(['admin', 'operator', 'support']), + requireAdminPermission('worker_order.publish'), createJsonHandler( (req) => publishAdminWorkOrder(String(req.params.workOrderId || ''), req.adminSession?.username || ''), @@ -318,7 +324,7 @@ router.post( router.post( '/worker-platform/orders/:workOrderId/unpublish', - requireAdminRoles(['admin', 'operator', 'support']), + requireAdminPermission('worker_order.publish'), createJsonHandler( (req) => unpublishAdminWorkOrder( @@ -341,7 +347,7 @@ router.post( router.post( '/worker-platform/orders/:workOrderId/pin', - requireAdminRoles(['admin', 'operator', 'support']), + requireAdminPermission('worker_order.publish'), createJsonHandler( (req) => pinAdminWorkOrder( @@ -365,7 +371,7 @@ router.post( router.post( '/worker-platform/orders/:workOrderId/confirm-friend', - requireAdminRoles(['admin', 'operator', 'support']), + requireAdminPermission('worker_order.manage_problem'), createJsonHandler( (req) => confirmAdminWorkOrderFriendAdded( @@ -388,7 +394,7 @@ router.post( router.post( '/worker-platform/orders/:workOrderId/reset-gift-material', - requireAdminRoles(['admin', 'operator', 'support']), + requireAdminPermission('worker_order.manage_problem'), createJsonHandler( (req) => resetAdminWorkOrderGiftMaterial( @@ -412,7 +418,7 @@ router.post( router.get( '/worker-platform/orders/:workOrderId/sharing', - requireAdminRoles(['admin', 'operator', 'support']), + requireAdminPermission('worker_order.view'), createJsonHandler((req) => getAdminWorkOrderSharing(String(req.params.workOrderId || '')), { successMessage: 'ok', errorMessage: '读取拼单信息失败', @@ -422,7 +428,7 @@ router.get( router.get( '/worker-platform/orders/:workOrderId/events', - requireAdminRoles(['admin', 'operator', 'support']), + requireAdminPermission('worker_order.view'), createJsonHandler((req) => getAdminWorkOrderEvents(String(req.params.workOrderId || '')), { successMessage: 'ok', errorMessage: '读取工单流转记录失败', @@ -432,7 +438,7 @@ router.get( router.post( '/worker-platform/orders/:workOrderId/sharing', - requireAdminRoles(['admin', 'operator']), + requireAdminPermission('worker_order.configure_share'), createJsonHandler( (req) => updateAdminWorkOrderSharing( @@ -456,7 +462,7 @@ router.post( router.post( '/worker-platform/orders/accept-batch', - requireAdminRoles(['admin', 'operator']), + requireAdminPermission('worker_order.accept'), createJsonHandler( (req) => acceptAdminWorkOrders(req.body || {}, req.adminSession?.username || ''), { diff --git a/apps/backend/src/routes/admin/worker-platform/review.ts b/apps/backend/src/routes/admin/worker-platform/review.ts index 61de05d7..8a2ba256 100644 --- a/apps/backend/src/routes/admin/worker-platform/review.ts +++ b/apps/backend/src/routes/admin/worker-platform/review.ts @@ -6,13 +6,13 @@ import { resolveAdminWorkerOrderFeedback, reviewAdminWorkerCancelRequest, } from '../../../services/worker-platform/index.js' -import { createJsonHandler, requireAdminRoles } from '../session.js' +import { createJsonHandler, requireAdminPermission } from '../session.js' const router = Router() router.get( '/worker-platform/cancel-requests', - requireAdminRoles(['admin', 'operator', 'support']), + requireAdminPermission('worker_cancel_request.review'), createJsonHandler((req) => listAdminWorkerCancelRequests(req.query), { successMessage: 'ok', errorMessage: '读取撤单申请失败', @@ -22,7 +22,7 @@ router.get( router.get( '/worker-platform/feedbacks', - requireAdminRoles(['admin', 'operator', 'support']), + requireAdminPermission('worker_order.manage_problem'), createJsonHandler((req) => listAdminWorkerOrderFeedbacks(req.query), { successMessage: 'ok', errorMessage: '读取问题反馈失败', @@ -32,7 +32,7 @@ router.get( router.post( '/worker-platform/feedbacks/:feedbackId/resolve', - requireAdminRoles(['admin', 'operator', 'support']), + requireAdminPermission('worker_order.manage_problem'), createJsonHandler( (req) => resolveAdminWorkerOrderFeedback( @@ -50,7 +50,7 @@ router.post( router.post( '/worker-platform/cancel-requests/:requestId/review', - requireAdminRoles(['admin', 'operator', 'support']), + requireAdminPermission('worker_cancel_request.review'), createJsonHandler( (req) => reviewAdminWorkerCancelRequest( diff --git a/apps/backend/src/services/admin/admin-auth-service.ts b/apps/backend/src/services/admin/admin-auth-service.ts index d67a164e..d7d6e992 100644 --- a/apps/backend/src/services/admin/admin-auth-service.ts +++ b/apps/backend/src/services/admin/admin-auth-service.ts @@ -20,6 +20,7 @@ import { addHours, nowIso } from '../../utils/time.js' import { createHttpError } from '../../utils/http.js' import { normalizePage, normalizePageSize } from './admin-query-utils.js' import { recordAdminLoginLog } from './admin-login-log-service.js' +import { listAdminPermissionKeysForRole } from './admin-rbac-service.js' type AdminUserRow = NonNullable>> @@ -30,6 +31,7 @@ export type AdminSession = { role: AdminRole expiresAt: string sessionVersion: number + permissions: string[] } type AdminRole = 'admin' | 'operator' | 'support' @@ -230,6 +232,7 @@ export async function verifyAdminSessionToken(token: unknown): Promise { + const result = await query<{ permission_key: string }>( + ` + SELECT arp.permission_key + FROM admin_role_permissions arp + WHERE arp.role_key = $1 + ORDER BY arp.permission_key + `, + [ + String(role || '') + .trim() + .toLowerCase(), + ], + ) + return result.rows.map((row) => String(row.permission_key || '').trim()).filter(Boolean) +} + +export async function listAdminRolesWithPermissions() { + const result = await query< + PermissionRow & { role_key: string; role_name: string; role_description: string } + >( + ` + SELECT + ar.role_key, + ar.role_name, + ar.description AS role_description, + ap.permission_key, + ap.permission_name, + ap.permission_group, + ap.description + FROM admin_roles ar + CROSS JOIN admin_permissions ap + ORDER BY ar.role_key, ap.permission_group, ap.permission_key + `, + ) + + const roleMap = new Map< + string, + { + roleKey: string + roleName: string + description: string + permissions: Array<{ + permissionKey: string + permissionName: string + permissionGroup: string + description: string + enabled: boolean + }> + } + >() + + const enabled = await query<{ role_key: string; permission_key: string }>( + 'SELECT role_key, permission_key FROM admin_role_permissions', + ) + const enabledSet = new Set(enabled.rows.map((row) => `${row.role_key}:${row.permission_key}`)) + + for (const row of result.rows) { + let role = roleMap.get(row.role_key) + if (!role) { + role = { + roleKey: row.role_key, + roleName: row.role_name, + description: row.role_description, + permissions: [], + } + roleMap.set(row.role_key, role) + } + role.permissions.push({ + permissionKey: row.permission_key, + permissionName: row.permission_name, + permissionGroup: row.permission_group, + description: row.description, + enabled: enabledSet.has(`${row.role_key}:${row.permission_key}`), + }) + } + + return { roles: Array.from(roleMap.values()) } +} + +export async function updateAdminRolePermissions(roleKey: unknown, permissionKeys: unknown) { + const normalizedRole = String(roleKey || '') + .trim() + .toLowerCase() + if (!['admin', 'operator', 'support'].includes(normalizedRole)) { + throw createHttpError('角色不存在或不可修改', { + statusCode: 400, + errorCode: 'admin_role_invalid', + }) + } + + if (!Array.isArray(permissionKeys)) { + throw createHttpError('权限列表格式无效', { + statusCode: 400, + errorCode: 'admin_permissions_invalid', + }) + } + + const requested = Array.from( + new Set( + permissionKeys + .map((item) => String(item || '').trim()) + .filter((item): item is AdminPermissionKey => + (ADMIN_PERMISSION_KEYS as readonly string[]).includes(item), + ), + ), + ) + + if (normalizedRole === 'admin' && requested.length !== ADMIN_PERMISSION_KEYS.length) { + throw createHttpError('管理员角色必须保留全部权限', { + statusCode: 409, + errorCode: 'admin_role_full_permissions_required', + }) + } + + await withTransaction(async (client) => { + await client.query('DELETE FROM admin_role_permissions WHERE role_key = $1', [normalizedRole]) + if (requested.length > 0) { + await client.query( + ` + INSERT INTO admin_role_permissions (role_key, permission_key) + SELECT $1, permission_key + FROM admin_permissions + WHERE permission_key = ANY($2::text[]) + ON CONFLICT DO NOTHING + `, + [normalizedRole, requested], + ) + } + }) + + return (await listAdminRolesWithPermissions()).roles.find( + (role) => role.roleKey === normalizedRole, + ) +} diff --git a/apps/backend/src/services/worker-platform/admin-work-order-cancellation-service.ts b/apps/backend/src/services/worker-platform/admin-work-order-cancellation-service.ts index 0d9ac177..84a530dc 100644 --- a/apps/backend/src/services/worker-platform/admin-work-order-cancellation-service.ts +++ b/apps/backend/src/services/worker-platform/admin-work-order-cancellation-service.ts @@ -55,6 +55,7 @@ export async function cancelAdminWorkOrder( workOrderId: number | string, payload: JsonObject = {}, actorName = '', + actorPermissions: string[] = [], ) { const workOrder = await getRequiredWorkOrder(workOrderId) if (workOrder.status !== WORK_ORDER_STATUS.IN_PROGRESS || !workOrder.assigned_worker_id) { @@ -70,6 +71,14 @@ export async function cancelAdminWorkOrder( errorCode: 'work_order_cancel_action_required', }) } + const requiredPermission = + action === 'return_to_hall' ? 'worker_order.return_to_hall' : 'worker_order.cancel' + if (!actorPermissions.includes(requiredPermission)) { + throw createHttpError('当前账号没有此撤单权限', { + statusCode: 403, + errorCode: 'admin_permission_denied', + }) + } const reason = String(payload.reason || '').trim() if (action === 'cancel_order' && !reason) { throw createHttpError('取消订单时请填写退款或撤单原因', { diff --git a/apps/frontend/src/layouts/AdminLayout.tsx b/apps/frontend/src/layouts/AdminLayout.tsx index de21197f..c8ea6aa8 100644 --- a/apps/frontend/src/layouts/AdminLayout.tsx +++ b/apps/frontend/src/layouts/AdminLayout.tsx @@ -32,10 +32,11 @@ import { } from 'antd' import type { MenuProps } from 'antd' import { useQuery, useQueryClient } from '@tanstack/react-query' -import { useMemo, useState } from 'react' +import { useEffect, useMemo, useState } from 'react' import { Outlet, useLocation, useNavigate } from 'react-router' import { + fetchAdminSession, fetchAdminNotifications, fetchDevMockStatus, logoutAdmin, @@ -47,6 +48,7 @@ import { getAdminToken, getAdminTokenExpiresAt, getAdminUsername, + setAdminPermissions, } from '@/utils/admin-auth' import { formatAdminDateTime } from '@/utils/admin-time' import { useRealtimeEvents, type RealtimeEvent } from '@/hooks/useRealtimeEvents' @@ -69,6 +71,19 @@ export default function AdminLayout() { () => window.localStorage.getItem(NOTIFICATION_SOUND_KEY) === '1', ) + const adminSessionQuery = useQuery({ + queryKey: ['admin-session'], + queryFn: fetchAdminSession, + retry: false, + }) + + useEffect(() => { + const session = adminSessionQuery.data?.data + if (session?.authenticated && session.user) { + setAdminPermissions(session.user.permissions || []) + } + }, [adminSessionQuery.data]) + const devMockStatusQuery = useQuery({ queryKey: ['admin-dev-mock-status'], queryFn: () => fetchDevMockStatus(), @@ -546,7 +561,12 @@ function isRecord(value: unknown): value is Record { } type NotificationAlertKind = - 'withdraw' | 'recharge' | 'acceptance' | 'material' | 'reminder' | 'default' + | 'withdraw' + | 'recharge' + | 'acceptance' + | 'material' + | 'reminder' + | 'default' function playNotificationSound(kind: NotificationAlertKind) { try { diff --git a/apps/frontend/src/pages/admin/AdminUsersPage.tsx b/apps/frontend/src/pages/admin/AdminUsersPage.tsx index 8629234b..9231df21 100644 --- a/apps/frontend/src/pages/admin/AdminUsersPage.tsx +++ b/apps/frontend/src/pages/admin/AdminUsersPage.tsx @@ -24,10 +24,12 @@ import PageHeader from '@/components/admin/PageHeader' import StatusTag from '@/components/admin/StatusTag' import { createAdminUser, + fetchAdminRolePermissions, fetchAdminUsers, resetAdminUserPassword, updateAdminUserRole, updateAdminUserStatus, + updateAdminRolePermissions, } from '@/services/admin' import type { AdminRole, AdminUserListItem } from '@/types/admin' import { getAdminUserId, hasAdminRole } from '@/utils/admin-auth' @@ -83,11 +85,33 @@ export default function AdminUsersPage() { queryFn: () => fetchAdminUsers(queryParams), }) const data = query.data?.data + const rolePermissionsQuery = useQuery({ + queryKey: ['admin-role-permissions'], + enabled: isAdmin, + queryFn: () => fetchAdminRolePermissions(), + }) async function reloadUsers() { await queryClient.invalidateQueries({ queryKey: ['admin-users'] }) } + async function toggleRolePermission(roleKey: string, permissionKey: string, enabled: boolean) { + const role = rolePermissionsQuery.data?.data.roles.find((item) => item.roleKey === roleKey) + if (!role || roleKey === 'admin') return + const permissions = role.permissions + .filter((item) => item.enabled) + .map((item) => item.permissionKey) + .filter((item) => item !== permissionKey) + if (enabled) permissions.push(permissionKey) + try { + await updateAdminRolePermissions(roleKey, permissions) + await queryClient.invalidateQueries({ queryKey: ['admin-role-permissions'] }) + message.success('角色权限已更新') + } catch (error) { + message.error(error instanceof Error ? error.message : '更新角色权限失败') + } + } + async function submitCreate(values: CreateUserForm) { setCreating(true) @@ -420,6 +444,37 @@ export default function AdminUsersPage() { })} /> + + + + {(rolePermissionsQuery.data?.data.roles || []).map((role) => ( +
+ {role.roleName} +
+ + {role.permissions.map((permission) => ( + + ))} + +
+
+ ))} +
+
) } diff --git a/apps/frontend/src/pages/admin/panels/WorkOrdersPanel.tsx b/apps/frontend/src/pages/admin/panels/WorkOrdersPanel.tsx index 8b2675f5..7d7f8a3a 100644 --- a/apps/frontend/src/pages/admin/panels/WorkOrdersPanel.tsx +++ b/apps/frontend/src/pages/admin/panels/WorkOrdersPanel.tsx @@ -53,6 +53,7 @@ import { deductAdminWorkOrderPendingDeposit, deleteAdminWorkOrder, fetchAdminWorkCategories, + fetchAdminSession, fetchAdminWorkOrderSharing, fetchAdminWorkerLevels, fetchAdminWorkerUsers, @@ -72,7 +73,7 @@ import type { UploadedFile, WorkOrder, WorkOrderShare } from '@/types/worker-pla import { AdminRangePicker } from '@/components/admin/AdminDatePicker' import dayjs, { ADMIN_DATE_FORMAT } from '@/lib/dayjs' import { ADMIN_DEFAULT_PAGE_SIZE, buildAdminTablePagination } from '@/utils/admin-pagination' -import { hasAdminRole } from '@/utils/admin-auth' +import { hasAdminPermission, hasAdminRole } from '@/utils/admin-auth' import { formatAdminDateTime } from '@/utils/admin-time' import { DeadlineCountdown, @@ -151,6 +152,51 @@ export default function WorkOrdersPanel() { const queryClient = useQueryClient() const canEditRequirementFields = hasAdminRole('operator') const canViewStatistics = hasAdminRole('operator') + const adminSessionQuery = useQuery({ + queryKey: ['admin-session'], + queryFn: fetchAdminSession, + retry: false, + }) + const sessionPermissions = adminSessionQuery.data?.data.user.permissions + const canReturnToHall = sessionPermissions + ? sessionPermissions.includes('worker_order.return_to_hall') + : hasAdminPermission('worker_order.return_to_hall') + const canCancelOrder = sessionPermissions + ? sessionPermissions.includes('worker_order.cancel') + : hasAdminPermission('worker_order.cancel') + const canCancelShare = sessionPermissions + ? sessionPermissions.includes('worker_order.cancel_share') + : hasAdminPermission('worker_order.cancel_share') + const canEditOrder = sessionPermissions + ? sessionPermissions.includes('worker_order.edit') + : hasAdminPermission('worker_order.edit') + const canAssignOrder = sessionPermissions + ? sessionPermissions.includes('worker_order.assign') + : hasAdminPermission('worker_order.assign') + const canDeleteOrder = sessionPermissions + ? sessionPermissions.includes('worker_order.delete') + : hasAdminPermission('worker_order.delete') + const canReopenOrder = sessionPermissions + ? sessionPermissions.includes('worker_order.reopen') + : hasAdminPermission('worker_order.reopen') + const canPublishOrder = sessionPermissions + ? sessionPermissions.includes('worker_order.publish') + : hasAdminPermission('worker_order.publish') + const canAcceptOrder = sessionPermissions + ? sessionPermissions.includes('worker_order.accept') + : hasAdminPermission('worker_order.accept') + const canUpdateMaterial = sessionPermissions + ? sessionPermissions.includes('worker_order.update_material') + : hasAdminPermission('worker_order.update_material') + const canManageProblem = sessionPermissions + ? sessionPermissions.includes('worker_order.manage_problem') + : hasAdminPermission('worker_order.manage_problem') + const canDeductDeposit = sessionPermissions + ? sessionPermissions.includes('worker_order.deduct_deposit') + : hasAdminPermission('worker_order.deduct_deposit') + const canConfigureSharing = sessionPermissions + ? sessionPermissions.includes('worker_order.configure_share') + : hasAdminPermission('worker_order.configure_share') const [statuses, setStatuses] = useState([]) const [targetWorkOrderId, setTargetWorkOrderId] = useState() const [keyword, setKeyword] = useState('') @@ -976,17 +1022,17 @@ export default function WorkOrdersPanel() { render: (_, row) => ( - {row.capabilities?.canEdit ? ( + {row.capabilities?.canEdit && canEditOrder ? ( ) : null} - {row.capabilities?.canDelete ? ( + {row.capabilities?.canDelete && canDeleteOrder ? ( ) : null} - {row.capabilities?.canReopen ? ( + {row.capabilities?.canReopen && canReopenOrder ? ( ) : null} - {row.sharing?.enabled || row.capabilities?.canConfigureSharing ? ( + {row.sharing?.enabled || + (row.capabilities?.canConfigureSharing && canConfigureSharing) ? ( ) : null} - {row.capabilities?.canAssign ? ( + {row.capabilities?.canAssign && canAssignOrder ? ( ) : null} - {row.capabilities?.canCancel ? ( + {row.capabilities?.canCancel && (canReturnToHall || canCancelOrder) ? ( ) : null} - {row.capabilities?.canUpdateMaterial ? ( + {row.capabilities?.canUpdateMaterial && canUpdateMaterial ? ( ) : null} - {row.capabilities?.canPublish ? ( + {row.capabilities?.canPublish && canPublishOrder ? ( ) : null} - {row.capabilities?.canPin ? ( + {row.capabilities?.canPin && canPublishOrder ? ( ) : null} - {row.capabilities?.canUnpublish ? ( + {row.capabilities?.canUnpublish && canPublishOrder ? ( ) : null} - {row.capabilities?.canAccept ? ( + {row.capabilities?.canAccept && canAcceptOrder ? ( ) : null} - {row.capabilities?.canDeduct && Number(row.pendingUnfreezeAmount || 0) > 0 ? ( + {row.capabilities?.canDeduct && + canDeductDeposit && + Number(row.pendingUnfreezeAmount || 0) > 0 ? ( @@ -1098,7 +1147,7 @@ export default function WorkOrdersPanel() { 售后 ) : null} - {row.capabilities?.canConfirmFriend ? ( + {row.capabilities?.canConfirmFriend && canManageProblem ? ( ) : null} - {row.capabilities?.canResetGiftFlow ? ( + {row.capabilities?.canResetGiftFlow && canManageProblem ? ( ) : null} - {row.capabilities?.canMarkProblem ? ( + {row.capabilities?.canMarkProblem && canManageProblem ? ( ) : null} - {row.capabilities?.canResolveProblem ? ( + {row.capabilities?.canResolveProblem && canManageProblem ? ( @@ -1470,7 +1519,7 @@ export default function WorkOrdersPanel() { {formatGiftPhase(detailOrder.gift.phase)} - {detailOrder.capabilities?.canConfirmFriend ? ( + {detailOrder.capabilities?.canConfirmFriend && canManageProblem ? ( + {canCancelShare ? ( + + ) : null} ) }, diff --git a/apps/frontend/src/services/admin/users.ts b/apps/frontend/src/services/admin/users.ts index 039a1ddb..4b3c73d4 100644 --- a/apps/frontend/src/services/admin/users.ts +++ b/apps/frontend/src/services/admin/users.ts @@ -1,6 +1,31 @@ -import { apiGet, apiPost } from '@/lib/http' +import { apiGet, apiPost, apiPut } from '@/lib/http' import type { AdminPagination, AdminUserListItem } from '@/types/admin' +export type AdminRolePermission = { + permissionKey: string + permissionName: string + permissionGroup: string + description: string + enabled: boolean +} + +export type AdminRolePermissions = { + roleKey: string + roleName: string + description: string + permissions: AdminRolePermission[] +} + +export function fetchAdminRolePermissions() { + return apiGet<{ roles: AdminRolePermissions[] }>('/api/v1/admin/rbac') +} + +export function updateAdminRolePermissions(roleKey: string, permissions: string[]) { + return apiPut(`/api/v1/admin/rbac/roles/${roleKey}/permissions`, { + permissions, + }) +} + export function fetchAdminUsers(params?: Record) { return apiGet<{ items: AdminUserListItem[]; pagination: AdminPagination }>( '/api/v1/admin/users', diff --git a/apps/frontend/src/types/admin/auth.ts b/apps/frontend/src/types/admin/auth.ts index d9d23fc7..c08e8f24 100644 --- a/apps/frontend/src/types/admin/auth.ts +++ b/apps/frontend/src/types/admin/auth.ts @@ -8,6 +8,7 @@ export interface AdminLoginResponse { userId: number username: string role: AdminRole + permissions?: string[] } } @@ -18,5 +19,6 @@ export interface AdminSessionSummary { userId: number username: string role: AdminRole + permissions?: string[] } } diff --git a/apps/frontend/src/utils/admin-auth.ts b/apps/frontend/src/utils/admin-auth.ts index cbb374e6..20280479 100644 --- a/apps/frontend/src/utils/admin-auth.ts +++ b/apps/frontend/src/utils/admin-auth.ts @@ -3,6 +3,7 @@ const ADMIN_EXPIRES_AT_KEY = 'order-site-admin-token-expires-at' const ADMIN_USER_ID_KEY = 'order-site-admin-user-id' const ADMIN_USERNAME_KEY = 'order-site-admin-username' const ADMIN_ROLE_KEY = 'order-site-admin-role' +const ADMIN_PERMISSIONS_KEY = 'order-site-admin-permissions' const ADMIN_ROLE_LEVEL: Record<'support' | 'operator' | 'admin', number> = { support: 1, @@ -36,6 +37,26 @@ export function getAdminRole() { return 'support' } +export function getAdminPermissions(): string[] { + try { + const value = JSON.parse(localStorage.getItem(ADMIN_PERMISSIONS_KEY) || '[]') + return Array.isArray(value) + ? value.map((item) => String(item || '').trim()).filter(Boolean) + : [] + } catch { + return [] + } +} + +export function setAdminPermissions(permissions: string[]) { + localStorage.setItem(ADMIN_PERMISSIONS_KEY, JSON.stringify(permissions || [])) +} + +export function hasAdminPermission(permission: string) { + if (!hasAdminSession()) return false + return getAdminPermissions().includes(String(permission || '').trim()) +} + export function hasAdminRole(role: 'admin' | 'operator' | 'support') { if (!hasAdminSession()) { return false @@ -47,7 +68,12 @@ export function hasAdminRole(role: 'admin' | 'operator' | 'support') { export function setAdminSession( token: string, expiresAt: string, - user?: { userId: number; username: string; role: 'admin' | 'operator' | 'support' }, + user?: { + userId: number + username: string + role: 'admin' | 'operator' | 'support' + permissions?: string[] + }, ) { localStorage.setItem(ADMIN_TOKEN_KEY, token) localStorage.setItem(ADMIN_EXPIRES_AT_KEY, expiresAt) @@ -63,6 +89,8 @@ export function setAdminSession( if (user?.role) { localStorage.setItem(ADMIN_ROLE_KEY, user.role) } + + localStorage.setItem(ADMIN_PERMISSIONS_KEY, JSON.stringify(user?.permissions || [])) } export function clearAdminSession() { @@ -71,6 +99,7 @@ export function clearAdminSession() { localStorage.removeItem(ADMIN_USER_ID_KEY) localStorage.removeItem(ADMIN_USERNAME_KEY) localStorage.removeItem(ADMIN_ROLE_KEY) + localStorage.removeItem(ADMIN_PERMISSIONS_KEY) } export function hasAdminSession() {