diff --git a/.codex/hooks.json b/.codex/hooks.json index d03749a..880be27 100644 --- a/.codex/hooks.json +++ b/.codex/hooks.json @@ -7,7 +7,7 @@ { "type": "command", "command": "python \"$(git rev-parse --show-toplevel)/.codex/hooks/codex_hook_bridge.py\" pre-bash", - "commandWindows": "powershell -NoProfile -ExecutionPolicy Bypass -Command \"$root = git rev-parse --show-toplevel; python (Join-Path $root '.codex\\hooks\\codex_hook_bridge.py') pre-bash\"", + "commandWindows": "powershell -NoProfile -ExecutionPolicy Bypass -Command \"`$root = git rev-parse --show-toplevel; python (Join-Path `$root '.codex\\hooks\\codex_hook_bridge.py') pre-bash\"", "statusMessage": "Checking command safety" } ] @@ -20,7 +20,7 @@ { "type": "command", "command": "python \"$(git rev-parse --show-toplevel)/.codex/hooks/codex_hook_bridge.py\" post-edit", - "commandWindows": "powershell -NoProfile -ExecutionPolicy Bypass -Command \"$root = git rev-parse --show-toplevel; python (Join-Path $root '.codex\\hooks\\codex_hook_bridge.py') post-edit\"", + "commandWindows": "powershell -NoProfile -ExecutionPolicy Bypass -Command \"`$root = git rev-parse --show-toplevel; python (Join-Path `$root '.codex\\hooks\\codex_hook_bridge.py') post-edit\"", "statusMessage": "Checking test coverage" } ] @@ -31,7 +31,7 @@ { "type": "command", "command": "python \"$(git rev-parse --show-toplevel)/.codex/hooks/codex_hook_bridge.py\" post-bash", - "commandWindows": "powershell -NoProfile -ExecutionPolicy Bypass -Command \"$root = git rev-parse --show-toplevel; python (Join-Path $root '.codex\\hooks\\codex_hook_bridge.py') post-bash\"", + "commandWindows": "powershell -NoProfile -ExecutionPolicy Bypass -Command \"`$root = git rev-parse --show-toplevel; python (Join-Path `$root '.codex\\hooks\\codex_hook_bridge.py') post-bash\"", "statusMessage": "Checking repeated failures" } ] diff --git a/backend/pom.xml b/backend/pom.xml index abdb45e..1baf2c4 100644 --- a/backend/pom.xml +++ b/backend/pom.xml @@ -34,12 +34,34 @@ org.springframework.boot spring-boot-starter + + org.springframework.boot + spring-boot-starter-jdbc + + + org.springframework.boot + spring-boot-starter-validation + + + org.springframework.boot + spring-boot-starter-web + + + org.postgresql + postgresql + runtime + org.springframework.boot spring-boot-starter-test test + + com.h2database + h2 + test + diff --git a/backend/src/main/java/com/ssafy/salmanhae/common/exception/ApiException.java b/backend/src/main/java/com/ssafy/salmanhae/common/exception/ApiException.java new file mode 100644 index 0000000..0debc59 --- /dev/null +++ b/backend/src/main/java/com/ssafy/salmanhae/common/exception/ApiException.java @@ -0,0 +1,15 @@ +package com.ssafy.salmanhae.common.exception; + +public class ApiException extends RuntimeException { + + private final ErrorCode errorCode; + + public ApiException(ErrorCode errorCode) { + super(errorCode.getMessage()); + this.errorCode = errorCode; + } + + public ErrorCode getErrorCode() { + return errorCode; + } +} diff --git a/backend/src/main/java/com/ssafy/salmanhae/common/exception/ErrorCode.java b/backend/src/main/java/com/ssafy/salmanhae/common/exception/ErrorCode.java new file mode 100644 index 0000000..85e751d --- /dev/null +++ b/backend/src/main/java/com/ssafy/salmanhae/common/exception/ErrorCode.java @@ -0,0 +1,26 @@ +package com.ssafy.salmanhae.common.exception; + +import org.springframework.http.HttpStatus; + +public enum ErrorCode { + INVALID_REQUEST(HttpStatus.BAD_REQUEST, "요청 파라미터가 올바르지 않습니다."), + INVALID_BOUNDS(HttpStatus.BAD_REQUEST, "지도 범위 파라미터가 올바르지 않습니다."), + PROPERTY_NOT_FOUND(HttpStatus.NOT_FOUND, "해당 매물을 찾을 수 없습니다."), + INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "서버 내부 오류가 발생했습니다."); + + private final HttpStatus status; + private final String message; + + ErrorCode(HttpStatus status, String message) { + this.status = status; + this.message = message; + } + + public HttpStatus getStatus() { + return status; + } + + public String getMessage() { + return message; + } +} diff --git a/backend/src/main/java/com/ssafy/salmanhae/common/exception/ErrorResponse.java b/backend/src/main/java/com/ssafy/salmanhae/common/exception/ErrorResponse.java new file mode 100644 index 0000000..a7e7729 --- /dev/null +++ b/backend/src/main/java/com/ssafy/salmanhae/common/exception/ErrorResponse.java @@ -0,0 +1,16 @@ +package com.ssafy.salmanhae.common.exception; + +public record ErrorResponse( + String code, + String message, + int status +) { + + public static ErrorResponse from(ErrorCode errorCode) { + return new ErrorResponse( + errorCode.name(), + errorCode.getMessage(), + errorCode.getStatus().value() + ); + } +} 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 new file mode 100644 index 0000000..87c1f87 --- /dev/null +++ b/backend/src/main/java/com/ssafy/salmanhae/common/exception/GlobalExceptionHandler.java @@ -0,0 +1,29 @@ +package com.ssafy.salmanhae.common.exception; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.MissingServletRequestParameterException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException; + +@RestControllerAdvice +public class GlobalExceptionHandler { + + @ExceptionHandler(ApiException.class) + public ResponseEntity handleApiException(ApiException exception) { + ErrorCode errorCode = exception.getErrorCode(); + return ResponseEntity + .status(errorCode.getStatus()) + .body(ErrorResponse.from(errorCode)); + } + + @ExceptionHandler({ + MissingServletRequestParameterException.class, + MethodArgumentTypeMismatchException.class + }) + public ResponseEntity handleBadRequest(Exception exception) { + return ResponseEntity + .status(ErrorCode.INVALID_REQUEST.getStatus()) + .body(ErrorResponse.from(ErrorCode.INVALID_REQUEST)); + } +} diff --git a/backend/src/main/java/com/ssafy/salmanhae/common/response/ApiResponse.java b/backend/src/main/java/com/ssafy/salmanhae/common/response/ApiResponse.java new file mode 100644 index 0000000..0b2635e --- /dev/null +++ b/backend/src/main/java/com/ssafy/salmanhae/common/response/ApiResponse.java @@ -0,0 +1,11 @@ +package com.ssafy.salmanhae.common.response; + +public record ApiResponse( + T data, + String message +) { + + public static ApiResponse ok(T data) { + return new ApiResponse<>(data, "OK"); + } +} diff --git a/backend/src/main/java/com/ssafy/salmanhae/common/response/ListResponse.java b/backend/src/main/java/com/ssafy/salmanhae/common/response/ListResponse.java new file mode 100644 index 0000000..4b0679a --- /dev/null +++ b/backend/src/main/java/com/ssafy/salmanhae/common/response/ListResponse.java @@ -0,0 +1,14 @@ +package com.ssafy.salmanhae.common.response; + +import java.util.List; + +public record ListResponse( + List items, + int totalCount +) { + + public static ListResponse from(List items) { + List snapshot = List.copyOf(items); + return new ListResponse<>(snapshot, snapshot.size()); + } +} 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 new file mode 100644 index 0000000..5782d00 --- /dev/null +++ b/backend/src/main/java/com/ssafy/salmanhae/controller/property/PropertyController.java @@ -0,0 +1,65 @@ +package com.ssafy.salmanhae.controller.property; + +import java.math.BigDecimal; +import java.util.List; + +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import com.ssafy.salmanhae.common.response.ApiResponse; +import com.ssafy.salmanhae.common.response.ListResponse; +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.PropertyType; +import com.ssafy.salmanhae.model.dto.property.TransactionType; +import com.ssafy.salmanhae.service.property.PropertyService; + +@RestController +@RequestMapping("/api/v1/properties") +public class PropertyController { + + private final PropertyService propertyService; + + public PropertyController(PropertyService propertyService) { + this.propertyService = propertyService; + } + + @GetMapping + public ApiResponse> searchProperties( + @RequestParam BigDecimal west, + @RequestParam BigDecimal east, + @RequestParam BigDecimal south, + @RequestParam BigDecimal north, + @RequestParam(required = false) TransactionType transactionType, + @RequestParam(required = false) PropertyType propertyType, + @RequestParam(required = false) Long minDeposit, + @RequestParam(required = false) Long maxDeposit, + @RequestParam(required = false) Long minPrice, + @RequestParam(required = false) Long maxPrice + ) { + PropertySearchCriteria criteria = new PropertySearchCriteria( + west, + east, + south, + north, + transactionType, + propertyType, + minDeposit, + maxDeposit, + minPrice, + maxPrice + ); + criteria.validateBounds(); + List properties = propertyService.searchProperties(criteria); + return ApiResponse.ok(ListResponse.from(properties)); + } + + @GetMapping("/{propertyId}") + public ApiResponse getProperty(@PathVariable Long propertyId) { + return ApiResponse.ok(propertyService.getProperty(propertyId)); + } +} 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 new file mode 100644 index 0000000..b842994 --- /dev/null +++ b/backend/src/main/java/com/ssafy/salmanhae/model/dao/property/JdbcPropertyDao.java @@ -0,0 +1,130 @@ +package com.ssafy.salmanhae.model.dao.property; + +import java.sql.ResultSet; +import java.sql.SQLException; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import org.springframework.jdbc.core.RowMapper; +import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate; +import org.springframework.stereotype.Repository; + +import com.ssafy.salmanhae.model.dto.property.PropertyRow; +import com.ssafy.salmanhae.model.dto.property.PropertySearchCriteria; +import com.ssafy.salmanhae.model.dto.property.PropertyType; +import com.ssafy.salmanhae.model.dto.property.TransactionType; + +@Repository +public class JdbcPropertyDao implements PropertyDao { + + private static final String PROPERTY_COLUMNS = """ + id, title, building_name, building_key, address, road_address, + legal_dong_code, property_type, transaction_type, deposit, monthly_rent, + price, maintenance_fee, area_m2, floor, total_floor, latitude, longitude, + description + """; + + private final NamedParameterJdbcTemplate jdbcTemplate; + + public JdbcPropertyDao(NamedParameterJdbcTemplate jdbcTemplate) { + this.jdbcTemplate = jdbcTemplate; + } + + @Override + public List findInBounds(PropertySearchCriteria criteria) { + Map params = new HashMap<>(); + params.put("west", criteria.west()); + params.put("east", criteria.east()); + params.put("south", criteria.south()); + params.put("north", criteria.north()); + + StringBuilder sql = new StringBuilder(""" + SELECT %s + FROM properties + WHERE is_active = true + AND longitude BETWEEN :west AND :east + 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()); + } + + sql.append(" ORDER BY id ASC"); + return jdbcTemplate.query(sql.toString(), params, propertyRowMapper()); + } + + @Override + public Optional findActiveById(Long propertyId) { + String sql = """ + SELECT %s + FROM properties + WHERE id = :id + AND is_active = true + """.formatted(PROPERTY_COLUMNS); + Map params = Map.of("id", propertyId); + List rows = jdbcTemplate.query(sql, params, propertyRowMapper()); + return rows.stream().findFirst(); + } + + private RowMapper propertyRowMapper() { + return (rs, rowNum) -> mapPropertyRow(rs); + } + + private PropertyRow mapPropertyRow(ResultSet rs) throws SQLException { + return new PropertyRow( + rs.getLong("id"), + rs.getString("title"), + rs.getString("building_name"), + rs.getString("building_key"), + rs.getString("address"), + rs.getString("road_address"), + rs.getString("legal_dong_code"), + PropertyType.valueOf(rs.getString("property_type")), + TransactionType.valueOf(rs.getString("transaction_type")), + nullableLong(rs, "deposit"), + nullableLong(rs, "monthly_rent"), + nullableLong(rs, "price"), + nullableLong(rs, "maintenance_fee"), + rs.getBigDecimal("area_m2"), + nullableInteger(rs, "floor"), + nullableInteger(rs, "total_floor"), + rs.getBigDecimal("latitude"), + rs.getBigDecimal("longitude"), + rs.getString("description") + ); + } + + private Long nullableLong(ResultSet rs, String column) throws SQLException { + long value = rs.getLong(column); + return rs.wasNull() ? null : value; + } + + private Integer nullableInteger(ResultSet rs, String column) throws SQLException { + int value = rs.getInt(column); + return rs.wasNull() ? null : value; + } +} 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 new file mode 100644 index 0000000..e75fe06 --- /dev/null +++ b/backend/src/main/java/com/ssafy/salmanhae/model/dao/property/PropertyDao.java @@ -0,0 +1,14 @@ +package com.ssafy.salmanhae.model.dao.property; + +import java.util.List; +import java.util.Optional; + +import com.ssafy.salmanhae.model.dto.property.PropertyRow; +import com.ssafy.salmanhae.model.dto.property.PropertySearchCriteria; + +public interface PropertyDao { + + List findInBounds(PropertySearchCriteria criteria); + + Optional findActiveById(Long propertyId); +} diff --git a/backend/src/main/java/com/ssafy/salmanhae/model/dto/property/PropertyDetailResponse.java b/backend/src/main/java/com/ssafy/salmanhae/model/dto/property/PropertyDetailResponse.java new file mode 100644 index 0000000..0e04d76 --- /dev/null +++ b/backend/src/main/java/com/ssafy/salmanhae/model/dto/property/PropertyDetailResponse.java @@ -0,0 +1,50 @@ +package com.ssafy.salmanhae.model.dto.property; + +import java.math.BigDecimal; + +public record PropertyDetailResponse( + Long id, + String title, + String buildingName, + String buildingKey, + String address, + String roadAddress, + String legalDongCode, + PropertyType propertyType, + TransactionType transactionType, + Long deposit, + Long monthlyRent, + Long price, + Long maintenanceFee, + BigDecimal areaM2, + Integer floor, + Integer totalFloor, + BigDecimal latitude, + BigDecimal longitude, + String description +) { + + public static PropertyDetailResponse from(PropertyRow row) { + return new PropertyDetailResponse( + row.id(), + row.title(), + row.buildingName(), + row.buildingKey(), + row.address(), + row.roadAddress(), + row.legalDongCode(), + row.propertyType(), + row.transactionType(), + row.deposit(), + row.monthlyRent(), + row.price(), + row.maintenanceFee(), + row.areaM2(), + row.floor(), + row.totalFloor(), + row.latitude(), + row.longitude(), + row.description() + ); + } +} diff --git a/backend/src/main/java/com/ssafy/salmanhae/model/dto/property/PropertyRow.java b/backend/src/main/java/com/ssafy/salmanhae/model/dto/property/PropertyRow.java new file mode 100644 index 0000000..3677c26 --- /dev/null +++ b/backend/src/main/java/com/ssafy/salmanhae/model/dto/property/PropertyRow.java @@ -0,0 +1,26 @@ +package com.ssafy.salmanhae.model.dto.property; + +import java.math.BigDecimal; + +public record PropertyRow( + Long id, + String title, + String buildingName, + String buildingKey, + String address, + String roadAddress, + String legalDongCode, + PropertyType propertyType, + TransactionType transactionType, + Long deposit, + Long monthlyRent, + Long price, + Long maintenanceFee, + BigDecimal areaM2, + Integer floor, + Integer totalFloor, + BigDecimal latitude, + BigDecimal longitude, + String description +) { +} 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 new file mode 100644 index 0000000..5b21398 --- /dev/null +++ b/backend/src/main/java/com/ssafy/salmanhae/model/dto/property/PropertySearchCriteria.java @@ -0,0 +1,54 @@ +package com.ssafy.salmanhae.model.dto.property; + +import java.math.BigDecimal; + +import com.ssafy.salmanhae.common.exception.ApiException; +import com.ssafy.salmanhae.common.exception.ErrorCode; + +public record PropertySearchCriteria( + BigDecimal west, + BigDecimal east, + BigDecimal south, + BigDecimal north, + TransactionType transactionType, + PropertyType propertyType, + Long minDeposit, + Long maxDeposit, + Long minPrice, + Long maxPrice +) { + + private static final BigDecimal MIN_LONGITUDE = BigDecimal.valueOf(-180); + private static final BigDecimal MAX_LONGITUDE = BigDecimal.valueOf(180); + private static final BigDecimal MIN_LATITUDE = BigDecimal.valueOf(-90); + private static final BigDecimal MAX_LATITUDE = BigDecimal.valueOf(90); + + public void validateBounds() { + if (west == null || east == null || south == null || north == null) { + throw new ApiException(ErrorCode.INVALID_BOUNDS); + } + if (west.compareTo(east) >= 0 || south.compareTo(north) >= 0) { + throw new ApiException(ErrorCode.INVALID_BOUNDS); + } + if (west.compareTo(MIN_LONGITUDE) < 0 || east.compareTo(MAX_LONGITUDE) > 0 + || south.compareTo(MIN_LATITUDE) < 0 || north.compareTo(MAX_LATITUDE) > 0) { + throw new ApiException(ErrorCode.INVALID_BOUNDS); + } + if (minDeposit != null && maxDeposit != null && minDeposit > maxDeposit) { + throw new ApiException(ErrorCode.INVALID_REQUEST); + } + if (isNegative(minDeposit) || isNegative(maxDeposit)) { + throw new ApiException(ErrorCode.INVALID_REQUEST); + } + if (minPrice != null && maxPrice != null && minPrice > maxPrice) { + throw new ApiException(ErrorCode.INVALID_REQUEST); + } + if (isNegative(minPrice) || isNegative(maxPrice)) { + throw new ApiException(ErrorCode.INVALID_REQUEST); + } + } + + private boolean isNegative(Long value) { + return value != null && value < 0; + } +} diff --git a/backend/src/main/java/com/ssafy/salmanhae/model/dto/property/PropertySummaryResponse.java b/backend/src/main/java/com/ssafy/salmanhae/model/dto/property/PropertySummaryResponse.java new file mode 100644 index 0000000..cd7916a --- /dev/null +++ b/backend/src/main/java/com/ssafy/salmanhae/model/dto/property/PropertySummaryResponse.java @@ -0,0 +1,38 @@ +package com.ssafy.salmanhae.model.dto.property; + +import java.math.BigDecimal; + +public record PropertySummaryResponse( + Long id, + String title, + String buildingName, + String address, + PropertyType propertyType, + TransactionType transactionType, + Long deposit, + Long monthlyRent, + Long price, + BigDecimal areaM2, + Integer floor, + BigDecimal latitude, + BigDecimal longitude +) { + + public static PropertySummaryResponse from(PropertyRow row) { + return new PropertySummaryResponse( + row.id(), + row.title(), + row.buildingName(), + row.address(), + row.propertyType(), + row.transactionType(), + row.deposit(), + row.monthlyRent(), + row.price(), + row.areaM2(), + row.floor(), + row.latitude(), + row.longitude() + ); + } +} diff --git a/backend/src/main/java/com/ssafy/salmanhae/model/dto/property/PropertyType.java b/backend/src/main/java/com/ssafy/salmanhae/model/dto/property/PropertyType.java new file mode 100644 index 0000000..42856a3 --- /dev/null +++ b/backend/src/main/java/com/ssafy/salmanhae/model/dto/property/PropertyType.java @@ -0,0 +1,9 @@ +package com.ssafy.salmanhae.model.dto.property; + +public enum PropertyType { + ONE_ROOM, + OFFICETEL, + VILLA, + APARTMENT, + MULTI_FAMILY +} diff --git a/backend/src/main/java/com/ssafy/salmanhae/model/dto/property/TransactionType.java b/backend/src/main/java/com/ssafy/salmanhae/model/dto/property/TransactionType.java new file mode 100644 index 0000000..be38f2f --- /dev/null +++ b/backend/src/main/java/com/ssafy/salmanhae/model/dto/property/TransactionType.java @@ -0,0 +1,7 @@ +package com.ssafy.salmanhae.model.dto.property; + +public enum TransactionType { + MONTHLY_RENT, + JEONSE, + SALE +} 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 new file mode 100644 index 0000000..2df9e18 --- /dev/null +++ b/backend/src/main/java/com/ssafy/salmanhae/service/property/PropertyService.java @@ -0,0 +1,14 @@ +package com.ssafy.salmanhae.service.property; + +import java.util.List; + +import com.ssafy.salmanhae.model.dto.property.PropertyDetailResponse; +import com.ssafy.salmanhae.model.dto.property.PropertySearchCriteria; +import com.ssafy.salmanhae.model.dto.property.PropertySummaryResponse; + +public interface PropertyService { + + List searchProperties(PropertySearchCriteria criteria); + + PropertyDetailResponse getProperty(Long propertyId); +} 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 new file mode 100644 index 0000000..ba78ef0 --- /dev/null +++ b/backend/src/main/java/com/ssafy/salmanhae/service/property/PropertyServiceImpl.java @@ -0,0 +1,37 @@ +package com.ssafy.salmanhae.service.property; + +import java.util.List; + +import org.springframework.stereotype.Service; + +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.PropertyDetailResponse; +import com.ssafy.salmanhae.model.dto.property.PropertySearchCriteria; +import com.ssafy.salmanhae.model.dto.property.PropertySummaryResponse; + +@Service +public class PropertyServiceImpl implements PropertyService { + + private final PropertyDao propertyDao; + + public PropertyServiceImpl(PropertyDao propertyDao) { + this.propertyDao = propertyDao; + } + + @Override + public List searchProperties(PropertySearchCriteria criteria) { + criteria.validateBounds(); + return propertyDao.findInBounds(criteria).stream() + .map(PropertySummaryResponse::from) + .toList(); + } + + @Override + public PropertyDetailResponse getProperty(Long propertyId) { + return propertyDao.findActiveById(propertyId) + .map(PropertyDetailResponse::from) + .orElseThrow(() -> new ApiException(ErrorCode.PROPERTY_NOT_FOUND)); + } +} diff --git a/backend/src/main/resources/application.properties b/backend/src/main/resources/application.properties index 0a57d49..51ace95 100644 --- a/backend/src/main/resources/application.properties +++ b/backend/src/main/resources/application.properties @@ -1 +1,9 @@ spring.application.name=salmanhae +# Required environment variables: +# SUPABASE_DB_URL - PostgreSQL JDBC connection string +# SUPABASE_DB_USERNAME - database username +# SUPABASE_DB_PASSWORD - database password +spring.datasource.url=${SUPABASE_DB_URL:} +spring.datasource.username=${SUPABASE_DB_USERNAME:} +spring.datasource.password=${SUPABASE_DB_PASSWORD:} +spring.datasource.driver-class-name=org.postgresql.Driver diff --git a/backend/src/test/java/com/ssafy/salmanhae/SalmanhaeApplicationTests.java b/backend/src/test/java/com/ssafy/salmanhae/SalmanhaeApplicationTests.java index 30e05bb..a10cbb4 100644 --- a/backend/src/test/java/com/ssafy/salmanhae/SalmanhaeApplicationTests.java +++ b/backend/src/test/java/com/ssafy/salmanhae/SalmanhaeApplicationTests.java @@ -2,8 +2,10 @@ import org.junit.jupiter.api.Test; import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; @SpringBootTest +@ActiveProfiles("test") class SalmanhaeApplicationTests { @Test 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 new file mode 100644 index 0000000..de88203 --- /dev/null +++ b/backend/src/test/java/com/ssafy/salmanhae/controller/property/PropertyControllerTest.java @@ -0,0 +1,126 @@ +package com.ssafy.salmanhae.controller.property; + +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 PropertyControllerTest { + + @Autowired + private MockMvc mockMvc; + + @Test + void searchPropertiesReturnsActivePropertiesInBounds() throws Exception { + mockMvc.perform(get("/api/v1/properties") + .param("west", "126.93") + .param("east", "126.94") + .param("south", "37.46") + .param("north", "37.48")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.message").value("OK")) + .andExpect(jsonPath("$.data.totalCount").value(2)) + .andExpect(jsonPath("$.data.items", hasSize(2))) + .andExpect(jsonPath("$.data.items[0].id").value(1)) + .andExpect(jsonPath("$.data.items[0].title").value("대학동 그린빌 월세")) + .andExpect(jsonPath("$.data.items[0].transactionType").value("MONTHLY_RENT")); + } + + @Test + void searchPropertiesAppliesTransactionAndPropertyFilters() 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("transactionType", "SALE") + .param("propertyType", "APARTMENT") + .param("minPrice", "700000000") + .param("maxPrice", "750000000")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.totalCount").value(1)) + .andExpect(jsonPath("$.data.items[0].id").value(2)) + .andExpect(jsonPath("$.data.items[0].price").value(720000000)); + } + + @Test + void searchPropertiesAppliesDepositFilters() 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("minDeposit", "9000000") + .param("maxDeposit", "11000000")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.totalCount").value(1)) + .andExpect(jsonPath("$.data.items[0].id").value(1)) + .andExpect(jsonPath("$.data.items[0].deposit").value(10000000)); + } + + @Test + void searchPropertiesRejectsInvalidBounds() throws Exception { + mockMvc.perform(get("/api/v1/properties") + .param("west", "127.00") + .param("east", "126.00") + .param("south", "37.46") + .param("north", "37.48")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("INVALID_BOUNDS")) + .andExpect(jsonPath("$.status").value(400)); + } + + @Test + void searchPropertiesRejectsInvalidLatitudeBounds() throws Exception { + mockMvc.perform(get("/api/v1/properties") + .param("west", "126.93") + .param("east", "126.94") + .param("south", "37.48") + .param("north", "37.46")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("INVALID_BOUNDS")) + .andExpect(jsonPath("$.status").value(400)); + } + + @Test + void searchPropertiesRejectsNegativeFilters() 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("minPrice", "-1")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("INVALID_REQUEST")) + .andExpect(jsonPath("$.status").value(400)); + } + + @Test + void getPropertyReturnsActivePropertyDetail() throws Exception { + mockMvc.perform(get("/api/v1/properties/{propertyId}", 1)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.message").value("OK")) + .andExpect(jsonPath("$.data.id").value(1)) + .andExpect(jsonPath("$.data.buildingKey").value("1162010200:ONE_ROOM:그린빌:12-3")) + .andExpect(jsonPath("$.data.maintenanceFee").value(70000)) + .andExpect(jsonPath("$.data.description").value("대학가 인근 원룸입니다.")); + } + + @Test + void getPropertyReturnsNotFoundForMissingOrInactiveProperty() throws Exception { + mockMvc.perform(get("/api/v1/properties/{propertyId}", 3)) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value("PROPERTY_NOT_FOUND")) + .andExpect(jsonPath("$.status").value(404)); + } +} diff --git a/backend/src/test/java/com/ssafy/salmanhae/controller/property/PropertyHttpIntegrationTest.java b/backend/src/test/java/com/ssafy/salmanhae/controller/property/PropertyHttpIntegrationTest.java new file mode 100644 index 0000000..0227dd9 --- /dev/null +++ b/backend/src/test/java/com/ssafy/salmanhae/controller/property/PropertyHttpIntegrationTest.java @@ -0,0 +1,54 @@ +package com.ssafy.salmanhae.controller.property; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.Map; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.client.TestRestTemplate; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.test.context.ActiveProfiles; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@ActiveProfiles("test") +class PropertyHttpIntegrationTest { + + @Autowired + private TestRestTemplate restTemplate; + + @Test + void listEndpointReturnsOkWithValidBounds() { + ResponseEntity listResponse = restTemplate.getForEntity( + "/api/v1/properties?west=126.93&east=126.94&south=37.46&north=37.48", + Map.class + ); + + assertThat(listResponse.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(listResponse.getBody()).containsEntry("message", "OK"); + } + + @Test + void detailEndpointReturnsOkForExistingProperty() { + ResponseEntity detailResponse = restTemplate.getForEntity( + "/api/v1/properties/1", + Map.class + ); + + assertThat(detailResponse.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(detailResponse.getBody()).containsEntry("message", "OK"); + } + + @Test + void detailEndpointReturnsNotFoundForMissingProperty() { + ResponseEntity notFoundResponse = restTemplate.getForEntity( + "/api/v1/properties/9999", + Map.class + ); + + assertThat(notFoundResponse.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); + assertThat(notFoundResponse.getBody()).containsEntry("code", "PROPERTY_NOT_FOUND"); + } +} diff --git a/backend/src/test/resources/application-test.properties b/backend/src/test/resources/application-test.properties new file mode 100644 index 0000000..d531e57 --- /dev/null +++ b/backend/src/test/resources/application-test.properties @@ -0,0 +1,5 @@ +spring.datasource.url=jdbc:h2:mem:salmanhae;MODE=PostgreSQL;DATABASE_TO_LOWER=TRUE;DEFAULT_NULL_ORDERING=HIGH +spring.datasource.username=sa +spring.datasource.password= +spring.datasource.driver-class-name=org.h2.Driver +spring.sql.init.mode=always diff --git a/backend/src/test/resources/data.sql b/backend/src/test/resources/data.sql new file mode 100644 index 0000000..ca368f6 --- /dev/null +++ b/backend/src/test/resources/data.sql @@ -0,0 +1,39 @@ +INSERT INTO properties ( + id, title, building_name, building_key, property_type, transaction_type, + deposit, monthly_rent, price, maintenance_fee, area_m2, floor, total_floor, + address, road_address, sido, sigungu, dong, legal_dong_code, + latitude, longitude, description, source, source_property_id, registered_at, + is_active, created_at, updated_at +) VALUES +( + 1, '대학동 그린빌 월세', '그린빌', '1162010200:ONE_ROOM:그린빌:12-3', + 'ONE_ROOM', 'MONTHLY_RENT', 10000000, 550000, NULL, 70000, + 22.50, 3, 5, '서울특별시 관악구 대학동 12-3', '서울특별시 관악구 대학길 12', + '서울특별시', '관악구', '대학동', '1162010200', + 37.4701230, 126.9364560, '대학가 인근 원룸입니다.', + 'MVP_SYNTHETIC', 'synthetic-1', DATE '2026-06-01', TRUE, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP +), +( + 2, '대학동 그린빌 매매', '그린빌', '1162010200:APARTMENT:그린빌:12-3', + 'APARTMENT', 'SALE', NULL, NULL, 720000000, 120000, + 59.90, 8, 15, '서울특별시 관악구 대학동 12-3', '서울특별시 관악구 대학길 12', + '서울특별시', '관악구', '대학동', '1162010200', + 37.4710000, 126.9370000, '실거래가 기반 매매 더미입니다.', + 'MVP_SYNTHETIC', 'synthetic-2', DATE '2026-06-01', TRUE, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP +), +( + 3, '비활성 매물', '비활성빌', '1162010200:ONE_ROOM:비활성빌:99-9', + 'ONE_ROOM', 'MONTHLY_RENT', 5000000, 400000, NULL, 50000, + 18.00, 2, 4, '서울특별시 관악구 대학동 99-9', NULL, + '서울특별시', '관악구', '대학동', '1162010200', + 37.4705000, 126.9365000, '노출되지 않아야 합니다.', + 'MVP_SYNTHETIC', 'synthetic-3', DATE '2026-06-01', FALSE, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP +), +( + 4, '범위 밖 매물', '멀리빌', '1162010200:ONE_ROOM:멀리빌:1-1', + 'ONE_ROOM', 'MONTHLY_RENT', 9000000, 520000, NULL, 60000, + 20.00, 4, 5, '서울특별시 관악구 대학동 1-1', NULL, + '서울특별시', '관악구', '대학동', '1162010200', + 37.6000000, 127.1000000, '지도 범위 밖 매물입니다.', + 'MVP_SYNTHETIC', 'synthetic-4', DATE '2026-06-01', TRUE, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP +); diff --git a/backend/src/test/resources/schema.sql b/backend/src/test/resources/schema.sql new file mode 100644 index 0000000..406dfb3 --- /dev/null +++ b/backend/src/test/resources/schema.sql @@ -0,0 +1,38 @@ +DROP TABLE IF EXISTS properties; + +CREATE TABLE properties ( + id BIGINT PRIMARY KEY, + title VARCHAR(200) NOT NULL, + building_name VARCHAR(200), + building_key VARCHAR(300), + anchor_transaction_id BIGINT, + property_type VARCHAR(20) NOT NULL, + transaction_type VARCHAR(20) NOT NULL, + deposit BIGINT, + monthly_rent BIGINT, + price BIGINT, + maintenance_fee BIGINT, + area_m2 DECIMAL(8, 2), + floor INT, + total_floor INT, + address TEXT, + road_address TEXT, + sido VARCHAR(20), + sigungu VARCHAR(30), + dong VARCHAR(30), + legal_dong_code VARCHAR(10), + latitude DECIMAL(10, 7), + longitude DECIMAL(10, 7), + geocoding_provider VARCHAR(50), + geocoding_quality VARCHAR(30), + geocoded_at TIMESTAMP, + description TEXT, + source VARCHAR(30), + source_property_id VARCHAR(120), + source_url TEXT, + crawled_at TIMESTAMP, + registered_at DATE, + is_active BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMP, + updated_at TIMESTAMP +); diff --git a/phases/property-api-be/phase1-property-api-be.md b/phases/property-api-be/phase1-property-api-be.md new file mode 100644 index 0000000..481f101 --- /dev/null +++ b/phases/property-api-be/phase1-property-api-be.md @@ -0,0 +1,43 @@ +# Phase 1: Property API Backend + +## Goal +Implement the F-1 public property lookup backend API using the existing `properties` table. The API must support map bounds search, basic filters, and property detail lookup for the frontend map marker flow. + +## Files +- `backend/pom.xml` - Add backend dependencies needed for REST API, validation, database access, PostgreSQL runtime, and focused tests. +- `backend/src/main/resources/application.properties` - Add datasource placeholders using environment variables only. +- `backend/src/main/java/com/ssafy/salmanhae/controller/property/*` - Add property REST controller with request validation and delegation only. +- `backend/src/main/java/com/ssafy/salmanhae/service/property/*` - Add property service business logic. +- `backend/src/main/java/com/ssafy/salmanhae/model/dao/property/*` - Add DAO/query layer for `properties`. +- `backend/src/main/java/com/ssafy/salmanhae/model/dto/property/*` - Add request/response DTOs for list and detail responses. +- `backend/src/main/java/com/ssafy/salmanhae/common/*` - Add minimal common API response/error handling if no existing pattern exists. +- `backend/src/test/java/com/ssafy/salmanhae/**` - Add tests first for bounds validation, filter handling, list response, detail response, and not-found behavior. +- `docs/08_API_SPEC.md` - Update only if implementation response fields or error behavior differs from the current spec. + +## Done When +- [ ] `GET /api/v1/properties?west=...&east=...&south=...&north=...` returns active properties inside the map bounds. +- [ ] The list endpoint supports `transactionType`, `propertyType`, `minDeposit`, `maxDeposit`, `minPrice`, and `maxPrice`. +- [ ] `GET /api/v1/properties/{propertyId}` returns one active property by id. +- [ ] Missing property returns `PROPERTY_NOT_FOUND` with HTTP 404. +- [ ] Invalid bounds return `INVALID_BOUNDS` or `INVALID_REQUEST` with HTTP 400. +- [ ] Responses use camelCase JSON and the common `{ data, message }` success envelope from `docs/08_API_SPEC.md`. +- [ ] Business logic lives in Service classes; Controller only validates input and delegates. +- [ ] `cd backend && ./mvnw test` passes. + +## Architecture Rules +- F-1 is public: property lookup must not require Supabase JWT. +- Frontend will call only Spring Boot REST APIs. +- Public API data must be read from DB, not live external API calls. +- All business logic must live in Service classes. +- API keys, DB URLs, usernames, and passwords must be read from environment variables only. +- Do not implement MVP-excluded features: real brokerage, community, precise HUG judgment, registry AI analysis. +- Do not implement F-4/F-3 scope in this phase: safety summary, region price average layer, and transaction comparison endpoints are follow-up phases. + +## Implementation Instructions +1. Start with backend tests that describe the expected controller/service behavior. +2. Prefer the repository's documented Controller-Service-DAO layering. If adding a persistence dependency, keep it minimal and aligned with the existing architecture documentation. +3. Use `properties.latitude` and `properties.longitude` for bounds filtering and `is_active = true` for visible map properties. +4. Keep query filters optional and composable. Numeric filters should ignore null values. +5. Return only fields already documented for the F-1 property list/detail API unless the spec is updated in the same phase. +6. Do not call Naver Maps, MOLIT, Supabase REST, FastAPI, or any LLM from this backend API. +7. Run `cd backend && ./mvnw test` before completing the phase. diff --git a/phases/property-api-be/phase1.status.json b/phases/property-api-be/phase1.status.json new file mode 100644 index 0000000..5d17d4a --- /dev/null +++ b/phases/property-api-be/phase1.status.json @@ -0,0 +1,8 @@ +{ + "status": "completed", + "phase": "phase1-property-api-be.md", + "issue_number": 9, + "timestamp": "2026-06-18T22:49:53", + "detail": "Manual continuation after Codex harness executor was blocked by local gh auth and codex.exe access. Implemented F-1 property backend API and verified with backend Maven tests.", + "runner": "codex" +}