diff --git a/docs/API.md b/docs/API.md index 4e141069..89674982 100644 --- a/docs/API.md +++ b/docs/API.md @@ -256,7 +256,7 @@ Legend: - POST `/portfolio-corrections` -> IMPLEMENTED (`title` required, consumes tickets, can return `TICKET402`) - GET `/portfolio-corrections/{correctionId}/status` -> IMPLEMENTED - GET `/portfolio-corrections/{correctionId}/company-insight` -> IMPLEMENTED -- GET `/portfolio-corrections/{correctionId}` -> IMPLEMENTED +- GET `/portfolio-corrections/{correctionId}` -> IMPLEMENTED (응답: `CorrectionResultResDTO`, `status: CorrectionStatus` 포함) - POST `/portfolio-corrections/{correctionId}/company-insight` -> IMPLEMENTED - PATCH `/portfolio-corrections/{correctionId}/company-insight` -> IMPLEMENTED - POST `/portfolio-corrections/{correctionId}/select` -> IMPLEMENTED (선택 포트폴리오를 매핑 테이블에 활성화 상태로 저장) @@ -266,7 +266,7 @@ Legend: ### External Portfolios -- GET `/external-portfolios?correctionId=...` -> IMPLEMENTED +- GET `/external-portfolios?correctionId=...` -> IMPLEMENTED (응답: `ExternalPortfolioListResDTO` = `{ status: PdfExtractionStatus, originalFileName: string \| null, portfolios: StructuredPortfolioResDTO[] }`) - POST `/external-portfolios` -> IMPLEMENTED - POST `/external-portfolios/extract` -> IMPLEMENTED (multipart PDF + `correctionId`, busboy 파싱, AI 추출 후 DB 저장) - PATCH `/external-portfolios/{portfolioId}` -> IMPLEMENTED diff --git a/src/modules/portfolio-correction/application/dtos/correction-result.dto.ts b/src/modules/portfolio-correction/application/dtos/correction-result.dto.ts index 73f22e8b..488463e4 100644 --- a/src/modules/portfolio-correction/application/dtos/correction-result.dto.ts +++ b/src/modules/portfolio-correction/application/dtos/correction-result.dto.ts @@ -1,5 +1,6 @@ import { PortfolioCorrection } from '../../domain/portfolio-correction.entity'; import { CorrectionItem } from '../../domain/correction-item.entity'; +import { CorrectionStatus } from '../../domain/enums/correction-status.enum'; type JsonPrimitive = string | number | boolean | null; type JsonValue = JsonPrimitive | JsonObject | JsonValue[]; @@ -13,6 +14,7 @@ type ProblemSolvingPayload = JsonObject; type LearningsPayload = JsonObject; export class CorrectionResultResDTO { + status: CorrectionStatus; companyName: string; positionName: string; jobDescription: string; @@ -23,6 +25,7 @@ export class CorrectionResultResDTO { static from(correction: PortfolioCorrection, items: CorrectionItem[]): CorrectionResultResDTO { const dto = new CorrectionResultResDTO(); + dto.status = correction.status; dto.companyName = correction.companyName; dto.positionName = correction.positionName; dto.jobDescription = correction.jobDescription; diff --git a/src/modules/portfolio-correction/application/dtos/external-portfolio.dto.ts b/src/modules/portfolio-correction/application/dtos/external-portfolio.dto.ts index 5e4b13ec..52971d95 100644 --- a/src/modules/portfolio-correction/application/dtos/external-portfolio.dto.ts +++ b/src/modules/portfolio-correction/application/dtos/external-portfolio.dto.ts @@ -1,6 +1,9 @@ +import { ApiProperty } from '@nestjs/swagger'; import { Transform } from 'class-transformer'; import { IsInt, IsOptional, IsPositive, IsString, MaxLength, MinLength } from 'class-validator'; import { Portfolio } from 'src/modules/portfolio/domain/portfolio.entity'; +import { PdfExtractionStatus } from '../../domain/enums/pdf-extraction-status.enum'; +import { normalizeOriginalFileName } from '../../common/utils/original-file-name-normalizer.util'; export class StructuredPortfolioResDTO { portfolioId: number; @@ -22,6 +25,31 @@ export class StructuredPortfolioResDTO { } } +export class ExternalPortfolioListResDTO { + @ApiProperty({ enum: PdfExtractionStatus }) + status: PdfExtractionStatus; + + @ApiProperty({ nullable: true }) + originalFileName: string | null; + + @ApiProperty({ type: [StructuredPortfolioResDTO] }) + portfolios: StructuredPortfolioResDTO[]; + + static from( + status: PdfExtractionStatus, + originalFileName: string | null, + portfolios: Portfolio[] + ): ExternalPortfolioListResDTO { + const dto = new ExternalPortfolioListResDTO(); + dto.status = status; + dto.originalFileName = originalFileName + ? normalizeOriginalFileName(originalFileName) + : null; + dto.portfolios = portfolios.map((portfolio) => StructuredPortfolioResDTO.from(portfolio)); + return dto; + } +} + export class CreateExternalPortfolioReqDTO { @IsInt() @IsPositive() diff --git a/src/modules/portfolio-correction/application/facades/external-portfolio.facade.spec.ts b/src/modules/portfolio-correction/application/facades/external-portfolio.facade.spec.ts index 9528f504..47029eb1 100644 --- a/src/modules/portfolio-correction/application/facades/external-portfolio.facade.spec.ts +++ b/src/modules/portfolio-correction/application/facades/external-portfolio.facade.spec.ts @@ -12,6 +12,7 @@ import { PortfolioCorrectionService } from '../services/portfolio-correction.ser import { CorrectionPortfolioSelectionService } from '../services/correction-portfolio-selection.service'; import { CorrectionItemService } from '../services/correction-item.service'; import { PdfExtractService } from '../services/pdf-extract.service'; +import { PdfExtractionStatus } from '../../domain/enums/pdf-extraction-status.enum'; class ExternalPortfolioServiceStub { readonly deleteExternalPortfolio = jest.fn, [number, number]>(); @@ -19,41 +20,181 @@ class ExternalPortfolioServiceStub { class PortfolioServiceStub { readonly findByIdsAndUserIdOrThrow = jest.fn, [number[], number]>(); + readonly findByIds = jest.fn, [number[]]>(); } -class PortfolioCorrectionServiceStub {} +class PortfolioCorrectionServiceStub { + readonly findByIdAndUserIdOrThrow = jest.fn< + Promise<{ pdfExtractionStatus: PdfExtractionStatus; originalFileName: string | null }>, + [number, number] + >(); + readonly updatePdfExtractionStatus = jest.fn, [number, PdfExtractionStatus]>(); + readonly updateOriginalFileName = jest.fn, [number, string]>(); +} class CorrectionPortfolioSelectionServiceStub { readonly deleteByPortfolioId = jest.fn, [number]>(); + readonly findActivePortfolioIdsByCorrectionId = jest.fn, [number]>(); } class CorrectionItemServiceStub { readonly deleteByPortfolioId = jest.fn, [number]>(); } -class PdfExtractServiceStub {} +class PdfExtractServiceStub { + readonly extractText = jest.fn, [number, Buffer, string]>(); +} describe('ExternalPortfolioFacade', () => { let externalPortfolioFacade: ExternalPortfolioFacade; let externalPortfolioServiceStub: ExternalPortfolioServiceStub; let portfolioServiceStub: PortfolioServiceStub; + let portfolioCorrectionServiceStub: PortfolioCorrectionServiceStub; let correctionPortfolioSelectionServiceStub: CorrectionPortfolioSelectionServiceStub; let correctionItemServiceStub: CorrectionItemServiceStub; + let pdfExtractServiceStub: PdfExtractServiceStub; beforeEach(() => { externalPortfolioServiceStub = new ExternalPortfolioServiceStub(); portfolioServiceStub = new PortfolioServiceStub(); + portfolioCorrectionServiceStub = new PortfolioCorrectionServiceStub(); correctionPortfolioSelectionServiceStub = new CorrectionPortfolioSelectionServiceStub(); correctionItemServiceStub = new CorrectionItemServiceStub(); + pdfExtractServiceStub = new PdfExtractServiceStub(); externalPortfolioFacade = new ExternalPortfolioFacade( externalPortfolioServiceStub as unknown as ExternalPortfolioService, portfolioServiceStub as unknown as PortfolioService, - new PortfolioCorrectionServiceStub() as unknown as PortfolioCorrectionService, + portfolioCorrectionServiceStub as unknown as PortfolioCorrectionService, correctionPortfolioSelectionServiceStub as unknown as CorrectionPortfolioSelectionService, correctionItemServiceStub as unknown as CorrectionItemService, - new PdfExtractServiceStub() as unknown as PdfExtractService + pdfExtractServiceStub as unknown as PdfExtractService + ); + }); + + it('stores original file name when extraction is accepted', async () => { + portfolioCorrectionServiceStub.findByIdAndUserIdOrThrow.mockResolvedValue({ + pdfExtractionStatus: PdfExtractionStatus.NONE, + originalFileName: null, + }); + pdfExtractServiceStub.extractText.mockResolvedValue({ + message: 'ok', + }); + + const result = await externalPortfolioFacade.extractPortfolio( + 9, + 1, + Buffer.from('pdf'), + 'resume.pdf' + ); + + expect(result).toBe('ok'); + expect(portfolioCorrectionServiceStub.updatePdfExtractionStatus).toHaveBeenCalledWith( + 1, + PdfExtractionStatus.GENERATING + ); + expect(portfolioCorrectionServiceStub.updateOriginalFileName).toHaveBeenCalledWith( + 1, + 'resume.pdf' + ); + }); + + it('returns top-level pdf extraction status with portfolios list', async () => { + portfolioCorrectionServiceStub.findByIdAndUserIdOrThrow.mockResolvedValue({ + pdfExtractionStatus: PdfExtractionStatus.GENERATING, + originalFileName: 'portfolio.pdf', + }); + correctionPortfolioSelectionServiceStub.findActivePortfolioIdsByCorrectionId.mockResolvedValue( + [10, 20] + ); + portfolioServiceStub.findByIds.mockResolvedValue([ + { + id: 10, + name: 'A', + description: 'D1', + responsibilities: 'R1', + problemSolving: 'P1', + learnings: 'L1', + }, + { + id: 20, + name: 'B', + description: 'D2', + responsibilities: 'R2', + problemSolving: 'P2', + learnings: 'L2', + }, + ]); + + const result = await externalPortfolioFacade.getSelectedPortfolios(1, 9); + + expect(result.status).toBe(PdfExtractionStatus.GENERATING); + expect(result.originalFileName).toBe('portfolio.pdf'); + expect(result.portfolios).toEqual([ + { + portfolioId: 10, + name: 'A', + description: 'D1', + responsibilities: 'R1', + problemSolving: 'P1', + learnings: 'L1', + }, + { + portfolioId: 20, + name: 'B', + description: 'D2', + responsibilities: 'R2', + problemSolving: 'P2', + learnings: 'L2', + }, + ]); + }); + + it('normalizes legacy mojibake original file name on get response', async () => { + portfolioCorrectionServiceStub.findByIdAndUserIdOrThrow.mockResolvedValue({ + pdfExtractionStatus: PdfExtractionStatus.GENERATING, + originalFileName: '첨삭_이력서.pdf', + }); + correctionPortfolioSelectionServiceStub.findActivePortfolioIdsByCorrectionId.mockResolvedValue( + [] ); + + const result = await externalPortfolioFacade.getSelectedPortfolios(1, 9); + + expect(result.originalFileName).toBe('첨삭_이력서.pdf'); + expect(result.portfolios).toEqual([]); + }); + + it('normalizes NFD original file name to NFC on get response', async () => { + const nfcName = '첨삭_이력서.pdf'; + const nfdName = nfcName.normalize('NFD'); + portfolioCorrectionServiceStub.findByIdAndUserIdOrThrow.mockResolvedValue({ + pdfExtractionStatus: PdfExtractionStatus.GENERATING, + originalFileName: nfdName, + }); + correctionPortfolioSelectionServiceStub.findActivePortfolioIdsByCorrectionId.mockResolvedValue( + [] + ); + + const result = await externalPortfolioFacade.getSelectedPortfolios(1, 9); + + expect(result.originalFileName).toBe(nfcName); + expect(result.originalFileName?.normalize('NFC')).toBe(result.originalFileName); + expect(result.originalFileName).not.toBe(nfdName); + }); + + it('sanitizes directory and control characters on get response', async () => { + portfolioCorrectionServiceStub.findByIdAndUserIdOrThrow.mockResolvedValue({ + pdfExtractionStatus: PdfExtractionStatus.GENERATING, + originalFileName: 'C:\\fakepath\\folioo\u0000-report.pdf', + }); + correctionPortfolioSelectionServiceStub.findActivePortfolioIdsByCorrectionId.mockResolvedValue( + [] + ); + + const result = await externalPortfolioFacade.getSelectedPortfolios(1, 9); + + expect(result.originalFileName).toBe('folioo-report.pdf'); }); it('validates ownership first, then deletes related links and portfolio', async () => { diff --git a/src/modules/portfolio-correction/application/facades/external-portfolio.facade.ts b/src/modules/portfolio-correction/application/facades/external-portfolio.facade.ts index 285dbcf6..1d596008 100644 --- a/src/modules/portfolio-correction/application/facades/external-portfolio.facade.ts +++ b/src/modules/portfolio-correction/application/facades/external-portfolio.facade.ts @@ -10,6 +10,7 @@ import { CorrectionPortfolioSelectionService } from '../services/correction-port import { CorrectionItemService } from '../services/correction-item.service'; import { PdfExtractService } from '../services/pdf-extract.service'; import { + ExternalPortfolioListResDTO, StructuredPortfolioResDTO, UpdatePortfolioBlockReqDTO, } from '../dtos/external-portfolio.dto'; @@ -47,14 +48,18 @@ export class ExternalPortfolioFacade { correctionId, PdfExtractionStatus.GENERATING ); + await this.portfolioCorrectionService.updateOriginalFileName(correctionId, fileName); return message; } async getSelectedPortfolios( correctionId: number, userId: number - ): Promise { - await this.portfolioCorrectionService.findByIdAndUserIdOrThrow(correctionId, userId); + ): Promise { + const correction = await this.portfolioCorrectionService.findByIdAndUserIdOrThrow( + correctionId, + userId + ); const portfolioIds = await this.correctionPortfolioSelectionService.findActivePortfolioIdsByCorrectionId( @@ -62,11 +67,19 @@ export class ExternalPortfolioFacade { ); if (portfolioIds.length === 0) { - return []; + return ExternalPortfolioListResDTO.from( + correction.pdfExtractionStatus, + correction.originalFileName, + [] + ); } const portfolios = await this.portfolioService.findByIds(portfolioIds); - return portfolios.map((portfolio) => StructuredPortfolioResDTO.from(portfolio)); + return ExternalPortfolioListResDTO.from( + correction.pdfExtractionStatus, + correction.originalFileName, + portfolios + ); } @Transactional() diff --git a/src/modules/portfolio-correction/application/services/portfolio-correction.service.ts b/src/modules/portfolio-correction/application/services/portfolio-correction.service.ts index 475e1597..a89f909a 100644 --- a/src/modules/portfolio-correction/application/services/portfolio-correction.service.ts +++ b/src/modules/portfolio-correction/application/services/portfolio-correction.service.ts @@ -163,6 +163,12 @@ export class PortfolioCorrectionService { await this.portfolioCorrectionRepository.save(correction); } + async updateOriginalFileName(correctionId: number, originalFileName: string): Promise { + const correction = await this.findByIdOrThrow(correctionId); + correction.originalFileName = originalFileName; + await this.portfolioCorrectionRepository.save(correction); + } + async getCorrectionDetail( correctionId: number, userId: number diff --git a/src/modules/portfolio-correction/common/utils/original-file-name-normalizer.util.spec.ts b/src/modules/portfolio-correction/common/utils/original-file-name-normalizer.util.spec.ts new file mode 100644 index 00000000..1c0f3842 --- /dev/null +++ b/src/modules/portfolio-correction/common/utils/original-file-name-normalizer.util.spec.ts @@ -0,0 +1,17 @@ +import { normalizeOriginalFileName } from './original-file-name-normalizer.util'; + +describe('normalizeOriginalFileName', () => { + it('strips directory segments and control characters', () => { + expect(normalizeOriginalFileName('C:\\fakepath\\folioo\u0000-report.pdf')).toBe( + 'folioo-report.pdf' + ); + }); + + it('caps file name length to 255 while preserving extension when possible', () => { + const longBase = 'a'.repeat(300); + const result = normalizeOriginalFileName(`${longBase}.pdf`); + + expect(result.length).toBe(255); + expect(result.endsWith('.pdf')).toBe(true); + }); +}); diff --git a/src/modules/portfolio-correction/common/utils/original-file-name-normalizer.util.ts b/src/modules/portfolio-correction/common/utils/original-file-name-normalizer.util.ts new file mode 100644 index 00000000..12003fb2 --- /dev/null +++ b/src/modules/portfolio-correction/common/utils/original-file-name-normalizer.util.ts @@ -0,0 +1,60 @@ +export function normalizeOriginalFileName(value: string): string { + const decodeLatin1 = (input: string): string => Buffer.from(input, 'latin1').toString('utf8'); + const once = decodeLatin1(value); + const twice = decodeLatin1(once); + + const hasHangul = (input: string): boolean => + /[\u1100-\u11FF\u3130-\u318F\uAC00-\uD7AF]/.test(input); + const score = (input: string): number => { + const matched = input.match(/[ÃÂáâãð�\u0080-\u009F]/g); + return matched ? matched.length : 0; + }; + + const candidates = [value, once, twice] + .filter((candidate) => !candidate.includes('�')) + .map((candidate) => candidate.normalize('NFC')); + if (candidates.length === 0) { + return value.normalize('NFC'); + } + + const uniqueCandidates = [...new Set(candidates)]; + const withHangul = uniqueCandidates.filter((candidate) => hasHangul(candidate)); + const pool = withHangul.length > 0 ? withHangul : uniqueCandidates; + + const selected = pool.reduce((best, current) => + score(current) < score(best) ? current : best + ); + + const normalized = selected.normalize('NFC'); + const baseName = normalized.split(/[\\/]/).pop() ?? normalized; + const withoutControlChars = Array.from(baseName) + .filter((char) => { + const code = char.codePointAt(0) ?? 0; + return !(code <= 0x1f || code === 0x7f); + }) + .join('') + .trim(); + + if (!withoutControlChars) { + return 'upload.pdf'; + } + + const MAX_FILE_NAME_LENGTH = 255; + if (withoutControlChars.length <= MAX_FILE_NAME_LENGTH) { + return withoutControlChars; + } + + const extensionIndex = withoutControlChars.lastIndexOf('.'); + const hasExtension = extensionIndex > 0 && extensionIndex < withoutControlChars.length - 1; + if (!hasExtension) { + return withoutControlChars.slice(0, MAX_FILE_NAME_LENGTH); + } + + const extension = withoutControlChars.slice(extensionIndex); + const baseNameMaxLength = MAX_FILE_NAME_LENGTH - extension.length; + if (baseNameMaxLength <= 0) { + return withoutControlChars.slice(0, MAX_FILE_NAME_LENGTH); + } + + return withoutControlChars.slice(0, extensionIndex).slice(0, baseNameMaxLength) + extension; +} diff --git a/src/modules/portfolio-correction/domain/portfolio-correction.entity.ts b/src/modules/portfolio-correction/domain/portfolio-correction.entity.ts index 4d4b752c..797bc8a2 100644 --- a/src/modules/portfolio-correction/domain/portfolio-correction.entity.ts +++ b/src/modules/portfolio-correction/domain/portfolio-correction.entity.ts @@ -41,6 +41,9 @@ export class PortfolioCorrection extends BaseEntity { @Column({ type: 'text', nullable: true }) extractedText: string; + @Column({ type: 'text', nullable: true }) + originalFileName: string | null; + @Column({ type: 'timestamptz', nullable: true }) extractedAt: Date; diff --git a/src/modules/portfolio-correction/presentation/decorators/portfolio-correction-swagger.decorator.ts b/src/modules/portfolio-correction/presentation/decorators/portfolio-correction-swagger.decorator.ts index b4ddd7b2..61b44c94 100644 --- a/src/modules/portfolio-correction/presentation/decorators/portfolio-correction-swagger.decorator.ts +++ b/src/modules/portfolio-correction/presentation/decorators/portfolio-correction-swagger.decorator.ts @@ -1,6 +1,6 @@ import { applyDecorators, Type } from '@nestjs/common'; import { ApiBody, ApiConsumes, ApiQuery } from '@nestjs/swagger'; -import { ApiCommonResponseArray } from 'src/common/decorators/swagger.decorator'; +import { ApiCommonResponse, ApiCommonResponseArray } from 'src/common/decorators/swagger.decorator'; export function ApiExternalPortfolioExtractRequest() { return applyDecorators( @@ -46,3 +46,15 @@ export function ApiCorrectionIdListResponse>(model: T) { ApiCommonResponseArray(model) ); } + +export function ApiCorrectionIdResponse>(model: T) { + return applyDecorators( + ApiQuery({ + name: 'correctionId', + required: true, + type: Number, + description: '조회할 첨삭 ID', + }), + ApiCommonResponse(model) + ); +} diff --git a/src/modules/portfolio-correction/presentation/external-portfolio.controller.ts b/src/modules/portfolio-correction/presentation/external-portfolio.controller.ts index d34f337a..eeb8e1ac 100644 --- a/src/modules/portfolio-correction/presentation/external-portfolio.controller.ts +++ b/src/modules/portfolio-correction/presentation/external-portfolio.controller.ts @@ -21,12 +21,13 @@ import { User } from 'src/common/decorators/user.decorator'; import { ErrorCode } from 'src/common/exceptions/error-code.enum'; import { CreateExternalPortfolioReqDTO, + ExternalPortfolioListResDTO, StructuredPortfolioResDTO, UpdatePortfolioBlockReqDTO, } from '../application/dtos/external-portfolio.dto'; import { ExternalPortfolioFacade } from '../application/facades/external-portfolio.facade'; import { - ApiCorrectionIdListResponse, + ApiCorrectionIdResponse, ApiExternalPortfolioExtractRequest, } from './decorators/portfolio-correction-swagger.decorator'; import { ExternalPortfolioExtractRequestParserService } from './services/external-portfolio-extract-request-parser.service'; @@ -83,12 +84,12 @@ export class ExternalPortfolioController { summary: 'PDF 포트폴리오 텍스트 정리 결과 조회', description: 'AI가 구조화한 포트폴리오 정보를 조회합니다.', }) - @ApiCorrectionIdListResponse(StructuredPortfolioResDTO) + @ApiCorrectionIdResponse(ExternalPortfolioListResDTO) @ApiCommonErrorResponse(ErrorCode.UNAUTHORIZED, ErrorCode.CORRECTION_NOT_FOUND) async getSelectedPortfolios( @User('sub') userId: number, @Query('correctionId', ParseIntPipe) correctionId: number - ): Promise { + ): Promise { return this.externalPortfolioFacade.getSelectedPortfolios(correctionId, userId); } diff --git a/src/modules/portfolio-correction/presentation/portfolio-correction.controller.ts b/src/modules/portfolio-correction/presentation/portfolio-correction.controller.ts index 8a6d9029..fc87c5f5 100644 --- a/src/modules/portfolio-correction/presentation/portfolio-correction.controller.ts +++ b/src/modules/portfolio-correction/presentation/portfolio-correction.controller.ts @@ -128,7 +128,11 @@ export class PortfolioCorrectionController { description: '특정 AI 첨삭의 기업 분석 정보를 조회합니다.', }) @ApiCommonResponse(UpdateCompanyInsightResDTO) - @ApiCommonErrorResponse(ErrorCode.UNAUTHORIZED, ErrorCode.CORRECTION_NOT_FOUND) + @ApiCommonErrorResponse( + ErrorCode.UNAUTHORIZED, + ErrorCode.CORRECTION_NOT_FOUND, + ErrorCode.COMPANY_INSIGHT_NOT_READY + ) async getCompanyInsight( @User('sub') userId: number, @Param('correctionId', ParseIntPipe) correctionId: number diff --git a/src/modules/portfolio-correction/presentation/services/external-portfolio-extract-request-parser.service.spec.ts b/src/modules/portfolio-correction/presentation/services/external-portfolio-extract-request-parser.service.spec.ts index b3876f55..dfeb072d 100644 --- a/src/modules/portfolio-correction/presentation/services/external-portfolio-extract-request-parser.service.spec.ts +++ b/src/modules/portfolio-correction/presentation/services/external-portfolio-extract-request-parser.service.spec.ts @@ -59,6 +59,60 @@ describe('ExternalPortfolioExtractRequestParserService', () => { }); }); + it('normalizes korean filename encoded as latin1 bytes', async () => { + const boundary = 'folioo-boundary'; + const fileBuffer = Buffer.from('%PDF-1.4\nfolioo'); + const koreanName = '첨삭_이력서.pdf'; + const latin1BrokenName = Buffer.from(koreanName, 'utf8').toString('latin1'); + const request = createMultipartRequest( + createMultipartBody(boundary, '3', latin1BrokenName, 'application/pdf', fileBuffer), + boundary + ); + + await expect(parser.parse(request)).resolves.toEqual({ + correctionId: 3, + fileBuffer, + fileName: koreanName, + }); + }); + + it('normalizes macOS NFD korean filename to NFC', async () => { + const boundary = 'folioo-boundary'; + const fileBuffer = Buffer.from('%PDF-1.4\nfolioo'); + const nfcName = '첨삭_이력서.pdf'; + const nfdName = nfcName.normalize('NFD'); + const request = createMultipartRequest( + createMultipartBody(boundary, '3', nfdName, 'application/pdf', fileBuffer), + boundary + ); + + const result = await parser.parse(request); + + expect(result.fileName).toBe(nfcName); + expect(result.fileName.normalize('NFC')).toBe(result.fileName); + expect(result.fileName).not.toBe(nfdName); + }); + + it('sanitizes path segments from filename', async () => { + const boundary = 'folioo-boundary'; + const fileBuffer = Buffer.from('%PDF-1.4\nfolioo'); + const request = createMultipartRequest( + createMultipartBody( + boundary, + '3', + 'C:\\fakepath\\portfolio_report.pdf', + 'application/pdf', + fileBuffer + ), + boundary + ); + + await expect(parser.parse(request)).resolves.toMatchObject({ + correctionId: 3, + fileName: 'portfolio_report.pdf', + }); + }); + it('rejects non-pdf uploads', async () => { const boundary = 'folioo-boundary'; const request = createMultipartRequest( diff --git a/src/modules/portfolio-correction/presentation/services/external-portfolio-extract-request-parser.service.ts b/src/modules/portfolio-correction/presentation/services/external-portfolio-extract-request-parser.service.ts index 177eb246..8dc5dfc7 100644 --- a/src/modules/portfolio-correction/presentation/services/external-portfolio-extract-request-parser.service.ts +++ b/src/modules/portfolio-correction/presentation/services/external-portfolio-extract-request-parser.service.ts @@ -3,6 +3,7 @@ import Busboy from 'busboy'; import type { IncomingHttpHeaders } from 'http'; import { BusinessException } from 'src/common/exceptions/business.exception'; import { ErrorCode } from 'src/common/exceptions/error-code.enum'; +import { normalizeOriginalFileName } from '../../common/utils/original-file-name-normalizer.util'; const MULTIPART_CONTENT_TYPE = 'multipart/form-data'; const PDF_MIME_TYPE = 'application/pdf'; @@ -94,7 +95,9 @@ export class ExternalPortfolioExtractRequestParserService { } hasFile = true; - parsedFileName = info.filename || 'upload.pdf'; + parsedFileName = info.filename + ? normalizeOriginalFileName(info.filename) + : 'upload.pdf'; fileStream.on('data', (chunk: Buffer) => { chunks.push(chunk); diff --git a/supabase/migrations/20260419153000_add_original_file_name_to_portfolio_correction.sql b/supabase/migrations/20260419153000_add_original_file_name_to_portfolio_correction.sql new file mode 100644 index 00000000..63de6c75 --- /dev/null +++ b/supabase/migrations/20260419153000_add_original_file_name_to_portfolio_correction.sql @@ -0,0 +1,2 @@ +ALTER TABLE "public"."portfolio_correction" + ADD COLUMN IF NOT EXISTS "original_file_name" text;