Skip to content

Commit 1b1d692

Browse files
committed
feat: 트래킹 세션 종료 시 HikingRecord 자동 생성
1 parent c8c3e8f commit 1b1d692

3 files changed

Lines changed: 75 additions & 5 deletions

File tree

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: 27 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;
@@ -33,6 +37,9 @@ public class TrackingSessionService {
3337
private final MountainRepository mountainRepository;
3438
private final CourseRepository courseRepository;
3539
private final UserReader userReader;
40+
private final TrackingSessionStatsService statsService;
41+
private final HikingRecordRepository hikingRecordRepository;
42+
private final HikingMemberRepository hikingMemberRepository;
3643

3744
@Transactional
3845
public TrackingSessionResponse create(Long userId, CreateTrackingSessionRequest request) {
@@ -75,14 +82,31 @@ public TrackingSessionResponse resume(Long userId, Long sessionId) {
7582
}
7683

7784
/**
78-
* 정상 종료. 본 메서드는 상태/시각만 마감하고, 실제 HikingRecord 변환은 #20 에서 추가될 예정.
79-
* 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).
8090
*/
8191
@Transactional
8292
public TrackingSessionResponse complete(Long userId, Long sessionId) {
8393
TrackingSession session = findOwnedSession(userId, sessionId);
8494
session.complete();
85-
return TrackingSessionResponse.from(session);
95+
96+
TrackingSessionStatsService.Stats stats = statsService.getStats(sessionId);
97+
HikingRecord record = HikingRecord.fromTrackingSession(
98+
session,
99+
stats.distanceMeters(),
100+
stats.maxAltitudeMeters(),
101+
stats.ascentMeters(),
102+
stats.descentMeters()
103+
);
104+
HikingRecord savedRecord = hikingRecordRepository.save(record);
105+
106+
HikingMember member = HikingMember.create(savedRecord, session.getUser());
107+
hikingMemberRepository.save(member);
108+
109+
return TrackingSessionResponse.from(session, savedRecord.getId());
86110
}
87111

88112
@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);

0 commit comments

Comments
 (0)