feat: 完善员工门店软删除与密码管理
This commit is contained in:
@@ -22,8 +22,10 @@ interface EmployeeLoginRow extends RowDataPacket {
|
||||
username: string;
|
||||
password_hash: string;
|
||||
display_name: string;
|
||||
status: "ACTIVE" | "INACTIVE";
|
||||
store_id: number;
|
||||
store_name: string;
|
||||
store_status: "ACTIVE" | "INACTIVE";
|
||||
}
|
||||
|
||||
interface EmployeeRoleRow extends RowDataPacket {
|
||||
@@ -67,6 +69,22 @@ export const authRepository = {
|
||||
return rows[0] ? toSuperAdmin(rows[0]) : null;
|
||||
},
|
||||
|
||||
async findByUsername(
|
||||
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 = ?
|
||||
LIMIT 1
|
||||
`,
|
||||
[username],
|
||||
);
|
||||
|
||||
return rows[0] ? toSuperAdmin(rows[0]) : null;
|
||||
},
|
||||
|
||||
async findActiveById(id: number): Promise<SuperAdminWithPassword | null> {
|
||||
const [rows] = await pool.execute<SuperAdminRow[]>(
|
||||
`
|
||||
@@ -102,8 +120,10 @@ export const authRepository = {
|
||||
e.phone AS username,
|
||||
e.password_hash,
|
||||
e.name AS display_name,
|
||||
e.status,
|
||||
e.store_id,
|
||||
s.name AS store_name
|
||||
s.name AS store_name,
|
||||
s.status AS store_status
|
||||
FROM employees e
|
||||
INNER JOIN stores s ON s.id = e.store_id
|
||||
WHERE e.phone = ?
|
||||
@@ -127,6 +147,41 @@ export const authRepository = {
|
||||
);
|
||||
},
|
||||
|
||||
async findEmployeeByPhoneForLogin(
|
||||
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.status,
|
||||
e.store_id,
|
||||
s.name AS store_name,
|
||||
s.status AS store_status
|
||||
FROM employees e
|
||||
INNER JOIN stores s ON s.id = e.store_id
|
||||
WHERE e.phone = ?
|
||||
AND e.deleted_at IS NULL
|
||||
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> {
|
||||
@@ -137,8 +192,10 @@ export const authRepository = {
|
||||
e.phone AS username,
|
||||
e.password_hash,
|
||||
e.name AS display_name,
|
||||
e.status,
|
||||
e.store_id,
|
||||
s.name AS store_name
|
||||
s.name AS store_name,
|
||||
s.status AS store_status
|
||||
FROM employees e
|
||||
INNER JOIN stores s ON s.id = e.store_id
|
||||
WHERE e.id = ?
|
||||
@@ -189,6 +246,8 @@ export const authRepository = {
|
||||
FROM employee_roles er
|
||||
INNER JOIN roles r ON r.id = er.role_id
|
||||
WHERE er.employee_id IN (${placeholders})
|
||||
AND er.deleted_at IS NULL
|
||||
AND r.deleted_at IS NULL
|
||||
ORDER BY r.id ASC
|
||||
`,
|
||||
employeeIds,
|
||||
@@ -217,8 +276,10 @@ function toEmployeeLoginAccount(
|
||||
username: row.username,
|
||||
passwordHash: row.password_hash,
|
||||
displayName: row.display_name,
|
||||
status: row.status,
|
||||
storeId: row.store_id,
|
||||
storeName: row.store_name,
|
||||
storeStatus: row.store_status,
|
||||
roles,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -83,7 +83,7 @@ export const authService = {
|
||||
user: AuthUser;
|
||||
payload: AuthJwtPayload;
|
||||
}> {
|
||||
const admin = await authRepository.findActiveByUsername(input.username);
|
||||
const admin = await authRepository.findByUsername(input.username);
|
||||
|
||||
if (admin) {
|
||||
const passwordMatched = await verifyPassword(
|
||||
@@ -95,6 +95,10 @@ export const authService = {
|
||||
throw unauthorized("用户名或密码错误");
|
||||
}
|
||||
|
||||
if (admin.status === "INACTIVE") {
|
||||
throw unauthorized("账号已被禁用");
|
||||
}
|
||||
|
||||
await authRepository.updateLastLoginAt(admin.id);
|
||||
|
||||
const user = await toAuthUser(admin);
|
||||
@@ -105,7 +109,7 @@ export const authService = {
|
||||
};
|
||||
}
|
||||
|
||||
const employee = await authRepository.findActiveEmployeeByPhone(
|
||||
const employee = await authRepository.findEmployeeByPhoneForLogin(
|
||||
input.username,
|
||||
);
|
||||
|
||||
@@ -122,6 +126,14 @@ export const authService = {
|
||||
throw unauthorized("用户名或密码错误");
|
||||
}
|
||||
|
||||
if (employee.status === "INACTIVE") {
|
||||
throw unauthorized("账号已被禁用");
|
||||
}
|
||||
|
||||
if (employee.storeStatus === "INACTIVE") {
|
||||
throw unauthorized("所属门店已被禁用");
|
||||
}
|
||||
|
||||
await authRepository.updateEmployeeLastLoginAt(employee.id);
|
||||
|
||||
const user = await toEmployeeAuthUser(employee);
|
||||
@@ -140,7 +152,7 @@ export const authService = {
|
||||
user: AuthUser;
|
||||
payload: AuthJwtPayload;
|
||||
}> {
|
||||
const employee = await authRepository.findActiveEmployeeByPhone(
|
||||
const employee = await authRepository.findEmployeeByPhoneForLogin(
|
||||
input.username,
|
||||
);
|
||||
|
||||
@@ -157,6 +169,14 @@ export const authService = {
|
||||
throw unauthorized("用户名或密码错误");
|
||||
}
|
||||
|
||||
if (employee.status === "INACTIVE") {
|
||||
throw unauthorized("账号已被禁用");
|
||||
}
|
||||
|
||||
if (employee.storeStatus === "INACTIVE") {
|
||||
throw unauthorized("所属门店已被禁用");
|
||||
}
|
||||
|
||||
await authRepository.updateEmployeeLastLoginAt(employee.id);
|
||||
|
||||
const user = await toEmployeeAuthUser(employee);
|
||||
|
||||
@@ -28,8 +28,10 @@ export interface EmployeeLoginAccount {
|
||||
username: string;
|
||||
passwordHash: string;
|
||||
displayName: string;
|
||||
status: "ACTIVE" | "INACTIVE";
|
||||
storeId: number;
|
||||
storeName: string;
|
||||
storeStatus: "ACTIVE" | "INACTIVE";
|
||||
roles: Array<{
|
||||
id: number;
|
||||
code: string;
|
||||
|
||||
@@ -6,6 +6,9 @@ const HASH_SCHEME = "pbkdf2";
|
||||
const HASH_ALGORITHM = "sha256";
|
||||
const ITERATIONS = 310000;
|
||||
const KEY_LENGTH = 32;
|
||||
export const INITIAL_EMPLOYEE_PASSWORD = "pw111111";
|
||||
export const INITIAL_EMPLOYEE_PASSWORD_HASH =
|
||||
"pbkdf2$sha256$310000$QnXyjrpm0QzcGYLEPdunWg$CfR-CywGl1c_Omh_3PyOWPmo93EcbMY1FEjjd5MDjFo";
|
||||
|
||||
function toBase64Url(buffer: Buffer): string {
|
||||
return buffer.toString("base64url");
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { created, ok, paginated } from "../../shared/response";
|
||||
import { permissionGuard } from "../auth/auth.guard";
|
||||
import { employeeService } from "../employees/employee.service";
|
||||
import { PERMISSIONS } from "../permissions/permission.policy";
|
||||
import { catalogService } from "./catalog.service";
|
||||
import {
|
||||
@@ -9,6 +10,7 @@ import {
|
||||
idParamSchema,
|
||||
listRolesQuerySchema,
|
||||
listStoresQuerySchema,
|
||||
storeEmployeeParamSchema,
|
||||
updateRoleBodySchema,
|
||||
updateStoreBodySchema,
|
||||
} from "./catalog.schema";
|
||||
@@ -58,9 +60,17 @@ export async function catalogRoutes(app: FastifyInstance): Promise<void> {
|
||||
return ok(stores);
|
||||
});
|
||||
|
||||
app.get("/stores/:id/employees", { preHandler: permissionGuard(PERMISSIONS.STORE_VIEW) }, async (request) => {
|
||||
const params = idParamSchema.parse(request.params);
|
||||
await catalogService.getStoreById(params.id);
|
||||
const employees = await employeeService.listByStoreId(params.id);
|
||||
|
||||
return ok(employees);
|
||||
});
|
||||
|
||||
app.get("/stores/:id", { preHandler: permissionGuard(PERMISSIONS.STORE_VIEW) }, async (request) => {
|
||||
const params = idParamSchema.parse(request.params);
|
||||
const store = await catalogService.getStoreById(params.id);
|
||||
const store = await catalogService.getStoreDetailById(params.id);
|
||||
|
||||
return ok(store);
|
||||
});
|
||||
@@ -80,6 +90,17 @@ export async function catalogRoutes(app: FastifyInstance): Promise<void> {
|
||||
return ok(store);
|
||||
});
|
||||
|
||||
app.delete(
|
||||
"/stores/:storeId/employees/:employeeId",
|
||||
{ preHandler: permissionGuard(PERMISSIONS.EMPLOYEE_MANAGE) },
|
||||
async (request, reply) => {
|
||||
const params = storeEmployeeParamSchema.parse(request.params);
|
||||
await employeeService.removeFromStore(params.storeId, params.employeeId);
|
||||
|
||||
return reply.code(204).send();
|
||||
}
|
||||
);
|
||||
|
||||
app.delete("/stores/:id", { preHandler: permissionGuard(PERMISSIONS.STORE_MANAGE) }, async (request, reply) => {
|
||||
const params = idParamSchema.parse(request.params);
|
||||
await catalogService.deleteStore(params.id);
|
||||
|
||||
@@ -136,7 +136,7 @@ function buildRoleListWhere(query: ListRolesQuery): {
|
||||
whereSql: string;
|
||||
params: SqlParam[];
|
||||
} {
|
||||
const where: string[] = [];
|
||||
const where: string[] = ["deleted_at IS NULL"];
|
||||
const params: SqlParam[] = [];
|
||||
|
||||
if (query.isSystem !== undefined) {
|
||||
@@ -154,7 +154,7 @@ function buildRoleListWhere(query: ListRolesQuery): {
|
||||
}
|
||||
|
||||
return {
|
||||
whereSql: where.length > 0 ? where.join(" AND ") : "1 = 1",
|
||||
whereSql: where.join(" AND "),
|
||||
params,
|
||||
};
|
||||
}
|
||||
@@ -342,6 +342,7 @@ export const catalogRepository = {
|
||||
`
|
||||
SELECT id, code, name, description, is_system, created_at, updated_at
|
||||
FROM roles
|
||||
WHERE deleted_at IS NULL
|
||||
ORDER BY id ASC
|
||||
`,
|
||||
);
|
||||
@@ -386,6 +387,7 @@ export const catalogRepository = {
|
||||
`
|
||||
SELECT id, code, name, description, is_system, created_at, updated_at
|
||||
FROM roles
|
||||
WHERE deleted_at IS NULL
|
||||
ORDER BY id ASC
|
||||
`,
|
||||
);
|
||||
@@ -399,6 +401,7 @@ export const catalogRepository = {
|
||||
SELECT id, code, name, description, is_system, created_at, updated_at
|
||||
FROM roles
|
||||
WHERE id = ?
|
||||
AND deleted_at IS NULL
|
||||
LIMIT 1
|
||||
`,
|
||||
[id],
|
||||
@@ -425,6 +428,7 @@ export const catalogRepository = {
|
||||
SELECT id, code, name, description, is_system, created_at, updated_at
|
||||
FROM roles
|
||||
WHERE code = ?
|
||||
AND deleted_at IS NULL
|
||||
${excludeSql}
|
||||
LIMIT 1
|
||||
`,
|
||||
@@ -474,22 +478,32 @@ export const catalogRepository = {
|
||||
`
|
||||
UPDATE roles
|
||||
SET ${sets.join(", ")}
|
||||
WHERE id = ?
|
||||
WHERE id = ? AND deleted_at IS NULL
|
||||
`,
|
||||
params,
|
||||
);
|
||||
},
|
||||
|
||||
async deleteRole(id: number): Promise<void> {
|
||||
await pool.execute("DELETE FROM roles WHERE id = ?", [id]);
|
||||
await pool.execute(
|
||||
`
|
||||
UPDATE roles
|
||||
SET deleted_at = CURRENT_TIMESTAMP(3)
|
||||
WHERE id = ? AND deleted_at IS NULL
|
||||
`,
|
||||
[id],
|
||||
);
|
||||
},
|
||||
|
||||
async countEmployeesByRole(roleId: number): Promise<number> {
|
||||
const [rows] = await pool.execute<CountRow[]>(
|
||||
`
|
||||
SELECT COUNT(*) AS total
|
||||
FROM employee_roles
|
||||
WHERE role_id = ?
|
||||
FROM employee_roles er
|
||||
INNER JOIN employees e ON e.id = er.employee_id
|
||||
WHERE er.role_id = ?
|
||||
AND er.deleted_at IS NULL
|
||||
AND e.deleted_at IS NULL
|
||||
`,
|
||||
[roleId],
|
||||
);
|
||||
|
||||
@@ -34,6 +34,11 @@ export const idParamSchema = z.object({
|
||||
id: z.coerce.number().int().positive(),
|
||||
});
|
||||
|
||||
export const storeEmployeeParamSchema = z.object({
|
||||
storeId: z.coerce.number().int().positive(),
|
||||
employeeId: z.coerce.number().int().positive(),
|
||||
});
|
||||
|
||||
export const listStoresQuerySchema = z.object({
|
||||
includeInactive: z.preprocess(
|
||||
(value) => stringToBoolean(emptyStringToUndefined(value)),
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { conflict, notFound } from "../../shared/http-error";
|
||||
import { employeeService } from "../employees/employee.service";
|
||||
import { catalogRepository } from "./catalog.repository";
|
||||
import type {
|
||||
CreateRoleInput,
|
||||
@@ -8,6 +9,7 @@ import type {
|
||||
Role,
|
||||
RoleOption,
|
||||
Store,
|
||||
StoreDetail,
|
||||
StoreOption,
|
||||
UpdateRoleInput,
|
||||
UpdateStoreInput,
|
||||
@@ -40,6 +42,16 @@ export const catalogService = {
|
||||
return store;
|
||||
},
|
||||
|
||||
async getStoreDetailById(id: number): Promise<StoreDetail> {
|
||||
const store = await this.getStoreById(id);
|
||||
const employees = await employeeService.listByStoreId(id);
|
||||
|
||||
return {
|
||||
...store,
|
||||
employees,
|
||||
};
|
||||
},
|
||||
|
||||
async createStore(input: CreateStoreInput): Promise<Store> {
|
||||
// 门店名称在“未删除门店”范围内保持唯一,避免管理后台出现两个同名门店。
|
||||
const duplicatedStore = await catalogRepository.findActiveStoreByName(
|
||||
@@ -56,7 +68,7 @@ export const catalogService = {
|
||||
},
|
||||
|
||||
async updateStore(id: number, input: UpdateStoreInput): Promise<Store> {
|
||||
const currentStore = await this.getStoreById(id);
|
||||
await this.getStoreById(id);
|
||||
|
||||
if (input.name !== undefined) {
|
||||
const duplicatedStore = await catalogRepository.findActiveStoreByName(
|
||||
@@ -69,15 +81,6 @@ export const catalogService = {
|
||||
}
|
||||
}
|
||||
|
||||
// 如果门店下还有员工,停用门店会让员工数据失去可用归属,所以这里先阻止。
|
||||
if (currentStore.status === "ACTIVE" && input.status === "INACTIVE") {
|
||||
const employeeCount = await catalogRepository.countEmployeesByStore(id);
|
||||
|
||||
if (employeeCount > 0) {
|
||||
throw conflict("门店下还有员工,不能停用");
|
||||
}
|
||||
}
|
||||
|
||||
await catalogRepository.updateStore(id, input);
|
||||
return this.getStoreById(id);
|
||||
},
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { Employee } from "../employees/employee.types";
|
||||
|
||||
export const STORE_STATUS = ["ACTIVE", "INACTIVE"] as const;
|
||||
|
||||
export const FIXED_ROLE_DEFINITIONS = [
|
||||
@@ -57,6 +59,10 @@ export interface Store extends StoreOption {
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface StoreDetail extends Store {
|
||||
employees: Employee[];
|
||||
}
|
||||
|
||||
export interface Role extends RoleOption {
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
idParamSchema,
|
||||
listEmployeesQuerySchema,
|
||||
updateEmployeeBodySchema,
|
||||
updateEmployeePasswordBodySchema,
|
||||
updateEmployeeStatusBodySchema
|
||||
} from "./employee.schema";
|
||||
import type { AuthUser } from "../auth/auth.types";
|
||||
@@ -123,6 +124,27 @@ export async function employeeRoutes(app: FastifyInstance): Promise<void> {
|
||||
return ok(employee);
|
||||
});
|
||||
|
||||
app.patch("/employees/:id/password", async (request) => {
|
||||
const user = await authService.getCurrentUser(request.user);
|
||||
assertCanManageEmployees(user);
|
||||
|
||||
const params = idParamSchema.parse(request.params);
|
||||
const body = updateEmployeePasswordBodySchema.parse(request.body);
|
||||
const employee = await employeeService.updatePassword(params.id, body);
|
||||
|
||||
return ok(employee);
|
||||
});
|
||||
|
||||
app.patch("/employees/:id/password/reset", async (request) => {
|
||||
const user = await authService.getCurrentUser(request.user);
|
||||
assertCanManageEmployees(user);
|
||||
|
||||
const params = idParamSchema.parse(request.params);
|
||||
const employee = await employeeService.resetPassword(params.id);
|
||||
|
||||
return ok(employee);
|
||||
});
|
||||
|
||||
app.delete("/employees/:id", async (request, reply) => {
|
||||
const user = await authService.getCurrentUser(request.user);
|
||||
assertCanManageEmployees(user);
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import type { PoolConnection, ResultSetHeader, RowDataPacket } from "mysql2/promise";
|
||||
import { pool } from "../../db/pool";
|
||||
import { INITIAL_EMPLOYEE_PASSWORD_HASH } from "../auth/password";
|
||||
import type {
|
||||
CreateEmployeeInput,
|
||||
Employee,
|
||||
EmployeeStatusTag,
|
||||
EmployeeStatus,
|
||||
EmployeeStoreStatus,
|
||||
ListEmployeesQuery,
|
||||
RoleSummary,
|
||||
UpdateEmployeeInput
|
||||
@@ -11,13 +14,12 @@ import type {
|
||||
|
||||
type DbExecutor = typeof pool | PoolConnection;
|
||||
type SqlParam = string | number | boolean | Date | null;
|
||||
const DEFAULT_EMPLOYEE_PASSWORD_HASH =
|
||||
"pbkdf2$sha256$310000$QnXyjrpm0QzcGYLEPdunWg$CfR-CywGl1c_Omh_3PyOWPmo93EcbMY1FEjjd5MDjFo";
|
||||
|
||||
interface EmployeeRow extends RowDataPacket {
|
||||
id: number;
|
||||
store_id: number;
|
||||
store_name: string;
|
||||
store_status: EmployeeStoreStatus;
|
||||
name: string;
|
||||
phone: string;
|
||||
status: EmployeeStatus;
|
||||
@@ -37,20 +39,52 @@ interface CountRow extends RowDataPacket {
|
||||
total: number;
|
||||
}
|
||||
|
||||
interface EmployeePasswordRow extends RowDataPacket {
|
||||
password_hash: string;
|
||||
}
|
||||
|
||||
// 数据库层统一把 Date 转成字符串,避免响应里混入运行时对象。
|
||||
function toIso(value: Date): string {
|
||||
return value.toISOString();
|
||||
}
|
||||
|
||||
function buildStatusTags(row: Pick<EmployeeRow, "status" | "store_status">): EmployeeStatusTag[] {
|
||||
const tags: EmployeeStatusTag[] = [
|
||||
row.status === "ACTIVE"
|
||||
? {
|
||||
code: "EMPLOYEE_ACTIVE",
|
||||
label: "员工启用",
|
||||
variant: "success",
|
||||
}
|
||||
: {
|
||||
code: "EMPLOYEE_INACTIVE",
|
||||
label: "员工停用",
|
||||
variant: "default",
|
||||
},
|
||||
];
|
||||
|
||||
if (row.store_status === "INACTIVE") {
|
||||
tags.push({
|
||||
code: "STORE_INACTIVE",
|
||||
label: "门店被禁用",
|
||||
variant: "warning",
|
||||
});
|
||||
}
|
||||
|
||||
return tags;
|
||||
}
|
||||
|
||||
// 把 JOIN 查询出来的数据库行转换成接口返回结构。
|
||||
function toEmployee(row: EmployeeRow, roles: RoleSummary[] = []): Employee {
|
||||
return {
|
||||
id: row.id,
|
||||
storeId: row.store_id,
|
||||
storeName: row.store_name,
|
||||
storeStatus: row.store_status,
|
||||
name: row.name,
|
||||
phone: row.phone,
|
||||
status: row.status,
|
||||
statusTags: buildStatusTags(row),
|
||||
remark: row.remark,
|
||||
roles,
|
||||
createdAt: toIso(row.created_at),
|
||||
@@ -103,7 +137,7 @@ export const employeeRepository = {
|
||||
},
|
||||
|
||||
async storeExists(storeId: number, db: DbExecutor = pool): Promise<boolean> {
|
||||
// 员工只能绑定到启用且未删除的门店。
|
||||
// 新建或调整员工归属时,只允许选择启用且未删除的门店。
|
||||
const [rows] = await db.execute<CountRow[]>(
|
||||
"SELECT COUNT(*) AS total FROM stores WHERE id = ? AND status = 'ACTIVE' AND deleted_at IS NULL",
|
||||
[storeId]
|
||||
@@ -124,6 +158,7 @@ export const employeeRepository = {
|
||||
SELECT id
|
||||
FROM roles
|
||||
WHERE id IN (${placeholders})
|
||||
AND deleted_at IS NULL
|
||||
`,
|
||||
roleIds
|
||||
);
|
||||
@@ -147,7 +182,7 @@ export const employeeRepository = {
|
||||
|
||||
const [rows] = await db.execute<EmployeeRow[]>(
|
||||
`
|
||||
SELECT e.*, s.name AS store_name
|
||||
SELECT e.*, s.name AS store_name, s.status AS store_status
|
||||
FROM employees e
|
||||
INNER JOIN stores s ON s.id = e.store_id
|
||||
WHERE e.phone = ?
|
||||
@@ -179,7 +214,7 @@ export const employeeRepository = {
|
||||
// LIMIT/OFFSET 已经过 zod 校验成受控正整数;这里直接拼接数字,避免部分 MySQL 驱动对分页占位符的兼容问题。
|
||||
const [rows] = await pool.execute<EmployeeRow[]>(
|
||||
`
|
||||
SELECT e.*, s.name AS store_name
|
||||
SELECT e.*, s.name AS store_name, s.status AS store_status
|
||||
FROM employees e
|
||||
INNER JOIN stores s ON s.id = e.store_id
|
||||
WHERE ${whereSql}
|
||||
@@ -198,10 +233,26 @@ export const employeeRepository = {
|
||||
};
|
||||
},
|
||||
|
||||
async listByStoreId(storeId: number, db: DbExecutor = pool): Promise<Employee[]> {
|
||||
const [rows] = await db.execute<EmployeeRow[]>(
|
||||
`
|
||||
SELECT e.*, s.name AS store_name, s.status AS store_status
|
||||
FROM employees e
|
||||
INNER JOIN stores s ON s.id = e.store_id
|
||||
WHERE e.store_id = ? AND e.deleted_at IS NULL
|
||||
ORDER BY e.id DESC
|
||||
`,
|
||||
[storeId]
|
||||
);
|
||||
|
||||
const rolesByEmployeeId = await this.findRolesByEmployeeIds(rows.map((row) => row.id), db);
|
||||
return rows.map((row) => toEmployee(row, rolesByEmployeeId.get(row.id) ?? []));
|
||||
},
|
||||
|
||||
async findById(id: number, db: DbExecutor = pool): Promise<Employee | null> {
|
||||
const [rows] = await db.execute<EmployeeRow[]>(
|
||||
`
|
||||
SELECT e.*, s.name AS store_name
|
||||
SELECT e.*, s.name AS store_name, s.status AS store_status
|
||||
FROM employees e
|
||||
INNER JOIN stores s ON s.id = e.store_id
|
||||
WHERE e.id = ? AND e.deleted_at IS NULL
|
||||
@@ -228,7 +279,7 @@ export const employeeRepository = {
|
||||
input.storeId,
|
||||
input.name,
|
||||
input.phone,
|
||||
DEFAULT_EMPLOYEE_PASSWORD_HASH,
|
||||
INITIAL_EMPLOYEE_PASSWORD_HASH,
|
||||
input.status,
|
||||
input.remark ?? null
|
||||
]
|
||||
@@ -273,6 +324,31 @@ export const employeeRepository = {
|
||||
);
|
||||
},
|
||||
|
||||
async updatePasswordHash(id: number, passwordHash: string, db: DbExecutor = pool): Promise<void> {
|
||||
await db.execute(
|
||||
`
|
||||
UPDATE employees
|
||||
SET password_hash = ?
|
||||
WHERE id = ? AND deleted_at IS NULL
|
||||
`,
|
||||
[passwordHash, id]
|
||||
);
|
||||
},
|
||||
|
||||
async findPasswordHashById(id: number, db: DbExecutor = pool): Promise<string | null> {
|
||||
const [rows] = await db.execute<EmployeePasswordRow[]>(
|
||||
`
|
||||
SELECT password_hash
|
||||
FROM employees
|
||||
WHERE id = ? AND deleted_at IS NULL
|
||||
LIMIT 1
|
||||
`,
|
||||
[id]
|
||||
);
|
||||
|
||||
return rows[0]?.password_hash ?? null;
|
||||
},
|
||||
|
||||
async softDelete(id: number): Promise<void> {
|
||||
// 员工删除采用软删除:保留历史记录,同时将状态置为 INACTIVE。
|
||||
await pool.execute(
|
||||
@@ -286,8 +362,15 @@ export const employeeRepository = {
|
||||
},
|
||||
|
||||
async replaceRoles(employeeId: number, roleIds: number[], db: DbExecutor = pool): Promise<void> {
|
||||
// 简化多对多更新:先删旧关系,再批量插入新关系。调用方用事务包住它。
|
||||
await db.execute("DELETE FROM employee_roles WHERE employee_id = ?", [employeeId]);
|
||||
// 关系解绑也使用逻辑删除;重新绑定同一角色时通过 upsert 恢复 deleted_at。
|
||||
await db.execute(
|
||||
`
|
||||
UPDATE employee_roles
|
||||
SET deleted_at = CURRENT_TIMESTAMP(3)
|
||||
WHERE employee_id = ? AND deleted_at IS NULL
|
||||
`,
|
||||
[employeeId]
|
||||
);
|
||||
|
||||
if (roleIds.length === 0) {
|
||||
return;
|
||||
@@ -297,6 +380,9 @@ export const employeeRepository = {
|
||||
`
|
||||
INSERT INTO employee_roles (employee_id, role_id)
|
||||
VALUES ${roleIds.map(() => "(?, ?)").join(", ")}
|
||||
ON DUPLICATE KEY UPDATE
|
||||
deleted_at = NULL,
|
||||
created_at = CURRENT_TIMESTAMP(3)
|
||||
`,
|
||||
roleIds.flatMap((roleId) => [employeeId, roleId])
|
||||
);
|
||||
@@ -320,6 +406,8 @@ export const employeeRepository = {
|
||||
FROM employee_roles er
|
||||
INNER JOIN roles r ON r.id = er.role_id
|
||||
WHERE er.employee_id IN (${placeholders})
|
||||
AND er.deleted_at IS NULL
|
||||
AND r.deleted_at IS NULL
|
||||
ORDER BY r.id ASC
|
||||
`,
|
||||
employeeIds
|
||||
|
||||
@@ -61,3 +61,8 @@ export const updateEmployeeBodySchema = z
|
||||
export const updateEmployeeStatusBodySchema = z.object({
|
||||
status: z.enum(EMPLOYEE_STATUS)
|
||||
});
|
||||
|
||||
export const updateEmployeePasswordBodySchema = z.object({
|
||||
oldPassword: z.string().min(8).max(128),
|
||||
newPassword: z.string().min(8).max(128)
|
||||
});
|
||||
|
||||
@@ -1,13 +1,20 @@
|
||||
import { badRequest, conflict, notFound } from "../../shared/http-error";
|
||||
import { hashPassword, INITIAL_EMPLOYEE_PASSWORD_HASH, verifyPassword } from "../auth/password";
|
||||
import { employeeRepository } from "./employee.repository";
|
||||
import type { CreateEmployeeInput, Employee, ListEmployeesQuery, UpdateEmployeeInput } from "./employee.types";
|
||||
import type {
|
||||
CreateEmployeeInput,
|
||||
Employee,
|
||||
ListEmployeesQuery,
|
||||
UpdateEmployeeInput,
|
||||
UpdateEmployeePasswordInput
|
||||
} from "./employee.types";
|
||||
|
||||
// 角色 id 可能从前端重复提交;进入数据库前先去重,减少无意义 SQL 和主键冲突。
|
||||
function uniqueIds(ids: number[]): number[] {
|
||||
return [...new Set(ids)];
|
||||
}
|
||||
|
||||
// 创建或修改员工时,必须保证门店存在且处于启用状态。
|
||||
// 创建员工或更换员工门店时,必须保证目标门店存在且处于启用状态。
|
||||
async function assertStoreExists(storeId: number): Promise<void> {
|
||||
const exists = await employeeRepository.storeExists(storeId);
|
||||
|
||||
@@ -22,7 +29,7 @@ async function assertRolesExist(roleIds: number[]): Promise<number[]> {
|
||||
const existingRoleIds = await employeeRepository.existingRoleIds(dedupedRoleIds);
|
||||
|
||||
if (existingRoleIds.length !== dedupedRoleIds.length) {
|
||||
throw badRequest("提交的角色包含不存在的角色");
|
||||
throw badRequest("提交的角色包含不存在或已删除的角色");
|
||||
}
|
||||
|
||||
return dedupedRoleIds;
|
||||
@@ -34,6 +41,10 @@ export const employeeService = {
|
||||
return employeeRepository.list(query);
|
||||
},
|
||||
|
||||
async listByStoreId(storeId: number): Promise<Employee[]> {
|
||||
return employeeRepository.listByStoreId(storeId);
|
||||
},
|
||||
|
||||
async getById(id: number): Promise<Employee> {
|
||||
const employee = await employeeRepository.findById(id);
|
||||
|
||||
@@ -68,7 +79,7 @@ export const employeeService = {
|
||||
async update(id: number, input: UpdateEmployeeInput): Promise<Employee> {
|
||||
const currentEmployee = await this.getById(id);
|
||||
|
||||
if (input.storeId !== undefined) {
|
||||
if (input.storeId !== undefined && input.storeId !== currentEmployee.storeId) {
|
||||
await assertStoreExists(input.storeId);
|
||||
}
|
||||
|
||||
@@ -106,6 +117,40 @@ export const employeeService = {
|
||||
return this.getById(id);
|
||||
},
|
||||
|
||||
async updatePassword(id: number, input: UpdateEmployeePasswordInput): Promise<Employee> {
|
||||
const currentPasswordHash = await employeeRepository.findPasswordHashById(id);
|
||||
|
||||
if (!currentPasswordHash) {
|
||||
throw notFound("员工不存在");
|
||||
}
|
||||
|
||||
const passwordMatched = await verifyPassword(input.oldPassword, currentPasswordHash);
|
||||
|
||||
if (!passwordMatched) {
|
||||
throw badRequest("旧密码不正确");
|
||||
}
|
||||
|
||||
const passwordHash = await hashPassword(input.newPassword);
|
||||
await employeeRepository.updatePasswordHash(id, passwordHash);
|
||||
return this.getById(id);
|
||||
},
|
||||
|
||||
async resetPassword(id: number): Promise<Employee> {
|
||||
await this.getById(id);
|
||||
await employeeRepository.updatePasswordHash(id, INITIAL_EMPLOYEE_PASSWORD_HASH);
|
||||
return this.getById(id);
|
||||
},
|
||||
|
||||
async removeFromStore(storeId: number, employeeId: number): Promise<void> {
|
||||
const employee = await this.getById(employeeId);
|
||||
|
||||
if (employee.storeId !== storeId) {
|
||||
throw notFound("门店员工不存在");
|
||||
}
|
||||
|
||||
await employeeRepository.softDelete(employeeId);
|
||||
},
|
||||
|
||||
async delete(id: number): Promise<void> {
|
||||
await this.getById(id);
|
||||
// 软删除保留历史数据,也能配合 active_phone 释放手机号唯一约束。
|
||||
|
||||
@@ -2,6 +2,12 @@ export const EMPLOYEE_STATUS = ["ACTIVE", "INACTIVE"] as const;
|
||||
|
||||
// 从状态常量推导类型,确保 schema 校验和 TypeScript 类型保持一致。
|
||||
export type EmployeeStatus = (typeof EMPLOYEE_STATUS)[number];
|
||||
export type EmployeeStoreStatus = "ACTIVE" | "INACTIVE";
|
||||
export type EmployeeStatusTagCode =
|
||||
| "EMPLOYEE_ACTIVE"
|
||||
| "EMPLOYEE_INACTIVE"
|
||||
| "STORE_INACTIVE";
|
||||
export type EmployeeStatusTagVariant = "success" | "warning" | "default";
|
||||
|
||||
// 员工详情里只需要角色摘要,完整角色管理由 catalog 模块负责。
|
||||
export interface RoleSummary {
|
||||
@@ -10,14 +16,22 @@ export interface RoleSummary {
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface EmployeeStatusTag {
|
||||
code: EmployeeStatusTagCode;
|
||||
label: string;
|
||||
variant: EmployeeStatusTagVariant;
|
||||
}
|
||||
|
||||
// Employee 是接口返回给前端的员工结构,字段名使用 camelCase。
|
||||
export interface Employee {
|
||||
id: number;
|
||||
storeId: number;
|
||||
storeName: string;
|
||||
storeStatus: EmployeeStoreStatus;
|
||||
name: string;
|
||||
phone: string;
|
||||
status: EmployeeStatus;
|
||||
statusTags: EmployeeStatusTag[];
|
||||
remark: string | null;
|
||||
roles: RoleSummary[];
|
||||
createdAt: string;
|
||||
@@ -49,3 +63,8 @@ export interface UpdateEmployeeInput {
|
||||
remark?: string | null;
|
||||
roleIds?: number[];
|
||||
}
|
||||
|
||||
export interface UpdateEmployeePasswordInput {
|
||||
oldPassword: string;
|
||||
newPassword: string;
|
||||
}
|
||||
|
||||
@@ -75,14 +75,14 @@ const PERMISSION_DEFINITIONS: PermissionDefinition[] = [
|
||||
{
|
||||
code: PERMISSIONS.STORE_VIEW,
|
||||
title: "查看门店",
|
||||
description: "查看门店列表、门店详情和门店下拉选项。",
|
||||
description: "查看门店列表、门店详情、门店下员工和门店下拉选项。",
|
||||
groupKey: "stores",
|
||||
groupTitle: "门店管理",
|
||||
},
|
||||
{
|
||||
code: PERMISSIONS.STORE_MANAGE,
|
||||
title: "管理门店",
|
||||
description: "新增、编辑、停用和删除门店。",
|
||||
description: "新增、编辑、停用和软删除门店。",
|
||||
groupKey: "stores",
|
||||
groupTitle: "门店管理",
|
||||
},
|
||||
@@ -96,7 +96,7 @@ const PERMISSION_DEFINITIONS: PermissionDefinition[] = [
|
||||
{
|
||||
code: PERMISSIONS.ROLE_MANAGE,
|
||||
title: "管理角色",
|
||||
description: "新增、编辑和删除非系统角色。",
|
||||
description: "新增、编辑和软删除非系统角色。",
|
||||
groupKey: "roles",
|
||||
groupTitle: "角色管理",
|
||||
},
|
||||
@@ -117,7 +117,7 @@ const PERMISSION_DEFINITIONS: PermissionDefinition[] = [
|
||||
{
|
||||
code: PERMISSIONS.EMPLOYEE_MANAGE,
|
||||
title: "管理员工",
|
||||
description: "新增、编辑、启停和删除员工,并维护员工角色。",
|
||||
description: "新增、编辑、启停、移除、软删除员工,并维护员工角色和密码。",
|
||||
groupKey: "employees",
|
||||
groupTitle: "员工管理",
|
||||
},
|
||||
|
||||
@@ -82,7 +82,9 @@ export const permissionRepository = {
|
||||
r.is_system,
|
||||
rp.permission_code
|
||||
FROM roles r
|
||||
LEFT JOIN role_permissions rp ON rp.role_id = r.id
|
||||
LEFT JOIN role_permissions rp
|
||||
ON rp.role_id = r.id AND rp.deleted_at IS NULL
|
||||
WHERE r.deleted_at IS NULL
|
||||
ORDER BY r.id ASC, rp.permission_code ASC
|
||||
`,
|
||||
);
|
||||
@@ -104,8 +106,10 @@ export const permissionRepository = {
|
||||
r.is_system,
|
||||
rp.permission_code
|
||||
FROM roles r
|
||||
LEFT JOIN role_permissions rp ON rp.role_id = r.id
|
||||
LEFT JOIN role_permissions rp
|
||||
ON rp.role_id = r.id AND rp.deleted_at IS NULL
|
||||
WHERE r.id = ?
|
||||
AND r.deleted_at IS NULL
|
||||
ORDER BY rp.permission_code ASC
|
||||
`,
|
||||
[roleId],
|
||||
@@ -126,6 +130,8 @@ export const permissionRepository = {
|
||||
FROM role_permissions rp
|
||||
INNER JOIN roles r ON r.id = rp.role_id
|
||||
WHERE r.code IN (${placeholders})
|
||||
AND r.deleted_at IS NULL
|
||||
AND rp.deleted_at IS NULL
|
||||
ORDER BY rp.permission_code ASC
|
||||
`,
|
||||
roleCodes,
|
||||
@@ -139,7 +145,14 @@ export const permissionRepository = {
|
||||
permissionCodes: string[],
|
||||
db: DbExecutor = pool,
|
||||
): Promise<void> {
|
||||
await db.execute("DELETE FROM role_permissions WHERE role_id = ?", [roleId]);
|
||||
await db.execute(
|
||||
`
|
||||
UPDATE role_permissions
|
||||
SET deleted_at = CURRENT_TIMESTAMP(3)
|
||||
WHERE role_id = ? AND deleted_at IS NULL
|
||||
`,
|
||||
[roleId],
|
||||
);
|
||||
|
||||
if (permissionCodes.length === 0) {
|
||||
return;
|
||||
@@ -149,6 +162,9 @@ export const permissionRepository = {
|
||||
`
|
||||
INSERT INTO role_permissions (role_id, permission_code)
|
||||
VALUES ${permissionCodes.map(() => "(?, ?)").join(", ")}
|
||||
ON DUPLICATE KEY UPDATE
|
||||
deleted_at = NULL,
|
||||
created_at = CURRENT_TIMESTAMP(3)
|
||||
`,
|
||||
permissionCodes.flatMap((permissionCode) => [roleId, permissionCode]),
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user