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
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
package com.moongeul.backend.api.book.controller;

import com.moongeul.backend.api.book.dto.ReviewResponseDTO;
import com.moongeul.backend.api.book.service.ReviewService;
import com.moongeul.backend.common.response.ApiResponse;
import com.moongeul.backend.common.response.SuccessStatus;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.responses.ApiResponses;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;

@Tag(name = "Book Review", description = "책 리뷰 관련 API 입니다.")
@RestController
@RequiredArgsConstructor
@RequestMapping("/api/v2/book/review/{isbn}")
@Validated
public class ReviewController {

private final ReviewService reviewService;

@Operation(
summary = "도서 리뷰 조회 API",
description = "도서에 대한 게시글(리뷰)을 최신순으로 조회하는 API 입니다."
)
@ApiResponses({
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "리뷰 조회 성공"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "해당 도서를 찾을 수 없습니다.")
})
@GetMapping
public ResponseEntity<ApiResponse<ReviewResponseDTO>> getReviews(
@PathVariable @NotBlank(message = "ISBN은 필수입니다.") String isbn,
@RequestParam(required = false, defaultValue = "1") @Min(value = 1, message = "페이지는 1 이상이어야 합니다.") Integer page,
@RequestParam(required = false, defaultValue = "10") @Min(value = 1, message = "한 페이지당 개수는 1 이상이어야 합니다.") Integer size) {

ReviewResponseDTO response = reviewService.getReviews(isbn, page, size);
return ApiResponse.success(SuccessStatus.GET_BOOK_REVIEWS_SUCCESS, response);
}
}

Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,6 @@ public class BookSearchResponseDTO {
private Integer size; // 페이지당 개수
private Integer totalPages; // 전체 페이지 수
private Boolean isLast; // 마지막 페이지 여부
private List<BookDTO> books; // 책 목록
private List<BookDTO> data; // 책 목록
}

18 changes: 18 additions & 0 deletions src/main/java/com/moongeul/backend/api/book/dto/ReviewItemDTO.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package com.moongeul.backend.api.book.dto;

import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;

@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class ReviewItemDTO {
private Long postId; // 게시글 ID
private String nickname; // 사용자 닉네임
private Double rating; // 별점
private String content; // 내용
}

Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package com.moongeul.backend.api.book.dto;

import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotNull;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;

@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class ReviewRequestDTO {

@NotNull(message = "평점은 필수입니다")
@Min(value = 1, message = "평점은 1 이상이어야 합니다")
@Max(value = 5, message = "평점은 5 이하이어야 합니다")
private Integer rating; // 평점 (1-5)

private String content; // 리뷰 내용
}

Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package com.moongeul.backend.api.book.dto;

import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;

import java.util.List;

@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class ReviewResponseDTO {
private Long total; // 전체 리뷰 개수
private Integer page; // 현재 페이지
private Integer size; // 페이지당 개수
private Integer totalPages; // 전체 페이지 수
private Boolean isLast; // 마지막 페이지 여부
private List<ReviewItemDTO> data; // 리뷰 목록
}

Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ public BookSearchResponseDTO searchBooks(BookSearchRequestDTO bookSearchRequestD

if (naverBookSearchResponseDTO.getItems() == null || naverBookSearchResponseDTO.getItems().isEmpty()) {
return BookSearchResponseDTO.builder()
.books(new ArrayList<>())
.data(new ArrayList<>())
.total(0)
.page(bookSearchRequestDTO.getPage())
.size(bookSearchRequestDTO.getSize())
Expand Down Expand Up @@ -105,7 +105,7 @@ public BookSearchResponseDTO searchBooks(BookSearchRequestDTO bookSearchRequestD
boolean isLast = (start + bookSearchRequestDTO.getSize() - 1) >= total;

return BookSearchResponseDTO.builder()
.books(bookDTOs)
.data(bookDTOs)
.total(total)
.page(bookSearchRequestDTO.getPage())
.size(bookSearchRequestDTO.getSize())
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
package com.moongeul.backend.api.book.service;

import com.moongeul.backend.api.book.dto.ReviewItemDTO;
import com.moongeul.backend.api.book.dto.ReviewResponseDTO;
import com.moongeul.backend.api.book.entity.Book;
import com.moongeul.backend.api.book.repository.BookRepository;
import com.moongeul.backend.api.post.entity.Post;
import com.moongeul.backend.api.post.repository.PostRepository;
import com.moongeul.backend.common.exception.NotFoundException;
import com.moongeul.backend.common.response.ErrorStatus;
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.util.List;
import java.util.stream.Collectors;

@Slf4j
@Service
@RequiredArgsConstructor
public class ReviewService {

private final PostRepository postRepository;
private final BookRepository bookRepository;

// 리뷰 조회 (최신순) - Post 기반
@Transactional(readOnly = true)
public ReviewResponseDTO getReviews(String isbn, Integer page, Integer size) {
Book book = bookRepository.findByIsbn(isbn)
.orElseThrow(() -> new NotFoundException(ErrorStatus.BOOK_NOTFOUND_EXCEPTION.getMessage()));

// 페이지네이션 설정
Pageable pageable = PageRequest.of(page - 1, size);

// 게시글 목록 조회 (최신순)
Page<Post> postPage = postRepository.findByBookOrderByCreatedAtDesc(book, pageable);

List<ReviewItemDTO> reviewItems = postPage.getContent().stream()
.map(this::convertToReviewItemDTO)
.collect(Collectors.toList());

return ReviewResponseDTO.builder()
.total(postPage.getTotalElements())
.page(page)
.size(size)
.totalPages(postPage.getTotalPages())
.isLast(postPage.isLast())
.data(reviewItems)
.build();
}

private ReviewItemDTO convertToReviewItemDTO(Post post) {
return ReviewItemDTO.builder()
.postId(post.getId())
.nickname(post.getMember().getNickname())
.rating(post.getRating())
.content(post.getContent())
.build();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,4 +12,6 @@ public interface MemberRepository extends JpaRepository<Member, Long> {
Optional<Member> findBySocialId(String socialId);

Optional<Member> findByRefreshToken(String refreshToken);

Optional<Member> findByNickname(String nickname);
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import com.moongeul.backend.api.member.entity.Role;
import com.moongeul.backend.api.member.jwt.dto.JwtTokenDTO;
import com.moongeul.backend.api.member.repository.MemberRepository;
import com.moongeul.backend.api.member.util.NicknameGenerator;
import com.moongeul.backend.common.config.jwt.JwtTokenProvider;
import com.moongeul.backend.common.exception.NotFoundException;
import com.moongeul.backend.common.exception.UnauthorizedException;
Expand All @@ -25,6 +26,7 @@ public class MemberService {
private final JwtTokenProvider jwtTokenProvider;
private final GoogleOAuthService googleOAuthService;
private final KakaoOAuthService kakaoOAuthService;
private final NicknameGenerator nicknameGenerator;

// 인가코드 받아 JWT로 교환 및 회원가입/로그인 처리
@Transactional
Expand Down Expand Up @@ -88,12 +90,17 @@ public LoginResponseDTO loginWithKakao(String code){

// 신규 회원가입 처리 로직 (DB 저장)
private Member signUp(String socialId, String email, String name, String picture, String socialType) {

// 랜덤 닉네임 생성
String nickname = nicknameGenerator.generateUniqueNickname();

Member newUser = Member.builder()
.email(email)
.name(name)
.profileImage(picture)
.nickname(nickname)
.password("OAuth Password") // 임시 패스워드
.socialId(socialId) // 예시 사용자명 생성
.socialId(socialId)
.socialType(socialType)
.role(Role.GUEST) // 이후 필요 정보 모두 입력 시 USER 로 승격
.build();
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
package com.moongeul.backend.api.member.util;

import com.moongeul.backend.api.member.repository.MemberRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;

import java.util.List;
import java.util.Random;

@Service
@RequiredArgsConstructor
public class NicknameGenerator {

private final MemberRepository memberRepository;
private final Random random = new Random();

// 랜덤닉네임 (형용사 + 명사 조합) : 200 * 200 = 40,000개
private static final List<String> ADJECTIVES = List.of(
// 1. 감성적 & 분위기 있는 (50)
"고요한", "적막한", "아련한", "애틋한", "아득한", "신비한", "찬란한", "영롱한", "투명한", "화사한",
"은은한", "은밀한", "몽환적인", "아침의", "새벽의", "노을진", "빛나는", "어스름한", "저녁의", "포근한",
"따스한", "차가운", "서늘한", "촉촉한", "보드라운", "소박한", "화려한", "고결한", "우아한", "고독한",
"쓸쓸한", "안온한", "평온한", "안락한", "그리운", "정겨운", "나른한", "온기있는", "무심한", "세심한",
"사색하는", "꿈꾸는", "사라진", "잊혀진", "기다린", "머물던", "흘러간", "숨겨진", "잠든", "깨어난",

// 2. 귀엽고 발랄한 (30)
"귀여운", "깜찍한", "앙증맞은", "폭신한", "말랑한", "뽀짝한", "소중한", "사랑스런", "발랄한", "씩씩한",
"엉뚱한", "장난스러운", "명랑한", "상큼한", "달콤한", "고소한", "상쾌한", "싱그러운", "파릇한", "조그만",
"아기자기한", "쫀득한", "달달한", "몽글한", "매끈한", "빤짝이는", "통통튀는", "재기발랄한", "호기심많은", "듬직한",

// 3. 색상 및 시각적 표현 (40)
"하얀", "새하얀", "까만", "새까만", "붉은", "노란", "푸른", "초록빛", "보랏빛", "분홍빛",
"투명빛", "선명한", "흐릿한", "눈부신", "어두운", "밝은", "잿빛의", "캄캄한", "알록달록한", "은빛의",
"금빛의", "보석같은", "우윳빛", "푸른빛", "하늘색", "샛노란", "샛빨간", "짙푸른", "옅은", "진한",
"창백한", "칙칙한", "일렁이는", "그늘진", "청명한", "연두빛", "빛바랜", "칠흑같은", "노을지는", "무지개빛",

// 4. 상태 및 성격 (48)
"즐거운", "행복한", "슬픈", "침울한", "용감한", "소심한", "대범한", "기묘한", "이상한", "평범한",
"특별한", "위대한", "강력한", "연약한", "강인한", "부지런한", "게으른", "똑똑한", "멍때리는", "차분한",
"삐뚤어진", "느긋한", "가벼운", "무거운", "딱딱한", "단단한", "거친", "냉철한", "끈기있는", "대단한",
"엄청난", "소소한", "확실한", "애매한", "분명한", "친절한", "엄격한", "다정한", "당당한", "굳건한",
"의리있는", "비범한", "융통성있는", "솔직한", "신중한", "무던한", "대담한", "낭만적인",

// 5. 동작 및 상태 (32)
"달리는", "잠자는", "깨어있는", "멈춰진", "흩날리는", "부서진", "쏟아진", "피어난", "가득한", "부족한",
"넘치는", "숨쉬는", "노래하는", "춤추는", "기다리는", "헤매는", "여행하는", "모험하는", "개척하는", "수호하는",
"타오르는", "얼어붙은", "방랑하는", "망가진", "망설이는", "고정된", "열려있는", "닫혀있는", "경이로운", "놀라운",
"오래된", "새로운"
);

private static final List<String> NOUNS = List.of(
// 1. 분위기 있는 사물 및 도구 (50)
"나침반", "지도책", "오르골", "만년필", "돋보기", "망원경", "일기장", "다이어리", "지구본", "유리병",
"깃털펜", "모래함", "태엽기", "유리잔", "손거울", "장난감", "도자기", "비눗물", "손수건", "편지함",
"잉크병", "등잔불", "은수저", "금수저", "보석함", "저금통", "필통함", "시곗방", "우체국", "형광펜",
"타자기", "시계바늘", "모래시계", "열쇠고리", "편지봉투", "비밀지도", "구슬조각", "가죽가방", "보석상자", "잉크방울",
"골동품함", "회전목마", "수정구슬", "천체망원", "램프불빛", "오르골소리", "유리병조각", "롤러코스터", "피아노선율", "종이비행기",

// 2. 자연 및 식물 (50)
"무지개", "은하수", "반딧불", "들판길", "이슬비", "보름달", "눈송이", "새싹들", "단풍잎", "소나기",
"바닷가", "꽃망울", "밤바다", "들꽃들", "숲속길", "나뭇잎", "돌멩이", "안개꽃", "연꽃잎", "낙엽들",
"들녘길", "소나무", "대나무", "파도벽", "번개불", "별조각", "산울림", "벚꽃잎", "아침햇살", "파도소리",
"가을바람", "가을아침", "새벽안개", "새벽이슬", "별빛조각", "숲속나무", "자작나무", "밤하늘별", "파도물결", "초록들판",
"은빛파도", "여름장마", "단풍그늘", "밤공기들", "바닷바람", "달그림자", "수정구슬", "숲속오솔길", "별빛이야기", "풀숲그늘집",

// 3. 동물 및 곤충 (50)
"기린", "고래", "여우", "펭귄", "치타", "사자", "토끼", "하마",
"고양이", "강아지", "호랑이", "다람쥐", "거북이", "독수리", "햄스터", "병아리", "돌고래", "송아지",
"너구리", "두루미", "반달곰", "코끼리", "올빼미", "꾀꼬리", "기러기", "잠자리", "얼룩말", "앵무새",
"꽃사슴", "노랑나비", "호랑나비", "고라",
"사막여우", "북극여우", "아기판다", "아기표범", "바다거북", "황금사자", "토끼인형", "생쥐가족", "아기오리", "꿀벌군단",
"꼬마펭귄", "바다물개", "숲속여우", "무당벌레", "개미핥기", "카피바라", "아기코알라", "흰수염고래",

// 4. 음식 (21)
"마카롱", "복숭아", "사과즙", "초코칩", "꿀단지", "딸기쨈", "푸딩컵", "레몬차", "커피잔", "머핀틀",
"캔디바", "젤리빈", "설탕물", "핫초코", "우유병",
"초코쿠키", "과일푸딩", "사과파이", "호박파이", "호두과자", "포도송이",

// 5. 캐릭터 및 개념 (29)
"마법사", "탐험가", "여행자", "몽상가", "수호자", "개척자", "방랑자", "인도자", "이야기", "멜로디",
"그림자", "발걸음", "추억들", "약속들", "지휘자", "연주자",
"타락천사", "아기요정", "구름나라",
"전설탐험가", "우주여행자", "달빛그림자", "기록보관소", "바다탐험가", "시간여행자", "별빛수호자", "황혼순례자", "비밀도서관", "꿈결수집가"
);

/**
* 중복되지 않는 랜덤 닉네임 생성
* @return 중복되지 않는 랜덤 닉네임
*/
public String generateUniqueNickname() {
String nickname;
do {
nickname = generateRandomNickname();
} while (memberRepository.findByNickname(nickname).isPresent());

return nickname;
}

/**
* 랜덤 닉네임 생성 (형용사 + 명사 조합)
* @return 랜덤 닉네임
*/
private String generateRandomNickname() {
String adjective = ADJECTIVES.get(random.nextInt(ADJECTIVES.size()));
String noun = NOUNS.get(random.nextInt(NOUNS.size()));
return adjective + " " + noun;
}
}
Original file line number Diff line number Diff line change
@@ -1,7 +1,16 @@
package com.moongeul.backend.api.post.repository;

import com.moongeul.backend.api.book.entity.Book;
import com.moongeul.backend.api.post.entity.Post;
import org.springframework.data.domain.Page;
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;

public interface PostRepository extends JpaRepository<Post, Long> {

// 책의 게시글을 최신순으로 조회
@Query("SELECT p FROM Post p WHERE p.book = :book ORDER BY p.createdAt DESC")
Page<Post> findByBookOrderByCreatedAtDesc(@Param("book") Book book, Pageable pageable);
}
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ public enum ErrorStatus {
INVALID_QUESTION_NUMBER(HttpStatus.BAD_REQUEST, "테스트에 잘못된 questionNo(질문번호)가 입력되었습니다. (질문번호: %d)"),
INVALID_ANSWER_VALUE(HttpStatus.BAD_REQUEST, "테스트 답변은 A 또는 B여야 합니다. (질문번호: %d, 현재답변: %s)"),
NO_SCORE_RESULT(HttpStatus.BAD_REQUEST, "테스트 점수 계산 결과가 없습니다."),
REVIEW_UNAUTHORIZED(HttpStatus.BAD_REQUEST, "수정하려는 회원의 리뷰가 아닙니다."),

/**
* 401 UNAUTHORIZED
Expand All @@ -40,11 +41,13 @@ public enum ErrorStatus {
BOOK_NOTFOUND_EXCEPTION(HttpStatus.NOT_FOUND, "해당 도서를 찾을 수 없습니다."),
POST_NOTFOUND_EXCEPTION(HttpStatus.NOT_FOUND, "해당 기록(게시글)을 찾을 수 없습니다."),
CATEGORY_NOTFOUND_EXCEPTION(HttpStatus.NOT_FOUND, "해당 카테고리를 찾을 수 없습니다."),
REVIEW_NOTFOUND_EXCEPTION(HttpStatus.NOT_FOUND, "해당 리뷰를 찾을 수 없습니다."),

/**
* 400 BAD_REQUEST (추가)
* 400 BAD_REQUEST
*/
BOOK_ALREADY_ADDED_EXCEPTION(HttpStatus.BAD_REQUEST, "이미 읽고 싶은 책으로 등록된 도서입니다."),
REVIEW_ALREADY_EXISTS_EXCEPTION(HttpStatus.BAD_REQUEST, "이미 리뷰를 작성한 도서입니다."),

/**
* 500 SERVER_ERROR
Expand Down
Loading