diff --git a/apps/admin-web/src/pages/CitiesPage.tsx b/apps/admin-web/src/pages/CitiesPage.tsx index 86edf46..fca7551 100644 --- a/apps/admin-web/src/pages/CitiesPage.tsx +++ b/apps/admin-web/src/pages/CitiesPage.tsx @@ -17,7 +17,7 @@ import { } from 'antd'; import type { ColumnsType } from 'antd/es/table'; import { WAREHOUSE_MANAGER_LABELS, WarehouseManagerType } from '@dukang/shared-types'; -import { request, type Paginated } from '../lib/api'; +import { request, type HqProfile, type Paginated } from '../lib/api'; import { parseProvinceCityCodes, type ParsedProvinceCity } from '../lib/china-region'; import { ADMIN_OPTIONS_PAGE_SIZE, CITY_STATUS_LABELS, fmtTime } from '../lib/constants'; import { useAdminList } from '../lib/useAdminList'; @@ -122,6 +122,12 @@ export default function CitiesPage() { const [deleteTarget, setDeleteTarget] = useState(null); const [deletePreview, setDeletePreview] = useState(null); const [confirmName, setConfirmName] = useState(''); + const [profile, setProfile] = useState(null); + const canDeleteCities = (profile?.permissionKeys ?? []).includes('cities_delete'); + + useEffect(() => { + request('/admin/auth/me').then(setProfile).catch(() => {}); + }, []); const loadPartners = useCallback(async (cityId: string) => { const res = await request>(`/admin/partners?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}&cityId=${cityId}`); @@ -233,9 +239,11 @@ export default function CitiesPage() { - + {canDeleteCities ? ( + + ) : null} ), }, @@ -356,24 +364,26 @@ export default function CitiesPage() { setDetail(refreshed); void reload(); }}>保存 - + {canDeleteCities ? ( + + ) : null} ), diff --git a/apps/admin-web/src/pages/HqPermissionsPage.tsx b/apps/admin-web/src/pages/HqPermissionsPage.tsx index a131af9..084a5cf 100644 --- a/apps/admin-web/src/pages/HqPermissionsPage.tsx +++ b/apps/admin-web/src/pages/HqPermissionsPage.tsx @@ -172,7 +172,8 @@ export default function HqPermissionsPage() {
权限分配 - 按角色配置基础权限;按用户可追加专属权限。最终生效权限 = 角色权限 ∪ 用户权限(超级管理员始终拥有全部权限)。 + 按角色配置基础权限;按用户可追加专属权限。最终生效权限 = 角色权限 ∪ 用户权限。 + 超级管理员默认拥有除「危险操作」外的全部权限;删除用户/订单/城市需在「按用户分配」中单独勾选(默认均无)。 「系统设置」已拆分为各配置分组;「财务」对应门店/合伙人/酒厂账单。 @@ -198,7 +199,11 @@ export default function HqPermissionsPage() { {role === 'SUPER_ADMIN' ? ( - + ) : ( <> @@ -227,12 +232,10 @@ export default function HqPermissionsPage() { value={accountId} onChange={setAccountId} optionFilterProp="label" - options={accounts - .filter((a) => a.adminRole !== 'SUPER_ADMIN') - .map((a) => ({ - value: a.id, - label: `${a.name} · ${a.loginName || a.phone} · ${ROLE_LABELS[a.adminRole] || a.adminRole}`, - }))} + options={accounts.map((a) => ({ + value: a.id, + label: `${a.name} · ${a.loginName || a.phone} · ${ROLE_LABELS[a.adminRole] || a.adminRole}`, + }))} /> @@ -252,7 +255,7 @@ export default function HqPermissionsPage() { })} - 下方勾选为用户专属追加权限(保存后与角色权限合并生效)。 + 下方勾选为用户专属追加权限(保存后与角色权限合并生效)。危险操作(删除用户/订单/城市)默认不授予,需在此勾选。 diff --git a/apps/admin-web/src/pages/OrdersPage.tsx b/apps/admin-web/src/pages/OrdersPage.tsx index 18edfb9..f077be4 100644 --- a/apps/admin-web/src/pages/OrdersPage.tsx +++ b/apps/admin-web/src/pages/OrdersPage.tsx @@ -18,7 +18,7 @@ import { message, } from 'antd'; import type { ColumnsType } from 'antd/es/table'; -import { request, type AdminOrderItem, type AdminOrderRow, type Paginated } from '../lib/api'; +import { request, type AdminOrderItem, type AdminOrderRow, type HqProfile, type Paginated } from '../lib/api'; import { ADMIN_OPTIONS_PAGE_SIZE, DELIVERY_PROVIDER_LABELS, @@ -154,6 +154,7 @@ export default function OrdersPage() { const [form] = Form.useForm(); const [shipForm] = Form.useForm(); const [logisticsForm] = Form.useForm(); + const [profile, setProfile] = useState(null); const [data, setData] = useState | null>(null); const [loading, setLoading] = useState(false); const [page, setPage] = useState(1); @@ -174,12 +175,17 @@ export default function OrdersPage() { const [shipTarget, setShipTarget] = useState(null); const [shipMode, setShipMode] = useState('EXPRESS'); const [warehouses, setWarehouses] = useState([]); + const canDeleteOrders = (profile?.permissionKeys ?? []).includes('orders_delete'); const selectedOrders = useMemo( () => (data?.items ?? []).filter((row) => selectedRowKeys.includes(row.id)), [data?.items, selectedRowKeys], ); + useEffect(() => { + request('/admin/auth/me').then(setProfile).catch(() => {}); + }, []); + async function openRedeemDetail(redeemId: string) { setRedeemDetailLoading(true); setRedeemDrawerOpen(true); @@ -454,13 +460,15 @@ export default function OrdersPage() {
订单监控 - + {canDeleteOrders ? ( + + ) : null}
{ setPage(1); void load(); }}> @@ -504,10 +512,10 @@ export default function OrdersPage() { columns={columns} dataSource={data?.items ?? []} scroll={{ x: 1500 }} - rowSelection={{ + rowSelection={canDeleteOrders ? { selectedRowKeys, onChange: (keys) => setSelectedRowKeys(keys as string[]), - }} + } : undefined} pagination={{ current: page, pageSize, diff --git a/apps/admin-web/src/pages/UsersPage.tsx b/apps/admin-web/src/pages/UsersPage.tsx index 90b2072..18661b6 100644 --- a/apps/admin-web/src/pages/UsersPage.tsx +++ b/apps/admin-web/src/pages/UsersPage.tsx @@ -18,7 +18,7 @@ import { } from 'antd'; import type { ColumnsType } from 'antd/es/table'; import { USER_SOURCE_TYPE_LABELS, type UserSourceType } from '@dukang/shared-types'; -import { request, type AdminUserRow, type Paginated } from '../lib/api'; +import { request, type AdminUserRow, type HqProfile, type Paginated } from '../lib/api'; import { ORDER_STATUS_LABELS, fmtTime, maskPhone } from '../lib/constants'; type UserOrderRow = { @@ -66,6 +66,7 @@ export default function UsersPage() { const navigate = useNavigate(); const location = useLocation(); const [form] = Form.useForm(); + const [profile, setProfile] = useState(null); const [data, setData] = useState | null>(null); const [loading, setLoading] = useState(false); const [page, setPage] = useState(1); @@ -82,6 +83,11 @@ export default function UsersPage() { const [batchPreviewLoading, setBatchPreviewLoading] = useState(false); const [batchDeleting, setBatchDeleting] = useState(false); const [batchRiskAck, setBatchRiskAck] = useState(false); + const canDeleteUsers = (profile?.permissionKeys ?? []).includes('users_delete'); + + useEffect(() => { + request('/admin/auth/me').then(setProfile).catch(() => {}); + }, []); const load = useCallback(async () => { setLoading(true); @@ -309,13 +315,15 @@ export default function UsersPage() {
用户监控 - + {canDeleteUsers ? ( + + ) : null} { setPage(1); void load(); }}> @@ -353,11 +361,11 @@ export default function UsersPage() { columns={columns} dataSource={data?.items ?? []} scroll={{ x: 1500 }} - rowSelection={{ + rowSelection={canDeleteUsers ? { selectedRowKeys, preserveSelectedRowKeys: true, onChange: (keys) => setSelectedRowKeys(keys as string[]), - }} + } : undefined} pagination={{ current: page, pageSize, @@ -375,9 +383,9 @@ export default function UsersPage() { width={640} open={drawerOpen} onClose={() => setDrawerOpen(false)} - extra={detail && ( + extra={detail && canDeleteUsers ? ( - )} + ) : undefined} > {detail && ( <> diff --git a/packages/shared-types/src/hq-permissions.ts b/packages/shared-types/src/hq-permissions.ts index c8fe87c..1856d29 100644 --- a/packages/shared-types/src/hq-permissions.ts +++ b/packages/shared-types/src/hq-permissions.ts @@ -19,6 +19,9 @@ export const HQ_PERMISSION_CATALOG = [ { key: 'knowledge_bases', label: '知识库', group: '业务' }, { key: 'resources', label: 'OSS 资源库', group: '业务' }, { key: 'logs', label: '日志', group: '业务' }, + { key: 'users_delete', label: '删除用户', group: '危险操作' }, + { key: 'orders_delete', label: '删除订单', group: '危险操作' }, + { key: 'cities_delete', label: '删除城市', group: '危险操作' }, { key: 'hq_permissions', label: '权限分配', group: '管理' }, { key: 'hq_accounts', label: 'HQ 账户', group: '管理' }, { key: 'system_settings_feature', label: '功能开关', group: '系统设置' }, @@ -33,6 +36,21 @@ export const HQ_PERMISSION_CATALOG = [ export type HqPermissionKey = (typeof HQ_PERMISSION_CATALOG)[number]['key']; +/** 危险操作:角色默认与超管自动权限均不含,需在权限分配中显式勾选 */ +export const HQ_DANGEROUS_PERMISSION_KEYS = [ + 'users_delete', + 'orders_delete', + 'cities_delete', +] as const satisfies readonly HqPermissionKey[]; + +export function isHqDangerousPermission(key: string): boolean { + return (HQ_DANGEROUS_PERMISSION_KEYS as readonly string[]).includes(key); +} + +export function hqBasePermissionKeys(): HqPermissionKey[] { + return HQ_PERMISSION_CATALOG.map((p) => p.key).filter((k) => !isHqDangerousPermission(k)); +} + /** 系统配置 registry group → 权限 key */ export const SYSTEM_CONFIG_GROUP_PERMISSION: Record = { feature: 'system_settings_feature', @@ -76,7 +94,7 @@ export const HQ_ADMIN_ROLES = [ ] as const; export const HQ_ROLE_DEFAULT_PERMISSIONS: Record = { - SUPER_ADMIN: HQ_PERMISSION_CATALOG.map((p) => p.key), + SUPER_ADMIN: hqBasePermissionKeys(), OPS: [ 'dashboard', 'users', diff --git a/server/dukang-api/src/common/guards/hq-permission.guard.ts b/server/dukang-api/src/common/guards/hq-permission.guard.ts index 8e7d4c2..846d381 100644 --- a/server/dukang-api/src/common/guards/hq-permission.guard.ts +++ b/server/dukang-api/src/common/guards/hq-permission.guard.ts @@ -7,10 +7,10 @@ import { } from '@nestjs/common'; import { Reflector } from '@nestjs/core'; import { - HQ_PERMISSION_CATALOG, HQ_ROLE_DEFAULT_PERMISSIONS, expandHqPermissionKeys, hasAnySystemSettingsPermission, + hqBasePermissionKeys, type HqPermissionKey, } from '@dukang/shared-types'; import { PrismaService } from '../prisma/prisma.module'; @@ -35,30 +35,29 @@ export class HqPermissionsResolver { if (!account || account.status !== 'ACTIVE') { throw new ForbiddenException('HQ 账号不可用'); } + + const userRows = await this.prisma.hqAccountPermission.findMany({ + where: { hqAccountId: actorId }, + select: { permissionKey: true }, + }); + const userKeys = userRows.map((r) => r.permissionKey); + if (account.adminRole === 'SUPER_ADMIN') { - return HQ_PERMISSION_CATALOG.map((p) => p.key); + // 超管默认拥有业务/管理权限,但不含危险操作;危险权限需按用户显式勾选 + return expandHqPermissionKeys([...hqBasePermissionKeys(), ...userKeys]); } - const [roleRows, userRows] = await Promise.all([ - this.prisma.hqRolePermission.findMany({ - where: { adminRole: account.adminRole }, - select: { permissionKey: true }, - }), - this.prisma.hqAccountPermission.findMany({ - where: { hqAccountId: actorId }, - select: { permissionKey: true }, - }), - ]); + const roleRows = await this.prisma.hqRolePermission.findMany({ + where: { adminRole: account.adminRole }, + select: { permissionKey: true }, + }); const roleKeys = roleRows.length > 0 ? roleRows.map((r) => r.permissionKey) : [...(HQ_ROLE_DEFAULT_PERMISSIONS[account.adminRole] ?? [])]; - return expandHqPermissionKeys([ - ...roleKeys, - ...userRows.map((r) => r.permissionKey), - ]); + return expandHqPermissionKeys([...roleKeys, ...userKeys]); } } diff --git a/server/dukang-api/src/modules/ops/admin-cities.controller.ts b/server/dukang-api/src/modules/ops/admin-cities.controller.ts index f486916..f94f3bd 100644 --- a/server/dukang-api/src/modules/ops/admin-cities.controller.ts +++ b/server/dukang-api/src/modules/ops/admin-cities.controller.ts @@ -1,6 +1,10 @@ import { Body, Controller, Delete, Get, Param, Post, Put, Query, UseGuards } from '@nestjs/common'; import { IsString, MinLength } from 'class-validator'; import { HqAuthGuard } from '../../common/guards/hq-auth.guard'; +import { + HqPermissionGuard, + RequireHqPermissions, +} from '../../common/guards/hq-permission.guard'; import { HqOperation } from '../../common/hq-operation/hq-operation.decorator'; import { HqOperationAction } from '../../common/hq-operation/hq-operation.constants'; import { AdminCitiesService } from './admin-cities.service'; @@ -24,6 +28,8 @@ export class AdminCitiesController { } @Get(':id/delete-preview') + @UseGuards(HqPermissionGuard) + @RequireHqPermissions('cities_delete') deletePreview(@Param('id') id: string) { return this.service.deletePreview(BigInt(id)); } @@ -51,6 +57,8 @@ export class AdminCitiesController { } @Delete(':id') + @UseGuards(HqPermissionGuard) + @RequireHqPermissions('cities_delete') @HqOperation({ action: HqOperationAction.CITY_DELETE, refType: 'CITY', diff --git a/server/dukang-api/src/modules/ops/admin-hq-permissions.service.ts b/server/dukang-api/src/modules/ops/admin-hq-permissions.service.ts index 5427992..d574809 100644 --- a/server/dukang-api/src/modules/ops/admin-hq-permissions.service.ts +++ b/server/dukang-api/src/modules/ops/admin-hq-permissions.service.ts @@ -4,6 +4,7 @@ import { HQ_ROLE_DEFAULT_PERMISSIONS, LEGACY_SYSTEM_SETTINGS_KEY, expandHqPermissionKeys, + hqBasePermissionKeys, type HqPermissionKey, } from '@dukang/shared-types'; import { PrismaService } from '../../common/prisma/prisma.module'; @@ -49,7 +50,7 @@ export class AdminHqPermissionsService { async saveRolePermissions(role: string, permissionKeys: string[]) { if (role === 'SUPER_ADMIN') { - throw new BadRequestException('超级管理员拥有全部权限,无需配置'); + throw new BadRequestException('超级管理员基础权限固定,危险操作请按用户单独授权'); } assertPermissionKeys(permissionKeys); const normalized = expandHqPermissionKeys(permissionKeys); @@ -73,24 +74,28 @@ export class AdminHqPermissionsService { select: { id: true, name: true, phone: true, loginName: true, adminRole: true, status: true }, }); if (!account) throw new NotFoundException('HQ 账号不存在'); + + const userPerms = await this.prisma.hqAccountPermission.findMany({ + where: { hqAccountId: accountId }, + select: { permissionKey: true }, + }); + const userPermissionKeys = expandHqPermissionKeys(userPerms.map((p) => p.permissionKey)); + if (account.adminRole === 'SUPER_ADMIN') { + const rolePermissionKeys = hqBasePermissionKeys(); + const effectivePermissionKeys = [ + ...new Set([...rolePermissionKeys, ...userPermissionKeys]), + ] as HqPermissionKey[]; return serializeBigInt({ account, - permissionKeys: HQ_PERMISSION_CATALOG.map((p) => p.key), - rolePermissionKeys: HQ_PERMISSION_CATALOG.map((p) => p.key), - userPermissionKeys: [], - effectivePermissionKeys: HQ_PERMISSION_CATALOG.map((p) => p.key), + permissionKeys: userPermissionKeys, + rolePermissionKeys, + userPermissionKeys, + effectivePermissionKeys, }); } - const [rolePerms, userPerms] = await Promise.all([ - this.getRolePermissions(account.adminRole), - this.prisma.hqAccountPermission.findMany({ - where: { hqAccountId: accountId }, - select: { permissionKey: true }, - }), - ]); - const userPermissionKeys = expandHqPermissionKeys(userPerms.map((p) => p.permissionKey)); + const rolePerms = await this.getRolePermissions(account.adminRole); const effectivePermissionKeys = [ ...new Set([...rolePerms.permissionKeys, ...userPermissionKeys]), ] as HqPermissionKey[]; @@ -107,9 +112,6 @@ export class AdminHqPermissionsService { async saveAccountPermissions(accountId: bigint, permissionKeys: string[]) { const account = await this.prisma.hqAccount.findUnique({ where: { id: accountId } }); if (!account) throw new NotFoundException('HQ 账号不存在'); - if (account.adminRole === 'SUPER_ADMIN') { - throw new BadRequestException('超级管理员拥有全部权限,无需配置'); - } assertPermissionKeys(permissionKeys); const normalized = expandHqPermissionKeys(permissionKeys); await this.prisma.$transaction([ diff --git a/server/dukang-api/src/modules/ops/admin-orders.controller.ts b/server/dukang-api/src/modules/ops/admin-orders.controller.ts index eb9031b..0622ea3 100644 --- a/server/dukang-api/src/modules/ops/admin-orders.controller.ts +++ b/server/dukang-api/src/modules/ops/admin-orders.controller.ts @@ -1,6 +1,9 @@ import { Body, Controller, Get, Param, Post, Put, Query, UseGuards } from '@nestjs/common'; import { HqAuthGuard } from '../../common/guards/hq-auth.guard'; -import { SuperAdminGuard } from '../../common/guards/super-admin.guard'; +import { + HqPermissionGuard, + RequireHqPermissions, +} from '../../common/guards/hq-permission.guard'; import { HqOperation } from '../../common/hq-operation/hq-operation.decorator'; import { HqOperationAction } from '../../common/hq-operation/hq-operation.constants'; import { AdminOrdersService } from './admin-orders.service'; @@ -18,7 +21,8 @@ export class AdminOrdersController { } @Post('batch-delete') - @UseGuards(SuperAdminGuard) + @UseGuards(HqPermissionGuard) + @RequireHqPermissions('orders_delete') @HqOperation({ action: HqOperationAction.ORDER_BATCH_DELETE, refType: 'ORDER', diff --git a/server/dukang-api/src/modules/ops/admin-users.controller.ts b/server/dukang-api/src/modules/ops/admin-users.controller.ts index c34a6ad..1df998a 100644 --- a/server/dukang-api/src/modules/ops/admin-users.controller.ts +++ b/server/dukang-api/src/modules/ops/admin-users.controller.ts @@ -1,6 +1,9 @@ import { Body, Controller, Delete, Get, Param, Post, Query, UseGuards } from '@nestjs/common'; import { HqAuthGuard } from '../../common/guards/hq-auth.guard'; -import { SuperAdminGuard } from '../../common/guards/super-admin.guard'; +import { + HqPermissionGuard, + RequireHqPermissions, +} from '../../common/guards/hq-permission.guard'; import { HqOperation } from '../../common/hq-operation/hq-operation.decorator'; import { HqOperationAction } from '../../common/hq-operation/hq-operation.constants'; import { AdminUsersService } from './admin-users.service'; @@ -18,13 +21,15 @@ export class AdminUsersController { } @Post('batch-delete/preview') - @UseGuards(SuperAdminGuard) + @UseGuards(HqPermissionGuard) + @RequireHqPermissions('users_delete') previewBatchDelete(@Body() dto: BatchDeleteUsersDto) { return this.usersService.previewBatchDelete(dto.ids.map((id) => BigInt(id))); } @Post('batch-delete') - @UseGuards(SuperAdminGuard) + @UseGuards(HqPermissionGuard) + @RequireHqPermissions('users_delete') @HqOperation({ action: HqOperationAction.USER_BATCH_DELETE, refType: 'USER', @@ -44,7 +49,8 @@ export class AdminUsersController { } @Delete(':id') - @UseGuards(SuperAdminGuard) + @UseGuards(HqPermissionGuard) + @RequireHqPermissions('users_delete') @HqOperation({ action: HqOperationAction.USER_DELETE, refType: 'USER',