feat: 设计菜单权限和员工端登录

This commit is contained in:
湛兮
2026-05-26 12:30:38 +08:00
parent 55b99b5307
commit aa65cb0928
17 changed files with 708 additions and 64 deletions
+74 -19
View File
@@ -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);
+2
View File
@@ -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(
+27 -1
View File
@@ -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({
+17 -1
View File
@@ -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("当前账号没有权限执行该操作");
}
};
}
+51 -6
View File
@@ -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
View File
@@ -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;
+32 -8
View File
@@ -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();
});
}
+99 -11
View File
@@ -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;
},
};
+21
View File
@@ -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: "至少需要提交一个要修改的字段",
});
+51 -1
View File
@@ -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);
},
};
+13
View File
@@ -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;
}
+79 -3
View File
@@ -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);
+4 -6
View File
@@ -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
+7 -8
View File
@@ -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"] }],
},
];
}