From 1ac72e9a6906e4115fd1d5ad2508dcd81f7461ac Mon Sep 17 00:00:00 2001 From: jacy-dukang Date: Tue, 7 Jul 2026 18:49:47 +0800 Subject: [PATCH] =?UTF-8?q?=E7=AE=A1=E7=90=86=E5=90=8E=E5=8F=B0=E5=B7=A6?= =?UTF-8?q?=E4=BE=A7=E5=88=97=E8=A1=A8=E8=B0=83=E6=95=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/admin-web/src/App.tsx | 2 + apps/admin-web/src/layouts/AdminLayout.tsx | 24 +- apps/admin-web/src/lib/hq-log.ts | 3 +- apps/admin-web/src/pages/HqAccountsPage.tsx | 101 +++++-- .../admin-web/src/pages/HqPermissionsPage.tsx | 264 ++++++++++++++++++ packages/shared-types/src/hq-permissions.ts | 45 +++ packages/shared-types/src/index.ts | 1 + server/dukang-api/prisma/schema.prisma | 22 ++ .../hq-operation/hq-operation.constants.ts | 4 +- .../modules/ops/admin-hq-accounts.service.ts | 131 ++++++++- .../ops/admin-hq-permissions.controller.ts | 50 ++++ .../ops/admin-hq-permissions.service.ts | 120 ++++++++ .../src/modules/ops/dto/admin-mutate.dto.ts | 49 +++- .../dukang-api/src/modules/ops/ops.module.ts | 4 + 14 files changed, 780 insertions(+), 40 deletions(-) create mode 100644 apps/admin-web/src/pages/HqPermissionsPage.tsx create mode 100644 packages/shared-types/src/hq-permissions.ts create mode 100644 server/dukang-api/src/modules/ops/admin-hq-permissions.controller.ts create mode 100644 server/dukang-api/src/modules/ops/admin-hq-permissions.service.ts diff --git a/apps/admin-web/src/App.tsx b/apps/admin-web/src/App.tsx index 4748a1c..b99046d 100644 --- a/apps/admin-web/src/App.tsx +++ b/apps/admin-web/src/App.tsx @@ -30,6 +30,7 @@ import ThirdPartyLogsPage from './pages/ThirdPartyLogsPage'; import StoreLogsPage from './pages/StoreLogsPage'; import PartnerLogsPage from './pages/PartnerLogsPage'; import WechatBindingsPage from './pages/WechatBindingsPage'; +import HqPermissionsPage from './pages/HqPermissionsPage'; function RequireAuth({ children }: { children: React.ReactNode }) { if (!getToken()) return ; @@ -75,6 +76,7 @@ export default function App() { } /> } /> } /> + } /> } /> } /> diff --git a/apps/admin-web/src/layouts/AdminLayout.tsx b/apps/admin-web/src/layouts/AdminLayout.tsx index a9e632f..7216fbf 100644 --- a/apps/admin-web/src/layouts/AdminLayout.tsx +++ b/apps/admin-web/src/layouts/AdminLayout.tsx @@ -14,6 +14,7 @@ import { LogoutOutlined, CloudUploadOutlined, FileTextOutlined, + LockOutlined, } from '@ant-design/icons'; import { clearAuth, request, type HqProfile } from '../lib/api'; import { bindAdminEllipsisTitle } from '../lib/ellipsis-title'; @@ -34,7 +35,6 @@ const MENU_ITEMS: MenuProps['items'] = [ ], }, { key: '/orders', icon: , label: '订单' }, - { key: '/resources', icon: , label: 'OSS 资源库' }, { key: 'stores-group', icon: , @@ -54,6 +54,7 @@ const MENU_ITEMS: MenuProps['items'] = [ { key: '/partners', label: '开城合伙人' }, { key: '/cities', label: '开城城市' }, { key: '/partner-accounts', label: '开城合伙人账户' }, + { key: '/partner-bills', label: '合伙人结算' }, ], }, { @@ -67,8 +68,17 @@ const MENU_ITEMS: MenuProps['items'] = [ { key: '/redeem/debug', label: '核销调试' }, ], }, - { key: '/partner-bills', icon: , label: '合伙人结算' }, + { + key: 'deliveries-group', + icon: , + label: '配送单', + children: [ + { key: '/deliveries', label: '配送单列表' }, + { key: '/deliveries/xiaofeixia', label: '小飞侠联调' }, + ], + }, { key: '/tickets', icon: , label: '工单中心' }, + { key: '/resources', icon: , label: 'OSS 资源库' }, { key: 'logs-group', icon: , @@ -81,15 +91,7 @@ const MENU_ITEMS: MenuProps['items'] = [ { key: '/logs/third-party', label: '第三方日志' }, ], }, - { - key: 'deliveries-group', - icon: , - label: '配送单', - children: [ - { key: '/deliveries', label: '配送单列表' }, - { key: '/deliveries/xiaofeixia', label: '小飞侠联调' }, - ], - }, + { key: '/hq-permissions', icon: , label: '权限分配' }, { key: '/hq-accounts', icon: , label: 'HQ账户' }, ]; diff --git a/apps/admin-web/src/lib/hq-log.ts b/apps/admin-web/src/lib/hq-log.ts index 62bbbe1..9eebfc8 100644 --- a/apps/admin-web/src/lib/hq-log.ts +++ b/apps/admin-web/src/lib/hq-log.ts @@ -6,7 +6,8 @@ export const HQ_OPERATION_ACTION_OPTIONS = [ { value: 'PARTNER_ACCOUNT_CREATE', label: '新增合伙人账户' }, { value: 'PARTNER_ACCOUNT_UPDATE', label: '编辑合伙人账户' }, { value: 'HQ_ACCOUNT_CREATE', label: '新增 HQ 管理员' }, - { value: 'HQ_ACCOUNT_UPDATE', label: '编辑 HQ 管理员/权限' }, + { value: 'HQ_ACCOUNT_UPDATE', label: '编辑 HQ 管理员' }, + { value: 'HQ_PERMISSION_UPDATE', label: '配置 HQ 权限' }, { value: 'USER_DELETE', label: '删除用户' }, { value: 'USER_BATCH_DELETE', label: '批量删除用户' }, { value: 'ORDER_SHIP', label: '订单发货' }, diff --git a/apps/admin-web/src/pages/HqAccountsPage.tsx b/apps/admin-web/src/pages/HqAccountsPage.tsx index c56cd20..3e53760 100644 --- a/apps/admin-web/src/pages/HqAccountsPage.tsx +++ b/apps/admin-web/src/pages/HqAccountsPage.tsx @@ -1,6 +1,6 @@ import { useEffect, useState } from 'react'; import { - Button, Drawer, Form, Input, Modal, Select, Space, Table, Tag, Typography, message, + Button, Drawer, Form, Input, Modal, Radio, Select, Space, Table, Tag, Typography, message, } from 'antd'; import type { ColumnsType } from 'antd/es/table'; import { request, type HqProfile } from '../lib/api'; @@ -8,7 +8,15 @@ import { ACCOUNT_STATUS_LABELS, fmtTime } from '../lib/constants'; import { useAdminList } from '../lib/useAdminList'; type Row = { - id: string; phone: string; name: string; adminRole: string; status: string; lastLoginAt: string | null; createdAt: string; + id: string; + phone: string; + loginName: string | null; + hasPassword: boolean; + name: string; + adminRole: string; + status: string; + lastLoginAt: string | null; + createdAt: string; }; const ROLE_LABELS: Record = { @@ -38,6 +46,7 @@ export default function HqAccountsPage() { const [detail, setDetail] = useState(null); const [drawerOpen, setDrawerOpen] = useState(false); const [createOpen, setCreateOpen] = useState(false); + const credentialType = Form.useWatch('credentialType', createForm) ?? 'phone'; const isSuperAdmin = profile?.adminRole === 'SUPER_ADMIN'; useEffect(() => { @@ -46,7 +55,18 @@ export default function HqAccountsPage() { const columns: ColumnsType = [ { title: '姓名', dataIndex: 'name' }, + { title: '用户名', dataIndex: 'loginName', width: 120, render: (v) => v || '—' }, { title: '手机', dataIndex: 'phone', width: 130 }, + { + title: '登录方式', + width: 110, + render: (_, r) => ( + + {r.hasPassword ? 密码 : null} + 短信 + + ), + }, { title: '角色', dataIndex: 'adminRole', width: 110, render: (r) => ROLE_LABELS[r] || r }, { title: '状态', dataIndex: 'status', width: 90, render: (s) => {ACCOUNT_STATUS_LABELS[s] || s} }, { title: '最后登录', dataIndex: 'lastLoginAt', width: 160, render: fmtTime }, @@ -55,7 +75,12 @@ export default function HqAccountsPage() { render: (_, row) => ( ), @@ -66,7 +91,18 @@ export default function HqAccountsPage() {
HQ 账户 - {isSuperAdmin && } + {isSuperAdmin && ( + + )}
{ setFilters(v); setPage(1); }}> @@ -82,7 +118,9 @@ export default function HqAccountsPage() {
diff --git a/apps/admin-web/src/pages/HqPermissionsPage.tsx b/apps/admin-web/src/pages/HqPermissionsPage.tsx new file mode 100644 index 0000000..11ae3ed --- /dev/null +++ b/apps/admin-web/src/pages/HqPermissionsPage.tsx @@ -0,0 +1,264 @@ +import { useEffect, useMemo, useState } from 'react'; +import { + Alert, + Button, + Card, + Checkbox, + Col, + Form, + Row, + Select, + Space, + Tabs, + Tag, + Typography, + message, +} from 'antd'; +import { + HQ_ADMIN_ROLES, + HQ_PERMISSION_CATALOG, + type HqPermissionKey, +} from '@dukang/shared-types'; +import { request, type HqProfile } from '../lib/api'; + +type RolePermRes = { role: string; permissionKeys: string[] }; +type AccountOption = { id: string; name: string; phone: string; loginName: string | null; adminRole: string }; +type AccountPermRes = { + account: AccountOption; + permissionKeys: string[]; + rolePermissionKeys: string[]; + userPermissionKeys: string[]; + effectivePermissionKeys: string[]; +}; + +const ROLE_LABELS = Object.fromEntries(HQ_ADMIN_ROLES.map((r) => [r.value, r.label])); + +function PermissionChecklist({ + value, + onChange, + disabled, +}: { + value: string[]; + onChange: (keys: string[]) => void; + disabled?: boolean; +}) { + return ( + onChange(checked as string[])} + > + + {HQ_PERMISSION_CATALOG.map((item) => ( + + {item.label} + + ))} + + + ); +} + +export default function HqPermissionsPage() { + const [profile, setProfile] = useState(null); + const [role, setRole] = useState('OPS'); + const [roleKeys, setRoleKeys] = useState([]); + const [roleLoading, setRoleLoading] = useState(false); + const [roleSaving, setRoleSaving] = useState(false); + + const [accounts, setAccounts] = useState([]); + const [accountId, setAccountId] = useState(); + const [accountKeys, setAccountKeys] = useState([]); + const [roleInheritedKeys, setRoleInheritedKeys] = useState([]); + const [accountLoading, setAccountLoading] = useState(false); + const [accountSaving, setAccountSaving] = useState(false); + + const previewEffectiveKeys = useMemo( + () => [...new Set([...roleInheritedKeys, ...accountKeys])], + [roleInheritedKeys, accountKeys], + ); + + const isSuperAdmin = profile?.adminRole === 'SUPER_ADMIN'; + + useEffect(() => { + request('/admin/auth/me').then(setProfile).catch(() => {}); + }, []); + + useEffect(() => { + if (!isSuperAdmin) return; + request<{ items: AccountOption[] }>('/admin/hq-accounts?page=1&pageSize=100') + .then((res) => setAccounts(res.items)) + .catch(() => {}); + }, [isSuperAdmin]); + + useEffect(() => { + if (!isSuperAdmin || !role) return; + setRoleLoading(true); + request(`/admin/hq-permissions/roles/${role}`) + .then((res) => setRoleKeys(res.permissionKeys)) + .finally(() => setRoleLoading(false)); + }, [isSuperAdmin, role]); + + useEffect(() => { + if (!isSuperAdmin || !accountId) return; + setAccountLoading(true); + request(`/admin/hq-permissions/accounts/${accountId}`) + .then((res) => { + setAccountKeys(res.userPermissionKeys); + setRoleInheritedKeys(res.rolePermissionKeys); + }) + .finally(() => setAccountLoading(false)); + }, [isSuperAdmin, accountId]); + + async function saveRolePermissions() { + setRoleSaving(true); + try { + const res = await request(`/admin/hq-permissions/roles/${role}`, { + method: 'PUT', + body: JSON.stringify({ permissionKeys: roleKeys }), + }); + setRoleKeys(res.permissionKeys); + message.success('角色权限已保存'); + } catch (e) { + message.error(e instanceof Error ? e.message : '保存失败'); + } finally { + setRoleSaving(false); + } + } + + async function saveAccountPermissions() { + if (!accountId) return; + setAccountSaving(true); + try { + const res = await request(`/admin/hq-permissions/accounts/${accountId}`, { + method: 'PUT', + body: JSON.stringify({ permissionKeys: accountKeys }), + }); + setAccountKeys(res.userPermissionKeys); + setRoleInheritedKeys(res.rolePermissionKeys); + message.success('用户权限已保存'); + } catch (e) { + message.error(e instanceof Error ? e.message : '保存失败'); + } finally { + setAccountSaving(false); + } + } + + if (!isSuperAdmin) { + return ( +
+ 权限分配 + +
+ ); + } + + return ( +
+ 权限分配 + + 按角色配置基础权限;按用户可追加专属权限。最终生效权限 = 角色权限 ∪ 用户权限(超级管理员始终拥有全部权限)。 + + + +
+ + a.adminRole !== 'SUPER_ADMIN') + .map((a) => ({ + value: a.id, + label: `${a.name} · ${a.loginName || a.phone} · ${ROLE_LABELS[a.adminRole] || a.adminRole}`, + }))} + /> + +
+ {!accountId ? ( + + ) : ( + <> + + 角色继承: + {roleInheritedKeys.map((key) => { + const item = HQ_PERMISSION_CATALOG.find((p) => p.key === key); + return ( + + {item?.label || key} + + ); + })} + + + 下方勾选为用户专属追加权限(保存后与角色权限合并生效)。 + + + + 合并生效: + {previewEffectiveKeys.map((key) => { + const item = HQ_PERMISSION_CATALOG.find((p) => p.key === (key as HqPermissionKey)); + return {item?.label || key}; + })} + +
+ +
+ + )} + + ), + }, + ]} + /> +
+ ); +} diff --git a/packages/shared-types/src/hq-permissions.ts b/packages/shared-types/src/hq-permissions.ts new file mode 100644 index 0000000..8565a4b --- /dev/null +++ b/packages/shared-types/src/hq-permissions.ts @@ -0,0 +1,45 @@ +export const HQ_PERMISSION_CATALOG = [ + { key: 'dashboard', label: '概览' }, + { key: 'users', label: '用户管理' }, + { key: 'wechat_bindings', label: '微信绑定' }, + { key: 'products', label: '商品管理' }, + { key: 'orders', label: '订单管理' }, + { key: 'stores', label: '门店管理' }, + { key: 'partners', label: '开城管理' }, + { key: 'benefit', label: '好客权益' }, + { key: 'deliveries', label: '配送单' }, + { key: 'tickets', label: '工单中心' }, + { key: 'resources', label: 'OSS 资源库' }, + { key: 'logs', label: '日志' }, + { key: 'hq_permissions', label: '权限分配' }, + { key: 'hq_accounts', label: 'HQ 账户' }, +] as const; + +export type HqPermissionKey = (typeof HQ_PERMISSION_CATALOG)[number]['key']; + +export const HQ_ADMIN_ROLES = [ + { value: 'SUPER_ADMIN', label: '超级管理员' }, + { value: 'OPS', label: '运营' }, + { value: 'FINANCE', label: '财务' }, + { value: 'CUSTOMER_SERVICE', label: '客服' }, +] as const; + +export const HQ_ROLE_DEFAULT_PERMISSIONS: Record = { + SUPER_ADMIN: HQ_PERMISSION_CATALOG.map((p) => p.key), + OPS: [ + 'dashboard', + 'users', + 'wechat_bindings', + 'products', + 'orders', + 'stores', + 'partners', + 'benefit', + 'deliveries', + 'tickets', + 'resources', + 'logs', + ], + FINANCE: ['dashboard', 'orders', 'stores', 'partners', 'benefit', 'logs'], + CUSTOMER_SERVICE: ['dashboard', 'users', 'orders', 'tickets', 'logs'], +}; diff --git a/packages/shared-types/src/index.ts b/packages/shared-types/src/index.ts index 8fddc1c..c04afc5 100644 --- a/packages/shared-types/src/index.ts +++ b/packages/shared-types/src/index.ts @@ -13,3 +13,4 @@ export * from './user-log'; export * from './store-log'; export * from './partner-log'; export * from './promo'; +export * from './hq-permissions'; diff --git a/server/dukang-api/prisma/schema.prisma b/server/dukang-api/prisma/schema.prisma index 18e6289..2f456b7 100644 --- a/server/dukang-api/prisma/schema.prisma +++ b/server/dukang-api/prisma/schema.prisma @@ -506,9 +506,31 @@ model HqAccount { createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3) updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3) + permissions HqAccountPermission[] + @@map("hq_account") } +model HqRolePermission { + adminRole HqAdminRole @map("admin_role") + permissionKey String @map("permission_key") @db.VarChar(64) + createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3) + + @@id([adminRole, permissionKey]) + @@map("hq_role_permission") +} + +model HqAccountPermission { + hqAccountId BigInt @map("hq_account_id") @db.UnsignedBigInt + permissionKey String @map("permission_key") @db.VarChar(64) + createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3) + + hqAccount HqAccount @relation(fields: [hqAccountId], references: [id], onDelete: Cascade) + + @@id([hqAccountId, permissionKey]) + @@map("hq_account_permission") +} + // ─── USER ───────────────────────────────────────────── model User { diff --git a/server/dukang-api/src/common/hq-operation/hq-operation.constants.ts b/server/dukang-api/src/common/hq-operation/hq-operation.constants.ts index 40669e0..92b71f1 100644 --- a/server/dukang-api/src/common/hq-operation/hq-operation.constants.ts +++ b/server/dukang-api/src/common/hq-operation/hq-operation.constants.ts @@ -8,6 +8,7 @@ export const HqOperationAction = { PARTNER_ACCOUNT_UPDATE: 'PARTNER_ACCOUNT_UPDATE', HQ_ACCOUNT_CREATE: 'HQ_ACCOUNT_CREATE', HQ_ACCOUNT_UPDATE: 'HQ_ACCOUNT_UPDATE', + HQ_PERMISSION_UPDATE: 'HQ_PERMISSION_UPDATE', USER_DELETE: 'USER_DELETE', USER_BATCH_DELETE: 'USER_BATCH_DELETE', ORDER_SHIP: 'ORDER_SHIP', @@ -50,7 +51,8 @@ export const HQ_OPERATION_ACTION_LABELS: Record = { [HqOperationAction.PARTNER_ACCOUNT_CREATE]: '新增合伙人账户', [HqOperationAction.PARTNER_ACCOUNT_UPDATE]: '编辑合伙人账户', [HqOperationAction.HQ_ACCOUNT_CREATE]: '新增 HQ 管理员', - [HqOperationAction.HQ_ACCOUNT_UPDATE]: '编辑 HQ 管理员/权限', + [HqOperationAction.HQ_ACCOUNT_UPDATE]: '编辑 HQ 管理员', + [HqOperationAction.HQ_PERMISSION_UPDATE]: '配置 HQ 权限', [HqOperationAction.USER_DELETE]: '删除用户', [HqOperationAction.USER_BATCH_DELETE]: '批量删除用户', [HqOperationAction.ORDER_SHIP]: '订单发货', diff --git a/server/dukang-api/src/modules/ops/admin-hq-accounts.service.ts b/server/dukang-api/src/modules/ops/admin-hq-accounts.service.ts index a289c1e..c582a7a 100644 --- a/server/dukang-api/src/modules/ops/admin-hq-accounts.service.ts +++ b/server/dukang-api/src/modules/ops/admin-hq-accounts.service.ts @@ -4,6 +4,31 @@ import { PrismaService } from '../../common/prisma/prisma.module'; import { serializeBigInt } from '../../common/decorators/current-user.decorator'; import type { AdminHqAccountsQueryDto } from './dto/admin-query.dto'; import type { CreateHqAccountDto, UpdateHqAccountDto } from './dto/admin-mutate.dto'; +import { hashPassword } from '../../common/crypto/password.util'; + +function mapHqAccountRow(account: { + id: bigint; + phone: string; + loginName: string | null; + passwordHash: string | null; + name: string; + adminRole: string; + status: string; + lastLoginAt: Date | null; + createdAt: Date; +}) { + return { + id: account.id, + phone: account.phone, + loginName: account.loginName, + hasPassword: !!account.passwordHash, + name: account.name, + adminRole: account.adminRole, + status: account.status, + lastLoginAt: account.lastLoginAt, + createdAt: account.createdAt, + }; +} @Injectable() export class AdminHqAccountsService { @@ -23,42 +48,130 @@ export class AdminHqAccountsService { orderBy: { createdAt: 'desc' }, skip: (page - 1) * pageSize, take: pageSize, + select: { + id: true, + phone: true, + loginName: true, + passwordHash: true, + name: true, + adminRole: true, + status: true, + lastLoginAt: true, + createdAt: true, + }, }), this.prisma.hqAccount.count({ where }), ]); - return serializeBigInt({ items, total, page, pageSize }); + return serializeBigInt({ + items: items.map(mapHqAccountRow), + total, + page, + pageSize, + }); } async detail(id: bigint) { - const account = await this.prisma.hqAccount.findUnique({ where: { id } }); + const account = await this.prisma.hqAccount.findUnique({ + where: { id }, + select: { + id: true, + phone: true, + loginName: true, + passwordHash: true, + name: true, + adminRole: true, + status: true, + lastLoginAt: true, + createdAt: true, + }, + }); if (!account) throw new NotFoundException('HQ 账号不存在'); - return serializeBigInt(account); + return serializeBigInt(mapHqAccountRow(account)); } async create(dto: CreateHqAccountDto) { - const exists = await this.prisma.hqAccount.findUnique({ where: { phone: dto.phone } }); - if (exists) throw new BadRequestException('手机号已存在'); + const adminRole = (dto.adminRole ?? 'OPS') as 'SUPER_ADMIN' | 'OPS' | 'FINANCE' | 'CUSTOMER_SERVICE'; + + if (dto.credentialType === 'phone') { + if (!dto.phone?.trim()) throw new BadRequestException('请填写手机号'); + const phone = dto.phone.trim(); + const exists = await this.prisma.hqAccount.findUnique({ where: { phone } }); + if (exists) throw new BadRequestException('手机号已存在'); + const account = await this.prisma.hqAccount.create({ + data: { phone, name: dto.name, adminRole }, + }); + return serializeBigInt(mapHqAccountRow({ ...account, passwordHash: null })); + } + + if (!dto.loginName?.trim() || !dto.password) { + throw new BadRequestException('账号密码模式需填写用户名和密码'); + } + const loginName = dto.loginName.trim(); + const loginTaken = await this.prisma.hqAccount.findUnique({ where: { loginName } }); + if (loginTaken) throw new BadRequestException('用户名已存在'); + + const phone = dto.phone?.trim() || (await this.generatePlaceholderPhone()); + const phoneTaken = await this.prisma.hqAccount.findUnique({ where: { phone } }); + if (phoneTaken) throw new BadRequestException('手机号已存在'); + const account = await this.prisma.hqAccount.create({ data: { - phone: dto.phone, + phone, + loginName, + passwordHash: hashPassword(dto.password), name: dto.name, - adminRole: (dto.adminRole ?? 'OPS') as 'SUPER_ADMIN' | 'OPS' | 'FINANCE' | 'CUSTOMER_SERVICE', + adminRole, }, }); - return serializeBigInt(account); + return serializeBigInt(mapHqAccountRow(account)); } async update(id: bigint, dto: UpdateHqAccountDto) { + const current = await this.prisma.hqAccount.findUnique({ where: { id } }); + if (!current) throw new NotFoundException('HQ 账号不存在'); + + if (dto.loginName !== undefined) { + const loginName = dto.loginName.trim(); + if (!loginName) throw new BadRequestException('用户名不能为空'); + const conflict = await this.prisma.hqAccount.findFirst({ + where: { loginName, id: { not: id } }, + }); + if (conflict) throw new BadRequestException('用户名已存在'); + } + const account = await this.prisma.hqAccount.update({ where: { id }, data: { ...(dto.name !== undefined ? { name: dto.name } : {}), + ...(dto.loginName !== undefined ? { loginName: dto.loginName.trim() } : {}), + ...(dto.password ? { passwordHash: hashPassword(dto.password) } : {}), ...(dto.adminRole !== undefined ? { adminRole: dto.adminRole as 'SUPER_ADMIN' | 'OPS' | 'FINANCE' | 'CUSTOMER_SERVICE' } : {}), ...(dto.status !== undefined ? { status: dto.status as 'ACTIVE' | 'DISABLED' } : {}), }, + select: { + id: true, + phone: true, + loginName: true, + passwordHash: true, + name: true, + adminRole: true, + status: true, + lastLoginAt: true, + createdAt: true, + }, }); - return serializeBigInt(account); + return serializeBigInt(mapHqAccountRow(account)); + } + + private async generatePlaceholderPhone(): Promise { + for (let i = 0; i < 8; i += 1) { + const suffix = `${Date.now()}${Math.floor(Math.random() * 1000)}`.slice(-8); + const phone = `199${suffix}`; + const exists = await this.prisma.hqAccount.findUnique({ where: { phone } }); + if (!exists) return phone; + } + throw new BadRequestException('无法生成占位手机号,请手动填写'); } } diff --git a/server/dukang-api/src/modules/ops/admin-hq-permissions.controller.ts b/server/dukang-api/src/modules/ops/admin-hq-permissions.controller.ts new file mode 100644 index 0000000..345bb38 --- /dev/null +++ b/server/dukang-api/src/modules/ops/admin-hq-permissions.controller.ts @@ -0,0 +1,50 @@ +import { Body, Controller, Get, Param, Put, UseGuards } from '@nestjs/common'; +import { HqAuthGuard } from '../../common/guards/hq-auth.guard'; +import { SuperAdminGuard } from '../../common/guards/super-admin.guard'; +import { HqOperation } from '../../common/hq-operation/hq-operation.decorator'; +import { HqOperationAction } from '../../common/hq-operation/hq-operation.constants'; +import { AdminHqPermissionsService } from './admin-hq-permissions.service'; +import { SaveHqAccountPermissionsDto, SaveHqRolePermissionsDto } from './dto/admin-mutate.dto'; + +@Controller('admin/hq-permissions') +@UseGuards(HqAuthGuard, SuperAdminGuard) +export class AdminHqPermissionsController { + constructor(private readonly service: AdminHqPermissionsService) {} + + @Get('catalog') + catalog() { + return this.service.catalog(); + } + + @Get('roles/:role') + getRolePermissions(@Param('role') role: string) { + return this.service.getRolePermissions(role); + } + + @Put('roles/:role') + @HqOperation({ + action: HqOperationAction.HQ_PERMISSION_UPDATE, + refType: 'HQ_ROLE', + refIdParam: 'role', + includeBody: true, + }) + saveRolePermissions(@Param('role') role: string, @Body() dto: SaveHqRolePermissionsDto) { + return this.service.saveRolePermissions(role, dto.permissionKeys); + } + + @Get('accounts/:id') + getAccountPermissions(@Param('id') id: string) { + return this.service.getAccountPermissions(BigInt(id)); + } + + @Put('accounts/:id') + @HqOperation({ + action: HqOperationAction.HQ_PERMISSION_UPDATE, + refType: 'HQ_ACCOUNT', + refIdParam: 'id', + includeBody: true, + }) + saveAccountPermissions(@Param('id') id: string, @Body() dto: SaveHqAccountPermissionsDto) { + return this.service.saveAccountPermissions(BigInt(id), dto.permissionKeys); + } +} 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 new file mode 100644 index 0000000..452162c --- /dev/null +++ b/server/dukang-api/src/modules/ops/admin-hq-permissions.service.ts @@ -0,0 +1,120 @@ +import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; +import { + HQ_PERMISSION_CATALOG, + HQ_ROLE_DEFAULT_PERMISSIONS, + type HqPermissionKey, +} from '@dukang/shared-types'; +import { PrismaService } from '../../common/prisma/prisma.module'; +import { serializeBigInt } from '../../common/decorators/current-user.decorator'; + +const VALID_PERMISSION_KEYS = new Set(HQ_PERMISSION_CATALOG.map((p) => p.key)); + +function assertPermissionKeys(keys: string[]) { + const invalid = keys.filter((key) => !VALID_PERMISSION_KEYS.has(key)); + if (invalid.length) { + throw new BadRequestException(`无效权限项: ${invalid.join(', ')}`); + } +} + +@Injectable() +export class AdminHqPermissionsService { + constructor(private readonly prisma: PrismaService) {} + + catalog() { + return { + permissions: HQ_PERMISSION_CATALOG, + roles: Object.entries(HQ_ROLE_DEFAULT_PERMISSIONS).map(([role, permissionKeys]) => ({ + role, + permissionKeys, + })), + }; + } + + async getRolePermissions(role: string) { + const rows = await this.prisma.hqRolePermission.findMany({ + where: { adminRole: role as 'SUPER_ADMIN' | 'OPS' | 'FINANCE' | 'CUSTOMER_SERVICE' }, + select: { permissionKey: true }, + }); + const permissionKeys = + rows.length > 0 + ? rows.map((r) => r.permissionKey) + : [...(HQ_ROLE_DEFAULT_PERMISSIONS[role] ?? [])]; + return { role, permissionKeys }; + } + + async saveRolePermissions(role: string, permissionKeys: string[]) { + if (role === 'SUPER_ADMIN') { + throw new BadRequestException('超级管理员拥有全部权限,无需配置'); + } + assertPermissionKeys(permissionKeys); + const adminRole = role as 'OPS' | 'FINANCE' | 'CUSTOMER_SERVICE'; + await this.prisma.$transaction([ + this.prisma.hqRolePermission.deleteMany({ where: { adminRole } }), + ...(permissionKeys.length + ? [ + this.prisma.hqRolePermission.createMany({ + data: permissionKeys.map((permissionKey) => ({ adminRole, permissionKey })), + }), + ] + : []), + ]); + return this.getRolePermissions(role); + } + + async getAccountPermissions(accountId: bigint) { + const account = await this.prisma.hqAccount.findUnique({ + where: { id: accountId }, + select: { id: true, name: true, phone: true, loginName: true, adminRole: true, status: true }, + }); + if (!account) throw new NotFoundException('HQ 账号不存在'); + if (account.adminRole === 'SUPER_ADMIN') { + 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), + }); + } + + const [rolePerms, userPerms] = await Promise.all([ + this.getRolePermissions(account.adminRole), + this.prisma.hqAccountPermission.findMany({ + where: { hqAccountId: accountId }, + select: { permissionKey: true }, + }), + ]); + const userPermissionKeys = userPerms.map((p) => p.permissionKey); + const effectivePermissionKeys = [ + ...new Set([...rolePerms.permissionKeys, ...userPermissionKeys]), + ] as HqPermissionKey[]; + + return serializeBigInt({ + account, + permissionKeys: userPermissionKeys, + rolePermissionKeys: rolePerms.permissionKeys, + userPermissionKeys, + effectivePermissionKeys, + }); + } + + 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); + await this.prisma.$transaction([ + this.prisma.hqAccountPermission.deleteMany({ where: { hqAccountId: accountId } }), + ...(permissionKeys.length + ? [ + this.prisma.hqAccountPermission.createMany({ + data: permissionKeys.map((permissionKey) => ({ hqAccountId: accountId, permissionKey })), + }), + ] + : []), + ]); + return this.getAccountPermissions(accountId); + } +} diff --git a/server/dukang-api/src/modules/ops/dto/admin-mutate.dto.ts b/server/dukang-api/src/modules/ops/dto/admin-mutate.dto.ts index 90b3573..71f0694 100644 --- a/server/dukang-api/src/modules/ops/dto/admin-mutate.dto.ts +++ b/server/dukang-api/src/modules/ops/dto/admin-mutate.dto.ts @@ -1,5 +1,17 @@ import { Type } from 'class-transformer'; -import { IsArray, IsBoolean, IsIn, IsNotEmpty, IsNumber, IsObject, IsOptional, IsString, Min } from 'class-validator'; +import { + IsArray, + IsBoolean, + IsIn, + IsNotEmpty, + IsNumber, + IsObject, + IsOptional, + IsString, + Min, + MinLength, + ValidateIf, +} from 'class-validator'; export class UpdateStoreStatusDto { @IsString() @@ -413,9 +425,22 @@ export class UpdateDeliveryDto { } export class CreateHqAccountDto { + @IsIn(['phone', 'password']) + credentialType: 'phone' | 'password'; + + @IsOptional() + @IsString() + phone?: string; + + @ValidateIf((o: CreateHqAccountDto) => o.credentialType === 'password') @IsString() @IsNotEmpty() - phone: string; + loginName?: string; + + @ValidateIf((o: CreateHqAccountDto) => o.credentialType === 'password') + @IsString() + @MinLength(6) + password?: string; @IsString() @IsNotEmpty() @@ -427,6 +452,14 @@ export class CreateHqAccountDto { } export class UpdateHqAccountDto { + @IsOptional() + @IsString() + loginName?: string; + + @IsOptional() + @IsString() + @MinLength(6) + password?: string; @IsOptional() @IsString() name?: string; @@ -440,6 +473,18 @@ export class UpdateHqAccountDto { status?: string; } +export class SaveHqRolePermissionsDto { + @IsArray() + @IsString({ each: true }) + permissionKeys: string[]; +} + +export class SaveHqAccountPermissionsDto { + @IsArray() + @IsString({ each: true }) + permissionKeys: string[]; +} + export class CreateProductDto { @IsString() @IsNotEmpty() diff --git a/server/dukang-api/src/modules/ops/ops.module.ts b/server/dukang-api/src/modules/ops/ops.module.ts index 1dcccdd..84a2de0 100644 --- a/server/dukang-api/src/modules/ops/ops.module.ts +++ b/server/dukang-api/src/modules/ops/ops.module.ts @@ -46,6 +46,8 @@ import { AdminRedeemDebugController } from './admin-redeem-debug.controller'; import { AdminRedeemDebugService } from './admin-redeem-debug.service'; import { AdminWechatBindingsController } from './admin-wechat-bindings.controller'; import { AdminWechatBindingsService } from './admin-wechat-bindings.service'; +import { AdminHqPermissionsController } from './admin-hq-permissions.controller'; +import { AdminHqPermissionsService } from './admin-hq-permissions.service'; @Module({ imports: [IamModule, TradeModule, BenefitModule, CommonModule, IntegrationsModule, RedeemModule], @@ -75,6 +77,7 @@ import { AdminWechatBindingsService } from './admin-wechat-bindings.service'; AdminRedeemDebugController, AdminPromoCodesController, AdminWechatBindingsController, + AdminHqPermissionsController, ], providers: [ AdminDashboardService, @@ -98,6 +101,7 @@ import { AdminWechatBindingsService } from './admin-wechat-bindings.service'; AdminRedeemDebugService, AdminPromoCodesService, AdminWechatBindingsService, + AdminHqPermissionsService, SuperAdminGuard, ], })