Skip to content
Merged
Show file tree
Hide file tree
Changes from 30 commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
e7d170d
feat: Content 엔티티 클래스 작성
plzslp Jun 23, 2026
3337fac
feat: Tag, ContentTag 엔티티 클래스 작성
plzslp Jun 23, 2026
42ecbb1
feat: ContentStats 엔티티 클래스 작성
plzslp Jun 23, 2026
d7cb0ec
refactor: watcher_count DDL 수정 및 Java 필드 타입 변경
plzslp Jun 23, 2026
bcf824c
feat: 콘텐츠 요청/응답 DTO 작성
plzslp Jun 23, 2026
2cd12d4
feat: 콘텐츠 예외처리 추가
plzslp Jun 23, 2026
bc1c868
feat: ContentType Json 변환 로직 추가
plzslp Jun 23, 2026
096b91d
refactor: ContentSource ADMIN 소스 추가
plzslp Jun 23, 2026
186e607
refactor: create 정적 팩토리 메서드 추가 및 Content 연관관계 편의 메서드 추가
plzslp Jun 23, 2026
255845b
feat: ContentMapper 구현
plzslp Jun 23, 2026
b93ed4c
feat: Content 관련 예외 클래스 추가
plzslp Jun 23, 2026
f906c34
build: SwaggerUI 버전 변경
plzslp Jun 23, 2026
e80de6d
refactor: contents 스키마 파일 변경
plzslp Jun 23, 2026
3a1524e
feat: 콘텐츠 컨트롤러 추가
plzslp Jun 23, 2026
889f0de
feat: 콘텐츠, 태그, 콘텐츠 스탯 Repository 추가
plzslp Jun 23, 2026
07533d4
refactor: Tag 디렉토리 구조 변경
plzslp Jun 23, 2026
d94a467
refactor: Content, ContentStats 생성 로직 수정
plzslp Jun 23, 2026
4b6722b
feat: ContentService 콘텐츠 생성 로직 작성
plzslp Jun 23, 2026
273a319
test: ContentService 테스트 코드 작성
plzslp Jun 23, 2026
5c82758
test: ContentController 테스트 코드 작성
plzslp Jun 23, 2026
f3d36a5
refactor: 불필요한 import문 제거
plzslp Jun 23, 2026
df9cc57
refactor: 오버라이드 어노테이션 추가
plzslp Jun 23, 2026
29a1b03
refactor: tag 중복 저장 요청 방지 distinct 추가
plzslp Jun 23, 2026
764f4ce
test: 중복 태그 요청 서비스 테스트 코드 작성
plzslp Jun 23, 2026
3c2074c
feat: Tag Exception 클래스 작성
plzslp Jun 24, 2026
79827cf
refactor: 코드래빗 피드백 반영
plzslp Jun 24, 2026
2b87123
test: WatchingSession 테스트 Content 클래스 변경사항 반영
plzslp Jun 24, 2026
bc7731e
refactor: 코드래빗 리뷰 2차 반영
plzslp Jun 24, 2026
20daeb0
refactor: 코드래빗 리뷰 3차 반영
plzslp Jun 24, 2026
6556afb
refactor: 코드 리뷰 반영
plzslp Jun 24, 2026
bcb55e5
refactor: 새로운 태그가 없을 시 saveAll 호출하지 않도록 수정
plzslp Jun 24, 2026
ac21a78
test: saveAll 호출로 인한 테스트 코드 수정
plzslp Jun 24, 2026
786ea0c
build: Swagger UI 버전 변경
plzslp Jun 24, 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
2 changes: 1 addition & 1 deletion build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ dependencies {
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
implementation 'org.flywaydb:flyway-core'
implementation 'org.flywaydb:flyway-database-postgresql'
implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.6.0'
implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.8'
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
implementation 'org.mapstruct:mapstruct:1.6.3'
// Spring security
implementation 'org.springframework.boot:spring-boot-starter-security'
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
package com.codeit.team5.mopl.content.controller;

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.response.ContentResponse;
import com.codeit.team5.mopl.content.service.ContentService;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestPart;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;

@RestController
@RequestMapping("/api/contents")
@RequiredArgsConstructor
@Slf4j
public class ContentController implements ContentApi {

private final ContentService contentService;

@Override
@PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public ResponseEntity<ContentResponse> postContent(
@Valid @RequestPart("request") ContentCreateRequest request,
@RequestPart(value = "thumbnail", required = false) MultipartFile thumbnail
) {
log.info("Content Create request: POST /api/contents");
ContentResponse response = contentService.create(request, thumbnail);
return ResponseEntity.status(HttpStatus.CREATED).body(response);
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Comment thread
plzslp marked this conversation as resolved.
}
Comment thread
plzslp marked this conversation as resolved.
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
package com.codeit.team5.mopl.content.controller.api;

import com.codeit.team5.mopl.content.dto.request.ContentCreateRequest;
import com.codeit.team5.mopl.content.dto.response.ContentResponse;
import com.codeit.team5.mopl.global.dto.ErrorResponse;
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.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.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.multipart.MultipartFile;

@Tag(name = "콘텐츠 관리", description = "콘텐츠 관련 API")
public interface ContentApi {

@Schema(name = "ContentCreateMultipartRequest")
class MultipartRequest {
@Schema(implementation = ContentCreateRequest.class)
public ContentCreateRequest request;

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

@Operation(summary = "[어드민] 콘텐츠 생성", description = "새로운 콘텐츠를 등록합니다.")
@ApiResponses({
@ApiResponse(responseCode = "201", description = "성공",
content = @Content(schema = @Schema(implementation = ContentResponse.class))),
@ApiResponse(responseCode = "400", description = "잘못된 요청",
content = @Content(schema = @Schema(implementation = ErrorResponse.class))),
@ApiResponse(responseCode = "401", description = "인증 오류",
content = @Content(schema = @Schema(implementation = ErrorResponse.class))),
@ApiResponse(responseCode = "403", description = "권한 오류",
content = @Content(schema = @Schema(implementation = ErrorResponse.class))),
@ApiResponse(responseCode = "500", description = "서버 오류",
content = @Content(schema = @Schema(implementation = ErrorResponse.class)))
})
@RequestBody(content = @Content(
mediaType = MediaType.MULTIPART_FORM_DATA_VALUE,
schema = @Schema(implementation = MultipartRequest.class),
encoding = @Encoding(name = "request", contentType = MediaType.APPLICATION_JSON_VALUE)
))
ResponseEntity<ContentResponse> postContent(
@Parameter(hidden = true) ContentCreateRequest request,
@Parameter(hidden = true) MultipartFile thumbnail
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package com.codeit.team5.mopl.content.dto.request;

import com.codeit.team5.mopl.content.entity.ContentType;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import java.util.List;

public record ContentCreateRequest(
@NotNull(message = "콘텐츠 타입은 필수입니다.")
ContentType type,

@NotBlank(message = "제목은 필수입니다.")
@Size(max = 500, message = "제목은 500자를 초과할 수 없습니다.")
String title,

String description,

@NotNull(message = "콘텐츠 태그는 필수 입니다.")
@NotEmpty(message = "콘텐츠 태그 목록은 비어있을 수 없습니다.")
List<@NotBlank(message = "태그는 공백일 수 없습니다.") String> tags
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package com.codeit.team5.mopl.content.dto.response;

import com.codeit.team5.mopl.content.entity.ContentType;
import java.util.List;
import java.util.UUID;

public record ContentResponse(
UUID id,
ContentType type,
String title,
String description,
String thumbnailUrl,
List<String> tags,
double averageRating,
int reviewCount,
long watcherCount
) {
}
109 changes: 83 additions & 26 deletions src/main/java/com/codeit/team5/mopl/content/entity/Content.java
Original file line number Diff line number Diff line change
@@ -1,59 +1,116 @@
package com.codeit.team5.mopl.content.entity;

import com.codeit.team5.mopl.content.exception.InvalidContentSourceException;
import com.codeit.team5.mopl.global.entity.BaseUpdatableEntity;
import com.codeit.team5.mopl.tag.entity.Tag;
import jakarta.persistence.CascadeType;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.FetchType;
import jakarta.persistence.OneToMany;
import jakarta.persistence.Table;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import jakarta.persistence.UniqueConstraint;
import java.time.Instant;
import java.util.Map;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
import lombok.AccessLevel;
import lombok.Getter;
import lombok.Setter;
import lombok.NoArgsConstructor;
import org.hibernate.annotations.JdbcTypeCode;
import org.hibernate.type.SqlTypes;

@Getter
@Setter
@Entity
@Table(name = "contents")
@Table(
name = "contents",
uniqueConstraints = {
@UniqueConstraint(
name = "uk_contents_source_external_id",
columnNames = {"source", "external_id"}
)
}
)
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Content extends BaseUpdatableEntity {

@Size(max = 20)
@NotNull
@Column(name = "type", nullable = false, length = 20)
private String type;
@Enumerated(EnumType.STRING)
@Column(nullable = false, length = 20)
private ContentType type;

@Size(max = 500)
@NotNull
@Column(name = "title", nullable = false, length = 500)
@Column(nullable = false, length = 500)
private String title;

@Column(name = "description", length = Integer.MAX_VALUE)
@Column(columnDefinition = "TEXT")
private String description;

@Size(max = 512)
@Column(name = "thumbnail_url", length = 512)
private String thumbnailUrl;

@Column(name = "released_at")
private Instant releasedAt;

@JdbcTypeCode(SqlTypes.JSON)
@Column(name = "metadata")
private Map<String, Object> metadata;
@Column(columnDefinition = "JSONB")
private String metadata;

@Size(max = 20)
@NotNull
@Column(name = "source", nullable = false, length = 20)
private String source;
@Enumerated(EnumType.STRING)
@Column(nullable = false, length = 20)
private ContentSource source;

@Size(max = 100)
@NotNull
@Column(name = "external_id", nullable = false, length = 100)
private String externalId;

@NotNull
@Column(name = "updated_at", nullable = false)
private Instant updatedAt;
@OneToMany(mappedBy = "content", fetch = FetchType.LAZY, cascade = CascadeType.ALL, orphanRemoval = true)
private List<ContentTag> contentTags = new ArrayList<>();

public static Content createByAdmin(ContentType type, String title, String description) {
Content content = new Content();
content.type = type;
content.title = title;
content.description = description;
content.source = ContentSource.ADMIN;
content.externalId = UUID.randomUUID().toString();
return content;
}

public static Content createByExternalSource(ContentType type, String title, String description,
ContentSource source, String externalId, Instant releasedAt, String metadata) {
if (source == null || source == ContentSource.ADMIN) {
throw new InvalidContentSourceException("외부 소스 생성에 ADMIN 소스는 사용할 수 없습니다.");
}
if (externalId == null || externalId.isBlank()) {
throw new InvalidContentSourceException("외부 소스 콘텐츠는 externalId가 필수입니다.");
}
Content content = new Content();
content.type = type;
content.title = title;
content.description = description;
content.source = source;
content.externalId = externalId;
content.releasedAt = releasedAt;
content.metadata = metadata;
return content;
Comment thread
plzslp marked this conversation as resolved.
}

public void addTag(Tag tag) {
if (tag == null) {
return;
}

boolean alreadyExists = this.contentTags.stream()
.anyMatch(ct -> {
Tag existing = ct.getTag();
if (existing.getId() != null && tag.getId() != null) {
return existing.getId().equals(tag.getId());
}
return existing.getName().equals(tag.getName());
});

if (!alreadyExists) {
contentTags.add(ContentTag.create(this, tag));
}
}
Comment thread
plzslp marked this conversation as resolved.
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package com.codeit.team5.mopl.content.entity;

public enum ContentSource {
TMDB,
SPORTS_DB,
ADMIN
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
package com.codeit.team5.mopl.content.entity;

import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.MapsId;
import jakarta.persistence.OneToOne;
import jakarta.persistence.PreUpdate;
import jakarta.persistence.Table;
import java.time.Instant;
import java.util.UUID;
import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor;

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

@Id
private UUID contentId;

@MapsId
Comment thread
plzslp marked this conversation as resolved.
@OneToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "content_id")
private Content content;

@Column(nullable = false)
private int reviewCount;

@Column(nullable = false)
private double ratingSum;
Comment thread
plzslp marked this conversation as resolved.

@Column(nullable = false)
private long watcherCount;

@Column(nullable = false)
private Instant updatedAt;
Comment thread
plzslp marked this conversation as resolved.

@PreUpdate
protected void onUpdate() {
this.updatedAt = Instant.now();
}

public static ContentStats create(Content content) {
ContentStats stats = new ContentStats();
stats.content = content;
stats.reviewCount = 0;
stats.ratingSum = 0.0;
stats.watcherCount = 0;
stats.updatedAt = Instant.now();
return stats;
}
}
Loading
Loading