From 7d10e5441f76974fb1fe32eed9aed6f50aa67dcb Mon Sep 17 00:00:00 2001 From: dongan Date: Tue, 3 Mar 2026 00:44:20 +0900 Subject: [PATCH 1/5] =?UTF-8?q?feat:=EC=97=90=EC=9D=B4=EC=A0=84=ED=8A=B8?= =?UTF-8?q?=20=EC=B1=97=EB=B4=87=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/ChatController.java | 40 +++ .../Capstone_project/domain/ChatMessage.java | 42 +++ .../Capstone_project/dto/ChatRequestDto.java | 36 ++ .../Capstone_project/dto/ChatResponseDto.java | 27 ++ .../repository/ChatMessageRepository.java | 17 + .../repository/FittingRepository.java | 41 +++ .../Capstone_project/service/ChatService.java | 307 ++++++++++++++++++ .../service/StyleRecommendationService.java | 170 +++++++++- .../service/WebSearchService.java | 86 +++++ 9 files changed, 751 insertions(+), 15 deletions(-) create mode 100644 src/main/java/com/example/Capstone_project/controller/ChatController.java create mode 100644 src/main/java/com/example/Capstone_project/domain/ChatMessage.java create mode 100644 src/main/java/com/example/Capstone_project/dto/ChatRequestDto.java create mode 100644 src/main/java/com/example/Capstone_project/dto/ChatResponseDto.java create mode 100644 src/main/java/com/example/Capstone_project/repository/ChatMessageRepository.java create mode 100644 src/main/java/com/example/Capstone_project/service/ChatService.java create mode 100644 src/main/java/com/example/Capstone_project/service/WebSearchService.java diff --git a/src/main/java/com/example/Capstone_project/controller/ChatController.java b/src/main/java/com/example/Capstone_project/controller/ChatController.java new file mode 100644 index 0000000..7889545 --- /dev/null +++ b/src/main/java/com/example/Capstone_project/controller/ChatController.java @@ -0,0 +1,40 @@ +package com.example.Capstone_project.controller; + +import com.example.Capstone_project.common.dto.ApiResponse; +import com.example.Capstone_project.config.CustomUserDetails; +import com.example.Capstone_project.dto.ChatRequestDto; +import com.example.Capstone_project.dto.ChatResponseDto; +import com.example.Capstone_project.service.ChatService; +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.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@Tag(name = "Chat", description = "Gemini AI 챗봇. 대화 중 스타일 추천 요청 시 Gemini function calling(style_recommend 등)으로 추천 후 답변 생성.") +@RestController +@RequestMapping("/api/v1/chat") +@RequiredArgsConstructor +public class ChatController { + + private final ChatService chatService; + + @Operation( + summary = "챗봇 메시지 전송", + description = "사용자 메시지를 보내면 AI가 응답합니다. '결혼식 옷 추천해줘', '캐주얼 스타일 추천' 등 요청 시 스타일 추천 툴이 호출되어 추천 결과를 반환합니다." + ) + @PostMapping + public ResponseEntity> chat( + @AuthenticationPrincipal CustomUserDetails userDetails, + @Valid @RequestBody ChatRequestDto request + ) { + Long userId = userDetails.getUser().getId(); + ChatResponseDto response = chatService.chat(userId, request); + return ResponseEntity.ok(ApiResponse.success("챗봇 응답", response)); + } +} diff --git a/src/main/java/com/example/Capstone_project/domain/ChatMessage.java b/src/main/java/com/example/Capstone_project/domain/ChatMessage.java new file mode 100644 index 0000000..c6b1eef --- /dev/null +++ b/src/main/java/com/example/Capstone_project/domain/ChatMessage.java @@ -0,0 +1,42 @@ +package com.example.Capstone_project.domain; + +import jakarta.persistence.*; +import lombok.*; + +import java.time.LocalDateTime; + +/** + * 챗봇 대화 메시지 (메모리). user_id로 구분, 세션 테이블 없이 최근 N개 로드. + */ +@Entity +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +@Table(name = "chat_messages") +public class ChatMessage { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "user_id", nullable = false) + private Long userId; + + @Column(nullable = false, length = 20) + private String role; // user | assistant + + @Column(columnDefinition = "TEXT", nullable = false) + private String content; + + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + @PrePersist + protected void onCreate() { + if (createdAt == null) { + createdAt = LocalDateTime.now(); + } + } +} diff --git a/src/main/java/com/example/Capstone_project/dto/ChatRequestDto.java b/src/main/java/com/example/Capstone_project/dto/ChatRequestDto.java new file mode 100644 index 0000000..af2beba --- /dev/null +++ b/src/main/java/com/example/Capstone_project/dto/ChatRequestDto.java @@ -0,0 +1,36 @@ +package com.example.Capstone_project.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotEmpty; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.util.List; + +@Getter +@NoArgsConstructor +@AllArgsConstructor +@Schema(description = "챗봇 메시지 요청 (대화 이력 + 새 메시지)") +public class ChatRequestDto { + + @Schema(description = "이전 대화 목록 (선택). 비우면 새 대화로 처리.") + private List history = List.of(); + + @NotBlank(message = "메시지를 입력해주세요.") + @Schema(description = "사용자가 보낼 메시지", required = true, example = "결혼식에 입을 옷 추천해줘") + private String message; + + @Getter + @NoArgsConstructor + @AllArgsConstructor + @Schema(description = "대화 한 턴") + public static class ChatMessageDto { + @Schema(description = "역할: user 또는 assistant", example = "user") + private String role; + + @Schema(description = "메시지 내용") + private String content; + } +} diff --git a/src/main/java/com/example/Capstone_project/dto/ChatResponseDto.java b/src/main/java/com/example/Capstone_project/dto/ChatResponseDto.java new file mode 100644 index 0000000..e292393 --- /dev/null +++ b/src/main/java/com/example/Capstone_project/dto/ChatResponseDto.java @@ -0,0 +1,27 @@ +package com.example.Capstone_project.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +@Schema(description = "챗봇 응답") +public class ChatResponseDto { + + @Schema(description = "AI 응답 메시지") + private String message; + + @Schema(description = "스타일 추천 결과. 챗봇이 style_recommend 툴을 사용했을 때만 포함됨") + private StyleRecommendationResponse recommendations; + + @Schema(description = "상의 추천 결과. 챗봇이 recommend_tops 툴을 사용했을 때만 포함됨") + private ClothesRecommendationResponse recommendationsTops; + + @Schema(description = "하의 추천 결과. 챗봇이 recommend_bottoms 툴을 사용했을 때만 포함됨") + private ClothesRecommendationResponse recommendationsBottoms; +} diff --git a/src/main/java/com/example/Capstone_project/repository/ChatMessageRepository.java b/src/main/java/com/example/Capstone_project/repository/ChatMessageRepository.java new file mode 100644 index 0000000..f068332 --- /dev/null +++ b/src/main/java/com/example/Capstone_project/repository/ChatMessageRepository.java @@ -0,0 +1,17 @@ +package com.example.Capstone_project.repository; + +import com.example.Capstone_project.domain.ChatMessage; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.List; + +@Repository +public interface ChatMessageRepository extends JpaRepository { + + /** + * 해당 사용자의 최근 메시지 최대 20개 (최신 순). + * 서비스에서 역순으로 정렬해 오래된 순으로 contents 구성. + */ + List findTop20ByUserIdOrderByCreatedAtDesc(Long userId); +} diff --git a/src/main/java/com/example/Capstone_project/repository/FittingRepository.java b/src/main/java/com/example/Capstone_project/repository/FittingRepository.java index 019cf96..cb6c6db 100644 --- a/src/main/java/com/example/Capstone_project/repository/FittingRepository.java +++ b/src/main/java/com/example/Capstone_project/repository/FittingRepository.java @@ -44,4 +44,45 @@ List findSimilarIdsWithDistance( @Param("gender") String gender, @Param("limit") int limit ); + + /** + * 내 옷장 전용: 동일 쿼리 + user_id로 해당 사용자 피팅만 검색. + */ + @Query(value = """ + SELECT ft.id, (ft.style_embedding <=> CAST(:queryVector AS vector)) AS distance + FROM fitting_tasks ft + WHERE ft.style_embedding IS NOT NULL + AND ft.user_id = :userId + AND (:maxDistance IS NULL OR (ft.style_embedding <=> CAST(:queryVector AS vector)) <= :maxDistance) + AND (:gender IS NULL OR ft.result_gender::text = :gender) + ORDER BY distance + LIMIT :limit + """, nativeQuery = true) + List findSimilarIdsWithDistanceByUser( + @Param("queryVector") String queryVector, + @Param("maxDistance") Double maxDistance, + @Param("gender") String gender, + @Param("userId") Long userId, + @Param("limit") int limit + ); + + /** + * 피드 전용: feeds에 올라온 fitting_task_id만 대상으로 유사도 검색. + */ + @Query(value = """ + SELECT ft.id, (ft.style_embedding <=> CAST(:queryVector AS vector)) AS distance + FROM fitting_tasks ft + INNER JOIN feeds f ON f.fitting_task_id = ft.id AND f.deleted_at IS NULL + WHERE ft.style_embedding IS NOT NULL + AND (:maxDistance IS NULL OR (ft.style_embedding <=> CAST(:queryVector AS vector)) <= :maxDistance) + AND (:gender IS NULL OR ft.result_gender::text = :gender) + ORDER BY distance + LIMIT :limit + """, nativeQuery = true) + List findSimilarIdsWithDistanceFromFeed( + @Param("queryVector") String queryVector, + @Param("maxDistance") Double maxDistance, + @Param("gender") String gender, + @Param("limit") int limit + ); } \ No newline at end of file diff --git a/src/main/java/com/example/Capstone_project/service/ChatService.java b/src/main/java/com/example/Capstone_project/service/ChatService.java new file mode 100644 index 0000000..63a8190 --- /dev/null +++ b/src/main/java/com/example/Capstone_project/service/ChatService.java @@ -0,0 +1,307 @@ +package com.example.Capstone_project.service; + +import com.example.Capstone_project.domain.ChatMessage; +import com.example.Capstone_project.dto.ChatRequestDto; +import com.example.Capstone_project.dto.ChatResponseDto; +import com.example.Capstone_project.dto.ClothesRecommendationResponse; +import com.example.Capstone_project.dto.StyleRecommendationResponse; +import com.example.Capstone_project.repository.ChatMessageRepository; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Service; +import org.springframework.web.reactive.function.client.WebClient; +import org.springframework.web.reactive.function.client.WebClientResponseException; + +import com.fasterxml.jackson.core.JsonProcessingException; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +/** + * Gemini 기반 AI 챗봇. 에이전트 스타일: 내 옷장 추천, 피드 추천, 웹 검색 툴 + DB 메모리(chat_messages). + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class ChatService { + + private static final String TOOL_NAME_RECOMMEND_FROM_MY_CLOSET = "recommend_from_my_closet"; + private static final String TOOL_NAME_RECOMMEND_FROM_FEED = "recommend_from_feed"; + private static final String TOOL_NAME_SEARCH_WEB_STYLES = "search_web_styles"; + private static final int MAX_TOOL_ROUNDS = 3; + private static final double MIN_SCORE = 0.7; + private final WebClient geminiWebClient; + private final StyleRecommendationService styleRecommendationService; + private final WebSearchService webSearchService; + private final ChatMessageRepository chatMessageRepository; + private final ObjectMapper objectMapper; + + @Value("${gemini.api.key}") + private String geminiApiKey; + + @Value("${gemini.api.analysis-model:gemini-2.0-flash}") + private String chatModel; + + private static final String SYSTEM_INSTRUCTION = """ + 너는 옷·스타일 추천을 돕는 친절한 챗봇이야. 다음 세 가지 도구만 사용해줘. + + 1) recommend_from_my_closet: **내 옷장**에서만 추천해달라고 할 때 사용해. ("내 옷장에서 ~", "내가 가진 옷으로 ~", "제 옷장 기준으로 ~" 등) + 2) recommend_from_feed: **피드/커뮤니티**에 올라온 코디에서 추천해달라고 할 때 사용해. ("피드에서 ~", "다른 사람 코디에서 ~", "피드 기준으로 ~" 등) + 3) search_web_styles: **인터넷/웹에서** 스타일·트렌드를 **검색만** 해달라고 할 때 사용해. ("요즘 유행하는 스타일", "웹에서 검색해줘", "인터넷에서 찾아줘" 등). 추천이 아니라 검색일 때만 이 툴을 써. + + recommend_from_my_closet 호출 시 **category** 파라미터를 반드시 넣어줘. + - 사용자가 "전체 코디", "스타일 추천", "옷 추천" 같이 **전체**를 원하면 category="style" + - "상의만", "티셔츠/셔츠 추천", "윗옷만" 같이 **상의만** 원하면 category="tops" + - "하의만", "바지 추천", "아래옷만" 같이 **하의만** 원하면 category="bottoms" + + 일반 대화나 "추천해줘"만 하고 내 옷장/피드/웹 검색 구분이 없으면 recommend_from_my_closet을 우선 사용해줘. + + 툴 결과에 추천이 1개 이상 있으면 절대 "못 찾았어요"라고 하지 말고, 최소 1개 이상 구체적으로 추천해줘. 추천이 없으면 "아직 비슷한 스타일이 없어요. 가상 피팅을 먼저 해보시면 더 많은 추천을 받을 수 있어요."처럼 안내해줘. + + search_web_styles 툴 결과를 받았을 때는, **"요즘 트렌디한 스타일은 [검색 결과를 반영한 요약]입니다"** 처럼 검색 내용을 반영한 한두 문장 요약으로 답해줘. + """; + + public ChatResponseDto chat(Long userId, ChatRequestDto request) { + List> contents = buildContents(userId, request); + // 사용자 메시지 DB 저장 (히스토리 로드 후 저장해 현재 메시지가 중복으로 로드되지 않게 함) + chatMessageRepository.save(ChatMessage.builder() + .userId(userId) + .role("user") + .content(request.getMessage()) + .build()); + StyleRecommendationResponse lastRecommendations = null; + ClothesRecommendationResponse lastRecommendationsTops = null; + ClothesRecommendationResponse lastRecommendationsBottoms = null; + int rounds = 0; + while (rounds < MAX_TOOL_ROUNDS) { + ObjectNode requestBody = buildGenerateContentRequest(contents); + String responseStr; + try { + responseStr = geminiWebClient.post() + .uri(uri -> uri.path("/models/" + chatModel + ":generateContent").queryParam("key", geminiApiKey).build()) + .contentType(MediaType.APPLICATION_JSON) + .bodyValue(requestBody) + .retrieve() + .bodyToMono(String.class) + .timeout(java.time.Duration.ofSeconds(60)) + .block(); + } catch (WebClientResponseException e) { + log.error("Gemini chat API error: {}", e.getResponseBodyAsString(), e); + saveAssistantMessage(userId, "응답을 생성하지 못했어요. 다시 말씀해 주세요."); + throw new RuntimeException("챗봇 응답 생성 실패: " + e.getMessage()); + } + JsonNode response; + try { + response = objectMapper.readTree(responseStr); + } catch (JsonProcessingException e) { + log.error("Gemini response parse error", e); + saveAssistantMessage(userId, "챗봇 응답 파싱 실패"); + throw new RuntimeException("챗봇 응답 파싱 실패"); + } + JsonNode candidates = response.path("candidates"); + if (candidates.isEmpty() || !candidates.get(0).path("content").path("parts").isArray()) { + String msg = "응답을 생성하지 못했어요. 다시 말씀해 주세요."; + saveAssistantMessage(userId, msg); + return ChatResponseDto.builder() + .message(msg) + .recommendations(lastRecommendations) + .recommendationsTops(lastRecommendationsTops) + .recommendationsBottoms(lastRecommendationsBottoms) + .build(); + } + ArrayNode parts = (ArrayNode) candidates.get(0).path("content").path("parts"); + Optional functionCall = findFunctionCall(parts); + if (functionCall.isEmpty()) { + String text = extractTextFromParts(parts); + String message = text != null ? text : "응답이 비어있어요."; + saveAssistantMessage(userId, message); + return ChatResponseDto.builder() + .message(message) + .recommendations(lastRecommendations) + .recommendationsTops(lastRecommendationsTops) + .recommendationsBottoms(lastRecommendationsBottoms) + .build(); + } + JsonNode fc = functionCall.get(); + String name = fc.path("name").asText(); + JsonNode args = fc.path("args"); + String query = args.has("query") && !args.path("query").isNull() ? args.path("query").asText("") : ""; + String gender = args.has("gender") && !args.path("gender").isNull() ? args.path("gender").asText() : null; + Integer limit = args.has("limit") && !args.path("limit").isNull() ? args.path("limit").asInt() : null; + String category = args.has("category") && !args.path("category").isNull() ? args.path("category").asText("").trim().toLowerCase() : "style"; + + if (TOOL_NAME_RECOMMEND_FROM_MY_CLOSET.equals(name)) { + try { + if ("tops".equals(category)) { + ClothesRecommendationResponse rec = styleRecommendationService.recommendTopsByStyleFromMyCloset(query, MIN_SCORE, gender, limit, userId); + lastRecommendationsTops = rec; + Map responseMap = objectMapper.convertValue(rec, Map.class); + appendFunctionResponse(contents, name, responseMap); + } else if ("bottoms".equals(category)) { + ClothesRecommendationResponse rec = styleRecommendationService.recommendBottomsByStyleFromMyCloset(query, MIN_SCORE, gender, limit, userId); + lastRecommendationsBottoms = rec; + Map responseMap = objectMapper.convertValue(rec, Map.class); + appendFunctionResponse(contents, name, responseMap); + } else { + var list = styleRecommendationService.recommendByStyleWithFilters(query, MIN_SCORE, gender, limit, userId); + StyleRecommendationResponse rec = StyleRecommendationResponse.from(list); + lastRecommendations = rec; + Map responseMap = objectMapper.convertValue(rec, Map.class); + appendFunctionResponse(contents, name, responseMap); + } + } catch (Exception e) { + log.warn("recommend_from_my_closet 실행 실패: {}", e.getMessage()); + appendFunctionResponse(contents, name, Map.of("error", e.getMessage())); + } + } else if (TOOL_NAME_RECOMMEND_FROM_FEED.equals(name)) { + try { + var list = styleRecommendationService.recommendFromFeed(query, MIN_SCORE, gender, limit); + StyleRecommendationResponse rec = StyleRecommendationResponse.from(list); + lastRecommendations = rec; + Map responseMap = objectMapper.convertValue(rec, Map.class); + appendFunctionResponse(contents, name, responseMap); + } catch (Exception e) { + log.warn("recommend_from_feed 실행 실패: {}", e.getMessage()); + appendFunctionResponse(contents, name, Map.of("error", e.getMessage())); + } + } else if (TOOL_NAME_SEARCH_WEB_STYLES.equals(name)) { + String searchResult = webSearchService.searchStyleTrends(query); + appendFunctionResponse(contents, name, Map.of("summary", searchResult)); + } else { + appendFunctionResponse(contents, name, Map.of("error", "알 수 없는 툴: " + name)); + } + rounds++; + } + String fallbackMsg = "처리 중 문제가 생겼어요. 잠시 후 다시 시도해 주세요."; + saveAssistantMessage(userId, fallbackMsg); + return ChatResponseDto.builder() + .message(fallbackMsg) + .recommendations(lastRecommendations) + .recommendationsTops(lastRecommendationsTops) + .recommendationsBottoms(lastRecommendationsBottoms) + .build(); + } + + private void saveAssistantMessage(Long userId, String content) { + chatMessageRepository.save(ChatMessage.builder() + .userId(userId) + .role("assistant") + .content(content) + .build()); + } + + private List> buildContents(Long userId, ChatRequestDto request) { + List> contents = new ArrayList<>(); + List history = request.getHistory(); + if (history != null && !history.isEmpty()) { + for (ChatRequestDto.ChatMessageDto m : history) { + String role = "user".equalsIgnoreCase(m.getRole()) ? "user" : "model"; + contents.add(Map.of( + "role", role, + "parts", List.of(Map.of("text", m.getContent() != null ? m.getContent() : "")) + )); + } + } else { + List dbMessages = chatMessageRepository.findTop20ByUserIdOrderByCreatedAtDesc(userId); + Collections.reverse(dbMessages); + for (ChatMessage m : dbMessages) { + String role = "user".equals(m.getRole()) ? "user" : "model"; + contents.add(Map.of( + "role", role, + "parts", List.of(Map.of("text", m.getContent() != null ? m.getContent() : "")) + )); + } + } + contents.add(Map.of( + "role", "user", + "parts", List.of(Map.of("text", request.getMessage())) + )); + return contents; + } + + private ObjectNode buildGenerateContentRequest(List> contents) { + ObjectNode body = objectMapper.createObjectNode(); + body.set("contents", objectMapper.valueToTree(contents)); + ObjectNode genConfig = objectMapper.createObjectNode(); + genConfig.put("temperature", 0.7); + genConfig.put("maxOutputTokens", 1024); + body.set("generationConfig", genConfig); + body.set("systemInstruction", objectMapper.createObjectNode().set("parts", objectMapper.createArrayNode().add(objectMapper.createObjectNode().put("text", SYSTEM_INSTRUCTION)))); + Map recommendParamsMyCloset = Map.of( + "type", "OBJECT", + "properties", Map.of( + "query", Map.of("type", "STRING", "description", "검색어 (예: 깔끔하고 단정한 스타일, 캐주얼 데일리)"), + "category", Map.of("type", "STRING", "description", "style=전체 코디, tops=상의만(티셔츠/셔츠 등), bottoms=하의만. 사용자 말에 맞게 넣어줘."), + "gender", Map.of("type", "STRING", "description", "MALE 또는 FEMALE (선택)"), + "limit", Map.of("type", "INTEGER", "description", "최대 개수 1~50 (선택)") + ), + "required", List.of("query", "category") + ); + Map recommendParamsFeed = Map.of( + "type", "OBJECT", + "properties", Map.of( + "query", Map.of("type", "STRING", "description", "검색어 (예: 깔끔한 스타일)"), + "gender", Map.of("type", "STRING", "description", "MALE 또는 FEMALE (선택)"), + "limit", Map.of("type", "INTEGER", "description", "최대 개수 1~50 (선택)") + ), + "required", List.of("query") + ); + Map searchParams = Map.of( + "type", "OBJECT", + "properties", Map.of("query", Map.of("type", "STRING", "description", "검색어 (예: 요즘 유행 스타일, 2024 FW 트렌드)")), + "required", List.of("query") + ); + List> toolDecl = List.of( + Map.of( + "name", TOOL_NAME_RECOMMEND_FROM_MY_CLOSET, + "description", "내 옷장 기준 추천. 전체 코디면 category=style, 상의만(티셔츠/셔츠 등)이면 category=tops, 하의만이면 category=bottoms로 호출하세요. '내 옷장에서 ~', '내가 가진 옷으로 ~' 요청일 때 사용.", + "parameters", recommendParamsMyCloset + ), + Map.of( + "name", TOOL_NAME_RECOMMEND_FROM_FEED, + "description", "피드(커뮤니티)에 올라온 코디 중에서 스타일을 추천합니다. '피드에서 ~', '다른 사람 코디에서 ~' 요청일 때 사용하세요.", + "parameters", recommendParamsFeed + ), + Map.of( + "name", TOOL_NAME_SEARCH_WEB_STYLES, + "description", "인터넷에서 스타일·트렌드를 검색합니다. 추천이 아니라 검색만 할 때 사용하세요. (요즘 유행, 웹에서 찾아줘 등)", + "parameters", searchParams + ) + ); + body.set("tools", objectMapper.createArrayNode().add(objectMapper.createObjectNode().set("functionDeclarations", objectMapper.valueToTree(toolDecl)))); + return body; + } + + private Optional findFunctionCall(ArrayNode parts) { + for (JsonNode p : parts) { + if (p.has("functionCall")) return Optional.of(p.path("functionCall")); + } + return Optional.empty(); + } + + private String extractTextFromParts(ArrayNode parts) { + for (JsonNode p : parts) { + if (p.has("text")) return p.path("text").asText(""); + } + return null; + } + + private void appendFunctionResponse(List> contents, String name, Map response) { + // Gemini tool-calling 흐름: + // (1) 모델이 functionCall 생성 → (2) 클라이언트가 tool 실행 → (3) user role로 functionResponse를 전달 + // 여기서 functionCall을 다시 추가하면 모델이 혼동할 수 있어 functionResponse만 전달합니다. + contents.add(Map.of( + "role", "user", + "parts", List.of(Map.of("functionResponse", Map.of("name", name, "response", response))) + )); + } +} diff --git a/src/main/java/com/example/Capstone_project/service/StyleRecommendationService.java b/src/main/java/com/example/Capstone_project/service/StyleRecommendationService.java index 483fc15..7aff4b5 100644 --- a/src/main/java/com/example/Capstone_project/service/StyleRecommendationService.java +++ b/src/main/java/com/example/Capstone_project/service/StyleRecommendationService.java @@ -2,8 +2,12 @@ import com.example.Capstone_project.common.exception.BadRequestException; import com.example.Capstone_project.domain.Clothes; +import com.example.Capstone_project.domain.Gender; import com.example.Capstone_project.domain.FittingTask; +import com.example.Capstone_project.dto.ClothesRecommendationResponse; +import com.example.Capstone_project.dto.ClothesResponseDto; import com.example.Capstone_project.dto.FittingTaskWithScore; +import com.example.Capstone_project.repository.ClothesRepository; import com.example.Capstone_project.repository.FittingRepository; import com.example.Capstone_project.repository.UserRepository; import lombok.RequiredArgsConstructor; @@ -12,6 +16,8 @@ import org.springframework.transaction.annotation.Transactional; import java.util.ArrayList; +import java.util.Comparator; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.stream.Collectors; @@ -27,9 +33,11 @@ public class StyleRecommendationService { private static final int DEFAULT_LIMIT = 10; + private static final int MAX_LIMIT = 50; private final GeminiService geminiService; private final FittingRepository fittingRepository; + private final ClothesRepository clothesRepository; private final UserRepository userRepository; /** @@ -42,6 +50,115 @@ public class StyleRecommendationService { */ @Transactional(readOnly = true) public List recommendByStyle(String userQuery, Double minScore, Long userId) { + // 성별 필터: userId가 있으면 User에서 성별 조회 + String genderFilter = null; + if (userId != null) { + genderFilter = userRepository.findById(userId) + .map(u -> u.getGender() != null ? u.getGender().name() : null) + .orElse(null); + } + return recommendInternal(userQuery, minScore, genderFilter, DEFAULT_LIMIT, null, false); + } + + /** + * 검색어·성별·limit 지정 스타일 추천 (챗봇 Gemini function calling 및 외부 API 공용). + * userIdForMyCloset가 null이면 전체, 널이 아니면 해당 사용자 옷장만 검색. + * + * @param userQuery 예: "결혼식에 입고 갈 단정하고 깔끔한 스타일 추천해줘" + * @param minScore 최소 유사도 점수 (0~1, null이면 필터 없음) + * @param gender 성별 필터 (MALE/FEMALE, 대소문자 무관, null/blank면 필터 없음) + * @param limit 최대 개수 (기본 10, 최대 50) + * @param userIdForMyCloset null이면 전체 피팅 검색, 널이 아니면 해당 사용자(user_id) 피팅만 검색 (내 옷장) + */ + @Transactional(readOnly = true) + public List recommendByStyleWithFilters(String userQuery, Double minScore, String gender, Integer limit, Long userIdForMyCloset) { + String genderFilter = parseGenderOrNull(gender); + int resolvedLimit = resolveLimit(limit); + return recommendInternal(userQuery, minScore, genderFilter, resolvedLimit, userIdForMyCloset, false); + } + + /** + * 피드 전용: 피드에 올라온 코디만 대상으로 스타일 유사도 추천. + */ + @Transactional(readOnly = true) + public List recommendFromFeed(String userQuery, Double minScore, String gender, Integer limit) { + String genderFilter = parseGenderOrNull(gender); + int resolvedLimit = resolveLimit(limit); + return recommendInternal(userQuery, minScore, genderFilter, resolvedLimit, null, true); + } + + /** + * 상의만 추천: 스타일 추천과 동일한 유사도 검색 후, 추천된 피팅 결과에서 상의만 추출해 유사도 순으로 반환. (챗봇·외부 API 공용) + */ + @Transactional(readOnly = true) + public ClothesRecommendationResponse recommendTopsByStyle(String userQuery, Double minScore, String gender, Integer limit) { + int resolvedLimit = resolveLimit(limit); + List tasks = recommendInternal(userQuery, minScore, parseGenderOrNull(gender), MAX_LIMIT, null, false); + return extractClothesByCategory(tasks, true, resolvedLimit); + } + + /** + * 하의만 추천: 스타일 추천과 동일한 유사도 검색 후, 추천된 피팅 결과에서 하의만 추출해 유사도 순으로 반환. (챗봇·외부 API 공용) + */ + @Transactional(readOnly = true) + public ClothesRecommendationResponse recommendBottomsByStyle(String userQuery, Double minScore, String gender, Integer limit) { + int resolvedLimit = resolveLimit(limit); + List tasks = recommendInternal(userQuery, minScore, parseGenderOrNull(gender), MAX_LIMIT, null, false); + return extractClothesByCategory(tasks, false, resolvedLimit); + } + + /** + * 내 옷장 전용 - 상의만 추천 (티셔츠, 셔츠 등). 해당 사용자 피팅 결과에서만 검색. + */ + @Transactional(readOnly = true) + public ClothesRecommendationResponse recommendTopsByStyleFromMyCloset(String userQuery, Double minScore, String gender, Integer limit, Long userId) { + int resolvedLimit = resolveLimit(limit); + List tasks = recommendInternal(userQuery, minScore, parseGenderOrNull(gender), MAX_LIMIT, userId, false); + return extractClothesByCategory(tasks, true, resolvedLimit); + } + + /** + * 내 옷장 전용 - 하의만 추천. 해당 사용자 피팅 결과에서만 검색. + */ + @Transactional(readOnly = true) + public ClothesRecommendationResponse recommendBottomsByStyleFromMyCloset(String userQuery, Double minScore, String gender, Integer limit, Long userId) { + int resolvedLimit = resolveLimit(limit); + List tasks = recommendInternal(userQuery, minScore, parseGenderOrNull(gender), MAX_LIMIT, userId, false); + return extractClothesByCategory(tasks, false, resolvedLimit); + } + + private ClothesRecommendationResponse extractClothesByCategory(List tasks, boolean isTop, int limit) { + Map idToBestScore = new LinkedHashMap<>(); + for (FittingTaskWithScore tws : tasks) { + Long clothesId = isTop ? tws.getTask().getTopId() : tws.getTask().getBottomId(); + if (clothesId == null) continue; + idToBestScore.merge(clothesId, tws.getScore(), Math::max); + } + List orderedIds = idToBestScore.entrySet().stream() + .sorted(Comparator.>comparingDouble(Map.Entry::getValue).reversed()) + .limit(limit) + .map(Map.Entry::getKey) + .toList(); + if (orderedIds.isEmpty()) { + return ClothesRecommendationResponse.builder().items(List.of()).build(); + } + Map clothesMap = clothesRepository.findAllById(orderedIds).stream().collect(Collectors.toMap(Clothes::getId, c -> c)); + List items = orderedIds.stream() + .map(id -> { + Clothes c = clothesMap.get(id); + if (c == null) return null; + Double score = idToBestScore.get(id); + return ClothesRecommendationResponse.ClothesRecommendationItem.builder() + .clothes(ClothesResponseDto.from(c)) + .score(score != null ? Math.round(score * 100.0) / 100.0 : null) + .build(); + }) + .filter(item -> item != null) + .toList(); + return ClothesRecommendationResponse.builder().items(items).build(); + } + + private List recommendInternal(String userQuery, Double minScore, String genderFilter, int limit, Long userIdForMyCloset, boolean fromFeedOnly) { if (userQuery == null || userQuery.trim().isEmpty()) { throw new BadRequestException("검색어를 입력해주세요."); } @@ -49,18 +166,11 @@ public List recommendByStyle(String userQuery, Double minS throw new BadRequestException("minScore는 0~1 사이여야 합니다."); } - // 성별 필터: userId가 있으면 User에서 성별 조회 - String genderFilter = null; - if (userId != null) { - genderFilter = userRepository.findById(userId) - .map(u -> u.getGender() != null ? u.getGender().name() : null) - .orElse(null); - } - - log.info("🔍 스타일 추천 검색 - 쿼리: {}, minScore: {}, genderFilter: {}", userQuery, minScore, genderFilter); + String normalizedQuery = userQuery.trim(); + log.info("🔍 스타일 추천 검색 - 쿼리: {}, minScore: {}, genderFilter: {}, limit: {}, myCloset: {}, feedOnly: {}", normalizedQuery, minScore, genderFilter, limit, userIdForMyCloset, fromFeedOnly); // 1. 사용자 검색어를 RETRIEVAL_QUERY로 임베딩 - float[] queryEmbedding = geminiService.embedText(userQuery.trim(), "RETRIEVAL_QUERY"); + float[] queryEmbedding = geminiService.embedText(normalizedQuery, "RETRIEVAL_QUERY"); // 2. pgvector 포맷 문자열로 변환 "[0.1,0.2,...]" String queryVectorStr = toPgVectorString(queryEmbedding); @@ -68,10 +178,15 @@ public List recommendByStyle(String userQuery, Double minS // 3. maxDistance = 1 - minScore (코사인 거리. 낮을수록 유사, score=1-distance) Double maxDistance = minScore != null ? (1.0 - minScore) : null; - // 4. 유사도 검색 (거리 + 성별 필터, 최대 10개) - List idWithDistance = fittingRepository.findSimilarIdsWithDistance( - queryVectorStr, maxDistance, genderFilter, DEFAULT_LIMIT - ); + // 4. 유사도 검색 (내 옷장 / 피드 / 전체) + List idWithDistance; + if (fromFeedOnly) { + idWithDistance = fittingRepository.findSimilarIdsWithDistanceFromFeed(queryVectorStr, maxDistance, genderFilter, limit); + } else if (userIdForMyCloset != null) { + idWithDistance = fittingRepository.findSimilarIdsWithDistanceByUser(queryVectorStr, maxDistance, genderFilter, userIdForMyCloset, limit); + } else { + idWithDistance = fittingRepository.findSimilarIdsWithDistance(queryVectorStr, maxDistance, genderFilter, limit); + } if (idWithDistance.isEmpty()) { log.info("✅ 스타일 추천 완료 - 조건에 맞는 결과 없음"); @@ -97,7 +212,7 @@ public List recommendByStyle(String userQuery, Double minS } } - log.info("✅ 스타일 추천 완료 - {}건 반환 (minScore={}, genderFilter={})", results.size(), minScore, genderFilter); + log.info("✅ 스타일 추천 완료 - {}건 반환 (minScore={}, genderFilter={}, limit={}, myCloset={}, feedOnly={})", results.size(), minScore, genderFilter, limit, userIdForMyCloset, fromFeedOnly); return results; } @@ -185,6 +300,31 @@ private double clamp01(double v) { return Math.max(0.0, Math.min(1.0, v)); } + private String parseGenderOrNull(String gender) { + if (gender == null || gender.isBlank()) { + return null; + } + String normalized = gender.trim().toUpperCase(); + try { + return Gender.valueOf(normalized).name(); + } catch (IllegalArgumentException e) { + throw new BadRequestException("gender는 MALE 또는 FEMALE 이어야 합니다."); + } + } + + private int resolveLimit(Integer limit) { + if (limit == null) { + return DEFAULT_LIMIT; + } + if (limit <= 0) { + throw new BadRequestException("limit은 1 이상이어야 합니다."); + } + if (limit > MAX_LIMIT) { + return MAX_LIMIT; + } + return limit; + } + private String toPgVectorString(float[] embedding) { if (embedding == null || embedding.length == 0) { throw new BadRequestException("Invalid embedding"); diff --git a/src/main/java/com/example/Capstone_project/service/WebSearchService.java b/src/main/java/com/example/Capstone_project/service/WebSearchService.java new file mode 100644 index 0000000..c2a7d2e --- /dev/null +++ b/src/main/java/com/example/Capstone_project/service/WebSearchService.java @@ -0,0 +1,86 @@ +package com.example.Capstone_project.service; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Service; +import org.springframework.web.reactive.function.client.WebClient; +import org.springframework.web.reactive.function.client.WebClientResponseException; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +/** + * Serper API를 이용한 웹 검색. 챗봇 툴 search_web_styles에서 스타일·트렌드 검색 시 사용. + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class WebSearchService { + + private static final String SERPER_SEARCH_URL = "https://google.serper.dev/search"; + + private final WebClient webClient = WebClient.builder().build(); + private final ObjectMapper objectMapper = new ObjectMapper(); + + @Value("${serper.api-key:}") + private String serperApiKey; + + /** + * 검색어로 웹 검색 후 결과 스니펫(제목+요약)을 한 문자열로 포맷해 반환. + * API 키가 없으면 "검색 API를 사용할 수 없습니다." 반환. + */ + public String searchStyleTrends(String query) { + if (query == null || query.isBlank()) { + return "검색어를 입력해주세요."; + } + if (serperApiKey == null || serperApiKey.isBlank()) { + log.warn("Serper API key not configured. Web search disabled."); + return "검색 API를 사용할 수 없습니다. 관리자에게 문의해 주세요."; + } + + try { + String body = objectMapper.writeValueAsString(Map.of("q", query.trim())); + String responseStr = webClient.post() + .uri(SERPER_SEARCH_URL) + .header("X-API-KEY", serperApiKey) + .contentType(MediaType.APPLICATION_JSON) + .bodyValue(body) + .retrieve() + .bodyToMono(String.class) + .timeout(java.time.Duration.ofSeconds(10)) + .block(); + + JsonNode root = objectMapper.readTree(responseStr); + JsonNode organic = root.path("organic"); + if (organic == null || !organic.isArray()) { + return "검색 결과가 없습니다."; + } + + List lines = new ArrayList<>(); + int max = Math.min(organic.size(), 8); + for (int i = 0; i < max; i++) { + JsonNode item = organic.get(i); + String title = item.path("title").asText(""); + String snippet = item.path("snippet").asText(""); + if (!title.isEmpty() || !snippet.isEmpty()) { + lines.add((title.isEmpty() ? "" : title + ": ") + snippet); + } + } + if (lines.isEmpty()) { + return "검색 결과가 없습니다."; + } + return String.join("\n", lines); + } catch (WebClientResponseException e) { + log.warn("Serper API error: {} - {}", e.getStatusCode(), e.getResponseBodyAsString()); + return "검색 중 오류가 발생했습니다. 잠시 후 다시 시도해 주세요."; + } catch (Exception e) { + log.warn("Web search failed: {}", e.getMessage()); + return "검색 중 오류가 발생했습니다."; + } + } +} From 7fcd6e3fb52a09330636644f44335011350270e5 Mon Sep 17 00:00:00 2001 From: dongan Date: Tue, 3 Mar 2026 00:54:25 +0900 Subject: [PATCH 2/5] =?UTF-8?q?perf:=20=EA=B0=80=EC=83=81=20=ED=94=BC?= =?UTF-8?q?=ED=8C=85=20=EB=B3=91=EB=A0=AC=20=EC=B2=98=EB=A6=AC=20=EB=B0=8F?= =?UTF-8?q?=20=ED=8A=B8=EB=9E=9C=EC=9E=AD=EC=85=98=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Capstone_project/domain/FittingTask.java | 4 +- .../service/ClothesAnalysisService.java | 100 +++++++++++++--- .../service/FittingCleanupService.java | 8 +- .../service/FittingService.java | 83 +++++++------ .../service/GeminiService.java | 110 ++++++++++-------- 5 files changed, 201 insertions(+), 104 deletions(-) diff --git a/src/main/java/com/example/Capstone_project/domain/FittingTask.java b/src/main/java/com/example/Capstone_project/domain/FittingTask.java index 02bb266..c58075c 100644 --- a/src/main/java/com/example/Capstone_project/domain/FittingTask.java +++ b/src/main/java/com/example/Capstone_project/domain/FittingTask.java @@ -4,11 +4,10 @@ import lombok.Getter; import lombok.NoArgsConstructor; import lombok.Setter; -import com.example.Capstone_project.domain.ClothesSet; import org.hibernate.annotations.Array; import org.hibernate.annotations.JdbcTypeCode; import org.hibernate.type.SqlTypes; -import java.time.LocalDateTime; + @Entity @Getter @Setter @@ -94,7 +93,6 @@ public class FittingTask { @Column(name = "result_gender", length = 10) private Gender resultGender; - // 👈 FittingTask.java 파일 맨 아래 } 바로 위에 넣으세요. @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "clothes_set_id") private ClothesSet clothesSet; diff --git a/src/main/java/com/example/Capstone_project/service/ClothesAnalysisService.java b/src/main/java/com/example/Capstone_project/service/ClothesAnalysisService.java index bb71af7..74d8fa3 100644 --- a/src/main/java/com/example/Capstone_project/service/ClothesAnalysisService.java +++ b/src/main/java/com/example/Capstone_project/service/ClothesAnalysisService.java @@ -1,10 +1,13 @@ package com.example.Capstone_project.service; import com.example.Capstone_project.domain.Clothes; +import com.example.Capstone_project.domain.ClothesUploadStatus; +import com.example.Capstone_project.domain.ClothesUploadTask; import com.example.Capstone_project.domain.User; import com.example.Capstone_project.dto.ClothesAnalysisResultDto; -import com.example.Capstone_project.dto.ClothesRequestDto; +import com.example.Capstone_project.dto.ClothesUploadStatusResponse; import com.example.Capstone_project.repository.ClothesRepository; +import com.example.Capstone_project.repository.ClothesUploadTaskRepository; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.scheduling.annotation.Async; @@ -23,14 +26,17 @@ public class ClothesAnalysisService { private final GeminiService geminiService; private final ClothesRepository clothesRepository; private final GoogleCloudStorageService gcsService; + private final ClothesUploadTaskRepository clothesUploadTaskRepository; + private final ClothesUploadSseService clothesUploadSseService; /** - * 옷 분석 및 저장 내부 로직 (핵심 리팩토링 구간) + * 옷 분석 및 저장. **트랜잭션 분리**: GCS 업로드 + Gemini 호출은 트랜잭션 밖(커넥션 미사용), + * DB 저장만 짧은 트랜잭션으로 수행해 커넥션 풀 고갈을 방지. * @param inCloset true=내 옷장에 표시(직접 등록), false=가상피팅 입력용(옷장에 미표시) */ private Long analyzeAndSaveClothesInternal(byte[] imageBytes, String filename, String category, User user, boolean inCloset) throws IOException { - // [Step 1] 이미지를 GCS에 업로드 (기존 로직 유지) + // [Step 1] GCS 업로드 (트랜잭션 없음 - 외부 I/O) String fileExtension = filename.contains(".") ? filename.substring(filename.lastIndexOf(".")) : ".jpg"; String uniqueFilename = UUID.randomUUID().toString() + fileExtension; @@ -43,17 +49,21 @@ private Long analyzeAndSaveClothesInternal(byte[] imageBytes, String filename, S imgUrl = gcsService.uploadImage(imageBytes, uniqueFilename, "image/jpeg"); } - // [Step 2] Gemini에게 "한 방에" 물어보기 (if문 300줄이 이 한 줄로 끝납니다) + // [Step 2] Gemini 호출 (트랜잭션 없음 - 오래 걸리는 외부 API, 커넥션 보유하지 않음) String prompt = "이 옷 사진을 분석해서 category, color, material, pattern, neckLine, sleeveType, closure, style, fit, length, texture, detail, season, thickness, occasion 정보를 " + "한국어 JSON 형식으로만 답변해줘. 예: {\"category\": \"상의\", \"color\": \"검정\", ...}"; - - // GeminiService에 추가한 메서드 호출 ClothesAnalysisResultDto result = geminiService.analyzeClothesImage(imageBytes, prompt); - // [Step 3] 이름 자동 생성 (결과값 활용) - String autoName = result.getColor() + " " + result.getMaterial() + " " + result.getCategory(); + // [Step 3] DB 저장만 짧은 트랜잭션으로 수행 (커넥션 짧게 사용 후 반납) + return saveClothesToDb(imgUrl, result, category, user, inCloset); + } - // [Step 4] DB 저장 (Gemini가 준 데이터를 그대로 매핑) + /** + * 분석 결과를 DB에만 저장. 짧은 트랜잭션만 사용하여 커넥션 풀 고갈을 방지. + */ + @Transactional(rollbackFor = Exception.class) + public Long saveClothesToDb(String imgUrl, ClothesAnalysisResultDto result, String category, User user, boolean inCloset) { + String autoName = result.getColor() + " " + result.getMaterial() + " " + result.getCategory(); Clothes clothes = Clothes.builder() .user(user) .inCloset(inCloset) @@ -80,7 +90,6 @@ private Long analyzeAndSaveClothesInternal(byte[] imageBytes, String filename, S Clothes saved = clothesRepository.save(clothes); log.info("✅ Gemini 스마트 분석 완료 - ID: {}, name: {}", saved.getId(), saved.getName()); - return saved.getId(); } @@ -90,9 +99,8 @@ private Long analyzeAndSaveClothesInternal(byte[] imageBytes, String filename, S * 비동기 옷 분석. MultipartFile 대신 byte[]를 받아야 함. * (MultipartFile은 요청 종료 시 임시파일이 삭제되므로 @Async에서 getBytes() 호출 시 NoSuchFileException 발생) */ - /** 내 옷장 직접 등록용 - inCloset=true */ + /** 내 옷장 직접 등록용 - inCloset=true (SSE 없이 기존 동작, 하위 호환). 트랜잭션은 내부 saveClothesToDb에서만 짧게 사용. */ @Async("taskExecutor") - @Transactional public void analyzeAndSaveClothesAsync(byte[] imageBytes, String filename, String category, User user) { try { analyzeAndSaveClothesInternal(imageBytes, filename, category, user, true); @@ -102,15 +110,75 @@ public void analyzeAndSaveClothesAsync(byte[] imageBytes, String filename, Strin } /** - * 가상피팅 입력용 - inCloset=false (내 옷장에 미표시) - * 내 옷장 직접 등록 시에는 analyzeAndSaveClothesAsync 사용 + * 옷 업로드 작업 실행: PROCESSING → 분석 → COMPLETED/FAILED, SSE로 상태 전송. + * 트랜잭션 분리: 상태 변경/저장만 짧은 트랜잭션, GCS+Gemini 구간은 트랜잭션 없음. + */ + @Async("taskExecutor") + public void startClothesUploadAndNotify(Long taskId, byte[] imageBytes, String filename, String category, User user) { + setTaskProcessingAndNotify(taskId); + + try { + Long clothesId = analyzeAndSaveClothesInternal(imageBytes, filename, category, user, true); + setTaskCompletedAndNotify(taskId, clothesId); + } catch (Exception e) { + log.error("❌ 옷 업로드/분석 실패 taskId={}", taskId, e); + String msg = e.getMessage() != null ? e.getMessage() : e.getClass().getSimpleName(); + if (msg != null && msg.length() > 500) { + msg = msg.substring(0, 500); + } + setTaskFailedAndNotify(taskId, msg); + } + } + + @Transactional(rollbackFor = Exception.class) + public void setTaskProcessingAndNotify(Long taskId) { + ClothesUploadTask task = clothesUploadTaskRepository.findById(taskId) + .orElseThrow(() -> new RuntimeException("ClothesUploadTask not found: " + taskId)); + task.setStatus(ClothesUploadStatus.PROCESSING); + clothesUploadTaskRepository.save(task); + clothesUploadSseService.notifyStatus(taskId, ClothesUploadStatusResponse.builder() + .taskId(taskId) + .status(ClothesUploadStatus.PROCESSING) + .build()); + } + + @Transactional(rollbackFor = Exception.class) + public void setTaskCompletedAndNotify(Long taskId, Long clothesId) { + ClothesUploadTask task = clothesUploadTaskRepository.findById(taskId) + .orElseThrow(() -> new RuntimeException("ClothesUploadTask not found: " + taskId)); + task.setStatus(ClothesUploadStatus.COMPLETED); + task.setClothesId(clothesId); + task.setErrorMessage(null); + clothesUploadTaskRepository.save(task); + clothesUploadSseService.notifyStatus(taskId, ClothesUploadStatusResponse.builder() + .taskId(taskId) + .status(ClothesUploadStatus.COMPLETED) + .clothesId(clothesId) + .build()); + } + + @Transactional(rollbackFor = Exception.class) + public void setTaskFailedAndNotify(Long taskId, String errorMessage) { + ClothesUploadTask task = clothesUploadTaskRepository.findById(taskId) + .orElseThrow(() -> new RuntimeException("ClothesUploadTask not found: " + taskId)); + task.setStatus(ClothesUploadStatus.FAILED); + task.setErrorMessage(errorMessage); + clothesUploadTaskRepository.save(task); + clothesUploadSseService.notifyStatus(taskId, ClothesUploadStatusResponse.builder() + .taskId(taskId) + .status(ClothesUploadStatus.FAILED) + .errorMessage(errorMessage) + .build()); + } + + /** + * 가상피팅 입력용 - inCloset=false. 트랜잭션은 내부 saveClothesToDb에서만 짧게 사용. */ - @Transactional public Long analyzeAndSaveClothes(byte[] imageBytes, String filename, String category, User user) throws IOException { return analyzeAndSaveClothesInternal(imageBytes, filename, category, user, false); } - @Transactional + /** 내 옷장 동기 등록용. 트랜잭션은 내부 saveClothesToDb에서만 짧게 사용. */ public Long analyzeAndSaveClothesSync(MultipartFile file, String category, User user) throws IOException { return analyzeAndSaveClothesInternal(file.getBytes(), file.getOriginalFilename(), category, user, true); } diff --git a/src/main/java/com/example/Capstone_project/service/FittingCleanupService.java b/src/main/java/com/example/Capstone_project/service/FittingCleanupService.java index c4ca6d3..284ed24 100644 --- a/src/main/java/com/example/Capstone_project/service/FittingCleanupService.java +++ b/src/main/java/com/example/Capstone_project/service/FittingCleanupService.java @@ -23,15 +23,15 @@ public class FittingCleanupService { @Async("taskExecutor") @Transactional(propagation = Propagation.REQUIRES_NEW) public void cleanupAfterTaskDelete(String bodyImgUrl, String resultImgUrl, Long topId, Long bottomId) { - log.info("🧹 [비동기] FittingTask 연관 리소스 정리 시작"); + log.info("[Async] FittingTask resource cleanup started"); try { deleteGcsImageIfPresent(bodyImgUrl, "bodyImg"); deleteGcsImageIfPresent(resultImgUrl, "resultImg"); deleteClothesIfVirtualFittingOnly(topId); deleteClothesIfVirtualFittingOnly(bottomId); - log.info("🧹 [비동기] FittingTask 연관 리소스 정리 완료"); + log.info("[Async] FittingTask resource cleanup completed"); } catch (Exception e) { - log.error("❌ [비동기] FittingTask 연관 리소스 정리 중 오류", e); + log.error("[Async] FittingTask resource cleanup error", e); } } @@ -40,7 +40,7 @@ private void deleteGcsImageIfPresent(String url, String label) { try { String blobName = gcsService.extractBlobNameFromUrl(url); gcsService.deleteImage(blobName); - log.info("🗑️ GCS {} 이미지 삭제 완료: {}", label, blobName); + log.info("GCS {} image deleted: {}", label, blobName); } catch (Exception e) { log.warn("GCS {} 이미지 삭제 스킵: {}", label, e.getMessage()); } diff --git a/src/main/java/com/example/Capstone_project/service/FittingService.java b/src/main/java/com/example/Capstone_project/service/FittingService.java index fb6b0e5..467f91f 100644 --- a/src/main/java/com/example/Capstone_project/service/FittingService.java +++ b/src/main/java/com/example/Capstone_project/service/FittingService.java @@ -21,8 +21,6 @@ import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; -import java.util.ArrayList; -import java.util.List; import java.util.concurrent.CompletableFuture; import java.util.concurrent.Executor; @@ -61,6 +59,11 @@ public void updateFittingTaskClothes(Long taskId, Long topId, Long bottomId) { } public void processFitting(Long taskId, byte[] userImgData, String userImageFilename, byte[] topImgData, byte[] bottomImgData) { + processFitting(taskId, userImgData, userImageFilename, topImgData, bottomImgData, System.currentTimeMillis()); + } + + public void processFitting(Long taskId, byte[] userImgData, String userImageFilename, byte[] topImgData, byte[] bottomImgData, long processStartTime) { + long startTime = System.currentTimeMillis(); log.info("🚀 가상 피팅 작업 시작 - Task ID: {}", taskId); try { @@ -76,9 +79,12 @@ public void processFitting(Long taskId, byte[] userImgData, String userImageFile ); if (response != null && "completed".equals(response.getStatus())) { - // 3) 결과 URL과 COMPLETED 상태를 먼저 저장 (빠르게 커밋) + // 3) 결과 URL과 COMPLETED 상태를 먼저 저장 → SSE로 클라이언트에 즉시 전달 updateFittingTaskResult(taskId, response.getImageUrl()); - log.info("✅ [작업 완료] URL: {}", response.getImageUrl()); + long fittingElapsed = System.currentTimeMillis() - startTime; + log.info("✅ [작업 완료] URL: {} (Gemini 피팅 소요: {}초)", response.getImageUrl(), String.format("%.1f", fittingElapsed / 1000.0)); + long userWaitTime = System.currentTimeMillis() - processStartTime; + log.info("⚡ [사용자 반환 완료] Task ID: {} - 사용자 대기시간: {}초", taskId, String.format("%.1f", userWaitTime / 1000.0)); // 4) 전신 사진 업로드 및 스타일 분석은 후처리 (DB 트랜잭션과 분리) String bodyImgUrl = null; @@ -121,12 +127,16 @@ public void processFitting(Long taskId, byte[] userImgData, String userImageFile updateFittingTaskStyleAndBody(taskId, bodyImgUrl, styleAnalysis, styleEmbedding, styleResult != null ? styleResult.getResultGender() : null); } + + long totalElapsed = System.currentTimeMillis() - startTime; + log.info("🏁 가상 피팅 전체 완료 - Task ID: {}, 총 소요시간: {}초", taskId, String.format("%.1f", totalElapsed / 1000.0)); } else { log.error("❌ 가상 피팅 실패 - 응답 상태: {}", response != null ? response.getStatus() : "null"); updateTaskStatus(taskId, FittingStatus.FAILED); } } catch (Exception e) { - log.error("❌ 가상 피팅 처리 중 오류: {}", e.getMessage(), e); + long errorElapsed = System.currentTimeMillis() - startTime; + log.error("❌ 가상 피팅 처리 중 오류 ({}초 경과): {}", String.format("%.1f", errorElapsed / 1000.0), e.getMessage(), e); updateTaskStatus(taskId, FittingStatus.FAILED); } } @@ -199,8 +209,8 @@ public void updateFittingTaskStyleAndBody(Long taskId, String bodyImgUrl, String * @param bottomImageFilename 하의 사진 파일명 * @param clothesAnalysisService 옷 분석 서비스 (순환 참조 방지를 위해 파라미터로 전달) */ + /** 트랜잭션 없음: 내부에서 updateTaskStatus 등 짧은 트랜잭션만 사용. Gemini 대기 구간에서 커넥션 보유하지 않음. */ @Async("taskExecutor") - @Transactional public void processVirtualFittingWithClothesAnalysis( Long taskId, byte[] userImageBytes, @@ -212,82 +222,89 @@ public void processVirtualFittingWithClothesAnalysis( ClothesAnalysisService clothesAnalysisService, User user ) { + long processStartTime = System.currentTimeMillis(); log.info("🚀 [비동기] 가상 피팅 전체 프로세스 시작 - Task ID: {}", taskId); - final User dbUser = user; - - try { - // 최소 하나는 있어야 함 (컨트롤러에서 검증하지만 이중 체크) if (topImageBytes == null && bottomImageBytes == null) { log.error("❌ 상의와 하의가 모두 없습니다 - Task ID: {}", taskId); updateTaskStatus(taskId, FittingStatus.FAILED); return; } - // 1. 옷 분석 시작 (병렬 처리 - taskExecutor 사용, null 체크 포함) - List> analysisFutures = new ArrayList<>(); + // === 1. 가상 피팅을 먼저 시작 (가장 오래 걸리는 작업) === + final long pStart = processStartTime; + final CompletableFuture fittingFuture = CompletableFuture.runAsync(() -> { + processFitting(taskId, userImageBytes, userImageFilename, topImageBytes, bottomImageBytes, pStart); + }, taskExecutor); - // final 변수로 선언하여 람다에서 안전하게 참조 가능하도록 함 + // === 2. 2초 후 옷 분석 시작 (Rate Limit 회피, 동시 2개씩만 Gemini 호출) === final CompletableFuture topAnalysisFuture; + final CompletableFuture bottomAnalysisFuture; + + // 상의 분석 (2초 후 시작) if (topImageBytes != null) { topAnalysisFuture = CompletableFuture.supplyAsync(() -> { try { - log.info("💎 [비동기] 상의 분석 시작 - Task ID: {}", taskId); + Thread.sleep(2000); + log.info("💎 [지연 시작] 상의 분석 시작 - Task ID: {}", taskId); return clothesAnalysisService.analyzeAndSaveClothes(topImageBytes, topImageFilename, "Top", user); + } catch (InterruptedException ie) { + Thread.currentThread().interrupt(); + return null; } catch (Exception e) { log.error("❌ 상의 분석 중 오류 발생 - Task ID: {}", taskId, e); return null; } }, taskExecutor); - analysisFutures.add(topAnalysisFuture); } else { topAnalysisFuture = CompletableFuture.completedFuture(null); } - final CompletableFuture bottomAnalysisFuture; + // 하의 분석 (2초 후 시작, 상의와 동시) if (bottomImageBytes != null) { bottomAnalysisFuture = CompletableFuture.supplyAsync(() -> { try { - log.info("💎 [비동기] 하의 분석 시작 - Task ID: {}", taskId); + Thread.sleep(2000); + log.info("💎 [지연 시작] 하의 분석 시작 - Task ID: {}", taskId); return clothesAnalysisService.analyzeAndSaveClothes(bottomImageBytes, bottomImageFilename, "Bottom", user); + } catch (InterruptedException ie) { + Thread.currentThread().interrupt(); + return null; } catch (Exception e) { log.error("❌ 하의 분석 중 오류 발생 - Task ID: {}", taskId, e); return null; } }, taskExecutor); - analysisFutures.add(bottomAnalysisFuture); } else { bottomAnalysisFuture = CompletableFuture.completedFuture(null); } - // 2. 옷 분석 완료 대기 및 가상 피팅 시작 (동일 taskExecutor에서 실행) - CompletableFuture.allOf(analysisFutures.toArray(new CompletableFuture[0])).thenRunAsync(() -> { + // === 3. 옷 분석 완료 시 → clothes ID를 FittingTask에 연결 === + CompletableFuture.allOf(topAnalysisFuture, bottomAnalysisFuture).thenRunAsync(() -> { try { Long topId = topAnalysisFuture.join(); Long bottomId = bottomAnalysisFuture.join(); + long analysisElapsed = System.currentTimeMillis() - processStartTime; + log.info("📊 옷 분석 완료 - Task ID: {}, topId: {}, bottomId: {}, 분석 소요: {}초", + taskId, topId, bottomId, String.format("%.1f", analysisElapsed / 1000.0)); - // 최소 하나는 성공해야 함 if (topId != null || bottomId != null) { - // FittingTask에 옷 ID 연결 (null일 수 있음) updateFittingTaskClothes(taskId, topId, bottomId); - log.info("✅ FittingTask에 옷 정보 연결 완료 - Task ID: {}, topId: {}, bottomId: {}", - taskId, topId, bottomId); - - // 가상 피팅 처리 시작 (비동기) - processFitting(taskId, userImageBytes, userImageFilename, topImageBytes, bottomImageBytes); - log.info("🚀 가상 피팅 작업 시작 - Task ID: {}", taskId); } else { - log.error("❌ 옷 분석 실패로 인해 가상 피팅을 시작할 수 없습니다 - Task ID: {}, topId: {}, bottomId: {}", - taskId, topId, bottomId); - updateTaskStatus(taskId, FittingStatus.FAILED); + log.warn("⚠️ 옷 분석 모두 실패 - Task ID: {}, clothes ID 연결 생략 (피팅 결과는 유지)", taskId); } } catch (Exception e) { - log.error("❌ 가상 피팅 작업 시작 중 오류 발생 - Task ID: {}", taskId, e); - updateTaskStatus(taskId, FittingStatus.FAILED); + log.error("❌ 옷 분석 결과 연결 중 오류 - Task ID: {}", taskId, e); } }, taskExecutor); + // === 4. 모든 작업 완료 후 최종 로그 === + CompletableFuture.allOf(topAnalysisFuture, bottomAnalysisFuture, fittingFuture).thenRunAsync(() -> { + long totalElapsed = System.currentTimeMillis() - processStartTime; + log.info("🏁 전체 프로세스 완료 - Task ID: {}, 총 소요시간: {}초", taskId, String.format("%.1f", totalElapsed / 1000.0)); + }, taskExecutor); + } catch (Exception e) { log.error("❌ 가상 피팅 전체 프로세스 시작 중 오류 발생 - Task ID: {}", taskId, e); updateTaskStatus(taskId, FittingStatus.FAILED); diff --git a/src/main/java/com/example/Capstone_project/service/GeminiService.java b/src/main/java/com/example/Capstone_project/service/GeminiService.java index 92fb683..a0c75b8 100644 --- a/src/main/java/com/example/Capstone_project/service/GeminiService.java +++ b/src/main/java/com/example/Capstone_project/service/GeminiService.java @@ -58,7 +58,7 @@ public class GeminiService { @Value("${gemini.api.base-url:https://generativelanguage.googleapis.com/v1beta}") private String geminiBaseUrl; - @Value("${gemini.api.model:gemini-3-pro-image-preview}") + @Value("${gemini.api.model:gemini-3.1-flash-image-preview}") private String model; @Value("${gemini.api.analysis-model}") @@ -150,7 +150,7 @@ private String saveBase64ImageToFile(String imageBase64, String mimeType) throws String contentType = mimeType != null ? mimeType : "image/jpeg"; String gcsUrl = gcsService.uploadBase64Image(imageBase64, contentType); - log.info("✅ 이미지 GCS 업로드 완료 - URL: {}", gcsUrl); + log.info("Image uploaded to GCS - URL: {}", gcsUrl); // GCS 공개 URL 반환 return gcsUrl; @@ -403,20 +403,19 @@ public VirtualFittingResponse processVirtualFitting( throw new BadRequestException("Failed to generate image: " + e.getMessage()); } } + private static final int MAX_RETRIES = 3; + private static final long RETRY_BASE_DELAY_MS = 3000; + public VirtualFittingResponse processVirtualFitting(byte[] userImageBytes, byte[] topImageBytes, byte[] bottomImageBytes, String positivePrompt, String negativePrompt, String resolution) { try { - // 최소 하나는 필요 (컨트롤러에서 검증하지만 이중 체크) if (topImageBytes == null && bottomImageBytes == null) { throw new BadRequestException("At least one of top_image or bottom_image is required"); } - - // 1. 이미지 리사이징 (이미 구현된 메서드 활용) + byte[] resUser = resizeImageIfNeeded(userImageBytes); byte[] resTop = topImageBytes != null ? resizeImageIfNeeded(topImageBytes) : null; byte[] resBottom = bottomImageBytes != null ? resizeImageIfNeeded(bottomImageBytes) : null; - // 2. 형(동료)이 아래쪽에 짜놓은 진짜 요청 본문 생성기 호출! (중요) - // 형님 파일 아래쪽에 있는 'createGeminiRequestBody' 메서드를 그대로 씁니다. Map requestBody = createGeminiRequestBody( Base64.getEncoder().encodeToString(resUser), resTop != null ? Base64.getEncoder().encodeToString(resTop) : null, @@ -424,32 +423,14 @@ public VirtualFittingResponse processVirtualFitting(byte[] userImageBytes, byte[ positivePrompt, negativePrompt ); - log.info("🚀 비동기 AI 요청 시작..."); - -// ✅ 436행: 주소 설정 String endpoint = "/models/" + model + ":generateContent"; - log.info("📡 구글 AI에게 사진을 전달했습니다. 응답 대기 중... (최대 60초)"); + String responseString = callGeminiWithRetry(endpoint, requestBody); - String responseString = geminiWebClient.post() - .uri(uriBuilder -> uriBuilder - .path(endpoint) - .queryParam("key", geminiApiKey) - .build()) - .contentType(MediaType.APPLICATION_JSON) - .bodyValue(requestBody) - .retrieve() - .bodyToMono(String.class) - .timeout(java.time.Duration.ofSeconds(180)) - .block(); + log.info("Response received from Google AI. Parsing data..."); - // ✅ 2. 이 로그가 찍히는지 보는 게 핵심입니다! - log.info("📥 구글로부터 응답을 받았습니다! 데이터 해석을 시작합니다."); - - // ✅ 448행: 응답을 객체로 변환 GeminiGenerateContentResponse responseObj = objectMapper.readValue(responseString, GeminiGenerateContentResponse.class); - // ✅ 450행: 형(동료)의 진짜 이미지 추출 로직 이식 (여기서부터 중요!) GeminiGenerateContentResponse.Candidate candidate = responseObj.getCandidates().get(0); String imageBase64 = null; String mimeType = null; @@ -464,13 +445,11 @@ public VirtualFittingResponse processVirtualFitting(byte[] userImageBytes, byte[ if (imageBase64 == null) throw new BadRequestException("No image data in Gemini API response"); - // ✅ 형(동료)이 115행에 만든 진짜 파일 저장 메서드 호출! String imageUrl = saveBase64ImageToFile(imageBase64, mimeType); String imageId = "gemini-" + System.currentTimeMillis(); - log.info("💾 비동기 가상피팅 성공! 이미지 저장 경로: {}", imageUrl); + log.info("Async virtual fitting success! Image saved at: {}", imageUrl); - // ✅ 최종 결과 반환 (이 값이 FittingService를 통해 DB에 저장됩니다) return VirtualFittingResponse.builder() .imageId(imageId) .status("completed") @@ -478,11 +457,46 @@ public VirtualFittingResponse processVirtualFitting(byte[] userImageBytes, byte[ .build(); } catch (Exception e) { - log.error("💥 가상피팅 엔진 오류: {}", e.getMessage()); + log.error("Virtual fitting engine error: {}", e.getMessage()); throw new RuntimeException("AI 처리 실패: " + e.getMessage()); } } + /** + * Gemini API 호출 + 429 Rate Limit 시 자동 재시도 (최대 MAX_RETRIES회, 지수 백오프). + * 옷 분석과 가상 피팅이 동시에 실행되면서 429가 발생할 수 있어 재시도로 처리. + */ + private String callGeminiWithRetry(String endpoint, Map requestBody) { + for (int attempt = 1; attempt <= MAX_RETRIES; attempt++) { + try { + log.info("Gemini API 호출 시도 {}/{} - endpoint: {}", attempt, MAX_RETRIES, endpoint); + + return geminiWebClient.post() + .uri(uriBuilder -> uriBuilder + .path(endpoint) + .queryParam("key", geminiApiKey) + .build()) + .contentType(MediaType.APPLICATION_JSON) + .bodyValue(requestBody) + .retrieve() + .bodyToMono(String.class) + .timeout(java.time.Duration.ofSeconds(180)) + .block(); + + } catch (Exception e) { + boolean isRateLimit = e.getMessage() != null && e.getMessage().contains("429"); + if (isRateLimit && attempt < MAX_RETRIES) { + long delay = RETRY_BASE_DELAY_MS * attempt; + log.warn("⚠️ Gemini 429 Rate Limit - {}초 후 재시도 ({}/{})", delay / 1000, attempt + 1, MAX_RETRIES); + try { Thread.sleep(delay); } catch (InterruptedException ie) { Thread.currentThread().interrupt(); } + } else { + throw e; + } + } + } + throw new RuntimeException("Gemini API 호출 실패: 최대 재시도 횟수 초과"); + } + // ================================================================================= // [유틸 메서드] 리사이징 로직 (MultipartFile -> byte[] 로 변경됨) // ================================================================================= @@ -574,7 +588,7 @@ private void addInlineData(List> parts, String base64Data) { */ public String analyzeImageStyle(byte[] imageBytes, String prompt) { try { - log.info("🎨 Gemini API로 이미지 스타일 분석 시작 - 모델: {}", analysisModel); + log.info("Gemini API style analysis started - model: {}", analysisModel); // 이미지를 Base64로 인코딩 String imageBase64 = Base64.getEncoder().encodeToString(imageBytes); @@ -613,7 +627,7 @@ public String analyzeImageStyle(byte[] imageBytes, String prompt) { // Gemini API 호출 // v1beta 대신 v1 사용 (더 안정적) String endpoint = "/models/" + analysisModel + ":generateContent"; - log.info("📡 Gemini API 호출 - 엔드포인트: {}, 모델: {}", endpoint, analysisModel); + log.info("Gemini API call - endpoint: {}, model: {}", endpoint, analysisModel); GeminiGenerateContentResponse response = geminiWebClient.post() .uri(endpoint) @@ -624,14 +638,14 @@ public String analyzeImageStyle(byte[] imageBytes, String prompt) { .block(); if (response == null || response.getCandidates() == null || response.getCandidates().isEmpty()) { - log.error("Gemini API 분석 응답이 null이거나 비어있음"); + log.error("Gemini API analysis response is null or empty"); throw new BadRequestException("No response from Gemini API for style analysis"); } // 응답에서 텍스트 추출 GeminiGenerateContentResponse.Candidate candidate = response.getCandidates().get(0); if (candidate.getContent() == null || candidate.getContent().getParts() == null) { - log.error("Gemini API 응답에 content 또는 parts가 없음"); + log.error("Gemini API response has no content or parts"); throw new BadRequestException("Invalid response from Gemini API for style analysis"); } @@ -644,31 +658,31 @@ public String analyzeImageStyle(byte[] imageBytes, String prompt) { } if (analysisText.length() == 0) { - log.warn("Gemini API 응답에 텍스트가 없음"); + log.warn("Gemini API response has no text"); return "스타일 분석 결과를 받을 수 없습니다."; } String result = analysisText.toString(); - log.info("✅ Gemini API 스타일 분석 완료 - 결과 길이: {} 문자", result.length()); - log.debug("분석 결과: {}", result.substring(0, Math.min(200, result.length()))); + log.info("Gemini API style analysis done - result length: {} chars", result.length()); + log.debug("Analysis result: {}", result.substring(0, Math.min(200, result.length()))); return result; } catch (WebClientResponseException e) { String responseBody = e.getResponseBodyAsString(); - log.error("❌ Gemini API 스타일 분석 실패 - Status: {}, Response: {}, Model: {}", + log.error("Gemini API style analysis failed - Status: {}, Response: {}, Model: {}", e.getStatusCode(), responseBody, analysisModel, e); // 404 오류인 경우 더 자세한 정보 로깅 if (e.getStatusCode().value() == 404) { - log.error("⚠️ 모델을 찾을 수 없습니다. 모델 이름 확인 필요: {}", analysisModel); - log.error("⚠️ API Base URL: {}", geminiBaseUrl); - log.error("⚠️ 전체 엔드포인트: {}/models/{}:generateContent", geminiBaseUrl, analysisModel); + log.error("Model not found. Check model name: {}", analysisModel); + log.error("API Base URL: {}", geminiBaseUrl); + log.error("Full endpoint: {}/models/{}:generateContent", geminiBaseUrl, analysisModel); } throw new BadRequestException("Failed to analyze image style with Gemini API: " + e.getMessage()); } catch (Exception e) { - log.error("❌ Gemini API 스타일 분석 중 예외 발생", e); + log.error("Gemini API style analysis exception", e); throw new BadRequestException("Error analyzing image style: " + e.getMessage()); } } @@ -687,7 +701,7 @@ public ClothesAnalysisResultDto analyzeClothesImage(byte[] imageBytes, String pr return objectMapper.readValue(cleanJson, ClothesAnalysisResultDto.class); } catch (Exception e) { - log.error("❌ Gemini JSON 파싱 실패. 기본 객체를 반환합니다: {}", e.getMessage()); + log.error("Gemini JSON parse failed. Returning default object: {}", e.getMessage()); // 파싱 실패 시 에러 방지를 위해 빈 객체라도 반환 ClothesAnalysisResultDto fallback = new ClothesAnalysisResultDto(); fallback.setCategory("Unknown"); @@ -737,7 +751,7 @@ public StyleAnalysisResult analyzeImageStyleWithGender(byte[] imageBytes) { .resultGender(gender) .build(); } catch (Exception e) { - log.warn("스타일+성별 JSON 파싱 실패, 스타일만 반환 - {}", e.getMessage()); + log.warn("Style+gender JSON parse failed, returning style only - {}", e.getMessage()); return StyleAnalysisResult.builder() .styleAnalysis(rawResponse) .resultGender(null) @@ -783,13 +797,13 @@ public float[] embedText(String text, String taskType) { for (int i = 0; i < valuesNode.size(); i++) { result[i] = (float) valuesNode.get(i).asDouble(); } - log.info("✅ Gemini Embedding 완료 - 차원: {}", result.length); + log.info("Gemini Embedding done - dimensions: {}", result.length); return result; } catch (WebClientResponseException e) { - log.error("❌ Gemini Embedding API 실패 - {}", e.getResponseBodyAsString(), e); + log.error("Gemini Embedding API failed - {}", e.getResponseBodyAsString(), e); throw new BadRequestException("Embedding API failed: " + e.getMessage()); } catch (Exception e) { - log.error("❌ Embedding 변환 중 오류", e); + log.error("Embedding conversion error", e); throw new BadRequestException("Error creating embedding: " + e.getMessage()); } } From 531b48a5347d2f03d793c32c44463d7ea6f53611 Mon Sep 17 00:00:00 2001 From: dongan Date: Tue, 3 Mar 2026 00:57:58 +0900 Subject: [PATCH 3/5] =?UTF-8?q?feat:=20=EC=98=B7=20=EC=97=85=EB=A1=9C?= =?UTF-8?q?=EB=93=9C=20SSE=20=EC=95=8C=EB=A6=BC=20=EB=B0=8F=20API=20?= =?UTF-8?q?=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/ClothesController.java | 79 ++++++++++++++-- .../controller/VirtualFittingController.java | 53 ++++++----- .../domain/ClothesUploadStatus.java | 12 +++ .../domain/ClothesUploadTask.java | 50 ++++++++++ .../dto/ClothesRecommendationResponse.java | 32 +++++++ .../dto/ClothesUploadStatusResponse.java | 27 ++++++ .../dto/ClothesUploadTaskIdResponse.java | 16 ++++ .../ClothesUploadTaskRepository.java | 9 ++ .../service/ClothesUploadSseService.java | 94 +++++++++++++++++++ 9 files changed, 342 insertions(+), 30 deletions(-) create mode 100644 src/main/java/com/example/Capstone_project/domain/ClothesUploadStatus.java create mode 100644 src/main/java/com/example/Capstone_project/domain/ClothesUploadTask.java create mode 100644 src/main/java/com/example/Capstone_project/dto/ClothesRecommendationResponse.java create mode 100644 src/main/java/com/example/Capstone_project/dto/ClothesUploadStatusResponse.java create mode 100644 src/main/java/com/example/Capstone_project/dto/ClothesUploadTaskIdResponse.java create mode 100644 src/main/java/com/example/Capstone_project/repository/ClothesUploadTaskRepository.java create mode 100644 src/main/java/com/example/Capstone_project/service/ClothesUploadSseService.java diff --git a/src/main/java/com/example/Capstone_project/controller/ClothesController.java b/src/main/java/com/example/Capstone_project/controller/ClothesController.java index 495cc42..c492ada 100644 --- a/src/main/java/com/example/Capstone_project/controller/ClothesController.java +++ b/src/main/java/com/example/Capstone_project/controller/ClothesController.java @@ -1,14 +1,22 @@ package com.example.Capstone_project.controller; import com.example.Capstone_project.common.dto.ApiResponse; +import com.example.Capstone_project.common.exception.BadRequestException; +import com.example.Capstone_project.common.exception.ResourceNotFoundException; import com.example.Capstone_project.domain.Clothes; +import com.example.Capstone_project.domain.ClothesUploadStatus; +import com.example.Capstone_project.domain.ClothesUploadTask; import com.example.Capstone_project.domain.User; -import com.example.Capstone_project.dto.ClothesRequestDto; import com.example.Capstone_project.dto.ClothesResponseDto; +import com.example.Capstone_project.dto.ClothesUploadStatusResponse; +import com.example.Capstone_project.dto.ClothesUploadTaskIdResponse; import com.example.Capstone_project.repository.ClothesRepository; +import com.example.Capstone_project.repository.ClothesUploadTaskRepository; import com.example.Capstone_project.service.ClothesAnalysisService; +import com.example.Capstone_project.service.ClothesUploadSseService; import com.example.Capstone_project.service.GoogleCloudStorageService; import com.example.Capstone_project.service.RedisLockService; +import com.fasterxml.jackson.databind.ObjectMapper; import com.example.Capstone_project.config.CustomUserDetails; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; @@ -21,6 +29,7 @@ import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; import org.springframework.web.multipart.MultipartFile; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; import java.io.IOException; import java.util.List; @@ -34,16 +43,20 @@ public class ClothesController { private final ClothesRepository clothesRepository; + private final ClothesUploadTaskRepository clothesUploadTaskRepository; private final ClothesAnalysisService clothesAnalysisService; + private final ClothesUploadSseService clothesUploadSseService; private final GoogleCloudStorageService gcsService; private final RedisLockService redisLockService; + private final ObjectMapper objectMapper; @Operation( summary = "옷 등록", - description = "옷 사진 1장을 업로드하여 AI 분석 후 저장합니다. **비동기 처리** → 즉시 202 Accepted 반환, 백그라운드에서 분석·저장됩니다." + description = "옷 사진 1장을 업로드하여 AI 분석 후 저장합니다. **비동기 + SSE** → 202 Accepted와 taskId 반환. " + + "GET /api/v1/clothes/upload/{taskId}/stream 으로 진행 상황(이벤트 name=status)을 실시간 수신하세요." ) @PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE) - public ResponseEntity> uploadClothes( + public ResponseEntity> uploadClothes( @Parameter(description = "옷 이미지 파일", required = true) @RequestParam("file") MultipartFile file, @Parameter(description = "카테고리 (Top / Bottom / Shoes)", example = "Top", required = true) @RequestParam("category") String category, @AuthenticationPrincipal CustomUserDetails userDetails @@ -59,7 +72,6 @@ public ResponseEntity> uploadClothes( final Long userId = userDetails.getUser().getId(); final String lockKey = "lock:clothes-upload:" + userId; -// 1) 락 시도 if (!redisLockService.tryLock(lockKey, Duration.ofSeconds(8))) { return ResponseEntity.status(HttpStatus.CONFLICT) .body(ApiResponse.error("이미 옷 등록이 처리 중입니다. 잠시 후 다시 시도해주세요.")); @@ -68,17 +80,66 @@ public ResponseEntity> uploadClothes( try { byte[] imageBytes = file.getBytes(); String filename = file.getOriginalFilename(); - clothesAnalysisService.analyzeAndSaveClothesAsync(imageBytes, filename, category, userDetails.getUser()); + + ClothesUploadTask task = ClothesUploadTask.builder() + .userId(userId) + .category(category) + .status(ClothesUploadStatus.WAITING) + .build(); + task = clothesUploadTaskRepository.save(task); + Long taskId = task.getId(); + + clothesAnalysisService.startClothesUploadAndNotify(taskId, imageBytes, filename, category, userDetails.getUser()); + + ClothesUploadTaskIdResponse body = new ClothesUploadTaskIdResponse(taskId); + return ResponseEntity.status(HttpStatus.ACCEPTED) + .body(ApiResponse.success( + "옷 등록이 시작되었습니다. GET /api/v1/clothes/upload/" + taskId + "/stream 으로 진행 상황을 확인하세요.", + body)); } catch (IOException e) { return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) .body(ApiResponse.error("파일 읽기 실패: " + e.getMessage())); } + } + + @Operation( + summary = "옷 업로드 진행 상황 스트림 (SSE)", + description = "taskId에 대한 상태 변경을 실시간으로 수신합니다. 이벤트 name=status (가상 피팅과 동일). " + + "이미 COMPLETED/FAILED면 현재 상태 1회 전송 후 종료." + ) + @GetMapping(value = "/upload/{taskId}/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE) + public SseEmitter streamClothesUploadStatus( + @Parameter(description = "업로드 작업 ID", required = true) @PathVariable Long taskId, + @AuthenticationPrincipal CustomUserDetails userDetails + ) { + Long userId = userDetails.getUser().getId(); + ClothesUploadTask task = clothesUploadTaskRepository.findById(taskId) + .orElseThrow(() -> new ResourceNotFoundException("Clothes upload task not found: " + taskId)); + if (!userId.equals(task.getUserId())) { + throw new BadRequestException("해당 작업에 대한 권한이 없습니다."); + } - // -> “연타 방지” 목적이면 TTL로 자연 해제시키는 게 안전합니다. + ClothesUploadStatusResponse current = ClothesUploadStatusResponse.builder() + .taskId(task.getId()) + .status(task.getStatus()) + .clothesId(task.getClothesId()) + .errorMessage(task.getErrorMessage()) + .build(); + + if (task.getStatus() == ClothesUploadStatus.COMPLETED || task.getStatus() == ClothesUploadStatus.FAILED) { + SseEmitter emitter = new SseEmitter(60_000L); + clothesUploadSseService.sendOnceAndComplete(emitter, current); + return emitter; + } - return ResponseEntity.status(HttpStatus.ACCEPTED) - .body(ApiResponse.success("Clothes registration started. Processing in background.", - "옷 등록이 시작되었습니다. 백그라운드에서 분석 및 저장이 진행됩니다.")); + SseEmitter registered = clothesUploadSseService.register(taskId); + try { + registered.send(SseEmitter.event().name("status").data(objectMapper.writeValueAsString(current))); + } catch (IOException e) { + log.warn("SSE initial send failed for clothes upload taskId={}", taskId, e); + clothesUploadSseService.sendOnceAndComplete(registered, current); + } + return registered; } // @Operation( diff --git a/src/main/java/com/example/Capstone_project/controller/VirtualFittingController.java b/src/main/java/com/example/Capstone_project/controller/VirtualFittingController.java index c9ede28..79db638 100644 --- a/src/main/java/com/example/Capstone_project/controller/VirtualFittingController.java +++ b/src/main/java/com/example/Capstone_project/controller/VirtualFittingController.java @@ -31,6 +31,7 @@ import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.beans.factory.ObjectProvider; import org.springframework.web.bind.annotation.*; import org.springframework.web.multipart.MultipartFile; import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; @@ -60,7 +61,7 @@ public class VirtualFittingController { private final StyleRecommendationService styleRecommendationService; private final RedisLockService RedisLockService; private final VirtualFittingSseService virtualFittingSseService; - private final StringRedisTemplate stringRedisTemplate; + private final ObjectProvider stringRedisTemplateProvider; private final ObjectMapper objectMapper; @Value("${virtual-fitting.image.storage-path:./images/virtual-fitting}") @@ -219,15 +220,18 @@ public ResponseEntity> getFittingStatu final String cacheKey = "cache:fitting-status:" + taskId; try { - // 1) Redis 먼저 조회 - String cached = stringRedisTemplate.opsForValue().get(cacheKey); - if (cached != null) { - VirtualFittingStatusResponse cachedBody = - objectMapper.readValue(cached, VirtualFittingStatusResponse.class); - - return ResponseEntity.ok( - ApiResponse.success("Fitting task status retrieved (cached)", cachedBody) - ); + StringRedisTemplate redis = stringRedisTemplateProvider.getIfAvailable(); + if (redis != null) { + // 1) Redis 먼저 조회 + String cached = redis.opsForValue().get(cacheKey); + if (cached != null) { + VirtualFittingStatusResponse cachedBody = + objectMapper.readValue(cached, VirtualFittingStatusResponse.class); + + return ResponseEntity.ok( + ApiResponse.success("Fitting task status retrieved (cached)", cachedBody) + ); + } } // 2) Redis에 없으면 DB 조회 @@ -245,7 +249,9 @@ public ResponseEntity> getFittingStatu // 3) 10초 캐시 저장(폴링 방지) String json = objectMapper.writeValueAsString(body); - stringRedisTemplate.opsForValue().set(cacheKey, json, Duration.ofSeconds(10)); + if (redis != null) { + redis.opsForValue().set(cacheKey, json, Duration.ofSeconds(10)); + } return ResponseEntity.ok( ApiResponse.success("Fitting task status retrieved", body) @@ -271,15 +277,18 @@ public ResponseEntity> recommendByStyle final String cacheKey = "cache:style:" + userId + ":" + query; try { - //Redis 먼저 조회 - String cached = stringRedisTemplate.opsForValue().get(cacheKey); - if (cached != null) { - StyleRecommendationResponse cachedBody = - objectMapper.readValue(cached, StyleRecommendationResponse.class); - - return ResponseEntity.ok( - ApiResponse.success("스타일 추천 결과 (cached)", cachedBody) - ); + StringRedisTemplate redis = stringRedisTemplateProvider.getIfAvailable(); + if (redis != null) { + //Redis 먼저 조회 + String cached = redis.opsForValue().get(cacheKey); + if (cached != null) { + StyleRecommendationResponse cachedBody = + objectMapper.readValue(cached, StyleRecommendationResponse.class); + + return ResponseEntity.ok( + ApiResponse.success("스타일 추천 결과 (cached)", cachedBody) + ); + } } //실제 추천 실행 @@ -289,7 +298,9 @@ public ResponseEntity> recommendByStyle //Redis 60초 캐시 String json = objectMapper.writeValueAsString(body); - stringRedisTemplate.opsForValue().set(cacheKey, json, Duration.ofSeconds(60)); + if (redis != null) { + redis.opsForValue().set(cacheKey, json, Duration.ofSeconds(60)); + } return ResponseEntity.ok( ApiResponse.success("스타일 추천 결과", body) diff --git a/src/main/java/com/example/Capstone_project/domain/ClothesUploadStatus.java b/src/main/java/com/example/Capstone_project/domain/ClothesUploadStatus.java new file mode 100644 index 0000000..cfe436a --- /dev/null +++ b/src/main/java/com/example/Capstone_project/domain/ClothesUploadStatus.java @@ -0,0 +1,12 @@ +package com.example.Capstone_project.domain; + +/** + * 옷 업로드(분석) 작업 상태. + * 가상 피팅 FittingStatus와 동일한 흐름 (WAITING → PROCESSING → COMPLETED/FAILED). + */ +public enum ClothesUploadStatus { + WAITING, // 대기 + PROCESSING, // 분석 중 (GCS 업로드, AI 분석) + COMPLETED, // 완료 (Clothes 저장됨) + FAILED // 실패 +} diff --git a/src/main/java/com/example/Capstone_project/domain/ClothesUploadTask.java b/src/main/java/com/example/Capstone_project/domain/ClothesUploadTask.java new file mode 100644 index 0000000..636045b --- /dev/null +++ b/src/main/java/com/example/Capstone_project/domain/ClothesUploadTask.java @@ -0,0 +1,50 @@ +package com.example.Capstone_project.domain; + +import jakarta.persistence.*; +import lombok.*; + +import java.time.LocalDateTime; + +/** + * 옷 1건 업로드(분석) 작업. + * FittingTask와 동일 패턴: taskId 반환 → SSE로 상태 스트리밍. + */ +@Entity +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +@Table(name = "clothes_upload_tasks") +public class ClothesUploadTask { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "user_id", nullable = false) + private Long userId; + + @Column(nullable = false, length = 20) + private String category; // Top, Bottom, Shoes + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 20) + private ClothesUploadStatus status; + + @Column(name = "clothes_id") + private Long clothesId; // 완료 시 저장된 Clothes.id + + @Column(name = "error_message", length = 500) + private String errorMessage; // 실패 시 사유 + + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + @PrePersist + protected void onCreate() { + if (createdAt == null) { + createdAt = LocalDateTime.now(); + } + } +} diff --git a/src/main/java/com/example/Capstone_project/dto/ClothesRecommendationResponse.java b/src/main/java/com/example/Capstone_project/dto/ClothesRecommendationResponse.java new file mode 100644 index 0000000..ad5bb75 --- /dev/null +++ b/src/main/java/com/example/Capstone_project/dto/ClothesRecommendationResponse.java @@ -0,0 +1,32 @@ +package com.example.Capstone_project.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.util.List; + +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +@Schema(description = "상의/하의 추천 결과 (스타일 추천과 동일한 유사도 검색, 해당 카테고리 옷만 반환)") +public class ClothesRecommendationResponse { + + @Schema(description = "추천 옷 목록 (유사도 순)") + private List items; + + @Getter + @Builder + @NoArgsConstructor + @AllArgsConstructor + @Schema(description = "추천 옷 1건 (옷 정보 + 유사도 점수)") + public static class ClothesRecommendationItem { + @Schema(description = "옷 정보") + private ClothesResponseDto clothes; + @Schema(description = "유사도 점수 (0~1)") + private Double score; + } +} diff --git a/src/main/java/com/example/Capstone_project/dto/ClothesUploadStatusResponse.java b/src/main/java/com/example/Capstone_project/dto/ClothesUploadStatusResponse.java new file mode 100644 index 0000000..dbb7fac --- /dev/null +++ b/src/main/java/com/example/Capstone_project/dto/ClothesUploadStatusResponse.java @@ -0,0 +1,27 @@ +package com.example.Capstone_project.dto; + +import com.example.Capstone_project.domain.ClothesUploadStatus; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@Schema(description = "옷 업로드 작업 상태 (SSE 이벤트 name=status)") +public class ClothesUploadStatusResponse { + + @Schema(description = "작업 ID") + private Long taskId; + @Schema(description = "상태 (WAITING, PROCESSING, COMPLETED, FAILED)", example = "COMPLETED") + private ClothesUploadStatus status; + @Schema(description = "완료 시 저장된 옷(Clothes) ID, 미완료 시 null") + private Long clothesId; + @Schema(description = "실패 시 오류 메시지, 성공 시 null") + private String errorMessage; +} diff --git a/src/main/java/com/example/Capstone_project/dto/ClothesUploadTaskIdResponse.java b/src/main/java/com/example/Capstone_project/dto/ClothesUploadTaskIdResponse.java new file mode 100644 index 0000000..b8874c9 --- /dev/null +++ b/src/main/java/com/example/Capstone_project/dto/ClothesUploadTaskIdResponse.java @@ -0,0 +1,16 @@ +package com.example.Capstone_project.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.Getter; + +@Getter +@Data +@AllArgsConstructor +@Schema(description = "옷 업로드 작업 ID (SSE 스트림 구독용)") +public class ClothesUploadTaskIdResponse { + + @Schema(example = "1", description = "업로드 작업 ID. GET /api/v1/clothes/upload/{taskId}/stream 으로 진행 상황 수신") + private Long taskId; +} diff --git a/src/main/java/com/example/Capstone_project/repository/ClothesUploadTaskRepository.java b/src/main/java/com/example/Capstone_project/repository/ClothesUploadTaskRepository.java new file mode 100644 index 0000000..4fe944f --- /dev/null +++ b/src/main/java/com/example/Capstone_project/repository/ClothesUploadTaskRepository.java @@ -0,0 +1,9 @@ +package com.example.Capstone_project.repository; + +import com.example.Capstone_project.domain.ClothesUploadTask; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface ClothesUploadTaskRepository extends JpaRepository { +} diff --git a/src/main/java/com/example/Capstone_project/service/ClothesUploadSseService.java b/src/main/java/com/example/Capstone_project/service/ClothesUploadSseService.java new file mode 100644 index 0000000..208852e --- /dev/null +++ b/src/main/java/com/example/Capstone_project/service/ClothesUploadSseService.java @@ -0,0 +1,94 @@ +package com.example.Capstone_project.service; + +import com.example.Capstone_project.domain.ClothesUploadStatus; +import com.example.Capstone_project.dto.ClothesUploadStatusResponse; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; + +import java.io.IOException; +import java.util.concurrent.ConcurrentHashMap; + +/** + * 옷 업로드 작업별 SSE 구독 관리. + * 가상 피팅과 동일: task당 1연결, 이벤트 name="status". + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class ClothesUploadSseService { + + private static final long SSE_TIMEOUT_MS = 60_000L; // 1분 + + private final ObjectMapper objectMapper; + private final ConcurrentHashMap emitters = new ConcurrentHashMap<>(); + + public SseEmitter register(Long taskId) { + SseEmitter existing = emitters.remove(taskId); + if (existing != null) { + try { + existing.complete(); + } catch (Exception ignored) { } + } + + SseEmitter emitter = new SseEmitter(SSE_TIMEOUT_MS); + emitters.put(taskId, emitter); + + emitter.onCompletion(() -> { + emitters.remove(taskId, emitter); + log.debug("SSE completed for clothes upload taskId={}", taskId); + }); + emitter.onTimeout(() -> { + emitters.remove(taskId, emitter); + log.debug("SSE timeout for clothes upload taskId={}", taskId); + }); + emitter.onError(e -> { + emitters.remove(taskId, emitter); + log.warn("SSE error for clothes upload taskId={}: {}", taskId, e.getMessage()); + }); + + return emitter; + } + + public void notifyStatus(Long taskId, ClothesUploadStatusResponse response) { + SseEmitter emitter = emitters.get(taskId); + if (emitter == null) return; + + try { + String json = objectMapper.writeValueAsString(response); + emitter.send(SseEmitter.event().name("status").data(json)); + } catch (IOException e) { + log.warn("SSE send failed for clothes upload taskId={}: {}", taskId, e.getMessage()); + emitters.remove(taskId, emitter); + try { + emitter.completeWithError(e); + } catch (Exception ignored) { } + return; + } + + if (response.getStatus() == ClothesUploadStatus.COMPLETED || response.getStatus() == ClothesUploadStatus.FAILED) { + emitters.remove(taskId, emitter); + try { + emitter.complete(); + } catch (Exception ignored) { } + } + } + + public void sendOnceAndComplete(SseEmitter emitter, ClothesUploadStatusResponse response) { + try { + String json = objectMapper.writeValueAsString(response); + emitter.send(SseEmitter.event().name("status").data(json)); + } catch (JsonProcessingException e) { + log.warn("SSE JSON write failed: {}", e.getMessage()); + } catch (IOException e) { + log.warn("SSE send failed: {}", e.getMessage()); + } finally { + try { + emitter.complete(); + } catch (Exception ignored) { } + } + } +} From b3cf5e4cced9288559ab4a2a21c166f5a32b9c31 Mon Sep 17 00:00:00 2001 From: dongan Date: Tue, 3 Mar 2026 00:59:55 +0900 Subject: [PATCH 4/5] =?UTF-8?q?chore:=20=EB=AA=A8=EB=8B=88=ED=84=B0?= =?UTF-8?q?=EB=A7=81/Actuator=20=EC=B6=94=EA=B0=80,=20=EB=A1=9C=EA=B7=B8?= =?UTF-8?q?=20=EC=9D=B8=EC=BD=94=EB=94=A9=20=EC=88=98=EC=A0=95,=20DDocker?= =?UTF-8?q?=20healthcheck?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Made-with: Cursor --- Dockerfile | 4 ++ build.gradle | 18 +++++++++ docker-compose.yml | 8 ++++ gradlew.bat | 3 +- .../CapstoneProjectApplication.java | 2 + .../exception/GlobalExceptionHandler.java | 13 +++++-- .../config/SecurityConfig.java | 3 +- .../service/RedisLockService.java | 22 +++++++++-- .../service/RefreshTokenService.java | 15 +++++++- src/main/resources/logback-spring.xml | 37 +++++++++++++++++++ 10 files changed, 116 insertions(+), 9 deletions(-) create mode 100644 src/main/resources/logback-spring.xml diff --git a/Dockerfile b/Dockerfile index 9d9cd22..5fa585d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -16,6 +16,10 @@ RUN gradle clean build -x test --no-daemon FROM eclipse-temurin:21-jdk WORKDIR /app +# curl for actuator healthcheck +RUN apt-get update && apt-get install -y --no-install-recommends curl \ + && rm -rf /var/lib/apt/lists/* + # Copy the built JAR from build stage COPY --from=build /app/build/libs/*.jar app.jar diff --git a/build.gradle b/build.gradle index 0c2f2ee..78281d9 100644 --- a/build.gradle +++ b/build.gradle @@ -32,6 +32,9 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-data-redis' implementation 'org.springframework.boot:spring-boot-starter-validation' implementation 'org.springframework.boot:spring-boot-starter-oauth2-client' + implementation 'org.springframework.boot:spring-boot-starter-actuator' + // JSON 로깅 (ELK/모니터링 연동 대비) + implementation 'net.logstash.logback:logstash-logback-encoder:7.4' // SpringDoc OpenAPI (Swagger) // Spring Boot 4.0.x / Spring Framework 6.2+ 호환을 위한 최신 버전 implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.0' @@ -52,6 +55,21 @@ dependencies { } +tasks.withType(JavaCompile).configureEach { + options.encoding = 'UTF-8' +} + tasks.named('test') { useJUnitPlatform() + jvmArgs = ['-Dfile.encoding=UTF-8', '-Dstdout.encoding=UTF-8', '-Dstderr.encoding=UTF-8'] +} + +tasks.named('bootRun') { + jvmArgs = [ + '-Dfile.encoding=UTF-8', + '-Dstdout.encoding=UTF-8', + '-Dstderr.encoding=UTF-8', + '-Dsun.stdout.encoding=UTF-8', + '-Dsun.stderr.encoding=UTF-8' + ] } diff --git a/docker-compose.yml b/docker-compose.yml index 8a9001e..2526bf8 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -44,7 +44,15 @@ services: restart: always ports: - "${SERVER_PORT}:8080" + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8080/actuator/health"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 40s environment: + SERVER_PORT: 8080 + SPRING_PROFILES_ACTIVE: docker SPRING_DATASOURCE_URL: ${SPRING_DATASOURCE_URL} SPRING_DATASOURCE_USERNAME: ${SPRING_DATASOURCE_USERNAME} SPRING_DATASOURCE_PASSWORD: ${SPRING_DATASOURCE_PASSWORD} diff --git a/gradlew.bat b/gradlew.bat index c4bdd3a..5c407a1 100644 --- a/gradlew.bat +++ b/gradlew.bat @@ -36,7 +36,7 @@ set APP_HOME=%DIRNAME% for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. -set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" "-Dfile.encoding=UTF-8" "-Dstdout.encoding=UTF-8" "-Dstderr.encoding=UTF-8" "-Dsun.stdout.encoding=UTF-8" "-Dsun.stderr.encoding=UTF-8" @rem Find java.exe if defined JAVA_HOME goto findJavaFromJavaHome @@ -69,6 +69,7 @@ goto fail :execute @rem Setup the command line +chcp 65001 >NUL 2>&1 diff --git a/src/main/java/com/example/Capstone_project/CapstoneProjectApplication.java b/src/main/java/com/example/Capstone_project/CapstoneProjectApplication.java index a9fbbc7..08cef86 100644 --- a/src/main/java/com/example/Capstone_project/CapstoneProjectApplication.java +++ b/src/main/java/com/example/Capstone_project/CapstoneProjectApplication.java @@ -3,11 +3,13 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.data.jpa.repository.config.EnableJpaAuditing; +import org.springframework.data.jpa.repository.config.EnableJpaRepositories; import org.springframework.scheduling.annotation.EnableAsync; @EnableAsync @SpringBootApplication @EnableJpaAuditing +@EnableJpaRepositories(basePackages = "com.example.Capstone_project.repository") public class CapstoneProjectApplication { public static void main(String[] args) { diff --git a/src/main/java/com/example/Capstone_project/common/exception/GlobalExceptionHandler.java b/src/main/java/com/example/Capstone_project/common/exception/GlobalExceptionHandler.java index 4b3fd78..4b850bc 100644 --- a/src/main/java/com/example/Capstone_project/common/exception/GlobalExceptionHandler.java +++ b/src/main/java/com/example/Capstone_project/common/exception/GlobalExceptionHandler.java @@ -1,36 +1,43 @@ package com.example.Capstone_project.common.exception; import com.example.Capstone_project.common.dto.ApiResponse; +import lombok.extern.slf4j.Slf4j; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; +@Slf4j @RestControllerAdvice public class GlobalExceptionHandler { @ExceptionHandler(ResourceNotFoundException.class) public ResponseEntity> handleResourceNotFoundException(ResourceNotFoundException e) { + log.warn("Resource not found: {}", e.getMessage()); return ResponseEntity.status(HttpStatus.NOT_FOUND) .body(ApiResponse.error(e.getMessage())); } - + @ExceptionHandler(BadRequestException.class) public ResponseEntity> handleBadRequestException(BadRequestException e) { + log.warn("Bad request: {}", e.getMessage()); return ResponseEntity.status(HttpStatus.BAD_REQUEST) .body(ApiResponse.error(e.getMessage())); } @ExceptionHandler(IllegalArgumentException.class) public ResponseEntity> handleIllegalArgument(IllegalArgumentException e) { + log.warn("Illegal argument: {}", e.getMessage()); return ResponseEntity.status(HttpStatus.UNAUTHORIZED) .body(ApiResponse.error(e.getMessage())); } - + @ExceptionHandler(Exception.class) public ResponseEntity> handleException(Exception e) { + log.error("Unhandled exception: {} - {}", e.getClass().getSimpleName(), e.getMessage(), e); + String clientMessage = "일시적인 오류가 발생했습니다. 잠시 후 다시 시도해 주세요."; return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) - .body(ApiResponse.error("Internal server error: " + e.getMessage())); + .body(ApiResponse.error(clientMessage)); } } diff --git a/src/main/java/com/example/Capstone_project/config/SecurityConfig.java b/src/main/java/com/example/Capstone_project/config/SecurityConfig.java index c00439a..3d1370a 100644 --- a/src/main/java/com/example/Capstone_project/config/SecurityConfig.java +++ b/src/main/java/com/example/Capstone_project/config/SecurityConfig.java @@ -1,6 +1,5 @@ package com.example.Capstone_project.config; -import lombok.RequiredArgsConstructor; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Lazy; @@ -37,6 +36,8 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti .csrf(csrf -> csrf.disable()) .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .authorizeHttpRequests(auth -> auth + // Actuator 헬스체크 (Docker/로드밸런서용) + .requestMatchers("/actuator/health", "/actuator/info").permitAll() // Swagger 및 API 문서 .requestMatchers("/v3/api-docs/**", "/v3/api-docs").permitAll() .requestMatchers("/swagger-ui/**", "/swagger-ui.html").permitAll() diff --git a/src/main/java/com/example/Capstone_project/service/RedisLockService.java b/src/main/java/com/example/Capstone_project/service/RedisLockService.java index 383a601..b8f0e43 100644 --- a/src/main/java/com/example/Capstone_project/service/RedisLockService.java +++ b/src/main/java/com/example/Capstone_project/service/RedisLockService.java @@ -1,26 +1,42 @@ package com.example.Capstone_project.service; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.ObjectProvider; import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.stereotype.Service; import java.time.Duration; +@Slf4j @Service public class RedisLockService { - private final StringRedisTemplate redisTemplate; + private final ObjectProvider redisTemplateProvider; + private volatile boolean warnedNoRedis = false; - public RedisLockService(StringRedisTemplate redisTemplate) { - this.redisTemplate = redisTemplate; + public RedisLockService(ObjectProvider redisTemplateProvider) { + this.redisTemplateProvider = redisTemplateProvider; } /** lockKey가 없으면 생성하고 true, 이미 있으면 false */ public boolean tryLock(String lockKey, Duration ttl) { + StringRedisTemplate redisTemplate = redisTemplateProvider.getIfAvailable(); + if (redisTemplate == null) { + if (!warnedNoRedis) { + warnedNoRedis = true; + log.warn("Redis is not configured; RedisLockService will allow all locks (lockKey={})", lockKey); + } + return true; // Redis 미구성 시 락을 강제하지 않음 + } Boolean ok = redisTemplate.opsForValue().setIfAbsent(lockKey, "1", ttl); return Boolean.TRUE.equals(ok); } public void unlock(String lockKey) { + StringRedisTemplate redisTemplate = redisTemplateProvider.getIfAvailable(); + if (redisTemplate == null) { + return; + } redisTemplate.delete(lockKey); } } \ No newline at end of file diff --git a/src/main/java/com/example/Capstone_project/service/RefreshTokenService.java b/src/main/java/com/example/Capstone_project/service/RefreshTokenService.java index 6aca35a..d47f364 100644 --- a/src/main/java/com/example/Capstone_project/service/RefreshTokenService.java +++ b/src/main/java/com/example/Capstone_project/service/RefreshTokenService.java @@ -2,6 +2,7 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.ObjectProvider; import org.springframework.beans.factory.annotation.Value; import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.stereotype.Service; @@ -20,13 +21,17 @@ public class RefreshTokenService { private static final String KEY_PREFIX = "refresh:"; - private final StringRedisTemplate redisTemplate; + private final ObjectProvider redisTemplateProvider; @Value("${app.auth.refresh-token-ttl-days:1}") private long ttlDays; /** refreshToken 생성 후 Redis에 저장, 토큰 값 반환 */ public String createAndStore(String email) { + StringRedisTemplate redisTemplate = redisTemplateProvider.getIfAvailable(); + if (redisTemplate == null) { + throw new IllegalStateException("Redis is not configured (StringRedisTemplate bean missing)"); + } String token = UUID.randomUUID().toString().replace("-", ""); String key = KEY_PREFIX + token; redisTemplate.opsForValue().set(key, email, ttlDays, TimeUnit.DAYS); @@ -37,6 +42,10 @@ public String createAndStore(String email) { /** refreshToken으로 email 조회. 유효하면 email, 아니면 null */ public String getEmailByToken(String refreshToken) { if (refreshToken == null || refreshToken.isBlank()) return null; + StringRedisTemplate redisTemplate = redisTemplateProvider.getIfAvailable(); + if (redisTemplate == null) { + return null; + } try { String key = KEY_PREFIX + refreshToken; return redisTemplate.opsForValue().get(key); @@ -49,6 +58,10 @@ public String getEmailByToken(String refreshToken) { /** refreshToken 무효화 (로그아웃 시) */ public void invalidate(String refreshToken) { if (refreshToken == null || refreshToken.isBlank()) return; + StringRedisTemplate redisTemplate = redisTemplateProvider.getIfAvailable(); + if (redisTemplate == null) { + return; + } try { redisTemplate.delete(KEY_PREFIX + refreshToken); log.debug("RefreshToken 무효화 완료"); diff --git a/src/main/resources/logback-spring.xml b/src/main/resources/logback-spring.xml new file mode 100644 index 0000000..67bf958 --- /dev/null +++ b/src/main/resources/logback-spring.xml @@ -0,0 +1,37 @@ + + + + + + + + traceId + spanId + {"application":"${spring.application.name:-capstone}"} + + @timestamp + [ignore] + + + + + + + + %d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n + UTF-8 + + + + + + + + + + + + + + + From fc6e831248297c8d694d41242c0bc97e52d3fe48 Mon Sep 17 00:00:00 2001 From: dongan Date: Tue, 3 Mar 2026 01:39:40 +0900 Subject: [PATCH 5/5] =?UTF-8?q?fix:=20redis=20=EC=B0=B8=EC=A1=B0=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/VirtualFittingController.java | 44 +++++++------------ 1 file changed, 15 insertions(+), 29 deletions(-) diff --git a/src/main/java/com/example/Capstone_project/controller/VirtualFittingController.java b/src/main/java/com/example/Capstone_project/controller/VirtualFittingController.java index 79db638..776eaf6 100644 --- a/src/main/java/com/example/Capstone_project/controller/VirtualFittingController.java +++ b/src/main/java/com/example/Capstone_project/controller/VirtualFittingController.java @@ -11,22 +11,14 @@ import com.example.Capstone_project.service.ClothesAnalysisService; import com.example.Capstone_project.service.FittingService; import com.example.Capstone_project.service.RedisLockService; -import com.example.Capstone_project.service.GoogleCloudStorageService; import com.example.Capstone_project.service.StyleRecommendationService; import com.example.Capstone_project.service.VirtualFittingSseService; import com.fasterxml.jackson.databind.ObjectMapper; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; -import io.swagger.v3.oas.annotations.media.Content; -import io.swagger.v3.oas.annotations.media.Schema; -import io.swagger.v3.oas.annotations.responses.ApiResponses; import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.core.io.FileSystemResource; -import org.springframework.core.io.Resource; -import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; @@ -37,11 +29,7 @@ import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; import org.springframework.data.redis.core.StringRedisTemplate; -import java.io.File; import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Path; -import java.nio.file.Paths; import java.time.Duration; import java.util.List; import java.util.concurrent.CompletableFuture; @@ -56,7 +44,6 @@ public class VirtualFittingController { private final FittingService fittingService; private final ClothesAnalysisService clothesAnalysisService; - private final GoogleCloudStorageService gcsService; private final Executor taskExecutor; private final StyleRecommendationService styleRecommendationService; private final RedisLockService RedisLockService; @@ -64,9 +51,6 @@ public class VirtualFittingController { private final ObjectProvider stringRedisTemplateProvider; private final ObjectMapper objectMapper; - @Value("${virtual-fitting.image.storage-path:./images/virtual-fitting}") - private String imageStoragePath; - @Operation( summary = "가상 피팅 요청", description = "전신 사진 + 상의(필수) + 하의(선택)를 업로드하여 가상 피팅을 요청합니다. **비동기 처리** → 즉시 202 Accepted + taskId 반환. 이후 `/status/{taskId}`로 진행 상태를 폴링하세요." @@ -325,27 +309,29 @@ public ResponseEntity> recommendByWeath final String cacheKey = "cache:weather-style:" + userId + ":" + query + ":" + tempKey; try { - //Redis 먼저 조회 - String cached = stringRedisTemplate.opsForValue().get(cacheKey); - if (cached != null) { - StyleRecommendationResponse cachedBody = - objectMapper.readValue(cached, StyleRecommendationResponse.class); - - return ResponseEntity.ok( - ApiResponse.success("날씨 기반 스타일 추천 결과 (cached)", cachedBody) - ); + StringRedisTemplate redis = stringRedisTemplateProvider.getIfAvailable(); + + if (redis != null) { + String cached = redis.opsForValue().get(cacheKey); + if (cached != null) { + StyleRecommendationResponse cachedBody = + objectMapper.readValue(cached, StyleRecommendationResponse.class); + return ResponseEntity.ok( + ApiResponse.success("날씨 기반 스타일 추천 결과 (cached)", cachedBody) + ); + } } - //실제 추천 실행 var recommendations = styleRecommendationService .recommendByWeatherStyle(query, 0.7, userId, temp); StyleRecommendationResponse body = StyleRecommendationResponse.from(recommendations); - //Redis 60초 캐시 - String json = objectMapper.writeValueAsString(body); - stringRedisTemplate.opsForValue().set(cacheKey, json, java.time.Duration.ofSeconds(60)); + if (redis != null) { + String json = objectMapper.writeValueAsString(body); + redis.opsForValue().set(cacheKey, json, java.time.Duration.ofSeconds(60)); + } return ResponseEntity.ok( ApiResponse.success("날씨 기반 스타일 추천 결과", body)