Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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 {
Expand Down Expand Up @@ -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);
}
}
Original file line number Diff line number Diff line change
@@ -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<Embed> embeds) {

record Embed(String title, String description, int color, List<Field> 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<Field> 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<Field> fields) {
return new DiscordMessage(List.of(
new Embed(title, null, color, fields, Instant.now().toString())
));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package com.ryu.studyhelper.infrastructure.discord;

/**
* Discord Webhook 알림 인터페이스
* 채널별로 분리된 메서드 제공 (스케줄러, 이벤트)
*/
public interface DiscordNotifier {

void sendScheduler(DiscordMessage message);

void sendEvent(DiscordMessage message);
}
Original file line number Diff line number Diff line change
@@ -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
) {}
Original file line number Diff line number Diff line change
@@ -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());
}
}
}
Original file line number Diff line number Diff line change
@@ -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();
}
}
12 changes: 12 additions & 0 deletions src/main/java/com/ryu/studyhelper/member/MemberService.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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}")
Expand Down Expand Up @@ -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;
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package com.ryu.studyhelper.recommendation.dto.internal;

/**
* 배치 작업 결과
*/
public record BatchResult(int totalCount, int successCount, int failCount) {}
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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);
}
}
}
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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);
}
}
}
Loading