Skip to content
75 changes: 72 additions & 3 deletions src/main/java/com/umc/linkyou/converter/CurationConverter.java
Original file line number Diff line number Diff line change
@@ -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();
}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
package com.umc.linkyou.domain.enums;

public enum CurationLinkuType {
RECOMMENDED,
INTERNAL,
EXTERNAL
}
10 changes: 4 additions & 6 deletions src/main/java/com/umc/linkyou/domain/mapping/CurationLinku.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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")
Expand All @@ -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();
Expand All @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,14 @@ List<UsersLinku> findAllByUserIdAndCreatedAtBetween(

List<UsersLinku> 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<UsersLinku> findByUserIdAndLinkuUrlIn(@Param("userId") Long userId, @Param("urls") List<String> urls);

// 해당 기간 유저가 저장한 링크의 감정별 저장 횟수
@Query("""
SELECT new com.umc.linkyou.repository.dto.TagCountRow(ul.emotion.emotionId, COUNT(ul))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ List<CurationLinku> 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<CurationLinku> findWithDomainByCurationIdAndType(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import org.springframework.data.repository.query.Param;

import java.util.List;
import java.util.Optional;

public interface CurationSectionInfoRepository extends JpaRepository<CurationSectionInfo, Long> {

Expand All @@ -15,4 +16,6 @@ public interface CurationSectionInfoRepository extends JpaRepository<CurationSec
ORDER BY cs.sectionNumber ASC
""")
List<CurationSectionInfo> findAllByMonth(@Param("month") String month);

Optional<CurationSectionInfo> findByMonthAndSectionNumber(String month, int sectionNumber);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -90,17 +90,8 @@ public List<CurationListResponse> 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;
}
Expand All @@ -110,11 +101,14 @@ public List<CurationListResponse> getCurationList(Long userId, int year) {
@Transactional(readOnly = true)
public Optional<CurationLatestResponse> 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);
}

// 월별 섹션 정보 조회 (제목, 설명, 대표 이미지)
Expand All @@ -124,12 +118,7 @@ public List<CurationSectionResponse> 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();
}

Expand All @@ -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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;

Expand All @@ -29,6 +32,7 @@ public class CurationRecommendBuilderServiceImpl implements CurationRecommendBui

private final CurationRepository curationRepository;
private final CurationLinkuRepository curationLinkuRepository;
private final UsersLinkuRepository usersLinkuRepository;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

for file in \
  src/main/java/com/umc/linkyou/service/curation/recommend/CurationRecommendBuilderServiceImpl.java \
  src/main/java/com/umc/linkyou/service/curation/recommend/external/ExternalRecommendWorker.java
do
  echo "== $file =="
  rg -n -C2 '`@Service`|`@Transactional`' "$file"
done

Repository: LinkYou-2025/LinkU_backend

Length of output: 843


🏁 Script executed:

#!/bin/bash
set -euo pipefail

for file in \
  src/main/java/com/umc/linkyou/service/curation/recommend/CurationRecommendBuilderServiceImpl.java \
  src/main/java/com/umc/linkyou/service/curation/recommend/external/ExternalRecommendWorker.java
do
  echo "== $file =="
  wc -l "$file"
  sed -n '1,140p' "$file" | cat -n
done

python3 - <<'PY'
from pathlib import Path
files = [
  Path("src/main/java/com/umc/linkyou/service/curation/recommend/CurationRecommendBuilderServiceImpl.java"),
  Path("src/main/java/com/umc/linkyou/service/curation/recommend/external/ExternalRecommendWorker.java"),
]
for p in files:
    text = p.read_text()
    lines = text.splitlines()
    print(f"== {p} annotation check ==")
    for i, line in enumerate(lines, 1):
        if "`@Service`" in line or "`@Transactional`" in line:
            print(f"{i}: {line.strip()}")
    cls_line = next((i for i,l in enumerate(lines,1) if "`@Service`" in l), None)
    method_line = next((i for i,l in enumerate(lines,1) if l.startswith("    public void generateExternal") or "public void generateExternal" in l), None)
    print(f"class_line={cls_line}, generate_line={method_line}")
PY

Repository: LinkYou-2025/LinkU_backend

Length of output: 12968


서비스 기본 읽기 전용 트랜잭션을 적용하세요.

CurationRecommendBuilderServiceImpl은 전용 트랜잭션이 없어 클래스 레벨의 @Transactional(readOnly = true)를 추가하고, ExternalRecommendWorker도 클래스 레벨 기본 읽기 전용 트랜잭션을 적용한 뒤 generateExternal의 저장 매핑에만 메서드 레벨 @Transactional을 유지하세요.

📍 Affects 2 files
  • src/main/java/com/umc/linkyou/service/curation/recommend/CurationRecommendBuilderServiceImpl.java#L35-L35 (this comment)
  • src/main/java/com/umc/linkyou/service/curation/recommend/external/ExternalRecommendWorker.java#L35-L38
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@src/main/java/com/umc/linkyou/service/curation/recommend/CurationRecommendBuilderServiceImpl.java`
at line 35, CurationRecommendBuilderServiceImpl 클래스에 `@Transactional`(readOnly =
true)를 적용하세요. ExternalRecommendWorker에도 클래스 레벨 기본 읽기 전용 트랜잭션을 추가하고, 저장 매핑을 수행하는
generateExternal 메서드에는 기존 메서드 레벨 `@Transactional을` 유지하세요. 대상 파일:
src/main/java/com/umc/linkyou/service/curation/recommend/CurationRecommendBuilderServiceImpl.java
35-35,
src/main/java/com/umc/linkyou/service/curation/recommend/external/ExternalRecommendWorker.java
35-38.

Source: Coding guidelines

private final ExternalRecommendReader externalRecommendReader;
private final InternalRecommendMaterializer internalRecommendMaterializer;
private final ExternalRecommendMaterializer externalRecommendMaterializer;
Expand All @@ -43,7 +47,7 @@ public List<RecommendedLinkResponse> buildRecommendedLinks(Long userId, Long cur
}

List<RecommendedLinkResponse> internal = curationLinkuRepository
.findWithDomainByCurationIdAndType(curationId, CurationLinkuType.RECOMMENDED)
.findWithDomainByCurationIdAndType(curationId, CurationLinkuType.INTERNAL)
.stream()
.map(CurationConverter::toRecommendedLinkResponse)
.limit(INTERNAL_LIMIT)
Expand All @@ -59,6 +63,21 @@ public List<RecommendedLinkResponse> buildRecommendedLinks(Long userId, Long cur
externalRecommendMaterializer.generateExternalAsync(curationId);
}

// 외부 추천이 이미 저장된 링크와 같은 URL이면, 외부 추천인 채로 유지하되 userLinkuId만 채워준다
if (!external.isEmpty()) {
List<UsersLinku> savedMatches = usersLinkuRepository.findByUserIdAndLinkuUrlIn(
userId, external.stream().map(RecommendedLinkResponse::getUrl).toList());
if (!savedMatches.isEmpty()) {
Map<String, Long> 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<RecommendedLinkResponse> result = new ArrayList<>(INTERNAL_LIMIT + EXTERNAL_LIMIT);
result.addAll(internal);

Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -47,11 +48,8 @@ public List<RecommendedLinkResponse> 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();
}

Expand All @@ -65,13 +63,8 @@ public List<RecommendedLinkResponse> 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();
}
Expand Down
Loading
Loading