[BE-57] 문서 계열·버전 도메인 · 로컬 파일 게이트웨이(경로 새니타이즈) (FR-81~84)

작업 내용 (설계 의도)

근거 TDD: 20260808-지원관리-확장-tdd.md — “방안 5 (document 신규 컨텍스트)”, “document 인터페이스 시그니처”

변경 사항

  1. 신규 document 컨텍스트를 만듭니다. application에 합류시키지 않는 이유: FR-85가 “하나의 서류 버전을 여러 지원 건에 재사용”을 요구하므로 문서는 지원과 독립적으로 존재하고, 파일시스템이라는 별도 영속 매체를 소유합니다.
  2. 문서 계열(series) — 같은 이력서의 v2를 표현할 대상이 현재 데이터 모델에 없습니다(FR-81 B-8). application_document_series + application_document_versions로 계열별 연속 버전 번호를 채번합니다. 채번은 ApplicationDocumentSeries.nextVersionNumber()가 결정하고, UNIQUE (series_id, version_number) 제약이 최종 방어선입니다.
  3. DocumentFileGateway(domain interface) + LocalDocumentFileGatewayImpl(infra) — 로컬 파일시스템은 DB가 아니라 프로세스 밖 자원이므로 Repository가 아니라 Gateway입니다. Path·InputStream 타입이 domain/application에 노출되지 않고 바이트 배열과 도메인 값 객체만 오갑니다.
  4. 경로 새니타이즈는 값 객체가 생성 시점에 강제합니다(FR-84) — DocumentFileName.ofOrThrow()가 NFKC 정규화 → 경로 구분자(/,\)·상위 참조(..)·제어문자 제거 → 공백 정리 → 길이 상한 120을 수행합니다. 검증을 UseCase의 if + throw로 흩뿌리지 않습니다.
  5. 최종 경로 검증 — 게이트웨이가 Path.normalize() 후 저장 루트로 startsWith 검증을 한 번 더 합니다. 값 객체를 우회한 입력이 있어도 루트를 벗어나지 못합니다(이중 방어).
  6. 저장 경로(FR-82) — {root}/{문서유형}/{YYYY-MM-DD}_{문서제목}_v{버전}_{원본파일명}. 회사별 폴더를 만들지 않습니다(FR-82 D-2 — 여러 지원 건 재사용과 모순).
  7. 검증은 Entity에 — 확장자 4종(pdf·docx·md·hwp)·20MB 상한은 ApplicationDocumentVersion.create() 내부에서 판정합니다(FR-83·NFR-14).
  8. 보상 트랜잭션 — 파일 쓰기 성공 후 DB 저장이 실패하면 파일을 삭제합니다. DB만 롤백되면 고아 파일이 남습니다.
  9. 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)
  • 확장자 exeDocumentExtensionNotAllowedException이고 파일이 생성되지 않는다
  • 20MB를 초과하면 DocumentSizeExceededException이고 파일이 생성되지 않는다
  • 정확히 20MB는 통과한다 (경계값)
  • 문서 제목에 ../../etc를 넣으면 새니타이즈 후 저장 루트 하위에만 저장된다
  • 파일명에 /가 포함되면 치환되고, DB의 original_file_name에는 원본이 보존된다
  • 파일명에 제어문자가 있으면 제거된다
  • 새니타이즈를 우회한 상대 경로를 게이트웨이에 직접 넘기면 DocumentPathEscapeException이다
  • 같은 경로에 이미 파일이 있으면 덮어쓰지 않고 예외를 던진다
  • 파일 쓰기 성공 후 DB 저장이 실패하면 파일이 삭제된다 (보상 트랜잭션, 고아 파일 0건)
  • 계열과 버전이 cascade로 한 번에 저장된다 (RepositoryImpl에 수동 delete/insert 없음)
  • 파일명이 121자면 잘려서 120자로 저장된다 (경계값)
  • 존재하지 않는 계열 id로 업로드하면 DocumentSeriesNotFoundException이다