From aa65cb0928bd4761a4c54dc987dbbeb7886dd27c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B9=9B=E5=85=AE?= Date: Tue, 26 May 2026 12:30:38 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E8=AE=BE=E8=AE=A1=E8=8F=9C=E5=8D=95?= =?UTF-8?q?=E6=9D=83=E9=99=90=E5=92=8C=E5=91=98=E5=B7=A5=E7=AB=AF=E7=99=BB?= =?UTF-8?q?=E5=BD=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 93 +++++++-- ..._refine_employee_login_and_role_policy.sql | 21 ++ src/app.ts | 2 + src/modules/auth/auth.controller.ts | 28 ++- src/modules/auth/auth.guard.ts | 18 +- src/modules/auth/auth.service.ts | 57 +++++- src/modules/auth/auth.types.ts | 2 + src/modules/catalog/catalog.controller.ts | 40 +++- src/modules/catalog/catalog.repository.ts | 110 +++++++++-- src/modules/catalog/catalog.schema.ts | 21 ++ src/modules/catalog/catalog.service.ts | 52 ++++- src/modules/catalog/catalog.types.ts | 13 ++ src/modules/employees/employee.controller.ts | 82 +++++++- src/modules/employees/employee.repository.ts | 10 +- src/modules/employees/employee.service.ts | 15 +- .../permissions/permission.controller.ts | 26 +++ src/modules/permissions/permission.policy.ts | 182 ++++++++++++++++++ 17 files changed, 708 insertions(+), 64 deletions(-) create mode 100644 migrations/005_refine_employee_login_and_role_policy.sql create mode 100644 src/modules/permissions/permission.controller.ts create mode 100644 src/modules/permissions/permission.policy.ts diff --git a/README.md b/README.md index 5ce3fc5..d4e92df 100644 --- a/README.md +++ b/README.md @@ -17,12 +17,12 @@ ## 项目能力 - 门店管理:查询、新增、修改、软删除门店。 -- 角色管理:查询服务端固定角色,用于员工权限分配。 +- 角色管理:管理员可查看角色;超级管理员可新增、修改、删除自定义角色,服务端内置角色不可变更。 - 员工管理:分页查询、新增、修改、启用/停用、软删除员工。 - 员工角色:一个员工可以绑定多个角色。 - 登录账号:超级管理员和员工都可以登录。 -- 后台权限:超级管理员拥有所有权限;员工只有绑定 `admin` 角色时才能访问后台管理接口。 -- 固定角色:店长、收银员、后厨、兼职、管理员是服务端固定角色,不提供角色新增、修改、删除接口。 +- 后台权限:超级管理员拥有所有权限;管理员可管理门店和员工、只读角色;店长只看当前门店员工。 +- 固定权限:菜单和动作权限由服务端写死,前端只按接口返回结果展示。 - JWT 鉴权:登录后签发 token,除健康检查和登录外,接口都需要 Bearer token。 - 数据校验:使用 zod 校验路径参数、查询参数和请求体。 - 数据库迁移:使用 `migrations/*.sql` 管理建表和初始化数据。 @@ -44,7 +44,8 @@ │ ├── 001_initial_schema.sql # 创建基础表结构 │ ├── 002_seed_demo_data.sql # 初始化演示门店和角色 │ ├── 003_create_super_admins.sql # 创建超级管理员表和默认账号 -│ └── 004_add_employee_login_fields.sql # 给员工补充登录字段 +│ ├── 004_add_employee_login_fields.sql # 给员工补充登录字段 +│ └── 005_refine_employee_login_and_role_policy.sql # 调整员工默认密码、手机号唯一和系统角色 ├── src/ │ ├── app.ts # 创建 Fastify 应用、注册路由、统一错误处理 │ ├── server.ts # 启动 HTTP 服务和优雅停机 @@ -56,7 +57,8 @@ │ ├── modules/ │ │ ├── auth/ # 登录、当前用户和 JWT 鉴权模块 │ │ ├── catalog/ # 门店和角色模块 -│ │ └── employees/ # 员工 CRUD 模块 +│ │ ├── employees/ # 员工 CRUD 模块 +│ │ └── permissions/ # 服务端固定菜单和动作权限策略 │ └── shared/ # 通用响应结构和业务错误 ├── docker-compose.yml # 本地 MySQL ├── package.json @@ -81,9 +83,10 @@ | `src/config/env.ts` | 使用 zod 校验 `.env.development` 中的环境变量,避免配置错误拖到请求阶段才暴露。 | | `src/db/migrate.ts` | 执行 `migrations/*.sql`,并用 `schema_migrations` 记录已执行迁移。 | | `src/db/pool.ts` | 创建 MySQL 连接池,提供数据库健康检查和关闭连接的方法。 | -| `src/modules/auth/` | 登录鉴权模块,负责超级管理员和员工登录、密码校验、JWT 签发、当前用户查询和后台权限 guard。 | +| `src/modules/auth/` | 登录鉴权模块,负责后台登录、员工端登录、密码校验、JWT 签发、当前用户查询和权限 guard。 | | `src/modules/catalog/` | 门店和角色模块,负责基础资料接口。 | | `src/modules/employees/` | 员工模块,负责员工分页、详情、新增、修改、状态变更和软删除。 | +| `src/modules/permissions/` | 服务端固定权限策略,返回前端菜单、动作权限和权限策略说明。 | | `src/shared/` | 跨模块复用的响应结构和业务错误类型。 | | `docker-compose.yml` | 本地开发用 MySQL 容器配置。 | | `package.json` | 项目信息、依赖和常用脚本;脚本会读取现有 `.env.development`。 | @@ -260,7 +263,7 @@ pnpm dev 本项目有两类可登录账号: - 超级管理员:拥有所有后台管理权限。 -- 员工:都可以登录;只有绑定 `admin` 角色的员工才能访问后台管理接口。 +- 员工:都可以通过员工端接口登录;后台登录只开放给有后台菜单权限的员工,例如 `admin` 和 `store_manager`。 默认本地超级管理员账号由 [003_create_super_admins.sql](./migrations/003_create_super_admins.sql) 初始化: @@ -269,26 +272,39 @@ pnpm dev 密码:Admin@123456 ``` -员工登录字段由 [004_add_employee_login_fields.sql](./migrations/004_add_employee_login_fields.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) 初始化。已有员工和新建员工默认密码是: ```text 账号:员工手机号 -密码:Employee@123456 +密码:pw111111 ``` -登录获取 token: +后台登录获取 token。超级管理员、管理员和店长使用这个接口: ```bash -curl -X POST http://localhost:3500/api/auth/login \ +curl -X POST http://localhost:3500/api/auth/admin/login \ -H 'Content-Type: application/json' \ -d '{ "username": "admin", "password": "Admin@123456" +}' +``` + +`POST /api/auth/login` 也保留为后台登录的兼容入口。 + +员工端登录使用独立接口,给后续 toc 项目使用: + +```bash +curl -X POST http://localhost:3500/api/auth/employee/login \ + -H 'Content-Type: application/json' \ + -d '{ + "username": "13812345678", + "password": "pw111111" }' ``` 响应里的 `data.token` 就是后续接口要使用的 JWT。 -响应里的 `data.user.canManage` 表示当前账号是否能访问后台管理接口。 +响应里的 `data.user.permissions` 是服务端计算出的固定权限点;菜单和按钮动作以 `/api/permissions/me` 返回结果为准。 为了方便测试,可以先把 token 保存成 shell 变量: @@ -309,7 +325,30 @@ curl http://localhost:3500/api/auth/me \ -H "Authorization: Bearer $TOKEN" ``` -如果员工账号没有 `admin` 角色,可以登录并访问 `/api/auth/me`,但访问门店、角色、员工等后台管理接口会返回 `403 FORBIDDEN`。 +获取当前账号菜单和动作权限: + +```bash +curl http://localhost:3500/api/permissions/me \ + -H "Authorization: Bearer $TOKEN" +``` + +查看服务端固定权限策略: + +```bash +curl http://localhost:3500/api/permissions/policies \ + -H "Authorization: Bearer $TOKEN" +``` + +如果员工账号没有后台菜单权限,可以通过员工端登录并访问 `/api/auth/me`,但访问门店、角色、员工等后台管理接口会返回 `403 FORBIDDEN`。 + +### 后台菜单权限 + +| 菜单 | 超级管理员 | 管理员 `admin` | 店长 `store_manager` | 其他员工 | +| --- | --- | --- | --- | --- | +| 门店管理 | 查看、新增、修改、删除 | 查看、新增、修改、删除 | 不可见 | 不可见 | +| 角色管理 | 查看、新增、修改、删除自定义角色 | 仅查看 | 不可见 | 不可见 | +| 员工管理 | 查看全部、新增、修改、删除 | 查看全部、新增、修改、删除 | 仅查看当前门店员工 | 不可见 | +| 权限管理 | 查看 | 查看 | 不可见 | 不可见 | ## 门店接口示例 @@ -363,7 +402,8 @@ curl -X DELETE http://localhost:3500/api/stores/1 \ ## 角色接口示例 -角色是服务端固定权限集合,只允许查询,不允许通过接口新增、修改或删除。 +角色管理页面只有超级管理员和管理员可见。管理员只能看;超级管理员可以新增、修改、删除自定义角色。服务端内置角色不可修改或删除。 +自定义角色默认不绑定后台菜单权限;后台菜单权限仍由服务端固定策略控制。 查询角色: @@ -372,6 +412,19 @@ curl http://localhost:3500/api/roles \ -H "Authorization: Bearer $TOKEN" ``` +新增自定义角色: + +```bash +curl -X POST http://localhost:3500/api/roles \ + -H "Authorization: Bearer $TOKEN" \ + -H 'Content-Type: application/json' \ + -d '{ + "code": "regional_manager", + "name": "区域经理", + "description": "自定义角色示例" + }' +``` + ## 员工接口示例 新增员工: @@ -445,7 +498,7 @@ curl -X DELETE http://localhost:3500/api/employees/1 \ -H "Authorization: Bearer $TOKEN" ``` -同一个门店下,未删除员工的手机号不能重复。员工软删除后,可以重新录入相同手机号。 +员工手机号就是登录账号,未删除员工的手机号不能重复。员工软删除后,可以重新录入相同手机号。 ## 数据库迁移说明 @@ -457,6 +510,7 @@ curl -X DELETE http://localhost:3500/api/employees/1 \ - [002_seed_demo_data.sql](./migrations/002_seed_demo_data.sql):写入一个示例门店和几个常见角色。 - [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`,手机号改为全局唯一,并标记服务端内置角色。 执行 `pnpm db:migrate` 时,脚本会: @@ -476,12 +530,13 @@ migrations/003_add_employee_email.sql ## 学习重点 - `stores.deleted_at` 和 `employees.deleted_at` 用于软删除。 -- `employees.active_phone` 是生成列,用来实现“同一门店未删除员工手机号唯一”。 -- `employees.password_hash` 让员工也能登录,默认本地密码是 `Employee@123456`。 +- `employees.active_phone` 是生成列,用来实现“未删除员工手机号全局唯一”。 +- `employees.password_hash` 让员工也能登录,默认本地密码是 `pw111111`。 - `employee_roles` 是多对多关系表。 - `super_admins` 保存超级管理员账号,密码使用 PBKDF2 哈希,禁止存明文。 -- 角色定义由服务端固定,`admin` 角色用于判断员工是否能访问后台管理接口。 -- JWT 鉴权在 `src/modules/auth/` 中实现,`managementGuard` 统一保护后台管理接口。 +- 菜单和动作权限由 `src/modules/permissions/` 固定,前端根据 `/api/permissions/me` 渲染。 +- `admin` 角色可查看角色、管理门店和员工;`store_manager` 只能查看当前门店员工。 +- JWT 鉴权在 `src/modules/auth/` 中实现,`permissionGuard` 按固定权限点保护接口。 - `repository` 使用参数化查询,避免 SQL 注入。 - `service` 使用事务保证员工信息和角色绑定同时成功或同时失败。 - `app.ts` 统一注册 JWT、业务路由和错误处理,并处理 zod 校验错误、业务错误和数据库唯一索引冲突。 diff --git a/migrations/005_refine_employee_login_and_role_policy.sql b/migrations/005_refine_employee_login_and_role_policy.sql new file mode 100644 index 0000000..ee6e0fb --- /dev/null +++ b/migrations/005_refine_employee_login_and_role_policy.sql @@ -0,0 +1,21 @@ +-- 005_refine_employee_login_and_role_policy.sql +-- 调整员工登录规则:员工默认密码改为 pw111111,手机号在未删除员工范围内全局唯一。 + +ALTER TABLE roles + ADD COLUMN is_system TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否服务端内置角色,内置角色不可修改或删除' AFTER description; + +UPDATE roles +SET is_system = 1 +WHERE code IN ('store_manager', 'cashier', 'kitchen', 'part_time', 'admin'); + +-- 旧版本默认员工密码是 Employee@123456,这里只迁移仍使用旧默认哈希的员工。 +UPDATE employees +SET password_hash = 'pbkdf2$sha256$310000$QnXyjrpm0QzcGYLEPdunWg$CfR-CywGl1c_Omh_3PyOWPmo93EcbMY1FEjjd5MDjFo' +WHERE password_hash = 'pbkdf2$sha256$310000$Vd5Mh3XgZPZ4ozECQzmviA$YnzG8OAqy9bZE9ZmA2yT1RpUl0bbC0yA9LpYUO8LltQ'; + +ALTER TABLE employees + MODIFY password_hash VARCHAR(255) NOT NULL DEFAULT 'pbkdf2$sha256$310000$QnXyjrpm0QzcGYLEPdunWg$CfR-CywGl1c_Omh_3PyOWPmo93EcbMY1FEjjd5MDjFo' COMMENT '员工登录密码哈希,禁止存储明文密码'; + +ALTER TABLE employees + DROP INDEX uk_employees_store_active_phone, + ADD UNIQUE KEY uk_employees_active_phone (active_phone); diff --git a/src/app.ts b/src/app.ts index e7c3f94..2ceebc2 100644 --- a/src/app.ts +++ b/src/app.ts @@ -7,6 +7,7 @@ import { authRoutes } from "./modules/auth/auth.controller"; import { managementGuard } from "./modules/auth/auth.guard"; import { catalogRoutes } from "./modules/catalog/catalog.controller"; import { employeeRoutes } from "./modules/employees/employee.controller"; +import { permissionRoutes } from "./modules/permissions/permission.controller"; import { HttpError } from "./shared/http-error"; import { ok } from "./shared/response"; @@ -56,6 +57,7 @@ export function createApp() { // 登录接口不需要 token;/auth/me 在 authRoutes 内部单独加了 authGuard。 app.register(authRoutes, { prefix: "/api" }); + app.register(permissionRoutes, { prefix: "/api" }); // 业务管理接口统一要求后台权限:超级管理员或拥有 admin 角色的员工。 app.register( diff --git a/src/modules/auth/auth.controller.ts b/src/modules/auth/auth.controller.ts index e5e5e82..2527afb 100644 --- a/src/modules/auth/auth.controller.ts +++ b/src/modules/auth/auth.controller.ts @@ -8,7 +8,33 @@ import { authService } from "./auth.service"; export async function authRoutes(app: FastifyInstance): Promise { app.post("/auth/login", async (request) => { const body = loginBodySchema.parse(request.body); - const { user, payload } = await authService.login(body); + const { user, payload } = await authService.loginManagement(body); + const token = app.jwt.sign(payload); + + return ok({ + token, + tokenType: "Bearer", + expiresIn: env.JWT_EXPIRES_IN, + user, + }); + }); + + app.post("/auth/admin/login", async (request) => { + const body = loginBodySchema.parse(request.body); + const { user, payload } = await authService.loginManagement(body); + const token = app.jwt.sign(payload); + + return ok({ + token, + tokenType: "Bearer", + expiresIn: env.JWT_EXPIRES_IN, + user, + }); + }); + + app.post("/auth/employee/login", async (request) => { + const body = loginBodySchema.parse(request.body); + const { user, payload } = await authService.loginEmployee(body); const token = app.jwt.sign(payload); return ok({ diff --git a/src/modules/auth/auth.guard.ts b/src/modules/auth/auth.guard.ts index c014441..8ded0b9 100644 --- a/src/modules/auth/auth.guard.ts +++ b/src/modules/auth/auth.guard.ts @@ -1,6 +1,10 @@ import type { FastifyRequest } from "fastify"; import { forbidden, unauthorized } from "../../shared/http-error"; import { authService } from "./auth.service"; +import { + hasPermission, + type PermissionCode, +} from "../permissions/permission.policy"; // 统一 JWT 鉴权入口。后续新增需要登录的路由,复用这个 guard 即可。 export async function authGuard(request: FastifyRequest): Promise { @@ -23,7 +27,19 @@ export async function managementGuard(request: FastifyRequest): Promise { const user = await authService.getCurrentUser(request.user); - if (!user.canManage) { + if (!user.permissions.includes("*") && user.permissions.length === 0) { throw forbidden("当前账号没有后台管理权限"); } } + +export function permissionGuard(permission: PermissionCode) { + return async (request: FastifyRequest): Promise => { + await authGuard(request); + + const user = await authService.getCurrentUser(request.user); + + if (!hasPermission(user.permissions, permission)) { + throw forbidden("当前账号没有权限执行该操作"); + } + }; +} diff --git a/src/modules/auth/auth.service.ts b/src/modules/auth/auth.service.ts index 649762b..a1ed4ad 100644 --- a/src/modules/auth/auth.service.ts +++ b/src/modules/auth/auth.service.ts @@ -5,11 +5,15 @@ import type { AuthUser, EmployeeLoginAccount, LoginInput, + LoginScene, SuperAdmin, } from "./auth.types"; import { verifyPassword } from "./password"; +import { resolvePermissions } from "../permissions/permission.policy"; function toAuthUser(admin: SuperAdmin): AuthUser { + const roleCodes = ["super_admin"]; + return { id: admin.id, username: admin.username, @@ -22,13 +26,14 @@ function toAuthUser(admin: SuperAdmin): AuthUser { name: "超级管理员", }, ], - permissions: ["*"], + permissions: resolvePermissions("SUPER_ADMIN", roleCodes), canManage: true, }; } function toEmployeeAuthUser(employee: EmployeeLoginAccount): AuthUser { const canManage = employee.roles.some((role) => role.code === "admin"); + const roleCodes = employee.roles.map((role) => role.code); return { id: employee.id, @@ -38,18 +43,23 @@ function toEmployeeAuthUser(employee: EmployeeLoginAccount): AuthUser { storeId: employee.storeId, storeName: employee.storeName, roles: employee.roles, - permissions: canManage ? ["*"] : [], + permissions: resolvePermissions("EMPLOYEE", roleCodes), canManage, }; } -function toJwtPayload(user: AuthUser): AuthJwtPayload { +function hasBackendMenu(user: AuthUser): boolean { + return user.permissions.includes("*") || user.permissions.length > 0; +} + +function toJwtPayload(user: AuthUser, scene: LoginScene): AuthJwtPayload { const subjectPrefix = user.accountType === "SUPER_ADMIN" ? "super_admin" : "employee"; return { sub: `${subjectPrefix}:${user.id}`, accountType: user.accountType, + scene, adminId: user.accountType === "SUPER_ADMIN" ? user.id : undefined, employeeId: user.accountType === "EMPLOYEE" ? user.id : undefined, username: user.username, @@ -60,7 +70,7 @@ function toJwtPayload(user: AuthUser): AuthJwtPayload { } export const authService = { - async login(input: LoginInput): Promise<{ + async loginManagement(input: LoginInput): Promise<{ user: AuthUser; payload: AuthJwtPayload; }> { @@ -82,7 +92,7 @@ export const authService = { return { user, - payload: toJwtPayload(user), + payload: toJwtPayload(user, "MANAGEMENT"), }; } @@ -107,9 +117,44 @@ export const authService = { const user = toEmployeeAuthUser(employee); + if (!hasBackendMenu(user)) { + throw unauthorized("当前账号没有后台登录权限"); + } + return { user, - payload: toJwtPayload(user), + payload: toJwtPayload(user, "MANAGEMENT"), + }; + }, + + async loginEmployee(input: LoginInput): Promise<{ + user: AuthUser; + payload: AuthJwtPayload; + }> { + const employee = await authRepository.findActiveEmployeeByPhone( + input.username, + ); + + if (!employee) { + throw unauthorized("用户名或密码错误"); + } + + const passwordMatched = await verifyPassword( + input.password, + employee.passwordHash, + ); + + if (!passwordMatched) { + throw unauthorized("用户名或密码错误"); + } + + await authRepository.updateEmployeeLastLoginAt(employee.id); + + const user = toEmployeeAuthUser(employee); + + return { + user, + payload: toJwtPayload(user, "EMPLOYEE_APP"), }; }, diff --git a/src/modules/auth/auth.types.ts b/src/modules/auth/auth.types.ts index f155774..126659b 100644 --- a/src/modules/auth/auth.types.ts +++ b/src/modules/auth/auth.types.ts @@ -2,6 +2,7 @@ export const SUPER_ADMIN_STATUS = ["ACTIVE", "INACTIVE"] as const; export type SuperAdminStatus = (typeof SUPER_ADMIN_STATUS)[number]; export type AuthAccountType = "SUPER_ADMIN" | "EMPLOYEE"; +export type LoginScene = "MANAGEMENT" | "EMPLOYEE_APP"; export interface LoginInput { username: string; @@ -55,6 +56,7 @@ export interface AuthUser { export interface AuthJwtPayload { sub: string; accountType: AuthAccountType; + scene: LoginScene; adminId?: number; employeeId?: number; username: string; diff --git a/src/modules/catalog/catalog.controller.ts b/src/modules/catalog/catalog.controller.ts index 059c801..586a31f 100644 --- a/src/modules/catalog/catalog.controller.ts +++ b/src/modules/catalog/catalog.controller.ts @@ -1,17 +1,21 @@ import type { FastifyInstance } from "fastify"; import { created, ok } from "../../shared/response"; +import { permissionGuard } from "../auth/auth.guard"; +import { PERMISSIONS } from "../permissions/permission.policy"; import { catalogService } from "./catalog.service"; import { + createRoleBodySchema, createStoreBodySchema, idParamSchema, listStoresQuerySchema, + updateRoleBodySchema, updateStoreBodySchema, } from "./catalog.schema"; // catalogRoutes 管理“字典/基础资料”接口:门店和角色。 // controller 层只做三件事:校验入参、调用 service、包装 HTTP 响应。 export async function catalogRoutes(app: FastifyInstance): Promise { - app.get("/stores", async (request) => { + app.get("/stores", { preHandler: permissionGuard(PERMISSIONS.STORE_VIEW) }, async (request) => { const query = listStoresQuerySchema.parse(request.query); // 默认返回可选门店;需要管理后台列表时,可通过 includeInactive=true 带出停用门店。 const stores = query.includeInactive @@ -21,21 +25,21 @@ export async function catalogRoutes(app: FastifyInstance): Promise { return ok(stores); }); - app.get("/stores/:id", async (request) => { + app.get("/stores/:id", { preHandler: permissionGuard(PERMISSIONS.STORE_VIEW) }, async (request) => { const params = idParamSchema.parse(request.params); const store = await catalogService.getStoreById(params.id); return ok(store); }); - app.post("/stores", async (request, reply) => { + app.post("/stores", { preHandler: permissionGuard(PERMISSIONS.STORE_MANAGE) }, async (request, reply) => { const body = createStoreBodySchema.parse(request.body); const store = await catalogService.createStore(body); return reply.code(201).send(created(store)); }); - app.patch("/stores/:id", async (request) => { + app.patch("/stores/:id", { preHandler: permissionGuard(PERMISSIONS.STORE_MANAGE) }, async (request) => { const params = idParamSchema.parse(request.params); const body = updateStoreBodySchema.parse(request.body); const store = await catalogService.updateStore(params.id, body); @@ -43,7 +47,7 @@ export async function catalogRoutes(app: FastifyInstance): Promise { return ok(store); }); - app.delete("/stores/:id", async (request, reply) => { + app.delete("/stores/:id", { preHandler: permissionGuard(PERMISSIONS.STORE_MANAGE) }, async (request, reply) => { const params = idParamSchema.parse(request.params); await catalogService.deleteStore(params.id); @@ -51,18 +55,38 @@ export async function catalogRoutes(app: FastifyInstance): Promise { return reply.code(204).send(); }); - app.get("/roles", async () => { + app.get("/roles", { preHandler: permissionGuard(PERMISSIONS.ROLE_VIEW) }, async () => { const roles = await catalogService.listRoles(); return ok(roles); }); - app.get("/roles/:id", async (request) => { + app.get("/roles/:id", { preHandler: permissionGuard(PERMISSIONS.ROLE_VIEW) }, async (request) => { const params = idParamSchema.parse(request.params); const role = await catalogService.getRoleById(params.id); return ok(role); }); - // roles 是服务端固定权限集合,只允许查询,不提供新增、修改、删除接口。 + app.post("/roles", { preHandler: permissionGuard(PERMISSIONS.ROLE_MANAGE) }, async (request, reply) => { + const body = createRoleBodySchema.parse(request.body); + const role = await catalogService.createRole(body); + + return reply.code(201).send(created(role)); + }); + + app.patch("/roles/:id", { preHandler: permissionGuard(PERMISSIONS.ROLE_MANAGE) }, async (request) => { + const params = idParamSchema.parse(request.params); + const body = updateRoleBodySchema.parse(request.body); + const role = await catalogService.updateRole(params.id, body); + + return ok(role); + }); + + app.delete("/roles/:id", { preHandler: permissionGuard(PERMISSIONS.ROLE_MANAGE) }, async (request, reply) => { + const params = idParamSchema.parse(request.params); + await catalogService.deleteRole(params.id); + + return reply.code(204).send(); + }); } diff --git a/src/modules/catalog/catalog.repository.ts b/src/modules/catalog/catalog.repository.ts index 3bcde1a..ff4968f 100644 --- a/src/modules/catalog/catalog.repository.ts +++ b/src/modules/catalog/catalog.repository.ts @@ -1,7 +1,7 @@ import type { ResultSetHeader, RowDataPacket } from "mysql2/promise"; import { pool } from "../../db/pool"; -import { FIXED_ROLE_CODES } from "./catalog.types"; import type { + CreateRoleInput, CreateStoreInput, ListStoresQuery, Role, @@ -9,11 +9,11 @@ import type { Store, StoreOption, StoreStatus, + UpdateRoleInput, UpdateStoreInput, } from "./catalog.types"; type SqlParam = string | number | boolean | Date | null; -const fixedRoleCodePlaceholders = FIXED_ROLE_CODES.map(() => "?").join(", "); interface StoreRow extends RowDataPacket { id: number; @@ -30,6 +30,7 @@ interface RoleRow extends RowDataPacket { code: string; name: string; description: string | null; + is_system: number; created_at: Date; updated_at: Date; } @@ -71,6 +72,7 @@ function toRole(row: RoleRow): Role { code: row.code, name: row.name, description: row.description, + isSystem: row.is_system === 1, createdAt: toIso(row.created_at), updatedAt: toIso(row.updated_at), }; @@ -82,6 +84,7 @@ function toRoleOption(row: RoleRow): RoleOption { code: row.code, name: row.name, description: row.description, + isSystem: row.is_system === 1, }; } @@ -234,12 +237,10 @@ export const catalogRepository = { async listRoles(): Promise { const [rows] = await pool.execute( ` - SELECT id, code, name, description, created_at, updated_at + SELECT id, code, name, description, is_system, created_at, updated_at FROM roles - WHERE code IN (${fixedRoleCodePlaceholders}) ORDER BY id ASC `, - [...FIXED_ROLE_CODES], ); return rows.map(toRole); @@ -248,12 +249,10 @@ export const catalogRepository = { async listRoleOptions(): Promise { const [rows] = await pool.execute( ` - SELECT id, code, name, description, created_at, updated_at + SELECT id, code, name, description, is_system, created_at, updated_at FROM roles - WHERE code IN (${fixedRoleCodePlaceholders}) ORDER BY id ASC `, - [...FIXED_ROLE_CODES], ); return rows.map(toRoleOption); @@ -262,15 +261,104 @@ export const catalogRepository = { async findRoleById(id: number): Promise { const [rows] = await pool.execute( ` - SELECT id, code, name, description, created_at, updated_at + SELECT id, code, name, description, is_system, created_at, updated_at FROM roles - WHERE id = ? AND code IN (${fixedRoleCodePlaceholders}) + WHERE id = ? LIMIT 1 `, - [id, ...FIXED_ROLE_CODES], + [id], ); return rows[0] ? toRole(rows[0]) : null; }, + async findRoleByCode( + code: string, + excludeRoleId?: number, + ): Promise { + const params: SqlParam[] = [code]; + let excludeSql = ""; + + if (excludeRoleId !== undefined) { + // 修改角色编码时排除当前角色,避免自己和自己冲突。 + excludeSql = " AND id <> ?"; + params.push(excludeRoleId); + } + + const [rows] = await pool.execute( + ` + SELECT id, code, name, description, is_system, created_at, updated_at + FROM roles + WHERE code = ? + ${excludeSql} + LIMIT 1 + `, + params, + ); + + return rows[0] ? toRole(rows[0]) : null; + }, + + async createRole(input: CreateRoleInput): Promise { + const [result] = await pool.execute( + ` + INSERT INTO roles (code, name, description, is_system) + VALUES (?, ?, ?, 0) + `, + [input.code, input.name, input.description ?? null], + ); + + return result.insertId; + }, + + async updateRole(id: number, input: UpdateRoleInput): Promise { + // 角色 PATCH 只更新请求里明确出现的字段。 + const fieldMap: Array<[keyof UpdateRoleInput, string]> = [ + ["code", "code"], + ["name", "name"], + ["description", "description"], + ]; + + const sets: string[] = []; + const params: SqlParam[] = []; + + for (const [inputKey, columnName] of fieldMap) { + if (Object.prototype.hasOwnProperty.call(input, inputKey)) { + sets.push(`${columnName} = ?`); + params.push(input[inputKey] ?? null); + } + } + + if (sets.length === 0) { + return; + } + + params.push(id); + + await pool.execute( + ` + UPDATE roles + SET ${sets.join(", ")} + WHERE id = ? + `, + params, + ); + }, + + async deleteRole(id: number): Promise { + await pool.execute("DELETE FROM roles WHERE id = ?", [id]); + }, + + async countEmployeesByRole(roleId: number): Promise { + const [rows] = await pool.execute( + ` + SELECT COUNT(*) AS total + FROM employee_roles + WHERE role_id = ? + `, + [roleId], + ); + + return rows[0]?.total ?? 0; + }, }; diff --git a/src/modules/catalog/catalog.schema.ts b/src/modules/catalog/catalog.schema.ts index 93d8e92..d557edd 100644 --- a/src/modules/catalog/catalog.schema.ts +++ b/src/modules/catalog/catalog.schema.ts @@ -58,3 +58,24 @@ export const updateStoreBodySchema = z .refine((value) => Object.keys(value).length > 0, { message: "至少需要提交一个要修改的字段", }); + +export const createRoleBodySchema = z.object({ + code: z + .string() + .trim() + .min(1) + .max(50) + // code 作为程序里的稳定标识,限制成简单格式可以减少大小写和特殊字符带来的混乱。 + .regex( + /^[a-z][a-z0-9_]*$/, + "角色编码只能使用小写字母、数字和下划线,并以字母开头", + ), + name: z.string().trim().min(1).max(50), + description: nullableText(255), +}); + +export const updateRoleBodySchema = createRoleBodySchema + .partial() + .refine((value) => Object.keys(value).length > 0, { + message: "至少需要提交一个要修改的字段", + }); diff --git a/src/modules/catalog/catalog.service.ts b/src/modules/catalog/catalog.service.ts index e9394bc..cb73667 100644 --- a/src/modules/catalog/catalog.service.ts +++ b/src/modules/catalog/catalog.service.ts @@ -1,12 +1,14 @@ import { conflict, notFound } from "../../shared/http-error"; import { catalogRepository } from "./catalog.repository"; import type { + CreateRoleInput, CreateStoreInput, ListStoresQuery, Role, RoleOption, Store, StoreOption, + UpdateRoleInput, UpdateStoreInput, } from "./catalog.types"; @@ -104,5 +106,53 @@ export const catalogService = { return role; }, - // 角色是服务端固定权限集合,只允许查询,不允许通过接口变更。 + async createRole(input: CreateRoleInput): Promise { + // code 是权限判断和前端展示用的稳定编码,必须唯一。 + const duplicatedRole = await catalogRepository.findRoleByCode(input.code); + + if (duplicatedRole) { + throw conflict("角色编码已存在"); + } + + const roleId = await catalogRepository.createRole(input); + return this.getRoleById(roleId); + }, + + async updateRole(id: number, input: UpdateRoleInput): Promise { + const currentRole = await this.getRoleById(id); + + if (currentRole.isSystem) { + throw conflict("服务端内置角色不可修改"); + } + + if (input.code !== undefined) { + const duplicatedRole = await catalogRepository.findRoleByCode( + input.code, + id, + ); + + if (duplicatedRole) { + throw conflict("角色编码已存在"); + } + } + + await catalogRepository.updateRole(id, input); + return this.getRoleById(id); + }, + + async deleteRole(id: number): Promise { + const currentRole = await this.getRoleById(id); + + if (currentRole.isSystem) { + throw conflict("服务端内置角色不可删除"); + } + + const employeeCount = await catalogRepository.countEmployeesByRole(id); + + if (employeeCount > 0) { + throw conflict("角色已绑定员工,不能删除"); + } + + await catalogRepository.deleteRole(id); + }, }; diff --git a/src/modules/catalog/catalog.types.ts b/src/modules/catalog/catalog.types.ts index 6de1022..617026a 100644 --- a/src/modules/catalog/catalog.types.ts +++ b/src/modules/catalog/catalog.types.ts @@ -47,6 +47,7 @@ export interface RoleOption { code: string; name: string; description: string | null; + isSystem: boolean; } // Store/Role 是详情或管理列表使用的完整返回结构。 @@ -78,3 +79,15 @@ export interface UpdateStoreInput { phone?: string | null; status?: StoreStatus; } + +export interface CreateRoleInput { + code: string; + name: string; + description?: string | null; +} + +export interface UpdateRoleInput { + code?: string; + name?: string; + description?: string | null; +} diff --git a/src/modules/employees/employee.controller.ts b/src/modules/employees/employee.controller.ts index 97ff467..7e7b2ab 100644 --- a/src/modules/employees/employee.controller.ts +++ b/src/modules/employees/employee.controller.ts @@ -1,5 +1,12 @@ import type { FastifyInstance } from "fastify"; +import { forbidden } from "../../shared/http-error"; import { created, ok, paginated } from "../../shared/response"; +import { authService } from "../auth/auth.service"; +import { + hasAnyPermission, + hasPermission, + PERMISSIONS, +} from "../permissions/permission.policy"; import { employeeService } from "./employee.service"; import { createEmployeeBodySchema, @@ -8,25 +15,85 @@ import { updateEmployeeBodySchema, updateEmployeeStatusBodySchema } from "./employee.schema"; +import type { AuthUser } from "../auth/auth.types"; +import type { Employee, ListEmployeesQuery } from "./employee.types"; + +function canViewEmployees(user: AuthUser): boolean { + return hasAnyPermission(user.permissions, [ + PERMISSIONS.EMPLOYEE_VIEW_ALL, + PERMISSIONS.EMPLOYEE_VIEW_STORE, + ]); +} + +function assertCanManageEmployees(user: AuthUser): void { + if (!hasPermission(user.permissions, PERMISSIONS.EMPLOYEE_MANAGE)) { + throw forbidden("当前账号没有员工管理操作权限"); + } +} + +function scopeEmployeeListQuery( + user: AuthUser, + query: ListEmployeesQuery, +): ListEmployeesQuery { + if (hasPermission(user.permissions, PERMISSIONS.EMPLOYEE_VIEW_ALL)) { + return query; + } + + if (hasPermission(user.permissions, PERMISSIONS.EMPLOYEE_VIEW_STORE) && user.storeId) { + return { + ...query, + storeId: user.storeId, + }; + } + + throw forbidden("当前账号没有员工查看权限"); +} + +function assertCanViewEmployee(user: AuthUser, employee: Employee): void { + if (hasPermission(user.permissions, PERMISSIONS.EMPLOYEE_VIEW_ALL)) { + return; + } + + if ( + hasPermission(user.permissions, PERMISSIONS.EMPLOYEE_VIEW_STORE) && + user.storeId === employee.storeId + ) { + return; + } + + throw forbidden("当前账号没有查看该员工的权限"); +} // 员工接口是本项目的核心 CRUD。 // controller 层保持轻量:解析请求参数,调用 service,返回统一响应。 export async function employeeRoutes(app: FastifyInstance): Promise { app.get("/employees", async (request) => { - const query = listEmployeesQuerySchema.parse(request.query); - const result = await employeeService.list(query); + const user = await authService.getCurrentUser(request.user); - return paginated(result.items, query.page, query.pageSize, result.total); + if (!canViewEmployees(user)) { + throw forbidden("当前账号没有员工查看权限"); + } + + const query = listEmployeesQuerySchema.parse(request.query); + const scopedQuery = scopeEmployeeListQuery(user, query); + const result = await employeeService.list(scopedQuery); + + return paginated(result.items, scopedQuery.page, scopedQuery.pageSize, result.total); }); app.get("/employees/:id", async (request) => { + const user = await authService.getCurrentUser(request.user); const params = idParamSchema.parse(request.params); const employee = await employeeService.getById(params.id); + assertCanViewEmployee(user, employee); return ok(employee); }); app.post("/employees", async (request, reply) => { + const user = await authService.getCurrentUser(request.user); + assertCanManageEmployees(user); + const body = createEmployeeBodySchema.parse(request.body); const employee = await employeeService.create(body); @@ -34,6 +101,9 @@ export async function employeeRoutes(app: FastifyInstance): Promise { }); app.patch("/employees/:id", async (request) => { + const user = await authService.getCurrentUser(request.user); + assertCanManageEmployees(user); + const params = idParamSchema.parse(request.params); const body = updateEmployeeBodySchema.parse(request.body); const employee = await employeeService.update(params.id, body); @@ -42,6 +112,9 @@ export async function employeeRoutes(app: FastifyInstance): Promise { }); app.patch("/employees/:id/status", async (request) => { + const user = await authService.getCurrentUser(request.user); + assertCanManageEmployees(user); + const params = idParamSchema.parse(request.params); const body = updateEmployeeStatusBodySchema.parse(request.body); // 单独提供状态接口,方便前端做“启用/停用”开关,而不必提交完整员工表单。 @@ -51,6 +124,9 @@ export async function employeeRoutes(app: FastifyInstance): Promise { }); app.delete("/employees/:id", async (request, reply) => { + const user = await authService.getCurrentUser(request.user); + assertCanManageEmployees(user); + const params = idParamSchema.parse(request.params); await employeeService.delete(params.id); diff --git a/src/modules/employees/employee.repository.ts b/src/modules/employees/employee.repository.ts index 27a5566..b8658e7 100644 --- a/src/modules/employees/employee.repository.ts +++ b/src/modules/employees/employee.repository.ts @@ -14,7 +14,7 @@ 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$Vd5Mh3XgZPZ4ozECQzmviA$YnzG8OAqy9bZE9ZmA2yT1RpUl0bbC0yA9LpYUO8LltQ"; + "pbkdf2$sha256$310000$QnXyjrpm0QzcGYLEPdunWg$CfR-CywGl1c_Omh_3PyOWPmo93EcbMY1FEjjd5MDjFo"; interface EmployeeRow extends RowDataPacket { id: number; @@ -134,13 +134,12 @@ export const employeeRepository = { return rows.map((row) => row.id); }, - async findActiveByStoreAndPhone( - storeId: number, + async findActiveByPhone( phone: string, excludeEmployeeId?: number, db: DbExecutor = pool ): Promise { - const params: SqlParam[] = [storeId, phone]; + const params: SqlParam[] = [phone]; let excludeSql = ""; if (excludeEmployeeId !== undefined) { @@ -154,8 +153,7 @@ export const employeeRepository = { SELECT e.*, s.name AS store_name FROM employees e INNER JOIN stores s ON s.id = e.store_id - WHERE e.store_id = ? - AND e.phone = ? + WHERE e.phone = ? AND e.deleted_at IS NULL ${excludeSql} LIMIT 1 diff --git a/src/modules/employees/employee.service.ts b/src/modules/employees/employee.service.ts index 8e2947e..32690f2 100644 --- a/src/modules/employees/employee.service.ts +++ b/src/modules/employees/employee.service.ts @@ -48,11 +48,11 @@ export const employeeService = { await assertStoreExists(input.storeId); const roleIds = await assertRolesExist(input.roleIds); - // 手机号只要求在同一个未删除门店内唯一;不同门店可以存在同一手机号。 - const duplicatedEmployee = await employeeRepository.findActiveByStoreAndPhone(input.storeId, input.phone); + // 员工手机号就是登录账号,因此未删除员工范围内必须全局唯一。 + const duplicatedEmployee = await employeeRepository.findActiveByPhone(input.phone); if (duplicatedEmployee) { - throw conflict("同一门店下手机号已存在"); + throw conflict("员工手机号已存在"); } // 创建员工和绑定角色必须放在一个事务里,避免员工创建成功但角色绑定失败。 @@ -72,15 +72,14 @@ export const employeeService = { await assertStoreExists(input.storeId); } - const nextStoreId = input.storeId ?? currentEmployee.storeId; const nextPhone = input.phone ?? currentEmployee.phone; - // 只有门店或手机号发生变化时才需要重新检查唯一性。 - if (input.storeId !== undefined || input.phone !== undefined) { - const duplicatedEmployee = await employeeRepository.findActiveByStoreAndPhone(nextStoreId, nextPhone, id); + // 只有手机号发生变化时才需要重新检查全局唯一性。 + if (input.phone !== undefined) { + const duplicatedEmployee = await employeeRepository.findActiveByPhone(nextPhone, id); if (duplicatedEmployee) { - throw conflict("同一门店下手机号已存在"); + throw conflict("员工手机号已存在"); } } diff --git a/src/modules/permissions/permission.controller.ts b/src/modules/permissions/permission.controller.ts new file mode 100644 index 0000000..c92f303 --- /dev/null +++ b/src/modules/permissions/permission.controller.ts @@ -0,0 +1,26 @@ +import type { FastifyInstance } from "fastify"; +import { ok } from "../../shared/response"; +import { authGuard, permissionGuard } from "../auth/auth.guard"; +import { authService } from "../auth/auth.service"; +import { + getPermissionPolicies, + getVisibleMenus, + PERMISSIONS, +} from "./permission.policy"; + +export async function permissionRoutes(app: FastifyInstance): Promise { + app.get("/permissions/me", { preHandler: authGuard }, async (request) => { + const user = await authService.getCurrentUser(request.user); + + return ok({ + permissions: user.permissions, + menus: getVisibleMenus(user), + }); + }); + + app.get( + "/permissions/policies", + { preHandler: permissionGuard(PERMISSIONS.PERMISSION_VIEW) }, + async () => ok(getPermissionPolicies()), + ); +} diff --git a/src/modules/permissions/permission.policy.ts b/src/modules/permissions/permission.policy.ts new file mode 100644 index 0000000..3dd8cee --- /dev/null +++ b/src/modules/permissions/permission.policy.ts @@ -0,0 +1,182 @@ +import type { AuthAccountType, AuthUser } from "../auth/auth.types"; + +export const PERMISSIONS = { + STORE_VIEW: "store:view", + STORE_MANAGE: "store:manage", + ROLE_VIEW: "role:view", + ROLE_MANAGE: "role:manage", + EMPLOYEE_VIEW_ALL: "employee:view:all", + EMPLOYEE_VIEW_STORE: "employee:view:store", + EMPLOYEE_MANAGE: "employee:manage", + PERMISSION_VIEW: "permission:view", +} as const; + +export type PermissionCode = (typeof PERMISSIONS)[keyof typeof PERMISSIONS]; + +export interface PermissionMenu { + key: string; + title: string; + icon?: string; + permission: PermissionCode; + actions: string[]; +} + +const ROLE_PERMISSION_MAP: Record = { + 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], +}; + +const MENUS: PermissionMenu[] = [ + { + key: "stores", + title: "门店管理", + permission: PERMISSIONS.STORE_VIEW, + actions: ["view", "create", "update", "delete"], + }, + { + key: "roles", + title: "角色管理", + permission: PERMISSIONS.ROLE_VIEW, + actions: ["view", "create", "update", "delete"], + }, + { + key: "employees", + title: "员工管理", + permission: PERMISSIONS.EMPLOYEE_VIEW_ALL, + actions: ["view", "create", "update", "delete"], + }, + { + key: "permissions", + title: "权限管理", + icon: "key", + permission: PERMISSIONS.PERMISSION_VIEW, + actions: ["view"], + }, +]; + +export function resolvePermissions( + accountType: AuthAccountType, + roleCodes: string[], +): string[] { + if (accountType === "SUPER_ADMIN") { + return ["*"]; + } + + const permissions = new Set(); + + for (const roleCode of roleCodes) { + for (const permission of ROLE_PERMISSION_MAP[roleCode] ?? []) { + permissions.add(permission); + } + } + + return [...permissions]; +} + +export function hasPermission( + permissions: string[], + permission: PermissionCode, +): boolean { + return permissions.includes("*") || permissions.includes(permission); +} + +export function hasAnyPermission( + permissions: string[], + permissionsToCheck: PermissionCode[], +): boolean { + return permissionsToCheck.some((permission) => + hasPermission(permissions, permission), + ); +} + +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) => ({ + ...menu, + actions: getAllowedActions(user, menu.key), + })); +} + +export function getAllowedActions(user: AuthUser, menuKey: string): string[] { + if (user.accountType === "SUPER_ADMIN") { + return MENUS.find((menu) => menu.key === menuKey)?.actions ?? []; + } + + if (menuKey === "stores") { + return hasPermission(user.permissions, PERMISSIONS.STORE_MANAGE) + ? ["view", "create", "update", "delete"] + : ["view"]; + } + + if (menuKey === "roles") { + return ["view"]; + } + + if (menuKey === "employees") { + if (hasPermission(user.permissions, PERMISSIONS.EMPLOYEE_MANAGE)) { + return ["view", "create", "update", "delete"]; + } + + if (hasPermission(user.permissions, PERMISSIONS.EMPLOYEE_VIEW_STORE)) { + return ["view"]; + } + } + + if (menuKey === "permissions") { + return ["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"] }], + }, + ]; +}