From 62e71ed6458fd261558d364c7a9f21046ab0b73c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=ED=95=9C=EC=9D=8C?= Date: Wed, 25 Mar 2026 14:07:13 +0900 Subject: [PATCH 1/2] [Feature] Implement flower event append and upload multipart image to S3 (#130) * feat: Implement flower event append and upload multipart image to S3 * fix: update swagger ui description * feat: Implement flower spot cafe append and upload multipart image to S3 * refactor: facade layer class name update * refactor: update minor changes reviewed by coderabbit * refactor: change endpoints as Swagger UI Tag named --- .../pida/client/aws/image/ImageS3Processor.kt | 25 +++++++++ .../com/pida/client/aws/s3/AwsS3Client.kt | 17 ++++++ .../v2/flowerevent/FlowerEventController.kt | 48 +++++++++++++++++ .../request/FlowerEventCreateRequest.kt | 53 +++++++++++++++++++ .../FlowerSpotCafeController.kt | 48 +++++++++++++++++ .../request/FlowerSpotCafeCreateRequest.kt | 49 +++++++++++++++++ .../pida/flowerevent/FlowerEventAppender.kt | 17 ++++++ .../com/pida/flowerevent/FlowerEventFacade.kt | 31 +++++++++++ .../pida/flowerevent/FlowerEventRepository.kt | 7 +++ .../com/pida/flowerevent/NewFlowerEvent.kt | 32 +++++++++++ .../pida/flowerspot/FlowerSpotCafeAppender.kt | 17 ++++++ .../pida/flowerspot/FlowerSpotCafeFacade.kt | 31 +++++++++++ .../flowerspot/FlowerSpotCafeRepository.kt | 7 +++ .../com/pida/flowerspot/NewFlowerSpotCafe.kt | 29 ++++++++++ .../com/pida/support/aws/ImageS3Caller.kt | 18 +++++++ .../com/pida/support/aws/S3UploadResult.kt | 6 +++ .../flowerevent/FlowerEventCoreRepository.kt | 36 +++++++++++++ .../FlowerEventCustomRepository.kt | 27 ++++++++++ .../FlowerSpotCafeCoreRepository.kt | 35 ++++++++++++ .../FlowerSpotCafeCustomRepository.kt | 27 ++++++++++ 20 files changed, 560 insertions(+) create mode 100644 pida-core/core-api/src/main/kotlin/com/pida/presentation/v2/flowerevent/FlowerEventController.kt create mode 100644 pida-core/core-api/src/main/kotlin/com/pida/presentation/v2/flowerevent/request/FlowerEventCreateRequest.kt create mode 100644 pida-core/core-api/src/main/kotlin/com/pida/presentation/v2/flowerspotcafe/FlowerSpotCafeController.kt create mode 100644 pida-core/core-api/src/main/kotlin/com/pida/presentation/v2/flowerspotcafe/request/FlowerSpotCafeCreateRequest.kt create mode 100644 pida-core/core-domain/src/main/kotlin/com/pida/flowerevent/FlowerEventAppender.kt create mode 100644 pida-core/core-domain/src/main/kotlin/com/pida/flowerevent/FlowerEventFacade.kt create mode 100644 pida-core/core-domain/src/main/kotlin/com/pida/flowerevent/NewFlowerEvent.kt create mode 100644 pida-core/core-domain/src/main/kotlin/com/pida/flowerspot/FlowerSpotCafeAppender.kt create mode 100644 pida-core/core-domain/src/main/kotlin/com/pida/flowerspot/FlowerSpotCafeFacade.kt create mode 100644 pida-core/core-domain/src/main/kotlin/com/pida/flowerspot/NewFlowerSpotCafe.kt create mode 100644 pida-core/core-domain/src/main/kotlin/com/pida/support/aws/S3UploadResult.kt create mode 100644 pida-storage/db-core/src/main/kotlin/com/pida/storage/db/core/flowerevent/FlowerEventCustomRepository.kt create mode 100644 pida-storage/db-core/src/main/kotlin/com/pida/storage/db/core/flowerspot/FlowerSpotCafeCustomRepository.kt diff --git a/pida-clients/aws-client/src/main/kotlin/com/pida/client/aws/image/ImageS3Processor.kt b/pida-clients/aws-client/src/main/kotlin/com/pida/client/aws/image/ImageS3Processor.kt index 30affa5d..b83753b0 100644 --- a/pida-clients/aws-client/src/main/kotlin/com/pida/client/aws/image/ImageS3Processor.kt +++ b/pida-clients/aws-client/src/main/kotlin/com/pida/client/aws/image/ImageS3Processor.kt @@ -6,6 +6,7 @@ import com.pida.support.aws.ImageS3Caller import com.pida.support.aws.PresignedUrlRateLimiter import com.pida.support.aws.S3ImageInfo import com.pida.support.aws.S3ImageUrl +import com.pida.support.aws.S3UploadResult import org.springframework.stereotype.Component import software.amazon.awssdk.services.s3.model.S3Object import java.time.Duration @@ -60,6 +61,30 @@ class ImageS3Processor( } ?: listPresignedGets(imageFilePath) // 아니면 해당 경로 아래 모든 이미지 탐색 } + override fun uploadImage( + prefix: String, + prefixId: Long, + subPath: String, + contentType: String, + bytes: ByteArray, + ): S3UploadResult { + val filePath = imageFileConstructor.imageFilePath(prefix, prefixId) + val fileName = imageFileConstructor.imageFileName() + val s3Key = "$filePath/$subPath/$fileName" + + awsS3Client.putObject( + bucketName = awsProperties.s3.bucket, + key = s3Key, + contentType = contentType, + bytes = bytes, + ) + + return S3UploadResult( + s3Key = s3Key, + publicUrl = "${awsProperties.s3.imageOriginUrl}/$s3Key", + ) + } + override suspend fun getPreviewImage( prefix: String, prefixId: Long, diff --git a/pida-clients/aws-client/src/main/kotlin/com/pida/client/aws/s3/AwsS3Client.kt b/pida-clients/aws-client/src/main/kotlin/com/pida/client/aws/s3/AwsS3Client.kt index 72478222..dcfab9c6 100644 --- a/pida-clients/aws-client/src/main/kotlin/com/pida/client/aws/s3/AwsS3Client.kt +++ b/pida-clients/aws-client/src/main/kotlin/com/pida/client/aws/s3/AwsS3Client.kt @@ -1,6 +1,7 @@ package com.pida.client.aws.s3 import org.springframework.stereotype.Component +import software.amazon.awssdk.core.sync.RequestBody import software.amazon.awssdk.services.s3.S3Client import software.amazon.awssdk.services.s3.model.GetObjectRequest import software.amazon.awssdk.services.s3.model.HeadObjectRequest @@ -126,6 +127,22 @@ class AwsS3Client( return s3Client.headObject(request).lastModified() } + fun putObject( + bucketName: String, + key: String, + contentType: String, + bytes: ByteArray, + ) { + val request = + PutObjectRequest + .builder() + .bucket(bucketName) + .key(key) + .contentType(contentType) + .build() + s3Client.putObject(request, RequestBody.fromBytes(bytes)) + } + fun getObjectAsBytes( bucketName: String, filePath: String, diff --git a/pida-core/core-api/src/main/kotlin/com/pida/presentation/v2/flowerevent/FlowerEventController.kt b/pida-core/core-api/src/main/kotlin/com/pida/presentation/v2/flowerevent/FlowerEventController.kt new file mode 100644 index 00000000..04225f01 --- /dev/null +++ b/pida-core/core-api/src/main/kotlin/com/pida/presentation/v2/flowerevent/FlowerEventController.kt @@ -0,0 +1,48 @@ +package com.pida.presentation.v2.flowerevent + +import com.pida.flowerevent.FlowerEventFacade +import com.pida.presentation.v2.flowerevent.request.FlowerEventCreateRequest +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.tags.Tag +import org.springframework.http.HttpStatus +import org.springframework.http.MediaType +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RequestPart +import org.springframework.web.bind.annotation.ResponseStatus +import org.springframework.web.bind.annotation.RestController +import org.springframework.web.multipart.MultipartFile + +@Tag(name = "🌸 Flower Spot Admin API", description = "관리자용 벚꽃 연관 데이터 관리 API") +@RestController +@RequestMapping("/admin") +class FlowerEventController( + private val flowerEventFacade: FlowerEventFacade, +) { + @PostMapping("/flower-event") + @ResponseStatus(HttpStatus.CREATED) + @Operation( + summary = "벚꽃 축제 일괄 등록", + description = "벚꽃 축제 데이터를 일괄 등록합니다. 썸네일 이미지는 등록 후 개별 업로드 API를 사용해주세요.", + ) + suspend fun addFlowerEvents( + @RequestBody data: List, + ) { + flowerEventFacade.addAll(data.map { it.toNewFlowerEvent() }) + } + + @PostMapping("/flower-event/{eventId}/thumbnail", consumes = [MediaType.MULTIPART_FORM_DATA_VALUE]) + @ResponseStatus(HttpStatus.CREATED) + @Operation( + summary = "벚꽃 축제 썸네일 업로드", + description = "벚꽃 축제 썸네일 이미지를 업로드합니다.", + ) + suspend fun uploadThumbnail( + @PathVariable eventId: Long, + @RequestPart("thumbnail") thumbnail: MultipartFile, + ) { + flowerEventFacade.uploadThumbnail(eventId, thumbnail.bytes) + } +} diff --git a/pida-core/core-api/src/main/kotlin/com/pida/presentation/v2/flowerevent/request/FlowerEventCreateRequest.kt b/pida-core/core-api/src/main/kotlin/com/pida/presentation/v2/flowerevent/request/FlowerEventCreateRequest.kt new file mode 100644 index 00000000..2b03f309 --- /dev/null +++ b/pida-core/core-api/src/main/kotlin/com/pida/presentation/v2/flowerevent/request/FlowerEventCreateRequest.kt @@ -0,0 +1,53 @@ +package com.pida.presentation.v2.flowerevent.request + +import com.pida.flowerevent.NewFlowerEvent +import com.pida.support.geo.Region +import io.swagger.v3.oas.annotations.media.Schema +import java.time.LocalDate + +@Schema(description = "꽃 이벤트 생성 요청") +data class FlowerEventCreateRequest( + @Schema(description = "이벤트 이름", example = "여의도 벚꽃축제") + val name: String, + @Schema(description = "주소", example = "서울특별시 영등포구 여의도동", required = false) + val address: String?, + @Schema(description = "경도 (longitude)", example = "126.9246") + val longitude: Double, + @Schema(description = "위도 (latitude)", example = "37.5284") + val latitude: Double, + @Schema(description = "지역", example = "SEOUL") + val region: Region, + @Schema(description = "홈페이지 URL", example = "https://example.com", required = false) + val homepageUrl: String?, + @Schema(description = "시작일", example = "2026-04-01") + val startDate: LocalDate, + @Schema(description = "종료일", example = "2026-04-07") + val endDate: LocalDate, + @Schema(description = "카테고리 ID", example = "1") + val categoryId: Long, +) { + init { + if (!homepageUrl.isNullOrBlank()) { + require(URL_PATTERN.matches(homepageUrl)) { + "Invalid homepage URL format: $homepageUrl" + } + } + } + + fun toNewFlowerEvent(): NewFlowerEvent = + NewFlowerEvent( + name = name, + address = address, + longitude = longitude, + latitude = latitude, + region = region, + homepageUrl = homepageUrl, + startDate = startDate, + endDate = endDate, + categoryId = categoryId, + ) + + companion object { + private val URL_PATTERN = Regex("^https?://[\\w\\-.]+(:\\d+)?(/\\S*)?$") + } +} diff --git a/pida-core/core-api/src/main/kotlin/com/pida/presentation/v2/flowerspotcafe/FlowerSpotCafeController.kt b/pida-core/core-api/src/main/kotlin/com/pida/presentation/v2/flowerspotcafe/FlowerSpotCafeController.kt new file mode 100644 index 00000000..a2516d15 --- /dev/null +++ b/pida-core/core-api/src/main/kotlin/com/pida/presentation/v2/flowerspotcafe/FlowerSpotCafeController.kt @@ -0,0 +1,48 @@ +package com.pida.presentation.v2.flowerspotcafe + +import com.pida.flowerspot.FlowerSpotCafeFacade +import com.pida.presentation.v2.flowerspotcafe.request.FlowerSpotCafeCreateRequest +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.tags.Tag +import org.springframework.http.HttpStatus +import org.springframework.http.MediaType +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RequestPart +import org.springframework.web.bind.annotation.ResponseStatus +import org.springframework.web.bind.annotation.RestController +import org.springframework.web.multipart.MultipartFile + +@Tag(name = "🌸 Flower Spot Admin API", description = "관리자용 벚꽃 연관 데이터 관리 API") +@RestController +@RequestMapping("/admin") +class FlowerSpotCafeController( + private val flowerSpotCafeFacade: FlowerSpotCafeFacade, +) { + @PostMapping("/flower-spot-cafe") + @ResponseStatus(HttpStatus.CREATED) + @Operation( + summary = "벚꽃 명소 카페 일괄 등록", + description = "벚꽃 명소 카페 데이터를 일괄 등록합니다. 썸네일 이미지는 등록 후 개별 업로드 API를 사용해주세요.", + ) + suspend fun addFlowerSpotCafes( + @RequestBody data: List, + ) { + flowerSpotCafeFacade.addAll(data.map { it.toNewFlowerSpotCafe() }) + } + + @PostMapping("/flower-spot-cafe/{cafeId}/thumbnail", consumes = [MediaType.MULTIPART_FORM_DATA_VALUE]) + @ResponseStatus(HttpStatus.CREATED) + @Operation( + summary = "벚꽃 명소 카페 썸네일 업로드", + description = "벚꽃 명소 카페 썸네일 이미지를 업로드합니다.", + ) + suspend fun uploadThumbnail( + @PathVariable cafeId: Long, + @RequestPart("thumbnail") thumbnail: MultipartFile, + ) { + flowerSpotCafeFacade.uploadThumbnail(cafeId, thumbnail.bytes) + } +} diff --git a/pida-core/core-api/src/main/kotlin/com/pida/presentation/v2/flowerspotcafe/request/FlowerSpotCafeCreateRequest.kt b/pida-core/core-api/src/main/kotlin/com/pida/presentation/v2/flowerspotcafe/request/FlowerSpotCafeCreateRequest.kt new file mode 100644 index 00000000..8de89ad3 --- /dev/null +++ b/pida-core/core-api/src/main/kotlin/com/pida/presentation/v2/flowerspotcafe/request/FlowerSpotCafeCreateRequest.kt @@ -0,0 +1,49 @@ +package com.pida.presentation.v2.flowerspotcafe.request + +import com.pida.flowerspot.NewFlowerSpotCafe +import com.pida.support.geo.Region +import io.swagger.v3.oas.annotations.media.Schema + +@Schema(description = "꽃 명소 카페 생성 요청") +data class FlowerSpotCafeCreateRequest( + @Schema(description = "꽃 명소 ID", example = "1") + val flowerSpotId: Long, + @Schema(description = "카페 이름", example = "벚꽃 카페") + val name: String, + @Schema(description = "주소", example = "서울특별시 영등포구 여의도동", required = false) + val address: String?, + @Schema(description = "설명", example = "벚꽃 명소 근처 분위기 좋은 카페", required = false) + val description: String?, + @Schema(description = "경도 (longitude)", example = "126.9246") + val longitude: Double, + @Schema(description = "위도 (latitude)", example = "37.5284") + val latitude: Double, + @Schema(description = "지역", example = "SEOUL") + val region: Region, + @Schema(description = "지도 URL", example = "https://map.naver.com/example", required = false) + val mapUrl: String?, +) { + init { + if (!mapUrl.isNullOrBlank()) { + require(URL_PATTERN.matches(mapUrl)) { + "Invalid map URL format: $mapUrl" + } + } + } + + fun toNewFlowerSpotCafe(): NewFlowerSpotCafe = + NewFlowerSpotCafe( + flowerSpotId = flowerSpotId, + name = name, + address = address, + description = description, + longitude = longitude, + latitude = latitude, + region = region, + mapUrl = mapUrl, + ) + + companion object { + private val URL_PATTERN = Regex("^https?://[\\w\\-.]+(:\\d+)?(/\\S*)?$") + } +} diff --git a/pida-core/core-domain/src/main/kotlin/com/pida/flowerevent/FlowerEventAppender.kt b/pida-core/core-domain/src/main/kotlin/com/pida/flowerevent/FlowerEventAppender.kt new file mode 100644 index 00000000..fd945df9 --- /dev/null +++ b/pida-core/core-domain/src/main/kotlin/com/pida/flowerevent/FlowerEventAppender.kt @@ -0,0 +1,17 @@ +package com.pida.flowerevent + +import org.springframework.stereotype.Component + +@Component +class FlowerEventAppender( + private val flowerEventRepository: FlowerEventRepository, +) { + suspend fun add(event: FlowerEvent): FlowerEvent = flowerEventRepository.save(event) + + suspend fun updateThumbnailUrl( + eventId: Long, + thumbnailUrl: String, + ) { + flowerEventRepository.updateThumbnailUrl(eventId, thumbnailUrl) + } +} diff --git a/pida-core/core-domain/src/main/kotlin/com/pida/flowerevent/FlowerEventFacade.kt b/pida-core/core-domain/src/main/kotlin/com/pida/flowerevent/FlowerEventFacade.kt new file mode 100644 index 00000000..0c943602 --- /dev/null +++ b/pida-core/core-domain/src/main/kotlin/com/pida/flowerevent/FlowerEventFacade.kt @@ -0,0 +1,31 @@ +package com.pida.flowerevent + +import com.pida.support.aws.ImageS3Caller +import org.springframework.stereotype.Service + +@Service +class FlowerEventFacade( + private val flowerEventFinder: FlowerEventFinder, + private val flowerEventAppender: FlowerEventAppender, + private val imageS3Caller: ImageS3Caller, +) { + suspend fun addAll(requests: List) = requests.forEach { flowerEventAppender.add(it.toDomain()) } + + suspend fun uploadThumbnail( + eventId: Long, + imageBytes: ByteArray, + ) { + flowerEventFinder.readBy(eventId) + + val uploadResult = + imageS3Caller.uploadImage( + prefix = "flowerevent", + prefixId = eventId, + subPath = "thumbnail", + contentType = "image/jpeg", + bytes = imageBytes, + ) + + flowerEventAppender.updateThumbnailUrl(eventId, uploadResult.publicUrl) + } +} diff --git a/pida-core/core-domain/src/main/kotlin/com/pida/flowerevent/FlowerEventRepository.kt b/pida-core/core-domain/src/main/kotlin/com/pida/flowerevent/FlowerEventRepository.kt index 9e19e071..e1df3764 100644 --- a/pida-core/core-domain/src/main/kotlin/com/pida/flowerevent/FlowerEventRepository.kt +++ b/pida-core/core-domain/src/main/kotlin/com/pida/flowerevent/FlowerEventRepository.kt @@ -17,4 +17,11 @@ interface FlowerEventRepository { longitude: Double, radiusMeters: Double, ): List + + suspend fun save(event: FlowerEvent): FlowerEvent + + suspend fun updateThumbnailUrl( + eventId: Long, + thumbnailUrl: String, + ) } diff --git a/pida-core/core-domain/src/main/kotlin/com/pida/flowerevent/NewFlowerEvent.kt b/pida-core/core-domain/src/main/kotlin/com/pida/flowerevent/NewFlowerEvent.kt new file mode 100644 index 00000000..8383cc6f --- /dev/null +++ b/pida-core/core-domain/src/main/kotlin/com/pida/flowerevent/NewFlowerEvent.kt @@ -0,0 +1,32 @@ +package com.pida.flowerevent + +import com.pida.support.geo.GeoJson +import com.pida.support.geo.Region +import java.time.LocalDate + +data class NewFlowerEvent( + val name: String, + val address: String?, + val longitude: Double, + val latitude: Double, + val region: Region, + val homepageUrl: String?, + val startDate: LocalDate, + val endDate: LocalDate, + val categoryId: Long, +) { + fun toDomain(): FlowerEvent = + FlowerEvent( + id = 0, + name = name, + address = address, + thumbnailUrl = null, + pinPoint = GeoJson.Point(listOf(longitude, latitude)), + region = region, + homepageUrl = homepageUrl, + startDate = startDate, + endDate = endDate, + categoryId = categoryId, + deletedAt = null, + ) +} diff --git a/pida-core/core-domain/src/main/kotlin/com/pida/flowerspot/FlowerSpotCafeAppender.kt b/pida-core/core-domain/src/main/kotlin/com/pida/flowerspot/FlowerSpotCafeAppender.kt new file mode 100644 index 00000000..9a050848 --- /dev/null +++ b/pida-core/core-domain/src/main/kotlin/com/pida/flowerspot/FlowerSpotCafeAppender.kt @@ -0,0 +1,17 @@ +package com.pida.flowerspot + +import org.springframework.stereotype.Component + +@Component +class FlowerSpotCafeAppender( + private val flowerSpotCafeRepository: FlowerSpotCafeRepository, +) { + suspend fun add(cafe: FlowerSpotCafe): FlowerSpotCafe = flowerSpotCafeRepository.save(cafe) + + suspend fun updateThumbnailUrl( + cafeId: Long, + thumbnailUrl: String, + ) { + flowerSpotCafeRepository.updateThumbnailUrl(cafeId, thumbnailUrl) + } +} diff --git a/pida-core/core-domain/src/main/kotlin/com/pida/flowerspot/FlowerSpotCafeFacade.kt b/pida-core/core-domain/src/main/kotlin/com/pida/flowerspot/FlowerSpotCafeFacade.kt new file mode 100644 index 00000000..bff2a005 --- /dev/null +++ b/pida-core/core-domain/src/main/kotlin/com/pida/flowerspot/FlowerSpotCafeFacade.kt @@ -0,0 +1,31 @@ +package com.pida.flowerspot + +import com.pida.support.aws.ImageS3Caller +import org.springframework.stereotype.Service + +@Service +class FlowerSpotCafeFacade( + private val flowerSpotCafeFinder: FlowerSpotCafeFinder, + private val flowerSpotCafeAppender: FlowerSpotCafeAppender, + private val imageS3Caller: ImageS3Caller, +) { + suspend fun addAll(requests: List) = requests.forEach { flowerSpotCafeAppender.add(it.toDomain()) } + + suspend fun uploadThumbnail( + cafeId: Long, + imageBytes: ByteArray, + ) { + flowerSpotCafeFinder.readBy(cafeId) + + val uploadResult = + imageS3Caller.uploadImage( + prefix = "flowerspotcafe", + prefixId = cafeId, + subPath = "thumbnail", + contentType = "image/jpeg", + bytes = imageBytes, + ) + + flowerSpotCafeAppender.updateThumbnailUrl(cafeId, uploadResult.publicUrl) + } +} diff --git a/pida-core/core-domain/src/main/kotlin/com/pida/flowerspot/FlowerSpotCafeRepository.kt b/pida-core/core-domain/src/main/kotlin/com/pida/flowerspot/FlowerSpotCafeRepository.kt index 56741d6f..99db6247 100644 --- a/pida-core/core-domain/src/main/kotlin/com/pida/flowerspot/FlowerSpotCafeRepository.kt +++ b/pida-core/core-domain/src/main/kotlin/com/pida/flowerspot/FlowerSpotCafeRepository.kt @@ -8,4 +8,11 @@ interface FlowerSpotCafeRepository { suspend fun findAllByLocation(location: FlowerSpotLocation): List suspend fun findAllByFlowerSpotId(flowerSpotId: Long): List + + suspend fun save(cafe: FlowerSpotCafe): FlowerSpotCafe + + suspend fun updateThumbnailUrl( + cafeId: Long, + thumbnailUrl: String, + ) } diff --git a/pida-core/core-domain/src/main/kotlin/com/pida/flowerspot/NewFlowerSpotCafe.kt b/pida-core/core-domain/src/main/kotlin/com/pida/flowerspot/NewFlowerSpotCafe.kt new file mode 100644 index 00000000..2bb7dfda --- /dev/null +++ b/pida-core/core-domain/src/main/kotlin/com/pida/flowerspot/NewFlowerSpotCafe.kt @@ -0,0 +1,29 @@ +package com.pida.flowerspot + +import com.pida.support.geo.GeoJson +import com.pida.support.geo.Region + +data class NewFlowerSpotCafe( + val flowerSpotId: Long, + val name: String, + val address: String?, + val description: String?, + val longitude: Double, + val latitude: Double, + val region: Region, + val mapUrl: String?, +) { + fun toDomain(): FlowerSpotCafe = + FlowerSpotCafe( + id = 0, + flowerSpotId = flowerSpotId, + name = name, + address = address, + description = description, + thumbnailUrl = null, + pinPoint = GeoJson.Point(listOf(longitude, latitude)), + region = region, + mapUrl = mapUrl, + deletedAt = null, + ) +} diff --git a/pida-core/core-domain/src/main/kotlin/com/pida/support/aws/ImageS3Caller.kt b/pida-core/core-domain/src/main/kotlin/com/pida/support/aws/ImageS3Caller.kt index 8a782b5f..aca86182 100644 --- a/pida-core/core-domain/src/main/kotlin/com/pida/support/aws/ImageS3Caller.kt +++ b/pida-core/core-domain/src/main/kotlin/com/pida/support/aws/ImageS3Caller.kt @@ -32,4 +32,22 @@ interface ImageS3Caller { prefix: String, prefixId: Long, ): S3ImageInfo? + + /** + * 서버 사이드 이미지 업로드 + * + * @param prefix [String] 도메인 + * @param prefixId [Long] 도메인 ID + * @param subPath [String] 추가 하위 경로 (e.g. "thumbnail") + * @param contentType [String] 이미지 Content-Type + * @param bytes [ByteArray] 이미지 데이터 + * @return 업로드된 이미지의 공개 URL + */ + fun uploadImage( + prefix: String, + prefixId: Long, + subPath: String, + contentType: String, + bytes: ByteArray, + ): S3UploadResult } diff --git a/pida-core/core-domain/src/main/kotlin/com/pida/support/aws/S3UploadResult.kt b/pida-core/core-domain/src/main/kotlin/com/pida/support/aws/S3UploadResult.kt new file mode 100644 index 00000000..8f207746 --- /dev/null +++ b/pida-core/core-domain/src/main/kotlin/com/pida/support/aws/S3UploadResult.kt @@ -0,0 +1,6 @@ +package com.pida.support.aws + +data class S3UploadResult( + val s3Key: String, + val publicUrl: String, +) diff --git a/pida-storage/db-core/src/main/kotlin/com/pida/storage/db/core/flowerevent/FlowerEventCoreRepository.kt b/pida-storage/db-core/src/main/kotlin/com/pida/storage/db/core/flowerevent/FlowerEventCoreRepository.kt index da5253c8..4701b548 100644 --- a/pida-storage/db-core/src/main/kotlin/com/pida/storage/db/core/flowerevent/FlowerEventCoreRepository.kt +++ b/pida-storage/db-core/src/main/kotlin/com/pida/storage/db/core/flowerevent/FlowerEventCoreRepository.kt @@ -4,15 +4,24 @@ import com.pida.flowerevent.FlowerEvent import com.pida.flowerevent.FlowerEventRepository import com.pida.flowerspot.FlowerSpotLocation import com.pida.storage.db.core.support.findByIdAndDeletedAtIsNullOrElseThrow +import com.pida.support.geo.GeoJson import com.pida.support.tx.TransactionTemplates import com.pida.support.tx.coExecute +import org.locationtech.jts.geom.Coordinate +import org.locationtech.jts.geom.GeometryFactory +import org.locationtech.jts.geom.PrecisionModel import org.springframework.stereotype.Repository @Repository class FlowerEventCoreRepository( private val flowerEventJpaRepository: FlowerEventJpaRepository, + private val flowerEventCustomRepository: FlowerEventCustomRepository, private val tx: TransactionTemplates, ) : FlowerEventRepository { + companion object { + private val GEOMETRY_FACTORY = GeometryFactory(PrecisionModel(), 4326) + } + override suspend fun findBy(eventId: Long): FlowerEvent = tx.reader.coExecute { flowerEventJpaRepository @@ -52,4 +61,31 @@ class FlowerEventCoreRepository( .findWithinRadius(latitude, longitude, radiusMeters) .map { it.toFlowerEvent() } } + + override suspend fun save(event: FlowerEvent): FlowerEvent = + tx.writer.coExecute { + val point = event.pinPoint as GeoJson.Point + val entity = + FlowerEventEntity( + name = event.name, + address = event.address, + thumbnailUrl = event.thumbnailUrl, + pinPoint = GEOMETRY_FACTORY.createPoint(Coordinate(point.coordinates[0], point.coordinates[1])), + region = event.region, + homepageUrl = event.homepageUrl, + startDate = event.startDate, + endDate = event.endDate, + categoryId = event.categoryId, + ) + flowerEventJpaRepository.save(entity).toFlowerEvent() + } + + override suspend fun updateThumbnailUrl( + eventId: Long, + thumbnailUrl: String, + ) { + tx.writer.coExecute { + flowerEventCustomRepository.updateThumbnailUrl(eventId, thumbnailUrl) + } + } } diff --git a/pida-storage/db-core/src/main/kotlin/com/pida/storage/db/core/flowerevent/FlowerEventCustomRepository.kt b/pida-storage/db-core/src/main/kotlin/com/pida/storage/db/core/flowerevent/FlowerEventCustomRepository.kt new file mode 100644 index 00000000..2b59a767 --- /dev/null +++ b/pida-storage/db-core/src/main/kotlin/com/pida/storage/db/core/flowerevent/FlowerEventCustomRepository.kt @@ -0,0 +1,27 @@ +package com.pida.storage.db.core.flowerevent + +import com.linecorp.kotlinjdsl.dsl.jpql.jpql +import com.linecorp.kotlinjdsl.render.RenderContext +import com.linecorp.kotlinjdsl.support.spring.data.jpa.extension.createQuery +import jakarta.persistence.EntityManager +import org.springframework.stereotype.Repository + +@Repository +class FlowerEventCustomRepository( + private val entityManager: EntityManager, + private val jdslRenderContext: RenderContext, +) { + fun updateThumbnailUrl( + eventId: Long, + thumbnailUrl: String, + ) { + val query = + jpql { + update(entity(FlowerEventEntity::class)) + .set(path(FlowerEventEntity::thumbnailUrl), thumbnailUrl) + .where(path(FlowerEventEntity::id).eq(eventId)) + } + + entityManager.createQuery(query, jdslRenderContext).executeUpdate() + } +} diff --git a/pida-storage/db-core/src/main/kotlin/com/pida/storage/db/core/flowerspot/FlowerSpotCafeCoreRepository.kt b/pida-storage/db-core/src/main/kotlin/com/pida/storage/db/core/flowerspot/FlowerSpotCafeCoreRepository.kt index 0fda801e..07ea8bdd 100644 --- a/pida-storage/db-core/src/main/kotlin/com/pida/storage/db/core/flowerspot/FlowerSpotCafeCoreRepository.kt +++ b/pida-storage/db-core/src/main/kotlin/com/pida/storage/db/core/flowerspot/FlowerSpotCafeCoreRepository.kt @@ -4,15 +4,24 @@ import com.pida.flowerspot.FlowerSpotCafe import com.pida.flowerspot.FlowerSpotCafeRepository import com.pida.flowerspot.FlowerSpotLocation import com.pida.storage.db.core.support.findByIdAndDeletedAtIsNullOrElseThrow +import com.pida.support.geo.GeoJson import com.pida.support.tx.TransactionTemplates import com.pida.support.tx.coExecute +import org.locationtech.jts.geom.Coordinate +import org.locationtech.jts.geom.GeometryFactory +import org.locationtech.jts.geom.PrecisionModel import org.springframework.stereotype.Repository @Repository class FlowerSpotCafeCoreRepository( private val flowerSpotCafeJpaRepository: FlowerSpotCafeJpaRepository, + private val flowerSpotCafeCustomRepository: FlowerSpotCafeCustomRepository, private val tx: TransactionTemplates, ) : FlowerSpotCafeRepository { + companion object { + private val GEOMETRY_FACTORY = GeometryFactory(PrecisionModel(), 4326) + } + override suspend fun findBy(cafeId: Long): FlowerSpotCafe = tx.reader.coExecute { flowerSpotCafeJpaRepository @@ -44,4 +53,30 @@ class FlowerSpotCafeCoreRepository( .findByFlowerSpotIdAndDeletedAtIsNull(flowerSpotId) .map { it.toFlowerSpotCafe() } } + + override suspend fun save(cafe: FlowerSpotCafe): FlowerSpotCafe = + tx.writer.coExecute { + val point = cafe.pinPoint as GeoJson.Point + val entity = + FlowerSpotCafeEntity( + flowerSpotId = cafe.flowerSpotId, + name = cafe.name, + address = cafe.address, + description = cafe.description, + thumbnailUrl = cafe.thumbnailUrl, + pinPoint = GEOMETRY_FACTORY.createPoint(Coordinate(point.coordinates[0], point.coordinates[1])), + region = cafe.region, + mapUrl = cafe.mapUrl, + ) + flowerSpotCafeJpaRepository.save(entity).toFlowerSpotCafe() + } + + override suspend fun updateThumbnailUrl( + cafeId: Long, + thumbnailUrl: String, + ) { + tx.writer.coExecute { + flowerSpotCafeCustomRepository.updateThumbnailUrl(cafeId, thumbnailUrl) + } + } } diff --git a/pida-storage/db-core/src/main/kotlin/com/pida/storage/db/core/flowerspot/FlowerSpotCafeCustomRepository.kt b/pida-storage/db-core/src/main/kotlin/com/pida/storage/db/core/flowerspot/FlowerSpotCafeCustomRepository.kt new file mode 100644 index 00000000..add6c641 --- /dev/null +++ b/pida-storage/db-core/src/main/kotlin/com/pida/storage/db/core/flowerspot/FlowerSpotCafeCustomRepository.kt @@ -0,0 +1,27 @@ +package com.pida.storage.db.core.flowerspot + +import com.linecorp.kotlinjdsl.dsl.jpql.jpql +import com.linecorp.kotlinjdsl.render.RenderContext +import com.linecorp.kotlinjdsl.support.spring.data.jpa.extension.createQuery +import jakarta.persistence.EntityManager +import org.springframework.stereotype.Repository + +@Repository +class FlowerSpotCafeCustomRepository( + private val entityManager: EntityManager, + private val jdslRenderContext: RenderContext, +) { + fun updateThumbnailUrl( + cafeId: Long, + thumbnailUrl: String, + ) { + val query = + jpql { + update(entity(FlowerSpotCafeEntity::class)) + .set(path(FlowerSpotCafeEntity::thumbnailUrl), thumbnailUrl) + .where(path(FlowerSpotCafeEntity::id).eq(cafeId)) + } + + entityManager.createQuery(query, jdslRenderContext).executeUpdate() + } +} From a422c737245156dbc19f3bf64afe1ab7ed1b4818 Mon Sep 17 00:00:00 2001 From: char-yb Date: Wed, 25 Mar 2026 14:12:42 +0900 Subject: [PATCH 2/2] [Fix&Docs] Push Notification Documentation, Blooming ALterType --- docs/push-notification.md | 305 ++++++++++++++++++ .../NotificationTestController.kt | 9 +- .../notification/bloomed/BloomedAlertType.kt | 12 + ...oomedFirstVoteNotificationEventListener.kt | 2 +- .../BloomedNotificationMessageBuilder.kt | 22 +- .../bloomed/BloomedNotificationService.kt | 24 +- .../WeekdayNotificationEligibilityChecker.kt | 19 +- 7 files changed, 360 insertions(+), 33 deletions(-) create mode 100644 docs/push-notification.md create mode 100644 pida-core/core-domain/src/main/kotlin/com/pida/notification/bloomed/BloomedAlertType.kt diff --git a/docs/push-notification.md b/docs/push-notification.md new file mode 100644 index 00000000..e2e0952b --- /dev/null +++ b/docs/push-notification.md @@ -0,0 +1,305 @@ +# 푸시 알림(FCM) 시스템 문서 + +## 시스템 아키텍처 + +### 기술 스택 + +- **Firebase Cloud Messaging (FCM)**: Firebase Admin SDK를 통한 푸시 알림 발송 +- **Spring Scheduler**: `@Scheduled`를 사용한 정기 알림 스케줄링 +- **Spring Event**: `@TransactionalEventListener`를 통한 이벤트 기반 실시간 알림 + +### 발송 흐름 + +``` +[트리거] [서비스 레이어] [인프라 레이어] +스케줄러 / 이벤트 리스너 → NotificationService → FcmSender → FcmRepository → FirebaseCloudMessageSender → FCM + ├ EligibilityChecker (최대 3회 재시도) (MulticastMessage 배치 발송) + ├ MessageBuilder + └ NotificationStored 저장 +``` + +### 핵심 컴포넌트 + +| 컴포넌트 | 역할 | +|----------|------| +| `EligibilityChecker` | 알림 수신 대상 사용자 필터링 | +| `MessageBuilder` | FCM 메시지(title, body, destination) 생성 | +| `NotificationService` | 대상 조회 → 메시지 생성 → 발송 → 히스토리 저장 오케스트레이션 | +| `FcmSender` | FCM 발송 및 재시도 로직 (최대 3회, 실패 메시지만 재발송) | +| `NotificationStored` | 발송 히스토리 DB 저장 (중복 방지 및 읽음 상태 추적) | + +### FCM 메시지 구조 + +모든 알림은 아래 형식으로 발송됩니다: + +- **Notification**: `title`, `body` +- **APNS Config**: `destination` (클라이언트 화면 이동 대상), `badge: 1`, `sound: default` +- **destination**: 모든 알림이 `"home"`으로 설정 (홈 화면으로 이동) + +--- + +## 알림 유형 (`NotificationType`) + +| enum | 설명 | +|------|------| +| `BLOOMED_ALERT` | 지역별 만개 알림 (개화 초기 + 만개 절정) | +| `WITHERED_ALERT` | 지역별 저물었어요 알림 | +| `BLOOMED_SPOT_ALERT` | 벚꽃길 만개 알림 | +| `BLOOMED_EVENT_ALERT` | 꽃 이벤트 만개 알림 | +| `WEEKDAY_HEALING` | 평일 힐링 알림 | +| `WEEKEND_HEALING` | 주말 힐링 알림 | +| `RAIN_FORECAST_ALERT` | 비 예보 알림 | +| `ADMIN_PUSH` | 관리자 수동 푸시 | +| `REGULAR` | 정기 푸시 알림 | + +--- + +## 알림 케이스 상세 + +### 1. 내 주변 만개 알림 (`BLOOMED_SPOT_ALERT` / `BLOOMED_EVENT_ALERT`) + +#### 트리거 + +- 내 현재 위치 기준 **3km 이내 벚꽃길**(FlowerSpot)에 **'만개' 투표** 발생 +- 내 현재 위치 기준 **3km 이내 꽃 이벤트**(FlowerEvent)에 **'만개' 투표** 발생 + +> 이벤트 리스너(`BloomedSpotNotificationEventListener`, `BloomedEventNotificationEventListener`)를 통해 즉시 발송 + +#### 발송 대상 + +- **위치 권한을 허용한 사용자** 중 반경 **3km 이내**에 있는 사용자 + +#### 제외 대상 + +- 당일 이미 푸시를 받은 사용자 +- 해당 벚꽃길/이벤트로 이미 만개 알림을 받은 사용자 (벚꽃 시즌당 1회) + +#### 메시지 내용 + +**벚꽃길 (`BLOOMED_SPOT_ALERT`)** + +| 항목 | 내용 | +|------|------| +| title | `우리 동네에 만개한 벚꽃길이 생겼어요 🌸` | +| body | `지금 제일 예쁜 {벚꽃길 명} 확인해보세요.` | + +**꽃 이벤트 (`BLOOMED_EVENT_ALERT`)** + +| 항목 | 내용 | +|------|------| +| title | `우리 동네에 만개한 꽃 이벤트가 열렸어요 🌸` | +| body | `지금 {이벤트명} 확인해보세요.` | + +--- + +### 2. 비 예보 알림 (`RAIN_FORECAST_ALERT`) + +#### 트리거 + +- **내일 강수확률 60% 이상**인 경우 + +> 스케줄러(`EveningNotificationScheduler`) — **Cron**: `0 0 18 * * *` (매일 오후 6시) + +#### 발송 대상 + +- **최근 30일 내 앱 활동 이력** 있는 사용자 + +#### 제외 대상 + +- 당일 이미 다른 푸시를 받은 사용자 +- 당주 이미 비 예보 알림을 받은 사용자 (주 1회 제한) + +#### 메시지 내용 + +| 항목 | 내용 | +|------|------| +| title | `내일 비 소식이 있어요 ☔️` | +| body | `마지막 꽃구경 찬스! 오늘 밤 산책을 놓치지 마세요.` | + +--- + +### 3. 개화 초기 알림 (`BLOOMED_ALERT` — 첫 투표) + +#### 트리거 + +- 사용자 접속 위치가 속한 **광역 자치단체(시/도)**에 **'만개했어요' 첫 투표** 시 + +> 이벤트 리스너(`BloomedFirstVoteNotificationEventListener`)를 통해 즉시 발송 + +#### 발송 대상 + +- 해당 광역 자치단체(시/도)에 **최근 접속 기록**이 있는 사용자 + +#### 제외 대상 + +- 해당 지역을 벗어난 사용자 +- 해당 년도 내에 이미 알림을 **1회 받은** 사용자 (연 1회 제한) + +#### 메시지 내용 + +| 항목 | 내용 | +|------|------| +| title | `{지역명}에도 벚꽃이 눈을 떴어요! 🌸` | +| body | `우리 동네 가장 빠른 봄을 만나보세요.` | + +--- + +### 4. 만개 알림 (`BLOOMED_ALERT` — 80% 이상) + +#### 트리거 + +- 사용자 접속 위치가 속한 **광역 자치단체(시/도)**에 **'만개했어요'가 전체 투표 중 80% 이상**일 시 + +> 스케줄러(`BloomedNotificationScheduler`) — **Cron**: `0 0 9 * * *` (매일 오전 9시) + +#### 발송 대상 + +- 해당 광역 자치단체(시/도)에 **최근 접속 기록**이 있는 사용자 + +#### 제외 대상 + +- 해당 지역을 벗어난 사용자 +- 해당 년도 내에 이미 알림을 **1회 받은** 사용자 (연 1회 제한) + +#### 메시지 내용 + +| 항목 | 내용 | +|------|------| +| title | `지금이 절정! 1년 중 가장 예쁜 {지역명} 벚꽃의 만개 순간, 놓치면 후회해요. 🌸` | +| body | *(title에 통합)* | + +--- + +### 5. 낙화 알림 (`WITHERED_ALERT`) + +#### 트리거 + +- 사용자 접속 위치가 속한 **광역 자치단체(시/도)**에 **'저물었어요'가 전체 투표 중 30% 이상**일 시 + +> 스케줄러(`WitheredNotificationScheduler`) — **Cron**: `0 0 9 * * *` (매일 오전 9시), 하루 1회만 실행 + +#### 발송 대상 + +- 해당 광역 자치단체(시/도)에 **최근 접속 기록**이 있는 사용자 + +#### 제외 대상 + +- 해당 지역을 벗어난 사용자 +- 해당 년도 내에 이미 알림을 **1회 받은** 사용자 (연 1회 제한) + +#### 메시지 내용 + +| 항목 | 내용 | +|------|------| +| title | `이번 주말이 지나면 늦을 지도 몰라요. 🌸` | +| body | `엔딩 크레딧 올라가기 전, 마지막 벚꽃 산책 어때요?` | + +--- + +### 6. 퇴근길 알림 (`WEEKDAY_HEALING`) + +#### 트리거 + +- **평일(월~금) 중 랜덤으로 2회**, 오후 6시일 경우 +- 그리고 사용자 접속 위치가 **개화 시기**일 경우 + +> 스케줄러(`EveningNotificationScheduler` → `WeekdayNotificationScheduler`) — **Cron**: `0 0 18 * * *` +> - 월~목: 40% 확률로 실행 (목요일에 아직 0회이면 60% 확률로 부스트) +> - 금요일: 남은 횟수만큼 연속 실행 (보장) + +#### 발송 대상 + +- **최근 30일 내 앱 활동 이력** 있는 사용자 + +#### 제외 대상 + +- 해당 주에 이미 **2회의 알림**을 받은 사용자 + +#### 메시지 내용 + +| 항목 | 내용 | +|------|------| +| title | `오늘 하루도 고생했어요. 🌙` | +| body | `퇴근길은 가까운 벚꽃 구경 어때요?` | + +--- + +### 7. 주말 알림 (`WEEKEND_HEALING`) + +#### 트리거 + +- **주말(토, 일) 중 랜덤으로 1회** 오전 10시일 경우 +- 그리고 사용자 접속 위치가 **개화 시기**일 경우 + +> 스케줄러(`WeekendNotificationScheduler`) — **Cron**: `0 0 10 ? * SAT,SUN` +> - 주차 key 기준으로 실행 요일 결정 (짝수주 → 토요일, 홀수주 → 일요일) + +#### 발송 대상 + +- **최근 30일 내 앱 활동 이력** 있는 사용자 + +#### 제외 대상 + +- 사용자가 있는 위치의 **미세먼지 '나쁨' 이상**일 때 (PM10 >= 81µg/m³) + +#### 메시지 내용 + +| 항목 | 내용 | +|------|------| +| title | `이번 주도 치열하게 보낸 당신에게 🎁` | +| body | `걷기만 해도 힐링되는 벚꽃길이 기다려요.` | + +--- + +### 8. 관리자 푸시 (`ADMIN_PUSH`) + +- 관리자가 수동으로 발송하는 알림 +- 별도의 자동 트리거 없음 + +--- + +## 스케줄링 요약 + +| 시간 | 알림 | 주기 제한 | 비고 | +|------|------|----------|------| +| 실시간 | BLOOMED_SPOT_ALERT | 벚꽃 시즌 1회/벚꽃길 | BLOOMED 투표 이벤트 기반 | +| 실시간 | BLOOMED_EVENT_ALERT | 벚꽃 시즌 1회/이벤트 | BLOOMED 투표 이벤트 기반 | +| 매일 18:00 | RAIN_FORECAST_ALERT | 주 1회/사용자 | 내일 POP 60% 이상 시 | +| 실시간 | BLOOMED_ALERT (첫 투표) | 연 1회/사용자 | 지역 첫 BLOOMED 투표 시 | +| 매일 09:00 | BLOOMED_ALERT (80%) | 연 1회/사용자 | 지역 BLOOMED 80% 이상 시 | +| 매일 09:00 | WITHERED_ALERT | 연 1회/사용자 | 지역 WITHERED 30% 이상 시 | +| 매일 18:00 | WEEKDAY_HEALING | 주 2회 | 평일만, 확률 기반 + 금요일 보장 | +| 토/일 10:00 | WEEKEND_HEALING | 주 1회 | 짝수주-토, 홀수주-일 | + +## 중복 방지 메커니즘 + +| 메커니즘 | 적용 대상 | 설명 | +|---------|----------|------| +| 연 1회 | BLOOMED_ALERT, WITHERED_ALERT | 올해 1/1 이후 수신 이력 체크 | +| 벚꽃 시즌 1회/대상 | BLOOMED_SPOT_ALERT, BLOOMED_EVENT_ALERT | 같은 연도 내 동일 spot/event에 대해 1회 | +| 일 1회 | BLOOMED_SPOT_ALERT, BLOOMED_EVENT_ALERT | 당일 어떤 푸시도 받지 않은 사용자만 | +| 주 1회 | WEEKEND_HEALING, RAIN_FORECAST_ALERT | 주차 key 또는 월요일 기준 | +| 주 2회 | WEEKDAY_HEALING | 확률 기반 + 금요일 보장으로 정확히 2회 | +| 당일 다른 알림 제외 | RAIN_FORECAST_ALERT | 당일 다른 종류의 푸시를 받은 사용자 제외 | +| 분산 락 | 저녁 알림(18:00) | `NotificationExecutionLock`으로 다중 인스턴스 중복 실행 방지 | + +## 재시도 정책 + +- `FcmSender`에서 **최대 3회** 재시도 +- 실패한 메시지만 선별하여 재발송 +- 성공/실패 결과를 토큰별로 추적 + +## 테스트 API + +수동 트리거용 테스트 엔드포인트 (`NotificationTestController`): + +| Method | Path | 파라미터 | +|--------|------|---------| +| POST | `/test/weekend-notification` | - | +| POST | `/test/weekday-notification` | - | +| POST | `/test/withered-notification` | - | +| POST | `/test/bloomed-notification` | `region` (query), `alertType` (query, default: FIRST_VOTE) | +| POST | `/test/bloomed-spot-notification` | `spotId` (query) | +| POST | `/test/bloomed-event-notification` | `eventId` (query) | +| POST | `/test/rain-forecast-notification` | - | + diff --git a/pida-core/core-api/src/main/kotlin/com/pida/presentation/v1/notification/NotificationTestController.kt b/pida-core/core-api/src/main/kotlin/com/pida/presentation/v1/notification/NotificationTestController.kt index 2b7803c5..aafc616b 100644 --- a/pida-core/core-api/src/main/kotlin/com/pida/presentation/v1/notification/NotificationTestController.kt +++ b/pida-core/core-api/src/main/kotlin/com/pida/presentation/v1/notification/NotificationTestController.kt @@ -1,5 +1,6 @@ package com.pida.presentation.v1.notification +import com.pida.notification.bloomed.BloomedAlertType import com.pida.notification.bloomed.BloomedNotificationService import com.pida.notification.bloomedevent.BloomedEventNotificationService import com.pida.notification.bloomedspot.BloomedSpotNotificationService @@ -83,17 +84,19 @@ class NotificationTestController( summary = "만개했어요 알림 수동 트리거", description = "BLOOMED 상태 푸시 알림을 수동으로 발송합니다. (테스트용)\n\n" + - "입력한 지역의 사용자들에게 BLOOMED 알림을 수동 발송합니다.", + "입력한 지역의 사용자들에게 BLOOMED 알림을 수동 발송합니다.\n\n" + + "alertType: FIRST_VOTE(개화 초기), THRESHOLD_REACHED(만개 절정)", ) fun triggerBloomedNotification( @RequestParam region: Region, + @RequestParam(defaultValue = "FIRST_VOTE") alertType: BloomedAlertType, ): NotificationTriggerResponse { val startTime = LocalDateTime.now() - bloomedNotificationService.sendBloomedNotificationForRegion(region) + bloomedNotificationService.sendBloomedNotificationForRegion(region, alertType) return NotificationTriggerResponse( - message = "Bloomed notification triggered successfully", + message = "Bloomed notification triggered successfully (alertType=$alertType)", triggeredAt = startTime, ) } diff --git a/pida-core/core-domain/src/main/kotlin/com/pida/notification/bloomed/BloomedAlertType.kt b/pida-core/core-domain/src/main/kotlin/com/pida/notification/bloomed/BloomedAlertType.kt new file mode 100644 index 00000000..2ac9a4bd --- /dev/null +++ b/pida-core/core-domain/src/main/kotlin/com/pida/notification/bloomed/BloomedAlertType.kt @@ -0,0 +1,12 @@ +package com.pida.notification.bloomed + +/** + * BLOOMED 알림 유형 + * + * - FIRST_VOTE: 개화 초기 — 지역의 첫 BLOOMED 투표 시 발송 + * - THRESHOLD_REACHED: 만개 절정 — 지역 BLOOMED 비율 80% 이상 시 발송 + */ +enum class BloomedAlertType { + FIRST_VOTE, + THRESHOLD_REACHED, +} diff --git a/pida-core/core-domain/src/main/kotlin/com/pida/notification/bloomed/BloomedFirstVoteNotificationEventListener.kt b/pida-core/core-domain/src/main/kotlin/com/pida/notification/bloomed/BloomedFirstVoteNotificationEventListener.kt index cdf65880..c1219135 100644 --- a/pida-core/core-domain/src/main/kotlin/com/pida/notification/bloomed/BloomedFirstVoteNotificationEventListener.kt +++ b/pida-core/core-domain/src/main/kotlin/com/pida/notification/bloomed/BloomedFirstVoteNotificationEventListener.kt @@ -39,7 +39,7 @@ class BloomedFirstVoteNotificationEventListener( return } - bloomedNotificationService.sendBloomedNotificationForRegion(targetRegion) + bloomedNotificationService.sendBloomedNotificationForRegion(targetRegion, BloomedAlertType.FIRST_VOTE) } private fun readTargetRegion(newBlooming: NewBlooming): Region? = diff --git a/pida-core/core-domain/src/main/kotlin/com/pida/notification/bloomed/BloomedNotificationMessageBuilder.kt b/pida-core/core-domain/src/main/kotlin/com/pida/notification/bloomed/BloomedNotificationMessageBuilder.kt index b8303388..21a28488 100644 --- a/pida-core/core-domain/src/main/kotlin/com/pida/notification/bloomed/BloomedNotificationMessageBuilder.kt +++ b/pida-core/core-domain/src/main/kotlin/com/pida/notification/bloomed/BloomedNotificationMessageBuilder.kt @@ -7,7 +7,7 @@ import org.springframework.stereotype.Component /** * BLOOMED 알림 메시지 빌더 * - * 만개했어요 상태 알림의 FCM 메시지를 생성합니다. + * 개화 초기(첫 투표)와 만개 절정(80% 이상) 케이스에 따라 다른 메시지를 생성합니다. */ @Component class BloomedNotificationMessageBuilder { @@ -20,18 +20,20 @@ class BloomedNotificationMessageBuilder { * * @param fcmToken FCM 토큰 * @param region 지역 + * @param alertType 알림 유형 (개화 초기 / 만개 절정) * @return FCM 메시지 */ fun buildMessage( fcmToken: String, region: Region, + alertType: BloomedAlertType = BloomedAlertType.FIRST_VOTE, ): NewFirebaseCloudMessage { - val messageContent = getMessageContent(region) + val messageContent = getMessageContent(region, alertType) return NewFirebaseCloudMessage( fcmToken = fcmToken, - title = messageContent.lines()[0], // 첫 번째 줄을 제목으로 - body = messageContent.lines().drop(1).joinToString("\n"), // 나머지를 본문으로 + title = messageContent.lines()[0], + body = messageContent.lines().drop(1).joinToString("\n"), destination = DESTINATION, ) } @@ -40,7 +42,17 @@ class BloomedNotificationMessageBuilder { * 메시지 내용 반환 (NotificationStored 저장용) * * @param region 지역 + * @param alertType 알림 유형 (개화 초기 / 만개 절정) * @return 메시지 전체 내용 */ - fun getMessageContent(region: Region): String = "${region.toKoreanName()}에도 벚꽃이 눈을 떴어요! 🌸\n우리 동네 가장 빠른 봄을 만나보세요." + fun getMessageContent( + region: Region, + alertType: BloomedAlertType = BloomedAlertType.FIRST_VOTE, + ): String = + when (alertType) { + BloomedAlertType.FIRST_VOTE -> + "${region.toKoreanName()}에도 벚꽃이 눈을 떴어요! 🌸\n우리 동네 가장 빠른 봄을 만나보세요." + BloomedAlertType.THRESHOLD_REACHED -> + "지금이 절정! 1년 중 가장 예쁜 ${region.toKoreanName()} 벚꽃의 만개 순간, 놓치면 후회해요. 🌸" + } } diff --git a/pida-core/core-domain/src/main/kotlin/com/pida/notification/bloomed/BloomedNotificationService.kt b/pida-core/core-domain/src/main/kotlin/com/pida/notification/bloomed/BloomedNotificationService.kt index 64f863e8..ba3cb5cc 100644 --- a/pida-core/core-domain/src/main/kotlin/com/pida/notification/bloomed/BloomedNotificationService.kt +++ b/pida-core/core-domain/src/main/kotlin/com/pida/notification/bloomed/BloomedNotificationService.kt @@ -41,7 +41,7 @@ class BloomedNotificationService( var totalSentCount = 0 regionsExceedingThreshold.forEach { bloomedRegion -> - val sentCount = sendBloomedNotificationForRegion(bloomedRegion.region) + val sentCount = sendBloomedNotificationForRegion(bloomedRegion.region, BloomedAlertType.THRESHOLD_REACHED) totalSentCount += sentCount } @@ -53,10 +53,16 @@ class BloomedNotificationService( /** * 특정 지역에 대해 BLOOMED 알림을 발송합니다. + * + * @param region 대상 지역 + * @param alertType 알림 유형 (개화 초기 / 만개 절정) */ - fun sendBloomedNotificationForRegion(region: Region): Int { + fun sendBloomedNotificationForRegion( + region: Region, + alertType: BloomedAlertType = BloomedAlertType.FIRST_VOTE, + ): Int { try { - logger.info("Processing region: $region") + logger.info("Processing region: $region, alertType: $alertType") // 1. 지역별 적격 사용자 조회 val eligibleUsers = bloomedNotificationEligibilityChecker.findEligibleUsersForRegion(region) @@ -67,7 +73,7 @@ class BloomedNotificationService( } // 2. FCM 메시지 생성 - val messages = buildNotificationMessages(eligibleUsers, region) + val messages = buildNotificationMessages(eligibleUsers, region, alertType) if (messages.isEmpty()) { return 0 @@ -76,7 +82,7 @@ class BloomedNotificationService( fcmSender.sendAllAsync(messages) // 4. 알림 이력 저장 - storeNotificationRecords(eligibleUsers, region) + storeNotificationRecords(eligibleUsers, region, alertType) return messages.size } catch (e: Exception) { @@ -90,17 +96,19 @@ class BloomedNotificationService( * * @param users 대상 사용자 목록 * @param region 대상 지역 + * @param alertType 알림 유형 * @return FCM 메시지 집합 */ private fun buildNotificationMessages( users: List, region: Region, + alertType: BloomedAlertType, ): Set = userDeviceReader.readLastByUserIds(users.map { it.userId }).let { latestDevicesByUserId -> users .mapNotNull { user -> latestDevicesByUserId[user.userId]?.let { device -> - bloomedNotificationMessageBuilder.buildMessage(device.fcmToken, region) + bloomedNotificationMessageBuilder.buildMessage(device.fcmToken, region, alertType) } }.toSet() } @@ -110,10 +118,12 @@ class BloomedNotificationService( * * @param users 대상 사용자 목록 * @param region 대상 지역 + * @param alertType 알림 유형 */ private fun storeNotificationRecords( users: List, region: Region, + alertType: BloomedAlertType, ) { val commands = users.map { user -> @@ -123,7 +133,7 @@ class BloomedNotificationService( type = NotificationType.BLOOMED_ALERT, parameterValue = region.name, topic = "피다", - contents = bloomedNotificationMessageBuilder.getMessageContent(region), + contents = bloomedNotificationMessageBuilder.getMessageContent(region, alertType), readStatus = ReadStatus.UNREAD, ) } diff --git a/pida-core/core-domain/src/main/kotlin/com/pida/notification/weekday/WeekdayNotificationEligibilityChecker.kt b/pida-core/core-domain/src/main/kotlin/com/pida/notification/weekday/WeekdayNotificationEligibilityChecker.kt index 63446c6d..15492656 100644 --- a/pida-core/core-domain/src/main/kotlin/com/pida/notification/weekday/WeekdayNotificationEligibilityChecker.kt +++ b/pida-core/core-domain/src/main/kotlin/com/pida/notification/weekday/WeekdayNotificationEligibilityChecker.kt @@ -3,7 +3,6 @@ package com.pida.notification.weekday import com.pida.notification.EligibleUser import com.pida.notification.NotificationStoredRepository import com.pida.notification.NotificationType -import com.pida.notification.weekend.WeekendNotificationAirQualityChecker import com.pida.notification.weekend.WeekendNotificationLocationChecker import com.pida.notification.weekend.WeekendNotificationUserReader import com.pida.support.extension.logger @@ -19,7 +18,6 @@ import java.time.temporal.TemporalAdjusters class WeekdayNotificationEligibilityChecker( private val userReader: WeekendNotificationUserReader, private val locationChecker: WeekendNotificationLocationChecker, - private val airQualityChecker: WeekendNotificationAirQualityChecker, private val notificationStoredRepository: NotificationStoredRepository, ) { private val logger by logger() @@ -35,7 +33,6 @@ class WeekdayNotificationEligibilityChecker( * - 최근 30일 내 활성 사용자 * - 위치 정보가 있는 사용자 * - 3km 반경 내 개화 상태(BLOOMED) FlowerSpot 또는 FlowerEvent 존재 - * - PM10 < 81µg/m³ * - 이번 주 평일 알림 수신 횟수 2회 미만 * * @return 적격 사용자 목록 @@ -77,24 +74,12 @@ class WeekdayNotificationEligibilityChecker( } // 4. 필터링: 위치 기반 (3km 반경 내 개화 상태) - val eligibleByLocation = + val eligibleUsers = eligibleByNotificationCount.filter { user -> locationChecker.hasNearbyBloomingLocations(user.latitude, user.longitude) } - logger.info("${eligibleByLocation.size} users have nearby blooming locations") - - if (eligibleByLocation.isEmpty()) { - return emptyList() - } - - // 5. 필터링: 대기질 (PM10 < 81µg/m³) - val eligibleUsers = - eligibleByLocation.filter { user -> - airQualityChecker.hasGoodAirQuality(user.latitude, user.longitude) - } - - logger.info("${eligibleUsers.size} users have good air quality") + logger.info("${eligibleUsers.size} users have nearby blooming locations") return eligibleUsers }