From 4722cff72221e341f3dc515c992ceabd15c93454 Mon Sep 17 00:00:00 2001 From: jeongchanmin Date: Fri, 24 Jan 2025 17:03:35 +0900 Subject: [PATCH 01/18] =?UTF-8?q?Feat=20:=20=ED=81=AC=EB=A1=A4=EB=A7=81=20?= =?UTF-8?q?=EB=9D=BC=EC=9D=B4=EB=B8=8C=EB=9F=AC=EB=A6=AC=20jsoup=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20=20(#28)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 3 +++ 1 file changed, 3 insertions(+) diff --git a/build.gradle b/build.gradle index f06ea67..e6f36c1 100644 --- a/build.gradle +++ b/build.gradle @@ -78,6 +78,9 @@ dependencies { annotationProcessor "jakarta.annotation:jakarta.annotation-api" annotationProcessor "jakarta.persistence:jakarta.persistence-api" + // jsoup + implementation 'org.jsoup:jsoup:1.18.3' + } From a6be5c6af0e78cd07e21eea38c147439dd8dc517 Mon Sep 17 00:00:00 2001 From: jeongchanmin Date: Fri, 24 Jan 2025 17:04:20 +0900 Subject: [PATCH 02/18] =?UTF-8?q?Feat=20:=20=EA=B0=81=20=EC=B9=B4=ED=85=8C?= =?UTF-8?q?=EA=B3=A0=EB=A6=AC=EB=B3=84=20=EB=89=B4=EC=8A=A4=20=ED=81=AC?= =?UTF-8?q?=EB=A1=A4=EB=9F=AC=20=EC=B6=94=EA=B0=80=20=20(#28)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ripple/BE/news/crawler/NewsCrawler.java | 68 +++++++++++++++++++ .../news/crawler/impl/FinanceNewsCrawler.java | 29 ++++++++ .../news/crawler/impl/GlobalNewsCrawler.java | 28 ++++++++ .../crawler/impl/IndustryNewsCrawler.java | 28 ++++++++ .../crawler/impl/InvestmentNewsCrawler.java | 28 ++++++++ .../news/crawler/impl/NormalNewsCrawler.java | 28 ++++++++ .../news/crawler/impl/OtherNewsCrawler.java | 28 ++++++++ .../crawler/impl/RealEstateNewsCrawler.java | 28 ++++++++ 8 files changed, 265 insertions(+) create mode 100644 src/main/java/com/ripple/BE/news/crawler/NewsCrawler.java create mode 100644 src/main/java/com/ripple/BE/news/crawler/impl/FinanceNewsCrawler.java create mode 100644 src/main/java/com/ripple/BE/news/crawler/impl/GlobalNewsCrawler.java create mode 100644 src/main/java/com/ripple/BE/news/crawler/impl/IndustryNewsCrawler.java create mode 100644 src/main/java/com/ripple/BE/news/crawler/impl/InvestmentNewsCrawler.java create mode 100644 src/main/java/com/ripple/BE/news/crawler/impl/NormalNewsCrawler.java create mode 100644 src/main/java/com/ripple/BE/news/crawler/impl/OtherNewsCrawler.java create mode 100644 src/main/java/com/ripple/BE/news/crawler/impl/RealEstateNewsCrawler.java diff --git a/src/main/java/com/ripple/BE/news/crawler/NewsCrawler.java b/src/main/java/com/ripple/BE/news/crawler/NewsCrawler.java new file mode 100644 index 0000000..115fd23 --- /dev/null +++ b/src/main/java/com/ripple/BE/news/crawler/NewsCrawler.java @@ -0,0 +1,68 @@ +package com.ripple.BE.news.crawler; + +import static com.ripple.BE.news.exception.errorcode.NewsErrorCode.*; + +import com.ripple.BE.news.domain.type.NewsCategory; +import com.ripple.BE.news.dto.NewsDTO; +import com.ripple.BE.news.exception.NewsException; +import java.util.ArrayList; +import java.util.List; +import lombok.extern.slf4j.Slf4j; +import org.jsoup.Jsoup; +import org.jsoup.nodes.Element; +import org.jsoup.select.Elements; + +@Slf4j +public abstract class NewsCrawler { + + protected static final String LIST_SELECTOR = "li.sa_item"; // 리스트 선택자 + protected static final String TITLE_SELECTOR = "strong.sa_text_strong"; // 제목 선택자 + protected static final String CONTENT_SELECTOR = "div.sa_text_lede"; // 내용 선택자 + protected static final String PUBLISH_SELECTOR = "div.sa_text_press"; // 언론사 선택자 + protected static final String CONTENT_URL_SELECTOR = "div.sa_text a"; // 본문 URL 선택자 + + /** 크롤링 기본 메서드 어제 날짜에 해당하는 데이터만 출력 */ + public List crawl() { + + log.info("Crawling started"); + + String baseUrl = getPageUrl(); // 크롤링할 URL + List newsList = new ArrayList<>(); + + try { + + Elements elementList = Jsoup.connect(baseUrl).get().select(LIST_SELECTOR); + if (elementList.isEmpty()) { + return newsList; + } + + for (Element element : elementList) { + String title = element.select(TITLE_SELECTOR).text(); + String content = element.select(CONTENT_SELECTOR).text(); + String publisher = element.select(PUBLISH_SELECTOR).text(); + String contentUrl = element.select(CONTENT_URL_SELECTOR).attr("href"); + + if (!title.isEmpty() && !content.isEmpty()) { + NewsDTO newsDTO = + NewsDTO.toNewsDTO(title, content, publisher, contentUrl, getNewsCategory()); + newsList.add(newsDTO); + } + } + + } catch (Exception e) { + throw new NewsException(NEWS_INTERNAL_SERVER_ERROR); + } + + return newsList; + } + + /** + * 크롤링할 페이지 URL 반환 + * + * @return 페이지 URL + */ + protected abstract String getPageUrl(); + + /** 카테고리 반환 */ + protected abstract NewsCategory getNewsCategory(); +} diff --git a/src/main/java/com/ripple/BE/news/crawler/impl/FinanceNewsCrawler.java b/src/main/java/com/ripple/BE/news/crawler/impl/FinanceNewsCrawler.java new file mode 100644 index 0000000..a428067 --- /dev/null +++ b/src/main/java/com/ripple/BE/news/crawler/impl/FinanceNewsCrawler.java @@ -0,0 +1,29 @@ +package com.ripple.BE.news.crawler.impl; + +import com.ripple.BE.news.crawler.NewsCrawler; +import com.ripple.BE.news.domain.type.NewsCategory; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +public class FinanceNewsCrawler extends NewsCrawler { + + protected static final String URL = "https://news.naver.com/breakingnews/section/101/259"; + + /** + * 페이지 URL + * + * @return 금융 페이지 URL + */ + @Override + public String getPageUrl() { + return URL; + } + + /** 카테고리 */ + @Override + public NewsCategory getNewsCategory() { + return NewsCategory.FINANCE; + } +} diff --git a/src/main/java/com/ripple/BE/news/crawler/impl/GlobalNewsCrawler.java b/src/main/java/com/ripple/BE/news/crawler/impl/GlobalNewsCrawler.java new file mode 100644 index 0000000..5a29e9f --- /dev/null +++ b/src/main/java/com/ripple/BE/news/crawler/impl/GlobalNewsCrawler.java @@ -0,0 +1,28 @@ +package com.ripple.BE.news.crawler.impl; + +import com.ripple.BE.news.crawler.NewsCrawler; +import com.ripple.BE.news.domain.type.NewsCategory; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +public class GlobalNewsCrawler extends NewsCrawler { + + protected static final String URL = "https://news.naver.com/breakingnews/section/101/262"; + + /** + * 페이지 URL + * + * @return 국제 경제 페이지 URL + */ + @Override + public String getPageUrl() { + return URL; + } + + @Override + public NewsCategory getNewsCategory() { + return NewsCategory.GLOBAL; + } +} diff --git a/src/main/java/com/ripple/BE/news/crawler/impl/IndustryNewsCrawler.java b/src/main/java/com/ripple/BE/news/crawler/impl/IndustryNewsCrawler.java new file mode 100644 index 0000000..e6d6b28 --- /dev/null +++ b/src/main/java/com/ripple/BE/news/crawler/impl/IndustryNewsCrawler.java @@ -0,0 +1,28 @@ +package com.ripple.BE.news.crawler.impl; + +import com.ripple.BE.news.crawler.NewsCrawler; +import com.ripple.BE.news.domain.type.NewsCategory; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +public class IndustryNewsCrawler extends NewsCrawler { + + protected static final String URL = "https://news.naver.com/breakingnews/section/101/261"; + + /** + * 페이지 URL + * + * @return 산업 페이지 URL + */ + @Override + public String getPageUrl() { + return URL; + } + + @Override + public NewsCategory getNewsCategory() { + return NewsCategory.INDUSTRY; + } +} diff --git a/src/main/java/com/ripple/BE/news/crawler/impl/InvestmentNewsCrawler.java b/src/main/java/com/ripple/BE/news/crawler/impl/InvestmentNewsCrawler.java new file mode 100644 index 0000000..3721656 --- /dev/null +++ b/src/main/java/com/ripple/BE/news/crawler/impl/InvestmentNewsCrawler.java @@ -0,0 +1,28 @@ +package com.ripple.BE.news.crawler.impl; + +import com.ripple.BE.news.crawler.NewsCrawler; +import com.ripple.BE.news.domain.type.NewsCategory; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +public class InvestmentNewsCrawler extends NewsCrawler { + + protected static final String URL = "https://news.naver.com/breakingnews/section/101/258"; + + /** + * 페이지 URL + * + * @return 투자 페이지 URL + */ + @Override + public String getPageUrl() { + return URL; + } + + @Override + public NewsCategory getNewsCategory() { + return NewsCategory.INVESTMENT; + } +} diff --git a/src/main/java/com/ripple/BE/news/crawler/impl/NormalNewsCrawler.java b/src/main/java/com/ripple/BE/news/crawler/impl/NormalNewsCrawler.java new file mode 100644 index 0000000..6d6a5fa --- /dev/null +++ b/src/main/java/com/ripple/BE/news/crawler/impl/NormalNewsCrawler.java @@ -0,0 +1,28 @@ +package com.ripple.BE.news.crawler.impl; + +import com.ripple.BE.news.crawler.NewsCrawler; +import com.ripple.BE.news.domain.type.NewsCategory; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +public class NormalNewsCrawler extends NewsCrawler { + + protected static final String URL = "https://news.naver.com/breakingnews/section/101/263"; + + /** + * 페이지 URL + * + * @return 일반 경제 페이지 URL + */ + @Override + public String getPageUrl() { + return URL; + } + + @Override + public NewsCategory getNewsCategory() { + return NewsCategory.NORMAL; + } +} diff --git a/src/main/java/com/ripple/BE/news/crawler/impl/OtherNewsCrawler.java b/src/main/java/com/ripple/BE/news/crawler/impl/OtherNewsCrawler.java new file mode 100644 index 0000000..588f8e3 --- /dev/null +++ b/src/main/java/com/ripple/BE/news/crawler/impl/OtherNewsCrawler.java @@ -0,0 +1,28 @@ +package com.ripple.BE.news.crawler.impl; + +import com.ripple.BE.news.crawler.NewsCrawler; +import com.ripple.BE.news.domain.type.NewsCategory; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +public class OtherNewsCrawler extends NewsCrawler { + + protected static final String URL = "https://news.naver.com/breakingnews/section/101/310"; + + /** + * 페이지 URL + * + * @return 기타 페이지 URL + */ + @Override + public String getPageUrl() { + return URL; + } + + @Override + public NewsCategory getNewsCategory() { + return NewsCategory.OTHER; + } +} diff --git a/src/main/java/com/ripple/BE/news/crawler/impl/RealEstateNewsCrawler.java b/src/main/java/com/ripple/BE/news/crawler/impl/RealEstateNewsCrawler.java new file mode 100644 index 0000000..d12493e --- /dev/null +++ b/src/main/java/com/ripple/BE/news/crawler/impl/RealEstateNewsCrawler.java @@ -0,0 +1,28 @@ +package com.ripple.BE.news.crawler.impl; + +import com.ripple.BE.news.crawler.NewsCrawler; +import com.ripple.BE.news.domain.type.NewsCategory; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +public class RealEstateNewsCrawler extends NewsCrawler { + + protected static final String URL = "https://news.naver.com/breakingnews/section/101/260"; + + /** + * 페이지 URL + * + * @return 부동산 페이지 URL + */ + @Override + public String getPageUrl() { + return URL; + } + + @Override + public NewsCategory getNewsCategory() { + return NewsCategory.REAL_ESTATE; + } +} From b28ac18793b9f9175214b4439a573e62977433af Mon Sep 17 00:00:00 2001 From: jeongchanmin Date: Fri, 24 Jan 2025 17:04:49 +0900 Subject: [PATCH 03/18] =?UTF-8?q?Feat=20:=20=EB=89=B4=EC=8A=A4=20=EC=98=88?= =?UTF-8?q?=EC=99=B8=20=EC=84=A4=EC=A0=95=20=EC=B6=94=EA=B0=80=20=20(#28)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../handler/GlobalExceptionHandler.java | 6 ++++++ .../BE/news/exception/NewsException.java | 12 ++++++++++++ .../exception/errorcode/NewsErrorCode.java | 18 ++++++++++++++++++ 3 files changed, 36 insertions(+) create mode 100644 src/main/java/com/ripple/BE/news/exception/NewsException.java create mode 100644 src/main/java/com/ripple/BE/news/exception/errorcode/NewsErrorCode.java diff --git a/src/main/java/com/ripple/BE/global/exception/handler/GlobalExceptionHandler.java b/src/main/java/com/ripple/BE/global/exception/handler/GlobalExceptionHandler.java index 0985827..8e73dc7 100644 --- a/src/main/java/com/ripple/BE/global/exception/handler/GlobalExceptionHandler.java +++ b/src/main/java/com/ripple/BE/global/exception/handler/GlobalExceptionHandler.java @@ -7,6 +7,7 @@ import com.ripple.BE.global.exception.response.ErrorResponse.ValidationErrors; import com.ripple.BE.learning.exception.LearningException; import com.ripple.BE.learning.exception.QuizException; +import com.ripple.BE.news.exception.NewsException; import com.ripple.BE.post.exception.PostException; import com.ripple.BE.user.exception.UserException; import io.micrometer.common.lang.NonNull; @@ -86,6 +87,11 @@ public ResponseEntity handlePostException(final PostException e) { return handleExceptionInternal(e.getErrorCode()); } + @ExceptionHandler(NewsException.class) + public ResponseEntity handleNewsException(final NewsException e) { + return handleExceptionInternal(e.getErrorCode()); + } + /** * 예외 처리 결과를 생성하는 내부 메서드 * diff --git a/src/main/java/com/ripple/BE/news/exception/NewsException.java b/src/main/java/com/ripple/BE/news/exception/NewsException.java new file mode 100644 index 0000000..7849422 --- /dev/null +++ b/src/main/java/com/ripple/BE/news/exception/NewsException.java @@ -0,0 +1,12 @@ +package com.ripple.BE.news.exception; + +import com.ripple.BE.global.exception.errorcode.ErrorCode; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public class NewsException extends RuntimeException { + + private final ErrorCode errorCode; +} diff --git a/src/main/java/com/ripple/BE/news/exception/errorcode/NewsErrorCode.java b/src/main/java/com/ripple/BE/news/exception/errorcode/NewsErrorCode.java new file mode 100644 index 0000000..0344edc --- /dev/null +++ b/src/main/java/com/ripple/BE/news/exception/errorcode/NewsErrorCode.java @@ -0,0 +1,18 @@ +package com.ripple.BE.news.exception.errorcode; + +import com.ripple.BE.global.exception.errorcode.ErrorCode; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; + +@Getter +@RequiredArgsConstructor +public enum NewsErrorCode implements ErrorCode { + NEWS_NOT_FOUND(HttpStatus.NOT_FOUND, "News not found"), + NEWS_SCRAP_NOT_FOUND(HttpStatus.NOT_FOUND, "News scrap not found"), + NEWS_INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "Internal server error"), + ; + + private final HttpStatus httpStatus; + private final String message; +} From acc94793da3b79d54ecd9a2ff4a9742cf9214363 Mon Sep 17 00:00:00 2001 From: jeongchanmin Date: Fri, 24 Jan 2025 17:05:19 +0900 Subject: [PATCH 04/18] =?UTF-8?q?Refactor=20:=20=EB=89=B4=EC=8A=A4=20?= =?UTF-8?q?=EC=B9=B4=ED=85=8C=EA=B3=A0=EB=A6=AC=20enum=EC=9C=BC=EB=A1=9C?= =?UTF-8?q?=20=EB=B3=80=EA=B2=BD=20=20(#28)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ripple/BE/news/domain/NewsCategory.java | 40 ------------------- .../BE/news/domain/type/NewsCategory.java | 20 ++++++++++ 2 files changed, 20 insertions(+), 40 deletions(-) delete mode 100644 src/main/java/com/ripple/BE/news/domain/NewsCategory.java create mode 100644 src/main/java/com/ripple/BE/news/domain/type/NewsCategory.java diff --git a/src/main/java/com/ripple/BE/news/domain/NewsCategory.java b/src/main/java/com/ripple/BE/news/domain/NewsCategory.java deleted file mode 100644 index 485f5b7..0000000 --- a/src/main/java/com/ripple/BE/news/domain/NewsCategory.java +++ /dev/null @@ -1,40 +0,0 @@ -package com.ripple.BE.news.domain; - -import com.ripple.BE.category.domain.Category; -import com.ripple.BE.global.entity.BaseEntity; -import jakarta.persistence.Column; -import jakarta.persistence.Entity; -import jakarta.persistence.FetchType; -import jakarta.persistence.GeneratedValue; -import jakarta.persistence.GenerationType; -import jakarta.persistence.Id; -import jakarta.persistence.JoinColumn; -import jakarta.persistence.ManyToOne; -import jakarta.persistence.Table; -import lombok.AccessLevel; -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Getter; -import lombok.NoArgsConstructor; - -@Table(name = "news_categories") -@Getter -@Builder -@Entity -@NoArgsConstructor(access = AccessLevel.PROTECTED) -@AllArgsConstructor -public class NewsCategory extends BaseEntity { - - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - @Column(name = "id", nullable = false) - private Long id; - - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "category_id") - private Category category; - - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "news_id") - private News news; -} diff --git a/src/main/java/com/ripple/BE/news/domain/type/NewsCategory.java b/src/main/java/com/ripple/BE/news/domain/type/NewsCategory.java new file mode 100644 index 0000000..4400daf --- /dev/null +++ b/src/main/java/com/ripple/BE/news/domain/type/NewsCategory.java @@ -0,0 +1,20 @@ +package com.ripple.BE.news.domain.type; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public enum NewsCategory { + FINANCE("금융"), + INVESTMENT("투자"), + NORMAL("경제 일반"), + GLOBAL("국제 경제"), + INDUSTRY("산업"), + REAL_ESTATE("부동산"), + ECONOMIC_ANALYSIS("경기 분석"), + ECONOMIC_POLICY("경제 정책"), + OTHER("기타"); + + private final String newsCategory; +} From 703683c0b485d39ce98330c1e9f29208dd0fd78a Mon Sep 17 00:00:00 2001 From: jeongchanmin Date: Fri, 24 Jan 2025 17:05:45 +0900 Subject: [PATCH 05/18] =?UTF-8?q?Feat=20:=20=EB=89=B4=EC=8A=A4=20=EC=8A=A4?= =?UTF-8?q?=ED=81=AC=EB=9E=A9=20=EC=A0=80=EC=9E=A5=20=EA=B8=B0=EB=8A=A5=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20=20(#28)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/ripple/BE/news/domain/NewsScrap.java | 11 +++++++++++ .../repository/newscrap/NewsScrapRepository.java | 12 ++++++++++++ 2 files changed, 23 insertions(+) create mode 100644 src/main/java/com/ripple/BE/news/repository/newscrap/NewsScrapRepository.java diff --git a/src/main/java/com/ripple/BE/news/domain/NewsScrap.java b/src/main/java/com/ripple/BE/news/domain/NewsScrap.java index 2618912..d7aadd5 100644 --- a/src/main/java/com/ripple/BE/news/domain/NewsScrap.java +++ b/src/main/java/com/ripple/BE/news/domain/NewsScrap.java @@ -16,6 +16,7 @@ import lombok.Builder; import lombok.Getter; import lombok.NoArgsConstructor; +import lombok.Setter; @Table(name = "news_scraps") @Getter @@ -34,7 +35,17 @@ public class NewsScrap extends BaseEntity { @JoinColumn(name = "user_id", nullable = false) private User user; + @Setter @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "news_id") private News news; + + public static NewsScrap toNewsScrapEntity() { + return NewsScrap.builder().build(); + } + + public void setUser(User user) { + this.user = user; + user.getNewsScrapList().add(this); + } } diff --git a/src/main/java/com/ripple/BE/news/repository/newscrap/NewsScrapRepository.java b/src/main/java/com/ripple/BE/news/repository/newscrap/NewsScrapRepository.java new file mode 100644 index 0000000..4daee03 --- /dev/null +++ b/src/main/java/com/ripple/BE/news/repository/newscrap/NewsScrapRepository.java @@ -0,0 +1,12 @@ +package com.ripple.BE.news.repository.newscrap; + +import com.ripple.BE.news.domain.NewsScrap; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface NewsScrapRepository extends JpaRepository { + + Optional findByNewsIdAndUserId(long newsId, long userId); + + boolean existsByNewsIdAndUserId(long newsId, long userId); +} From 2f21bf6e452c97a525dcaf9d41717c88dee4992f Mon Sep 17 00:00:00 2001 From: jeongchanmin Date: Fri, 24 Jan 2025 17:07:03 +0900 Subject: [PATCH 06/18] =?UTF-8?q?Feat=20:=20=EB=89=B4=EC=8A=A4=20=EB=A6=AC?= =?UTF-8?q?=ED=8F=AC=EC=A7=80=ED=84=B0=EB=A6=AC=20=ED=95=A8=EC=88=98=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20=20(#28)=20-=20=EC=B9=B4=ED=85=8C=EA=B3=A0?= =?UTF-8?q?=EB=A6=AC=EB=B3=84=20=EC=A1=B0=ED=9A=8C=20-=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=EC=88=98=20=EB=B3=84=20=EC=A1=B0=ED=9A=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../news/repository/news/NewsRepository.java | 17 +++++ .../repository/news/NewsRepositoryCustom.java | 14 ++++ .../news/NewsRepositoryCustomImpl.java | 66 +++++++++++++++++++ 3 files changed, 97 insertions(+) create mode 100644 src/main/java/com/ripple/BE/news/repository/news/NewsRepository.java create mode 100644 src/main/java/com/ripple/BE/news/repository/news/NewsRepositoryCustom.java create mode 100644 src/main/java/com/ripple/BE/news/repository/news/NewsRepositoryCustomImpl.java diff --git a/src/main/java/com/ripple/BE/news/repository/news/NewsRepository.java b/src/main/java/com/ripple/BE/news/repository/news/NewsRepository.java new file mode 100644 index 0000000..599896a --- /dev/null +++ b/src/main/java/com/ripple/BE/news/repository/news/NewsRepository.java @@ -0,0 +1,17 @@ +package com.ripple.BE.news.repository.news; + +import com.ripple.BE.news.domain.News; +import jakarta.persistence.LockModeType; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; +import org.springframework.data.jpa.repository.Query; +import org.springframework.stereotype.Repository; + +@Repository +public interface NewsRepository extends JpaRepository, NewsRepositoryCustom { + + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("SELECT n FROM News n WHERE n.id = :id") + Optional findByIdForUpdate(long id); +} diff --git a/src/main/java/com/ripple/BE/news/repository/news/NewsRepositoryCustom.java b/src/main/java/com/ripple/BE/news/repository/news/NewsRepositoryCustom.java new file mode 100644 index 0000000..2ba64a5 --- /dev/null +++ b/src/main/java/com/ripple/BE/news/repository/news/NewsRepositoryCustom.java @@ -0,0 +1,14 @@ +package com.ripple.BE.news.repository.news; + +import com.ripple.BE.news.domain.News; +import com.ripple.BE.news.domain.type.NewsCategory; +import com.ripple.BE.news.domain.type.NewsSort; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +public interface NewsRepositoryCustom { + + Page findByCategory(NewsCategory category, NewsSort newsSort, Pageable pageable); + + Page findAll(Pageable pageable, NewsSort newsSort); +} diff --git a/src/main/java/com/ripple/BE/news/repository/news/NewsRepositoryCustomImpl.java b/src/main/java/com/ripple/BE/news/repository/news/NewsRepositoryCustomImpl.java new file mode 100644 index 0000000..31bb35a --- /dev/null +++ b/src/main/java/com/ripple/BE/news/repository/news/NewsRepositoryCustomImpl.java @@ -0,0 +1,66 @@ +package com.ripple.BE.news.repository.news; + +import static com.ripple.BE.news.domain.QNews.*; + +import com.querydsl.core.types.dsl.BooleanExpression; +import com.querydsl.jpa.impl.JPAQuery; +import com.querydsl.jpa.impl.JPAQueryFactory; +import com.ripple.BE.news.domain.News; +import com.ripple.BE.news.domain.type.NewsCategory; +import com.ripple.BE.news.domain.type.NewsSort; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.support.PageableExecutionUtils; + +@RequiredArgsConstructor +public class NewsRepositoryCustomImpl implements NewsRepositoryCustom { + + private final JPAQueryFactory queryFactory; + + @Override + public Page findByCategory(NewsCategory category, NewsSort newsSort, Pageable pageable) { + BooleanExpression predicate = news.category.eq(category); + + List newsList = getNewsByPageable(pageable, predicate, newsSort); + + JPAQuery countQuery = queryFactory.select(news.count()).from(news).where(predicate); + + return PageableExecutionUtils.getPage(newsList, pageable, countQuery::fetchOne); + } + + @Override + public Page findAll(Pageable pageable, NewsSort newsSort) { + + List newsList = getNewsByPageable(pageable, null, newsSort); + + JPAQuery countQuery = queryFactory.select(news.count()).from(news); + + return PageableExecutionUtils.getPage(newsList, pageable, countQuery::fetchOne); + } + + private List getNewsByPageable( + Pageable pageable, BooleanExpression predicate, NewsSort newsSort) { + if (newsSort == NewsSort.POPULAR) { + return queryFactory + .selectFrom(news) + .where(predicate) + .orderBy( + news.views.desc(), // 조회수 내림차순 + news.createdDate.desc() // 생성일 내림차순 + ) + .offset(pageable.getOffset()) + .limit(pageable.getPageSize()) + .fetch(); + } else { + return queryFactory + .selectFrom(news) + .where(predicate) + .orderBy(news.createdDate.desc()) // 생성일 내림차순 + .offset(pageable.getOffset()) + .limit(pageable.getPageSize()) + .fetch(); + } + } +} From c485920faf9f473f7b6fb1d306fb10ec319eea9d Mon Sep 17 00:00:00 2001 From: jeongchanmin Date: Fri, 24 Jan 2025 17:07:31 +0900 Subject: [PATCH 07/18] =?UTF-8?q?Feat=20:=20=EB=89=B4=EC=8A=A4=20DTO=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20=20(#28)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/ripple/BE/news/dto/NewsDTO.java | 40 +++++++++++++++++++ .../com/ripple/BE/news/dto/NewsListDTO.java | 15 +++++++ 2 files changed, 55 insertions(+) create mode 100644 src/main/java/com/ripple/BE/news/dto/NewsDTO.java create mode 100644 src/main/java/com/ripple/BE/news/dto/NewsListDTO.java diff --git a/src/main/java/com/ripple/BE/news/dto/NewsDTO.java b/src/main/java/com/ripple/BE/news/dto/NewsDTO.java new file mode 100644 index 0000000..e66f9e2 --- /dev/null +++ b/src/main/java/com/ripple/BE/news/dto/NewsDTO.java @@ -0,0 +1,40 @@ +package com.ripple.BE.news.dto; + +import com.ripple.BE.image.dto.ImageListDTO; +import com.ripple.BE.news.domain.News; +import com.ripple.BE.news.domain.type.NewsCategory; +import java.time.LocalDateTime; + +public record NewsDTO( + Long id, + String title, + String content, + String publisher, + String url, + Long views, + NewsCategory category, + LocalDateTime createdDate, + ImageListDTO imageList) { + + public static NewsDTO toNewsDTO(final News news) { + return new NewsDTO( + news.getId(), + news.getTitle(), + news.getContent(), + news.getPublisher(), + news.getUrl(), + news.getViews(), + news.getCategory(), + news.getCreatedDate(), + ImageListDTO.toImageListDTO(news.getImageList())); + } + + public static NewsDTO toNewsDTO( + final String title, + final String content, + final String publisher, + final String url, + final NewsCategory category) { + return new NewsDTO(null, title, content, publisher, url, null, category, null, null); + } +} diff --git a/src/main/java/com/ripple/BE/news/dto/NewsListDTO.java b/src/main/java/com/ripple/BE/news/dto/NewsListDTO.java new file mode 100644 index 0000000..adeb364 --- /dev/null +++ b/src/main/java/com/ripple/BE/news/dto/NewsListDTO.java @@ -0,0 +1,15 @@ +package com.ripple.BE.news.dto; + +import com.ripple.BE.news.domain.News; +import java.util.List; +import org.springframework.data.domain.Page; + +public record NewsListDTO(List newsDTOList, int totalPage, int currentPage) { + + public static NewsListDTO toNewsListDTO(Page newsPage) { + return new NewsListDTO( + newsPage.getContent().stream().map(NewsDTO::toNewsDTO).toList(), + newsPage.getTotalPages(), + newsPage.getNumber()); + } +} From 03932c41f8bc12629a405efc8511e91b5c7fc49b Mon Sep 17 00:00:00 2001 From: jeongchanmin Date: Fri, 24 Jan 2025 17:07:41 +0900 Subject: [PATCH 08/18] =?UTF-8?q?Feat=20:=20=EB=89=B4=EC=8A=A4=20Response?= =?UTF-8?q?=20DTO=20=EC=B6=94=EA=B0=80=20=20(#28)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../news/dto/response/NewsListResponse.java | 14 ++++++++++ .../BE/news/dto/response/NewsResponse.java | 27 +++++++++++++++++++ 2 files changed, 41 insertions(+) create mode 100644 src/main/java/com/ripple/BE/news/dto/response/NewsListResponse.java create mode 100644 src/main/java/com/ripple/BE/news/dto/response/NewsResponse.java diff --git a/src/main/java/com/ripple/BE/news/dto/response/NewsListResponse.java b/src/main/java/com/ripple/BE/news/dto/response/NewsListResponse.java new file mode 100644 index 0000000..6894ec7 --- /dev/null +++ b/src/main/java/com/ripple/BE/news/dto/response/NewsListResponse.java @@ -0,0 +1,14 @@ +package com.ripple.BE.news.dto.response; + +import com.ripple.BE.news.dto.NewsListDTO; +import java.util.List; + +public record NewsListResponse(List newsList, int totalPage, int currentPage) { + + public static NewsListResponse toNewsListResponse(NewsListDTO newsListDTO) { + return new NewsListResponse( + newsListDTO.newsDTOList().stream().map(NewsResponse::toNewsResponse).toList(), + newsListDTO.totalPage(), + newsListDTO.currentPage()); + } +} diff --git a/src/main/java/com/ripple/BE/news/dto/response/NewsResponse.java b/src/main/java/com/ripple/BE/news/dto/response/NewsResponse.java new file mode 100644 index 0000000..c7bdbfa --- /dev/null +++ b/src/main/java/com/ripple/BE/news/dto/response/NewsResponse.java @@ -0,0 +1,27 @@ +package com.ripple.BE.news.dto.response; + +import com.ripple.BE.global.utils.RelativeTimeFormatter; +import com.ripple.BE.news.dto.NewsDTO; + +public record NewsResponse( + Long id, + String title, + String content, + String publisher, + long views, + String url, + String category, + String createdDate) { + + public static NewsResponse toNewsResponse(NewsDTO newDTO) { + return new NewsResponse( + newDTO.id(), + newDTO.title(), + newDTO.content(), + newDTO.publisher(), + newDTO.views(), + newDTO.url(), + newDTO.category().toString(), + RelativeTimeFormatter.formatRelativeTime(newDTO.createdDate())); + } +} From e12e6a05169a0fea525c47647f221429f4e3bdc1 Mon Sep 17 00:00:00 2001 From: jeongchanmin Date: Fri, 24 Jan 2025 17:08:00 +0900 Subject: [PATCH 09/18] =?UTF-8?q?Feat=20:=20=EB=89=B4=EC=8A=A4=20=EB=B0=B0?= =?UTF-8?q?=EC=B9=98=20insert=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80=20?= =?UTF-8?q?=20(#28)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../repository/news/NewsJdbcRepository.java | 52 +++++++++++++++++++ 1 file changed, 52 insertions(+) create mode 100644 src/main/java/com/ripple/BE/news/repository/news/NewsJdbcRepository.java diff --git a/src/main/java/com/ripple/BE/news/repository/news/NewsJdbcRepository.java b/src/main/java/com/ripple/BE/news/repository/news/NewsJdbcRepository.java new file mode 100644 index 0000000..42c5c7e --- /dev/null +++ b/src/main/java/com/ripple/BE/news/repository/news/NewsJdbcRepository.java @@ -0,0 +1,52 @@ +package com.ripple.BE.news.repository.news; + +import com.ripple.BE.news.domain.News; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.namedparam.MapSqlParameterSource; +import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate; +import org.springframework.stereotype.Repository; +import org.springframework.transaction.annotation.Transactional; + +@Repository +@RequiredArgsConstructor +public class NewsJdbcRepository { + + private final JdbcTemplate jdbcTemplate; + private final NamedParameterJdbcTemplate namedParameterJdbcTemplate; + + private static final int BATCH_SIZE = 1000; + + @Transactional + public void saveAllNewsByJdbcTemplate(List newsList) { + + String insertQuery = + "INSERT INTO news (title, content, publisher, url, category, views, created_date, modified_date) " + + "VALUES (?, ?, ?, ?, ?, ?, NOW(), NOW())"; + + jdbcTemplate.batchUpdate( + insertQuery, + newsList, + BATCH_SIZE, + (ps, news) -> { + ps.setString(1, news.getTitle()); + ps.setString(2, news.getContent()); + ps.setString(3, news.getPublisher()); + ps.setString(4, news.getUrl()); + ps.setString(5, news.getCategory().toString()); + ps.setLong(6, 0L); + }); + } + + public List findExistingUrls(List urls) { + if (urls.isEmpty()) { + return List.of(); + } + + String query = "SELECT url FROM news WHERE url IN (:urls)"; + MapSqlParameterSource params = new MapSqlParameterSource("urls", urls); + + return namedParameterJdbcTemplate.query(query, params, (rs, rowNum) -> rs.getString("url")); + } +} From a56535fb886b62fa50f3eb02e5fce0eb17aa6e50 Mon Sep 17 00:00:00 2001 From: jeongchanmin Date: Fri, 24 Jan 2025 17:08:11 +0900 Subject: [PATCH 10/18] =?UTF-8?q?Feat=20:=20=EB=89=B4=EC=8A=A4=20Sort=20EN?= =?UTF-8?q?UM=20=EC=B6=94=EA=B0=80=20=20(#28)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/ripple/BE/news/domain/type/NewsSort.java | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 src/main/java/com/ripple/BE/news/domain/type/NewsSort.java diff --git a/src/main/java/com/ripple/BE/news/domain/type/NewsSort.java b/src/main/java/com/ripple/BE/news/domain/type/NewsSort.java new file mode 100644 index 0000000..d9d4f5d --- /dev/null +++ b/src/main/java/com/ripple/BE/news/domain/type/NewsSort.java @@ -0,0 +1,13 @@ +package com.ripple.BE.news.domain.type; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public enum NewsSort { + RECENT("최신순"), + POPULAR("인기순"); + + private final String newsSort; +} From 670c7ecd0c94bf1bbf2531d534055129f5260b25 Mon Sep 17 00:00:00 2001 From: jeongchanmin Date: Fri, 24 Jan 2025 17:08:27 +0900 Subject: [PATCH 11/18] =?UTF-8?q?Chore=20:=20NewsTerm=20=EC=97=94=ED=84=B0?= =?UTF-8?q?=ED=8B=B0=20=EC=82=AD=EC=A0=9C=20=20(#28)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/ripple/BE/news/domain/NewsTerm.java | 46 ------------------- 1 file changed, 46 deletions(-) delete mode 100644 src/main/java/com/ripple/BE/news/domain/NewsTerm.java diff --git a/src/main/java/com/ripple/BE/news/domain/NewsTerm.java b/src/main/java/com/ripple/BE/news/domain/NewsTerm.java deleted file mode 100644 index c02f982..0000000 --- a/src/main/java/com/ripple/BE/news/domain/NewsTerm.java +++ /dev/null @@ -1,46 +0,0 @@ -package com.ripple.BE.news.domain; - -import com.ripple.BE.global.entity.BaseEntity; -import com.ripple.BE.term.domain.Term; -import jakarta.persistence.Column; -import jakarta.persistence.Entity; -import jakarta.persistence.FetchType; -import jakarta.persistence.GeneratedValue; -import jakarta.persistence.GenerationType; -import jakarta.persistence.Id; -import jakarta.persistence.JoinColumn; -import jakarta.persistence.ManyToOne; -import jakarta.persistence.Table; -import lombok.AccessLevel; -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Getter; -import lombok.NoArgsConstructor; - -@Table(name = "news_term") -@Getter -@Builder -@Entity -@NoArgsConstructor(access = AccessLevel.PROTECTED) -@AllArgsConstructor -public class NewsTerm extends BaseEntity { - - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - @Column(name = "id", nullable = false) - private Long id; - - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "term_id") - private Term term; - - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "news_id") - private News news; - - @Column(name = "start_position", nullable = false) - private int startPosition; // 용어의 뉴스 내 시작 위치, 0부터 시작 - - @Column(name = "end_position", nullable = false) - private int endPosition; // 용어의 뉴스 내 끝 위치, 뉴스 길이보다 작아야 함 -} From 2bba51af637256161db76893fb84620dae566f95 Mon Sep 17 00:00:00 2001 From: jeongchanmin Date: Fri, 24 Jan 2025 17:09:01 +0900 Subject: [PATCH 12/18] =?UTF-8?q?Chore=20:=20News=20=EC=97=94=ED=84=B0?= =?UTF-8?q?=ED=8B=B0=20=EC=88=98=EC=A0=95=20=20(#28)=20-=20url,=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=EC=88=98=20=ED=95=84=EB=93=9C=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20-=20=EC=97=94=ED=84=B0=ED=8B=B0=20=EB=B3=80?= =?UTF-8?q?=ED=99=98=20=EB=A9=94=EC=86=8C=EB=93=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/ripple/BE/news/domain/News.java | 35 +++++++++++++++---- 1 file changed, 28 insertions(+), 7 deletions(-) diff --git a/src/main/java/com/ripple/BE/news/domain/News.java b/src/main/java/com/ripple/BE/news/domain/News.java index 354a469..0c0a632 100644 --- a/src/main/java/com/ripple/BE/news/domain/News.java +++ b/src/main/java/com/ripple/BE/news/domain/News.java @@ -2,15 +2,18 @@ import com.ripple.BE.global.entity.BaseEntity; import com.ripple.BE.image.domain.Image; +import com.ripple.BE.news.domain.type.NewsCategory; +import com.ripple.BE.news.dto.NewsDTO; import jakarta.persistence.CascadeType; import jakarta.persistence.Column; import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; import jakarta.persistence.Id; import jakarta.persistence.OneToMany; import jakarta.persistence.Table; -import jakarta.validation.constraints.Size; import java.util.ArrayList; import java.util.List; import lombok.AccessLevel; @@ -32,7 +35,6 @@ public class News extends BaseEntity { @Column(name = "id", nullable = false) private Long id; - @Size(min = 2, max = 50) @Column(name = "title", nullable = false) private String title; @@ -40,14 +42,33 @@ public class News extends BaseEntity { private String content; @Column(name = "publisher") - private String publisher; // 출판사 + private String publisher; - @OneToMany(mappedBy = "news", cascade = CascadeType.ALL, orphanRemoval = true) - private List newsTermList = new ArrayList<>(); + @Column(name = "views") + private long views = 0L; - @OneToMany(mappedBy = "news", cascade = CascadeType.ALL, orphanRemoval = true) - private List newsCategoryList = new ArrayList<>(); + @Column(name = "url") + private String url; + + @Enumerated(EnumType.STRING) + @Column(name = "category", nullable = false) + private NewsCategory category; @OneToMany(mappedBy = "news", cascade = CascadeType.ALL, orphanRemoval = true) private List imageList = new ArrayList<>(); + + public static News toNewsEntity(NewsDTO newsDTO) { + return News.builder() + .title(newsDTO.title()) + .content(newsDTO.content()) + .publisher(newsDTO.publisher()) + .views(0L) + .url(newsDTO.url()) + .category(newsDTO.category()) + .build(); + } + + public void increaseViews() { + this.views++; + } } From 732c374a22395f112e560f3c26b356671c973763 Mon Sep 17 00:00:00 2001 From: jeongchanmin Date: Fri, 24 Jan 2025 17:09:44 +0900 Subject: [PATCH 13/18] =?UTF-8?q?Feat=20:=20NewsService=20=EB=B0=8F=20api?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80=20=20(#28)=20-=20=EB=89=B4=EC=8A=A4=20?= =?UTF-8?q?=EB=AA=A9=EB=A1=9D=20=EC=A1=B0=ED=9A=8C=20-=20=EB=89=B4?= =?UTF-8?q?=EC=8A=A4=20=EC=83=81=EC=84=B8=20=EC=A1=B0=ED=9A=8C=20-=20?= =?UTF-8?q?=EB=89=B4=EC=8A=A4=20=EC=8A=A4=ED=81=AC=EB=9E=A9=20=EB=B0=8F=20?= =?UTF-8?q?=EC=B7=A8=EC=86=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../BE/news/controller/NewsController.java | 75 +++++++++++ .../ripple/BE/news/service/NewsService.java | 127 ++++++++++++++++++ 2 files changed, 202 insertions(+) create mode 100644 src/main/java/com/ripple/BE/news/controller/NewsController.java create mode 100644 src/main/java/com/ripple/BE/news/service/NewsService.java diff --git a/src/main/java/com/ripple/BE/news/controller/NewsController.java b/src/main/java/com/ripple/BE/news/controller/NewsController.java new file mode 100644 index 0000000..08a74c6 --- /dev/null +++ b/src/main/java/com/ripple/BE/news/controller/NewsController.java @@ -0,0 +1,75 @@ +package com.ripple.BE.news.controller; + +import com.ripple.BE.global.dto.response.ApiResponse; +import com.ripple.BE.news.domain.type.NewsCategory; +import com.ripple.BE.news.domain.type.NewsSort; +import com.ripple.BE.news.dto.NewsDTO; +import com.ripple.BE.news.dto.NewsListDTO; +import com.ripple.BE.news.dto.response.NewsListResponse; +import com.ripple.BE.news.dto.response.NewsResponse; +import com.ripple.BE.news.service.NewsService; +import com.ripple.BE.user.domain.CustomUserDetails; +import io.swagger.v3.oas.annotations.Operation; +import jakarta.validation.constraints.PositiveOrZero; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/posts") +@RequiredArgsConstructor +public class NewsController { + + private final NewsService newsService; + + @Operation(summary = "뉴스 목록 조회", description = "뉴스 목록을 조회합니다.") + @GetMapping + public ResponseEntity> getNewsList( + final @RequestParam(required = false, defaultValue = "0") @PositiveOrZero int page, + final @RequestParam(required = false, defaultValue = "RECENT") NewsSort sort, + final @RequestParam(required = false) NewsCategory category) { + + NewsListDTO newsListDTO = newsService.getNewsList(page, sort, category); + + return ResponseEntity.status(HttpStatus.OK) + .body(ApiResponse.from(NewsListResponse.toNewsListResponse(newsListDTO))); + } + + @Operation(summary = "뉴스 상세 조회", description = "뉴스의 상세 정보를 조회합니다.") + @GetMapping("/{id}") + public ResponseEntity> getNews(final @PathVariable("id") long id) { + + NewsDTO newsDTO = newsService.getNews(id); + return ResponseEntity.status(HttpStatus.OK) + .body(ApiResponse.from(NewsResponse.toNewsResponse(newsDTO))); + } + + @Operation(summary = "뉴스 스크랩", description = "뉴스를 스크랩합니다.") + @PostMapping("/{id}/scrap") + public ResponseEntity> scrapNews( + final @AuthenticationPrincipal CustomUserDetails currentUser, + final @PathVariable("id") long id) { + + newsService.addScrapToNews(id, currentUser.getId()); + return ResponseEntity.status(HttpStatus.OK).body(ApiResponse.from(ApiResponse.EMPTY_RESPONSE)); + } + + @Operation(summary = "뉴스 스크랩 취소", description = "뉴스 스크랩을 취소합니다.") + @DeleteMapping("/{id}/scrap") + public ResponseEntity> unscrapPost( + final @AuthenticationPrincipal CustomUserDetails currentUser, + final @PathVariable("id") long id) { + + newsService.removeScrapFromNews(id, currentUser.getId()); + + return ResponseEntity.status(HttpStatus.OK).body(ApiResponse.from(ApiResponse.EMPTY_RESPONSE)); + } +} diff --git a/src/main/java/com/ripple/BE/news/service/NewsService.java b/src/main/java/com/ripple/BE/news/service/NewsService.java new file mode 100644 index 0000000..c93661e --- /dev/null +++ b/src/main/java/com/ripple/BE/news/service/NewsService.java @@ -0,0 +1,127 @@ +package com.ripple.BE.news.service; + +import static com.ripple.BE.news.exception.errorcode.NewsErrorCode.*; +import static com.ripple.BE.post.exception.errorcode.PostErrorCode.*; + +import com.ripple.BE.news.crawler.NewsCrawler; +import com.ripple.BE.news.domain.News; +import com.ripple.BE.news.domain.NewsScrap; +import com.ripple.BE.news.domain.type.NewsCategory; +import com.ripple.BE.news.domain.type.NewsSort; +import com.ripple.BE.news.dto.NewsDTO; +import com.ripple.BE.news.dto.NewsListDTO; +import com.ripple.BE.news.exception.NewsException; +import com.ripple.BE.news.repository.news.NewsJdbcRepository; +import com.ripple.BE.news.repository.news.NewsRepository; +import com.ripple.BE.news.repository.newscrap.NewsScrapRepository; +import com.ripple.BE.user.domain.User; +import com.ripple.BE.user.service.UserService; +import java.util.List; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@RequiredArgsConstructor +@Service +@Slf4j +public class NewsService { + + private final NewsRepository newsRepository; + private final NewsScrapRepository newsScrapRepository; + private final NewsJdbcRepository newsJdbcRepository; + + private final UserService userService; + private final List crawlers; + + private static final int PAGE_SIZE = 10; + + @Transactional(readOnly = true) + public NewsListDTO getNewsList(final int page, final NewsSort sort, final NewsCategory category) { + + Pageable pageable = PageRequest.of(page, PAGE_SIZE); + + // 게시글 조회 (타입에 따른 필터링) + Page newsPage = + category == null + ? newsRepository.findAll(pageable, sort) // 일반 게시글 조회 + : newsRepository.findByCategory(category, sort, pageable); // 특정 타입 게시글 조회 + + return NewsListDTO.toNewsListDTO(newsPage); + } + + @Transactional + public NewsDTO getNews(final long id) { + News news = + newsRepository.findByIdForUpdate(id).orElseThrow(() -> new NewsException(NEWS_NOT_FOUND)); + + news.increaseViews(); + + return NewsDTO.toNewsDTO(news); + } + + @Transactional + public void addScrapToNews(final long newsId, final long userId) { + + News news = + newsRepository.findById(newsId).orElseThrow(() -> new NewsException(NEWS_NOT_FOUND)); + User user = userService.findUserById(userId); + + if (newsScrapRepository.existsByNewsIdAndUserId(newsId, userId)) { + throw new NewsException(SCRAP_ALREADY_EXISTS); + } + + NewsScrap newsScrap = NewsScrap.toNewsScrapEntity(); + newsScrap.setUser(user); + newsScrap.setNews(news); + } + + @Transactional + public void removeScrapFromNews(final long newsId, final long userId) { + NewsScrap newsScrap = + newsScrapRepository + .findByNewsIdAndUserId(newsId, userId) + .orElseThrow(() -> new NewsException(NEWS_SCRAP_NOT_FOUND)); + + newsScrapRepository.delete(newsScrap); + } + + // 하루 한 번 뉴스 크롤링, 실제 배포시는 짧은 주기로 변경 필요 + @Scheduled(cron = "0 0 0 * * *") + @Transactional + public void fetchAndSaveAllNews() { + for (NewsCrawler crawler : crawlers) { + List newsList = crawler.crawl(); + + List filteredNews = filterOutDuplicateUrls(newsList); + + saveNewsBatch(filteredNews); + } + } + + private List filterOutDuplicateUrls(List newsList) { + if (newsList.isEmpty()) { + return List.of(); + } + + List urls = newsList.stream().map(NewsDTO::url).toList(); + List existingUrls = newsJdbcRepository.findExistingUrls(urls); + + return newsList.stream() + .filter(dto -> !existingUrls.contains(dto.url())) + .map(News::toNewsEntity) + .toList(); + } + + private void saveNewsBatch(List newsList) { + if (newsList.isEmpty()) { + return; + } + + newsJdbcRepository.saveAllNewsByJdbcTemplate(newsList); + } +} From a32605352b779e20bffdb5446effb58ec0892a63 Mon Sep 17 00:00:00 2001 From: jeongchanmin Date: Fri, 24 Jan 2025 17:30:25 +0900 Subject: [PATCH 14/18] =?UTF-8?q?Refactor=20:=20api=20=EB=AA=85=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD=20=20(#28)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/com/ripple/BE/news/controller/NewsController.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/com/ripple/BE/news/controller/NewsController.java b/src/main/java/com/ripple/BE/news/controller/NewsController.java index 08a74c6..6b6804f 100644 --- a/src/main/java/com/ripple/BE/news/controller/NewsController.java +++ b/src/main/java/com/ripple/BE/news/controller/NewsController.java @@ -24,7 +24,7 @@ import org.springframework.web.bind.annotation.RestController; @RestController -@RequestMapping("/api/posts") +@RequestMapping("/api/news") @RequiredArgsConstructor public class NewsController { From fd0d47f32ed1a9ead8ec516840893ed154a33a41 Mon Sep 17 00:00:00 2001 From: jeongchanmin Date: Fri, 24 Jan 2025 17:30:43 +0900 Subject: [PATCH 15/18] =?UTF-8?q?Refactor=20:=20=EC=A3=BC=EC=84=9D=20?= =?UTF-8?q?=EC=82=AD=EC=A0=9C=20=20(#28)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/com/ripple/BE/news/crawler/NewsCrawler.java | 1 - 1 file changed, 1 deletion(-) diff --git a/src/main/java/com/ripple/BE/news/crawler/NewsCrawler.java b/src/main/java/com/ripple/BE/news/crawler/NewsCrawler.java index 115fd23..57bda08 100644 --- a/src/main/java/com/ripple/BE/news/crawler/NewsCrawler.java +++ b/src/main/java/com/ripple/BE/news/crawler/NewsCrawler.java @@ -21,7 +21,6 @@ public abstract class NewsCrawler { protected static final String PUBLISH_SELECTOR = "div.sa_text_press"; // 언론사 선택자 protected static final String CONTENT_URL_SELECTOR = "div.sa_text a"; // 본문 URL 선택자 - /** 크롤링 기본 메서드 어제 날짜에 해당하는 데이터만 출력 */ public List crawl() { log.info("Crawling started"); From 08893e56a2f3815393e733ff4cfe7175463ead6b Mon Sep 17 00:00:00 2001 From: jeongchanmin Date: Fri, 24 Jan 2025 17:30:53 +0900 Subject: [PATCH 16/18] =?UTF-8?q?Refactor=20:=20=EC=97=90=EB=9F=AC?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20=EB=B3=80=EA=B2=BD=20=20(#28)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ripple/BE/news/exception/errorcode/NewsErrorCode.java | 1 + src/main/java/com/ripple/BE/news/service/NewsService.java | 5 ++--- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/main/java/com/ripple/BE/news/exception/errorcode/NewsErrorCode.java b/src/main/java/com/ripple/BE/news/exception/errorcode/NewsErrorCode.java index 0344edc..4bca8a2 100644 --- a/src/main/java/com/ripple/BE/news/exception/errorcode/NewsErrorCode.java +++ b/src/main/java/com/ripple/BE/news/exception/errorcode/NewsErrorCode.java @@ -10,6 +10,7 @@ public enum NewsErrorCode implements ErrorCode { NEWS_NOT_FOUND(HttpStatus.NOT_FOUND, "News not found"), NEWS_SCRAP_NOT_FOUND(HttpStatus.NOT_FOUND, "News scrap not found"), + NEWS_SCRAP_ALREADY_EXIST(HttpStatus.BAD_REQUEST, "News scrap already exist"), NEWS_INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "Internal server error"), ; diff --git a/src/main/java/com/ripple/BE/news/service/NewsService.java b/src/main/java/com/ripple/BE/news/service/NewsService.java index c93661e..e81c5ba 100644 --- a/src/main/java/com/ripple/BE/news/service/NewsService.java +++ b/src/main/java/com/ripple/BE/news/service/NewsService.java @@ -1,7 +1,6 @@ package com.ripple.BE.news.service; import static com.ripple.BE.news.exception.errorcode.NewsErrorCode.*; -import static com.ripple.BE.post.exception.errorcode.PostErrorCode.*; import com.ripple.BE.news.crawler.NewsCrawler; import com.ripple.BE.news.domain.News; @@ -59,7 +58,7 @@ public NewsDTO getNews(final long id) { News news = newsRepository.findByIdForUpdate(id).orElseThrow(() -> new NewsException(NEWS_NOT_FOUND)); - news.increaseViews(); + news.increaseViews(); // 조회수 증가 return NewsDTO.toNewsDTO(news); } @@ -72,7 +71,7 @@ public void addScrapToNews(final long newsId, final long userId) { User user = userService.findUserById(userId); if (newsScrapRepository.existsByNewsIdAndUserId(newsId, userId)) { - throw new NewsException(SCRAP_ALREADY_EXISTS); + throw new NewsException(NEWS_SCRAP_ALREADY_EXIST); } NewsScrap newsScrap = NewsScrap.toNewsScrapEntity(); From 7b5d1cd6c1410baa87d0f43868a2932aa6adfb3c Mon Sep 17 00:00:00 2001 From: jeongchanmin Date: Fri, 24 Jan 2025 17:44:45 +0900 Subject: [PATCH 17/18] =?UTF-8?q?Refactor=20:=20=EC=8A=A4=EC=9B=A8?= =?UTF-8?q?=EA=B1=B0=20=ED=83=9C=EA=B7=B8=EB=AA=85=20=EB=B3=80=EA=B2=BD=20?= =?UTF-8?q?=20(#28)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/com/ripple/BE/news/controller/NewsController.java | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/main/java/com/ripple/BE/news/controller/NewsController.java b/src/main/java/com/ripple/BE/news/controller/NewsController.java index 6b6804f..d3db39f 100644 --- a/src/main/java/com/ripple/BE/news/controller/NewsController.java +++ b/src/main/java/com/ripple/BE/news/controller/NewsController.java @@ -10,6 +10,7 @@ import com.ripple.BE.news.service.NewsService; import com.ripple.BE.user.domain.CustomUserDetails; import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.constraints.PositiveOrZero; import lombok.RequiredArgsConstructor; import org.springframework.http.HttpStatus; @@ -26,6 +27,7 @@ @RestController @RequestMapping("/api/news") @RequiredArgsConstructor +@Tag(name = "News", description = "뉴스 API") public class NewsController { private final NewsService newsService; From a74b7391d5e7ba212d3c065db6ca96fb73da045b Mon Sep 17 00:00:00 2001 From: jeongchanmin Date: Fri, 24 Jan 2025 18:34:20 +0900 Subject: [PATCH 18/18] =?UTF-8?q?Bug=20:=20=EC=84=9C=EB=B2=84=EC=97=90?= =?UTF-8?q?=EC=84=9C=20=EC=97=91=EC=85=80=20=ED=8C=8C=EC=9D=BC=20=EA=B2=BD?= =?UTF-8?q?=EB=A1=9C=20=EC=B0=BE=EC=A7=80=20=EB=AA=BB=ED=95=98=EB=8A=94=20?= =?UTF-8?q?=EC=98=A4=EB=A5=98=20=EC=88=98=EC=A0=95=20=20(#28)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../learningset/LearningAdminService.java | 52 +++++++++++++------ 1 file changed, 37 insertions(+), 15 deletions(-) diff --git a/src/main/java/com/ripple/BE/learning/service/learningset/LearningAdminService.java b/src/main/java/com/ripple/BE/learning/service/learningset/LearningAdminService.java index 3760ece..acb3a6c 100644 --- a/src/main/java/com/ripple/BE/learning/service/learningset/LearningAdminService.java +++ b/src/main/java/com/ripple/BE/learning/service/learningset/LearningAdminService.java @@ -12,11 +12,13 @@ import com.ripple.BE.learning.exception.errorcode.LearningErrorCode; import com.ripple.BE.learning.repository.LearningSetRepository; import com.ripple.BE.learning.repository.QuizRepository; +import java.io.File; import java.util.List; import java.util.Map; import java.util.stream.Collectors; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.core.io.ClassPathResource; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -25,7 +27,7 @@ @RequiredArgsConstructor public class LearningAdminService { - private static final String FILE_PATH = "src/main/resources/static/excel/example.xlsx"; + private static final String FILE_PATH = "static/excel/example.xlsx"; private static final int LEARNING_SET_SHEET_INDEX = 0; private static final int CONCEPT_SHEET_INDEX = 1; private static final int QUIZ_SHEET_INDEX = 2; @@ -37,8 +39,9 @@ public class LearningAdminService { @Transactional public void createLearningSetByExcel() { try { + File file = new ClassPathResource(FILE_PATH).getFile(); - List learningSetList = parseLearningSetsFromExcel(); + List learningSetList = parseLearningSetsFromExcel(file.getPath()); List existingLearningSets = learningSetRepository.findAll(); Map learningSetMap = @@ -57,11 +60,15 @@ public void createLearningSetByExcel() { List newLearningSets = learningSetList.stream() - .filter(learningSet -> !existingLearningSets.contains(learningSet)) + .filter( + learningSet -> + existingLearningSets.stream() + .noneMatch( + existingSet -> existingSet.getName().equals(learningSet.getName()))) .collect(Collectors.toList()); - addConceptsToLearningSets(newLearningSetMap); - addQuizzesToLearningSets(newLearningSetMap); + addConceptsToLearningSets(file.getPath(), newLearningSetMap); + addQuizzesToLearningSets(file.getPath(), newLearningSetMap); learningSetRepository.saveAll(newLearningSets); @@ -71,29 +78,44 @@ public void createLearningSetByExcel() { } } - private List parseLearningSetsFromExcel() throws Exception { - return ExcelUtils.parseExcelFile(FILE_PATH, LEARNING_SET_SHEET_INDEX).stream() + private List parseLearningSetsFromExcel(String filePath) throws Exception { + return ExcelUtils.parseExcelFile(filePath, LEARNING_SET_SHEET_INDEX).stream() .map(LearningSetDTO::toLearningSetDTO) .map(LearningSet::toLearningSet) .collect(Collectors.toList()); } - private void addConceptsToLearningSets(Map learningSetMap) throws Exception { - ExcelUtils.parseExcelFile(FILE_PATH, CONCEPT_SHEET_INDEX).stream() + private void addConceptsToLearningSets(String filePath, Map learningSetMap) + throws Exception { + ExcelUtils.parseExcelFile(filePath, CONCEPT_SHEET_INDEX).stream() .map(ConceptDTO::toConceptDTO) .forEach( - conceptDTO -> - Concept.toConcept(conceptDTO) - .setLearningSet(learningSetMap.get(conceptDTO.learningSetName()))); + conceptDTO -> { + LearningSet learningSet = learningSetMap.get(conceptDTO.learningSetName()); + + if (learningSet == null) { + return; + } + + Concept concept = Concept.toConcept(conceptDTO); + concept.setLearningSet(learningSet); + }); } - private void addQuizzesToLearningSets(Map learningSetMap) throws Exception { - ExcelUtils.parseExcelFile(FILE_PATH, QUIZ_SHEET_INDEX).stream() + private void addQuizzesToLearningSets(String filePath, Map learningSetMap) + throws Exception { + ExcelUtils.parseExcelFile(filePath, QUIZ_SHEET_INDEX).stream() .map(QuizDTO::toQuizDTO) .forEach( quizDTO -> { + LearningSet learningSet = learningSetMap.get(quizDTO.learningSetName()); + + if (learningSet == null) { + return; // 해당 quizDTO를 건너뜀 + } + Quiz quiz = Quiz.toQuiz(quizDTO); - quiz.setLearningSet(learningSetMap.get(quizDTO.learningSetName())); + quiz.setLearningSet(learningSet); quizRepository.save(quiz);