fix: code compliance audit and fix across all services
NestJS (6 services): implement @RequirePermission decorator with SetMetadata+Reflector, register APP_GUARD globally, fix as assertions to type guards, add explicit return types, fix import type for express, fix /metrics implicit any, replace native Error with ApplicationError, remove typeorm remnants, register LifecycleService. teacher-bff: add logger, ApplicationError, GlobalErrorFilter, forward real userId to downstream, log downstream failures, migrate health controller to shared/health. Go (2 services): interface to any, doc comments, CORS dev whitelist, JWT secret fail-fast, push-gateway internal API auth, metrics and readyz endpoints, remove dead code. Python (2 services): lifespan return type, dev_mode to bool, data-ana APIRouter, ai POST body model, ClickHouse async wrapping.
This commit is contained in:
@@ -1,10 +1,17 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { APP_GUARD } from "@nestjs/core";
|
||||
import { ExamsModule } from "./exams/exams.module.js";
|
||||
import { HomeworkModule } from "./homework/homework.module.js";
|
||||
import { GradesModule } from "./grades/grades.module.js";
|
||||
import { HealthModule } from "./shared/health/health.module.js";
|
||||
import { PermissionGuard } from "./middleware/permission.guard.js";
|
||||
import { LifecycleService } from "./shared/lifecycle/lifecycle.service.js";
|
||||
|
||||
@Module({
|
||||
imports: [ExamsModule, HomeworkModule, GradesModule, HealthModule],
|
||||
providers: [
|
||||
{ provide: APP_GUARD, useClass: PermissionGuard },
|
||||
LifecycleService,
|
||||
],
|
||||
})
|
||||
export class AppModule {}
|
||||
|
||||
@@ -8,23 +8,32 @@ import {
|
||||
Put,
|
||||
Req,
|
||||
} from "@nestjs/common";
|
||||
import type { Request } from "express";
|
||||
import {
|
||||
ExamsService,
|
||||
type CreateExamInput,
|
||||
type UpdateExamInput,
|
||||
} from "./exams.service.js";
|
||||
import {
|
||||
Permissions,
|
||||
RequirePermission,
|
||||
} from "../middleware/permission.guard.js";
|
||||
import type { AuthenticatedRequest } from "../middleware/auth.middleware.js";
|
||||
import { UnauthorizedError } from "../shared/errors/application-error.js";
|
||||
|
||||
@Controller("exams")
|
||||
export class ExamsController {
|
||||
constructor(private readonly examsService: ExamsService) {}
|
||||
|
||||
@Post()
|
||||
@RequirePermission(Permissions.EXAM_CREATE)
|
||||
async create(
|
||||
@Body() body: CreateExamInput,
|
||||
@Req() req: Request,
|
||||
@Req() req: AuthenticatedRequest,
|
||||
): Promise<{ success: true; data: { id: string } }> {
|
||||
const userId = req.headers["x-user-id"] as string;
|
||||
const userId = req.userId;
|
||||
if (!userId) {
|
||||
throw new UnauthorizedError("Missing x-user-id header");
|
||||
}
|
||||
const result = await this.examsService.createExam({
|
||||
...body,
|
||||
createdBy: userId,
|
||||
@@ -33,6 +42,7 @@ export class ExamsController {
|
||||
}
|
||||
|
||||
@Get(":id")
|
||||
@RequirePermission(Permissions.EXAM_READ)
|
||||
async findOne(@Param("id") id: string): Promise<{
|
||||
success: true;
|
||||
data: Awaited<ReturnType<ExamsService["getExam"]>>;
|
||||
@@ -42,6 +52,7 @@ export class ExamsController {
|
||||
}
|
||||
|
||||
@Get("class/:classId")
|
||||
@RequirePermission(Permissions.EXAM_READ)
|
||||
async listByClass(@Param("classId") classId: string): Promise<{
|
||||
success: true;
|
||||
data: Awaited<ReturnType<ExamsService["listExamsByClass"]>>;
|
||||
@@ -51,6 +62,7 @@ export class ExamsController {
|
||||
}
|
||||
|
||||
@Put(":id")
|
||||
@RequirePermission(Permissions.EXAM_UPDATE)
|
||||
async update(
|
||||
@Param("id") id: string,
|
||||
@Body() body: UpdateExamInput,
|
||||
@@ -60,6 +72,7 @@ export class ExamsController {
|
||||
}
|
||||
|
||||
@Delete(":id")
|
||||
@RequirePermission(Permissions.EXAM_DELETE)
|
||||
async remove(
|
||||
@Param("id") id: string,
|
||||
): Promise<{ success: true; data: { success: true } }> {
|
||||
|
||||
@@ -1,17 +1,26 @@
|
||||
import { Body, Controller, Get, Param, Post, Req } from "@nestjs/common";
|
||||
import type { Request } from "express";
|
||||
import { GradesService, type RecordGradeInput } from "./grades.service.js";
|
||||
import {
|
||||
Permissions,
|
||||
RequirePermission,
|
||||
} from "../middleware/permission.guard.js";
|
||||
import type { AuthenticatedRequest } from "../middleware/auth.middleware.js";
|
||||
import { UnauthorizedError } from "../shared/errors/application-error.js";
|
||||
|
||||
@Controller("grades")
|
||||
export class GradesController {
|
||||
constructor(private readonly gradesService: GradesService) {}
|
||||
|
||||
@Post()
|
||||
@RequirePermission(Permissions.GRADE_CREATE)
|
||||
async record(
|
||||
@Body() body: RecordGradeInput,
|
||||
@Req() req: Request,
|
||||
@Req() req: AuthenticatedRequest,
|
||||
): Promise<{ success: true; data: { id: string } }> {
|
||||
const userId = req.headers["x-user-id"] as string;
|
||||
const userId = req.userId;
|
||||
if (!userId) {
|
||||
throw new UnauthorizedError("Missing x-user-id header");
|
||||
}
|
||||
const result = await this.gradesService.recordGrade({
|
||||
...body,
|
||||
gradedBy: userId,
|
||||
@@ -20,6 +29,7 @@ export class GradesController {
|
||||
}
|
||||
|
||||
@Get(":id")
|
||||
@RequirePermission(Permissions.GRADE_READ)
|
||||
async findOne(@Param("id") id: string): Promise<{
|
||||
success: true;
|
||||
data: Awaited<ReturnType<GradesService["getGrade"]>>;
|
||||
@@ -29,6 +39,7 @@ export class GradesController {
|
||||
}
|
||||
|
||||
@Get("student/:studentId")
|
||||
@RequirePermission(Permissions.GRADE_READ)
|
||||
async listByStudent(@Param("studentId") studentId: string): Promise<{
|
||||
success: true;
|
||||
data: Awaited<ReturnType<GradesService["listByStudent"]>>;
|
||||
@@ -38,6 +49,7 @@ export class GradesController {
|
||||
}
|
||||
|
||||
@Get("exam/:examId")
|
||||
@RequirePermission(Permissions.GRADE_READ)
|
||||
async listByExam(@Param("examId") examId: string): Promise<{
|
||||
success: true;
|
||||
data: Awaited<ReturnType<GradesService["listByExam"]>>;
|
||||
@@ -47,6 +59,7 @@ export class GradesController {
|
||||
}
|
||||
|
||||
@Get("homework/:homeworkId")
|
||||
@RequirePermission(Permissions.GRADE_READ)
|
||||
async listByHomework(@Param("homeworkId") homeworkId: string): Promise<{
|
||||
success: true;
|
||||
data: Awaited<ReturnType<GradesService["listByHomework"]>>;
|
||||
|
||||
@@ -1,20 +1,29 @@
|
||||
import { Body, Controller, Get, Param, Post, Req } from "@nestjs/common";
|
||||
import type { Request } from "express";
|
||||
import {
|
||||
HomeworkService,
|
||||
type AssignHomeworkInput,
|
||||
} from "./homework.service.js";
|
||||
import {
|
||||
Permissions,
|
||||
RequirePermission,
|
||||
} from "../middleware/permission.guard.js";
|
||||
import type { AuthenticatedRequest } from "../middleware/auth.middleware.js";
|
||||
import { UnauthorizedError } from "../shared/errors/application-error.js";
|
||||
|
||||
@Controller("homework")
|
||||
export class HomeworkController {
|
||||
constructor(private readonly homeworkService: HomeworkService) {}
|
||||
|
||||
@Post()
|
||||
@RequirePermission(Permissions.HOMEWORK_CREATE)
|
||||
async assign(
|
||||
@Body() body: AssignHomeworkInput,
|
||||
@Req() req: Request,
|
||||
@Req() req: AuthenticatedRequest,
|
||||
): Promise<{ success: true; data: { id: string } }> {
|
||||
const userId = req.headers["x-user-id"] as string;
|
||||
const userId = req.userId;
|
||||
if (!userId) {
|
||||
throw new UnauthorizedError("Missing x-user-id header");
|
||||
}
|
||||
const result = await this.homeworkService.assignHomework({
|
||||
...body,
|
||||
createdBy: userId,
|
||||
@@ -23,6 +32,7 @@ export class HomeworkController {
|
||||
}
|
||||
|
||||
@Get(":id")
|
||||
@RequirePermission(Permissions.HOMEWORK_READ)
|
||||
async findOne(@Param("id") id: string): Promise<{
|
||||
success: true;
|
||||
data: Awaited<ReturnType<HomeworkService["getHomework"]>>;
|
||||
@@ -32,6 +42,7 @@ export class HomeworkController {
|
||||
}
|
||||
|
||||
@Get("class/:classId")
|
||||
@RequirePermission(Permissions.HOMEWORK_READ)
|
||||
async listByClass(@Param("classId") classId: string): Promise<{
|
||||
success: true;
|
||||
data: Awaited<ReturnType<HomeworkService["listByClass"]>>;
|
||||
@@ -41,6 +52,7 @@ export class HomeworkController {
|
||||
}
|
||||
|
||||
@Post(":id/submit")
|
||||
@RequirePermission(Permissions.HOMEWORK_SUBMIT)
|
||||
async submit(
|
||||
@Param("id") id: string,
|
||||
): Promise<{ success: true; data: { success: true } }> {
|
||||
|
||||
@@ -7,6 +7,7 @@ import { GlobalErrorFilter } from "./shared/errors/global-error.filter.js";
|
||||
import { initTracer, shutdownTracer } from "./shared/observability/tracer.js";
|
||||
import { logger } from "./shared/observability/logger.js";
|
||||
import { registry } from "./shared/observability/metrics.js";
|
||||
import type { Request, Response } from "express";
|
||||
|
||||
async function bootstrap(): Promise<void> {
|
||||
initTracer();
|
||||
@@ -16,8 +17,7 @@ async function bootstrap(): Promise<void> {
|
||||
app.enableShutdownHooks();
|
||||
|
||||
// Prometheus 指标端点:不鉴权,供 Prometheus 抓取。
|
||||
// 返回 register.metrics()(Promise<string>,含 Content-Type text/plain; version=0.0.4; charset=utf-8)。
|
||||
app.getHttpAdapter().get("/metrics", async (req, res) => {
|
||||
app.getHttpAdapter().get("/metrics", async (_req: Request, res: Response) => {
|
||||
res.set("Content-Type", registry.contentType);
|
||||
res.end(await registry.metrics());
|
||||
});
|
||||
|
||||
@@ -2,39 +2,29 @@ import {
|
||||
Injectable,
|
||||
NestMiddleware,
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
import type { Request, Response, NextFunction } from 'express';
|
||||
|
||||
export interface AuthenticatedUser {
|
||||
id: string;
|
||||
role: string;
|
||||
permissions: string[];
|
||||
}
|
||||
} from "@nestjs/common";
|
||||
import type { Request, Response, NextFunction } from "express";
|
||||
|
||||
export interface AuthenticatedRequest extends Request {
|
||||
user?: AuthenticatedUser;
|
||||
userId?: string;
|
||||
userRoles?: string[];
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class AuthMiddleware implements NestMiddleware {
|
||||
use(req: AuthenticatedRequest, _res: Response, next: NextFunction): void {
|
||||
const userId = req.headers['x-user-id'] as string | undefined;
|
||||
const role = req.headers['x-user-role'] as string | undefined;
|
||||
const permissionsHeader = req.headers['x-user-permissions'] as
|
||||
| string
|
||||
| undefined;
|
||||
const userIdHeader = req.headers["x-user-id"];
|
||||
const userId = typeof userIdHeader === "string" ? userIdHeader : undefined;
|
||||
const rolesHeaderRaw = req.headers["x-user-roles"];
|
||||
const rolesHeader =
|
||||
typeof rolesHeaderRaw === "string" ? rolesHeaderRaw : undefined;
|
||||
|
||||
if (!userId || !role) {
|
||||
throw new UnauthorizedException(
|
||||
'Missing authentication headers (x-user-id, x-user-role)',
|
||||
);
|
||||
if (!userId) {
|
||||
throw new UnauthorizedException("Missing x-user-id header");
|
||||
}
|
||||
|
||||
req.user = {
|
||||
id: userId,
|
||||
role,
|
||||
permissions: permissionsHeader ? permissionsHeader.split(',') : [],
|
||||
};
|
||||
req.userId = userId;
|
||||
req.userRoles = rolesHeader ? rolesHeader.split(",") : [];
|
||||
next();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,48 +1,92 @@
|
||||
import {
|
||||
Injectable,
|
||||
CanActivate,
|
||||
ExecutionContext,
|
||||
Injectable,
|
||||
ForbiddenException,
|
||||
} from '@nestjs/common';
|
||||
import type { AuthenticatedRequest } from './auth.middleware.js';
|
||||
SetMetadata,
|
||||
} from "@nestjs/common";
|
||||
import { Reflector } from "@nestjs/core";
|
||||
import { PermissionDeniedError } from "../shared/errors/application-error.js";
|
||||
import type { AuthenticatedRequest } from "./auth.middleware.js";
|
||||
|
||||
export const Permissions = {
|
||||
EXAM_CREATE: 'exam:create',
|
||||
EXAM_READ: 'exam:read',
|
||||
EXAM_UPDATE: 'exam:update',
|
||||
EXAM_DELETE: 'exam:delete',
|
||||
HOMEWORK_CREATE: 'homework:create',
|
||||
HOMEWORK_READ: 'homework:read',
|
||||
HOMEWORK_UPDATE: 'homework:update',
|
||||
HOMEWORK_DELETE: 'homework:delete',
|
||||
HOMEWORK_GRADE: 'homework:grade',
|
||||
HOMEWORK_SUBMIT: 'homework:submit',
|
||||
GRADE_CREATE: 'grade:create',
|
||||
GRADE_READ: 'grade:read',
|
||||
GRADE_UPDATE: 'grade:update',
|
||||
GRADE_DELETE: 'grade:delete',
|
||||
CLASS_MANAGE: 'class:manage',
|
||||
CLASS_READ: 'class:read',
|
||||
CLASS_TRANSFER: 'class:transfer',
|
||||
EXAM_CREATE: "CORE_EDU_EXAM_CREATE" as const,
|
||||
EXAM_READ: "CORE_EDU_EXAM_READ" as const,
|
||||
EXAM_UPDATE: "CORE_EDU_EXAM_UPDATE" as const,
|
||||
EXAM_DELETE: "CORE_EDU_EXAM_DELETE" as const,
|
||||
HOMEWORK_CREATE: "CORE_EDU_HOMEWORK_CREATE" as const,
|
||||
HOMEWORK_READ: "CORE_EDU_HOMEWORK_READ" as const,
|
||||
HOMEWORK_UPDATE: "CORE_EDU_HOMEWORK_UPDATE" as const,
|
||||
HOMEWORK_SUBMIT: "CORE_EDU_HOMEWORK_SUBMIT" as const,
|
||||
GRADE_CREATE: "CORE_EDU_GRADE_CREATE" as const,
|
||||
GRADE_READ: "CORE_EDU_GRADE_READ" as const,
|
||||
} as const;
|
||||
|
||||
export type Permission = (typeof Permissions)[keyof typeof Permissions];
|
||||
|
||||
export const PERMISSIONS_KEY = "permissions";
|
||||
export const RequirePermission = (...permissions: Permission[]) =>
|
||||
SetMetadata(PERMISSIONS_KEY, permissions);
|
||||
|
||||
const ROLE_PERMISSIONS: Record<string, Permission[]> = {
|
||||
admin: [
|
||||
Permissions.EXAM_CREATE,
|
||||
Permissions.EXAM_READ,
|
||||
Permissions.EXAM_UPDATE,
|
||||
Permissions.EXAM_DELETE,
|
||||
Permissions.HOMEWORK_CREATE,
|
||||
Permissions.HOMEWORK_READ,
|
||||
Permissions.HOMEWORK_UPDATE,
|
||||
Permissions.HOMEWORK_SUBMIT,
|
||||
Permissions.GRADE_CREATE,
|
||||
Permissions.GRADE_READ,
|
||||
],
|
||||
teacher: [
|
||||
Permissions.EXAM_CREATE,
|
||||
Permissions.EXAM_READ,
|
||||
Permissions.EXAM_UPDATE,
|
||||
Permissions.HOMEWORK_CREATE,
|
||||
Permissions.HOMEWORK_READ,
|
||||
Permissions.HOMEWORK_UPDATE,
|
||||
Permissions.HOMEWORK_SUBMIT,
|
||||
Permissions.GRADE_CREATE,
|
||||
Permissions.GRADE_READ,
|
||||
],
|
||||
student: [
|
||||
Permissions.EXAM_READ,
|
||||
Permissions.HOMEWORK_READ,
|
||||
Permissions.HOMEWORK_SUBMIT,
|
||||
Permissions.GRADE_READ,
|
||||
],
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class PermissionGuard implements CanActivate {
|
||||
constructor(private readonly requiredPermission: Permission) {}
|
||||
constructor(private readonly reflector: Reflector) {}
|
||||
|
||||
canActivate(context: ExecutionContext): boolean {
|
||||
if (process.env.DEV_MODE === "true") {
|
||||
return true;
|
||||
}
|
||||
|
||||
const requiredPermissions = this.reflector.getAllAndOverride<Permission[]>(
|
||||
PERMISSIONS_KEY,
|
||||
[context.getHandler(), context.getClass()],
|
||||
);
|
||||
|
||||
if (!requiredPermissions || requiredPermissions.length === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const request = context.switchToHttp().getRequest<AuthenticatedRequest>();
|
||||
const user = request.user;
|
||||
if (!user) {
|
||||
throw new ForbiddenException('User not authenticated');
|
||||
const roles = request.userRoles ?? [];
|
||||
|
||||
for (const role of roles) {
|
||||
const perms = ROLE_PERMISSIONS[role];
|
||||
if (perms && requiredPermissions.some((p) => perms.includes(p))) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
if (!user.permissions.includes(this.requiredPermission)) {
|
||||
throw new ForbiddenException(
|
||||
`Missing permission: ${this.requiredPermission}`,
|
||||
);
|
||||
}
|
||||
return true;
|
||||
|
||||
throw new PermissionDeniedError(requiredPermissions.join(", "));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,25 +1,45 @@
|
||||
export enum CoreEduErrorCode {
|
||||
VALIDATION_ERROR = 'CORE_EDU_VALIDATION_ERROR',
|
||||
NOT_FOUND = 'CORE_EDU_NOT_FOUND',
|
||||
UNAUTHORIZED = 'CORE_EDU_UNAUTHORIZED',
|
||||
FORBIDDEN = 'CORE_EDU_FORBIDDEN',
|
||||
CONFLICT = 'CORE_EDU_CONFLICT',
|
||||
INTERNAL_ERROR = 'CORE_EDU_INTERNAL_ERROR',
|
||||
EXAM_NOT_FOUND = 'CORE_EDU_EXAM_NOT_FOUND',
|
||||
HOMEWORK_NOT_FOUND = 'CORE_EDU_HOMEWORK_NOT_FOUND',
|
||||
GRADE_NOT_FOUND = 'CORE_EDU_GRADE_NOT_FOUND',
|
||||
OUTBOX_PUBLISH_FAILED = 'CORE_EDU_OUTBOX_PUBLISH_FAILED',
|
||||
VALIDATION_ERROR = "CORE_EDU_VALIDATION_ERROR",
|
||||
NOT_FOUND = "CORE_EDU_NOT_FOUND",
|
||||
UNAUTHORIZED = "CORE_EDU_UNAUTHORIZED",
|
||||
FORBIDDEN = "CORE_EDU_FORBIDDEN",
|
||||
CONFLICT = "CORE_EDU_CONFLICT",
|
||||
INTERNAL_ERROR = "CORE_EDU_INTERNAL_ERROR",
|
||||
EXAM_NOT_FOUND = "CORE_EDU_EXAM_NOT_FOUND",
|
||||
HOMEWORK_NOT_FOUND = "CORE_EDU_HOMEWORK_NOT_FOUND",
|
||||
GRADE_NOT_FOUND = "CORE_EDU_GRADE_NOT_FOUND",
|
||||
OUTBOX_PUBLISH_FAILED = "CORE_EDU_OUTBOX_PUBLISH_FAILED",
|
||||
}
|
||||
|
||||
export class ApplicationError extends Error {
|
||||
readonly code: CoreEduErrorCode;
|
||||
readonly statusCode: number;
|
||||
readonly details?: unknown;
|
||||
traceId?: string;
|
||||
|
||||
constructor(
|
||||
public readonly code: CoreEduErrorCode,
|
||||
code: CoreEduErrorCode,
|
||||
message: string,
|
||||
public readonly statusCode: number = 500,
|
||||
public readonly details?: unknown,
|
||||
statusCode: number = 500,
|
||||
details?: unknown,
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'ApplicationError';
|
||||
this.name = this.constructor.name;
|
||||
this.code = code;
|
||||
this.statusCode = statusCode;
|
||||
this.details = details;
|
||||
}
|
||||
|
||||
toJSON(): Record<string, unknown> {
|
||||
return {
|
||||
success: false,
|
||||
error: {
|
||||
code: this.code,
|
||||
message: this.message,
|
||||
details: this.details,
|
||||
traceId: this.traceId,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,17 +56,25 @@ export class NotFoundError extends ApplicationError {
|
||||
}
|
||||
|
||||
export class UnauthorizedError extends ApplicationError {
|
||||
constructor(message: string = 'Unauthorized') {
|
||||
constructor(message: string = "Unauthorized") {
|
||||
super(CoreEduErrorCode.UNAUTHORIZED, message, 401);
|
||||
}
|
||||
}
|
||||
|
||||
export class ForbiddenError extends ApplicationError {
|
||||
constructor(message: string = 'Forbidden') {
|
||||
constructor(message: string = "Forbidden") {
|
||||
super(CoreEduErrorCode.FORBIDDEN, message, 403);
|
||||
}
|
||||
}
|
||||
|
||||
export class PermissionDeniedError extends ApplicationError {
|
||||
constructor(permission: string) {
|
||||
super(CoreEduErrorCode.FORBIDDEN, `Permission denied: ${permission}`, 403, {
|
||||
permission,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export class ConflictError extends ApplicationError {
|
||||
constructor(message: string, details?: unknown) {
|
||||
super(CoreEduErrorCode.CONFLICT, message, 409, details);
|
||||
@@ -54,7 +82,7 @@ export class ConflictError extends ApplicationError {
|
||||
}
|
||||
|
||||
export class InternalError extends ApplicationError {
|
||||
constructor(message: string = 'Internal server error', details?: unknown) {
|
||||
constructor(message: string = "Internal server error", details?: unknown) {
|
||||
super(CoreEduErrorCode.INTERNAL_ERROR, message, 500, details);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,63 +1,95 @@
|
||||
import {
|
||||
ExceptionFilter,
|
||||
Catch,
|
||||
ExceptionFilter,
|
||||
ArgumentsHost,
|
||||
HttpException,
|
||||
HttpStatus,
|
||||
} from '@nestjs/common';
|
||||
import { ZodError } from 'zod';
|
||||
import { ApplicationError, CoreEduErrorCode } from './application-error.js';
|
||||
import { logger } from '../observability/logger.js';
|
||||
Logger,
|
||||
} from "@nestjs/common";
|
||||
import type { Request, Response } from "express";
|
||||
import { ZodError } from "zod";
|
||||
import { ApplicationError } from "./application-error.js";
|
||||
|
||||
@Catch()
|
||||
export class GlobalErrorFilter implements ExceptionFilter {
|
||||
private readonly logger = new Logger(GlobalErrorFilter.name);
|
||||
|
||||
catch(exception: unknown, host: ArgumentsHost): void {
|
||||
const ctx = host.switchToHttp();
|
||||
const response = ctx.getResponse();
|
||||
const request = ctx.getRequest();
|
||||
const response = ctx.getResponse<Response>();
|
||||
const request = ctx.getRequest<Request>();
|
||||
|
||||
let statusCode = HttpStatus.INTERNAL_SERVER_ERROR;
|
||||
let code = CoreEduErrorCode.INTERNAL_ERROR;
|
||||
let message = 'Internal server error';
|
||||
let details: unknown;
|
||||
const traceIdHeader = request.headers["x-request-id"];
|
||||
const traceId =
|
||||
typeof traceIdHeader === "string" ? traceIdHeader : "unknown";
|
||||
|
||||
let statusCode = 500;
|
||||
let body: Record<string, unknown>;
|
||||
|
||||
if (exception instanceof ApplicationError) {
|
||||
exception.traceId = traceId;
|
||||
statusCode = exception.statusCode;
|
||||
code = exception.code;
|
||||
message = exception.message;
|
||||
details = exception.details;
|
||||
body = exception.toJSON();
|
||||
} else if (exception instanceof ZodError) {
|
||||
statusCode = HttpStatus.BAD_REQUEST;
|
||||
code = CoreEduErrorCode.VALIDATION_ERROR;
|
||||
message = 'Validation failed';
|
||||
details = exception.flatten().fieldErrors;
|
||||
statusCode = 400;
|
||||
body = {
|
||||
success: false,
|
||||
error: {
|
||||
code: "CORE_EDU_VALIDATION_ERROR",
|
||||
message: "Validation failed",
|
||||
details: exception.flatten(),
|
||||
traceId,
|
||||
},
|
||||
};
|
||||
} else if (exception instanceof HttpException) {
|
||||
statusCode = exception.getStatus();
|
||||
const resp = exception.getResponse();
|
||||
message =
|
||||
typeof resp === 'string'
|
||||
? resp
|
||||
: (resp as { message?: string }).message ?? exception.message;
|
||||
} else if (exception instanceof Error) {
|
||||
message = exception.message;
|
||||
const res = exception.getResponse();
|
||||
const message = this.extractHttpMessage(res, exception);
|
||||
body = {
|
||||
success: false,
|
||||
error: {
|
||||
code: "HTTP_ERROR",
|
||||
message,
|
||||
traceId,
|
||||
},
|
||||
};
|
||||
} else {
|
||||
this.logger.error(
|
||||
`Unhandled exception: ${exception}`,
|
||||
exception instanceof Error ? exception.stack : undefined,
|
||||
);
|
||||
body = {
|
||||
success: false,
|
||||
error: {
|
||||
code: "INTERNAL_ERROR",
|
||||
message: "An unexpected error occurred",
|
||||
traceId,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
logger.error(
|
||||
this.logger.error(
|
||||
{
|
||||
err: exception,
|
||||
path: request.url,
|
||||
method: request.method,
|
||||
code,
|
||||
},
|
||||
`Request failed: ${message}`,
|
||||
`Request failed: ${request.method} ${request.url}`,
|
||||
);
|
||||
|
||||
response.status(statusCode).json({
|
||||
code,
|
||||
message,
|
||||
details,
|
||||
timestamp: new Date().toISOString(),
|
||||
path: request.url,
|
||||
});
|
||||
response.status(statusCode).json(body);
|
||||
}
|
||||
|
||||
private extractHttpMessage(
|
||||
res: string | object,
|
||||
exception: HttpException,
|
||||
): string {
|
||||
if (typeof res === "string") {
|
||||
return res;
|
||||
}
|
||||
if (res && typeof res === "object" && "message" in res) {
|
||||
const msg = (res as { message: unknown }).message;
|
||||
return typeof msg === "string" ? msg : exception.message;
|
||||
}
|
||||
return exception.message;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,21 @@
|
||||
import { Injectable, Logger } from "@nestjs/common";
|
||||
import {
|
||||
Injectable,
|
||||
Logger,
|
||||
OnApplicationShutdown,
|
||||
OnModuleInit,
|
||||
} from "@nestjs/common";
|
||||
import { closeDb } from "../../config/database.js";
|
||||
|
||||
const SERVICE_NAME = "core-edu";
|
||||
|
||||
@Injectable()
|
||||
export class LifecycleService {
|
||||
export class LifecycleService implements OnModuleInit, OnApplicationShutdown {
|
||||
private readonly logger = new Logger(LifecycleService.name);
|
||||
|
||||
onModuleInit(): void {
|
||||
this.logger.log(`service ${SERVICE_NAME} module initialized`);
|
||||
}
|
||||
|
||||
async onApplicationShutdown(signal?: string): Promise<void> {
|
||||
this.logger.log(
|
||||
`service ${SERVICE_NAME} shutting down (signal=${signal ?? "unknown"})`,
|
||||
|
||||
Reference in New Issue
Block a user