From bc6117d016b389da3340a009a4061caf680b2e09 Mon Sep 17 00:00:00 2001 From: JangInho Date: Sun, 17 May 2026 21:48:18 +0900 Subject: [PATCH 01/22] =?UTF-8?q?chore:=20tracking=5Fphotos=20=ED=85=8C?= =?UTF-8?q?=EC=9D=B4=EB=B8=94=20=EB=B0=8F=20notifications=20type=20?= =?UTF-8?q?=EC=A0=9C=EC=95=BD=20=EA=B0=B1=EC=8B=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../api/common/status/ErrorStatus.java | 2 + .../api/common/status/SuccessStatus.java | 2 + .../notification/enums/NotificationType.java | 10 +++ .../domain/tracking/entity/TrackingPhoto.java | 87 +++++++++++++++++++ .../repository/TrackingPhotoRepository.java | 13 +++ .../db/migration/003_tracking_photos.sql | 36 ++++++++ 6 files changed, 150 insertions(+) create mode 100644 src/main/java/com/semosan/api/domain/tracking/entity/TrackingPhoto.java create mode 100644 src/main/java/com/semosan/api/domain/tracking/repository/TrackingPhotoRepository.java create mode 100644 src/main/resources/db/migration/003_tracking_photos.sql diff --git a/src/main/java/com/semosan/api/common/status/ErrorStatus.java b/src/main/java/com/semosan/api/common/status/ErrorStatus.java index 933180c1..6f779c55 100644 --- a/src/main/java/com/semosan/api/common/status/ErrorStatus.java +++ b/src/main/java/com/semosan/api/common/status/ErrorStatus.java @@ -109,6 +109,8 @@ public enum ErrorStatus implements BaseStatus { TRACKING_SESSION_ALREADY_IN_PROGRESS(HttpStatus.CONFLICT, "TRK_409_1", "이미 진행 중인 트래킹 세션이 있습니다."), TRACKING_SESSION_INVALID_STATE(HttpStatus.CONFLICT, "TRK_409_2", "현재 상태에서는 수행할 수 없는 작업입니다."), TRACKING_COURSE_MOUNTAIN_MISMATCH(HttpStatus.BAD_REQUEST, "TRK_400_1", "선택한 코스가 해당 산의 코스가 아닙니다."), + 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", "코스를 찾을 수 없습니다."), /** diff --git a/src/main/java/com/semosan/api/common/status/SuccessStatus.java b/src/main/java/com/semosan/api/common/status/SuccessStatus.java index 3fa527f5..ef74a1b1 100644 --- a/src/main/java/com/semosan/api/common/status/SuccessStatus.java +++ b/src/main/java/com/semosan/api/common/status/SuccessStatus.java @@ -59,6 +59,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 diff --git a/src/main/java/com/semosan/api/domain/notification/enums/NotificationType.java b/src/main/java/com/semosan/api/domain/notification/enums/NotificationType.java index 58d80a0d..41e43e46 100644 --- a/src/main/java/com/semosan/api/domain/notification/enums/NotificationType.java +++ b/src/main/java/com/semosan/api/domain/notification/enums/NotificationType.java @@ -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; diff --git a/src/main/java/com/semosan/api/domain/tracking/entity/TrackingPhoto.java b/src/main/java/com/semosan/api/domain/tracking/entity/TrackingPhoto.java new file mode 100644 index 00000000..428471fa --- /dev/null +++ b/src/main/java/com/semosan/api/domain/tracking/entity/TrackingPhoto.java @@ -0,0 +1,87 @@ +package com.semosan.api.domain.tracking.entity; + +import com.semosan.api.common.base.BaseEntity; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +/** + * 트래킹 진행 중 마일스톤 도달 시 사용자가 촬영해 업로드한 사진의 메타. + * 실제 이미지 바이너리는 MinIO 에 보관되고 본 테이블엔 URL 만 저장. + * 한 트래킹 세션이 N 개의 사진(=마일스톤 수)을 가질 수 있다 — N 의 상한은 정책상 6. + */ +@Table( + name = "tracking_photos", + indexes = { + @Index(name = "idx_tracking_photos_session", columnList = "tracking_session_id"), + @Index(name = "idx_tracking_photos_session_milestone", + columnList = "tracking_session_id, milestone_index") + } +) +@Getter +@Entity +@Builder(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PROTECTED) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class TrackingPhoto extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "tracking_session_id", nullable = false) + private TrackingSession trackingSession; + + /** 이 사진이 어떤 마일스톤에 해당하는지 (0-based, 코스 4컷 / 자유 최대 6컷). */ + @Column(name = "milestone_index", nullable = false) + private Integer milestoneIndex; + + /** 마일스톤의 기준 거리 (m). 예: 500, 1000, ... 또는 코스의 distance/4 등. */ + @Column(name = "milestone_distance_m", nullable = false) + private Double milestoneDistanceM; + + /** MinIO 등 외부 스토리지에 업로드된 이미지 URL (presigned URL 발급은 #41). */ + @Column(name = "image_url", nullable = false, length = 500) + private String imageUrl; + + @Column(name = "captured_at", nullable = false) + private LocalDateTime capturedAt; + + @Column(name = "lat", nullable = false) + private Double lat; + + @Column(name = "lng", nullable = false) + private Double lng; + + /** 촬영 시점 디바이스가 보고한 고도 (nullable). */ + @Column(name = "altitude") + private Double altitude; + + public static TrackingPhoto create( + TrackingSession session, + Integer milestoneIndex, + Double milestoneDistanceM, + String imageUrl, + LocalDateTime capturedAt, + Double lat, + Double lng, + Double altitude + ) { + return TrackingPhoto.builder() + .trackingSession(session) + .milestoneIndex(milestoneIndex) + .milestoneDistanceM(milestoneDistanceM) + .imageUrl(imageUrl) + .capturedAt(capturedAt) + .lat(lat) + .lng(lng) + .altitude(altitude) + .build(); + } +} diff --git a/src/main/java/com/semosan/api/domain/tracking/repository/TrackingPhotoRepository.java b/src/main/java/com/semosan/api/domain/tracking/repository/TrackingPhotoRepository.java new file mode 100644 index 00000000..f2587f2c --- /dev/null +++ b/src/main/java/com/semosan/api/domain/tracking/repository/TrackingPhotoRepository.java @@ -0,0 +1,13 @@ +package com.semosan.api.domain.tracking.repository; + +import com.semosan.api.domain.tracking.entity.TrackingPhoto; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface TrackingPhotoRepository extends JpaRepository { + + List findByTrackingSession_IdOrderByMilestoneIndexAsc(Long trackingSessionId); + + boolean existsByTrackingSession_IdAndMilestoneIndex(Long trackingSessionId, Integer milestoneIndex); +} diff --git a/src/main/resources/db/migration/003_tracking_photos.sql b/src/main/resources/db/migration/003_tracking_photos.sql new file mode 100644 index 00000000..0c97d53f --- /dev/null +++ b/src/main/resources/db/migration/003_tracking_photos.sql @@ -0,0 +1,36 @@ +-- ===================================================================== +-- 003_tracking_photos.sql +-- 목적: 트래킹 마일스톤 사진 메타 저장 테이블 생성. +-- 실제 이미지 바이너리는 MinIO 에 보관되고 본 테이블엔 URL 만 저장. +-- 적용 시점: PR #46 머지 직후. 1회성. +-- ===================================================================== + +CREATE TABLE IF NOT EXISTS tracking_photos ( + id BIGSERIAL PRIMARY KEY, + tracking_session_id BIGINT NOT NULL REFERENCES tracking_sessions(id), + milestone_index INTEGER NOT NULL, + milestone_distance_m DOUBLE PRECISION NOT NULL, + image_url VARCHAR(500) NOT NULL, + captured_at TIMESTAMP NOT NULL, + lat DOUBLE PRECISION NOT NULL, + lng DOUBLE PRECISION NOT NULL, + altitude DOUBLE PRECISION, + created_at TIMESTAMP NOT NULL DEFAULT now(), + updated_at TIMESTAMP NOT NULL DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS idx_tracking_photos_session + ON tracking_photos (tracking_session_id); + +CREATE INDEX IF NOT EXISTS idx_tracking_photos_session_milestone + ON tracking_photos (tracking_session_id, milestone_index); + +-- ===================================================================== +-- notifications.type CHECK 제약 갱신 +-- Hibernate 가 enum 컬럼 생성 시점에 그 시점 값만으로 CHECK 를 만들고 +-- 새 enum 값 추가 시 자동 갱신하지 않으므로 수동 갱신 필요. +-- TRACKING_PHOTO_MILESTONE 추가 — 추후 enum 값 추가 시 동일 패턴으로 갱신. +-- ===================================================================== +ALTER TABLE notifications DROP CONSTRAINT IF EXISTS notifications_type_check; +ALTER TABLE notifications ADD CONSTRAINT notifications_type_check + CHECK (type IN ('COMMUNITY_COMMENT', 'TRACKING_PHOTO_MILESTONE')); From 76ce8bb082a5612f85f6935d437f98d0ec81856f Mon Sep 17 00:00:00 2001 From: JangInho Date: Sun, 17 May 2026 21:53:03 +0900 Subject: [PATCH 02/22] =?UTF-8?q?feat:=20=EA=B1=B0=EB=A6=AC=20=EB=A7=88?= =?UTF-8?q?=EC=9D=BC=EC=8A=A4=ED=86=A4=20=EB=8F=84=EB=8B=AC=20=EC=8B=9C=20?= =?UTF-8?q?=EC=82=AC=EC=A7=84=20=EC=B4=AC=EC=98=81=20=ED=91=B8=EC=8B=9C=20?= =?UTF-8?q?=ED=8A=B8=EB=A6=AC=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/TrackingMilestoneCalculator.java | 48 ++++++ .../service/TrackingPhotoTriggerService.java | 151 ++++++++++++++++++ .../service/TrackingSessionService.java | 11 ++ .../service/TrackingSessionStatsService.java | 4 +- .../service/TrackingStreamConsumer.java | 6 +- 5 files changed, 218 insertions(+), 2 deletions(-) create mode 100644 src/main/java/com/semosan/api/domain/tracking/service/TrackingMilestoneCalculator.java create mode 100644 src/main/java/com/semosan/api/domain/tracking/service/TrackingPhotoTriggerService.java diff --git a/src/main/java/com/semosan/api/domain/tracking/service/TrackingMilestoneCalculator.java b/src/main/java/com/semosan/api/domain/tracking/service/TrackingMilestoneCalculator.java new file mode 100644 index 00000000..3a7277ff --- /dev/null +++ b/src/main/java/com/semosan/api/domain/tracking/service/TrackingMilestoneCalculator.java @@ -0,0 +1,48 @@ +package com.semosan.api.domain.tracking.service; + +import com.semosan.api.domain.mountain.entity.Course; +import com.semosan.api.domain.tracking.entity.TrackingSession; +import org.springframework.stereotype.Component; + +import java.util.ArrayList; +import java.util.List; + +/** + * 트래킹 세션의 사진 촬영 마일스톤 거리 리스트를 계산한다. + * - 코스 따라가기: course.distance(km) → m 변환 후 1/4, 2/4, 3/4, 4/4 (총 4컷) + * - 자유 기록: 500m 간격, 정책상 최대 6컷 (500/1000/1500/2000/2500/3000m) + * + * 모든 반환 단위는 미터(m). distanceTotal(Haversine) 과 단위 일치. + */ +@Component +public class TrackingMilestoneCalculator { + + private static final int COURSE_MILESTONE_COUNT = 4; + private static final double FREE_RECORDING_INTERVAL_METERS = 500.0; + private static final int FREE_RECORDING_MAX_COUNT = 6; + private static final double KM_TO_M = 1000.0; + + public List calculate(TrackingSession session) { + if (Boolean.TRUE.equals(session.getIsFreeRecording()) || session.getCourse() == null) { + return freeRecordingMilestones(); + } + return courseMilestones(session.getCourse()); + } + + private List courseMilestones(Course course) { + double totalMeters = (course.getDistance() == null ? 0.0 : course.getDistance()) * KM_TO_M; + List result = new ArrayList<>(COURSE_MILESTONE_COUNT); + for (int i = 1; i <= COURSE_MILESTONE_COUNT; i++) { + result.add(totalMeters * i / COURSE_MILESTONE_COUNT); + } + return result; + } + + private List freeRecordingMilestones() { + List result = new ArrayList<>(FREE_RECORDING_MAX_COUNT); + for (int i = 1; i <= FREE_RECORDING_MAX_COUNT; i++) { + result.add(FREE_RECORDING_INTERVAL_METERS * i); + } + return result; + } +} diff --git a/src/main/java/com/semosan/api/domain/tracking/service/TrackingPhotoTriggerService.java b/src/main/java/com/semosan/api/domain/tracking/service/TrackingPhotoTriggerService.java new file mode 100644 index 00000000..7f8f3d8c --- /dev/null +++ b/src/main/java/com/semosan/api/domain/tracking/service/TrackingPhotoTriggerService.java @@ -0,0 +1,151 @@ +package com.semosan.api.domain.tracking.service; + +import com.semosan.api.domain.notification.enums.NotificationType; +import com.semosan.api.domain.notification.service.NotificationService; +import com.semosan.api.domain.tracking.entity.TrackingSession; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.messaging.simp.SimpMessagingTemplate; +import org.springframework.stereotype.Service; + +import java.time.Duration; +import java.time.LocalDateTime; +import java.util.Arrays; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + +/** + * 트래킹 진행 중 GPS 거리 누적값을 보고, 사전에 결정된 마일스톤 거리 ±10% 윈도우에 + * 진입/이탈할 때 다음 동작을 수행한다. + * - 진입(OPEN): WebSocket OPEN 메시지 + FCM 푸시 ("{distance}m 돌파! 인증 사진을 남겨보세요!") + * - 이탈(CLOSED): WebSocket CLOSED 메시지 (FCM 없음) + * 마일스톤 도달 상태는 Redis Set 으로 idempotent 하게 관리. + * + * TODO: 다중 인스턴스 동시성 — 같은 마일스톤에 대해 두 인스턴스가 동시 OPEN 발송할 가능성. + * Lua 스크립트 또는 분산 락으로 보강 필요. + * TODO: 푸시 본문 단위 — 현재 m 단위 정수. 코스 마일스톤이 정수가 아닐 때 km 단위 포맷 고려. + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class TrackingPhotoTriggerService { + + private static final double TOLERANCE_RATIO = 0.10; + private static final Duration TTL = Duration.ofHours(24); + + private final StringRedisTemplate redisTemplate; + private final SimpMessagingTemplate messagingTemplate; + private final NotificationService notificationService; + private final TrackingMilestoneCalculator milestoneCalculator; + + /** 세션 생성 시 1회 호출 — 마일스톤 거리 리스트를 Redis 에 저장. */ + public void initializeMilestones(TrackingSession session) { + List milestones = milestoneCalculator.calculate(session); + if (milestones.isEmpty()) { + return; + } + String key = milestonesKey(session.getId()); + String value = milestones.stream().map(String::valueOf).collect(Collectors.joining(",")); + redisTemplate.opsForValue().set(key, value); + redisTemplate.expire(key, TTL); + } + + /** GPS Consumer 가 매 점 처리 직후 호출. distanceTotal 은 누적 거리(m). */ + public void evaluate(Long sessionId, Long userId, double distanceTotal) { + List milestones = loadMilestones(sessionId); + if (milestones.isEmpty()) { + return; + } + Set opened = membersOrEmpty(openedKey(sessionId)); + Set closed = membersOrEmpty(closedKey(sessionId)); + + for (int i = 0; i < milestones.size(); i++) { + String idxStr = String.valueOf(i); + double mi = milestones.get(i); + double entry = mi * (1 - TOLERANCE_RATIO); + double exit = mi * (1 + TOLERANCE_RATIO); + boolean isOpened = opened.contains(idxStr); + boolean isClosed = closed.contains(idxStr); + + if (!isOpened && distanceTotal >= entry && distanceTotal <= exit) { + sendOpen(sessionId, userId, i, mi); + redisTemplate.opsForSet().add(openedKey(sessionId), idxStr); + redisTemplate.expire(openedKey(sessionId), TTL); + } + if (isOpened && !isClosed && distanceTotal > exit) { + sendClosed(sessionId, i, mi); + redisTemplate.opsForSet().add(closedKey(sessionId), idxStr); + redisTemplate.expire(closedKey(sessionId), TTL); + } + } + } + + private void sendOpen(Long sessionId, Long userId, int idx, double mi) { + Map payload = new LinkedHashMap<>(); + payload.put("milestoneIndex", idx); + payload.put("milestoneDistance", mi); + payload.put("status", "OPEN"); + payload.put("openedAt", LocalDateTime.now().toString()); + messagingTemplate.convertAndSend(photoTopic(sessionId), payload); + log.info("Photo window OPEN: sessionId={} idx={} milestone={}m", sessionId, idx, (int) Math.round(mi)); + + try { + notificationService.send( + userId, + NotificationType.TRACKING_PHOTO_MILESTONE, + Map.of("distance", (int) Math.round(mi)) + ); + } catch (Exception e) { + // FCM 발송 실패가 WebSocket OPEN 자체를 막아선 안 됨 — 로그만 남기고 진행 + log.warn("Failed to send TRACKING_PHOTO_MILESTONE FCM: sessionId={} idx={} mi={} err={}", + sessionId, idx, mi, e.getMessage()); + } + } + + private void sendClosed(Long sessionId, int idx, double mi) { + Map payload = new LinkedHashMap<>(); + payload.put("milestoneIndex", idx); + payload.put("milestoneDistance", mi); + payload.put("status", "CLOSED"); + payload.put("closedAt", LocalDateTime.now().toString()); + messagingTemplate.convertAndSend(photoTopic(sessionId), payload); + log.info("Photo window CLOSED: sessionId={} idx={} milestone={}m", sessionId, idx, (int) Math.round(mi)); + } + + private List loadMilestones(Long sessionId) { + String raw = redisTemplate.opsForValue().get(milestonesKey(sessionId)); + if (raw == null || raw.isBlank()) { + return List.of(); + } + return Arrays.stream(raw.split(",")) + .map(String::trim) + .filter(s -> !s.isEmpty()) + .map(Double::parseDouble) + .toList(); + } + + private Set membersOrEmpty(String key) { + Set members = redisTemplate.opsForSet().members(key); + return members == null ? Set.of() : members; + } + + private static String milestonesKey(Long sessionId) { + return "tracking:session:" + sessionId + ":milestones"; + } + + private static String openedKey(Long sessionId) { + return "tracking:session:" + sessionId + ":photo:opened"; + } + + private static String closedKey(Long sessionId) { + return "tracking:session:" + sessionId + ":photo:closed"; + } + + private static String photoTopic(Long sessionId) { + return "/topic/tracking/" + sessionId + "/photo-window"; + } +} diff --git a/src/main/java/com/semosan/api/domain/tracking/service/TrackingSessionService.java b/src/main/java/com/semosan/api/domain/tracking/service/TrackingSessionService.java index 028df612..9378eed3 100644 --- a/src/main/java/com/semosan/api/domain/tracking/service/TrackingSessionService.java +++ b/src/main/java/com/semosan/api/domain/tracking/service/TrackingSessionService.java @@ -40,7 +40,17 @@ public class TrackingSessionService { private final TrackingSessionStatsService statsService; private final HikingRecordRepository hikingRecordRepository; private final HikingMemberRepository hikingMemberRepository; + private final TrackingPhotoTriggerService photoTriggerService; + /** + * 세션 생성. 유저당 진행 중 세션은 1개만 허용한다. + * + * TODO/주의 — 사용자가 종료(complete/abandon)를 누르지 않고 앱을 강제 종료하면 이전 세션이 IN_PROGRESS + * 로 유령처럼 남아 다음 세션 생성을 막을 수 있다. 대응 메커니즘: + * 1) 클라이언트 진입 시 GET /me/active 로 진행 중 세션 확인 후 "이어서 / 폐기 후 새로 시작" 분기 노출 + * 2) {@link TrackingSessionExpiryScheduler} 가 24h 무업데이트 세션을 ABANDONED 처리하는 fallback + * 운영 시 자주 발생할 수 있는 케이스이므로 클라이언트 흐름에서 (1)을 반드시 구현할 것. + */ @Transactional public TrackingSessionResponse create(Long userId, CreateTrackingSessionRequest request) { if (trackingSessionRepository.existsByUser_IdAndStatusIn(userId, ACTIVE_STATES)) { @@ -54,6 +64,7 @@ public TrackingSessionResponse create(Long userId, CreateTrackingSessionRequest TrackingSession session = TrackingSession.create(user, mountain, course, request.isFreeRecording()); TrackingSession saved = trackingSessionRepository.save(session); + photoTriggerService.initializeMilestones(saved); return TrackingSessionResponse.from(saved); } diff --git a/src/main/java/com/semosan/api/domain/tracking/service/TrackingSessionStatsService.java b/src/main/java/com/semosan/api/domain/tracking/service/TrackingSessionStatsService.java index 75a33d81..4291a2c9 100644 --- a/src/main/java/com/semosan/api/domain/tracking/service/TrackingSessionStatsService.java +++ b/src/main/java/com/semosan/api/domain/tracking/service/TrackingSessionStatsService.java @@ -43,7 +43,8 @@ public class TrackingSessionStatsService { private final StringRedisTemplate redisTemplate; - public void recordPoint(Long sessionId, double lat, double lng, Double altitude, LocalDateTime recordedAt) { + /** 점을 누적 갱신하고, 갱신 후 총 거리(m)를 반환한다 — 호출자에서 마일스톤 트리거에 활용. */ + public double recordPoint(Long sessionId, double lat, double lng, Double altitude, LocalDateTime recordedAt) { String key = statsKey(sessionId); HashOperations hash = redisTemplate.opsForHash(); Map prev = hash.entries(key); @@ -90,6 +91,7 @@ public void recordPoint(Long sessionId, double lat, double lng, Double altitude, hash.putAll(key, next); redisTemplate.expire(key, TTL); + return distanceTotal; } public static String statsKey(Long sessionId) { diff --git a/src/main/java/com/semosan/api/domain/tracking/service/TrackingStreamConsumer.java b/src/main/java/com/semosan/api/domain/tracking/service/TrackingStreamConsumer.java index b4fec765..5fc1dc72 100644 --- a/src/main/java/com/semosan/api/domain/tracking/service/TrackingStreamConsumer.java +++ b/src/main/java/com/semosan/api/domain/tracking/service/TrackingStreamConsumer.java @@ -36,6 +36,7 @@ public class TrackingStreamConsumer implements StreamListener message) { Map body = message.getValue(); try { Long sessionId = Long.parseLong(body.get(F_SESSION_ID)); + Long userId = Long.parseLong(body.get(F_USER_ID)); double lat = Double.parseDouble(body.get(F_LAT)); double lng = Double.parseDouble(body.get(F_LNG)); Double altitude = parseNullableDouble(body.get(F_ALTITUDE)); LocalDateTime recordedAt = LocalDateTime.parse(body.get(F_RECORDED_AT)); - statsService.recordPoint(sessionId, lat, lng, altitude, recordedAt); + double distanceTotal = statsService.recordPoint(sessionId, lat, lng, altitude, recordedAt); + photoTriggerService.evaluate(sessionId, userId, distanceTotal); Queue queue = buffers.computeIfAbsent(sessionId, k -> new ConcurrentLinkedQueue<>()); From 82b5895da864f85c0b6d97f581a559d6aa98bab3 Mon Sep 17 00:00:00 2001 From: JangInho Date: Sun, 17 May 2026 21:53:13 +0900 Subject: [PATCH 03/22] =?UTF-8?q?feat:=20=ED=8A=B8=EB=9E=98=ED=82=B9=20?= =?UTF-8?q?=EC=82=AC=EC=A7=84=20=EB=A9=94=ED=83=80=20=EC=97=85=EB=A1=9C?= =?UTF-8?q?=EB=93=9C=20API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/TrackingPhotoController.java | 44 ++++++++++++ .../docs/TrackingPhotoControllerDocs.java | 51 ++++++++++++++ .../request/TrackingPhotoUploadRequest.java | 35 ++++++++++ .../dto/response/TrackingPhotoResponse.java | 32 +++++++++ .../service/TrackingPhotoService.java | 69 +++++++++++++++++++ 5 files changed, 231 insertions(+) create mode 100644 src/main/java/com/semosan/api/domain/tracking/controller/TrackingPhotoController.java create mode 100644 src/main/java/com/semosan/api/domain/tracking/controller/docs/TrackingPhotoControllerDocs.java create mode 100644 src/main/java/com/semosan/api/domain/tracking/dto/request/TrackingPhotoUploadRequest.java create mode 100644 src/main/java/com/semosan/api/domain/tracking/dto/response/TrackingPhotoResponse.java create mode 100644 src/main/java/com/semosan/api/domain/tracking/service/TrackingPhotoService.java diff --git a/src/main/java/com/semosan/api/domain/tracking/controller/TrackingPhotoController.java b/src/main/java/com/semosan/api/domain/tracking/controller/TrackingPhotoController.java new file mode 100644 index 00000000..f0e1738b --- /dev/null +++ b/src/main/java/com/semosan/api/domain/tracking/controller/TrackingPhotoController.java @@ -0,0 +1,44 @@ +package com.semosan.api.domain.tracking.controller; + +import com.semosan.api.common.response.ApiResponse; +import com.semosan.api.common.status.SuccessStatus; +import com.semosan.api.domain.tracking.controller.docs.TrackingPhotoControllerDocs; +import com.semosan.api.domain.tracking.dto.request.TrackingPhotoUploadRequest; +import com.semosan.api.domain.tracking.dto.response.TrackingPhotoResponse; +import com.semosan.api.domain.tracking.service.TrackingPhotoService; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@RestController +@RequestMapping("/api/tracking/sessions/{sessionId}/photos") +@RequiredArgsConstructor +public class TrackingPhotoController implements TrackingPhotoControllerDocs { + + private final TrackingPhotoService trackingPhotoService; + + @PostMapping + @Override + public ResponseEntity> upload( + @AuthenticationPrincipal Long userId, + @PathVariable Long sessionId, + @Valid @RequestBody TrackingPhotoUploadRequest request + ) { + TrackingPhotoResponse response = trackingPhotoService.upload(userId, sessionId, request); + return ApiResponse.success(SuccessStatus.TRACKING_PHOTO_UPLOAD_SUCCESS, response); + } + + @GetMapping + @Override + public ResponseEntity>> list( + @AuthenticationPrincipal Long userId, + @PathVariable Long sessionId + ) { + List response = trackingPhotoService.listBySession(userId, sessionId); + return ApiResponse.success(SuccessStatus.TRACKING_PHOTO_LIST_SUCCESS, response); + } +} diff --git a/src/main/java/com/semosan/api/domain/tracking/controller/docs/TrackingPhotoControllerDocs.java b/src/main/java/com/semosan/api/domain/tracking/controller/docs/TrackingPhotoControllerDocs.java new file mode 100644 index 00000000..82143142 --- /dev/null +++ b/src/main/java/com/semosan/api/domain/tracking/controller/docs/TrackingPhotoControllerDocs.java @@ -0,0 +1,51 @@ +package com.semosan.api.domain.tracking.controller.docs; + +import com.semosan.api.common.response.ApiResponse; +import com.semosan.api.domain.tracking.dto.request.TrackingPhotoUploadRequest; +import com.semosan.api.domain.tracking.dto.response.TrackingPhotoResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestBody; + +import java.util.List; + +@Tag(name = "Tracking Photo", description = "트래킹 마일스톤 사진 메타 API") +public interface TrackingPhotoControllerDocs { + + @Operation( + summary = "트래킹 마일스톤 사진 메타 업로드", + description = "클라이언트가 MinIO 등 외부 스토리지(#41) 에 사진을 업로드한 직후 호출. " + + "본 API 는 이미지 바이너리를 받지 않으며 메타(URL, 좌표, 마일스톤 정보) 만 저장한다. " + + "해당 마일스톤에 이미 사진이 있으면 409, 종료된 세션이면 409." + ) + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "201", description = "사진 메타 저장 성공"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "본인 세션 아님", + content = @Content(schema = @Schema(implementation = ApiResponse.class))), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "세션 없음", + content = @Content(schema = @Schema(implementation = ApiResponse.class))), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "409", + description = "중복 마일스톤 또는 종료된 세션", + content = @Content(schema = @Schema(implementation = ApiResponse.class))) + }) + ResponseEntity> upload( + @Parameter(hidden = true) @AuthenticationPrincipal Long userId, + @Parameter(description = "트래킹 세션 ID", required = true) @PathVariable Long sessionId, + @Valid @RequestBody TrackingPhotoUploadRequest request + ); + + @Operation(summary = "트래킹 세션의 사진 목록 조회", + description = "본인 소유 세션의 마일스톤 사진을 milestone_index 오름차순으로 반환합니다.") + ResponseEntity>> list( + @Parameter(hidden = true) @AuthenticationPrincipal Long userId, + @Parameter(description = "트래킹 세션 ID", required = true) @PathVariable Long sessionId + ); +} diff --git a/src/main/java/com/semosan/api/domain/tracking/dto/request/TrackingPhotoUploadRequest.java b/src/main/java/com/semosan/api/domain/tracking/dto/request/TrackingPhotoUploadRequest.java new file mode 100644 index 00000000..d035a553 --- /dev/null +++ b/src/main/java/com/semosan/api/domain/tracking/dto/request/TrackingPhotoUploadRequest.java @@ -0,0 +1,35 @@ +package com.semosan.api.domain.tracking.dto.request; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.PositiveOrZero; + +import java.time.LocalDateTime; + +/** + * 클라이언트가 MinIO 에 사진을 업로드한 직후, 백엔드에 메타를 통보하는 요청. + * imageUrl 자체는 #41 presigned URL 흐름에서 얻는다. + */ +public record TrackingPhotoUploadRequest( + @NotNull(message = "마일스톤 인덱스는 필수입니다.") + @PositiveOrZero(message = "마일스톤 인덱스는 0 이상이어야 합니다.") + Integer milestoneIndex, + + @NotNull(message = "마일스톤 기준 거리(m)는 필수입니다.") + Double milestoneDistanceM, + + @NotBlank(message = "imageUrl 은 필수입니다.") + String imageUrl, + + @NotNull(message = "촬영 시각은 필수입니다.") + LocalDateTime capturedAt, + + @NotNull(message = "위도는 필수입니다.") + Double lat, + + @NotNull(message = "경도는 필수입니다.") + Double lng, + + Double altitude +) { +} diff --git a/src/main/java/com/semosan/api/domain/tracking/dto/response/TrackingPhotoResponse.java b/src/main/java/com/semosan/api/domain/tracking/dto/response/TrackingPhotoResponse.java new file mode 100644 index 00000000..7e502752 --- /dev/null +++ b/src/main/java/com/semosan/api/domain/tracking/dto/response/TrackingPhotoResponse.java @@ -0,0 +1,32 @@ +package com.semosan.api.domain.tracking.dto.response; + +import com.semosan.api.domain.tracking.entity.TrackingPhoto; + +import java.time.LocalDateTime; + +public record TrackingPhotoResponse( + Long photoId, + Long trackingSessionId, + Integer milestoneIndex, + Double milestoneDistanceM, + String imageUrl, + LocalDateTime capturedAt, + Double lat, + Double lng, + Double altitude +) { + + public static TrackingPhotoResponse from(TrackingPhoto photo) { + return new TrackingPhotoResponse( + photo.getId(), + photo.getTrackingSession().getId(), + photo.getMilestoneIndex(), + photo.getMilestoneDistanceM(), + photo.getImageUrl(), + photo.getCapturedAt(), + photo.getLat(), + photo.getLng(), + photo.getAltitude() + ); + } +} diff --git a/src/main/java/com/semosan/api/domain/tracking/service/TrackingPhotoService.java b/src/main/java/com/semosan/api/domain/tracking/service/TrackingPhotoService.java new file mode 100644 index 00000000..1ebcee30 --- /dev/null +++ b/src/main/java/com/semosan/api/domain/tracking/service/TrackingPhotoService.java @@ -0,0 +1,69 @@ +package com.semosan.api.domain.tracking.service; + +import com.semosan.api.common.exception.GeneralException; +import com.semosan.api.common.status.ErrorStatus; +import com.semosan.api.domain.tracking.dto.request.TrackingPhotoUploadRequest; +import com.semosan.api.domain.tracking.dto.response.TrackingPhotoResponse; +import com.semosan.api.domain.tracking.entity.TrackingPhoto; +import com.semosan.api.domain.tracking.entity.TrackingSession; +import com.semosan.api.domain.tracking.enums.TrackingSessionStatus; +import com.semosan.api.domain.tracking.repository.TrackingPhotoRepository; +import com.semosan.api.domain.tracking.repository.TrackingSessionRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.EnumSet; +import java.util.List; +import java.util.Set; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class TrackingPhotoService { + + private static final Set ACTIVE_STATES = + EnumSet.of(TrackingSessionStatus.IN_PROGRESS, TrackingSessionStatus.PAUSED); + + private final TrackingPhotoRepository trackingPhotoRepository; + private final TrackingSessionRepository trackingSessionRepository; + + @Transactional + public TrackingPhotoResponse upload(Long userId, Long sessionId, TrackingPhotoUploadRequest request) { + TrackingSession session = findOwnedSession(userId, sessionId); + if (!ACTIVE_STATES.contains(session.getStatus())) { + throw new GeneralException(ErrorStatus.TRACKING_PHOTO_SESSION_INACTIVE); + } + if (trackingPhotoRepository.existsByTrackingSession_IdAndMilestoneIndex(sessionId, request.milestoneIndex())) { + throw new GeneralException(ErrorStatus.TRACKING_PHOTO_DUPLICATE); + } + TrackingPhoto photo = TrackingPhoto.create( + session, + request.milestoneIndex(), + request.milestoneDistanceM(), + request.imageUrl(), + request.capturedAt(), + request.lat(), + request.lng(), + request.altitude() + ); + TrackingPhoto saved = trackingPhotoRepository.save(photo); + return TrackingPhotoResponse.from(saved); + } + + public List listBySession(Long userId, Long sessionId) { + findOwnedSession(userId, sessionId); + return trackingPhotoRepository.findByTrackingSession_IdOrderByMilestoneIndexAsc(sessionId).stream() + .map(TrackingPhotoResponse::from) + .toList(); + } + + private TrackingSession findOwnedSession(Long userId, Long sessionId) { + TrackingSession session = trackingSessionRepository.findById(sessionId) + .orElseThrow(() -> new GeneralException(ErrorStatus.TRACKING_SESSION_NOT_FOUND)); + if (!session.isOwnedBy(userId)) { + throw new GeneralException(ErrorStatus.TRACKING_SESSION_FORBIDDEN); + } + return session; + } +} From 86148ff39df6884ccdfb7efc7aff5c28b8f30f86 Mon Sep 17 00:00:00 2001 From: pooreumjung Date: Thu, 21 May 2026 15:21:15 +0900 Subject: [PATCH 04/22] =?UTF-8?q?feat:=20=EB=94=94=EC=8A=A4=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=EC=84=9C=EB=B2=84=20=EC=97=90=EB=9F=AC=20=EC=95=8C?= =?UTF-8?q?=EB=A6=BC=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../api/common/alert/DiscordAlertClient.java | 40 +++++++++ .../common/alert/DiscordAlertProperties.java | 28 ++++++ .../common/alert/ServerErrorAlertService.java | 90 +++++++++++++++++++ .../exception/GeneralExceptionAdvice.java | 20 ++++- src/main/resources/application-prod.yaml | 5 ++ src/main/resources/application.yaml | 5 ++ 6 files changed, 184 insertions(+), 4 deletions(-) create mode 100644 src/main/java/com/semosan/api/common/alert/DiscordAlertClient.java create mode 100644 src/main/java/com/semosan/api/common/alert/DiscordAlertProperties.java create mode 100644 src/main/java/com/semosan/api/common/alert/ServerErrorAlertService.java diff --git a/src/main/java/com/semosan/api/common/alert/DiscordAlertClient.java b/src/main/java/com/semosan/api/common/alert/DiscordAlertClient.java new file mode 100644 index 00000000..c7bb66a7 --- /dev/null +++ b/src/main/java/com/semosan/api/common/alert/DiscordAlertClient.java @@ -0,0 +1,40 @@ +package com.semosan.api.common.alert; + +import lombok.RequiredArgsConstructor; +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; +import java.util.Map; + +@Slf4j +@Component +@RequiredArgsConstructor +public class DiscordAlertClient { + + private static final Duration REQUEST_TIMEOUT = Duration.ofSeconds(3); + + private final DiscordAlertProperties properties; + private final WebClient.Builder webClientBuilder; + + public void send(String content) { + if (!properties.isEnabled() || !StringUtils.hasText(properties.getWebhookUrl())) { + return; + } + + try { + webClientBuilder.build() + .post() + .uri(properties.getWebhookUrl()) + .bodyValue(Map.of("content", content)) + .retrieve() + .toBodilessEntity() + .timeout(REQUEST_TIMEOUT) + .block(); + } catch (Exception e) { + log.warn("[*] Discord alert send failed: {}", e.getMessage()); + } + } +} diff --git a/src/main/java/com/semosan/api/common/alert/DiscordAlertProperties.java b/src/main/java/com/semosan/api/common/alert/DiscordAlertProperties.java new file mode 100644 index 00000000..91fca6bd --- /dev/null +++ b/src/main/java/com/semosan/api/common/alert/DiscordAlertProperties.java @@ -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; + } +} diff --git a/src/main/java/com/semosan/api/common/alert/ServerErrorAlertService.java b/src/main/java/com/semosan/api/common/alert/ServerErrorAlertService.java new file mode 100644 index 00000000..b1713353 --- /dev/null +++ b/src/main/java/com/semosan/api/common/alert/ServerErrorAlertService.java @@ -0,0 +1,90 @@ +package com.semosan.api.common.alert; + +import jakarta.servlet.http.HttpServletRequest; +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.time.OffsetDateTime; +import java.util.Arrays; + +@Service +@RequiredArgsConstructor +public class ServerErrorAlertService { + + private static final int MAX_MESSAGE_LENGTH = 300; + private static final int MAX_USER_AGENT_LENGTH = 180; + private static final int MAX_CONTENT_LENGTH = 1900; + + private final DiscordAlertClient discordAlertClient; + private final Environment environment; + + @Async("notificationTaskExecutor") + public void notify(int status, Exception exception, HttpServletRequest request) { + discordAlertClient.send(buildMessage(status, exception, request)); + } + + private String buildMessage(int status, Exception exception, HttpServletRequest request) { + String content = """ + [SEMOSAN API %d ERROR] + + Profile: %s + Method: %s + URI: %s + Status: %d + Exception: %s + Message: %s + Client IP: %s + User-Agent: %s + Time: %s + """.formatted( + status, + activeProfiles(), + request.getMethod(), + request.getRequestURI(), + status, + exception.getClass().getSimpleName(), + sanitize(exception.getMessage(), MAX_MESSAGE_LENGTH), + clientIp(request), + sanitize(request.getHeader("User-Agent"), MAX_USER_AGENT_LENGTH), + OffsetDateTime.now() + ); + + if (content.length() <= MAX_CONTENT_LENGTH) { + return content; + } + return content.substring(0, MAX_CONTENT_LENGTH) + "\n..."; + } + + private String activeProfiles() { + String[] profiles = environment.getActiveProfiles(); + if (profiles.length == 0) { + profiles = environment.getDefaultProfiles(); + } + return String.join(",", Arrays.asList(profiles)); + } + + private String clientIp(HttpServletRequest request) { + String forwardedFor = request.getHeader("X-Forwarded-For"); + if (StringUtils.hasText(forwardedFor)) { + return forwardedFor.split(",")[0].trim(); + } + return request.getRemoteAddr(); + } + + private String sanitize(String value, int maxLength) { + if (!StringUtils.hasText(value)) { + return "-"; + } + String sanitized = value + .replaceAll("(?i)(authorization|cookie|token|secret|password)=\\S+", "$1=***") + .replace("\n", " ") + .replace("\r", " "); + if (sanitized.length() <= maxLength) { + return sanitized; + } + return sanitized.substring(0, maxLength) + "..."; + } +} diff --git a/src/main/java/com/semosan/api/common/exception/GeneralExceptionAdvice.java b/src/main/java/com/semosan/api/common/exception/GeneralExceptionAdvice.java index 4e5b2b45..1dfc7f3e 100644 --- a/src/main/java/com/semosan/api/common/exception/GeneralExceptionAdvice.java +++ b/src/main/java/com/semosan/api/common/exception/GeneralExceptionAdvice.java @@ -1,9 +1,12 @@ 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.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; @@ -17,15 +20,20 @@ @Slf4j @RestControllerAdvice +@RequiredArgsConstructor public class GeneralExceptionAdvice extends ResponseEntityExceptionHandler { + private final ServerErrorAlertService serverErrorAlertService; + // 커스텀 예외(GeneralException)를 잡아서 정의된 에러 상태로 응답 반환 @ExceptionHandler(GeneralException.class) public ResponseEntity> handleGeneralException( - GeneralException e + GeneralException e, + HttpServletRequest request ) { if (e.getErrorStatus().getHttpStatus().is5xxServerError()) { log.error("[*] GeneralException :", e); + serverErrorAlertService.notify(e.getErrorStatus().getHttpStatus().value(), e, request); } else { log.warn("[*] GeneralException : {}", e.getMessage()); } @@ -74,18 +82,22 @@ protected ResponseEntity handleHandlerMethodValidationException( // null 참조로 발생한 서버 오류를 500 에러로 응답 @ExceptionHandler(NullPointerException.class) public ResponseEntity> handleNullPointerException( - NullPointerException e + NullPointerException e, + HttpServletRequest request ) { log.error("[*] NullPointerException :", e); + serverErrorAlertService.notify(ErrorStatus.INTERNAL_SERVER_ERROR.getHttpStatus().value(), e, request); return ApiResponse.error(ErrorStatus.INTERNAL_SERVER_ERROR); } // 처리되지 않은 모든 예외를 잡아 500 서버 오류로 응답 @ExceptionHandler(Exception.class) public ResponseEntity> handleException( - Exception e + Exception e, + HttpServletRequest request ) { log.error("[*] Internal Server Error :", e); + serverErrorAlertService.notify(ErrorStatus.INTERNAL_SERVER_ERROR.getHttpStatus().value(), e, request); return ApiResponse.error(ErrorStatus.INTERNAL_SERVER_ERROR); } diff --git a/src/main/resources/application-prod.yaml b/src/main/resources/application-prod.yaml index 40f4daa6..871b33c8 100644 --- a/src/main/resources/application-prod.yaml +++ b/src/main/resources/application-prod.yaml @@ -41,3 +41,8 @@ tracking: test: secret-key: ${TEST_SECRET_KEY} + +discord: + alert: + enabled: ${DISCORD_ALERT_ENABLED:false} + webhook-url: ${DISCORD_WEBHOOK_URL:} diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index 71e22b51..0538297c 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -56,3 +56,8 @@ minio: access-key: ${MINIO_ACCESS_KEY} secret-key: ${MINIO_SECRET_KEY} public-url: ${MINIO_PUBLIC_URL} + +discord: + alert: + enabled: ${DISCORD_ALERT_ENABLED:false} + webhook-url: ${DISCORD_WEBHOOK_URL:} From 764339aacec4817f6e188272cc3f4ba211c4750e Mon Sep 17 00:00:00 2001 From: pooreumjung Date: Thu, 21 May 2026 15:24:27 +0900 Subject: [PATCH 05/22] =?UTF-8?q?refactor:=20application.yaml,=20applicati?= =?UTF-8?q?on-prod.yaml=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/resources/application-prod.yaml | 4 ++-- src/main/resources/application.yaml | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/main/resources/application-prod.yaml b/src/main/resources/application-prod.yaml index 871b33c8..b7d4b691 100644 --- a/src/main/resources/application-prod.yaml +++ b/src/main/resources/application-prod.yaml @@ -44,5 +44,5 @@ test: discord: alert: - enabled: ${DISCORD_ALERT_ENABLED:false} - webhook-url: ${DISCORD_WEBHOOK_URL:} + enabled: ${DISCORD_ALERT_ENABLED} + webhook-url: ${DISCORD_WEBHOOK_URL} diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index 0538297c..87246793 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -59,5 +59,5 @@ minio: discord: alert: - enabled: ${DISCORD_ALERT_ENABLED:false} - webhook-url: ${DISCORD_WEBHOOK_URL:} + enabled: ${DISCORD_ALERT_ENABLED} + webhook-url: ${DISCORD_WEBHOOK_URL} From d92d8d1083fa0a337862f3f66eed7afc1eb4e10b Mon Sep 17 00:00:00 2001 From: pooreumjung Date: Thu, 21 May 2026 15:37:08 +0900 Subject: [PATCH 06/22] =?UTF-8?q?feat:=20=EB=94=94=EC=8A=A4=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=EC=84=9C=EB=B2=84=20=EC=97=90=EB=9F=AC=20=EC=95=8C?= =?UTF-8?q?=EB=A6=BC=20=ED=8F=AC=EB=A7=B7=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../api/common/alert/DiscordAlertClient.java | 6 +- .../alert/DiscordAlertTestController.java | 15 +++ .../common/alert/ServerErrorAlertService.java | 103 ++++++++++++------ .../api/common/alert/dto/DiscordEmbed.java | 8 ++ .../api/common/alert/dto/DiscordMessage.java | 9 ++ 5 files changed, 104 insertions(+), 37 deletions(-) create mode 100644 src/main/java/com/semosan/api/common/alert/DiscordAlertTestController.java create mode 100644 src/main/java/com/semosan/api/common/alert/dto/DiscordEmbed.java create mode 100644 src/main/java/com/semosan/api/common/alert/dto/DiscordMessage.java diff --git a/src/main/java/com/semosan/api/common/alert/DiscordAlertClient.java b/src/main/java/com/semosan/api/common/alert/DiscordAlertClient.java index c7bb66a7..0e59d94e 100644 --- a/src/main/java/com/semosan/api/common/alert/DiscordAlertClient.java +++ b/src/main/java/com/semosan/api/common/alert/DiscordAlertClient.java @@ -1,5 +1,6 @@ package com.semosan.api.common.alert; +import com.semosan.api.common.alert.dto.DiscordMessage; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; @@ -7,7 +8,6 @@ import org.springframework.web.reactive.function.client.WebClient; import java.time.Duration; -import java.util.Map; @Slf4j @Component @@ -19,7 +19,7 @@ public class DiscordAlertClient { private final DiscordAlertProperties properties; private final WebClient.Builder webClientBuilder; - public void send(String content) { + public void send(DiscordMessage message) { if (!properties.isEnabled() || !StringUtils.hasText(properties.getWebhookUrl())) { return; } @@ -28,7 +28,7 @@ public void send(String content) { webClientBuilder.build() .post() .uri(properties.getWebhookUrl()) - .bodyValue(Map.of("content", content)) + .bodyValue(message) .retrieve() .toBodilessEntity() .timeout(REQUEST_TIMEOUT) diff --git a/src/main/java/com/semosan/api/common/alert/DiscordAlertTestController.java b/src/main/java/com/semosan/api/common/alert/DiscordAlertTestController.java new file mode 100644 index 00000000..62cb86b5 --- /dev/null +++ b/src/main/java/com/semosan/api/common/alert/DiscordAlertTestController.java @@ -0,0 +1,15 @@ +package com.semosan.api.common.alert; + +import org.springframework.context.annotation.Profile; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; + +@Profile("local") +@RestController +public class DiscordAlertTestController { + + @GetMapping("/test/discord-alert") + public void testDiscordAlert() { + throw new NullPointerException("discord alert test"); + } +} diff --git a/src/main/java/com/semosan/api/common/alert/ServerErrorAlertService.java b/src/main/java/com/semosan/api/common/alert/ServerErrorAlertService.java index b1713353..a724afd9 100644 --- a/src/main/java/com/semosan/api/common/alert/ServerErrorAlertService.java +++ b/src/main/java/com/semosan/api/common/alert/ServerErrorAlertService.java @@ -1,5 +1,7 @@ package com.semosan.api.common.alert; +import com.semosan.api.common.alert.dto.DiscordEmbed; +import com.semosan.api.common.alert.dto.DiscordMessage; import jakarta.servlet.http.HttpServletRequest; import lombok.RequiredArgsConstructor; import org.springframework.core.env.Environment; @@ -7,16 +9,23 @@ import org.springframework.stereotype.Service; import org.springframework.util.StringUtils; -import java.time.OffsetDateTime; +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 int MAX_MESSAGE_LENGTH = 300; - private static final int MAX_USER_AGENT_LENGTH = 180; - private static final int MAX_CONTENT_LENGTH = 1900; + 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; @@ -26,36 +35,38 @@ public void notify(int status, Exception exception, HttpServletRequest request) discordAlertClient.send(buildMessage(status, exception, request)); } - private String buildMessage(int status, Exception exception, HttpServletRequest request) { - String content = """ - [SEMOSAN API %d ERROR] - - Profile: %s - Method: %s - URI: %s - Status: %d - Exception: %s - Message: %s - Client IP: %s - User-Agent: %s - Time: %s + private DiscordMessage buildMessage(int status, Exception exception, HttpServletRequest request) { + String description = """ + ### 에러 발생 시간 + %s + ### 실행 프로필 + %s + ### 요청 엔드포인트 + %s + ### 응답 상태 + %d + ### 요청 클라이언트 + %s + ### 에러 메시지 + %s + ### 에러 스택 트레이스 + ```text + %s + ``` """.formatted( - status, + ZonedDateTime.now(KOREA_ZONE).format(TIME_FORMATTER), activeProfiles(), - request.getMethod(), - request.getRequestURI(), + endpoint(request), status, - exception.getClass().getSimpleName(), - sanitize(exception.getMessage(), MAX_MESSAGE_LENGTH), - clientIp(request), - sanitize(request.getHeader("User-Agent"), MAX_USER_AGENT_LENGTH), - OffsetDateTime.now() + client(request), + sanitize(exception.getMessage()), + stackTrace(exception) ); - if (content.length() <= MAX_CONTENT_LENGTH) { - return content; - } - return content.substring(0, MAX_CONTENT_LENGTH) + "\n..."; + return new DiscordMessage( + "# 🚨 서버 에러 발생 🚨", + List.of(new DiscordEmbed("에러 정보", truncate(description, MAX_EMBED_DESCRIPTION_LENGTH), ERROR_COLOR)) + ); } private String activeProfiles() { @@ -66,6 +77,21 @@ private String activeProfiles() { return String.join(",", Arrays.asList(profiles)); } + private String endpoint(HttpServletRequest request) { + return "[%s] %s".formatted(request.getMethod(), request.getRequestURL()); + } + + private String client(HttpServletRequest request) { + String userIdentifier = request.getUserPrincipal() == null + ? "" + : " / [UserId]: " + sanitize(request.getUserPrincipal().getName()); + return "[IP]: %s%s / [User-Agent]: %s".formatted( + clientIp(request), + userIdentifier, + sanitize(request.getHeader("User-Agent")) + ); + } + private String clientIp(HttpServletRequest request) { String forwardedFor = request.getHeader("X-Forwarded-For"); if (StringUtils.hasText(forwardedFor)) { @@ -74,17 +100,26 @@ private String clientIp(HttpServletRequest request) { return request.getRemoteAddr(); } - private String sanitize(String value, int maxLength) { + private String stackTrace(Exception exception) { + StringWriter writer = new StringWriter(); + exception.printStackTrace(new PrintWriter(writer)); + return truncate(sanitize(writer.toString()), MAX_STACK_TRACE_LENGTH); + } + + private String sanitize(String value) { if (!StringUtils.hasText(value)) { return "-"; } - String sanitized = value + return value .replaceAll("(?i)(authorization|cookie|token|secret|password)=\\S+", "$1=***") .replace("\n", " ") .replace("\r", " "); - if (sanitized.length() <= maxLength) { - return sanitized; + } + + private String truncate(String value, int maxLength) { + if (value.length() <= maxLength) { + return value; } - return sanitized.substring(0, maxLength) + "..."; + return value.substring(0, maxLength) + "..."; } } diff --git a/src/main/java/com/semosan/api/common/alert/dto/DiscordEmbed.java b/src/main/java/com/semosan/api/common/alert/dto/DiscordEmbed.java new file mode 100644 index 00000000..9831313e --- /dev/null +++ b/src/main/java/com/semosan/api/common/alert/dto/DiscordEmbed.java @@ -0,0 +1,8 @@ +package com.semosan.api.common.alert.dto; + +public record DiscordEmbed( + String title, + String description, + Integer color +) { +} diff --git a/src/main/java/com/semosan/api/common/alert/dto/DiscordMessage.java b/src/main/java/com/semosan/api/common/alert/dto/DiscordMessage.java new file mode 100644 index 00000000..05b58d6a --- /dev/null +++ b/src/main/java/com/semosan/api/common/alert/dto/DiscordMessage.java @@ -0,0 +1,9 @@ +package com.semosan.api.common.alert.dto; + +import java.util.List; + +public record DiscordMessage( + String content, + List embeds +) { +} From 882e11b0d0c13025f3abba44a4e634ffd0576ee9 Mon Sep 17 00:00:00 2001 From: pooreumjung Date: Thu, 21 May 2026 15:38:51 +0900 Subject: [PATCH 07/22] =?UTF-8?q?refactor:=20Discord=20=EC=95=8C=EB=A6=BC?= =?UTF-8?q?=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=BB=A8=ED=8B=80=EB=A1=A4?= =?UTF-8?q?=EB=9F=AC=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../common/alert/DiscordAlertTestController.java | 15 --------------- 1 file changed, 15 deletions(-) delete mode 100644 src/main/java/com/semosan/api/common/alert/DiscordAlertTestController.java diff --git a/src/main/java/com/semosan/api/common/alert/DiscordAlertTestController.java b/src/main/java/com/semosan/api/common/alert/DiscordAlertTestController.java deleted file mode 100644 index 62cb86b5..00000000 --- a/src/main/java/com/semosan/api/common/alert/DiscordAlertTestController.java +++ /dev/null @@ -1,15 +0,0 @@ -package com.semosan.api.common.alert; - -import org.springframework.context.annotation.Profile; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.RestController; - -@Profile("local") -@RestController -public class DiscordAlertTestController { - - @GetMapping("/test/discord-alert") - public void testDiscordAlert() { - throw new NullPointerException("discord alert test"); - } -} From 00ed3eea5a3b8c0e018942b4b76f00809143f12e Mon Sep 17 00:00:00 2001 From: pooreumjung Date: Thu, 21 May 2026 15:44:27 +0900 Subject: [PATCH 08/22] =?UTF-8?q?fix:=20=EB=94=94=EC=8A=A4=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=EC=95=8C=EB=A6=BC=20=EB=B9=84=EB=8F=99=EA=B8=B0=20?= =?UTF-8?q?=EC=B2=98=EB=A6=AC=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../api/common/alert/ServerErrorAlertService.java | 11 +++++++---- .../com/semosan/api/common/config/AsyncConfig.java | 11 +++++++++++ 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/src/main/java/com/semosan/api/common/alert/ServerErrorAlertService.java b/src/main/java/com/semosan/api/common/alert/ServerErrorAlertService.java index a724afd9..30b2709a 100644 --- a/src/main/java/com/semosan/api/common/alert/ServerErrorAlertService.java +++ b/src/main/java/com/semosan/api/common/alert/ServerErrorAlertService.java @@ -30,7 +30,7 @@ public class ServerErrorAlertService { private final DiscordAlertClient discordAlertClient; private final Environment environment; - @Async("notificationTaskExecutor") + @Async("discordAlertExecutor") public void notify(int status, Exception exception, HttpServletRequest request) { discordAlertClient.send(buildMessage(status, exception, request)); } @@ -103,19 +103,22 @@ private String clientIp(HttpServletRequest request) { private String stackTrace(Exception exception) { StringWriter writer = new StringWriter(); exception.printStackTrace(new PrintWriter(writer)); - return truncate(sanitize(writer.toString()), MAX_STACK_TRACE_LENGTH); + return truncate(maskSensitiveValues(writer.toString()), MAX_STACK_TRACE_LENGTH); } private String sanitize(String value) { if (!StringUtils.hasText(value)) { return "-"; } - return value - .replaceAll("(?i)(authorization|cookie|token|secret|password)=\\S+", "$1=***") + 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; diff --git a/src/main/java/com/semosan/api/common/config/AsyncConfig.java b/src/main/java/com/semosan/api/common/config/AsyncConfig.java index b4c89cfe..04ed2029 100644 --- a/src/main/java/com/semosan/api/common/config/AsyncConfig.java +++ b/src/main/java/com/semosan/api/common/config/AsyncConfig.java @@ -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.CallerRunsPolicy()); + return executor; + } + @Bean(name = "authCleanupTaskExecutor") public Executor authCleanupTaskExecutor() { ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); From 4794abb93b01bd358a9b769cd982d5bc705ab5f7 Mon Sep 17 00:00:00 2001 From: pooreumjung Date: Thu, 21 May 2026 15:45:33 +0900 Subject: [PATCH 09/22] =?UTF-8?q?refactor:=20=EB=94=94=EC=8A=A4=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=EC=95=8C=EB=A6=BC=20=ED=81=B4=EB=9D=BC=EC=9D=B4?= =?UTF-8?q?=EC=96=B8=ED=8A=B8=EC=99=80=20=EC=98=88=EC=99=B8=20=ED=95=B8?= =?UTF-8?q?=EB=93=A4=EB=9F=AC=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../semosan/api/common/alert/DiscordAlertClient.java | 12 +++++++----- .../api/common/exception/GeneralExceptionAdvice.java | 11 ----------- 2 files changed, 7 insertions(+), 16 deletions(-) diff --git a/src/main/java/com/semosan/api/common/alert/DiscordAlertClient.java b/src/main/java/com/semosan/api/common/alert/DiscordAlertClient.java index 0e59d94e..17691d77 100644 --- a/src/main/java/com/semosan/api/common/alert/DiscordAlertClient.java +++ b/src/main/java/com/semosan/api/common/alert/DiscordAlertClient.java @@ -1,7 +1,6 @@ package com.semosan.api.common.alert; import com.semosan.api.common.alert.dto.DiscordMessage; -import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; import org.springframework.util.StringUtils; @@ -11,13 +10,17 @@ @Slf4j @Component -@RequiredArgsConstructor public class DiscordAlertClient { private static final Duration REQUEST_TIMEOUT = Duration.ofSeconds(3); private final DiscordAlertProperties properties; - private final WebClient.Builder webClientBuilder; + 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())) { @@ -25,8 +28,7 @@ public void send(DiscordMessage message) { } try { - webClientBuilder.build() - .post() + webClient.post() .uri(properties.getWebhookUrl()) .bodyValue(message) .retrieve() diff --git a/src/main/java/com/semosan/api/common/exception/GeneralExceptionAdvice.java b/src/main/java/com/semosan/api/common/exception/GeneralExceptionAdvice.java index 1dfc7f3e..a203825b 100644 --- a/src/main/java/com/semosan/api/common/exception/GeneralExceptionAdvice.java +++ b/src/main/java/com/semosan/api/common/exception/GeneralExceptionAdvice.java @@ -79,17 +79,6 @@ protected ResponseEntity handleHandlerMethodValidationException( return handleExceptionInternal(ex, body, headers, status, request); } - // null 참조로 발생한 서버 오류를 500 에러로 응답 - @ExceptionHandler(NullPointerException.class) - public ResponseEntity> handleNullPointerException( - NullPointerException e, - HttpServletRequest request - ) { - log.error("[*] NullPointerException :", e); - serverErrorAlertService.notify(ErrorStatus.INTERNAL_SERVER_ERROR.getHttpStatus().value(), e, request); - return ApiResponse.error(ErrorStatus.INTERNAL_SERVER_ERROR); - } - // 처리되지 않은 모든 예외를 잡아 500 서버 오류로 응답 @ExceptionHandler(Exception.class) public ResponseEntity> handleException( From 149b174a23d7e1dfe61650b526d64d85a06893c1 Mon Sep 17 00:00:00 2001 From: pooreumjung Date: Thu, 21 May 2026 15:53:26 +0900 Subject: [PATCH 10/22] =?UTF-8?q?fix:=20=EB=94=94=EC=8A=A4=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=EC=95=8C=EB=A6=BC=20=EC=9A=94=EC=B2=AD=20=EC=BB=A8?= =?UTF-8?q?=ED=85=8D=EC=8A=A4=ED=8A=B8=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../api/common/alert/RequestContext.java | 38 +++++++++++++++++++ .../common/alert/ServerErrorAlertService.java | 33 ++++++---------- .../exception/GeneralExceptionAdvice.java | 5 ++- 3 files changed, 53 insertions(+), 23 deletions(-) create mode 100644 src/main/java/com/semosan/api/common/alert/RequestContext.java diff --git a/src/main/java/com/semosan/api/common/alert/RequestContext.java b/src/main/java/com/semosan/api/common/alert/RequestContext.java new file mode 100644 index 00000000..d5d9c9e9 --- /dev/null +++ b/src/main/java/com/semosan/api/common/alert/RequestContext.java @@ -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(); + } +} diff --git a/src/main/java/com/semosan/api/common/alert/ServerErrorAlertService.java b/src/main/java/com/semosan/api/common/alert/ServerErrorAlertService.java index 30b2709a..0fc4fcff 100644 --- a/src/main/java/com/semosan/api/common/alert/ServerErrorAlertService.java +++ b/src/main/java/com/semosan/api/common/alert/ServerErrorAlertService.java @@ -2,7 +2,6 @@ import com.semosan.api.common.alert.dto.DiscordEmbed; import com.semosan.api.common.alert.dto.DiscordMessage; -import jakarta.servlet.http.HttpServletRequest; import lombok.RequiredArgsConstructor; import org.springframework.core.env.Environment; import org.springframework.scheduling.annotation.Async; @@ -31,11 +30,11 @@ public class ServerErrorAlertService { private final Environment environment; @Async("discordAlertExecutor") - public void notify(int status, Exception exception, HttpServletRequest request) { - discordAlertClient.send(buildMessage(status, exception, request)); + public void notify(int status, Exception exception, RequestContext requestContext) { + discordAlertClient.send(buildMessage(status, exception, requestContext)); } - private DiscordMessage buildMessage(int status, Exception exception, HttpServletRequest request) { + private DiscordMessage buildMessage(int status, Exception exception, RequestContext requestContext) { String description = """ ### 에러 발생 시간 %s @@ -56,9 +55,9 @@ private DiscordMessage buildMessage(int status, Exception exception, HttpServlet """.formatted( ZonedDateTime.now(KOREA_ZONE).format(TIME_FORMATTER), activeProfiles(), - endpoint(request), + endpoint(requestContext), status, - client(request), + client(requestContext), sanitize(exception.getMessage()), stackTrace(exception) ); @@ -77,29 +76,21 @@ private String activeProfiles() { return String.join(",", Arrays.asList(profiles)); } - private String endpoint(HttpServletRequest request) { - return "[%s] %s".formatted(request.getMethod(), request.getRequestURL()); + private String endpoint(RequestContext requestContext) { + return "[%s] %s".formatted(requestContext.method(), requestContext.url()); } - private String client(HttpServletRequest request) { - String userIdentifier = request.getUserPrincipal() == null + private String client(RequestContext requestContext) { + String userIdentifier = !StringUtils.hasText(requestContext.userId()) ? "" - : " / [UserId]: " + sanitize(request.getUserPrincipal().getName()); + : " / [UserId]: " + sanitize(requestContext.userId()); return "[IP]: %s%s / [User-Agent]: %s".formatted( - clientIp(request), + requestContext.ip(), userIdentifier, - sanitize(request.getHeader("User-Agent")) + sanitize(requestContext.userAgent()) ); } - private String clientIp(HttpServletRequest request) { - String forwardedFor = request.getHeader("X-Forwarded-For"); - if (StringUtils.hasText(forwardedFor)) { - return forwardedFor.split(",")[0].trim(); - } - return request.getRemoteAddr(); - } - private String stackTrace(Exception exception) { StringWriter writer = new StringWriter(); exception.printStackTrace(new PrintWriter(writer)); diff --git a/src/main/java/com/semosan/api/common/exception/GeneralExceptionAdvice.java b/src/main/java/com/semosan/api/common/exception/GeneralExceptionAdvice.java index a203825b..1250c466 100644 --- a/src/main/java/com/semosan/api/common/exception/GeneralExceptionAdvice.java +++ b/src/main/java/com/semosan/api/common/exception/GeneralExceptionAdvice.java @@ -1,6 +1,7 @@ package com.semosan.api.common.exception; import com.semosan.api.common.base.BaseStatus; +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; @@ -33,7 +34,7 @@ public ResponseEntity> handleGeneralException( ) { if (e.getErrorStatus().getHttpStatus().is5xxServerError()) { log.error("[*] GeneralException :", e); - serverErrorAlertService.notify(e.getErrorStatus().getHttpStatus().value(), e, request); + serverErrorAlertService.notify(e.getErrorStatus().getHttpStatus().value(), e, RequestContext.from(request)); } else { log.warn("[*] GeneralException : {}", e.getMessage()); } @@ -86,7 +87,7 @@ public ResponseEntity> handleException( HttpServletRequest request ) { log.error("[*] Internal Server Error :", e); - serverErrorAlertService.notify(ErrorStatus.INTERNAL_SERVER_ERROR.getHttpStatus().value(), e, request); + serverErrorAlertService.notify(ErrorStatus.INTERNAL_SERVER_ERROR.getHttpStatus().value(), e, RequestContext.from(request)); return ApiResponse.error(ErrorStatus.INTERNAL_SERVER_ERROR); } From f14008e83be9ed711301fd0da5962efba23fa149 Mon Sep 17 00:00:00 2001 From: pooreumjung Date: Thu, 21 May 2026 15:59:22 +0900 Subject: [PATCH 11/22] =?UTF-8?q?fix:=20=EB=94=94=EC=8A=A4=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=EC=95=8C=EB=A6=BC=20=EA=B1=B0=EC=A0=88=20=EC=A0=95?= =?UTF-8?q?=EC=B1=85=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/com/semosan/api/common/config/AsyncConfig.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/com/semosan/api/common/config/AsyncConfig.java b/src/main/java/com/semosan/api/common/config/AsyncConfig.java index 04ed2029..d26aa229 100644 --- a/src/main/java/com/semosan/api/common/config/AsyncConfig.java +++ b/src/main/java/com/semosan/api/common/config/AsyncConfig.java @@ -32,7 +32,7 @@ public Executor discordAlertExecutor() { executor.setMaxPoolSize(2); executor.setQueueCapacity(50); executor.setThreadNamePrefix("discord-alert-"); - executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy()); + executor.setRejectedExecutionHandler(new ThreadPoolExecutor.DiscardPolicy()); return executor; } From d1ad431f5272b70854bd74b22342b19ea824dd1e Mon Sep 17 00:00:00 2001 From: pooreumjung Date: Thu, 21 May 2026 16:13:59 +0900 Subject: [PATCH 12/22] =?UTF-8?q?feat:=20=EC=8A=A4=EC=9B=A8=EA=B1=B0=20?= =?UTF-8?q?=ED=83=9C=EA=B7=B8=20=EC=88=9C=20=EC=95=8C=ED=8C=8C=EB=B2=B3=20?= =?UTF-8?q?=EC=A0=95=EB=A0=AC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/resources/application.yaml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index 87246793..24d30fa1 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -49,6 +49,8 @@ firebase: springdoc: swagger-ui: path: /swagger-ui.html + tags-sorter: alpha + operations-sorter: alpha default-flat-param-object: true minio: From cd4929592e2d72082a2d5a67f8981d43f0a0eaa5 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 21 May 2026 11:10:25 +0000 Subject: [PATCH 13/22] chore: update image to 43242da4f169267b38d7f50e4a5b4e38dc6fe001 [skip ci] --- k8s/kustomization.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/k8s/kustomization.yaml b/k8s/kustomization.yaml index abef55f8..ea9ea05e 100644 --- a/k8s/kustomization.yaml +++ b/k8s/kustomization.yaml @@ -14,4 +14,4 @@ resources: - minio/service.yaml images: - name: ghcr.io/semosan/semosan_be - newTag: 2408eb0c0f5fc413f174a8745cfd08f32d7a88ce + newTag: 43242da4f169267b38d7f50e4a5b4e38dc6fe001 From f188c63ef39c75cb4c3feac38aa7b5a846c91134 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 21 May 2026 11:25:24 +0000 Subject: [PATCH 14/22] chore: update image to 26ede778938b25bbf1153a69d626600c468b10db [skip ci] --- k8s/kustomization.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/k8s/kustomization.yaml b/k8s/kustomization.yaml index ea9ea05e..33e335f2 100644 --- a/k8s/kustomization.yaml +++ b/k8s/kustomization.yaml @@ -14,4 +14,4 @@ resources: - minio/service.yaml images: - name: ghcr.io/semosan/semosan_be - newTag: 43242da4f169267b38d7f50e4a5b4e38dc6fe001 + newTag: 26ede778938b25bbf1153a69d626600c468b10db From 0fcb71b089f282241f6245da40e5e5d4f07f48d1 Mon Sep 17 00:00:00 2001 From: JangInho Date: Thu, 21 May 2026 20:40:11 +0900 Subject: [PATCH 15/22] =?UTF-8?q?chore:=20tracking=5Fphotos=20=ED=85=8C?= =?UTF-8?q?=EC=9D=B4=EB=B8=94=20=EB=A7=88=EC=9D=B4=EA=B7=B8=EB=A0=88?= =?UTF-8?q?=EC=9D=B4=EC=85=98=20V10=20=EC=9C=BC=EB=A1=9C=20=EC=9E=AC?= =?UTF-8?q?=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 003_tracking_photos.sql → V10__create_tracking_photos.sql Flyway 컨벤션 통일 - BIGSERIAL → bigint identity, FK 제약 이름 명시(fk_tracking_photos_session) - timestamp(6), DEFAULT now() 제거 (BaseEntity 가 채움) - notifications.type CHECK 갱신 동봉 (TRACKING_PHOTO_MILESTONE 추가) --- ...os.sql => V10__create_tracking_photos.sql} | 38 ++++++++++--------- 1 file changed, 21 insertions(+), 17 deletions(-) rename src/main/resources/db/migration/{003_tracking_photos.sql => V10__create_tracking_photos.sql} (50%) diff --git a/src/main/resources/db/migration/003_tracking_photos.sql b/src/main/resources/db/migration/V10__create_tracking_photos.sql similarity index 50% rename from src/main/resources/db/migration/003_tracking_photos.sql rename to src/main/resources/db/migration/V10__create_tracking_photos.sql index 0c97d53f..c2ec8635 100644 --- a/src/main/resources/db/migration/003_tracking_photos.sql +++ b/src/main/resources/db/migration/V10__create_tracking_photos.sql @@ -1,22 +1,26 @@ -- ===================================================================== --- 003_tracking_photos.sql +-- V10__create_tracking_photos.sql -- 목적: 트래킹 마일스톤 사진 메타 저장 테이블 생성. -- 실제 이미지 바이너리는 MinIO 에 보관되고 본 테이블엔 URL 만 저장. --- 적용 시점: PR #46 머지 직후. 1회성. +-- + notifications.type CHECK 제약 갱신 (TRACKING_PHOTO_MILESTONE 추가) -- ===================================================================== -CREATE TABLE IF NOT EXISTS tracking_photos ( - id BIGSERIAL PRIMARY KEY, - tracking_session_id BIGINT NOT NULL REFERENCES tracking_sessions(id), - milestone_index INTEGER NOT NULL, - milestone_distance_m DOUBLE PRECISION NOT NULL, - image_url VARCHAR(500) NOT NULL, - captured_at TIMESTAMP NOT NULL, - lat DOUBLE PRECISION NOT NULL, - lng DOUBLE PRECISION NOT NULL, - altitude DOUBLE PRECISION, - created_at TIMESTAMP NOT NULL DEFAULT now(), - updated_at TIMESTAMP NOT NULL DEFAULT now() +CREATE TABLE IF NOT EXISTS tracking_photos +( + id bigint generated by default as identity + primary key, + created_at timestamp(6) not null, + updated_at timestamp(6) not null, + tracking_session_id bigint not null + constraint fk_tracking_photos_session + references tracking_sessions, + milestone_index integer not null, + milestone_distance_m double precision not null, + image_url varchar(500) not null, + captured_at timestamp(6) not null, + lat double precision not null, + lng double precision not null, + altitude double precision ); CREATE INDEX IF NOT EXISTS idx_tracking_photos_session @@ -26,10 +30,10 @@ CREATE INDEX IF NOT EXISTS idx_tracking_photos_session_milestone ON tracking_photos (tracking_session_id, milestone_index); -- ===================================================================== --- notifications.type CHECK 제약 갱신 --- Hibernate 가 enum 컬럼 생성 시점에 그 시점 값만으로 CHECK 를 만들고 +-- notifications.type CHECK 제약 갱신. +-- Hibernate 가 enum 컬럼 생성 시점의 값만으로 CHECK 를 만들고 -- 새 enum 값 추가 시 자동 갱신하지 않으므로 수동 갱신 필요. --- TRACKING_PHOTO_MILESTONE 추가 — 추후 enum 값 추가 시 동일 패턴으로 갱신. +-- 새 enum 값 추가 시 동일 패턴으로 갱신. -- ===================================================================== ALTER TABLE notifications DROP CONSTRAINT IF EXISTS notifications_type_check; ALTER TABLE notifications ADD CONSTRAINT notifications_type_check From 493e199213c353f884c7724caed0fb9ca6c7a3f3 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 21 May 2026 12:06:34 +0000 Subject: [PATCH 16/22] chore: update image to c100d9c16e4129e4a03d45eab0ad1c37966e7707 [skip ci] --- k8s/kustomization.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/k8s/kustomization.yaml b/k8s/kustomization.yaml index 33e335f2..7a751f18 100644 --- a/k8s/kustomization.yaml +++ b/k8s/kustomization.yaml @@ -14,4 +14,4 @@ resources: - minio/service.yaml images: - name: ghcr.io/semosan/semosan_be - newTag: 26ede778938b25bbf1153a69d626600c468b10db + newTag: c100d9c16e4129e4a03d45eab0ad1c37966e7707 From bc90b908034c81f5198093ffdd36e760b636900a Mon Sep 17 00:00:00 2001 From: JangInho Date: Thu, 21 May 2026 21:10:55 +0900 Subject: [PATCH 17/22] =?UTF-8?q?fix:=20getNearbyMountain=20=EB=A9=94?= =?UTF-8?q?=EC=86=8C=EB=93=9C=20=ED=8C=8C=EB=9D=BC=EB=AF=B8=ED=84=B0=20?= =?UTF-8?q?=EC=A0=9C=EC=95=BD=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../api/domain/tracking/controller/TrackingController.java | 6 ++---- .../tracking/controller/docs/TrackingControllerDocs.java | 6 ++++-- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/main/java/com/semosan/api/domain/tracking/controller/TrackingController.java b/src/main/java/com/semosan/api/domain/tracking/controller/TrackingController.java index 223de48b..f372d36d 100644 --- a/src/main/java/com/semosan/api/domain/tracking/controller/TrackingController.java +++ b/src/main/java/com/semosan/api/domain/tracking/controller/TrackingController.java @@ -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; @@ -34,8 +32,8 @@ public class TrackingController implements TrackingControllerDocs { @Override public ResponseEntity> 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); diff --git a/src/main/java/com/semosan/api/domain/tracking/controller/docs/TrackingControllerDocs.java b/src/main/java/com/semosan/api/domain/tracking/controller/docs/TrackingControllerDocs.java index 5675d2b1..3b6ae663 100644 --- a/src/main/java/com/semosan/api/domain/tracking/controller/docs/TrackingControllerDocs.java +++ b/src/main/java/com/semosan/api/domain/tracking/controller/docs/TrackingControllerDocs.java @@ -8,6 +8,8 @@ import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.responses.ApiResponses; import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.RequestParam; @@ -35,8 +37,8 @@ ResponseEntity> getNearbyMountain( @Parameter(hidden = true) @AuthenticationPrincipal Long userId, @Parameter(description = "현재 위치 위도", required = true) - @RequestParam Double lat, + @RequestParam @Min(-90) @Max(90) Double lat, @Parameter(description = "현재 위치 경도", required = true) - @RequestParam Double lng + @RequestParam @Min(-180) @Max(180) Double lng ); } From 84b8ea96eb5f5997d4fb4df50e2d9212c3501b61 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 21 May 2026 12:19:01 +0000 Subject: [PATCH 18/22] chore: update image to 80a67009192af06f3f1daa788fa9fba5626851d4 [skip ci] --- k8s/kustomization.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/k8s/kustomization.yaml b/k8s/kustomization.yaml index 7a751f18..6ea43e4e 100644 --- a/k8s/kustomization.yaml +++ b/k8s/kustomization.yaml @@ -14,4 +14,4 @@ resources: - minio/service.yaml images: - name: ghcr.io/semosan/semosan_be - newTag: c100d9c16e4129e4a03d45eab0ad1c37966e7707 + newTag: 80a67009192af06f3f1daa788fa9fba5626851d4 From 886ccacbde42218b9dd941239c7ec2b1e039421f Mon Sep 17 00:00:00 2001 From: Hoyeon Lee <89958157+howooyeon@users.noreply.github.com> Date: Thu, 21 May 2026 21:51:51 +0900 Subject: [PATCH 19/22] =?UTF-8?q?fix:=20Swagger=20MountainInfo=20=EC=8A=A4?= =?UTF-8?q?=ED=82=A4=EB=A7=88=20=EC=B6=A9=EB=8F=8C=20=ED=95=B4=EC=86=8C=20?= =?UTF-8?q?=EB=B0=8F=20enum=20=ED=83=80=EC=9E=85=20=EB=85=B8=EC=B6=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../tracking/dto/response/NearbyMountainResponse.java | 10 +++++----- src/main/resources/application.yaml | 1 + 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/src/main/java/com/semosan/api/domain/tracking/dto/response/NearbyMountainResponse.java b/src/main/java/com/semosan/api/domain/tracking/dto/response/NearbyMountainResponse.java index f7ad4485..f0f32066 100644 --- a/src/main/java/com/semosan/api/domain/tracking/dto/response/NearbyMountainResponse.java +++ b/src/main/java/com/semosan/api/domain/tracking/dto/response/NearbyMountainResponse.java @@ -7,18 +7,18 @@ import java.util.List; public record NearbyMountainResponse( - MountainInfo mountain, + NearbyMountainInfo mountain, List courses ) { public static NearbyMountainResponse of(Mountain mountain, List courses) { return new NearbyMountainResponse( - MountainInfo.from(mountain), + NearbyMountainInfo.from(mountain), courses.stream().map(CourseInfo::from).toList() ); } - public record MountainInfo( + public record NearbyMountainInfo( Long mountainId, String name, String address, @@ -27,8 +27,8 @@ public record MountainInfo( Double longitude, List imageUrls ) { - public static MountainInfo from(Mountain mountain) { - return new MountainInfo( + public static NearbyMountainInfo from(Mountain mountain) { + return new NearbyMountainInfo( mountain.getId(), mountain.getName(), mountain.getAddress(), diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index 24d30fa1..39209119 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -52,6 +52,7 @@ springdoc: tags-sorter: alpha operations-sorter: alpha default-flat-param-object: true + use-enum-ref: true minio: endpoint: ${MINIO_ENDPOINT} From 0f84324f6ebe4e9eb0d53b6fbeb182c63d26d7fb Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 21 May 2026 12:57:09 +0000 Subject: [PATCH 20/22] chore: update image to b8a12990175fc314f736d0b905a8dbffe5daf070 [skip ci] --- k8s/kustomization.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/k8s/kustomization.yaml b/k8s/kustomization.yaml index 6ea43e4e..9a236d8c 100644 --- a/k8s/kustomization.yaml +++ b/k8s/kustomization.yaml @@ -14,4 +14,4 @@ resources: - minio/service.yaml images: - name: ghcr.io/semosan/semosan_be - newTag: 80a67009192af06f3f1daa788fa9fba5626851d4 + newTag: b8a12990175fc314f736d0b905a8dbffe5daf070 From b976ab6dd6006e94df6af125add740f1b4ed681f Mon Sep 17 00:00:00 2001 From: pooreumjung Date: Fri, 22 May 2026 10:51:45 +0900 Subject: [PATCH 21/22] =?UTF-8?q?refactor:=20=EC=8A=A4=EC=9B=A8=EA=B1=B0?= =?UTF-8?q?=20=EC=A0=95=EB=A0=AC=20=EC=88=9C=EC=84=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/resources/application.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index 39209119..d25bf51c 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -50,7 +50,7 @@ springdoc: swagger-ui: path: /swagger-ui.html tags-sorter: alpha - operations-sorter: alpha + operations-sorter: method default-flat-param-object: true use-enum-ref: true From f6b3ae4ee537b7606078159d0b0697e2485c9715 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 22 May 2026 01:52:56 +0000 Subject: [PATCH 22/22] chore: update image to b976ab6dd6006e94df6af125add740f1b4ed681f [skip ci] --- k8s/kustomization.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/k8s/kustomization.yaml b/k8s/kustomization.yaml index 9a236d8c..1ab2a77e 100644 --- a/k8s/kustomization.yaml +++ b/k8s/kustomization.yaml @@ -14,4 +14,4 @@ resources: - minio/service.yaml images: - name: ghcr.io/semosan/semosan_be - newTag: b8a12990175fc314f736d0b905a8dbffe5daf070 + newTag: b976ab6dd6006e94df6af125add740f1b4ed681f