[BE-77] 연락 검토 화면 API · 확인형 반영 (FR-90)

작업 내용 (설계 의도)

근거 TDD: 20260808-지원관리-확장-tdd.md — “방안 10 연락 이벤트”, “상태 전이 표 연락 이벤트 검토”, “API 계약 3단계 연락 검토 화면”

변경 사항

  1. GET /api/contact-events, GET /api/contact-events/{id}, POST /api/contact-events/{id}/decisions를 신설합니다.
  2. 반영을 선택한 경우에만 상태가 바뀝니다(PRD Non-Goals “수신 연락 자동 상태 변경 미지원”). IGNORE·REASSIGN도 지원합니다.
  3. 전이 제약을 그대로 적용합니다(FR-90) — ApplicationStatus.canTransitTo()(ApplicationStatus.kt:25)를 통과해야 하고, 종료 4종은 emptySet()이라 어떤 반영도 거부됩니다(:40-43).
    • 종료 상태 대상은 응답에서 미리 terminal: true로 표시해 화면이 선택지를 막습니다(시나리오 15).
    • 응답의 allowedNextStatuses가 후보별 선택지를 확정합니다 — FE가 상수를 복제하지 않게 합니다.
  4. 면접 회차 규칙(FR-90) — 회차 번호는 해당 지원 건의 기존 최대 회차 + 1입니다(uk_job_application_interviews_round 유니크와 대응, baseline...sql:302). 레이블 기본값은 추출된 전형명, 없으면 {N}차 면접이며 반영 전 수정 가능합니다. 종료된 지원에는 면접을 추가하지 않습니다.
  5. 크로스 컨텍스트 조합DecideContactEventUseCaseContactEventDomainService(결정 기록)와 ApplicationDomainService·InterviewDomainService(상태 전이·면접 등록)를 함께 호출합니다. UseCase가 UseCase를 부르지 않습니다.
  6. 원본·파싱·후보·결정을 모두 저장합니다(FR-89) — 중복 수신과 오탐을 추적하기 위해서입니다. 결정은 PENDING에서만 가능하고, 재결정은 409입니다.
  7. 이 화면은 터널을 통해 외부에서 열립니다(FR-71·90 A-3) — FR-70 인증을 통과해야만 접근됩니다. 웹훅 경로와 달리 인증 필수입니다.
  8. 목록 조회는 PageResponse를 씁니다.

의존

  • BE-75 (파싱·후보 계산)
  • BE-76 (알림 — 링크가 가리키는 화면)

다이어그램

처리 흐름

sequenceDiagram
    participant FE as web(SPA)
    participant C as ContactEventApiController
    participant U as DecideContactEventUseCase
    participant CD as ContactEventDomainService
    participant AD as ApplicationDomainService
    participant ID as InterviewDomainService
    FE->>C: GET /api/contact-events/{id}
    C-->>FE: 후보 + allowedNextStatuses + terminal + nextRound
    FE->>C: POST /decisions (APPLY)
    C->>U: execute(command)
    U->>CD: decide(eventId, decision)
    alt 이미 결정됨
        CD-->>U: ContactEventAlreadyDecidedException (409)
    end
    U->>AD: 대상 지원 조회
    alt 종료 상태
        AD-->>U: ContactEventTargetTerminalException (409)
    else 전이 가능
        U->>AD: transit(applicationId, targetStatus)
        opt 면접 지정
            U->>ID: add(applicationId, 최대회차+1, label, scheduledAt)
        end
    end
    U-->>C: ContactEventDetailResponse

클래스 의존

flowchart LR
    subgraph Presentation["presentation/contact"]
        Api[ContactEventApiController]
    end
    subgraph Application["application/contact"]
        List[ListContactEventsUseCase]
        Get[GetContactEventUseCase]
        Decide[DecideContactEventUseCase]
    end
    subgraph Domain["domain"]
        CD[ContactEventDomainService]
        AD[ApplicationDomainService]
        ID[InterviewDomainService]
        Status[ApplicationStatus]
    end
    Api --> List
    Api --> Get
    Api --> Decide
    Decide --> CD
    Decide --> AD
    Decide --> ID
    AD --> Status

테스트 케이스

  • PENDING 이벤트 목록이 페이지로 조회된다
  • 상세 조회 시 원문·파싱 결과·후보 목록·autoSelectedJobApplicationId가 반환된다
  • 상세 조회 시 첨부 메타데이터(파일명·MIME·크기)가 반환되고 바이너리는 포함되지 않는다 (B-1)
  • 후보마다 allowedNextStatusescanTransitTo 결과와 일치한다
  • 종료 상태 지원 건 후보는 terminal: true로 표시된다
  • APPLY로 반영하면 지원 상태가 전이되고 이벤트가 APPLIED가 된다
  • 종료 상태 지원 건에 반영하면 409 CONTACT_EVENT_TARGET_TERMINAL이고 상태가 변하지 않는다 (시나리오 15)
  • 전이 규칙을 위반하는 상태로 반영하면 409 TRANSITION_NOT_ALLOWED
  • IGNORE를 선택하면 이벤트가 IGNORED가 되고 지원 상태는 그대로다
  • REASSIGN으로 다른 지원 건을 지정해 반영할 수 있다
  • 이미 결정된 이벤트에 다시 결정하면 409 CONTACT_EVENT_ALREADY_DECIDED
  • 면접을 함께 등록하면 회차가 기존 최대 + 1이다
  • 면접 레이블 기본값이 추출된 전형명이고, 없으면 {N}차 면접이다
  • 종료된 지원에는 면접이 추가되지 않는다
  • 반영 시 원본·파싱·후보·결정이 모두 저장된다
  • 결정이 실패하면 상태 전이와 면접 등록이 모두 롤백된다 (원자성)
  • 인증 없이 검토 화면 API를 호출하면 401이다 (웹훅과 달리 인증 필수)
  • 존재하지 않는 이벤트 조회는 404 CONTACT_EVENT_NOT_FOUND
  • APPLY인데 jobApplicationId가 없으면 400이다