feat: 设计菜单权限和员工端登录
This commit is contained in:
@@ -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