-
Notifications
You must be signed in to change notification settings - Fork 0
[Feat]#19 트래킹 GPS #53
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 3 commits
8172c72
57c0d97
c0509d0
41ca956
7c7d219
2f198bd
a1cc3c2
5199719
7f3e238
8397428
594b6c0
f34a536
f544939
7ab3ca3
a189be8
8f6a244
349e814
445e75d
f506a72
0b145de
e5beefb
569514d
ace41a6
ff6beb3
324c6ea
457fafb
0cb828a
c274afe
cb43205
5819cdb
2598403
1fb1de7
cbbda93
d27b7b8
73bc7e0
c0d6d99
8d76665
4645a2f
99bf9c9
a88e749
2408eb0
9a7bb76
e3897f6
ee96567
7054f1e
0b2b4cc
ffa4cfc
c227637
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,75 @@ | ||
| package com.semosan.api.domain.tracking.config; | ||
|
|
||
| import com.semosan.api.common.config.TrackingProperties; | ||
| import com.semosan.api.domain.tracking.service.TrackingStreamConsumer; | ||
| import jakarta.annotation.PreDestroy; | ||
| import lombok.RequiredArgsConstructor; | ||
| import lombok.extern.slf4j.Slf4j; | ||
| import org.springframework.beans.factory.InitializingBean; | ||
| import org.springframework.context.annotation.Configuration; | ||
| import org.springframework.data.redis.connection.RedisConnectionFactory; | ||
| import org.springframework.data.redis.connection.stream.Consumer; | ||
| import org.springframework.data.redis.connection.stream.MapRecord; | ||
| import org.springframework.data.redis.connection.stream.ReadOffset; | ||
| import org.springframework.data.redis.connection.stream.StreamOffset; | ||
| import org.springframework.data.redis.stream.StreamMessageListenerContainer; | ||
|
|
||
| import java.net.InetAddress; | ||
| import java.net.UnknownHostException; | ||
| import java.time.Duration; | ||
| import java.util.UUID; | ||
|
|
||
| /** | ||
| * Redis Stream(tracking:gps) 의 GPS 점을 소비하는 컨테이너를 부트업한다. | ||
| * - Consumer group 자체는 RedisStreamConfig 에서 사전 생성됨. | ||
| * - 본 클래스는 그룹의 이 인스턴스를 위한 consumer 등록 + listener container 시작. | ||
| * - consumer 이름은 호스트명 + UUID prefix 로 인스턴스별 유일성 확보 (다중 인스턴스 대비). | ||
| */ | ||
| @Slf4j | ||
| @Configuration | ||
| @RequiredArgsConstructor | ||
| public class TrackingStreamListenerConfig implements InitializingBean { | ||
|
|
||
| private final RedisConnectionFactory redisConnectionFactory; | ||
| private final TrackingProperties trackingProperties; | ||
| private final TrackingStreamConsumer trackingStreamConsumer; | ||
|
|
||
| private StreamMessageListenerContainer<String, MapRecord<String, String, String>> container; | ||
|
|
||
| @Override | ||
| public void afterPropertiesSet() { | ||
| var options = StreamMessageListenerContainer | ||
| .StreamMessageListenerContainerOptions.builder() | ||
| .pollTimeout(Duration.ofSeconds(1)) | ||
| .build(); | ||
|
|
||
| container = StreamMessageListenerContainer.create(redisConnectionFactory, options); | ||
|
|
||
| container.receiveAutoAck( | ||
| Consumer.from(trackingProperties.getConsumerGroup(), buildConsumerName()), | ||
| StreamOffset.create(trackingProperties.getStreamKey(), ReadOffset.lastConsumed()), | ||
| trackingStreamConsumer | ||
| ); | ||
|
Comment on lines
+48
to
+52
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain🌐 Web query:
💡 Result: Yes. In StreamMessageListenerContainer, using receiveAutoAck (auto-acknowledge) acknowledges messages when they are received, not after your listener code completes successfully. This is explicitly stated as “Every message is acknowledged when received” for receiveAutoAck, whereas the non-auto-ack mode requires acknowledging via StreamOperations.acknowledge after processing completes successfully [1][2][3]. Recommended pattern for at-least-once with consumer groups: 1) Use external acknowledgement: call receive(...) (autoAck=false) and only acknowledge with StreamOperations.acknowledge after your listener processing succeeds [1][4][3]. This keeps failed/unacknowledged deliveries in the consumer group’s Pending Entries List (PEL) [3][5]. 2) Handle PENDING recovery explicitly: start (or periodically run) a “drain PEL” phase for your consumer group (e.g., read pending/previously delivered entries), and only when the PEL is empty switch to reading new messages (ID “>”). Redis documents that re-delivered pending entries remain pending until explicitly acknowledged via XACK, and the typical recovery approach is to first process the pending backlog (using an initial ID such as 0), then proceed to “>” for new messages [6]. In code terms, the container doesn’t provide a built-in “always re-process PENDING until it succeeds before polling for new” switch; you implement the two-phase (PEL-drain then new) flow by choosing the appropriate StreamRead offsets/requests (PEL history first, then “>”), and by acknowledging only after successful processing [1][3][6]. Citations:
Use manual acknowledgement instead of auto-ack to ensure at-least-once delivery for GPS points.
Switch to 🤖 Prompt for AI Agents |
||
| container.start(); | ||
| log.info("Started Redis Stream listener: stream={} group={}", | ||
| trackingProperties.getStreamKey(), | ||
| trackingProperties.getConsumerGroup()); | ||
| } | ||
|
|
||
| @PreDestroy | ||
| public void stop() { | ||
| if (container != null) { | ||
| container.stop(); | ||
| } | ||
| } | ||
|
|
||
| private static String buildConsumerName() { | ||
| String host; | ||
| try { | ||
| host = InetAddress.getLocalHost().getHostName(); | ||
| } catch (UnknownHostException e) { | ||
| host = "unknown"; | ||
| } | ||
| return host + "-" + UUID.randomUUID().toString().substring(0, 8); | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,43 @@ | ||
| package com.semosan.api.domain.tracking.controller; | ||
|
|
||
| import com.semosan.api.domain.tracking.dto.message.GpsPointMessage; | ||
| import com.semosan.api.domain.tracking.service.TrackingGpsPublisher; | ||
| import com.semosan.api.domain.tracking.websocket.UserIdPrincipal; | ||
| import jakarta.validation.Valid; | ||
| import lombok.RequiredArgsConstructor; | ||
| import lombok.extern.slf4j.Slf4j; | ||
| import org.springframework.messaging.handler.annotation.DestinationVariable; | ||
| import org.springframework.messaging.handler.annotation.MessageMapping; | ||
| import org.springframework.messaging.handler.annotation.Payload; | ||
| import org.springframework.stereotype.Controller; | ||
|
|
||
| import java.security.Principal; | ||
|
|
||
| /** | ||
| * 클라이언트 STOMP SEND: /app/tracking/{sessionId}/gps | ||
| * 인증된 사용자만 도달 (CONNECT 시 JWT 검증 완료). userId 는 Principal 에서 추출. | ||
| */ | ||
| @Slf4j | ||
| @Controller | ||
| @RequiredArgsConstructor | ||
| public class TrackingGpsWebSocketController { | ||
|
|
||
| private final TrackingGpsPublisher trackingGpsPublisher; | ||
|
|
||
| @MessageMapping("/tracking/{sessionId}/gps") | ||
| public void receiveGps( | ||
| @DestinationVariable Long sessionId, | ||
| @Valid @Payload GpsPointMessage message, | ||
| Principal principal | ||
| ) { | ||
| Long userId = resolveUserId(principal); | ||
| trackingGpsPublisher.publish(userId, sessionId, message); | ||
| } | ||
|
|
||
| private Long resolveUserId(Principal principal) { | ||
| if (principal instanceof UserIdPrincipal userPrincipal) { | ||
| return userPrincipal.getUserId(); | ||
| } | ||
| throw new IllegalStateException("Unauthenticated WebSocket message"); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Use domain auth exception instead of Line 41 currently throws a generic runtime exception, which can surface as a 500-style internal failure path. Please throw your standard auth/business exception type so STOMP error semantics stay consistent. 🤖 Prompt for AI Agents |
||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,24 @@ | ||
| package com.semosan.api.domain.tracking.dto.message; | ||
|
|
||
| import jakarta.validation.constraints.NotNull; | ||
|
|
||
| import java.time.LocalDateTime; | ||
|
|
||
| /** | ||
| * 프론트 → 서버 (STOMP SEND /app/tracking/{sessionId}/gps). | ||
| * 좌표만 받는다 — 리버스 지오코딩 결과는 받지 않음. | ||
| */ | ||
| public record GpsPointMessage( | ||
| @NotNull(message = "위도(lat)는 필수입니다.") | ||
| Double lat, | ||
|
|
||
| @NotNull(message = "경도(lng)는 필수입니다.") | ||
| Double lng, | ||
|
Comment on lines
+12
to
+16
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Add latitude/longitude range validation.
✅ Suggested validation annotations import jakarta.validation.constraints.NotNull;
+import jakarta.validation.constraints.DecimalMax;
+import jakarta.validation.constraints.DecimalMin;
@@
- `@NotNull`(message = "위도(lat)는 필수입니다.")
+ `@NotNull`(message = "위도(lat)는 필수입니다.")
+ `@DecimalMin`(value = "-90.0", message = "위도(lat)는 -90 이상이어야 합니다.")
+ `@DecimalMax`(value = "90.0", message = "위도(lat)는 90 이하여야 합니다.")
Double lat,
@@
- `@NotNull`(message = "경도(lng)는 필수입니다.")
+ `@NotNull`(message = "경도(lng)는 필수입니다.")
+ `@DecimalMin`(value = "-180.0", message = "경도(lng)는 -180 이상이어야 합니다.")
+ `@DecimalMax`(value = "180.0", message = "경도(lng)는 180 이하여야 합니다.")
Double lng,🤖 Prompt for AI Agents |
||
|
|
||
| /** 디바이스가 보고한 고도(m). 일부 디바이스에서 누락 가능. */ | ||
| Double altitude, | ||
|
|
||
| @NotNull(message = "측정 시각(recordedAt)은 필수입니다.") | ||
| LocalDateTime recordedAt | ||
| ) { | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,57 @@ | ||
| package com.semosan.api.domain.tracking.entity; | ||
|
|
||
| import com.semosan.api.common.base.BaseEntity; | ||
| import jakarta.persistence.*; | ||
| import lombok.AccessLevel; | ||
| import lombok.AllArgsConstructor; | ||
| import lombok.Builder; | ||
| import lombok.Getter; | ||
| import lombok.NoArgsConstructor; | ||
| import org.locationtech.jts.geom.Point; | ||
|
|
||
| import java.time.LocalDateTime; | ||
|
|
||
| @Table( | ||
| name = "tracking_points", | ||
| indexes = { | ||
| @Index(name = "idx_tracking_points_session_recorded", columnList = "tracking_session_id, recorded_at") | ||
| } | ||
| ) | ||
| @Getter | ||
| @Entity | ||
| @Builder(access = AccessLevel.PROTECTED) | ||
| @AllArgsConstructor(access = AccessLevel.PROTECTED) | ||
| @NoArgsConstructor(access = AccessLevel.PROTECTED) | ||
| public class TrackingPoint extends BaseEntity { | ||
|
|
||
| @Id | ||
| @GeneratedValue(strategy = GenerationType.IDENTITY) | ||
| private Long id; | ||
|
|
||
| @ManyToOne(fetch = FetchType.LAZY) | ||
| @JoinColumn(name = "tracking_session_id", nullable = false) | ||
| private TrackingSession trackingSession; | ||
|
|
||
| /** | ||
| * 좌표 (PostGIS geography(Point, 4326)). | ||
| * Haversine 직접 계산보다 ST_Distance(geography) 가 정확 (구 좌표계 기반). | ||
| */ | ||
| @Column(name = "location", columnDefinition = "geography(Point, 4326)", nullable = false) | ||
| private Point location; | ||
|
|
||
| /** 디바이스가 보고한 고도(m). null 가능 — 일부 디바이스/실내에선 누락. */ | ||
| @Column(name = "altitude") | ||
| private Double altitude; | ||
|
|
||
| @Column(name = "recorded_at", nullable = false) | ||
| private LocalDateTime recordedAt; | ||
|
|
||
| public static TrackingPoint create(TrackingSession session, Point location, Double altitude, LocalDateTime recordedAt) { | ||
|
JangInho marked this conversation as resolved.
|
||
| return TrackingPoint.builder() | ||
| .trackingSession(session) | ||
| .location(location) | ||
| .altitude(altitude) | ||
| .recordedAt(recordedAt) | ||
| .build(); | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,11 @@ | ||
| package com.semosan.api.domain.tracking.repository; | ||
|
|
||
| import com.semosan.api.domain.tracking.entity.TrackingPoint; | ||
| import org.springframework.data.jpa.repository.JpaRepository; | ||
|
|
||
| import java.util.List; | ||
|
|
||
| public interface TrackingPointRepository extends JpaRepository<TrackingPoint, Long> { | ||
|
|
||
| List<TrackingPoint> findByTrackingSession_IdOrderByRecordedAtAsc(Long sessionId); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,65 @@ | ||
| package com.semosan.api.domain.tracking.service; | ||
|
|
||
| import com.semosan.api.common.config.TrackingProperties; | ||
| import com.semosan.api.common.exception.GeneralException; | ||
| import com.semosan.api.common.status.ErrorStatus; | ||
| import com.semosan.api.domain.tracking.dto.message.GpsPointMessage; | ||
| import com.semosan.api.domain.tracking.entity.TrackingSession; | ||
| import com.semosan.api.domain.tracking.enums.TrackingSessionStatus; | ||
| import com.semosan.api.domain.tracking.repository.TrackingSessionRepository; | ||
| import lombok.RequiredArgsConstructor; | ||
| import lombok.extern.slf4j.Slf4j; | ||
| import org.springframework.data.redis.connection.stream.RecordId; | ||
| import org.springframework.data.redis.connection.stream.StringRecord; | ||
| import org.springframework.data.redis.core.StringRedisTemplate; | ||
| import org.springframework.stereotype.Service; | ||
| import org.springframework.transaction.annotation.Transactional; | ||
|
|
||
| import java.util.Map; | ||
|
|
||
| /** | ||
| * 트래킹 GPS 점을 Redis Stream 으로 발행하기 전, 세션 소유자/상태를 검증한다. | ||
| * - 세션 존재 + 본인 소유 검증은 강제 (위반 시 throw → WebSocket 연결 강제 종료 효과) | ||
| * - PAUSED/COMPLETED/ABANDONED 상태에서는 silent drop (예외 X) — 클라이언트 race 흡수 | ||
| */ | ||
| @Slf4j | ||
| @Service | ||
| @RequiredArgsConstructor | ||
| public class TrackingGpsPublisher { | ||
|
|
||
| private static final String FIELD_SESSION_ID = "sessionId"; | ||
| private static final String FIELD_USER_ID = "userId"; | ||
| private static final String FIELD_LAT = "lat"; | ||
| private static final String FIELD_LNG = "lng"; | ||
| private static final String FIELD_ALTITUDE = "altitude"; | ||
| private static final String FIELD_RECORDED_AT = "recordedAt"; | ||
|
|
||
| private final StringRedisTemplate redisTemplate; | ||
| private final TrackingProperties trackingProperties; | ||
| private final TrackingSessionRepository trackingSessionRepository; | ||
|
|
||
| @Transactional(readOnly = true) | ||
| public void publish(Long userId, Long sessionId, GpsPointMessage message) { | ||
| TrackingSession session = trackingSessionRepository.findById(sessionId) | ||
|
JangInho marked this conversation as resolved.
|
||
| .orElseThrow(() -> new GeneralException(ErrorStatus.TRACKING_SESSION_NOT_FOUND)); | ||
| if (!session.isOwnedBy(userId)) { | ||
|
Comment on lines
+42
to
+45
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Remove per-point DB lookup from ingress hot path. Line 43 does a DB read for every GPS message before publishing to Redis. At scale, this undermines the PR’s low-latency ingestion goal and creates avoidable DB pressure. Consider caching session ownership/state in Redis (or validating once at session start and refreshing asynchronously) so publish remains lightweight. 🤖 Prompt for AI Agents |
||
| throw new GeneralException(ErrorStatus.TRACKING_SESSION_FORBIDDEN); | ||
| } | ||
| if (session.getStatus() != TrackingSessionStatus.IN_PROGRESS) { | ||
| log.debug("Dropping GPS point: sessionId={} status={}", sessionId, session.getStatus()); | ||
| return; | ||
| } | ||
|
|
||
| Map<String, String> body = Map.of( | ||
| FIELD_SESSION_ID, String.valueOf(sessionId), | ||
| FIELD_USER_ID, String.valueOf(userId), | ||
| FIELD_LAT, String.valueOf(message.lat()), | ||
| FIELD_LNG, String.valueOf(message.lng()), | ||
| FIELD_ALTITUDE, message.altitude() == null ? "" : String.valueOf(message.altitude()), | ||
| FIELD_RECORDED_AT, message.recordedAt().toString() | ||
| ); | ||
| StringRecord record = StringRecord.of(body).withStreamKey(trackingProperties.getStreamKey()); | ||
| RecordId id = redisTemplate.opsForStream().add(record); | ||
| log.trace("Published GPS point: sessionId={} streamId={}", sessionId, id); | ||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Restrict WebSocket origins now (don’t ship wildcard + credentials).
Line 95 currently allows any origin for
/ws/**while Line 98 enables credentials. This leaves the handshake policy overly permissive for a privileged channel. Replace*with an explicit allowlist and keep it environment-driven. Also alignWebSocketConfigLine 29 to the same allowlist.🔒 Suggested change
Also applies to: 101-101
🤖 Prompt for AI Agents