From f815dbb057cb47618f9a79dcedee0db993fa5c7a Mon Sep 17 00:00:00 2001 From: hyoinkang Date: Sun, 19 Apr 2026 21:54:46 +0900 Subject: [PATCH 1/3] =?UTF-8?q?fix:=20=EC=99=B8=EB=B6=80=20=ED=8F=AC?= =?UTF-8?q?=ED=8A=B8=ED=8F=B4=EB=A6=AC=EC=98=A4=20=EC=A1=B0=ED=9A=8C=20?= =?UTF-8?q?=EC=9D=91=EB=8B=B5=EC=97=90=20status=20=EC=B6=94=EA=B0=80=20?= =?UTF-8?q?=EB=B0=8F=20=EC=8A=A4=EC=9B=A8=EA=B1=B0=20=EB=AA=85=EC=84=B8=20?= =?UTF-8?q?=EC=A0=95=ED=95=A9=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 기업정보분석 조회에 대해 409 응답 명세 누락 수정 - 외부 포트폴리오 조회 응답에 PdfExtractionStatus 추가 --- docs/API.md | 4 +- .../application/dtos/correction-result.dto.ts | 3 + .../dtos/external-portfolio.dto.ts | 17 +++++ .../facades/external-portfolio.facade.spec.ts | 63 ++++++++++++++++++- .../facades/external-portfolio.facade.ts | 12 ++-- .../portfolio-correction-swagger.decorator.ts | 14 ++++- .../external-portfolio.controller.ts | 7 ++- .../portfolio-correction.controller.ts | 6 +- 8 files changed, 113 insertions(+), 13 deletions(-) diff --git a/docs/API.md b/docs/API.md index 4e141069..3b52fb3f 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, 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..9dc3bf5a 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,8 @@ +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'; export class StructuredPortfolioResDTO { portfolioId: number; @@ -22,6 +24,21 @@ export class StructuredPortfolioResDTO { } } +export class ExternalPortfolioListResDTO { + @ApiProperty({ enum: PdfExtractionStatus }) + status: PdfExtractionStatus; + + @ApiProperty({ type: [StructuredPortfolioResDTO] }) + portfolios: StructuredPortfolioResDTO[]; + + static from(status: PdfExtractionStatus, portfolios: Portfolio[]): ExternalPortfolioListResDTO { + const dto = new ExternalPortfolioListResDTO(); + dto.status = status; + 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..57b09286 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,12 +20,19 @@ 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 }>, + [number, number] + >(); +} class CorrectionPortfolioSelectionServiceStub { readonly deleteByPortfolioId = jest.fn, [number]>(); + readonly findActivePortfolioIdsByCorrectionId = jest.fn, [number]>(); } class CorrectionItemServiceStub { @@ -37,25 +45,76 @@ describe('ExternalPortfolioFacade', () => { let externalPortfolioFacade: ExternalPortfolioFacade; let externalPortfolioServiceStub: ExternalPortfolioServiceStub; let portfolioServiceStub: PortfolioServiceStub; + let portfolioCorrectionServiceStub: PortfolioCorrectionServiceStub; let correctionPortfolioSelectionServiceStub: CorrectionPortfolioSelectionServiceStub; let correctionItemServiceStub: CorrectionItemServiceStub; beforeEach(() => { externalPortfolioServiceStub = new ExternalPortfolioServiceStub(); portfolioServiceStub = new PortfolioServiceStub(); + portfolioCorrectionServiceStub = new PortfolioCorrectionServiceStub(); correctionPortfolioSelectionServiceStub = new CorrectionPortfolioSelectionServiceStub(); correctionItemServiceStub = new CorrectionItemServiceStub(); 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 ); }); + it('returns top-level pdf extraction status with portfolios list', async () => { + portfolioCorrectionServiceStub.findByIdAndUserIdOrThrow.mockResolvedValue({ + pdfExtractionStatus: PdfExtractionStatus.GENERATING, + }); + 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.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('validates ownership first, then deletes related links and portfolio', async () => { portfolioServiceStub.findByIdsAndUserIdOrThrow.mockResolvedValue([{}]); correctionItemServiceStub.deleteByPortfolioId.mockResolvedValue(); 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..a7a18479 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'; @@ -53,8 +54,11 @@ export class ExternalPortfolioFacade { 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 +66,11 @@ export class ExternalPortfolioFacade { ); if (portfolioIds.length === 0) { - return []; + return ExternalPortfolioListResDTO.from(correction.pdfExtractionStatus, []); } const portfolios = await this.portfolioService.findByIds(portfolioIds); - return portfolios.map((portfolio) => StructuredPortfolioResDTO.from(portfolio)); + return ExternalPortfolioListResDTO.from(correction.pdfExtractionStatus, portfolios); } @Transactional() 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 From 11d85ed7272985a27e804f975b88da0330b780c8 Mon Sep 17 00:00:00 2001 From: hyoinkang Date: Sun, 19 Apr 2026 22:35:46 +0900 Subject: [PATCH 2/3] =?UTF-8?q?feat:=20external-portfolio=20=ED=8C=8C?= =?UTF-8?q?=EC=9D=BC=EB=AA=85=20=EC=A0=80=EC=9E=A5/=EC=A1=B0=ED=9A=8C=20?= =?UTF-8?q?=EB=A1=9C=EC=A7=81=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - portfolio_correction에 파일이름 저장용 nullable 칼럼 추가 - 한글 파일명에 대한 인코딩/정규화 처리를 NFC로 통일 --- docs/API.md | 2 +- .../dtos/external-portfolio.dto.ts | 44 ++++++++++- .../facades/external-portfolio.facade.spec.ts | 74 ++++++++++++++++++- .../facades/external-portfolio.facade.ts | 13 +++- .../services/portfolio-correction.service.ts | 6 ++ .../domain/portfolio-correction.entity.ts | 3 + ...lio-extract-request-parser.service.spec.ts | 34 +++++++++ ...ortfolio-extract-request-parser.service.ts | 36 ++++++++- ...inal_file_name_to_portfolio_correction.sql | 2 + 9 files changed, 206 insertions(+), 8 deletions(-) create mode 100644 supabase/migrations/20260419153000_add_original_file_name_to_portfolio_correction.sql diff --git a/docs/API.md b/docs/API.md index 3b52fb3f..89674982 100644 --- a/docs/API.md +++ b/docs/API.md @@ -266,7 +266,7 @@ Legend: ### External Portfolios -- GET `/external-portfolios?correctionId=...` -> IMPLEMENTED (응답: `ExternalPortfolioListResDTO` = `{ status: PdfExtractionStatus, portfolios: StructuredPortfolioResDTO[] }`) +- 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/external-portfolio.dto.ts b/src/modules/portfolio-correction/application/dtos/external-portfolio.dto.ts index 9dc3bf5a..74c93a90 100644 --- a/src/modules/portfolio-correction/application/dtos/external-portfolio.dto.ts +++ b/src/modules/portfolio-correction/application/dtos/external-portfolio.dto.ts @@ -4,6 +4,40 @@ import { IsInt, IsOptional, IsPositive, IsString, MaxLength, MinLength } from 'c import { Portfolio } from 'src/modules/portfolio/domain/portfolio.entity'; import { PdfExtractionStatus } from '../../domain/enums/pdf-extraction-status.enum'; +function normalizeOriginalFileNameForResponse(originalFileName: string | null): string | null { + if (originalFileName === null) { + return null; + } + + const decodeLatin1 = (value: string): string => Buffer.from(value, 'latin1').toString('utf8'); + const once = decodeLatin1(originalFileName); + const twice = decodeLatin1(once); + + const hasHangul = (value: string): boolean => + /[\u1100-\u11FF\u3130-\u318F\uAC00-\uD7AF]/.test(value); + const score = (value: string): number => { + const matched = value.match(/[ÃÂáâãð�\u0080-\u009F]/g); + return matched ? matched.length : 0; + }; + + const candidates = [originalFileName, once, twice] + .filter((value) => !value.includes('�')) + .map((value) => value.normalize('NFC')); + if (candidates.length === 0) { + return originalFileName.normalize('NFC'); + } + + const uniqueCandidates = [...new Set(candidates)]; + const withKorean = uniqueCandidates.filter((value) => hasHangul(value)); + const pool = withKorean.length > 0 ? withKorean : uniqueCandidates; + + const selected = pool.reduce((best, current) => + score(current) < score(best) ? current : best + ); + + return selected.normalize('NFC'); +} + export class StructuredPortfolioResDTO { portfolioId: number; name: string; @@ -28,12 +62,20 @@ export class ExternalPortfolioListResDTO { @ApiProperty({ enum: PdfExtractionStatus }) status: PdfExtractionStatus; + @ApiProperty({ nullable: true }) + originalFileName: string | null; + @ApiProperty({ type: [StructuredPortfolioResDTO] }) portfolios: StructuredPortfolioResDTO[]; - static from(status: PdfExtractionStatus, portfolios: Portfolio[]): ExternalPortfolioListResDTO { + static from( + status: PdfExtractionStatus, + originalFileName: string | null, + portfolios: Portfolio[] + ): ExternalPortfolioListResDTO { const dto = new ExternalPortfolioListResDTO(); dto.status = status; + dto.originalFileName = normalizeOriginalFileNameForResponse(originalFileName); dto.portfolios = portfolios.map((portfolio) => StructuredPortfolioResDTO.from(portfolio)); return dto; } 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 57b09286..774daef2 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 @@ -25,9 +25,11 @@ class PortfolioServiceStub { class PortfolioCorrectionServiceStub { readonly findByIdAndUserIdOrThrow = jest.fn< - Promise<{ pdfExtractionStatus: PdfExtractionStatus }>, + Promise<{ pdfExtractionStatus: PdfExtractionStatus; originalFileName: string | null }>, [number, number] >(); + readonly updatePdfExtractionStatus = jest.fn, [number, PdfExtractionStatus]>(); + readonly updateOriginalFileName = jest.fn, [number, string]>(); } class CorrectionPortfolioSelectionServiceStub { @@ -39,7 +41,9 @@ class CorrectionItemServiceStub { readonly deleteByPortfolioId = jest.fn, [number]>(); } -class PdfExtractServiceStub {} +class PdfExtractServiceStub { + readonly extractText = jest.fn, [number, Buffer, string]>(); +} describe('ExternalPortfolioFacade', () => { let externalPortfolioFacade: ExternalPortfolioFacade; @@ -48,6 +52,7 @@ describe('ExternalPortfolioFacade', () => { let portfolioCorrectionServiceStub: PortfolioCorrectionServiceStub; let correctionPortfolioSelectionServiceStub: CorrectionPortfolioSelectionServiceStub; let correctionItemServiceStub: CorrectionItemServiceStub; + let pdfExtractServiceStub: PdfExtractServiceStub; beforeEach(() => { externalPortfolioServiceStub = new ExternalPortfolioServiceStub(); @@ -55,6 +60,7 @@ describe('ExternalPortfolioFacade', () => { portfolioCorrectionServiceStub = new PortfolioCorrectionServiceStub(); correctionPortfolioSelectionServiceStub = new CorrectionPortfolioSelectionServiceStub(); correctionItemServiceStub = new CorrectionItemServiceStub(); + pdfExtractServiceStub = new PdfExtractServiceStub(); externalPortfolioFacade = new ExternalPortfolioFacade( externalPortfolioServiceStub as unknown as ExternalPortfolioService, @@ -62,13 +68,41 @@ describe('ExternalPortfolioFacade', () => { 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] @@ -95,6 +129,7 @@ describe('ExternalPortfolioFacade', () => { 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, @@ -115,6 +150,39 @@ describe('ExternalPortfolioFacade', () => { ]); }); + 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('validates ownership first, then deletes related links and portfolio', async () => { portfolioServiceStub.findByIdsAndUserIdOrThrow.mockResolvedValue([{}]); correctionItemServiceStub.deleteByPortfolioId.mockResolvedValue(); 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 a7a18479..1d596008 100644 --- a/src/modules/portfolio-correction/application/facades/external-portfolio.facade.ts +++ b/src/modules/portfolio-correction/application/facades/external-portfolio.facade.ts @@ -48,6 +48,7 @@ export class ExternalPortfolioFacade { correctionId, PdfExtractionStatus.GENERATING ); + await this.portfolioCorrectionService.updateOriginalFileName(correctionId, fileName); return message; } @@ -66,11 +67,19 @@ export class ExternalPortfolioFacade { ); if (portfolioIds.length === 0) { - return ExternalPortfolioListResDTO.from(correction.pdfExtractionStatus, []); + return ExternalPortfolioListResDTO.from( + correction.pdfExtractionStatus, + correction.originalFileName, + [] + ); } const portfolios = await this.portfolioService.findByIds(portfolioIds); - return ExternalPortfolioListResDTO.from(correction.pdfExtractionStatus, portfolios); + 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/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/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..c4cf9b11 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,40 @@ 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('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..81df8da5 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 @@ -9,6 +9,40 @@ const PDF_MIME_TYPE = 'application/pdf'; const PDF_SIGNATURE = '%PDF'; const MAX_PDF_SIZE_BYTES = 10 * 1024 * 1024; +function normalizeOriginalFileName(fileName: string | undefined): string { + if (!fileName) { + return 'upload.pdf'; + } + + const decodeLatin1 = (value: string): string => Buffer.from(value, 'latin1').toString('utf8'); + const once = decodeLatin1(fileName); + const twice = decodeLatin1(once); + + const hasHangul = (value: string): boolean => + /[\u1100-\u11FF\u3130-\u318F\uAC00-\uD7AF]/.test(value); + const score = (value: string): number => { + const matched = value.match(/[ÃÂáâãð�\u0080-\u009F]/g); + return matched ? matched.length : 0; + }; + + const candidates = [fileName, once, twice] + .filter((value) => !value.includes('�')) + .map((value) => value.normalize('NFC')); + if (candidates.length === 0) { + return fileName.normalize('NFC'); + } + + const uniqueCandidates = [...new Set(candidates)]; + const withKorean = uniqueCandidates.filter((value) => hasHangul(value)); + const pool = withKorean.length > 0 ? withKorean : uniqueCandidates; + + const selected = pool.reduce((best, current) => + score(current) < score(best) ? current : best + ); + + return selected.normalize('NFC'); +} + export interface MultipartRequestLike { headers: IncomingHttpHeaders; pipe(destination: T, options?: { end?: boolean }): T; @@ -94,7 +128,7 @@ export class ExternalPortfolioExtractRequestParserService { } hasFile = true; - parsedFileName = info.filename || 'upload.pdf'; + parsedFileName = normalizeOriginalFileName(info.filename); 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; From 6a70b0dd918aab3f49bded7e57e9896c7fa64e45 Mon Sep 17 00:00:00 2001 From: hyoinkang Date: Sun, 19 Apr 2026 23:44:21 +0900 Subject: [PATCH 3/3] =?UTF-8?q?refactor:=20=ED=8C=8C=EC=9D=BC=EB=AA=85=20?= =?UTF-8?q?=EC=A0=95=EA=B7=9C=ED=99=94=20=EB=A1=9C=EC=A7=81=20=EC=9C=A0?= =?UTF-8?q?=ED=8B=B8=EB=A1=9C=20=EB=B6=84=EB=A6=AC=20=EB=B0=8F=20=EC=A0=95?= =?UTF-8?q?=EC=A0=9C=20=EB=A1=9C=EC=A7=81=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 코파일럿 코드리뷰 반영 --- .../dtos/external-portfolio.dto.ts | 39 ++---------- .../facades/external-portfolio.facade.spec.ts | 14 +++++ ...original-file-name-normalizer.util.spec.ts | 17 ++++++ .../original-file-name-normalizer.util.ts | 60 +++++++++++++++++++ ...lio-extract-request-parser.service.spec.ts | 20 +++++++ ...ortfolio-extract-request-parser.service.ts | 39 ++---------- 6 files changed, 119 insertions(+), 70 deletions(-) create mode 100644 src/modules/portfolio-correction/common/utils/original-file-name-normalizer.util.spec.ts create mode 100644 src/modules/portfolio-correction/common/utils/original-file-name-normalizer.util.ts 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 74c93a90..52971d95 100644 --- a/src/modules/portfolio-correction/application/dtos/external-portfolio.dto.ts +++ b/src/modules/portfolio-correction/application/dtos/external-portfolio.dto.ts @@ -3,40 +3,7 @@ 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'; - -function normalizeOriginalFileNameForResponse(originalFileName: string | null): string | null { - if (originalFileName === null) { - return null; - } - - const decodeLatin1 = (value: string): string => Buffer.from(value, 'latin1').toString('utf8'); - const once = decodeLatin1(originalFileName); - const twice = decodeLatin1(once); - - const hasHangul = (value: string): boolean => - /[\u1100-\u11FF\u3130-\u318F\uAC00-\uD7AF]/.test(value); - const score = (value: string): number => { - const matched = value.match(/[ÃÂáâãð�\u0080-\u009F]/g); - return matched ? matched.length : 0; - }; - - const candidates = [originalFileName, once, twice] - .filter((value) => !value.includes('�')) - .map((value) => value.normalize('NFC')); - if (candidates.length === 0) { - return originalFileName.normalize('NFC'); - } - - const uniqueCandidates = [...new Set(candidates)]; - const withKorean = uniqueCandidates.filter((value) => hasHangul(value)); - const pool = withKorean.length > 0 ? withKorean : uniqueCandidates; - - const selected = pool.reduce((best, current) => - score(current) < score(best) ? current : best - ); - - return selected.normalize('NFC'); -} +import { normalizeOriginalFileName } from '../../common/utils/original-file-name-normalizer.util'; export class StructuredPortfolioResDTO { portfolioId: number; @@ -75,7 +42,9 @@ export class ExternalPortfolioListResDTO { ): ExternalPortfolioListResDTO { const dto = new ExternalPortfolioListResDTO(); dto.status = status; - dto.originalFileName = normalizeOriginalFileNameForResponse(originalFileName); + dto.originalFileName = originalFileName + ? normalizeOriginalFileName(originalFileName) + : null; dto.portfolios = portfolios.map((portfolio) => StructuredPortfolioResDTO.from(portfolio)); return dto; } 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 774daef2..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 @@ -183,6 +183,20 @@ describe('ExternalPortfolioFacade', () => { 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 () => { portfolioServiceStub.findByIdsAndUserIdOrThrow.mockResolvedValue([{}]); correctionItemServiceStub.deleteByPortfolioId.mockResolvedValue(); 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/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 c4cf9b11..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 @@ -93,6 +93,26 @@ describe('ExternalPortfolioExtractRequestParserService', () => { 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 81df8da5..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,46 +3,13 @@ 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'; const PDF_SIGNATURE = '%PDF'; const MAX_PDF_SIZE_BYTES = 10 * 1024 * 1024; -function normalizeOriginalFileName(fileName: string | undefined): string { - if (!fileName) { - return 'upload.pdf'; - } - - const decodeLatin1 = (value: string): string => Buffer.from(value, 'latin1').toString('utf8'); - const once = decodeLatin1(fileName); - const twice = decodeLatin1(once); - - const hasHangul = (value: string): boolean => - /[\u1100-\u11FF\u3130-\u318F\uAC00-\uD7AF]/.test(value); - const score = (value: string): number => { - const matched = value.match(/[ÃÂáâãð�\u0080-\u009F]/g); - return matched ? matched.length : 0; - }; - - const candidates = [fileName, once, twice] - .filter((value) => !value.includes('�')) - .map((value) => value.normalize('NFC')); - if (candidates.length === 0) { - return fileName.normalize('NFC'); - } - - const uniqueCandidates = [...new Set(candidates)]; - const withKorean = uniqueCandidates.filter((value) => hasHangul(value)); - const pool = withKorean.length > 0 ? withKorean : uniqueCandidates; - - const selected = pool.reduce((best, current) => - score(current) < score(best) ? current : best - ); - - return selected.normalize('NFC'); -} - export interface MultipartRequestLike { headers: IncomingHttpHeaders; pipe(destination: T, options?: { end?: boolean }): T; @@ -128,7 +95,9 @@ export class ExternalPortfolioExtractRequestParserService { } hasFile = true; - parsedFileName = normalizeOriginalFileName(info.filename); + parsedFileName = info.filename + ? normalizeOriginalFileName(info.filename) + : 'upload.pdf'; fileStream.on('data', (chunk: Buffer) => { chunks.push(chunk);