From ad3de24b1e5a4ba0ad69abe9c5b175d0795f4b90 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=9A=A9=ED=9C=98?= Date: Thu, 25 Jun 2026 15:03:12 +0900 Subject: [PATCH 1/2] =?UTF-8?q?fix(property):=20=EA=B2=80=EC=83=89=20?= =?UTF-8?q?=EC=A1=B0=EA=B1=B4=20=EC=A7=80=EC=97=AD=20=EC=A7=91=EA=B3=84=20?= =?UTF-8?q?=EB=B0=98=EC=98=81=20(#94)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/map/MapViewportController.java | 4 ++- .../property/PropertyController.java | 6 ++-- .../model/dao/property/JdbcPropertyDao.java | 36 +++++++------------ .../dto/property/PropertySearchCriteria.java | 11 +++++- .../map/MapViewportControllerTest.java | 21 +++++++++++ .../property/PropertyControllerTest.java | 23 ++++++++++++ docs/08_API_SPEC.md | 8 ++++- frontend/src/api/properties.test.mjs | 2 ++ frontend/src/store/mapStore.js | 14 ++++++-- frontend/src/utils/mapViewport.js | 4 +-- frontend/src/utils/mapViewport.test.mjs | 16 +++++++++ frontend/src/views/MapExplorer.vue | 6 +++- 12 files changed, 116 insertions(+), 35 deletions(-) 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 index e5163bd..c943252 100644 --- a/backend/src/main/java/com/ssafy/salmanhae/controller/map/MapViewportController.java +++ b/backend/src/main/java/com/ssafy/salmanhae/controller/map/MapViewportController.java @@ -43,6 +43,7 @@ public ApiResponse getViewport( @RequestParam(required = false) Long maxDeposit, @RequestParam(required = false) Long minPrice, @RequestParam(required = false) Long maxPrice, + @RequestParam(required = false) String keyword, @RequestParam(required = false) Integer clusterThreshold ) { PropertySearchCriteria criteria = new PropertySearchCriteria( @@ -55,7 +56,8 @@ public ApiResponse getViewport( minDeposit, maxDeposit, minPrice, - maxPrice + maxPrice, + keyword ); criteria.validateBounds(); return ApiResponse.ok(mapViewportService.getViewport(new MapViewportRequest(criteria, zoom, clusterThreshold))); diff --git a/backend/src/main/java/com/ssafy/salmanhae/controller/property/PropertyController.java b/backend/src/main/java/com/ssafy/salmanhae/controller/property/PropertyController.java index dd28faa..bbea5f1 100644 --- a/backend/src/main/java/com/ssafy/salmanhae/controller/property/PropertyController.java +++ b/backend/src/main/java/com/ssafy/salmanhae/controller/property/PropertyController.java @@ -47,7 +47,8 @@ public ApiResponse> searchProperties( @RequestParam(required = false) Long minDeposit, @RequestParam(required = false) Long maxDeposit, @RequestParam(required = false) Long minPrice, - @RequestParam(required = false) Long maxPrice + @RequestParam(required = false) Long maxPrice, + @RequestParam(required = false) String keyword ) { PropertySearchCriteria criteria = new PropertySearchCriteria( west, @@ -59,7 +60,8 @@ public ApiResponse> searchProperties( minDeposit, maxDeposit, minPrice, - maxPrice + maxPrice, + keyword ); criteria.validateBounds(); List properties = propertyService.searchProperties(criteria); diff --git a/backend/src/main/java/com/ssafy/salmanhae/model/dao/property/JdbcPropertyDao.java b/backend/src/main/java/com/ssafy/salmanhae/model/dao/property/JdbcPropertyDao.java index d6ab9ef..324a8e9 100644 --- a/backend/src/main/java/com/ssafy/salmanhae/model/dao/property/JdbcPropertyDao.java +++ b/backend/src/main/java/com/ssafy/salmanhae/model/dao/property/JdbcPropertyDao.java @@ -60,30 +60,7 @@ public List findInBounds(PropertySearchCriteria criteria) { AND latitude BETWEEN :south AND :north """.formatted(PROPERTY_COLUMNS)); - if (criteria.transactionType() != null) { - sql.append(" AND transaction_type = :transactionType"); - params.put("transactionType", criteria.transactionType().name()); - } - if (criteria.propertyType() != null) { - sql.append(" AND property_type = :propertyType"); - params.put("propertyType", criteria.propertyType().name()); - } - if (criteria.minDeposit() != null) { - sql.append(" AND deposit >= :minDeposit"); - params.put("minDeposit", criteria.minDeposit()); - } - if (criteria.maxDeposit() != null) { - sql.append(" AND deposit <= :maxDeposit"); - params.put("maxDeposit", criteria.maxDeposit()); - } - if (criteria.minPrice() != null) { - sql.append(" AND price >= :minPrice"); - params.put("minPrice", criteria.minPrice()); - } - if (criteria.maxPrice() != null) { - sql.append(" AND price <= :maxPrice"); - params.put("maxPrice", criteria.maxPrice()); - } + appendPropertyFilters(sql, params, "", criteria); sql.append(" ORDER BY id ASC"); return jdbcTemplate.query(sql.toString(), params, propertyRowMapper()); @@ -679,6 +656,17 @@ private void appendPropertyFilters( sql.append(" AND ").append(prefix).append("price <= :maxPrice"); params.put("maxPrice", criteria.maxPrice()); } + if (criteria.hasKeyword()) { + sql.append(""" + AND ( + LOWER(COALESCE(%stitle, '')) LIKE :keywordPattern + OR LOWER(COALESCE(%sbuilding_name, '')) LIKE :keywordPattern + OR LOWER(COALESCE(%saddress, '')) LIKE :keywordPattern + OR LOWER(COALESCE(%sroad_address, '')) LIKE :keywordPattern + ) + """.formatted(prefix, prefix, prefix, prefix)); + params.put("keywordPattern", "%" + criteria.normalizedKeyword().toLowerCase(Locale.ROOT) + "%"); + } } private void appendRegionStatFilters( diff --git a/backend/src/main/java/com/ssafy/salmanhae/model/dto/property/PropertySearchCriteria.java b/backend/src/main/java/com/ssafy/salmanhae/model/dto/property/PropertySearchCriteria.java index 5b21398..15fb927 100644 --- a/backend/src/main/java/com/ssafy/salmanhae/model/dto/property/PropertySearchCriteria.java +++ b/backend/src/main/java/com/ssafy/salmanhae/model/dto/property/PropertySearchCriteria.java @@ -15,7 +15,8 @@ public record PropertySearchCriteria( Long minDeposit, Long maxDeposit, Long minPrice, - Long maxPrice + Long maxPrice, + String keyword ) { private static final BigDecimal MIN_LONGITUDE = BigDecimal.valueOf(-180); @@ -51,4 +52,12 @@ public void validateBounds() { private boolean isNegative(Long value) { return value != null && value < 0; } + + public boolean hasKeyword() { + return keyword != null && !keyword.isBlank(); + } + + public String normalizedKeyword() { + return keyword == null ? "" : keyword.trim(); + } } 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 fcc272c..941300c 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 @@ -102,6 +102,27 @@ void getViewportRegionAverageUsesOnlyStatsMatchingFilteredVisiblePropertyCombina .andExpect(jsonPath("$.data.items[0].transactionCount").value(1)); } + @Test + void getViewportRegionAverageAppliesKeywordToVisibleProperties() throws Exception { + mockMvc.perform(baseViewportRequest() + .param("zoom", "10") + .param("keyword", "매매")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.mode").value("SIGUNGU_AVG")) + .andExpect(jsonPath("$.data.totalCount").value(1)) + .andExpect(jsonPath("$.data.items[0].regionLevel").value("SIGUNGU")) + .andExpect(jsonPath("$.data.items[0].avgSalePrice").value(720000000)) + .andExpect(jsonPath("$.data.items[0].transactionCount").value(1)); + + mockMvc.perform(baseViewportRequest() + .param("zoom", "10") + .param("keyword", "검색결과없음")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.mode").value("SIGUNGU_AVG")) + .andExpect(jsonPath("$.data.totalCount").value(0)) + .andExpect(jsonPath("$.data.items", hasSize(0))); + } + @Test void getViewportReturnsClusterModeBeforeDetailedMarkers() throws Exception { mockMvc.perform(baseViewportRequest().param("zoom", "14")) diff --git a/backend/src/test/java/com/ssafy/salmanhae/controller/property/PropertyControllerTest.java b/backend/src/test/java/com/ssafy/salmanhae/controller/property/PropertyControllerTest.java index eb1a56e..8caebe7 100644 --- a/backend/src/test/java/com/ssafy/salmanhae/controller/property/PropertyControllerTest.java +++ b/backend/src/test/java/com/ssafy/salmanhae/controller/property/PropertyControllerTest.java @@ -68,6 +68,29 @@ void searchPropertiesAppliesDepositFilters() throws Exception { .andExpect(jsonPath("$.data.items[0].deposit").value(10000000)); } + @Test + void searchPropertiesAppliesKeywordFilter() throws Exception { + mockMvc.perform(get("/api/v1/properties") + .param("west", "126.93") + .param("east", "126.94") + .param("south", "37.46") + .param("north", "37.48") + .param("keyword", "매매")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.totalCount").value(1)) + .andExpect(jsonPath("$.data.items[0].id").value(2)); + + mockMvc.perform(get("/api/v1/properties") + .param("west", "126.93") + .param("east", "126.94") + .param("south", "37.46") + .param("north", "37.48") + .param("keyword", "검색결과없음")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.totalCount").value(0)) + .andExpect(jsonPath("$.data.items", hasSize(0))); + } + @Test void searchPropertiesRejectsInvalidBounds() throws Exception { mockMvc.perform(get("/api/v1/properties") diff --git a/docs/08_API_SPEC.md b/docs/08_API_SPEC.md index 63a5712..00f69dc 100644 --- a/docs/08_API_SPEC.md +++ b/docs/08_API_SPEC.md @@ -104,6 +104,7 @@ GET /api/v1/properties?west=126.91&east=127.02&south=37.45&north=37.55 | `maxDeposit` | — | 최대 보증금 (원) | | `minPrice` | — | 최소 매매가 (원) | | `maxPrice` | — | 최대 매매가 (원) | +| `keyword` | — | 건물명 또는 주소 검색어 | F-1 MVP에서는 실거래가 건물 anchor 기반 `MVP_SYNTHETIC` 더미 매물을 조회합니다. 운영 단계에서는 제휴 피드 또는 합법적으로 확보한 매물 데이터를 `properties`에 저장한 뒤 같은 API로 조회합니다. @@ -156,6 +157,11 @@ GET /api/v1/map/viewport?west=126.91&east=127.02&south=37.45&north=37.55&zoom=10 | `zoom` | ✅ | 네이버지도 현재 zoom | | `transactionType` | — | `MONTHLY_RENT` / `JEONSE` / `SALE` | | `propertyType` | — | `ONE_ROOM` / `OFFICETEL` / `APARTMENT` / `VILLA` / `MULTI_FAMILY` | +| `minDeposit` | — | 최소 보증금 (원) | +| `maxDeposit` | — | 최대 보증금 (원) | +| `minPrice` | — | 최소 매매가 (원) | +| `maxPrice` | — | 최대 매매가 (원) | +| `keyword` | — | 건물명 또는 주소 검색어 | | `clusterThreshold` | — | 매물 클러스터링 기준 수. 기본값은 서버 설정 사용 | **표시 모드** @@ -168,7 +174,7 @@ GET /api/v1/map/viewport?west=126.91&east=127.02&south=37.45&north=37.55&zoom=10 | `PROPERTY_CLUSTER` | 거리/밀집 수준 | 원형 클러스터 | | `PROPERTY_MARKER` | 상세 확대 | 개별 매물 | -`SIDO_AVG`, `SIGUNGU_AVG`, `DONG_AVG`는 지도에 표시된 지역 마커를 확대했을 때 실제 매물이 비지 않도록 현재 bounds 안의 활성 매물을 행정구역별로 직접 집계합니다. 월세 대표 가격은 보증금이 아니라 `avgMonthlyRent` 기준으로 표시합니다. +`SIDO_AVG`, `SIGUNGU_AVG`, `DONG_AVG`는 지도에 표시된 지역 마커를 확대했을 때 실제 매물이 비지 않도록 현재 bounds 안의 활성 매물을 행정구역별로 직접 집계합니다. `transactionType`, `propertyType`, 가격 범위, `keyword` 조건도 같은 방식으로 적용하며 조건에 맞는 매물이 없으면 해당 지역은 반환하지 않습니다. 월세 대표 가격은 보증금이 아니라 `avgMonthlyRent` 기준으로 표시합니다. 프론트엔드는 검색 조건이 활성화된 상태에서 지역 마커를 가격 대신 조건에 맞는 매물 수로 표시합니다. 초기 운영 threshold는 네이버지도 zoom 숫자를 기준으로 서버에서 결정합니다. diff --git a/frontend/src/api/properties.test.mjs b/frontend/src/api/properties.test.mjs index 54f39f7..e33df98 100644 --- a/frontend/src/api/properties.test.mjs +++ b/frontend/src/api/properties.test.mjs @@ -29,6 +29,7 @@ test('fetchMapViewport calls zoom-aware viewport endpoint with cleaned params', south: 37.46, north: 37.48, zoom: 14, + keyword: '그린빌', transactionType: 'MONTHLY_RENT', propertyType: '', minDeposit: null, @@ -45,6 +46,7 @@ test('fetchMapViewport calls zoom-aware viewport endpoint with cleaned params', south: 37.46, north: 37.48, zoom: 14, + keyword: '그린빌', transactionType: 'MONTHLY_RENT' } }) diff --git a/frontend/src/store/mapStore.js b/frontend/src/store/mapStore.js index 8a4facf..e6d97e7 100644 --- a/frontend/src/store/mapStore.js +++ b/frontend/src/store/mapStore.js @@ -79,9 +79,13 @@ export default defineStore('map', { const keyword = state.searchKeyword.trim().toLowerCase() if (!keyword) return state.properties return state.properties.filter((property) => { - const title = property.title || property.buildingName || '' - const address = property.address || property.roadAddress || '' - return title.toLowerCase().includes(keyword) || address.toLowerCase().includes(keyword) + const searchableText = [ + property.title, + property.buildingName, + property.address, + property.roadAddress + ].filter(Boolean).join(' ').toLowerCase() + return searchableText.includes(keyword) }) }, regionStatus(state) { @@ -91,6 +95,9 @@ export default defineStore('map', { }, hasActiveFilters(state) { return Object.values(state.filters).some((value) => value !== '') + }, + hasActiveSearchConditions(state) { + return state.searchKeyword.trim() !== '' || Object.values(state.filters).some((value) => value !== '') } }, actions: { @@ -153,6 +160,7 @@ export default defineStore('map', { const data = await fetchMapViewport({ ...this.bounds, zoom: this.zoom, + keyword: this.searchKeyword, ...this.filters }) diff --git a/frontend/src/utils/mapViewport.js b/frontend/src/utils/mapViewport.js index 379cc71..f31096e 100644 --- a/frontend/src/utils/mapViewport.js +++ b/frontend/src/utils/mapViewport.js @@ -76,14 +76,14 @@ export const viewportMarkerAnchor = (item) => { return { x: 42, y: 44 } } -export const viewportMarkerLabel = (item, { formatWons, transactionLabel } = {}) => { +export const viewportMarkerLabel = (item, { formatWons, transactionLabel, showCount = false } = {}) => { const formatPrice = formatWons || ((value) => String(value ?? '-')) if (item?.type === VIEWPORT_ITEM_TYPES.REGION_AVG) { const regionName = item.regionName || item.regionCode || '지역' return { eyebrow: regionLevelLabel(item.regionLevel), title: regionName, - value: formatPrice(getPrimaryPriceValue(item)) + value: showCount ? `${Number(item.transactionCount || 0).toLocaleString()}개` : formatPrice(getPrimaryPriceValue(item)) } } diff --git a/frontend/src/utils/mapViewport.test.mjs b/frontend/src/utils/mapViewport.test.mjs index 0f84cab..87c062d 100644 --- a/frontend/src/utils/mapViewport.test.mjs +++ b/frontend/src/utils/mapViewport.test.mjs @@ -40,6 +40,22 @@ test('region average marker uses region metadata and representative price', () = }) }) +test('region marker can show matching property count for search results', () => { + const item = { + type: 'REGION_AVG', + regionLevel: 'SIGUNGU', + regionName: '관악구', + avgMonthlyRent: 620000, + transactionCount: 3 + } + + assert.deepEqual(viewportMarkerLabel(item, { formatWons, showCount: true }), { + eyebrow: '시/군/구', + title: '관악구', + value: '3개' + }) +}) + test('monthly rent viewport items use monthly rent as representative price', () => { assert.equal(getPrimaryPriceValue({ type: 'PROPERTY', diff --git a/frontend/src/views/MapExplorer.vue b/frontend/src/views/MapExplorer.vue index 2bc23e2..62c9a91 100644 --- a/frontend/src/views/MapExplorer.vue +++ b/frontend/src/views/MapExplorer.vue @@ -434,7 +434,11 @@ const propertyMarkerContent = (property, isSelected) => { } const regionMarkerContent = (item) => { - const label = viewportMarkerLabel(item, { formatWons, transactionLabel }) + const label = viewportMarkerLabel(item, { + formatWons, + transactionLabel, + showCount: store.hasActiveSearchConditions + }) return `