690406:2310 Done Task BE-ERR-01
CI / CD Pipeline / build (push) Failing after 4m53s
CI / CD Pipeline / deploy (push) Has been skipped

This commit is contained in:
2026-04-06 23:10:56 +07:00
parent c95e0f537e
commit 961ee72343
24 changed files with 1329 additions and 268 deletions
+2 -2
View File
@@ -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,
['ลองใหม่ภายหลัง', 'แจ้งผู้ดูแลระบบหากยังพบปัญหา']
);
}
}
+19
View File
@@ -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 ['ลองใหม่อีกครั้ง', 'ติดต่อผู้ดูแลระบบหากยังพบปัญหา'];
}
}
}