From 443685f84ee2a70007b2e76293877d490dc0f61b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=9A=A9=ED=9C=98?= Date: Wed, 24 Jun 2026 15:20:58 +0900 Subject: [PATCH 1/2] =?UTF-8?q?feat(harness):=201=EB=8B=A8=EA=B3=84=20map?= =?UTF-8?q?=20viewport=20contract=20=EA=B5=AC=ED=98=84=20(#53)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 1 + .../controller/map/MapViewportController.java | 63 +++++++++++++ .../dto/map/MapViewportItemResponse.java | 6 ++ .../model/dto/map/MapViewportItemType.java | 7 ++ .../model/dto/map/MapViewportMode.java | 8 ++ .../model/dto/map/MapViewportRequest.java | 10 +++ .../model/dto/map/MapViewportResponse.java | 14 +++ .../dto/map/PropertyClusterViewportItem.java | 16 ++++ .../model/dto/map/PropertyViewportItem.java | 19 ++++ .../dto/map/RegionAverageViewportItem.java | 17 ++++ .../service/map/MapViewportService.java | 9 ++ .../service/map/MapViewportServiceImpl.java | 35 ++++++++ .../map/MapViewportControllerTest.java | 90 +++++++++++++++++++ backend/src/test/resources/.env.properties | 2 + .../resources/application-test.properties | 1 + docs/08_API_SPEC.md | 12 ++- .../phase1-map-viewport-contract.md | 27 ++++++ phases/map-viewport-zoom/phase1.status.json | 8 ++ .../phase2-region-and-cluster-query.md | 26 ++++++ .../phase3-frontend-map-viewport-api.md | 23 +++++ .../phase4-frontend-zoom-rendering.md | 23 +++++ .../phase5-qa-docs-deploy.md | 23 +++++ 22 files changed, 439 insertions(+), 1 deletion(-) create mode 100644 backend/src/main/java/com/ssafy/salmanhae/controller/map/MapViewportController.java create mode 100644 backend/src/main/java/com/ssafy/salmanhae/model/dto/map/MapViewportItemResponse.java create mode 100644 backend/src/main/java/com/ssafy/salmanhae/model/dto/map/MapViewportItemType.java create mode 100644 backend/src/main/java/com/ssafy/salmanhae/model/dto/map/MapViewportMode.java create mode 100644 backend/src/main/java/com/ssafy/salmanhae/model/dto/map/MapViewportRequest.java create mode 100644 backend/src/main/java/com/ssafy/salmanhae/model/dto/map/MapViewportResponse.java create mode 100644 backend/src/main/java/com/ssafy/salmanhae/model/dto/map/PropertyClusterViewportItem.java create mode 100644 backend/src/main/java/com/ssafy/salmanhae/model/dto/map/PropertyViewportItem.java create mode 100644 backend/src/main/java/com/ssafy/salmanhae/model/dto/map/RegionAverageViewportItem.java create mode 100644 backend/src/main/java/com/ssafy/salmanhae/service/map/MapViewportService.java create mode 100644 backend/src/main/java/com/ssafy/salmanhae/service/map/MapViewportServiceImpl.java create mode 100644 backend/src/test/java/com/ssafy/salmanhae/controller/map/MapViewportControllerTest.java create mode 100644 backend/src/test/resources/.env.properties create mode 100644 phases/map-viewport-zoom/phase1-map-viewport-contract.md create mode 100644 phases/map-viewport-zoom/phase1.status.json create mode 100644 phases/map-viewport-zoom/phase2-region-and-cluster-query.md create mode 100644 phases/map-viewport-zoom/phase3-frontend-map-viewport-api.md create mode 100644 phases/map-viewport-zoom/phase4-frontend-zoom-rendering.md create mode 100644 phases/map-viewport-zoom/phase5-qa-docs-deploy.md diff --git a/.gitignore b/.gitignore index 9c1b0e3..f91d3aa 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ .env.* !.env.example !*.env.example +!backend/src/test/resources/.env.properties # Local generated data inputs and pipeline outputs data/raw/ diff --git a/backend/src/main/java/com/ssafy/salmanhae/controller/map/MapViewportController.java b/backend/src/main/java/com/ssafy/salmanhae/controller/map/MapViewportController.java new file mode 100644 index 0000000..e5163bd --- /dev/null +++ b/backend/src/main/java/com/ssafy/salmanhae/controller/map/MapViewportController.java @@ -0,0 +1,63 @@ +package com.ssafy.salmanhae.controller.map; + +import java.math.BigDecimal; + +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import com.ssafy.salmanhae.common.response.ApiResponse; +import com.ssafy.salmanhae.model.dto.map.MapViewportRequest; +import com.ssafy.salmanhae.model.dto.map.MapViewportResponse; +import com.ssafy.salmanhae.model.dto.property.PropertySearchCriteria; +import com.ssafy.salmanhae.model.dto.property.PropertyType; +import com.ssafy.salmanhae.model.dto.property.TransactionType; +import com.ssafy.salmanhae.service.map.MapViewportService; + +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; + +@RestController +@RequestMapping("/api/v1/map") +@Validated +public class MapViewportController { + + private final MapViewportService mapViewportService; + + public MapViewportController(MapViewportService mapViewportService) { + this.mapViewportService = mapViewportService; + } + + @GetMapping("/viewport") + public ApiResponse getViewport( + @RequestParam BigDecimal west, + @RequestParam BigDecimal east, + @RequestParam BigDecimal south, + @RequestParam BigDecimal north, + @RequestParam @Min(0) @Max(21) Integer zoom, + @RequestParam(required = false) TransactionType transactionType, + @RequestParam(required = false) PropertyType propertyType, + @RequestParam(required = false) Long minDeposit, + @RequestParam(required = false) Long maxDeposit, + @RequestParam(required = false) Long minPrice, + @RequestParam(required = false) Long maxPrice, + @RequestParam(required = false) Integer clusterThreshold + ) { + PropertySearchCriteria criteria = new PropertySearchCriteria( + west, + east, + south, + north, + transactionType, + propertyType, + minDeposit, + maxDeposit, + minPrice, + maxPrice + ); + criteria.validateBounds(); + return ApiResponse.ok(mapViewportService.getViewport(new MapViewportRequest(criteria, zoom, clusterThreshold))); + } +} diff --git a/backend/src/main/java/com/ssafy/salmanhae/model/dto/map/MapViewportItemResponse.java b/backend/src/main/java/com/ssafy/salmanhae/model/dto/map/MapViewportItemResponse.java new file mode 100644 index 0000000..957cf08 --- /dev/null +++ b/backend/src/main/java/com/ssafy/salmanhae/model/dto/map/MapViewportItemResponse.java @@ -0,0 +1,6 @@ +package com.ssafy.salmanhae.model.dto.map; + +public sealed interface MapViewportItemResponse permits RegionAverageViewportItem, PropertyClusterViewportItem, PropertyViewportItem { + + MapViewportItemType type(); +} diff --git a/backend/src/main/java/com/ssafy/salmanhae/model/dto/map/MapViewportItemType.java b/backend/src/main/java/com/ssafy/salmanhae/model/dto/map/MapViewportItemType.java new file mode 100644 index 0000000..bb46783 --- /dev/null +++ b/backend/src/main/java/com/ssafy/salmanhae/model/dto/map/MapViewportItemType.java @@ -0,0 +1,7 @@ +package com.ssafy.salmanhae.model.dto.map; + +public enum MapViewportItemType { + REGION_AVG, + CLUSTER, + PROPERTY +} diff --git a/backend/src/main/java/com/ssafy/salmanhae/model/dto/map/MapViewportMode.java b/backend/src/main/java/com/ssafy/salmanhae/model/dto/map/MapViewportMode.java new file mode 100644 index 0000000..0645e81 --- /dev/null +++ b/backend/src/main/java/com/ssafy/salmanhae/model/dto/map/MapViewportMode.java @@ -0,0 +1,8 @@ +package com.ssafy.salmanhae.model.dto.map; + +public enum MapViewportMode { + SIGUNGU_AVG, + DONG_AVG, + PROPERTY_CLUSTER, + PROPERTY_MARKER +} diff --git a/backend/src/main/java/com/ssafy/salmanhae/model/dto/map/MapViewportRequest.java b/backend/src/main/java/com/ssafy/salmanhae/model/dto/map/MapViewportRequest.java new file mode 100644 index 0000000..497d790 --- /dev/null +++ b/backend/src/main/java/com/ssafy/salmanhae/model/dto/map/MapViewportRequest.java @@ -0,0 +1,10 @@ +package com.ssafy.salmanhae.model.dto.map; + +import com.ssafy.salmanhae.model.dto.property.PropertySearchCriteria; + +public record MapViewportRequest( + PropertySearchCriteria criteria, + int zoom, + Integer clusterThreshold +) { +} diff --git a/backend/src/main/java/com/ssafy/salmanhae/model/dto/map/MapViewportResponse.java b/backend/src/main/java/com/ssafy/salmanhae/model/dto/map/MapViewportResponse.java new file mode 100644 index 0000000..a58ee65 --- /dev/null +++ b/backend/src/main/java/com/ssafy/salmanhae/model/dto/map/MapViewportResponse.java @@ -0,0 +1,14 @@ +package com.ssafy.salmanhae.model.dto.map; + +import java.util.List; + +public record MapViewportResponse( + MapViewportMode mode, + List items, + int totalCount +) { + + public static MapViewportResponse empty(MapViewportMode mode) { + return new MapViewportResponse(mode, List.of(), 0); + } +} diff --git a/backend/src/main/java/com/ssafy/salmanhae/model/dto/map/PropertyClusterViewportItem.java b/backend/src/main/java/com/ssafy/salmanhae/model/dto/map/PropertyClusterViewportItem.java new file mode 100644 index 0000000..1065e51 --- /dev/null +++ b/backend/src/main/java/com/ssafy/salmanhae/model/dto/map/PropertyClusterViewportItem.java @@ -0,0 +1,16 @@ +package com.ssafy.salmanhae.model.dto.map; + +import java.math.BigDecimal; + +public record PropertyClusterViewportItem( + MapViewportItemType type, + String clusterId, + int count, + BigDecimal latitude, + BigDecimal longitude, + Integer radiusM, + Long avgDeposit, + Long avgMonthlyRent, + Long avgSalePrice +) implements MapViewportItemResponse { +} diff --git a/backend/src/main/java/com/ssafy/salmanhae/model/dto/map/PropertyViewportItem.java b/backend/src/main/java/com/ssafy/salmanhae/model/dto/map/PropertyViewportItem.java new file mode 100644 index 0000000..d31ec12 --- /dev/null +++ b/backend/src/main/java/com/ssafy/salmanhae/model/dto/map/PropertyViewportItem.java @@ -0,0 +1,19 @@ +package com.ssafy.salmanhae.model.dto.map; + +import java.math.BigDecimal; + +import com.ssafy.salmanhae.model.dto.property.TransactionType; + +public record PropertyViewportItem( + MapViewportItemType type, + Long id, + String title, + TransactionType transactionType, + Long deposit, + Long monthlyRent, + Long price, + BigDecimal areaM2, + BigDecimal latitude, + BigDecimal longitude +) implements MapViewportItemResponse { +} diff --git a/backend/src/main/java/com/ssafy/salmanhae/model/dto/map/RegionAverageViewportItem.java b/backend/src/main/java/com/ssafy/salmanhae/model/dto/map/RegionAverageViewportItem.java new file mode 100644 index 0000000..49fd417 --- /dev/null +++ b/backend/src/main/java/com/ssafy/salmanhae/model/dto/map/RegionAverageViewportItem.java @@ -0,0 +1,17 @@ +package com.ssafy.salmanhae.model.dto.map; + +import java.math.BigDecimal; + +public record RegionAverageViewportItem( + MapViewportItemType type, + String regionLevel, + String regionCode, + String regionName, + Long avgDeposit, + Long avgMonthlyRent, + Long avgSalePrice, + Integer transactionCount, + BigDecimal latitude, + BigDecimal longitude +) implements MapViewportItemResponse { +} diff --git a/backend/src/main/java/com/ssafy/salmanhae/service/map/MapViewportService.java b/backend/src/main/java/com/ssafy/salmanhae/service/map/MapViewportService.java new file mode 100644 index 0000000..6a85742 --- /dev/null +++ b/backend/src/main/java/com/ssafy/salmanhae/service/map/MapViewportService.java @@ -0,0 +1,9 @@ +package com.ssafy.salmanhae.service.map; + +import com.ssafy.salmanhae.model.dto.map.MapViewportRequest; +import com.ssafy.salmanhae.model.dto.map.MapViewportResponse; + +public interface MapViewportService { + + MapViewportResponse getViewport(MapViewportRequest request); +} diff --git a/backend/src/main/java/com/ssafy/salmanhae/service/map/MapViewportServiceImpl.java b/backend/src/main/java/com/ssafy/salmanhae/service/map/MapViewportServiceImpl.java new file mode 100644 index 0000000..26777c5 --- /dev/null +++ b/backend/src/main/java/com/ssafy/salmanhae/service/map/MapViewportServiceImpl.java @@ -0,0 +1,35 @@ +package com.ssafy.salmanhae.service.map; + +import org.springframework.stereotype.Service; + +import com.ssafy.salmanhae.common.exception.ApiException; +import com.ssafy.salmanhae.common.exception.ErrorCode; +import com.ssafy.salmanhae.model.dto.map.MapViewportMode; +import com.ssafy.salmanhae.model.dto.map.MapViewportRequest; +import com.ssafy.salmanhae.model.dto.map.MapViewportResponse; + +@Service +public class MapViewportServiceImpl implements MapViewportService { + + @Override + public MapViewportResponse getViewport(MapViewportRequest request) { + if (request == null || request.criteria() == null) { + throw new ApiException(ErrorCode.INVALID_REQUEST); + } + request.criteria().validateBounds(); + return MapViewportResponse.empty(resolveMode(request.zoom())); + } + + private MapViewportMode resolveMode(int zoom) { + if (zoom <= 11) { + return MapViewportMode.SIGUNGU_AVG; + } + if (zoom <= 13) { + return MapViewportMode.DONG_AVG; + } + if (zoom <= 15) { + return MapViewportMode.PROPERTY_CLUSTER; + } + return MapViewportMode.PROPERTY_MARKER; + } +} diff --git a/backend/src/test/java/com/ssafy/salmanhae/controller/map/MapViewportControllerTest.java b/backend/src/test/java/com/ssafy/salmanhae/controller/map/MapViewportControllerTest.java new file mode 100644 index 0000000..a5ac23c --- /dev/null +++ b/backend/src/test/java/com/ssafy/salmanhae/controller/map/MapViewportControllerTest.java @@ -0,0 +1,90 @@ +package com.ssafy.salmanhae.controller.map; + +import static org.hamcrest.Matchers.hasSize; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; + +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("test") +class MapViewportControllerTest { + + @Autowired + private MockMvc mockMvc; + + @Test + void getViewportIsPublicAndReturnsSigunguModeAtWideZoom() throws Exception { + mockMvc.perform(baseViewportRequest().param("zoom", "11")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.message").value("OK")) + .andExpect(jsonPath("$.data.mode").value("SIGUNGU_AVG")) + .andExpect(jsonPath("$.data.totalCount").value(0)) + .andExpect(jsonPath("$.data.items", hasSize(0))); + } + + @Test + void getViewportReturnsDongModeAtMiddleZoom() throws Exception { + mockMvc.perform(baseViewportRequest().param("zoom", "12")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.mode").value("DONG_AVG")); + + mockMvc.perform(baseViewportRequest().param("zoom", "13")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.mode").value("DONG_AVG")); + } + + @Test + void getViewportReturnsClusterModeBeforeDetailedMarkers() throws Exception { + mockMvc.perform(baseViewportRequest().param("zoom", "14")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.mode").value("PROPERTY_CLUSTER")); + + mockMvc.perform(baseViewportRequest().param("zoom", "15")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.mode").value("PROPERTY_CLUSTER")); + } + + @Test + void getViewportReturnsPropertyMarkerModeAtDetailedZoom() throws Exception { + mockMvc.perform(baseViewportRequest().param("zoom", "16")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.mode").value("PROPERTY_MARKER")); + } + + @Test + void getViewportRejectsInvalidBounds() throws Exception { + mockMvc.perform(get("/api/v1/map/viewport") + .param("west", "127.00") + .param("east", "126.00") + .param("south", "37.46") + .param("north", "37.48") + .param("zoom", "12")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("INVALID_BOUNDS")) + .andExpect(jsonPath("$.status").value(400)); + } + + @Test + void getViewportRejectsMissingZoom() throws Exception { + mockMvc.perform(baseViewportRequest()) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("INVALID_REQUEST")) + .andExpect(jsonPath("$.status").value(400)); + } + + private org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder baseViewportRequest() { + return get("/api/v1/map/viewport") + .param("west", "126.93") + .param("east", "126.94") + .param("south", "37.46") + .param("north", "37.48"); + } +} diff --git a/backend/src/test/resources/.env.properties b/backend/src/test/resources/.env.properties new file mode 100644 index 0000000..12f6cb2 --- /dev/null +++ b/backend/src/test/resources/.env.properties @@ -0,0 +1,2 @@ +filename=.env.test +ignoreIfMissing=true diff --git a/backend/src/test/resources/application-test.properties b/backend/src/test/resources/application-test.properties index 3192124..228592d 100644 --- a/backend/src/test/resources/application-test.properties +++ b/backend/src/test/resources/application-test.properties @@ -14,3 +14,4 @@ ai.agent.base-url=http://localhost:8000 ai.agent.internal-api-key=change-me ai.agent.connect-timeout-ms=2000 ai.agent.read-timeout-ms=10000 +app.cors.allowed-origins=http://localhost:5173,http://127.0.0.1:5173 diff --git a/docs/08_API_SPEC.md b/docs/08_API_SPEC.md index 14a878a..786b961 100644 --- a/docs/08_API_SPEC.md +++ b/docs/08_API_SPEC.md @@ -165,7 +165,17 @@ GET /api/v1/map/viewport?west=126.91&east=127.02&south=37.45&north=37.55&zoom=12 | `SIDO_AVG` | 시/도 수준 | 시/도 실거래가 평균 | | `SIGUNGU_AVG` | 시/군/구 수준 | 시/군/구 실거래가 평균 | | `DONG_AVG` | 읍/면/동 수준 | 읍/면/동 실거래가 평균 | -| `PROPERTY_MARKER` | 상세 확대 | 개별 매물 또는 원형 클러스터 | +| `PROPERTY_CLUSTER` | 거리/밀집 수준 | 원형 클러스터 | +| `PROPERTY_MARKER` | 상세 확대 | 개별 매물 | + +초기 운영 threshold는 네이버지도 zoom 숫자를 기준으로 서버에서 결정합니다. + +| zoom | mode | 설명 | +| --- | --- | --- | +| `<= 11` | `SIGUNGU_AVG` | 시/군/구 평균 표시 | +| `12`-`13` | `DONG_AVG` | 읍/면/동 평균 표시 | +| `14`-`15` | `PROPERTY_CLUSTER` | 거리 수준 밀집 매물 클러스터 표시 | +| `>= 16` | `PROPERTY_MARKER` | 개별 매물 마커 표시 | **지역 평균 Response** ```json diff --git a/phases/map-viewport-zoom/phase1-map-viewport-contract.md b/phases/map-viewport-zoom/phase1-map-viewport-contract.md new file mode 100644 index 0000000..95357c8 --- /dev/null +++ b/phases/map-viewport-zoom/phase1-map-viewport-contract.md @@ -0,0 +1,27 @@ +# Phase 1: Map Viewport API Contract + +## Goal +Define the backend contract for zoom-aware map data so the frontend can stop loading every property in the current bounds. Add tests first for `/api/v1/map/viewport` mode selection, response shape, validation, and public access. + +## Files +- `backend/src/test/java/com/ssafy/salmanhae/controller/map/MapViewportControllerTest.java` - MockMvc contract tests for the new endpoint +- `backend/src/main/java/com/ssafy/salmanhae/controller/map/MapViewportController.java` - Controller that validates request params and delegates to service +- `backend/src/main/java/com/ssafy/salmanhae/service/map/MapViewportService.java` - Service boundary for zoom-aware map data +- `backend/src/main/java/com/ssafy/salmanhae/service/map/MapViewportServiceImpl.java` - Minimal implementation of zoom threshold routing +- `backend/src/main/java/com/ssafy/salmanhae/model/dto/map/*.java` - DTOs/enums for map viewport request and response items +- `docs/08_API_SPEC.md` - Document the cluster-specific viewport mode and zoom threshold policy + +## Done When +- [ ] `GET /api/v1/map/viewport` is publicly accessible without JWT. +- [ ] zoom `11` returns `SIGUNGU_AVG`, zoom `12`-`13` returns `DONG_AVG`, zoom `14`-`15` returns `PROPERTY_CLUSTER`, and zoom `16+` returns `PROPERTY_MARKER`. +- [ ] invalid bounds or missing zoom returns `400 INVALID_REQUEST` or the existing bounds validation error. +- [ ] Backend tests for the controller contract pass. +- [ ] API spec describes the implemented threshold policy. + +## Architecture Rules +- Business logic belongs in Service classes; the Controller only validates input and delegates. +- API response shape must match `docs/08_API_SPEC.md` camelCase JSON. +- F-1 is public; it must not require Spring Security JWT. + +## Implementation Instructions +Start with failing MockMvc tests for the endpoint. Implement only enough backend structure to route by zoom and return the documented response envelope, using empty `items` for phase1 if DAO queries are not ready yet. diff --git a/phases/map-viewport-zoom/phase1.status.json b/phases/map-viewport-zoom/phase1.status.json new file mode 100644 index 0000000..ec5d529 --- /dev/null +++ b/phases/map-viewport-zoom/phase1.status.json @@ -0,0 +1,8 @@ +{ + "status": "completed", + "phase": "phase1-map-viewport-contract.md", + "issue_number": 0, + "timestamp": "2026-06-24T15:07:10+09:00", + "detail": "Implemented map viewport API contract, zoom threshold routing, test dotenv isolation, and API spec update. Verified with backend full test suite.", + "runner": "codex" +} diff --git a/phases/map-viewport-zoom/phase2-region-and-cluster-query.md b/phases/map-viewport-zoom/phase2-region-and-cluster-query.md new file mode 100644 index 0000000..097b37a --- /dev/null +++ b/phases/map-viewport-zoom/phase2-region-and-cluster-query.md @@ -0,0 +1,26 @@ +# Phase 2: Region And Cluster Query + +## Goal +Populate `/api/v1/map/viewport` with real region-average and cluster data from PostgreSQL without sending thousands of properties to the client. + +## Files +- `backend/src/test/java/com/ssafy/salmanhae/service/map/MapViewportServiceTest.java` - Service tests for mode-specific query behavior +- `backend/src/main/java/com/ssafy/salmanhae/model/dao/property/PropertyDao.java` - DAO methods for region averages, property clusters, and detailed markers +- `backend/src/main/java/com/ssafy/salmanhae/model/dao/property/JdbcPropertyDao.java` - SQL implementations using bounds and filters +- `backend/src/main/java/com/ssafy/salmanhae/service/map/MapViewportServiceImpl.java` - Compose mode-specific items and enforce maximum payload sizes +- `backend/src/test/resources/schema.sql` - Test schema adjustments only if needed +- `backend/src/test/resources/data.sql` - Test fixtures for region and cluster data + +## Done When +- [ ] region modes return `REGION_AVG` items with region labels, average prices, transaction counts, and usable marker coordinates. +- [ ] zoom `14`-`15` returns `CLUSTER` items grouped by a deterministic coordinate grid. +- [ ] zoom `16+` returns individual `PROPERTY` items, with clustering or a cap applied if the bounds are still too dense. +- [ ] Existing `/api/v1/properties` behavior is unchanged. + +## Architecture Rules +- Public API requests must query stored DB data only; no external public API call is made on request path. +- Query and aggregation logic stays behind DAO and Service boundaries. +- Avoid schema changes unless the existing `region_price_stat` data cannot produce reliable coordinates. + +## Implementation Instructions +Prefer deriving region marker coordinates from active `properties` grouped by `sido/sigungu/dong` so this phase can avoid a production migration. If that proves unreliable, stop and document the required `region_price_stat` coordinate migration before implementing it. diff --git a/phases/map-viewport-zoom/phase3-frontend-map-viewport-api.md b/phases/map-viewport-zoom/phase3-frontend-map-viewport-api.md new file mode 100644 index 0000000..d5f1568 --- /dev/null +++ b/phases/map-viewport-zoom/phase3-frontend-map-viewport-api.md @@ -0,0 +1,23 @@ +# Phase 3: Frontend Map Viewport API + +## Goal +Switch the map frontend from loading all properties with `/api/v1/properties` to loading zoom-aware viewport items with `/api/v1/map/viewport`. + +## Files +- `frontend/src/api/*` - Add a map viewport API function using current bounds, zoom, and filters +- `frontend/src/stores/*` - Store viewport mode, viewport items, loading/error state, and selected property state +- `frontend/src/components/map/*` - Wire map idle/zoom/filter changes to the new API call + +## Done When +- [ ] map movement and zoom call `/api/v1/map/viewport` instead of loading every property. +- [ ] requests are debounced or triggered on map idle to avoid excessive network calls. +- [ ] property filters are preserved in viewport requests. +- [ ] API errors show the existing map error UI without breaking the page. + +## Architecture Rules +- Frontend calls Spring Boot REST APIs only. +- State shared between map, list, and chat belongs in Pinia stores. +- UI should keep the existing map/chat layout and not introduce a landing page or unrelated redesign. + +## Implementation Instructions +Keep the existing `/api/v1/properties` API available for legacy/detail flows, but make the visible map use the viewport API. Do not call FastAPI from the frontend. diff --git a/phases/map-viewport-zoom/phase4-frontend-zoom-rendering.md b/phases/map-viewport-zoom/phase4-frontend-zoom-rendering.md new file mode 100644 index 0000000..de47cc1 --- /dev/null +++ b/phases/map-viewport-zoom/phase4-frontend-zoom-rendering.md @@ -0,0 +1,23 @@ +# Phase 4: Frontend Zoom Rendering + +## Goal +Render different marker styles for region averages, clusters, and individual properties so the map stays readable at every zoom level. + +## Files +- `frontend/src/components/map/*` - Add marker rendering for `REGION_AVG`, `CLUSTER`, and `PROPERTY` +- `frontend/src/stores/*` - Maintain counts and selected item behavior per viewport mode +- `frontend/src/utils/*` - Add formatting helpers if existing helpers are insufficient + +## Done When +- [ ] broad zoom levels show region average markers instead of thousands of property labels. +- [ ] cluster mode shows compact cluster markers with count and representative price. +- [ ] detailed zoom shows individual property markers. +- [ ] side list behavior remains useful and does not try to render region-average items as property cards. + +## Architecture Rules +- UI follows `docs/04_UI_GUIDE.md`: region averages and clusters should reduce clutter and keep the map dominant. +- Text must not overflow markers or controls on desktop/mobile viewports. +- Components should remain focused and avoid card nesting or unrelated visual changes. + +## Implementation Instructions +Use the backend `mode` and item `type` fields to decide rendering. Keep marker labels short and stable; long names should be omitted or truncated in markers and shown in side panels if needed. diff --git a/phases/map-viewport-zoom/phase5-qa-docs-deploy.md b/phases/map-viewport-zoom/phase5-qa-docs-deploy.md new file mode 100644 index 0000000..aeb450b --- /dev/null +++ b/phases/map-viewport-zoom/phase5-qa-docs-deploy.md @@ -0,0 +1,23 @@ +# Phase 5: QA Docs Deploy + +## Goal +Verify the zoom-aware map flow end to end, update documentation if the implemented API differs, and deploy through the existing CI/CD path. + +## Files +- `docs/08_API_SPEC.md` - Update only if the implemented response differs from the current contract +- `docs/07_DOMAIN_MODEL.md` - Update only if a schema/model change was introduced +- `docs/04_UI_GUIDE.md` - Update only if UI behavior policy changed +- `.github/workflows/*` - No change expected; existing backend/frontend deployment paths should be used + +## Done When +- [ ] backend tests pass. +- [ ] frontend tests/build pass. +- [ ] deployed map no longer attempts to render thousands of markers at broad zoom. +- [ ] Cloud Run and Vercel deployments use the existing CI/CD flow without secret changes. + +## Architecture Rules +- API/domain documentation must stay synchronized with implemented response or schema changes. +- Deployment uses Cloud Run for Spring Boot and Vercel for frontend; FastAPI is not part of this F-1 map rendering change. + +## Implementation Instructions +Capture before/after behavior on deployed or local production build. If only backend and frontend code changed, rely on the existing GitHub Actions and Vercel integration after merge to `develop`. From c9c54e28b43b11ac7f30115f7cd9d9dd76ec393b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=9A=A9=ED=9C=98?= Date: Wed, 24 Jun 2026 15:31:46 +0900 Subject: [PATCH 2/2] =?UTF-8?q?test(property):=20=EC=A7=80=EB=8F=84=20?= =?UTF-8?q?=EB=B7=B0=ED=8F=AC=ED=8A=B8=20=EA=B3=84=EC=95=BD=20=EB=A6=AC?= =?UTF-8?q?=EB=B7=B0=20=EB=B0=98=EC=98=81=20(#53)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../map/MapViewportControllerTest.java | 16 +++++++++ docs/08_API_SPEC.md | 36 +++++++++++++------ .../phase1-map-viewport-contract.md | 10 +++--- 3 files changed, 46 insertions(+), 16 deletions(-) diff --git a/backend/src/test/java/com/ssafy/salmanhae/controller/map/MapViewportControllerTest.java b/backend/src/test/java/com/ssafy/salmanhae/controller/map/MapViewportControllerTest.java index a5ac23c..d3b86d8 100644 --- a/backend/src/test/java/com/ssafy/salmanhae/controller/map/MapViewportControllerTest.java +++ b/backend/src/test/java/com/ssafy/salmanhae/controller/map/MapViewportControllerTest.java @@ -22,6 +22,10 @@ class MapViewportControllerTest { @Test void getViewportIsPublicAndReturnsSigunguModeAtWideZoom() throws Exception { + mockMvc.perform(baseViewportRequest().param("zoom", "0")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.mode").value("SIGUNGU_AVG")); + mockMvc.perform(baseViewportRequest().param("zoom", "11")) .andExpect(status().isOk()) .andExpect(jsonPath("$.message").value("OK")) @@ -57,6 +61,10 @@ void getViewportReturnsPropertyMarkerModeAtDetailedZoom() throws Exception { mockMvc.perform(baseViewportRequest().param("zoom", "16")) .andExpect(status().isOk()) .andExpect(jsonPath("$.data.mode").value("PROPERTY_MARKER")); + + mockMvc.perform(baseViewportRequest().param("zoom", "21")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.mode").value("PROPERTY_MARKER")); } @Test @@ -80,6 +88,14 @@ void getViewportRejectsMissingZoom() throws Exception { .andExpect(jsonPath("$.status").value(400)); } + @Test + void getViewportRejectsOutOfRangeZoom() throws Exception { + mockMvc.perform(baseViewportRequest().param("zoom", "22")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("INVALID_REQUEST")) + .andExpect(jsonPath("$.status").value(400)); + } + private org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder baseViewportRequest() { return get("/api/v1/map/viewport") .param("west", "126.93") diff --git a/docs/08_API_SPEC.md b/docs/08_API_SPEC.md index 786b961..114d890 100644 --- a/docs/08_API_SPEC.md +++ b/docs/08_API_SPEC.md @@ -202,6 +202,29 @@ GET /api/v1/map/viewport?west=126.91&east=127.02&south=37.45&north=37.55&zoom=12 } ``` +**거리/밀집 Response** +```json +{ + "data": { + "mode": "PROPERTY_CLUSTER", + "items": [ + { + "type": "CLUSTER", + "clusterId": "cluster-37.471-126.938", + "count": 42, + "latitude": 37.47102, + "longitude": 126.93811, + "radiusM": 180, + "avgDeposit": 12000000, + "avgMonthlyRent": 580000 + } + ], + "totalCount": 1 + }, + "message": "OK" +} +``` + **상세 확대 Response** ```json { @@ -215,22 +238,13 @@ GET /api/v1/map/viewport?west=126.91&east=127.02&south=37.45&north=37.55&zoom=12 "transactionType": "MONTHLY_RENT", "deposit": 10000000, "monthlyRent": 550000, + "price": null, "areaM2": 22.5, "latitude": 37.470123, "longitude": 126.936456 - }, - { - "type": "CLUSTER", - "clusterId": "cluster-37.471-126.938", - "count": 42, - "latitude": 37.47102, - "longitude": 126.93811, - "radiusM": 180, - "avgDeposit": 12000000, - "avgMonthlyRent": 580000 } ], - "totalCount": 2 + "totalCount": 1 }, "message": "OK" } diff --git a/phases/map-viewport-zoom/phase1-map-viewport-contract.md b/phases/map-viewport-zoom/phase1-map-viewport-contract.md index 95357c8..de78abd 100644 --- a/phases/map-viewport-zoom/phase1-map-viewport-contract.md +++ b/phases/map-viewport-zoom/phase1-map-viewport-contract.md @@ -12,11 +12,11 @@ Define the backend contract for zoom-aware map data so the frontend can stop loa - `docs/08_API_SPEC.md` - Document the cluster-specific viewport mode and zoom threshold policy ## Done When -- [ ] `GET /api/v1/map/viewport` is publicly accessible without JWT. -- [ ] zoom `11` returns `SIGUNGU_AVG`, zoom `12`-`13` returns `DONG_AVG`, zoom `14`-`15` returns `PROPERTY_CLUSTER`, and zoom `16+` returns `PROPERTY_MARKER`. -- [ ] invalid bounds or missing zoom returns `400 INVALID_REQUEST` or the existing bounds validation error. -- [ ] Backend tests for the controller contract pass. -- [ ] API spec describes the implemented threshold policy. +- [x] `GET /api/v1/map/viewport` is publicly accessible without JWT. +- [x] zoom `11` returns `SIGUNGU_AVG`, zoom `12`-`13` returns `DONG_AVG`, zoom `14`-`15` returns `PROPERTY_CLUSTER`, and zoom `16+` returns `PROPERTY_MARKER`. +- [x] invalid bounds or missing zoom returns `400 INVALID_REQUEST` or the existing bounds validation error. +- [x] Backend tests for the controller contract pass. +- [x] API spec describes the implemented threshold policy. ## Architecture Rules - Business logic belongs in Service classes; the Controller only validates input and delegates.