feat: 增加员工端工作台后端能力

This commit is contained in:
湛兮
2026-06-02 12:23:00 +08:00
parent 667dc411fc
commit 98cea63203
33 changed files with 3021 additions and 18 deletions
+13
View File
@@ -5,9 +5,14 @@ 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 { announcementAdminRoutes, announcementMobileRoutes } from "./modules/announcements/announcement.controller";
import { catalogRoutes } from "./modules/catalog/catalog.controller";
import { credentialAdminRoutes } from "./modules/credentials/credential.controller";
import { employeeRoutes } from "./modules/employees/employee.controller";
import { mobileRoutes } from "./modules/mobile/mobile.controller";
import { permissionRoutes } from "./modules/permissions/permission.controller";
import { shiftAdminRoutes, shiftMobileRoutes } from "./modules/shifts/shift.controller";
import { taskAdminRoutes, taskMobileRoutes } from "./modules/tasks/task.controller";
import { HttpError } from "./shared/http-error";
import { ok } from "./shared/response";
@@ -58,6 +63,10 @@ export function createApp() {
// 登录接口不需要 token/auth/me 在 authRoutes 内部单独加了 authGuard。
app.register(authRoutes, { prefix: "/api" });
app.register(permissionRoutes, { prefix: "/api" });
app.register(mobileRoutes, { prefix: "/api" });
app.register(announcementMobileRoutes, { prefix: "/api" });
app.register(taskMobileRoutes, { prefix: "/api" });
app.register(shiftMobileRoutes, { prefix: "/api" });
// 业务管理接口统一要求后台权限:超级管理员或拥有 admin 角色的员工。
app.register(
@@ -65,6 +74,10 @@ export function createApp() {
protectedApp.addHook("preHandler", managementGuard);
protectedApp.register(catalogRoutes);
protectedApp.register(employeeRoutes);
protectedApp.register(announcementAdminRoutes);
protectedApp.register(taskAdminRoutes);
protectedApp.register(shiftAdminRoutes);
protectedApp.register(credentialAdminRoutes);
},
{ prefix: "/api" },
);
@@ -0,0 +1,64 @@
import type { FastifyInstance } from "fastify";
import { created, ok, paginated } from "../../shared/response";
import { authGuard, permissionGuard } from "../auth/auth.guard";
import { authService } from "../auth/auth.service";
import { PERMISSIONS } from "../permissions/permission.policy";
import { announcementService } from "./announcement.service";
import {
announcementIdParamSchema,
createAnnouncementBodySchema,
listAnnouncementsQuerySchema,
updateAnnouncementBodySchema,
} from "./announcement.schema";
export async function announcementAdminRoutes(app: FastifyInstance): Promise<void> {
app.get("/admin/announcements", { preHandler: permissionGuard(PERMISSIONS.ANNOUNCEMENT_VIEW) }, async (request) => {
const query = listAnnouncementsQuerySchema.parse(request.query);
const result = await announcementService.list(query);
return paginated(result.items, query.page, query.pageSize, result.total);
});
app.post("/admin/announcements", { preHandler: permissionGuard(PERMISSIONS.ANNOUNCEMENT_MANAGE) }, async (request, reply) => {
const user = await authService.getCurrentUser(request.user);
const body = createAnnouncementBodySchema.parse(request.body);
const announcement = await announcementService.create(body, user);
return reply.code(201).send(created(announcement));
});
app.patch("/admin/announcements/:id", { preHandler: permissionGuard(PERMISSIONS.ANNOUNCEMENT_MANAGE) }, async (request) => {
const params = announcementIdParamSchema.parse(request.params);
const body = updateAnnouncementBodySchema.parse(request.body);
return ok(await announcementService.update(params.id, body));
});
app.post("/admin/announcements/:id/publish", { preHandler: permissionGuard(PERMISSIONS.ANNOUNCEMENT_MANAGE) }, async (request) => {
const params = announcementIdParamSchema.parse(request.params);
return ok(await announcementService.publish(params.id));
});
app.post("/admin/announcements/:id/archive", { preHandler: permissionGuard(PERMISSIONS.ANNOUNCEMENT_MANAGE) }, async (request) => {
const params = announcementIdParamSchema.parse(request.params);
return ok(await announcementService.archive(params.id));
});
}
export async function announcementMobileRoutes(app: FastifyInstance): Promise<void> {
app.get("/mobile/announcements", { preHandler: authGuard }, async (request) => {
const employee = await authService.getCurrentEmployeeUser(request.user);
const query = listAnnouncementsQuerySchema.parse(request.query);
const result = await announcementService.listVisibleForEmployee(employee, query);
return paginated(result.items, query.page, query.pageSize, result.total);
});
app.get("/mobile/announcements/:id", { preHandler: authGuard }, async (request) => {
const employee = await authService.getCurrentEmployeeUser(request.user);
const params = announcementIdParamSchema.parse(request.params);
return ok(await announcementService.getVisibleByIdForEmployee(params.id, employee));
});
app.post("/mobile/announcements/:id/read", { preHandler: authGuard }, async (request) => {
const employee = await authService.getCurrentEmployeeUser(request.user);
const params = announcementIdParamSchema.parse(request.params);
return ok(await announcementService.markRead(params.id, employee));
});
}
@@ -0,0 +1,401 @@
import type { PoolConnection, ResultSetHeader, RowDataPacket } from "mysql2/promise";
import { pool } from "../../db/pool";
import type { AuthUser } from "../auth/auth.types";
import type {
Announcement,
AnnouncementTargetInput,
CreateAnnouncementInput,
ListAnnouncementsQuery,
UpdateAnnouncementInput,
} from "./announcement.types";
type DbExecutor = typeof pool | PoolConnection;
type SqlParam = string | number | Date | null;
interface AnnouncementRow extends RowDataPacket {
id: number;
title: string;
content: string;
level: Announcement["level"];
status: Announcement["status"];
target_type: Announcement["targetType"];
published_at: Date | null;
read_at?: Date | null;
created_at: Date;
updated_at: Date;
}
interface TargetRow extends RowDataPacket {
announcement_id: number;
target_type: AnnouncementTargetInput["type"];
target_id: number;
}
interface CountRow extends RowDataPacket {
total: number;
}
function toIso(value: Date | null): string | null {
return value ? value.toISOString() : null;
}
function toAnnouncement(row: AnnouncementRow, targets: AnnouncementTargetInput[] = []): Announcement {
return {
id: row.id,
title: row.title,
content: row.content,
level: row.level,
status: row.status,
targetType: row.target_type,
publishedAt: toIso(row.published_at),
readAt: row.read_at === undefined ? undefined : toIso(row.read_at ?? null),
createdAt: toIso(row.created_at) ?? "",
updatedAt: toIso(row.updated_at) ?? "",
targets,
};
}
function actorColumns(user: AuthUser): {
publisherAdminId: number | null;
publisherEmployeeId: number | null;
} {
return {
publisherAdminId: user.accountType === "SUPER_ADMIN" ? user.id : null,
publisherEmployeeId: user.accountType === "EMPLOYEE" ? user.id : null,
};
}
async function findTargetsByAnnouncementIds(ids: number[], db: DbExecutor = pool) {
const result = new Map<number, AnnouncementTargetInput[]>();
if (ids.length === 0) {
return result;
}
const [rows] = await db.execute<TargetRow[]>(
`
SELECT announcement_id, target_type, target_id
FROM announcement_targets
WHERE announcement_id IN (${ids.map(() => "?").join(", ")})
ORDER BY id ASC
`,
ids,
);
for (const row of rows) {
const targets = result.get(row.announcement_id) ?? [];
targets.push({ type: row.target_type, id: row.target_id });
result.set(row.announcement_id, targets);
}
return result;
}
export const announcementRepository = {
async withTransaction<T>(handler: (connection: PoolConnection) => Promise<T>): Promise<T> {
const connection = await pool.getConnection();
try {
await connection.beginTransaction();
const result = await handler(connection);
await connection.commit();
return result;
} catch (error) {
await connection.rollback();
throw error;
} finally {
connection.release();
}
},
async list(query: ListAnnouncementsQuery): Promise<{ items: Announcement[]; total: number }> {
const where = ["deleted_at IS NULL"];
const params: SqlParam[] = [];
if (query.status) {
where.push("status = ?");
params.push(query.status);
}
if (query.keyword) {
where.push("(title LIKE ? OR content LIKE ?)");
params.push(`%${query.keyword}%`, `%${query.keyword}%`);
}
const whereSql = where.join(" AND ");
const offset = (query.page - 1) * query.pageSize;
const [countRows] = await pool.execute<CountRow[]>(
`SELECT COUNT(*) AS total FROM announcements WHERE ${whereSql}`,
params,
);
const [rows] = await pool.execute<AnnouncementRow[]>(
`
SELECT id, title, content, level, status, target_type, published_at, created_at, updated_at
FROM announcements
WHERE ${whereSql}
ORDER BY id DESC
LIMIT ${query.pageSize} OFFSET ${offset}
`,
params,
);
const targetsById = await findTargetsByAnnouncementIds(rows.map((row) => row.id));
return {
items: rows.map((row) => toAnnouncement(row, targetsById.get(row.id) ?? [])),
total: countRows[0]?.total ?? 0,
};
},
async findById(id: number, db: DbExecutor = pool): Promise<Announcement | null> {
const [rows] = await db.execute<AnnouncementRow[]>(
`
SELECT id, title, content, level, status, target_type, published_at, created_at, updated_at
FROM announcements
WHERE id = ? AND deleted_at IS NULL
LIMIT 1
`,
[id],
);
if (!rows[0]) {
return null;
}
const targetsById = await findTargetsByAnnouncementIds([id], db);
return toAnnouncement(rows[0], targetsById.get(id) ?? []);
},
async create(input: CreateAnnouncementInput, user: AuthUser, db: DbExecutor = pool): Promise<number> {
const actor = actorColumns(user);
const [result] = await db.execute<ResultSetHeader>(
`
INSERT INTO announcements
(title, content, level, target_type, publisher_admin_id, publisher_employee_id)
VALUES (?, ?, ?, ?, ?, ?)
`,
[
input.title,
input.content,
input.level,
input.targetType,
actor.publisherAdminId,
actor.publisherEmployeeId,
],
);
await this.replaceTargets(result.insertId, input.targets, db);
return result.insertId;
},
async update(id: number, input: UpdateAnnouncementInput, db: DbExecutor = pool): Promise<void> {
const sets: string[] = [];
const params: SqlParam[] = [];
const fieldMap: Array<[keyof UpdateAnnouncementInput, string]> = [
["title", "title"],
["content", "content"],
["level", "level"],
["targetType", "target_type"],
];
for (const [key, column] of fieldMap) {
if (Object.prototype.hasOwnProperty.call(input, key)) {
sets.push(`${column} = ?`);
params.push(input[key] as SqlParam);
}
}
if (sets.length > 0) {
params.push(id);
await db.execute(
`UPDATE announcements SET ${sets.join(", ")} WHERE id = ? AND deleted_at IS NULL`,
params,
);
}
if (input.targets !== undefined) {
await this.replaceTargets(id, input.targets, db);
}
},
async replaceTargets(id: number, targets: AnnouncementTargetInput[], db: DbExecutor = pool): Promise<void> {
await db.execute("DELETE FROM announcement_targets WHERE announcement_id = ?", [id]);
if (targets.length === 0) {
return;
}
await db.execute(
`
INSERT INTO announcement_targets (announcement_id, target_type, target_id)
VALUES ${targets.map(() => "(?, ?, ?)").join(", ")}
`,
targets.flatMap((target) => [id, target.type, target.id]),
);
},
async setStatus(id: number, status: Announcement["status"]): Promise<void> {
const publishedAtSql = status === "PUBLISHED" ? ", published_at = COALESCE(published_at, CURRENT_TIMESTAMP(3))" : "";
await pool.execute(
`
UPDATE announcements
SET status = ?${publishedAtSql}
WHERE id = ? AND deleted_at IS NULL
`,
[status, id],
);
},
async listVisibleForEmployee(employeeId: number, storeId: number, roleCodes: string[], query: ListAnnouncementsQuery) {
const rolePlaceholders = roleCodes.length > 0 ? roleCodes.map(() => "?").join(", ") : "NULL";
const where = ["a.deleted_at IS NULL", "a.status = 'PUBLISHED'"];
const whereParams: SqlParam[] = [];
const visibilityParams: SqlParam[] = [storeId, employeeId, ...roleCodes];
if (query.keyword) {
where.push("(a.title LIKE ? OR a.content LIKE ?)");
whereParams.push(`%${query.keyword}%`, `%${query.keyword}%`);
}
const visibilitySql = `
(
a.target_type = 'ALL'
OR EXISTS (
SELECT 1 FROM announcement_targets at_store
WHERE at_store.announcement_id = a.id
AND at_store.target_type = 'STORE'
AND at_store.target_id = ?
)
OR EXISTS (
SELECT 1 FROM announcement_targets at_employee
WHERE at_employee.announcement_id = a.id
AND at_employee.target_type = 'EMPLOYEE'
AND at_employee.target_id = ?
)
OR EXISTS (
SELECT 1 FROM announcement_targets at_role
INNER JOIN roles r ON r.id = at_role.target_id
WHERE at_role.announcement_id = a.id
AND at_role.target_type = 'ROLE'
AND r.code IN (${rolePlaceholders})
)
)
`;
const offset = (query.page - 1) * query.pageSize;
const [countRows] = await pool.execute<CountRow[]>(
`
SELECT COUNT(*) AS total
FROM announcements a
WHERE ${where.join(" AND ")} AND ${visibilitySql}
`,
[...whereParams, ...visibilityParams],
);
const [rows] = await pool.execute<AnnouncementRow[]>(
`
SELECT a.id, a.title, a.content, a.level, a.status, a.target_type, a.published_at,
ar.read_at, a.created_at, a.updated_at
FROM announcements a
LEFT JOIN announcement_reads ar ON ar.announcement_id = a.id AND ar.employee_id = ?
WHERE ${where.join(" AND ")} AND ${visibilitySql}
ORDER BY a.published_at DESC, a.id DESC
LIMIT ${query.pageSize} OFFSET ${offset}
`,
[employeeId, ...whereParams, ...visibilityParams],
);
return {
items: rows.map((row) => toAnnouncement(row)),
total: countRows[0]?.total ?? 0,
};
},
async findVisibleByIdForEmployee(id: number, employeeId: number, storeId: number, roleCodes: string[]) {
const rolePlaceholders = roleCodes.length > 0 ? roleCodes.map(() => "?").join(", ") : "NULL";
const [rows] = await pool.execute<AnnouncementRow[]>(
`
SELECT a.id, a.title, a.content, a.level, a.status, a.target_type, a.published_at,
ar.read_at, a.created_at, a.updated_at
FROM announcements a
LEFT JOIN announcement_reads ar ON ar.announcement_id = a.id AND ar.employee_id = ?
WHERE a.id = ?
AND a.deleted_at IS NULL
AND a.status = 'PUBLISHED'
AND (
a.target_type = 'ALL'
OR EXISTS (
SELECT 1 FROM announcement_targets at_store
WHERE at_store.announcement_id = a.id
AND at_store.target_type = 'STORE'
AND at_store.target_id = ?
)
OR EXISTS (
SELECT 1 FROM announcement_targets at_employee
WHERE at_employee.announcement_id = a.id
AND at_employee.target_type = 'EMPLOYEE'
AND at_employee.target_id = ?
)
OR EXISTS (
SELECT 1 FROM announcement_targets at_role
INNER JOIN roles r ON r.id = at_role.target_id
WHERE at_role.announcement_id = a.id
AND at_role.target_type = 'ROLE'
AND r.code IN (${rolePlaceholders})
)
)
LIMIT 1
`,
[employeeId, id, storeId, employeeId, ...roleCodes],
);
return rows[0] ? toAnnouncement(rows[0]) : null;
},
async markRead(id: number, employeeId: number): Promise<void> {
await pool.execute(
`
INSERT INTO announcement_reads (announcement_id, employee_id)
VALUES (?, ?)
ON DUPLICATE KEY UPDATE read_at = CURRENT_TIMESTAMP(3)
`,
[id, employeeId],
);
},
async countUnreadForEmployee(employeeId: number, storeId: number, roleCodes: string[]): Promise<number> {
const rolePlaceholders = roleCodes.length > 0 ? roleCodes.map(() => "?").join(", ") : "NULL";
const [rows] = await pool.execute<CountRow[]>(
`
SELECT COUNT(*) AS total
FROM announcements a
LEFT JOIN announcement_reads ar ON ar.announcement_id = a.id AND ar.employee_id = ?
WHERE a.deleted_at IS NULL
AND a.status = 'PUBLISHED'
AND ar.employee_id IS NULL
AND (
a.target_type = 'ALL'
OR EXISTS (
SELECT 1 FROM announcement_targets at_store
WHERE at_store.announcement_id = a.id
AND at_store.target_type = 'STORE'
AND at_store.target_id = ?
)
OR EXISTS (
SELECT 1 FROM announcement_targets at_employee
WHERE at_employee.announcement_id = a.id
AND at_employee.target_type = 'EMPLOYEE'
AND at_employee.target_id = ?
)
OR EXISTS (
SELECT 1 FROM announcement_targets at_role
INNER JOIN roles r ON r.id = at_role.target_id
WHERE at_role.announcement_id = a.id
AND at_role.target_type = 'ROLE'
AND r.code IN (${rolePlaceholders})
)
)
`,
[employeeId, storeId, employeeId, ...roleCodes],
);
return rows[0]?.total ?? 0;
},
};
@@ -0,0 +1,48 @@
import { z } from "zod";
import {
ANNOUNCEMENT_LEVELS,
ANNOUNCEMENT_STATUSES,
ANNOUNCEMENT_TARGET_TYPES,
} from "./announcement.types";
const emptyStringToUndefined = (value: unknown) =>
typeof value === "string" && value.trim() === "" ? undefined : value;
const targetSchema = z.object({
type: z.enum(["STORE", "ROLE", "EMPLOYEE"]),
id: z.coerce.number().int().positive(),
});
export const announcementIdParamSchema = z.object({
id: z.coerce.number().int().positive(),
});
export const listAnnouncementsQuerySchema = z.object({
status: z.preprocess(emptyStringToUndefined, z.enum(ANNOUNCEMENT_STATUSES).optional()),
keyword: z.preprocess(emptyStringToUndefined, z.string().trim().min(1).max(100).optional()),
page: z.coerce.number().int().min(1).default(1),
pageSize: z.coerce.number().int().min(1).max(100).default(20),
});
const announcementBodyBaseSchema = z.object({
title: z.string().trim().min(1).max(120),
content: z.string().trim().min(1).max(20000),
level: z.enum(ANNOUNCEMENT_LEVELS).default("NORMAL"),
targetType: z.enum(ANNOUNCEMENT_TARGET_TYPES).default("ALL"),
targets: z.array(targetSchema).max(200).default([]),
});
export const createAnnouncementBodySchema = announcementBodyBaseSchema
.refine(
(value) =>
value.targetType === "ALL"
? value.targets.length === 0
: value.targets.every((target) => target.type === value.targetType),
{ message: "公告目标范围和目标列表不一致" },
);
export const updateAnnouncementBodySchema = announcementBodyBaseSchema
.partial()
.refine((value) => Object.keys(value).length > 0, {
message: "至少需要提交一个要修改的字段",
});
@@ -0,0 +1,100 @@
import { badRequest, notFound } from "../../shared/http-error";
import type { AuthUser } from "../auth/auth.types";
import { announcementRepository } from "./announcement.repository";
import type { CreateAnnouncementInput, ListAnnouncementsQuery, UpdateAnnouncementInput } from "./announcement.types";
function assertTargetShape(input: Pick<CreateAnnouncementInput, "targetType" | "targets">): void {
if (input.targetType === "ALL" && input.targets.length > 0) {
throw badRequest("全员公告不能提交目标列表");
}
if (input.targetType !== "ALL" && input.targets.length === 0) {
throw badRequest("非全员公告必须提交目标列表");
}
}
export const announcementService = {
list(query: ListAnnouncementsQuery) {
return announcementRepository.list(query);
},
async getById(id: number) {
const announcement = await announcementRepository.findById(id);
if (!announcement) {
throw notFound("公告不存在");
}
return announcement;
},
async create(input: CreateAnnouncementInput, user: AuthUser) {
assertTargetShape(input);
const id = await announcementRepository.withTransaction((connection) =>
announcementRepository.create(input, user, connection),
);
return this.getById(id);
},
async update(id: number, input: UpdateAnnouncementInput) {
const current = await this.getById(id);
assertTargetShape({
targetType: input.targetType ?? current.targetType,
targets: input.targets ?? current.targets,
});
await announcementRepository.withTransaction((connection) =>
announcementRepository.update(id, input, connection),
);
return this.getById(id);
},
async publish(id: number) {
await this.getById(id);
await announcementRepository.setStatus(id, "PUBLISHED");
return this.getById(id);
},
async archive(id: number) {
await this.getById(id);
await announcementRepository.setStatus(id, "ARCHIVED");
return this.getById(id);
},
listVisibleForEmployee(employee: AuthUser, query: ListAnnouncementsQuery) {
return announcementRepository.listVisibleForEmployee(
employee.id,
employee.storeId ?? 0,
employee.roles.map((role) => role.code),
query,
);
},
async getVisibleByIdForEmployee(id: number, employee: AuthUser) {
const announcement = await announcementRepository.findVisibleByIdForEmployee(
id,
employee.id,
employee.storeId ?? 0,
employee.roles.map((role) => role.code),
);
if (!announcement) {
throw notFound("公告不存在");
}
return announcement;
},
async markRead(id: number, employee: AuthUser) {
await this.getVisibleByIdForEmployee(id, employee);
await announcementRepository.markRead(id, employee.id);
return this.getVisibleByIdForEmployee(id, employee);
},
countUnreadForEmployee(employee: AuthUser) {
return announcementRepository.countUnreadForEmployee(
employee.id,
employee.storeId ?? 0,
employee.roles.map((role) => role.code),
);
},
};
@@ -0,0 +1,43 @@
export const ANNOUNCEMENT_LEVELS = ["NORMAL", "IMPORTANT", "URGENT"] as const;
export const ANNOUNCEMENT_STATUSES = ["DRAFT", "PUBLISHED", "ARCHIVED"] as const;
export const ANNOUNCEMENT_TARGET_TYPES = ["ALL", "STORE", "ROLE", "EMPLOYEE"] as const;
export type AnnouncementLevel = (typeof ANNOUNCEMENT_LEVELS)[number];
export type AnnouncementStatus = (typeof ANNOUNCEMENT_STATUSES)[number];
export type AnnouncementTargetType = (typeof ANNOUNCEMENT_TARGET_TYPES)[number];
export interface AnnouncementTargetInput {
type: Exclude<AnnouncementTargetType, "ALL">;
id: number;
}
export interface Announcement {
id: number;
title: string;
content: string;
level: AnnouncementLevel;
status: AnnouncementStatus;
targetType: AnnouncementTargetType;
publishedAt: string | null;
readAt?: string | null;
createdAt: string;
updatedAt: string;
targets: AnnouncementTargetInput[];
}
export interface ListAnnouncementsQuery {
status?: AnnouncementStatus;
keyword?: string;
page: number;
pageSize: number;
}
export interface CreateAnnouncementInput {
title: string;
content: string;
level: AnnouncementLevel;
targetType: AnnouncementTargetType;
targets: AnnouncementTargetInput[];
}
export type UpdateAnnouncementInput = Partial<CreateAnnouncementInput>;
+11 -1
View File
@@ -2,7 +2,7 @@ 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 { loginBodySchema, updateOwnPasswordBodySchema } from "./auth.schema";
import { authService } from "./auth.service";
export async function authRoutes(app: FastifyInstance): Promise<void> {
@@ -50,4 +50,14 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
return ok(user);
});
app.patch("/auth/me/password", { preHandler: authGuard }, async (request) => {
const body = updateOwnPasswordBodySchema.parse(request.body);
const user = await authService.updateOwnPassword(request.user, body, {
ip: request.ip,
userAgent: request.headers["user-agent"] ?? null,
});
return ok(user);
});
}
+22 -3
View File
@@ -26,6 +26,7 @@ interface EmployeeLoginRow extends RowDataPacket {
store_id: number;
store_name: string;
store_status: "ACTIVE" | "INACTIVE";
must_change_password: 0 | 1 | null;
}
interface EmployeeRoleRow extends RowDataPacket {
@@ -123,9 +124,11 @@ export const authRepository = {
e.status,
e.store_id,
s.name AS store_name,
s.status AS store_status
s.status AS store_status,
eps.must_change_password
FROM employees e
INNER JOIN stores s ON s.id = e.store_id
LEFT JOIN employee_password_states eps ON eps.employee_id = e.id
WHERE e.phone = ?
AND e.status = 'ACTIVE'
AND e.deleted_at IS NULL
@@ -160,9 +163,11 @@ export const authRepository = {
e.status,
e.store_id,
s.name AS store_name,
s.status AS store_status
s.status AS store_status,
eps.must_change_password
FROM employees e
INNER JOIN stores s ON s.id = e.store_id
LEFT JOIN employee_password_states eps ON eps.employee_id = e.id
WHERE e.phone = ?
AND e.deleted_at IS NULL
AND s.deleted_at IS NULL
@@ -195,9 +200,11 @@ export const authRepository = {
e.status,
e.store_id,
s.name AS store_name,
s.status AS store_status
s.status AS store_status,
eps.must_change_password
FROM employees e
INNER JOIN stores s ON s.id = e.store_id
LEFT JOIN employee_password_states eps ON eps.employee_id = e.id
WHERE e.id = ?
AND e.status = 'ACTIVE'
AND e.deleted_at IS NULL
@@ -230,6 +237,17 @@ export const authRepository = {
);
},
async updateEmployeePasswordHash(id: number, passwordHash: string): Promise<void> {
await pool.execute(
`
UPDATE employees
SET password_hash = ?
WHERE id = ? AND deleted_at IS NULL
`,
[passwordHash, id],
);
},
async findRolesByEmployeeIds(
employeeIds: number[],
): Promise<Map<number, EmployeeLoginAccount["roles"]>> {
@@ -280,6 +298,7 @@ function toEmployeeLoginAccount(
storeId: row.store_id,
storeName: row.store_name,
storeStatus: row.store_status,
mustChangePassword: row.must_change_password === 1,
roles,
};
}
+5
View File
@@ -4,3 +4,8 @@ export const loginBodySchema = z.object({
username: z.string().trim().min(1).max(50),
password: z.string().min(8).max(128),
});
export const updateOwnPasswordBodySchema = z.object({
oldPassword: z.string().min(8).max(128),
newPassword: z.string().min(8).max(128),
});
+42 -2
View File
@@ -1,4 +1,4 @@
import { unauthorized } from "../../shared/http-error";
import { badRequest, forbidden, unauthorized } from "../../shared/http-error";
import { authRepository } from "./auth.repository";
import type {
AuthJwtPayload,
@@ -8,7 +8,8 @@ import type {
LoginScene,
SuperAdmin,
} from "./auth.types";
import { verifyPassword } from "./password";
import { hashPassword, verifyPassword } from "./password";
import { credentialService } from "../credentials/credential.service";
import { permissionService } from "../permissions/permission.service";
async function toAuthUser(admin: SuperAdmin): Promise<AuthUser> {
@@ -54,6 +55,7 @@ async function toEmployeeAuthUser(
roles: employee.roles,
permissions,
canManage: permissions.some((permission) => permission.endsWith(":manage")),
mustChangePassword: employee.mustChangePassword,
};
}
@@ -212,4 +214,42 @@ export const authService = {
throw unauthorized("登录已失效,请重新登录");
},
async getCurrentEmployeeUser(payload: AuthJwtPayload): Promise<AuthUser> {
const user = await this.getCurrentUser(payload);
if (user.accountType !== "EMPLOYEE") {
throw forbidden("只有员工账号可以访问员工端接口");
}
return user;
},
async updateOwnPassword(
payload: AuthJwtPayload,
input: { oldPassword: string; newPassword: string },
meta: { ip?: string | null; userAgent?: string | null },
): Promise<AuthUser> {
if (payload.accountType !== "EMPLOYEE" || !payload.employeeId) {
throw forbidden("超级管理员暂不通过员工端接口修改密码");
}
const employee = await authRepository.findActiveEmployeeById(payload.employeeId);
if (!employee) {
throw unauthorized("登录已失效,请重新登录");
}
const passwordMatched = await verifyPassword(input.oldPassword, employee.passwordHash);
if (!passwordMatched) {
throw badRequest("旧密码不正确");
}
const passwordHash = await hashPassword(input.newPassword);
await authRepository.updateEmployeePasswordHash(employee.id, passwordHash);
const actor = await this.getCurrentEmployeeUser(payload);
await credentialService.recordOwnPasswordChanged(employee.id, actor, meta);
return this.getCurrentUser(payload);
},
};
+2
View File
@@ -32,6 +32,7 @@ export interface EmployeeLoginAccount {
storeId: number;
storeName: string;
storeStatus: "ACTIVE" | "INACTIVE";
mustChangePassword: boolean;
roles: Array<{
id: number;
code: string;
@@ -53,6 +54,7 @@ export interface AuthUser {
}>;
permissions: string[];
canManage: boolean;
mustChangePassword?: boolean;
}
export interface AuthJwtPayload {
@@ -0,0 +1,29 @@
import type { FastifyInstance } from "fastify";
import { ok, paginated } from "../../shared/response";
import { permissionGuard } from "../auth/auth.guard";
import { authService } from "../auth/auth.service";
import { PERMISSIONS } from "../permissions/permission.policy";
import { credentialEmployeeIdParamSchema, listCredentialAuditsQuerySchema, resetPasswordBodySchema } from "./credential.schema";
import { credentialService } from "./credential.service";
export async function credentialAdminRoutes(app: FastifyInstance): Promise<void> {
app.post("/admin/employees/:id/password/reset", { preHandler: permissionGuard(PERMISSIONS.CREDENTIAL_RESET) }, async (request) => {
const actor = await authService.getCurrentUser(request.user);
const params = credentialEmployeeIdParamSchema.parse(request.params);
const body = resetPasswordBodySchema.parse(request.body ?? {});
return ok(
await credentialService.resetEmployeePassword(params.id, actor, {
reason: body.reason,
ip: request.ip,
userAgent: request.headers["user-agent"] ?? null,
}),
);
});
app.get("/admin/credential-audits", { preHandler: permissionGuard(PERMISSIONS.CREDENTIAL_AUDIT_VIEW) }, async (request) => {
const actor = await authService.getCurrentUser(request.user);
const query = listCredentialAuditsQuerySchema.parse(request.query);
const result = await credentialService.listAudits(query, actor);
return paginated(result.items, query.page, query.pageSize, result.total);
});
}
@@ -0,0 +1,192 @@
import type { RowDataPacket } from "mysql2/promise";
import { pool } from "../../db/pool";
import type { AuthUser } from "../auth/auth.types";
import type { Employee } from "../employees/employee.types";
import type { CredentialAudit, ListCredentialAuditsQuery, ResetEmployeePasswordInput } from "./credential.types";
type SqlParam = string | number | null;
interface AuditRow extends RowDataPacket {
id: number;
actor_account_type: CredentialAudit["actorAccountType"];
actor_admin_id: number | null;
actor_employee_id: number | null;
actor_name: string | null;
target_employee_id: number;
target_employee_name: string;
target_employee_phone: string;
store_name: string | null;
action: CredentialAudit["action"];
reason: string | null;
ip: string | null;
user_agent: string | null;
created_at: Date;
}
interface CountRow extends RowDataPacket {
total: number;
}
function toIso(value: Date): string {
return value.toISOString();
}
function toAudit(row: AuditRow): CredentialAudit {
return {
id: row.id,
actorAccountType: row.actor_account_type,
actorAdminId: row.actor_admin_id,
actorEmployeeId: row.actor_employee_id,
actorName: row.actor_name,
targetEmployeeId: row.target_employee_id,
targetEmployeeName: row.target_employee_name,
targetEmployeePhone: row.target_employee_phone,
storeName: row.store_name,
action: row.action,
reason: row.reason,
ip: row.ip,
userAgent: row.user_agent,
createdAt: toIso(row.created_at),
};
}
function actorColumns(user: AuthUser): [CredentialAudit["actorAccountType"], number | null, number | null] {
return user.accountType === "SUPER_ADMIN" ? ["SUPER_ADMIN", user.id, null] : ["EMPLOYEE", null, user.id];
}
export const credentialRepository = {
async updatePasswordForReset(employeeId: number, passwordHash: string, actor: AuthUser): Promise<void> {
const [, adminId, actorEmployeeId] = actorColumns(actor);
const connection = await pool.getConnection();
try {
await connection.beginTransaction();
await connection.execute(
"UPDATE employees SET password_hash = ? WHERE id = ? AND deleted_at IS NULL",
[passwordHash, employeeId],
);
await connection.execute(
`
INSERT INTO employee_password_states
(employee_id, must_change_password, last_reset_at, last_reset_by_admin_id, last_reset_by_employee_id)
VALUES (?, 1, CURRENT_TIMESTAMP(3), ?, ?)
ON DUPLICATE KEY UPDATE
must_change_password = 1,
last_reset_at = CURRENT_TIMESTAMP(3),
last_reset_by_admin_id = VALUES(last_reset_by_admin_id),
last_reset_by_employee_id = VALUES(last_reset_by_employee_id)
`,
[employeeId, adminId, actorEmployeeId],
);
await connection.commit();
} catch (error) {
await connection.rollback();
throw error;
} finally {
connection.release();
}
},
async markOwnPasswordChanged(employeeId: number): Promise<void> {
await pool.execute(
`
INSERT INTO employee_password_states (employee_id, must_change_password)
VALUES (?, 0)
ON DUPLICATE KEY UPDATE must_change_password = 0
`,
[employeeId],
);
},
async recordAudit(
actor: AuthUser,
target: Employee,
action: CredentialAudit["action"],
input: ResetEmployeePasswordInput,
): Promise<void> {
const [actorAccountType, actorAdminId, actorEmployeeId] = actorColumns(actor);
await pool.execute(
`
INSERT INTO credential_audits
(actor_account_type, actor_admin_id, actor_employee_id, target_employee_id, action, reason, ip, user_agent)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`,
[
actorAccountType,
actorAdminId,
actorEmployeeId,
target.id,
action,
input.reason ?? null,
input.ip ?? null,
input.userAgent ?? null,
],
);
},
async listAudits(query: ListCredentialAuditsQuery): Promise<{ items: CredentialAudit[]; total: number }> {
const where: string[] = [];
const params: SqlParam[] = [];
if (query.operatorId !== undefined) {
where.push("(ca.actor_admin_id = ? OR ca.actor_employee_id = ?)");
params.push(query.operatorId, query.operatorId);
}
if (query.targetEmployeeId !== undefined) {
where.push("ca.target_employee_id = ?");
params.push(query.targetEmployeeId);
}
if (query.storeId !== undefined) {
where.push("te.store_id = ?");
params.push(query.storeId);
}
if (query.startDate) {
where.push("ca.created_at >= ?");
params.push(`${query.startDate} 00:00:00.000`);
}
if (query.endDate) {
where.push("ca.created_at < DATE_ADD(?, INTERVAL 1 DAY)");
params.push(`${query.endDate} 00:00:00.000`);
}
const whereSql = where.length > 0 ? `WHERE ${where.join(" AND ")}` : "";
const offset = (query.page - 1) * query.pageSize;
const [countRows] = await pool.execute<CountRow[]>(
`
SELECT COUNT(*) AS total
FROM credential_audits ca
INNER JOIN employees te ON te.id = ca.target_employee_id
${whereSql}
`,
params,
);
const [rows] = await pool.execute<AuditRow[]>(
`
SELECT
ca.*,
COALESCE(sa.display_name, ae.name) AS actor_name,
te.name AS target_employee_name,
te.phone AS target_employee_phone,
s.name AS store_name
FROM credential_audits ca
LEFT JOIN super_admins sa ON sa.id = ca.actor_admin_id
LEFT JOIN employees ae ON ae.id = ca.actor_employee_id
INNER JOIN employees te ON te.id = ca.target_employee_id
LEFT JOIN stores s ON s.id = te.store_id
${whereSql}
ORDER BY ca.id DESC
LIMIT ${query.pageSize} OFFSET ${offset}
`,
params,
);
return {
items: rows.map(toAudit),
total: countRows[0]?.total ?? 0,
};
},
};
@@ -0,0 +1,23 @@
import { z } from "zod";
const emptyStringToUndefined = (value: unknown) =>
typeof value === "string" && value.trim() === "" ? undefined : value;
const dateSchema = z.string().trim().regex(/^\d{4}-\d{2}-\d{2}$/, "日期格式应为 YYYY-MM-DD");
export const credentialEmployeeIdParamSchema = z.object({
id: z.coerce.number().int().positive(),
});
export const resetPasswordBodySchema = z.object({
reason: z.string().trim().max(500).nullable().optional(),
});
export const listCredentialAuditsQuerySchema = z.object({
operatorId: z.preprocess(emptyStringToUndefined, z.coerce.number().int().positive().optional()),
targetEmployeeId: z.preprocess(emptyStringToUndefined, z.coerce.number().int().positive().optional()),
storeId: z.preprocess(emptyStringToUndefined, z.coerce.number().int().positive().optional()),
startDate: z.preprocess(emptyStringToUndefined, dateSchema.optional()),
endDate: z.preprocess(emptyStringToUndefined, dateSchema.optional()),
page: z.coerce.number().int().min(1).default(1),
pageSize: z.coerce.number().int().min(1).max(100).default(20),
});
@@ -0,0 +1,86 @@
import { randomBytes } from "node:crypto";
import { badRequest, forbidden } from "../../shared/http-error";
import type { AuthUser } from "../auth/auth.types";
import { hashPassword } from "../auth/password";
import { employeeService } from "../employees/employee.service";
import type { Employee } from "../employees/employee.types";
import { hasAnyPermission, hasPermission, PERMISSIONS } from "../permissions/permission.policy";
import { credentialRepository } from "./credential.repository";
import type { ListCredentialAuditsQuery, ResetEmployeePasswordInput } from "./credential.types";
function generateTemporaryPassword(): string {
return `Tmp-${randomBytes(9).toString("base64url")}9`;
}
function assertCanReset(actor: AuthUser, target: Employee): void {
if (!hasPermission(actor.permissions, PERMISSIONS.CREDENTIAL_RESET)) {
throw forbidden("当前账号没有重置密码权限");
}
if (actor.accountType === "EMPLOYEE" && actor.id === target.id) {
throw badRequest("本人改密请使用修改本人密码接口");
}
if (hasPermission(actor.permissions, PERMISSIONS.EMPLOYEE_VIEW_ALL)) {
return;
}
if (
hasPermission(actor.permissions, PERMISSIONS.EMPLOYEE_VIEW_STORE) &&
actor.storeId === target.storeId
) {
return;
}
throw forbidden("当前账号无权重置该员工密码");
}
function assertCanViewAudit(actor: AuthUser): void {
if (!hasPermission(actor.permissions, PERMISSIONS.CREDENTIAL_AUDIT_VIEW)) {
throw forbidden("当前账号没有查看凭据审计权限");
}
if (
!hasAnyPermission(actor.permissions, [
PERMISSIONS.EMPLOYEE_VIEW_ALL,
PERMISSIONS.EMPLOYEE_VIEW_STORE,
])
) {
throw forbidden("当前账号缺少员工数据范围权限");
}
}
export const credentialService = {
async resetEmployeePassword(employeeId: number, actor: AuthUser, input: ResetEmployeePasswordInput) {
const target = await employeeService.getById(employeeId);
assertCanReset(actor, target);
const temporaryPassword = generateTemporaryPassword();
const passwordHash = await hashPassword(temporaryPassword);
await credentialRepository.updatePasswordForReset(employeeId, passwordHash, actor);
await credentialRepository.recordAudit(actor, target, "RESET_PASSWORD", input);
return {
employee: await employeeService.getById(employeeId),
temporaryPassword,
mustChangePassword: true,
};
},
async recordOwnPasswordChanged(employeeId: number, actor: AuthUser, input: ResetEmployeePasswordInput) {
const target = await employeeService.getById(employeeId);
await credentialRepository.markOwnPasswordChanged(employeeId);
await credentialRepository.recordAudit(actor, target, "CHANGE_OWN_PASSWORD", input);
},
listAudits(query: ListCredentialAuditsQuery, actor: AuthUser) {
assertCanViewAudit(actor);
const scopedQuery =
hasPermission(actor.permissions, PERMISSIONS.EMPLOYEE_VIEW_STORE) &&
!hasPermission(actor.permissions, PERMISSIONS.EMPLOYEE_VIEW_ALL)
? { ...query, storeId: actor.storeId }
: query;
return credentialRepository.listAudits(scopedQuery);
},
};
@@ -0,0 +1,32 @@
export interface CredentialAudit {
id: number;
actorAccountType: "SUPER_ADMIN" | "EMPLOYEE";
actorAdminId: number | null;
actorEmployeeId: number | null;
actorName: string | null;
targetEmployeeId: number;
targetEmployeeName: string;
targetEmployeePhone: string;
storeName: string | null;
action: "RESET_PASSWORD" | "CHANGE_OWN_PASSWORD";
reason: string | null;
ip: string | null;
userAgent: string | null;
createdAt: string;
}
export interface ListCredentialAuditsQuery {
operatorId?: number;
targetEmployeeId?: number;
storeId?: number;
startDate?: string;
endDate?: string;
page: number;
pageSize: number;
}
export interface ResetEmployeePasswordInput {
reason?: string | null;
ip?: string | null;
userAgent?: string | null;
}
+9 -2
View File
@@ -2,6 +2,7 @@ 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 { credentialService } from "../credentials/credential.service";
import {
hasAnyPermission,
hasPermission,
@@ -137,10 +138,16 @@ export async function employeeRoutes(app: FastifyInstance): Promise<void> {
app.patch("/employees/:id/password/reset", async (request) => {
const user = await authService.getCurrentUser(request.user);
assertCanManageEmployees(user);
if (!hasPermission(user.permissions, PERMISSIONS.CREDENTIAL_RESET)) {
throw forbidden("当前账号没有重置密码权限");
}
const params = idParamSchema.parse(request.params);
const employee = await employeeService.resetPassword(params.id);
const employee = await credentialService.resetEmployeePassword(params.id, user, {
reason: "LEGACY_EMPLOYEE_RESET_ENDPOINT",
ip: request.ip,
userAgent: request.headers["user-agent"] ?? null,
});
return ok(employee);
});
+61
View File
@@ -0,0 +1,61 @@
import type { FastifyInstance } from "fastify";
import { ok } from "../../shared/response";
import { authGuard } from "../auth/auth.guard";
import { authService } from "../auth/auth.service";
import { getVisibleMenus } from "../permissions/permission.policy";
import { announcementService } from "../announcements/announcement.service";
import { taskService } from "../tasks/task.service";
import { shiftService } from "../shifts/shift.service";
export async function mobileRoutes(app: FastifyInstance): Promise<void> {
app.get("/mobile/bootstrap", { preHandler: authGuard }, async (request) => {
const user = await authService.getCurrentEmployeeUser(request.user);
const [
unreadAnnouncementCount,
pendingTaskCount,
overdueTaskCount,
latestAnnouncementsResult,
tasks,
todayShifts,
] = await Promise.all([
announcementService.countUnreadForEmployee(user),
taskService.countPendingForEmployee(user),
taskService.countOverdueForEmployee(user),
announcementService.listVisibleForEmployee(user, { page: 1, pageSize: 3 }),
taskService.listOpenForEmployee(user, 5),
shiftService.todayForEmployee(user),
]);
return ok({
user: {
id: user.id,
username: user.username,
displayName: user.displayName,
accountType: user.accountType,
storeId: user.storeId,
storeName: user.storeName,
roles: user.roles,
permissions: user.permissions,
mustChangePassword: user.mustChangePassword ?? false,
},
store: user.storeId
? {
id: user.storeId,
name: user.storeName ?? "",
}
: null,
permissions: {
codes: user.permissions,
menus: getVisibleMenus(user),
},
counters: {
unreadAnnouncementCount,
pendingTaskCount,
overdueTaskCount,
},
latestAnnouncements: latestAnnouncementsResult.items,
tasks,
todayShifts,
});
});
}
@@ -10,6 +10,14 @@ export const PERMISSIONS = {
EMPLOYEE_MANAGE: "employee:manage",
PERMISSION_VIEW: "permission:view",
PERMISSION_MANAGE: "permission:manage",
ANNOUNCEMENT_VIEW: "announcement:view",
ANNOUNCEMENT_MANAGE: "announcement:manage",
TASK_VIEW: "task:view",
TASK_MANAGE: "task:manage",
SHIFT_VIEW: "shift:view",
SHIFT_MANAGE: "shift:manage",
CREDENTIAL_RESET: "credential:reset",
CREDENTIAL_AUDIT_VIEW: "credential:audit:view",
} as const;
export type PermissionCode = (typeof PERMISSIONS)[keyof typeof PERMISSIONS];
@@ -41,6 +49,10 @@ const ACTION_LABELS: Record<string, string> = {
create: "新增",
update: "编辑",
delete: "删除",
publish: "发布",
archive: "归档",
cancel: "取消",
reset: "重置",
};
const MENUS: PermissionMenu[] = [
@@ -69,6 +81,34 @@ const MENUS: PermissionMenu[] = [
permission: PERMISSIONS.PERMISSION_VIEW,
actions: ["view", "update"],
},
{
key: "announcements",
title: "公告管理",
icon: "megaphone",
permission: PERMISSIONS.ANNOUNCEMENT_VIEW,
actions: ["view", "create", "update", "publish", "archive"],
},
{
key: "tasks",
title: "任务管理",
icon: "clipboard-list",
permission: PERMISSIONS.TASK_VIEW,
actions: ["view", "create", "update", "cancel"],
},
{
key: "shifts",
title: "排班管理",
icon: "calendar-clock",
permission: PERMISSIONS.SHIFT_VIEW,
actions: ["view", "create", "update", "delete"],
},
{
key: "credentials",
title: "凭据安全",
icon: "shield-keyhole",
permission: PERMISSIONS.CREDENTIAL_AUDIT_VIEW,
actions: ["view", "reset"],
},
];
const PERMISSION_DEFINITIONS: PermissionDefinition[] = [
@@ -135,6 +175,62 @@ const PERMISSION_DEFINITIONS: PermissionDefinition[] = [
groupKey: "permissions",
groupTitle: "权限管理",
},
{
code: PERMISSIONS.ANNOUNCEMENT_VIEW,
title: "查看公告",
description: "查看后台公告列表和公告详情。",
groupKey: "announcements",
groupTitle: "公告管理",
},
{
code: PERMISSIONS.ANNOUNCEMENT_MANAGE,
title: "管理公告",
description: "新增、编辑、发布和归档公告。",
groupKey: "announcements",
groupTitle: "公告管理",
},
{
code: PERMISSIONS.TASK_VIEW,
title: "查看任务",
description: "查看后台任务列表和任务详情。",
groupKey: "tasks",
groupTitle: "任务管理",
},
{
code: PERMISSIONS.TASK_MANAGE,
title: "管理任务",
description: "新建、编辑和取消任务。",
groupKey: "tasks",
groupTitle: "任务管理",
},
{
code: PERMISSIONS.SHIFT_VIEW,
title: "查看排班",
description: "查看后台排班列表和排班详情。",
groupKey: "shifts",
groupTitle: "排班管理",
},
{
code: PERMISSIONS.SHIFT_MANAGE,
title: "管理排班",
description: "新增、编辑和取消排班。",
groupKey: "shifts",
groupTitle: "排班管理",
},
{
code: PERMISSIONS.CREDENTIAL_RESET,
title: "重置凭据",
description: "在权限范围内重置下级员工临时密码。",
groupKey: "credentials",
groupTitle: "凭据安全",
},
{
code: PERMISSIONS.CREDENTIAL_AUDIT_VIEW,
title: "查看凭据审计",
description: "查看密码重置等凭据操作审计记录。",
groupKey: "credentials",
groupTitle: "凭据安全",
},
];
const PERMISSION_ORDER = new Map(
@@ -148,6 +244,10 @@ const PERMISSION_DEPENDENCIES: Partial<
[PERMISSIONS.ROLE_MANAGE]: [PERMISSIONS.ROLE_VIEW],
[PERMISSIONS.EMPLOYEE_MANAGE]: [PERMISSIONS.EMPLOYEE_VIEW_ALL],
[PERMISSIONS.PERMISSION_MANAGE]: [PERMISSIONS.PERMISSION_VIEW],
[PERMISSIONS.ANNOUNCEMENT_MANAGE]: [PERMISSIONS.ANNOUNCEMENT_VIEW],
[PERMISSIONS.TASK_MANAGE]: [PERMISSIONS.TASK_VIEW],
[PERMISSIONS.SHIFT_MANAGE]: [PERMISSIONS.SHIFT_VIEW],
[PERMISSIONS.CREDENTIAL_RESET]: [PERMISSIONS.EMPLOYEE_VIEW_STORE],
};
export function isPermissionCode(value: string): value is PermissionCode {
@@ -260,6 +360,46 @@ export function getAllowedActions(user: AuthUser, menuKey: string): string[] {
: [];
}
if (menuKey === "announcements") {
if (hasPermission(user.permissions, PERMISSIONS.ANNOUNCEMENT_MANAGE)) {
return ["view", "create", "update", "publish", "archive"];
}
return hasPermission(user.permissions, PERMISSIONS.ANNOUNCEMENT_VIEW)
? ["view"]
: [];
}
if (menuKey === "tasks") {
if (hasPermission(user.permissions, PERMISSIONS.TASK_MANAGE)) {
return ["view", "create", "update", "cancel"];
}
return hasPermission(user.permissions, PERMISSIONS.TASK_VIEW) ? ["view"] : [];
}
if (menuKey === "shifts") {
if (hasPermission(user.permissions, PERMISSIONS.SHIFT_MANAGE)) {
return ["view", "create", "update", "delete"];
}
return hasPermission(user.permissions, PERMISSIONS.SHIFT_VIEW) ? ["view"] : [];
}
if (menuKey === "credentials") {
const actions: string[] = [];
if (hasPermission(user.permissions, PERMISSIONS.CREDENTIAL_AUDIT_VIEW)) {
actions.push("view");
}
if (hasPermission(user.permissions, PERMISSIONS.CREDENTIAL_RESET)) {
actions.push("reset");
}
return actions;
}
return [];
}
+48
View File
@@ -0,0 +1,48 @@
import type { FastifyInstance } from "fastify";
import { created, ok, paginated } from "../../shared/response";
import { authGuard, permissionGuard } from "../auth/auth.guard";
import { authService } from "../auth/auth.service";
import { PERMISSIONS } from "../permissions/permission.policy";
import { createShiftBodySchema, listShiftsQuerySchema, shiftIdParamSchema, updateShiftBodySchema } from "./shift.schema";
import { shiftService } from "./shift.service";
export async function shiftAdminRoutes(app: FastifyInstance): Promise<void> {
app.get("/admin/shifts", { preHandler: permissionGuard(PERMISSIONS.SHIFT_VIEW) }, async (request) => {
const query = listShiftsQuerySchema.parse(request.query);
const result = await shiftService.list(query);
return paginated(result.items, query.page, query.pageSize, result.total);
});
app.post("/admin/shifts", { preHandler: permissionGuard(PERMISSIONS.SHIFT_MANAGE) }, async (request, reply) => {
const user = await authService.getCurrentUser(request.user);
const body = createShiftBodySchema.parse(request.body);
const shift = await shiftService.create(body, user);
return reply.code(201).send(created(shift));
});
app.patch("/admin/shifts/:id", { preHandler: permissionGuard(PERMISSIONS.SHIFT_MANAGE) }, async (request) => {
const params = shiftIdParamSchema.parse(request.params);
const body = updateShiftBodySchema.parse(request.body);
return ok(await shiftService.update(params.id, body));
});
app.delete("/admin/shifts/:id", { preHandler: permissionGuard(PERMISSIONS.SHIFT_MANAGE) }, async (request, reply) => {
const params = shiftIdParamSchema.parse(request.params);
await shiftService.cancel(params.id);
return reply.code(204).send();
});
}
export async function shiftMobileRoutes(app: FastifyInstance): Promise<void> {
app.get("/mobile/shifts/today", { preHandler: authGuard }, async (request) => {
const employee = await authService.getCurrentEmployeeUser(request.user);
return ok(await shiftService.todayForEmployee(employee));
});
app.get("/mobile/shifts", { preHandler: authGuard }, async (request) => {
const employee = await authService.getCurrentEmployeeUser(request.user);
const query = listShiftsQuerySchema.parse(request.query);
const result = await shiftService.listForEmployee(employee, query);
return paginated(result.items, query.page, query.pageSize, result.total);
});
}
+254
View File
@@ -0,0 +1,254 @@
import type { ResultSetHeader, RowDataPacket } from "mysql2/promise";
import { pool } from "../../db/pool";
import type { AuthUser } from "../auth/auth.types";
import type { CreateShiftInput, ListShiftsQuery, Shift, UpdateShiftInput } from "./shift.types";
type SqlParam = string | number | Date | null;
interface ShiftRow extends RowDataPacket {
id: number;
store_id: number;
store_name: string;
employee_id: number;
employee_name: string;
role_name: string | null;
start_at: Date;
end_at: Date;
status: Shift["status"];
created_at: Date;
updated_at: Date;
}
interface CountRow extends RowDataPacket {
total: number;
}
function toIso(value: Date): string {
return value.toISOString();
}
function toShift(row: ShiftRow): Shift {
return {
id: row.id,
storeId: row.store_id,
storeName: row.store_name,
employeeId: row.employee_id,
employeeName: row.employee_name,
roleName: row.role_name,
startAt: toIso(row.start_at),
endAt: toIso(row.end_at),
status: row.status,
createdAt: toIso(row.created_at),
updatedAt: toIso(row.updated_at),
};
}
function actorValues(user: AuthUser): [number | null, number | null] {
return user.accountType === "SUPER_ADMIN" ? [user.id, null] : [null, user.id];
}
function buildWhere(query: ListShiftsQuery): { whereSql: string; params: SqlParam[] } {
const where = ["sh.deleted_at IS NULL"];
const params: SqlParam[] = [];
if (query.storeId !== undefined) {
where.push("sh.store_id = ?");
params.push(query.storeId);
}
if (query.employeeId !== undefined) {
where.push("sh.employee_id = ?");
params.push(query.employeeId);
}
if (query.status) {
where.push("sh.status = ?");
params.push(query.status);
}
if (query.startDate) {
where.push("sh.start_at >= ?");
params.push(`${query.startDate} 00:00:00.000`);
}
if (query.endDate) {
where.push("sh.start_at < DATE_ADD(?, INTERVAL 1 DAY)");
params.push(`${query.endDate} 00:00:00.000`);
}
return { whereSql: where.join(" AND "), params };
}
export const shiftRepository = {
async employeeBelongsToStore(employeeId: number, storeId: number): Promise<boolean> {
const [rows] = await pool.execute<CountRow[]>(
`
SELECT COUNT(*) AS total
FROM employees
WHERE id = ?
AND store_id = ?
AND status = 'ACTIVE'
AND deleted_at IS NULL
`,
[employeeId, storeId],
);
return (rows[0]?.total ?? 0) > 0;
},
async hasOverlappingShift(
employeeId: number,
startAt: string,
endAt: string,
excludeShiftId?: number,
): Promise<boolean> {
const params: SqlParam[] = [employeeId, new Date(endAt), new Date(startAt)];
const excludeSql = excludeShiftId === undefined ? "" : "AND id <> ?";
if (excludeShiftId !== undefined) {
params.push(excludeShiftId);
}
const [rows] = await pool.execute<CountRow[]>(
`
SELECT COUNT(*) AS total
FROM shifts
WHERE employee_id = ?
AND deleted_at IS NULL
AND status <> 'CANCELLED'
AND start_at < ?
AND end_at > ?
${excludeSql}
`,
params,
);
return (rows[0]?.total ?? 0) > 0;
},
async list(query: ListShiftsQuery): Promise<{ items: Shift[]; total: number }> {
const { whereSql, params } = buildWhere(query);
const offset = (query.page - 1) * query.pageSize;
const [countRows] = await pool.execute<CountRow[]>(
`
SELECT COUNT(*) AS total
FROM shifts sh
INNER JOIN stores s ON s.id = sh.store_id
INNER JOIN employees e ON e.id = sh.employee_id
WHERE ${whereSql}
`,
params,
);
const [rows] = await pool.execute<ShiftRow[]>(
`
SELECT sh.*, s.name AS store_name, e.name AS employee_name
FROM shifts sh
INNER JOIN stores s ON s.id = sh.store_id
INNER JOIN employees e ON e.id = sh.employee_id
WHERE ${whereSql}
ORDER BY sh.start_at ASC, sh.id ASC
LIMIT ${query.pageSize} OFFSET ${offset}
`,
params,
);
return {
items: rows.map(toShift),
total: countRows[0]?.total ?? 0,
};
},
async findById(id: number): Promise<Shift | null> {
const [rows] = await pool.execute<ShiftRow[]>(
`
SELECT sh.*, s.name AS store_name, e.name AS employee_name
FROM shifts sh
INNER JOIN stores s ON s.id = sh.store_id
INNER JOIN employees e ON e.id = sh.employee_id
WHERE sh.id = ? AND sh.deleted_at IS NULL
LIMIT 1
`,
[id],
);
return rows[0] ? toShift(rows[0]) : null;
},
async create(input: CreateShiftInput, user: AuthUser): Promise<number> {
const [creatorAdminId, creatorEmployeeId] = actorValues(user);
const [result] = await pool.execute<ResultSetHeader>(
`
INSERT INTO shifts
(store_id, employee_id, role_name, start_at, end_at, status, creator_admin_id, creator_employee_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`,
[
input.storeId,
input.employeeId,
input.roleName ?? null,
new Date(input.startAt),
new Date(input.endAt),
input.status,
creatorAdminId,
creatorEmployeeId,
],
);
return result.insertId;
},
async update(id: number, input: UpdateShiftInput): Promise<void> {
const fieldMap: Array<[keyof UpdateShiftInput, string]> = [
["storeId", "store_id"],
["employeeId", "employee_id"],
["roleName", "role_name"],
["startAt", "start_at"],
["endAt", "end_at"],
["status", "status"],
];
const sets: string[] = [];
const params: SqlParam[] = [];
for (const [key, column] of fieldMap) {
if (Object.prototype.hasOwnProperty.call(input, key)) {
sets.push(`${column} = ?`);
params.push((key === "startAt" || key === "endAt") && input[key] ? new Date(input[key]) : (input[key] as SqlParam));
}
}
if (sets.length === 0) {
return;
}
params.push(id);
await pool.execute(
`UPDATE shifts SET ${sets.join(", ")} WHERE id = ? AND deleted_at IS NULL`,
params,
);
},
async cancel(id: number): Promise<void> {
await pool.execute(
`
UPDATE shifts
SET status = 'CANCELLED', deleted_at = CURRENT_TIMESTAMP(3)
WHERE id = ? AND deleted_at IS NULL
`,
[id],
);
},
async listTodayForEmployee(employeeId: number): Promise<Shift[]> {
const [rows] = await pool.execute<ShiftRow[]>(
`
SELECT sh.*, s.name AS store_name, e.name AS employee_name
FROM shifts sh
INNER JOIN stores s ON s.id = sh.store_id
INNER JOIN employees e ON e.id = sh.employee_id
WHERE sh.employee_id = ?
AND sh.deleted_at IS NULL
AND sh.status = 'SCHEDULED'
AND DATE(sh.start_at) = CURRENT_DATE()
ORDER BY sh.start_at ASC
`,
[employeeId],
);
return rows.map(toShift);
},
};
+50
View File
@@ -0,0 +1,50 @@
import { z } from "zod";
import { SHIFT_STATUSES } from "./shift.types";
const emptyStringToUndefined = (value: unknown) =>
typeof value === "string" && value.trim() === "" ? undefined : value;
const dateTimeSchema = z.string().trim().datetime({ offset: true });
const dateSchema = z.string().trim().regex(/^\d{4}-\d{2}-\d{2}$/, "日期格式应为 YYYY-MM-DD");
export const shiftIdParamSchema = z.object({
id: z.coerce.number().int().positive(),
});
export const listShiftsQuerySchema = z.object({
storeId: z.preprocess(emptyStringToUndefined, z.coerce.number().int().positive().optional()),
employeeId: z.preprocess(emptyStringToUndefined, z.coerce.number().int().positive().optional()),
status: z.preprocess(emptyStringToUndefined, z.enum(SHIFT_STATUSES).optional()),
startDate: z.preprocess(emptyStringToUndefined, dateSchema.optional()),
endDate: z.preprocess(emptyStringToUndefined, dateSchema.optional()),
page: z.coerce.number().int().min(1).default(1),
pageSize: z.coerce.number().int().min(1).max(100).default(20),
});
const shiftBodyBaseSchema = z.object({
storeId: z.coerce.number().int().positive(),
employeeId: z.coerce.number().int().positive(),
roleName: z.string().trim().max(50).nullable().optional(),
startAt: dateTimeSchema,
endAt: dateTimeSchema,
status: z.enum(SHIFT_STATUSES).default("SCHEDULED"),
});
export const createShiftBodySchema = shiftBodyBaseSchema.refine(
(value) => new Date(value.endAt).getTime() > new Date(value.startAt).getTime(),
{
message: "结束时间必须晚于开始时间",
},
);
export const updateShiftBodySchema = shiftBodyBaseSchema
.partial()
.refine((value) => Object.keys(value).length > 0, {
message: "至少需要提交一个要修改的字段",
})
.refine(
(value) =>
!value.startAt ||
!value.endAt ||
new Date(value.endAt).getTime() > new Date(value.startAt).getTime(),
{ message: "结束时间必须晚于开始时间" },
);
+95
View File
@@ -0,0 +1,95 @@
import { badRequest, conflict, notFound } from "../../shared/http-error";
import type { AuthUser } from "../auth/auth.types";
import { shiftRepository } from "./shift.repository";
import type { CreateShiftInput, ListShiftsQuery, UpdateShiftInput } from "./shift.types";
async function assertEmployeeStore(employeeId: number, storeId: number): Promise<void> {
const matched = await shiftRepository.employeeBelongsToStore(employeeId, storeId);
if (!matched) {
throw badRequest("排班员工不存在、已停用或不属于该门店");
}
}
function assertTimeRange(startAt: string, endAt: string): void {
if (new Date(endAt).getTime() <= new Date(startAt).getTime()) {
throw badRequest("结束时间必须晚于开始时间");
}
}
async function assertNoOverlap(
employeeId: number,
startAt: string,
endAt: string,
excludeShiftId?: number,
): Promise<void> {
const overlapped = await shiftRepository.hasOverlappingShift(employeeId, startAt, endAt, excludeShiftId);
if (overlapped) {
throw conflict("该员工在该时间段已有排班");
}
}
export const shiftService = {
list(query: ListShiftsQuery) {
return shiftRepository.list(query);
},
async getById(id: number) {
const shift = await shiftRepository.findById(id);
if (!shift) {
throw notFound("排班不存在");
}
return shift;
},
async create(input: CreateShiftInput, user: AuthUser) {
await assertEmployeeStore(input.employeeId, input.storeId);
assertTimeRange(input.startAt, input.endAt);
if (input.status !== "CANCELLED") {
await assertNoOverlap(input.employeeId, input.startAt, input.endAt);
}
const id = await shiftRepository.create(input, user);
return this.getById(id);
},
async update(id: number, input: UpdateShiftInput) {
const current = await this.getById(id);
const employeeId = input.employeeId ?? current.employeeId;
const storeId = input.storeId ?? current.storeId;
await assertEmployeeStore(employeeId, storeId);
const startAt = input.startAt ?? current.startAt;
const endAt = input.endAt ?? current.endAt;
const status = input.status ?? current.status;
assertTimeRange(startAt, endAt);
if (status !== "CANCELLED") {
await assertNoOverlap(employeeId, startAt, endAt, id);
}
await shiftRepository.update(id, input);
return this.getById(id);
},
async cancel(id: number) {
await this.getById(id);
await shiftRepository.cancel(id);
},
listForEmployee(employee: AuthUser, query: ListShiftsQuery) {
return shiftRepository.list({
...query,
employeeId: employee.id,
storeId: undefined,
});
},
todayForEmployee(employee: AuthUser) {
return shiftRepository.listTodayForEmployee(employee.id);
},
};
+37
View File
@@ -0,0 +1,37 @@
export const SHIFT_STATUSES = ["SCHEDULED", "CANCELLED"] as const;
export type ShiftStatus = (typeof SHIFT_STATUSES)[number];
export interface Shift {
id: number;
storeId: number;
storeName: string;
employeeId: number;
employeeName: string;
roleName: string | null;
startAt: string;
endAt: string;
status: ShiftStatus;
createdAt: string;
updatedAt: string;
}
export interface ListShiftsQuery {
storeId?: number;
employeeId?: number;
status?: ShiftStatus;
startDate?: string;
endDate?: string;
page: number;
pageSize: number;
}
export interface CreateShiftInput {
storeId: number;
employeeId: number;
roleName?: string | null;
startAt: string;
endAt: string;
status: ShiftStatus;
}
export type UpdateShiftInput = Partial<CreateShiftInput>;
+69
View File
@@ -0,0 +1,69 @@
import type { FastifyInstance } from "fastify";
import { created, ok, paginated } from "../../shared/response";
import { authGuard, permissionGuard } from "../auth/auth.guard";
import { authService } from "../auth/auth.service";
import { PERMISSIONS } from "../permissions/permission.policy";
import { createTaskBodySchema, listTasksQuerySchema, taskCommentBodySchema, taskIdParamSchema, updateTaskBodySchema } from "./task.schema";
import { taskService } from "./task.service";
export async function taskAdminRoutes(app: FastifyInstance): Promise<void> {
app.get("/admin/tasks", { preHandler: permissionGuard(PERMISSIONS.TASK_VIEW) }, async (request) => {
const query = listTasksQuerySchema.parse(request.query);
const result = await taskService.list(query);
return paginated(result.items, query.page, query.pageSize, result.total);
});
app.post("/admin/tasks", { preHandler: permissionGuard(PERMISSIONS.TASK_MANAGE) }, async (request, reply) => {
const user = await authService.getCurrentUser(request.user);
const body = createTaskBodySchema.parse(request.body);
const task = await taskService.create(body, user);
return reply.code(201).send(created(task));
});
app.patch("/admin/tasks/:id", { preHandler: permissionGuard(PERMISSIONS.TASK_MANAGE) }, async (request) => {
const user = await authService.getCurrentUser(request.user);
const params = taskIdParamSchema.parse(request.params);
const body = updateTaskBodySchema.parse(request.body);
return ok(await taskService.update(params.id, body, user));
});
app.post("/admin/tasks/:id/cancel", { preHandler: permissionGuard(PERMISSIONS.TASK_MANAGE) }, async (request) => {
const user = await authService.getCurrentUser(request.user);
const params = taskIdParamSchema.parse(request.params);
return ok(await taskService.cancel(params.id, user));
});
}
export async function taskMobileRoutes(app: FastifyInstance): Promise<void> {
app.get("/mobile/tasks", { preHandler: authGuard }, async (request) => {
const employee = await authService.getCurrentEmployeeUser(request.user);
const query = listTasksQuerySchema.parse(request.query);
const result = await taskService.listForEmployee(employee, query);
return paginated(result.items, query.page, query.pageSize, result.total);
});
app.get("/mobile/tasks/:id", { preHandler: authGuard }, async (request) => {
const employee = await authService.getCurrentEmployeeUser(request.user);
const params = taskIdParamSchema.parse(request.params);
return ok(await taskService.getForEmployee(params.id, employee));
});
app.post("/mobile/tasks/:id/start", { preHandler: authGuard }, async (request) => {
const employee = await authService.getCurrentEmployeeUser(request.user);
const params = taskIdParamSchema.parse(request.params);
return ok(await taskService.start(params.id, employee));
});
app.post("/mobile/tasks/:id/complete", { preHandler: authGuard }, async (request) => {
const employee = await authService.getCurrentEmployeeUser(request.user);
const params = taskIdParamSchema.parse(request.params);
return ok(await taskService.complete(params.id, employee));
});
app.post("/mobile/tasks/:id/comment", { preHandler: authGuard }, async (request) => {
const employee = await authService.getCurrentEmployeeUser(request.user);
const params = taskIdParamSchema.parse(request.params);
const body = taskCommentBodySchema.parse(request.body);
return ok(await taskService.comment(params.id, employee, body.comment));
});
}
+423
View File
@@ -0,0 +1,423 @@
import type { PoolConnection, ResultSetHeader, RowDataPacket } from "mysql2/promise";
import { pool } from "../../db/pool";
import type { AuthUser } from "../auth/auth.types";
import type { CreateTaskInput, ListTasksQuery, Task, TaskEvent, TaskStatus, UpdateTaskInput } from "./task.types";
type DbExecutor = typeof pool | PoolConnection;
type SqlParam = string | number | Date | null;
interface TaskRow extends RowDataPacket {
id: number;
store_id: number | null;
store_name: string | null;
title: string;
description: string | null;
status: TaskStatus;
priority: Task["priority"];
due_at: Date | null;
created_at: Date;
updated_at: Date;
}
interface AssigneeRow extends RowDataPacket {
task_id: number;
id: number;
name: string;
phone: string;
}
interface EventRow extends RowDataPacket {
id: number;
task_id: number;
event_type: TaskEvent["eventType"];
from_status: TaskStatus | null;
to_status: TaskStatus | null;
comment: string | null;
created_at: Date;
}
interface CountRow extends RowDataPacket {
total: number;
}
function toIso(value: Date | null): string | null {
return value ? value.toISOString() : null;
}
function toTask(row: TaskRow, assignees: Task["assignees"] = []): Task {
return {
id: row.id,
storeId: row.store_id,
storeName: row.store_name,
title: row.title,
description: row.description,
status: row.status,
priority: row.priority,
dueAt: toIso(row.due_at),
assignees,
createdAt: toIso(row.created_at) ?? "",
updatedAt: toIso(row.updated_at) ?? "",
};
}
function actorValues(user: AuthUser): [number | null, number | null] {
return user.accountType === "SUPER_ADMIN" ? [user.id, null] : [null, user.id];
}
async function findAssigneesByTaskIds(ids: number[], db: DbExecutor = pool) {
const result = new Map<number, Task["assignees"]>();
if (ids.length === 0) {
return result;
}
const [rows] = await db.execute<AssigneeRow[]>(
`
SELECT ta.task_id, e.id, e.name, e.phone
FROM task_assignees ta
INNER JOIN employees e ON e.id = ta.employee_id
WHERE ta.task_id IN (${ids.map(() => "?").join(", ")})
ORDER BY e.id ASC
`,
ids,
);
for (const row of rows) {
const assignees = result.get(row.task_id) ?? [];
assignees.push({ id: row.id, name: row.name, phone: row.phone });
result.set(row.task_id, assignees);
}
return result;
}
export const taskRepository = {
async withTransaction<T>(handler: (connection: PoolConnection) => Promise<T>): Promise<T> {
const connection = await pool.getConnection();
try {
await connection.beginTransaction();
const result = await handler(connection);
await connection.commit();
return result;
} catch (error) {
await connection.rollback();
throw error;
} finally {
connection.release();
}
},
async employeeIdsExist(ids: number[], db: DbExecutor = pool): Promise<number[]> {
if (ids.length === 0) {
return [];
}
const [rows] = await db.execute<(RowDataPacket & { id: number })[]>(
`
SELECT id
FROM employees
WHERE id IN (${ids.map(() => "?").join(", ")})
AND deleted_at IS NULL
AND status = 'ACTIVE'
`,
ids,
);
return rows.map((row) => row.id);
},
async list(query: ListTasksQuery): Promise<{ items: Task[]; total: number }> {
const where = ["t.deleted_at IS NULL"];
const params: SqlParam[] = [];
if (query.storeId !== undefined) {
where.push("t.store_id = ?");
params.push(query.storeId);
}
if (query.status) {
where.push("t.status = ?");
params.push(query.status);
}
if (query.keyword) {
where.push("(t.title LIKE ? OR t.description LIKE ?)");
params.push(`%${query.keyword}%`, `%${query.keyword}%`);
}
const whereSql = where.join(" AND ");
const offset = (query.page - 1) * query.pageSize;
const [countRows] = await pool.execute<CountRow[]>(
`
SELECT COUNT(*) AS total
FROM tasks t
LEFT JOIN stores s ON s.id = t.store_id
WHERE ${whereSql}
`,
params,
);
const [rows] = await pool.execute<TaskRow[]>(
`
SELECT t.*, s.name AS store_name
FROM tasks t
LEFT JOIN stores s ON s.id = t.store_id
WHERE ${whereSql}
ORDER BY t.id DESC
LIMIT ${query.pageSize} OFFSET ${offset}
`,
params,
);
const assigneesByTaskId = await findAssigneesByTaskIds(rows.map((row) => row.id));
return {
items: rows.map((row) => toTask(row, assigneesByTaskId.get(row.id) ?? [])),
total: countRows[0]?.total ?? 0,
};
},
async findById(id: number, db: DbExecutor = pool): Promise<Task | null> {
const [rows] = await db.execute<TaskRow[]>(
`
SELECT t.*, s.name AS store_name
FROM tasks t
LEFT JOIN stores s ON s.id = t.store_id
WHERE t.id = ? AND t.deleted_at IS NULL
LIMIT 1
`,
[id],
);
if (!rows[0]) {
return null;
}
const assigneesByTaskId = await findAssigneesByTaskIds([id], db);
return toTask(rows[0], assigneesByTaskId.get(id) ?? []);
},
async create(input: CreateTaskInput, user: AuthUser, db: DbExecutor = pool): Promise<number> {
const [creatorAdminId, creatorEmployeeId] = actorValues(user);
const [result] = await db.execute<ResultSetHeader>(
`
INSERT INTO tasks
(store_id, title, description, priority, due_at, creator_admin_id, creator_employee_id)
VALUES (?, ?, ?, ?, ?, ?, ?)
`,
[
input.storeId ?? null,
input.title,
input.description ?? null,
input.priority,
input.dueAt ? new Date(input.dueAt) : null,
creatorAdminId,
creatorEmployeeId,
],
);
await this.replaceAssignees(result.insertId, input.assigneeIds, db);
await this.addEvent(result.insertId, "CREATED", null, "PENDING", null, user, null, db);
return result.insertId;
},
async update(id: number, input: UpdateTaskInput, user: AuthUser, db: DbExecutor = pool): Promise<void> {
const sets: string[] = [];
const params: SqlParam[] = [];
const fieldMap: Array<[keyof UpdateTaskInput, string]> = [
["storeId", "store_id"],
["title", "title"],
["description", "description"],
["priority", "priority"],
["dueAt", "due_at"],
];
for (const [key, column] of fieldMap) {
if (Object.prototype.hasOwnProperty.call(input, key)) {
sets.push(`${column} = ?`);
params.push(key === "dueAt" && input.dueAt ? new Date(input.dueAt) : (input[key] as SqlParam));
}
}
if (sets.length > 0) {
params.push(id);
await db.execute(`UPDATE tasks SET ${sets.join(", ")} WHERE id = ? AND deleted_at IS NULL`, params);
}
if (input.assigneeIds !== undefined) {
await this.replaceAssignees(id, input.assigneeIds, db);
}
await this.addEvent(id, "UPDATED", null, null, null, user, null, db);
},
async replaceAssignees(taskId: number, employeeIds: number[], db: DbExecutor = pool): Promise<void> {
await db.execute("DELETE FROM task_assignees WHERE task_id = ?", [taskId]);
await db.execute(
`
INSERT INTO task_assignees (task_id, employee_id)
VALUES ${employeeIds.map(() => "(?, ?)").join(", ")}
`,
employeeIds.flatMap((employeeId) => [taskId, employeeId]),
);
},
async setStatus(id: number, fromStatus: TaskStatus, toStatus: TaskStatus, user: AuthUser, comment?: string | null): Promise<void> {
await pool.execute(
"UPDATE tasks SET status = ? WHERE id = ? AND deleted_at IS NULL",
[toStatus, id],
);
await this.addEvent(id, toStatus === "CANCELLED" ? "CANCELLED" : toStatus === "COMPLETED" ? "COMPLETED" : "STARTED", fromStatus, toStatus, null, user, comment ?? null);
},
async addComment(id: number, employeeId: number, user: AuthUser, comment: string): Promise<void> {
await this.addEvent(id, "COMMENTED", null, null, employeeId, user, comment);
},
async addEvent(
taskId: number,
eventType: TaskEvent["eventType"],
fromStatus: TaskStatus | null,
toStatus: TaskStatus | null,
employeeId: number | null,
user: AuthUser,
comment: string | null,
db: DbExecutor = pool,
): Promise<void> {
const [actorAdminId, actorEmployeeId] = actorValues(user);
await db.execute(
`
INSERT INTO task_events
(task_id, employee_id, actor_admin_id, actor_employee_id, event_type, from_status, to_status, comment)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`,
[taskId, employeeId, actorAdminId, actorEmployeeId, eventType, fromStatus, toStatus, comment],
);
},
async listEvents(taskId: number): Promise<TaskEvent[]> {
const [rows] = await pool.execute<EventRow[]>(
`
SELECT id, task_id, event_type, from_status, to_status, comment, created_at
FROM task_events
WHERE task_id = ?
ORDER BY id ASC
`,
[taskId],
);
return rows.map((row) => ({
id: row.id,
taskId: row.task_id,
eventType: row.event_type,
fromStatus: row.from_status,
toStatus: row.to_status,
comment: row.comment,
createdAt: toIso(row.created_at) ?? "",
}));
},
async listForEmployee(employeeId: number, query: ListTasksQuery): Promise<{ items: Task[]; total: number }> {
const where = ["t.deleted_at IS NULL", "ta.employee_id = ?"];
const params: SqlParam[] = [employeeId];
if (query.status) {
where.push("t.status = ?");
params.push(query.status);
}
if (query.keyword) {
where.push("(t.title LIKE ? OR t.description LIKE ?)");
params.push(`%${query.keyword}%`, `%${query.keyword}%`);
}
const whereSql = where.join(" AND ");
const offset = (query.page - 1) * query.pageSize;
const [countRows] = await pool.execute<CountRow[]>(
`
SELECT COUNT(*) AS total
FROM tasks t
INNER JOIN task_assignees ta ON ta.task_id = t.id
WHERE ${whereSql}
`,
params,
);
const [rows] = await pool.execute<TaskRow[]>(
`
SELECT t.*, s.name AS store_name
FROM tasks t
INNER JOIN task_assignees ta ON ta.task_id = t.id
LEFT JOIN stores s ON s.id = t.store_id
WHERE ${whereSql}
ORDER BY t.id DESC
LIMIT ${query.pageSize} OFFSET ${offset}
`,
params,
);
const assigneesByTaskId = await findAssigneesByTaskIds(rows.map((row) => row.id));
return {
items: rows.map((row) => toTask(row, assigneesByTaskId.get(row.id) ?? [])),
total: countRows[0]?.total ?? 0,
};
},
async isAssigned(taskId: number, employeeId: number): Promise<boolean> {
const [rows] = await pool.execute<CountRow[]>(
"SELECT COUNT(*) AS total FROM task_assignees WHERE task_id = ? AND employee_id = ?",
[taskId, employeeId],
);
return (rows[0]?.total ?? 0) > 0;
},
async listOpenForEmployee(employeeId: number, limit: number): Promise<Task[]> {
const [rows] = await pool.execute<TaskRow[]>(
`
SELECT t.*, s.name AS store_name
FROM tasks t
INNER JOIN task_assignees ta ON ta.task_id = t.id
LEFT JOIN stores s ON s.id = t.store_id
WHERE ta.employee_id = ?
AND t.deleted_at IS NULL
AND t.status IN ('PENDING', 'IN_PROGRESS')
ORDER BY
t.due_at IS NULL ASC,
t.due_at ASC,
t.id DESC
LIMIT ?
`,
[employeeId, limit],
);
const assigneesByTaskId = await findAssigneesByTaskIds(rows.map((row) => row.id));
return rows.map((row) => toTask(row, assigneesByTaskId.get(row.id) ?? []));
},
async countPendingForEmployee(employeeId: number): Promise<number> {
const [rows] = await pool.execute<CountRow[]>(
`
SELECT COUNT(*) AS total
FROM tasks t
INNER JOIN task_assignees ta ON ta.task_id = t.id
WHERE ta.employee_id = ?
AND t.deleted_at IS NULL
AND t.status IN ('PENDING', 'IN_PROGRESS')
`,
[employeeId],
);
return rows[0]?.total ?? 0;
},
async countOverdueForEmployee(employeeId: number): Promise<number> {
const [rows] = await pool.execute<CountRow[]>(
`
SELECT COUNT(*) AS total
FROM tasks t
INNER JOIN task_assignees ta ON ta.task_id = t.id
WHERE ta.employee_id = ?
AND t.deleted_at IS NULL
AND t.status IN ('PENDING', 'IN_PROGRESS')
AND t.due_at IS NOT NULL
AND t.due_at < CURRENT_TIMESTAMP(3)
`,
[employeeId],
);
return rows[0]?.total ?? 0;
},
};
+38
View File
@@ -0,0 +1,38 @@
import { z } from "zod";
import { TASK_PRIORITIES, TASK_STATUSES } from "./task.types";
const emptyStringToUndefined = (value: unknown) =>
typeof value === "string" && value.trim() === "" ? undefined : value;
const dateStringSchema = z.string().trim().datetime({ offset: true });
export const taskIdParamSchema = z.object({
id: z.coerce.number().int().positive(),
});
export const listTasksQuerySchema = z.object({
storeId: z.preprocess(emptyStringToUndefined, z.coerce.number().int().positive().optional()),
status: z.preprocess(emptyStringToUndefined, z.enum(TASK_STATUSES).optional()),
keyword: z.preprocess(emptyStringToUndefined, z.string().trim().min(1).max(100).optional()),
page: z.coerce.number().int().min(1).default(1),
pageSize: z.coerce.number().int().min(1).max(100).default(20),
});
export const createTaskBodySchema = z.object({
storeId: z.coerce.number().int().positive().nullable().optional(),
title: z.string().trim().min(1).max(120),
description: z.string().trim().max(20000).nullable().optional(),
priority: z.enum(TASK_PRIORITIES).default("NORMAL"),
dueAt: dateStringSchema.nullable().optional(),
assigneeIds: z.array(z.coerce.number().int().positive()).min(1).max(200),
});
export const updateTaskBodySchema = createTaskBodySchema
.partial()
.refine((value) => Object.keys(value).length > 0, {
message: "至少需要提交一个要修改的字段",
});
export const taskCommentBodySchema = z.object({
comment: z.string().trim().min(1).max(1000),
});
+134
View File
@@ -0,0 +1,134 @@
import { badRequest, forbidden, notFound } from "../../shared/http-error";
import type { AuthUser } from "../auth/auth.types";
import { taskRepository } from "./task.repository";
import type { CreateTaskInput, ListTasksQuery, Task, UpdateTaskInput } from "./task.types";
function uniqueIds(ids: number[]): number[] {
return [...new Set(ids)];
}
async function assertAssigneesExist(ids: number[]): Promise<number[]> {
const unique = uniqueIds(ids);
const existingIds = await taskRepository.employeeIdsExist(unique);
if (existingIds.length !== unique.length) {
throw badRequest("任务分配员工包含不存在、停用或已删除的员工");
}
return unique;
}
function assertCanTransition(task: Task, action: "start" | "complete" | "cancel"): void {
if (action === "start" && task.status !== "PENDING") {
throw badRequest("只有待处理任务可以开始");
}
if (action === "complete" && !["PENDING", "IN_PROGRESS"].includes(task.status)) {
throw badRequest("只有待处理或处理中任务可以完成");
}
if (action === "cancel" && task.status === "COMPLETED") {
throw badRequest("已完成任务不能取消");
}
}
export const taskService = {
list(query: ListTasksQuery) {
return taskRepository.list(query);
},
async getById(id: number) {
const task = await taskRepository.findById(id);
if (!task) {
throw notFound("任务不存在");
}
return {
...task,
events: await taskRepository.listEvents(id),
};
},
async create(input: CreateTaskInput, user: AuthUser) {
const assigneeIds = await assertAssigneesExist(input.assigneeIds);
const id = await taskRepository.withTransaction((connection) =>
taskRepository.create({ ...input, assigneeIds }, user, connection),
);
return this.getById(id);
},
async update(id: number, input: UpdateTaskInput, user: AuthUser) {
await this.getById(id);
const assigneeIds = input.assigneeIds === undefined ? undefined : await assertAssigneesExist(input.assigneeIds);
await taskRepository.withTransaction((connection) =>
taskRepository.update(id, { ...input, assigneeIds }, user, connection),
);
return this.getById(id);
},
async cancel(id: number, user: AuthUser) {
const task = await this.getById(id);
assertCanTransition(task, "cancel");
await taskRepository.setStatus(id, task.status, "CANCELLED", user);
return this.getById(id);
},
listForEmployee(employee: AuthUser, query: ListTasksQuery) {
return taskRepository.listForEmployee(employee.id, query);
},
async getForEmployee(id: number, employee: AuthUser) {
const assigned = await taskRepository.isAssigned(id, employee.id);
if (!assigned) {
throw notFound("任务不存在");
}
return this.getById(id);
},
async start(id: number, employee: AuthUser) {
const task = await this.getForEmployee(id, employee);
assertCanTransition(task, "start");
await taskRepository.setStatus(id, task.status, "IN_PROGRESS", employee);
return this.getForEmployee(id, employee);
},
async complete(id: number, employee: AuthUser) {
const task = await this.getForEmployee(id, employee);
assertCanTransition(task, "complete");
await taskRepository.setStatus(id, task.status, "COMPLETED", employee);
return this.getForEmployee(id, employee);
},
async comment(id: number, employee: AuthUser, comment: string) {
await this.getForEmployee(id, employee);
await taskRepository.addComment(id, employee.id, employee, comment);
return this.getForEmployee(id, employee);
},
async countPendingForEmployee(employee: AuthUser) {
if (employee.accountType !== "EMPLOYEE") {
throw forbidden("只有员工账号可以访问员工端任务");
}
return taskRepository.countPendingForEmployee(employee.id);
},
async listOpenForEmployee(employee: AuthUser, limit: number) {
if (employee.accountType !== "EMPLOYEE") {
throw forbidden("只有员工账号可以访问员工端任务");
}
return taskRepository.listOpenForEmployee(employee.id, limit);
},
async countOverdueForEmployee(employee: AuthUser) {
if (employee.accountType !== "EMPLOYEE") {
throw forbidden("只有员工账号可以访问员工端任务");
}
return taskRepository.countOverdueForEmployee(employee.id);
},
};
+52
View File
@@ -0,0 +1,52 @@
export const TASK_STATUSES = ["PENDING", "IN_PROGRESS", "COMPLETED", "CANCELLED"] as const;
export const TASK_PRIORITIES = ["LOW", "NORMAL", "HIGH", "URGENT"] as const;
export type TaskStatus = (typeof TASK_STATUSES)[number];
export type TaskPriority = (typeof TASK_PRIORITIES)[number];
export interface Task {
id: number;
storeId: number | null;
storeName: string | null;
title: string;
description: string | null;
status: TaskStatus;
priority: TaskPriority;
dueAt: string | null;
assignees: Array<{
id: number;
name: string;
phone: string;
}>;
createdAt: string;
updatedAt: string;
}
export interface TaskEvent {
id: number;
taskId: number;
eventType: "CREATED" | "UPDATED" | "STARTED" | "COMPLETED" | "CANCELLED" | "COMMENTED";
fromStatus: TaskStatus | null;
toStatus: TaskStatus | null;
comment: string | null;
createdAt: string;
}
export interface ListTasksQuery {
storeId?: number;
status?: TaskStatus;
keyword?: string;
page: number;
pageSize: number;
}
export interface CreateTaskInput {
storeId?: number | null;
title: string;
description?: string | null;
priority: TaskPriority;
dueAt?: string | null;
assigneeIds: number[];
}
export type UpdateTaskInput = Partial<CreateTaskInput>;