From 6b31ea7bbf63227530477bb0b870bf29d01deb5e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B9=9B=E5=85=AE?= Date: Tue, 26 May 2026 16:24:25 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=94=AF=E6=8C=81=E5=8A=A8=E6=80=81?= =?UTF-8?q?=E8=A7=92=E8=89=B2=E6=9D=83=E9=99=90=E5=88=86=E9=85=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 60 +- docs/API.md | 1134 +++++++++++++++++ migrations/006_create_role_permissions.sql | 27 + src/modules/auth/auth.service.ts | 29 +- src/modules/catalog/catalog.controller.ts | 53 +- src/modules/catalog/catalog.repository.ts | 135 ++ src/modules/catalog/catalog.schema.ts | 26 + src/modules/catalog/catalog.service.ts | 13 + src/modules/catalog/catalog.types.ts | 11 + src/modules/employees/employee.repository.ts | 5 +- .../permissions/permission.controller.ts | 32 +- src/modules/permissions/permission.policy.ts | 278 ++-- .../permissions/permission.repository.ts | 156 +++ src/modules/permissions/permission.schema.ts | 12 + src/modules/permissions/permission.service.ts | 166 +++ 15 files changed, 2020 insertions(+), 117 deletions(-) create mode 100644 docs/API.md create mode 100644 migrations/006_create_role_permissions.sql create mode 100644 src/modules/permissions/permission.repository.ts create mode 100644 src/modules/permissions/permission.schema.ts create mode 100644 src/modules/permissions/permission.service.ts diff --git a/README.md b/README.md index d4e92df..de02c90 100644 --- a/README.md +++ b/README.md @@ -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 校验错误、业务错误和数据库唯一索引冲突。 diff --git a/docs/API.md b/docs/API.md new file mode 100644 index 0000000..9197b24 --- /dev/null +++ b/docs/API.md @@ -0,0 +1,1134 @@ +# access-manage 接口文档 + +本文档按当前后端代码整理,供前端对接使用。接口来源主要是: + +- `src/app.ts` +- `src/modules/auth/*` +- `src/modules/permissions/*` +- `src/modules/catalog/*` +- `src/modules/employees/*` + +## 基础约定 + +| 项目 | 说明 | +| --- | --- | +| 本地 Base URL | `http://localhost:3500` | +| 业务接口前缀 | `/api` | +| 健康检查 | `/health`,不带 `/api` 前缀 | +| 请求体格式 | `Content-Type: application/json` | +| 鉴权方式 | `Authorization: Bearer ` | +| 时间格式 | ISO 字符串,例如 `2026-05-26T08:00:00.000Z` | +| 字段命名 | 请求和响应都使用 `camelCase` | + +不需要登录的接口: + +- `GET /health` +- `POST /api/auth/login` +- `POST /api/auth/admin/login` +- `POST /api/auth/employee/login` + +其他接口都需要 Bearer token。门店、角色、员工管理接口还要求当前账号具备后台管理权限。 + +## 通用响应 + +### 成功响应 + +```json +{ + "success": true, + "data": {} +} +``` + +### 分页响应 + +```json +{ + "success": true, + "data": { + "items": [], + "pagination": { + "page": 1, + "pageSize": 20, + "total": 0, + "totalPages": 0 + } + } +} +``` + +### 错误响应 + +```json +{ + "success": false, + "error": { + "code": "VALIDATION_ERROR", + "message": "请求参数不合法", + "details": [] + } +} +``` + +常见错误码: + +| HTTP 状态码 | `error.code` | 说明 | +| --- | --- | --- | +| 400 | `VALIDATION_ERROR` | zod 参数校验失败 | +| 400 | `BAD_REQUEST` | 业务参数不合法,例如门店不存在或角色不存在 | +| 401 | `UNAUTHORIZED` | 未登录、token 过期、账号密码错误或后台登录权限不足 | +| 403 | `FORBIDDEN` | 已登录但没有访问或操作权限 | +| 404 | `NOT_FOUND` | 资源不存在 | +| 409 | `CONFLICT` | 唯一字段冲突、资源被绑定不能删除等 | +| 500 | `INTERNAL_SERVER_ERROR` | 未处理的服务端异常 | + +`DELETE` 成功时返回 `204 No Content`,没有响应体。 + +## 测试账号 + +本地迁移会初始化超级管理员: + +| 类型 | 账号 | 密码 | +| --- | --- | --- | +| 超级管理员 | `admin` | `Admin@123456` | + +员工创建后默认密码为: + +```text +pw111111 +``` + +员工登录账号使用员工手机号。 + +## 权限模型 + +### 权限码 + +| 权限码 | 说明 | +| --- | --- | +| `*` | 超级管理员,拥有全部权限 | +| `store:view` | 查看门店 | +| `store:manage` | 新增、修改、删除门店 | +| `role:view` | 查看角色 | +| `role:manage` | 新增、修改、删除自定义角色 | +| `employee:view:all` | 查看全部门店员工 | +| `employee:view:store` | 查看当前门店员工 | +| `employee:manage` | 新增、修改、删除员工 | +| `permission:view` | 查看权限策略 | +| `permission:manage` | 分配角色权限 | + +### 角色权限 + +| 角色 | 作用范围 | 权限 | +| --- | --- | --- | +| `super_admin` 超级管理员 | 全部门店 | `*` | +| `admin` 管理员 | 按权限点控制 | `store:view`, `store:manage`, `role:view`, `role:manage`, `employee:view:all`, `employee:manage`, `permission:view`, `permission:manage` | +| `store_manager` 店长 | 当前门店 | `employee:view:store` | +| `cashier` 收银员 | 默认无后台菜单 | 可通过权限管理动态分配 | +| `kitchen` 后厨 | 默认无后台菜单 | 可通过权限管理动态分配 | +| `part_time` 兼职 | 默认无后台菜单 | 可通过权限管理动态分配 | + +角色权限保存在 `role_permissions` 表。前端通过 `GET /api/permissions/definitions` 获取可分配权限点,通过 `PUT /api/permissions/roles/:roleId` 保存角色权限。超级管理员是独立账号类型,固定拥有 `*`,不参与角色权限分配。 + +## 数据结构 + +### AuthUser + +```ts +interface AuthUser { + id: number; + username: string; + displayName: string; + accountType: "SUPER_ADMIN" | "EMPLOYEE"; + storeId?: number; + storeName?: string; + roles: Array<{ + id: number; + code: string; + name: string; + }>; + permissions: string[]; + canManage: boolean; +} +``` + +### Store + +```ts +type StoreStatus = "ACTIVE" | "INACTIVE"; + +interface StoreOption { + id: number; + name: string; + address: string | null; + phone: string | null; +} + +interface Store extends StoreOption { + status: StoreStatus; + createdAt: string; + updatedAt: string; +} +``` + +### Role + +```ts +interface RoleOption { + id: number; + code: string; + name: string; + description: string | null; + isSystem: boolean; +} + +interface Role extends RoleOption { + createdAt: string; + updatedAt: string; +} +``` + +### Employee + +```ts +type EmployeeStatus = "ACTIVE" | "INACTIVE"; + +interface Employee { + id: number; + storeId: number; + storeName: string; + name: string; + phone: string; + status: EmployeeStatus; + remark: string | null; + roles: Array<{ + id: number; + code: string; + name: string; + }>; + createdAt: string; + updatedAt: string; +} +``` + +### PermissionMenu + +```ts +interface PermissionMenu { + key: string; + title: string; + icon?: string; + permission: string; + actions: string[]; +} +``` + +## 接口总览 + +| 方法 | 路径 | 鉴权 | 权限 | 说明 | +| --- | --- | --- | --- | --- | +| `GET` | `/health` | 否 | 无 | 健康检查 | +| `POST` | `/api/auth/login` | 否 | 无 | 后台登录,兼容入口 | +| `POST` | `/api/auth/admin/login` | 否 | 无 | 后台登录 | +| `POST` | `/api/auth/employee/login` | 否 | 无 | 员工端登录 | +| `GET` | `/api/auth/me` | 是 | 登录即可 | 当前用户 | +| `GET` | `/api/permissions/me` | 是 | 登录即可 | 当前用户权限和菜单 | +| `GET` | `/api/permissions/policies` | 是 | `permission:view` | 角色权限策略 | +| `GET` | `/api/permissions/definitions` | 是 | `permission:view` | 可分配权限点定义 | +| `PUT` | `/api/permissions/roles/:roleId` | 是 | `permission:manage` | 更新角色权限 | +| `GET` | `/api/stores` | 是 | `store:view` | 门店列表或门店下拉选项 | +| `GET` | `/api/stores/:id` | 是 | `store:view` | 门店详情 | +| `POST` | `/api/stores` | 是 | `store:manage` | 新增门店 | +| `PATCH` | `/api/stores/:id` | 是 | `store:manage` | 修改门店 | +| `DELETE` | `/api/stores/:id` | 是 | `store:manage` | 删除门店 | +| `GET` | `/api/roles` | 是 | `role:view` | 角色列表 | +| `GET` | `/api/roles/:id` | 是 | `role:view` | 角色详情 | +| `POST` | `/api/roles` | 是 | `role:manage` | 新增自定义角色 | +| `PATCH` | `/api/roles/:id` | 是 | `role:manage` | 修改自定义角色 | +| `DELETE` | `/api/roles/:id` | 是 | `role:manage` | 删除自定义角色 | +| `GET` | `/api/employees` | 是 | `employee:view:all` 或 `employee:view:store` | 员工分页列表 | +| `GET` | `/api/employees/:id` | 是 | `employee:view:all` 或当前门店 `employee:view:store` | 员工详情 | +| `POST` | `/api/employees` | 是 | `employee:manage` | 新增员工 | +| `PATCH` | `/api/employees/:id` | 是 | `employee:manage` | 修改员工 | +| `PATCH` | `/api/employees/:id/status` | 是 | `employee:manage` | 修改员工状态 | +| `DELETE` | `/api/employees/:id` | 是 | `employee:manage` | 删除员工 | + +## 健康检查 + +### GET /health + +检查 HTTP 服务和数据库连接。 + +响应: + +```json +{ + "success": true, + "data": { + "status": "ok", + "database": "up", + "now": "2026-05-26T08:00:00.000Z" + } +} +``` + +## 认证接口 + +### POST /api/auth/login + +后台登录兼容入口,逻辑等同于 `POST /api/auth/admin/login`。 + +请求体: + +| 字段 | 类型 | 必填 | 约束 | 说明 | +| --- | --- | --- | --- | --- | +| `username` | `string` | 是 | trim 后 1-50 字符 | 超级管理员用户名,或员工手机号 | +| `password` | `string` | 是 | 8-128 字符 | 登录密码 | + +请求示例: + +```json +{ + "username": "admin", + "password": "Admin@123456" +} +``` + +响应: + +```json +{ + "success": true, + "data": { + "token": "", + "tokenType": "Bearer", + "expiresIn": "2h", + "user": { + "id": 1, + "username": "admin", + "displayName": "超级管理员", + "accountType": "SUPER_ADMIN", + "roles": [ + { + "id": 0, + "code": "super_admin", + "name": "超级管理员" + } + ], + "permissions": ["*"], + "canManage": true + } + } +} +``` + +后台登录规则: + +- 超级管理员使用 `super_admins.username` 登录。 +- 员工使用手机号登录。 +- 员工必须拥有后台菜单权限,否则返回 `401 UNAUTHORIZED`,消息为 `当前账号没有后台登录权限`。 +- `cashier`、`kitchen`、`part_time` 默认没有后台登录权限。 + +### POST /api/auth/admin/login + +后台登录正式入口。请求和响应与 `POST /api/auth/login` 一致。 + +### POST /api/auth/employee/login + +员工端登录入口。员工使用手机号和密码登录,不要求后台管理权限。 + +请求体: + +| 字段 | 类型 | 必填 | 约束 | 说明 | +| --- | --- | --- | --- | --- | +| `username` | `string` | 是 | trim 后 1-50 字符 | 员工手机号 | +| `password` | `string` | 是 | 8-128 字符 | 员工密码 | + +响应中的 `user.accountType` 为 `EMPLOYEE`,`storeId`、`storeName` 会返回。 + +### GET /api/auth/me + +获取当前登录用户。 + +请求头: + +```http +Authorization: Bearer +``` + +响应: + +```json +{ + "success": true, + "data": { + "id": 1, + "username": "admin", + "displayName": "超级管理员", + "accountType": "SUPER_ADMIN", + "roles": [ + { + "id": 0, + "code": "super_admin", + "name": "超级管理员" + } + ], + "permissions": ["*"], + "canManage": true + } +} +``` + +## 权限接口 + +### GET /api/permissions/me + +获取当前用户可见菜单和权限码。任意已登录账号都可访问。 + +响应: + +```json +{ + "success": true, + "data": { + "permissions": ["*"], + "menus": [ + { + "key": "stores", + "title": "门店管理", + "permission": "store:view", + "actions": ["view", "create", "update", "delete"] + }, + { + "key": "roles", + "title": "角色管理", + "permission": "role:view", + "actions": ["view", "create", "update", "delete"] + }, + { + "key": "employees", + "title": "员工管理", + "permission": "employee:view:all", + "actions": ["view", "create", "update", "delete"] + }, + { + "key": "permissions", + "title": "权限管理", + "icon": "key", + "permission": "permission:view", + "actions": ["view", "update"] + } + ] + } +} +``` + +前端建议用此接口决定菜单显隐和按钮显隐。 + +### GET /api/permissions/policies + +获取当前数据库里的角色权限策略。需要 `permission:view`。 + +响应: + +```json +{ + "success": true, + "data": [ + { + "roleId": 0, + "roleCode": "super_admin", + "roleName": "超级管理员", + "roleDescription": "系统内置最高权限账号,不参与角色权限分配。", + "isSystem": true, + "editable": false, + "scope": "全部门店", + "permissions": ["*"], + "menus": [ + { + "key": "stores", + "title": "门店管理", + "actions": ["view", "create", "update", "delete"] + } + ] + }, + { + "roleId": 5, + "roleCode": "admin", + "roleName": "管理员", + "roleDescription": "系统管理角色,仅授予可信人员", + "isSystem": true, + "editable": true, + "scope": "按权限点控制", + "permissions": [ + "store:view", + "store:manage", + "role:view", + "role:manage", + "employee:view:all", + "employee:manage", + "permission:view", + "permission:manage" + ], + "menus": [ + { + "key": "stores", + "title": "门店管理", + "actions": ["view", "create", "update", "delete"] + } + ] + }, + { + "roleId": 1, + "roleCode": "store_manager", + "roleName": "店长", + "roleDescription": "负责门店日常管理、排班和权限审批", + "isSystem": true, + "editable": true, + "scope": "当前门店", + "permissions": ["employee:view:store"], + "menus": [ + { + "key": "employees", + "title": "员工管理", + "actions": ["view"] + } + ] + } + ] +} +``` + +### GET /api/permissions/definitions + +获取后端允许分配的权限点定义。需要 `permission:view`。 + +响应: + +```json +{ + "success": true, + "data": { + "permissions": [ + { + "code": "store:view", + "title": "查看门店", + "description": "查看门店列表、门店详情和门店下拉选项。", + "groupKey": "stores", + "groupTitle": "门店管理" + } + ], + "groups": [ + { + "key": "stores", + "title": "门店管理", + "permissions": [ + { + "code": "store:view", + "title": "查看门店", + "description": "查看门店列表、门店详情和门店下拉选项。", + "groupKey": "stores", + "groupTitle": "门店管理" + } + ] + } + ], + "menus": [ + { + "key": "stores", + "title": "门店管理", + "permission": "store:view", + "actions": ["view", "create", "update", "delete"], + "actionLabels": { + "view": "查看", + "create": "新增", + "update": "编辑", + "delete": "删除" + } + } + ] + } +} +``` + +### PUT /api/permissions/roles/:roleId + +更新指定角色拥有的权限点。需要 `permission:manage`。 + +后端只接受 `GET /api/permissions/definitions` 返回的权限码。保存时会自动补齐依赖权限,例如提交 `permission:manage` 会自动保留 `permission:view`。 + +请求: + +```http +PUT /api/permissions/roles/5 +Authorization: Bearer +Content-Type: application/json +``` + +```json +{ + "permissions": [ + "store:view", + "store:manage", + "role:view", + "role:manage", + "permission:view", + "permission:manage" + ] +} +``` + +响应: + +```json +{ + "success": true, + "data": { + "roleId": 5, + "roleCode": "admin", + "roleName": "管理员", + "roleDescription": "系统管理角色,仅授予可信人员", + "isSystem": true, + "editable": true, + "scope": "按权限点控制", + "permissions": [ + "store:view", + "store:manage", + "role:view", + "role:manage", + "permission:view", + "permission:manage" + ], + "menus": [ + { + "key": "stores", + "title": "门店管理", + "actions": ["view", "create", "update", "delete"] + }, + { + "key": "roles", + "title": "角色管理", + "actions": ["view", "create", "update", "delete"] + }, + { + "key": "permissions", + "title": "权限管理", + "actions": ["view", "update"] + } + ] + } +} +``` + +上面示例中的 `menus` 为截断示例,真实响应会返回完整菜单数组。 + +## 门店接口 + +### GET /api/stores + +查询门店。需要 `store:view`。 + +查询参数: + +| 参数 | 类型 | 必填 | 默认值 | 约束 | 说明 | +| --- | --- | --- | --- | --- | --- | +| `includeInactive` | `boolean` | 否 | 无 | `true` 或 `false` | 不传时由接口模式决定;传 `false` 且不传 `status` 时只查启用门店 | +| `status` | `"ACTIVE" \| "INACTIVE"` | 否 | 无 | 枚举 | 按门店状态筛选;优先级高于 `includeInactive` | +| `keyword` | `string` | 否 | 无 | trim 后 1-100 字符 | 按门店名称、地址、电话模糊搜索 | +| `page` | `number` | 否 | `1` | 正整数 | 页码;传了筛选或分页参数时才进入分页列表模式 | +| `pageSize` | `number` | 否 | `20` | 1-100 | 每页数量 | + +响应结构会随查询参数变化: + +- 不传任何查询参数,或只传 `includeInactive=false`:返回启用门店下拉选项 `StoreOption[]`,供表单选择门店。 +- 只传 `includeInactive=true`:返回未删除门店完整数组 `Store[]`,兼容旧调用。 +- 传 `status`、`keyword`、`page`、`pageSize` 中任意一个:返回分页结构,`items` 为 `Store[]`,供门店管理列表使用。 + +管理列表常用写法: + +- 全部状态:`GET /api/stores?page=1&pageSize=20` +- 只看启用:`GET /api/stores?status=ACTIVE&page=1&pageSize=20` +- 关键词搜索:`GET /api/stores?keyword=人民&page=1&pageSize=20` + +请求示例: + +```http +GET /api/stores?status=ACTIVE&keyword=人民&page=1&pageSize=20 +Authorization: Bearer +``` + +响应示例: + +```json +{ + "success": true, + "data": { + "items": [ + { + "id": 1, + "name": "示例门店", + "address": "请改成你的真实门店地址", + "phone": "13800000000", + "status": "ACTIVE", + "createdAt": "2026-05-26T08:00:00.000Z", + "updatedAt": "2026-05-26T08:00:00.000Z" + } + ], + "pagination": { + "page": 1, + "pageSize": 20, + "total": 1, + "totalPages": 1 + } + } +} +``` + +### GET /api/stores/:id + +查询门店详情。需要 `store:view`。 + +路径参数: + +| 参数 | 类型 | 约束 | +| --- | --- | --- | +| `id` | `number` | 正整数 | + +响应 `data` 为 `Store`。 + +### POST /api/stores + +新增门店。需要 `store:manage`。 + +请求体: + +| 字段 | 类型 | 必填 | 默认值 | 约束 | 说明 | +| --- | --- | --- | --- | --- | --- | +| `name` | `string` | 是 | 无 | trim 后 1-100 字符 | 门店名称,未删除门店内唯一 | +| `address` | `string \| null` | 否 | `null` | trim 后最多 255 字符 | 空字符串会保存为 `null` | +| `phone` | `string \| null` | 否 | `null` | trim 后最多 30 字符 | 空字符串会保存为 `null` | +| `status` | `"ACTIVE" \| "INACTIVE"` | 否 | `"ACTIVE"` | 枚举 | 门店状态 | + +请求示例: + +```json +{ + "name": "人民广场店", + "address": "上海市黄浦区人民广场", + "phone": "021-12345678", + "status": "ACTIVE" +} +``` + +成功响应:`201 Created`,`data` 为 `Store`。 + +可能的业务错误: + +- `409 CONFLICT`:门店名称已存在。 + +### PATCH /api/stores/:id + +修改门店。需要 `store:manage`。 + +路径参数: + +| 参数 | 类型 | 约束 | +| --- | --- | --- | +| `id` | `number` | 正整数 | + +请求体至少提交一个字段: + +| 字段 | 类型 | 约束 | 说明 | +| --- | --- | --- | --- | +| `name` | `string` | trim 后 1-100 字符 | 门店名称 | +| `address` | `string \| null` | trim 后最多 255 字符 | 空字符串会保存为 `null` | +| `phone` | `string \| null` | trim 后最多 30 字符 | 空字符串会保存为 `null` | +| `status` | `"ACTIVE" \| "INACTIVE"` | 枚举 | 门店状态 | + +请求示例: + +```json +{ + "name": "人民广场旗舰店", + "status": "ACTIVE" +} +``` + +响应 `data` 为 `Store`。 + +可能的业务错误: + +- `404 NOT_FOUND`:门店不存在。 +- `409 CONFLICT`:门店名称已存在。 +- `409 CONFLICT`:门店下还有员工,不能停用。 + +### DELETE /api/stores/:id + +软删除门店。需要 `store:manage`。 + +成功响应:`204 No Content`。 + +可能的业务错误: + +- `404 NOT_FOUND`:门店不存在。 +- `409 CONFLICT`:门店下还有员工,不能删除。 + +## 角色接口 + +### GET /api/roles + +查询角色列表。需要 `role:view`。 + +查询参数: + +| 参数 | 类型 | 必填 | 默认值 | 约束 | 说明 | +| --- | --- | --- | --- | --- | --- | +| `keyword` | `string` | 否 | 无 | trim 后 1-100 字符 | 按角色编码、名称、说明模糊搜索 | +| `isSystem` | `boolean` | 否 | 无 | `true` 或 `false` | 是否服务端内置角色 | +| `page` | `number` | 否 | `1` | 正整数 | 页码;传了筛选或分页参数时才进入分页列表模式 | +| `pageSize` | `number` | 否 | `20` | 1-100 | 每页数量 | + +响应结构会随查询参数变化: + +- 不传任何查询参数:返回 `Role[]`,兼容角色下拉和旧调用。 +- 传任意一个查询参数:返回分页结构,`items` 为 `Role[]`,供角色管理列表使用。 + +请求示例: + +```http +GET /api/roles?keyword=店长&isSystem=true&page=1&pageSize=20 +Authorization: Bearer +``` + +响应示例: + +```json +{ + "success": true, + "data": { + "items": [ + { + "id": 1, + "code": "store_manager", + "name": "店长", + "description": "负责门店日常管理、排班和权限审批", + "isSystem": true, + "createdAt": "2026-05-26T08:00:00.000Z", + "updatedAt": "2026-05-26T08:00:00.000Z" + } + ], + "pagination": { + "page": 1, + "pageSize": 20, + "total": 1, + "totalPages": 1 + } + } +} +``` + +### GET /api/roles/:id + +查询角色详情。需要 `role:view`。 + +路径参数: + +| 参数 | 类型 | 约束 | +| --- | --- | --- | +| `id` | `number` | 正整数 | + +响应 `data` 为 `Role`。 + +### POST /api/roles + +新增自定义角色。需要 `role:manage`。当前内置 `admin` 角色没有 `role:manage`,通常只有超级管理员可调用。 + +请求体: + +| 字段 | 类型 | 必填 | 约束 | 说明 | +| --- | --- | --- | --- | --- | +| `code` | `string` | 是 | trim 后 1-50 字符,必须匹配 `^[a-z][a-z0-9_]*$` | 角色编码,全局唯一 | +| `name` | `string` | 是 | trim 后 1-50 字符 | 角色名称 | +| `description` | `string \| null` | 否 | trim 后最多 255 字符 | 空字符串会保存为 `null` | + +请求示例: + +```json +{ + "code": "auditor", + "name": "审计员", + "description": "只用于示例的自定义角色" +} +``` + +成功响应:`201 Created`,`data` 为 `Role`,其中 `isSystem` 为 `false`。 + +可能的业务错误: + +- `409 CONFLICT`:角色编码已存在。 + +### PATCH /api/roles/:id + +修改自定义角色。需要 `role:manage`。 + +请求体至少提交一个字段: + +| 字段 | 类型 | 约束 | 说明 | +| --- | --- | --- | --- | +| `code` | `string` | trim 后 1-50 字符,必须匹配 `^[a-z][a-z0-9_]*$` | 角色编码 | +| `name` | `string` | trim 后 1-50 字符 | 角色名称 | +| `description` | `string \| null` | trim 后最多 255 字符 | 空字符串会保存为 `null` | + +响应 `data` 为 `Role`。 + +可能的业务错误: + +- `404 NOT_FOUND`:角色不存在。 +- `409 CONFLICT`:服务端内置角色不可修改。 +- `409 CONFLICT`:角色编码已存在。 + +### DELETE /api/roles/:id + +删除自定义角色。需要 `role:manage`。 + +成功响应:`204 No Content`。 + +可能的业务错误: + +- `404 NOT_FOUND`:角色不存在。 +- `409 CONFLICT`:服务端内置角色不可删除。 +- `409 CONFLICT`:角色已绑定员工,不能删除。 + +## 员工接口 + +### GET /api/employees + +分页查询员工。需要 `employee:view:all` 或 `employee:view:store`。 + +查询参数: + +| 参数 | 类型 | 必填 | 默认值 | 约束 | 说明 | +| --- | --- | --- | --- | --- | --- | +| `storeId` | `number` | 否 | 无 | 正整数 | 门店筛选 | +| `status` | `"ACTIVE" \| "INACTIVE"` | 否 | 无 | 枚举 | 员工状态 | +| `keyword` | `string` | 否 | 无 | trim 后 1-100 字符 | 按姓名或手机号模糊搜索 | +| `page` | `number` | 否 | `1` | 正整数 | 页码 | +| `pageSize` | `number` | 否 | `20` | 1-100 | 每页数量 | + +权限范围: + +- 超级管理员和 `admin`:可看全部员工,`storeId` 参数生效。 +- `store_manager`:只能看当前门店员工,即使传了其他 `storeId`,后端也会强制改成当前用户的 `storeId`。 + +请求示例: + +```http +GET /api/employees?storeId=1&status=ACTIVE&keyword=张&page=1&pageSize=20 +Authorization: Bearer +``` + +响应: + +```json +{ + "success": true, + "data": { + "items": [ + { + "id": 1, + "storeId": 1, + "storeName": "示例门店", + "name": "张三", + "phone": "13800000001", + "status": "ACTIVE", + "remark": null, + "roles": [ + { + "id": 1, + "code": "store_manager", + "name": "店长" + } + ], + "createdAt": "2026-05-26T08:00:00.000Z", + "updatedAt": "2026-05-26T08:00:00.000Z" + } + ], + "pagination": { + "page": 1, + "pageSize": 20, + "total": 1, + "totalPages": 1 + } + } +} +``` + +### GET /api/employees/:id + +查询员工详情。需要员工查看权限。 + +权限范围: + +- `employee:view:all`:可看任意员工。 +- `employee:view:store`:只能看当前门店员工。 + +路径参数: + +| 参数 | 类型 | 约束 | +| --- | --- | --- | +| `id` | `number` | 正整数 | + +响应 `data` 为 `Employee`。 + +可能的业务错误: + +- `404 NOT_FOUND`:员工不存在。 +- `403 FORBIDDEN`:没有查看该员工的权限。 + +### POST /api/employees + +新增员工。需要 `employee:manage`。 + +请求体: + +| 字段 | 类型 | 必填 | 默认值 | 约束 | 说明 | +| --- | --- | --- | --- | --- | --- | +| `storeId` | `number` | 是 | 无 | 正整数 | 必须是启用且未删除的门店 | +| `name` | `string` | 是 | 无 | trim 后 1-50 字符 | 员工姓名 | +| `phone` | `string` | 是 | 无 | 中国大陆手机号,`/^1[3-9]\d{9}$/` | 员工登录账号,未删除员工范围内全局唯一 | +| `status` | `"ACTIVE" \| "INACTIVE"` | 否 | `"ACTIVE"` | 枚举 | 员工状态 | +| `remark` | `string \| null` | 否 | `null` | trim 后最多 500 字符 | 备注 | +| `roleIds` | `number[]` | 否 | `[]` | 正整数数组,最多 20 个 | 绑定角色 ID,重复 ID 会自动去重 | + +请求示例: + +```json +{ + "storeId": 1, + "name": "张三", + "phone": "13800000001", + "status": "ACTIVE", + "remark": null, + "roleIds": [1, 5] +} +``` + +成功响应:`201 Created`,`data` 为 `Employee`。 + +业务规则: + +- `storeId` 必须对应启用且未删除门店。 +- `phone` 在未删除员工范围内全局唯一。 +- `roleIds` 中的角色必须存在;自定义角色可先通过角色接口创建,再通过权限接口分配权限。 +- 新员工默认密码为 `pw111111`。 + +可能的业务错误: + +- `400 BAD_REQUEST`:门店不存在或已停用。 +- `400 BAD_REQUEST`:提交的角色包含不存在的角色。 +- `409 CONFLICT`:员工手机号已存在。 + +### PATCH /api/employees/:id + +修改员工。需要 `employee:manage`。 + +请求体至少提交一个字段: + +| 字段 | 类型 | 约束 | 说明 | +| --- | --- | --- | --- | +| `storeId` | `number` | 正整数 | 新门店必须启用且未删除 | +| `name` | `string` | trim 后 1-50 字符 | 员工姓名 | +| `phone` | `string` | 中国大陆手机号,`/^1[3-9]\d{9}$/` | 员工手机号,未删除员工范围内全局唯一 | +| `status` | `"ACTIVE" \| "INACTIVE"` | 枚举 | 员工状态 | +| `remark` | `string \| null` | trim 后最多 500 字符 | 备注 | +| `roleIds` | `number[]` | 正整数数组,最多 20 个 | 不传表示不修改角色,传空数组表示清空角色 | + +请求示例: + +```json +{ + "name": "张三丰", + "roleIds": [5] +} +``` + +响应 `data` 为 `Employee`。 + +可能的业务错误: + +- `404 NOT_FOUND`:员工不存在。 +- `400 BAD_REQUEST`:门店不存在或已停用。 +- `400 BAD_REQUEST`:提交的角色包含不存在的角色。 +- `409 CONFLICT`:员工手机号已存在。 + +### PATCH /api/employees/:id/status + +修改员工状态。需要 `employee:manage`。 + +请求体: + +| 字段 | 类型 | 必填 | 约束 | +| --- | --- | --- | --- | +| `status` | `"ACTIVE" \| "INACTIVE"` | 是 | 枚举 | + +请求示例: + +```json +{ + "status": "INACTIVE" +} +``` + +响应 `data` 为 `Employee`。 + +### DELETE /api/employees/:id + +软删除员工。需要 `employee:manage`。 + +成功响应:`204 No Content`。 + +删除后: + +- 员工 `status` 会改为 `INACTIVE`。 +- `deleted_at` 会写入删除时间。 +- 该手机号的唯一约束会释放,之后可以重新创建同手机号员工。 + +## 前端推荐接入流程 + +1. 调用 `POST /api/auth/admin/login` 完成后台登录。 +2. 保存 `data.token`,后续请求统一带 `Authorization: Bearer `。 +3. 调用 `GET /api/auth/me` 获取账号基础信息。 +4. 调用 `GET /api/permissions/me` 渲染菜单和按钮权限。 +5. 员工表单初始化时调用 `GET /api/stores` 获取启用门店选项,调用 `GET /api/roles` 获取角色选项。 +6. 员工列表使用 `GET /api/employees`,按返回的 `pagination` 渲染分页器。 + +## curl 示例 + +登录: + +```bash +curl -X POST http://localhost:3500/api/auth/admin/login \ + -H 'Content-Type: application/json' \ + -d '{"username":"admin","password":"Admin@123456"}' +``` + +查询当前用户: + +```bash +curl http://localhost:3500/api/auth/me \ + -H 'Authorization: Bearer ' +``` + +创建员工: + +```bash +curl -X POST http://localhost:3500/api/employees \ + -H 'Content-Type: application/json' \ + -H 'Authorization: Bearer ' \ + -d '{ + "storeId": 1, + "name": "张三", + "phone": "13800000001", + "roleIds": [1, 5] + }' +``` diff --git a/migrations/006_create_role_permissions.sql b/migrations/006_create_role_permissions.sql new file mode 100644 index 0000000..485edc2 --- /dev/null +++ b/migrations/006_create_role_permissions.sql @@ -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; diff --git a/src/modules/auth/auth.service.ts b/src/modules/auth/auth.service.ts index a1ed4ad..4afe08e 100644 --- a/src/modules/auth/auth.service.ts +++ b/src/modules/auth/auth.service.ts @@ -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 { 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 { 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, diff --git a/src/modules/catalog/catalog.controller.ts b/src/modules/catalog/catalog.controller.ts index 586a31f..a2a9214 100644 --- a/src/modules/catalog/catalog.controller.ts +++ b/src/modules/catalog/catalog.controller.ts @@ -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 { 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 { 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); diff --git a/src/modules/catalog/catalog.repository.ts b/src/modules/catalog/catalog.repository.ts index ff4968f..cb1ce29 100644 --- a/src/modules/catalog/catalog.repository.ts +++ b/src/modules/catalog/catalog.repository.ts @@ -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 { // 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( + ` + SELECT COUNT(*) AS total + FROM stores + WHERE ${whereSql} + `, + params, + ); + + const [rows] = await pool.execute( + ` + 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 { const [rows] = await pool.execute( ` @@ -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( + ` + SELECT COUNT(*) AS total + FROM roles + WHERE ${whereSql} + `, + params, + ); + + const [rows] = await pool.execute( + ` + 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 { const [rows] = await pool.execute( ` diff --git a/src/modules/catalog/catalog.schema.ts b/src/modules/catalog/catalog.schema.ts index d557edd..218361b 100644 --- a/src/modules/catalog/catalog.schema.ts +++ b/src/modules/catalog/catalog.schema.ts @@ -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({ diff --git a/src/modules/catalog/catalog.service.ts b/src/modules/catalog/catalog.service.ts index cb73667..8c202bc 100644 --- a/src/modules/catalog/catalog.service.ts +++ b/src/modules/catalog/catalog.service.ts @@ -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 { 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 { return catalogRepository.listRoleOptions(); }, diff --git a/src/modules/catalog/catalog.types.ts b/src/modules/catalog/catalog.types.ts index 617026a..550fe46 100644 --- a/src/modules/catalog/catalog.types.ts +++ b/src/modules/catalog/catalog.types.ts @@ -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 { diff --git a/src/modules/employees/employee.repository.ts b/src/modules/employees/employee.repository.ts index b8658e7..739bc38 100644 --- a/src/modules/employees/employee.repository.ts +++ b/src/modules/employees/employee.repository.ts @@ -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); diff --git a/src/modules/permissions/permission.controller.ts b/src/modules/permissions/permission.controller.ts index c92f303..770ac30 100644 --- a/src/modules/permissions/permission.controller.ts +++ b/src/modules/permissions/permission.controller.ts @@ -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 { app.get("/permissions/me", { preHandler: authGuard }, async (request) => { @@ -21,6 +22,27 @@ export async function permissionRoutes(app: FastifyInstance): Promise { 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); + }, ); } diff --git a/src/modules/permissions/permission.policy.ts b/src/modules/permissions/permission.policy.ts index 3dd8cee..e7b550f 100644 --- a/src/modules/permissions/permission.policy.ts +++ b/src/modules/permissions/permission.policy.ts @@ -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 = { - 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 = { + 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 +> = { + [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(); + + function addWithDependencies(permission: PermissionCode): void { + for (const dependency of PERMISSION_DEPENDENCIES[permission] ?? []) { + addWithDependencies(dependency); + } + + normalized.add(permission); } - const permissions = new Set(); - - 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 }>; +} { + const groups = new Map(); + + 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, + })); } diff --git a/src/modules/permissions/permission.repository.ts b/src/modules/permissions/permission.repository.ts new file mode 100644 index 0000000..17d9e0a --- /dev/null +++ b/src/modules/permissions/permission.repository.ts @@ -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(); + + 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( + handler: (connection: PoolConnection) => Promise, + ): Promise { + 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 { + const [rows] = await db.execute( + ` + 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 { + const [rows] = await db.execute( + ` + 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 { + if (roleCodes.length === 0) { + return []; + } + + const placeholders = roleCodes.map(() => "?").join(", "); + const [rows] = await pool.execute( + ` + 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 { + 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]), + ); + }, +}; diff --git a/src/modules/permissions/permission.schema.ts b/src/modules/permissions/permission.schema.ts new file mode 100644 index 0000000..ced3477 --- /dev/null +++ b/src/modules/permissions/permission.schema.ts @@ -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([]), +}); diff --git a/src/modules/permissions/permission.service.ts b/src/modules/permissions/permission.service.ts new file mode 100644 index 0000000..047efb1 --- /dev/null +++ b/src/modules/permissions/permission.service.ts @@ -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 { + if (accountType === "SUPER_ADMIN") { + return ["*"]; + } + + const permissions = + await permissionRepository.findPermissionCodesByRoleCodes(roleCodes); + + return normalizePermissionCodes(permissions); + }, + + getDefinitions() { + return getPermissionDefinitions(); + }, + + async listPolicies(): Promise { + const records = await permissionRepository.listRolePermissions(); + + return [buildSuperAdminPolicy(), ...records.map(buildPolicy)]; + }, + + async updateRolePermissions( + roleId: number, + permissions: string[], + ): Promise { + 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, + }); + }); + }, +};