This commit is contained in:
lsh9672
2025-06-11 16:31:06 +09:00
commit f0fbb47c51
164 changed files with 8667 additions and 0 deletions
+6
View File
@@ -0,0 +1,6 @@
dependencies {
implementation project(':common')
// File Storage
implementation 'org.springframework.boot:spring-boot-starter-webflux'
}
@@ -0,0 +1,21 @@
package com.ktds.hi.review;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.data.jpa.repository.config.EnableJpaAuditing;
/**
* 리뷰 관리 서비스 메인 애플리케이션 클래스
* 리뷰 작성, 조회, 삭제, 반응, 댓글 기능을 제공
*
* @author 하이오더 개발팀
* @version 1.0.0
*/
@SpringBootApplication(scanBasePackages = {"com.ktds.hi.review", "com.ktds.hi.common"})
@EnableJpaAuditing
public class ReviewServiceApplication {
public static void main(String[] args) {
SpringApplication.run(ReviewServiceApplication.class, args);
}
}
@@ -0,0 +1,20 @@
package com.ktds.hi.review.biz.domain;
/**
* 반응 유형 열거형
* 리뷰 반응의 종류를 정의
*/
public enum ReactionType {
LIKE("좋아요"),
DISLIKE("싫어요");
private final String description;
ReactionType(String description) {
this.description = description;
}
public String getDescription() {
return description;
}
}
@@ -0,0 +1,93 @@
package com.ktds.hi.review.biz.domain;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
import java.util.List;
/**
* 리뷰 도메인 클래스
* 리뷰의 기본 정보를 담는 도메인 객체
*/
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class Review {
private Long id;
private Long storeId;
private Long memberId;
private String memberNickname;
private Integer rating;
private String content;
private List<String> imageUrls;
private ReviewStatus status;
private Integer likeCount;
private Integer dislikeCount;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
/**
* 리뷰 내용 수정
*/
public Review updateContent(String newContent, Integer newRating) {
return Review.builder()
.id(this.id)
.storeId(this.storeId)
.memberId(this.memberId)
.memberNickname(this.memberNickname)
.rating(newRating)
.content(newContent)
.imageUrls(this.imageUrls)
.status(this.status)
.likeCount(this.likeCount)
.dislikeCount(this.dislikeCount)
.createdAt(this.createdAt)
.updatedAt(LocalDateTime.now())
.build();
}
/**
* 리뷰 상태 변경
*/
public Review updateStatus(ReviewStatus newStatus) {
return Review.builder()
.id(this.id)
.storeId(this.storeId)
.memberId(this.memberId)
.memberNickname(this.memberNickname)
.rating(this.rating)
.content(this.content)
.imageUrls(this.imageUrls)
.status(newStatus)
.likeCount(this.likeCount)
.dislikeCount(this.dislikeCount)
.createdAt(this.createdAt)
.updatedAt(LocalDateTime.now())
.build();
}
/**
* 좋아요 수 업데이트
*/
public Review updateLikeCount(Integer likeCount, Integer dislikeCount) {
return Review.builder()
.id(this.id)
.storeId(this.storeId)
.memberId(this.memberId)
.memberNickname(this.memberNickname)
.rating(this.rating)
.content(this.content)
.imageUrls(this.imageUrls)
.status(this.status)
.likeCount(likeCount)
.dislikeCount(dislikeCount)
.createdAt(this.createdAt)
.updatedAt(this.updatedAt)
.build();
}
}
@@ -0,0 +1,42 @@
package com.ktds.hi.review.biz.domain;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
/**
* 리뷰 댓글 도메인 클래스
* 리뷰에 대한 점주 댓글 정보를 담는 도메인 객체
*/
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class ReviewComment {
private Long id;
private Long reviewId;
private Long ownerId;
private String ownerNickname;
private String content;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
/**
* 댓글 내용 수정
*/
public ReviewComment updateContent(String newContent) {
return ReviewComment.builder()
.id(this.id)
.reviewId(this.reviewId)
.ownerId(this.ownerId)
.ownerNickname(this.ownerNickname)
.content(newContent)
.createdAt(this.createdAt)
.updatedAt(LocalDateTime.now())
.build();
}
}
@@ -0,0 +1,38 @@
package com.ktds.hi.review.biz.domain;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
/**
* 리뷰 반응 도메인 클래스
* 리뷰에 대한 좋아요/싫어요 반응 정보를 담는 도메인 객체
*/
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class ReviewReaction {
private Long id;
private Long reviewId;
private Long memberId;
private ReactionType reactionType;
private LocalDateTime createdAt;
/**
* 반응 유형 변경
*/
public ReviewReaction updateReactionType(ReactionType newType) {
return ReviewReaction.builder()
.id(this.id)
.reviewId(this.reviewId)
.memberId(this.memberId)
.reactionType(newType)
.createdAt(this.createdAt)
.build();
}
}
@@ -0,0 +1,22 @@
package com.ktds.hi.review.biz.domain;
/**
* 리뷰 상태 열거형
* 리뷰의 현재 상태를 정의
*/
public enum ReviewStatus {
ACTIVE("활성"),
DELETED("삭제됨"),
HIDDEN("숨김"),
REPORTED("신고됨");
private final String description;
ReviewStatus(String description) {
this.description = description;
}
public String getDescription() {
return description;
}
}
@@ -0,0 +1,86 @@
package com.ktds.hi.review.biz.service;
import com.ktds.hi.review.biz.usecase.in.ManageReviewCommentUseCase;
import com.ktds.hi.review.biz.usecase.out.ReviewCommentRepository;
import com.ktds.hi.review.biz.usecase.out.ReviewRepository;
import com.ktds.hi.review.biz.domain.ReviewComment;
import com.ktds.hi.review.biz.domain.Review;
import com.ktds.hi.review.infra.dto.request.ReviewCommentRequest;
import com.ktds.hi.review.infra.dto.response.ReviewCommentResponse;
import com.ktds.hi.common.exception.BusinessException;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.util.List;
import java.util.stream.Collectors;
/**
* 리뷰 댓글 인터랙터 클래스
* 리뷰 댓글 작성, 조회, 삭제 기능을 구현
*/
@Service
@RequiredArgsConstructor
@Slf4j
@Transactional
public class ReviewCommentInteractor implements ManageReviewCommentUseCase {
private final ReviewCommentRepository commentRepository;
private final ReviewRepository reviewRepository;
@Override
@Transactional(readOnly = true)
public List<ReviewCommentResponse> getReviewComments(Long reviewId) {
List<ReviewComment> comments = commentRepository.findCommentsByReviewId(reviewId);
return comments.stream()
.map(comment -> ReviewCommentResponse.builder()
.commentId(comment.getId())
.ownerNickname(comment.getOwnerNickname())
.content(comment.getContent())
.createdAt(comment.getCreatedAt())
.build())
.collect(Collectors.toList());
}
@Override
public ReviewCommentResponse createComment(Long reviewId, Long ownerId, ReviewCommentRequest request) {
// 리뷰 존재 확인
Review review = reviewRepository.findReviewById(reviewId)
.orElseThrow(() -> new BusinessException("존재하지 않는 리뷰입니다"));
// 댓글 생성
ReviewComment comment = ReviewComment.builder()
.reviewId(reviewId)
.ownerId(ownerId)
.ownerNickname("점주" + ownerId) // TODO: 점주 서비스에서 닉네임 조회
.content(request.getContent())
.createdAt(LocalDateTime.now())
.updatedAt(LocalDateTime.now())
.build();
ReviewComment savedComment = commentRepository.saveComment(comment);
log.info("리뷰 댓글 생성 완료: commentId={}, reviewId={}, ownerId={}",
savedComment.getId(), reviewId, ownerId);
return ReviewCommentResponse.builder()
.commentId(savedComment.getId())
.ownerNickname(savedComment.getOwnerNickname())
.content(savedComment.getContent())
.createdAt(savedComment.getCreatedAt())
.build();
}
@Override
public void deleteComment(Long commentId, Long ownerId) {
ReviewComment comment = commentRepository.findCommentByIdAndOwnerId(commentId, ownerId)
.orElseThrow(() -> new BusinessException("댓글을 찾을 수 없거나 권한이 없습니다"));
commentRepository.deleteComment(commentId);
log.info("리뷰 댓글 삭제 완료: commentId={}, ownerId={}", commentId, ownerId);
}
}
@@ -0,0 +1,144 @@
package com.ktds.hi.review.biz.service;
import com.ktds.hi.review.biz.usecase.in.CreateReviewUseCase;
import com.ktds.hi.review.biz.usecase.in.DeleteReviewUseCase;
import com.ktds.hi.review.biz.usecase.in.GetReviewUseCase;
import com.ktds.hi.review.biz.usecase.out.ReviewRepository;
import com.ktds.hi.review.biz.domain.Review;
import com.ktds.hi.review.biz.domain.ReviewStatus;
import com.ktds.hi.review.infra.dto.request.ReviewCreateRequest;
import com.ktds.hi.review.infra.dto.response.*;
import com.ktds.hi.common.exception.BusinessException;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.util.List;
import java.util.stream.Collectors;
/**
* 리뷰 인터랙터 클래스
* 리뷰 생성, 조회, 삭제 기능을 구현
*/
@Service
@RequiredArgsConstructor
@Slf4j
@Transactional
public class ReviewInteractor implements CreateReviewUseCase, DeleteReviewUseCase, GetReviewUseCase {
private final ReviewRepository reviewRepository;
@Override
public ReviewCreateResponse createReview(Long memberId, ReviewCreateRequest request) {
// 리뷰 생성
Review review = Review.builder()
.storeId(request.getStoreId())
.memberId(memberId)
.memberNickname("회원" + memberId) // TODO: 회원 서비스에서 닉네임 조회
.rating(request.getRating())
.content(request.getContent())
.imageUrls(request.getImageUrls())
.status(ReviewStatus.ACTIVE)
.likeCount(0)
.dislikeCount(0)
.createdAt(LocalDateTime.now())
.updatedAt(LocalDateTime.now())
.build();
Review savedReview = reviewRepository.saveReview(review);
log.info("리뷰 생성 완료: reviewId={}, storeId={}, memberId={}",
savedReview.getId(), savedReview.getStoreId(), savedReview.getMemberId());
return ReviewCreateResponse.builder()
.reviewId(savedReview.getId())
.message("리뷰가 성공적으로 등록되었습니다")
.build();
}
@Override
public ReviewDeleteResponse deleteReview(Long reviewId, Long memberId) {
Review review = reviewRepository.findReviewByIdAndMemberId(reviewId, memberId)
.orElseThrow(() -> new BusinessException("리뷰를 찾을 수 없거나 권한이 없습니다"));
Review deletedReview = review.updateStatus(ReviewStatus.DELETED);
reviewRepository.saveReview(deletedReview);
log.info("리뷰 삭제 완료: reviewId={}, memberId={}", reviewId, memberId);
return ReviewDeleteResponse.builder()
.success(true)
.message("리뷰가 삭제되었습니다")
.build();
}
@Override
@Transactional(readOnly = true)
public List<ReviewListResponse> getStoreReviews(Long storeId, Integer page, Integer size) {
Pageable pageable = PageRequest.of(page != null ? page : 0, size != null ? size : 20);
Page<Review> reviews = reviewRepository.findReviewsByStoreId(storeId, pageable);
return reviews.stream()
.filter(review -> review.getStatus() == ReviewStatus.ACTIVE)
.map(review -> ReviewListResponse.builder()
.reviewId(review.getId())
.memberNickname(review.getMemberNickname())
.rating(review.getRating())
.content(review.getContent())
.imageUrls(review.getImageUrls())
.likeCount(review.getLikeCount())
.dislikeCount(review.getDislikeCount())
.createdAt(review.getCreatedAt())
.build())
.collect(Collectors.toList());
}
@Override
@Transactional(readOnly = true)
public ReviewDetailResponse getReviewDetail(Long reviewId) {
Review review = reviewRepository.findReviewById(reviewId)
.orElseThrow(() -> new BusinessException("존재하지 않는 리뷰입니다"));
if (review.getStatus() != ReviewStatus.ACTIVE) {
throw new BusinessException("삭제되었거나 숨겨진 리뷰입니다");
}
return ReviewDetailResponse.builder()
.reviewId(review.getId())
.storeId(review.getStoreId())
.memberNickname(review.getMemberNickname())
.rating(review.getRating())
.content(review.getContent())
.imageUrls(review.getImageUrls())
.likeCount(review.getLikeCount())
.dislikeCount(review.getDislikeCount())
.createdAt(review.getCreatedAt())
.build();
}
@Override
@Transactional(readOnly = true)
public List<ReviewListResponse> getMyReviews(Long memberId, Integer page, Integer size) {
Pageable pageable = PageRequest.of(page != null ? page : 0, size != null ? size : 20);
Page<Review> reviews = reviewRepository.findReviewsByMemberId(memberId, pageable);
return reviews.stream()
.filter(review -> review.getStatus() == ReviewStatus.ACTIVE)
.map(review -> ReviewListResponse.builder()
.reviewId(review.getId())
.memberNickname(review.getMemberNickname())
.rating(review.getRating())
.content(review.getContent())
.imageUrls(review.getImageUrls())
.likeCount(review.getLikeCount())
.dislikeCount(review.getDislikeCount())
.createdAt(review.getCreatedAt())
.build())
.collect(Collectors.toList());
}
}
@@ -0,0 +1,105 @@
package com.ktds.hi.review.biz.service;
import com.ktds.hi.review.biz.usecase.in.ManageReviewReactionUseCase;
import com.ktds.hi.review.biz.usecase.out.ReviewReactionRepository;
import com.ktds.hi.review.biz.usecase.out.ReviewRepository;
import com.ktds.hi.review.biz.domain.ReviewReaction;
import com.ktds.hi.review.biz.domain.ReactionType;
import com.ktds.hi.review.biz.domain.Review;
import com.ktds.hi.review.infra.dto.response.ReviewReactionResponse;
import com.ktds.hi.common.exception.BusinessException;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.util.Optional;
/**
* 리뷰 반응 인터랙터 클래스
* 리뷰 좋아요/싫어요 기능을 구현
*/
@Service
@RequiredArgsConstructor
@Slf4j
@Transactional
public class ReviewReactionInteractor implements ManageReviewReactionUseCase {
private final ReviewReactionRepository reactionRepository;
private final ReviewRepository reviewRepository;
@Override
public ReviewReactionResponse addReaction(Long reviewId, Long memberId, ReactionType reactionType) {
// 리뷰 존재 확인
Review review = reviewRepository.findReviewById(reviewId)
.orElseThrow(() -> new BusinessException("존재하지 않는 리뷰입니다"));
// 기존 반응 확인
Optional<ReviewReaction> existingReaction = reactionRepository
.findReactionByReviewIdAndMemberId(reviewId, memberId);
if (existingReaction.isPresent()) {
ReviewReaction reaction = existingReaction.get();
if (reaction.getReactionType() == reactionType) {
throw new BusinessException("이미 같은 반응을 등록했습니다");
}
// 반응 유형 변경
ReviewReaction updatedReaction = reaction.updateReactionType(reactionType);
reactionRepository.saveReaction(updatedReaction);
} else {
// 새로운 반응 생성
ReviewReaction newReaction = ReviewReaction.builder()
.reviewId(reviewId)
.memberId(memberId)
.reactionType(reactionType)
.createdAt(LocalDateTime.now())
.build();
reactionRepository.saveReaction(newReaction);
}
// 반응 개수 업데이트
updateReactionCounts(reviewId);
log.info("리뷰 반응 추가: reviewId={}, memberId={}, type={}", reviewId, memberId, reactionType);
return ReviewReactionResponse.builder()
.success(true)
.message("반응이 등록되었습니다")
.build();
}
@Override
public ReviewReactionResponse removeReaction(Long reviewId, Long memberId) {
ReviewReaction reaction = reactionRepository.findReactionByReviewIdAndMemberId(reviewId, memberId)
.orElseThrow(() -> new BusinessException("등록된 반응이 없습니다"));
reactionRepository.deleteReaction(reaction.getId());
// 반응 개수 업데이트
updateReactionCounts(reviewId);
log.info("리뷰 반응 제거: reviewId={}, memberId={}", reviewId, memberId);
return ReviewReactionResponse.builder()
.success(true)
.message("반응이 제거되었습니다")
.build();
}
/**
* 리뷰의 반응 개수 업데이트
*/
private void updateReactionCounts(Long reviewId) {
Long likeCount = reactionRepository.countReactionsByReviewIdAndType(reviewId, ReactionType.LIKE);
Long dislikeCount = reactionRepository.countReactionsByReviewIdAndType(reviewId, ReactionType.DISLIKE);
Review review = reviewRepository.findReviewById(reviewId)
.orElseThrow(() -> new BusinessException("존재하지 않는 리뷰입니다"));
Review updatedReview = review.updateLikeCount(likeCount.intValue(), dislikeCount.intValue());
reviewRepository.saveReview(updatedReview);
}
}
@@ -0,0 +1,16 @@
package com.ktds.hi.review.biz.usecase.in;
import com.ktds.hi.review.infra.dto.request.ReviewCreateRequest;
import com.ktds.hi.review.infra.dto.response.ReviewCreateResponse;
/**
* 리뷰 생성 유스케이스 인터페이스
* 새로운 리뷰 작성 기능을 정의
*/
public interface CreateReviewUseCase {
/**
* 리뷰 생성
*/
ReviewCreateResponse createReview(Long memberId, ReviewCreateRequest request);
}
@@ -0,0 +1,15 @@
package com.ktds.hi.review.biz.usecase.in;
import com.ktds.hi.review.infra.dto.response.ReviewDeleteResponse;
/**
* 리뷰 삭제 유스케이스 인터페이스
* 리뷰 삭제 기능을 정의
*/
public interface DeleteReviewUseCase {
/**
* 리뷰 삭제
*/
ReviewDeleteResponse deleteReview(Long reviewId, Long memberId);
}
@@ -0,0 +1,28 @@
package com.ktds.hi.review.biz.usecase.in;
import com.ktds.hi.review.infra.dto.response.ReviewDetailResponse;
import com.ktds.hi.review.infra.dto.response.ReviewListResponse;
import java.util.List;
/**
* 리뷰 조회 유스케이스 인터페이스
* 리뷰 목록 및 상세 조회 기능을 정의
*/
public interface GetReviewUseCase {
/**
* 매장 리뷰 목록 조회
*/
List<ReviewListResponse> getStoreReviews(Long storeId, Integer page, Integer size);
/**
* 리뷰 상세 조회
*/
ReviewDetailResponse getReviewDetail(Long reviewId);
/**
* 내가 작성한 리뷰 목록 조회
*/
List<ReviewListResponse> getMyReviews(Long memberId, Integer page, Integer size);
}
@@ -0,0 +1,28 @@
package com.ktds.hi.review.biz.usecase.in;
import com.ktds.hi.review.infra.dto.request.ReviewCommentRequest;
import com.ktds.hi.review.infra.dto.response.ReviewCommentResponse;
import java.util.List;
/**
* 리뷰 댓글 관리 유스케이스 인터페이스
* 리뷰 댓글 작성, 조회, 삭제 기능을 정의
*/
public interface ManageReviewCommentUseCase {
/**
* 리뷰 댓글 목록 조회
*/
List<ReviewCommentResponse> getReviewComments(Long reviewId);
/**
* 리뷰 댓글 작성 (점주용)
*/
ReviewCommentResponse createComment(Long reviewId, Long ownerId, ReviewCommentRequest request);
/**
* 리뷰 댓글 삭제
*/
void deleteComment(Long commentId, Long ownerId);
}
@@ -0,0 +1,21 @@
package com.ktds.hi.review.biz.usecase.in;
import com.ktds.hi.review.biz.domain.ReactionType;
import com.ktds.hi.review.infra.dto.response.ReviewReactionResponse;
/**
* 리뷰 반응 관리 유스케이스 인터페이스
* 리뷰 좋아요/싫어요 기능을 정의
*/
public interface ManageReviewReactionUseCase {
/**
* 리뷰 반응 추가
*/
ReviewReactionResponse addReaction(Long reviewId, Long memberId, ReactionType reactionType);
/**
* 리뷰 반응 제거
*/
ReviewReactionResponse removeReaction(Long reviewId, Long memberId);
}
@@ -0,0 +1,38 @@
package com.ktds.hi.review.biz.usecase.out;
import com.ktds.hi.review.biz.domain.ReviewComment;
import java.util.List;
import java.util.Optional;
/**
* 리뷰 댓글 리포지토리 인터페이스
* 리뷰 댓글 데이터 영속성 기능을 정의
*/
public interface ReviewCommentRepository {
/**
* 댓글 저장
*/
ReviewComment saveComment(ReviewComment comment);
/**
* 리뷰 ID로 댓글 목록 조회
*/
List<ReviewComment> findCommentsByReviewId(Long reviewId);
/**
* 댓글 ID로 조회
*/
Optional<ReviewComment> findCommentById(Long commentId);
/**
* 댓글 삭제
*/
void deleteComment(Long commentId);
/**
* 댓글 ID와 소유자 ID로 댓글 조회
*/
Optional<ReviewComment> findCommentByIdAndOwnerId(Long commentId, Long ownerId);
}
@@ -0,0 +1,33 @@
package com.ktds.hi.review.biz.usecase.out;
import com.ktds.hi.review.biz.domain.ReviewReaction;
import com.ktds.hi.review.biz.domain.ReactionType;
import java.util.Optional;
/**
* 리뷰 반응 리포지토리 인터페이스
* 리뷰 반응 데이터 영속성 기능을 정의
*/
public interface ReviewReactionRepository {
/**
* 반응 저장
*/
ReviewReaction saveReaction(ReviewReaction reaction);
/**
* 리뷰 ID와 회원 ID로 반응 조회
*/
Optional<ReviewReaction> findReactionByReviewIdAndMemberId(Long reviewId, Long memberId);
/**
* 반응 삭제
*/
void deleteReaction(Long reactionId);
/**
* 리뷰 ID와 반응 유형별 개수 조회
*/
Long countReactionsByReviewIdAndType(Long reviewId, ReactionType reactionType);
}
@@ -0,0 +1,45 @@
package com.ktds.hi.review.biz.usecase.out;
import com.ktds.hi.review.biz.domain.Review;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import java.util.List;
import java.util.Optional;
/**
* 리뷰 리포지토리 인터페이스
* 리뷰 데이터 영속성 기능을 정의
*/
public interface ReviewRepository {
/**
* 리뷰 저장
*/
Review saveReview(Review review);
/**
* 리뷰 ID로 조회
*/
Optional<Review> findReviewById(Long reviewId);
/**
* 매장 ID로 리뷰 목록 조회
*/
Page<Review> findReviewsByStoreId(Long storeId, Pageable pageable);
/**
* 회원 ID로 리뷰 목록 조회
*/
Page<Review> findReviewsByMemberId(Long memberId, Pageable pageable);
/**
* 리뷰 삭제
*/
void deleteReview(Long reviewId);
/**
* 리뷰 ID와 회원 ID로 리뷰 조회
*/
Optional<Review> findReviewByIdAndMemberId(Long reviewId, Long memberId);
}
@@ -0,0 +1,12 @@
package com.ktds.hi.review.infra.config;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
/**
* 리뷰 서비스 설정 클래스
*/
@Configuration
@EnableJpaRepositories(basePackages = "com.ktds.hi.review.infra.gateway.repository")
public class ReviewConfig {
}
@@ -0,0 +1,26 @@
package com.ktds.hi.review.infra.config;
import io.swagger.v3.oas.models.OpenAPI;
import io.swagger.v3.oas.models.info.Info;
import io.swagger.v3.oas.models.servers.Server;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* Swagger 설정 클래스
* API 문서화를 위한 OpenAPI 설정
*/
@Configuration
public class SwaggerConfig {
@Bean
public OpenAPI openAPI() {
return new OpenAPI()
.addServersItem(new Server().url("/"))
.info(new Info()
.title("하이오더 리뷰 관리 서비스 API")
.description("리뷰 작성, 조회, 삭제, 반응, 댓글 등 리뷰 관련 기능을 제공하는 API")
.version("1.0.0"));
}
}
@@ -0,0 +1,64 @@
package com.ktds.hi.review.infra.controller;
import com.ktds.hi.review.biz.usecase.in.ManageReviewCommentUseCase;
import com.ktds.hi.review.infra.dto.request.ReviewCommentRequest;
import com.ktds.hi.review.infra.dto.response.ReviewCommentResponse;
import com.ktds.hi.common.dto.SuccessResponse;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.*;
import java.util.List;
/**
* 리뷰 댓글 관리 컨트롤러 클래스
* 리뷰 댓글 작성, 조회, 삭제 API를 제공
*/
@RestController
@RequestMapping("/api/reviews")
@RequiredArgsConstructor
@Tag(name = "리뷰 댓글 API", description = "리뷰 댓글 작성, 조회, 삭제 관련 API")
public class ReviewCommentController {
private final ManageReviewCommentUseCase manageReviewCommentUseCase;
/**
* 리뷰 댓글 목록 조회 API
*/
@GetMapping("/{reviewId}/comments")
@Operation(summary = "리뷰 댓글 목록 조회", description = "특정 리뷰의 댓글 목록을 조회합니다.")
public ResponseEntity<List<ReviewCommentResponse>> getReviewComments(@PathVariable Long reviewId) {
List<ReviewCommentResponse> comments = manageReviewCommentUseCase.getReviewComments(reviewId);
return ResponseEntity.ok(comments);
}
/**
* 리뷰 댓글 작성 API (점주용)
*/
@PostMapping("/{reviewId}/comments")
@Operation(summary = "리뷰 댓글 작성", description = "점주가 리뷰에 댓글을 작성합니다.")
public ResponseEntity<ReviewCommentResponse> createComment(Authentication authentication,
@PathVariable Long reviewId,
@Valid @RequestBody ReviewCommentRequest request) {
Long ownerId = Long.valueOf(authentication.getName());
ReviewCommentResponse response = manageReviewCommentUseCase.createComment(reviewId, ownerId, request);
return ResponseEntity.ok(response);
}
/**
* 리뷰 댓글 삭제 API
*/
@DeleteMapping("/{reviewId}/comments/{commentId}")
@Operation(summary = "리뷰 댓글 삭제", description = "작성한 리뷰 댓글을 삭제합니다.")
public ResponseEntity<SuccessResponse> deleteComment(Authentication authentication,
@PathVariable Long reviewId,
@PathVariable Long commentId) {
Long ownerId = Long.valueOf(authentication.getName());
manageReviewCommentUseCase.deleteComment(commentId, ownerId);
return ResponseEntity.ok(SuccessResponse.of("댓글이 삭제되었습니다"));
}
}
@@ -0,0 +1,126 @@
package com.ktds.hi.review.infra.controller;
import com.ktds.hi.review.biz.usecase.in.*;
import com.ktds.hi.review.biz.domain.ReactionType;
import com.ktds.hi.review.infra.dto.request.ReviewCreateRequest;
import com.ktds.hi.review.infra.dto.response.*;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.*;
import java.util.List;
/**
* 리뷰 관리 컨트롤러 클래스
* 리뷰 생성, 조회, 삭제 및 반응 관리 API를 제공
*/
@RestController
@RequestMapping("/api/reviews")
@RequiredArgsConstructor
@Tag(name = "리뷰 관리 API", description = "리뷰 작성, 조회, 삭제 및 반응 관리 관련 API")
public class ReviewController {
private final CreateReviewUseCase createReviewUseCase;
private final DeleteReviewUseCase deleteReviewUseCase;
private final GetReviewUseCase getReviewUseCase;
private final ManageReviewReactionUseCase manageReviewReactionUseCase;
/**
* 리뷰 작성 API
*/
@PostMapping
@Operation(summary = "리뷰 작성", description = "새로운 리뷰를 작성합니다.")
public ResponseEntity<ReviewCreateResponse> createReview(Authentication authentication,
@Valid @RequestBody ReviewCreateRequest request) {
Long memberId = Long.valueOf(authentication.getName());
ReviewCreateResponse response = createReviewUseCase.createReview(memberId, request);
return ResponseEntity.ok(response);
}
/**
* 매장 리뷰 목록 조회 API
*/
@GetMapping("/stores/{storeId}")
@Operation(summary = "매장 리뷰 목록 조회", description = "특정 매장의 리뷰 목록을 조회합니다.")
public ResponseEntity<List<ReviewListResponse>> getStoreReviews(@PathVariable Long storeId,
@RequestParam(defaultValue = "0") Integer page,
@RequestParam(defaultValue = "20") Integer size) {
List<ReviewListResponse> reviews = getReviewUseCase.getStoreReviews(storeId, page, size);
return ResponseEntity.ok(reviews);
}
/**
* 리뷰 상세 조회 API
*/
@GetMapping("/{reviewId}")
@Operation(summary = "리뷰 상세 조회", description = "특정 리뷰의 상세 정보를 조회합니다.")
public ResponseEntity<ReviewDetailResponse> getReviewDetail(@PathVariable Long reviewId) {
ReviewDetailResponse review = getReviewUseCase.getReviewDetail(reviewId);
return ResponseEntity.ok(review);
}
/**
* 내가 작성한 리뷰 목록 조회 API
*/
@GetMapping("/my")
@Operation(summary = "내 리뷰 목록 조회", description = "현재 로그인한 회원이 작성한 리뷰 목록을 조회합니다.")
public ResponseEntity<List<ReviewListResponse>> getMyReviews(Authentication authentication,
@RequestParam(defaultValue = "0") Integer page,
@RequestParam(defaultValue = "20") Integer size) {
Long memberId = Long.valueOf(authentication.getName());
List<ReviewListResponse> reviews = getReviewUseCase.getMyReviews(memberId, page, size);
return ResponseEntity.ok(reviews);
}
/**
* 리뷰 삭제 API
*/
@DeleteMapping("/{reviewId}")
@Operation(summary = "리뷰 삭제", description = "작성한 리뷰를 삭제합니다.")
public ResponseEntity<ReviewDeleteResponse> deleteReview(Authentication authentication,
@PathVariable Long reviewId) {
Long memberId = Long.valueOf(authentication.getName());
ReviewDeleteResponse response = deleteReviewUseCase.deleteReview(reviewId, memberId);
return ResponseEntity.ok(response);
}
/**
* 리뷰 좋아요 API
*/
@PostMapping("/{reviewId}/like")
@Operation(summary = "리뷰 좋아요", description = "리뷰에 좋아요를 등록합니다.")
public ResponseEntity<ReviewReactionResponse> likeReview(Authentication authentication,
@PathVariable Long reviewId) {
Long memberId = Long.valueOf(authentication.getName());
ReviewReactionResponse response = manageReviewReactionUseCase.addReaction(reviewId, memberId, ReactionType.LIKE);
return ResponseEntity.ok(response);
}
/**
* 리뷰 싫어요 API
*/
@PostMapping("/{reviewId}/dislike")
@Operation(summary = "리뷰 싫어요", description = "리뷰에 싫어요를 등록합니다.")
public ResponseEntity<ReviewReactionResponse> dislikeReview(Authentication authentication,
@PathVariable Long reviewId) {
Long memberId = Long.valueOf(authentication.getName());
ReviewReactionResponse response = manageReviewReactionUseCase.addReaction(reviewId, memberId, ReactionType.DISLIKE);
return ResponseEntity.ok(response);
}
/**
* 리뷰 반응 제거 API
*/
@DeleteMapping("/{reviewId}/reaction")
@Operation(summary = "리뷰 반응 제거", description = "리뷰에 등록한 반응을 제거합니다.")
public ResponseEntity<ReviewReactionResponse> removeReaction(Authentication authentication,
@PathVariable Long reviewId) {
Long memberId = Long.valueOf(authentication.getName());
ReviewReactionResponse response = manageReviewReactionUseCase.removeReaction(reviewId, memberId);
return ResponseEntity.ok(response);
}
}
@@ -0,0 +1,32 @@
package com.ktds.hi.review.infra.dto.request;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
/**
* 리뷰 댓글 요청 DTO
*/
@Getter
@NoArgsConstructor
@AllArgsConstructor
@Schema(description = "리뷰 댓글 요청")
public class ReviewCommentRequest {
@NotBlank(message = "댓글 내용은 필수입니다")
@Size(min = 5, max = 500, message = "댓글 내용은 5자 이상 500자 이하여야 합니다")
@Schema(description = "댓글 내용", example = "소중한 리뷰 감사합니다. 더 좋은 서비스로 보답하겠습니다.")
private String content;
/**
* 유효성 검증
*/
public void validate() {
if (content == null || content.trim().length() < 5) {
throw new IllegalArgumentException("댓글 내용은 5자 이상이어야 합니다");
}
}
}
@@ -0,0 +1,52 @@
package com.ktds.hi.review.infra.dto.request;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.*;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
import java.util.List;
/**
* 리뷰 생성 요청 DTO
*/
@Getter
@NoArgsConstructor
@AllArgsConstructor
@Schema(description = "리뷰 생성 요청")
public class ReviewCreateRequest {
@NotNull(message = "매장 ID는 필수입니다")
@Schema(description = "매장 ID", example = "1")
private Long storeId;
@NotNull(message = "평점은 필수입니다")
@Min(value = 1, message = "평점은 1점 이상이어야 합니다")
@Max(value = 5, message = "평점은 5점 이하여야 합니다")
@Schema(description = "평점 (1-5)", example = "4")
private Integer rating;
@NotBlank(message = "리뷰 내용은 필수입니다")
@Size(min = 10, max = 1000, message = "리뷰 내용은 10자 이상 1000자 이하여야 합니다")
@Schema(description = "리뷰 내용", example = "음식이 정말 맛있었습니다. 서비스도 친절하고 재방문 의사 있습니다.")
private String content;
@Schema(description = "이미지 URL 목록", example = "[\"https://example.com/image1.jpg\", \"https://example.com/image2.jpg\"]")
private List<String> imageUrls;
/**
* 유효성 검증
*/
public void validate() {
if (storeId == null || storeId <= 0) {
throw new IllegalArgumentException("유효한 매장 ID가 필요합니다");
}
if (rating == null || rating < 1 || rating > 5) {
throw new IllegalArgumentException("평점은 1점에서 5점 사이여야 합니다");
}
if (content == null || content.trim().length() < 10) {
throw new IllegalArgumentException("리뷰 내용은 10자 이상이어야 합니다");
}
}
}
@@ -0,0 +1,32 @@
package com.ktds.hi.review.infra.dto.response;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
/**
* 리뷰 댓글 응답 DTO
*/
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Schema(description = "리뷰 댓글 응답")
public class ReviewCommentResponse {
@Schema(description = "댓글 ID")
private Long commentId;
@Schema(description = "점주 닉네임")
private String ownerNickname;
@Schema(description = "댓글 내용")
private String content;
@Schema(description = "작성일시")
private LocalDateTime createdAt;
}
@@ -0,0 +1,24 @@
package com.ktds.hi.review.infra.dto.response;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
/**
* 리뷰 생성 응답 DTO
*/
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Schema(description = "리뷰 생성 응답")
public class ReviewCreateResponse {
@Schema(description = "생성된 리뷰 ID")
private Long reviewId;
@Schema(description = "응답 메시지")
private String message;
}
@@ -0,0 +1,24 @@
package com.ktds.hi.review.infra.dto.response;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
/**
* 리뷰 삭제 응답 DTO
*/
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Schema(description = "리뷰 삭제 응답")
public class ReviewDeleteResponse {
@Schema(description = "성공 여부")
private Boolean success;
@Schema(description = "응답 메시지")
private String message;
}
@@ -0,0 +1,48 @@
package com.ktds.hi.review.infra.dto.response;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
import java.util.List;
/**
* 리뷰 상세 응답 DTO
*/
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Schema(description = "리뷰 상세 응답")
public class ReviewDetailResponse {
@Schema(description = "리뷰 ID")
private Long reviewId;
@Schema(description = "매장 ID")
private Long storeId;
@Schema(description = "작성자 닉네임")
private String memberNickname;
@Schema(description = "평점")
private Integer rating;
@Schema(description = "리뷰 내용")
private String content;
@Schema(description = "이미지 URL 목록")
private List<String> imageUrls;
@Schema(description = "좋아요 수")
private Integer likeCount;
@Schema(description = "싫어요 수")
private Integer dislikeCount;
@Schema(description = "작성일시")
private LocalDateTime createdAt;
}
@@ -0,0 +1,45 @@
package com.ktds.hi.review.infra.dto.response;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
import java.util.List;
/**
* 리뷰 목록 응답 DTO
*/
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Schema(description = "리뷰 목록 응답")
public class ReviewListResponse {
@Schema(description = "리뷰 ID")
private Long reviewId;
@Schema(description = "작성자 닉네임")
private String memberNickname;
@Schema(description = "평점")
private Integer rating;
@Schema(description = "리뷰 내용")
private String content;
@Schema(description = "이미지 URL 목록")
private List<String> imageUrls;
@Schema(description = "좋아요 수")
private Integer likeCount;
@Schema(description = "싫어요 수")
private Integer dislikeCount;
@Schema(description = "작성일시")
private LocalDateTime createdAt;
}
@@ -0,0 +1,24 @@
package com.ktds.hi.review.infra.dto.response;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
/**
* 리뷰 반응 응답 DTO
*/
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Schema(description = "리뷰 반응 응답")
public class ReviewReactionResponse {
@Schema(description = "성공 여부")
private Boolean success;
@Schema(description = "응답 메시지")
private String message;
}
@@ -0,0 +1,83 @@
package com.ktds.hi.review.infra.gateway;
import com.ktds.hi.review.biz.usecase.out.ReviewCommentRepository;
import com.ktds.hi.review.biz.domain.ReviewComment;
import com.ktds.hi.review.infra.gateway.repository.ReviewCommentJpaRepository;
import com.ktds.hi.review.infra.gateway.entity.ReviewCommentEntity;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
import java.util.List;
import java.util.Optional;
import java.util.stream.Collectors;
/**
* 리뷰 댓글 리포지토리 어댑터 클래스
* 도메인 리포지토리 인터페이스를 JPA 리포지토리에 연결
*/
@Component
@RequiredArgsConstructor
public class ReviewCommentRepositoryAdapter implements ReviewCommentRepository {
private final ReviewCommentJpaRepository reviewCommentJpaRepository;
@Override
public ReviewComment saveComment(ReviewComment comment) {
ReviewCommentEntity entity = toEntity(comment);
ReviewCommentEntity savedEntity = reviewCommentJpaRepository.save(entity);
return toDomain(savedEntity);
}
@Override
public List<ReviewComment> findCommentsByReviewId(Long reviewId) {
List<ReviewCommentEntity> entities = reviewCommentJpaRepository.findByReviewIdOrderByCreatedAtDesc(reviewId);
return entities.stream()
.map(this::toDomain)
.collect(Collectors.toList());
}
@Override
public Optional<ReviewComment> findCommentById(Long commentId) {
return reviewCommentJpaRepository.findById(commentId)
.map(this::toDomain);
}
@Override
public void deleteComment(Long commentId) {
reviewCommentJpaRepository.deleteById(commentId);
}
@Override
public Optional<ReviewComment> findCommentByIdAndOwnerId(Long commentId, Long ownerId) {
return reviewCommentJpaRepository.findByIdAndOwnerId(commentId, ownerId)
.map(this::toDomain);
}
/**
* 엔티티를 도메인으로 변환
*/
private ReviewComment toDomain(ReviewCommentEntity entity) {
return ReviewComment.builder()
.id(entity.getId())
.reviewId(entity.getReviewId())
.ownerId(entity.getOwnerId())
.ownerNickname(entity.getOwnerNickname())
.content(entity.getContent())
.createdAt(entity.getCreatedAt())
.updatedAt(entity.getUpdatedAt())
.build();
}
/**
* 도메인을 엔티티로 변환
*/
private ReviewCommentEntity toEntity(ReviewComment domain) {
return ReviewCommentEntity.builder()
.id(domain.getId())
.reviewId(domain.getReviewId())
.ownerId(domain.getOwnerId())
.ownerNickname(domain.getOwnerNickname())
.content(domain.getContent())
.build();
}
}
@@ -0,0 +1,70 @@
package com.ktds.hi.review.infra.gateway;
import com.ktds.hi.review.biz.usecase.out.ReviewReactionRepository;
import com.ktds.hi.review.biz.domain.ReviewReaction;
import com.ktds.hi.review.biz.domain.ReactionType;
import com.ktds.hi.review.infra.gateway.repository.ReviewReactionJpaRepository;
import com.ktds.hi.review.infra.gateway.entity.ReviewReactionEntity;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
import java.util.Optional;
/**
* 리뷰 반응 리포지토리 어댑터 클래스
* 도메인 리포지토리 인터페이스를 JPA 리포지토리에 연결
*/
@Component
@RequiredArgsConstructor
public class ReviewReactionRepositoryAdapter implements ReviewReactionRepository {
private final ReviewReactionJpaRepository reviewReactionJpaRepository;
@Override
public ReviewReaction saveReaction(ReviewReaction reaction) {
ReviewReactionEntity entity = toEntity(reaction);
ReviewReactionEntity savedEntity = reviewReactionJpaRepository.save(entity);
return toDomain(savedEntity);
}
@Override
public Optional<ReviewReaction> findReactionByReviewIdAndMemberId(Long reviewId, Long memberId) {
return reviewReactionJpaRepository.findByReviewIdAndMemberId(reviewId, memberId)
.map(this::toDomain);
}
@Override
public void deleteReaction(Long reactionId) {
reviewReactionJpaRepository.deleteById(reactionId);
}
@Override
public Long countReactionsByReviewIdAndType(Long reviewId, ReactionType reactionType) {
return reviewReactionJpaRepository.countByReviewIdAndReactionType(reviewId, reactionType);
}
/**
* 엔티티를 도메인으로 변환
*/
private ReviewReaction toDomain(ReviewReactionEntity entity) {
return ReviewReaction.builder()
.id(entity.getId())
.reviewId(entity.getReviewId())
.memberId(entity.getMemberId())
.reactionType(entity.getReactionType())
.createdAt(entity.getCreatedAt())
.build();
}
/**
* 도메인을 엔티티로 변환
*/
private ReviewReactionEntity toEntity(ReviewReaction domain) {
return ReviewReactionEntity.builder()
.id(domain.getId())
.reviewId(domain.getReviewId())
.memberId(domain.getMemberId())
.reactionType(domain.getReactionType())
.build();
}
}
@@ -0,0 +1,98 @@
package com.ktds.hi.review.infra.gateway;
import com.ktds.hi.review.biz.usecase.out.ReviewRepository;
import com.ktds.hi.review.biz.domain.Review;
import com.ktds.hi.review.biz.domain.ReviewStatus;
import com.ktds.hi.review.infra.gateway.repository.ReviewJpaRepository;
import com.ktds.hi.review.infra.gateway.entity.ReviewEntity;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Component;
import java.util.Optional;
/**
* 리뷰 리포지토리 어댑터 클래스
* 도메인 리포지토리 인터페이스를 JPA 리포지토리에 연결
*/
@Component
@RequiredArgsConstructor
public class ReviewRepositoryAdapter implements ReviewRepository {
private final ReviewJpaRepository reviewJpaRepository;
@Override
public Review saveReview(Review review) {
ReviewEntity entity = toEntity(review);
ReviewEntity savedEntity = reviewJpaRepository.save(entity);
return toDomain(savedEntity);
}
@Override
public Optional<Review> findReviewById(Long reviewId) {
return reviewJpaRepository.findById(reviewId)
.map(this::toDomain);
}
@Override
public Page<Review> findReviewsByStoreId(Long storeId, Pageable pageable) {
Page<ReviewEntity> entities = reviewJpaRepository.findByStoreIdAndStatus(storeId, ReviewStatus.ACTIVE, pageable);
return entities.map(this::toDomain);
}
@Override
public Page<Review> findReviewsByMemberId(Long memberId, Pageable pageable) {
Page<ReviewEntity> entities = reviewJpaRepository.findByMemberIdAndStatus(memberId, ReviewStatus.ACTIVE, pageable);
return entities.map(this::toDomain);
}
@Override
public void deleteReview(Long reviewId) {
reviewJpaRepository.deleteById(reviewId);
}
@Override
public Optional<Review> findReviewByIdAndMemberId(Long reviewId, Long memberId) {
return reviewJpaRepository.findByIdAndMemberId(reviewId, memberId)
.map(this::toDomain);
}
/**
* 엔티티를 도메인으로 변환
*/
private Review toDomain(ReviewEntity entity) {
return Review.builder()
.id(entity.getId())
.storeId(entity.getStoreId())
.memberId(entity.getMemberId())
.memberNickname(entity.getMemberNickname())
.rating(entity.getRating())
.content(entity.getContent())
.imageUrls(entity.getImageUrls())
.status(entity.getStatus())
.likeCount(entity.getLikeCount())
.dislikeCount(entity.getDislikeCount())
.createdAt(entity.getCreatedAt())
.updatedAt(entity.getUpdatedAt())
.build();
}
/**
* 도메인을 엔티티로 변환
*/
private ReviewEntity toEntity(Review domain) {
return ReviewEntity.builder()
.id(domain.getId())
.storeId(domain.getStoreId())
.memberId(domain.getMemberId())
.memberNickname(domain.getMemberNickname())
.rating(domain.getRating())
.content(domain.getContent())
.imageUrls(domain.getImageUrls())
.status(domain.getStatus())
.likeCount(domain.getLikeCount())
.dislikeCount(domain.getDislikeCount())
.build();
}
}
@@ -0,0 +1,50 @@
package com.ktds.hi.review.infra.gateway.entity;
import jakarta.persistence.*;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.annotation.LastModifiedDate;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;
import java.time.LocalDateTime;
/**
* 리뷰 댓글 엔티티 클래스
* 데이터베이스 review_comments 테이블과 매핑되는 JPA 엔티티
*/
@Entity
@Table(name = "review_comments")
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
@EntityListeners(AuditingEntityListener.class)
public class ReviewCommentEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "review_id", nullable = false)
private Long reviewId;
@Column(name = "owner_id", nullable = false)
private Long ownerId;
@Column(name = "owner_nickname", nullable = false, length = 50)
private String ownerNickname;
@Column(nullable = false, length = 500)
private String content;
@CreatedDate
@Column(name = "created_at", updatable = false)
private LocalDateTime createdAt;
@LastModifiedDate
@Column(name = "updated_at")
private LocalDateTime updatedAt;
}
@@ -0,0 +1,74 @@
package com.ktds.hi.review.infra.gateway.entity;
import com.ktds.hi.review.biz.domain.ReviewStatus;
import jakarta.persistence.*;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.annotation.LastModifiedDate;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;
import java.time.LocalDateTime;
import java.util.List;
/**
* 리뷰 엔티티 클래스
* 데이터베이스 reviews 테이블과 매핑되는 JPA 엔티티
*/
@Entity
@Table(name = "reviews")
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
@EntityListeners(AuditingEntityListener.class)
public class ReviewEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "store_id", nullable = false)
private Long storeId;
@Column(name = "member_id", nullable = false)
private Long memberId;
@Column(name = "member_nickname", nullable = false, length = 50)
private String memberNickname;
@Column(nullable = false)
private Integer rating;
@Column(nullable = false, length = 1000)
private String content;
@ElementCollection
@CollectionTable(name = "review_images",
joinColumns = @JoinColumn(name = "review_id"))
@Column(name = "image_url")
private List<String> imageUrls;
@Enumerated(EnumType.STRING)
@Column(nullable = false)
@Builder.Default
private ReviewStatus status = ReviewStatus.ACTIVE;
@Column(name = "like_count")
@Builder.Default
private Integer likeCount = 0;
@Column(name = "dislike_count")
@Builder.Default
private Integer dislikeCount = 0;
@CreatedDate
@Column(name = "created_at", updatable = false)
private LocalDateTime createdAt;
@LastModifiedDate
@Column(name = "updated_at")
private LocalDateTime updatedAt;
}
@@ -0,0 +1,45 @@
package com.ktds.hi.review.infra.gateway.entity;
import com.ktds.hi.review.biz.domain.ReactionType;
import jakarta.persistence.*;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;
import java.time.LocalDateTime;
/**
* 리뷰 반응 엔티티 클래스
* 데이터베이스 review_reactions 테이블과 매핑되는 JPA 엔티티
*/
@Entity
@Table(name = "review_reactions",
uniqueConstraints = @UniqueConstraint(columnNames = {"review_id", "member_id"}))
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
@EntityListeners(AuditingEntityListener.class)
public class ReviewReactionEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "review_id", nullable = false)
private Long reviewId;
@Column(name = "member_id", nullable = false)
private Long memberId;
@Enumerated(EnumType.STRING)
@Column(name = "reaction_type", nullable = false)
private ReactionType reactionType;
@CreatedDate
@Column(name = "created_at", updatable = false)
private LocalDateTime createdAt;
}
@@ -0,0 +1,31 @@
package com.ktds.hi.review.infra.gateway.repository;
import com.ktds.hi.review.infra.gateway.entity.ReviewCommentEntity;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.List;
import java.util.Optional;
/**
* 리뷰 댓글 JPA 리포지토리 인터페이스
* 리뷰 댓글 데이터의 CRUD 작업을 담당
*/
@Repository
public interface ReviewCommentJpaRepository extends JpaRepository<ReviewCommentEntity, Long> {
/**
* 리뷰 ID로 댓글 목록 조회 (최신순)
*/
List<ReviewCommentEntity> findByReviewIdOrderByCreatedAtDesc(Long reviewId);
/**
* 댓글 ID와 소유자 ID로 댓글 조회
*/
Optional<ReviewCommentEntity> findByIdAndOwnerId(Long id, Long ownerId);
/**
* 소유자 ID로 댓글 목록 조회
*/
List<ReviewCommentEntity> findByOwnerIdOrderByCreatedAtDesc(Long ownerId);
}
@@ -0,0 +1,38 @@
package com.ktds.hi.review.infra.gateway.repository;
import com.ktds.hi.review.biz.domain.ReviewStatus;
import com.ktds.hi.review.infra.gateway.entity.ReviewEntity;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.Optional;
/**
* 리뷰 JPA 리포지토리 인터페이스
* 리뷰 데이터의 CRUD 작업을 담당
*/
@Repository
public interface ReviewJpaRepository extends JpaRepository<ReviewEntity, Long> {
/**
* 매장 ID와 상태로 리뷰 목록 조회
*/
Page<ReviewEntity> findByStoreIdAndStatus(Long storeId, ReviewStatus status, Pageable pageable);
/**
* 회원 ID와 상태로 리뷰 목록 조회
*/
Page<ReviewEntity> findByMemberIdAndStatus(Long memberId, ReviewStatus status, Pageable pageable);
/**
* 리뷰 ID와 회원 ID로 리뷰 조회
*/
Optional<ReviewEntity> findByIdAndMemberId(Long id, Long memberId);
/**
* 매장 ID와 회원 ID로 리뷰 존재 여부 확인
*/
boolean existsByStoreIdAndMemberId(Long storeId, Long memberId);
}
@@ -0,0 +1,31 @@
package com.ktds.hi.review.infra.gateway.repository;
import com.ktds.hi.review.biz.domain.ReactionType;
import com.ktds.hi.review.infra.gateway.entity.ReviewReactionEntity;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.Optional;
/**
* 리뷰 반응 JPA 리포지토리 인터페이스
* 리뷰 반응 데이터의 CRUD 작업을 담당
*/
@Repository
public interface ReviewReactionJpaRepository extends JpaRepository<ReviewReactionEntity, Long> {
/**
* 리뷰 ID와 회원 ID로 반응 조회
*/
Optional<ReviewReactionEntity> findByReviewIdAndMemberId(Long reviewId, Long memberId);
/**
* 리뷰 ID와 반응 유형별 개수 조회
*/
Long countByReviewIdAndReactionType(Long reviewId, ReactionType reactionType);
/**
* 회원 ID로 반응 목록 조회
*/
Long countByMemberId(Long memberId);
}
+42
View File
@@ -0,0 +1,42 @@
server:
port: ${REVIEW_SERVICE_PORT:8083}
spring:
application:
name: review-service
datasource:
url: ${REVIEW_DB_URL:jdbc:postgresql://localhost:5432/hiorder_review}
username: ${REVIEW_DB_USERNAME:hiorder_user}
password: ${REVIEW_DB_PASSWORD:hiorder_pass}
driver-class-name: org.postgresql.Driver
jpa:
hibernate:
ddl-auto: ${JPA_DDL_AUTO:validate}
show-sql: ${JPA_SHOW_SQL:false}
properties:
hibernate:
format_sql: true
dialect: org.hibernate.dialect.PostgreSQLDialect
redis:
host: ${REDIS_HOST:localhost}
port: ${REDIS_PORT:6379}
password: ${REDIS_PASSWORD:}
servlet:
multipart:
max-file-size: ${MAX_FILE_SIZE:10MB}
max-request-size: ${MAX_REQUEST_SIZE:50MB}
file-storage:
base-path: ${FILE_STORAGE_PATH:/var/hiorder/uploads}
allowed-extensions: jpg,jpeg,png,gif,webp
max-file-size: 10485760 # 10MB
springdoc:
api-docs:
path: /api-docs
swagger-ui:
path: /swagger-ui.html