diff --git a/pida-clients/aws-client/src/main/kotlin/com/pida/client/aws/image/ImageS3Processor.kt b/pida-clients/aws-client/src/main/kotlin/com/pida/client/aws/image/ImageS3Processor.kt index 13fdb49..f405b47 100644 --- a/pida-clients/aws-client/src/main/kotlin/com/pida/client/aws/image/ImageS3Processor.kt +++ b/pida-clients/aws-client/src/main/kotlin/com/pida/client/aws/image/ImageS3Processor.kt @@ -4,9 +4,12 @@ import com.pida.client.aws.config.AwsProperties import com.pida.client.aws.s3.AwsS3Client import com.pida.support.aws.ImageS3Caller import com.pida.support.aws.PresignedUrlRateLimiter +import com.pida.support.aws.S3ImageInfo import com.pida.support.aws.S3ImageUrl import org.springframework.stereotype.Component import java.time.Duration +import java.time.LocalDateTime +import java.time.ZoneId @Component class ImageS3Processor( @@ -35,7 +38,7 @@ class ImageS3Processor( return S3ImageUrl( presignedUrl, - presignedGet(imageFilePath, imageFileName), + generateGetUrl(imageFilePath, imageFileName), ) } @@ -43,7 +46,7 @@ class ImageS3Processor( prefix: String, prefixId: Long, fileName: String?, - ): List { + ): List { val imageFilePath = imageFileConstructor.imageFilePath(prefix, prefixId) return fileName @@ -52,7 +55,7 @@ class ImageS3Processor( } ?: listPresignedGets(imageFilePath) // 아니면 해당 경로 아래 모든 이미지 탐색 } - private fun presignedGet( + private fun generateGetUrl( filePath: String, fileName: String, ttl: Duration = Duration.ofSeconds(30), @@ -64,10 +67,33 @@ class ImageS3Processor( ttl = ttl, ) + private fun presignedGet( + filePath: String, + fileName: String, + ttl: Duration = Duration.ofSeconds(30), + ): S3ImageInfo { + val url = + awsS3Client.generateUrl( + bucketName = awsProperties.s3.bucket, + filePath = filePath, + fileName = fileName, + ttl = ttl, + ) + val lastModified = + awsS3Client.getObjectLastModified( + bucketName = awsProperties.s3.bucket, + key = "$filePath/$fileName", + ) + return S3ImageInfo( + url = url, + uploadedAt = LocalDateTime.ofInstant(lastModified, ZoneId.of("Asia/Seoul")), + ) + } + private fun listPresignedGets( filePath: String, ttl: Duration = Duration.ofSeconds(30), - ): List = + ): List = awsS3Client .getBucketListObjects( bucketName = awsProperties.s3.bucket, @@ -76,7 +102,17 @@ class ImageS3Processor( .orEmpty() .asSequence() .filterNot { it.key().endsWith("/") } - .map { it.key().substringAfterLast("/") } - .map { presignedGet(filePath, it, ttl) } - .toList() + .map { s3Object -> + val fileName = s3Object.key().substringAfterLast("/") + S3ImageInfo( + url = + awsS3Client.generateUrl( + bucketName = awsProperties.s3.bucket, + filePath = filePath, + fileName = fileName, + ttl = ttl, + ), + uploadedAt = LocalDateTime.ofInstant(s3Object.lastModified(), ZoneId.of("Asia/Seoul")), + ) + }.toList() } diff --git a/pida-clients/aws-client/src/main/kotlin/com/pida/client/aws/s3/AwsS3Client.kt b/pida-clients/aws-client/src/main/kotlin/com/pida/client/aws/s3/AwsS3Client.kt index 816b557..7247822 100644 --- a/pida-clients/aws-client/src/main/kotlin/com/pida/client/aws/s3/AwsS3Client.kt +++ b/pida-clients/aws-client/src/main/kotlin/com/pida/client/aws/s3/AwsS3Client.kt @@ -3,6 +3,7 @@ package com.pida.client.aws.s3 import org.springframework.stereotype.Component import software.amazon.awssdk.services.s3.S3Client import software.amazon.awssdk.services.s3.model.GetObjectRequest +import software.amazon.awssdk.services.s3.model.HeadObjectRequest import software.amazon.awssdk.services.s3.model.ListObjectsV2Request import software.amazon.awssdk.services.s3.model.ListObjectsV2Response import software.amazon.awssdk.services.s3.model.PutObjectRequest @@ -12,6 +13,7 @@ import software.amazon.awssdk.services.s3.presigner.model.GetObjectPresignReques import software.amazon.awssdk.services.s3.presigner.model.PutObjectPresignRequest import java.nio.charset.Charset import java.time.Duration +import java.time.Instant @Component class AwsS3Client( @@ -111,6 +113,19 @@ class AwsS3Client( .build() } + fun getObjectLastModified( + bucketName: String, + key: String, + ): Instant { + val request = + HeadObjectRequest + .builder() + .bucket(bucketName) + .key(key) + .build() + return s3Client.headObject(request).lastModified() + } + fun getObjectAsBytes( bucketName: String, filePath: String, diff --git a/pida-core/core-api/src/main/kotlin/com/pida/presentation/v1/flowerspot/response/FlowerSpotAllResponse.kt b/pida-core/core-api/src/main/kotlin/com/pida/presentation/v1/flowerspot/response/FlowerSpotAllResponse.kt index ad94dcc..bb4818a 100644 --- a/pida-core/core-api/src/main/kotlin/com/pida/presentation/v1/flowerspot/response/FlowerSpotAllResponse.kt +++ b/pida-core/core-api/src/main/kotlin/com/pida/presentation/v1/flowerspot/response/FlowerSpotAllResponse.kt @@ -90,7 +90,7 @@ data class FlowerSpotResponseDto( pinPoint = flowerSpot.pinPoint, region = flowerSpot.region, kind = flowerSpot.kind, - previewUrl = flowerSpot.imageUrls.firstOrNull(), + previewUrl = flowerSpot.images.firstOrNull()?.url, deletedAt = flowerSpot.deletedAt, ) } diff --git a/pida-core/core-api/src/main/kotlin/com/pida/presentation/v1/flowerspot/response/FlowerSpotDetailsResponse.kt b/pida-core/core-api/src/main/kotlin/com/pida/presentation/v1/flowerspot/response/FlowerSpotDetailsResponse.kt index 1b2d1f8..4dd1119 100644 --- a/pida-core/core-api/src/main/kotlin/com/pida/presentation/v1/flowerspot/response/FlowerSpotDetailsResponse.kt +++ b/pida-core/core-api/src/main/kotlin/com/pida/presentation/v1/flowerspot/response/FlowerSpotDetailsResponse.kt @@ -52,11 +52,8 @@ data class FlowerSpotDetailsResponse( val region: Region, @Schema(description = "꽃 종류", example = "BLOSSOM") val kind: FlowerKind, - @Schema( - description = "이미지 URL 목록", - example = "[\"https://example.com/image1.jpg\", \"https://example.com/image2.jpg\"]", - ) - val imageUrls: List = emptyList(), + @Schema(description = "이미지 목록") + val imageUrls: List = emptyList(), @Schema(description = "삭제 일자", example = "2025-04-01T00:00:00") val deletedAt: LocalDateTime?, ) { @@ -74,7 +71,7 @@ data class FlowerSpotDetailsResponse( pinPoint = flowerSpotDetails.pinPoint, region = flowerSpotDetails.region, kind = flowerSpotDetails.kind, - imageUrls = flowerSpotDetails.imageUrls, + imageUrls = flowerSpotDetails.images.map { FlowerSpotImageResponse.from(it) }, deletedAt = flowerSpotDetails.deletedAt, ) } diff --git a/pida-core/core-api/src/main/kotlin/com/pida/presentation/v1/flowerspot/response/FlowerSpotImageResponse.kt b/pida-core/core-api/src/main/kotlin/com/pida/presentation/v1/flowerspot/response/FlowerSpotImageResponse.kt new file mode 100644 index 0000000..b8cda1e --- /dev/null +++ b/pida-core/core-api/src/main/kotlin/com/pida/presentation/v1/flowerspot/response/FlowerSpotImageResponse.kt @@ -0,0 +1,21 @@ +package com.pida.presentation.v1.flowerspot.response + +import com.pida.flowerspot.FlowerSpotImage +import io.swagger.v3.oas.annotations.media.Schema +import java.time.LocalDateTime + +@Schema(description = "벚꽃길 이미지 응답") +data class FlowerSpotImageResponse( + @field:Schema(description = "이미지 URL", example = "https://example.com/image1.jpg") + val url: String, + @field:Schema(description = "등록 일자", example = "2025-04-01T12:00:00") + val createdAt: LocalDateTime, +) { + companion object { + fun from(image: FlowerSpotImage) = + FlowerSpotImageResponse( + url = image.url, + createdAt = image.createdAt, + ) + } +} diff --git a/pida-core/core-api/src/main/kotlin/com/pida/presentation/v1/place/response/PlaceSearchResponse.kt b/pida-core/core-api/src/main/kotlin/com/pida/presentation/v1/place/response/PlaceSearchResponse.kt index dc258f8..bf3cad9 100644 --- a/pida-core/core-api/src/main/kotlin/com/pida/presentation/v1/place/response/PlaceSearchResponse.kt +++ b/pida-core/core-api/src/main/kotlin/com/pida/presentation/v1/place/response/PlaceSearchResponse.kt @@ -1,5 +1,6 @@ package com.pida.presentation.v1.place.response +import com.fasterxml.jackson.annotation.JsonInclude import com.pida.flowerspot.FlowerSpot import com.pida.place.District import com.pida.place.Landmark @@ -37,11 +38,11 @@ data class PlaceSearchResultResponse( @Schema(description = "공통 장소 응답") data class PlaceSearchResponse( - @Schema(description = "장소 이름", example = "여의도 한강공원") + @field:Schema(description = "장소 이름", example = "여의도 한강공원", requiredMode = Schema.RequiredMode.REQUIRED) val name: String, - @Schema(description = "주소", example = "서울특별시 영등포구 여의도동") + @field:Schema(description = "주소", example = "서울특별시 영등포구 여의도동", requiredMode = Schema.RequiredMode.REQUIRED) val address: String?, - @Schema( + @field:Schema( description = "핀 포인트 정보 (GeoJson)", example = """ { @@ -49,10 +50,18 @@ data class PlaceSearchResponse( "coordinates": [126.9340, 37.5284] } """, + requiredMode = Schema.RequiredMode.REQUIRED, ) val pinPoint: GeoJson, - @Schema(description = "지역", example = "SEOUL") + @field:Schema(description = "지역", example = "SEOUL", requiredMode = Schema.RequiredMode.REQUIRED) val region: Region, + @field:JsonInclude(JsonInclude.Include.NON_NULL) + @field:Schema( + description = "벚꽃길 ID (벚꽃길인 경우에만 포함)", + example = "1", + requiredMode = Schema.RequiredMode.NOT_REQUIRED, + ) + val flowerSpotId: Long? = null, ) { companion object { fun from(landmark: Landmark) = @@ -69,13 +78,19 @@ data class PlaceSearchResponse( address = flowerSpot.address, pinPoint = flowerSpot.pinPoint, region = flowerSpot.region, + flowerSpotId = flowerSpot.id, ) fun from(district: District) = PlaceSearchResponse( name = - listOfNotNull(district.sido, district.sigungu, district.eupmyeondonggu, district.eupmyeonridong, district.ri) - .last(), + listOfNotNull( + district.sido, + district.sigungu, + district.eupmyeondonggu, + district.eupmyeonridong, + district.ri, + ).last(), address = listOfNotNull(district.sigungu, district.eupmyeondonggu, district.eupmyeonridong, district.ri) .takeIf { it.isNotEmpty() } diff --git a/pida-core/core-domain/src/main/kotlin/com/pida/flowerspot/FlowerSpotDetails.kt b/pida-core/core-domain/src/main/kotlin/com/pida/flowerspot/FlowerSpotDetails.kt index c77b9b7..43555fd 100644 --- a/pida-core/core-domain/src/main/kotlin/com/pida/flowerspot/FlowerSpotDetails.kt +++ b/pida-core/core-domain/src/main/kotlin/com/pida/flowerspot/FlowerSpotDetails.kt @@ -2,6 +2,7 @@ package com.pida.flowerspot import com.pida.blooming.Blooming import com.pida.blooming.BloomingStatus +import com.pida.support.aws.S3ImageInfo import com.pida.support.geo.GeoJson import com.pida.support.geo.Region import java.time.LocalDateTime @@ -18,14 +19,14 @@ data class FlowerSpotDetails( val pinPoint: GeoJson, // Point GeoJson val region: Region, val kind: FlowerKind, - val imageUrls: List = emptyList(), + val images: List = emptyList(), val deletedAt: LocalDateTime?, ) { companion object { fun of( flowerSpot: FlowerSpot, bloomings: List, - imageUrls: List = emptyList(), + images: List = emptyList(), ) = FlowerSpotDetails( id = flowerSpot.id, address = flowerSpot.address, @@ -38,7 +39,7 @@ data class FlowerSpotDetails( pinPoint = flowerSpot.pinPoint, region = flowerSpot.region, kind = flowerSpot.kind, - imageUrls = imageUrls, + images = images.map { FlowerSpotImage(url = it.url, createdAt = it.uploadedAt) }, deletedAt = flowerSpot.deletedAt, ) } diff --git a/pida-core/core-domain/src/main/kotlin/com/pida/flowerspot/FlowerSpotFacade.kt b/pida-core/core-domain/src/main/kotlin/com/pida/flowerspot/FlowerSpotFacade.kt index b486dd4..3fc44ec 100644 --- a/pida-core/core-domain/src/main/kotlin/com/pida/flowerspot/FlowerSpotFacade.kt +++ b/pida-core/core-domain/src/main/kotlin/com/pida/flowerspot/FlowerSpotFacade.kt @@ -18,7 +18,7 @@ class FlowerSpotFacade( coroutineScope { val flowerSpotDeferred = async { flowerSpotService.readOneFlowerSpot(spotId) } val bloomings = async { bloomingService.recentlyBloomingBySpotId(spotId) } - val imageUrls = + val images = async { imageS3Caller.getImageUrl( prefix = ImagePrefix.FLOWERSPOT.value, @@ -30,7 +30,7 @@ class FlowerSpotFacade( return@coroutineScope FlowerSpotDetails.of( flowerSpot = flowerSpotDeferred.await(), bloomings = bloomings.await().groupBy { it.flowerSpotId }[spotId] ?: emptyList(), - imageUrls = imageUrls.await(), + images = images.await(), ) } @@ -45,7 +45,7 @@ class FlowerSpotFacade( FlowerSpotDetails.of( flowerSpot = flowerSpot, bloomings = recentlyBlooming.groupBy { it.flowerSpotId }[flowerSpot.id] ?: emptyList(), - imageUrls = + images = imageS3Caller.getImageUrl( prefix = ImagePrefix.FLOWERSPOT.value, prefixId = flowerSpot.id, diff --git a/pida-core/core-domain/src/main/kotlin/com/pida/flowerspot/FlowerSpotImage.kt b/pida-core/core-domain/src/main/kotlin/com/pida/flowerspot/FlowerSpotImage.kt new file mode 100644 index 0000000..df95048 --- /dev/null +++ b/pida-core/core-domain/src/main/kotlin/com/pida/flowerspot/FlowerSpotImage.kt @@ -0,0 +1,8 @@ +package com.pida.flowerspot + +import java.time.LocalDateTime + +data class FlowerSpotImage( + val url: String, + val createdAt: LocalDateTime, +) diff --git a/pida-core/core-domain/src/main/kotlin/com/pida/support/aws/ImageS3Caller.kt b/pida-core/core-domain/src/main/kotlin/com/pida/support/aws/ImageS3Caller.kt index 16e9d19..d04f4f5 100644 --- a/pida-core/core-domain/src/main/kotlin/com/pida/support/aws/ImageS3Caller.kt +++ b/pida-core/core-domain/src/main/kotlin/com/pida/support/aws/ImageS3Caller.kt @@ -21,5 +21,5 @@ interface ImageS3Caller { prefix: String, prefixId: Long, fileName: String?, - ): List + ): List } diff --git a/pida-core/core-domain/src/main/kotlin/com/pida/support/aws/S3ImageInfo.kt b/pida-core/core-domain/src/main/kotlin/com/pida/support/aws/S3ImageInfo.kt new file mode 100644 index 0000000..38fe048 --- /dev/null +++ b/pida-core/core-domain/src/main/kotlin/com/pida/support/aws/S3ImageInfo.kt @@ -0,0 +1,8 @@ +package com.pida.support.aws + +import java.time.LocalDateTime + +data class S3ImageInfo( + val url: String, + val uploadedAt: LocalDateTime, +)