diff --git a/app-server/subprojects/bounded_context/accessibility/application/build.gradle.kts b/app-server/subprojects/bounded_context/accessibility/application/build.gradle.kts index de69d70a4..4ce460554 100644 --- a/app-server/subprojects/bounded_context/accessibility/application/build.gradle.kts +++ b/app-server/subprojects/bounded_context/accessibility/application/build.gradle.kts @@ -7,4 +7,10 @@ dependencies { implementation("net.coobird:thumbnailator:0.4.20") implementation("org.sejda.webp-imageio:webp-imageio-sejda:0.1.0") + implementation("org.bytedeco:javacv-platform:1.5.10") + + testImplementation("org.mockito.kotlin:mockito-kotlin:5.1.0") + + integrationTestImplementation(projects.crossCuttingConcern.test.springIt) + integrationTestImplementation("org.springframework.boot:spring-boot-starter-web") } diff --git a/app-server/subprojects/bounded_context/accessibility/application/src/integrationTest/kotlin/club/staircrusher/accessibility/application/port/in/BlurFacesITBase.kt b/app-server/subprojects/bounded_context/accessibility/application/src/integrationTest/kotlin/club/staircrusher/accessibility/application/port/in/BlurFacesITBase.kt new file mode 100644 index 000000000..db0c8f2da --- /dev/null +++ b/app-server/subprojects/bounded_context/accessibility/application/src/integrationTest/kotlin/club/staircrusher/accessibility/application/port/in/BlurFacesITBase.kt @@ -0,0 +1,46 @@ +package club.staircrusher.accessibility.application.port.`in` + +import club.staircrusher.accessibility.application.port.`in`.image.ImageProcessor +import club.staircrusher.accessibility.application.port.out.DetectFacesResponse +import club.staircrusher.accessibility.application.port.out.DetectFacesService +import club.staircrusher.accessibility.domain.model.DetectedFacePosition +import club.staircrusher.stdlib.Size +import club.staircrusher.testing.spring_it.ITDataGenerator +import club.staircrusher.testing.spring_it.base.SccSpringITBase +import club.staircrusher.testing.spring_it.mock.MockSccClock +import kotlinx.coroutines.runBlocking +import org.mockito.Mockito +import org.mockito.kotlin.eq +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.mock.mockito.MockBean + +class BlurFacesITBase : SccSpringITBase() { + @Autowired + lateinit var dataGenerator: ITDataGenerator + + @Autowired + lateinit var clock: MockSccClock + + @MockBean + lateinit var imageProcessor: ImageProcessor + + @MockBean + lateinit var detectFacesService: DetectFacesService + + + fun mockDetectFacesWithFaceImage(imageUrl: String, imageBytes: ByteArray) = runBlocking { + Mockito.`when`(detectFacesService.detect(eq(imageUrl))).thenReturn( + DetectFacesResponse( + imageBytes = imageBytes, imageSize = Size(100, 100), positions = listOf(DetectedFacePosition(0, 0, 10, 10)) + ) + ) + } + + fun mockDetectFacesWithNoFaceImage(imageUrl: String, imageBytes: ByteArray) = runBlocking { + Mockito.`when`(detectFacesService.detect(eq(imageUrl))).thenReturn( + DetectFacesResponse( + imageBytes = imageBytes, imageSize = Size(100, 100), positions = emptyList() + ) + ) + } +} diff --git a/app-server/subprojects/bounded_context/accessibility/application/src/integrationTest/kotlin/club/staircrusher/accessibility/application/port/in/BlurFacesInLatestBuildingAccessibilityImagesUseCaseTest.kt b/app-server/subprojects/bounded_context/accessibility/application/src/integrationTest/kotlin/club/staircrusher/accessibility/application/port/in/BlurFacesInLatestBuildingAccessibilityImagesUseCaseTest.kt new file mode 100644 index 000000000..76ce35e0a --- /dev/null +++ b/app-server/subprojects/bounded_context/accessibility/application/src/integrationTest/kotlin/club/staircrusher/accessibility/application/port/in/BlurFacesInLatestBuildingAccessibilityImagesUseCaseTest.kt @@ -0,0 +1,149 @@ +package club.staircrusher.accessibility.application.port.`in` + +import club.staircrusher.accessibility.application.port.out.persistence.AccessibilityImageFaceBlurringHistoryRepository +import club.staircrusher.accessibility.application.port.out.persistence.BuildingAccessibilityRepository +import club.staircrusher.accessibility.application.port.out.persistence.PlaceAccessibilityRepository +import club.staircrusher.accessibility.domain.model.AccessibilityImage +import club.staircrusher.accessibility.domain.model.AccessibilityImageFaceBlurringHistory +import club.staircrusher.stdlib.testing.SccRandom +import club.staircrusher.testing.spring_it.base.SccSpringITApplication +import club.staircrusher.testing.spring_it.mock.MockDetectFacesService +import kotlinx.coroutines.runBlocking +import org.junit.jupiter.api.Assertions +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.mockito.Mockito +import org.mockito.kotlin.any +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.context.SpringBootTest +import java.time.Duration + +@SpringBootTest(classes = [SccSpringITApplication::class]) +class BlurFacesInLatestBuildingAccessibilityImagesUseCaseTest : BlurFacesITBase() { + @Autowired + private lateinit var accessibilityImageFaceBlurringHistoryRepository: AccessibilityImageFaceBlurringHistoryRepository + + @Autowired + private lateinit var blurFacesInLatestBuildingAccessibilityImagesUseCase: BlurFacesInLatestBuildingAccessibilityImagesUseCase + + @Autowired + private lateinit var buildingAccessibilityRepository: BuildingAccessibilityRepository + + @Autowired + private lateinit var placeAccessibilityRepository: PlaceAccessibilityRepository + + @BeforeEach + fun setUp() = runBlocking { + val imageBytes = ByteArray(10) { it.toByte() } + mockDetectFacesWithFaceImage(MockDetectFacesService.URL_WITH_FACES, imageBytes) + mockDetectFacesWithNoFaceImage(MockDetectFacesService.URL_WITHOUT_FACES, imageBytes) + Mockito.`when`(imageProcessor.blur(any(), any(), any())).thenReturn(imageBytes) + + placeAccessibilityRepository.removeAll() + buildingAccessibilityRepository.removeAll() + accessibilityImageFaceBlurringHistoryRepository.removeAll() + } + + @Test + fun `얼굴 블러링 기록이 없으면 가장 오래된 building accessibility 의 이미지부터 얼굴 블러링한다`() { + val (_, _, oldestBuildingAccessibility) = registerPlaceAccessibilityAndBuildingAccessibility( + listOf(MockDetectFacesService.URL_WITH_FACES) + ) + clock.advanceTime(Duration.ofMinutes(1)) + val (_, _, secondOldestBuildingAccessibility) = registerPlaceAccessibilityAndBuildingAccessibility( + listOf(MockDetectFacesService.URL_WITH_FACES) + ) + + blurFacesInLatestBuildingAccessibilityImagesUseCase.handle() + + val blurredResult = accessibilityImageFaceBlurringHistoryRepository.findByBuildingAccessibilityId( + oldestBuildingAccessibility.id + ) + Assertions.assertTrue(blurredResult.isNotEmpty()) + val notBlurredResult = accessibilityImageFaceBlurringHistoryRepository.findByBuildingAccessibilityId( + secondOldestBuildingAccessibility.id + ) + Assertions.assertTrue(notBlurredResult.isEmpty()) + } + + @Test + fun `얼굴 블러링 기록이 이후 가장 오래된 building accessibility 의 이미지부터 얼굴 블러링한다`() { + val (_, _, oldestBuildingAccessibility) = registerPlaceAccessibilityAndBuildingAccessibility( + listOf(MockDetectFacesService.URL_WITH_FACES) + ) + clock.advanceTime(Duration.ofMinutes(1)) + val (_, _, secondOldestBuildingAccessibility) = registerPlaceAccessibilityAndBuildingAccessibility( + listOf(MockDetectFacesService.URL_WITH_FACES) + ) + transactionManager.doInTransaction { + accessibilityImageFaceBlurringHistoryRepository.save( + AccessibilityImageFaceBlurringHistory( + id = "", placeAccessibilityId = null, + buildingAccessibilityId = oldestBuildingAccessibility.id, + originalImageUrls = listOf("image_url"), + blurredImageUrls = listOf("blurred_image_url"), + detectedPeopleCounts = emptyList(), + createdAt = clock.instant(), + updatedAt = clock.instant() + ) + ) + } + + blurFacesInLatestBuildingAccessibilityImagesUseCase.handle() + + val result = accessibilityImageFaceBlurringHistoryRepository.findByBuildingAccessibilityId( + secondOldestBuildingAccessibility.id + ) + Assertions.assertTrue(result.isNotEmpty()) + } + + @Test + fun `BuildingAccessibility 이미지 중 얼굴이 감지된 사진만 업데이트한다`() = runBlocking { + val (_, _, buildingAccessibility) = registerPlaceAccessibilityAndBuildingAccessibility( + listOf(MockDetectFacesService.URL_WITH_FACES, MockDetectFacesService.URL_WITHOUT_FACES) + ) + + blurFacesInLatestBuildingAccessibilityImagesUseCase.handle() + + val reloadBuildingAccessibility = transactionManager.doInTransaction { + buildingAccessibilityRepository.findByIdOrNull(buildingAccessibility.id) + } + val entranceImages = reloadBuildingAccessibility?.entranceImages?.map { it.imageUrl } ?: emptyList() + Assertions.assertFalse(entranceImages.contains(MockDetectFacesService.URL_WITH_FACES)) + Assertions.assertTrue(entranceImages.contains(MockDetectFacesService.URL_WITHOUT_FACES)) + val elevatorImages = reloadBuildingAccessibility?.entranceImages?.map { it.imageUrl } ?: emptyList() + Assertions.assertFalse(elevatorImages.contains(MockDetectFacesService.URL_WITH_FACES)) + Assertions.assertTrue(elevatorImages.contains(MockDetectFacesService.URL_WITHOUT_FACES)) + } + + + @Test + fun `이미 썸네일 처리가 된 BuildingAccessibility 의 경우 블러링 한 이미지 사용을 위해 썸네일 url을 제거한다`() = runBlocking { + val (_, _, buildingAccessibility) = registerPlaceAccessibilityAndBuildingAccessibility( + imageUrls = listOf(MockDetectFacesService.URL_WITH_FACES, MockDetectFacesService.URL_WITHOUT_FACES) + ) + + blurFacesInLatestBuildingAccessibilityImagesUseCase.handle() + + val reloadBuildingAccessibility = transactionManager.doInTransaction { + buildingAccessibilityRepository.findByIdOrNull(buildingAccessibility.id) + } + val entranceImages = reloadBuildingAccessibility?.entranceImages ?: emptyList() + Assertions.assertTrue(entranceImages.isNotEmpty()) + Assertions.assertTrue(entranceImages.mapNotNull { it.thumbnailUrl }.isEmpty()) + val elevatorImages = reloadBuildingAccessibility?.entranceImages ?: emptyList() + Assertions.assertTrue(elevatorImages.isNotEmpty()) + Assertions.assertTrue(elevatorImages.mapNotNull { it.thumbnailUrl }.isEmpty()) + } + + private fun registerPlaceAccessibilityAndBuildingAccessibility(imageUrls: List) = + transactionManager.doInTransaction { + val user = dataGenerator.createUser() + val building = dataGenerator.createBuilding() + val place = dataGenerator.createPlace(placeName = SccRandom.string(32), building = building) + val (placeAccessibility, buildingAccessibility) = dataGenerator.registerBuildingAndPlaceAccessibility( + place = place, user = user, imageUrls = imageUrls, images = imageUrls.map { AccessibilityImage(it, it) } + ) + Triple(user, placeAccessibility, buildingAccessibility) + } +} diff --git a/app-server/subprojects/bounded_context/accessibility/application/src/integrationTest/kotlin/club/staircrusher/accessibility/application/port/in/BlurFacesInLatestPlaceAccessibilityImagesUseCaseTest.kt b/app-server/subprojects/bounded_context/accessibility/application/src/integrationTest/kotlin/club/staircrusher/accessibility/application/port/in/BlurFacesInLatestPlaceAccessibilityImagesUseCaseTest.kt new file mode 100644 index 000000000..ae60c5a45 --- /dev/null +++ b/app-server/subprojects/bounded_context/accessibility/application/src/integrationTest/kotlin/club/staircrusher/accessibility/application/port/in/BlurFacesInLatestPlaceAccessibilityImagesUseCaseTest.kt @@ -0,0 +1,140 @@ +package club.staircrusher.accessibility.application.port.`in` + +import club.staircrusher.accessibility.application.port.out.persistence.AccessibilityImageFaceBlurringHistoryRepository +import club.staircrusher.accessibility.application.port.out.persistence.BuildingAccessibilityRepository +import club.staircrusher.accessibility.application.port.out.persistence.PlaceAccessibilityRepository +import club.staircrusher.accessibility.domain.model.AccessibilityImage +import club.staircrusher.accessibility.domain.model.AccessibilityImageFaceBlurringHistory +import club.staircrusher.stdlib.testing.SccRandom +import club.staircrusher.testing.spring_it.base.SccSpringITApplication +import club.staircrusher.testing.spring_it.mock.MockDetectFacesService +import kotlinx.coroutines.runBlocking +import org.junit.jupiter.api.Assertions +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.mockito.Mockito +import org.mockito.kotlin.any +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.context.SpringBootTest +import java.time.Duration + +@SpringBootTest(classes = [SccSpringITApplication::class]) +class BlurFacesInLatestPlaceAccessibilityImagesUseCaseTest : BlurFacesITBase() { + @Autowired + private lateinit var accessibilityImageFaceBlurringHistoryRepository: AccessibilityImageFaceBlurringHistoryRepository + + @Autowired + private lateinit var blurFacesInLatestPlaceAccessibilityImagesUseCase: BlurFacesInLatestPlaceAccessibilityImagesUseCase + + @Autowired + private lateinit var buildingAccessibilityRepository: BuildingAccessibilityRepository + + @Autowired + private lateinit var placeAccessibilityRepository: PlaceAccessibilityRepository + + @BeforeEach + fun setUp() = runBlocking { + val imageBytes = ByteArray(10) { it.toByte() } + mockDetectFacesWithFaceImage(MockDetectFacesService.URL_WITH_FACES, imageBytes) + mockDetectFacesWithNoFaceImage(MockDetectFacesService.URL_WITHOUT_FACES, imageBytes) + Mockito.`when`(imageProcessor.blur(any(), any(), any())).thenReturn(imageBytes) + + placeAccessibilityRepository.removeAll() + buildingAccessibilityRepository.removeAll() + accessibilityImageFaceBlurringHistoryRepository.removeAll() + } + + @Test + fun `얼굴 블러링 기록이 없으면 가장 오래된 place accessibility 의 이미지부터 얼굴 블러링한다`() { + val (_, oldestPlaceAccessibility, _) = registerPlaceAccessibilityAndBuildingAccessibility( + listOf(MockDetectFacesService.URL_WITH_FACES) + ) + clock.advanceTime(Duration.ofMinutes(1)) + val (_, secondOldestPlaceAccessibility, _) = registerPlaceAccessibilityAndBuildingAccessibility( + listOf(MockDetectFacesService.URL_WITH_FACES) + ) + + blurFacesInLatestPlaceAccessibilityImagesUseCase.handle() + + val blurredResult = + accessibilityImageFaceBlurringHistoryRepository.findByPlaceAccessibilityId(oldestPlaceAccessibility.id) + Assertions.assertTrue(blurredResult.isNotEmpty()) + val notBlurredResult = + accessibilityImageFaceBlurringHistoryRepository.findByPlaceAccessibilityId(secondOldestPlaceAccessibility.id) + Assertions.assertTrue(notBlurredResult.isEmpty()) + } + + @Test + fun `얼굴 블러링 기록이 이후 가장 오래된 place accessibility 의 이미지부터 얼굴 블러링한다`() { + val (_, oldestPlaceAccessibility, _) = registerPlaceAccessibilityAndBuildingAccessibility( + listOf(MockDetectFacesService.URL_WITH_FACES) + ) + clock.advanceTime(Duration.ofMinutes(1)) + val (_, secondOldestPlaceAccessibility, _) = registerPlaceAccessibilityAndBuildingAccessibility( + listOf(MockDetectFacesService.URL_WITH_FACES) + ) + transactionManager.doInTransaction { + accessibilityImageFaceBlurringHistoryRepository.save( + AccessibilityImageFaceBlurringHistory( + id = "", + placeAccessibilityId = oldestPlaceAccessibility.id, + buildingAccessibilityId = null, + originalImageUrls = listOf("image_url"), + blurredImageUrls = listOf("blurred_image_url"), + detectedPeopleCounts = emptyList(), + createdAt = clock.instant(), + updatedAt = clock.instant() + ) + ) + } + + blurFacesInLatestPlaceAccessibilityImagesUseCase.handle() + + val result = + accessibilityImageFaceBlurringHistoryRepository.findByPlaceAccessibilityId(secondOldestPlaceAccessibility.id) + Assertions.assertTrue(result.isNotEmpty()) + } + + @Test + fun `PlaceAccessibility 이미지 중 얼굴이 감지된 사진만 업데이트한다`() = runBlocking { + val (_, placeAccessibility, _) = registerPlaceAccessibilityAndBuildingAccessibility( + listOf(MockDetectFacesService.URL_WITH_FACES, MockDetectFacesService.URL_WITHOUT_FACES) + ) + + blurFacesInLatestPlaceAccessibilityImagesUseCase.handle() + + val reloadPlaceAccessibility = transactionManager.doInTransaction { + placeAccessibilityRepository.findByIdOrNull(placeAccessibility.id) + } + val imageUrls = reloadPlaceAccessibility?.images?.map { it.imageUrl } ?: emptyList() + Assertions.assertFalse(imageUrls.contains(MockDetectFacesService.URL_WITH_FACES)) + Assertions.assertTrue(imageUrls.contains(MockDetectFacesService.URL_WITHOUT_FACES)) + } + + @Test + fun `이미 썸네일 처리가 된 PlaceAccessibility 의 경우 블러링 한 이미지 사용을 위해 썸네일 url을 제거한다`() = runBlocking { + val (_, placeAccessibility, _) = registerPlaceAccessibilityAndBuildingAccessibility( + listOf(MockDetectFacesService.URL_WITH_FACES, MockDetectFacesService.URL_WITHOUT_FACES) + ) + + blurFacesInLatestPlaceAccessibilityImagesUseCase.handle() + + val reloadPlaceAccessibility = transactionManager.doInTransaction { + placeAccessibilityRepository.findByIdOrNull(placeAccessibility.id) + } + val images = reloadPlaceAccessibility?.images ?: emptyList() + Assertions.assertTrue(images.isNotEmpty()) + Assertions.assertTrue(images.mapNotNull { it.thumbnailUrl }.isEmpty()) + } + + private fun registerPlaceAccessibilityAndBuildingAccessibility(imageUrls: List) = + transactionManager.doInTransaction { + val user = dataGenerator.createUser() + val building = dataGenerator.createBuilding() + val place = dataGenerator.createPlace(placeName = SccRandom.string(32), building = building) + val (placeAccessibility, buildingAccessibility) = dataGenerator.registerBuildingAndPlaceAccessibility( + place = place, user = user, imageUrls = imageUrls, images = imageUrls.map { AccessibilityImage(it, it) } + ) + Triple(user, placeAccessibility, buildingAccessibility) + } +} diff --git a/app-server/subprojects/bounded_context/accessibility/application/src/main/kotlin/club/staircrusher/accessibility/application/port/in/AccessibilityImageFaceBlurringService.kt b/app-server/subprojects/bounded_context/accessibility/application/src/main/kotlin/club/staircrusher/accessibility/application/port/in/AccessibilityImageFaceBlurringService.kt new file mode 100644 index 000000000..8077b90ae --- /dev/null +++ b/app-server/subprojects/bounded_context/accessibility/application/src/main/kotlin/club/staircrusher/accessibility/application/port/in/AccessibilityImageFaceBlurringService.kt @@ -0,0 +1,102 @@ +package club.staircrusher.accessibility.application.port.`in` + +import club.staircrusher.accessibility.application.port.`in`.image.ImageProcessor +import club.staircrusher.accessibility.application.port.out.DetectFacesService +import club.staircrusher.accessibility.application.port.out.file_management.FileManagementService +import club.staircrusher.stdlib.di.annotation.Component +import club.staircrusher.stdlib.persistence.TransactionManager +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope +import mu.KotlinLogging + +@Component +open class AccessibilityImageFaceBlurringService( + private val accessibilityImageService: AccessibilityImageService, + private val imageProcessor: ImageProcessor, + private val detectFacesService: DetectFacesService, + private val fileManagementService: FileManagementService, + private val transactionManager: TransactionManager, +) { + private val logger = KotlinLogging.logger {} + + suspend fun blurFacesInPlaceAccessibility(placeAccessibilityId: String): PlaceAccessibilityBlurResult? { + val placeAccessibility = transactionManager.doInTransaction { + accessibilityImageService.doMigratePlaceAccessibilityImageUrlsToImagesIfNeeded(placeAccessibilityId = placeAccessibilityId) + } + if (placeAccessibility == null) return null + val imageUrls = placeAccessibility.images.map { it.imageUrl } + val result = detectAndBlurFaces(imageUrls) + return PlaceAccessibilityBlurResult(result) + } + + suspend fun blurFacesInBuildingAccessibility(buildingAccessibilityId: String): BuildingAccessibilityBlurResult? { + val buildingAccessibility = transactionManager.doInTransaction { + accessibilityImageService.doMigrateBuildingAccessibilityImageUrlsToImagesIfNeeded(buildingAccessibilityId = buildingAccessibilityId) + } + if (buildingAccessibility == null) return null + val entranceResult = detectAndBlurFaces(buildingAccessibility.entranceImages.map { it.imageUrl }) + val elevatorResult = detectAndBlurFaces(buildingAccessibility.elevatorImages.map { it.imageUrl }) + return BuildingAccessibilityBlurResult(entranceResult, elevatorResult) + } + + private suspend fun detectAndBlurFaces(imageUrls: List): List = coroutineScope { + imageUrls.map { async { detectAndBlurFaces(it) } }.awaitAll() + } + + @Suppress("ReturnCount") + private suspend fun detectAndBlurFaces(imageUrl: String): BlurResult { + try { + val (name, extension) = imageUrl.split("/").last().split(".") + if (listOf("jpg", "jpeg", "png", "webp").contains(extension).not()) { + logger.info { "Detecting and blurring faces failed. $imageUrl is not image." } + return BlurResult( + originalImageUrl = imageUrl, + blurredImageUrl = imageUrl, + detectedPeopleCount = 0 + ) + } + val detected = detectFacesService.detect(imageUrl) + val imageBytes = detected.imageBytes + if (detected.positions.isEmpty()) return BlurResult( + originalImageUrl = imageUrl, + blurredImageUrl = imageUrl, + detectedPeopleCount = 0 + ) + val (blurredImageUrl, detectedPositions) = run { + val outputByteArray = imageProcessor.blur(imageBytes, extension, detected.positions) + val blurredImageUrl = fileManagementService.uploadImage("${name}_b.$extension", outputByteArray) + blurredImageUrl to detected.positions + } + return BlurResult( + originalImageUrl = imageUrl, + blurredImageUrl = blurredImageUrl ?: imageUrl, + detectedPeopleCount = detectedPositions.size + ) + } catch (e: Exception) { + logger.error(e) { "Detecting and blurring faces in the image($imageUrl) failed." } + return BlurResult( + originalImageUrl = imageUrl, + blurredImageUrl = imageUrl, + detectedPeopleCount = 0 + ) + } + } + + data class BlurResult( + val originalImageUrl: String, + val blurredImageUrl: String, + val detectedPeopleCount: Int, + ) { + fun isBlurred() = originalImageUrl != blurredImageUrl + } + + data class PlaceAccessibilityBlurResult( + val entranceResults: List, + ) + + data class BuildingAccessibilityBlurResult( + val entranceResults: List, + val elevatorResults: List, + ) +} diff --git a/app-server/subprojects/bounded_context/accessibility/application/src/main/kotlin/club/staircrusher/accessibility/application/port/in/AccessibilityImageProcessor.kt b/app-server/subprojects/bounded_context/accessibility/application/src/main/kotlin/club/staircrusher/accessibility/application/port/in/AccessibilityImageProcessor.kt new file mode 100644 index 000000000..09546f9d7 --- /dev/null +++ b/app-server/subprojects/bounded_context/accessibility/application/src/main/kotlin/club/staircrusher/accessibility/application/port/in/AccessibilityImageProcessor.kt @@ -0,0 +1,40 @@ +package club.staircrusher.accessibility.application.port.`in` + +import club.staircrusher.accessibility.application.port.`in`.image.ImageProcessor +import club.staircrusher.accessibility.domain.model.DetectedFacePosition +import club.staircrusher.stdlib.di.annotation.Component +import org.bytedeco.javacpp.BytePointer +import org.bytedeco.opencv.global.opencv_imgcodecs.IMREAD_COLOR +import org.bytedeco.opencv.global.opencv_imgcodecs.imdecode +import org.bytedeco.opencv.global.opencv_imgcodecs.imencode +import org.bytedeco.opencv.global.opencv_imgproc.GaussianBlur +import org.bytedeco.opencv.opencv_core.Mat +import org.bytedeco.opencv.opencv_core.Size + +@Component +class AccessibilityImageProcessor : ImageProcessor { + override fun blur(originalImage: ByteArray, imageExtension: String, positions: List): ByteArray { + BytePointer(*originalImage).use { imagePointer -> + val originalImageMat = imdecode(Mat(imagePointer), IMREAD_COLOR) + // Blur images + val blurredMat = originalImageMat.clone() + for (position in positions) { + val faceRegion = blurredMat.apply(position.toMatRect()) + GaussianBlur( + faceRegion, + faceRegion, + Size(0, 0), // sigmaX, sigmaY 에 의해서 결정 10.0 + 10.0 + ) + } + // Convert the result back to byte array + val outputPointer = BytePointer() + imencode(".$imageExtension", blurredMat, outputPointer) + return ByteArray(outputPointer.limit().toInt()).apply { outputPointer.get(this) } + } + } + + private fun DetectedFacePosition.toMatRect(): org.bytedeco.opencv.opencv_core.Rect { + return org.bytedeco.opencv.opencv_core.Rect(this.x, this.y, this.width, this.height) + } +} diff --git a/app-server/subprojects/bounded_context/accessibility/application/src/main/kotlin/club/staircrusher/accessibility/application/port/in/AccessibilityImageService.kt b/app-server/subprojects/bounded_context/accessibility/application/src/main/kotlin/club/staircrusher/accessibility/application/port/in/AccessibilityImageService.kt index ecf33af8c..016a16bb3 100644 --- a/app-server/subprojects/bounded_context/accessibility/application/src/main/kotlin/club/staircrusher/accessibility/application/port/in/AccessibilityImageService.kt +++ b/app-server/subprojects/bounded_context/accessibility/application/src/main/kotlin/club/staircrusher/accessibility/application/port/in/AccessibilityImageService.kt @@ -5,6 +5,8 @@ import club.staircrusher.accessibility.application.port.out.file_management.File import club.staircrusher.accessibility.application.port.out.persistence.BuildingAccessibilityRepository import club.staircrusher.accessibility.application.port.out.persistence.PlaceAccessibilityRepository import club.staircrusher.accessibility.domain.model.AccessibilityImage +import club.staircrusher.accessibility.domain.model.BuildingAccessibility +import club.staircrusher.accessibility.domain.model.PlaceAccessibility import club.staircrusher.place.application.port.`in`.PlaceApplicationService import club.staircrusher.stdlib.di.annotation.Component import club.staircrusher.stdlib.persistence.TransactionIsolationLevel @@ -30,23 +32,42 @@ class AccessibilityImageService( fun migrateImageUrlsToImagesIfNeeded(placeId: String) = transactionManager.doInTransaction(isolationLevel = TransactionIsolationLevel.REPEATABLE_READ) { val place = placeApplicationService.findPlace(placeId) ?: return@doInTransaction - val placeAccessibility = placeAccessibilityRepository.findByPlaceId(placeId) - val buildingAccessibility = buildingAccessibilityRepository.findByBuildingId(place.building.id) + doMigratePlaceAccessibilityImageUrlsToImagesIfNeeded(placeId = placeId) + doMigrateBuildingAccessibilityImageUrlsToImagesIfNeeded(buildingId = place.building.id) + } - if (placeAccessibility?.images?.isEmpty() == true && placeAccessibility.imageUrls.isNotEmpty()) { + fun doMigratePlaceAccessibilityImageUrlsToImagesIfNeeded( + placeId: String? = null, + placeAccessibilityId: String? = null + ): PlaceAccessibility? { + val placeAccessibility = + placeId?.let { placeAccessibilityRepository.findByPlaceId(it) } + ?: placeAccessibilityId?.let { placeAccessibilityRepository.findById(it) } + ?: return null + if (placeAccessibility.images.isEmpty() && placeAccessibility.imageUrls.isNotEmpty()) { val placeAccessibilityImages = placeAccessibility.imageUrls.map { AccessibilityImage(imageUrl = it, thumbnailUrl = null) } placeAccessibilityRepository.updateImages(placeAccessibility.id, placeAccessibilityImages) } + return placeId?.let { placeAccessibilityRepository.findByPlaceId(it) } ?: placeAccessibilityId?.let { placeAccessibilityRepository.findById(it) } + } - if (buildingAccessibility?.entranceImages?.isEmpty() == true && buildingAccessibility.entranceImageUrls.isNotEmpty()) { + fun doMigrateBuildingAccessibilityImageUrlsToImagesIfNeeded( + buildingId: String? = null, + buildingAccessibilityId: String? = null, + ): BuildingAccessibility? { + val buildingAccessibility = buildingId?.let { buildingAccessibilityRepository.findByBuildingId(it) } + ?: buildingAccessibilityId?.let { buildingAccessibilityRepository.findById(it) } ?: return null + if (buildingAccessibility.entranceImages.isEmpty() && buildingAccessibility.entranceImageUrls.isNotEmpty()) { val buildingEntranceImages = buildingAccessibility.entranceImageUrls.map { AccessibilityImage(imageUrl = it, thumbnailUrl = null) } buildingAccessibilityRepository.updateEntranceImages(buildingAccessibility.id, buildingEntranceImages) } - if (buildingAccessibility?.elevatorImages?.isEmpty() == true && buildingAccessibility.elevatorImageUrls.isNotEmpty()) { + if (buildingAccessibility.elevatorImages.isEmpty() && buildingAccessibility.elevatorImageUrls.isNotEmpty()) { val buildingElevatorImages = buildingAccessibility.elevatorImageUrls.map { AccessibilityImage(imageUrl = it, thumbnailUrl = null) } buildingAccessibilityRepository.updateElevatorImages(buildingAccessibility.id, buildingElevatorImages) } + + return buildingId?.let { buildingAccessibilityRepository.findByBuildingId(it) } ?: buildingAccessibilityId?.let { buildingAccessibilityRepository.findById(it) } } fun generateThumbnailsIfNeeded(placeId: String) { diff --git a/app-server/subprojects/bounded_context/accessibility/application/src/main/kotlin/club/staircrusher/accessibility/application/port/in/BlurFacesInLatestBuildingAccessibilityImagesUseCase.kt b/app-server/subprojects/bounded_context/accessibility/application/src/main/kotlin/club/staircrusher/accessibility/application/port/in/BlurFacesInLatestBuildingAccessibilityImagesUseCase.kt new file mode 100644 index 000000000..4162f4df1 --- /dev/null +++ b/app-server/subprojects/bounded_context/accessibility/application/src/main/kotlin/club/staircrusher/accessibility/application/port/in/BlurFacesInLatestBuildingAccessibilityImagesUseCase.kt @@ -0,0 +1,74 @@ +package club.staircrusher.accessibility.application.port.`in` + +import club.staircrusher.accessibility.application.port.out.persistence.AccessibilityImageFaceBlurringHistoryRepository +import club.staircrusher.accessibility.application.port.out.persistence.BuildingAccessibilityRepository +import club.staircrusher.accessibility.domain.model.AccessibilityImage +import club.staircrusher.accessibility.domain.model.AccessibilityImageFaceBlurringHistory +import club.staircrusher.accessibility.domain.model.BuildingAccessibility +import club.staircrusher.stdlib.clock.SccClock +import club.staircrusher.stdlib.di.annotation.Component +import club.staircrusher.stdlib.domain.entity.EntityIdGenerator +import club.staircrusher.stdlib.persistence.TransactionManager +import kotlinx.coroutines.runBlocking +import java.time.Instant +import java.util.concurrent.Executors + +@Component +class BlurFacesInLatestBuildingAccessibilityImagesUseCase( + private val accessibilityImageFaceBlurringService: AccessibilityImageFaceBlurringService, + private val buildingAccessibilityRepository: BuildingAccessibilityRepository, + private val accessibilityImageFaceBlurringHistoryRepository: AccessibilityImageFaceBlurringHistoryRepository, + private val transactionManager: TransactionManager, +) { + private val taskExecutor = Executors.newCachedThreadPool() + + fun handleAsync() { + taskExecutor.execute { + handle() + } + } + + fun handle() { + val targetAccessibility: BuildingAccessibility = transactionManager.doInTransaction { + val latestHistory = accessibilityImageFaceBlurringHistoryRepository.findLatestBuildingHistoryOrNull() + val lastBlurredBuildingAccessibility = latestHistory?.let { history -> + history.buildingAccessibilityId?.let { buildingAccessibilityRepository.findByIdOrNull(it) } + } + buildingAccessibilityRepository.findOneOrNullByCreatedAtGreaterThanAndOrderByCreatedAtAsc( + createdAt = lastBlurredBuildingAccessibility?.createdAt ?: Instant.EPOCH + ) + } ?: return + val result = runBlocking { accessibilityImageFaceBlurringService.blurFacesInBuildingAccessibility(targetAccessibility.id) } ?: return + transactionManager.doInTransaction { + val entranceResults = result.entranceResults + val elevatorResults = result.elevatorResults + + val entranceImageUrls = entranceResults.map { it.blurredImageUrl } + buildingAccessibilityRepository.updateEntranceImageUrlsAndImages( + targetAccessibility.id, + entranceImageUrls, + entranceImageUrls.map { AccessibilityImage(imageUrl = it, thumbnailUrl = null) } + ) + val elevatorImageUrls = elevatorResults.map { it.blurredImageUrl } + buildingAccessibilityRepository.updateElevatorImageUrlsAndImages( + targetAccessibility.id, + elevatorImageUrls, + elevatorImageUrls.map { AccessibilityImage(imageUrl = it, thumbnailUrl = null) } + ) + + val originalImageUrls = (entranceResults + elevatorResults).map { it.originalImageUrl } + val blurredImageUrls = (entranceResults + elevatorResults).filter { it.isBlurred() }.map { it.blurredImageUrl } + val detectedPeopleCounts = (entranceResults + elevatorResults).map { it.detectedPeopleCount } + accessibilityImageFaceBlurringHistoryRepository.save( + AccessibilityImageFaceBlurringHistory( + id = EntityIdGenerator.generateRandom(), + placeAccessibilityId = null, buildingAccessibilityId = targetAccessibility.id, + originalImageUrls = originalImageUrls, + blurredImageUrls = blurredImageUrls, + detectedPeopleCounts = detectedPeopleCounts, + createdAt = SccClock.instant(), updatedAt = SccClock.instant() + ) + ) + } + } +} diff --git a/app-server/subprojects/bounded_context/accessibility/application/src/main/kotlin/club/staircrusher/accessibility/application/port/in/BlurFacesInLatestPlaceAccessibilityImagesUseCase.kt b/app-server/subprojects/bounded_context/accessibility/application/src/main/kotlin/club/staircrusher/accessibility/application/port/in/BlurFacesInLatestPlaceAccessibilityImagesUseCase.kt new file mode 100644 index 000000000..06df2907a --- /dev/null +++ b/app-server/subprojects/bounded_context/accessibility/application/src/main/kotlin/club/staircrusher/accessibility/application/port/in/BlurFacesInLatestPlaceAccessibilityImagesUseCase.kt @@ -0,0 +1,66 @@ +package club.staircrusher.accessibility.application.port.`in` + +import club.staircrusher.accessibility.application.port.out.persistence.AccessibilityImageFaceBlurringHistoryRepository +import club.staircrusher.accessibility.application.port.out.persistence.PlaceAccessibilityRepository +import club.staircrusher.accessibility.domain.model.AccessibilityImage +import club.staircrusher.accessibility.domain.model.AccessibilityImageFaceBlurringHistory +import club.staircrusher.accessibility.domain.model.PlaceAccessibility +import club.staircrusher.stdlib.clock.SccClock +import club.staircrusher.stdlib.di.annotation.Component +import club.staircrusher.stdlib.domain.entity.EntityIdGenerator +import club.staircrusher.stdlib.persistence.TransactionManager +import kotlinx.coroutines.runBlocking +import java.time.Instant +import java.util.concurrent.Executors + +@Component +class BlurFacesInLatestPlaceAccessibilityImagesUseCase( + private val accessibilityImageFaceBlurringService: AccessibilityImageFaceBlurringService, + private val accessibilityImageFaceBlurringHistoryRepository: AccessibilityImageFaceBlurringHistoryRepository, + private val placeAccessibilityRepository: PlaceAccessibilityRepository, + private val transactionManager: TransactionManager, +) { + private val taskExecutor = Executors.newCachedThreadPool() + + fun handleAsync() { + taskExecutor.execute { + handle() + } + } + + fun handle() { + val targetAccessibility: PlaceAccessibility = transactionManager.doInTransaction { + val latestHistory = accessibilityImageFaceBlurringHistoryRepository.findLatestPlaceHistoryOrNull() + val lastBlurredPlaceAccessibility = latestHistory?.let { history -> + history.placeAccessibilityId?.let { placeAccessibilityRepository.findByIdOrNull(it) } + } + placeAccessibilityRepository.findOneOrNullByCreatedAtGreaterThanAndOrderByCreatedAtAsc( + createdAt = lastBlurredPlaceAccessibility?.createdAt ?: Instant.EPOCH + ) + } ?: return + val result = + runBlocking { accessibilityImageFaceBlurringService.blurFacesInPlaceAccessibility(targetAccessibility.id) } + ?: return + val entranceResults = result.entranceResults + transactionManager.doInTransaction { + val imageUrls = entranceResults.map { it.blurredImageUrl } + placeAccessibilityRepository.updateImageUrlsAndImages( + targetAccessibility.id, + imageUrls, + imageUrls.map { AccessibilityImage(imageUrl = it, thumbnailUrl = null) } + ) + accessibilityImageFaceBlurringHistoryRepository.save( + AccessibilityImageFaceBlurringHistory( + id = EntityIdGenerator.generateRandom(), + placeAccessibilityId = targetAccessibility.id, + buildingAccessibilityId = null, + originalImageUrls = entranceResults.map { it.originalImageUrl }, + blurredImageUrls = entranceResults.filter { it.isBlurred() }.map { it.blurredImageUrl }, + detectedPeopleCounts = entranceResults.map { it.detectedPeopleCount }, + createdAt = SccClock.instant(), + updatedAt = SccClock.instant() + ) + ) + } + } +} diff --git a/app-server/subprojects/bounded_context/accessibility/application/src/main/kotlin/club/staircrusher/accessibility/application/port/in/image/ImageProcessor.kt b/app-server/subprojects/bounded_context/accessibility/application/src/main/kotlin/club/staircrusher/accessibility/application/port/in/image/ImageProcessor.kt new file mode 100644 index 000000000..579f68436 --- /dev/null +++ b/app-server/subprojects/bounded_context/accessibility/application/src/main/kotlin/club/staircrusher/accessibility/application/port/in/image/ImageProcessor.kt @@ -0,0 +1,7 @@ +package club.staircrusher.accessibility.application.port.`in`.image + +import club.staircrusher.accessibility.domain.model.DetectedFacePosition + +interface ImageProcessor { + fun blur(originalImage: ByteArray, imageExtension: String, positions: List): ByteArray +} diff --git a/app-server/subprojects/bounded_context/accessibility/application/src/main/kotlin/club/staircrusher/accessibility/application/port/out/DetectFacesService.kt b/app-server/subprojects/bounded_context/accessibility/application/src/main/kotlin/club/staircrusher/accessibility/application/port/out/DetectFacesService.kt new file mode 100644 index 000000000..3dc8945f1 --- /dev/null +++ b/app-server/subprojects/bounded_context/accessibility/application/src/main/kotlin/club/staircrusher/accessibility/application/port/out/DetectFacesService.kt @@ -0,0 +1,35 @@ +package club.staircrusher.accessibility.application.port.out + +import club.staircrusher.accessibility.domain.model.DetectedFacePosition +import club.staircrusher.stdlib.Size + +interface DetectFacesService { + suspend fun detect(imageUrl: String): DetectFacesResponse + suspend fun detect(imageBytes: ByteArray): DetectFacesResponse +} + +data class DetectFacesResponse( + val imageBytes: ByteArray, + val imageSize: Size, + val positions: List, +) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as DetectFacesResponse + + if (!imageBytes.contentEquals(other.imageBytes)) return false + if (imageSize != other.imageSize) return false + if (positions != other.positions) return false + + return true + } + + override fun hashCode(): Int { + var result = imageBytes.contentHashCode() + result = 31 * result + imageSize.hashCode() + result = 31 * result + positions.hashCode() + return result + } +} diff --git a/app-server/subprojects/bounded_context/accessibility/application/src/main/kotlin/club/staircrusher/accessibility/application/port/out/file_management/FileManagementService.kt b/app-server/subprojects/bounded_context/accessibility/application/src/main/kotlin/club/staircrusher/accessibility/application/port/out/file_management/FileManagementService.kt index 2cf4194d7..fa4b4d23f 100644 --- a/app-server/subprojects/bounded_context/accessibility/application/src/main/kotlin/club/staircrusher/accessibility/application/port/out/file_management/FileManagementService.kt +++ b/app-server/subprojects/bounded_context/accessibility/application/src/main/kotlin/club/staircrusher/accessibility/application/port/out/file_management/FileManagementService.kt @@ -5,7 +5,8 @@ import java.io.File import java.nio.file.Path interface FileManagementService { - fun getFileUploadUrl(filenameExtension: String): UploadUrl + fun getFileUploadUrl(fileExtension: String): UploadUrl fun downloadFile(url: String, destination: Path): File + suspend fun uploadImage(fileName: String, fileBytes: ByteArray): String? suspend fun uploadThumbnailImage(fileName: String, outputStream: ByteArrayOutputStream): String? } diff --git a/app-server/subprojects/bounded_context/accessibility/application/src/main/kotlin/club/staircrusher/accessibility/application/port/out/persistence/AccessibilityImageFaceBlurringHistoryRepository.kt b/app-server/subprojects/bounded_context/accessibility/application/src/main/kotlin/club/staircrusher/accessibility/application/port/out/persistence/AccessibilityImageFaceBlurringHistoryRepository.kt new file mode 100644 index 000000000..bc6d30da3 --- /dev/null +++ b/app-server/subprojects/bounded_context/accessibility/application/src/main/kotlin/club/staircrusher/accessibility/application/port/out/persistence/AccessibilityImageFaceBlurringHistoryRepository.kt @@ -0,0 +1,13 @@ +package club.staircrusher.accessibility.application.port.out.persistence + +import club.staircrusher.accessibility.domain.model.AccessibilityImageFaceBlurringHistory +import club.staircrusher.stdlib.domain.repository.EntityRepository + +interface AccessibilityImageFaceBlurringHistoryRepository : + EntityRepository { + fun findLatestPlaceHistoryOrNull(): AccessibilityImageFaceBlurringHistory? + fun findLatestBuildingHistoryOrNull(): AccessibilityImageFaceBlurringHistory? + fun findByPlaceAccessibilityId(placeAccessibilityId: String): List + fun findByBuildingAccessibilityId(buildingAccessibilityId: String): List + fun findAll(): List +} diff --git a/app-server/subprojects/bounded_context/accessibility/application/src/main/kotlin/club/staircrusher/accessibility/application/port/out/persistence/BuildingAccessibilityRepository.kt b/app-server/subprojects/bounded_context/accessibility/application/src/main/kotlin/club/staircrusher/accessibility/application/port/out/persistence/BuildingAccessibilityRepository.kt index 69430d157..733a76f53 100644 --- a/app-server/subprojects/bounded_context/accessibility/application/src/main/kotlin/club/staircrusher/accessibility/application/port/out/persistence/BuildingAccessibilityRepository.kt +++ b/app-server/subprojects/bounded_context/accessibility/application/src/main/kotlin/club/staircrusher/accessibility/application/port/out/persistence/BuildingAccessibilityRepository.kt @@ -15,9 +15,13 @@ interface BuildingAccessibilityRepository : EntityRepository): List fun findByUserIdAndCreatedAtBetween(userId: String, from: Instant, to: Instant): List fun findByEupMyeonDong(eupMyeonDong: EupMyeonDong): List + fun findOneOrNullByCreatedAtGreaterThanAndOrderByCreatedAtAsc(createdAt: Instant): BuildingAccessibility? + fun findAll(): List fun countByUserIdCreatedAtBetween(userId: String, from: Instant, to: Instant): Int fun updateEntranceImages(id: String, entranceImages: List) + fun updateEntranceImageUrlsAndImages(id: String, urls: List, images: List) fun updateElevatorImages(id: String, elevatorImages: List) + fun updateElevatorImageUrlsAndImages(id: String, urls: List, images: List) fun countByUserId(userId: String): Int fun remove(id: String) diff --git a/app-server/subprojects/bounded_context/accessibility/application/src/main/kotlin/club/staircrusher/accessibility/application/port/out/persistence/PlaceAccessibilityRepository.kt b/app-server/subprojects/bounded_context/accessibility/application/src/main/kotlin/club/staircrusher/accessibility/application/port/out/persistence/PlaceAccessibilityRepository.kt index 836174375..4c5ad49a4 100644 --- a/app-server/subprojects/bounded_context/accessibility/application/src/main/kotlin/club/staircrusher/accessibility/application/port/out/persistence/PlaceAccessibilityRepository.kt +++ b/app-server/subprojects/bounded_context/accessibility/application/src/main/kotlin/club/staircrusher/accessibility/application/port/out/persistence/PlaceAccessibilityRepository.kt @@ -15,11 +15,12 @@ interface PlaceAccessibilityRepository : EntityRepository fun findByUserIdAndCreatedAtBetween(userId: String, from: Instant, to: Instant): List + fun findByBuildingId(buildingId: String): List + fun findOneOrNullByCreatedAtGreaterThanAndOrderByCreatedAtAsc(createdAt: Instant): PlaceAccessibility? fun countByEupMyeonDong(eupMyeonDong: EupMyeonDong): Int fun countByUserId(userId: String): Int fun countByUserIdAndCreatedAtBetween(userId: String, from: Instant, to: Instant): Int fun hasAccessibilityNotRegisteredPlaceInBuilding(buildingId: String): Boolean - fun findByBuildingId(buildingId: String): List fun searchForAdmin( placeName: String?, createdAtFrom: Instant?, @@ -30,6 +31,7 @@ interface PlaceAccessibilityRepository : EntityRepository fun updateImages(id: String, images: List) + fun updateImageUrlsAndImages(id: String, imageUrls: List, images: List) fun countAll(): Int fun remove(id: String) diff --git a/app-server/subprojects/bounded_context/accessibility/domain/src/main/kotlin/club/staircrusher/accessibility/domain/model/AccessibilityImageFaceBlurringHistory.kt b/app-server/subprojects/bounded_context/accessibility/domain/src/main/kotlin/club/staircrusher/accessibility/domain/model/AccessibilityImageFaceBlurringHistory.kt new file mode 100644 index 000000000..18dfaed4c --- /dev/null +++ b/app-server/subprojects/bounded_context/accessibility/domain/src/main/kotlin/club/staircrusher/accessibility/domain/model/AccessibilityImageFaceBlurringHistory.kt @@ -0,0 +1,14 @@ +package club.staircrusher.accessibility.domain.model + +import java.time.Instant + +data class AccessibilityImageFaceBlurringHistory( + val id: String, + val placeAccessibilityId: String?, + val buildingAccessibilityId: String?, + val originalImageUrls: List, + val blurredImageUrls: List, + val detectedPeopleCounts: List, + val createdAt: Instant, + val updatedAt: Instant +) diff --git a/app-server/subprojects/bounded_context/accessibility/domain/src/main/kotlin/club/staircrusher/accessibility/domain/model/DetectedFacePosition.kt b/app-server/subprojects/bounded_context/accessibility/domain/src/main/kotlin/club/staircrusher/accessibility/domain/model/DetectedFacePosition.kt new file mode 100644 index 000000000..636d23539 --- /dev/null +++ b/app-server/subprojects/bounded_context/accessibility/domain/src/main/kotlin/club/staircrusher/accessibility/domain/model/DetectedFacePosition.kt @@ -0,0 +1,8 @@ +package club.staircrusher.accessibility.domain.model + +data class DetectedFacePosition( + val x: Int, + val y: Int, + val width: Int, + val height: Int, +) diff --git a/app-server/subprojects/bounded_context/accessibility/infra/build.gradle.kts b/app-server/subprojects/bounded_context/accessibility/infra/build.gradle.kts index d7e8f2f0e..0fa2b84d5 100644 --- a/app-server/subprojects/bounded_context/accessibility/infra/build.gradle.kts +++ b/app-server/subprojects/bounded_context/accessibility/infra/build.gradle.kts @@ -16,6 +16,7 @@ dependencies { val awsSdkVersion: String by project implementation("software.amazon.awssdk:s3:$awsSdkVersion") + implementation("software.amazon.awssdk:rekognition:$awsSdkVersion") runtimeOnly("software.amazon.awssdk:sts:$awsSdkVersion") // IRSA를 사용하기 위해서 필요함 testImplementation(projects.apiSpecification.domainEvent) testImplementation("org.mockito.kotlin:mockito-kotlin:5.1.0") diff --git a/app-server/subprojects/bounded_context/accessibility/infra/src/main/kotlin/club/staircrusher/accessibility/infra/adapter/in/controller/AccessibilityImagePostProcessController.kt b/app-server/subprojects/bounded_context/accessibility/infra/src/main/kotlin/club/staircrusher/accessibility/infra/adapter/in/controller/AccessibilityImagePostProcessController.kt new file mode 100644 index 000000000..f165f9b52 --- /dev/null +++ b/app-server/subprojects/bounded_context/accessibility/infra/src/main/kotlin/club/staircrusher/accessibility/infra/adapter/in/controller/AccessibilityImagePostProcessController.kt @@ -0,0 +1,24 @@ +package club.staircrusher.accessibility.infra.adapter.`in`.controller + +import club.staircrusher.accessibility.application.port.`in`.BlurFacesInLatestBuildingAccessibilityImagesUseCase +import club.staircrusher.accessibility.application.port.`in`.BlurFacesInLatestPlaceAccessibilityImagesUseCase +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RestController + +@RestController +class AccessibilityImagePostProcessController( + private val blurFacesInLatestPlaceAccessibilityImagesUseCase: BlurFacesInLatestPlaceAccessibilityImagesUseCase, + private val blurFacesInLatestBuildingAccessibilityImagesUseCase: BlurFacesInLatestBuildingAccessibilityImagesUseCase +) { + @PostMapping("/blurFacesInLatestPlaceAccessibilityImages") + fun blurFacesInLatestPlaceAccessibilityImages() { + // TODO: UpdateChallengeRank 처럼 IP 체크 + blurFacesInLatestPlaceAccessibilityImagesUseCase.handleAsync() + } + + @PostMapping("/blurFacesInLatestBuildingAccessibilityImages") + fun blurFacesInLatestBuildingAccessibilityImages() { + // TODO: UpdateChallengeRank 처럼 IP 체크 + blurFacesInLatestBuildingAccessibilityImagesUseCase.handleAsync() + } +} diff --git a/app-server/subprojects/bounded_context/accessibility/infra/src/main/kotlin/club/staircrusher/accessibility/infra/adapter/in/model/BlurFacesInBuildingAccessibilityImagesParams.kt b/app-server/subprojects/bounded_context/accessibility/infra/src/main/kotlin/club/staircrusher/accessibility/infra/adapter/in/model/BlurFacesInBuildingAccessibilityImagesParams.kt new file mode 100644 index 000000000..d31597b9e --- /dev/null +++ b/app-server/subprojects/bounded_context/accessibility/infra/src/main/kotlin/club/staircrusher/accessibility/infra/adapter/in/model/BlurFacesInBuildingAccessibilityImagesParams.kt @@ -0,0 +1,8 @@ +package club.staircrusher.accessibility.infra.adapter.`in`.model + +import com.fasterxml.jackson.annotation.JsonProperty + +data class BlurFacesInBuildingAccessibilityImagesParams( + @field:JsonProperty("placeAccessibilityId") + val buildingAccessibilityId: String +) diff --git a/app-server/subprojects/bounded_context/accessibility/infra/src/main/kotlin/club/staircrusher/accessibility/infra/adapter/in/model/BlurFacesInPlaceAccessibilityImagesParams.kt b/app-server/subprojects/bounded_context/accessibility/infra/src/main/kotlin/club/staircrusher/accessibility/infra/adapter/in/model/BlurFacesInPlaceAccessibilityImagesParams.kt new file mode 100644 index 000000000..72c3c3cfd --- /dev/null +++ b/app-server/subprojects/bounded_context/accessibility/infra/src/main/kotlin/club/staircrusher/accessibility/infra/adapter/in/model/BlurFacesInPlaceAccessibilityImagesParams.kt @@ -0,0 +1,8 @@ +package club.staircrusher.accessibility.infra.adapter.`in`.model + +import com.fasterxml.jackson.annotation.JsonProperty + +data class BlurFacesInPlaceAccessibilityImagesParams( + @field:JsonProperty("placeAccessibilityId") + val placeAccessibilityId: String +) diff --git a/app-server/subprojects/bounded_context/accessibility/infra/src/main/kotlin/club/staircrusher/accessibility/infra/adapter/out/AwsRekognitionService.kt b/app-server/subprojects/bounded_context/accessibility/infra/src/main/kotlin/club/staircrusher/accessibility/infra/adapter/out/AwsRekognitionService.kt new file mode 100644 index 000000000..6df9aa2d7 --- /dev/null +++ b/app-server/subprojects/bounded_context/accessibility/infra/src/main/kotlin/club/staircrusher/accessibility/infra/adapter/out/AwsRekognitionService.kt @@ -0,0 +1,85 @@ +package club.staircrusher.accessibility.infra.adapter.out + +import club.staircrusher.accessibility.application.port.out.DetectFacesResponse +import club.staircrusher.accessibility.application.port.out.DetectFacesService +import club.staircrusher.accessibility.domain.model.DetectedFacePosition +import club.staircrusher.stdlib.Size +import club.staircrusher.stdlib.di.annotation.Component +import kotlinx.coroutines.future.await +import software.amazon.awssdk.core.SdkBytes +import software.amazon.awssdk.regions.Region +import software.amazon.awssdk.services.rekognition.RekognitionAsyncClient +import software.amazon.awssdk.services.rekognition.model.Attribute +import software.amazon.awssdk.services.rekognition.model.BoundingBox +import software.amazon.awssdk.services.rekognition.model.DetectFacesRequest +import software.amazon.awssdk.services.rekognition.model.Image +import java.awt.image.BufferedImage +import java.io.ByteArrayInputStream +import java.net.URL +import javax.imageio.ImageIO + +@Component +internal class AwsRekognitionService( + private val properties: RekognitionProperties, +) : DetectFacesService { + private val rekognitionClient = RekognitionAsyncClient.builder() + .region(Region.AP_NORTHEAST_2) + .apply { + properties.getAwsCredentials()?.let { credentialsProvider { it } } + } + .build() + + override suspend fun detect(imageUrl: String): DetectFacesResponse { + val imageBytes = downloadImage(imageUrl) + return detect(imageBytes) + } + + override suspend fun detect(imageBytes: ByteArray): DetectFacesResponse { + val imageSize = getImageSize(imageBytes) + val detected = detectFacesFromBytes(imageBytes) + return DetectFacesResponse( + imageBytes = imageBytes, + imageSize = imageSize, + positions = detected.faceDetails().map { + calculateFacePosition(getImageSize(imageBytes), it.boundingBox()) + }, + ) + } + + private suspend fun detectFacesFromBytes(imageBytes: ByteArray): software.amazon.awssdk.services.rekognition.model.DetectFacesResponse { + val image = Image.builder().bytes(SdkBytes.fromByteArray(imageBytes)).build() + + val request = DetectFacesRequest.builder() + .image(image) + .attributes(Attribute.ALL) + .build() + + return rekognitionClient.detectFaces(request).await() + } + + private fun calculateFacePosition(imageSize: Size, boundingBox: BoundingBox): DetectedFacePosition { + val startX = (boundingBox.left() * imageSize.width).toInt() + val startY = (boundingBox.top() * imageSize.height).toInt() + val endX = (startX + boundingBox.width() * imageSize.width).toInt() + val endY = (startY + boundingBox.height() * imageSize.height).toInt() + return DetectedFacePosition( + x = startX, + y = startY, + width = endX - startX, + height = endY - startY, + ) + } + + private fun getImageSize(imageBytes: ByteArray): Size { + ByteArrayInputStream(imageBytes).use { + val image: BufferedImage = ImageIO.read(it) + return Size(width = image.width, height = image.height) + } + } + + private fun downloadImage(imageUrl: String): ByteArray { + URL(imageUrl).openStream().use { inputStream -> + return inputStream.readBytes() + } + } +} diff --git a/app-server/subprojects/bounded_context/accessibility/infra/src/main/kotlin/club/staircrusher/accessibility/infra/adapter/out/RekognitionProperties.kt b/app-server/subprojects/bounded_context/accessibility/infra/src/main/kotlin/club/staircrusher/accessibility/infra/adapter/out/RekognitionProperties.kt new file mode 100644 index 000000000..39303fd65 --- /dev/null +++ b/app-server/subprojects/bounded_context/accessibility/infra/src/main/kotlin/club/staircrusher/accessibility/infra/adapter/out/RekognitionProperties.kt @@ -0,0 +1,20 @@ +package club.staircrusher.accessibility.infra.adapter.out + +import org.springframework.boot.context.properties.ConfigurationProperties +import org.springframework.boot.context.properties.ConstructorBinding +import software.amazon.awssdk.auth.credentials.AwsBasicCredentials +import software.amazon.awssdk.auth.credentials.AwsCredentials + +@ConfigurationProperties("scc.rekognition") +internal data class RekognitionProperties @ConstructorBinding constructor( + val accessKey: String?, + val secretKey: String?, +) { + fun getAwsCredentials(): AwsCredentials? { + return if (accessKey != null && secretKey != null) { + AwsBasicCredentials.create(accessKey, secretKey) + } else { + null + } + } +} diff --git a/app-server/subprojects/bounded_context/accessibility/infra/src/main/kotlin/club/staircrusher/accessibility/infra/adapter/out/file_management/S3FileManagementService.kt b/app-server/subprojects/bounded_context/accessibility/infra/src/main/kotlin/club/staircrusher/accessibility/infra/adapter/out/file_management/S3FileManagementService.kt index 57bd749b0..62806b0d4 100644 --- a/app-server/subprojects/bounded_context/accessibility/infra/src/main/kotlin/club/staircrusher/accessibility/infra/adapter/out/file_management/S3FileManagementService.kt +++ b/app-server/subprojects/bounded_context/accessibility/infra/src/main/kotlin/club/staircrusher/accessibility/infra/adapter/out/file_management/S3FileManagementService.kt @@ -36,7 +36,6 @@ internal class S3FileManagementService( region(Region.AP_NORTHEAST_2) } .build() - private val s3Client = S3AsyncClient.builder() .apply { properties.getAwsCredentials()?.let { credentialsProvider { it } } @@ -45,20 +44,18 @@ internal class S3FileManagementService( .build() // TODO: 유저별 rate limit 걸기. 위치는 여기가 아니라 application service여야 할 수도 있을 듯. - override fun getFileUploadUrl(filenameExtension: String): UploadUrl { - val normalizedFilenameExtension = getNormalizedFileExtension(filenameExtension) + override fun getFileUploadUrl(fileExtension: String): UploadUrl { + val normalizedFilenameExtension = getNormalizedFileExtension(fileExtension) val objectRequest = PutObjectRequest.builder() .bucket(properties.bucketName) .key(generateObjectKey(normalizedFilenameExtension)) .contentType(Files.probeContentType(Path.of("dummy.${normalizedFilenameExtension}"))) .acl(ObjectCannedACL.PUBLIC_READ) .build() - val s3PresignRequest: PutObjectPresignRequest = PutObjectPresignRequest.builder() .signatureDuration(presignedUrlExpiryDuration) .putObjectRequest(objectRequest) .build() - val presignedRequest = s3Presigner.presignPutObject(s3PresignRequest) return UploadUrl( url = presignedRequest.url().toString(), @@ -75,28 +72,40 @@ internal class S3FileManagementService( return file } - override suspend fun uploadThumbnailImage(fileName: String, outputStream: ByteArrayOutputStream): String? { - val objectRequest = PutObjectRequest.builder() - .bucket(properties.thumbnailBucketName) - .key(fileName) - .acl(ObjectCannedACL.PUBLIC_READ) - .build() + override suspend fun uploadImage(fileName: String, fileBytes: ByteArray): String? { + try { + return upload(properties.bucketName, fileName, fileBytes) + } catch (t: Throwable) { + logger.error(t) { "Failed to upload image: $fileName" } + return null + } + } + override suspend fun uploadThumbnailImage(fileName: String, outputStream: ByteArrayOutputStream): String? { try { - s3Client.putObject(objectRequest, AsyncRequestBody.fromBytes(outputStream.toByteArray())).await() - return s3Client - .utilities() - .getUrl { - it.bucket(properties.thumbnailBucketName) - it.key(fileName) - } - .toString() + return upload(properties.thumbnailBucketName, fileName, outputStream.toByteArray()) } catch (t: Throwable) { - logger.error(t) { "Failed to upload thumbnail image" } + logger.error(t) { "Failed to upload thumbnail image: $fileName" } return null } } + private suspend fun upload(bucketName: String, fileName: String, fileBytes: ByteArray): String { + val objectRequest = PutObjectRequest.builder() + .bucket(bucketName) + .key(fileName) + .acl(ObjectCannedACL.PUBLIC_READ) + .build() + s3Client.putObject(objectRequest, AsyncRequestBody.fromBytes(fileBytes)).await() + return s3Client + .utilities() + .getUrl { + it.bucket(bucketName) + it.key(fileName) + } + .toString() + } + private val objectKeyTimestampPrefixFormat = DateTimeFormatter.ofPattern("yyyyMMddHHmmss") private fun generateObjectKey(extension: String?): String { return buildString { diff --git a/app-server/subprojects/bounded_context/accessibility/infra/src/main/kotlin/club/staircrusher/accessibility/infra/adapter/out/persistence/AccessibilityImageFaceBlurringHistoryRepository.kt b/app-server/subprojects/bounded_context/accessibility/infra/src/main/kotlin/club/staircrusher/accessibility/infra/adapter/out/persistence/AccessibilityImageFaceBlurringHistoryRepository.kt new file mode 100644 index 000000000..643f8337a --- /dev/null +++ b/app-server/subprojects/bounded_context/accessibility/infra/src/main/kotlin/club/staircrusher/accessibility/infra/adapter/out/persistence/AccessibilityImageFaceBlurringHistoryRepository.kt @@ -0,0 +1,67 @@ +package club.staircrusher.accessibility.infra.adapter.out.persistence + +import club.staircrusher.accessibility.application.port.out.persistence.AccessibilityImageFaceBlurringHistoryRepository +import club.staircrusher.accessibility.domain.model.AccessibilityImageFaceBlurringHistory +import club.staircrusher.accessibility.infra.adapter.out.persistence.sqldelight.toDomainModel +import club.staircrusher.accessibility.infra.adapter.out.persistence.sqldelight.toPersistenceModel +import club.staircrusher.infra.persistence.sqldelight.DB +import club.staircrusher.stdlib.clock.SccClock +import club.staircrusher.stdlib.di.annotation.Component +import club.staircrusher.stdlib.time.toOffsetDateTime + +@Suppress("TooManyFunctions") +@Component +class AccessibilityImageFaceBlurringHistoryRepository( + db: DB, +) : AccessibilityImageFaceBlurringHistoryRepository { + private val queries = db.accessibilityImageFaceBlurringHistoryQueries + + override fun findLatestPlaceHistoryOrNull(): AccessibilityImageFaceBlurringHistory? { + return queries.findLatestPlaceHistory().executeAsOneOrNull()?.toDomainModel() + } + + override fun findLatestBuildingHistoryOrNull(): AccessibilityImageFaceBlurringHistory? { + return queries.findLatestBuildingHistory().executeAsOneOrNull()?.toDomainModel() + } + + override fun findByPlaceAccessibilityId(placeAccessibilityId: String): List { + return queries.findByPlaceAccessibility(placeAccessibilityId) + .executeAsList() + .map { it.toDomainModel() } + } + + override fun findByBuildingAccessibilityId(buildingAccessibilityId: String): List { + return queries.findByBuildingAccessilbility(buildingAccessibilityId) + .executeAsList() + .map { it.toDomainModel() } + } + + override fun save(entity: AccessibilityImageFaceBlurringHistory): AccessibilityImageFaceBlurringHistory { + queries.save( + entity.toPersistenceModel() + .copy(updated_at = SccClock.instant().toOffsetDateTime()) + ) + return entity + } + + override fun saveAll(entities: Collection) { + entities.forEach(::save) + } + + override fun removeAll() { + queries.removeAll() + } + + override fun findById(id: String): AccessibilityImageFaceBlurringHistory { + return findByIdOrNull(id) + ?: throw IllegalArgumentException("AccessibilityImagesBlurringHistory of id $id does not exist.") + } + + override fun findByIdOrNull(id: String): AccessibilityImageFaceBlurringHistory? { + return queries.findById(id = id).executeAsOneOrNull()?.toDomainModel() + } + + override fun findAll(): List { + return queries.findAll().executeAsList().map { it.toDomainModel() } + } +} diff --git a/app-server/subprojects/bounded_context/accessibility/infra/src/main/kotlin/club/staircrusher/accessibility/infra/adapter/out/persistence/BuildingAccessibilityRepository.kt b/app-server/subprojects/bounded_context/accessibility/infra/src/main/kotlin/club/staircrusher/accessibility/infra/adapter/out/persistence/BuildingAccessibilityRepository.kt index 1733da530..22effc0e3 100644 --- a/app-server/subprojects/bounded_context/accessibility/infra/src/main/kotlin/club/staircrusher/accessibility/infra/adapter/out/persistence/BuildingAccessibilityRepository.kt +++ b/app-server/subprojects/bounded_context/accessibility/infra/src/main/kotlin/club/staircrusher/accessibility/infra/adapter/out/persistence/BuildingAccessibilityRepository.kt @@ -77,6 +77,18 @@ class BuildingAccessibilityRepository( .map { it.toDomainModel() } } + override fun findOneOrNullByCreatedAtGreaterThanAndOrderByCreatedAtAsc(createdAt: Instant): BuildingAccessibility? { + return queries.findByCreatedAtGreaterThanAndOrderByCreatedAtAsc( + createdAt = createdAt.toOffsetDateTime(), limit = 1 + ) + .executeAsOneOrNull() + ?.toDomainModel() + } + + override fun findAll(): List { + return queries.findAll().executeAsList().map { it.toDomainModel() } + } + override fun updateEntranceImages(id: String, entranceImages: List) { return queries.updateEntranceImages( entranceImages = entranceImages, @@ -84,6 +96,14 @@ class BuildingAccessibilityRepository( ) } + override fun updateEntranceImageUrlsAndImages(id: String, urls: List, images: List) { + return queries.updateEntranceImageUrlsAndImages( + id = id, + entranceImageUrls = urls, + entranceImages = images, + ) + } + override fun updateElevatorImages(id: String, elevatorImages: List) { return queries.updateElevatorImages( elevatorImages = elevatorImages, @@ -91,6 +111,14 @@ class BuildingAccessibilityRepository( ) } + override fun updateElevatorImageUrlsAndImages(id: String, urls: List, images: List) { + return queries.updateElevatorImageUrlsAndImages( + id = id, + elevatorImageUrls = urls, + elevatorImages = images, + ) + } + override fun countByUserId(userId: String): Int { return queries.countByUserId(userId = userId).executeAsOne().toInt() } diff --git a/app-server/subprojects/bounded_context/accessibility/infra/src/main/kotlin/club/staircrusher/accessibility/infra/adapter/out/persistence/PlaceAccessibilityRepository.kt b/app-server/subprojects/bounded_context/accessibility/infra/src/main/kotlin/club/staircrusher/accessibility/infra/adapter/out/persistence/PlaceAccessibilityRepository.kt index a07950c1c..280909fb2 100644 --- a/app-server/subprojects/bounded_context/accessibility/infra/src/main/kotlin/club/staircrusher/accessibility/infra/adapter/out/persistence/PlaceAccessibilityRepository.kt +++ b/app-server/subprojects/bounded_context/accessibility/infra/src/main/kotlin/club/staircrusher/accessibility/infra/adapter/out/persistence/PlaceAccessibilityRepository.kt @@ -97,6 +97,14 @@ class PlaceAccessibilityRepository( .map { it.toDomainModel() } } + override fun findOneOrNullByCreatedAtGreaterThanAndOrderByCreatedAtAsc(createdAt: Instant): PlaceAccessibility? { + return queries.findByCreatedAtGreaterThanAndOrderByCreatedAtAsc( + createdAt = createdAt.toOffsetDateTime(), limit = 1 + ) + .executeAsOneOrNull() + ?.toDomainModel() + } + override fun searchForAdmin( placeName: String?, createdAtFrom: Instant?, @@ -121,6 +129,10 @@ class PlaceAccessibilityRepository( return queries.updateImages(images, id) } + override fun updateImageUrlsAndImages(id: String, imageUrls: List, images: List) { + return queries.updateImageUrlsAndImages(id = id, imageUrls = imageUrls, images = images) + } + override fun countAll(): Int { return queries.countAll() .executeAsOne() diff --git a/app-server/subprojects/bounded_context/accessibility/infra/src/main/kotlin/club/staircrusher/accessibility/infra/adapter/out/persistence/sqldelight/Converters.kt b/app-server/subprojects/bounded_context/accessibility/infra/src/main/kotlin/club/staircrusher/accessibility/infra/adapter/out/persistence/sqldelight/Converters.kt index 25c63e780..70527199d 100644 --- a/app-server/subprojects/bounded_context/accessibility/infra/src/main/kotlin/club/staircrusher/accessibility/infra/adapter/out/persistence/sqldelight/Converters.kt +++ b/app-server/subprojects/bounded_context/accessibility/infra/src/main/kotlin/club/staircrusher/accessibility/infra/adapter/out/persistence/sqldelight/Converters.kt @@ -3,6 +3,7 @@ package club.staircrusher.accessibility.infra.adapter.out.persistence.sqldelight import club.staircrusher.accessibility.domain.model.AccessibilityAllowedRegion +import club.staircrusher.accessibility.domain.model.AccessibilityImageFaceBlurringHistory import club.staircrusher.accessibility.domain.model.AccessibilityRank import club.staircrusher.accessibility.domain.model.BuildingAccessibility import club.staircrusher.accessibility.domain.model.BuildingAccessibilityComment @@ -12,6 +13,7 @@ import club.staircrusher.accessibility.domain.model.PlaceAccessibilityComment import club.staircrusher.accessibility.domain.model.PlaceAccessibilityUpvote import club.staircrusher.accessibility.domain.model.StairInfo import club.staircrusher.infra.persistence.sqldelight.migration.Accessibility_allowed_region +import club.staircrusher.infra.persistence.sqldelight.migration.Accessibility_image_face_blurring_history import club.staircrusher.infra.persistence.sqldelight.migration.Accessibility_rank import club.staircrusher.infra.persistence.sqldelight.migration.Building_accessibility import club.staircrusher.infra.persistence.sqldelight.migration.Building_accessibility_comment @@ -21,6 +23,8 @@ import club.staircrusher.infra.persistence.sqldelight.migration.Place_accessibil import club.staircrusher.infra.persistence.sqldelight.migration.Place_accessibility_upvote import club.staircrusher.infra.persistence.sqldelight.query.accessibility.BuildingAccessibilityUpvoteFindById import club.staircrusher.infra.persistence.sqldelight.query.accessibility.FindByUserAndBuildingAccessibilityAndNotDeleted +import club.staircrusher.infra.persistence.sqldelight.query.accessibility.FindLatestBuildingHistory +import club.staircrusher.infra.persistence.sqldelight.query.accessibility.FindLatestPlaceHistory import club.staircrusher.stdlib.time.toOffsetDateTime fun Building_accessibility.toDomainModel() = BuildingAccessibility( @@ -250,3 +254,47 @@ fun Accessibility_allowed_region.toDomainModel() = AccessibilityAllowedRegion( createdAt = created_at.toInstant(), updatedAt = updated_at.toInstant(), ) + +fun AccessibilityImageFaceBlurringHistory.toPersistenceModel() = Accessibility_image_face_blurring_history( + id = id, + place_accessibility_id = placeAccessibilityId, + building_accessibility_id = buildingAccessibilityId, + original_image_urls = originalImageUrls, + blurred_image_urls = blurredImageUrls, + detected_people_counts = detectedPeopleCounts, + created_at = createdAt.toOffsetDateTime(), + updated_at = updatedAt.toOffsetDateTime(), +) + +fun FindLatestPlaceHistory.toDomainModel() = AccessibilityImageFaceBlurringHistory( + id = id, + placeAccessibilityId = place_accessibility_id, + buildingAccessibilityId = building_accessibility_id, + originalImageUrls = original_image_urls, + blurredImageUrls = blurred_image_urls, + detectedPeopleCounts = detected_people_counts, + createdAt = created_at.toInstant(), + updatedAt = updated_at.toInstant(), +) + +fun FindLatestBuildingHistory.toDomainModel() = AccessibilityImageFaceBlurringHistory( + id = id, + placeAccessibilityId = place_accessibility_id, + buildingAccessibilityId = building_accessibility_id, + originalImageUrls = original_image_urls, + blurredImageUrls = blurred_image_urls, + detectedPeopleCounts = detected_people_counts, + createdAt = created_at.toInstant(), + updatedAt = updated_at.toInstant(), +) + +fun Accessibility_image_face_blurring_history.toDomainModel() = AccessibilityImageFaceBlurringHistory( + id = id, + placeAccessibilityId = place_accessibility_id, + buildingAccessibilityId = building_accessibility_id, + originalImageUrls = original_image_urls, + blurredImageUrls = blurred_image_urls, + detectedPeopleCounts = detected_people_counts, + createdAt = created_at.toInstant(), + updatedAt = updated_at.toInstant(), +) diff --git a/app-server/subprojects/cross_cutting_concern/infra/persistence_model/src/main/kotlin/club/staircrusher/infra/persistence/sqldelight/DB.kt b/app-server/subprojects/cross_cutting_concern/infra/persistence_model/src/main/kotlin/club/staircrusher/infra/persistence/sqldelight/DB.kt index b806ee2d1..cd2ab0730 100644 --- a/app-server/subprojects/cross_cutting_concern/infra/persistence_model/src/main/kotlin/club/staircrusher/infra/persistence/sqldelight/DB.kt +++ b/app-server/subprojects/cross_cutting_concern/infra/persistence_model/src/main/kotlin/club/staircrusher/infra/persistence/sqldelight/DB.kt @@ -12,6 +12,7 @@ import club.staircrusher.infra.persistence.sqldelight.column_adapter.LocationLis import club.staircrusher.infra.persistence.sqldelight.column_adapter.StairHeightLevelStringColumnAdapter import club.staircrusher.infra.persistence.sqldelight.column_adapter.StringListToTextColumnAdapter import club.staircrusher.infra.persistence.sqldelight.migration.Accessibility_allowed_region +import club.staircrusher.infra.persistence.sqldelight.migration.Accessibility_image_face_blurring_history import club.staircrusher.infra.persistence.sqldelight.migration.Building_accessibility import club.staircrusher.infra.persistence.sqldelight.migration.Challenge import club.staircrusher.infra.persistence.sqldelight.migration.External_accessibility @@ -45,7 +46,7 @@ class DB(dataSource: DataSource) { entrance_door_typesAdapter = EntranceDoorTypeListStringColumnAdapter, elevator_stair_height_levelAdapter = StairHeightLevelStringColumnAdapter, entrance_imagesAdapter = AccessibilityImageListStringColumnAdapter, - elevator_imagesAdapter = AccessibilityImageListStringColumnAdapter, + elevator_imagesAdapter = AccessibilityImageListStringColumnAdapter, ), accessibility_allowed_regionAdapter = Accessibility_allowed_region.Adapter( boundary_verticesAdapter = LocationListToTextColumnAdapter, @@ -91,9 +92,15 @@ class DB(dataSource: DataSource) { return objectMapper.writeValueAsString(value) } } - ) + ), + accessibility_image_face_blurring_historyAdapter = Accessibility_image_face_blurring_history.Adapter( + original_image_urlsAdapter = StringListToTextColumnAdapter, + blurred_image_urlsAdapter = StringListToTextColumnAdapter, + detected_people_countsAdapter = IntListToTextColumnAdapter, + ), ) + val accessibilityImageFaceBlurringHistoryQueries = scc.accessibilityImageFaceBlurringHistoryQueries val buildingAccessibilityQueries = scc.buildingAccessibilityQueries val buildingAccessibilityCommentQueries = scc.buildingAccessibilityCommentQueries val buildingAccessibilityUpvoteQueries = scc.buildingAccessibilityUpvoteQueries diff --git a/app-server/subprojects/cross_cutting_concern/infra/persistence_model/src/main/sqldelight/club/staircrusher/infra/persistence/sqldelight/migration/V28__add_accessibility_image_face_blurring_history.sqm b/app-server/subprojects/cross_cutting_concern/infra/persistence_model/src/main/sqldelight/club/staircrusher/infra/persistence/sqldelight/migration/V28__add_accessibility_image_face_blurring_history.sqm new file mode 100644 index 000000000..a3eb36d92 --- /dev/null +++ b/app-server/subprojects/cross_cutting_concern/infra/persistence_model/src/main/sqldelight/club/staircrusher/infra/persistence/sqldelight/migration/V28__add_accessibility_image_face_blurring_history.sqm @@ -0,0 +1,18 @@ +import kotlin.Int; +import kotlin.String; +import kotlin.collections.List; + +CREATE TABLE IF NOT EXISTS accessibility_image_face_blurring_history ( + id VARCHAR(36) NOT NULL PRIMARY KEY, + place_accessibility_id VARCHAR(36) NULL, + building_accessibility_id VARCHAR(36) NULL, + original_image_urls TEXT AS List NOT NULL DEFAULT '[]', + blurred_image_urls TEXT AS List NOT NULL DEFAULT '[]', + detected_people_counts TEXT AS List NOT NULL, + created_at TIMESTAMP(6) WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP(6) WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX idx_accessibility_image_face_blurring_history_1 ON accessibility_image_face_blurring_history(created_at); +CREATE INDEX idx_accessibility_image_face_blurring_history_2 ON accessibility_image_face_blurring_history(place_accessibility_id); +CREATE INDEX idx_accessibility_image_face_blurring_history_3 ON accessibility_image_face_blurring_history(building_accessibility_id); diff --git a/app-server/subprojects/cross_cutting_concern/infra/persistence_model/src/main/sqldelight/club/staircrusher/infra/persistence/sqldelight/query/accessibility/AccessibilityImageFaceBlurringHistory.sq b/app-server/subprojects/cross_cutting_concern/infra/persistence_model/src/main/sqldelight/club/staircrusher/infra/persistence/sqldelight/query/accessibility/AccessibilityImageFaceBlurringHistory.sq new file mode 100644 index 000000000..3e4a6a66e --- /dev/null +++ b/app-server/subprojects/cross_cutting_concern/infra/persistence_model/src/main/sqldelight/club/staircrusher/infra/persistence/sqldelight/query/accessibility/AccessibilityImageFaceBlurringHistory.sq @@ -0,0 +1,47 @@ +save: +INSERT INTO accessibility_image_face_blurring_history +VALUES :accessibility_image_face_blurring_history +ON CONFLICT(id) DO UPDATE SET + place_accessibility_id = EXCLUDED.place_accessibility_id, + building_accessibility_id = EXCLUDED.building_accessibility_id, + original_image_urls = EXCLUDED.original_image_urls, + blurred_image_urls = EXCLUDED.blurred_image_urls, + detected_people_counts = EXCLUDED.detected_people_counts, + created_at = EXCLUDED.created_at, + updated_at = EXCLUDED.updated_at; + +removeAll: +DELETE FROM accessibility_image_face_blurring_history; + +findAll: +SELECT * +FROM accessibility_image_face_blurring_history; + +findById: +SELECT h.* +FROM accessibility_image_face_blurring_history h +WHERE h.id = :id; + +findByPlaceAccessibility: +SELECT h.* +FROM accessibility_image_face_blurring_history h +WHERE h.place_accessibility_id = :place_accessibility_id; + +findByBuildingAccessilbility: +SELECT h.* +FROM accessibility_image_face_blurring_history h +WHERE h.building_accessibility_id = :building_accessibility_id; + +findLatestPlaceHistory: +SELECT h.* +FROM accessibility_image_face_blurring_history h +WHERE h.place_accessibility_id IS NOT NULL +ORDER BY h.created_at DESC +LIMIT 1; + +findLatestBuildingHistory: +SELECT * +FROM accessibility_image_face_blurring_history h +WHERE h.building_accessibility_id IS NOT NULL +ORDER BY h.created_at DESC +LIMIT 1; diff --git a/app-server/subprojects/cross_cutting_concern/infra/persistence_model/src/main/sqldelight/club/staircrusher/infra/persistence/sqldelight/query/accessibility/BuildingAccessibility.sq b/app-server/subprojects/cross_cutting_concern/infra/persistence_model/src/main/sqldelight/club/staircrusher/infra/persistence/sqldelight/query/accessibility/BuildingAccessibility.sq index 0cf8d6212..184f5dcdd 100644 --- a/app-server/subprojects/cross_cutting_concern/infra/persistence_model/src/main/sqldelight/club/staircrusher/infra/persistence/sqldelight/query/accessibility/BuildingAccessibility.sq +++ b/app-server/subprojects/cross_cutting_concern/infra/persistence_model/src/main/sqldelight/club/staircrusher/infra/persistence/sqldelight/query/accessibility/BuildingAccessibility.sq @@ -5,10 +5,12 @@ ON CONFLICT(id) DO UPDATE SET building_id = EXCLUDED.building_id, entrance_stair_info = EXCLUDED.entrance_stair_info, entrance_image_urls = EXCLUDED.entrance_image_urls, + entrance_images = EXCLUDED.entrance_images, has_slope = EXCLUDED.has_slope, has_elevator = EXCLUDED.has_elevator, elevator_stair_info = EXCLUDED.elevator_stair_info, elevator_image_urls = EXCLUDED.elevator_image_urls, + elevator_images = EXCLUDED.elevator_images, user_id = EXCLUDED.user_id, created_at = EXCLUDED.created_at, deleted_at = NULL; @@ -58,16 +60,43 @@ FROM building_accessibility WHERE building.eup_myeon_dong_id = :eupMyeonDongId AND building_accessibility.deleted_at IS NULL; +findByCreatedAtGreaterThanAndOrderByCreatedAtAsc: +SELECT * +FROM building_accessibility ba +WHERE + ba.created_at > :createdAt + AND ba.deleted_at IS NULL +ORDER BY ba.created_at ASC, ba.id DESC +LIMIT :limit; + +findAll: +SELECT * +FROM building_accessibility; + updateEntranceImages: UPDATE building_accessibility SET entrance_images = :entranceImages WHERE id = :id; +updateEntranceImageUrlsAndImages: +UPDATE building_accessibility +SET +entrance_image_urls = :entranceImageUrls, +entrance_images = :entranceImages +WHERE id = :id; + updateElevatorImages: UPDATE building_accessibility SET elevator_images = :elevatorImages WHERE id = :id; +updateElevatorImageUrlsAndImages: +UPDATE building_accessibility +SET +elevator_image_urls = :elevatorImageUrls, +elevator_images = :elevatorImages +WHERE id = :id; + countByUserId: SELECT COUNT(1) FROM building_accessibility diff --git a/app-server/subprojects/cross_cutting_concern/infra/persistence_model/src/main/sqldelight/club/staircrusher/infra/persistence/sqldelight/query/accessibility/PlaceAccessibility.sq b/app-server/subprojects/cross_cutting_concern/infra/persistence_model/src/main/sqldelight/club/staircrusher/infra/persistence/sqldelight/query/accessibility/PlaceAccessibility.sq index 6ae4355c1..2f8a80477 100644 --- a/app-server/subprojects/cross_cutting_concern/infra/persistence_model/src/main/sqldelight/club/staircrusher/infra/persistence/sqldelight/query/accessibility/PlaceAccessibility.sq +++ b/app-server/subprojects/cross_cutting_concern/infra/persistence_model/src/main/sqldelight/club/staircrusher/infra/persistence/sqldelight/query/accessibility/PlaceAccessibility.sq @@ -11,6 +11,7 @@ ON CONFLICT(id) DO UPDATE SET has_slope = EXCLUDED.has_slope, entrance_door_types = EXCLUDED.entrance_door_types, image_urls = EXCLUDED.image_urls, + images = EXCLUDED.images, user_id = EXCLUDED.user_id, created_at = EXCLUDED.created_at, deleted_at = NULL; @@ -51,6 +52,15 @@ WHERE AND pa.created_at <=:to AND pa.deleted_at IS NULL; +findByCreatedAtGreaterThanAndOrderByCreatedAtAsc: +SELECT pa.* +FROM place_accessibility pa +WHERE + pa.created_at > :createdAt + AND pa.deleted_at IS NULL +ORDER BY pa.created_at ASC, pa.id DESC +LIMIT :limit; + countByEupMyeonDong: SELECT COUNT(1) FROM place_accessibility @@ -101,6 +111,11 @@ UPDATE place_accessibility SET images = :images WHERE id = :id; +updateImageUrlsAndImages: +UPDATE place_accessibility +SET image_urls = :imageUrls, images = :images +WHERE id = :id; + countAll: SELECT COUNT(*) FROM place_accessibility pa diff --git a/app-server/subprojects/cross_cutting_concern/infra/spring_web/src/integrationTest/resources/application.yaml b/app-server/subprojects/cross_cutting_concern/infra/spring_web/src/integrationTest/resources/application.yaml index 88a8e3f60..adfe53562 100644 --- a/app-server/subprojects/cross_cutting_concern/infra/spring_web/src/integrationTest/resources/application.yaml +++ b/app-server/subprojects/cross_cutting_concern/infra/spring_web/src/integrationTest/resources/application.yaml @@ -31,6 +31,10 @@ scc: accessKey: test secretKey: test + rekognition: + accessKey: test + secretKey: test + cloudfront: domain: cloudfronttest diff --git a/app-server/subprojects/cross_cutting_concern/stdlib/src/main/kotlin/club/staircrusher/stdlib/Size.kt b/app-server/subprojects/cross_cutting_concern/stdlib/src/main/kotlin/club/staircrusher/stdlib/Size.kt new file mode 100644 index 000000000..43a20e899 --- /dev/null +++ b/app-server/subprojects/cross_cutting_concern/stdlib/src/main/kotlin/club/staircrusher/stdlib/Size.kt @@ -0,0 +1,6 @@ +package club.staircrusher.stdlib + +data class Size( + val width: Int, + val height: Int +) diff --git a/app-server/subprojects/cross_cutting_concern/test/spring_it/src/main/kotlin/club/staircrusher/testing/spring_it/ITDataGenerator.kt b/app-server/subprojects/cross_cutting_concern/test/spring_it/src/main/kotlin/club/staircrusher/testing/spring_it/ITDataGenerator.kt index 0cff0b89d..fc6cbbc60 100644 --- a/app-server/subprojects/cross_cutting_concern/test/spring_it/src/main/kotlin/club/staircrusher/testing/spring_it/ITDataGenerator.kt +++ b/app-server/subprojects/cross_cutting_concern/test/spring_it/src/main/kotlin/club/staircrusher/testing/spring_it/ITDataGenerator.kt @@ -348,10 +348,14 @@ class ITDataGenerator { building: Building, entranceStairInfo: StairInfo = StairInfo.NONE, entranceStairHeightLevel: StairHeightLevel = StairHeightLevel.THUMB, + entranceImageUrls: List = emptyList(), + entranceImages: List = emptyList(), + entranceDoorTypes: List = listOf(EntranceDoorType.Sliding, EntranceDoorType.Automatic), hasSlope: Boolean = true, hasElevator: Boolean = true, - entranceDoorTypes: List = listOf(EntranceDoorType.Sliding, EntranceDoorType.Automatic), elevatorStairHeightLevel: StairHeightLevel = StairHeightLevel.HALF_THUMB, + elevatorImageUrls: List = emptyList(), + elevatorImages: List = emptyList(), user: User? = null, at: Instant = clock.instant(), ): BuildingAccessibility { @@ -361,15 +365,15 @@ class ITDataGenerator { buildingId = building.id, entranceStairInfo = entranceStairInfo, entranceStairHeightLevel = entranceStairHeightLevel, - entranceImageUrls = emptyList(), - entranceImages = emptyList(), + entranceImageUrls = entranceImageUrls, + entranceImages = entranceImages, hasSlope = hasSlope, hasElevator = hasElevator, entranceDoorTypes = entranceDoorTypes, elevatorStairInfo = StairInfo.NONE, elevatorStairHeightLevel = elevatorStairHeightLevel, - elevatorImageUrls = emptyList(), - elevatorImages = emptyList(), + elevatorImageUrls = elevatorImageUrls, + elevatorImages = elevatorImages, userId = user?.id, createdAt = at, ), @@ -385,7 +389,15 @@ class ITDataGenerator { ): Pair { return Pair( registerPlaceAccessibility(place = place, user = user, imageUrls = imageUrls, images = images, at = at), - registerBuildingAccessibilityIfNotExists(place.building, user = user, at = at), + registerBuildingAccessibilityIfNotExists( + place.building, + user = user, + entranceImageUrls = imageUrls, + entranceImages = images, + elevatorImageUrls = imageUrls, + elevatorImages = images, + at = at + ), ) } diff --git a/app-server/subprojects/cross_cutting_concern/test/spring_it/src/main/kotlin/club/staircrusher/testing/spring_it/mock/MockDetectFacesService.kt b/app-server/subprojects/cross_cutting_concern/test/spring_it/src/main/kotlin/club/staircrusher/testing/spring_it/mock/MockDetectFacesService.kt new file mode 100644 index 000000000..5d645d72b --- /dev/null +++ b/app-server/subprojects/cross_cutting_concern/test/spring_it/src/main/kotlin/club/staircrusher/testing/spring_it/mock/MockDetectFacesService.kt @@ -0,0 +1,31 @@ +package club.staircrusher.testing.spring_it.mock + +import club.staircrusher.accessibility.application.port.out.DetectFacesResponse +import club.staircrusher.accessibility.application.port.out.DetectFacesService +import club.staircrusher.accessibility.domain.model.DetectedFacePosition +import club.staircrusher.stdlib.Size + +class MockDetectFacesService : DetectFacesService { + override suspend fun detect(imageUrl: String): DetectFacesResponse { + return DetectFacesResponse( + imageBytes = if (imageUrl == URL_WITH_FACES) byteArrayWithFaces else ByteArray(0), + imageSize = Size(100, 100), + positions = if (imageUrl == URL_WITH_FACES) listOf(DetectedFacePosition(30, 30, 10, 10)) else emptyList() + ) + } + + override suspend fun detect(imageBytes: ByteArray): DetectFacesResponse { + return DetectFacesResponse( + imageBytes = imageBytes, + imageSize = Size(100, 100), + positions = if (imageBytes.contentEquals(byteArrayWithFaces)) listOf(DetectedFacePosition(0, 0, 100, 100)) else emptyList() + ) + } + + companion object { + const val URL_WITH_FACES = "https://staircrusher.club/faces.jpg" + const val BLURRED_URL_WITH_FACES = "https://staircrusher.club/faces_b.jpg" + const val URL_WITHOUT_FACES = "https://staircrusher.club/without_faces.jpg" + val byteArrayWithFaces = ByteArray(100 * 100 * 3) { it.toByte() } + } +} diff --git a/app-server/subprojects/cross_cutting_concern/test/spring_it/src/main/kotlin/club/staircrusher/testing/spring_it/mock/MockFileManagementService.kt b/app-server/subprojects/cross_cutting_concern/test/spring_it/src/main/kotlin/club/staircrusher/testing/spring_it/mock/MockFileManagementService.kt index 28bb1d3e0..fd1d2acd3 100644 --- a/app-server/subprojects/cross_cutting_concern/test/spring_it/src/main/kotlin/club/staircrusher/testing/spring_it/mock/MockFileManagementService.kt +++ b/app-server/subprojects/cross_cutting_concern/test/spring_it/src/main/kotlin/club/staircrusher/testing/spring_it/mock/MockFileManagementService.kt @@ -9,9 +9,9 @@ import java.io.File import java.nio.file.Path class MockFileManagementService : FileManagementService { - override fun getFileUploadUrl(filenameExtension: String): UploadUrl { + override fun getFileUploadUrl(fileExtension: String): UploadUrl { return UploadUrl( - url = "example.com", + url = "example.$fileExtension", expireAt = SccClock.instant().plusSeconds(60L) ) } @@ -20,6 +20,10 @@ class MockFileManagementService : FileManagementService { return ClassPathResource("example.png").file } + override suspend fun uploadImage(fileName: String, fileBytes: ByteArray): String { + return fileName + } + override suspend fun uploadThumbnailImage(fileName: String, outputStream: ByteArrayOutputStream): String { return fileName } diff --git a/app-server/subprojects/cross_cutting_concern/test/spring_it/src/main/kotlin/club/staircrusher/testing/spring_it/mock/MockImageProcessor.kt b/app-server/subprojects/cross_cutting_concern/test/spring_it/src/main/kotlin/club/staircrusher/testing/spring_it/mock/MockImageProcessor.kt new file mode 100644 index 000000000..a0c790538 --- /dev/null +++ b/app-server/subprojects/cross_cutting_concern/test/spring_it/src/main/kotlin/club/staircrusher/testing/spring_it/mock/MockImageProcessor.kt @@ -0,0 +1,12 @@ +package club.staircrusher.testing.spring_it.mock + +import club.staircrusher.accessibility.application.port.`in`.image.ImageProcessor +import club.staircrusher.accessibility.domain.model.DetectedFacePosition + +class MockImageProcessor : ImageProcessor { + override fun blur( + originalImage: ByteArray, imageExtension: String, positions: List + ): ByteArray { + return originalImage + } +} diff --git a/app-server/subprojects/cross_cutting_concern/test/spring_it/src/main/kotlin/club/staircrusher/testing/spring_it/mock/SccSpringItMockConfiguration.kt b/app-server/subprojects/cross_cutting_concern/test/spring_it/src/main/kotlin/club/staircrusher/testing/spring_it/mock/SccSpringItMockConfiguration.kt index 25c9a98c1..ffd673bfb 100644 --- a/app-server/subprojects/cross_cutting_concern/test/spring_it/src/main/kotlin/club/staircrusher/testing/spring_it/mock/SccSpringItMockConfiguration.kt +++ b/app-server/subprojects/cross_cutting_concern/test/spring_it/src/main/kotlin/club/staircrusher/testing/spring_it/mock/SccSpringItMockConfiguration.kt @@ -1,6 +1,8 @@ package club.staircrusher.testing.spring_it.mock +import club.staircrusher.accessibility.application.port.`in`.image.ImageProcessor import club.staircrusher.accessibility.application.port.`in`.image.ThumbnailGenerator +import club.staircrusher.accessibility.application.port.out.DetectFacesService import club.staircrusher.accessibility.application.port.out.file_management.FileManagementService import club.staircrusher.place.application.port.out.web.MapsService import club.staircrusher.quest.application.port.out.web.UrlShorteningService @@ -64,4 +66,16 @@ open class SccSpringItMockConfiguration { open fun mockUrlShorteningService(): UrlShorteningService { return MockUrlShorteningService() } + + @Bean + @Primary + open fun mockDetectFacesService(): DetectFacesService { + return MockDetectFacesService() + } + + @Bean + @Primary + open fun mockImageProcessor(): ImageProcessor { + return MockImageProcessor() + } } diff --git a/app-server/subprojects/cross_cutting_concern/test/spring_it/src/main/resources/application.yaml b/app-server/subprojects/cross_cutting_concern/test/spring_it/src/main/resources/application.yaml index fc44e6123..bd843b1ea 100644 --- a/app-server/subprojects/cross_cutting_concern/test/spring_it/src/main/resources/application.yaml +++ b/app-server/subprojects/cross_cutting_concern/test/spring_it/src/main/resources/application.yaml @@ -37,6 +37,10 @@ scc: accessKey: test secretKey: test + rekognition: + accessKey: test + secretKey: test + cloudfront: domain: cloudfronttest diff --git a/app-server/subprojects/deploying_apps/scc_server/src/main/resources/application.yaml b/app-server/subprojects/deploying_apps/scc_server/src/main/resources/application.yaml index 074d4d1a6..7d63761fa 100644 --- a/app-server/subprojects/deploying_apps/scc_server/src/main/resources/application.yaml +++ b/app-server/subprojects/deploying_apps/scc_server/src/main/resources/application.yaml @@ -64,6 +64,7 @@ scc: imageUpload: bucketName: test thumbnailBucketName: test + rekognition: cloudfront: domain: test diff --git a/infra/helm/scc-server/templates/cronjob.yaml b/infra/helm/scc-server/templates/cronjob.yaml index 4eabfb369..99e2e4b84 100644 --- a/infra/helm/scc-server/templates/cronjob.yaml +++ b/infra/helm/scc-server/templates/cronjob.yaml @@ -1,3 +1,49 @@ +#apiVersion: batch/v1 +#kind: CronJob +#metadata: +# name: scc-blur-face-in-accessibility-images +# labels: +# {{- include "scc-server.labels" . | nindent 4 }} +#spec: +# schedule: "*/5 * * * *" +# jobTemplate: +# spec: +# template: +# spec: +# containers: +# - name: curl-container +# image: appropriate/curl +# args: +# - "--fail" +# - "-XPOST" +# - "http://scc-server.{{ .Release.Namespace }}.svc.cluster.local/blurFacesInLatestPlaceAccessibilityImages" +# restartPolicy: OnFailure +# +#--- +# +#apiVersion: batch/v1 +#kind: CronJob +#metadata: +# name: scc-blur-face-in-accessibility-images +# labels: +# {{- include "scc-server.labels" . | nindent 4 }} +#spec: +# schedule: "*/5 * * * *" +# jobTemplate: +# spec: +# template: +# spec: +# containers: +# - name: curl-container +# image: appropriate/curl +# args: +# - "--fail" +# - "-XPOST" +# - "http://scc-server.{{ .Release.Namespace }}.svc.cluster.local/blurFacesInLatestBuildingAccessibilityImages" +# restartPolicy: OnFailure +# +#--- + apiVersion: batch/v1 kind: CronJob metadata: diff --git a/infra/terraform/scc/iam-dev.tf b/infra/terraform/scc/iam-dev.tf index 8964ead11..380c5541a 100644 --- a/infra/terraform/scc/iam-dev.tf +++ b/infra/terraform/scc/iam-dev.tf @@ -4,14 +4,14 @@ data "aws_iam_policy_document" "scc_dev" { actions = ["sts:AssumeRoleWithWebIdentity"] principals { - type = "Federated" + type = "Federated" identifiers = [data.terraform_remote_state.oidc.outputs.k3s_oidc_arn] } condition { test = "StringEquals" variable = "k3s.staircrusher.club:sub" - values = ["system:serviceaccount:dev:scc-server"] + values = ["system:serviceaccount:dev:scc-server"] } } } @@ -40,6 +40,15 @@ data "aws_iam_policy_document" "scc_dev_accessibility_thumbnails_full_access" { } } +data "aws_iam_policy_document" "scc_dev_rekognition_access" { + statement { + actions = [ + "rekognition:DetectFaces", + ] + resources = ["*"] + } +} + resource "aws_iam_role" "scc_dev" { name = "scc-dev" assume_role_policy = data.aws_iam_policy_document.scc_dev.json @@ -55,6 +64,11 @@ resource "aws_iam_policy" "scc_dev_accessibility_thumbnails_full_access" { policy = data.aws_iam_policy_document.scc_dev_accessibility_thumbnails_full_access.json } +resource "aws_iam_policy" "scc_dev_rekognition_access" { + name = "scc-dev-rekognition-access" + policy = data.aws_iam_policy_document.scc_dev_rekognition_access.json +} + resource "aws_iam_role_policy_attachment" "scc_dev_accessibility_images_full_access" { role = aws_iam_role.scc_dev.name policy_arn = aws_iam_policy.scc_dev_accessibility_images_full_access.arn @@ -65,19 +79,24 @@ resource "aws_iam_role_policy_attachment" "scc_dev_accessibility_thumbnails_full policy_arn = aws_iam_policy.scc_dev_accessibility_thumbnails_full_access.arn } +resource "aws_iam_role_policy_attachment" "scc_dev_rekognition_access" { + role = aws_iam_role.scc_dev.name + policy_arn = aws_iam_policy.scc_dev_rekognition_access.arn +} + data "aws_iam_policy_document" "scc_deploy_secret_dev" { statement { actions = ["sts:AssumeRoleWithWebIdentity"] principals { - type = "Federated" + type = "Federated" identifiers = [data.terraform_remote_state.oidc.outputs.k3s_oidc_arn] } condition { test = "StringEquals" variable = "k3s.staircrusher.club:sub" - values = ["system:serviceaccount:dev:scc-server-deploy-secret"] + values = ["system:serviceaccount:dev:scc-server-deploy-secret"] } } } diff --git a/infra/terraform/scc/iam.tf b/infra/terraform/scc/iam.tf index dff4dd35e..d44780d77 100644 --- a/infra/terraform/scc/iam.tf +++ b/infra/terraform/scc/iam.tf @@ -4,14 +4,14 @@ data "aws_iam_policy_document" "scc" { actions = ["sts:AssumeRoleWithWebIdentity"] principals { - type = "Federated" + type = "Federated" identifiers = [data.terraform_remote_state.oidc.outputs.k3s_oidc_arn] } condition { test = "StringEquals" variable = "k3s.staircrusher.club:sub" - values = ["system:serviceaccount:scc:scc-server"] + values = ["system:serviceaccount:scc:scc-server"] } } } @@ -40,6 +40,15 @@ data "aws_iam_policy_document" "scc_accessibility_thumbnails_full_access" { } } +data "aws_iam_policy_document" "scc_rekognition_access" { + statement { + actions = [ + "rekognition:DetectFaces", + ] + resources = ["*"] + } +} + resource "aws_iam_role" "scc" { name = "scc" assume_role_policy = data.aws_iam_policy_document.scc.json @@ -55,6 +64,11 @@ resource "aws_iam_policy" "scc_accessibility_thumbnails_full_access" { policy = data.aws_iam_policy_document.scc_accessibility_thumbnails_full_access.json } +resource "aws_iam_policy" "scc_rekognition_access" { + name = "scc-accessibility-thumbnails-full-access" + policy = data.aws_iam_policy_document.scc_accessibility_thumbnails_full_access.json +} + resource "aws_iam_role_policy_attachment" "scc_accessibility_images_full_access" { role = aws_iam_role.scc.name policy_arn = aws_iam_policy.scc_accessibility_images_full_access.arn @@ -65,19 +79,24 @@ resource "aws_iam_role_policy_attachment" "scc_accessibility_thumbnails_full_acc policy_arn = aws_iam_policy.scc_accessibility_thumbnails_full_access.arn } +resource "aws_iam_role_policy_attachment" "scc_rekognition_access" { + role = aws_iam_role.scc.name + policy_arn = aws_iam_policy.scc_rekognition_access.arn +} + data "aws_iam_policy_document" "scc_deploy_secret" { statement { actions = ["sts:AssumeRoleWithWebIdentity"] principals { - type = "Federated" + type = "Federated" identifiers = [data.terraform_remote_state.oidc.outputs.k3s_oidc_arn] } condition { test = "StringEquals" variable = "k3s.staircrusher.club:sub" - values = [ + values = [ "system:serviceaccount:scc:scc-server-deploy-secret", "system:serviceaccount:scc-redash:scc-redash-deploy-secret", ]