[BE-18] 공고 조회 API · 수동 공고 등록 API

작업 내용 (설계 의도)

근거 TDD: 20260722-타깃-공고-알림-및-지원-히스토리-tdd.md — “API 계약”, “근무형태 확신도 판정”

변경 사항

공고를 회사 단위로 조회하고(FR-50), 자동 수집이 불가능한 공고를 직접 등록합니다(FR-20).

핵심 설계 의도 — 수동 등록 공고는 델타 판정 모수에서 반드시 빠져야 합니다:

  • 수동 공고는 origin=MANUAL, jobSourceId=null로 저장됩니다. 소스가 없으므로 수집 델타 비교에서 매번 미발견으로 잡히면 2회차에 즉시 CLOSED됩니다. JobPosting.isDeltaTarget()이 false를 반환하는 도메인 규칙(BE-02)과 수집 쿼리의 origin=COLLECTED 조건(BE-10)이 이를 막습니다. 이 티켓은 저장 시점에 origin을 정확히 설정할 책임을 집니다.
  • 대상: 로그인 필요 공고, 자소서 필수 공고, 매핑 후보 0건 회사의 공고(FR-48, 시나리오 5·6). 수동 공고도 지원 기록 연결과 히스토리 기능이 동일하게 적용됩니다(FR-21).
  • 마감일은 선택 입력이며, 미입력 시 null(상시채용)로 저장됩니다. 마감일을 입력했다면 마감일 경과 배치(BE-11)가 CLOSED 전환을 처리합니다.

조회 API 설계 의도:

목록 응답 아이템 필드 확정(FE 요청 #4, blocking) — 목록만으로 화면이 성립해야 합니다. 없으면 공고마다 상세를 N번 호출하게 됩니다.

{ jobPostingId, companyId, companyName, title, postingUrl, postingStatus, closedReason,
  deadlineAt,        // null = 상시채용
  matched,           // 직무 키워드 매칭 여부
  workArrangement,   // { keyword, confidence } | null — 대표 1건, UNKNOWN 이면 null
  applicationId,     // null = 미지원 (FR-42: "미지원"은 상태값이 아님)
  sourceType,        // COMPANY_BOUND | AGGREGATOR
  alternateSourceCount, // 중복으로 묶인 다른 출처 수 (0=단독)
  accessRestricted, postingOrigin, firstSeenAt }
  • 목록은 대표 공고만 반환합니다 (FR-64) — 쿼리에 representative_id IS NULL 조건이 들어갑니다. 비대표(중복본)는 목록에서 제외되고 상세의 alternateSources[]로만 노출됩니다. 공고 상세 응답에 alternateSources: [{platform, postingUrl}]를 포함합니다.

  • applied: boolean 대신 applicationId(nullable) 로 내립니다 — FE가 지원 상세로 바로 이동할 수 있고, null이 곧 “미지원”이라 FR-42의 표현과 1:1 대응합니다.

  • workArrangement대표 1건만 내립니다. 근거 스니펫과 복수 근거는 상세 응답 전용입니다 — 목록 응답을 무겁게 만들지 않기 위함입니다.

  • 근무형태는 라벨·정렬 전용입니다(FR-33). sort=WORK_ARRANGEMENT일 때 정렬은 work_arrangement_sort_rank 정수 컬럼(1=CONFIRMED, 2=LIKELY, 99=그 외) → firstSeenAt DESC 순으로 수행합니다. 확신도 문자열로 정렬하면 알파벳 순(CONFIRMED < INFERRED < LIKELY)이 도메인 순서와 어긋납니다. 어떤 확신도에서도 목록에서 제외하지 않습니다 — 필터로 쓰면 안 됩니다.

  • 확신도가 UNKNOWN인 공고는 “근무형태 정보 없음”으로 표기합니다(FR-34). 근거가 있는 공고는 근거 스니펫과 확신도를 함께 반환합니다(FR-29).

  • 매칭 실패 공고도 목록에 포함됩니다 — 매칭은 알림 조건일 뿐 저장·조회 조건이 아닙니다(FR-25).

  • accessRestricted=true 공고는 “접근 제한” 배지를 반환합니다(시나리오 6 — P0에서는 알림 대신 배지로 표현합니다).

  • 공고 상세 응답은 목록 아이템 + workArrangements[](근거 스니펫·근거 단계 포함 복수 건) + application(없으면 null) + descriptionBody를 반환합니다(FR-29, FR-42).

  • 전 회사 통합 공고 조회(GET /api/job-postings)는 P0 범위 밖입니다 — FR-50이 “회사 단위 분류 조회”를 P0으로 규정하고, FE도 회사 우선 내비게이션으로 완주 가능(non-blocking)함을 확인했습니다. 통합 목록은 페이지네이션·전역 정렬 요구를 파생시키므로 P1로 보류합니다. JobPostingQueryServicecompanyId를 nullable로 받는 오버로드만 추가하면 되도록 시그니처를 열어 둡니다.

범위: JobPostingApiController, RegisterManualJobPostingUseCase, GetJobPostingUseCase, ListJobPostingsUseCase, ManualJobPostingDomainService, JobPostingQueryService.

의존

  • BE-02 (공고 도메인·수동 생성 팩토리)

다이어그램

처리 흐름

sequenceDiagram
    participant U as 사용자
    participant C as JobPostingApiController
    participant UC as RegisterManualJobPostingUseCase
    participant D as ManualJobPostingDomainService
    participant P as JobPosting
    participant Q as JobPostingQueryService
    U->>C: POST /api/job-postings/manual-registrations
    C->>UC: execute(command)
    UC->>D: register(companyId, title, url, deadlineAt?)
    D->>P: createManual(command)
    P->>P: origin=MANUAL · jobSourceId=null · isDeltaTarget()=false
    D-->>C: jobPostingId
    U->>C: GET /api/companies/{id}/job-postings?sort=WORK_ARRANGEMENT
    C->>Q: list(companyId, sort)
    Q-->>C: 공고 + 근무형태 라벨 + 확신도 (제외 없이 전건)

클래스 의존

flowchart LR
    subgraph Presentation["presentation/posting"]
        Api[JobPostingApiController]
    end
    subgraph Application["application/posting"]
        UC1[RegisterManualJobPostingUseCase]
        UC2[GetJobPostingUseCase]
        UC3[ListJobPostingsUseCase]
    end
    subgraph Domain["domain/posting"]
        DS[ManualJobPostingDomainService]
        Query[JobPostingQueryService]
        Posting[JobPosting]
        Origin[JobPostingOrigin]
        Repo[JobPostingRepository]
    end
    Api --> UC1
    Api --> UC2
    Api --> UC3
    UC1 --> DS
    UC2 --> Query
    UC3 --> Query
    DS --> Posting
    Posting --> Origin
    DS --> Repo

테스트 케이스

  • 수동 공고를 등록하면 origin=MANUAL, jobSourceId=null로 저장된다
  • 수동 등록 공고의 isDeltaTarget()이 false를 반환한다
  • 마감일 없이 등록하면 deadlineAt=null(상시채용)로 저장된다
  • 마감일과 함께 등록하면 마감일 경과 배치의 대상이 된다
  • 존재하지 않는 회사 ID로 등록하면 404를 반환한다
  • MANUAL_ONLY 회사에도 수동 공고를 등록할 수 있다
  • 회사별 공고 목록에 자동 수집 공고와 수동 공고가 함께 조회된다
  • 목록 아이템에 matched·applicationId·workArrangement·closedReason·accessRestricted가 포함된다
  • 지원하지 않은 공고의 applicationId가 null로 반환된다
  • 지원한 공고의 applicationId로 지원 상세를 조회할 수 있다
  • sort=WORK_ARRANGEMENT에서 정수 rank 기준으로 CONFIRMEDLIKELY 순 정렬되고 UNKNOWN이 뒤로 밀린다
  • 확신도가 UNKNOWN인 공고의 workArrangement가 null로 반환된다
  • 목록 응답에 본문(descriptionBody)이 포함되지 않는다(상세 전용 — 목록 경량 유지)
  • 비대표(중복본) 공고는 목록에서 제외된다(대표만 반환)
  • 대표 공고 상세에 alternateSources[]로 중복 묶인 다른 출처가 반환된다
  • 중복이 없는 공고의 alternateSourceCount가 0이다
  • 애그리게이터 공고 목록 아이템에 sourceType=AGGREGATOR가 포함된다
  • 확신도가 UNKNOWN인 공고도 목록에서 제외되지 않는다 (필터 아님)
  • 확신도 UNKNOWN 공고가 “근무형태 정보 없음”으로 표기된다
  • 근거가 있는 공고는 근거 스니펫과 확신도가 함께 반환된다
  • 매칭 실패 공고도 목록에 포함된다
  • accessRestricted=true 공고에 접근 제한 배지가 반환된다
  • 공고 상세 응답에 지원 기록이 없으면 application이 null로 반환된다
  • CLOSED 공고도 조회 가능하며 마감 사유가 함께 반환된다