This commit is contained in:
2025-12-09 17:02:27 +09:00
parent 26f8e1dab2
commit 83127da569
275 changed files with 139682 additions and 1 deletions

View File

@@ -0,0 +1,20 @@
import { Test, TestingModule } from '@nestjs/testing';
import { CommonController } from './common.controller';
import { CommonService } from './common.service';
describe('CommonController', () => {
let controller: CommonController;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [CommonController],
providers: [CommonService],
}).compile();
controller = module.get<CommonController>(CommonController);
});
it('should be defined', () => {
expect(controller).toBeDefined();
});
});

View File

@@ -0,0 +1,7 @@
import { Controller } from '@nestjs/common';
import { CommonService } from './common.service';
@Controller('common')
export class CommonController {
constructor(private readonly commonService: CommonService) {}
}

View File

@@ -0,0 +1,32 @@
import { Module } from '@nestjs/common';
import { CommonController } from './common.controller';
import { CommonService } from './common.service';
import { TypeOrmModule } from '@nestjs/typeorm';
import { JwtStrategy } from './jwt/jwt.strategy';
import { JwtAuthGuard } from './guards/jwt-auth.guard';
import { HttpExceptionFilter } from './filters/http-exception.filter';
import { AllExceptionsFilter } from './filters/all-exceptions.filter';
import { LoggingInterceptor } from './interceptors/logging.interceptor';
import { TransformInterceptor } from './interceptors/transform.interceptor';
@Module({
controllers: [CommonController],
providers: [
CommonService,
JwtStrategy,
JwtAuthGuard,
HttpExceptionFilter,
AllExceptionsFilter,
LoggingInterceptor,
TransformInterceptor,
],
exports: [
JwtStrategy,
JwtAuthGuard,
HttpExceptionFilter,
AllExceptionsFilter,
LoggingInterceptor,
TransformInterceptor,
],
})
export class CommonModule {}

View File

@@ -0,0 +1,18 @@
import { Test, TestingModule } from '@nestjs/testing';
import { CommonService } from './common.service';
describe('CommonService', () => {
let service: CommonService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [CommonService],
}).compile();
service = module.get<CommonService>(CommonService);
});
it('should be defined', () => {
expect(service).toBeDefined();
});
});

View File

@@ -0,0 +1,4 @@
import { Injectable } from '@nestjs/common';
@Injectable()
export class CommonService {}

View File

@@ -0,0 +1,61 @@
/**
* 소 용도 분류 설정
*
* @description
* 소의 용도를 결정하는 비즈니스 로직 임계값 정의
* - 도태 (Culling): 낮은 수태율, 번식 능력 부족
* - 인공수정 (Artificial Insemination): 높은 수태율 + 우수 등급
* - 공란우 (Donor): 중간 수태율 + 우수 등급
* - 수란우 (Recipient): 높은 수태율 + 낮은 등급
*/
export const COW_PURPOSE_CONFIG = {
/**
* 수태율 기반 임계값 (%)
*/
CONCEPTION_RATE_THRESHOLDS: {
/**
* 도태 대상 최대 수태율 (30% 미만)
* 수태율이 이 값보다 낮으면 번식 능력이 부족하여 도태 대상
*/
CULLING_MAX: 30,
/**
* 공란우 최대 수태율 (50% 미만)
* 수태율이 낮지만 우수한 유전자 보유 시 수정란 공급
*/
DONOR_MAX: 50,
/**
* 수란우 최소 수태율 (65% 이상)
* 높은 수태율을 가진 소에게 우수 수정란 이식
*/
RECIPIENT_MIN: 65,
/**
* 인공수정 최소 수태율 (65% 이상)
* 높은 수태율 + 우수 등급 → 일반 인공수정 대상
*/
INSEMINATION_MIN: 65,
},
/**
* 나이 기반 임계값 (년)
*/
AGE_THRESHOLDS: {
/**
* 노령우 기준 (10년 이상)
* 이 나이 이상이면 도태 고려 대상
*/
OLD_AGE_YEARS: 10,
/**
* 번식 적정 최소 나이 (2년)
*/
BREEDING_MIN_AGE: 2,
/**
* 번식 적정 최대 나이 (8년)
*/
BREEDING_MAX_AGE: 8,
},
} as const;

View File

@@ -0,0 +1,85 @@
/**
* 유전체 분석 유효성 조건 설정
*
* @description
* 유전체 분석 데이터가 유효한지 판단하는 조건 정의
*
* 유효 조건:
* 1. chipSireName === '일치' (아비 칩 데이터 일치)
* 2. chipDamName !== '불일치' (어미 칩 데이터 불일치가 아님)
* 3. chipDamName !== '이력제부재' (어미 이력제 부재가 아님)
* 4. cowId가 EXCLUDED_COW_IDS에 포함되지 않음
*
* 제외되는 경우:
* - chipSireName !== '일치' (아비 불일치, 이력제부재 등)
* - chipDamName === '불일치' (어미 불일치)
* - chipDamName === '이력제부재' (어미 이력제 부재)
* - 분석불가 개체 (EXCLUDED_COW_IDS)
*/
/** 유효한 아비 칩 이름 값 */
export const VALID_CHIP_SIRE_NAME = '일치';
/** 제외할 어미 칩 이름 값 목록 */
export const INVALID_CHIP_DAM_NAMES = ['불일치', '이력제부재'];
/** ===============================개별 제외 개체 목록 (분석불가 등 특수 사유) 하단 개체 정보없음 확인필요=================*/
export const EXCLUDED_COW_IDS = [
'KOR002191642861', // 1회 분석 반려내역서 재분석 불가능
];
//=================================================================================================================
/** @deprecated INVALID_CHIP_DAM_NAMES 사용 권장 */
export const INVALID_CHIP_DAM_NAME = '불일치';
/**
* 유전체 분석 데이터 유효성 검사
*
* @param chipSireName - 아비 칩 이름 (친자감별 결과)
* @param chipDamName - 어미 칩 이름 (친자감별 결과)
* @param cowId - 개체식별번호 (선택, 개별 제외 목록 확인용)
* @returns 유효한 분석 데이터인지 여부
*
* @example
* // 유효한 경우
* isValidGenomeAnalysis('일치', '일치') // true
* isValidGenomeAnalysis('일치', null) // true
* isValidGenomeAnalysis('일치', '정보없음') // true
*
* // 유효하지 않은 경우
* isValidGenomeAnalysis('불일치', '일치') // false (아비 불일치)
* isValidGenomeAnalysis('일치', '불일치') // false (어미 불일치)
* isValidGenomeAnalysis('일치', '이력제부재') // false (어미 이력제부재)
* isValidGenomeAnalysis('일치', '일치', 'KOR002191642861') // false (제외 개체)
*/
export function isValidGenomeAnalysis(
chipSireName: string | null | undefined,
chipDamName: string | null | undefined,
cowId?: string | null,
): boolean {
// 1. 아비 일치 확인
if (chipSireName !== VALID_CHIP_SIRE_NAME) return false;
// 2. 어미 제외 조건 확인
if (chipDamName && INVALID_CHIP_DAM_NAMES.includes(chipDamName)) return false;
// 3. 개별 제외 개체 확인
if (cowId && EXCLUDED_COW_IDS.includes(cowId)) return false;
return true;
}
/**
* SQL WHERE 조건 생성 (TypeORM QueryBuilder용)
* 주의: cowId 제외 목록은 SQL에 포함되지 않으므로 별도 필터링 필요
*
* @param alias - 테이블 별칭 (예: 'request', 'genome')
* @returns SQL 조건 문자열
*
* @example
* queryBuilder.andWhere(getValidGenomeConditionSQL('request'));
*/
export function getValidGenomeConditionSQL(alias: string): string {
const damConditions = INVALID_CHIP_DAM_NAMES.map(name => `${alias}.chipDamName != '${name}'`).join(' AND ');
return `${alias}.chipSireName = '${VALID_CHIP_SIRE_NAME}' AND (${alias}.chipDamName IS NULL OR (${damConditions}))`;
}

View File

@@ -0,0 +1,73 @@
/**
* 근친도 관련 설정 상수
*
* @description
* Wright's Coefficient of Inbreeding 알고리즘 기반 근친도 계산 및 위험도 판정 기준
*
* @source PRD 기능요구사항20.md SFR-COW-016-3
* @reference Wright, S. (1922). Coefficients of Inbreeding and Relationship
*/
export const INBREEDING_CONFIG = {
/**
* 위험도 판정 기준 (%)
* - 정상: < 15%
* - 주의: 15-20%
* - 위험: > 20%
*/
RISK_LEVELS: {
NORMAL_MAX: 15, // 정상 상한선 (< 15%)
WARNING_MIN: 15, // 주의 하한선 (>= 15%)
WARNING_MAX: 20, // 주의 상한선 (<= 20%)
DANGER_MIN: 20, // 위험 하한선 (> 20%)
},
/**
* 다세대 시뮬레이션 위험도 판정 기준 (%)
* - 정상: < 6.25%
* - 주의: 6.25% ~ 임계값
* - 위험: > 임계값
*/
MULTI_GENERATION_RISK_LEVELS: {
SAFE_MAX: 6.25, // 안전 상한선 (< 6.25%)
// WARNING: 6.25% ~ inbreedingThreshold (사용자 지정)
// DANGER: > inbreedingThreshold (사용자 지정)
},
/**
* 기본 근친도 임계값 (%)
* Wright's Coefficient 기준 안전 임계값
*/
DEFAULT_THRESHOLD: 12.5,
/**
* 세대별 근친도 영향 감소율
* - 1세대: 100% 영향
* - 2세대: 50% 영향 (1/2)
* - 3세대: 25% 영향 (1/4)
* - 4세대: 12.5% 영향 (1/8)
*/
GENERATION_DECAY: {
GEN_1: 1.0, // 100%
GEN_2: 0.5, // 50%
GEN_3: 0.25, // 25%
GEN_4: 0.125, // 12.5%
GEN_5: 0.0625, // 6.25%
},
/**
* KPN 순환 전략 설정
*/
ROTATION_STRATEGY: {
CYCLE_GENERATIONS: 2, // N세대마다 순환 (기본값: 2세대)
},
/**
* 유리형 비율 평가 기준 (%)
*/
FAVORABLE_RATE_THRESHOLDS: {
EXCELLENT: 75, // 매우 우수 (>= 75%)
GOOD: 60, // 양호 (>= 60%)
AVERAGE: 50, // 보통 (>= 50%)
POOR: 70, // 권장사항 생성 기준 (>= 70%)
},
} as const;

View File

@@ -0,0 +1,90 @@
/**
* MPT 혈액대사검사 정상 범위 기준값
*
* @description
* 각 MPT 항목별 권장 정상 범위를 정의합니다.
* 이 범위 내에 있으면 "우수" 판정을 받습니다.
*
* @export
* @constant
*/
export const MPT_NORMAL_RANGES = {
/**
* 알부민 (Albumin)
* 단위: g/dL
*/
albumin: { min: 3.3, max: 4.3 },
/**
* 총 글로불린 (Total Globulin)
* 단위: g/L
*/
totalGlobulin: { min: 9.1, max: 36.1 },
/**
* A/G 비율 (Albumin/Globulin Ratio)
* 단위: 비율
*/
agRatio: { min: 0.1, max: 0.4 },
/**
* 혈중요소질소 (Blood Urea Nitrogen)
* 단위: mg/dL
*/
bun: { min: 11.7, max: 18.9 },
/**
* AST (Aspartate Aminotransferase)
* 단위: U/L
*/
ast: { min: 47, max: 92 },
/**
* GGT (Gamma-Glutamyl Transferase)
* 단위: U/L
*/
ggt: { min: 11, max: 32 },
/**
* 지방간 지수 (Fatty Liver Index)
* 단위: 지수
*/
fattyLiverIndex: { min: -1.2, max: 9.9 },
/**
* 칼슘 (Calcium)
* 단위: mg/dL
*/
calcium: { min: 8.1, max: 10.6 },
/**
* 인 (Phosphorus)
* 단위: mg/dL
*/
phosphorus: { min: 6.2, max: 8.9 },
/**
* Ca/P 비율 (Calcium/Phosphorus Ratio)
* 단위: 비율
*/
caPRatio: { min: 1.2, max: 1.3 },
/**
* 마그네슘 (Magnesium)
* 단위: mg/dL
*/
magnesium: { min: 1.6, max: 3.3 },
} as const;
/**
* MPT 항목 타입
*/
export type MptCriteriaKey = keyof typeof MPT_NORMAL_RANGES;
/**
* MPT 범위 타입
*/
export interface MptRange {
min: number;
max: number;
}

View File

@@ -0,0 +1,62 @@
/**
* 페이징 설정 상수
*
* @description
* 목록 조회, 검색, 가상 스크롤링 등의 페이징 기본값
*/
export const PAGINATION_CONFIG = {
/**
* 목록별 기본 페이지당 항목 수
*/
DEFAULTS: {
/**
* 개체 목록
* 일반 개체 목록 조회 시 기본값
*/
COW_LIST: 10,
/**
* 교배조합 목록
*/
BREED_SAVE: 10,
/**
* 검색 결과
* 키워드 검색 결과 표시 기본값
*/
SEARCH_RESULT: 20,
/**
* 유전자 검색
* 5000개 이상 유전자 검색 시 기본값
*/
GENE_SEARCH: 50,
/**
* 가상 스크롤링
* 무한 스크롤 방식 로딩 단위
*/
VIRTUAL_SCROLL: 50,
},
/**
* 제한값
*/
LIMITS: {
/**
* 최소 페이지당 항목 수
*/
MIN: 1,
/**
* 최대 페이지당 항목 수
* 성능 및 메모리 고려
*/
MAX: 100,
/**
* 기본 페이지 번호
*/
DEFAULT_PAGE: 1,
},
} as const;

View File

@@ -0,0 +1,105 @@
/**
* 추천 시스템 설정 상수
*
* @description
* KPN 추천, 개체 추천, 패키지 추천 등 추천 시스템 관련 설정값
*
* @source PRD 기능요구사항20.md SFR-COW-016, SFR-COW-037
*/
export const RECOMMENDATION_CONFIG = {
/**
* 유전자 매칭 점수 관련
*/
GENE_SCORE: {
/**
* 점수 차이 임계값
* 유전자 매칭 점수 차이가 이 값보다 작으면 근친도를 우선 고려
*/
DIFF_THRESHOLD: 5,
},
/**
* 기본값
*/
DEFAULTS: {
/**
* 근친도 임계값 (%)
* Wright's Coefficient 기준
*/
INBREEDING_THRESHOLD: 12.5,
/**
* 추천 개수
* 상위 N개의 KPN/개체를 추천
*/
RECOMMENDATION_LIMIT: 10,
/**
* 세대제약 기준
* 최근 N세대 이내 사용된 KPN을 추천에서 제외
*/
GENERATION_THRESHOLD: 3,
},
/**
* KPN 패키지 설정
*/
PACKAGE: {
/**
* 기본 패키지 크기
* 추천할 KPN 세트 개수
*/
DEFAULT_SIZE: 5,
/**
* 최소 패키지 크기
*/
MIN_SIZE: 3,
/**
* 최대 패키지 크기
*/
MAX_SIZE: 10,
},
/**
* 커버리지 기준 (%)
* 유전자 목표 달성률 평가 기준
*/
COVERAGE: {
/**
* 우수 기준
* 50% 이상 커버리지
*/
EXCELLENT: 50,
/**
* 양호 기준
* 30% 이상 커버리지
*/
GOOD: 30,
/**
* 최소 기준
* 20% 이상 커버리지
*/
MINIMUM: 20,
},
/**
* KPN 순환 전략
*/
ROTATION: {
/**
* 최소 KPN 개수
* 순환 전략 적용 최소 개수
*/
MIN_KPN_COUNT: 3,
/**
* 재사용 안전 세대
* 동일 KPN을 이 세대 이후에 재사용 가능
*/
SAFE_REUSE_GENERATION: 4,
},
} as const;

View File

@@ -0,0 +1,36 @@
/**
* 인증 관련 설정 (이메일)
*
* @description
* 이메일 인증, 비밀번호 재설정 등의 인증 관련 상수 값 정의
*/
export const VERIFICATION_CONFIG = {
/**
* 인증 코드 만료 시간 (초)
* 인증시간 3분 = 180초
*/
CODE_EXPIRY_SECONDS: 180,
/**
* 비밀번호 재설정 토큰 만료 시간 (초)
* 30분 = 1800초
*/
RESET_TOKEN_EXPIRY_SECONDS: 1800,
/**
* 랜덤 토큰 생성을 위한 바이트 길이
* 32바이트 = 64자의 16진수 문자열
*/
TOKEN_BYTES_LENGTH: 32,
/**
* 인증 코드 길이 (6자리 숫자)
*/
CODE_LENGTH: 6,
/**
* 인증 코드 재전송 대기 시간 (초)
* 1분 = 60초
*/
RESEND_DELAY_SECONDS: 60,
} as const;

View File

@@ -0,0 +1,6 @@
// 계정 상태 Enum
export enum AccountStatusType {
ACTIVE = "ACTIVE", // 정상
INACTIVE = "INACTIVE", // 비활성
SUSPENDED = "SUSPENDED", // 정지
}

View File

@@ -0,0 +1,5 @@
// 개체 타입 Enum
export enum AnimalType {
COW = 'COW', // 개체
KPN = 'KPN', // KPN
}

View File

@@ -0,0 +1,12 @@
/**
* 분석 현황 상태 값 Enum
*
* @export
* @enum {number}
*/
export enum AnlysStatType {
MATCH = '친자일치',
MISMATCH = '친자불일치',
IMPOSSIBLE = '분석불가',
NO_HISTORY = '이력제부재',
}

View File

@@ -0,0 +1,13 @@
/**
* 사육/도태 추천 타입 Enum
*
* @export
* @enum {string}
*/
export enum BreedingRecommendationType {
/** 사육 추천 */
BREED = '사육추천',
/** 도태 추천 */
CULL = '도태추천',
}

View File

@@ -0,0 +1,7 @@
// 개체 번식 타입 Enum
export enum CowReproType {
DONOR = "공란우",
RECIPIENT = "수란우",
AI = "인공수정",
CULL = "도태대상",
}

View File

@@ -0,0 +1,7 @@
// 개체 상태 Enum
export enum CowStatusType {
NORMAL = "정상",
DEAD = "폐사",
SLAUGHTER = "도축",
SALE = "매각",
}

View File

@@ -0,0 +1,55 @@
/**
* 파일 타입 Enum
*
* @description
* 엑셀 업로드 시 지원되는 파일 유형을 정의합니다.
* 각 파일 유형별로 고유한 파싱 로직과 대상 테이블이 매핑됩니다.
*
* @reference SFR-ADMIN-001 (기능요구사항20.md)
*
* 파일 유형별 매핑:
* - COW: 개체(암소) 정보 → tb_cow
* - GENE: 유전자(SNP) 데이터 → tb_snp_cow
* - GENOME: 유전체(유전능력) 데이터 → tb_genome_cow
* - MPT: 혈액대사검사(MPT) (1행: 카테고리, 2행: 항목명, 3행~: 데이터) → tb_repro_mpt, tb_repro_mpt_item
* - FERTILITY: 수태율 데이터 → tb_fertility_rate
* - KPN_GENE: KPN 유전자 데이터 → tb_kpn_snp
* - KPN_GENOME: KPN 유전체 데이터 → tb_kpn_genome
* - KPN_MPT: KPN 혈액대사검사 → tb_kpn_mpt
* - REGION_COW: 지역 개체 정보 → tb_region_cow
* - REGION_GENE: 지역 유전자 데이터 → tb_region_snp
* - REGION_GENOME: 지역 유전체 데이터 → tb_region_genome
* - REGION_MPT: 지역 혈액대사검사 → tb_region_mpt
* - HELP: 도움말 데이터 (유전자/유전체/번식능력 설명) → tb_help_content
* - MARKER: 마커(유전자) 정보 (마커명, 관련형질, 목표유전자형 등) → tb_marker
* 목표유전자형(target_genotype): KPN 추천 시 각 유전자의 우량형 기준 (AA, GG, CC 등)
*/
export enum FileType {
// 개체(암소) 데이터
COW = '개체',
// 유전 데이터
GENE = '유전자',
GENOME = '유전체',
// 번식 데이터
MPT = '혈액대사검사',
FERTILITY = '수태율',
// KPN 데이터
KPN_GENE = 'KPN유전자',
KPN_GENOME = 'KPN유전체',
KPN_MPT = 'KPN혈액대사검사',
// 지역 개체 데이터 (보은군 비교용)
REGION_COW = '지역개체',
REGION_GENE = '지역유전자',
REGION_GENOME = '지역유전체',
REGION_MPT = '지역혈액대사검사',
// 도움말 데이터
HELP = '도움말',
// 마커(유전자) 정보
MARKER = '마커정보',
}

View File

@@ -0,0 +1,11 @@
/**
* 사용자 타입 구분 Enum
*
* @export
* @enum {number}
*/
export enum UserSeType {
FARM = 'FARM', // 농가
CNSLT = 'CNSLT', // 컨설턴트
ORGAN = 'ORGAN', // 기관담당자
}

View File

@@ -0,0 +1,41 @@
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
/**
* 현재 로그인한 사용자 정보를 가져오는 Decorator
*
* @description
* 인증 미들웨어(JWT, Passport 등)가 req.user에 추가한 사용자 정보를 추출합니다.
* 인증되지 않은 경우 기본값을 반환합니다.
*
* @example
* // 전체 user 객체 가져오기
* async method(@CurrentUser() user: any) {
* console.log(user.userId, user.email);
* }
*
* @example
* // 특정 속성만 가져오기
* async method(@CurrentUser('userId') userId: string) {
* console.log(userId); // 'user123' or 'system'
* }
*/
export const CurrentUser = createParamDecorator(
(data: string | undefined, ctx: ExecutionContext) => {
const request = ctx.switchToHttp().getRequest();
const user = request.user;
// 사용자 정보가 없으면 기본값 반환
if (!user) {
// userId를 요청한 경우 'system' 반환
if (data === 'userId') {
return 'system';
}
// 전체 user 객체를 요청한 경우 null 반환
return null;
}
// 특정 속성을 요청한 경우 해당 속성 반환
// 전체 user 객체를 요청한 경우 user 반환
return data ? user[data] : user;
},
);

View File

@@ -0,0 +1,29 @@
import { SetMetadata } from '@nestjs/common';
/**
* Public 데코레이터
*
* @description
* 인증이 필요 없는 공개 엔드포인트를 표시하는 데코레이터입니다.
* JwtAuthGuard와 함께 사용하여 특정 엔드포인트의 인증을 건너뜁니다.
*
* @example
* // 로그인, 회원가입 등 인증 없이 접근 가능한 엔드포인트
* @Public()
* @Post('login')
* async login(@Body() loginDto: LoginDto) {
* return this.authService.login(loginDto);
* }
*
* @example
* // 전체 컨트롤러를 공개로 설정
* @Public()
* @Controller('public')
* export class PublicController {
* // 모든 엔드포인트가 인증 없이 접근 가능
* }
*
* @export
* @constant Public
*/
export const Public = () => SetMetadata('isPublic', true);

View File

@@ -0,0 +1,37 @@
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
/**
* User 데코레이터
*
* @description
* JWT 인증 후 Request 객체에서 사용자 정보를 추출하는 데코레이터입니다.
* @Req() req 대신 사용하여 더 간결하게 사용자 정보를 가져올 수 있습니다.
*
* @example
* // 전체 사용자 정보 가져오기
* @Get('profile')
* @UseGuards(JwtAuthGuard)
* getProfile(@User() user: any) {
* return user; // { userId: '...', userNo: 1, role: 'user' }
* }
*
* @example
* // 특정 필드만 가져오기
* @Get('my-data')
* @UseGuards(JwtAuthGuard)
* getMyData(@User('userId') userId: string) {
* return `Your ID is ${userId}`;
* }
*
* @export
* @constant User
*/
export const User = createParamDecorator(
(data: string, ctx: ExecutionContext) => {
const request = ctx.switchToHttp().getRequest();
const user = request.user;
// 특정 필드만 반환
return data ? user?.[data] : user;
},
);

View File

@@ -0,0 +1,87 @@
import { Column, CreateDateColumn, DeleteDateColumn, UpdateDateColumn } from 'typeorm';
/**
* TypeORM Entity
*
* @export
* @abstract
* @class BaseModel
* @typedef {BaseModel}
*/
export abstract class BaseModel {
/**
* 모든 테이블의 기본 키 (Primary Key)
*/
// @PrimaryGeneratedColumn()
// no: number;
/**
* 데이터가 처음 생성된 시간
*/
@CreateDateColumn({
name: 'reg_dt',
type: 'timestamp',
nullable: false,
default: () => 'NOW()',
comment: '등록일시',
})
regDt: Date;
/**
*데이터가 마지막으로 업데이트된 시간
*/
@UpdateDateColumn({
name: 'updt_dt',
type: 'timestamp',
nullable: true,
comment: '수정일시',
})
updtDt: Date;
/**
* Soft Delete 삭제일시 (NULL이면 활성 데이터)
*/
@DeleteDateColumn({
name: 'del_dt',
type: 'timestamp',
nullable: true,
comment: '삭제일시 (Soft Delete, NULL=활성)',
})
delDt: Date;
@Column({
name: 'reg_ip',
type: 'varchar',
length: 50,
nullable: true,
comment: '등록 IP',
})
regIp: string;
@Column({
name: 'reg_user_id',
type: 'varchar',
length: 100,
nullable: true,
comment: '등록자 ID',
})
regUserId: string;
@Column({
name: 'updt_ip',
type: 'varchar',
length: 50,
nullable: true,
comment: '수정 IP',
})
updtIp: string;
@Column({
name: 'updt_user_id',
type: 'varchar',
length: 100,
nullable: true,
comment: '수정자 ID',
})
updtUserId: string;
}

View File

@@ -0,0 +1,80 @@
import {
ExceptionFilter,
Catch,
ArgumentsHost,
HttpException,
HttpStatus,
} from '@nestjs/common';
import { Request, Response } from 'express';
/**
* 모든 예외 필터
*
* @description
* HTTP 예외뿐만 아니라 모든 예외를 잡아서 처리합니다.
* 예상치 못한 에러도 일관된 형식으로 응답합니다.
*
* @example
* // main.ts에서 전역 적용
* app.useGlobalFilters(new AllExceptionsFilter());
*
* @export
* @class AllExceptionsFilter
* @implements {ExceptionFilter}
*/
@Catch()
export class AllExceptionsFilter implements ExceptionFilter {
catch(exception: unknown, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
const request = ctx.getRequest<Request>();
let status: number;
let message: string | string[];
if (exception instanceof HttpException) {
// HTTP 예외 처리
status = exception.getStatus();
const exceptionResponse = exception.getResponse();
if (typeof exceptionResponse === 'string') {
message = exceptionResponse;
} else if (typeof exceptionResponse === 'object' && exceptionResponse !== null) {
message = (exceptionResponse as any).message || exception.message;
} else {
message = exception.message;
}
} else {
// 예상치 못한 에러 처리
status = HttpStatus.INTERNAL_SERVER_ERROR;
message = '서버 내부 오류가 발생했습니다.';
// 개발 환경에서는 실제 에러 메시지 표시
if (process.env.NODE_ENV === 'development' && exception instanceof Error) {
message = exception.message;
}
}
const errorResponse = {
success: false,
statusCode: status,
timestamp: new Date().toISOString(),
path: request.url,
method: request.method,
message: Array.isArray(message) ? message : [message],
};
// 개발 환경에서는 스택 트레이스 포함
if (process.env.NODE_ENV === 'development' && exception instanceof Error) {
(errorResponse as any).stack = exception.stack;
}
// 에러 로깅
console.error(
`[${errorResponse.timestamp}] ${request.method} ${request.url} - ${status}`,
exception,
);
response.status(status).json(errorResponse);
}
}

View File

@@ -0,0 +1,93 @@
import {
ExceptionFilter,
Catch,
ArgumentsHost,
HttpException,
HttpStatus,
} from '@nestjs/common';
import { Request, Response } from 'express';
/**
* HTTP 예외 필터
*
* @description
* 모든 HTTP 예외를 잡아서 일관된 형식으로 응답을 반환합니다.
*
* @example
* // main.ts에서 전역 적용
* app.useGlobalFilters(new HttpExceptionFilter());
*
* @export
* @class HttpExceptionFilter
* @implements {ExceptionFilter}
*/
@Catch(HttpException)
export class HttpExceptionFilter implements ExceptionFilter {
catch(exception: HttpException, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
const request = ctx.getRequest<Request>();
const status = exception.getStatus();
const exceptionResponse = exception.getResponse();
// 에러 메시지 추출
let message: string | string[];
if (typeof exceptionResponse === 'string') {
message = exceptionResponse;
} else if (typeof exceptionResponse === 'object' && exceptionResponse !== null) {
message = (exceptionResponse as any).message || exception.message;
} else {
message = exception.message;
}
// 일관된 에러 응답 형식
const errorResponse = {
success: false,
statusCode: status,
timestamp: new Date().toISOString(),
path: request.url,
method: request.method,
message: Array.isArray(message) ? message : [message],
error: this.getErrorName(status),
};
// 개발 환경에서는 스택 트레이스 포함
if (process.env.NODE_ENV === 'development') {
(errorResponse as any).stack = exception.stack;
}
// 로깅
console.error(
`[${errorResponse.timestamp}] ${request.method} ${request.url} - ${status}`,
message,
);
response.status(status).json(errorResponse);
}
/**
* HTTP 상태 코드에 따른 에러 이름 반환
*
* @private
* @param {number} status - HTTP 상태 코드
* @returns {string}
*/
private getErrorName(status: number): string {
switch (status) {
case HttpStatus.BAD_REQUEST:
return 'Bad Request';
case HttpStatus.UNAUTHORIZED:
return 'Unauthorized';
case HttpStatus.FORBIDDEN:
return 'Forbidden';
case HttpStatus.NOT_FOUND:
return 'Not Found';
case HttpStatus.CONFLICT:
return 'Conflict';
case HttpStatus.INTERNAL_SERVER_ERROR:
return 'Internal Server Error';
default:
return 'Error';
}
}
}

View File

@@ -0,0 +1,78 @@
import { Injectable, ExecutionContext, UnauthorizedException } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { Reflector } from '@nestjs/core';
/**
* JWT 인증 가드
*
* @description
* JWT 토큰 검증 가드입니다.
* @Public() 데코레이터가 있는 엔드포인트는 인증을 건너뜁니다.
*
* @example
* // 사용법 1: 특정 엔드포인트에 적용
* @UseGuards(JwtAuthGuard)
* @Get('profile')
* getProfile(@Req() req) {
* return req.user;
* }
*
* @example
* // 사용법 2: 전역 적용 (main.ts)
* app.useGlobalGuards(new JwtAuthGuard(new Reflector()));
*
* @example
* // 사용법 3: 인증 제외
* @Public()
* @Get('public')
* getPublicData() {
* return 'Anyone can access';
* }
*
* @export
* @class JwtAuthGuard
* @extends {AuthGuard('jwt')}
*/
@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {
constructor(private reflector: Reflector) {
super();
}
/**
* 인증 확인
*
* @param {ExecutionContext} context - 실행 컨텍스트
* @returns {boolean | Promise<boolean>}
*/
canActivate(context: ExecutionContext) {
// @Public() 데코레이터 확인
const isPublic = this.reflector.getAllAndOverride<boolean>('isPublic', [
context.getHandler(),
context.getClass(),
]);
// Public 엔드포인트는 인증 건너뜀
if (isPublic) {
return true;
}
// JWT 검증 수행
return super.canActivate(context);
}
/**
* 요청 처리
*
* @param {any} err - 에러
* @param {any} user - 사용자 정보
* @param {any} info - 추가 정보
* @returns {any}
*/
handleRequest(err: any, user: any, info: any) {
if (err || !user) {
throw err || new UnauthorizedException('인증이 필요합니다. 로그인 후 이용해주세요.');
}
return user;
}
}

View File

@@ -0,0 +1,54 @@
import {
Injectable,
NestInterceptor,
ExecutionContext,
CallHandler,
} from '@nestjs/common';
import { Observable } from 'rxjs';
import { tap } from 'rxjs/operators';
import { Request } from 'express';
/**
* 로깅 인터셉터
*
* @description
* API 요청/응답을 로깅하고 실행 시간을 측정합니다.
*
* @example
* // main.ts에서 전역 적용
* app.useGlobalInterceptors(new LoggingInterceptor());
*
* @export
* @class LoggingInterceptor
* @implements {NestInterceptor}
*/
@Injectable()
export class LoggingInterceptor implements NestInterceptor {
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
const request = context.switchToHttp().getRequest<Request>();
const { method, url, ip } = request;
const userAgent = request.get('user-agent') || '';
const now = Date.now();
console.log(
`[${new Date().toISOString()}] Incoming Request: ${method} ${url} - ${ip} - ${userAgent}`,
);
return next.handle().pipe(
tap({
next: (data) => {
const responseTime = Date.now() - now;
console.log(
`[${new Date().toISOString()}] Response: ${method} ${url} - ${responseTime}ms`,
);
},
error: (error) => {
const responseTime = Date.now() - now;
console.error(
`[${new Date().toISOString()}] Error Response: ${method} ${url} - ${responseTime}ms - ${error.message}`,
);
},
}),
);
}
}

View File

@@ -0,0 +1,56 @@
import {
Injectable,
NestInterceptor,
ExecutionContext,
CallHandler,
} from '@nestjs/common';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
/**
* 응답 인터페이스 (이메일)
*/
export interface Response<T> {
success: boolean;
data: T;
timestamp: string;
}
/**
* 응답 변환 인터셉터
*
* @description
* 모든 API 응답을 일관된 형식으로 변환합니다.
* { success: true, data: ..., timestamp: ... } 형태로 래핑합니다.
*
* @example
* // main.ts에서 전역 적용
* app.useGlobalInterceptors(new TransformInterceptor());
*
* @example
* // 원래 응답: { name: "홍길동" }
* // 변환 후: { success: true, data: { name: "홍길동" }, timestamp: "2024-01-01T00:00:00.000Z" }
* TransformInterceptor는 모든 응답을 { success, data, timestamp } 구조로 감싼다.
* response.data.data.verified → true
*
* @export
* @class TransformInterceptor
* @implements {NestInterceptor<T, Response<T>>}
*/
@Injectable()
export class TransformInterceptor<T>
implements NestInterceptor<T, Response<T>>
{
intercept(
context: ExecutionContext,
next: CallHandler,
): Observable<Response<T>> {
return next.handle().pipe(
map((data) => ({
success: true,
data, //여기에 return 데이터 객체 전체 응답
timestamp: new Date().toISOString(),
})),
);
}
}

View File

@@ -0,0 +1,23 @@
import { Module } from '@nestjs/common';
import { JwtModule as NestJwtModule, JwtModuleOptions } from '@nestjs/jwt';
import { PassportModule } from '@nestjs/passport';
import { ConfigModule, ConfigService } from '@nestjs/config';
@Module({
imports: [
PassportModule.register({ defaultStrategy: 'jwt' }),
NestJwtModule.registerAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: (configService: ConfigService): JwtModuleOptions => ({
secret: configService.get<string>('JWT_SECRET'),
signOptions: {
expiresIn: configService.get<string>('JWT_EXPIRES_IN') || '24h',
} as any,
global: true,
}),
}),
],
exports: [NestJwtModule, PassportModule],
})
export class JwtModule {}

View File

@@ -0,0 +1,45 @@
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from 'passport-jwt';
import { ConfigService } from '@nestjs/config';
/**
* JWT 전략
*
* @description
* JWT 토큰을 검증하고 사용자 정보를 추출하는 전략입니다.
* Bearer 토큰에서 JWT를 추출하여 검증합니다.
*
* @export
* @class JwtStrategy
* @extends {PassportStrategy(Strategy)}
*/
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
constructor(private configService: ConfigService) {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
ignoreExpiration: false, //false로 설정하면 토큰 만료 시 인증 실패
secretOrKey: configService.get<string>('JWT_SECRET') || 'your-secret-key',
});
}
/**
* JWT 페이로드 검증 및 사용자 정보 반환
*
* @param {any} payload - JWT 페이로드
* @returns {any} 검증된 사용자 정보
*/
validate(payload: any) {
if (!payload.userId) {
throw new UnauthorizedException('유효하지 않은 토큰입니다.');
}
// Request 객체의 user 속성에 저장됨
return {
userId: payload.userId,
userNo: payload.userNo,
role: payload.role || 'user',
};
}
}

View File

@@ -0,0 +1,52 @@
import { Request } from 'express';
/**
* 클라이언트의 실제 IP 주소를 추출합니다.
*
* @description
* Proxy, Load Balancer, CDN 뒤에 있어도 실제 클라이언트 IP를 정확하게 가져옵니다.
* 다음 순서로 IP를 확인합니다:
* 1. X-Forwarded-For 헤더 (Proxy/Load Balancer)
* 2. X-Real-IP 헤더 (Nginx)
* 3. req.ip (Express 기본)
* 4. req.socket.remoteAddress (직접 연결)
* 5. 'unknown' (IP를 찾을 수 없는 경우)
*
* @param req - Express Request 객체
* @returns 클라이언트 IP 주소
*
* @example
* const ip = getClientIp(req);
* console.log(ip); // '203.123.45.67' or 'unknown'
*/
export function getClientIp(req: Request): string {
// 1. X-Forwarded-For 헤더 확인 (Proxy/Load Balancer 환경)
// 형식: "client IP, proxy1 IP, proxy2 IP"
const forwardedFor = req.headers['x-forwarded-for'];
if (forwardedFor) {
// 배열이면 첫 번째 요소, 문자열이면 콤마로 split
const ips = Array.isArray(forwardedFor)
? forwardedFor[0]
: forwardedFor.split(',')[0];
return ips.trim();
}
// 2. X-Real-IP 헤더 확인 (Nginx 환경)
const realIp = req.headers['x-real-ip'];
if (realIp && typeof realIp === 'string') {
return realIp.trim();
}
// 3. Express가 제공하는 req.ip
if (req.ip) {
return req.ip;
}
// 4. Socket의 remoteAddress
if (req.socket?.remoteAddress) {
return req.socket.remoteAddress;
}
// 5. IP를 찾을 수 없는 경우
return 'unknown';
}