Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,12 @@
import com.semosan.api.common.base.BaseEntity;
import com.semosan.api.domain.mountain.entity.Course;
import com.semosan.api.domain.mountain.entity.Mountain;
import com.semosan.api.domain.tracking.entity.TrackingSession;
import jakarta.persistence.*;
import lombok.*;

import java.time.LocalDateTime;

@Table(name = "hiking_records")
@Getter
@Entity
Expand All @@ -22,46 +25,98 @@ public class HikingRecord extends BaseEntity {
@JoinColumn(name = "mountain_id", nullable = false)
private Mountain mountain;

/** 자유 기록 또는 매뉴얼 입력 시 null. */
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "course_id", nullable = false)
@JoinColumn(name = "course_id")
private Course course;

/** 실제 소요 시간 (초) */
/**
* 트래킹 흐름으로 생성된 기록은 원본 세션을 참조.
* 외부에서 수동 생성된 기록은 null 가능.
* tracking_points 조회용: HikingRecord → trackingSession.id → tracking_points
*/
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "tracking_session_id")
private TrackingSession trackingSession;

/** 실제 소요 시간 (초). 일시정지 시간 제외. */
@Column(name = "duration", nullable = false)
private Integer duration;

/** 등산 중 도달한 최고 고도 (m) */
/** 등산 중 도달한 최고 고도 (m). */
@Column(name = "max_altitude", nullable = false)
private Double altitude;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

이거 columne name이랑 entity 필드 명이랑 불일치

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

수정완료!


/** 소모 칼로리 (kcal) */
/**
* 소모 칼로리 (kcal).
* TODO: 실제 추정 공식(distance/ascent/사용자 체중 등 기반)이 정해지면 산정 로직 추가.
*/
@Column(name = "calories", nullable = false)
private Integer calories;

/** 트래킹 측정 거리 (m). 수동 기록은 null 가능. */
@Column(name = "distance")
private Double distance;

/** 누적 상승 고도 (m). */
@Column(name = "ascent")
private Double ascent;

/** 누적 하강 고도 (m). */
@Column(name = "descent")
private Double descent;

/** 일시정지 누적 시간 (초). duration 에선 이미 제외된 값이고, 본 컬럼은 감사용. */
@Column(name = "paused_seconds_total", nullable = false)
private Integer pausedSecondsTotal;

@Column(name = "started_at")
private LocalDateTime startedAt;

@Column(name = "ended_at")
private LocalDateTime endedAt;

@Column(name = "clive_image_url", length = 500)
private String cliveImageUrl;

@Column(name = "photo_report_image_url", length = 500)
private String photoReportImageUrl;

// 등산 기록을 생성합니다.
public static HikingRecord create(
Mountain mountain,
Course course,
Integer duration,
Double altitude,
Integer calories,
String cliveImageUrl,
String photoReportImageUrl
/**
* 트래킹 세션 종료 시점의 통계를 영구 기록으로 변환한다.
* 이미지(clive/photoReport) URL 은 추후 #46 사진 흐름에서 채워진다.
*/
public static HikingRecord fromTrackingSession(
TrackingSession session,
Double distance,
Double maxAltitude,
Double ascent,
Double descent
) {
int duration = computeDurationSeconds(session);
return HikingRecord.builder()
.mountain(mountain)
.course(course)
.mountain(session.getMountain())
.course(session.getCourse())
.trackingSession(session)
.duration(duration)
.altitude(altitude)
.calories(calories)
.cliveImageUrl(cliveImageUrl)
.photoReportImageUrl(photoReportImageUrl)
.altitude(maxAltitude == null ? 0.0 : maxAltitude)
.calories(0) // TODO: 칼로리 산정 공식 도입 시 교체
.distance(distance)
.ascent(ascent)
.descent(descent)
.pausedSecondsTotal(session.getPausedSecondsTotal() == null ? 0 : session.getPausedSecondsTotal())
.startedAt(session.getStartedAt())
.endedAt(session.getEndedAt())
.build();
}

private static int computeDurationSeconds(TrackingSession session) {
if (session.getStartedAt() == null || session.getEndedAt() == null) {
return 0;
}
long totalSeconds = java.time.Duration.between(session.getStartedAt(), session.getEndedAt()).toSeconds();
int paused = session.getPausedSecondsTotal() == null ? 0 : session.getPausedSecondsTotal();
long net = totalSeconds - paused;
return (int) Math.max(net, 0);
}
}
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
package com.semosan.api.domain.tracking.dto.response;

import com.fasterxml.jackson.annotation.JsonInclude;
import com.semosan.api.domain.tracking.entity.TrackingSession;
import com.semosan.api.domain.tracking.enums.TrackingSessionStatus;

import java.time.LocalDateTime;

@JsonInclude(JsonInclude.Include.NON_NULL)
public record TrackingSessionResponse(
Long sessionId,
Long userId,
Expand All @@ -17,10 +19,16 @@ public record TrackingSessionResponse(
LocalDateTime startedAt,
LocalDateTime endedAt,
LocalDateTime pausedAt,
Integer pausedSecondsTotal
Integer pausedSecondsTotal,
/** complete 시에만 채워짐 — 변환된 HikingRecord 의 ID. 다른 경로에선 null 직렬화에서 제외. */
Long hikingRecordId
) {

public static TrackingSessionResponse from(TrackingSession session) {
return from(session, null);
}

public static TrackingSessionResponse from(TrackingSession session, Long hikingRecordId) {
return new TrackingSessionResponse(
session.getId(),
session.getUser().getId(),
Expand All @@ -33,7 +41,8 @@ public static TrackingSessionResponse from(TrackingSession session) {
session.getStartedAt(),
session.getEndedAt(),
session.getPausedAt(),
session.getPausedSecondsTotal()
session.getPausedSecondsTotal(),
hikingRecordId
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@

import com.semosan.api.common.exception.GeneralException;
import com.semosan.api.common.status.ErrorStatus;
import com.semosan.api.domain.hiking.entity.HikingMember;
import com.semosan.api.domain.hiking.entity.HikingRecord;
import com.semosan.api.domain.hiking.repository.HikingMemberRepository;
import com.semosan.api.domain.hiking.repository.HikingRecordRepository;
import com.semosan.api.domain.mountain.entity.Course;
import com.semosan.api.domain.mountain.entity.Mountain;
import com.semosan.api.domain.mountain.repository.CourseRepository;
Expand Down Expand Up @@ -33,6 +37,9 @@ public class TrackingSessionService {
private final MountainRepository mountainRepository;
private final CourseRepository courseRepository;
private final UserReader userReader;
private final TrackingSessionStatsService statsService;
private final HikingRecordRepository hikingRecordRepository;
private final HikingMemberRepository hikingMemberRepository;

@Transactional
public TrackingSessionResponse create(Long userId, CreateTrackingSessionRequest request) {
Expand Down Expand Up @@ -75,14 +82,31 @@ public TrackingSessionResponse resume(Long userId, Long sessionId) {
}

/**
* 정상 종료. 본 메서드는 상태/시각만 마감하고, 실제 HikingRecord 변환은 #20 에서 추가될 예정.
* TODO(#20): 종료 시 Redis Stream 의 GPS 점들을 모아 통계 계산 + HikingRecord/HikingMember 생성.
* 정상 종료. 세션 상태를 COMPLETED 로 마감하고, Redis Hash 의 실시간 통계를 스냅샷 떠
* HikingRecord + HikingMember 로 영구 변환한다.
* - 통계는 GPS Consumer 가 메시지 수신 즉시 갱신해두므로 종료 시점의 스냅샷이 최신값.
* - 동행자 기능 미구현이므로 본인 1명만 HikingMember 로 등록.
* - cliveImageUrl / photoReportImageUrl 은 #46 사진 흐름에서 채워질 예정 (현재 null).
*/
@Transactional
public TrackingSessionResponse complete(Long userId, Long sessionId) {
TrackingSession session = findOwnedSession(userId, sessionId);
session.complete();
return TrackingSessionResponse.from(session);

TrackingSessionStatsService.Stats stats = statsService.getStats(sessionId);
HikingRecord record = HikingRecord.fromTrackingSession(

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

stats의 pointCount가 0이면 통계 데이터 없음으로 간주하고 경고 처리나 예외 처리하면 좋을 듯!

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

좋습니다 일단 예외는 안넣고 로그만 넣었습니다

session,
stats.distanceMeters(),
stats.maxAltitudeMeters(),
stats.ascentMeters(),
stats.descentMeters()
);
HikingRecord savedRecord = hikingRecordRepository.save(record);

HikingMember member = HikingMember.create(savedRecord, session.getUser());
hikingMemberRepository.save(member);

return TrackingSessionResponse.from(session, savedRecord.getId());
}

@Transactional
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ public class TrackingSessionStatsService {
private static final String F_DISTANCE_TOTAL = "distance_total";
private static final String F_ASCENT_TOTAL = "ascent_total";
private static final String F_DESCENT_TOTAL = "descent_total";
private static final String F_MAX_ALTITUDE = "max_altitude";
private static final String F_POINT_COUNT = "point_count";

private final StringRedisTemplate redisTemplate;
Expand All @@ -50,6 +51,7 @@ public void recordPoint(Long sessionId, double lat, double lng, Double altitude,
double distanceTotal = parseDouble(prev.get(F_DISTANCE_TOTAL));
double ascentTotal = parseDouble(prev.get(F_ASCENT_TOTAL));
double descentTotal = parseDouble(prev.get(F_DESCENT_TOTAL));
Double maxAltitude = parseNullableDouble(prev.get(F_MAX_ALTITUDE));
long pointCount = parseLong(prev.get(F_POINT_COUNT));

Double prevLat = parseNullableDouble(prev.get(F_LAST_LAT));
Expand All @@ -66,6 +68,9 @@ public void recordPoint(Long sessionId, double lat, double lng, Double altitude,
descentTotal += -delta;
}
}
if (altitude != null && (maxAltitude == null || altitude > maxAltitude)) {
maxAltitude = altitude;
}
pointCount += 1;

Map<String, String> next = new HashMap<>();
Expand All @@ -78,6 +83,9 @@ public void recordPoint(Long sessionId, double lat, double lng, Double altitude,
next.put(F_DISTANCE_TOTAL, String.valueOf(distanceTotal));
next.put(F_ASCENT_TOTAL, String.valueOf(ascentTotal));
next.put(F_DESCENT_TOTAL, String.valueOf(descentTotal));
if (maxAltitude != null) {
next.put(F_MAX_ALTITUDE, String.valueOf(maxAltitude));
}
next.put(F_POINT_COUNT, String.valueOf(pointCount));

hash.putAll(key, next);
Expand All @@ -88,6 +96,35 @@ public static String statsKey(Long sessionId) {
return KEY_PREFIX + sessionId + STATS_SUFFIX;
}

/**
* 세션 종료 시 통계 스냅샷 조회 — HikingRecord 생성에 사용된다.
* 점이 한 번도 들어오지 않았으면 모든 필드 0/null.
*/
public Stats getStats(Long sessionId) {
Map<String, String> entries = redisTemplate.opsForHash().entries(statsKey(sessionId))
.entrySet().stream()
.collect(java.util.stream.Collectors.toMap(
e -> String.valueOf(e.getKey()),
e -> String.valueOf(e.getValue())
));
return new Stats(
parseDouble(entries.get(F_DISTANCE_TOTAL)),
parseDouble(entries.get(F_ASCENT_TOTAL)),
parseDouble(entries.get(F_DESCENT_TOTAL)),
parseNullableDouble(entries.get(F_MAX_ALTITUDE)),
parseLong(entries.get(F_POINT_COUNT))
);
}

public record Stats(
double distanceMeters,
double ascentMeters,
double descentMeters,
Double maxAltitudeMeters,
long pointCount
) {
}

/** Haversine 거리(m). */
static double haversineMeters(double lat1, double lng1, double lat2, double lng2) {
double dLat = Math.toRadians(lat2 - lat1);
Expand Down
53 changes: 53 additions & 0 deletions src/main/resources/db/migration/002_hiking_record_extension.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
-- =====================================================================

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

파일명도 바꿔야 할듯

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

수정 완료

-- 002_hiking_record_extension.sql
-- 목적: 트래킹 흐름에서 생성될 HikingRecord 가 가져야 할 필드들 추가.
-- - 트래킹 세션 참조 (tracking_session_id)
-- - 시간 정보 (started_at, ended_at, paused_seconds_total)
-- - GPS 통계 (distance, ascent, descent)
-- - 자유 기록 대응 (course_id NOT NULL → NULLABLE)
-- 적용 시점: PR #20 머지 직후. 1회성.
-- idempotent: ADD COLUMN IF NOT EXISTS / DROP NOT NULL 사용.
-- =====================================================================

-- 1) course_id 를 nullable 로 (자유 기록 대응)
ALTER TABLE hiking_records
ALTER COLUMN course_id DROP NOT NULL;

-- 2) 트래킹 세션 참조
ALTER TABLE hiking_records
ADD COLUMN IF NOT EXISTS tracking_session_id BIGINT;

-- 3) 시간 정보
ALTER TABLE hiking_records
ADD COLUMN IF NOT EXISTS started_at TIMESTAMP;
ALTER TABLE hiking_records
ADD COLUMN IF NOT EXISTS ended_at TIMESTAMP;
ALTER TABLE hiking_records
ADD COLUMN IF NOT EXISTS paused_seconds_total INTEGER NOT NULL DEFAULT 0;

-- 4) GPS 통계
ALTER TABLE hiking_records
ADD COLUMN IF NOT EXISTS distance DOUBLE PRECISION;
ALTER TABLE hiking_records
ADD COLUMN IF NOT EXISTS ascent DOUBLE PRECISION;
ALTER TABLE hiking_records
ADD COLUMN IF NOT EXISTS descent DOUBLE PRECISION;

-- 5) FK 제약 (tracking_sessions 테이블이 먼저 존재해야 함 — 본 마이그레이션 이전에 tracking_sessions DDL 적용 필요)
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1
FROM information_schema.table_constraints
WHERE table_name = 'hiking_records'
AND constraint_name = 'fk_hiking_records_tracking_session'
) THEN
ALTER TABLE hiking_records
ADD CONSTRAINT fk_hiking_records_tracking_session
FOREIGN KEY (tracking_session_id) REFERENCES tracking_sessions(id);
END IF;
END$$;

-- 6) 조회 인덱스 (HikingRecord 로 트래킹 세션 역방향 찾을 일은 거의 없으나, 디버그/분석용)
CREATE INDEX IF NOT EXISTS idx_hiking_records_tracking_session
ON hiking_records (tracking_session_id);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Enforce one-record-per-session at DB level.

tracking_session_id currently has FK + index only, so duplicate hiking_records per session are still possible under retries/concurrency. Add a unique constraint (or unique index) on tracking_session_id to protect data integrity.

Suggested migration adjustment
 DO $$
 BEGIN
     IF NOT EXISTS (
         SELECT 1
         FROM information_schema.table_constraints
         WHERE table_name = 'hiking_records'
           AND constraint_name = 'fk_hiking_records_tracking_session'
     ) THEN
         ALTER TABLE hiking_records
             ADD CONSTRAINT fk_hiking_records_tracking_session
             FOREIGN KEY (tracking_session_id) REFERENCES tracking_sessions(id);
     END IF;
 END$$;

-CREATE INDEX IF NOT EXISTS idx_hiking_records_tracking_session
-    ON hiking_records (tracking_session_id);
+CREATE UNIQUE INDEX IF NOT EXISTS uq_hiking_records_tracking_session
+    ON hiking_records (tracking_session_id)
+    WHERE tracking_session_id IS NOT NULL;
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
ALTER TABLE hiking_records
ADD CONSTRAINT fk_hiking_records_tracking_session
FOREIGN KEY (tracking_session_id) REFERENCES tracking_sessions(id);
END IF;
END$$;
-- 6) 조회 인덱스 (HikingRecord 로 트래킹 세션 역방향 찾을 일은 거의 없으나, 디버그/분석용)
CREATE INDEX IF NOT EXISTS idx_hiking_records_tracking_session
ON hiking_records (tracking_session_id);
ALTER TABLE hiking_records
ADD CONSTRAINT fk_hiking_records_tracking_session
FOREIGN KEY (tracking_session_id) REFERENCES tracking_sessions(id);
END IF;
END$$;
-- 6) 조회 인덱스 (HikingRecord 로 트래킹 세션 역방향 찾을 일은 거의 없으나, 디버그/분석용)
CREATE UNIQUE INDEX IF NOT EXISTS uq_hiking_records_tracking_session
ON hiking_records (tracking_session_id)
WHERE tracking_session_id IS NOT NULL;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/main/resources/db/migration/002_hiking_record_extension.sql` around lines
45 - 53, Add a DB-level uniqueness guard so each tracking_session_id maps to at
most one hiking_records row: in the migration that alters table hiking_records
(currently adding fk_hiking_records_tracking_session and creating
idx_hiking_records_tracking_session), also create a UNIQUE constraint or UNIQUE
INDEX on hiking_records(tracking_session_id) (e.g. name it
uq_hiking_records_tracking_session or
unique_idx_hiking_records_tracking_session) using the same IF NOT EXISTS pattern
so retries/concurrent runs are safe; ensure the UNIQUE creation follows/avoids
conflict with the existing fk_hiking_records_tracking_session and
idx_hiking_records_tracking_session.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

여기 unique 제약 필요할 듯!

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

추가완료