Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package com.hansung.tracktory.domain.briefing.ai;

import java.util.List;
import tools.jackson.databind.PropertyNamingStrategies;
import tools.jackson.databind.annotation.JsonNaming;

/**
* AI 중계 서버의 직무 브리핑 요청 바디 — FastAPI BriefingRequest 를 mirror 한다.
*
* <p>브리핑 표면은 추천 입력 전체가 아니라 직무 식별자만 받는다. camelCase 자바 필드 {@code jobIds} 는 snake_case JSON {@code
* job_ids} 로 직렬화된다. FastAPI 계약상 최소 1개이며 각 코드는 공백 제거 후 1자 이상이어야 하므로, 빈 목록을 보내지 않는 책임은 호출 서비스에 있다.
*/
@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class)
public record AiBriefingRequest(List<String> jobIds) {}
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
package com.hansung.tracktory.domain.briefing.ai;

import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import java.util.List;
import tools.jackson.databind.PropertyNamingStrategies;
import tools.jackson.databind.annotation.JsonNaming;

/**
* AI 중계 서버 직무 브리핑 응답의 data 본문 — FastAPI BriefingResponse 와 중첩 모델(JobBriefing/BriefingSource)을
* mirror 한다. 모든 레코드는 snake_case JSON 매핑 + 알 수 없는 필드 무시를 적용한다.
*/
@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class)
@JsonIgnoreProperties(ignoreUnknown = true)
public record AiBriefingResponse(List<Briefing> briefings) {

/**
* 추천 직무 한 건에 연결된 트렌드 브리핑 카드. {@code jobId} 는 직무 카탈로그 표준 코드(추천 직무 식별자와 정합), {@code sources} 는 검증
* 가능한 출처(FastAPI 가 최소 1개를 보장).
*/
@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class)
@JsonIgnoreProperties(ignoreUnknown = true)
public record Briefing(
String jobId,
String jobName,
String headline,
String summary,
List<String> skills,
List<Source> sources) {}

/** 브리핑 근거 출처. {@code publishedAt} 은 발행 시점(YYYY 또는 YYYY-MM, 미상이면 null). */
@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class)
@JsonIgnoreProperties(ignoreUnknown = true)
public record Source(String title, String url, String publishedAt) {}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
package com.hansung.tracktory.domain.briefing.ai;

import com.hansung.tracktory.domain.recommendation.ai.AiEnvelope;
import com.hansung.tracktory.global.exception.BusinessException;
import com.hansung.tracktory.global.exception.ErrorCode;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.core.ParameterizedTypeReference;
import org.springframework.stereotype.Component;
import org.springframework.web.reactive.function.client.WebClient;
import org.springframework.web.reactive.function.client.WebClientException;
import org.springframework.web.reactive.function.client.WebClientResponseException;

/**
* AI 중계 서버(FastAPI)의 직무 브리핑 엔드포인트를 호출하는 클라이언트.
*
* <p>내부 인증 헤더(X-Internal-Token)·사용자 식별 헤더(X-User-Id)는 {@code aiRelayWebClient} 빈의 필터가 자동 부착하므로 여기서
* 설정하지 않는다. 필터가 호출 스레드의 SecurityContext 를 읽으므로 인증된 요청 스레드에서 블로킹 호출해야 한다.
*/
@Component
public class BriefingClient {

private static final String BRIEFING_PATH = "/api/v1/ai/briefing";
private static final Logger log = LoggerFactory.getLogger(BriefingClient.class);

private final WebClient aiRelayWebClient;

public BriefingClient(@Qualifier("aiRelayWebClient") WebClient aiRelayWebClient) {
this.aiRelayWebClient = aiRelayWebClient;
}

/**
* 추천 직무 코드 목록으로 큐레이션 브리핑 카드를 요청한다.
*
* @throws BusinessException AI 서버 호출 실패 또는 비정상 응답 시 ({@link ErrorCode#AI_RELAY_ERROR}).
*/
public AiBriefingResponse fetch(AiBriefingRequest request) {
AiEnvelope<AiBriefingResponse> envelope;
try {
envelope =
aiRelayWebClient
.post()
.uri(BRIEFING_PATH)
.bodyValue(request)
.retrieve()
.bodyToMono(new ParameterizedTypeReference<AiEnvelope<AiBriefingResponse>>() {})
.block();
} catch (WebClientResponseException e) {
log.warn("AI relay 응답 오류: status={} body={}", e.getStatusCode(), e.getResponseBodyAsString());
throw new BusinessException(ErrorCode.AI_RELAY_ERROR);
} catch (WebClientException e) {
log.warn("AI relay 호출 실패", e);
throw new BusinessException(ErrorCode.AI_RELAY_ERROR);
}

if (envelope == null || !envelope.success() || envelope.data() == null) {
throw new BusinessException(ErrorCode.AI_RELAY_ERROR);
}
return envelope.data();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
package com.hansung.tracktory.domain.briefing.controller;

import com.hansung.tracktory.domain.briefing.dto.BriefingResponse;
import com.hansung.tracktory.domain.briefing.service.BriefingService;
import com.hansung.tracktory.domain.user.service.UserPrincipal;
import com.hansung.tracktory.global.response.ApiResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

/**
* 직무 브리핑 조회 API — 인증된 사용자의 활성 추천 직무에 연결된 트렌드 브리핑 카드를 반환한다.
*
* <p>앱은 AI 서버를 직접 호출하지 않고 본 백엔드를 경유한다. 활성 추천이 없으면 {@code RECOMMENDATION_NOT_FOUND}(404) 로 거절된다.
*/
@RestController
@RequestMapping("/api/v1/briefings")
@RequiredArgsConstructor
public class BriefingController {

private final BriefingService briefingService;

@GetMapping
public ResponseEntity<ApiResponse<BriefingResponse>> getBriefings(
@AuthenticationPrincipal UserPrincipal principal) {
BriefingResponse response = briefingService.getBriefings(principal.getUserId());
return ResponseEntity.ok(ApiResponse.ok(response));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
package com.hansung.tracktory.domain.briefing.dto;

import java.util.List;

/**
* 직무 브리핑 조회 API 의 프론트엔드 응답 — 활성 추천 직무에 연결된 트렌드 카드 묶음을 전달한다.
*
* <p>홈 브리핑 시트가 소비하며, 카드는 추천 직무 순서(적합도 내림차순)를 따른다. 큐레이션이 없는 직무는 빠지므로 카드 수가 추천 직무 수보다 적을 수 있고, 매칭이
* 하나도 없으면 빈 리스트다.
*/
public record BriefingResponse(List<BriefingCardView> briefings) {

/**
* 직무 한 건의 트렌드 브리핑 카드. {@code code} 는 직무 카탈로그 표준 코드(추천 직무 식별자와 정합), {@code skills} 는 카드가 강조하는 필수
* 역량·기술 키워드(없으면 빈 리스트), {@code sources} 는 검증 가능한 출처(최소 1개).
*/
public record BriefingCardView(
String code,
String name,
String headline,
String summary,
List<String> skills,
List<SourceView> sources) {}

/** 브리핑 근거 출처. {@code publishedAt} 은 발행 시점(YYYY 또는 YYYY-MM), 미상이면 null. */
public record SourceView(String title, String url, String publishedAt) {}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
package com.hansung.tracktory.domain.briefing.service;

import com.hansung.tracktory.domain.briefing.ai.AiBriefingRequest;
import com.hansung.tracktory.domain.briefing.ai.AiBriefingResponse;
import com.hansung.tracktory.domain.briefing.ai.BriefingClient;
import com.hansung.tracktory.domain.briefing.dto.BriefingResponse;
import com.hansung.tracktory.domain.briefing.dto.BriefingResponse.BriefingCardView;
import com.hansung.tracktory.domain.briefing.dto.BriefingResponse.SourceView;
import com.hansung.tracktory.domain.recommendation.entity.Recommendation;
import com.hansung.tracktory.domain.recommendation.entity.RecommendationStatus;
import com.hansung.tracktory.domain.recommendation.entity.RecommendedJob;
import com.hansung.tracktory.domain.recommendation.repository.RecommendationRepository;
import com.hansung.tracktory.global.exception.BusinessException;
import com.hansung.tracktory.global.exception.ErrorCode;
import java.util.Comparator;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

/**
* 직무 브리핑 중계 — 사용자의 활성 추천 직무를 기준으로 AI 중계 서버의 큐레이션 브리핑을 조회한다.
*
* <p>추천 재계산 없이 홈 추천과 같은 활성 추천 aggregate 를 읽어 직무 코드만 추출하므로 홈 결과와 정합한다. 활성 추천이 없으면 {@code
* RECOMMENDATION_NOT_FOUND}(404) 로 거절해 새 추천 생성을 유도한다. 브리핑 카드는 AI 서버가 큐레이션 카탈로그에서 직접 채우며, 본 백엔드는 추천
* 직무 코드 전달과 응답 매핑만 담당한다.
*/
@Service
@RequiredArgsConstructor
public class BriefingService {

private final RecommendationRepository recommendationRepository;
private final BriefingClient briefingClient;

/**
* 활성 추천 직무 코드로 브리핑 카드를 조회한다. 직무 코드는 적합도(score) 내림차순으로 정렬하고 중복을 제거해 카드 순서가 추천 직무 순서를 따르게 한다.
*
* <p>추천 직무가 하나도 없으면 AI 가 빈 {@code job_ids} 를 422 로 거절하므로, 호출하지 않고 빈 결과를 반환한다(부분 충족 허용). 큐레이션이 없는
* 직무는 AI 응답에서 빠져 카드 수가 추천 직무 수보다 적을 수 있다.
*/
@Transactional(readOnly = true)
public BriefingResponse getBriefings(Long userId) {
Recommendation active =
recommendationRepository
.findFirstByUser_IdAndStatusOrderByCreatedAtDesc(userId, RecommendationStatus.ACTIVE)
.orElseThrow(() -> new BusinessException(ErrorCode.RECOMMENDATION_NOT_FOUND));

List<String> jobCodes = jobCodesByScoreDesc(active);
if (jobCodes.isEmpty()) {
return new BriefingResponse(List.of());
}

AiBriefingResponse aiResponse = briefingClient.fetch(new AiBriefingRequest(jobCodes));
return toResponse(aiResponse);
}

// 추천 직무 코드를 적합도 내림차순 + 등장 순 중복 제거로 추출한다. 같은 코드로 fold 된 중복 직무가 같은 브리핑을 두 번 요청하지 않도록 한다.
private static List<String> jobCodesByScoreDesc(Recommendation recommendation) {
Set<String> ordered = new LinkedHashSet<>();
recommendation.getRecommendedJobs().stream()
.filter(job -> job.getJob() != null && job.getJob().getCode() != null)
.sorted(Comparator.comparingInt(RecommendedJob::getScore).reversed())
.map(job -> job.getJob().getCode())
.forEach(ordered::add);
return List.copyOf(ordered);
}

private static BriefingResponse toResponse(AiBriefingResponse aiResponse) {
List<BriefingCardView> cards =
nullSafe(aiResponse.briefings()).stream().map(BriefingService::toCard).toList();
return new BriefingResponse(cards);
}

private static BriefingCardView toCard(AiBriefingResponse.Briefing briefing) {
return new BriefingCardView(
briefing.jobId(),
briefing.jobName(),
briefing.headline(),
briefing.summary(),
nullSafe(briefing.skills()),
nullSafe(briefing.sources()).stream().map(BriefingService::toSource).toList());
}

private static SourceView toSource(AiBriefingResponse.Source source) {
return new SourceView(source.title(), source.url(), source.publishedAt());
}

private static <T> List<T> nullSafe(List<T> list) {
return list == null ? List.of() : list;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
package com.hansung.tracktory.domain.briefing.ai;

import static org.assertj.core.api.Assertions.assertThat;

import com.hansung.tracktory.domain.briefing.ai.AiBriefingResponse.Briefing;
import com.hansung.tracktory.domain.briefing.ai.AiBriefingResponse.Source;
import com.hansung.tracktory.domain.recommendation.ai.AiEnvelope;
import org.junit.jupiter.api.Test;
import tools.jackson.core.type.TypeReference;
import tools.jackson.databind.json.JsonMapper;

/**
* AI 중계 서버(FastAPI) 브리핑 응답 계약의 역직렬화 회귀 가드.
*
* <p>실 FastAPI 페이로드 형태({@code {success, data:{briefings:[...]}}} envelope + 카드의
* job_id/headline/summary + 출처의 published_at)가 DTO 로 손실 없이 매핑되는지 검증한다. WebClient 도 DTO 자체 어노테이션만으로
* 동일하게 매핑하므로 기본 매퍼로 충분하다.
*/
class AiBriefingEnvelopeContractTest {

private static final JsonMapper MAPPER = JsonMapper.builder().build();

@Test
void deserializesRealFastApiBriefingEnvelopeWithoutFieldLoss() {
String json =
"""
{
"success": true,
"data": {
"briefings": [
{
"job_id": "BE",
"job_name": "백엔드 개발자",
"headline": "클라우드 네이티브 백엔드 수요 증가",
"summary": "컨테이너·MSA 역량이 채용에서 점점 더 중요해지고 있다.",
"skills": ["Spring Boot", "Kubernetes", "AWS"],
"sources": [
{"title": "Stack Overflow Developer Survey 2024",
"url": "https://survey.stackoverflow.co/2024",
"published_at": "2024"},
{"title": "JetBrains State of Developer Ecosystem",
"url": "https://www.jetbrains.com/lp/devecosystem-2023",
"published_at": null}
]
}
]
},
"error": null
}
""";

AiEnvelope<AiBriefingResponse> envelope =
MAPPER.readValue(json, new TypeReference<AiEnvelope<AiBriefingResponse>>() {});

assertThat(envelope.success()).isTrue();
AiBriefingResponse data = envelope.data();
assertThat(data).isNotNull();
assertThat(data.briefings()).hasSize(1);

Briefing card = data.briefings().get(0);
assertThat(card.jobId()).isEqualTo("BE");
assertThat(card.jobName()).isEqualTo("백엔드 개발자");
assertThat(card.headline()).isEqualTo("클라우드 네이티브 백엔드 수요 증가");
assertThat(card.summary()).isNotBlank();
assertThat(card.skills()).containsExactly("Spring Boot", "Kubernetes", "AWS");
assertThat(card.sources()).hasSize(2);

Source dated = card.sources().get(0);
assertThat(dated.title()).isEqualTo("Stack Overflow Developer Survey 2024");
assertThat(dated.url()).isEqualTo("https://survey.stackoverflow.co/2024");
assertThat(dated.publishedAt()).isEqualTo("2024");

// published_at 미상(null)도 손실 없이 매핑되어야 한다.
assertThat(card.sources().get(1).publishedAt()).isNull();
}
}
Loading
Loading