diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml index 0d6ade8e..2f91c9ce 100644 --- a/.github/workflows/cd.yml +++ b/.github/workflows/cd.yml @@ -72,6 +72,9 @@ jobs: JWT_LOGIN_FAILURE_REDIRECT_URI: ${{ secrets.JWT_LOGIN_FAILURE_REDIRECT_URI }} JWT_LOGIN_FAILURE_REDIRECT_URI_DEV: ${{ secrets.JWT_LOGIN_FAILURE_REDIRECT_URI_DEV }} SERVER_DOMAIN: ${{ secrets.SERVER_DOMAIN }} + CLOUDFLARE_THIRD_PARTY_THUMBNAIL_OPTIMIZATION_ENABLED: ${{ secrets.CLOUDFLARE_THIRD_PARTY_THUMBNAIL_OPTIMIZATION_ENABLED }} + CLOUDFLARE_THIRD_PARTY_THUMBNAIL_OPTIMIZATION_DELIVERY_BASE_URL: ${{ secrets.CLOUDFLARE_THIRD_PARTY_THUMBNAIL_OPTIMIZATION_DELIVERY_BASE_URL }} + CLOUDFLARE_THIRD_PARTY_THUMBNAIL_OPTIMIZATION_DELIVERY_BASE_URL_DEV: ${{ secrets.CLOUDFLARE_THIRD_PARTY_THUMBNAIL_OPTIMIZATION_DELIVERY_BASE_URL_DEV }} steps: - name: Checkout code @@ -114,8 +117,10 @@ jobs: DISCORD_WEBHOOK_URL,ANTHROPIC_API_KEY,OPENAI_API_KEY, KAKAO_REST_API_KEY,KAKAO_CLIENT_SECRET, APPLE_TEAM_ID,APPLE_KEY_ID,APPLE_CLIENT_ID, - JWT_SECRET,JWT_REDIRECT_URI,JWT_REDIRECT_URI_DEV,JWT_LOGIN_FAILURE_REDIRECT_URI,JWT_LOGIN_FAILURE_REDIRECT_URI_DEV,SERVER_DOMAIN + JWT_SECRET,JWT_REDIRECT_URI,JWT_REDIRECT_URI_DEV,JWT_LOGIN_FAILURE_REDIRECT_URI,JWT_LOGIN_FAILURE_REDIRECT_URI_DEV,SERVER_DOMAIN, + CLOUDFLARE_THIRD_PARTY_THUMBNAIL_OPTIMIZATION_ENABLED,CLOUDFLARE_THIRD_PARTY_THUMBNAIL_OPTIMIZATION_DELIVERY_BASE_URL, + CLOUDFLARE_THIRD_PARTY_THUMBNAIL_OPTIMIZATION_ENABLED_DEV,CLOUDFLARE_THIRD_PARTY_THUMBNAIL_OPTIMIZATION_DELIVERY_BASE_URL_DEV script: | cd ~/deploy chmod +x scripts/deploy.sh - ./scripts/deploy.sh \ No newline at end of file + ./scripts/deploy.sh diff --git a/docker/docker-compose.blue.yml b/docker/docker-compose.blue.yml index e60cc904..b7091f21 100644 --- a/docker/docker-compose.blue.yml +++ b/docker/docker-compose.blue.yml @@ -20,11 +20,13 @@ services: - APPLE_CLIENT_ID=${APPLE_CLIENT_ID} - APPLE_PRIVATE_KEY_PATH=/app/keys/AuthKey_${APPLE_KEY_ID}.p8 - JWT_SECRET=${JWT_SECRET} - - JWT_REDIRECT_URI=${JWT_REDIRECT_URI} - - JWT_LOGIN_FAILURE_REDIRECT_URI=${JWT_LOGIN_FAILURE_REDIRECT_URI} - - SERVER_DOMAIN=${SERVER_DOMAIN} - ports: - - "8080:8080" + - JWT_REDIRECT_URI=${JWT_REDIRECT_URI} + - JWT_LOGIN_FAILURE_REDIRECT_URI=${JWT_LOGIN_FAILURE_REDIRECT_URI} + - SERVER_DOMAIN=${SERVER_DOMAIN} + - CLOUDFLARE_THIRD_PARTY_THUMBNAIL_OPTIMIZATION_ENABLED=${CLOUDFLARE_THIRD_PARTY_THUMBNAIL_OPTIMIZATION_ENABLED} + - CLOUDFLARE_THIRD_PARTY_THUMBNAIL_OPTIMIZATION_DELIVERY_BASE_URL=${CLOUDFLARE_THIRD_PARTY_THUMBNAIL_OPTIMIZATION_DELIVERY_BASE_URL} + ports: + - "8080:8080" volumes: - ~/keys:/app/keys:ro networks: diff --git a/docker/docker-compose.dev.yml b/docker/docker-compose.dev.yml index 7300682e..eef0efd3 100644 --- a/docker/docker-compose.dev.yml +++ b/docker/docker-compose.dev.yml @@ -23,6 +23,8 @@ services: - JWT_REDIRECT_URI=${JWT_REDIRECT_URI_DEV} - JWT_LOGIN_FAILURE_REDIRECT_URI=${JWT_LOGIN_FAILURE_REDIRECT_URI_DEV} - SERVER_DOMAIN=${SERVER_DOMAIN} + - CLOUDFLARE_THIRD_PARTY_THUMBNAIL_OPTIMIZATION_ENABLED=${CLOUDFLARE_THIRD_PARTY_THUMBNAIL_OPTIMIZATION_ENABLED_DEV} + - CLOUDFLARE_THIRD_PARTY_THUMBNAIL_OPTIMIZATION_DELIVERY_BASE_URL=${CLOUDFLARE_THIRD_PARTY_THUMBNAIL_OPTIMIZATION_DELIVERY_BASE_URL_DEV} volumes: - ~/keys:/app/keys:ro networks: diff --git a/docker/docker-compose.green.yml b/docker/docker-compose.green.yml index 62697e22..b17b165a 100644 --- a/docker/docker-compose.green.yml +++ b/docker/docker-compose.green.yml @@ -20,11 +20,13 @@ services: - APPLE_CLIENT_ID=${APPLE_CLIENT_ID} - APPLE_PRIVATE_KEY_PATH=/app/keys/AuthKey_${APPLE_KEY_ID}.p8 - JWT_SECRET=${JWT_SECRET} - - JWT_REDIRECT_URI=${JWT_REDIRECT_URI} - - JWT_LOGIN_FAILURE_REDIRECT_URI=${JWT_LOGIN_FAILURE_REDIRECT_URI} - - SERVER_DOMAIN=${SERVER_DOMAIN} - ports: - - "8081:8080" + - JWT_REDIRECT_URI=${JWT_REDIRECT_URI} + - JWT_LOGIN_FAILURE_REDIRECT_URI=${JWT_LOGIN_FAILURE_REDIRECT_URI} + - SERVER_DOMAIN=${SERVER_DOMAIN} + - CLOUDFLARE_THIRD_PARTY_THUMBNAIL_OPTIMIZATION_ENABLED=${CLOUDFLARE_THIRD_PARTY_THUMBNAIL_OPTIMIZATION_ENABLED} + - CLOUDFLARE_THIRD_PARTY_THUMBNAIL_OPTIMIZATION_DELIVERY_BASE_URL=${CLOUDFLARE_THIRD_PARTY_THUMBNAIL_OPTIMIZATION_DELIVERY_BASE_URL} + ports: + - "8081:8080" volumes: - ~/keys:/app/keys:ro networks: diff --git a/scripts/deploy.sh b/scripts/deploy.sh index c23ac486..d161deb1 100644 --- a/scripts/deploy.sh +++ b/scripts/deploy.sh @@ -102,7 +102,7 @@ trap cleanup EXIT # Generate .env from SSH-injected environment variables log "Writing .env file..." -env | grep -E '^(DOCKER_IMAGE|BRANCH|SPRING_PROFILES_ACTIVE|DB_|REDIS_|ANTHROPIC_|OPENAI_|DISCORD_|KAKAO_|APPLE_TEAM_ID|APPLE_KEY_ID|APPLE_CLIENT_ID|JWT_|SERVER_)' > "${DOCKER_DIR}/.env" +env | grep -E '^(DOCKER_IMAGE|BRANCH|SPRING_PROFILES_ACTIVE|DB_|REDIS_|ANTHROPIC_|OPENAI_|DISCORD_|KAKAO_|APPLE_TEAM_ID|APPLE_KEY_ID|APPLE_CLIENT_ID|JWT_|SERVER_|CLOUDFLARE_THIRD_PARTY_THUMBNAIL_OPTIMIZATION_ENABLED|CLOUDFLARE_THIRD_PARTY_THUMBNAIL_OPTIMIZATION_DELIVERY_BASE_URL|CLOUDFLARE_THIRD_PARTY_THUMBNAIL_OPTIMIZATION_ENABLED_DEV|CLOUDFLARE_THIRD_PARTY_THUMBNAIL_OPTIMIZATION_DELIVERY_BASE_URL_DEV)' > "${DOCKER_DIR}/.env" chmod 600 "${DOCKER_DIR}/.env" # Step 1: Ensure Docker network exists diff --git a/src/main/java/com/techfork/domain/activity/service/ActivityQueryService.java b/src/main/java/com/techfork/domain/activity/service/ActivityQueryService.java index 8f62a044..131a51ae 100644 --- a/src/main/java/com/techfork/domain/activity/service/ActivityQueryService.java +++ b/src/main/java/com/techfork/domain/activity/service/ActivityQueryService.java @@ -9,12 +9,13 @@ import com.techfork.domain.activity.repository.ScrabPostRepository; import com.techfork.domain.post.entity.PostKeyword; import com.techfork.domain.post.repository.PostKeywordRepository; -import com.techfork.domain.user.entity.User; -import com.techfork.domain.user.exception.UserErrorCode; -import com.techfork.domain.user.repository.UserRepository; -import com.techfork.global.exception.GeneralException; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; +import com.techfork.domain.user.entity.User; +import com.techfork.domain.user.exception.UserErrorCode; +import com.techfork.domain.user.repository.UserRepository; +import com.techfork.global.exception.GeneralException; +import com.techfork.global.util.CloudflareThirdPartyThumbnailOptimizer; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.data.domain.PageRequest; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -31,9 +32,10 @@ public class ActivityQueryService { private final UserRepository userRepository; private final ScrabPostRepository scrabPostRepository; - private final PostKeywordRepository postKeywordRepository; - private final ReadPostRepository readPostRepository; - private final ActivityConverter activityConverter; + private final PostKeywordRepository postKeywordRepository; + private final ReadPostRepository readPostRepository; + private final ActivityConverter activityConverter; + private final CloudflareThirdPartyThumbnailOptimizer thumbnailOptimizer; public BookmarkListResponse getBookmarks(Long userId, Long lastBookmarkId, int size) { User user = userRepository.findById(userId) @@ -78,13 +80,13 @@ private List attachKeywordsToPostInfoList(List bookmar .title(post.title()) .shortSummary(post.shortSummary()) .url(post.url()) - .companyName(post.companyName()) - .logoUrl(post.logoUrl()) - .publishedAt(post.publishedAt()) - .thumbnailUrl(post.thumbnailUrl()) - .viewCount(post.viewCount()) - .keywords(keywordMap.getOrDefault(post.postId(), List.of())) - .isBookmarked(post.isBookmarked()) + .companyName(post.companyName()) + .logoUrl(post.logoUrl()) + .publishedAt(post.publishedAt()) + .thumbnailUrl(thumbnailOptimizer.optimize(post.thumbnailUrl())) + .viewCount(post.viewCount()) + .keywords(keywordMap.getOrDefault(post.postId(), List.of())) + .isBookmarked(post.isBookmarked()) .build()) .toList(); } @@ -112,13 +114,13 @@ private List attachKeywordsToReadPosts(List readPosts) .title(readPost.title()) .shortSummary(readPost.shortSummary()) .url(readPost.url()) - .companyName(readPost.companyName()) - .logoUrl(readPost.logoUrl()) - .publishedAt(readPost.publishedAt()) - .thumbnailUrl(readPost.thumbnailUrl()) - .viewCount(readPost.viewCount()) - .keywords(keywordMap.getOrDefault(readPost.postId(), List.of())) - .isBookmarked(null) + .companyName(readPost.companyName()) + .logoUrl(readPost.logoUrl()) + .publishedAt(readPost.publishedAt()) + .thumbnailUrl(thumbnailOptimizer.optimize(readPost.thumbnailUrl())) + .viewCount(readPost.viewCount()) + .keywords(keywordMap.getOrDefault(readPost.postId(), List.of())) + .isBookmarked(null) .readAt(readPost.readAt()) .build()) .toList(); @@ -142,13 +144,13 @@ private List attachBookmarksToReadPosts(List readPosts .title(readPost.title()) .shortSummary(readPost.shortSummary()) .url(readPost.url()) - .companyName(readPost.companyName()) - .logoUrl(readPost.logoUrl()) - .publishedAt(readPost.publishedAt()) - .thumbnailUrl(readPost.thumbnailUrl()) - .viewCount(readPost.viewCount()) - .keywords(readPost.keywords()) - .isBookmarked(bookmarkedPostIds.contains(readPost.postId())) + .companyName(readPost.companyName()) + .logoUrl(readPost.logoUrl()) + .publishedAt(readPost.publishedAt()) + .thumbnailUrl(thumbnailOptimizer.optimize(readPost.thumbnailUrl())) + .viewCount(readPost.viewCount()) + .keywords(readPost.keywords()) + .isBookmarked(bookmarkedPostIds.contains(readPost.postId())) .readAt(readPost.readAt()) .build()) .toList(); diff --git a/src/main/java/com/techfork/domain/post/service/PostQueryService.java b/src/main/java/com/techfork/domain/post/service/PostQueryService.java index 824ae5fa..82ad100f 100644 --- a/src/main/java/com/techfork/domain/post/service/PostQueryService.java +++ b/src/main/java/com/techfork/domain/post/service/PostQueryService.java @@ -5,12 +5,13 @@ import com.techfork.domain.post.dto.*; import com.techfork.domain.post.entity.PostKeyword; import com.techfork.domain.post.enums.EPostSortType; -import com.techfork.domain.post.repository.PostKeywordRepository; -import com.techfork.domain.post.repository.PostRepository; -import com.techfork.global.exception.CommonErrorCode; -import com.techfork.global.exception.GeneralException; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; +import com.techfork.domain.post.repository.PostKeywordRepository; +import com.techfork.domain.post.repository.PostRepository; +import com.techfork.global.exception.CommonErrorCode; +import com.techfork.global.exception.GeneralException; +import com.techfork.global.util.CloudflareThirdPartyThumbnailOptimizer; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.data.domain.PageRequest; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -28,9 +29,10 @@ public class PostQueryService { private final PostRepository postRepository; - private final PostKeywordRepository postKeywordRepository; - private final ScrabPostRepository scrabPostRepository; - private final PostConverter postConverter; + private final PostKeywordRepository postKeywordRepository; + private final ScrabPostRepository scrabPostRepository; + private final PostConverter postConverter; + private final CloudflareThirdPartyThumbnailOptimizer thumbnailOptimizer; public CompanyListResponse getCompanies() { List companies = postRepository.findDistinctCompanies(); @@ -142,13 +144,13 @@ private List attachKeywordsToPostInfoList(List posts) .id(post.id()) .title(post.title()) .shortSummary(post.shortSummary()) - .company(post.company()) - .url(post.url()) - .logoUrl(post.logoUrl()) - .thumbnailUrl(post.thumbnailUrl()) - .publishedAt(post.publishedAt()) - .viewCount(post.viewCount()) - .keywords(keywordMap.getOrDefault(post.id(), List.of())) + .company(post.company()) + .url(post.url()) + .logoUrl(post.logoUrl()) + .thumbnailUrl(thumbnailOptimizer.optimize(post.thumbnailUrl())) + .publishedAt(post.publishedAt()) + .viewCount(post.viewCount()) + .keywords(keywordMap.getOrDefault(post.id(), List.of())) .isBookmarked(null) .build()) .toList(); @@ -172,13 +174,13 @@ private List attachBookmarksToPostInfoList(List posts, .id(post.id()) .title(post.title()) .shortSummary(post.shortSummary()) - .company(post.company()) - .url(post.url()) - .logoUrl(post.logoUrl()) - .thumbnailUrl(post.thumbnailUrl()) - .publishedAt(post.publishedAt()) - .viewCount(post.viewCount()) - .keywords(post.keywords()) + .company(post.company()) + .url(post.url()) + .logoUrl(post.logoUrl()) + .thumbnailUrl(thumbnailOptimizer.optimize(post.thumbnailUrl())) + .publishedAt(post.publishedAt()) + .viewCount(post.viewCount()) + .keywords(post.keywords()) .isBookmarked(bookmarkedPostIds.contains(post.id())) .build()) .toList(); diff --git a/src/main/java/com/techfork/domain/recommendation/converter/RecommendationConverter.java b/src/main/java/com/techfork/domain/recommendation/converter/RecommendationConverter.java index 3be467d2..4f94b133 100644 --- a/src/main/java/com/techfork/domain/recommendation/converter/RecommendationConverter.java +++ b/src/main/java/com/techfork/domain/recommendation/converter/RecommendationConverter.java @@ -1,18 +1,23 @@ package com.techfork.domain.recommendation.converter; -import com.techfork.domain.post.entity.Post; -import com.techfork.domain.post.entity.PostKeyword; -import com.techfork.domain.recommendation.dto.RecommendationListResponse; -import com.techfork.domain.recommendation.dto.RecommendedPostDto; -import com.techfork.domain.recommendation.entity.RecommendedPost; -import org.springframework.stereotype.Component; +import com.techfork.domain.post.entity.Post; +import com.techfork.domain.post.entity.PostKeyword; +import com.techfork.domain.recommendation.dto.RecommendationListResponse; +import com.techfork.domain.recommendation.dto.RecommendedPostDto; +import com.techfork.domain.recommendation.entity.RecommendedPost; +import com.techfork.global.util.CloudflareThirdPartyThumbnailOptimizer; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; import java.util.List; -@Component -public class RecommendationConverter { - - public RecommendationListResponse toRecommendationListResponse(List recommendedPosts) { +@Component +@RequiredArgsConstructor +public class RecommendationConverter { + + private final CloudflareThirdPartyThumbnailOptimizer thumbnailOptimizer; + + public RecommendationListResponse toRecommendationListResponse(List recommendedPosts) { List dtos = recommendedPosts.stream() .map(this::toRecommendedPostDto) .toList(); @@ -34,14 +39,14 @@ public RecommendedPostDto toRecommendedPostDto(RecommendedPost recommendedPost) .id(recommendedPost.getId()) .postId(post.getId()) .title(post.getTitle()) - .shortSummary(post.getShortSummary()) - .company(post.getCompany()) - .url(post.getUrl()) - .logoUrl(post.getTechBlog().getLogoUrl()) - .thumbnailUrl(post.getThumbnailUrl()) - .viewCount(post.getViewCount()) - .isBookmarked(null) // Will be set later in service layer - .publishedAt(post.getPublishedAt()) + .shortSummary(post.getShortSummary()) + .company(post.getCompany()) + .url(post.getUrl()) + .logoUrl(post.getTechBlog().getLogoUrl()) + .thumbnailUrl(thumbnailOptimizer.optimize(post.getThumbnailUrl())) + .viewCount(post.getViewCount()) + .isBookmarked(null) // Will be set later in service layer + .publishedAt(post.getPublishedAt()) .keywords(keywords) .similarityScore(recommendedPost.getSimilarityScore()) .mmrScore(recommendedPost.getMmrScore()) diff --git a/src/main/java/com/techfork/domain/search/service/SearchServiceImpl.java b/src/main/java/com/techfork/domain/search/service/SearchServiceImpl.java index 768e0ae3..d21cea21 100644 --- a/src/main/java/com/techfork/domain/search/service/SearchServiceImpl.java +++ b/src/main/java/com/techfork/domain/search/service/SearchServiceImpl.java @@ -16,6 +16,7 @@ import com.techfork.domain.user.document.UserProfileDocument; import com.techfork.domain.user.repository.UserProfileDocumentRepository; import com.techfork.global.llm.EmbeddingClient; +import com.techfork.global.util.CloudflareThirdPartyThumbnailOptimizer; import com.techfork.global.util.RrfScorer; import com.techfork.global.util.VectorUtil; import java.io.IOException; @@ -55,6 +56,7 @@ public class SearchServiceImpl implements SearchService { private final PostRepository postRepository; private final ScrabPostRepository scrabPostRepository; private final Executor searchAsyncExecutor; + private final CloudflareThirdPartyThumbnailOptimizer thumbnailOptimizer; @Override public List searchOnlyBm25(String query) { @@ -325,7 +327,7 @@ private SearchResult mapToSearchResult(Hit hit) { .companyName(doc.getCompany()) .url(doc.getUrl()) .logoUrl(doc.getLogoUrl()) - .thumbnailUrl(doc.getThumbnailUrl()) + .thumbnailUrl(thumbnailOptimizer.optimize(doc.getThumbnailUrl())) .publishedAt(doc.getPublishedAt()) .hybridScore(score) .finalScore(score) @@ -400,4 +402,4 @@ private List attachPostMetadata(List results, Long u .build()) .collect(Collectors.toList()); } -} \ No newline at end of file +} diff --git a/src/main/java/com/techfork/global/config/CloudflareThirdPartyThumbnailOptimizationProperties.java b/src/main/java/com/techfork/global/config/CloudflareThirdPartyThumbnailOptimizationProperties.java new file mode 100644 index 00000000..c2b894e6 --- /dev/null +++ b/src/main/java/com/techfork/global/config/CloudflareThirdPartyThumbnailOptimizationProperties.java @@ -0,0 +1,29 @@ +package com.techfork.global.config; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +@Getter +@Setter +@Component +@NoArgsConstructor +@AllArgsConstructor +@ConfigurationProperties(prefix = "cloudflare.third-party-thumbnail-optimization") +public class CloudflareThirdPartyThumbnailOptimizationProperties { + + private boolean enabled = false; + + private String deliveryBaseUrl = ""; + + private Integer width = 480; + + private Integer quality = 75; + + private String fit = "scale-down"; + + private String format = "auto"; +} diff --git a/src/main/java/com/techfork/global/util/CloudflareThirdPartyThumbnailOptimizer.java b/src/main/java/com/techfork/global/util/CloudflareThirdPartyThumbnailOptimizer.java new file mode 100644 index 00000000..83c6760e --- /dev/null +++ b/src/main/java/com/techfork/global/util/CloudflareThirdPartyThumbnailOptimizer.java @@ -0,0 +1,86 @@ +package com.techfork.global.util; + +import com.techfork.global.config.CloudflareThirdPartyThumbnailOptimizationProperties; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; + +import java.net.URI; + +@Component +@RequiredArgsConstructor +public class CloudflareThirdPartyThumbnailOptimizer { + + private static final String CLOUDFLARE_IMAGE_PATH = "/cdn-cgi/image/"; + + private final CloudflareThirdPartyThumbnailOptimizationProperties properties; + + public String optimize(String thumbnailUrl) { + if (!properties.isEnabled() || !StringUtils.hasText(thumbnailUrl) || isAlreadyOptimized(thumbnailUrl)) { + return thumbnailUrl; + } + + if (!isAbsoluteHttpUrl(thumbnailUrl)) { + return thumbnailUrl; + } + + return normalizeDeliveryBaseUrl(properties.getDeliveryBaseUrl()) + + CLOUDFLARE_IMAGE_PATH + + buildOptions() + + "/" + + thumbnailUrl; + } + + private boolean isAlreadyOptimized(String thumbnailUrl) { + return thumbnailUrl.contains(CLOUDFLARE_IMAGE_PATH); + } + + private boolean isAbsoluteHttpUrl(String thumbnailUrl) { + try { + URI uri = URI.create(thumbnailUrl); + String scheme = uri.getScheme(); + return ("http".equalsIgnoreCase(scheme) || "https".equalsIgnoreCase(scheme)) + && StringUtils.hasText(uri.getHost()); + } catch (IllegalArgumentException e) { + return false; + } + } + + private String normalizeDeliveryBaseUrl(String deliveryBaseUrl) { + if (!StringUtils.hasText(deliveryBaseUrl)) { + return ""; + } + + return deliveryBaseUrl.endsWith("/") + ? deliveryBaseUrl.substring(0, deliveryBaseUrl.length() - 1) + : deliveryBaseUrl; + } + + private String buildOptions() { + StringBuilder options = new StringBuilder(); + + appendOption(options, "fit", properties.getFit()); + appendOption(options, "width", properties.getWidth()); + appendOption(options, "quality", properties.getQuality()); + appendOption(options, "format", properties.getFormat()); + + return options.toString(); + } + + private void appendOption(StringBuilder options, String key, Object value) { + if (value == null) { + return; + } + + String stringValue = value.toString(); + if (!StringUtils.hasText(stringValue)) { + return; + } + + if (!options.isEmpty()) { + options.append(','); + } + + options.append(key).append('=').append(stringValue); + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 67122695..c3bd13bd 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -126,6 +126,15 @@ webhook: discord: url: ${DISCORD_WEBHOOK_URL} +cloudflare: + third-party-thumbnail-optimization: + enabled: ${CLOUDFLARE_THIRD_PARTY_THUMBNAIL_OPTIMIZATION_ENABLED:false} + delivery-base-url: ${CLOUDFLARE_THIRD_PARTY_THUMBNAIL_OPTIMIZATION_DELIVERY_BASE_URL:} + width: 480 + quality: 75 + fit: scale-down + format: auto + # Resilience4j 설정 (LLM API 호출용) resilience4j: circuitbreaker: diff --git a/src/test/java/com/techfork/domain/activity/service/ActivityQueryServiceTest.java b/src/test/java/com/techfork/domain/activity/service/ActivityQueryServiceTest.java index f682150c..f173f954 100644 --- a/src/test/java/com/techfork/domain/activity/service/ActivityQueryServiceTest.java +++ b/src/test/java/com/techfork/domain/activity/service/ActivityQueryServiceTest.java @@ -14,6 +14,7 @@ import com.techfork.domain.user.exception.UserErrorCode; import com.techfork.domain.user.repository.UserRepository; import com.techfork.global.exception.GeneralException; +import com.techfork.global.util.CloudflareThirdPartyThumbnailOptimizer; import org.junit.jupiter.api.AfterAll; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; @@ -32,6 +33,7 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.BDDMockito.given; import static org.mockito.Mockito.*; @@ -54,6 +56,9 @@ class ActivityQueryServiceTest { @Mock private ActivityConverter activityConverter; + @Mock + private CloudflareThirdPartyThumbnailOptimizer thumbnailOptimizer; + @InjectMocks private ActivityQueryService activityQueryService; @@ -64,6 +69,7 @@ class ActivityQueryServiceTest { @BeforeEach void setUp() { + lenient().when(thumbnailOptimizer.optimize(anyString())).thenAnswer(invocation -> invocation.getArgument(0)); mockUser = mock(User.class); mockBookmarksFirstPage = Arrays.asList( diff --git a/src/test/java/com/techfork/domain/post/service/PostQueryServiceTest.java b/src/test/java/com/techfork/domain/post/service/PostQueryServiceTest.java index 9a2a45b1..3f670679 100644 --- a/src/test/java/com/techfork/domain/post/service/PostQueryServiceTest.java +++ b/src/test/java/com/techfork/domain/post/service/PostQueryServiceTest.java @@ -8,6 +8,8 @@ import com.techfork.domain.post.repository.PostKeywordRepository; import com.techfork.domain.post.repository.PostRepository; import com.techfork.global.exception.GeneralException; +import com.techfork.global.util.CloudflareThirdPartyThumbnailOptimizer; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -23,6 +25,7 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.BDDMockito.given; import static org.mockito.Mockito.*; @@ -47,9 +50,17 @@ class PostQueryServiceTest { @Mock private PostConverter postConverter; + @Mock + private CloudflareThirdPartyThumbnailOptimizer thumbnailOptimizer; + @InjectMocks private PostQueryService postQueryService; + @BeforeEach + void setUp() { + lenient().when(thumbnailOptimizer.optimize(anyString())).thenAnswer(invocation -> invocation.getArgument(0)); + } + @Test @DisplayName("getCompanies() - 회사 목록 조회 성공") void getCompanies_Success() { diff --git a/src/test/java/com/techfork/domain/recommendation/converter/RecommendationConverterTest.java b/src/test/java/com/techfork/domain/recommendation/converter/RecommendationConverterTest.java new file mode 100644 index 00000000..6208f93c --- /dev/null +++ b/src/test/java/com/techfork/domain/recommendation/converter/RecommendationConverterTest.java @@ -0,0 +1,62 @@ +package com.techfork.domain.recommendation.converter; + +import com.techfork.domain.post.entity.Post; +import com.techfork.domain.recommendation.dto.RecommendedPostDto; +import com.techfork.domain.recommendation.entity.RecommendedPost; +import com.techfork.domain.source.entity.TechBlog; +import com.techfork.domain.user.entity.User; +import com.techfork.domain.user.enums.SocialType; +import com.techfork.global.util.CloudflareThirdPartyThumbnailOptimizer; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.time.LocalDateTime; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class RecommendationConverterTest { + + @Test + @DisplayName("추천 DTO 생성 시 썸네일 URL에 Cloudflare 최적화를 적용한다") + void toRecommendedPostDto_OptimizesThumbnailUrl() { + CloudflareThirdPartyThumbnailOptimizer thumbnailOptimizer = mock(CloudflareThirdPartyThumbnailOptimizer.class); + RecommendationConverter converter = new RecommendationConverter(thumbnailOptimizer); + + TechBlog techBlog = TechBlog.create( + "테스트 회사", + "https://techfork.com", + "https://techfork.com/rss", + "https://techfork.com/logo.png" + ); + + Post post = Post.builder() + .title("게시글") + .shortSummary("요약") + .company("테스트 회사") + .url("https://techfork.com/posts/1") + .thumbnailUrl("https://images.example.com/thumb.jpg") + .publishedAt(LocalDateTime.now()) + .crawledAt(LocalDateTime.now()) + .techBlog(techBlog) + .build(); + + User user = User.createSocialUser( + SocialType.KAKAO, + "social-id", + "test@example.com", + "https://example.com/profile.png" + ); + + RecommendedPost recommendedPost = RecommendedPost.create(user, post, 0.9, 0.8, 1); + + when(thumbnailOptimizer.optimize("https://images.example.com/thumb.jpg")) + .thenReturn("https://api.techfork.com/cdn-cgi/image/fit=scale-down,width=480,quality=75,format=auto/https://images.example.com/thumb.jpg"); + + RecommendedPostDto result = converter.toRecommendedPostDto(recommendedPost); + + assertThat(result.thumbnailUrl()) + .isEqualTo("https://api.techfork.com/cdn-cgi/image/fit=scale-down,width=480,quality=75,format=auto/https://images.example.com/thumb.jpg"); + } +} diff --git a/src/test/java/com/techfork/evaluation/search/SearchEvaluationTestBase.java b/src/test/java/com/techfork/evaluation/search/SearchEvaluationTestBase.java index abab3477..03249d3d 100644 --- a/src/test/java/com/techfork/evaluation/search/SearchEvaluationTestBase.java +++ b/src/test/java/com/techfork/evaluation/search/SearchEvaluationTestBase.java @@ -14,7 +14,9 @@ import com.techfork.domain.user.repository.UserProfileDocumentRepository; import com.techfork.evaluation.search.util.GroundTruthItem; import com.techfork.evaluation.search.util.SearchQualityService; +import com.techfork.global.config.CloudflareThirdPartyThumbnailOptimizationProperties; import com.techfork.global.llm.EmbeddingClient; +import com.techfork.global.util.CloudflareThirdPartyThumbnailOptimizer; import lombok.extern.slf4j.Slf4j; import org.junit.jupiter.api.Tag; import org.springframework.beans.factory.annotation.Autowired; @@ -101,11 +103,14 @@ protected Map runEvaluation( for (Map.Entry entry : scenarios.entrySet()) { String scenarioName = entry.getKey(); GeneralSearchProperties props = entry.getValue(); + CloudflareThirdPartyThumbnailOptimizer thumbnailOptimizer = new CloudflareThirdPartyThumbnailOptimizer( + new CloudflareThirdPartyThumbnailOptimizationProperties() + ); SearchService svc = new SearchServiceImpl( elasticsearchClient, embeddingClient, props, userProfileDocumentRepository, postRepository, - scrabPostRepository, searchAsyncExecutor); + scrabPostRepository, searchAsyncExecutor, thumbnailOptimizer); // index: [nDCG@4, nDCG@8, nDCG@20, Recall@4, Recall@8, Recall@20, latency] double[] sums = new double[7]; diff --git a/src/test/java/com/techfork/evaluation/search/setup/SearchGroundTruthGenerator.java b/src/test/java/com/techfork/evaluation/search/setup/SearchGroundTruthGenerator.java index 2c5269d9..3968d5ec 100644 --- a/src/test/java/com/techfork/evaluation/search/setup/SearchGroundTruthGenerator.java +++ b/src/test/java/com/techfork/evaluation/search/setup/SearchGroundTruthGenerator.java @@ -11,8 +11,10 @@ import com.techfork.domain.user.repository.UserProfileDocumentRepository; import com.techfork.evaluation.recommendation.setup.components.FileExporter; import com.techfork.evaluation.search.util.GroundTruthItem; +import com.techfork.global.config.CloudflareThirdPartyThumbnailOptimizationProperties; import com.techfork.global.llm.EmbeddingClient; import com.techfork.global.llm.LlmClient; +import com.techfork.global.util.CloudflareThirdPartyThumbnailOptimizer; import lombok.extern.slf4j.Slf4j; import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.DisplayName; @@ -138,9 +140,12 @@ void generateSearchGroundTruth() throws IOException { // Step 3 & 4: 각 쿼리로 검색 실행 + LLM 관련도 평가 log.info("=== [Step 3 & 4] 검색 실행 및 LLM 관련도 평가 시작 ==="); + CloudflareThirdPartyThumbnailOptimizer thumbnailOptimizer = new CloudflareThirdPartyThumbnailOptimizer( + new CloudflareThirdPartyThumbnailOptimizationProperties() + ); SearchServiceImpl searchService = new SearchServiceImpl( elasticsearchClient, embeddingClient, generalSearchProperties, - userProfileDocumentRepository, postRepository, scrabPostRepository, searchAsyncExecutor); + userProfileDocumentRepository, postRepository, scrabPostRepository, searchAsyncExecutor, thumbnailOptimizer); List groundTruthItems = scoreAllQueries(uniqueQueryMap, searchService); log.info("최종 ground-truth 항목 수: {}", groundTruthItems.size()); diff --git a/src/test/java/com/techfork/global/util/CloudflareThirdPartyThumbnailOptimizerTest.java b/src/test/java/com/techfork/global/util/CloudflareThirdPartyThumbnailOptimizerTest.java new file mode 100644 index 00000000..34c455c8 --- /dev/null +++ b/src/test/java/com/techfork/global/util/CloudflareThirdPartyThumbnailOptimizerTest.java @@ -0,0 +1,66 @@ +package com.techfork.global.util; + +import com.techfork.global.config.CloudflareThirdPartyThumbnailOptimizationProperties; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class CloudflareThirdPartyThumbnailOptimizerTest { + + @Test + @DisplayName("활성화되면 외부 썸네일 URL을 Cloudflare 이미지 변환 URL로 바꾼다") + void optimize_ReturnsCloudflareImageUrl_ForAbsoluteHttpUrl() { + CloudflareThirdPartyThumbnailOptimizationProperties properties = new CloudflareThirdPartyThumbnailOptimizationProperties( + true, + "https://api.techfork.com", + 360, + 70, + "scale-down", + "auto" + ); + + CloudflareThirdPartyThumbnailOptimizer optimizer = new CloudflareThirdPartyThumbnailOptimizer(properties); + + String optimizedUrl = optimizer.optimize("https://images.example.com/thumb.jpg"); + + assertThat(optimizedUrl) + .isEqualTo("https://api.techfork.com/cdn-cgi/image/fit=scale-down,width=360,quality=70,format=auto/https://images.example.com/thumb.jpg"); + } + + @Test + @DisplayName("비활성화되면 기존 URL을 그대로 유지한다") + void optimize_ReturnsOriginalUrl_WhenDisabled() { + CloudflareThirdPartyThumbnailOptimizationProperties properties = new CloudflareThirdPartyThumbnailOptimizationProperties(); + CloudflareThirdPartyThumbnailOptimizer optimizer = new CloudflareThirdPartyThumbnailOptimizer(properties); + + String originalUrl = "https://images.example.com/thumb.jpg"; + + assertThat(optimizer.optimize(originalUrl)).isEqualTo(originalUrl); + } + + @Test + @DisplayName("이미 Cloudflare 변환 경로면 다시 감싸지 않는다") + void optimize_ReturnsOriginalUrl_WhenAlreadyOptimized() { + CloudflareThirdPartyThumbnailOptimizationProperties properties = new CloudflareThirdPartyThumbnailOptimizationProperties(); + properties.setEnabled(true); + + CloudflareThirdPartyThumbnailOptimizer optimizer = new CloudflareThirdPartyThumbnailOptimizer(properties); + + String originalUrl = "https://api.techfork.com/cdn-cgi/image/width=480,format=auto/https://images.example.com/thumb.jpg"; + + assertThat(optimizer.optimize(originalUrl)).isEqualTo(originalUrl); + } + + @Test + @DisplayName("상대 경로나 비정상 URL은 건드리지 않는다") + void optimize_ReturnsOriginalUrl_ForNonAbsoluteUrl() { + CloudflareThirdPartyThumbnailOptimizationProperties properties = new CloudflareThirdPartyThumbnailOptimizationProperties(); + properties.setEnabled(true); + + CloudflareThirdPartyThumbnailOptimizer optimizer = new CloudflareThirdPartyThumbnailOptimizer(properties); + + assertThat(optimizer.optimize("/images/thumb.jpg")).isEqualTo("/images/thumb.jpg"); + assertThat(optimizer.optimize("thumb.jpg")).isEqualTo("thumb.jpg"); + } +}