From 778e9f79cb610cb2dbc7c65541d4386bb59ed46d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=9A=A9=ED=9C=98?= Date: Tue, 23 Jun 2026 15:07:18 +0900 Subject: [PATCH 1/2] =?UTF-8?q?feat(property):=20=EC=8B=9C=EC=84=B8=20?= =?UTF-8?q?=EC=95=88=EC=A0=84=20=EB=B6=84=EC=84=9D=20API=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84=20(#38)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../price/PriceAnalysisController.java | 31 ++++ .../property/PropertyController.java | 18 ++ .../model/dao/property/JdbcPropertyDao.java | 168 ++++++++++++++++++ .../model/dao/property/PropertyDao.java | 22 +++ .../property/BuildingPriceStatResponse.java | 19 ++ .../dto/property/PriceAnalysisResponse.java | 16 ++ .../PropertySafetySummaryResponse.java | 13 ++ .../property/PropertyTransactionResponse.java | 14 ++ .../dto/property/RegionPriceStatResponse.java | 19 ++ .../service/property/PropertyService.java | 15 ++ .../service/property/PropertyServiceImpl.java | 52 ++++++ .../price/PriceAnalysisControllerTest.java | 42 +++++ .../property/PropertyControllerTest.java | 44 +++++ backend/src/test/resources/data.sql | 74 ++++++++ backend/src/test/resources/schema.sql | 88 ++++++++- database/README.md | 2 + ...02606230003_create_property_score_stat.sql | 27 +++ docs/07_DOMAIN_MODEL.md | 16 +- docs/08_API_SPEC.md | 50 ++++++ 19 files changed, 723 insertions(+), 7 deletions(-) create mode 100644 backend/src/main/java/com/ssafy/salmanhae/controller/price/PriceAnalysisController.java create mode 100644 backend/src/main/java/com/ssafy/salmanhae/model/dto/property/BuildingPriceStatResponse.java create mode 100644 backend/src/main/java/com/ssafy/salmanhae/model/dto/property/PriceAnalysisResponse.java create mode 100644 backend/src/main/java/com/ssafy/salmanhae/model/dto/property/PropertySafetySummaryResponse.java create mode 100644 backend/src/main/java/com/ssafy/salmanhae/model/dto/property/PropertyTransactionResponse.java create mode 100644 backend/src/main/java/com/ssafy/salmanhae/model/dto/property/RegionPriceStatResponse.java create mode 100644 backend/src/test/java/com/ssafy/salmanhae/controller/price/PriceAnalysisControllerTest.java create mode 100644 database/migrations/202606230003_create_property_score_stat.sql diff --git a/backend/src/main/java/com/ssafy/salmanhae/controller/price/PriceAnalysisController.java b/backend/src/main/java/com/ssafy/salmanhae/controller/price/PriceAnalysisController.java new file mode 100644 index 0000000..242704b --- /dev/null +++ b/backend/src/main/java/com/ssafy/salmanhae/controller/price/PriceAnalysisController.java @@ -0,0 +1,31 @@ +package com.ssafy.salmanhae.controller.price; + +import com.ssafy.salmanhae.common.response.ApiResponse; +import com.ssafy.salmanhae.model.dto.property.PriceAnalysisResponse; +import com.ssafy.salmanhae.model.dto.property.PropertyType; +import com.ssafy.salmanhae.model.dto.property.TransactionType; +import com.ssafy.salmanhae.service.property.PropertyService; +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; + +@RestController +@RequestMapping("/api/v1/price-analysis") +public class PriceAnalysisController { + + private final PropertyService propertyService; + + public PriceAnalysisController(PropertyService propertyService) { + this.propertyService = propertyService; + } + + @GetMapping + public ApiResponse getPriceAnalysis( + @RequestParam String legalDongCode, + @RequestParam PropertyType propertyType, + @RequestParam TransactionType transactionType + ) { + return ApiResponse.ok(propertyService.getPriceAnalysis(legalDongCode, propertyType, transactionType)); + } +} 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 5782d00..b28bc80 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 @@ -12,8 +12,10 @@ import com.ssafy.salmanhae.common.response.ApiResponse; import com.ssafy.salmanhae.common.response.ListResponse; import com.ssafy.salmanhae.model.dto.property.PropertyDetailResponse; +import com.ssafy.salmanhae.model.dto.property.PropertySafetySummaryResponse; import com.ssafy.salmanhae.model.dto.property.PropertySearchCriteria; import com.ssafy.salmanhae.model.dto.property.PropertySummaryResponse; +import com.ssafy.salmanhae.model.dto.property.PropertyTransactionResponse; import com.ssafy.salmanhae.model.dto.property.PropertyType; import com.ssafy.salmanhae.model.dto.property.TransactionType; import com.ssafy.salmanhae.service.property.PropertyService; @@ -62,4 +64,20 @@ public ApiResponse> searchProperties( public ApiResponse getProperty(@PathVariable Long propertyId) { return ApiResponse.ok(propertyService.getProperty(propertyId)); } + + @GetMapping("/{propertyId}/transactions") + public ApiResponse> getPropertyTransactions( + @PathVariable Long propertyId, + @RequestParam(required = false) Integer years + ) { + return ApiResponse.ok(ListResponse.from(propertyService.getTransactions(propertyId, years))); + } + + @GetMapping("/{propertyId}/safety-summary") + public ApiResponse getPropertySafetySummary( + @PathVariable Long propertyId, + @RequestParam(required = false) Integer radius + ) { + return ApiResponse.ok(propertyService.getSafetySummary(propertyId, radius)); + } } 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 b842994..0e2c3e2 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 @@ -2,6 +2,7 @@ import java.sql.ResultSet; import java.sql.SQLException; +import java.math.BigDecimal; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -11,9 +12,13 @@ import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate; import org.springframework.stereotype.Repository; +import com.ssafy.salmanhae.model.dto.property.BuildingPriceStatResponse; +import com.ssafy.salmanhae.model.dto.property.PropertySafetySummaryResponse; import com.ssafy.salmanhae.model.dto.property.PropertyRow; import com.ssafy.salmanhae.model.dto.property.PropertySearchCriteria; +import com.ssafy.salmanhae.model.dto.property.PropertyTransactionResponse; import com.ssafy.salmanhae.model.dto.property.PropertyType; +import com.ssafy.salmanhae.model.dto.property.RegionPriceStatResponse; import com.ssafy.salmanhae.model.dto.property.TransactionType; @Repository @@ -90,10 +95,166 @@ public Optional findActiveById(Long propertyId) { return rows.stream().findFirst(); } + @Override + public List findComparableTransactions(PropertyRow property, String minContractYearMonth) { + String sql = """ + SELECT transaction_type, contract_year_month, deposit, monthly_rent, price, area_m2, floor, + CASE WHEN building_key = :buildingKey THEN 0 ELSE 1 END AS match_rank + FROM transaction_history + WHERE contract_year_month >= :minContractYearMonth + AND transaction_type = :transactionType + AND property_type = :propertyType + AND ( + building_key = :buildingKey + OR ( + legal_dong_code = :legalDongCode + AND area_m2 BETWEEN :minAreaM2 AND :maxAreaM2 + ) + ) + ORDER BY match_rank ASC, contract_year_month DESC, contract_day DESC NULLS LAST, id DESC + LIMIT 20 + """; + BigDecimal areaM2 = property.areaM2(); + Map params = Map.of( + "buildingKey", property.buildingKey(), + "minContractYearMonth", minContractYearMonth, + "transactionType", property.transactionType().name(), + "propertyType", property.propertyType().name(), + "legalDongCode", property.legalDongCode(), + "minAreaM2", areaM2.subtract(BigDecimal.TEN), + "maxAreaM2", areaM2.add(BigDecimal.TEN) + ); + return jdbcTemplate.query(sql, params, propertyTransactionMapper()); + } + + @Override + public Optional findSafetySummary(Long propertyId, Integer radius) { + String sql = """ + SELECT property_id, safety_score, price_score, cctv_count_300m, bell_count_300m, + light_count_300m, police_count_500m + FROM property_score_stat + WHERE property_id = :propertyId + """; + List rows = jdbcTemplate.query( + sql, + Map.of("propertyId", propertyId), + (rs, rowNum) -> new PropertySafetySummaryResponse( + rs.getLong("property_id"), + radius, + nullableInteger(rs, "safety_score"), + nullableInteger(rs, "price_score"), + nullableInteger(rs, "cctv_count_300m"), + nullableInteger(rs, "bell_count_300m"), + nullableInteger(rs, "light_count_300m"), + nullableInteger(rs, "police_count_500m") + ) + ); + return rows.stream().findFirst(); + } + + @Override + public List findRegionPriceStats( + String legalDongCode, + PropertyType propertyType, + TransactionType transactionType + ) { + String sql = """ + SELECT region_level, region_code, sido, sigungu, dong, avg_deposit, median_deposit, + avg_monthly_rent, median_monthly_rent, avg_price, median_price, + transaction_count, sample_from_ym, sample_to_ym + FROM region_price_stat + WHERE region_code = :legalDongCode + AND property_type = :propertyType + AND transaction_type = :transactionType + ORDER BY region_level, region_code + """; + Map params = Map.of( + "legalDongCode", legalDongCode, + "propertyType", propertyType.name(), + "transactionType", transactionType.name() + ); + return jdbcTemplate.query(sql, params, regionPriceStatMapper()); + } + + @Override + public List findBuildingPriceStats( + String legalDongCode, + PropertyType propertyType, + TransactionType transactionType + ) { + String sql = """ + SELECT building_key, building_name, sido, sigungu, dong, avg_deposit, median_deposit, + avg_monthly_rent, median_monthly_rent, avg_price, median_price, + transaction_count, sample_from_ym, sample_to_ym + FROM building_price_stat + WHERE legal_dong_code = :legalDongCode + AND property_type = :propertyType + AND transaction_type = :transactionType + ORDER BY transaction_count DESC, building_key ASC + LIMIT 20 + """; + Map params = Map.of( + "legalDongCode", legalDongCode, + "propertyType", propertyType.name(), + "transactionType", transactionType.name() + ); + return jdbcTemplate.query(sql, params, buildingPriceStatMapper()); + } + private RowMapper propertyRowMapper() { return (rs, rowNum) -> mapPropertyRow(rs); } + private RowMapper propertyTransactionMapper() { + return (rs, rowNum) -> new PropertyTransactionResponse( + TransactionType.valueOf(rs.getString("transaction_type")), + formatYearMonth(rs.getString("contract_year_month")), + nullableLong(rs, "deposit"), + nullableLong(rs, "monthly_rent"), + nullableLong(rs, "price"), + rs.getBigDecimal("area_m2"), + nullableInteger(rs, "floor") + ); + } + + private RowMapper regionPriceStatMapper() { + return (rs, rowNum) -> new RegionPriceStatResponse( + rs.getString("region_level"), + rs.getString("region_code"), + rs.getString("sido"), + rs.getString("sigungu"), + rs.getString("dong"), + nullableLong(rs, "avg_deposit"), + nullableLong(rs, "median_deposit"), + nullableLong(rs, "avg_monthly_rent"), + nullableLong(rs, "median_monthly_rent"), + nullableLong(rs, "avg_price"), + nullableLong(rs, "median_price"), + nullableInteger(rs, "transaction_count"), + formatYearMonth(rs.getString("sample_from_ym")), + formatYearMonth(rs.getString("sample_to_ym")) + ); + } + + private RowMapper buildingPriceStatMapper() { + return (rs, rowNum) -> new BuildingPriceStatResponse( + rs.getString("building_key"), + rs.getString("building_name"), + rs.getString("sido"), + rs.getString("sigungu"), + rs.getString("dong"), + nullableLong(rs, "avg_deposit"), + nullableLong(rs, "median_deposit"), + nullableLong(rs, "avg_monthly_rent"), + nullableLong(rs, "median_monthly_rent"), + nullableLong(rs, "avg_price"), + nullableLong(rs, "median_price"), + nullableInteger(rs, "transaction_count"), + formatYearMonth(rs.getString("sample_from_ym")), + formatYearMonth(rs.getString("sample_to_ym")) + ); + } + private PropertyRow mapPropertyRow(ResultSet rs) throws SQLException { return new PropertyRow( rs.getLong("id"), @@ -127,4 +288,11 @@ private Integer nullableInteger(ResultSet rs, String column) throws SQLException int value = rs.getInt(column); return rs.wasNull() ? null : value; } + + private String formatYearMonth(String yearMonth) { + if (yearMonth == null || yearMonth.length() != 6) { + return yearMonth; + } + return yearMonth.substring(0, 4) + "-" + yearMonth.substring(4); + } } diff --git a/backend/src/main/java/com/ssafy/salmanhae/model/dao/property/PropertyDao.java b/backend/src/main/java/com/ssafy/salmanhae/model/dao/property/PropertyDao.java index e75fe06..5b64951 100644 --- a/backend/src/main/java/com/ssafy/salmanhae/model/dao/property/PropertyDao.java +++ b/backend/src/main/java/com/ssafy/salmanhae/model/dao/property/PropertyDao.java @@ -3,12 +3,34 @@ import java.util.List; import java.util.Optional; +import com.ssafy.salmanhae.model.dto.property.BuildingPriceStatResponse; +import com.ssafy.salmanhae.model.dto.property.PropertySafetySummaryResponse; +import com.ssafy.salmanhae.model.dto.property.PropertyTransactionResponse; +import com.ssafy.salmanhae.model.dto.property.PropertyType; +import com.ssafy.salmanhae.model.dto.property.RegionPriceStatResponse; import com.ssafy.salmanhae.model.dto.property.PropertyRow; import com.ssafy.salmanhae.model.dto.property.PropertySearchCriteria; +import com.ssafy.salmanhae.model.dto.property.TransactionType; public interface PropertyDao { List findInBounds(PropertySearchCriteria criteria); Optional findActiveById(Long propertyId); + + List findComparableTransactions(PropertyRow property, String minContractYearMonth); + + Optional findSafetySummary(Long propertyId, Integer radius); + + List findRegionPriceStats( + String legalDongCode, + PropertyType propertyType, + TransactionType transactionType + ); + + List findBuildingPriceStats( + String legalDongCode, + PropertyType propertyType, + TransactionType transactionType + ); } diff --git a/backend/src/main/java/com/ssafy/salmanhae/model/dto/property/BuildingPriceStatResponse.java b/backend/src/main/java/com/ssafy/salmanhae/model/dto/property/BuildingPriceStatResponse.java new file mode 100644 index 0000000..15e3238 --- /dev/null +++ b/backend/src/main/java/com/ssafy/salmanhae/model/dto/property/BuildingPriceStatResponse.java @@ -0,0 +1,19 @@ +package com.ssafy.salmanhae.model.dto.property; + +public record BuildingPriceStatResponse( + String buildingKey, + String buildingName, + String sido, + String sigungu, + String dong, + Long avgDeposit, + Long medianDeposit, + Long avgMonthlyRent, + Long medianMonthlyRent, + Long avgPrice, + Long medianPrice, + Integer transactionCount, + String sampleFromYm, + String sampleToYm +) { +} diff --git a/backend/src/main/java/com/ssafy/salmanhae/model/dto/property/PriceAnalysisResponse.java b/backend/src/main/java/com/ssafy/salmanhae/model/dto/property/PriceAnalysisResponse.java new file mode 100644 index 0000000..40efc21 --- /dev/null +++ b/backend/src/main/java/com/ssafy/salmanhae/model/dto/property/PriceAnalysisResponse.java @@ -0,0 +1,16 @@ +package com.ssafy.salmanhae.model.dto.property; + +import java.util.List; + +public record PriceAnalysisResponse( + String legalDongCode, + PropertyType propertyType, + TransactionType transactionType, + List regionStats, + List buildingStats +) { + public PriceAnalysisResponse { + regionStats = regionStats == null ? List.of() : List.copyOf(regionStats); + buildingStats = buildingStats == null ? List.of() : List.copyOf(buildingStats); + } +} diff --git a/backend/src/main/java/com/ssafy/salmanhae/model/dto/property/PropertySafetySummaryResponse.java b/backend/src/main/java/com/ssafy/salmanhae/model/dto/property/PropertySafetySummaryResponse.java new file mode 100644 index 0000000..8de99a7 --- /dev/null +++ b/backend/src/main/java/com/ssafy/salmanhae/model/dto/property/PropertySafetySummaryResponse.java @@ -0,0 +1,13 @@ +package com.ssafy.salmanhae.model.dto.property; + +public record PropertySafetySummaryResponse( + Long propertyId, + Integer radius, + Integer safetyScore, + Integer priceScore, + Integer cctvCount300m, + Integer bellCount300m, + Integer lightCount300m, + Integer policeCount500m +) { +} diff --git a/backend/src/main/java/com/ssafy/salmanhae/model/dto/property/PropertyTransactionResponse.java b/backend/src/main/java/com/ssafy/salmanhae/model/dto/property/PropertyTransactionResponse.java new file mode 100644 index 0000000..3831b12 --- /dev/null +++ b/backend/src/main/java/com/ssafy/salmanhae/model/dto/property/PropertyTransactionResponse.java @@ -0,0 +1,14 @@ +package com.ssafy.salmanhae.model.dto.property; + +import java.math.BigDecimal; + +public record PropertyTransactionResponse( + TransactionType transactionType, + String contractYearMonth, + Long deposit, + Long monthlyRent, + Long price, + BigDecimal areaM2, + Integer floor +) { +} diff --git a/backend/src/main/java/com/ssafy/salmanhae/model/dto/property/RegionPriceStatResponse.java b/backend/src/main/java/com/ssafy/salmanhae/model/dto/property/RegionPriceStatResponse.java new file mode 100644 index 0000000..87da86c --- /dev/null +++ b/backend/src/main/java/com/ssafy/salmanhae/model/dto/property/RegionPriceStatResponse.java @@ -0,0 +1,19 @@ +package com.ssafy.salmanhae.model.dto.property; + +public record RegionPriceStatResponse( + String regionLevel, + String regionCode, + String sido, + String sigungu, + String dong, + Long avgDeposit, + Long medianDeposit, + Long avgMonthlyRent, + Long medianMonthlyRent, + Long avgPrice, + Long medianPrice, + Integer transactionCount, + String sampleFromYm, + String sampleToYm +) { +} diff --git a/backend/src/main/java/com/ssafy/salmanhae/service/property/PropertyService.java b/backend/src/main/java/com/ssafy/salmanhae/service/property/PropertyService.java index 2df9e18..6d731dd 100644 --- a/backend/src/main/java/com/ssafy/salmanhae/service/property/PropertyService.java +++ b/backend/src/main/java/com/ssafy/salmanhae/service/property/PropertyService.java @@ -2,13 +2,28 @@ import java.util.List; +import com.ssafy.salmanhae.model.dto.property.PriceAnalysisResponse; +import com.ssafy.salmanhae.model.dto.property.PropertySafetySummaryResponse; import com.ssafy.salmanhae.model.dto.property.PropertyDetailResponse; import com.ssafy.salmanhae.model.dto.property.PropertySearchCriteria; import com.ssafy.salmanhae.model.dto.property.PropertySummaryResponse; +import com.ssafy.salmanhae.model.dto.property.PropertyTransactionResponse; +import com.ssafy.salmanhae.model.dto.property.PropertyType; +import com.ssafy.salmanhae.model.dto.property.TransactionType; public interface PropertyService { List searchProperties(PropertySearchCriteria criteria); PropertyDetailResponse getProperty(Long propertyId); + + List getTransactions(Long propertyId, Integer years); + + PropertySafetySummaryResponse getSafetySummary(Long propertyId, Integer radius); + + PriceAnalysisResponse getPriceAnalysis( + String legalDongCode, + PropertyType propertyType, + TransactionType transactionType + ); } diff --git a/backend/src/main/java/com/ssafy/salmanhae/service/property/PropertyServiceImpl.java b/backend/src/main/java/com/ssafy/salmanhae/service/property/PropertyServiceImpl.java index ba78ef0..e20afe4 100644 --- a/backend/src/main/java/com/ssafy/salmanhae/service/property/PropertyServiceImpl.java +++ b/backend/src/main/java/com/ssafy/salmanhae/service/property/PropertyServiceImpl.java @@ -1,5 +1,6 @@ package com.ssafy.salmanhae.service.property; +import java.time.YearMonth; import java.util.List; import org.springframework.stereotype.Service; @@ -7,9 +8,15 @@ import com.ssafy.salmanhae.common.exception.ApiException; import com.ssafy.salmanhae.common.exception.ErrorCode; import com.ssafy.salmanhae.model.dao.property.PropertyDao; +import com.ssafy.salmanhae.model.dto.property.PriceAnalysisResponse; +import com.ssafy.salmanhae.model.dto.property.PropertySafetySummaryResponse; import com.ssafy.salmanhae.model.dto.property.PropertyDetailResponse; +import com.ssafy.salmanhae.model.dto.property.PropertyRow; import com.ssafy.salmanhae.model.dto.property.PropertySearchCriteria; import com.ssafy.salmanhae.model.dto.property.PropertySummaryResponse; +import com.ssafy.salmanhae.model.dto.property.PropertyTransactionResponse; +import com.ssafy.salmanhae.model.dto.property.PropertyType; +import com.ssafy.salmanhae.model.dto.property.TransactionType; @Service public class PropertyServiceImpl implements PropertyService { @@ -34,4 +41,49 @@ public PropertyDetailResponse getProperty(Long propertyId) { .map(PropertyDetailResponse::from) .orElseThrow(() -> new ApiException(ErrorCode.PROPERTY_NOT_FOUND)); } + + @Override + public List getTransactions(Long propertyId, Integer years) { + PropertyRow property = getActiveProperty(propertyId); + int lookupYears = years == null ? 3 : years; + if (lookupYears < 1) { + throw new ApiException(ErrorCode.INVALID_REQUEST); + } + String minContractYearMonth = YearMonth.now().minusYears(lookupYears).toString().replace("-", ""); + return propertyDao.findComparableTransactions(property, minContractYearMonth); + } + + @Override + public PropertySafetySummaryResponse getSafetySummary(Long propertyId, Integer radius) { + getActiveProperty(propertyId); + Integer lookupRadius = radius == null ? 500 : radius; + if (lookupRadius < 1) { + throw new ApiException(ErrorCode.INVALID_REQUEST); + } + return propertyDao.findSafetySummary(propertyId, lookupRadius) + .orElse(new PropertySafetySummaryResponse(propertyId, lookupRadius, null, null, 0, 0, 0, 0)); + } + + @Override + public PriceAnalysisResponse getPriceAnalysis( + String legalDongCode, + PropertyType propertyType, + TransactionType transactionType + ) { + if (legalDongCode == null || legalDongCode.isBlank() || propertyType == null || transactionType == null) { + throw new ApiException(ErrorCode.INVALID_REQUEST); + } + return new PriceAnalysisResponse( + legalDongCode, + propertyType, + transactionType, + propertyDao.findRegionPriceStats(legalDongCode, propertyType, transactionType), + propertyDao.findBuildingPriceStats(legalDongCode, propertyType, transactionType) + ); + } + + private PropertyRow getActiveProperty(Long propertyId) { + return propertyDao.findActiveById(propertyId) + .orElseThrow(() -> new ApiException(ErrorCode.PROPERTY_NOT_FOUND)); + } } diff --git a/backend/src/test/java/com/ssafy/salmanhae/controller/price/PriceAnalysisControllerTest.java b/backend/src/test/java/com/ssafy/salmanhae/controller/price/PriceAnalysisControllerTest.java new file mode 100644 index 0000000..b079921 --- /dev/null +++ b/backend/src/test/java/com/ssafy/salmanhae/controller/price/PriceAnalysisControllerTest.java @@ -0,0 +1,42 @@ +package com.ssafy.salmanhae.controller.price; + +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 PriceAnalysisControllerTest { + + @Autowired + private MockMvc mockMvc; + + @Test + void getPriceAnalysisReturnsCachedRegionAndBuildingStats() throws Exception { + mockMvc.perform(get("/api/v1/price-analysis") + .param("legalDongCode", "1162010200") + .param("propertyType", "ONE_ROOM") + .param("transactionType", "MONTHLY_RENT")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.message").value("OK")) + .andExpect(jsonPath("$.data.legalDongCode").value("1162010200")) + .andExpect(jsonPath("$.data.propertyType").value("ONE_ROOM")) + .andExpect(jsonPath("$.data.transactionType").value("MONTHLY_RENT")) + .andExpect(jsonPath("$.data.regionStats", hasSize(1))) + .andExpect(jsonPath("$.data.regionStats[0].regionLevel").value("DONG")) + .andExpect(jsonPath("$.data.regionStats[0].avgDeposit").value(10500000)) + .andExpect(jsonPath("$.data.regionStats[0].avgMonthlyRent").value(520000)) + .andExpect(jsonPath("$.data.buildingStats", hasSize(1))) + .andExpect(jsonPath("$.data.buildingStats[0].buildingKey").value("1162010200:ONE_ROOM:그린빌:12-3")) + .andExpect(jsonPath("$.data.buildingStats[0].medianDeposit").value(11000000)); + } +} 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 de88203..b1e69c8 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 @@ -123,4 +123,48 @@ void getPropertyReturnsNotFoundForMissingOrInactiveProperty() throws Exception { .andExpect(jsonPath("$.code").value("PROPERTY_NOT_FOUND")) .andExpect(jsonPath("$.status").value(404)); } + + @Test + void getPropertyTransactionsReturnsComparableRows() throws Exception { + mockMvc.perform(get("/api/v1/properties/{propertyId}/transactions", 1) + .param("years", "3")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.message").value("OK")) + .andExpect(jsonPath("$.data.totalCount").value(3)) + .andExpect(jsonPath("$.data.items", hasSize(3))) + .andExpect(jsonPath("$.data.items[0].contractYearMonth").value("2026-05")) + .andExpect(jsonPath("$.data.items[0].deposit").value(10000000)) + .andExpect(jsonPath("$.data.items[0].monthlyRent").value(520000)) + .andExpect(jsonPath("$.data.items[0].areaM2").value(21.80)); + } + + @Test + void getPropertyTransactionsReturnsNotFoundForMissingProperty() throws Exception { + mockMvc.perform(get("/api/v1/properties/{propertyId}/transactions", 999)) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value("PROPERTY_NOT_FOUND")); + } + + @Test + void getPropertySafetySummaryReturnsPrecomputedScore() throws Exception { + mockMvc.perform(get("/api/v1/properties/{propertyId}/safety-summary", 1) + .param("radius", "500")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.message").value("OK")) + .andExpect(jsonPath("$.data.propertyId").value(1)) + .andExpect(jsonPath("$.data.radius").value(500)) + .andExpect(jsonPath("$.data.safetyScore").value(78)) + .andExpect(jsonPath("$.data.priceScore").value(64)) + .andExpect(jsonPath("$.data.cctvCount300m").value(8)) + .andExpect(jsonPath("$.data.bellCount300m").value(2)) + .andExpect(jsonPath("$.data.lightCount300m").value(14)) + .andExpect(jsonPath("$.data.policeCount500m").value(1)); + } + + @Test + void getPropertySafetySummaryReturnsNotFoundForMissingProperty() throws Exception { + mockMvc.perform(get("/api/v1/properties/{propertyId}/safety-summary", 999)) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value("PROPERTY_NOT_FOUND")); + } } diff --git a/backend/src/test/resources/data.sql b/backend/src/test/resources/data.sql index ca368f6..466b478 100644 --- a/backend/src/test/resources/data.sql +++ b/backend/src/test/resources/data.sql @@ -37,3 +37,77 @@ INSERT INTO properties ( 37.6000000, 127.1000000, '지도 범위 밖 매물입니다.', 'MVP_SYNTHETIC', 'synthetic-4', DATE '2026-06-01', TRUE, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP ); + +INSERT INTO transaction_history ( + id, source_api, source_transaction_key, property_type, transaction_type, + sido, sigungu, dong, legal_dong_code, jibun, building_name, building_key, + contract_year_month, contract_day, deposit, monthly_rent, price, area_m2, + floor, build_year, raw_json, created_at +) VALUES +( + 101, 'MOLIT_TEST', 'tx-101', 'ONE_ROOM', 'MONTHLY_RENT', + '서울특별시', '관악구', '대학동', '1162010200', '12-3', '그린빌', + '1162010200:ONE_ROOM:그린빌:12-3', '202605', 11, + 10000000, 520000, NULL, 21.80, 2, 2015, '{}', CURRENT_TIMESTAMP +), +( + 102, 'MOLIT_TEST', 'tx-102', 'ONE_ROOM', 'MONTHLY_RENT', + '서울특별시', '관악구', '대학동', '1162010200', '12-3', '그린빌', + '1162010200:ONE_ROOM:그린빌:12-3', '202604', 8, + 12000000, 500000, NULL, 22.10, 4, 2015, '{}', CURRENT_TIMESTAMP +), +( + 103, 'MOLIT_TEST', 'tx-103', 'ONE_ROOM', 'MONTHLY_RENT', + '서울특별시', '관악구', '대학동', '1162010200', '77-7', '비교빌', + '1162010200:ONE_ROOM:비교빌:77-7', '202603', 22, + 9000000, 540000, NULL, 24.00, 3, 2018, '{}', CURRENT_TIMESTAMP +), +( + 201, 'MOLIT_TEST', 'tx-201', 'APARTMENT', 'SALE', + '서울특별시', '관악구', '대학동', '1162010200', '12-3', '그린빌', + '1162010200:APARTMENT:그린빌:12-3', '202605', 5, + NULL, NULL, 710000000, 59.80, 7, 2012, '{}', CURRENT_TIMESTAMP +); + +INSERT INTO property_score_stat ( + property_id, safety_score, price_score, cctv_count_300m, bell_count_300m, + light_count_300m, police_count_500m, updated_at +) VALUES +(1, 78, 64, 8, 2, 14, 1, CURRENT_TIMESTAMP), +(2, 85, 71, 10, 3, 18, 2, CURRENT_TIMESTAMP); + +INSERT INTO region_price_stat ( + id, region_level, region_code, sido, sigungu, dong, property_type, transaction_type, + avg_deposit, median_deposit, avg_monthly_rent, median_monthly_rent, + avg_price, median_price, transaction_count, sample_from_ym, sample_to_ym, + created_at, updated_at +) VALUES +( + 301, 'DONG', '1162010200', '서울특별시', '관악구', '대학동', + 'ONE_ROOM', 'MONTHLY_RENT', 10500000, 10000000, 520000, 520000, + NULL, NULL, 3, '202603', '202605', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP +), +( + 302, 'DONG', '1162010200', '서울특별시', '관악구', '대학동', + 'APARTMENT', 'SALE', NULL, NULL, NULL, NULL, + 715000000, 710000000, 1, '202605', '202605', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP +); + +INSERT INTO building_price_stat ( + id, building_key, building_name, sido, sigungu, dong, legal_dong_code, + property_type, transaction_type, avg_deposit, median_deposit, + avg_monthly_rent, median_monthly_rent, avg_price, median_price, + transaction_count, sample_from_ym, sample_to_ym, created_at, updated_at +) VALUES +( + 401, '1162010200:ONE_ROOM:그린빌:12-3', '그린빌', + '서울특별시', '관악구', '대학동', '1162010200', + 'ONE_ROOM', 'MONTHLY_RENT', 11000000, 11000000, 510000, 510000, + NULL, NULL, 2, '202604', '202605', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP +), +( + 402, '1162010200:APARTMENT:그린빌:12-3', '그린빌', + '서울특별시', '관악구', '대학동', '1162010200', + 'APARTMENT', 'SALE', NULL, NULL, NULL, NULL, + 710000000, 710000000, 1, '202605', '202605', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP +); diff --git a/backend/src/test/resources/schema.sql b/backend/src/test/resources/schema.sql index d561e7f..60e37d7 100644 --- a/backend/src/test/resources/schema.sql +++ b/backend/src/test/resources/schema.sql @@ -1,4 +1,9 @@ DROP TABLE IF EXISTS users; +DROP TABLE IF EXISTS property_score_stat; +DROP TABLE IF EXISTS building_price_stat; +DROP TABLE IF EXISTS region_price_stat; +DROP TABLE IF EXISTS transaction_history; +DROP TABLE IF EXISTS properties; CREATE TABLE users ( id UUID DEFAULT RANDOM_UUID() PRIMARY KEY, @@ -9,8 +14,6 @@ CREATE TABLE users ( updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); -DROP TABLE IF EXISTS properties; - CREATE TABLE properties ( id BIGINT PRIMARY KEY, title VARCHAR(200) NOT NULL, @@ -47,3 +50,84 @@ CREATE TABLE properties ( created_at TIMESTAMP, updated_at TIMESTAMP ); + +CREATE TABLE transaction_history ( + id BIGINT PRIMARY KEY, + source_api VARCHAR(50) NOT NULL, + source_transaction_key VARCHAR(160) NOT NULL, + property_type VARCHAR(20) NOT NULL, + transaction_type VARCHAR(20) NOT NULL, + sido VARCHAR(20) NOT NULL, + sigungu VARCHAR(30) NOT NULL, + dong VARCHAR(30) NOT NULL, + legal_dong_code VARCHAR(10) NOT NULL, + jibun VARCHAR(50), + building_name VARCHAR(200), + building_key VARCHAR(300) NOT NULL, + contract_year_month VARCHAR(6) NOT NULL, + contract_day INT, + deposit BIGINT, + monthly_rent BIGINT, + price BIGINT, + area_m2 DECIMAL(8, 2), + floor INT, + build_year INT, + raw_json TEXT, + created_at TIMESTAMP +); + +CREATE TABLE property_score_stat ( + property_id BIGINT PRIMARY KEY, + safety_score INT, + price_score INT, + cctv_count_300m INT, + bell_count_300m INT, + light_count_300m INT, + police_count_500m INT, + updated_at TIMESTAMP +); + +CREATE TABLE region_price_stat ( + id BIGINT PRIMARY KEY, + region_level VARCHAR(20) NOT NULL, + region_code VARCHAR(80) NOT NULL, + sido VARCHAR(20) NOT NULL, + sigungu VARCHAR(30), + dong VARCHAR(30), + property_type VARCHAR(20) NOT NULL, + transaction_type VARCHAR(20) NOT NULL, + avg_deposit BIGINT, + median_deposit BIGINT, + avg_monthly_rent BIGINT, + median_monthly_rent BIGINT, + avg_price BIGINT, + median_price BIGINT, + transaction_count INT NOT NULL, + sample_from_ym VARCHAR(6), + sample_to_ym VARCHAR(6), + created_at TIMESTAMP, + updated_at TIMESTAMP +); + +CREATE TABLE building_price_stat ( + id BIGINT PRIMARY KEY, + building_key VARCHAR(300) NOT NULL, + building_name VARCHAR(200), + sido VARCHAR(20) NOT NULL, + sigungu VARCHAR(30) NOT NULL, + dong VARCHAR(30) NOT NULL, + legal_dong_code VARCHAR(10) NOT NULL, + property_type VARCHAR(20) NOT NULL, + transaction_type VARCHAR(20) NOT NULL, + avg_deposit BIGINT, + median_deposit BIGINT, + avg_monthly_rent BIGINT, + median_monthly_rent BIGINT, + avg_price BIGINT, + median_price BIGINT, + transaction_count INT NOT NULL, + sample_from_ym VARCHAR(6), + sample_to_ym VARCHAR(6), + created_at TIMESTAMP, + updated_at TIMESTAMP +); diff --git a/database/README.md b/database/README.md index 41b45a9..5cb3710 100644 --- a/database/README.md +++ b/database/README.md @@ -11,6 +11,7 @@ Run migrations in numeric order: 2. `migrations/202606220001_create_legal_document_chunks.sql` 3. `migrations/202606230001_create_price_stats.sql` 4. `migrations/202606230002_cleanup_region_price_stat_codes.sql` +5. `migrations/202606230003_create_property_score_stat.sql` ## Data Loading Policy @@ -27,6 +28,7 @@ The pipeline upserts into: - `transaction_history` - `region_price_stat` - `building_price_stat` +- `property_score_stat` - `properties` It does not truncate existing rows by default. It accumulates and updates rows diff --git a/database/migrations/202606230003_create_property_score_stat.sql b/database/migrations/202606230003_create_property_score_stat.sql new file mode 100644 index 0000000..3c08098 --- /dev/null +++ b/database/migrations/202606230003_create_property_score_stat.sql @@ -0,0 +1,27 @@ +create or replace function public.set_updated_at() +returns trigger +language plpgsql +as $$ +begin + new.updated_at = now(); + return new; +end; +$$; + +create table if not exists public.property_score_stat ( + property_id bigint primary key references public.properties(id) on delete cascade, + safety_score integer, + price_score integer, + cctv_count_300m integer not null default 0, + bell_count_300m integer not null default 0, + light_count_300m integer not null default 0, + police_count_500m integer not null default 0, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now() +); + +drop trigger if exists trg_property_score_stat_updated_at on public.property_score_stat; +create trigger trg_property_score_stat_updated_at +before update on public.property_score_stat +for each row +execute function public.set_updated_at(); diff --git a/docs/07_DOMAIN_MODEL.md b/docs/07_DOMAIN_MODEL.md index aaa8664..1cfe789 100644 --- a/docs/07_DOMAIN_MODEL.md +++ b/docs/07_DOMAIN_MODEL.md @@ -142,16 +142,22 @@ region_price_stat - id - region_level ← SIDO / SIGUNGU / DONG - region_code -- region_name +- sido +- sigungu +- dong - property_type - transaction_type - avg_deposit +- median_deposit - avg_monthly_rent -- avg_sale_price +- median_monthly_rent +- avg_price +- median_price - transaction_count -- latitude -- longitude -- calculated_at +- sample_from_ym +- sample_to_ym +- created_at +- updated_at ``` --- diff --git a/docs/08_API_SPEC.md b/docs/08_API_SPEC.md index 79141a0..3cef3a1 100644 --- a/docs/08_API_SPEC.md +++ b/docs/08_API_SPEC.md @@ -280,6 +280,7 @@ GET /api/v1/properties/{propertyId}/transactions?years=3 "contractYearMonth": "2026-05", "deposit": 10000000, "monthlyRent": 520000, + "price": null, "areaM2": 21.8, "floor": 2 } @@ -304,6 +305,7 @@ GET /api/v1/properties/{propertyId}/safety-summary?radius=500 "propertyId": 1, "radius": 500, "safetyScore": 78, + "priceScore": 64, "cctvCount300m": 8, "bellCount300m": 2, "lightCount300m": 14, @@ -572,6 +574,54 @@ Authorization: Bearer {token} GET /api/v1/price-analysis?legalDongCode=1162010200&propertyType=ONE_ROOM&transactionType=MONTHLY_RENT ``` +**Response** +```json +{ + "data": { + "legalDongCode": "1162010200", + "propertyType": "ONE_ROOM", + "transactionType": "MONTHLY_RENT", + "regionStats": [ + { + "regionLevel": "DONG", + "regionCode": "1162010200", + "sido": "서울특별시", + "sigungu": "관악구", + "dong": "대학동", + "avgDeposit": 10500000, + "medianDeposit": 10000000, + "avgMonthlyRent": 520000, + "medianMonthlyRent": 520000, + "avgPrice": null, + "medianPrice": null, + "transactionCount": 3, + "sampleFromYm": "2026-03", + "sampleToYm": "2026-05" + } + ], + "buildingStats": [ + { + "buildingKey": "1162010200:ONE_ROOM:그린빌:12-3", + "buildingName": "그린빌", + "sido": "서울특별시", + "sigungu": "관악구", + "dong": "대학동", + "avgDeposit": 11000000, + "medianDeposit": 11000000, + "avgMonthlyRent": 510000, + "medianMonthlyRent": 510000, + "avgPrice": null, + "medianPrice": null, + "transactionCount": 2, + "sampleFromYm": "2026-04", + "sampleToYm": "2026-05" + } + ] + }, + "message": "OK" +} +``` + --- ## HUG 계산 API (1.5차, 인증 필요) From cf746ed55e78b648ed0e48f3de6e6ccc9e82fd17 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=9A=A9=ED=9C=98?= Date: Tue, 23 Jun 2026 15:27:25 +0900 Subject: [PATCH 2/2] =?UTF-8?q?fix(property):=20=EB=B6=84=EC=84=9D=20API?= =?UTF-8?q?=20=EB=A6=AC=EB=B7=B0=20=EB=B0=98=EC=98=81=20(#38)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../exception/GlobalExceptionHandler.java | 5 ++++- .../price/PriceAnalysisController.java | 11 ++++++++--- .../property/PropertyController.java | 16 +++++++++++----- .../service/property/PropertyServiceImpl.java | 2 +- .../price/PriceAnalysisControllerTest.java | 11 +++++++++++ .../property/PropertyControllerTest.java | 18 ++++++++++++++++++ backend/src/test/resources/schema.sql | 13 ++++++++----- 7 files changed, 61 insertions(+), 15 deletions(-) diff --git a/backend/src/main/java/com/ssafy/salmanhae/common/exception/GlobalExceptionHandler.java b/backend/src/main/java/com/ssafy/salmanhae/common/exception/GlobalExceptionHandler.java index 87c1f87..0f6df51 100644 --- a/backend/src/main/java/com/ssafy/salmanhae/common/exception/GlobalExceptionHandler.java +++ b/backend/src/main/java/com/ssafy/salmanhae/common/exception/GlobalExceptionHandler.java @@ -6,6 +6,8 @@ import org.springframework.web.bind.annotation.RestControllerAdvice; import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException; +import jakarta.validation.ConstraintViolationException; + @RestControllerAdvice public class GlobalExceptionHandler { @@ -19,7 +21,8 @@ public ResponseEntity handleApiException(ApiException exception) @ExceptionHandler({ MissingServletRequestParameterException.class, - MethodArgumentTypeMismatchException.class + MethodArgumentTypeMismatchException.class, + ConstraintViolationException.class }) public ResponseEntity handleBadRequest(Exception exception) { return ResponseEntity diff --git a/backend/src/main/java/com/ssafy/salmanhae/controller/price/PriceAnalysisController.java b/backend/src/main/java/com/ssafy/salmanhae/controller/price/PriceAnalysisController.java index 242704b..08fe65c 100644 --- a/backend/src/main/java/com/ssafy/salmanhae/controller/price/PriceAnalysisController.java +++ b/backend/src/main/java/com/ssafy/salmanhae/controller/price/PriceAnalysisController.java @@ -5,6 +5,10 @@ import com.ssafy.salmanhae.model.dto.property.PropertyType; import com.ssafy.salmanhae.model.dto.property.TransactionType; import com.ssafy.salmanhae.service.property.PropertyService; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Pattern; +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; @@ -12,6 +16,7 @@ @RestController @RequestMapping("/api/v1/price-analysis") +@Validated public class PriceAnalysisController { private final PropertyService propertyService; @@ -22,9 +27,9 @@ public PriceAnalysisController(PropertyService propertyService) { @GetMapping public ApiResponse getPriceAnalysis( - @RequestParam String legalDongCode, - @RequestParam PropertyType propertyType, - @RequestParam TransactionType transactionType + @RequestParam @NotBlank @Pattern(regexp = "\\d{10}") String legalDongCode, + @RequestParam @NotNull PropertyType propertyType, + @RequestParam @NotNull TransactionType transactionType ) { return ApiResponse.ok(propertyService.getPriceAnalysis(legalDongCode, propertyType, transactionType)); } 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 b28bc80..dd28faa 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 @@ -19,9 +19,15 @@ import com.ssafy.salmanhae.model.dto.property.PropertyType; import com.ssafy.salmanhae.model.dto.property.TransactionType; import com.ssafy.salmanhae.service.property.PropertyService; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Positive; +import org.springframework.validation.annotation.Validated; @RestController @RequestMapping("/api/v1/properties") +@Validated public class PropertyController { private final PropertyService propertyService; @@ -61,22 +67,22 @@ public ApiResponse> searchProperties( } @GetMapping("/{propertyId}") - public ApiResponse getProperty(@PathVariable Long propertyId) { + public ApiResponse getProperty(@PathVariable @NotNull @Positive Long propertyId) { return ApiResponse.ok(propertyService.getProperty(propertyId)); } @GetMapping("/{propertyId}/transactions") public ApiResponse> getPropertyTransactions( - @PathVariable Long propertyId, - @RequestParam(required = false) Integer years + @PathVariable @NotNull @Positive Long propertyId, + @RequestParam(required = false) @Min(1) @Max(10) Integer years ) { return ApiResponse.ok(ListResponse.from(propertyService.getTransactions(propertyId, years))); } @GetMapping("/{propertyId}/safety-summary") public ApiResponse getPropertySafetySummary( - @PathVariable Long propertyId, - @RequestParam(required = false) Integer radius + @PathVariable @NotNull @Positive Long propertyId, + @RequestParam(required = false) @Min(300) @Max(500) Integer radius ) { return ApiResponse.ok(propertyService.getSafetySummary(propertyId, radius)); } diff --git a/backend/src/main/java/com/ssafy/salmanhae/service/property/PropertyServiceImpl.java b/backend/src/main/java/com/ssafy/salmanhae/service/property/PropertyServiceImpl.java index e20afe4..d3e95e3 100644 --- a/backend/src/main/java/com/ssafy/salmanhae/service/property/PropertyServiceImpl.java +++ b/backend/src/main/java/com/ssafy/salmanhae/service/property/PropertyServiceImpl.java @@ -57,7 +57,7 @@ public List getTransactions(Long propertyId, Intege public PropertySafetySummaryResponse getSafetySummary(Long propertyId, Integer radius) { getActiveProperty(propertyId); Integer lookupRadius = radius == null ? 500 : radius; - if (lookupRadius < 1) { + if (lookupRadius != 300 && lookupRadius != 500) { throw new ApiException(ErrorCode.INVALID_REQUEST); } return propertyDao.findSafetySummary(propertyId, lookupRadius) diff --git a/backend/src/test/java/com/ssafy/salmanhae/controller/price/PriceAnalysisControllerTest.java b/backend/src/test/java/com/ssafy/salmanhae/controller/price/PriceAnalysisControllerTest.java index b079921..8721315 100644 --- a/backend/src/test/java/com/ssafy/salmanhae/controller/price/PriceAnalysisControllerTest.java +++ b/backend/src/test/java/com/ssafy/salmanhae/controller/price/PriceAnalysisControllerTest.java @@ -39,4 +39,15 @@ void getPriceAnalysisReturnsCachedRegionAndBuildingStats() throws Exception { .andExpect(jsonPath("$.data.buildingStats[0].buildingKey").value("1162010200:ONE_ROOM:그린빌:12-3")) .andExpect(jsonPath("$.data.buildingStats[0].medianDeposit").value(11000000)); } + + @Test + void getPriceAnalysisRejectsBlankLegalDongCodeAtController() throws Exception { + mockMvc.perform(get("/api/v1/price-analysis") + .param("legalDongCode", "") + .param("propertyType", "ONE_ROOM") + .param("transactionType", "MONTHLY_RENT")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("INVALID_REQUEST")) + .andExpect(jsonPath("$.status").value(400)); + } } 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 b1e69c8..eb1a56e 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 @@ -145,6 +145,15 @@ void getPropertyTransactionsReturnsNotFoundForMissingProperty() throws Exception .andExpect(jsonPath("$.code").value("PROPERTY_NOT_FOUND")); } + @Test + void getPropertyTransactionsRejectsInvalidYearsAtController() throws Exception { + mockMvc.perform(get("/api/v1/properties/{propertyId}/transactions", 1) + .param("years", "0")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("INVALID_REQUEST")) + .andExpect(jsonPath("$.status").value(400)); + } + @Test void getPropertySafetySummaryReturnsPrecomputedScore() throws Exception { mockMvc.perform(get("/api/v1/properties/{propertyId}/safety-summary", 1) @@ -167,4 +176,13 @@ void getPropertySafetySummaryReturnsNotFoundForMissingProperty() throws Exceptio .andExpect(status().isNotFound()) .andExpect(jsonPath("$.code").value("PROPERTY_NOT_FOUND")); } + + @Test + void getPropertySafetySummaryRejectsUnsupportedRadius() throws Exception { + mockMvc.perform(get("/api/v1/properties/{propertyId}/safety-summary", 1) + .param("radius", "400")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("INVALID_REQUEST")) + .andExpect(jsonPath("$.status").value(400)); + } } diff --git a/backend/src/test/resources/schema.sql b/backend/src/test/resources/schema.sql index 60e37d7..f4afad9 100644 --- a/backend/src/test/resources/schema.sql +++ b/backend/src/test/resources/schema.sql @@ -80,11 +80,14 @@ CREATE TABLE property_score_stat ( property_id BIGINT PRIMARY KEY, safety_score INT, price_score INT, - cctv_count_300m INT, - bell_count_300m INT, - light_count_300m INT, - police_count_500m INT, - updated_at TIMESTAMP + cctv_count_300m INT NOT NULL DEFAULT 0, + bell_count_300m INT NOT NULL DEFAULT 0, + light_count_300m INT NOT NULL DEFAULT 0, + police_count_500m INT NOT NULL DEFAULT 0, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT fk_property_score_stat_property + FOREIGN KEY (property_id) REFERENCES properties(id) ON DELETE CASCADE ); CREATE TABLE region_price_stat (