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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,10 @@ private val log = KotlinLogging.logger {}
* **그룹 상태를 바꾸지 않는다.** `is_active=false` 자체가 이미 "성사되지 않음"이고,
* 취소 플래그를 따로 두면 마이그레이션이 필요해진다. 재발송은 알림 유형의
* `ONCE_PER_TARGET`(대상 = `group_match.id`)이 막으므로 상태 없이도 멱등이다.
*
* 그 멱등은 알림 행이 살아 있는 동안만이다 — 보관 기간(30일)이 지나 purge 되면 존재 검사가
* 다시 통과한다. 그래서 스캔을 최근 [NOTIFIABLE_WINDOW_DAYS]일로 자른다. 마감 직후 안내라
* 지난 주차를 다시 집을 이유도 없다.
*/
@Service
class UnformedGroupNotifier(
Expand All @@ -33,7 +37,10 @@ class UnformedGroupNotifier(
fun notifyUnformed(now: LocalDateTime): Int {
// 마감은 그 주 금요일 00:00. 주 시작일(월요일) 기준이라 4일을 뺀다.
val lastWeekStartedOn = now.toLocalDate().minusDays(DAYS_FROM_MONDAY_TO_FRIDAY)
val unformed = groupMatchRepository.findUnformedUntil(lastWeekStartedOn)
val unformed = groupMatchRepository.findUnformedBetween(
oldestWeekStartedOn = lastWeekStartedOn.minusDays(NOTIFIABLE_WINDOW_DAYS),
lastWeekStartedOn = lastWeekStartedOn,
)
if (unformed.isEmpty()) return 0

var notified = 0
Expand All @@ -59,5 +66,11 @@ class UnformedGroupNotifier(

companion object {
private const val DAYS_FROM_MONDAY_TO_FRIDAY = 4L

/**
* 안내를 보낼 주차의 범위. 알림 보관 기간(30일)보다 짧아야 purge 된 뒤 다시 알리는 일이 없고,
* 배포 중단 등으로 스케줄러가 한두 주 멈춰도 놓친 주차를 따라잡을 만큼은 길다.
*/
private const val NOTIFIABLE_WINDOW_DAYS = 14L
}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package com.ditto.api.notification.controller

import com.ditto.api.config.auth.MemberPrincipal
import com.ditto.api.notification.dto.DeleteNotificationsResponse
import com.ditto.api.notification.dto.NotificationsResponse
import com.ditto.api.notification.dto.ReadAllNotificationsResponse
import com.ditto.api.notification.dto.UnreadNotificationCountResponse
Expand All @@ -9,13 +10,14 @@ import com.ditto.common.logging.Loggable
import com.ditto.common.response.ApiResponse
import com.ditto.domain.notification.entity.NotificationCategory
import org.springframework.security.core.annotation.AuthenticationPrincipal
import org.springframework.web.bind.annotation.DeleteMapping
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.PutMapping
import org.springframework.web.bind.annotation.RequestParam
import org.springframework.web.bind.annotation.RestController

/** 알림 센터 화면(피그마 7.2)의 목록·읽음 엔드포인트. */
/** 알림 센터 화면(피그마 7.2)의 목록·읽음·삭제 엔드포인트. */
@RestController
class NotificationController(
private val notificationService: NotificationService,
Expand Down Expand Up @@ -63,4 +65,24 @@ class NotificationController(
notificationService.markRead(principal.memberId, id)
return ApiResponse.ok(Unit)
}

/** 전체 삭제. [category]를 주면 그 칩만 지운다. 지울 것이 없어도 성공하며 `deletedCount`가 0이다. */
@Loggable
@DeleteMapping("/api/v1/notifications")
fun deleteAll(
@AuthenticationPrincipal principal: MemberPrincipal,
@RequestParam(required = false) category: NotificationCategory?,
): ApiResponse<DeleteNotificationsResponse> =
ApiResponse.ok(DeleteNotificationsResponse(notificationService.deleteAll(principal.memberId, category)))

/** 개별 삭제. 내 알림이 아니거나 이미 지웠으면 404 다. */
@Loggable
@DeleteMapping("/api/v1/notifications/{id}")
fun delete(
@AuthenticationPrincipal principal: MemberPrincipal,
@PathVariable id: Long,
): ApiResponse<DeleteNotificationsResponse> {
notificationService.delete(principal.memberId, id)
return ApiResponse.ok(DeleteNotificationsResponse(deletedCount = 1))
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package com.ditto.api.notification.dto

/** 삭제 결과. 단건은 항상 1이고(없으면 404), 전체는 지울 것이 없으면 0이다. */
data class DeleteNotificationsResponse(
val deletedCount: Long,
)
Original file line number Diff line number Diff line change
Expand Up @@ -124,10 +124,7 @@ class PushNotifier(

/** 벨 배지 API 와 같은 창을 써야 인앱과 아이콘 뱃지가 같은 수가 된다. */
private fun countUnread(memberId: Long): Int =
notificationRepository.countByMemberIdAndReadAtIsNullAndCreatedAtGreaterThanEqual(
memberId,
Notification.retentionFrom(),
).toInt()
notificationRepository.countUnread(memberId, Notification.retentionFrom()).toInt()

/** 시효가 있는 알림만 짧게. 없으면 FCM 기본(4주)이라 꺼져 있던 기기에 지난 알림이 몰린다. */
private fun ttlOf(type: NotificationType): Duration? = when (type) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ class NotificationService(

/** 홈 헤더 벨 배지용 미읽음 수 */
fun getUnreadCount(memberId: Long): Long =
notificationRepository.countByMemberIdAndReadAtIsNullAndCreatedAtGreaterThanEqual(memberId, Notification.retentionFrom())
notificationRepository.countUnread(memberId, Notification.retentionFrom())

/**
* 알림 하나를 읽음으로 표시한다. 이미 읽은 알림에 다시 요청해도 성공한다(멱등).
Expand All @@ -62,7 +62,7 @@ class NotificationService(
*/
@Transactional
fun markRead(memberId: Long, notificationId: Long) {
val notification = notificationRepository.findByIdAndMemberId(notificationId, memberId)
val notification = notificationRepository.findByIdAndMemberIdAndDeletedAtIsNull(notificationId, memberId)
?: throw WarnException(ErrorCode.NOT_FOUND, "존재하지 않는 알림입니다.")
notification.markRead(LocalDateTime.now())
}
Expand All @@ -79,6 +79,34 @@ class NotificationService(
@Transactional
fun markAllRead(memberId: Long): Long = notificationRepository.markAllRead(memberId, LocalDateTime.now())

/**
* 알림 하나를 지운다. 남의 알림·이미 지운 알림은 [ErrorCode.NOT_FOUND]다.
*
* 행은 남기고 지운 시각만 찍는다 — 중복 검사가 행의 존재를 보기 때문에 지워버리면
* 스케줄러가 같은 알림을 다시 적재한다. 행은 30일 뒤 purge 가 지운다.
*/
@Transactional
fun delete(memberId: Long, notificationId: Long) {
val notification = notificationRepository.findByIdAndMemberIdAndDeletedAtIsNull(notificationId, memberId)
?: throw WarnException(ErrorCode.NOT_FOUND, "존재하지 않는 알림입니다.")
notification.markDeleted(LocalDateTime.now())
}

/**
* 화면에 보이는 내 알림을 모두 지운다. [category]가 있으면 그 칩만.
*
* 보관 창 밖은 건드리지 않는다 — 응답하는 건수가 사용자가 보던 목록과 맞아야 한다.
*
* @return 이번 호출로 지워진 건수
*/
@Transactional
fun deleteAll(memberId: Long, category: NotificationCategory?): Long =
notificationRepository.markAllDeleted(
memberId = memberId,
category = category,
at = LocalDateTime.now(),
from = Notification.retentionFrom(),
)

companion object {
/** 한 페이지 최대 건수. 채팅 메시지 페이징과 같은 상한을 쓴다. */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,16 +36,18 @@ class UnformedGroupNotifierTest(
val afterDeadline = LocalDateTime.of(2026, 6, 5, 0, 30)
val beforeDeadline = LocalDateTime.of(2026, 6, 4, 23, 30)

fun quizSetId(): Long = quizSetRepository.save(
fun quizSetId(weekStartedOn: LocalDate = monday): Long = quizSetRepository.save(
QuizSetFixture.create(
startDate = monday.atStartOfDay(),
endDate = monday.plusDays(2).atTime(23, 59, 59),
startDate = weekStartedOn.atStartOfDay(),
endDate = weekStartedOn.plusDays(2).atTime(23, 59, 59),
),
).id

/** 수락자 [acceptedMemberIds] 를 가진 미성사 그룹. 임계값(3) 미만이라 활성화되지 않는다. */
fun unformedGroup(acceptedMemberIds: List<Long>): GroupMatch {
val group = groupMatchRepository.save(GroupMatchFixture.create(quizSetId = quizSetId()))
fun unformedGroup(acceptedMemberIds: List<Long>, weekStartedOn: LocalDate = monday): GroupMatch {
val group = groupMatchRepository.save(
GroupMatchFixture.create(quizSetId = quizSetId(weekStartedOn)),
)
acceptedMemberIds.forEach { memberId ->
val member = GroupMatchMember.candidate(roomId = group.id, memberId = memberId)
member.accept()
Expand Down Expand Up @@ -93,6 +95,24 @@ class UnformedGroupNotifierTest(
notifications(1L).size shouldBe 1
}

// 알림 행은 30일 뒤 purge 된다. 스캔이 그보다 오래 거슬러 올라가면 존재 검사가 다시
// 통과해 한참 전에 끝난 그룹의 안내와 푸시가 다시 나간다.
"오래된 주차의 미성사 그룹은 대상이 아니다" {
unformedGroup(listOf(1L), weekStartedOn = monday.minusDays(21))

unformedGroupNotifier.notifyUnformed(afterDeadline) shouldBe 0

notifications(1L).size shouldBe 0
}

"지난주 미성사 그룹은 아직 대상이다 — 스케줄러가 멈췄다 돌아도 따라잡는다" {
unformedGroup(listOf(1L), weekStartedOn = monday.minusDays(7))

unformedGroupNotifier.notifyUnformed(afterDeadline) shouldBe 1

notifications(1L).size shouldBe 1
}

"성사된 그룹은 대상이 아니다" {
val formed = groupMatchRepository.save(
GroupMatchFixture.create(quizSetId = quizSetId(), acceptedCount = 3),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,17 @@ class NotificationAppenderTest(

notificationRepository.count() shouldBe 2
}

"사용자가 지운 알림은 다시 적재되지 않는다 — 안 그러면 지울 때마다 스케줄러가 되살린다" {
notificationAppender.append(ME, NotificationMessages.matchResult(), targetId = QUIZ_SET)
val notification = notificationRepository.findAll().single()
notification.markDeleted(LocalDateTime.now())
notificationRepository.save(notification)

notificationAppender.append(ME, NotificationMessages.matchResult(), targetId = QUIZ_SET) shouldBe false

notificationRepository.count() shouldBe 1
}
}

"새 메시지는 방 단위로 접힌다 (COLLAPSE_UNREAD)" - {
Expand Down
Loading
Loading