From 278ad8c87bb42e3123f09c8995c55c89d27fdf70 Mon Sep 17 00:00:00 2001 From: MINYONG PARK Date: Tue, 4 Aug 2026 17:40:37 +0900 Subject: [PATCH] =?UTF-8?q?feat(S15P11A705-308):=20=EC=A7=80=EB=8F=84=20?= =?UTF-8?q?=EB=A7=88=EC=BB=A4=20=EC=9D=91=EB=8B=B5=EC=97=90=20=EA=B0=80?= =?UTF-8?q?=EC=9E=A5=20=EC=B5=9C=EA=B7=BC=EC=97=90=20=EB=8B=B4=EA=B8=B4=20?= =?UTF-8?q?=EC=BB=AC=EB=A0=89=EC=85=98=20id=EB=A5=BC=20=EB=8D=94=ED=95=9C?= =?UTF-8?q?=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5 --- ...S15P11A705-308-map-latest-collection-id.md | 14 ++++ .../RecordLatestCollectionRepository.java | 50 ++++++++++++ .../domain/record/dto/MapMarkerResponse.java | 15 +++- .../domain/record/service/RecordService.java | 23 +++++- .../domain/record/RecordMapApiTests.java | 76 ++++++++++++++++++- 5 files changed, 171 insertions(+), 7 deletions(-) create mode 100644 docs/backend/worklog/2026-08-04-S15P11A705-308-map-latest-collection-id.md create mode 100644 src/main/java/com/pinlog/pinlogback/domain/collection/repository/RecordLatestCollectionRepository.java diff --git a/docs/backend/worklog/2026-08-04-S15P11A705-308-map-latest-collection-id.md b/docs/backend/worklog/2026-08-04-S15P11A705-308-map-latest-collection-id.md new file mode 100644 index 00000000..f2a6a317 --- /dev/null +++ b/docs/backend/worklog/2026-08-04-S15P11A705-308-map-latest-collection-id.md @@ -0,0 +1,14 @@ +# 지도 마커 응답에 가장 최근에 담긴 컬렉션 id를 더한다 + +- **날짜**: 2026-08-04 +- **추적**: S15P11A705-308 +- **관련**: [docs#48](https://github.com/Team-PinLog/docs/pull/48) (08 §4.2 계약 선행 개정) + +프론트가 지도 마커 색상을 레코드가 담긴 컬렉션 기준으로 구분하기로 해서, `GET /v1/records/map` 응답 `items`의 각 마커에 `latestCollectionId`를 실었다. 값은 그 Record가 담긴 활성 연결(`collection_record`) 중 **담은 시각 최신**(`created_at DESC`, 동시각이면 `id DESC`) 기준 Collection id — 컬렉션 내부 정렬(데이터모델 2.7)과 같은 기준이라 "컬렉션에서 보이는 최신"과 "마커 색"이 어긋나지 않는다. 어느 컬렉션에도 담기지 않은 Record는 `null`이고, 컬렉션에서 뺀(소프트 삭제) 연결은 판단에서 제외된다. + +구현 판단 둘: + +1. **마커 쿼리에 조인하지 않고 별도 배치 질의로 채웠다.** "record별 최신 1건"은 `DISTINCT ON`이 필요해 JPQL로는 안 되고, 기존 마커 JPQL을 native로 갈아엎는 것보다 `RecordLatestCollectionRepository`(NamedParameterJdbcTemplate, `ContextKeywordRepository`·`CollectionFirstPageRepository`와 같은 패턴) 한 번을 더 부르는 쪽이 변경 반경이 작다. 마커마다 반복 조회하면 N+1이라 record id 목록을 IN으로 묶어 한 번에 가져온다. +2. **native라 소프트 삭제 제외를 쿼리에 직접 적었다.** `@SQLRestriction`은 native 쿼리에 적용되지 않으므로 `deleted_at IS NULL`을 빼먹으면 뺀 연결이 최신 판단에 되살아난다 — 제거 링크 제외를 테스트로 고정했다. + +`MapMarkerResponse`는 6번째 컴포넌트로 `latestCollectionId`를 얻었고, 마커 JPQL은 5개 인자 보조 생성자를 그대로 쓴다. bbox·keyword 경로 모두 질의 이후 한 지점에서 채우므로 경로별 분기가 없다. diff --git a/src/main/java/com/pinlog/pinlogback/domain/collection/repository/RecordLatestCollectionRepository.java b/src/main/java/com/pinlog/pinlogback/domain/collection/repository/RecordLatestCollectionRepository.java new file mode 100644 index 00000000..aec0f137 --- /dev/null +++ b/src/main/java/com/pinlog/pinlogback/domain/collection/repository/RecordLatestCollectionRepository.java @@ -0,0 +1,50 @@ +package com.pinlog.pinlogback.domain.collection.repository; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate; +import org.springframework.stereotype.Repository; + +/** + * Record별 "가장 최근에 담긴" Collection id를 한 번의 질의로 모은다(API 명세 4.2, + * S15P11A705-308). 지도 마커 색상 구분용이라 마커마다 반복 조회하면 그대로 N+1이다. + * + *

"가장 최근"의 기준은 컬렉션 내부 정렬(데이터모델 2.7)과 같은 담은 시각 + * ({@code collection_record.created_at DESC}, 동시각이면 {@code id DESC})이다 — Collection의 + * 생성·수정 시각이 아니다. {@code DISTINCT ON}은 JPQL에 없어 native가 필요하고, native는 + * {@code @SQLRestriction} 밖이므로 소프트 삭제 제외({@code deleted_at IS NULL})를 직접 적는다. + */ +@Repository +public class RecordLatestCollectionRepository { + + private static final String LATEST_COLLECTION_IDS_SQL = """ + SELECT DISTINCT ON (cr.record_id) cr.record_id, cr.collection_id + FROM core.collection_record cr + WHERE cr.record_id IN (:recordIds) + AND cr.deleted_at IS NULL + ORDER BY cr.record_id, cr.created_at DESC, cr.id DESC + """; + + private final NamedParameterJdbcTemplate jdbc; + + public RecordLatestCollectionRepository(NamedParameterJdbcTemplate jdbc) { + this.jdbc = jdbc; + } + + /** + * @return Record id → 가장 최근에 담긴 Collection id. 어느 Collection에도 담기지 않은 + * Record는 키가 없다 — 호출부가 {@code null}로 채운다 + */ + public Map findLatestCollectionIds(List recordIds) { + if (recordIds.isEmpty()) { + return Map.of(); + } + Map byRecord = new LinkedHashMap<>(); + jdbc.query(LATEST_COLLECTION_IDS_SQL, Map.of("recordIds", recordIds), rows -> { + byRecord.put(rows.getLong("record_id"), rows.getLong("collection_id")); + }); + return byRecord; + } +} diff --git a/src/main/java/com/pinlog/pinlogback/domain/record/dto/MapMarkerResponse.java b/src/main/java/com/pinlog/pinlogback/domain/record/dto/MapMarkerResponse.java index 29f85d9f..def64d8a 100644 --- a/src/main/java/com/pinlog/pinlogback/domain/record/dto/MapMarkerResponse.java +++ b/src/main/java/com/pinlog/pinlogback/domain/record/dto/MapMarkerResponse.java @@ -4,6 +4,19 @@ /** * 지도 마커(API 명세 4.2). JPQL 생성자 표현식으로 직접 조회한다. + * + *

{@code latestCollectionId}는 이 Record가 가장 최근에 담긴 Collection의 id이며, 어느 + * Collection에도 담기지 않았으면 {@code null}이다(프론트 마커 색상 구분용, S15P11A705-308). + * 마커 조회와 별개의 배치 질의로 채우므로 JPQL은 5개 인자 생성자를 쓴다. */ -public record MapMarkerResponse(Long recordId, Long placeId, String name, BigDecimal lat, BigDecimal lng) { +public record MapMarkerResponse(Long recordId, Long placeId, String name, BigDecimal lat, BigDecimal lng, + Long latestCollectionId) { + + public MapMarkerResponse(Long recordId, Long placeId, String name, BigDecimal lat, BigDecimal lng) { + this(recordId, placeId, name, lat, lng, null); + } + + public MapMarkerResponse withLatestCollectionId(Long latestCollectionId) { + return new MapMarkerResponse(recordId, placeId, name, lat, lng, latestCollectionId); + } } diff --git a/src/main/java/com/pinlog/pinlogback/domain/record/service/RecordService.java b/src/main/java/com/pinlog/pinlogback/domain/record/service/RecordService.java index f4a023c8..52890eea 100644 --- a/src/main/java/com/pinlog/pinlogback/domain/record/service/RecordService.java +++ b/src/main/java/com/pinlog/pinlogback/domain/record/service/RecordService.java @@ -5,6 +5,7 @@ import java.util.Comparator; import java.util.List; import java.util.Locale; +import java.util.Map; import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; @@ -16,6 +17,7 @@ import com.pinlog.pinlogback.domain.ai.repository.AiDerivedDataRepository; import com.pinlog.pinlogback.domain.ai.repository.ContextAiStateRepository; import com.pinlog.pinlogback.domain.ai.repository.ContextKeywordRepository; +import com.pinlog.pinlogback.domain.collection.repository.RecordLatestCollectionRepository; import com.pinlog.pinlogback.domain.place.entity.Place; import com.pinlog.pinlogback.domain.place.repository.PlaceRepository; import com.pinlog.pinlogback.domain.record.dto.ContextMutationResponse; @@ -50,18 +52,20 @@ public class RecordService { private final ContextAiStateRepository contextAiStateRepository; private final AiDerivedDataRepository aiDerivedDataRepository; private final ContextKeywordRepository contextKeywordRepository; + private final RecordLatestCollectionRepository recordLatestCollectionRepository; private final ApplicationEventPublisher events; public RecordService(PlaceRepository placeRepository, RecordRepository recordRepository, ContextRepository contextRepository, ContextAiStateRepository contextAiStateRepository, AiDerivedDataRepository aiDerivedDataRepository, ContextKeywordRepository contextKeywordRepository, - ApplicationEventPublisher events) { + RecordLatestCollectionRepository recordLatestCollectionRepository, ApplicationEventPublisher events) { this.placeRepository = placeRepository; this.recordRepository = recordRepository; this.contextRepository = contextRepository; this.contextAiStateRepository = contextAiStateRepository; this.aiDerivedDataRepository = aiDerivedDataRepository; this.contextKeywordRepository = contextKeywordRepository; + this.recordLatestCollectionRepository = recordLatestCollectionRepository; this.events = events; } @@ -167,11 +171,26 @@ public MapResponse map(Long memberId, BigDecimal swLat, BigDecimal swLng, BigDec List found = allPresent ? recordRepository.findMarkersWithinBounds(memberId, swLat, swLng, neLat, neLng, likeKeyword) : recordRepository.findMarkers(memberId, likeKeyword); - List items = sortByName(found); + List items = sortByName(withLatestCollectionIds(found)); return new MapResponse( BoundsResponse.enclosing(items, MapMarkerResponse::lat, MapMarkerResponse::lng), items); } + /** + * 마커마다 가장 최근에 담긴 Collection id를 붙인다(API 명세 4.2 — 프론트 마커 색상 구분용). + * 담기지 않은 Record는 {@code null}로 남는다. + */ + private List withLatestCollectionIds(List markers) { + if (markers.isEmpty()) { + return markers; + } + Map latestByRecord = recordLatestCollectionRepository.findLatestCollectionIds( + markers.stream().map(MapMarkerResponse::recordId).toList()); + return markers.stream() + .map(marker -> marker.withLatestCollectionId(latestByRecord.get(marker.recordId()))) + .toList(); + } + /** * 이름 오름차순, 동명이면 recordId 오름차순(API 명세 4.2). DB {@code ORDER BY}가 아니라 여기서 * 정렬하는 이유는 collation 의존성 때문이다 — 한글에 동순위 가중치를 주는 collation에서는 diff --git a/src/test/java/com/pinlog/pinlogback/domain/record/RecordMapApiTests.java b/src/test/java/com/pinlog/pinlogback/domain/record/RecordMapApiTests.java index 5931fd1e..1a1e496e 100644 --- a/src/test/java/com/pinlog/pinlogback/domain/record/RecordMapApiTests.java +++ b/src/test/java/com/pinlog/pinlogback/domain/record/RecordMapApiTests.java @@ -14,6 +14,10 @@ import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; import org.springframework.test.web.servlet.MockMvc; +import com.pinlog.pinlogback.domain.collection.entity.Collection; +import com.pinlog.pinlogback.domain.collection.entity.CollectionRecord; +import com.pinlog.pinlogback.domain.collection.repository.CollectionRecordRepository; +import com.pinlog.pinlogback.domain.collection.repository.CollectionRepository; import com.pinlog.pinlogback.domain.member.entity.Member; import com.pinlog.pinlogback.domain.member.repository.MemberRepository; import com.pinlog.pinlogback.domain.place.entity.Place; @@ -38,6 +42,12 @@ class RecordMapApiTests extends IntegrationContainerSupport { @Autowired private RecordRepository recordRepository; + @Autowired + private CollectionRepository collectionRepository; + + @Autowired + private CollectionRecordRepository collectionRecordRepository; + @Test void mapWithoutBboxReturnsAllMyMarkersWithEnclosingBounds() throws Exception { long memberId = newMemberId(); @@ -227,6 +237,60 @@ void itemsAreSortedByNameAscending() throws Exception { .andExpect(jsonPath("$.data.items[2].name").value("코엑스")); } + @Test + void markerCarriesLatestCollectionId() throws Exception { + long memberId = newMemberId(); + long recordId = saveMarker(memberId, "map-col-latest-1", "코엑스", "37.5118242", "127.0591586"); + long earlier = newCollectionId(memberId, "먼저 담음"); + long later = newCollectionId(memberId, "나중 담음"); + collectionRecordRepository.save(CollectionRecord.create(earlier, recordId)); + collectionRecordRepository.save(CollectionRecord.create(later, recordId)); + + mockMvc.perform(get("/v1/records/map").with(loginAs(memberId))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.items[0].latestCollectionId").value(later)); + } + + @Test + void markerWithoutCollectionHasNullLatestCollectionId() throws Exception { + long memberId = newMemberId(); + saveMarker(memberId, "map-col-none-1", "코엑스", "37.5118242", "127.0591586"); + + mockMvc.perform(get("/v1/records/map").with(loginAs(memberId))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.items[0]").exists()) + .andExpect(jsonPath("$.data.items[0].latestCollectionId").value(Matchers.nullValue())); + } + + @Test + void removedCollectionLinkIsExcludedFromLatest() throws Exception { + long memberId = newMemberId(); + long recordId = saveMarker(memberId, "map-col-removed-1", "코엑스", "37.5118242", "127.0591586"); + long kept = newCollectionId(memberId, "남는 쪽"); + long removed = newCollectionId(memberId, "빠지는 쪽"); + collectionRecordRepository.save(CollectionRecord.create(kept, recordId)); + CollectionRecord removedLink = collectionRecordRepository.save(CollectionRecord.create(removed, recordId)); + collectionRecordRepository.delete(removedLink); + + mockMvc.perform(get("/v1/records/map").with(loginAs(memberId))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.items[0].latestCollectionId").value(kept)); + } + + @Test + void bboxPathAlsoCarriesLatestCollectionId() throws Exception { + long memberId = newMemberId(); + long recordId = saveMarker(memberId, "map-col-bbox-1", "코엑스", "37.5118242", "127.0591586"); + long collectionId = newCollectionId(memberId, "유일"); + collectionRecordRepository.save(CollectionRecord.create(collectionId, recordId)); + + mockMvc.perform(get("/v1/records/map").with(loginAs(memberId)) + .param("swLat", "37.4").param("swLng", "126.9") + .param("neLat", "37.6").param("neLng", "127.1")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.items[0].latestCollectionId").value(collectionId)); + } + @Test void partialBboxIs400() throws Exception { long memberId = newMemberId(); @@ -241,13 +305,17 @@ private long newMemberId() { return memberRepository.save(Member.create()).getId(); } - private void saveMarker(long memberId, String kakaoPlaceId, String name, String lat, String lng) { - saveMarker(memberId, kakaoPlaceId, name, "주소", lat, lng); + private long newCollectionId(long memberId, String title) { + return collectionRepository.save(Collection.create(memberId, title)).getId(); + } + + private long saveMarker(long memberId, String kakaoPlaceId, String name, String lat, String lng) { + return saveMarker(memberId, kakaoPlaceId, name, "주소", lat, lng); } - private void saveMarker(long memberId, String kakaoPlaceId, String name, String address, String lat, String lng) { + private long saveMarker(long memberId, String kakaoPlaceId, String name, String address, String lat, String lng) { Place place = placeRepository.save(Place.create( kakaoPlaceId, name, address, null, null, null, new BigDecimal(lat), new BigDecimal(lng))); - recordRepository.save(Record.create(memberId, place.getId())); + return recordRepository.save(Record.create(memberId, place.getId())).getId(); } }