Skip to content
Merged
Show file tree
Hide file tree
Changes from 27 commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
0c027aa
feat: Content 단건조회 구현
plzslp Jun 26, 2026
41528e0
build: QueryDSL 의존성 추가
plzslp Jun 26, 2026
8ba7ee2
feat: 콘텐츠 목록 조회 Controller 코드 작성
plzslp Jun 26, 2026
311a6d3
feat: ContentCursorRequest 추가 및 파라미터 변환을 위한 WebConfig 추가
plzslp Jun 26, 2026
a0f9912
feat: Content 엔티티 contentTags 배치사이즈 어노테이션 추가
plzslp Jun 26, 2026
b12e976
feat: QueryDslConfig 추가
plzslp Jun 26, 2026
9241160
feat: Content QueryDslRepository 구현
plzslp Jun 26, 2026
17059b5
feat: 목록 조회 ContentService 로직 작성
plzslp Jun 26, 2026
d6b30da
feat: 목록 조회 ContentMapper 로직 작성
plzslp Jun 26, 2026
0751c8a
test: Content 조회 API 테스트 코드 작성
plzslp Jun 28, 2026
cb95f46
test: Content 조회 Service 로직 테스트 코드 작성
plzslp Jun 28, 2026
7335de0
style: QueryDSL 통합테스트 Todo 주석 추가
plzslp Jun 28, 2026
449981a
refactor: QueryDslTestConfig 추가
plzslp Jun 28, 2026
10f728b
refactor: FollowRepositoryTest에 QueryDslTestConfig import 추가
plzslp Jun 28, 2026
61df9c2
refactor: 업데이트 멀티파트 스키마의 DTO 타입 수정
plzslp Jun 28, 2026
95af603
refactor: limit 검증에 Positive 추가
plzslp Jun 28, 2026
8e7850e
refactor: Controller 테스트 요청값 수정
plzslp Jun 28, 2026
334462f
refactor: 콘텐츠 커서 값 서비스 검증 로직 추가
plzslp Jun 28, 2026
74dadfa
feat: V12 scheam.sql ContentStats average_rating 컬럼 추가
plzslp Jun 28, 2026
34dbbda
refactor: ContentStats averageRating 필드 추가, updateRating() 메서드 추가
plzslp Jun 28, 2026
4b5c0c3
refactor: ContentRepositoryImpl RATE 정렬/커서를 averageRating 기준으로 교체
plzslp Jun 28, 2026
fbb6b1f
refactor: ContentMapper RATE 커서를 getAverageRating()으로 교체
plzslp Jun 28, 2026
e705d49
refactor: ContentMapper 정렬 방향 반환값 ASCENDING/DESCENDING으로 수정
plzslp Jun 28, 2026
dcf7950
refactor: cursor 값을 예외 메시지에서 제거
plzslp Jun 28, 2026
e48ac49
refactor: 콘텐츠 cursor 검증 추가 및 limit 한도 추가
plzslp Jun 28, 2026
6488863
fix: Cursor 예외 사용되지 않는 파라미터 제거
plzslp Jun 29, 2026
53a45db
fix: 피드백 반영
plzslp Jun 29, 2026
151ade2
refactor: 콘텐츠 커서 검증 커스텀 어노테이션으로 리펙토링
plzslp Jun 29, 2026
1d2406e
refactor: WebConfig InitBinder로 분리
plzslp Jun 29, 2026
30cb7e3
fix: 콘텐츠 통계 검증 로직 추가
plzslp Jun 29, 2026
3eb0c61
fix: 커서 idAfter XOR 검증로직 제거 및 Validator 테스트 코드 추가
plzslp Jun 29, 2026
140a20c
fix: N+1 문제 해결 및 XOR 커서 검증 복구
plzslp Jun 29, 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
4 changes: 4 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,10 @@ dependencies {
implementation 'org.flywaydb:flyway-database-postgresql'
implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.17'
implementation 'org.mapstruct:mapstruct:1.6.3'
implementation 'com.querydsl:querydsl-jpa:5.1.0:jakarta'
annotationProcessor 'com.querydsl:querydsl-apt:5.1.0:jakarta'
annotationProcessor 'jakarta.annotation:jakarta.annotation-api'
annotationProcessor 'jakarta.persistence:jakarta.persistence-api'
// AWS SDK (S3)
implementation platform('software.amazon.awssdk:bom:2.46.7')
implementation 'software.amazon.awssdk:s3'
Expand Down
19 changes: 19 additions & 0 deletions src/main/java/com/codeit/team5/mopl/config/QueryDslConfig.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package com.codeit.team5.mopl.config;

import com.querydsl.jpa.impl.JPAQueryFactory;
import jakarta.persistence.EntityManager;
import jakarta.persistence.PersistenceContext;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class QueryDslConfig {

@PersistenceContext
private EntityManager entityManager;

@Bean
public JPAQueryFactory jpaQueryFactory() {
return new JPAQueryFactory(entityManager);
}
}
30 changes: 30 additions & 0 deletions src/main/java/com/codeit/team5/mopl/config/WebConfig.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
package com.codeit.team5.mopl.config;

import com.codeit.team5.mopl.content.entity.ContentSortByType;
import com.codeit.team5.mopl.content.entity.ContentType;
import com.codeit.team5.mopl.global.exception.InvalidSortDirectionException;
import com.codeit.team5.mopl.watcher.constant.SortByType;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.domain.Sort;
import org.springframework.format.FormatterRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

@Configuration
public class WebConfig implements WebMvcConfigurer {

@Override
public void addFormatters(FormatterRegistry registry) {
registry.addConverter(String.class, Sort.Direction.class, value -> {
if (value.equalsIgnoreCase("ASCENDING") || value.equalsIgnoreCase("ASC")) {
return Sort.Direction.ASC;
}
if (value.equalsIgnoreCase("DESCENDING") || value.equalsIgnoreCase("DESC")) {
return Sort.Direction.DESC;
}
throw new InvalidSortDirectionException(value);
});
registry.addConverter(String.class, ContentSortByType.class, ContentSortByType::from);
registry.addConverter(String.class, ContentType.class, ContentType::from);
registry.addConverter(String.class, SortByType.class, SortByType::from);
}
}
Comment thread
metDaisy marked this conversation as resolved.
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
package com.codeit.team5.mopl.content.controller;

import com.codeit.team5.mopl.binarycontent.support.MultipartFiles;
import com.codeit.team5.mopl.content.controller.api.ContentApi;
import com.codeit.team5.mopl.content.dto.request.ContentCreateRequest;
import com.codeit.team5.mopl.content.dto.request.ContentCursorRequest;
import com.codeit.team5.mopl.content.dto.request.ContentUpdateRequest;
import com.codeit.team5.mopl.content.dto.response.ContentResponse;
import com.codeit.team5.mopl.content.service.ContentService;
import com.codeit.team5.mopl.binarycontent.support.MultipartFiles;
import com.codeit.team5.mopl.global.dto.CursorResponse;
import jakarta.validation.Valid;
import java.util.UUID;
import lombok.RequiredArgsConstructor;
Expand All @@ -14,6 +16,7 @@
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
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;
Expand Down Expand Up @@ -53,6 +56,22 @@ public ResponseEntity<ContentResponse> patchContent(
return ResponseEntity.ok(response);
}

@Override
@GetMapping("/{contentId}")
public ResponseEntity<ContentResponse> getContent(@PathVariable UUID contentId) {
log.info("Content Get request: GET /api/contents/{}", contentId);
ContentResponse response = contentService.findById(contentId);
return ResponseEntity.ok(response);
}

@Override
@GetMapping
public ResponseEntity<CursorResponse<ContentResponse>> getContents(@Valid ContentCursorRequest request) {
log.info("Content List request: GET /api/contents");
CursorResponse<ContentResponse> response = contentService.findContents(request);
return ResponseEntity.ok(response);
}

@Override
@DeleteMapping("/{contentId}")
public ResponseEntity<Void> deleteContent(@PathVariable UUID contentId) {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,18 +1,25 @@
package com.codeit.team5.mopl.content.controller.api;

import com.codeit.team5.mopl.content.dto.request.ContentCreateRequest;
import com.codeit.team5.mopl.content.dto.request.ContentCursorRequest;
import com.codeit.team5.mopl.content.dto.request.ContentUpdateRequest;
import com.codeit.team5.mopl.content.dto.response.ContentResponse;
import com.codeit.team5.mopl.global.dto.CursorResponse;
import com.codeit.team5.mopl.global.dto.suggestion.ErrorResponseSuggestion;
import com.codeit.team5.mopl.content.entity.ContentSortByType;
import com.codeit.team5.mopl.content.entity.ContentType;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.Parameters;
import io.swagger.v3.oas.annotations.media.ArraySchema;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Encoding;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.parameters.RequestBody;
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 org.springframework.data.domain.Sort;
import java.util.UUID;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
Expand Down Expand Up @@ -55,13 +62,14 @@ ResponseEntity<ContentResponse> postContent(

@Schema(name = "ContentUpdateMultipartRequest")
class ContentUpdateMultipartRequest {

@Schema(implementation = ContentUpdateRequest.class)
public ContentCreateRequest request;
public ContentUpdateRequest request;

@Schema(type = "string", format = "binary", description = "썸네일 이미지")
public MultipartFile thumbnail;
}

}
@Operation(summary = "[어드민] 콘텐츠 수정", description = "콘텐츠의 제목, 설명, 태그를 수정합니다.")
@ApiResponses({
@ApiResponse(responseCode = "200", description = "성공",
Expand All @@ -87,6 +95,47 @@ ResponseEntity<ContentResponse> patchContent(
@Parameter(hidden = true) ContentUpdateRequest request,
@Parameter(hidden = true) MultipartFile thumbnail
);
@Operation(summary = "콘텐츠 단건 조회", description = "콘텐츠 ID로 단건 조회합니다.")
@ApiResponses({
@ApiResponse(responseCode = "200", description = "성공",
content = @Content(schema = @Schema(implementation = ContentResponse.class))),
@ApiResponse(responseCode = "400", description = "잘못된 요청",
content = @Content(schema = @Schema(implementation = ErrorResponseSuggestion.class))),
@ApiResponse(responseCode = "401", 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<ContentResponse> getContent(@Parameter(description = "콘텐츠 ID") UUID contentId);

@Operation(summary = "콘텐츠 목록 조회", description = "콘텐츠 목록을 커서 기반 페이지네이션으로 조회합니다.")
@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)))
})
@Parameters({
@Parameter(name = "typeEqual", description = "콘텐츠 타입",
schema = @Schema(implementation = ContentType.class)),
@Parameter(name = "keywordLike", description = "검색 키워드"),
@Parameter(name = "tagsIn", description = "태그 목록",
array = @ArraySchema(schema = @Schema(type = "string", example = "string"))),
@Parameter(name = "cursor", description = "커서"),
@Parameter(name = "idAfter", description = "보조 커서"),
@Parameter(name = "limit", description = "한 번에 가져올 개수", example = "20", required = true),
@Parameter(name = "sortDirection", description = "정렬 방향", required = true,
schema = @Schema(type = "string", allowableValues = {"ASCENDING", "DESCENDING"}, defaultValue = "DESCENDING")),
Comment thread
plzslp marked this conversation as resolved.
@Parameter(name = "sortBy", description = "정렬 기준", required = true,
schema = @Schema(type = "string", allowableValues = {"createdAt", "watcherCount", "rate"}, defaultValue = "createdAt"))
})
ResponseEntity<CursorResponse<ContentResponse>> getContents(
@Parameter(hidden = true) ContentCursorRequest request);

@Operation(summary = "[어드민] 콘텐츠 삭제", description = "콘텐츠를 삭제합니다.")
@ApiResponses({
Expand All @@ -103,4 +152,4 @@ ResponseEntity<ContentResponse> patchContent(
content = @Content(schema = @Schema(implementation = ErrorResponseSuggestion.class)))
})
ResponseEntity<Void> deleteContent(@Parameter(description = "콘텐츠 ID") UUID contentId);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
package com.codeit.team5.mopl.content.dto.request;

import com.codeit.team5.mopl.content.entity.ContentSortByType;
import com.codeit.team5.mopl.content.entity.ContentType;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Positive;
import java.util.List;
import org.springframework.data.domain.Sort;

public record ContentCursorRequest(
ContentType typeEqual,

String keywordLike,

List<String> tagsIn,

String cursor,

String idAfter,

@NotNull
@Positive
@Max(100)
Integer limit,
Comment thread
plzslp marked this conversation as resolved.

@NotNull
Sort.Direction sortDirection,

@NotNull
ContentSortByType sortBy
) {
}
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor;
import org.hibernate.annotations.BatchSize;
import org.hibernate.annotations.JdbcTypeCode;
import org.hibernate.type.SqlTypes;
import org.springframework.util.StringUtils;
Expand Down Expand Up @@ -73,6 +74,7 @@ public class Content extends BaseUpdatableEntity {
private ContentStats stats;

@OneToMany(mappedBy = "content", fetch = FetchType.LAZY, cascade = CascadeType.ALL, orphanRemoval = true)
@BatchSize(size = 30)
private Set<ContentTag> contentTags = new HashSet<>();

public static Content createByAdmin(ContentType type, String title, String description) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package com.codeit.team5.mopl.content.entity;

import com.codeit.team5.mopl.content.exception.ContentIncorrectSortByException;
import lombok.Getter;
import lombok.RequiredArgsConstructor;

@Getter
@RequiredArgsConstructor
public enum ContentSortByType {
CREATED_AT("createdAt"),
WATCHER_COUNT("watcherCount"),
RATE("rate")
;
private final String value;

public static ContentSortByType from(String text) {
for (ContentSortByType type : ContentSortByType.values()) {
if (type.getValue().equalsIgnoreCase(text)) {
return type;
}
}
throw new ContentIncorrectSortByException(text);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -30,4 +30,14 @@ public static ContentStats create() {
stats.watcherCount = 0;
return stats;
}

public double getAverageRating() {
if (reviewCount == 0) return 0.0;
return ratingSum / reviewCount;
}

public void updateRating(double newRatingSum, int newReviewCount) {
this.ratingSum = newRatingSum;
this.reviewCount = newReviewCount;
}
Comment thread
plzslp marked this conversation as resolved.
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,21 +9,21 @@ public enum ContentType {
TV_SERIES("tvSeries"),
SPORT("sport");

private final String response;
private final String value;

ContentType(String response) {
this.response = response;
ContentType(String value) {
this.value = value;
}

@JsonValue
public String getResponse() {
return response;
public String getValue() {
return value;
}

@JsonCreator
public static ContentType from(String value) {
for (ContentType type : values()) {
if (type.response.equals(value)) {
if (type.value.equals(value)) {
Comment thread
plzslp marked this conversation as resolved.
return type;
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package com.codeit.team5.mopl.content.exception;

import java.util.Map;
import org.springframework.http.HttpStatus;

public class ContentIncorrectSortByException extends ContentException {

public ContentIncorrectSortByException(String sortBy) {
super(HttpStatus.BAD_REQUEST, "SortBy 입력값이 올바르지 않습니다.", Map.of("incorrectSortBy", sortBy));
Comment thread
plzslp marked this conversation as resolved.
Outdated
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package com.codeit.team5.mopl.content.exception;

import org.springframework.http.HttpStatus;

public class CursorIdAfterNotTogetherException extends ContentException {

public CursorIdAfterNotTogetherException() {
super(HttpStatus.BAD_REQUEST, "cursor와 idAfter가 같이 제공되지 않습니다.");
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package com.codeit.team5.mopl.content.exception;

import org.springframework.http.HttpStatus;

public class InvalidCursorException extends ContentException {

public InvalidCursorException() {
super(HttpStatus.BAD_REQUEST, "커서 값이 유효하지 않습니다.");
}
}
Loading
Loading