[BE-72] 추천도 축 가중치 수정 API · 59점 상한 해제 API (FR-97)

작업 내용 (설계 의도)

근거 TDD: 20260808-지원관리-확장-tdd.md — “방안 9 (상한 해제는 결과 행과 분리)”, “API 계약 3단계 가중치 수정·상한 해제”

변경 사항

  1. GET·PUT /api/recommendations/weights, POST·DELETE /api/job-postings/{jobPostingId}/recommendation/cap-release를 신설합니다.
  2. 가중치 변경은 평가 기준 버전을 증가시킵니다(FR-97) — change_target=AXIS_WEIGHT. 이 버전은 match_criteria_revisions(키워드 매칭)와 별개 축입니다.
  3. 합계 100 검증은 값 객체가 수행합니다 — RecommendationAxisWeights.of()가 4축 전부·합계 100을 검증하고, 실패 시 InvalidRecommendationWeightException(400)입니다. UseCase에 if + throw를 두지 않습니다.
  4. 상한 해제는 별도 테이블(job_posting_recommendation_cap_overrides, 공고당 1행)에 영속화합니다 — 결과 행은 재평가 때 덮어써지므로 같은 행에 두면 해제가 날아갑니다. FR-97의 “재평가 시에도 유지”가 이 분리의 이유입니다.
  5. 해제 후에도 원 상한 사유를 계속 노출합니다(FR-97) — 응답의 capReleased=true + capReason. 사용자가 “왜 상한이 걸렸었는지”를 잊지 않게 합니다.
  6. 해제·재적용 API는 즉시 재계산한 결과를 반환합니다 — 사용자가 다음 재평가를 기다리지 않아도 점수 변화를 봅니다.
  7. 신규 컨트롤러 파일(RecommendationWeightApiController.kt)로 만들어 BE-65의 RecommendationApiController.kt와 충돌하지 않습니다(다른 wave이지만 파일 분리를 유지).

의존

  • BE-63 (추천도 계산 도메인 — 2단계 산출물)
  • BE-68 (에러 코드)

다이어그램

처리 흐름

sequenceDiagram
    participant FE as web(SPA)
    participant C as RecommendationWeightApiController
    participant U as UpdateRecommendationWeightsUseCase
    participant D as RecommendationDomainService
    participant W as RecommendationAxisWeights
    participant R as RecommendationCriteriaRepository
    FE->>C: PUT /api/recommendations/weights
    C->>U: execute(command)
    U->>D: updateWeights(weights)
    D->>W: of(weights) — 4축·합계 100 검증
    alt 검증 실패
        W-->>D: InvalidRecommendationWeightException (400)
    else 통과
        D->>R: bumpRevision(AXIS_WEIGHT, reason)
        R-->>D: 새 criteriaRevision
    end
    D-->>U: criteriaRevision + weights
    C-->>FE: 200

클래스 의존

flowchart LR
    subgraph Presentation["presentation/recommendation"]
        Api[RecommendationWeightApiController]
    end
    subgraph Application["application/recommendation"]
        Get[GetRecommendationWeightsUseCase]
        Upd[UpdateRecommendationWeightsUseCase]
        Rel[ReleaseRecommendationCapUseCase]
        Res[RestoreRecommendationCapUseCase]
    end
    subgraph Domain["domain/recommendation"]
        DS[RecommendationDomainService]
        Weights[RecommendationAxisWeights]
        CapRepo[CapOverrideRepository]
        CRepo[RecommendationCriteriaRepository]
    end
    Api --> Get
    Api --> Upd
    Api --> Rel
    Api --> Res
    Upd --> DS
    Rel --> DS
    DS --> Weights
    DS --> CapRepo
    DS --> CRepo

테스트 케이스

  • 현재 가중치를 조회하면 4축과 기준 버전이 반환된다
  • 합계 100인 가중치를 저장하면 200과 증가된 기준 버전이 반환된다
  • 합계가 99면 400 RECOMMENDATION_WEIGHT_INVALID다 (경계값)
  • 합계가 101이면 400이다 (경계값)
  • 축이 3종만 전달되면 400이다
  • 알 수 없는 축 이름이면 400이다
  • 가중치 변경 후 재평가하면 새 비중으로 점수가 계산된다
  • 가중치 변경이 match_criteria_revisions를 증가시키지 않는다 (별개 축 검증)
  • 상한이 걸린 공고에 해제를 요청하면 점수가 재계산되고 capReleased=true
  • 해제 후 재평가해도 해제가 유지된다
  • 해제된 결과에 원 상한 사유(capReason)가 계속 노출된다
  • 해제를 취소하면 다시 59점 상한이 적용된다
  • 상한이 걸리지 않은 공고에 해제를 요청해도 200이고 점수가 그대로다 (멱등)
  • 평가 결과가 없는 공고에 해제를 요청하면 404 RECOMMENDATION_NOT_FOUND