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