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
4 changes: 3 additions & 1 deletion api/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,14 @@ dependencies {

implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.7.0'

implementation 'org.jsoup:jsoup:1.17.2'

implementation 'io.jsonwebtoken:jjwt-api:0.12.6'
implementation 'io.jsonwebtoken:jjwt-impl:0.12.6'
implementation 'io.jsonwebtoken:jjwt-jackson:0.12.6'

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

runtimeOnly 'com.mysql:mysql-connector-j'
runtimeOnly 'com.h2database:h2'

Expand Down
2 changes: 2 additions & 0 deletions api/src/main/java/com/pinback/api/PinbackApiApplication.java
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.data.jpa.repository.config.EnableJpaAuditing;
import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
import org.springframework.scheduling.annotation.EnableAsync;

import com.pinback.application.config.ProfileImageConfig;

Expand All @@ -18,6 +19,7 @@
@EntityScan("com.pinback.domain")
@EnableJpaRepositories("com.pinback.infrastructure")
@EnableJpaAuditing
@EnableAsync
@EnableConfigurationProperties(ProfileImageConfig.class)
public class PinbackApiApplication {
public static void main(String[] args) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
package com.pinback.api.article.controller;

import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import com.pinback.api.article.dto.request.ArticleCreateRequest;
import com.pinback.application.article.port.in.CreateArticlePort;
import com.pinback.domain.user.entity.User;
import com.pinback.infrastructure.article.service.ArticleUpdateService;
import com.pinback.shared.annotation.CurrentUser;
import com.pinback.shared.dto.ResponseDto;

import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;

@Slf4j
@RestController
@RequestMapping("/api/v3/articles")
@RequiredArgsConstructor
@Tag(name = "ArticleV3", description = "아티클 관리 API V3")
public class ArticleControllerV3 {
private final CreateArticlePort createArticlePort;
private final ArticleUpdateService articleMetadataUpdateService;

@Operation(summary = "아티클 생성v3", description = "url에서 썸네일과 제목을 추출하여 새로운 아티클을 생성합니다")
@PostMapping
public ResponseDto<Void> createArticle(
@Parameter(hidden = true) @CurrentUser User user,
@Valid @RequestBody ArticleCreateRequest request
) {
createArticlePort.createArticleV3(user, request.toCommand());
return ResponseDto.ok();
}

@PostMapping("/metadata")
public ResponseDto<Void> migrateMetadata() {
articleMetadataUpdateService.migrateMissingMetadata();
return ResponseDto.ok();
}
}
4 changes: 3 additions & 1 deletion api/src/main/resources/application.yml
Original file line number Diff line number Diff line change
Expand Up @@ -51,4 +51,6 @@ profile-images:
images:
IMAGE1: ${PROFILE_IMAGE1:}
IMAGE2: ${PROFILE_IMAGE2:}
IMAGE3: ${PROFILE_IMAGE3:}
IMAGE3: ${PROFILE_IMAGE3:}

default-thumbnail: ${DEFAULT_THUMBNAIL:}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package com.pinback.application.article.dto.response;

public record ArticleMetadataResponse(
String title,
String thumbnailUrl
) {
public static ArticleMetadataResponse of(String title, String thumbnailUrl) {
return new ArticleMetadataResponse(title, thumbnailUrl);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,6 @@

public interface CreateArticlePort {
void createArticle(User user, ArticleCreateCommand command);

void createArticleV3(User user, ArticleCreateCommand command);
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import org.springframework.transaction.annotation.Transactional;

import com.pinback.application.article.dto.command.ArticleCreateCommand;
import com.pinback.application.article.dto.response.ArticleMetadataResponse;
import com.pinback.application.article.port.in.CreateArticlePort;
import com.pinback.application.article.port.out.ArticleGetServicePort;
import com.pinback.application.article.port.out.ArticleSaveServicePort;
Expand All @@ -14,14 +15,17 @@
import com.pinback.application.common.exception.MemoLengthLimitException;
import com.pinback.application.notification.port.in.GetPushSubscriptionPort;
import com.pinback.application.notification.port.in.ScheduleArticleReminderPort;
import com.pinback.application.test.port.out.ArticleMetadataPort;
import com.pinback.domain.article.entity.Article;
import com.pinback.domain.category.entity.Category;
import com.pinback.domain.notification.entity.PushSubscription;
import com.pinback.domain.user.entity.User;
import com.pinback.shared.util.TextUtil;

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;

@Slf4j
@Service
@RequiredArgsConstructor
@Transactional
Expand All @@ -37,6 +41,8 @@ public class CreateArticleUsecase implements CreateArticlePort {
private final GetPushSubscriptionPort getPushSubscription;
private final ScheduleArticleReminderPort scheduleArticleReminder;

private final ArticleMetadataPort articleMetadataPort;

@Override
public void createArticle(User user, ArticleCreateCommand command) {
validateArticleCreation(user, command);
Expand All @@ -48,6 +54,26 @@ public void createArticle(User user, ArticleCreateCommand command) {
scheduleReminderIfNeeded(savedArticle, user, command.remindTime());
}

@Override
public void createArticleV3(User user, ArticleCreateCommand command) {
// 1. url 중복 검증
validateArticleCreation(user, command);

// 2. 메타데이터 가져오기
ArticleMetadataResponse metadata = articleMetadataPort.extractMetadata(command.url());

// 3.0 로그로 찍어서 확인해보기
log.info("title: {}, thumbnail: {}", metadata.title(), metadata.thumbnailUrl());

// 3. 아티클 저장
Category category = getCategoryPort.getCategoryAndUser(command.categoryId(), user);
// s3에 url 있는지 확인 후 있으면 가져오고, 없으면 저장해서 가져오기
Article article = Article.createWithMetaData(command.url(), command.memo(), user, category,
command.remindTime(), metadata.title(), metadata.thumbnailUrl());
Article savedArticle = articleSaveService.save(article);
scheduleReminderIfNeeded(savedArticle, user, command.remindTime());
}

private void validateArticleCreation(User user, ArticleCreateCommand command) {
if (articleGetService.checkExistsByUserAndUrl(user, command.url())) {
throw new ArticleAlreadyExistException();
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package com.pinback.application.test.port.out;

import com.pinback.application.article.dto.response.ArticleMetadataResponse;

public interface ArticleMetadataPort {
ArticleMetadataResponse extractMetadata(String url);
}
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,12 @@ public class Article extends BaseEntity {
@ColumnDefault("false")
private Boolean isReadAfterRemind;

@Column(name = "title")
private String title;

@Column(name = "thumbnail")
private String thumbnail;

public static Article create(String url, String memo, User user, Category category, LocalDateTime remindAt) {
validateMemo(memo);

Expand All @@ -76,6 +82,23 @@ public static Article create(String url, String memo, User user, Category catego
.build();
}

public static Article createWithMetaData(String url, String memo, User user, Category category,
LocalDateTime remindAt, String title, String thumbnail) {
validateMemo(memo);

return Article.builder()
.url(url)
.memo(memo)
.user(user)
.category(category)
.isRead(false)
.remindAt(remindAt)
.isReadAfterRemind(false)
.title(title)
.thumbnail(thumbnail)
.build();
}

// 아티클 자체의 비즈니스 로직을 보호하기 위한 유효성 검사
private static void validateMemo(String memo) {
if (memo != null && TextUtil.countGraphemeClusters(memo) > 500) {
Expand Down Expand Up @@ -117,4 +140,15 @@ public boolean hasReminder() {
public boolean isReminderDue(LocalDateTime now) {
return hasReminder() && this.remindAt.isBefore(now);
}

public void updateMetadata(String title, String thumbnail) {
if (title != null && !title.isBlank()) {
this.title = title;
}

// 썸네일이 유효할 때만 업데이트 (null 방어 로직)
if (thumbnail != null && !thumbnail.isBlank()) {
this.thumbnail = thumbnail;
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package com.pinback.domain.article.exception;

import com.pinback.shared.constant.ExceptionCode;
import com.pinback.shared.exception.ApplicationException;

public class ArticleTitleNotFoundException extends ApplicationException {
public ArticleTitleNotFoundException() {
super(ExceptionCode.ARTICLE_TILE_NOT_FOUND);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package com.pinback.domain.article.exception;

import com.pinback.shared.constant.ExceptionCode;
import com.pinback.shared.exception.ApplicationException;

public class InvalidUrlException extends ApplicationException {
public InvalidUrlException() {
super(ExceptionCode.INVALID_URL);
}
}
2 changes: 2 additions & 0 deletions infrastructure/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ dependencies {
implementation 'com.amazonaws:aws-java-sdk-s3:1.12.549'
implementation 'io.awspring.cloud:spring-cloud-aws-starter-s3:3.1.1'

implementation 'org.jsoup:jsoup:1.15.3'

}

def generated = layout.buildDirectory.dir("generated/querydsl").get().asFile
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package com.pinback.infrastructure.article.repository;

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

import org.springframework.data.jpa.repository.JpaRepository;
Expand All @@ -23,4 +24,6 @@ public interface ArticleRepository extends JpaRepository<Article, Long>, Article
Optional<Article> findRecentArticleByUser(@Param("user") User user);

void deleteAllByUser(User user);

List<Article> findByTitleIsNull();
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
package com.pinback.infrastructure.article.service;

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

import org.jsoup.Jsoup;
import org.jsoup.nodes.Document;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;

import com.pinback.application.article.dto.response.ArticleMetadataResponse;
import com.pinback.application.common.exception.S3UploadException;
import com.pinback.application.test.port.out.ArticleMetadataPort;
import com.pinback.domain.article.exception.ArticleTitleNotFoundException;
import com.pinback.domain.article.exception.InvalidUrlException;
import com.pinback.infrastructure.s3.service.S3StorageService;

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;

@Slf4j
@Component
@RequiredArgsConstructor
public class ArticleMetadataAdapter implements ArticleMetadataPort {
private static final String COMMON_USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36";
private static final int TIMEOUT_MILLIS = 5000;
private final S3StorageService s3StorageService;
@Value("${default-thumbnail}")
private String DEFAULT_THUMBNAIL_URL;

@Override
public ArticleMetadataResponse extractMetadata(String url) {
String processedUrl = preProcessUrl(url);
try {
// 1. 웹페이지 접속
Document doc = Jsoup.connect(processedUrl)
.userAgent(COMMON_USER_AGENT)
.timeout(TIMEOUT_MILLIS)
.get();

// 2. 제목 추출 (Open Graph -> HTML Title 순)
String title = extractMetaContent(doc, "meta[property=og:title]");
if (title.isBlank()) {
title = doc.title();
}

// 제목을 불러올 수 없는 경우 예외 처리
if (title.isBlank()) {
log.error("제목 추출 실패 (URL: {})", url);
throw new ArticleTitleNotFoundException();
}

// 3. 썸네일 추출 (Open Graph)
String originalThumbnail = extractMetaContent(doc, "meta[property=og:image]");

// 썸네일이 없는 경우 기본 이미지로 처리
String finalThumbnail;
if (originalThumbnail.isBlank()) {
finalThumbnail = DEFAULT_THUMBNAIL_URL;
} else {
finalThumbnail = uploadToS3(originalThumbnail);
}

return ArticleMetadataResponse.of(title, finalThumbnail);

} catch (IOException | IllegalArgumentException e) {
log.error("URL 정보를 가져오는 중 오류 발생: {} / 사유: {}", url, e.getMessage());
throw new InvalidUrlException();
}

}

private String extractMetaContent(Document doc, String selector) {
return doc.select(selector).attr("content").trim();
}

private String preProcessUrl(String url) {
if (url == null || url.isBlank()) {
return url;
}

// 1. 네이버 블로그 처리: blog.naver.com -> m.blog.naver.com
if (url.contains("blog.naver.com") && !url.contains("m.blog.naver.com")) {
return url.replace("blog.naver.com", "m.blog.naver.com");
}

// 2. 티스토리 처리: tistory.com 게시글 -> 도메인 뒤에 /m 붙이기 (메인 페이지 제외)
if (url.contains("tistory.com") && !url.contains("tistory.com/m/") && isTistoryPost(url)) {
return url.replaceFirst("tistory.com/", "tistory.com/m/");
}

return url;
}

private boolean isTistoryPost(String url) {
// 도메인 뒤에 추가 경로(숫자나 문자)가 있으면 포스팅으로 간주
String path = url.replaceFirst("https?://[^/]+", "");
return path.length() > 1;
}

private String uploadToS3(String originalThumbnailUrl) {
try {
URL url = new URL(originalThumbnailUrl);
URLConnection conn = url.openConnection();

// 타임아웃 설정
conn.setConnectTimeout(3000);
conn.setReadTimeout(3000);

try (InputStream is = conn.getInputStream()) {
long fileSize = conn.getContentLengthLong();
String contentType = conn.getContentType();

return s3StorageService.uploadArticleThumbnail(is, fileSize, contentType, originalThumbnailUrl);
}
} catch (Exception e) {
log.warn("썸네일 S3 업로드 실패, 원본 URL을 사용합니다. 사유: {}", e.getMessage());
throw new S3UploadException();
}
}
}
Loading