diff --git a/eeos/build.gradle.kts b/eeos/build.gradle.kts index 9f8bc691..e4279703 100644 --- a/eeos/build.gradle.kts +++ b/eeos/build.gradle.kts @@ -67,6 +67,11 @@ dependencies { // Firebase Admin SDK implementation(libs.firebase.admin) + implementation(libs.spring.retry) + implementation(libs.spring.aspects) + + + } dependencyManagement { diff --git a/eeos/gradle/libs.versions.toml b/eeos/gradle/libs.versions.toml index 67f0a322..9c561931 100644 --- a/eeos/gradle/libs.versions.toml +++ b/eeos/gradle/libs.versions.toml @@ -22,6 +22,10 @@ spring-security-test="6.5.2" # Database flyway = "10.7.1" +# Retry +spring-retry = "2.0.5" + + [libraries] # Spring Boot spring-boot-starter-web = { group = "org.springframework.boot", name = "spring-boot-starter-web" } @@ -61,6 +65,11 @@ lombok = { group = "org.projectlombok", name = "lombok" } # Firebase Admin SDK firebase-admin = { group = "com.google.firebase", name = "firebase-admin", version.ref = "firebase-admin" } +# Retry +spring-retry = { group = "org.springframework.retry", name = "spring-retry", version.ref = "spring-retry" } +spring-aspects = { group = "org.springframework", name = "spring-aspects" } + + [plugins] spring-boot = { id = "org.springframework.boot", version.ref = "spring-boot" } spring-dependency-management = { id = "io.spring.dependency-management", version.ref = "spring-dependency-management" } diff --git a/eeos/src/main/java/com/blackcompany/eeos/config/RetryConfig.java b/eeos/src/main/java/com/blackcompany/eeos/config/RetryConfig.java new file mode 100644 index 00000000..678e2e47 --- /dev/null +++ b/eeos/src/main/java/com/blackcompany/eeos/config/RetryConfig.java @@ -0,0 +1,8 @@ +package com.blackcompany.eeos.config; + +import org.springframework.context.annotation.Configuration; +import org.springframework.retry.annotation.EnableRetry; + +@Configuration +@EnableRetry +public class RetryConfig {} diff --git a/eeos/src/main/java/com/blackcompany/eeos/notification/application/repository/MemberPushTokenRepository.java b/eeos/src/main/java/com/blackcompany/eeos/notification/application/repository/MemberPushTokenRepository.java index b7754175..17db4b3d 100644 --- a/eeos/src/main/java/com/blackcompany/eeos/notification/application/repository/MemberPushTokenRepository.java +++ b/eeos/src/main/java/com/blackcompany/eeos/notification/application/repository/MemberPushTokenRepository.java @@ -23,4 +23,6 @@ List findByMemberIdAndProvider( int deleteByUpdatedDateBefore(LocalDateTime updatedDate); MemberPushTokenModel save(MemberPushTokenModel memberPushToken); + + int deleteByLastActiveAtBefore(LocalDateTime limitDate); } diff --git a/eeos/src/main/java/com/blackcompany/eeos/notification/application/scheduler/PushTokenCleanScheduler.java b/eeos/src/main/java/com/blackcompany/eeos/notification/application/scheduler/PushTokenCleanScheduler.java new file mode 100644 index 00000000..b912fe95 --- /dev/null +++ b/eeos/src/main/java/com/blackcompany/eeos/notification/application/scheduler/PushTokenCleanScheduler.java @@ -0,0 +1,51 @@ +package com.blackcompany.eeos.notification.application.scheduler; + +import com.blackcompany.eeos.notification.application.service.NotificationTokenService; +import com.blackcompany.eeos.notification.application.service.SlackNotificationService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.retry.annotation.Backoff; +import org.springframework.retry.annotation.Recover; +import org.springframework.retry.annotation.Retryable; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +@Component +@Slf4j +@RequiredArgsConstructor +public class PushTokenCleanScheduler { + + private final SlackNotificationService slackNotificationService; + private final NotificationTokenService notificationTokenService; + + private static final int INACTIVE_DAYS_THRESHOLD = 90; + private static final String SCHEDULER_NAME = "비활성화 토큰 삭제 스케줄러"; + + /* + * 매주 토요일 새벽 3시 90일 이상 비활성화된 푸시 토큰 삭제 + * cron : 초 분 시 일 월 요일(6=토요일) + * */ + + @Scheduled(cron = "0 0 3 * * 6") + @Retryable( + maxAttempts = 3, + backoff = @Backoff(delay = 2000), + recover = "recoverDeleteInactiveTokens") + public void deleteInactiveTokens() { + log.info("{} 시작", SCHEDULER_NAME); + int deleteCount = notificationTokenService.deleteInactiveTokens(); + log.info("{}개의 비활성화 토큰 삭제 완료", deleteCount); + } + + @Recover + public void recoverDeleteInactiveTokens(Exception e) { + log.error("{} 실행 실패 - 모든 재시도 소진", SCHEDULER_NAME, e); + + String errorMessage = e.getMessage() != null ? e.getMessage() : e.getClass().getSimpleName(); + + if (errorMessage.length() > 300) { + errorMessage = errorMessage.substring(0, 300) + "...생략"; + } + slackNotificationService.sendSchedulerFailureMessage(SCHEDULER_NAME, errorMessage); + } +} diff --git a/eeos/src/main/java/com/blackcompany/eeos/notification/application/service/NotificationTokenService.java b/eeos/src/main/java/com/blackcompany/eeos/notification/application/service/NotificationTokenService.java index bdb01e86..5ebd7a55 100644 --- a/eeos/src/main/java/com/blackcompany/eeos/notification/application/service/NotificationTokenService.java +++ b/eeos/src/main/java/com/blackcompany/eeos/notification/application/service/NotificationTokenService.java @@ -24,6 +24,7 @@ public class NotificationTokenService DeleteMemberPushTokenUsecase { private final MemberPushTokenRepository memberPushTokenRepository; + private static final int INACTIVE_DAYS_THRESHOLD = 90; @Override @Transactional @@ -64,4 +65,10 @@ public void delete(Long memberId, DeleteMemberPushTokenRequest request) { } memberPushTokenRepository.deleteByPushToken(request.getPushToken()); } + + @Transactional + public int deleteInactiveTokens() { + LocalDateTime limitDate = LocalDateTime.now().minusDays(INACTIVE_DAYS_THRESHOLD); + return memberPushTokenRepository.deleteByLastActiveAtBefore(limitDate); + } } diff --git a/eeos/src/main/java/com/blackcompany/eeos/notification/application/service/SlackNotificationService.java b/eeos/src/main/java/com/blackcompany/eeos/notification/application/service/SlackNotificationService.java new file mode 100644 index 00000000..7e280b4d --- /dev/null +++ b/eeos/src/main/java/com/blackcompany/eeos/notification/application/service/SlackNotificationService.java @@ -0,0 +1,54 @@ +package com.blackcompany.eeos.notification.application.service; + +import com.blackcompany.eeos.program.infra.api.slack.chat.client.SlackChatApiClient; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.List; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +@Slf4j +@Service +@RequiredArgsConstructor +public class SlackNotificationService { + + private final SlackChatApiClient slackChatApiClient; + private final ObjectMapper objectMapper; + + @Value("${slack.bot.black-company.eeos}") + private String botToken; + + @Value("${slack.channel.black-company.error-report}") + private String errorReportChannel; + + public void sendSchedulerFailureMessage(String schedulerName, String errorMessage) { + String message = + String.format( + ":rotating_light: *스케줄러 실행 실패 알림*\n\n" + + "*Scheduler*\n `%s`\n\n" + + "*Failed At*\n`%s`\n\n" + + "*Error Message*\n`%s`\n\n", + schedulerName, + LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")), + errorMessage); + sendMessage(message); + } + + private void sendMessage(String text) { + try { + String blocks = + objectMapper.writeValueAsString( + List.of(Map.of("type", "section", "text", Map.of("type", "mrkdwn", "text", text)))); + + slackChatApiClient.post( + "Bearer " + botToken, errorReportChannel, blocks, "EEOS Scheduler Bot"); + + } catch (Exception e) { + log.error("Slack 알림 전송 실패", e); + } + } +} diff --git a/eeos/src/main/java/com/blackcompany/eeos/notification/persistence/JpaMemberPushTokenRepository.java b/eeos/src/main/java/com/blackcompany/eeos/notification/persistence/JpaMemberPushTokenRepository.java index bc8a0eb1..24392acb 100644 --- a/eeos/src/main/java/com/blackcompany/eeos/notification/persistence/JpaMemberPushTokenRepository.java +++ b/eeos/src/main/java/com/blackcompany/eeos/notification/persistence/JpaMemberPushTokenRepository.java @@ -2,6 +2,7 @@ import com.blackcompany.eeos.notification.application.model.NotificationProvider; import java.sql.Timestamp; +import java.time.LocalDateTime; import java.util.List; import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; @@ -27,4 +28,8 @@ List findByMemberIdAndProvider( void deleteByPushToken(String token); int deleteByUpdatedDateBefore(Timestamp updatedDate); + + @Modifying + @Query("DELETE FROM MemberPushTokenEntity t where t.lastActiveAt < :limitDate") + int deleteByLastActiveAtBefore(@Param("limitDate") LocalDateTime limitDate); } diff --git a/eeos/src/main/java/com/blackcompany/eeos/notification/persistence/MemberPushTokenEntity.java b/eeos/src/main/java/com/blackcompany/eeos/notification/persistence/MemberPushTokenEntity.java index c4f3bdaa..88a1940e 100644 --- a/eeos/src/main/java/com/blackcompany/eeos/notification/persistence/MemberPushTokenEntity.java +++ b/eeos/src/main/java/com/blackcompany/eeos/notification/persistence/MemberPushTokenEntity.java @@ -24,10 +24,10 @@ @AllArgsConstructor(access = AccessLevel.PRIVATE) @ToString @SuperBuilder(toBuilder = true) -@Table(name = "member_push_token") +@Table(name = "Notification_token") public class MemberPushTokenEntity extends BaseEntity { - public static final String ENTITY_PREFIX = "member_push_token"; + public static final String ENTITY_PREFIX = "Notification_token"; @Id @GeneratedValue(strategy = GenerationType.IDENTITY) diff --git a/eeos/src/main/java/com/blackcompany/eeos/notification/persistence/MemberPushTokenRepositoryImpl.java b/eeos/src/main/java/com/blackcompany/eeos/notification/persistence/MemberPushTokenRepositoryImpl.java index f449b2dd..5fcdacc4 100644 --- a/eeos/src/main/java/com/blackcompany/eeos/notification/persistence/MemberPushTokenRepositoryImpl.java +++ b/eeos/src/main/java/com/blackcompany/eeos/notification/persistence/MemberPushTokenRepositoryImpl.java @@ -65,4 +65,9 @@ public MemberPushTokenModel save(MemberPushTokenModel memberPushToken) { MemberPushTokenEntity saved = jpaRepository.save(memberPushTokenEntity); return converter.from(saved); } + + @Override + public int deleteByLastActiveAtBefore(LocalDateTime limitDate) { + return jpaRepository.deleteByLastActiveAtBefore(limitDate); + } } diff --git a/eeos/src/main/resources/application-slack.yml b/eeos/src/main/resources/application-slack.yml index 8a4c8624..8d57033e 100644 --- a/eeos/src/main/resources/application-slack.yml +++ b/eeos/src/main/resources/application-slack.yml @@ -14,6 +14,8 @@ slack: black-company: slack-message-test: ${BLACK_COMPANY_TEST_CHANNEL_ID} notification : ${BLACK_COMPANY_NOTIFICATION_CHANNEL_ID} + error-report : ${BLACK_COMPANY_ERROR_REPORT_CHANNEL_ID} + econovation: notification: ${ECONOVATION_NOTIFICATION_CHANNEL_ID} small_talk: ${ECONOVATION_SMALL_TALK_CHANNEL_ID} diff --git a/eeos/src/main/resources/db/migration/V1.00.0.0__init_tables.sql b/eeos/src/main/resources/db/migration/V1.00.0.0__init_tables.sql index 0a267217..6f539cf3 100644 --- a/eeos/src/main/resources/db/migration/V1.00.0.0__init_tables.sql +++ b/eeos/src/main/resources/db/migration/V1.00.0.0__init_tables.sql @@ -101,4 +101,3 @@ CREATE TABLE `restrict_team_building` ( -