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
+44 -16
View File
@@ -17,12 +17,12 @@
## 项目能力
- 门店管理:查询、新增、修改、软删除门店。
- 角色管理:管理员可查看角色;超级管理员可新增、修改、删除自定义角色,服务端内置角色不可变更。
- 角色管理:拥有 `role:manage` 的账号可新增、修改、删除自定义角色,服务端内置角色不可变更。
- 员工管理:分页查询、新增、修改、启用/停用、软删除员工。
- 员工角色:一个员工可以绑定多个角色。
- 登录账号:超级管理员和员工都可以登录。
- 后台权限:超级管理员拥有所有权限;管理员可管理门店和员工、只读角色;店长只看当前门店员工
- 固定权限:菜单和动作权限由服务端写死,前端只按接口返回结果展示
- 后台权限:超级管理员拥有所有权限;角色权限由 `role_permissions` 动态分配
- 动态权限:菜单和按钮动作由 `/api/permissions/me` 返回,前端可通过权限管理页分配角色权限
- JWT 鉴权:登录后签发 token,除健康检查和登录外,接口都需要 Bearer token。
- 数据校验:使用 zod 校验路径参数、查询参数和请求体。
- 数据库迁移:使用 `migrations/*.sql` 管理建表和初始化数据。
@@ -40,12 +40,15 @@
├── .gitignore # Git 忽略规则,排除本地配置、依赖和编译产物
├── AGENTS.md # Codex/Agent 入口指令,当前指向 RTK.md
├── RTK.md # 项目协作规则和开发约定
├── docs/
│ └── API.md # 前端对接接口文档
├── migrations/ # 数据库迁移 SQL
│ ├── 001_initial_schema.sql # 创建基础表结构
│ ├── 002_seed_demo_data.sql # 初始化演示门店和角色
│ ├── 003_create_super_admins.sql # 创建超级管理员表和默认账号
│ ├── 004_add_employee_login_fields.sql # 给员工补充登录字段
── 005_refine_employee_login_and_role_policy.sql # 调整员工默认密码、手机号唯一和系统角色
── 005_refine_employee_login_and_role_policy.sql # 调整员工默认密码、手机号唯一和系统角色
│ └── 006_create_role_permissions.sql # 创建角色权限关系表并初始化默认权限
├── src/
│ ├── app.ts # 创建 Fastify 应用、注册路由、统一错误处理
│ ├── server.ts # 启动 HTTP 服务和优雅停机
@@ -58,7 +61,7 @@
│ │ ├── auth/ # 登录、当前用户和 JWT 鉴权模块
│ │ ├── catalog/ # 门店和角色模块
│ │ ├── employees/ # 员工 CRUD 模块
│ │ └── permissions/ # 服务端固定菜单动作权限策略
│ │ └── permissions/ # 权限点定义、角色权限分配和菜单动作策略
│ └── shared/ # 通用响应结构和业务错误
├── docker-compose.yml # 本地 MySQL
├── package.json
@@ -77,6 +80,7 @@
| `.gitignore` | 忽略本地环境变量、依赖目录、编译产物和系统文件,避免把无关文件推到仓库。 |
| `AGENTS.md` | Agent 工具读取的入口文件,当前通过 `@RTK.md` 引入项目规则。 |
| `RTK.md` | 本项目的协作规则,例如使用中文说明、保持分层、改目录时同步 README。 |
| `docs/API.md` | 面向前端对接的完整接口文档,包含认证、权限、字段约束、示例请求响应和错误码。 |
| `migrations/` | 数据库迁移目录。所有建表、改表、初始化基础数据的 SQL 都放在这里。 |
| `src/app.ts` | 创建 Fastify 应用,注册路由,处理健康检查和全局错误。 |
| `src/server.ts` | 真正启动 HTTP 服务,监听端口,并处理优雅停机。 |
@@ -86,7 +90,7 @@
| `src/modules/auth/` | 登录鉴权模块,负责后台登录、员工端登录、密码校验、JWT 签发、当前用户查询和权限 guard。 |
| `src/modules/catalog/` | 门店和角色模块,负责基础资料接口。 |
| `src/modules/employees/` | 员工模块,负责员工分页、详情、新增、修改、状态变更和软删除。 |
| `src/modules/permissions/` | 服务端固定权限策略,返回前端菜单动作权限和权限策略说明。 |
| `src/modules/permissions/` | 权限模块,维护权限点定义、角色权限分配、当前用户菜单动作权限和权限策略说明。 |
| `src/shared/` | 跨模块复用的响应结构和业务错误类型。 |
| `docker-compose.yml` | 本地开发用 MySQL 容器配置。 |
| `package.json` | 项目信息、依赖和常用脚本;脚本会读取现有 `.env.development`。 |
@@ -160,6 +164,7 @@ pnpm db:migrate
- `roles`:角色表
- `employees`:员工表
- `employee_roles`:员工角色关系表
- `role_permissions`:角色权限关系表
- `super_admins`:超级管理员表
- `schema_migrations`:迁移记录表
@@ -217,6 +222,10 @@ pnpm db:migrate
pnpm dev
```
## 接口文档
完整前端对接文档见 [docs/API.md](./docs/API.md),包含认证、权限、字段约束、全部接口、示例请求响应和常见错误码。
## 接口响应格式
成功响应:
@@ -304,7 +313,7 @@ curl -X POST http://localhost:3500/api/auth/employee/login \
```
响应里的 `data.token` 就是后续接口要使用的 JWT。
响应里的 `data.user.permissions` 是服务端计算出的固定权限点;菜单和按钮动作以 `/api/permissions/me` 返回结果为准。
响应里的 `data.user.permissions` 是服务端按角色动态计算出的权限点;菜单和按钮动作以 `/api/permissions/me` 返回结果为准。
为了方便测试,可以先把 token 保存成 shell 变量:
@@ -332,23 +341,39 @@ curl http://localhost:3500/api/permissions/me \
-H "Authorization: Bearer $TOKEN"
```
查看服务端固定权限策略:
查看角色权限策略:
```bash
curl http://localhost:3500/api/permissions/policies \
-H "Authorization: Bearer $TOKEN"
```
查看可分配权限点定义:
```bash
curl http://localhost:3500/api/permissions/definitions \
-H "Authorization: Bearer $TOKEN"
```
给角色分配权限:
```bash
curl -X PUT http://localhost:3500/api/permissions/roles/5 \
-H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' \
-d '{"permissions":["store:view","store:manage","permission:view","permission:manage"]}'
```
如果员工账号没有后台菜单权限,可以通过员工端登录并访问 `/api/auth/me`,但访问门店、角色、员工等后台管理接口会返回 `403 FORBIDDEN`
### 后台菜单权限
| 菜单 | 超级管理员 | 管理员 `admin` | 店长 `store_manager` | 其他员工 |
| 菜单 | 超级管理员 | 默认管理员 `admin` | 默认店长 `store_manager` | 其他角色 |
| --- | --- | --- | --- | --- |
| 门店管理 | 查看、新增、修改、删除 | 查看、新增、修改、删除 | 不可见 | 不可见 |
| 角色管理 | 查看、新增、修改、删除自定义角色 | 查看 | 不可见 | 不可见 |
| 角色管理 | 查看、新增、修改、删除自定义角色 | 查看、新增、修改、删除自定义角色 | 不可见 | 按角色权限决定 |
| 员工管理 | 查看全部、新增、修改、删除 | 查看全部、新增、修改、删除 | 仅查看当前门店员工 | 不可见 |
| 权限管理 | 查看 | 查看 | 不可见 | 不可见 |
| 权限管理 | 查看、分配 | 查看、分配 | 不可见 | 按角色权限决定 |
## 门店接口示例
@@ -402,8 +427,8 @@ curl -X DELETE http://localhost:3500/api/stores/1 \
## 角色接口示例
角色管理页面只有超级管理员和管理员可见。管理员只能看;超级管理员可以新增、修改、删除自定义角色。服务端内置角色不可修改或删除。
自定义角色默认不绑定后台菜单权限;后台菜单权限仍由服务端固定策略控制
角色管理页面`role:view` 控制可见性,由 `role:manage` 控制新增、修改、删除。服务端内置角色不可修改或删除。
自定义角色默认不绑定后台菜单权限;可以在权限管理页面给角色分配权限后,再把角色绑定给员工
查询角色:
@@ -511,6 +536,7 @@ curl -X DELETE http://localhost:3500/api/employees/1 \
- [003_create_super_admins.sql](./migrations/003_create_super_admins.sql):创建超级管理员表,并初始化本地登录账号。
- [004_add_employee_login_fields.sql](./migrations/004_add_employee_login_fields.sql):给员工补充登录密码哈希和最后登录时间。
- [005_refine_employee_login_and_role_policy.sql](./migrations/005_refine_employee_login_and_role_policy.sql):员工默认密码改为 `pw111111`,手机号改为全局唯一,并标记服务端内置角色。
- [006_create_role_permissions.sql](./migrations/006_create_role_permissions.sql):创建角色权限关系表,并初始化 `admin``store_manager` 的默认权限。
执行 `pnpm db:migrate` 时,脚本会:
@@ -533,10 +559,12 @@ migrations/003_add_employee_email.sql
- `employees.active_phone` 是生成列,用来实现“未删除员工手机号全局唯一”。
- `employees.password_hash` 让员工也能登录,默认本地密码是 `pw111111`
- `employee_roles` 是多对多关系表。
- `role_permissions` 保存角色和权限点的多对多关系,权限分配保存后会在接口鉴权时实时生效。
- `super_admins` 保存超级管理员账号,密码使用 PBKDF2 哈希,禁止存明文。
- 菜单和动作权限由 `src/modules/permissions/` 固定,前端根据 `/api/permissions/me` 渲染
- `admin` 角色可查看角色、管理门店和员工;`store_manager` 只能查看当前门店员工
- JWT 鉴权在 `src/modules/auth/` 中实现,`permissionGuard` 按固定权限点保护接口
- 权限点定义`src/modules/permissions/` 固定,角色拥有的权限点由 `role_permissions` 动态决定
- 前端根据 `/api/permissions/me` 渲染菜单和按钮,根据 `/api/permissions/definitions` 渲染可分配权限点
- `admin` 角色默认可管理门店、角色、员工和权限;`store_manager` 默认只能查看当前门店员工
- JWT 鉴权在 `src/modules/auth/` 中实现,`permissionGuard` 按当前角色权限点保护接口。
- `repository` 使用参数化查询,避免 SQL 注入。
- `service` 使用事务保证员工信息和角色绑定同时成功或同时失败。
- `app.ts` 统一注册 JWT、业务路由和错误处理,并处理 zod 校验错误、业务错误和数据库唯一索引冲突。
+1134
View File
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,27 @@
-- 006_create_role_permissions.sql
-- 角色权限关系表:把每个角色拥有的权限点落库,后台才能动态分配权限。
CREATE TABLE IF NOT EXISTS role_permissions (
role_id INT UNSIGNED NOT NULL COMMENT '角色 ID',
permission_code VARCHAR(100) NOT NULL COMMENT '权限点编码',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
PRIMARY KEY (role_id, permission_code),
KEY idx_role_permissions_permission_code (permission_code),
CONSTRAINT fk_role_permissions_role_id FOREIGN KEY (role_id) REFERENCES roles (id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='角色权限关系表';
-- 初始化内置角色的默认后台权限,保持历史行为,同时给管理员开放权限分配能力。
INSERT IGNORE INTO role_permissions (role_id, permission_code)
SELECT r.id, p.permission_code
FROM roles r
INNER JOIN (
SELECT 'admin' AS role_code, 'store:view' AS permission_code
UNION ALL SELECT 'admin', 'store:manage'
UNION ALL SELECT 'admin', 'role:view'
UNION ALL SELECT 'admin', 'role:manage'
UNION ALL SELECT 'admin', 'employee:view:all'
UNION ALL SELECT 'admin', 'employee:manage'
UNION ALL SELECT 'admin', 'permission:view'
UNION ALL SELECT 'admin', 'permission:manage'
UNION ALL SELECT 'store_manager', 'employee:view:store'
) p ON p.role_code = r.code;
+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,
});
});
},
};