From 98cdbd03678dba0f5df210b55a7e18c6d0bfce7b Mon Sep 17 00:00:00 2001 From: warcat12 Date: Thu, 28 May 2026 14:25:45 +0900 Subject: [PATCH 01/11] =?UTF-8?q?feat/=20#33=20=EC=82=AC=EC=A7=84=20?= =?UTF-8?q?=EA=B8=B0=EB=B0=98=20=EB=B6=84=EC=84=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ai/dto/response/AiAnalyzeResponse.java | 20 ++ .../be/domain/ai/entity/ClothingAiPhoto.java | 144 ++++++++++++ .../be/domain/ai/enums/AiAnalysisStatus.java | 10 + .../repository/ClothingAiPhotoRepository.java | 11 + .../be/domain/ai/service/AiService.java | 181 +++++++++++++++ .../PhotoClothesRegistrationController.java | 75 ++++++ .../dto/request/PhotoClothesSaveRequest.java | 23 ++ .../response/PhotoClothesDraftResponse.java | 20 ++ .../PhotoClothesRegistrationResponse.java | 18 ++ .../dto/response/PhotoUploadResponse.java | 9 + .../clothes/entity/WardrobeClothes.java | 82 +++++++ .../domain/clothes/enums/OwnershipStatus.java | 7 + .../clothes/enums/RegistrationSource.java | 7 + .../repository/WardrobeClothesRepository.java | 22 ++ .../PhotoClothesRegistrationService.java | 213 ++++++++++++++++++ .../exception/GlobalExceptionHandler.java | 12 + .../be/global/config/SecurityConfig.java | 3 +- .../be/global/config/StorageProperties.java | 33 +++ .../be/global/config/WebMvcConfig.java | 26 +++ .../global/external/gemini/GeminiService.java | 122 ++++++++++ .../GeminiClothingClassificationResult.java | 16 ++ .../storage/LocalImageStorageService.java | 130 +++++++++++ src/main/resources/application.yml | 27 ++- 23 files changed, 1207 insertions(+), 4 deletions(-) create mode 100644 src/main/java/com/closetnangam/be/domain/ai/enums/AiAnalysisStatus.java create mode 100644 src/main/java/com/closetnangam/be/domain/clothes/controller/PhotoClothesRegistrationController.java create mode 100644 src/main/java/com/closetnangam/be/domain/clothes/dto/request/PhotoClothesSaveRequest.java create mode 100644 src/main/java/com/closetnangam/be/domain/clothes/dto/response/PhotoClothesDraftResponse.java create mode 100644 src/main/java/com/closetnangam/be/domain/clothes/dto/response/PhotoClothesRegistrationResponse.java create mode 100644 src/main/java/com/closetnangam/be/domain/clothes/dto/response/PhotoUploadResponse.java create mode 100644 src/main/java/com/closetnangam/be/domain/clothes/entity/WardrobeClothes.java create mode 100644 src/main/java/com/closetnangam/be/domain/clothes/enums/OwnershipStatus.java create mode 100644 src/main/java/com/closetnangam/be/domain/clothes/enums/RegistrationSource.java create mode 100644 src/main/java/com/closetnangam/be/domain/clothes/repository/WardrobeClothesRepository.java create mode 100644 src/main/java/com/closetnangam/be/domain/clothes/service/PhotoClothesRegistrationService.java create mode 100644 src/main/java/com/closetnangam/be/global/config/StorageProperties.java create mode 100644 src/main/java/com/closetnangam/be/global/config/WebMvcConfig.java create mode 100644 src/main/java/com/closetnangam/be/global/external/gemini/dto/GeminiClothingClassificationResult.java create mode 100644 src/main/java/com/closetnangam/be/global/storage/LocalImageStorageService.java 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..301655f 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,20 @@ +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 color, + 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..8f86e27 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,144 @@ +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 draftColor; + + @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 draftColor, + String draftStylesJson, + String rawAiResponse + ) { + this.analysisStatus = AiAnalysisStatus.SUCCESS; + this.failureMessage = null; + this.draftName = draftName; + this.draftBrandName = draftBrandName; + this.draftCategory = draftCategory; + this.draftItemType = draftItemType; + this.draftColor = draftColor; + 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..9f5afe2 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,11 @@ +package com.closetnangam.be.domain.ai.repository; + +import com.closetnangam.be.domain.ai.entity.ClothingAiPhoto; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface ClothingAiPhotoRepository extends JpaRepository { + + Optional findByIdAndUser_Id(Long id, 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..99c8e3d 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,185 @@ 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.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 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 상태 기록 (단기 트랜잭션) + PhotoAnalysisContext ctx = transactionTemplate.execute(status -> { + ClothingAiPhoto photo = getOwnedPhoto(userId, photoId); + 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) { + // 파일 미존재 등 입력 문제 + failureMessage = "업로드된 이미지를 찾을 수 없습니다."; + } catch (IllegalStateException e) { + // Gemini API 실패, 응답 파싱 실패, AI 결과 불충분 + failureMessage = "AI가 옷 이미지를 분석하지 못했습니다. 직접 입력해 주세요."; + } catch (Exception e) { + // 예기치 않은 런타임 오류도 FAILED로 처리해 ANALYZING 상태 고착 방지 + failureMessage = "AI 분석 중 예기치 않은 오류가 발생했습니다. 직접 입력해 주세요."; + } + + // Phase 3: 분석 결과 저장 (단기 트랜잭션) + final GeminiClothingClassificationResult finalResult = result; + final String finalFailure = failureMessage; + AiAnalyzeResponse response = transactionTemplate.execute(status -> { + ClothingAiPhoto photo = getOwnedPhoto(userId, photoId); + if (finalResult != null) { + photo.applyAnalysisSuccess( + finalResult.name(), + finalResult.brandName(), + finalResult.category(), + finalResult.itemType(), + finalResult.color(), + 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.color()) + || result.styles() == null + || result.styles().isEmpty()) { + throw new IllegalStateException("AI 판별 결과가 충분하지 않습니다."); + } + categoryCatalogService.validateClothesClassification(result.category(), result.itemType(), result.color()); + categoryCatalogService.validateStyleCodes(result.styles()); + } + + 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()); + 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.getDraftColor(), + styles + ); + } + + 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/clothes/controller/PhotoClothesRegistrationController.java b/src/main/java/com/closetnangam/be/domain/clothes/controller/PhotoClothesRegistrationController.java new file mode 100644 index 0000000..df10a99 --- /dev/null +++ b/src/main/java/com/closetnangam/be/domain/clothes/controller/PhotoClothesRegistrationController.java @@ -0,0 +1,75 @@ +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 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; + + // TODO: JWT 인증 구현 후 @PreAuthorize 또는 SecurityContextHolder로 userId 소유권 검증 추가 필요 + @Operation(summary = "의류 사진 업로드", description = "jpg, png, webp 형식의 옷 사진을 업로드하고 미리보기 URL을 반환합니다.") + @PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + public ResponseEntity> uploadPhoto( + @PathVariable Long userId, + @RequestPart("file") MultipartFile file + ) { + 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 + ) { + 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 + ) { + 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 + ) { + return ResponseEntity.status(HttpStatus.CREATED) + .body(ApiResponse.ok(photoClothesRegistrationService.savePhotoClothes(userId, photoId, request))); + } +} 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..7c34e45 --- /dev/null +++ b/src/main/java/com/closetnangam/be/domain/clothes/dto/request/PhotoClothesSaveRequest.java @@ -0,0 +1,23 @@ +package com.closetnangam.be.domain.clothes.dto.request; + +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 color, + @NotEmpty 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/response/PhotoClothesDraftResponse.java b/src/main/java/com/closetnangam/be/domain/clothes/dto/response/PhotoClothesDraftResponse.java new file mode 100644 index 0000000..9bba548 --- /dev/null +++ b/src/main/java/com/closetnangam/be/domain/clothes/dto/response/PhotoClothesDraftResponse.java @@ -0,0 +1,20 @@ +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 color, + 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/entity/WardrobeClothes.java b/src/main/java/com/closetnangam/be/domain/clothes/entity/WardrobeClothes.java new file mode 100644 index 0000000..a1a1f20 --- /dev/null +++ b/src/main/java/com/closetnangam/be/domain/clothes/entity/WardrobeClothes.java @@ -0,0 +1,82 @@ +package com.closetnangam.be.domain.clothes.entity; + +import com.closetnangam.be.domain.clothes.enums.OwnershipStatus; +import com.closetnangam.be.domain.clothes.enums.RegistrationSource; +import com.closetnangam.be.domain.wardrobe.entity.Wardrobe; +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 = "wardrobe_clothes") +public class WardrobeClothes extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "wardrobe_clothes_id") + private Long id; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "wardrobe_id", nullable = false) + private Wardrobe wardrobe; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "clothes_id", nullable = false) + private Clothes clothes; + + @Enumerated(EnumType.STRING) + @Column(name = "ownership_status", nullable = false, length = 30) + private OwnershipStatus ownershipStatus; + + @Column(nullable = false, length = 50) + private String size; + + @Column(length = 50) + private String season; + + @Column(nullable = false) + private Boolean favorite = false; + + @Enumerated(EnumType.STRING) + @Column(name = "registration_source", nullable = false, length = 50) + private RegistrationSource registrationSource; + + @Column(name = "user_image_url", nullable = false, length = 500) + private String userImageUrl; + + @Builder + private WardrobeClothes( + Wardrobe wardrobe, + Clothes clothes, + OwnershipStatus ownershipStatus, + String size, + String season, + Boolean favorite, + RegistrationSource registrationSource, + String userImageUrl + ) { + this.wardrobe = wardrobe; + this.clothes = clothes; + this.ownershipStatus = ownershipStatus; + this.size = size; + this.season = season; + this.favorite = favorite != null ? favorite : false; + this.registrationSource = registrationSource; + this.userImageUrl = userImageUrl; + } +} diff --git a/src/main/java/com/closetnangam/be/domain/clothes/enums/OwnershipStatus.java b/src/main/java/com/closetnangam/be/domain/clothes/enums/OwnershipStatus.java new file mode 100644 index 0000000..a1e9896 --- /dev/null +++ b/src/main/java/com/closetnangam/be/domain/clothes/enums/OwnershipStatus.java @@ -0,0 +1,7 @@ +package com.closetnangam.be.domain.clothes.enums; + +public enum OwnershipStatus { + + OWNED, + WISHLIST +} diff --git a/src/main/java/com/closetnangam/be/domain/clothes/enums/RegistrationSource.java b/src/main/java/com/closetnangam/be/domain/clothes/enums/RegistrationSource.java new file mode 100644 index 0000000..9137226 --- /dev/null +++ b/src/main/java/com/closetnangam/be/domain/clothes/enums/RegistrationSource.java @@ -0,0 +1,7 @@ +package com.closetnangam.be.domain.clothes.enums; + +public enum RegistrationSource { + + PHOTO, + PURCHASE_HISTORY +} diff --git a/src/main/java/com/closetnangam/be/domain/clothes/repository/WardrobeClothesRepository.java b/src/main/java/com/closetnangam/be/domain/clothes/repository/WardrobeClothesRepository.java new file mode 100644 index 0000000..c0acca4 --- /dev/null +++ b/src/main/java/com/closetnangam/be/domain/clothes/repository/WardrobeClothesRepository.java @@ -0,0 +1,22 @@ +package com.closetnangam.be.domain.clothes.repository; + +import com.closetnangam.be.domain.clothes.entity.WardrobeClothes; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.Optional; + +public interface WardrobeClothesRepository extends JpaRepository { + + @Query(""" + select wc from WardrobeClothes wc + join fetch wc.wardrobe w + join fetch w.user + join fetch wc.clothes c + left join fetch c.styleTags st + left join fetch st.style + where wc.id = :wardrobeClothesId + """) + Optional findByIdWithDetails(@Param("wardrobeClothesId") Long wardrobeClothesId); +} diff --git a/src/main/java/com/closetnangam/be/domain/clothes/service/PhotoClothesRegistrationService.java b/src/main/java/com/closetnangam/be/domain/clothes/service/PhotoClothesRegistrationService.java new file mode 100644 index 0000000..a236125 --- /dev/null +++ b/src/main/java/com/closetnangam/be/domain/clothes/service/PhotoClothesRegistrationService.java @@ -0,0 +1,213 @@ +package com.closetnangam.be.domain.clothes.service; + +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.entity.Style; +import com.closetnangam.be.domain.catalog.repository.StyleRepository; +import com.closetnangam.be.domain.catalog.service.CategoryCatalogService; +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.entity.Clothes; +import com.closetnangam.be.domain.clothes.entity.ClothesStyleTag; +import com.closetnangam.be.domain.clothes.entity.WardrobeClothes; +import com.closetnangam.be.domain.clothes.enums.OwnershipStatus; +import com.closetnangam.be.domain.clothes.enums.RegistrationSource; +import com.closetnangam.be.domain.clothes.enums.SourceType; +import com.closetnangam.be.domain.clothes.repository.ClothesRepository; +import com.closetnangam.be.domain.clothes.repository.WardrobeClothesRepository; +import com.closetnangam.be.domain.user.entity.User; +import com.closetnangam.be.domain.user.repository.UserRepository; +import com.closetnangam.be.domain.wardrobe.entity.Wardrobe; +import com.closetnangam.be.domain.wardrobe.service.WardrobeService; +import com.closetnangam.be.global.storage.LocalImageStorageService; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; +import org.springframework.web.multipart.MultipartFile; + +import java.util.Collections; +import java.util.List; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class PhotoClothesRegistrationService { + + private static final String EXTERNAL_NONE = "NONE"; + + private final ClothingAiPhotoRepository clothingAiPhotoRepository; + private final ClothesRepository clothesRepository; + private final WardrobeClothesRepository wardrobeClothesRepository; + private final StyleRepository styleRepository; + private final CategoryCatalogService categoryCatalogService; + private final WardrobeService wardrobeService; + private final UserRepository userRepository; + private final LocalImageStorageService localImageStorageService; + private final ObjectMapper objectMapper; + + @Transactional + public PhotoUploadResponse uploadPhoto(Long userId, MultipartFile file) { + User user = getUser(userId); + LocalImageStorageService.StoredImage storedImage = localImageStorageService.storeClothesPhoto(userId, file); + + ClothingAiPhoto photo = clothingAiPhotoRepository.save(ClothingAiPhoto.builder() + .user(user) + .imageUrl(storedImage.publicUrl()) + .storedPath(storedImage.storedPath()) + .originalFilename(storedImage.originalFilename()) + .contentType(storedImage.contentType()) + .analysisStatus(AiAnalysisStatus.UPLOADED) + .build()); + + return new PhotoUploadResponse( + photo.getId(), + photo.getImageUrl(), + photo.getOriginalFilename(), + photo.getContentType() + ); + } + + public PhotoClothesDraftResponse getDraft(Long userId, Long photoId) { + ClothingAiPhoto photo = getOwnedPhoto(userId, photoId); + return toDraftResponse(photo); + } + + @Transactional + public PhotoClothesRegistrationResponse savePhotoClothes( + Long userId, + Long photoId, + PhotoClothesSaveRequest request + ) { + validateClassification(request.category(), request.itemType(), request.color(), request.styles()); + + ClothingAiPhoto photo = getOwnedPhoto(userId, photoId); + if (photo.isAlreadySaved()) { + throw new IllegalStateException("이미 저장된 사진입니다."); + } + + Wardrobe wardrobe = wardrobeService.getOrCreateWardrobe(userId); + + Clothes clothes = Clothes.builder() + .wardrobe(wardrobe) + .name(request.name()) + .brandName(request.brandName()) + .productCode(request.productCode()) + .imageUrl(photo.getImageUrl()) + .category(request.category()) + .itemType(request.itemType()) + .color(request.color()) + .sourceType(SourceType.OWNED) + .externalSource(EXTERNAL_NONE) + .externalProductId(EXTERNAL_NONE) + .externalProductUrl(EXTERNAL_NONE) + .isVerified(request.isVerified()) + .isFavorite(request.favorite()) + .build(); + + applyStyleTags(clothes, request.styles()); + Clothes savedClothes = clothesRepository.save(clothes); + + WardrobeClothes wardrobeClothes = wardrobeClothesRepository.save(WardrobeClothes.builder() + .wardrobe(wardrobe) + .clothes(savedClothes) + .ownershipStatus(OwnershipStatus.OWNED) + .size(request.size()) + .season(request.season()) + .favorite(request.favorite()) + .registrationSource(RegistrationSource.PHOTO) + .userImageUrl(photo.getImageUrl()) + .build()); + + photo.markSaved(savedClothes.getId(), wardrobeClothes.getId()); + + Clothes loadedClothes = clothesRepository.findByIdWithDetails(savedClothes.getId()) + .orElseThrow(() -> new IllegalArgumentException("저장된 옷을 찾을 수 없습니다.")); + + return new PhotoClothesRegistrationResponse( + wardrobeClothes.getId(), + loadedClothes.getId(), + photo.getId(), + wardrobeClothes.getUserImageUrl(), + wardrobeClothes.getOwnershipStatus(), + wardrobeClothes.getRegistrationSource(), + wardrobeClothes.getSize(), + wardrobeClothes.getSeason(), + wardrobeClothes.getFavorite(), + com.closetnangam.be.domain.clothes.dto.response.ClothesResponse.from(loadedClothes) + ); + } + + private User getUser(Long userId) { + return userRepository.findById(userId) + .orElseThrow(() -> new IllegalArgumentException("사용자를 찾을 수 없습니다.")); + } + + private ClothingAiPhoto getOwnedPhoto(Long userId, Long photoId) { + return clothingAiPhotoRepository.findByIdAndUser_Id(photoId, userId) + .orElseThrow(() -> new IllegalArgumentException("업로드한 사진을 찾을 수 없습니다.")); + } + + private PhotoClothesDraftResponse toDraftResponse(ClothingAiPhoto photo) { + List styles = parseStyles(photo.getDraftStylesJson()); + boolean aiFailed = photo.getAnalysisStatus() == AiAnalysisStatus.FAILED; + + return new PhotoClothesDraftResponse( + photo.getId(), + photo.getAnalysisStatus(), + photo.getImageUrl(), + photo.getFailureMessage(), + aiFailed, + photo.getDraftName(), + photo.getDraftBrandName(), + photo.getDraftCategory(), + photo.getDraftItemType(), + photo.getDraftColor(), + styles + ); + } + + private void validateClassification( + String category, + String itemType, + String color, + List styles + ) { + categoryCatalogService.validateClothesClassification(category, itemType, color); + categoryCatalogService.validateStyleCodes(styles); + } + + private void applyStyleTags(Clothes clothes, List styleCodes) { + buildStyleTags(clothes, styleCodes).forEach(clothes::addStyleTag); + } + + private List buildStyleTags(Clothes clothes, List styleCodes) { + List uniqueCodes = styleCodes.stream().distinct().toList(); + List