Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
124 changes: 124 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -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 <fully.qualified.TestClass> # 단일 클래스 테스트
./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/<Name>ControllerDocs` 인터페이스에 작성하고, 컨트롤러가 이 인터페이스를 구현한다.

DTO는 다음 규칙을 따른다.
- 요청: `dto/request/` 하위 또는 `dto/` 직하위에 `*Request.java`
- 응답: `dto/response/` 하위 또는 `dto/` 직하위에 `*Response.java`
- 서비스 간 전달 커맨드: `dto/command/` 하위에 `*Command.java`

### API Response Convention

모든 컨트롤러는 `ApiResponse<T>`로 응답을 반환한다.

```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 <token>` 헤더를 검증한다.
- 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 문서 확인 가능.
41 changes: 21 additions & 20 deletions src/main/java/com/semosan/api/common/status/ErrorStatus.java
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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;
Expand Down
15 changes: 8 additions & 7 deletions src/main/java/com/semosan/api/common/status/SuccessStatus.java
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 (좋아요)
Expand All @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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;
}
Loading