[BE-57] 문서 계열·버전 도메인 · 로컬 파일 게이트웨이(경로 새니타이즈) (FR-81~84)
작업 내용 (설계 의도)
근거 TDD: 20260808-지원관리-확장-tdd.md — “방안 5 (document 신규 컨텍스트)”, “document 인터페이스 시그니처”
변경 사항
- 신규
document컨텍스트를 만듭니다.application에 합류시키지 않는 이유: FR-85가 “하나의 서류 버전을 여러 지원 건에 재사용”을 요구하므로 문서는 지원과 독립적으로 존재하고, 파일시스템이라는 별도 영속 매체를 소유합니다. - 문서 계열(series) — 같은 이력서의 v2를 표현할 대상이 현재 데이터 모델에 없습니다(FR-81 B-8).
application_document_series+application_document_versions로 계열별 연속 버전 번호를 채번합니다. 채번은ApplicationDocumentSeries.nextVersionNumber()가 결정하고,UNIQUE (series_id, version_number)제약이 최종 방어선입니다. DocumentFileGateway(domain interface) +LocalDocumentFileGatewayImpl(infra) — 로컬 파일시스템은 DB가 아니라 프로세스 밖 자원이므로 Repository가 아니라 Gateway입니다.Path·InputStream타입이 domain/application에 노출되지 않고 바이트 배열과 도메인 값 객체만 오갑니다.- 경로 새니타이즈는 값 객체가 생성 시점에 강제합니다(FR-84) —
DocumentFileName.ofOrThrow()가 NFKC 정규화 → 경로 구분자(/,\)·상위 참조(..)·제어문자 제거 → 공백 정리 → 길이 상한 120을 수행합니다. 검증을 UseCase의if + throw로 흩뿌리지 않습니다. - 최종 경로 검증 — 게이트웨이가
Path.normalize()후 저장 루트로startsWith검증을 한 번 더 합니다. 값 객체를 우회한 입력이 있어도 루트를 벗어나지 못합니다(이중 방어). - 저장 경로(FR-82) —
{root}/{문서유형}/{YYYY-MM-DD}_{문서제목}_v{버전}_{원본파일명}. 회사별 폴더를 만들지 않습니다(FR-82 D-2 — 여러 지원 건 재사용과 모순). - 검증은 Entity에 — 확장자 4종(
pdf·docx·md·hwp)·20MB 상한은ApplicationDocumentVersion.create()내부에서 판정합니다(FR-83·NFR-14). - 보상 트랜잭션 — 파일 쓰기 성공 후 DB 저장이 실패하면 파일을 삭제합니다. DB만 롤백되면 고아 파일이 남습니다.
DocumentDomainService는 얇게 — infra 어댑터에 다단계 쓰기 오케스트레이션을 두지 않습니다(no-business-flow-in-infra). 계열·버전은@OneToMany(cascade, orphanRemoval)로 한 단위 저장합니다.
이 티켓은 API를 만들지 않습니다 — 도메인·게이트웨이만 만듭니다. 업로드 API는 BE-61입니다.
롤백: 신규 테이블·신규 파일만 추가합니다. 이미 저장된 파일은 남지만 무해합니다.
의존
- BE-56 (예외·의존성·저장 루트 설정·볼륨 마운트)
다이어그램
처리 흐름
sequenceDiagram participant U as UploadDocumentUseCase participant D as DocumentDomainService participant S as ApplicationDocumentSeries participant V as ApplicationDocumentVersion participant G as DocumentFileGateway participant R as DocumentRepository U->>D: upload(input) D->>S: nextVersionNumber() D->>V: create(series, fileName, bytes) — 확장자·용량 검증 V->>V: DocumentFileName.ofOrThrow(새니타이즈) D->>G: store(relativePath, content) G->>G: normalize() 후 루트 startsWith 검증 alt 루트 이탈 G-->>D: DocumentPathEscapeException else 저장 성공 D->>R: save(series with version) cascade alt DB 저장 실패 D->>G: delete(relativePath) 보상 end end D-->>U: ApplicationDocumentVersion
클래스 의존
flowchart LR subgraph Domain["domain/document"] DS[DocumentDomainService] Series[ApplicationDocumentSeries] Version[ApplicationDocumentVersion] FileName[DocumentFileName] Type[DocumentType] Gateway[DocumentFileGateway] Repo[ApplicationDocumentSeriesRepository] end subgraph Infra["infrastructure/document"] Local[LocalDocumentFileGatewayImpl] Persist[DocumentSeriesRepositoryImpl] end DS --> Series DS --> Version DS --> Gateway DS --> Repo Version --> FileName Version --> Type Local -.implements.-> Gateway Persist -.implements.-> Repo
테스트 케이스
- 신규 계열에 업로드하면
version_number=1이 부여된다 - 같은 계열에 3번째 업로드하면
version_number=3이고 v1·v2가 보존된다 - 저장 경로가
{root}/{문서유형}/{YYYY-MM-DD}_{제목}_v{버전}_{원본파일명}형식이다 - 회사별 폴더가 생성되지 않는다 (FR-82 D-2)
- 확장자
exe면DocumentExtensionNotAllowedException이고 파일이 생성되지 않는다 - 20MB를 초과하면
DocumentSizeExceededException이고 파일이 생성되지 않는다 - 정확히 20MB는 통과한다 (경계값)
- 문서 제목에
../../etc를 넣으면 새니타이즈 후 저장 루트 하위에만 저장된다 - 파일명에
/가 포함되면 치환되고, DB의original_file_name에는 원본이 보존된다 - 파일명에 제어문자가 있으면 제거된다
- 새니타이즈를 우회한 상대 경로를 게이트웨이에 직접 넘기면
DocumentPathEscapeException이다 - 같은 경로에 이미 파일이 있으면 덮어쓰지 않고 예외를 던진다
- 파일 쓰기 성공 후 DB 저장이 실패하면 파일이 삭제된다 (보상 트랜잭션, 고아 파일 0건)
- 계열과 버전이 cascade로 한 번에 저장된다 (RepositoryImpl에 수동 delete/insert 없음)
- 파일명이 121자면 잘려서 120자로 저장된다 (경계값)
- 존재하지 않는 계열 id로 업로드하면
DocumentSeriesNotFoundException이다