diff --git a/.github/workflows/api-deploy.yml b/.github/workflows/api-deploy.yml index d9722d69..98b5b8b0 100644 --- a/.github/workflows/api-deploy.yml +++ b/.github/workflows/api-deploy.yml @@ -46,6 +46,8 @@ jobs: echo "${{ secrets.APPLICATION_CORE }}" > ./application/src/main/resources/application-core.yml mkdir -p ./adapters/in-web/src/main/resources echo "${{ secrets.APPLICATION_IN_WEB_YML }}" > ./application/src/main/resources/application-in-web.yml + mkdir -p ./adapters/in-batch/src/main/resources + echo "${{ secrets.APPLICATION_IN_BATCH }}" > ./application/src/main/resources/application-in-batch.yml - name: Build with Gradle run: ./gradlew :entry:web:build --no-daemon diff --git a/adapters/in-batch/src/main/kotlin/com/pokit/alert/component/AlertProcessor.kt b/adapters/in-batch/src/main/kotlin/com/pokit/alert/component/AlertProcessor.kt new file mode 100644 index 00000000..ab40398c --- /dev/null +++ b/adapters/in-batch/src/main/kotlin/com/pokit/alert/component/AlertProcessor.kt @@ -0,0 +1,17 @@ +package com.pokit.alert.component + +import com.pokit.alert.model.AlertBatch +import com.pokit.alert.port.`in`.AlertUseCase +import org.springframework.batch.item.ItemProcessor +import org.springframework.stereotype.Component + +@Component +class AlertProcessor( + private val alertUseCase: AlertUseCase, +) : ItemProcessor { + + override fun process(alertBatch: AlertBatch): AlertBatch { + alertUseCase.sendMessage(alertBatch) + return alertBatch + } +} diff --git a/adapters/in-batch/src/main/kotlin/com/pokit/alert/component/AlertReader.kt b/adapters/in-batch/src/main/kotlin/com/pokit/alert/component/AlertReader.kt new file mode 100644 index 00000000..e07435bd --- /dev/null +++ b/adapters/in-batch/src/main/kotlin/com/pokit/alert/component/AlertReader.kt @@ -0,0 +1,29 @@ +package com.pokit.alert.component + +import com.pokit.alert.model.AlertBatch +import com.pokit.alert.model.AlertBatchValue +import com.pokit.alert.port.`in`.AlertUseCase +import org.springframework.batch.item.ItemReader +import org.springframework.stereotype.Component + +@Component +class AlertReader( + private val alertUseCase: AlertUseCase, +) : ItemReader { + + private var currentPage: Int = 0 + private var alertBatchList: MutableList = mutableListOf() + + override fun read(): AlertBatch? { + if (alertBatchList.isEmpty()) { + val alertBatches = alertUseCase.loadAllAlertBatch(currentPage++, AlertBatchValue.CHUNK_SIZE) + alertBatchList.addAll(alertBatches) + + if (alertBatchList.isEmpty()) { + return null + } + } + + return alertBatchList.removeFirst() + } +} diff --git a/adapters/in-batch/src/main/kotlin/com/pokit/alert/component/AlertWriter.kt b/adapters/in-batch/src/main/kotlin/com/pokit/alert/component/AlertWriter.kt new file mode 100644 index 00000000..c33236dd --- /dev/null +++ b/adapters/in-batch/src/main/kotlin/com/pokit/alert/component/AlertWriter.kt @@ -0,0 +1,19 @@ +package com.pokit.alert.component + +import com.pokit.alert.model.AlertBatch +import com.pokit.alert.port.`in`.AlertUseCase +import org.springframework.batch.item.Chunk +import org.springframework.batch.item.ItemWriter +import org.springframework.stereotype.Component + +@Component +class AlertWriter( + private val alertUseCase: AlertUseCase, +) : ItemWriter { + override fun write(alertBatches: Chunk) { + val batchIds = alertBatches.map { it.id } + val alertContents = alertUseCase.fetchAllAlertContent(batchIds) + + alertUseCase.createAlerts(alertContents) + } +} diff --git a/adapters/in-batch/src/main/kotlin/com/pokit/alert/job/SendAlertConfig.kt b/adapters/in-batch/src/main/kotlin/com/pokit/alert/job/SendAlertConfig.kt new file mode 100644 index 00000000..8aa6df6b --- /dev/null +++ b/adapters/in-batch/src/main/kotlin/com/pokit/alert/job/SendAlertConfig.kt @@ -0,0 +1,43 @@ +package com.pokit.alert.job + +import com.navercorp.spring.batch.plus.kotlin.configuration.BatchDsl +import com.navercorp.spring.batch.plus.kotlin.configuration.step.SimpleStepBuilderDsl +import com.pokit.alert.component.AlertProcessor +import com.pokit.alert.component.AlertReader +import com.pokit.alert.component.AlertWriter +import com.pokit.alert.model.AlertBatch +import com.pokit.alert.model.AlertBatchValue +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.transaction.PlatformTransactionManager +import org.springframework.transaction.TransactionDefinition +import org.springframework.transaction.interceptor.DefaultTransactionAttribute + + +@Configuration +class SendAlertConfig( + private val transactionManager: PlatformTransactionManager, + private val batch: BatchDsl, + private val alertReader: AlertReader, + private val alertProcessor: AlertProcessor, + private val alertWriter: AlertWriter +) { + @Bean(name = ["sendAlertJob"]) + fun sendAlertJob() = batch { + job("sendAlertJob") { + step(sendAlertStep()) + } + } + + @Bean + fun sendAlertStep() = batch { + step("sendAlertStep") { + chunk(AlertBatchValue.CHUNK_SIZE, transactionManager, fun SimpleStepBuilderDsl.() { + reader(alertReader) + processor(alertProcessor) + writer(alertWriter) + transactionAttribute(DefaultTransactionAttribute(TransactionDefinition.PROPAGATION_NOT_SUPPORTED)) + }) + } + } +} diff --git a/adapters/in-batch/src/main/kotlin/com/pokit/alert/scheduler/SendAlertScheduler.kt b/adapters/in-batch/src/main/kotlin/com/pokit/alert/scheduler/SendAlertScheduler.kt new file mode 100644 index 00000000..4e684428 --- /dev/null +++ b/adapters/in-batch/src/main/kotlin/com/pokit/alert/scheduler/SendAlertScheduler.kt @@ -0,0 +1,32 @@ +package com.pokit.alert.scheduler + +import io.github.oshai.kotlinlogging.KotlinLogging +import org.springframework.batch.core.Job +import org.springframework.batch.core.JobParametersBuilder +import org.springframework.batch.core.launch.JobLauncher +import org.springframework.beans.factory.annotation.Qualifier +import org.springframework.scheduling.annotation.Scheduled +import org.springframework.stereotype.Component + +@Component +class SendAlertScheduler( + private val jobLauncher: JobLauncher, + @Qualifier("sendAlertJob") private val sendAlertJob: Job +) { + private val logger = KotlinLogging.logger { } + + companion object { + private const val 매일_오전_8시 = "0 0 8 * * *" + } + + @Scheduled(cron = 매일_오전_8시) + fun sendAlert() { + val jobParameters = JobParametersBuilder() + .addLong("run.id", System.currentTimeMillis()) + .toJobParameters() + + logger.info { "[ALERT BATCH] start daily send alert job" } + jobLauncher.run(sendAlertJob, jobParameters) + logger.info { "[ALERT BATCH] end daily send alert job" } + } +} diff --git a/adapters/in-batch/src/main/kotlin/com/pokit/config/SchedulerConfig.kt b/adapters/in-batch/src/main/kotlin/com/pokit/config/SchedulerConfig.kt new file mode 100644 index 00000000..1c921dfc --- /dev/null +++ b/adapters/in-batch/src/main/kotlin/com/pokit/config/SchedulerConfig.kt @@ -0,0 +1,24 @@ +package com.pokit.config + +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.scheduling.TaskScheduler +import org.springframework.scheduling.annotation.EnableScheduling +import org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler + +@Configuration +@EnableScheduling +class SchedulerConfig { + companion object { + private const val POOL_SIZE = 3 + } + + @Bean("schedulerTask") + fun taskScheduler(): TaskScheduler { + val executor = ThreadPoolTaskScheduler() + executor.poolSize = POOL_SIZE + executor.threadNamePrefix = "scheduler-thread-" + executor.initialize() + return executor + } +} diff --git a/adapters/in-batch/src/main/kotlin/com/pokit/remind/job/DailyContentConfig.kt b/adapters/in-batch/src/main/kotlin/com/pokit/remind/job/DailyContentConfig.kt index c6567883..7575be0b 100644 --- a/adapters/in-batch/src/main/kotlin/com/pokit/remind/job/DailyContentConfig.kt +++ b/adapters/in-batch/src/main/kotlin/com/pokit/remind/job/DailyContentConfig.kt @@ -18,7 +18,7 @@ class DailyContentConfig( private val batch: BatchDsl, ) { - @Bean + @Bean(name = ["updateDailyContentJob"]) fun updateDailyContentJob(): Job = batch { job("updateDailyContentJob") { step(deleteAllStep()) { diff --git a/adapters/in-batch/src/main/kotlin/com/pokit/remind/scheduler/DailyContentUpdateScheduler.kt b/adapters/in-batch/src/main/kotlin/com/pokit/remind/scheduler/DailyContentUpdateScheduler.kt index c34e9a03..5cefb751 100644 --- a/adapters/in-batch/src/main/kotlin/com/pokit/remind/scheduler/DailyContentUpdateScheduler.kt +++ b/adapters/in-batch/src/main/kotlin/com/pokit/remind/scheduler/DailyContentUpdateScheduler.kt @@ -4,13 +4,14 @@ import io.github.oshai.kotlinlogging.KotlinLogging import org.springframework.batch.core.Job import org.springframework.batch.core.JobParametersBuilder import org.springframework.batch.core.launch.JobLauncher +import org.springframework.beans.factory.annotation.Qualifier import org.springframework.scheduling.annotation.Scheduled import org.springframework.stereotype.Component @Component class DailyContentUpdateScheduler( private val jobLauncher: JobLauncher, - private val updateDailyContent: Job, + @Qualifier("updateDailyContentJob") private val updateDailyContent: Job, ) { private val logger = KotlinLogging.logger { } diff --git a/adapters/out-persistence/src/main/kotlin/com/pokit/out/persistence/alert/impl/AlertAdapter.kt b/adapters/out-persistence/src/main/kotlin/com/pokit/out/persistence/alert/impl/AlertAdapter.kt index 10108620..b8e3708b 100644 --- a/adapters/out-persistence/src/main/kotlin/com/pokit/out/persistence/alert/impl/AlertAdapter.kt +++ b/adapters/out-persistence/src/main/kotlin/com/pokit/out/persistence/alert/impl/AlertAdapter.kt @@ -2,6 +2,8 @@ package com.pokit.out.persistence.alert.impl import com.pokit.alert.model.Alert import com.pokit.alert.port.out.AlertPort +import com.pokit.out.persistence.alert.persist.AlertEntity +import com.pokit.out.persistence.alert.persist.AlertJdbcRepository import com.pokit.out.persistence.alert.persist.AlertRepository import com.pokit.out.persistence.alert.persist.toDomain import org.springframework.data.domain.Pageable @@ -11,7 +13,8 @@ import org.springframework.stereotype.Repository @Repository class AlertAdapter( - private val alertRepository: AlertRepository + private val alertRepository: AlertRepository, + private val alertJdbcRepository: AlertJdbcRepository ) : AlertPort { override fun loadAllByUserId(userId: Long, pageable: Pageable): Slice { return alertRepository.findAllByUserIdAndDeleted(userId, false, pageable) @@ -28,4 +31,11 @@ class AlertAdapter( ?.delete() } + override fun persistAlerts(alerts: List) { + val alertEntities = alerts.map { + AlertEntity.of(it) + } + alertJdbcRepository.bulkInsert(alertEntities) + } + } diff --git a/adapters/out-persistence/src/main/kotlin/com/pokit/out/persistence/alert/impl/AlertBatchAdapter.kt b/adapters/out-persistence/src/main/kotlin/com/pokit/out/persistence/alert/impl/AlertBatchAdapter.kt new file mode 100644 index 00000000..15355b32 --- /dev/null +++ b/adapters/out-persistence/src/main/kotlin/com/pokit/out/persistence/alert/impl/AlertBatchAdapter.kt @@ -0,0 +1,37 @@ +package com.pokit.out.persistence.alert.impl + +import com.pokit.alert.model.AlertBatch +import com.pokit.alert.port.out.AlertBatchPort +import com.pokit.out.persistence.alert.persist.AlertBatchEntity +import com.pokit.out.persistence.alert.persist.AlertBatchRepository +import com.pokit.out.persistence.alert.persist.toDomain +import org.springframework.data.domain.Page +import org.springframework.data.domain.Pageable +import org.springframework.data.repository.findByIdOrNull +import org.springframework.stereotype.Repository +import java.time.LocalDate + +@Repository +class AlertBatchAdapter( + private val alertBatchRepository: AlertBatchRepository +) : AlertBatchPort { + override fun loadAllByShouldBeSentAt(now: LocalDate, pageable: Pageable): Page { + return alertBatchRepository.findAllByShouldBeSentAtAfterAndSent(now, false, pageable) + .map { it.toDomain() } + } + + override fun send(alertBatch: AlertBatch) { + alertBatchRepository.findByIdOrNull(alertBatch.id) + ?.sent() + } + + override fun loadByUserIdAndDate(userId: Long, date: LocalDate): AlertBatch? { + return alertBatchRepository.findByUserIdAndShouldBeSentAtAndSent(userId, date, false) + ?.run { toDomain() } + } + + override fun persist(alertBatch: AlertBatch): AlertBatch { + val alertBatchEntity = AlertBatchEntity.of(alertBatch) + return alertBatchRepository.save(alertBatchEntity).toDomain() + } +} diff --git a/adapters/out-persistence/src/main/kotlin/com/pokit/out/persistence/alert/impl/AlertContentAdapter.kt b/adapters/out-persistence/src/main/kotlin/com/pokit/out/persistence/alert/impl/AlertContentAdapter.kt new file mode 100644 index 00000000..bac99209 --- /dev/null +++ b/adapters/out-persistence/src/main/kotlin/com/pokit/out/persistence/alert/impl/AlertContentAdapter.kt @@ -0,0 +1,27 @@ +package com.pokit.out.persistence.alert.impl + +import com.pokit.alert.model.AlertContent +import com.pokit.alert.port.out.AlertContentPort +import com.pokit.out.persistence.alert.persist.AlertContentEntity +import com.pokit.out.persistence.alert.persist.AlertContentRepository +import com.pokit.out.persistence.alert.persist.toDomain +import org.springframework.stereotype.Repository + +@Repository +class AlertContentAdapter( + private val alertContentRepository: AlertContentRepository +) : AlertContentPort { + override fun loadAllInAlertBatchIds(ids: List): List { + return alertContentRepository.findAllByAlertBatchIdIn(ids) + .map { it.toDomain() } + } + + override fun deleteAll(ids: List) { + alertContentRepository.deleteAllByIdInBatch(ids) + } + + override fun persist(alertContent: AlertContent): AlertContent { + val alertContentEntity = AlertContentEntity.of(alertContent) + return alertContentRepository.save(alertContentEntity).toDomain() + } +} diff --git a/adapters/out-persistence/src/main/kotlin/com/pokit/out/persistence/alert/persist/AlertBatchEntity.kt b/adapters/out-persistence/src/main/kotlin/com/pokit/out/persistence/alert/persist/AlertBatchEntity.kt index 2058bcf0..d6fea400 100644 --- a/adapters/out-persistence/src/main/kotlin/com/pokit/out/persistence/alert/persist/AlertBatchEntity.kt +++ b/adapters/out-persistence/src/main/kotlin/com/pokit/out/persistence/alert/persist/AlertBatchEntity.kt @@ -33,3 +33,9 @@ class AlertBatchEntity( ) } } + +fun AlertBatchEntity.toDomain() = AlertBatch( + id = this.id, + userId = this.userId, + shouldBeSentAt = this.shouldBeSentAt +) diff --git a/adapters/out-persistence/src/main/kotlin/com/pokit/out/persistence/alert/persist/AlertBatchRepository.kt b/adapters/out-persistence/src/main/kotlin/com/pokit/out/persistence/alert/persist/AlertBatchRepository.kt new file mode 100644 index 00000000..b35f90fb --- /dev/null +++ b/adapters/out-persistence/src/main/kotlin/com/pokit/out/persistence/alert/persist/AlertBatchRepository.kt @@ -0,0 +1,12 @@ +package com.pokit.out.persistence.alert.persist + +import org.springframework.data.domain.Page +import org.springframework.data.domain.Pageable +import org.springframework.data.jpa.repository.JpaRepository +import java.time.LocalDate + +interface AlertBatchRepository : JpaRepository { + fun findAllByShouldBeSentAtAfterAndSent(now: LocalDate, send: Boolean, pageable: Pageable): Page + + fun findByUserIdAndShouldBeSentAtAndSent(userId: Long, date: LocalDate, sent: Boolean): AlertBatchEntity? +} diff --git a/adapters/out-persistence/src/main/kotlin/com/pokit/out/persistence/alert/persist/AlertContentEntity.kt b/adapters/out-persistence/src/main/kotlin/com/pokit/out/persistence/alert/persist/AlertContentEntity.kt index 16fd9bb7..afd85e3a 100644 --- a/adapters/out-persistence/src/main/kotlin/com/pokit/out/persistence/alert/persist/AlertContentEntity.kt +++ b/adapters/out-persistence/src/main/kotlin/com/pokit/out/persistence/alert/persist/AlertContentEntity.kt @@ -17,18 +17,18 @@ class AlertContentEntity( @Column(name = "content_id") val contentId: Long, - - @Column(name = "is_deleted") - var delete: Boolean = false ) : BaseEntity() { - fun delete() { - this.delete = true - } companion object { fun of(alertContent: AlertContent) = AlertContentEntity( alertBatchId = alertContent.alertBatchId, - contentId = alertContent.contentId + contentId = alertContent.contentId, ) } } + +fun AlertContentEntity.toDomain() = AlertContent( + id = this.id, + alertBatchId = this.alertBatchId, + contentId = this.contentId, +) diff --git a/adapters/out-persistence/src/main/kotlin/com/pokit/out/persistence/alert/persist/AlertContentRepository.kt b/adapters/out-persistence/src/main/kotlin/com/pokit/out/persistence/alert/persist/AlertContentRepository.kt new file mode 100644 index 00000000..c6532e76 --- /dev/null +++ b/adapters/out-persistence/src/main/kotlin/com/pokit/out/persistence/alert/persist/AlertContentRepository.kt @@ -0,0 +1,7 @@ +package com.pokit.out.persistence.alert.persist + +import org.springframework.data.jpa.repository.JpaRepository + +interface AlertContentRepository : JpaRepository { + fun findAllByAlertBatchIdIn(ids: List): List +} diff --git a/adapters/out-persistence/src/main/kotlin/com/pokit/out/persistence/alert/persist/AlertJdbcRepository.kt b/adapters/out-persistence/src/main/kotlin/com/pokit/out/persistence/alert/persist/AlertJdbcRepository.kt new file mode 100644 index 00000000..cee820b3 --- /dev/null +++ b/adapters/out-persistence/src/main/kotlin/com/pokit/out/persistence/alert/persist/AlertJdbcRepository.kt @@ -0,0 +1,5 @@ +package com.pokit.out.persistence.alert.persist + +interface AlertJdbcRepository { + fun bulkInsert(alertEntities: List) +} diff --git a/adapters/out-persistence/src/main/kotlin/com/pokit/out/persistence/alert/persist/AlertJdbcRepositoryImpl.kt b/adapters/out-persistence/src/main/kotlin/com/pokit/out/persistence/alert/persist/AlertJdbcRepositoryImpl.kt new file mode 100644 index 00000000..1e8ace19 --- /dev/null +++ b/adapters/out-persistence/src/main/kotlin/com/pokit/out/persistence/alert/persist/AlertJdbcRepositoryImpl.kt @@ -0,0 +1,31 @@ +package com.pokit.out.persistence.alert.persist + +import org.springframework.jdbc.core.JdbcTemplate +import org.springframework.stereotype.Repository + +@Repository +class AlertJdbcRepositoryImpl( + private val jdbcTemplate: JdbcTemplate +) : AlertJdbcRepository { + override fun bulkInsert(alertEntities: List) { + val sql = """ + INSERT INTO alert ( + user_id, content_id, content_thumb_nail + , title, is_deleted, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) + """.trimIndent() + + + val batchArgs = alertEntities.map { alert -> + arrayOf( + alert.userId, + alert.contentId, + alert.contentThumbNail, + alert.title, + alert.deleted + ) + } + + jdbcTemplate.batchUpdate(sql, batchArgs) + } +} diff --git a/adapters/out-persistence/src/main/kotlin/com/pokit/out/persistence/content/impl/ContentAdapter.kt b/adapters/out-persistence/src/main/kotlin/com/pokit/out/persistence/content/impl/ContentAdapter.kt index 570b2142..d2ab7a3f 100644 --- a/adapters/out-persistence/src/main/kotlin/com/pokit/out/persistence/content/impl/ContentAdapter.kt +++ b/adapters/out-persistence/src/main/kotlin/com/pokit/out/persistence/content/impl/ContentAdapter.kt @@ -5,6 +5,7 @@ import com.pokit.content.dto.request.ContentSearchCondition import com.pokit.content.dto.response.ContentsResult import com.pokit.content.dto.response.SharedContentResult import com.pokit.content.model.Content +import com.pokit.content.model.ContentWithUser import com.pokit.content.port.out.ContentPort import com.pokit.log.model.LogType import com.pokit.out.persistence.bookmark.persist.QBookmarkEntity.bookmarkEntity @@ -190,6 +191,10 @@ class ContentAdapter( contentRepository.bulkInsert(targetContentEntities) } + override fun loadByContentIdsWithUser(contetIds: List): List { + return contentRepository.findByIdInWithUser(contetIds) + } + override fun loadByContentIds(contentIds: List): List = contentRepository.findByIdIn(contentIds) .map { it.toDomain() } diff --git a/adapters/out-persistence/src/main/kotlin/com/pokit/out/persistence/content/persist/ContentJdbcRepositoryImpl.kt b/adapters/out-persistence/src/main/kotlin/com/pokit/out/persistence/content/persist/ContentJdbcRepositoryImpl.kt index ed58298f..64cd6b24 100644 --- a/adapters/out-persistence/src/main/kotlin/com/pokit/out/persistence/content/persist/ContentJdbcRepositoryImpl.kt +++ b/adapters/out-persistence/src/main/kotlin/com/pokit/out/persistence/content/persist/ContentJdbcRepositoryImpl.kt @@ -14,6 +14,7 @@ class ContentJdbcRepositoryImpl( ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) """.trimIndent() + val batchArgs = contentEntities.map { content -> arrayOf( content.categoryId, diff --git a/adapters/out-persistence/src/main/kotlin/com/pokit/out/persistence/content/persist/ContentRepository.kt b/adapters/out-persistence/src/main/kotlin/com/pokit/out/persistence/content/persist/ContentRepository.kt index 39c46273..4f67ead2 100644 --- a/adapters/out-persistence/src/main/kotlin/com/pokit/out/persistence/content/persist/ContentRepository.kt +++ b/adapters/out-persistence/src/main/kotlin/com/pokit/out/persistence/content/persist/ContentRepository.kt @@ -1,5 +1,6 @@ package com.pokit.out.persistence.content.persist +import com.pokit.content.model.ContentWithUser import org.springframework.data.jpa.repository.JpaRepository import org.springframework.data.jpa.repository.Modifying import org.springframework.data.jpa.repository.Query @@ -40,4 +41,12 @@ interface ContentRepository : JpaRepository, ContentJdbcRep """ ) fun countByUserId(userId: Long): Int + + @Query(""" + select new com.pokit.content.model.ContentWithUser(c.id, u.id, c.title, c.thumbNail) from ContentEntity c + join CategoryEntity ca on ca.id = c.categoryId + join UserEntity u on u.id = ca.userId + where c.id in :ids + """) + fun findByIdInWithUser(ids: List): List } diff --git a/adapters/out-persistence/src/main/kotlin/com/pokit/out/persistence/user/impl/FcmTokenAdapter.kt b/adapters/out-persistence/src/main/kotlin/com/pokit/out/persistence/user/impl/FcmTokenAdapter.kt index c96fc315..725fe319 100644 --- a/adapters/out-persistence/src/main/kotlin/com/pokit/out/persistence/user/impl/FcmTokenAdapter.kt +++ b/adapters/out-persistence/src/main/kotlin/com/pokit/out/persistence/user/impl/FcmTokenAdapter.kt @@ -15,4 +15,9 @@ class FcmTokenAdapter( val fcmTokenEntity = FcmTokenEntity.of(fcmToken) return fcmTokenRepository.save(fcmTokenEntity).toDomain() } + + override fun loadByUserId(userId: Long): List { + return fcmTokenRepository.findByUserId(userId) + .map { it.toDomain() } + } } diff --git a/adapters/out-persistence/src/main/kotlin/com/pokit/out/persistence/user/persist/FcmTokenRepository.kt b/adapters/out-persistence/src/main/kotlin/com/pokit/out/persistence/user/persist/FcmTokenRepository.kt index 5ac9a926..30253542 100644 --- a/adapters/out-persistence/src/main/kotlin/com/pokit/out/persistence/user/persist/FcmTokenRepository.kt +++ b/adapters/out-persistence/src/main/kotlin/com/pokit/out/persistence/user/persist/FcmTokenRepository.kt @@ -3,4 +3,5 @@ package com.pokit.out.persistence.user.persist import org.springframework.data.jpa.repository.JpaRepository interface FcmTokenRepository : JpaRepository { + fun findByUserId(userId: Long): List } diff --git a/application/src/main/kotlin/com/pokit/alert/port/event/AlertEvent.kt b/application/src/main/kotlin/com/pokit/alert/port/event/AlertEvent.kt new file mode 100644 index 00000000..336f69be --- /dev/null +++ b/application/src/main/kotlin/com/pokit/alert/port/event/AlertEvent.kt @@ -0,0 +1,41 @@ +package com.pokit.alert.port.event + +import com.pokit.alert.model.AlertBatch +import com.pokit.alert.model.AlertContent +import com.pokit.alert.model.CreateAlertRequest +import com.pokit.alert.port.out.AlertBatchPort +import com.pokit.alert.port.out.AlertContentPort +import org.springframework.stereotype.Component +import org.springframework.transaction.annotation.Propagation +import org.springframework.transaction.annotation.Transactional +import org.springframework.transaction.event.TransactionPhase +import org.springframework.transaction.event.TransactionalEventListener +import java.time.LocalDate +import java.util.function.Supplier + +@Component +class AlertEventHandler( + private val now: Supplier, + private val alertBatchPort: AlertBatchPort, + private val alertContentPort: AlertContentPort, +) { + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + @Transactional(propagation = Propagation.REQUIRES_NEW) + fun createAlert(request: CreateAlertRequest) { + val alertBatch = alertBatchPort.loadByUserIdAndDate(request.userId, now.get().plusDays(7)) + ?: createAlertBatch(request.userId) + val alertContent = AlertContent( + alertBatchId = alertBatch.id, + contentId = request.contetId + ) + alertContentPort.persist(alertContent) + } + + private fun createAlertBatch(userId: Long): AlertBatch { + val alertBatch = AlertBatch( + userId = userId, + shouldBeSentAt = now.get().plusDays(7) + ) + return alertBatchPort.persist(alertBatch) + } +} diff --git a/application/src/main/kotlin/com/pokit/alert/port/in/AlertUseCase.kt b/application/src/main/kotlin/com/pokit/alert/port/in/AlertUseCase.kt index f6aecf68..7d192c9c 100644 --- a/application/src/main/kotlin/com/pokit/alert/port/in/AlertUseCase.kt +++ b/application/src/main/kotlin/com/pokit/alert/port/in/AlertUseCase.kt @@ -1,6 +1,8 @@ package com.pokit.alert.port.`in` import com.pokit.alert.dto.response.AlertsResponse +import com.pokit.alert.model.AlertBatch +import com.pokit.alert.model.AlertContent import org.springframework.data.domain.Pageable import org.springframework.data.domain.Slice @@ -8,4 +10,12 @@ interface AlertUseCase { fun getAlerts(userId: Long, pageable: Pageable): Slice fun delete(userId: Long, alertId: Long) + + fun loadAllAlertBatch(currentPageNum: Int, pageSize: Int): List + + fun sendMessage(alertBatch: AlertBatch) + + fun fetchAllAlertContent(ids: List): List + + fun createAlerts(alertContents: List) } diff --git a/application/src/main/kotlin/com/pokit/alert/port/out/AlertBatchPort.kt b/application/src/main/kotlin/com/pokit/alert/port/out/AlertBatchPort.kt new file mode 100644 index 00000000..2d8cefae --- /dev/null +++ b/application/src/main/kotlin/com/pokit/alert/port/out/AlertBatchPort.kt @@ -0,0 +1,16 @@ +package com.pokit.alert.port.out + +import com.pokit.alert.model.AlertBatch +import org.springframework.data.domain.Page +import org.springframework.data.domain.Pageable +import java.time.LocalDate + +interface AlertBatchPort { + fun loadAllByShouldBeSentAt(now: LocalDate, pageable: Pageable): Page + + fun send(alertBatch: AlertBatch) + + fun loadByUserIdAndDate(userId: Long, date: LocalDate): AlertBatch? + + fun persist(alertBatch: AlertBatch): AlertBatch +} diff --git a/application/src/main/kotlin/com/pokit/alert/port/out/AlertContentPort.kt b/application/src/main/kotlin/com/pokit/alert/port/out/AlertContentPort.kt new file mode 100644 index 00000000..0058cf37 --- /dev/null +++ b/application/src/main/kotlin/com/pokit/alert/port/out/AlertContentPort.kt @@ -0,0 +1,11 @@ +package com.pokit.alert.port.out + +import com.pokit.alert.model.AlertContent + +interface AlertContentPort { + fun loadAllInAlertBatchIds(ids: List): List + + fun deleteAll(ids: List) + + fun persist(alertContent: AlertContent): AlertContent +} diff --git a/application/src/main/kotlin/com/pokit/alert/port/out/AlertPort.kt b/application/src/main/kotlin/com/pokit/alert/port/out/AlertPort.kt index 6120444f..e519a453 100644 --- a/application/src/main/kotlin/com/pokit/alert/port/out/AlertPort.kt +++ b/application/src/main/kotlin/com/pokit/alert/port/out/AlertPort.kt @@ -10,4 +10,6 @@ interface AlertPort { fun loadByIdAndUserId(id: Long, userId: Long): Alert? fun delete(alert: Alert) + + fun persistAlerts(alerts: List) } diff --git a/application/src/main/kotlin/com/pokit/alert/port/out/AlertSender.kt b/application/src/main/kotlin/com/pokit/alert/port/out/AlertSender.kt index 63b87f9f..0a2b6e4c 100644 --- a/application/src/main/kotlin/com/pokit/alert/port/out/AlertSender.kt +++ b/application/src/main/kotlin/com/pokit/alert/port/out/AlertSender.kt @@ -1,7 +1,5 @@ package com.pokit.alert.port.out -import com.pokit.alert.model.AlertBatch - interface AlertSender { fun send(tokens: List) } diff --git a/application/src/main/kotlin/com/pokit/alert/port/service/AlertService.kt b/application/src/main/kotlin/com/pokit/alert/port/service/AlertService.kt index e96732cc..f4ff78ec 100644 --- a/application/src/main/kotlin/com/pokit/alert/port/service/AlertService.kt +++ b/application/src/main/kotlin/com/pokit/alert/port/service/AlertService.kt @@ -3,9 +3,19 @@ package com.pokit.alert.port.service import com.pokit.alert.dto.response.AlertsResponse import com.pokit.alert.dto.response.toAlertsResponse import com.pokit.alert.exception.AlertErrorCode +import com.pokit.alert.model.Alert +import com.pokit.alert.model.AlertBatch +import com.pokit.alert.model.AlertContent import com.pokit.alert.port.`in`.AlertUseCase +import com.pokit.alert.port.out.AlertBatchPort +import com.pokit.alert.port.out.AlertContentPort import com.pokit.alert.port.out.AlertPort +import com.pokit.alert.port.out.AlertSender import com.pokit.common.exception.NotFoundCustomException +import com.pokit.content.model.ContentDefault +import com.pokit.content.port.out.ContentPort +import com.pokit.user.port.out.FcmTokenPort +import org.springframework.data.domain.PageRequest import org.springframework.data.domain.Pageable import org.springframework.data.domain.Slice import org.springframework.stereotype.Service @@ -19,7 +29,12 @@ import kotlin.math.abs @Transactional(readOnly = true) class AlertService( private val now: Supplier, - private val alertPort: AlertPort + private val alertPort: AlertPort, + private val alertBatchPort: AlertBatchPort, + private val alertSender: AlertSender, + private val fcmTokenPort: FcmTokenPort, + private val alertContentPort: AlertContentPort, + private val contentPort: ContentPort ) : AlertUseCase { override fun getAlerts(userId: Long, pageable: Pageable): Slice { val nowDay = now.get().toLocalDate() @@ -37,4 +52,36 @@ class AlertService( ?: throw NotFoundCustomException(AlertErrorCode.NOT_FOUND_ALERT) alertPort.delete(alert) } + + override fun loadAllAlertBatch(currentPageNum: Int, pageSize: Int): List { + val pageable = PageRequest.of(currentPageNum, pageSize) + return alertBatchPort.loadAllByShouldBeSentAt(now.get().toLocalDate(), pageable).content + } + + @Transactional + override fun sendMessage(alertBatch: AlertBatch) { + val tokens = fcmTokenPort.loadByUserId(alertBatch.userId) + .map { it.token } + alertBatchPort.send(alertBatch) + alertSender.send(tokens) + } + + override fun fetchAllAlertContent(ids: List): List { + return alertContentPort.loadAllInAlertBatchIds(ids) + } + + @Transactional + override fun createAlerts(alertContents: List) { + val contents = contentPort.loadByContentIdsWithUser(alertContents.map { it.contentId }) + val alerts = contents.map { + Alert( + userId = it.userId, + contentId = it.contentId, + contentThumbNail = it.thumbNail ?: ContentDefault.THUMB_NAIL, + title = it.title + ) + } + alertPort.persistAlerts(alerts) + alertContentPort.deleteAll(alertContents.map { it.id }) + } } diff --git a/application/src/main/kotlin/com/pokit/config/TimeConfig.kt b/application/src/main/kotlin/com/pokit/config/TimeConfig.kt index 9cb74ad6..a0443873 100644 --- a/application/src/main/kotlin/com/pokit/config/TimeConfig.kt +++ b/application/src/main/kotlin/com/pokit/config/TimeConfig.kt @@ -2,6 +2,7 @@ package com.pokit.config import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Configuration +import java.time.LocalDate import java.time.LocalDateTime import java.util.function.Supplier @@ -12,4 +13,9 @@ class TimeConfig { fun nowTimeSupplier(): Supplier { return Supplier { LocalDateTime.now() } } + + @Bean("nowDateSupplier") + fun nowDateSupplier(): Supplier { + return Supplier { LocalDate.now() } + } } diff --git a/application/src/main/kotlin/com/pokit/content/port/out/ContentPort.kt b/application/src/main/kotlin/com/pokit/content/port/out/ContentPort.kt index 6214f24f..38a3d93d 100644 --- a/application/src/main/kotlin/com/pokit/content/port/out/ContentPort.kt +++ b/application/src/main/kotlin/com/pokit/content/port/out/ContentPort.kt @@ -5,6 +5,7 @@ import com.pokit.content.dto.response.ContentsResult import com.pokit.content.dto.request.ContentSearchCondition import com.pokit.content.dto.response.SharedContentResult import com.pokit.content.model.Content +import com.pokit.content.model.ContentWithUser import org.springframework.data.domain.Pageable import org.springframework.data.domain.Slice @@ -44,4 +45,6 @@ interface ContentPort { ): Slice fun duplicateContent(originCategoryId: Long, targetCategoryId: Long) + + fun loadByContentIdsWithUser(contetIds: List): List } diff --git a/application/src/main/kotlin/com/pokit/content/port/service/ContentService.kt b/application/src/main/kotlin/com/pokit/content/port/service/ContentService.kt index fbe62c53..79525603 100644 --- a/application/src/main/kotlin/com/pokit/content/port/service/ContentService.kt +++ b/application/src/main/kotlin/com/pokit/content/port/service/ContentService.kt @@ -1,5 +1,6 @@ package com.pokit.content.port.service +import com.pokit.alert.model.CreateAlertRequest import com.pokit.bookmark.exception.BookmarkErrorCode import com.pokit.bookmark.model.Bookmark import com.pokit.bookmark.port.out.BookmarkPort @@ -22,6 +23,7 @@ import com.pokit.log.model.LogType import com.pokit.log.model.UserLog import com.pokit.log.port.out.UserLogPort import com.pokit.user.model.User +import org.springframework.context.ApplicationEventPublisher import org.springframework.data.domain.Pageable import org.springframework.data.domain.Slice import org.springframework.data.domain.SliceImpl @@ -34,8 +36,14 @@ class ContentService( private val contentPort: ContentPort, private val bookMarkPort: BookmarkPort, private val categoryPort: CategoryPort, - private val userLogPort: UserLogPort + private val userLogPort: UserLogPort, + private val publisher: ApplicationEventPublisher ) : ContentUseCase { + companion object { + private const val MIN_CONTENT_COUNT = 3 + private const val YES = "YES" + } + @Transactional override fun bookmarkContent(user: User, contentId: Long): BookMarkContentResponse { verifyContent(user.id, contentId) @@ -53,8 +61,14 @@ class ContentService( val category = categoryPort.loadCategoryOrThrow(contentCommand.categoryId, user.id) val content = contentCommand.toDomain() content.parseDomain() - return contentPort.persist(content) + val contentResult = contentPort.persist(content) .toGetContentResult(false, category) + + if (contentCommand.alertYn == YES) { + publisher.publishEvent(CreateAlertRequest(userId = user.id, contetId = contentResult.contentId)) + } + + return contentResult } @Transactional @@ -62,6 +76,11 @@ class ContentService( val category = categoryPort.loadCategoryOrThrow(contentCommand.categoryId, user.id) val content = verifyContent(user.id, contentId) content.modify(contentCommand) + + if (contentCommand.alertYn === YES) { + publisher.publishEvent(CreateAlertRequest(userId = user.id, contetId = content.id)) + } + return contentPort.persist(content) .toGetContentResult(bookMarkPort.isBookmarked(contentId, user.id), category) } diff --git a/application/src/main/kotlin/com/pokit/user/port/out/FcmTokenPort.kt b/application/src/main/kotlin/com/pokit/user/port/out/FcmTokenPort.kt index 949d7835..db7688a0 100644 --- a/application/src/main/kotlin/com/pokit/user/port/out/FcmTokenPort.kt +++ b/application/src/main/kotlin/com/pokit/user/port/out/FcmTokenPort.kt @@ -4,4 +4,6 @@ import com.pokit.user.model.FcmToken interface FcmTokenPort { fun persist(fcmToken: FcmToken): FcmToken + + fun loadByUserId(userId: Long): List } diff --git a/application/src/test/kotlin/com/pokit/alert/port/service/AlertServiceTest.kt b/application/src/test/kotlin/com/pokit/alert/port/service/AlertServiceTest.kt index 38290e92..f94fcc88 100644 --- a/application/src/test/kotlin/com/pokit/alert/port/service/AlertServiceTest.kt +++ b/application/src/test/kotlin/com/pokit/alert/port/service/AlertServiceTest.kt @@ -1,6 +1,7 @@ package com.pokit.alert.port.service import com.pokit.alert.AlertFixure +import com.pokit.alert.port.out.AlertBatchPort import com.pokit.alert.port.out.AlertPort import com.pokit.user.UserFixture import io.kotest.core.spec.style.BehaviorSpec @@ -15,8 +16,8 @@ import java.util.function.Supplier class AlertServiceTest : BehaviorSpec({ val alertPort = mockk() val now = mockk>() - - val alertService = AlertService(now, alertPort) + val alertBatchPort = mockk() + val alertService = AlertService(now, alertPort, alertBatchPort) Given("알림 관련 요청이 들어올 때") { val nowTime = LocalDateTime.of(2024, 8, 15, 15, 30) diff --git a/domain/src/main/kotlin/com/pokit/alert/model/AlertBatch.kt b/domain/src/main/kotlin/com/pokit/alert/model/AlertBatch.kt index 03aedd39..1037b387 100644 --- a/domain/src/main/kotlin/com/pokit/alert/model/AlertBatch.kt +++ b/domain/src/main/kotlin/com/pokit/alert/model/AlertBatch.kt @@ -3,6 +3,16 @@ package com.pokit.alert.model import java.time.LocalDate data class AlertBatch( + val id: Long = 0L, val userId: Long, val shouldBeSentAt: LocalDate, ) + +object AlertBatchValue { + const val CHUNK_SIZE = 50 +} + +data class CreateAlertRequest( + val userId: Long, + val contetId: Long +) diff --git a/domain/src/main/kotlin/com/pokit/alert/model/AlertContent.kt b/domain/src/main/kotlin/com/pokit/alert/model/AlertContent.kt index a97bd878..53b72613 100644 --- a/domain/src/main/kotlin/com/pokit/alert/model/AlertContent.kt +++ b/domain/src/main/kotlin/com/pokit/alert/model/AlertContent.kt @@ -1,6 +1,7 @@ package com.pokit.alert.model data class AlertContent( + val id: Long = 0L, val alertBatchId: Long, - val contentId: Long + val contentId: Long, ) diff --git a/domain/src/main/kotlin/com/pokit/content/model/Content.kt b/domain/src/main/kotlin/com/pokit/content/model/Content.kt index a69df294..af5176f0 100644 --- a/domain/src/main/kotlin/com/pokit/content/model/Content.kt +++ b/domain/src/main/kotlin/com/pokit/content/model/Content.kt @@ -52,3 +52,10 @@ data class CategoryInfo( object ContentDefault { const val THUMB_NAIL = "https://pokit-storage.s3.ap-northeast-2.amazonaws.com/logo/pokit.png" } + +data class ContentWithUser( + val contentId: Long, + val userId: Long, + var title: String, + var thumbNail: String? +)