recommend 수정

This commit is contained in:
youbeen
2025-06-12 13:41:18 +09:00
parent 544fa1624e
commit b822917e4e
11 changed files with 521 additions and 149 deletions
@@ -2,20 +2,20 @@ package com.ktds.hi.recommend;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cloud.openfeign.EnableFeignClients;
import org.springframework.data.jpa.repository.config.EnableJpaAuditing;
/**
* 추천 서비스 메인 애플리케이션 클래스
* 가게 추천, 취향 분석 기능을 제공
*
* @author 하이오더 개발팀
* @version 1.0.0
* 추천 서비스 메인 애플리케이션
*/
@SpringBootApplication(scanBasePackages = {"com.ktds.hi.recommend", "com.ktds.hi.common"})
@SpringBootApplication(scanBasePackages = {
"com.ktds.hi.recommend",
"com.ktds.hi.common"
})
@EnableFeignClients
@EnableJpaAuditing
public class RecommendServiceApplication {
public static void main(String[] args) {
SpringApplication.run(RecommendServiceApplication.class, args);
}
}
}
@@ -1,12 +1,18 @@
// recommend/src/main/java/com/ktds/hi/recommend/infra/controller/StoreRecommendController.java
package com.ktds.hi.recommend.infra.controller;
import com.ktds.hi.recommend.biz.usecase.in.StoreRecommendUseCase;
import com.ktds.hi.recommend.infra.dto.request.RecommendStoreRequest;
import com.ktds.hi.recommend.infra.dto.response.RecommendStoreResponse;
import com.ktds.hi.recommend.infra.dto.response.StoreDetailResponse;
import com.ktds.hi.common.dto.response.ApiResponse;
import com.ktds.hi.common.dto.response.PageResponse;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Pageable;
import org.springframework.data.web.PageableDefault;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.*;
@@ -14,55 +20,93 @@ import org.springframework.web.bind.annotation.*;
import java.util.List;
/**
* 매장 추천 컨트롤러 클래스
* 매장 추천 관련 API를 제공
* 매장 추천 컨트롤러
*/
@RestController
@RequestMapping("/api/recommend")
@RequestMapping("/api/recommend/stores")
@RequiredArgsConstructor
@Tag(name = "매장 추천 API", description = "사용자 취향 기반 매장 추천 관련 API")
public class StoreRecommendController {
private final StoreRecommendUseCase storeRecommendUseCase;
/**
* 사용자 취향 기반 매장 추천 API
* 개인화 매장 추천
*/
@PostMapping("/stores")
@Operation(summary = "매장 추천", description = "사용자 취향과 위치를 기반으로 매장을 추천합니다.")
public ResponseEntity<List<RecommendStoreResponse>> recommendStores(Authentication authentication,
@Valid @RequestBody RecommendStoreRequest request) {
@PostMapping
@Operation(summary = "개인화 매장 추천", description = "사용자 취향과 위치를 기반으로 매장을 추천합니다.")
public ResponseEntity<ApiResponse<List<RecommendStoreResponse>>> recommendStores(
Authentication authentication,
@Valid @RequestBody RecommendStoreRequest request) {
Long memberId = Long.valueOf(authentication.getName());
List<RecommendStoreResponse> recommendations = storeRecommendUseCase.recommendStores(memberId, request);
return ResponseEntity.ok(recommendations);
List<RecommendStoreResponse> recommendations =
storeRecommendUseCase.recommendPersonalizedStores(memberId, request);
return ResponseEntity.ok(ApiResponse.success(recommendations));
}
/**
* 위치 기반 매장 추천 API
* 위치 기반 매장 추천
*/
@GetMapping("/stores/nearby")
@GetMapping("/nearby")
@Operation(summary = "주변 매장 추천", description = "현재 위치 기반으로 주변 매장을 추천합니다.")
public ResponseEntity<List<RecommendStoreResponse>> recommendNearbyStores(
public ResponseEntity<ApiResponse<PageResponse<RecommendStoreResponse>>> recommendNearbyStores(
@RequestParam Double latitude,
@RequestParam Double longitude,
@RequestParam(defaultValue = "5000") Integer radius) {
List<RecommendStoreResponse> recommendations = storeRecommendUseCase
.recommendStoresByLocation(latitude, longitude, radius);
return ResponseEntity.ok(recommendations);
@RequestParam(defaultValue = "5000") Integer radius,
@RequestParam(required = false) String category,
@PageableDefault(size = 20) Pageable pageable) {
PageResponse<RecommendStoreResponse> recommendations =
storeRecommendUseCase.recommendStoresByLocation(latitude, longitude, radius, category, pageable);
return ResponseEntity.ok(ApiResponse.success(recommendations));
}
/**
* 인기 매장 추천 API
* 인기 매장 추천
*/
@GetMapping("/stores/popular")
@GetMapping("/popular")
@Operation(summary = "인기 매장 추천", description = "카테고리별 인기 매장을 추천합니다.")
public ResponseEntity<List<RecommendStoreResponse>> recommendPopularStores(
public ResponseEntity<ApiResponse<List<RecommendStoreResponse>>> recommendPopularStores(
@RequestParam(required = false) String category,
@RequestParam(defaultValue = "10") Integer limit) {
List<RecommendStoreResponse> recommendations = storeRecommendUseCase
.recommendPopularStores(category, limit);
return ResponseEntity.ok(recommendations);
List<RecommendStoreResponse> recommendations =
storeRecommendUseCase.recommendPopularStores(category, limit);
return ResponseEntity.ok(ApiResponse.success(recommendations));
}
}
/**
* 추천 매장 상세 조회
*/
@GetMapping("/{storeId}")
@Operation(summary = "추천 매장 상세 조회", description = "추천된 매장의 상세 정보를 조회합니다.")
public ResponseEntity<ApiResponse<StoreDetailResponse>> getRecommendedStoreDetail(
@PathVariable Long storeId,
Authentication authentication) {
Long memberId = authentication != null ? Long.valueOf(authentication.getName()) : null;
StoreDetailResponse storeDetail =
storeRecommendUseCase.getRecommendedStoreDetail(storeId, memberId);
return ResponseEntity.ok(ApiResponse.success(storeDetail));
}
/**
* 추천 클릭 로깅
*/
@PostMapping("/{storeId}/click")
@Operation(summary = "추천 클릭 로깅", description = "추천된 매장 클릭을 로깅합니다.")
public ResponseEntity<ApiResponse<Void>> logRecommendClick(
@PathVariable Long storeId,
Authentication authentication) {
Long memberId = Long.valueOf(authentication.getName());
storeRecommendUseCase.logRecommendClick(memberId, storeId);
return ResponseEntity.ok(ApiResponse.success());
}
}
@@ -1,46 +1,69 @@
package com.ktds.hi.recommend.infra.controller;
import com.ktds.hi.recommend.biz.usecase.in.TasteAnalysisUseCase;
import com.ktds.hi.recommend.infra.dto.request.TasteUpdateRequest;
import com.ktds.hi.recommend.infra.dto.response.TasteAnalysisResponse;
import com.ktds.hi.common.dto.SuccessResponse;
import com.ktds.hi.recommend.infra.dto.response.PreferenceTagResponse;
import com.ktds.hi.common.dto.response.ApiResponse;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.*;
import java.util.List;
/**
* 취향 분석 컨트롤러 클래스
* 사용자 취향 분석 관련 API를 제공
* 취향 분석 컨트롤러
*/
@RestController
@RequestMapping("/api/recommend/taste")
@RequiredArgsConstructor
@Tag(name = "취향 분석 API", description = "사용자 취향 분석 관련 API")
public class TasteAnalysisController {
private final TasteAnalysisUseCase tasteAnalysisUseCase;
/**
* 사용자 취향 분석 조회 API
* 사용자 취향 분석 조회
*/
@GetMapping("/analysis")
@Operation(summary = "취향 분석 조회", description = "현재 로그인한 사용자의 취향 분석 결과를 조회합니다.")
public ResponseEntity<TasteAnalysisResponse> getMemberTasteAnalysis(Authentication authentication) {
public ResponseEntity<ApiResponse<TasteAnalysisResponse>> getMemberTasteAnalysis(
Authentication authentication) {
Long memberId = Long.valueOf(authentication.getName());
TasteAnalysisResponse analysis = tasteAnalysisUseCase.analyzeMemberTaste(memberId);
return ResponseEntity.ok(analysis);
return ResponseEntity.ok(ApiResponse.success(analysis));
}
/**
* 취향 프로필 업데이트 API
* 취향 프로필 업데이트
*/
@PostMapping("/update")
@Operation(summary = "취향 프로필 업데이트", description = "사용자의 리뷰 데이터를 기반으로 취향 프로필을 업데이트합니다.")
public ResponseEntity<SuccessResponse> updateTasteProfile(Authentication authentication) {
public ResponseEntity<ApiResponse<Void>> updateTasteProfile(
Authentication authentication,
@Valid @RequestBody TasteUpdateRequest request) {
Long memberId = Long.valueOf(authentication.getName());
tasteAnalysisUseCase.updateTasteProfile(memberId);
return ResponseEntity.ok(SuccessResponse.of("취향 프로필이 업데이트되었습니다"));
tasteAnalysisUseCase.updateTasteProfile(memberId, request);
return ResponseEntity.ok(ApiResponse.success("취향 프로필이 업데이트되었습니다"));
}
}
/**
* 가용한 취향 태그 조회
*/
@GetMapping("/tags")
@Operation(summary = "취향 태그 목록 조회", description = "선택 가능한 취향 태그 목록을 조회합니다.")
public ResponseEntity<ApiResponse<List<PreferenceTagResponse>>> getAvailablePreferenceTags() {
List<PreferenceTagResponse> tags = tasteAnalysisUseCase.getAvailablePreferenceTags();
return ResponseEntity.ok(ApiResponse.success(tags));
}
}
@@ -1,10 +1,10 @@
package com.ktds.hi.recommend.infra.dto.request;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotNull;
import lombok.AllArgsConstructor;
import jakarta.validation.constraints.*;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import java.util.List;
@@ -12,46 +12,36 @@ import java.util.List;
* 매장 추천 요청 DTO
*/
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Schema(description = "매장 추천 요청")
public class RecommendStoreRequest {
@NotNull(message = "위도는 필수입니다")
@DecimalMin(value = "-90.0", message = "위도는 -90도 이상이어야 합니다")
@DecimalMax(value = "90.0", message = "위도는 90도 이하여야 합니다")
@Schema(description = "위도", example = "37.5665")
private Double latitude;
@NotNull(message = "경도는 필수입니다")
@DecimalMin(value = "-180.0", message = "경도는 -180도 이상이어야 합니다")
@DecimalMax(value = "180.0", message = "경도는 180도 이하여야 합니다")
@Schema(description = "경도", example = "126.9780")
private Double longitude;
@Schema(description = "검색 반경(미터)", example = "5000", defaultValue = "5000")
@Min(value = 100, message = "반경은 최소 100m 이상이어야 합니다")
@Max(value = 50000, message = "반경은 최대 50km 이하여야 합니다")
@Schema(description = "검색 반경 (미터)", example = "5000")
private Integer radius = 5000;
@Schema(description = "선호 카테고리", example = "[\"한식\", \"일식\"]")
private List<String> preferredCategories;
@Schema(description = "가격 범위", example = "MEDIUM")
private String priceRange;
@Schema(description = "추천 개수", example = "10", defaultValue = "10")
@Schema(description = "카테고리 필터", example = "한식")
private String category;
@Schema(description = "취향 태그 목록", example = "[\"매운맛\", \"혼밥\"]")
private List<String> tags;
@Min(value = 1, message = "최소 1개 이상의 결과가 필요합니다")
@Max(value = 50, message = "최대 50개까지 조회 가능합니다")
@Schema(description = "결과 개수", example = "10")
private Integer limit = 10;
/**
* 유효성 검증
*/
public void validate() {
if (latitude == null || longitude == null) {
throw new IllegalArgumentException("위도와 경도는 필수입니다");
}
if (latitude < -90 || latitude > 90) {
throw new IllegalArgumentException("위도는 -90과 90 사이여야 합니다");
}
if (longitude < -180 || longitude > 180) {
throw new IllegalArgumentException("경도는 -180과 180 사이여야 합니다");
}
if (radius != null && radius <= 0) {
throw new IllegalArgumentException("검색 반경은 0보다 커야 합니다");
}
}
}
}
@@ -0,0 +1,26 @@
package com.ktds.hi.recommend.infra.dto.request;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotEmpty;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import java.util.List;
/**
* 취향 업데이트 요청 DTO
*/
@Getter
@Setter
@NoArgsConstructor
@Schema(description = "취향 프로필 업데이트 요청")
public class TasteUpdateRequest {
@NotEmpty(message = "선호 카테고리는 최소 1개 이상 선택해야 합니다")
@Schema(description = "선호 카테고리 목록", example = "[\"한식\", \"일식\"]")
private List<String> preferredCategories;
@Schema(description = "선호 태그 목록", example = "[\"매운맛\", \"혼밥\", \"가성비\"]")
private List<String> preferredTags;
}
@@ -0,0 +1,34 @@
package com.ktds.hi.recommend.infra.dto.response;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Builder;
import lombok.Getter;
/**
* 취향 태그 응답 DTO
*/
@Getter
@Builder
@Schema(description = "취향 태그 응답")
public class PreferenceTagResponse {
@Schema(description = "태그명", example = "매운맛")
private String tagName;
@Schema(description = "태그 아이콘", example = "🌶️")
private String icon;
@Schema(description = "태그 설명", example = "매운 음식을 선호")
private String description;
/**
* 정적 생성 메서드
*/
public static PreferenceTagResponse of(String tagName, String icon, String description) {
return PreferenceTagResponse.builder()
.tagName(tagName)
.icon(icon)
.description(description)
.build();
}
}
@@ -1,50 +1,55 @@
package com.ktds.hi.recommend.infra.dto.response;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import java.util.List;
/**
* 매장 추천 응답 DTO
* 추천 매장 응답 DTO
*/
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Schema(description = "매장 추천 응답")
@Schema(description = "추천 매장 응답")
public class RecommendStoreResponse {
@Schema(description = "매장 ID")
@Schema(description = "매장 ID", example = "1")
private Long storeId;
@Schema(description = "매장명")
@Schema(description = "매장명", example = "맛있는 김치찌개")
private String storeName;
@Schema(description = "주소")
private String address;
@Schema(description = "카테고리")
@Schema(description = "카테고리", example = "한식")
private String category;
@Schema(description = "태그 목록")
private List<String> tags;
@Schema(description = "평점")
private Double rating;
@Schema(description = "리뷰 수")
@Schema(description = "주소", example = "서울시 강남구 테헤란로 123")
private String address;
@Schema(description = "위도", example = "37.5665")
private Double latitude;
@Schema(description = "경도", example = "126.9780")
private Double longitude;
@Schema(description = "평균 평점", example = "4.5")
private Double averageRating;
@Schema(description = "리뷰 수", example = "127")
private Integer reviewCount;
@Schema(description = "거리(미터)")
private Double distance;
@Schema(description = "추천 점수")
@Schema(description = "추천 점수", example = "0.85")
private Double recommendScore;
@Schema(description = "추천 이유")
@Schema(description = "추천 이유", example = "좋아하는 '매운맛' 태그에 맞는 매장입니다")
private String recommendReason;
@Schema(description = "거리 (미터)", example = "1200")
private Double distance;
@Schema(description = "매장 이미지 URL 목록")
private List<String> imageUrls;
@Schema(description = "태그 목록", example = "[\"매운맛\", \"혼밥\"]")
private List<String> tags;
}
@@ -0,0 +1,58 @@
package com.ktds.hi.recommend.infra.dto.response;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Builder;
import lombok.Getter;
import java.util.List;
/**
* 매장 상세 정보 응답 DTO
*/
@Getter
@Builder
@Schema(description = "매장 상세 정보 응답")
public class StoreDetailResponse {
@Schema(description = "매장 ID", example = "1")
private Long storeId;
@Schema(description = "매장명", example = "맛있는 김치찌개")
private String storeName;
@Schema(description = "카테고리", example = "한식")
private String category;
@Schema(description = "주소", example = "서울시 강남구 테헤란로 123")
private String address;
@Schema(description = "전화번호", example = "02-1234-5678")
private String phoneNumber;
@Schema(description = "위도", example = "37.5665")
private Double latitude;
@Schema(description = "경도", example = "126.9780")
private Double longitude;
@Schema(description = "평균 평점", example = "4.5")
private Double averageRating;
@Schema(description = "리뷰 수", example = "127")
private Integer reviewCount;
@Schema(description = "매장 이미지 URL 목록")
private List<String> imageUrls;
@Schema(description = "운영시간", example = "10:00-22:00")
private String operatingHours;
@Schema(description = "AI 요약", example = "친절한 서비스와 맛있는 김치찌개로 유명한 곳입니다")
private String aiSummary;
@Schema(description = "긍정 키워드", example = "[\"맛있다\", \"친절하다\", \"깔끔하다\"]")
private List<String> topPositiveKeywords;
@Schema(description = "부정 키워드", example = "[\"시끄럽다\", \"대기시간\"]")
private List<String> topNegativeKeywords;
}
@@ -1,46 +1,42 @@
package com.ktds.hi.recommend.infra.dto.response;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;
/**
* 취향 분석 응답 DTO
* 취향 분석 결과 응답 DTO
*/
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Schema(description = "취향 분석 응답")
@Schema(description = "취향 분석 결과 응답")
public class TasteAnalysisResponse {
@Schema(description = "회원 ID")
@Schema(description = "회원 ID", example = "1")
private Long memberId;
@Schema(description = "선호 카테고리")
@Schema(description = "선호 카테고리 목록", example = "[\"한식\", \"일식\"]")
private List<String> preferredCategories;
@Schema(description = "최고 선호 카테고리")
@Schema(description = "최고 선호 카테고리", example = "한식")
private String topCategory;
@Schema(description = "카테고리별 점수")
private Map<String, Double> categoryScores;
@Schema(description = "선호 태그")
@Schema(description = "선호 태그 목록", example = "[\"매운맛\", \"혼밥\"]")
private List<String> preferredTags;
@Schema(description = "가격 선호도")
private Double pricePreference;
@Schema(description = "거리 선호도")
private Double distancePreference;
@Schema(description = "분석 일시")
private LocalDateTime analysisDate;
@Schema(description = "신뢰도", example = "0.75")
private Double confidence;
@Schema(description = "추천사항", example = "[\"한식 카테고리의 새로운 매장을 시도해보세요\"]")
private List<String> recommendations;
}