diff --git a/build.gradle b/build.gradle index d531c89..3d3699c 100644 --- a/build.gradle +++ b/build.gradle @@ -61,4 +61,17 @@ dependencies { tasks.named('test') { useJUnitPlatform() + + def envFile = rootProject.file('.env') + if (envFile.exists()) { + envFile.readLines().each { line -> + line = line.trim() + if (!line.startsWith('#') && line.contains('=')) { + def idx = line.indexOf('=') + def key = line.substring(0, idx).trim() + def value = line.substring(idx + 1).trim() + environment key, value + } + } + } } diff --git a/src/main/java/com/closetnangam/be/domain/catalog/entity/Style.java b/src/main/java/com/closetnangam/be/domain/catalog/entity/Style.java index 6f70291..8942c59 100644 --- a/src/main/java/com/closetnangam/be/domain/catalog/entity/Style.java +++ b/src/main/java/com/closetnangam/be/domain/catalog/entity/Style.java @@ -7,7 +7,9 @@ import lombok.Builder; import lombok.Getter; import lombok.NoArgsConstructor; +import org.hibernate.annotations.BatchSize; +@BatchSize(size = 100) @Entity @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) 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 c434d40..9392788 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 @@ -12,8 +12,10 @@ import org.springframework.transaction.annotation.Transactional; import java.util.Arrays; +import java.util.HashSet; import java.util.List; import java.util.Map; +import java.util.Set; import java.util.stream.Collectors; @Service @@ -49,9 +51,15 @@ public CategoryUsageGuideResponse getUsageGuide() { "SHORT_SLEEVE" ), new GuideFieldResponse( - "color", - "컬러", - "색상 코드(code)를 DB에 저장하고, 화면에는 hex 값으로 색상 원(swatch)을 표시합니다.", + "primaryColor", + "주 색상", + "색상 코드(code)를 clothing_colors 테이블 PRIMARY 역할로 저장합니다.", + "WHITE" + ), + new GuideFieldResponse( + "secondaryColors", + "보조 색상", + "색상 코드 배열입니다. clothing_colors 테이블 SECONDARY 역할로 저장합니다.", "NAVY" ), new GuideFieldResponse( @@ -73,7 +81,8 @@ public CategoryUsageGuideResponse getUsageGuide() { Map.of( "category", "TOP", "item_type", "SHORT_SLEEVE", - "color", "WHITE", + "primaryColor", "WHITE", + "secondaryColors", List.of("NAVY"), "styles", List.of("CASUAL", "MINIMAL") ), "colorDisplay", @@ -120,7 +129,8 @@ public String getAiClassificationGuide() { { "category": "TOP", "item_type": "SHORT_SLEEVE", - "color": "WHITE", + "primaryColor": "WHITE", + "secondaryColors": ["NAVY"], "styles": ["CASUAL", "MINIMAL"] } """); @@ -129,19 +139,48 @@ public String getAiClassificationGuide() { } public void validateClothesClassification(String categoryCode, String itemTypeCode, String colorCode) { + validateCategoryAndItemType(categoryCode, itemTypeCode); + validateColorCode(colorCode); + } + + public void validateCategoryAndItemType(String categoryCode, String itemTypeCode) { ClothesCategory.fromCode(categoryCode); if (!ClothesItemType.matchesCategory(categoryCode, itemTypeCode)) { throw new IllegalArgumentException("item_type이 category와 일치하지 않습니다."); } + } + + public void validateColorCode(String colorCode) { ClothesColor.fromCode(colorCode); } + public void validateClothesColors(String primaryColor, List secondaryColors) { + validateColorCode(primaryColor); + if (secondaryColors == null || secondaryColors.isEmpty()) { + return; + } + Set seen = new HashSet<>(); + for (String secondaryColor : secondaryColors) { + validateColorCode(secondaryColor); + if (primaryColor.equals(secondaryColor)) { + throw new IllegalArgumentException("주 색상과 보조 색상은 같을 수 없습니다."); + } + if (!seen.add(secondaryColor)) { + throw new IllegalArgumentException("보조 색상에 중복된 값이 있습니다: " + secondaryColor); + } + } + } + public void validateStyleCodes(List styleCodes) { if (styleCodes == null || styleCodes.isEmpty()) { throw new IllegalArgumentException("스타일은 1개 이상 선택해야 합니다."); } + Set seen = new HashSet<>(); for (String styleCode : styleCodes) { StyleCode.fromCode(styleCode); + if (!seen.add(styleCode)) { + throw new IllegalArgumentException("스타일에 중복된 값이 있습니다: " + styleCode); + } } } diff --git a/src/main/java/com/closetnangam/be/domain/clothes/dto/request/ClothesConvertToOwnedRequest.java b/src/main/java/com/closetnangam/be/domain/clothes/dto/request/ClothesConvertToOwnedRequest.java index 52e4989..bc4bfb5 100644 --- a/src/main/java/com/closetnangam/be/domain/clothes/dto/request/ClothesConvertToOwnedRequest.java +++ b/src/main/java/com/closetnangam/be/domain/clothes/dto/request/ClothesConvertToOwnedRequest.java @@ -6,6 +6,9 @@ public record ClothesConvertToOwnedRequest( @NotBlank @Size(max = 100) String productCode, + @NotBlank @Size(max = 50) String size, + @Size(max = 50) String season, + @NotBlank @Size(max = 500) String userImageUrl, @NotNull Boolean isVerified ) { } 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 941c115..6fe201d 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 @@ -14,8 +14,11 @@ public record ClothesCreateRequest( @NotBlank @Size(max = 500) String imageUrl, @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 primaryColor, + @Size(max = 10) List<@NotBlank String> secondaryColors, + @NotEmpty @Size(max = 10) 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 5386c48..60341be 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 @@ -14,8 +14,11 @@ public record ClothesUpdateRequest( @NotBlank @Size(max = 500) String imageUrl, @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 primaryColor, + @Size(max = 10) List<@NotBlank String> secondaryColors, + @NotEmpty @Size(max = 10) 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/WishlistClothesCreateRequest.java b/src/main/java/com/closetnangam/be/domain/clothes/dto/request/WishlistClothesCreateRequest.java index c0e050d..ee8f084 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 @@ -13,8 +13,11 @@ public record WishlistClothesCreateRequest( @NotBlank @Size(max = 500) String imageUrl, @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 primaryColor, + @Size(max = 10) List<@NotBlank String> secondaryColors, + @NotEmpty @Size(max = 10) List<@NotBlank String> styles, + @NotBlank @Size(max = 50) String size, + @Size(max = 50) String season, @NotBlank @Size(max = 50) String externalSource, @NotBlank @Size(max = 255) String externalProductId, @NotBlank @Size(max = 500) String externalProductUrl diff --git a/src/main/java/com/closetnangam/be/domain/clothes/dto/response/ClothesResponse.java b/src/main/java/com/closetnangam/be/domain/clothes/dto/response/ClothesResponse.java index 18f3e53..6779375 100644 --- a/src/main/java/com/closetnangam/be/domain/clothes/dto/response/ClothesResponse.java +++ b/src/main/java/com/closetnangam/be/domain/clothes/dto/response/ClothesResponse.java @@ -2,6 +2,9 @@ import com.closetnangam.be.domain.catalog.enums.ClothesColor; import com.closetnangam.be.domain.clothes.entity.Clothes; +import com.closetnangam.be.domain.clothes.entity.ClothingColor; +import com.closetnangam.be.domain.clothes.entity.WardrobeClothes; +import com.closetnangam.be.domain.clothes.enums.ColorRole; import com.closetnangam.be.domain.clothes.enums.SourceType; import java.time.LocalDateTime; @@ -9,6 +12,7 @@ public record ClothesResponse( Long clothesId, + Long wardrobeClothesId, Long wardrobeId, Long userId, String name, @@ -17,8 +21,9 @@ public record ClothesResponse( String imageUrl, String category, String itemType, - String color, - ColorDisplayResponse colorDisplay, + String primaryColor, + ColorDisplayResponse primaryColorDisplay, + List secondaryColors, List styles, SourceType sourceType, String externalSource, @@ -26,49 +31,85 @@ public record ClothesResponse( String externalProductUrl, Boolean isVerified, Boolean isFavorite, + String size, + String season, + String userImageUrl, LocalDateTime createdAt, LocalDateTime updatedAt ) { public static ClothesResponse from(Clothes clothes) { - ClothesColor clothesColor = ClothesColor.fromCode(clothes.getColor()); + return from(clothes, null); + } + + public static ClothesResponse from(Clothes clothes, WardrobeClothes wardrobeClothes) { + ClothingColor primaryColorTag = clothes.getSortedColorTags().stream() + .filter(color -> color.getColorRole() == ColorRole.PRIMARY) + .findFirst() + .orElse(null); - List styles = clothes.getStyleTags().stream() + String primaryColorCode = primaryColorTag != null ? primaryColorTag.getColorCode() : null; + ColorDisplayResponse primaryColorDisplay = primaryColorCode != null + ? toColorDisplay(primaryColorCode) + : null; + + List secondaryColors = clothes.getSortedColorTags().stream() + .filter(color -> color.getColorRole() == ColorRole.SECONDARY) + .map(color -> new SecondaryColorResponse( + color.getColorCode(), + toColorDisplay(color.getColorCode()), + color.getSortOrder() + )) + .toList(); + + List styles = clothes.getSortedStyleTags().stream() .map(tag -> new StyleTagResponse( tag.getStyle().getId(), tag.getStyle().getCode(), - tag.getStyle().getName() + tag.getStyle().getName(), + tag.getStyleRole().name(), + tag.getSortOrder() )) .toList(); return new ClothesResponse( clothes.getId(), - clothes.getWardrobe().getId(), - clothes.getWardrobe().getUser().getId(), + wardrobeClothes != null ? wardrobeClothes.getId() : null, + wardrobeClothes != null ? wardrobeClothes.getWardrobe().getId() : null, + wardrobeClothes != null ? wardrobeClothes.getWardrobe().getUser().getId() : null, clothes.getName(), clothes.getBrandName(), clothes.getProductCode(), clothes.getImageUrl(), clothes.getCategory(), clothes.getItemType(), - clothes.getColor(), - new ColorDisplayResponse( - clothesColor.name(), - clothesColor.getLabel(), - clothesColor.getHex() - ), + primaryColorCode, + primaryColorDisplay, + secondaryColors, styles, clothes.getSourceType(), clothes.getExternalSource(), clothes.getExternalProductId(), clothes.getExternalProductUrl(), clothes.getIsVerified(), - clothes.getIsFavorite(), + wardrobeClothes != null ? wardrobeClothes.getFavorite() : null, + wardrobeClothes != null ? wardrobeClothes.getSize() : null, + wardrobeClothes != null ? wardrobeClothes.getSeason() : null, + wardrobeClothes != null ? wardrobeClothes.getUserImageUrl() : null, clothes.getCreatedAt(), clothes.getUpdatedAt() ); } + private static ColorDisplayResponse toColorDisplay(String colorCode) { + ClothesColor clothesColor = ClothesColor.fromCode(colorCode); + return new ColorDisplayResponse( + clothesColor.name(), + clothesColor.getLabel(), + clothesColor.getHex() + ); + } + public record ColorDisplayResponse( String code, String name, @@ -76,10 +117,19 @@ public record ColorDisplayResponse( ) { } + public record SecondaryColorResponse( + String code, + ColorDisplayResponse colorDisplay, + Byte sortOrder + ) { + } + public record StyleTagResponse( Long styleId, String code, - String name + String name, + String styleRole, + Byte sortOrder ) { } } diff --git a/src/main/java/com/closetnangam/be/domain/clothes/entity/Clothes.java b/src/main/java/com/closetnangam/be/domain/clothes/entity/Clothes.java index 2d4dc90..9cbfe99 100644 --- a/src/main/java/com/closetnangam/be/domain/clothes/entity/Clothes.java +++ b/src/main/java/com/closetnangam/be/domain/clothes/entity/Clothes.java @@ -1,7 +1,6 @@ package com.closetnangam.be.domain.clothes.entity; import com.closetnangam.be.domain.clothes.enums.SourceType; -import com.closetnangam.be.domain.wardrobe.entity.Wardrobe; import com.closetnangam.be.global.common.entity.BaseEntity; import jakarta.persistence.CascadeType; import jakarta.persistence.Column; @@ -12,20 +11,19 @@ import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; import jakarta.persistence.Id; -import jakarta.persistence.JoinColumn; -import jakarta.persistence.ManyToOne; import jakarta.persistence.OneToMany; +import jakarta.persistence.OrderBy; import jakarta.persistence.Table; +import jakarta.persistence.Version; +import org.hibernate.annotations.BatchSize; import lombok.AccessLevel; import lombok.Builder; import lombok.Getter; import lombok.NoArgsConstructor; import java.util.ArrayList; -import java.util.HashSet; +import java.util.Comparator; import java.util.List; -import java.util.Set; -import java.util.stream.Collectors; @Entity @Getter @@ -40,10 +38,6 @@ public class Clothes extends BaseEntity { @Column(name = "clothes_id") private Long id; - @ManyToOne(fetch = FetchType.LAZY, optional = false) - @JoinColumn(name = "wardrobe_id", nullable = false) - private Wardrobe wardrobe; - @Column(nullable = false) private String name; @@ -62,9 +56,6 @@ public class Clothes extends BaseEntity { @Column(name = "item_type", nullable = false, length = 50) private String itemType; - @Column(nullable = false, length = 50) - private String color; - @Enumerated(EnumType.STRING) @Column(name = "source_type", nullable = false, length = 50) private SourceType sourceType; @@ -72,7 +63,7 @@ public class Clothes extends BaseEntity { @Column(name = "external_source", nullable = false, length = 50) private String externalSource; - @Column(name = "external_product_id", nullable = false) + @Column(name = "external_product_id", nullable = false, length = 255) private String externalProductId; @Column(name = "external_product_url", nullable = false, length = 500) @@ -81,43 +72,45 @@ public class Clothes extends BaseEntity { @Column(name = "is_verified", nullable = false) private Boolean isVerified; - @Column(name = "is_favorite", nullable = false) - private Boolean isFavorite = false; + @Version + @Column(nullable = false) + private Long version; - @OneToMany(mappedBy = "clothes", cascade = CascadeType.ALL, orphanRemoval = true) + @BatchSize(size = 100) + @OrderBy("sortOrder ASC") + @OneToMany(mappedBy = "clothes", cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.LAZY) + private List colorTags = new ArrayList<>(); + + @BatchSize(size = 100) + @OrderBy("sortOrder ASC") + @OneToMany(mappedBy = "clothes", cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.LAZY) private List styleTags = new ArrayList<>(); @Builder private Clothes( - Wardrobe wardrobe, String name, String brandName, String productCode, String imageUrl, String category, String itemType, - String color, SourceType sourceType, String externalSource, String externalProductId, String externalProductUrl, - Boolean isVerified, - Boolean isFavorite + Boolean isVerified ) { - this.wardrobe = wardrobe; this.name = name; this.brandName = brandName; this.productCode = productCode; this.imageUrl = imageUrl; this.category = category; this.itemType = itemType; - this.color = color; this.sourceType = sourceType; this.externalSource = externalSource; this.externalProductId = externalProductId; this.externalProductUrl = externalProductUrl; this.isVerified = isVerified; - this.isFavorite = isFavorite != null ? isFavorite : false; } public void update( @@ -127,7 +120,6 @@ public void update( String imageUrl, String category, String itemType, - String color, Boolean isVerified ) { this.name = name; @@ -136,32 +128,37 @@ public void update( this.imageUrl = imageUrl; this.category = category; this.itemType = itemType; - this.color = color; this.isVerified = isVerified; } - public void replaceStyleTags(List newStyleTags) { - Set newStyleIds = newStyleTags.stream() - .map(tag -> tag.getStyle().getId()) - .collect(Collectors.toSet()); - - this.styleTags.removeIf(existing -> !newStyleIds.contains(existing.getStyle().getId())); + public void replaceColorTags(List newColorTags) { + this.colorTags.clear(); + this.colorTags.addAll(newColorTags); + } - Set existingStyleIds = this.styleTags.stream() - .map(tag -> tag.getStyle().getId()) - .collect(Collectors.toSet()); + public void replaceStyleTags(List newStyleTags) { + this.styleTags.clear(); + this.styleTags.addAll(newStyleTags); + } - newStyleTags.stream() - .filter(tag -> !existingStyleIds.contains(tag.getStyle().getId())) - .forEach(this.styleTags::add); + public void addColorTag(ClothingColor colorTag) { + this.colorTags.add(colorTag); } public void addStyleTag(ClothesStyleTag styleTag) { this.styleTags.add(styleTag); } - public void updateFavorite(Boolean isFavorite) { - this.isFavorite = isFavorite; + public List getSortedColorTags() { + return colorTags.stream() + .sorted(Comparator.comparing(ClothingColor::getSortOrder)) + .toList(); + } + + public List getSortedStyleTags() { + return styleTags.stream() + .sorted(Comparator.comparing(ClothesStyleTag::getSortOrder)) + .toList(); } public void convertToOwned(String productCode, Boolean isVerified) { diff --git a/src/main/java/com/closetnangam/be/domain/clothes/entity/ClothesStyleTag.java b/src/main/java/com/closetnangam/be/domain/clothes/entity/ClothesStyleTag.java index 8bf820d..bc2456e 100644 --- a/src/main/java/com/closetnangam/be/domain/clothes/entity/ClothesStyleTag.java +++ b/src/main/java/com/closetnangam/be/domain/clothes/entity/ClothesStyleTag.java @@ -1,8 +1,12 @@ package com.closetnangam.be.domain.clothes.entity; import com.closetnangam.be.domain.catalog.entity.Style; +import com.closetnangam.be.domain.clothes.enums.StyleRole; +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; @@ -25,7 +29,7 @@ columnNames = {"clothes_id", "style_id"} ) ) -public class ClothesStyleTag { +public class ClothesStyleTag extends BaseEntity { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) @@ -40,12 +44,21 @@ public class ClothesStyleTag { @JoinColumn(name = "style_id", nullable = false) private Style style; - private ClothesStyleTag(Clothes clothes, Style style) { + @Enumerated(EnumType.STRING) + @Column(name = "style_role", nullable = false, length = 20) + private StyleRole styleRole; + + @Column(name = "sort_order", nullable = false) + private Byte sortOrder; + + private ClothesStyleTag(Clothes clothes, Style style, StyleRole styleRole, byte sortOrder) { this.clothes = clothes; this.style = style; + this.styleRole = styleRole; + this.sortOrder = sortOrder; } - public static ClothesStyleTag create(Clothes clothes, Style style) { - return new ClothesStyleTag(clothes, style); + public static ClothesStyleTag create(Clothes clothes, Style style, StyleRole styleRole, byte sortOrder) { + return new ClothesStyleTag(clothes, style, styleRole, sortOrder); } } diff --git a/src/main/java/com/closetnangam/be/domain/clothes/entity/ClothingColor.java b/src/main/java/com/closetnangam/be/domain/clothes/entity/ClothingColor.java new file mode 100644 index 0000000..7680215 --- /dev/null +++ b/src/main/java/com/closetnangam/be/domain/clothes/entity/ClothingColor.java @@ -0,0 +1,62 @@ +package com.closetnangam.be.domain.clothes.entity; + +import com.closetnangam.be.domain.clothes.enums.ColorRole; +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 jakarta.persistence.UniqueConstraint; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Table( + name = "clothing_colors", + uniqueConstraints = @UniqueConstraint( + name = "uk_clothing_colors_clothes_role_order", + columnNames = {"clothes_id", "color_role", "sort_order"} + ) +) +public class ClothingColor extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "clothing_color_id") + private Long id; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "clothes_id", nullable = false) + private Clothes clothes; + + @Column(name = "color_code", nullable = false, length = 50) + private String colorCode; + + @Enumerated(EnumType.STRING) + @Column(name = "color_role", nullable = false, length = 20) + private ColorRole colorRole; + + @Column(name = "sort_order", nullable = false) + private Byte sortOrder; + + private ClothingColor(Clothes clothes, String colorCode, ColorRole colorRole, byte sortOrder) { + this.clothes = clothes; + this.colorCode = colorCode; + this.colorRole = colorRole; + this.sortOrder = sortOrder; + } + + public static ClothingColor create(Clothes clothes, String colorCode, ColorRole colorRole, byte sortOrder) { + return new ClothingColor(clothes, colorCode, colorRole, sortOrder); + } +} 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..6ae8c56 --- /dev/null +++ b/src/main/java/com/closetnangam/be/domain/clothes/entity/WardrobeClothes.java @@ -0,0 +1,99 @@ +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; + } + + public void updateFavorite(Boolean favorite) { + this.favorite = favorite; + } + + public void updateWardrobeDetails(String size, String season, String userImageUrl) { + this.size = size; + this.season = season; + this.userImageUrl = userImageUrl; + } + + public void convertToOwned(String size, String season, String userImageUrl) { + this.ownershipStatus = OwnershipStatus.OWNED; + this.size = size; + this.season = season; + this.userImageUrl = userImageUrl; + } +} diff --git a/src/main/java/com/closetnangam/be/domain/clothes/enums/ColorRole.java b/src/main/java/com/closetnangam/be/domain/clothes/enums/ColorRole.java new file mode 100644 index 0000000..b9c04c8 --- /dev/null +++ b/src/main/java/com/closetnangam/be/domain/clothes/enums/ColorRole.java @@ -0,0 +1,7 @@ +package com.closetnangam.be.domain.clothes.enums; + +public enum ColorRole { + + PRIMARY, + SECONDARY +} 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..8063094 --- /dev/null +++ b/src/main/java/com/closetnangam/be/domain/clothes/enums/RegistrationSource.java @@ -0,0 +1,8 @@ +package com.closetnangam.be.domain.clothes.enums; + +public enum RegistrationSource { + + PHOTO, + PURCHASE_HISTORY, + MANUAL +} diff --git a/src/main/java/com/closetnangam/be/domain/clothes/enums/StyleRole.java b/src/main/java/com/closetnangam/be/domain/clothes/enums/StyleRole.java new file mode 100644 index 0000000..9e6d1ef --- /dev/null +++ b/src/main/java/com/closetnangam/be/domain/clothes/enums/StyleRole.java @@ -0,0 +1,7 @@ +package com.closetnangam.be.domain.clothes.enums; + +public enum StyleRole { + + PRIMARY, + SECONDARY +} diff --git a/src/main/java/com/closetnangam/be/domain/clothes/repository/ClothesRepository.java b/src/main/java/com/closetnangam/be/domain/clothes/repository/ClothesRepository.java index d787c2a..e81955d 100644 --- a/src/main/java/com/closetnangam/be/domain/clothes/repository/ClothesRepository.java +++ b/src/main/java/com/closetnangam/be/domain/clothes/repository/ClothesRepository.java @@ -1,57 +1,7 @@ package com.closetnangam.be.domain.clothes.repository; import com.closetnangam.be.domain.clothes.entity.Clothes; -import com.closetnangam.be.domain.clothes.enums.SourceType; import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.data.jpa.repository.Query; -import org.springframework.data.repository.query.Param; - -import java.util.List; -import java.util.Optional; public interface ClothesRepository extends JpaRepository { - - /** - * ClothesResponse 매핑을 위해 wardrobe, user, styleTags, style을 join fetch합니다. - * fetch 대상 변경 시 {@link com.closetnangam.be.domain.clothes.dto.response.ClothesResponse#from}도 함께 검토하세요. - */ - @Query(""" - select distinct c from Clothes c - join fetch c.wardrobe w - join fetch w.user - left join fetch c.styleTags st - left join fetch st.style - where w.user.id = :userId and c.sourceType = :sourceType - order by c.createdAt desc - """) - List findAllByUserIdAndSourceType( - @Param("userId") Long userId, - @Param("sourceType") SourceType sourceType - ); - - @Query(""" - select c from Clothes c - join fetch c.wardrobe w - join fetch w.user - left join fetch c.styleTags st - left join fetch st.style - where c.id = :clothesId - """) - Optional findByIdWithDetails(@Param("clothesId") Long clothesId); - - @Query(""" - select distinct c from Clothes c - join fetch c.wardrobe w - join fetch w.user - left join fetch c.styleTags st - left join fetch st.style - where w.user.id = :userId - and c.sourceType = :sourceType - and c.isFavorite = true - order by c.updatedAt desc - """) - List findFavoritesByUserIdAndSourceType( - @Param("userId") Long userId, - @Param("sourceType") SourceType sourceType - ); } 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..a3c4cc3 --- /dev/null +++ b/src/main/java/com/closetnangam/be/domain/clothes/repository/WardrobeClothesRepository.java @@ -0,0 +1,64 @@ +package com.closetnangam.be.domain.clothes.repository; + +import com.closetnangam.be.domain.clothes.entity.WardrobeClothes; +import com.closetnangam.be.domain.clothes.enums.OwnershipStatus; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.List; +import java.util.Optional; + +public interface WardrobeClothesRepository extends JpaRepository { + + @Query(""" + select distinct wc from WardrobeClothes wc + join fetch wc.clothes c + join fetch wc.wardrobe w + join fetch w.user + where w.user.id = :userId + and wc.ownershipStatus = :ownershipStatus + order by c.createdAt desc + """) + List findAllByUserIdAndOwnershipStatus( + @Param("userId") Long userId, + @Param("ownershipStatus") OwnershipStatus ownershipStatus + ); + + @Query(""" + select distinct wc from WardrobeClothes wc + join fetch wc.clothes c + join fetch wc.wardrobe w + join fetch w.user + where w.user.id = :userId + and wc.ownershipStatus = :ownershipStatus + and wc.favorite = true + order by wc.updatedAt desc + """) + List findFavoritesByUserIdAndOwnershipStatus( + @Param("userId") Long userId, + @Param("ownershipStatus") OwnershipStatus ownershipStatus + ); + + @Query(""" + select wc from WardrobeClothes wc + join fetch wc.clothes c + join fetch wc.wardrobe w + join fetch w.user + where c.id = :clothesId + """) + Optional findByClothesIdWithDetails(@Param("clothesId") Long clothesId); + + @Query(""" + select wc from WardrobeClothes wc + join fetch wc.clothes c + join fetch wc.wardrobe w + where c.id = :clothesId and w.user.id = :userId + """) + Optional findByClothesIdAndUserId( + @Param("clothesId") Long clothesId, + @Param("userId") Long userId + ); + + void deleteByClothes_Id(Long clothesId); +} diff --git a/src/main/java/com/closetnangam/be/domain/clothes/service/ClothesService.java b/src/main/java/com/closetnangam/be/domain/clothes/service/ClothesService.java index 31f2366..e339420 100644 --- a/src/main/java/com/closetnangam/be/domain/clothes/service/ClothesService.java +++ b/src/main/java/com/closetnangam/be/domain/clothes/service/ClothesService.java @@ -11,26 +11,28 @@ import com.closetnangam.be.domain.clothes.dto.response.ClothesResponse; 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.entity.WardrobeClothes; +import com.closetnangam.be.domain.clothes.enums.ColorRole; +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.enums.StyleRole; import com.closetnangam.be.domain.clothes.repository.ClothesRepository; +import com.closetnangam.be.domain.clothes.repository.WardrobeClothesRepository; import com.closetnangam.be.domain.wardrobe.entity.Wardrobe; import com.closetnangam.be.domain.wardrobe.service.WardrobeService; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.CollectionUtils; +import java.util.ArrayList; import java.util.List; +import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collectors; -/** - * 옷 CRUD 서비스. - *

- * {@link ClothesResponse} 매핑은 {@code wardrobe}, {@code user}, {@code styleTags.style} 연관 관계가 - * 로딩된 {@link Clothes} 엔티티를 전제로 합니다. 목록/상세 조회는 - * {@link com.closetnangam.be.domain.clothes.repository.ClothesRepository}의 join fetch 쿼리에 의존합니다. - *

- * 등록(create) 직후 응답은 영속성 컨텍스트 내 lazy loading으로 처리됩니다. - * 조회 API와 동일한 fetch 전략이 필요하면 DTO 프로젝션 또는 save 후 재조회로 전환을 검토하세요. - */ @Service @RequiredArgsConstructor @Transactional(readOnly = true) @@ -39,109 +41,153 @@ public class ClothesService { private static final String EXTERNAL_NONE = "NONE"; private final ClothesRepository clothesRepository; + private final WardrobeClothesRepository wardrobeClothesRepository; private final StyleRepository styleRepository; private final CategoryCatalogService categoryCatalogService; private final WardrobeService wardrobeService; public List getOwnedClothes(Long userId) { - return clothesRepository.findAllByUserIdAndSourceType(userId, SourceType.OWNED).stream() - .map(ClothesResponse::from) + return wardrobeClothesRepository.findAllByUserIdAndOwnershipStatus(userId, OwnershipStatus.OWNED).stream() + .map(entry -> ClothesResponse.from(entry.getClothes(), entry)) .toList(); } public List getFavoriteOwnedClothes(Long userId) { - return clothesRepository.findFavoritesByUserIdAndSourceType(userId, SourceType.OWNED).stream() - .map(ClothesResponse::from) + return wardrobeClothesRepository.findFavoritesByUserIdAndOwnershipStatus(userId, OwnershipStatus.OWNED).stream() + .map(entry -> ClothesResponse.from(entry.getClothes(), entry)) .toList(); } public List getWishlistClothes(Long userId) { - return clothesRepository.findAllByUserIdAndSourceType(userId, SourceType.WISHLIST).stream() - .map(ClothesResponse::from) + return wardrobeClothesRepository.findAllByUserIdAndOwnershipStatus(userId, OwnershipStatus.WISHLIST).stream() + .map(entry -> ClothesResponse.from(entry.getClothes(), entry)) .toList(); } public List getFavoriteWishlistClothes(Long userId) { - return clothesRepository.findFavoritesByUserIdAndSourceType(userId, SourceType.WISHLIST).stream() - .map(ClothesResponse::from) + return wardrobeClothesRepository.findFavoritesByUserIdAndOwnershipStatus(userId, OwnershipStatus.WISHLIST).stream() + .map(entry -> ClothesResponse.from(entry.getClothes(), entry)) .toList(); } public ClothesResponse getClothes(Long clothesId) { - Clothes clothes = getClothesWithDetails(clothesId); - return ClothesResponse.from(clothes); + WardrobeClothes wardrobeClothes = wardrobeClothesRepository.findByClothesIdWithDetails(clothesId).orElse(null); + Clothes clothes = wardrobeClothes != null + ? wardrobeClothes.getClothes() + : getClothesWithDetails(clothesId); + return ClothesResponse.from(clothes, wardrobeClothes); } @Transactional public ClothesResponse createOwnedClothes(Long userId, ClothesCreateRequest request) { - validateClassification(request.category(), request.itemType(), request.color(), request.styles()); + validateClassification( + request.category(), + request.itemType(), + request.primaryColor(), + request.secondaryColors(), + request.styles() + ); Wardrobe wardrobe = wardrobeService.getOrCreateWardrobe(userId); + Clothes clothes = buildClothes( + request.name(), + request.brandName(), + request.productCode(), + request.imageUrl(), + request.category(), + request.itemType(), + SourceType.OWNED, + EXTERNAL_NONE, + EXTERNAL_NONE, + EXTERNAL_NONE, + request.isVerified(), + request.primaryColor(), + request.secondaryColors(), + request.styles() + ); + Clothes savedClothes = clothesRepository.save(clothes); - Clothes clothes = Clothes.builder() + WardrobeClothes wardrobeClothes = wardrobeClothesRepository.save(WardrobeClothes.builder() .wardrobe(wardrobe) - .name(request.name()) - .brandName(request.brandName()) - .productCode(request.productCode()) - .imageUrl(request.imageUrl()) - .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(false) - .build(); + .clothes(savedClothes) + .ownershipStatus(OwnershipStatus.OWNED) + .size(request.size()) + .season(request.season()) + .favorite(false) + .registrationSource(RegistrationSource.MANUAL) + .userImageUrl(request.imageUrl()) + .build()); - applyStyleTags(clothes, request.styles()); - Clothes saved = clothesRepository.save(clothes); - // save 직후 join fetch 재조회로 N+1 방지 (wardrobe.user, styleTags.style lazy 로딩 차단) - return ClothesResponse.from(getClothesWithDetails(saved.getId())); + return ClothesResponse.from(getClothesWithDetails(savedClothes.getId()), wardrobeClothes); } @Transactional public ClothesResponse createWishlistClothes(Long userId, WishlistClothesCreateRequest request) { - validateClassification(request.category(), request.itemType(), request.color(), request.styles()); + validateClassification( + request.category(), + request.itemType(), + request.primaryColor(), + request.secondaryColors(), + request.styles() + ); Wardrobe wardrobe = wardrobeService.getOrCreateWardrobe(userId); + Clothes clothes = buildClothes( + request.name(), + request.brandName(), + request.productCode(), + request.imageUrl(), + request.category(), + request.itemType(), + SourceType.WISHLIST, + request.externalSource(), + request.externalProductId(), + request.externalProductUrl(), + false, + request.primaryColor(), + request.secondaryColors(), + request.styles() + ); + Clothes savedClothes = clothesRepository.save(clothes); - Clothes clothes = Clothes.builder() + WardrobeClothes wardrobeClothes = wardrobeClothesRepository.save(WardrobeClothes.builder() .wardrobe(wardrobe) - .name(request.name()) - .brandName(request.brandName()) - .productCode(request.productCode()) - .imageUrl(request.imageUrl()) - .category(request.category()) - .itemType(request.itemType()) - .color(request.color()) - .sourceType(SourceType.WISHLIST) - .externalSource(request.externalSource()) - .externalProductId(request.externalProductId()) - .externalProductUrl(request.externalProductUrl()) - .isVerified(false) - .isFavorite(false) - .build(); + .clothes(savedClothes) + .ownershipStatus(OwnershipStatus.WISHLIST) + .size(request.size()) + .season(request.season()) + .favorite(false) + .registrationSource(RegistrationSource.MANUAL) + .userImageUrl(request.imageUrl()) + .build()); - applyStyleTags(clothes, request.styles()); - Clothes saved = clothesRepository.save(clothes); - // save 직후 join fetch 재조회로 N+1 방지 (wardrobe.user, styleTags.style lazy 로딩 차단) - return ClothesResponse.from(getClothesWithDetails(saved.getId())); + return ClothesResponse.from(getClothesWithDetails(savedClothes.getId()), wardrobeClothes); } @Transactional public ClothesResponse convertToOwned(Long clothesId, ClothesConvertToOwnedRequest request) { - Clothes clothes = getClothesWithDetails(clothesId); - clothes.convertToOwned(request.productCode(), request.isVerified()); - return ClothesResponse.from(clothes); + WardrobeClothes wardrobeClothes = wardrobeClothesRepository.findByClothesIdWithDetails(clothesId) + .orElseThrow(() -> new IllegalArgumentException("옷장 등록 정보를 찾을 수 없습니다.")); + + wardrobeClothes.getClothes().convertToOwned(request.productCode(), request.isVerified()); + wardrobeClothes.convertToOwned(request.size(), request.season(), request.userImageUrl()); + + return ClothesResponse.from(wardrobeClothes.getClothes(), wardrobeClothes); } @Transactional public ClothesResponse updateClothes(Long clothesId, ClothesUpdateRequest request) { - validateClassification(request.category(), request.itemType(), request.color(), request.styles()); + validateClassification( + request.category(), + request.itemType(), + request.primaryColor(), + request.secondaryColors(), + request.styles() + ); - Clothes clothes = getClothesWithDetails(clothesId); + WardrobeClothes wardrobeClothes = wardrobeClothesRepository.findByClothesIdWithDetails(clothesId) + .orElseThrow(() -> new IllegalArgumentException("옷장 등록 정보를 찾을 수 없습니다.")); + Clothes clothes = wardrobeClothes.getClothes(); clothes.update( request.name(), @@ -150,42 +196,108 @@ public ClothesResponse updateClothes(Long clothesId, ClothesUpdateRequest reques request.imageUrl(), request.category(), request.itemType(), - request.color(), request.isVerified() ); - + clothes.replaceColorTags(buildColorTags(clothes, request.primaryColor(), request.secondaryColors())); clothes.replaceStyleTags(buildStyleTags(clothes, request.styles())); - return ClothesResponse.from(clothes); + + wardrobeClothes.updateWardrobeDetails( + request.size(), + request.season(), + request.imageUrl() + ); + + return ClothesResponse.from(clothes, wardrobeClothes); } @Transactional public ClothesResponse updateFavorite(Long clothesId, ClothesFavoriteRequest request) { - Clothes clothes = getClothesWithDetails(clothesId); - clothes.updateFavorite(request.isFavorite()); - return ClothesResponse.from(clothes); + WardrobeClothes wardrobeClothes = wardrobeClothesRepository.findByClothesIdWithDetails(clothesId) + .orElseThrow(() -> new IllegalArgumentException("옷장 등록 정보를 찾을 수 없습니다.")); + wardrobeClothes.updateFavorite(request.isFavorite()); + return ClothesResponse.from(wardrobeClothes.getClothes(), wardrobeClothes); } - @Transactional + @Transactional(readOnly = false) public void deleteClothes(Long clothesId) { - Clothes clothes = getClothesWithDetails(clothesId); - clothesRepository.delete(clothes); + getClothesWithDetails(clothesId); + wardrobeClothesRepository.deleteByClothes_Id(clothesId); + clothesRepository.deleteById(clothesId); } private Clothes getClothesWithDetails(Long clothesId) { - return clothesRepository.findByIdWithDetails(clothesId) + return clothesRepository.findById(clothesId) .orElseThrow(() -> new IllegalArgumentException("옷을 찾을 수 없습니다.")); } + private Clothes buildClothes( + String name, + String brandName, + String productCode, + String imageUrl, + String category, + String itemType, + SourceType sourceType, + String externalSource, + String externalProductId, + String externalProductUrl, + Boolean isVerified, + String primaryColor, + List secondaryColors, + List styles + ) { + Clothes clothes = Clothes.builder() + .name(name) + .brandName(brandName) + .productCode(productCode) + .imageUrl(imageUrl) + .category(category) + .itemType(itemType) + .sourceType(sourceType) + .externalSource(externalSource) + .externalProductId(externalProductId) + .externalProductUrl(externalProductUrl) + .isVerified(isVerified) + .build(); + + applyColorTags(clothes, primaryColor, secondaryColors); + applyStyleTags(clothes, styles); + return clothes; + } + private void validateClassification( String category, String itemType, - String color, + String primaryColor, + List secondaryColors, List styles ) { - categoryCatalogService.validateClothesClassification(category, itemType, color); + categoryCatalogService.validateCategoryAndItemType(category, itemType); + categoryCatalogService.validateClothesColors(primaryColor, secondaryColors); categoryCatalogService.validateStyleCodes(styles); } + private void applyColorTags(Clothes clothes, String primaryColor, List secondaryColors) { + buildColorTags(clothes, primaryColor, secondaryColors).forEach(clothes::addColorTag); + } + + private List buildColorTags( + Clothes clothes, + String primaryColor, + List secondaryColors + ) { + List colorTags = new ArrayList<>(); + colorTags.add(ClothingColor.create(clothes, primaryColor, ColorRole.PRIMARY, (byte) 0)); + + if (!CollectionUtils.isEmpty(secondaryColors)) { + byte sortOrder = 1; + for (String secondaryColor : secondaryColors) { + colorTags.add(ClothingColor.create(clothes, secondaryColor, ColorRole.SECONDARY, sortOrder++)); + } + } + return colorTags; + } + private void applyStyleTags(Clothes clothes, List styleCodes) { buildStyleTags(clothes, styleCodes).forEach(clothes::addStyleTag); } @@ -195,8 +307,17 @@ private List buildStyleTags(Clothes clothes, List style if (styles.size() != styleCodes.size()) { throw new IllegalArgumentException("존재하지 않는 스타일 코드가 포함되어 있습니다."); } - return styles.stream() - .map(style -> ClothesStyleTag.create(clothes, style)) - .toList(); + + Map styleMap = styles.stream() + .collect(Collectors.toMap(Style::getCode, Function.identity())); + + List styleTags = new ArrayList<>(); + byte sortOrder = 0; + for (String styleCode : styleCodes) { + Style style = styleMap.get(styleCode); + StyleRole styleRole = sortOrder == 0 ? StyleRole.PRIMARY : StyleRole.SECONDARY; + styleTags.add(ClothesStyleTag.create(clothes, style, styleRole, sortOrder++)); + } + return styleTags; } } diff --git a/src/main/java/com/closetnangam/be/domain/wardrobe/service/WardrobeService.java b/src/main/java/com/closetnangam/be/domain/wardrobe/service/WardrobeService.java index 922282f..74d1c97 100644 --- a/src/main/java/com/closetnangam/be/domain/wardrobe/service/WardrobeService.java +++ b/src/main/java/com/closetnangam/be/domain/wardrobe/service/WardrobeService.java @@ -25,6 +25,9 @@ public WardrobeResponse getWardrobeByUserId(Long userId) { @Transactional public WardrobeResponse createWardrobe(Long userId) { + if (wardrobeRepository.existsByUser_Id(userId)) { + throw new IllegalStateException("이미 옷장이 존재합니다."); + } return WardrobeResponse.from(createWardrobeEntity(userId)); } @@ -37,11 +40,6 @@ public Wardrobe getOrCreateWardrobe(Long userId) { private Wardrobe createWardrobeEntity(Long userId) { User user = userRepository.findById(userId) .orElseThrow(() -> new IllegalArgumentException("사용자를 찾을 수 없습니다.")); - - if (wardrobeRepository.existsByUser_Id(userId)) { - throw new IllegalStateException("이미 옷장이 존재합니다."); - } - return wardrobeRepository.save(Wardrobe.create(user)); } } diff --git a/src/main/resources/db/migration.sql b/src/main/resources/db/migration.sql new file mode 100644 index 0000000..f1f56bb --- /dev/null +++ b/src/main/resources/db/migration.sql @@ -0,0 +1,61 @@ +-- ===================================================================== +-- 마이그레이션: clothes 테이블 분리 (color, wardrobe 관계 테이블 이전) +-- 실행 전 반드시 백업 후 진행하세요. +-- ===================================================================== + +-- 1. version 컬럼 추가 (낙관적 락) +-- ddl-auto: update 환경에서는 앱 재시작 시 자동 추가됨 +ALTER TABLE `clothes` + ADD COLUMN `version` BIGINT NOT NULL DEFAULT 0; + +-- ===================================================================== +-- 아래 구문은 기존 데이터가 있을 경우에만 실행하세요. +-- 로컬 개발 환경은 DB를 초기화 후 앱을 재시작하면 됩니다. +-- ===================================================================== + +-- 2. 기존 clothes.color → clothing_colors 백필 +-- (기존 테이블에 color VARCHAR 컬럼이 있었던 경우) +-- INSERT INTO `clothing_colors` (clothes_id, color_code, color_role, sort_order, created_at, updated_at) +-- SELECT clothes_id, color, 'PRIMARY', 0, NOW(), NOW() +-- FROM `clothes` +-- WHERE color IS NOT NULL; + +-- 3. 기존 clothes.wardrobe_id → wardrobe_clothes 백필 +-- (기존 테이블에 wardrobe_id, is_favorite 컬럼이 있었던 경우) +-- INSERT INTO `wardrobe_clothes` +-- (wardrobe_id, clothes_id, ownership_status, size, season, favorite, +-- registration_source, user_image_url, created_at, updated_at) +-- SELECT +-- wardrobe_id, +-- clothes_id, +-- source_type, -- 'OWNED' or 'WISHLIST' +-- 'FREE', -- size 기본값 (실제 값으로 교체) +-- NULL, -- season +-- COALESCE(is_favorite, 0), +-- 'MANUAL', +-- image_url, +-- NOW(), +-- NOW() +-- FROM `clothes` +-- WHERE wardrobe_id IS NOT NULL; + +-- 4. clothing_styles에 style_role, sort_order 컬럼 추가된 경우 백필 +-- (기존 clothing_styles row에 해당 컬럼이 NULL인 경우) +-- ALTER TABLE `clothing_styles` +-- ADD COLUMN IF NOT EXISTS `style_role` VARCHAR(20) NOT NULL DEFAULT 'PRIMARY', +-- ADD COLUMN IF NOT EXISTS `sort_order` TINYINT NOT NULL DEFAULT 0; +-- +-- -- 첫 번째 스타일은 PRIMARY, 나머지는 SECONDARY로 설정 +-- UPDATE `clothing_styles` cs +-- JOIN ( +-- SELECT clothes_id, style_id, +-- ROW_NUMBER() OVER (PARTITION BY clothes_id ORDER BY clothing_style_id) AS rn +-- FROM `clothing_styles` +-- ) ranked ON cs.clothes_id = ranked.clothes_id AND cs.style_id = ranked.style_id +-- SET cs.style_role = IF(ranked.rn = 1, 'PRIMARY', 'SECONDARY'), +-- cs.sort_order = ranked.rn - 1; + +-- 5. 이전 완료 후 기존 컬럼 제거 (데이터 검증 후 실행) +-- ALTER TABLE `clothes` DROP COLUMN `color`; +-- ALTER TABLE `clothes` DROP COLUMN `wardrobe_id`; +-- ALTER TABLE `clothes` DROP COLUMN `is_favorite`;