feat: 支持动态角色权限分配
This commit is contained in:
@@ -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
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;
|
||||
@@ -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