Skip to content
Merged
Show file tree
Hide file tree
Changes from 34 commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
4e4d3c6
Feat: 리뷰 엔티티 작성
conradrado Jul 3, 2026
81f096d
Feat: Api 인터페이스를 swagger에 맞게 명세 및 컨트롤러 구현
conradrado Jul 3, 2026
281f0e8
Feat: Service, Repository 구현 및 예외 작성
conradrado Jul 3, 2026
7f1a0ba
Merge branch 'dev' of https://github.com/sb10-part4-team5/sb10-mopl-t…
conradrado Jul 3, 2026
f15b09d
Fix: 테이블 컬럼명과 엔티티 컬럼명이 달라 생기는 문제 해결
conradrado Jul 3, 2026
ced8ed1
Merge branch 'dev' of https://github.com/sb10-part4-team5/sb10-mopl-t…
conradrado Jul 3, 2026
469305d
Fix: 코드래빗 피드백 반영
conradrado Jul 3, 2026
2d491de
Fix: ReviewQueryRepositoryImpl에서 던지던 IllegalArgument..Exception을 Busi…
conradrado Jul 3, 2026
49c62fc
Fix: 리뷰 수정 요청 DTO 평점 필드에 NotNull 어노테이션 추가
conradrado Jul 3, 2026
fefed22
Fix: Entity의 필요없는 검증 메서드 제거
conradrado Jul 3, 2026
682daca
Feat: 서비스 계층 로그 추가
conradrado Jul 3, 2026
c6a7e74
Refactor: Refactor: Review 엔티티 content_id를 @ManyToOne 매핑으로 전환
conradrado Jul 3, 2026
e65b429
Fix: 레포지토리 계층에서 직접접으로 contentId를 참조해서 생기는 문제를 해결
conradrado Jul 3, 2026
0b298b6
Merge branch 'dev' of https://github.com/sb10-part4-team5/sb10-mopl-t…
conradrado Jul 4, 2026
b2508e8
Feat: SecurityConfig에서 review 엔드포인트 들어가는 요청 authenticated 조건 걸어놓기
conradrado Jul 4, 2026
d345de0
Refactor: review 엔티티 생성 메서드를 create에서 of로 통일
conradrado Jul 4, 2026
6a4e97b
Test: 리뷰 레포지토리 테스트 코드 작성
conradrado Jul 4, 2026
2e7682a
Test: 서비스 계층 테스트 코드 작성 및 주석 작성
conradrado Jul 5, 2026
46696b4
Test: 컨트롤러 통합 테스트 코드 작성
conradrado Jul 5, 2026
509a4cb
Refactor: 사용되지 않는 평점 예외 삭제 및 MoplPrincipal로 인증 객체를 받도록 수정
conradrado Jul 5, 2026
ce784b6
Merge branch 'dev' of https://github.com/sb10-part4-team5/sb10-mopl-t…
conradrado Jul 5, 2026
0577804
Refactor: update 요청의 멱등성 보장
conradrado Jul 5, 2026
d0a608c
Refactor: 서비스 테스트 코드에서 타인의 리뷰를 수정할 때 잘못된 메소드 호출 수정
conradrado Jul 5, 2026
d4ceca0
Fix: 컨트롤러 테스트 코드에서 추가된 JwtAuthenticationService를 mocking
conradrado Jul 5, 2026
94c6a53
Refactor: 서비스 테스트 코드에서 Ascending 정렬 조회 테스트 케이스 추가
conradrado Jul 5, 2026
51f290e
Refactor: 서비스 테스트 코드에서 임시로 만들어놨었떤 빈 테스트 케이스 삭제
conradrado Jul 5, 2026
b082c2c
Refactor: SortBy enum화 및 Review 도메인 패키지 내 예외 생성
conradrado Jul 5, 2026
2bebe35
Refactor: SortBy enum과 GetRequestDto 수정
conradrado Jul 5, 2026
e9192cd
Refactor: Playlist 코드 참조하여 parser 구현과 실패 시 예외 처리 구현
conradrado Jul 5, 2026
7ac921b
Refactor: Review 엔티티가 User 객체를 직접 참조
conradrado Jul 5, 2026
e7fe2a9
Refactor: 서비스 & 레포지토리 코드 책임 분리 작업
conradrado Jul 5, 2026
c97032e
Refactor: Review 조회 dto에 @NotNull 어노테이션 제거
conradrado Jul 5, 2026
4509055
Test: 바뀐 구조에 맞게 테스트 코드 수정
conradrado Jul 5, 2026
4ccae25
Merge branch 'dev' of https://github.com/sb10-part4-team5/sb10-mopl-t…
conradrado Jul 5, 2026
6b6c367
Feat: WebConfig에 ReviewSortBy 컨버터 등록
conradrado Jul 5, 2026
6beea71
Fix: 서비스 계층에서 cursor가 blank 값으로 들어오면 null로 처리
conradrado Jul 5, 2026
7274137
Refactor: 피드백 반영
conradrado Jul 6, 2026
3ba47d8
Merge branch 'dev' of https://github.com/sb10-part4-team5/sb10-mopl-t…
conradrado Jul 6, 2026
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
Expand Up @@ -86,6 +86,7 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti
.requestMatchers("/api/conversations/**").authenticated()
.requestMatchers("/api/notifications/**").authenticated()
.requestMatchers("/api/sse/**").authenticated()
.requestMatchers("/api/reviews/**").authenticated()

.requestMatchers(HttpMethod.POST, "/api/auth/reset-password").permitAll()
.requestMatchers("/api/auth/sign-in").permitAll()
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
package com.codeit.team5.mopl.review.contant;

import com.codeit.team5.mopl.review.exception.InvalidReviewSortByException;
import com.codeit.team5.mopl.review.exception.ReviewSortByMismatchException;
import java.time.Instant;
import java.util.function.Function;
import lombok.Getter;
import lombok.RequiredArgsConstructor;

@Getter
@RequiredArgsConstructor
public enum ReviewSortBy {
CREATED_AT("createdAt", Instant::parse),
RATING("rating", Double::parseDouble),
;

private final String sortByType;
private final Function<String, ?> parser;

public static ReviewSortBy from(String string) {
for (ReviewSortBy value : ReviewSortBy.values()) {
if (string.equalsIgnoreCase(value.sortByType)) {
return value;
}
}
throw new InvalidReviewSortByException(string);
}
Comment thread
conradrado marked this conversation as resolved.

public Object parse(String cursor){
try{
return parser.apply(cursor);
} catch (Exception e){
throw new ReviewSortByMismatchException(sortByType, cursor);
}
}
Comment thread
conradrado marked this conversation as resolved.
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
package com.codeit.team5.mopl.review.controller;

import com.codeit.team5.mopl.auth.security.details.MoplPrincipal;
import com.codeit.team5.mopl.global.dto.CursorResponse;
import com.codeit.team5.mopl.review.controller.api.ReviewApi;
import com.codeit.team5.mopl.review.dto.request.ReviewCreateRequest;
import com.codeit.team5.mopl.review.dto.request.ReviewGetRequest;
import com.codeit.team5.mopl.review.dto.request.ReviewUpdateRequest;
import com.codeit.team5.mopl.review.dto.response.ReviewResponse;
import com.codeit.team5.mopl.review.service.ReviewService;
import jakarta.validation.Valid;
import java.util.UUID;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.domain.Sort;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.validation.annotation.Validated;
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.RestController;

@RestController
@RequiredArgsConstructor
@Validated
@Slf4j
@RequestMapping("/api/reviews")
public class ReviewController implements ReviewApi {

private final ReviewService reviewService;

@Override
@GetMapping
public ResponseEntity<CursorResponse<ReviewResponse>> getReviews(@Valid ReviewGetRequest request) {

log.info("리뷰 목록 조회: GET /api/reviews, contentId={}", request.contentId());

CursorResponse<ReviewResponse> response = reviewService.getReviews(request);

return ResponseEntity.ok(response);
}

@Override
@PostMapping
public ResponseEntity<ReviewResponse> createReview(
@AuthenticationPrincipal MoplPrincipal moplPrincipal,
@RequestBody @Valid ReviewCreateRequest request) {

log.info("리뷰 생성: POST /api/reviews, authorId={}", moplPrincipal.getId());

ReviewResponse response = reviewService.createReview(moplPrincipal.getId(), request);

return ResponseEntity.status(HttpStatus.CREATED).body(response);
}

@Override
@PatchMapping("/{reviewId}")
public ResponseEntity<ReviewResponse> updateReview(
@AuthenticationPrincipal MoplPrincipal moplPrincipal,
@PathVariable UUID reviewId,
@RequestBody @Valid ReviewUpdateRequest request) {

log.info("리뷰 수정: PATCH /api/reviews/{}, authorId={}", reviewId, moplPrincipal.getId());

ReviewResponse response = reviewService.updateReview(reviewId, moplPrincipal.getId(), request);

return ResponseEntity.ok(response);
}

@Override
@DeleteMapping("/{reviewId}")
public ResponseEntity<Void> deleteReview(
@AuthenticationPrincipal MoplPrincipal moplPrincipal,
@PathVariable UUID reviewId) {

log.info("리뷰 삭제: DELETE /api/reviews/{}, authorId={}", reviewId, moplPrincipal.getId());

reviewService.deleteReview(reviewId, moplPrincipal.getId());

return ResponseEntity.noContent().build();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
package com.codeit.team5.mopl.review.controller.api;

import com.codeit.team5.mopl.auth.security.details.MoplPrincipal;
import com.codeit.team5.mopl.global.dto.CursorResponse;
import com.codeit.team5.mopl.global.dto.suggestion.ErrorResponseSuggestion;
import com.codeit.team5.mopl.review.dto.request.ReviewCreateRequest;
import com.codeit.team5.mopl.review.dto.request.ReviewGetRequest;
import com.codeit.team5.mopl.review.dto.request.ReviewUpdateRequest;
import com.codeit.team5.mopl.review.dto.response.ReviewResponse;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.responses.ApiResponses;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import java.util.UUID;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestBody;

@Tag(name = "리뷰 관리", description = "리뷰 API")
public interface ReviewApi {

@Operation(operationId = "getReviews", summary = "리뷰 목록 조회 (커서 페이지네이션)")
@ApiResponses({
@ApiResponse(responseCode = "200", description = "조회 성공"),
@ApiResponse(responseCode = "400", description = "잘못된 요청",
content = @Content(schema = @Schema(implementation = ErrorResponseSuggestion.class))),
@ApiResponse(responseCode = "401", description = "인증 오류",
content = @Content(schema = @Schema(implementation = ErrorResponseSuggestion.class))),
@ApiResponse(responseCode = "500", description = "서버 내부 오류",
content = @Content(schema = @Schema(implementation = ErrorResponseSuggestion.class)))
})
ResponseEntity<CursorResponse<ReviewResponse>> getReviews(
@Valid ReviewGetRequest request);

@Operation(operationId = "createReview", summary = "리뷰 생성",
description = "생성한 리뷰는 API 요청자 본인의 리뷰로 생성됩니다.")
@ApiResponses({
@ApiResponse(responseCode = "201", description = "생성 성공"),
@ApiResponse(responseCode = "400", description = "잘못된 요청",
content = @Content(schema = @Schema(implementation = ErrorResponseSuggestion.class))),
@ApiResponse(responseCode = "401", description = "인증 오류",
content = @Content(schema = @Schema(implementation = ErrorResponseSuggestion.class))),
@ApiResponse(responseCode = "409", description = "이미 리뷰 작성함",
content = @Content(schema = @Schema(implementation = ErrorResponseSuggestion.class))),
@ApiResponse(responseCode = "500", description = "서버 오류",
content = @Content(schema = @Schema(implementation = ErrorResponseSuggestion.class)))
})
ResponseEntity<ReviewResponse> createReview(
@Parameter(hidden = true) MoplPrincipal moplPrincipal,
@RequestBody @Valid ReviewCreateRequest request);

@Operation(operationId = "updateReview", summary = "리뷰 수정")
@ApiResponses({
@ApiResponse(responseCode = "200", description = "수정 성공"),
@ApiResponse(responseCode = "400", description = "잘못된 요청",
content = @Content(schema = @Schema(implementation = ErrorResponseSuggestion.class))),
@ApiResponse(responseCode = "401", description = "인증 오류",
content = @Content(schema = @Schema(implementation = ErrorResponseSuggestion.class))),
@ApiResponse(responseCode = "403", description = "권한 없음",
content = @Content(schema = @Schema(implementation = ErrorResponseSuggestion.class))),
@ApiResponse(responseCode = "404", description = "리뷰 없음",
content = @Content(schema = @Schema(implementation = ErrorResponseSuggestion.class))),
@ApiResponse(responseCode = "500", description = "서버 오류",
content = @Content(schema = @Schema(implementation = ErrorResponseSuggestion.class)))
})
ResponseEntity<ReviewResponse> updateReview(
@Parameter(hidden = true) MoplPrincipal moplPrincipal,
@Parameter(description = "리뷰 ID", required = true)
@PathVariable UUID reviewId,
@RequestBody @Valid ReviewUpdateRequest request);

@Operation(operationId = "deleteReview", summary = "리뷰 삭제")
@ApiResponses({
@ApiResponse(responseCode = "204", description = "삭제 성공"),
@ApiResponse(responseCode = "401", description = "인증 오류",
content = @Content(schema = @Schema(implementation = ErrorResponseSuggestion.class))),
@ApiResponse(responseCode = "403", description = "권한 없음",
content = @Content(schema = @Schema(implementation = ErrorResponseSuggestion.class))),
@ApiResponse(responseCode = "404", description = "리뷰 없음",
content = @Content(schema = @Schema(implementation = ErrorResponseSuggestion.class))),
@ApiResponse(responseCode = "500", description = "서버 오류",
content = @Content(schema = @Schema(implementation = ErrorResponseSuggestion.class)))
})
ResponseEntity<Void> deleteReview(
@Parameter(hidden = true) MoplPrincipal moplPrincipal,
@Parameter(description = "리뷰 ID", required = true)
@PathVariable UUID reviewId);
Comment thread
conradrado marked this conversation as resolved.
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
package com.codeit.team5.mopl.review.dto.request;

import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.DecimalMax;
import jakarta.validation.constraints.DecimalMin;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import java.util.UUID;

@Schema(name = "ReviewCreateRequest", description = "리뷰 생성 요청")
public record ReviewCreateRequest(
@Schema(description = "콘텐츠 ID", requiredMode = Schema.RequiredMode.REQUIRED)
@NotNull(message = "contentId는 필수입니다.")
UUID contentId,

@Schema(description = "리뷰 내용", requiredMode = Schema.RequiredMode.REQUIRED)
@NotBlank(message = "리뷰 내용은 필수입니다.")
String text,

@Schema(description = "평점 (0.0 ~ 5.0)", requiredMode = Schema.RequiredMode.REQUIRED)
@NotNull(message = "rating은 필수입니다.")
@DecimalMin(value = "0.0", message = "평점은 0.0 이상이어야 합니다.")
@DecimalMax(value = "5.0", message = "평점은 5.0 이하여야 합니다.")
Double rating
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
package com.codeit.team5.mopl.review.dto.request;

import com.codeit.team5.mopl.review.contant.ReviewSortBy;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotNull;
import java.util.UUID;
import org.springframework.data.domain.Sort;

public record ReviewGetRequest(
@NotNull
UUID contentId,

String cursor,

UUID idAfter,

@Min(1)
@Max(100)
Integer limit,

Sort.Direction sortDirection,

ReviewSortBy sortBy
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package com.codeit.team5.mopl.review.dto.request;

import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.DecimalMax;
import jakarta.validation.constraints.DecimalMin;

@Schema(name = "ReviewUpdateRequest", description = "리뷰 수정 요청")
public record ReviewUpdateRequest(
@Schema(description = "리뷰 내용")
String text,

@Schema(description = "평점 (0.0 ~ 5.0)")
@DecimalMin(value = "0.0", message = "평점은 0.0 이상이어야 합니다.")
@DecimalMax(value = "5.0", message = "평점은 5.0 이하여야 합니다.")
Double rating
) {
Comment thread
conradrado marked this conversation as resolved.
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package com.codeit.team5.mopl.review.dto.response;

import com.codeit.team5.mopl.user.dto.response.UserSummaryResponse;
import java.util.UUID;

public record ReviewResponse(
UUID id,
UUID contentId,
UserSummaryResponse author,
String text,
Double rating
) {
}
66 changes: 66 additions & 0 deletions src/main/java/com/codeit/team5/mopl/review/entity/Review.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
package com.codeit.team5.mopl.review.entity;

import com.codeit.team5.mopl.content.entity.Content;
import com.codeit.team5.mopl.global.entity.BaseUpdatableEntity;
import com.codeit.team5.mopl.user.entity.User;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import jakarta.persistence.UniqueConstraint;
import java.util.UUID;
import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor;

@Entity
@Table(name = "reviews",
uniqueConstraints = @UniqueConstraint(columnNames = {"content_id", "user_id"}))
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Review extends BaseUpdatableEntity {

@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "content_id", nullable = false)
private Content content;

@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id", nullable = false)
private User author;

@Column(columnDefinition = "TEXT", nullable = false)
private String text;

@Column(nullable = false)
private Double rating;

public static Review of(Content content, User author, String text, Double rating) {
return new Review(content, author, text, rating);
}

private Review(Content content, User author, String text, Double rating) {
this.content = content;
this.author = author;
this.text = text;
this.rating = rating;
}

public UUID getContentId() {
return content.getId();
}

public UUID getAuthorId() {
return author.getId();
}

public void update(String text, Double rating) {
if (text != null) {
this.text = text;
}
if(rating != null){
this.rating = rating;
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package com.codeit.team5.mopl.review.exception;

import com.codeit.team5.mopl.global.exception.BusinessException;
import org.springframework.http.HttpStatus;

public class CursorIdAfterNotTogetherException extends BusinessException {


public CursorIdAfterNotTogetherException() {

super(HttpStatus.BAD_REQUEST, "cursor와 idAfter가 같이 제공되지 않습니다.");
}
}
Loading
Loading