690406:2310 Done Task BE-ERR-01
This commit is contained in:
@@ -7,7 +7,7 @@ import { CryptoService } from './services/crypto.service';
|
||||
import { RequestContextService } from './services/request-context.service';
|
||||
import { UuidResolverService } from './services/uuid-resolver.service';
|
||||
import { APP_FILTER, APP_INTERCEPTOR } from '@nestjs/core';
|
||||
import { HttpExceptionFilter } from './exceptions/http-exception.filter';
|
||||
import { GlobalExceptionFilter } from './filters/global-exception.filter';
|
||||
import { TransformInterceptor } from './interceptors/transform.interceptor';
|
||||
// import { IdempotencyInterceptor } from './interceptors/idempotency.interceptor'; // นำเข้าถ้าต้องการใช้ Global
|
||||
|
||||
@@ -21,7 +21,7 @@ import { TransformInterceptor } from './interceptors/transform.interceptor';
|
||||
// Register Global Filter & Interceptor ที่นี่ หรือใน AppModule ก็ได้
|
||||
{
|
||||
provide: APP_FILTER,
|
||||
useClass: HttpExceptionFilter,
|
||||
useClass: GlobalExceptionFilter,
|
||||
},
|
||||
{
|
||||
provide: APP_INTERCEPTOR,
|
||||
|
||||
@@ -0,0 +1,235 @@
|
||||
// File: src/common/exceptions/base.exception.ts
|
||||
// ADR-007: Exception hierarchy สำหรับ Layered Error Handling
|
||||
|
||||
import { HttpException, HttpStatus } from '@nestjs/common';
|
||||
|
||||
// ประเภทของ Error ที่ระบบรองรับ
|
||||
export enum ErrorType {
|
||||
VALIDATION = 'VALIDATION',
|
||||
BUSINESS_RULE = 'BUSINESS_RULE',
|
||||
PERMISSION_DENIED = 'PERMISSION_DENIED',
|
||||
NOT_FOUND = 'NOT_FOUND',
|
||||
CONFLICT = 'CONFLICT',
|
||||
INTERNAL_ERROR = 'INTERNAL_ERROR',
|
||||
DATABASE_ERROR = 'DATABASE_ERROR',
|
||||
EXTERNAL_SERVICE = 'EXTERNAL_SERVICE',
|
||||
INFRASTRUCTURE = 'INFRASTRUCTURE',
|
||||
}
|
||||
|
||||
// ระดับความรุนแรงของ Error
|
||||
export enum ErrorSeverity {
|
||||
LOW = 'LOW', // ผู้ใช้ทำผิด แก้ไขง่าย
|
||||
MEDIUM = 'MEDIUM', // ละเมิดกฎทางธุรกิจ ต้องดำเนินการ
|
||||
HIGH = 'HIGH', // ปัญหาระบบ อาจต้องติดต่อ Support
|
||||
CRITICAL = 'CRITICAL', // ระบบล้มเหลว ต้องแก้ไขทันที
|
||||
}
|
||||
|
||||
// รายละเอียด Validation Error แต่ละ Field
|
||||
export interface ValidationErrorDetail {
|
||||
field: string;
|
||||
message: string;
|
||||
value?: unknown;
|
||||
}
|
||||
|
||||
// แปลง ErrorType เป็น HTTP Status Code
|
||||
export function getStatusCode(type: ErrorType): number {
|
||||
switch (type) {
|
||||
case ErrorType.VALIDATION:
|
||||
return HttpStatus.BAD_REQUEST;
|
||||
case ErrorType.BUSINESS_RULE:
|
||||
return HttpStatus.UNPROCESSABLE_ENTITY;
|
||||
case ErrorType.PERMISSION_DENIED:
|
||||
return HttpStatus.FORBIDDEN;
|
||||
case ErrorType.NOT_FOUND:
|
||||
return HttpStatus.NOT_FOUND;
|
||||
case ErrorType.CONFLICT:
|
||||
return HttpStatus.CONFLICT;
|
||||
case ErrorType.INTERNAL_ERROR:
|
||||
case ErrorType.DATABASE_ERROR:
|
||||
case ErrorType.EXTERNAL_SERVICE:
|
||||
case ErrorType.INFRASTRUCTURE:
|
||||
return HttpStatus.INTERNAL_SERVER_ERROR;
|
||||
default:
|
||||
return HttpStatus.INTERNAL_SERVER_ERROR;
|
||||
}
|
||||
}
|
||||
|
||||
// รูปแบบ Payload ของ Error Response
|
||||
export interface ErrorPayload {
|
||||
type: ErrorType;
|
||||
code: string;
|
||||
message: string;
|
||||
severity: ErrorSeverity;
|
||||
timestamp: string;
|
||||
recoveryActions?: string[];
|
||||
technicalMessage?: string;
|
||||
details?: ValidationErrorDetail[] | Record<string, unknown>;
|
||||
}
|
||||
|
||||
// Base Exception ที่ทุก Custom Exception ต้อง extends
|
||||
export abstract class BaseException extends HttpException {
|
||||
public readonly httpStatus: number;
|
||||
|
||||
constructor(
|
||||
public readonly type: ErrorType,
|
||||
public readonly code: string,
|
||||
public readonly technicalMessage: string,
|
||||
public readonly userMessage?: string,
|
||||
public readonly severity: ErrorSeverity = ErrorSeverity.MEDIUM,
|
||||
public readonly details?: ValidationErrorDetail[] | Record<string, unknown>,
|
||||
public readonly recoveryActions?: string[]
|
||||
) {
|
||||
const httpStatus = getStatusCode(type);
|
||||
|
||||
const payload: ErrorPayload = {
|
||||
type,
|
||||
code,
|
||||
message: userMessage || technicalMessage,
|
||||
severity,
|
||||
timestamp: new Date().toISOString(),
|
||||
...(recoveryActions && { recoveryActions }),
|
||||
...(process.env['NODE_ENV'] !== 'production' && {
|
||||
technicalMessage,
|
||||
...(details && { details }),
|
||||
}),
|
||||
};
|
||||
|
||||
super({ error: payload }, httpStatus);
|
||||
this.httpStatus = httpStatus;
|
||||
}
|
||||
}
|
||||
|
||||
// Validation Errors (400) - ข้อมูล Input ผิดพลาด
|
||||
export class ValidationException extends BaseException {
|
||||
constructor(message: string, details?: ValidationErrorDetail[]) {
|
||||
super(
|
||||
ErrorType.VALIDATION,
|
||||
'VALIDATION_ERROR',
|
||||
message,
|
||||
'ข้อมูลที่กรอกไม่ถูกต้อง กรุณาตรวจสอบและลองใหม่',
|
||||
ErrorSeverity.LOW,
|
||||
details,
|
||||
['ตรวจสอบข้อมูลที่กรอก', 'แก้ไขข้อมูลที่ผิดพลาด', 'ลองใหม่อีกครั้ง']
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Business Rule Errors (422) - ละเมิดกฎทางธุรกิจ
|
||||
export class BusinessException extends BaseException {
|
||||
constructor(
|
||||
code: string,
|
||||
message: string,
|
||||
userMessage?: string,
|
||||
recoveryActions?: string[]
|
||||
) {
|
||||
super(
|
||||
ErrorType.BUSINESS_RULE,
|
||||
code,
|
||||
message,
|
||||
userMessage || 'ไม่สามารถดำเนินการได้เนื่องจากเงื่อนไขทางธุรกิจ',
|
||||
ErrorSeverity.MEDIUM,
|
||||
undefined,
|
||||
recoveryActions || ['ติดต่อผู้ดูแลระบบ', 'ตรวจสอบเงื่อนไขการดำเนินการ']
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Not Found Errors (404) - ไม่พบข้อมูล
|
||||
export class NotFoundException extends BaseException {
|
||||
constructor(resource: string, identifier?: string) {
|
||||
super(
|
||||
ErrorType.NOT_FOUND,
|
||||
'NOT_FOUND',
|
||||
`${resource}${identifier ? ` with identifier "${identifier}"` : ''} not found`,
|
||||
`ไม่พบ${resource}ที่ค้นหา`,
|
||||
ErrorSeverity.LOW,
|
||||
undefined,
|
||||
['ตรวจสอบ ID/UUID ที่ระบุ', 'ค้นหาข้อมูลจากรายการ']
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Permission Errors (403) - ไม่มีสิทธิ์
|
||||
export class PermissionException extends BaseException {
|
||||
constructor(resource: string, action: string) {
|
||||
super(
|
||||
ErrorType.PERMISSION_DENIED,
|
||||
'PERMISSION_DENIED',
|
||||
`User lacks permission for "${action}" on "${resource}"`,
|
||||
`คุณไม่มีสิทธิ์ดำเนินการ "${action}" บน "${resource}"`,
|
||||
ErrorSeverity.MEDIUM,
|
||||
{ resource, action },
|
||||
['ติดต่อผู้ดูแลระบบเพื่อขอสิทธิ์', 'ลองใช้บัญชีที่มีสิทธิ์']
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Conflict Errors (409) - ข้อมูลซ้ำ / ขัดแย้ง
|
||||
export class ConflictException extends BaseException {
|
||||
constructor(
|
||||
code: string,
|
||||
message: string,
|
||||
userMessage?: string,
|
||||
recoveryActions?: string[]
|
||||
) {
|
||||
super(
|
||||
ErrorType.CONFLICT,
|
||||
code,
|
||||
message,
|
||||
userMessage || 'ข้อมูลซ้ำกันหรือขัดแย้งกับข้อมูลที่มีอยู่',
|
||||
ErrorSeverity.MEDIUM,
|
||||
undefined,
|
||||
recoveryActions || ['ตรวจสอบข้อมูลที่มีอยู่', 'แก้ไขข้อมูลที่ขัดแย้ง']
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Workflow Errors (422) - ข้อผิดพลาดจาก Workflow Engine
|
||||
export class WorkflowException extends BaseException {
|
||||
constructor(
|
||||
code: string,
|
||||
message: string,
|
||||
userMessage?: string,
|
||||
recoveryActions?: string[]
|
||||
) {
|
||||
super(
|
||||
ErrorType.BUSINESS_RULE,
|
||||
code,
|
||||
message,
|
||||
userMessage || 'ไม่สามารถดำเนินการ Workflow ได้ในสถานะปัจจุบัน',
|
||||
ErrorSeverity.MEDIUM,
|
||||
undefined,
|
||||
recoveryActions || ['ตรวจสอบสถานะเอกสาร', 'ดำเนินการอื่นที่อนุญาต']
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// System/Infrastructure Errors (500) - ปัญหาระบบ
|
||||
export class SystemException extends BaseException {
|
||||
constructor(message: string, details?: Record<string, unknown>) {
|
||||
super(
|
||||
ErrorType.INTERNAL_ERROR,
|
||||
'INTERNAL_ERROR',
|
||||
message,
|
||||
'เกิดข้อผิดพลาดในระบบ กรุณาลองใหม่ภายหลัง',
|
||||
ErrorSeverity.HIGH,
|
||||
details,
|
||||
['ลองใหม่อีกครั้ง', 'ติดต่อผู้ดูแลระบบหากยังพบปัญหา']
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Database Errors (500) - ปัญหาฐานข้อมูล
|
||||
export class DatabaseException extends BaseException {
|
||||
constructor(message: string, details?: Record<string, unknown>) {
|
||||
super(
|
||||
ErrorType.DATABASE_ERROR,
|
||||
'DATABASE_ERROR',
|
||||
message,
|
||||
'เกิดข้อผิดพลาดของฐานข้อมูล กรุณาลองใหม่ภายหลัง',
|
||||
ErrorSeverity.HIGH,
|
||||
details,
|
||||
['ลองใหม่ภายหลัง', 'แจ้งผู้ดูแลระบบหากยังพบปัญหา']
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
// File: src/common/exceptions/index.ts
|
||||
// Barrel export สำหรับ Exception Hierarchy ทั้งหมด
|
||||
|
||||
export {
|
||||
ErrorType,
|
||||
ErrorSeverity,
|
||||
getStatusCode,
|
||||
BaseException,
|
||||
ValidationException,
|
||||
BusinessException,
|
||||
NotFoundException,
|
||||
PermissionException,
|
||||
ConflictException,
|
||||
WorkflowException,
|
||||
SystemException,
|
||||
DatabaseException,
|
||||
} from './base.exception';
|
||||
|
||||
export type { ValidationErrorDetail, ErrorPayload } from './base.exception';
|
||||
@@ -0,0 +1,216 @@
|
||||
// File: src/common/filters/global-exception.filter.ts
|
||||
// ADR-007: Global Exception Filter พร้อม Layered Error Processing
|
||||
|
||||
import {
|
||||
ExceptionFilter,
|
||||
Catch,
|
||||
ArgumentsHost,
|
||||
HttpException,
|
||||
HttpStatus,
|
||||
Injectable,
|
||||
Logger,
|
||||
} from '@nestjs/common';
|
||||
import { Response } from 'express';
|
||||
import { RequestWithUser } from '../interfaces/request-with-user.interface';
|
||||
import {
|
||||
BaseException,
|
||||
ErrorType,
|
||||
ErrorSeverity,
|
||||
ErrorPayload,
|
||||
} from '../exceptions/base.exception';
|
||||
|
||||
// รูปแบบ Error Response ที่ส่งกลับ Client
|
||||
interface ErrorResponse {
|
||||
error: ErrorPayload & { statusCode: number };
|
||||
}
|
||||
|
||||
// ข้อมูล Log สำหรับ Error
|
||||
interface ErrorLogData {
|
||||
path: string;
|
||||
method: string;
|
||||
userId?: number;
|
||||
ip: string;
|
||||
userAgent: string;
|
||||
exception: {
|
||||
name: string;
|
||||
message: string;
|
||||
stack?: string;
|
||||
details?: unknown;
|
||||
};
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
@Catch()
|
||||
export class GlobalExceptionFilter implements ExceptionFilter {
|
||||
private readonly logger = new Logger(GlobalExceptionFilter.name);
|
||||
|
||||
catch(exception: unknown, host: ArgumentsHost): void {
|
||||
const ctx = host.switchToHttp();
|
||||
const response = ctx.getResponse<Response>();
|
||||
const request = ctx.getRequest<RequestWithUser>();
|
||||
|
||||
let errorResponse: ErrorResponse;
|
||||
let httpStatus: number;
|
||||
|
||||
if (exception instanceof BaseException) {
|
||||
// จัดการ Custom Exception ของเรา (ADR-007)
|
||||
const payload = exception.getResponse() as { error: ErrorPayload };
|
||||
httpStatus = exception.httpStatus;
|
||||
errorResponse = {
|
||||
error: {
|
||||
...payload.error,
|
||||
statusCode: httpStatus,
|
||||
},
|
||||
};
|
||||
this.logError(
|
||||
exception,
|
||||
request,
|
||||
exception.severity === ErrorSeverity.CRITICAL
|
||||
);
|
||||
} else if (exception instanceof HttpException) {
|
||||
// จัดการ NestJS Built-in Exceptions
|
||||
httpStatus = exception.getStatus();
|
||||
const exceptionResponse = exception.getResponse();
|
||||
|
||||
// แปลง NestJS exception response เป็น user-friendly message
|
||||
let technicalDetail: unknown = exceptionResponse;
|
||||
if (typeof exceptionResponse === 'object' && exceptionResponse !== null) {
|
||||
technicalDetail = exceptionResponse;
|
||||
}
|
||||
|
||||
errorResponse = {
|
||||
error: {
|
||||
type: this.mapStatusToErrorType(httpStatus),
|
||||
code: 'HTTP_ERROR',
|
||||
message: this.mapStatusToUserMessage(httpStatus),
|
||||
severity:
|
||||
httpStatus >= 500 ? ErrorSeverity.HIGH : ErrorSeverity.MEDIUM,
|
||||
timestamp: new Date().toISOString(),
|
||||
statusCode: httpStatus,
|
||||
recoveryActions: this.mapStatusToRecoveryActions(httpStatus),
|
||||
...(process.env['NODE_ENV'] !== 'production' && {
|
||||
technicalMessage: exception.message,
|
||||
details: technicalDetail as Record<string, unknown>,
|
||||
}),
|
||||
},
|
||||
};
|
||||
this.logError(exception, request, httpStatus >= 500);
|
||||
} else {
|
||||
// จัดการ Unexpected Errors (ไม่รู้ประเภท)
|
||||
httpStatus = HttpStatus.INTERNAL_SERVER_ERROR;
|
||||
errorResponse = {
|
||||
error: {
|
||||
type: ErrorType.INTERNAL_ERROR,
|
||||
code: 'UNEXPECTED_ERROR',
|
||||
message: 'เกิดข้อผิดพลาดที่ไม่คาดคิด กรุณาลองใหม่ภายหลัง',
|
||||
severity: ErrorSeverity.CRITICAL,
|
||||
timestamp: new Date().toISOString(),
|
||||
statusCode: httpStatus,
|
||||
recoveryActions: [
|
||||
'ลองใหม่อีกครั้ง',
|
||||
'ติดต่อผู้ดูแลระบบหากยังพบปัญหา',
|
||||
],
|
||||
},
|
||||
};
|
||||
this.logError(exception, request, true);
|
||||
}
|
||||
|
||||
response.status(httpStatus).json(errorResponse);
|
||||
}
|
||||
|
||||
// Logging แยกตามความรุนแรง
|
||||
private logError(
|
||||
exception: unknown,
|
||||
request: RequestWithUser,
|
||||
isCritical: boolean
|
||||
): void {
|
||||
const err =
|
||||
exception instanceof Error ? exception : new Error(String(exception));
|
||||
|
||||
const logData: ErrorLogData = {
|
||||
path: request.url,
|
||||
method: request.method,
|
||||
userId: request.user?.user_id,
|
||||
ip: request.ip ?? 'unknown',
|
||||
userAgent: (request.headers['user-agent'] as string) ?? 'unknown',
|
||||
exception: {
|
||||
name: err.name,
|
||||
message: err.message,
|
||||
stack: err.stack,
|
||||
details:
|
||||
exception instanceof BaseException ? exception.details : undefined,
|
||||
},
|
||||
};
|
||||
|
||||
if (isCritical) {
|
||||
this.logger.error('Critical error occurred', JSON.stringify(logData));
|
||||
} else {
|
||||
this.logger.warn('Error occurred', JSON.stringify(logData));
|
||||
}
|
||||
}
|
||||
|
||||
// แปลง HTTP Status เป็น ErrorType
|
||||
private mapStatusToErrorType(status: number): ErrorType {
|
||||
switch (status) {
|
||||
case 400:
|
||||
return ErrorType.VALIDATION;
|
||||
case 401:
|
||||
case 403:
|
||||
return ErrorType.PERMISSION_DENIED;
|
||||
case 404:
|
||||
return ErrorType.NOT_FOUND;
|
||||
case 409:
|
||||
return ErrorType.CONFLICT;
|
||||
case 422:
|
||||
return ErrorType.BUSINESS_RULE;
|
||||
default:
|
||||
return ErrorType.INTERNAL_ERROR;
|
||||
}
|
||||
}
|
||||
|
||||
// แปลง HTTP Status เป็น User-friendly Message (ภาษาไทย)
|
||||
private mapStatusToUserMessage(status: number): string {
|
||||
switch (status) {
|
||||
case 400:
|
||||
return 'ข้อมูลที่ส่งมาไม่ถูกต้อง กรุณาตรวจสอบและลองใหม่';
|
||||
case 401:
|
||||
return 'กรุณาเข้าสู่ระบบก่อนใช้งาน';
|
||||
case 403:
|
||||
return 'คุณไม่มีสิทธิ์ในการดำเนินการนี้';
|
||||
case 404:
|
||||
return 'ไม่พบข้อมูลที่ร้องขอ';
|
||||
case 409:
|
||||
return 'ข้อมูลซ้ำกันหรือมีความขัดแย้ง';
|
||||
case 422:
|
||||
return 'ไม่สามารถดำเนินการได้เนื่องจากเงื่อนไขทางธุรกิจ';
|
||||
case 429:
|
||||
return 'คำขอมากเกินไป กรุณารอสักครู่แล้วลองใหม่';
|
||||
default:
|
||||
return 'เกิดข้อผิดพลาดในระบบ กรุณาลองใหม่ภายหลัง';
|
||||
}
|
||||
}
|
||||
|
||||
// Recovery Actions สำหรับแต่ละ HTTP Status
|
||||
private mapStatusToRecoveryActions(status: number): string[] {
|
||||
switch (status) {
|
||||
case 400:
|
||||
return [
|
||||
'ตรวจสอบข้อมูลที่กรอก',
|
||||
'แก้ไขข้อมูลที่ผิดพลาด',
|
||||
'ลองใหม่อีกครั้ง',
|
||||
];
|
||||
case 401:
|
||||
return ['เข้าสู่ระบบ', 'ตรวจสอบ Session ที่หมดอายุ'];
|
||||
case 403:
|
||||
return ['ติดต่อผู้ดูแลระบบเพื่อขอสิทธิ์', 'ลองใช้บัญชีที่มีสิทธิ์'];
|
||||
case 404:
|
||||
return ['ตรวจสอบ ID/UUID ที่ระบุ', 'ค้นหาข้อมูลจากรายการ'];
|
||||
case 409:
|
||||
return ['ตรวจสอบข้อมูลที่มีอยู่', 'แก้ไขข้อมูลที่ขัดแย้ง'];
|
||||
case 429:
|
||||
return ['รอสักครู่แล้วลองใหม่'];
|
||||
default:
|
||||
return ['ลองใหม่อีกครั้ง', 'ติดต่อผู้ดูแลระบบหากยังพบปัญหา'];
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user