feat: 班级小助手 阶段1-3(monorepo + 后端 REST/Socket.IO + Web 管理端)
This commit is contained in:
commit
84ab49de10
102 files changed
+14751
No files matched your search
@@ -0,0 +1,61 @@
|
||||
import type { Request, RequestHandler } from 'express';
|
||||
import type { UserRole } from '@classhelper/shared';
|
||||
import { prisma } from '../lib/db.js';
|
||||
import { ApiError } from '../lib/http.js';
|
||||
import { extractBearerToken, verifyToken, type TokenPayload } from '../lib/jwt.js';
|
||||
|
||||
/**
|
||||
* 认证中间件:解析 Bearer token 并回查数据库,
|
||||
* 保证角色 / 班级变更(例如教师把学生转到别的班)立即生效,不被过期载荷影响。
|
||||
*/
|
||||
export function authenticate(): RequestHandler {
|
||||
return async (req, _res, next) => {
|
||||
try {
|
||||
const token = extractBearerToken(req.headers.authorization);
|
||||
if (!token) throw ApiError.unauthorized('缺少 Authorization 头');
|
||||
|
||||
const payload = verifyToken(token);
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: payload.sub },
|
||||
select: { id: true, username: true, name: true, role: true, classId: true },
|
||||
});
|
||||
if (!user) throw ApiError.unauthorized('账号不存在或已被删除');
|
||||
|
||||
req.auth = {
|
||||
token,
|
||||
user: {
|
||||
sub: user.id,
|
||||
username: user.username,
|
||||
name: user.name,
|
||||
role: user.role as UserRole,
|
||||
classId: user.classId,
|
||||
},
|
||||
};
|
||||
next();
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/** 角色守卫 */
|
||||
export function requireRole(...roles: UserRole[]): RequestHandler {
|
||||
return (req, _res, next) => {
|
||||
const user = req.auth?.user;
|
||||
if (!user) {
|
||||
next(ApiError.unauthorized());
|
||||
return;
|
||||
}
|
||||
if (!roles.includes(user.role)) {
|
||||
next(ApiError.forbidden(`该操作仅限 ${roles.join(' / ')} 角色使用`));
|
||||
return;
|
||||
}
|
||||
next();
|
||||
};
|
||||
}
|
||||
|
||||
/** 取出当前登录用户,未认证时抛 401 */
|
||||
export function getAuthUser(req: Request): TokenPayload {
|
||||
if (!req.auth?.user) throw ApiError.unauthorized();
|
||||
return req.auth.user;
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import type { ErrorRequestHandler, RequestHandler } from 'express';
|
||||
import { ZodError } from 'zod';
|
||||
import type { ApiErrorResponse } from '@classhelper/shared';
|
||||
import { ApiError } from '../lib/http.js';
|
||||
import { logger } from '../lib/logger.js';
|
||||
import { env } from '../config/env.js';
|
||||
|
||||
function sendError(
|
||||
res: Parameters<ErrorRequestHandler>[2],
|
||||
status: number,
|
||||
message: string,
|
||||
code: string,
|
||||
details?: unknown,
|
||||
): void {
|
||||
const body: ApiErrorResponse = { success: false, data: null, message, code };
|
||||
if (details !== undefined) body.details = details;
|
||||
res.status(status).json(body);
|
||||
}
|
||||
|
||||
/** 404:未匹配到任何路由 */
|
||||
export const notFoundHandler: RequestHandler = (req, res) => {
|
||||
sendError(res, 404, `接口不存在:${req.method} ${req.originalUrl}`, 'NOT_FOUND');
|
||||
};
|
||||
|
||||
/** 统一错误处理:把 Zod / ApiError / Prisma 错误转换成统一响应体 */
|
||||
export const errorHandler: ErrorRequestHandler = (error, req, res, _next) => {
|
||||
if (error instanceof ZodError) {
|
||||
const details = error.issues.map((issue) => ({
|
||||
path: issue.path.join('.'),
|
||||
message: issue.message,
|
||||
}));
|
||||
logger.warn(`参数校验失败:${req.method} ${req.originalUrl}`, details);
|
||||
sendError(res, 422, '请求参数不合法', 'VALIDATION_ERROR', details);
|
||||
return;
|
||||
}
|
||||
|
||||
if (error instanceof ApiError) {
|
||||
if (error.status >= 500) logger.error(error.message, error.details);
|
||||
sendError(res, error.status, error.message, error.code, error.details);
|
||||
return;
|
||||
}
|
||||
|
||||
// Prisma 已知错误码(P2002 唯一约束 / P2025 记录不存在 / P2003 外键约束)
|
||||
const prismaCode = (error as { code?: string }).code;
|
||||
if (prismaCode === 'P2002') {
|
||||
sendError(res, 409, '数据已存在,请检查唯一字段(用户名 / 课程名等)', 'CONFLICT');
|
||||
return;
|
||||
}
|
||||
if (prismaCode === 'P2025') {
|
||||
sendError(res, 404, '目标记录不存在或已被删除', 'NOT_FOUND');
|
||||
return;
|
||||
}
|
||||
if (prismaCode === 'P2003') {
|
||||
sendError(res, 400, '关联数据不存在或仍被引用', 'BAD_REQUEST');
|
||||
return;
|
||||
}
|
||||
|
||||
logger.error(`未处理异常:${req.method} ${req.originalUrl}`, error);
|
||||
sendError(
|
||||
res,
|
||||
500,
|
||||
env.isProduction ? '服务器内部错误' : `服务器内部错误:${(error as Error).message}`,
|
||||
'INTERNAL_ERROR',
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,38 @@
|
||||
import type { Request, RequestHandler } from 'express';
|
||||
import type { ZodType } from 'zod';
|
||||
|
||||
interface ValidationTargets<B, Q, P> {
|
||||
body?: ZodType<B>;
|
||||
query?: ZodType<Q>;
|
||||
params?: ZodType<P>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 基于 zod 的请求校验中间件。
|
||||
* 校验结果写入 req.body / req.validatedQuery / req.validatedParams,
|
||||
* 失败抛出的 ZodError 由统一错误中间件转换成 422。
|
||||
*/
|
||||
export function validate<B = unknown, Q = unknown, P = unknown>(
|
||||
targets: ValidationTargets<B, Q, P>,
|
||||
): RequestHandler {
|
||||
return (req, _res, next) => {
|
||||
try {
|
||||
if (targets.body) req.body = targets.body.parse(req.body) as unknown;
|
||||
if (targets.query) req.validatedQuery = targets.query.parse(req.query) as unknown;
|
||||
if (targets.params) req.validatedParams = targets.params.parse(req.params) as unknown;
|
||||
next();
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/** 读取已校验的 query(类型由调用方用 zod schema 保证) */
|
||||
export function validatedQuery<T>(req: Request): T {
|
||||
return (req.validatedQuery ?? {}) as T;
|
||||
}
|
||||
|
||||
/** 读取已校验的 params */
|
||||
export function validatedParams<T>(req: Request): T {
|
||||
return (req.validatedParams ?? {}) as T;
|
||||
}
|
||||
Reference in new issue
Block a user