Skip to content

[Feat]#46 트래킹 푸시 #55

Merged
JangInho merged 28 commits into
feat/#20-tracking-record-finalizefrom
feat/#46-tracking-photo-push
May 22, 2026
Merged

[Feat]#46 트래킹 푸시 #55
JangInho merged 28 commits into
feat/#20-tracking-record-finalizefrom
feat/#46-tracking-photo-push

Conversation

@JangInho

@JangInho JangInho commented May 19, 2026

Copy link
Copy Markdown
Contributor

🧾 요약

  • 트래킹 중 거리 마일스톤마다 사진 촬영을 유도하는 푸시 알림과, 촬영한 사진 메타 업로드 API를 추가했습니다.

🔗 이슈

✨ 변경 내용

  • 거리 마일스톤 도달 시 사진 촬영 푸시 트리거 추가
  • 트래킹 사진 메타 업로드 API 추가
  • tracking_photos 테이블 신설 및 notifications.type 제약 갱신 마이그레이션 추가

✅ 확인

  • 빌드 OK
  • 테스트 OK

Summary by CodeRabbit

  • New Features
    • Users can now upload certification photos at distance milestones during tracking sessions and view their photo history.
    • Automatic notifications alert users when they reach distance milestones, prompting them to submit verification photos.
    • Enhanced validation prevents duplicate photo uploads and rejects submissions when tracking sessions are inactive.

Review Change Stack

@JangInho JangInho self-assigned this May 19, 2026
@coderabbitai

coderabbitai Bot commented May 19, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 6d817bfc-6c1f-4f9d-a419-052be4ae1425

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/#46-tracking-photo-push

Comment @coderabbitai help to get the list of available commands and usage tips.

@JangInho
JangInho force-pushed the feat/#20-tracking-record-finalize branch from 1b1d692 to 7a65656 Compare May 19, 2026 13:15
@JangInho
JangInho force-pushed the feat/#46-tracking-photo-push branch from a740530 to 82b5895 Compare May 19, 2026 13:19

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 6

🧹 Nitpick comments (1)
src/main/java/com/semosan/api/domain/tracking/controller/docs/TrackingPhotoControllerDocs.java (1)

45-50: ⚡ Quick win

Add error response documentation for consistency.

The list endpoint documentation is missing @ApiResponses to document error cases. Based on the service implementation, this endpoint can return 403 (Forbidden) when accessing another user's session and 404 (Not Found) when the session doesn't exist. Document these for consistency with the upload endpoint.

📝 Suggested documentation addition
 `@Operation`(summary = "트래킹 세션의 사진 목록 조회",
         description = "본인 소유 세션의 마일스톤 사진을 milestone_index 오름차순으로 반환합니다.")
+@ApiResponses({
+        `@io.swagger.v3.oas.annotations.responses.ApiResponse`(responseCode = "200", description = "사진 목록 조회 성공"),
+        `@io.swagger.v3.oas.annotations.responses.ApiResponse`(responseCode = "403", description = "본인 세션 아님",
+                content = `@Content`(schema = `@Schema`(implementation = ApiResponse.class))),
+        `@io.swagger.v3.oas.annotations.responses.ApiResponse`(responseCode = "404", description = "세션 없음",
+                content = `@Content`(schema = `@Schema`(implementation = ApiResponse.class)))
+})
 ResponseEntity<ApiResponse<List<TrackingPhotoResponse>>> list(
🤖 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/java/com/semosan/api/domain/tracking/controller/docs/TrackingPhotoControllerDocs.java`
around lines 45 - 50, Add ApiResponses to the TrackingPhotoControllerDocs.list
method to document the error cases (403 and 404) the service can return: add an
`@ApiResponses` annotation containing `@ApiResponse`(responseCode = "403",
description = "Forbidden - accessing another user's session") and
`@ApiResponse`(responseCode = "404", description = "Not Found - session does not
exist") so the list operation's OpenAPI docs match the upload endpoint; update
the annotations on the TrackingPhotoControllerDocs.list declaration accordingly.
🤖 Prompt for all review comments with 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.

Inline comments:
In
`@src/main/java/com/semosan/api/domain/tracking/dto/request/TrackingPhotoUploadRequest.java`:
- Around line 18-31: In TrackingPhotoUploadRequest add numeric bounds validation
to the Double fields: annotate milestoneDistanceM with a non-negative constraint
(e.g., `@DecimalMin`(value="0.0", inclusive=true) or `@PositiveOrZero` and an
optional sensible upper bound via `@DecimalMax`), and annotate lat and lng with
latitude/longitude bounds (lat: `@DecimalMin`(value="-90.0") and
`@DecimalMax`(value="90.0"); lng: `@DecimalMin`(value="-180.0") and
`@DecimalMax`(value="180.0")). Apply these annotations to the existing fields
(milestoneDistanceM, lat, lng) in class TrackingPhotoUploadRequest and add the
corresponding javax.validation/hibernate-validator imports so validation
triggers on incoming requests.

In
`@src/main/java/com/semosan/api/domain/tracking/service/TrackingPhotoService.java`:
- Around line 37-39: The existence check in TrackingPhotoService (uses
trackingPhotoRepository.existsByTrackingSession_IdAndMilestoneIndex) is not
atomic with the subsequent save and can allow race-condition duplicates; add a
DB-level unique constraint in the migration (003_tracking_photos.sql) to enforce
uniqueness on (tracking_session_id, milestone_index) by adding the unique
constraint named uk_tracking_photos_session_milestone so concurrent inserts
cannot create duplicate tracking photos.

In
`@src/main/java/com/semosan/api/domain/tracking/service/TrackingPhotoTriggerService.java`:
- Around line 74-83: Replace the current read-then-send-then-write flow with an
atomic gate using the SADD result: call
redisTemplate.opsForSet().add(openedKey(sessionId), idxStr) (and similarly for
closedKey) and check that the returned Long equals 1 before calling
sendOpen/sendClosed; only when add returns 1, invoke sendOpen/sendClosed and set
the TTL with redisTemplate.expire(openedKey(sessionId), TTL) (or closedKey) so
emission is performed only once under concurrency. Ensure you still use the same
idxStr, sessionId, i, mi parameters when calling sendOpen/sendClosed.

In
`@src/main/java/com/semosan/api/domain/tracking/service/TrackingSessionService.java`:
- Line 67: The create flow in TrackingSessionService currently calls
photoTriggerService.initializeMilestones(saved) inside the DB transaction which
couples session creation to Redis work; move the Redis call out of the
transactional boundary so DB commit cannot be rolled back by Redis failures.
Concretely, keep the DB save logic in the existing create method (or
`@Transactional` block) but either (a) invoke
photoTriggerService.initializeMilestones(saved) after the transaction commits
(use TransactionSynchronizationManager.registerSynchronization(... onAfterCommit
...) or call it from the caller after create returns) or (b) run
initializeMilestones asynchronously in a try/catch and log failures so Redis
errors don’t throw back into the transaction; reference
TrackingSessionService.create (or the method that saves `saved`) and the
photoTriggerService.initializeMilestones(saved) call when applying the change.

In
`@src/main/java/com/semosan/api/domain/tracking/service/TrackingStreamConsumer.java`:
- Around line 56-64: The current code lets parsing userId or photoTrigger
failures abort the main GPS ingestion path; change TrackingStreamConsumer so
statsService.recordPoint(sessionId, lat, lng, altitude, recordedAt) always runs
and any photo-trigger work is best-effort: parse
latitude/longitude/altitude/recordedAt as before and call
statsService.recordPoint first, then wrap parsing of userId and the call to
photoTriggerService.evaluate(sessionId, userId, distanceTotal) in a try-catch
block that logs but swallows exceptions; ensure any NumberFormatException or
DateTimeParseException from userId parsing or photoTriggerService.evaluate does
not propagate and does not prevent point buffering/flush.

In `@src/main/resources/db/migration/003_tracking_photos.sql`:
- Around line 25-26: The current non-unique index
idx_tracking_photos_session_milestone on tracking_photos (tracking_session_id,
milestone_index) allows duplicate rows under concurrent inserts; change it to
enforce uniqueness at the DB level by creating a UNIQUE index or adding a UNIQUE
constraint on (tracking_session_id, milestone_index) (replace the existing
idx_tracking_photos_session_milestone creation with a UNIQUE variant or use
ALTER TABLE ... ADD CONSTRAINT unique_tracking_photos_session_milestone UNIQUE
(tracking_session_id, milestone_index)); ensure the migration is written in a
safe way for your DB (use CREATE UNIQUE INDEX IF NOT EXISTS or the appropriate
ALTER TABLE statement).

---

Nitpick comments:
In
`@src/main/java/com/semosan/api/domain/tracking/controller/docs/TrackingPhotoControllerDocs.java`:
- Around line 45-50: Add ApiResponses to the TrackingPhotoControllerDocs.list
method to document the error cases (403 and 404) the service can return: add an
`@ApiResponses` annotation containing `@ApiResponse`(responseCode = "403",
description = "Forbidden - accessing another user's session") and
`@ApiResponse`(responseCode = "404", description = "Not Found - session does not
exist") so the list operation's OpenAPI docs match the upload endpoint; update
the annotations on the TrackingPhotoControllerDocs.list declaration accordingly.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: b501449e-9dc4-4fa6-ab7c-6c8333d36593

📥 Commits

Reviewing files that changed from the base of the PR and between 7a65656 and 82b5895.

📒 Files selected for processing (16)
  • src/main/java/com/semosan/api/common/status/ErrorStatus.java
  • src/main/java/com/semosan/api/common/status/SuccessStatus.java
  • src/main/java/com/semosan/api/domain/notification/enums/NotificationType.java
  • src/main/java/com/semosan/api/domain/tracking/controller/TrackingPhotoController.java
  • src/main/java/com/semosan/api/domain/tracking/controller/docs/TrackingPhotoControllerDocs.java
  • src/main/java/com/semosan/api/domain/tracking/dto/request/TrackingPhotoUploadRequest.java
  • src/main/java/com/semosan/api/domain/tracking/dto/response/TrackingPhotoResponse.java
  • src/main/java/com/semosan/api/domain/tracking/entity/TrackingPhoto.java
  • src/main/java/com/semosan/api/domain/tracking/repository/TrackingPhotoRepository.java
  • src/main/java/com/semosan/api/domain/tracking/service/TrackingMilestoneCalculator.java
  • src/main/java/com/semosan/api/domain/tracking/service/TrackingPhotoService.java
  • src/main/java/com/semosan/api/domain/tracking/service/TrackingPhotoTriggerService.java
  • src/main/java/com/semosan/api/domain/tracking/service/TrackingSessionService.java
  • src/main/java/com/semosan/api/domain/tracking/service/TrackingSessionStatsService.java
  • src/main/java/com/semosan/api/domain/tracking/service/TrackingStreamConsumer.java
  • src/main/resources/db/migration/003_tracking_photos.sql

Comment on lines +18 to +31
@NotNull(message = "마일스톤 기준 거리(m)는 필수입니다.")
Double milestoneDistanceM,

@NotBlank(message = "imageUrl 은 필수입니다.")
String imageUrl,

@NotNull(message = "촬영 시각은 필수입니다.")
LocalDateTime capturedAt,

@NotNull(message = "위도는 필수입니다.")
Double lat,

@NotNull(message = "경도는 필수입니다.")
Double lng,

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 | 🟡 Minor | ⚡ Quick win

Add bounds validation for distance and coordinates.

milestoneDistanceM, lat, and lng currently allow impossible values, so malformed payloads can be saved.

Suggested fix
 import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.DecimalMax;
+import jakarta.validation.constraints.DecimalMin;
 import jakarta.validation.constraints.NotNull;
+import jakarta.validation.constraints.Positive;
 import jakarta.validation.constraints.PositiveOrZero;
@@
         `@NotNull`(message = "마일스톤 기준 거리(m)는 필수입니다.")
+        `@Positive`(message = "마일스톤 기준 거리(m)는 0보다 커야 합니다.")
         Double milestoneDistanceM,
@@
         `@NotNull`(message = "위도는 필수입니다.")
+        `@DecimalMin`(value = "-90.0", message = "위도는 -90 이상이어야 합니다.")
+        `@DecimalMax`(value = "90.0", message = "위도는 90 이하여야 합니다.")
         Double lat,
@@
         `@NotNull`(message = "경도는 필수입니다.")
+        `@DecimalMin`(value = "-180.0", message = "경도는 -180 이상이어야 합니다.")
+        `@DecimalMax`(value = "180.0", message = "경도는 180 이하여야 합니다.")
         Double lng,
📝 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
@NotNull(message = "마일스톤 기준 거리(m)는 필수입니다.")
Double milestoneDistanceM,
@NotBlank(message = "imageUrl 은 필수입니다.")
String imageUrl,
@NotNull(message = "촬영 시각은 필수입니다.")
LocalDateTime capturedAt,
@NotNull(message = "위도는 필수입니다.")
Double lat,
@NotNull(message = "경도는 필수입니다.")
Double lng,
`@NotNull`(message = "마일스톤 기준 거리(m)는 필수입니다.")
`@Positive`(message = "마일스톤 기준 거리(m)는 0보다 커야 합니다.")
Double milestoneDistanceM,
`@NotBlank`(message = "imageUrl 은 필수입니다.")
String imageUrl,
`@NotNull`(message = "촬영 시각은 필수입니다.")
LocalDateTime capturedAt,
`@NotNull`(message = "위도는 필수입니다.")
`@DecimalMin`(value = "-90.0", message = "위도는 -90 이상이어야 합니다.")
`@DecimalMax`(value = "90.0", message = "위도는 90 이하여야 합니다.")
Double lat,
`@NotNull`(message = "경도는 필수입니다.")
`@DecimalMin`(value = "-180.0", message = "경도는 -180 이상이어야 합니다.")
`@DecimalMax`(value = "180.0", message = "경도는 180 이하여야 합니다.")
Double lng,
🤖 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/java/com/semosan/api/domain/tracking/dto/request/TrackingPhotoUploadRequest.java`
around lines 18 - 31, In TrackingPhotoUploadRequest add numeric bounds
validation to the Double fields: annotate milestoneDistanceM with a non-negative
constraint (e.g., `@DecimalMin`(value="0.0", inclusive=true) or `@PositiveOrZero`
and an optional sensible upper bound via `@DecimalMax`), and annotate lat and lng
with latitude/longitude bounds (lat: `@DecimalMin`(value="-90.0") and
`@DecimalMax`(value="90.0"); lng: `@DecimalMin`(value="-180.0") and
`@DecimalMax`(value="180.0")). Apply these annotations to the existing fields
(milestoneDistanceM, lat, lng) in class TrackingPhotoUploadRequest and add the
corresponding javax.validation/hibernate-validator imports so validation
triggers on incoming requests.

Comment on lines +37 to +39
if (trackingPhotoRepository.existsByTrackingSession_IdAndMilestoneIndex(sessionId, request.milestoneIndex())) {
throw new GeneralException(ErrorStatus.TRACKING_PHOTO_DUPLICATE);
}

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 | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check if the tracking_photos table has a unique constraint on session + milestone
rg -n "UNIQUE.*tracking_session_id.*milestone_index|milestone_index.*tracking_session_id" src/main/resources/db/migration/003_tracking_photos.sql

Repository: SEMOSAN/SEMOSAN_BE

Length of output: 44


🏁 Script executed:

#!/bin/bash
# Find the migration file
fd "003_tracking_photos" src/

# Also check for any migration files related to tracking
echo "=== All migration files ==="
fd "\.sql$" src/main/resources/db/migration/ 2>/dev/null | head -20

Repository: SEMOSAN/SEMOSAN_BE

Length of output: 320


🏁 Script executed:

#!/bin/bash
# If the file exists, read it to check the schema
if [ -f "src/main/resources/db/migration/003_tracking_photos.sql" ]; then
  echo "=== Content of migration file ==="
  cat "src/main/resources/db/migration/003_tracking_photos.sql"
else
  echo "File not found at expected location. Searching for it..."
  find . -name "*tracking_photos*" -type f 2>/dev/null
fi

Repository: SEMOSAN/SEMOSAN_BE

Length of output: 1818


Add a database unique constraint to prevent duplicate tracking photos.

The existsByTrackingSession_IdAndMilestoneIndex check at line 37 and the subsequent save at line 50 are not atomic. Without database-level enforcement, two concurrent upload requests for the same milestone could both pass the existence check and proceed to save, creating duplicate entries.

The migration file 003_tracking_photos.sql currently only creates a composite index on (tracking_session_id, milestone_index) for query performance, but no UNIQUE constraint. Add a unique constraint to the migration to enforce this at the database level:

ALTER TABLE tracking_photos
ADD CONSTRAINT uk_tracking_photos_session_milestone 
UNIQUE (tracking_session_id, milestone_index);
🤖 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/java/com/semosan/api/domain/tracking/service/TrackingPhotoService.java`
around lines 37 - 39, The existence check in TrackingPhotoService (uses
trackingPhotoRepository.existsByTrackingSession_IdAndMilestoneIndex) is not
atomic with the subsequent save and can allow race-condition duplicates; add a
DB-level unique constraint in the migration (003_tracking_photos.sql) to enforce
uniqueness on (tracking_session_id, milestone_index) by adding the unique
constraint named uk_tracking_photos_session_milestone so concurrent inserts
cannot create duplicate tracking photos.

Comment on lines +74 to +83
if (!isOpened && distanceTotal >= entry && distanceTotal <= exit) {
sendOpen(sessionId, userId, i, mi);
redisTemplate.opsForSet().add(openedKey(sessionId), idxStr);
redisTemplate.expire(openedKey(sessionId), TTL);
}
if (isOpened && !isClosed && distanceTotal > exit) {
sendClosed(sessionId, i, mi);
redisTemplate.opsForSet().add(closedKey(sessionId), idxStr);
redisTemplate.expire(closedKey(sessionId), TTL);
}

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

Make OPEN/CLOSED emission atomic at Redis write time.

Line 74–83 currently does read-then-send-then-write, so concurrent instances can emit duplicate OPEN/CLOSED for the same milestone. Gate emission on SADD result (1 only) to keep idempotency under concurrency.

Suggested fix
-        Set<String> opened = membersOrEmpty(openedKey(sessionId));
-        Set<String> closed = membersOrEmpty(closedKey(sessionId));
-
         for (int i = 0; i < milestones.size(); i++) {
             String idxStr = String.valueOf(i);
             double mi = milestones.get(i);
             double entry = mi * (1 - TOLERANCE_RATIO);
             double exit = mi * (1 + TOLERANCE_RATIO);
-            boolean isOpened = opened.contains(idxStr);
-            boolean isClosed = closed.contains(idxStr);
-
-            if (!isOpened && distanceTotal >= entry && distanceTotal <= exit) {
-                sendOpen(sessionId, userId, i, mi);
-                redisTemplate.opsForSet().add(openedKey(sessionId), idxStr);
-                redisTemplate.expire(openedKey(sessionId), TTL);
-            }
-            if (isOpened && !isClosed && distanceTotal > exit) {
-                sendClosed(sessionId, i, mi);
-                redisTemplate.opsForSet().add(closedKey(sessionId), idxStr);
-                redisTemplate.expire(closedKey(sessionId), TTL);
-            }
+            if (distanceTotal >= entry && distanceTotal <= exit) {
+                Long added = redisTemplate.opsForSet().add(openedKey(sessionId), idxStr);
+                if (Long.valueOf(1L).equals(added)) {
+                    redisTemplate.expire(openedKey(sessionId), TTL);
+                    sendOpen(sessionId, userId, i, mi);
+                }
+            }
+            if (distanceTotal > exit
+                    && Boolean.TRUE.equals(redisTemplate.opsForSet().isMember(openedKey(sessionId), idxStr))) {
+                Long addedClosed = redisTemplate.opsForSet().add(closedKey(sessionId), idxStr);
+                if (Long.valueOf(1L).equals(addedClosed)) {
+                    redisTemplate.expire(closedKey(sessionId), TTL);
+                    sendClosed(sessionId, i, mi);
+                }
+            }
         }
🤖 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/java/com/semosan/api/domain/tracking/service/TrackingPhotoTriggerService.java`
around lines 74 - 83, Replace the current read-then-send-then-write flow with an
atomic gate using the SADD result: call
redisTemplate.opsForSet().add(openedKey(sessionId), idxStr) (and similarly for
closedKey) and check that the returned Long equals 1 before calling
sendOpen/sendClosed; only when add returns 1, invoke sendOpen/sendClosed and set
the TTL with redisTemplate.expire(openedKey(sessionId), TTL) (or closedKey) so
emission is performed only once under concurrency. Ensure you still use the same
idxStr, sessionId, i, mi parameters when calling sendOpen/sendClosed.


TrackingSession session = TrackingSession.create(user, mountain, course, request.isFreeRecording());
TrackingSession saved = trackingSessionRepository.save(session);
photoTriggerService.initializeMilestones(saved);

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

Do not couple session creation success to Redis milestone initialization.

Line 67 invokes external Redis work inside the create transaction. If that call fails, session creation fails/rolls back even though the core DB write already succeeded.

Suggested fix
         TrackingSession session = TrackingSession.create(user, mountain, course, request.isFreeRecording());
         TrackingSession saved = trackingSessionRepository.save(session);
-        photoTriggerService.initializeMilestones(saved);
+        try {
+            photoTriggerService.initializeMilestones(saved);
+        } catch (RuntimeException e) {
+            // 보조 기능 실패가 세션 생성 자체를 막지 않도록 fail-open 처리
+            log.warn("Failed to initialize photo milestones. sessionId={}", saved.getId(), e);
+        }
         return TrackingSessionResponse.from(saved);
🤖 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/java/com/semosan/api/domain/tracking/service/TrackingSessionService.java`
at line 67, The create flow in TrackingSessionService currently calls
photoTriggerService.initializeMilestones(saved) inside the DB transaction which
couples session creation to Redis work; move the Redis call out of the
transactional boundary so DB commit cannot be rolled back by Redis failures.
Concretely, keep the DB save logic in the existing create method (or
`@Transactional` block) but either (a) invoke
photoTriggerService.initializeMilestones(saved) after the transaction commits
(use TransactionSynchronizationManager.registerSynchronization(... onAfterCommit
...) or call it from the caller after create returns) or (b) run
initializeMilestones asynchronously in a try/catch and log failures so Redis
errors don’t throw back into the transaction; reference
TrackingSessionService.create (or the method that saves `saved`) and the
photoTriggerService.initializeMilestones(saved) call when applying the change.

Comment on lines +56 to 64
Long userId = Long.parseLong(body.get(F_USER_ID));
double lat = Double.parseDouble(body.get(F_LAT));
double lng = Double.parseDouble(body.get(F_LNG));
Double altitude = parseNullableDouble(body.get(F_ALTITUDE));
LocalDateTime recordedAt = LocalDateTime.parse(body.get(F_RECORDED_AT));

statsService.recordPoint(sessionId, lat, lng, altitude, recordedAt);
double distanceTotal = statsService.recordPoint(sessionId, lat, lng, altitude, recordedAt);
photoTriggerService.evaluate(sessionId, userId, distanceTotal);

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

Isolate photo-trigger errors from core GPS ingestion path.

Line 56–64 lets userId parse or photo-trigger failures short-circuit the whole message, so point buffering/flush can be skipped. Keep stats + point persistence on the main path, and handle photo trigger as best-effort.

Suggested fix
-            Long userId = Long.parseLong(body.get(F_USER_ID));
             double lat = Double.parseDouble(body.get(F_LAT));
             double lng = Double.parseDouble(body.get(F_LNG));
             Double altitude = parseNullableDouble(body.get(F_ALTITUDE));
             LocalDateTime recordedAt = LocalDateTime.parse(body.get(F_RECORDED_AT));
 
             double distanceTotal = statsService.recordPoint(sessionId, lat, lng, altitude, recordedAt);
-            photoTriggerService.evaluate(sessionId, userId, distanceTotal);
+            Long userId = parseNullableLong(body.get(F_USER_ID));
+            if (userId != null) {
+                try {
+                    photoTriggerService.evaluate(sessionId, userId, distanceTotal);
+                } catch (RuntimeException e) {
+                    log.warn("Photo trigger evaluation failed: sessionId={} userId={}", sessionId, userId, e);
+                }
+            }
@@
     private static Double parseNullableDouble(String value) {
         return (value == null || value.isEmpty()) ? null : Double.parseDouble(value);
     }
+
+    private static Long parseNullableLong(String value) {
+        if (value == null || value.isEmpty()) return null;
+        try {
+            return Long.parseLong(value);
+        } catch (NumberFormatException e) {
+            return 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
Long userId = Long.parseLong(body.get(F_USER_ID));
double lat = Double.parseDouble(body.get(F_LAT));
double lng = Double.parseDouble(body.get(F_LNG));
Double altitude = parseNullableDouble(body.get(F_ALTITUDE));
LocalDateTime recordedAt = LocalDateTime.parse(body.get(F_RECORDED_AT));
statsService.recordPoint(sessionId, lat, lng, altitude, recordedAt);
double distanceTotal = statsService.recordPoint(sessionId, lat, lng, altitude, recordedAt);
photoTriggerService.evaluate(sessionId, userId, distanceTotal);
double lat = Double.parseDouble(body.get(F_LAT));
double lng = Double.parseDouble(body.get(F_LNG));
Double altitude = parseNullableDouble(body.get(F_ALTITUDE));
LocalDateTime recordedAt = LocalDateTime.parse(body.get(F_RECORDED_AT));
double distanceTotal = statsService.recordPoint(sessionId, lat, lng, altitude, recordedAt);
Long userId = parseNullableLong(body.get(F_USER_ID));
if (userId != null) {
try {
photoTriggerService.evaluate(sessionId, userId, distanceTotal);
} catch (RuntimeException e) {
log.warn("Photo trigger evaluation failed: sessionId={} userId={}", sessionId, userId, e);
}
}
🤖 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/java/com/semosan/api/domain/tracking/service/TrackingStreamConsumer.java`
around lines 56 - 64, The current code lets parsing userId or photoTrigger
failures abort the main GPS ingestion path; change TrackingStreamConsumer so
statsService.recordPoint(sessionId, lat, lng, altitude, recordedAt) always runs
and any photo-trigger work is best-effort: parse
latitude/longitude/altitude/recordedAt as before and call
statsService.recordPoint first, then wrap parsing of userId and the call to
photoTriggerService.evaluate(sessionId, userId, distanceTotal) in a try-catch
block that logs but swallows exceptions; ensure any NumberFormatException or
DateTimeParseException from userId parsing or photoTriggerService.evaluate does
not propagate and does not prevent point buffering/flush.

Comment on lines +25 to +26
CREATE INDEX IF NOT EXISTS idx_tracking_photos_session_milestone
ON tracking_photos (tracking_session_id, milestone_index);

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 session+milestone uniqueness at DB level.

This index is non-unique, so concurrent uploads can still insert duplicate photos for the same (tracking_session_id, milestone_index) despite application checks.

Suggested fix
-CREATE INDEX IF NOT EXISTS idx_tracking_photos_session_milestone
-    ON tracking_photos (tracking_session_id, milestone_index);
+CREATE UNIQUE INDEX IF NOT EXISTS uq_tracking_photos_session_milestone
+    ON tracking_photos (tracking_session_id, milestone_index);
📝 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
CREATE INDEX IF NOT EXISTS idx_tracking_photos_session_milestone
ON tracking_photos (tracking_session_id, milestone_index);
CREATE UNIQUE INDEX IF NOT EXISTS uq_tracking_photos_session_milestone
ON tracking_photos (tracking_session_id, milestone_index);
🤖 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/003_tracking_photos.sql` around lines 25 -
26, The current non-unique index idx_tracking_photos_session_milestone on
tracking_photos (tracking_session_id, milestone_index) allows duplicate rows
under concurrent inserts; change it to enforce uniqueness at the DB level by
creating a UNIQUE index or adding a UNIQUE constraint on (tracking_session_id,
milestone_index) (replace the existing idx_tracking_photos_session_milestone
creation with a UNIQUE variant or use ALTER TABLE ... ADD CONSTRAINT
unique_tracking_photos_session_milestone UNIQUE (tracking_session_id,
milestone_index)); ensure the migration is written in a safe way for your DB
(use CREATE UNIQUE INDEX IF NOT EXISTS or the appropriate ALTER TABLE
statement).

@howooyeon howooyeon added the enhancement New feature or request label May 20, 2026
pooreumjung and others added 21 commits May 21, 2026 15:26
…photo-push

# Conflicts:
#	src/main/java/com/semosan/api/common/status/ErrorStatus.java
#	src/main/java/com/semosan/api/domain/tracking/service/TrackingSessionService.java
#	src/main/java/com/semosan/api/domain/tracking/service/TrackingStreamConsumer.java
- 003_tracking_photos.sql → V10__create_tracking_photos.sql Flyway 컨벤션 통일
- BIGSERIAL → bigint identity, FK 제약 이름 명시(fk_tracking_photos_session)
- timestamp(6), DEFAULT now() 제거 (BaseEntity 가 채움)
- notifications.type CHECK 갱신 동봉 (TRACKING_PHOTO_MILESTONE 추가)
[Feat] 디스코드 서버 에러 알림 전송 기능 추가
[fix] #75 getNearbyMountain 메소드 파라미터 제약 수정
…onflict

[Hotfix]#77 Swagger MountainInfo 스키마 충돌 해소
@JangInho
JangInho merged commit ffddcce into feat/#20-tracking-record-finalize May 22, 2026
1 check passed
@howooyeon
howooyeon deleted the feat/#46-tracking-photo-push branch May 28, 2026 13:25
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants