diff --git a/CLAUDE.md b/CLAUDE.md index e69de29b..34efe3ca 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -0,0 +1,124 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Communication + +- 한국어로 답변한다. +- "확인해줘", "봐줘", "뭐가 문제야" 같은 표현은 분석만 요청하는 것으로 간주하고 코드를 수정하지 않는다. +- "수정해줘", "고쳐줘", "반영해줘", "만들어줘", "추가해줘", "삭제해줘" 같은 명시적 요청이 있을 때만 파일을 편집한다. +- 수정 전에 어떤 파일을 왜 건드릴지 먼저 설명하고, 명시적 승인을 기다린다. + +## Build & Test Commands + +```bash +./gradlew build # 전체 빌드 +./gradlew test # 전체 테스트 +./gradlew test --tests # 단일 클래스 테스트 +./gradlew bootRun # 애플리케이션 실행 (local 프로파일) +``` + +로컬 인프라(PostgreSQL, Redis, MinIO)가 없으면 테스트가 실패할 수 있다. 인프라 부재로 인한 실패와 코드 문제로 인한 실패를 명확히 구분해서 보고한다. + +## Architecture Overview + +### Package Structure + +```text +com.semosan.api +├── common/ # 공통 인프라 (응답, 예외, JWT, 설정, FCM, 알림 공통) +└── domain/ # 도메인별 비즈니스 로직 + ├── auth/ # JWT 로그인/로그아웃/토큰 재발급, 회원탈퇴 + ├── oauth/ # 카카오·애플 소셜 로그인 + ├── user/ # 사용자 프로필, 온보딩, 알림 설정 + ├── mountain/ # 산 정보, 코스, 좋아요, 지도 검색 (PostGIS) + ├── hiking/ # 등산 기록 조회 + ├── tracking/ # 실시간 GPS 트래킹 (WebSocket STOMP + Redis Stream) + ├── community/ # 자유게시글·기록게시글, 댓글, 좋아요 + ├── image/ # MinIO presigned URL 발급 + ├── notification/# FCM 토큰, 알림 엔티티, 이벤트 + └── review/ # 산 리뷰 +``` + +### Layer Convention + +각 도메인은 `controller → service → repository` 계층을 따른다. +Swagger 문서는 컨트롤러와 분리된 `controller/docs/ControllerDocs` 인터페이스에 작성하고, 컨트롤러가 이 인터페이스를 구현한다. + +DTO는 다음 규칙을 따른다. +- 요청: `dto/request/` 하위 또는 `dto/` 직하위에 `*Request.java` +- 응답: `dto/response/` 하위 또는 `dto/` 직하위에 `*Response.java` +- 서비스 간 전달 커맨드: `dto/command/` 하위에 `*Command.java` + +### API Response Convention + +모든 컨트롤러는 `ApiResponse`로 응답을 반환한다. + +```java +// 데이터 없는 성공 +return ApiResponse.success(SuccessStatus.XXX); + +// 데이터 포함 성공 +return ApiResponse.success(SuccessStatus.XXX, data); + +// 오류 +throw new GeneralException(ErrorStatus.XXX); +``` + +`SuccessStatus`와 `ErrorStatus` 모두 `BaseStatus` 인터페이스를 구현하는 enum이다. +새 에러 코드는 `ErrorStatus`에, 성공 코드는 `SuccessStatus`에 추가한다. + +### Authentication + +- Stateless JWT 방식. `JwtFilter`가 `Authorization: Bearer ` 헤더를 검증한다. +- WebSocket(STOMP) 연결은 HTTP 필터를 통과(`/ws/tracking/**` permitAll)하고, `StompAuthChannelInterceptor`가 STOMP CONNECT 프레임에서 JWT를 검증한다. +- 퍼블릭 엔드포인트: 스웨거, OAuth 로그인, 토큰 재발급, `/api/auth/test/login` + +### Real-time GPS Tracking Flow + +1. 클라이언트가 WebSocket(STOMP)으로 GPS 좌표를 전송 +2. `TrackingGpsPublisher`가 Redis Stream(`tracking:gps`)에 publish +3. `TrackingStreamConsumer`가 스트림을 consume → 실시간 통계(Redis Hash) 갱신 + 메모리 버퍼 적재 +4. 버퍼가 100개 이상이거나 10초 주기 스케줄러가 동작하면 `TrackingPointFlushService`가 DB에 배치 insert +5. 세션 종료 시 `TrackingSessionTerminatedEvent`로 잔여 버퍼를 final flush + +`TrackingPointFlushService`는 self-invocation AOP 우회를 위해 별도 빈으로 분리되어 있다. + +### Database & Migrations + +- PostgreSQL + PostGIS (지리 좌표 검색에 `hibernate-spatial` + JTS 사용) +- **prod 프로파일**: Flyway 활성화, `ddl-auto: validate` +- **local 프로파일**: Flyway 비활성화, `ddl-auto: update` +- 마이그레이션 스크립트: `src/main/resources/db/migration/V{N}__description.sql` + +### Infrastructure + +| 컴포넌트 | 용도 | +|---------|------| +| PostgreSQL | 메인 DB | +| Redis | JWT refresh token 블랙리스트, GPS 실시간 통계 (Hash), GPS 이벤트 스트림 (Stream) | +| MinIO | 이미지 오브젝트 스토리지 (presigned URL 방식) | +| Firebase FCM | 푸시 알림 | + +### Environment Variables + +`application.yaml`은 환경변수만 참조한다. 로컬 개발은 `application-local.yaml`에 기본값이 하드코딩되어 있다(로컬 전용). +prod에서 필요한 주요 변수: `DB_URL`, `DB_USERNAME`, `DB_PASSWORD`, `REDIS_HOST`, `REDIS_PORT`, `JWT_SECRET`, `JWT_ACCESS_TOKEN_EXPIRATION`, `JWT_REFRESH_TOKEN_EXPIRATION`, `KAKAO_CLIENT_ID`, `KAKAO_CLIENT_SECRET`, `KAKAO_REDIRECT_URI`, `KAKAO_ADMIN_KEY`, `MINIO_ENDPOINT`, `MINIO_ACCESS_KEY`, `MINIO_SECRET_KEY`, `MINIO_PUBLIC_URL`, `TRACKING_STREAM_KEY`, `TRACKING_CONSUMER_GROUP`, `FIREBASE_SERVICE_ACCOUNT_PATH`, `DISCORD_ALERT_ENABLED`, `DISCORD_WEBHOOK_URL` + +## Branch & Commit Convention + +브랜치명: +```text +feat/#이슈번호-기능명 +fix/#이슈번호-버그명 +``` + +커밋 타입: `feat`, `fix`, `refactor`, `docs`, `test`, `chore` + +## PR Rules + +- 리뷰어 2명 전원 승인 후 머지 (셀프 머지 금지) + +## Swagger + +로컬 실행 후 `http://localhost:8080/swagger-ui.html` 에서 API 문서 확인 가능. 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 cdc4c217..cede5098 100644 --- a/src/main/java/com/semosan/api/common/status/ErrorStatus.java +++ b/src/main/java/com/semosan/api/common/status/ErrorStatus.java @@ -49,22 +49,22 @@ public enum ErrorStatus implements BaseStatus { /** * User */ - USER_NOT_FOUND(HttpStatus.NOT_FOUND, "USER_404_1", "사용자를 찾을 수 없습니다."), - ONBOARDING_ALREADY_COMPLETED(HttpStatus.CONFLICT, "USER_409_1", "이미 온보딩을 완료한 사용자입니다."), PREFERRED_DIFFICULTY_REQUIRED(HttpStatus.BAD_REQUEST, "USER_400_1", "숙련자는 선호 난이도를 선택해야 합니다."), PREFERRED_DIFFICULTY_NOT_ALLOWED(HttpStatus.BAD_REQUEST, "USER_400_2", "선호 난이도는 숙련자만 선택할 수 있습니다."), PROFILE_UPDATE_FIELD_REQUIRED(HttpStatus.BAD_REQUEST, "USER_400_3", "수정할 프로필 정보가 없습니다."), - NOTIFICATION_SETTING_NOT_FOUND(HttpStatus.NOT_FOUND, "USER_404_2", "알림 설정을 찾을 수 없습니다."), INVALID_NICKNAME(HttpStatus.BAD_REQUEST, "USER_400_4", "사용할 수 없는 닉네임입니다."), - DUPLICATED_NICKNAME(HttpStatus.CONFLICT, "USER_409_2", "이미 사용 중인 닉네임입니다."), UNDER_AGE_NOT_ALLOWED(HttpStatus.BAD_REQUEST, "USER_400_5", "만 14세 미만은 가입할 수 없습니다."), INVALID_NICKNAME_FORMAT(HttpStatus.BAD_REQUEST, "USER_400_6", "닉네임 형식이 올바르지 않습니다."), NICKNAME_RESERVED(HttpStatus.BAD_REQUEST, "USER_400_7", "사용할 수 없는 사칭 표현이 포함된 닉네임입니다."), NICKNAME_BLOCKED_WORD(HttpStatus.BAD_REQUEST, "USER_400_8", "금칙어가 포함된 닉네임입니다."), EXERCISE_DETAIL_REQUIRED(HttpStatus.BAD_REQUEST, "USER_400_9", "운동 빈도와 운동 시간을 입력해야 합니다."), EXERCISE_DETAIL_NOT_ALLOWED(HttpStatus.BAD_REQUEST, "USER_400_10", "운동 안함 선택 시 운동 빈도와 운동 시간을 입력할 수 없습니다."), - ONBOARDING_NOT_FOUND(HttpStatus.NOT_FOUND, "USER_404_3", "온보딩 정보를 찾을 수 없습니다."), ONBOARDING_NOT_COMPLETED(HttpStatus.FORBIDDEN, "USER_403_1", "온보딩을 완료해야 이용할 수 있습니다."), + USER_NOT_FOUND(HttpStatus.NOT_FOUND, "USER_404_1", "사용자를 찾을 수 없습니다."), + NOTIFICATION_SETTING_NOT_FOUND(HttpStatus.NOT_FOUND, "USER_404_2", "알림 설정을 찾을 수 없습니다."), + ONBOARDING_NOT_FOUND(HttpStatus.NOT_FOUND, "USER_404_3", "온보딩 정보를 찾을 수 없습니다."), + ONBOARDING_ALREADY_COMPLETED(HttpStatus.CONFLICT, "USER_409_1", "이미 온보딩을 완료한 사용자입니다."), + DUPLICATED_NICKNAME(HttpStatus.CONFLICT, "USER_409_2", "이미 사용 중인 닉네임입니다."), /** * Notification @@ -74,17 +74,18 @@ public enum ErrorStatus implements BaseStatus { /** * Apple OAuth */ - APPLE_PUBLIC_KEY_REQUEST_FAILED(HttpStatus.BAD_GATEWAY, "APPLE_502_1", "애플 공개키 조회에 실패했습니다."), APPLE_PUBLIC_KEY_NOT_FOUND(HttpStatus.UNAUTHORIZED, "APPLE_401_1", "유효한 애플 공개키를 찾을 수 없습니다."), APPLE_IDENTITY_TOKEN_INVALID(HttpStatus.UNAUTHORIZED, "APPLE_401_2", "애플 identity token이 유효하지 않습니다."), + APPLE_PUBLIC_KEY_REQUEST_FAILED(HttpStatus.BAD_GATEWAY, "APPLE_502_1", "애플 공개키 조회에 실패했습니다."), /** * Mountain */ MOUNTAIN_NOT_FOUND(HttpStatus.NOT_FOUND, "MTN_404_1", "산을 찾을 수 없습니다."), MOUNTAIN_LIKE_ALREADY_EXISTS(HttpStatus.CONFLICT, "MTN_409_1", "이미 좋아요한 산입니다."), - MOUNTAIN_LIKE_NOT_FOUND(HttpStatus.NOT_FOUND, "MTN_404_2", "좋아요한 산이 아닙니다."), MOUNTAIN_BBOX_PARTIAL(HttpStatus.BAD_REQUEST, "MTN_400_1", "BBox 좌표는 4개(swLat, swLng, neLat, neLng) 모두 보내거나 모두 비워주세요."), + MOUNTAIN_LIKE_NOT_FOUND(HttpStatus.NOT_FOUND, "MTN_404_2", "좋아요한 산이 아닙니다."), + COURSE_NOT_FOUND(HttpStatus.NOT_FOUND, "MTN_404_3", "코스를 찾을 수 없습니다."), /** * Image @@ -102,45 +103,45 @@ public enum ErrorStatus implements BaseStatus { /** * Hiking (등산 기록) */ - HIKING_RECORD_NOT_FOUND(HttpStatus.NOT_FOUND, "HIKE_404_1", "등산 기록을 찾을 수 없습니다."), HIKING_RECORD_FORBIDDEN(HttpStatus.FORBIDDEN, "HIKE_403_1", "본인이 참여한 등산 기록만 공유할 수 있습니다."), + HIKING_RECORD_NOT_FOUND(HttpStatus.NOT_FOUND, "HIKE_404_1", "등산 기록을 찾을 수 없습니다."), /** * Tracking (트래킹 세션) */ - TRACKING_SESSION_NOT_FOUND(HttpStatus.NOT_FOUND, "TRK_404_1", "트래킹 세션을 찾을 수 없습니다."), + TRACKING_COURSE_MOUNTAIN_MISMATCH(HttpStatus.BAD_REQUEST, "TRK_400_1", "선택한 코스가 해당 산의 코스가 아닙니다."), + TRACKING_COURSE_ID_REQUIRED(HttpStatus.BAD_REQUEST, "TRK_400_2", "자유 기록이 아니면 코스 ID는 필수입니다."), TRACKING_SESSION_FORBIDDEN(HttpStatus.FORBIDDEN, "TRK_403_1", "본인의 트래킹 세션만 조작할 수 있습니다."), + TRACKING_SESSION_NOT_FOUND(HttpStatus.NOT_FOUND, "TRK_404_1", "트래킹 세션을 찾을 수 없습니다."), + TRACKING_PHOTO_NOT_FOUND(HttpStatus.NOT_FOUND, "TRK_404_2", "트래킹 사진을 찾을 수 없습니다."), 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_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", "활성 상태가 아닌 세션에는 사진을 업로드할 수 없습니다."), - TRACKING_PHOTO_NOT_FOUND(HttpStatus.NOT_FOUND, "TRK_404_2", "트래킹 사진을 찾을 수 없습니다."), - COURSE_NOT_FOUND(HttpStatus.NOT_FOUND, "MTN_404_3", "코스를 찾을 수 없습니다."), + TRACKING_COURSE_POLYLINE_REQUIRED(HttpStatus.UNPROCESSABLE_ENTITY, "TRK_422_1", "코스 경로 좌표가 등록되지 않았습니다."), /** * Post (게시글 공통) */ - POST_NOT_FOUND(HttpStatus.NOT_FOUND, "POST_404_1", "게시글을 찾을 수 없습니다."), - POST_DELETED(HttpStatus.NOT_FOUND, "POST_404_2", "삭제된 게시글입니다."), - POST_FORBIDDEN(HttpStatus.FORBIDDEN, "POST_403_1", "본인의 게시글만 처리할 수 있습니다."), POST_CONTENT_REQUIRED(HttpStatus.BAD_REQUEST, "POST_400_1", "본문은 비어있을 수 없습니다."), POST_IMAGE_INDEX_INVALID(HttpStatus.BAD_REQUEST, "POST_400_2", "대표 이미지 인덱스가 잘못되었습니다."), + POST_FORBIDDEN(HttpStatus.FORBIDDEN, "POST_403_1", "본인의 게시글만 처리할 수 있습니다."), + POST_NOT_FOUND(HttpStatus.NOT_FOUND, "POST_404_1", "게시글을 찾을 수 없습니다."), + POST_DELETED(HttpStatus.NOT_FOUND, "POST_404_2", "삭제된 게시글입니다."), /** * Comment (댓글/대댓글) */ + COMMENT_PARENT_POST_MISMATCH(HttpStatus.BAD_REQUEST, "CMT_400_1", "부모 댓글이 같은 게시글의 댓글이 아닙니다."), + COMMENT_FORBIDDEN(HttpStatus.FORBIDDEN, "CMT_403_1", "본인의 댓글만 처리할 수 있습니다."), COMMENT_NOT_FOUND(HttpStatus.NOT_FOUND, "CMT_404_1", "댓글을 찾을 수 없습니다."), COMMENT_DELETED(HttpStatus.NOT_FOUND, "CMT_404_2", "삭제된 댓글입니다."), - COMMENT_FORBIDDEN(HttpStatus.FORBIDDEN, "CMT_403_1", "본인의 댓글만 처리할 수 있습니다."), - COMMENT_PARENT_POST_MISMATCH(HttpStatus.BAD_REQUEST, "CMT_400_1", "부모 댓글이 같은 게시글의 댓글이 아닙니다."), /** * SemoFeed (세모피드) */ - SEMOFEED_NOT_FOUND(HttpStatus.NOT_FOUND, "SF_404_1", "세모피드를 찾을 수 없습니다."), - SEMOFEED_FORBIDDEN(HttpStatus.FORBIDDEN, "SF_403_1", "본인의 세모피드만 처리할 수 있습니다."); + SEMOFEED_FORBIDDEN(HttpStatus.FORBIDDEN, "SF_403_1", "본인의 세모피드만 처리할 수 있습니다."), + SEMOFEED_NOT_FOUND(HttpStatus.NOT_FOUND, "SF_404_1", "세모피드를 찾을 수 없습니다."); private final HttpStatus httpStatus; private final String code; 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 ffc1868c..5b4b4789 100644 --- a/src/main/java/com/semosan/api/common/status/SuccessStatus.java +++ b/src/main/java/com/semosan/api/common/status/SuccessStatus.java @@ -56,24 +56,25 @@ public enum SuccessStatus implements BaseStatus { * Tracking — 진입 화면 (#45) & 세션 (#18) */ TRACKING_NEAREST_MOUNTAIN_SUCCESS(HttpStatus.OK, "TRK_200_1", "현재 위치 기준 가까운 산과 코스 조회에 성공했습니다."), - TRACKING_SESSION_CREATE_SUCCESS(HttpStatus.CREATED, "TRK_201_1", "트래킹 세션이 시작되었습니다."), TRACKING_SESSION_GET_ACTIVE_SUCCESS(HttpStatus.OK, "TRK_200_2", "현재 진행 중인 트래킹 세션 조회에 성공했습니다."), TRACKING_SESSION_GET_SUCCESS(HttpStatus.OK, "TRK_200_3", "트래킹 세션 상세 조회에 성공했습니다."), TRACKING_SESSION_PAUSE_SUCCESS(HttpStatus.OK, "TRK_200_4", "트래킹 세션을 일시정지했습니다."), 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_LIVE_ACTIVITY_COURSE_SUCCESS(HttpStatus.OK, "TRK_200_8", "라이브 액티비티용 코스 정보 조회에 성공했습니다."), + TRACKING_PHOTO_LIST_SUCCESS(HttpStatus.OK, "TRK_200_9", "트래킹 사진 목록 조회에 성공했습니다."), + TRACKING_SESSION_CREATE_SUCCESS(HttpStatus.CREATED, "TRK_201_1", "트래킹 세션이 시작되었습니다."), TRACKING_PHOTO_UPLOAD_SUCCESS(HttpStatus.CREATED, "TRK_201_2", "트래킹 사진이 저장되었습니다."), - TRACKING_PHOTO_LIST_SUCCESS(HttpStatus.OK, "TRK_200_8", "트래킹 사진 목록 조회에 성공했습니다."), /** * SemoFeed */ - SEMOFEED_CREATE_SUCCESS(HttpStatus.CREATED, "SF_201_1", "세모피드가 저장되었습니다."), SEMOFEED_LIST_SUCCESS(HttpStatus.OK, "SF_200_1", "세모피드 목록 조회에 성공했습니다."), SEMOFEED_MY_LIST_SUCCESS(HttpStatus.OK, "SF_200_2", "내 세모피드 목록 조회에 성공했습니다."), SEMOFEED_TOGGLE_PUBLIC_SUCCESS(HttpStatus.OK, "SF_200_3", "세모피드 공개 상태가 변경되었습니다."), SEMOFEED_DELETE_SUCCESS(HttpStatus.OK, "SF_200_4", "세모피드가 삭제되었습니다."), + SEMOFEED_CREATE_SUCCESS(HttpStatus.CREATED, "SF_201_1", "세모피드가 저장되었습니다."), /** * Image @@ -99,11 +100,11 @@ public enum SuccessStatus implements BaseStatus { /** * Comment (댓글/대댓글) */ - COMMENT_CREATE_SUCCESS(HttpStatus.CREATED, "CMT_201_1", "댓글이 작성되었습니다."), - COMMENT_REPLY_SUCCESS(HttpStatus.CREATED, "CMT_201_2", "대댓글이 작성되었습니다."), COMMENT_LIST_SUCCESS(HttpStatus.OK, "CMT_200_1", "댓글 목록 조회에 성공했습니다."), COMMENT_REPLY_LIST_SUCCESS(HttpStatus.OK, "CMT_200_2", "대댓글 목록 조회에 성공했습니다."), COMMENT_DELETE_SUCCESS(HttpStatus.OK, "CMT_200_3", "댓글이 삭제되었습니다."), + COMMENT_CREATE_SUCCESS(HttpStatus.CREATED, "CMT_201_1", "댓글이 작성되었습니다."), + COMMENT_REPLY_SUCCESS(HttpStatus.CREATED, "CMT_201_2", "대댓글이 작성되었습니다."), /** * Post Like (좋아요) @@ -114,12 +115,12 @@ public enum SuccessStatus implements BaseStatus { /** * Free Post (자유게시판 게시글) */ - FREE_POST_CREATE_SUCCESS(HttpStatus.CREATED, "FPOST_201_1", "자유게시판 게시글이 작성되었습니다."), FREE_POST_LIST_SUCCESS(HttpStatus.OK, "FPOST_200_1", "자유게시판 게시글 목록 조회에 성공했습니다."), FREE_POST_MY_LIST_SUCCESS(HttpStatus.OK, "FPOST_200_2", "내 자유게시판 게시글 목록 조회에 성공했습니다."), FREE_POST_DETAIL_SUCCESS(HttpStatus.OK, "FPOST_200_3", "자유게시판 게시글 상세 조회에 성공했습니다."), FREE_POST_DELETE_SUCCESS(HttpStatus.OK, "FPOST_200_4", "자유게시판 게시글이 삭제되었습니다."), - FREE_POST_SEARCH_SUCCESS(HttpStatus.OK, "FPOST_200_5", "자유게시판 게시글 검색에 성공했습니다."); + FREE_POST_SEARCH_SUCCESS(HttpStatus.OK, "FPOST_200_5", "자유게시판 게시글 검색에 성공했습니다."), + FREE_POST_CREATE_SUCCESS(HttpStatus.CREATED, "FPOST_201_1", "자유게시판 게시글이 작성되었습니다."); private final HttpStatus httpStatus; private final String code; diff --git a/src/main/java/com/semosan/api/domain/mountain/entity/Course.java b/src/main/java/com/semosan/api/domain/mountain/entity/Course.java index b3af49fa..0a8983a2 100644 --- a/src/main/java/com/semosan/api/domain/mountain/entity/Course.java +++ b/src/main/java/com/semosan/api/domain/mountain/entity/Course.java @@ -4,6 +4,7 @@ import com.semosan.api.domain.mountain.enums.Difficulty; import jakarta.persistence.*; import lombok.*; +import org.locationtech.jts.geom.LineString; @Table(name = "courses") @Getter @@ -33,4 +34,7 @@ public class Course extends BaseEntity { @Column(name = "duration", nullable = false) private Integer duration; + + @Column(name = "polyline", columnDefinition = "geography(LineString, 4326)") + private LineString polyline; } 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 f372d36d..f1532ce8 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 @@ -3,6 +3,7 @@ import com.semosan.api.common.response.ApiResponse; import com.semosan.api.common.status.SuccessStatus; import com.semosan.api.domain.tracking.controller.docs.TrackingControllerDocs; +import com.semosan.api.domain.tracking.dto.response.LiveActivityCourseResponse; import com.semosan.api.domain.tracking.dto.response.NearbyMountainResponse; import com.semosan.api.domain.tracking.service.TrackingService; import lombok.RequiredArgsConstructor; @@ -10,6 +11,7 @@ import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; @@ -38,4 +40,14 @@ public ResponseEntity> getNearbyMountain( NearbyMountainResponse response = trackingService.getNearbyMountain(userId, lat, lng); return ApiResponse.success(SuccessStatus.TRACKING_NEAREST_MOUNTAIN_SUCCESS, response); } + + @GetMapping("/live-activity/courses/{courseId}") + @Override + public ResponseEntity> getLiveActivityCourse( + @AuthenticationPrincipal Long userId, + @PathVariable Long courseId + ) { + LiveActivityCourseResponse response = trackingService.getLiveActivityCourse(userId, courseId); + return ApiResponse.success(SuccessStatus.TRACKING_LIVE_ACTIVITY_COURSE_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 3b6ae663..732d473a 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 @@ -1,6 +1,7 @@ package com.semosan.api.domain.tracking.controller.docs; import com.semosan.api.common.response.ApiResponse; +import com.semosan.api.domain.tracking.dto.response.LiveActivityCourseResponse; import com.semosan.api.domain.tracking.dto.response.NearbyMountainResponse; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; @@ -12,6 +13,7 @@ import jakarta.validation.constraints.Min; 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.RequestParam; @Tag(name = "Tracking", description = "트래킹(등산 시작/진행) 관련 API") @@ -41,4 +43,32 @@ ResponseEntity> getNearbyMountain( @Parameter(description = "현재 위치 경도", required = true) @RequestParam @Min(-180) @Max(180) Double lng ); + + @Operation( + summary = "라이브 액티비티용 코스 정보 조회", + description = "코스 기반 트래킹의 Live Activity 초기화에 필요한 전체 코스 좌표 배열, " + + "전체 거리(m), 예상 소요 시간(분)을 반환합니다. 자유 기록에서는 호출하지 않습니다." + ) + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "200", + description = "라이브 액티비티용 코스 정보 조회 성공" + ), + @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 = "422", + description = "코스 경로 좌표가 등록되지 않음", + content = @Content(schema = @Schema(implementation = ApiResponse.class)) + ) + }) + ResponseEntity> getLiveActivityCourse( + @Parameter(hidden = true) + @AuthenticationPrincipal Long userId, + @Parameter(description = "코스 ID", required = true) + @PathVariable Long courseId + ); } diff --git a/src/main/java/com/semosan/api/domain/tracking/dto/response/LiveActivityCourseResponse.java b/src/main/java/com/semosan/api/domain/tracking/dto/response/LiveActivityCourseResponse.java new file mode 100644 index 00000000..7eecf21f --- /dev/null +++ b/src/main/java/com/semosan/api/domain/tracking/dto/response/LiveActivityCourseResponse.java @@ -0,0 +1,48 @@ +package com.semosan.api.domain.tracking.dto.response; + +import com.semosan.api.common.exception.GeneralException; +import com.semosan.api.common.status.ErrorStatus; +import com.semosan.api.domain.mountain.entity.Course; +import org.locationtech.jts.geom.Coordinate; +import org.locationtech.jts.geom.LineString; + +import java.util.Arrays; +import java.util.List; + +public record LiveActivityCourseResponse( + Long courseId, + List coordinates, + Double totalDistance, + Integer estimatedTime +) { + + public static LiveActivityCourseResponse from(Course course) { + return new LiveActivityCourseResponse( + course.getId(), + toCoordinates(course.getPolyline()), + course.getDistance(), + course.getDuration() + ); + } + + private static List toCoordinates(LineString polyline) { + if (polyline == null || polyline.isEmpty()) { + throw new GeneralException(ErrorStatus.TRACKING_COURSE_POLYLINE_REQUIRED); + } + return Arrays.stream(polyline.getCoordinates()) + .map(CoordinateInfo::from) + .toList(); + } + + public record CoordinateInfo( + Double latitude, + Double longitude + ) { + public static CoordinateInfo from(Coordinate coordinate) { + return new CoordinateInfo( + coordinate.y, + coordinate.x + ); + } + } +} diff --git a/src/main/java/com/semosan/api/domain/tracking/dto/response/TrackingSessionResponse.java b/src/main/java/com/semosan/api/domain/tracking/dto/response/TrackingSessionResponse.java index 5ec5d352..ced7b895 100644 --- a/src/main/java/com/semosan/api/domain/tracking/dto/response/TrackingSessionResponse.java +++ b/src/main/java/com/semosan/api/domain/tracking/dto/response/TrackingSessionResponse.java @@ -6,7 +6,6 @@ import java.time.LocalDateTime; -@JsonInclude(JsonInclude.Include.NON_NULL) public record TrackingSessionResponse( Long sessionId, Long userId, @@ -21,6 +20,7 @@ public record TrackingSessionResponse( LocalDateTime pausedAt, Integer pausedSecondsTotal, /** complete 시에만 채워짐 — 변환된 HikingRecord 의 ID. 다른 경로에선 null 직렬화에서 제외. */ + @JsonInclude(JsonInclude.Include.NON_NULL) Long hikingRecordId ) { diff --git a/src/main/java/com/semosan/api/domain/tracking/service/TrackingService.java b/src/main/java/com/semosan/api/domain/tracking/service/TrackingService.java index 67096b5a..56aa7e6d 100644 --- a/src/main/java/com/semosan/api/domain/tracking/service/TrackingService.java +++ b/src/main/java/com/semosan/api/domain/tracking/service/TrackingService.java @@ -5,6 +5,7 @@ import com.semosan.api.domain.mountain.entity.Mountain; import com.semosan.api.domain.mountain.repository.CourseRepository; import com.semosan.api.domain.mountain.repository.MountainRepository; +import com.semosan.api.domain.tracking.dto.response.LiveActivityCourseResponse; import com.semosan.api.domain.tracking.dto.response.NearbyMountainResponse; import com.semosan.api.domain.user.service.UserReader; import lombok.RequiredArgsConstructor; @@ -26,6 +27,7 @@ public class TrackingService { * 산 데이터가 비어있거나 location 이 null 인 산만 존재할 경우 MOUNTAIN_NOT_FOUND 로 응답. */ public NearbyMountainResponse getNearbyMountain(Long userId, Double lat, Double lng) { + // JWT 인증 이후에도 탈퇴/비활성 유저의 트래킹 진입을 막기 위한 도메인 검증. userReader.findActiveUserById(userId); Mountain mountain = mountainRepository.findNearestByLatLng(lat, lng) .orElseThrow(() -> new GeneralException(ErrorStatus.MOUNTAIN_NOT_FOUND)); @@ -34,4 +36,12 @@ public NearbyMountainResponse getNearbyMountain(Long userId, Double lat, Double courseRepository.findByMountainId(mountain.getId()) ); } + + public LiveActivityCourseResponse getLiveActivityCourse(Long userId, Long courseId) { + // JWT 인증 이후에도 탈퇴/비활성 유저의 Live Activity 코스 조회를 막기 위한 도메인 검증. + userReader.findActiveUserById(userId); + return courseRepository.findById(courseId) + .map(LiveActivityCourseResponse::from) + .orElseThrow(() -> new GeneralException(ErrorStatus.COURSE_NOT_FOUND)); + } } 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 513c0ec3..a811b183 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 @@ -20,7 +20,9 @@ import com.semosan.api.domain.user.service.UserReader; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.hibernate.exception.ConstraintViolationException; import org.springframework.context.ApplicationEventPublisher; +import org.springframework.dao.DataIntegrityViolationException; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -32,6 +34,8 @@ @Transactional(readOnly = true) public class TrackingSessionService { + private static final String ACTIVE_SESSION_UNIQUE_INDEX = "uq_tracking_sessions_user_active"; + private final TrackingSessionRepository trackingSessionRepository; private final MountainRepository mountainRepository; private final CourseRepository courseRepository; @@ -63,8 +67,7 @@ public TrackingSessionResponse create(Long userId, CreateTrackingSessionRequest Course course = resolveCourse(request, mountain); TrackingSession session = TrackingSession.create(user, mountain, course, request.isFreeRecording()); - TrackingSession saved = trackingSessionRepository.save(session); - photoTriggerService.initializeMilestones(saved); + TrackingSession saved = saveSession(session); return TrackingSessionResponse.from(saved); } @@ -158,4 +161,31 @@ private Course resolveCourse(CreateTrackingSessionRequest request, Mountain moun } return course; } + + private TrackingSession saveSession(TrackingSession session) { + try { + return trackingSessionRepository.save(session); + } catch (DataIntegrityViolationException e) { + if (isActiveSessionUniqueViolation(e)) { + throw new GeneralException(ErrorStatus.TRACKING_SESSION_ALREADY_IN_PROGRESS); + } + throw e; + } + } + + private boolean isActiveSessionUniqueViolation(Throwable exception) { + Throwable current = exception; + while (current != null) { + if (current instanceof ConstraintViolationException constraintViolation + && ACTIVE_SESSION_UNIQUE_INDEX.equals(constraintViolation.getConstraintName())) { + return true; + } + String message = current.getMessage(); + if (message != null && message.contains(ACTIVE_SESSION_UNIQUE_INDEX)) { + return true; + } + current = current.getCause(); + } + return false; + } } 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 4291a2c9..869afe7a 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 @@ -103,12 +103,8 @@ public static String statsKey(Long sessionId) { * 점이 한 번도 들어오지 않았으면 모든 필드 0/null. */ public Stats getStats(Long sessionId) { - Map entries = redisTemplate.opsForHash().entries(statsKey(sessionId)) - .entrySet().stream() - .collect(java.util.stream.Collectors.toMap( - e -> String.valueOf(e.getKey()), - e -> String.valueOf(e.getValue()) - )); + HashOperations hash = redisTemplate.opsForHash(); + Map entries = hash.entries(statsKey(sessionId)); return new Stats( parseDouble(entries.get(F_DISTANCE_TOTAL)), parseDouble(entries.get(F_ASCENT_TOTAL)), diff --git a/src/main/resources/db/migration/V16__add_tracking_session_active_unique_index.sql b/src/main/resources/db/migration/V16__add_tracking_session_active_unique_index.sql new file mode 100644 index 00000000..8cbe4ecf --- /dev/null +++ b/src/main/resources/db/migration/V16__add_tracking_session_active_unique_index.sql @@ -0,0 +1,24 @@ +-- 유저당 활성 트래킹 세션(IN_PROGRESS/PAUSED)은 1개만 허용한다. +-- 애플리케이션의 exists -> save 검증만으로는 동시 생성 요청을 막을 수 없어서 DB 제약으로 보강한다. +-- 기존 중복 데이터가 있으면 사용자별 최신 활성 세션 1건만 유지하고 나머지는 ABANDONED 처리한다. +WITH ranked_active_sessions AS ( + SELECT + id, + ROW_NUMBER() OVER ( + PARTITION BY user_id + ORDER BY started_at DESC, id DESC + ) AS row_number + FROM tracking_sessions + WHERE status IN ('IN_PROGRESS', 'PAUSED') +) +UPDATE tracking_sessions ts +SET status = 'ABANDONED', + ended_at = COALESCE(ts.ended_at, now()), + updated_at = now() +FROM ranked_active_sessions ras +WHERE ts.id = ras.id + AND ras.row_number > 1; + +CREATE UNIQUE INDEX IF NOT EXISTS uq_tracking_sessions_user_active + ON tracking_sessions (user_id) + WHERE status IN ('IN_PROGRESS', 'PAUSED');