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
6 changes: 3 additions & 3 deletions src/main/java/com/codeit/team5/mopl/config/BatchConfig.java
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@ public TmdbMovieItemReader tmdbMovieItemReader() {

@Bean
public TmdbMovieItemProcessor tmdbMovieItemProcessor() {
return new TmdbMovieItemProcessor(contentRepository, objectMapper);
return new TmdbMovieItemProcessor(objectMapper);
}

// ── TMDB TV 시리즈 Job ─────────────────────────────────────────
Expand Down Expand Up @@ -136,7 +136,7 @@ public TmdbTvSeriesItemReader tmdbTvSeriesItemReader() {

@Bean
public TmdbTvSeriesItemProcessor tmdbTvSeriesItemProcessor() {
return new TmdbTvSeriesItemProcessor(contentRepository, objectMapper);
return new TmdbTvSeriesItemProcessor(objectMapper);
}

// ── SportsDB 리그-시즌 Job ───────────────────────────────────────────────
Expand Down Expand Up @@ -170,7 +170,7 @@ public SportsDbEventItemReader sportsDbEventItemReader() {

@Bean
public SportsDbEventItemProcessor sportsDbEventItemProcessor() {
return new SportsDbEventItemProcessor(contentRepository, objectMapper);
return new SportsDbEventItemProcessor(objectMapper);
}

// ── SportsDB 일별 Job ──────────────────────────────────────────
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
import org.springframework.http.HttpStatus;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
Expand All @@ -26,6 +27,7 @@

@Configuration
@EnableWebSecurity
@EnableMethodSecurity
@RequiredArgsConstructor
public class SecurityConfig {

Expand Down Expand Up @@ -68,6 +70,12 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti
.requestMatchers(HttpMethod.GET, "/api/users/*").authenticated()
.requestMatchers(HttpMethod.PATCH, "/api/users/*").authenticated()

.requestMatchers(HttpMethod.GET, "/api/contents", "/api/contents/*").authenticated()
.requestMatchers(HttpMethod.POST, "/api/contents").hasRole("ADMIN")
.requestMatchers(HttpMethod.PATCH, "/api/contents/*").hasRole("ADMIN")
.requestMatchers(HttpMethod.DELETE, "/api/contents/*").hasRole("ADMIN")
.requestMatchers("/api/admin/**").hasRole("ADMIN")
Comment thread
plzslp marked this conversation as resolved.

.requestMatchers("/api/users/**").authenticated()
.requestMatchers("/api/follows/**").authenticated()
.requestMatchers("/api/notifications/**").authenticated()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@
import com.codeit.team5.mopl.content.entity.Content;
import com.codeit.team5.mopl.content.entity.ContentSource;
import com.codeit.team5.mopl.content.entity.ContentType;
import com.codeit.team5.mopl.content.repository.ContentRepository;
import com.codeit.team5.mopl.content.service.util.ContentCollectionUtils;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
Expand All @@ -21,16 +20,10 @@
@RequiredArgsConstructor
public class SportsDbEventItemProcessor implements ItemProcessor<SportsDbEventDto, ContentWithMetaData> {

private final ContentRepository contentRepository;
private final ObjectMapper objectMapper;

@Override
public ContentWithMetaData process(SportsDbEventDto dto) {
if (contentRepository.existsBySourceAndExternalId(ContentSource.SPORTS_DB, dto.idEvent())) {
log.debug("[SportsDB] 경기 스킵 (이미 존재) - idEvent={}, event={}", dto.idEvent(), dto.strEvent());
return null;
}

Content content = Content.createByExternalSource(
ContentType.SPORT,
dto.strEvent(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@
import com.codeit.team5.mopl.content.entity.Content;
import com.codeit.team5.mopl.content.entity.ContentSource;
import com.codeit.team5.mopl.content.entity.ContentType;
import com.codeit.team5.mopl.content.repository.ContentRepository;
import com.codeit.team5.mopl.content.service.util.ContentCollectionUtils;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
Expand All @@ -23,16 +22,10 @@ public class TmdbMovieItemProcessor implements ItemProcessor<TmdbMovieDto, Conte

private static final String TMDB_IMAGE_BASE_URL = "https://image.tmdb.org/t/p/w500";

private final ContentRepository contentRepository;
private final ObjectMapper objectMapper;

@Override
public ContentWithMetaData process(TmdbMovieDto dto) {
if (contentRepository.existsBySourceAndExternalId(ContentSource.TMDB, String.valueOf(dto.id()))) {
log.debug("[TMDB] 영화 스킵 (이미 존재) - externalId={}, title={}", dto.id(), dto.title());
return null;
}

Content content = Content.createByExternalSource(
ContentType.MOVIE,
dto.title(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@
import com.codeit.team5.mopl.content.entity.Content;
import com.codeit.team5.mopl.content.entity.ContentSource;
import com.codeit.team5.mopl.content.entity.ContentType;
import com.codeit.team5.mopl.content.repository.ContentRepository;
import com.codeit.team5.mopl.content.service.util.ContentCollectionUtils;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
Expand All @@ -23,16 +22,10 @@ public class TmdbTvSeriesItemProcessor implements ItemProcessor<TmdbTvDto, Conte

private static final String TMDB_IMAGE_BASE_URL = "https://image.tmdb.org/t/p/w500";

private final ContentRepository contentRepository;
private final ObjectMapper objectMapper;

@Override
public ContentWithMetaData process(TmdbTvDto dto) {
if (contentRepository.existsBySourceAndExternalId(ContentSource.TMDB, String.valueOf(dto.id()))) {
log.debug("[TMDB] TV 시리즈 스킵 (이미 존재) - externalId={}, title={}", dto.id(), dto.name());
return null;
}

Content content = Content.createByExternalSource(
ContentType.TV_SERIES,
dto.name(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,18 @@
import com.codeit.team5.mopl.binarycontent.repository.BinaryContentRepository;
import com.codeit.team5.mopl.content.batch.dto.ContentWithMetaData;
import com.codeit.team5.mopl.content.entity.Content;
import com.codeit.team5.mopl.content.entity.ContentSource;
import com.codeit.team5.mopl.content.entity.ContentStats;
import com.codeit.team5.mopl.content.entity.ContentTag;
import com.codeit.team5.mopl.content.repository.ContentRepository;
import com.codeit.team5.mopl.content.repository.ContentStatsRepository;
import com.codeit.team5.mopl.tag.entity.Tag;
import com.codeit.team5.mopl.tag.repository.TagRepository;
import java.util.ArrayList;
import java.util.Collection;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.function.Function;
import java.util.stream.Collectors;
import lombok.RequiredArgsConstructor;
Expand All @@ -33,20 +35,41 @@ public class ContentItemWriter implements ItemWriter<ContentWithMetaData> {

@Override
public void write(Chunk<? extends ContentWithMetaData> chunk) {
List<ContentWithMetaData> items = new java.util.ArrayList<>(chunk.getItems());
List<? extends ContentWithMetaData> items = chunk.getItems();

// 1. Content 일괄 저장 (청크 내 externalId 중복 제거 — TMDB가 페이지 간 동일 영화를 중복 반환하는 경우 대비)
List<ContentWithMetaData> deduplicatedItems = items.stream()
if (items.isEmpty()) {
log.debug("[Batch] 빈 청크 수신 — 저장 생략");
return;
}

// 1. 청크 내부 externalId 중복 제거
// TMDB는 인기순 정렬 특성상 페이지 경계에서 같은 항목이 다음 페이지에 다시 나올 수 있어,
// 한 청크 안에 동일 externalId가 들어올 수 있다. contents(source, external_id) 유니크 제약에
// 걸려 청크 전체가 롤백되는 것을 막기 위해 저장 전에 externalId 기준으로 먼저 걸러낸다.
// LinkedHashMap 병합 함수로 "먼저 온 항목 유지" 기준을 명시하고 원본 순서를 보존한다.
Map<String, ContentWithMetaData> uniqueByExternalId = items.stream()
.collect(Collectors.toMap(
item -> item.content().getExternalId(),
Function.identity(),
(a, b) -> a,
item -> (ContentWithMetaData) item,
(first, second) -> first,
LinkedHashMap::new
))
.values()
.stream()
));
Collection<ContentWithMetaData> uniqueItems = uniqueByExternalId.values();

// 2. DB에 이미 존재하는 externalId 조회 후 신규 항목만 필터 (SELECT 1번)
List<String> externalIds = uniqueByExternalId.keySet().stream().toList();
ContentSource source = uniqueItems.iterator().next().content().getSource();
Set<String> existingIds = contentRepository.findExternalIdsBySourceAndExternalIdIn(source, externalIds);

List<ContentWithMetaData> deduplicatedItems = uniqueItems.stream()
.filter(item -> !existingIds.contains(item.content().getExternalId()))
.toList();

Comment thread
plzslp marked this conversation as resolved.
if (deduplicatedItems.isEmpty()) {
log.info("[Batch] 신규 항목 없음 — 저장 생략 (청크 원본: {}건)", items.size());
return;
}

List<Content> contents = deduplicatedItems.stream()
.map(ContentWithMetaData::content)
.toList();
Expand All @@ -61,7 +84,7 @@ public void write(Chunk<? extends ContentWithMetaData> chunk) {
// 3. 썸네일 저장 (아이템별로 별도 BinaryContent를 저장 — thumbnail_id는 1:1 유니크 제약이라
// 서로 다른 콘텐츠가 같은 thumbnailUrl을 갖더라도 BinaryContent를 공유할 수 없다.
// saveAll은 입력 순서를 보존해 반환하므로 인덱스로 1:1 매칭한다.
List<ContentWithMetaData> itemsWithThumbnail = deduplicatedItems.stream()
List<? extends ContentWithMetaData> itemsWithThumbnail = deduplicatedItems.stream()
.filter(item -> StringUtils.hasText(item.thumbnailUrl()))
.toList();
if (!itemsWithThumbnail.isEmpty()) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.format.annotation.DateTimeFormat;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PostMapping;
Expand All @@ -24,6 +25,7 @@

@Slf4j
@Validated
@PreAuthorize("hasRole('ADMIN')")
@RestController
@RequestMapping("/api/admin/contents/collect")
public class ContentCollectionController implements ContentCollectionApi {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.WebDataBinder;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
Expand Down Expand Up @@ -55,6 +56,7 @@ public void setAsText(String text) {
}

@Override
@PreAuthorize("hasRole('ADMIN')")
@PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public ResponseEntity<ContentResponse> postContent(
@Valid @RequestPart("request") ContentCreateRequest request,
Expand All @@ -66,6 +68,7 @@ public ResponseEntity<ContentResponse> postContent(
}

@Override
@PreAuthorize("hasRole('ADMIN')")
@PatchMapping(value = "/{contentId}", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public ResponseEntity<ContentResponse> patchContent(
@PathVariable UUID contentId,
Expand Down Expand Up @@ -94,6 +97,7 @@ public ResponseEntity<CursorResponse<ContentResponse>> getContents(@Valid Conten
}

@Override
@PreAuthorize("hasRole('ADMIN')")
@DeleteMapping("/{contentId}")
public ResponseEntity<Void> deleteContent(@PathVariable UUID contentId) {
log.info("Content Delete request: DELETE /api/contents/{}", contentId);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,23 @@
import com.codeit.team5.mopl.content.entity.Content;
import com.codeit.team5.mopl.content.entity.ContentSource;
import com.codeit.team5.mopl.content.repository.querydsl.ContentQueryRepository;
import java.util.List;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
import org.springframework.data.jpa.repository.EntityGraph;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;

public interface ContentRepository extends JpaRepository<Content, UUID>, ContentQueryRepository {

@EntityGraph(attributePaths = {"thumbnail", "stats", "contentTags", "contentTags.tag"})
Optional<Content> findWithStatsAndTagsById(UUID id);

boolean existsBySourceAndExternalId(ContentSource source, String externalId);
@Query("SELECT c.externalId FROM Content c WHERE c.source = :source AND c.externalId IN :externalIds")
Set<String> findExternalIdsBySourceAndExternalIdIn(
@Param("source") ContentSource source,
@Param("externalIds") List<String> externalIds
);
}
Original file line number Diff line number Diff line change
@@ -1,58 +1,32 @@
package com.codeit.team5.mopl.content.batch.processor;

import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.BDDMockito.given;

import com.codeit.team5.mopl.content.batch.dto.ContentWithMetaData;
import com.codeit.team5.mopl.content.dto.external.sportsdb.SportsDbEventDto;
import com.codeit.team5.mopl.content.entity.ContentSource;
import com.codeit.team5.mopl.content.entity.ContentType;
import com.codeit.team5.mopl.content.repository.ContentRepository;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;

@ExtendWith(MockitoExtension.class)
class SportsDbEventItemProcessorTest {

@Mock
private ContentRepository contentRepository;

private SportsDbEventItemProcessor processor;

@BeforeEach
void setUp() {
processor = new SportsDbEventItemProcessor(contentRepository, new ObjectMapper());
}

@Test
@DisplayName("이미 존재하는 경기면 null을 반환한다")
void process_duplicate_returnsNull() throws Exception {
// given
SportsDbEventDto dto = new SportsDbEventDto("123", "Arsenal vs Chelsea",
"https://thumb.jpg", "2024-12-26", "English Premier League",
"Soccer", "Arsenal", "Chelsea", "2", "1");
given(contentRepository.existsBySourceAndExternalId(ContentSource.SPORTS_DB, "123")).willReturn(true);

// when
ContentWithMetaData result = processor.process(dto);

// then
assertThat(result).isNull();
processor = new SportsDbEventItemProcessor(new ObjectMapper());
}

@Test
@DisplayName("새로운 경기면 ContentWithMetaData를 반환한다")
@DisplayName("경기 DTO를 ContentWithMetaData로 변환한다")
void process_newEvent_returnsContentWithMetaData() throws Exception {
// given
SportsDbEventDto dto = new SportsDbEventDto("123", "Arsenal vs Chelsea",
"https://thumb.jpg", "2024-12-26", "English Premier League",
"Soccer", "Arsenal", "Chelsea", "2", "1");
given(contentRepository.existsBySourceAndExternalId(ContentSource.SPORTS_DB, "123")).willReturn(false);

// when
ContentWithMetaData result = processor.process(dto);
Expand All @@ -72,7 +46,6 @@ void process_leagueName_normalizedToLowerCase() throws Exception {
SportsDbEventDto dto = new SportsDbEventDto("456", "Arsenal vs Chelsea",
null, "2024-12-26", "English Premier League",
"Soccer", "Arsenal", "Chelsea", null, null);
given(contentRepository.existsBySourceAndExternalId(ContentSource.SPORTS_DB, "456")).willReturn(false);

// when
ContentWithMetaData result = processor.process(dto);
Expand All @@ -89,7 +62,6 @@ void process_noScore_descriptionContainsVs() throws Exception {
SportsDbEventDto dto = new SportsDbEventDto("789", "Arsenal vs Chelsea",
null, "2024-12-26", "English Premier League",
"Soccer", "Arsenal", "Chelsea", null, null);
given(contentRepository.existsBySourceAndExternalId(ContentSource.SPORTS_DB, "789")).willReturn(false);

// when
ContentWithMetaData result = processor.process(dto);
Expand Down
Loading
Loading