diff --git a/src/main/java/com/ryu/studyhelper/auth/GoogleOAuth2UserService.java b/src/main/java/com/ryu/studyhelper/auth/GoogleOAuth2UserService.java index 800a247..6d37887 100644 --- a/src/main/java/com/ryu/studyhelper/auth/GoogleOAuth2UserService.java +++ b/src/main/java/com/ryu/studyhelper/auth/GoogleOAuth2UserService.java @@ -2,6 +2,8 @@ import com.ryu.studyhelper.auth.dto.OAuthInfo; import com.ryu.studyhelper.config.security.PrincipalDetails; +import com.ryu.studyhelper.infrastructure.discord.DiscordMessage; +import com.ryu.studyhelper.infrastructure.discord.DiscordNotifier; import com.ryu.studyhelper.member.repository.MemberRepository; import com.ryu.studyhelper.member.domain.Member; import jakarta.transaction.Transactional; @@ -22,6 +24,7 @@ public class GoogleOAuth2UserService extends DefaultOAuth2UserService { private final MemberRepository memberRepository; + private final DiscordNotifier discordNotifier; @Override public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException { @@ -54,7 +57,25 @@ private Member saveOrUpdateMember(OAuthInfo oAuthInfo) { Member newMember = Member.create("google", oAuthInfo.getProviderId(), oAuthInfo.getEmail()); Member savedMember = memberRepository.save(newMember); log.info("신규 사용자 생성 완료: {}", savedMember.getEmail()); + + try { + discordNotifier.sendEvent(DiscordMessage.event("신규 회원 가입", + "회원 ID", String.valueOf(savedMember.getId()), + "이메일", maskEmail(savedMember.getEmail()) + )); + } catch (Exception e) { + log.warn("Discord 알림 전송 실패", e); + } + return savedMember; } } + + private String maskEmail(String email) { + int atIndex = email.indexOf('@'); + if (atIndex <= 3) { + return email.charAt(0) + "***" + email.substring(atIndex); + } + return email.substring(0, 3) + "***" + email.substring(atIndex); + } } \ No newline at end of file diff --git a/src/main/java/com/ryu/studyhelper/infrastructure/discord/DiscordMessage.java b/src/main/java/com/ryu/studyhelper/infrastructure/discord/DiscordMessage.java new file mode 100644 index 0000000..5eda509 --- /dev/null +++ b/src/main/java/com/ryu/studyhelper/infrastructure/discord/DiscordMessage.java @@ -0,0 +1,67 @@ +package com.ryu.studyhelper.infrastructure.discord; + +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; + +/** + * Discord Embed 메시지 구조 + * + * 사용법: 용도에 맞는 팩토리 메서드를 사용 + * - batchResult() : 배치 작업 결과 (스케줄러) + * - error() : 예외 발생 (스케줄러 실패) + * - event() : 비즈니스 이벤트 (가입, 핸들등록 등) + */ +public record DiscordMessage(List embeds) { + + record Embed(String title, String description, int color, List fields, String timestamp) {} + + record Field(String name, String value, boolean inline) {} + + private static final int COLOR_SUCCESS = 0x2ECC71; + private static final int COLOR_ERROR = 0xE74C3C; + private static final int COLOR_INFO = 0x3498DB; + + /** + * 배치 작업 결과 + * 실패가 있으면 빨간색, 없으면 초록색 + */ + public static DiscordMessage batchResult(String title, int totalCount, int successCount, int failCount, long elapsedMs) { + int color = failCount > 0 ? COLOR_ERROR : COLOR_SUCCESS; + return create(title, color, List.of( + new Field("대상", totalCount + "건", true), + new Field("성공", successCount + "건", true), + new Field("실패", failCount + "건", true), + new Field("소요시간", elapsedMs + "ms", true) + )); + } + + /** + * 예외 발생 (빨간색) + */ + public static DiscordMessage error(String title, Exception e, long elapsedMs) { + return create(title, COLOR_ERROR, List.of( + new Field("예외", e.getClass().getSimpleName(), true), + new Field("메시지", e.getMessage() != null ? e.getMessage() : "(없음)", false), + new Field("소요시간", elapsedMs + "ms", true) + )); + } + + /** + * 비즈니스 이벤트 (파란색) + * keyValues는 이름-값 쌍: "회원 ID", "1", "이메일", "test@..." + */ + public static DiscordMessage event(String title, String... keyValues) { + List fields = new ArrayList<>(); + for (int i = 0; i + 1 < keyValues.length; i += 2) { + fields.add(new Field(keyValues[i], keyValues[i + 1], true)); + } + return create(title, COLOR_INFO, fields); + } + + private static DiscordMessage create(String title, int color, List fields) { + return new DiscordMessage(List.of( + new Embed(title, null, color, fields, Instant.now().toString()) + )); + } +} \ No newline at end of file diff --git a/src/main/java/com/ryu/studyhelper/infrastructure/discord/DiscordNotifier.java b/src/main/java/com/ryu/studyhelper/infrastructure/discord/DiscordNotifier.java new file mode 100644 index 0000000..2208771 --- /dev/null +++ b/src/main/java/com/ryu/studyhelper/infrastructure/discord/DiscordNotifier.java @@ -0,0 +1,12 @@ +package com.ryu.studyhelper.infrastructure.discord; + +/** + * Discord Webhook 알림 인터페이스 + * 채널별로 분리된 메서드 제공 (스케줄러, 이벤트) + */ +public interface DiscordNotifier { + + void sendScheduler(DiscordMessage message); + + void sendEvent(DiscordMessage message); +} diff --git a/src/main/java/com/ryu/studyhelper/infrastructure/discord/DiscordProperties.java b/src/main/java/com/ryu/studyhelper/infrastructure/discord/DiscordProperties.java new file mode 100644 index 0000000..db5ffff --- /dev/null +++ b/src/main/java/com/ryu/studyhelper/infrastructure/discord/DiscordProperties.java @@ -0,0 +1,13 @@ +package com.ryu.studyhelper.infrastructure.discord; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +/** + * Discord Webhook URL 설정 + * 채널별 Webhook URL 바인딩 + */ +@ConfigurationProperties(prefix = "discord.webhooks") +public record DiscordProperties( + String scheduler, + String event +) {} diff --git a/src/main/java/com/ryu/studyhelper/infrastructure/discord/DiscordWebhookNotifier.java b/src/main/java/com/ryu/studyhelper/infrastructure/discord/DiscordWebhookNotifier.java new file mode 100644 index 0000000..ff105a5 --- /dev/null +++ b/src/main/java/com/ryu/studyhelper/infrastructure/discord/DiscordWebhookNotifier.java @@ -0,0 +1,61 @@ +package com.ryu.studyhelper.infrastructure.discord; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Profile; +import org.springframework.http.client.SimpleClientHttpRequestFactory; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestClient; + +import java.time.Duration; + +/** + * Discord Webhook 알림 구현체 (prod 전용) + * RestClient로 Webhook POST 요청 + */ +@Component +@Profile("prod") +@EnableConfigurationProperties(DiscordProperties.class) +@Slf4j +public class DiscordWebhookNotifier implements DiscordNotifier { + + private final DiscordProperties properties; + private final RestClient restClient; + + public DiscordWebhookNotifier(DiscordProperties properties) { + this.properties = properties; + SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory(); + factory.setConnectTimeout(Duration.ofSeconds(3)); + factory.setReadTimeout(Duration.ofSeconds(5)); + this.restClient = RestClient.builder() + .requestFactory(factory) + .build(); + } + + @Override + public void sendScheduler(DiscordMessage message) { + send(properties.scheduler(), message); + } + + @Override + public void sendEvent(DiscordMessage message) { + send(properties.event(), message); + } + + private void send(String webhookUrl, DiscordMessage message) { + if (webhookUrl == null || webhookUrl.isBlank()) { + log.debug("Discord webhook URL이 설정되지 않아 알림을 건너뜁니다"); + return; + } + try { + restClient.post() + .uri(webhookUrl) + .header("Content-Type", "application/json") + .body(message) + .retrieve() + .toBodilessEntity(); + } catch (Exception e) { + log.warn("Discord 알림 전송 실패: {}", e.getMessage()); + } + } +} diff --git a/src/main/java/com/ryu/studyhelper/infrastructure/discord/FakeDiscordNotifier.java b/src/main/java/com/ryu/studyhelper/infrastructure/discord/FakeDiscordNotifier.java new file mode 100644 index 0000000..3f02422 --- /dev/null +++ b/src/main/java/com/ryu/studyhelper/infrastructure/discord/FakeDiscordNotifier.java @@ -0,0 +1,32 @@ +package com.ryu.studyhelper.infrastructure.discord; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Component; + +/** + * 로컬/테스트용 Discord 알림 구현체 + * 실제 Webhook 호출 대신 로그 출력 + */ +@Component +@Profile("!prod") +@Slf4j +public class FakeDiscordNotifier implements DiscordNotifier { + + @Override + public void sendScheduler(DiscordMessage message) { + log.info("[FAKE DISCORD] 스케줄러 알림: {}", extractTitle(message)); + } + + @Override + public void sendEvent(DiscordMessage message) { + log.info("[FAKE DISCORD] 이벤트 알림: {}", extractTitle(message)); + } + + private String extractTitle(DiscordMessage message) { + if (message.embeds() == null || message.embeds().isEmpty()) { + return "(제목 없음)"; + } + return message.embeds().get(0).title(); + } +} diff --git a/src/main/java/com/ryu/studyhelper/member/MemberService.java b/src/main/java/com/ryu/studyhelper/member/MemberService.java index 24627a8..3979e7c 100644 --- a/src/main/java/com/ryu/studyhelper/member/MemberService.java +++ b/src/main/java/com/ryu/studyhelper/member/MemberService.java @@ -3,6 +3,8 @@ import com.ryu.studyhelper.common.enums.CustomResponseStatus; import com.ryu.studyhelper.common.exception.CustomException; import com.ryu.studyhelper.config.security.jwt.JwtUtil; +import com.ryu.studyhelper.infrastructure.discord.DiscordMessage; +import com.ryu.studyhelper.infrastructure.discord.DiscordNotifier; import com.ryu.studyhelper.infrastructure.solvedac.SolvedAcClient; import com.ryu.studyhelper.infrastructure.mail.sender.MailSender; import com.ryu.studyhelper.member.mail.EmailChangeMailBuilder; @@ -35,6 +37,7 @@ public class MemberService { private final JwtUtil jwtUtil; private final MailSender mailSender; private final EmailChangeMailBuilder emailChangeMailBuilder; + private final DiscordNotifier discordNotifier; private final Clock clock; @Value("${FRONTEND_URL:http://localhost:5173}") @@ -80,6 +83,15 @@ public Member verifySolvedAcHandle(Long memberId, String handle) { Member member = getById(memberId); member.changeHandle(handle); + try { + discordNotifier.sendEvent(DiscordMessage.event("핸들 등록", + "회원 ID", String.valueOf(memberId), + "핸들", handle + )); + } catch (Exception e) { + log.warn("Discord 알림 전송 실패", e); + } + return member; } diff --git a/src/main/java/com/ryu/studyhelper/recommendation/dto/internal/BatchResult.java b/src/main/java/com/ryu/studyhelper/recommendation/dto/internal/BatchResult.java new file mode 100644 index 0000000..d79adbb --- /dev/null +++ b/src/main/java/com/ryu/studyhelper/recommendation/dto/internal/BatchResult.java @@ -0,0 +1,6 @@ +package com.ryu.studyhelper.recommendation.dto.internal; + +/** + * 배치 작업 결과 + */ +public record BatchResult(int totalCount, int successCount, int failCount) {} diff --git a/src/main/java/com/ryu/studyhelper/recommendation/scheduler/EmailSendScheduler.java b/src/main/java/com/ryu/studyhelper/recommendation/scheduler/EmailSendScheduler.java index 2342dbc..414b9f6 100644 --- a/src/main/java/com/ryu/studyhelper/recommendation/scheduler/EmailSendScheduler.java +++ b/src/main/java/com/ryu/studyhelper/recommendation/scheduler/EmailSendScheduler.java @@ -1,5 +1,8 @@ package com.ryu.studyhelper.recommendation.scheduler; +import com.ryu.studyhelper.infrastructure.discord.DiscordMessage; +import com.ryu.studyhelper.infrastructure.discord.DiscordNotifier; +import com.ryu.studyhelper.recommendation.dto.internal.BatchResult; import com.ryu.studyhelper.recommendation.service.RecommendationEmailService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -16,26 +19,38 @@ public class EmailSendScheduler { private final RecommendationEmailService recommendationEmailService; + private final DiscordNotifier discordNotifier; - /** - * 매일 오전 9시에 이메일 발송 - * Cron 표현식: "0 0 9 * * *" = 매일 9시 0분 0초 - */ @Scheduled(cron = "0 0 9 * * *", zone = "Asia/Seoul") public void sendPendingEmails() { log.info("=== 이메일 발송 배치 작업 시작 ==="); long startTime = System.currentTimeMillis(); + BatchResult result = null; + Exception failure = null; try { - recommendationEmailService.sendAll(); + result = recommendationEmailService.sendAll(); + log.info("=== 이메일 발송 배치 작업 완료 === (소요시간: {}ms)", System.currentTimeMillis() - startTime); + } catch (Exception e) { + failure = e; + log.error("=== 이메일 발송 배치 작업 실패 === (소요시간: {}ms)", System.currentTimeMillis() - startTime, e); + } - long endTime = System.currentTimeMillis(); - log.info("=== 이메일 발송 배치 작업 완료 === (소요시간: {}ms)", endTime - startTime); + notifyDiscord(result, failure, System.currentTimeMillis() - startTime); + } + private void notifyDiscord(BatchResult result, Exception failure, long elapsed) { + try { + if (failure != null) { + discordNotifier.sendScheduler(DiscordMessage.error("이메일 발송 배치 실패", failure, elapsed)); + } else { + String title = result.failCount() > 0 ? "이메일 발송 배치 실패" : "이메일 발송 배치 완료"; + discordNotifier.sendScheduler(DiscordMessage.batchResult( + title, result.totalCount(), result.successCount(), result.failCount(), elapsed)); + } } catch (Exception e) { - long endTime = System.currentTimeMillis(); - log.error("=== 이메일 발송 배치 작업 실패 === (소요시간: {}ms)", endTime - startTime, e); + log.warn("Discord 알림 전송 실패", e); } } } diff --git a/src/main/java/com/ryu/studyhelper/recommendation/scheduler/ProblemRecommendationScheduler.java b/src/main/java/com/ryu/studyhelper/recommendation/scheduler/ProblemRecommendationScheduler.java index b2d70bb..51b3ef9 100644 --- a/src/main/java/com/ryu/studyhelper/recommendation/scheduler/ProblemRecommendationScheduler.java +++ b/src/main/java/com/ryu/studyhelper/recommendation/scheduler/ProblemRecommendationScheduler.java @@ -1,5 +1,8 @@ package com.ryu.studyhelper.recommendation.scheduler; +import com.ryu.studyhelper.infrastructure.discord.DiscordMessage; +import com.ryu.studyhelper.infrastructure.discord.DiscordNotifier; +import com.ryu.studyhelper.recommendation.dto.internal.BatchResult; import com.ryu.studyhelper.recommendation.service.ScheduledRecommendationService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -16,26 +19,38 @@ public class ProblemRecommendationScheduler { private final ScheduledRecommendationService scheduledRecommendationService; + private final DiscordNotifier discordNotifier; - /** - * 매일 오전 6시에 문제 추천 준비 - * Cron 표현식: "0 0 6 * * *" = 매일 6시 0분 0초 - */ @Scheduled(cron = "0 0 6 * * *", zone = "Asia/Seoul") public void prepareDailyRecommendations() { log.info("=== 문제 추천 배치 작업 시작 ==="); long startTime = System.currentTimeMillis(); + BatchResult result = null; + Exception failure = null; try { - scheduledRecommendationService.prepareDailyRecommendations(); + result = scheduledRecommendationService.prepareDailyRecommendations(); + log.info("=== 문제 추천 배치 작업 완료 === (소요시간: {}ms)", System.currentTimeMillis() - startTime); + } catch (Exception e) { + failure = e; + log.error("=== 문제 추천 배치 작업 실패 === (소요시간: {}ms)", System.currentTimeMillis() - startTime, e); + } - long endTime = System.currentTimeMillis(); - log.info("=== 문제 추천 배치 작업 완료 === (소요시간: {}ms)", endTime - startTime); + notifyDiscord(result, failure, System.currentTimeMillis() - startTime); + } + private void notifyDiscord(BatchResult result, Exception failure, long elapsed) { + try { + if (failure != null) { + discordNotifier.sendScheduler(DiscordMessage.error("문제 추천 배치 실패", failure, elapsed)); + } else { + String title = result.failCount() > 0 ? "문제 추천 배치 실패" : "문제 추천 배치 완료"; + discordNotifier.sendScheduler(DiscordMessage.batchResult( + title, result.totalCount(), result.successCount(), result.failCount(), elapsed)); + } } catch (Exception e) { - long endTime = System.currentTimeMillis(); - log.error("=== 문제 추천 배치 작업 실패 === (소요시간: {}ms)", endTime - startTime, e); + log.warn("Discord 알림 전송 실패", e); } } } diff --git a/src/main/java/com/ryu/studyhelper/recommendation/service/RecommendationEmailService.java b/src/main/java/com/ryu/studyhelper/recommendation/service/RecommendationEmailService.java index 601fbab..e5a03d4 100644 --- a/src/main/java/com/ryu/studyhelper/recommendation/service/RecommendationEmailService.java +++ b/src/main/java/com/ryu/studyhelper/recommendation/service/RecommendationEmailService.java @@ -1,5 +1,6 @@ package com.ryu.studyhelper.recommendation.service; + import com.ryu.studyhelper.recommendation.dto.internal.BatchResult; import com.ryu.studyhelper.infrastructure.mail.sender.MailSender; import com.ryu.studyhelper.recommendation.domain.member.EmailSendStatus; import com.ryu.studyhelper.recommendation.domain.member.MemberRecommendation; @@ -33,7 +34,7 @@ public class RecommendationEmailService { * 배치: PENDING 상태의 추천들에 대해 이메일 발송 * 미션 사이클 기준(06:00~06:00)으로 조회 */ - public void sendAll() { + public BatchResult sendAll() { LocalDateTime now = LocalDateTime.now(clock); LocalDateTime missionCycleStart = MissionCyclePolicy.getMissionCycleStart(clock); log.info("이메일 발송 배치 시작: {} (미션 사이클: {} 06:00 ~)", now.toLocalDate(), missionCycleStart.toLocalDate()); @@ -54,6 +55,7 @@ public void sendAll() { log.info("이메일 발송 배치 완료 - 대상: {}개, 성공: {}개, 실패: {}개", pendingRecommendations.size(), successCount, failCount); + return new BatchResult(pendingRecommendations.size(), successCount, failCount); } /** diff --git a/src/main/java/com/ryu/studyhelper/recommendation/service/ScheduledRecommendationService.java b/src/main/java/com/ryu/studyhelper/recommendation/service/ScheduledRecommendationService.java index c15c4ec..3eb613a 100644 --- a/src/main/java/com/ryu/studyhelper/recommendation/service/ScheduledRecommendationService.java +++ b/src/main/java/com/ryu/studyhelper/recommendation/service/ScheduledRecommendationService.java @@ -1,5 +1,6 @@ package com.ryu.studyhelper.recommendation.service; +import com.ryu.studyhelper.recommendation.dto.internal.BatchResult; import com.ryu.studyhelper.recommendation.domain.RecommendationType; import com.ryu.studyhelper.recommendation.repository.RecommendationRepository; import com.ryu.studyhelper.team.domain.Team; @@ -34,7 +35,7 @@ public class ScheduledRecommendationService { * 문제 추천만 수행 (이메일 발송 X) * 미션 사이클 기준(06:00~06:00)으로 중복 체크 */ - public void prepareDailyRecommendations() { + public BatchResult prepareDailyRecommendations() { LocalDateTime now = LocalDateTime.now(clock); LocalDateTime missionCycleStart = MissionCyclePolicy.getMissionCycleStart(clock); log.info("문제 추천 준비 시작: {} (미션 사이클: {} 06:00 ~)", now.toLocalDate(), missionCycleStart.toLocalDate()); @@ -64,6 +65,7 @@ public void prepareDailyRecommendations() { log.info("문제 추천 배치 완료 - 대상: {}개, 성공: {}개, 실패: {}개", activeTeams.size(), successCount, failCount); + return new BatchResult(activeTeams.size(), successCount, failCount); } private List getActiveTeams(LocalDate date) { diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 3e85ea5..69283b7 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -38,6 +38,12 @@ aws: from-email: ${AWS_SES_FROM_EMAIL:noreply@example.com} configuration-set: ${AWS_SES_CONFIGURATION_SET:} +# Discord Webhook 설정 +discord: + webhooks: + scheduler: ${DISCORD_WEBHOOK_SCHEDULER:} + event: ${DISCORD_WEBHOOK_EVENT:} + # JWT 공통 설정 jwt: secret: ${JWT_SECRET}