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..d21e546 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,24 @@ 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 ESCAPE '!' + OR LOWER(COALESCE(%sbuilding_name, '')) LIKE :keywordPattern ESCAPE '!' + OR LOWER(COALESCE(%saddress, '')) LIKE :keywordPattern ESCAPE '!' + OR LOWER(COALESCE(%sroad_address, '')) LIKE :keywordPattern ESCAPE '!' + ) + """.formatted(prefix, prefix, prefix, prefix)); + params.put("keywordPattern", "%" + escapeLikePattern(criteria.normalizedKeyword().toLowerCase(Locale.ROOT)) + "%"); + } + } + + private String escapeLikePattern(String keyword) { + return keyword + .replace("!", "!!") + .replace("%", "!%") + .replace("_", "!_"); } 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..49fdfd4 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,52 @@ 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 searchPropertiesTreatsKeywordWildcardsAsLiteralText() 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(0)) + .andExpect(jsonPath("$.data.items", hasSize(0))); + + 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..a9f6a57 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..884ad19 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) { @@ -89,8 +93,14 @@ export default defineStore('map', { if (state.isLoading) return { text: '매물 불러오는 중', tone: 'text-slate-600' } return { text: `${state.totalCount.toLocaleString()}개 매물 표시`, tone: 'text-emerald-700' } }, + normalizedSearchKeyword(state) { + return state.searchKeyword.trim() + }, hasActiveFilters(state) { return Object.values(state.filters).some((value) => value !== '') + }, + hasActiveSearchConditions() { + return this.normalizedSearchKeyword !== '' || this.hasActiveFilters } }, actions: { @@ -153,6 +163,7 @@ export default defineStore('map', { const data = await fetchMapViewport({ ...this.bounds, zoom: this.zoom, + keyword: this.normalizedSearchKeyword, ...this.filters }) diff --git a/frontend/src/store/mapStore.test.mjs b/frontend/src/store/mapStore.test.mjs index 55e1272..f9ada6b 100644 --- a/frontend/src/store/mapStore.test.mjs +++ b/frontend/src/store/mapStore.test.mjs @@ -74,3 +74,15 @@ test('setBounds keeps the last valid bounds when map reports a transient invalid }), false) assert.deepEqual(store.bounds, previousBounds) }) + +test('search condition state uses trimmed keyword', () => { + const store = createStore() + + store.searchKeyword = ' ' + assert.equal(store.normalizedSearchKeyword, '') + assert.equal(store.hasActiveSearchConditions, false) + + store.searchKeyword = ' 그린빌 ' + assert.equal(store.normalizedSearchKeyword, '그린빌') + assert.equal(store.hasActiveSearchConditions, true) +}) 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 `