generated from ttipo/project-base
Initial commit
This commit is contained in:
@@ -0,0 +1,84 @@
|
||||
import { HttpStatus, ServiceUnavailableException } from '@nestjs/common';
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import {
|
||||
HealthCheckResult,
|
||||
HealthCheckService,
|
||||
TypeOrmHealthIndicator,
|
||||
} from '@nestjs/terminus';
|
||||
import { Response } from 'express';
|
||||
|
||||
import { HealthController } from './health.controller';
|
||||
import { SKIP_TRANSFORM_KEY } from '@/common/decorators/skip-transform.decorator';
|
||||
|
||||
const okResult = {
|
||||
status: 'ok',
|
||||
info: { database: { status: 'up' } },
|
||||
error: {},
|
||||
details: { database: { status: 'up' } },
|
||||
} as unknown as HealthCheckResult;
|
||||
|
||||
const downResult = {
|
||||
status: 'error',
|
||||
info: {},
|
||||
error: { database: { status: 'down', message: 'timeout' } },
|
||||
details: { database: { status: 'down', message: 'timeout' } },
|
||||
};
|
||||
|
||||
describe('HealthController', () => {
|
||||
let controller: HealthController;
|
||||
let check: jest.Mock;
|
||||
let res: { status: jest.Mock };
|
||||
|
||||
beforeEach(async () => {
|
||||
check = jest.fn();
|
||||
res = { status: jest.fn() };
|
||||
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
controllers: [HealthController],
|
||||
providers: [
|
||||
{ provide: HealthCheckService, useValue: { check } },
|
||||
{ provide: TypeOrmHealthIndicator, useValue: { pingCheck: jest.fn() } },
|
||||
],
|
||||
}).compile();
|
||||
|
||||
controller = module.get(HealthController);
|
||||
});
|
||||
|
||||
it('헬스체크는 표준 응답 래핑에서 제외된다 (모니터링 도구가 원본 형식을 읽는다)', () => {
|
||||
// SkipTransform 메타데이터가 컨트롤러에 붙어 있어야 인터셉터가 래핑을 건너뛴다
|
||||
const skip: unknown = Reflect.getMetadata(
|
||||
SKIP_TRANSFORM_KEY,
|
||||
HealthController,
|
||||
);
|
||||
expect(skip).toBe(true);
|
||||
});
|
||||
|
||||
it('정상이면 terminus 결과를 그대로 반환한다', async () => {
|
||||
check.mockResolvedValue(okResult);
|
||||
|
||||
const result = await controller.check(res as unknown as Response);
|
||||
|
||||
expect(result).toEqual(okResult);
|
||||
// 성공 시에는 상태 코드를 건드리지 않는다
|
||||
expect(res.status).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('실패하면 503 과 함께 어느 구성요소가 죽었는지 그대로 노출한다', async () => {
|
||||
check.mockRejectedValue(new ServiceUnavailableException(downResult));
|
||||
|
||||
const result = await controller.check(res as unknown as Response);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(HttpStatus.SERVICE_UNAVAILABLE);
|
||||
// SYS_001 로 덮어쓰지 않고 원인을 유지해야 한다
|
||||
expect(result).toEqual(downResult);
|
||||
expect(JSON.stringify(result)).toContain('database');
|
||||
});
|
||||
|
||||
it('terminus 예외가 아닌 오류는 전역 필터로 넘긴다', async () => {
|
||||
check.mockRejectedValue(new Error('예상치 못한 오류'));
|
||||
|
||||
await expect(controller.check(res as unknown as Response)).rejects.toThrow(
|
||||
'예상치 못한 오류',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
HttpStatus,
|
||||
Res,
|
||||
ServiceUnavailableException,
|
||||
} from '@nestjs/common';
|
||||
import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
|
||||
import { SkipThrottle } from '@nestjs/throttler';
|
||||
import {
|
||||
HealthCheck,
|
||||
HealthCheckResult,
|
||||
HealthCheckService,
|
||||
TypeOrmHealthIndicator,
|
||||
} from '@nestjs/terminus';
|
||||
import type { Response } from 'express';
|
||||
|
||||
import { SkipTransform } from '@/common/decorators/skip-transform.decorator';
|
||||
|
||||
// DB ping 타임아웃 — 기본값 1000ms 는 너무 짧다.
|
||||
// 커넥션 풀의 유휴 커넥션이 정리된 뒤(idleTimeoutMillis) 재연결하는 경우 1초를 넘겨
|
||||
// 정상인 DB 를 down 으로 오판하고 헬스체크가 간헐적으로 실패한다.
|
||||
const DB_PING_TIMEOUT_MS = 5_000;
|
||||
|
||||
/**
|
||||
* 헬스체크 엔드포인트 — Docker healthcheck, 모니터링 시스템이 읽는다.
|
||||
*
|
||||
* 이 엔드포인트는 **사람이 아니라 기계가 소비**하므로 표준 응답 래퍼와 에러 코드 규칙에서 제외한다.
|
||||
* (`{success,data}` 로 감싸거나 실패를 SYS_001 로 덮어쓰면 "어느 컴포넌트가 죽었는지"가 사라진다)
|
||||
*
|
||||
* - 성공 200: `{ status: 'ok', info: { database: { status: 'up' } }, ... }`
|
||||
* - 실패 503: `{ status: 'error', error: { database: { status: 'down', message } }, ... }`
|
||||
*
|
||||
* 주기적으로 호출되므로 rate limit 대상에서도 제외한다 (한도를 소진시켜 실제 요청을 막으면 안 된다).
|
||||
*/
|
||||
@SkipThrottle()
|
||||
@SkipTransform()
|
||||
@ApiTags('Health')
|
||||
@Controller('health')
|
||||
export class HealthController {
|
||||
constructor(
|
||||
private readonly health: HealthCheckService,
|
||||
private readonly db: TypeOrmHealthIndicator,
|
||||
) {}
|
||||
|
||||
@Get()
|
||||
@HealthCheck()
|
||||
@ApiOperation({
|
||||
summary: '애플리케이션 상태 확인 (표준 응답 래퍼 미적용)',
|
||||
})
|
||||
@ApiResponse({ status: 200, description: '모든 구성요소 정상' })
|
||||
@ApiResponse({
|
||||
status: 503,
|
||||
description: '하나 이상의 구성요소 비정상 — 본문의 error 에 원인이 담긴다',
|
||||
})
|
||||
async check(
|
||||
@Res({ passthrough: true }) res: Response,
|
||||
): Promise<HealthCheckResult> {
|
||||
try {
|
||||
return await this.health.check([
|
||||
() => this.db.pingCheck('database', { timeout: DB_PING_TIMEOUT_MS }),
|
||||
]);
|
||||
} catch (error) {
|
||||
// terminus 는 실패 시 ServiceUnavailableException 을 던지며 본문에 상세 결과를 담는다.
|
||||
// 전역 필터가 SYS_001 로 덮어쓰지 않도록 여기서 직접 503 과 원본 본문을 반환한다.
|
||||
if (error instanceof ServiceUnavailableException) {
|
||||
res.status(HttpStatus.SERVICE_UNAVAILABLE);
|
||||
return error.getResponse() as HealthCheckResult;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TerminusModule } from '@nestjs/terminus';
|
||||
import { HealthController } from './health.controller';
|
||||
|
||||
@Module({
|
||||
imports: [TerminusModule],
|
||||
controllers: [HealthController],
|
||||
})
|
||||
export class HealthModule {}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
/** 업로드 결과 — 저장 경로(절대경로)는 노출하지 않는다 */
|
||||
export class UploadResponseDto {
|
||||
@ApiProperty({
|
||||
description: '서버가 생성한 파일 식별자 (저장 파일명)',
|
||||
example: 'b1f0c2e4-8a3d-4c7e-9f21-0d5a6b7c8e9f.png',
|
||||
})
|
||||
fileName!: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: '업로드 당시 원본 파일명',
|
||||
example: '프로필.png',
|
||||
})
|
||||
originalName!: string;
|
||||
|
||||
@ApiProperty({ description: 'MIME 타입', example: 'image/png' })
|
||||
mimeType!: string;
|
||||
|
||||
@ApiProperty({ description: '파일 크기 (바이트)', example: 20480 })
|
||||
size!: number;
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { allowedExtensionList, isAllowedFile } from './upload.config';
|
||||
|
||||
describe('파일 업로드 검증', () => {
|
||||
it('확장자와 MIME 이 모두 맞으면 통과한다', () => {
|
||||
expect(isAllowedFile('photo.png', 'image/png')).toBe(true);
|
||||
expect(isAllowedFile('scan.PDF', 'application/pdf')).toBe(true);
|
||||
});
|
||||
|
||||
it('허용되지 않은 MIME 은 거부한다', () => {
|
||||
expect(isAllowedFile('script.js', 'application/javascript')).toBe(false);
|
||||
expect(isAllowedFile('page.html', 'text/html')).toBe(false);
|
||||
});
|
||||
|
||||
it('MIME 은 허용이지만 확장자가 불일치하면 거부한다 (확장자 위장 방지)', () => {
|
||||
expect(isAllowedFile('payload.html', 'image/png')).toBe(false);
|
||||
expect(isAllowedFile('payload.exe', 'application/pdf')).toBe(false);
|
||||
});
|
||||
|
||||
it('확장자가 없으면 거부한다', () => {
|
||||
expect(isAllowedFile('nofile', 'image/png')).toBe(false);
|
||||
});
|
||||
|
||||
it('실행 가능한 형식은 허용 목록에 없다', () => {
|
||||
const allowed = allowedExtensionList();
|
||||
|
||||
for (const dangerous of ['.html', '.svg', '.js', '.exe', '.sh']) {
|
||||
expect(allowed).not.toContain(dangerous);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
import { extname } from 'path';
|
||||
|
||||
import { parsePositiveInt } from '@/shared/utils/env';
|
||||
|
||||
/**
|
||||
* 파일 업로드 정책 (로컬 디스크 기준).
|
||||
*
|
||||
* 오브젝트 스토리지(S3 등)로 전환할 때는 저장 어댑터만 교체하고
|
||||
* 검증 규칙(허용 형식·크기)은 그대로 재사용한다.
|
||||
*/
|
||||
|
||||
/** 업로드 파일 저장 디렉터리 — 컨테이너에서는 compose 볼륨이 연결된다 */
|
||||
export const UPLOAD_DIR = process.env.UPLOAD_DIR ?? './uploads';
|
||||
|
||||
const MEGABYTE = 1024 * 1024;
|
||||
|
||||
/** 파일 1개 최대 크기 (기본 5MB) */
|
||||
export const MAX_FILE_SIZE_BYTES =
|
||||
parsePositiveInt(process.env.UPLOAD_MAX_SIZE_MB, 5) * MEGABYTE;
|
||||
|
||||
/**
|
||||
* 허용 형식 — 확장자와 MIME 타입을 **둘 다** 검사한다.
|
||||
* 확장자만 보면 위장이 쉽고, MIME 만 보면 클라이언트가 보낸 값이라 신뢰할 수 없다.
|
||||
*
|
||||
* ⚠️ 실행 가능한 형식(.html, .svg, .js 등)은 넣지 말 것 —
|
||||
* 저장된 파일을 그대로 서빙할 경우 XSS 로 이어진다.
|
||||
*/
|
||||
export const ALLOWED_FILE_TYPES: ReadonlyMap<string, readonly string[]> =
|
||||
new Map([
|
||||
['image/jpeg', ['.jpg', '.jpeg']],
|
||||
['image/png', ['.png']],
|
||||
['image/webp', ['.webp']],
|
||||
['image/gif', ['.gif']],
|
||||
['application/pdf', ['.pdf']],
|
||||
]);
|
||||
|
||||
/** 허용 형식인지 검사한다 (확장자 ↔ MIME 조합이 일치해야 통과) */
|
||||
export function isAllowedFile(originalName: string, mimeType: string): boolean {
|
||||
const allowedExtensions = ALLOWED_FILE_TYPES.get(mimeType);
|
||||
if (!allowedExtensions) return false;
|
||||
|
||||
return allowedExtensions.includes(extname(originalName).toLowerCase());
|
||||
}
|
||||
|
||||
/** 사용자에게 안내할 허용 확장자 목록 */
|
||||
export function allowedExtensionList(): string {
|
||||
return [...ALLOWED_FILE_TYPES.values()].flat().join(', ');
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import {
|
||||
Controller,
|
||||
Post,
|
||||
UploadedFile,
|
||||
UseInterceptors,
|
||||
} from '@nestjs/common';
|
||||
import { FileInterceptor } from '@nestjs/platform-express';
|
||||
import { ApiBody, ApiConsumes, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
|
||||
import {
|
||||
ApiErrorResponse,
|
||||
ApiStandardResponse,
|
||||
} from '@/common/decorators/api-standard-response.decorator';
|
||||
import { BusinessException } from '@/common/errors/business.exception';
|
||||
import { UploadResponseDto } from './dto/upload-response.dto';
|
||||
import { UploadService } from './upload.service';
|
||||
|
||||
// 파일 업로드 샘플 — 새 업로드 엔드포인트는 이 패턴을 복제한다.
|
||||
// 검증(형식·크기)과 안전한 파일명 생성은 UploadModule 의 multer 설정이 담당한다.
|
||||
@ApiTags('Upload')
|
||||
@Controller('files')
|
||||
export class UploadController {
|
||||
constructor(private readonly uploadService: UploadService) {}
|
||||
|
||||
@Post()
|
||||
@UseInterceptors(FileInterceptor('file'))
|
||||
@ApiOperation({ summary: '파일 업로드 (단일)' })
|
||||
@ApiConsumes('multipart/form-data')
|
||||
@ApiBody({
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
file: { type: 'string', format: 'binary' },
|
||||
},
|
||||
required: ['file'],
|
||||
},
|
||||
})
|
||||
@ApiStandardResponse(UploadResponseDto, {
|
||||
status: 201,
|
||||
description: '업로드 성공',
|
||||
})
|
||||
@ApiErrorResponse('FILE_001', '허용되지 않는 파일 형식')
|
||||
@ApiErrorResponse('FILE_002', '파일 크기 초과')
|
||||
upload(@UploadedFile() file?: Express.Multer.File): UploadResponseDto {
|
||||
if (!file) {
|
||||
// 파일 없이 요청한 경우 — multer 는 오류를 내지 않으므로 여기서 막는다
|
||||
throw new BusinessException('VAL_001', {
|
||||
message: '업로드할 파일을 선택해 주세요.',
|
||||
});
|
||||
}
|
||||
|
||||
return this.uploadService.toResponse(file);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { MulterModule } from '@nestjs/platform-express';
|
||||
import { randomUUID } from 'crypto';
|
||||
import { diskStorage } from 'multer';
|
||||
import { mkdirSync } from 'fs';
|
||||
import { extname } from 'path';
|
||||
|
||||
import { BusinessException } from '@/common/errors/business.exception';
|
||||
import {
|
||||
MAX_FILE_SIZE_BYTES,
|
||||
UPLOAD_DIR,
|
||||
isAllowedFile,
|
||||
} from './upload.config';
|
||||
import { UploadController } from './upload.controller';
|
||||
import { UploadService } from './upload.service';
|
||||
|
||||
// 저장 디렉터리는 기동 시 한 번 보장한다 (없으면 multer 가 매 요청 실패한다)
|
||||
mkdirSync(UPLOAD_DIR, { recursive: true });
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
MulterModule.register({
|
||||
storage: diskStorage({
|
||||
destination: UPLOAD_DIR,
|
||||
filename: (_req, file, callback) => {
|
||||
// ⚠️ 클라이언트가 보낸 파일명을 경로에 쓰지 않는다.
|
||||
// '../../etc/passwd' 같은 값이 그대로 들어오면 경로 탈출이 된다.
|
||||
// 서버가 생성한 UUID + 검증된 확장자만 사용한다.
|
||||
const safeExtension = extname(file.originalname).toLowerCase();
|
||||
callback(null, `${randomUUID()}${safeExtension}`);
|
||||
},
|
||||
}),
|
||||
limits: {
|
||||
fileSize: MAX_FILE_SIZE_BYTES,
|
||||
// 파일 외 필드 수를 제한해 과도한 파싱을 막는다
|
||||
files: 1,
|
||||
fields: 10,
|
||||
},
|
||||
fileFilter: (_req, file, callback) => {
|
||||
if (!isAllowedFile(file.originalname, file.mimetype)) {
|
||||
// 에러 코드와 함께 거부한다 (전역 필터가 표준 응답으로 변환)
|
||||
callback(new BusinessException('FILE_001'), false);
|
||||
return;
|
||||
}
|
||||
callback(null, true);
|
||||
},
|
||||
}),
|
||||
],
|
||||
controllers: [UploadController],
|
||||
providers: [UploadService],
|
||||
exports: [UploadService],
|
||||
})
|
||||
export class UploadModule {}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { unlink } from 'fs/promises';
|
||||
import { join } from 'path';
|
||||
|
||||
import { UPLOAD_DIR } from './upload.config';
|
||||
import { UploadResponseDto } from './dto/upload-response.dto';
|
||||
|
||||
// 업로드된 파일의 메타데이터를 다루는 계층.
|
||||
// 저장 자체는 multer(diskStorage)가 수행하며, 여기서는 그 결과를 정리한다.
|
||||
@Injectable()
|
||||
export class UploadService {
|
||||
private readonly logger = new Logger(UploadService.name);
|
||||
|
||||
/** multer 가 저장한 파일 정보를 응답 DTO 로 변환한다 */
|
||||
toResponse(file: Express.Multer.File): UploadResponseDto {
|
||||
this.logger.log(
|
||||
`파일 업로드 완료: ${file.filename} (${file.size} bytes, ${file.mimetype})`,
|
||||
);
|
||||
|
||||
return {
|
||||
fileName: file.filename,
|
||||
// 원본 파일명은 표시용 메타데이터일 뿐이며 경로 조립에 사용하지 않는다
|
||||
originalName: file.originalname,
|
||||
mimeType: file.mimetype,
|
||||
size: file.size,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 저장된 파일을 삭제한다.
|
||||
* 파일명은 서버가 생성한 값만 받아야 한다 — 외부 입력을 그대로 넘기면 경로 탈출 위험이 있다.
|
||||
*/
|
||||
async remove(fileName: string): Promise<void> {
|
||||
await unlink(join(UPLOAD_DIR, fileName));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { IsEmail, IsNotEmpty, IsString, MinLength } from 'class-validator';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
// 사용자 생성 DTO 샘플 — 모든 검증 메시지는 한국어, Swagger 메타 필수
|
||||
export class CreateUserDto {
|
||||
@ApiProperty({ description: '사용자 이메일', example: 'user@example.com' })
|
||||
@IsEmail({}, { message: '올바른 이메일 형식을 입력해 주세요.' })
|
||||
@IsNotEmpty({ message: '이메일은 필수 입력 항목입니다.' })
|
||||
email!: string;
|
||||
|
||||
@ApiProperty({ description: '비밀번호 (최소 8자)', example: 'password123' })
|
||||
@IsString()
|
||||
@MinLength(8, { message: '비밀번호는 최소 8자 이상이어야 합니다.' })
|
||||
password!: string;
|
||||
|
||||
@ApiProperty({ description: '사용자 이름', example: '홍길동' })
|
||||
@IsString()
|
||||
@IsNotEmpty({ message: '이름은 필수 입력 항목입니다.' })
|
||||
name!: string;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
/** 사용자 응답 DTO — Swagger 스키마와 클라이언트 타입의 기준이 된다. */
|
||||
export class UserResponseDto {
|
||||
@ApiProperty({ description: '사용자 ID', example: 1 })
|
||||
id!: number;
|
||||
|
||||
@ApiProperty({ description: '사용자 이메일', example: 'user@example.com' })
|
||||
email!: string;
|
||||
|
||||
@ApiProperty({ description: '사용자 이름', example: '홍길동' })
|
||||
name!: string;
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
Param,
|
||||
ParseIntPipe,
|
||||
Post,
|
||||
Query,
|
||||
} from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
|
||||
import {
|
||||
ApiErrorResponse,
|
||||
ApiStandardResponse,
|
||||
} from '@/common/decorators/api-standard-response.decorator';
|
||||
import { CursorPaginationQueryDto } from '@/common/dto/pagination.dto';
|
||||
import { UserService } from './user.service';
|
||||
import { CreateUserDto } from './dto/create-user.dto';
|
||||
import { UserResponseDto } from './dto/user-response.dto';
|
||||
|
||||
// 사용자 도메인 컨트롤러 샘플 — 라우팅과 입출력만 담당, 비즈니스 로직은 모두 service 위임
|
||||
@ApiTags('User')
|
||||
@ApiBearerAuth()
|
||||
@Controller('users')
|
||||
export class UserController {
|
||||
constructor(private readonly userService: UserService) {}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: '사용자 목록 조회 (커서 페이지네이션)' })
|
||||
@ApiStandardResponse(UserResponseDto, {
|
||||
status: 200,
|
||||
description: '사용자 목록 조회 성공',
|
||||
isArray: true,
|
||||
})
|
||||
@ApiErrorResponse('VAL_001', '페이지네이션 파라미터 오류')
|
||||
findMany(@Query() query: CursorPaginationQueryDto) {
|
||||
return this.userService.findMany(query.limit, query.cursor);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@ApiOperation({ summary: '사용자 단건 조회' })
|
||||
@ApiStandardResponse(UserResponseDto, { description: '사용자 조회 성공' })
|
||||
@ApiErrorResponse('RES_001', '사용자를 찾을 수 없음')
|
||||
findOne(@Param('id', ParseIntPipe) id: number) {
|
||||
return this.userService.findOne(id);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@ApiOperation({ summary: '사용자 생성' })
|
||||
@ApiStandardResponse(UserResponseDto, {
|
||||
status: 201,
|
||||
description: '사용자 생성 성공',
|
||||
})
|
||||
@ApiErrorResponse('VAL_001', '입력값 검증 실패')
|
||||
@ApiErrorResponse('BIZ_001', '이메일 중복 등 비즈니스 규칙 위반')
|
||||
create(@Body() dto: CreateUserDto) {
|
||||
return this.userService.create(dto);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { UserController } from './user.controller';
|
||||
import { UserService } from './user.service';
|
||||
|
||||
@Module({
|
||||
controllers: [UserController],
|
||||
providers: [UserService],
|
||||
exports: [UserService],
|
||||
})
|
||||
export class UserModule {}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { BusinessException } from '@/common/errors/business.exception';
|
||||
import {
|
||||
PaginatedResult,
|
||||
toPaginatedResult,
|
||||
} from '@/common/dto/pagination.dto';
|
||||
import { CreateUserDto } from './dto/create-user.dto';
|
||||
import { UserResponseDto } from './dto/user-response.dto';
|
||||
|
||||
// 비즈니스 로직 계층 샘플 — 실제 구현 시 Repository 주입하여 데이터 접근
|
||||
@Injectable()
|
||||
export class UserService {
|
||||
// 임시 인메모리 저장소 — TypeORM Repository 도입 시 교체
|
||||
private readonly users = new Map<number, UserResponseDto>();
|
||||
|
||||
findOne(id: number): UserResponseDto {
|
||||
const user = this.users.get(id);
|
||||
if (!user) {
|
||||
// 사용자 노출 실패는 항상 에러 코드와 함께 던진다.
|
||||
throw new BusinessException('RES_001', { context: { userId: id } });
|
||||
}
|
||||
return user;
|
||||
}
|
||||
|
||||
/** 커서 기반 목록 조회 샘플 — 목록 API 는 항상 페이지네이션을 적용한다. */
|
||||
findMany(limit: number, cursor?: string): PaginatedResult<UserResponseDto> {
|
||||
const afterId = cursor ? Number(cursor) : 0;
|
||||
const sorted = [...this.users.values()].sort((a, b) => a.id - b.id);
|
||||
const page = sorted.filter((user) => user.id > afterId).slice(0, limit);
|
||||
const last = page.at(-1);
|
||||
const hasNext = last ? sorted.some((user) => user.id > last.id) : false;
|
||||
|
||||
return toPaginatedResult(page, {
|
||||
hasNext,
|
||||
nextCursor: hasNext && last ? String(last.id) : null,
|
||||
total: sorted.length,
|
||||
});
|
||||
}
|
||||
|
||||
create(dto: CreateUserDto): UserResponseDto {
|
||||
const duplicated = [...this.users.values()].some(
|
||||
(user) => user.email === dto.email,
|
||||
);
|
||||
if (duplicated) {
|
||||
// 도메인 고유 코드가 필요하면 error-codes.ts 의 DOMAIN_ERROR_CODES 에 등록 후 사용한다.
|
||||
throw new BusinessException('BIZ_001', {
|
||||
message: '이미 가입된 이메일입니다. 다른 이메일을 사용해 주세요.',
|
||||
context: { email: dto.email },
|
||||
});
|
||||
}
|
||||
|
||||
const id = this.users.size + 1;
|
||||
const user: UserResponseDto = { id, email: dto.email, name: dto.name };
|
||||
this.users.set(id, user);
|
||||
return user;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user