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
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ public static HikingRecordSummaryResponse from(HikingRecord record) {
record.getMountain().getName(),
record.getCourse().getName(),
record.getDuration(),
record.getAltitude(),
record.getMaxAltitude(),
record.getCalories(),
record.getCliveImageUrl(),
record.getPhotoReportImageUrl()
Expand Down
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;
private Double maxAltitude;

/** 소모 칼로리 (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)
.maxAltitude(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 All @@ -15,12 +19,14 @@
import com.semosan.api.domain.user.entity.User;
import com.semosan.api.domain.user.service.UserReader;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.util.Optional;

@Slf4j
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
Expand All @@ -30,6 +36,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;
private final ApplicationEventPublisher eventPublisher;

@Transactional
Expand Down Expand Up @@ -73,15 +82,37 @@ 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();

TrackingSessionStatsService.Stats stats = statsService.getStats(sessionId);
if (stats.pointCount() == 0) {
// GPS 점이 한 건도 들어오지 않은 채 종료 — 사용자의 명시적 종료는 존중하되 통계가 비어있음을 알림.
log.warn("Completing tracking session {} with no GPS points; stats will be zero/null", 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);

eventPublisher.publishEvent(new TrackingSessionTerminatedEvent(sessionId));
return TrackingSessionResponse.from(session);

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
41 changes: 41 additions & 0 deletions src/main/resources/db/migration/V9__extend_hiking_record.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
-- =====================================================================
-- V9__extend_hiking_record.sql
-- 목적: 트래킹 흐름에서 생성될 HikingRecord 확장.
-- - 트래킹 세션 참조 (tracking_session_id, UNIQUE — 세션당 기록 1건 강제)
-- - 시간 정보 (started_at, ended_at, paused_seconds_total)
-- - GPS 통계 (distance, ascent, descent)
-- - 자유 기록 대응 (course_id NOT NULL → NULLABLE)
-- =====================================================================

-- 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(6);
ALTER TABLE hiking_records
ADD COLUMN IF NOT EXISTS ended_at timestamp(6);
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 + UNIQUE 제약 (NULL 은 표준 UNIQUE 검사에서 제외돼 수동 기록은 영향 없음)
ALTER TABLE hiking_records
ADD CONSTRAINT fk_hiking_records_tracking_session
FOREIGN KEY (tracking_session_id) REFERENCES tracking_sessions(id);

ALTER TABLE hiking_records
ADD CONSTRAINT uk_hiking_records_tracking_session
UNIQUE (tracking_session_id);