feat: 增加登录鉴权和固定角色权限

This commit is contained in:
湛兮
2026-05-26 12:14:33 +08:00
parent 643244abab
commit 55b99b5307
21 changed files with 957 additions and 250 deletions
+24 -4
View File
@@ -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
View File
@@ -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);
+27
View File
@@ -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);
});
}
+29
View File
@@ -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("当前账号没有后台管理权限");
}
}
+224
View File
@@ -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,
};
}
+6
View File
@@ -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),
});
+141
View File
@@ -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("登录已失效,请重新登录");
},
};
+71
View File
@@ -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;
}
}
+76
View File
@@ -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)
);
}
+1 -24
View File
@@ -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 是服务端固定权限集合,只允许查询,不提供新增、修改、删除接口。
}
+8 -93
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;
@@ -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;
},
};
-21
View File
@@ -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 -44
View File
@@ -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);
},
// 角色是服务端固定权限集合,只允许查询,不允许通过接口变更。
};
+31 -12
View File
@@ -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;
}
+21 -5
View File
@@ -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;
+4
View File
@@ -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);
}