From 641bc8ff66e2f64b91adf2e17861c13044adfb43 Mon Sep 17 00:00:00 2001 From: hyorim jo <20231164@sungshin.ac.kr> Date: Wed, 29 Jul 2026 18:42:12 +0900 Subject: [PATCH 01/10] =?UTF-8?q?feat:=20#400=20=EB=A7=81=ED=81=AC=20?= =?UTF-8?q?=EC=B6=94=EC=B2=9C=20=ED=83=80=EC=9E=85=20=EB=84=A4=EC=9D=B4?= =?UTF-8?q?=EB=B0=8D=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/umc/linkyou/domain/enums/CurationLinkuType.java | 2 +- .../java/com/umc/linkyou/domain/mapping/CurationLinku.java | 4 ++-- .../recommend/CurationRecommendBuilderServiceImpl.java | 2 +- .../curation/recommend/internal/InternalRecommendWorker.java | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) 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..088d67ed 100644 --- a/src/main/java/com/umc/linkyou/domain/mapping/CurationLinku.java +++ b/src/main/java/com/umc/linkyou/domain/mapping/CurationLinku.java @@ -31,7 +31,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") @@ -61,7 +61,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/service/curation/recommend/CurationRecommendBuilderServiceImpl.java b/src/main/java/com/umc/linkyou/service/curation/recommend/CurationRecommendBuilderServiceImpl.java index 39bd7ffb..d7bd56f8 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 @@ -43,7 +43,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) 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())) From 60b82a4870cbeb184ddfd277c9e2782aa9111173 Mon Sep 17 00:00:00 2001 From: hyorim jo <20231164@sungshin.ac.kr> Date: Wed, 29 Jul 2026 18:42:12 +0900 Subject: [PATCH 02/10] =?UTF-8?q?feat:=20#400=20=EB=A7=81=ED=81=AC=20?= =?UTF-8?q?=EC=B6=94=EC=B2=9C=20=ED=83=80=EC=9E=85=20=EB=84=A4=EC=9D=B4?= =?UTF-8?q?=EB=B0=8D=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../umc/linkyou/domain/enums/CurationLinkuType.java | 2 +- .../com/umc/linkyou/domain/mapping/CurationLinku.java | 4 ++-- .../CurationRecommendBuilderServiceImpl.java | 2 +- .../recommend/internal/InternalRecommendWorker.java | 2 +- ...e_curation_linkus_type_recommended_to_internal.sql | 11 +++++++++++ 5 files changed, 16 insertions(+), 5 deletions(-) create mode 100644 src/main/resources/db/migration/V18__rename_curation_linkus_type_recommended_to_internal.sql 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..088d67ed 100644 --- a/src/main/java/com/umc/linkyou/domain/mapping/CurationLinku.java +++ b/src/main/java/com/umc/linkyou/domain/mapping/CurationLinku.java @@ -31,7 +31,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") @@ -61,7 +61,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/service/curation/recommend/CurationRecommendBuilderServiceImpl.java b/src/main/java/com/umc/linkyou/service/curation/recommend/CurationRecommendBuilderServiceImpl.java index 39bd7ffb..d7bd56f8 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 @@ -43,7 +43,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) 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/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[])); From f2e4a6f37e19d11268609d06ede528199c3490ea Mon Sep 17 00:00:00 2001 From: hyorim jo <20231164@sungshin.ac.kr> Date: Thu, 30 Jul 2026 00:23:10 +0900 Subject: [PATCH 03/10] =?UTF-8?q?refactor:=20#400=20url=5Fnormalized=20?= =?UTF-8?q?=EC=BB=AC=EB=9F=BC=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/umc/linkyou/domain/mapping/CurationLinku.java | 6 ++---- .../migration/V19__drop_curation_linkus_url_normalized.sql | 2 ++ 2 files changed, 4 insertions(+), 4 deletions(-) create mode 100644 src/main/resources/db/migration/V19__drop_curation_linkus_url_normalized.sql 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 088d67ed..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; @@ -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(); 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; From c2b847093f5713577708ff711d8f16895b87d6af Mon Sep 17 00:00:00 2001 From: hyorim jo <20231164@sungshin.ac.kr> Date: Thu, 30 Jul 2026 00:53:47 +0900 Subject: [PATCH 04/10] =?UTF-8?q?fix:=20#400=20=ED=81=90=EB=A0=88=EC=9D=B4?= =?UTF-8?q?=EC=85=98=20=EC=8D=B8=EB=84=A4=EC=9D=BC=EC=9D=84=20=EC=84=B9?= =?UTF-8?q?=EC=85=981=20=EC=9D=B4=EB=AF=B8=EC=A7=80=EB=A1=9C=20=ED=86=B5?= =?UTF-8?q?=EC=9D=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../linkyou/converter/CurationConverter.java | 75 ++++++++++++++++++- .../CurationSectionInfoRepository.java | 3 + .../service/curation/CurationServiceImpl.java | 33 +++----- .../curation/utils/ThumbnailUrlProvider.java | 20 ----- 4 files changed, 86 insertions(+), 45 deletions(-) delete mode 100644 src/main/java/com/umc/linkyou/service/curation/utils/ThumbnailUrlProvider.java 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/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..4916f996 100644 --- a/src/main/java/com/umc/linkyou/service/curation/CurationServiceImpl.java +++ b/src/main/java/com/umc/linkyou/service/curation/CurationServiceImpl.java @@ -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(); } 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 From ba060c1b7334f81660fa0a4c42ee49f25d9506ef Mon Sep 17 00:00:00 2001 From: hyorim jo <20231164@sungshin.ac.kr> Date: Thu, 30 Jul 2026 00:56:42 +0900 Subject: [PATCH 05/10] =?UTF-8?q?fix:=20#400=20=EC=B6=94=EC=B2=9C=20?= =?UTF-8?q?=EB=A7=81=ED=81=AC=20=EC=9D=91=EB=8B=B5=EC=9D=98=20userLinkuId/?= =?UTF-8?q?=EC=B9=B4=ED=85=8C=EA=B3=A0=EB=A6=AC/=ED=83=80=EC=9E=85=20?= =?UTF-8?q?=ED=95=84=EB=93=9C=20=EB=B3=B4=EA=B0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 외부 추천 링크를 사용자가 저장한 경우 처리 - 내부 추천 링크의 카테고리 null 오류 수정 --- .../UsersLinkuRepository.java | 8 ++++ .../CurationLinkuRepository.java | 1 + .../service/curation/CurationServiceImpl.java | 13 ++---- .../CurationRecommendBuilderServiceImpl.java | 19 ++++++++ .../external/ExternalRecommendReader.java | 17 ++----- .../external/ExternalRecommendWorker.java | 46 +++++++++++++++++-- .../dto/curation/RecommendedLinkResponse.java | 2 + 7 files changed, 81 insertions(+), 25 deletions(-) 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/service/curation/CurationServiceImpl.java b/src/main/java/com/umc/linkyou/service/curation/CurationServiceImpl.java index 4916f996..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; @@ -136,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 d7bd56f8..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; @@ -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/web/dto/curation/RecommendedLinkResponse.java b/src/main/java/com/umc/linkyou/web/dto/curation/RecommendedLinkResponse.java index c792cc7c..db6d3580 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,6 @@ package com.umc.linkyou.web.dto.curation; +import com.umc.linkyou.domain.enums.CurationLinkuType; import lombok.Builder; import lombok.Getter; import lombok.Setter; @@ -17,4 +18,5 @@ public class RecommendedLinkResponse { private String domain; private String domainImageUrl; // ex. 도메인 아이콘 이미지 private List categories; // 내부 추천만 포함됨 + private CurationLinkuType type; // INTERNAL / EXTERNAL } \ No newline at end of file From a3ec60d53eeb38841448c40da1e8db30054b4aae Mon Sep 17 00:00:00 2001 From: hyorim jo <20231164@sungshin.ac.kr> Date: Thu, 30 Jul 2026 01:19:49 +0900 Subject: [PATCH 06/10] =?UTF-8?q?docs:=20#400=20=ED=81=90=EB=A0=88?= =?UTF-8?q?=EC=9D=B4=EC=85=98=20API=20Swagger=20=EB=AC=B8=EC=84=9C=20?= =?UTF-8?q?=EB=B3=B4=EA=B0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/umc/linkyou/web/api/CurationApi.java | 51 ++++++++++++++++--- .../web/controller/CurationController.java | 6 +-- .../dto/curation/CurationDetailResponse.java | 10 ++++ .../dto/curation/CurationLatestResponse.java | 8 ++- .../dto/curation/CurationListResponse.java | 8 ++- .../dto/curation/CurationSectionResponse.java | 8 +++ .../dto/curation/RecommendedLinkResponse.java | 24 +++++++-- .../service/curation/CurationServiceTest.java | 8 +-- 8 files changed, 103 insertions(+), 20 deletions(-) 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 db6d3580..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,6 +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; @@ -11,12 +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; // 내부 추천만 포함됨 - private CurationLinkuType type; // INTERNAL / EXTERNAL + + @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/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); From 40cd01c3b6607025e5b89df488bd8de1b48cadc3 Mon Sep 17 00:00:00 2001 From: hyorim jo <20231164@sungshin.ac.kr> Date: Fri, 31 Jul 2026 20:04:37 +0900 Subject: [PATCH 07/10] =?UTF-8?q?feat:=20#400=20/latest=EC=9D=98=20204=20?= =?UTF-8?q?=EC=9D=91=EB=8B=B5=EC=9D=84=20Swagger=20=EC=9D=91=EB=8B=B5?= =?UTF-8?q?=EC=97=90=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/umc/linkyou/config/swagger/SwaggerConfig.java | 6 ++++++ .../validation/annotation/swagger/ApiNoContentCode.java | 9 +++++++++ src/main/java/com/umc/linkyou/web/api/CurationApi.java | 2 ++ 3 files changed, 17 insertions(+) create mode 100644 src/main/java/com/umc/linkyou/validation/annotation/swagger/ApiNoContentCode.java diff --git a/src/main/java/com/umc/linkyou/config/swagger/SwaggerConfig.java b/src/main/java/com/umc/linkyou/config/swagger/SwaggerConfig.java index 1801307a..3cef50bf 100644 --- a/src/main/java/com/umc/linkyou/config/swagger/SwaggerConfig.java +++ b/src/main/java/com/umc/linkyou/config/swagger/SwaggerConfig.java @@ -23,6 +23,7 @@ import com.umc.linkyou.validation.annotation.swagger.ApiDomainErrorCodes; import com.umc.linkyou.validation.annotation.swagger.ApiErrorCode; import com.umc.linkyou.validation.annotation.swagger.ApiErrorCodes; +import com.umc.linkyou.validation.annotation.swagger.ApiNoContentCode; import com.umc.linkyou.validation.annotation.swagger.ApiSuccessCode; import io.swagger.v3.oas.models.Components; import io.swagger.v3.oas.models.OpenAPI; @@ -96,6 +97,11 @@ public OperationCustomizer customize() { if (authSuccessAnnotation != null) { generateAuthSuccessResponseExample(operation, authSuccessAnnotation.value()); } + ApiNoContentCode noContentAnnotation = handlerMethod.getMethodAnnotation(ApiNoContentCode.class); + if (noContentAnnotation != null) { + operation.getResponses().addApiResponse("204", + new ApiResponse().description(noContentAnnotation.description())); + } // 2. 에러 응답 처리 (Repeatable 컨테이너와 단일 어노테이션 모두 상속 관계 포함 스캔) List errorCodes = new ArrayList<>(); diff --git a/src/main/java/com/umc/linkyou/validation/annotation/swagger/ApiNoContentCode.java b/src/main/java/com/umc/linkyou/validation/annotation/swagger/ApiNoContentCode.java new file mode 100644 index 00000000..6ee3fdd8 --- /dev/null +++ b/src/main/java/com/umc/linkyou/validation/annotation/swagger/ApiNoContentCode.java @@ -0,0 +1,9 @@ +package com.umc.linkyou.validation.annotation.swagger; + +import java.lang.annotation.*; + +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.RUNTIME) +public @interface ApiNoContentCode { + String description() default "본문 없이 204 No Content를 반환합니다."; +} 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 b0590a7e..756b8e36 100644 --- a/src/main/java/com/umc/linkyou/web/api/CurationApi.java +++ b/src/main/java/com/umc/linkyou/web/api/CurationApi.java @@ -7,6 +7,7 @@ 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.ApiNoContentCode; import com.umc.linkyou.validation.annotation.swagger.ApiSuccessCode; import com.umc.linkyou.web.dto.curation.*; import io.swagger.v3.oas.annotations.Operation; @@ -55,6 +56,7 @@ ResponseEntity>> getMyCurationList( - 생성된 큐레이션이 하나도 없는 경우, 204 No Content를 반환합니다. """) @ApiSuccessCode(SuccessStatus._OK) + @ApiNoContentCode @GetMapping("/latest") ResponseEntity> getLatestCuration( @CurrentUser CustomUserDetails userDetails From b5d8d42bda89d86b68850b61c60b3b0984504ec8 Mon Sep 17 00:00:00 2001 From: hyorim jo <20231164@sungshin.ac.kr> Date: Fri, 31 Jul 2026 20:05:57 +0900 Subject: [PATCH 08/10] =?UTF-8?q?refactor:=20gemini=20=EB=AA=A8=EB=8D=B8?= =?UTF-8?q?=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - gemini 2.5 flash lite 모델을 3.5 flash lite로 변경 --- src/main/resources/application.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 1392e3f2..b19fd7e7 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -154,7 +154,7 @@ custom: gemini: model: - name: gemini-2.5-flash-lite + name: gemini-3.5-flash-lite app: deeplink: From f0a122218dcbefc5152ac659cf644a35d5d4607e Mon Sep 17 00:00:00 2001 From: hyorim jo <20231164@sungshin.ac.kr> Date: Fri, 31 Jul 2026 20:37:55 +0900 Subject: [PATCH 09/10] =?UTF-8?q?fix:=20#400=20=EC=9C=A0=EC=A0=80=20?= =?UTF-8?q?=EC=B2=AD=ED=81=AC=20=EC=A0=84=EC=B2=B4=20=EC=8A=A4=ED=82=B5?= =?UTF-8?q?=EC=9D=84=20=EA=B0=9C=EB=B3=84=20=EC=8A=A4=ED=82=B5=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../GenerateMonthlyCurationJobConfig.java | 25 ++++++++++++++++++ .../curation/SendCurationAlarmJobConfig.java | 26 +++++++++++++++++++ 2 files changed, 51 insertions(+) diff --git a/src/main/java/com/umc/linkyou/batch/curation/GenerateMonthlyCurationJobConfig.java b/src/main/java/com/umc/linkyou/batch/curation/GenerateMonthlyCurationJobConfig.java index 2b292475..de61136b 100644 --- a/src/main/java/com/umc/linkyou/batch/curation/GenerateMonthlyCurationJobConfig.java +++ b/src/main/java/com/umc/linkyou/batch/curation/GenerateMonthlyCurationJobConfig.java @@ -2,7 +2,9 @@ import com.umc.linkyou.domain.Users; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.batch.core.Job; +import org.springframework.batch.core.SkipListener; import org.springframework.batch.core.Step; import org.springframework.batch.core.job.builder.JobBuilder; import org.springframework.batch.core.repository.JobRepository; @@ -15,11 +17,15 @@ * 매월 1일마다 유저별 큐레이션을 생성하는 배치 작업 설정 * 생성된 큐레이션에 대한 알림 발송은 발송 시각이 달라(오전 8시) {@link SendCurationAlarmJobConfig}의 별도 Job으로 분리되어 있음 */ +@Slf4j @Configuration @RequiredArgsConstructor public class GenerateMonthlyCurationJobConfig { private static final int CHUNK_SIZE = 100; + // 유저 개별 처리 실패가 전체 배치를 막지 않도록 스킵 허용치를 둔다. + // 이 값을 넘기면 데이터/설정 문제로 보고 Step을 FAILED 처리해 배치를 중단시킨다. + private static final int SKIP_LIMIT = 50; private final JobRepository jobRepository; private final PlatformTransactionManager transactionManager; @@ -41,6 +47,25 @@ public Step generateMonthlyCurationStep() { .reader(curationItemReader) .processor(curationItemProcessor) .writer(curationItemWriter) + .faultTolerant() + .skip(Exception.class) + .skipLimit(SKIP_LIMIT) + .listener(skipListener()) .build(); } + + private SkipListener skipListener() { + return new SkipListener<>() { + @Override + public void onSkipInProcess(Users item, Throwable t) { + log.warn("[CurationBatch] 유저 처리 스킵 userId={}, cause={}", item.getId(), t.getMessage()); + } + + @Override + public void onSkipInWrite(MonthlyCurationBatchItem item, Throwable t) { + log.warn("[CurationBatch] 큐레이션 생성 스킵 userId={}, cause={}", + item.user().getId(), t.getMessage()); + } + }; + } } diff --git a/src/main/java/com/umc/linkyou/batch/curation/SendCurationAlarmJobConfig.java b/src/main/java/com/umc/linkyou/batch/curation/SendCurationAlarmJobConfig.java index ee0939af..9b855b12 100644 --- a/src/main/java/com/umc/linkyou/batch/curation/SendCurationAlarmJobConfig.java +++ b/src/main/java/com/umc/linkyou/batch/curation/SendCurationAlarmJobConfig.java @@ -1,7 +1,9 @@ package com.umc.linkyou.batch.curation; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.batch.core.Job; +import org.springframework.batch.core.SkipListener; import org.springframework.batch.core.Step; import org.springframework.batch.core.job.builder.JobBuilder; import org.springframework.batch.core.repository.JobRepository; @@ -16,11 +18,15 @@ *

큐레이션 생성(generateMonthlyCurationJob)과는 발송 시각이 달라(매월 1일 오전 8시) 별도 Job으로 분리함 * 발송 대상은 Alarm 존재 여부로 판단하므로, 이 Job만 재실행돼도 이미 보낸 유저는 다시 대상이 되지 않음 */ +@Slf4j @Configuration @RequiredArgsConstructor public class SendCurationAlarmJobConfig { private static final int CHUNK_SIZE = 100; + // 유저 개별 처리 실패가 전체 배치를 막지 않도록 스킵 허용치를 둔다. + // 이 값을 넘기면 데이터/설정 문제로 보고 Step을 FAILED 처리해 배치를 중단시킨다. + private static final int SKIP_LIMIT = 50; private final JobRepository jobRepository; private final PlatformTransactionManager transactionManager; @@ -42,6 +48,26 @@ public Step sendCurationAlarmStep() { .reader(curationAlarmItemReader) .processor(curationAlarmItemProcessor) .writer(curationAlarmItemWriter) + .faultTolerant() + .skip(Exception.class) + .skipLimit(SKIP_LIMIT) + .listener(skipListener()) .build(); } + + private SkipListener skipListener() { + return new SkipListener<>() { + @Override + public void onSkipInProcess(CurationAlarmCandidate item, Throwable t) { + log.warn("[CurationAlarmBatch] 알림 대상 스킵 curationId={}, userId={}, cause={}", + item.curationId(), item.userId(), t.getMessage()); + } + + @Override + public void onSkipInWrite(CurationAlarmBatchItem item, Throwable t) { + log.warn("[CurationAlarmBatch] 알림 저장 스킵 curationId={}, userId={}, cause={}", + item.curationId(), item.userId(), t.getMessage()); + } + }; + } } From 7d3a21bbaedaad3bc508f1b2aca18db6a64e50c1 Mon Sep 17 00:00:00 2001 From: hyorim jo <20231164@sungshin.ac.kr> Date: Fri, 31 Jul 2026 20:49:44 +0900 Subject: [PATCH 10/10] =?UTF-8?q?fix:=20#400=20ai=20=ED=98=B8=EC=B6=9C=20l?= =?UTF-8?q?ocation=20global=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/resources/application.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index b19fd7e7..36cde9a3 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -51,7 +51,7 @@ spring: cloud: gcp: project-id: linku-server - location: us-central1 + location: global security: oauth2: