Skip to content

Commit 16ea2c8

Browse files
authored
feat/273 gpt 활용한 기능 구현 (#281)
* refactor/273 Impl 코드 정리 * refactor/273 gpt 연동 (mergeX) 상의 내용 * refactor/273 gpt 연동 (mergeX) 상의 내용
1 parent d377d57 commit 16ea2c8

File tree

14 files changed

+935
-6
lines changed

14 files changed

+935
-6
lines changed

build.gradle.kts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,9 @@ dependencies {
6767
// Redis
6868
implementation("org.springframework.boot:spring-boot-starter-data-redis")
6969
implementation("org.springframework.session:spring-session-data-redis")
70+
71+
// OpenAI Java SDK (Spring Boot Starter)
72+
implementation("com.openai:openai-java-spring-boot-starter:4.3.0")
7073
//Elasticsearch
7174
//implementation ("org.springframework.boot:spring-boot-starter-data-elasticsearch")
7275

src/main/java/com/back/domain/dashboard/admin/service/AdminDashboardServiceImpl.java

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -605,10 +605,11 @@ public AdminSettlementResponse getSettlements(AdminSettlementRequest request) {
605605
* 정산 데이터 포인트 생성
606606
*/
607607
private void addSettlementDataPoint(MonthlySettlementDto settlement,
608-
List<AdminSettlementResponse.DataPoint> grossSalesData,
609-
List<AdminSettlementResponse.DataPoint> artistPayoutData,
610-
List<AdminSettlementResponse.DataPoint> netIncomeData,
611-
List<AdminSettlementResponse.TableRow> tableData) {
608+
List<AdminSettlementResponse.DataPoint> grossSalesData,
609+
List<AdminSettlementResponse.DataPoint> artistPayoutData,
610+
List<AdminSettlementResponse.DataPoint> netIncomeData,
611+
List<AdminSettlementResponse.TableRow> tableData) {
612+
612613

613614
String bucketStart = settlement.date().toString();
614615
long grossSales = settlement.totalAmount();

src/main/java/com/back/domain/product/product/repository/ProductRepository.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
public interface ProductRepository extends JpaRepository<Product, Long>, ProductCustomRepository, JpaSpecificationExecutor<Product> {
2020
Optional<Product> findByProductUuid(UUID productUuid);
2121

22+
2223
// 재고 감소용 - Pessimistic Write Lock (동시성 제어)
2324
@Lock(LockModeType.PESSIMISTIC_WRITE)
2425
@Query("SELECT p FROM Product p WHERE p.productUuid = :productUuid")
Lines changed: 218 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,218 @@
1+
package com.back.domain.recommendation.controller;
2+
3+
import com.back.domain.product.product.dto.response.ProductListResponse;
4+
import com.back.domain.product.product.entity.Product;
5+
import com.back.domain.product.product.repository.ProductRepository;
6+
import com.back.domain.recommendation.dto.request.PreferenceRequest;
7+
import com.back.domain.recommendation.dto.response.MatchResponse;
8+
import com.back.domain.recommendation.dto.response.RecommendedItem;
9+
import com.back.domain.recommendation.service.MatcherService;
10+
import com.back.domain.recommendation.service.TagDictionary;
11+
import com.back.global.rsData.RsData;
12+
import io.swagger.v3.oas.annotations.Operation;
13+
import io.swagger.v3.oas.annotations.media.Content;
14+
import io.swagger.v3.oas.annotations.media.Schema;
15+
import io.swagger.v3.oas.annotations.responses.ApiResponse;
16+
import io.swagger.v3.oas.annotations.tags.Tag;
17+
import jakarta.validation.Valid;
18+
import lombok.RequiredArgsConstructor;
19+
import lombok.extern.slf4j.Slf4j;
20+
import org.springframework.data.domain.PageRequest;
21+
import org.springframework.data.domain.Pageable;
22+
import org.springframework.data.domain.Sort;
23+
import org.springframework.http.ResponseEntity;
24+
import org.springframework.web.bind.annotation.PostMapping;
25+
import org.springframework.web.bind.annotation.RequestBody;
26+
import org.springframework.web.bind.annotation.RequestMapping;
27+
import org.springframework.web.bind.annotation.RestController;
28+
29+
import java.util.List;
30+
import java.util.Map;
31+
import java.util.Optional;
32+
33+
/**
34+
* 취향 기반 상품 추천 컨트롤러
35+
*/
36+
@RestController
37+
@RequestMapping("/api/recommendations")
38+
@RequiredArgsConstructor
39+
@Slf4j
40+
@Tag(name = "상품 추천", description = "취향 테스트 기반 상품 추천 API")
41+
public class RecommendationController {
42+
43+
private final ProductRepository productRepository;
44+
private final TagDictionary tagDictionary;
45+
private final MatcherService matcherService;
46+
47+
@PostMapping("/match")
48+
@Operation(
49+
summary = "취향 기반 상품 매칭",
50+
description = "사용자의 취향 테스트 결과(태그별 점수)를 기반으로 상품을 추천합니다. " +
51+
"7~9문항의 테스트 결과를 태그별 점수로 변환하여 요청하세요.",
52+
responses = {
53+
@ApiResponse(
54+
responseCode = "200",
55+
description = "추천 성공",
56+
content = @Content(
57+
mediaType = "application/json",
58+
schema = @Schema(
59+
example = """
60+
{
61+
"resultCode": "200",
62+
"msg": "5개 상품을 추천했습니다",
63+
"data": {
64+
"recommendations": [
65+
{
66+
"rank": 1,
67+
"matchScore": 2.4,
68+
"product": {
69+
"productUuid": "550e8400-...",
70+
"imageUrl": "https://...",
71+
"brandName": "문구브랜드",
72+
"name": "부드러운 4B 연필",
73+
"price": 15000,
74+
"discountRate": 10,
75+
"discountPrice": 13500,
76+
"rating": 4.5
77+
},
78+
"matchedTags": [
79+
{"name": "부드러운", "yourScore": 0.9},
80+
{"name": "실용적인", "yourScore": 0.8}
81+
],
82+
"reason": "'부드러운·실용적인·데일리' 선호와 일치"
83+
}
84+
]
85+
}
86+
}
87+
"""
88+
)
89+
)
90+
)
91+
}
92+
)
93+
public ResponseEntity<RsData<MatchResponse>> matchProducts(
94+
@Valid @RequestBody PreferenceRequest request) {
95+
96+
try {
97+
log.info("===== 추천 요청 시작 =====");
98+
log.info("선호 태그: {}", request.preferences());
99+
log.info("가격: {}-{}", request.minPrice(), request.maxPrice());
100+
101+
// 1. 상위 선호 태그 추출 (점수 0.3 이상)
102+
List<String> topTagNames = request.preferences().entrySet().stream()
103+
.filter(entry -> entry.getValue() >= 0.3) // 0.5 → 0.3으로 낮춤
104+
.sorted(Map.Entry.<String, Double>comparingByValue().reversed())
105+
.map(Map.Entry::getKey)
106+
.toList();
107+
108+
log.info("선호도 0.3 이상 태그: {}", topTagNames);
109+
110+
if (topTagNames.isEmpty()) {
111+
log.warn("선호도 0.3 이상인 태그가 없음");
112+
return ResponseEntity.ok(RsData.of("200", "조건에 맞는 상품이 없습니다",
113+
new MatchResponse(List.of())));
114+
}
115+
116+
// 2. 태그명 → 태그ID 변환
117+
List<Long> tagIds = tagDictionary.toIds(topTagNames);
118+
119+
log.info("변환된 태그 ID: {}", tagIds);
120+
121+
if (tagIds.isEmpty()) {
122+
log.warn("유효한 태그 ID를 찾을 수 없음. 입력된 태그명: {}", topTagNames);
123+
return ResponseEntity.ok(RsData.of("200", "조건에 맞는 상품이 없습니다 (태그를 찾을 수 없음)",
124+
new MatchResponse(List.of())));
125+
}
126+
127+
// 3. 후보 상품 조회 (충분한 후보군 확보 후 상위 3개 선택)
128+
int page = 0; // 첫 페이지만
129+
int size = 50; // 50개 후보 조회 → 점수 계산 → 상위 3개 선택
130+
int minPrice = Optional.ofNullable(request.minPrice()).orElse(0);
131+
int maxPrice = Optional.ofNullable(request.maxPrice()).orElse(9_999_999);
132+
133+
log.info("후보군 조회: {}개 (이 중 최적 3개 선택)", size);
134+
log.info("가격 필터: {}원 ~ {}원", minPrice, maxPrice);
135+
136+
Pageable pageable = PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "createDate"));
137+
138+
// 기존 findProducts 메서드 활용 (태그 필터링 포함)
139+
ProductListResponse response = productRepository.findProducts(
140+
null, // categoryId: 전체 카테고리
141+
tagIds, // 선호 태그로 필터링
142+
minPrice, // 최소 가격
143+
maxPrice, // 최대 가격
144+
null, // deliveryType: 전체
145+
"newest", // 신상품순
146+
pageable
147+
);
148+
149+
log.info("findProducts 결과: {}개 상품", response.products().size());
150+
151+
// ProductInfo → Product 변환이 필요하므로, UUID로 다시 조회 (태그 포함)
152+
List<Product> filtered = response.products().stream()
153+
.map(productInfo -> {
154+
Optional<Product> product = productRepository.findByProductUuidWithTags(productInfo.productUuid());
155+
if (product.isEmpty()) {
156+
log.warn("상품 UUID로 조회 실패: {}", productInfo.productUuid());
157+
}
158+
return product;
159+
})
160+
.filter(Optional::isPresent)
161+
.map(Optional::get)
162+
.toList();
163+
164+
log.info("UUID 재조회 결과: {}개 상품", filtered.size());
165+
166+
if (filtered.isEmpty()) {
167+
log.warn("조건에 맞는 상품이 없음");
168+
return ResponseEntity.ok(RsData.of("200", "조건에 맞는 상품이 없습니다",
169+
new MatchResponse(List.of())));
170+
}
171+
172+
// 4. 매칭 스코어 계산 및 정렬
173+
List<RecommendedItem> ranked;
174+
try {
175+
log.info("매칭 스코어 계산 시작...");
176+
ranked = matcherService.scoreAndRank(
177+
filtered,
178+
request.preferences(),
179+
request.specPrefs()
180+
);
181+
log.info("✅ 매칭 스코어 계산 완료: {}개", ranked.size());
182+
} catch (Exception e) {
183+
log.error("❌ 매칭 스코어 계산 중 오류 발생", e);
184+
return ResponseEntity.ok(RsData.of("500",
185+
"추천 시스템 오류: " + e.getMessage(),
186+
new MatchResponse(List.of())));
187+
}
188+
189+
// 5. 상위 3개만 추출 (프론트 요구사항)
190+
final int RECOMMENDATION_LIMIT = 3;
191+
List<RecommendedItem> topN = ranked.stream()
192+
.limit(RECOMMENDATION_LIMIT)
193+
.toList();
194+
195+
log.info("===== 최종 추천: {}개 상품 (고정) =====", topN.size());
196+
197+
MatchResponse matchResponse = new MatchResponse(topN);
198+
RsData<MatchResponse> result = RsData.of("200",
199+
topN.size() + "개 상품을 추천했습니다",
200+
matchResponse);
201+
202+
log.info("🎉 응답 반환 준비 완료");
203+
log.info("Response: resultCode={}, msg={}, data.size={}",
204+
result.resultCode(), result.msg(), topN.size());
205+
206+
return ResponseEntity.ok(result);
207+
208+
} catch (Exception e) {
209+
log.error("❌❌❌ RecommendationController에서 예상치 못한 오류 발생 ❌❌❌", e);
210+
log.error("오류 타입: {}", e.getClass().getName());
211+
log.error("오류 메시지: {}", e.getMessage());
212+
213+
return ResponseEntity.ok(RsData.of("500",
214+
"추천 시스템에 문제가 발생했습니다: " + e.getMessage(),
215+
new MatchResponse(List.of())));
216+
}
217+
}
218+
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
package com.back.domain.recommendation.dto.request;
2+
3+
import io.swagger.v3.oas.annotations.media.Schema;
4+
5+
import java.util.Map;
6+
7+
@Schema(description = "사용자 취향 기반 상품 추천 요청")
8+
public record PreferenceRequest(
9+
10+
@Schema(description = "태그별 선호도 점수 (0.0 ~ 1.0)",
11+
example = "{\"부드러운\": 0.9, \"실용적인\": 0.8, \"데일리\": 0.7}")
12+
Map<String, Double> preferences,
13+
14+
@Schema(description = "최소 가격", example = "0")
15+
Integer minPrice,
16+
17+
@Schema(description = "최대 가격", example = "20000")
18+
Integer maxPrice,
19+
20+
@Schema(description = "추천 개수", example = "5")
21+
Integer limit,
22+
23+
@Schema(description = "후보 검색 페이지", example = "0")
24+
Integer page,
25+
26+
@Schema(description = "후보 검색 사이즈", example = "50")
27+
Integer size,
28+
29+
@Schema(description = "스펙 선호도 (선택)",
30+
example = "{\"연필경도:4B\": 0.6, \"종이_gsm:100\": 0.4}")
31+
Map<String, Double> specPrefs
32+
) {
33+
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
package com.back.domain.recommendation.dto.response;
2+
3+
import io.swagger.v3.oas.annotations.media.Schema;
4+
5+
import java.util.List;
6+
7+
@Schema(description = "취향 매칭 추천 응답")
8+
public record MatchResponse(
9+
@Schema(description = "추천 상품 목록")
10+
List<RecommendedItem> recommendations
11+
) {
12+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
package com.back.domain.recommendation.dto.response;
2+
3+
import io.swagger.v3.oas.annotations.media.Schema;
4+
5+
@Schema(description = "매칭된 태그 정보")
6+
public record MatchedTag(
7+
@Schema(description = "태그명", example = "부드러운")
8+
String name,
9+
10+
@Schema(description = "사용자 선호도 점수", example = "0.9")
11+
double yourScore
12+
) {
13+
}
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
package com.back.domain.recommendation.dto.response;
2+
3+
import com.back.domain.product.product.dto.response.ProductListResponse;
4+
import io.swagger.v3.oas.annotations.media.Schema;
5+
6+
import java.util.List;
7+
8+
@Schema(description = "추천 상품 아이템")
9+
public record RecommendedItem(
10+
@Schema(description = "추천 순위", example = "1")
11+
int rank,
12+
13+
@Schema(description = "매칭 점수", example = "2.4")
14+
double matchScore,
15+
16+
@Schema(description = "상품 정보")
17+
ProductListResponse.ProductInfo product,
18+
19+
@Schema(description = "매칭된 태그 목록")
20+
List<MatchedTag> matchedTags,
21+
22+
@Schema(description = "추천 이유", example = "'부드러운·실용적인·데일리' 선호와 일치")
23+
String reason
24+
) {
25+
}

0 commit comments

Comments
 (0)