From dd69103ec0a647f062a394e9c2cc26693473f7e9 Mon Sep 17 00:00:00 2001 From: jbh010204 Date: Thu, 7 Aug 2025 19:36:13 +0900 Subject: [PATCH 01/43] =?UTF-8?q?mosu-240=20feat:=20ratelimit=20=EA=B4=80?= =?UTF-8?q?=EB=A6=AC=ED=95=A0=20=EA=B0=9D=EC=B2=B4=EB=93=A4=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../global/filter/dto/BlockedHistory.java | 29 +++++++++++++++++++ .../global/filter/dto/BlockedIp.java | 20 +++++++++++++ .../global/filter/dto/RequestCounter.java | 12 ++++++++ 3 files changed, 61 insertions(+) create mode 100644 src/main/java/life/mosu/mosuserver/global/filter/dto/BlockedHistory.java create mode 100644 src/main/java/life/mosu/mosuserver/global/filter/dto/BlockedIp.java create mode 100644 src/main/java/life/mosu/mosuserver/global/filter/dto/RequestCounter.java diff --git a/src/main/java/life/mosu/mosuserver/global/filter/dto/BlockedHistory.java b/src/main/java/life/mosu/mosuserver/global/filter/dto/BlockedHistory.java new file mode 100644 index 00000000..350e83dd --- /dev/null +++ b/src/main/java/life/mosu/mosuserver/global/filter/dto/BlockedHistory.java @@ -0,0 +1,29 @@ +package life.mosu.mosuserver.global.filter.dto; + +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import life.mosu.mosuserver.global.filter.TimePenalty; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public class BlockedHistory { + private String ip; + + @Enumerated(EnumType.STRING) + private TimePenalty penaltyLevel; + private long blockedAt; + + public void updateHistory(TimePenalty penaltyLevel) { + this.penaltyLevel = penaltyLevel; + this.blockedAt = System.currentTimeMillis(); + } + + public BlockedHistory (String ip) { + this.ip = ip; + this.penaltyLevel = TimePenalty.LEVEL_0; + this.blockedAt = System.currentTimeMillis(); + } + +} diff --git a/src/main/java/life/mosu/mosuserver/global/filter/dto/BlockedIp.java b/src/main/java/life/mosu/mosuserver/global/filter/dto/BlockedIp.java new file mode 100644 index 00000000..fcf8e306 --- /dev/null +++ b/src/main/java/life/mosu/mosuserver/global/filter/dto/BlockedIp.java @@ -0,0 +1,20 @@ +package life.mosu.mosuserver.global.filter.dto; + +import java.time.Duration; +import life.mosu.mosuserver.global.filter.TimePenalty; +import lombok.Getter; + +@Getter +public class BlockedIp { + TimePenalty penaltyLevel; + + public BlockedIp(TimePenalty penaltyLevel) { + this.penaltyLevel = penaltyLevel; + } + + public Duration getTtl(){ + return penaltyLevel.getDuration(); + } + + +} diff --git a/src/main/java/life/mosu/mosuserver/global/filter/dto/RequestCounter.java b/src/main/java/life/mosu/mosuserver/global/filter/dto/RequestCounter.java new file mode 100644 index 00000000..6cf619f0 --- /dev/null +++ b/src/main/java/life/mosu/mosuserver/global/filter/dto/RequestCounter.java @@ -0,0 +1,12 @@ +package life.mosu.mosuserver.global.filter.dto; + +import lombok.Getter; + +@Getter +public class RequestCounter { + int count = 0; + + public void increment() { + count++; + } +} From 15580b0a7dfd92556f89a2b318616d230ab4ce41 Mon Sep 17 00:00:00 2001 From: jbh010204 Date: Thu, 7 Aug 2025 19:36:37 +0900 Subject: [PATCH 02/43] =?UTF-8?q?mosu-240=20feat:=20TimePenalty=20?= =?UTF-8?q?=EA=B4=80=EB=A6=AC=ED=95=A0=20enum=20=EC=83=9D=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../mosuserver/global/filter/TimePenalty.java | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 src/main/java/life/mosu/mosuserver/global/filter/TimePenalty.java diff --git a/src/main/java/life/mosu/mosuserver/global/filter/TimePenalty.java b/src/main/java/life/mosu/mosuserver/global/filter/TimePenalty.java new file mode 100644 index 00000000..5f9a51f1 --- /dev/null +++ b/src/main/java/life/mosu/mosuserver/global/filter/TimePenalty.java @@ -0,0 +1,28 @@ +package life.mosu.mosuserver.global.filter; + +import java.time.Duration; +import java.util.concurrent.TimeUnit; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum TimePenalty { + LEVEL_0(0, Duration.ZERO), + LEVEL_1(1, Duration.ofMinutes(1)), + LEVEL_2(2, Duration.ofMinutes(5)), + LEVEL_3(3, Duration.ofMinutes(30)), + LEVEL_4(4, Duration.ofHours(1)), + LEVEL_5(5, Duration.ofDays(1)); + + private final int count; + private final Duration duration; + + public TimePenalty nextLevel() { + if (this == LEVEL_5) { + return this; + } + return TimePenalty.values()[this.ordinal() + 1]; + } + +} From 516efda18d1f881599a1f9cae5e93843abe3c5e1 Mon Sep 17 00:00:00 2001 From: jbh010204 Date: Thu, 7 Aug 2025 19:37:26 +0900 Subject: [PATCH 03/43] =?UTF-8?q?mosu-240=20feat:=20=ED=8E=98=EB=84=90?= =?UTF-8?q?=ED=8B=B0=20=EC=A6=9D=EA=B0=80=EC=97=90=20=EB=94=B0=EB=A5=B8=20?= =?UTF-8?q?=EC=A0=91=EC=86=8D=20=EC=B0=A8=EB=8B=A8=20=EC=8B=9C=EA=B0=84=20?= =?UTF-8?q?=EC=A6=9D=EA=B0=80=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../config/IpRateLimitingProperties.java | 5 +- .../global/filter/IpRateLimitingFilter.java | 75 ++++++++++++++----- 2 files changed, 62 insertions(+), 18 deletions(-) diff --git a/src/main/java/life/mosu/mosuserver/global/config/IpRateLimitingProperties.java b/src/main/java/life/mosu/mosuserver/global/config/IpRateLimitingProperties.java index d4f0ac89..69720fd2 100644 --- a/src/main/java/life/mosu/mosuserver/global/config/IpRateLimitingProperties.java +++ b/src/main/java/life/mosu/mosuserver/global/config/IpRateLimitingProperties.java @@ -1,6 +1,7 @@ package life.mosu.mosuserver.global.config; import jakarta.validation.constraints.Min; +import life.mosu.mosuserver.global.filter.TimePenalty; import lombok.Data; import org.springframework.boot.context.properties.ConfigurationProperties; import org.springframework.stereotype.Component; @@ -12,9 +13,11 @@ @Validated public class IpRateLimitingProperties { - private boolean enabled = false; + private boolean enabled = true; @Min(1) private int maxRequestsPerMinute; @Min(1) private long timeWindowMs; + + private TimePenalty timePenalty; } diff --git a/src/main/java/life/mosu/mosuserver/global/filter/IpRateLimitingFilter.java b/src/main/java/life/mosu/mosuserver/global/filter/IpRateLimitingFilter.java index 4cff09f7..720d9fcc 100644 --- a/src/main/java/life/mosu/mosuserver/global/filter/IpRateLimitingFilter.java +++ b/src/main/java/life/mosu/mosuserver/global/filter/IpRateLimitingFilter.java @@ -4,6 +4,8 @@ import com.github.benmanes.caffeine.cache.Cache; import com.github.benmanes.caffeine.cache.Caffeine; +import com.github.benmanes.caffeine.cache.Expiry; +import com.github.benmanes.caffeine.cache.LoadingCache; import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; @@ -13,6 +15,10 @@ import life.mosu.mosuserver.global.config.IpRateLimitingProperties; import life.mosu.mosuserver.global.exception.CustomRuntimeException; import life.mosu.mosuserver.global.exception.ErrorCode; +import life.mosu.mosuserver.global.filter.dto.BlockedHistory; +import life.mosu.mosuserver.global.filter.dto.BlockedIp; + +import life.mosu.mosuserver.global.filter.dto.RequestCounter; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; import org.springframework.web.filter.OncePerRequestFilter; @@ -22,14 +28,40 @@ public class IpRateLimitingFilter extends OncePerRequestFilter { private final IpRateLimitingProperties ipRateLimitingProperties; - private final Cache ipRequestCounts; + private final Cache ipRequestCountsCache; + private final Cache blockedHistoryCache; + private final LoadingCache blockedIpCache; + public IpRateLimitingFilter(IpRateLimitingProperties ipRateLimitingProperties) { this.ipRateLimitingProperties = ipRateLimitingProperties; - this.ipRequestCounts = Caffeine.newBuilder() + this.ipRequestCountsCache = Caffeine.newBuilder() .expireAfterWrite(ipRateLimitingProperties.getTimeWindowMs(), TimeUnit.MILLISECONDS) .recordStats() .build(); + this.blockedHistoryCache = Caffeine.newBuilder() + .expireAfterWrite(1, TimeUnit.DAYS) + .recordStats() + .build(); + + this.blockedIpCache = Caffeine.newBuilder() + .expireAfter(new Expiry() { + @Override + public long expireAfterCreate(String key, BlockedIp value, long currentTime) { + return value.getTtl().toNanos(); + } + + @Override + public long expireAfterUpdate(String key, BlockedIp value, long currentTime, long currentDuration) { + return currentDuration; + } + + @Override + public long expireAfterRead(String key, BlockedIp value, long currentTime, long currentDuration) { + return currentDuration; + } + }) + .build(key -> null); } @Override @@ -38,42 +70,51 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse throws ServletException, IOException { if (!ipRateLimitingProperties.isEnabled()) { + log.info("IpRateLimitingFilter disabled"); filterChain.doFilter(request, response); return; } String ip = getClientIp(request); - RequestCounter counter = ipRequestCounts.get(ip, k -> new RequestCounter()); + + isAlreadyBlocked(ip); + + RequestCounter counter = ipRequestCountsCache.get(ip, k -> new RequestCounter()); synchronized (counter) { counter.increment(); - if (isBlocked(counter)) { - log.warn("차단된 IP: {}, 요청 횟수: {}", ip, counter.count); - handleBlockedIp(); + if (isOverPerMaxRequest(counter)) { + log.warn("차단된 IP: {}, 요청 횟수: {}", ip, counter.getCount()); + handleBlockedIp(ip); } } - log.info("IP: {}, 요청 횟수 증가 후: {}", ip, counter.count); - log.debug("Cache stats: {}", ipRequestCounts.stats()); + log.debug("IP: {}, 요청 횟수 증가 후: {}", ip, counter.getCount()); + log.debug("Cache stats: {}", ipRequestCountsCache.stats()); filterChain.doFilter(request, response); } - private boolean isBlocked(RequestCounter counter) { - return counter.count >= ipRateLimitingProperties.getMaxRequestsPerMinute(); + private boolean isOverPerMaxRequest(RequestCounter counter) { + return counter.getCount() >= ipRateLimitingProperties.getMaxRequestsPerMinute(); } - private void handleBlockedIp() { - throw new CustomRuntimeException(ErrorCode.TOO_MANY_REQUESTS); - } + private void handleBlockedIp(String ip) { + BlockedHistory history = blockedHistoryCache.get(ip, k -> new BlockedHistory(ip)); + TimePenalty nextPenaltyLevel = history.getPenaltyLevel().nextLevel(); + history.updateHistory(nextPenaltyLevel); - private static class RequestCounter { + blockedIpCache.invalidate(ip); + blockedIpCache.put(ip, new BlockedIp(nextPenaltyLevel)); + log.warn("IP 차단: {}, 차단 레벨: {})", ip, nextPenaltyLevel); - int count = 0; + } - void increment() { - count++; + private void isAlreadyBlocked(String requestedIp) { + if(blockedIpCache.getIfPresent(requestedIp) != null){ + log.warn("이미 차단된 IP: {}", requestedIp); + throw new CustomRuntimeException(ErrorCode.TOO_MANY_REQUESTS); } } } From 7a4b055241c03454895047efc29ce416a2ed457f Mon Sep 17 00:00:00 2001 From: jbh010204 Date: Thu, 7 Aug 2025 20:15:19 +0900 Subject: [PATCH 04/43] =?UTF-8?q?mosu-240=20refactor:=20=EC=B9=B4=ED=8E=98?= =?UTF-8?q?=EC=9D=B8=20=EC=BA=90=EC=8B=9C=20=EC=84=A4=EC=A0=95=EB=93=A4=20?= =?UTF-8?q?config=EB=A1=9C=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../global/config/CaffeineCacheConfig.java | 55 +++++++++++++++++++ .../global/filter/IpRateLimitingFilter.java | 39 ++----------- 2 files changed, 60 insertions(+), 34 deletions(-) create mode 100644 src/main/java/life/mosu/mosuserver/global/config/CaffeineCacheConfig.java diff --git a/src/main/java/life/mosu/mosuserver/global/config/CaffeineCacheConfig.java b/src/main/java/life/mosu/mosuserver/global/config/CaffeineCacheConfig.java new file mode 100644 index 00000000..e9b8bdf1 --- /dev/null +++ b/src/main/java/life/mosu/mosuserver/global/config/CaffeineCacheConfig.java @@ -0,0 +1,55 @@ +package life.mosu.mosuserver.global.config; + +import com.github.benmanes.caffeine.cache.Cache; +import com.github.benmanes.caffeine.cache.Caffeine; +import com.github.benmanes.caffeine.cache.Expiry; +import com.github.benmanes.caffeine.cache.LoadingCache; +import java.util.concurrent.TimeUnit; +import life.mosu.mosuserver.global.filter.caffeine.dto.BlockedHistory; +import life.mosu.mosuserver.global.filter.caffeine.dto.BlockedIp; +import life.mosu.mosuserver.global.filter.caffeine.dto.RequestCounter; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class CaffeineCacheConfig { + + @Bean + public Cache ipRequestCountsCache(IpRateLimitingProperties properties) { + return Caffeine.newBuilder() + .expireAfterWrite(properties.getTimeWindowMs(), TimeUnit.MILLISECONDS) + .recordStats() + .build(); + } + + @Bean + public Cache blockedHistoryCache() { + return Caffeine.newBuilder() + .expireAfterWrite(1, TimeUnit.DAYS) + .recordStats() + .build(); + } + + + @Bean + public LoadingCache blockedIpCache() { + return Caffeine.newBuilder() + .expireAfter(new Expiry() { + @Override + public long expireAfterCreate(String key, BlockedIp value, long currentTime) { + return value.getTtl().toNanos(); + } + + @Override + public long expireAfterUpdate(String key, BlockedIp value, long currentTime, long currentDuration) { + return currentDuration; + } + + @Override + public long expireAfterRead(String key, BlockedIp value, long currentTime, long currentDuration) { + return currentDuration; + } + }) + .build(key -> null); + } +} diff --git a/src/main/java/life/mosu/mosuserver/global/filter/IpRateLimitingFilter.java b/src/main/java/life/mosu/mosuserver/global/filter/IpRateLimitingFilter.java index 720d9fcc..7fa6a413 100644 --- a/src/main/java/life/mosu/mosuserver/global/filter/IpRateLimitingFilter.java +++ b/src/main/java/life/mosu/mosuserver/global/filter/IpRateLimitingFilter.java @@ -15,16 +15,18 @@ import life.mosu.mosuserver.global.config.IpRateLimitingProperties; import life.mosu.mosuserver.global.exception.CustomRuntimeException; import life.mosu.mosuserver.global.exception.ErrorCode; -import life.mosu.mosuserver.global.filter.dto.BlockedHistory; -import life.mosu.mosuserver.global.filter.dto.BlockedIp; +import life.mosu.mosuserver.global.filter.caffeine.dto.BlockedHistory; +import life.mosu.mosuserver.global.filter.caffeine.dto.BlockedIp; -import life.mosu.mosuserver.global.filter.dto.RequestCounter; +import life.mosu.mosuserver.global.filter.caffeine.dto.RequestCounter; +import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; import org.springframework.web.filter.OncePerRequestFilter; @Slf4j @Component +@RequiredArgsConstructor public class IpRateLimitingFilter extends OncePerRequestFilter { private final IpRateLimitingProperties ipRateLimitingProperties; @@ -33,37 +35,6 @@ public class IpRateLimitingFilter extends OncePerRequestFilter { private final LoadingCache blockedIpCache; - public IpRateLimitingFilter(IpRateLimitingProperties ipRateLimitingProperties) { - this.ipRateLimitingProperties = ipRateLimitingProperties; - this.ipRequestCountsCache = Caffeine.newBuilder() - .expireAfterWrite(ipRateLimitingProperties.getTimeWindowMs(), TimeUnit.MILLISECONDS) - .recordStats() - .build(); - this.blockedHistoryCache = Caffeine.newBuilder() - .expireAfterWrite(1, TimeUnit.DAYS) - .recordStats() - .build(); - - this.blockedIpCache = Caffeine.newBuilder() - .expireAfter(new Expiry() { - @Override - public long expireAfterCreate(String key, BlockedIp value, long currentTime) { - return value.getTtl().toNanos(); - } - - @Override - public long expireAfterUpdate(String key, BlockedIp value, long currentTime, long currentDuration) { - return currentDuration; - } - - @Override - public long expireAfterRead(String key, BlockedIp value, long currentTime, long currentDuration) { - return currentDuration; - } - }) - .build(key -> null); - } - @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) From 51c8e7bd2fd9cd5f82d56db490679df1036cf74e Mon Sep 17 00:00:00 2001 From: jbh010204 Date: Thu, 7 Aug 2025 20:16:10 +0900 Subject: [PATCH 05/43] =?UTF-8?q?mosu-240=20refactor:=20IP=20=EC=B0=A8?= =?UTF-8?q?=EB=8B=A8=20=EB=A1=9C=EA=B7=B8=EB=A5=BC=20=EA=B8=B0=EB=A1=9D?= =?UTF-8?q?=ED=95=98=EA=B8=B0=20=EC=9C=84=ED=95=9C=20Entity=20=EC=83=9D?= =?UTF-8?q?=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../BlockedHistoryLogJpaRepository.java | 8 +++++++ .../dto/BlockedHistoryLogJpaEntity.java | 23 +++++++++++++++++++ 2 files changed, 31 insertions(+) create mode 100644 src/main/java/life/mosu/mosuserver/global/filter/caffeine/BlockedHistoryLogJpaRepository.java create mode 100644 src/main/java/life/mosu/mosuserver/global/filter/caffeine/dto/BlockedHistoryLogJpaEntity.java diff --git a/src/main/java/life/mosu/mosuserver/global/filter/caffeine/BlockedHistoryLogJpaRepository.java b/src/main/java/life/mosu/mosuserver/global/filter/caffeine/BlockedHistoryLogJpaRepository.java new file mode 100644 index 00000000..a01c340f --- /dev/null +++ b/src/main/java/life/mosu/mosuserver/global/filter/caffeine/BlockedHistoryLogJpaRepository.java @@ -0,0 +1,8 @@ +package life.mosu.mosuserver.global.filter.caffeine; + +import life.mosu.mosuserver.global.filter.caffeine.dto.BlockedHistoryLogJpaEntity; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface BlockedHistoryLogJpaRepository extends JpaRepository { + +} diff --git a/src/main/java/life/mosu/mosuserver/global/filter/caffeine/dto/BlockedHistoryLogJpaEntity.java b/src/main/java/life/mosu/mosuserver/global/filter/caffeine/dto/BlockedHistoryLogJpaEntity.java new file mode 100644 index 00000000..68f285a1 --- /dev/null +++ b/src/main/java/life/mosu/mosuserver/global/filter/caffeine/dto/BlockedHistoryLogJpaEntity.java @@ -0,0 +1,23 @@ +package life.mosu.mosuserver.global.filter.caffeine.dto; + +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import life.mosu.mosuserver.global.filter.TimePenalty; + +@Table(name = "ip_blocked_history_log") +@Entity +public class BlockedHistoryLogJpaEntity { + + @Id + private Long id; + + private String ip; + + @Enumerated(EnumType.STRING) + private TimePenalty penaltyLevel; + + private long blockedAt; +} From e37be0281444c4ba33e581212282a37552566a58 Mon Sep 17 00:00:00 2001 From: jbh010204 Date: Thu, 7 Aug 2025 21:42:49 +0900 Subject: [PATCH 06/43] =?UTF-8?q?mosu-240=20feat:=20=EC=B0=A8=EB=8B=A8?= =?UTF-8?q?=EB=90=9C=20=EC=9C=A0=EC=A0=80=20=EA=B8=B0=EB=A1=9D=20=EC=A0=80?= =?UTF-8?q?=EC=9E=A5=EC=9D=84=20=EC=9C=84=ED=95=9C=20=EC=97=94=ED=8B=B0?= =?UTF-8?q?=ED=8B=B0=20=EC=83=9D=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../entity/BlockedIpHistoryLogJpaEntity.java | 40 +++++++++++++++++++ 1 file changed, 40 insertions(+) create mode 100644 src/main/java/life/mosu/mosuserver/domain/caffeine/entity/BlockedIpHistoryLogJpaEntity.java diff --git a/src/main/java/life/mosu/mosuserver/domain/caffeine/entity/BlockedIpHistoryLogJpaEntity.java b/src/main/java/life/mosu/mosuserver/domain/caffeine/entity/BlockedIpHistoryLogJpaEntity.java new file mode 100644 index 00000000..223d90e1 --- /dev/null +++ b/src/main/java/life/mosu/mosuserver/domain/caffeine/entity/BlockedIpHistoryLogJpaEntity.java @@ -0,0 +1,40 @@ +package life.mosu.mosuserver.domain.caffeine.entity; + +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.Table; +import java.time.LocalDateTime; +import life.mosu.mosuserver.global.filter.TimePenalty; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Table(name = "blocked_ip_history_log") +@Entity +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Getter +public class BlockedIpHistoryLogJpaEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + private String ip; + + @Enumerated(EnumType.STRING) + private TimePenalty penaltyLevel; + + private LocalDateTime blockedAt; + + @Builder + public BlockedIpHistoryLogJpaEntity(String ip, TimePenalty penaltyLevel, LocalDateTime blockedAt) { + this.ip = ip; + this.penaltyLevel = penaltyLevel; + this.blockedAt = blockedAt; + } +} From 1bcb134c46c617ce98022d9e56e27066fb999143 Mon Sep 17 00:00:00 2001 From: jbh010204 Date: Thu, 7 Aug 2025 21:45:44 +0900 Subject: [PATCH 07/43] =?UTF-8?q?mosu-240=20feat:=20=EC=B0=A8=EB=8B=A8IP?= =?UTF-8?q?=EA=B8=B0=EB=A1=9D=20DB=EC=98=81=EC=86=8D=ED=99=94=20=EB=B0=8F?= =?UTF-8?q?=20=EB=B0=B0=EC=B9=98=20insert=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../BlockedIpHistoryLogJpaJpaRepository.java | 9 ++++++ ...ockedIpHistoryLogJpaJpaRepositoryImpl.java | 32 +++++++++++++++++++ ...lockedIpHistoryLogJpaRepositoryCustom.java | 8 +++++ 3 files changed, 49 insertions(+) create mode 100644 src/main/java/life/mosu/mosuserver/domain/caffeine/repository/BlockedIpHistoryLogJpaJpaRepository.java create mode 100644 src/main/java/life/mosu/mosuserver/domain/caffeine/repository/BlockedIpHistoryLogJpaJpaRepositoryImpl.java create mode 100644 src/main/java/life/mosu/mosuserver/domain/caffeine/repository/BlockedIpHistoryLogJpaRepositoryCustom.java diff --git a/src/main/java/life/mosu/mosuserver/domain/caffeine/repository/BlockedIpHistoryLogJpaJpaRepository.java b/src/main/java/life/mosu/mosuserver/domain/caffeine/repository/BlockedIpHistoryLogJpaJpaRepository.java new file mode 100644 index 00000000..577ca954 --- /dev/null +++ b/src/main/java/life/mosu/mosuserver/domain/caffeine/repository/BlockedIpHistoryLogJpaJpaRepository.java @@ -0,0 +1,9 @@ +package life.mosu.mosuserver.domain.caffeine.repository; + +import life.mosu.mosuserver.domain.caffeine.entity.BlockedIpHistoryLogJpaEntity; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface BlockedIpHistoryLogJpaJpaRepository extends + JpaRepository, BlockedIpHistoryLogJpaRepositoryCustom { + +} diff --git a/src/main/java/life/mosu/mosuserver/domain/caffeine/repository/BlockedIpHistoryLogJpaJpaRepositoryImpl.java b/src/main/java/life/mosu/mosuserver/domain/caffeine/repository/BlockedIpHistoryLogJpaJpaRepositoryImpl.java new file mode 100644 index 00000000..4ad01c8f --- /dev/null +++ b/src/main/java/life/mosu/mosuserver/domain/caffeine/repository/BlockedIpHistoryLogJpaJpaRepositoryImpl.java @@ -0,0 +1,32 @@ +package life.mosu.mosuserver.domain.caffeine.repository; + +import java.sql.Timestamp; +import java.util.List; +import life.mosu.mosuserver.domain.caffeine.entity.BlockedIpHistoryLogJpaEntity; +import lombok.RequiredArgsConstructor; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Repository; + +@Repository +@RequiredArgsConstructor +public class BlockedIpHistoryLogJpaJpaRepositoryImpl implements + BlockedIpHistoryLogJpaRepositoryCustom { + + private final JdbcTemplate jdbcTemplate; + + @Override + public void saveAllUsingBatch(List entities){ + String sql = """ + INSERT INTO blocked_ip_history_log (ip, penalty_level, blocked_at) + VALUES (?, ?, ?) + """; + + jdbcTemplate.batchUpdate(sql, entities, 500, (ps, entity) -> { + ps.setString(1, entity.getIp()); + ps.setString(2, String.valueOf(entity.getPenaltyLevel())); + ps.setTimestamp(3, Timestamp.valueOf(entity.getBlockedAt())); + }); + } + + +} diff --git a/src/main/java/life/mosu/mosuserver/domain/caffeine/repository/BlockedIpHistoryLogJpaRepositoryCustom.java b/src/main/java/life/mosu/mosuserver/domain/caffeine/repository/BlockedIpHistoryLogJpaRepositoryCustom.java new file mode 100644 index 00000000..bffa765c --- /dev/null +++ b/src/main/java/life/mosu/mosuserver/domain/caffeine/repository/BlockedIpHistoryLogJpaRepositoryCustom.java @@ -0,0 +1,8 @@ +package life.mosu.mosuserver.domain.caffeine.repository; + +import java.util.List; +import life.mosu.mosuserver.domain.caffeine.entity.BlockedIpHistoryLogJpaEntity; + +public interface BlockedIpHistoryLogJpaRepositoryCustom { + void saveAllUsingBatch(List logs); +} From 1ccfd9836eb9dc53788544e23d7bc2705080fd1b Mon Sep 17 00:00:00 2001 From: jbh010204 Date: Thu, 7 Aug 2025 21:47:09 +0900 Subject: [PATCH 08/43] =?UTF-8?q?mosu-240=20feat:=20=EB=B0=B0=EC=B9=98=20?= =?UTF-8?q?=EC=B2=98=EB=A6=AC=EB=A5=BC=20=EC=9C=84=ED=95=9C=20Achiever=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../caffeine/BlockedIpBatchAchiever.java | 67 +++++++++++++++++++ .../caffeine/dto/BlockedIpHistory.java} | 14 ++-- .../global/config/CaffeineCacheConfig.java | 8 +-- .../global/filter/IpRateLimitingFilter.java | 13 ++-- 4 files changed, 84 insertions(+), 18 deletions(-) create mode 100644 src/main/java/life/mosu/mosuserver/application/caffeine/BlockedIpBatchAchiever.java rename src/main/java/life/mosu/mosuserver/{global/filter/dto/BlockedHistory.java => domain/caffeine/dto/BlockedIpHistory.java} (64%) diff --git a/src/main/java/life/mosu/mosuserver/application/caffeine/BlockedIpBatchAchiever.java b/src/main/java/life/mosu/mosuserver/application/caffeine/BlockedIpBatchAchiever.java new file mode 100644 index 00000000..d2d248e7 --- /dev/null +++ b/src/main/java/life/mosu/mosuserver/application/caffeine/BlockedIpBatchAchiever.java @@ -0,0 +1,67 @@ +package life.mosu.mosuserver.application.caffeine; + +import com.github.benmanes.caffeine.cache.Cache; +import java.time.Duration; +import java.util.List; +import java.util.Map; +import life.mosu.mosuserver.global.filter.TimePenalty; +import life.mosu.mosuserver.domain.caffeine.dto.BlockedIpHistory; +import life.mosu.mosuserver.domain.caffeine.entity.BlockedIpHistoryLogJpaEntity; +import life.mosu.mosuserver.domain.caffeine.repository.BlockedIpHistoryLogJpaJpaRepository; +import life.mosu.mosuserver.global.support.DomainArchiver; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +@RequiredArgsConstructor +public class BlockedIpBatchAchiever implements DomainArchiver { + + private final static Duration DURATION_HOURS_STANDARD = Duration.ofHours(1); + private final static int BATCH_SIZE = 500; + + private final BlockedIpHistoryLogJpaJpaRepository blockedIpHistoryLogJpaRepository; + private final Cache blockedHistoryCache; + + + @Override + public void archive() { + Map blockedHistoryMap = blockedHistoryCache.asMap(); + + List logs = blockedHistoryMap.values().stream() + .filter(entry -> entry.getPenaltyLevel() == TimePenalty.LEVEL_5) + .map(this::createBlockedHistoryLog) + .toList(); + + if (logs.isEmpty()) { + log.debug("[BlockedIpArchiver] 저장할 로그가 없음."); + return; + } + + for (int i = 0; i < logs.size(); i += BATCH_SIZE) { + int end = Math.min(i + BATCH_SIZE, logs.size()); + List batch = logs.subList(i, end); + + try { + blockedIpHistoryLogJpaRepository.saveAllUsingBatch(batch); + log.debug("[BlockedIpArchiver] 저장 완료: {}개", batch.size()); + } catch (Exception e) { + log.error("[BlockedIpArchiver] 저장 실패: {}~{} 인덱스", i, end, e); + } + } + } + + @Override + public String getName() { + return "blocked-ip"; + } + + private BlockedIpHistoryLogJpaEntity createBlockedHistoryLog(BlockedIpHistory blockedIpHistory) { + return new BlockedIpHistoryLogJpaEntity( + blockedIpHistory.getIp(), + blockedIpHistory.getPenaltyLevel(), + blockedIpHistory.getBlockedAt() + ); + } +} diff --git a/src/main/java/life/mosu/mosuserver/global/filter/dto/BlockedHistory.java b/src/main/java/life/mosu/mosuserver/domain/caffeine/dto/BlockedIpHistory.java similarity index 64% rename from src/main/java/life/mosu/mosuserver/global/filter/dto/BlockedHistory.java rename to src/main/java/life/mosu/mosuserver/domain/caffeine/dto/BlockedIpHistory.java index 350e83dd..2ff24be0 100644 --- a/src/main/java/life/mosu/mosuserver/global/filter/dto/BlockedHistory.java +++ b/src/main/java/life/mosu/mosuserver/domain/caffeine/dto/BlockedIpHistory.java @@ -1,29 +1,31 @@ -package life.mosu.mosuserver.global.filter.dto; +package life.mosu.mosuserver.domain.caffeine.dto; import jakarta.persistence.EnumType; import jakarta.persistence.Enumerated; +import java.time.LocalDateTime; import life.mosu.mosuserver.global.filter.TimePenalty; import lombok.Getter; import lombok.RequiredArgsConstructor; @Getter @RequiredArgsConstructor -public class BlockedHistory { +public class BlockedIpHistory { private String ip; @Enumerated(EnumType.STRING) private TimePenalty penaltyLevel; - private long blockedAt; + + private LocalDateTime blockedAt; public void updateHistory(TimePenalty penaltyLevel) { this.penaltyLevel = penaltyLevel; - this.blockedAt = System.currentTimeMillis(); + this.blockedAt = LocalDateTime.now(); } - public BlockedHistory (String ip) { + public BlockedIpHistory(String ip) { this.ip = ip; this.penaltyLevel = TimePenalty.LEVEL_0; - this.blockedAt = System.currentTimeMillis(); + this.blockedAt = LocalDateTime.now(); } } diff --git a/src/main/java/life/mosu/mosuserver/global/config/CaffeineCacheConfig.java b/src/main/java/life/mosu/mosuserver/global/config/CaffeineCacheConfig.java index e9b8bdf1..627b9545 100644 --- a/src/main/java/life/mosu/mosuserver/global/config/CaffeineCacheConfig.java +++ b/src/main/java/life/mosu/mosuserver/global/config/CaffeineCacheConfig.java @@ -5,9 +5,9 @@ import com.github.benmanes.caffeine.cache.Expiry; import com.github.benmanes.caffeine.cache.LoadingCache; import java.util.concurrent.TimeUnit; -import life.mosu.mosuserver.global.filter.caffeine.dto.BlockedHistory; -import life.mosu.mosuserver.global.filter.caffeine.dto.BlockedIp; -import life.mosu.mosuserver.global.filter.caffeine.dto.RequestCounter; +import life.mosu.mosuserver.domain.caffeine.dto.BlockedIpHistory; +import life.mosu.mosuserver.domain.caffeine.dto.BlockedIp; +import life.mosu.mosuserver.domain.caffeine.dto.RequestCounter; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @@ -23,7 +23,7 @@ public Cache ipRequestCountsCache(IpRateLimitingProperti } @Bean - public Cache blockedHistoryCache() { + public Cache blockedHistoryCache() { return Caffeine.newBuilder() .expireAfterWrite(1, TimeUnit.DAYS) .recordStats() diff --git a/src/main/java/life/mosu/mosuserver/global/filter/IpRateLimitingFilter.java b/src/main/java/life/mosu/mosuserver/global/filter/IpRateLimitingFilter.java index 7fa6a413..e04d5b28 100644 --- a/src/main/java/life/mosu/mosuserver/global/filter/IpRateLimitingFilter.java +++ b/src/main/java/life/mosu/mosuserver/global/filter/IpRateLimitingFilter.java @@ -3,22 +3,19 @@ import static life.mosu.mosuserver.global.util.IpUtil.getClientIp; import com.github.benmanes.caffeine.cache.Cache; -import com.github.benmanes.caffeine.cache.Caffeine; -import com.github.benmanes.caffeine.cache.Expiry; import com.github.benmanes.caffeine.cache.LoadingCache; import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import java.io.IOException; -import java.util.concurrent.TimeUnit; import life.mosu.mosuserver.global.config.IpRateLimitingProperties; import life.mosu.mosuserver.global.exception.CustomRuntimeException; import life.mosu.mosuserver.global.exception.ErrorCode; -import life.mosu.mosuserver.global.filter.caffeine.dto.BlockedHistory; -import life.mosu.mosuserver.global.filter.caffeine.dto.BlockedIp; +import life.mosu.mosuserver.domain.caffeine.dto.BlockedIpHistory; +import life.mosu.mosuserver.domain.caffeine.dto.BlockedIp; -import life.mosu.mosuserver.global.filter.caffeine.dto.RequestCounter; +import life.mosu.mosuserver.domain.caffeine.dto.RequestCounter; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; @@ -31,7 +28,7 @@ public class IpRateLimitingFilter extends OncePerRequestFilter { private final IpRateLimitingProperties ipRateLimitingProperties; private final Cache ipRequestCountsCache; - private final Cache blockedHistoryCache; + private final Cache blockedHistoryCache; private final LoadingCache blockedIpCache; @@ -72,7 +69,7 @@ private boolean isOverPerMaxRequest(RequestCounter counter) { } private void handleBlockedIp(String ip) { - BlockedHistory history = blockedHistoryCache.get(ip, k -> new BlockedHistory(ip)); + BlockedIpHistory history = blockedHistoryCache.get(ip, k -> new BlockedIpHistory(ip)); TimePenalty nextPenaltyLevel = history.getPenaltyLevel().nextLevel(); history.updateHistory(nextPenaltyLevel); From ece06eddf3ac358049bbb326875826c887b9f5eb Mon Sep 17 00:00:00 2001 From: jbh010204 Date: Thu, 7 Aug 2025 21:48:04 +0900 Subject: [PATCH 09/43] =?UTF-8?q?mosu-240=20feat:=20=ED=8F=B4=EB=8D=94=20?= =?UTF-8?q?=EA=B5=AC=EC=A1=B0=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../caffeine}/dto/BlockedIp.java | 2 +- .../caffeine}/dto/RequestCounter.java | 2 +- .../BlockedHistoryLogJpaRepository.java | 8 ------- .../dto/BlockedHistoryLogJpaEntity.java | 23 ------------------- 4 files changed, 2 insertions(+), 33 deletions(-) rename src/main/java/life/mosu/mosuserver/{global/filter => domain/caffeine}/dto/BlockedIp.java (87%) rename src/main/java/life/mosu/mosuserver/{global/filter => domain/caffeine}/dto/RequestCounter.java (73%) delete mode 100644 src/main/java/life/mosu/mosuserver/global/filter/caffeine/BlockedHistoryLogJpaRepository.java delete mode 100644 src/main/java/life/mosu/mosuserver/global/filter/caffeine/dto/BlockedHistoryLogJpaEntity.java diff --git a/src/main/java/life/mosu/mosuserver/global/filter/dto/BlockedIp.java b/src/main/java/life/mosu/mosuserver/domain/caffeine/dto/BlockedIp.java similarity index 87% rename from src/main/java/life/mosu/mosuserver/global/filter/dto/BlockedIp.java rename to src/main/java/life/mosu/mosuserver/domain/caffeine/dto/BlockedIp.java index fcf8e306..0d6d3540 100644 --- a/src/main/java/life/mosu/mosuserver/global/filter/dto/BlockedIp.java +++ b/src/main/java/life/mosu/mosuserver/domain/caffeine/dto/BlockedIp.java @@ -1,4 +1,4 @@ -package life.mosu.mosuserver.global.filter.dto; +package life.mosu.mosuserver.domain.caffeine.dto; import java.time.Duration; import life.mosu.mosuserver.global.filter.TimePenalty; diff --git a/src/main/java/life/mosu/mosuserver/global/filter/dto/RequestCounter.java b/src/main/java/life/mosu/mosuserver/domain/caffeine/dto/RequestCounter.java similarity index 73% rename from src/main/java/life/mosu/mosuserver/global/filter/dto/RequestCounter.java rename to src/main/java/life/mosu/mosuserver/domain/caffeine/dto/RequestCounter.java index 6cf619f0..b4e9af19 100644 --- a/src/main/java/life/mosu/mosuserver/global/filter/dto/RequestCounter.java +++ b/src/main/java/life/mosu/mosuserver/domain/caffeine/dto/RequestCounter.java @@ -1,4 +1,4 @@ -package life.mosu.mosuserver.global.filter.dto; +package life.mosu.mosuserver.domain.caffeine.dto; import lombok.Getter; diff --git a/src/main/java/life/mosu/mosuserver/global/filter/caffeine/BlockedHistoryLogJpaRepository.java b/src/main/java/life/mosu/mosuserver/global/filter/caffeine/BlockedHistoryLogJpaRepository.java deleted file mode 100644 index a01c340f..00000000 --- a/src/main/java/life/mosu/mosuserver/global/filter/caffeine/BlockedHistoryLogJpaRepository.java +++ /dev/null @@ -1,8 +0,0 @@ -package life.mosu.mosuserver.global.filter.caffeine; - -import life.mosu.mosuserver.global.filter.caffeine.dto.BlockedHistoryLogJpaEntity; -import org.springframework.data.jpa.repository.JpaRepository; - -public interface BlockedHistoryLogJpaRepository extends JpaRepository { - -} diff --git a/src/main/java/life/mosu/mosuserver/global/filter/caffeine/dto/BlockedHistoryLogJpaEntity.java b/src/main/java/life/mosu/mosuserver/global/filter/caffeine/dto/BlockedHistoryLogJpaEntity.java deleted file mode 100644 index 68f285a1..00000000 --- a/src/main/java/life/mosu/mosuserver/global/filter/caffeine/dto/BlockedHistoryLogJpaEntity.java +++ /dev/null @@ -1,23 +0,0 @@ -package life.mosu.mosuserver.global.filter.caffeine.dto; - -import jakarta.persistence.Entity; -import jakarta.persistence.EnumType; -import jakarta.persistence.Enumerated; -import jakarta.persistence.Id; -import jakarta.persistence.Table; -import life.mosu.mosuserver.global.filter.TimePenalty; - -@Table(name = "ip_blocked_history_log") -@Entity -public class BlockedHistoryLogJpaEntity { - - @Id - private Long id; - - private String ip; - - @Enumerated(EnumType.STRING) - private TimePenalty penaltyLevel; - - private long blockedAt; -} From 5bc3998348d354532207a117279695222c4d9db1 Mon Sep 17 00:00:00 2001 From: jbh010204 Date: Thu, 7 Aug 2025 22:14:26 +0900 Subject: [PATCH 10/43] =?UTF-8?q?mosu-240=20refactor:=20=EB=A6=AC=EB=B7=B0?= =?UTF-8?q?=20=EB=82=B4=EC=9A=A9=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../life/mosu/mosuserver/domain/caffeine/dto/BlockedIp.java | 2 +- .../mosu/mosuserver/domain/caffeine/dto/BlockedIpHistory.java | 1 + .../mosu/mosuserver/domain/caffeine/dto/RequestCounter.java | 2 +- .../mosuserver/global/config/IpRateLimitingProperties.java | 4 +--- .../mosu/mosuserver/global/filter/IpRateLimitingFilter.java | 1 + .../java/life/mosu/mosuserver/global/filter/TimePenalty.java | 1 - 6 files changed, 5 insertions(+), 6 deletions(-) diff --git a/src/main/java/life/mosu/mosuserver/domain/caffeine/dto/BlockedIp.java b/src/main/java/life/mosu/mosuserver/domain/caffeine/dto/BlockedIp.java index 0d6d3540..0c2d59c4 100644 --- a/src/main/java/life/mosu/mosuserver/domain/caffeine/dto/BlockedIp.java +++ b/src/main/java/life/mosu/mosuserver/domain/caffeine/dto/BlockedIp.java @@ -6,7 +6,7 @@ @Getter public class BlockedIp { - TimePenalty penaltyLevel; + private final TimePenalty penaltyLevel; public BlockedIp(TimePenalty penaltyLevel) { this.penaltyLevel = penaltyLevel; diff --git a/src/main/java/life/mosu/mosuserver/domain/caffeine/dto/BlockedIpHistory.java b/src/main/java/life/mosu/mosuserver/domain/caffeine/dto/BlockedIpHistory.java index 2ff24be0..59b71df2 100644 --- a/src/main/java/life/mosu/mosuserver/domain/caffeine/dto/BlockedIpHistory.java +++ b/src/main/java/life/mosu/mosuserver/domain/caffeine/dto/BlockedIpHistory.java @@ -10,6 +10,7 @@ @Getter @RequiredArgsConstructor public class BlockedIpHistory { + private String ip; @Enumerated(EnumType.STRING) diff --git a/src/main/java/life/mosu/mosuserver/domain/caffeine/dto/RequestCounter.java b/src/main/java/life/mosu/mosuserver/domain/caffeine/dto/RequestCounter.java index b4e9af19..7e852423 100644 --- a/src/main/java/life/mosu/mosuserver/domain/caffeine/dto/RequestCounter.java +++ b/src/main/java/life/mosu/mosuserver/domain/caffeine/dto/RequestCounter.java @@ -4,7 +4,7 @@ @Getter public class RequestCounter { - int count = 0; + private int count = 0; public void increment() { count++; diff --git a/src/main/java/life/mosu/mosuserver/global/config/IpRateLimitingProperties.java b/src/main/java/life/mosu/mosuserver/global/config/IpRateLimitingProperties.java index 69720fd2..fce56cc7 100644 --- a/src/main/java/life/mosu/mosuserver/global/config/IpRateLimitingProperties.java +++ b/src/main/java/life/mosu/mosuserver/global/config/IpRateLimitingProperties.java @@ -1,7 +1,6 @@ package life.mosu.mosuserver.global.config; import jakarta.validation.constraints.Min; -import life.mosu.mosuserver.global.filter.TimePenalty; import lombok.Data; import org.springframework.boot.context.properties.ConfigurationProperties; import org.springframework.stereotype.Component; @@ -13,11 +12,10 @@ @Validated public class IpRateLimitingProperties { - private boolean enabled = true; + private boolean enabled = false; @Min(1) private int maxRequestsPerMinute; @Min(1) private long timeWindowMs; - private TimePenalty timePenalty; } diff --git a/src/main/java/life/mosu/mosuserver/global/filter/IpRateLimitingFilter.java b/src/main/java/life/mosu/mosuserver/global/filter/IpRateLimitingFilter.java index e04d5b28..49f1f3bb 100644 --- a/src/main/java/life/mosu/mosuserver/global/filter/IpRateLimitingFilter.java +++ b/src/main/java/life/mosu/mosuserver/global/filter/IpRateLimitingFilter.java @@ -77,6 +77,7 @@ private void handleBlockedIp(String ip) { blockedIpCache.put(ip, new BlockedIp(nextPenaltyLevel)); log.warn("IP 차단: {}, 차단 레벨: {})", ip, nextPenaltyLevel); + throw new CustomRuntimeException(ErrorCode.TOO_MANY_REQUESTS); } private void isAlreadyBlocked(String requestedIp) { diff --git a/src/main/java/life/mosu/mosuserver/global/filter/TimePenalty.java b/src/main/java/life/mosu/mosuserver/global/filter/TimePenalty.java index 5f9a51f1..9a36da15 100644 --- a/src/main/java/life/mosu/mosuserver/global/filter/TimePenalty.java +++ b/src/main/java/life/mosu/mosuserver/global/filter/TimePenalty.java @@ -1,7 +1,6 @@ package life.mosu.mosuserver.global.filter; import java.time.Duration; -import java.util.concurrent.TimeUnit; import lombok.Getter; import lombok.RequiredArgsConstructor; From be9b81cf3859b979ab0c0681b39951b17088f10a Mon Sep 17 00:00:00 2001 From: jbh010204 Date: Thu, 7 Aug 2025 22:35:52 +0900 Subject: [PATCH 11/43] =?UTF-8?q?mosu-246=20refactor:=20=EA=B3=B5=EC=A7=80?= =?UTF-8?q?=20=EB=AA=A9=EB=A1=9D=20=EC=A1=B0=ED=9A=8C=EC=8B=9C=20=ED=8C=8C?= =?UTF-8?q?=EC=9D=BC=20=EC=B2=A8=EB=B6=80=20x?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../presentation/notice/dto/NoticeResponse.java | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/src/main/java/life/mosu/mosuserver/presentation/notice/dto/NoticeResponse.java b/src/main/java/life/mosu/mosuserver/presentation/notice/dto/NoticeResponse.java index 965a5989..7cd77fca 100644 --- a/src/main/java/life/mosu/mosuserver/presentation/notice/dto/NoticeResponse.java +++ b/src/main/java/life/mosu/mosuserver/presentation/notice/dto/NoticeResponse.java @@ -20,8 +20,6 @@ public record NoticeResponse( @Schema(description = "작성일시 (yyyy-MM-dd)", example = "2025-07-08") String createdAt -// @Schema(description = "첨부파일 목록") -// List attachments ) { public static NoticeResponse of(NoticeJpaEntity notice) { @@ -31,18 +29,6 @@ public static NoticeResponse of(NoticeJpaEntity notice) { notice.getContent(), notice.getAuthor(), notice.getCreatedAt() -// attachments ); } - -// public record AttachmentResponse( -// -// @Schema(description = "파일 이름", example = "service_guide.pdf") -// String fileName, -// -// @Schema(description = "S3 Presigned URL", example = "https://bucket.s3.amazonaws.com/.../service_guide.pdf") -// String url -// ) { -// -// } } \ No newline at end of file From b3421cdba379980ff75e91544635eca447c8c74c Mon Sep 17 00:00:00 2001 From: jbh010204 Date: Thu, 7 Aug 2025 22:50:25 +0900 Subject: [PATCH 12/43] =?UTF-8?q?mosu-246=20refactor:=20public=20URL?= =?UTF-8?q?=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../notice/NoticeAttachmentService.java | 15 +-------------- .../application/notice/NoticeService.java | 8 ++++---- 2 files changed, 5 insertions(+), 18 deletions(-) diff --git a/src/main/java/life/mosu/mosuserver/application/notice/NoticeAttachmentService.java b/src/main/java/life/mosu/mosuserver/application/notice/NoticeAttachmentService.java index 4319be53..df9e2bf8 100644 --- a/src/main/java/life/mosu/mosuserver/application/notice/NoticeAttachmentService.java +++ b/src/main/java/life/mosu/mosuserver/application/notice/NoticeAttachmentService.java @@ -40,19 +40,6 @@ public void deleteAttachment(NoticeJpaEntity entity) { noticeAttachmentJpaRepository.deleteAll(attachments); } -// public List toAttachmentResponses(NoticeJpaEntity notice) { -// -// List attachments = noticeAttachmentJpaRepository.findAllByNoticeId( -// notice.getId()); -// -// return attachments.stream() -// .map(attachment -> new NoticeResponse.AttachmentResponse( -// attachment.getFileName(), -// fileUrl(attachment.getS3Key()) -// )) -// .toList(); -// } - public List toDetailAttResponses( NoticeJpaEntity notice) { @@ -70,7 +57,7 @@ public List toDetailAttResponses( } private String fileUrl(String s3Key) { - return s3Service.getPreSignedUrl(s3Key); + return s3Service.getPublicUrl(s3Key); } } diff --git a/src/main/java/life/mosu/mosuserver/application/notice/NoticeService.java b/src/main/java/life/mosu/mosuserver/application/notice/NoticeService.java index 53bb0e93..287fe6c8 100644 --- a/src/main/java/life/mosu/mosuserver/application/notice/NoticeService.java +++ b/src/main/java/life/mosu/mosuserver/application/notice/NoticeService.java @@ -45,20 +45,20 @@ public List getNotices(int page, int size) { @Transactional(readOnly = true, propagation = Propagation.SUPPORTS) public NoticeDetailResponse getNoticeDetail(Long noticeId) { - NoticeJpaEntity notice = getNoticeOrThrow(noticeId); + NoticeJpaEntity notice = getNotice(noticeId); return toNoticeDetailResponse(notice); } @Transactional public void deleteNotice(Long noticeId) { - NoticeJpaEntity noticeEntity = getNoticeOrThrow(noticeId); + NoticeJpaEntity noticeEntity = getNotice(noticeId); noticeJpaRepository.delete(noticeEntity); } @Transactional public void updateNotice(Long noticeId, NoticeUpdateRequest request) { - NoticeJpaEntity noticeEntity = getNoticeOrThrow(noticeId); + NoticeJpaEntity noticeEntity = getNotice(noticeId); noticeEntity.update(request.title(), request.content(), request.author()); attachmentService.deleteAttachment(noticeEntity); @@ -77,7 +77,7 @@ private NoticeDetailResponse toNoticeDetailResponse(NoticeJpaEntity notice) { ); } - private NoticeJpaEntity getNoticeOrThrow(Long noticeId) { + private NoticeJpaEntity getNotice(Long noticeId) { return noticeJpaRepository.findById(noticeId) .orElseThrow(() -> new CustomRuntimeException(ErrorCode.NOTICE_NOT_FOUND)); } From 0f3198dbe3681c9fbd09a54ff98ed0330359acc9 Mon Sep 17 00:00:00 2001 From: jbh010204 Date: Fri, 8 Aug 2025 02:45:02 +0900 Subject: [PATCH 13/43] MOSU-246 refactor:: integrate user details into notice creation and update processes --- .../application/notice/NoticeService.java | 9 +++++---- .../presentation/notice/NoticeController.java | 9 +++++++-- .../presentation/notice/NoticeControllerDocs.java | 10 +++++++++- .../notice/dto/NoticeCreateRequest.java | 13 ++++--------- .../notice/dto/NoticeUpdateRequest.java | 7 ------- 5 files changed, 25 insertions(+), 23 deletions(-) diff --git a/src/main/java/life/mosu/mosuserver/application/notice/NoticeService.java b/src/main/java/life/mosu/mosuserver/application/notice/NoticeService.java index 287fe6c8..0cfa48db 100644 --- a/src/main/java/life/mosu/mosuserver/application/notice/NoticeService.java +++ b/src/main/java/life/mosu/mosuserver/application/notice/NoticeService.java @@ -3,6 +3,7 @@ import java.util.List; import life.mosu.mosuserver.domain.notice.entity.NoticeJpaEntity; import life.mosu.mosuserver.domain.notice.repository.NoticeJpaRepository; +import life.mosu.mosuserver.domain.user.entity.UserJpaEntity; import life.mosu.mosuserver.global.exception.CustomRuntimeException; import life.mosu.mosuserver.global.exception.ErrorCode; import life.mosu.mosuserver.presentation.notice.dto.NoticeCreateRequest; @@ -28,8 +29,8 @@ public class NoticeService { private final NoticeAttachmentService attachmentService; @Transactional - public void createNotice(NoticeCreateRequest request) { - NoticeJpaEntity noticeEntity = noticeJpaRepository.save(request.toEntity()); + public void createNotice(NoticeCreateRequest request, UserJpaEntity user) { + NoticeJpaEntity noticeEntity = noticeJpaRepository.save(request.toEntity(user)); attachmentService.createAttachment(request.attachments(), noticeEntity); } @@ -57,10 +58,10 @@ public void deleteNotice(Long noticeId) { } @Transactional - public void updateNotice(Long noticeId, NoticeUpdateRequest request) { + public void updateNotice(Long noticeId, NoticeUpdateRequest request, UserJpaEntity user) { NoticeJpaEntity noticeEntity = getNotice(noticeId); - noticeEntity.update(request.title(), request.content(), request.author()); + noticeEntity.update(request.title(), request.content(), user.getName()); attachmentService.deleteAttachment(noticeEntity); attachmentService.createAttachment(request.attachments(), noticeEntity); } diff --git a/src/main/java/life/mosu/mosuserver/presentation/notice/NoticeController.java b/src/main/java/life/mosu/mosuserver/presentation/notice/NoticeController.java index a9207073..f863fe1d 100644 --- a/src/main/java/life/mosu/mosuserver/presentation/notice/NoticeController.java +++ b/src/main/java/life/mosu/mosuserver/presentation/notice/NoticeController.java @@ -2,7 +2,9 @@ import jakarta.validation.Valid; import java.util.List; +import life.mosu.mosuserver.application.auth.PrincipalDetails; import life.mosu.mosuserver.application.notice.NoticeService; +import life.mosu.mosuserver.global.annotation.UserId; import life.mosu.mosuserver.global.util.ApiResponseWrapper; import life.mosu.mosuserver.presentation.notice.dto.NoticeCreateRequest; import life.mosu.mosuserver.presentation.notice.dto.NoticeDetailResponse; @@ -12,6 +14,7 @@ import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.security.access.prepost.PreAuthorize; +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; @@ -32,8 +35,9 @@ public class NoticeController implements NoticeControllerDocs { @PostMapping @PreAuthorize("isAuthenticated() and hasRole('ADMIN')") public ResponseEntity> createNotice( + @AuthenticationPrincipal PrincipalDetails principalDetails, @Valid @RequestBody NoticeCreateRequest request) { - noticeService.createNotice(request); + noticeService.createNotice(request, principalDetails.user()); return ResponseEntity.ok(ApiResponseWrapper.success(HttpStatus.CREATED, "게시글 등록 성공")); } @@ -63,10 +67,11 @@ public ResponseEntity> deleteNotice(@PathVariable Long @PutMapping("/{noticeId}") @PreAuthorize("isAuthenticated() and hasRole('ADMIN')") public ResponseEntity> updateNotice( + @AuthenticationPrincipal PrincipalDetails principalDetails, @PathVariable Long noticeId, @Valid @RequestBody NoticeUpdateRequest request ) { - noticeService.updateNotice(noticeId, request); + noticeService.updateNotice(noticeId, request, principalDetails.user()); return ResponseEntity.ok(ApiResponseWrapper.success(HttpStatus.OK, "게시글 수정 성공")); } } \ No newline at end of file diff --git a/src/main/java/life/mosu/mosuserver/presentation/notice/NoticeControllerDocs.java b/src/main/java/life/mosu/mosuserver/presentation/notice/NoticeControllerDocs.java index 2310bc7d..7652e558 100644 --- a/src/main/java/life/mosu/mosuserver/presentation/notice/NoticeControllerDocs.java +++ b/src/main/java/life/mosu/mosuserver/presentation/notice/NoticeControllerDocs.java @@ -10,12 +10,14 @@ import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; import java.util.List; +import life.mosu.mosuserver.application.auth.PrincipalDetails; import life.mosu.mosuserver.global.util.ApiResponseWrapper; import life.mosu.mosuserver.presentation.notice.dto.NoticeCreateRequest; import life.mosu.mosuserver.presentation.notice.dto.NoticeDetailResponse; import life.mosu.mosuserver.presentation.notice.dto.NoticeResponse; import life.mosu.mosuserver.presentation.notice.dto.NoticeUpdateRequest; import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestParam; @@ -28,7 +30,11 @@ public interface NoticeControllerDocs { @ApiResponse(responseCode = "201", description = "공지사항 등록 성공") }) ResponseEntity> createNotice( - @Parameter(description = "공지사항 생성에 필요한 정보") @Valid @RequestBody NoticeCreateRequest request + @Parameter(hidden = true, description = "요청을 보낸 사용자 ID (관리자 권한 필요)") + @AuthenticationPrincipal PrincipalDetails principalDetails, + + @Parameter(description = "공지사항 생성에 필요한 정보") + @Valid @RequestBody NoticeCreateRequest request ); @Operation(summary = "공지사항 목록 조회", description = "공지사항 리스트를 페이징하여 조회합니다.") @@ -67,6 +73,8 @@ ResponseEntity> deleteNotice( @ApiResponse(responseCode = "200", description = "공지사항 수정 성공") }) ResponseEntity> updateNotice( + @Parameter(hidden = true, description = "요청을 보낸 사용자 ID (관리자 권한 필요)") + @AuthenticationPrincipal PrincipalDetails principalDetails, @Parameter(name = "noticeId", description = "수정할 공지사항 ID", in = ParameterIn.PATH) @PathVariable Long noticeId, @Parameter(description = "수정할 공지사항 정보") diff --git a/src/main/java/life/mosu/mosuserver/presentation/notice/dto/NoticeCreateRequest.java b/src/main/java/life/mosu/mosuserver/presentation/notice/dto/NoticeCreateRequest.java index 49ec21dc..0b5e9424 100644 --- a/src/main/java/life/mosu/mosuserver/presentation/notice/dto/NoticeCreateRequest.java +++ b/src/main/java/life/mosu/mosuserver/presentation/notice/dto/NoticeCreateRequest.java @@ -4,6 +4,7 @@ import jakarta.validation.constraints.NotNull; import java.util.List; import life.mosu.mosuserver.domain.notice.entity.NoticeJpaEntity; +import life.mosu.mosuserver.domain.user.entity.UserJpaEntity; import life.mosu.mosuserver.presentation.common.FileRequest; public record NoticeCreateRequest( @@ -14,23 +15,17 @@ public record NoticeCreateRequest( @Schema(description = "공지사항 본문 내용", example = "6월 30일 오전 2시부터 서비스 점검이 진행됩니다.") @NotNull String content, - @Schema(description = "작성자 이름", example = "관리자") - @NotNull String author, - - @Schema(description = "작성자 ID (추후 토큰 기반 자동 추출 예정)", example = "1") - Long userId, - @Schema(description = "첨부파일 리스트 (S3 key 포함)") List attachments ) { - public NoticeJpaEntity toEntity() { + public NoticeJpaEntity toEntity(UserJpaEntity user) { return NoticeJpaEntity.builder() .title(title) .content(content) - .userId(userId) - .author(author) + .userId(user.getId()) + .author(user.getName()) .build(); } } \ No newline at end of file diff --git a/src/main/java/life/mosu/mosuserver/presentation/notice/dto/NoticeUpdateRequest.java b/src/main/java/life/mosu/mosuserver/presentation/notice/dto/NoticeUpdateRequest.java index 1e1959f7..e88ac79a 100644 --- a/src/main/java/life/mosu/mosuserver/presentation/notice/dto/NoticeUpdateRequest.java +++ b/src/main/java/life/mosu/mosuserver/presentation/notice/dto/NoticeUpdateRequest.java @@ -15,13 +15,6 @@ public record NoticeUpdateRequest( @NotNull String content, - @Schema(description = "작성자 이름", example = "관리자") - @NotNull - String author, - - @Schema(description = "작성자 ID (토큰에서 추출 예정)", example = "42") - Long userId, - @Schema(description = "첨부파일 목록") List attachments From 0700fce4076093735f9cbdb996a8d51d832028fe Mon Sep 17 00:00:00 2001 From: jbh010204 Date: Fri, 8 Aug 2025 03:45:10 +0900 Subject: [PATCH 14/43] MOSU-246 refactor: enhance inquiry answer handling with author tracking and attachment updates --- .../InquiryAnswerAttachmentService.java | 10 ++++++++++ .../inquiry/InquiryAnswerService.java | 18 +++++++++--------- .../inquiry/InquiryAttachmentService.java | 9 +++++++++ .../application/inquiry/InquiryService.java | 6 ++---- .../entity/InquiryAnswerJpaEntity.java | 11 +++++++++-- .../inquiry/dto/InquiryAnswerRequest.java | 8 ++++---- 6 files changed, 43 insertions(+), 19 deletions(-) diff --git a/src/main/java/life/mosu/mosuserver/application/inquiry/InquiryAnswerAttachmentService.java b/src/main/java/life/mosu/mosuserver/application/inquiry/InquiryAnswerAttachmentService.java index 0440bc91..378d1d02 100644 --- a/src/main/java/life/mosu/mosuserver/application/inquiry/InquiryAnswerAttachmentService.java +++ b/src/main/java/life/mosu/mosuserver/application/inquiry/InquiryAnswerAttachmentService.java @@ -53,6 +53,15 @@ public List toAttachmentResponse .toList(); } + @Override + public void updateAttachment( + List requests, + InquiryAnswerJpaEntity answerEntity + ) { + deleteAttachment(answerEntity); + createAttachment(requests, answerEntity); + } + private InquiryDetailResponse.AttachmentResponse createAttachResponse( InquiryAnswerAttachmentEntity attachment) { String preSignedUrl = s3Service.getPreSignedUrl(attachment.getS3Key()); @@ -74,4 +83,5 @@ private InquiryDetailResponse.AttachmentDetailResponse createAttachDetailRespons ); } + } \ No newline at end of file diff --git a/src/main/java/life/mosu/mosuserver/application/inquiry/InquiryAnswerService.java b/src/main/java/life/mosu/mosuserver/application/inquiry/InquiryAnswerService.java index bac2eda8..8987e8fa 100644 --- a/src/main/java/life/mosu/mosuserver/application/inquiry/InquiryAnswerService.java +++ b/src/main/java/life/mosu/mosuserver/application/inquiry/InquiryAnswerService.java @@ -4,6 +4,7 @@ import life.mosu.mosuserver.domain.inquiry.repository.InquiryJpaRepository; import life.mosu.mosuserver.domain.inquiryAnswer.entity.InquiryAnswerJpaEntity; import life.mosu.mosuserver.domain.inquiryAnswer.repository.InquiryAnswerJpaRepository; +import life.mosu.mosuserver.domain.user.entity.UserJpaEntity; import life.mosu.mosuserver.global.exception.CustomRuntimeException; import life.mosu.mosuserver.global.exception.ErrorCode; import life.mosu.mosuserver.presentation.inquiry.dto.InquiryAnswerRequest; @@ -28,14 +29,14 @@ public class InquiryAnswerService { private final InquiryAnswerTxService eventTxService; @Transactional - public void createInquiryAnswer(Long postId, InquiryAnswerRequest request) { + public void createInquiryAnswer(Long postId, InquiryAnswerRequest request, UserJpaEntity user) { isAnswerAlreadyRegister(postId); InquiryJpaEntity inquiryEntity = getInquiry(postId); Long userId = inquiryEntity.getUserId(); try { InquiryAnswerJpaEntity answerEntity = inquiryAnswerJpaRepository.save( - request.toEntity(postId)); + request.toEntity(postId, user)); answerAttachmentService.createAttachment(request.attachments(), answerEntity); inquiryEntity.updateStatusToComplete(); @@ -51,12 +52,12 @@ public void createInquiryAnswer(Long postId, InquiryAnswerRequest request) { @Transactional public void deleteInquiryAnswer(Long postId) { InquiryJpaEntity inquiryEntity = getInquiry(postId); - InquiryAnswerJpaEntity answerEntity = inquiryAnswerJpaRepository.findByInquiryId(postId) .orElseThrow(() -> new CustomRuntimeException(ErrorCode.INQUIRY_ANSWER_NOT_FOUND)); inquiryAnswerJpaRepository.delete(answerEntity); inquiryEntity.updateStatusToPending(); + answerAttachmentService.deleteAttachment(answerEntity); } @Transactional(readOnly = true, propagation = Propagation.SUPPORTS) @@ -71,17 +72,14 @@ public InquiryDetailResponse.InquiryAnswerDetailResponse getInquiryAnswerDetail( } @Transactional - public void updateInquiryAnswer(Long postId, InquiryAnswerUpdateRequest request) { - InquiryJpaEntity inquiryEntity = getInquiry(postId); - + public void updateInquiryAnswer(Long postId, InquiryAnswerUpdateRequest request, UserJpaEntity user) { InquiryAnswerJpaEntity answerEntity = inquiryAnswerJpaRepository.findByInquiryId(postId) .orElseThrow(() -> new CustomRuntimeException(ErrorCode.INQUIRY_ANSWER_NOT_FOUND)); - answerEntity.update(request.title(), request.content()); + answerEntity.update(request.title(), request.content(), user.getName()); inquiryAnswerJpaRepository.save(answerEntity); - answerAttachmentService.deleteAttachment(answerEntity); - answerAttachmentService.createAttachment(request.attachments(), answerEntity); + answerAttachmentService.updateAttachment(request.attachments(), answerEntity); } private InquiryJpaEntity getInquiry(Long postId) { @@ -96,4 +94,6 @@ private void isAnswerAlreadyRegister(Long postId) { } + + } diff --git a/src/main/java/life/mosu/mosuserver/application/inquiry/InquiryAttachmentService.java b/src/main/java/life/mosu/mosuserver/application/inquiry/InquiryAttachmentService.java index 0298ff05..7939d7d9 100644 --- a/src/main/java/life/mosu/mosuserver/application/inquiry/InquiryAttachmentService.java +++ b/src/main/java/life/mosu/mosuserver/application/inquiry/InquiryAttachmentService.java @@ -38,6 +38,15 @@ public void deleteAttachment(InquiryJpaEntity entity) { inquiryAttachmentJpaRepository.deleteAll(attachments); } + @Override + public void updateAttachment( + List requests, + InquiryJpaEntity inquiryEntity + ) { + deleteAttachment(inquiryEntity); + createAttachment(requests, inquiryEntity); + } + public List toAttachmentResponses( InquiryJpaEntity inquiry) { diff --git a/src/main/java/life/mosu/mosuserver/application/inquiry/InquiryService.java b/src/main/java/life/mosu/mosuserver/application/inquiry/InquiryService.java index d98d6452..965f5f42 100644 --- a/src/main/java/life/mosu/mosuserver/application/inquiry/InquiryService.java +++ b/src/main/java/life/mosu/mosuserver/application/inquiry/InquiryService.java @@ -68,11 +68,9 @@ public void deleteInquiry(UserJpaEntity user, Long postId) { InquiryJpaEntity inquiry = getInquiry(postId); hasPermission(inquiry.getUserId(), user); - inquiryAnswerJpaRepository.findByInquiryId(postId).ifPresent(answer -> { - inquiryAnswerService.deleteInquiryAnswer(postId); - }); + inquiryAnswerService.deleteInquiryAnswer(postId); -// inquiryAttachmentService.deleteAttachment(inquiry); + inquiryAttachmentService.deleteAttachment(inquiry); inquiryJpaRepository.delete(inquiry); } diff --git a/src/main/java/life/mosu/mosuserver/domain/inquiryAnswer/entity/InquiryAnswerJpaEntity.java b/src/main/java/life/mosu/mosuserver/domain/inquiryAnswer/entity/InquiryAnswerJpaEntity.java index 3e7f08b2..ec3118c9 100644 --- a/src/main/java/life/mosu/mosuserver/domain/inquiryAnswer/entity/InquiryAnswerJpaEntity.java +++ b/src/main/java/life/mosu/mosuserver/domain/inquiryAnswer/entity/InquiryAnswerJpaEntity.java @@ -34,6 +34,9 @@ public class InquiryAnswerJpaEntity extends BaseTimeEntity { @Column(name = "inquiry_id", nullable = false) private Long inquiryId; + @Column(name = "author", nullable = false) + private String author; + @Column(name = "user_id", nullable = false) private Long userId; @@ -42,16 +45,20 @@ public InquiryAnswerJpaEntity( final String title, final String content, final Long inquiryId, - final Long userId + final Long userId, + final String author + ) { this.title = title; this.content = content; this.inquiryId = inquiryId; this.userId = userId; + this.author = author; } - public void update(final String title, final String content) { + public void update(final String title, final String content, final String author) { this.title = title; this.content = content; + this.author = author; } } diff --git a/src/main/java/life/mosu/mosuserver/presentation/inquiry/dto/InquiryAnswerRequest.java b/src/main/java/life/mosu/mosuserver/presentation/inquiry/dto/InquiryAnswerRequest.java index 01d5d075..ca422a90 100644 --- a/src/main/java/life/mosu/mosuserver/presentation/inquiry/dto/InquiryAnswerRequest.java +++ b/src/main/java/life/mosu/mosuserver/presentation/inquiry/dto/InquiryAnswerRequest.java @@ -4,6 +4,7 @@ import jakarta.validation.constraints.NotNull; import java.util.List; import life.mosu.mosuserver.domain.inquiryAnswer.entity.InquiryAnswerJpaEntity; +import life.mosu.mosuserver.domain.user.entity.UserJpaEntity; import life.mosu.mosuserver.presentation.common.FileRequest; public record InquiryAnswerRequest( @@ -11,17 +12,16 @@ public record InquiryAnswerRequest( @NotNull String title, @Schema(description = "문의 내용", example = "포인트는 어떻게 사용하나요?") @NotNull String content, - @Schema(description = "작성자 ID 추후 토큰에서 추출하도록 변경 예정입니다.", example = "12") - Long userId, List attachments ) { - public InquiryAnswerJpaEntity toEntity(Long postId) { + public InquiryAnswerJpaEntity toEntity(Long postId, UserJpaEntity user) { return InquiryAnswerJpaEntity.builder() .inquiryId(postId) .title(title) .content(content) - .userId(userId) + .userId(user.getId()) + .author(user.getName()) .build(); } } \ No newline at end of file From 46a6bac47f18b207dd2702bc63be676a9b5a12bd Mon Sep 17 00:00:00 2001 From: jbh010204 Date: Fri, 8 Aug 2025 03:45:46 +0900 Subject: [PATCH 15/43] MOSU-246 refactor: streamline attachment handling by consolidating delete and create operations into update method --- .../application/notice/NoticeAttachmentService.java | 9 +++++++++ .../mosuserver/application/notice/NoticeService.java | 3 +-- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/src/main/java/life/mosu/mosuserver/application/notice/NoticeAttachmentService.java b/src/main/java/life/mosu/mosuserver/application/notice/NoticeAttachmentService.java index df9e2bf8..d784b7a1 100644 --- a/src/main/java/life/mosu/mosuserver/application/notice/NoticeAttachmentService.java +++ b/src/main/java/life/mosu/mosuserver/application/notice/NoticeAttachmentService.java @@ -40,6 +40,15 @@ public void deleteAttachment(NoticeJpaEntity entity) { noticeAttachmentJpaRepository.deleteAll(attachments); } + @Override + public void updateAttachment( + List requests, + NoticeJpaEntity noticeEntity + ) { + deleteAttachment(noticeEntity); + createAttachment(requests, noticeEntity); + } + public List toDetailAttResponses( NoticeJpaEntity notice) { diff --git a/src/main/java/life/mosu/mosuserver/application/notice/NoticeService.java b/src/main/java/life/mosu/mosuserver/application/notice/NoticeService.java index 0cfa48db..4405792c 100644 --- a/src/main/java/life/mosu/mosuserver/application/notice/NoticeService.java +++ b/src/main/java/life/mosu/mosuserver/application/notice/NoticeService.java @@ -62,8 +62,7 @@ public void updateNotice(Long noticeId, NoticeUpdateRequest request, UserJpaEnti NoticeJpaEntity noticeEntity = getNotice(noticeId); noticeEntity.update(request.title(), request.content(), user.getName()); - attachmentService.deleteAttachment(noticeEntity); - attachmentService.createAttachment(request.attachments(), noticeEntity); + attachmentService.updateAttachment(request.attachments(), noticeEntity); } private NoticeResponse toNoticeResponse(NoticeJpaEntity notice) { From dafcc96dda9e868317a7d3a2a05804ff5724d232 Mon Sep 17 00:00:00 2001 From: jbh010204 Date: Fri, 8 Aug 2025 03:51:15 +0900 Subject: [PATCH 16/43] MOSU-246 refactor: enhance global exception handling with additional type mismatch and resource not found responses --- .../exception/GlobalExceptionHandler.java | 36 +++++++++++++++++-- 1 file changed, 34 insertions(+), 2 deletions(-) diff --git a/src/main/java/life/mosu/mosuserver/global/exception/GlobalExceptionHandler.java b/src/main/java/life/mosu/mosuserver/global/exception/GlobalExceptionHandler.java index c578e565..49bf374c 100644 --- a/src/main/java/life/mosu/mosuserver/global/exception/GlobalExceptionHandler.java +++ b/src/main/java/life/mosu/mosuserver/global/exception/GlobalExceptionHandler.java @@ -1,6 +1,7 @@ package life.mosu.mosuserver.global.exception; import jakarta.persistence.EntityNotFoundException; +import jakarta.servlet.http.HttpServletRequest; import java.util.LinkedHashMap; import java.util.Map; import life.mosu.mosuserver.infra.notify.NotifyClientAdapter; @@ -15,6 +16,8 @@ import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException; +import org.springframework.web.reactive.resource.NoResourceFoundException; @Slf4j @RestControllerAdvice @@ -123,6 +126,31 @@ public ResponseEntity handleHttpMessageNotReadableException( return ResponseEntity.status(HttpStatus.CONFLICT).body(response); } + @ExceptionHandler(NoResourceFoundException.class ) + public ResponseEntity handleNotFound(Exception ex) { + notifyIfNeeded(ex); + + ErrorResponse response = ErrorResponse.builder() + .status(HttpStatus.NOT_FOUND.value()) + .message("요청하신 리소스를 찾을 수 없습니다.") + .errors(ex.getMessage()) + .build(); + + return ResponseEntity.status(HttpStatus.NOT_FOUND).body(response); + } + + @ExceptionHandler(MethodArgumentTypeMismatchException.class) + public ResponseEntity handleTypeMismatch(MethodArgumentTypeMismatchException ex) { + notifyIfNeeded(ex); + ErrorResponse response = ErrorResponse.builder() + .status(HttpStatus.BAD_REQUEST.value()) + .code("TYPE_MISMATCH") + .message("요청 파라미터 타입이 올바르지 않습니다.") + .build(); + + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(response); + } + @ExceptionHandler(Exception.class) public ResponseEntity handleGeneralException(Exception ex) { notifyIfNeeded(ex); @@ -137,13 +165,17 @@ public ResponseEntity handleGeneralException(Exception ex) { } @ExceptionHandler(CustomRuntimeException.class) - public ResponseEntity handleCustomRuntimeException(CustomRuntimeException ex) { + public ResponseEntity handleCustomRuntimeException( + CustomRuntimeException ex, + HttpServletRequest request + ) { notifyIfNeeded(ex); ErrorResponse response = ErrorResponse.builder() .status(ex.getStatus().value()) .message(ex.getMessage()) .code(ex.getCode()) + .path(request.getRequestURI()) .build(); return ResponseEntity.status(ex.getStatus()).body(response); @@ -152,7 +184,7 @@ public ResponseEntity handleCustomRuntimeException(CustomRuntimeE private void notifyIfNeeded(Exception ex) { try { DiscordExceptionNotifyEventRequest request = DiscordExceptionNotifyEventRequest.of( - ex.getCause().toString(), + ex.getCause() != null ? ex.getCause().toString() : "Unknown Cause", ex.getMessage() ); notifier.send(request); From 198373473b794bf1ae69d1013f64af46a6eda9fc Mon Sep 17 00:00:00 2001 From: jbh010204 Date: Fri, 8 Aug 2025 03:51:40 +0900 Subject: [PATCH 17/43] MOSU-246 refactor: enhance inquiry answer methods to include user tracking and update attachment handling --- .../infra/persistence/s3/AttachmentService.java | 2 ++ .../presentation/admin/AdminInquiryController.java | 8 ++++++-- .../admin/docs/AdminInquiryControllerDocs.java | 6 ++++++ 3 files changed, 14 insertions(+), 2 deletions(-) diff --git a/src/main/java/life/mosu/mosuserver/infra/persistence/s3/AttachmentService.java b/src/main/java/life/mosu/mosuserver/infra/persistence/s3/AttachmentService.java index 083b892b..19bca804 100644 --- a/src/main/java/life/mosu/mosuserver/infra/persistence/s3/AttachmentService.java +++ b/src/main/java/life/mosu/mosuserver/infra/persistence/s3/AttachmentService.java @@ -7,4 +7,6 @@ public interface AttachmentService { void createAttachment(List fileRequests, E entity); void deleteAttachment(E entity); + + void updateAttachment(List fileRequests, E entity); } diff --git a/src/main/java/life/mosu/mosuserver/presentation/admin/AdminInquiryController.java b/src/main/java/life/mosu/mosuserver/presentation/admin/AdminInquiryController.java index cb3b7623..8bf7da39 100644 --- a/src/main/java/life/mosu/mosuserver/presentation/admin/AdminInquiryController.java +++ b/src/main/java/life/mosu/mosuserver/presentation/admin/AdminInquiryController.java @@ -1,5 +1,6 @@ package life.mosu.mosuserver.presentation.admin; +import life.mosu.mosuserver.application.auth.PrincipalDetails; import life.mosu.mosuserver.application.inquiry.InquiryAnswerService; import life.mosu.mosuserver.application.inquiry.InquiryService; import life.mosu.mosuserver.domain.inquiry.entity.InquiryStatus; @@ -15,6 +16,7 @@ import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.security.access.prepost.PreAuthorize; +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; @@ -50,20 +52,22 @@ public ResponseEntity>> getInquiryList( @PostMapping("/{postId}/answer") @PreAuthorize("isAuthenticated() and hasRole('ADMIN')") public ResponseEntity> inquiryAnswer( + @AuthenticationPrincipal PrincipalDetails principalDetails, @PathVariable Long postId, @RequestBody InquiryAnswerRequest request ) { - inquiryAnswerService.createInquiryAnswer(postId, request); + inquiryAnswerService.createInquiryAnswer(postId, request, principalDetails.user()); return ResponseEntity.ok(ApiResponseWrapper.success(HttpStatus.OK, "답변 등록 성공")); } @PutMapping("/{postId}/answer") @PreAuthorize("isAuthenticated() and hasRole('ADMIN')") public ResponseEntity> updateInquiryAnswer( + @AuthenticationPrincipal PrincipalDetails principalDetails, @PathVariable Long postId, @RequestBody InquiryAnswerUpdateRequest request ) { - inquiryAnswerService.updateInquiryAnswer(postId, request); + inquiryAnswerService.updateInquiryAnswer(postId, request, principalDetails.user()); return ResponseEntity.ok(ApiResponseWrapper.success(HttpStatus.OK, "답변 수정 성공")); } diff --git a/src/main/java/life/mosu/mosuserver/presentation/admin/docs/AdminInquiryControllerDocs.java b/src/main/java/life/mosu/mosuserver/presentation/admin/docs/AdminInquiryControllerDocs.java index 2985ca47..47a633b4 100644 --- a/src/main/java/life/mosu/mosuserver/presentation/admin/docs/AdminInquiryControllerDocs.java +++ b/src/main/java/life/mosu/mosuserver/presentation/admin/docs/AdminInquiryControllerDocs.java @@ -8,6 +8,7 @@ import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.responses.ApiResponses; import io.swagger.v3.oas.annotations.tags.Tag; +import life.mosu.mosuserver.application.auth.PrincipalDetails; import life.mosu.mosuserver.domain.inquiry.entity.InquiryStatus; import life.mosu.mosuserver.global.util.ApiResponseWrapper; import life.mosu.mosuserver.presentation.inquiry.dto.InquiryAnswerRequest; @@ -16,6 +17,7 @@ import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestParam; @@ -42,6 +44,8 @@ ResponseEntity>> getInquiryList( @ApiResponse(responseCode = "200", description = "답변 등록 성공") }) ResponseEntity> inquiryAnswer( + @Parameter(hidden = true, description = "요청을 보낸 사용자 ID (관리자 권한 필요)") + @AuthenticationPrincipal PrincipalDetails principalDetails, @Parameter(name = "postId", description = "답변을 등록할 문의의 ID", in = ParameterIn.PATH) @PathVariable Long postId, @Parameter(description = "답변 내용") @@ -53,6 +57,8 @@ ResponseEntity> inquiryAnswer( @ApiResponse(responseCode = "200", description = "답변 수정 성공") }) ResponseEntity> updateInquiryAnswer( + @Parameter(hidden = true, description = "요청을 보낸 사용자 ID (관리자 권한 필요)") + @AuthenticationPrincipal PrincipalDetails principalDetails, @Parameter(name = "postId", description = "답변을 수정할 문의의 ID", in = ParameterIn.PATH) @PathVariable Long postId, @Parameter(description = "수정할 답변 내용") From bde3392360442f32cc507ecbaff9cf2187549d74 Mon Sep 17 00:00:00 2001 From: jbh010204 Date: Fri, 8 Aug 2025 03:52:45 +0900 Subject: [PATCH 18/43] refactor: simplify custom runtime exception handling by removing unnecessary request path logging --- .../mosuserver/global/exception/GlobalExceptionHandler.java | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/main/java/life/mosu/mosuserver/global/exception/GlobalExceptionHandler.java b/src/main/java/life/mosu/mosuserver/global/exception/GlobalExceptionHandler.java index 49bf374c..645da67f 100644 --- a/src/main/java/life/mosu/mosuserver/global/exception/GlobalExceptionHandler.java +++ b/src/main/java/life/mosu/mosuserver/global/exception/GlobalExceptionHandler.java @@ -166,8 +166,7 @@ public ResponseEntity handleGeneralException(Exception ex) { @ExceptionHandler(CustomRuntimeException.class) public ResponseEntity handleCustomRuntimeException( - CustomRuntimeException ex, - HttpServletRequest request + CustomRuntimeException ex ) { notifyIfNeeded(ex); @@ -175,7 +174,6 @@ public ResponseEntity handleCustomRuntimeException( .status(ex.getStatus().value()) .message(ex.getMessage()) .code(ex.getCode()) - .path(request.getRequestURI()) .build(); return ResponseEntity.status(ex.getStatus()).body(response); From 722d9c18439dcfe5eb6bdbc269b885b11a142155 Mon Sep 17 00:00:00 2001 From: jbh010204 Date: Fri, 8 Aug 2025 04:15:52 +0900 Subject: [PATCH 19/43] MOSU-246 refactor: update title and content field lengths in inquiry entities and requests --- .../mosuserver/domain/inquiry/entity/InquiryJpaEntity.java | 4 ++-- .../domain/inquiryAnswer/entity/InquiryAnswerJpaEntity.java | 4 ++-- .../presentation/inquiry/dto/InquiryCreateRequest.java | 5 +++++ 3 files changed, 9 insertions(+), 4 deletions(-) diff --git a/src/main/java/life/mosu/mosuserver/domain/inquiry/entity/InquiryJpaEntity.java b/src/main/java/life/mosu/mosuserver/domain/inquiry/entity/InquiryJpaEntity.java index 73ea1319..9d56c56e 100644 --- a/src/main/java/life/mosu/mosuserver/domain/inquiry/entity/InquiryJpaEntity.java +++ b/src/main/java/life/mosu/mosuserver/domain/inquiry/entity/InquiryJpaEntity.java @@ -26,10 +26,10 @@ public class InquiryJpaEntity extends BaseTimeEntity { @Column(name = "inquiry_id", nullable = false) private Long id; - @Column(name = "title", nullable = false) + @Column(name = "title", nullable = false, length = 300) private String title; - @Column(name = "content", nullable = false) + @Column(name = "content", nullable = false, length = 1000) private String content; @Column(name = "user_id", nullable = false) diff --git a/src/main/java/life/mosu/mosuserver/domain/inquiryAnswer/entity/InquiryAnswerJpaEntity.java b/src/main/java/life/mosu/mosuserver/domain/inquiryAnswer/entity/InquiryAnswerJpaEntity.java index ec3118c9..a1120f15 100644 --- a/src/main/java/life/mosu/mosuserver/domain/inquiryAnswer/entity/InquiryAnswerJpaEntity.java +++ b/src/main/java/life/mosu/mosuserver/domain/inquiryAnswer/entity/InquiryAnswerJpaEntity.java @@ -25,10 +25,10 @@ public class InquiryAnswerJpaEntity extends BaseTimeEntity { @Column(name = "inquiry_answer_id", nullable = false) private Long id; - @Column(name = "title", nullable = false, length = 3000) + @Column(name = "title", nullable = false, length = 300) private String title; - @Column(name = "content", nullable = false) + @Column(name = "content", nullable = false, length = 1000) private String content; @Column(name = "inquiry_id", nullable = false) diff --git a/src/main/java/life/mosu/mosuserver/presentation/inquiry/dto/InquiryCreateRequest.java b/src/main/java/life/mosu/mosuserver/presentation/inquiry/dto/InquiryCreateRequest.java index 265383b2..d3b4a7ba 100644 --- a/src/main/java/life/mosu/mosuserver/presentation/inquiry/dto/InquiryCreateRequest.java +++ b/src/main/java/life/mosu/mosuserver/presentation/inquiry/dto/InquiryCreateRequest.java @@ -2,14 +2,19 @@ import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; import java.util.List; import life.mosu.mosuserver.domain.inquiry.entity.InquiryJpaEntity; import life.mosu.mosuserver.domain.user.entity.UserJpaEntity; import life.mosu.mosuserver.presentation.common.FileRequest; public record InquiryCreateRequest( + + @Size(max = 100, message = "제목은 최대 300자까지 입력 가능합니다.") @Schema(description = "문의 제목", example = "서비스 이용 관련 질문입니다.") @NotNull String title, + + @Size(max = 1000, message = "본문은 최대 1000자까지 입력 가능합니다.") @Schema(description = "문의 내용", example = "포인트는 어떻게 사용하나요?") @NotNull String content, List attachments From 4e5fba13ada69dad7752e942106d0dfd5db52d55 Mon Sep 17 00:00:00 2001 From: KNU-K Date: Fri, 8 Aug 2025 13:06:18 +0900 Subject: [PATCH 20/43] feat: add ArchivingOrchestratorJob for scheduled domain archiving --- .../infra/cron/ArchivingOrchestratorJob.java | 66 ------------------- .../cron/job/ArchivingOrchestratorJob.java | 38 +++++++++++ 2 files changed, 38 insertions(+), 66 deletions(-) delete mode 100644 src/main/java/life/mosu/mosuserver/infra/cron/ArchivingOrchestratorJob.java create mode 100644 src/main/java/life/mosu/mosuserver/infra/cron/job/ArchivingOrchestratorJob.java diff --git a/src/main/java/life/mosu/mosuserver/infra/cron/ArchivingOrchestratorJob.java b/src/main/java/life/mosu/mosuserver/infra/cron/ArchivingOrchestratorJob.java deleted file mode 100644 index 8ec20eee..00000000 --- a/src/main/java/life/mosu/mosuserver/infra/cron/ArchivingOrchestratorJob.java +++ /dev/null @@ -1,66 +0,0 @@ -package life.mosu.mosuserver.infra.cron; - -import jakarta.annotation.PreDestroy; -import java.util.List; -import java.util.concurrent.Executors; -import java.util.concurrent.ScheduledExecutorService; -import java.util.concurrent.TimeUnit; -import life.mosu.mosuserver.global.support.DomainArchiver; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.quartz.Job; -import org.quartz.JobExecutionContext; -import org.quartz.JobExecutionException; -import org.springframework.stereotype.Component; - - -@Component -@RequiredArgsConstructor -@Slf4j -public class ArchivingOrchestratorJob implements Job { - - private static final int corePoolSize = 2; - private final List domainArchivers; - private final ScheduledExecutorService executor = Executors.newScheduledThreadPool( - corePoolSize, - runnable -> { - Thread t = new Thread(runnable, "archiving-orchestrator"); - t.setDaemon(true); - return t; - }); - - @Override - public void execute(JobExecutionContext ctx) throws JobExecutionException { - if (domainArchivers == null || domainArchivers.isEmpty()) { - log.info("No domain archivers configured, skipping execution"); - return; - } - - for (int i = 0; i < domainArchivers.size(); ++i) { - DomainArchiver archiver = domainArchivers.get(i); - long delayMinutes = i * 10L; - log.info("Scheduling {} to run in {} minutes", archiver.getName(), delayMinutes); - executor.schedule(() -> { - try { - archiver.archive(); - log.debug("Archiving completed for {}", archiver.getName()); - } catch (Exception e) { - log.error("Archiving failed for {}", archiver.getName(), e); - } - }, delayMinutes, TimeUnit.SECONDS); // 10초 간격으로 시간차 줌 (Schedule 무시 될 때도 있음) - } - } - - @PreDestroy - public void shutdown() { - executor.shutdown(); - try { - if (!executor.awaitTermination(60, TimeUnit.SECONDS)) { - executor.shutdownNow(); - } - } catch (InterruptedException e) { - executor.shutdownNow(); - Thread.currentThread().interrupt(); - } - } -} diff --git a/src/main/java/life/mosu/mosuserver/infra/cron/job/ArchivingOrchestratorJob.java b/src/main/java/life/mosu/mosuserver/infra/cron/job/ArchivingOrchestratorJob.java new file mode 100644 index 00000000..d9af3665 --- /dev/null +++ b/src/main/java/life/mosu/mosuserver/infra/cron/job/ArchivingOrchestratorJob.java @@ -0,0 +1,38 @@ +package life.mosu.mosuserver.infra.cron.job; + +import java.util.List; +import life.mosu.mosuserver.global.support.cron.DomainArchiveExecutor; +import life.mosu.mosuserver.infra.cron.annotation.CronJob; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.quartz.Job; +import org.quartz.JobExecutionContext; + +@Slf4j +@CronJob( + cron = "0 0 4 * * ?", // 매일 새벽 2시에 실행 + name = "archivingOrchestratorJob" +) +@RequiredArgsConstructor +public class ArchivingOrchestratorJob implements Job { + + private final List domainArchiveExecutors; + + @Override + public void execute(JobExecutionContext ctx) { + if (domainArchiveExecutors == null || domainArchiveExecutors.isEmpty()) { + log.info("No domain archivers configured, skipping execution"); + return; + } + + for (DomainArchiveExecutor archiver : domainArchiveExecutors) { + try { + log.info("Starting archive for {}", archiver.getName()); + archiver.archive(); + log.info("Archiving completed for {}", archiver.getName()); + } catch (Exception e) { + log.error("Archiving failed for {}", archiver.getName(), e); + } + } + } +} \ No newline at end of file From 9156daeca35a9f8250c35b6610a3aeb8bff4a905 Mon Sep 17 00:00:00 2001 From: KNU-K Date: Fri, 8 Aug 2025 13:06:40 +0900 Subject: [PATCH 21/43] feat: add QuartzAutoRegisterConfig for automatic job registration and scheduling --- .../ArchivingOrchestratorCronConfig.java | 76 ----------- .../config/QuartzAutoRegisterConfig.java | 128 ++++++++++++++++++ 2 files changed, 128 insertions(+), 76 deletions(-) delete mode 100644 src/main/java/life/mosu/mosuserver/infra/config/ArchivingOrchestratorCronConfig.java create mode 100644 src/main/java/life/mosu/mosuserver/infra/config/QuartzAutoRegisterConfig.java diff --git a/src/main/java/life/mosu/mosuserver/infra/config/ArchivingOrchestratorCronConfig.java b/src/main/java/life/mosu/mosuserver/infra/config/ArchivingOrchestratorCronConfig.java deleted file mode 100644 index d20eaff2..00000000 --- a/src/main/java/life/mosu/mosuserver/infra/config/ArchivingOrchestratorCronConfig.java +++ /dev/null @@ -1,76 +0,0 @@ -package life.mosu.mosuserver.infra.config; - -import life.mosu.mosuserver.infra.cron.ArchivingOrchestratorJob; -import life.mosu.mosuserver.infra.cron.LogCleanupJob; -import life.mosu.mosuserver.infra.cron.di.AutowiringSpringBeanJobFactory; -import lombok.RequiredArgsConstructor; -import org.quartz.CronScheduleBuilder; -import org.quartz.JobBuilder; -import org.quartz.JobDetail; -import org.quartz.Trigger; -import org.quartz.TriggerBuilder; -import org.springframework.beans.factory.config.AutowireCapableBeanFactory; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; -import org.springframework.scheduling.quartz.SchedulerFactoryBean; - -@Configuration -@RequiredArgsConstructor -public class ArchivingOrchestratorCronConfig { - - private final AutowireCapableBeanFactory beanFactory; - - @Bean - public JobDetail starvationCleanUpJobDetail() { - return JobBuilder.newJob(ArchivingOrchestratorJob.class) - .withIdentity("starvationCleanupJob") - .storeDurably() - .build(); - } - - @Bean - public Trigger starvationCleanupTrigger(JobDetail starvationCleanUpJobDetail) { - return TriggerBuilder.newTrigger() - .forJob(starvationCleanUpJobDetail) - .withIdentity("starvationCleanupTrigger") - .withSchedule(CronScheduleBuilder.cronSchedule("0 0/30 2-5 * * ?")) -// .withSchedule(CronScheduleBuilder.cronSchedule("0/10 * * * * ?")) - .build(); - } - - @Bean - public JobDetail logCleanupJobDetail() { - return JobBuilder.newJob(LogCleanupJob.class) - .withIdentity("logCleanupJob") - .storeDurably() - .build(); - } - - @Bean - public Trigger logCleanupTrigger(JobDetail logCleanupJobDetail) { - return TriggerBuilder.newTrigger() - .forJob(logCleanupJobDetail) - .withIdentity("logCleanupTrigger") - .withSchedule(CronScheduleBuilder.cronSchedule("0 0 3 1 1/3 ?")) // 매 3개월마다 1일 03시 - .build(); - } - - @Bean - public AutowiringSpringBeanJobFactory springBeanJobFactory() { - return new AutowiringSpringBeanJobFactory(beanFactory); - } - - @Bean - public SchedulerFactoryBean schedulerFactoryBean( - Trigger starvationCleanupTrigger, - Trigger logCleanupTrigger, - JobDetail starvationCleanUpJobDetail, - JobDetail logCleanupJobDetail - ) { - SchedulerFactoryBean factory = new SchedulerFactoryBean(); - factory.setJobFactory(springBeanJobFactory()); - factory.setJobDetails(starvationCleanUpJobDetail, logCleanupJobDetail); - factory.setTriggers(starvationCleanupTrigger, logCleanupTrigger); - return factory; - } -} diff --git a/src/main/java/life/mosu/mosuserver/infra/config/QuartzAutoRegisterConfig.java b/src/main/java/life/mosu/mosuserver/infra/config/QuartzAutoRegisterConfig.java new file mode 100644 index 00000000..53bb4f0c --- /dev/null +++ b/src/main/java/life/mosu/mosuserver/infra/config/QuartzAutoRegisterConfig.java @@ -0,0 +1,128 @@ +package life.mosu.mosuserver.infra.config; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import life.mosu.mosuserver.infra.cron.annotation.CronJob; +import life.mosu.mosuserver.infra.cron.support.AutowiringSpringBeanJobFactory; +import lombok.RequiredArgsConstructor; +import org.quartz.CronScheduleBuilder; +import org.quartz.Job; +import org.quartz.JobBuilder; +import org.quartz.JobDetail; +import org.quartz.Trigger; +import org.quartz.TriggerBuilder; +import org.springframework.aop.support.AopUtils; +import org.springframework.beans.factory.config.AutowireCapableBeanFactory; +import org.springframework.context.ApplicationContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.quartz.SchedulerFactoryBean; + +@Configuration +@RequiredArgsConstructor +public class QuartzAutoRegisterConfig { + + private final AutowireCapableBeanFactory beanFactory; + private final ApplicationContext applicationContext; + + @Bean + public SchedulerFactoryBean schedulerFactoryBean() { + SchedulerFactoryBean factory = new SchedulerFactoryBean(); + factory.setJobFactory(new AutowiringSpringBeanJobFactory(beanFactory)); + + List jobDetails = new ArrayList<>(); + List triggers = new ArrayList<>(); + + for (Object jobBean : findQuartzScheduledBeans().values()) { + JobDetail jobDetail = createJobDetail(jobBean); + Trigger trigger = createTrigger(jobBean, jobDetail); + + jobDetails.add(jobDetail); + triggers.add(trigger); + } + + factory.setJobDetails(jobDetails.toArray(new JobDetail[0])); + factory.setTriggers(triggers.toArray(new Trigger[0])); + + return factory; + } + + /** + * @QuartzScheduled 붙은 Bean 스캔 + */ + private Map findQuartzScheduledBeans() { + return applicationContext.getBeansWithAnnotation(CronJob.class); + } + + /** + * JobDetail 생성 + */ + @SuppressWarnings("unchecked") + private JobDetail createJobDetail(Object jobBean) { + Class jobClass = AopUtils.getTargetClass(jobBean); + CronJob annotation = getQuartzScheduledAnnotation(jobClass); + + validateJobClass(jobClass); + + String name = resolveJobName(jobClass, annotation); + + return JobBuilder.newJob((Class) jobClass) + .withIdentity(name) + .storeDurably() + .build(); + } + + /** + * Trigger 생성 + */ + private Trigger createTrigger(Object jobBean, JobDetail jobDetail) { + Class jobClass = AopUtils.getTargetClass(jobBean); + CronJob annotation = getQuartzScheduledAnnotation(jobClass); + + CronScheduleBuilder cronSchedule = buildCronSchedule(annotation.cron()); + + return TriggerBuilder.newTrigger() + .forJob(jobDetail) + .withIdentity(jobDetail.getKey().getName() + "Trigger") + .withSchedule(cronSchedule) + .build(); + } + + /** + * CronScheduleBuilder 생성 (검증 포함) + */ + private CronScheduleBuilder buildCronSchedule(String cron) { + try { + return CronScheduleBuilder.cronSchedule(cron) + .withMisfireHandlingInstructionDoNothing(); + } catch (Exception e) { + throw new IllegalArgumentException("잘못된 cron 표현식: " + cron, e); + } + } + + /** + * QuartzScheduled 애노테이션 가져오기 + */ + private CronJob getQuartzScheduledAnnotation(Class jobClass) { + return jobClass.getAnnotation(CronJob.class); + } + + /** + * Job 클래스 타입 검증 + */ + private void validateJobClass(Class jobClass) { + if (!Job.class.isAssignableFrom(jobClass)) { + throw new IllegalStateException(jobClass.getName() + "는 org.quartz.Job을 구현해야 합니다."); + } + } + + /** + * Job 이름 결정 + */ + private String resolveJobName(Class jobClass, CronJob annotation) { + String defaultName = jobClass.getPackageName() + "." + jobClass.getSimpleName(); + return annotation.name().isEmpty() ? defaultName : annotation.name(); + } + +} From a23d073b5821018bed622ef134ffa4d8eda3aeae Mon Sep 17 00:00:00 2001 From: KNU-K Date: Fri, 8 Aug 2025 13:06:55 +0900 Subject: [PATCH 22/43] feat: add CronJob annotation and CronJobExecutor interface for scheduling tasks --- .../infra/cron/annotation/CronJob.java | 17 +++++++++++++++++ .../infra/cron/support/CronJobExecutor.java | 5 +++++ 2 files changed, 22 insertions(+) create mode 100644 src/main/java/life/mosu/mosuserver/infra/cron/annotation/CronJob.java create mode 100644 src/main/java/life/mosu/mosuserver/infra/cron/support/CronJobExecutor.java diff --git a/src/main/java/life/mosu/mosuserver/infra/cron/annotation/CronJob.java b/src/main/java/life/mosu/mosuserver/infra/cron/annotation/CronJob.java new file mode 100644 index 00000000..01231507 --- /dev/null +++ b/src/main/java/life/mosu/mosuserver/infra/cron/annotation/CronJob.java @@ -0,0 +1,17 @@ +package life.mosu.mosuserver.infra.cron.annotation; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; +import org.springframework.stereotype.Component; + +@Target(ElementType.TYPE) +@Retention(RetentionPolicy.RUNTIME) +@Component +public @interface CronJob { + + String cron(); + + String name() default ""; +} \ No newline at end of file diff --git a/src/main/java/life/mosu/mosuserver/infra/cron/support/CronJobExecutor.java b/src/main/java/life/mosu/mosuserver/infra/cron/support/CronJobExecutor.java new file mode 100644 index 00000000..c5a57245 --- /dev/null +++ b/src/main/java/life/mosu/mosuserver/infra/cron/support/CronJobExecutor.java @@ -0,0 +1,5 @@ +package life.mosu.mosuserver.infra.cron.support; + +public interface CronJobExecutor { + +} From 72d05dec4cbbcfde47742c39e6da1747af79544e Mon Sep 17 00:00:00 2001 From: KNU-K Date: Fri, 8 Aug 2025 13:07:08 +0900 Subject: [PATCH 23/43] feat: add CronTarget annotation and DomainArchiveExecutor interface for cron job execution --- .../global/support/DomainArchiver.java | 8 -------- .../support/cron/DomainArchiveExecutor.java | 10 ++++++++++ .../LogCleanupExecutor.java} | 5 +++-- .../infra/cron/annotation/CronTarget.java | 16 ++++++++++++++++ 4 files changed, 29 insertions(+), 10 deletions(-) delete mode 100644 src/main/java/life/mosu/mosuserver/global/support/DomainArchiver.java create mode 100644 src/main/java/life/mosu/mosuserver/global/support/cron/DomainArchiveExecutor.java rename src/main/java/life/mosu/mosuserver/global/support/{LogCleanup.java => cron/LogCleanupExecutor.java} (59%) create mode 100644 src/main/java/life/mosu/mosuserver/infra/cron/annotation/CronTarget.java diff --git a/src/main/java/life/mosu/mosuserver/global/support/DomainArchiver.java b/src/main/java/life/mosu/mosuserver/global/support/DomainArchiver.java deleted file mode 100644 index db8fe1d8..00000000 --- a/src/main/java/life/mosu/mosuserver/global/support/DomainArchiver.java +++ /dev/null @@ -1,8 +0,0 @@ -package life.mosu.mosuserver.global.support; - -public interface DomainArchiver { - - void archive(); - - String getName(); -} diff --git a/src/main/java/life/mosu/mosuserver/global/support/cron/DomainArchiveExecutor.java b/src/main/java/life/mosu/mosuserver/global/support/cron/DomainArchiveExecutor.java new file mode 100644 index 00000000..42eeae36 --- /dev/null +++ b/src/main/java/life/mosu/mosuserver/global/support/cron/DomainArchiveExecutor.java @@ -0,0 +1,10 @@ +package life.mosu.mosuserver.global.support.cron; + +import life.mosu.mosuserver.infra.cron.support.CronJobExecutor; + +public interface DomainArchiveExecutor extends CronJobExecutor { + + void archive(); + + String getName(); +} diff --git a/src/main/java/life/mosu/mosuserver/global/support/LogCleanup.java b/src/main/java/life/mosu/mosuserver/global/support/cron/LogCleanupExecutor.java similarity index 59% rename from src/main/java/life/mosu/mosuserver/global/support/LogCleanup.java rename to src/main/java/life/mosu/mosuserver/global/support/cron/LogCleanupExecutor.java index 704cd3c3..340ba515 100644 --- a/src/main/java/life/mosu/mosuserver/global/support/LogCleanup.java +++ b/src/main/java/life/mosu/mosuserver/global/support/cron/LogCleanupExecutor.java @@ -1,8 +1,9 @@ -package life.mosu.mosuserver.global.support; +package life.mosu.mosuserver.global.support.cron; import java.time.LocalDateTime; +import life.mosu.mosuserver.infra.cron.support.CronJobExecutor; -public interface LogCleanup { +public interface LogCleanupExecutor extends CronJobExecutor { /** * 지정된 기준 날짜보다 오래된 로그 데이터를 삭제한다. diff --git a/src/main/java/life/mosu/mosuserver/infra/cron/annotation/CronTarget.java b/src/main/java/life/mosu/mosuserver/infra/cron/annotation/CronTarget.java new file mode 100644 index 00000000..4b0f0226 --- /dev/null +++ b/src/main/java/life/mosu/mosuserver/infra/cron/annotation/CronTarget.java @@ -0,0 +1,16 @@ +package life.mosu.mosuserver.infra.cron.annotation; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; +import org.quartz.DisallowConcurrentExecution; +import org.springframework.stereotype.Component; + +@Target(ElementType.TYPE) +@Retention(RetentionPolicy.RUNTIME) +@Component +@DisallowConcurrentExecution +public @interface CronTarget { + +} From 4f76947ca4f4ef68ae337342e8de953e80452370 Mon Sep 17 00:00:00 2001 From: KNU-K Date: Fri, 8 Aug 2025 13:07:23 +0900 Subject: [PATCH 24/43] feat: rename and refactor log cleanup and archiving classes for cron job execution --- ...va => ApplicationFailureLogCleanupExecutor.java} | 4 ++-- ...ApplicationFailureLogDomainArchiveExecutor.java} | 10 ++++------ .../caffeine/BlockedIpBatchAchiever.java | 13 +++++++------ 3 files changed, 13 insertions(+), 14 deletions(-) rename src/main/java/life/mosu/mosuserver/application/application/cron/{ApplicationFailureLogCleanup.java => ApplicationFailureLogCleanupExecutor.java} (80%) rename src/main/java/life/mosu/mosuserver/application/application/cron/{ApplicationFailureLogDomainArchiver.java => ApplicationFailureLogDomainArchiveExecutor.java} (92%) diff --git a/src/main/java/life/mosu/mosuserver/application/application/cron/ApplicationFailureLogCleanup.java b/src/main/java/life/mosu/mosuserver/application/application/cron/ApplicationFailureLogCleanupExecutor.java similarity index 80% rename from src/main/java/life/mosu/mosuserver/application/application/cron/ApplicationFailureLogCleanup.java rename to src/main/java/life/mosu/mosuserver/application/application/cron/ApplicationFailureLogCleanupExecutor.java index 2d1c9fda..6e9bbb91 100644 --- a/src/main/java/life/mosu/mosuserver/application/application/cron/ApplicationFailureLogCleanup.java +++ b/src/main/java/life/mosu/mosuserver/application/application/cron/ApplicationFailureLogCleanupExecutor.java @@ -2,7 +2,7 @@ import java.time.LocalDateTime; import life.mosu.mosuserver.domain.application.repository.ApplicationFailureLogJpaRepository; -import life.mosu.mosuserver.global.support.LogCleanup; +import life.mosu.mosuserver.global.support.cron.LogCleanupExecutor; import lombok.RequiredArgsConstructor; import org.quartz.DisallowConcurrentExecution; import org.springframework.stereotype.Component; @@ -10,7 +10,7 @@ @DisallowConcurrentExecution @Component @RequiredArgsConstructor -public class ApplicationFailureLogCleanup implements LogCleanup { +public class ApplicationFailureLogCleanupExecutor implements LogCleanupExecutor { private final ApplicationFailureLogJpaRepository applicationFailureLogJpaRepository; diff --git a/src/main/java/life/mosu/mosuserver/application/application/cron/ApplicationFailureLogDomainArchiver.java b/src/main/java/life/mosu/mosuserver/application/application/cron/ApplicationFailureLogDomainArchiveExecutor.java similarity index 92% rename from src/main/java/life/mosu/mosuserver/application/application/cron/ApplicationFailureLogDomainArchiver.java rename to src/main/java/life/mosu/mosuserver/application/application/cron/ApplicationFailureLogDomainArchiveExecutor.java index 9989c403..a219a7a0 100644 --- a/src/main/java/life/mosu/mosuserver/application/application/cron/ApplicationFailureLogDomainArchiver.java +++ b/src/main/java/life/mosu/mosuserver/application/application/cron/ApplicationFailureLogDomainArchiveExecutor.java @@ -11,18 +11,16 @@ import life.mosu.mosuserver.domain.application.entity.ApplicationJpaEntity; import life.mosu.mosuserver.domain.application.repository.ApplicationFailureLogJpaRepository; import life.mosu.mosuserver.domain.application.repository.ApplicationJpaRepository; -import life.mosu.mosuserver.global.support.DomainArchiver; +import life.mosu.mosuserver.global.support.cron.DomainArchiveExecutor; +import life.mosu.mosuserver.infra.cron.annotation.CronTarget; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.quartz.DisallowConcurrentExecution; -import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; @Slf4j -@DisallowConcurrentExecution -@Component +@CronTarget @RequiredArgsConstructor -public class ApplicationFailureLogDomainArchiver implements DomainArchiver { +public class ApplicationFailureLogDomainArchiveExecutor implements DomainArchiveExecutor { private final static Duration DURATION_HOURS_STANDARD = Duration.ofHours(1); private final static int BATCH_SIZE = 500; diff --git a/src/main/java/life/mosu/mosuserver/application/caffeine/BlockedIpBatchAchiever.java b/src/main/java/life/mosu/mosuserver/application/caffeine/BlockedIpBatchAchiever.java index d2d248e7..8a1bb449 100644 --- a/src/main/java/life/mosu/mosuserver/application/caffeine/BlockedIpBatchAchiever.java +++ b/src/main/java/life/mosu/mosuserver/application/caffeine/BlockedIpBatchAchiever.java @@ -4,19 +4,19 @@ import java.time.Duration; import java.util.List; import java.util.Map; -import life.mosu.mosuserver.global.filter.TimePenalty; import life.mosu.mosuserver.domain.caffeine.dto.BlockedIpHistory; import life.mosu.mosuserver.domain.caffeine.entity.BlockedIpHistoryLogJpaEntity; import life.mosu.mosuserver.domain.caffeine.repository.BlockedIpHistoryLogJpaJpaRepository; -import life.mosu.mosuserver.global.support.DomainArchiver; +import life.mosu.mosuserver.global.filter.TimePenalty; +import life.mosu.mosuserver.global.support.cron.DomainArchiveExecutor; +import life.mosu.mosuserver.infra.cron.annotation.CronTarget; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.springframework.stereotype.Component; @Slf4j -@Component +@CronTarget @RequiredArgsConstructor -public class BlockedIpBatchAchiever implements DomainArchiver { +public class BlockedIpBatchAchiever implements DomainArchiveExecutor { private final static Duration DURATION_HOURS_STANDARD = Duration.ofHours(1); private final static int BATCH_SIZE = 500; @@ -57,7 +57,8 @@ public String getName() { return "blocked-ip"; } - private BlockedIpHistoryLogJpaEntity createBlockedHistoryLog(BlockedIpHistory blockedIpHistory) { + private BlockedIpHistoryLogJpaEntity createBlockedHistoryLog( + BlockedIpHistory blockedIpHistory) { return new BlockedIpHistoryLogJpaEntity( blockedIpHistory.getIp(), blockedIpHistory.getPenaltyLevel(), From 3a9f056b4642e93324d04d0c6de77dfb72edc330 Mon Sep 17 00:00:00 2001 From: KNU-K Date: Fri, 8 Aug 2025 13:07:36 +0900 Subject: [PATCH 25/43] feat: rename AutowiringSpringBeanJobFactory and update package to support --- .../cron/{di => support}/AutowiringSpringBeanJobFactory.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename src/main/java/life/mosu/mosuserver/infra/cron/{di => support}/AutowiringSpringBeanJobFactory.java (92%) diff --git a/src/main/java/life/mosu/mosuserver/infra/cron/di/AutowiringSpringBeanJobFactory.java b/src/main/java/life/mosu/mosuserver/infra/cron/support/AutowiringSpringBeanJobFactory.java similarity index 92% rename from src/main/java/life/mosu/mosuserver/infra/cron/di/AutowiringSpringBeanJobFactory.java rename to src/main/java/life/mosu/mosuserver/infra/cron/support/AutowiringSpringBeanJobFactory.java index 643b3045..f61e839c 100644 --- a/src/main/java/life/mosu/mosuserver/infra/cron/di/AutowiringSpringBeanJobFactory.java +++ b/src/main/java/life/mosu/mosuserver/infra/cron/support/AutowiringSpringBeanJobFactory.java @@ -1,4 +1,4 @@ -package life.mosu.mosuserver.infra.cron.di; +package life.mosu.mosuserver.infra.cron.support; import lombok.RequiredArgsConstructor; import org.quartz.spi.TriggerFiredBundle; From bc5b811006dfbb8506e052d1e510ed8c56339fb9 Mon Sep 17 00:00:00 2001 From: KNU-K Date: Fri, 8 Aug 2025 13:07:46 +0900 Subject: [PATCH 26/43] feat: rename LogCleanupJob and update package structure; refactor cleanup logic to use LogCleanupExecutor --- .../infra/cron/{ => job}/LogCleanupJob.java | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) rename src/main/java/life/mosu/mosuserver/infra/cron/{ => job}/LogCleanupJob.java (69%) diff --git a/src/main/java/life/mosu/mosuserver/infra/cron/LogCleanupJob.java b/src/main/java/life/mosu/mosuserver/infra/cron/job/LogCleanupJob.java similarity index 69% rename from src/main/java/life/mosu/mosuserver/infra/cron/LogCleanupJob.java rename to src/main/java/life/mosu/mosuserver/infra/cron/job/LogCleanupJob.java index e8b50275..7bdd51f3 100644 --- a/src/main/java/life/mosu/mosuserver/infra/cron/LogCleanupJob.java +++ b/src/main/java/life/mosu/mosuserver/infra/cron/job/LogCleanupJob.java @@ -1,27 +1,29 @@ -package life.mosu.mosuserver.infra.cron; +package life.mosu.mosuserver.infra.cron.job; import java.time.LocalDateTime; import java.util.List; -import life.mosu.mosuserver.global.support.LogCleanup; +import life.mosu.mosuserver.global.support.cron.LogCleanupExecutor; +import life.mosu.mosuserver.infra.cron.annotation.CronJob; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.quartz.Job; import org.quartz.JobExecutionContext; -import org.springframework.stereotype.Component; @Slf4j -@Component +@CronJob( + cron = "0 0 3 1 1/3 ?", + name = "logCleanupJob" +) @RequiredArgsConstructor public class LogCleanupJob implements Job { - //추가 분리 - private final List cleanups; + private final List cleanups; @Override public void execute(JobExecutionContext context) { LocalDateTime threshold = LocalDateTime.now().minusMonths(3); - for (LogCleanup cleanup : cleanups) { + for (LogCleanupExecutor cleanup : cleanups) { try { int deleted = cleanup.deleteLogsBefore(threshold); log.info("[LogCleanupJob] Deleted total {} logs older than {}", deleted, threshold); @@ -30,6 +32,5 @@ public void execute(JobExecutionContext context) { cleanup.getClass().getSimpleName(), e); } } - } } From 22ddb8bcf21b984c69784c1eaffb01d22c1cf38e Mon Sep 17 00:00:00 2001 From: KNU-K Date: Fri, 8 Aug 2025 13:07:57 +0900 Subject: [PATCH 27/43] feat: rename cleanup and archiving classes to use Executor pattern; update annotations for cron job execution --- ...nup.java => PaymentFailureLogCleanupExecutor.java} | 4 ++-- ...va => PaymentFailureLogDomainArchiveExecutor.java} | 11 +++++------ ...anup.java => RefundFailureLogCleanupExecutor.java} | 4 ++-- ...ava => RefundFailureLogDomainArchiveExecutor.java} | 10 ++++------ 4 files changed, 13 insertions(+), 16 deletions(-) rename src/main/java/life/mosu/mosuserver/application/payment/cron/{PaymentFailureLogCleanup.java => PaymentFailureLogCleanupExecutor.java} (80%) rename src/main/java/life/mosu/mosuserver/application/payment/cron/{PaymentFailureLogDomainArchiver.java => PaymentFailureLogDomainArchiveExecutor.java} (92%) rename src/main/java/life/mosu/mosuserver/application/refund/cron/{RefundFailureLogCleanup.java => RefundFailureLogCleanupExecutor.java} (80%) rename src/main/java/life/mosu/mosuserver/application/refund/cron/{RefundFailureLogDomainArchiver.java => RefundFailureLogDomainArchiveExecutor.java} (91%) diff --git a/src/main/java/life/mosu/mosuserver/application/payment/cron/PaymentFailureLogCleanup.java b/src/main/java/life/mosu/mosuserver/application/payment/cron/PaymentFailureLogCleanupExecutor.java similarity index 80% rename from src/main/java/life/mosu/mosuserver/application/payment/cron/PaymentFailureLogCleanup.java rename to src/main/java/life/mosu/mosuserver/application/payment/cron/PaymentFailureLogCleanupExecutor.java index 0ae0aa2b..8b8c7bca 100644 --- a/src/main/java/life/mosu/mosuserver/application/payment/cron/PaymentFailureLogCleanup.java +++ b/src/main/java/life/mosu/mosuserver/application/payment/cron/PaymentFailureLogCleanupExecutor.java @@ -2,7 +2,7 @@ import java.time.LocalDateTime; import life.mosu.mosuserver.domain.payment.repository.PaymentFailureLogJpaRepository; -import life.mosu.mosuserver.global.support.LogCleanup; +import life.mosu.mosuserver.global.support.cron.LogCleanupExecutor; import lombok.RequiredArgsConstructor; import org.quartz.DisallowConcurrentExecution; import org.springframework.stereotype.Component; @@ -10,7 +10,7 @@ @DisallowConcurrentExecution @Component @RequiredArgsConstructor -public class PaymentFailureLogCleanup implements LogCleanup { +public class PaymentFailureLogCleanupExecutor implements LogCleanupExecutor { private final PaymentFailureLogJpaRepository paymentFailureLogJpaRepository; diff --git a/src/main/java/life/mosu/mosuserver/application/payment/cron/PaymentFailureLogDomainArchiver.java b/src/main/java/life/mosu/mosuserver/application/payment/cron/PaymentFailureLogDomainArchiveExecutor.java similarity index 92% rename from src/main/java/life/mosu/mosuserver/application/payment/cron/PaymentFailureLogDomainArchiver.java rename to src/main/java/life/mosu/mosuserver/application/payment/cron/PaymentFailureLogDomainArchiveExecutor.java index acabbd1b..f0bedfc2 100644 --- a/src/main/java/life/mosu/mosuserver/application/payment/cron/PaymentFailureLogDomainArchiver.java +++ b/src/main/java/life/mosu/mosuserver/application/payment/cron/PaymentFailureLogDomainArchiveExecutor.java @@ -11,18 +11,16 @@ import life.mosu.mosuserver.domain.payment.entity.PaymentJpaEntity; import life.mosu.mosuserver.domain.payment.repository.PaymentFailureLogJpaRepository; import life.mosu.mosuserver.domain.payment.repository.PaymentJpaRepository; -import life.mosu.mosuserver.global.support.DomainArchiver; +import life.mosu.mosuserver.global.support.cron.DomainArchiveExecutor; +import life.mosu.mosuserver.infra.cron.annotation.CronTarget; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.quartz.DisallowConcurrentExecution; -import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; @Slf4j -@Component +@CronTarget @RequiredArgsConstructor -@DisallowConcurrentExecution -public class PaymentFailureLogDomainArchiver implements DomainArchiver { +public class PaymentFailureLogDomainArchiveExecutor implements DomainArchiveExecutor { private final static Duration DURATION_HOURS_STANDARD = Duration.ofHours(1); private final static int BATCH_SIZE = 500; @@ -35,6 +33,7 @@ public class PaymentFailureLogDomainArchiver implements DomainArchiver { @Override @Transactional public void archive() { + log.info("hello"); List targets = findFailedPayments(); for (int i = 0; i < targets.size(); i += BATCH_SIZE) { int end = Math.min(i + BATCH_SIZE, targets.size()); diff --git a/src/main/java/life/mosu/mosuserver/application/refund/cron/RefundFailureLogCleanup.java b/src/main/java/life/mosu/mosuserver/application/refund/cron/RefundFailureLogCleanupExecutor.java similarity index 80% rename from src/main/java/life/mosu/mosuserver/application/refund/cron/RefundFailureLogCleanup.java rename to src/main/java/life/mosu/mosuserver/application/refund/cron/RefundFailureLogCleanupExecutor.java index fbf50778..3077bb8c 100644 --- a/src/main/java/life/mosu/mosuserver/application/refund/cron/RefundFailureLogCleanup.java +++ b/src/main/java/life/mosu/mosuserver/application/refund/cron/RefundFailureLogCleanupExecutor.java @@ -2,7 +2,7 @@ import java.time.LocalDateTime; import life.mosu.mosuserver.domain.refund.repository.RefundFailureLogJpaRepository; -import life.mosu.mosuserver.global.support.LogCleanup; +import life.mosu.mosuserver.global.support.cron.LogCleanupExecutor; import lombok.RequiredArgsConstructor; import org.quartz.DisallowConcurrentExecution; import org.springframework.stereotype.Component; @@ -10,7 +10,7 @@ @DisallowConcurrentExecution @Component @RequiredArgsConstructor -public class RefundFailureLogCleanup implements LogCleanup { +public class RefundFailureLogCleanupExecutor implements LogCleanupExecutor { private final RefundFailureLogJpaRepository refundFailureLogJpaRepository; diff --git a/src/main/java/life/mosu/mosuserver/application/refund/cron/RefundFailureLogDomainArchiver.java b/src/main/java/life/mosu/mosuserver/application/refund/cron/RefundFailureLogDomainArchiveExecutor.java similarity index 91% rename from src/main/java/life/mosu/mosuserver/application/refund/cron/RefundFailureLogDomainArchiver.java rename to src/main/java/life/mosu/mosuserver/application/refund/cron/RefundFailureLogDomainArchiveExecutor.java index 4cc6fec6..5f7e6e4a 100644 --- a/src/main/java/life/mosu/mosuserver/application/refund/cron/RefundFailureLogDomainArchiver.java +++ b/src/main/java/life/mosu/mosuserver/application/refund/cron/RefundFailureLogDomainArchiveExecutor.java @@ -11,18 +11,16 @@ import life.mosu.mosuserver.domain.refund.entity.RefundJpaEntity; import life.mosu.mosuserver.domain.refund.repository.RefundFailureLogJpaRepository; import life.mosu.mosuserver.domain.refund.repository.RefundJpaRepository; -import life.mosu.mosuserver.global.support.DomainArchiver; +import life.mosu.mosuserver.global.support.cron.DomainArchiveExecutor; +import life.mosu.mosuserver.infra.cron.annotation.CronTarget; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.quartz.DisallowConcurrentExecution; -import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; @Slf4j -@Component +@CronTarget @RequiredArgsConstructor -@DisallowConcurrentExecution -public class RefundFailureLogDomainArchiver implements DomainArchiver { +public class RefundFailureLogDomainArchiveExecutor implements DomainArchiveExecutor { private final static Duration DURATION_HOURS_STANDARD = Duration.ofHours(1); private final static int BATCH_SIZE = 500; From 9904a69047a222af187ff1028b777a3774bb086c Mon Sep 17 00:00:00 2001 From: KNU-K Date: Fri, 8 Aug 2025 13:08:03 +0900 Subject: [PATCH 28/43] feat: comment out scheduled processing in RollbackLogScheduler --- .../mosu/mosuserver/global/scheduler/RollbackLogScheduler.java | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/main/java/life/mosu/mosuserver/global/scheduler/RollbackLogScheduler.java b/src/main/java/life/mosu/mosuserver/global/scheduler/RollbackLogScheduler.java index 52534658..13dc60e0 100644 --- a/src/main/java/life/mosu/mosuserver/global/scheduler/RollbackLogScheduler.java +++ b/src/main/java/life/mosu/mosuserver/global/scheduler/RollbackLogScheduler.java @@ -2,7 +2,6 @@ import java.util.List; import lombok.RequiredArgsConstructor; -import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Component; @Component @@ -11,7 +10,7 @@ public class RollbackLogScheduler { private final List> processors; - @Scheduled(fixedDelay = 10_000) + // @Scheduled(fixedDelay = 10_000) public void processAllRollbackLogs() { for (RollbackLogProcessor processor : processors) { processLogs(processor); From 791ff20feee7f862cfdb000e81fffadc4d2c7a95 Mon Sep 17 00:00:00 2001 From: KNU-K Date: Fri, 8 Aug 2025 13:13:43 +0900 Subject: [PATCH 29/43] feat: add DisallowConcurrentExecution annotation to ArchivingOrchestratorJob and LogCleanupJob --- .../life/mosu/mosuserver/infra/cron/annotation/CronTarget.java | 2 -- .../mosuserver/infra/cron/job/ArchivingOrchestratorJob.java | 2 ++ .../java/life/mosu/mosuserver/infra/cron/job/LogCleanupJob.java | 2 ++ 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/main/java/life/mosu/mosuserver/infra/cron/annotation/CronTarget.java b/src/main/java/life/mosu/mosuserver/infra/cron/annotation/CronTarget.java index 4b0f0226..7b0491b9 100644 --- a/src/main/java/life/mosu/mosuserver/infra/cron/annotation/CronTarget.java +++ b/src/main/java/life/mosu/mosuserver/infra/cron/annotation/CronTarget.java @@ -4,13 +4,11 @@ import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; -import org.quartz.DisallowConcurrentExecution; import org.springframework.stereotype.Component; @Target(ElementType.TYPE) @Retention(RetentionPolicy.RUNTIME) @Component -@DisallowConcurrentExecution public @interface CronTarget { } diff --git a/src/main/java/life/mosu/mosuserver/infra/cron/job/ArchivingOrchestratorJob.java b/src/main/java/life/mosu/mosuserver/infra/cron/job/ArchivingOrchestratorJob.java index d9af3665..c77946e8 100644 --- a/src/main/java/life/mosu/mosuserver/infra/cron/job/ArchivingOrchestratorJob.java +++ b/src/main/java/life/mosu/mosuserver/infra/cron/job/ArchivingOrchestratorJob.java @@ -5,6 +5,7 @@ import life.mosu.mosuserver.infra.cron.annotation.CronJob; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.quartz.DisallowConcurrentExecution; import org.quartz.Job; import org.quartz.JobExecutionContext; @@ -13,6 +14,7 @@ cron = "0 0 4 * * ?", // 매일 새벽 2시에 실행 name = "archivingOrchestratorJob" ) +@DisallowConcurrentExecution @RequiredArgsConstructor public class ArchivingOrchestratorJob implements Job { diff --git a/src/main/java/life/mosu/mosuserver/infra/cron/job/LogCleanupJob.java b/src/main/java/life/mosu/mosuserver/infra/cron/job/LogCleanupJob.java index 7bdd51f3..99f34460 100644 --- a/src/main/java/life/mosu/mosuserver/infra/cron/job/LogCleanupJob.java +++ b/src/main/java/life/mosu/mosuserver/infra/cron/job/LogCleanupJob.java @@ -7,6 +7,7 @@ import life.mosu.mosuserver.infra.cron.annotation.CronJob; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.quartz.DisallowConcurrentExecution; import org.quartz.Job; import org.quartz.JobExecutionContext; @@ -15,6 +16,7 @@ cron = "0 0 3 1 1/3 ?", name = "logCleanupJob" ) +@DisallowConcurrentExecution @RequiredArgsConstructor public class LogCleanupJob implements Job { From 003ce2fa1324c2fd8e1330f316a79a43082a0bc8 Mon Sep 17 00:00:00 2001 From: KNU-K Date: Fri, 8 Aug 2025 13:14:00 +0900 Subject: [PATCH 30/43] feat: remove debug log statement from PaymentFailureLogDomainArchiveExecutor --- .../payment/cron/PaymentFailureLogDomainArchiveExecutor.java | 1 - 1 file changed, 1 deletion(-) diff --git a/src/main/java/life/mosu/mosuserver/application/payment/cron/PaymentFailureLogDomainArchiveExecutor.java b/src/main/java/life/mosu/mosuserver/application/payment/cron/PaymentFailureLogDomainArchiveExecutor.java index f0bedfc2..3dbb4ed3 100644 --- a/src/main/java/life/mosu/mosuserver/application/payment/cron/PaymentFailureLogDomainArchiveExecutor.java +++ b/src/main/java/life/mosu/mosuserver/application/payment/cron/PaymentFailureLogDomainArchiveExecutor.java @@ -33,7 +33,6 @@ public class PaymentFailureLogDomainArchiveExecutor implements DomainArchiveExec @Override @Transactional public void archive() { - log.info("hello"); List targets = findFailedPayments(); for (int i = 0; i < targets.size(); i += BATCH_SIZE) { int end = Math.min(i + BATCH_SIZE, targets.size()); From c0d23ea53f2148502bf3dd5c3272be18646b2ab1 Mon Sep 17 00:00:00 2001 From: KNU-K Date: Fri, 8 Aug 2025 13:16:51 +0900 Subject: [PATCH 31/43] feat: handle exceptions during Quartz job registration in QuartzAutoRegisterConfig --- .../infra/config/QuartzAutoRegisterConfig.java | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/src/main/java/life/mosu/mosuserver/infra/config/QuartzAutoRegisterConfig.java b/src/main/java/life/mosu/mosuserver/infra/config/QuartzAutoRegisterConfig.java index 53bb4f0c..c9ff7922 100644 --- a/src/main/java/life/mosu/mosuserver/infra/config/QuartzAutoRegisterConfig.java +++ b/src/main/java/life/mosu/mosuserver/infra/config/QuartzAutoRegisterConfig.java @@ -13,6 +13,7 @@ import org.quartz.Trigger; import org.quartz.TriggerBuilder; import org.springframework.aop.support.AopUtils; +import org.springframework.beans.factory.BeanCreationException; import org.springframework.beans.factory.config.AutowireCapableBeanFactory; import org.springframework.context.ApplicationContext; import org.springframework.context.annotation.Bean; @@ -35,11 +36,15 @@ public SchedulerFactoryBean schedulerFactoryBean() { List triggers = new ArrayList<>(); for (Object jobBean : findQuartzScheduledBeans().values()) { - JobDetail jobDetail = createJobDetail(jobBean); - Trigger trigger = createTrigger(jobBean, jobDetail); - - jobDetails.add(jobDetail); - triggers.add(trigger); + try { + JobDetail jobDetail = createJobDetail(jobBean); + Trigger trigger = createTrigger(jobBean, jobDetail); + + jobDetails.add(jobDetail); + triggers.add(trigger); + } catch (Exception e) { + throw new BeanCreationException("Failed to register Quartz job", e); + } } factory.setJobDetails(jobDetails.toArray(new JobDetail[0])); From 7c3b84aed73e5a0325645dea841dff00c47c021b Mon Sep 17 00:00:00 2001 From: KNU-K Date: Fri, 8 Aug 2025 17:42:13 +0900 Subject: [PATCH 32/43] feat: add LuaScriptsFunctionalRegistrar to ApplicationContextInitializer in spring.factories --- src/main/resources/META-INF/spring.factories | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 src/main/resources/META-INF/spring.factories diff --git a/src/main/resources/META-INF/spring.factories b/src/main/resources/META-INF/spring.factories new file mode 100644 index 00000000..f508cd52 --- /dev/null +++ b/src/main/resources/META-INF/spring.factories @@ -0,0 +1,2 @@ +org.springframework.context.ApplicationContextInitializer=\ +life.mosu.mosuserver.infra.persistence.redis.support.LuaScriptsFunctionalRegistrar \ No newline at end of file From 546d6ce82d3f65973738cdc6dc5efdb36a5b732a Mon Sep 17 00:00:00 2001 From: KNU-K Date: Fri, 8 Aug 2025 17:42:33 +0900 Subject: [PATCH 33/43] feat: rename Lua script files for clarity and consistency --- .../{decrement_exam_quota.lua => exam/decrement_quota.lua} | 0 .../{increment_exam_quota.lua => exam/increment_quota.lua} | 0 2 files changed, 0 insertions(+), 0 deletions(-) rename src/main/resources/scripts/{decrement_exam_quota.lua => exam/decrement_quota.lua} (100%) rename src/main/resources/scripts/{increment_exam_quota.lua => exam/increment_quota.lua} (100%) diff --git a/src/main/resources/scripts/decrement_exam_quota.lua b/src/main/resources/scripts/exam/decrement_quota.lua similarity index 100% rename from src/main/resources/scripts/decrement_exam_quota.lua rename to src/main/resources/scripts/exam/decrement_quota.lua diff --git a/src/main/resources/scripts/increment_exam_quota.lua b/src/main/resources/scripts/exam/increment_quota.lua similarity index 100% rename from src/main/resources/scripts/increment_exam_quota.lua rename to src/main/resources/scripts/exam/increment_quota.lua From 9d6003d15b92ccdb3c7d8d8be4fba85b0d1d63e0 Mon Sep 17 00:00:00 2001 From: KNU-K Date: Fri, 8 Aug 2025 17:43:18 +0900 Subject: [PATCH 34/43] feat: add AtomicOperatorAutoRegistrar for dynamic registration of CacheAtomicOperator beans --- .../ExamQuotaAtomicOperationConfig.java | 66 ------------------- .../config/AtomicOperatorAutoRegistrar.java | 50 ++++++++++++++ 2 files changed, 50 insertions(+), 66 deletions(-) delete mode 100644 src/main/java/life/mosu/mosuserver/global/config/ExamQuotaAtomicOperationConfig.java create mode 100644 src/main/java/life/mosu/mosuserver/infra/config/AtomicOperatorAutoRegistrar.java diff --git a/src/main/java/life/mosu/mosuserver/global/config/ExamQuotaAtomicOperationConfig.java b/src/main/java/life/mosu/mosuserver/global/config/ExamQuotaAtomicOperationConfig.java deleted file mode 100644 index e7da0024..00000000 --- a/src/main/java/life/mosu/mosuserver/global/config/ExamQuotaAtomicOperationConfig.java +++ /dev/null @@ -1,66 +0,0 @@ -package life.mosu.mosuserver.global.config; - -import java.io.IOException; -import java.nio.charset.StandardCharsets; -import java.util.Map; -import life.mosu.mosuserver.application.exam.cache.AtomicExamQuotaDecrementOperator; -import life.mosu.mosuserver.application.exam.cache.AtomicExamQuotaIncrementOperator; -import life.mosu.mosuserver.infra.persistence.redis.operator.CacheAtomicOperator; -import org.springframework.beans.factory.annotation.Qualifier; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; -import org.springframework.core.io.Resource; -import org.springframework.data.redis.core.script.DefaultRedisScript; - -@Configuration -public class ExamQuotaAtomicOperationConfig { - - @Value("classpath:scripts/decrement_exam_quota.lua") - private Resource decrementScript; - - @Value("classpath:scripts/increment_exam_quota.lua") - private Resource incrementScript; - - @Bean - @Qualifier("decrementExamQuotaScript") - public DefaultRedisScript decrementExamQuotaScript() { - DefaultRedisScript script = new DefaultRedisScript<>(); - script.setResultType(Long.class); - try { - String lua = new String(decrementScript.getInputStream().readAllBytes(), - StandardCharsets.UTF_8); - script.setScriptText(lua); - } catch (IOException e) { - throw new RuntimeException("Failed to load decrement_exam_quota.lua", e); - } - return script; - } - - @Bean - @Qualifier("incrementExamQuotaScript") - public DefaultRedisScript incrementExamQuotaScript() { - DefaultRedisScript script = new DefaultRedisScript<>(); - script.setResultType(Long.class); - try { - String lua = new String(incrementScript.getInputStream().readAllBytes(), - StandardCharsets.UTF_8); - script.setScriptText(lua); - } catch (IOException e) { - throw new RuntimeException("Failed to load increment_exam_quota.lua", e); - } - return script; - } - - @Bean - @Qualifier("examCacheAtomicOperatorMap") - public Map> examCacheAtomicOperatorMap( - AtomicExamQuotaIncrementOperator incrementOp, - AtomicExamQuotaDecrementOperator decrementOp - ) { - return Map.of( - incrementOp.getActionName(), incrementOp, - decrementOp.getActionName(), decrementOp - ); - } -} diff --git a/src/main/java/life/mosu/mosuserver/infra/config/AtomicOperatorAutoRegistrar.java b/src/main/java/life/mosu/mosuserver/infra/config/AtomicOperatorAutoRegistrar.java new file mode 100644 index 00000000..a1524f00 --- /dev/null +++ b/src/main/java/life/mosu/mosuserver/infra/config/AtomicOperatorAutoRegistrar.java @@ -0,0 +1,50 @@ +package life.mosu.mosuserver.infra.config; + +import java.util.List; +import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collectors; +import life.mosu.mosuserver.infra.persistence.redis.operator.CacheAtomicOperator; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.SmartInitializingSingleton; +import org.springframework.beans.factory.config.ConfigurableListableBeanFactory; +import org.springframework.beans.factory.support.DefaultListableBeanFactory; +import org.springframework.beans.factory.support.GenericBeanDefinition; +import org.springframework.context.annotation.Configuration; + +@Configuration +@Slf4j +public class AtomicOperatorAutoRegistrar implements SmartInitializingSingleton { + + private final ConfigurableListableBeanFactory beanFactory; + + public AtomicOperatorAutoRegistrar(ConfigurableListableBeanFactory beanFactory) { + this.beanFactory = beanFactory; + } + + @Override + public void afterSingletonsInstantiated() { + Map allOperators = beanFactory.getBeansOfType( + CacheAtomicOperator.class); + + Map> grouped = allOperators.values().stream() + .collect(Collectors.groupingBy(CacheAtomicOperator::getName)); + + DefaultListableBeanFactory registry = (DefaultListableBeanFactory) beanFactory; + for (Map.Entry> entry : grouped.entrySet()) { + String domain = entry.getKey(); + List operators = entry.getValue(); + + Map mapValue = operators.stream() + .collect(Collectors.toMap(CacheAtomicOperator::getActionName, + Function.identity())); + + String beanName = domain + "CacheAtomicOperatorMap"; + GenericBeanDefinition beanDefinition = new GenericBeanDefinition(); + beanDefinition.setBeanClass(Map.class); + beanDefinition.setInstanceSupplier(() -> mapValue); + + registry.registerBeanDefinition(beanName, beanDefinition); + } + } +} From 6c4dcd2c2b260eee1405f68657dddd392aeac7a6 Mon Sep 17 00:00:00 2001 From: KNU-K Date: Fri, 8 Aug 2025 17:43:25 +0900 Subject: [PATCH 35/43] feat: add LuaScriptsFunctionalRegistrar for dynamic loading of Lua scripts in Redis --- .../LuaScriptsFunctionalRegistrar.java | 90 +++++++++++++++++++ 1 file changed, 90 insertions(+) create mode 100644 src/main/java/life/mosu/mosuserver/infra/persistence/redis/support/LuaScriptsFunctionalRegistrar.java diff --git a/src/main/java/life/mosu/mosuserver/infra/persistence/redis/support/LuaScriptsFunctionalRegistrar.java b/src/main/java/life/mosu/mosuserver/infra/persistence/redis/support/LuaScriptsFunctionalRegistrar.java new file mode 100644 index 00000000..a2a272ce --- /dev/null +++ b/src/main/java/life/mosu/mosuserver/infra/persistence/redis/support/LuaScriptsFunctionalRegistrar.java @@ -0,0 +1,90 @@ +package life.mosu.mosuserver.infra.persistence.redis.support; + +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.util.HashMap; +import java.util.Map; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.ApplicationContextInitializer; +import org.springframework.context.support.GenericApplicationContext; +import org.springframework.core.io.Resource; +import org.springframework.core.io.support.PathMatchingResourcePatternResolver; +import org.springframework.data.redis.core.script.DefaultRedisScript; + +/** + * FQCN + */ +@Slf4j +public class LuaScriptsFunctionalRegistrar implements + ApplicationContextInitializer { + + private static final String SCRIPT_BASE_PATH = "classpath:scripts/"; + private static final String SCRIPT_PATH_PREFIX = "/scripts/"; + + @Override + public void initialize(GenericApplicationContext context) { + try { + PathMatchingResourcePatternResolver resolver = new PathMatchingResourcePatternResolver(); + Resource[] resources = resolver.getResources(SCRIPT_BASE_PATH + "**/*.lua"); + + Map>> domainScriptsMap = new HashMap<>(); + + for (Resource resource : resources) { + String path = resource.getURL().getPath(); + int idx = path.indexOf(SCRIPT_PATH_PREFIX); + if (idx < 0) { + continue; + } + + String relativePath = path.substring(idx + SCRIPT_PATH_PREFIX.length()); + String[] parts = relativePath.split("/"); + if (parts.length < 2) { + continue; + } + + String domain = parts[0]; + String filename = parts[1]; + String scriptName = toCamelCase(filename.replace(".lua", "")); + + DefaultRedisScript script = new DefaultRedisScript<>(); + script.setResultType(Long.class); + + try (InputStream is = resource.getInputStream()) { + String lua = new String(is.readAllBytes(), StandardCharsets.UTF_8); + script.setScriptText(lua); + } + + domainScriptsMap + .computeIfAbsent(domain, k -> new HashMap<>()) + .put(scriptName, script); + } + + for (Map.Entry>> entry : domainScriptsMap.entrySet()) { + String beanName = entry.getKey() + "LuaScripts"; + log.info("Registering Lua scripts for domain: {}", beanName); + Map> scripts = entry.getValue(); + + String keys = String.join(", ", scripts.keySet()); + log.info("Lua script keys: [{}]", keys); + + context.registerBean(beanName, Map.class, () -> scripts); + } + } catch (Exception e) { + throw new RuntimeException("Failed to load Lua scripts", e); + } + } + + private String toCamelCase(String snakeCase) { + StringBuilder result = new StringBuilder(); + boolean toUpper = false; + for (char c : snakeCase.toCharArray()) { + if (c == '_') { + toUpper = true; + } else { + result.append(toUpper ? Character.toUpperCase(c) : c); + toUpper = false; + } + } + return result.toString(); + } +} From 8db0333e90013f90fe45247422b32e766eb2add4 Mon Sep 17 00:00:00 2001 From: KNU-K Date: Fri, 8 Aug 2025 17:43:33 +0900 Subject: [PATCH 36/43] feat: refactor AtomicExamQuota operators to use dynamic Lua script loading --- .../cache/AtomicExamQuotaDecrementOperator.java | 9 +++++++-- .../cache/AtomicExamQuotaIncrementOperator.java | 14 ++++++++++---- 2 files changed, 17 insertions(+), 6 deletions(-) diff --git a/src/main/java/life/mosu/mosuserver/application/exam/cache/AtomicExamQuotaDecrementOperator.java b/src/main/java/life/mosu/mosuserver/application/exam/cache/AtomicExamQuotaDecrementOperator.java index 85679e97..8b9c1be8 100644 --- a/src/main/java/life/mosu/mosuserver/application/exam/cache/AtomicExamQuotaDecrementOperator.java +++ b/src/main/java/life/mosu/mosuserver/application/exam/cache/AtomicExamQuotaDecrementOperator.java @@ -1,6 +1,7 @@ package life.mosu.mosuserver.application.exam.cache; import java.util.List; +import java.util.Map; import life.mosu.mosuserver.global.exception.CustomRuntimeException; import life.mosu.mosuserver.global.exception.ErrorCode; import life.mosu.mosuserver.infra.persistence.redis.operator.VoidCacheAtomicOperator; @@ -15,12 +16,16 @@ public class AtomicExamQuotaDecrementOperator implements VoidCacheAtomicOperator private final RedisTemplate redisTemplate; private final DefaultRedisScript decrementScript; + public AtomicExamQuotaDecrementOperator( RedisTemplate redisTemplate, - @Qualifier("decrementExamQuotaScript") DefaultRedisScript decrementScript + + @SuppressWarnings("SpringJavaInjectionPointsAutowiringInspection") + @Qualifier("examLuaScripts") + Map> examLuaScripts ) { this.redisTemplate = redisTemplate; - this.decrementScript = decrementScript; + this.decrementScript = examLuaScripts.get("decrementQuota"); } @Override diff --git a/src/main/java/life/mosu/mosuserver/application/exam/cache/AtomicExamQuotaIncrementOperator.java b/src/main/java/life/mosu/mosuserver/application/exam/cache/AtomicExamQuotaIncrementOperator.java index ef7657b2..8aadecef 100644 --- a/src/main/java/life/mosu/mosuserver/application/exam/cache/AtomicExamQuotaIncrementOperator.java +++ b/src/main/java/life/mosu/mosuserver/application/exam/cache/AtomicExamQuotaIncrementOperator.java @@ -1,26 +1,32 @@ package life.mosu.mosuserver.application.exam.cache; import java.util.List; +import java.util.Map; import life.mosu.mosuserver.global.exception.CustomRuntimeException; import life.mosu.mosuserver.global.exception.ErrorCode; import life.mosu.mosuserver.infra.persistence.redis.operator.VoidCacheAtomicOperator; +import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.data.redis.core.RedisTemplate; import org.springframework.data.redis.core.script.DefaultRedisScript; import org.springframework.stereotype.Component; @Component +@Slf4j public class AtomicExamQuotaIncrementOperator implements VoidCacheAtomicOperator { private final RedisTemplate redisTemplate; - private final DefaultRedisScript decrementScript; + private final DefaultRedisScript incrementScript; public AtomicExamQuotaIncrementOperator( RedisTemplate redisTemplate, - @Qualifier("incrementExamQuotaScript") DefaultRedisScript decrementScript + + @SuppressWarnings("SpringJavaInjectionPointsAutowiringInspection") + @Qualifier("examLuaScripts") + Map> examLuaScripts ) { this.redisTemplate = redisTemplate; - this.decrementScript = decrementScript; + this.incrementScript = examLuaScripts.get("incrementQuota"); } @Override @@ -36,7 +42,7 @@ public String getActionName() { @Override public void execute(String key) { try { - Long result = redisTemplate.execute(decrementScript, List.of( + Long result = redisTemplate.execute(incrementScript, List.of( ExamQuotaPrefix.CURRENT_APPLICATIONS.with(key), ExamQuotaPrefix.MAX_CAPACITY.with(key) )); From 7c0d2b7532563535d911d79e3bbd11e2bcb74742 Mon Sep 17 00:00:00 2001 From: KNU-K Date: Fri, 8 Aug 2025 17:43:39 +0900 Subject: [PATCH 37/43] feat: add @Lazy annotation to examQuotaCacheAtomicOperatorMap injection in ExamQuotaCacheManager --- .../application/exam/cache/ExamQuotaCacheManager.java | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/main/java/life/mosu/mosuserver/application/exam/cache/ExamQuotaCacheManager.java b/src/main/java/life/mosu/mosuserver/application/exam/cache/ExamQuotaCacheManager.java index a0056cc3..9f8b3ed9 100644 --- a/src/main/java/life/mosu/mosuserver/application/exam/cache/ExamQuotaCacheManager.java +++ b/src/main/java/life/mosu/mosuserver/application/exam/cache/ExamQuotaCacheManager.java @@ -14,6 +14,7 @@ import life.mosu.mosuserver.infra.persistence.redis.operator.VoidCacheAtomicOperator; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.context.annotation.Lazy; import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; @@ -29,7 +30,9 @@ public ExamQuotaCacheManager( CacheWriter cacheWriter, CacheReader cacheReader, - @Qualifier("examCacheAtomicOperatorMap") + @Lazy + @SuppressWarnings("SpringJavaInjectionPointsAutowiringInspection") + @Qualifier("examQuotaCacheAtomicOperatorMap") Map> cacheAtomicOperatorMap, ExamJpaRepository examJpaRepository ) { From e5076ec91d3183e89a2d9400c0dac34ce4c68d02 Mon Sep 17 00:00:00 2001 From: KNU-K Date: Fri, 8 Aug 2025 17:45:18 +0900 Subject: [PATCH 38/43] chore: add .gitkeep files to maintain empty directory structure --- src/main/resources/META-INF/.gitkeep | 0 src/main/resources/scripts/.gitkeep | 0 2 files changed, 0 insertions(+), 0 deletions(-) create mode 100644 src/main/resources/META-INF/.gitkeep create mode 100644 src/main/resources/scripts/.gitkeep diff --git a/src/main/resources/META-INF/.gitkeep b/src/main/resources/META-INF/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/src/main/resources/scripts/.gitkeep b/src/main/resources/scripts/.gitkeep new file mode 100644 index 00000000..e69de29b From 211511b72a087c1b69ac4747c5e9872d6a3e8cb9 Mon Sep 17 00:00:00 2001 From: KNU-K Date: Fri, 8 Aug 2025 17:53:49 +0900 Subject: [PATCH 39/43] feat: ensure Redis scripts for quota operations are not null --- .../exam/cache/AtomicExamQuotaDecrementOperator.java | 4 +++- .../exam/cache/AtomicExamQuotaIncrementOperator.java | 6 ++++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/src/main/java/life/mosu/mosuserver/application/exam/cache/AtomicExamQuotaDecrementOperator.java b/src/main/java/life/mosu/mosuserver/application/exam/cache/AtomicExamQuotaDecrementOperator.java index 8b9c1be8..b246598c 100644 --- a/src/main/java/life/mosu/mosuserver/application/exam/cache/AtomicExamQuotaDecrementOperator.java +++ b/src/main/java/life/mosu/mosuserver/application/exam/cache/AtomicExamQuotaDecrementOperator.java @@ -2,6 +2,7 @@ import java.util.List; import java.util.Map; +import java.util.Objects; import life.mosu.mosuserver.global.exception.CustomRuntimeException; import life.mosu.mosuserver.global.exception.ErrorCode; import life.mosu.mosuserver.infra.persistence.redis.operator.VoidCacheAtomicOperator; @@ -25,7 +26,8 @@ public AtomicExamQuotaDecrementOperator( Map> examLuaScripts ) { this.redisTemplate = redisTemplate; - this.decrementScript = examLuaScripts.get("decrementQuota"); + this.decrementScript = Objects.requireNonNull(examLuaScripts.get("decrementQuota"), + "Redis script 'decrementQuota' not found"); } @Override diff --git a/src/main/java/life/mosu/mosuserver/application/exam/cache/AtomicExamQuotaIncrementOperator.java b/src/main/java/life/mosu/mosuserver/application/exam/cache/AtomicExamQuotaIncrementOperator.java index 8aadecef..bfd4d235 100644 --- a/src/main/java/life/mosu/mosuserver/application/exam/cache/AtomicExamQuotaIncrementOperator.java +++ b/src/main/java/life/mosu/mosuserver/application/exam/cache/AtomicExamQuotaIncrementOperator.java @@ -2,6 +2,7 @@ import java.util.List; import java.util.Map; +import java.util.Objects; import life.mosu.mosuserver.global.exception.CustomRuntimeException; import life.mosu.mosuserver.global.exception.ErrorCode; import life.mosu.mosuserver.infra.persistence.redis.operator.VoidCacheAtomicOperator; @@ -20,13 +21,14 @@ public class AtomicExamQuotaIncrementOperator implements VoidCacheAtomicOperator public AtomicExamQuotaIncrementOperator( RedisTemplate redisTemplate, - + @SuppressWarnings("SpringJavaInjectionPointsAutowiringInspection") @Qualifier("examLuaScripts") Map> examLuaScripts ) { this.redisTemplate = redisTemplate; - this.incrementScript = examLuaScripts.get("incrementQuota"); + this.incrementScript = Objects.requireNonNull(examLuaScripts.get("incrementQuota"), + "Redis script 'incrementQuota' not found"); } @Override From 1daba52b8d806a8ee56561f8279af17701bd0c94 Mon Sep 17 00:00:00 2001 From: KNU-K Date: Fri, 8 Aug 2025 17:54:13 +0900 Subject: [PATCH 40/43] fix: correct index calculation for script path in LuaScriptsFunctionalRegistrar --- .../redis/support/LuaScriptsFunctionalRegistrar.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/life/mosu/mosuserver/infra/persistence/redis/support/LuaScriptsFunctionalRegistrar.java b/src/main/java/life/mosu/mosuserver/infra/persistence/redis/support/LuaScriptsFunctionalRegistrar.java index a2a272ce..b80a4bdb 100644 --- a/src/main/java/life/mosu/mosuserver/infra/persistence/redis/support/LuaScriptsFunctionalRegistrar.java +++ b/src/main/java/life/mosu/mosuserver/infra/persistence/redis/support/LuaScriptsFunctionalRegistrar.java @@ -31,7 +31,7 @@ public void initialize(GenericApplicationContext context) { for (Resource resource : resources) { String path = resource.getURL().getPath(); - int idx = path.indexOf(SCRIPT_PATH_PREFIX); + int idx = path.lastIndexOf(SCRIPT_PATH_PREFIX); if (idx < 0) { continue; } From 795a6e988faa5316e2905fa35fd7a6b873aaae96 Mon Sep 17 00:00:00 2001 From: toothlessdev Date: Sat, 9 Aug 2025 09:45:13 +0900 Subject: [PATCH 41/43] =?UTF-8?q?chore:=20dev.mosuedu.com:3000=20cors=20?= =?UTF-8?q?=EC=84=A4=EC=A0=95=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../global/config/SecurityConfig.java | 23 +++++++++++-------- .../global/config/WebMvcConfig.java | 11 +++++---- 2 files changed, 20 insertions(+), 14 deletions(-) diff --git a/src/main/java/life/mosu/mosuserver/global/config/SecurityConfig.java b/src/main/java/life/mosu/mosuserver/global/config/SecurityConfig.java index c6c9f251..9ebc44a0 100644 --- a/src/main/java/life/mosu/mosuserver/global/config/SecurityConfig.java +++ b/src/main/java/life/mosu/mosuserver/global/config/SecurityConfig.java @@ -4,15 +4,7 @@ import java.util.HashMap; import java.util.List; import java.util.Map; -import life.mosu.mosuserver.application.oauth.OAuthUserService; -import life.mosu.mosuserver.global.filter.TokenExceptionFilter; -import life.mosu.mosuserver.global.filter.TokenFilter; -import life.mosu.mosuserver.global.handler.AuthLogoutHandler; -import life.mosu.mosuserver.global.handler.AuthLogoutSuccessHandler; -import life.mosu.mosuserver.global.handler.OAuth2LoginFailureHandler; -import life.mosu.mosuserver.global.handler.OAuth2LoginSuccessHandler; -import life.mosu.mosuserver.global.resolver.AuthorizationRequestRedirectResolver; -import lombok.RequiredArgsConstructor; + import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.http.HttpMethod; @@ -39,6 +31,16 @@ import org.springframework.web.cors.CorsConfigurationSource; import org.springframework.web.cors.UrlBasedCorsConfigurationSource; +import life.mosu.mosuserver.application.oauth.OAuthUserService; +import life.mosu.mosuserver.global.filter.TokenExceptionFilter; +import life.mosu.mosuserver.global.filter.TokenFilter; +import life.mosu.mosuserver.global.handler.AuthLogoutHandler; +import life.mosu.mosuserver.global.handler.AuthLogoutSuccessHandler; +import life.mosu.mosuserver.global.handler.OAuth2LoginFailureHandler; +import life.mosu.mosuserver.global.handler.OAuth2LoginSuccessHandler; +import life.mosu.mosuserver.global.resolver.AuthorizationRequestRedirectResolver; +import lombok.RequiredArgsConstructor; + @Configuration @EnableWebSecurity @EnableMethodSecurity @@ -51,7 +53,8 @@ public class SecurityConfig { "https://api.mosuedu.com", "https://www.mosuedu.com", "https://partnership.mosuedu.com", - "https://admin.mosuedu.com" + "https://admin.mosuedu.com", + "http://dev.mosuedu.com:3000" ); private final OAuthUserService userService; private final OAuth2LoginSuccessHandler loginSuccessHandler; diff --git a/src/main/java/life/mosu/mosuserver/global/config/WebMvcConfig.java b/src/main/java/life/mosu/mosuserver/global/config/WebMvcConfig.java index 806d59aa..6a4ccdb7 100644 --- a/src/main/java/life/mosu/mosuserver/global/config/WebMvcConfig.java +++ b/src/main/java/life/mosu/mosuserver/global/config/WebMvcConfig.java @@ -1,14 +1,16 @@ package life.mosu.mosuserver.global.config; import java.util.List; -import life.mosu.mosuserver.global.resolver.PhoneNumberArgumentResolver; -import life.mosu.mosuserver.global.resolver.UserIdArgumentResolver; -import lombok.RequiredArgsConstructor; + import org.springframework.context.annotation.Configuration; import org.springframework.web.method.support.HandlerMethodArgumentResolver; import org.springframework.web.servlet.config.annotation.CorsRegistry; import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; +import life.mosu.mosuserver.global.resolver.PhoneNumberArgumentResolver; +import life.mosu.mosuserver.global.resolver.UserIdArgumentResolver; +import lombok.RequiredArgsConstructor; + @Configuration @RequiredArgsConstructor public class WebMvcConfig implements WebMvcConfigurer { @@ -33,7 +35,8 @@ public void addCorsMappings(CorsRegistry registry) { "https://api.mosuedu.com", "https://www.mosuedu.com", "https://partnership.mosuedu.com", - "https://admin.mosuedu.com" + "https://admin.mosuedu.com", + "http://dev.mosuedu.com:3000" ) .allowCredentials(true) .maxAge(3600); From 6ea148a85fee33cb3c7cb485af7971169ea625e2 Mon Sep 17 00:00:00 2001 From: KNU-K Date: Sat, 9 Aug 2025 11:28:23 +0900 Subject: [PATCH 42/43] feat: update self-deploy workflow to include JDK 21 setup and Docker image build steps --- .github/workflows/self-depoly.yaml | 42 +++++++++++++++++++++++++++++- 1 file changed, 41 insertions(+), 1 deletion(-) diff --git a/.github/workflows/self-depoly.yaml b/.github/workflows/self-depoly.yaml index 20a1ebf8..2a3e8327 100644 --- a/.github/workflows/self-depoly.yaml +++ b/.github/workflows/self-depoly.yaml @@ -3,12 +3,52 @@ name: Docker CI/CD - Deploy on: workflow_dispatch: branches: - - test + - develop jobs: deploy: runs-on: self-hosted steps: + - name: Checkout source + uses: actions/checkout@v3 + + - name: Set up JDK 21 + uses: actions/setup-java@v3 + with: + java-version: '21' + distribution: 'temurin' + + - name: Cache Gradle files + uses: actions/cache@v3 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: gradle-${{ runner.os }}-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} + restore-keys: | + gradle-${{ runner.os }}- + + - name: Clone external repo with jar into libs/ + run: | + mkdir -p libs + git clone https://x-access-token:${{ secrets.GH_PAT }}@github.com/mosu-dev/mosu-kmc-jar.git temp-jar + cp temp-jar/*.jar libs/ + + - name: Build with Gradle + run: ./gradlew build -x test + + - name: Log in to DockerHub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_PASSWORD }} + + - name: Build Docker image + run: docker build -t kangtaehyun1107/mosu-server:${{ github.sha }} . + working-directory: + - name: Push Docker image + run: docker push kangtaehyun1107/mosu-server:${{ github.sha }} + - name: Deploy via SSH run: | cd ~/mosu-server From 9e44526783370ee402b0e50698ec269a893ff247 Mon Sep 17 00:00:00 2001 From: wlgns12370 Date: Sat, 9 Aug 2025 11:31:30 +0900 Subject: [PATCH 43/43] =?UTF-8?q?MOSU=20fix:=20=ED=9A=8C=EC=9B=90=EA=B0=80?= =?UTF-8?q?=EC=9E=85=20=EC=A0=95=EB=B3=B4=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../presentation/auth/dto/request/SignUpAccountRequest.java | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/main/java/life/mosu/mosuserver/presentation/auth/dto/request/SignUpAccountRequest.java b/src/main/java/life/mosu/mosuserver/presentation/auth/dto/request/SignUpAccountRequest.java index b4ae1690..2bab8039 100644 --- a/src/main/java/life/mosu/mosuserver/presentation/auth/dto/request/SignUpAccountRequest.java +++ b/src/main/java/life/mosu/mosuserver/presentation/auth/dto/request/SignUpAccountRequest.java @@ -58,7 +58,10 @@ public UserJpaEntity toAuthEntity(PasswordEncoder passwordEncoder) { .agreedToTermsOfService(true) .agreedToPrivacyPolicy(true) .agreedToMarketing(serviceTermRequest.agreedToMarketing()) - .gender(Gender.PENDING) + .gender(Gender.fromName(gender)) + .name(userName) + .phoneNumber(phoneNumber) + .birth(birth) .provider(AuthProvider.MOSU) .userRole(UserRole.ROLE_PENDING) .build();