feat: 设计菜单权限和员工端登录
This commit is contained in:
@@ -17,12 +17,12 @@
|
|||||||
## 项目能力
|
## 项目能力
|
||||||
|
|
||||||
- 门店管理:查询、新增、修改、软删除门店。
|
- 门店管理:查询、新增、修改、软删除门店。
|
||||||
- 角色管理:查询服务端固定角色,用于员工权限分配。
|
- 角色管理:管理员可查看角色;超级管理员可新增、修改、删除自定义角色,服务端内置角色不可变更。
|
||||||
- 员工管理:分页查询、新增、修改、启用/停用、软删除员工。
|
- 员工管理:分页查询、新增、修改、启用/停用、软删除员工。
|
||||||
- 员工角色:一个员工可以绑定多个角色。
|
- 员工角色:一个员工可以绑定多个角色。
|
||||||
- 登录账号:超级管理员和员工都可以登录。
|
- 登录账号:超级管理员和员工都可以登录。
|
||||||
- 后台权限:超级管理员拥有所有权限;员工只有绑定 `admin` 角色时才能访问后台管理接口。
|
- 后台权限:超级管理员拥有所有权限;管理员可管理门店和员工、只读角色;店长只看当前门店员工。
|
||||||
- 固定角色:店长、收银员、后厨、兼职、管理员是服务端固定角色,不提供角色新增、修改、删除接口。
|
- 固定权限:菜单和动作权限由服务端写死,前端只按接口返回结果展示。
|
||||||
- JWT 鉴权:登录后签发 token,除健康检查和登录外,接口都需要 Bearer token。
|
- JWT 鉴权:登录后签发 token,除健康检查和登录外,接口都需要 Bearer token。
|
||||||
- 数据校验:使用 zod 校验路径参数、查询参数和请求体。
|
- 数据校验:使用 zod 校验路径参数、查询参数和请求体。
|
||||||
- 数据库迁移:使用 `migrations/*.sql` 管理建表和初始化数据。
|
- 数据库迁移:使用 `migrations/*.sql` 管理建表和初始化数据。
|
||||||
@@ -44,7 +44,8 @@
|
|||||||
│ ├── 001_initial_schema.sql # 创建基础表结构
|
│ ├── 001_initial_schema.sql # 创建基础表结构
|
||||||
│ ├── 002_seed_demo_data.sql # 初始化演示门店和角色
|
│ ├── 002_seed_demo_data.sql # 初始化演示门店和角色
|
||||||
│ ├── 003_create_super_admins.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/
|
├── src/
|
||||||
│ ├── app.ts # 创建 Fastify 应用、注册路由、统一错误处理
|
│ ├── app.ts # 创建 Fastify 应用、注册路由、统一错误处理
|
||||||
│ ├── server.ts # 启动 HTTP 服务和优雅停机
|
│ ├── server.ts # 启动 HTTP 服务和优雅停机
|
||||||
@@ -56,7 +57,8 @@
|
|||||||
│ ├── modules/
|
│ ├── modules/
|
||||||
│ │ ├── auth/ # 登录、当前用户和 JWT 鉴权模块
|
│ │ ├── auth/ # 登录、当前用户和 JWT 鉴权模块
|
||||||
│ │ ├── catalog/ # 门店和角色模块
|
│ │ ├── catalog/ # 门店和角色模块
|
||||||
│ │ └── employees/ # 员工 CRUD 模块
|
│ │ ├── employees/ # 员工 CRUD 模块
|
||||||
|
│ │ └── permissions/ # 服务端固定菜单和动作权限策略
|
||||||
│ └── shared/ # 通用响应结构和业务错误
|
│ └── shared/ # 通用响应结构和业务错误
|
||||||
├── docker-compose.yml # 本地 MySQL
|
├── docker-compose.yml # 本地 MySQL
|
||||||
├── package.json
|
├── package.json
|
||||||
@@ -81,9 +83,10 @@
|
|||||||
| `src/config/env.ts` | 使用 zod 校验 `.env.development` 中的环境变量,避免配置错误拖到请求阶段才暴露。 |
|
| `src/config/env.ts` | 使用 zod 校验 `.env.development` 中的环境变量,避免配置错误拖到请求阶段才暴露。 |
|
||||||
| `src/db/migrate.ts` | 执行 `migrations/*.sql`,并用 `schema_migrations` 记录已执行迁移。 |
|
| `src/db/migrate.ts` | 执行 `migrations/*.sql`,并用 `schema_migrations` 记录已执行迁移。 |
|
||||||
| `src/db/pool.ts` | 创建 MySQL 连接池,提供数据库健康检查和关闭连接的方法。 |
|
| `src/db/pool.ts` | 创建 MySQL 连接池,提供数据库健康检查和关闭连接的方法。 |
|
||||||
| `src/modules/auth/` | 登录鉴权模块,负责超级管理员和员工登录、密码校验、JWT 签发、当前用户查询和后台权限 guard。 |
|
| `src/modules/auth/` | 登录鉴权模块,负责后台登录、员工端登录、密码校验、JWT 签发、当前用户查询和权限 guard。 |
|
||||||
| `src/modules/catalog/` | 门店和角色模块,负责基础资料接口。 |
|
| `src/modules/catalog/` | 门店和角色模块,负责基础资料接口。 |
|
||||||
| `src/modules/employees/` | 员工模块,负责员工分页、详情、新增、修改、状态变更和软删除。 |
|
| `src/modules/employees/` | 员工模块,负责员工分页、详情、新增、修改、状态变更和软删除。 |
|
||||||
|
| `src/modules/permissions/` | 服务端固定权限策略,返回前端菜单、动作权限和权限策略说明。 |
|
||||||
| `src/shared/` | 跨模块复用的响应结构和业务错误类型。 |
|
| `src/shared/` | 跨模块复用的响应结构和业务错误类型。 |
|
||||||
| `docker-compose.yml` | 本地开发用 MySQL 容器配置。 |
|
| `docker-compose.yml` | 本地开发用 MySQL 容器配置。 |
|
||||||
| `package.json` | 项目信息、依赖和常用脚本;脚本会读取现有 `.env.development`。 |
|
| `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) 初始化:
|
默认本地超级管理员账号由 [003_create_super_admins.sql](./migrations/003_create_super_admins.sql) 初始化:
|
||||||
|
|
||||||
@@ -269,17 +272,17 @@ pnpm dev
|
|||||||
密码:Admin@123456
|
密码: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
|
```text
|
||||||
账号:员工手机号
|
账号:员工手机号
|
||||||
密码:Employee@123456
|
密码:pw111111
|
||||||
```
|
```
|
||||||
|
|
||||||
登录获取 token:
|
后台登录获取 token。超级管理员、管理员和店长使用这个接口:
|
||||||
|
|
||||||
```bash
|
```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' \
|
-H 'Content-Type: application/json' \
|
||||||
-d '{
|
-d '{
|
||||||
"username": "admin",
|
"username": "admin",
|
||||||
@@ -287,8 +290,21 @@ curl -X POST http://localhost:3500/api/auth/login \
|
|||||||
}'
|
}'
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`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.token` 就是后续接口要使用的 JWT。
|
||||||
响应里的 `data.user.canManage` 表示当前账号是否能访问后台管理接口。
|
响应里的 `data.user.permissions` 是服务端计算出的固定权限点;菜单和按钮动作以 `/api/permissions/me` 返回结果为准。
|
||||||
|
|
||||||
为了方便测试,可以先把 token 保存成 shell 变量:
|
为了方便测试,可以先把 token 保存成 shell 变量:
|
||||||
|
|
||||||
@@ -309,7 +325,30 @@ curl http://localhost:3500/api/auth/me \
|
|||||||
-H "Authorization: Bearer $TOKEN"
|
-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"
|
-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"
|
-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):写入一个示例门店和几个常见角色。
|
- [002_seed_demo_data.sql](./migrations/002_seed_demo_data.sql):写入一个示例门店和几个常见角色。
|
||||||
- [003_create_super_admins.sql](./migrations/003_create_super_admins.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):给员工补充登录密码哈希和最后登录时间。
|
- [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` 时,脚本会:
|
执行 `pnpm db:migrate` 时,脚本会:
|
||||||
|
|
||||||
@@ -476,12 +530,13 @@ migrations/003_add_employee_email.sql
|
|||||||
## 学习重点
|
## 学习重点
|
||||||
|
|
||||||
- `stores.deleted_at` 和 `employees.deleted_at` 用于软删除。
|
- `stores.deleted_at` 和 `employees.deleted_at` 用于软删除。
|
||||||
- `employees.active_phone` 是生成列,用来实现“同一门店未删除员工手机号唯一”。
|
- `employees.active_phone` 是生成列,用来实现“未删除员工手机号全局唯一”。
|
||||||
- `employees.password_hash` 让员工也能登录,默认本地密码是 `Employee@123456`。
|
- `employees.password_hash` 让员工也能登录,默认本地密码是 `pw111111`。
|
||||||
- `employee_roles` 是多对多关系表。
|
- `employee_roles` 是多对多关系表。
|
||||||
- `super_admins` 保存超级管理员账号,密码使用 PBKDF2 哈希,禁止存明文。
|
- `super_admins` 保存超级管理员账号,密码使用 PBKDF2 哈希,禁止存明文。
|
||||||
- 角色定义由服务端固定,`admin` 角色用于判断员工是否能访问后台管理接口。
|
- 菜单和动作权限由 `src/modules/permissions/` 固定,前端根据 `/api/permissions/me` 渲染。
|
||||||
- JWT 鉴权在 `src/modules/auth/` 中实现,`managementGuard` 统一保护后台管理接口。
|
- `admin` 角色可查看角色、管理门店和员工;`store_manager` 只能查看当前门店员工。
|
||||||
|
- JWT 鉴权在 `src/modules/auth/` 中实现,`permissionGuard` 按固定权限点保护接口。
|
||||||
- `repository` 使用参数化查询,避免 SQL 注入。
|
- `repository` 使用参数化查询,避免 SQL 注入。
|
||||||
- `service` 使用事务保证员工信息和角色绑定同时成功或同时失败。
|
- `service` 使用事务保证员工信息和角色绑定同时成功或同时失败。
|
||||||
- `app.ts` 统一注册 JWT、业务路由和错误处理,并处理 zod 校验错误、业务错误和数据库唯一索引冲突。
|
- `app.ts` 统一注册 JWT、业务路由和错误处理,并处理 zod 校验错误、业务错误和数据库唯一索引冲突。
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -7,6 +7,7 @@ import { authRoutes } from "./modules/auth/auth.controller";
|
|||||||
import { managementGuard } from "./modules/auth/auth.guard";
|
import { managementGuard } from "./modules/auth/auth.guard";
|
||||||
import { catalogRoutes } from "./modules/catalog/catalog.controller";
|
import { catalogRoutes } from "./modules/catalog/catalog.controller";
|
||||||
import { employeeRoutes } from "./modules/employees/employee.controller";
|
import { employeeRoutes } from "./modules/employees/employee.controller";
|
||||||
|
import { permissionRoutes } from "./modules/permissions/permission.controller";
|
||||||
import { HttpError } from "./shared/http-error";
|
import { HttpError } from "./shared/http-error";
|
||||||
import { ok } from "./shared/response";
|
import { ok } from "./shared/response";
|
||||||
|
|
||||||
@@ -56,6 +57,7 @@ export function createApp() {
|
|||||||
|
|
||||||
// 登录接口不需要 token;/auth/me 在 authRoutes 内部单独加了 authGuard。
|
// 登录接口不需要 token;/auth/me 在 authRoutes 内部单独加了 authGuard。
|
||||||
app.register(authRoutes, { prefix: "/api" });
|
app.register(authRoutes, { prefix: "/api" });
|
||||||
|
app.register(permissionRoutes, { prefix: "/api" });
|
||||||
|
|
||||||
// 业务管理接口统一要求后台权限:超级管理员或拥有 admin 角色的员工。
|
// 业务管理接口统一要求后台权限:超级管理员或拥有 admin 角色的员工。
|
||||||
app.register(
|
app.register(
|
||||||
|
|||||||
@@ -8,7 +8,33 @@ import { authService } from "./auth.service";
|
|||||||
export async function authRoutes(app: FastifyInstance): Promise<void> {
|
export async function authRoutes(app: FastifyInstance): Promise<void> {
|
||||||
app.post("/auth/login", async (request) => {
|
app.post("/auth/login", async (request) => {
|
||||||
const body = loginBodySchema.parse(request.body);
|
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);
|
const token = app.jwt.sign(payload);
|
||||||
|
|
||||||
return ok({
|
return ok({
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
import type { FastifyRequest } from "fastify";
|
import type { FastifyRequest } from "fastify";
|
||||||
import { forbidden, unauthorized } from "../../shared/http-error";
|
import { forbidden, unauthorized } from "../../shared/http-error";
|
||||||
import { authService } from "./auth.service";
|
import { authService } from "./auth.service";
|
||||||
|
import {
|
||||||
|
hasPermission,
|
||||||
|
type PermissionCode,
|
||||||
|
} from "../permissions/permission.policy";
|
||||||
|
|
||||||
// 统一 JWT 鉴权入口。后续新增需要登录的路由,复用这个 guard 即可。
|
// 统一 JWT 鉴权入口。后续新增需要登录的路由,复用这个 guard 即可。
|
||||||
export async function authGuard(request: FastifyRequest): Promise<void> {
|
export async function authGuard(request: FastifyRequest): Promise<void> {
|
||||||
@@ -23,7 +27,19 @@ export async function managementGuard(request: FastifyRequest): Promise<void> {
|
|||||||
|
|
||||||
const user = await authService.getCurrentUser(request.user);
|
const user = await authService.getCurrentUser(request.user);
|
||||||
|
|
||||||
if (!user.canManage) {
|
if (!user.permissions.includes("*") && user.permissions.length === 0) {
|
||||||
throw forbidden("当前账号没有后台管理权限");
|
throw forbidden("当前账号没有后台管理权限");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function permissionGuard(permission: PermissionCode) {
|
||||||
|
return async (request: FastifyRequest): Promise<void> => {
|
||||||
|
await authGuard(request);
|
||||||
|
|
||||||
|
const user = await authService.getCurrentUser(request.user);
|
||||||
|
|
||||||
|
if (!hasPermission(user.permissions, permission)) {
|
||||||
|
throw forbidden("当前账号没有权限执行该操作");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,11 +5,15 @@ import type {
|
|||||||
AuthUser,
|
AuthUser,
|
||||||
EmployeeLoginAccount,
|
EmployeeLoginAccount,
|
||||||
LoginInput,
|
LoginInput,
|
||||||
|
LoginScene,
|
||||||
SuperAdmin,
|
SuperAdmin,
|
||||||
} from "./auth.types";
|
} from "./auth.types";
|
||||||
import { verifyPassword } from "./password";
|
import { verifyPassword } from "./password";
|
||||||
|
import { resolvePermissions } from "../permissions/permission.policy";
|
||||||
|
|
||||||
function toAuthUser(admin: SuperAdmin): AuthUser {
|
function toAuthUser(admin: SuperAdmin): AuthUser {
|
||||||
|
const roleCodes = ["super_admin"];
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: admin.id,
|
id: admin.id,
|
||||||
username: admin.username,
|
username: admin.username,
|
||||||
@@ -22,13 +26,14 @@ function toAuthUser(admin: SuperAdmin): AuthUser {
|
|||||||
name: "超级管理员",
|
name: "超级管理员",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
permissions: ["*"],
|
permissions: resolvePermissions("SUPER_ADMIN", roleCodes),
|
||||||
canManage: true,
|
canManage: true,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function toEmployeeAuthUser(employee: EmployeeLoginAccount): AuthUser {
|
function toEmployeeAuthUser(employee: EmployeeLoginAccount): AuthUser {
|
||||||
const canManage = employee.roles.some((role) => role.code === "admin");
|
const canManage = employee.roles.some((role) => role.code === "admin");
|
||||||
|
const roleCodes = employee.roles.map((role) => role.code);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: employee.id,
|
id: employee.id,
|
||||||
@@ -38,18 +43,23 @@ function toEmployeeAuthUser(employee: EmployeeLoginAccount): AuthUser {
|
|||||||
storeId: employee.storeId,
|
storeId: employee.storeId,
|
||||||
storeName: employee.storeName,
|
storeName: employee.storeName,
|
||||||
roles: employee.roles,
|
roles: employee.roles,
|
||||||
permissions: canManage ? ["*"] : [],
|
permissions: resolvePermissions("EMPLOYEE", roleCodes),
|
||||||
canManage,
|
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 =
|
const subjectPrefix =
|
||||||
user.accountType === "SUPER_ADMIN" ? "super_admin" : "employee";
|
user.accountType === "SUPER_ADMIN" ? "super_admin" : "employee";
|
||||||
|
|
||||||
return {
|
return {
|
||||||
sub: `${subjectPrefix}:${user.id}`,
|
sub: `${subjectPrefix}:${user.id}`,
|
||||||
accountType: user.accountType,
|
accountType: user.accountType,
|
||||||
|
scene,
|
||||||
adminId: user.accountType === "SUPER_ADMIN" ? user.id : undefined,
|
adminId: user.accountType === "SUPER_ADMIN" ? user.id : undefined,
|
||||||
employeeId: user.accountType === "EMPLOYEE" ? user.id : undefined,
|
employeeId: user.accountType === "EMPLOYEE" ? user.id : undefined,
|
||||||
username: user.username,
|
username: user.username,
|
||||||
@@ -60,7 +70,7 @@ function toJwtPayload(user: AuthUser): AuthJwtPayload {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const authService = {
|
export const authService = {
|
||||||
async login(input: LoginInput): Promise<{
|
async loginManagement(input: LoginInput): Promise<{
|
||||||
user: AuthUser;
|
user: AuthUser;
|
||||||
payload: AuthJwtPayload;
|
payload: AuthJwtPayload;
|
||||||
}> {
|
}> {
|
||||||
@@ -82,7 +92,7 @@ export const authService = {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
user,
|
user,
|
||||||
payload: toJwtPayload(user),
|
payload: toJwtPayload(user, "MANAGEMENT"),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -107,9 +117,44 @@ export const authService = {
|
|||||||
|
|
||||||
const user = toEmployeeAuthUser(employee);
|
const user = toEmployeeAuthUser(employee);
|
||||||
|
|
||||||
|
if (!hasBackendMenu(user)) {
|
||||||
|
throw unauthorized("当前账号没有后台登录权限");
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
user,
|
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"),
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ export const SUPER_ADMIN_STATUS = ["ACTIVE", "INACTIVE"] as const;
|
|||||||
|
|
||||||
export type SuperAdminStatus = (typeof SUPER_ADMIN_STATUS)[number];
|
export type SuperAdminStatus = (typeof SUPER_ADMIN_STATUS)[number];
|
||||||
export type AuthAccountType = "SUPER_ADMIN" | "EMPLOYEE";
|
export type AuthAccountType = "SUPER_ADMIN" | "EMPLOYEE";
|
||||||
|
export type LoginScene = "MANAGEMENT" | "EMPLOYEE_APP";
|
||||||
|
|
||||||
export interface LoginInput {
|
export interface LoginInput {
|
||||||
username: string;
|
username: string;
|
||||||
@@ -55,6 +56,7 @@ export interface AuthUser {
|
|||||||
export interface AuthJwtPayload {
|
export interface AuthJwtPayload {
|
||||||
sub: string;
|
sub: string;
|
||||||
accountType: AuthAccountType;
|
accountType: AuthAccountType;
|
||||||
|
scene: LoginScene;
|
||||||
adminId?: number;
|
adminId?: number;
|
||||||
employeeId?: number;
|
employeeId?: number;
|
||||||
username: string;
|
username: string;
|
||||||
|
|||||||
@@ -1,17 +1,21 @@
|
|||||||
import type { FastifyInstance } from "fastify";
|
import type { FastifyInstance } from "fastify";
|
||||||
import { created, ok } from "../../shared/response";
|
import { created, ok } from "../../shared/response";
|
||||||
|
import { permissionGuard } from "../auth/auth.guard";
|
||||||
|
import { PERMISSIONS } from "../permissions/permission.policy";
|
||||||
import { catalogService } from "./catalog.service";
|
import { catalogService } from "./catalog.service";
|
||||||
import {
|
import {
|
||||||
|
createRoleBodySchema,
|
||||||
createStoreBodySchema,
|
createStoreBodySchema,
|
||||||
idParamSchema,
|
idParamSchema,
|
||||||
listStoresQuerySchema,
|
listStoresQuerySchema,
|
||||||
|
updateRoleBodySchema,
|
||||||
updateStoreBodySchema,
|
updateStoreBodySchema,
|
||||||
} from "./catalog.schema";
|
} from "./catalog.schema";
|
||||||
|
|
||||||
// catalogRoutes 管理“字典/基础资料”接口:门店和角色。
|
// catalogRoutes 管理“字典/基础资料”接口:门店和角色。
|
||||||
// controller 层只做三件事:校验入参、调用 service、包装 HTTP 响应。
|
// controller 层只做三件事:校验入参、调用 service、包装 HTTP 响应。
|
||||||
export async function catalogRoutes(app: FastifyInstance): Promise<void> {
|
export async function catalogRoutes(app: FastifyInstance): Promise<void> {
|
||||||
app.get("/stores", async (request) => {
|
app.get("/stores", { preHandler: permissionGuard(PERMISSIONS.STORE_VIEW) }, async (request) => {
|
||||||
const query = listStoresQuerySchema.parse(request.query);
|
const query = listStoresQuerySchema.parse(request.query);
|
||||||
// 默认返回可选门店;需要管理后台列表时,可通过 includeInactive=true 带出停用门店。
|
// 默认返回可选门店;需要管理后台列表时,可通过 includeInactive=true 带出停用门店。
|
||||||
const stores = query.includeInactive
|
const stores = query.includeInactive
|
||||||
@@ -21,21 +25,21 @@ export async function catalogRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
return ok(stores);
|
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 params = idParamSchema.parse(request.params);
|
||||||
const store = await catalogService.getStoreById(params.id);
|
const store = await catalogService.getStoreById(params.id);
|
||||||
|
|
||||||
return ok(store);
|
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 body = createStoreBodySchema.parse(request.body);
|
||||||
const store = await catalogService.createStore(body);
|
const store = await catalogService.createStore(body);
|
||||||
|
|
||||||
return reply.code(201).send(created(store));
|
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 params = idParamSchema.parse(request.params);
|
||||||
const body = updateStoreBodySchema.parse(request.body);
|
const body = updateStoreBodySchema.parse(request.body);
|
||||||
const store = await catalogService.updateStore(params.id, body);
|
const store = await catalogService.updateStore(params.id, body);
|
||||||
@@ -43,7 +47,7 @@ export async function catalogRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
return ok(store);
|
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);
|
const params = idParamSchema.parse(request.params);
|
||||||
await catalogService.deleteStore(params.id);
|
await catalogService.deleteStore(params.id);
|
||||||
|
|
||||||
@@ -51,18 +55,38 @@ export async function catalogRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
return reply.code(204).send();
|
return reply.code(204).send();
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get("/roles", async () => {
|
app.get("/roles", { preHandler: permissionGuard(PERMISSIONS.ROLE_VIEW) }, async () => {
|
||||||
const roles = await catalogService.listRoles();
|
const roles = await catalogService.listRoles();
|
||||||
|
|
||||||
return ok(roles);
|
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 params = idParamSchema.parse(request.params);
|
||||||
const role = await catalogService.getRoleById(params.id);
|
const role = await catalogService.getRoleById(params.id);
|
||||||
|
|
||||||
return ok(role);
|
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();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { ResultSetHeader, RowDataPacket } from "mysql2/promise";
|
import type { ResultSetHeader, RowDataPacket } from "mysql2/promise";
|
||||||
import { pool } from "../../db/pool";
|
import { pool } from "../../db/pool";
|
||||||
import { FIXED_ROLE_CODES } from "./catalog.types";
|
|
||||||
import type {
|
import type {
|
||||||
|
CreateRoleInput,
|
||||||
CreateStoreInput,
|
CreateStoreInput,
|
||||||
ListStoresQuery,
|
ListStoresQuery,
|
||||||
Role,
|
Role,
|
||||||
@@ -9,11 +9,11 @@ import type {
|
|||||||
Store,
|
Store,
|
||||||
StoreOption,
|
StoreOption,
|
||||||
StoreStatus,
|
StoreStatus,
|
||||||
|
UpdateRoleInput,
|
||||||
UpdateStoreInput,
|
UpdateStoreInput,
|
||||||
} from "./catalog.types";
|
} from "./catalog.types";
|
||||||
|
|
||||||
type SqlParam = string | number | boolean | Date | null;
|
type SqlParam = string | number | boolean | Date | null;
|
||||||
const fixedRoleCodePlaceholders = FIXED_ROLE_CODES.map(() => "?").join(", ");
|
|
||||||
|
|
||||||
interface StoreRow extends RowDataPacket {
|
interface StoreRow extends RowDataPacket {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -30,6 +30,7 @@ interface RoleRow extends RowDataPacket {
|
|||||||
code: string;
|
code: string;
|
||||||
name: string;
|
name: string;
|
||||||
description: string | null;
|
description: string | null;
|
||||||
|
is_system: number;
|
||||||
created_at: Date;
|
created_at: Date;
|
||||||
updated_at: Date;
|
updated_at: Date;
|
||||||
}
|
}
|
||||||
@@ -71,6 +72,7 @@ function toRole(row: RoleRow): Role {
|
|||||||
code: row.code,
|
code: row.code,
|
||||||
name: row.name,
|
name: row.name,
|
||||||
description: row.description,
|
description: row.description,
|
||||||
|
isSystem: row.is_system === 1,
|
||||||
createdAt: toIso(row.created_at),
|
createdAt: toIso(row.created_at),
|
||||||
updatedAt: toIso(row.updated_at),
|
updatedAt: toIso(row.updated_at),
|
||||||
};
|
};
|
||||||
@@ -82,6 +84,7 @@ function toRoleOption(row: RoleRow): RoleOption {
|
|||||||
code: row.code,
|
code: row.code,
|
||||||
name: row.name,
|
name: row.name,
|
||||||
description: row.description,
|
description: row.description,
|
||||||
|
isSystem: row.is_system === 1,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -234,12 +237,10 @@ export const catalogRepository = {
|
|||||||
async listRoles(): Promise<Role[]> {
|
async listRoles(): Promise<Role[]> {
|
||||||
const [rows] = await pool.execute<RoleRow[]>(
|
const [rows] = await pool.execute<RoleRow[]>(
|
||||||
`
|
`
|
||||||
SELECT id, code, name, description, created_at, updated_at
|
SELECT id, code, name, description, is_system, created_at, updated_at
|
||||||
FROM roles
|
FROM roles
|
||||||
WHERE code IN (${fixedRoleCodePlaceholders})
|
|
||||||
ORDER BY id ASC
|
ORDER BY id ASC
|
||||||
`,
|
`,
|
||||||
[...FIXED_ROLE_CODES],
|
|
||||||
);
|
);
|
||||||
|
|
||||||
return rows.map(toRole);
|
return rows.map(toRole);
|
||||||
@@ -248,12 +249,10 @@ export const catalogRepository = {
|
|||||||
async listRoleOptions(): Promise<RoleOption[]> {
|
async listRoleOptions(): Promise<RoleOption[]> {
|
||||||
const [rows] = await pool.execute<RoleRow[]>(
|
const [rows] = await pool.execute<RoleRow[]>(
|
||||||
`
|
`
|
||||||
SELECT id, code, name, description, created_at, updated_at
|
SELECT id, code, name, description, is_system, created_at, updated_at
|
||||||
FROM roles
|
FROM roles
|
||||||
WHERE code IN (${fixedRoleCodePlaceholders})
|
|
||||||
ORDER BY id ASC
|
ORDER BY id ASC
|
||||||
`,
|
`,
|
||||||
[...FIXED_ROLE_CODES],
|
|
||||||
);
|
);
|
||||||
|
|
||||||
return rows.map(toRoleOption);
|
return rows.map(toRoleOption);
|
||||||
@@ -262,15 +261,104 @@ export const catalogRepository = {
|
|||||||
async findRoleById(id: number): Promise<Role | null> {
|
async findRoleById(id: number): Promise<Role | null> {
|
||||||
const [rows] = await pool.execute<RoleRow[]>(
|
const [rows] = await pool.execute<RoleRow[]>(
|
||||||
`
|
`
|
||||||
SELECT id, code, name, description, created_at, updated_at
|
SELECT id, code, name, description, is_system, created_at, updated_at
|
||||||
FROM roles
|
FROM roles
|
||||||
WHERE id = ? AND code IN (${fixedRoleCodePlaceholders})
|
WHERE id = ?
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
`,
|
`,
|
||||||
[id, ...FIXED_ROLE_CODES],
|
[id],
|
||||||
);
|
);
|
||||||
|
|
||||||
return rows[0] ? toRole(rows[0]) : null;
|
return rows[0] ? toRole(rows[0]) : null;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async findRoleByCode(
|
||||||
|
code: string,
|
||||||
|
excludeRoleId?: number,
|
||||||
|
): Promise<Role | null> {
|
||||||
|
const params: SqlParam[] = [code];
|
||||||
|
let excludeSql = "";
|
||||||
|
|
||||||
|
if (excludeRoleId !== undefined) {
|
||||||
|
// 修改角色编码时排除当前角色,避免自己和自己冲突。
|
||||||
|
excludeSql = " AND id <> ?";
|
||||||
|
params.push(excludeRoleId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [rows] = await pool.execute<RoleRow[]>(
|
||||||
|
`
|
||||||
|
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<number> {
|
||||||
|
const [result] = await pool.execute<ResultSetHeader>(
|
||||||
|
`
|
||||||
|
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<void> {
|
||||||
|
// 角色 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<void> {
|
||||||
|
await pool.execute("DELETE FROM roles WHERE id = ?", [id]);
|
||||||
|
},
|
||||||
|
|
||||||
|
async countEmployeesByRole(roleId: number): Promise<number> {
|
||||||
|
const [rows] = await pool.execute<CountRow[]>(
|
||||||
|
`
|
||||||
|
SELECT COUNT(*) AS total
|
||||||
|
FROM employee_roles
|
||||||
|
WHERE role_id = ?
|
||||||
|
`,
|
||||||
|
[roleId],
|
||||||
|
);
|
||||||
|
|
||||||
|
return rows[0]?.total ?? 0;
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -58,3 +58,24 @@ export const updateStoreBodySchema = z
|
|||||||
.refine((value) => Object.keys(value).length > 0, {
|
.refine((value) => Object.keys(value).length > 0, {
|
||||||
message: "至少需要提交一个要修改的字段",
|
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: "至少需要提交一个要修改的字段",
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
import { conflict, notFound } from "../../shared/http-error";
|
import { conflict, notFound } from "../../shared/http-error";
|
||||||
import { catalogRepository } from "./catalog.repository";
|
import { catalogRepository } from "./catalog.repository";
|
||||||
import type {
|
import type {
|
||||||
|
CreateRoleInput,
|
||||||
CreateStoreInput,
|
CreateStoreInput,
|
||||||
ListStoresQuery,
|
ListStoresQuery,
|
||||||
Role,
|
Role,
|
||||||
RoleOption,
|
RoleOption,
|
||||||
Store,
|
Store,
|
||||||
StoreOption,
|
StoreOption,
|
||||||
|
UpdateRoleInput,
|
||||||
UpdateStoreInput,
|
UpdateStoreInput,
|
||||||
} from "./catalog.types";
|
} from "./catalog.types";
|
||||||
|
|
||||||
@@ -104,5 +106,53 @@ export const catalogService = {
|
|||||||
return role;
|
return role;
|
||||||
},
|
},
|
||||||
|
|
||||||
// 角色是服务端固定权限集合,只允许查询,不允许通过接口变更。
|
async createRole(input: CreateRoleInput): Promise<Role> {
|
||||||
|
// 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<Role> {
|
||||||
|
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<void> {
|
||||||
|
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);
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ export interface RoleOption {
|
|||||||
code: string;
|
code: string;
|
||||||
name: string;
|
name: string;
|
||||||
description: string | null;
|
description: string | null;
|
||||||
|
isSystem: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Store/Role 是详情或管理列表使用的完整返回结构。
|
// Store/Role 是详情或管理列表使用的完整返回结构。
|
||||||
@@ -78,3 +79,15 @@ export interface UpdateStoreInput {
|
|||||||
phone?: string | null;
|
phone?: string | null;
|
||||||
status?: StoreStatus;
|
status?: StoreStatus;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface CreateRoleInput {
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
description?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateRoleInput {
|
||||||
|
code?: string;
|
||||||
|
name?: string;
|
||||||
|
description?: string | null;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,12 @@
|
|||||||
import type { FastifyInstance } from "fastify";
|
import type { FastifyInstance } from "fastify";
|
||||||
|
import { forbidden } from "../../shared/http-error";
|
||||||
import { created, ok, paginated } from "../../shared/response";
|
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 { employeeService } from "./employee.service";
|
||||||
import {
|
import {
|
||||||
createEmployeeBodySchema,
|
createEmployeeBodySchema,
|
||||||
@@ -8,25 +15,85 @@ import {
|
|||||||
updateEmployeeBodySchema,
|
updateEmployeeBodySchema,
|
||||||
updateEmployeeStatusBodySchema
|
updateEmployeeStatusBodySchema
|
||||||
} from "./employee.schema";
|
} 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。
|
// 员工接口是本项目的核心 CRUD。
|
||||||
// controller 层保持轻量:解析请求参数,调用 service,返回统一响应。
|
// controller 层保持轻量:解析请求参数,调用 service,返回统一响应。
|
||||||
export async function employeeRoutes(app: FastifyInstance): Promise<void> {
|
export async function employeeRoutes(app: FastifyInstance): Promise<void> {
|
||||||
app.get("/employees", async (request) => {
|
app.get("/employees", async (request) => {
|
||||||
const query = listEmployeesQuerySchema.parse(request.query);
|
const user = await authService.getCurrentUser(request.user);
|
||||||
const result = await employeeService.list(query);
|
|
||||||
|
|
||||||
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) => {
|
app.get("/employees/:id", async (request) => {
|
||||||
|
const user = await authService.getCurrentUser(request.user);
|
||||||
const params = idParamSchema.parse(request.params);
|
const params = idParamSchema.parse(request.params);
|
||||||
const employee = await employeeService.getById(params.id);
|
const employee = await employeeService.getById(params.id);
|
||||||
|
assertCanViewEmployee(user, employee);
|
||||||
|
|
||||||
return ok(employee);
|
return ok(employee);
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post("/employees", async (request, reply) => {
|
app.post("/employees", async (request, reply) => {
|
||||||
|
const user = await authService.getCurrentUser(request.user);
|
||||||
|
assertCanManageEmployees(user);
|
||||||
|
|
||||||
const body = createEmployeeBodySchema.parse(request.body);
|
const body = createEmployeeBodySchema.parse(request.body);
|
||||||
const employee = await employeeService.create(body);
|
const employee = await employeeService.create(body);
|
||||||
|
|
||||||
@@ -34,6 +101,9 @@ export async function employeeRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
});
|
});
|
||||||
|
|
||||||
app.patch("/employees/:id", async (request) => {
|
app.patch("/employees/:id", async (request) => {
|
||||||
|
const user = await authService.getCurrentUser(request.user);
|
||||||
|
assertCanManageEmployees(user);
|
||||||
|
|
||||||
const params = idParamSchema.parse(request.params);
|
const params = idParamSchema.parse(request.params);
|
||||||
const body = updateEmployeeBodySchema.parse(request.body);
|
const body = updateEmployeeBodySchema.parse(request.body);
|
||||||
const employee = await employeeService.update(params.id, body);
|
const employee = await employeeService.update(params.id, body);
|
||||||
@@ -42,6 +112,9 @@ export async function employeeRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
});
|
});
|
||||||
|
|
||||||
app.patch("/employees/:id/status", async (request) => {
|
app.patch("/employees/:id/status", async (request) => {
|
||||||
|
const user = await authService.getCurrentUser(request.user);
|
||||||
|
assertCanManageEmployees(user);
|
||||||
|
|
||||||
const params = idParamSchema.parse(request.params);
|
const params = idParamSchema.parse(request.params);
|
||||||
const body = updateEmployeeStatusBodySchema.parse(request.body);
|
const body = updateEmployeeStatusBodySchema.parse(request.body);
|
||||||
// 单独提供状态接口,方便前端做“启用/停用”开关,而不必提交完整员工表单。
|
// 单独提供状态接口,方便前端做“启用/停用”开关,而不必提交完整员工表单。
|
||||||
@@ -51,6 +124,9 @@ export async function employeeRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
});
|
});
|
||||||
|
|
||||||
app.delete("/employees/:id", async (request, reply) => {
|
app.delete("/employees/:id", async (request, reply) => {
|
||||||
|
const user = await authService.getCurrentUser(request.user);
|
||||||
|
assertCanManageEmployees(user);
|
||||||
|
|
||||||
const params = idParamSchema.parse(request.params);
|
const params = idParamSchema.parse(request.params);
|
||||||
await employeeService.delete(params.id);
|
await employeeService.delete(params.id);
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ type DbExecutor = typeof pool | PoolConnection;
|
|||||||
type SqlParam = string | number | boolean | Date | null;
|
type SqlParam = string | number | boolean | Date | null;
|
||||||
const fixedRoleCodePlaceholders = FIXED_ROLE_CODES.map(() => "?").join(", ");
|
const fixedRoleCodePlaceholders = FIXED_ROLE_CODES.map(() => "?").join(", ");
|
||||||
const DEFAULT_EMPLOYEE_PASSWORD_HASH =
|
const DEFAULT_EMPLOYEE_PASSWORD_HASH =
|
||||||
"pbkdf2$sha256$310000$Vd5Mh3XgZPZ4ozECQzmviA$YnzG8OAqy9bZE9ZmA2yT1RpUl0bbC0yA9LpYUO8LltQ";
|
"pbkdf2$sha256$310000$QnXyjrpm0QzcGYLEPdunWg$CfR-CywGl1c_Omh_3PyOWPmo93EcbMY1FEjjd5MDjFo";
|
||||||
|
|
||||||
interface EmployeeRow extends RowDataPacket {
|
interface EmployeeRow extends RowDataPacket {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -134,13 +134,12 @@ export const employeeRepository = {
|
|||||||
return rows.map((row) => row.id);
|
return rows.map((row) => row.id);
|
||||||
},
|
},
|
||||||
|
|
||||||
async findActiveByStoreAndPhone(
|
async findActiveByPhone(
|
||||||
storeId: number,
|
|
||||||
phone: string,
|
phone: string,
|
||||||
excludeEmployeeId?: number,
|
excludeEmployeeId?: number,
|
||||||
db: DbExecutor = pool
|
db: DbExecutor = pool
|
||||||
): Promise<Employee | null> {
|
): Promise<Employee | null> {
|
||||||
const params: SqlParam[] = [storeId, phone];
|
const params: SqlParam[] = [phone];
|
||||||
let excludeSql = "";
|
let excludeSql = "";
|
||||||
|
|
||||||
if (excludeEmployeeId !== undefined) {
|
if (excludeEmployeeId !== undefined) {
|
||||||
@@ -154,8 +153,7 @@ export const employeeRepository = {
|
|||||||
SELECT e.*, s.name AS store_name
|
SELECT e.*, s.name AS store_name
|
||||||
FROM employees e
|
FROM employees e
|
||||||
INNER JOIN stores s ON s.id = e.store_id
|
INNER JOIN stores s ON s.id = e.store_id
|
||||||
WHERE e.store_id = ?
|
WHERE e.phone = ?
|
||||||
AND e.phone = ?
|
|
||||||
AND e.deleted_at IS NULL
|
AND e.deleted_at IS NULL
|
||||||
${excludeSql}
|
${excludeSql}
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
|
|||||||
@@ -48,11 +48,11 @@ export const employeeService = {
|
|||||||
await assertStoreExists(input.storeId);
|
await assertStoreExists(input.storeId);
|
||||||
const roleIds = await assertRolesExist(input.roleIds);
|
const roleIds = await assertRolesExist(input.roleIds);
|
||||||
|
|
||||||
// 手机号只要求在同一个未删除门店内唯一;不同门店可以存在同一手机号。
|
// 员工手机号就是登录账号,因此未删除员工范围内必须全局唯一。
|
||||||
const duplicatedEmployee = await employeeRepository.findActiveByStoreAndPhone(input.storeId, input.phone);
|
const duplicatedEmployee = await employeeRepository.findActiveByPhone(input.phone);
|
||||||
|
|
||||||
if (duplicatedEmployee) {
|
if (duplicatedEmployee) {
|
||||||
throw conflict("同一门店下手机号已存在");
|
throw conflict("员工手机号已存在");
|
||||||
}
|
}
|
||||||
|
|
||||||
// 创建员工和绑定角色必须放在一个事务里,避免员工创建成功但角色绑定失败。
|
// 创建员工和绑定角色必须放在一个事务里,避免员工创建成功但角色绑定失败。
|
||||||
@@ -72,15 +72,14 @@ export const employeeService = {
|
|||||||
await assertStoreExists(input.storeId);
|
await assertStoreExists(input.storeId);
|
||||||
}
|
}
|
||||||
|
|
||||||
const nextStoreId = input.storeId ?? currentEmployee.storeId;
|
|
||||||
const nextPhone = input.phone ?? currentEmployee.phone;
|
const nextPhone = input.phone ?? currentEmployee.phone;
|
||||||
|
|
||||||
// 只有门店或手机号发生变化时才需要重新检查唯一性。
|
// 只有手机号发生变化时才需要重新检查全局唯一性。
|
||||||
if (input.storeId !== undefined || input.phone !== undefined) {
|
if (input.phone !== undefined) {
|
||||||
const duplicatedEmployee = await employeeRepository.findActiveByStoreAndPhone(nextStoreId, nextPhone, id);
|
const duplicatedEmployee = await employeeRepository.findActiveByPhone(nextPhone, id);
|
||||||
|
|
||||||
if (duplicatedEmployee) {
|
if (duplicatedEmployee) {
|
||||||
throw conflict("同一门店下手机号已存在");
|
throw conflict("员工手机号已存在");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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<void> {
|
||||||
|
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()),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<string, PermissionCode[]> = {
|
||||||
|
admin: [
|
||||||
|
PERMISSIONS.STORE_VIEW,
|
||||||
|
PERMISSIONS.STORE_MANAGE,
|
||||||
|
PERMISSIONS.ROLE_VIEW,
|
||||||
|
PERMISSIONS.EMPLOYEE_VIEW_ALL,
|
||||||
|
PERMISSIONS.EMPLOYEE_MANAGE,
|
||||||
|
PERMISSIONS.PERMISSION_VIEW,
|
||||||
|
],
|
||||||
|
store_manager: [PERMISSIONS.EMPLOYEE_VIEW_STORE],
|
||||||
|
};
|
||||||
|
|
||||||
|
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<PermissionCode>();
|
||||||
|
|
||||||
|
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"] }],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user