diff --git a/.github/workflows/discord.yml b/.github/workflows/discord.yml index 3c133fd..641fdb6 100644 --- a/.github/workflows/discord.yml +++ b/.github/workflows/discord.yml @@ -28,23 +28,27 @@ jobs: echo "$COMMITS_JSON" > commits.json - # 커밋 리스트 메시지 생성 (3개 제한, 링크 포함) - COMMITS_MSG=$(echo "$COMMITS_JSON" | jq -r '[.[] | {sha: .sha[:7], url: .html_url, message: .commit.message}] | .[:3] | map("- [\(.sha)](\(.url)) \(.message)") | join("\n")') + COMMITS_COUNT=$(echo "$COMMITS_JSON" | jq 'length') + COMMITS_MSG=$(echo "$COMMITS_JSON" | jq -r '[.[] | {sha: .sha[:7], url: .html_url, message: .commit.message}] | map("- [\(.sha)](\(.url)) \(.message)") | join("\n")') + echo "COMMITS_COUNT=$COMMITS_COUNT" >> $GITHUB_ENV echo "COMMITS_MSG<> $GITHUB_ENV echo "$COMMITS_MSG" >> $GITHUB_ENV echo "EOF" >> $GITHUB_ENV + - name: Send Discord notification run: | USERNAME="${{ github.actor }}" NOW=$(date '+%Y-%m-%d %H:%M') - CONTENT="🚀 **배포 완료** (main 브랜치)\n\n배포자: @$USERNAME\n커밋 ${#COMMITS_MSG[@]}개 포함\n배포 시간: $NOW\n\n커밋 내역:\n$COMMITS_MSG" + # JSON 문자열을 jq로 안전하게 생성 + JSON_PAYLOAD=$(jq -n --arg content "🚀 **배포 완료** (main 브랜치)\n\n배포자: @$USERNAME\n커밋 $COMMITS_COUNT개 포함\n배포 시간: $NOW\n\n커밋 내역:\n$COMMITS_MSG" '{content: $content}') + curl -H "Content-Type: application/json" \ -X POST \ - -d "{\"content\": \"$CONTENT\"}" \ + -d "$JSON_PAYLOAD" \ "$DISCORD_WEBHOOK_URL" env: COMMITS_MSG: ${{ env.COMMITS_MSG }} diff --git a/src/main/kotlin/busanVibe/busan/domain/common/dto/InfoType.kt b/src/main/kotlin/busanVibe/busan/domain/common/dto/InfoType.kt new file mode 100644 index 0000000..f2da98d --- /dev/null +++ b/src/main/kotlin/busanVibe/busan/domain/common/dto/InfoType.kt @@ -0,0 +1,16 @@ +package busanVibe.busan.domain.common.dto + +enum class InfoType( + val kr: String, + val en: String, +) { + ALL("전체", "ALL"), + // 명소 + SIGHT("명소", "SIGHT"), + RESTAURANT("식당", "RESTAURANT"), + CAFE("카페", "CAFE"), + // 축제 + FESTIVAL("축제", "FESTIVAL"), + ; + +} \ No newline at end of file diff --git a/src/main/kotlin/busanVibe/busan/domain/festival/repository/FestivalRepository.kt b/src/main/kotlin/busanVibe/busan/domain/festival/repository/FestivalRepository.kt index 1778138..114f309 100644 --- a/src/main/kotlin/busanVibe/busan/domain/festival/repository/FestivalRepository.kt +++ b/src/main/kotlin/busanVibe/busan/domain/festival/repository/FestivalRepository.kt @@ -23,5 +23,14 @@ interface FestivalRepository: JpaRepository { """) fun findByIdWithLikesAndImages(@Param("id") id: Long): Festival? + @Query( + """ + SELECT f FROM Festival f + LEFT JOIN FETCH f.festivalImages + LEFT JOIN FETCH f.festivalLikes fl + LEFT JOIN FETCH fl.user + """ + ) + fun findAllWithFetch(): List } \ No newline at end of file diff --git a/src/main/kotlin/busanVibe/busan/domain/home/controller/HomeController.kt b/src/main/kotlin/busanVibe/busan/domain/home/controller/HomeController.kt new file mode 100644 index 0000000..60b548f --- /dev/null +++ b/src/main/kotlin/busanVibe/busan/domain/home/controller/HomeController.kt @@ -0,0 +1,27 @@ +package busanVibe.busan.domain.home.controller + +import busanVibe.busan.domain.home.dto.HomeResponseDTO +import busanVibe.busan.domain.home.service.HomeQueryService +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.RequestMapping +import org.springframework.web.bind.annotation.RestController + +@Tag(name = "홈화면 API") +@RestController +@RequestMapping("/api/home") +class HomeController( + private val homeQueryService: HomeQueryService +) { + + @GetMapping + @Operation(summary = "홈화면 정보 조회 API", description = "지금 붐비는 곳과 추천 명소를 각각 5개씩 반환합니다.") + fun getHomeInfo(): ApiResponse { + + val homeInfo = homeQueryService.getHomeInfo() + return ApiResponse.onSuccess(homeInfo) + } + +} \ No newline at end of file diff --git a/src/main/kotlin/busanVibe/busan/domain/home/dto/HomeResponseDTO.kt b/src/main/kotlin/busanVibe/busan/domain/home/dto/HomeResponseDTO.kt new file mode 100644 index 0000000..5934f74 --- /dev/null +++ b/src/main/kotlin/busanVibe/busan/domain/home/dto/HomeResponseDTO.kt @@ -0,0 +1,41 @@ +package busanVibe.busan.domain.home.dto + +import com.fasterxml.jackson.annotation.JsonProperty +import com.fasterxml.jackson.databind.PropertyNamingStrategies +import com.fasterxml.jackson.databind.annotation.JsonNaming + +class HomeResponseDTO { + + @JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy::class) + data class HomeResultDto( + val mostCrowded: List, + val recommendPlace: List + ) + + @JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy::class) + data class MostCongestion( + val placeId: Long?, + val name: String, + val latitude: Double, + val longitude: Double, + val type: String, + val image: String?, + val congestionLevel: Int, + val region: String + ) + + @JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy::class) + data class RecommendPlace( + val placeId: Long?, + val name: String, + val congestionLevel: Int, + val type: String, + val image: String?, + val latitude: Double, + val longitude: Double, + val region: String, + @get:JsonProperty("is_liked") + val isLiked: Boolean + ) + +} \ No newline at end of file diff --git a/src/main/kotlin/busanVibe/busan/domain/home/service/HomeQueryService.kt b/src/main/kotlin/busanVibe/busan/domain/home/service/HomeQueryService.kt new file mode 100644 index 0000000..65ab9fe --- /dev/null +++ b/src/main/kotlin/busanVibe/busan/domain/home/service/HomeQueryService.kt @@ -0,0 +1,83 @@ +package busanVibe.busan.domain.home.service + +import busanVibe.busan.domain.home.dto.HomeResponseDTO +import busanVibe.busan.domain.place.repository.PlaceRepository +import busanVibe.busan.domain.place.util.PlaceRedisUtil +import busanVibe.busan.domain.user.service.login.AuthService +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional + +@Service +class HomeQueryService( + private val placeRepository: PlaceRepository, + private val placeRedisUtil: PlaceRedisUtil +) { + + @Transactional(readOnly = true) + fun getHomeInfo(): HomeResponseDTO.HomeResultDto{ + + // 조회 메서드 호출 + val mostCongestions:List = getMostCongestions() + val recommendPlaces:List = getRecommendPlaces() + + // 반환 + return HomeResponseDTO.HomeResultDto( + mostCongestions, recommendPlaces + ) + } + + // 가장 붐비는 곳 조회 하여 List 반환 - 5개 + private fun getMostCongestions(): List{ + + val places = placeRepository.findAllWithImages() + val placesWithCongestion = places.mapNotNull { place -> + val congestion = placeRedisUtil.getRedisCongestion(place.id) + if (congestion != null) { + place to congestion + } else { + null + } + } + + val top5 = placesWithCongestion.sortedByDescending { it.second }.take(5) + + return top5.map { (place, congestion) -> + HomeResponseDTO.MostCongestion( + placeId = place.id, + name = place.name, + latitude = place.latitude.toDouble(), + longitude = place.longitude.toDouble(), + type = place.type.korean, + image = place.placeImages.firstOrNull()?.imgUrl, + congestionLevel = congestion, + region = place.address + ) + } + + } + + // 추천 명소 조회하여 List 반환 - 5개 + private fun getRecommendPlaces(): List { + val currentUser = AuthService().getCurrentUser() + val places = placeRepository.findAllWithFetch() + + return places.take(5).map { place -> + + val congestion = placeRedisUtil.getRedisCongestion(place.id) + + HomeResponseDTO.RecommendPlace( + placeId = place.id, + name = place.name, + congestionLevel = congestion, + type = place.type.korean, + image = place.placeImages.firstOrNull()?.imgUrl, + latitude = place.latitude.toDouble(), + longitude = place.longitude.toDouble(), + region = place.address, + isLiked = place.placeLikes.any { it.user == currentUser } + ) + } + } + + +} \ No newline at end of file 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 d44e3c2..4096e1a 100644 --- a/src/main/kotlin/busanVibe/busan/domain/place/controller/PlaceCongestionController.kt +++ b/src/main/kotlin/busanVibe/busan/domain/place/controller/PlaceCongestionController.kt @@ -24,9 +24,9 @@ class PlaceCongestionController ( fun map( @RequestParam("type", required = false, defaultValue = "ALL") type: PlaceType, @RequestParam("latitude")latitude: Double, - @RequestParam("longitude")longtitude: Double): ApiResponse{ + @RequestParam("longitude")longitude: Double): ApiResponse{ - val places = placeCongestionQueryService.getMap(type, latitude, longtitude) + val places = placeCongestionQueryService.getMap(type, latitude, longitude) return ApiResponse.onSuccess(places); } @@ -47,9 +47,10 @@ class PlaceCongestionController ( } @GetMapping("/place/{placeId}/distribution") - @Operation(summary = "명소 이용객 분포 조회") + @Operation(summary = "명소 이용객 분포 조회", description = "각 분포 항목의 백분율 정보를 반환합니다.") fun placeUsesDistribution(@PathVariable("placeId") placeId: Long): ApiResponse?{ - return null; + val distribution = placeCongestionQueryService.getDistribution(placeId) + return ApiResponse.onSuccess(distribution) } } \ No newline at end of file diff --git a/src/main/kotlin/busanVibe/busan/domain/place/converter/PlaceDetailsConverter.kt b/src/main/kotlin/busanVibe/busan/domain/place/converter/PlaceDetailsConverter.kt index 18324c4..1a7323e 100644 --- a/src/main/kotlin/busanVibe/busan/domain/place/converter/PlaceDetailsConverter.kt +++ b/src/main/kotlin/busanVibe/busan/domain/place/converter/PlaceDetailsConverter.kt @@ -5,7 +5,7 @@ import busanVibe.busan.domain.place.domain.Place import busanVibe.busan.domain.place.domain.PlaceImage import busanVibe.busan.domain.place.domain.PlaceLike import busanVibe.busan.domain.place.dto.PlaceResponseDTO -import busanVibe.busan.domain.place.service.PlaceRedisUtil +import busanVibe.busan.domain.place.util.PlaceRedisUtil import busanVibe.busan.domain.review.domain.Review import java.time.LocalTime import java.time.format.DateTimeFormatter 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 5f9efcc..18fd428 100644 --- a/src/main/kotlin/busanVibe/busan/domain/place/domain/Place.kt +++ b/src/main/kotlin/busanVibe/busan/domain/place/domain/Place.kt @@ -3,6 +3,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.CascadeType import jakarta.persistence.Column import jakarta.persistence.Entity import jakarta.persistence.EnumType @@ -11,6 +12,7 @@ import jakarta.persistence.FetchType import jakarta.persistence.GeneratedValue import jakarta.persistence.GenerationType import jakarta.persistence.Id +import jakarta.persistence.JoinColumn import jakarta.persistence.OneToMany import jakarta.persistence.OneToOne import java.math.BigDecimal @@ -53,10 +55,14 @@ class Place( @OneToMany(mappedBy = "place", fetch = FetchType.LAZY) val placeLikes: Set, - @OneToOne(mappedBy = "place", fetch = FetchType.LAZY) + @OneToOne(mappedBy = "place", fetch = FetchType.LAZY, optional = true) val openTime: OpenTime, @OneToMany(mappedBy="place", fetch = FetchType.LAZY) - val placeImages: Set + val placeImages: Set, + + @OneToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "visitor_distribution_id") + val visitorDistribution: VisitorDistribution? = null, ) : BaseEntity() \ No newline at end of file diff --git a/src/main/kotlin/busanVibe/busan/domain/place/domain/VisitorDistribution.kt b/src/main/kotlin/busanVibe/busan/domain/place/domain/VisitorDistribution.kt new file mode 100644 index 0000000..05b06d5 --- /dev/null +++ b/src/main/kotlin/busanVibe/busan/domain/place/domain/VisitorDistribution.kt @@ -0,0 +1,66 @@ +package busanVibe.busan.domain.place.domain + +import jakarta.persistence.Column +import jakarta.persistence.Entity +import jakarta.persistence.FetchType +import jakarta.persistence.GeneratedValue +import jakarta.persistence.GenerationType +import jakarta.persistence.Id +import jakarta.persistence.JoinColumn +import jakarta.persistence.OneToOne +import org.hibernate.annotations.ColumnDefault + +@Entity +class VisitorDistribution( + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + val id: Long? = null, + + @Column(nullable = false) + @ColumnDefault("0") + var m1020: Int = 0, + + @Column(nullable = false) + @ColumnDefault("0") + var f1020: Int = 0, + + @Column(nullable = false) + @ColumnDefault("0") + var m3040: Int = 0, + + @Column(nullable = false) + @ColumnDefault("0") + var f3040: Int = 0, + + @Column(nullable = false) + @ColumnDefault("0") + var m5060: Int = 0, + + @Column(nullable = false) + @ColumnDefault("0") + var f5060: Int = 0, + + @Column(nullable = false) + @ColumnDefault("0") + var m70: Int = 0, + + @Column(nullable = false) + @ColumnDefault("0") + var f70: Int = 0, + +) { + + fun getTotalVisitorCount(): Int { + return (m1020 ?: 0) + + (f1020 ?: 0) + + (m3040 ?: 0) + + (f3040 ?: 0) + + (m5060 ?: 0) + + (f5060 ?: 0) + + (m70 ?: 0) + + (f70 ?: 0) + } + + +} \ No newline at end of file diff --git a/src/main/kotlin/busanVibe/busan/domain/place/enums/PlaceSortType.kt b/src/main/kotlin/busanVibe/busan/domain/place/enums/PlaceSortType.kt index 5294e10..ec123ca 100644 --- a/src/main/kotlin/busanVibe/busan/domain/place/enums/PlaceSortType.kt +++ b/src/main/kotlin/busanVibe/busan/domain/place/enums/PlaceSortType.kt @@ -3,7 +3,7 @@ package busanVibe.busan.domain.place.enums enum class PlaceSortType { DEFAULT, - LIKES, + LIKE, CONGESTION ; 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 7d5acfe..b0d9827 100644 --- a/src/main/kotlin/busanVibe/busan/domain/place/repository/PlaceRepository.kt +++ b/src/main/kotlin/busanVibe/busan/domain/place/repository/PlaceRepository.kt @@ -2,10 +2,12 @@ package busanVibe.busan.domain.place.repository import busanVibe.busan.domain.place.domain.Place import busanVibe.busan.domain.place.enums.PlaceType +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 import java.math.BigDecimal +import java.util.Optional interface PlaceRepository: JpaRepository { @@ -57,4 +59,47 @@ interface PlaceRepository: JpaRepository { ) fun findByIdWithReviewAndImage(@Param("placeId") placeId: Long): Place? + @EntityGraph(attributePaths = ["visitorDistribution"]) + @Query("SELECT p FROM Place p WHERE p.id = :placeId") + fun findWithDistribution(@Param("placeId") placeId: Long): Optional + + @Query(""" + SELECT p FROM Place p + LEFT JOIN FETCH p.placeLikes pl + LEFT JOIN FETCH p.openTime + LEFT JOIN FETCH p.placeImages + LEFT JOIN FETCH pl.user + """) + fun findAllWithLikesAndOpenTime(): List + + @Query(""" + SELECT p FROM Place p + LEFT JOIN FETCH p.placeLikes pl + LEFT JOIN FETCH p.openTime + LEFT JOIN FETCH p.placeImages + LEFT JOIN FETCH pl.user + WHERE p.type = :type + """) + fun findAllWithLikesAndOpenTimeByType(@Param("type") type: PlaceType): List + + @Query( + """ + SELECT p FROM Place p + LEFT JOIN FETCH p.placeImages + LEFT JOIN FETCH p.openTime + """ + ) + fun findAllWithImages(): List + + @Query( + """ + SELECT DISTINCT p FROM Place p + LEFT JOIN FETCH p.placeImages + LEFT JOIN FETCH p.openTime + LEFT JOIN FETCH p.placeLikes pl + LEFT JOIN FETCH pl.user + """ + ) + fun findAllWithFetch(): List + } \ No newline at end of file diff --git a/src/main/kotlin/busanVibe/busan/domain/place/repository/VisitorDistributionRepository.kt b/src/main/kotlin/busanVibe/busan/domain/place/repository/VisitorDistributionRepository.kt new file mode 100644 index 0000000..e484c55 --- /dev/null +++ b/src/main/kotlin/busanVibe/busan/domain/place/repository/VisitorDistributionRepository.kt @@ -0,0 +1,8 @@ +package busanVibe.busan.domain.place.repository + +import busanVibe.busan.domain.place.domain.VisitorDistribution +import org.springframework.data.jpa.repository.JpaRepository + +interface VisitorDistributionRepository: JpaRepository { + +} \ 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 index 827c96b..43d45ff 100644 --- a/src/main/kotlin/busanVibe/busan/domain/place/service/PlaceCongestionQueryService.kt +++ b/src/main/kotlin/busanVibe/busan/domain/place/service/PlaceCongestionQueryService.kt @@ -2,9 +2,12 @@ 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.domain.VisitorDistribution 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.place.repository.VisitorDistributionRepository +import busanVibe.busan.domain.place.util.PlaceRedisUtil import busanVibe.busan.domain.review.domain.Review import busanVibe.busan.domain.review.domain.repository.ReviewRepository import busanVibe.busan.global.apiPayload.code.status.ErrorStatus @@ -21,6 +24,7 @@ class PlaceCongestionQueryService( private val placeRepository: PlaceRepository, private val placeRedisUtil: PlaceRedisUtil, private val reviewRepository: ReviewRepository, + private val visitorDistributionRepository: VisitorDistributionRepository, ) { private val latitudeRange: Double = 0.05 @@ -126,6 +130,34 @@ class PlaceCongestionQueryService( ) } + @Transactional(readOnly = false) + fun getDistribution(placeId: Long): PlaceMapResponseDTO.PlaceUserDistributionDto{ + + val place = placeRepository.findWithDistribution(placeId) + .orElseThrow { ExceptionHandler(ErrorStatus.PLACE_NOT_FOUND) } + + val distribution: VisitorDistribution = place.visitorDistribution + ?: visitorDistributionRepository.saveAndFlush(VisitorDistribution()) + + val totalVisitorCount: Float = distribution.getTotalVisitorCount().toFloat() + + return PlaceMapResponseDTO.PlaceUserDistributionDto( + male1020 = safePercent(distribution.m1020, totalVisitorCount), + male3040 = safePercent(distribution.m3040, totalVisitorCount), + male5060 = safePercent(distribution.m5060, totalVisitorCount), + male70 = safePercent(distribution.m70, totalVisitorCount), + female1020 = safePercent(distribution.f1020, totalVisitorCount), + female3040 = safePercent(distribution.f3040, totalVisitorCount), + female5060 = safePercent(distribution.f5060, totalVisitorCount), + female70 = safePercent(distribution.f70, totalVisitorCount) + ) + + } + + private fun safePercent(numerator: Int, denominator: Float): Float{ + return if(denominator == 0.0f) 0f else numerator / denominator * 100.0f + } + } \ No newline at end of file diff --git a/src/main/kotlin/busanVibe/busan/domain/place/service/PlaceQueryService.kt b/src/main/kotlin/busanVibe/busan/domain/place/service/PlaceQueryService.kt index 6fc01d4..524ae1d 100644 --- a/src/main/kotlin/busanVibe/busan/domain/place/service/PlaceQueryService.kt +++ b/src/main/kotlin/busanVibe/busan/domain/place/service/PlaceQueryService.kt @@ -7,10 +7,10 @@ import busanVibe.busan.domain.place.domain.PlaceLike import busanVibe.busan.domain.place.dto.PlaceResponseDTO import busanVibe.busan.domain.place.enums.PlaceSortType import busanVibe.busan.domain.place.enums.PlaceType -import busanVibe.busan.domain.place.repository.OpenTimeRepository import busanVibe.busan.domain.place.repository.PlaceImageRepository import busanVibe.busan.domain.place.repository.PlaceLikeRepository import busanVibe.busan.domain.place.repository.PlaceRepository +import busanVibe.busan.domain.place.util.PlaceRedisUtil import busanVibe.busan.domain.review.domain.Review import busanVibe.busan.domain.review.domain.repository.ReviewRepository import busanVibe.busan.domain.user.data.User @@ -92,7 +92,7 @@ class PlaceQueryService( // 정렬 처리 val sortedList = when (sort) { PlaceSortType.DEFAULT -> dtoList - PlaceSortType.LIKES -> dtoList.sortedByDescending { it.likeAmount } + PlaceSortType.LIKE -> dtoList.sortedByDescending { it.likeAmount } PlaceSortType.CONGESTION -> dtoList.sortedByDescending { it.congestionLevel } } diff --git a/src/main/kotlin/busanVibe/busan/domain/place/service/PlaceRedisUtil.kt b/src/main/kotlin/busanVibe/busan/domain/place/util/PlaceRedisUtil.kt similarity index 92% rename from src/main/kotlin/busanVibe/busan/domain/place/service/PlaceRedisUtil.kt rename to src/main/kotlin/busanVibe/busan/domain/place/util/PlaceRedisUtil.kt index 8a46fa0..4ed71e6 100644 --- a/src/main/kotlin/busanVibe/busan/domain/place/service/PlaceRedisUtil.kt +++ b/src/main/kotlin/busanVibe/busan/domain/place/util/PlaceRedisUtil.kt @@ -1,4 +1,4 @@ -package busanVibe.busan.domain.place.service +package busanVibe.busan.domain.place.util import org.slf4j.LoggerFactory import org.springframework.data.redis.core.StringRedisTemplate @@ -6,10 +6,9 @@ import org.springframework.stereotype.Component import java.time.Duration import java.time.LocalDateTime - @Component class PlaceRedisUtil( - private val redisTemplate: StringRedisTemplate + private val redisTemplate: StringRedisTemplate, ) { private val log = LoggerFactory.getLogger("busanVibe.busan.domain.place") @@ -42,7 +41,11 @@ class PlaceRedisUtil( } // 지정 시간 혼잡도 조회 - fun getTimeCongestion(placeId: Long, dateTime: LocalDateTime): Float { + // null이면 현재시간 기준 + fun getTimeCongestion(placeId: Long, dateTime: LocalDateTime?): Float { + + val dateTime = dateTime ?: LocalDateTime.now() + val roundedHour = (dateTime.hour / 3) * 3 val key = "place:congestion:$placeId-${dateTime.year}-${dateTime.monthValue}-${dateTime.dayOfMonth}-$roundedHour" diff --git a/src/main/kotlin/busanVibe/busan/domain/search/controller/SearchController.kt b/src/main/kotlin/busanVibe/busan/domain/search/controller/SearchController.kt new file mode 100644 index 0000000..f4fcabc --- /dev/null +++ b/src/main/kotlin/busanVibe/busan/domain/search/controller/SearchController.kt @@ -0,0 +1,30 @@ +package busanVibe.busan.domain.search.controller + +import busanVibe.busan.domain.common.dto.InfoType +import busanVibe.busan.domain.search.dto.SearchResultDTO +import busanVibe.busan.domain.search.enums.GeneralSortType +import busanVibe.busan.domain.search.service.SearchQueryService +import busanVibe.busan.global.apiPayload.exception.ApiResponse +import io.swagger.v3.oas.annotations.tags.Tag +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 + +@Tag(name = "검색 관련 API") +@RestController +@RequestMapping("/api/search") +class SearchController( + private val searchQueryService: SearchQueryService +) { + + @GetMapping("/search") + fun searchResult(@RequestParam("option", defaultValue = "ALL") infoType: InfoType, + @RequestParam("sort", defaultValue = "DEFAULT") sort: GeneralSortType): ApiResponse{ + + val searchResult = searchQueryService.getSearchResult(infoType, sort) + return ApiResponse.onSuccess(searchResult) + + } + +} \ No newline at end of file diff --git a/src/main/kotlin/busanVibe/busan/domain/search/dto/SearchResultDTO.kt b/src/main/kotlin/busanVibe/busan/domain/search/dto/SearchResultDTO.kt new file mode 100644 index 0000000..9fb425a --- /dev/null +++ b/src/main/kotlin/busanVibe/busan/domain/search/dto/SearchResultDTO.kt @@ -0,0 +1,32 @@ +package busanVibe.busan.domain.search.dto + +import com.fasterxml.jackson.annotation.JsonProperty +import com.fasterxml.jackson.databind.PropertyNamingStrategies +import com.fasterxml.jackson.databind.annotation.JsonNaming + +class SearchResultDTO { + + @JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy::class) + data class ListDto( + val resultList: List + ) + + @JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy::class) + data class InfoDto( + val typeKr: String, + val typeEn: String, + val id: Long? = null, + val name: String, + val latitude: Double? = null, + val longitude: Double? = null, + val region: String, + val congestionLevel: Int? = null, + @get:JsonProperty("is_liked") + val isLiked: Boolean, + val startDate: String? = null, + val endDate: String? = null, + @get:JsonProperty("is_end") + val isEnd: Boolean? + ) + +} \ No newline at end of file diff --git a/src/main/kotlin/busanVibe/busan/domain/search/enums/GeneralSortType.kt b/src/main/kotlin/busanVibe/busan/domain/search/enums/GeneralSortType.kt new file mode 100644 index 0000000..b618a10 --- /dev/null +++ b/src/main/kotlin/busanVibe/busan/domain/search/enums/GeneralSortType.kt @@ -0,0 +1,10 @@ +package busanVibe.busan.domain.search.enums + +enum class GeneralSortType { + + DEFAULT, + LIKE, + CONGESTION + ; + +} \ No newline at end of file diff --git a/src/main/kotlin/busanVibe/busan/domain/search/service/SearchQueryService.kt b/src/main/kotlin/busanVibe/busan/domain/search/service/SearchQueryService.kt new file mode 100644 index 0000000..6f506b4 --- /dev/null +++ b/src/main/kotlin/busanVibe/busan/domain/search/service/SearchQueryService.kt @@ -0,0 +1,111 @@ +package busanVibe.busan.domain.search.service + +import busanVibe.busan.domain.common.dto.InfoType +import busanVibe.busan.domain.festival.repository.FestivalRepository +import busanVibe.busan.domain.place.enums.PlaceType +import busanVibe.busan.domain.place.repository.PlaceRepository +import busanVibe.busan.domain.place.util.PlaceRedisUtil +import busanVibe.busan.domain.search.dto.SearchResultDTO +import busanVibe.busan.domain.search.enums.GeneralSortType +import busanVibe.busan.domain.user.service.login.AuthService +import busanVibe.busan.global.apiPayload.code.status.ErrorStatus +import busanVibe.busan.global.apiPayload.exception.handler.ExceptionHandler +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional + +@Service +class SearchQueryService( + private val placeRepository: PlaceRepository, + private val festivalRepository: FestivalRepository, + private val placeRedisUtil: PlaceRedisUtil +) { + + @Transactional(readOnly = true) + fun getSearchResult(infoType: InfoType, sort: GeneralSortType): SearchResultDTO.ListDto { + val currentUser = AuthService().getCurrentUser() + + // 축제 + 혼잡도 검색 예외 처리 + if(infoType == InfoType.FESTIVAL && sort == GeneralSortType.CONGESTION) { + throw ExceptionHandler(ErrorStatus.SEARCH_INVALID_CONDITION) + } + + // 명소 타입별 조회 + val places = when (infoType) { + InfoType.ALL -> placeRepository.findAllWithLikesAndOpenTime() + InfoType.CAFE -> placeRepository.findAllWithLikesAndOpenTimeByType(PlaceType.CAFE) + InfoType.RESTAURANT -> placeRepository.findAllWithLikesAndOpenTimeByType(PlaceType.RESTAURANT) + InfoType.SIGHT -> placeRepository.findAllWithLikesAndOpenTimeByType(PlaceType.SIGHT) + else -> emptyList() + } + + // 축제 조회 + val festivals = when (infoType) { + InfoType.ALL, InfoType.FESTIVAL -> festivalRepository.findAllWithFetch() + else -> emptyList() + } + + // 축제 List -> dto List 변환 + val festivalDtoList = festivals.map { festival -> + SearchResultDTO.InfoDto( + typeKr = InfoType.FESTIVAL.kr, + typeEn = InfoType.FESTIVAL.en, + id = festival.id, + name = festival.name, + region = festival.place, + isLiked = festival.festivalLikes.any { it.user == currentUser }, + startDate = festival.startDate.toString(), + endDate = festival.endDate.toString(), + isEnd = festival.endDate.before(java.util.Date()) + ) + } + + // 혼잡도 List -> dto List 변환 + val placeDtoList = places.map { place -> + SearchResultDTO.InfoDto( + typeKr = place.type.korean, + typeEn = place.type.capitalEnglish, + id = place.id, + name = place.name, + latitude = place.latitude.toDouble(), + longitude = place.longitude.toDouble(), + region = place.address, + congestionLevel = placeRedisUtil.getRedisCongestion(place.id), + isLiked = place.placeLikes.any { it.user == currentUser }, + startDate = null, + endDate = null, + isEnd = null + ) + } + + // 정렬 기준에 따라 처리 + val resultList: List = when (sort) { + GeneralSortType.LIKE -> { + (placeDtoList + festivalDtoList).sortedByDescending { item -> + // 좋아요 수 기준 정렬 + when (item.typeEn) { + InfoType.FESTIVAL.en -> festivals.find { it.id == item.id }?.festivalLikes?.size ?: 0 + else -> places.find { it.id == item.id }?.placeLikes?.size ?: 0 + } + } + } + + GeneralSortType.CONGESTION -> { + // 혼잡도 정렬은 명소만 해당 + placeDtoList + .filter { it.congestionLevel != null } + .sortedBy { it.congestionLevel } + } + + GeneralSortType.DEFAULT -> { + // 기본 정렬은 그대로 + placeDtoList + festivalDtoList + } + } + + // 반환 + return SearchResultDTO.ListDto(resultList) + } + + + +} \ No newline at end of file diff --git a/src/main/kotlin/busanVibe/busan/global/apiPayload/code/status/ErrorStatus.kt b/src/main/kotlin/busanVibe/busan/global/apiPayload/code/status/ErrorStatus.kt index e3d357f..c32042b 100644 --- a/src/main/kotlin/busanVibe/busan/global/apiPayload/code/status/ErrorStatus.kt +++ b/src/main/kotlin/busanVibe/busan/global/apiPayload/code/status/ErrorStatus.kt @@ -27,6 +27,9 @@ enum class ErrorStatus( // 축제 관련 에러 FESTIVAL_NOT_FOUND(HttpStatus.NOT_FOUND, "FESTIVAL4004", "축제를 찾을 수 없습니다."), + // 검색 관련 에러 + SEARCH_INVALID_CONDITION(HttpStatus.BAD_REQUEST, "SEARCH4002", "잘못된 검색 조건입니다."), + // 인증 관련 에러 AUTHENTICATION_FAILED(HttpStatus.UNAUTHORIZED, "AUTH4010", "인증에 실패했습니다.");