feat: 支持动态角色权限分配

This commit is contained in:
湛兮
2026-05-26 16:24:25 +08:00
parent aa65cb0928
commit 6b31ea7bbf
15 changed files with 2020 additions and 117 deletions
+19 -10
View File
@@ -9,10 +9,14 @@ import type {
SuperAdmin,
} from "./auth.types";
import { verifyPassword } from "./password";
import { resolvePermissions } from "../permissions/permission.policy";
import { permissionService } from "../permissions/permission.service";
function toAuthUser(admin: SuperAdmin): AuthUser {
async function toAuthUser(admin: SuperAdmin): Promise<AuthUser> {
const roleCodes = ["super_admin"];
const permissions = await permissionService.resolvePermissions(
"SUPER_ADMIN",
roleCodes,
);
return {
id: admin.id,
@@ -26,14 +30,19 @@ function toAuthUser(admin: SuperAdmin): AuthUser {
name: "超级管理员",
},
],
permissions: resolvePermissions("SUPER_ADMIN", roleCodes),
permissions,
canManage: true,
};
}
function toEmployeeAuthUser(employee: EmployeeLoginAccount): AuthUser {
const canManage = employee.roles.some((role) => role.code === "admin");
async function toEmployeeAuthUser(
employee: EmployeeLoginAccount,
): Promise<AuthUser> {
const roleCodes = employee.roles.map((role) => role.code);
const permissions = await permissionService.resolvePermissions(
"EMPLOYEE",
roleCodes,
);
return {
id: employee.id,
@@ -43,8 +52,8 @@ function toEmployeeAuthUser(employee: EmployeeLoginAccount): AuthUser {
storeId: employee.storeId,
storeName: employee.storeName,
roles: employee.roles,
permissions: resolvePermissions("EMPLOYEE", roleCodes),
canManage,
permissions,
canManage: permissions.some((permission) => permission.endsWith(":manage")),
};
}
@@ -88,7 +97,7 @@ export const authService = {
await authRepository.updateLastLoginAt(admin.id);
const user = toAuthUser(admin);
const user = await toAuthUser(admin);
return {
user,
@@ -115,7 +124,7 @@ export const authService = {
await authRepository.updateEmployeeLastLoginAt(employee.id);
const user = toEmployeeAuthUser(employee);
const user = await toEmployeeAuthUser(employee);
if (!hasBackendMenu(user)) {
throw unauthorized("当前账号没有后台登录权限");
@@ -150,7 +159,7 @@ export const authService = {
await authRepository.updateEmployeeLastLoginAt(employee.id);
const user = toEmployeeAuthUser(employee);
const user = await toEmployeeAuthUser(employee);
return {
user,
+50 -3
View File
@@ -1,5 +1,5 @@
import type { FastifyInstance } from "fastify";
import { created, ok } from "../../shared/response";
import { created, ok, paginated } from "../../shared/response";
import { permissionGuard } from "../auth/auth.guard";
import { PERMISSIONS } from "../permissions/permission.policy";
import { catalogService } from "./catalog.service";
@@ -7,17 +7,50 @@ import {
createRoleBodySchema,
createStoreBodySchema,
idParamSchema,
listRolesQuerySchema,
listStoresQuerySchema,
updateRoleBodySchema,
updateStoreBodySchema,
} from "./catalog.schema";
import type { ListRolesQuery, ListStoresQuery } from "./catalog.types";
function shouldUseStorePage(query: ListStoresQuery): boolean {
return (
query.status !== undefined ||
query.keyword !== undefined ||
query.page !== undefined ||
query.pageSize !== undefined
);
}
function shouldUseRolePage(query: ListRolesQuery): boolean {
return (
query.keyword !== undefined ||
query.isSystem !== undefined ||
query.page !== undefined ||
query.pageSize !== undefined
);
}
// catalogRoutes 管理“字典/基础资料”接口:门店和角色。
// controller 层只做三件事:校验入参、调用 service、包装 HTTP 响应。
export async function catalogRoutes(app: FastifyInstance): Promise<void> {
app.get("/stores", { preHandler: permissionGuard(PERMISSIONS.STORE_VIEW) }, async (request) => {
const query = listStoresQuerySchema.parse(request.query);
// 默认返回可选门店;需要管理后台列表时,可通过 includeInactive=true 带出停用门店。
if (shouldUseStorePage(query)) {
const page = query.page ?? 1;
const pageSize = query.pageSize ?? 20;
const result = await catalogService.listStorePage({
...query,
page,
pageSize,
});
return paginated(result.items, page, pageSize, result.total);
}
// 不带筛选参数时保留旧返回结构:默认给下拉选项,includeInactive=true 给完整数组。
const stores = query.includeInactive
? await catalogService.listStores(query)
: await catalogService.listActiveStoreOptions();
@@ -55,7 +88,21 @@ export async function catalogRoutes(app: FastifyInstance): Promise<void> {
return reply.code(204).send();
});
app.get("/roles", { preHandler: permissionGuard(PERMISSIONS.ROLE_VIEW) }, async () => {
app.get("/roles", { preHandler: permissionGuard(PERMISSIONS.ROLE_VIEW) }, async (request) => {
const query = listRolesQuerySchema.parse(request.query);
if (shouldUseRolePage(query)) {
const page = query.page ?? 1;
const pageSize = query.pageSize ?? 20;
const result = await catalogService.listRolePage({
...query,
page,
pageSize,
});
return paginated(result.items, page, pageSize, result.total);
}
const roles = await catalogService.listRoles();
return ok(roles);
+135
View File
@@ -3,6 +3,7 @@ import { pool } from "../../db/pool";
import type {
CreateRoleInput,
CreateStoreInput,
ListRolesQuery,
ListStoresQuery,
Role,
RoleOption,
@@ -88,6 +89,76 @@ function toRoleOption(row: RoleRow): RoleOption {
};
}
function normalizePagination(query: {
page?: number;
pageSize?: number;
}): { page: number; pageSize: number; offset: number } {
const page = query.page ?? 1;
const pageSize = query.pageSize ?? 20;
return {
page,
pageSize,
offset: (page - 1) * pageSize,
};
}
function buildStoreListWhere(query: ListStoresQuery): {
whereSql: string;
params: SqlParam[];
} {
const where = ["deleted_at IS NULL"];
const params: SqlParam[] = [];
if (query.status !== undefined) {
where.push("status = ?");
params.push(query.status);
} else if (query.includeInactive === false) {
where.push("status = 'ACTIVE'");
}
if (query.keyword !== undefined) {
where.push("(name LIKE ? OR address LIKE ? OR phone LIKE ?)");
params.push(
`%${query.keyword}%`,
`%${query.keyword}%`,
`%${query.keyword}%`,
);
}
return {
whereSql: where.join(" AND "),
params,
};
}
function buildRoleListWhere(query: ListRolesQuery): {
whereSql: string;
params: SqlParam[];
} {
const where: string[] = [];
const params: SqlParam[] = [];
if (query.isSystem !== undefined) {
where.push("is_system = ?");
params.push(query.isSystem ? 1 : 0);
}
if (query.keyword !== undefined) {
where.push("(code LIKE ? OR name LIKE ? OR description LIKE ?)");
params.push(
`%${query.keyword}%`,
`%${query.keyword}%`,
`%${query.keyword}%`,
);
}
return {
whereSql: where.length > 0 ? where.join(" AND ") : "1 = 1",
params,
};
}
export const catalogRepository = {
async listStores(query: ListStoresQuery = {}): Promise<Store[]> {
// includeInactive=true 用于管理列表;默认只查启用且未软删除的门店。
@@ -120,6 +191,38 @@ export const catalogRepository = {
return rows.map(toStoreOption);
},
async listStorePage(
query: ListStoresQuery,
): Promise<{ items: Store[]; total: number }> {
const { whereSql, params } = buildStoreListWhere(query);
const { pageSize, offset } = normalizePagination(query);
const [countRows] = await pool.execute<CountRow[]>(
`
SELECT COUNT(*) AS total
FROM stores
WHERE ${whereSql}
`,
params,
);
const [rows] = await pool.execute<StoreRow[]>(
`
SELECT id, name, address, phone, status, created_at, updated_at
FROM stores
WHERE ${whereSql}
ORDER BY id ASC
LIMIT ${pageSize} OFFSET ${offset}
`,
params,
);
return {
items: rows.map(toStore),
total: countRows[0]?.total ?? 0,
};
},
async findStoreById(id: number): Promise<Store | null> {
const [rows] = await pool.execute<StoreRow[]>(
`
@@ -246,6 +349,38 @@ export const catalogRepository = {
return rows.map(toRole);
},
async listRolePage(
query: ListRolesQuery,
): Promise<{ items: Role[]; total: number }> {
const { whereSql, params } = buildRoleListWhere(query);
const { pageSize, offset } = normalizePagination(query);
const [countRows] = await pool.execute<CountRow[]>(
`
SELECT COUNT(*) AS total
FROM roles
WHERE ${whereSql}
`,
params,
);
const [rows] = await pool.execute<RoleRow[]>(
`
SELECT id, code, name, description, is_system, created_at, updated_at
FROM roles
WHERE ${whereSql}
ORDER BY id ASC
LIMIT ${pageSize} OFFSET ${offset}
`,
params,
);
return {
items: rows.map(toRole),
total: countRows[0]?.total ?? 0,
};
},
async listRoleOptions(): Promise<RoleOption[]> {
const [rows] = await pool.execute<RoleRow[]>(
`
+26
View File
@@ -39,6 +39,32 @@ export const listStoresQuerySchema = z.object({
(value) => stringToBoolean(emptyStringToUndefined(value)),
z.boolean().optional(),
),
status: z.preprocess(emptyStringToUndefined, z.enum(STORE_STATUS).optional()),
keyword: z.preprocess(
emptyStringToUndefined,
z.string().trim().min(1).max(100).optional(),
),
page: z.preprocess(emptyStringToUndefined, z.coerce.number().int().min(1).optional()),
pageSize: z.preprocess(
emptyStringToUndefined,
z.coerce.number().int().min(1).max(100).optional(),
),
});
export const listRolesQuerySchema = z.object({
keyword: z.preprocess(
emptyStringToUndefined,
z.string().trim().min(1).max(100).optional(),
),
isSystem: z.preprocess(
(value) => stringToBoolean(emptyStringToUndefined(value)),
z.boolean().optional(),
),
page: z.preprocess(emptyStringToUndefined, z.coerce.number().int().min(1).optional()),
pageSize: z.preprocess(
emptyStringToUndefined,
z.coerce.number().int().min(1).max(100).optional(),
),
});
export const createStoreBodySchema = z.object({
+13
View File
@@ -3,6 +3,7 @@ import { catalogRepository } from "./catalog.repository";
import type {
CreateRoleInput,
CreateStoreInput,
ListRolesQuery,
ListStoresQuery,
Role,
RoleOption,
@@ -23,6 +24,12 @@ export const catalogService = {
return catalogRepository.listActiveStoreOptions();
},
async listStorePage(
query: ListStoresQuery,
): Promise<{ items: Store[]; total: number }> {
return catalogRepository.listStorePage(query);
},
async getStoreById(id: number): Promise<Store> {
const store = await catalogRepository.findStoreById(id);
@@ -92,6 +99,12 @@ export const catalogService = {
return catalogRepository.listRoles();
},
async listRolePage(
query: ListRolesQuery,
): Promise<{ items: Role[]; total: number }> {
return catalogRepository.listRolePage(query);
},
async listRoleOptions(): Promise<RoleOption[]> {
return catalogRepository.listRoleOptions();
},
+11
View File
@@ -64,6 +64,17 @@ export interface Role extends RoleOption {
export interface ListStoresQuery {
includeInactive?: boolean;
status?: StoreStatus;
keyword?: string;
page?: number;
pageSize?: number;
}
export interface ListRolesQuery {
keyword?: string;
isSystem?: boolean;
page?: number;
pageSize?: number;
}
export interface CreateStoreInput {
+1 -4
View File
@@ -1,6 +1,5 @@
import type { PoolConnection, ResultSetHeader, RowDataPacket } from "mysql2/promise";
import { pool } from "../../db/pool";
import { FIXED_ROLE_CODES } from "../catalog/catalog.types";
import type {
CreateEmployeeInput,
Employee,
@@ -12,7 +11,6 @@ import type {
type DbExecutor = typeof pool | PoolConnection;
type SqlParam = string | number | boolean | Date | null;
const fixedRoleCodePlaceholders = FIXED_ROLE_CODES.map(() => "?").join(", ");
const DEFAULT_EMPLOYEE_PASSWORD_HASH =
"pbkdf2$sha256$310000$QnXyjrpm0QzcGYLEPdunWg$CfR-CywGl1c_Omh_3PyOWPmo93EcbMY1FEjjd5MDjFo";
@@ -126,9 +124,8 @@ export const employeeRepository = {
SELECT id
FROM roles
WHERE id IN (${placeholders})
AND code IN (${fixedRoleCodePlaceholders})
`,
[...roleIds, ...FIXED_ROLE_CODES]
roleIds
);
return rows.map((row) => row.id);
@@ -2,11 +2,12 @@ import type { FastifyInstance } from "fastify";
import { ok } from "../../shared/response";
import { authGuard, permissionGuard } from "../auth/auth.guard";
import { authService } from "../auth/auth.service";
import { getVisibleMenus, PERMISSIONS } from "./permission.policy";
import {
getPermissionPolicies,
getVisibleMenus,
PERMISSIONS,
} from "./permission.policy";
rolePermissionParamSchema,
updateRolePermissionsBodySchema,
} from "./permission.schema";
import { permissionService } from "./permission.service";
export async function permissionRoutes(app: FastifyInstance): Promise<void> {
app.get("/permissions/me", { preHandler: authGuard }, async (request) => {
@@ -21,6 +22,27 @@ export async function permissionRoutes(app: FastifyInstance): Promise<void> {
app.get(
"/permissions/policies",
{ preHandler: permissionGuard(PERMISSIONS.PERMISSION_VIEW) },
async () => ok(getPermissionPolicies()),
async () => ok(await permissionService.listPolicies()),
);
app.get(
"/permissions/definitions",
{ preHandler: permissionGuard(PERMISSIONS.PERMISSION_VIEW) },
async () => ok(permissionService.getDefinitions()),
);
app.put(
"/permissions/roles/:roleId",
{ preHandler: permissionGuard(PERMISSIONS.PERMISSION_MANAGE) },
async (request) => {
const params = rolePermissionParamSchema.parse(request.params);
const body = updateRolePermissionsBodySchema.parse(request.body);
const policy = await permissionService.updateRolePermissions(
params.roleId,
body.permissions,
);
return ok(policy);
},
);
}
+199 -79
View File
@@ -1,4 +1,4 @@
import type { AuthAccountType, AuthUser } from "../auth/auth.types";
import type { AuthUser } from "../auth/auth.types";
export const PERMISSIONS = {
STORE_VIEW: "store:view",
@@ -9,6 +9,7 @@ export const PERMISSIONS = {
EMPLOYEE_VIEW_STORE: "employee:view:store",
EMPLOYEE_MANAGE: "employee:manage",
PERMISSION_VIEW: "permission:view",
PERMISSION_MANAGE: "permission:manage",
} as const;
export type PermissionCode = (typeof PERMISSIONS)[keyof typeof PERMISSIONS];
@@ -21,16 +22,25 @@ export interface PermissionMenu {
actions: string[];
}
const ROLE_PERMISSION_MAP: Record<string, PermissionCode[]> = {
admin: [
PERMISSIONS.STORE_VIEW,
PERMISSIONS.STORE_MANAGE,
PERMISSIONS.ROLE_VIEW,
PERMISSIONS.EMPLOYEE_VIEW_ALL,
PERMISSIONS.EMPLOYEE_MANAGE,
PERMISSIONS.PERMISSION_VIEW,
],
store_manager: [PERMISSIONS.EMPLOYEE_VIEW_STORE],
export interface PermissionDefinition {
code: PermissionCode;
title: string;
description: string;
groupKey: string;
groupTitle: string;
}
export interface PermissionDefinitionGroup {
key: string;
title: string;
permissions: PermissionDefinition[];
}
const ACTION_LABELS: Record<string, string> = {
view: "查看",
create: "新增",
update: "编辑",
delete: "删除",
};
const MENUS: PermissionMenu[] = [
@@ -57,27 +67,124 @@ const MENUS: PermissionMenu[] = [
title: "权限管理",
icon: "key",
permission: PERMISSIONS.PERMISSION_VIEW,
actions: ["view"],
actions: ["view", "update"],
},
];
export function resolvePermissions(
accountType: AuthAccountType,
roleCodes: string[],
): string[] {
if (accountType === "SUPER_ADMIN") {
return ["*"];
const PERMISSION_DEFINITIONS: PermissionDefinition[] = [
{
code: PERMISSIONS.STORE_VIEW,
title: "查看门店",
description: "查看门店列表、门店详情和门店下拉选项。",
groupKey: "stores",
groupTitle: "门店管理",
},
{
code: PERMISSIONS.STORE_MANAGE,
title: "管理门店",
description: "新增、编辑、停用和删除门店。",
groupKey: "stores",
groupTitle: "门店管理",
},
{
code: PERMISSIONS.ROLE_VIEW,
title: "查看角色",
description: "查看角色列表、角色详情和角色下拉选项。",
groupKey: "roles",
groupTitle: "角色管理",
},
{
code: PERMISSIONS.ROLE_MANAGE,
title: "管理角色",
description: "新增、编辑和删除非系统角色。",
groupKey: "roles",
groupTitle: "角色管理",
},
{
code: PERMISSIONS.EMPLOYEE_VIEW_ALL,
title: "查看全部员工",
description: "查看所有门店的员工列表和员工详情。",
groupKey: "employees",
groupTitle: "员工管理",
},
{
code: PERMISSIONS.EMPLOYEE_VIEW_STORE,
title: "查看本店员工",
description: "仅查看自己所属门店的员工列表和员工详情。",
groupKey: "employees",
groupTitle: "员工管理",
},
{
code: PERMISSIONS.EMPLOYEE_MANAGE,
title: "管理员工",
description: "新增、编辑、启停和删除员工,并维护员工角色。",
groupKey: "employees",
groupTitle: "员工管理",
},
{
code: PERMISSIONS.PERMISSION_VIEW,
title: "查看权限",
description: "查看角色权限策略和权限点定义。",
groupKey: "permissions",
groupTitle: "权限管理",
},
{
code: PERMISSIONS.PERMISSION_MANAGE,
title: "分配权限",
description: "修改角色拥有的权限点,变更会在下次接口鉴权时实时生效。",
groupKey: "permissions",
groupTitle: "权限管理",
},
];
const PERMISSION_ORDER = new Map(
PERMISSION_DEFINITIONS.map((definition, index) => [definition.code, index]),
);
const PERMISSION_DEPENDENCIES: Partial<
Record<PermissionCode, PermissionCode[]>
> = {
[PERMISSIONS.STORE_MANAGE]: [PERMISSIONS.STORE_VIEW],
[PERMISSIONS.ROLE_MANAGE]: [PERMISSIONS.ROLE_VIEW],
[PERMISSIONS.EMPLOYEE_MANAGE]: [PERMISSIONS.EMPLOYEE_VIEW_ALL],
[PERMISSIONS.PERMISSION_MANAGE]: [PERMISSIONS.PERMISSION_VIEW],
};
export function isPermissionCode(value: string): value is PermissionCode {
return PERMISSION_ORDER.has(value as PermissionCode);
}
export function sortPermissions(permissions: string[]): PermissionCode[] {
return [...new Set(permissions)]
.filter(isPermissionCode)
.sort(
(left, right) =>
(PERMISSION_ORDER.get(left) ?? 0) - (PERMISSION_ORDER.get(right) ?? 0),
);
}
export function getInvalidPermissionCodes(permissions: string[]): string[] {
return [...new Set(permissions)].filter((permission) => !isPermissionCode(permission));
}
export function normalizePermissionCodes(permissions: string[]): PermissionCode[] {
const normalized = new Set<PermissionCode>();
function addWithDependencies(permission: PermissionCode): void {
for (const dependency of PERMISSION_DEPENDENCIES[permission] ?? []) {
addWithDependencies(dependency);
}
normalized.add(permission);
}
const permissions = new Set<PermissionCode>();
for (const roleCode of roleCodes) {
for (const permission of ROLE_PERMISSION_MAP[roleCode] ?? []) {
permissions.add(permission);
for (const permission of permissions) {
if (isPermissionCode(permission)) {
addWithDependencies(permission);
}
}
return [...permissions];
return sortPermissions([...normalized]);
}
export function hasPermission(
@@ -97,36 +204,37 @@ export function hasAnyPermission(
}
export function getVisibleMenus(user: AuthUser): PermissionMenu[] {
const employeeMenuPermissions = [
PERMISSIONS.EMPLOYEE_VIEW_ALL,
PERMISSIONS.EMPLOYEE_VIEW_STORE,
];
return MENUS.filter((menu) => {
if (menu.key === "employees") {
return hasAnyPermission(user.permissions, employeeMenuPermissions);
}
return hasPermission(user.permissions, menu.permission);
}).map((menu) => ({
return MENUS.map((menu) => ({
...menu,
actions: getAllowedActions(user, menu.key),
}));
})).filter((menu) => menu.actions.length > 0);
}
export function getAllowedActions(user: AuthUser, menuKey: string): string[] {
const menu = MENUS.find((item) => item.key === menuKey);
if (!menu) {
return [];
}
if (user.accountType === "SUPER_ADMIN") {
return MENUS.find((menu) => menu.key === menuKey)?.actions ?? [];
return menu.actions;
}
if (menuKey === "stores") {
return hasPermission(user.permissions, PERMISSIONS.STORE_MANAGE)
? ["view", "create", "update", "delete"]
: ["view"];
if (hasPermission(user.permissions, PERMISSIONS.STORE_MANAGE)) {
return ["view", "create", "update", "delete"];
}
return hasPermission(user.permissions, PERMISSIONS.STORE_VIEW) ? ["view"] : [];
}
if (menuKey === "roles") {
return ["view"];
if (hasPermission(user.permissions, PERMISSIONS.ROLE_MANAGE)) {
return ["view", "create", "update", "delete"];
}
return hasPermission(user.permissions, PERMISSIONS.ROLE_VIEW) ? ["view"] : [];
}
if (menuKey === "employees") {
@@ -134,49 +242,61 @@ export function getAllowedActions(user: AuthUser, menuKey: string): string[] {
return ["view", "create", "update", "delete"];
}
if (hasPermission(user.permissions, PERMISSIONS.EMPLOYEE_VIEW_STORE)) {
return ["view"];
}
return hasAnyPermission(user.permissions, [
PERMISSIONS.EMPLOYEE_VIEW_ALL,
PERMISSIONS.EMPLOYEE_VIEW_STORE,
])
? ["view"]
: [];
}
if (menuKey === "permissions") {
return ["view"];
if (hasPermission(user.permissions, PERMISSIONS.PERMISSION_MANAGE)) {
return ["view", "update"];
}
return hasPermission(user.permissions, PERMISSIONS.PERMISSION_VIEW)
? ["view"]
: [];
}
return [];
}
export function getPermissionPolicies() {
return [
{
roleCode: "super_admin",
roleName: "超级管理员",
scope: "全部门店",
permissions: ["*"],
menus: MENUS.map((menu) => ({
key: menu.key,
title: menu.title,
actions: menu.actions,
})),
},
{
roleCode: "admin",
roleName: "管理员",
scope: "全部门店",
permissions: ROLE_PERMISSION_MAP.admin,
menus: [
{ key: "stores", title: "门店管理", actions: ["view", "create", "update", "delete"] },
{ key: "roles", title: "角色管理", actions: ["view"] },
{ key: "employees", title: "员工管理", actions: ["view", "create", "update", "delete"] },
{ key: "permissions", title: "权限管理", actions: ["view"] },
],
},
{
roleCode: "store_manager",
roleName: "店长",
scope: "当前门店",
permissions: ROLE_PERMISSION_MAP.store_manager,
menus: [{ key: "employees", title: "员工管理", actions: ["view"] }],
},
];
export function getPermissionDefinitions(): {
permissions: PermissionDefinition[];
groups: PermissionDefinitionGroup[];
menus: Array<PermissionMenu & { actionLabels: Record<string, string> }>;
} {
const groups = new Map<string, PermissionDefinitionGroup>();
for (const definition of PERMISSION_DEFINITIONS) {
const group = groups.get(definition.groupKey) ?? {
key: definition.groupKey,
title: definition.groupTitle,
permissions: [],
};
group.permissions.push(definition);
groups.set(definition.groupKey, group);
}
return {
permissions: PERMISSION_DEFINITIONS,
groups: [...groups.values()],
menus: MENUS.map((menu) => ({
...menu,
actionLabels: Object.fromEntries(
menu.actions.map((action) => [action, ACTION_LABELS[action] ?? action]),
),
})),
};
}
export function toPermissionPolicyMenus(user: AuthUser) {
return getVisibleMenus(user).map((menu) => ({
key: menu.key,
title: menu.title,
actions: menu.actions,
}));
}
@@ -0,0 +1,156 @@
import type { PoolConnection, RowDataPacket } from "mysql2/promise";
import { pool } from "../../db/pool";
type DbExecutor = typeof pool | PoolConnection;
export interface RolePermissionRecord {
roleId: number;
roleCode: string;
roleName: string;
roleDescription: string | null;
isSystem: boolean;
permissions: string[];
}
interface RolePermissionRow extends RowDataPacket {
role_id: number;
role_code: string;
role_name: string;
role_description: string | null;
is_system: number;
permission_code: string | null;
}
interface PermissionCodeRow extends RowDataPacket {
permission_code: string;
}
function toRolePermissionRecords(
rows: RolePermissionRow[],
): RolePermissionRecord[] {
const records = new Map<number, RolePermissionRecord>();
for (const row of rows) {
const record = records.get(row.role_id) ?? {
roleId: row.role_id,
roleCode: row.role_code,
roleName: row.role_name,
roleDescription: row.role_description,
isSystem: row.is_system === 1,
permissions: [],
};
if (row.permission_code) {
record.permissions.push(row.permission_code);
}
records.set(row.role_id, record);
}
return [...records.values()];
}
export const permissionRepository = {
async withTransaction<T>(
handler: (connection: PoolConnection) => Promise<T>,
): Promise<T> {
const connection = await pool.getConnection();
try {
await connection.beginTransaction();
const result = await handler(connection);
await connection.commit();
return result;
} catch (error) {
await connection.rollback();
throw error;
} finally {
connection.release();
}
},
async listRolePermissions(
db: DbExecutor = pool,
): Promise<RolePermissionRecord[]> {
const [rows] = await db.execute<RolePermissionRow[]>(
`
SELECT
r.id AS role_id,
r.code AS role_code,
r.name AS role_name,
r.description AS role_description,
r.is_system,
rp.permission_code
FROM roles r
LEFT JOIN role_permissions rp ON rp.role_id = r.id
ORDER BY r.id ASC, rp.permission_code ASC
`,
);
return toRolePermissionRecords(rows);
},
async findRolePermissionsByRoleId(
roleId: number,
db: DbExecutor = pool,
): Promise<RolePermissionRecord | null> {
const [rows] = await db.execute<RolePermissionRow[]>(
`
SELECT
r.id AS role_id,
r.code AS role_code,
r.name AS role_name,
r.description AS role_description,
r.is_system,
rp.permission_code
FROM roles r
LEFT JOIN role_permissions rp ON rp.role_id = r.id
WHERE r.id = ?
ORDER BY rp.permission_code ASC
`,
[roleId],
);
return toRolePermissionRecords(rows)[0] ?? null;
},
async findPermissionCodesByRoleCodes(roleCodes: string[]): Promise<string[]> {
if (roleCodes.length === 0) {
return [];
}
const placeholders = roleCodes.map(() => "?").join(", ");
const [rows] = await pool.execute<PermissionCodeRow[]>(
`
SELECT DISTINCT rp.permission_code
FROM role_permissions rp
INNER JOIN roles r ON r.id = rp.role_id
WHERE r.code IN (${placeholders})
ORDER BY rp.permission_code ASC
`,
roleCodes,
);
return rows.map((row) => row.permission_code);
},
async replaceRolePermissions(
roleId: number,
permissionCodes: string[],
db: DbExecutor = pool,
): Promise<void> {
await db.execute("DELETE FROM role_permissions WHERE role_id = ?", [roleId]);
if (permissionCodes.length === 0) {
return;
}
await db.execute(
`
INSERT INTO role_permissions (role_id, permission_code)
VALUES ${permissionCodes.map(() => "(?, ?)").join(", ")}
`,
permissionCodes.flatMap((permissionCode) => [roleId, permissionCode]),
);
},
};
@@ -0,0 +1,12 @@
import { z } from "zod";
export const rolePermissionParamSchema = z.object({
roleId: z.coerce.number().int().positive(),
});
export const updateRolePermissionsBodySchema = z.object({
permissions: z
.array(z.string().trim().min(1).max(100))
.max(50, "单个角色不建议绑定过多权限点")
.default([]),
});
@@ -0,0 +1,166 @@
import { badRequest, notFound } from "../../shared/http-error";
import type { AuthAccountType, AuthUser } from "../auth/auth.types";
import {
getInvalidPermissionCodes,
getPermissionDefinitions,
normalizePermissionCodes,
toPermissionPolicyMenus,
} from "./permission.policy";
import {
permissionRepository,
type RolePermissionRecord,
} from "./permission.repository";
export interface PermissionPolicy {
roleId: number;
roleCode: string;
roleName: string;
roleDescription: string | null;
isSystem: boolean;
editable: boolean;
scope: string;
permissions: string[];
menus: Array<{
key: string;
title: string;
actions: string[];
}>;
}
function resolveScope(permissions: string[]): string {
if (permissions.includes("*")) {
return "全部门店";
}
if (permissions.length === 0) {
return "未分配后台权限";
}
if (permissions.includes("employee:view:store")) {
return "当前门店";
}
return "按权限点控制";
}
function buildPolicy(record: RolePermissionRecord): PermissionPolicy {
const permissions = normalizePermissionCodes(record.permissions);
const user: AuthUser = {
id: 0,
username: record.roleCode,
displayName: record.roleName,
accountType: "EMPLOYEE",
roles: [
{
id: record.roleId,
code: record.roleCode,
name: record.roleName,
},
],
permissions,
canManage: permissions.some((permission) => permission.endsWith(":manage")),
};
return {
roleId: record.roleId,
roleCode: record.roleCode,
roleName: record.roleName,
roleDescription: record.roleDescription,
isSystem: record.isSystem,
editable: true,
scope: resolveScope(permissions),
permissions,
menus: toPermissionPolicyMenus(user),
};
}
function buildSuperAdminPolicy(): PermissionPolicy {
const user: AuthUser = {
id: 0,
username: "super_admin",
displayName: "超级管理员",
accountType: "SUPER_ADMIN",
roles: [
{
id: 0,
code: "super_admin",
name: "超级管理员",
},
],
permissions: ["*"],
canManage: true,
};
return {
roleId: 0,
roleCode: "super_admin",
roleName: "超级管理员",
roleDescription: "系统内置最高权限账号,不参与角色权限分配。",
isSystem: true,
editable: false,
scope: "全部门店",
permissions: ["*"],
menus: toPermissionPolicyMenus(user),
};
}
export const permissionService = {
async resolvePermissions(
accountType: AuthAccountType,
roleCodes: string[],
): Promise<string[]> {
if (accountType === "SUPER_ADMIN") {
return ["*"];
}
const permissions =
await permissionRepository.findPermissionCodesByRoleCodes(roleCodes);
return normalizePermissionCodes(permissions);
},
getDefinitions() {
return getPermissionDefinitions();
},
async listPolicies(): Promise<PermissionPolicy[]> {
const records = await permissionRepository.listRolePermissions();
return [buildSuperAdminPolicy(), ...records.map(buildPolicy)];
},
async updateRolePermissions(
roleId: number,
permissions: string[],
): Promise<PermissionPolicy> {
const invalidPermissions = getInvalidPermissionCodes(permissions);
if (invalidPermissions.length > 0) {
throw badRequest("提交的权限点不存在", { invalidPermissions });
}
const normalizedPermissions = normalizePermissionCodes(permissions);
return permissionRepository.withTransaction(async (connection) => {
const role = await permissionRepository.findRolePermissionsByRoleId(
roleId,
connection,
);
if (!role) {
throw notFound("角色不存在");
}
await permissionRepository.replaceRolePermissions(
roleId,
normalizedPermissions,
connection,
);
return buildPolicy({
...role,
permissions: normalizedPermissions,
});
});
},
};