Skip to content

Commit de13398

Browse files
authored
[feat] 미사용 이미지 매일 오전 4시에 자동삭제 기능 구현
2 parents 6b540fd + 1f6a1e8 commit de13398

File tree

4 files changed

+162
-2
lines changed

4 files changed

+162
-2
lines changed

src/main/java/com/web/coreclass/CoreclassApplication.java

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,11 @@
77
import org.springframework.boot.SpringApplication;
88
import org.springframework.boot.autoconfigure.SpringBootApplication;
99
import org.springframework.context.annotation.Bean;
10-
import org.springframework.data.jpa.repository.config.EnableJpaAuditing;
10+
import org.springframework.scheduling.annotation.EnableScheduling;
1111
import org.springframework.security.crypto.password.PasswordEncoder;
1212

1313
@SpringBootApplication
14-
//@EnableJpaAuditing
14+
@EnableScheduling
1515
@Slf4j
1616
public class CoreclassApplication {
1717

src/main/java/com/web/coreclass/domain/article/repository/ArticleRepository.java

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import com.web.coreclass.domain.article.entity.Article;
44
import com.web.coreclass.domain.article.entity.ArticleCategory;
55
import org.springframework.data.jpa.repository.JpaRepository;
6+
import org.springframework.data.jpa.repository.Query;
67

78
import java.util.List;
89

@@ -16,4 +17,11 @@ public interface ArticleRepository extends JpaRepository<Article, Long> {
1617

1718
// 3. 팝업으로 지정된 게시글만 (1)우선순위 (2)최신순으로 조회 (필요시 사용)
1819
List<Article> findAllByIsPopupTrueOrderByPriorityAscPostedAtDesc();
20+
21+
// [추가] 공지사항 썸네일과 본문(Markdown) 조회
22+
@Query("SELECT a.thumbnailUrl FROM Article a WHERE a.thumbnailUrl IS NOT NULL")
23+
List<String> findAllThumbnailUrls();
24+
25+
@Query("SELECT a.content FROM Article a")
26+
List<String> findAllContents();
1927
}

src/main/java/com/web/coreclass/domain/instructor/repository/InstructorRepository.java

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,4 +21,15 @@ public interface InstructorRepository extends JpaRepository<Instructor, Long> {
2121
@Query("SELECT i FROM Instructor i " +
2222
"LEFT JOIN FETCH i.games ig")
2323
List<Instructor> findAllWithGames();
24+
25+
// [추가] 모든 강사의 프로필 이미지와 로고 URL만 조회
26+
@Query("SELECT i.profileImgUrl FROM Instructor i WHERE i.profileImgUrl IS NOT NULL")
27+
List<String> findAllProfileImgUrls();
28+
29+
@Query("SELECT i.sgeaLogoImgUrl FROM Instructor i WHERE i.sgeaLogoImgUrl IS NOT NULL")
30+
List<String> findAllSgeaLogoImgUrls();
31+
32+
// (CareerHistory에 있는 로고들도 가져와야 함)
33+
@Query("SELECT c.logoImgUrl FROM CareerHistory c WHERE c.logoImgUrl IS NOT NULL")
34+
List<String> findAllCareerLogoImgUrls();
2435
}
Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
package com.web.coreclass.global.s3;
2+
3+
import com.web.coreclass.domain.article.repository.ArticleRepository;
4+
import com.web.coreclass.domain.game.entity.GameType;
5+
import com.web.coreclass.domain.instructor.repository.InstructorRepository;
6+
import io.awspring.cloud.s3.S3Template;
7+
import lombok.RequiredArgsConstructor;
8+
import lombok.extern.slf4j.Slf4j;
9+
import org.springframework.beans.factory.annotation.Value;
10+
import org.springframework.scheduling.annotation.Scheduled;
11+
import org.springframework.stereotype.Component;
12+
import software.amazon.awssdk.services.s3.S3Client;
13+
import software.amazon.awssdk.services.s3.model.ListObjectsV2Request;
14+
import software.amazon.awssdk.services.s3.model.ListObjectsV2Response;
15+
import software.amazon.awssdk.services.s3.model.S3Object;
16+
17+
import java.net.URLDecoder;
18+
import java.nio.charset.StandardCharsets;
19+
import java.time.Instant;
20+
import java.time.temporal.ChronoUnit;
21+
import java.util.HashSet;
22+
import java.util.List;
23+
import java.util.Set;
24+
import java.util.regex.Matcher;
25+
import java.util.regex.Pattern;
26+
27+
@Slf4j
28+
@Component
29+
@RequiredArgsConstructor
30+
public class S3CleanupScheduler {
31+
32+
private final S3Client s3Client; // AWS SDK Client (목록 조회용)
33+
private final S3Template s3Template; // Spring Cloud S3 (삭제용)
34+
private final InstructorRepository instructorRepository;
35+
private final ArticleRepository articleRepository;
36+
37+
@Value("${spring.cloud.aws.s3.bucket}")
38+
private String bucket;
39+
40+
// 매일 새벽 4시에 실행 (초 분 시 일 월 요일)
41+
@Scheduled(cron = "0 0 4 * * *")
42+
public void cleanupOrphanImages() {
43+
log.info("🧹 [S3 고아 파일 청소] 시작합니다...");
44+
45+
// 1. DB에 등록된 '사용 중인' 이미지 파일명 다 모으기
46+
Set<String> validFileNames = new HashSet<>();
47+
48+
// (1) 강사 관련 이미지
49+
validFileNames.addAll(extractFileNames(instructorRepository.findAllProfileImgUrls()));
50+
validFileNames.addAll(extractFileNames(instructorRepository.findAllSgeaLogoImgUrls()));
51+
validFileNames.addAll(extractFileNames(instructorRepository.findAllCareerLogoImgUrls()));
52+
53+
// (2) 공지사항 관련 이미지 (썸네일)
54+
validFileNames.addAll(extractFileNames(articleRepository.findAllThumbnailUrls()));
55+
56+
// (3) 공지사항 본문(Markdown)에 포함된 이미지 파싱
57+
List<String> contents = articleRepository.findAllContents();
58+
for (String content : contents) {
59+
validFileNames.addAll(extractUrlsFromMarkdown(content));
60+
}
61+
62+
// (4) Enum(GameType)에 하드코딩된 이미지도 보호해야 함!
63+
for (GameType game : GameType.values()) {
64+
validFileNames.add(extractFileNameFromUrl(game.getLogoUrl()));
65+
}
66+
67+
log.info("✅ DB에서 확인된 사용 중인 파일 개수: {}개", validFileNames.size());
68+
69+
// 2. S3에 있는 모든 파일 목록 조회 및 비교
70+
int deletedCount = 0;
71+
ListObjectsV2Request request = ListObjectsV2Request.builder().bucket(bucket).build();
72+
ListObjectsV2Response result;
73+
74+
do {
75+
result = s3Client.listObjectsV2(request);
76+
77+
for (S3Object s3Object : result.contents()) {
78+
String key = s3Object.key(); // S3 파일명 (예: uuid_image.png)
79+
80+
// (A) DB 목록에 없고
81+
// (B) 생성된 지 24시간이 지난 파일만 삭제 (방금 업로드 중인 파일 보호)
82+
if (!validFileNames.contains(key) && isOlderThan24Hours(s3Object.lastModified())) {
83+
try {
84+
log.info("🗑️ 고아 파일 발견 및 삭제: {}", key);
85+
s3Template.deleteObject(bucket, key);
86+
deletedCount++;
87+
} catch (Exception e) {
88+
log.error("삭제 실패: {}", key, e);
89+
}
90+
}
91+
}
92+
// 다음 페이지가 있으면 계속 조회
93+
request = request.toBuilder().continuationToken(result.nextContinuationToken()).build();
94+
} while (result.isTruncated());
95+
96+
log.info("✨ [S3 고아 파일 청소] 완료. 총 {}개 파일 삭제됨.", deletedCount);
97+
}
98+
99+
// --- Helper Methods ---
100+
101+
// 1. URL 리스트에서 파일명만 추출 (예: https://.../abc.png -> abc.png)
102+
private Set<String> extractFileNames(List<String> urls) {
103+
Set<String> fileNames = new HashSet<>();
104+
for (String url : urls) {
105+
fileNames.add(extractFileNameFromUrl(url));
106+
}
107+
return fileNames;
108+
}
109+
110+
// 2. 단일 URL에서 파일명 추출
111+
private String extractFileNameFromUrl(String url) {
112+
if (url == null || url.isEmpty()) return "";
113+
try {
114+
// URL 디코딩 (한글 파일명 대비)
115+
String decodedUrl = URLDecoder.decode(url, StandardCharsets.UTF_8);
116+
return decodedUrl.substring(decodedUrl.lastIndexOf("/") + 1);
117+
} catch (Exception e) {
118+
return "";
119+
}
120+
}
121+
122+
// 3. 마크다운 본문에서 이미지 URL 추출 (정규식)
123+
private Set<String> extractUrlsFromMarkdown(String content) {
124+
Set<String> fileNames = new HashSet<>();
125+
// 마크다운 이미지 패턴: ![...](URL) 또는 <img src="URL">
126+
// 간단하게 http로 시작해서 괄호나 따옴표로 끝나는 패턴을 잡습니다.
127+
Pattern pattern = Pattern.compile("https://[^\\s)\"]+");
128+
Matcher matcher = pattern.matcher(content);
129+
130+
while (matcher.find()) {
131+
String url = matcher.group();
132+
fileNames.add(extractFileNameFromUrl(url));
133+
}
134+
return fileNames;
135+
}
136+
137+
// 4. 24시간 지났는지 확인
138+
private boolean isOlderThan24Hours(Instant lastModified) {
139+
return lastModified.isBefore(Instant.now().minus(1, ChronoUnit.DAYS));
140+
}
141+
}

0 commit comments

Comments
 (0)