Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions pida-clients/aws-client/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ dependencies {
compileOnly(libs.spring.boot.starter.web)

implementation(libs.bundles.aws.client)
implementation(libs.kotlinx.coroutine.core)
implementation(project(":pida-core:core-domain"))

testImplementation(libs.spring.boot.starter.web)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import software.amazon.awssdk.auth.credentials.AwsCredentialsProvider
import software.amazon.awssdk.auth.credentials.DefaultCredentialsProvider
import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider
import software.amazon.awssdk.regions.Region
import software.amazon.awssdk.services.s3.S3AsyncClient
import software.amazon.awssdk.services.s3.S3Client
import software.amazon.awssdk.services.s3.presigner.S3Presigner
import java.net.URI
Expand Down Expand Up @@ -46,6 +47,20 @@ class AwsConfig(
return client.build()
}

@Bean(destroyMethod = "close")
fun s3AsyncClient(): S3AsyncClient {
val client =
S3AsyncClient
.builder()
.credentialsProvider(credentialProvider())
.region(Region.of(awsProperties.region))
awsProperties.endpoint?.let {
client.endpointOverride(URI.create(awsProperties.endpoint))
}

return client.build()
}

@Bean(destroyMethod = "close")
fun s3Presigner(): S3Presigner {
val client =
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package com.pida.client.aws.image

import com.pida.client.aws.config.AwsProperties
import com.pida.client.aws.s3.AwsS3AsyncClient
import com.pida.client.aws.s3.AwsS3Client
import com.pida.support.aws.ImageS3Caller
import com.pida.support.aws.PresignedUrlRateLimiter
Expand All @@ -16,6 +17,7 @@ import java.time.ZoneId
@Component
class ImageS3Processor(
private val awsS3Client: AwsS3Client,
private val awsS3AsyncClient: AwsS3AsyncClient,
private val awsProperties: AwsProperties,
private val imageFileConstructor: ImageFileConstructor,
private val rateLimiter: PresignedUrlRateLimiter,
Expand All @@ -42,9 +44,11 @@ class ImageS3Processor(
Duration.ofSeconds(30), // ๋งŒ๋ฃŒ ์‹œ๊ฐ„ ์ตœ์†Œํ™”
)

val s3Key = "$imageFilePath/$imageFileName"
return S3ImageUrl(
presignedUrl,
generateGetUrl(imageFilePath, imageFileName),
s3Key,
)
}

Expand Down Expand Up @@ -91,11 +95,21 @@ class ImageS3Processor(
): S3ImageInfo? {
val imageFilePath = imageFileConstructor.imageFilePath(prefix, prefixId)

return listImageObjects(imageFilePath)
return awsS3AsyncClient
.listObjects(
bucketName = awsProperties.s3.bucket,
filePath = imageFilePath,
).filterNot { it.key().endsWith("/") }
.maxByOrNull(S3Object::lastModified)
?.toImageInfo(imageFilePath, Duration.ofSeconds(30))
}

override fun generatePresignedUrl(s3Key: String): String {
val filePath = s3Key.substringBeforeLast("/")
val fileName = s3Key.substringAfterLast("/")
return generateGetUrl(filePath, fileName)
}

private fun generateGetUrl(
filePath: String,
fileName: String,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
package com.pida.client.aws.s3

import kotlinx.coroutines.future.await
import org.springframework.stereotype.Component
import software.amazon.awssdk.services.s3.S3AsyncClient
import software.amazon.awssdk.services.s3.model.ListObjectsV2Request
import software.amazon.awssdk.services.s3.model.S3Object

@Component
class AwsS3AsyncClient(
private val s3AsyncClient: S3AsyncClient,
) {
suspend fun listObjects(
bucketName: String,
filePath: String,
): List<S3Object> {
val allObjects = mutableListOf<S3Object>()
var continuationToken: String? = null

do {
val request =
ListObjectsV2Request
.builder()
.bucket(bucketName)
.prefix(filePath)
.maxKeys(1000)
.continuationToken(continuationToken)
.build()

val response = s3AsyncClient.listObjectsV2(request).await()
allObjects.addAll(response.contents())
continuationToken = response.nextContinuationToken()
} while (response.isTruncated)

return allObjects
}
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package com.pida.blooming

import com.pida.flowerspot.FlowerSpotService
import com.pida.reporter.RecentReporterService
import com.pida.support.aws.ImagePrefix
import com.pida.support.aws.ImageS3Caller
Expand All @@ -20,6 +21,7 @@ class BloomingFacade(
private val userService: UserService,
private val imageS3Caller: ImageS3Caller,
private val eventPublisher: ApplicationEventPublisher,
private val flowerSpotService: FlowerSpotService,
) {
suspend fun readBloomingDetailsBySpotId(flowerSpotId: Long): BloomingDetails =
coroutineScope {
Expand Down Expand Up @@ -107,9 +109,19 @@ class BloomingFacade(
is NewBlooming.FlowerEvent -> ImagePrefix.FLOWEREVENT.value to newBlooming.flowerEventId
}

return BloomingImageUploadUrl.from(
imageS3Caller.createUploadUrl(newBlooming.userId, prefix, prefixId),
)
val imageUploadUrl = imageS3Caller.createUploadUrl(newBlooming.userId, prefix, prefixId)

when (newBlooming) {
is NewBlooming.FlowerSpot -> {
flowerSpotService.updatePreviewImageKey(
newBlooming.flowerSpotId,
imageUploadUrl.s3Key,
)
}
is NewBlooming.FlowerEvent -> {}
}

return BloomingImageUploadUrl.from(imageUploadUrl)
Comment thread
LeeHanEum marked this conversation as resolved.
}

private fun buildBloomingDetails(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,4 +16,6 @@ data class FlowerSpot(
val kind: FlowerKind,
val type: FlowerSpotType,
val deletedAt: LocalDateTime?,
val previewImageKey: String? = null,
val previewImageUploadedAt: LocalDateTime? = null,
)
Original file line number Diff line number Diff line change
@@ -1,11 +1,9 @@
package com.pida.flowerspot

import com.fasterxml.jackson.core.type.TypeReference
import com.pida.blooming.BloomingService
import com.pida.support.aws.ImagePrefix
import com.pida.support.aws.ImageS3Caller
import com.pida.support.aws.S3ImageInfo
import com.pida.support.cache.Cache
import com.pida.support.geo.Region
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
Expand All @@ -17,11 +15,6 @@ class FlowerSpotFacade(
private val bloomingService: BloomingService,
private val imageS3Caller: ImageS3Caller,
) {
companion object {
const val PREVIEW_IMAGE_KEY = "spot:preview"
const val PREVIEW_IMAGE_TTL = 10L
}

suspend fun readFlowerSpotDetails(spotId: Long): FlowerSpotDetails =
coroutineScope {
val flowerSpotDeferred = async { flowerSpotService.readOneFlowerSpot(spotId) }
Expand Down Expand Up @@ -50,33 +43,25 @@ class FlowerSpotFacade(
val flowerSpots = flowerSpotService.readAllFlowerSpot(region, location)
if (flowerSpots.isEmpty()) return@coroutineScope emptyList()

val bloomingDeferred = async { bloomingService.recentlyBloomingBySpotIds(flowerSpots.map { it.id }) }
val previewDeferred =
flowerSpots.map { spot ->
async { spot.id to cachedPreviewImage(spot.id) }
}

val bloomingBySpotId = bloomingDeferred.await().groupBy { it.flowerSpotId }
val previewBySpotId = previewDeferred.associate { it.await() }
val bloomingBySpotId =
bloomingService
.recentlyBloomingBySpotIds(flowerSpots.map { it.id })
.groupBy { it.flowerSpotId }

flowerSpots.map { flowerSpot ->
FlowerSpotDetails.of(
flowerSpot = flowerSpot,
bloomings = bloomingBySpotId[flowerSpot.id] ?: emptyList(),
images = listOfNotNull(previewBySpotId[flowerSpot.id]),
images = listOfNotNull(previewImagePresignedUrl(flowerSpot)),
)
}
}

private suspend fun cachedPreviewImage(spotId: Long): S3ImageInfo? =
Cache.cache(
ttl = PREVIEW_IMAGE_TTL,
key = "$PREVIEW_IMAGE_KEY:$spotId",
typeReference = object : TypeReference<S3ImageInfo?>() {},
) {
imageS3Caller.getPreviewImage(
prefix = ImagePrefix.FLOWERSPOT.value,
prefixId = spotId,
private fun previewImagePresignedUrl(flowerSpot: FlowerSpot): S3ImageInfo? =
flowerSpot.previewImageKey?.let { key ->
S3ImageInfo(
url = imageS3Caller.generatePresignedUrl(key),
uploadedAt = flowerSpot.previewImageUploadedAt!!,
)
}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package com.pida.flowerspot

import com.pida.support.geo.Region
import java.time.LocalDateTime

interface FlowerSpotRepository {
suspend fun findBy(spotId: Long): FlowerSpot
Expand All @@ -23,4 +24,10 @@ interface FlowerSpotRepository {
): List<FlowerSpot>

fun findByStreetNameContaining(streetName: String): List<FlowerSpot>

suspend fun updatePreviewImageKey(
spotId: Long,
key: String,
uploadedAt: LocalDateTime,
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import org.springframework.stereotype.Service
@Service
class FlowerSpotService(
private val flowerSpotFinder: FlowerSpotFinder,
private val flowerSpotUpdater: FlowerSpotUpdater,
) {
suspend fun readAllFlowerSpot(
region: Region?,
Expand All @@ -29,4 +30,9 @@ class FlowerSpotService(

return flowerSpotFinder.searchByStreetName(trimmed)
}

suspend fun updatePreviewImageKey(
spotId: Long,
key: String,
) = flowerSpotUpdater.updatePreviewImageKey(spotId, key)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package com.pida.flowerspot

import org.springframework.stereotype.Component
import java.time.LocalDateTime

@Component
class FlowerSpotUpdater(
private val flowerSpotRepository: FlowerSpotRepository,
) {
suspend fun updatePreviewImageKey(
spotId: Long,
key: String,
) {
flowerSpotRepository.updatePreviewImageKey(spotId, key, LocalDateTime.now())
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,13 @@ interface ImageS3Caller {
prefixId: Long,
): S3ImageInfo?

/**
* S3 key๋กœ๋ถ€ํ„ฐ presigned GET URL ์ƒ์„ฑ (๋กœ์ปฌ ์„œ๋ช…, ๋„คํŠธ์›Œํฌ ํ˜ธ์ถœ ์—†์Œ)
*
* @param s3Key [String] S3 ๊ฐ์ฒด ์ „์ฒด ํ‚ค (e.g. "prod/flowerspot/42/abcdef.jpeg")
*/
fun generatePresignedUrl(s3Key: String): String

/**
* ์„œ๋ฒ„ ์‚ฌ์ด๋“œ ์ด๋ฏธ์ง€ ์—…๋กœ๋“œ
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,5 @@ package com.pida.support.aws
data class S3ImageUrl(
val presignedUrl: String,
val presignedGetUrl: String,
val s3Key: String,
)
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package com.pida.blooming

import com.pida.flowerspot.FlowerSpotService
import com.pida.reporter.RecentReporterService
import com.pida.support.aws.ImagePrefix
import com.pida.support.aws.ImageS3Caller
Expand All @@ -25,13 +26,15 @@ class BloomingFacadeTest {
val userService = mockk<UserService>()
val imageS3Caller = mockk<ImageS3Caller>()
val eventPublisher = mockk<ApplicationEventPublisher>()
val flowerSpotService = mockk<FlowerSpotService>()
val facade =
BloomingFacade(
bloomingService = bloomingService,
recentReporterService = recentReporterService,
userService = userService,
imageS3Caller = imageS3Caller,
eventPublisher = eventPublisher,
flowerSpotService = flowerSpotService,
)

coEvery { bloomingService.recentlyBloomingByEventId(7L) } returns
Expand Down Expand Up @@ -90,13 +93,15 @@ class BloomingFacadeTest {
val userService = mockk<UserService>()
val imageS3Caller = mockk<ImageS3Caller>()
val eventPublisher = mockk<ApplicationEventPublisher>()
val flowerSpotService = mockk<FlowerSpotService>()
val facade =
BloomingFacade(
bloomingService = bloomingService,
recentReporterService = recentReporterService,
userService = userService,
imageS3Caller = imageS3Caller,
eventPublisher = eventPublisher,
flowerSpotService = flowerSpotService,
)
val newBlooming =
NewBlooming.FlowerSpot(
Expand All @@ -121,7 +126,9 @@ class BloomingFacadeTest {
S3ImageUrl(
presignedUrl = "spot-upload",
presignedGetUrl = "spot-preview",
s3Key = "prod/flowerspot/3/abc.jpeg",
)
coEvery { flowerSpotService.updatePreviewImageKey(3L, "prod/flowerspot/3/abc.jpeg") } returns Unit

val result = facade.uploadBloomingStatus(newBlooming)

Expand All @@ -140,13 +147,15 @@ class BloomingFacadeTest {
val userService = mockk<UserService>()
val imageS3Caller = mockk<ImageS3Caller>()
val eventPublisher = mockk<ApplicationEventPublisher>()
val flowerSpotService = mockk<FlowerSpotService>()
val facade =
BloomingFacade(
bloomingService = bloomingService,
recentReporterService = recentReporterService,
userService = userService,
imageS3Caller = imageS3Caller,
eventPublisher = eventPublisher,
flowerSpotService = flowerSpotService,
)
val newBlooming =
NewBlooming.FlowerEvent(
Expand All @@ -171,6 +180,7 @@ class BloomingFacadeTest {
S3ImageUrl(
presignedUrl = "event-upload",
presignedGetUrl = "event-preview",
s3Key = "prod/flowerevent/7/abc.jpeg",
)

val result = facade.uploadBloomingStatus(newBlooming)
Expand Down
Loading
Loading