Skip to content

Commit ffddcce

Browse files
authored
Merge pull request #55 from SEMOSAN/feat/#46-tracking-photo-push
[Feat]#46 트래킹 푸시
2 parents 2dd76e2 + b586ec5 commit ffddcce

30 files changed

Lines changed: 897 additions & 26 deletions

k8s/kustomization.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,4 +14,4 @@ resources:
1414
- minio/service.yaml
1515
images:
1616
- name: ghcr.io/semosan/semosan_be
17-
newTag: 2408eb0c0f5fc413f174a8745cfd08f32d7a88ce
17+
newTag: b976ab6dd6006e94df6af125add740f1b4ed681f
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
package com.semosan.api.common.alert;
2+
3+
import com.semosan.api.common.alert.dto.DiscordMessage;
4+
import lombok.extern.slf4j.Slf4j;
5+
import org.springframework.stereotype.Component;
6+
import org.springframework.util.StringUtils;
7+
import org.springframework.web.reactive.function.client.WebClient;
8+
9+
import java.time.Duration;
10+
11+
@Slf4j
12+
@Component
13+
public class DiscordAlertClient {
14+
15+
private static final Duration REQUEST_TIMEOUT = Duration.ofSeconds(3);
16+
17+
private final DiscordAlertProperties properties;
18+
private final WebClient webClient;
19+
20+
public DiscordAlertClient(DiscordAlertProperties properties, WebClient.Builder webClientBuilder) {
21+
this.properties = properties;
22+
this.webClient = webClientBuilder.build();
23+
}
24+
25+
public void send(DiscordMessage message) {
26+
if (!properties.isEnabled() || !StringUtils.hasText(properties.getWebhookUrl())) {
27+
return;
28+
}
29+
30+
try {
31+
webClient.post()
32+
.uri(properties.getWebhookUrl())
33+
.bodyValue(message)
34+
.retrieve()
35+
.toBodilessEntity()
36+
.timeout(REQUEST_TIMEOUT)
37+
.block();
38+
} catch (Exception e) {
39+
log.warn("[*] Discord alert send failed: {}", e.getMessage());
40+
}
41+
}
42+
}
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
package com.semosan.api.common.alert;
2+
3+
import org.springframework.boot.context.properties.ConfigurationProperties;
4+
import org.springframework.stereotype.Component;
5+
6+
@Component
7+
@ConfigurationProperties(prefix = "discord.alert")
8+
public class DiscordAlertProperties {
9+
10+
private boolean enabled;
11+
private String webhookUrl;
12+
13+
public boolean isEnabled() {
14+
return enabled;
15+
}
16+
17+
public void setEnabled(boolean enabled) {
18+
this.enabled = enabled;
19+
}
20+
21+
public String getWebhookUrl() {
22+
return webhookUrl;
23+
}
24+
25+
public void setWebhookUrl(String webhookUrl) {
26+
this.webhookUrl = webhookUrl;
27+
}
28+
}
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
package com.semosan.api.common.alert;
2+
3+
import jakarta.servlet.http.HttpServletRequest;
4+
import org.springframework.util.StringUtils;
5+
6+
public record RequestContext(
7+
String method,
8+
String url,
9+
String ip,
10+
String userId,
11+
String userAgent
12+
) {
13+
14+
public static RequestContext from(HttpServletRequest request) {
15+
return new RequestContext(
16+
request.getMethod(),
17+
request.getRequestURL().toString(),
18+
clientIp(request),
19+
userId(request),
20+
request.getHeader("User-Agent")
21+
);
22+
}
23+
24+
private static String clientIp(HttpServletRequest request) {
25+
String forwardedFor = request.getHeader("X-Forwarded-For");
26+
if (StringUtils.hasText(forwardedFor)) {
27+
return forwardedFor.split(",")[0].trim();
28+
}
29+
return request.getRemoteAddr();
30+
}
31+
32+
private static String userId(HttpServletRequest request) {
33+
if (request.getUserPrincipal() == null) {
34+
return null;
35+
}
36+
return request.getUserPrincipal().getName();
37+
}
38+
}
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
package com.semosan.api.common.alert;
2+
3+
import com.semosan.api.common.alert.dto.DiscordEmbed;
4+
import com.semosan.api.common.alert.dto.DiscordMessage;
5+
import lombok.RequiredArgsConstructor;
6+
import org.springframework.core.env.Environment;
7+
import org.springframework.scheduling.annotation.Async;
8+
import org.springframework.stereotype.Service;
9+
import org.springframework.util.StringUtils;
10+
11+
import java.io.PrintWriter;
12+
import java.io.StringWriter;
13+
import java.time.ZoneId;
14+
import java.time.ZonedDateTime;
15+
import java.time.format.DateTimeFormatter;
16+
import java.util.Arrays;
17+
import java.util.List;
18+
19+
@Service
20+
@RequiredArgsConstructor
21+
public class ServerErrorAlertService {
22+
23+
private static final ZoneId KOREA_ZONE = ZoneId.of("Asia/Seoul");
24+
private static final DateTimeFormatter TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH시 mm분 ss초");
25+
private static final int ERROR_COLOR = 0xED4245;
26+
private static final int MAX_STACK_TRACE_LENGTH = 1000;
27+
private static final int MAX_EMBED_DESCRIPTION_LENGTH = 4000;
28+
29+
private final DiscordAlertClient discordAlertClient;
30+
private final Environment environment;
31+
32+
@Async("discordAlertExecutor")
33+
public void notify(int status, Exception exception, RequestContext requestContext) {
34+
discordAlertClient.send(buildMessage(status, exception, requestContext));
35+
}
36+
37+
private DiscordMessage buildMessage(int status, Exception exception, RequestContext requestContext) {
38+
String description = """
39+
### 에러 발생 시간
40+
%s
41+
### 실행 프로필
42+
%s
43+
### 요청 엔드포인트
44+
%s
45+
### 응답 상태
46+
%d
47+
### 요청 클라이언트
48+
%s
49+
### 에러 메시지
50+
%s
51+
### 에러 스택 트레이스
52+
```text
53+
%s
54+
```
55+
""".formatted(
56+
ZonedDateTime.now(KOREA_ZONE).format(TIME_FORMATTER),
57+
activeProfiles(),
58+
endpoint(requestContext),
59+
status,
60+
client(requestContext),
61+
sanitize(exception.getMessage()),
62+
stackTrace(exception)
63+
);
64+
65+
return new DiscordMessage(
66+
"# 🚨 서버 에러 발생 🚨",
67+
List.of(new DiscordEmbed("에러 정보", truncate(description, MAX_EMBED_DESCRIPTION_LENGTH), ERROR_COLOR))
68+
);
69+
}
70+
71+
private String activeProfiles() {
72+
String[] profiles = environment.getActiveProfiles();
73+
if (profiles.length == 0) {
74+
profiles = environment.getDefaultProfiles();
75+
}
76+
return String.join(",", Arrays.asList(profiles));
77+
}
78+
79+
private String endpoint(RequestContext requestContext) {
80+
return "[%s] %s".formatted(requestContext.method(), requestContext.url());
81+
}
82+
83+
private String client(RequestContext requestContext) {
84+
String userIdentifier = !StringUtils.hasText(requestContext.userId())
85+
? ""
86+
: " / [UserId]: " + sanitize(requestContext.userId());
87+
return "[IP]: %s%s / [User-Agent]: %s".formatted(
88+
requestContext.ip(),
89+
userIdentifier,
90+
sanitize(requestContext.userAgent())
91+
);
92+
}
93+
94+
private String stackTrace(Exception exception) {
95+
StringWriter writer = new StringWriter();
96+
exception.printStackTrace(new PrintWriter(writer));
97+
return truncate(maskSensitiveValues(writer.toString()), MAX_STACK_TRACE_LENGTH);
98+
}
99+
100+
private String sanitize(String value) {
101+
if (!StringUtils.hasText(value)) {
102+
return "-";
103+
}
104+
return maskSensitiveValues(value)
105+
.replace("\n", " ")
106+
.replace("\r", " ");
107+
}
108+
109+
private String maskSensitiveValues(String value) {
110+
return value.replaceAll("(?i)(authorization|cookie|token|secret|password)=\\S+", "$1=***");
111+
}
112+
113+
private String truncate(String value, int maxLength) {
114+
if (value.length() <= maxLength) {
115+
return value;
116+
}
117+
return value.substring(0, maxLength) + "...";
118+
}
119+
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
package com.semosan.api.common.alert.dto;
2+
3+
public record DiscordEmbed(
4+
String title,
5+
String description,
6+
Integer color
7+
) {
8+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
package com.semosan.api.common.alert.dto;
2+
3+
import java.util.List;
4+
5+
public record DiscordMessage(
6+
String content,
7+
List<DiscordEmbed> embeds
8+
) {
9+
}

src/main/java/com/semosan/api/common/config/AsyncConfig.java

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,17 @@ public Executor notificationTaskExecutor() {
2525
return executor;
2626
}
2727

28+
@Bean(name = "discordAlertExecutor")
29+
public Executor discordAlertExecutor() {
30+
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
31+
executor.setCorePoolSize(1);
32+
executor.setMaxPoolSize(2);
33+
executor.setQueueCapacity(50);
34+
executor.setThreadNamePrefix("discord-alert-");
35+
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.DiscardPolicy());
36+
return executor;
37+
}
38+
2839
@Bean(name = "authCleanupTaskExecutor")
2940
public Executor authCleanupTaskExecutor() {
3041
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();

src/main/java/com/semosan/api/common/exception/GeneralExceptionAdvice.java

Lines changed: 14 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,13 @@
11
package com.semosan.api.common.exception;
22

33
import com.semosan.api.common.base.BaseStatus;
4-
import com.semosan.api.common.status.ErrorStatus;
4+
import com.semosan.api.common.alert.RequestContext;
5+
import com.semosan.api.common.alert.ServerErrorAlertService;
56
import com.semosan.api.common.response.ApiResponse;
7+
import com.semosan.api.common.status.ErrorStatus;
8+
import jakarta.servlet.http.HttpServletRequest;
69
import jakarta.validation.ConstraintViolationException;
10+
import lombok.RequiredArgsConstructor;
711
import lombok.extern.slf4j.Slf4j;
812
import org.springframework.http.HttpHeaders;
913
import org.springframework.http.HttpStatusCode;
@@ -17,15 +21,20 @@
1721

1822
@Slf4j
1923
@RestControllerAdvice
24+
@RequiredArgsConstructor
2025
public class GeneralExceptionAdvice extends ResponseEntityExceptionHandler {
2126

27+
private final ServerErrorAlertService serverErrorAlertService;
28+
2229
// 커스텀 예외(GeneralException)를 잡아서 정의된 에러 상태로 응답 반환
2330
@ExceptionHandler(GeneralException.class)
2431
public ResponseEntity<ApiResponse<Void>> handleGeneralException(
25-
GeneralException e
32+
GeneralException e,
33+
HttpServletRequest request
2634
) {
2735
if (e.getErrorStatus().getHttpStatus().is5xxServerError()) {
2836
log.error("[*] GeneralException :", e);
37+
serverErrorAlertService.notify(e.getErrorStatus().getHttpStatus().value(), e, RequestContext.from(request));
2938
} else {
3039
log.warn("[*] GeneralException : {}", e.getMessage());
3140
}
@@ -71,21 +80,14 @@ protected ResponseEntity<Object> handleHandlerMethodValidationException(
7180
return handleExceptionInternal(ex, body, headers, status, request);
7281
}
7382

74-
// null 참조로 발생한 서버 오류를 500 에러로 응답
75-
@ExceptionHandler(NullPointerException.class)
76-
public ResponseEntity<ApiResponse<Void>> handleNullPointerException(
77-
NullPointerException e
78-
) {
79-
log.error("[*] NullPointerException :", e);
80-
return ApiResponse.error(ErrorStatus.INTERNAL_SERVER_ERROR);
81-
}
82-
8383
// 처리되지 않은 모든 예외를 잡아 500 서버 오류로 응답
8484
@ExceptionHandler(Exception.class)
8585
public ResponseEntity<ApiResponse<Void>> handleException(
86-
Exception e
86+
Exception e,
87+
HttpServletRequest request
8788
) {
8889
log.error("[*] Internal Server Error :", e);
90+
serverErrorAlertService.notify(ErrorStatus.INTERNAL_SERVER_ERROR.getHttpStatus().value(), e, RequestContext.from(request));
8991
return ApiResponse.error(ErrorStatus.INTERNAL_SERVER_ERROR);
9092
}
9193

src/main/java/com/semosan/api/common/status/ErrorStatus.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,8 @@ public enum ErrorStatus implements BaseStatus {
114114
TRACKING_SESSION_INVALID_STATE(HttpStatus.CONFLICT, "TRK_409_2", "현재 상태에서는 수행할 수 없는 작업입니다."),
115115
TRACKING_COURSE_MOUNTAIN_MISMATCH(HttpStatus.BAD_REQUEST, "TRK_400_1", "선택한 코스가 해당 산의 코스가 아닙니다."),
116116
TRACKING_COURSE_ID_REQUIRED(HttpStatus.BAD_REQUEST, "TRK_400_2", "자유 기록이 아니면 코스 ID는 필수입니다."),
117+
TRACKING_PHOTO_DUPLICATE(HttpStatus.CONFLICT, "TRK_409_3", "해당 마일스톤에 이미 업로드된 사진이 있습니다."),
118+
TRACKING_PHOTO_SESSION_INACTIVE(HttpStatus.CONFLICT, "TRK_409_4", "활성 상태가 아닌 세션에는 사진을 업로드할 수 없습니다."),
117119
COURSE_NOT_FOUND(HttpStatus.NOT_FOUND, "MTN_404_3", "코스를 찾을 수 없습니다."),
118120

119121
/**

0 commit comments

Comments
 (0)