diff --git a/build.gradle b/build.gradle index 3d3699c..894eb7a 100644 --- a/build.gradle +++ b/build.gradle @@ -54,6 +54,7 @@ dependencies { // Test testImplementation 'org.springframework.boot:spring-boot-starter-test' testImplementation 'org.springframework.security:spring-security-test' + testRuntimeOnly 'com.h2database:h2' testCompileOnly 'org.projectlombok:lombok' testAnnotationProcessor 'org.projectlombok:lombok' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' diff --git a/src/main/java/com/closetnangam/be/domain/ai/dto/response/AiAnalyzeResponse.java b/src/main/java/com/closetnangam/be/domain/ai/dto/response/AiAnalyzeResponse.java index e69de29..a7bea9a 100644 --- a/src/main/java/com/closetnangam/be/domain/ai/dto/response/AiAnalyzeResponse.java +++ b/src/main/java/com/closetnangam/be/domain/ai/dto/response/AiAnalyzeResponse.java @@ -0,0 +1,21 @@ +package com.closetnangam.be.domain.ai.dto.response; + +import com.closetnangam.be.domain.ai.enums.AiAnalysisStatus; + +import java.util.List; + +public record AiAnalyzeResponse( + Long photoId, + AiAnalysisStatus analysisStatus, + String previewUrl, + String failureMessage, + Boolean aiFailed, + String name, + String brandName, + String category, + String itemType, + String primaryColor, + List secondaryColors, + List styles +) { +} diff --git a/src/main/java/com/closetnangam/be/domain/ai/entity/ClothingAiPhoto.java b/src/main/java/com/closetnangam/be/domain/ai/entity/ClothingAiPhoto.java index e69de29..b2c105a 100644 --- a/src/main/java/com/closetnangam/be/domain/ai/entity/ClothingAiPhoto.java +++ b/src/main/java/com/closetnangam/be/domain/ai/entity/ClothingAiPhoto.java @@ -0,0 +1,149 @@ +package com.closetnangam.be.domain.ai.entity; + +import com.closetnangam.be.domain.ai.enums.AiAnalysisStatus; +import com.closetnangam.be.domain.user.entity.User; +import com.closetnangam.be.global.common.entity.BaseEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Table(name = "clothing_ai_photos") +public class ClothingAiPhoto extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "clothing_ai_photo_id") + private Long id; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "user_id", nullable = false) + private User user; + + @Column(name = "image_url", nullable = false, length = 500) + private String imageUrl; + + @Column(name = "stored_path", nullable = false, length = 500) + private String storedPath; + + @Column(name = "original_filename", nullable = false, length = 255) + private String originalFilename; + + @Column(name = "content_type", nullable = false, length = 100) + private String contentType; + + @Enumerated(EnumType.STRING) + @Column(name = "analysis_status", nullable = false, length = 30) + private AiAnalysisStatus analysisStatus; + + @Column(name = "failure_message", length = 500) + private String failureMessage; + + @Column(name = "draft_name", length = 255) + private String draftName; + + @Column(name = "draft_brand_name", length = 100) + private String draftBrandName; + + @Column(name = "draft_category", length = 50) + private String draftCategory; + + @Column(name = "draft_item_type", length = 50) + private String draftItemType; + + @Column(name = "draft_color", length = 50) + private String draftPrimaryColor; + + @Column(name = "draft_secondary_colors_json", columnDefinition = "TEXT") + private String draftSecondaryColorsJson; + + @Column(name = "draft_styles_json", columnDefinition = "TEXT") + private String draftStylesJson; + + @Column(name = "raw_ai_response", columnDefinition = "TEXT") + private String rawAiResponse; + + @Column(name = "saved_clothes_id") + private Long savedClothesId; + + @Column(name = "saved_wardrobe_clothes_id") + private Long savedWardrobeClothesId; + + @Builder + private ClothingAiPhoto( + User user, + String imageUrl, + String storedPath, + String originalFilename, + String contentType, + AiAnalysisStatus analysisStatus + ) { + this.user = user; + this.imageUrl = imageUrl; + this.storedPath = storedPath; + this.originalFilename = originalFilename; + this.contentType = contentType; + this.analysisStatus = analysisStatus; + } + + public void markAnalyzing() { + this.analysisStatus = AiAnalysisStatus.ANALYZING; + this.failureMessage = null; + } + + public void applyAnalysisSuccess( + String draftName, + String draftBrandName, + String draftCategory, + String draftItemType, + String draftPrimaryColor, + String draftSecondaryColorsJson, + String draftStylesJson, + String rawAiResponse + ) { + this.analysisStatus = AiAnalysisStatus.SUCCESS; + this.failureMessage = null; + this.draftName = draftName; + this.draftBrandName = draftBrandName; + this.draftCategory = draftCategory; + this.draftItemType = draftItemType; + this.draftPrimaryColor = draftPrimaryColor; + this.draftSecondaryColorsJson = draftSecondaryColorsJson; + this.draftStylesJson = draftStylesJson; + this.rawAiResponse = rawAiResponse; + } + + public void applyAnalysisFailure(String failureMessage, String rawAiResponse) { + this.analysisStatus = AiAnalysisStatus.FAILED; + this.failureMessage = failureMessage; + this.rawAiResponse = rawAiResponse; + } + + public void markSaved(Long clothesId, Long wardrobeClothesId) { + this.analysisStatus = AiAnalysisStatus.SAVED; + this.savedClothesId = clothesId; + this.savedWardrobeClothesId = wardrobeClothesId; + } + + public boolean isOwnedBy(Long userId) { + return user.getId().equals(userId); + } + + public boolean isAlreadySaved() { + return analysisStatus == AiAnalysisStatus.SAVED; + } +} diff --git a/src/main/java/com/closetnangam/be/domain/ai/enums/AiAnalysisStatus.java b/src/main/java/com/closetnangam/be/domain/ai/enums/AiAnalysisStatus.java new file mode 100644 index 0000000..2466d2b --- /dev/null +++ b/src/main/java/com/closetnangam/be/domain/ai/enums/AiAnalysisStatus.java @@ -0,0 +1,10 @@ +package com.closetnangam.be.domain.ai.enums; + +public enum AiAnalysisStatus { + + UPLOADED, + ANALYZING, + SUCCESS, + FAILED, + SAVED +} diff --git a/src/main/java/com/closetnangam/be/domain/ai/repository/ClothingAiPhotoRepository.java b/src/main/java/com/closetnangam/be/domain/ai/repository/ClothingAiPhotoRepository.java index e69de29..3305d84 100644 --- a/src/main/java/com/closetnangam/be/domain/ai/repository/ClothingAiPhotoRepository.java +++ b/src/main/java/com/closetnangam/be/domain/ai/repository/ClothingAiPhotoRepository.java @@ -0,0 +1,19 @@ +package com.closetnangam.be.domain.ai.repository; + +import com.closetnangam.be.domain.ai.entity.ClothingAiPhoto; +import jakarta.persistence.LockModeType; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.Optional; + +public interface ClothingAiPhotoRepository extends JpaRepository { + + Optional findByIdAndUser_Id(Long id, Long userId); + + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("SELECT p FROM ClothingAiPhoto p WHERE p.id = :id AND p.user.id = :userId") + Optional findByIdAndUser_IdForUpdate(@Param("id") Long id, @Param("userId") Long userId); +} diff --git a/src/main/java/com/closetnangam/be/domain/ai/service/AiService.java b/src/main/java/com/closetnangam/be/domain/ai/service/AiService.java index b090f22..fa3cfba 100644 --- a/src/main/java/com/closetnangam/be/domain/ai/service/AiService.java +++ b/src/main/java/com/closetnangam/be/domain/ai/service/AiService.java @@ -1,4 +1,237 @@ package com.closetnangam.be.domain.ai.service; +import com.closetnangam.be.domain.ai.dto.response.AiAnalyzeResponse; +import com.closetnangam.be.domain.ai.entity.ClothingAiPhoto; +import com.closetnangam.be.domain.ai.enums.AiAnalysisStatus; +import com.closetnangam.be.domain.ai.repository.ClothingAiPhotoRepository; +import com.closetnangam.be.domain.catalog.service.CategoryCatalogService; +import com.closetnangam.be.global.external.gemini.GeminiService; +import com.closetnangam.be.global.external.gemini.dto.GeminiClothingClassificationResult; +import com.closetnangam.be.global.storage.LocalImageStorageService; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Service; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.support.TransactionTemplate; +import org.springframework.util.StringUtils; + +import java.util.Collections; +import java.util.List; + +@Service public class AiService { + + private static final Logger log = LoggerFactory.getLogger(AiService.class); + + private final ClothingAiPhotoRepository clothingAiPhotoRepository; + private final CategoryCatalogService categoryCatalogService; + private final GeminiService geminiService; + private final LocalImageStorageService localImageStorageService; + private final ObjectMapper objectMapper; + private final TransactionTemplate transactionTemplate; + + public AiService( + ClothingAiPhotoRepository clothingAiPhotoRepository, + CategoryCatalogService categoryCatalogService, + GeminiService geminiService, + LocalImageStorageService localImageStorageService, + ObjectMapper objectMapper, + PlatformTransactionManager transactionManager + ) { + this.clothingAiPhotoRepository = clothingAiPhotoRepository; + this.categoryCatalogService = categoryCatalogService; + this.geminiService = geminiService; + this.localImageStorageService = localImageStorageService; + this.objectMapper = objectMapper; + this.transactionTemplate = new TransactionTemplate(transactionManager); + } + + // 트랜잭션을 외부 API 호출 구간에서 분리해 DB 커넥션 점유를 최소화 + public AiAnalyzeResponse analyzeClothingPhoto(Long userId, Long photoId) { + // Phase 1: 소유권 확인 + ANALYZING 상태 기록 (단기 트랜잭션) + // FOR UPDATE: 저장 API와 동일한 row lock 규칙으로 직렬화하여 + // 저장 트랜잭션이 SAVED로 커밋한 뒤 markAnalyzing()이 그 상태를 덮어쓰는 것을 방지 + PhotoAnalysisContext ctx = transactionTemplate.execute(status -> { + ClothingAiPhoto photo = clothingAiPhotoRepository.findByIdAndUser_IdForUpdate(photoId, userId) + .orElseThrow(() -> new IllegalArgumentException("업로드한 사진을 찾을 수 없습니다.")); + if (photo.isAlreadySaved()) { + throw new IllegalStateException("이미 저장된 사진은 다시 분석할 수 없습니다."); + } + photo.markAnalyzing(); + return new PhotoAnalysisContext(photo.getStoredPath(), photo.getContentType()); + }); + if (ctx == null) { + throw new IllegalStateException("분석 준비 중 오류가 발생했습니다."); + } + + // Phase 2: 파일 읽기 + Gemini API 호출 (DB 커넥션 미보유) + // Exception을 포함한 모든 예외를 포착해 photo가 ANALYZING 상태로 영구 고착되는 것을 방지 + GeminiClothingClassificationResult result = null; + String failureMessage = null; + try { + byte[] imageBytes = localImageStorageService.readStoredImage(ctx.storedPath()); + result = geminiService.classifyClothingImage( + imageBytes, + ctx.contentType(), + categoryCatalogService.getAiClassificationGuide() + ); + validateClassificationResult(result); + } catch (IllegalArgumentException e) { + // 파일 미존재 등 입력 문제 + log.warn("[AI분석] 이미지 파일을 읽지 못했습니다. userId={}, photoId={}: {}", userId, photoId, e.getMessage()); + failureMessage = "업로드된 이미지를 찾을 수 없습니다."; + } catch (IllegalStateException e) { + // Gemini API 실패, 응답 파싱 실패, AI 결과 불충분(유효하지 않은 category/color/style 포함) + log.warn("[AI분석] AI 분석 실패. userId={}, photoId={}: {}", userId, photoId, e.getMessage()); + failureMessage = "AI가 옷 이미지를 분석하지 못했습니다. 직접 입력해 주세요."; + } catch (Exception e) { + // 예기치 않은 런타임 오류도 FAILED로 처리해 ANALYZING 상태 고착 방지 + log.error("[AI분석] 예상치 못한 오류. userId={}, photoId={}", userId, photoId, e); + failureMessage = "AI 분석 중 예기치 않은 오류가 발생했습니다. 직접 입력해 주세요."; + } + + // Phase 3: 분석 결과 저장 (단기 트랜잭션) + final GeminiClothingClassificationResult finalResult = result; + final String finalFailure = failureMessage; + AiAnalyzeResponse response = transactionTemplate.execute(status -> { + // FOR UPDATE: 저장 API의 row lock과 동일한 규칙으로 직렬화하여 + // 저장 트랜잭션이 먼저 커밋된 경우 SAVED 상태를 반드시 확인하고 덮어쓰지 않도록 보장 + ClothingAiPhoto photo = clothingAiPhotoRepository.findByIdAndUser_IdForUpdate(photoId, userId) + .orElseThrow(() -> new IllegalArgumentException("업로드한 사진을 찾을 수 없습니다.")); + // 분석 대기 중 사용자가 저장을 완료한 경우 분석 결과로 SAVED 상태를 덮어쓰지 않음 + if (photo.isAlreadySaved()) { + return toAnalyzeResponse(photo); + } + if (finalResult != null) { + photo.applyAnalysisSuccess( + finalResult.name(), + finalResult.brandName(), + finalResult.category(), + finalResult.itemType(), + finalResult.primaryColor(), + toColorsJson(finalResult.secondaryColors()), + toStylesJson(finalResult.styles()), + toRawJson(finalResult) + ); + } else { + photo.applyAnalysisFailure(finalFailure, null); + } + return toAnalyzeResponse(photo); + }); + if (response == null) { + throw new IllegalStateException("분석 결과 저장 중 오류가 발생했습니다."); + } + return response; + } + + @Transactional(readOnly = true) + public AiAnalyzeResponse getAnalyzeResult(Long userId, Long photoId) { + return toAnalyzeResponse(getOwnedPhoto(userId, photoId)); + } + + private ClothingAiPhoto getOwnedPhoto(Long userId, Long photoId) { + return clothingAiPhotoRepository.findByIdAndUser_Id(photoId, userId) + .orElseThrow(() -> new IllegalArgumentException("업로드한 사진을 찾을 수 없습니다.")); + } + + private void validateClassificationResult(GeminiClothingClassificationResult result) { + if (!StringUtils.hasText(result.name()) + || !StringUtils.hasText(result.category()) + || !StringUtils.hasText(result.itemType()) + || !StringUtils.hasText(result.primaryColor()) + || result.styles() == null + || result.styles().isEmpty()) { + throw new IllegalStateException("AI 판별 결과가 충분하지 않습니다."); + } + try { + categoryCatalogService.validateCategoryAndItemType(result.category(), result.itemType()); + categoryCatalogService.validateClothesColors(result.primaryColor(), normalizeSecondaryColors(result.secondaryColors())); + categoryCatalogService.validateStyleCodes(result.styles()); + } catch (IllegalArgumentException e) { + // AI가 유효하지 않은 category/color/style 코드를 반환한 경우 + throw new IllegalStateException("AI가 유효하지 않은 분류 결과를 반환했습니다: " + e.getMessage(), e); + } + } + + private List normalizeSecondaryColors(List secondaryColors) { + return secondaryColors == null ? Collections.emptyList() : secondaryColors; + } + + private String toColorsJson(List secondaryColors) { + try { + return objectMapper.writeValueAsString(normalizeSecondaryColors(secondaryColors)); + } catch (JsonProcessingException exception) { + throw new IllegalStateException("AI 판별 결과를 저장하지 못했습니다."); + } + } + + private String toStylesJson(List styles) { + try { + return objectMapper.writeValueAsString(styles); + } catch (JsonProcessingException exception) { + throw new IllegalStateException("AI 판별 결과를 저장하지 못했습니다."); + } + } + + private String toRawJson(GeminiClothingClassificationResult result) { + try { + return objectMapper.writeValueAsString(result); + } catch (JsonProcessingException exception) { + return result.toString(); + } + } + + private AiAnalyzeResponse toAnalyzeResponse(ClothingAiPhoto photo) { + List styles = parseStyles(photo.getDraftStylesJson()); + List secondaryColors = parseColors(photo.getDraftSecondaryColorsJson()); + boolean aiFailed = photo.getAnalysisStatus() == AiAnalysisStatus.FAILED; + + return new AiAnalyzeResponse( + photo.getId(), + photo.getAnalysisStatus(), + photo.getImageUrl(), + photo.getFailureMessage(), + aiFailed, + photo.getDraftName(), + photo.getDraftBrandName(), + photo.getDraftCategory(), + photo.getDraftItemType(), + photo.getDraftPrimaryColor(), + secondaryColors, + styles + ); + } + + private List parseColors(String draftSecondaryColorsJson) { + if (!StringUtils.hasText(draftSecondaryColorsJson)) { + return Collections.emptyList(); + } + try { + return objectMapper.readValue( + draftSecondaryColorsJson, + objectMapper.getTypeFactory().constructCollectionType(List.class, String.class) + ); + } catch (JsonProcessingException exception) { + return Collections.emptyList(); + } + } + + private List parseStyles(String draftStylesJson) { + if (!StringUtils.hasText(draftStylesJson)) { + return Collections.emptyList(); + } + try { + return objectMapper.readValue( + draftStylesJson, + objectMapper.getTypeFactory().constructCollectionType(List.class, String.class) + ); + } catch (JsonProcessingException exception) { + return Collections.emptyList(); + } + } + + private record PhotoAnalysisContext(String storedPath, String contentType) {} } diff --git a/src/main/java/com/closetnangam/be/domain/catalog/constants/CatalogLimits.java b/src/main/java/com/closetnangam/be/domain/catalog/constants/CatalogLimits.java new file mode 100644 index 0000000..2ff12ea --- /dev/null +++ b/src/main/java/com/closetnangam/be/domain/catalog/constants/CatalogLimits.java @@ -0,0 +1,30 @@ +package com.closetnangam.be.domain.catalog.constants; + +import com.closetnangam.be.domain.catalog.enums.ClothesColor; +import com.closetnangam.be.domain.catalog.enums.StyleCode; + +public final class CatalogLimits { + + /** ClothesColor catalog size minus the required primary color slot. */ + public static final int MAX_SECONDARY_COLORS = 12; + + /** StyleCode catalog size. */ + public static final int MAX_STYLES = 10; + + private CatalogLimits() { + } + + static { + int colorCatalogSize = ClothesColor.values().length; + int styleCatalogSize = StyleCode.values().length; + if (MAX_SECONDARY_COLORS != colorCatalogSize - 1) { + throw new ExceptionInInitializerError( + "MAX_SECONDARY_COLORS must be ClothesColor count - 1: expected " + + (colorCatalogSize - 1)); + } + if (MAX_STYLES != styleCatalogSize) { + throw new ExceptionInInitializerError( + "MAX_STYLES must match StyleCode count: expected " + styleCatalogSize); + } + } +} diff --git a/src/main/java/com/closetnangam/be/domain/catalog/service/CategoryCatalogService.java b/src/main/java/com/closetnangam/be/domain/catalog/service/CategoryCatalogService.java index 9392788..c6de6cb 100644 --- a/src/main/java/com/closetnangam/be/domain/catalog/service/CategoryCatalogService.java +++ b/src/main/java/com/closetnangam/be/domain/catalog/service/CategoryCatalogService.java @@ -1,5 +1,6 @@ package com.closetnangam.be.domain.catalog.service; +import com.closetnangam.be.domain.catalog.constants.CatalogLimits; import com.closetnangam.be.domain.catalog.dto.response.*; import com.closetnangam.be.domain.catalog.entity.Style; import com.closetnangam.be.domain.catalog.enums.ClothesCategory; @@ -59,13 +60,15 @@ public CategoryUsageGuideResponse getUsageGuide() { new GuideFieldResponse( "secondaryColors", "보조 색상", - "색상 코드 배열입니다. clothing_colors 테이블 SECONDARY 역할로 저장합니다.", + "색상 코드 배열입니다. clothing_colors 테이블 SECONDARY 역할로 저장합니다. " + + "최대 " + CatalogLimits.MAX_SECONDARY_COLORS + "개(primaryColor 제외).", "NAVY" ), new GuideFieldResponse( "styles", "스타일", - "스타일 코드 배열입니다. styles 목록의 code 값을 사용합니다.", + "스타일 코드 배열입니다. styles 목록의 code 값을 사용합니다. " + + "최대 " + CatalogLimits.MAX_STYLES + "개.", "CASUAL" ) ), @@ -105,7 +108,7 @@ public String getAiClassificationGuide() { .append(" (").append(category.getLabel()).append(")\n"); } - guide.append("\n[item_type]\n"); + guide.append("\n[itemType]\n"); for (ClothesCategory category : ClothesCategory.values()) { guide.append(category.name()).append(":\n"); for (ClothesItemType itemType : ClothesItemType.byCategory(category)) { @@ -114,12 +117,18 @@ public String getAiClassificationGuide() { } } - guide.append("\n[color]\n"); + guide.append("\n[primaryColor]\n"); + guide.append("옷의 대표 색상 코드 1개. 아래 color 코드 목록에서 선택하세요.\n"); guide.append(Arrays.stream(ClothesColor.values()) .map(color -> color.name() + " (" + color.getLabel() + ")") .collect(Collectors.joining(", "))); - guide.append("\n\n[style]\n"); + guide.append("\n\n[secondaryColors]\n"); + guide.append("보조 색상 코드 배열. 없으면 빈 배열 []을 사용하고, primaryColor와 중복되면 안 됩니다. "); + guide.append("최대 ").append(CatalogLimits.MAX_SECONDARY_COLORS).append("개.\n"); + + guide.append("\n\n[styles]\n"); + guide.append("스타일 코드 배열. 최소 1개, 최대 ").append(CatalogLimits.MAX_STYLES).append("개.\n"); guide.append(Arrays.stream(StyleCode.values()) .map(style -> style.name() + " (" + style.getLabel() + ")") .collect(Collectors.joining(", "))); @@ -128,7 +137,7 @@ public String getAiClassificationGuide() { guide.append(""" { "category": "TOP", - "item_type": "SHORT_SLEEVE", + "itemType": "SHORT_SLEEVE", "primaryColor": "WHITE", "secondaryColors": ["NAVY"], "styles": ["CASUAL", "MINIMAL"] @@ -159,6 +168,10 @@ public void validateClothesColors(String primaryColor, List secondaryCol if (secondaryColors == null || secondaryColors.isEmpty()) { return; } + if (secondaryColors.size() > CatalogLimits.MAX_SECONDARY_COLORS) { + throw new IllegalArgumentException( + "보조 색상은 최대 " + CatalogLimits.MAX_SECONDARY_COLORS + "개까지 선택할 수 있습니다."); + } Set seen = new HashSet<>(); for (String secondaryColor : secondaryColors) { validateColorCode(secondaryColor); @@ -175,6 +188,10 @@ public void validateStyleCodes(List styleCodes) { if (styleCodes == null || styleCodes.isEmpty()) { throw new IllegalArgumentException("스타일은 1개 이상 선택해야 합니다."); } + if (styleCodes.size() > CatalogLimits.MAX_STYLES) { + throw new IllegalArgumentException( + "스타일은 최대 " + CatalogLimits.MAX_STYLES + "개까지 선택할 수 있습니다."); + } Set seen = new HashSet<>(); for (String styleCode : styleCodes) { StyleCode.fromCode(styleCode); diff --git a/src/main/java/com/closetnangam/be/domain/clothes/controller/ClothesController.java b/src/main/java/com/closetnangam/be/domain/clothes/controller/ClothesController.java index 2ba5f25..a99e71a 100644 --- a/src/main/java/com/closetnangam/be/domain/clothes/controller/ClothesController.java +++ b/src/main/java/com/closetnangam/be/domain/clothes/controller/ClothesController.java @@ -7,6 +7,7 @@ import com.closetnangam.be.domain.clothes.dto.response.ClothesResponse; import com.closetnangam.be.domain.clothes.service.ClothesRegistrationService; import com.closetnangam.be.domain.clothes.service.ClothesService; +import com.closetnangam.be.global.auth.util.SecurityUtils; import com.closetnangam.be.global.common.response.ApiResponse; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; @@ -43,23 +44,25 @@ public ResponseEntity> getRegist return ResponseEntity.ok(ApiResponse.ok(clothesRegistrationService.getRegistrationMethods())); } - // TODO: JWT 인증 구현 후 @PreAuthorize 또는 SecurityContextHolder로 userId 소유권 검증 추가 필요 @Operation(summary = "보유 옷 목록 조회", description = "사용자 옷장의 보유 옷(OWNED) 목록을 조회합니다.") @GetMapping("/users/{userId}/clothes") public ResponseEntity>> getOwnedClothes(@PathVariable Long userId) { + SecurityUtils.verifyUserIdMatch(userId); return ResponseEntity.ok(ApiResponse.ok(clothesService.getOwnedClothes(userId))); } @Operation(summary = "즐겨찾기 옷 목록 조회", description = "즐겨찾기로 표시한 보유 옷 목록을 조회합니다.") @GetMapping("/users/{userId}/clothes/favorites") public ResponseEntity>> getFavoriteOwnedClothes(@PathVariable Long userId) { + SecurityUtils.verifyUserIdMatch(userId); return ResponseEntity.ok(ApiResponse.ok(clothesService.getFavoriteOwnedClothes(userId))); } @Operation(summary = "옷 상세 조회", description = "보유/미보유 옷의 상세 정보(이미지, 이름, 브랜드, 카테고리, 타입, 색상, 스타일)를 조회합니다.") @GetMapping("/clothes/{clothesId}") public ResponseEntity> getClothes(@PathVariable Long clothesId) { - return ResponseEntity.ok(ApiResponse.ok(clothesService.getClothes(clothesId))); + Long userId = SecurityUtils.getCurrentUserId(); + return ResponseEntity.ok(ApiResponse.ok(clothesService.getClothes(userId, clothesId))); } @Operation(summary = "보유 옷 등록", description = "사용자 옷장에 보유 옷을 등록합니다.") @@ -68,6 +71,7 @@ public ResponseEntity> createOwnedClothes( @PathVariable Long userId, @Valid @RequestBody ClothesCreateRequest request ) { + SecurityUtils.verifyUserIdMatch(userId); return ResponseEntity.status(HttpStatus.CREATED) .body(ApiResponse.ok(clothesService.createOwnedClothes(userId, request))); } @@ -78,7 +82,8 @@ public ResponseEntity> updateFavorite( @PathVariable Long clothesId, @Valid @RequestBody ClothesFavoriteRequest request ) { - return ResponseEntity.ok(ApiResponse.ok(clothesService.updateFavorite(clothesId, request))); + Long userId = SecurityUtils.getCurrentUserId(); + return ResponseEntity.ok(ApiResponse.ok(clothesService.updateFavorite(userId, clothesId, request))); } @Operation(summary = "옷 정보 수정", description = "등록된 보유/미보유 옷 정보를 수정합니다.") @@ -87,13 +92,15 @@ public ResponseEntity> updateClothes( @PathVariable Long clothesId, @Valid @RequestBody ClothesUpdateRequest request ) { - return ResponseEntity.ok(ApiResponse.ok(clothesService.updateClothes(clothesId, request))); + Long userId = SecurityUtils.getCurrentUserId(); + return ResponseEntity.ok(ApiResponse.ok(clothesService.updateClothes(userId, clothesId, request))); } @Operation(summary = "옷 삭제", description = "등록된 보유/미보유 옷을 삭제합니다. 삭제 확인은 프론트에서 처리합니다.") @DeleteMapping("/clothes/{clothesId}") public ResponseEntity deleteClothes(@PathVariable Long clothesId) { - clothesService.deleteClothes(clothesId); + Long userId = SecurityUtils.getCurrentUserId(); + clothesService.deleteClothes(userId, clothesId); return ResponseEntity.noContent().build(); } } diff --git a/src/main/java/com/closetnangam/be/domain/clothes/controller/PhotoClothesRegistrationController.java b/src/main/java/com/closetnangam/be/domain/clothes/controller/PhotoClothesRegistrationController.java new file mode 100644 index 0000000..7b5af41 --- /dev/null +++ b/src/main/java/com/closetnangam/be/domain/clothes/controller/PhotoClothesRegistrationController.java @@ -0,0 +1,79 @@ +package com.closetnangam.be.domain.clothes.controller; + +import com.closetnangam.be.domain.ai.dto.response.AiAnalyzeResponse; +import com.closetnangam.be.domain.ai.service.AiService; +import com.closetnangam.be.domain.clothes.dto.request.PhotoClothesSaveRequest; +import com.closetnangam.be.domain.clothes.dto.response.PhotoClothesDraftResponse; +import com.closetnangam.be.domain.clothes.dto.response.PhotoClothesRegistrationResponse; +import com.closetnangam.be.domain.clothes.dto.response.PhotoUploadResponse; +import com.closetnangam.be.domain.clothes.service.PhotoClothesRegistrationService; +import com.closetnangam.be.global.common.response.ApiResponse; +import com.closetnangam.be.global.common.util.SecurityUtils; +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.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestPart; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; + +@Tag(name = "Photo Clothes Registration", description = "사진 기반 보유 옷 등록 API") +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/v1/users/{userId}/clothes/photos") +public class PhotoClothesRegistrationController { + + private final PhotoClothesRegistrationService photoClothesRegistrationService; + private final AiService aiService; + + @Operation(summary = "의류 사진 업로드", description = "jpg, png, webp 형식의 옷 사진을 업로드하고 미리보기 URL을 반환합니다.") + @PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + public ResponseEntity> uploadPhoto( + @PathVariable Long userId, + @RequestPart("file") MultipartFile file + ) { + SecurityUtils.verifyOwnership(userId); + return ResponseEntity.status(HttpStatus.CREATED) + .body(ApiResponse.ok(photoClothesRegistrationService.uploadPhoto(userId, file))); + } + + @Operation(summary = "AI 의류 판별", description = "업로드한 사진을 Gemini API로 분석해 카테고리, 타입, 색상, 스타일 임시값을 저장합니다.") + @PostMapping("/{photoId}/analyze") + public ResponseEntity> analyzePhoto( + @PathVariable Long userId, + @PathVariable Long photoId + ) { + SecurityUtils.verifyOwnership(userId); + return ResponseEntity.ok(ApiResponse.ok(aiService.analyzeClothingPhoto(userId, photoId))); + } + + @Operation(summary = "AI 판별 임시 결과 조회", description = "AI 판별 결과 또는 실패 안내를 포함한 임시 등록 정보를 조회합니다.") + @GetMapping("/{photoId}/draft") + public ResponseEntity> getDraft( + @PathVariable Long userId, + @PathVariable Long photoId + ) { + SecurityUtils.verifyOwnership(userId); + return ResponseEntity.ok(ApiResponse.ok(photoClothesRegistrationService.getDraft(userId, photoId))); + } + + @Operation(summary = "사진 기반 옷 최종 저장", description = "사용자가 수정한 정보와 옷장 전용 정보(size, season 등)를 저장합니다.") + @PostMapping("/{photoId}/save") + public ResponseEntity> savePhotoClothes( + @PathVariable Long userId, + @PathVariable Long photoId, + @Valid @RequestBody PhotoClothesSaveRequest request + ) { + SecurityUtils.verifyOwnership(userId); + return ResponseEntity.status(HttpStatus.CREATED) + .body(ApiResponse.ok(photoClothesRegistrationService.savePhotoClothes(userId, photoId, request))); + } +} diff --git a/src/main/java/com/closetnangam/be/domain/clothes/controller/WishlistClothesController.java b/src/main/java/com/closetnangam/be/domain/clothes/controller/WishlistClothesController.java index b4810c6..9e448ba 100644 --- a/src/main/java/com/closetnangam/be/domain/clothes/controller/WishlistClothesController.java +++ b/src/main/java/com/closetnangam/be/domain/clothes/controller/WishlistClothesController.java @@ -4,6 +4,7 @@ import com.closetnangam.be.domain.clothes.dto.request.WishlistClothesCreateRequest; import com.closetnangam.be.domain.clothes.dto.response.ClothesResponse; import com.closetnangam.be.domain.clothes.service.ClothesService; +import com.closetnangam.be.global.auth.util.SecurityUtils; import com.closetnangam.be.global.common.response.ApiResponse; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; @@ -27,16 +28,17 @@ public class WishlistClothesController { private final ClothesService clothesService; - // TODO: JWT 인증 구현 후 @PreAuthorize 또는 SecurityContextHolder로 userId 소유권 검증 추가 필요 @Operation(summary = "미보유 옷 목록 조회", description = "추천받아 저장한 옷, 관심 상품(WISHLIST) 목록을 조회합니다.") @GetMapping("/api/users/{userId}/wishlist-clothes") public ResponseEntity>> getWishlistClothes(@PathVariable Long userId) { + SecurityUtils.verifyUserIdMatch(userId); return ResponseEntity.ok(ApiResponse.ok(clothesService.getWishlistClothes(userId))); } @Operation(summary = "미보유 옷 즐겨찾기 목록 조회", description = "즐겨찾기로 표시한 미보유 옷 목록을 조회합니다.") @GetMapping("/api/users/{userId}/wishlist-clothes/favorites") public ResponseEntity>> getFavoriteWishlistClothes(@PathVariable Long userId) { + SecurityUtils.verifyUserIdMatch(userId); return ResponseEntity.ok(ApiResponse.ok(clothesService.getFavoriteWishlistClothes(userId))); } @@ -46,17 +48,18 @@ public ResponseEntity> createWishlistClothes( @PathVariable Long userId, @Valid @RequestBody WishlistClothesCreateRequest request ) { + SecurityUtils.verifyUserIdMatch(userId); return ResponseEntity.status(HttpStatus.CREATED) .body(ApiResponse.ok(clothesService.createWishlistClothes(userId, request))); } - // TODO: JWT 인증 구현 후 SecurityContextHolder로 clothesId 소유권 검증 추가 필요 @Operation(summary = "미보유 → 보유 전환", description = "구매 후 미보유 옷을 보유 옷(OWNED)으로 전환합니다.") @PatchMapping("/api/clothes/{clothesId}/convert-to-owned") public ResponseEntity> convertToOwned( @PathVariable Long clothesId, @Valid @RequestBody ClothesConvertToOwnedRequest request ) { - return ResponseEntity.ok(ApiResponse.ok(clothesService.convertToOwned(clothesId, request))); + Long userId = SecurityUtils.getCurrentUserId(); + return ResponseEntity.ok(ApiResponse.ok(clothesService.convertToOwned(userId, clothesId, request))); } } diff --git a/src/main/java/com/closetnangam/be/domain/clothes/dto/request/ClothesCreateRequest.java b/src/main/java/com/closetnangam/be/domain/clothes/dto/request/ClothesCreateRequest.java index 6fe201d..908cfc2 100644 --- a/src/main/java/com/closetnangam/be/domain/clothes/dto/request/ClothesCreateRequest.java +++ b/src/main/java/com/closetnangam/be/domain/clothes/dto/request/ClothesCreateRequest.java @@ -1,5 +1,6 @@ package com.closetnangam.be.domain.clothes.dto.request; +import com.closetnangam.be.domain.catalog.constants.CatalogLimits; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotEmpty; import jakarta.validation.constraints.NotNull; @@ -15,8 +16,8 @@ public record ClothesCreateRequest( @NotBlank @Size(max = 50) String category, @NotBlank @Size(max = 50) String itemType, @NotBlank @Size(max = 50) String primaryColor, - @Size(max = 10) List<@NotBlank String> secondaryColors, - @NotEmpty @Size(max = 10) List<@NotBlank String> styles, + @Size(max = CatalogLimits.MAX_SECONDARY_COLORS) List<@NotBlank String> secondaryColors, + @NotEmpty @Size(max = CatalogLimits.MAX_STYLES) List<@NotBlank String> styles, @NotBlank @Size(max = 50) String size, @Size(max = 50) String season, @NotNull Boolean isVerified diff --git a/src/main/java/com/closetnangam/be/domain/clothes/dto/request/ClothesUpdateRequest.java b/src/main/java/com/closetnangam/be/domain/clothes/dto/request/ClothesUpdateRequest.java index 60341be..98f5707 100644 --- a/src/main/java/com/closetnangam/be/domain/clothes/dto/request/ClothesUpdateRequest.java +++ b/src/main/java/com/closetnangam/be/domain/clothes/dto/request/ClothesUpdateRequest.java @@ -1,5 +1,6 @@ package com.closetnangam.be.domain.clothes.dto.request; +import com.closetnangam.be.domain.catalog.constants.CatalogLimits; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotEmpty; import jakarta.validation.constraints.NotNull; @@ -15,8 +16,8 @@ public record ClothesUpdateRequest( @NotBlank @Size(max = 50) String category, @NotBlank @Size(max = 50) String itemType, @NotBlank @Size(max = 50) String primaryColor, - @Size(max = 10) List<@NotBlank String> secondaryColors, - @NotEmpty @Size(max = 10) List<@NotBlank String> styles, + @Size(max = CatalogLimits.MAX_SECONDARY_COLORS) List<@NotBlank String> secondaryColors, + @NotEmpty @Size(max = CatalogLimits.MAX_STYLES) List<@NotBlank String> styles, @NotBlank @Size(max = 50) String size, @Size(max = 50) String season, @NotNull Boolean isVerified diff --git a/src/main/java/com/closetnangam/be/domain/clothes/dto/request/PhotoClothesSaveRequest.java b/src/main/java/com/closetnangam/be/domain/clothes/dto/request/PhotoClothesSaveRequest.java new file mode 100644 index 0000000..efa8cbb --- /dev/null +++ b/src/main/java/com/closetnangam/be/domain/clothes/dto/request/PhotoClothesSaveRequest.java @@ -0,0 +1,25 @@ +package com.closetnangam.be.domain.clothes.dto.request; + +import com.closetnangam.be.domain.catalog.constants.CatalogLimits; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; + +import java.util.List; + +public record PhotoClothesSaveRequest( + @NotBlank @Size(max = 255) String name, + @NotBlank @Size(max = 100) String brandName, + @NotBlank @Size(max = 100) String productCode, + @NotBlank @Size(max = 50) String category, + @NotBlank @Size(max = 50) String itemType, + @NotBlank @Size(max = 50) String primaryColor, + @Size(max = CatalogLimits.MAX_SECONDARY_COLORS) List<@NotBlank String> secondaryColors, + @NotEmpty @Size(max = CatalogLimits.MAX_STYLES) List<@NotBlank String> styles, + @NotBlank @Size(max = 50) String size, + @Size(max = 50) String season, + @NotNull Boolean favorite, + @NotNull Boolean isVerified +) { +} diff --git a/src/main/java/com/closetnangam/be/domain/clothes/dto/request/WishlistClothesCreateRequest.java b/src/main/java/com/closetnangam/be/domain/clothes/dto/request/WishlistClothesCreateRequest.java index ee8f084..e679514 100644 --- a/src/main/java/com/closetnangam/be/domain/clothes/dto/request/WishlistClothesCreateRequest.java +++ b/src/main/java/com/closetnangam/be/domain/clothes/dto/request/WishlistClothesCreateRequest.java @@ -1,5 +1,6 @@ package com.closetnangam.be.domain.clothes.dto.request; +import com.closetnangam.be.domain.catalog.constants.CatalogLimits; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotEmpty; import jakarta.validation.constraints.Size; @@ -14,8 +15,8 @@ public record WishlistClothesCreateRequest( @NotBlank @Size(max = 50) String category, @NotBlank @Size(max = 50) String itemType, @NotBlank @Size(max = 50) String primaryColor, - @Size(max = 10) List<@NotBlank String> secondaryColors, - @NotEmpty @Size(max = 10) List<@NotBlank String> styles, + @Size(max = CatalogLimits.MAX_SECONDARY_COLORS) List<@NotBlank String> secondaryColors, + @NotEmpty @Size(max = CatalogLimits.MAX_STYLES) List<@NotBlank String> styles, @NotBlank @Size(max = 50) String size, @Size(max = 50) String season, @NotBlank @Size(max = 50) String externalSource, diff --git a/src/main/java/com/closetnangam/be/domain/clothes/dto/response/PhotoClothesDraftResponse.java b/src/main/java/com/closetnangam/be/domain/clothes/dto/response/PhotoClothesDraftResponse.java new file mode 100644 index 0000000..afaf512 --- /dev/null +++ b/src/main/java/com/closetnangam/be/domain/clothes/dto/response/PhotoClothesDraftResponse.java @@ -0,0 +1,21 @@ +package com.closetnangam.be.domain.clothes.dto.response; + +import com.closetnangam.be.domain.ai.enums.AiAnalysisStatus; + +import java.util.List; + +public record PhotoClothesDraftResponse( + Long photoId, + AiAnalysisStatus analysisStatus, + String previewUrl, + String failureMessage, + Boolean aiFailed, + String name, + String brandName, + String category, + String itemType, + String primaryColor, + List secondaryColors, + List styles +) { +} diff --git a/src/main/java/com/closetnangam/be/domain/clothes/dto/response/PhotoClothesRegistrationResponse.java b/src/main/java/com/closetnangam/be/domain/clothes/dto/response/PhotoClothesRegistrationResponse.java new file mode 100644 index 0000000..811b9c6 --- /dev/null +++ b/src/main/java/com/closetnangam/be/domain/clothes/dto/response/PhotoClothesRegistrationResponse.java @@ -0,0 +1,18 @@ +package com.closetnangam.be.domain.clothes.dto.response; + +import com.closetnangam.be.domain.clothes.enums.OwnershipStatus; +import com.closetnangam.be.domain.clothes.enums.RegistrationSource; + +public record PhotoClothesRegistrationResponse( + Long wardrobeClothesId, + Long clothesId, + Long photoId, + String userImageUrl, + OwnershipStatus ownershipStatus, + RegistrationSource registrationSource, + String size, + String season, + Boolean favorite, + ClothesResponse clothes +) { +} diff --git a/src/main/java/com/closetnangam/be/domain/clothes/dto/response/PhotoUploadResponse.java b/src/main/java/com/closetnangam/be/domain/clothes/dto/response/PhotoUploadResponse.java new file mode 100644 index 0000000..c5e0f85 --- /dev/null +++ b/src/main/java/com/closetnangam/be/domain/clothes/dto/response/PhotoUploadResponse.java @@ -0,0 +1,9 @@ +package com.closetnangam.be.domain.clothes.dto.response; + +public record PhotoUploadResponse( + Long photoId, + String previewUrl, + String originalFilename, + String contentType +) { +} diff --git a/src/main/java/com/closetnangam/be/domain/clothes/helper/ClothesTagHelper.java b/src/main/java/com/closetnangam/be/domain/clothes/helper/ClothesTagHelper.java new file mode 100644 index 0000000..696d6c5 --- /dev/null +++ b/src/main/java/com/closetnangam/be/domain/clothes/helper/ClothesTagHelper.java @@ -0,0 +1,90 @@ +package com.closetnangam.be.domain.clothes.helper; + +import com.closetnangam.be.domain.catalog.entity.Style; +import com.closetnangam.be.domain.catalog.repository.StyleRepository; +import com.closetnangam.be.domain.catalog.service.CategoryCatalogService; +import com.closetnangam.be.domain.clothes.entity.Clothes; +import com.closetnangam.be.domain.clothes.entity.ClothesStyleTag; +import com.closetnangam.be.domain.clothes.entity.ClothingColor; +import com.closetnangam.be.domain.clothes.enums.ColorRole; +import com.closetnangam.be.domain.clothes.enums.StyleRole; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collectors; + +@Component +@RequiredArgsConstructor +public class ClothesTagHelper { + + private final StyleRepository styleRepository; + private final CategoryCatalogService categoryCatalogService; + + public void validateClassification( + String category, + String itemType, + String primaryColor, + List secondaryColors, + List styles + ) { + categoryCatalogService.validateCategoryAndItemType(category, itemType); + categoryCatalogService.validateClothesColors(primaryColor, secondaryColors); + categoryCatalogService.validateStyleCodes(styles); + } + + public void applyColorTags(Clothes clothes, String primaryColor, List secondaryColors) { + buildColorTags(clothes, primaryColor, secondaryColors).forEach(clothes::addColorTag); + } + + public void replaceColorTags(Clothes clothes, String primaryColor, List secondaryColors) { + clothes.replaceColorTags(buildColorTags(clothes, primaryColor, secondaryColors)); + } + + public void applyStyleTags(Clothes clothes, List styleCodes) { + buildStyleTags(clothes, styleCodes).forEach(clothes::addStyleTag); + } + + public void replaceStyleTags(Clothes clothes, List styleCodes) { + clothes.replaceStyleTags(buildStyleTags(clothes, styleCodes)); + } + + private List buildColorTags( + Clothes clothes, + String primaryColor, + List secondaryColors + ) { + List colorTags = new ArrayList<>(); + colorTags.add(ClothingColor.create(clothes, primaryColor, ColorRole.PRIMARY, (byte) 0)); + + if (secondaryColors != null && !secondaryColors.isEmpty()) { + byte sortOrder = 1; + for (String secondaryColor : secondaryColors) { + colorTags.add(ClothingColor.create(clothes, secondaryColor, ColorRole.SECONDARY, sortOrder++)); + } + } + return colorTags; + } + + private List buildStyleTags(Clothes clothes, List styleCodes) { + List