From a56a3eda2232901c4d96eafea9af1beabd97ce80 Mon Sep 17 00:00:00 2001 From: joona95 Date: Fri, 3 Jul 2026 22:43:08 +0900 Subject: [PATCH 1/3] =?UTF-8?q?feat:=20500=20=EC=97=90=EB=9F=AC=20?= =?UTF-8?q?=EB=B0=9C=EC=83=9D=20=EC=8B=9C=20=EB=94=94=EC=8A=A4=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=EC=95=8C=EB=9E=8C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../config/GeneralExceptionHandler.java | 26 +++++ .../src/common/utils/DiscordAlertService.java | 103 ++++++++++++++++++ 2 files changed, 129 insertions(+) create mode 100644 src/main/java/com/recipe/app/src/common/utils/DiscordAlertService.java diff --git a/src/main/java/com/recipe/app/src/common/config/GeneralExceptionHandler.java b/src/main/java/com/recipe/app/src/common/config/GeneralExceptionHandler.java index bd42f57a..4de6d5aa 100644 --- a/src/main/java/com/recipe/app/src/common/config/GeneralExceptionHandler.java +++ b/src/main/java/com/recipe/app/src/common/config/GeneralExceptionHandler.java @@ -8,21 +8,33 @@ import com.recipe.app.src.ingredient.exception.NotFoundIngredientException; import com.recipe.app.src.recipe.exception.NotFoundRecipeException; import com.recipe.app.src.recipe.exception.NotFoundRecipeLevelException; +import com.recipe.app.src.common.utils.DiscordAlertService; import com.recipe.app.src.user.exception.ForbiddenAccessException; import com.recipe.app.src.user.exception.NotFoundUserException; import com.recipe.app.src.user.exception.UserTokenNotExistException; +import jakarta.servlet.http.HttpServletRequest; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.ObjectProvider; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.ControllerAdvice; import org.springframework.web.bind.annotation.ExceptionHandler; +import java.util.Map; + @ControllerAdvice public class GeneralExceptionHandler { private final Logger log = LoggerFactory.getLogger(getClass()); + // DiscordAlertService 는 prod 프로필에서만 빈으로 등록되므로 ObjectProvider 로 안전하게 주입한다. + private final ObjectProvider discordAlertServiceProvider; + + public GeneralExceptionHandler(ObjectProvider discordAlertServiceProvider) { + this.discordAlertServiceProvider = discordAlertServiceProvider; + } + @ExceptionHandler({NotFoundFridgeException.class, NotFoundIngredientException.class, NotFoundFridgeBasketException.class, NotFoundIngredientCategoryException.class, NotFoundNoticeException.class, NotFoundRecipeLevelException.class, NotFoundRecipeException.class, NotFoundUserException.class}) @@ -42,4 +54,18 @@ public ResponseEntity handleBadRequestException(Exception e) { log.error(e.getMessage()); return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(e); } + + // 위에서 처리되지 않은 모든 예외 = 500. 로그를 남기고 (prod 한정) Discord 로 알림을 보낸다. + @ExceptionHandler(Exception.class) + public ResponseEntity handleInternalServerError(Exception e, HttpServletRequest request) { + log.error("Internal Server Error. {} {}", request.getMethod(), request.getRequestURI(), e); + + DiscordAlertService discordAlertService = discordAlertServiceProvider.getIfAvailable(); + if (discordAlertService != null) { + discordAlertService.sendErrorAlert(request.getMethod(), request.getRequestURI(), e); + } + + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) + .body(Map.of("message", "서버 오류가 발생했습니다.")); + } } diff --git a/src/main/java/com/recipe/app/src/common/utils/DiscordAlertService.java b/src/main/java/com/recipe/app/src/common/utils/DiscordAlertService.java new file mode 100644 index 00000000..04e38bd1 --- /dev/null +++ b/src/main/java/com/recipe/app/src/common/utils/DiscordAlertService.java @@ -0,0 +1,103 @@ +package com.recipe.app.src.common.utils; + +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import okhttp3.*; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; + +import java.io.IOException; +import java.io.PrintWriter; +import java.io.StringWriter; +import java.util.List; +import java.util.Map; + +/** + * 처리되지 않은 예외(500)를 Discord 채널로 알림 전송한다. + * 운영(prod) 프로필에서만 빈으로 등록되며, 요청 스레드를 막지 않도록 비동기(enqueue)로 전송한다. + */ +@Component +@Profile("prod") +@RequiredArgsConstructor +public class DiscordAlertService { + + private static final Logger log = LoggerFactory.getLogger(DiscordAlertService.class); + private static final int RED = 15158332; + private static final int MAX_STACKTRACE_LENGTH = 1000; // Discord embed field value 제한(1024) 대응 + + @Value("${discord.webhook.url:}") + private String webhookUrl; + + private final ObjectMapper objectMapper; + private final OkHttpClient client = new OkHttpClient(); + + public void sendErrorAlert(String httpMethod, String requestUri, Throwable throwable) { + if (!StringUtils.hasText(webhookUrl)) { + log.warn("Discord webhook URL 이 설정되지 않아 알림을 건너뜁니다."); + return; + } + + try { + String payload = makePayload(httpMethod, requestUri, throwable); + RequestBody body = RequestBody.create(payload, MediaType.get("application/json; charset=utf-8")); + Request request = new Request.Builder() + .url(webhookUrl) + .post(body) + .build(); + + client.newCall(request).enqueue(new Callback() { + @Override + public void onFailure(Call call, IOException e) { + log.error("Discord 알림 전송 실패", e); + } + + @Override + public void onResponse(Call call, Response response) { + try (response) { + if (!response.isSuccessful()) { + log.error("Discord 알림 전송 거절. status={}", response.code()); + } + } + } + }); + } catch (Exception e) { + // 알림 전송 실패가 원본 요청 처리를 방해해서는 안 된다. + log.error("Discord 알림 페이로드 생성 실패", e); + } + } + + private String makePayload(String httpMethod, String requestUri, Throwable throwable) throws IOException { + Map embed = Map.of( + "title", "🚨 500 Internal Server Error", + "color", RED, + "fields", List.of( + Map.of("name", "Endpoint", "value", httpMethod + " " + requestUri, "inline", false), + Map.of("name", "Exception", "value", throwable.getClass().getSimpleName(), "inline", false), + Map.of("name", "Message", "value", truncate(throwable.getMessage()), "inline", false), + Map.of("name", "Stacktrace", "value", "```" + stackTrace(throwable) + "```", "inline", false) + ) + ); + return objectMapper.writeValueAsString(Map.of("embeds", List.of(embed))); + } + + private String stackTrace(Throwable throwable) { + StringWriter sw = new StringWriter(); + throwable.printStackTrace(new PrintWriter(sw)); + return truncate(sw.toString(), MAX_STACKTRACE_LENGTH); + } + + private String truncate(String value) { + return truncate(value, 1000); + } + + private String truncate(String value, int maxLength) { + if (!StringUtils.hasText(value)) { + return "(none)"; + } + return value.length() > maxLength ? value.substring(0, maxLength) + "..." : value; + } +} From f0d435cf3adb9fab36269fd1b7c0edd483567bf3 Mon Sep 17 00:00:00 2001 From: joona95 Date: Fri, 3 Jul 2026 22:53:09 +0900 Subject: [PATCH 2/3] =?UTF-8?q?feat:=20=EC=8B=A0=EA=B3=A0=20=EB=B0=9C?= =?UTF-8?q?=EC=83=9D=20=EC=8B=9C=20=EB=94=94=EC=8A=A4=EC=BD=94=EB=93=9C=20?= =?UTF-8?q?=EC=95=8C=EB=9E=8C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/common/utils/DiscordAlertService.java | 59 ++++++++++++++----- .../RecipeReportAlertListener.java | 30 ++++++++++ .../application/RecipeReportService.java | 26 ++++++-- .../event/RecipeReportedEvent.java | 8 +++ 4 files changed, 102 insertions(+), 21 deletions(-) create mode 100644 src/main/java/com/recipe/app/src/recipe/application/RecipeReportAlertListener.java create mode 100644 src/main/java/com/recipe/app/src/recipe/application/event/RecipeReportedEvent.java diff --git a/src/main/java/com/recipe/app/src/common/utils/DiscordAlertService.java b/src/main/java/com/recipe/app/src/common/utils/DiscordAlertService.java index 04e38bd1..13b37600 100644 --- a/src/main/java/com/recipe/app/src/common/utils/DiscordAlertService.java +++ b/src/main/java/com/recipe/app/src/common/utils/DiscordAlertService.java @@ -17,7 +17,7 @@ import java.util.Map; /** - * 처리되지 않은 예외(500)를 Discord 채널로 알림 전송한다. + * 서버 이벤트(500 에러, 레시피 신고)를 Discord 채널로 알림 전송한다. * 운영(prod) 프로필에서만 빈으로 등록되며, 요청 스레드를 막지 않도록 비동기(enqueue)로 전송한다. */ @Component @@ -27,22 +27,61 @@ public class DiscordAlertService { private static final Logger log = LoggerFactory.getLogger(DiscordAlertService.class); private static final int RED = 15158332; + private static final int ORANGE = 15105570; private static final int MAX_STACKTRACE_LENGTH = 1000; // Discord embed field value 제한(1024) 대응 @Value("${discord.webhook.url:}") - private String webhookUrl; + private String errorWebhookUrl; + + @Value("${discord.webhook.report-url:}") + private String reportWebhookUrl; private final ObjectMapper objectMapper; private final OkHttpClient client = new OkHttpClient(); + /** + * 처리되지 않은 예외(500) 알림. + */ public void sendErrorAlert(String httpMethod, String requestUri, Throwable throwable) { + Map embed = Map.of( + "title", "🚨 500 Internal Server Error", + "color", RED, + "fields", List.of( + field("Endpoint", httpMethod + " " + requestUri), + field("Exception", throwable.getClass().getSimpleName()), + field("Message", truncate(throwable.getMessage())), + field("Stacktrace", "```" + stackTrace(throwable) + "```") + ) + ); + send(errorWebhookUrl, embed); + } + + /** + * 레시피 신고 접수 알림. + * + * @param reachedThreshold 누적 신고가 숨김 처리 기준 이상인지 여부 + */ + public void sendReportAlert(long recipeId, long reporterUserId, long reportCount, boolean reachedThreshold) { + Map embed = Map.of( + "title", reachedThreshold ? "⛔ 레시피 신고 (기준 초과)" : "⚠️ 레시피 신고 접수", + "color", reachedThreshold ? RED : ORANGE, + "fields", List.of( + field("Recipe ID", String.valueOf(recipeId)), + field("신고자 User ID", String.valueOf(reporterUserId)), + field("누적 신고 수", reportCount + "회" + (reachedThreshold ? " (기준 초과 · 숨김 처리 대상)" : "")) + ) + ); + send(reportWebhookUrl, embed); + } + + private void send(String webhookUrl, Map embed) { if (!StringUtils.hasText(webhookUrl)) { log.warn("Discord webhook URL 이 설정되지 않아 알림을 건너뜁니다."); return; } try { - String payload = makePayload(httpMethod, requestUri, throwable); + String payload = objectMapper.writeValueAsString(Map.of("embeds", List.of(embed))); RequestBody body = RequestBody.create(payload, MediaType.get("application/json; charset=utf-8")); Request request = new Request.Builder() .url(webhookUrl) @@ -70,18 +109,8 @@ public void onResponse(Call call, Response response) { } } - private String makePayload(String httpMethod, String requestUri, Throwable throwable) throws IOException { - Map embed = Map.of( - "title", "🚨 500 Internal Server Error", - "color", RED, - "fields", List.of( - Map.of("name", "Endpoint", "value", httpMethod + " " + requestUri, "inline", false), - Map.of("name", "Exception", "value", throwable.getClass().getSimpleName(), "inline", false), - Map.of("name", "Message", "value", truncate(throwable.getMessage()), "inline", false), - Map.of("name", "Stacktrace", "value", "```" + stackTrace(throwable) + "```", "inline", false) - ) - ); - return objectMapper.writeValueAsString(Map.of("embeds", List.of(embed))); + private Map field(String name, String value) { + return Map.of("name", name, "value", truncate(value), "inline", false); } private String stackTrace(Throwable throwable) { diff --git a/src/main/java/com/recipe/app/src/recipe/application/RecipeReportAlertListener.java b/src/main/java/com/recipe/app/src/recipe/application/RecipeReportAlertListener.java new file mode 100644 index 00000000..fe27502b --- /dev/null +++ b/src/main/java/com/recipe/app/src/recipe/application/RecipeReportAlertListener.java @@ -0,0 +1,30 @@ +package com.recipe.app.src.recipe.application; + +import com.recipe.app.src.common.utils.DiscordAlertService; +import com.recipe.app.src.recipe.application.event.RecipeReportedEvent; +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +/** + * 레시피 신고 이벤트를 받아 Discord 알림을 전송한다. + * 트랜잭션이 실제로 커밋된 뒤에만 동작하므로 롤백 시 유령 알림이 발생하지 않는다. + * DiscordAlertService 와 동일하게 prod 프로필에서만 등록된다. (다른 프로필에서는 이벤트가 무시됨) + */ +@Component +@Profile("prod") +public class RecipeReportAlertListener { + + private final DiscordAlertService discordAlertService; + + public RecipeReportAlertListener(DiscordAlertService discordAlertService) { + this.discordAlertService = discordAlertService; + } + + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void handleRecipeReported(RecipeReportedEvent event) { + discordAlertService.sendReportAlert(event.recipeId(), event.reporterUserId(), + event.reportCount(), event.reachedThreshold()); + } +} diff --git a/src/main/java/com/recipe/app/src/recipe/application/RecipeReportService.java b/src/main/java/com/recipe/app/src/recipe/application/RecipeReportService.java index 97665e9a..96e2694f 100644 --- a/src/main/java/com/recipe/app/src/recipe/application/RecipeReportService.java +++ b/src/main/java/com/recipe/app/src/recipe/application/RecipeReportService.java @@ -1,7 +1,9 @@ package com.recipe.app.src.recipe.application; +import com.recipe.app.src.recipe.application.event.RecipeReportedEvent; import com.recipe.app.src.recipe.domain.RecipeReport; import com.recipe.app.src.recipe.infra.RecipeReportRepository; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -12,20 +14,32 @@ public class RecipeReportService { private static final int RECIPE_REPORT_MIN_CNT = 5; private final RecipeReportRepository recipeReportRepository; + private final ApplicationEventPublisher eventPublisher; - public RecipeReportService(RecipeReportRepository recipeReportRepository) { + public RecipeReportService(RecipeReportRepository recipeReportRepository, + ApplicationEventPublisher eventPublisher) { this.recipeReportRepository = recipeReportRepository; + this.eventPublisher = eventPublisher; } @Transactional public void createRecipeReport(long userId, long recipeId) { recipeReportRepository.findByUserIdAndRecipeId(userId, recipeId) - .orElseGet(() -> recipeReportRepository.save( - RecipeReport.builder() - .userId(userId) - .recipeId(recipeId) - .build())); + .ifPresentOrElse( + report -> {}, // 이미 신고한 경우: 중복 저장/알림하지 않는다. + () -> { + recipeReportRepository.save( + RecipeReport.builder() + .userId(userId) + .recipeId(recipeId) + .build()); + + long reportCount = recipeReportRepository.countByRecipeId(recipeId); + // 실제 알림 전송은 커밋 이후에 리스너가 처리한다. (롤백 시 유령 알림 방지) + eventPublisher.publishEvent(new RecipeReportedEvent(recipeId, userId, reportCount, + reportCount >= RECIPE_REPORT_MIN_CNT)); + }); } @Transactional(readOnly = true) diff --git a/src/main/java/com/recipe/app/src/recipe/application/event/RecipeReportedEvent.java b/src/main/java/com/recipe/app/src/recipe/application/event/RecipeReportedEvent.java new file mode 100644 index 00000000..c01dccd6 --- /dev/null +++ b/src/main/java/com/recipe/app/src/recipe/application/event/RecipeReportedEvent.java @@ -0,0 +1,8 @@ +package com.recipe.app.src.recipe.application.event; + +/** + * 레시피 신고가 새로 접수되었을 때 발행되는 이벤트. + * 실제 알림 전송은 트랜잭션 커밋 이후(AFTER_COMMIT) 리스너에서 처리한다. + */ +public record RecipeReportedEvent(long recipeId, long reporterUserId, long reportCount, boolean reachedThreshold) { +} From 7312658e577cc80521ba2c69d3757af2aeed99be Mon Sep 17 00:00:00 2001 From: joona95 Date: Fri, 3 Jul 2026 22:59:27 +0900 Subject: [PATCH 3/3] =?UTF-8?q?test:=20=EC=8B=A0=EA=B3=A0=20=EB=B0=9C?= =?UTF-8?q?=EC=83=9D=20=EC=8B=9C=20=EB=94=94=EC=8A=A4=EC=BD=94=EB=93=9C=20?= =?UTF-8?q?=EC=95=8C=EB=9E=8C=20=EC=B6=94=EA=B0=80=EC=97=90=20=EB=94=B0?= =?UTF-8?q?=EB=A5=B8=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/recipe/application/RecipeReportServiceTest.groovy | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/test/groovy/com/recipe/app/src/recipe/application/RecipeReportServiceTest.groovy b/src/test/groovy/com/recipe/app/src/recipe/application/RecipeReportServiceTest.groovy index 4a45fe6e..d84528b8 100644 --- a/src/test/groovy/com/recipe/app/src/recipe/application/RecipeReportServiceTest.groovy +++ b/src/test/groovy/com/recipe/app/src/recipe/application/RecipeReportServiceTest.groovy @@ -1,13 +1,16 @@ package com.recipe.app.src.recipe.application +import com.recipe.app.src.recipe.application.event.RecipeReportedEvent import com.recipe.app.src.recipe.domain.RecipeReport import com.recipe.app.src.recipe.infra.RecipeReportRepository +import org.springframework.context.ApplicationEventPublisher import spock.lang.Specification class RecipeReportServiceTest extends Specification { private RecipeReportRepository recipeReportRepository = Mock() - private RecipeReportService recipeReportService = new RecipeReportService(recipeReportRepository) + private ApplicationEventPublisher eventPublisher = Mock() + private RecipeReportService recipeReportService = new RecipeReportService(recipeReportRepository, eventPublisher) def "레시피 신고 생성"() { @@ -21,6 +24,7 @@ class RecipeReportServiceTest extends Specification { then: 1 * recipeReportRepository.save(_) + 1 * eventPublisher.publishEvent(_ as RecipeReportedEvent) } def "레시피 신고 생성 시 이미 생성된 경우 새로 생성하지 않음"() { @@ -41,6 +45,7 @@ class RecipeReportServiceTest extends Specification { then: 0 * recipeReportRepository.save(recipeReport) + 0 * eventPublisher.publishEvent(_) } def "신고 횟수에 따라 신고된 레시피인지 여부 확인"() {