Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ public ApiResponse<MapViewportResponse> 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(
Expand All @@ -55,7 +56,8 @@ public ApiResponse<MapViewportResponse> getViewport(
minDeposit,
maxDeposit,
minPrice,
maxPrice
maxPrice,
keyword
);
criteria.validateBounds();
return ApiResponse.ok(mapViewportService.getViewport(new MapViewportRequest(criteria, zoom, clusterThreshold)));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,8 @@ public ApiResponse<ListResponse<PropertySummaryResponse>> 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,
Expand All @@ -59,7 +60,8 @@ public ApiResponse<ListResponse<PropertySummaryResponse>> searchProperties(
minDeposit,
maxDeposit,
minPrice,
maxPrice
maxPrice,
keyword
);
criteria.validateBounds();
List<PropertySummaryResponse> properties = propertyService.searchProperties(criteria);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -60,30 +60,7 @@ public List<PropertyRow> 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());
Expand Down Expand Up @@ -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)) + "%");
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

private String escapeLikePattern(String keyword) {
return keyword
.replace("!", "!!")
.replace("%", "!%")
.replace("_", "!_");
}

private void appendRegionStatFilters(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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"))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
8 changes: 7 additions & 1 deletion docs/08_API_SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -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로 조회합니다.

Expand Down Expand Up @@ -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` | — | 매물 클러스터링 기준 수. 기본값은 서버 설정 사용 |

**표시 모드**
Expand All @@ -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 숫자를 기준으로 서버에서 결정합니다.

Expand Down
2 changes: 2 additions & 0 deletions frontend/src/api/properties.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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'
}
})
Expand Down
17 changes: 14 additions & 3 deletions frontend/src/store/mapStore.js
Original file line number Diff line number Diff line change
Expand Up @@ -79,18 +79,28 @@ 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) {
if (state.error) return { text: '매물 API 확인 필요', tone: 'text-rose-600' }
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: {
Expand Down Expand Up @@ -153,6 +163,7 @@ export default defineStore('map', {
const data = await fetchMapViewport({
...this.bounds,
zoom: this.zoom,
keyword: this.normalizedSearchKeyword,
...this.filters
})

Expand Down
12 changes: 12 additions & 0 deletions frontend/src/store/mapStore.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
})
4 changes: 2 additions & 2 deletions frontend/src/utils/mapViewport.js
Original file line number Diff line number Diff line change
Expand Up @@ -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))
}
}

Expand Down
16 changes: 16 additions & 0 deletions frontend/src/utils/mapViewport.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
6 changes: 5 additions & 1 deletion frontend/src/views/MapExplorer.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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 `
<button type="button" style="
position: relative;
Expand Down