Skip to content

Commit

Permalink
Merge pull request #79 from dnd-side-project/feat/#78-evaluation
Browse files Browse the repository at this point in the history
완독한 책에 평가를 할 수 있다.
  • Loading branch information
f1v3-dev authored Feb 9, 2025
2 parents 53971e1 + d30c43c commit b2d0227
Show file tree
Hide file tree
Showing 11 changed files with 260 additions and 5 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package com.dnd.sbooky.api.docs.spec;

import com.dnd.sbooky.api.evaluation.request.RegisterEvaluationRequest;
import com.dnd.sbooky.api.support.response.ApiResponse;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.springframework.security.core.userdetails.UserDetails;

@Tag(name = "[Evaluation API]", description = "평가에 관련된 API")
public interface RegisterEvaluationApiSpec {

@Operation(summary = "평가 등록", description = "완독한 책에 대해 평가를 등록한다.")
ApiResponse<?> registerEvaluation(
Long memberBookId, RegisterEvaluationRequest request, UserDetails userDetails);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
package com.dnd.sbooky.api.evaluation;

import com.dnd.sbooky.api.docs.spec.RegisterEvaluationApiSpec;
import com.dnd.sbooky.api.evaluation.request.RegisterEvaluationRequest;
import com.dnd.sbooky.api.support.response.ApiResponse;
import io.swagger.v3.oas.annotations.Parameter;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/api")
@RequiredArgsConstructor
public class RegisterEvaluationController implements RegisterEvaluationApiSpec {

private final RegisterEvaluationUseCase registerEvaluationUseCase;

@PutMapping("/books/{memberBookId}/evaluation")
@ResponseStatus(HttpStatus.CREATED)
public ApiResponse<?> registerEvaluation(
@PathVariable Long memberBookId,
@Valid @RequestBody RegisterEvaluationRequest request,
@Parameter(hidden = true) @AuthenticationPrincipal UserDetails user) {

registerEvaluationUseCase.register(memberBookId, extractMemberId(user), request);
return ApiResponse.success();
}

private Long extractMemberId(UserDetails user) {
return Long.valueOf(user.getUsername());
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
package com.dnd.sbooky.api.evaluation;

import com.dnd.sbooky.api.book.exception.BookForbiddenException;
import com.dnd.sbooky.api.book.exception.BookNotFoundException;
import com.dnd.sbooky.api.book.exception.BookReadStatusException;
import com.dnd.sbooky.api.evaluation.exception.EvaluationNotFoundException;
import com.dnd.sbooky.api.evaluation.request.RegisterEvaluationRequest;
import com.dnd.sbooky.api.support.error.ErrorType;
import com.dnd.sbooky.core.book.MemberBookEntity;
import com.dnd.sbooky.core.book.MemberBookRepository;
import com.dnd.sbooky.core.book.ReadStatus;
import com.dnd.sbooky.core.evaluation.BookEvaluationEntity;
import com.dnd.sbooky.core.evaluation.BookEvaluationRepository;
import com.dnd.sbooky.core.evaluation.EvaluationEntity;
import com.dnd.sbooky.core.evaluation.EvaluationRepository;
import java.util.List;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
@RequiredArgsConstructor
@Transactional
public class RegisterEvaluationUseCase {

private final MemberBookRepository memberBookRepository;
private final BookEvaluationRepository bookEvaluationRepository;
private final EvaluationRepository evaluationRepository;

public void register(Long memberBookId, Long memberId, RegisterEvaluationRequest request) {

MemberBookEntity memberBook = validateAndGetMemberBook(memberBookId, memberId);

bookEvaluationRepository.deleteAllByMemberBookId(memberBookId);

List<EvaluationEntity> evaluations = getValidatedEvaluations(request.keywordIds());

List<BookEvaluationEntity> bookEvaluations =
evaluations.stream()
.map(evaluation -> BookEvaluationEntity.newInstance(memberBook, evaluation))
.toList();

bookEvaluationRepository.saveAll(bookEvaluations);
}

private MemberBookEntity validateAndGetMemberBook(Long memberBookId, Long memberId) {
MemberBookEntity memberBook =
memberBookRepository
.findById(memberBookId)
.orElseThrow(() -> new BookNotFoundException(ErrorType.BOOK_NOT_FOUND));

if (!memberBook.isSameMember(memberId)) {
throw new BookForbiddenException(ErrorType.BOOK_ACCESS_FORBIDDEN);
}

if (memberBook.getReadStatus() != ReadStatus.COMPLETED) {
throw new BookReadStatusException(ErrorType.BOOK_READ_STATUS_NOT_COMPLETED);
}

return memberBook;
}

private List<EvaluationEntity> getValidatedEvaluations(List<Long> keywordIds) {
return keywordIds.stream()
.map(
id ->
evaluationRepository
.findById(id)
.orElseThrow(
() ->
new EvaluationNotFoundException(
ErrorType.EVALUATION_KEYWORD_NOT_FOUND)))
.toList();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package com.dnd.sbooky.api.evaluation.exception;

import com.dnd.sbooky.api.support.error.ApiException;
import com.dnd.sbooky.api.support.error.ErrorType;

public class EvaluationNotFoundException extends ApiException {

public EvaluationNotFoundException(ErrorType errorType) {
super(errorType);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package com.dnd.sbooky.api.evaluation.request;

import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import java.util.List;

@Schema(description = "평가 등록 요청 DTO")
public record RegisterEvaluationRequest(
@Schema(description = "평가 키워드 아이디 리스트")
@NotNull @Size(min = 1, max = 6, message = "평가 키워드는 1개 이상 6개 이하로 등록해주세요.") List<Long> keywordIds) {}
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
package com.dnd.sbooky.api.support.error;

import com.dnd.sbooky.api.support.response.ApiResponse;
import java.util.List;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.support.DefaultMessageSourceResolvable;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.MissingRequestCookieException;
import org.springframework.web.bind.MissingServletRequestParameterException;
import org.springframework.web.bind.annotation.ExceptionHandler;
Expand All @@ -25,6 +28,20 @@ public ResponseEntity<ApiResponse<?>> handleApiException(ApiException e) {
.body(ApiResponse.error(e.getErrorType(), e.getData()));
}

@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ApiResponse<?>> handleMethodArgumentNotValidException(
MethodArgumentNotValidException e) {
log.info("MethodArgumentNotValidException = {}", e.getMessage());

List<String> errorMessages =
e.getBindingResult().getFieldErrors().stream()
.map(DefaultMessageSourceResolvable::getDefaultMessage)
.toList();

return ResponseEntity.status(ErrorType.REQUEST_VALIDATION_FAILED.getStatus())
.body(ApiResponse.error(ErrorType.REQUEST_VALIDATION_FAILED, errorMessages));
}

@ExceptionHandler(MissingRequestCookieException.class)
public ResponseEntity<ApiResponse<?>> handleMissingRequestCookieException(
MissingRequestCookieException e) {
Expand Down
21 changes: 18 additions & 3 deletions api/src/main/java/com/dnd/sbooky/api/support/error/ErrorCode.java
Original file line number Diff line number Diff line change
Expand Up @@ -3,17 +3,32 @@
public enum ErrorCode {

// TODO: 에러 코드 추가 필요 (Domain + HTTP Status + [Number])

// Server Error
E500,
E400,
E400_1,
E400_2,
E403,
E404,

// Member Error
MEMBER_404,

// Book Error
BOOK_400_1,
BOOK_403,
BOOK_404,
MEMBER_404,

// Security Error
SECURITY_401_2,
SECURITY_401_3,
SECURITY_404_1,
SECURITY_401_1,

// Item Error
ITEM_404,
SECURITY_401_1

// Evaluation Error
EVALUATION_404_1,
;
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,9 @@ public enum ErrorType {

// Server Error
DEFAULT_ERROR(INTERNAL_SERVER_ERROR, ErrorCode.E500, "Internal server error.", LogLevel.ERROR),
INVALID_PARAMETER(BAD_REQUEST, ErrorCode.E400, "Request parameter is invalid.", LogLevel.INFO),
INVALID_PARAMETER(BAD_REQUEST, ErrorCode.E400_1, "Request parameter is invalid.", LogLevel.INFO),
REQUEST_VALIDATION_FAILED(
BAD_REQUEST, ErrorCode.E400_2, "Request body is invalid", LogLevel.INFO),

// Security Error
INVALID_TOKEN(UNAUTHORIZED, ErrorCode.SECURITY_401_1, "Invalid token.", LogLevel.INFO),
Expand All @@ -35,7 +37,12 @@ public enum ErrorType {
BAD_REQUEST, ErrorCode.BOOK_400_1, "Book read status is not completed.", LogLevel.INFO),

// Item Error
ITEM_NOT_FOUND(NOT_FOUND, ErrorCode.ITEM_404, "Item was not found.", LogLevel.INFO);
ITEM_NOT_FOUND(NOT_FOUND, ErrorCode.ITEM_404, "Item was not found.", LogLevel.INFO),

// Evaluation Error
EVALUATION_KEYWORD_NOT_FOUND(
NOT_FOUND, ErrorCode.EVALUATION_404_1, "Evaluation keyword not found.", LogLevel.INFO),
;

private final HttpStatus status;
private final ErrorCode code;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
package com.dnd.sbooky.core.evaluation;

import com.dnd.sbooky.core.book.MemberBookEntity;
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.Getter;
import lombok.NoArgsConstructor;

@Getter
@Entity
@Table(name = "book_evaluation")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class BookEvaluationEntity {

@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;

@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "member_book_id")
private MemberBookEntity memberBook;

@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "evaluation_id")
private EvaluationEntity evaluation;

private BookEvaluationEntity(MemberBookEntity memberBookEntity, EvaluationEntity evaluation) {
this.memberBook = memberBookEntity;
this.evaluation = evaluation;
}

public static BookEvaluationEntity newInstance(
MemberBookEntity memberBookEntity, EvaluationEntity evaluation) {
return new BookEvaluationEntity(memberBookEntity, evaluation);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package com.dnd.sbooky.core.evaluation;

import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;

public interface BookEvaluationRepository extends JpaRepository<BookEvaluationEntity, Long> {

@Modifying
@Query("DELETE FROM BookEvaluationEntity be WHERE be.memberBook.id = :memberBookId")
void deleteAllByMemberBookId(@Param("memberBookId") Long memberBookId);
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package com.dnd.sbooky.core.evaluation;

import java.util.Arrays;
import lombok.Getter;

@Getter
Expand Down Expand Up @@ -29,4 +30,11 @@ public enum EvaluationKeyword {
this.type = type;
this.description = description;
}

public static EvaluationKeyword fromId(Long id) {
return Arrays.stream(values())
.filter(keyword -> keyword.getId().equals(id))
.findFirst()
.orElseThrow(() -> new IllegalArgumentException("Invalid evaluation ID: " + id));
}
}

0 comments on commit b2d0227

Please sign in to comment.