Skip to content

Commit b61348d

Browse files
authored
Merge pull request #54 from SEMOSAN/feat/#20-tracking-record-finalize
[Feat] #20 트래킹 세션 종료 시 하이킹 기록 생성
2 parents c227637 + 2dd76e2 commit b61348d

6 files changed

Lines changed: 199 additions & 26 deletions

File tree

src/main/java/com/semosan/api/domain/hiking/dto/HikingRecordSummaryResponse.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ public static HikingRecordSummaryResponse from(HikingRecord record) {
1818
record.getMountain().getName(),
1919
record.getCourse().getName(),
2020
record.getDuration(),
21-
record.getAltitude(),
21+
record.getMaxAltitude(),
2222
record.getCalories(),
2323
record.getCliveImageUrl(),
2424
record.getPhotoReportImageUrl()

src/main/java/com/semosan/api/domain/hiking/entity/HikingRecord.java

Lines changed: 75 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,12 @@
33
import com.semosan.api.common.base.BaseEntity;
44
import com.semosan.api.domain.mountain.entity.Course;
55
import com.semosan.api.domain.mountain.entity.Mountain;
6+
import com.semosan.api.domain.tracking.entity.TrackingSession;
67
import jakarta.persistence.*;
78
import lombok.*;
89

10+
import java.time.LocalDateTime;
11+
912
@Table(name = "hiking_records")
1013
@Getter
1114
@Entity
@@ -22,46 +25,98 @@ public class HikingRecord extends BaseEntity {
2225
@JoinColumn(name = "mountain_id", nullable = false)
2326
private Mountain mountain;
2427

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

29-
/** 실제 소요 시간 (초) */
33+
/**
34+
* 트래킹 흐름으로 생성된 기록은 원본 세션을 참조.
35+
* 외부에서 수동 생성된 기록은 null 가능.
36+
* tracking_points 조회용: HikingRecord → trackingSession.id → tracking_points
37+
*/
38+
@ManyToOne(fetch = FetchType.LAZY)
39+
@JoinColumn(name = "tracking_session_id")
40+
private TrackingSession trackingSession;
41+
42+
/** 실제 소요 시간 (초). 일시정지 시간 제외. */
3043
@Column(name = "duration", nullable = false)
3144
private Integer duration;
3245

33-
/** 등산 중 도달한 최고 고도 (m) */
46+
/** 등산 중 도달한 최고 고도 (m). */
3447
@Column(name = "max_altitude", nullable = false)
35-
private Double altitude;
48+
private Double maxAltitude;
3649

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

57+
/** 트래킹 측정 거리 (m). 수동 기록은 null 가능. */
58+
@Column(name = "distance")
59+
private Double distance;
60+
61+
/** 누적 상승 고도 (m). */
62+
@Column(name = "ascent")
63+
private Double ascent;
64+
65+
/** 누적 하강 고도 (m). */
66+
@Column(name = "descent")
67+
private Double descent;
68+
69+
/** 일시정지 누적 시간 (초). duration 에선 이미 제외된 값이고, 본 컬럼은 감사용. */
70+
@Column(name = "paused_seconds_total", nullable = false)
71+
private Integer pausedSecondsTotal;
72+
73+
@Column(name = "started_at")
74+
private LocalDateTime startedAt;
75+
76+
@Column(name = "ended_at")
77+
private LocalDateTime endedAt;
78+
4179
@Column(name = "clive_image_url", length = 500)
4280
private String cliveImageUrl;
4381

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

47-
// 등산 기록을 생성합니다.
48-
public static HikingRecord create(
49-
Mountain mountain,
50-
Course course,
51-
Integer duration,
52-
Double altitude,
53-
Integer calories,
54-
String cliveImageUrl,
55-
String photoReportImageUrl
85+
/**
86+
* 트래킹 세션 종료 시점의 통계를 영구 기록으로 변환한다.
87+
* 이미지(clive/photoReport) URL 은 추후 #46 사진 흐름에서 채워진다.
88+
*/
89+
public static HikingRecord fromTrackingSession(
90+
TrackingSession session,
91+
Double distance,
92+
Double maxAltitude,
93+
Double ascent,
94+
Double descent
5695
) {
96+
int duration = computeDurationSeconds(session);
5797
return HikingRecord.builder()
58-
.mountain(mountain)
59-
.course(course)
98+
.mountain(session.getMountain())
99+
.course(session.getCourse())
100+
.trackingSession(session)
60101
.duration(duration)
61-
.altitude(altitude)
62-
.calories(calories)
63-
.cliveImageUrl(cliveImageUrl)
64-
.photoReportImageUrl(photoReportImageUrl)
102+
.maxAltitude(maxAltitude == null ? 0.0 : maxAltitude)
103+
.calories(0) // TODO: 칼로리 산정 공식 도입 시 교체
104+
.distance(distance)
105+
.ascent(ascent)
106+
.descent(descent)
107+
.pausedSecondsTotal(session.getPausedSecondsTotal() == null ? 0 : session.getPausedSecondsTotal())
108+
.startedAt(session.getStartedAt())
109+
.endedAt(session.getEndedAt())
65110
.build();
66111
}
112+
113+
private static int computeDurationSeconds(TrackingSession session) {
114+
if (session.getStartedAt() == null || session.getEndedAt() == null) {
115+
return 0;
116+
}
117+
long totalSeconds = java.time.Duration.between(session.getStartedAt(), session.getEndedAt()).toSeconds();
118+
int paused = session.getPausedSecondsTotal() == null ? 0 : session.getPausedSecondsTotal();
119+
long net = totalSeconds - paused;
120+
return (int) Math.max(net, 0);
121+
}
67122
}

src/main/java/com/semosan/api/domain/tracking/dto/response/TrackingSessionResponse.java

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
package com.semosan.api.domain.tracking.dto.response;
22

3+
import com.fasterxml.jackson.annotation.JsonInclude;
34
import com.semosan.api.domain.tracking.entity.TrackingSession;
45
import com.semosan.api.domain.tracking.enums.TrackingSessionStatus;
56

67
import java.time.LocalDateTime;
78

9+
@JsonInclude(JsonInclude.Include.NON_NULL)
810
public record TrackingSessionResponse(
911
Long sessionId,
1012
Long userId,
@@ -17,10 +19,16 @@ public record TrackingSessionResponse(
1719
LocalDateTime startedAt,
1820
LocalDateTime endedAt,
1921
LocalDateTime pausedAt,
20-
Integer pausedSecondsTotal
22+
Integer pausedSecondsTotal,
23+
/** complete 시에만 채워짐 — 변환된 HikingRecord 의 ID. 다른 경로에선 null 직렬화에서 제외. */
24+
Long hikingRecordId
2125
) {
2226

2327
public static TrackingSessionResponse from(TrackingSession session) {
28+
return from(session, null);
29+
}
30+
31+
public static TrackingSessionResponse from(TrackingSession session, Long hikingRecordId) {
2432
return new TrackingSessionResponse(
2533
session.getId(),
2634
session.getUser().getId(),
@@ -33,7 +41,8 @@ public static TrackingSessionResponse from(TrackingSession session) {
3341
session.getStartedAt(),
3442
session.getEndedAt(),
3543
session.getPausedAt(),
36-
session.getPausedSecondsTotal()
44+
session.getPausedSecondsTotal(),
45+
hikingRecordId
3746
);
3847
}
3948
}

src/main/java/com/semosan/api/domain/tracking/service/TrackingSessionService.java

Lines changed: 34 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,10 @@
22

33
import com.semosan.api.common.exception.GeneralException;
44
import com.semosan.api.common.status.ErrorStatus;
5+
import com.semosan.api.domain.hiking.entity.HikingMember;
6+
import com.semosan.api.domain.hiking.entity.HikingRecord;
7+
import com.semosan.api.domain.hiking.repository.HikingMemberRepository;
8+
import com.semosan.api.domain.hiking.repository.HikingRecordRepository;
59
import com.semosan.api.domain.mountain.entity.Course;
610
import com.semosan.api.domain.mountain.entity.Mountain;
711
import com.semosan.api.domain.mountain.repository.CourseRepository;
@@ -15,12 +19,14 @@
1519
import com.semosan.api.domain.user.entity.User;
1620
import com.semosan.api.domain.user.service.UserReader;
1721
import lombok.RequiredArgsConstructor;
22+
import lombok.extern.slf4j.Slf4j;
1823
import org.springframework.context.ApplicationEventPublisher;
1924
import org.springframework.stereotype.Service;
2025
import org.springframework.transaction.annotation.Transactional;
2126

2227
import java.util.Optional;
2328

29+
@Slf4j
2430
@Service
2531
@RequiredArgsConstructor
2632
@Transactional(readOnly = true)
@@ -30,6 +36,9 @@ public class TrackingSessionService {
3036
private final MountainRepository mountainRepository;
3137
private final CourseRepository courseRepository;
3238
private final UserReader userReader;
39+
private final TrackingSessionStatsService statsService;
40+
private final HikingRecordRepository hikingRecordRepository;
41+
private final HikingMemberRepository hikingMemberRepository;
3342
private final ApplicationEventPublisher eventPublisher;
3443

3544
@Transactional
@@ -73,15 +82,37 @@ public TrackingSessionResponse resume(Long userId, Long sessionId) {
7382
}
7483

7584
/**
76-
* 정상 종료. 본 메서드는 상태/시각만 마감하고, 실제 HikingRecord 변환은 #20 에서 추가될 예정.
77-
* TODO(#20): 종료 시 Redis Stream 의 GPS 점들을 모아 통계 계산 + HikingRecord/HikingMember 생성.
85+
* 정상 종료. 세션 상태를 COMPLETED 로 마감하고, Redis Hash 의 실시간 통계를 스냅샷 떠
86+
* HikingRecord + HikingMember 로 영구 변환한다.
87+
* - 통계는 GPS Consumer 가 메시지 수신 즉시 갱신해두므로 종료 시점의 스냅샷이 최신값.
88+
* - 동행자 기능 미구현이므로 본인 1명만 HikingMember 로 등록.
89+
* - cliveImageUrl / photoReportImageUrl 은 #46 사진 흐름에서 채워질 예정 (현재 null).
7890
*/
7991
@Transactional
8092
public TrackingSessionResponse complete(Long userId, Long sessionId) {
8193
TrackingSession session = findOwnedSession(userId, sessionId);
8294
session.complete();
95+
96+
TrackingSessionStatsService.Stats stats = statsService.getStats(sessionId);
97+
if (stats.pointCount() == 0) {
98+
// GPS 점이 한 건도 들어오지 않은 채 종료 — 사용자의 명시적 종료는 존중하되 통계가 비어있음을 알림.
99+
log.warn("Completing tracking session {} with no GPS points; stats will be zero/null", sessionId);
100+
}
101+
HikingRecord record = HikingRecord.fromTrackingSession(
102+
session,
103+
stats.distanceMeters(),
104+
stats.maxAltitudeMeters(),
105+
stats.ascentMeters(),
106+
stats.descentMeters()
107+
);
108+
HikingRecord savedRecord = hikingRecordRepository.save(record);
109+
110+
HikingMember member = HikingMember.create(savedRecord, session.getUser());
111+
hikingMemberRepository.save(member);
112+
83113
eventPublisher.publishEvent(new TrackingSessionTerminatedEvent(sessionId));
84-
return TrackingSessionResponse.from(session);
114+
115+
return TrackingSessionResponse.from(session, savedRecord.getId());
85116
}
86117

87118
@Transactional

src/main/java/com/semosan/api/domain/tracking/service/TrackingSessionStatsService.java

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ public class TrackingSessionStatsService {
3838
private static final String F_DISTANCE_TOTAL = "distance_total";
3939
private static final String F_ASCENT_TOTAL = "ascent_total";
4040
private static final String F_DESCENT_TOTAL = "descent_total";
41+
private static final String F_MAX_ALTITUDE = "max_altitude";
4142
private static final String F_POINT_COUNT = "point_count";
4243

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

5557
Double prevLat = parseNullableDouble(prev.get(F_LAST_LAT));
@@ -66,6 +68,9 @@ public void recordPoint(Long sessionId, double lat, double lng, Double altitude,
6668
descentTotal += -delta;
6769
}
6870
}
71+
if (altitude != null && (maxAltitude == null || altitude > maxAltitude)) {
72+
maxAltitude = altitude;
73+
}
6974
pointCount += 1;
7075

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

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

99+
/**
100+
* 세션 종료 시 통계 스냅샷 조회 — HikingRecord 생성에 사용된다.
101+
* 점이 한 번도 들어오지 않았으면 모든 필드 0/null.
102+
*/
103+
public Stats getStats(Long sessionId) {
104+
Map<String, String> entries = redisTemplate.opsForHash().entries(statsKey(sessionId))
105+
.entrySet().stream()
106+
.collect(java.util.stream.Collectors.toMap(
107+
e -> String.valueOf(e.getKey()),
108+
e -> String.valueOf(e.getValue())
109+
));
110+
return new Stats(
111+
parseDouble(entries.get(F_DISTANCE_TOTAL)),
112+
parseDouble(entries.get(F_ASCENT_TOTAL)),
113+
parseDouble(entries.get(F_DESCENT_TOTAL)),
114+
parseNullableDouble(entries.get(F_MAX_ALTITUDE)),
115+
parseLong(entries.get(F_POINT_COUNT))
116+
);
117+
}
118+
119+
public record Stats(
120+
double distanceMeters,
121+
double ascentMeters,
122+
double descentMeters,
123+
Double maxAltitudeMeters,
124+
long pointCount
125+
) {
126+
}
127+
91128
/** Haversine 거리(m). */
92129
static double haversineMeters(double lat1, double lng1, double lat2, double lng2) {
93130
double dLat = Math.toRadians(lat2 - lat1);
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
-- =====================================================================
2+
-- V9__extend_hiking_record.sql
3+
-- 목적: 트래킹 흐름에서 생성될 HikingRecord 확장.
4+
-- - 트래킹 세션 참조 (tracking_session_id, UNIQUE — 세션당 기록 1건 강제)
5+
-- - 시간 정보 (started_at, ended_at, paused_seconds_total)
6+
-- - GPS 통계 (distance, ascent, descent)
7+
-- - 자유 기록 대응 (course_id NOT NULL → NULLABLE)
8+
-- =====================================================================
9+
10+
-- 1) course_id 를 nullable 로 (자유 기록 대응)
11+
ALTER TABLE hiking_records
12+
ALTER COLUMN course_id DROP NOT NULL;
13+
14+
-- 2) 트래킹 세션 참조
15+
ALTER TABLE hiking_records
16+
ADD COLUMN IF NOT EXISTS tracking_session_id bigint;
17+
18+
-- 3) 시간 정보
19+
ALTER TABLE hiking_records
20+
ADD COLUMN IF NOT EXISTS started_at timestamp(6);
21+
ALTER TABLE hiking_records
22+
ADD COLUMN IF NOT EXISTS ended_at timestamp(6);
23+
ALTER TABLE hiking_records
24+
ADD COLUMN IF NOT EXISTS paused_seconds_total integer not null default 0;
25+
26+
-- 4) GPS 통계
27+
ALTER TABLE hiking_records
28+
ADD COLUMN IF NOT EXISTS distance double precision;
29+
ALTER TABLE hiking_records
30+
ADD COLUMN IF NOT EXISTS ascent double precision;
31+
ALTER TABLE hiking_records
32+
ADD COLUMN IF NOT EXISTS descent double precision;
33+
34+
-- 5) FK + UNIQUE 제약 (NULL 은 표준 UNIQUE 검사에서 제외돼 수동 기록은 영향 없음)
35+
ALTER TABLE hiking_records
36+
ADD CONSTRAINT fk_hiking_records_tracking_session
37+
FOREIGN KEY (tracking_session_id) REFERENCES tracking_sessions(id);
38+
39+
ALTER TABLE hiking_records
40+
ADD CONSTRAINT uk_hiking_records_tracking_session
41+
UNIQUE (tracking_session_id);

0 commit comments

Comments
 (0)