Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,11 @@ dependencies {

// WebFlux
implementation 'org.springframework.boot:spring-boot-starter-webflux'

//s3
implementation platform("io.awspring.cloud:spring-cloud-aws-dependencies:3.1.1")
implementation 'io.awspring.cloud:spring-cloud-aws-starter-s3'
implementation 'org.apache.httpcomponents:httpclient:4.5.14'
}

dependencyManagement {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package com.sofa.linkiving.domain.link.abstraction;

public interface ImageUploader {
/**
* 외부 이미지 URL을 입력받아 스토리지에 저장하고, 접근 가능한 URL을 반환한다.
* 실패 시 null 값을 반환한다 (Soft Fail).
*/
String uploadFromUrl(String originalUrl);
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import com.sofa.linkiving.domain.link.abstraction.ImageUploader;
import com.sofa.linkiving.domain.link.dto.internal.LinkDto;
import com.sofa.linkiving.domain.link.dto.internal.LinksDto;
import com.sofa.linkiving.domain.link.dto.internal.OgTagDto;
Expand All @@ -29,9 +30,11 @@ public class LinkFacade {
private final LinkService linkService;
private final OgTagCrawler ogTagCrawler;
private final SummaryService summaryService;
private final ImageUploader imageUploader;

public LinkRes createLink(Member member, String url, String title, String memo, String imageUrl) {
Link link = linkService.createLink(member, url, title, memo, imageUrl);
String storedImageUrl = imageUploader.uploadFromUrl(imageUrl);
Link link = linkService.createLink(member, url, title, memo, storedImageUrl);
return LinkRes.from(link);
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package com.sofa.linkiving.infra.s3;

import java.io.IOException;
import java.net.URL;
import java.net.URLConnection;

import org.springframework.stereotype.Component;

@Component
public class DefaultUrlConnectionFactory implements UrlConnectionFactory {

@Override
public URLConnection createConnection(String url) throws IOException {
return new URL(url).openConnection();
}
}
92 changes: 92 additions & 0 deletions src/main/java/com/sofa/linkiving/infra/s3/S3ImageUploader.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
package com.sofa.linkiving.infra.s3;

import java.io.InputStream;
import java.net.URLConnection;
import java.nio.charset.StandardCharsets;
import java.util.UUID;

import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;

import com.sofa.linkiving.domain.link.abstraction.ImageUploader;

import io.awspring.cloud.s3.S3Template;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;

@Slf4j
@Component
@RequiredArgsConstructor
public class S3ImageUploader implements ImageUploader {

private final S3Template s3Template;
private final UrlConnectionFactory urlConnectionFactory;

@Value("${spring.cloud.aws.s3.bucket}")
private String bucketName;

@Value("${spring.cloud.aws.region.static}")
private String region;

@Override
public String uploadFromUrl(String originalUrl) {
if (originalUrl == null || originalUrl.isBlank()) {
return null;
}

try {
String s3Key = generateUniqueKeyFromUrl(originalUrl);

String s3Url = buildS3Url(s3Key);

if (s3Template.objectExists(bucketName, s3Key)) {
log.info("Image already exists (Cache Hit): {} -> {}", originalUrl, s3Key);
return s3Url;
}

URLConnection connection = urlConnectionFactory.createConnection(originalUrl);
connection.setConnectTimeout(3000);
connection.setReadTimeout(3000);

String contentType = connection.getContentType();
if (contentType == null || !contentType.startsWith("image/")) {
log.warn("Not Image: {}", originalUrl);
return null;
}

try (InputStream inputStream = connection.getInputStream()) {
s3Template.upload(bucketName, s3Key, inputStream, null);
log.info("Image uploaded: {}", s3Key);
return s3Url;
}

} catch (Exception e) {
log.warn("Upload failed, falling back to original URL: {}", e.getMessage());
return null;
}
}

private String buildS3Url(String key) {
return String.format("https://%s.s3.%s.amazonaws.com/%s", bucketName, region, key);
}

private String generateUniqueKeyFromUrl(String url) {
try {
String extension = "jpg";
int lastDotIndex = url.lastIndexOf('.');
if (lastDotIndex > 0 && lastDotIndex < url.length() - 1) {
String ext = url.substring(lastDotIndex + 1);
if (ext.contains("?")) {
ext = ext.substring(0, ext.indexOf("?"));
}
if (ext.length() <= 4 && ext.matches("[a-zA-Z]+")) {
extension = ext;
}
}
UUID uuid = UUID.nameUUIDFromBytes(url.getBytes(StandardCharsets.UTF_8));
return "links/" + uuid + "." + extension;
} catch (Exception e) {
return "links/" + UUID.randomUUID() + ".jpg";
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package com.sofa.linkiving.infra.s3;

import java.io.IOException;
import java.net.URLConnection;

public interface UrlConnectionFactory {
URLConnection createConnection(String url) throws IOException;
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,12 @@
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.context.ApplicationEventPublisher;

import com.sofa.linkiving.domain.link.abstraction.ImageUploader;
import com.sofa.linkiving.domain.link.dto.internal.LinkDto;
import com.sofa.linkiving.domain.link.dto.internal.LinksDto;
import com.sofa.linkiving.domain.link.dto.internal.OgTagDto;
import com.sofa.linkiving.domain.link.dto.response.LinkCardsRes;
import com.sofa.linkiving.domain.link.dto.response.LinkRes;
import com.sofa.linkiving.domain.link.dto.response.MetaScrapeRes;
import com.sofa.linkiving.domain.link.dto.response.RecreateSummaryResponse;
import com.sofa.linkiving.domain.link.entity.Link;
Expand Down Expand Up @@ -48,18 +50,21 @@ public class LinkFacadeTest {
@Mock
private LinkQueryService linkQueryService;

@Mock
private ApplicationEventPublisher eventPublisher;

@Mock
private SummaryService summaryService;

@Mock
private OgTagCrawler ogTagCrawler;

@Mock
private ImageUploader imageUploader;

@Mock
private ApplicationEventPublisher eventPublisher;

@BeforeEach
void setUp() {
linkFacade = new LinkFacade(linkService, ogTagCrawler, summaryService);
linkFacade = new LinkFacade(linkService, ogTagCrawler, summaryService, imageUploader);
}

@Test
Expand Down Expand Up @@ -93,7 +98,7 @@ void shouldReturnMetaScrapeResWhenCrawlSucceeds() {
void shouldReturnRecreateSummaryResponseWhenRecreateSummary() {
// given
Long linkId = 1L;
Member member = mock(Member.class); // Member 객체 Mock
Member member = mock(Member.class);
Format format = Format.DETAILED;
String url = "https://example.com";
String existingSummaryBody = "기존 요약 내용입니다.";
Expand Down Expand Up @@ -149,31 +154,40 @@ void shouldReturnEmptyMetaScrapeResWhenCrawlFails() {
}

@Test
@DisplayName("링크를 생성하고 Entity를 반환한다")
@DisplayName("이미지 URL을 업로드하고 반환된 저장 경로로 링크를 생성한다")
void shouldCreateLink() {
// given
Member member = Member.builder().email("test@example.com").build();
Link link = Link.builder()
.member(member)
.url("https://example.com")
.title("테스트 링크")
Member member = mock(Member.class);
String url = "https://example.com";
String title = "테스트 제목";
String memo = "테스트 메모";
String originalImageUrl = "https://original.com/image.jpg";
String storedImageUrl = "https://s3-bucket.com/stored-image.jpg";

given(imageUploader.uploadFromUrl(originalImageUrl)).willReturn(storedImageUrl);

Link savedLink = Link.builder()
.url(url)
.title(title)
.memo(memo)
.imageUrl(storedImageUrl)
.build();

given(linkQueryService.existsByUrl(member, "https://example.com")).willReturn(false);
given(linkCommandService.saveLink(any(), any(), any(), any(), any())).willReturn(link);
given(linkCommandService.saveLink(member, url, title, memo, storedImageUrl))
.willReturn(savedLink);

// when
Link createdLink = linkService.createLink(
member, "https://example.com", "테스트 링크", "메모", null
);
LinkRes result = linkFacade.createLink(member, url, title, memo, originalImageUrl);

// then
assertThat(createdLink).isNotNull();
assertThat(createdLink).isInstanceOf(Link.class); // Entity 반환 확인
assertThat(createdLink.getUrl()).isEqualTo("https://example.com");
assertThat(result).isNotNull();
assertThat(result.url()).isEqualTo(url);
assertThat(result.imageUrl()).isEqualTo(storedImageUrl);

verify(linkQueryService, times(1)).existsByUrl(member, "https://example.com");
verify(linkCommandService, times(1)).saveLink(any(), any(), any(), any(), any());
// Verify
verify(imageUploader, times(1)).uploadFromUrl(originalImageUrl);
verify(linkQueryService, times(1)).existsByUrl(member, url);
verify(linkCommandService, times(1)).saveLink(member, url, title, memo, storedImageUrl);
}

@Test
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
import org.springframework.transaction.annotation.Transactional;

import com.fasterxml.jackson.databind.ObjectMapper;
import com.sofa.linkiving.domain.link.abstraction.ImageUploader;
import com.sofa.linkiving.domain.link.ai.AiSummaryClient;
import com.sofa.linkiving.domain.link.dto.request.LinkCreateReq;
import com.sofa.linkiving.domain.link.dto.request.LinkMemoUpdateReq;
Expand Down Expand Up @@ -64,6 +65,9 @@ public class LinkApiIntegrationTest {
@MockitoBean
private AiSummaryClient aiSummaryClient;

@MockitoBean
private ImageUploader imageUploader;

private Member testMember;
private Member otherMember;
private UserDetails testUserDetails;
Expand All @@ -89,7 +93,17 @@ void setUp() {
@DisplayName("링크 생성 성공 시 DB에 저장되고 200 OK 응답")
void shouldCreateLinkSuccessfully() throws Exception {
// given
LinkCreateReq req = new LinkCreateReq("https://example.com", "테스트 링크", "테스트 메모", null);
String originalImageUrl = "https://original.com/image.jpg";
String uploadedS3Url = "https://s3.amazonaws.com/bucket/links/uuid.jpg";

given(imageUploader.uploadFromUrl(originalImageUrl)).willReturn(uploadedS3Url);

LinkCreateReq req = new LinkCreateReq(
"https://example.com",
"테스트 링크",
"테스트 메모",
originalImageUrl
);

// when & then
mockMvc.perform(
Expand Down
Loading