From 0a2a3040a1947b93f01c46313e9678465218fb16 Mon Sep 17 00:00:00 2001 From: fakerdeft Date: Sun, 30 Aug 2026 13:52:12 +0900 Subject: [PATCH 1/3] =?UTF-8?q?feat:=20=EC=95=8C=EB=A6=BC=20=EB=8C=80?= =?UTF-8?q?=ED=9A=8C=20=ED=95=84=ED=84=B0=EB=A7=81=20=EB=B0=8F=20=EC=A0=95?= =?UTF-8?q?=EB=A0=AC=20=EA=B0=9C=EC=84=A0=20-=20EventScale=20=EB=8F=84?= =?UTF-8?q?=EB=A9=94=EC=9D=B8=20=EC=B6=94=EA=B0=80=20=EB=B0=8F=20includeSm?= =?UTF-8?q?all=20=EA=B5=AC=EB=8F=85=20=EC=84=A4=EC=A0=95=EC=9D=84=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=20=EC=A1=B0=EA=B1=B4=EC=97=90=20=EC=97=B0?= =?UTF-8?q?=EA=B2=B0=20-=20=EC=A0=91=EC=88=98=20=EC=9E=84=EB=B0=95=20?= =?UTF-8?q?=EB=8C=80=ED=9A=8C=EB=A5=BC=20=EC=B5=9C=EC=9A=B0=EC=84=A0=20?= =?UTF-8?q?=EB=85=B8=EC=B6=9C=ED=95=98=EB=8A=94=204=EB=8B=A8=EA=B3=84=20?= =?UTF-8?q?=EC=A0=95=EB=A0=AC=20=EC=9A=B0=EC=84=A0=EC=88=9C=EC=9C=84=20?= =?UTF-8?q?=EB=8F=84=EC=9E=85=20-=20=EC=A0=91=EC=88=98=EC=A4=91=20?= =?UTF-8?q?=EB=8C=80=ED=9A=8C=EB=8A=94=20=EC=B5=9C=EA=B7=BC=20=EC=88=98?= =?UTF-8?q?=EC=A7=91=EC=88=9C=EC=9C=BC=EB=A1=9C=20=EC=A0=95=EB=A0=AC?= =?UTF-8?q?=ED=95=B4=20=EB=B0=9C=EC=86=A1=EB=A7=88=EB=8B=A4=20=EB=AA=A9?= =?UTF-8?q?=EB=A1=9D=20=EA=B0=B1=EC=8B=A0=20-=20=EB=A9=94=EC=9D=BC=201?= =?UTF-8?q?=EA=B1=B4=EB=8B=B9=20=EB=8C=80=ED=9A=8C=20=EB=85=B8=EC=B6=9C?= =?UTF-8?q?=EC=9D=84=2010=EA=B1=B4=EC=97=90=EC=84=9C=2015=EA=B1=B4?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=ED=99=95=EB=8C=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../app/scheduler/NotificationScheduler.kt | 12 +- .../event/MarathonEventQueryRepositoryTest.kt | 144 +++++++++++++++++- .../notification/NotificationSchedulerTest.kt | 35 ++++- .../out/persistence/MarathonEventJpaEntity.kt | 9 ++ .../MarathonEventPersistenceAdapter.kt | 4 +- .../MarathonEventQueryRepository.kt | 80 ++++++++-- .../com/maggom/event/domain/EventScale.kt | 7 + .../com/maggom/event/domain/MarathonEvent.kt | 1 + .../event/port/out/MarathonEventPort.kt | 2 +- 9 files changed, 271 insertions(+), 23 deletions(-) create mode 100644 event/src/main/kotlin/com/maggom/event/domain/EventScale.kt diff --git a/app/src/main/kotlin/com/maggom/app/scheduler/NotificationScheduler.kt b/app/src/main/kotlin/com/maggom/app/scheduler/NotificationScheduler.kt index f6d2be4..9850f27 100644 --- a/app/src/main/kotlin/com/maggom/app/scheduler/NotificationScheduler.kt +++ b/app/src/main/kotlin/com/maggom/app/scheduler/NotificationScheduler.kt @@ -36,7 +36,7 @@ class NotificationScheduler( if (!shouldSendToday(pref.receiveDays, todayCode)) continue if (!isReceiveHour(pref.receiveTime, now)) continue - val events = findMatchingEvents(pref.prefRegions, pref.prefDistances) + val events = findMatchingEvents(pref.prefRegions, pref.prefDistances, pref.includeSmall) if (events.isEmpty()) continue notificationMailPort.sendNotification(member.email, events) @@ -51,8 +51,12 @@ class NotificationScheduler( log.info("알림 발송 완료 - 발송 수: $sentCount") } - private fun findMatchingEvents(prefRegions: List, prefDistances: List): List { - return marathonEventPort.findOpenByRegions(prefRegions) + private fun findMatchingEvents( + prefRegions: List, + prefDistances: List, + includeSmall: Boolean, + ): List { + return marathonEventPort.findOpenByRegions(prefRegions, includeSmall) .filter { event -> event.distances.any { it in prefDistances } } .take(MAX_EVENTS_PER_MAIL) } @@ -79,6 +83,6 @@ class NotificationScheduler( companion object { private const val SEND_INTERVAL_MS = 200L - private const val MAX_EVENTS_PER_MAIL = 10 + private const val MAX_EVENTS_PER_MAIL = 15 } } diff --git a/app/src/test/kotlin/com/maggom/app/event/MarathonEventQueryRepositoryTest.kt b/app/src/test/kotlin/com/maggom/app/event/MarathonEventQueryRepositoryTest.kt index 28d95ba..62a94f8 100644 --- a/app/src/test/kotlin/com/maggom/app/event/MarathonEventQueryRepositoryTest.kt +++ b/app/src/test/kotlin/com/maggom/app/event/MarathonEventQueryRepositoryTest.kt @@ -2,6 +2,7 @@ package com.maggom.app.event import com.maggom.event.adapter.out.persistence.MarathonEventJpaEntity import com.maggom.event.adapter.out.persistence.MarathonEventQueryRepository +import com.maggom.event.domain.EventScale import com.maggom.event.domain.MarathonEventStatus import jakarta.persistence.EntityManager import org.junit.jupiter.api.DisplayName @@ -162,10 +163,15 @@ class MarathonEventQueryRepositoryTest { } @Test - @DisplayName("OPEN이 UPCOMING보다 먼저 정렬") - fun open_events_sorted_before_upcoming_events() { + @DisplayName("접수 시작이 먼 UPCOMING은 OPEN보다 뒤로 정렬") + fun distant_upcoming_events_sorted_after_open_events() { // given - save(status = MarathonEventStatus.UPCOMING, region = "서울특별시 송파구", regEndDate = now.plusDays(10)) + save( + status = MarathonEventStatus.UPCOMING, + region = "서울특별시 송파구", + regStartDate = now.plusDays(30), + regEndDate = now.plusDays(60), + ) save(status = MarathonEventStatus.OPEN, region = "서울특별시 마포구", regEndDate = now.plusDays(5)) // when @@ -177,11 +183,141 @@ class MarathonEventQueryRepositoryTest { assertEquals(MarathonEventStatus.UPCOMING, results[1].status) } + @Test + @DisplayName("includeSmall=false - SMALL 규모 대회 제외") + fun exclude_small_scale_when_include_small_is_false() { + // given + save( + status = MarathonEventStatus.OPEN, + region = "서울특별시 마포구", + regEndDate = now.plusDays(7), + eventScale = EventScale.SMALL, + ) + + // when + val results = repository.findOpenByRegions(listOf("수도권"), includeSmall = false) + + // then + assertTrue(results.isEmpty()) + } + + @Test + @DisplayName("includeSmall=false - MAJOR와 UNKNOWN 규모 대회는 포함") + fun include_major_and_unknown_scale_when_include_small_is_false() { + // given + save( + status = MarathonEventStatus.OPEN, + region = "서울특별시 마포구", + regEndDate = now.plusDays(7), + eventScale = EventScale.MAJOR, + ) + save( + status = MarathonEventStatus.OPEN, + region = "경기도 성남시", + regEndDate = now.plusDays(7), + eventScale = EventScale.UNKNOWN, + ) + + // when + val results = repository.findOpenByRegions(listOf("수도권"), includeSmall = false) + + // then + assertEquals(2, results.size) + assertTrue(results.none { it.eventScale == EventScale.SMALL }) + } + + @Test + @DisplayName("includeSmall=true - 모든 규모 대회 포함") + fun include_all_scales_when_include_small_is_true() { + // given + save( + status = MarathonEventStatus.OPEN, + region = "서울특별시 마포구", + regEndDate = now.plusDays(7), + eventScale = EventScale.SMALL, + ) + save( + status = MarathonEventStatus.OPEN, + region = "경기도 성남시", + regEndDate = now.plusDays(7), + eventScale = EventScale.MAJOR, + ) + + // when + val results = repository.findOpenByRegions(listOf("수도권"), includeSmall = true) + + // then + assertEquals(2, results.size) + } + + @Test + @DisplayName("접수 임박(7일 내 오픈) UPCOMING이 접수중보다 먼저 정렬") + fun imminent_upcoming_events_sorted_first() { + // given + save(status = MarathonEventStatus.OPEN, region = "서울특별시 마포구", regEndDate = now.plusDays(30)) + save( + status = MarathonEventStatus.UPCOMING, + region = "서울특별시 송파구", + regStartDate = now.plusDays(3), + regEndDate = now.plusDays(40), + ) + + // when + val results = repository.findOpenByRegions(listOf("수도권")) + + // then + assertEquals(2, results.size) + assertEquals(MarathonEventStatus.UPCOMING, results[0].status) + assertEquals(MarathonEventStatus.OPEN, results[1].status) + } + + @Test + @DisplayName("마감 임박 대회가 일반 접수중 대회보다 먼저 정렬") + fun closing_soon_events_sorted_before_other_open_events() { + // given + save(status = MarathonEventStatus.OPEN, region = "서울특별시 마포구", regEndDate = now.plusDays(30)) + save(status = MarathonEventStatus.OPEN, region = "경기도 성남시", regEndDate = now.plusDays(2)) + + // when + val results = repository.findOpenByRegions(listOf("수도권")) + + // then + assertEquals(2, results.size) + assertEquals("경기도 성남시", results[0].region) + } + + @Test + @DisplayName("일반 접수중 대회는 최근 수집된 순으로 정렬") + fun other_open_events_sorted_by_newest_crawled_first() { + // given + save( + status = MarathonEventStatus.OPEN, + region = "서울특별시 마포구", + regEndDate = now.plusDays(30), + createdAt = now.minusDays(10), + ) + save( + status = MarathonEventStatus.OPEN, + region = "경기도 성남시", + regEndDate = now.plusDays(60), + createdAt = now.minusDays(1), + ) + + // when + val results = repository.findOpenByRegions(listOf("수도권")) + + // then + assertEquals(2, results.size) + assertEquals("경기도 성남시", results[0].region) + } + private fun save( status: MarathonEventStatus, region: String, regStartDate: LocalDateTime = now.minusDays(1), regEndDate: LocalDateTime?, + eventScale: EventScale = EventScale.UNKNOWN, + createdAt: LocalDateTime = now, ) { val entity = MarathonEventJpaEntity( title = "테스트 마라톤", @@ -192,6 +328,8 @@ class MarathonEventQueryRepositoryTest { regEndDate = regEndDate, linkUrl = "https://example.com", status = status, + eventScale = eventScale, + createdAt = createdAt, sourceName = "test", sourceUrl = "https://source.com", crawledAtKst = now, diff --git a/app/src/test/kotlin/com/maggom/app/notification/NotificationSchedulerTest.kt b/app/src/test/kotlin/com/maggom/app/notification/NotificationSchedulerTest.kt index 0bc0467..c586384 100644 --- a/app/src/test/kotlin/com/maggom/app/notification/NotificationSchedulerTest.kt +++ b/app/src/test/kotlin/com/maggom/app/notification/NotificationSchedulerTest.kt @@ -209,6 +209,38 @@ class NotificationSchedulerTest { verify(exactly = 1) { notificationMailPort.sendNotification("ok@test.com", any()) } } + @Test + @DisplayName("includeSmall=false면 조회 시 includeSmall=false로 전달") + fun include_small_false_is_passed_to_event_port() { + // given + every { memberPort.findAll() } returns listOf(member()) + every { subscriptionQueryUseCase.getByEmail(any()) } returns pref(includeSmall = false) + every { marathonEventPort.findOpenByRegions(any(), any()) } returns listOf(event(distances = listOf("10K"))) + justRun { notificationMailPort.sendNotification(any(), any()) } + + // when + scheduler.sendNotifications() + + // then + verify(exactly = 1) { marathonEventPort.findOpenByRegions(listOf("수도권"), false) } + } + + @Test + @DisplayName("includeSmall=true면 조회 시 includeSmall=true로 전달") + fun include_small_true_is_passed_to_event_port() { + // given + every { memberPort.findAll() } returns listOf(member()) + every { subscriptionQueryUseCase.getByEmail(any()) } returns pref(includeSmall = true) + every { marathonEventPort.findOpenByRegions(any(), any()) } returns listOf(event(distances = listOf("10K"))) + justRun { notificationMailPort.sendNotification(any(), any()) } + + // when + scheduler.sendNotifications() + + // then + verify(exactly = 1) { marathonEventPort.findOpenByRegions(listOf("수도권"), true) } + } + // ── helpers ────────────────────────────────────────────────────────────── private fun member(email: String = "user@test.com") = Member( @@ -221,12 +253,13 @@ class NotificationSchedulerTest { receiveTime: LocalTime = LocalTime.of(currentHour, 0), prefRegions: List = listOf("수도권"), prefDistances: List = listOf("10K", "HALF"), + includeSmall: Boolean = true, ) = SubscriptionResult( receiveDays = receiveDays, receiveTime = receiveTime, prefRegions = prefRegions, prefDistances = prefDistances, - includeSmall = true, + includeSmall = includeSmall, ) private fun event(distances: List) = MarathonEvent( diff --git a/event/src/main/kotlin/com/maggom/event/adapter/out/persistence/MarathonEventJpaEntity.kt b/event/src/main/kotlin/com/maggom/event/adapter/out/persistence/MarathonEventJpaEntity.kt index bbbe7d1..a5f383a 100644 --- a/event/src/main/kotlin/com/maggom/event/adapter/out/persistence/MarathonEventJpaEntity.kt +++ b/event/src/main/kotlin/com/maggom/event/adapter/out/persistence/MarathonEventJpaEntity.kt @@ -1,5 +1,6 @@ package com.maggom.event.adapter.out.persistence +import com.maggom.event.domain.EventScale import com.maggom.event.domain.MarathonEvent import com.maggom.event.domain.MarathonEventStatus import jakarta.persistence.Column @@ -46,6 +47,10 @@ class MarathonEventJpaEntity( @Column(nullable = false) val status: MarathonEventStatus, + @Enumerated(EnumType.STRING) + @Column(nullable = false) + val eventScale: EventScale = EventScale.UNKNOWN, + @Column(nullable = false) val sourceName: String, @@ -54,6 +59,9 @@ class MarathonEventJpaEntity( @Column(nullable = false) val crawledAtKst: LocalDateTime, + + @Column(nullable = false, updatable = false) + val createdAt: LocalDateTime = LocalDateTime.now(), ) { fun toDomain(): MarathonEvent = MarathonEvent( id = id, @@ -65,6 +73,7 @@ class MarathonEventJpaEntity( regEndDate = regEndDate, linkUrl = linkUrl, status = status, + eventScale = eventScale, sourceName = sourceName, sourceUrl = sourceUrl, crawledAtKst = crawledAtKst, diff --git a/event/src/main/kotlin/com/maggom/event/adapter/out/persistence/MarathonEventPersistenceAdapter.kt b/event/src/main/kotlin/com/maggom/event/adapter/out/persistence/MarathonEventPersistenceAdapter.kt index fafc472..af02b4b 100644 --- a/event/src/main/kotlin/com/maggom/event/adapter/out/persistence/MarathonEventPersistenceAdapter.kt +++ b/event/src/main/kotlin/com/maggom/event/adapter/out/persistence/MarathonEventPersistenceAdapter.kt @@ -9,9 +9,9 @@ class MarathonEventPersistenceAdapter( private val marathonEventQueryRepository: MarathonEventQueryRepository, ) : MarathonEventPort { - override fun findOpenByRegions(regions: List): List { + override fun findOpenByRegions(regions: List, includeSmall: Boolean): List { return marathonEventQueryRepository - .findOpenByRegions(regions) + .findOpenByRegions(regions, includeSmall) .map { it.toDomain() } } } diff --git a/event/src/main/kotlin/com/maggom/event/adapter/out/persistence/MarathonEventQueryRepository.kt b/event/src/main/kotlin/com/maggom/event/adapter/out/persistence/MarathonEventQueryRepository.kt index 3f4e6f9..3c01bf2 100644 --- a/event/src/main/kotlin/com/maggom/event/adapter/out/persistence/MarathonEventQueryRepository.kt +++ b/event/src/main/kotlin/com/maggom/event/adapter/out/persistence/MarathonEventQueryRepository.kt @@ -1,8 +1,11 @@ package com.maggom.event.adapter.out.persistence +import com.maggom.event.domain.EventScale import com.maggom.event.domain.MarathonEventStatus import com.maggom.event.domain.RegionGroup import com.querydsl.core.BooleanBuilder +import com.querydsl.core.types.dsl.BooleanExpression +import com.querydsl.core.types.dsl.DateTimeExpression import com.querydsl.core.types.dsl.Expressions import com.querydsl.jpa.impl.JPAQueryFactory import org.springframework.stereotype.Repository @@ -14,18 +17,9 @@ class MarathonEventQueryRepository( ) { private val event = QMarathonEventJpaEntity.marathonEventJpaEntity - fun findOpenByRegions(regions: List): List { + fun findOpenByRegions(regions: List, includeSmall: Boolean = true): List { val now = LocalDateTime.now() - val statusOrder = Expressions.cases() - .`when`(event.status.eq(MarathonEventStatus.OPEN)).then(0) - .otherwise(1) - - // OPEN: regEndDate 오름차순 (마감 임박 순), UPCOMING: regStartDate 오름차순 (오픈 빠른 순) - val secondarySort = Expressions.cases() - .`when`(event.status.eq(MarathonEventStatus.OPEN)).then(event.regEndDate) - .otherwise(event.regStartDate) - val prefixes = regions.flatMap { RegionGroup.toPrefixes(it) } val regionPredicate = prefixes.fold(BooleanBuilder()) { builder, prefix -> builder.or(event.region.startsWith(prefix)) @@ -37,11 +31,73 @@ class MarathonEventQueryRepository( event.status.`in`(MarathonEventStatus.OPEN, MarathonEventStatus.UPCOMING), regionPredicate, event.regEndDate.isNull.or(event.regEndDate.gt(now)), + scalePredicate(includeSmall), ) .orderBy( - statusOrder.asc(), - secondarySort.asc().nullsLast(), + tier(now).asc(), + earliestFirstKey(now).asc(), + newestFirstKey(now).desc(), + event.eventDate.asc(), ) .fetch() } + + /** + * 알림 우선순위. + * + * 0. 접수 임박 - 곧 접수가 열리는 대회 (인기 대회는 오픈 당일 마감되므로 가장 가치가 높다) + * 1. 마감 임박 - 지금 신청하지 않으면 놓치는 대회 + * 2. 접수중 - 나머지 접수중 대회 (신규 수집순이라 발송할 때마다 목록이 갱신된다) + * 3. 접수 예정 - 오픈까지 여유가 있는 대회 + */ + private fun tier(now: LocalDateTime) = Expressions.cases() + .`when`(openingSoon(now)).then(0) + .`when`(closingSoon(now)).then(1) + .`when`(event.status.eq(MarathonEventStatus.OPEN)).then(2) + .otherwise(3) + + /** + * 0·1·3순위는 날짜가 빠른 순. + * 2순위는 아래 신규순으로 정렬해야 하므로 고정값을 넣어 이 키의 영향을 없앤다. + * (정렬은 tier로 이미 분리되어 다른 순위와 섞이지 않는다.) + */ + private fun earliestFirstKey(now: LocalDateTime): DateTimeExpression = Expressions.cases() + .`when`(openingSoon(now)).then(event.regStartDate) + .`when`(closingSoon(now)).then(event.regEndDate) + .`when`(event.status.eq(MarathonEventStatus.OPEN)).then(sortKeyPlaceholder()) + .otherwise(event.regStartDate) + + /** 2순위(접수중)만 최근 수집된 대회를 먼저 노출해 매 발송마다 목록이 고이지 않게 한다. */ + private fun newestFirstKey(now: LocalDateTime): DateTimeExpression = Expressions.cases() + .`when`(openingSoon(now).or(closingSoon(now))).then(sortKeyPlaceholder()) + .`when`(event.status.eq(MarathonEventStatus.OPEN)).then(event.createdAt) + .otherwise(sortKeyPlaceholder()) + + private fun openingSoon(now: LocalDateTime): BooleanExpression { + return event.status.eq(MarathonEventStatus.UPCOMING) + .and(event.regStartDate.loe(now.plusDays(IMMINENT_DAYS))) + } + + private fun closingSoon(now: LocalDateTime): BooleanExpression { + return event.status.eq(MarathonEventStatus.OPEN) + .and(event.regEndDate.isNotNull) + .and(event.regEndDate.loe(now.plusDays(IMMINENT_DAYS))) + } + + // includeSmall=false면 SMALL만 제외하고 MAJOR, UNKNOWN은 포함 (null 반환 시 조건 미적용) + private fun scalePredicate(includeSmall: Boolean): BooleanExpression? { + if (includeSmall) return null + + return event.eventScale.ne(EventScale.SMALL) + } + + /** 해당 순위에서 쓰지 않는 정렬 키를 무력화하기 위한 고정값. */ + private fun sortKeyPlaceholder(): DateTimeExpression { + return Expressions.asDateTime(SORT_KEY_PLACEHOLDER) + } + + companion object { + private const val IMMINENT_DAYS = 7L + private val SORT_KEY_PLACEHOLDER: LocalDateTime = LocalDateTime.of(1970, 1, 1, 0, 0) + } } diff --git a/event/src/main/kotlin/com/maggom/event/domain/EventScale.kt b/event/src/main/kotlin/com/maggom/event/domain/EventScale.kt new file mode 100644 index 0000000..ee957e3 --- /dev/null +++ b/event/src/main/kotlin/com/maggom/event/domain/EventScale.kt @@ -0,0 +1,7 @@ +package com.maggom.event.domain + +enum class EventScale { + MAJOR, + SMALL, + UNKNOWN, +} diff --git a/event/src/main/kotlin/com/maggom/event/domain/MarathonEvent.kt b/event/src/main/kotlin/com/maggom/event/domain/MarathonEvent.kt index c003b7d..65540ad 100644 --- a/event/src/main/kotlin/com/maggom/event/domain/MarathonEvent.kt +++ b/event/src/main/kotlin/com/maggom/event/domain/MarathonEvent.kt @@ -13,6 +13,7 @@ data class MarathonEvent( val regEndDate: LocalDateTime?, val linkUrl: String, val status: MarathonEventStatus, + val eventScale: EventScale = EventScale.UNKNOWN, val sourceName: String, val sourceUrl: String, val crawledAtKst: LocalDateTime, diff --git a/event/src/main/kotlin/com/maggom/event/port/out/MarathonEventPort.kt b/event/src/main/kotlin/com/maggom/event/port/out/MarathonEventPort.kt index 69b021f..6df57c4 100644 --- a/event/src/main/kotlin/com/maggom/event/port/out/MarathonEventPort.kt +++ b/event/src/main/kotlin/com/maggom/event/port/out/MarathonEventPort.kt @@ -3,5 +3,5 @@ package com.maggom.event.port.out import com.maggom.event.domain.MarathonEvent interface MarathonEventPort { - fun findOpenByRegions(regions: List): List + fun findOpenByRegions(regions: List, includeSmall: Boolean = true): List } From 684d2566e836e683ac488c9f0e4a98ba4a9659cd Mon Sep 17 00:00:00 2001 From: fakerdeft Date: Sun, 30 Aug 2026 13:53:44 +0900 Subject: [PATCH 2/3] =?UTF-8?q?feat:=20=EB=A9=94=EC=9D=BC=20=EC=9B=90?= =?UTF-8?q?=ED=81=B4=EB=A6=AD=20=EA=B5=AC=EB=8F=85=20=ED=95=B4=EC=A7=80=20?= =?UTF-8?q?=EC=A7=80=EC=9B=90=20-=20HMAC=20=EC=84=9C=EB=AA=85=20=EA=B8=B0?= =?UTF-8?q?=EB=B0=98=20=EB=AC=B4=EA=B8=B0=ED=95=9C=20=ED=95=B4=EC=A7=80=20?= =?UTF-8?q?=ED=86=A0=ED=81=B0=20=EB=B0=8F=20=EC=9D=B8=EC=A6=9D=20=EC=97=86?= =?UTF-8?q?=EC=9D=B4=20=EC=A0=91=EA=B7=BC=20=EA=B0=80=EB=8A=A5=ED=95=9C=20?= =?UTF-8?q?=ED=95=B4=EC=A7=80=20=EC=97=94=EB=93=9C=ED=8F=AC=EC=9D=B8?= =?UTF-8?q?=ED=8A=B8=20=EC=B6=94=EA=B0=80=20-=20List-Unsubscribe=20/=20Lis?= =?UTF-8?q?t-Unsubscribe-Post=20=ED=97=A4=EB=8D=94=EB=A1=9C=20RFC=208058?= =?UTF-8?q?=20=EC=9B=90=ED=81=B4=EB=A6=AD=20=ED=95=B4=EC=A7=80=20=EB=8C=80?= =?UTF-8?q?=EC=9D=91=20-=20=EC=95=8C=EB=A6=BC/=EC=9B=B0=EC=BB=B4=20?= =?UTF-8?q?=EB=A9=94=EC=9D=BC=20=ED=91=B8=ED=84=B0=EC=97=90=20=EA=B0=9C?= =?UTF-8?q?=EC=9D=B8=20=ED=95=B4=EC=A7=80=20=EB=A7=81=ED=81=AC=20=EB=B0=8F?= =?UTF-8?q?=20=EA=B2=B0=EA=B3=BC=20=EC=95=88=EB=82=B4=20=ED=8E=98=EC=9D=B4?= =?UTF-8?q?=EC=A7=80=20=EC=B6=94=EA=B0=80=20-=20=ED=95=B4=EC=A7=80=20?= =?UTF-8?q?=ED=86=A0=ED=81=B0=20=EC=84=9C=EB=AA=85=ED=82=A4=EB=8A=94=20JWT?= =?UTF-8?q?=20=EC=8B=9C=ED=81=AC=EB=A6=BF=EC=97=90=EC=84=9C=20=EC=9A=A9?= =?UTF-8?q?=EB=8F=84=EB=B3=84=EB=A1=9C=20=EB=B6=84=EB=A6=AC=20=ED=8C=8C?= =?UTF-8?q?=EC=83=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/maggom/app/adapter/MailAdapter.kt | 98 ++++++++++++++----- .../app/adapter/UnsubscribeTokenAdapter.kt | 73 ++++++++++++++ .../adapter/in/web/UnsubscribeController.kt | 40 ++++++++ .../app/filter/JwtAuthenticationFilter.kt | 1 + .../templates/mail/notification.html | 3 +- .../resources/templates/mail/welcome.html | 3 +- .../templates/unsubscribe/result.html | 66 +++++++++++++ .../UnsubscribeTokenAdapterTest.kt | 88 +++++++++++++++++ .../member/application/UnsubscribeService.kt | 35 +++++++ .../member/port/in/UnsubscribeUseCase.kt | 5 + .../member/port/out/UnsubscribeTokenPort.kt | 6 ++ .../application/UnsubscribeServiceTest.kt | 64 ++++++++++++ 12 files changed, 455 insertions(+), 27 deletions(-) create mode 100644 app/src/main/kotlin/com/maggom/app/adapter/UnsubscribeTokenAdapter.kt create mode 100644 app/src/main/kotlin/com/maggom/app/adapter/in/web/UnsubscribeController.kt create mode 100644 app/src/main/resources/templates/unsubscribe/result.html create mode 100644 app/src/test/kotlin/com/maggom/app/unsubscribe/UnsubscribeTokenAdapterTest.kt create mode 100644 member/src/main/kotlin/com/maggom/member/application/UnsubscribeService.kt create mode 100644 member/src/main/kotlin/com/maggom/member/port/in/UnsubscribeUseCase.kt create mode 100644 member/src/main/kotlin/com/maggom/member/port/out/UnsubscribeTokenPort.kt create mode 100644 member/src/test/kotlin/com/maggom/member/application/UnsubscribeServiceTest.kt diff --git a/app/src/main/kotlin/com/maggom/app/adapter/MailAdapter.kt b/app/src/main/kotlin/com/maggom/app/adapter/MailAdapter.kt index 74b3333..6464bc3 100644 --- a/app/src/main/kotlin/com/maggom/app/adapter/MailAdapter.kt +++ b/app/src/main/kotlin/com/maggom/app/adapter/MailAdapter.kt @@ -8,6 +8,7 @@ import com.maggom.auth.port.out.WelcomeMailPort import com.maggom.event.domain.MarathonEvent import com.maggom.event.port.out.MarathonEventPort import com.maggom.event.port.out.NotificationMailPort +import com.maggom.member.port.out.UnsubscribeTokenPort import jakarta.mail.internet.InternetAddress import jakarta.mail.internet.MimeMessage import org.slf4j.LoggerFactory @@ -28,53 +29,83 @@ class MailAdapter( private val templateEngine: TemplateEngine, @Value("\${spring.mail.from-email}") private val fromEmail: String, @Value("\${spring.mail.from-name}") private val fromName: String, + @Value("\${maggom.mail.base-url}") private val baseUrl: String, @Autowired(required = false) private val fallbackMailProps: FallbackMailProperties?, @Autowired(required = false) @Qualifier("gmailMailSender") private val fallbackMailSender: JavaMailSender?, private val marathonEventPort: MarathonEventPort, + private val unsubscribeTokenPort: UnsubscribeTokenPort, ) : EmailSenderPort, WelcomeMailPort, NotificationMailPort, TestMailPort { companion object { private val DEFAULT_REGIONS = listOf("수도권") private const val WELCOME_EVENTS_COUNT = 2 + private const val UNSUBSCRIBE_PATH = "/api/v1/subscriptions/unsubscribe" } private val log = LoggerFactory.getLogger(javaClass) + private data class MailSpec( + val to: String, + val subject: String, + val template: String, + val context: Context, + val unsubscribeUrl: String? = null, + ) + + private data class MailPayload( + val to: String, + val subject: String, + val html: String, + val unsubscribeUrl: String?, + ) + override fun sendAuthCode(message: AuthCodeEmailMessage) { val context = Context(Locale.KOREAN).apply { setVariable("code", message.code) setVariable("expiryMinutes", message.expiryMinutes) } sendHtml( - to = message.to, - subject = "[마꼼] 이메일 인증 번호", - template = "mail/auth-code", - context = context, + MailSpec( + to = message.to, + subject = "[마꼼] 이메일 인증 번호", + template = "mail/auth-code", + context = context, + ) ) } override fun sendWelcomeMail(to: String) { val events = marathonEventPort.findOpenByRegions(DEFAULT_REGIONS).take(WELCOME_EVENTS_COUNT) + val unsubscribeUrl = unsubscribeUrl(to) val context = Context(Locale.KOREAN).apply { setVariable("events", events) + setVariable("unsubscribeUrl", unsubscribeUrl) } sendHtml( - to = to, - subject = "[마꼼] 구독을 시작했어요! 🎉", - template = "mail/welcome", - context = context, + MailSpec( + to = to, + subject = "[마꼼] 구독을 시작했어요! 🎉", + template = "mail/welcome", + context = context, + unsubscribeUrl = unsubscribeUrl, + ) ) } override fun sendNotification(to: String, events: List) { + val unsubscribeUrl = unsubscribeUrl(to) val context = Context(Locale.KOREAN).apply { setVariable("events", events) + setVariable("unsubscribeUrl", unsubscribeUrl) } sendHtml( - to = to, - subject = "[마꼼] 마라톤 대회 목록 🏃", - template = "mail/notification", - context = context, + MailSpec( + to = to, + subject = "[마꼼] 마라톤 대회 목록 🏃", + template = "mail/notification", + context = context, + unsubscribeUrl = unsubscribeUrl, + ) ) } @@ -83,35 +114,52 @@ class MailAdapter( setVariable("templateType", templateType) } sendHtml( - to = to, - subject = "[마꼼] 테스트 메일 발송", - template = "mail/test", - context = context, + MailSpec( + to = to, + subject = "[마꼼] 테스트 메일 발송", + template = "mail/test", + context = context, + ) ) } - private fun sendHtml(to: String, subject: String, template: String, context: Context) { - val html = templateEngine.process(template, context) + private fun unsubscribeUrl(to: String): String { + return "$baseUrl$UNSUBSCRIBE_PATH?token=${unsubscribeTokenPort.generate(to)}" + } + + private fun sendHtml(spec: MailSpec) { + val payload = MailPayload( + to = spec.to, + subject = spec.subject, + html = templateEngine.process(spec.template, spec.context), + unsubscribeUrl = spec.unsubscribeUrl, + ) try { - doSend(mailSender, fromEmail, to, subject, html) + doSend(mailSender, fromEmail, payload) } catch (e: MailException) { if (fallbackMailSender != null && fallbackMailProps != null) { - log.warn("주 발송 실패, Gmail 폴백 시도 [to={}, subject={}]: {}", to, subject, e.message) - doSend(fallbackMailSender, fallbackMailProps.fromEmail, to, subject, html) + log.warn("주 발송 실패, Gmail 폴백 시도 [to={}, subject={}]: {}", spec.to, spec.subject, e.message) + doSend(fallbackMailSender, fallbackMailProps.fromEmail, payload) } else { throw e } } } - private fun doSend(sender: JavaMailSender, from: String, to: String, subject: String, html: String) { + private fun doSend(sender: JavaMailSender, from: String, payload: MailPayload) { val message: MimeMessage = sender.createMimeMessage() val helper = MimeMessageHelper(message, true, "UTF-8") helper.setFrom(InternetAddress(from, fromName, "UTF-8")) - helper.setTo(to) - helper.setSubject(subject) - helper.setText(html, true) + helper.setTo(payload.to) + helper.setSubject(payload.subject) + helper.setText(payload.html, true) + + // RFC 8058 원클릭 수신 거부 - 메일 클라이언트가 구독 취소 버튼을 노출한다. + payload.unsubscribeUrl?.let { + message.addHeader("List-Unsubscribe", "<$it>") + message.addHeader("List-Unsubscribe-Post", "List-Unsubscribe=One-Click") + } sender.send(message) } diff --git a/app/src/main/kotlin/com/maggom/app/adapter/UnsubscribeTokenAdapter.kt b/app/src/main/kotlin/com/maggom/app/adapter/UnsubscribeTokenAdapter.kt new file mode 100644 index 0000000..0884826 --- /dev/null +++ b/app/src/main/kotlin/com/maggom/app/adapter/UnsubscribeTokenAdapter.kt @@ -0,0 +1,73 @@ +package com.maggom.app.adapter + +import com.maggom.member.port.out.UnsubscribeTokenPort +import org.springframework.beans.factory.annotation.Value +import org.springframework.stereotype.Component +import java.security.MessageDigest +import java.util.Base64 +import javax.crypto.Mac +import javax.crypto.spec.SecretKeySpec + +/** + * 메일 수신 거부용 서명 토큰 어댑터. + * + * 형식: `base64url(email).base64url(HMAC-SHA256(base64url(email)))` + * 메일은 발송 후 한참 뒤에 열릴 수 있으므로 만료를 두지 않는다. + */ +@Component +class UnsubscribeTokenAdapter( + @Value("\${maggom.auth.unsubscribe-secret:\${maggom.auth.jwt-secret}}") private val secret: String, +) : UnsubscribeTokenPort { + + /** JWT 서명키와 같은 시크릿을 쓰더라도 키 재질이 겹치지 않도록 용도별로 파생한다. */ + private val key by lazy { + val mac = Mac.getInstance(HMAC_ALGORITHM) + mac.init(SecretKeySpec(secret.toByteArray(Charsets.UTF_8), HMAC_ALGORITHM)) + + SecretKeySpec(mac.doFinal(KEY_INFO.toByteArray(Charsets.UTF_8)), HMAC_ALGORITHM) + } + + override fun generate(email: String): String { + val payload = encode(email.toByteArray(Charsets.UTF_8)) + + return "$payload.${sign(payload)}" + } + + override fun extractEmail(token: String): String? { + val parts = token.split(".") + if (parts.size != 2) return null + + val payload = parts[0] + val signature = parts[1] + if (!isValidSignature(payload, signature)) return null + + return try { + String(Base64.getUrlDecoder().decode(payload), Charsets.UTF_8) + } catch (e: IllegalArgumentException) { + null + } + } + + private fun isValidSignature(payload: String, signature: String): Boolean { + return MessageDigest.isEqual( + sign(payload).toByteArray(Charsets.UTF_8), + signature.toByteArray(Charsets.UTF_8), + ) + } + + private fun sign(payload: String): String { + val mac = Mac.getInstance(HMAC_ALGORITHM) + mac.init(key) + + return encode(mac.doFinal(payload.toByteArray(Charsets.UTF_8))) + } + + private fun encode(bytes: ByteArray): String { + return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes) + } + + companion object { + private const val HMAC_ALGORITHM = "HmacSHA256" + private const val KEY_INFO = "maggom:unsubscribe:v1" + } +} diff --git a/app/src/main/kotlin/com/maggom/app/adapter/in/web/UnsubscribeController.kt b/app/src/main/kotlin/com/maggom/app/adapter/in/web/UnsubscribeController.kt new file mode 100644 index 0000000..7b21ef3 --- /dev/null +++ b/app/src/main/kotlin/com/maggom/app/adapter/in/web/UnsubscribeController.kt @@ -0,0 +1,40 @@ +package com.maggom.app.adapter.`in`.web + +import com.maggom.member.port.`in`.UnsubscribeUseCase +import org.springframework.http.HttpStatus +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RequestParam +import org.springframework.web.bind.annotation.ResponseStatus +import org.springframework.web.bind.annotation.RestController +import org.thymeleaf.TemplateEngine +import org.thymeleaf.context.Context +import java.util.Locale + +@RestController +@RequestMapping("/api/v1/subscriptions/unsubscribe") +class UnsubscribeController( + private val unsubscribeUseCase: UnsubscribeUseCase, + private val templateEngine: TemplateEngine, +) { + + /** 메일 본문의 해지 링크 (사람이 클릭) - 결과 안내 페이지를 그대로 응답한다. */ + @GetMapping(produces = ["text/html;charset=UTF-8"]) + @ResponseStatus(HttpStatus.OK) + fun unsubscribeByLink(@RequestParam token: String): String { + val success = unsubscribeUseCase.unsubscribe(token) + val context = Context(Locale.KOREAN).apply { + setVariable("success", success) + } + + return templateEngine.process("unsubscribe/result", context) + } + + /** List-Unsubscribe-Post 원클릭 해지 (RFC 8058) - 메일 클라이언트가 호출한다. */ + @PostMapping + @ResponseStatus(HttpStatus.OK) + fun unsubscribeOneClick(@RequestParam token: String) { + unsubscribeUseCase.unsubscribe(token) + } +} diff --git a/app/src/main/kotlin/com/maggom/app/filter/JwtAuthenticationFilter.kt b/app/src/main/kotlin/com/maggom/app/filter/JwtAuthenticationFilter.kt index fc622cd..3e47c84 100644 --- a/app/src/main/kotlin/com/maggom/app/filter/JwtAuthenticationFilter.kt +++ b/app/src/main/kotlin/com/maggom/app/filter/JwtAuthenticationFilter.kt @@ -84,6 +84,7 @@ class JwtAuthenticationFilter( private val PUBLIC_PATHS = listOf( "/api/v1/auth/", "/api/v1/subscriptions/count", + "/api/v1/subscriptions/unsubscribe", ) } } diff --git a/app/src/main/resources/templates/mail/notification.html b/app/src/main/resources/templates/mail/notification.html index 98340b1..d722c9b 100644 --- a/app/src/main/resources/templates/mail/notification.html +++ b/app/src/main/resources/templates/mail/notification.html @@ -60,7 +60,8 @@

- 수신 설정 변경 또는 구독 해지는 마꼼에서 가능합니다.
+ 수신 설정 변경은 마꼼에서 가능합니다.
+ 이 메일을 더 받고 싶지 않으시면 구독 해지를 눌러주세요.
본 메일은 발신 전용입니다.

diff --git a/app/src/main/resources/templates/mail/welcome.html b/app/src/main/resources/templates/mail/welcome.html index 77b1847..20b7738 100644 --- a/app/src/main/resources/templates/mail/welcome.html +++ b/app/src/main/resources/templates/mail/welcome.html @@ -66,7 +66,8 @@

- 수신 설정 변경 또는 구독 해지는 마꼼에서 가능합니다.
+ 수신 설정 변경은 마꼼에서 가능합니다.
+ 이 메일을 더 받고 싶지 않으시면 구독 해지를 눌러주세요.
본 메일은 발신 전용입니다.

diff --git a/app/src/main/resources/templates/unsubscribe/result.html b/app/src/main/resources/templates/unsubscribe/result.html new file mode 100644 index 0000000..2d9a2ed --- /dev/null +++ b/app/src/main/resources/templates/unsubscribe/result.html @@ -0,0 +1,66 @@ + + + + + + 마꼼 구독 해지 + + + + + + +
+ + + + + + + + + + + + + + + + + +
+ +

마꼼

+
+
+
+

구독이 해지되었어요

+

+ 더 이상 마라톤 대회 알림 메일을 보내드리지 않습니다.
+ 그동안 이용해 주셔서 감사합니다. +

+ + 다시 구독하기 + +
+ +
+

해지 링크가 올바르지 않아요

+

+ 링크가 손상되었거나 잘못된 주소입니다.
+ 마꼼에서 직접 구독을 해지하실 수 있습니다. +

+ + 마꼼으로 이동 + +
+
+

+ 본 페이지는 마꼼 알림 메일의 수신 거부 처리 결과입니다. +

+
+
+ + diff --git a/app/src/test/kotlin/com/maggom/app/unsubscribe/UnsubscribeTokenAdapterTest.kt b/app/src/test/kotlin/com/maggom/app/unsubscribe/UnsubscribeTokenAdapterTest.kt new file mode 100644 index 0000000..b48512e --- /dev/null +++ b/app/src/test/kotlin/com/maggom/app/unsubscribe/UnsubscribeTokenAdapterTest.kt @@ -0,0 +1,88 @@ +package com.maggom.app.unsubscribe + +import com.maggom.app.adapter.UnsubscribeTokenAdapter +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class UnsubscribeTokenAdapterTest { + + private val adapter = UnsubscribeTokenAdapter("test-unsubscribe-secret-key-at-least-32-chars!!") + + @Test + @DisplayName("생성한 토큰에서 원본 이메일 추출") + fun generated_token_returns_original_email() { + // given + val email = "user+tag@test.com" + + // when + val token = adapter.generate(email) + + // then + assertEquals(email, adapter.extractEmail(token)) + } + + @Test + @DisplayName("토큰은 URL 안전 문자만 포함") + fun token_contains_only_url_safe_characters() { + // given + val token = adapter.generate("user+tag@test.com") + + // when + val illegal = token.filterNot { it.isLetterOrDigit() || it in "-_." } + + // then + assertEquals("", illegal) + } + + @Test + @DisplayName("서명이 위조된 토큰은 거부") + fun tampered_signature_is_rejected() { + // given + val token = adapter.generate("user@test.com") + val payload = token.substringBefore(".") + + // when + val result = adapter.extractEmail("$payload.forged-signature") + + // then + assertNull(result) + } + + @Test + @DisplayName("payload가 바뀐 토큰은 거부") + fun tampered_payload_is_rejected() { + // given + val token = adapter.generate("user@test.com") + val otherPayload = adapter.generate("attacker@test.com").substringBefore(".") + + // when + val result = adapter.extractEmail("$otherPayload.${token.substringAfter(".")}") + + // then + assertNull(result) + } + + @Test + @DisplayName("형식이 잘못된 토큰은 거부") + fun malformed_token_is_rejected() { + assertNull(adapter.extractEmail("")) + assertNull(adapter.extractEmail("no-separator")) + assertNull(adapter.extractEmail("a.b.c")) + } + + @Test + @DisplayName("다른 시크릿으로 생성한 토큰은 거부") + fun token_from_another_secret_is_rejected() { + // given + val other = UnsubscribeTokenAdapter("another-secret-key-at-least-32-characters!!") + val token = other.generate("user@test.com") + + // when + val result = adapter.extractEmail(token) + + // then + assertNull(result) + } +} diff --git a/member/src/main/kotlin/com/maggom/member/application/UnsubscribeService.kt b/member/src/main/kotlin/com/maggom/member/application/UnsubscribeService.kt new file mode 100644 index 0000000..01bbde9 --- /dev/null +++ b/member/src/main/kotlin/com/maggom/member/application/UnsubscribeService.kt @@ -0,0 +1,35 @@ +package com.maggom.member.application + +import com.maggom.common.exception.MemberNotFoundException +import com.maggom.member.port.`in`.SubscriptionDeleteUseCase +import com.maggom.member.port.`in`.UnsubscribeUseCase +import com.maggom.member.port.out.UnsubscribeTokenPort +import org.slf4j.LoggerFactory +import org.springframework.stereotype.Service + +@Service +class UnsubscribeService( + private val unsubscribeTokenPort: UnsubscribeTokenPort, + private val subscriptionDeleteUseCase: SubscriptionDeleteUseCase, +) : UnsubscribeUseCase { + + private val log = LoggerFactory.getLogger(this::class.java) + + override fun unsubscribe(token: String): Boolean { + val email = unsubscribeTokenPort.extractEmail(token) + if (email == null) { + log.debug("유효하지 않은 구독 해지 토큰") + + return false + } + + return try { + subscriptionDeleteUseCase.delete(email) + true + } catch (e: MemberNotFoundException) { + // 이미 해지된 경우도 성공으로 처리 (원클릭 해지는 멱등해야 함) + log.debug("이미 해지된 구독 - email: {}", email) + true + } + } +} diff --git a/member/src/main/kotlin/com/maggom/member/port/in/UnsubscribeUseCase.kt b/member/src/main/kotlin/com/maggom/member/port/in/UnsubscribeUseCase.kt new file mode 100644 index 0000000..f2a9955 --- /dev/null +++ b/member/src/main/kotlin/com/maggom/member/port/in/UnsubscribeUseCase.kt @@ -0,0 +1,5 @@ +package com.maggom.member.port.`in` + +interface UnsubscribeUseCase { + fun unsubscribe(token: String): Boolean +} diff --git a/member/src/main/kotlin/com/maggom/member/port/out/UnsubscribeTokenPort.kt b/member/src/main/kotlin/com/maggom/member/port/out/UnsubscribeTokenPort.kt new file mode 100644 index 0000000..d67df53 --- /dev/null +++ b/member/src/main/kotlin/com/maggom/member/port/out/UnsubscribeTokenPort.kt @@ -0,0 +1,6 @@ +package com.maggom.member.port.out + +interface UnsubscribeTokenPort { + fun generate(email: String): String + fun extractEmail(token: String): String? +} diff --git a/member/src/test/kotlin/com/maggom/member/application/UnsubscribeServiceTest.kt b/member/src/test/kotlin/com/maggom/member/application/UnsubscribeServiceTest.kt new file mode 100644 index 0000000..a66da5a --- /dev/null +++ b/member/src/test/kotlin/com/maggom/member/application/UnsubscribeServiceTest.kt @@ -0,0 +1,64 @@ +package com.maggom.member.application + +import com.maggom.common.exception.MemberNotFoundException +import com.maggom.member.port.`in`.SubscriptionDeleteUseCase +import com.maggom.member.port.out.UnsubscribeTokenPort +import io.mockk.every +import io.mockk.justRun +import io.mockk.mockk +import io.mockk.verify +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class UnsubscribeServiceTest { + + private val unsubscribeTokenPort: UnsubscribeTokenPort = mockk() + private val subscriptionDeleteUseCase: SubscriptionDeleteUseCase = mockk() + + private val service = UnsubscribeService(unsubscribeTokenPort, subscriptionDeleteUseCase) + + @Test + @DisplayName("유효한 토큰이면 구독을 해지하고 true 반환") + fun valid_token_deletes_subscription() { + // given + every { unsubscribeTokenPort.extractEmail("valid-token") } returns "user@test.com" + justRun { subscriptionDeleteUseCase.delete("user@test.com") } + + // when + val result = service.unsubscribe("valid-token") + + // then + assertTrue(result) + verify(exactly = 1) { subscriptionDeleteUseCase.delete("user@test.com") } + } + + @Test + @DisplayName("유효하지 않은 토큰이면 해지하지 않고 false 반환") + fun invalid_token_returns_false() { + // given + every { unsubscribeTokenPort.extractEmail("invalid-token") } returns null + + // when + val result = service.unsubscribe("invalid-token") + + // then + assertFalse(result) + verify(exactly = 0) { subscriptionDeleteUseCase.delete(any()) } + } + + @Test + @DisplayName("이미 해지된 회원이면 멱등하게 true 반환") + fun already_unsubscribed_member_returns_true() { + // given + every { unsubscribeTokenPort.extractEmail("valid-token") } returns "gone@test.com" + every { subscriptionDeleteUseCase.delete("gone@test.com") } throws MemberNotFoundException() + + // when + val result = service.unsubscribe("valid-token") + + // then + assertTrue(result) + } +} From a7ef2dcf7176e6b5b5e23ccb0617a3f2ab9ae50b Mon Sep 17 00:00:00 2001 From: fakerdeft Date: Sun, 30 Aug 2026 13:54:06 +0900 Subject: [PATCH 3/3] =?UTF-8?q?chore:=20Flyway=20=EB=A7=88=EC=9D=B4?= =?UTF-8?q?=EA=B7=B8=EB=A0=88=EC=9D=B4=EC=85=98=20=EB=8F=84=EC=9E=85=20-?= =?UTF-8?q?=20spring-boot-flyway=20=EB=B0=8F=20flyway-core/postgresql=20?= =?UTF-8?q?=EC=9D=98=EC=A1=B4=EC=84=B1=20=EC=B6=94=EA=B0=80=20-=20?= =?UTF-8?q?=EA=B8=B0=EC=A1=B4=20schema.sql=EC=9D=84=20V1=20=EC=B4=88?= =?UTF-8?q?=EA=B8=B0=20=EB=A7=88=EC=9D=B4=EA=B7=B8=EB=A0=88=EC=9D=B4?= =?UTF-8?q?=EC=85=98=EC=9C=BC=EB=A1=9C=20=EC=9D=B4=EA=B4=80,=20=EC=9A=B4?= =?UTF-8?q?=EC=98=81=20DB=EB=8A=94=20baseline=20=EC=B2=98=EB=A6=AC=20-=20?= =?UTF-8?q?=EB=A7=88=EC=9D=B4=EA=B7=B8=EB=A0=88=EC=9D=B4=EC=85=98=EA=B3=BC?= =?UTF-8?q?=20=EC=97=94=ED=8B=B0=ED=8B=B0=20=EB=A7=A4=ED=95=91=EC=9D=84=20?= =?UTF-8?q?=EA=B2=80=EC=A6=9D=ED=95=98=EB=8A=94=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/build.gradle.kts | 3 + app/config | 2 +- .../db/migration/V1__init_schema.sql | 124 ++++++++++++++++++ .../app/migration/FlywayMigrationTest.kt | 78 +++++++++++ app/src/test/resources/application-test.yml | 3 + 5 files changed, 209 insertions(+), 1 deletion(-) create mode 100644 app/src/main/resources/db/migration/V1__init_schema.sql create mode 100644 app/src/test/kotlin/com/maggom/app/migration/FlywayMigrationTest.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index c45436d..39b1158 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -18,6 +18,9 @@ dependencies { implementation(project(":admin")) implementation("org.springframework.boot:spring-boot-starter-web") implementation("org.springframework.boot:spring-boot-starter-data-jpa") + implementation("org.springframework.boot:spring-boot-flyway") + implementation("org.flywaydb:flyway-core") + runtimeOnly("org.flywaydb:flyway-database-postgresql") implementation("org.springframework.boot:spring-boot-starter-mail") implementation("org.springframework.boot:spring-boot-starter-thymeleaf") implementation("com.fasterxml.jackson.module:jackson-module-kotlin") diff --git a/app/config b/app/config index d824dc5..6833339 160000 --- a/app/config +++ b/app/config @@ -1 +1 @@ -Subproject commit d824dc534b6b56ac95a455f1e2fe23321b9b6240 +Subproject commit 6833339c51d709dad4e2938c996ed5f995a364cb diff --git a/app/src/main/resources/db/migration/V1__init_schema.sql b/app/src/main/resources/db/migration/V1__init_schema.sql new file mode 100644 index 0000000..1b186f6 --- /dev/null +++ b/app/src/main/resources/db/migration/V1__init_schema.sql @@ -0,0 +1,124 @@ +-- 최초 스키마. 기존 운영 DB는 baseline 처리되어 이 스크립트를 건너뛴다. + + +-- ── 1. 회원/구독 ───────────────────────────────────────────────────────────── + +CREATE TABLE IF NOT EXISTS member ( + id BIGSERIAL PRIMARY KEY, + email VARCHAR(255) NOT NULL UNIQUE, + role VARCHAR(20) NOT NULL DEFAULT 'MEMBER', + is_verified BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMP NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS subscription_preference ( + id BIGSERIAL PRIMARY KEY, + member_id BIGINT NOT NULL UNIQUE REFERENCES member (id) ON DELETE CASCADE, + receive_days VARCHAR(50) NOT NULL DEFAULT 'MON,WED,FRI', + receive_time TIME NOT NULL DEFAULT '08:00:00', + pref_regions TEXT NOT NULL DEFAULT '["수도권"]', + pref_distances TEXT NOT NULL DEFAULT '["10K","HALF"]', + include_small BOOLEAN NOT NULL DEFAULT TRUE +); + +-- ── 2. 마라톤 이벤트 (API + 크롤러 공용) ────────────────────────────────────── + +CREATE TABLE IF NOT EXISTS marathon_event ( + id BIGSERIAL PRIMARY KEY, + title VARCHAR(255) NOT NULL, + event_date DATE NOT NULL, + region VARCHAR(100) NOT NULL, + distances TEXT NOT NULL DEFAULT '[]', + reg_start_date TIMESTAMP NOT NULL, + reg_end_date TIMESTAMP, + is_major BOOLEAN NOT NULL DEFAULT FALSE, + event_scale VARCHAR(20) NOT NULL DEFAULT 'UNKNOWN', + link_url TEXT NOT NULL, + status VARCHAR(20) NOT NULL, + source_name VARCHAR(100), + source_url TEXT, + crawled_at_kst TIMESTAMP, + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP NOT NULL DEFAULT NOW() +); + +CREATE UNIQUE INDEX IF NOT EXISTS uq_marathon_event_dedup + ON marathon_event (title, event_date, region, link_url); +CREATE INDEX IF NOT EXISTS idx_marathon_event_event_date + ON marathon_event (event_date); +CREATE INDEX IF NOT EXISTS idx_marathon_event_status + ON marathon_event (status); +CREATE INDEX IF NOT EXISTS idx_marathon_event_event_scale + ON marathon_event (event_scale); + +-- ── 3. 크롤러 raw 데이터 ────────────────────────────────────────────────────── + +CREATE TABLE IF NOT EXISTS raw_crawled_data ( + id BIGSERIAL PRIMARY KEY, + source VARCHAR(100) NOT NULL, + payload JSONB NOT NULL, + parsed_status VARCHAR(20) NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_raw_crawled_data_source_created_at + ON raw_crawled_data (source, created_at DESC); +CREATE INDEX IF NOT EXISTS idx_raw_crawled_data_parsed_status + ON raw_crawled_data (parsed_status); + +-- ── 4. 연간 재개최 추적 ─────────────────────────────────────────────────────── + +CREATE TABLE IF NOT EXISTS marathon_event_watch ( + id BIGSERIAL PRIMARY KEY, + watch_key VARCHAR(255) NOT NULL UNIQUE, + base_title VARCHAR(255) NOT NULL, + sample_title VARCHAR(255) NOT NULL, + last_event_date DATE NOT NULL, + expected_event_date DATE NOT NULL, + detected_event_date DATE, + status VARCHAR(20) NOT NULL, + source_name VARCHAR(100), + source_url TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_marathon_event_watch_status + ON marathon_event_watch (status); +CREATE INDEX IF NOT EXISTS idx_marathon_event_watch_expected_event_date + ON marathon_event_watch (expected_event_date); + +CREATE TABLE IF NOT EXISTS marathon_event_watch_seed ( + id BIGSERIAL PRIMARY KEY, + title VARCHAR(255) NOT NULL, + event_date DATE NOT NULL, + source_name VARCHAR(100), + source_url TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE UNIQUE INDEX IF NOT EXISTS uq_marathon_event_watch_seed_dedup + ON marathon_event_watch_seed (title, event_date, COALESCE(source_name, ''), COALESCE(source_url, '')); + +-- ── 5. 크롤러 소스 레지스트리 ───────────────────────────────────────────────── + +CREATE TABLE IF NOT EXISTS crawler_source_registry ( + id BIGSERIAL PRIMARY KEY, + source_name VARCHAR(100) NOT NULL UNIQUE, + source_url TEXT, + source_kind VARCHAR(30) NOT NULL DEFAULT 'UNKNOWN', + enabled BOOLEAN NOT NULL DEFAULT TRUE, + first_seen_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + last_seen_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + last_crawled_at TIMESTAMPTZ, + last_success_at TIMESTAMPTZ, + last_status VARCHAR(20) NOT NULL DEFAULT 'UNKNOWN', + last_event_count INTEGER NOT NULL DEFAULT 0, + last_rare_event_count INTEGER NOT NULL DEFAULT 0, + success_count INTEGER NOT NULL DEFAULT 0, + fail_count INTEGER NOT NULL DEFAULT 0, + last_error TEXT +); + +CREATE INDEX IF NOT EXISTS idx_crawler_source_registry_status_enabled + ON crawler_source_registry (last_status, enabled); diff --git a/app/src/test/kotlin/com/maggom/app/migration/FlywayMigrationTest.kt b/app/src/test/kotlin/com/maggom/app/migration/FlywayMigrationTest.kt new file mode 100644 index 0000000..069b138 --- /dev/null +++ b/app/src/test/kotlin/com/maggom/app/migration/FlywayMigrationTest.kt @@ -0,0 +1,78 @@ +package com.maggom.app.migration + +import jakarta.persistence.EntityManager +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.mail.javamail.JavaMailSender +import org.springframework.test.context.ActiveProfiles +import org.springframework.test.context.DynamicPropertyRegistry +import org.springframework.test.context.DynamicPropertySource +import org.springframework.test.context.bean.override.mockito.MockitoBean +import org.testcontainers.containers.PostgreSQLContainer +import org.testcontainers.junit.jupiter.Container +import org.testcontainers.junit.jupiter.Testcontainers +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * 운영과 동일하게 Flyway로 스키마를 만들고 `ddl-auto: validate`로 엔티티 매핑을 검증한다. + * 마이그레이션 스크립트와 엔티티가 어긋나면 컨텍스트 로딩 단계에서 실패한다. + */ +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE) +@ActiveProfiles("test") +@Testcontainers +class FlywayMigrationTest { + + companion object { + @Container + @JvmStatic + val postgres: PostgreSQLContainer<*> = PostgreSQLContainer("postgres:16") + + @JvmStatic + @DynamicPropertySource + fun properties(registry: DynamicPropertyRegistry) { + registry.add("spring.datasource.url", postgres::getJdbcUrl) + registry.add("spring.datasource.username", postgres::getUsername) + registry.add("spring.datasource.password", postgres::getPassword) + registry.add("spring.flyway.enabled") { true } + registry.add("spring.jpa.hibernate.ddl-auto") { "validate" } + } + } + + @MockitoBean + lateinit var mailSender: JavaMailSender + + @Autowired + lateinit var entityManager: EntityManager + + @Test + @DisplayName("마이그레이션이 성공하고 스키마 이력이 기록된다") + fun migration_is_applied_and_recorded() { + // when + val applied = entityManager + .createNativeQuery("SELECT COUNT(*) FROM flyway_schema_history WHERE success = true") + .singleResult as Number + + // then + assertTrue(applied.toInt() >= 1) + } + + @Test + @DisplayName("알림 정렬에 사용하는 컬럼이 마이그레이션에 포함된다") + fun notification_sorting_columns_exist() { + // when + val count = entityManager + .createNativeQuery( + """ + SELECT COUNT(*) FROM information_schema.columns + WHERE table_name = 'marathon_event' AND column_name IN ('event_scale', 'created_at') + """.trimIndent() + ) + .singleResult as Number + + // then + assertEquals(2, count.toInt()) + } +} diff --git a/app/src/test/resources/application-test.yml b/app/src/test/resources/application-test.yml index db60ff1..d2859e3 100644 --- a/app/src/test/resources/application-test.yml +++ b/app/src/test/resources/application-test.yml @@ -1,4 +1,7 @@ spring: + flyway: + enabled: false + jpa: hibernate: ddl-auto: create-drop