[BE-75] 연락 이벤트 파싱 · 후보 매칭 점수 (Layer 1 리스너) (FR-91)

작업 내용 (설계 의도)

근거 TDD: 20260808-지원관리-확장-tdd.md — “방안 10 연락 이벤트(후보 매칭 점수)”, “Sequence Diagram 연락 이벤트 수신부터 반영까지”

변경 사항

  1. Layer 1 구독 — presentation/contact/ContactEventReceivedListener가 @Async @TransactionalEventListener(AFTER_COMMIT)로 수신 이벤트를 받아 UseCase를 경유합니다. 리스너에 비즈니스 로직을 두지 않습니다. Kafka는 도입하지 않습니다(같은 앱·같은 배포 단위이고, 원본이 이미 커밋돼 재계산 가능하므로 내구성이 필요 없습니다).
  2. 파싱은 순수 함수 — ContactMessageParser가 제목·본문·발신 주소에서 회사명·공고명·전형 키워드·제안 상태·면접 일정을 추출합니다. Repository를 주입받지 않아 전부 단위 테스트로 커버됩니다.
  3. 제안 상태는 4종으로 한정합니다(PRD 용어 통일 B-28) — DOCUMENT_SCREENING·INTERVIEWING·OFFERED·REJECTED. 종료 3종(ACCEPTED·WITHDRAWN·OFFER_DECLINED)은 연락 이벤트로 제안하지 않습니다.
  4. 후보 점수(FR-91) — 회사 40 / 공고·직무 제목 30 / 담당자 20 / 진행 중·180일 내 지원 10. 신뢰도 80+ HIGH / 55~79 MEDIUM / 54- LOW.
    • 담당자 20점 축은 BE-49(FR-80)가 세운 job_application_contacts.contact_email_address가 전제입니다. 담당자 저장소가 없으면 이 축이 항상 0점입니다(PRD B-14).
  5. 자동 선택 금지 규칙(FR-91 B-15) — 최고점이 55 미만이거나 동점 최고 후보가 2건 이상이면 autoSelectableCandidate()가 null을 반환해 사용자 수동 선택을 요구합니다. “회사만 일치(40점)“가 수동으로 떨어지는 것은 오탐 방지를 위한 의도된 동작입니다(시나리오 16).
  6. 크로스 컨텍스트 조합 — 후보는 지원 건입니다. contact 도메인은 application 타입을 모르므로, AnalyzeContactEventUseCase가 application·posting·company에서 지원 요약을 모아 ContactMatchInput 값 객체로 변환해 넘깁니다. contact DomainService 시그니처에 application 타입을 두지 않습니다.
  7. 상태를 절대 자동 변경하지 않습니다(PRD Non-Goals) — 후보 계산 후 Discord 검토 요청 알림(BE-76)만 트리거합니다.
  8. 파싱 실패·후보 0건도 정상 경로입니다 — 이벤트는 PENDING으로 남기고 파싱 결과를 null로 저장하되 알림은 발송합니다(사용자가 원문을 보고 판단).

의존

  • BE-71 (연락 이벤트 도메인·수신)
  • BE-49 (담당자 — 20점 축 전제, 1단계 산출물)

다이어그램

처리 흐름

sequenceDiagram
    participant L as ContactEventReceivedListener
    participant U as AnalyzeContactEventUseCase
    participant P as ContactMessageParser
    participant A as ApplicationQueryDomainService
    participant S as ContactMatchScorer
    participant D as ContactEventDomainService
    L->>U: execute(contactEventId)
    U->>D: findBy(contactEventId)
    U->>P: parse(subject, bodyText, senderAddress)
    U->>A: 진행 중 지원 + 담당자 + 공고·회사 요약 조회
    U->>U: ContactMatchInput 값 객체로 변환
    U->>S: score(parse, inputs)
    S->>S: 회사40 + 제목30 + 담당자20 + 최근10
    S-->>U: 후보 목록 (신뢰도 등급 포함)
    U->>D: applyParse(parse, candidates)
    D->>D: autoSelectableCandidate() — 55미만·동점이면 null

클래스 의존

flowchart LR
    subgraph Presentation["presentation/contact"]
        Listener[ContactEventReceivedListener]
    end
    subgraph Application["application/contact"]
        UC[AnalyzeContactEventUseCase]
        Mapper[ContactMatchInputMapper]
    end
    subgraph Domain["domain/contact"]
        DS[ContactEventDomainService]
        Parser[ContactMessageParser]
        Scorer[ContactMatchScorer]
        Candidate[ContactMatchCandidate]
        Conf[ContactConfidence]
    end
    Listener --> UC
    UC --> Mapper
    UC --> DS
    DS --> Parser
    DS --> Scorer
    Scorer --> Candidate
    Candidate --> Conf

테스트 케이스

  • 회사·공고·담당자가 모두 일치하면 90점 HIGH다
  • 회사만 일치하면 40점 LOW이고 자동 선택되지 않는다 (시나리오 16)
  • 회사 + 제목이면 70점 MEDIUM이고 자동 선택된다
  • 정확히 55점이면 MEDIUM이고 자동 선택된다 (경계값)
  • 54점이면 LOW이고 자동 선택되지 않는다 (경계값)
  • 정확히 80점이면 HIGH다 (경계값)
  • 동점 최고 후보가 2건이면 자동 선택되지 않는다
  • 담당자 이메일이 등록되지 않은 지원 건은 담당자 축이 0점이다
  • 발신 이메일 대소문자가 달라도 담당자가 일치로 판정된다
  • 지원 시점이 181일 전이면 최근 축이 0점이다 (경계값)
  • 종료 상태 지원 건은 최근 축이 0점이다
  • 파싱 실패 시 후보가 0건이어도 이벤트가 PENDING으로 남고 예외가 없다
  • 제안 상태는 4종(DOCUMENT_SCREENING·INTERVIEWING·OFFERED·REJECTED) 중에서만 나온다
  • 어떤 신뢰도에서도 지원 상태가 자동 변경되지 않는다
  • 리스너가 원 트랜잭션 커밋 이후에 실행된다 (AFTER_COMMIT)
  • 리스너가 실패해도 수신된 이벤트는 저장된 상태로 남는다
  • contact DomainService 시그니처에 application 도메인 타입이 없다 (컨텍스트 격리 검증)