diff --git a/src/main/kotlin/com/hh2/katj/history/component/KakaoApiManager.kt b/src/main/kotlin/com/hh2/katj/history/component/KakaoApiManager.kt new file mode 100644 index 00000000..3048a359 --- /dev/null +++ b/src/main/kotlin/com/hh2/katj/history/component/KakaoApiManager.kt @@ -0,0 +1,48 @@ +package com.hh2.katj.history.component + +import com.hh2.katj.user.model.dto.KakaoAddressSearchResponse +import org.springframework.http.HttpEntity +import org.springframework.http.HttpHeaders +import org.springframework.http.HttpMethod +import org.springframework.stereotype.Component +import org.springframework.web.client.RestTemplate +import java.net.URI + +@Component +class KakaoApiManager( + private val kakaoUriBuilder: KakaoUriBuilder, + private val restTemplate: RestTemplate, +) { + + private val kakaoRestApiKey: String = "e53e7a4c2825be5f0c4c487a91ae3e2b" + + fun requestAddressSearch(address: String?): KakaoAddressSearchResponse? { + checkNotNull(address) { return null } + + val uri: URI = kakaoUriBuilder.buildUriByAddressSearch(address) + val headers = HttpHeaders() + headers.set(HttpHeaders.AUTHORIZATION, "KakaoAK $kakaoRestApiKey") + + val httpEntity = HttpEntity(headers) + + val response = restTemplate.exchange(uri, HttpMethod.GET, httpEntity, KakaoAddressSearchResponse::class.java).body + + return response + } + + fun requestCarDirectionSearch(origin: String?, destination: String?): KakaoAddressSearchResponse? { + // TODO 형식 검사 + // ${X좌표},${Y좌표},name=${출발지명} 또는 ${X좌표},${Y좌표} + checkNotNull(origin) + checkNotNull(destination) + + val uri = kakaoUriBuilder.buildUriByCarDirectionSearch(origin, destination) + val headers = HttpHeaders() + headers.set(HttpHeaders.AUTHORIZATION, "KakaoAK $kakaoRestApiKey") + + val httpEntity = HttpEntity(headers) + + return restTemplate.exchange(uri, HttpMethod.GET, httpEntity, KakaoAddressSearchResponse::class.java).body + } + +} diff --git a/src/main/kotlin/com/hh2/katj/history/component/KakaoUriBuilder.kt b/src/main/kotlin/com/hh2/katj/history/component/KakaoUriBuilder.kt new file mode 100644 index 00000000..8bf9fbc0 --- /dev/null +++ b/src/main/kotlin/com/hh2/katj/history/component/KakaoUriBuilder.kt @@ -0,0 +1,30 @@ +package com.hh2.katj.history.component + +import org.springframework.stereotype.Component +import org.springframework.web.util.UriComponentsBuilder +import java.net.URI + +@Component +class KakaoUriBuilder { + + private companion object { + const val KAKAO_SEARCH_ADDRESS_URL = "https://dapi.kakao.com/v2/local/search/address.json" + const val KAKAO_SEARCH_CAR_DIRECTION_URL = "https://apis-navi.kakaomobility.com/v1/directions" + } + + fun buildUriByAddressSearch(address: String): URI { + val uriBuilder = UriComponentsBuilder.fromHttpUrl(KAKAO_SEARCH_ADDRESS_URL) + uriBuilder.queryParam("query", address) + + return uriBuilder.build().encode().toUri() + } + + fun buildUriByCarDirectionSearch(origin: String, destination: String): URI { + val uriBuilder = UriComponentsBuilder.fromHttpUrl(KAKAO_SEARCH_CAR_DIRECTION_URL) + uriBuilder.queryParam("origin", origin) + uriBuilder.queryParam("destination", destination) + + return uriBuilder.build().encode().toUri() + } + +} diff --git a/src/main/kotlin/com/hh2/katj/history/component/LocationHistoryManager.kt b/src/main/kotlin/com/hh2/katj/history/component/LocationHistoryManager.kt new file mode 100644 index 00000000..43d0f49c --- /dev/null +++ b/src/main/kotlin/com/hh2/katj/history/component/LocationHistoryManager.kt @@ -0,0 +1,20 @@ +package com.hh2.katj.history.component + +import com.hh2.katj.history.model.entity.SearchLocationHistory +import com.hh2.katj.history.repository.LocationHistoryRepository +import com.hh2.katj.user.model.entity.User +import com.hh2.katj.util.model.RoadAddress +import org.springframework.stereotype.Component +import org.springframework.transaction.annotation.Transactional + +@Component +class LocationHistoryManager( + private val locationHistoryRepository: LocationHistoryRepository, +) { + + @Transactional + fun addLocationHistory(user: User, keyword: String, roadAddress: RoadAddress): SearchLocationHistory { + val history = SearchLocationHistory(user = user, keyword = keyword, roadAddress = roadAddress) + return locationHistoryRepository.save(history) + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/hh2/katj/history/controller/LocationHistoryController.kt b/src/main/kotlin/com/hh2/katj/history/controller/LocationHistoryController.kt new file mode 100644 index 00000000..2fb1dd50 --- /dev/null +++ b/src/main/kotlin/com/hh2/katj/history/controller/LocationHistoryController.kt @@ -0,0 +1,27 @@ +package com.hh2.katj.history.controller + +import com.hh2.katj.history.model.dto.RequestLocationHistory +import com.hh2.katj.history.model.dto.ResponseLocationHistory +import com.hh2.katj.history.service.LocationHistoryService +import com.hh2.katj.user.service.UserService +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController + +@RequestMapping("/location") +@RestController +class LocationHistoryController( + private val locationHistoryService: LocationHistoryService, + private val userService: UserService, +) { + + @PostMapping + fun saveLocation(@RequestBody request: RequestLocationHistory): ResponseEntity { + val user = userService.findByUserId(userId = request.userId) + val response = locationHistoryService.saveLocationHistory(user, request.keyword) + return ResponseEntity.ok(response) + } + +} \ No newline at end of file diff --git a/src/main/kotlin/com/hh2/katj/history/model/dto/RequestLocationHistory.kt b/src/main/kotlin/com/hh2/katj/history/model/dto/RequestLocationHistory.kt new file mode 100644 index 00000000..28b294bb --- /dev/null +++ b/src/main/kotlin/com/hh2/katj/history/model/dto/RequestLocationHistory.kt @@ -0,0 +1,6 @@ +package com.hh2.katj.history.model.dto + +data class RequestLocationHistory( + val userId: Long, + val keyword: String, +) \ No newline at end of file diff --git a/src/main/kotlin/com/hh2/katj/history/model/dto/ResponseLocationHistory.kt b/src/main/kotlin/com/hh2/katj/history/model/dto/ResponseLocationHistory.kt new file mode 100644 index 00000000..db508d42 --- /dev/null +++ b/src/main/kotlin/com/hh2/katj/history/model/dto/ResponseLocationHistory.kt @@ -0,0 +1,8 @@ +package com.hh2.katj.history.model.dto + +import com.hh2.katj.util.model.RoadAddress + +data class ResponseLocationHistory( + val userId: Long, + val roadAddress: RoadAddress, +) \ No newline at end of file diff --git a/src/main/kotlin/com/hh2/katj/history/model/entity/SearchLocationHistory.kt b/src/main/kotlin/com/hh2/katj/history/model/entity/SearchLocationHistory.kt new file mode 100644 index 00000000..935d4b89 --- /dev/null +++ b/src/main/kotlin/com/hh2/katj/history/model/entity/SearchLocationHistory.kt @@ -0,0 +1,39 @@ +package com.hh2.katj.history.model.entity + +import com.hh2.katj.history.model.dto.ResponseLocationHistory +import com.hh2.katj.user.model.entity.User +import com.hh2.katj.util.model.BaseEntity +import com.hh2.katj.util.model.RoadAddress +import jakarta.persistence.* + +@Entity +@Table(name = "search_location_history") +class SearchLocationHistory( + user: User, + roadAddress: RoadAddress, + keyword: String, +) : BaseEntity() { + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false, + foreignKey = ForeignKey(ConstraintMode.NO_CONSTRAINT) + ) + var user = user + protected set + + @Embedded + @Column(nullable = false) + var roadAddress = roadAddress + protected set + + @Column(nullable = false) + var keyword = keyword + protected set + + fun toResponseDto(): ResponseLocationHistory { + return ResponseLocationHistory( + userId = user.id, + roadAddress = roadAddress, + ) + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/hh2/katj/history/repository/LocationHistoryRepository.kt b/src/main/kotlin/com/hh2/katj/history/repository/LocationHistoryRepository.kt new file mode 100644 index 00000000..fe66f513 --- /dev/null +++ b/src/main/kotlin/com/hh2/katj/history/repository/LocationHistoryRepository.kt @@ -0,0 +1,7 @@ +package com.hh2.katj.history.repository + +import com.hh2.katj.history.model.entity.SearchLocationHistory +import org.springframework.data.jpa.repository.JpaRepository + +interface LocationHistoryRepository : JpaRepository { +} \ No newline at end of file diff --git a/src/main/kotlin/com/hh2/katj/history/service/LocationHistoryService.kt b/src/main/kotlin/com/hh2/katj/history/service/LocationHistoryService.kt new file mode 100644 index 00000000..404a05b7 --- /dev/null +++ b/src/main/kotlin/com/hh2/katj/history/service/LocationHistoryService.kt @@ -0,0 +1,30 @@ +package com.hh2.katj.history.service + +import com.hh2.katj.history.component.KakaoApiManager +import com.hh2.katj.history.component.LocationHistoryManager +import com.hh2.katj.history.model.dto.ResponseLocationHistory +import com.hh2.katj.user.model.entity.User +import com.hh2.katj.util.exception.ExceptionMessage +import com.hh2.katj.util.exception.failWithMessage +import org.springframework.stereotype.Service + +@Service +class LocationHistoryService( + private val locationHistoryManager: LocationHistoryManager, + private val kakaoApiManager: KakaoApiManager, +) { + + fun saveLocationHistory(user: User, keyword: String): ResponseLocationHistory { + val response = kakaoApiManager.requestAddressSearch(keyword) + + // TODO 예외 메시지 + checkNotNull(response) { "api 호출 오류" } + check(response.documents.isNotEmpty()) { + failWithMessage(ExceptionMessage.NO_SEARCH_LOCATION_RESULT.name) + } + + val roadAddress = response.documents[0].roadAddress + + return locationHistoryManager.addLocationHistory(user, keyword, roadAddress).toResponseDto() + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/hh2/katj/user/model/dto/KakaoAddressSearchResponse.kt b/src/main/kotlin/com/hh2/katj/user/model/dto/KakaoAddressSearchResponse.kt new file mode 100644 index 00000000..8b8c41e9 --- /dev/null +++ b/src/main/kotlin/com/hh2/katj/user/model/dto/KakaoAddressSearchResponse.kt @@ -0,0 +1,85 @@ +package com.hh2.katj.user.model.dto + +import com.fasterxml.jackson.annotation.JsonProperty +import com.hh2.katj.util.model.RoadAddress + +data class KakaoAddressSearchResponse( + @JsonProperty("meta") + var meta: Meta, + + @JsonProperty("documents") + var documents: List, +) + +data class Meta( + @JsonProperty("total_count") + val totalCount: Int = 0, +) + +data class Document( + @JsonProperty("address_name") + val addressName: String, + + @JsonProperty("address_type") + val addressType: String, + + @JsonProperty("road_address") + val roadAddressResponse: RoadAddressResponse, + + val roadAddress: RoadAddress = roadAddressResponse.toRoadAddress(), +) + +data class RoadAddressResponse( + @JsonProperty("address_name") + val addressName: String?, + + @JsonProperty("region_1depth_name") + val region1depthName: String?, + + @JsonProperty("region_2depth_name") + val region2depthName: String?, + + @JsonProperty("region_3depth_name") + val region3depthName: String?, + + @JsonProperty("road_name") + val roadName: String?, + + @JsonProperty("underground_yn") + val undergroundYn: String?, + + @JsonProperty("main_building_no") + val mainBuildingNo: String?, + + @JsonProperty("sub_building_no") + val subBuildingNo: String?, + + @JsonProperty("building_name") + val buildingName: String?, + + @JsonProperty("zone_no") + val zoneNo: String?, + + @JsonProperty("x") + val longitude: String?, + + @JsonProperty("y") + val latitude: String?, +) { + fun toRoadAddress(): RoadAddress { + return RoadAddress( + addressName = addressName, + region1depthName = region1depthName, + region2depthName = region2depthName, + region3depthName = region3depthName, + roadName = roadName, + undergroundYn = undergroundYn, + mainBuildingNo = mainBuildingNo, + subBuildingNo = subBuildingNo, + buildingName = buildingName, + zoneNo = zoneNo, + longitude = longitude, + latitude = latitude, + ) + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/hh2/katj/util/config/RestTemplateConfig.kt b/src/main/kotlin/com/hh2/katj/util/config/RestTemplateConfig.kt new file mode 100644 index 00000000..a5d40cb6 --- /dev/null +++ b/src/main/kotlin/com/hh2/katj/util/config/RestTemplateConfig.kt @@ -0,0 +1,15 @@ +package com.hh2.katj.util.config + +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.web.client.RestTemplate + + +@Configuration +class RestTemplateConfig { + + @Bean + fun restTemplate(): RestTemplate { + return RestTemplate() + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/hh2/katj/util/exception/ExceptionMessage.kt b/src/main/kotlin/com/hh2/katj/util/exception/ExceptionMessage.kt index 118a6e9d..80cfe03c 100644 --- a/src/main/kotlin/com/hh2/katj/util/exception/ExceptionMessage.kt +++ b/src/main/kotlin/com/hh2/katj/util/exception/ExceptionMessage.kt @@ -6,6 +6,7 @@ enum class ExceptionMessage(message: String, status: Int) { USER_DOES_NOT_EXIST("user does not exist", 400), INTERNAL_SERVER_ERROR_FROM_DATABASE("internal server error from database", 500), INCORRECT_STATUS_VALUE("incorrect status value", 500), + NO_SEARCH_LOCATION_RESULT("no search results", 400), NO_SUCH_VALUE_EXISTS("no such value exists", 400), - INVALID_PAYMENT_METHOD("invalid payment method", 500) + INVALID_PAYMENT_METHOD("invalid payment method", 500), } \ No newline at end of file diff --git a/src/main/kotlin/com/hh2/katj/util/model/RoadAddress.kt b/src/main/kotlin/com/hh2/katj/util/model/RoadAddress.kt index f9348f33..79ac65d9 100644 --- a/src/main/kotlin/com/hh2/katj/util/model/RoadAddress.kt +++ b/src/main/kotlin/com/hh2/katj/util/model/RoadAddress.kt @@ -1,43 +1,19 @@ package com.hh2.katj.util.model -import com.fasterxml.jackson.annotation.JsonProperty import jakarta.persistence.Embeddable @Embeddable data class RoadAddress( - @JsonProperty("addressName") val addressName: String?, - - @JsonProperty("region1depthName") val region1depthName: String?, - - @JsonProperty("region2depthName") val region2depthName: String?, - - @JsonProperty("region3depthName") val region3depthName: String?, - - @JsonProperty("roadName") val roadName: String?, - - @JsonProperty("undergroundYn") val undergroundYn: String?, - - @JsonProperty("mainBuildingNo") val mainBuildingNo: String?, - - @JsonProperty("subBuildingNo") val subBuildingNo: String?, - - @JsonProperty("buildingName") val buildingName: String?, - - @JsonProperty("zoneNo") val zoneNo: String?, - - @JsonProperty("x") val longitude: String?, - - @JsonProperty("y") val latitude: String?, ) \ No newline at end of file diff --git a/src/test/kotlin/com/hh2/katj/history/service/KakaoApiManagerTest.kt b/src/test/kotlin/com/hh2/katj/history/service/KakaoApiManagerTest.kt new file mode 100644 index 00000000..f3bf981b --- /dev/null +++ b/src/test/kotlin/com/hh2/katj/history/service/KakaoApiManagerTest.kt @@ -0,0 +1,53 @@ +package com.hh2.katj.history.service + +import com.hh2.katj.history.component.KakaoApiManager +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.test.context.TestConstructor + +@SpringBootTest +@TestConstructor(autowireMode = TestConstructor.AutowireMode.ALL) +class KakaoApiManagerTest( + private val kakaoApiManager: KakaoApiManager, +) { + + @Test + fun `주소가 null 이면 null 반환`() { + // given + val address = null + + // when + val result = kakaoApiManager.requestAddressSearch(address) + + // then + assertThat(result).isNull() + } + + @Test + fun `주소가 유효한 경우 검색결과 1개 이상 반환`() { + // given + val address = "서울시 관악구 법원단지5가길 76" + + // when + val result = kakaoApiManager.requestAddressSearch(address) + + // then + assertThat(result!!.meta.totalCount).isGreaterThanOrEqualTo(1) + assertThat(result.documents[0].roadAddress).isNotNull + } + + @Test + fun `주소가 유효하지 않은 경우 카운트가 0, 빈 리스트 반환`() { + // given + val address = "서울시 관악구 법원단지5가길 7655555" + + // when + val result = kakaoApiManager.requestAddressSearch(address) + + // then + assertThat(result!!.meta.totalCount).isEqualTo(0) + assertThat(result.documents.size).isEqualTo(0) + } + +} \ No newline at end of file diff --git a/src/test/kotlin/com/hh2/katj/history/service/LocationHistoryServiceTest.kt b/src/test/kotlin/com/hh2/katj/history/service/LocationHistoryServiceTest.kt new file mode 100644 index 00000000..722e3156 --- /dev/null +++ b/src/test/kotlin/com/hh2/katj/history/service/LocationHistoryServiceTest.kt @@ -0,0 +1,86 @@ +package com.hh2.katj.history.service + +import com.hh2.katj.history.repository.LocationHistoryRepository +import com.hh2.katj.user.model.entity.Gender +import com.hh2.katj.user.model.entity.User +import com.hh2.katj.user.model.entity.UserStatus +import com.hh2.katj.user.repository.UserRepository +import com.hh2.katj.util.exception.ExceptionMessage +import com.hh2.katj.util.model.RoadAddress +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.test.context.TestConstructor + +@SpringBootTest +@TestConstructor(autowireMode = TestConstructor.AutowireMode.ALL) +class LocationHistoryServiceTest( + private val locationHistoryRepository: LocationHistoryRepository, + private val locationHistoryService: LocationHistoryService, + private val userRepository: UserRepository, +) { + + @AfterEach + fun tearUp() { + userRepository.deleteAllInBatch() + locationHistoryRepository.deleteAllInBatch() + } + + @Test + fun `검색어를 통해 위치정보를 찾고 이력을 저장한다`() { + // given + val user = initUser() + + // when + val saveUser = userRepository.save(user) + val response = locationHistoryService.saveLocationHistory(saveUser, keyword = "법원단지5가길 76") + + // then + assertThat(response.roadAddress.addressName).isEqualTo("서울 관악구 법원단지5가길 76") + } + + @Test + fun `검색어로 주소 검색후 결과가 없으면 예외 반환`() { + // given + val user = initUser() + + // when + val saveUser = userRepository.save(user) + + // then + assertThrows { + locationHistoryService.saveLocationHistory(saveUser, keyword = "법원단지5가길 76555") + }.apply { + assertThat(message).isEqualTo(ExceptionMessage.NO_SEARCH_LOCATION_RESULT.name) + } + } + + private fun initUser(): User { + val roadAddress = RoadAddress( + addressName = "서울 관악구 법원단지5가길 76", + buildingName = "대명아트빌", + mainBuildingNo = "76", + region1depthName = "서울", + region2depthName = "관악구", + region3depthName = "신림동", + roadName = "법원단지5가길", + subBuildingNo = "", + undergroundYn = "N", + longitude = "126.923157313768", + latitude = "37.4764786774284", + zoneNo = "08852", + ) + + return User( + name = "탁지성", + phoneNumber = "01032535576", + email = "email@naver.com", + gender = Gender.MALE, + status = UserStatus.ACTIVE, + roadAddress = roadAddress, + ) + } + +} \ No newline at end of file