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