[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로 보류합니다.JobPostingQueryService의companyId를 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 기준으로CONFIRMED→LIKELY순 정렬되고UNKNOWN이 뒤로 밀린다- 확신도가
UNKNOWN인 공고의workArrangement가 null로 반환된다 - 목록 응답에 본문(
descriptionBody)이 포함되지 않는다(상세 전용 — 목록 경량 유지) - 비대표(중복본) 공고는 목록에서 제외된다(대표만 반환)
- 대표 공고 상세에
alternateSources[]로 중복 묶인 다른 출처가 반환된다 - 중복이 없는 공고의
alternateSourceCount가 0이다 - 애그리게이터 공고 목록 아이템에
sourceType=AGGREGATOR가 포함된다 - 확신도가
UNKNOWN인 공고도 목록에서 제외되지 않는다 (필터 아님) - 확신도
UNKNOWN공고가 “근무형태 정보 없음”으로 표기된다 - 근거가 있는 공고는 근거 스니펫과 확신도가 함께 반환된다
- 매칭 실패 공고도 목록에 포함된다
accessRestricted=true공고에 접근 제한 배지가 반환된다- 공고 상세 응답에 지원 기록이 없으면
application이 null로 반환된다 - CLOSED 공고도 조회 가능하며 마감 사유가 함께 반환된다