diff --git a/.gitignore b/.gitignore index fe461d98..5e7af8e5 100644 --- a/.gitignore +++ b/.gitignore @@ -19,6 +19,7 @@ bin/ ### IntelliJ IDEA ### .env +.oci-keys/ .idea *.iws *.iml @@ -26,6 +27,7 @@ bin/ out/ !**/src/main/**/out/ !**/src/test/**/out/ +src/main/resources/oci ### NetBeans ### /nbproject/private/ 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 e1e662d3..3dfc7864 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 @@ -281,4 +281,36 @@ List findPinsInBounds( @Param("maxLng") Double maxLng, @Param("now") LocalDateTime now ); + + @EntityGraph(attributePaths = {"place"}) + @Query(""" + SELECT scp + FROM StudentCouncilPost scp + JOIN scp.writer sc + LEFT JOIN Review r + ON r.place = scp.place + AND r.createdAt >= :from + WHERE scp.startDateTime <= :now + AND scp.endDateTime >= :now + AND ( + (sc.councilType = com.campus.campus.domain.council.domain.entity.CouncilType.MAJOR_COUNCIL + AND sc.major.majorId = :majorId) + OR (sc.councilType = com.campus.campus.domain.council.domain.entity.CouncilType.COLLEGE_COUNCIL + AND sc.college.collegeId = :collegeId) + OR (sc.councilType = com.campus.campus.domain.council.domain.entity.CouncilType.SCHOOL_COUNCIL + AND sc.school.schoolId = :schoolId) + ) + AND sc.deletedAt IS NULL + GROUP BY scp + ORDER BY COUNT(r.id) DESC + """) + List findTop3RecommendedPartnershipPlaces( + @Param("majorId") Long majorId, + @Param("collegeId") Long collegeId, + @Param("schoolId") Long schoolId, + @Param("from") LocalDateTime from, + @Param("now") LocalDateTime now, + Pageable pageable + ); + } 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/PartnershipResponse.java index 2353aad4..0fd54235 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/PartnershipResponse.java @@ -13,7 +13,7 @@ public record PartnershipResponse( Double longitude, String tag, //(ex.) 총학생회, 사회과학대학, IT공학과 boolean isLiked, - double star, //리뷰 평점 + Double star, //리뷰 평점 String partnerTitle, //제휴 제목 double distance, //거리(m) LocalDate endDate, //제휴 끝나는 시점 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 6743b63d..5d3b2cec 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 @@ -14,7 +14,6 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import com.campus.campus.domain.councilpost.application.dto.request.PostRequest; 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.SearchCandidateResponse; @@ -88,7 +87,7 @@ public List searchByKeyword(String keyword) { return processSearchResults(naverSearchResponse); } - + public Place findOrCreatePlace(SavedPlaceInfo place) { String placeKey = place.placeKey(); diff --git a/src/main/java/com/campus/campus/domain/review/application/dto/request/ReviewRequest.java b/src/main/java/com/campus/campus/domain/review/application/dto/request/ReviewRequest.java new file mode 100644 index 00000000..5d6c11b2 --- /dev/null +++ b/src/main/java/com/campus/campus/domain/review/application/dto/request/ReviewRequest.java @@ -0,0 +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 + +) { +} diff --git a/src/main/java/com/campus/campus/domain/review/application/dto/response/CursorPageReviewResponse.java b/src/main/java/com/campus/campus/domain/review/application/dto/response/CursorPageReviewResponse.java new file mode 100644 index 00000000..1046abbd --- /dev/null +++ b/src/main/java/com/campus/campus/domain/review/application/dto/response/CursorPageReviewResponse.java @@ -0,0 +1,16 @@ +package com.campus.campus.domain.review.application.dto.response; + +import java.util.List; + +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder +public class CursorPageReviewResponse { + + private List items; + private String nextCursorCreatedAt; + private Long nextCursorId; + private boolean hasNext; +} diff --git a/src/main/java/com/campus/campus/domain/review/application/dto/response/PlaceReviewRankResponse.java b/src/main/java/com/campus/campus/domain/review/application/dto/response/PlaceReviewRankResponse.java new file mode 100644 index 00000000..052075f5 --- /dev/null +++ b/src/main/java/com/campus/campus/domain/review/application/dto/response/PlaceReviewRankResponse.java @@ -0,0 +1,14 @@ +package com.campus.campus.domain.review.application.dto.response; + +import lombok.Builder; + +@Builder +public record PlaceReviewRankResponse( + Long placeId, + String placeName, + String category, + String partnership, + String thumbnailUrl + // double distance +) { +} diff --git a/src/main/java/com/campus/campus/domain/review/application/dto/response/RankingScope.java b/src/main/java/com/campus/campus/domain/review/application/dto/response/RankingScope.java new file mode 100644 index 00000000..3e0d8196 --- /dev/null +++ b/src/main/java/com/campus/campus/domain/review/application/dto/response/RankingScope.java @@ -0,0 +1,10 @@ +package com.campus.campus.domain.review.application.dto.response; + +import lombok.Builder; + +@Builder +public record RankingScope( + String scope, + long rank //n번째 리뷰 +) { +} diff --git a/src/main/java/com/campus/campus/domain/review/application/dto/response/ReviewCreateResponse.java b/src/main/java/com/campus/campus/domain/review/application/dto/response/ReviewCreateResponse.java new file mode 100644 index 00000000..26f12bd4 --- /dev/null +++ b/src/main/java/com/campus/campus/domain/review/application/dto/response/ReviewCreateResponse.java @@ -0,0 +1,12 @@ +package com.campus.campus.domain.review.application.dto.response; + +import lombok.Builder; + +@Builder +public record ReviewCreateResponse( + WriteReviewResponse review, + ReviewCreateResult result, + ReviewRankingResponse ranking + +) { +} diff --git a/src/main/java/com/campus/campus/domain/review/application/dto/response/ReviewCreateResult.java b/src/main/java/com/campus/campus/domain/review/application/dto/response/ReviewCreateResult.java new file mode 100644 index 00000000..4974b4d7 --- /dev/null +++ b/src/main/java/com/campus/campus/domain/review/application/dto/response/ReviewCreateResult.java @@ -0,0 +1,11 @@ +package com.campus.campus.domain.review.application.dto.response; + +import lombok.Builder; + +@Builder +public record ReviewCreateResult( + boolean isFirstReviewOfPlace, + int userReviewCountOfPlace + // int NumberOfUserStamp, +) { +} diff --git a/src/main/java/com/campus/campus/domain/review/application/dto/response/ReviewRankingResponse.java b/src/main/java/com/campus/campus/domain/review/application/dto/response/ReviewRankingResponse.java new file mode 100644 index 00000000..51e5b0c4 --- /dev/null +++ b/src/main/java/com/campus/campus/domain/review/application/dto/response/ReviewRankingResponse.java @@ -0,0 +1,8 @@ +package com.campus.campus.domain.review.application.dto.response; + +public record ReviewRankingResponse( + RankingScope major, + RankingScope college, + RankingScope school +) { +} diff --git a/src/main/java/com/campus/campus/domain/review/application/dto/response/ReviewResponse.java b/src/main/java/com/campus/campus/domain/review/application/dto/response/ReviewResponse.java new file mode 100644 index 00000000..31ff73f5 --- /dev/null +++ b/src/main/java/com/campus/campus/domain/review/application/dto/response/ReviewResponse.java @@ -0,0 +1,19 @@ +package com.campus.campus.domain.review.application.dto.response; + +import java.time.LocalDate; +import java.util.List; + +import lombok.Builder; + +@Builder +public record ReviewResponse( + Long id, + Long userId, + String userName, + LocalDate createDate, + Long placeId, + String content, + Double star, + List imageUrls +) { +} diff --git a/src/main/java/com/campus/campus/domain/review/application/dto/response/WriteReviewResponse.java b/src/main/java/com/campus/campus/domain/review/application/dto/response/WriteReviewResponse.java new file mode 100644 index 00000000..0105110f --- /dev/null +++ b/src/main/java/com/campus/campus/domain/review/application/dto/response/WriteReviewResponse.java @@ -0,0 +1,18 @@ +package com.campus.campus.domain.review.application.dto.response; + +import java.time.LocalDate; + +import lombok.Builder; + +@Builder +public record WriteReviewResponse( + Long id, + Long userId, + String userName, + LocalDate createDate, + Long placeId, + String content, + Double star, + String imageUrl +) { +} 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 new file mode 100644 index 00000000..88a52ff4 --- /dev/null +++ b/src/main/java/com/campus/campus/domain/review/application/exception/ErrorCode.java @@ -0,0 +1,21 @@ +package com.campus.campus.domain.review.application.exception; + +import org.springframework.http.HttpStatus; + +import com.campus.campus.global.common.exception.ErrorCodeInterface; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public enum ErrorCode implements ErrorCodeInterface { + + REVIEW_NOT_FOUND(2800, HttpStatus.NOT_FOUND, "리뷰를 찾을 수 없습니다."), + NOT_REVIEW_WRITER(2801, HttpStatus.FORBIDDEN, "작성자만 해당 작업을 수행할 수 있습니다."); + + 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/NotUserWriterException.java b/src/main/java/com/campus/campus/domain/review/application/exception/NotUserWriterException.java new file mode 100644 index 00000000..bda5d0f1 --- /dev/null +++ b/src/main/java/com/campus/campus/domain/review/application/exception/NotUserWriterException.java @@ -0,0 +1,9 @@ +package com.campus.campus.domain.review.application.exception; + +import com.campus.campus.global.common.exception.ApplicationException; + +public class NotUserWriterException extends ApplicationException { + public NotUserWriterException() { + super(ErrorCode.NOT_REVIEW_WRITER); + } +} diff --git a/src/main/java/com/campus/campus/domain/review/application/exception/ReviewNotFoundException.java b/src/main/java/com/campus/campus/domain/review/application/exception/ReviewNotFoundException.java new file mode 100644 index 00000000..cc1c1a03 --- /dev/null +++ b/src/main/java/com/campus/campus/domain/review/application/exception/ReviewNotFoundException.java @@ -0,0 +1,9 @@ +package com.campus.campus.domain.review.application.exception; + +import com.campus.campus.global.common.exception.ApplicationException; + +public class ReviewNotFoundException extends ApplicationException { + public ReviewNotFoundException() { + super(ErrorCode.REVIEW_NOT_FOUND); + } +} 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 new file mode 100644 index 00000000..9ab0cfc9 --- /dev/null +++ b/src/main/java/com/campus/campus/domain/review/application/mapper/ReviewMapper.java @@ -0,0 +1,132 @@ +package com.campus.campus.domain.review.application.mapper; + +import java.util.Collections; +import java.util.List; + +import org.springframework.stereotype.Component; + +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.response.CursorPageReviewResponse; +import com.campus.campus.domain.review.application.dto.response.PlaceReviewRankResponse; +import com.campus.campus.domain.review.application.dto.response.RankingScope; +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.ReviewRankingResponse; +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.domain.entity.Review; +import com.campus.campus.domain.review.domain.entity.ReviewImage; +import com.campus.campus.domain.user.domain.entity.User; + +import lombok.RequiredArgsConstructor; + +@Component +@RequiredArgsConstructor +public class ReviewMapper { + + public Review createReview(ReviewRequest request, User user, Place place) { + return Review.builder() + .user(user) + .content(request.content()) + .star(request.star()) + .place(place) + .build(); + } + + public CursorPageReviewResponse toEmptyCursorReviewResponse() { + return CursorPageReviewResponse.builder() + .items(List.of()) + .nextCursorCreatedAt(null) + .nextCursorId(null) + .hasNext(false) + .build(); + } + + public ReviewImage createReviewImage(Review review, String imageUrl) { + return ReviewImage.builder() + .review(review) + .imageUrl(imageUrl) + .build(); + } + + public ReviewResponse toReviewResponse(Review review, List imageUrls) { + return ReviewResponse.builder() + .id(review.getId()) + .userId(review.getUser().getId()) + .userName(review.getUser().getNickname()) + .createDate(review.getCreatedAt().toLocalDate()) + .placeId(review.getPlace().getPlaceId()) + .content(review.getContent()) + .star(review.getStar()) + .imageUrls(imageUrls != null ? imageUrls : Collections.emptyList()) + .build(); + } + + public WriteReviewResponse toWriteReviewResponse(Review review, String imageUrl) { + return WriteReviewResponse.builder() + .id(review.getId()) + .userId(review.getUser().getId()) + .userName(review.getUser().getNickname()) + .createDate(review.getCreatedAt().toLocalDate()) + .placeId(review.getPlace().getPlaceId()) + .content(review.getContent()) + .star(review.getStar()) + .imageUrl(imageUrl) + .build(); + } + + public CursorPageReviewResponse toCursorReviewResponse(List items, Review last, + boolean hasNext) { + return CursorPageReviewResponse.builder() + .items(items) + .nextCursorCreatedAt(last.getCreatedAt().toString()) + .nextCursorId(last.getId()) + .hasNext(hasNext) + .build(); + } + + public ReviewCreateResult toReviewCreateResult(boolean isFirstReviewOfPlace, long userReviewCountOfPlace) { + return ReviewCreateResult.builder() + .isFirstReviewOfPlace(isFirstReviewOfPlace) + .userReviewCountOfPlace((int)userReviewCountOfPlace) + //스탬프 추가 예정 + .build(); + } + + public ReviewRankingResponse toReviewRankingResponse( + String majorName, long majorRank, + String collegeName, long collegeRank, + String schoolName, long schoolRank + ) { + return new ReviewRankingResponse( + new RankingScope(majorName, majorRank), + new RankingScope(collegeName, collegeRank), + new RankingScope(schoolName, schoolRank) + ); + } + + public ReviewCreateResponse toReviewCreateResponse(WriteReviewResponse response, ReviewCreateResult createResult, + ReviewRankingResponse rankingResponse) { + return ReviewCreateResponse.builder() + .review(response) + .result(createResult) + .ranking(rankingResponse) + .build(); + } + + public PlaceReviewRankResponse toTopPartnershipResponse( + StudentCouncilPost post + ) { + return PlaceReviewRankResponse.builder() + .placeId(post.getPlace().getPlaceId()) + .placeName(post.getPlace().getPlaceName()) + .category(post.getPlace().getPlaceCategory()) + .partnership(post.getTitle()) + .thumbnailUrl(post.getThumbnailImageUrl()) + // .reviewCount(projection.getReviewCount()) + .build(); + } + +} diff --git a/src/main/java/com/campus/campus/domain/review/application/service/ReviewService.java b/src/main/java/com/campus/campus/domain/review/application/service/ReviewService.java new file mode 100644 index 00000000..f20f8d02 --- /dev/null +++ b/src/main/java/com/campus/campus/domain/review/application/service/ReviewService.java @@ -0,0 +1,318 @@ +package com.campus.campus.domain.review.application.service; + +import java.time.LocalDateTime; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +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.service.PlaceService; +import com.campus.campus.domain.place.domain.entity.Place; +import com.campus.campus.domain.review.application.dto.request.ReviewRequest; +import com.campus.campus.domain.review.application.dto.response.CursorPageReviewResponse; +import com.campus.campus.domain.review.application.dto.response.PlaceReviewRankResponse; +import com.campus.campus.domain.review.application.dto.response.ReviewCreateResponse; +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.WriteReviewResponse; +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; +import com.campus.campus.domain.review.domain.entity.Review; +import com.campus.campus.domain.review.domain.entity.ReviewImage; +import com.campus.campus.domain.review.domain.repository.ReviewImageRepository; +import com.campus.campus.domain.review.domain.repository.ReviewRepository; +import com.campus.campus.domain.school.domain.entity.College; +import com.campus.campus.domain.school.domain.entity.Major; +import com.campus.campus.domain.school.domain.entity.School; +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; +import com.campus.campus.global.oci.application.service.PresignedUrlService; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Service +@Slf4j +@RequiredArgsConstructor +public class ReviewService { + + private final UserRepository userRepository; + private final ReviewMapper reviewMapper; + private final ReviewRepository reviewRepository; + private final PlaceService placeService; + private final ReviewImageRepository reviewImageRepository; + private final PresignedUrlService presignedUrlService; + private final StudentCouncilPostRepository studentCouncilPostRepository; + + @Transactional + public ReviewCreateResponse writeReview(ReviewRequest request, Long userId) { + User user = userRepository.findById(userId) + .orElseThrow(UserNotFoundException::new); + + if (request.imageUrls() != null && request.imageUrls().size() > 10) { + throw new PostImageLimitExceededException(); + } + + //place 객체 생성 + Place place = placeService.findOrCreatePlace(request.place()); + + Review review = reviewMapper.createReview(request, user, place); + reviewRepository.save(review); + + if (request.imageUrls() != null) { + for (String imageUrl : request.imageUrls()) { + reviewImageRepository.save(reviewMapper.createReviewImage(review, imageUrl)); + } + } + + String imageUrl = + (request.imageUrls() == null || request.imageUrls().isEmpty()) + ? null : request.imageUrls().getFirst(); + + WriteReviewResponse response = reviewMapper.toWriteReviewResponse(review, imageUrl); + ReviewCreateResult createResult = getCreateResult(place, user); + ReviewRankingResponse rankingResponse = getRankingResult(place, user); + + return reviewMapper.toReviewCreateResponse(response, createResult, rankingResponse); + } + + @Transactional(readOnly = true) + public ReviewResponse readReview(Long reviewId) { + Review review = reviewRepository.findById(reviewId) + .orElseThrow(ReviewNotFoundException::new); + + List imageUrls = reviewImageRepository + .findAllByReviewOrderByIdAsc(review) + .stream() + .map(ReviewImage::getImageUrl) + .toList(); + + return reviewMapper.toReviewResponse(review, imageUrls); + } + + @Transactional + public void delete(Long userId, Long reviewId) { + + Review review = reviewRepository.findById(reviewId) + .orElseThrow(ReviewNotFoundException::new); + + if (!review.getUser().getId().equals(userId)) { + throw new NotUserWriterException(); + } + + List reviewImages = reviewImageRepository.findAllByReview(review); + + Set deleted = new HashSet<>(); + reviewImages.stream() + .map(ReviewImage::getImageUrl) + .forEach(deleted::add); + + reviewImageRepository.deleteAll(reviewImages); + reviewRepository.delete(review); + + for (String imageUrl : deleted) { + try { + presignedUrlService.deleteImage(imageUrl); + } catch (Exception e) { + log.warn("OCI 파일 삭제 실패: {}", imageUrl, e); + } + } + } + + @Transactional + public WriteReviewResponse update(Long userId, Long reviewId, ReviewRequest request) { + + if (request.imageUrls() != null && request.imageUrls().size() > 10) { + throw new PostImageLimitExceededException(); + } + + Review review = reviewRepository.findById(reviewId) + .orElseThrow(ReviewNotFoundException::new); + + if (!review.getUser().getId().equals(userId)) { + throw new NotUserWriterException(); + } + + List oldImages = reviewImageRepository.findAllByReview(review); + review.update( + request.content(), + request.star() + ); + + reviewImageRepository.deleteByReview(review); + if (request.imageUrls() != null) { + for (String imageUrl : request.imageUrls()) { + reviewImageRepository.save(reviewMapper.createReviewImage(review, imageUrl)); + } + } + + cleanupUnusedImages(oldImages, request); + + String imageUrl = + (request.imageUrls() == null || request.imageUrls().isEmpty()) + ? null : request.imageUrls().getFirst(); + + return reviewMapper.toWriteReviewResponse(review, imageUrl); + } + + @Transactional(readOnly = true) + public CursorPageReviewResponse getReviewList( + Long placeId, + LocalDateTime cursorCreatedAt, + Long cursorId, + int size + ) { + + //size+1로 조회 -> 다음 페이지 여부(hasNext) 판단 + Pageable pageable = PageRequest.of(0, size + 1); + List fetched = reviewRepository.findByPlaceIdWithCursor( + placeId, cursorCreatedAt, cursorId, pageable + ); + + //다음 페이지가 있는지 판단, 실제로 내려줄 items는 size개만 자름 + boolean hasNext = fetched.size() > size; + List reviews = hasNext ? fetched.subList(0, size) : fetched; + + if (reviews.isEmpty()) { + return reviewMapper.toEmptyCursorReviewResponse(); + } + + //리뷰 ID를 뽑아서 이미지들을 한 번에 조회 + List reviewIds = reviews.stream() + .map(Review::getId) + .toList(); + + //reviewId -> imageUrls로 그룹핑 + Map> imageMap = reviewImageRepository + .findAllByReviewIdInOrderByIdAsc(reviewIds) + .stream() + .collect(Collectors.groupingBy( + ri -> ri.getReview().getId(), + Collectors.mapping(ReviewImage::getImageUrl, Collectors.toList()) + )); + + List items = reviews.stream() + .map(review -> + reviewMapper.toReviewResponse( + review, + imageMap.get(review.getId()) + ) + ) + .toList(); + + Review last = reviews.getLast(); + + return reviewMapper.toCursorReviewResponse(items, last, hasNext); + + } + + @Transactional(readOnly = true) + public List readPopularPartnerships(Long userId) { + User user = userRepository.findById(userId) + .orElseThrow(UserNotFoundException::new); + + LocalDateTime now = LocalDateTime.now(); + LocalDateTime from = now.minusMonths(1); + + //리뷰가 가장 많은 top3 placeIds + List partnerships = + studentCouncilPostRepository.findTop3RecommendedPartnershipPlaces( + user.getMajor().getMajorId(), + user.getCollege().getCollegeId(), + user.getSchool().getSchoolId(), + from, + now, + PageRequest.of(0, 3) + ); + + log.info("찾은 결과:{}", partnerships.stream().toList()); + + if (partnerships.isEmpty()) { + return List.of(); + } + + return partnerships.stream() + .map(reviewMapper::toTopPartnershipResponse) + .toList(); + } + + //이미지 삭제 + private void cleanupUnusedImages(List oldImages, ReviewRequest request) { + List newUrls = request.imageUrls() == null ? List.of() : request.imageUrls(); + Set deleteTargets = new HashSet<>(); + + // 본문 이미지 중 제거된 이미지 + oldImages.stream() + .map(ReviewImage::getImageUrl) + .filter(url -> !newUrls.contains(url)) + .forEach(deleteTargets::add); + + //삭제 + for (String imageUrl : deleteTargets) { + if (imageUrl == null || imageUrl.isBlank()) { + continue; + } + + try { + presignedUrlService.deleteImage(imageUrl); + } catch (Exception e) { + log.warn("OCI 파일 삭제 실패 (파일이 없을 수 있음): {}", imageUrl, e); + } + } + } + + private ReviewCreateResult getCreateResult(Place place, User user) { + //해당 장소 리뷰 개수 + long totalReviewCountOfPlace = reviewRepository.countByPlace_PlaceId(place.getPlaceId()); + + //해당 장소에서 유저가 쓴 리뷰가 몇번째인지 + long count = reviewRepository.countByPlaceAndUser(place, user); + boolean isFirstReviewOfPlace = totalReviewCountOfPlace == 1; + + return reviewMapper.toReviewCreateResult(isFirstReviewOfPlace, count); + + } + + private ReviewRankingResponse getRankingResult(Place place, User user) { + Long placeId = place.getPlaceId(); + Major major = user.getMajor(); + College college = user.getCollege(); + School school = user.getSchool(); + + Long majorId = major.getMajorId(); + Long collegeId = college.getCollegeId(); + Long schoolId = school.getSchoolId(); + + long majorRank = reviewRepository.countByPlace_PlaceIdAndUser_Major_MajorId( + placeId, + majorId + ); + + long collegeRank = reviewRepository.countByPlace_PlaceIdAndUser_College_CollegeId( + placeId, + collegeId + ); + + long schoolRank = reviewRepository.countByPlace_PlaceIdAndUser_School_SchoolId( + placeId, + schoolId + ); + + return reviewMapper.toReviewRankingResponse(major.getMajorName(), majorRank, college.getCollegeName(), + 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 new file mode 100644 index 00000000..8f61236f --- /dev/null +++ b/src/main/java/com/campus/campus/domain/review/domain/entity/Review.java @@ -0,0 +1,57 @@ +package com.campus.campus.domain.review.domain.entity; + +import com.campus.campus.domain.place.domain.entity.Place; +import com.campus.campus.domain.user.domain.entity.User; +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 lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "reviews") +@Getter +@Builder +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PRIVATE) +public class Review extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + private String content; + + private Double star; + + //영수증 제휴 인증 여부 + @Column(name = "is_verified") + private boolean isVerified; + + @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 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 new file mode 100644 index 00000000..08ee39c8 --- /dev/null +++ b/src/main/java/com/campus/campus/domain/review/domain/entity/ReviewImage.java @@ -0,0 +1,36 @@ +package com.campus.campus.domain.review.domain.entity; + +import com.campus.campus.global.entity.BaseEntity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Builder +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PRIVATE) +public class ReviewImage extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false) + private String imageUrl; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "review_id") + private Review review; +} diff --git a/src/main/java/com/campus/campus/domain/review/domain/repository/ReviewImageRepository.java b/src/main/java/com/campus/campus/domain/review/domain/repository/ReviewImageRepository.java new file mode 100644 index 00000000..26b51dc0 --- /dev/null +++ b/src/main/java/com/campus/campus/domain/review/domain/repository/ReviewImageRepository.java @@ -0,0 +1,21 @@ +package com.campus.campus.domain.review.domain.repository; + +import java.util.List; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.repository.query.Param; + +import com.campus.campus.domain.review.domain.entity.Review; +import com.campus.campus.domain.review.domain.entity.ReviewImage; + +public interface ReviewImageRepository extends JpaRepository { + + List findAllByReviewOrderByIdAsc(Review review); + + List findAllByReviewIdInOrderByIdAsc(@Param("reviewIds") List reviewIds); + + List findAllByReview(Review review); + + void deleteByReview(Review review); + +} 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 new file mode 100644 index 00000000..7b7cb900 --- /dev/null +++ b/src/main/java/com/campus/campus/domain/review/domain/repository/ReviewRepository.java @@ -0,0 +1,44 @@ +package com.campus.campus.domain.review.domain.repository; + +import java.time.LocalDateTime; +import java.util.List; + +import org.springframework.data.domain.Pageable; +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.review.domain.entity.Review; +import com.campus.campus.domain.user.domain.entity.User; + +public interface ReviewRepository extends JpaRepository { + + @Query(""" + SELECT r + FROM Review r + WHERE r.place.placeId = :placeId + AND ( + :cursorCreatedAt IS NULL + OR r.createdAt < :cursorCreatedAt + OR (r.createdAt = :cursorCreatedAt AND r.id < :cursorId) + ) + ORDER BY r.createdAt DESC, r.id DESC + """) + List findByPlaceIdWithCursor( + @Param("placeId") Long placeId, + @Param("cursorCreatedAt") LocalDateTime cursorCreatedAt, + @Param("cursorId") Long cursorId, + Pageable pageable + ); + + long countByPlace_PlaceId(long placeId); + + long countByPlaceAndUser(Place place, User user); + + long countByPlace_PlaceIdAndUser_Major_MajorId(long placeId, long majorId); + + long countByPlace_PlaceIdAndUser_College_CollegeId(Long placeId, long collegeId); + + long countByPlace_PlaceIdAndUser_School_SchoolId(Long placeId, long schoolId); +} 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 new file mode 100644 index 00000000..6794ce85 --- /dev/null +++ b/src/main/java/com/campus/campus/domain/review/presentation/ReviewController.java @@ -0,0 +1,131 @@ +package com.campus.campus.domain.review.presentation; + +import java.time.LocalDateTime; +import java.util.List; + +import org.springframework.format.annotation.DateTimeFormat; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import com.campus.campus.domain.review.application.dto.request.ReviewRequest; +import com.campus.campus.domain.review.application.dto.response.CursorPageReviewResponse; +import com.campus.campus.domain.review.application.dto.response.PlaceReviewRankResponse; +import com.campus.campus.domain.review.application.dto.response.ReviewCreateResponse; +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.ReviewService; +import com.campus.campus.global.annotation.CurrentUserId; +import com.campus.campus.global.common.response.CommonResponse; + +import io.swagger.v3.oas.annotations.Operation; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; + +@RestController +@RequestMapping("/reviews") +@RequiredArgsConstructor +public class ReviewController { + + private final ReviewService reviewService; + + @PostMapping + @Operation( + summary = "리뷰 작성", + 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" + ] + } + } + """ + ) + ) + ) + ) + public CommonResponse writeReview( + @Valid @RequestBody ReviewRequest request, + @CurrentUserId Long userId + ) { + ReviewCreateResponse response = reviewService.writeReview(request, userId); + return CommonResponse.success(ReviewResponseCode.REVIEW_SAVE_SUCCESS, response); + } + + @GetMapping("/{reviewId}") + @Operation(summary = "리뷰 상세 조회") + public CommonResponse readReview(@PathVariable Long reviewId) { + ReviewResponse response = reviewService.readReview(reviewId); + return CommonResponse.success(ReviewResponseCode.GET_REVIEW_SUCCESS, response); + } + + @DeleteMapping("/{reviewId}") + @Operation(summary = "리뷰 삭제") + public CommonResponse deleteReview(@PathVariable Long reviewId, @CurrentUserId Long userId) { + reviewService.delete(userId, reviewId); + return CommonResponse.success(ReviewResponseCode.REVIEW_DELETE_SUCCESS); + } + + @PatchMapping("/{reviewId}") + @Operation(summary = "리뷰 수정") + public CommonResponse updateReview( + @CurrentUserId Long userId, + @PathVariable Long reviewId, + @RequestBody @Valid ReviewRequest request + ) { + WriteReviewResponse response = reviewService.update(userId, reviewId, request); + return CommonResponse.success(ReviewResponseCode.REVIEW_UPDATE_SUCCESS, response); + } + + @GetMapping("/list/{placeId}") + @Operation(summary = "리뷰 목록 조회 - 최신순 (더보기 이후)") + public CommonResponse> readAllReviews( + @PathVariable Long placeId, + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime cursorCreatedAt, + @RequestParam(required = false) Long cursorId, + @RequestParam(defaultValue = "10") int size + ) { + CursorPageReviewResponse response = reviewService.getReviewList(placeId, cursorCreatedAt, + cursorId, size); + return CommonResponse.success(ReviewResponseCode.GET_REVIEW_LIST_SUCCESS, response); + } + + @GetMapping("/partnership-list") + @Operation(summary = "제휴 매장 둘러보기", description = "최근 한달 간 제휴 이용수가 많았던 매장") + public CommonResponse> readAllPartnerships( + @CurrentUserId Long userId + ) { + List response = reviewService.readPopularPartnerships(userId); + return CommonResponse.success(ReviewResponseCode.GET_RANK_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 new file mode 100644 index 00000000..d18dbfed --- /dev/null +++ b/src/main/java/com/campus/campus/domain/review/presentation/ReviewResponseCode.java @@ -0,0 +1,24 @@ +package com.campus.campus.domain.review.presentation; + +import org.springframework.http.HttpStatus; + +import com.campus.campus.global.common.response.ResponseCodeInterface; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public enum ReviewResponseCode implements ResponseCodeInterface { + + REVIEW_SAVE_SUCCESS(200, HttpStatus.OK, "리뷰 작성이 완료되었습니다."), + REVIEW_DELETE_SUCCESS(200, HttpStatus.OK, "리뷰 삭제가 완료되었습니다."), + 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, "리뷰 상세 조회에 성공하였습니다."); + + private final int code; + private final HttpStatus status; + private final String message; +} diff --git a/src/main/java/com/campus/campus/domain/user/application/service/KakaoOauthService.java b/src/main/java/com/campus/campus/domain/user/application/service/KakaoOauthService.java index 6a44d0b3..deacd8e6 100644 --- a/src/main/java/com/campus/campus/domain/user/application/service/KakaoOauthService.java +++ b/src/main/java/com/campus/campus/domain/user/application/service/KakaoOauthService.java @@ -3,30 +3,28 @@ import java.time.LocalDateTime; import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.MediaType; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.web.client.RestClient; import com.campus.campus.domain.user.application.exception.NicknameNotMatchException; -import com.campus.campus.domain.user.application.exception.UserSignupForbiddenException; import com.campus.campus.domain.user.application.exception.UserNotFoundException; -import com.campus.campus.global.auth.application.mapper.LoginMapper; +import com.campus.campus.domain.user.application.exception.UserSignupForbiddenException; import com.campus.campus.domain.user.application.mapper.UserMapper; import com.campus.campus.domain.user.domain.entity.User; import com.campus.campus.domain.user.domain.repository.UserRepository; -import com.campus.campus.global.auth.application.dto.KakaoTokenResponse; import com.campus.campus.global.auth.application.dto.KakaoUserResponse; import com.campus.campus.global.auth.application.dto.OauthLoginResponse; +import com.campus.campus.global.auth.application.mapper.LoginMapper; import com.campus.campus.global.auth.application.property.KakaoOauthProperty; -import com.campus.campus.global.util.jwt.application.service.RedisTokenService; import com.campus.campus.global.util.jwt.JwtProvider; +import com.campus.campus.global.util.jwt.application.service.RedisTokenService; import lombok.RequiredArgsConstructor; -import org.springframework.http.MediaType; -import org.springframework.transaction.annotation.Transactional; -import org.springframework.util.LinkedMultiValueMap; -import org.springframework.util.MultiValueMap; -import org.springframework.web.client.RestClient; - @Service @RequiredArgsConstructor public class KakaoOauthService { @@ -133,4 +131,4 @@ private User findOrCreateUser(KakaoUserResponse kakaoUserResponse) { return userRepository.save(newUser); }); } -} +} \ No newline at end of file diff --git a/src/main/java/com/campus/campus/domain/user/presentation/AuthController.java b/src/main/java/com/campus/campus/domain/user/presentation/AuthController.java index a6012080..6b5c4f77 100644 --- a/src/main/java/com/campus/campus/domain/user/presentation/AuthController.java +++ b/src/main/java/com/campus/campus/domain/user/presentation/AuthController.java @@ -8,9 +8,9 @@ import org.springframework.web.bind.annotation.RestController; import com.campus.campus.domain.user.application.dto.request.UserWithdrawRequest; +import com.campus.campus.domain.user.application.service.KakaoOauthService; import com.campus.campus.global.annotation.CurrentUserId; import com.campus.campus.global.auth.application.dto.OauthLoginResponse; -import com.campus.campus.domain.user.application.service.KakaoOauthService; import com.campus.campus.global.common.response.CommonResponse; import io.swagger.v3.oas.annotations.Operation; @@ -39,4 +39,4 @@ public CommonResponse withdraw(@CurrentUserId Long userId, return CommonResponse.success(UserResponseCode.WITHDRAW_SUCCESS); } -} +} \ No newline at end of file