feat: 增加登录鉴权和固定角色权限
This commit is contained in:
+24
-4
@@ -1,6 +1,10 @@
|
||||
import Fastify from "fastify";
|
||||
import fastifyJwt from "@fastify/jwt";
|
||||
import { ZodError } from "zod";
|
||||
import { env } from "./config/env";
|
||||
import { pingDatabase } from "./db/pool";
|
||||
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 { HttpError } from "./shared/http-error";
|
||||
@@ -31,6 +35,14 @@ export function createApp() {
|
||||
},
|
||||
);
|
||||
|
||||
// 注册 JWT 能力。登录接口负责签发 token,受保护接口通过 authGuard 校验 token。
|
||||
app.register(fastifyJwt, {
|
||||
secret: env.JWT_SECRET,
|
||||
sign: {
|
||||
expiresIn: env.JWT_EXPIRES_IN,
|
||||
},
|
||||
});
|
||||
|
||||
// 健康检查接口,供负载均衡器和监控系统使用。
|
||||
app.get("/health", async () => {
|
||||
await pingDatabase();
|
||||
@@ -42,10 +54,18 @@ export function createApp() {
|
||||
});
|
||||
});
|
||||
|
||||
// 注册业务路由,所有接口都以 /api 开头,便于区分静态资源和 API 请求。
|
||||
app.register(catalogRoutes, { prefix: "/api" });
|
||||
// 员工管理相关接口,包含员工的增删改查和状态更新等功能。
|
||||
app.register(employeeRoutes, { prefix: "/api" });
|
||||
// 登录接口不需要 token;/auth/me 在 authRoutes 内部单独加了 authGuard。
|
||||
app.register(authRoutes, { prefix: "/api" });
|
||||
|
||||
// 业务管理接口统一要求后台权限:超级管理员或拥有 admin 角色的员工。
|
||||
app.register(
|
||||
async (protectedApp) => {
|
||||
protectedApp.addHook("preHandler", managementGuard);
|
||||
protectedApp.register(catalogRoutes);
|
||||
protectedApp.register(employeeRoutes);
|
||||
},
|
||||
{ prefix: "/api" },
|
||||
);
|
||||
|
||||
// 全局错误处理器,捕获所有未处理的异常,并根据错误类型返回合适的 HTTP 状态码和错误信息。
|
||||
app.setErrorHandler((error, request, reply) => {
|
||||
|
||||
+7
-2
@@ -4,7 +4,9 @@ import { z } from "zod";
|
||||
// 所有运行时配置都从环境变量读取,并在启动时一次性校验。
|
||||
// 这样数据库密码、端口等配置错误会在服务启动阶段暴露,而不是等到请求进来才失败。
|
||||
const envSchema = z.object({
|
||||
NODE_ENV: z.enum(["development", "test", "production"]).default("development"),
|
||||
NODE_ENV: z
|
||||
.enum(["local", "development", "test", "production"])
|
||||
.default("development"),
|
||||
PORT: z.coerce.number().int().positive().default(3000),
|
||||
|
||||
DB_HOST: z.string().min(1),
|
||||
@@ -12,7 +14,10 @@ const envSchema = z.object({
|
||||
DB_USER: z.string().min(1),
|
||||
DB_PASSWORD: z.string().min(1),
|
||||
DB_NAME: z.string().min(1),
|
||||
DB_CONNECTION_LIMIT: z.coerce.number().int().positive().default(10)
|
||||
DB_CONNECTION_LIMIT: z.coerce.number().int().positive().default(10),
|
||||
|
||||
JWT_SECRET: z.string().min(32),
|
||||
JWT_EXPIRES_IN: z.string().min(1).default("2h")
|
||||
});
|
||||
|
||||
const result = envSchema.safeParse(process.env);
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { env } from "../../config/env";
|
||||
import { ok } from "../../shared/response";
|
||||
import { authGuard } from "./auth.guard";
|
||||
import { loginBodySchema } from "./auth.schema";
|
||||
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 token = app.jwt.sign(payload);
|
||||
|
||||
return ok({
|
||||
token,
|
||||
tokenType: "Bearer",
|
||||
expiresIn: env.JWT_EXPIRES_IN,
|
||||
user,
|
||||
});
|
||||
});
|
||||
|
||||
app.get("/auth/me", { preHandler: authGuard }, async (request) => {
|
||||
const user = await authService.getCurrentUser(request.user);
|
||||
|
||||
return ok(user);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { FastifyRequest } from "fastify";
|
||||
import { forbidden, unauthorized } from "../../shared/http-error";
|
||||
import { authService } from "./auth.service";
|
||||
|
||||
// 统一 JWT 鉴权入口。后续新增需要登录的路由,复用这个 guard 即可。
|
||||
export async function authGuard(request: FastifyRequest): Promise<void> {
|
||||
const authorization = request.headers.authorization;
|
||||
|
||||
if (!authorization?.startsWith("Bearer ")) {
|
||||
throw unauthorized("请先登录");
|
||||
}
|
||||
|
||||
try {
|
||||
await request.jwtVerify();
|
||||
} catch {
|
||||
throw unauthorized("登录已过期,请重新登录");
|
||||
}
|
||||
}
|
||||
|
||||
// 后台管理系统只允许超级管理员和拥有 admin 角色的员工访问。
|
||||
export async function managementGuard(request: FastifyRequest): Promise<void> {
|
||||
await authGuard(request);
|
||||
|
||||
const user = await authService.getCurrentUser(request.user);
|
||||
|
||||
if (!user.canManage) {
|
||||
throw forbidden("当前账号没有后台管理权限");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
import type { RowDataPacket } from "mysql2/promise";
|
||||
import { pool } from "../../db/pool";
|
||||
import type {
|
||||
EmployeeLoginAccount,
|
||||
SuperAdminStatus,
|
||||
SuperAdminWithPassword,
|
||||
} from "./auth.types";
|
||||
|
||||
interface SuperAdminRow extends RowDataPacket {
|
||||
id: number;
|
||||
username: string;
|
||||
password_hash: string;
|
||||
display_name: string;
|
||||
status: SuperAdminStatus;
|
||||
last_login_at: Date | null;
|
||||
created_at: Date;
|
||||
updated_at: Date;
|
||||
}
|
||||
|
||||
interface EmployeeLoginRow extends RowDataPacket {
|
||||
id: number;
|
||||
username: string;
|
||||
password_hash: string;
|
||||
display_name: string;
|
||||
store_id: number;
|
||||
store_name: string;
|
||||
}
|
||||
|
||||
interface EmployeeRoleRow extends RowDataPacket {
|
||||
employee_id: number;
|
||||
id: number;
|
||||
code: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
function toIso(value: Date | null): string | null {
|
||||
return value ? value.toISOString() : null;
|
||||
}
|
||||
|
||||
function toSuperAdmin(row: SuperAdminRow): SuperAdminWithPassword {
|
||||
return {
|
||||
id: row.id,
|
||||
username: row.username,
|
||||
passwordHash: row.password_hash,
|
||||
displayName: row.display_name,
|
||||
status: row.status,
|
||||
lastLoginAt: toIso(row.last_login_at),
|
||||
createdAt: toIso(row.created_at) ?? "",
|
||||
updatedAt: toIso(row.updated_at) ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
export const authRepository = {
|
||||
async findActiveByUsername(
|
||||
username: string,
|
||||
): Promise<SuperAdminWithPassword | null> {
|
||||
const [rows] = await pool.execute<SuperAdminRow[]>(
|
||||
`
|
||||
SELECT id, username, password_hash, display_name, status, last_login_at, created_at, updated_at
|
||||
FROM super_admins
|
||||
WHERE username = ? AND status = 'ACTIVE'
|
||||
LIMIT 1
|
||||
`,
|
||||
[username],
|
||||
);
|
||||
|
||||
return rows[0] ? toSuperAdmin(rows[0]) : null;
|
||||
},
|
||||
|
||||
async findActiveById(id: number): Promise<SuperAdminWithPassword | null> {
|
||||
const [rows] = await pool.execute<SuperAdminRow[]>(
|
||||
`
|
||||
SELECT id, username, password_hash, display_name, status, last_login_at, created_at, updated_at
|
||||
FROM super_admins
|
||||
WHERE id = ? AND status = 'ACTIVE'
|
||||
LIMIT 1
|
||||
`,
|
||||
[id],
|
||||
);
|
||||
|
||||
return rows[0] ? toSuperAdmin(rows[0]) : null;
|
||||
},
|
||||
|
||||
async updateLastLoginAt(id: number): Promise<void> {
|
||||
await pool.execute(
|
||||
`
|
||||
UPDATE super_admins
|
||||
SET last_login_at = CURRENT_TIMESTAMP(3)
|
||||
WHERE id = ?
|
||||
`,
|
||||
[id],
|
||||
);
|
||||
},
|
||||
|
||||
async findActiveEmployeeByPhone(
|
||||
phone: string,
|
||||
): Promise<EmployeeLoginAccount | null> {
|
||||
const [rows] = await pool.execute<EmployeeLoginRow[]>(
|
||||
`
|
||||
SELECT
|
||||
e.id,
|
||||
e.phone AS username,
|
||||
e.password_hash,
|
||||
e.name AS display_name,
|
||||
e.store_id,
|
||||
s.name AS store_name
|
||||
FROM employees e
|
||||
INNER JOIN stores s ON s.id = e.store_id
|
||||
WHERE e.phone = ?
|
||||
AND e.status = 'ACTIVE'
|
||||
AND e.deleted_at IS NULL
|
||||
AND s.status = 'ACTIVE'
|
||||
AND s.deleted_at IS NULL
|
||||
LIMIT 1
|
||||
`,
|
||||
[phone],
|
||||
);
|
||||
|
||||
if (!rows[0]) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const rolesByEmployeeId = await this.findRolesByEmployeeIds([rows[0].id]);
|
||||
return toEmployeeLoginAccount(
|
||||
rows[0],
|
||||
rolesByEmployeeId.get(rows[0].id) ?? [],
|
||||
);
|
||||
},
|
||||
|
||||
async findActiveEmployeeById(
|
||||
id: number,
|
||||
): Promise<EmployeeLoginAccount | null> {
|
||||
const [rows] = await pool.execute<EmployeeLoginRow[]>(
|
||||
`
|
||||
SELECT
|
||||
e.id,
|
||||
e.phone AS username,
|
||||
e.password_hash,
|
||||
e.name AS display_name,
|
||||
e.store_id,
|
||||
s.name AS store_name
|
||||
FROM employees e
|
||||
INNER JOIN stores s ON s.id = e.store_id
|
||||
WHERE e.id = ?
|
||||
AND e.status = 'ACTIVE'
|
||||
AND e.deleted_at IS NULL
|
||||
AND s.status = 'ACTIVE'
|
||||
AND s.deleted_at IS NULL
|
||||
LIMIT 1
|
||||
`,
|
||||
[id],
|
||||
);
|
||||
|
||||
if (!rows[0]) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const rolesByEmployeeId = await this.findRolesByEmployeeIds([rows[0].id]);
|
||||
return toEmployeeLoginAccount(
|
||||
rows[0],
|
||||
rolesByEmployeeId.get(rows[0].id) ?? [],
|
||||
);
|
||||
},
|
||||
|
||||
async updateEmployeeLastLoginAt(id: number): Promise<void> {
|
||||
await pool.execute(
|
||||
`
|
||||
UPDATE employees
|
||||
SET last_login_at = CURRENT_TIMESTAMP(3)
|
||||
WHERE id = ?
|
||||
`,
|
||||
[id],
|
||||
);
|
||||
},
|
||||
|
||||
async findRolesByEmployeeIds(
|
||||
employeeIds: number[],
|
||||
): Promise<Map<number, EmployeeLoginAccount["roles"]>> {
|
||||
const result = new Map<number, EmployeeLoginAccount["roles"]>();
|
||||
|
||||
if (employeeIds.length === 0) {
|
||||
return result;
|
||||
}
|
||||
|
||||
const placeholders = employeeIds.map(() => "?").join(", ");
|
||||
const [rows] = await pool.execute<EmployeeRoleRow[]>(
|
||||
`
|
||||
SELECT er.employee_id, r.id, r.code, r.name
|
||||
FROM employee_roles er
|
||||
INNER JOIN roles r ON r.id = er.role_id
|
||||
WHERE er.employee_id IN (${placeholders})
|
||||
ORDER BY r.id ASC
|
||||
`,
|
||||
employeeIds,
|
||||
);
|
||||
|
||||
for (const row of rows) {
|
||||
const roles = result.get(row.employee_id) ?? [];
|
||||
roles.push({
|
||||
id: row.id,
|
||||
code: row.code,
|
||||
name: row.name,
|
||||
});
|
||||
result.set(row.employee_id, roles);
|
||||
}
|
||||
|
||||
return result;
|
||||
},
|
||||
};
|
||||
|
||||
function toEmployeeLoginAccount(
|
||||
row: EmployeeLoginRow,
|
||||
roles: EmployeeLoginAccount["roles"],
|
||||
): EmployeeLoginAccount {
|
||||
return {
|
||||
id: row.id,
|
||||
username: row.username,
|
||||
passwordHash: row.password_hash,
|
||||
displayName: row.display_name,
|
||||
storeId: row.store_id,
|
||||
storeName: row.store_name,
|
||||
roles,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const loginBodySchema = z.object({
|
||||
username: z.string().trim().min(1).max(50),
|
||||
password: z.string().min(8).max(128),
|
||||
});
|
||||
@@ -0,0 +1,141 @@
|
||||
import { unauthorized } from "../../shared/http-error";
|
||||
import { authRepository } from "./auth.repository";
|
||||
import type {
|
||||
AuthJwtPayload,
|
||||
AuthUser,
|
||||
EmployeeLoginAccount,
|
||||
LoginInput,
|
||||
SuperAdmin,
|
||||
} from "./auth.types";
|
||||
import { verifyPassword } from "./password";
|
||||
|
||||
function toAuthUser(admin: SuperAdmin): AuthUser {
|
||||
return {
|
||||
id: admin.id,
|
||||
username: admin.username,
|
||||
displayName: admin.displayName,
|
||||
accountType: "SUPER_ADMIN",
|
||||
roles: [
|
||||
{
|
||||
id: 0,
|
||||
code: "super_admin",
|
||||
name: "超级管理员",
|
||||
},
|
||||
],
|
||||
permissions: ["*"],
|
||||
canManage: true,
|
||||
};
|
||||
}
|
||||
|
||||
function toEmployeeAuthUser(employee: EmployeeLoginAccount): AuthUser {
|
||||
const canManage = employee.roles.some((role) => role.code === "admin");
|
||||
|
||||
return {
|
||||
id: employee.id,
|
||||
username: employee.username,
|
||||
displayName: employee.displayName,
|
||||
accountType: "EMPLOYEE",
|
||||
storeId: employee.storeId,
|
||||
storeName: employee.storeName,
|
||||
roles: employee.roles,
|
||||
permissions: canManage ? ["*"] : [],
|
||||
canManage,
|
||||
};
|
||||
}
|
||||
|
||||
function toJwtPayload(user: AuthUser): AuthJwtPayload {
|
||||
const subjectPrefix =
|
||||
user.accountType === "SUPER_ADMIN" ? "super_admin" : "employee";
|
||||
|
||||
return {
|
||||
sub: `${subjectPrefix}:${user.id}`,
|
||||
accountType: user.accountType,
|
||||
adminId: user.accountType === "SUPER_ADMIN" ? user.id : undefined,
|
||||
employeeId: user.accountType === "EMPLOYEE" ? user.id : undefined,
|
||||
username: user.username,
|
||||
roleCodes: user.roles.map((role) => role.code),
|
||||
permissions: user.permissions,
|
||||
canManage: user.canManage,
|
||||
};
|
||||
}
|
||||
|
||||
export const authService = {
|
||||
async login(input: LoginInput): Promise<{
|
||||
user: AuthUser;
|
||||
payload: AuthJwtPayload;
|
||||
}> {
|
||||
const admin = await authRepository.findActiveByUsername(input.username);
|
||||
|
||||
if (admin) {
|
||||
const passwordMatched = await verifyPassword(
|
||||
input.password,
|
||||
admin.passwordHash,
|
||||
);
|
||||
|
||||
if (!passwordMatched) {
|
||||
throw unauthorized("用户名或密码错误");
|
||||
}
|
||||
|
||||
await authRepository.updateLastLoginAt(admin.id);
|
||||
|
||||
const user = toAuthUser(admin);
|
||||
|
||||
return {
|
||||
user,
|
||||
payload: toJwtPayload(user),
|
||||
};
|
||||
}
|
||||
|
||||
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),
|
||||
};
|
||||
},
|
||||
|
||||
async getCurrentUser(payload: AuthJwtPayload): Promise<AuthUser> {
|
||||
if (payload.accountType === "SUPER_ADMIN" && payload.adminId) {
|
||||
const admin = await authRepository.findActiveById(payload.adminId);
|
||||
|
||||
if (!admin) {
|
||||
throw unauthorized("登录已失效,请重新登录");
|
||||
}
|
||||
|
||||
return toAuthUser(admin);
|
||||
}
|
||||
|
||||
if (payload.accountType === "EMPLOYEE" && payload.employeeId) {
|
||||
const employee = await authRepository.findActiveEmployeeById(
|
||||
payload.employeeId,
|
||||
);
|
||||
|
||||
if (!employee) {
|
||||
throw unauthorized("登录已失效,请重新登录");
|
||||
}
|
||||
|
||||
return toEmployeeAuthUser(employee);
|
||||
}
|
||||
|
||||
throw unauthorized("登录已失效,请重新登录");
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,71 @@
|
||||
export const SUPER_ADMIN_STATUS = ["ACTIVE", "INACTIVE"] as const;
|
||||
|
||||
export type SuperAdminStatus = (typeof SUPER_ADMIN_STATUS)[number];
|
||||
export type AuthAccountType = "SUPER_ADMIN" | "EMPLOYEE";
|
||||
|
||||
export interface LoginInput {
|
||||
username: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export interface SuperAdmin {
|
||||
id: number;
|
||||
username: string;
|
||||
displayName: string;
|
||||
status: SuperAdminStatus;
|
||||
lastLoginAt: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface SuperAdminWithPassword extends SuperAdmin {
|
||||
passwordHash: string;
|
||||
}
|
||||
|
||||
export interface EmployeeLoginAccount {
|
||||
id: number;
|
||||
username: string;
|
||||
passwordHash: string;
|
||||
displayName: string;
|
||||
storeId: number;
|
||||
storeName: string;
|
||||
roles: Array<{
|
||||
id: number;
|
||||
code: string;
|
||||
name: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface AuthUser {
|
||||
id: number;
|
||||
username: string;
|
||||
displayName: string;
|
||||
accountType: AuthAccountType;
|
||||
storeId?: number;
|
||||
storeName?: string;
|
||||
roles: Array<{
|
||||
id: number;
|
||||
code: string;
|
||||
name: string;
|
||||
}>;
|
||||
permissions: string[];
|
||||
canManage: boolean;
|
||||
}
|
||||
|
||||
export interface AuthJwtPayload {
|
||||
sub: string;
|
||||
accountType: AuthAccountType;
|
||||
adminId?: number;
|
||||
employeeId?: number;
|
||||
username: string;
|
||||
roleCodes: string[];
|
||||
permissions: string[];
|
||||
canManage: boolean;
|
||||
}
|
||||
|
||||
declare module "@fastify/jwt" {
|
||||
interface FastifyJWT {
|
||||
payload: AuthJwtPayload;
|
||||
user: AuthJwtPayload;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { pbkdf2, randomBytes, timingSafeEqual } from "node:crypto";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
const pbkdf2Async = promisify(pbkdf2);
|
||||
const HASH_SCHEME = "pbkdf2";
|
||||
const HASH_ALGORITHM = "sha256";
|
||||
const ITERATIONS = 310000;
|
||||
const KEY_LENGTH = 32;
|
||||
|
||||
function toBase64Url(buffer: Buffer): string {
|
||||
return buffer.toString("base64url");
|
||||
}
|
||||
|
||||
function fromBase64Url(value: string): Buffer {
|
||||
return Buffer.from(value, "base64url");
|
||||
}
|
||||
|
||||
// 密码哈希格式:pbkdf2$sha256$310000$salt$hash。
|
||||
// 把算法、迭代次数和 salt 都放进字符串,后续升级算法时也能兼容旧密码。
|
||||
export async function hashPassword(password: string): Promise<string> {
|
||||
const salt = randomBytes(16);
|
||||
const derivedKey = (await pbkdf2Async(
|
||||
password,
|
||||
salt,
|
||||
ITERATIONS,
|
||||
KEY_LENGTH,
|
||||
HASH_ALGORITHM,
|
||||
)) as Buffer;
|
||||
|
||||
return [
|
||||
HASH_SCHEME,
|
||||
HASH_ALGORITHM,
|
||||
String(ITERATIONS),
|
||||
toBase64Url(salt),
|
||||
toBase64Url(derivedKey),
|
||||
].join("$");
|
||||
}
|
||||
|
||||
export async function verifyPassword(
|
||||
password: string,
|
||||
storedHash: string,
|
||||
): Promise<boolean> {
|
||||
const [scheme, algorithm, iterationsText, saltText, hashText] =
|
||||
storedHash.split("$");
|
||||
|
||||
if (
|
||||
scheme !== HASH_SCHEME ||
|
||||
algorithm !== HASH_ALGORITHM ||
|
||||
!iterationsText ||
|
||||
!saltText ||
|
||||
!hashText
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const iterations = Number(iterationsText);
|
||||
|
||||
if (!Number.isInteger(iterations) || iterations <= 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const salt = fromBase64Url(saltText);
|
||||
const expectedHash = fromBase64Url(hashText);
|
||||
const derivedKey = (await pbkdf2Async(
|
||||
password,
|
||||
salt,
|
||||
iterations,
|
||||
expectedHash.length,
|
||||
algorithm,
|
||||
)) as Buffer;
|
||||
|
||||
return (
|
||||
expectedHash.length === derivedKey.length &&
|
||||
timingSafeEqual(expectedHash, derivedKey)
|
||||
);
|
||||
}
|
||||
@@ -2,11 +2,9 @@ import type { FastifyInstance } from "fastify";
|
||||
import { created, ok } from "../../shared/response";
|
||||
import { catalogService } from "./catalog.service";
|
||||
import {
|
||||
createRoleBodySchema,
|
||||
createStoreBodySchema,
|
||||
idParamSchema,
|
||||
listStoresQuerySchema,
|
||||
updateRoleBodySchema,
|
||||
updateStoreBodySchema,
|
||||
} from "./catalog.schema";
|
||||
|
||||
@@ -66,26 +64,5 @@ export async function catalogRoutes(app: FastifyInstance): Promise<void> {
|
||||
return ok(role);
|
||||
});
|
||||
|
||||
app.post("/roles", 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", 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", async (request, reply) => {
|
||||
const params = idParamSchema.parse(request.params);
|
||||
await catalogService.deleteRole(params.id);
|
||||
|
||||
// 角色删除成功后同样不返回 body。
|
||||
return reply.code(204).send();
|
||||
});
|
||||
// roles 是服务端固定权限集合,只允许查询,不提供新增、修改、删除接口。
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -236,8 +236,10 @@ export const catalogRepository = {
|
||||
`
|
||||
SELECT id, code, name, description, created_at, updated_at
|
||||
FROM roles
|
||||
WHERE code IN (${fixedRoleCodePlaceholders})
|
||||
ORDER BY id ASC
|
||||
`,
|
||||
[...FIXED_ROLE_CODES],
|
||||
);
|
||||
|
||||
return rows.map(toRole);
|
||||
@@ -248,8 +250,10 @@ export const catalogRepository = {
|
||||
`
|
||||
SELECT id, code, name, description, created_at, updated_at
|
||||
FROM roles
|
||||
WHERE code IN (${fixedRoleCodePlaceholders})
|
||||
ORDER BY id ASC
|
||||
`,
|
||||
[...FIXED_ROLE_CODES],
|
||||
);
|
||||
|
||||
return rows.map(toRoleOption);
|
||||
@@ -260,102 +264,13 @@ export const catalogRepository = {
|
||||
`
|
||||
SELECT id, code, name, description, created_at, updated_at
|
||||
FROM roles
|
||||
WHERE id = ?
|
||||
WHERE id = ? AND code IN (${fixedRoleCodePlaceholders})
|
||||
LIMIT 1
|
||||
`,
|
||||
[id],
|
||||
[id, ...FIXED_ROLE_CODES],
|
||||
);
|
||||
|
||||
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, 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)
|
||||
VALUES (?, ?, ?)
|
||||
`,
|
||||
[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,24 +58,3 @@ 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,14 +1,12 @@
|
||||
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";
|
||||
|
||||
@@ -106,46 +104,5 @@ 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> {
|
||||
await this.getRoleById(id);
|
||||
|
||||
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> {
|
||||
await this.getRoleById(id);
|
||||
|
||||
// 角色被员工使用时不允许删除,避免员工详情里出现失效角色。
|
||||
const employeeCount = await catalogRepository.countEmployeesByRole(id);
|
||||
|
||||
if (employeeCount > 0) {
|
||||
throw conflict("角色已绑定员工,不能删除");
|
||||
}
|
||||
|
||||
await catalogRepository.deleteRole(id);
|
||||
},
|
||||
// 角色是服务端固定权限集合,只允许查询,不允许通过接口变更。
|
||||
};
|
||||
|
||||
@@ -1,7 +1,38 @@
|
||||
export const STORE_STATUS = ["ACTIVE", "INACTIVE"] as const;
|
||||
|
||||
export const FIXED_ROLE_DEFINITIONS = [
|
||||
{
|
||||
code: "store_manager",
|
||||
name: "店长",
|
||||
description: "负责门店日常管理、排班和权限审批",
|
||||
},
|
||||
{
|
||||
code: "cashier",
|
||||
name: "收银员",
|
||||
description: "负责收银、订单核对和基础会员操作",
|
||||
},
|
||||
{
|
||||
code: "kitchen",
|
||||
name: "后厨",
|
||||
description: "负责出品、备货和库存相关操作",
|
||||
},
|
||||
{
|
||||
code: "part_time",
|
||||
name: "兼职",
|
||||
description: "临时员工,默认只开放基础操作",
|
||||
},
|
||||
{
|
||||
code: "admin",
|
||||
name: "管理员",
|
||||
description: "系统管理角色,仅授予可信人员",
|
||||
},
|
||||
] as const;
|
||||
|
||||
export const FIXED_ROLE_CODES = FIXED_ROLE_DEFINITIONS.map((role) => role.code);
|
||||
|
||||
// 从常量数组推导联合类型,避免状态枚举在 schema 和类型定义里写两遍。
|
||||
export type StoreStatus = (typeof STORE_STATUS)[number];
|
||||
export type FixedRoleCode = (typeof FIXED_ROLE_DEFINITIONS)[number]["code"];
|
||||
|
||||
// Option 类型用于下拉框等轻量接口,只返回页面选择所需字段。
|
||||
export interface StoreOption {
|
||||
@@ -47,15 +78,3 @@ 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,6 @@
|
||||
import type { PoolConnection, ResultSetHeader, RowDataPacket } from "mysql2/promise";
|
||||
import { pool } from "../../db/pool";
|
||||
import { FIXED_ROLE_CODES } from "../catalog/catalog.types";
|
||||
import type {
|
||||
CreateEmployeeInput,
|
||||
Employee,
|
||||
@@ -11,6 +12,9 @@ import type {
|
||||
|
||||
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";
|
||||
|
||||
interface EmployeeRow extends RowDataPacket {
|
||||
id: number;
|
||||
@@ -118,8 +122,13 @@ export const employeeRepository = {
|
||||
// IN 条件的占位符数量必须和 roleIds 长度一致,仍然使用参数化查询避免 SQL 注入。
|
||||
const placeholders = roleIds.map(() => "?").join(", ");
|
||||
const [rows] = await db.execute<(RowDataPacket & { id: number })[]>(
|
||||
`SELECT id FROM roles WHERE id IN (${placeholders})`,
|
||||
roleIds
|
||||
`
|
||||
SELECT id
|
||||
FROM roles
|
||||
WHERE id IN (${placeholders})
|
||||
AND code IN (${fixedRoleCodePlaceholders})
|
||||
`,
|
||||
[...roleIds, ...FIXED_ROLE_CODES]
|
||||
);
|
||||
|
||||
return rows.map((row) => row.id);
|
||||
@@ -217,10 +226,17 @@ export const employeeRepository = {
|
||||
async create(input: CreateEmployeeInput, db: DbExecutor = pool): Promise<number> {
|
||||
const [result] = await db.execute<ResultSetHeader>(
|
||||
`
|
||||
INSERT INTO employees (store_id, name, phone, status, remark)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
INSERT INTO employees (store_id, name, phone, password_hash, status, remark)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`,
|
||||
[input.storeId, input.name, input.phone, input.status, input.remark ?? null]
|
||||
[
|
||||
input.storeId,
|
||||
input.name,
|
||||
input.phone,
|
||||
DEFAULT_EMPLOYEE_PASSWORD_HASH,
|
||||
input.status,
|
||||
input.remark ?? null
|
||||
]
|
||||
);
|
||||
|
||||
return result.insertId;
|
||||
|
||||
@@ -32,3 +32,7 @@ export function internalServerError(message: string): HttpError {
|
||||
export function unauthorized(message: string): HttpError {
|
||||
return new HttpError(401, "UNAUTHORIZED", message);
|
||||
}
|
||||
|
||||
export function forbidden(message: string): HttpError {
|
||||
return new HttpError(403, "FORBIDDEN", message);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user