diff --git a/src/main/java/com/umc/linkyou/converter/CurationConverter.java b/src/main/java/com/umc/linkyou/converter/CurationConverter.java index 302c19af..35056102 100644 --- a/src/main/java/com/umc/linkyou/converter/CurationConverter.java +++ b/src/main/java/com/umc/linkyou/converter/CurationConverter.java @@ -1,21 +1,90 @@ package com.umc.linkyou.converter; +import com.umc.linkyou.domain.Curation; +import com.umc.linkyou.domain.CurationSectionInfo; +import com.umc.linkyou.domain.Linku; +import com.umc.linkyou.domain.classification.Category; import com.umc.linkyou.domain.classification.Domain; +import com.umc.linkyou.domain.enums.CurationLinkuType; import com.umc.linkyou.domain.mapping.CurationLinku; +import com.umc.linkyou.domain.mapping.UsersLinku; +import com.umc.linkyou.web.dto.curation.CurationDetailResponse; +import com.umc.linkyou.web.dto.curation.CurationLatestResponse; +import com.umc.linkyou.web.dto.curation.CurationListResponse; +import com.umc.linkyou.web.dto.curation.CurationSectionResponse; import com.umc.linkyou.web.dto.curation.RecommendedLinkResponse; +import java.util.List; + public class CurationConverter { public static RecommendedLinkResponse toRecommendedLinkResponse(CurationLinku entity) { - Domain domain = entity.getUsersLinku() != null - ? entity.getUsersLinku().getLinku().getDomain() - : null; + UsersLinku usersLinku = entity.getUsersLinku(); + Linku linku = usersLinku != null ? usersLinku.getLinku() : null; + Domain domain = linku != null ? linku.getDomain() : null; + Category category = linku != null ? linku.getCategory() : null; return RecommendedLinkResponse.builder() + .userLinkuId(usersLinku != null ? usersLinku.getUserLinkuId() : null) .url(entity.getUrl()) .title(entity.getTitle()) .domain(domain != null ? domain.getName() : null) .domainImageUrl(domain != null ? domain.getImageUrl() : null) .imageUrl(entity.getImageUrl()) + .categories(category != null ? List.of(category.getCategoryName()) : null) + .type(CurationLinkuType.INTERNAL) + .build(); + } + + // 외부 추천: 도메인 브랜딩(Domain)이 URL 기반 별도 조회로 해석된 경우 + public static RecommendedLinkResponse toRecommendedLinkResponse( + String url, String title, String imageUrl, Domain domain) { + return RecommendedLinkResponse.builder() + .url(url) + .title(title) + .imageUrl(imageUrl) + .domain(domain != null ? domain.getName() : null) + .domainImageUrl(domain != null ? domain.getImageUrl() : null) + .type(CurationLinkuType.EXTERNAL) + .build(); + } + + // 연도별 히스토리 항목 변환 (해당 월에 큐레이션이 없으면 month만 채운다) + public static CurationListResponse toCurationListResponse(Curation curation, String month, String thumbnailUrl) { + if (curation == null) { + return CurationListResponse.builder().month(month).build(); + } + return CurationListResponse.builder() + .curationId(curation.getCurationId()) + .month(month) + .thumbnailUrl(thumbnailUrl) + .build(); + } + + public static CurationLatestResponse toCurationLatestResponse(Curation curation, String thumbnailUrl) { + return CurationLatestResponse.builder() + .curationId(curation.getCurationId()) + .month(curation.getBaseMonth()) + .thumbnailUrl(thumbnailUrl) + .build(); + } + + public static CurationSectionResponse toCurationSectionResponse(CurationSectionInfo info) { + return CurationSectionResponse.builder() + .section(info.getSectionNumber()) + .title(info.getTitle()) + .description(info.getDescription()) + .imageUrl(info.getImageUrl()) + .build(); + } + + public static CurationDetailResponse toCurationDetailResponse(Curation curation) { + return CurationDetailResponse.builder() + .curationId(curation.getCurationId()) + .month(curation.getBaseMonth()) + .headerMent(curation.getHeaderMent()) + .footerMent(curation.getFooterMent()) + .mentReady(curation.getHeaderMent() != null && !curation.getHeaderMent().isBlank() + && curation.getFooterMent() != null && !curation.getFooterMent().isBlank()) .build(); } } diff --git a/src/main/java/com/umc/linkyou/domain/enums/CurationLinkuType.java b/src/main/java/com/umc/linkyou/domain/enums/CurationLinkuType.java index cf4568d4..b10a92bd 100644 --- a/src/main/java/com/umc/linkyou/domain/enums/CurationLinkuType.java +++ b/src/main/java/com/umc/linkyou/domain/enums/CurationLinkuType.java @@ -1,6 +1,6 @@ package com.umc.linkyou.domain.enums; public enum CurationLinkuType { - RECOMMENDED, + INTERNAL, EXTERNAL } diff --git a/src/main/java/com/umc/linkyou/domain/mapping/CurationLinku.java b/src/main/java/com/umc/linkyou/domain/mapping/CurationLinku.java index 01570c4d..2da0f165 100644 --- a/src/main/java/com/umc/linkyou/domain/mapping/CurationLinku.java +++ b/src/main/java/com/umc/linkyou/domain/mapping/CurationLinku.java @@ -2,6 +2,7 @@ import com.umc.linkyou.domain.Curation; import com.umc.linkyou.domain.enums.CurationLinkuType; +import com.umc.linkyou.utils.UrlUtils; import jakarta.persistence.*; import lombok.*; import org.hibernate.annotations.OnDelete; @@ -31,7 +32,7 @@ public class CurationLinku { @Column(name = "type", nullable = false) @Enumerated(EnumType.STRING) - private CurationLinkuType type; // RECOMMENDED / EXTERNAL + private CurationLinkuType type; // INTERNAL / EXTERNAL // 외부추천용 최소 컬럼 @Column(name = "url", columnDefinition = "TEXT") @@ -43,15 +44,12 @@ public class CurationLinku { @Column(name = "image_url", length = 1024) private String imageUrl; - @Column(name = "url_normalized", length = 2048, insertable = false, updatable = false) - private String urlNormalized; - // 팩토리 메서드 public static CurationLinku ofExternal(Curation curation, String url, String title, String imageUrl) { return CurationLinku.builder() .curation(curation) .type(CurationLinkuType.EXTERNAL) - .url(url) + .url(UrlUtils.normalizeUrl(url)) .title(title) .imageUrl(imageUrl) .build(); @@ -61,7 +59,7 @@ public static CurationLinku ofInternal(Curation curation, UsersLinku usersLinku, return CurationLinku.builder() .curation(curation) .usersLinku(usersLinku) - .type(CurationLinkuType.RECOMMENDED) + .type(CurationLinkuType.INTERNAL) .url(usersLinku.getLinku().getLinkuUrl()) .title(usersLinku.getLinku().getTitle()) .imageUrl(imageUrl) diff --git a/src/main/java/com/umc/linkyou/repository/UserLinkuRepository/UsersLinkuRepository.java b/src/main/java/com/umc/linkyou/repository/UserLinkuRepository/UsersLinkuRepository.java index 49e0672b..edb0ceec 100644 --- a/src/main/java/com/umc/linkyou/repository/UserLinkuRepository/UsersLinkuRepository.java +++ b/src/main/java/com/umc/linkyou/repository/UserLinkuRepository/UsersLinkuRepository.java @@ -43,6 +43,14 @@ List findAllByUserIdAndCreatedAtBetween( List findByUser_IdAndLastViewedAtIsNull(Long userId); + // 후보 URL 중 사용자가 이미 저장한 것을 찾는다 (외부 추천에 userLinkuId를 채워주기 위함) + @Query(""" + SELECT ul FROM UsersLinku ul + JOIN FETCH ul.linku l + WHERE ul.user.id = :userId AND l.linkuUrl IN :urls + """) + List findByUserIdAndLinkuUrlIn(@Param("userId") Long userId, @Param("urls") List urls); + // 해당 기간 유저가 저장한 링크의 감정별 저장 횟수 @Query(""" SELECT new com.umc.linkyou.repository.dto.TagCountRow(ul.emotion.emotionId, COUNT(ul)) diff --git a/src/main/java/com/umc/linkyou/repository/curationRepository/CurationLinkuRepository.java b/src/main/java/com/umc/linkyou/repository/curationRepository/CurationLinkuRepository.java index 6b1647db..8f3e4c19 100644 --- a/src/main/java/com/umc/linkyou/repository/curationRepository/CurationLinkuRepository.java +++ b/src/main/java/com/umc/linkyou/repository/curationRepository/CurationLinkuRepository.java @@ -24,6 +24,7 @@ List findByCurationIdAndType( JOIN FETCH cl.usersLinku ul JOIN FETCH ul.linku l LEFT JOIN FETCH l.domain + LEFT JOIN FETCH l.category WHERE cl.curation.curationId = :curationId AND cl.type = :type """) List findWithDomainByCurationIdAndType( diff --git a/src/main/java/com/umc/linkyou/repository/curationRepository/CurationSectionInfoRepository.java b/src/main/java/com/umc/linkyou/repository/curationRepository/CurationSectionInfoRepository.java index 0117830d..b6247246 100644 --- a/src/main/java/com/umc/linkyou/repository/curationRepository/CurationSectionInfoRepository.java +++ b/src/main/java/com/umc/linkyou/repository/curationRepository/CurationSectionInfoRepository.java @@ -6,6 +6,7 @@ import org.springframework.data.repository.query.Param; import java.util.List; +import java.util.Optional; public interface CurationSectionInfoRepository extends JpaRepository { @@ -15,4 +16,6 @@ public interface CurationSectionInfoRepository extends JpaRepository findAllByMonth(@Param("month") String month); + + Optional findByMonthAndSectionNumber(String month, int sectionNumber); } diff --git a/src/main/java/com/umc/linkyou/service/curation/CurationServiceImpl.java b/src/main/java/com/umc/linkyou/service/curation/CurationServiceImpl.java index 860ec031..e20ad9fd 100644 --- a/src/main/java/com/umc/linkyou/service/curation/CurationServiceImpl.java +++ b/src/main/java/com/umc/linkyou/service/curation/CurationServiceImpl.java @@ -2,10 +2,11 @@ import com.umc.linkyou.apiPayload.code.status.user.UserErrorStatus; import com.umc.linkyou.apiPayload.exception.GeneralException; +import com.umc.linkyou.converter.CurationConverter; import com.umc.linkyou.domain.Curation; +import com.umc.linkyou.domain.CurationSectionInfo; import com.umc.linkyou.domain.Users; import com.umc.linkyou.service.curation.ment.CurationMentMaterializer; -import com.umc.linkyou.service.curation.utils.ThumbnailUrlProvider; import com.umc.linkyou.service.curation.recommend.external.ExternalRecommendMaterializer; import com.umc.linkyou.service.curation.recommend.internal.InternalRecommendMaterializer; import com.umc.linkyou.apiPayload.code.status.curation.CurationErrorStatus; @@ -38,7 +39,6 @@ public class CurationServiceImpl implements CurationService { private final UserRepository userRepository; private final CurationRepository curationRepository; private final CurationSectionInfoRepository curationSectionInfoRepository; - private final ThumbnailUrlProvider thumbnailUrlProvider; private final ExternalRecommendMaterializer externalRecommendMaterializer; private final InternalRecommendMaterializer internalRecommendMaterializer; private final CurationMentMaterializer curationMentMaterializer; @@ -90,17 +90,8 @@ public List getCurationList(Long userId, int year) { for (int m = 1; m <= 12; m++) { String month = String.format("%d-%02d", year, m); Curation c = existing.get(month); - if (c != null) { - result.add(CurationListResponse.builder() - .curationId(c.getCurationId()) - .month(month) - .thumbnailUrl(thumbnailUrlProvider.getUrlForMonth(month)) - .build()); - } else { - result.add(CurationListResponse.builder() - .month(month) - .build()); - } + String thumbnailUrl = c != null ? getSection1ImageUrl(month) : null; + result.add(CurationConverter.toCurationListResponse(c, month, thumbnailUrl)); } return result; } @@ -110,11 +101,14 @@ public List getCurationList(Long userId, int year) { @Transactional(readOnly = true) public Optional getLatestCuration(Long userId) { return curationRepository.findLatestByUserId(userId) - .map(curation -> CurationLatestResponse.builder() - .curationId(curation.getCurationId()) - .month(curation.getBaseMonth()) - .thumbnailUrl(thumbnailUrlProvider.getUrlForMonth(curation.getBaseMonth())) - .build()); + .map(curation -> CurationConverter.toCurationLatestResponse( + curation, getSection1ImageUrl(curation.getBaseMonth()))); + } + + private String getSection1ImageUrl(String month) { + return curationSectionInfoRepository.findByMonthAndSectionNumber(month, 1) + .map(CurationSectionInfo::getImageUrl) + .orElse(null); } // 월별 섹션 정보 조회 (제목, 설명, 대표 이미지) @@ -124,12 +118,7 @@ public List getCurationSections(String month) { return curationSectionInfoRepository .findAllByMonth(month) .stream() - .map(s -> CurationSectionResponse.builder() - .section(s.getSectionNumber()) - .title(s.getTitle()) - .description(s.getDescription()) - .imageUrl(s.getImageUrl()) - .build()) + .map(CurationConverter::toCurationSectionResponse) .toList(); } @@ -147,13 +136,6 @@ public CurationDetailResponse getCurationDetail(Long userId, Long curationId) { throw new GeneralException(CurationErrorStatus._CURATION_FORBIDDEN); } - return CurationDetailResponse.builder() - .curationId(curation.getCurationId()) - .month(curation.getBaseMonth()) - .headerMent(curation.getHeaderMent()) - .footerMent(curation.getFooterMent()) - .mentReady(curation.getHeaderMent() != null && !curation.getHeaderMent().isBlank() - && curation.getFooterMent() != null && !curation.getFooterMent().isBlank()) - .build(); + return CurationConverter.toCurationDetailResponse(curation); } } diff --git a/src/main/java/com/umc/linkyou/service/curation/recommend/CurationRecommendBuilderServiceImpl.java b/src/main/java/com/umc/linkyou/service/curation/recommend/CurationRecommendBuilderServiceImpl.java index 39bd7ffb..fb67aab6 100644 --- a/src/main/java/com/umc/linkyou/service/curation/recommend/CurationRecommendBuilderServiceImpl.java +++ b/src/main/java/com/umc/linkyou/service/curation/recommend/CurationRecommendBuilderServiceImpl.java @@ -5,6 +5,8 @@ import com.umc.linkyou.converter.CurationConverter; import com.umc.linkyou.domain.Curation; import com.umc.linkyou.domain.enums.CurationLinkuType; +import com.umc.linkyou.domain.mapping.UsersLinku; +import com.umc.linkyou.repository.UserLinkuRepository.UsersLinkuRepository; import com.umc.linkyou.repository.curationRepository.CurationLinkuRepository; import com.umc.linkyou.repository.curationRepository.CurationRepository; import com.umc.linkyou.service.curation.recommend.external.ExternalRecommendMaterializer; @@ -17,6 +19,7 @@ import java.util.ArrayList; import java.util.HashSet; import java.util.List; +import java.util.Map; import java.util.Set; import java.util.stream.Collectors; @@ -29,6 +32,7 @@ public class CurationRecommendBuilderServiceImpl implements CurationRecommendBui private final CurationRepository curationRepository; private final CurationLinkuRepository curationLinkuRepository; + private final UsersLinkuRepository usersLinkuRepository; private final ExternalRecommendReader externalRecommendReader; private final InternalRecommendMaterializer internalRecommendMaterializer; private final ExternalRecommendMaterializer externalRecommendMaterializer; @@ -43,7 +47,7 @@ public List buildRecommendedLinks(Long userId, Long cur } List internal = curationLinkuRepository - .findWithDomainByCurationIdAndType(curationId, CurationLinkuType.RECOMMENDED) + .findWithDomainByCurationIdAndType(curationId, CurationLinkuType.INTERNAL) .stream() .map(CurationConverter::toRecommendedLinkResponse) .limit(INTERNAL_LIMIT) @@ -59,6 +63,21 @@ public List buildRecommendedLinks(Long userId, Long cur externalRecommendMaterializer.generateExternalAsync(curationId); } + // 외부 추천이 이미 저장된 링크와 같은 URL이면, 외부 추천인 채로 유지하되 userLinkuId만 채워준다 + if (!external.isEmpty()) { + List savedMatches = usersLinkuRepository.findByUserIdAndLinkuUrlIn( + userId, external.stream().map(RecommendedLinkResponse::getUrl).toList()); + if (!savedMatches.isEmpty()) { + Map urlToUserLinkuId = savedMatches.stream() + .collect(Collectors.toMap(ul -> ul.getLinku().getLinkuUrl(), UsersLinku::getUserLinkuId)); + external = external.stream() + .map(ex -> urlToUserLinkuId.containsKey(ex.getUrl()) + ? ex.toBuilder().userLinkuId(urlToUserLinkuId.get(ex.getUrl())).build() + : ex) + .toList(); + } + } + List result = new ArrayList<>(INTERNAL_LIMIT + EXTERNAL_LIMIT); result.addAll(internal); diff --git a/src/main/java/com/umc/linkyou/service/curation/recommend/external/ExternalRecommendReader.java b/src/main/java/com/umc/linkyou/service/curation/recommend/external/ExternalRecommendReader.java index 98dae59a..45be35ee 100644 --- a/src/main/java/com/umc/linkyou/service/curation/recommend/external/ExternalRecommendReader.java +++ b/src/main/java/com/umc/linkyou/service/curation/recommend/external/ExternalRecommendReader.java @@ -1,5 +1,6 @@ package com.umc.linkyou.service.curation.recommend.external; +import com.umc.linkyou.converter.CurationConverter; import com.umc.linkyou.domain.classification.Domain; import com.umc.linkyou.domain.enums.CurationLinkuType; import com.umc.linkyou.domain.mapping.CurationLinku; @@ -47,11 +48,8 @@ public List read(Long curationId) { if (tails.isEmpty()) { return items.stream() - .map(item -> RecommendedLinkResponse.builder() - .url(item.url()) - .title(item.title()) - .imageUrl(item.imageUrl()) - .build()) + .map(item -> CurationConverter.toRecommendedLinkResponse( + item.url(), item.title(), item.imageUrl(), null)) .toList(); } @@ -65,13 +63,8 @@ public List read(Long curationId) { .filter(Objects::nonNull) .findFirst() .orElse(null); - return RecommendedLinkResponse.builder() - .url(item.url()) - .title(item.title()) - .domain(domain != null ? domain.getName() : null) - .domainImageUrl(domain != null ? domain.getImageUrl() : null) - .imageUrl(item.imageUrl()) - .build(); + return CurationConverter.toRecommendedLinkResponse( + item.url(), item.title(), item.imageUrl(), domain); }) .toList(); } diff --git a/src/main/java/com/umc/linkyou/service/curation/recommend/external/ExternalRecommendWorker.java b/src/main/java/com/umc/linkyou/service/curation/recommend/external/ExternalRecommendWorker.java index a434c46a..5b9ca68e 100644 --- a/src/main/java/com/umc/linkyou/service/curation/recommend/external/ExternalRecommendWorker.java +++ b/src/main/java/com/umc/linkyou/service/curation/recommend/external/ExternalRecommendWorker.java @@ -7,15 +7,19 @@ import com.umc.linkyou.domain.enums.CurationLinkuType; import com.umc.linkyou.domain.enums.KeywordType; import com.umc.linkyou.domain.mapping.CurationLinku; +import com.umc.linkyou.domain.mapping.UsersLinku; import com.umc.linkyou.infra.ai.AiSearchService; import com.umc.linkyou.infra.ai.dto.ExternalLinkResultDTO; import com.umc.linkyou.infra.ai.dto.ExternalSearchRequest; +import com.umc.linkyou.infra.net.SafeUrlFetcher; +import com.umc.linkyou.repository.UserLinkuRepository.UsersLinkuRepository; import com.umc.linkyou.repository.curationRepository.CurationLinkuRepository; import com.umc.linkyou.repository.curationRepository.CurationRepository; import com.umc.linkyou.repository.classification.SituationRepository; import com.umc.linkyou.repository.keywordRepository.KeywordMonthlyCountRepository; import com.umc.linkyou.service.common.EmotionMapper; import com.umc.linkyou.service.common.ImageFetchService; +import com.umc.linkyou.utils.UrlUtils; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.data.domain.PageRequest; @@ -24,7 +28,9 @@ import java.util.List; import java.util.Map; +import java.util.Set; import java.util.concurrent.CompletableFuture; +import java.util.stream.Collectors; @Service @RequiredArgsConstructor @@ -32,14 +38,18 @@ public class ExternalRecommendWorker { private static final int EXTERNAL_LIMIT = 5; + // 도달 불가/봇 차단으로 걸러지는 링크를 대비해 필요한 개수보다 여유 있게 요청한다. + private static final int EXTERNAL_SEARCH_LIMIT = 8; private final CurationRepository curationRepository; private final CurationLinkuRepository curationLinkuRepository; + private final UsersLinkuRepository usersLinkuRepository; private final KeywordMonthlyCountRepository keywordMonthlyCountRepository; private final AiSearchService aiSearchService; private final ImageFetchService imageFetchService; private final EmotionMapper emotionMapper; private final SituationRepository situationRepository; + private final SafeUrlFetcher safeUrlFetcher; @Transactional public void generateExternal(Long curationId) { @@ -65,7 +75,7 @@ public void generateExternal(Long curationId) { try { long t0 = System.currentTimeMillis(); external = aiSearchService.searchExternalLinks( - new ExternalSearchRequest(topTags, EXTERNAL_LIMIT, jobName, gender)); + new ExternalSearchRequest(topTags, EXTERNAL_SEARCH_LIMIT, jobName, gender)); log.info("[AI] 외부 추천 응답 elapsed={}ms", System.currentTimeMillis() - t0); } catch (Exception e) { log.warn("[AI] 외부 추천 실패, 기존 데이터 유지 curationId={}", curationId, e); @@ -77,9 +87,39 @@ public void generateExternal(Long curationId) { return; } - // 이미지 fetch 병렬 실행 - List>> tasks = external.stream() + // 도달 불가/봇 차단 링크는 즉시 탈락시킨다. + List reachable = external.stream() .filter(item -> item.getUrl() != null && !item.getUrl().isBlank()) + .filter(item -> safeUrlFetcher.isReachable(item.getUrl())) + .toList(); + + if (reachable.isEmpty()) { + log.warn("[EXT] 도달 가능한 링크 없음, 기존 데이터 유지 curationId={}", curationId); + return; + } + + // 유저가 이미 저장한 링크는 외부 추천 후보에서 제외하고, 나머지 후보로 목표 개수(EXTERNAL_LIMIT)를 채운다. + // 저장된 Linku.linkuUrl은 정규화된 값이므로 비교 전 동일하게 정규화한다. + List normalizedUrls = reachable.stream() + .map(item -> UrlUtils.normalizeUrl(item.getUrl())) + .toList(); + List savedMatches = usersLinkuRepository.findByUserIdAndLinkuUrlIn(userId, normalizedUrls); + Set savedUrls = savedMatches.stream() + .map(ul -> ul.getLinku().getLinkuUrl()) + .collect(Collectors.toSet()); + + List candidates = reachable.stream() + .filter(item -> !savedUrls.contains(UrlUtils.normalizeUrl(item.getUrl()))) + .limit(EXTERNAL_LIMIT) + .toList(); + + if (candidates.isEmpty()) { + log.warn("[EXT] 저장되지 않은 도달 가능 링크 없음, 기존 데이터 유지 curationId={}", curationId); + return; + } + + // 이미지 fetch 병렬 실행 + List>> tasks = candidates.stream() .map(item -> Map.entry(item, imageFetchService.fetchAsync(item.getUrl(), item.getTitle()))) .toList(); diff --git a/src/main/java/com/umc/linkyou/service/curation/recommend/internal/InternalRecommendWorker.java b/src/main/java/com/umc/linkyou/service/curation/recommend/internal/InternalRecommendWorker.java index 280fa9c1..4b5f83f0 100644 --- a/src/main/java/com/umc/linkyou/service/curation/recommend/internal/InternalRecommendWorker.java +++ b/src/main/java/com/umc/linkyou/service/curation/recommend/internal/InternalRecommendWorker.java @@ -36,7 +36,7 @@ public void generateInternal(Long curationId) { List candidates = internalLinkCandidateService.getInternalCandidates(userId, curationId, 4); - curationLinkuRepository.deleteAllByCurationIdAndType(curationId, CurationLinkuType.RECOMMENDED); + curationLinkuRepository.deleteAllByCurationIdAndType(curationId, CurationLinkuType.INTERNAL); List toSave = candidates.stream() .map(item -> CurationLinku.ofInternal(curation, item, item.getImageUrl())) diff --git a/src/main/java/com/umc/linkyou/service/curation/utils/ThumbnailUrlProvider.java b/src/main/java/com/umc/linkyou/service/curation/utils/ThumbnailUrlProvider.java deleted file mode 100644 index 09932cdc..00000000 --- a/src/main/java/com/umc/linkyou/service/curation/utils/ThumbnailUrlProvider.java +++ /dev/null @@ -1,20 +0,0 @@ -package com.umc.linkyou.service.curation.utils; - -import org.springframework.beans.factory.annotation.Value; -import org.springframework.stereotype.Component; - -@Component -public class ThumbnailUrlProvider { - - private final String baseUrl; - private static final String EXTENSION = ".png"; - - public ThumbnailUrlProvider(@Value("${cloud.aws.s3.base-url}") String baseUrl) { - this.baseUrl = baseUrl; - } - - public String getUrlForMonth(String month) { - String[] parts = month.split("-"); - return String.format("%s/curation/%s/%s%s", baseUrl, parts[0], parts[1], EXTENSION); - } -} \ No newline at end of file diff --git a/src/main/java/com/umc/linkyou/web/api/CurationApi.java b/src/main/java/com/umc/linkyou/web/api/CurationApi.java index 16e37270..b0590a7e 100644 --- a/src/main/java/com/umc/linkyou/web/api/CurationApi.java +++ b/src/main/java/com/umc/linkyou/web/api/CurationApi.java @@ -1,42 +1,70 @@ package com.umc.linkyou.web.api; import com.umc.linkyou.apiPayload.ApiResponse; +import com.umc.linkyou.apiPayload.code.status.SuccessStatus; import com.umc.linkyou.apiPayload.code.status.curation.CurationErrorStatus; +import com.umc.linkyou.apiPayload.code.status.user.UserErrorStatus; import com.umc.linkyou.jwt.CurrentUser; import com.umc.linkyou.jwt.CustomUserDetails; import com.umc.linkyou.validation.annotation.swagger.ApiErrorCode; +import com.umc.linkyou.validation.annotation.swagger.ApiSuccessCode; import com.umc.linkyou.web.dto.curation.*; import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.tags.Tag; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; +import java.time.YearMonth; import java.util.List; @Tag(name = "큐레이션 API", description = "큐레이션과 관련 된 API 입니다") @RequestMapping("/curations") public interface CurationApi { - @Operation(summary = "큐레이션 섹션 정보 조회", description = "지정한 월(YYYY-MM)의 섹션별 제목, 설명, 대표 이미지를 반환합니다. 미입력 시 이번 달 기준. 모든 유저 동일.") + @Operation(summary = "큐레이션 섹션 정보 조회", description = """ + 지정한 월(YYYY-MM)의 섹션별 제목, 설명, 대표 이미지를 반환합니다. + + - 로그인 여부와 무관하게 모든 유저에게 동일한 응답을 반환합니다. + - 해당 월의 큐레이션이 아직 생성되지 않은 경우, 빈 배열(`[]`)을 반환합니다. + """) + @ApiSuccessCode(SuccessStatus._OK) @GetMapping("/sections") ResponseEntity>> getSectionInfo( - @RequestParam(required = false) String month + @Parameter(description = "섹션 정보 월입니다. 미입력 시 이번 달을 기준으로 합니다.", example = "2025-07") @RequestParam(required = false) YearMonth month ); - @Operation(summary = "연도별 큐레이션 히스토리 조회", description = "지정한 연도의 1~12월 큐레이션 목록을 반환합니다. 미입력 시 올해 기준. 생성되지 않은 달은 curationId, thumbnailUrl이 null입니다.") + @Operation(summary = "연도별 큐레이션 히스토리 조회", description = """ + 지정한 연도의 1~12월 큐레이션 목록을 반환합니다. + + - 연도를 입력하지 않으면 올해를 기준으로 조회합니다. + - 2025년 이전 연도를 요청하면 에러가 발생합니다. + - 아직 생성되지 않은 달은 curationId, thumbnailUrl이 null로 반환됩니다. + """) + @ApiSuccessCode(SuccessStatus._OK) + @ApiErrorCode(curationErrorStatus = {CurationErrorStatus._CURATION_INVALID_YEAR}) @GetMapping("/history") ResponseEntity>> getMyCurationList( @CurrentUser CustomUserDetails userDetails, @RequestParam(required = false) Integer year ); - @Operation(summary = "가장 최근 큐레이션 조회", description = "내 최신 큐레이션을 조회합니다.") + @Operation(summary = "가장 최근 큐레이션 조회", description = """ + 로그인한 사용자의 가장 최근 큐레이션 정보를 조회합니다. + + - 생성된 큐레이션이 하나도 없는 경우, 204 No Content를 반환합니다. + """) + @ApiSuccessCode(SuccessStatus._OK) @GetMapping("/latest") ResponseEntity> getLatestCuration( @CurrentUser CustomUserDetails userDetails ); - @Operation(summary = "큐레이션 상세 조회", description = "큐레이션 ID로 상세 정보를 조회합니다.") + @Operation(summary = "큐레이션 상세 조회", description = """ + 큐레이션 ID로 상세 정보를 조회합니다. + """) + @ApiSuccessCode(SuccessStatus._OK) + @ApiErrorCode(userErrorStatus = {UserErrorStatus._USER_NOT_FOUND}) @ApiErrorCode(curationErrorStatus = {CurationErrorStatus._CURATION_NOT_FOUND, CurationErrorStatus._CURATION_FORBIDDEN}) @GetMapping("/detail/{curationId}") ResponseEntity> getCurationDetail( @@ -44,11 +72,18 @@ ResponseEntity> getCurationDetail( @PathVariable Long curationId ); - @Operation(summary = "큐레이션 기반 링크 추천", description = "내부/외부 링크를 추천합니다.") - @ApiErrorCode(curationErrorStatus = {CurationErrorStatus._CURATION_NOT_FOUND}) + @Operation(summary = "큐레이션 기반 링크 추천", description = """ + 내부 링크(4개)와 외부 링크(5개)를 반환합니다. + + - 해당 큐레이션에 아직 생성된 추천이 없으면 백그라운드로 생성을 트리거합니다. + - 생성이 끝나기 전이라면 현재 시점에 만들어져 있는 결과만(비어있을 수 있음) 즉시 반환합니다. + - 생성이 끝난 뒤 재요청하면 채워진 결과를 받을 수 있습니다. + """) + @ApiSuccessCode(SuccessStatus._OK) + @ApiErrorCode(curationErrorStatus = {CurationErrorStatus._CURATION_NOT_FOUND, CurationErrorStatus._CURATION_FORBIDDEN}) @GetMapping("/recommend-links") ResponseEntity>> getRecommendedLinks( @CurrentUser CustomUserDetails userDetails, - @RequestParam Long curationId + @Parameter(description = "추천 링크를 조회할 큐레이션 ID", example = "1") @RequestParam Long curationId ); } diff --git a/src/main/java/com/umc/linkyou/web/controller/CurationController.java b/src/main/java/com/umc/linkyou/web/controller/CurationController.java index 3d2f65fb..d7415f37 100644 --- a/src/main/java/com/umc/linkyou/web/controller/CurationController.java +++ b/src/main/java/com/umc/linkyou/web/controller/CurationController.java @@ -23,9 +23,9 @@ public class CurationController implements CurationApi { private final CurationRecommendBuilderService curationRecommendBuilderService; @Override - public ResponseEntity>> getSectionInfo(@RequestParam(required = false) String month) { - String resolvedMonth = (month != null) ? month : YearMonth.now().toString(); - return ResponseEntity.ok(ApiResponse.onSuccess(curationService.getCurationSections(resolvedMonth))); + public ResponseEntity>> getSectionInfo(@RequestParam(required = false) YearMonth month) { + YearMonth resolvedMonth = (month != null) ? month : YearMonth.now(); + return ResponseEntity.ok(ApiResponse.onSuccess(curationService.getCurationSections(resolvedMonth.toString()))); } @Override diff --git a/src/main/java/com/umc/linkyou/web/dto/curation/CurationDetailResponse.java b/src/main/java/com/umc/linkyou/web/dto/curation/CurationDetailResponse.java index 4f1b5089..4dfaa5cc 100644 --- a/src/main/java/com/umc/linkyou/web/dto/curation/CurationDetailResponse.java +++ b/src/main/java/com/umc/linkyou/web/dto/curation/CurationDetailResponse.java @@ -1,14 +1,24 @@ package com.umc.linkyou.web.dto.curation; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.Builder; import lombok.Getter; @Getter @Builder public class CurationDetailResponse { + @Schema(description = "큐레이션 ID", example = "1") private Long curationId; + + @Schema(description = "큐레이션 기준 월", example = "2025-07") private String month; + + @Schema(description = "상단 멘트", example = "닉네임님, 잔잔한 평온함 속에서 잠시 숨을 고르며 나를 돌아보는 시간을 가져보세요.") private String headerMent; + + @Schema(description = "하단 멘트", example = "닉네임님, 당신의 평온한 하루가 더욱 깊어지기를 바라며, 링크유가 언제나 당신 곁에 있겠습니다.") private String footerMent; + + @Schema(description = "헤더/푸터 멘트가 모두 준비됐는지 여부", example = "true") private boolean mentReady; } \ No newline at end of file diff --git a/src/main/java/com/umc/linkyou/web/dto/curation/CurationLatestResponse.java b/src/main/java/com/umc/linkyou/web/dto/curation/CurationLatestResponse.java index 8a5dc4e0..b36e0044 100644 --- a/src/main/java/com/umc/linkyou/web/dto/curation/CurationLatestResponse.java +++ b/src/main/java/com/umc/linkyou/web/dto/curation/CurationLatestResponse.java @@ -1,5 +1,6 @@ package com.umc.linkyou.web.dto.curation; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Getter; @@ -8,7 +9,12 @@ @Builder @AllArgsConstructor public class CurationLatestResponse { + @Schema(description = "큐레이션 ID", example = "1") private Long curationId; - private String month; // e.g., "2025-07" + + @Schema(description = "큐레이션 기준 월", example = "2025-07") + private String month; + + @Schema(description = "썸네일 이미지 URL (섹션 1 대표 이미지)", example = "https://d3f9zmi4jicqrs.cloudfront.net/curation/section01/07.png") private String thumbnailUrl; } \ No newline at end of file diff --git a/src/main/java/com/umc/linkyou/web/dto/curation/CurationListResponse.java b/src/main/java/com/umc/linkyou/web/dto/curation/CurationListResponse.java index 77f92d07..6a00bd15 100644 --- a/src/main/java/com/umc/linkyou/web/dto/curation/CurationListResponse.java +++ b/src/main/java/com/umc/linkyou/web/dto/curation/CurationListResponse.java @@ -1,5 +1,6 @@ package com.umc.linkyou.web.dto.curation; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Getter; @@ -10,7 +11,12 @@ @AllArgsConstructor @NoArgsConstructor public class CurationListResponse { + @Schema(description = "큐레이션 ID (생성되지 않은 달이면 null)", example = "1") private Long curationId; - private String month; // e.g., "2025-07" + + @Schema(description = "큐레이션 기준 월", example = "2025-07") + private String month; + + @Schema(description = "썸네일 이미지 URL (섹션 1 대표 이미지, 생성되지 않은 달이면 null)", example = "https://d3f9zmi4jicqrs.cloudfront.net/curation/section01/07.png") private String thumbnailUrl; } \ No newline at end of file diff --git a/src/main/java/com/umc/linkyou/web/dto/curation/CurationSectionResponse.java b/src/main/java/com/umc/linkyou/web/dto/curation/CurationSectionResponse.java index 48f8072a..f4010c21 100644 --- a/src/main/java/com/umc/linkyou/web/dto/curation/CurationSectionResponse.java +++ b/src/main/java/com/umc/linkyou/web/dto/curation/CurationSectionResponse.java @@ -1,13 +1,21 @@ package com.umc.linkyou.web.dto.curation; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.Builder; import lombok.Getter; @Getter @Builder public class CurationSectionResponse { + @Schema(description = "섹션 번호", example = "1") private int section; + + @Schema(description = "섹션 제목", example = "2026 월간 큐레이션 6월호") private String title; + + @Schema(description = "섹션 설명", example = "이번달을 위한 링크, 링큐가 준비했어요.") private String description; + + @Schema(description = "섹션 대표 이미지 URL", example = "https://cdn.example.com/sections/2026-06-1.png") private String imageUrl; } diff --git a/src/main/java/com/umc/linkyou/web/dto/curation/RecommendedLinkResponse.java b/src/main/java/com/umc/linkyou/web/dto/curation/RecommendedLinkResponse.java index c792cc7c..a153b937 100644 --- a/src/main/java/com/umc/linkyou/web/dto/curation/RecommendedLinkResponse.java +++ b/src/main/java/com/umc/linkyou/web/dto/curation/RecommendedLinkResponse.java @@ -1,5 +1,7 @@ package com.umc.linkyou.web.dto.curation; +import com.umc.linkyou.domain.enums.CurationLinkuType; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.Builder; import lombok.Getter; import lombok.Setter; @@ -10,11 +12,27 @@ @Setter @Builder(toBuilder = true) public class RecommendedLinkResponse { + @Schema(description = "저장된 링크 ID (사용자가 저장하지 않은 외부 추천이면 null)", example = "1") private Long userLinkuId; + + @Schema(description = "링크 제목", example = "퇴근 후 30분 홈트 루틴 추천") private String title; + + @Schema(description = "링크 URL", example = "https://example.com/articles/home-workout") private String url; - private String imageUrl; // 실제 콘텐츠 대표 이미지 + + @Schema(description = "링크 대표 이미지 URL", example = "https://cdn.example.com/links/home-workout.png") + private String imageUrl; + + @Schema(description = "도메인 이름", example = "example.com") private String domain; - private String domainImageUrl; // ex. 도메인 아이콘 이미지 - private List categories; // 내부 추천만 포함됨 + + @Schema(description = "도메인 아이콘 이미지 URL", example = "https://cdn.example.com/domains/example.png") + private String domainImageUrl; + + @Schema(description = "카테고리 목록 (내부 추천만 포함, 외부 추천이면 null)", example = "[\"라이프스타일\"]") + private List categories; + + @Schema(description = "추천 종류 (INTERNAL: 내가 저장한 링크 기반, EXTERNAL: AI 웹 검색 기반)", example = "INTERNAL") + private CurationLinkuType type; } \ No newline at end of file diff --git a/src/main/resources/db/migration/V18__rename_curation_linkus_type_recommended_to_internal.sql b/src/main/resources/db/migration/V18__rename_curation_linkus_type_recommended_to_internal.sql new file mode 100644 index 00000000..8b142048 --- /dev/null +++ b/src/main/resources/db/migration/V18__rename_curation_linkus_type_recommended_to_internal.sql @@ -0,0 +1,11 @@ +-- curation_linkus.type: RECOMMENDED -> INTERNAL (명칭을 EXTERNAL과 대칭되도록 변경) +ALTER TABLE curation_linkus DROP CONSTRAINT IF EXISTS curation_linkus_type_check; + +UPDATE curation_linkus SET type = 'INTERNAL' WHERE type = 'RECOMMENDED'; + +ALTER TABLE curation_linkus + ADD CONSTRAINT curation_linkus_type_check + CHECK (type = ANY (ARRAY [ + 'INTERNAL', + 'EXTERNAL' + ]::varchar[])); diff --git a/src/main/resources/db/migration/V19__drop_curation_linkus_url_normalized.sql b/src/main/resources/db/migration/V19__drop_curation_linkus_url_normalized.sql new file mode 100644 index 00000000..f851a5d1 --- /dev/null +++ b/src/main/resources/db/migration/V19__drop_curation_linkus_url_normalized.sql @@ -0,0 +1,2 @@ +-- url_normalized: 정규화된 url을 별도 저장하지 않고 url 컬럼에 정규화된 값을 바로 저장하기로 변경 +ALTER TABLE curation_linkus DROP COLUMN IF EXISTS url_normalized; diff --git a/src/test/java/com/umc/linkyou/service/curation/CurationServiceTest.java b/src/test/java/com/umc/linkyou/service/curation/CurationServiceTest.java index 1047ecab..c8004dfd 100644 --- a/src/test/java/com/umc/linkyou/service/curation/CurationServiceTest.java +++ b/src/test/java/com/umc/linkyou/service/curation/CurationServiceTest.java @@ -4,10 +4,11 @@ import com.umc.linkyou.apiPayload.code.status.user.UserErrorStatus; import com.umc.linkyou.apiPayload.exception.GeneralException; import com.umc.linkyou.domain.Curation; +import com.umc.linkyou.domain.CurationSectionInfo; import com.umc.linkyou.domain.Users; import com.umc.linkyou.repository.curationRepository.CurationRepository; +import com.umc.linkyou.repository.curationRepository.CurationSectionInfoRepository; import com.umc.linkyou.repository.userRepository.UserRepository; -import com.umc.linkyou.service.curation.utils.ThumbnailUrlProvider; import com.umc.linkyou.web.dto.curation.CurationDetailResponse; import com.umc.linkyou.web.dto.curation.CurationListResponse; import org.junit.jupiter.api.DisplayName; @@ -37,7 +38,7 @@ class CurationServiceTest { @Mock private UserRepository userRepository; @Mock private CurationRepository curationRepository; - @Mock private ThumbnailUrlProvider thumbnailUrlProvider; + @Mock private CurationSectionInfoRepository curationSectionInfoRepository; @Nested @DisplayName("큐레이션 생성") @@ -179,7 +180,8 @@ class GetCurationList { Curation curation = Curation.builder().user(user).baseMonth("2026-03").build(); given(curationRepository.findAllByUserIdAndYear(USER_ID, "2026")).willReturn(List.of(curation)); - given(thumbnailUrlProvider.getUrlForMonth("2026-03")).willReturn("url"); + given(curationSectionInfoRepository.findByMonthAndSectionNumber("2026-03", 1)) + .willReturn(Optional.of(CurationSectionInfo.builder().imageUrl("url").build())); List result = curationService.getCurationList(USER_ID, 2026);