From 9fade18ae2d0ec63559f2c64750340eb2b44fe66 Mon Sep 17 00:00:00 2001 From: minsikk <131092169+1224kang@users.noreply.github.com> Date: Fri, 16 Jan 2026 14:04:18 +0900 Subject: [PATCH 01/18] =?UTF-8?q?feat:=20exception,ocrClient,ocrConfig,rev?= =?UTF-8?q?iewController=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/exception/ErrorCode.java | 10 +- .../NotPartnershipReceiptException.java | 9 ++ .../ReceiptFileConvertException.java | 9 ++ .../ReceiptImageFormatException.java | 9 ++ .../exception/ReceiptOcrFailedException.java | 9 ++ .../application/service/OcrService.java | 147 ++++++++++++++++++ .../review/infrastructure/ClovaOcrClient.java | 82 ++++++++++ .../review/presentation/ReviewController.java | 14 ++ .../presentation/ReviewResponseCode.java | 3 +- .../campus/global/config/ClovaOcrConfig.java | 14 ++ 10 files changed, 302 insertions(+), 4 deletions(-) create mode 100644 src/main/java/com/campus/campus/domain/review/application/exception/NotPartnershipReceiptException.java create mode 100644 src/main/java/com/campus/campus/domain/review/application/exception/ReceiptFileConvertException.java create mode 100644 src/main/java/com/campus/campus/domain/review/application/exception/ReceiptImageFormatException.java create mode 100644 src/main/java/com/campus/campus/domain/review/application/exception/ReceiptOcrFailedException.java create mode 100644 src/main/java/com/campus/campus/domain/review/application/service/OcrService.java create mode 100644 src/main/java/com/campus/campus/domain/review/infrastructure/ClovaOcrClient.java create mode 100644 src/main/java/com/campus/campus/global/config/ClovaOcrConfig.java diff --git a/src/main/java/com/campus/campus/domain/review/application/exception/ErrorCode.java b/src/main/java/com/campus/campus/domain/review/application/exception/ErrorCode.java index 88a52ff4..1c522342 100644 --- a/src/main/java/com/campus/campus/domain/review/application/exception/ErrorCode.java +++ b/src/main/java/com/campus/campus/domain/review/application/exception/ErrorCode.java @@ -11,11 +11,15 @@ @AllArgsConstructor public enum ErrorCode implements ErrorCodeInterface { - REVIEW_NOT_FOUND(2800, HttpStatus.NOT_FOUND, "리뷰를 찾을 수 없습니다."), - NOT_REVIEW_WRITER(2801, HttpStatus.FORBIDDEN, "작성자만 해당 작업을 수행할 수 있습니다."); + REVIEW_NOT_FOUND(2700, HttpStatus.NOT_FOUND, "리뷰글을 찾을 수 없습니다."), + NOT_REVIEW_WRITER(2701, HttpStatus.FORBIDDEN, "작성자만 해당 작업을 수행할 수 있습니다."), + RECEIPT_OCR_FAILED(2702, HttpStatus.UNPROCESSABLE_ENTITY, "OCR 인식에 실패하였습니다."), + RECEIPT_FILE_CONVERT_ERROR(2703, HttpStatus.UNPROCESSABLE_ENTITY, "영수증 FILE 형태 변형에 실패하였습니다."), + RECEIPT_FILE_TYPE_ERROR(2704, HttpStatus.UNPROCESSABLE_ENTITY, "지원하지 않는 이미지 형식입니다."), + NOT_PARTNERSHIP_RECEIPT_ERROR(2705, HttpStatus.UNPROCESSABLE_ENTITY, "영수증과 일치하는 제휴 정보를 찾을 수 없어요."); private final int code; private final HttpStatus status; private final String message; -} +} \ No newline at end of file diff --git a/src/main/java/com/campus/campus/domain/review/application/exception/NotPartnershipReceiptException.java b/src/main/java/com/campus/campus/domain/review/application/exception/NotPartnershipReceiptException.java new file mode 100644 index 00000000..e32e1882 --- /dev/null +++ b/src/main/java/com/campus/campus/domain/review/application/exception/NotPartnershipReceiptException.java @@ -0,0 +1,9 @@ +package com.campus.campus.domain.review.application.exception; + +import com.campus.campus.global.common.exception.ApplicationException; + +public class NotPartnershipReceiptException extends ApplicationException { + public NotPartnershipReceiptException() { + super(ErrorCode.NOT_PARTNERSHIP_RECEIPT_ERROR); + } +} \ No newline at end of file diff --git a/src/main/java/com/campus/campus/domain/review/application/exception/ReceiptFileConvertException.java b/src/main/java/com/campus/campus/domain/review/application/exception/ReceiptFileConvertException.java new file mode 100644 index 00000000..dd0d72c1 --- /dev/null +++ b/src/main/java/com/campus/campus/domain/review/application/exception/ReceiptFileConvertException.java @@ -0,0 +1,9 @@ +package com.campus.campus.domain.review.application.exception; + +import com.campus.campus.global.common.exception.ApplicationException; + +public class ReceiptFileConvertException extends ApplicationException { + public ReceiptFileConvertException() { + super(ErrorCode.RECEIPT_FILE_CONVERT_ERROR); + } +} \ No newline at end of file diff --git a/src/main/java/com/campus/campus/domain/review/application/exception/ReceiptImageFormatException.java b/src/main/java/com/campus/campus/domain/review/application/exception/ReceiptImageFormatException.java new file mode 100644 index 00000000..f357dda9 --- /dev/null +++ b/src/main/java/com/campus/campus/domain/review/application/exception/ReceiptImageFormatException.java @@ -0,0 +1,9 @@ +package com.campus.campus.domain.review.application.exception; + +import com.campus.campus.global.common.exception.ApplicationException; + +public class ReceiptImageFormatException extends ApplicationException { + public ReceiptImageFormatException() { + super(ErrorCode.RECEIPT_FILE_TYPE_ERROR); + } +} \ No newline at end of file diff --git a/src/main/java/com/campus/campus/domain/review/application/exception/ReceiptOcrFailedException.java b/src/main/java/com/campus/campus/domain/review/application/exception/ReceiptOcrFailedException.java new file mode 100644 index 00000000..b8a91368 --- /dev/null +++ b/src/main/java/com/campus/campus/domain/review/application/exception/ReceiptOcrFailedException.java @@ -0,0 +1,9 @@ +package com.campus.campus.domain.review.application.exception; + +import com.campus.campus.global.common.exception.ApplicationException; + +public class ReceiptOcrFailedException extends ApplicationException { + public ReceiptOcrFailedException() { + super(ErrorCode.RECEIPT_OCR_FAILED); + } +} \ No newline at end of file diff --git a/src/main/java/com/campus/campus/domain/review/application/service/OcrService.java b/src/main/java/com/campus/campus/domain/review/application/service/OcrService.java new file mode 100644 index 00000000..87a22a6f --- /dev/null +++ b/src/main/java/com/campus/campus/domain/review/application/service/OcrService.java @@ -0,0 +1,147 @@ +package com.campus.campus.domain.review.application.service; + +import java.io.IOException; +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.util.List; +import java.util.Optional; + +import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; + +import com.campus.campus.domain.place.application.dto.response.SavedPlaceInfo; +import com.campus.campus.domain.place.application.service.PlaceService; +import com.campus.campus.domain.place.domain.entity.Place; +import com.campus.campus.domain.review.application.dto.response.ReviewPartnerResponse; +import com.campus.campus.domain.review.application.dto.response.ocr.PaymentInfo; +import com.campus.campus.domain.review.application.dto.response.ocr.PriceInfo; +import com.campus.campus.domain.review.application.dto.response.ocr.ReceiptItemDto; +import com.campus.campus.domain.review.application.dto.response.ocr.ReceiptOcrResponse; +import com.campus.campus.domain.review.application.dto.response.ocr.ReceiptResultDto; +import com.campus.campus.domain.review.application.dto.response.ocr.ReceiptWrapper; +import com.campus.campus.domain.review.application.dto.response.ocr.StoreInfo; +import com.campus.campus.domain.review.application.dto.response.ocr.TextField; +import com.campus.campus.domain.review.application.dto.response.ocr.TotalPrice; +import com.campus.campus.domain.review.application.exception.ReceiptFileConvertException; +import com.campus.campus.domain.review.application.exception.ReceiptOcrFailedException; +import com.campus.campus.domain.review.infrastructure.ocr.ClovaOcrClient; +import com.fasterxml.jackson.databind.ObjectMapper; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Service +@Slf4j +@RequiredArgsConstructor +public class OcrService { + + private final ClovaOcrClient clovaOcrClient; + private final ObjectMapper objectMapper; + private final ReviewService reviewService; + private final PlaceService placeService; + + public ReviewPartnerResponse processReceipt(MultipartFile file, Long userId, SavedPlaceInfo placeInfo) { + Place place = placeService.findOrCreatePlace(placeInfo); + Long placeId = place.getPlaceId(); + + //MultipartFIle -> byte[] + byte[] imageBytes; + try { + imageBytes = file.getBytes(); + } catch (IOException e) { + throw new ReceiptFileConvertException(); + } + + //ocr + String rawResponse = clovaOcrClient.requestReceiptOcr(imageBytes, file.getOriginalFilename()); + log.info("[OCR RAW RESPONSE] {}", rawResponse); + + ReceiptOcrResponse ocrResponse = parse(rawResponse); + ReceiptResultDto result = extractReceiptResult(ocrResponse); + log.info("영수증 ocr 인식 결과:{}", result); + + return reviewService.findPartnership(placeId, result, userId); + } + + private ReceiptOcrResponse parse(String json) { + try { + log.debug("[OCR PARSE INPUT] {}", json); + return objectMapper.readValue(json, ReceiptOcrResponse.class); + } catch (Exception e) { + log.error("[OCR PARSE FAILED] raw={}", json, e); + throw new ReceiptOcrFailedException(); + } + } + + private ReceiptResultDto extractReceiptResult(ReceiptOcrResponse response) { + log.info("[OCR RESPONSE] images size={}", + response.images() != null ? response.images().size() : null); + //images 존재 검증 + var image = response.images().stream() + .findFirst() + .orElseThrow(() -> { + log.warn("[OCR FAILED] images empty"); + return new ReceiptOcrFailedException(); + }); + + var receipt = Optional.ofNullable(image.receipt()) + .map(ReceiptWrapper::result) + .orElseThrow(() -> { + log.warn("[OCR FAILED] receipt.result is null"); + return new ReceiptOcrFailedException(); + }); + + //상호명 + String storeName = Optional.ofNullable(receipt.storeInfo()) + .map(StoreInfo::name) + .map(TextField::text) + .orElseThrow(() -> { + log.warn("[OCR FAILED] storeName missing"); + return new ReceiptOcrFailedException(); + }); + + //총액 + String totalPrice = Optional.ofNullable(receipt.totalPrice()) + .map(TotalPrice::price) + .map(TextField::text) + .orElseThrow(() -> { + log.warn("[OCR FAILED] totalPrice missing, paymentInfo={}", + receipt.paymentInfo()); + return new ReceiptOcrFailedException(); + }); + + //결제일 + LocalDate paymentDate = Optional.ofNullable(receipt.paymentInfo()) + .map(PaymentInfo::date) + .map(TextField::text) + .map(text -> LocalDate.parse(text, DateTimeFormatter.BASIC_ISO_DATE)) + .orElse(null); + + //상품 목록 + List items = Optional.ofNullable(receipt.subResults()) + .orElse(List.of()) + .stream() + .flatMap(sr -> Optional.ofNullable(sr.items()).orElse(List.of()).stream()) + .map(i -> new ReceiptItemDto( + safeText(i.name()), + Optional.ofNullable(i.price()) + .map(PriceInfo::price) + .map(TextField::text) + .orElse(null) + )) + + .toList(); + log.info("[OCR ITEMS] count={}", items.size()); + + return new ReceiptResultDto( + storeName, + totalPrice, + paymentDate, + items + ); + } + + private String safeText(TextField field) { + return field != null ? field.text() : null; + } +} \ No newline at end of file diff --git a/src/main/java/com/campus/campus/domain/review/infrastructure/ClovaOcrClient.java b/src/main/java/com/campus/campus/domain/review/infrastructure/ClovaOcrClient.java new file mode 100644 index 00000000..fa7d7781 --- /dev/null +++ b/src/main/java/com/campus/campus/domain/review/infrastructure/ClovaOcrClient.java @@ -0,0 +1,82 @@ +package com.campus.campus.domain.review.infrastructure.ocr; + +import java.util.Base64; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestTemplate; + +import com.campus.campus.domain.review.application.exception.ReceiptImageFormatException; + +import lombok.RequiredArgsConstructor; + +@Component +@RequiredArgsConstructor +public class ClovaOcrClient { + + private final RestTemplate restTemplate; + + @Value("${clova.ocr.invoke-url}") + private String invokeUrl; + + @Value("${clova.ocr.secret-key}") + private String secretKey; + + public String requestReceiptOcr(byte[] imageBytes, String originalFilename) { + //이미지->Base64 + String base64Image = Base64.getEncoder().encodeToString(imageBytes); + extractFormat(originalFilename); + + //json body 구성 + Map image = new HashMap<>(); + image.put("format", "png"); + image.put("data", base64Image); + image.put("name", "receipt_test2"); + + //message 파트 + Map body = new HashMap<>(); + body.put("version", "V2"); + body.put("requestId", UUID.randomUUID().toString()); + body.put("timestamp", System.currentTimeMillis()); + body.put("images", List.of(image)); + + //header + HttpHeaders headers = new HttpHeaders(); + headers.set("X-OCR-SECRET", secretKey); + headers.setContentType(MediaType.APPLICATION_JSON); + + HttpEntity> request = new HttpEntity<>(body, headers); + + //호출 + ResponseEntity response = + restTemplate.postForEntity(invokeUrl, request, String.class); + return response.getBody(); + } + + private String extractFormat(String originalFilename) { + if (originalFilename == null) { + throw new ReceiptImageFormatException(); + } + + String lower = originalFilename.toLowerCase(); + + if (lower.endsWith(".jpg") || lower.endsWith(".jpeg")) { + return "jpg"; + } + + if (lower.endsWith(".png")) { + return "png"; + } + + throw new ReceiptImageFormatException(); + } + +} \ No newline at end of file diff --git a/src/main/java/com/campus/campus/domain/review/presentation/ReviewController.java b/src/main/java/com/campus/campus/domain/review/presentation/ReviewController.java index 6794ce85..cdbd40ae 100644 --- a/src/main/java/com/campus/campus/domain/review/presentation/ReviewController.java +++ b/src/main/java/com/campus/campus/domain/review/presentation/ReviewController.java @@ -12,8 +12,11 @@ import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RequestPart; import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; +import com.campus.campus.domain.place.application.dto.response.SavedPlaceInfo; import com.campus.campus.domain.review.application.dto.request.ReviewRequest; import com.campus.campus.domain.review.application.dto.response.CursorPageReviewResponse; import com.campus.campus.domain.review.application.dto.response.PlaceReviewRankResponse; @@ -82,6 +85,17 @@ public CommonResponse writeReview( return CommonResponse.success(ReviewResponseCode.REVIEW_SAVE_SUCCESS, response); } + @PostMapping("/receipt-ocr") + @Operation(summary = "영수증 ocr을 통해 제휴 매장 이용 인증") + public CommonResponse upload( + @RequestPart MultipartFile file, + @Valid @RequestBody SavedPlaceInfo placeInfo, + @CurrentUserId Long userId + ) { + return CommonResponse.success(ReviewResponseCode.OCR_SUCCESS, + ocrService.processReceipt(file, userId, placeInfo)); + } + @GetMapping("/{reviewId}") @Operation(summary = "리뷰 상세 조회") public CommonResponse readReview(@PathVariable Long reviewId) { diff --git a/src/main/java/com/campus/campus/domain/review/presentation/ReviewResponseCode.java b/src/main/java/com/campus/campus/domain/review/presentation/ReviewResponseCode.java index d18dbfed..3d2a7db1 100644 --- a/src/main/java/com/campus/campus/domain/review/presentation/ReviewResponseCode.java +++ b/src/main/java/com/campus/campus/domain/review/presentation/ReviewResponseCode.java @@ -16,7 +16,8 @@ public enum ReviewResponseCode implements ResponseCodeInterface { REVIEW_UPDATE_SUCCESS(200, HttpStatus.OK, "리뷰 수정이 완료되었습니다."), GET_REVIEW_LIST_SUCCESS(200, HttpStatus.OK, "리뷰 리스트 조회에 성공하였습니다."), GET_RANK_SUCCESS(200, HttpStatus.OK, "최근 한달 리뷰 순에 따른 조회 가게 조회에 성공하였습니다."), - GET_REVIEW_SUCCESS(200, HttpStatus.OK, "리뷰 상세 조회에 성공하였습니다."); + GET_REVIEW_SUCCESS(200, HttpStatus.OK, "리뷰 상세 조회에 성공하였습니다."), + OCR_SUCCESS(200, HttpStatus.OK, "OCR 인식에 성공하였습니다."); private final int code; private final HttpStatus status; diff --git a/src/main/java/com/campus/campus/global/config/ClovaOcrConfig.java b/src/main/java/com/campus/campus/global/config/ClovaOcrConfig.java new file mode 100644 index 00000000..cda349d3 --- /dev/null +++ b/src/main/java/com/campus/campus/global/config/ClovaOcrConfig.java @@ -0,0 +1,14 @@ +package com.campus.campus.global.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.client.RestTemplate; + +@Configuration +public class ClovaOcrConfig { + + @Bean + public RestTemplate clovaOcrRestTemplate() { + return new RestTemplate(); + } +} \ No newline at end of file From 136b7361ceaa6b6ded9278572db154017b1ae5b2 Mon Sep 17 00:00:00 2001 From: minsikk <131092169+1224kang@users.noreply.github.com> Date: Fri, 16 Jan 2026 14:14:36 +0900 Subject: [PATCH 02/18] =?UTF-8?q?feat:=20DTO=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/dto/response/PlaceStarAvgRow.java | 7 +++++++ .../dto/response/ReviewPartnerResponse.java | 10 ++++++++++ .../dto/response/SimpleReviewResponse.java | 12 ++++++++++++ .../application/dto/response/ocr/ImageResult.java | 6 ++++++ .../application/dto/response/ocr/PaymentInfo.java | 7 +++++++ .../application/dto/response/ocr/PriceInfo.java | 7 +++++++ .../dto/response/ocr/ReceiptItemDto.java | 7 +++++++ .../dto/response/ocr/ReceiptOcrItem.java | 7 +++++++ .../dto/response/ocr/ReceiptOcrResponse.java | 9 +++++++++ .../application/dto/response/ocr/ReceiptResult.java | 11 +++++++++++ .../dto/response/ocr/ReceiptResultDto.java | 12 ++++++++++++ .../dto/response/ocr/ReceiptWrapper.java | 6 ++++++ .../application/dto/response/ocr/StoreInfo.java | 6 ++++++ .../application/dto/response/ocr/SubResult.java | 8 ++++++++ .../application/dto/response/ocr/TextField.java | 13 +++++++++++++ .../application/dto/response/ocr/TotalPrice.java | 6 ++++++ .../review/application/mapper/ReviewMapper.java | 9 +++++++++ 17 files changed, 143 insertions(+) create mode 100644 src/main/java/com/campus/campus/domain/review/application/dto/response/PlaceStarAvgRow.java create mode 100644 src/main/java/com/campus/campus/domain/review/application/dto/response/ReviewPartnerResponse.java create mode 100644 src/main/java/com/campus/campus/domain/review/application/dto/response/SimpleReviewResponse.java create mode 100644 src/main/java/com/campus/campus/domain/review/application/dto/response/ocr/ImageResult.java create mode 100644 src/main/java/com/campus/campus/domain/review/application/dto/response/ocr/PaymentInfo.java create mode 100644 src/main/java/com/campus/campus/domain/review/application/dto/response/ocr/PriceInfo.java create mode 100644 src/main/java/com/campus/campus/domain/review/application/dto/response/ocr/ReceiptItemDto.java create mode 100644 src/main/java/com/campus/campus/domain/review/application/dto/response/ocr/ReceiptOcrItem.java create mode 100644 src/main/java/com/campus/campus/domain/review/application/dto/response/ocr/ReceiptOcrResponse.java create mode 100644 src/main/java/com/campus/campus/domain/review/application/dto/response/ocr/ReceiptResult.java create mode 100644 src/main/java/com/campus/campus/domain/review/application/dto/response/ocr/ReceiptResultDto.java create mode 100644 src/main/java/com/campus/campus/domain/review/application/dto/response/ocr/ReceiptWrapper.java create mode 100644 src/main/java/com/campus/campus/domain/review/application/dto/response/ocr/StoreInfo.java create mode 100644 src/main/java/com/campus/campus/domain/review/application/dto/response/ocr/SubResult.java create mode 100644 src/main/java/com/campus/campus/domain/review/application/dto/response/ocr/TextField.java create mode 100644 src/main/java/com/campus/campus/domain/review/application/dto/response/ocr/TotalPrice.java diff --git a/src/main/java/com/campus/campus/domain/review/application/dto/response/PlaceStarAvgRow.java b/src/main/java/com/campus/campus/domain/review/application/dto/response/PlaceStarAvgRow.java new file mode 100644 index 00000000..c6e6a957 --- /dev/null +++ b/src/main/java/com/campus/campus/domain/review/application/dto/response/PlaceStarAvgRow.java @@ -0,0 +1,7 @@ +package com.campus.campus.domain.review.application.dto.response; + +public record PlaceStarAvgRow( + Long placeId, + Double avgStar +) { +} \ No newline at end of file diff --git a/src/main/java/com/campus/campus/domain/review/application/dto/response/ReviewPartnerResponse.java b/src/main/java/com/campus/campus/domain/review/application/dto/response/ReviewPartnerResponse.java new file mode 100644 index 00000000..97a28384 --- /dev/null +++ b/src/main/java/com/campus/campus/domain/review/application/dto/response/ReviewPartnerResponse.java @@ -0,0 +1,10 @@ +package com.campus.campus.domain.review.application.dto.response; + +public record ReviewPartnerResponse( + String placeName, + String placeCategory, + String council, + double star, + String title +) { +} diff --git a/src/main/java/com/campus/campus/domain/review/application/dto/response/SimpleReviewResponse.java b/src/main/java/com/campus/campus/domain/review/application/dto/response/SimpleReviewResponse.java new file mode 100644 index 00000000..f8416e5d --- /dev/null +++ b/src/main/java/com/campus/campus/domain/review/application/dto/response/SimpleReviewResponse.java @@ -0,0 +1,12 @@ +package com.campus.campus.domain.review.application.dto.response; + +import lombok.Builder; + +@Builder +public record SimpleReviewResponse( + double star, + String writerName, + String content, + String thumbnailImgUrl +) { +} \ No newline at end of file diff --git a/src/main/java/com/campus/campus/domain/review/application/dto/response/ocr/ImageResult.java b/src/main/java/com/campus/campus/domain/review/application/dto/response/ocr/ImageResult.java new file mode 100644 index 00000000..513570e3 --- /dev/null +++ b/src/main/java/com/campus/campus/domain/review/application/dto/response/ocr/ImageResult.java @@ -0,0 +1,6 @@ +package com.campus.campus.domain.review.application.dto.response.ocr; + +public record ImageResult( + ReceiptWrapper receipt +) { +} \ No newline at end of file diff --git a/src/main/java/com/campus/campus/domain/review/application/dto/response/ocr/PaymentInfo.java b/src/main/java/com/campus/campus/domain/review/application/dto/response/ocr/PaymentInfo.java new file mode 100644 index 00000000..9761c16c --- /dev/null +++ b/src/main/java/com/campus/campus/domain/review/application/dto/response/ocr/PaymentInfo.java @@ -0,0 +1,7 @@ +package com.campus.campus.domain.review.application.dto.response.ocr; + +public record PaymentInfo( + TextField date, + TotalPrice totalPrice +) { +} \ No newline at end of file diff --git a/src/main/java/com/campus/campus/domain/review/application/dto/response/ocr/PriceInfo.java b/src/main/java/com/campus/campus/domain/review/application/dto/response/ocr/PriceInfo.java new file mode 100644 index 00000000..398d18a2 --- /dev/null +++ b/src/main/java/com/campus/campus/domain/review/application/dto/response/ocr/PriceInfo.java @@ -0,0 +1,7 @@ +package com.campus.campus.domain.review.application.dto.response.ocr; + +public record PriceInfo( + TextField price, + TextField unitPrice +) { +} \ No newline at end of file diff --git a/src/main/java/com/campus/campus/domain/review/application/dto/response/ocr/ReceiptItemDto.java b/src/main/java/com/campus/campus/domain/review/application/dto/response/ocr/ReceiptItemDto.java new file mode 100644 index 00000000..5066eb2f --- /dev/null +++ b/src/main/java/com/campus/campus/domain/review/application/dto/response/ocr/ReceiptItemDto.java @@ -0,0 +1,7 @@ +package com.campus.campus.domain.review.application.dto.response.ocr; + +public record ReceiptItemDto( + String name, + String price +) { +} \ No newline at end of file diff --git a/src/main/java/com/campus/campus/domain/review/application/dto/response/ocr/ReceiptOcrItem.java b/src/main/java/com/campus/campus/domain/review/application/dto/response/ocr/ReceiptOcrItem.java new file mode 100644 index 00000000..86f4f1db --- /dev/null +++ b/src/main/java/com/campus/campus/domain/review/application/dto/response/ocr/ReceiptOcrItem.java @@ -0,0 +1,7 @@ +package com.campus.campus.domain.review.application.dto.response.ocr; + +public record ReceiptOcrItem( + TextField name, + PriceInfo price +) { +} \ No newline at end of file diff --git a/src/main/java/com/campus/campus/domain/review/application/dto/response/ocr/ReceiptOcrResponse.java b/src/main/java/com/campus/campus/domain/review/application/dto/response/ocr/ReceiptOcrResponse.java new file mode 100644 index 00000000..e0018152 --- /dev/null +++ b/src/main/java/com/campus/campus/domain/review/application/dto/response/ocr/ReceiptOcrResponse.java @@ -0,0 +1,9 @@ +package com.campus.campus.domain.review.application.dto.response.ocr; + +import java.util.List; + +public record ReceiptOcrResponse( + List images +) { +} + diff --git a/src/main/java/com/campus/campus/domain/review/application/dto/response/ocr/ReceiptResult.java b/src/main/java/com/campus/campus/domain/review/application/dto/response/ocr/ReceiptResult.java new file mode 100644 index 00000000..ae6cd6cb --- /dev/null +++ b/src/main/java/com/campus/campus/domain/review/application/dto/response/ocr/ReceiptResult.java @@ -0,0 +1,11 @@ +package com.campus.campus.domain.review.application.dto.response.ocr; + +import java.util.List; + +public record ReceiptResult( + StoreInfo storeInfo, + PaymentInfo paymentInfo, + TotalPrice totalPrice, + List subResults +) { +} \ No newline at end of file diff --git a/src/main/java/com/campus/campus/domain/review/application/dto/response/ocr/ReceiptResultDto.java b/src/main/java/com/campus/campus/domain/review/application/dto/response/ocr/ReceiptResultDto.java new file mode 100644 index 00000000..48107aef --- /dev/null +++ b/src/main/java/com/campus/campus/domain/review/application/dto/response/ocr/ReceiptResultDto.java @@ -0,0 +1,12 @@ +package com.campus.campus.domain.review.application.dto.response.ocr; + +import java.time.LocalDate; +import java.util.List; + +public record ReceiptResultDto( + String storeName, + String totalPlace, + LocalDate paymentDate, + List items +) { +} \ No newline at end of file diff --git a/src/main/java/com/campus/campus/domain/review/application/dto/response/ocr/ReceiptWrapper.java b/src/main/java/com/campus/campus/domain/review/application/dto/response/ocr/ReceiptWrapper.java new file mode 100644 index 00000000..b1ee4213 --- /dev/null +++ b/src/main/java/com/campus/campus/domain/review/application/dto/response/ocr/ReceiptWrapper.java @@ -0,0 +1,6 @@ +package com.campus.campus.domain.review.application.dto.response.ocr; + +public record ReceiptWrapper( + ReceiptResult result +) { +} \ No newline at end of file diff --git a/src/main/java/com/campus/campus/domain/review/application/dto/response/ocr/StoreInfo.java b/src/main/java/com/campus/campus/domain/review/application/dto/response/ocr/StoreInfo.java new file mode 100644 index 00000000..0d6e78d7 --- /dev/null +++ b/src/main/java/com/campus/campus/domain/review/application/dto/response/ocr/StoreInfo.java @@ -0,0 +1,6 @@ +package com.campus.campus.domain.review.application.dto.response.ocr; + +public record StoreInfo( + TextField name +) { +} \ No newline at end of file diff --git a/src/main/java/com/campus/campus/domain/review/application/dto/response/ocr/SubResult.java b/src/main/java/com/campus/campus/domain/review/application/dto/response/ocr/SubResult.java new file mode 100644 index 00000000..941cf0c5 --- /dev/null +++ b/src/main/java/com/campus/campus/domain/review/application/dto/response/ocr/SubResult.java @@ -0,0 +1,8 @@ +package com.campus.campus.domain.review.application.dto.response.ocr; + +import java.util.List; + +public record SubResult( + List items +) { +} \ No newline at end of file diff --git a/src/main/java/com/campus/campus/domain/review/application/dto/response/ocr/TextField.java b/src/main/java/com/campus/campus/domain/review/application/dto/response/ocr/TextField.java new file mode 100644 index 00000000..bffc5292 --- /dev/null +++ b/src/main/java/com/campus/campus/domain/review/application/dto/response/ocr/TextField.java @@ -0,0 +1,13 @@ +package com.campus.campus.domain.review.application.dto.response.ocr; + +public record TextField( + String text, + Formatted formatted, + Double confidenceScore +) { + public record Formatted( + String value + ) { + + } +} \ No newline at end of file diff --git a/src/main/java/com/campus/campus/domain/review/application/dto/response/ocr/TotalPrice.java b/src/main/java/com/campus/campus/domain/review/application/dto/response/ocr/TotalPrice.java new file mode 100644 index 00000000..29c6868e --- /dev/null +++ b/src/main/java/com/campus/campus/domain/review/application/dto/response/ocr/TotalPrice.java @@ -0,0 +1,6 @@ +package com.campus.campus.domain.review.application.dto.response.ocr; + +public record TotalPrice( + TextField price +) { +} \ No newline at end of file diff --git a/src/main/java/com/campus/campus/domain/review/application/mapper/ReviewMapper.java b/src/main/java/com/campus/campus/domain/review/application/mapper/ReviewMapper.java index 2212d316..71c7e618 100644 --- a/src/main/java/com/campus/campus/domain/review/application/mapper/ReviewMapper.java +++ b/src/main/java/com/campus/campus/domain/review/application/mapper/ReviewMapper.java @@ -44,6 +44,15 @@ public CursorPageReviewResponse toEmptyCursorReviewResponse() { .build(); } + public SimpleReviewResponse toSimpleReviewResponse(Review review, String imageUrl) { + return SimpleReviewResponse.builder() + .star(review.getStar()) + .writerName(review.getUser().getNickname()) + .content(review.getContent()) + .thumbnailImgUrl(imageUrl) + .build(); + } + public ReviewImage createReviewImage(Review review, String imageUrl) { return ReviewImage.builder() .review(review) From 89c0a53c63548f7eb1b53d0f872d2976b8dbd20b Mon Sep 17 00:00:00 2001 From: minsikk <131092169+1224kang@users.noreply.github.com> Date: Fri, 16 Jan 2026 14:55:31 +0900 Subject: [PATCH 03/18] =?UTF-8?q?feat:=20=EC=9E=AC=C3=AA=C2=B5=C3=AC=C2=83?= =?UTF-8?q?=EC=9E=A5=EC=86=8C=20=EC=83=81=EC=84=B8=20=EC=A1=B0=ED=9A=8C,?= =?UTF-8?q?=20=EC=A0=9C=ED=9C=B4=20=EC=9A=94=EC=B2=AD=C3=AA=C2=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../repository/StudentCouncilRepository.java | 6 + .../StudentCouncilPostRepository.java | 77 +++++++++ .../dto/response/PlaceDetailResponse.java | 24 +++ .../dto/response/PlaceDetailView.java | 24 +++ ...se.java => PartnershipDetailResponse.java} | 53 +++--- .../AlreadySuggestedPartnershipException.java | 9 + .../application/exception/ErrorCode.java | 3 +- .../place/application/mapper/PlaceMapper.java | 53 +++++- .../service/PartnershipPlaceService.java | 157 ++++++++++++++++-- .../application/service/PlaceService.java | 71 +++++++- .../entity/CouncilPartnershipSuggestion.java | 59 +++++++ .../entity/UserPartnershipSuggestion.java | 47 ++++++ ...ouncilPartnershipSuggestionRepository.java | 14 ++ .../repository/PlaceImagesRepository.java | 11 ++ .../UserPartnershipSuggestionRepository.java | 12 ++ .../place/presentation/PlaceController.java | 46 ++++- .../place/presentation/PlaceResponseCode.java | 4 +- .../application/mapper/ReviewMapper.java | 1 + .../application/service/OcrService.java | 2 +- .../application/service/ReviewService.java | 107 ++++++++++++ .../domain/review/domain/entity/Review.java | 5 +- .../domain/repository/ReviewRepository.java | 19 +++ .../review/infrastructure/ClovaOcrClient.java | 2 +- .../review/presentation/ReviewController.java | 3 + src/main/resources/application-local.yml | 7 +- 25 files changed, 755 insertions(+), 61 deletions(-) create mode 100644 src/main/java/com/campus/campus/domain/place/application/dto/response/PlaceDetailResponse.java create mode 100644 src/main/java/com/campus/campus/domain/place/application/dto/response/PlaceDetailView.java rename src/main/java/com/campus/campus/domain/place/application/dto/response/partnership/{PartnershipResponse.java => PartnershipDetailResponse.java} (58%) create mode 100644 src/main/java/com/campus/campus/domain/place/application/exception/AlreadySuggestedPartnershipException.java create mode 100644 src/main/java/com/campus/campus/domain/place/domain/entity/CouncilPartnershipSuggestion.java create mode 100644 src/main/java/com/campus/campus/domain/place/domain/entity/UserPartnershipSuggestion.java create mode 100644 src/main/java/com/campus/campus/domain/place/domain/repository/CouncilPartnershipSuggestionRepository.java create mode 100644 src/main/java/com/campus/campus/domain/place/domain/repository/UserPartnershipSuggestionRepository.java diff --git a/src/main/java/com/campus/campus/domain/council/domain/repository/StudentCouncilRepository.java b/src/main/java/com/campus/campus/domain/council/domain/repository/StudentCouncilRepository.java index 9db42ddf..f4cc6481 100644 --- a/src/main/java/com/campus/campus/domain/council/domain/repository/StudentCouncilRepository.java +++ b/src/main/java/com/campus/campus/domain/council/domain/repository/StudentCouncilRepository.java @@ -44,4 +44,10 @@ Optional findByIdWithDetailsAndManagerApprovedIsTrueAndDeletedAt boolean existsByEmailAndDeletedAtIsNotNull(String email); boolean existsByLoginIdAndManagerApprovedIsTrueAndDeletedAtIsNull(String loginId); + + Optional findByMajor_MajorId(Long majorId); + + Optional findByCollege_CollegeId(Long collegeId); + + Optional findBySchool_SchoolId(Long schoolId); } diff --git a/src/main/java/com/campus/campus/domain/councilpost/domain/repository/StudentCouncilPostRepository.java b/src/main/java/com/campus/campus/domain/councilpost/domain/repository/StudentCouncilPostRepository.java index 0783ad3a..56d35e56 100644 --- a/src/main/java/com/campus/campus/domain/councilpost/domain/repository/StudentCouncilPostRepository.java +++ b/src/main/java/com/campus/campus/domain/councilpost/domain/repository/StudentCouncilPostRepository.java @@ -15,6 +15,7 @@ import com.campus.campus.domain.councilpost.domain.entity.PostCategory; import com.campus.campus.domain.councilpost.domain.entity.StudentCouncilPost; import com.campus.campus.domain.councilpost.domain.entity.ThumbnailIcon; +import com.campus.campus.domain.place.domain.entity.Place; public interface StudentCouncilPostRepository extends JpaRepository { @@ -314,6 +315,33 @@ List findTop3RecommendedPartnershipPlaces( Pageable pageable ); + @Query(""" + SELECT p + FROM StudentCouncilPost p + JOIN p.writer w + LEFT JOIN w.major m + LEFT JOIN w.college c + LEFT JOIN w.school s + WHERE p.place.placeId = :placeId + AND p.startDateTime <= :paymentDate + AND p.endDateTime >= :paymentDate + AND ( + (w.councilType =:majorType AND m.majorId = :majorId) + OR (w.councilType =:collegeType AND c.collegeId = :collegeId) + OR (w.councilType =:schoolType AND s.schoolId = :schoolId) + ) + """) + Optional findValidPartnershipForUserScope( + @Param("placeId") Long placeId, + @Param("paymentDate") LocalDateTime paymentDate, + @Param("majorId") Long majorId, + @Param("collegeId") Long collegeId, + @Param("schoolId") Long schoolId, + @Param("majorType") CouncilType majorType, + @Param("collegeType") CouncilType collegeType, + @Param("schoolType") CouncilType schoolType + ); + @EntityGraph(attributePaths = {"writer", "writer.school", "writer.college", "writer.major", "place"}) @Query(""" SELECT p FROM StudentCouncilPost p @@ -383,4 +411,53 @@ List findTodayEvent( @Param("endOfDay") LocalDateTime endOfDay, Pageable pageable ); + + @Query(""" + SELECT COUNT(p) > 0 + FROM StudentCouncilPost p + JOIN p.writer w + WHERE p.place = :place + AND p.startDateTime <= :now + AND p.endDateTime >= :now + AND ( + (w.councilType = :majorType AND w.major.majorId = :majorId) + OR (w.councilType = :collegeType AND w.college.collegeId = :collegeId) + OR (w.councilType = :schoolType AND w.school.schoolId = :schoolId) + ) + """) + boolean existsActiveByPlaceAndUserScope( + @Param("place") Place place, + @Param("now") LocalDateTime now, + @Param("majorType") CouncilType majorType, + @Param("majorId") Long majorId, + @Param("collegeType") CouncilType collegeType, + @Param("collegeId") Long collegeId, + @Param("schoolType") CouncilType schoolType, + @Param("schoolId") Long schoolId + ); + + @Query(""" + SELECT p + FROM StudentCouncilPost p + JOIN p.writer w + WHERE p.place = :place + AND p.startDateTime <= :now + AND p.endDateTime >= :now + AND ( + (w.councilType = :majorType AND w.major.majorId = :majorId) + OR (w.councilType = :collegeType AND w.college.collegeId = :collegeId) + OR (w.councilType = :schoolType AND w.school.schoolId = :schoolId) + ) + ORDER BY p.endDateTime DESC + """) + Optional findActiveByPlaceAndUserScope( + @Param("place") Place place, + @Param("now") LocalDateTime now, + @Param("majorType") CouncilType majorType, + @Param("majorId") Long majorId, + @Param("collegeType") CouncilType collegeType, + @Param("collegeId") Long collegeId, + @Param("schoolType") CouncilType schoolType, + @Param("schoolId") Long schoolId + ); } diff --git a/src/main/java/com/campus/campus/domain/place/application/dto/response/PlaceDetailResponse.java b/src/main/java/com/campus/campus/domain/place/application/dto/response/PlaceDetailResponse.java new file mode 100644 index 00000000..403a2dd2 --- /dev/null +++ b/src/main/java/com/campus/campus/domain/place/application/dto/response/PlaceDetailResponse.java @@ -0,0 +1,24 @@ +package com.campus.campus.domain.place.application.dto.response; + +import java.util.List; + +import com.campus.campus.domain.review.application.dto.response.SimpleReviewResponse; + +public record PlaceDetailResponse( + Long placeId, + String placeKey, + String name, + String category, + String address, + Double latitude, + Double longitude, + boolean isLiked, + double star, + double distance, + + //PlaceImg 이미지 받아오기 + List imgUrls, + List reviews, + int reviewSize +) implements PlaceDetailView { +} \ No newline at end of file diff --git a/src/main/java/com/campus/campus/domain/place/application/dto/response/PlaceDetailView.java b/src/main/java/com/campus/campus/domain/place/application/dto/response/PlaceDetailView.java new file mode 100644 index 00000000..e9a5ac9c --- /dev/null +++ b/src/main/java/com/campus/campus/domain/place/application/dto/response/PlaceDetailView.java @@ -0,0 +1,24 @@ +package com.campus.campus.domain.place.application.dto.response; + +public interface PlaceDetailView { + + Long placeId(); + + String placeKey(); + + String name(); + + String category(); + + String address(); + + Double latitude(); + + Double longitude(); + + boolean isLiked(); + + double star(); + + double distance(); +} \ No newline at end of file diff --git a/src/main/java/com/campus/campus/domain/place/application/dto/response/partnership/PartnershipResponse.java b/src/main/java/com/campus/campus/domain/place/application/dto/response/partnership/PartnershipDetailResponse.java similarity index 58% rename from src/main/java/com/campus/campus/domain/place/application/dto/response/partnership/PartnershipResponse.java rename to src/main/java/com/campus/campus/domain/place/application/dto/response/partnership/PartnershipDetailResponse.java index 0fd54235..6f48c969 100644 --- a/src/main/java/com/campus/campus/domain/place/application/dto/response/partnership/PartnershipResponse.java +++ b/src/main/java/com/campus/campus/domain/place/application/dto/response/partnership/PartnershipDetailResponse.java @@ -1,24 +1,29 @@ -package com.campus.campus.domain.place.application.dto.response.partnership; - -import java.time.LocalDate; -import java.util.List; - -public record PartnershipResponse( - Long placeId, - String placeKey, - String name, - String category, - String address, - Double latitude, - Double longitude, - String tag, //(ex.) 총학생회, 사회과학대학, IT공학과 - boolean isLiked, - Double star, //리뷰 평점 - String partnerTitle, //제휴 제목 - double distance, //거리(m) - LocalDate endDate, //제휴 끝나는 시점 - - //StudentCouncilPost 이미지 받아오기 - List imgUrls -) { -} +package com.campus.campus.domain.place.application.dto.response.partnership; + +import java.time.LocalDate; +import java.util.List; + +import com.campus.campus.domain.place.application.dto.response.PlaceDetailView; +import com.campus.campus.domain.review.application.dto.response.SimpleReviewResponse; + +public record PartnershipDetailResponse( + Long placeId, + String placeKey, + String name, + String category, + String address, + Double latitude, + Double longitude, + String tag, //(ex.) 총학생회, 사회과학대학, IT공학과 + boolean isLiked, + double star, //리뷰 평점 + String partnerTitle, //제휴 제목 + double distance, //거리(m) + LocalDate endDate, //제휴 끝나는 시점 + + //StudentCouncilPost 이미지 받아오기 + List imgUrls, + List reviews, + int reviewSize +) implements PlaceDetailView { +} diff --git a/src/main/java/com/campus/campus/domain/place/application/exception/AlreadySuggestedPartnershipException.java b/src/main/java/com/campus/campus/domain/place/application/exception/AlreadySuggestedPartnershipException.java new file mode 100644 index 00000000..6486e3f7 --- /dev/null +++ b/src/main/java/com/campus/campus/domain/place/application/exception/AlreadySuggestedPartnershipException.java @@ -0,0 +1,9 @@ +package com.campus.campus.domain.place.application.exception; + +import com.campus.campus.global.common.exception.ApplicationException; + +public class AlreadySuggestedPartnershipException extends ApplicationException { + public AlreadySuggestedPartnershipException() { + super(ErrorCode.ALREADY_PARTNERSHIP_SUGGESTED); + } +} \ No newline at end of file diff --git a/src/main/java/com/campus/campus/domain/place/application/exception/ErrorCode.java b/src/main/java/com/campus/campus/domain/place/application/exception/ErrorCode.java index a55734ea..a97848ff 100644 --- a/src/main/java/com/campus/campus/domain/place/application/exception/ErrorCode.java +++ b/src/main/java/com/campus/campus/domain/place/application/exception/ErrorCode.java @@ -16,7 +16,8 @@ public enum ErrorCode implements ErrorCodeInterface { SHA256_NOT_SUPPORTED(2603, HttpStatus.INTERNAL_SERVER_ERROR, "SHA-256이 지원되지 않습니다."), NAVER_API_ERROR(2604, HttpStatus.INTERNAL_SERVER_ERROR, "네이버 api 호출에 실패하였습니다."), PLACE_CREATION_ERROR(2605, HttpStatus.INTERNAL_SERVER_ERROR, "Place 생성에 오류가 발생하였습니다."), - GEOCODER_ERROR(2606, HttpStatus.INTERNAL_SERVER_ERROR, "좌표 -> 주소 변환 과정에서 오류가 발생하였습니다."); + GEOCODER_ERROR(2606, HttpStatus.INTERNAL_SERVER_ERROR, "좌표 -> 주소 변환 과정에서 오류가 발생하였습니다."), + ALREADY_PARTNERSHIP_SUGGESTED(2607, HttpStatus.CONFLICT, "이미 제휴 신청 완료된 장소입니다."); private final int code; private final HttpStatus status; diff --git a/src/main/java/com/campus/campus/domain/place/application/mapper/PlaceMapper.java b/src/main/java/com/campus/campus/domain/place/application/mapper/PlaceMapper.java index 8e317553..929ee5a3 100644 --- a/src/main/java/com/campus/campus/domain/place/application/mapper/PlaceMapper.java +++ b/src/main/java/com/campus/campus/domain/place/application/mapper/PlaceMapper.java @@ -6,20 +6,23 @@ import com.campus.campus.domain.council.domain.entity.CouncilType; import com.campus.campus.domain.councilpost.domain.entity.StudentCouncilPost; -import com.campus.campus.domain.place.application.dto.response.PartnershipPinResponse; import com.campus.campus.domain.place.application.dto.response.LikeResponse; -import com.campus.campus.domain.place.application.dto.response.SearchPartnershipInfoResponse; -import com.campus.campus.domain.place.application.dto.response.SearchPlaceInfoResponse; +import com.campus.campus.domain.place.application.dto.response.PartnershipPinResponse; +import com.campus.campus.domain.place.application.dto.response.PlaceDetailResponse; import com.campus.campus.domain.place.application.dto.response.RecommendNearByPlaceResponse; import com.campus.campus.domain.place.application.dto.response.RecommendPartnershipPlaceResponse; import com.campus.campus.domain.place.application.dto.response.RecommendPlaceByTimeResponse; import com.campus.campus.domain.place.application.dto.response.SavedPlaceInfo; +import com.campus.campus.domain.place.application.dto.response.SearchPartnershipInfoResponse; +import com.campus.campus.domain.place.application.dto.response.SearchPlaceInfoResponse; import com.campus.campus.domain.place.application.dto.response.naver.NaverSearchResponse; -import com.campus.campus.domain.place.application.dto.response.partnership.PartnershipResponse; +import com.campus.campus.domain.place.application.dto.response.partnership.PartnershipDetailResponse; import com.campus.campus.domain.place.domain.entity.Coordinate; import com.campus.campus.domain.place.domain.entity.LikedPlace; import com.campus.campus.domain.place.domain.entity.Place; import com.campus.campus.domain.place.domain.entity.PlaceImages; +import com.campus.campus.domain.review.application.dto.response.ReviewPartnerResponse; +import com.campus.campus.domain.review.application.dto.response.SimpleReviewResponse; import com.campus.campus.domain.user.domain.entity.User; @Component @@ -71,9 +74,10 @@ public PartnershipPinResponse toPartnershipPinResponse(StudentCouncilPost post, ); } - public PartnershipResponse toPartnershipResponse(User user, StudentCouncilPost post, Place place, boolean isLiked, - List imgUrls, double distance) { - return new PartnershipResponse( + public PartnershipDetailResponse toPartnershipResponse(User user, StudentCouncilPost post, Place place, + boolean isLiked, + List imgUrls, double distance, Double averageStar, List reviews, Integer size) { + return new PartnershipDetailResponse( place.getPlaceId(), place.getPlaceKey(), place.getPlaceName(), @@ -83,11 +87,42 @@ public PartnershipResponse toPartnershipResponse(User user, StudentCouncilPost p place.getCoordinate().longitude(), resolveTag(post, user), isLiked, - 5.0, //리뷰 구현 이후 수정 예정 + averageStar, post.getTitle(), distance, post.getEndDateTime().toLocalDate(), - imgUrls + imgUrls, + reviews, + size + ); + } + + public PlaceDetailResponse toPlaceDetailResponse(User user, Place place, boolean isLiked, + List imgUrls, double distance, Double averageStar, List reviews, Integer size) { + return new PlaceDetailResponse( + place.getPlaceId(), + place.getPlaceKey(), + place.getPlaceName(), + place.getPlaceCategory(), + place.getAddress(), + place.getCoordinate().latitude(), + place.getCoordinate().longitude(), + isLiked, + averageStar, + distance, + imgUrls, + reviews, + size + ); + } + + public ReviewPartnerResponse toReviewPartnerResponse(StudentCouncilPost post, Place place, double averageStar) { + return new ReviewPartnerResponse( + place.getPlaceName(), + place.getPlaceCategory(), + post.getWriter().getCouncilName(), + averageStar, //리뷰 별점 + post.getTitle() ); } diff --git a/src/main/java/com/campus/campus/domain/place/application/service/PartnershipPlaceService.java b/src/main/java/com/campus/campus/domain/place/application/service/PartnershipPlaceService.java index 90563e35..709cbd6c 100644 --- a/src/main/java/com/campus/campus/domain/place/application/service/PartnershipPlaceService.java +++ b/src/main/java/com/campus/campus/domain/place/application/service/PartnershipPlaceService.java @@ -1,10 +1,13 @@ package com.campus.campus.domain.place.application.service; +import java.math.BigDecimal; +import java.math.RoundingMode; import java.time.LocalDateTime; import java.util.AbstractMap; import java.util.Collections; import java.util.List; import java.util.Map; +import java.util.Optional; import java.util.Set; import java.util.stream.Collectors; @@ -23,10 +26,14 @@ import com.campus.campus.domain.councilpost.domain.repository.PostImageRepository; import com.campus.campus.domain.councilpost.domain.repository.StudentCouncilPostRepository; import com.campus.campus.domain.place.application.dto.response.PartnershipPinResponse; -import com.campus.campus.domain.place.application.dto.response.partnership.PartnershipResponse; +import com.campus.campus.domain.place.application.dto.response.PlaceDetailView; +import com.campus.campus.domain.place.application.dto.response.partnership.PartnershipDetailResponse; import com.campus.campus.domain.place.application.mapper.PlaceMapper; import com.campus.campus.domain.place.domain.entity.Place; import com.campus.campus.domain.place.domain.repository.LikedPlacesRepository; +import com.campus.campus.domain.place.domain.repository.PlaceImagesRepository; +import com.campus.campus.domain.place.domain.repository.PlaceRepository; +import com.campus.campus.domain.review.application.service.ReviewService; import com.campus.campus.domain.user.application.exception.UserNotFoundException; import com.campus.campus.domain.user.domain.entity.User; import com.campus.campus.domain.user.domain.repository.UserRepository; @@ -45,9 +52,12 @@ public class PartnershipPlaceService { private final PostImageRepository postImageRepository; private final PlaceMapper placeMapper; private final StudentCouncilPostRepository studentCouncilPostRepository; + private final ReviewService reviewService; + private final PlaceRepository placeRepository; + private final PlaceImagesRepository placeImagesRepository; @Transactional(readOnly = true) - public List getPartnershipPlaces(Long userId, Long cursor, int size, double userLat, + public List getPartnershipPlaces(Long userId, Long cursor, int size, double userLat, double userLng) { User user = userRepository.findById(userId) .orElseThrow(UserNotFoundException::new); @@ -94,6 +104,9 @@ public List getPartnershipPlaces(Long userId, Long cursor, .map(post -> post.getPlace().getPlaceId()) .collect(Collectors.toSet()); + Map averageStarMap = + reviewService.getAverageListOfStars(placeIds); + Map> postImageMap = postImageRepository.findAllByPostIn(targetPosts) .stream() .collect(Collectors.groupingBy( @@ -117,13 +130,18 @@ public List getPartnershipPlaces(Long userId, Long cursor, List images = postImageMap.getOrDefault(post.getId(), List.of()); boolean isLiked = likedPlaceIds.contains(post.getPlace().getPlaceId()); + double averageStar = averageStarMap.getOrDefault(post.getId(), 0.0); + return placeMapper.toPartnershipResponse( user, post, post.getPlace(), isLiked, images, - rounded + rounded, + averageStar, + null, + null ); }) .toList(); @@ -155,26 +173,133 @@ public List findPartnerInBounds(Long userId, Double minL .toList(); } - @Transactional - public PartnershipResponse getPartnershipDetail(Long postId, Long userId, double userLat, - double userLng) { + //장소 상세 조회 : 제휴 + private PartnershipDetailResponse getPartnershipDetailInternal( + StudentCouncilPost post, + User user, + double userLat, + double userLng + ) { + Place place = post.getPlace(); + + double distance = calculateDistance(place, userLat, userLng); + double averageStar = calculateAverageStar(place); + + return placeMapper.toPartnershipResponse( + user, + post, + place, + isLiked(place, user), + getImgUrls(post), + distance, + averageStar, + reviewService.getReviewSummaryList(place.getPlaceId()), + reviewService.getReviewCount(place.getPlaceId()) + ); + } + + //장소 상세 조회 : 제휴 + public PartnershipDetailResponse getPartnershipDetail( + Long postId, + Long userId, + double userLat, + double userLng + ) { + User user = userRepository.findById(userId) + .orElseThrow(UserNotFoundException::new); + StudentCouncilPost post = studentCouncilPostRepository.findById(postId) .orElseThrow(PostNotFoundException::new); + Place place = post.getPlace(); - if (place == null || place.getCoordinate() == null) { - throw new PlaceInfoNotFoundException(); - } + double distance = calculateDistance(place, userLat, userLng); + double averageStar = calculateAverageStar(place); + + return placeMapper.toPartnershipResponse( + user, + post, + place, + isLiked(place, user), + getImgUrls(post), + distance, + averageStar, + reviewService.getReviewSummaryList(place.getPlaceId()), + reviewService.getReviewCount(place.getPlaceId()) + ); + } + //장소 상세 조회: 제휴X + private PlaceDetailView getNormalPlaceDetailInternal( + User user, + Place place, + double userLat, + double userLng + ) { + double distance = calculateDistance(place, userLat, userLng); + double averageStar = calculateAverageStar(place); + + return placeMapper.toPlaceDetailResponse( + user, + place, + isLiked(place, user), + getPlaceImgUrls(place), + distance, + averageStar, + reviewService.getReviewSummaryList(place.getPlaceId()), + reviewService.getReviewCount(place.getPlaceId()) + ); + } + + @Transactional(readOnly = true) + public PlaceDetailView getPlaceDetails( + Long userId, + Long placeId, + double userLat, + double userLng + ) { User user = userRepository.findById(userId) .orElseThrow(UserNotFoundException::new); - double distanceMeter = GeoUtil.distanceMeter( - userLat, userLng, place.getCoordinate().latitude(), place.getCoordinate().longitude() + Place place = placeRepository.findById(placeId) + .orElseThrow(PlaceInfoNotFoundException::new); + + Optional activePost = + studentCouncilPostRepository.findActiveByPlaceAndUserScope( + place, + LocalDateTime.now(), + CouncilType.MAJOR_COUNCIL, + user.getMajor().getMajorId(), + CouncilType.COLLEGE_COUNCIL, + user.getCollege().getCollegeId(), + CouncilType.SCHOOL_COUNCIL, + user.getSchool().getSchoolId() + ); + + if (activePost.isPresent()) { + StudentCouncilPost post = activePost.get(); + return getPartnershipDetailInternal(post, user, userLat, userLng); + } else { + return getNormalPlaceDetailInternal(user, place, userLat, userLng); + } + } + + private double calculateDistance(Place place, double lat, double lng) { + double distance = GeoUtil.distanceMeter( + lat, + lng, + place.getCoordinate().latitude(), + place.getCoordinate().longitude() ); - double rounded = Math.round(distanceMeter * 100.0) / 100.0; + return Math.round(distance * 100.0) / 100.0; + } - return placeMapper.toPartnershipResponse(user, post, place, isLiked(place, user), getImgUrls(post), rounded); + private double calculateAverageStar(Place place) { + return BigDecimal.valueOf( + reviewService.getAverageOfStars(place.getPlaceId()) + ) + .setScale(1, RoundingMode.HALF_UP) + .doubleValue(); } private boolean isLiked(Place place, User user) { @@ -185,10 +310,14 @@ private List getImgUrls(StudentCouncilPost post) { return postImageRepository.findImageUrlsByPost(post); } + private List getPlaceImgUrls(Place place) { + return placeImagesRepository.findImageUrlsByPlace(place); + } + private void validateAcademicInfo(User user) { if (user.getSchool() == null || user.getCollege() == null || user.getMajor() == null) { throw new AcademicInfoNotSetException(); } } -} +} \ No newline at end of file diff --git a/src/main/java/com/campus/campus/domain/place/application/service/PlaceService.java b/src/main/java/com/campus/campus/domain/place/application/service/PlaceService.java index c104124f..1aea36a7 100644 --- a/src/main/java/com/campus/campus/domain/place/application/service/PlaceService.java +++ b/src/main/java/com/campus/campus/domain/place/application/service/PlaceService.java @@ -21,31 +21,38 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import com.campus.campus.domain.council.domain.entity.StudentCouncil; +import com.campus.campus.domain.council.domain.repository.StudentCouncilRepository; import com.campus.campus.domain.councilpost.application.exception.AcademicInfoNotSetException; import com.campus.campus.domain.councilpost.domain.entity.StudentCouncilPost; import com.campus.campus.domain.councilpost.domain.entity.ThumbnailIcon; import com.campus.campus.domain.councilpost.domain.repository.StudentCouncilPostRepository; import com.campus.campus.domain.place.application.dto.response.LikeResponse; -import com.campus.campus.domain.place.application.dto.response.SearchPlaceInfoResponse; import com.campus.campus.domain.place.application.dto.response.RecommendNearByPlaceResponse; import com.campus.campus.domain.place.application.dto.response.RecommendPartnershipPlaceResponse; import com.campus.campus.domain.place.application.dto.response.RecommendPlaceByTimeResponse; import com.campus.campus.domain.place.application.dto.response.SavedPlaceInfo; import com.campus.campus.domain.place.application.dto.response.SearchCandidateResponse; import com.campus.campus.domain.place.application.dto.response.SearchPartnershipInfoResponse; +import com.campus.campus.domain.place.application.dto.response.SearchPlaceInfoResponse; import com.campus.campus.domain.place.application.dto.response.geocoder.AddressResponse; import com.campus.campus.domain.place.application.dto.response.naver.NaverSearchResponse; +import com.campus.campus.domain.place.application.exception.AlreadySuggestedPartnershipException; import com.campus.campus.domain.place.application.exception.NaverMapAPIException; import com.campus.campus.domain.place.application.exception.PlaceCreationException; import com.campus.campus.domain.place.application.mapper.PlaceMapper; import com.campus.campus.domain.place.application.util.PlaceKeyGenerator; import com.campus.campus.domain.place.domain.entity.Coordinate; +import com.campus.campus.domain.place.domain.entity.CouncilPartnershipSuggestion; import com.campus.campus.domain.place.domain.entity.LikedPlace; import com.campus.campus.domain.place.domain.entity.Place; import com.campus.campus.domain.place.domain.entity.PlaceImages; +import com.campus.campus.domain.place.domain.entity.UserPartnershipSuggestion; +import com.campus.campus.domain.place.domain.repository.CouncilPartnershipSuggestionRepository; import com.campus.campus.domain.place.domain.repository.LikedPlacesRepository; import com.campus.campus.domain.place.domain.repository.PlaceImagesRepository; import com.campus.campus.domain.place.domain.repository.PlaceRepository; +import com.campus.campus.domain.place.domain.repository.UserPartnershipSuggestionRepository; import com.campus.campus.domain.place.infrastructure.geocoder.GeoCoderClient; import com.campus.campus.domain.place.infrastructure.google.GooglePlaceClient; import com.campus.campus.domain.place.infrastructure.naver.NaverMapClient; @@ -88,6 +95,9 @@ public class PlaceService { private final ExecutorService executorService; private final GeoCoderClient geoCoderClient; private final ReviewRepository reviewRepository; + private final UserPartnershipSuggestionRepository userPartnershipSuggestionRepository; + private final CouncilPartnershipSuggestionRepository partnershipSuggestionRepository; + private final StudentCouncilRepository studentCouncilRepository; public List searchByLocationAndKeyword(double lat, double lng, String keyword, int imageLimit) { String searchWord = keyword; @@ -176,6 +186,41 @@ public Place findOrCreatePlace(SavedPlaceInfo place) { }); } + //제휴 신청 + @Transactional + public void suggestPartnership(Long userId, SavedPlaceInfo placeInfo) { + User user = userRepository.findById(userId) + .orElseThrow(UserNotFoundException::new); + + Place place = findOrCreatePlace(placeInfo); + + //이미 신청했는지 체크 + if (userPartnershipSuggestionRepository + .existsByUserAndPlace(user, place)) { + throw new AlreadySuggestedPartnershipException(); + } + + //중복 방지 저장 + userPartnershipSuggestionRepository.save( + UserPartnershipSuggestion.create(user, place) + ); + + //유저 소속 studentCouncil + List councils = resolveCouncils(user); + + // demand 조회 or 생성 + for (StudentCouncil council : councils) { + CouncilPartnershipSuggestion demand = + partnershipSuggestionRepository.findByPlaceAndCouncil(place, council) + .orElseGet(() -> + partnershipSuggestionRepository.save( + CouncilPartnershipSuggestion.create(place, council) + )); + demand.increase(); + } + + } + //장소 저장 @Transactional public LikeResponse likePlace(SavedPlaceInfo placeInfo, Long userId) { @@ -291,6 +336,30 @@ private SavedPlaceInfo fallback(SearchCandidateResponse response) { response.naverPlaceUrl(), List.of()); } + private List resolveCouncils(User user) { + List councils = new ArrayList<>(); + + if (user.getMajor() != null) { + studentCouncilRepository.findByMajor_MajorId( + user.getMajor().getMajorId() + ).ifPresent(councils::add); + } + + if (user.getCollege() != null) { + studentCouncilRepository.findByCollege_CollegeId( + user.getCollege().getCollegeId() + ).ifPresent(councils::add); + } + + if (user.getSchool() != null) { + studentCouncilRepository.findBySchool_SchoolId( + user.getSchool().getSchoolId() + ).ifPresent(councils::add); + } + + return councils; + } + /* * 태그 제거용 */ diff --git a/src/main/java/com/campus/campus/domain/place/domain/entity/CouncilPartnershipSuggestion.java b/src/main/java/com/campus/campus/domain/place/domain/entity/CouncilPartnershipSuggestion.java new file mode 100644 index 00000000..56b98447 --- /dev/null +++ b/src/main/java/com/campus/campus/domain/place/domain/entity/CouncilPartnershipSuggestion.java @@ -0,0 +1,59 @@ +package com.campus.campus.domain.place.domain.entity; + +import com.campus.campus.domain.council.domain.entity.StudentCouncil; +import com.campus.campus.global.entity.BaseEntity; + +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.experimental.SuperBuilder; + +@Entity +@Table(name = "council_partnership_suggestion") +@Getter +@SuperBuilder +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PRIVATE) +public class CouncilPartnershipSuggestion extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "place_id", nullable = false) + private Place place; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "student_council_id", nullable = false) + private StudentCouncil council; + + private int requestCount; + + public void increase() { + this.requestCount++; + } + + public static CouncilPartnershipSuggestion create( + Place place, + StudentCouncil council + ) { + CouncilPartnershipSuggestion demand = new CouncilPartnershipSuggestion(); + demand.place = place; + demand.council = council; + demand.requestCount = 0; + return demand; + } +} + + + diff --git a/src/main/java/com/campus/campus/domain/place/domain/entity/UserPartnershipSuggestion.java b/src/main/java/com/campus/campus/domain/place/domain/entity/UserPartnershipSuggestion.java new file mode 100644 index 00000000..d3d53f64 --- /dev/null +++ b/src/main/java/com/campus/campus/domain/place/domain/entity/UserPartnershipSuggestion.java @@ -0,0 +1,47 @@ +package com.campus.campus.domain.place.domain.entity; + +import com.campus.campus.domain.user.domain.entity.User; +import com.campus.campus.global.entity.BaseEntity; + +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.experimental.SuperBuilder; + +@Entity +@Table(name = "user_partnership_suggestion") +@Getter +@SuperBuilder +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PRIVATE) +public class UserPartnershipSuggestion extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private User user; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "place_id", nullable = false) + private Place place; + + public static UserPartnershipSuggestion create(User user, Place place) { + UserPartnershipSuggestion s = new UserPartnershipSuggestion(); + s.user = user; + s.place = place; + return s; + } + +} \ No newline at end of file diff --git a/src/main/java/com/campus/campus/domain/place/domain/repository/CouncilPartnershipSuggestionRepository.java b/src/main/java/com/campus/campus/domain/place/domain/repository/CouncilPartnershipSuggestionRepository.java new file mode 100644 index 00000000..1708fc10 --- /dev/null +++ b/src/main/java/com/campus/campus/domain/place/domain/repository/CouncilPartnershipSuggestionRepository.java @@ -0,0 +1,14 @@ +package com.campus.campus.domain.place.domain.repository; + +import java.util.Optional; + +import org.springframework.data.jpa.repository.JpaRepository; + +import com.campus.campus.domain.council.domain.entity.StudentCouncil; +import com.campus.campus.domain.place.domain.entity.CouncilPartnershipSuggestion; +import com.campus.campus.domain.place.domain.entity.Place; + +public interface CouncilPartnershipSuggestionRepository extends JpaRepository { + + Optional findByPlaceAndCouncil(Place place, StudentCouncil council); +} \ No newline at end of file diff --git a/src/main/java/com/campus/campus/domain/place/domain/repository/PlaceImagesRepository.java b/src/main/java/com/campus/campus/domain/place/domain/repository/PlaceImagesRepository.java index aa763a30..c6949efe 100644 --- a/src/main/java/com/campus/campus/domain/place/domain/repository/PlaceImagesRepository.java +++ b/src/main/java/com/campus/campus/domain/place/domain/repository/PlaceImagesRepository.java @@ -4,7 +4,10 @@ import java.util.List; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import com.campus.campus.domain.place.domain.entity.Place; import com.campus.campus.domain.place.domain.entity.PlaceImages; public interface PlaceImagesRepository extends JpaRepository { @@ -12,4 +15,12 @@ public interface PlaceImagesRepository extends JpaRepository List findByPlaceKey(String placeKey); List findAllByPlaceKeyIn(Collection placeKeys); + + @Query(""" + select pi.imageUrl + from PlaceImages pi + where pi.place = :place + order by pi.placeImagesId asc + """) + List findImageUrlsByPlace(@Param("place") Place place); } diff --git a/src/main/java/com/campus/campus/domain/place/domain/repository/UserPartnershipSuggestionRepository.java b/src/main/java/com/campus/campus/domain/place/domain/repository/UserPartnershipSuggestionRepository.java new file mode 100644 index 00000000..0cfefac0 --- /dev/null +++ b/src/main/java/com/campus/campus/domain/place/domain/repository/UserPartnershipSuggestionRepository.java @@ -0,0 +1,12 @@ +package com.campus.campus.domain.place.domain.repository; + +import org.springframework.data.jpa.repository.JpaRepository; + +import com.campus.campus.domain.place.domain.entity.Place; +import com.campus.campus.domain.place.domain.entity.UserPartnershipSuggestion; +import com.campus.campus.domain.user.domain.entity.User; + +public interface UserPartnershipSuggestionRepository extends JpaRepository { + + boolean existsByUserAndPlace(User user, Place place); +} \ No newline at end of file diff --git a/src/main/java/com/campus/campus/domain/place/presentation/PlaceController.java b/src/main/java/com/campus/campus/domain/place/presentation/PlaceController.java index 4a3c1b6c..1665609f 100644 --- a/src/main/java/com/campus/campus/domain/place/presentation/PlaceController.java +++ b/src/main/java/com/campus/campus/domain/place/presentation/PlaceController.java @@ -10,14 +10,15 @@ import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; +import com.campus.campus.domain.place.application.dto.response.LikeResponse; import com.campus.campus.domain.place.application.dto.response.PartnershipPinResponse; -import com.campus.campus.domain.place.application.dto.response.SearchPlaceInfoResponse; +import com.campus.campus.domain.place.application.dto.response.PlaceDetailView; import com.campus.campus.domain.place.application.dto.response.RecommendPlaceByTimeResponse; -import com.campus.campus.domain.place.application.service.PartnershipPlaceService; -import com.campus.campus.domain.place.application.dto.response.LikeResponse; import com.campus.campus.domain.place.application.dto.response.SavedPlaceInfo; +import com.campus.campus.domain.place.application.dto.response.SearchPlaceInfoResponse; import com.campus.campus.domain.place.application.dto.response.geocoder.AddressResponse; -import com.campus.campus.domain.place.application.dto.response.partnership.PartnershipResponse; +import com.campus.campus.domain.place.application.dto.response.partnership.PartnershipDetailResponse; +import com.campus.campus.domain.place.application.service.PartnershipPlaceService; import com.campus.campus.domain.place.application.service.PlaceService; import com.campus.campus.domain.place.infrastructure.geocoder.GeoCoderClient; import com.campus.campus.global.annotation.CurrentUserId; @@ -91,7 +92,7 @@ public CommonResponse likePlace(@Valid @RequestBody SavedPlaceInfo @GetMapping("/partnership") @Operation(summary = "리스트로 제휴 장소 전체 조회", description = "무한 스크롤 방식으로 제휴 장소 목록을 조회합니다.") - public CommonResponse> getPartnershipPlaces( + public CommonResponse> getPartnershipPlaces( @CurrentUserId Long userId, @Parameter(description = "현재 위치의 위도", example = "37.50415") @RequestParam double lat, @Parameter(description = "현재 위치의 경도", example = "126.9570") @RequestParam double lng, @@ -114,7 +115,8 @@ public CommonResponse> getPartnershipPlaces( ) @RequestParam(required = false) Long cursor, @Parameter(description = "한 번에 조회할 개수", example = "5") @RequestParam(defaultValue = "5") int size) { - List response = partnershipPlaceService.getPartnershipPlaces(userId, cursor, size, lat, + List response = partnershipPlaceService.getPartnershipPlaces(userId, cursor, size, + lat, lng); return CommonResponse.success(PlaceResponseCode.CHECK_PARTNERSHIP_PLACES_SUCCESS, response); @@ -141,9 +143,29 @@ public CommonResponse> getPartnershipPlacesInMap( ); } + @GetMapping("/detail") + @Operation(summary = "장소 세부 조회") + public CommonResponse getPlaceDetails( + @CurrentUserId Long userId, + @Parameter( + description = "현재 위치의 위도", + example = "37.50415" + ) + @RequestParam double lat, + @Parameter( + description = "현재 위치의 경도", + example = "126.9570" + ) + @RequestParam double lng, + @RequestParam Long placeId + ) { + PlaceDetailView response = partnershipPlaceService.getPlaceDetails(userId, placeId, lat, lng); + return CommonResponse.success(PlaceResponseCode.GET_PLACE_DETAILS_SUCCESS, response); + } + @GetMapping("/partnership/detail") @Operation(summary = "제휴 장소 상세 조회(맵에서 핀 클릭 시)") - public CommonResponse getPartnershipPlaceDetail( + public CommonResponse getPartnershipPlaceDetail( @Parameter(description = "현재 위치의 위도", example = "37.50415") @RequestParam double lat, @Parameter(description = "현재 위치의 경도", example = "126.9570") @RequestParam double lng, @Parameter(description = "제휴글 ID", example = "10") @RequestParam Long postId, @@ -165,4 +187,14 @@ public CommonResponse getRandomPlaceByTime( return CommonResponse.success(PlaceResponseCode.GET_RANDOM_PLACE_SUCCESS, response); } + + @PostMapping("/suggest-partnership") + @Operation(summary = "제휴 신청하기") + public CommonResponse suggestPartnership( + @CurrentUserId Long userId, + @Valid @RequestBody SavedPlaceInfo placeInfo + ) { + placeService.suggestPartnership(userId, placeInfo); + return CommonResponse.success(PlaceResponseCode.PARTNERSHIP_SUGGEST_SUCCESS); + } } diff --git a/src/main/java/com/campus/campus/domain/place/presentation/PlaceResponseCode.java b/src/main/java/com/campus/campus/domain/place/presentation/PlaceResponseCode.java index b4a52f8f..43ed5bb2 100644 --- a/src/main/java/com/campus/campus/domain/place/presentation/PlaceResponseCode.java +++ b/src/main/java/com/campus/campus/domain/place/presentation/PlaceResponseCode.java @@ -15,7 +15,9 @@ public enum PlaceResponseCode implements ResponseCodeInterface { CHECK_PARTNERSHIP_PLACE_SUCCESS(200, HttpStatus.OK, "제휴 장소 조회가 완료되었습니다."), CHECK_PARTNERSHIP_PLACES_SUCCESS(200, HttpStatus.OK, "제휴 장소 리스트 조회가 완료되었습니다."), CHECK_ONE_PARTNERSHIP_PLACE_SUCCESS(200, HttpStatus.OK, "제휴 장소 단건 조회가 완료되었습니다."), - GET_RANDOM_PLACE_SUCCESS(200, HttpStatus.OK, "시간대별 랜덤 장소 조회가 완료되었습니다."); + GET_RANDOM_PLACE_SUCCESS(200, HttpStatus.OK, "시간대별 랜덤 장소 조회가 완료되었습니다."), + PARTNERSHIP_SUGGEST_SUCCESS(200, HttpStatus.OK, "제휴 신청이 완료되었어요."), + GET_PLACE_DETAILS_SUCCESS(200, HttpStatus.OK, "장소 단건 상세 조회가 완료되었어요."); private final int code; private final HttpStatus status; diff --git a/src/main/java/com/campus/campus/domain/review/application/mapper/ReviewMapper.java b/src/main/java/com/campus/campus/domain/review/application/mapper/ReviewMapper.java index 71c7e618..545321af 100644 --- a/src/main/java/com/campus/campus/domain/review/application/mapper/ReviewMapper.java +++ b/src/main/java/com/campus/campus/domain/review/application/mapper/ReviewMapper.java @@ -15,6 +15,7 @@ import com.campus.campus.domain.review.application.dto.response.ReviewCreateResult; import com.campus.campus.domain.review.application.dto.response.ReviewRankingResponse; import com.campus.campus.domain.review.application.dto.response.ReviewResponse; +import com.campus.campus.domain.review.application.dto.response.SimpleReviewResponse; import com.campus.campus.domain.review.application.dto.response.WriteReviewResponse; import com.campus.campus.domain.review.domain.entity.Review; import com.campus.campus.domain.review.domain.entity.ReviewImage; diff --git a/src/main/java/com/campus/campus/domain/review/application/service/OcrService.java b/src/main/java/com/campus/campus/domain/review/application/service/OcrService.java index 87a22a6f..79396598 100644 --- a/src/main/java/com/campus/campus/domain/review/application/service/OcrService.java +++ b/src/main/java/com/campus/campus/domain/review/application/service/OcrService.java @@ -24,7 +24,7 @@ import com.campus.campus.domain.review.application.dto.response.ocr.TotalPrice; import com.campus.campus.domain.review.application.exception.ReceiptFileConvertException; import com.campus.campus.domain.review.application.exception.ReceiptOcrFailedException; -import com.campus.campus.domain.review.infrastructure.ocr.ClovaOcrClient; +import com.campus.campus.domain.review.infrastructure.ClovaOcrClient; import com.fasterxml.jackson.databind.ObjectMapper; import lombok.RequiredArgsConstructor; diff --git a/src/main/java/com/campus/campus/domain/review/application/service/ReviewService.java b/src/main/java/com/campus/campus/domain/review/application/service/ReviewService.java index 885fc3b2..dfd16609 100644 --- a/src/main/java/com/campus/campus/domain/review/application/service/ReviewService.java +++ b/src/main/java/com/campus/campus/domain/review/application/service/ReviewService.java @@ -1,6 +1,8 @@ package com.campus.campus.domain.review.application.service; +import java.time.LocalDate; import java.time.LocalDateTime; +import java.util.Collections; import java.util.HashSet; import java.util.List; import java.util.Map; @@ -12,19 +14,26 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import com.campus.campus.domain.council.domain.entity.CouncilType; import com.campus.campus.domain.councilpost.application.exception.PostImageLimitExceededException; import com.campus.campus.domain.councilpost.domain.entity.StudentCouncilPost; import com.campus.campus.domain.councilpost.domain.repository.StudentCouncilPostRepository; +import com.campus.campus.domain.place.application.mapper.PlaceMapper; import com.campus.campus.domain.place.application.service.PlaceService; import com.campus.campus.domain.place.domain.entity.Place; import com.campus.campus.domain.review.application.dto.request.ReviewRequest; import com.campus.campus.domain.review.application.dto.response.CursorPageReviewResponse; import com.campus.campus.domain.review.application.dto.response.PlaceReviewRankResponse; +import com.campus.campus.domain.review.application.dto.response.PlaceStarAvgRow; import com.campus.campus.domain.review.application.dto.response.ReviewCreateResponse; import com.campus.campus.domain.review.application.dto.response.ReviewCreateResult; +import com.campus.campus.domain.review.application.dto.response.ReviewPartnerResponse; import com.campus.campus.domain.review.application.dto.response.ReviewRankingResponse; import com.campus.campus.domain.review.application.dto.response.ReviewResponse; +import com.campus.campus.domain.review.application.dto.response.SimpleReviewResponse; import com.campus.campus.domain.review.application.dto.response.WriteReviewResponse; +import com.campus.campus.domain.review.application.dto.response.ocr.ReceiptResultDto; +import com.campus.campus.domain.review.application.exception.NotPartnershipReceiptException; import com.campus.campus.domain.review.application.exception.NotUserWriterException; import com.campus.campus.domain.review.application.exception.ReviewNotFoundException; import com.campus.campus.domain.review.application.mapper.ReviewMapper; @@ -59,6 +68,7 @@ public class ReviewService { private final StudentCouncilPostRepository studentCouncilPostRepository; private final StampService stampService; private final StampRepository stampRepository; + private final PlaceMapper placeMapper; @Transactional public ReviewCreateResponse writeReview(ReviewRequest request, Long userId) { @@ -177,6 +187,39 @@ public WriteReviewResponse update(Long userId, Long reviewId, ReviewRequest requ return reviewMapper.toWriteReviewResponse(review, imageUrl); } + @Transactional(readOnly = true) + public List getReviewSummaryList(Long placeId) { + + List reviews = + reviewRepository.findTop3ByPlace_PlaceIdOrderByCreatedAtDesc(placeId); + + if (reviews.isEmpty()) { + return List.of(); + } + + List reviewIds = reviews.stream() + .map(Review::getId) + .toList(); + + Map imageMap = + reviewImageRepository.findAllByReviewIdInOrderByIdAsc(reviewIds) + .stream() + .collect(Collectors.toMap( + img -> img.getReview().getId(), + ReviewImage::getImageUrl, + (existing, ignored) -> existing + )); + + return reviews.stream() + .map(review -> + reviewMapper.toSimpleReviewResponse( + review, + imageMap.get(review.getId()) + ) + ) + .toList(); + } + @Transactional(readOnly = true) public CursorPageReviewResponse getReviewList( Long placeId, @@ -228,6 +271,70 @@ public CursorPageReviewResponse getReviewList( } + @Transactional(readOnly = true) + public ReviewPartnerResponse findPartnership(Long placeId, ReceiptResultDto result, Long userId) { + User user = userRepository.findById(userId) + .orElseThrow(UserNotFoundException::new); + Long majorId = user.getMajor().getMajorId(); + Long collegeId = user.getCollege().getCollegeId(); + Long schoolId = user.getSchool().getSchoolId(); + + //OCR 리턴 타입보고 변경해야 함 + LocalDate paymentDateTime = result.paymentDate(); + LocalDateTime time = paymentDateTime.atStartOfDay(); //시간은 우선 임의로 + + //제휴기간 내에 결제 했는지 확인 + StudentCouncilPost post = studentCouncilPostRepository.findValidPartnershipForUserScope( + placeId, time, majorId, collegeId, schoolId, CouncilType.MAJOR_COUNCIL, + CouncilType.COLLEGE_COUNCIL, CouncilType.SCHOOL_COUNCIL + ).orElseThrow(NotPartnershipReceiptException::new); + + //review isVerified 필드 true로 변경 + + double averageStar = getAverageOfStars(placeId); + return placeMapper.toReviewPartnerResponse(post, post.getPlace(), averageStar); + } + + public double getAverageOfStars(Long placeId) { + List reviews = reviewRepository.findALlByPlace_PlaceId(placeId); + double averageStar = reviews.stream() + .mapToDouble(Review::getStar) + .average() + .orElse(0.0); + return averageStar; + } + + @Transactional(readOnly = true) + public int getReviewCount(Long placeId) { + List reviews = reviewRepository.findALlByPlace_PlaceId(placeId); + return reviews.size(); + } + + @Transactional(readOnly = true) + public Map getAverageListOfStars(Set placeIds) { + + if (placeIds == null || placeIds.isEmpty()) { + return Collections.emptyMap(); + } + + List rows = + reviewRepository.findAverageStarsByPlaceIds(placeIds); + + // 조회된 placeId → 평균 + Map avgMap = rows.stream() + .collect(Collectors.toMap( + PlaceStarAvgRow::placeId, + row -> row.avgStar() != null ? row.avgStar() : 0.0 + )); + + // 리뷰가 하나도 없는 placeId는 0.0으로 채움 + for (Long placeId : placeIds) { + avgMap.putIfAbsent(placeId, 0.0); + } + + return avgMap; + } + @Transactional(readOnly = true) public List readPopularPartnerships(Long userId) { User user = userRepository.findById(userId) diff --git a/src/main/java/com/campus/campus/domain/review/domain/entity/Review.java b/src/main/java/com/campus/campus/domain/review/domain/entity/Review.java index f781cc86..4430d631 100644 --- a/src/main/java/com/campus/campus/domain/review/domain/entity/Review.java +++ b/src/main/java/com/campus/campus/domain/review/domain/entity/Review.java @@ -47,7 +47,10 @@ public class Review extends BaseEntity { @JoinColumn(name = "place_id", nullable = false) private Place place; - public void update(String content, Double star) { + public void update( + String content, + double star + ) { this.content = content; this.star = star; } diff --git a/src/main/java/com/campus/campus/domain/review/domain/repository/ReviewRepository.java b/src/main/java/com/campus/campus/domain/review/domain/repository/ReviewRepository.java index 9d7e06ca..ea9497b3 100644 --- a/src/main/java/com/campus/campus/domain/review/domain/repository/ReviewRepository.java +++ b/src/main/java/com/campus/campus/domain/review/domain/repository/ReviewRepository.java @@ -2,6 +2,7 @@ import java.time.LocalDateTime; import java.util.List; +import java.util.Set; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; @@ -9,6 +10,7 @@ import org.springframework.data.repository.query.Param; import com.campus.campus.domain.place.domain.entity.Place; +import com.campus.campus.domain.review.application.dto.response.PlaceStarAvgRow; import com.campus.campus.domain.review.domain.entity.Review; import com.campus.campus.domain.user.domain.entity.User; @@ -32,6 +34,19 @@ List findByPlaceIdWithCursor( Pageable pageable ); + @Query(""" + SELECT new com.campus.campus.domain.review.application.dto.response.PlaceStarAvgRow( + r.place.placeId, + AVG(r.star) + ) + FROM Review r + WHERE r.place.placeId IN :placeIds + GROUP BY r.place.placeId + """) + List findAverageStarsByPlaceIds( + @Param("placeIds") Set placeIds + ); + @Query(""" SELECT r.place.placeKey, AVG(r.star) FROM Review r @@ -49,4 +64,8 @@ SELECT r.place.placeKey, AVG(r.star) long countByPlace_PlaceIdAndUser_College_CollegeId(Long placeId, long collegeId); long countByPlace_PlaceIdAndUser_School_SchoolId(Long placeId, long schoolId); + + List findALlByPlace_PlaceId(long placeId); + + List findTop3ByPlace_PlaceIdOrderByCreatedAtDesc(Long placeId); } diff --git a/src/main/java/com/campus/campus/domain/review/infrastructure/ClovaOcrClient.java b/src/main/java/com/campus/campus/domain/review/infrastructure/ClovaOcrClient.java index fa7d7781..c6fc30ea 100644 --- a/src/main/java/com/campus/campus/domain/review/infrastructure/ClovaOcrClient.java +++ b/src/main/java/com/campus/campus/domain/review/infrastructure/ClovaOcrClient.java @@ -1,4 +1,4 @@ -package com.campus.campus.domain.review.infrastructure.ocr; +package com.campus.campus.domain.review.infrastructure; import java.util.Base64; import java.util.HashMap; diff --git a/src/main/java/com/campus/campus/domain/review/presentation/ReviewController.java b/src/main/java/com/campus/campus/domain/review/presentation/ReviewController.java index cdbd40ae..c93b7d28 100644 --- a/src/main/java/com/campus/campus/domain/review/presentation/ReviewController.java +++ b/src/main/java/com/campus/campus/domain/review/presentation/ReviewController.java @@ -21,8 +21,10 @@ import com.campus.campus.domain.review.application.dto.response.CursorPageReviewResponse; import com.campus.campus.domain.review.application.dto.response.PlaceReviewRankResponse; import com.campus.campus.domain.review.application.dto.response.ReviewCreateResponse; +import com.campus.campus.domain.review.application.dto.response.ReviewPartnerResponse; import com.campus.campus.domain.review.application.dto.response.ReviewResponse; import com.campus.campus.domain.review.application.dto.response.WriteReviewResponse; +import com.campus.campus.domain.review.application.service.OcrService; import com.campus.campus.domain.review.application.service.ReviewService; import com.campus.campus.global.annotation.CurrentUserId; import com.campus.campus.global.common.response.CommonResponse; @@ -37,6 +39,7 @@ public class ReviewController { private final ReviewService reviewService; + private final OcrService ocrService; @PostMapping @Operation( diff --git a/src/main/resources/application-local.yml b/src/main/resources/application-local.yml index 2943e5ee..df7f701e 100644 --- a/src/main/resources/application-local.yml +++ b/src/main/resources/application-local.yml @@ -69,4 +69,9 @@ server-uri: http://localhost:8080 firebase: credentials: - path: ${FIREBASE_CREDENTIALS_PATH} \ No newline at end of file + path: ${FIREBASE_CREDENTIALS_PATH} + +clova: + ocr: + invoke-url: https://oprno6zgu9.apigw.ntruss.com/custom/v1/49413/b8f236bbccbb9b0008d8d0485d9028faba366671ac72ccab91d2df2bb9c96001/document/receipt + secret-key: ${OCR_KEY} \ No newline at end of file From f2ee0f584ef2e2c996506b2c69fd875e8f879047 Mon Sep 17 00:00:00 2001 From: minsikk <131092169+1224kang@users.noreply.github.com> Date: Fri, 16 Jan 2026 15:21:10 +0900 Subject: [PATCH 04/18] =?UTF-8?q?feat:=20OCR=20=EB=A6=AC=EB=B7=B0=20isVeri?= =?UTF-8?q?fied=3Dtrue=EB=A1=9C=20=EB=B3=80=EA=B2=BD=ED=95=B4=EC=84=9C=20?= =?UTF-8?q?=EC=A0=80=EC=9E=A5=ED=95=98=EB=8F=84=EB=A1=9D=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../review/application/dto/request/ReviewRequest.java | 3 +++ .../review/application/mapper/ReviewMapper.java | 1 + .../review/application/service/ReviewService.java | 7 +------ .../domain/review/presentation/ReviewController.java | 11 +++++++---- 4 files changed, 12 insertions(+), 10 deletions(-) diff --git a/src/main/java/com/campus/campus/domain/review/application/dto/request/ReviewRequest.java b/src/main/java/com/campus/campus/domain/review/application/dto/request/ReviewRequest.java index 5d6c11b2..c06414ea 100644 --- a/src/main/java/com/campus/campus/domain/review/application/dto/request/ReviewRequest.java +++ b/src/main/java/com/campus/campus/domain/review/application/dto/request/ReviewRequest.java @@ -26,6 +26,9 @@ public record ReviewRequest( List imageUrls, + @Schema(description = "영수증 리뷰를 하고 오면 isVerified=True로 주세요.") + Boolean isVerified, + @Schema(example = """ "placeName": "숙명여자대학교", diff --git a/src/main/java/com/campus/campus/domain/review/application/mapper/ReviewMapper.java b/src/main/java/com/campus/campus/domain/review/application/mapper/ReviewMapper.java index 545321af..b35e6c64 100644 --- a/src/main/java/com/campus/campus/domain/review/application/mapper/ReviewMapper.java +++ b/src/main/java/com/campus/campus/domain/review/application/mapper/ReviewMapper.java @@ -32,6 +32,7 @@ public Review createReview(ReviewRequest request, User user, Place place) { .user(user) .content(request.content()) .star(request.star()) + .isVerified(request.isVerified()) .place(place) .build(); } diff --git a/src/main/java/com/campus/campus/domain/review/application/service/ReviewService.java b/src/main/java/com/campus/campus/domain/review/application/service/ReviewService.java index dfd16609..a40d1889 100644 --- a/src/main/java/com/campus/campus/domain/review/application/service/ReviewService.java +++ b/src/main/java/com/campus/campus/domain/review/application/service/ReviewService.java @@ -90,9 +90,7 @@ public ReviewCreateResponse writeReview(ReviewRequest request, Long userId) { } } - // isOcrVerificationSuccess는 ocr이 성공했다고 가정하고 구현했습니다. 이는 ocr을 구현하면서 수정해주시면 됩니다. - boolean isOcrVerificationSuccess = true; - if (isOcrVerificationSuccess) { + if (request.isVerified()) { review.verify(); stampService.grantStampForReview(user, review); } @@ -279,7 +277,6 @@ public ReviewPartnerResponse findPartnership(Long placeId, ReceiptResultDto resu Long collegeId = user.getCollege().getCollegeId(); Long schoolId = user.getSchool().getSchoolId(); - //OCR 리턴 타입보고 변경해야 함 LocalDate paymentDateTime = result.paymentDate(); LocalDateTime time = paymentDateTime.atStartOfDay(); //시간은 우선 임의로 @@ -289,8 +286,6 @@ public ReviewPartnerResponse findPartnership(Long placeId, ReceiptResultDto resu CouncilType.COLLEGE_COUNCIL, CouncilType.SCHOOL_COUNCIL ).orElseThrow(NotPartnershipReceiptException::new); - //review isVerified 필드 true로 변경 - double averageStar = getAverageOfStars(placeId); return placeMapper.toReviewPartnerResponse(post, post.getPlace(), averageStar); } diff --git a/src/main/java/com/campus/campus/domain/review/presentation/ReviewController.java b/src/main/java/com/campus/campus/domain/review/presentation/ReviewController.java index c93b7d28..607b9971 100644 --- a/src/main/java/com/campus/campus/domain/review/presentation/ReviewController.java +++ b/src/main/java/com/campus/campus/domain/review/presentation/ReviewController.java @@ -4,6 +4,7 @@ import java.util.List; import org.springframework.format.annotation.DateTimeFormat; +import org.springframework.http.MediaType; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PatchMapping; @@ -44,6 +45,7 @@ public class ReviewController { @PostMapping @Operation( summary = "리뷰 작성", + description = "제휴 가게여서 영수증 인증을 마쳤다면 isVerified=True값으로 넘겨주세요.", requestBody = @io.swagger.v3.oas.annotations.parameters.RequestBody( required = true, content = @io.swagger.v3.oas.annotations.media.Content( @@ -88,11 +90,12 @@ public CommonResponse writeReview( return CommonResponse.success(ReviewResponseCode.REVIEW_SAVE_SUCCESS, response); } - @PostMapping("/receipt-ocr") - @Operation(summary = "영수증 ocr을 통해 제휴 매장 이용 인증") + @PostMapping(value = "/receipt-ocr", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + @Operation(summary = "영수증 ocr을 통해 제휴 매장 이용 인증", + description = "위 api 에러 없이 끝낸 후에 리뷰 작성 api 호출. 이때, isVerified=True라는 필드를 주면됩니다. ") public CommonResponse upload( - @RequestPart MultipartFile file, - @Valid @RequestBody SavedPlaceInfo placeInfo, + @RequestPart("file") MultipartFile file, + @RequestPart("request") SavedPlaceInfo placeInfo, @CurrentUserId Long userId ) { return CommonResponse.success(ReviewResponseCode.OCR_SUCCESS, From 593809ec0f2488407057f5bc2d0f326e39754906 Mon Sep 17 00:00:00 2001 From: minsikk <131092169+1224kang@users.noreply.github.com> Date: Fri, 16 Jan 2026 15:26:44 +0900 Subject: [PATCH 05/18] =?UTF-8?q?feat:=20=EB=A6=AC=EB=B7=B0=20=EC=9E=91?= =?UTF-8?q?=EC=84=B1=20=EB=A1=9C=EC=A7=81=20=EC=84=A4=EB=AA=85=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20=EB=B0=8F=20=EC=A0=9C=ED=9C=B4=EC=97=AC=EB=B6=80=20?= =?UTF-8?q?=ED=8C=90=EB=8B=A8=20=ED=95=A0=20=EC=88=98=20=EC=9E=88=EB=8A=94?= =?UTF-8?q?=20=ED=95=84=EB=93=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dto/response/PlaceDetailResponse.java | 1 + .../dto/response/PlaceDetailView.java | 2 ++ .../PartnershipDetailResponse.java | 1 + .../place/application/mapper/PlaceMapper.java | 4 +++- .../service/PartnershipPlaceService.java | 1 - .../review/presentation/ReviewController.java | 19 ++++++++++++++++--- 6 files changed, 23 insertions(+), 5 deletions(-) diff --git a/src/main/java/com/campus/campus/domain/place/application/dto/response/PlaceDetailResponse.java b/src/main/java/com/campus/campus/domain/place/application/dto/response/PlaceDetailResponse.java index 403a2dd2..caa47104 100644 --- a/src/main/java/com/campus/campus/domain/place/application/dto/response/PlaceDetailResponse.java +++ b/src/main/java/com/campus/campus/domain/place/application/dto/response/PlaceDetailResponse.java @@ -5,6 +5,7 @@ import com.campus.campus.domain.review.application.dto.response.SimpleReviewResponse; public record PlaceDetailResponse( + boolean isPartnership, Long placeId, String placeKey, String name, diff --git a/src/main/java/com/campus/campus/domain/place/application/dto/response/PlaceDetailView.java b/src/main/java/com/campus/campus/domain/place/application/dto/response/PlaceDetailView.java index e9a5ac9c..415563c2 100644 --- a/src/main/java/com/campus/campus/domain/place/application/dto/response/PlaceDetailView.java +++ b/src/main/java/com/campus/campus/domain/place/application/dto/response/PlaceDetailView.java @@ -2,6 +2,8 @@ public interface PlaceDetailView { + boolean isPartnership(); + Long placeId(); String placeKey(); diff --git a/src/main/java/com/campus/campus/domain/place/application/dto/response/partnership/PartnershipDetailResponse.java b/src/main/java/com/campus/campus/domain/place/application/dto/response/partnership/PartnershipDetailResponse.java index 6f48c969..f35ca20c 100644 --- a/src/main/java/com/campus/campus/domain/place/application/dto/response/partnership/PartnershipDetailResponse.java +++ b/src/main/java/com/campus/campus/domain/place/application/dto/response/partnership/PartnershipDetailResponse.java @@ -7,6 +7,7 @@ import com.campus.campus.domain.review.application.dto.response.SimpleReviewResponse; public record PartnershipDetailResponse( + boolean isPartnership, Long placeId, String placeKey, String name, diff --git a/src/main/java/com/campus/campus/domain/place/application/mapper/PlaceMapper.java b/src/main/java/com/campus/campus/domain/place/application/mapper/PlaceMapper.java index 929ee5a3..803ecfd8 100644 --- a/src/main/java/com/campus/campus/domain/place/application/mapper/PlaceMapper.java +++ b/src/main/java/com/campus/campus/domain/place/application/mapper/PlaceMapper.java @@ -78,6 +78,7 @@ public PartnershipDetailResponse toPartnershipResponse(User user, StudentCouncil boolean isLiked, List imgUrls, double distance, Double averageStar, List reviews, Integer size) { return new PartnershipDetailResponse( + true, place.getPlaceId(), place.getPlaceKey(), place.getPlaceName(), @@ -97,9 +98,10 @@ public PartnershipDetailResponse toPartnershipResponse(User user, StudentCouncil ); } - public PlaceDetailResponse toPlaceDetailResponse(User user, Place place, boolean isLiked, + public PlaceDetailResponse toPlaceDetailResponse(Place place, boolean isLiked, List imgUrls, double distance, Double averageStar, List reviews, Integer size) { return new PlaceDetailResponse( + false, place.getPlaceId(), place.getPlaceKey(), place.getPlaceName(), diff --git a/src/main/java/com/campus/campus/domain/place/application/service/PartnershipPlaceService.java b/src/main/java/com/campus/campus/domain/place/application/service/PartnershipPlaceService.java index 709cbd6c..63a8d2cf 100644 --- a/src/main/java/com/campus/campus/domain/place/application/service/PartnershipPlaceService.java +++ b/src/main/java/com/campus/campus/domain/place/application/service/PartnershipPlaceService.java @@ -240,7 +240,6 @@ private PlaceDetailView getNormalPlaceDetailInternal( double averageStar = calculateAverageStar(place); return placeMapper.toPlaceDetailResponse( - user, place, isLiked(place, user), getPlaceImgUrls(place), diff --git a/src/main/java/com/campus/campus/domain/review/presentation/ReviewController.java b/src/main/java/com/campus/campus/domain/review/presentation/ReviewController.java index 607b9971..a5ffd948 100644 --- a/src/main/java/com/campus/campus/domain/review/presentation/ReviewController.java +++ b/src/main/java/com/campus/campus/domain/review/presentation/ReviewController.java @@ -90,9 +90,22 @@ public CommonResponse writeReview( return CommonResponse.success(ReviewResponseCode.REVIEW_SAVE_SUCCESS, response); } - @PostMapping(value = "/receipt-ocr", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) - @Operation(summary = "영수증 ocr을 통해 제휴 매장 이용 인증", - description = "위 api 에러 없이 끝낸 후에 리뷰 작성 api 호출. 이때, isVerified=True라는 필드를 주면됩니다. ") + @PostMapping( + value = "/receipt-ocr", + consumes = MediaType.MULTIPART_FORM_DATA_VALUE + ) + @Operation( + summary = "영수증 OCR을 통한 제휴 매장 이용 인증", + description = """ + 제휴 매장 리뷰 작성 전, 영수증 OCR을 통해 이용 여부를 인증하는 API입니다. + + - 제휴 매장 리뷰 작성 시 반드시 먼저 호출해야 합니다. + - OCR 인증이 성공적으로 완료된 후 리뷰 작성 API를 호출해주세요. + - 리뷰 작성 시 isVerified = true 값을 함께 전달해야 합니다. + - 제휴 매장이 아닌 경우에는 본 API를 호출하지 않고, + 리뷰 작성 API를 바로 호출하시면 됩니다. + """ + ) public CommonResponse upload( @RequestPart("file") MultipartFile file, @RequestPart("request") SavedPlaceInfo placeInfo, From 6c420bde894683a83e6f54eecc7f0c6bb6f1ed96 Mon Sep 17 00:00:00 2001 From: minsikk <131092169+1224kang@users.noreply.github.com> Date: Fri, 16 Jan 2026 15:58:02 +0900 Subject: [PATCH 06/18] =?UTF-8?q?fix:=20coderabbit=201=EC=B0=A8=20PR=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../StudentCouncilPostRepository.java | 26 ++----------------- .../service/PartnershipPlaceService.java | 7 +++-- .../application/service/PlaceService.java | 2 +- .../entity/CouncilPartnershipSuggestion.java | 6 ++++- ...ouncilPartnershipSuggestionRepository.java | 18 ++++++++++++- .../dto/response/ocr/ReceiptResultDto.java | 2 +- .../application/exception/ErrorCode.java | 3 ++- .../exception/ReceiptDateParseException.java | 9 +++++++ .../application/service/OcrService.java | 22 ++++++++++++++-- .../application/service/ReviewService.java | 10 ++----- .../review/domain/entity/ReviewImage.java | 8 +++--- .../domain/repository/ReviewRepository.java | 4 +++ .../review/infrastructure/ClovaOcrClient.java | 4 +-- .../campus/global/config/ClovaOcrConfig.java | 9 ++++++- .../campus/global/config/PermitUrlConfig.java | 1 - 15 files changed, 81 insertions(+), 50 deletions(-) create mode 100644 src/main/java/com/campus/campus/domain/review/application/exception/ReceiptDateParseException.java diff --git a/src/main/java/com/campus/campus/domain/councilpost/domain/repository/StudentCouncilPostRepository.java b/src/main/java/com/campus/campus/domain/councilpost/domain/repository/StudentCouncilPostRepository.java index 56d35e56..02152fc9 100644 --- a/src/main/java/com/campus/campus/domain/councilpost/domain/repository/StudentCouncilPostRepository.java +++ b/src/main/java/com/campus/campus/domain/councilpost/domain/repository/StudentCouncilPostRepository.java @@ -325,6 +325,7 @@ List findTop3RecommendedPartnershipPlaces( WHERE p.place.placeId = :placeId AND p.startDateTime <= :paymentDate AND p.endDateTime >= :paymentDate + AND w.deletedAt IS NULL AND ( (w.councilType =:majorType AND m.majorId = :majorId) OR (w.councilType =:collegeType AND c.collegeId = :collegeId) @@ -412,30 +413,6 @@ List findTodayEvent( Pageable pageable ); - @Query(""" - SELECT COUNT(p) > 0 - FROM StudentCouncilPost p - JOIN p.writer w - WHERE p.place = :place - AND p.startDateTime <= :now - AND p.endDateTime >= :now - AND ( - (w.councilType = :majorType AND w.major.majorId = :majorId) - OR (w.councilType = :collegeType AND w.college.collegeId = :collegeId) - OR (w.councilType = :schoolType AND w.school.schoolId = :schoolId) - ) - """) - boolean existsActiveByPlaceAndUserScope( - @Param("place") Place place, - @Param("now") LocalDateTime now, - @Param("majorType") CouncilType majorType, - @Param("majorId") Long majorId, - @Param("collegeType") CouncilType collegeType, - @Param("collegeId") Long collegeId, - @Param("schoolType") CouncilType schoolType, - @Param("schoolId") Long schoolId - ); - @Query(""" SELECT p FROM StudentCouncilPost p @@ -443,6 +420,7 @@ boolean existsActiveByPlaceAndUserScope( WHERE p.place = :place AND p.startDateTime <= :now AND p.endDateTime >= :now + AND w.deletedAt IS NULL AND ( (w.councilType = :majorType AND w.major.majorId = :majorId) OR (w.councilType = :collegeType AND w.college.collegeId = :collegeId) diff --git a/src/main/java/com/campus/campus/domain/place/application/service/PartnershipPlaceService.java b/src/main/java/com/campus/campus/domain/place/application/service/PartnershipPlaceService.java index 63a8d2cf..70a0632a 100644 --- a/src/main/java/com/campus/campus/domain/place/application/service/PartnershipPlaceService.java +++ b/src/main/java/com/campus/campus/domain/place/application/service/PartnershipPlaceService.java @@ -130,7 +130,7 @@ public List getPartnershipPlaces(Long userId, Long cu List images = postImageMap.getOrDefault(post.getId(), List.of()); boolean isLiked = likedPlaceIds.contains(post.getPlace().getPlaceId()); - double averageStar = averageStarMap.getOrDefault(post.getId(), 0.0); + double averageStar = averageStarMap.getOrDefault(post.getPlace().getPlaceId(), 0.0); return placeMapper.toPartnershipResponse( user, @@ -199,6 +199,7 @@ private PartnershipDetailResponse getPartnershipDetailInternal( } //장소 상세 조회 : 제휴 + @Transactional(readOnly = true) public PartnershipDetailResponse getPartnershipDetail( Long postId, Long userId, @@ -212,7 +213,9 @@ public PartnershipDetailResponse getPartnershipDetail( .orElseThrow(PostNotFoundException::new); Place place = post.getPlace(); - + if (place == null) { + throw new PlaceInfoNotFoundException(); + } double distance = calculateDistance(place, userLat, userLng); double averageStar = calculateAverageStar(place); diff --git a/src/main/java/com/campus/campus/domain/place/application/service/PlaceService.java b/src/main/java/com/campus/campus/domain/place/application/service/PlaceService.java index 1aea36a7..419b450d 100644 --- a/src/main/java/com/campus/campus/domain/place/application/service/PlaceService.java +++ b/src/main/java/com/campus/campus/domain/place/application/service/PlaceService.java @@ -211,7 +211,7 @@ public void suggestPartnership(Long userId, SavedPlaceInfo placeInfo) { // demand 조회 or 생성 for (StudentCouncil council : councils) { CouncilPartnershipSuggestion demand = - partnershipSuggestionRepository.findByPlaceAndCouncil(place, council) + partnershipSuggestionRepository.findForUpdate(place, council) .orElseGet(() -> partnershipSuggestionRepository.save( CouncilPartnershipSuggestion.create(place, council) diff --git a/src/main/java/com/campus/campus/domain/place/domain/entity/CouncilPartnershipSuggestion.java b/src/main/java/com/campus/campus/domain/place/domain/entity/CouncilPartnershipSuggestion.java index 56b98447..d5bbbd5f 100644 --- a/src/main/java/com/campus/campus/domain/place/domain/entity/CouncilPartnershipSuggestion.java +++ b/src/main/java/com/campus/campus/domain/place/domain/entity/CouncilPartnershipSuggestion.java @@ -3,6 +3,7 @@ import com.campus.campus.domain.council.domain.entity.StudentCouncil; import com.campus.campus.global.entity.BaseEntity; +import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.FetchType; import jakarta.persistence.GeneratedValue; @@ -11,6 +12,7 @@ import jakarta.persistence.JoinColumn; import jakarta.persistence.ManyToOne; import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; import lombok.AccessLevel; import lombok.AllArgsConstructor; import lombok.Getter; @@ -18,7 +20,8 @@ import lombok.experimental.SuperBuilder; @Entity -@Table(name = "council_partnership_suggestion") +@Table(name = "council_partnership_suggestion", + uniqueConstraints = @UniqueConstraint(columnNames = {"place_id", "student_council_id"})) @Getter @SuperBuilder @NoArgsConstructor(access = AccessLevel.PROTECTED) @@ -37,6 +40,7 @@ public class CouncilPartnershipSuggestion extends BaseEntity { @JoinColumn(name = "student_council_id", nullable = false) private StudentCouncil council; + @Column(nullable = false) private int requestCount; public void increase() { diff --git a/src/main/java/com/campus/campus/domain/place/domain/repository/CouncilPartnershipSuggestionRepository.java b/src/main/java/com/campus/campus/domain/place/domain/repository/CouncilPartnershipSuggestionRepository.java index 1708fc10..46155c69 100644 --- a/src/main/java/com/campus/campus/domain/place/domain/repository/CouncilPartnershipSuggestionRepository.java +++ b/src/main/java/com/campus/campus/domain/place/domain/repository/CouncilPartnershipSuggestionRepository.java @@ -3,12 +3,28 @@ import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import com.campus.campus.domain.council.domain.entity.StudentCouncil; import com.campus.campus.domain.place.domain.entity.CouncilPartnershipSuggestion; import com.campus.campus.domain.place.domain.entity.Place; +import jakarta.persistence.LockModeType; + public interface CouncilPartnershipSuggestionRepository extends JpaRepository { - Optional findByPlaceAndCouncil(Place place, StudentCouncil council); + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query(""" + SELECT cps + FROM CouncilPartnershipSuggestion cps + WHERE cps.place = :place + AND cps.council = :council + """) + Optional findForUpdate( + @Param("place") Place place, + @Param("council") StudentCouncil council + ); + } \ No newline at end of file diff --git a/src/main/java/com/campus/campus/domain/review/application/dto/response/ocr/ReceiptResultDto.java b/src/main/java/com/campus/campus/domain/review/application/dto/response/ocr/ReceiptResultDto.java index 48107aef..956003dc 100644 --- a/src/main/java/com/campus/campus/domain/review/application/dto/response/ocr/ReceiptResultDto.java +++ b/src/main/java/com/campus/campus/domain/review/application/dto/response/ocr/ReceiptResultDto.java @@ -5,7 +5,7 @@ public record ReceiptResultDto( String storeName, - String totalPlace, + String totalPrice, LocalDate paymentDate, List items ) { diff --git a/src/main/java/com/campus/campus/domain/review/application/exception/ErrorCode.java b/src/main/java/com/campus/campus/domain/review/application/exception/ErrorCode.java index 1c522342..75edcd12 100644 --- a/src/main/java/com/campus/campus/domain/review/application/exception/ErrorCode.java +++ b/src/main/java/com/campus/campus/domain/review/application/exception/ErrorCode.java @@ -16,7 +16,8 @@ public enum ErrorCode implements ErrorCodeInterface { RECEIPT_OCR_FAILED(2702, HttpStatus.UNPROCESSABLE_ENTITY, "OCR 인식에 실패하였습니다."), RECEIPT_FILE_CONVERT_ERROR(2703, HttpStatus.UNPROCESSABLE_ENTITY, "영수증 FILE 형태 변형에 실패하였습니다."), RECEIPT_FILE_TYPE_ERROR(2704, HttpStatus.UNPROCESSABLE_ENTITY, "지원하지 않는 이미지 형식입니다."), - NOT_PARTNERSHIP_RECEIPT_ERROR(2705, HttpStatus.UNPROCESSABLE_ENTITY, "영수증과 일치하는 제휴 정보를 찾을 수 없어요."); + NOT_PARTNERSHIP_RECEIPT_ERROR(2705, HttpStatus.UNPROCESSABLE_ENTITY, "영수증과 일치하는 제휴 정보를 찾을 수 없어요."), + RECEIPT_DATE_PARSE_ERROR(2706, HttpStatus.UNPROCESSABLE_ENTITY, "영수증 결제 일자를 파싱하는 도중 오류가 발생했어요."); private final int code; private final HttpStatus status; diff --git a/src/main/java/com/campus/campus/domain/review/application/exception/ReceiptDateParseException.java b/src/main/java/com/campus/campus/domain/review/application/exception/ReceiptDateParseException.java new file mode 100644 index 00000000..31f6b262 --- /dev/null +++ b/src/main/java/com/campus/campus/domain/review/application/exception/ReceiptDateParseException.java @@ -0,0 +1,9 @@ +package com.campus.campus.domain.review.application.exception; + +import com.campus.campus.global.common.exception.ApplicationException; + +public class ReceiptDateParseException extends ApplicationException { + public ReceiptDateParseException() { + super(ErrorCode.RECEIPT_DATE_PARSE_ERROR); + } +} diff --git a/src/main/java/com/campus/campus/domain/review/application/service/OcrService.java b/src/main/java/com/campus/campus/domain/review/application/service/OcrService.java index 79396598..41b62f42 100644 --- a/src/main/java/com/campus/campus/domain/review/application/service/OcrService.java +++ b/src/main/java/com/campus/campus/domain/review/application/service/OcrService.java @@ -3,6 +3,7 @@ import java.io.IOException; import java.time.LocalDate; import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeParseException; import java.util.List; import java.util.Optional; @@ -22,6 +23,7 @@ import com.campus.campus.domain.review.application.dto.response.ocr.StoreInfo; import com.campus.campus.domain.review.application.dto.response.ocr.TextField; import com.campus.campus.domain.review.application.dto.response.ocr.TotalPrice; +import com.campus.campus.domain.review.application.exception.ReceiptDateParseException; import com.campus.campus.domain.review.application.exception.ReceiptFileConvertException; import com.campus.campus.domain.review.application.exception.ReceiptOcrFailedException; import com.campus.campus.domain.review.infrastructure.ClovaOcrClient; @@ -114,8 +116,11 @@ private ReceiptResultDto extractReceiptResult(ReceiptOcrResponse response) { LocalDate paymentDate = Optional.ofNullable(receipt.paymentInfo()) .map(PaymentInfo::date) .map(TextField::text) - .map(text -> LocalDate.parse(text, DateTimeFormatter.BASIC_ISO_DATE)) - .orElse(null); + .map(this::parseDate) + .orElseThrow(() -> { + log.warn("[OCR FAILED] paymentDate missing"); + return new ReceiptOcrFailedException(); + }); //상품 목록 List items = Optional.ofNullable(receipt.subResults()) @@ -144,4 +149,17 @@ private ReceiptResultDto extractReceiptResult(ReceiptOcrResponse response) { private String safeText(TextField field) { return field != null ? field.text() : null; } + + private LocalDate parseDate(String text) { + if (text == null || text.isBlank()) + return null; + // 숫자가 아닌 문자 제거 + String normalized = text.replaceAll("[^0-9]", ""); + try { + return LocalDate.parse(normalized, DateTimeFormatter.BASIC_ISO_DATE); + } catch (DateTimeParseException e) { + log.warn("[OCR DATE PARSE FAILED] text={}", text, e); + throw new ReceiptDateParseException(); + } + } } \ No newline at end of file diff --git a/src/main/java/com/campus/campus/domain/review/application/service/ReviewService.java b/src/main/java/com/campus/campus/domain/review/application/service/ReviewService.java index a40d1889..b3576057 100644 --- a/src/main/java/com/campus/campus/domain/review/application/service/ReviewService.java +++ b/src/main/java/com/campus/campus/domain/review/application/service/ReviewService.java @@ -291,18 +291,12 @@ public ReviewPartnerResponse findPartnership(Long placeId, ReceiptResultDto resu } public double getAverageOfStars(Long placeId) { - List reviews = reviewRepository.findALlByPlace_PlaceId(placeId); - double averageStar = reviews.stream() - .mapToDouble(Review::getStar) - .average() - .orElse(0.0); - return averageStar; + return reviewRepository.findAverageStarByPlaceId(placeId).orElse(0.0); } @Transactional(readOnly = true) public int getReviewCount(Long placeId) { - List reviews = reviewRepository.findALlByPlace_PlaceId(placeId); - return reviews.size(); + return (int)reviewRepository.countByPlace_PlaceId(placeId); } @Transactional(readOnly = true) diff --git a/src/main/java/com/campus/campus/domain/review/domain/entity/ReviewImage.java b/src/main/java/com/campus/campus/domain/review/domain/entity/ReviewImage.java index 08ee39c8..2f04ad3c 100644 --- a/src/main/java/com/campus/campus/domain/review/domain/entity/ReviewImage.java +++ b/src/main/java/com/campus/campus/domain/review/domain/entity/ReviewImage.java @@ -1,7 +1,5 @@ package com.campus.campus.domain.review.domain.entity; -import com.campus.campus.global.entity.BaseEntity; - import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.FetchType; @@ -12,16 +10,16 @@ import jakarta.persistence.ManyToOne; import lombok.AccessLevel; import lombok.AllArgsConstructor; -import lombok.Builder; import lombok.Getter; import lombok.NoArgsConstructor; +import lombok.experimental.SuperBuilder; @Entity -@Builder +@SuperBuilder @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) @AllArgsConstructor(access = AccessLevel.PRIVATE) -public class ReviewImage extends BaseEntity { +public class ReviewImage { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) diff --git a/src/main/java/com/campus/campus/domain/review/domain/repository/ReviewRepository.java b/src/main/java/com/campus/campus/domain/review/domain/repository/ReviewRepository.java index ea9497b3..7421c29e 100644 --- a/src/main/java/com/campus/campus/domain/review/domain/repository/ReviewRepository.java +++ b/src/main/java/com/campus/campus/domain/review/domain/repository/ReviewRepository.java @@ -2,6 +2,7 @@ import java.time.LocalDateTime; import java.util.List; +import java.util.Optional; import java.util.Set; import org.springframework.data.domain.Pageable; @@ -47,6 +48,9 @@ List findAverageStarsByPlaceIds( @Param("placeIds") Set placeIds ); + @Query("SELECT AVG(r.star) FROM Review r WHERE r.place.placeId = :placeId") + Optional findAverageStarByPlaceId(@Param("placeId") Long placeId); + @Query(""" SELECT r.place.placeKey, AVG(r.star) FROM Review r diff --git a/src/main/java/com/campus/campus/domain/review/infrastructure/ClovaOcrClient.java b/src/main/java/com/campus/campus/domain/review/infrastructure/ClovaOcrClient.java index c6fc30ea..ad106f48 100644 --- a/src/main/java/com/campus/campus/domain/review/infrastructure/ClovaOcrClient.java +++ b/src/main/java/com/campus/campus/domain/review/infrastructure/ClovaOcrClient.java @@ -33,11 +33,11 @@ public class ClovaOcrClient { public String requestReceiptOcr(byte[] imageBytes, String originalFilename) { //이미지->Base64 String base64Image = Base64.getEncoder().encodeToString(imageBytes); - extractFormat(originalFilename); + String format = extractFormat(originalFilename); //json body 구성 Map image = new HashMap<>(); - image.put("format", "png"); + image.put("format", format); image.put("data", base64Image); image.put("name", "receipt_test2"); diff --git a/src/main/java/com/campus/campus/global/config/ClovaOcrConfig.java b/src/main/java/com/campus/campus/global/config/ClovaOcrConfig.java index cda349d3..916fc8c5 100644 --- a/src/main/java/com/campus/campus/global/config/ClovaOcrConfig.java +++ b/src/main/java/com/campus/campus/global/config/ClovaOcrConfig.java @@ -2,6 +2,7 @@ import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.http.client.SimpleClientHttpRequestFactory; import org.springframework.web.client.RestTemplate; @Configuration @@ -9,6 +10,12 @@ public class ClovaOcrConfig { @Bean public RestTemplate clovaOcrRestTemplate() { - return new RestTemplate(); + SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory(); + + factory.setConnectTimeout(3_000); // 연결 타임아웃 3초 + factory.setReadTimeout(15_000); // 응답 대기 타임아웃 15초 + + return new RestTemplate(factory); } + } \ No newline at end of file diff --git a/src/main/java/com/campus/campus/global/config/PermitUrlConfig.java b/src/main/java/com/campus/campus/global/config/PermitUrlConfig.java index 171fc2d6..05d9c4a8 100644 --- a/src/main/java/com/campus/campus/global/config/PermitUrlConfig.java +++ b/src/main/java/com/campus/campus/global/config/PermitUrlConfig.java @@ -30,7 +30,6 @@ public String[] getPublicUrl() { "/jwt/token/reissue", "/managers/login", "/places/search", - "/storage/presigned", "/places", "/api/partnership/list", "/api/partnership/map" From dde48562fd418797e69a916d89139e22b0544f18 Mon Sep 17 00:00:00 2001 From: 1winhyun Date: Fri, 16 Jan 2026 17:11:17 +0900 Subject: [PATCH 07/18] =?UTF-8?q?refactor:=20=EB=A6=AC=EC=86=8C=EC=8A=A4?= =?UTF-8?q?=20=ED=8C=8C=EC=9D=BC=20ocr=20=EC=84=A4=EC=A0=95=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/resources/application-dev.yml | 8 +++++++- src/main/resources/application-prod.yml | 8 +++++++- src/test/resources/application-test.yml | 5 +++++ 3 files changed, 19 insertions(+), 2 deletions(-) diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml index c0f861fa..a4173b18 100644 --- a/src/main/resources/application-dev.yml +++ b/src/main/resources/application-dev.yml @@ -66,6 +66,12 @@ map: api-key: ENC(xgLHhhUSoE7yyS+SzF/KNmjs9swnSwQ2M1xKqWeJoXxrmhH/1rVOal2l5mZ9MP5E) server-uri: ENC(ZrgPccnQ3mEqVQTFEvGn6hzhP4xcNn6ISnp3TbBcd1J3jpZPb3hlzQ==) + firebase: credentials: - path: ENC(ChMLHkElyTmc/x8gSGr6O6xmOpdU7QP49zwVdYWdd4M1DjrxQfumH/JB/HyQ2IjL) \ No newline at end of file + path: ENC(ChMLHkElyTmc/x8gSGr6O6xmOpdU7QP49zwVdYWdd4M1DjrxQfumH/JB/HyQ2IjL) + +clova: + ocr: + invoke-url: https://oprno6zgu9.apigw.ntruss.com/custom/v1/49413/b8f236bbccbb9b0008d8d0485d9028faba366671ac72ccab91d2df2bb9c96001/document/receipt + secret-key: ENC(27rOnbpzREczXXFbh6OSHXiqb6BQLhTNZ4VFKfv63lgV9O3ABBrNM8GHOxj3w+G4tp+lHGhazf0=) \ No newline at end of file diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml index 503d5273..41afee14 100644 --- a/src/main/resources/application-prod.yml +++ b/src/main/resources/application-prod.yml @@ -66,6 +66,12 @@ map: api-key: ENC(xgLHhhUSoE7yyS+SzF/KNmjs9swnSwQ2M1xKqWeJoXxrmhH/1rVOal2l5mZ9MP5E) server-uri: ENC(pitqB0FTjbgREG33LepbDH3Pobg/8eTTzP882D0V14EEt9fTr1cv+w==) + firebase: credentials: - path: ENC(ChMLHkElyTmc/x8gSGr6O6xmOpdU7QP49zwVdYWdd4M1DjrxQfumH/JB/HyQ2IjL) \ No newline at end of file + path: ENC(ChMLHkElyTmc/x8gSGr6O6xmOpdU7QP49zwVdYWdd4M1DjrxQfumH/JB/HyQ2IjL) + +clova: + ocr: + invoke-url: https://oprno6zgu9.apigw.ntruss.com/custom/v1/49413/b8f236bbccbb9b0008d8d0485d9028faba366671ac72ccab91d2df2bb9c96001/document/receipt + secret-key: ENC(27rOnbpzREczXXFbh6OSHXiqb6BQLhTNZ4VFKfv63lgV9O3ABBrNM8GHOxj3w+G4tp+lHGhazf0=) \ No newline at end of file diff --git a/src/test/resources/application-test.yml b/src/test/resources/application-test.yml index ecfb2623..c2d4ded3 100644 --- a/src/test/resources/application-test.yml +++ b/src/test/resources/application-test.yml @@ -61,6 +61,11 @@ oci: private-key: dummy passphrase: "" +clova: + ocr: + invoke-url: dummy + secret-key: dummy + server-uri: http://localhost management: From 42923486f37830bff7bf929f7be500f9eafdbd13 Mon Sep 17 00:00:00 2001 From: minsikk <131092169+1224kang@users.noreply.github.com> Date: Fri, 16 Jan 2026 18:27:13 +0900 Subject: [PATCH 08/18] =?UTF-8?q?fix:=20PR=202=EC=B0=A8=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../repository/StudentCouncilRepository.java | 6 +- .../place/application/mapper/PlaceMapper.java | 7 ++- .../application/service/PlaceService.java | 6 +- .../dto/response/ReviewPartnerResponse.java | 4 +- .../dto/response/ocr/ImageResult.java | 6 -- .../dto/response/ocr/PaymentInfo.java | 7 --- .../dto/response/ocr/PriceInfo.java | 7 --- .../dto/response/ocr/ReceiptOcrItem.java | 7 --- .../dto/response/ocr/ReceiptOcrResponse.java | 63 +++++++++++++++++++ .../dto/response/ocr/ReceiptResult.java | 11 ---- .../dto/response/ocr/ReceiptWrapper.java | 6 -- .../dto/response/ocr/StoreInfo.java | 6 -- .../dto/response/ocr/SubResult.java | 8 --- .../dto/response/ocr/TextField.java | 13 ---- .../dto/response/ocr/TotalPrice.java | 6 -- .../application/mapper/ReviewMapper.java | 30 +++++++++ .../application/service/OcrService.java | 44 ++++--------- .../application/service/ReviewService.java | 13 +++- .../review/domain/entity/ReviewImage.java | 4 +- .../domain/repository/ReviewRepository.java | 2 - 20 files changed, 134 insertions(+), 122 deletions(-) delete mode 100644 src/main/java/com/campus/campus/domain/review/application/dto/response/ocr/ImageResult.java delete mode 100644 src/main/java/com/campus/campus/domain/review/application/dto/response/ocr/PaymentInfo.java delete mode 100644 src/main/java/com/campus/campus/domain/review/application/dto/response/ocr/PriceInfo.java delete mode 100644 src/main/java/com/campus/campus/domain/review/application/dto/response/ocr/ReceiptOcrItem.java delete mode 100644 src/main/java/com/campus/campus/domain/review/application/dto/response/ocr/ReceiptResult.java delete mode 100644 src/main/java/com/campus/campus/domain/review/application/dto/response/ocr/ReceiptWrapper.java delete mode 100644 src/main/java/com/campus/campus/domain/review/application/dto/response/ocr/StoreInfo.java delete mode 100644 src/main/java/com/campus/campus/domain/review/application/dto/response/ocr/SubResult.java delete mode 100644 src/main/java/com/campus/campus/domain/review/application/dto/response/ocr/TextField.java delete mode 100644 src/main/java/com/campus/campus/domain/review/application/dto/response/ocr/TotalPrice.java diff --git a/src/main/java/com/campus/campus/domain/council/domain/repository/StudentCouncilRepository.java b/src/main/java/com/campus/campus/domain/council/domain/repository/StudentCouncilRepository.java index f4cc6481..a43eed4d 100644 --- a/src/main/java/com/campus/campus/domain/council/domain/repository/StudentCouncilRepository.java +++ b/src/main/java/com/campus/campus/domain/council/domain/repository/StudentCouncilRepository.java @@ -45,9 +45,9 @@ Optional findByIdWithDetailsAndManagerApprovedIsTrueAndDeletedAt boolean existsByLoginIdAndManagerApprovedIsTrueAndDeletedAtIsNull(String loginId); - Optional findByMajor_MajorId(Long majorId); + Optional findByMajor_MajorIdAndDeletedAtIsNull(Long majorId); - Optional findByCollege_CollegeId(Long collegeId); + Optional findByCollege_CollegeIdAndDeletedAtIsNull(Long collegeId); - Optional findBySchool_SchoolId(Long schoolId); + Optional findBySchool_SchoolIdAndDeletedAtIsNull(Long schoolId); } diff --git a/src/main/java/com/campus/campus/domain/place/application/mapper/PlaceMapper.java b/src/main/java/com/campus/campus/domain/place/application/mapper/PlaceMapper.java index 803ecfd8..391da2b5 100644 --- a/src/main/java/com/campus/campus/domain/place/application/mapper/PlaceMapper.java +++ b/src/main/java/com/campus/campus/domain/place/application/mapper/PlaceMapper.java @@ -118,13 +118,16 @@ public PlaceDetailResponse toPlaceDetailResponse(Place place, boolean isLiked, ); } - public ReviewPartnerResponse toReviewPartnerResponse(StudentCouncilPost post, Place place, double averageStar) { + public ReviewPartnerResponse toReviewPartnerResponse(StudentCouncilPost post, Place place, double averageStar, + String tag, boolean isLiked) { return new ReviewPartnerResponse( place.getPlaceName(), place.getPlaceCategory(), post.getWriter().getCouncilName(), averageStar, //리뷰 별점 - post.getTitle() + post.getTitle(), + tag, + isLiked ); } diff --git a/src/main/java/com/campus/campus/domain/place/application/service/PlaceService.java b/src/main/java/com/campus/campus/domain/place/application/service/PlaceService.java index 419b450d..a8cf5ae5 100644 --- a/src/main/java/com/campus/campus/domain/place/application/service/PlaceService.java +++ b/src/main/java/com/campus/campus/domain/place/application/service/PlaceService.java @@ -340,19 +340,19 @@ private List resolveCouncils(User user) { List councils = new ArrayList<>(); if (user.getMajor() != null) { - studentCouncilRepository.findByMajor_MajorId( + studentCouncilRepository.findByMajor_MajorIdAndDeletedAtIsNull( user.getMajor().getMajorId() ).ifPresent(councils::add); } if (user.getCollege() != null) { - studentCouncilRepository.findByCollege_CollegeId( + studentCouncilRepository.findByCollege_CollegeIdAndDeletedAtIsNull( user.getCollege().getCollegeId() ).ifPresent(councils::add); } if (user.getSchool() != null) { - studentCouncilRepository.findBySchool_SchoolId( + studentCouncilRepository.findBySchool_SchoolIdAndDeletedAtIsNull( user.getSchool().getSchoolId() ).ifPresent(councils::add); } diff --git a/src/main/java/com/campus/campus/domain/review/application/dto/response/ReviewPartnerResponse.java b/src/main/java/com/campus/campus/domain/review/application/dto/response/ReviewPartnerResponse.java index 97a28384..83bbd493 100644 --- a/src/main/java/com/campus/campus/domain/review/application/dto/response/ReviewPartnerResponse.java +++ b/src/main/java/com/campus/campus/domain/review/application/dto/response/ReviewPartnerResponse.java @@ -5,6 +5,8 @@ public record ReviewPartnerResponse( String placeCategory, String council, double star, - String title + String title, + String tag, + boolean isLiked ) { } diff --git a/src/main/java/com/campus/campus/domain/review/application/dto/response/ocr/ImageResult.java b/src/main/java/com/campus/campus/domain/review/application/dto/response/ocr/ImageResult.java deleted file mode 100644 index 513570e3..00000000 --- a/src/main/java/com/campus/campus/domain/review/application/dto/response/ocr/ImageResult.java +++ /dev/null @@ -1,6 +0,0 @@ -package com.campus.campus.domain.review.application.dto.response.ocr; - -public record ImageResult( - ReceiptWrapper receipt -) { -} \ No newline at end of file diff --git a/src/main/java/com/campus/campus/domain/review/application/dto/response/ocr/PaymentInfo.java b/src/main/java/com/campus/campus/domain/review/application/dto/response/ocr/PaymentInfo.java deleted file mode 100644 index 9761c16c..00000000 --- a/src/main/java/com/campus/campus/domain/review/application/dto/response/ocr/PaymentInfo.java +++ /dev/null @@ -1,7 +0,0 @@ -package com.campus.campus.domain.review.application.dto.response.ocr; - -public record PaymentInfo( - TextField date, - TotalPrice totalPrice -) { -} \ No newline at end of file diff --git a/src/main/java/com/campus/campus/domain/review/application/dto/response/ocr/PriceInfo.java b/src/main/java/com/campus/campus/domain/review/application/dto/response/ocr/PriceInfo.java deleted file mode 100644 index 398d18a2..00000000 --- a/src/main/java/com/campus/campus/domain/review/application/dto/response/ocr/PriceInfo.java +++ /dev/null @@ -1,7 +0,0 @@ -package com.campus.campus.domain.review.application.dto.response.ocr; - -public record PriceInfo( - TextField price, - TextField unitPrice -) { -} \ No newline at end of file diff --git a/src/main/java/com/campus/campus/domain/review/application/dto/response/ocr/ReceiptOcrItem.java b/src/main/java/com/campus/campus/domain/review/application/dto/response/ocr/ReceiptOcrItem.java deleted file mode 100644 index 86f4f1db..00000000 --- a/src/main/java/com/campus/campus/domain/review/application/dto/response/ocr/ReceiptOcrItem.java +++ /dev/null @@ -1,7 +0,0 @@ -package com.campus.campus.domain.review.application.dto.response.ocr; - -public record ReceiptOcrItem( - TextField name, - PriceInfo price -) { -} \ No newline at end of file diff --git a/src/main/java/com/campus/campus/domain/review/application/dto/response/ocr/ReceiptOcrResponse.java b/src/main/java/com/campus/campus/domain/review/application/dto/response/ocr/ReceiptOcrResponse.java index e0018152..3a379427 100644 --- a/src/main/java/com/campus/campus/domain/review/application/dto/response/ocr/ReceiptOcrResponse.java +++ b/src/main/java/com/campus/campus/domain/review/application/dto/response/ocr/ReceiptOcrResponse.java @@ -5,5 +5,68 @@ public record ReceiptOcrResponse( List images ) { + + public record ImageResult( + ReceiptWrapper receipt + ) { + } + + public record ReceiptWrapper( + ReceiptResult result + ) { + } + + public record ReceiptResult( + StoreInfo storeInfo, + PaymentInfo paymentInfo, + TotalPrice totalPrice, + List subResults + ) { + } + + public record StoreInfo( + TextField name + ) { + } + + public record PaymentInfo( + TextField date, + TotalPrice totalPrice + ) { + } + + public record TotalPrice( + TextField price + ) { + } + + public record SubResult( + List items + ) { + } + + public record TextField( + String text, + Formatted formatted, + Double confidenceScore + ) { + } + + public record Formatted( + String value + ) { + } + + public record ReceiptOcrItem( + TextField name, + PriceInfo price + ) { + } + + public record PriceInfo( + TextField price, + TextField unitPrice + ) { + } } diff --git a/src/main/java/com/campus/campus/domain/review/application/dto/response/ocr/ReceiptResult.java b/src/main/java/com/campus/campus/domain/review/application/dto/response/ocr/ReceiptResult.java deleted file mode 100644 index ae6cd6cb..00000000 --- a/src/main/java/com/campus/campus/domain/review/application/dto/response/ocr/ReceiptResult.java +++ /dev/null @@ -1,11 +0,0 @@ -package com.campus.campus.domain.review.application.dto.response.ocr; - -import java.util.List; - -public record ReceiptResult( - StoreInfo storeInfo, - PaymentInfo paymentInfo, - TotalPrice totalPrice, - List subResults -) { -} \ No newline at end of file diff --git a/src/main/java/com/campus/campus/domain/review/application/dto/response/ocr/ReceiptWrapper.java b/src/main/java/com/campus/campus/domain/review/application/dto/response/ocr/ReceiptWrapper.java deleted file mode 100644 index b1ee4213..00000000 --- a/src/main/java/com/campus/campus/domain/review/application/dto/response/ocr/ReceiptWrapper.java +++ /dev/null @@ -1,6 +0,0 @@ -package com.campus.campus.domain.review.application.dto.response.ocr; - -public record ReceiptWrapper( - ReceiptResult result -) { -} \ No newline at end of file diff --git a/src/main/java/com/campus/campus/domain/review/application/dto/response/ocr/StoreInfo.java b/src/main/java/com/campus/campus/domain/review/application/dto/response/ocr/StoreInfo.java deleted file mode 100644 index 0d6e78d7..00000000 --- a/src/main/java/com/campus/campus/domain/review/application/dto/response/ocr/StoreInfo.java +++ /dev/null @@ -1,6 +0,0 @@ -package com.campus.campus.domain.review.application.dto.response.ocr; - -public record StoreInfo( - TextField name -) { -} \ No newline at end of file diff --git a/src/main/java/com/campus/campus/domain/review/application/dto/response/ocr/SubResult.java b/src/main/java/com/campus/campus/domain/review/application/dto/response/ocr/SubResult.java deleted file mode 100644 index 941cf0c5..00000000 --- a/src/main/java/com/campus/campus/domain/review/application/dto/response/ocr/SubResult.java +++ /dev/null @@ -1,8 +0,0 @@ -package com.campus.campus.domain.review.application.dto.response.ocr; - -import java.util.List; - -public record SubResult( - List items -) { -} \ No newline at end of file diff --git a/src/main/java/com/campus/campus/domain/review/application/dto/response/ocr/TextField.java b/src/main/java/com/campus/campus/domain/review/application/dto/response/ocr/TextField.java deleted file mode 100644 index bffc5292..00000000 --- a/src/main/java/com/campus/campus/domain/review/application/dto/response/ocr/TextField.java +++ /dev/null @@ -1,13 +0,0 @@ -package com.campus.campus.domain.review.application.dto.response.ocr; - -public record TextField( - String text, - Formatted formatted, - Double confidenceScore -) { - public record Formatted( - String value - ) { - - } -} \ No newline at end of file diff --git a/src/main/java/com/campus/campus/domain/review/application/dto/response/ocr/TotalPrice.java b/src/main/java/com/campus/campus/domain/review/application/dto/response/ocr/TotalPrice.java deleted file mode 100644 index 29c6868e..00000000 --- a/src/main/java/com/campus/campus/domain/review/application/dto/response/ocr/TotalPrice.java +++ /dev/null @@ -1,6 +0,0 @@ -package com.campus.campus.domain.review.application.dto.response.ocr; - -public record TotalPrice( - TextField price -) { -} \ No newline at end of file diff --git a/src/main/java/com/campus/campus/domain/review/application/mapper/ReviewMapper.java b/src/main/java/com/campus/campus/domain/review/application/mapper/ReviewMapper.java index b35e6c64..f588b0ad 100644 --- a/src/main/java/com/campus/campus/domain/review/application/mapper/ReviewMapper.java +++ b/src/main/java/com/campus/campus/domain/review/application/mapper/ReviewMapper.java @@ -1,5 +1,6 @@ package com.campus.campus.domain.review.application.mapper; +import java.time.LocalDate; import java.util.Collections; import java.util.List; @@ -17,6 +18,9 @@ import com.campus.campus.domain.review.application.dto.response.ReviewResponse; import com.campus.campus.domain.review.application.dto.response.SimpleReviewResponse; import com.campus.campus.domain.review.application.dto.response.WriteReviewResponse; +import com.campus.campus.domain.review.application.dto.response.ocr.ReceiptItemDto; +import com.campus.campus.domain.review.application.dto.response.ocr.ReceiptOcrResponse; +import com.campus.campus.domain.review.application.dto.response.ocr.ReceiptResultDto; import com.campus.campus.domain.review.domain.entity.Review; import com.campus.campus.domain.review.domain.entity.ReviewImage; import com.campus.campus.domain.user.domain.entity.User; @@ -37,6 +41,32 @@ public Review createReview(ReviewRequest request, User user, Place place) { .build(); } + public ReceiptResultDto toReceiptResultDto( + String storeName, String totalPrice, LocalDate paymentDate, List items + ) { + return new ReceiptResultDto( + storeName, totalPrice, paymentDate, items + ); + } + + public ReceiptItemDto toDto(ReceiptOcrResponse.ReceiptOcrItem item) { + return new ReceiptItemDto( + safeText(item.name()), + extractPriceText(item.price()) + ); + } + + private String safeText(ReceiptOcrResponse.TextField field) { + return field != null ? field.text() : null; + } + + private String extractPriceText(ReceiptOcrResponse.PriceInfo priceInfo) { + if (priceInfo == null) { + return null; + } + return safeText(priceInfo.price()); + } + public CursorPageReviewResponse toEmptyCursorReviewResponse() { return CursorPageReviewResponse.builder() .items(List.of()) diff --git a/src/main/java/com/campus/campus/domain/review/application/service/OcrService.java b/src/main/java/com/campus/campus/domain/review/application/service/OcrService.java index 41b62f42..2ac1914a 100644 --- a/src/main/java/com/campus/campus/domain/review/application/service/OcrService.java +++ b/src/main/java/com/campus/campus/domain/review/application/service/OcrService.java @@ -14,18 +14,13 @@ import com.campus.campus.domain.place.application.service.PlaceService; import com.campus.campus.domain.place.domain.entity.Place; import com.campus.campus.domain.review.application.dto.response.ReviewPartnerResponse; -import com.campus.campus.domain.review.application.dto.response.ocr.PaymentInfo; -import com.campus.campus.domain.review.application.dto.response.ocr.PriceInfo; import com.campus.campus.domain.review.application.dto.response.ocr.ReceiptItemDto; import com.campus.campus.domain.review.application.dto.response.ocr.ReceiptOcrResponse; import com.campus.campus.domain.review.application.dto.response.ocr.ReceiptResultDto; -import com.campus.campus.domain.review.application.dto.response.ocr.ReceiptWrapper; -import com.campus.campus.domain.review.application.dto.response.ocr.StoreInfo; -import com.campus.campus.domain.review.application.dto.response.ocr.TextField; -import com.campus.campus.domain.review.application.dto.response.ocr.TotalPrice; import com.campus.campus.domain.review.application.exception.ReceiptDateParseException; import com.campus.campus.domain.review.application.exception.ReceiptFileConvertException; import com.campus.campus.domain.review.application.exception.ReceiptOcrFailedException; +import com.campus.campus.domain.review.application.mapper.ReviewMapper; import com.campus.campus.domain.review.infrastructure.ClovaOcrClient; import com.fasterxml.jackson.databind.ObjectMapper; @@ -41,6 +36,7 @@ public class OcrService { private final ObjectMapper objectMapper; private final ReviewService reviewService; private final PlaceService placeService; + private final ReviewMapper reviewMapper; public ReviewPartnerResponse processReceipt(MultipartFile file, Long userId, SavedPlaceInfo placeInfo) { Place place = placeService.findOrCreatePlace(placeInfo); @@ -56,7 +52,7 @@ public ReviewPartnerResponse processReceipt(MultipartFile file, Long userId, Sav //ocr String rawResponse = clovaOcrClient.requestReceiptOcr(imageBytes, file.getOriginalFilename()); - log.info("[OCR RAW RESPONSE] {}", rawResponse); + log.debug("[OCR RAW RESPONSE] {}", rawResponse); ReceiptOcrResponse ocrResponse = parse(rawResponse); ReceiptResultDto result = extractReceiptResult(ocrResponse); @@ -87,7 +83,7 @@ private ReceiptResultDto extractReceiptResult(ReceiptOcrResponse response) { }); var receipt = Optional.ofNullable(image.receipt()) - .map(ReceiptWrapper::result) + .map(ReceiptOcrResponse.ReceiptWrapper::result) .orElseThrow(() -> { log.warn("[OCR FAILED] receipt.result is null"); return new ReceiptOcrFailedException(); @@ -95,8 +91,8 @@ private ReceiptResultDto extractReceiptResult(ReceiptOcrResponse response) { //상호명 String storeName = Optional.ofNullable(receipt.storeInfo()) - .map(StoreInfo::name) - .map(TextField::text) + .map(ReceiptOcrResponse.StoreInfo::name) + .map(ReceiptOcrResponse.TextField::text) .orElseThrow(() -> { log.warn("[OCR FAILED] storeName missing"); return new ReceiptOcrFailedException(); @@ -104,8 +100,8 @@ private ReceiptResultDto extractReceiptResult(ReceiptOcrResponse response) { //총액 String totalPrice = Optional.ofNullable(receipt.totalPrice()) - .map(TotalPrice::price) - .map(TextField::text) + .map(ReceiptOcrResponse.TotalPrice::price) + .map(ReceiptOcrResponse.TextField::text) .orElseThrow(() -> { log.warn("[OCR FAILED] totalPrice missing, paymentInfo={}", receipt.paymentInfo()); @@ -114,8 +110,8 @@ private ReceiptResultDto extractReceiptResult(ReceiptOcrResponse response) { //결제일 LocalDate paymentDate = Optional.ofNullable(receipt.paymentInfo()) - .map(PaymentInfo::date) - .map(TextField::text) + .map(ReceiptOcrResponse.PaymentInfo::date) + .map(ReceiptOcrResponse.TextField::text) .map(this::parseDate) .orElseThrow(() -> { log.warn("[OCR FAILED] paymentDate missing"); @@ -127,27 +123,11 @@ private ReceiptResultDto extractReceiptResult(ReceiptOcrResponse response) { .orElse(List.of()) .stream() .flatMap(sr -> Optional.ofNullable(sr.items()).orElse(List.of()).stream()) - .map(i -> new ReceiptItemDto( - safeText(i.name()), - Optional.ofNullable(i.price()) - .map(PriceInfo::price) - .map(TextField::text) - .orElse(null) - )) - + .map(reviewMapper::toDto) .toList(); log.info("[OCR ITEMS] count={}", items.size()); - return new ReceiptResultDto( - storeName, - totalPrice, - paymentDate, - items - ); - } - - private String safeText(TextField field) { - return field != null ? field.text() : null; + return reviewMapper.toReceiptResultDto(storeName, totalPrice, paymentDate, items); } private LocalDate parseDate(String text) { diff --git a/src/main/java/com/campus/campus/domain/review/application/service/ReviewService.java b/src/main/java/com/campus/campus/domain/review/application/service/ReviewService.java index b3576057..13250a20 100644 --- a/src/main/java/com/campus/campus/domain/review/application/service/ReviewService.java +++ b/src/main/java/com/campus/campus/domain/review/application/service/ReviewService.java @@ -15,12 +15,15 @@ import org.springframework.transaction.annotation.Transactional; import com.campus.campus.domain.council.domain.entity.CouncilType; +import com.campus.campus.domain.councilpost.application.exception.PlaceInfoNotFoundException; import com.campus.campus.domain.councilpost.application.exception.PostImageLimitExceededException; import com.campus.campus.domain.councilpost.domain.entity.StudentCouncilPost; import com.campus.campus.domain.councilpost.domain.repository.StudentCouncilPostRepository; import com.campus.campus.domain.place.application.mapper.PlaceMapper; import com.campus.campus.domain.place.application.service.PlaceService; import com.campus.campus.domain.place.domain.entity.Place; +import com.campus.campus.domain.place.domain.repository.LikedPlacesRepository; +import com.campus.campus.domain.place.domain.repository.PlaceRepository; import com.campus.campus.domain.review.application.dto.request.ReviewRequest; import com.campus.campus.domain.review.application.dto.response.CursorPageReviewResponse; import com.campus.campus.domain.review.application.dto.response.PlaceReviewRankResponse; @@ -69,6 +72,8 @@ public class ReviewService { private final StampService stampService; private final StampRepository stampRepository; private final PlaceMapper placeMapper; + private final LikedPlacesRepository likedPlacesRepository; + private final PlaceRepository placeRepository; @Transactional public ReviewCreateResponse writeReview(ReviewRequest request, Long userId) { @@ -273,6 +278,9 @@ public CursorPageReviewResponse getReviewList( public ReviewPartnerResponse findPartnership(Long placeId, ReceiptResultDto result, Long userId) { User user = userRepository.findById(userId) .orElseThrow(UserNotFoundException::new); + Place place = placeRepository.findById(placeId) + .orElseThrow(PlaceInfoNotFoundException::new); + Long majorId = user.getMajor().getMajorId(); Long collegeId = user.getCollege().getCollegeId(); Long schoolId = user.getSchool().getSchoolId(); @@ -287,7 +295,10 @@ public ReviewPartnerResponse findPartnership(Long placeId, ReceiptResultDto resu ).orElseThrow(NotPartnershipReceiptException::new); double averageStar = getAverageOfStars(placeId); - return placeMapper.toReviewPartnerResponse(post, post.getPlace(), averageStar); + String writer = post.getWriter().getCouncilName(); + + boolean isLiked = likedPlacesRepository.existsByUserAndPlace(user, place); + return placeMapper.toReviewPartnerResponse(post, post.getPlace(), averageStar, writer, isLiked); } public double getAverageOfStars(Long placeId) { diff --git a/src/main/java/com/campus/campus/domain/review/domain/entity/ReviewImage.java b/src/main/java/com/campus/campus/domain/review/domain/entity/ReviewImage.java index 2f04ad3c..45120433 100644 --- a/src/main/java/com/campus/campus/domain/review/domain/entity/ReviewImage.java +++ b/src/main/java/com/campus/campus/domain/review/domain/entity/ReviewImage.java @@ -1,5 +1,7 @@ package com.campus.campus.domain.review.domain.entity; +import com.campus.campus.global.entity.BaseEntity; + import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.FetchType; @@ -19,7 +21,7 @@ @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) @AllArgsConstructor(access = AccessLevel.PRIVATE) -public class ReviewImage { +public class ReviewImage extends BaseEntity { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) diff --git a/src/main/java/com/campus/campus/domain/review/domain/repository/ReviewRepository.java b/src/main/java/com/campus/campus/domain/review/domain/repository/ReviewRepository.java index 7421c29e..1be7d22d 100644 --- a/src/main/java/com/campus/campus/domain/review/domain/repository/ReviewRepository.java +++ b/src/main/java/com/campus/campus/domain/review/domain/repository/ReviewRepository.java @@ -69,7 +69,5 @@ SELECT r.place.placeKey, AVG(r.star) long countByPlace_PlaceIdAndUser_School_SchoolId(Long placeId, long schoolId); - List findALlByPlace_PlaceId(long placeId); - List findTop3ByPlace_PlaceIdOrderByCreatedAtDesc(Long placeId); } From aea391fbbbe72ffb4d4a9977de1c4a4107aacb79 Mon Sep 17 00:00:00 2001 From: 1winhyun Date: Fri, 16 Jan 2026 22:00:49 +0900 Subject: [PATCH 09/18] =?UTF-8?q?refactor:=20=EB=A6=AC=EC=86=8C=EC=8A=A4?= =?UTF-8?q?=20=ED=8C=8C=EC=9D=BC=20ocr=20=EC=82=AC=EC=A7=84=20=EC=B5=9C?= =?UTF-8?q?=EB=8C=80=20=ED=81=AC=EA=B8=B0=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/resources/application-dev.yml | 4 ++++ src/main/resources/application-local.yml | 4 ++++ src/main/resources/application-prod.yml | 4 ++++ 3 files changed, 12 insertions(+) diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml index a4173b18..65e196e1 100644 --- a/src/main/resources/application-dev.yml +++ b/src/main/resources/application-dev.yml @@ -30,6 +30,10 @@ spring: host: ENC(wMI0GBAvI1WHsnAjmwy3xA==) port: 6379 password: ENC(Ix+lo7WCNZdyhr3vfXPd3A==) + servlet: + multipart: + max-file-size: 10MB + max-request-size: 10MB oauth: kakao: diff --git a/src/main/resources/application-local.yml b/src/main/resources/application-local.yml index df7f701e..88f72de9 100644 --- a/src/main/resources/application-local.yml +++ b/src/main/resources/application-local.yml @@ -30,6 +30,10 @@ spring: host: localhost port: 6379 password: ${REDIS_PASSWORD} + servlet: + multipart: + max-file-size: 10MB + max-request-size: 10MB oauth: kakao: diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml index 41afee14..7bfb2a6c 100644 --- a/src/main/resources/application-prod.yml +++ b/src/main/resources/application-prod.yml @@ -30,6 +30,10 @@ spring: host: ENC(wMI0GBAvI1WHsnAjmwy3xA==) port: 6379 password: ENC(Ix+lo7WCNZdyhr3vfXPd3A==) + servlet: + multipart: + max-file-size: 10MB + max-request-size: 10MB oauth: kakao: From 9feaf72adf14c9757222b0cd89ccb0fc524340ee Mon Sep 17 00:00:00 2001 From: 1winhyun Date: Fri, 16 Jan 2026 22:01:49 +0900 Subject: [PATCH 10/18] =?UTF-8?q?refactor:=20OcrConfig=20RestTemplate=20?= =?UTF-8?q?=EC=82=AC=EC=9A=A9=EC=97=90=EC=84=9C=20RestClient=20=EC=82=AC?= =?UTF-8?q?=EC=9A=A9=EC=9C=BC=EB=A1=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../review/infrastructure/ClovaOcrClient.java | 28 +++++++------------ .../campus/global/config/ClovaOcrConfig.java | 11 ++++---- 2 files changed, 16 insertions(+), 23 deletions(-) diff --git a/src/main/java/com/campus/campus/domain/review/infrastructure/ClovaOcrClient.java b/src/main/java/com/campus/campus/domain/review/infrastructure/ClovaOcrClient.java index ad106f48..e7b2c821 100644 --- a/src/main/java/com/campus/campus/domain/review/infrastructure/ClovaOcrClient.java +++ b/src/main/java/com/campus/campus/domain/review/infrastructure/ClovaOcrClient.java @@ -7,12 +7,9 @@ import java.util.UUID; import org.springframework.beans.factory.annotation.Value; -import org.springframework.http.HttpEntity; -import org.springframework.http.HttpHeaders; import org.springframework.http.MediaType; -import org.springframework.http.ResponseEntity; import org.springframework.stereotype.Component; -import org.springframework.web.client.RestTemplate; +import org.springframework.web.client.RestClient; import com.campus.campus.domain.review.application.exception.ReceiptImageFormatException; @@ -22,7 +19,7 @@ @RequiredArgsConstructor public class ClovaOcrClient { - private final RestTemplate restTemplate; + private final RestClient clovaOcrRestClient; @Value("${clova.ocr.invoke-url}") private String invokeUrl; @@ -48,17 +45,13 @@ public String requestReceiptOcr(byte[] imageBytes, String originalFilename) { body.put("timestamp", System.currentTimeMillis()); body.put("images", List.of(image)); - //header - HttpHeaders headers = new HttpHeaders(); - headers.set("X-OCR-SECRET", secretKey); - headers.setContentType(MediaType.APPLICATION_JSON); - - HttpEntity> request = new HttpEntity<>(body, headers); - - //호출 - ResponseEntity response = - restTemplate.postForEntity(invokeUrl, request, String.class); - return response.getBody(); + return clovaOcrRestClient.post() + .uri(invokeUrl) + .header("X-OCR-SECRET", secretKey) + .contentType(MediaType.APPLICATION_JSON) + .body(body) + .retrieve() // 응답 받기 시작 + .body(String.class); } private String extractFormat(String originalFilename) { @@ -78,5 +71,4 @@ private String extractFormat(String originalFilename) { throw new ReceiptImageFormatException(); } - -} \ No newline at end of file +} diff --git a/src/main/java/com/campus/campus/global/config/ClovaOcrConfig.java b/src/main/java/com/campus/campus/global/config/ClovaOcrConfig.java index 916fc8c5..6928ba3b 100644 --- a/src/main/java/com/campus/campus/global/config/ClovaOcrConfig.java +++ b/src/main/java/com/campus/campus/global/config/ClovaOcrConfig.java @@ -3,19 +3,20 @@ import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.http.client.SimpleClientHttpRequestFactory; -import org.springframework.web.client.RestTemplate; +import org.springframework.web.client.RestClient; @Configuration public class ClovaOcrConfig { @Bean - public RestTemplate clovaOcrRestTemplate() { + public RestClient clovaOcrRestClient() { SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory(); factory.setConnectTimeout(3_000); // 연결 타임아웃 3초 factory.setReadTimeout(15_000); // 응답 대기 타임아웃 15초 - return new RestTemplate(factory); + return RestClient.builder() + .requestFactory(factory) + .build(); } - -} \ No newline at end of file +} From 66037b433825da8cc98a685b06f870d715f7e0ba Mon Sep 17 00:00:00 2001 From: 1winhyun Date: Fri, 16 Jan 2026 22:02:27 +0900 Subject: [PATCH 11/18] =?UTF-8?q?refactor:=20OCR=20=EC=9D=B8=EC=A6=9D=20Sa?= =?UTF-8?q?vedPlaceInfo=EA=B0=80=20=EC=95=84=EB=8B=8C=20placeId=EB=A1=9C?= =?UTF-8?q?=20=EA=B0=80=EC=A0=B8=EC=98=A4=EB=8F=84=EB=A1=9D=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../review/application/service/OcrService.java | 17 ++++++++++------- .../review/presentation/ReviewController.java | 6 ++---- 2 files changed, 12 insertions(+), 11 deletions(-) diff --git a/src/main/java/com/campus/campus/domain/review/application/service/OcrService.java b/src/main/java/com/campus/campus/domain/review/application/service/OcrService.java index 2ac1914a..59b83c38 100644 --- a/src/main/java/com/campus/campus/domain/review/application/service/OcrService.java +++ b/src/main/java/com/campus/campus/domain/review/application/service/OcrService.java @@ -10,9 +10,10 @@ import org.springframework.stereotype.Service; import org.springframework.web.multipart.MultipartFile; -import com.campus.campus.domain.place.application.dto.response.SavedPlaceInfo; +import com.campus.campus.domain.councilpost.application.exception.PlaceInfoNotFoundException; import com.campus.campus.domain.place.application.service.PlaceService; import com.campus.campus.domain.place.domain.entity.Place; +import com.campus.campus.domain.place.domain.repository.PlaceRepository; import com.campus.campus.domain.review.application.dto.response.ReviewPartnerResponse; import com.campus.campus.domain.review.application.dto.response.ocr.ReceiptItemDto; import com.campus.campus.domain.review.application.dto.response.ocr.ReceiptOcrResponse; @@ -37,10 +38,11 @@ public class OcrService { private final ReviewService reviewService; private final PlaceService placeService; private final ReviewMapper reviewMapper; + private final PlaceRepository placeRepository; - public ReviewPartnerResponse processReceipt(MultipartFile file, Long userId, SavedPlaceInfo placeInfo) { - Place place = placeService.findOrCreatePlace(placeInfo); - Long placeId = place.getPlaceId(); + public ReviewPartnerResponse processReceipt(MultipartFile file, Long userId, Long placeId) { + Place place = placeRepository.findById(placeId) + .orElseThrow(PlaceInfoNotFoundException::new); //MultipartFIle -> byte[] byte[] imageBytes; @@ -58,7 +60,7 @@ public ReviewPartnerResponse processReceipt(MultipartFile file, Long userId, Sav ReceiptResultDto result = extractReceiptResult(ocrResponse); log.info("영수증 ocr 인식 결과:{}", result); - return reviewService.findPartnership(placeId, result, userId); + return reviewService.findPartnership(place.getPlaceId(), result, userId); } private ReceiptOcrResponse parse(String json) { @@ -74,8 +76,9 @@ private ReceiptOcrResponse parse(String json) { private ReceiptResultDto extractReceiptResult(ReceiptOcrResponse response) { log.info("[OCR RESPONSE] images size={}", response.images() != null ? response.images().size() : null); - //images 존재 검증 - var image = response.images().stream() + + var images = Optional.ofNullable(response.images()).orElse(List.of()); + var image = images.stream() .findFirst() .orElseThrow(() -> { log.warn("[OCR FAILED] images empty"); diff --git a/src/main/java/com/campus/campus/domain/review/presentation/ReviewController.java b/src/main/java/com/campus/campus/domain/review/presentation/ReviewController.java index a5ffd948..a2bcb796 100644 --- a/src/main/java/com/campus/campus/domain/review/presentation/ReviewController.java +++ b/src/main/java/com/campus/campus/domain/review/presentation/ReviewController.java @@ -17,7 +17,6 @@ import org.springframework.web.bind.annotation.RestController; import org.springframework.web.multipart.MultipartFile; -import com.campus.campus.domain.place.application.dto.response.SavedPlaceInfo; import com.campus.campus.domain.review.application.dto.request.ReviewRequest; import com.campus.campus.domain.review.application.dto.response.CursorPageReviewResponse; import com.campus.campus.domain.review.application.dto.response.PlaceReviewRankResponse; @@ -108,11 +107,10 @@ public CommonResponse writeReview( ) public CommonResponse upload( @RequestPart("file") MultipartFile file, - @RequestPart("request") SavedPlaceInfo placeInfo, + @RequestParam("placeId") Long placeId, @CurrentUserId Long userId ) { - return CommonResponse.success(ReviewResponseCode.OCR_SUCCESS, - ocrService.processReceipt(file, userId, placeInfo)); + return CommonResponse.success(ReviewResponseCode.OCR_SUCCESS, ocrService.processReceipt(file, userId, placeId)); } @GetMapping("/{reviewId}") From 7a9f98ffb3625323e941ac2910b84bcad9e6bc14 Mon Sep 17 00:00:00 2001 From: 1winhyun Date: Fri, 16 Jan 2026 22:20:36 +0900 Subject: [PATCH 12/18] =?UTF-8?q?refactor:=20OCR=20=EC=9D=B8=EC=A6=9D=20?= =?UTF-8?q?=EB=B0=98=ED=99=98=EA=B0=92=EC=97=90=20paymentDate=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../campus/domain/place/application/mapper/PlaceMapper.java | 6 ++++-- .../application/dto/response/ReviewPartnerResponse.java | 5 ++++- .../domain/review/application/service/ReviewService.java | 6 +++--- 3 files changed, 11 insertions(+), 6 deletions(-) diff --git a/src/main/java/com/campus/campus/domain/place/application/mapper/PlaceMapper.java b/src/main/java/com/campus/campus/domain/place/application/mapper/PlaceMapper.java index 391da2b5..947f1535 100644 --- a/src/main/java/com/campus/campus/domain/place/application/mapper/PlaceMapper.java +++ b/src/main/java/com/campus/campus/domain/place/application/mapper/PlaceMapper.java @@ -1,5 +1,6 @@ package com.campus.campus.domain.place.application.mapper; +import java.time.LocalDate; import java.util.List; import org.springframework.stereotype.Component; @@ -119,7 +120,7 @@ public PlaceDetailResponse toPlaceDetailResponse(Place place, boolean isLiked, } public ReviewPartnerResponse toReviewPartnerResponse(StudentCouncilPost post, Place place, double averageStar, - String tag, boolean isLiked) { + String tag, boolean isLiked, LocalDate paymentDate) { return new ReviewPartnerResponse( place.getPlaceName(), place.getPlaceCategory(), @@ -127,7 +128,8 @@ public ReviewPartnerResponse toReviewPartnerResponse(StudentCouncilPost post, Pl averageStar, //리뷰 별점 post.getTitle(), tag, - isLiked + isLiked, + paymentDate ); } diff --git a/src/main/java/com/campus/campus/domain/review/application/dto/response/ReviewPartnerResponse.java b/src/main/java/com/campus/campus/domain/review/application/dto/response/ReviewPartnerResponse.java index 83bbd493..3c1f902c 100644 --- a/src/main/java/com/campus/campus/domain/review/application/dto/response/ReviewPartnerResponse.java +++ b/src/main/java/com/campus/campus/domain/review/application/dto/response/ReviewPartnerResponse.java @@ -1,5 +1,7 @@ package com.campus.campus.domain.review.application.dto.response; +import java.time.LocalDate; + public record ReviewPartnerResponse( String placeName, String placeCategory, @@ -7,6 +9,7 @@ public record ReviewPartnerResponse( double star, String title, String tag, - boolean isLiked + boolean isLiked, + LocalDate paymentDate ) { } diff --git a/src/main/java/com/campus/campus/domain/review/application/service/ReviewService.java b/src/main/java/com/campus/campus/domain/review/application/service/ReviewService.java index 13250a20..9688e7f4 100644 --- a/src/main/java/com/campus/campus/domain/review/application/service/ReviewService.java +++ b/src/main/java/com/campus/campus/domain/review/application/service/ReviewService.java @@ -285,8 +285,8 @@ public ReviewPartnerResponse findPartnership(Long placeId, ReceiptResultDto resu Long collegeId = user.getCollege().getCollegeId(); Long schoolId = user.getSchool().getSchoolId(); - LocalDate paymentDateTime = result.paymentDate(); - LocalDateTime time = paymentDateTime.atStartOfDay(); //시간은 우선 임의로 + LocalDate paymentDate = result.paymentDate(); + LocalDateTime time = paymentDate.atStartOfDay(); //시간은 우선 임의로 //제휴기간 내에 결제 했는지 확인 StudentCouncilPost post = studentCouncilPostRepository.findValidPartnershipForUserScope( @@ -298,7 +298,7 @@ public ReviewPartnerResponse findPartnership(Long placeId, ReceiptResultDto resu String writer = post.getWriter().getCouncilName(); boolean isLiked = likedPlacesRepository.existsByUserAndPlace(user, place); - return placeMapper.toReviewPartnerResponse(post, post.getPlace(), averageStar, writer, isLiked); + return placeMapper.toReviewPartnerResponse(post, post.getPlace(), averageStar, writer, isLiked, paymentDate); } public double getAverageOfStars(Long placeId) { From 8feb4ea863089860f1cf9e423e6a4622d8f4f1d6 Mon Sep 17 00:00:00 2001 From: 1winhyun Date: Fri, 16 Jan 2026 22:23:43 +0900 Subject: [PATCH 13/18] =?UTF-8?q?refactor:=20=EC=BB=A8=EB=B2=A4=EC=85=98?= =?UTF-8?q?=20=EA=B0=9C=ED=96=89=20=EC=B2=98=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../review/application/dto/response/PlaceStarAvgRow.java | 2 +- .../review/application/dto/response/SimpleReviewResponse.java | 2 +- .../review/application/dto/response/ocr/ReceiptItemDto.java | 2 +- .../application/dto/response/ocr/ReceiptOcrResponse.java | 1 - .../review/application/dto/response/ocr/ReceiptResultDto.java | 2 +- .../campus/domain/review/application/exception/ErrorCode.java | 3 +-- .../application/exception/NotPartnershipReceiptException.java | 2 +- .../application/exception/ReceiptFileConvertException.java | 2 +- .../application/exception/ReceiptImageFormatException.java | 2 +- .../application/exception/ReceiptOcrFailedException.java | 2 +- .../campus/domain/review/application/service/OcrService.java | 1 - .../domain/review/application/service/ReviewService.java | 1 - 12 files changed, 9 insertions(+), 13 deletions(-) diff --git a/src/main/java/com/campus/campus/domain/review/application/dto/response/PlaceStarAvgRow.java b/src/main/java/com/campus/campus/domain/review/application/dto/response/PlaceStarAvgRow.java index c6e6a957..98746501 100644 --- a/src/main/java/com/campus/campus/domain/review/application/dto/response/PlaceStarAvgRow.java +++ b/src/main/java/com/campus/campus/domain/review/application/dto/response/PlaceStarAvgRow.java @@ -4,4 +4,4 @@ public record PlaceStarAvgRow( Long placeId, Double avgStar ) { -} \ No newline at end of file +} diff --git a/src/main/java/com/campus/campus/domain/review/application/dto/response/SimpleReviewResponse.java b/src/main/java/com/campus/campus/domain/review/application/dto/response/SimpleReviewResponse.java index f8416e5d..80dedb36 100644 --- a/src/main/java/com/campus/campus/domain/review/application/dto/response/SimpleReviewResponse.java +++ b/src/main/java/com/campus/campus/domain/review/application/dto/response/SimpleReviewResponse.java @@ -9,4 +9,4 @@ public record SimpleReviewResponse( String content, String thumbnailImgUrl ) { -} \ No newline at end of file +} diff --git a/src/main/java/com/campus/campus/domain/review/application/dto/response/ocr/ReceiptItemDto.java b/src/main/java/com/campus/campus/domain/review/application/dto/response/ocr/ReceiptItemDto.java index 5066eb2f..ae252725 100644 --- a/src/main/java/com/campus/campus/domain/review/application/dto/response/ocr/ReceiptItemDto.java +++ b/src/main/java/com/campus/campus/domain/review/application/dto/response/ocr/ReceiptItemDto.java @@ -4,4 +4,4 @@ public record ReceiptItemDto( String name, String price ) { -} \ No newline at end of file +} diff --git a/src/main/java/com/campus/campus/domain/review/application/dto/response/ocr/ReceiptOcrResponse.java b/src/main/java/com/campus/campus/domain/review/application/dto/response/ocr/ReceiptOcrResponse.java index 3a379427..78e0b9fa 100644 --- a/src/main/java/com/campus/campus/domain/review/application/dto/response/ocr/ReceiptOcrResponse.java +++ b/src/main/java/com/campus/campus/domain/review/application/dto/response/ocr/ReceiptOcrResponse.java @@ -69,4 +69,3 @@ public record PriceInfo( ) { } } - diff --git a/src/main/java/com/campus/campus/domain/review/application/dto/response/ocr/ReceiptResultDto.java b/src/main/java/com/campus/campus/domain/review/application/dto/response/ocr/ReceiptResultDto.java index 956003dc..b0baa371 100644 --- a/src/main/java/com/campus/campus/domain/review/application/dto/response/ocr/ReceiptResultDto.java +++ b/src/main/java/com/campus/campus/domain/review/application/dto/response/ocr/ReceiptResultDto.java @@ -9,4 +9,4 @@ public record ReceiptResultDto( LocalDate paymentDate, List items ) { -} \ No newline at end of file +} diff --git a/src/main/java/com/campus/campus/domain/review/application/exception/ErrorCode.java b/src/main/java/com/campus/campus/domain/review/application/exception/ErrorCode.java index 75edcd12..d96eccbc 100644 --- a/src/main/java/com/campus/campus/domain/review/application/exception/ErrorCode.java +++ b/src/main/java/com/campus/campus/domain/review/application/exception/ErrorCode.java @@ -22,5 +22,4 @@ public enum ErrorCode implements ErrorCodeInterface { private final int code; private final HttpStatus status; private final String message; - -} \ No newline at end of file +} diff --git a/src/main/java/com/campus/campus/domain/review/application/exception/NotPartnershipReceiptException.java b/src/main/java/com/campus/campus/domain/review/application/exception/NotPartnershipReceiptException.java index e32e1882..2d36b5ee 100644 --- a/src/main/java/com/campus/campus/domain/review/application/exception/NotPartnershipReceiptException.java +++ b/src/main/java/com/campus/campus/domain/review/application/exception/NotPartnershipReceiptException.java @@ -6,4 +6,4 @@ public class NotPartnershipReceiptException extends ApplicationException { public NotPartnershipReceiptException() { super(ErrorCode.NOT_PARTNERSHIP_RECEIPT_ERROR); } -} \ No newline at end of file +} diff --git a/src/main/java/com/campus/campus/domain/review/application/exception/ReceiptFileConvertException.java b/src/main/java/com/campus/campus/domain/review/application/exception/ReceiptFileConvertException.java index dd0d72c1..6c598f39 100644 --- a/src/main/java/com/campus/campus/domain/review/application/exception/ReceiptFileConvertException.java +++ b/src/main/java/com/campus/campus/domain/review/application/exception/ReceiptFileConvertException.java @@ -6,4 +6,4 @@ public class ReceiptFileConvertException extends ApplicationException { public ReceiptFileConvertException() { super(ErrorCode.RECEIPT_FILE_CONVERT_ERROR); } -} \ No newline at end of file +} diff --git a/src/main/java/com/campus/campus/domain/review/application/exception/ReceiptImageFormatException.java b/src/main/java/com/campus/campus/domain/review/application/exception/ReceiptImageFormatException.java index f357dda9..866b08f7 100644 --- a/src/main/java/com/campus/campus/domain/review/application/exception/ReceiptImageFormatException.java +++ b/src/main/java/com/campus/campus/domain/review/application/exception/ReceiptImageFormatException.java @@ -6,4 +6,4 @@ public class ReceiptImageFormatException extends ApplicationException { public ReceiptImageFormatException() { super(ErrorCode.RECEIPT_FILE_TYPE_ERROR); } -} \ No newline at end of file +} diff --git a/src/main/java/com/campus/campus/domain/review/application/exception/ReceiptOcrFailedException.java b/src/main/java/com/campus/campus/domain/review/application/exception/ReceiptOcrFailedException.java index b8a91368..68a8ed33 100644 --- a/src/main/java/com/campus/campus/domain/review/application/exception/ReceiptOcrFailedException.java +++ b/src/main/java/com/campus/campus/domain/review/application/exception/ReceiptOcrFailedException.java @@ -6,4 +6,4 @@ public class ReceiptOcrFailedException extends ApplicationException { public ReceiptOcrFailedException() { super(ErrorCode.RECEIPT_OCR_FAILED); } -} \ No newline at end of file +} diff --git a/src/main/java/com/campus/campus/domain/review/application/service/OcrService.java b/src/main/java/com/campus/campus/domain/review/application/service/OcrService.java index 59b83c38..16b5056e 100644 --- a/src/main/java/com/campus/campus/domain/review/application/service/OcrService.java +++ b/src/main/java/com/campus/campus/domain/review/application/service/OcrService.java @@ -36,7 +36,6 @@ public class OcrService { private final ClovaOcrClient clovaOcrClient; private final ObjectMapper objectMapper; private final ReviewService reviewService; - private final PlaceService placeService; private final ReviewMapper reviewMapper; private final PlaceRepository placeRepository; diff --git a/src/main/java/com/campus/campus/domain/review/application/service/ReviewService.java b/src/main/java/com/campus/campus/domain/review/application/service/ReviewService.java index 9688e7f4..74b3ead1 100644 --- a/src/main/java/com/campus/campus/domain/review/application/service/ReviewService.java +++ b/src/main/java/com/campus/campus/domain/review/application/service/ReviewService.java @@ -430,5 +430,4 @@ private ReviewRankingResponse getRankingResult(Place place, User user) { collegeRank, school.getSchoolName(), schoolRank); } - } From 1c885fa644d00c6fbc63fc58f074b2c4a262b7f8 Mon Sep 17 00:00:00 2001 From: minsikk <131092169+1224kang@users.noreply.github.com> Date: Fri, 16 Jan 2026 22:56:33 +0900 Subject: [PATCH 14/18] =?UTF-8?q?refactor:=20=EB=A6=AC=EB=B7=B0=20?= =?UTF-8?q?=EC=9E=91=EC=84=B1=20example=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../review/presentation/ReviewController.java | 65 ++++++++++--------- 1 file changed, 35 insertions(+), 30 deletions(-) diff --git a/src/main/java/com/campus/campus/domain/review/presentation/ReviewController.java b/src/main/java/com/campus/campus/domain/review/presentation/ReviewController.java index a2bcb796..196a1127 100644 --- a/src/main/java/com/campus/campus/domain/review/presentation/ReviewController.java +++ b/src/main/java/com/campus/campus/domain/review/presentation/ReviewController.java @@ -30,6 +30,7 @@ import com.campus.campus.global.common.response.CommonResponse; import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; @@ -44,40 +45,44 @@ public class ReviewController { @PostMapping @Operation( summary = "리뷰 작성", - description = "제휴 가게여서 영수증 인증을 마쳤다면 isVerified=True값으로 넘겨주세요.", + description = "제휴 가게여서 영수증 인증을 마쳤다면 isVerified=true 값으로 넘겨주세요.", requestBody = @io.swagger.v3.oas.annotations.parameters.RequestBody( required = true, content = @io.swagger.v3.oas.annotations.media.Content( mediaType = "application/json", - examples = @io.swagger.v3.oas.annotations.media.ExampleObject( - name = "리뷰 작성 요청 예시", - summary = "리뷰 작성 Request Body", - value = """ - { - "content": "아주 정말 맛있습니다. 저의 완전 짱 또간집. 꼭꼮꼬꼬꼭 가세요.", - "star": 3.5, - "imageUrls": [ - "https://image1.jpg", - "https://image2.jpg" - ], - "place": { - "placeName": "숙명여자대학교", - "placeKey": "string", - "address": "서울특별시 용산구 청파로47길 99", - "category": "교육,학문>대학교", - "link": "https://map.naver.com/v5/search/%EC%88%99%EB%AA%85%EC%97%AC%EC%9E%90%EB%8C%80%ED%95%99%EA%B5%90", - "telephone": "010-1234-1234", - "coordinate": { - "latitude": 37.545947, - "longitude": 126.964578 - }, - "imgUrls": [ - "https://place-image1.jpg" - ] - } - } - """ - ) + schema = @Schema(implementation = ReviewRequest.class), + examples = { + @io.swagger.v3.oas.annotations.media.ExampleObject( + name = "리뷰 작성 요청 예시", + summary = "영수증 인증 리뷰 작성", + value = """ + { + "content": "아주 정말 맛있습니다. 저의 완전 짱 또간집. 꼭꼭꼭꼭꼭꼭꼭꼭꼮 가세요.", + "star": 4.5, + "imageUrls": [ + "https://image.campus.com/review/1.jpg", + "https://image.campus.com/review/2.jpg" + ], + "isVerified": true, + "place": { + "placeName": "숙명여자대학교", + "placeKey": "a9f3c0d3b1f74c8a9c2a1d9a7b3e1234", + "address": "서울특별시 용산구 청파로47길 99", + "category": "교육,학문>대학교", + "link": "https://map.naver.com/v5/search/%EC%88%99%EB%AA%85%EC%97%AC%EC%9E%90%EB%8C%80%ED%95%99%EA%B5%90", + "telephone": "02-710-9114", + "coordinate": { + "latitude": 37.545947, + "longitude": 126.964578 + }, + "imgUrls": [ + "https://image.campus.com/place/1.jpg" + ] + } + } + """ + ) + } ) ) ) From 44fd559f2e423c87100e506c36fe617ab176b8b4 Mon Sep 17 00:00:00 2001 From: 1winhyun Date: Sat, 17 Jan 2026 00:44:18 +0900 Subject: [PATCH 15/18] =?UTF-8?q?refactor:=20=EC=A0=9C=ED=9C=B4=20?= =?UTF-8?q?=EC=9E=A5=EC=86=8C=20=EB=A6=AC=EB=B7=B0=EC=99=80=20=EC=A0=9C?= =?UTF-8?q?=ED=9C=B4=20=EC=97=86=EB=8A=94=20=EC=9E=A5=EC=86=8C=20=EB=A6=AC?= =?UTF-8?q?=EB=B7=B0=20=EC=9E=91=EC=84=B1=EC=97=90=20=EB=8C=80=ED=95=9C=20?= =?UTF-8?q?api=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/service/PlaceService.java | 14 +++ .../dto/request/PartnershipReviewRequest.java | 28 +++++ ...ewRequest.java => PlaceReviewRequest.java} | 105 +++++++++--------- .../application/mapper/ReviewMapper.java | 14 ++- .../application/service/ReviewService.java | 45 +++++++- .../review/presentation/ReviewController.java | 24 +++- 6 files changed, 162 insertions(+), 68 deletions(-) create mode 100644 src/main/java/com/campus/campus/domain/review/application/dto/request/PartnershipReviewRequest.java rename src/main/java/com/campus/campus/domain/review/application/dto/request/{ReviewRequest.java => PlaceReviewRequest.java} (88%) diff --git a/src/main/java/com/campus/campus/domain/place/application/service/PlaceService.java b/src/main/java/com/campus/campus/domain/place/application/service/PlaceService.java index a8cf5ae5..c9bc769d 100644 --- a/src/main/java/com/campus/campus/domain/place/application/service/PlaceService.java +++ b/src/main/java/com/campus/campus/domain/place/application/service/PlaceService.java @@ -186,6 +186,20 @@ public Place findOrCreatePlace(SavedPlaceInfo place) { }); } + public Place createPlace(SavedPlaceInfo place) { + try { + Place newPlace = placeRepository.save(placeMapper.createPlace(place)); + + migrateImagesToOci(newPlace.getPlaceKey(), place.imgUrls()); + + return newPlace; + } catch (DataIntegrityViolationException e) { + log.info("해당 키에 대한 장소 동시 생성이 감지되었습니다.: {}", place.placeKey()); + return placeRepository.findByPlaceKey(place.placeKey()) + .orElseThrow(PlaceCreationException::new); + } + } + //제휴 신청 @Transactional public void suggestPartnership(Long userId, SavedPlaceInfo placeInfo) { diff --git a/src/main/java/com/campus/campus/domain/review/application/dto/request/PartnershipReviewRequest.java b/src/main/java/com/campus/campus/domain/review/application/dto/request/PartnershipReviewRequest.java new file mode 100644 index 00000000..c2fdf11a --- /dev/null +++ b/src/main/java/com/campus/campus/domain/review/application/dto/request/PartnershipReviewRequest.java @@ -0,0 +1,28 @@ +package com.campus.campus.domain.review.application.dto.request; + +import java.util.List; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.DecimalMax; +import jakarta.validation.constraints.DecimalMin; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; + +public record PartnershipReviewRequest( + @NotNull + @Size(min = 10, message = "리뷰 내용은 최소 10자 이상이어야 합니다.") + @Schema(example = "아주 정말 맛있습니다. 저의 완전 짱 또간집. 꼭꼮꼬꼬꼭 가세요.") + String content, + + @NotNull + @DecimalMin(value = "0.0", inclusive = true) + @DecimalMax(value = "5.0", inclusive = true) + @Schema(example = "3.5") + Double star, + + @Schema(description = "영수증 리뷰를 하고 오면 isVerified=True로 주세요.") + Boolean isVerified, + + List imageUrls +) { +} diff --git a/src/main/java/com/campus/campus/domain/review/application/dto/request/ReviewRequest.java b/src/main/java/com/campus/campus/domain/review/application/dto/request/PlaceReviewRequest.java similarity index 88% rename from src/main/java/com/campus/campus/domain/review/application/dto/request/ReviewRequest.java rename to src/main/java/com/campus/campus/domain/review/application/dto/request/PlaceReviewRequest.java index c06414ea..787b07f9 100644 --- a/src/main/java/com/campus/campus/domain/review/application/dto/request/ReviewRequest.java +++ b/src/main/java/com/campus/campus/domain/review/application/dto/request/PlaceReviewRequest.java @@ -1,54 +1,51 @@ -package com.campus.campus.domain.review.application.dto.request; - -import java.util.List; - -import com.campus.campus.domain.place.application.dto.response.SavedPlaceInfo; - -import io.swagger.v3.oas.annotations.media.Schema; -import jakarta.validation.Valid; -import jakarta.validation.constraints.DecimalMax; -import jakarta.validation.constraints.DecimalMin; -import jakarta.validation.constraints.NotNull; -import jakarta.validation.constraints.Size; - -public record ReviewRequest( - - @NotNull - @Size(min = 10, message = "리뷰 내용은 최소 10자 이상이어야 합니다.") - @Schema(example = "아주 정말 맛있습니다. 저의 완전 짱 또간집. 꼭꼮꼬꼬꼭 가세요.") - String content, - - @NotNull - @DecimalMin(value = "0.0", inclusive = true) - @DecimalMax(value = "5.0", inclusive = true) - @Schema(example = "3.5") - Double star, - - List imageUrls, - - @Schema(description = "영수증 리뷰를 하고 오면 isVerified=True로 주세요.") - Boolean isVerified, - - @Schema(example = - """ - "placeName": "숙명여자대학교", - "placeKey": "string", - "address": "서울특별시 용산구 청파로47길 99", - "category": "교육,학문>대학교", - "link": "https://map.naver.com/v5/search/%EC%88%99%EB%AA%85%EC%97%AC%EC%9E%90%EB%8C%80%ED%95%99%EA%B5%90+%EC%A0%9C1%EC%BA%A0%ED%8D%BC%EC%8A%A4?c=37.545947,126.964578,15,0,0,0,dh", - "telephone": "010-1234-1234", - "coordinate": { - "latitude": 0.1, - "longitude": 0.1 - }, - "imgUrls": [ - "string" - ] - """, - description = "/search API에서 반환된 결과 중 하나를 선택") - @NotNull - @Valid - SavedPlaceInfo place - -) { -} +package com.campus.campus.domain.review.application.dto.request; + +import java.util.List; + +import com.campus.campus.domain.place.application.dto.response.SavedPlaceInfo; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.Valid; +import jakarta.validation.constraints.DecimalMax; +import jakarta.validation.constraints.DecimalMin; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; + +public record PlaceReviewRequest( + + @NotNull + @Size(min = 10, message = "리뷰 내용은 최소 10자 이상이어야 합니다.") + @Schema(example = "아주 정말 맛있습니다. 저의 완전 짱 또간집. 꼭꼮꼬꼬꼭 가세요.") + String content, + + @NotNull + @DecimalMin(value = "0.0", inclusive = true) + @DecimalMax(value = "5.0", inclusive = true) + @Schema(example = "3.5") + Double star, + + List imageUrls, + + @Schema(example = + """ + "placeName": "숙명여자대학교", + "placeKey": "string", + "address": "서울특별시 용산구 청파로47길 99", + "category": "교육,학문>대학교", + "link": "https://map.naver.com/v5/search/%EC%88%99%EB%AA%85%EC%97%AC%EC%9E%90%EB%8C%80%ED%95%99%EA%B5%90+%EC%A0%9C1%EC%BA%A0%ED%8D%BC%EC%8A%A4?c=37.545947,126.964578,15,0,0,0,dh", + "telephone": "010-1234-1234", + "coordinate": { + "latitude": 0.1, + "longitude": 0.1 + }, + "imgUrls": [ + "string" + ] + """, + description = "/search API에서 반환된 결과 중 하나를 선택") + @NotNull + @Valid + SavedPlaceInfo place + +) { +} diff --git a/src/main/java/com/campus/campus/domain/review/application/mapper/ReviewMapper.java b/src/main/java/com/campus/campus/domain/review/application/mapper/ReviewMapper.java index f588b0ad..898304c3 100644 --- a/src/main/java/com/campus/campus/domain/review/application/mapper/ReviewMapper.java +++ b/src/main/java/com/campus/campus/domain/review/application/mapper/ReviewMapper.java @@ -8,7 +8,8 @@ import com.campus.campus.domain.councilpost.domain.entity.StudentCouncilPost; import com.campus.campus.domain.place.domain.entity.Place; -import com.campus.campus.domain.review.application.dto.request.ReviewRequest; +import com.campus.campus.domain.review.application.dto.request.PartnershipReviewRequest; +import com.campus.campus.domain.review.application.dto.request.PlaceReviewRequest; import com.campus.campus.domain.review.application.dto.response.CursorPageReviewResponse; import com.campus.campus.domain.review.application.dto.response.PlaceReviewRankResponse; import com.campus.campus.domain.review.application.dto.response.RankingScope; @@ -31,7 +32,16 @@ @RequiredArgsConstructor public class ReviewMapper { - public Review createReview(ReviewRequest request, User user, Place place) { + public Review createPlaceReview(PlaceReviewRequest request, User user, Place place) { + return Review.builder() + .user(user) + .content(request.content()) + .star(request.star()) + .place(place) + .build(); + } + + public Review createPartnershipReview(PartnershipReviewRequest request, User user, Place place) { return Review.builder() .user(user) .content(request.content()) diff --git a/src/main/java/com/campus/campus/domain/review/application/service/ReviewService.java b/src/main/java/com/campus/campus/domain/review/application/service/ReviewService.java index 74b3ead1..edc8a748 100644 --- a/src/main/java/com/campus/campus/domain/review/application/service/ReviewService.java +++ b/src/main/java/com/campus/campus/domain/review/application/service/ReviewService.java @@ -24,7 +24,8 @@ import com.campus.campus.domain.place.domain.entity.Place; import com.campus.campus.domain.place.domain.repository.LikedPlacesRepository; import com.campus.campus.domain.place.domain.repository.PlaceRepository; -import com.campus.campus.domain.review.application.dto.request.ReviewRequest; +import com.campus.campus.domain.review.application.dto.request.PartnershipReviewRequest; +import com.campus.campus.domain.review.application.dto.request.PlaceReviewRequest; import com.campus.campus.domain.review.application.dto.response.CursorPageReviewResponse; import com.campus.campus.domain.review.application.dto.response.PlaceReviewRankResponse; import com.campus.campus.domain.review.application.dto.response.PlaceStarAvgRow; @@ -76,7 +77,7 @@ public class ReviewService { private final PlaceRepository placeRepository; @Transactional - public ReviewCreateResponse writeReview(ReviewRequest request, Long userId) { + public ReviewCreateResponse writePlaceReview(PlaceReviewRequest request, Long userId) { User user = userRepository.findById(userId) .orElseThrow(UserNotFoundException::new); @@ -84,9 +85,41 @@ public ReviewCreateResponse writeReview(ReviewRequest request, Long userId) { throw new PostImageLimitExceededException(); } - Place place = placeService.findOrCreatePlace(request.place()); + Place place = placeService.createPlace(request.place()); - Review review = reviewMapper.createReview(request, user, place); + Review review = reviewMapper.createPlaceReview(request, user, place); + reviewRepository.save(review); + + if (request.imageUrls() != null) { + for (String imageUrl : request.imageUrls()) { + reviewImageRepository.save(reviewMapper.createReviewImage(review, imageUrl)); + } + } + + String imageUrl = + (request.imageUrls() == null || request.imageUrls().isEmpty()) + ? null : request.imageUrls().getFirst(); + + WriteReviewResponse response = reviewMapper.toWriteReviewResponse(review, imageUrl); + ReviewCreateResult createResult = getCreateResult(place, user); + ReviewRankingResponse rankingResponse = getRankingResult(place, user); + + return reviewMapper.toReviewCreateResponse(response, createResult, rankingResponse); + } + + @Transactional + public ReviewCreateResponse writePartnershipReview(PartnershipReviewRequest request, Long userId, Long placeId) { + User user = userRepository.findById(userId) + .orElseThrow(UserNotFoundException::new); + + if (request.imageUrls() != null && request.imageUrls().size() > 10) { + throw new PostImageLimitExceededException(); + } + + Place place = placeRepository.findById(placeId) + .orElseThrow(PlaceInfoNotFoundException::new); + + Review review = reviewMapper.createPartnershipReview(request, user, place); reviewRepository.save(review); if (request.imageUrls() != null) { @@ -155,7 +188,7 @@ public void delete(Long userId, Long reviewId) { } @Transactional - public WriteReviewResponse update(Long userId, Long reviewId, ReviewRequest request) { + public WriteReviewResponse update(Long userId, Long reviewId, PlaceReviewRequest request) { if (request.imageUrls() != null && request.imageUrls().size() > 10) { throw new PostImageLimitExceededException(); @@ -366,7 +399,7 @@ public List readPopularPartnerships(Long userId) { } //이미지 삭제 - private void cleanupUnusedImages(List oldImages, ReviewRequest request) { + private void cleanupUnusedImages(List oldImages, PlaceReviewRequest request) { List newUrls = request.imageUrls() == null ? List.of() : request.imageUrls(); Set deleteTargets = new HashSet<>(); diff --git a/src/main/java/com/campus/campus/domain/review/presentation/ReviewController.java b/src/main/java/com/campus/campus/domain/review/presentation/ReviewController.java index 196a1127..76afb43d 100644 --- a/src/main/java/com/campus/campus/domain/review/presentation/ReviewController.java +++ b/src/main/java/com/campus/campus/domain/review/presentation/ReviewController.java @@ -17,7 +17,8 @@ import org.springframework.web.bind.annotation.RestController; import org.springframework.web.multipart.MultipartFile; -import com.campus.campus.domain.review.application.dto.request.ReviewRequest; +import com.campus.campus.domain.review.application.dto.request.PartnershipReviewRequest; +import com.campus.campus.domain.review.application.dto.request.PlaceReviewRequest; import com.campus.campus.domain.review.application.dto.response.CursorPageReviewResponse; import com.campus.campus.domain.review.application.dto.response.PlaceReviewRankResponse; import com.campus.campus.domain.review.application.dto.response.ReviewCreateResponse; @@ -44,13 +45,13 @@ public class ReviewController { @PostMapping @Operation( - summary = "리뷰 작성", + summary = "리뷰 작성(제휴 없음)", description = "제휴 가게여서 영수증 인증을 마쳤다면 isVerified=true 값으로 넘겨주세요.", requestBody = @io.swagger.v3.oas.annotations.parameters.RequestBody( required = true, content = @io.swagger.v3.oas.annotations.media.Content( mediaType = "application/json", - schema = @Schema(implementation = ReviewRequest.class), + schema = @Schema(implementation = PlaceReviewRequest.class), examples = { @io.swagger.v3.oas.annotations.media.ExampleObject( name = "리뷰 작성 요청 예시", @@ -87,10 +88,21 @@ public class ReviewController { ) ) public CommonResponse writeReview( - @Valid @RequestBody ReviewRequest request, + @Valid @RequestBody PlaceReviewRequest request, @CurrentUserId Long userId ) { - ReviewCreateResponse response = reviewService.writeReview(request, userId); + ReviewCreateResponse response = reviewService.writePlaceReview(request, userId); + return CommonResponse.success(ReviewResponseCode.REVIEW_SAVE_SUCCESS, response); + } + + @PostMapping("/partnership/{placeId}") + @Operation(summary = "리뷰 작성(제휴 존재)") + public CommonResponse writePartnershipReview( + @PathVariable Long placeId, + @Valid @RequestBody PartnershipReviewRequest request, + @CurrentUserId Long userId + ) { + ReviewCreateResponse response = reviewService.writePartnershipReview(request, userId, placeId); return CommonResponse.success(ReviewResponseCode.REVIEW_SAVE_SUCCESS, response); } @@ -137,7 +149,7 @@ public CommonResponse deleteReview(@PathVariable Long reviewId, @CurrentUs public CommonResponse updateReview( @CurrentUserId Long userId, @PathVariable Long reviewId, - @RequestBody @Valid ReviewRequest request + @RequestBody @Valid PlaceReviewRequest request ) { WriteReviewResponse response = reviewService.update(userId, reviewId, request); return CommonResponse.success(ReviewResponseCode.REVIEW_UPDATE_SUCCESS, response); From 6d65bada884370f2d0c44ed2f8047cf80181b483 Mon Sep 17 00:00:00 2001 From: 1winhyun Date: Sat, 17 Jan 2026 00:44:35 +0900 Subject: [PATCH 16/18] =?UTF-8?q?refactor:=20PermitUrlConfig=EC=97=90=20ur?= =?UTF-8?q?l=20=EB=B0=9C=EA=B8=89=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/campus/campus/global/config/PermitUrlConfig.java | 1 + 1 file changed, 1 insertion(+) diff --git a/src/main/java/com/campus/campus/global/config/PermitUrlConfig.java b/src/main/java/com/campus/campus/global/config/PermitUrlConfig.java index 05d9c4a8..171fc2d6 100644 --- a/src/main/java/com/campus/campus/global/config/PermitUrlConfig.java +++ b/src/main/java/com/campus/campus/global/config/PermitUrlConfig.java @@ -30,6 +30,7 @@ public String[] getPublicUrl() { "/jwt/token/reissue", "/managers/login", "/places/search", + "/storage/presigned", "/places", "/api/partnership/list", "/api/partnership/map" From d31d05e59c01d4af3ac855b4c4bb513e3a22dc25 Mon Sep 17 00:00:00 2001 From: 1winhyun Date: Sat, 17 Jan 2026 01:39:30 +0900 Subject: [PATCH 17/18] =?UTF-8?q?refactor:=20getPartnershipPlaces=20size?= =?UTF-8?q?=20=ED=8C=8C=EB=9D=BC=EB=AF=B8=ED=84=B0=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../place/application/service/PartnershipPlaceService.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/com/campus/campus/domain/place/application/service/PartnershipPlaceService.java b/src/main/java/com/campus/campus/domain/place/application/service/PartnershipPlaceService.java index 70a0632a..1fdf4536 100644 --- a/src/main/java/com/campus/campus/domain/place/application/service/PartnershipPlaceService.java +++ b/src/main/java/com/campus/campus/domain/place/application/service/PartnershipPlaceService.java @@ -141,7 +141,7 @@ public List getPartnershipPlaces(Long userId, Long cu rounded, averageStar, null, - null + size ); }) .toList(); From 962741494dce32ab6270af0e79a3fa56253baacf Mon Sep 17 00:00:00 2001 From: 1winhyun Date: Sat, 17 Jan 2026 02:01:55 +0900 Subject: [PATCH 18/18] =?UTF-8?q?refactor:=20=EA=B0=9C=ED=96=89=20?= =?UTF-8?q?=EC=B2=98=EB=A6=AC=20=EB=B0=8F=20=EC=A4=91=EB=B3=B5=20=EB=A9=94?= =?UTF-8?q?=EC=84=9C=EB=93=9C=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/dto/CursorResponse.java | 3 +- .../application/dto/NextCursor.java | 3 +- .../dto/response/PlaceDetailResponse.java | 2 +- .../service/PartnershipPlaceService.java | 3 +- .../entity/CouncilPartnershipSuggestion.java | 3 -- .../entity/UserPartnershipSuggestion.java | 3 +- ...ouncilPartnershipSuggestionRepository.java | 3 +- .../domain/repository/PlaceRepository.java | 1 - .../UserPartnershipSuggestionRepository.java | 2 +- .../application/service/ReviewService.java | 31 ++++++++----------- 10 files changed, 22 insertions(+), 32 deletions(-) diff --git a/src/main/java/com/campus/campus/domain/notification/application/dto/CursorResponse.java b/src/main/java/com/campus/campus/domain/notification/application/dto/CursorResponse.java index ad94b32b..34003ccd 100644 --- a/src/main/java/com/campus/campus/domain/notification/application/dto/CursorResponse.java +++ b/src/main/java/com/campus/campus/domain/notification/application/dto/CursorResponse.java @@ -6,4 +6,5 @@ public record CursorResponse( List items, NextCursor nextCursor, boolean hasNext -) {} +) { +} diff --git a/src/main/java/com/campus/campus/domain/notification/application/dto/NextCursor.java b/src/main/java/com/campus/campus/domain/notification/application/dto/NextCursor.java index 85234ffa..57e4040b 100644 --- a/src/main/java/com/campus/campus/domain/notification/application/dto/NextCursor.java +++ b/src/main/java/com/campus/campus/domain/notification/application/dto/NextCursor.java @@ -5,4 +5,5 @@ public record NextCursor( LocalDateTime createdAt, Long id -) {} +) { +} diff --git a/src/main/java/com/campus/campus/domain/place/application/dto/response/PlaceDetailResponse.java b/src/main/java/com/campus/campus/domain/place/application/dto/response/PlaceDetailResponse.java index caa47104..ef2cf640 100644 --- a/src/main/java/com/campus/campus/domain/place/application/dto/response/PlaceDetailResponse.java +++ b/src/main/java/com/campus/campus/domain/place/application/dto/response/PlaceDetailResponse.java @@ -22,4 +22,4 @@ public record PlaceDetailResponse( List reviews, int reviewSize ) implements PlaceDetailView { -} \ No newline at end of file +} diff --git a/src/main/java/com/campus/campus/domain/place/application/service/PartnershipPlaceService.java b/src/main/java/com/campus/campus/domain/place/application/service/PartnershipPlaceService.java index 1fdf4536..383c016d 100644 --- a/src/main/java/com/campus/campus/domain/place/application/service/PartnershipPlaceService.java +++ b/src/main/java/com/campus/campus/domain/place/application/service/PartnershipPlaceService.java @@ -321,5 +321,4 @@ private void validateAcademicInfo(User user) { throw new AcademicInfoNotSetException(); } } - -} \ No newline at end of file +} diff --git a/src/main/java/com/campus/campus/domain/place/domain/entity/CouncilPartnershipSuggestion.java b/src/main/java/com/campus/campus/domain/place/domain/entity/CouncilPartnershipSuggestion.java index d5bbbd5f..af2c1fca 100644 --- a/src/main/java/com/campus/campus/domain/place/domain/entity/CouncilPartnershipSuggestion.java +++ b/src/main/java/com/campus/campus/domain/place/domain/entity/CouncilPartnershipSuggestion.java @@ -58,6 +58,3 @@ public static CouncilPartnershipSuggestion create( return demand; } } - - - diff --git a/src/main/java/com/campus/campus/domain/place/domain/entity/UserPartnershipSuggestion.java b/src/main/java/com/campus/campus/domain/place/domain/entity/UserPartnershipSuggestion.java index d3d53f64..9b600a39 100644 --- a/src/main/java/com/campus/campus/domain/place/domain/entity/UserPartnershipSuggestion.java +++ b/src/main/java/com/campus/campus/domain/place/domain/entity/UserPartnershipSuggestion.java @@ -43,5 +43,4 @@ public static UserPartnershipSuggestion create(User user, Place place) { s.place = place; return s; } - -} \ No newline at end of file +} diff --git a/src/main/java/com/campus/campus/domain/place/domain/repository/CouncilPartnershipSuggestionRepository.java b/src/main/java/com/campus/campus/domain/place/domain/repository/CouncilPartnershipSuggestionRepository.java index 46155c69..a54b3d37 100644 --- a/src/main/java/com/campus/campus/domain/place/domain/repository/CouncilPartnershipSuggestionRepository.java +++ b/src/main/java/com/campus/campus/domain/place/domain/repository/CouncilPartnershipSuggestionRepository.java @@ -26,5 +26,4 @@ Optional findForUpdate( @Param("place") Place place, @Param("council") StudentCouncil council ); - -} \ No newline at end of file +} diff --git a/src/main/java/com/campus/campus/domain/place/domain/repository/PlaceRepository.java b/src/main/java/com/campus/campus/domain/place/domain/repository/PlaceRepository.java index fad50e43..041f8051 100644 --- a/src/main/java/com/campus/campus/domain/place/domain/repository/PlaceRepository.java +++ b/src/main/java/com/campus/campus/domain/place/domain/repository/PlaceRepository.java @@ -1,6 +1,5 @@ package com.campus.campus.domain.place.domain.repository; -import java.util.List; import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; diff --git a/src/main/java/com/campus/campus/domain/place/domain/repository/UserPartnershipSuggestionRepository.java b/src/main/java/com/campus/campus/domain/place/domain/repository/UserPartnershipSuggestionRepository.java index 0cfefac0..38b596de 100644 --- a/src/main/java/com/campus/campus/domain/place/domain/repository/UserPartnershipSuggestionRepository.java +++ b/src/main/java/com/campus/campus/domain/place/domain/repository/UserPartnershipSuggestionRepository.java @@ -9,4 +9,4 @@ public interface UserPartnershipSuggestionRepository extends JpaRepository { boolean existsByUserAndPlace(User user, Place place); -} \ No newline at end of file +} diff --git a/src/main/java/com/campus/campus/domain/review/application/service/ReviewService.java b/src/main/java/com/campus/campus/domain/review/application/service/ReviewService.java index edc8a748..95ffcf3f 100644 --- a/src/main/java/com/campus/campus/domain/review/application/service/ReviewService.java +++ b/src/main/java/com/campus/campus/domain/review/application/service/ReviewService.java @@ -96,15 +96,7 @@ public ReviewCreateResponse writePlaceReview(PlaceReviewRequest request, Long us } } - String imageUrl = - (request.imageUrls() == null || request.imageUrls().isEmpty()) - ? null : request.imageUrls().getFirst(); - - WriteReviewResponse response = reviewMapper.toWriteReviewResponse(review, imageUrl); - ReviewCreateResult createResult = getCreateResult(place, user); - ReviewRankingResponse rankingResponse = getRankingResult(place, user); - - return reviewMapper.toReviewCreateResponse(response, createResult, rankingResponse); + return createReviewResponse(review, place, user, request.imageUrls()); } @Transactional @@ -133,15 +125,7 @@ public ReviewCreateResponse writePartnershipReview(PartnershipReviewRequest requ stampService.grantStampForReview(user, review); } - String imageUrl = - (request.imageUrls() == null || request.imageUrls().isEmpty()) - ? null : request.imageUrls().getFirst(); - - WriteReviewResponse response = reviewMapper.toWriteReviewResponse(review, imageUrl); - ReviewCreateResult createResult = getCreateResult(place, user); - ReviewRankingResponse rankingResponse = getRankingResult(place, user); - - return reviewMapper.toReviewCreateResponse(response, createResult, rankingResponse); + return createReviewResponse(review, place, user, request.imageUrls()); } @Transactional(readOnly = true) @@ -398,6 +382,17 @@ public List readPopularPartnerships(Long userId) { .toList(); } + private ReviewCreateResponse createReviewResponse(Review review, Place place, User user, List imageUrls) { + String mainImageUrl = (imageUrls == null || imageUrls.isEmpty()) + ? null : imageUrls.getFirst(); + + WriteReviewResponse response = reviewMapper.toWriteReviewResponse(review, mainImageUrl); + ReviewCreateResult createResult = getCreateResult(place, user); + ReviewRankingResponse rankingResponse = getRankingResult(place, user); + + return reviewMapper.toReviewCreateResponse(response, createResult, rankingResponse); + } + //이미지 삭제 private void cleanupUnusedImages(List oldImages, PlaceReviewRequest request) { List newUrls = request.imageUrls() == null ? List.of() : request.imageUrls();