diff --git a/api/src/main/kotlin/com/ditto/api/match/service/UnformedGroupNotifier.kt b/api/src/main/kotlin/com/ditto/api/match/service/UnformedGroupNotifier.kt index c0a71e9d..00de4f0b 100644 --- a/api/src/main/kotlin/com/ditto/api/match/service/UnformedGroupNotifier.kt +++ b/api/src/main/kotlin/com/ditto/api/match/service/UnformedGroupNotifier.kt @@ -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( @@ -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 @@ -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 } } diff --git a/api/src/main/kotlin/com/ditto/api/notification/controller/NotificationController.kt b/api/src/main/kotlin/com/ditto/api/notification/controller/NotificationController.kt index 2c651ac6..e57fa161 100644 --- a/api/src/main/kotlin/com/ditto/api/notification/controller/NotificationController.kt +++ b/api/src/main/kotlin/com/ditto/api/notification/controller/NotificationController.kt @@ -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 @@ -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, @@ -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 = + ApiResponse.ok(DeleteNotificationsResponse(notificationService.deleteAll(principal.memberId, category))) + + /** 개별 삭제. 내 알림이 아니거나 이미 지웠으면 404 다. */ + @Loggable + @DeleteMapping("/api/v1/notifications/{id}") + fun delete( + @AuthenticationPrincipal principal: MemberPrincipal, + @PathVariable id: Long, + ): ApiResponse { + notificationService.delete(principal.memberId, id) + return ApiResponse.ok(DeleteNotificationsResponse(deletedCount = 1)) + } } diff --git a/api/src/main/kotlin/com/ditto/api/notification/dto/DeleteNotificationsResponse.kt b/api/src/main/kotlin/com/ditto/api/notification/dto/DeleteNotificationsResponse.kt new file mode 100644 index 00000000..ac561b60 --- /dev/null +++ b/api/src/main/kotlin/com/ditto/api/notification/dto/DeleteNotificationsResponse.kt @@ -0,0 +1,6 @@ +package com.ditto.api.notification.dto + +/** 삭제 결과. 단건은 항상 1이고(없으면 404), 전체는 지울 것이 없으면 0이다. */ +data class DeleteNotificationsResponse( + val deletedCount: Long, +) diff --git a/api/src/main/kotlin/com/ditto/api/notification/push/PushNotifier.kt b/api/src/main/kotlin/com/ditto/api/notification/push/PushNotifier.kt index d78d1fb1..8e7c4fca 100644 --- a/api/src/main/kotlin/com/ditto/api/notification/push/PushNotifier.kt +++ b/api/src/main/kotlin/com/ditto/api/notification/push/PushNotifier.kt @@ -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) { diff --git a/api/src/main/kotlin/com/ditto/api/notification/service/NotificationService.kt b/api/src/main/kotlin/com/ditto/api/notification/service/NotificationService.kt index f0e1dea6..d871e623 100644 --- a/api/src/main/kotlin/com/ditto/api/notification/service/NotificationService.kt +++ b/api/src/main/kotlin/com/ditto/api/notification/service/NotificationService.kt @@ -53,7 +53,7 @@ class NotificationService( /** 홈 헤더 벨 배지용 미읽음 수 */ fun getUnreadCount(memberId: Long): Long = - notificationRepository.countByMemberIdAndReadAtIsNullAndCreatedAtGreaterThanEqual(memberId, Notification.retentionFrom()) + notificationRepository.countUnread(memberId, Notification.retentionFrom()) /** * 알림 하나를 읽음으로 표시한다. 이미 읽은 알림에 다시 요청해도 성공한다(멱등). @@ -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()) } @@ -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 { /** 한 페이지 최대 건수. 채팅 메시지 페이징과 같은 상한을 쓴다. */ diff --git a/api/src/test/kotlin/com/ditto/api/match/UnformedGroupNotifierTest.kt b/api/src/test/kotlin/com/ditto/api/match/UnformedGroupNotifierTest.kt index 94a00e48..e5d8697c 100644 --- a/api/src/test/kotlin/com/ditto/api/match/UnformedGroupNotifierTest.kt +++ b/api/src/test/kotlin/com/ditto/api/match/UnformedGroupNotifierTest.kt @@ -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): GroupMatch { - val group = groupMatchRepository.save(GroupMatchFixture.create(quizSetId = quizSetId())) + fun unformedGroup(acceptedMemberIds: List, 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() @@ -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), diff --git a/api/src/test/kotlin/com/ditto/api/notification/NotificationAppenderTest.kt b/api/src/test/kotlin/com/ditto/api/notification/NotificationAppenderTest.kt index c727387f..ffc7469f 100644 --- a/api/src/test/kotlin/com/ditto/api/notification/NotificationAppenderTest.kt +++ b/api/src/test/kotlin/com/ditto/api/notification/NotificationAppenderTest.kt @@ -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)" - { diff --git a/api/src/test/kotlin/com/ditto/api/notification/NotificationControllerTest.kt b/api/src/test/kotlin/com/ditto/api/notification/NotificationControllerTest.kt index f985155b..ee097339 100644 --- a/api/src/test/kotlin/com/ditto/api/notification/NotificationControllerTest.kt +++ b/api/src/test/kotlin/com/ditto/api/notification/NotificationControllerTest.kt @@ -17,6 +17,7 @@ import java.time.LocalDateTime import org.junit.jupiter.api.DisplayName import org.junit.jupiter.api.Test import org.springframework.beans.factory.annotation.Autowired +import org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.delete import org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.get import org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.put import org.springframework.restdocs.operation.preprocess.Preprocessors.preprocessRequest @@ -206,7 +207,7 @@ class NotificationControllerTest : RestDocsTest() { .andExpect(jsonPath("$.success").value(true)) } - check(notificationRepository.findByIdAndMemberId(notification.id, member.id)!!.isRead) { + check(notificationRepository.findByIdAndMemberIdAndDeletedAtIsNull(notification.id, member.id)!!.isRead) { "읽음 처리되지 않았다" } @@ -257,7 +258,7 @@ class NotificationControllerTest : RestDocsTest() { .andExpect(jsonPath("$.success").value(false)) .andExpect(jsonPath("$.error.statusCode").value(404)) - check(!notificationRepository.findByIdAndMemberId(notification.id, other.id)!!.isRead) { + check(!notificationRepository.findByIdAndMemberIdAndDeletedAtIsNull(notification.id, other.id)!!.isRead) { "남의 알림이 읽음 처리됐다" } } @@ -309,6 +310,208 @@ class NotificationControllerTest : RestDocsTest() { .andExpect(jsonPath("$.data.readCount").value(0)) } + @Test + @DisplayName("알림 하나를 삭제한다 — 목록과 미읽음 수에서 함께 빠진다") + fun deleteNotification() { + val member = saveMember("삭제회원") + val notification = save(member.id, NotificationType.MATCH_RESULT, "지울 알림", targetId = 1L) + save(member.id, NotificationType.CHAT_MESSAGE, "남길 알림", targetId = 2L) + + mockMvc.perform( + delete("/api/v1/notifications/{id}", notification.id) + .withApiKey() + .withBearerToken(member.id), + ) + .andExpect(status().isOk) + .andExpect(jsonPath("$.data.deletedCount").value(1)) + .andDo( + document( + "notifications-delete", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + resource( + ResourceSnippetParameters.builder() + .tag("Notification") + .summary("알림 삭제") + .description( + "알림 하나를 지웁니다. 목록과 미읽음 수에서 함께 빠지며 되돌릴 수 없습니다. " + + "내 알림이 아니거나 이미 지운 알림이면 404 로 응답합니다. " + + "deletedCount 는 항상 1 입니다(전체 삭제와 형식을 맞춘 값).", + ) + .pathParameters(parameterWithName("id").description("알림 ID")) + .responseFields( + fieldWithPath("success").description("성공 여부"), + fieldWithPath("data.deletedCount").description("지운 건수"), + fieldWithPath("error").description("에러 정보 (성공 시 null)"), + ) + .build(), + ), + ), + ) + + mockMvc.perform( + get("/api/v1/notifications") + .withApiKey() + .withBearerToken(member.id), + ) + .andExpect(jsonPath("$.data.notifications.length()").value(1)) + .andExpect(jsonPath("$.data.notifications[0].title").value("남길 알림")) + + mockMvc.perform( + get("/api/v1/notifications/unread-count") + .withApiKey() + .withBearerToken(member.id), + ) + .andExpect(jsonPath("$.data.count").value(1)) + + // 행은 남는다 — 중복 검사가 존재를 보므로 지우면 스케줄러가 다시 적재한다. + check(notificationRepository.findById(notification.id).get().isDeleted) { + "삭제 표시가 찍히지 않았다" + } + } + + @Test + @DisplayName("이미 지운 알림을 다시 삭제하면 404 다") + fun deleteTwiceRejected() { + val member = saveMember("두번삭제회원") + val notification = save(member.id, NotificationType.MATCH_RESULT, "지울 알림", targetId = 1L) + + mockMvc.perform( + delete("/api/v1/notifications/{id}", notification.id) + .withApiKey() + .withBearerToken(member.id), + ) + .andExpect(jsonPath("$.success").value(true)) + + mockMvc.perform( + delete("/api/v1/notifications/{id}", notification.id) + .withApiKey() + .withBearerToken(member.id), + ) + .andExpect(status().isOk) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.error.statusCode").value(404)) + } + + @Test + @DisplayName("남의 알림은 삭제할 수 없다") + fun deleteOthersNotificationRejected() { + val member = saveMember("삭제요청회원") + val other = saveMember("삭제대상주인") + val notification = save(other.id, NotificationType.MATCH_RESULT, "남의 알림", targetId = 1L) + + mockMvc.perform( + delete("/api/v1/notifications/{id}", notification.id) + .withApiKey() + .withBearerToken(member.id), + ) + .andExpect(status().isOk) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.error.statusCode").value(404)) + + check(notificationRepository.findByIdAndMemberIdAndDeletedAtIsNull(notification.id, other.id) != null) { + "남의 알림이 삭제됐다" + } + } + + @Test + @DisplayName("지운 알림은 읽음 처리할 수 없다") + fun readDeletedNotificationRejected() { + val member = saveMember("지운알림읽음회원") + val notification = save(member.id, NotificationType.MATCH_RESULT, "지울 알림", targetId = 1L) + + mockMvc.perform( + delete("/api/v1/notifications/{id}", notification.id) + .withApiKey() + .withBearerToken(member.id), + ) + .andExpect(jsonPath("$.success").value(true)) + + mockMvc.perform( + put("/api/v1/notifications/{id}/read", notification.id) + .withApiKey() + .withBearerToken(member.id), + ) + .andExpect(status().isOk) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.error.statusCode").value(404)) + } + + @Test + @DisplayName("전체 삭제 — 지운 건수를 준다") + fun deleteAllNotifications() { + val member = saveMember("전체삭제회원") + save(member.id, NotificationType.MATCH_RESULT, "알림1", targetId = 1L) + save(member.id, NotificationType.CHAT_MESSAGE, "알림2", targetId = 2L) + + mockMvc.perform( + delete("/api/v1/notifications") + .withApiKey() + .withBearerToken(member.id), + ) + .andExpect(status().isOk) + .andExpect(jsonPath("$.data.deletedCount").value(2)) + .andDo( + document( + "notifications-delete-all", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + resource( + ResourceSnippetParameters.builder() + .tag("Notification") + .summary("알림 전체 삭제") + .description( + "화면에 보이는 내 알림을 모두 지웁니다. category 를 주면 그 필터 칩만 지웁니다. " + + "되돌릴 수 없으며, 지울 것이 없으면 deletedCount 가 0 이고 성공합니다.", + ) + .queryParameters( + queryParameterWithName("category") + .description("필터 (MATCHING·CHAT·SYSTEM). 생략 시 전체").optional(), + ) + .responseFields( + fieldWithPath("success").description("성공 여부"), + fieldWithPath("data.deletedCount").description("지운 건수"), + fieldWithPath("error").description("에러 정보 (성공 시 null)"), + ) + .build(), + ), + ), + ) + + // 두 번째 호출은 지울 것이 없다. + mockMvc.perform( + delete("/api/v1/notifications") + .withApiKey() + .withBearerToken(member.id), + ) + .andExpect(jsonPath("$.data.deletedCount").value(0)) + } + + @Test + @DisplayName("카테고리를 주면 그 칩의 알림만 지운다") + fun deleteAllByCategory() { + val member = saveMember("카테고리삭제회원") + save(member.id, NotificationType.MATCH_RESULT, "매칭 알림", targetId = 1L) + save(member.id, NotificationType.CHAT_MESSAGE, "채팅 알림", targetId = 2L) + + mockMvc.perform( + delete("/api/v1/notifications") + .withApiKey() + .withBearerToken(member.id) + .param("category", NotificationCategory.CHAT.name), + ) + .andExpect(status().isOk) + .andExpect(jsonPath("$.data.deletedCount").value(1)) + + mockMvc.perform( + get("/api/v1/notifications") + .withApiKey() + .withBearerToken(member.id), + ) + .andExpect(jsonPath("$.data.notifications.length()").value(1)) + .andExpect(jsonPath("$.data.notifications[0].title").value("매칭 알림")) + } + private fun save( memberId: Long, type: NotificationType, diff --git a/api/src/test/kotlin/com/ditto/api/notification/push/PushNotifierTest.kt b/api/src/test/kotlin/com/ditto/api/notification/push/PushNotifierTest.kt index 2a29d0aa..dd282557 100644 --- a/api/src/test/kotlin/com/ditto/api/notification/push/PushNotifierTest.kt +++ b/api/src/test/kotlin/com/ditto/api/notification/push/PushNotifierTest.kt @@ -50,7 +50,7 @@ class PushNotifierTest : FreeSpec({ } } val notificationRepository = mockk { - every { countByMemberIdAndReadAtIsNullAndCreatedAtGreaterThanEqual(any(), any()) } returns unreadCount + every { countUnread(any(), any()) } returns unreadCount } val chatRoomRepository = mockk { every { findById(any()) } returns Optional.ofNullable(room) diff --git a/docs/domains/match.md b/docs/domains/match.md index 90ca7d20..25bad9b1 100644 --- a/docs/domains/match.md +++ b/docs/domains/match.md @@ -31,7 +31,7 @@ - **성별·나이 하드 필터가 없다.** 기획에 없고, 성별이 둘뿐이라 3명 이상이 서로 전부 이성인 조합은 존재할 수 없다. 차단만 반영해 차단 관계인 두 사람을 같은 그룹에 넣지 않는다. - **점수가 두 종류다.** 선발용은 구성원 **모든 페어** 점수의 평균(`group_match.score`, 저장). 화면 표시용은 **나와 각 구성원**의 일치 문항 수 평균으로 다른 값이라 조회 시점에 계산한다(`GroupCandidateService`). - **상태 전이**: 배치가 `group_match`(비활성·score) + `group_match_member`(`PENDING`)로 깔고, 각자 수락·거절한다. 수락자가 3명에 닿으면 성사(`isActive`)되고 금요일에 채팅방이 열린다. 채팅방에는 **수락한 사람만** 들어간다. 수락·거절 모두 되돌릴 수 없다. -- **성사되지 못한 그룹은 상태를 바꾸지 않고 알림만 낸다**(`UnformedGroupNotifier`). 마감(그 주 금요일 00:00 — 채팅 개방과 같은 순간)까지 3명에 못 닿으면 수락자에게 `GROUP_NOT_FORMED`를 보낸다. 취소 플래그를 따로 두지 않는 이유: `is_active=false`가 이미 "성사되지 않음"이고, 재발송은 알림 유형의 `ONCE_PER_TARGET`(대상 = `group_match.id`)이 막아 상태 없이도 멱등이다. 알림이 없던 동안 수락자는 채팅방이 왜 안 열리는지 알 수 없었다(QA BUG-071). +- **성사되지 못한 그룹은 상태를 바꾸지 않고 알림만 낸다**(`UnformedGroupNotifier`). 마감(그 주 금요일 00:00 — 채팅 개방과 같은 순간)까지 3명에 못 닿으면 수락자에게 `GROUP_NOT_FORMED`를 보낸다. 취소 플래그를 따로 두지 않는 이유: `is_active=false`가 이미 "성사되지 않음"이고, 재발송은 알림 유형의 `ONCE_PER_TARGET`(대상 = `group_match.id`)이 막아 상태 없이도 멱등이다. 다만 그 멱등은 알림 행이 살아 있는 동안만이라(30일 뒤 purge) 스캔을 최근 2주 주차로 자른다. 알림이 없던 동안 수락자는 채팅방이 왜 안 열리는지 알 수 없었다(QA BUG-071). - **자동 거절**: 한 그룹을 수락하면 같은 퀴즈셋의 남은 `PENDING` 초대가 모두 `DECLINED`가 된다. 한 주에 열리는 채팅방이 하나뿐이라서다. 거절당한 그룹의 다른 구성원에게는 알리지 않는다. - **수락 경로는 방 행을 비관적 잠금**한다([ADR 0011](../adr/0011-rematch-pessimistic-lock.md)). 잠금이 없으면 동시 수락이 각자 낡은 수락자 수를 보고 둘 다 채팅방을 만들려다 `chat_room (source_type, source_id)` 유일키에 걸려 한쪽 트랜잭션이 통째로 롤백된다. 잠금 조회가 트랜잭션 **첫 접근**이어야 한다(규칙 5). - **후보 재생성은 응답이 시작되면 거부한다**(`GroupCandidateWriter` → `MATCH_CANDIDATES_ALREADY_RESPONDED`, 기존 후보는 그대로). `group_match` 하나가 후보이자 성사 상태라, 지우면 열린 채팅방이 가리킬 곳을 잃는다. 조용히 건너뛰지 않고 예외로 알리는 이유: 어드민이 재생성을 눌렀는데 성공처럼 보이면 안 된다. diff --git a/docs/domains/notification.md b/docs/domains/notification.md index efd2f0e3..795c6beb 100644 --- a/docs/domains/notification.md +++ b/docs/domains/notification.md @@ -46,9 +46,11 @@ - **보관·조회 창은 30일이다**(`Notification.RETENTION_DAYS`). 조회가 그 밖을 자르고, 같은 기준으로 purge 배치가 지운다. 미읽음 수(배지)도 같은 창을 써야 한다 — 창이 어긋나면 배지가 0이 되지 않는다. - **설정의 알림 토글은 이 목록을 막지 않는다.** 토글(`member_notification_setting`)은 푸시 수신 동의이고 센터는 인앱 기록이다. 채팅 알림을 끈 사람도 센터에서는 새 메시지를 본다. - **적재는 비즈니스 트랜잭션을 되돌리지 않는다.** `REQUIRES_NEW` + 실패 흡수. 반대급부로 롤백된 작업의 알림이 드물게 남을 수 있고, 그건 감수한다. -- **알림 행이 곧 처리 완료 표시다.** "대상당 1회" 유형은 존재 검사로 막으므로, 스케줄러가 같은 방·같은 퀴즈셋을 매 주기 다시 집어와도 알림은 하나다. 별도 플래그나 outbox 가 없다(`RematchChatRoomOpener`·`EndedChatReviewOpener`와 같은 수렴 루프). +- **알림 행이 곧 처리 완료 표시다.** 사용자 삭제가 행을 남기는 이유가 이것이다. "대상당 1회" 유형은 존재 검사로 막으므로, 스케줄러가 같은 방·같은 퀴즈셋을 매 주기 다시 집어와도 알림은 하나다. 별도 플래그나 outbox 가 없다(`RematchChatRoomOpener`·`EndedChatReviewOpener`와 같은 수렴 루프). - **재매칭 방 종료에는 평가 요청을 알리지 않는다.** 재매칭 채팅은 평가를 열지 않기 때문이다(#132). `ReviewRequestNotifier`가 `REMATCH`를 걸러낸다. - **탈퇴 완전 삭제는 알림도 지운다.** 본문에 닉네임·메시지 미리보기(개인정보)가 들어 있다. +- **사용자 삭제는 행을 남긴다(`deleted_at`).** 중복 검사가 행의 존재를 보므로 지워버리면 수렴 루프를 도는 스케줄러가 같은 알림과 푸시를 다시 내보낸다. 지운 알림은 목록·미읽음 수·전체 읽음에서 빠지고, 30일 뒤 purge 가 다른 행과 함께 지운다. 사용자에게는 되돌릴 수 없다. +- **중복 검사는 보관 기간까지만 유효하다.** 행이 purge 되면 존재 검사가 다시 통과하므로, 수렴 루프의 스캔 범위는 30일보다 짧아야 한다. 지금은 모두 그렇다 — `CHAT_ENDING_SOON`은 종료 6시간 창, `GROUP_NOT_FORMED`는 최근 2주 주차, 나머지는 이번 주기 처리분만 받는다. - **한 토큰 = 한 회원.** `member_device.token` 단독 유일 제약이 강제한다. 토큰은 기기의 것이라 로그아웃해도 그대로이므로, 공용 기기에서 다른 회원이 로그인하면 행 추가가 아니라 소유자 갱신이다 — 갱신하지 않으면 이전 회원의 알림이 남의 폰에 뜬다. 등록은 멱등이고(앱이 실행·토큰 갱신 때마다 재호출), @@ -91,6 +93,8 @@ | GET | `/api/v1/notifications/unread-count` | 홈 헤더 벨 배지용 | | PUT | `/api/v1/notifications/{id}/read` | 개별 읽음(멱등). 남의 알림은 404 | | PUT | `/api/v1/notifications/read-all` | 전체 읽음. `readCount` 반환 | +| DELETE | `/api/v1/notifications/{id}` | 개별 삭제. 남의 알림·이미 지운 알림은 404. `deletedCount`는 항상 1(전체 삭제와 형식을 맞춘 값) | +| DELETE | `/api/v1/notifications` | 전체 삭제. `category`를 주면 그 칩만, 보관 창 안만. `deletedCount` 반환 | | POST | `/api/v1/notifications/devices` | 푸시 디바이스 토큰 등록(멱등·소유권 이전). 앱 전용 | | DELETE | `/api/v1/notifications/devices/{token}` | 토큰 해제(멱등). 남의 토큰은 404. 로그아웃·탈퇴 직전에 앱이 호출 | diff --git "a/domain/db/V20260919224428_\354\225\214\353\246\274 \354\202\255\354\240\234 \355\221\234\354\213\234 \354\273\254\353\237\274 \354\266\224\352\260\200.sql" "b/domain/db/V20260919224428_\354\225\214\353\246\274 \354\202\255\354\240\234 \355\221\234\354\213\234 \354\273\254\353\237\274 \354\266\224\352\260\200.sql" new file mode 100644 index 00000000..7793ffa4 --- /dev/null +++ "b/domain/db/V20260919224428_\354\225\214\353\246\274 \354\202\255\354\240\234 \355\221\234\354\213\234 \354\273\254\353\237\274 \354\266\224\352\260\200.sql" @@ -0,0 +1,10 @@ +-- 사용자가 알림 센터에서 지운 알림을 표시하는 컬럼. +-- 행을 지우지 않는 이유는 중복 검사(notification_index_3) 때문이다. 검사가 행의 존재를 보므로 +-- 지워버리면 스케줄러가 같은 대상을 다시 집어와 알림과 푸시를 또 내보낸다. +-- 지운 알림은 목록·미읽음 수에서 빠지고, 30일이 지나면 기존 purge 배치가 다른 행과 함께 지운다. +ALTER TABLE notification + ADD COLUMN deleted_at DATETIME(6) NULL COMMENT '사용자가 지운 시각 (안 지웠으면 NULL)'; + +-- 목록 조회가 member_id + deleted_at IS NULL 로 자른 뒤 id DESC 로 커서 페이징한다. +DROP INDEX notification_index_1 ON notification; +CREATE INDEX notification_index_1 ON notification (member_id, deleted_at, id); diff --git a/domain/src/main/kotlin/com/ditto/domain/match/repository/querydsl/GroupMatchRepositoryCustom.kt b/domain/src/main/kotlin/com/ditto/domain/match/repository/querydsl/GroupMatchRepositoryCustom.kt index 204cbe8e..c2aff7ce 100644 --- a/domain/src/main/kotlin/com/ditto/domain/match/repository/querydsl/GroupMatchRepositoryCustom.kt +++ b/domain/src/main/kotlin/com/ditto/domain/match/repository/querydsl/GroupMatchRepositoryCustom.kt @@ -6,10 +6,12 @@ import java.time.LocalDate interface GroupMatchRepositoryCustom { /** - * 수락 마감이 지났는데도 성사되지 못한 그룹들. + * 수락 마감이 지났는데도 성사되지 못한 그룹들. 운영 주(월요일) 기준으로 구간을 받는다. * - * @param lastWeekStartedOn 포함할 운영 주의 상한(월요일). 마감은 그 주 금요일 00:00 이므로 + * @param oldestWeekStartedOn 포함할 가장 오래된 운영 주. 안내는 마감 직후의 것이라 지난 주차를 + * 무한정 다시 집으면 안 된다 — 알림 행이 purge 된 뒤 같은 안내가 다시 나간다. + * @param lastWeekStartedOn 포함할 운영 주의 상한. 마감은 그 주 금요일 00:00 이므로 * 부르는 쪽이 `기준일 - 4일`을 넘긴다. */ - fun findUnformedUntil(lastWeekStartedOn: LocalDate): List + fun findUnformedBetween(oldestWeekStartedOn: LocalDate, lastWeekStartedOn: LocalDate): List } diff --git a/domain/src/main/kotlin/com/ditto/domain/match/repository/querydsl/GroupMatchRepositoryImpl.kt b/domain/src/main/kotlin/com/ditto/domain/match/repository/querydsl/GroupMatchRepositoryImpl.kt index 9987b9a9..120920db 100644 --- a/domain/src/main/kotlin/com/ditto/domain/match/repository/querydsl/GroupMatchRepositoryImpl.kt +++ b/domain/src/main/kotlin/com/ditto/domain/match/repository/querydsl/GroupMatchRepositoryImpl.kt @@ -18,11 +18,15 @@ class GroupMatchRepositoryImpl( * * 수락자가 0명인 그룹도 함께 나온다. 알릴 사람이 없을 뿐이라 거르는 건 부르는 쪽 몫이다. */ - override fun findUnformedUntil(lastWeekStartedOn: LocalDate): List = queryFactory + override fun findUnformedBetween( + oldestWeekStartedOn: LocalDate, + lastWeekStartedOn: LocalDate, + ): List = queryFactory .selectFrom(groupMatch) .join(quizSet).on(groupMatch.quizSetId.eq(quizSet.id)) .where( groupMatch.isActive.isFalse, + quizSet.weekStartedOn.goe(oldestWeekStartedOn), quizSet.weekStartedOn.loe(lastWeekStartedOn), ) .fetch() diff --git a/domain/src/main/kotlin/com/ditto/domain/notification/entity/Notification.kt b/domain/src/main/kotlin/com/ditto/domain/notification/entity/Notification.kt index 9db3c1f4..9107d5f0 100644 --- a/domain/src/main/kotlin/com/ditto/domain/notification/entity/Notification.kt +++ b/domain/src/main/kotlin/com/ditto/domain/notification/entity/Notification.kt @@ -23,14 +23,17 @@ import org.hibernate.annotations.Comment * * 안읽음은 `readAt == null` 이다. 채팅처럼 읽음 커서 하나로 접지 않는다 — 화면이 개별 읽음을 요구한다. * + * 사용자가 지운 알림은 [deletedAt]만 찍고 행은 남긴다. 중복 검사가 행의 존재를 보기 때문에, + * 지워버리면 스케줄러가 같은 알림을 다시 적재한다. 행은 30일 뒤 purge 된다. + * * 보관은 30일이다([RETENTION_DAYS]). 화면이 최근 30일만 보여주므로 그 뒤의 행은 남길 이유가 없다. */ @Entity @Table( name = "notification", indexes = [ - // 목록 조회 — 내 알림을 id DESC 로 커서 페이징한다. - Index(name = "notification_index_1", columnList = "member_id, id"), + // 목록 조회 — 안 지운 내 알림을 id DESC 로 커서 페이징한다. + Index(name = "notification_index_1", columnList = "member_id, deleted_at, id"), // 미읽음 수(홈 배지)·전체 읽음이 읽는 경로. Index(name = "notification_index_2", columnList = "member_id, read_at"), // 같은 사건 중복 방지(존재 검사)와 새 메시지 알림 접기. @@ -70,12 +73,20 @@ class Notification private constructor( var readAt: LocalDateTime? = null protected set + @Comment("사용자가 지운 시각 (안 지웠으면 NULL)") + @Column(name = "deleted_at") + var deletedAt: LocalDateTime? = null + protected set + val category: NotificationCategory get() = type.category val isRead: Boolean get() = readAt != null + val isDeleted: Boolean + get() = deletedAt != null + /** * 읽음으로 표시한다. 이미 읽은 알림이면 아무 것도 하지 않는다 — * 처음 읽은 시각을 덮어쓰지 않기 위한 것이고, 더블 탭·재시도가 멱등해진다. @@ -90,6 +101,14 @@ class Notification private constructor( return true } + /** 지운 것으로 표시한다. 이미 지웠으면 아무 것도 하지 않는다. */ + fun markDeleted(at: LocalDateTime) { + if (isDeleted) { + return + } + deletedAt = at + } + companion object { const val TITLE_MAX_LENGTH = 100 const val BODY_MAX_LENGTH = 500 diff --git a/domain/src/main/kotlin/com/ditto/domain/notification/repository/NotificationRepository.kt b/domain/src/main/kotlin/com/ditto/domain/notification/repository/NotificationRepository.kt index 2ca9afa3..2f3acca2 100644 --- a/domain/src/main/kotlin/com/ditto/domain/notification/repository/NotificationRepository.kt +++ b/domain/src/main/kotlin/com/ditto/domain/notification/repository/NotificationRepository.kt @@ -3,7 +3,6 @@ package com.ditto.domain.notification.repository import com.ditto.domain.notification.entity.Notification import com.ditto.domain.notification.entity.NotificationType import com.ditto.domain.notification.repository.querydsl.NotificationRepositoryCustom -import java.time.LocalDateTime import org.springframework.data.jpa.repository.JpaRepository interface NotificationRepository : JpaRepository, NotificationRepositoryCustom { @@ -11,15 +10,14 @@ interface NotificationRepository : JpaRepository, Notificati /** * 내 알림 단건. 남의 알림을 id로 찍어 읽음 처리하는 것을 막으려고 회원 조건을 함께 둔다 — * 조회 후 소유자를 비교하는 방식은 비교를 빠뜨릴 수 있다. + * 지운 알림은 없는 것으로 다룬다. */ - fun findByIdAndMemberId(id: Long, memberId: Long): Notification? - - /** 홈 헤더 벨 배지 — 보관 기간 안의 안읽은 알림 수. 목록에 안 보이는 알림을 세지 않도록 창을 맞춘다. */ - fun countByMemberIdAndReadAtIsNullAndCreatedAtGreaterThanEqual(memberId: Long, from: LocalDateTime): Long + fun findByIdAndMemberIdAndDeletedAtIsNull(id: Long, memberId: Long): Notification? /** * 같은 사건을 두 번 알리지 않기 위한 존재 검사. 스케줄러가 같은 대상을 다시 집어와도 알림은 하나다. * 대상이 없으면 "같은 사건"을 정의할 수 없으므로 `targetId`는 필수다. + * 지운 알림도 함께 센다 — 지웠다고 다시 알리면 지울 때마다 같은 알림이 돌아온다. */ fun existsByMemberIdAndTypeAndTargetId(memberId: Long, type: NotificationType, targetId: Long): Boolean } diff --git a/domain/src/main/kotlin/com/ditto/domain/notification/repository/querydsl/NotificationRepositoryCustom.kt b/domain/src/main/kotlin/com/ditto/domain/notification/repository/querydsl/NotificationRepositoryCustom.kt index cd54b679..fd184411 100644 --- a/domain/src/main/kotlin/com/ditto/domain/notification/repository/querydsl/NotificationRepositoryCustom.kt +++ b/domain/src/main/kotlin/com/ditto/domain/notification/repository/querydsl/NotificationRepositoryCustom.kt @@ -7,11 +7,14 @@ import java.time.LocalDateTime interface NotificationRepositoryCustom { + /** 홈 헤더 벨 배지 — [from] 이후의 안읽은 알림 수. 목록과 창을 맞춰야 배지가 0이 된다. */ + fun countUnread(memberId: Long, from: LocalDateTime): Long + /** * 내 알림을 최신순(id DESC)으로 [size] 개 조회한다. * * [cursor]가 주어지면 그 id 미만(더 과거)만 — 아래로 스크롤 페이징용. [category]가 주어지면 - * 그 카테고리의 유형만(생략 = 전체 칩). [from] 이전에 생성된 알림은 보이지 않는다(보관 30일). + * 그 카테고리의 유형만(생략 = 전체 칩). [from] 이전 알림과 지운 알림은 보이지 않는다. * * 새 메시지 알림은 접힐 때 행을 갱신하지 않고 다시 삽입하므로, `id` 정렬이 곧 시간 정렬이다. */ @@ -27,7 +30,7 @@ interface NotificationRepositoryCustom { * 안읽은 알림을 모두 읽음으로 표시한다 — 화면 우상단 "모두 읽음". * * 벌크 UPDATE 로 처리한다. 회원의 안읽은 알림을 전부 로드해 하나씩 바꾸면 30일치를 메모리에 - * 올리게 되고, 목적이 "read_at 하나를 채우는 것"이라 엔티티를 거칠 이유가 없다. + * 올리게 되고, 목적이 "read_at 하나를 채우는 것"이라 엔티티를 거칠 이유가 없다. 지운 알림은 건드리지 않는다. * 호출자는 이후 같은 트랜잭션에서 알림 엔티티를 읽지 않아야 한다(영속성 컨텍스트와 어긋난다). * * @return 이번 호출로 읽음이 된 건수 @@ -44,6 +47,19 @@ interface NotificationRepositoryCustom { */ fun deleteUnread(memberId: Long, type: NotificationType, targetId: Long): Long + /** + * 화면의 "전체 삭제" — [from] 이후의 안 지운 내 알림에 지운 시각을 찍는다. + * [category]가 있으면 그 칩의 유형만. 행은 남긴다(중복 검사가 존재를 본다). + * + * @return 이번 호출로 지워진 건수 + */ + fun markAllDeleted( + memberId: Long, + category: NotificationCategory?, + at: LocalDateTime, + from: LocalDateTime, + ): Long + /** * 회원의 알림을 모두 지운다 — 탈퇴 완전 삭제용. * diff --git a/domain/src/main/kotlin/com/ditto/domain/notification/repository/querydsl/NotificationRepositoryImpl.kt b/domain/src/main/kotlin/com/ditto/domain/notification/repository/querydsl/NotificationRepositoryImpl.kt index 32a1628e..4ce9aa65 100644 --- a/domain/src/main/kotlin/com/ditto/domain/notification/repository/querydsl/NotificationRepositoryImpl.kt +++ b/domain/src/main/kotlin/com/ditto/domain/notification/repository/querydsl/NotificationRepositoryImpl.kt @@ -13,6 +13,18 @@ class NotificationRepositoryImpl( private val queryFactory: JPAQueryFactory, ) : NotificationRepositoryCustom { + override fun countUnread(memberId: Long, from: LocalDateTime): Long = + queryFactory + .select(notification.count()) + .from(notification) + .where( + notification.memberId.eq(memberId), + notification.readAt.isNull, + notification.deletedAt.isNull, + notification.createdAt.goe(from), + ) + .fetchOne() ?: 0L + override fun findByMemberIdWithCursor( memberId: Long, category: NotificationCategory?, @@ -24,6 +36,7 @@ class NotificationRepositoryImpl( .selectFrom(notification) .where( notification.memberId.eq(memberId), + notification.deletedAt.isNull, notification.createdAt.goe(from), category?.let { notification.type.`in`(NotificationType.of(it)) }, cursor?.let { notification.id.lt(it) }, @@ -41,6 +54,7 @@ class NotificationRepositoryImpl( .where( notification.memberId.eq(memberId), notification.readAt.isNull, + notification.deletedAt.isNull, ) .execute() @@ -56,6 +70,25 @@ class NotificationRepositoryImpl( ) .execute() + @Transactional + override fun markAllDeleted( + memberId: Long, + category: NotificationCategory?, + at: LocalDateTime, + from: LocalDateTime, + ): Long = + queryFactory + .update(notification) + .set(notification.deletedAt, at) + .set(notification.updatedAt, at) + .where( + notification.memberId.eq(memberId), + notification.deletedAt.isNull, + notification.createdAt.goe(from), + category?.let { notification.type.`in`(NotificationType.of(it)) }, + ) + .execute() + @Transactional override fun deleteAllByMemberId(memberId: Long): Long = queryFactory diff --git a/domain/src/test/kotlin/com/ditto/domain/notification/repository/NotificationRepositoryTest.kt b/domain/src/test/kotlin/com/ditto/domain/notification/repository/NotificationRepositoryTest.kt index 7a36e836..9ef99665 100644 --- a/domain/src/test/kotlin/com/ditto/domain/notification/repository/NotificationRepositoryTest.kt +++ b/domain/src/test/kotlin/com/ditto/domain/notification/repository/NotificationRepositoryTest.kt @@ -4,6 +4,7 @@ import com.ditto.domain.notification.NotificationFixture import com.ditto.domain.notification.entity.NotificationCategory import com.ditto.domain.notification.entity.NotificationType import com.ditto.domain.support.IntegrationTest +import io.kotest.matchers.collections.shouldBeEmpty import io.kotest.matchers.shouldBe import java.time.LocalDateTime import javax.sql.DataSource @@ -118,8 +119,8 @@ class NotificationRepositoryTest( val readCount = notificationRepository.markAllRead(ME, LocalDateTime.now()) readCount shouldBe 1 - notificationRepository.findByIdAndMemberId(mine.id, ME)!!.isRead shouldBe true - notificationRepository.findByIdAndMemberId(others.id, OTHER)!!.isRead shouldBe false + notificationRepository.findByIdAndMemberIdAndDeletedAtIsNull(mine.id, ME)!!.isRead shouldBe true + notificationRepository.findByIdAndMemberIdAndDeletedAtIsNull(others.id, OTHER)!!.isRead shouldBe false } "이미 다 읽었으면 0을 반환한다 — 멱등하다" { @@ -147,6 +148,65 @@ class NotificationRepositoryTest( } } + "markAllDeleted — 화면에서 지우기(행은 남긴다)" - { + "카테고리를 주면 그 칩의 내 알림만 지운 것으로 표시한다" { + val matching = save(type = NotificationType.MATCH_RESULT, title = "매칭") + val chat = save(type = NotificationType.CHAT_MESSAGE, title = "채팅") + val others = save(memberId = OTHER, type = NotificationType.CHAT_MESSAGE, title = "남의 채팅") + + val deleted = notificationRepository + .markAllDeleted(ME, NotificationCategory.CHAT, LocalDateTime.now(), LONG_AGO) + + deleted shouldBe 1 + notificationRepository.findById(chat.id).get().isDeleted shouldBe true + notificationRepository.findById(matching.id).get().isDeleted shouldBe false + notificationRepository.findById(others.id).get().isDeleted shouldBe false + } + + "카테고리가 없으면 창 안의 내 알림을 모두 지운다" { + save(type = NotificationType.MATCH_RESULT) + save(type = NotificationType.CHAT_MESSAGE) + + notificationRepository.markAllDeleted(ME, null, LocalDateTime.now(), LONG_AGO) shouldBe 2 + } + + "이미 지운 알림은 다시 세지 않는다 — 멱등하다" { + save() + notificationRepository.markAllDeleted(ME, null, LocalDateTime.now(), LONG_AGO) + + notificationRepository.markAllDeleted(ME, null, LocalDateTime.now(), LONG_AGO) shouldBe 0 + } + + "보관 창 밖의 알림은 건드리지 않는다 — 화면에 없던 행이 건수에 섞이면 안 된다" { + save() + + val tomorrow = LocalDateTime.now().plusDays(1) + + notificationRepository.markAllDeleted(ME, null, LocalDateTime.now(), tomorrow) shouldBe 0 + } + + "지운 알림은 목록·미읽음 수에서 빠지지만 행은 남는다" { + val notification = save() + notificationRepository.markAllDeleted(ME, null, LocalDateTime.now(), LONG_AGO) + + notificationRepository + .findByMemberIdWithCursor(ME, category = null, cursor = null, size = 10, from = LONG_AGO) + .shouldBeEmpty() + notificationRepository.countUnread(ME, LONG_AGO) shouldBe 0 + notificationRepository.findByIdAndMemberIdAndDeletedAtIsNull(notification.id, ME) shouldBe null + // 행이 남아야 재발송을 막는 존재 검사가 계속 통한다. + notificationRepository.findById(notification.id).isPresent shouldBe true + } + + "지운 알림도 중복 검사의 근거로 센다 — 지웠다고 다시 알리면 안 된다" { + save(type = NotificationType.REVIEW_REQUEST, targetId = 7L) + notificationRepository.markAllDeleted(ME, null, LocalDateTime.now(), LONG_AGO) + + notificationRepository + .existsByMemberIdAndTypeAndTargetId(ME, NotificationType.REVIEW_REQUEST, 7L) shouldBe true + } + } + "deleteCreatedBefore — 보관 기간 경과분 정리" - { "기준 시각 이전에 만들어진 알림을 지운다" { save() @@ -174,9 +234,9 @@ class NotificationRepositoryTest( val tomorrow = LocalDateTime.now().plusDays(1) notificationRepository - .countByMemberIdAndReadAtIsNullAndCreatedAtGreaterThanEqual(ME, LONG_AGO) shouldBe 2 + .countUnread(ME, LONG_AGO) shouldBe 2 notificationRepository - .countByMemberIdAndReadAtIsNullAndCreatedAtGreaterThanEqual(ME, tomorrow) shouldBe 0 + .countUnread(ME, tomorrow) shouldBe 0 } "같은 (회원·유형·대상)의 알림이 이미 있는지 답한다 — 한 번만 알리기의 근거" {