feat: 支持动态角色权限分配
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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[]>(
|
||||
`
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
|
||||
@@ -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,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);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user