From cddab0a10d7c8c70fca316efde0f8c8cfb897077 Mon Sep 17 00:00:00 2001 From: marshmallowing Date: Fri, 13 Feb 2026 13:10:54 +0900 Subject: [PATCH 01/12] =?UTF-8?q?feat:=20=EB=A6=AC=EB=B7=B0=20=EC=83=81?= =?UTF-8?q?=ED=83=9C=20enum=20=EC=83=9D=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/review/enums/ClothingCategory.java | 41 +++++++++++++++++++ .../domain/review/enums/ColorAnswer.java | 14 +++++++ .../domain/review/enums/MaterialAnswer.java | 21 ++++++++++ .../review/enums/MaterialFeatureType.java | 21 ++++++++++ .../domain/review/enums/SizeAnswer.java | 17 ++++++++ 5 files changed, 114 insertions(+) create mode 100644 src/main/java/com/ongil/backend/domain/review/enums/ClothingCategory.java create mode 100644 src/main/java/com/ongil/backend/domain/review/enums/ColorAnswer.java create mode 100644 src/main/java/com/ongil/backend/domain/review/enums/MaterialAnswer.java create mode 100644 src/main/java/com/ongil/backend/domain/review/enums/MaterialFeatureType.java create mode 100644 src/main/java/com/ongil/backend/domain/review/enums/SizeAnswer.java diff --git a/src/main/java/com/ongil/backend/domain/review/enums/ClothingCategory.java b/src/main/java/com/ongil/backend/domain/review/enums/ClothingCategory.java new file mode 100644 index 0000000..e401a3e --- /dev/null +++ b/src/main/java/com/ongil/backend/domain/review/enums/ClothingCategory.java @@ -0,0 +1,41 @@ +package com.ongil.backend.domain.review.enums; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +import java.util.Arrays; +import java.util.List; + +import com.ongil.backend.global.common.exception.AppException; +import com.ongil.backend.global.common.exception.ErrorCode; + +@Getter +@RequiredArgsConstructor +public enum ClothingCategory { + OUTER("아우터", Arrays.asList( + "전반적", "어깨&목", "가슴&몸통", "겨드랑이&팔" + )), + TOP("상의", Arrays.asList( + "전반적", "어깨&목", "가슴&몸통", "겨드랑이&팔" + )), + SKIRT("스커트", Arrays.asList( + "전반적", "허리&복부", "엉덩이&가랑이", "허벅지&종아리" + )), + DRESS("원피스", Arrays.asList( + "전반적", "목&어깨", "가슴&몸통", "겨드랑이&팔", "엉덩이&다리", "기장" + )), + PANTS("팬츠", Arrays.asList( + "전반적", "허리&복부", "엉덩이&가랑이", "허벅지&종아리" + )); + + private final String displayName; + private final List bodyParts; + + public static ClothingCategory fromDisplayName(String name) { + return Arrays.stream(ClothingCategory.values()) + .filter(category -> category.getDisplayName().equals(name)) + .findFirst() + .orElseThrow(() -> new AppException(ErrorCode.CATEGORY_NOT_FOUND)); + } + +} diff --git a/src/main/java/com/ongil/backend/domain/review/enums/ColorAnswer.java b/src/main/java/com/ongil/backend/domain/review/enums/ColorAnswer.java new file mode 100644 index 0000000..4e95e90 --- /dev/null +++ b/src/main/java/com/ongil/backend/domain/review/enums/ColorAnswer.java @@ -0,0 +1,14 @@ +package com.ongil.backend.domain.review.enums; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum ColorAnswer { + BRIGHTER_THAN_SCREEN("화면보다 밝음"), + SAME_AS_SCREEN("화면과 똑같음"), + DARKER_THAN_SCREEN("어두움"); + + private final String displayName; +} diff --git a/src/main/java/com/ongil/backend/domain/review/enums/MaterialAnswer.java b/src/main/java/com/ongil/backend/domain/review/enums/MaterialAnswer.java new file mode 100644 index 0000000..20939af --- /dev/null +++ b/src/main/java/com/ongil/backend/domain/review/enums/MaterialAnswer.java @@ -0,0 +1,21 @@ +package com.ongil.backend.domain.review.enums; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum MaterialAnswer { + VERY_GOOD("너무 좋음", true), + GOOD("좋음", true), + NORMAL("무난함", false), + BAD("아쉬움", true), + VERY_BAD("너무 아쉬움", true); + + private final String displayName; + private final boolean needsSecondaryQuestion; + + public boolean isPositive() { + return this == VERY_GOOD || this == GOOD; + } +} diff --git a/src/main/java/com/ongil/backend/domain/review/enums/MaterialFeatureType.java b/src/main/java/com/ongil/backend/domain/review/enums/MaterialFeatureType.java new file mode 100644 index 0000000..c3da09d --- /dev/null +++ b/src/main/java/com/ongil/backend/domain/review/enums/MaterialFeatureType.java @@ -0,0 +1,21 @@ +package com.ongil.backend.domain.review.enums; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +import java.util.Arrays; +import java.util.List; + +@Getter +@RequiredArgsConstructor +public enum MaterialFeatureType { + TEXTURE("촉감", Arrays.asList("부드러움", "거칠음")), + WEIGHT("무게감", Arrays.asList("가벼움", "무거움")), + WRINKLE("구김 정도", Arrays.asList("없음", "많음")), + THICKNESS("두께감", Arrays.asList("두꺼움", "얇음")), + PILLING("보풀", Arrays.asList("없음", "있음")), + TRANSPARENCY("비침 정도", Arrays.asList("안비쳐요", "비쳐요")); + + private final String displayName; + private final List values; +} diff --git a/src/main/java/com/ongil/backend/domain/review/enums/SizeAnswer.java b/src/main/java/com/ongil/backend/domain/review/enums/SizeAnswer.java new file mode 100644 index 0000000..d6e3c08 --- /dev/null +++ b/src/main/java/com/ongil/backend/domain/review/enums/SizeAnswer.java @@ -0,0 +1,17 @@ +package com.ongil.backend.domain.review.enums; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum SizeAnswer { + TIGHT_IMMEDIATELY("입자마자 답답", true), + TIGHT_WHEN_MOVING("움직이면 답답", true), + COMFORTABLE("편함", false), + LOOSE("헐렁함", true), + TOO_BIG_NEED_ALTERATION("너무 큼, 수선필요", true); + + private final String displayName; + private final boolean needsSecondaryQuestion; +} From a13f60081210c52e57c1e244cfc8f02a16aca5ed Mon Sep 17 00:00:00 2001 From: marshmallowing Date: Fri, 13 Feb 2026 13:17:19 +0900 Subject: [PATCH 02/12] =?UTF-8?q?feat:=20=EB=8B=A8=EA=B3=84=EB=B3=84=20?= =?UTF-8?q?=EB=A6=AC=EB=B7=B0=20=EC=83=9D=EC=84=B1=20=EA=B8=B0=EB=8A=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../review/controller/ReviewController.java | 120 ++++++++++++- .../dto/request/ReviewStep1Request.java | 38 ++++ .../request/ReviewStep2MaterialRequest.java | 20 +++ .../dto/request/ReviewStep2SizeRequest.java | 19 ++ .../review/dto/response/ReviewIdResponse.java | 20 +++ .../dto/response/ReviewStep1Response.java | 27 +++ .../review/service/ReviewCommandService.java | 169 ++++++++++++++++++ 7 files changed, 404 insertions(+), 9 deletions(-) create mode 100644 src/main/java/com/ongil/backend/domain/review/dto/request/ReviewStep1Request.java create mode 100644 src/main/java/com/ongil/backend/domain/review/dto/request/ReviewStep2MaterialRequest.java create mode 100644 src/main/java/com/ongil/backend/domain/review/dto/request/ReviewStep2SizeRequest.java create mode 100644 src/main/java/com/ongil/backend/domain/review/dto/response/ReviewIdResponse.java create mode 100644 src/main/java/com/ongil/backend/domain/review/dto/response/ReviewStep1Response.java create mode 100644 src/main/java/com/ongil/backend/domain/review/service/ReviewCommandService.java diff --git a/src/main/java/com/ongil/backend/domain/review/controller/ReviewController.java b/src/main/java/com/ongil/backend/domain/review/controller/ReviewController.java index f1fd7d4..47c61ce 100644 --- a/src/main/java/com/ongil/backend/domain/review/controller/ReviewController.java +++ b/src/main/java/com/ongil/backend/domain/review/controller/ReviewController.java @@ -3,14 +3,24 @@ import java.util.List; import org.springframework.data.domain.Page; +import org.springframework.http.MediaType; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; +import com.ongil.backend.domain.review.dto.request.ReviewFinalSubmitRequest; import com.ongil.backend.domain.review.dto.request.ReviewListRequest; +import com.ongil.backend.domain.review.dto.request.ReviewStep1Request; +import com.ongil.backend.domain.review.dto.request.ReviewStep2MaterialRequest; +import com.ongil.backend.domain.review.dto.request.ReviewStep2SizeRequest; import com.ongil.backend.domain.review.dto.response.*; import com.ongil.backend.domain.review.enums.ReviewType; -import com.ongil.backend.domain.review.service.ReviewService; +import com.ongil.backend.domain.review.service.ReviewCommandService; +import com.ongil.backend.domain.review.service.ReviewQueryService; import com.ongil.backend.global.common.dto.DataResponse; +import com.ongil.backend.global.common.exception.ErrorCode; +import com.ongil.backend.global.common.exception.ValidationException; +import com.ongil.backend.global.config.s3.S3ImageService; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; @@ -22,7 +32,9 @@ @RequiredArgsConstructor public class ReviewController { - private final ReviewService reviewService; + private final ReviewCommandService reviewCommandService; + private final ReviewQueryService reviewQueryService; + private final S3ImageService s3ImageService; @Operation(summary = "상품별 리뷰 목록 조회", description = "상품의 리뷰 목록을 조회합니다. 유사 체형 필터, 사이즈/색상 필터, 정렬을 지원합니다.") @GetMapping("/api/products/{productId}/reviews") @@ -31,7 +43,7 @@ public DataResponse> getProductReviews( @AuthenticationPrincipal Long userId, @ModelAttribute ReviewListRequest request ) { - Page reviews = reviewService.getProductReviews(productId, userId, request); + Page reviews = reviewQueryService.getProductReviews(productId, userId, request); return DataResponse.from(reviews); } @@ -41,7 +53,7 @@ public DataResponse getReviewSummary( @PathVariable Long productId, @AuthenticationPrincipal Long userId ) { - ReviewSummaryResponse summary = reviewService.getReviewSummary(productId, userId); + ReviewSummaryResponse summary = reviewQueryService.getReviewSummary(productId, userId); return DataResponse.from(summary); } @@ -51,7 +63,7 @@ public DataResponse getReviewDetail( @PathVariable Long reviewId, @AuthenticationPrincipal Long userId ) { - ReviewDetailResponse detail = reviewService.getReviewDetail(reviewId, userId); + ReviewDetailResponse detail = reviewQueryService.getReviewDetail(reviewId, userId); return DataResponse.from(detail); } @@ -63,7 +75,7 @@ public DataResponse> getMyReviews( @Parameter(description = "페이지 번호") @RequestParam(defaultValue = "0") int page, @Parameter(description = "페이지 크기") @RequestParam(defaultValue = "10") int pageSize ) { - Page reviews = reviewService.getMyReviews(userId, reviewType, page, pageSize); + Page reviews = reviewQueryService.getMyReviews(userId, reviewType, page, pageSize); return DataResponse.from(reviews); } @@ -72,7 +84,7 @@ public DataResponse> getMyReviews( public DataResponse> getPendingReviews( @AuthenticationPrincipal Long userId ) { - List pendingReviews = reviewService.getPendingReviews(userId); + List pendingReviews = reviewQueryService.getPendingReviews(userId); return DataResponse.from(pendingReviews); } @@ -81,7 +93,7 @@ public DataResponse> getPendingReviews( public DataResponse getPendingReviewCount( @AuthenticationPrincipal Long userId ) { - int count = reviewService.getPendingReviewCount(userId); + int count = reviewQueryService.getPendingReviewCount(userId); PendingReviewCountResponse response = PendingReviewCountResponse.builder() .pendingReviewCount(count) .build(); @@ -94,7 +106,97 @@ public DataResponse toggleHelpful( @PathVariable Long reviewId, @AuthenticationPrincipal Long userId ) { - ReviewHelpfulResponse response = reviewService.toggleHelpful(reviewId, userId); + ReviewHelpfulResponse response = reviewQueryService.toggleHelpful(reviewId, userId); return DataResponse.from(response); } + + @Operation(summary = "리뷰 작성 시작(ID 발급)", description = "리뷰 작성 진입 시 DRAFT 상태의 리뷰 ID를 미리 발급받습니다.") + @PostMapping("/api/reviews/init") + public DataResponse initializeReview( + @AuthenticationPrincipal Long userId, + @RequestParam Long orderItemId + ) { + Long reviewId = reviewCommandService.initializeReview(userId, orderItemId); + return DataResponse.from(new ReviewIdResponse(reviewId)); + } + + @Operation(summary = "리뷰 작성 1단계", description = "별점, 착용감 등 1차 답변을 기입합니다. 이전 단계 수정 시에도 사용됩니다.") + @PatchMapping("/api/reviews/{reviewId}/step1") + public DataResponse updateReviewStep1( + @AuthenticationPrincipal Long userId, + @PathVariable Long reviewId, + @RequestBody ReviewStep1Request request + ) { + ReviewStep1Response response = reviewCommandService.updateReviewStep1(userId, reviewId, request); + return DataResponse.from(response); + } + + @Operation(summary = "리뷰 작성 2단계 - 사이즈", description = "사이즈 2차 질문(불편 부위)에 답변합니다.") + @PatchMapping("/api/reviews/{reviewId}/step2/size") + public DataResponse updateReviewStep2Size( + @AuthenticationPrincipal Long userId, + @PathVariable Long reviewId, + @RequestBody ReviewStep2SizeRequest request + ) { + reviewCommandService.updateReviewStep2Size(userId, reviewId, request); + return DataResponse.ok(); + } + + @Operation(summary = "리뷰 작성 2단계 - 소재", description = "소재 2차 질문(소재 특징)에 답변합니다.") + @PatchMapping("/api/reviews/{reviewId}/step2/material") + public DataResponse updateReviewStep2Material( + @AuthenticationPrincipal Long userId, + @PathVariable Long reviewId, + @RequestBody ReviewStep2MaterialRequest request + ) { + reviewCommandService.updateReviewStep2Material(userId, reviewId, request); + return DataResponse.ok(); + } + + @Operation(summary = "리뷰 작성 3단계 - 사이즈 AI 생성", description = "사이즈 관련 AI 리뷰를 생성합니다.") + @GetMapping("/api/reviews/{reviewId}/ai/size") + public DataResponse generateSizeAiReview( + @PathVariable Long reviewId, + @AuthenticationPrincipal Long userId + ) { + AiReviewResponse response = reviewCommandService.generateSizeAiReview(userId, reviewId); + return DataResponse.from(response); + } + + @Operation(summary = "리뷰 작성 3단계 - 소재 AI 생성", description = "소재 관련 AI 리뷰를 생성합니다.") + @GetMapping("/api/reviews/{reviewId}/ai/material") + public DataResponse generateMaterialAiReview( + @PathVariable Long reviewId, + @AuthenticationPrincipal Long userId + ) { + AiReviewResponse response = reviewCommandService.generateMaterialAiReview(userId, reviewId); + return DataResponse.from(response); + } + + @Operation(summary = "리뷰 사진 업로드", description = "리뷰용 사진을 최대 5장까지 S3에 업로드합니다.") + @PostMapping(value = "/api/reviews/images", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + public DataResponse> uploadReviewImages( + @RequestPart("images") List images + ) { + if (images.size() > 5) { + throw new ValidationException(ErrorCode.INVALID_PARAMETER); + } + + List imageUrls = images.stream() + .map(s3ImageService::uploadReviewImage) + .toList(); + return DataResponse.from(imageUrls); + } + + @Operation(summary = "리뷰 최종 제출", description = "최종 리뷰 문장들과 사진을 저장하고 상태를 COMPLETED로 변경합니다.") + @PostMapping("/api/reviews/{reviewId}/submit") + public DataResponse submitReview( + @AuthenticationPrincipal Long userId, + @PathVariable Long reviewId, + @RequestBody ReviewFinalSubmitRequest request + ) { + reviewCommandService.submitReview(userId, reviewId, request); + return DataResponse.ok(); + } + } diff --git a/src/main/java/com/ongil/backend/domain/review/dto/request/ReviewStep1Request.java b/src/main/java/com/ongil/backend/domain/review/dto/request/ReviewStep1Request.java new file mode 100644 index 0000000..a83844e --- /dev/null +++ b/src/main/java/com/ongil/backend/domain/review/dto/request/ReviewStep1Request.java @@ -0,0 +1,38 @@ +package com.ongil.backend.domain.review.dto.request; + +import com.ongil.backend.domain.review.enums.ClothingCategory; +import com.ongil.backend.domain.review.enums.ColorAnswer; +import com.ongil.backend.domain.review.enums.MaterialAnswer; +import com.ongil.backend.domain.review.enums.SizeAnswer; + +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +public class ReviewStep1Request { + + @NotBlank(message = "의류 카테고리는 필수입니다.") + private ClothingCategory clothingCategory; + + @NotNull(message = "별점은 필수입니다.") + @Min(value = 1, message = "별점은 1점 이상이어야 합니다.") + @Max(value = 5, message = "별점은 5점 이하여야 합니다.") + private Integer rating; + + @NotBlank(message = "착용감 답변은 필수입니다.") + private SizeAnswer sizeAnswer; + + @NotBlank(message = "색감 답변은 필수입니다.") + private ColorAnswer colorAnswer; + + @NotBlank(message = "소재 답변은 필수입니다.") + private MaterialAnswer materialAnswer; +} diff --git a/src/main/java/com/ongil/backend/domain/review/dto/request/ReviewStep2MaterialRequest.java b/src/main/java/com/ongil/backend/domain/review/dto/request/ReviewStep2MaterialRequest.java new file mode 100644 index 0000000..608510a --- /dev/null +++ b/src/main/java/com/ongil/backend/domain/review/dto/request/ReviewStep2MaterialRequest.java @@ -0,0 +1,20 @@ +package com.ongil.backend.domain.review.dto.request; + +import jakarta.validation.constraints.NotEmpty; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.util.List; + +import com.ongil.backend.domain.review.enums.MaterialFeatureType; + +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +public class ReviewStep2MaterialRequest { + + @NotEmpty(message = "소재 특징을 최소 1개 이상 선택해주세요.") + private List featureTypes; +} diff --git a/src/main/java/com/ongil/backend/domain/review/dto/request/ReviewStep2SizeRequest.java b/src/main/java/com/ongil/backend/domain/review/dto/request/ReviewStep2SizeRequest.java new file mode 100644 index 0000000..f086299 --- /dev/null +++ b/src/main/java/com/ongil/backend/domain/review/dto/request/ReviewStep2SizeRequest.java @@ -0,0 +1,19 @@ +package com.ongil.backend.domain.review.dto.request; + +import jakarta.validation.constraints.NotEmpty; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.util.List; + +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +public class ReviewStep2SizeRequest { + + @NotEmpty(message = "불편했던 부위를 최소 1개 이상 선택해주세요.") + private List fitIssueParts; + +} diff --git a/src/main/java/com/ongil/backend/domain/review/dto/response/ReviewIdResponse.java b/src/main/java/com/ongil/backend/domain/review/dto/response/ReviewIdResponse.java new file mode 100644 index 0000000..c262ca5 --- /dev/null +++ b/src/main/java/com/ongil/backend/domain/review/dto/response/ReviewIdResponse.java @@ -0,0 +1,20 @@ +package com.ongil.backend.domain.review.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@AllArgsConstructor +@NoArgsConstructor +@Builder +public class ReviewIdResponse { + @Schema(description = "발급된 리뷰 ID", example = "123") + private Long reviewId; + + public static ReviewIdResponse from(Long reviewId) { + return new ReviewIdResponse(reviewId); + } +} diff --git a/src/main/java/com/ongil/backend/domain/review/dto/response/ReviewStep1Response.java b/src/main/java/com/ongil/backend/domain/review/dto/response/ReviewStep1Response.java new file mode 100644 index 0000000..ab5c662 --- /dev/null +++ b/src/main/java/com/ongil/backend/domain/review/dto/response/ReviewStep1Response.java @@ -0,0 +1,27 @@ +package com.ongil.backend.domain.review.dto.response; + +import java.util.List; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder +@AllArgsConstructor +public class ReviewStep1Response { + + private Long reviewId; + private boolean needsSizeSecondaryQuestion; + private boolean needsMaterialSecondaryQuestion; + private List availableBodyParts; + + public static ReviewStep1Response of(Long reviewId, boolean needsSizeQ, boolean needsMaterialQ, List availableBodyParts) { + return ReviewStep1Response.builder() + .reviewId(reviewId) + .needsSizeSecondaryQuestion(needsSizeQ) + .needsMaterialSecondaryQuestion(needsMaterialQ) + .availableBodyParts(availableBodyParts) + .build(); + } +} diff --git a/src/main/java/com/ongil/backend/domain/review/service/ReviewCommandService.java b/src/main/java/com/ongil/backend/domain/review/service/ReviewCommandService.java new file mode 100644 index 0000000..136178b --- /dev/null +++ b/src/main/java/com/ongil/backend/domain/review/service/ReviewCommandService.java @@ -0,0 +1,169 @@ +package com.ongil.backend.domain.review.service; + +import java.util.stream.Collectors; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.ongil.backend.domain.order.entity.OrderItem; +import com.ongil.backend.domain.order.repository.OrderItemRepository; +import com.ongil.backend.domain.review.converter.ReviewWriteConverter; +import com.ongil.backend.domain.review.dto.request.AiReviewGenerateRequest; +import com.ongil.backend.domain.review.dto.request.ReviewFinalSubmitRequest; +import com.ongil.backend.domain.review.dto.request.ReviewStep1Request; +import com.ongil.backend.domain.review.dto.request.ReviewStep2MaterialRequest; +import com.ongil.backend.domain.review.dto.request.ReviewStep2SizeRequest; +import com.ongil.backend.domain.review.dto.response.AiReviewResponse; +import com.ongil.backend.domain.review.dto.response.ReviewStep1Response; +import com.ongil.backend.domain.review.entity.Review; +import com.ongil.backend.domain.review.enums.ClothingCategory; +import com.ongil.backend.domain.review.enums.MaterialAnswer; +import com.ongil.backend.domain.review.enums.MaterialFeatureType; +import com.ongil.backend.domain.review.repository.ReviewRepository; +import com.ongil.backend.domain.review.validator.ReviewValidator; +import com.ongil.backend.domain.user.entity.User; +import com.ongil.backend.domain.user.repository.UserRepository; +import com.ongil.backend.global.common.exception.EntityNotFoundException; +import com.ongil.backend.global.common.exception.ErrorCode; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +@Transactional +public class ReviewCommandService { + + private final ReviewRepository reviewRepository; + private final UserRepository userRepository; + private final OrderItemRepository orderItemRepository; + private final AiReviewGeneratorService aiReviewGeneratorService; + private final ReviewValidator reviewValidator; + private final ReviewWriteConverter reviewWriteConverter; + + @Transactional + public Long initializeReview(Long userId, Long orderItemId) { + User user = getUserOrThrow(userId); + OrderItem orderItem = getOrderItemOrThrow(orderItemId); + + reviewValidator.validateReviewAuthority(orderItem, userId); + reviewValidator.validateInitialReviewAlreadyExists(orderItemId); + + String categoryName = orderItem.getProduct().getCategory().getParentCategory().getName(); + ClothingCategory clothingCategory = ClothingCategory.fromDisplayName(categoryName); + + Review review = reviewWriteConverter.toInitialReviewEntity(user, orderItem, clothingCategory); + return reviewRepository.save(review).getId(); + } + + @Transactional + public ReviewStep1Response updateReviewStep1(Long userId, Long reviewId, ReviewStep1Request request) { + Review review = getReviewOrThrow(reviewId); + reviewValidator.validateReviewAuthority(review.getOrderItem(), userId); + + review.clearStep2AndStep3(); + + review.updateStep1( + request.getRating(), + request.getSizeAnswer(), + request.getColorAnswer(), + request.getMaterialAnswer() + ); + + return reviewWriteConverter.toStep1Response(review); + } + + @Transactional + public void updateReviewStep2Size(Long userId, Long reviewId, ReviewStep2SizeRequest request) { + Review review = getReviewOrThrow(reviewId); + reviewValidator.validateReviewAuthority(review.getOrderItem(), userId); + + String fitIssueParts = String.join(",", request.getFitIssueParts()); + review.updateStep2Size(fitIssueParts); + } + + @Transactional + public void updateReviewStep2Material(Long userId, Long reviewId, ReviewStep2MaterialRequest request) { + Review review = getReviewOrThrow(reviewId); + reviewValidator.validateReviewAuthority(review.getOrderItem(), userId); + + MaterialAnswer step1Answer = review.getMaterialAnswer(); + + String materialFeatures = request.getFeatureTypes().stream() + .map(type -> { + if (type == MaterialFeatureType.THICKNESS) { + return "두께감:선택지전체"; + } + + // 1차 답변에 따라 자동 매핑 + String value = step1Answer.isPositive() + ? type.getValues().get(0) + : type.getValues().get(1); + return type.getDisplayName() + ":" + value; + }) + .collect(Collectors.joining(",")); + + review.updateStep2Material(materialFeatures); + } + + @Transactional(readOnly = true) + public AiReviewResponse generateSizeAiReview(Long userId, Long reviewId) { + Review review = getReviewOrThrow(reviewId); + reviewValidator.validateReviewAuthority(review.getOrderItem(), userId); + + AiReviewGenerateRequest aiRequest = reviewWriteConverter.toSizeAiRequest(review); + return aiReviewGeneratorService.generateSizeReview(aiRequest); + } + + @Transactional(readOnly = true) + public AiReviewResponse generateMaterialAiReview(Long userId, Long reviewId) { + Review review = getReviewOrThrow(reviewId); + reviewValidator.validateReviewAuthority(review.getOrderItem(), userId); + + AiReviewGenerateRequest aiRequest = reviewWriteConverter.toMaterialAiRequest(review); + return aiReviewGeneratorService.generateMaterialReview(aiRequest); + } + + @Transactional + public void submitReview(Long userId, Long reviewId, ReviewFinalSubmitRequest request) { + Review review = getReviewOrThrow(reviewId); + User user = getUserOrThrow(userId); + + reviewValidator.validateReviewAuthority(review.getOrderItem(), userId); + + String joinedSizeReview = (request.getSizeReview() != null && !request.getSizeReview().isEmpty()) + ? String.join("\n", request.getSizeReview()) : null; + + String joinedMaterialReview = (request.getMaterialReview() != null && !request.getMaterialReview().isEmpty()) + ? String.join("\n", request.getMaterialReview()) : null; + + String joinedImages = (request.getReviewImageUrls() != null && !request.getReviewImageUrls().isEmpty()) + ? String.join(",", request.getReviewImageUrls()) : null; + + int rewardAmount = 500; + review.submit( + request.getTextReview(), + joinedImages, + joinedSizeReview, + joinedMaterialReview, + rewardAmount + ); + + user.restorePoints(rewardAmount); + } + + private Review getReviewOrThrow(Long reviewId) { + return reviewRepository.findById(reviewId) + .orElseThrow(() -> new EntityNotFoundException(ErrorCode.REVIEW_NOT_FOUND)); + } + + private User getUserOrThrow(Long userId) { + return userRepository.findById(userId) + .orElseThrow(() -> new EntityNotFoundException(ErrorCode.USER_NOT_FOUND)); + } + + private OrderItem getOrderItemOrThrow(Long orderItemId) { + return orderItemRepository.findById(orderItemId) + .orElseThrow(() -> new EntityNotFoundException(ErrorCode.ORDER_ITEM_NOT_FOUND)); + } + +} From 8d8883006ac7527746af37a35ba7c2a8edc32bfc Mon Sep 17 00:00:00 2001 From: marshmallowing Date: Fri, 13 Feb 2026 13:18:05 +0900 Subject: [PATCH 03/12] =?UTF-8?q?feat:=20ai=20=EB=A6=AC=EB=B7=B0=20?= =?UTF-8?q?=EC=83=9D=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dto/request/AiReviewGenerateRequest.java | 30 ++ .../review/dto/response/AiReviewResponse.java | 24 ++ .../service/AiReviewGeneratorService.java | 337 ++++++++++++++++++ 3 files changed, 391 insertions(+) create mode 100644 src/main/java/com/ongil/backend/domain/review/dto/request/AiReviewGenerateRequest.java create mode 100644 src/main/java/com/ongil/backend/domain/review/dto/response/AiReviewResponse.java create mode 100644 src/main/java/com/ongil/backend/domain/review/service/AiReviewGeneratorService.java diff --git a/src/main/java/com/ongil/backend/domain/review/dto/request/AiReviewGenerateRequest.java b/src/main/java/com/ongil/backend/domain/review/dto/request/AiReviewGenerateRequest.java new file mode 100644 index 0000000..ec37b20 --- /dev/null +++ b/src/main/java/com/ongil/backend/domain/review/dto/request/AiReviewGenerateRequest.java @@ -0,0 +1,30 @@ +package com.ongil.backend.domain.review.dto.request; + +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.util.List; + +import com.ongil.backend.domain.review.enums.ClothingCategory; +import com.ongil.backend.domain.review.enums.MaterialAnswer; +import com.ongil.backend.domain.review.enums.SizeAnswer; + +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@Builder +public class AiReviewGenerateRequest { + + private Long reviewId; + + private ClothingCategory clothingType; + private SizeAnswer sizeAnswer; + private MaterialAnswer materialAnswer; + + private List fitIssueParts; + + private List materialFeatures; +} diff --git a/src/main/java/com/ongil/backend/domain/review/dto/response/AiReviewResponse.java b/src/main/java/com/ongil/backend/domain/review/dto/response/AiReviewResponse.java new file mode 100644 index 0000000..de1becd --- /dev/null +++ b/src/main/java/com/ongil/backend/domain/review/dto/response/AiReviewResponse.java @@ -0,0 +1,24 @@ +package com.ongil.backend.domain.review.dto.response; + +import java.util.List; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder +@AllArgsConstructor +public class AiReviewResponse { + + private Long reviewId; + + private List aiGeneratedReviews; + + public static AiReviewResponse of(Long reviewId, List aiReviews) { + return AiReviewResponse.builder() + .reviewId(reviewId) + .aiGeneratedReviews(aiReviews) + .build(); + } +} diff --git a/src/main/java/com/ongil/backend/domain/review/service/AiReviewGeneratorService.java b/src/main/java/com/ongil/backend/domain/review/service/AiReviewGeneratorService.java new file mode 100644 index 0000000..96d3094 --- /dev/null +++ b/src/main/java/com/ongil/backend/domain/review/service/AiReviewGeneratorService.java @@ -0,0 +1,337 @@ +package com.ongil.backend.domain.review.service; + +import com.ongil.backend.domain.review.dto.request.AiReviewGenerateRequest; +import com.ongil.backend.domain.review.dto.response.AiReviewResponse; +import com.ongil.backend.domain.review.validator.ReviewValidator; +import com.ongil.backend.global.common.exception.AppException; +import com.ongil.backend.global.common.exception.ErrorCode; +import com.theokanning.openai.completion.chat.ChatCompletionRequest; +import com.theokanning.openai.completion.chat.ChatMessage; +import com.theokanning.openai.completion.chat.ChatMessageRole; +import com.theokanning.openai.service.OpenAiService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +@Slf4j +@Service +@RequiredArgsConstructor +public class AiReviewGeneratorService { + + private final ReviewValidator reviewValidator; + + @Value("${openai.api-key}") + private String openAiApiKey; + + private static final String SIZE_REVIEW_PROMPT = """ + 너는 의류 착용 후기를 생성하는 AI임. + 사용자가 선택한 의류 종류, 부위, 강도를 기반으로 + 시니어도 이해하기 쉬운 표현으로 자연스러운 후기 문장을 생성해야 함. + + 아래 규칙을 반드시 모두 지켜야 함. + + ━━━━━━━━━━━━━━ + [1. 부위 범위 규칙] + + 사용자가 선택한 부위는 문장에서 언급 가능한 유일한 불편 범위임 + 선택된 부위 외의 신체 부위, 상황, 결과는 절대 언급 불가함 + 복합 부위(예: 엉덩이 & 가랑이)는 하나의 묶음이지만 + 문장 안에서 단일 부위만 언급했을 경우, + 불편한 상황·감정·원인은 해당 부위로만 한정해야 함 + + 예시로, + 가랑이가 답답해서 앉아 있을 때 불편함은 괜찮지만 + 가랑이가 답답해서 엉덩이 봉제선이 당겨지는 느낌은 안됨 + + 복합 부위를 함께 언급한 경우에만 + 두 부위 모두에 대한 불편 상황 설명 가능함 + 단, 부위 간 인과 관계(원인→결과) 표현은 금지함 + + ━━━━━━━━━━━━━━ + [2. 강도 표현 규칙] + + 강도에 따라 아래 표현 중에서만 선택하여 사용해야 하며, + 의미를 벗어나는 과장·완화 표현은 사용 불가함 + + 너무 답답: + 매우, 심하게, 숨쉬기 힘들 정도로 + + 조금 답답: + 살짝, 신경 쓰일 정도로 + (허리&복부 / 가슴&몸통의 경우만) + 밥 먹고 나면 답답해지는 느낌임 + + 약간 커서 거슬림: + 거슬릴 정도로 + + 너무 커서 불편함: + 많이, 지나치게 + + 수선이 필요할 정도로 큼: + 입고 다니기 힘들 정도로 큼 + + 편함: + 입는 내내 편함 + 신축성이 좋아 움직이기 편함 + 이 경우 특정 부위 언급 없이 + 전반적인 착용감만 작성해야 함 + + ━━━━━━━━━━━━━━ + [3. 표현 톤 규칙 (시니어 친화)] + + 전문 용어, 유행어, 신체 과장 표현 사용 금지 + 아래 표현은 사용하지 않음 + (핏, 옷매무새, Y존, 라인 부각, 실루엣 등) + + 대신 일상적이고 직관적인 표현 사용 + 예: + - 몸에 딱 붙는다 + - 움직일 때 불편하다 + - 앉거나 일어날 때 신경 쓰인다 + - 오래 입기엔 부담된다 + + ━━━━━━━━━━━━━━ + [4. 문장 구조 규칙] + + 하나의 문장에는 하나의 불편 경험만 포함함 + 평가 + 상황 + 느낌의 순서를 유지함 + 문장 끝은 반드시 ~임 으로 끝냄 + + ━━━━━━━━━━━━━━ + [출력 목표] + + 사용자가 선택한 + - 의류 종류 + - 부위 + - 강도 + 를 정확히 반영하여 + 부위 범위를 넘지 않는, + 강도에 맞는, + 시니어도 이해 가능한 한 문장 후기를 생성함. + """; + + private static final String MATERIAL_REVIEW_PROMPT = """ + 당신은 의류 소재 착용 후기를 실제 사용자 경험처럼 풀어내는 AI입니다. + 특히 시니어 사용자가 이해하기 쉬운 표현을 최우선으로 사용해야 합니다. + + ━━━━━━━━━━━━━━━━━━ + [기본 전제] + 본 프롬프트는 소재에 대한 후기만 작성함 + 핏, 사이즈, 신체 부위, 착용 부위 언급 금지 + 소재의 인상과 느낌만 다룸 + + ━━━━━━━━━━━━━━━━━━ + [가장 중요한 규칙 – 선택 항목 일치] + 사용자가 선택한 항목만 서술해야 함 + 선택하지 않은 소재 속성으로 확장 금지 + 예: 촉감 선택 → 촉감에 대한 내용만 작성 + 예: 무게감 선택 → 무게감 외 언급 금지 + + 좋은 점 선택 시 부정 표현 금지 + 아쉬운 점 선택 시 긍정 표현 금지 + 눈에 띄는 점 없음 선택 시 + → 장점·단점 분석, 속성 설명 모두 금지 + + ━━━━━━━━━━━━━━━━━━ + [핵심 작성 원칙] + 한 문장에는 하나의 느낌만 작성 + 1인칭 후기 톤 사용 + 시니어가 이해하기 쉬운 말만 사용 + 모든 문장 끝맺음은 반드시 "~임" + 판단, 비교, 조언, 추천, 해결책 작성 금지 + + ━━━━━━━━━━━━━━━━━━ + [소재 속성 카테고리] + 촉감 (부드러움 / 거칠음) + 무게감 (가벼움 / 무거움) + 구김 정도 (많음 / 없음) + 두께감 (얇음 / 두꺼움) + 보풀 (있음 / 없음) + 비침 정도 (안 비침 / 비침) + + ━━━━━━━━━━━━━━━━━━ + [2차 질문 – 좋은 점 선택 시 출력 규칙] + 선택한 속성 중 하나만 기준으로 1~2문장 작성 + 편안함, 부담 없음, 일상 사용 중심으로 표현 + + [좋은 점 예시 가이드] + 촉감(부드러움): + · 손에 닿는 느낌이 부드러움 + + 무게감(가벼움): + · 가벼워서 편안함 + + 구김 없음: + · 오래 입어도 구김이 잘 생기지 않음 + + 두께감 두꺼움/얇음: + · 두꺼워서 따뜻함 / 얇아서 시원함 / 봄, 가을에 입기 적당한 두께 + + 보풀 없음: + · 보풀이 잘 안나는 소재 + + 비침 없음: + · 안이 비치지 않아 신경 쓰이지 않음 + + ━━━━━━━━━━━━━━━━━━ + [2차 질문 – 아쉬운 점 선택 시 출력 규칙] + 선택한 속성 하나만 기준으로 1~2문장 작성 + 불편하지만 과장 없이, 체감 위주로 표현 + + [아쉬운 점 예시 가이드] + 촉감(거칠음): + · 피부에 닿을 때 거칠음 + + 무게감(무거움): + · 무거워서 오래 입기엔 부담됨 + + 구김 많음: + · 조금만 움직여도 구김이 생김 + + 두꺼움: + · 두꺼워서 더움 / 얇아서 추움 + + 보풀 있음: + · 보풀이 금방 생기는 소재임 + + 비침 있음: + · 안이 비쳐 보여 신경 쓰임 + + ━━━━━━━━━━━━━━━━━━ + [③ 눈에 띄는 점은 없었어요 선택 시 전용 규칙] + 소재 속성(촉감, 무게, 두께 등) 언급 금지 + 장점·단점 분석 금지 + "무난함 / 평범함 / 거슬리지 않음" 인상만 전달 + + 아래 문장 유형 중 1~2문장만 출력 + [허용 문장 예시] + 전반적으로 무난해서 부담 없이 입을 수 있음 + 특별히 좋거나 아쉬운 점 없이 평범한 느낌임 + 특별히 좋은 점은 없지만 입는 데 거슬리지도 않았음 + 일상적으로 입는 데에 무리가 없는 평범한 소재임 + + ━━━━━━━━━━━━━━━━━━ + [출력 분량 규칙] + 모든 선택지: 1~2문장 + 문장 간 의미 중복 금지 + 규칙 위반 시 잘못된 출력으로 간주됨 + """; + + public AiReviewResponse generateSizeReview(AiReviewGenerateRequest request) { + reviewValidator.validateReviewStepCompletion(request.getSizeAnswer(), request.getFitIssueParts()); + OpenAiService service = new OpenAiService(openAiApiKey); + String userMessage = buildSizeReviewPrompt(request); + + String aiResponse = callOpenAi(service, MATERIAL_REVIEW_PROMPT, userMessage); + + List reviewList = Arrays.stream(aiResponse.split("\\|")) + .map(String::trim) + .filter(s -> !s.isEmpty()) + .toList(); + + return AiReviewResponse.of(request.getReviewId(), reviewList); + } + + public AiReviewResponse generateMaterialReview(AiReviewGenerateRequest request) { + reviewValidator.validateReviewStepCompletion(request.getMaterialAnswer(), request.getMaterialFeatures()); + OpenAiService service = new OpenAiService(openAiApiKey); + String userMessage = buildMaterialReviewPrompt(request); + + String aiResponse = callOpenAi(service, MATERIAL_REVIEW_PROMPT, userMessage); + + List reviewList = Arrays.stream(aiResponse.split("\\|")) + .map(String::trim) + .filter(s -> !s.isEmpty()) + .toList(); + + return AiReviewResponse.of(request.getReviewId(), reviewList); + } + + private String buildSizeReviewPrompt(AiReviewGenerateRequest request) { + StringBuilder prompt = new StringBuilder(); + prompt.append("의류 종류: ").append(request.getClothingType().getDisplayName()).append("\n"); + prompt.append("착용 상태: ").append(request.getSizeAnswer().getDisplayName()).append("\n"); + + if (request.getSizeAnswer().isNeedsSecondaryQuestion() && !request.getFitIssueParts().isEmpty()) { + prompt.append("불편 부위: ").append(String.join(", ", request.getFitIssueParts())).append("\n"); + prompt.append("\n[특수 지시]"); + prompt.append("- 위 리스트에 있는 각 '복합 부위' 항목 전체를 소재로 하여 서로 다른 2문장씩 생성할 것.\n"); + prompt.append("- 예: '가슴&몸통' 선택 시 -> 가슴과 몸통 전체의 착용감을 다루는 문장 2개 생성.\n"); + } else { + prompt.append("특이사항: 전체적으로 편안함\n"); + prompt.append("[지시] 전반적인 편안함을 강조하는 서로 다른 2문장을 생성할 것.\n"); + } + + prompt.append("\n[출력 형식] 반드시 각 문장 사이를 '|' 기호로만 구분하여 출력할 것."); + return prompt.toString(); + } + + private String buildMaterialReviewPrompt(AiReviewGenerateRequest request) { + StringBuilder prompt = new StringBuilder(); + + boolean isPositive = request.getMaterialAnswer().isPositive(); + prompt.append("소재 평가 상태: ").append(isPositive ? "긍정적" : "부정적(아쉬움)").append("\n"); + prompt.append("소재 평가: ").append(request.getMaterialAnswer().getDisplayName()).append("\n"); + + if (!request.getMaterialFeatures().isEmpty()) { + prompt.append("선택한 소재 특징:\n"); + boolean hasThicknessAll = false; + + for (String feature : request.getMaterialFeatures()) { + if ("두께감:선택지전체".equals(feature)) { + hasThicknessAll = true; + continue; + } + prompt.append("- ").append(feature).append("\n"); + } + + prompt.append("\n[문장 생성 규칙]"); + prompt.append("\n1. 위 리스트에 나열된 각 특징마다 서로 다른 느낌의 '2문장씩'을 반드시 생성할 것."); + + if (hasThicknessAll) { + prompt.append("\n[특수 지시] 두께감은 아래 3가지 상황에 맞춰 생성하되, 각 문장 사이를 '|' 기호로 구분할 것:\n"); + if (isPositive) { + prompt.append("1. 두꺼워서 따뜻함 | 2. 얇아서 시원함 | 3. 적당한 두께임\n"); + } else { + prompt.append("1. 소재가 너무 두꺼워서 답답함 | 2. 너무 얇아서 추운 느낌임 | 3. 두께가 애매해서 아쉬움\n"); + } + } + } + else { + prompt.append("\n[지시] 특정 소재 속성 언급 없이, 전반적으로 무난하고 평범하다는 인상의 서로 다른 '2문장'을 생성할 것.\n"); + } + + prompt.append("\n[최종 출력 형식 지시]"); + prompt.append("\n- 모든 문장은 반드시 '|' 기호로만 구분하여 나열할 것."); + prompt.append("\n- 문장 끝은 반드시 '~임'으로 끝낼 것."); + prompt.append("\n- 마침표(.)나 줄바꿈(\n)을 구분자로 사용하지 말 것."); + + return prompt.toString(); + } + + private String callOpenAi(OpenAiService service, String systemPrompt, String userMessage) { + List messages = new ArrayList<>(); + messages.add(new ChatMessage(ChatMessageRole.SYSTEM.value(), systemPrompt)); + messages.add(new ChatMessage(ChatMessageRole.USER.value(), userMessage)); + + ChatCompletionRequest completionRequest = ChatCompletionRequest.builder() + .model("gpt-4o-mini") + .messages(messages) + .temperature(0.7) + .build(); + + try { + return service.createChatCompletion(completionRequest) + .getChoices().get(0).getMessage().getContent().trim(); + } catch (Exception e) { + log.error("AI 생성 실패: ", e); + throw new AppException(ErrorCode.AI_GENERATION_ERROR); + } + } +} From b4030e28fffb1d8699ad2e2906280b5f5268d31a Mon Sep 17 00:00:00 2001 From: marshmallowing Date: Fri, 13 Feb 2026 13:18:31 +0900 Subject: [PATCH 04/12] =?UTF-8?q?feat:=20DRAFT=20=EB=A6=AC=EB=B7=B0=20?= =?UTF-8?q?=EC=8A=A4=EC=BC=80=EC=A4=84=EB=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../scheduler/ReviewCleanupScheduler.java | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 src/main/java/com/ongil/backend/domain/review/scheduler/ReviewCleanupScheduler.java diff --git a/src/main/java/com/ongil/backend/domain/review/scheduler/ReviewCleanupScheduler.java b/src/main/java/com/ongil/backend/domain/review/scheduler/ReviewCleanupScheduler.java new file mode 100644 index 0000000..b58e47f --- /dev/null +++ b/src/main/java/com/ongil/backend/domain/review/scheduler/ReviewCleanupScheduler.java @@ -0,0 +1,36 @@ +package com.ongil.backend.domain.review.scheduler; + +import java.time.LocalDateTime; + +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import com.ongil.backend.domain.review.repository.ReviewRepository; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Component +@RequiredArgsConstructor +public class ReviewCleanupScheduler { + + private final ReviewRepository reviewRepository; + + // 30분 간격 + @Transactional + @Scheduled(fixedRate = 1800000) + public void cleanupDraftReviews() { + log.info("임시 저장 리뷰 정리 스케줄러 실행"); + + LocalDateTime threshold = LocalDateTime.now().minusMinutes(30); + + try { + reviewRepository.deleteExpiredDraftReviews(threshold); + log.info("만료된 DRAFT 리뷰 삭제 완료 (기준 시간: {})", threshold); + } catch (Exception e) { + log.error("DRAFT 리뷰 정리 중 에러 발생: ", e); + } + } +} From c9e3c0abe7c7de898b9f5cddedee665f9e237ec3 Mon Sep 17 00:00:00 2001 From: marshmallowing Date: Fri, 13 Feb 2026 13:20:42 +0900 Subject: [PATCH 05/12] =?UTF-8?q?feat:=20=EB=A6=AC=EB=B7=B0=20=EC=B5=9C?= =?UTF-8?q?=EC=A2=85=EC=A0=9C=EC=B6=9C,=20=EC=BB=A8=EB=B2=84=ED=84=B0,=20?= =?UTF-8?q?=EC=97=94=ED=8B=B0=ED=8B=B0=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../converter/ReviewWriteConverter.java | 67 ++++++++++++ .../dto/request/ReviewFinalSubmitRequest.java | 29 +++++ .../backend/domain/review/entity/Review.java | 102 +++++++++++------- 3 files changed, 160 insertions(+), 38 deletions(-) create mode 100644 src/main/java/com/ongil/backend/domain/review/converter/ReviewWriteConverter.java create mode 100644 src/main/java/com/ongil/backend/domain/review/dto/request/ReviewFinalSubmitRequest.java diff --git a/src/main/java/com/ongil/backend/domain/review/converter/ReviewWriteConverter.java b/src/main/java/com/ongil/backend/domain/review/converter/ReviewWriteConverter.java new file mode 100644 index 0000000..ba00f02 --- /dev/null +++ b/src/main/java/com/ongil/backend/domain/review/converter/ReviewWriteConverter.java @@ -0,0 +1,67 @@ +package com.ongil.backend.domain.review.converter; + +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +import org.springframework.stereotype.Component; + +import com.ongil.backend.domain.order.entity.OrderItem; +import com.ongil.backend.domain.review.dto.request.AiReviewGenerateRequest; +import com.ongil.backend.domain.review.dto.response.ReviewStep1Response; +import com.ongil.backend.domain.review.entity.Review; +import com.ongil.backend.domain.review.enums.ClothingCategory; +import com.ongil.backend.domain.review.enums.ReviewStatus; +import com.ongil.backend.domain.review.enums.ReviewType; +import com.ongil.backend.domain.user.entity.User; + +@Component +public class ReviewWriteConverter { + + public Review toInitialReviewEntity(User user, OrderItem orderItem, ClothingCategory category) { + return Review.builder() + .user(user) + .orderItem(orderItem) + .product(orderItem.getProduct()) + .clothingCategory(category) + .reviewStatus(ReviewStatus.DRAFT) + .reviewType(ReviewType.INITIAL) + .build(); + } + + public ReviewStep1Response toStep1Response(Review review) { + boolean needsSizeQ = review.getSizeAnswer().isNeedsSecondaryQuestion(); + boolean needsMaterialQ = review.getMaterialAnswer().isNeedsSecondaryQuestion(); + + List availableBodyParts = needsSizeQ + ? review.getClothingCategory().getBodyParts() + : Collections.emptyList(); + + return ReviewStep1Response.of( + review.getId(), + needsSizeQ, + needsMaterialQ, + availableBodyParts + ); + } + + public AiReviewGenerateRequest toMaterialAiRequest(Review review) { + return AiReviewGenerateRequest.builder() + .reviewId(review.getId()) + .clothingType(review.getClothingCategory()) + .materialAnswer(review.getMaterialAnswer()) + .materialFeatures(review.getMaterialFeatures() != null ? + Arrays.asList(review.getMaterialFeatures().split(",")) : Collections.emptyList()) + .build(); + } + + public AiReviewGenerateRequest toSizeAiRequest(Review review) { + return AiReviewGenerateRequest.builder() + .reviewId(review.getId()) + .clothingType(review.getClothingCategory()) + .sizeAnswer(review.getSizeAnswer()) + .fitIssueParts(review.getFitIssueParts() != null ? + Arrays.asList(review.getFitIssueParts().split(",")) : Collections.emptyList()) + .build(); + } +} diff --git a/src/main/java/com/ongil/backend/domain/review/dto/request/ReviewFinalSubmitRequest.java b/src/main/java/com/ongil/backend/domain/review/dto/request/ReviewFinalSubmitRequest.java new file mode 100644 index 0000000..1c150b7 --- /dev/null +++ b/src/main/java/com/ongil/backend/domain/review/dto/request/ReviewFinalSubmitRequest.java @@ -0,0 +1,29 @@ +package com.ongil.backend.domain.review.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.util.List; + +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@Builder +public class ReviewFinalSubmitRequest { + + @Schema(description = "기타 후기") + private String textReview; + + @Schema(description = "S3에서 받은 이미지 URL 리스트 (최대 5장)", example = "[\"https://s3.../1.jpg\"]") + private List reviewImageUrls; + + @Schema(description = "사이즈 관련 후기 문장 리스트", example = "[\"허리가 커서 걷다 보면 자꾸 흘러내려요\", \"신축성이 좋아 움직이기 편해요\"]") + private List sizeReview; + + @Schema(description = "소재 관련 후기 문장 리스트", example = "[\"소재가 부드러워 피부에 자극이 없어요\"]") + private List materialReview; +} diff --git a/src/main/java/com/ongil/backend/domain/review/entity/Review.java b/src/main/java/com/ongil/backend/domain/review/entity/Review.java index ff7191d..0cc6780 100644 --- a/src/main/java/com/ongil/backend/domain/review/entity/Review.java +++ b/src/main/java/com/ongil/backend/domain/review/entity/Review.java @@ -4,13 +4,18 @@ import com.ongil.backend.domain.order.entity.OrderItem; import com.ongil.backend.domain.product.entity.Product; +import com.ongil.backend.domain.review.enums.ClothingCategory; +import com.ongil.backend.domain.review.enums.ColorAnswer; +import com.ongil.backend.domain.review.enums.MaterialAnswer; import com.ongil.backend.domain.review.enums.ReviewStatus; import com.ongil.backend.domain.review.enums.ReviewType; +import com.ongil.backend.domain.review.enums.SizeAnswer; import com.ongil.backend.domain.user.entity.User; import com.ongil.backend.global.common.entity.BaseEntity; import jakarta.persistence.*; import lombok.AccessLevel; +import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Getter; import lombok.NoArgsConstructor; @@ -19,6 +24,8 @@ @Table(name = "reviews") @NoArgsConstructor(access = AccessLevel.PROTECTED) @Getter +@AllArgsConstructor +@Builder public class Review extends BaseEntity { @Id @@ -35,18 +42,28 @@ public class Review extends BaseEntity { private ReviewStatus reviewStatus; @Column(name = "current_step", nullable = false) - private Integer currentStep = 0; + @Builder.Default + private Integer currentStep = 1; @Column(nullable = false) - private Integer rating; + @Builder.Default + private Integer rating = 0; // 도움돼요 카운트 @Column(name = "helpful_count", nullable = false, columnDefinition = "INT DEFAULT 0") + @Builder.Default private Integer helpfulCount = 0; + @Enumerated(EnumType.STRING) + @Column(name = "clothing_category", nullable = false) + private ClothingCategory clothingCategory; + // 후기 내용 - @Column(name = "ai_generated_review", columnDefinition = "TEXT") - private String aiGeneratedReview; + @Column(name = "size_review", columnDefinition = "TEXT") + private String sizeReview; + + @Column(name = "material_review", columnDefinition = "TEXT") + private String materialReview; @Column(name = "text_review", columnDefinition = "TEXT") private String textReview; @@ -55,14 +72,17 @@ public class Review extends BaseEntity { private String reviewImageUrls; // 구매 직후 리뷰 - 1차 질문 - @Column(name = "size_answer", columnDefinition = "TEXT") - private String sizeAnswer; + @Enumerated(EnumType.STRING) + @Column(name = "size_answer") + private SizeAnswer sizeAnswer; - @Column(name = "color_answer", columnDefinition = "TEXT") - private String colorAnswer; + @Enumerated(EnumType.STRING) + @Column(name = "color_answer") + private ColorAnswer colorAnswer; - @Column(name = "material_answer", columnDefinition = "TEXT") - private String materialAnswer; + @Enumerated(EnumType.STRING) + @Column(name = "material_answer") + private MaterialAnswer materialAnswer; // 구매 직후 리뷰 - 2차 질문 @Column(name = "fit_issue_parts", columnDefinition = "TEXT") @@ -79,7 +99,8 @@ public class Review extends BaseEntity { private String oneMonthChanges; // 변화 항목 @Column(name = "earned_points") - private Integer earnedPoints; + @Builder.Default + private Integer earnedPoints = 0; @Column(name = "completed_at") private LocalDateTime completedAt; @@ -96,46 +117,51 @@ public class Review extends BaseEntity { @JoinColumn(name = "product_id", nullable = false) private Product product; - @Builder - public Review(ReviewType reviewType, ReviewStatus reviewStatus, Integer currentStep, - Integer rating, String aiGeneratedReview, String textReview, String reviewImageUrls, - String sizeAnswer, String colorAnswer, String materialAnswer, - String fitIssueParts, String materialFeatures, - String oneMonthOverall, String oneMonthChanges, - User user, OrderItem orderItem, Product product) { - this.reviewType = reviewType; - this.reviewStatus = reviewStatus != null ? reviewStatus : ReviewStatus.DRAFT; - this.currentStep = currentStep != null ? currentStep : 0; + public void incrementHelpfulCount() { + this.helpfulCount++; + } + + public void decrementHelpfulCount() { + if (this.helpfulCount > 0) { + this.helpfulCount--; + } + } + + public void updateStep1(int rating, SizeAnswer sizeAnswer, ColorAnswer colorAnswer, MaterialAnswer materialAnswer) { this.rating = rating; - this.helpfulCount = 0; - this.aiGeneratedReview = aiGeneratedReview; - this.textReview = textReview; - this.reviewImageUrls = reviewImageUrls; this.sizeAnswer = sizeAnswer; this.colorAnswer = colorAnswer; this.materialAnswer = materialAnswer; + } + + public void updateStep2Size(String fitIssueParts) { this.fitIssueParts = fitIssueParts; - this.materialFeatures = materialFeatures; - this.oneMonthOverall = oneMonthOverall; - this.oneMonthChanges = oneMonthChanges; - this.user = user; - this.orderItem = orderItem; - this.product = product; + this.currentStep = Math.max(this.currentStep, 2); } - public void incrementHelpfulCount() { - this.helpfulCount++; + public void updateStep2Material(String materialFeatures) { + this.materialFeatures = materialFeatures; + this.currentStep = Math.max(this.currentStep, 2); } - public void decrementHelpfulCount() { - if (this.helpfulCount > 0) { - this.helpfulCount--; - } + public void clearStep2AndStep3() { + this.fitIssueParts = null; + this.materialFeatures = null; + this.sizeReview = null; + this.materialReview = null; + this.textReview = null; + this.reviewImageUrls = null; } - public void complete(Integer points) { + public void submit(String textReview, String imageUrls, String sizeReview, String materialReview, Integer points) { + this.textReview = textReview; + this.reviewImageUrls = imageUrls; + this.sizeReview = sizeReview; + this.materialReview = materialReview; + this.reviewStatus = ReviewStatus.COMPLETED; this.completedAt = LocalDateTime.now(); + this.currentStep = 4; // 작성 완료 단계 this.earnedPoints = points; } } \ No newline at end of file From 2a453a15a3c3743af32f23689b32a1140511d18d Mon Sep 17 00:00:00 2001 From: marshmallowing Date: Fri, 13 Feb 2026 13:21:33 +0900 Subject: [PATCH 06/12] =?UTF-8?q?feat:=20Validator,=20=EC=98=88=EC=99=B8?= =?UTF-8?q?=EC=B2=98=EB=A6=AC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../review/repository/ReviewRepository.java | 14 +++- .../review/validator/ReviewValidator.java | 64 +++++++++++++++++++ .../global/common/exception/ErrorCode.java | 4 ++ 3 files changed, 80 insertions(+), 2 deletions(-) create mode 100644 src/main/java/com/ongil/backend/domain/review/validator/ReviewValidator.java diff --git a/src/main/java/com/ongil/backend/domain/review/repository/ReviewRepository.java b/src/main/java/com/ongil/backend/domain/review/repository/ReviewRepository.java index 794269e..7cb030e 100644 --- a/src/main/java/com/ongil/backend/domain/review/repository/ReviewRepository.java +++ b/src/main/java/com/ongil/backend/domain/review/repository/ReviewRepository.java @@ -1,5 +1,6 @@ package com.ongil.backend.domain.review.repository; +import java.time.LocalDateTime; import java.util.List; import java.util.Optional; @@ -8,6 +9,7 @@ import org.springframework.data.jpa.repository.EntityGraph; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Lock; +import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; @@ -186,9 +188,17 @@ List findReviewedOrderItemIds( @Param("reviewType") ReviewType reviewType ); - // ReviewRepository.java - @Lock(LockModeType.PESSIMISTIC_WRITE) @Query("SELECT r FROM Review r WHERE r.id = :reviewId") Optional findByIdWithLock(@Param("reviewId") Long reviewId); + + @Modifying + @Query("DELETE FROM Review r WHERE r.reviewStatus = 'DRAFT' AND r.createdAt < :threshold") + void deleteExpiredDraftReviews(LocalDateTime threshold); + + boolean existsByOrderItemIdAndReviewTypeAndReviewStatus( + Long orderItemId, + ReviewType reviewType, + ReviewStatus reviewStatus + ); } diff --git a/src/main/java/com/ongil/backend/domain/review/validator/ReviewValidator.java b/src/main/java/com/ongil/backend/domain/review/validator/ReviewValidator.java new file mode 100644 index 0000000..d2c082f --- /dev/null +++ b/src/main/java/com/ongil/backend/domain/review/validator/ReviewValidator.java @@ -0,0 +1,64 @@ +package com.ongil.backend.domain.review.validator; + +import java.util.List; + +import org.springframework.stereotype.Component; + +import com.ongil.backend.domain.order.entity.OrderItem; +import com.ongil.backend.domain.review.enums.MaterialAnswer; +import com.ongil.backend.domain.review.enums.ReviewStatus; +import com.ongil.backend.domain.review.enums.ReviewType; +import com.ongil.backend.domain.review.enums.SizeAnswer; +import com.ongil.backend.domain.review.repository.ReviewRepository; +import com.ongil.backend.global.common.exception.AppException; +import com.ongil.backend.global.common.exception.ErrorCode; +import com.ongil.backend.global.common.exception.ForbiddenException; +import com.ongil.backend.global.common.exception.ValidationException; + +import lombok.RequiredArgsConstructor; + +@Component +@RequiredArgsConstructor +public class ReviewValidator { + + private final ReviewRepository reviewRepository; + + // 리뷰 작성 권한 + public void validateReviewAuthority(OrderItem orderItem, Long userId) { + if (!orderItem.getOrder().getUser().getId().equals(userId)) { + throw new ForbiddenException(ErrorCode.FORBIDDEN); + } + } + + // 1차 답변 및 2차 답변 세트 검증 + public void validateReviewStepCompletion(SizeAnswer sizeAnswer, List fitIssueParts) { + if (sizeAnswer == null) { + throw new ValidationException(ErrorCode.REVIEW_STEP1_INCOMPLETE); + } + + if (sizeAnswer.isNeedsSecondaryQuestion()) { + if (fitIssueParts == null || fitIssueParts.isEmpty()) { + throw new ValidationException(ErrorCode.REVIEW_STEP2_INCOMPLETE); + } + } + } + + public void validateReviewStepCompletion(MaterialAnswer materialAnswer, List materialFeatures) { + if (materialAnswer == null) { + throw new ValidationException(ErrorCode.REVIEW_STEP1_INCOMPLETE); + } + + if (materialAnswer.isNeedsSecondaryQuestion()) { + if (materialFeatures == null || materialFeatures.isEmpty()) { + throw new ValidationException(ErrorCode.REVIEW_STEP2_INCOMPLETE); + } + } + } + + public void validateInitialReviewAlreadyExists(Long orderItemId) { + if (reviewRepository.existsByOrderItemIdAndReviewTypeAndReviewStatus( + orderItemId, ReviewType.INITIAL, ReviewStatus.COMPLETED)) { + throw new AppException(ErrorCode.REVIEW_ALREADY_EXISTS); + } + } +} diff --git a/src/main/java/com/ongil/backend/global/common/exception/ErrorCode.java b/src/main/java/com/ongil/backend/global/common/exception/ErrorCode.java index 689f37a..a73010c 100644 --- a/src/main/java/com/ongil/backend/global/common/exception/ErrorCode.java +++ b/src/main/java/com/ongil/backend/global/common/exception/ErrorCode.java @@ -59,6 +59,10 @@ public enum ErrorCode { // REVIEW REVIEW_NOT_FOUND(HttpStatus.NOT_FOUND, "리뷰를 찾을 수 없습니다.", "REVIEW-001"), REVIEW_FORBIDDEN(HttpStatus.FORBIDDEN, "본인의 리뷰만 수정/삭제할 수 있습니다.", "REVIEW-002"), + REVIEW_ALREADY_EXISTS(HttpStatus.BAD_REQUEST, "이미 해당 상품에 대한 리뷰를 작성했습니다.", "REVIEW-003"), + REVIEW_STEP1_INCOMPLETE(HttpStatus.BAD_REQUEST, "1차 답변이 완료되지 않았습니다.", "REVIEW-004"), + REVIEW_STEP2_INCOMPLETE(HttpStatus.BAD_REQUEST, "2차 답변이 완료되지 않았습니다.", "REVIEW-005"), + AI_GENERATION_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "AI 리뷰 생성 중 오류가 발생했습니다.", "REVIEW-006"), // ORDER ORDER_NOT_FOUND(HttpStatus.NOT_FOUND, "주문을 찾을 수 없습니다.", "ORDER-001"), From 5f84b8cbaadba73048b09f0be5a5fd449156c76c Mon Sep 17 00:00:00 2001 From: marshmallowing Date: Fri, 13 Feb 2026 13:22:28 +0900 Subject: [PATCH 07/12] =?UTF-8?q?refactor:=20s3=20=ED=94=84=EB=A1=9C?= =?UTF-8?q?=ED=95=84=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../backend/domain/user/service/UserService.java | 2 +- .../backend/global/config/s3/S3ImageService.java | 13 +++++++++++-- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/src/main/java/com/ongil/backend/domain/user/service/UserService.java b/src/main/java/com/ongil/backend/domain/user/service/UserService.java index 1daba56..2cea5b3 100644 --- a/src/main/java/com/ongil/backend/domain/user/service/UserService.java +++ b/src/main/java/com/ongil/backend/domain/user/service/UserService.java @@ -43,7 +43,7 @@ public UserInfoResDto updateProfileImage(Long userId, MultipartFile imageFile) { User user = findUser(userId); // 1. 새 이미지 먼저 S3 업로드 (실패 시 기존 이미지 보존) - String newImageUrl = s3ImageService.upload(imageFile); + String newImageUrl = s3ImageService.uploadProfileImage(imageFile); // 2. 기존 프로필 이미지 URL 백업 String oldImageUrl = user.getProfileImg(); diff --git a/src/main/java/com/ongil/backend/global/config/s3/S3ImageService.java b/src/main/java/com/ongil/backend/global/config/s3/S3ImageService.java index 9debad8..373eb1e 100644 --- a/src/main/java/com/ongil/backend/global/config/s3/S3ImageService.java +++ b/src/main/java/com/ongil/backend/global/config/s3/S3ImageService.java @@ -34,15 +34,24 @@ public class S3ImageService { private static final List ALLOWED_EXTENSIONS = List.of("jpg", "jpeg", "png"); private static final String PROFILE_DIRECTORY = "profile"; + private static final String REVIEW_DIRECTORY = "review"; + + public String uploadProfileImage(MultipartFile file) { + return upload(file, PROFILE_DIRECTORY); + } + + public String uploadReviewImage(MultipartFile file) { + return upload(file, REVIEW_DIRECTORY); + } /** * 이미지를 S3에 업로드하고 공개 URL을 반환한다. */ - public String upload(MultipartFile file) { + public String upload(MultipartFile file, String directory) { validateFile(file); String extension = extractExtension(file.getOriginalFilename()); - String key = PROFILE_DIRECTORY + "/" + UUID.randomUUID() + "." + extension; + String key = directory + "/" + UUID.randomUUID() + "." + extension; try { PutObjectRequest putRequest = PutObjectRequest.builder() From 1a0a92b6e82e34c391ca1aa90cc6b73f9a7ff9f9 Mon Sep 17 00:00:00 2001 From: marshmallowing Date: Fri, 13 Feb 2026 13:23:27 +0900 Subject: [PATCH 08/12] =?UTF-8?q?fix:=20order=20=EC=83=81=ED=83=9C=20?= =?UTF-8?q?=EC=B6=95=EC=86=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/order/converter/OrderConverter.java | 13 +++++++------ .../backend/domain/order/enums/OrderStatus.java | 4 ---- .../backend/domain/order/service/OrderService.java | 4 ++-- 3 files changed, 9 insertions(+), 12 deletions(-) diff --git a/src/main/java/com/ongil/backend/domain/order/converter/OrderConverter.java b/src/main/java/com/ongil/backend/domain/order/converter/OrderConverter.java index cba3eeb..6510e19 100644 --- a/src/main/java/com/ongil/backend/domain/order/converter/OrderConverter.java +++ b/src/main/java/com/ongil/backend/domain/order/converter/OrderConverter.java @@ -1,7 +1,5 @@ package com.ongil.backend.domain.order.converter; -import java.time.LocalDate; -import java.time.format.DateTimeFormatter; import java.util.List; import java.util.UUID; @@ -31,9 +29,11 @@ public class OrderConverter { // Request -> Order 엔티티 public Order toOrder(OrderCreateRequest request, User user, int finalAmount) { - String datePart = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyyMMdd")); - String randomPart = UUID.randomUUID().toString().substring(0, 8); - String orderNumber = "ORD-" + datePart + "-" + randomPart; + String orderNumber = UUID.randomUUID() + .toString() + .replace("-", "") + .substring(0, 10) + .toUpperCase(); return Order.builder() .orderNumber(orderNumber) @@ -44,7 +44,7 @@ public Order toOrder(OrderCreateRequest request, User user, int finalAmount) { .detailAddress(request.detailAddress()) .postalCode(request.postalCode()) .deliveryMessage(request.deliveryMessage()) - .orderStatus(OrderStatus.ORDER_RECEIVED) + .orderStatus(OrderStatus.CONFIRMED) .user(user) .build(); } @@ -110,6 +110,7 @@ public OrderItemDto toOrderItemDto(OrderItem orderItem) { String brandName = product.getBrand() != null ? product.getBrand().getName() : "일반 브랜드"; return new OrderItemDto( + orderItem.getId(), product.getId(), brandName, product.getName(), diff --git a/src/main/java/com/ongil/backend/domain/order/enums/OrderStatus.java b/src/main/java/com/ongil/backend/domain/order/enums/OrderStatus.java index d7aa182..5356c86 100644 --- a/src/main/java/com/ongil/backend/domain/order/enums/OrderStatus.java +++ b/src/main/java/com/ongil/backend/domain/order/enums/OrderStatus.java @@ -6,11 +6,7 @@ @Getter @RequiredArgsConstructor public enum OrderStatus { - ORDER_RECEIVED("주문 접수"), - SHIPPING("배송 중"), - DELIVERED("배송 완료"), CONFIRMED("구매 확정"), CANCELED("취소"); - private final String description; } diff --git a/src/main/java/com/ongil/backend/domain/order/service/OrderService.java b/src/main/java/com/ongil/backend/domain/order/service/OrderService.java index daa5f94..c3ec70d 100644 --- a/src/main/java/com/ongil/backend/domain/order/service/OrderService.java +++ b/src/main/java/com/ongil/backend/domain/order/service/OrderService.java @@ -201,7 +201,7 @@ public OrderCancelResponse cancelOrder(Long userId, Long orderId, OrderCancelReq public OrderDetailResponse updateDeliveryAddress(Long userId, Long orderId, DeliveryAddressUpdateRequest request) { Order order = getOrderAndValidateOwner(userId, orderId); - if (order.getOrderStatus() != OrderStatus.ORDER_RECEIVED) { + if (order.getOrderStatus() != OrderStatus.CONFIRMED) { throw new AppException(ErrorCode.ORDER_UPDATE_NOT_ALLOWED); } @@ -251,7 +251,7 @@ private void validateCancelable(Order order) { if (order.getOrderStatus() == OrderStatus.CANCELED) { throw new AppException(ErrorCode.ORDER_ALREADY_CANCELED); } - if (order.getOrderStatus() != OrderStatus.ORDER_RECEIVED) { + if (order.getOrderStatus() != OrderStatus.CONFIRMED) { throw new AppException(ErrorCode.ORDER_CANCEL_NOT_ALLOWED); } } From ad130f8782f4835d594998309ab4fe97ba3904f1 Mon Sep 17 00:00:00 2001 From: marshmallowing Date: Fri, 13 Feb 2026 13:23:52 +0900 Subject: [PATCH 09/12] =?UTF-8?q?chore:=20=EC=BD=94=EB=93=9C=20=EC=A0=95?= =?UTF-8?q?=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../auth/controller/AuthController.java | 3 +- .../order/dto/response/OrderItemDto.java | 1 + .../review/converter/ReviewConverter.java | 38 +++++++++++++------ .../review/dto/response/MyReviewResponse.java | 9 +++-- .../dto/response/ReviewDetailResponse.java | 9 +++-- .../dto/response/ReviewListResponse.java | 9 +++-- 6 files changed, 46 insertions(+), 23 deletions(-) diff --git a/src/main/java/com/ongil/backend/domain/auth/controller/AuthController.java b/src/main/java/com/ongil/backend/domain/auth/controller/AuthController.java index ef42a4a..2a33844 100644 --- a/src/main/java/com/ongil/backend/domain/auth/controller/AuthController.java +++ b/src/main/java/com/ongil/backend/domain/auth/controller/AuthController.java @@ -74,8 +74,6 @@ public ResponseEntity> refresh( return ResponseEntity.ok(DataResponse.from(res)); } - - @PostMapping("/logout") @Operation(summary = "로그아웃 API", description = "Redis에 저장된 리프레시 토큰을 삭제하여 로그아웃 처리") public ResponseEntity> logout( @@ -93,4 +91,5 @@ public ResponseEntity> withdraw( authService.withdraw(userId); return ResponseEntity.ok(DataResponse.from("회원 탈퇴가 완료되었습니다.")); } + } diff --git a/src/main/java/com/ongil/backend/domain/order/dto/response/OrderItemDto.java b/src/main/java/com/ongil/backend/domain/order/dto/response/OrderItemDto.java index ecf87ea..4c39100 100644 --- a/src/main/java/com/ongil/backend/domain/order/dto/response/OrderItemDto.java +++ b/src/main/java/com/ongil/backend/domain/order/dto/response/OrderItemDto.java @@ -1,6 +1,7 @@ package com.ongil.backend.domain.order.dto.response; public record OrderItemDto( + Long orderItemId, Long productId, String brandName, String productName, diff --git a/src/main/java/com/ongil/backend/domain/review/converter/ReviewConverter.java b/src/main/java/com/ongil/backend/domain/review/converter/ReviewConverter.java index 6360f7b..9c7f32c 100644 --- a/src/main/java/com/ongil/backend/domain/review/converter/ReviewConverter.java +++ b/src/main/java/com/ongil/backend/domain/review/converter/ReviewConverter.java @@ -33,7 +33,8 @@ public ReviewListResponse toListResponse(Review review, boolean isHelpful) { .rating(review.getRating()) .helpfulCount(review.getHelpfulCount()) .isHelpful(isHelpful) - .aiGeneratedReview(review.getAiGeneratedReview()) + .sizeReview(parseToList(review.getSizeReview(), "\n")) + .materialReview(parseToList(review.getMaterialReview(), "\n")) .textReview(review.getTextReview()) .reviewImageUrls(parseImageUrls(review.getReviewImageUrls())) .reviewer(buildReviewerInfo(user, review.getProduct())) @@ -66,9 +67,9 @@ private ReviewListResponse.PurchaseOption buildPurchaseOption(OrderItem orderIte // 선택지 답변 요약 구성 private ReviewListResponse.AnswerSummary buildAnswerSummary(Review review) { return ReviewListResponse.AnswerSummary.builder() - .sizeAnswer(review.getSizeAnswer()) - .colorAnswer(review.getColorAnswer()) - .materialAnswer(review.getMaterialAnswer()) + .sizeAnswer(review.getSizeAnswer().getDisplayName()) + .colorAnswer(review.getColorAnswer().getDisplayName()) + .materialAnswer(review.getMaterialAnswer().getDisplayName()) .build(); } @@ -86,7 +87,8 @@ public ReviewDetailResponse toDetailResponse(Review review, boolean isHelpful) { .rating(review.getRating()) .helpfulCount(review.getHelpfulCount()) .isHelpful(isHelpful) - .aiGeneratedReview(review.getAiGeneratedReview()) + .sizeReview(parseToList(review.getSizeReview(), "\n")) + .materialReview(parseToList(review.getMaterialReview(), "\n")) .textReview(review.getTextReview()) .reviewImageUrls(parseImageUrls(review.getReviewImageUrls())) .reviewer(buildDetailReviewerInfo(user)) @@ -138,9 +140,9 @@ private ReviewDetailResponse.ProductInfo buildProductInfo(Product product) { // 1차 리뷰 답변 구성 private ReviewDetailResponse.InitialFirstAnswers buildInitialFirstAnswers(Review review) { return ReviewDetailResponse.InitialFirstAnswers.builder() - .sizeAnswer(review.getSizeAnswer()) - .colorAnswer(review.getColorAnswer()) - .materialAnswer(review.getMaterialAnswer()) + .sizeAnswer(review.getSizeAnswer().getDisplayName()) + .colorAnswer(review.getColorAnswer().getDisplayName()) + .materialAnswer(review.getMaterialAnswer().getDisplayName()) .build(); } @@ -172,7 +174,8 @@ public MyReviewResponse toMyReviewResponse(Review review) { .reviewType(review.getReviewType().name()) .rating(review.getRating()) .helpfulCount(review.getHelpfulCount()) - .aiGeneratedReview(review.getAiGeneratedReview()) + .sizeReview(parseToList(review.getSizeReview(), "\n")) + .materialReview(parseToList(review.getMaterialReview(), "\n")) .textReview(review.getTextReview()) .reviewImageUrls(parseImageUrls(review.getReviewImageUrls())) .product(buildMyReviewProductInfo(product)) @@ -202,9 +205,9 @@ private MyReviewResponse.ProductInfo buildMyReviewProductInfo(Product product) { // 내가 작성한 리뷰 - 선택지 답변 요약 구성(2차 답변도 포함) private MyReviewResponse.AnswerSummary buildMyReviewAnswerSummary(Review review) { return MyReviewResponse.AnswerSummary.builder() - .sizeAnswer(review.getSizeAnswer()) - .colorAnswer(review.getColorAnswer()) - .materialAnswer(review.getMaterialAnswer()) + .sizeAnswer(review.getSizeAnswer().getDisplayName()) + .colorAnswer(review.getColorAnswer().getDisplayName()) + .materialAnswer(review.getMaterialAnswer().getDisplayName()) .fitIssueParts(review.getFitIssueParts()) .materialFeatures(review.getMaterialFeatures()) .build(); @@ -278,4 +281,15 @@ private String formatPurchaseOption(OrderItem orderItem) { } return null; } + + private List parseToList(String content, String delimiter) { + if (content == null || content.isBlank()) { + return Collections.emptyList(); + } + return Arrays.stream(content.split(delimiter)) + .map(String::trim) + .filter(s -> !s.isEmpty()) + .toList(); + } + } \ No newline at end of file diff --git a/src/main/java/com/ongil/backend/domain/review/dto/response/MyReviewResponse.java b/src/main/java/com/ongil/backend/domain/review/dto/response/MyReviewResponse.java index 80dde15..e9a0390 100644 --- a/src/main/java/com/ongil/backend/domain/review/dto/response/MyReviewResponse.java +++ b/src/main/java/com/ongil/backend/domain/review/dto/response/MyReviewResponse.java @@ -24,10 +24,13 @@ public class MyReviewResponse { @Schema(description = "도움돼요 수") private Integer helpfulCount; - @Schema(description = "AI 생성 리뷰") - private String aiGeneratedReview; + @Schema(description = "사이즈 관련 후기 문장 목록") + private List sizeReview; - @Schema(description = "텍스트 리뷰") + @Schema(description = "소재 관련 후기 문장 목록") + private List materialReview; + + @Schema(description = "기타 텍스트 리뷰") private String textReview; @Schema(description = "리뷰 이미지 URL 목록") diff --git a/src/main/java/com/ongil/backend/domain/review/dto/response/ReviewDetailResponse.java b/src/main/java/com/ongil/backend/domain/review/dto/response/ReviewDetailResponse.java index 8c353fd..76f653a 100644 --- a/src/main/java/com/ongil/backend/domain/review/dto/response/ReviewDetailResponse.java +++ b/src/main/java/com/ongil/backend/domain/review/dto/response/ReviewDetailResponse.java @@ -27,10 +27,13 @@ public class ReviewDetailResponse { @Schema(description = "현재 사용자가 도움돼요를 눌렀는지 여부") private Boolean isHelpful; - @Schema(description = "AI 생성 리뷰") - private String aiGeneratedReview; + @Schema(description = "사이즈 관련 후기 문장 목록") + private List sizeReview; - @Schema(description = "텍스트 리뷰") + @Schema(description = "소재 관련 후기 문장 목록") + private List materialReview; + + @Schema(description = "기타 텍스트 리뷰") private String textReview; @Schema(description = "리뷰 이미지 URL 목록") diff --git a/src/main/java/com/ongil/backend/domain/review/dto/response/ReviewListResponse.java b/src/main/java/com/ongil/backend/domain/review/dto/response/ReviewListResponse.java index a4af729..3f59f80 100644 --- a/src/main/java/com/ongil/backend/domain/review/dto/response/ReviewListResponse.java +++ b/src/main/java/com/ongil/backend/domain/review/dto/response/ReviewListResponse.java @@ -27,10 +27,13 @@ public class ReviewListResponse { @Schema(description = "현재 사용자가 도움돼요를 눌렀는지 여부") private Boolean isHelpful; - @Schema(description = "AI 생성 리뷰") - private String aiGeneratedReview; + @Schema(description = "사이즈 관련 후기 문장 목록") + private List sizeReview; - @Schema(description = "텍스트 리뷰") + @Schema(description = "소재 관련 후기 문장 목록") + private List materialReview; + + @Schema(description = "기타 텍스트 리뷰") private String textReview; @Schema(description = "리뷰 이미지 URL 목록") From ea14c66aadf3204d01516a3604eebd9e0bedbe0b Mon Sep 17 00:00:00 2001 From: marshmallowing Date: Fri, 13 Feb 2026 13:24:29 +0900 Subject: [PATCH 10/12] =?UTF-8?q?refactor:=20reviewService=20CQRS=ED=8C=A8?= =?UTF-8?q?=ED=84=B4=EC=9C=BC=EB=A1=9C=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...ewService.java => ReviewQueryService.java} | 69 +++++++++++-------- 1 file changed, 41 insertions(+), 28 deletions(-) rename src/main/java/com/ongil/backend/domain/review/service/{ReviewService.java => ReviewQueryService.java} (90%) diff --git a/src/main/java/com/ongil/backend/domain/review/service/ReviewService.java b/src/main/java/com/ongil/backend/domain/review/service/ReviewQueryService.java similarity index 90% rename from src/main/java/com/ongil/backend/domain/review/service/ReviewService.java rename to src/main/java/com/ongil/backend/domain/review/service/ReviewQueryService.java index 0b9a017..237723a 100644 --- a/src/main/java/com/ongil/backend/domain/review/service/ReviewService.java +++ b/src/main/java/com/ongil/backend/domain/review/service/ReviewQueryService.java @@ -15,15 +15,19 @@ import com.ongil.backend.domain.order.entity.OrderItem; import com.ongil.backend.domain.order.repository.OrderItemRepository; +import com.ongil.backend.domain.product.entity.Product; import com.ongil.backend.domain.product.repository.ProductRepository; import com.ongil.backend.domain.review.converter.ReviewConverter; import com.ongil.backend.domain.review.dto.request.ReviewListRequest; import com.ongil.backend.domain.review.dto.response.*; import com.ongil.backend.domain.review.entity.Review; import com.ongil.backend.domain.review.entity.ReviewHelpful; +import com.ongil.backend.domain.review.enums.ColorAnswer; +import com.ongil.backend.domain.review.enums.MaterialAnswer; import com.ongil.backend.domain.review.enums.ReviewSortType; import com.ongil.backend.domain.review.enums.ReviewStatus; import com.ongil.backend.domain.review.enums.ReviewType; +import com.ongil.backend.domain.review.enums.SizeAnswer; import com.ongil.backend.domain.review.repository.ReviewHelpfulRepository; import com.ongil.backend.domain.review.repository.ReviewRepository; import com.ongil.backend.domain.user.entity.User; @@ -36,7 +40,7 @@ @Service @RequiredArgsConstructor @Transactional(readOnly = true) -public class ReviewService { +public class ReviewQueryService { private final ReviewRepository reviewRepository; private final ReviewHelpfulRepository reviewHelpfulRepository; @@ -51,13 +55,13 @@ public class ReviewService { // 1. 상품별 리뷰 목록 조회 public Page getProductReviews(Long productId, Long userId, ReviewListRequest request) { - validateProductExists(productId); + getProductOrThrow(productId); Pageable pageable = createPageable(request.getPage(), request.getPageSize(), request.getSort()); Page reviews; // 유사 체형 필터링 적용 여부 확인 - User user = (request.isMySizeOnly() && userId != null) ? findUserById(userId) : null; + User user = (request.isMySizeOnly() && userId != null) ? getUserOrThrow(userId) : null; boolean useSimilarBodyType = user != null && hasBodyTypeInfo(user); if (useSimilarBodyType) { @@ -80,7 +84,7 @@ public Page getProductReviews(Long productId, Long userId, R // 2. 리뷰 통계 요약 조회 public ReviewSummaryResponse getReviewSummary(Long productId, Long userId) { - validateProductExists(productId); + getProductOrThrow(productId); Double avgRating = reviewRepository.getAverageRating(productId); // 평균 평점 Long initialReviewCount = reviewRepository.countByProductIdAndType(productId, ReviewType.INITIAL); // 초기 리뷰 수 @@ -89,7 +93,7 @@ public ReviewSummaryResponse getReviewSummary(Long productId, Long userId) { // 사이즈 통계 (유사 체형 기준) ReviewSummaryResponse.CategorySummary sizeSummary; if (userId != null) { - User user = findUserById(userId); + User user = getUserOrThrow(userId); if (hasBodyTypeInfo(user)) { sizeSummary = buildSizeSummaryWithSimilarBodyType(productId, user); } else { @@ -214,7 +218,7 @@ public ReviewHelpfulResponse toggleHelpful(Long reviewId, Long userId) { Review review = reviewRepository.findByIdWithLock(reviewId) .orElseThrow(() -> new EntityNotFoundException(ErrorCode.REVIEW_NOT_FOUND)); - User user = findUserById(userId); + User user = getUserOrThrow(userId); boolean exists = reviewHelpfulRepository.existsByReviewIdAndUserId(reviewId, userId); @@ -237,23 +241,6 @@ public ReviewHelpfulResponse toggleHelpful(Long reviewId, Long userId) { .build(); } - // 헬퍼 메서드 - - private void validateProductExists(Long productId) { - if (!productRepository.existsById(productId)) { - throw new EntityNotFoundException(ErrorCode.PRODUCT_NOT_FOUND); - } - } - - private User findUserById(Long userId) { - return userRepository.findById(userId) - .orElseThrow(() -> new EntityNotFoundException(ErrorCode.USER_NOT_FOUND)); - } - - private boolean hasBodyTypeInfo(User user) { - return user.getHeight() != null && user.getWeight() != null; - } - // 정렬 기준에 따른 Pageable 생성 private Pageable createPageable(int page, int size, ReviewSortType sortType) { Sort sort = switch (sortType) { @@ -334,11 +321,21 @@ private ReviewSummaryResponse.CategorySummary buildCategorySummary(String catego .sum(); List answerStats = stats.stream() - .map(row -> ReviewSummaryResponse.AnswerStat.builder() - .answer((String)row[0]) - .count((Long)row[1]) - .percentage(totalCount > 0 ? Math.round(((Long)row[1]) * 1000.0 / totalCount) / 10.0 : 0.0) - .build()) + .map(row -> { + Object key = row[0]; + String answerLabel; + + if (key instanceof SizeAnswer size) answerLabel = size.getDisplayName(); + else if (key instanceof ColorAnswer color) answerLabel = color.getDisplayName(); + else if (key instanceof MaterialAnswer mat) answerLabel = mat.getDisplayName(); + else answerLabel = String.valueOf(key); + + return ReviewSummaryResponse.AnswerStat.builder() + .answer(answerLabel) + .count((Long) row[1]) + .percentage(totalCount > 0 ? Math.round(((Long) row[1]) * 1000.0 / totalCount) / 10.0 : 0.0) + .build(); + }) .sorted(Comparator.comparing(ReviewSummaryResponse.AnswerStat::getCount).reversed()) .collect(Collectors.toList()); @@ -353,4 +350,20 @@ private ReviewSummaryResponse.CategorySummary buildCategorySummary(String catego .answerStats(answerStats) .build(); } + + + private User getUserOrThrow(Long userId) { + return userRepository.findById(userId) + .orElseThrow(() -> new EntityNotFoundException(ErrorCode.USER_NOT_FOUND)); + } + + private Product getProductOrThrow(Long productId) { + return productRepository.findById(productId) + .orElseThrow(() -> new EntityNotFoundException(ErrorCode.PRODUCT_NOT_FOUND)); + } + + private boolean hasBodyTypeInfo(User user) { + return user.getHeight() != null && user.getWeight() != null; + } } + From 9fcb733f5339e4c0c52f115d11fd60973d3e9439 Mon Sep 17 00:00:00 2001 From: marshmallowing Date: Fri, 13 Feb 2026 14:02:39 +0900 Subject: [PATCH 11/12] =?UTF-8?q?refactor:=20=EB=A6=AC=EB=B7=B0=20?= =?UTF-8?q?=ED=94=84=EB=A1=AC=ED=94=84=ED=8A=B8=20=EC=83=9D=EC=84=B1=20?= =?UTF-8?q?=EB=A1=9C=EC=A7=81=20=EB=B6=84=EB=A6=AC=20=EB=B0=8F=20=EB=B9=88?= =?UTF-8?q?=20=EC=A3=BC=EC=9E=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/AiReviewGeneratorService.java | 310 ++---------------- .../prompter/MaterialReviewPrompter.java | 165 ++++++++++ .../service/prompter/ReviewPrompter.java | 8 + .../service/prompter/SizeReviewPrompter.java | 130 ++++++++ .../backend/global/config/OpenAiConfig.java | 20 ++ 5 files changed, 349 insertions(+), 284 deletions(-) create mode 100644 src/main/java/com/ongil/backend/domain/review/service/prompter/MaterialReviewPrompter.java create mode 100644 src/main/java/com/ongil/backend/domain/review/service/prompter/ReviewPrompter.java create mode 100644 src/main/java/com/ongil/backend/domain/review/service/prompter/SizeReviewPrompter.java create mode 100644 src/main/java/com/ongil/backend/global/config/OpenAiConfig.java diff --git a/src/main/java/com/ongil/backend/domain/review/service/AiReviewGeneratorService.java b/src/main/java/com/ongil/backend/domain/review/service/AiReviewGeneratorService.java index 96d3094..b85f096 100644 --- a/src/main/java/com/ongil/backend/domain/review/service/AiReviewGeneratorService.java +++ b/src/main/java/com/ongil/backend/domain/review/service/AiReviewGeneratorService.java @@ -2,6 +2,8 @@ import com.ongil.backend.domain.review.dto.request.AiReviewGenerateRequest; import com.ongil.backend.domain.review.dto.response.AiReviewResponse; +import com.ongil.backend.domain.review.service.prompter.MaterialReviewPrompter; +import com.ongil.backend.domain.review.service.prompter.SizeReviewPrompter; import com.ongil.backend.domain.review.validator.ReviewValidator; import com.ongil.backend.global.common.exception.AppException; import com.ongil.backend.global.common.exception.ErrorCode; @@ -12,10 +14,8 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; -import java.util.ArrayList; import java.util.Arrays; import java.util.List; @@ -24,301 +24,36 @@ @RequiredArgsConstructor public class AiReviewGeneratorService { + private final OpenAiService openAiService; + private final SizeReviewPrompter sizePrompter; + private final MaterialReviewPrompter materialPrompter; private final ReviewValidator reviewValidator; - @Value("${openai.api-key}") - private String openAiApiKey; - - private static final String SIZE_REVIEW_PROMPT = """ - 너는 의류 착용 후기를 생성하는 AI임. - 사용자가 선택한 의류 종류, 부위, 강도를 기반으로 - 시니어도 이해하기 쉬운 표현으로 자연스러운 후기 문장을 생성해야 함. - - 아래 규칙을 반드시 모두 지켜야 함. - - ━━━━━━━━━━━━━━ - [1. 부위 범위 규칙] - - 사용자가 선택한 부위는 문장에서 언급 가능한 유일한 불편 범위임 - 선택된 부위 외의 신체 부위, 상황, 결과는 절대 언급 불가함 - 복합 부위(예: 엉덩이 & 가랑이)는 하나의 묶음이지만 - 문장 안에서 단일 부위만 언급했을 경우, - 불편한 상황·감정·원인은 해당 부위로만 한정해야 함 - - 예시로, - 가랑이가 답답해서 앉아 있을 때 불편함은 괜찮지만 - 가랑이가 답답해서 엉덩이 봉제선이 당겨지는 느낌은 안됨 - - 복합 부위를 함께 언급한 경우에만 - 두 부위 모두에 대한 불편 상황 설명 가능함 - 단, 부위 간 인과 관계(원인→결과) 표현은 금지함 - - ━━━━━━━━━━━━━━ - [2. 강도 표현 규칙] - - 강도에 따라 아래 표현 중에서만 선택하여 사용해야 하며, - 의미를 벗어나는 과장·완화 표현은 사용 불가함 - - 너무 답답: - 매우, 심하게, 숨쉬기 힘들 정도로 - - 조금 답답: - 살짝, 신경 쓰일 정도로 - (허리&복부 / 가슴&몸통의 경우만) - 밥 먹고 나면 답답해지는 느낌임 - - 약간 커서 거슬림: - 거슬릴 정도로 - - 너무 커서 불편함: - 많이, 지나치게 - - 수선이 필요할 정도로 큼: - 입고 다니기 힘들 정도로 큼 - - 편함: - 입는 내내 편함 - 신축성이 좋아 움직이기 편함 - 이 경우 특정 부위 언급 없이 - 전반적인 착용감만 작성해야 함 - - ━━━━━━━━━━━━━━ - [3. 표현 톤 규칙 (시니어 친화)] - - 전문 용어, 유행어, 신체 과장 표현 사용 금지 - 아래 표현은 사용하지 않음 - (핏, 옷매무새, Y존, 라인 부각, 실루엣 등) - - 대신 일상적이고 직관적인 표현 사용 - 예: - - 몸에 딱 붙는다 - - 움직일 때 불편하다 - - 앉거나 일어날 때 신경 쓰인다 - - 오래 입기엔 부담된다 - - ━━━━━━━━━━━━━━ - [4. 문장 구조 규칙] - - 하나의 문장에는 하나의 불편 경험만 포함함 - 평가 + 상황 + 느낌의 순서를 유지함 - 문장 끝은 반드시 ~임 으로 끝냄 - - ━━━━━━━━━━━━━━ - [출력 목표] - - 사용자가 선택한 - - 의류 종류 - - 부위 - - 강도 - 를 정확히 반영하여 - 부위 범위를 넘지 않는, - 강도에 맞는, - 시니어도 이해 가능한 한 문장 후기를 생성함. - """; - - private static final String MATERIAL_REVIEW_PROMPT = """ - 당신은 의류 소재 착용 후기를 실제 사용자 경험처럼 풀어내는 AI입니다. - 특히 시니어 사용자가 이해하기 쉬운 표현을 최우선으로 사용해야 합니다. - - ━━━━━━━━━━━━━━━━━━ - [기본 전제] - 본 프롬프트는 소재에 대한 후기만 작성함 - 핏, 사이즈, 신체 부위, 착용 부위 언급 금지 - 소재의 인상과 느낌만 다룸 - - ━━━━━━━━━━━━━━━━━━ - [가장 중요한 규칙 – 선택 항목 일치] - 사용자가 선택한 항목만 서술해야 함 - 선택하지 않은 소재 속성으로 확장 금지 - 예: 촉감 선택 → 촉감에 대한 내용만 작성 - 예: 무게감 선택 → 무게감 외 언급 금지 - - 좋은 점 선택 시 부정 표현 금지 - 아쉬운 점 선택 시 긍정 표현 금지 - 눈에 띄는 점 없음 선택 시 - → 장점·단점 분석, 속성 설명 모두 금지 - - ━━━━━━━━━━━━━━━━━━ - [핵심 작성 원칙] - 한 문장에는 하나의 느낌만 작성 - 1인칭 후기 톤 사용 - 시니어가 이해하기 쉬운 말만 사용 - 모든 문장 끝맺음은 반드시 "~임" - 판단, 비교, 조언, 추천, 해결책 작성 금지 - - ━━━━━━━━━━━━━━━━━━ - [소재 속성 카테고리] - 촉감 (부드러움 / 거칠음) - 무게감 (가벼움 / 무거움) - 구김 정도 (많음 / 없음) - 두께감 (얇음 / 두꺼움) - 보풀 (있음 / 없음) - 비침 정도 (안 비침 / 비침) - - ━━━━━━━━━━━━━━━━━━ - [2차 질문 – 좋은 점 선택 시 출력 규칙] - 선택한 속성 중 하나만 기준으로 1~2문장 작성 - 편안함, 부담 없음, 일상 사용 중심으로 표현 - - [좋은 점 예시 가이드] - 촉감(부드러움): - · 손에 닿는 느낌이 부드러움 - - 무게감(가벼움): - · 가벼워서 편안함 - - 구김 없음: - · 오래 입어도 구김이 잘 생기지 않음 - - 두께감 두꺼움/얇음: - · 두꺼워서 따뜻함 / 얇아서 시원함 / 봄, 가을에 입기 적당한 두께 - - 보풀 없음: - · 보풀이 잘 안나는 소재 - - 비침 없음: - · 안이 비치지 않아 신경 쓰이지 않음 - - ━━━━━━━━━━━━━━━━━━ - [2차 질문 – 아쉬운 점 선택 시 출력 규칙] - 선택한 속성 하나만 기준으로 1~2문장 작성 - 불편하지만 과장 없이, 체감 위주로 표현 - - [아쉬운 점 예시 가이드] - 촉감(거칠음): - · 피부에 닿을 때 거칠음 - - 무게감(무거움): - · 무거워서 오래 입기엔 부담됨 - - 구김 많음: - · 조금만 움직여도 구김이 생김 - - 두꺼움: - · 두꺼워서 더움 / 얇아서 추움 - - 보풀 있음: - · 보풀이 금방 생기는 소재임 - - 비침 있음: - · 안이 비쳐 보여 신경 쓰임 - - ━━━━━━━━━━━━━━━━━━ - [③ 눈에 띄는 점은 없었어요 선택 시 전용 규칙] - 소재 속성(촉감, 무게, 두께 등) 언급 금지 - 장점·단점 분석 금지 - "무난함 / 평범함 / 거슬리지 않음" 인상만 전달 - - 아래 문장 유형 중 1~2문장만 출력 - [허용 문장 예시] - 전반적으로 무난해서 부담 없이 입을 수 있음 - 특별히 좋거나 아쉬운 점 없이 평범한 느낌임 - 특별히 좋은 점은 없지만 입는 데 거슬리지도 않았음 - 일상적으로 입는 데에 무리가 없는 평범한 소재임 - - ━━━━━━━━━━━━━━━━━━ - [출력 분량 규칙] - 모든 선택지: 1~2문장 - 문장 간 의미 중복 금지 - 규칙 위반 시 잘못된 출력으로 간주됨 - """; - public AiReviewResponse generateSizeReview(AiReviewGenerateRequest request) { reviewValidator.validateReviewStepCompletion(request.getSizeAnswer(), request.getFitIssueParts()); - OpenAiService service = new OpenAiService(openAiApiKey); - String userMessage = buildSizeReviewPrompt(request); - - String aiResponse = callOpenAi(service, MATERIAL_REVIEW_PROMPT, userMessage); - List reviewList = Arrays.stream(aiResponse.split("\\|")) - .map(String::trim) - .filter(s -> !s.isEmpty()) - .toList(); + String systemPrompt = sizePrompter.getSystemPrompt(); + String userMessage = sizePrompter.buildUserMessage(request); - return AiReviewResponse.of(request.getReviewId(), reviewList); + String aiResponse = callOpenAi(systemPrompt, userMessage); + return AiReviewResponse.of(request.getReviewId(), parseAiResponse(aiResponse)); } public AiReviewResponse generateMaterialReview(AiReviewGenerateRequest request) { reviewValidator.validateReviewStepCompletion(request.getMaterialAnswer(), request.getMaterialFeatures()); - OpenAiService service = new OpenAiService(openAiApiKey); - String userMessage = buildMaterialReviewPrompt(request); - - String aiResponse = callOpenAi(service, MATERIAL_REVIEW_PROMPT, userMessage); - - List reviewList = Arrays.stream(aiResponse.split("\\|")) - .map(String::trim) - .filter(s -> !s.isEmpty()) - .toList(); - - return AiReviewResponse.of(request.getReviewId(), reviewList); - } - - private String buildSizeReviewPrompt(AiReviewGenerateRequest request) { - StringBuilder prompt = new StringBuilder(); - prompt.append("의류 종류: ").append(request.getClothingType().getDisplayName()).append("\n"); - prompt.append("착용 상태: ").append(request.getSizeAnswer().getDisplayName()).append("\n"); - - if (request.getSizeAnswer().isNeedsSecondaryQuestion() && !request.getFitIssueParts().isEmpty()) { - prompt.append("불편 부위: ").append(String.join(", ", request.getFitIssueParts())).append("\n"); - prompt.append("\n[특수 지시]"); - prompt.append("- 위 리스트에 있는 각 '복합 부위' 항목 전체를 소재로 하여 서로 다른 2문장씩 생성할 것.\n"); - prompt.append("- 예: '가슴&몸통' 선택 시 -> 가슴과 몸통 전체의 착용감을 다루는 문장 2개 생성.\n"); - } else { - prompt.append("특이사항: 전체적으로 편안함\n"); - prompt.append("[지시] 전반적인 편안함을 강조하는 서로 다른 2문장을 생성할 것.\n"); - } - - prompt.append("\n[출력 형식] 반드시 각 문장 사이를 '|' 기호로만 구분하여 출력할 것."); - return prompt.toString(); - } - - private String buildMaterialReviewPrompt(AiReviewGenerateRequest request) { - StringBuilder prompt = new StringBuilder(); - - boolean isPositive = request.getMaterialAnswer().isPositive(); - prompt.append("소재 평가 상태: ").append(isPositive ? "긍정적" : "부정적(아쉬움)").append("\n"); - prompt.append("소재 평가: ").append(request.getMaterialAnswer().getDisplayName()).append("\n"); - - if (!request.getMaterialFeatures().isEmpty()) { - prompt.append("선택한 소재 특징:\n"); - boolean hasThicknessAll = false; - - for (String feature : request.getMaterialFeatures()) { - if ("두께감:선택지전체".equals(feature)) { - hasThicknessAll = true; - continue; - } - prompt.append("- ").append(feature).append("\n"); - } - - prompt.append("\n[문장 생성 규칙]"); - prompt.append("\n1. 위 리스트에 나열된 각 특징마다 서로 다른 느낌의 '2문장씩'을 반드시 생성할 것."); - if (hasThicknessAll) { - prompt.append("\n[특수 지시] 두께감은 아래 3가지 상황에 맞춰 생성하되, 각 문장 사이를 '|' 기호로 구분할 것:\n"); - if (isPositive) { - prompt.append("1. 두꺼워서 따뜻함 | 2. 얇아서 시원함 | 3. 적당한 두께임\n"); - } else { - prompt.append("1. 소재가 너무 두꺼워서 답답함 | 2. 너무 얇아서 추운 느낌임 | 3. 두께가 애매해서 아쉬움\n"); - } - } - } - else { - prompt.append("\n[지시] 특정 소재 속성 언급 없이, 전반적으로 무난하고 평범하다는 인상의 서로 다른 '2문장'을 생성할 것.\n"); - } + String systemPrompt = materialPrompter.getSystemPrompt(); + String userMessage = materialPrompter.buildUserMessage(request); - prompt.append("\n[최종 출력 형식 지시]"); - prompt.append("\n- 모든 문장은 반드시 '|' 기호로만 구분하여 나열할 것."); - prompt.append("\n- 문장 끝은 반드시 '~임'으로 끝낼 것."); - prompt.append("\n- 마침표(.)나 줄바꿈(\n)을 구분자로 사용하지 말 것."); - - return prompt.toString(); + String aiResponse = callOpenAi(systemPrompt, userMessage); + return AiReviewResponse.of(request.getReviewId(), parseAiResponse(aiResponse)); } - private String callOpenAi(OpenAiService service, String systemPrompt, String userMessage) { - List messages = new ArrayList<>(); - messages.add(new ChatMessage(ChatMessageRole.SYSTEM.value(), systemPrompt)); - messages.add(new ChatMessage(ChatMessageRole.USER.value(), userMessage)); + private String callOpenAi(String systemPrompt, String userMessage) { + List messages = List.of( + new ChatMessage(ChatMessageRole.SYSTEM.value(), systemPrompt), + new ChatMessage(ChatMessageRole.USER.value(), userMessage) + ); ChatCompletionRequest completionRequest = ChatCompletionRequest.builder() .model("gpt-4o-mini") @@ -327,11 +62,18 @@ private String callOpenAi(OpenAiService service, String systemPrompt, String use .build(); try { - return service.createChatCompletion(completionRequest) + return openAiService.createChatCompletion(completionRequest) .getChoices().get(0).getMessage().getContent().trim(); } catch (Exception e) { log.error("AI 생성 실패: ", e); throw new AppException(ErrorCode.AI_GENERATION_ERROR); } } + + private List parseAiResponse(String aiResponse) { + return Arrays.stream(aiResponse.split("\\|")) + .map(String::trim) + .filter(s -> !s.isEmpty()) + .toList(); + } } diff --git a/src/main/java/com/ongil/backend/domain/review/service/prompter/MaterialReviewPrompter.java b/src/main/java/com/ongil/backend/domain/review/service/prompter/MaterialReviewPrompter.java new file mode 100644 index 0000000..f2966e7 --- /dev/null +++ b/src/main/java/com/ongil/backend/domain/review/service/prompter/MaterialReviewPrompter.java @@ -0,0 +1,165 @@ +package com.ongil.backend.domain.review.service.prompter; + +import org.springframework.stereotype.Component; + +import com.ongil.backend.domain.review.dto.request.AiReviewGenerateRequest; + +@Component +public class MaterialReviewPrompter implements ReviewPrompter { + + private static final String SYSTEM_PROMPT = """ + 당신은 의류 소재 착용 후기를 실제 사용자 경험처럼 풀어내는 AI입니다. + 특히 시니어 사용자가 이해하기 쉬운 표현을 최우선으로 사용해야 합니다. + + ━━━━━━━━━━━━━━━━━━ + [기본 전제] + 본 프롬프트는 소재에 대한 후기만 작성함 + 핏, 사이즈, 신체 부위, 착용 부위 언급 금지 + 소재의 인상과 느낌만 다룸 + + ━━━━━━━━━━━━━━━━━━ + [가장 중요한 규칙 – 선택 항목 일치] + 사용자가 선택한 항목만 서술해야 함 + 선택하지 않은 소재 속성으로 확장 금지 + 예: 촉감 선택 → 촉감에 대한 내용만 작성 + 예: 무게감 선택 → 무게감 외 언급 금지 + + 좋은 점 선택 시 부정 표현 금지 + 아쉬운 점 선택 시 긍정 표현 금지 + 눈에 띄는 점 없음 선택 시 + → 장점·단점 분석, 속성 설명 모두 금지 + + ━━━━━━━━━━━━━━━━━━ + [핵심 작성 원칙] + 한 문장에는 하나의 느낌만 작성 + 1인칭 후기 톤 사용 + 시니어가 이해하기 쉬운 말만 사용 + 모든 문장 끝맺음은 반드시 "~임" + 판단, 비교, 조언, 추천, 해결책 작성 금지 + + ━━━━━━━━━━━━━━━━━━ + [소재 속성 카테고리] + 촉감 (부드러움 / 거칠음) + 무게감 (가벼움 / 무거움) + 구김 정도 (많음 / 없음) + 두께감 (얇음 / 두꺼움) + 보풀 (있음 / 없음) + 비침 정도 (안 비침 / 비침) + + ━━━━━━━━━━━━━━━━━━ + [2차 질문 – 좋은 점 선택 시 출력 규칙] + 선택한 속성 중 하나만 기준으로 1~2문장 작성 + 편안함, 부담 없음, 일상 사용 중심으로 표현 + + [좋은 점 예시 가이드] + 촉감(부드러움): + · 손에 닿는 느낌이 부드러움 + + 무게감(가벼움): + · 가벼워서 편안함 + + 구김 없음: + · 오래 입어도 구김이 잘 생기지 않음 + + 두께감 두꺼움/얇음: + · 두꺼워서 따뜻함 / 얇아서 시원함 / 봄, 가을에 입기 적당한 두께 + + 보풀 없음: + · 보풀이 잘 안나는 소재 + + 비침 없음: + · 안이 비치지 않아 신경 쓰이지 않음 + + ━━━━━━━━━━━━━━━━━━ + [2차 질문 – 아쉬운 점 선택 시 출력 규칙] + 선택한 속성 하나만 기준으로 1~2문장 작성 + 불편하지만 과장 없이, 체감 위주로 표현 + + [아쉬운 점 예시 가이드] + 촉감(거칠음): + · 피부에 닿을 때 거칠음 + + 무게감(무거움): + · 무거워서 오래 입기엔 부담됨 + + 구김 많음: + · 조금만 움직여도 구김이 생김 + + 두꺼움: + · 두꺼워서 더움 / 얇아서 추움 + + 보풀 있음: + · 보풀이 금방 생기는 소재임 + + 비침 있음: + · 안이 비쳐 보여 신경 쓰임 + + ━━━━━━━━━━━━━━━━━━ + [③ 눈에 띄는 점은 없었어요 선택 시 전용 규칙] + 소재 속성(촉감, 무게, 두께 등) 언급 금지 + 장점·단점 분석 금지 + "무난함 / 평범함 / 거슬리지 않음" 인상만 전달 + + 아래 문장 유형 중 1~2문장만 출력 + [허용 문장 예시] + 전반적으로 무난해서 부담 없이 입을 수 있음 + 특별히 좋거나 아쉬운 점 없이 평범한 느낌임 + 특별히 좋은 점은 없지만 입는 데 거슬리지도 않았음 + 일상적으로 입는 데에 무리가 없는 평범한 소재임 + + ━━━━━━━━━━━━━━━━━━ + [출력 분량 규칙] + 모든 선택지: 1~2문장 + 문장 간 의미 중복 금지 + 규칙 위반 시 잘못된 출력으로 간주됨 + """; + + @Override + public String getSystemPrompt() { + return SYSTEM_PROMPT; + } + + @Override + public String buildUserMessage(AiReviewGenerateRequest request) { + StringBuilder prompt = new StringBuilder(); + + boolean isPositive = request.getMaterialAnswer().isPositive(); + prompt.append("소재 평가 상태: ").append(isPositive ? "긍정적" : "부정적(아쉬움)").append("\n"); + prompt.append("소재 평가: ").append(request.getMaterialAnswer().getDisplayName()).append("\n"); + + if (!request.getMaterialFeatures().isEmpty()) { + prompt.append("선택한 소재 특징:\n"); + boolean hasThicknessAll = false; + + for (String feature : request.getMaterialFeatures()) { + if ("두께감:선택지전체".equals(feature)) { + hasThicknessAll = true; + continue; + } + prompt.append("- ").append(feature).append("\n"); + } + + prompt.append("\n[문장 생성 규칙]"); + prompt.append("\n1. 위 리스트에 나열된 각 특징마다 서로 다른 느낌의 '2문장씩'을 반드시 생성할 것."); + + if (hasThicknessAll) { + prompt.append("\n[특수 지시] 두께감은 아래 3가지 상황에 맞춰 생성하되, 각 문장 사이를 '|' 기호로 구분할 것:\n"); + if (isPositive) { + prompt.append("1. 두꺼워서 따뜻함 | 2. 얇아서 시원함 | 3. 적당한 두께임\n"); + } else { + prompt.append("1. 소재가 너무 두꺼워서 답답함 | 2. 너무 얇아서 추운 느낌임 | 3. 두께가 애매해서 아쉬움\n"); + } + } + } + else { + prompt.append("\n[지시] 특정 소재 속성 언급 없이, 전반적으로 무난하고 평범하다는 인상의 서로 다른 '2문장'을 생성할 것.\n"); + } + + prompt.append("\n[최종 출력 형식 지시]"); + prompt.append("\n- 모든 문장은 반드시 '|' 기호로만 구분하여 나열할 것."); + prompt.append("\n- 문장 끝은 반드시 '~임'으로 끝낼 것."); + prompt.append("\n- 마침표(.)나 줄바꿈(\n)을 구분자로 사용하지 말 것."); + + return prompt.toString(); + } +} \ No newline at end of file diff --git a/src/main/java/com/ongil/backend/domain/review/service/prompter/ReviewPrompter.java b/src/main/java/com/ongil/backend/domain/review/service/prompter/ReviewPrompter.java new file mode 100644 index 0000000..6eb137c --- /dev/null +++ b/src/main/java/com/ongil/backend/domain/review/service/prompter/ReviewPrompter.java @@ -0,0 +1,8 @@ +package com.ongil.backend.domain.review.service.prompter; + +import com.ongil.backend.domain.review.dto.request.AiReviewGenerateRequest; + +public interface ReviewPrompter { + String getSystemPrompt(); + String buildUserMessage(AiReviewGenerateRequest request); +} diff --git a/src/main/java/com/ongil/backend/domain/review/service/prompter/SizeReviewPrompter.java b/src/main/java/com/ongil/backend/domain/review/service/prompter/SizeReviewPrompter.java new file mode 100644 index 0000000..3e50231 --- /dev/null +++ b/src/main/java/com/ongil/backend/domain/review/service/prompter/SizeReviewPrompter.java @@ -0,0 +1,130 @@ +package com.ongil.backend.domain.review.service.prompter; + +import org.springframework.stereotype.Component; + +import com.ongil.backend.domain.review.dto.request.AiReviewGenerateRequest; + +@Component +public class SizeReviewPrompter implements ReviewPrompter { + + private static final String SYSTEM_PROMPT = """ + 너는 의류 착용 후기를 생성하는 AI임. + 사용자가 선택한 의류 종류, 부위, 강도를 기반으로 + 시니어도 이해하기 쉬운 표현으로 자연스러운 후기 문장을 생성해야 함. + + 아래 규칙을 반드시 모두 지켜야 함. + + ━━━━━━━━━━━━━━ + [1. 부위 범위 규칙] + + 사용자가 선택한 부위는 문장에서 언급 가능한 유일한 불편 범위임 + 선택된 부위 외의 신체 부위, 상황, 결과는 절대 언급 불가함 + 복합 부위(예: 엉덩이 & 가랑이)는 하나의 묶음이지만 + 문장 안에서 단일 부위만 언급했을 경우, + 불편한 상황·감정·원인은 해당 부위로만 한정해야 함 + + 예시로, + 가랑이가 답답해서 앉아 있을 때 불편함은 괜찮지만 + 가랑이가 답답해서 엉덩이 봉제선이 당겨지는 느낌은 안됨 + + 복합 부위를 함께 언급한 경우에만 + 두 부위 모두에 대한 불편 상황 설명 가능함 + 단, 부위 간 인과 관계(원인→결과) 표현은 금지함 + + ━━━━━━━━━━━━━━ + [2. 강도 표현 규칙] + + 강도에 따라 아래 표현 중에서만 선택하여 사용해야 하며, + 의미를 벗어나는 과장·완화 표현은 사용 불가함 + + 너무 답답: + 매우, 심하게, 숨쉬기 힘들 정도로 + + 조금 답답: + 살짝, 신경 쓰일 정도로 + (허리&복부 / 가슴&몸통의 경우만) + 밥 먹고 나면 답답해지는 느낌임 + + 약간 커서 거슬림: + 거슬릴 정도로 + + 너무 커서 불편함: + 많이, 지나치게 + + 수선이 필요할 정도로 큼: + 입고 다니기 힘들 정도로 큼 + + 편함: + 입는 내내 편함 + 신축성이 좋아 움직이기 편함 + 이 경우 특정 부위 언급 없이 + 전반적인 착용감만 작성해야 함 + + ━━━━━━━━━━━━━━ + [3. 표현 톤 규칙 (시니어 친화)] + + 전문 용어, 유행어, 신체 과장 표현 사용 금지 + 아래 표현은 사용하지 않음 + (핏, 옷매무새, Y존, 라인 부각, 실루엣 등) + + 대신 일상적이고 직관적인 표현 사용 + 예: + - 몸에 딱 붙는다 + - 움직일 때 불편하다 + - 앉거나 일어날 때 신경 쓰인다 + - 오래 입기엔 부담된다 + + ━━━━━━━━━━━━━━ + [4. 문장 구조 규칙] + + 하나의 문장에는 하나의 불편 경험만 포함함 + 평가 + 상황 + 느낌의 순서를 유지함 + 문장 끝은 반드시 ~임 으로 끝냄 + + ━━━━━━━━━━━━━━ + [출력 목표] + + 사용자가 선택한 + - 의류 종류 + - 부위 + - 강도 + 를 정확히 반영하여 + 부위 범위를 넘지 않는, + 강도에 맞는, + 시니어도 이해 가능한 한 문장 후기를 생성함. + """; + + @Override + public String getSystemPrompt() { + return SYSTEM_PROMPT; + } + + @Override + public String buildUserMessage(AiReviewGenerateRequest request) { + StringBuilder prompt = new StringBuilder(); + prompt.append("의류 종류: ").append(request.getClothingType().getDisplayName()).append("\n"); + prompt.append("착용 상태: ").append(request.getSizeAnswer().getDisplayName()).append("\n"); + + if (request.getSizeAnswer().isNeedsSecondaryQuestion() && !request.getFitIssueParts().isEmpty()) { + prompt.append("불편 항목 리스트: ").append(String.join(", ", request.getFitIssueParts())).append("\n"); + + prompt.append("\n[생성 지시]"); + prompt.append("\n- 위 리스트에 나열된 각 항목(예: 어깨&목, 전반적 등)마다 **반드시 2문장씩** 생성할 것."); + + if (request.getFitIssueParts().contains("전반적")) { + prompt.append("\n- '전반적' 항목에 대해서는 특정 신체 부위 언급 없이 전체적인 실루엣이나 조이는 느낌에 대해 2문장을 생성할 것."); + } + + prompt.append("\n- 결과적으로 총 ").append(request.getFitIssueParts().size() * 2).append("문장이 생성되어야 함."); + } + else { + prompt.append("특이사항: 전반적으로 잘 맞고 편안함\n"); + prompt.append("[지시] 전반적인 편안함과 만족감을 강조하는 서로 다른 2문장을 생성할 것.\n"); + } + + prompt.append("\n[출력 형식 지시]"); + prompt.append("\n- 반드시 각 문장 사이를 '|' 기호로만 구분할 것."); + prompt.append("\n- 문장 끝에 마침표를 찍지 말고 바로 '|'로 이을 것."); + return prompt.toString(); + } +} diff --git a/src/main/java/com/ongil/backend/global/config/OpenAiConfig.java b/src/main/java/com/ongil/backend/global/config/OpenAiConfig.java new file mode 100644 index 0000000..a56bf12 --- /dev/null +++ b/src/main/java/com/ongil/backend/global/config/OpenAiConfig.java @@ -0,0 +1,20 @@ +package com.ongil.backend.global.config; + +import java.time.Duration; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import com.theokanning.openai.service.OpenAiService; + +@Configuration +public class OpenAiConfig { + @Value("${openai.api-key}") + private String apiKey; + + @Bean + public OpenAiService openAiService() { + return new OpenAiService(apiKey, Duration.ofSeconds(60)); + } +} From 02855d89b0d722a54261c80a7e201a483160bc52 Mon Sep 17 00:00:00 2001 From: marshmallowing Date: Fri, 13 Feb 2026 18:13:32 +0900 Subject: [PATCH 12/12] =?UTF-8?q?chore:=20=EB=A6=AC=EB=B7=B0=20=EB=B0=98?= =?UTF-8?q?=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/review/controller/ReviewController.java | 9 +++++---- .../review/converter/ReviewWriteConverter.java | 6 +++--- .../review/dto/request/ReviewStep1Request.java | 8 ++++---- .../domain/review/service/ReviewCommandService.java | 13 +++++++++---- .../review/service/prompter/SizeReviewPrompter.java | 2 +- .../backend/global/config/s3/S3ImageService.java | 2 +- 6 files changed, 23 insertions(+), 17 deletions(-) diff --git a/src/main/java/com/ongil/backend/domain/review/controller/ReviewController.java b/src/main/java/com/ongil/backend/domain/review/controller/ReviewController.java index 47c61ce..e713ad7 100644 --- a/src/main/java/com/ongil/backend/domain/review/controller/ReviewController.java +++ b/src/main/java/com/ongil/backend/domain/review/controller/ReviewController.java @@ -25,6 +25,7 @@ import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; @Tag(name = "Review", description = "리뷰 API (토큰 필요)") @@ -125,7 +126,7 @@ public DataResponse initializeReview( public DataResponse updateReviewStep1( @AuthenticationPrincipal Long userId, @PathVariable Long reviewId, - @RequestBody ReviewStep1Request request + @Valid @RequestBody ReviewStep1Request request ) { ReviewStep1Response response = reviewCommandService.updateReviewStep1(userId, reviewId, request); return DataResponse.from(response); @@ -136,7 +137,7 @@ public DataResponse updateReviewStep1( public DataResponse updateReviewStep2Size( @AuthenticationPrincipal Long userId, @PathVariable Long reviewId, - @RequestBody ReviewStep2SizeRequest request + @Valid @RequestBody ReviewStep2SizeRequest request ) { reviewCommandService.updateReviewStep2Size(userId, reviewId, request); return DataResponse.ok(); @@ -147,7 +148,7 @@ public DataResponse updateReviewStep2Size( public DataResponse updateReviewStep2Material( @AuthenticationPrincipal Long userId, @PathVariable Long reviewId, - @RequestBody ReviewStep2MaterialRequest request + @Valid @RequestBody ReviewStep2MaterialRequest request ) { reviewCommandService.updateReviewStep2Material(userId, reviewId, request); return DataResponse.ok(); @@ -193,7 +194,7 @@ public DataResponse> uploadReviewImages( public DataResponse submitReview( @AuthenticationPrincipal Long userId, @PathVariable Long reviewId, - @RequestBody ReviewFinalSubmitRequest request + @Valid @RequestBody ReviewFinalSubmitRequest request ) { reviewCommandService.submitReview(userId, reviewId, request); return DataResponse.ok(); diff --git a/src/main/java/com/ongil/backend/domain/review/converter/ReviewWriteConverter.java b/src/main/java/com/ongil/backend/domain/review/converter/ReviewWriteConverter.java index ba00f02..d1e0f11 100644 --- a/src/main/java/com/ongil/backend/domain/review/converter/ReviewWriteConverter.java +++ b/src/main/java/com/ongil/backend/domain/review/converter/ReviewWriteConverter.java @@ -30,10 +30,10 @@ public Review toInitialReviewEntity(User user, OrderItem orderItem, ClothingCate } public ReviewStep1Response toStep1Response(Review review) { - boolean needsSizeQ = review.getSizeAnswer().isNeedsSecondaryQuestion(); - boolean needsMaterialQ = review.getMaterialAnswer().isNeedsSecondaryQuestion(); + boolean needsSizeQ = review.getSizeAnswer() != null && review.getSizeAnswer().isNeedsSecondaryQuestion(); + boolean needsMaterialQ = review.getMaterialAnswer() != null && review.getMaterialAnswer().isNeedsSecondaryQuestion(); - List availableBodyParts = needsSizeQ + List availableBodyParts = (needsSizeQ && review.getClothingCategory() != null) ? review.getClothingCategory().getBodyParts() : Collections.emptyList(); diff --git a/src/main/java/com/ongil/backend/domain/review/dto/request/ReviewStep1Request.java b/src/main/java/com/ongil/backend/domain/review/dto/request/ReviewStep1Request.java index a83844e..b69f60a 100644 --- a/src/main/java/com/ongil/backend/domain/review/dto/request/ReviewStep1Request.java +++ b/src/main/java/com/ongil/backend/domain/review/dto/request/ReviewStep1Request.java @@ -19,7 +19,7 @@ @AllArgsConstructor public class ReviewStep1Request { - @NotBlank(message = "의류 카테고리는 필수입니다.") + @NotNull(message = "의류 카테고리는 필수입니다.") private ClothingCategory clothingCategory; @NotNull(message = "별점은 필수입니다.") @@ -27,12 +27,12 @@ public class ReviewStep1Request { @Max(value = 5, message = "별점은 5점 이하여야 합니다.") private Integer rating; - @NotBlank(message = "착용감 답변은 필수입니다.") + @NotNull(message = "착용감 답변은 필수입니다.") private SizeAnswer sizeAnswer; - @NotBlank(message = "색감 답변은 필수입니다.") + @NotNull(message = "색감 답변은 필수입니다.") private ColorAnswer colorAnswer; - @NotBlank(message = "소재 답변은 필수입니다.") + @NotNull(message = "소재 답변은 필수입니다.") private MaterialAnswer materialAnswer; } diff --git a/src/main/java/com/ongil/backend/domain/review/service/ReviewCommandService.java b/src/main/java/com/ongil/backend/domain/review/service/ReviewCommandService.java index 136178b..03998fb 100644 --- a/src/main/java/com/ongil/backend/domain/review/service/ReviewCommandService.java +++ b/src/main/java/com/ongil/backend/domain/review/service/ReviewCommandService.java @@ -5,6 +5,7 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import com.ongil.backend.domain.category.entity.Category; import com.ongil.backend.domain.order.entity.OrderItem; import com.ongil.backend.domain.order.repository.OrderItemRepository; import com.ongil.backend.domain.review.converter.ReviewWriteConverter; @@ -33,6 +34,8 @@ @Transactional public class ReviewCommandService { + private static final int REVIEW_REWARD_POINTS = 500; + private final ReviewRepository reviewRepository; private final UserRepository userRepository; private final OrderItemRepository orderItemRepository; @@ -48,7 +51,10 @@ public Long initializeReview(Long userId, Long orderItemId) { reviewValidator.validateReviewAuthority(orderItem, userId); reviewValidator.validateInitialReviewAlreadyExists(orderItemId); - String categoryName = orderItem.getProduct().getCategory().getParentCategory().getName(); + Category category = orderItem.getProduct().getCategory(); + String categoryName = (category.getParentCategory() != null) + ? category.getParentCategory().getName() + : category.getName(); ClothingCategory clothingCategory = ClothingCategory.fromDisplayName(categoryName); Review review = reviewWriteConverter.toInitialReviewEntity(user, orderItem, clothingCategory); @@ -139,16 +145,15 @@ public void submitReview(Long userId, Long reviewId, ReviewFinalSubmitRequest re String joinedImages = (request.getReviewImageUrls() != null && !request.getReviewImageUrls().isEmpty()) ? String.join(",", request.getReviewImageUrls()) : null; - int rewardAmount = 500; review.submit( request.getTextReview(), joinedImages, joinedSizeReview, joinedMaterialReview, - rewardAmount + REVIEW_REWARD_POINTS ); - user.restorePoints(rewardAmount); + user.restorePoints(REVIEW_REWARD_POINTS); } private Review getReviewOrThrow(Long reviewId) { diff --git a/src/main/java/com/ongil/backend/domain/review/service/prompter/SizeReviewPrompter.java b/src/main/java/com/ongil/backend/domain/review/service/prompter/SizeReviewPrompter.java index 3e50231..85fcf39 100644 --- a/src/main/java/com/ongil/backend/domain/review/service/prompter/SizeReviewPrompter.java +++ b/src/main/java/com/ongil/backend/domain/review/service/prompter/SizeReviewPrompter.java @@ -112,7 +112,7 @@ public String buildUserMessage(AiReviewGenerateRequest request) { prompt.append("\n- 위 리스트에 나열된 각 항목(예: 어깨&목, 전반적 등)마다 **반드시 2문장씩** 생성할 것."); if (request.getFitIssueParts().contains("전반적")) { - prompt.append("\n- '전반적' 항목에 대해서는 특정 신체 부위 언급 없이 전체적인 실루엣이나 조이는 느낌에 대해 2문장을 생성할 것."); + prompt.append("\n- '전반적' 항목에 대해서는 특정 신체 부위 언급 없이 전체적으로 조이거나 답답한 느낌에 대해 2문장을 생성할 것."); } prompt.append("\n- 결과적으로 총 ").append(request.getFitIssueParts().size() * 2).append("문장이 생성되어야 함."); diff --git a/src/main/java/com/ongil/backend/global/config/s3/S3ImageService.java b/src/main/java/com/ongil/backend/global/config/s3/S3ImageService.java index 373eb1e..d19b9f5 100644 --- a/src/main/java/com/ongil/backend/global/config/s3/S3ImageService.java +++ b/src/main/java/com/ongil/backend/global/config/s3/S3ImageService.java @@ -47,7 +47,7 @@ public String uploadReviewImage(MultipartFile file) { /** * 이미지를 S3에 업로드하고 공개 URL을 반환한다. */ - public String upload(MultipartFile file, String directory) { + private String upload(MultipartFile file, String directory) { validateFile(file); String extension = extractExtension(file.getOriginalFilename());