Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
bc6117d
chore: tracking_photos 테이블 및 notifications type 제약 갱신
JangInho May 17, 2026
76ce8bb
feat: 거리 마일스톤 도달 시 사진 촬영 푸시 트리거
JangInho May 17, 2026
82b5895
feat: 트래킹 사진 메타 업로드 API
JangInho May 17, 2026
86148ff
feat: 디스코드 서버 에러 알림 기능 추가
pooreumjung May 21, 2026
764339a
refactor: application.yaml, application-prod.yaml 수정
pooreumjung May 21, 2026
d92d8d1
feat: 디스코드 서버 에러 알림 포맷 개선
pooreumjung May 21, 2026
882e11b
refactor: Discord 알림 테스트 컨틀롤러 제거
pooreumjung May 21, 2026
00ed3ee
fix: 디스코드 알림 비동기 처리 개선
pooreumjung May 21, 2026
4794abb
refactor: 디스코드 알림 클라이언트와 예외 핸들러 정리
pooreumjung May 21, 2026
149b174
fix: 디스코드 알림 요청 컨텍스트 분리
pooreumjung May 21, 2026
f14008e
fix: 디스코드 알림 거절 정책 변경
pooreumjung May 21, 2026
d1ad431
feat: 스웨거 태그 순 알파벳 정렬 추가
pooreumjung May 21, 2026
cd49295
chore: update image to 43242da4f169267b38d7f50e4a5b4e38dc6fe001 [skip…
github-actions[bot] May 21, 2026
26ede77
Merge pull request #74 from SEMOSAN/feat/#20-tracking-record-finalize
JangInho May 21, 2026
f188c63
chore: update image to 26ede778938b25bbf1153a69d626600c468b10db [skip…
github-actions[bot] May 21, 2026
83ed7c7
Merge remote-tracking branch 'origin/develop' into feat/#46-tracking-…
JangInho May 21, 2026
0fcb71b
chore: tracking_photos 테이블 마이그레이션 V10 으로 재작성
JangInho May 21, 2026
c100d9c
Merge pull request #70 from SEMOSAN/feat/#63-discord-alert
pooreumjung May 21, 2026
493e199
chore: update image to c100d9c16e4129e4a03d45eab0ad1c37966e7707 [skip…
github-actions[bot] May 21, 2026
bc90b90
fix: getNearbyMountain 메소드 파라미터 제약 수정
JangInho May 21, 2026
80a6700
Merge pull request #76 from SEMOSAN/fix/#75-getNearbyMountain
JangInho May 21, 2026
84b8ea9
chore: update image to 80a67009192af06f3f1daa788fa9fba5626851d4 [skip…
github-actions[bot] May 21, 2026
886ccac
fix: Swagger MountainInfo 스키마 충돌 해소 및 enum 타입 노출
howooyeon May 21, 2026
b8a1299
Merge pull request #78 from SEMOSAN/hotfix/#77-swagger-mountaininfo-c…
howooyeon May 21, 2026
0f84324
chore: update image to b8a12990175fc314f736d0b905a8dbffe5daf070 [skip…
github-actions[bot] May 21, 2026
b976ab6
refactor: 스웨거 정렬 순서 변경
pooreumjung May 22, 2026
f6b3ae4
chore: update image to b976ab6dd6006e94df6af125add740f1b4ed681f [skip…
github-actions[bot] May 22, 2026
b586ec5
Merge remote-tracking branch 'origin/develop' into feat/#46-tracking-…
JangInho May 22, 2026
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
2 changes: 1 addition & 1 deletion k8s/kustomization.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -14,4 +14,4 @@ resources:
- minio/service.yaml
images:
- name: ghcr.io/semosan/semosan_be
newTag: 2408eb0c0f5fc413f174a8745cfd08f32d7a88ce
newTag: b976ab6dd6006e94df6af125add740f1b4ed681f
42 changes: 42 additions & 0 deletions src/main/java/com/semosan/api/common/alert/DiscordAlertClient.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
package com.semosan.api.common.alert;

import com.semosan.api.common.alert.dto.DiscordMessage;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import org.springframework.web.reactive.function.client.WebClient;

import java.time.Duration;

@Slf4j
@Component
public class DiscordAlertClient {

private static final Duration REQUEST_TIMEOUT = Duration.ofSeconds(3);

private final DiscordAlertProperties properties;
private final WebClient webClient;

public DiscordAlertClient(DiscordAlertProperties properties, WebClient.Builder webClientBuilder) {
this.properties = properties;
this.webClient = webClientBuilder.build();
}

public void send(DiscordMessage message) {
if (!properties.isEnabled() || !StringUtils.hasText(properties.getWebhookUrl())) {
return;
}

try {
webClient.post()
.uri(properties.getWebhookUrl())
.bodyValue(message)
.retrieve()
.toBodilessEntity()
.timeout(REQUEST_TIMEOUT)
.block();
} catch (Exception e) {
log.warn("[*] Discord alert send failed: {}", e.getMessage());
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package com.semosan.api.common.alert;

import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;

@Component
@ConfigurationProperties(prefix = "discord.alert")
public class DiscordAlertProperties {

private boolean enabled;
private String webhookUrl;

public boolean isEnabled() {
return enabled;
}

public void setEnabled(boolean enabled) {
this.enabled = enabled;
}

public String getWebhookUrl() {
return webhookUrl;
}

public void setWebhookUrl(String webhookUrl) {
this.webhookUrl = webhookUrl;
}
}
38 changes: 38 additions & 0 deletions src/main/java/com/semosan/api/common/alert/RequestContext.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
package com.semosan.api.common.alert;

import jakarta.servlet.http.HttpServletRequest;
import org.springframework.util.StringUtils;

public record RequestContext(
String method,
String url,
String ip,
String userId,
String userAgent
) {

public static RequestContext from(HttpServletRequest request) {
return new RequestContext(
request.getMethod(),
request.getRequestURL().toString(),
clientIp(request),
userId(request),
request.getHeader("User-Agent")
);
}

private static String clientIp(HttpServletRequest request) {
String forwardedFor = request.getHeader("X-Forwarded-For");
if (StringUtils.hasText(forwardedFor)) {
return forwardedFor.split(",")[0].trim();
}
return request.getRemoteAddr();
}

private static String userId(HttpServletRequest request) {
if (request.getUserPrincipal() == null) {
return null;
}
return request.getUserPrincipal().getName();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
package com.semosan.api.common.alert;

import com.semosan.api.common.alert.dto.DiscordEmbed;
import com.semosan.api.common.alert.dto.DiscordMessage;
import lombok.RequiredArgsConstructor;
import org.springframework.core.env.Environment;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;

import java.io.PrintWriter;
import java.io.StringWriter;
import java.time.ZoneId;
import java.time.ZonedDateTime;
import java.time.format.DateTimeFormatter;
import java.util.Arrays;
import java.util.List;

@Service
@RequiredArgsConstructor
public class ServerErrorAlertService {

private static final ZoneId KOREA_ZONE = ZoneId.of("Asia/Seoul");
private static final DateTimeFormatter TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH시 mm분 ss초");
private static final int ERROR_COLOR = 0xED4245;
private static final int MAX_STACK_TRACE_LENGTH = 1000;
private static final int MAX_EMBED_DESCRIPTION_LENGTH = 4000;

private final DiscordAlertClient discordAlertClient;
private final Environment environment;

@Async("discordAlertExecutor")
public void notify(int status, Exception exception, RequestContext requestContext) {
discordAlertClient.send(buildMessage(status, exception, requestContext));
}

private DiscordMessage buildMessage(int status, Exception exception, RequestContext requestContext) {
String description = """
### 에러 발생 시간
%s
### 실행 프로필
%s
### 요청 엔드포인트
%s
### 응답 상태
%d
### 요청 클라이언트
%s
### 에러 메시지
%s
### 에러 스택 트레이스
```text
%s
```
""".formatted(
ZonedDateTime.now(KOREA_ZONE).format(TIME_FORMATTER),
activeProfiles(),
endpoint(requestContext),
status,
client(requestContext),
sanitize(exception.getMessage()),
stackTrace(exception)
);

return new DiscordMessage(
"# 🚨 서버 에러 발생 🚨",
List.of(new DiscordEmbed("에러 정보", truncate(description, MAX_EMBED_DESCRIPTION_LENGTH), ERROR_COLOR))
);
}

private String activeProfiles() {
String[] profiles = environment.getActiveProfiles();
if (profiles.length == 0) {
profiles = environment.getDefaultProfiles();
}
return String.join(",", Arrays.asList(profiles));
}

private String endpoint(RequestContext requestContext) {
return "[%s] %s".formatted(requestContext.method(), requestContext.url());
}

private String client(RequestContext requestContext) {
String userIdentifier = !StringUtils.hasText(requestContext.userId())
? ""
: " / [UserId]: " + sanitize(requestContext.userId());
return "[IP]: %s%s / [User-Agent]: %s".formatted(
requestContext.ip(),
userIdentifier,
sanitize(requestContext.userAgent())
);
}

private String stackTrace(Exception exception) {
StringWriter writer = new StringWriter();
exception.printStackTrace(new PrintWriter(writer));
return truncate(maskSensitiveValues(writer.toString()), MAX_STACK_TRACE_LENGTH);
}

private String sanitize(String value) {
if (!StringUtils.hasText(value)) {
return "-";
}
return maskSensitiveValues(value)
.replace("\n", " ")
.replace("\r", " ");
}

private String maskSensitiveValues(String value) {
return value.replaceAll("(?i)(authorization|cookie|token|secret|password)=\\S+", "$1=***");
}

private String truncate(String value, int maxLength) {
if (value.length() <= maxLength) {
return value;
}
return value.substring(0, maxLength) + "...";
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package com.semosan.api.common.alert.dto;

public record DiscordEmbed(
String title,
String description,
Integer color
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package com.semosan.api.common.alert.dto;

import java.util.List;

public record DiscordMessage(
String content,
List<DiscordEmbed> embeds
) {
}
11 changes: 11 additions & 0 deletions src/main/java/com/semosan/api/common/config/AsyncConfig.java
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,17 @@ public Executor notificationTaskExecutor() {
return executor;
}

@Bean(name = "discordAlertExecutor")
public Executor discordAlertExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(1);
executor.setMaxPoolSize(2);
executor.setQueueCapacity(50);
executor.setThreadNamePrefix("discord-alert-");
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.DiscardPolicy());
return executor;
}

@Bean(name = "authCleanupTaskExecutor")
public Executor authCleanupTaskExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,13 @@
package com.semosan.api.common.exception;

import com.semosan.api.common.base.BaseStatus;
import com.semosan.api.common.status.ErrorStatus;
import com.semosan.api.common.alert.RequestContext;
import com.semosan.api.common.alert.ServerErrorAlertService;
import com.semosan.api.common.response.ApiResponse;
import com.semosan.api.common.status.ErrorStatus;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.ConstraintViolationException;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatusCode;
Expand All @@ -17,15 +21,20 @@

@Slf4j
@RestControllerAdvice
@RequiredArgsConstructor
public class GeneralExceptionAdvice extends ResponseEntityExceptionHandler {

private final ServerErrorAlertService serverErrorAlertService;

// 커스텀 예외(GeneralException)를 잡아서 정의된 에러 상태로 응답 반환
@ExceptionHandler(GeneralException.class)
public ResponseEntity<ApiResponse<Void>> handleGeneralException(
GeneralException e
GeneralException e,
HttpServletRequest request
) {
if (e.getErrorStatus().getHttpStatus().is5xxServerError()) {
log.error("[*] GeneralException :", e);
serverErrorAlertService.notify(e.getErrorStatus().getHttpStatus().value(), e, RequestContext.from(request));
} else {
log.warn("[*] GeneralException : {}", e.getMessage());
}
Expand Down Expand Up @@ -71,21 +80,14 @@ protected ResponseEntity<Object> handleHandlerMethodValidationException(
return handleExceptionInternal(ex, body, headers, status, request);
}

// null 참조로 발생한 서버 오류를 500 에러로 응답
@ExceptionHandler(NullPointerException.class)
public ResponseEntity<ApiResponse<Void>> handleNullPointerException(
NullPointerException e
) {
log.error("[*] NullPointerException :", e);
return ApiResponse.error(ErrorStatus.INTERNAL_SERVER_ERROR);
}

// 처리되지 않은 모든 예외를 잡아 500 서버 오류로 응답
@ExceptionHandler(Exception.class)
public ResponseEntity<ApiResponse<Void>> handleException(
Exception e
Exception e,
HttpServletRequest request
) {
log.error("[*] Internal Server Error :", e);
serverErrorAlertService.notify(ErrorStatus.INTERNAL_SERVER_ERROR.getHttpStatus().value(), e, RequestContext.from(request));
return ApiResponse.error(ErrorStatus.INTERNAL_SERVER_ERROR);
}

Expand Down
2 changes: 2 additions & 0 deletions src/main/java/com/semosan/api/common/status/ErrorStatus.java
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,8 @@ public enum ErrorStatus implements BaseStatus {
TRACKING_SESSION_INVALID_STATE(HttpStatus.CONFLICT, "TRK_409_2", "현재 상태에서는 수행할 수 없는 작업입니다."),
TRACKING_COURSE_MOUNTAIN_MISMATCH(HttpStatus.BAD_REQUEST, "TRK_400_1", "선택한 코스가 해당 산의 코스가 아닙니다."),
TRACKING_COURSE_ID_REQUIRED(HttpStatus.BAD_REQUEST, "TRK_400_2", "자유 기록이 아니면 코스 ID는 필수입니다."),
TRACKING_PHOTO_DUPLICATE(HttpStatus.CONFLICT, "TRK_409_3", "해당 마일스톤에 이미 업로드된 사진이 있습니다."),
TRACKING_PHOTO_SESSION_INACTIVE(HttpStatus.CONFLICT, "TRK_409_4", "활성 상태가 아닌 세션에는 사진을 업로드할 수 없습니다."),
COURSE_NOT_FOUND(HttpStatus.NOT_FOUND, "MTN_404_3", "코스를 찾을 수 없습니다."),

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,8 @@ public enum SuccessStatus implements BaseStatus {
TRACKING_SESSION_RESUME_SUCCESS(HttpStatus.OK, "TRK_200_5", "트래킹 세션을 재개했습니다."),
TRACKING_SESSION_COMPLETE_SUCCESS(HttpStatus.OK, "TRK_200_6", "트래킹 세션을 종료했습니다."),
TRACKING_SESSION_ABANDON_SUCCESS(HttpStatus.OK, "TRK_200_7", "트래킹 세션을 포기 처리했습니다."),
TRACKING_PHOTO_UPLOAD_SUCCESS(HttpStatus.CREATED, "TRK_201_2", "트래킹 사진이 저장되었습니다."),
TRACKING_PHOTO_LIST_SUCCESS(HttpStatus.OK, "TRK_200_8", "트래킹 사진 목록 조회에 성공했습니다."),

/**
* Image
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,16 @@ public enum NotificationType {
"새 댓글이 달렸어요",
"{actorName}: {commentPreview}",
Set.of("actorName", "commentPreview")
),

/**
* 트래킹 중 거리 마일스톤 도달 시 사진 촬영 유도.
* iOS 잠금화면에 title 은 앱 이름(SEMOSAN)이 자동 표시되므로 본 title 은 빈 문자열.
*/
TRACKING_PHOTO_MILESTONE(
"",
"{distance}m 돌파! 인증 사진을 남겨보세요!",
Set.of("distance")
);

private final String titleTemplate;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,6 @@
import com.semosan.api.domain.tracking.controller.docs.TrackingControllerDocs;
import com.semosan.api.domain.tracking.dto.response.NearbyMountainResponse;
import com.semosan.api.domain.tracking.service.TrackingService;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
Expand Down Expand Up @@ -34,8 +32,8 @@ public class TrackingController implements TrackingControllerDocs {
@Override
public ResponseEntity<ApiResponse<NearbyMountainResponse>> getNearbyMountain(
@AuthenticationPrincipal Long userId,
@RequestParam @Min(-90) @Max(90) Double lat,
@RequestParam @Min(-180) @Max(180) Double lng
@RequestParam Double lat,
@RequestParam Double lng
) {
NearbyMountainResponse response = trackingService.getNearbyMountain(userId, lat, lng);
return ApiResponse.success(SuccessStatus.TRACKING_NEAREST_MOUNTAIN_SUCCESS, response);
Expand Down
Loading