diff --git a/src/main/kotlin/busanVibe/busan/domain/festival/controller/FestivalController.kt b/src/main/kotlin/busanVibe/busan/domain/festival/controller/FestivalController.kt index c4cb7a7..e7d0e4b 100644 --- a/src/main/kotlin/busanVibe/busan/domain/festival/controller/FestivalController.kt +++ b/src/main/kotlin/busanVibe/busan/domain/festival/controller/FestivalController.kt @@ -7,12 +7,14 @@ import busanVibe.busan.domain.festival.enums.FestivalStatus import busanVibe.busan.domain.festival.service.FestivalQueryService import busanVibe.busan.global.apiPayload.exception.ApiResponse import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.tags.Tag 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 +@Tag(name = "축제 관련 API") @RestController @RequestMapping("/api/festivals") class FestivalController( diff --git a/src/main/kotlin/busanVibe/busan/domain/place/controller/PlaceCongestionController.kt b/src/main/kotlin/busanVibe/busan/domain/place/controller/PlaceCongestionController.kt index d735325..d44e3c2 100644 --- a/src/main/kotlin/busanVibe/busan/domain/place/controller/PlaceCongestionController.kt +++ b/src/main/kotlin/busanVibe/busan/domain/place/controller/PlaceCongestionController.kt @@ -2,40 +2,48 @@ package busanVibe.busan.domain.place.controller import busanVibe.busan.domain.place.dto.PlaceMapResponseDTO import busanVibe.busan.domain.place.enums.PlaceType +import busanVibe.busan.domain.place.service.PlaceCongestionQueryService import busanVibe.busan.global.apiPayload.exception.ApiResponse import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.tags.Tag 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 +@Tag(name = "혼잡도+지도 관련 API") @RestController @RequestMapping("/api/congestion") -class PlaceCongestionController { +class PlaceCongestionController ( + private val placeCongestionQueryService: PlaceCongestionQueryService, +){ @GetMapping @Operation(summary = "지도 조회") fun map( - @RequestParam("type", required = false) type: PlaceType, + @RequestParam("type", required = false, defaultValue = "ALL") type: PlaceType, @RequestParam("latitude")latitude: Double, - @RequestParam("longtitude")longtitude: Double): ApiResponse?{ + @RequestParam("longitude")longtitude: Double): ApiResponse{ - return null; + val places = placeCongestionQueryService.getMap(type, latitude, longtitude) + return ApiResponse.onSuccess(places); } @GetMapping("/place/{placeId}") @Operation(summary = "명소 기본 정보 조회") - fun placeDefaultInfo(@PathVariable("placeId") placeId: Long): ApiResponse?{ - return null; + fun placeDefaultInfo(@PathVariable("placeId") placeId: Long): ApiResponse{ + + val place = placeCongestionQueryService.getPlaceDefault(placeId) + return ApiResponse.onSuccess(place) } - @GetMapping("/place/{placeId}/read-time") + @GetMapping("/place/{placeId}/real-time") @Operation(summary = "명소 실시간 혼잡도 조회") fun placeRealTimeCongestion( - @PathVariable("placeId") placeId: Long, - @RequestParam("standard-time") standardTime: Integer): ApiResponse?{ - return null; + @PathVariable("placeId") placeId: Long): ApiResponse{ + val congestion = placeCongestionQueryService.getCongestion(placeId) + return ApiResponse.onSuccess(congestion) } @GetMapping("/place/{placeId}/distribution") diff --git a/src/main/kotlin/busanVibe/busan/domain/place/controller/PlaceController.kt b/src/main/kotlin/busanVibe/busan/domain/place/controller/PlaceController.kt index 12966f0..0696764 100644 --- a/src/main/kotlin/busanVibe/busan/domain/place/controller/PlaceController.kt +++ b/src/main/kotlin/busanVibe/busan/domain/place/controller/PlaceController.kt @@ -6,12 +6,14 @@ import busanVibe.busan.domain.place.enums.PlaceSortType import busanVibe.busan.domain.place.service.PlaceQueryService import busanVibe.busan.global.apiPayload.exception.ApiResponse import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.tags.Tag 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 +@Tag(name = "명소 관련 API") @RestController @RequestMapping("/api/places") class PlaceController( diff --git a/src/main/kotlin/busanVibe/busan/domain/place/domain/Place.kt b/src/main/kotlin/busanVibe/busan/domain/place/domain/Place.kt index b90c860..5f9efcc 100644 --- a/src/main/kotlin/busanVibe/busan/domain/place/domain/Place.kt +++ b/src/main/kotlin/busanVibe/busan/domain/place/domain/Place.kt @@ -2,6 +2,7 @@ package busanVibe.busan.domain.place.domain import busanVibe.busan.domain.common.BaseEntity import busanVibe.busan.domain.place.enums.PlaceType +import busanVibe.busan.domain.review.domain.Review import jakarta.persistence.Column import jakarta.persistence.Entity import jakarta.persistence.EnumType @@ -46,8 +47,8 @@ class Place( // @JoinColumn(name = "region_id", nullable = false) // val region: Region, // -// @OneToMany(mappedBy = "place", fetch = FetchType.LAZY) -// val reviews: List = emptyList(), + @OneToMany(mappedBy = "place", fetch = FetchType.LAZY) + val reviews: List, @OneToMany(mappedBy = "place", fetch = FetchType.LAZY) val placeLikes: Set, diff --git a/src/main/kotlin/busanVibe/busan/domain/place/dto/PlaceMapResponseDTO.kt b/src/main/kotlin/busanVibe/busan/domain/place/dto/PlaceMapResponseDTO.kt index 01cb9d6..e620e81 100644 --- a/src/main/kotlin/busanVibe/busan/domain/place/dto/PlaceMapResponseDTO.kt +++ b/src/main/kotlin/busanVibe/busan/domain/place/dto/PlaceMapResponseDTO.kt @@ -3,6 +3,7 @@ package busanVibe.busan.domain.place.dto import com.fasterxml.jackson.annotation.JsonProperty import com.fasterxml.jackson.databind.PropertyNamingStrategies import com.fasterxml.jackson.databind.annotation.JsonNaming +import java.math.BigDecimal class PlaceMapResponseDTO { @@ -14,23 +15,23 @@ class PlaceMapResponseDTO { @JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy::class) data class PlaceMapInfoDto( - val placeId: Long, + val placeId: Long?, val name: String, val type: String, - val congestionLevel: Integer, - val latitude: Double, - val longitude: Double + val congestionLevel: Int, + val latitude: BigDecimal, + val longitude: BigDecimal ) @JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy::class) data class PlaceDefaultInfoDto( - val id: Long, + val id: Long?, val name: String, - val congestionLevel: Integer, + val congestionLevel: Int, val grade: Float, - val reviewAmount: Integer, - val latitude: Double, - val longitude: Double, + val reviewAmount: Int, + val latitude: BigDecimal, + val longitude: BigDecimal, val address: String, @get:JsonProperty("is_open") val isOpen: Boolean, @@ -39,8 +40,8 @@ class PlaceMapResponseDTO { @JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy::class) data class PlaceCongestionDto( - val standardTime: String, - val realTimeCongestionLevel: Integer, + val standardTime: Int, + val realTimeCongestionLevel: Int, val byTimePercent: List ) diff --git a/src/main/kotlin/busanVibe/busan/domain/place/repository/PlaceRepository.kt b/src/main/kotlin/busanVibe/busan/domain/place/repository/PlaceRepository.kt index 38e6a54..7d5acfe 100644 --- a/src/main/kotlin/busanVibe/busan/domain/place/repository/PlaceRepository.kt +++ b/src/main/kotlin/busanVibe/busan/domain/place/repository/PlaceRepository.kt @@ -5,6 +5,7 @@ import busanVibe.busan.domain.place.enums.PlaceType import org.springframework.data.jpa.repository.JpaRepository import org.springframework.data.jpa.repository.Query import org.springframework.data.repository.query.Param +import java.math.BigDecimal interface PlaceRepository: JpaRepository { @@ -28,4 +29,32 @@ interface PlaceRepository: JpaRepository { ) fun findByIdWithLIkeAndImage(@Param("placeId") placeId: Long): Place? + // ALL 이면 검사 안 함 + @Query( + """ + SELECT p FROM Place p + LEFT JOIN FETCH p.openTime + WHERE p.latitude BETWEEN :minLat AND :maxLat + AND p.longitude BETWEEN :minLng AND :maxLng + AND (:#{#type.name() == 'ALL'} = true OR p.type = :type) + """ + ) + fun findPlacesByLocationAndType( + @Param("minLat") minLat: BigDecimal, + @Param("maxLat") maxLat: BigDecimal, + @Param("minLng") minLng: BigDecimal, + @Param("maxLng") maxLng: BigDecimal, + @Param("type") type: PlaceType? + ): List + + @Query( + """ + SELECT p FROM Place p + LEFT JOIN FETCH p.openTime + LEFT JOIN FETCH p.placeImages + WHERE p.id = :placeId + """ + ) + fun findByIdWithReviewAndImage(@Param("placeId") placeId: Long): Place? + } \ No newline at end of file diff --git a/src/main/kotlin/busanVibe/busan/domain/place/service/PlaceCongestionQueryService.kt b/src/main/kotlin/busanVibe/busan/domain/place/service/PlaceCongestionQueryService.kt new file mode 100644 index 0000000..827c96b --- /dev/null +++ b/src/main/kotlin/busanVibe/busan/domain/place/service/PlaceCongestionQueryService.kt @@ -0,0 +1,131 @@ +package busanVibe.busan.domain.place.service + +import busanVibe.busan.domain.place.domain.Place +import busanVibe.busan.domain.place.domain.PlaceImage +import busanVibe.busan.domain.place.dto.PlaceMapResponseDTO +import busanVibe.busan.domain.place.enums.PlaceType +import busanVibe.busan.domain.place.repository.PlaceRepository +import busanVibe.busan.domain.review.domain.Review +import busanVibe.busan.domain.review.domain.repository.ReviewRepository +import busanVibe.busan.global.apiPayload.code.status.ErrorStatus +import busanVibe.busan.global.apiPayload.exception.handler.ExceptionHandler +import org.slf4j.LoggerFactory +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import java.math.BigDecimal +import java.math.RoundingMode +import java.time.LocalDateTime + +@Service +class PlaceCongestionQueryService( + private val placeRepository: PlaceRepository, + private val placeRedisUtil: PlaceRedisUtil, + private val reviewRepository: ReviewRepository, +) { + + private val latitudeRange: Double = 0.05 + private val longitudeRange: Double = 0.05 + + private val log = LoggerFactory.getLogger(PlaceCongestionQueryService::class.java) + + @Transactional(readOnly = true) + fun getMap(type: PlaceType?, latitude: Double, longitude: Double): PlaceMapResponseDTO.MapListDto{ + + // Place -> name, type, latitude, longitude + // Redis -> congestion level + + // Place 목록 조회 + val placeList = placeRepository.findPlacesByLocationAndType( + BigDecimal(latitude - latitudeRange).setScale(6, RoundingMode.HALF_UP), + BigDecimal(latitude + latitudeRange).setScale(6, RoundingMode.HALF_UP), + BigDecimal(longitude - longitudeRange).setScale(6, RoundingMode.HALF_UP), + BigDecimal(longitude + longitudeRange).setScale(6, RoundingMode.HALF_UP), + type + ) + + // DTO 변환 + val placeDtoList :List = placeList.map { + PlaceMapResponseDTO.PlaceMapInfoDto( + placeId = it.id, + name = it.name, + type = it.type.capitalEnglish, + latitude = it.latitude, + longitude = it.longitude, + congestionLevel = placeRedisUtil.getRedisCongestion(it.id) + ) + } + + return PlaceMapResponseDTO.MapListDto(placeDtoList) + } + + @Transactional(readOnly = true) + fun getPlaceDefault(placeId: Long): PlaceMapResponseDTO.PlaceDefaultInfoDto{ + + // Place -> name, imageList, address, openTime + // Review -> grade, count + // Image -> list + // Redis -> congestion + + // 명소 조회 + val place: Place? = placeRepository.findByIdWithReviewAndImage(placeId) + place?: throw ExceptionHandler(ErrorStatus.PLACE_NOT_FOUND) + + // 이미지 조회 + val placeImageSet: Set = place.placeImages + val placeImageList = placeImageSet.toList() + .sortedBy { it.createdAt } + .map { it.imgUrl } + + // 리뷰 조회 + val reviewSet: Set = reviewRepository.findByPlace(place).toSet() + val grade = if (reviewSet.isEmpty()) 0f + else reviewSet.map { it.score }.average().toFloat() + + return PlaceMapResponseDTO.PlaceDefaultInfoDto( + id = place.id, + name = place.name, + congestionLevel = placeRedisUtil.getRedisCongestion(place.id), + grade = grade, + reviewAmount = reviewSet.size, + latitude = place.latitude, + longitude = place.longitude, + address = place.address, + isOpen = true, + imgList = placeImageList + ) + } + + @Transactional(readOnly = true) + fun getCongestion(placeId: Long): PlaceMapResponseDTO.PlaceCongestionDto { + + val current = LocalDateTime.now() + log.info("현재 시간: ${current}시") + + val roundedBase = (current.hour / 3) * 3 + + // 최근 6개 3시간 단위 시간 생성 (기준시간 포함 총 7개) + val hours = (6 downTo 0).map { i -> (roundedBase - i * 3 + 24) % 24 } + + log.info("배열 담기 시작") + + val byTimePercent: List = hours.map { hour -> + val adjustedDateTime = current.withHour(hour) + .withMinute(0).withSecond(0).withNano(0) + .let { + if (hour > current.hour) it.minusDays(1) else it + } + placeRedisUtil.getTimeCongestion(placeId, adjustedDateTime) + } + + log.info("배열 담기 끝") + + return PlaceMapResponseDTO.PlaceCongestionDto( + standardTime = roundedBase, + realTimeCongestionLevel = placeRedisUtil.getTimeCongestion(placeId, current).toInt(), + byTimePercent = byTimePercent + ) + } + + + +} \ No newline at end of file diff --git a/src/main/kotlin/busanVibe/busan/domain/place/service/PlaceRedisUtil.kt b/src/main/kotlin/busanVibe/busan/domain/place/service/PlaceRedisUtil.kt index 567aa1d..8a46fa0 100644 --- a/src/main/kotlin/busanVibe/busan/domain/place/service/PlaceRedisUtil.kt +++ b/src/main/kotlin/busanVibe/busan/domain/place/service/PlaceRedisUtil.kt @@ -1,21 +1,67 @@ package busanVibe.busan.domain.place.service +import org.slf4j.LoggerFactory import org.springframework.data.redis.core.StringRedisTemplate +import org.springframework.stereotype.Component +import java.time.Duration +import java.time.LocalDateTime + +@Component class PlaceRedisUtil( private val redisTemplate: StringRedisTemplate ) { + private val log = LoggerFactory.getLogger("busanVibe.busan.domain.place") + // 임의로 혼잡도 생성하여 반환. 레디스 키 값으로 저장함. fun getRedisCongestion(placeId: Long?): Int{ val key = "place:congestion:$placeId" - val randomCongestion: Int = (Math.random() * 5 + 1).toInt() + val randomCongestion = getRandomCongestion().toInt().toString() redisTemplate.opsForValue() - .set(key, randomCongestion.toString()) + .set(key, randomCongestion) + + return Integer.parseInt(randomCongestion) + } + + // 시간 혼잡도 설정 + fun setPlaceTimeCongestion(placeId: Long, dateTime: LocalDateTime) { + val roundedHour = (dateTime.hour / 3) * 3 + val key = "place:congestion:$placeId-${dateTime.year}-${dateTime.monthValue}-${dateTime.dayOfMonth}-$roundedHour" + val congestion = getRandomCongestion().toString() + val success = redisTemplate.opsForValue().setIfAbsent(key, congestion, Duration.ofHours(24)) + + if (success == true) { + log.info("혼잡도 기록 저장 완료: $key, 저장된 혼잡도: $congestion") + } else { + val existing = redisTemplate.opsForValue().get(key) + log.info("이미 존재하는 혼잡도 기록: $key, 기존 혼잡도: $existing") + } + } + + // 지정 시간 혼잡도 조회 + fun getTimeCongestion(placeId: Long, dateTime: LocalDateTime): Float { + val roundedHour = (dateTime.hour / 3) * 3 + val key = "place:congestion:$placeId-${dateTime.year}-${dateTime.monthValue}-${dateTime.dayOfMonth}-$roundedHour" + + val value = redisTemplate.opsForValue().get(key) - return randomCongestion + return if (value != null) { + log.info("이미 존재하는 혼잡도 기록: $key, 기존 혼잡도: $value") + value.toFloatOrNull() ?: 0f + } else { + setPlaceTimeCongestion(placeId, dateTime.withHour(roundedHour)) + val newValue = redisTemplate.opsForValue().get(key) + newValue?.toFloatOrNull() ?: 0f + } } + // 혼잡도 생성 (1.0 ~ 5.0 사이의 Float) + private fun getRandomCongestion(): Float { + return (Math.random() * 4 + 1).toFloat() + } + + } \ No newline at end of file diff --git a/src/main/kotlin/busanVibe/busan/domain/review/domain/repository/ReviewRepository.kt b/src/main/kotlin/busanVibe/busan/domain/review/domain/repository/ReviewRepository.kt index 551e871..a559bab 100644 --- a/src/main/kotlin/busanVibe/busan/domain/review/domain/repository/ReviewRepository.kt +++ b/src/main/kotlin/busanVibe/busan/domain/review/domain/repository/ReviewRepository.kt @@ -2,6 +2,7 @@ package busanVibe.busan.domain.review.domain.repository import busanVibe.busan.domain.place.domain.Place import busanVibe.busan.domain.review.domain.Review +import org.springframework.data.jpa.repository.EntityGraph import org.springframework.data.jpa.repository.JpaRepository import org.springframework.data.jpa.repository.Query import org.springframework.data.repository.query.Param @@ -17,4 +18,7 @@ interface ReviewRepository: JpaRepository { ) fun findForDetails(@Param("place")place: Place): List + @EntityGraph(attributePaths = ["user"]) + fun findByPlace(place: Place): List + } \ No newline at end of file diff --git a/src/main/kotlin/busanVibe/busan/domain/user/controller/UserController.kt b/src/main/kotlin/busanVibe/busan/domain/user/controller/UserController.kt index 417de35..9748412 100644 --- a/src/main/kotlin/busanVibe/busan/domain/user/controller/UserController.kt +++ b/src/main/kotlin/busanVibe/busan/domain/user/controller/UserController.kt @@ -3,6 +3,7 @@ package busanVibe.busan.domain.user.controller import busanVibe.busan.domain.user.data.dto.UserResponseDTO import busanVibe.busan.domain.user.service.UserCommandService import busanVibe.busan.global.apiPayload.exception.ApiResponse +import io.swagger.v3.oas.annotations.tags.Tag import org.slf4j.LoggerFactory import org.springframework.http.HttpHeaders import org.springframework.http.ResponseEntity @@ -11,6 +12,7 @@ import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.RequestParam import org.springframework.web.bind.annotation.RestController +@Tag(name = "유저 관련 API", description = "로그인과 분리할수도 있습니다") @RestController @RequestMapping("/users") class UserController (