diff --git a/src/main/java/com/ureca/unity/domain/category/dto/Category.java b/src/main/java/com/ureca/unity/domain/category/dto/Category.java new file mode 100644 index 0000000..012eda1 --- /dev/null +++ b/src/main/java/com/ureca/unity/domain/category/dto/Category.java @@ -0,0 +1,11 @@ +package com.ureca.unity.domain.category.dto; + +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +public class Category { + private int categoryId; + private String name; +} \ No newline at end of file diff --git a/src/main/java/com/ureca/unity/domain/category/mapper/CategoryMapper.java b/src/main/java/com/ureca/unity/domain/category/mapper/CategoryMapper.java new file mode 100644 index 0000000..c07b2f5 --- /dev/null +++ b/src/main/java/com/ureca/unity/domain/category/mapper/CategoryMapper.java @@ -0,0 +1,10 @@ +package com.ureca.unity.domain.category.mapper; + +import com.ureca.unity.domain.category.dto.Category; +import java.util.List; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface CategoryMapper { + List selectAll(); +} diff --git a/src/main/java/com/ureca/unity/domain/recommend/client/RecommendClient.java b/src/main/java/com/ureca/unity/domain/recommend/client/RecommendClient.java new file mode 100644 index 0000000..3edb933 --- /dev/null +++ b/src/main/java/com/ureca/unity/domain/recommend/client/RecommendClient.java @@ -0,0 +1,27 @@ +package com.ureca.unity.domain.recommend.client; + +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestTemplate; +import org.springframework.beans.factory.annotation.Value; +import com.ureca.unity.domain.recommend.dto.RecommendRequest; +import com.ureca.unity.domain.recommend.dto.RecommendResponse; +import lombok.RequiredArgsConstructor; + +@Component +@RequiredArgsConstructor +public class RecommendClient { + + private final RestTemplate restTemplate; + + @Value("${python.recommend.base-url:http://localhost:8000}") + private String baseUrl; + + public RecommendResponse recommend(RecommendRequest req) { + return restTemplate.postForObject( + baseUrl + "/api/v1/summary/" + req.getSummaryId() + "/recommend?k=" + req.getK(), + null, + RecommendResponse.class + ); + } +} + diff --git a/src/main/java/com/ureca/unity/domain/recommend/controller/RecommendController.java b/src/main/java/com/ureca/unity/domain/recommend/controller/RecommendController.java new file mode 100644 index 0000000..ea3f256 --- /dev/null +++ b/src/main/java/com/ureca/unity/domain/recommend/controller/RecommendController.java @@ -0,0 +1,52 @@ +package com.ureca.unity.domain.recommend.controller; + +import com.ureca.unity.domain.recommend.dto.RecommendResponse; +import com.ureca.unity.domain.recommend.service.RecommendService; +import com.ureca.unity.global.exception.CustomException; +import com.ureca.unity.global.exception.ErrorCode; +import lombok.RequiredArgsConstructor; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/v1/recommend") +@RequiredArgsConstructor +public class RecommendController { + + private static final Logger log = LoggerFactory.getLogger(RecommendController.class); + + private final RecommendService recommendService; + + // 요약 추천 생성 (FastAPI 호출 + DB 저장) + @PostMapping("/{summaryId}/generate") + public RecommendResponse generate(@PathVariable long summaryId) { + return recommendService.generateAndSave(summaryId); + } + + // 요약 추천 조회 (DB 조회만) + @GetMapping("/{summaryId}") + public RecommendResponse get(@PathVariable long summaryId) { + try { + return recommendService.getFromDb(summaryId); + } catch (CustomException e) { + log.error("get failed for summaryId={}", summaryId, e); + throw e; + } catch (Exception e) { + log.error("unexpected error for summaryId={}", summaryId, e); + throw new CustomException(ErrorCode.INTERNAL_SERVER_ERROR); + } + } + + // 전체 추천 조회 + @GetMapping("/me") + public RecommendResponse getAll() { + // undefined 'currentUserId' 제거 — 서비스의 카테고리 기반 조회 재사용 + return recommendService.getRandomByCategory(); + } +} + diff --git a/src/main/java/com/ureca/unity/domain/recommend/dto/RecommendItem.java b/src/main/java/com/ureca/unity/domain/recommend/dto/RecommendItem.java new file mode 100644 index 0000000..3019c72 --- /dev/null +++ b/src/main/java/com/ureca/unity/domain/recommend/dto/RecommendItem.java @@ -0,0 +1,16 @@ +package com.ureca.unity.domain.recommend.dto; + +import lombok.Getter; +import lombok.Setter; + +@Getter @Setter +public class RecommendItem { + private long productId; + private int categoryId; + private double score; + private int rankNo; + private String name; + private Double price; + private String img; + private String link; +} \ No newline at end of file diff --git a/src/main/java/com/ureca/unity/domain/recommend/dto/RecommendRequest.java b/src/main/java/com/ureca/unity/domain/recommend/dto/RecommendRequest.java new file mode 100644 index 0000000..1588f19 --- /dev/null +++ b/src/main/java/com/ureca/unity/domain/recommend/dto/RecommendRequest.java @@ -0,0 +1,10 @@ +package com.ureca.unity.domain.recommend.dto; + +import lombok.Getter; +import lombok.Setter; + +@Getter @Setter +public class RecommendRequest { + private long summaryId; + private int k = 5; //기본값 +} \ No newline at end of file diff --git a/src/main/java/com/ureca/unity/domain/recommend/dto/RecommendResponse.java b/src/main/java/com/ureca/unity/domain/recommend/dto/RecommendResponse.java new file mode 100644 index 0000000..ba16e68 --- /dev/null +++ b/src/main/java/com/ureca/unity/domain/recommend/dto/RecommendResponse.java @@ -0,0 +1,12 @@ +package com.ureca.unity.domain.recommend.dto; + +import java.util.List; +import lombok.Getter; +import lombok.Setter; + + +@Getter @Setter +public class RecommendResponse { + private long summaryId; + private List items; +} \ No newline at end of file diff --git a/src/main/java/com/ureca/unity/domain/recommend/mapper/RecommendMapper.java b/src/main/java/com/ureca/unity/domain/recommend/mapper/RecommendMapper.java new file mode 100644 index 0000000..4e1148b --- /dev/null +++ b/src/main/java/com/ureca/unity/domain/recommend/mapper/RecommendMapper.java @@ -0,0 +1,14 @@ +package com.ureca.unity.domain.recommend.mapper; + +import com.ureca.unity.domain.recommend.dto.RecommendItem; +import java.util.List; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; + +@Mapper +public interface RecommendMapper { + void deleteItems(@Param("summaryId") long summaryId); + void insertItems(@Param("summaryId") long summaryId, @Param("items") List items); + List selectBySummaryId(@Param("summaryId") long summaryId); + List selectRandomByCategory(@Param("categoryId") int categoryId); +} diff --git a/src/main/java/com/ureca/unity/domain/recommend/service/RecommendService.java b/src/main/java/com/ureca/unity/domain/recommend/service/RecommendService.java new file mode 100644 index 0000000..9aa97fa --- /dev/null +++ b/src/main/java/com/ureca/unity/domain/recommend/service/RecommendService.java @@ -0,0 +1,136 @@ +package com.ureca.unity.domain.recommend.service; + +import com.ureca.unity.domain.category.mapper.CategoryMapper; +import com.ureca.unity.domain.recommend.client.RecommendClient; +import com.ureca.unity.domain.recommend.dto.RecommendItem; +import com.ureca.unity.domain.recommend.dto.RecommendRequest; +import com.ureca.unity.domain.recommend.dto.RecommendResponse; +import com.ureca.unity.domain.recommend.mapper.RecommendMapper; +import com.ureca.unity.global.exception.CustomException; +import com.ureca.unity.global.exception.ErrorCode; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; +import com.ureca.unity.domain.category.dto.Category; +import lombok.RequiredArgsConstructor; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class RecommendService { + + private final RecommendClient recommendClient; + private final RecommendMapper recommendMapper; + private final CategoryMapper categoryMapper; + private static final Logger log = LoggerFactory.getLogger(RecommendService.class); + + // 추천 생성 + 저장 + @Transactional + public RecommendResponse generateAndSave(long summaryId) { + RecommendRequest req = new RecommendRequest(); + req.setSummaryId(summaryId); + req.setK(5); + + RecommendResponse response; + + // FastAPI 호출 + try { + response = recommendClient.recommend(req); + } catch (Exception e) { + throw new CustomException(ErrorCode.FASTAPI_CALL_FAILED); + } + + if (response == null || response.getItems() == null || response.getItems().isEmpty()) { + throw new CustomException(ErrorCode.RECOMMEND_EMPTY); + } + + // DB 삭제 + try { + recommendMapper.deleteItems(summaryId); + } catch (Exception e) { + throw new CustomException(ErrorCode.RECOMMEND_DELETE_FAILED); + } + + List items = response.getItems(); + items.sort((a, b) -> Double.compare(b.getScore(), a.getScore())); + for (int i = 0; i < items.size(); i++) { + items.get(i).setRankNo(i); // 0,1,2,... 순서대로 + } + + // DB 삽입 + try { + recommendMapper.insertItems(summaryId, items); + } catch (Exception e) { + throw new CustomException(ErrorCode.RECOMMEND_INSERT_FAILED); + } + + return response; + } + + // DB 조회만 + public RecommendResponse getFromDb(long summaryId) { + List items; + try { + items = recommendMapper.selectBySummaryId(summaryId); + log.debug("DB 조회 결과: {}", items); + } catch (Exception e) { + log.error("DB 조회 실패 summaryId={}", summaryId, e); + throw new CustomException(ErrorCode.INTERNAL_SERVER_ERROR); + } + + if (items == null || items.isEmpty()) { + throw new CustomException(ErrorCode.RECOMMEND_EMPTY); + } + + RecommendResponse response = new RecommendResponse(); + response.setSummaryId(summaryId); + response.setItems(items); + return response; + } + + @Transactional(readOnly = true) + public RecommendResponse getRandomByCategory() { + List categories = categoryMapper.selectAll(); + List results = new ArrayList<>(); + + for (Category c : categories) { + List fallback = recommendMapper.selectRandomByCategory(c.getCategoryId()); + results.addAll(fallback); + } + + RecommendResponse response = new RecommendResponse(); + response.setSummaryId(0); + response.setItems(results); + return response; + } + + +// public List getRecommendationsWithFallback(long summaryId) { +// List items = recommendMapper.selectBySummaryId(summaryId); +// +// // 추천 없으면 빈 리스트 처리 +// if (items == null) { +// items = new ArrayList<>(); +// } +// +// // 카테고리별로 그룹화 +// Map> byCategory = items.stream() +// .collect(Collectors.groupingBy(RecommendItem::getCategoryId)); +// +// List categories = categoryMapper.selectAll(); +// for (Category c : categories) { +// if (!byCategory.containsKey(c.getCategoryId()) || byCategory.get(c.getCategoryId()).isEmpty()) { +// // 추천 없으면 fallback +// List fallback = recommendMapper.selectRandomByCategory(c.getCategoryId()); +// items.addAll(fallback); +// } +// } +// +// // score=0, rankNo=0인 fallback도 이미 Mapper에서 설정됨 +// return items; +// } +} diff --git a/src/main/java/com/ureca/unity/global/exception/ErrorCode.java b/src/main/java/com/ureca/unity/global/exception/ErrorCode.java index 079550b..7f2ab08 100644 --- a/src/main/java/com/ureca/unity/global/exception/ErrorCode.java +++ b/src/main/java/com/ureca/unity/global/exception/ErrorCode.java @@ -1,47 +1,48 @@ package com.ureca.unity.global.exception; +import lombok.Getter; import org.springframework.http.HttpStatus; +@Getter public enum ErrorCode { - TOKEN_MISSING(HttpStatus.UNAUTHORIZED, "토큰이 없습니다."), - TOKEN_EXPIRED(HttpStatus.UNAUTHORIZED, "로그인이 만료되었습니다."), - INVALID_TOKEN(HttpStatus.UNAUTHORIZED, "유효하지 않은 토큰입니다."), + TOKEN_MISSING(HttpStatus.UNAUTHORIZED, "토큰이 없습니다."), + TOKEN_EXPIRED(HttpStatus.UNAUTHORIZED, "로그인이 만료되었습니다."), + INVALID_TOKEN(HttpStatus.UNAUTHORIZED, "유효하지 않은 토큰입니다."), - REFRESH_TOKEN_MISSING(HttpStatus.UNAUTHORIZED, "리프레시 토큰이 없습니다."), - REFRESH_TOKEN_INVALID(HttpStatus.UNAUTHORIZED, "유효하지 않은 리프레시 토큰입니다."), - REFRESH_TOKEN_EXPIRED(HttpStatus.UNAUTHORIZED, "리프레시 토큰이 만료되었습니다."), + REFRESH_TOKEN_MISSING(HttpStatus.UNAUTHORIZED, "리프레시 토큰이 없습니다."), + REFRESH_TOKEN_INVALID(HttpStatus.UNAUTHORIZED, "유효하지 않은 리프레시 토큰입니다."), + REFRESH_TOKEN_EXPIRED(HttpStatus.UNAUTHORIZED, "리프레시 토큰이 만료되었습니다."), - INVALID_OAUTH_PROVIDER(HttpStatus.BAD_REQUEST,"지원하지 않는 OAuth 제공자입니다."), + INVALID_OAUTH_PROVIDER(HttpStatus.BAD_REQUEST,"지원하지 않는 OAuth 제공자입니다."), - USER_NOT_FOUND(HttpStatus.NOT_FOUND, "사용자를 찾을 수 없습니다."), - USER_ALREADY_DELETED(HttpStatus.BAD_REQUEST, "이미 탈퇴한 사용자입니다."), + USER_NOT_FOUND(HttpStatus.NOT_FOUND, "사용자를 찾을 수 없습니다."), + USER_ALREADY_DELETED(HttpStatus.BAD_REQUEST, "이미 탈퇴한 사용자입니다."), - // 기타 공용 exception code - INVALID_INPUT_VALUE(HttpStatus.BAD_REQUEST, "입력값이 잘못되었습니다."), - INVALID_TYPE_VALUE(HttpStatus.BAD_REQUEST, "데이터 타입이 일치하지 않습니다."), - METHOD_NOT_ALLOWED(HttpStatus.METHOD_NOT_ALLOWED, "허용되지 않은 메소드입니다."), - NOT_FOUND(HttpStatus.NOT_FOUND, "대상을 찾을 수 없습니다."), - INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "서버 내부 로직 오류입니다."), + FASTAPI_CALL_FAILED(HttpStatus.BAD_GATEWAY, "추천 서버 호출 실패"), + RECOMMEND_DELETE_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "기존 추천 삭제 실패"), + RECOMMEND_INSERT_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "추천 결과 저장 실패"), + RECOMMEND_EMPTY(HttpStatus.NOT_FOUND, "추천 결과가 없습니다"), + RECOMMEND_RETRIEVE_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "추천 결과 조회 실패"), - OAUTH_TOKEN_NOT_FOUND(HttpStatus.BAD_REQUEST, "소셜 토큰이 없어 연결 해제를 진행할 수 없습니다. 다시 로그인 후 탈퇴해주세요."), - SOCIAL_UNLINK_FAILED(HttpStatus.BAD_GATEWAY, "소셜 연결 해제에 실패했습니다. 잠시 후 다시 시도해주세요."), + // 기타 공용 exception code + INVALID_INPUT_VALUE(HttpStatus.BAD_REQUEST, "입력값이 잘못되었습니다."), + INVALID_TYPE_VALUE(HttpStatus.BAD_REQUEST, "데이터 타입이 일치하지 않습니다."), + METHOD_NOT_ALLOWED(HttpStatus.METHOD_NOT_ALLOWED, "허용되지 않은 메소드입니다."), + NOT_FOUND(HttpStatus.NOT_FOUND, "대상을 찾을 수 없습니다."), + INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "서버 내부 로직 오류입니다."), - AUTH_STORAGE_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "소셜 로그인 정보 저장에 실패했습니다. 잠시 후 다시 시도해주세요."); + OAUTH_TOKEN_NOT_FOUND(HttpStatus.BAD_REQUEST, "소셜 토큰이 없어 연결 해제를 진행할 수 없습니다. 다시 로그인 후 탈퇴해주세요."), + SOCIAL_UNLINK_FAILED(HttpStatus.BAD_GATEWAY, "소셜 연결 해제에 실패했습니다. 잠시 후 다시 시도해주세요."), - private final HttpStatus status; - private final String message; + AUTH_STORAGE_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "소셜 로그인 정보 저장에 실패했습니다. 잠시 후 다시 시도해주세요."); - ErrorCode(HttpStatus status, String message) { - this.status = status; - this.message = message; - } + private final HttpStatus status; + private final String message; - public HttpStatus getStatus() { - return status; - } + ErrorCode(HttpStatus status, String message) { + this.status = status; + this.message = message; + } - public String getMessage() { - return message; - } -} +} \ No newline at end of file diff --git a/src/main/resources/mapper/category/CategoryMapper.xml b/src/main/resources/mapper/category/CategoryMapper.xml new file mode 100644 index 0000000..5ffc3dd --- /dev/null +++ b/src/main/resources/mapper/category/CategoryMapper.xml @@ -0,0 +1,12 @@ + + + + + + + diff --git a/src/main/resources/mapper/recommend/RecommendMapper.xml b/src/main/resources/mapper/recommend/RecommendMapper.xml new file mode 100644 index 0000000..c6107f8 --- /dev/null +++ b/src/main/resources/mapper/recommend/RecommendMapper.xml @@ -0,0 +1,54 @@ + + + + + + + DELETE FROM recommend WHERE summary_id = #{summaryId} + + + + INSERT INTO recommend + (summary_id, product_id, rank_no, score, created_at) + VALUES + + (#{summaryId}, #{it.productId}, #{it.rankNo}, #{it.score}, NOW()) + + + + + + + +