[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 도메인 타입이 없다 (컨텍스트 격리 검증)