diff --git a/.gradle/buildOutputCleanup/cache.properties b/.gradle/buildOutputCleanup/cache.properties index 8bfd2aa..c9f936c 100644 --- a/.gradle/buildOutputCleanup/cache.properties +++ b/.gradle/buildOutputCleanup/cache.properties @@ -1,2 +1,2 @@ -#Fri Feb 07 20:37:40 KST 2025 +#Sat Feb 15 09:08:54 KST 2025 gradle.version=8.10 diff --git a/.gradle/buildOutputCleanup/outputFiles.bin b/.gradle/buildOutputCleanup/outputFiles.bin index 1f7b7a3..4ea7a14 100644 Binary files a/.gradle/buildOutputCleanup/outputFiles.bin and b/.gradle/buildOutputCleanup/outputFiles.bin differ diff --git a/build.gradle b/build.gradle index 5c13890..dfd0dc7 100644 --- a/build.gradle +++ b/build.gradle @@ -24,36 +24,45 @@ repositories { } dependencies { - // Spring Boot Starters - implementation 'org.springframework.boot:spring-boot-starter-data-jpa' + // ✅ Spring Boot 기본 스타터 패키지 implementation 'org.springframework.boot:spring-boot-starter-web' implementation 'org.springframework.boot:spring-boot-starter-thymeleaf' implementation 'org.springframework.boot:spring-boot-starter-logging' implementation 'org.springframework.boot:spring-boot-starter-validation' implementation 'org.springframework.boot:spring-boot-starter-actuator' implementation 'org.springframework.boot:spring-boot-starter-aop' - implementation 'org.springframework.boot:spring-boot-starter-webflux' + implementation 'org.springframework.boot:spring-boot-starter-webflux' // ✅ WebClient 추가 + + // ✅ 데이터베이스 관련 + implementation 'org.springframework.boot:spring-boot-starter-data-jpa' + implementation 'com.mysql:mysql-connector-j' + + // ✅ 보안 관련 + implementation 'org.springframework.boot:spring-boot-starter-security' + implementation 'io.jsonwebtoken:jjwt:0.12.3' // ✅ JWT 토큰 생성을 위한 라이브러리 + implementation 'com.fasterxml.jackson.core:jackson-databind' - // Selenium for web scraping + // ✅ Selenium 웹 스크래핑 implementation 'org.seleniumhq.selenium:selenium-java:4.26.0' - // OpenAPI documentation + // ✅ OpenAPI 문서 자동화 (Swagger UI) implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.3.0' - // MySQL Driver - implementation 'com.mysql:mysql-connector-j' - - // Lombok for reducing boilerplate code + // ✅ Lombok (코드 간소화) compileOnly 'org.projectlombok:lombok' annotationProcessor 'org.projectlombok:lombok' - implementation 'org.springframework.boot:spring-boot-starter-security' + // ✅ 로깅 및 JSON 처리 + implementation 'com.fasterxml.jackson.core:jackson-databind' - // Testing + // ✅ 쿠팡 파트너스 API 관련 + implementation 'org.apache.httpcomponents.client5:httpclient5' // HTTP 요청을 위한 라이브러리 + + // ✅ 테스트 관련 testImplementation 'org.springframework.boot:spring-boot-starter-test' testImplementation 'org.springframework.security:spring-security-test' } tasks.named('test') { useJUnitPlatform() -} +} \ No newline at end of file diff --git a/src/main/java/com/team4/giftidea/controller/CoupangPartnersController.java b/src/main/java/com/team4/giftidea/controller/CoupangPartnersController.java new file mode 100644 index 0000000..122a87b --- /dev/null +++ b/src/main/java/com/team4/giftidea/controller/CoupangPartnersController.java @@ -0,0 +1,25 @@ +package com.team4.giftidea.controller; + +import com.team4.giftidea.service.CoupangPartnersService; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/api/coupang") +public class CoupangPartnersController { + + private final CoupangPartnersService coupangPartnersService; + + public CoupangPartnersController(CoupangPartnersService coupangPartnersService) { + this.coupangPartnersService = coupangPartnersService; + } + + /** + * DB의 모든 쿠팡 상품을 조회하여 파트너스 링크로 업데이트하는 API + */ + @PostMapping("/update-all") + public ResponseEntity updateAllCoupangProductLinks() { + int updatedCount = coupangPartnersService.updateAllCoupangProductLinks(); + return ResponseEntity.ok(updatedCount + "개의 쿠팡 상품이 업데이트되었습니다."); + } +} \ No newline at end of file diff --git a/src/main/java/com/team4/giftidea/controller/GptController.java b/src/main/java/com/team4/giftidea/controller/GptController.java index 55f5a82..fa3ba64 100644 --- a/src/main/java/com/team4/giftidea/controller/GptController.java +++ b/src/main/java/com/team4/giftidea/controller/GptController.java @@ -1,11 +1,17 @@ package com.team4.giftidea.controller; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.team4.giftidea.configuration.GptConfig; +import com.team4.giftidea.dto.GptRequestDTO; +import com.team4.giftidea.dto.GptResponseDTO; +import com.team4.giftidea.entity.Product; +import com.team4.giftidea.service.ProductService; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; import lombok.extern.slf4j.Slf4j; - import org.springframework.beans.factory.annotation.Autowired; import org.springframework.web.bind.annotation.*; import org.springframework.web.client.RestTemplate; @@ -13,19 +19,11 @@ import java.io.*; import java.nio.charset.StandardCharsets; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.Collections; -import java.util.List; - -import com.fasterxml.jackson.databind.ObjectMapper; -import com.team4.giftidea.configuration.GptConfig; -import com.team4.giftidea.dto.GptRequestDTO; -import com.team4.giftidea.dto.GptResponseDTO; -import com.team4.giftidea.entity.Product; -import com.team4.giftidea.service.ProductService; +import java.util.*; +import java.util.stream.Collectors; @Slf4j +@Tag(name = "🎁 GPT 추천 API", description = "카카오톡 대화를 분석하여 GPT를 통해 추천 선물을 제공하는 API") @RestController @RequestMapping("/api/gpt") public class GptController { @@ -41,18 +39,9 @@ public GptController(RestTemplate restTemplate, GptConfig gptConfig, ProductServ this.productService = productService; } - - // GPT 모델의 입력 토큰 제한 (예: 출력 토큰 고려 후 설정, 여기서는 예시로 25000) + // GPT 모델의 입력 토큰 제한 (예: 11000) private static final int GPT_INPUT_LIMIT = 11000; - /** - * 파일의 아랫부분부터 토큰을 센 후, 총 토큰 수가 GPT_INPUT_LIMIT 이하인 내용만 - * 선택하여 로컬에 저장하고, 그 청크를 반환합니다. - * - * @param file 업로드된 카카오톡 대화 파일 (.txt) - * @param targetName 대상 이름 (예: "여자친구") - * @return 전처리된 청크 (아랫부분부터 토큰 누적하여 GPT_INPUT_LIMIT 이하) - */ @Operation( summary = "카톡 대화 분석 후 선물 추천", description = "카카오톡 대화 파일을 분석하여 GPT API를 이용해 키워드를 추출하고, 이에 맞는 추천 상품을 반환합니다." @@ -64,19 +53,16 @@ public GptController(RestTemplate restTemplate, GptConfig gptConfig, ProductServ @ApiResponse(responseCode = "500", description = "서버 내부 오류 발생") }) @PostMapping(value = "/process", consumes = "multipart/form-data", produces = "application/json") - public List processFileAndRecommend( + public Map processFileAndRecommend( @RequestParam("file") @Parameter(description = "카카오톡 대화 파일 (.txt)", required = true) MultipartFile file, @RequestParam("targetName") @Parameter(description = "분석 대상 이름 (예: '여자친구')", required = true) String targetName, @RequestParam("relation") @Parameter(description = "대상과의 관계 (couple, friend, parent 등)", required = true) String relation, @RequestParam("sex") @Parameter(description = "대상 성별 (male 또는 female)", required = true) String sex, @RequestParam("theme") @Parameter(description = "선물 주제 (birthday, valentine 등)", required = true) String theme ) { - - List processedMessages = new ArrayList<>(); - int formatType = detectFormatType(file); - - // 1. 파일의 모든 줄을 읽고, targetName이 포함된 줄만 필터링하여 리스트에 저장 + // 1. 파일의 모든 줄 중 targetName이 포함된 줄을 수집 List allTargetLines = new ArrayList<>(); + int formatType = detectFormatType(file); try (BufferedReader reader = new BufferedReader( new InputStreamReader(file.getInputStream(), StandardCharsets.UTF_8))) { String line; @@ -90,41 +76,87 @@ public List processFileAndRecommend( log.error("파일 읽기 오류: ", e); } - // 2. 파일의 아랫부분부터 토큰을 누적 (역순으로 처리) + // 2. 파일의 아랫부분부터 역순으로 토큰을 누적하여 GPT_INPUT_LIMIT 이하 내용 선택 int currentTokenCount = 0; List selectedLines = new ArrayList<>(); - // reverse 순회 for (int i = allTargetLines.size() - 1; i >= 0; i--) { String currentLine = allTargetLines.get(i); int tokenCount = countTokens(currentLine); if (currentTokenCount + tokenCount > GPT_INPUT_LIMIT) { - // 토큰 제한을 초과하면 중단 break; } - // 아랫부분부터 선택하므로, 먼저 선택된 줄이 마지막에 온다. selectedLines.add(currentLine); currentTokenCount += tokenCount; } - // 원래 순서대로 복원 (파일에서 아랫부분이 우선이므로, 리스트를 reverse) Collections.reverse(selectedLines); - - // 3. 선택된 줄들을 하나의 청크로 합침 StringBuilder finalChunk = new StringBuilder(); for (String s : selectedLines) { finalChunk.append(s).append("\n"); } + List processedMessages = new ArrayList<>(); processedMessages.add(finalChunk.toString()); - // 2. GPT API 호출: 전처리된 메시지로 키워드 반환 - String categories = generatePrompt(processedMessages, relation, sex, theme); + // (옵션) 로컬 파일에 저장 + try { + File outputFile = new File(System.getProperty("user.home"), "processed_kakaochat.txt"); + try (BufferedWriter writer = new BufferedWriter(new FileWriter(outputFile, false))) { + writer.write(finalChunk.toString()); + writer.flush(); + } + log.info("전처리 완료. 결과 파일 저장 위치: " + outputFile.getAbsolutePath()); + } catch (IOException e) { + log.error("파일 저장 오류: ", e); + } + + // 3. GPT API 호출: 전처리된 메시지(청크)로부터 키워드 및 근거 추출 + String gptResponse = generatePrompt(processedMessages, relation, sex, theme); + + // 4. GPT 응답 파싱 + // 예상 응답 예시: + // "Categories: 향수, 무선이어폰, 목걸이\n- 향수: [첫번째 근거]\n- 향수: [마지막 근거]\n- 무선이어폰: [근거]\n- 목걸이: [근거]" + String[] responseParts = gptResponse.split("\n", 2); + String categoriesPart = responseParts[0].replace("Categories: ", "").trim(); + String reasonsPart = responseParts.length > 1 ? responseParts[1].trim() : ""; + + List keywords = Arrays.stream(categoriesPart.split(",")) + .map(String::trim) + .filter(s -> !s.isEmpty()) + .collect(Collectors.toList()); + + // 파싱: 각 reason 줄에서 키워드와 설명 추출하고, 같은 키워드는 마지막 설명으로 덮어씀. + Map reasonMap = new HashMap<>(); + String[] reasonLines = reasonsPart.split("\n"); + for (String line : reasonLines) { + line = line.trim(); + if (line.startsWith("- ")) { + int colonIdx = line.indexOf(":"); + if (colonIdx != -1) { + String key = line.substring(2, colonIdx).trim(); // 예: "향수" + String value = line.substring(colonIdx + 1).trim(); + reasonMap.put(key, value); // 마지막에 나온 설명이 덮어쓰기 됨 + } + } + } - // 3. 키워드 리스트 변환 및 상품 검색 - List keywords = Arrays.asList(categories.split(",")); - keywords.replaceAll(String::trim); + // 최종 reason 객체 리스트 생성: 각 키워드에 대해 reasonMap에서 설명 가져오기 + List> reasonList = new ArrayList<>(); + for (String keyword : keywords) { + if (reasonMap.containsKey(keyword)) { + Map entry = new HashMap<>(); + entry.put("keyword", keyword); + entry.put("reason", reasonMap.get(keyword)); + reasonList.add(entry); + } + } - List products = productService.searchByKeywords(keywords); + // 5. 데이터베이스에서 상품 검색 (키워드를 이용) + List productsNoReason = productService.searchByKeywords(keywords); - return products; + // 6. 최종 응답 구성 (JSON 객체) + Map result = new HashMap<>(); + result.put("product", productsNoReason); + result.put("reason", reasonList); + return result; } private int detectFormatType(MultipartFile file) { @@ -161,8 +193,7 @@ private int countTokens(String text) { } private String generatePrompt(List processedMessages, String relation, String sex, String theme) { - String combinedMessages = String.join("\n", processedMessages); // List을 하나의 String으로 합침 - + String combinedMessages = String.join("\n", processedMessages); if ("couple".equals(relation)) { if ("male".equals(sex)) { return extractKeywordsAndReasonsCoupleMan(theme, combinedMessages); @@ -186,46 +217,44 @@ private String generatePrompt(List processedMessages, String relation, S return extractKeywordsAndReasonsSeasonalWoman(theme, combinedMessages); } } - return "조건에 맞는 선물 추천 기능이 없습니다."; } private String generateText(String prompt) { - GptRequestDTO request = new GptRequestDTO(gptConfig.getModel(), prompt); + GptRequestDTO request = new GptRequestDTO(gptConfig.getModel(), prompt, 110); try { - - // HTTP 요청 전에 request 객체 로깅 ObjectMapper mapper = new ObjectMapper(); - GptResponseDTO response = restTemplate.postForObject(gptConfig.getApiUrl(), request, GptResponseDTO.class); - - // 응답 검증 - if (response != null) { - log.debug("GPT 응답 수신: {}", mapper.writeValueAsString(response)); - - // 응답에 'choices'가 있고, 그 중 첫 번째 항목이 존재하는지 확인 - if (response.getChoices() != null && !response.getChoices().isEmpty()) { - String content = response.getChoices().get(0).getMessage().getContent(); - - // 필요한 형태로 카테고리 추출 (예: "1. [무선이어폰, 스마트워치, 향수]" 형태) - if (content.contains("1.")) { - String categories = content.split("1.")[1].split("\n")[0]; // 첫 번째 카테고리 라인 추출 - - // 괄호 안의 항목들을 추출하고, 쉼표로 구분하여 키워드 리스트 만들기 - String[] categoryArray = categories.split("\\[|\\]")[1].split(","); - List keywords = new ArrayList<>(); - for (String category : categoryArray) { - keywords.add(category.trim()); - } - return String.join(", ", keywords); // 최종적으로 카테고리들을 반환 + if (response != null && response.getChoices() != null && !response.getChoices().isEmpty()) { + String content = response.getChoices().get(0).getMessage().getContent(); + log.debug("GPT 전체 응답: {}", content); + + // 응답 포맷: "1. [카테고리1,카테고리2,카테고리3]\n2.\n(이후 Reasons 내용)" + if (content.contains("1.") && content.contains("2.")) { + String[] parts = content.split("2\\.", 2); + String part1 = parts[0].trim(); + String reasonsPart = parts[1].trim(); + + if (part1.startsWith("1.")) { + part1 = part1.substring(2).trim(); + } + int startIdx = part1.indexOf("["); + int endIdx = part1.indexOf("]"); + String categories = ""; + if (startIdx != -1 && endIdx != -1 && endIdx > startIdx) { + categories = part1.substring(startIdx + 1, endIdx).trim(); } else { - log.warn("GPT 응답에서 카테고리 정보가 올바르지 않습니다."); + log.warn("카테고리 부분 추출 실패, 전체 내용: {}", part1); } + log.debug("추출된 카테고리: {}", categories); + log.debug("추출된 Reasons: {}", reasonsPart); + + return "Categories: " + categories + "\n" + reasonsPart; } else { - log.warn("GPT 응답에 'choices'가 없거나 빈 리스트입니다."); + log.warn("응답 포맷이 예상과 다릅니다: {}", content); } } else { - log.warn("GPT 응답이 null입니다."); + log.warn("GPT 응답이 null이거나 choices가 비어 있습니다."); } return "GPT 응답 오류 발생"; } catch (Exception e) { @@ -239,146 +268,138 @@ private String generateText(String prompt) { private String extractKeywordsAndReasonsCoupleMan(String theme, String message) { String prompt = String.format(""" - 다음 텍스트를 참고하여 남자 애인이 %s에 선물로 받으면 좋아할 카테고리 3개와 판단에 참고한 대화를 제공해주세요. - 카테고리: 남성 지갑, 남성 스니커즈, 백팩, 토트백, 크로스백, 벨트, 선글라스, 향수, 헬스가방, 무선이어폰, 스마트워치, 맨투맨, 마우스, 키보드, 전기면도기, 게임기 - - 텍스트: %s - - 출력 형식: - 1. [카테고리1,카테고리2,카테고리3] - 2. - - 카테고리1: [근거1] - - 카테고리2: [근거2] - - 카테고리3: [근거3] - """, theme, message); - + 다음 텍스트를 참고하여 남자 애인이 %s에 선물로 받으면 좋아할 카테고리 3개와 판단에 참고한 대화를 100토큰 내로 제공해주세요. + 카테고리: 남성 지갑, 남성 스니커즈, 백팩, 토트백, 크로스백, 벨트, 선글라스, 향수, 헬스가방, 무선이어폰, 스마트워치, 맨투맨, 마우스, 키보드, 전기면도기, 게임기 + + 텍스트: %s + + 출력 형식: + 1. [카테고리1,카테고리2,카테고리3] + 2. + - 카테고리1: [근거1] + - 카테고리2: [근거2] + - 카테고리3: [근거3] + """, theme, message); return generateText(prompt); } private String extractKeywordsAndReasonsCoupleWoman(String theme, String message) { String prompt = String.format(""" - 다음 텍스트를 참고하여 여자 애인이 %s에 선물로 받으면 좋아할 카테고리 3개와 판단에 참고한 대화를 제공해주세요. - 카테고리: 여성 지갑, 여성 스니커즈, 숄더백, 토트백, 크로스백, 향수, 목걸이, 무선이어폰, 스마트워치, 에어랩 - - 텍스트: %s - - 출력 형식: - 1. [카테고리1,카테고리2,카테고리3] - 2. - - 카테고리1: [근거1] - - 카테고리2: [근거2] - - 카테고리3: [근거3] - """, theme, message); - - return generateText(prompt); // GPT 모델 호출 + 다음 텍스트를 참고하여 여자 애인이 %s에 선물로 받으면 좋아할 카테고리 3개와 판단에 참고한 대화를 100토큰 내로 제공해주세요. + 카테고리: 여성 지갑, 여성 스니커즈, 숄더백, 토트백, 크로스백, 향수, 목걸이, 무선이어폰, 스마트워치, 에어랩 + + 텍스트: %s + + 출력 형식: + 1. [카테고리1,카테고리2,카테고리3] + 2. + - 카테고리1: [근거1] + - 카테고리2: [근거2] + - 카테고리3: [근거3] + """, theme, message); + return generateText(prompt); } private String extractKeywordsAndReasonsDad(String theme, String message) { String prompt = String.format(""" - 다음 텍스트를 참고하여 부모님이 %s에 선물로 받으면 좋아할 카테고리 3개와 판단에 참고한 대화를 제공해주세요. - 카테고리: 현금 박스, 안마기기, 아버지 신발, 시계 - - 텍스트: %s - - 출력 형식: - 1. [카테고리1,카테고리2,카테고리3] - 2. - - 카테고리1: [근거1] - - 카테고리2: [근거2] - - 카테고리3: [근거3] - """, theme, message); - + 다음 텍스트를 참고하여 부모님이 %s에 선물로 받으면 좋아할 카테고리 3개와 판단에 참고한 대화를 100토큰 내로 제공해주세요. + 카테고리: 현금 박스, 안마기기, 아버지 신발, 시계 + + 텍스트: %s + + 출력 형식: + 1. [카테고리1,카테고리2,카테고리3] + 2. + - 카테고리1: [근거1] + - 카테고리2: [근거2] + - 카테고리3: [근거3] + """, theme, message); return generateText(prompt); } private String extractKeywordsAndReasonsMom(String theme, String message) { String prompt = String.format(""" - 다음 텍스트를 참고하여 부모님이 %s에 선물로 받으면 좋아할 카테고리 3개와 판단에 참고한 대화를 제공해주세요. - 카테고리: 현금 박스, 안마기기, 어머니 신발, 건강식품, 스카프 - - 텍스트: %s - - 출력 형식: - 1. [카테고리1,카테고리2,카테고리3] - 2. - - 카테고리1: [근거1] - - 카테고리2: [근거2] - - 카테고리3: [근거3] - """, theme, message); - + 다음 텍스트를 참고하여 부모님이 %s에 선물로 받으면 좋아할 카테고리 3개와 판단에 참고한 대화를 100토큰 내로 제공해주세요. + 카테고리: 현금 박스, 안마기기, 어머니 신발, 건강식품, 스카프 + + 텍스트: %s + + 출력 형식: + 1. [카테고리1,카테고리2,카테고리3] + 2. + - 카테고리1: [근거1] + - 카테고리2: [근거2] + - 카테고리3: [근거3] + """, theme, message); return generateText(prompt); } private String extractKeywordsAndReasonsFriend(String theme, String message) { String prompt = String.format(""" - 다음 텍스트를 참고하여 친구가 %s에 선물로 받으면 좋아할 카테고리 3개와 판단에 참고한 대화를 제공해주세요. - 제시된 카테고리에 없는 추천 선물이 있다면 3개에 포함해주세요. - 카테고리: 핸드크림, 텀블러, 립밤, 머플러, 비타민, 입욕제, 블루투스 스피커 - - 텍스트: %s - - 출력 형식: - 1. [카테고리1,카테고리2,카테고리3] - 2. - - 카테고리1: [근거1] - - 카테고리2: [근거2] - - 카테고리3: [근거3] - """, theme, message); - + 다음 텍스트를 참고하여 친구가 %s에 선물로 받으면 좋아할 카테고리 3개와 판단에 참고한 대화를 100토큰 내로 제공해주세요. + 제시된 카테고리에 없는 추천 선물이 있다면 3개에 포함해주세요. + 카테고리: 핸드크림, 텀블러, 립밤, 머플러, 비타민, 입욕제, 블루투스 스피커 + + 텍스트: %s + + 출력 형식: + 1. [카테고리1,카테고리2,카테고리3] + 2. + - 카테고리1: [근거1] + - 카테고리2: [근거2] + - 카테고리3: [근거3] + """, theme, message); return generateText(prompt); } private String extractKeywordsAndReasonsHousewarming(String message) { String prompt = String.format(""" - 다음 텍스트를 참고하여 집들이에 선물로 받으면 좋아할 카테고리 3개와 판단에 참고한 대화를 제공해주세요. - 카테고리: 조명, 핸드워시, 식기, 디퓨저, 오설록 티세트, 휴지, 파자마세트, 무드등, 디퓨저, 수건, 전기포트, 에어프라이기 - - 텍스트: %s - - 출력 형식: - 1. [카테고리1,카테고리2,카테고리3] - 2. - - 카테고리1: [근거1] - - 카테고리2: [근거2] - - 카테고리3: [근거3] - """, message); - + 다음 텍스트를 참고하여 집들이에 선물로 받으면 좋아할 카테고리 3개와 판단에 참고한 대화를 100토큰 내로 제공해주세요. + 카테고리: 조명, 핸드워시, 식기, 디퓨저, 오설록 티세트, 휴지, 파자마세트, 무드등, 디퓨저, 수건, 전기포트, 에어프라이기 + + 텍스트: %s + + 출력 형식: + 1. [카테고리1,카테고리2,카테고리3] + 2. + - 카테고리1: [근거1] + - 카테고리2: [근거2] + - 카테고리3: [근거3] + """, message); return generateText(prompt); } private String extractKeywordsAndReasonsSeasonalMan(String theme, String message) { String prompt = String.format(""" - 다음 텍스트를 참고하여 %s에 선물로 받으면 좋아할 카테고리 3개와 판단에 참고한 대화를 제공해주세요. - 카테고리: 초콜릿, 수제 초콜릿 키트, 파자마세트, 남자 화장품 - - 텍스트: %s - - 출력 형식: - 1. [카테고리1,카테고리2,카테고리3] - 2. - - 카테고리1: [근거1] - - 카테고리2: [근거2] - - 카테고리3: [근거3] - """, theme, message); - + 다음 텍스트를 참고하여 %s에 선물로 받으면 좋아할 카테고리 3개와 판단에 참고한 대화를 100토큰 내로 제공해주세요. + 카테고리: 초콜릿, 수제 초콜릿 키트, 파자마세트, 남자 화장품 + + 텍스트: %s + + 출력 형식: + 1. [카테고리1,카테고리2,카테고리3] + 2. + - 카테고리1: [근거1] + - 카테고리2: [근거2] + - 카테고리3: [근거3] + """, theme, message); return generateText(prompt); } private String extractKeywordsAndReasonsSeasonalWoman(String theme, String message) { String prompt = String.format(""" - 다음 텍스트를 참고하여 %s에 선물로 받으면 좋아할 카테고리 3개와 판단에 참고한 대화를 제공해주세요. - 카테고리: 초콜릿, 수제 초콜릿 키트, 립밤, 파자마세트, 립스틱 - - 텍스트: %s - - 출력 형식: - 1. [카테고리1,카테고리2,카테고리3] - 2. - - 카테고리1: [근거1] - - 카테고리2: [근거2] - - 카테고리3: [근거3] - """, theme, message); - + 다음 텍스트를 참고하여 %s에 선물로 받으면 좋아할 카테고리 3개와 판단에 참고한 대화를 100토큰 내로 제공해주세요. + 카테고리: 초콜릿, 수제 초콜릿 키트, 립밤, 파자마세트, 립스틱 + + 텍스트: %s + + 출력 형식: + 1. [카테고리1,카테고리2,카테고리3] + 2. + - 카테고리1: [근거1] + - 카테고리2: [근거2] + - 카테고리3: [근거3] + """, theme, message); return generateText(prompt); } -} +} \ No newline at end of file diff --git a/src/main/java/com/team4/giftidea/controller/ProductController.java b/src/main/java/com/team4/giftidea/controller/ProductController.java index 89c7477..81d0d5f 100644 --- a/src/main/java/com/team4/giftidea/controller/ProductController.java +++ b/src/main/java/com/team4/giftidea/controller/ProductController.java @@ -3,30 +3,26 @@ import com.team4.giftidea.entity.Product; import com.team4.giftidea.service.CoupangApiService; import com.team4.giftidea.service.KreamApiService; -import com.team4.giftidea.service.NaverApiService; import com.team4.giftidea.service.ProductService; import lombok.extern.slf4j.Slf4j; + import org.springframework.scheduling.annotation.Scheduled; import org.springframework.web.bind.annotation.*; import java.util.List; -@Slf4j // 로깅 추가 +@Slf4j @RestController -@RequestMapping("/api/products") +@RequestMapping("/api/ products") public class ProductController { - - private final NaverApiService naverApiService; private final CoupangApiService coupangApiService; private final ProductService productService; private final KreamApiService kreamApiService; public ProductController( - NaverApiService naverApiService, CoupangApiService coupangApiService, KreamApiService kreamApiService, ProductService productService) { - this.naverApiService = naverApiService; this.coupangApiService = coupangApiService; this.kreamApiService = kreamApiService; this.productService = productService; @@ -39,17 +35,12 @@ public ProductController( public void crawlAndStoreData() { log.info("🔍 크롤링 시작..."); - // 네이버 키워드 목록 - List naverKeywords = List.of( - "현금 박스", "아버지 신발", "어머니 신발", "건강식품", "헬스가방", "핸드크림", "디퓨저", - "오설록 티세트", "휴지", "초콜릿", "수제 초콜릿 키트", "파자마세트", "남자 화장품", "에어랩", - "무드등", "수건", "전기포트", "에어프라이기", "비타민", "입욕제", "블루투스 스피커", "와인" - ); - // 쿠팡 키워드 목록 List coupangKeywords = List.of( "안마기기", "무선이어폰", "스마트워치", "등산용품", "스마트폰", "맨투맨", "마우스", - "키보드", "게임기", "전기면도기" + "키보드", "게임기", "전기면도기", "현금 박스", "아버지 신발", "어머니 신발", "건강식품", "헬스가방", "핸드크림", "디퓨저", + "오설록 티세트", "휴지", "초콜릿", "수제 초콜릿 키트", "파자마세트", "남자 화장품", "에어랩", + "무드등", "수건", "전기포트", "에어프라이기", "비타민", "입욕제", "블루투스 스피커", "와인" ); // Kream 키워드 목록 @@ -60,18 +51,6 @@ public void crawlAndStoreData() { ); log.info("📢 네이버 크롤링 시작..."); - naverKeywords.forEach(keyword -> { - log.debug("🔎 네이버 검색 키워드: {}", keyword); - List naverProducts = naverApiService.searchItems(List.of(keyword)); - log.info("✅ 네이버 크롤링 완료 (키워드: {}, 검색 결과: {} 개)", keyword, naverProducts.size()); - - if (!naverProducts.isEmpty()) { - productService.saveItems(naverProducts, keyword); - log.info("✅ 네이버 상품 저장 완료 (키워드: {}, 저장된 개수: {})", keyword, naverProducts.size()); - } else { - log.warn("⚠️ 네이버 크롤링 실패 또는 검색 결과 없음 (키워드: {})", keyword); - } - }); log.info("📢 쿠팡 크롤링 시작..."); coupangKeywords.forEach(keyword -> { @@ -103,4 +82,11 @@ public void crawlAndStoreData() { log.info("🎯 크롤링 및 저장 작업 완료!"); } + + @Scheduled(cron = "0 5 1 * * *") + public void autoCrawlAndStoreData() { + log.info("🕐 자동 크롤링 시작 (새벽 1시)..."); + crawlAndStoreData(); + log.info("✅ 자동 크롤링 완료!"); + } } \ No newline at end of file diff --git a/src/main/java/com/team4/giftidea/dto/GptRequestDTO.java b/src/main/java/com/team4/giftidea/dto/GptRequestDTO.java index 2be77b7..20cf324 100644 --- a/src/main/java/com/team4/giftidea/dto/GptRequestDTO.java +++ b/src/main/java/com/team4/giftidea/dto/GptRequestDTO.java @@ -21,15 +21,22 @@ public class GptRequestDTO { */ private List messages; + /** + * 최대 토큰 수 제한 (응답 길이 제한) + */ + private int max_tokens; // 🔥 추가된 필드 + /** * 생성자 - 주어진 모델과 프롬프트를 기반으로 GPT 요청을 생성합니다. * * @param model 사용할 GPT 모델 * @param prompt 사용자 입력 프롬프트 + * @param max_tokens 최대 토큰 제한 */ - public GptRequestDTO(String model, String prompt) { + public GptRequestDTO(String model, String prompt, int max_tokens) { this.model = model; this.messages = new ArrayList<>(); this.messages.add(new MessageDTO("user", prompt)); + this.max_tokens = max_tokens; } } \ No newline at end of file diff --git a/src/main/java/com/team4/giftidea/entity/Product.java b/src/main/java/com/team4/giftidea/entity/Product.java index b6aa81d..656c83e 100644 --- a/src/main/java/com/team4/giftidea/entity/Product.java +++ b/src/main/java/com/team4/giftidea/entity/Product.java @@ -72,4 +72,5 @@ public class Product { */ @Column(nullable = false) private String keyword; + } \ No newline at end of file diff --git a/src/main/java/com/team4/giftidea/repository/ProductRepository.java b/src/main/java/com/team4/giftidea/repository/ProductRepository.java index 51f2fc5..d6c0025 100644 --- a/src/main/java/com/team4/giftidea/repository/ProductRepository.java +++ b/src/main/java/com/team4/giftidea/repository/ProductRepository.java @@ -27,4 +27,6 @@ public interface ProductRepository extends JpaRepository { * @return 상품 엔티티 (없으면 Optional.empty()) */ Optional findByProductId(String productId); + + List findByMallName(String mallName); } \ No newline at end of file diff --git a/src/main/java/com/team4/giftidea/service/CoupangPartnersService.java b/src/main/java/com/team4/giftidea/service/CoupangPartnersService.java new file mode 100644 index 0000000..bed5f08 --- /dev/null +++ b/src/main/java/com/team4/giftidea/service/CoupangPartnersService.java @@ -0,0 +1,188 @@ +package com.team4.giftidea.service; + +import com.team4.giftidea.entity.Product; +import com.team4.giftidea.repository.ProductRepository; +import lombok.extern.slf4j.Slf4j; +import org.apache.hc.client5.http.utils.Hex; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.*; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.client.RestTemplate; + +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; +import java.nio.charset.StandardCharsets; +import java.text.SimpleDateFormat; +import java.util.*; + +@Slf4j +@Service +public class CoupangPartnersService { + + private final RestTemplate restTemplate = new RestTemplate(); + private final ProductRepository productRepository; + + @Value("${coupang.api.base-url}") + private String baseUrl; + + @Value("${coupang.api.access-key}") + private String accessKey; + + @Value("${coupang.api.secret-key}") + private String secretKey; + + @Value("${coupang.api.partner-id}") + private String partnerId; + + public CoupangPartnersService(ProductRepository productRepository) { + this.productRepository = productRepository; + } + + /** + * DB에서 모든 쿠팡 상품을 조회하고, 파트너스 링크로 업데이트 (배치+대기 적용) + */ + @Transactional + public int updateAllCoupangProductLinks() { + log.info("🔍 [START] 쿠팡 상품의 파트너스 링크 업데이트 시작"); + + List coupangProducts = productRepository.findByMallName("Coupang"); + log.info("📦 총 {}개의 쿠팡 상품을 찾음", coupangProducts.size()); + + // 한 번에 처리할 상품 수 (필요에 따라 조정) + final int BATCH_SIZE = 50; + // 각 배치 처리 후 대기 시간 (밀리초) (필요에 따라 조정) + final long SLEEP_MS = 60000L; + + int updatedCount = 0; + + // 배치(Chunk) 단위로 상품을 나눠 처리 + for (int i = 0; i < coupangProducts.size(); i += BATCH_SIZE) { + List batch = coupangProducts.subList(i, Math.min(i + BATCH_SIZE, coupangProducts.size())); + log.info("🔸 Batch 처리: index {} ~ {} (총 {}개)", i, i + batch.size() - 1, batch.size()); + + for (Product product : batch) { + String originalUrl = product.getLink(); + log.info("🔗 상품 ID {}의 기존 URL: {}", product.getProductId(), originalUrl); + + String partnerLink = generatePartnerLink(originalUrl); + if (partnerLink != null) { + log.info("✅ 상품 ID {}의 변환된 파트너스 링크: {}", product.getProductId(), partnerLink); + product.setLink(partnerLink); + productRepository.save(product); + updatedCount++; + } else { + log.warn("⚠️ 파트너스 링크 생성 실패 (상품 ID: {})", product.getProductId()); + } + } + + // 한 배치를 끝냈으므로 일정 시간 대기 (과도 호출 방지) + if (i + BATCH_SIZE < coupangProducts.size()) { + log.info("🔸 Batch 처리 완료: {}개 상품 업데이트, 다음 배치 전 {}ms 대기", batch.size(), SLEEP_MS); + try { + Thread.sleep(SLEEP_MS); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + log.warn("스레드 대기 중 인터럽트 발생: {}", e.getMessage()); + } + } + } + + log.info("🎯 [END] 총 {}개의 쿠팡 상품이 업데이트됨", updatedCount); + return updatedCount; + } + + /** + * 기존 쿠팡 상품 URL을 파트너스 트래킹 URL로 변환하기 위한 API 호출 + */ + private String generatePartnerLink(String originalUrl) { + try { + String endpoint = "/v2/providers/affiliate_open_api/apis/openapi/v1/deeplink"; + String apiUrl = baseUrl + endpoint; + log.info("📡 쿠팡 파트너스 API 호출: {}", apiUrl); + + // HMAC 기반 Authorization 헤더 생성 + String authorization = generateAuthorizationHeader("POST", endpoint); + log.info("🔑 생성된 Authorization 헤더: {}", authorization); + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + headers.set("Authorization", authorization); + String requestId = UUID.randomUUID().toString(); + headers.set("X-Request-Id", requestId); + log.info("🆔 X-Request-Id: {}", requestId); + + Map requestBody = new HashMap<>(); + requestBody.put("coupangUrls", Collections.singletonList(originalUrl)); + requestBody.put("subId", partnerId); + log.info("🔍 요청 바디: {}", requestBody); + log.debug("🔍 요청 헤더: {}", headers); + + long startTime = System.currentTimeMillis(); + HttpEntity> entity = new HttpEntity<>(requestBody, headers); + ResponseEntity response = restTemplate.exchange(apiUrl, HttpMethod.POST, entity, Map.class); + long duration = System.currentTimeMillis() - startTime; + log.info("⏱️ API 호출 소요 시간: {}ms", duration); + + log.info("🔍 API 응답 상태 코드: {}", response.getStatusCode()); + log.debug("🔍 API 응답 헤더: {}", response.getHeaders()); + log.info("📦 API 응답 바디: {}", response.getBody()); + + if (response.getStatusCode() == HttpStatus.OK && response.getBody() != null) { + String rCode = (String) response.getBody().get("rCode"); + if ("0".equals(rCode)) { + List> data = (List>) response.getBody().get("data"); + if (data != null && !data.isEmpty()) { + return data.get(0).get("shortenUrl").toString(); + } else { + log.warn("⚠️ 응답에 data 없음, API 응답: {}", response.getBody()); + } + } else { + log.warn("⚠️ API 호출 실패, rCode: {}, rMessage: {}", rCode, response.getBody().get("rMessage")); + } + } else { + log.warn("⚠️ API 호출 실패, 상태 코드: {}", response.getStatusCode()); + } + } catch (Exception e) { + log.error("❌ 쿠팡 파트너스 링크 생성 중 오류 발생: {}", e.getMessage(), e); + } + return null; + } + + /** + * HMAC 서명 기반의 Authorization 헤더 생성 + * + * 메시지 형식: signedDate + method + path + query + * signedDate 포맷: "yyMMdd'T'HHmmss'Z'" (GMT 기준) + * + * 최종 형식: + * "CEA algorithm=HmacSHA256, access-key=ACCESS_KEY, signed-date=SIGNED_DATE, signature=SIGNATURE" + */ + private String generateAuthorizationHeader(String method, String uri) { + SimpleDateFormat dateFormatGmt = new SimpleDateFormat("yyMMdd'T'HHmmss'Z'"); + dateFormatGmt.setTimeZone(TimeZone.getTimeZone("GMT")); + String signedDate = dateFormatGmt.format(new Date()); + + String[] parts = uri.split("\\?", 2); + String path = parts[0]; + String query = (parts.length == 2) ? parts[1] : ""; + + String message = signedDate + method + path + query; + log.debug("🔐 서명할 메시지: {}", message); + + String signature; + try { + SecretKeySpec signingKey = new SecretKeySpec(secretKey.getBytes(StandardCharsets.UTF_8), "HmacSHA256"); + Mac mac = Mac.getInstance("HmacSHA256"); + mac.init(signingKey); + byte[] rawHmac = mac.doFinal(message.getBytes(StandardCharsets.UTF_8)); + signature = Hex.encodeHexString(rawHmac); + log.debug("🔐 생성된 서명: {}", signature); + } catch (Exception e) { + throw new RuntimeException("HMAC 서명 생성 오류: " + e.getMessage(), e); + } + + return String.format("CEA algorithm=%s, access-key=%s, signed-date=%s, signature=%s", + "HmacSHA256", accessKey, signedDate, signature); + } +} \ No newline at end of file diff --git a/src/main/java/com/team4/giftidea/service/NaverApiService.java b/src/main/java/com/team4/giftidea/service/NaverApiService.java index 7e2bad0..ebe117a 100644 --- a/src/main/java/com/team4/giftidea/service/NaverApiService.java +++ b/src/main/java/com/team4/giftidea/service/NaverApiService.java @@ -13,7 +13,7 @@ import java.util.List; /** - * 네이버 쇼핑 API를 호출하여 상품 정보를 가져오는 서비스 클래스 + * 네이버 쇼핑 API를 호출하여 상품 정보를 가져오는 서비스 클래스(더 이상 사용하지 않음) */ @Service @Slf4j diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml index 6527078..5a24c01 100644 --- a/src/main/resources/application-prod.yml +++ b/src/main/resources/application-prod.yml @@ -36,3 +36,10 @@ openai: api: key: ${OPENAI_API_KEY} url: "https://api.openai.com/v1/chat/completions" + +coupang: + api: + base-url: https://api-gateway.coupang.com + access-key: "mock" + secret-key: "mock" + partner-id: "mock" \ No newline at end of file