Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -44,8 +44,34 @@ dependencies {
// Test
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'

// QueryDSL : OpenFeign
implementation "io.github.openfeign.querydsl:querydsl-jpa:7.0"
implementation "io.github.openfeign.querydsl:querydsl-core:7.0"
annotationProcessor "io.github.openfeign.querydsl:querydsl-apt:7.0:jpa"
annotationProcessor "jakarta.persistence:jakarta.persistence-api"
annotationProcessor "jakarta.annotation:jakarta.annotation-api"
}

tasks.named('test') {
useJUnitPlatform()
}

// QueryDSL 관련 설정
// generated/querydsl 폴더 생성 & 삽입
def querydslDir = layout.buildDirectory.dir("generated/querydsl").get().asFile

// 소스 세트에 생성 경로 추가 (구체적인 경로 지정)
sourceSets {
main.java.srcDirs += [ querydslDir ]
}

// 컴파일 시 생성 경로 지정
tasks.withType(JavaCompile).configureEach {
options.generatedSourceOutputDirectory.set(querydslDir)
}

// clean 태스크에 생성 폴더 삭제 로직 추가
clean.doLast {
file(querydslDir).deleteDir()
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,10 @@
import com.example.umc9th.domain.review.dto.ReviewResponse;
import com.example.umc9th.domain.review.service.ReviewService;
import com.example.umc9th.global.apiPayload.ApiResponse;
import io.swagger.v3.oas.annotations.Operation;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Slice;
import org.springframework.web.bind.annotation.*;

@RestController
Expand All @@ -15,11 +18,26 @@ public class ReviewController {
private final ReviewService reviewService;

@PostMapping
@Operation(summary = "리뷰 등록 API")
public ApiResponse<ReviewResponse.ReviewResultDTO> createReview(
@RequestBody ReviewRequest.ReviewCreateDTO request
){
ReviewResponse.ReviewResultDTO response = reviewService.createReview(1L, request.getStoreId(), request);
return ApiResponse.onSuccess(response);
}

@GetMapping("/my")
@Operation(summary = "나의 리뷰 조회 API", description = "가게별, 별점별(소수점 제외 몇점대)로 나의 리뷰를 조회할 수 있습니다.")
public ApiResponse<ReviewResponse.ReviewListDTO> getMyReviews(
@RequestParam(required = false) Long storeId,
@RequestParam(required = false) Integer rate,
@RequestParam(required = false) Long cursorId){

ReviewResponse.ReviewListDTO reviews =
reviewService.getMyReviews(1L, storeId, rate, cursorId);

return ApiResponse.onSuccess(reviews);

}

}
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@
import com.example.umc9th.domain.review.entity.Review;
import com.example.umc9th.domain.store.entity.Store;

import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;

public class ReviewConverter {

// ReviewCreateDTO -> Review 엔터티
Expand All @@ -18,10 +22,29 @@ public static Review toReview (ReviewRequest.ReviewCreateDTO dto, Member member,
.build();
}

// Review 엔터티 -> ReviewResultDTO
public static ReviewResponse.ReviewResultDTO toReviewResultDTO (Review review) {
// 단일 리뷰 엔티티 → ReviewDetailDTO
public static ReviewResponse.ReviewResultDTO toReviewResultDTO(Review review, List<String> images) {
return ReviewResponse.ReviewResultDTO.builder()
.reviewId(review.getId())
.storeName(review.getStore().getName())
.rate(review.getRate())
.content(review.getContent())
.reviewImages(images)
.createdAt(review.getCreatedAt())
.build();
}

// 리뷰 목록 변환
public static List<ReviewResponse.ReviewResultDTO> toReviewResultDTOList(
List<Review> reviews,
Map<Long, List<String>> reviewImageMap
) {
return reviews.stream()
.map(review -> toReviewResultDTO(
review,
reviewImageMap.getOrDefault(review.getId(), List.of())
))
.collect(Collectors.toList());
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,33 @@
import lombok.Builder;
import lombok.Getter;

import java.time.LocalDateTime;
import java.util.List;

public class ReviewResponse {

@Getter
@Builder
@AllArgsConstructor
@Schema(description = "리뷰 등록 응답 정보")
@Schema(description = "리뷰 응답 정보")
public static class ReviewResultDTO {
private Long reviewId;
private String storeName;
private Float rate;
private String content;
private List<String> reviewImages;
private LocalDateTime createdAt;
}

@Getter
@Builder
@AllArgsConstructor
@Schema(description = "리뷰 목록 조회 응답 정보")
public static class ReviewListDTO {

private List<ReviewResultDTO> reviewList; // 리뷰 리스트
private Boolean hasNext; // 다음 페이지 존재 여부
private Long nextCursorId; // 다음 커서 ID (다음 요청에 사용)
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package com.example.umc9th.domain.review.repository;

import com.example.umc9th.domain.review.dto.ReviewResponse;
import com.example.umc9th.domain.review.entity.Review;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Slice;

import java.util.List;
import java.util.Map;

public interface ReviewRepositoryCustom {

Slice<Review> findMyReviewsByFilter(
Long memberId,
Long storeId,
Integer rate, // 정수로 (3.x -> 3점대)
Long cursorId);

// ReviewImage N + 1 해결용
Map<Long, List<String>> findReviewImages(List<Long> reviewIds);


}
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
package com.example.umc9th.domain.review.repository;

import com.example.umc9th.domain.review.entity.Review;
import com.querydsl.core.BooleanBuilder;
import com.querydsl.core.group.GroupBy;
import com.querydsl.jpa.impl.JPAQueryFactory;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Slice;
import org.springframework.data.domain.SliceImpl;
import org.springframework.stereotype.Repository;
import java.util.Collections;
import java.util.List;
import java.util.Map;

import static com.example.umc9th.domain.review.entity.QReview.review;
import static com.example.umc9th.domain.review.entity.QReviewImage.reviewImage;
import static com.example.umc9th.domain.store.entity.QStore.store;

@Repository
@RequiredArgsConstructor
public class ReviewRepositoryImpl implements ReviewRepositoryCustom {

private final JPAQueryFactory jpaQueryFactory;

private static final int PAGE_SIZE = 10;

@Override
public Slice<Review> findMyReviewsByFilter(
Long memberId,
Long storeId,
Integer rate,
Long cursorId) {


BooleanBuilder builder = new BooleanBuilder();

// 1. 내가 쓴 리뷰
builder.and(review.member.id.eq(memberId));

// 2. 특정 가게 필터링
if(storeId != null) {
builder.and(review.store.id.eq(storeId));
}

// 3. 별점 필터링 (정수부분 기준 - 3점대, 4점대...)
// goe : >= (이상), lt : < (미만)
if(rate != null) {
builder.and(review.rate.goe(rate).and(review.rate.lt(rate + 1)));
}

// 4. 커서 기반 무한스크롤
if(cursorId != null) {
builder.and(review.id.lt(cursorId));
}

// 5. query 실행
List<Review> results = jpaQueryFactory
.selectFrom(review)
.leftJoin(review.store, store).fetchJoin() // store N+1 방지
.where(builder)
.orderBy(review.id.desc())
.limit(PAGE_SIZE + 1) // 다음페이지 존재 여부 확인
.fetch();

// 6. slice 반환
boolean hasNext = results.size() > PAGE_SIZE;
if (hasNext) {
results.remove(PAGE_SIZE); // +1 로 가져온건 삭제 - 다음 페이지 존재 여부 확인용 이니까
}

return new SliceImpl<>(results, PageRequest.of(0, PAGE_SIZE), hasNext);
}

// 리뷰 이미지 조회
@Override
public Map<Long, List<String>> findReviewImages(List<Long> reviewIds) {
if(reviewIds == null || reviewIds.isEmpty()) {
return Collections.emptyMap();
}

// reviewId를 Key로, imageUrl 리스트를 value로 묶어서 반환
return jpaQueryFactory
.from(reviewImage)
.where(reviewImage.review.id.in(reviewIds))
.transform(GroupBy.groupBy(reviewImage.review.id)
.as(GroupBy.list(reviewImage.imgUrl)));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,17 @@

import com.example.umc9th.domain.review.dto.ReviewRequest;
import com.example.umc9th.domain.review.dto.ReviewResponse;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Slice;

public interface ReviewService {

ReviewResponse.ReviewResultDTO createReview(Long memberId, Long storeId, ReviewRequest.ReviewCreateDTO request);

ReviewResponse.ReviewListDTO getMyReviews(
Long memberId,
Long storeId,
Integer rate,
Long cursorId
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,15 +7,20 @@
import com.example.umc9th.domain.review.dto.ReviewResponse;
import com.example.umc9th.domain.review.entity.Review;
import com.example.umc9th.domain.review.repository.ReviewRepository;
import com.example.umc9th.domain.review.repository.ReviewRepositoryCustom;
import com.example.umc9th.domain.store.entity.Store;
import com.example.umc9th.domain.store.repository.StoreRepository;
import com.example.umc9th.global.apiPayload.code.status.ErrorStatus;
import com.example.umc9th.global.apiPayload.exception.handler.ErrorHandler;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.domain.Slice;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.util.List;
import java.util.Map;

@Slf4j
@Service
@Transactional(readOnly = true)
Expand All @@ -25,7 +30,9 @@ public class ReviewServiceImpl implements ReviewService {
private final MemberRepository memberRepository;
private final StoreRepository storeRepository;
private final ReviewRepository reviewRepository;
private final ReviewRepositoryCustom reviewRepositoryCustom;

// 리뷰 작성
@Transactional
@Override
public ReviewResponse.ReviewResultDTO createReview(Long memberId, Long storeId, ReviewRequest.ReviewCreateDTO request){
Expand All @@ -45,7 +52,42 @@ public ReviewResponse.ReviewResultDTO createReview(Long memberId, Long storeId,
reviewRepository.save(review);

// 5. 응답 DTO로 변환하여 반환
return ReviewConverter.toReviewResultDTO(review);
return ReviewConverter.toReviewResultDTO(review, List.of());
}

// 나의 리뷰 조회
@Override
public ReviewResponse.ReviewListDTO getMyReviews(
Long memberId, Long storeId, Integer rate, Long cursorId) {

// 1. Repository 호출 → DB 조회
Slice<Review> reviewSlice = reviewRepositoryCustom.findMyReviewsByFilter(
memberId, storeId, rate, cursorId);

// 2. 리뷰 ID 리스트 추출
List<Long> reviewIds = reviewSlice.getContent().stream()
.map(Review::getId)
.toList();

// 3. 리뷰별 이미지 Map 조회
Map<Long, List<String>> reviewImageMap = reviewRepositoryCustom.findReviewImages(reviewIds);

// 4. Converter로 DTO 변환
List<ReviewResponse.ReviewResultDTO> dtoList =
ReviewConverter.toReviewResultDTOList(reviewSlice.getContent(), reviewImageMap);

// 5. 마지막 커서 아이디 확인
Long nextCursorId = reviewSlice.hasNext()
? dtoList.get(dtoList.size() - 1).getReviewId()
: null;

// 6. 응답 dto 반환
return ReviewResponse.ReviewListDTO.builder()
.reviewList(dtoList)
.hasNext(reviewSlice.hasNext())
.nextCursorId(nextCursorId)
.build();
}


}
Loading