Skip to content

Commit 4e3518c

Browse files
authored
Merge pull request #91 from yuhandemian/feature/#84
[feat] 뉴스 기사 백업 및 복구
2 parents 2f4ab25 + cd19dd1 commit 4e3518c

41 files changed

Lines changed: 1723 additions & 83 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

monew/build.gradle

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,10 @@ dependencies {
8383
annotationProcessor 'com.querydsl:querydsl-apt:5.1.0:jakarta'
8484
annotationProcessor 'jakarta.annotation:jakarta.annotation-api'
8585
annotationProcessor 'jakarta.persistence:jakarta.persistence-api'
86+
87+
// AWS SDK for S3
88+
implementation platform('software.amazon.awssdk:bom:2.34.0')
89+
implementation 'software.amazon.awssdk:s3'
8690
}
8791

8892
tasks.named('test') {

monew/src/main/java/com/spring/monew/article/controller/ArticleController.java

Lines changed: 59 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
package com.spring.monew.article.controller;
22

33
import com.spring.monew.article.controller.dto.response.ArticleDto;
4+
import com.spring.monew.article.controller.dto.response.ArticleRestoreResultDto;
45
import com.spring.monew.article.controller.dto.response.CursorPageResponseArticleDto;
56
import com.spring.monew.article.service.ArticleService;
6-
import com.spring.monew.auth.config.HeaderUserAuthentication;
77
import com.spring.monew.common.util.RequestUserExtractor;
88
import io.swagger.v3.oas.annotations.Operation;
99
import io.swagger.v3.oas.annotations.Parameter;
@@ -17,11 +17,16 @@
1717
import java.util.List;
1818
import java.util.UUID;
1919
import lombok.RequiredArgsConstructor;
20+
import org.springframework.format.annotation.DateTimeFormat;
21+
import org.springframework.http.HttpStatus;
22+
import org.springframework.http.ResponseEntity;
2023
import org.springframework.validation.annotation.Validated;
24+
import org.springframework.web.bind.annotation.DeleteMapping;
2125
import org.springframework.web.bind.annotation.GetMapping;
2226
import org.springframework.web.bind.annotation.PathVariable;
2327
import org.springframework.web.bind.annotation.RequestMapping;
2428
import org.springframework.web.bind.annotation.RequestParam;
29+
import org.springframework.web.bind.annotation.ResponseStatus;
2530
import org.springframework.web.bind.annotation.RestController;
2631

2732
@RestController
@@ -38,9 +43,9 @@ public class ArticleController {
3843
@GetMapping
3944
@Operation(summary = "뉴스 기사 목록 조회", description = "조건에 맞는 뉴스 기사 목록을 조회합니다.")
4045
@ApiResponses({
41-
@ApiResponse(responseCode = "200", description = "조회 성공"),
42-
@ApiResponse(responseCode = "400", description = "잘못된 요청 (정렬 기준 오류, 페이지네이션 파라미터 오류 등)"),
43-
@ApiResponse(responseCode = "500", description = "서버 내부 오류")
46+
@ApiResponse(responseCode = "200", description = "조회 성공"),
47+
@ApiResponse(responseCode = "400", description = "잘못된 요청 (정렬 기준 오류, 페이지네이션 파라미터 오류 등)"),
48+
@ApiResponse(responseCode = "500", description = "서버 내부 오류")
4449
})
4550
public CursorPageResponseArticleDto articleList(
4651
@Parameter(description = "검색어(제목, 요약)")
@@ -96,12 +101,12 @@ public CursorPageResponseArticleDto articleList(
96101
@GetMapping("/{articleId}")
97102
@Operation(
98103
summary = "뉴스 기사 단건 조회",
99-
description = "뉴스 기사 ID로 뉴스 기사 단건을 조회합니다."
104+
description = "뉴스 기사 ID로 뉴스 기사 단건을 조회합니다."
100105
)
101106
@ApiResponses({
102-
@ApiResponse(responseCode = "200", description = "조회 성공"),
103-
@ApiResponse(responseCode = "404", description = "뉴스 기사 정보 없음"),
104-
@ApiResponse(responseCode = "500", description = "서버 내부 오류")
107+
@ApiResponse(responseCode = "200", description = "조회 성공"),
108+
@ApiResponse(responseCode = "404", description = "뉴스 기사 정보 없음"),
109+
@ApiResponse(responseCode = "500", description = "서버 내부 오류")
105110
})
106111
public ArticleDto articleDetails(
107112
@Parameter(description = "뉴스 기사 ID", required = true)
@@ -116,10 +121,53 @@ public ArticleDto articleDetails(
116121
@GetMapping("/sources")
117122
@Operation(summary = "출처 목록 조회", description = "출처 목록을 조회합니다.")
118123
@ApiResponses({
119-
@ApiResponse(responseCode = "200", description = "조회 성공"),
120-
@ApiResponse(responseCode = "500", description = "서버 내부 오류")
124+
@ApiResponse(responseCode = "200", description = "조회 성공"),
125+
@ApiResponse(responseCode = "500", description = "서버 내부 오류")
121126
})
122127
public List<String> articleSourceList() {
123128
return articleService.getSources();
124129
}
125-
}
130+
131+
@DeleteMapping("/{articleId}")
132+
@ResponseStatus(HttpStatus.NO_CONTENT)
133+
@Operation(summary = "뉴스 기사 삭제", description = "특정 기사를 소프트 삭제합니다 (논리 삭제)")
134+
public void softDeleteArticle(
135+
@Parameter(description = "뉴스 기사 ID", required = true)
136+
@PathVariable UUID articleId,
137+
Principal principal
138+
) {
139+
UUID userId = userExtractor.extractUserId(principal);
140+
articleService.softDeleteArticle(articleId, userId);
141+
}
142+
143+
@DeleteMapping("/{articleId}/hard")
144+
@ResponseStatus(HttpStatus.NO_CONTENT)
145+
@Operation(summary = "뉴스 기사 영구 삭제", description = "특정 기사 및 관련된 모든 데이터(댓글, 좋아요, 조회수)를 데이터베이스에서 완전히 삭제합니다")
146+
public void hardDeleteArticle(
147+
@Parameter(description = "뉴스 기사 ID", required = true)
148+
@PathVariable UUID articleId,
149+
Principal principal
150+
) {
151+
UUID userId = userExtractor.extractUserId(principal);
152+
articleService.hardDeleteArticle(articleId, userId);
153+
}
154+
155+
@GetMapping("/restore")
156+
@Operation(
157+
summary = "백업에서 기사 복원",
158+
description = "S3 백업에서 지정된 날짜 범위의 누락된 기사를 복원합니다. 최대 31일 범위까지 가능합니다."
159+
)
160+
public ResponseEntity<ArticleRestoreResultDto> restoreArticles(
161+
@Parameter(description = "시작 날짜", required = true)
162+
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) Instant from,
163+
164+
@Parameter(description = "종료 날짜", required = true)
165+
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) Instant to,
166+
167+
Principal principal
168+
) {
169+
UUID userId = userExtractor.extractUserId(principal);
170+
ArticleRestoreResultDto result = articleService.restoreArticlesFromBackup(from, to, userId);
171+
return ResponseEntity.ok(result);
172+
}
173+
}

monew/src/main/java/com/spring/monew/article/domain/Article.java

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,19 @@ public static Article of(Interest interest, ArticleSource source, String sourceU
8383
return new Article(interest, source, sourceUrl, title, publishDate, summary);
8484
}
8585

86+
public static Article restore(UUID id, Interest interest, ArticleSource source,
87+
String sourceUrl, String title, Instant publishDate,
88+
String summary, long viewCount, long commentCount,
89+
Instant createdAt) {
90+
Article article = new Article(interest, source, sourceUrl, title, publishDate, summary);
91+
article.id = id;
92+
article.viewCount = viewCount;
93+
article.commentCount = commentCount;
94+
article.createdAt = createdAt;
95+
article.updatedAt = createdAt;
96+
return article;
97+
}
98+
8699
public void incrementViewCount() {
87100
this.viewCount++;
88101
}
@@ -98,7 +111,6 @@ public void decrementCommentCount() {
98111
}
99112

100113

101-
// 테스트용
102114
@TestOnly
103115
public Article(
104116
UUID id,
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,51 @@
11
package com.spring.monew.article.repository;
22

33
import com.spring.monew.article.domain.Article;
4+
import java.time.Instant;
45
import java.util.Collection;
6+
import java.util.List;
7+
import java.util.Optional;
58
import java.util.Set;
69
import java.util.UUID;
710
import org.springframework.data.jpa.repository.JpaRepository;
11+
import org.springframework.data.jpa.repository.Modifying;
812
import org.springframework.data.jpa.repository.Query;
913
import org.springframework.data.repository.query.Param;
1014

1115
public interface ArticleRepository extends JpaRepository<Article, UUID>, ArticleRepositoryCustom {
1216

1317
@Query("select a.sourceUrl from Article a where a.sourceUrl in :urls")
1418
Set<String> findExistingSourceUrls(@Param("urls") Collection<String> urls);
19+
20+
@Modifying(clearAutomatically = true)
21+
@Query(value = "DELETE FROM articles WHERE id = :articleId", nativeQuery = true)
22+
void hardDelete(@Param("articleId") UUID articleId);
23+
24+
@Query(value = "SELECT EXISTS(SELECT 1 FROM articles WHERE id = :articleId)", nativeQuery = true)
25+
boolean existsIncludingDeleted(@Param("articleId") UUID articleId);
26+
27+
@Query(value = "SELECT * FROM articles WHERE id = :articleId AND is_deleted = true", nativeQuery = true)
28+
Optional<Article> findIncludingDeleted(@Param("articleId") UUID articleId);
29+
30+
@Query("SELECT a.sourceUrl FROM Article a")
31+
Set<String> findAllSourceUrls();
32+
33+
@Query(value = """
34+
SELECT id FROM articles
35+
WHERE is_deleted = true
36+
AND deleted_at IS NOT NULL
37+
AND deleted_at < :threshold
38+
""", nativeQuery = true)
39+
List<UUID> findSoftDeletedBefore(@Param("threshold") Instant threshold);
40+
41+
@Query(value = """
42+
SELECT id FROM articles
43+
WHERE is_deleted = true
44+
AND deleted_at IS NOT NULL
45+
AND deleted_at >= :startTime
46+
AND deleted_at <= :endTime
47+
""", nativeQuery = true)
48+
List<UUID> findSoftDeletedBetween(@Param("startTime") Instant startTime, @Param("endTime") Instant endTime);
49+
50+
List<Article> findAllByInterestId(UUID interestId);
1551
}

monew/src/main/java/com/spring/monew/article/service/ArticleService.java

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package com.spring.monew.article.service;
22

33
import com.spring.monew.article.controller.dto.response.ArticleDto;
4+
import com.spring.monew.article.controller.dto.response.ArticleRestoreResultDto;
45
import com.spring.monew.article.controller.dto.response.CursorPageResponseArticleDto;
56
import java.time.Instant;
67
import java.util.List;
@@ -24,4 +25,10 @@ CursorPageResponseArticleDto getArticles(
2425
List<String> getSources();
2526

2627
ArticleDto getArticle(UUID articleId, UUID userId);
28+
29+
void softDeleteArticle(UUID articleId, UUID userId);
30+
31+
void hardDeleteArticle(UUID articleId, UUID userId);
32+
33+
ArticleRestoreResultDto restoreArticlesFromBackup(Instant fromDate, Instant toDate, UUID userId);
2734
}

0 commit comments

Comments
 (0)