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..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 @@ -44,4 +44,10 @@ Optional findByIdWithDetailsAndManagerApprovedIsTrueAndDeletedAt boolean existsByEmailAndDeletedAtIsNotNull(String email); boolean existsByLoginIdAndManagerApprovedIsTrueAndDeletedAtIsNull(String loginId); + + Optional findByMajor_MajorIdAndDeletedAtIsNull(Long majorId); + + Optional findByCollege_CollegeIdAndDeletedAtIsNull(Long collegeId); + + Optional findBySchool_SchoolIdAndDeletedAtIsNull(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..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 @@ -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,34 @@ 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.deletedAt IS NULL + 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 +412,30 @@ List findTodayEvent( @Param("endOfDay") LocalDateTime endOfDay, Pageable pageable ); + + @Query(""" + SELECT p + FROM StudentCouncilPost p + JOIN p.writer w + 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) + 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/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 new file mode 100644 index 00000000..ef2cf640 --- /dev/null +++ b/src/main/java/com/campus/campus/domain/place/application/dto/response/PlaceDetailResponse.java @@ -0,0 +1,25 @@ +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( + boolean isPartnership, + 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 { +} 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..415563c2 --- /dev/null +++ b/src/main/java/com/campus/campus/domain/place/application/dto/response/PlaceDetailView.java @@ -0,0 +1,26 @@ +package com.campus.campus.domain.place.application.dto.response; + +public interface PlaceDetailView { + + boolean isPartnership(); + + 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 57% 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..f35ca20c 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,30 @@ -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( + boolean isPartnership, + 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..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,25 +1,29 @@ package com.campus.campus.domain.place.application.mapper; +import java.time.LocalDate; import java.util.List; import org.springframework.stereotype.Component; 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 +75,11 @@ 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( + true, place.getPlaceId(), place.getPlaceKey(), place.getPlaceName(), @@ -83,11 +89,47 @@ 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(Place place, boolean isLiked, + List imgUrls, double distance, Double averageStar, List reviews, Integer size) { + return new PlaceDetailResponse( + false, + 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, + String tag, boolean isLiked, LocalDate paymentDate) { + return new ReviewPartnerResponse( + place.getPlaceName(), + place.getPlaceCategory(), + post.getWriter().getCouncilName(), + averageStar, //리뷰 별점 + post.getTitle(), + tag, + isLiked, + paymentDate ); } 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..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 @@ -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.getPlace().getPlaceId(), 0.0); + return placeMapper.toPartnershipResponse( user, post, post.getPlace(), isLiked, images, - rounded + rounded, + averageStar, + null, + size ); }) .toList(); @@ -155,26 +173,135 @@ 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()) + ); + } + + //장소 상세 조회 : 제휴 + @Transactional(readOnly = true) + 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) { + Place place = post.getPlace(); + if (place == 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( + 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 +312,13 @@ 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(); } } - } 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..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 @@ -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,55 @@ 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) { + 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.findForUpdate(place, council) + .orElseGet(() -> + partnershipSuggestionRepository.save( + CouncilPartnershipSuggestion.create(place, council) + )); + demand.increase(); + } + + } + //장소 저장 @Transactional public LikeResponse likePlace(SavedPlaceInfo placeInfo, Long userId) { @@ -291,6 +350,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_MajorIdAndDeletedAtIsNull( + user.getMajor().getMajorId() + ).ifPresent(councils::add); + } + + if (user.getCollege() != null) { + studentCouncilRepository.findByCollege_CollegeIdAndDeletedAtIsNull( + user.getCollege().getCollegeId() + ).ifPresent(councils::add); + } + + if (user.getSchool() != null) { + studentCouncilRepository.findBySchool_SchoolIdAndDeletedAtIsNull( + 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..af2c1fca --- /dev/null +++ b/src/main/java/com/campus/campus/domain/place/domain/entity/CouncilPartnershipSuggestion.java @@ -0,0 +1,60 @@ +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.Column; +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 jakarta.persistence.UniqueConstraint; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.experimental.SuperBuilder; + +@Entity +@Table(name = "council_partnership_suggestion", + uniqueConstraints = @UniqueConstraint(columnNames = {"place_id", "student_council_id"})) +@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; + + @Column(nullable = false) + 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..9b600a39 --- /dev/null +++ b/src/main/java/com/campus/campus/domain/place/domain/entity/UserPartnershipSuggestion.java @@ -0,0 +1,46 @@ +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; + } +} 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..a54b3d37 --- /dev/null +++ b/src/main/java/com/campus/campus/domain/place/domain/repository/CouncilPartnershipSuggestionRepository.java @@ -0,0 +1,29 @@ +package com.campus.campus.domain.place.domain.repository; + +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 { + + @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 + ); +} 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/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 new file mode 100644 index 00000000..38b596de --- /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); +} 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/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 95% 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 5d6c11b2..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,51 +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(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/dto/response/PlaceStarAvgRow.java b/src/main/java/com/campus/campus/domain/review/application/dto/response/PlaceStarAvgRow.java new file mode 100644 index 00000000..98746501 --- /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 +) { +} 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..3c1f902c --- /dev/null +++ b/src/main/java/com/campus/campus/domain/review/application/dto/response/ReviewPartnerResponse.java @@ -0,0 +1,15 @@ +package com.campus.campus.domain.review.application.dto.response; + +import java.time.LocalDate; + +public record ReviewPartnerResponse( + String placeName, + String placeCategory, + String council, + double star, + String title, + String tag, + boolean isLiked, + LocalDate paymentDate +) { +} 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..80dedb36 --- /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 +) { +} 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..ae252725 --- /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 +) { +} 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..78e0b9fa --- /dev/null +++ b/src/main/java/com/campus/campus/domain/review/application/dto/response/ocr/ReceiptOcrResponse.java @@ -0,0 +1,71 @@ +package com.campus.campus.domain.review.application.dto.response.ocr; + +import java.util.List; + +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/ReceiptResultDto.java b/src/main/java/com/campus/campus/domain/review/application/dto/response/ocr/ReceiptResultDto.java new file mode 100644 index 00000000..b0baa371 --- /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 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 88a52ff4..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 @@ -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, "영수증과 일치하는 제휴 정보를 찾을 수 없어요."), + RECEIPT_DATE_PARSE_ERROR(2706, HttpStatus.UNPROCESSABLE_ENTITY, "영수증 결제 일자를 파싱하는 도중 오류가 발생했어요."); private final int code; private final HttpStatus status; private final String message; - } 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..2d36b5ee --- /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); + } +} 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/exception/ReceiptFileConvertException.java b/src/main/java/com/campus/campus/domain/review/application/exception/ReceiptFileConvertException.java new file mode 100644 index 00000000..6c598f39 --- /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); + } +} 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..866b08f7 --- /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); + } +} 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..68a8ed33 --- /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); + } +} 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..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 @@ -1,5 +1,6 @@ package com.campus.campus.domain.review.application.mapper; +import java.time.LocalDate; import java.util.Collections; import java.util.List; @@ -7,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; @@ -15,7 +17,11 @@ 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.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; @@ -26,7 +32,7 @@ @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()) @@ -35,6 +41,42 @@ public Review createReview(ReviewRequest request, User user, Place place) { .build(); } + public Review createPartnershipReview(PartnershipReviewRequest request, User user, Place place) { + return Review.builder() + .user(user) + .content(request.content()) + .star(request.star()) + .isVerified(request.isVerified()) + .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()) @@ -44,6 +86,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) 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..16b5056e --- /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.time.format.DateTimeParseException; +import java.util.List; +import java.util.Optional; + +import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; + +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; +import com.campus.campus.domain.review.application.dto.response.ocr.ReceiptResultDto; +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; + +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 ReviewMapper reviewMapper; + private final PlaceRepository placeRepository; + + public ReviewPartnerResponse processReceipt(MultipartFile file, Long userId, Long placeId) { + Place place = placeRepository.findById(placeId) + .orElseThrow(PlaceInfoNotFoundException::new); + + //MultipartFIle -> byte[] + byte[] imageBytes; + try { + imageBytes = file.getBytes(); + } catch (IOException e) { + throw new ReceiptFileConvertException(); + } + + //ocr + String rawResponse = clovaOcrClient.requestReceiptOcr(imageBytes, file.getOriginalFilename()); + log.debug("[OCR RAW RESPONSE] {}", rawResponse); + + ReceiptOcrResponse ocrResponse = parse(rawResponse); + ReceiptResultDto result = extractReceiptResult(ocrResponse); + log.info("영수증 ocr 인식 결과:{}", result); + + return reviewService.findPartnership(place.getPlaceId(), 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); + + var images = Optional.ofNullable(response.images()).orElse(List.of()); + var image = images.stream() + .findFirst() + .orElseThrow(() -> { + log.warn("[OCR FAILED] images empty"); + return new ReceiptOcrFailedException(); + }); + + var receipt = Optional.ofNullable(image.receipt()) + .map(ReceiptOcrResponse.ReceiptWrapper::result) + .orElseThrow(() -> { + log.warn("[OCR FAILED] receipt.result is null"); + return new ReceiptOcrFailedException(); + }); + + //상호명 + String storeName = Optional.ofNullable(receipt.storeInfo()) + .map(ReceiptOcrResponse.StoreInfo::name) + .map(ReceiptOcrResponse.TextField::text) + .orElseThrow(() -> { + log.warn("[OCR FAILED] storeName missing"); + return new ReceiptOcrFailedException(); + }); + + //총액 + String totalPrice = Optional.ofNullable(receipt.totalPrice()) + .map(ReceiptOcrResponse.TotalPrice::price) + .map(ReceiptOcrResponse.TextField::text) + .orElseThrow(() -> { + log.warn("[OCR FAILED] totalPrice missing, paymentInfo={}", + receipt.paymentInfo()); + return new ReceiptOcrFailedException(); + }); + + //결제일 + LocalDate paymentDate = Optional.ofNullable(receipt.paymentInfo()) + .map(ReceiptOcrResponse.PaymentInfo::date) + .map(ReceiptOcrResponse.TextField::text) + .map(this::parseDate) + .orElseThrow(() -> { + log.warn("[OCR FAILED] paymentDate missing"); + return new ReceiptOcrFailedException(); + }); + + //상품 목록 + List items = Optional.ofNullable(receipt.subResults()) + .orElse(List.of()) + .stream() + .flatMap(sr -> Optional.ofNullable(sr.items()).orElse(List.of()).stream()) + .map(reviewMapper::toDto) + .toList(); + log.info("[OCR ITEMS] count={}", items.size()); + + return reviewMapper.toReceiptResultDto(storeName, totalPrice, paymentDate, items); + } + + 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 885fc3b2..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 @@ -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,30 @@ 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.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.review.application.dto.request.ReviewRequest; +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.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; 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,9 +72,12 @@ public class ReviewService { private final StudentCouncilPostRepository studentCouncilPostRepository; 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) { + public ReviewCreateResponse writePlaceReview(PlaceReviewRequest request, Long userId) { User user = userRepository.findById(userId) .orElseThrow(UserNotFoundException::new); @@ -69,9 +85,9 @@ 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) { @@ -80,22 +96,36 @@ public ReviewCreateResponse writeReview(ReviewRequest request, Long userId) { } } - // isOcrVerificationSuccess는 ocr이 성공했다고 가정하고 구현했습니다. 이는 ocr을 구현하면서 수정해주시면 됩니다. - boolean isOcrVerificationSuccess = true; - if (isOcrVerificationSuccess) { - review.verify(); - stampService.grantStampForReview(user, review); + return createReviewResponse(review, place, user, request.imageUrls()); + } + + @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(); } - String imageUrl = - (request.imageUrls() == null || request.imageUrls().isEmpty()) - ? null : request.imageUrls().getFirst(); + Place place = placeRepository.findById(placeId) + .orElseThrow(PlaceInfoNotFoundException::new); - WriteReviewResponse response = reviewMapper.toWriteReviewResponse(review, imageUrl); - ReviewCreateResult createResult = getCreateResult(place, user); - ReviewRankingResponse rankingResponse = getRankingResult(place, user); + Review review = reviewMapper.createPartnershipReview(request, user, place); + reviewRepository.save(review); - return reviewMapper.toReviewCreateResponse(response, createResult, rankingResponse); + if (request.imageUrls() != null) { + for (String imageUrl : request.imageUrls()) { + reviewImageRepository.save(reviewMapper.createReviewImage(review, imageUrl)); + } + } + + if (request.isVerified()) { + review.verify(); + stampService.grantStampForReview(user, review); + } + + return createReviewResponse(review, place, user, request.imageUrls()); } @Transactional(readOnly = true) @@ -142,7 +172,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(); @@ -177,6 +207,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 +291,67 @@ public CursorPageReviewResponse getReviewList( } + @Transactional(readOnly = true) + 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(); + + LocalDate paymentDate = result.paymentDate(); + LocalDateTime time = paymentDate.atStartOfDay(); //시간은 우선 임의로 + + //제휴기간 내에 결제 했는지 확인 + StudentCouncilPost post = studentCouncilPostRepository.findValidPartnershipForUserScope( + placeId, time, majorId, collegeId, schoolId, CouncilType.MAJOR_COUNCIL, + CouncilType.COLLEGE_COUNCIL, CouncilType.SCHOOL_COUNCIL + ).orElseThrow(NotPartnershipReceiptException::new); + + double averageStar = getAverageOfStars(placeId); + String writer = post.getWriter().getCouncilName(); + + boolean isLiked = likedPlacesRepository.existsByUserAndPlace(user, place); + return placeMapper.toReviewPartnerResponse(post, post.getPlace(), averageStar, writer, isLiked, paymentDate); + } + + public double getAverageOfStars(Long placeId) { + return reviewRepository.findAverageStarByPlaceId(placeId).orElse(0.0); + } + + @Transactional(readOnly = true) + public int getReviewCount(Long placeId) { + return (int)reviewRepository.countByPlace_PlaceId(placeId); + } + + @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) @@ -258,8 +382,19 @@ 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, ReviewRequest request) { + private void cleanupUnusedImages(List oldImages, PlaceReviewRequest request) { List newUrls = request.imageUrls() == null ? List.of() : request.imageUrls(); Set deleteTargets = new HashSet<>(); @@ -323,5 +458,4 @@ private ReviewRankingResponse getRankingResult(Place place, User user) { collegeRank, school.getSchoolName(), schoolRank); } - } 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/entity/ReviewImage.java b/src/main/java/com/campus/campus/domain/review/domain/entity/ReviewImage.java index 08ee39c8..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 @@ -12,12 +12,12 @@ 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) 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..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 @@ -2,6 +2,8 @@ import java.time.LocalDateTime; import java.util.List; +import java.util.Optional; +import java.util.Set; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; @@ -9,6 +11,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 +35,22 @@ 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 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 @@ -49,4 +68,6 @@ 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 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 new file mode 100644 index 00000000..e7b2c821 --- /dev/null +++ b/src/main/java/com/campus/campus/domain/review/infrastructure/ClovaOcrClient.java @@ -0,0 +1,74 @@ +package com.campus.campus.domain.review.infrastructure; + +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.MediaType; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestClient; + +import com.campus.campus.domain.review.application.exception.ReceiptImageFormatException; + +import lombok.RequiredArgsConstructor; + +@Component +@RequiredArgsConstructor +public class ClovaOcrClient { + + private final RestClient clovaOcrRestClient; + + @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); + String format = extractFormat(originalFilename); + + //json body 구성 + Map image = new HashMap<>(); + image.put("format", format); + 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)); + + 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) { + 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(); + } +} 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..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 @@ -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; @@ -12,19 +13,25 @@ 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.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; +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; import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; @@ -34,54 +41,95 @@ public class ReviewController { private final ReviewService reviewService; + private final OcrService ocrService; @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", - 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 = PlaceReviewRequest.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" + ] + } + } + """ + ) + } ) ) ) 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); + } + + @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, + @RequestParam("placeId") Long placeId, + @CurrentUserId Long userId + ) { + return CommonResponse.success(ReviewResponseCode.OCR_SUCCESS, ocrService.processReceipt(file, userId, placeId)); + } + @GetMapping("/{reviewId}") @Operation(summary = "리뷰 상세 조회") public CommonResponse readReview(@PathVariable Long reviewId) { @@ -101,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); 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..6928ba3b --- /dev/null +++ b/src/main/java/com/campus/campus/global/config/ClovaOcrConfig.java @@ -0,0 +1,22 @@ +package com.campus.campus.global.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.client.SimpleClientHttpRequestFactory; +import org.springframework.web.client.RestClient; + +@Configuration +public class ClovaOcrConfig { + + @Bean + public RestClient clovaOcrRestClient() { + SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory(); + + factory.setConnectTimeout(3_000); // 연결 타임아웃 3초 + factory.setReadTimeout(15_000); // 응답 대기 타임아웃 15초 + + return RestClient.builder() + .requestFactory(factory) + .build(); + } +} diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml index c0f861fa..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: @@ -66,6 +70,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-local.yml b/src/main/resources/application-local.yml index 2943e5ee..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: @@ -69,4 +73,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 diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml index 503d5273..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: @@ -66,6 +70,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: