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 @@ -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<DiscordAlertService> discordAlertServiceProvider;

public GeneralExceptionHandler(ObjectProvider<DiscordAlertService> discordAlertServiceProvider) {
this.discordAlertServiceProvider = discordAlertServiceProvider;
}

@ExceptionHandler({NotFoundFridgeException.class, NotFoundIngredientException.class, NotFoundFridgeBasketException.class,
NotFoundIngredientCategoryException.class, NotFoundNoticeException.class, NotFoundRecipeLevelException.class,
NotFoundRecipeException.class, NotFoundUserException.class})
Expand All @@ -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", "서버 오류가 발생했습니다."));
}
}
132 changes: 132 additions & 0 deletions src/main/java/com/recipe/app/src/common/utils/DiscordAlertService.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
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 ORANGE = 15105570;
private static final int MAX_STACKTRACE_LENGTH = 1000; // Discord embed field value 제한(1024) 대응

@Value("${discord.webhook.url:}")
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<String, Object> 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<String, Object> 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<String, Object> embed) {
if (!StringUtils.hasText(webhookUrl)) {
log.warn("Discord webhook URL 이 설정되지 않아 알림을 건너뜁니다.");
return;
}

try {
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)
.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 Map<String, Object> field(String name, String value) {
return Map.of("name", name, "value", truncate(value), "inline", false);
}

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;
}
}
Original file line number Diff line number Diff line change
@@ -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());
}
}
Original file line number Diff line number Diff line change
@@ -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;

Expand All @@ -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)
Expand Down
Original file line number Diff line number Diff line change
@@ -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) {
}
Original file line number Diff line number Diff line change
@@ -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 "레시피 신고 생성"() {

Expand All @@ -21,6 +24,7 @@ class RecipeReportServiceTest extends Specification {

then:
1 * recipeReportRepository.save(_)
1 * eventPublisher.publishEvent(_ as RecipeReportedEvent)
}

def "레시피 신고 생성 시 이미 생성된 경우 새로 생성하지 않음"() {
Expand All @@ -41,6 +45,7 @@ class RecipeReportServiceTest extends Specification {

then:
0 * recipeReportRepository.save(recipeReport)
0 * eventPublisher.publishEvent(_)
}

def "신고 횟수에 따라 신고된 레시피인지 여부 확인"() {
Expand Down
Loading