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
+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"] }],
},
];
}