diff --git a/src/main/kotlin/com/depromeet/piki/notification/config/NotificationProperties.kt b/src/main/kotlin/com/depromeet/piki/notification/config/NotificationProperties.kt new file mode 100644 index 00000000..5f35829b --- /dev/null +++ b/src/main/kotlin/com/depromeet/piki/notification/config/NotificationProperties.kt @@ -0,0 +1,19 @@ +package com.depromeet.piki.notification.config + +import org.springframework.boot.context.properties.ConfigurationProperties + +/** + * 알림 도메인 설정. `@ConfigurationPropertiesScan`(PikiApplication)으로 자동 등록. + * + * @property retentionDays 알림 보존 기간(일). 생성 후 이 기간을 넘긴 알림은 N일 자동삭제 스케줄러가 하드삭제한다. 하드코딩 금지 — 여기서 협의값을 둔다. + */ +@ConfigurationProperties(prefix = "notification") +data class NotificationProperties( + val retentionDays: Long = 30, +) { + init { + // 불변식 — 잘못 구성한 env(0·음수)면 cutoff(now-retentionDays)가 현재/미래가 되어 자동삭제가 알림을 대량 삭제한다. + // ops 오설정이라 클라가 닿는 계약이 아니므로 커스텀 예외가 아니라 require 로 부팅 시점에 실패시킨다. + require(retentionDays > 0) { "notification.retention-days 는 양수여야 한다 (현재=$retentionDays)" } + } +} diff --git a/src/main/kotlin/com/depromeet/piki/notification/controller/NotificationHistoryApi.kt b/src/main/kotlin/com/depromeet/piki/notification/controller/NotificationHistoryApi.kt index ae009ed9..ff7b140b 100644 --- a/src/main/kotlin/com/depromeet/piki/notification/controller/NotificationHistoryApi.kt +++ b/src/main/kotlin/com/depromeet/piki/notification/controller/NotificationHistoryApi.kt @@ -1,6 +1,8 @@ package com.depromeet.piki.notification.controller import com.depromeet.piki.common.response.ApiResponseBody +import com.depromeet.piki.notification.controller.dto.NotificationDeleteRequest +import com.depromeet.piki.notification.controller.dto.NotificationDeleteResponse import com.depromeet.piki.notification.controller.dto.NotificationHistoryResponse import com.depromeet.piki.notification.controller.dto.NotificationReadRequest import com.depromeet.piki.notification.controller.dto.NotificationReadResponse @@ -148,4 +150,58 @@ interface NotificationHistoryApi { @Parameter(hidden = true) userId: UUID, request: NotificationReadRequest, ): ApiResponseBody + + @Operation( + summary = "알림 삭제", + description = + "알림을 삭제한다 (**GUEST·MEMBER 모두, 본인 알림만**). 하드삭제라 삭제된 알림은 히스토리에서 영구 제거된다(복구 없음). " + + "요청 body 는 읽음(`POST /read`)과 같은 계약을 미러링해 두 방식 중 **정확히 하나**:\n\n" + + "| 방식 | 동작 |\n" + + "|---|---|\n" + + "| `all=true` | 본인 알림 전부 삭제 (읽음 무관, 모두 삭제 버튼) |\n" + + "| `ids=[...]` | 지정한 알림만 삭제 (단건은 `[id]` 1개, 다건은 `[id, ...]`) |\n\n" + + "- 둘 다 보내거나 둘 다 비우면(빈 `ids` 포함) **400**.\n" + + "- `ids` 는 본인 소유만 삭제되고 타인·없는 id 는 무시된다. **멱등**(이미 없는 것도 성공).\n" + + "- 삭제로 안읽음 알림이 사라지면 badge 도 줄어든다. 응답 `data` 의 `unreadCount`(앱 badge) · " + + "`unreadCountByCategory`(탭 badge)로 삭제 후 안읽음 수를 **서버 권위 값**으로 내려준다 — " + + "클라는 이 값들을 그대로 badge 로 미러링한다(읽음 응답과 동일 셰입).", + ) + @ApiResponses( + value = [ + ApiResponse( + responseCode = "200", + description = "삭제 성공 (삭제 후 unreadCount 동봉, 멱등)", + content = [ + Content( + mediaType = MediaType.APPLICATION_JSON_VALUE, + schema = Schema(implementation = ApiResponseBody::class), + ), + ], + ), + ApiResponse( + responseCode = "400", + description = "잘못된 요청 (all 과 ids 를 함께 보냄 · 둘 다 없음 · 빈 ids)", + content = [ + Content( + mediaType = MediaType.APPLICATION_JSON_VALUE, + schema = Schema(implementation = ApiResponseBody::class), + ), + ], + ), + ApiResponse( + responseCode = "401", + description = "미인증 (JWT 토큰 없음 또는 유효하지 않음)", + content = [ + Content( + mediaType = MediaType.APPLICATION_JSON_VALUE, + schema = Schema(implementation = ApiResponseBody::class), + ), + ], + ), + ], + ) + fun delete( + @Parameter(hidden = true) userId: UUID, + request: NotificationDeleteRequest, + ): ApiResponseBody } diff --git a/src/main/kotlin/com/depromeet/piki/notification/controller/NotificationHistoryApiExamples.kt b/src/main/kotlin/com/depromeet/piki/notification/controller/NotificationHistoryApiExamples.kt index 64794910..dd7bfd9e 100644 --- a/src/main/kotlin/com/depromeet/piki/notification/controller/NotificationHistoryApiExamples.kt +++ b/src/main/kotlin/com/depromeet/piki/notification/controller/NotificationHistoryApiExamples.kt @@ -6,6 +6,8 @@ import com.depromeet.piki.common.openapi.binds import com.depromeet.piki.common.openapi.examples import com.depromeet.piki.common.response.ApiResponseBody import com.depromeet.piki.common.response.PageResponse +import com.depromeet.piki.notification.controller.dto.NotificationDeleteRequest +import com.depromeet.piki.notification.controller.dto.NotificationDeleteResponse import com.depromeet.piki.notification.controller.dto.NotificationHistoryResponse import com.depromeet.piki.notification.controller.dto.NotificationReadRequest import com.depromeet.piki.notification.controller.dto.NotificationReadResponse @@ -99,6 +101,31 @@ class NotificationHistoryApiExamples( unauthorized() } } + if (handlerMethod.binds(NotificationHistoryController::delete)) { + operation.examples(openApiObjectMapper.delegate) { + add( + status = HttpStatus.OK, + name = "삭제 성공 (삭제 후 unreadCount 동봉)", + payload = + ApiResponseBody.ok( + data = NotificationDeleteResponse.of( + mapOf(NotificationCategory.ACTIVITY to 1L, NotificationCategory.SYSTEM to 0L), + ), + ), + ) + add( + status = HttpStatus.BAD_REQUEST, + name = "all 과 ids 동시 전송 / 둘 다 없음 / 빈 ids", + payload = + ApiResponseBody.fail( + category = ErrorCategory.INVALID_INPUT, + // @AssertTrue 위반은 GlobalExceptionHandler.detailOf 가 위반 필드의 메시지를 그대로 detail 로 내린다. + detail = NotificationDeleteRequest.VALID_SELECTION_MESSAGE, + ), + ) + unauthorized() + } + } operation } diff --git a/src/main/kotlin/com/depromeet/piki/notification/controller/NotificationHistoryController.kt b/src/main/kotlin/com/depromeet/piki/notification/controller/NotificationHistoryController.kt index 82d368b4..00e3ac19 100644 --- a/src/main/kotlin/com/depromeet/piki/notification/controller/NotificationHistoryController.kt +++ b/src/main/kotlin/com/depromeet/piki/notification/controller/NotificationHistoryController.kt @@ -2,14 +2,18 @@ package com.depromeet.piki.notification.controller import com.depromeet.piki.common.response.ApiResponseBody import com.depromeet.piki.common.response.PageResponse +import com.depromeet.piki.notification.controller.dto.NotificationDeleteRequest +import com.depromeet.piki.notification.controller.dto.NotificationDeleteResponse import com.depromeet.piki.notification.controller.dto.NotificationHistoryResponse import com.depromeet.piki.notification.controller.dto.NotificationReadRequest import com.depromeet.piki.notification.controller.dto.NotificationReadResponse import com.depromeet.piki.notification.domain.NotificationCategory +import com.depromeet.piki.notification.service.NotificationDeleteOrchestrator import com.depromeet.piki.notification.service.NotificationReadOrchestrator import com.depromeet.piki.notification.service.NotificationService import jakarta.validation.Valid 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.PostMapping import org.springframework.web.bind.annotation.RequestBody @@ -23,6 +27,7 @@ import java.util.UUID class NotificationHistoryController( private val notificationService: NotificationService, private val notificationReadOrchestrator: NotificationReadOrchestrator, + private val notificationDeleteOrchestrator: NotificationDeleteOrchestrator, ) : NotificationHistoryApi { @GetMapping override fun getHistory( @@ -46,4 +51,13 @@ class NotificationHistoryController( val unreadByCategory = notificationReadOrchestrator.readAndSyncBadge(userId = userId, command = request.toCommand()) return ApiResponseBody.ok(data = NotificationReadResponse.of(unreadByCategory)) } + + @DeleteMapping + override fun delete( + @AuthenticationPrincipal userId: UUID, + @Valid @RequestBody request: NotificationDeleteRequest, + ): ApiResponseBody { + val unreadByCategory = notificationDeleteOrchestrator.deleteAndSyncBadge(userId = userId, command = request.toCommand()) + return ApiResponseBody.ok(data = NotificationDeleteResponse.of(unreadByCategory)) + } } diff --git a/src/main/kotlin/com/depromeet/piki/notification/controller/dto/NotificationDeleteRequest.kt b/src/main/kotlin/com/depromeet/piki/notification/controller/dto/NotificationDeleteRequest.kt new file mode 100644 index 00000000..b64523a2 --- /dev/null +++ b/src/main/kotlin/com/depromeet/piki/notification/controller/dto/NotificationDeleteRequest.kt @@ -0,0 +1,42 @@ +package com.depromeet.piki.notification.controller.dto + +import com.depromeet.piki.notification.service.dto.NotificationDeleteCommand +import com.fasterxml.jackson.annotation.JsonIgnore +import io.swagger.v3.oas.annotations.media.Schema +import jakarta.validation.constraints.AssertTrue + +// 삭제 요청 — all=true(모두 삭제) XOR ids(지정 단건/다건). 읽음(NotificationReadRequest)과 같은 계약을 미러링한다. +// 정확히 하나만 유효(둘 다·둘 다 없음·빈 ids 는 400). 삭제는 읽음 무관 하드삭제이고 본인 소유만 걸러 멱등이다. +@Schema(description = "알림 삭제 요청 — all=true(모두) 또는 ids(지정) 중 정확히 하나") +data class NotificationDeleteRequest( + @field:Schema(description = "true 면 본인 알림 전부 삭제 (읽음 무관, 모두 삭제 버튼). ids 와 동시 사용 불가", nullable = true, example = "true") + val all: Boolean? = null, + @field:Schema(description = "삭제할 알림 id 목록 (단건은 [id] 1개, 다건은 [id, ...]). all 과 동시 사용 불가", nullable = true, example = "[1024]") + val ids: List? = null, +) { + // all XOR ids — 정확히 한쪽만. 둘 다·둘 다 없음·빈 ids 는 400(입력 경계 계약, NotificationReadRequest 와 동형). + @get:JsonIgnore + @get:AssertTrue(message = VALID_SELECTION_MESSAGE) + val validSelection: Boolean + get() { + val byAll = all == true + val byIds = !ids.isNullOrEmpty() + return byAll xor byIds + } + + // validSelection 통과 후 호출 — all=true 면 All, 아니면 ids 는 non-null·non-empty 가 보장된다(불변식). + fun toCommand(): NotificationDeleteCommand = + if (all == true) { + NotificationDeleteCommand.All + } else { + NotificationDeleteCommand.Ids(requireNotNull(ids) { "validSelection 통과 시 ids 는 non-null 이다" }) + } + + companion object { + // Bean Validation 위반 메시지의 single source — ApiExamples 가 같은 상수를 참조한다(detail single-source). + // ids 개수 상한은 두지 않는다 — 대량 삭제는 all=true 가 담당하고, ids 는 본인 알림 선택(보통 소수)이라 + // 인증·본인 한정·멱등 + HTTP 본문 크기 상한으로 이미 안전하다(임의 캡으로 정상 요청을 400 으로 막지 않는다). + // 응답 detail 은 사용자 대면이라 친화 문구로 둔다(어느 필드가 잘못됐는지는 앱이 자기 요청으로 안다). + const val VALID_SELECTION_MESSAGE = "요청을 처리하지 못했어요." + } +} diff --git a/src/main/kotlin/com/depromeet/piki/notification/controller/dto/NotificationDeleteResponse.kt b/src/main/kotlin/com/depromeet/piki/notification/controller/dto/NotificationDeleteResponse.kt new file mode 100644 index 00000000..e5ba6b46 --- /dev/null +++ b/src/main/kotlin/com/depromeet/piki/notification/controller/dto/NotificationDeleteResponse.kt @@ -0,0 +1,25 @@ +package com.depromeet.piki.notification.controller.dto + +import com.depromeet.piki.notification.domain.NotificationCategory +import com.depromeet.piki.notification.service.toUnreadTotal +import io.swagger.v3.oas.annotations.media.Schema + +// 삭제 처리 응답 — 삭제 후 안읽음 수(전체·탭별)를 서버 권위 값으로 내려, 클라가 +1/-1 산수 없이 그대로 미러링하게 한다. +// 읽음 응답(NotificationReadResponse)과 같은 셰입(unreadCount + unreadCountByCategory)이라, 삭제로 안읽음이 사라지면 +// 앱 badge 와 활동/시스템 탭 badge 를 한 왕복에서 갱신한다(다른 기기는 재진입 시 GET /notifications 로 보정). +@Schema(description = "알림 삭제 처리 응답 — 삭제 후 안읽음 수(전체·탭별)") +data class NotificationDeleteResponse( + @field:Schema(description = "삭제 후 본인 전체 안읽음 수 (앱 badge). 클라는 이 값을 그대로 badge 로 미러링한다", example = "1") + val unreadCount: Long, + @field:Schema(description = "삭제 후 카테고리별 안읽음 수 (탭 badge). 모든 카테고리 키 포함, 없으면 0", example = "{\"ACTIVITY\":1,\"SYSTEM\":0}") + val unreadCountByCategory: Map, +) { + companion object { + // total 은 카테고리 합으로 도출 — toUnreadTotal 단일 소스를 거쳐 전체·탭별 두 수치가 어긋날 여지를 없앤다(read 와 동일 규칙). + fun of(unreadCountByCategory: Map): NotificationDeleteResponse = + NotificationDeleteResponse( + unreadCount = unreadCountByCategory.toUnreadTotal(), + unreadCountByCategory = unreadCountByCategory, + ) + } +} diff --git a/src/main/kotlin/com/depromeet/piki/notification/repository/NotificationJpaRepository.kt b/src/main/kotlin/com/depromeet/piki/notification/repository/NotificationJpaRepository.kt index 18b20406..4e1f3300 100644 --- a/src/main/kotlin/com/depromeet/piki/notification/repository/NotificationJpaRepository.kt +++ b/src/main/kotlin/com/depromeet/piki/notification/repository/NotificationJpaRepository.kt @@ -7,11 +7,14 @@ import org.springframework.data.jpa.repository.JpaRepository import org.springframework.data.jpa.repository.Modifying import org.springframework.data.jpa.repository.Query import org.springframework.data.repository.query.Param +import java.time.LocalDateTime import java.util.UUID interface NotificationJpaRepository : JpaRepository { - // 탈퇴 cascade — 그 수신자(userId) 의 알림을 일괄 하드삭제. 멱등(없으면 0건). - @Modifying + // 탈퇴 cascade + 모두 삭제(NotificationDeleteCommand.All) — 그 수신자(userId) 의 알림을 일괄 하드삭제. 멱등(없으면 0건). + // 벌크 DELETE 는 1차 캐시를 우회하므로, 삭제 전 pending 을 반영(flush)하고 삭제 후 stale 관리 엔티티를 비운다(clear) — + // deleteByUserIdAndIds·markRead 와 동일. 이로써 같은 트랜잭션의 이후 조회가 삭제 결과와 어긋나지 않는다. + @Modifying(flushAutomatically = true, clearAutomatically = true) @Query("DELETE FROM Notification n WHERE n.userId = :userId") fun hardDeleteAllByUserId( @Param("userId") userId: UUID, @@ -61,6 +64,23 @@ interface NotificationJpaRepository : JpaRepository { val count: Long } + // 여러 유저의 안읽음 수를 (userId, type) 별로 한 쿼리에 집계 — 자동삭제 배지 재계산의 N+1 을 없앤다(유저마다 쿼리 대신 IN + GROUP BY 한 번). + // 안읽음이 0 인 유저는 결과 행에 안 나오므로, 호출부가 affectedUsers 전체를 0 으로 깔고 이 결과를 덮어 계약(모든 대상 유저 키 포함)을 유지한다. + @Query( + "SELECT n.userId AS userId, n.type AS type, COUNT(n) AS count FROM Notification n " + + "WHERE n.userId IN :userIds AND n.isRead = false GROUP BY n.userId, n.type", + ) + fun countUnreadByUserAndType( + @Param("userIds") userIds: Collection, + ): List + + // group by 결과 한 행 — userId·type 과 그 안읽음 수. Spring Data closed projection(SELECT alias 와 게터명 일치). + interface UserTypeUnreadCount { + val userId: UUID + val type: NotificationType + val count: Long + } + // 본인 소유(user_id) + 지정 id + 아직 안읽음만 read 로. user_id 가 WHERE 에 있어 타인/없는 id 는 무영향(소유 검증 겸용). // 영향 건수를 돌려준다. 멱등(이미 읽음·없는 id 는 0건). // 벌크 UPDATE 는 1차 캐시를 우회하므로, 같은 트랜잭션에서 이후 재조회가 stale 값을 읽지 않도록 컨텍스트를 flush·clear 한다. @@ -77,4 +97,30 @@ interface NotificationJpaRepository : JpaRepository { fun markAllReadByUserId( @Param("userId") userId: UUID, ): Int + + // 본인 소유(user_id) + 지정 id 만 하드삭제 (읽음 무관). user_id 가 WHERE 에 있어 타인/없는 id 는 무영향(소유 검증 겸용). + // 영향 건수를 돌려준다. 멱등(없는 id 는 0건). 벌크 DELETE 는 1차 캐시를 우회하므로 같은 트랜잭션의 이후 + // 안읽음 재집계가 stale 값을 읽지 않도록 컨텍스트를 flush·clear 한다(markReadByUserIdAndIds 와 동일 이유). + @Modifying(flushAutomatically = true, clearAutomatically = true) + @Query("DELETE FROM Notification n WHERE n.userId = :userId AND n.id IN :ids") + fun deleteByUserIdAndIds( + @Param("userId") userId: UUID, + @Param("ids") ids: Collection, + ): Int + + // N일 자동삭제로 배지가 바뀔 유저 — cutoff 미만 + 안읽음 알림을 가진 유저의 id 를 중복 없이. 삭제 전에 모아 둔다(삭제 후엔 사라진다). + // 읽음만 지워지는 유저는 unread 가 안 변해 배지 동기화가 불필요하므로 제외한다(is_read=false 조건). (idx (user_id, is_read) 커버) + @Query("SELECT DISTINCT n.userId FROM Notification n WHERE n.createdAt < :cutoff AND n.isRead = false") + fun findUserIdsWithUnreadCreatedBefore( + @Param("cutoff") cutoff: LocalDateTime, + ): List + + // N일 자동삭제 — created_at 이 cutoff 미만인 알림을 유저 무관 전부 하드삭제(읽음 무관). 영향 건수 반환. 멱등. + // age 기준이라 blue-green 중복 실행에도 결과가 같다(commutative). (idx_notifications_created_at 이 풀스캔을 막는다) + // 벌크 DELETE 는 1차 캐시를 우회하므로, 삭제 전 pending insert 를 반영(flush)하고 삭제 후 stale 관리 엔티티를 비운다(clear). + @Modifying(flushAutomatically = true, clearAutomatically = true) + @Query("DELETE FROM Notification n WHERE n.createdAt < :cutoff") + fun deleteByCreatedAtBefore( + @Param("cutoff") cutoff: LocalDateTime, + ): Int } diff --git a/src/main/kotlin/com/depromeet/piki/notification/repository/NotificationRepository.kt b/src/main/kotlin/com/depromeet/piki/notification/repository/NotificationRepository.kt index 38581b3b..a110b183 100644 --- a/src/main/kotlin/com/depromeet/piki/notification/repository/NotificationRepository.kt +++ b/src/main/kotlin/com/depromeet/piki/notification/repository/NotificationRepository.kt @@ -4,6 +4,7 @@ import com.depromeet.piki.notification.domain.Notification import com.depromeet.piki.notification.domain.NotificationCategory import com.depromeet.piki.notification.domain.NotificationCursor import com.depromeet.piki.notification.domain.NotificationType +import java.time.LocalDateTime import java.util.UUID interface NotificationRepository { @@ -25,6 +26,10 @@ interface NotificationRepository { // 전체 안읽음 수(앱 badge)는 이 맵의 값 합으로 도출한다 — 두 수치가 어긋날 여지를 없앤다. fun countUnreadByCategory(userId: UUID): Map + // 여러 유저의 카테고리별 안읽음 수를 한 쿼리로(자동삭제 배지 재계산의 N+1 제거). 대상 유저 전원을 키로 포함하며(안읽음 0 인 유저도), + // 각 유저 맵은 모든 카테고리 키를 0 기본으로 채운다 — countUnreadByCategory 의 다중 유저 버전이라 계약이 같다. + fun countUnreadByCategoryForUsers(userIds: List): Map> + // 지정 id 중 본인 소유 + 안읽음만 read 로 (타인/없는 id 무영향). 영향 건수 반환. 멱등. fun markRead( userId: UUID, @@ -33,4 +38,16 @@ interface NotificationRepository { // 본인 안읽음 전부 read. 영향 건수 반환. 멱등. fun markAllRead(userId: UUID): Int + + // 지정 id 중 본인 소유만 하드삭제 (읽음 무관, 타인/없는 id 무영향). 영향 건수 반환. 멱등. + fun deleteByUserIdAndIds( + userId: UUID, + ids: List, + ): Int + + // N일 자동삭제로 배지가 바뀔 유저(cutoff 미만 안읽음 보유)의 id 를 중복 없이. 삭제 전에 모아 배지 동기화 대상으로 쓴다. + fun findUserIdsWithUnreadCreatedBefore(cutoff: LocalDateTime): List + + // N일 자동삭제 — created_at 이 cutoff 미만인 알림을 유저 무관 전부 하드삭제. 영향 건수 반환. 멱등. + fun deleteByCreatedAtBefore(cutoff: LocalDateTime): Int } diff --git a/src/main/kotlin/com/depromeet/piki/notification/repository/NotificationRepositoryImpl.kt b/src/main/kotlin/com/depromeet/piki/notification/repository/NotificationRepositoryImpl.kt index a5a66b3e..eac7483b 100644 --- a/src/main/kotlin/com/depromeet/piki/notification/repository/NotificationRepositoryImpl.kt +++ b/src/main/kotlin/com/depromeet/piki/notification/repository/NotificationRepositoryImpl.kt @@ -6,6 +6,7 @@ import com.depromeet.piki.notification.domain.NotificationCursor import com.depromeet.piki.notification.domain.NotificationType import org.springframework.data.domain.Limit import org.springframework.stereotype.Repository +import java.time.LocalDateTime import java.util.UUID @Repository @@ -57,10 +58,34 @@ class NotificationRepositoryImpl( return result } + // 대상 유저 전원을 모든 카테고리 0 으로 깔고(안읽음 0 인 유저도 키 보장), IN + GROUP BY 한 쿼리 결과를 덮어 접는다. + // userIds 가 비면 쿼리를 건너뛴다(빈 IN 회피). + override fun countUnreadByCategoryForUsers(userIds: List): Map> { + if (userIds.isEmpty()) return emptyMap() + val result = + userIds.associateWithTo(mutableMapOf>()) { + NotificationCategory.entries.associateWithTo(mutableMapOf()) { 0L } + } + notificationJpaRepository.countUnreadByUserAndType(userIds).forEach { row -> + result.getValue(row.userId).merge(NotificationCategory.of(row.type), row.count, Long::plus) + } + return result + } + override fun markRead( userId: UUID, ids: List, ): Int = notificationJpaRepository.markReadByUserIdAndIds(userId, ids) override fun markAllRead(userId: UUID): Int = notificationJpaRepository.markAllReadByUserId(userId) + + override fun deleteByUserIdAndIds( + userId: UUID, + ids: List, + ): Int = notificationJpaRepository.deleteByUserIdAndIds(userId, ids) + + override fun findUserIdsWithUnreadCreatedBefore(cutoff: LocalDateTime): List = + notificationJpaRepository.findUserIdsWithUnreadCreatedBefore(cutoff) + + override fun deleteByCreatedAtBefore(cutoff: LocalDateTime): Int = notificationJpaRepository.deleteByCreatedAtBefore(cutoff) } diff --git a/src/main/kotlin/com/depromeet/piki/notification/service/NotificationCleanupScheduler.kt b/src/main/kotlin/com/depromeet/piki/notification/service/NotificationCleanupScheduler.kt new file mode 100644 index 00000000..d5a43fe8 --- /dev/null +++ b/src/main/kotlin/com/depromeet/piki/notification/service/NotificationCleanupScheduler.kt @@ -0,0 +1,40 @@ +package com.depromeet.piki.notification.service + +import com.depromeet.piki.notification.config.NotificationProperties +import org.slf4j.LoggerFactory +import org.springframework.scheduling.annotation.Scheduled +import org.springframework.stereotype.Component +import java.time.LocalDateTime + +// N일 자동삭제 스케줄러 — 보존 기간(notification.retention-days)을 넘긴 알림을 1일 1회 하드삭제한다. +// admin 백오피스와 무관한 코어 유지보수라 ConditionalOnAdminEnabled 를 붙이지 않는다(AnnouncementScheduler 와 다른 점). +// 스케줄러는 얇게 — cutoff 계산 + 위임만 한다. 삭제·트랜잭션 경계는 NotificationRetentionService 가, 배지 fan-out 은 +// NotificationRetentionBadgeNotifier 가 진다(둘 다 테스트가 직접 호출). 삭제로 안읽음이 줄면 배지를 최신으로 맞춰야 +// 하므로(사라진 unread 가 REST unreadCount·SSE·FCM 배지에 stale 로 남지 않게), 삭제 후 영향 유저에게 배지를 전파한다. +// 배지 전파는 단일 @Async 배치(notifier)라 스케줄 스레드를 잡지 않는다. +@Component +class NotificationCleanupScheduler( + private val notificationRetentionService: NotificationRetentionService, + private val notificationProperties: NotificationProperties, + private val badgeNotifier: NotificationRetentionBadgeNotifier, +) { + private val log = LoggerFactory.getLogger(javaClass) + + // 기본 매일 04:00 KST. 값은 notification.cleanup-cron 으로 덮을 수 있다(placeholder 기본값으로 미설정 시에도 부팅 안전). + // zone 을 KST 로 고정한다 — 이 앱의 JVM 기본 TZ 는 UTC 라(로그 표시만 KST), 미지정 시 04:00 UTC(13:00 KST)에 돈다. + // DailyActivityRecorder·WeeklyReportScheduler 등 다른 cron 스케줄러와 동일하게 zone="Asia/Seoul" 로 맞춘다. + @Scheduled(cron = "\${notification.cleanup-cron:0 0 4 * * *}", zone = "Asia/Seoul") + fun cleanup() { + // cutoff 은 created_at(JVM 기본 TZ 의 @CreatedDate)과 같은 기준의 now 에서 빼야 비교가 어긋나지 않는다. + val cutoff = LocalDateTime.now().minusDays(notificationProperties.retentionDays) + val result = notificationRetentionService.purgeOlderThan(cutoff) + // 커밋 후 배지 동기화 위임 — 단일 @Async 배치가 영향 유저를 순차 전파한다(대량 백로그에도 실행기 포화 없이 drop 0). + badgeNotifier.notifyAll(result.affectedUnreadByUser) + log.info( + "알림 자동삭제 스케줄 실행 — 보존 {}일, 삭제 {}건, 배지대상 {}명", + notificationProperties.retentionDays, + result.deletedCount, + result.affectedUnreadByUser.size, + ) + } +} diff --git a/src/main/kotlin/com/depromeet/piki/notification/service/NotificationDeleteOrchestrator.kt b/src/main/kotlin/com/depromeet/piki/notification/service/NotificationDeleteOrchestrator.kt new file mode 100644 index 00000000..9929ed7f --- /dev/null +++ b/src/main/kotlin/com/depromeet/piki/notification/service/NotificationDeleteOrchestrator.kt @@ -0,0 +1,32 @@ +package com.depromeet.piki.notification.service + +import com.depromeet.piki.notification.controller.dto.UnreadCountChanged +import com.depromeet.piki.notification.domain.NotificationCategory +import com.depromeet.piki.notification.service.dto.NotificationDeleteCommand +import com.depromeet.piki.notification.sse.SilentSyncDispatcher +import org.springframework.stereotype.Component +import java.util.UUID + +// 삭제 처리 + 갱신 badge 동기화를 묶는 얇은 오케스트레이터. 읽음(NotificationReadOrchestrator)과 대칭이다 — +// 삭제로 안읽음 알림이 사라지면 badge 도 줄어야 하므로, 삭제 후 안읽음 수를 두 경로로 다른 기기에 맞춘다. +// 트랜잭션이 없다(@Transactional 미부착) — 삭제 DELETE 는 NotificationService.delete(별도 빈의 @Transactional)가 +// 짧은 트랜잭션으로 커밋하고, badge 동기화(SSE·FCM) 둘 다 @Async 라 그 커밋 이후 응답 경로 밖에서 돈다. +@Component +class NotificationDeleteOrchestrator( + private val notificationService: NotificationService, + private val pushNotificationChannel: PushNotificationChannel, + private val silentSyncDispatcher: SilentSyncDispatcher, +) { + // 삭제 후 갱신 안읽음 수를 반환하고(삭제한 기기는 응답 body 로 즉시 badge 미러링), 같은 유저의 다른 기기엔 + // 두 경로로 badge 를 맞춘다: 온라인(열린 SSE) 기기는 silent-sync(UNREAD_COUNT_CHANGED)로 인앱 배지를, + // 오프라인 기기는 FCM silent 푸시로 OS 아이콘 badge 를. 읽음 flow(readAndSyncBadge)와 완전히 대칭이다. + fun deleteAndSyncBadge( + userId: UUID, + command: NotificationDeleteCommand, + ): Map { + val unread = notificationService.delete(userId, command) + silentSyncDispatcher.dispatch(listOf(userId), UnreadCountChanged.of(unread)) + pushNotificationChannel.syncBadge(userId, unread.toBadgeCount()) + return unread + } +} diff --git a/src/main/kotlin/com/depromeet/piki/notification/service/NotificationRetentionBadgeNotifier.kt b/src/main/kotlin/com/depromeet/piki/notification/service/NotificationRetentionBadgeNotifier.kt new file mode 100644 index 00000000..be923632 --- /dev/null +++ b/src/main/kotlin/com/depromeet/piki/notification/service/NotificationRetentionBadgeNotifier.kt @@ -0,0 +1,41 @@ +package com.depromeet.piki.notification.service + +import com.depromeet.piki.common.config.AsyncConfig +import com.depromeet.piki.notification.controller.dto.UnreadCountChanged +import com.depromeet.piki.notification.domain.NotificationCategory +import com.depromeet.piki.notification.sse.LocalSseDelivery +import org.slf4j.LoggerFactory +import org.springframework.scheduling.annotation.Async +import org.springframework.stereotype.Component +import java.util.UUID + +// 자동삭제로 안읽음이 줄어든 유저들에게 배지(SSE·FCM)를 전파하는 배치. 스케줄러가 이 한 메서드만 호출하고, 전체 fan-out 이 +// notificationExecutor 워커 "하나" 위에서 순차로 돈다. +// +// 왜 유저당 @Async 를 안 던지나: 단건·모두 삭제(NotificationDeleteOrchestrator)는 영향 유저가 1명이라 유저당 @Async 로 충분하지만, +// 자동삭제 첫 실행은 30일 넘긴 백로그가 수백~수천 유저에 몰릴 수 있다. 유저당 태스크(유저×2)를 한꺼번에 던지면 +// notificationExecutor(pool 4·queue 200·포화 시 drop)를 넘겨 배지가 대량 유실되고, 같은 실행기를 쓰는 실시간 알림까지 굶는다. +// (500명 실측: 유저당 던지기 = FCM 전달률 21.6%.) 순차 처리라 어느 순간에도 실행기 점유 태스크가 1개뿐 → drop 0, 실시간 알림 보존. +// +// 한 유저 전달 실패가 배치를 끊지 않게 각 유저를 방어한다(FCM 은 syncBadgeBlocking 이 자체 흡수, SSE 는 여기서 흡수). +// 배치 자체가 실행기에서 거부되면(이미 포화) 로그만 남고 이번 회차 배지 동기화를 건너뛴다 — 못 받은 기기는 재진입 시 GET 으로 보정된다. +@Component +class NotificationRetentionBadgeNotifier( + private val localDelivery: LocalSseDelivery, + private val pushNotificationChannel: PushNotificationChannel, +) { + private val log = LoggerFactory.getLogger(javaClass) + + @Async(AsyncConfig.NOTIFICATION_EXECUTOR) + fun notifyAll(unreadByUser: Map>) { + if (unreadByUser.isEmpty()) return + unreadByUser.forEach { (userId, unread) -> + // 온라인(열린 SSE) 기기 인앱 배지. + runCatching { localDelivery.deliverSilentSync(listOf(userId), UnreadCountChanged.of(unread)) } + .onFailure { log.warn("자동삭제 SSE 배지 동기화 실패 userId={}", userId, it) } + // 오프라인 기기 OS 아이콘 배지 (syncBadgeBlocking 이 예외를 자체 흡수). + pushNotificationChannel.syncBadgeBlocking(userId, unread.toBadgeCount()) + } + log.info("자동삭제 배지 동기화 완료 — 대상 {}명 순차 전파", unreadByUser.size) + } +} diff --git a/src/main/kotlin/com/depromeet/piki/notification/service/NotificationRetentionService.kt b/src/main/kotlin/com/depromeet/piki/notification/service/NotificationRetentionService.kt new file mode 100644 index 00000000..772b29e6 --- /dev/null +++ b/src/main/kotlin/com/depromeet/piki/notification/service/NotificationRetentionService.kt @@ -0,0 +1,31 @@ +package com.depromeet.piki.notification.service + +import com.depromeet.piki.notification.repository.NotificationRepository +import com.depromeet.piki.notification.service.dto.NotificationPurgeResult +import org.slf4j.LoggerFactory +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import java.time.LocalDateTime + +// N일 자동삭제의 순수 로직 — cutoff 이전(created_at < cutoff) 알림을 유저 무관 전부 하드삭제한다. +// 스케줄러(NotificationCleanupScheduler)가 cutoff 을 계산해 이 메서드를 호출하고, 테스트는 cutoff 을 직접 넘겨 결정적으로 검증한다. +// age 기준이라 멱등이고 commutative — blue-green 중복 실행에도 결과가 같다. +@Service +class NotificationRetentionService( + private val notificationRepository: NotificationRepository, +) { + private val log = LoggerFactory.getLogger(javaClass) + + // 삭제로 안읽음이 줄어든 유저를 삭제 전에 모아 두고(삭제 후엔 사라진다), 삭제 뒤 그 유저들의 안읽음을 재집계해 반환한다. + // 배지 동기화(SSE·FCM)는 외부 호출이라 이 트랜잭션 안에서 하지 않는다 — 스케줄러가 커밋 후 이 맵을 받아 유저별로 쏜다 + // (단건·모두 삭제의 NotificationDeleteOrchestrator 와 동일한 tx 밖 배지 동기화 결). + @Transactional + fun purgeOlderThan(cutoff: LocalDateTime): NotificationPurgeResult { + val affectedUsers = notificationRepository.findUserIdsWithUnreadCreatedBefore(cutoff) + val deleted = notificationRepository.deleteByCreatedAtBefore(cutoff) + // 유저마다 countUnreadByCategory 를 돌면 N+1 이라, IN + GROUP BY 한 쿼리로 대상 유저 전원의 안읽음을 한 번에 재집계한다. + val affectedUnreadByUser = notificationRepository.countUnreadByCategoryForUsers(affectedUsers) + log.info("알림 보존기간 정리 — cutoff={} 삭제건수={} 배지영향유저={}", cutoff, deleted, affectedUsers.size) + return NotificationPurgeResult(deletedCount = deleted, affectedUnreadByUser = affectedUnreadByUser) + } +} diff --git a/src/main/kotlin/com/depromeet/piki/notification/service/NotificationService.kt b/src/main/kotlin/com/depromeet/piki/notification/service/NotificationService.kt index 1d768d3a..56e470a7 100644 --- a/src/main/kotlin/com/depromeet/piki/notification/service/NotificationService.kt +++ b/src/main/kotlin/com/depromeet/piki/notification/service/NotificationService.kt @@ -4,6 +4,7 @@ import com.depromeet.piki.notification.domain.NotificationCategory import com.depromeet.piki.notification.domain.NotificationCursor import com.depromeet.piki.notification.domain.NotificationHistorySize import com.depromeet.piki.notification.repository.NotificationRepository +import com.depromeet.piki.notification.service.dto.NotificationDeleteCommand import com.depromeet.piki.notification.service.dto.NotificationHistoryPage import com.depromeet.piki.notification.service.dto.NotificationReadCommand import org.slf4j.LoggerFactory @@ -76,4 +77,22 @@ class NotificationService( log.info("알림 읽음 처리 userId={} 방식={} 처리후안읽음={}", userId, method, unread.values.sum()) return unread } + + // 삭제 처리 — 명령(All/Ids)별 벌크 하드삭제(읽음 무관). 본인 소유만 반영(소유 검증은 쿼리의 user_id 조건이 겸한다). 멱등. + // 삭제로 안읽음 알림이 사라지면 badge 도 줄어야 하므로, 읽음(read)과 대칭으로 처리 후 카테고리별 안읽음 수를 같은 + // 트랜잭션에서 세어 반환한다 — 클라가 앱 badge·탭 badge 를 서버 권위 값으로 미러링하게 해 산수 drift 를 없앤다. + @Transactional + fun delete( + userId: UUID, + command: NotificationDeleteCommand, + ): Map { + val deleted = + when (command) { + NotificationDeleteCommand.All -> notificationRepository.hardDeleteAllByUserId(userId) + is NotificationDeleteCommand.Ids -> notificationRepository.deleteByUserIdAndIds(userId, command.ids) + } + val unread = notificationRepository.countUnreadByCategory(userId) + log.info("알림 삭제 userId={} 삭제건수={} 처리후안읽음={}", userId, deleted, unread.values.sum()) + return unread + } } diff --git a/src/main/kotlin/com/depromeet/piki/notification/service/PushNotificationChannel.kt b/src/main/kotlin/com/depromeet/piki/notification/service/PushNotificationChannel.kt index afdd5457..07ab1506 100644 --- a/src/main/kotlin/com/depromeet/piki/notification/service/PushNotificationChannel.kt +++ b/src/main/kotlin/com/depromeet/piki/notification/service/PushNotificationChannel.kt @@ -65,6 +65,14 @@ class PushNotificationChannel( fun syncBadge( userId: UUID, badge: Int, + ) = syncBadgeBlocking(userId, badge) + + // 동기 배지 발송(best-effort). @Async syncBadge 와 자동삭제 배치(NotificationRetentionBadgeNotifier)가 공유한다 — + // 후자는 유저당 @Async 를 던지지 않고 한 워커에서 이걸 순차 호출해 실행기 포화(대량 drop)를 피한다. 자기 예외를 삼켜 + // 호출자(비동기 워커·순차 배치)로 전파하지 않는다: 배지 push 실패가 읽음/삭제를 깨면 안 되고, 못 받은 기기는 재진입 시 GET 으로 보정된다. + fun syncBadgeBlocking( + userId: UUID, + badge: Int, ) { try { val sender = sender() ?: return @@ -73,7 +81,7 @@ class PushNotificationChannel( val result = sender.sendBadgeSync(tokens, badge) userDeviceService.removeStaleTokens(result.staleTokens) } catch (e: Exception) { - log.warn("읽음 후 badge 동기화 푸시 실패 userId={}", userId, e) + log.warn("badge 동기화 푸시 실패 userId={}", userId, e) } } diff --git a/src/main/kotlin/com/depromeet/piki/notification/service/dto/NotificationDeleteCommand.kt b/src/main/kotlin/com/depromeet/piki/notification/service/dto/NotificationDeleteCommand.kt new file mode 100644 index 00000000..9970f930 --- /dev/null +++ b/src/main/kotlin/com/depromeet/piki/notification/service/dto/NotificationDeleteCommand.kt @@ -0,0 +1,13 @@ +package com.depromeet.piki.notification.service.dto + +// 삭제 명령 — 요청 DTO(NotificationDeleteRequest)의 XOR 검증을 통과한 "정확히 한 가지" 의도를 타입으로 고정한다. +// 읽음(NotificationReadCommand)과 대칭이다. 서비스는 when + sealed 로 분기해 nullable 잡탕 분기를 피한다. +sealed interface NotificationDeleteCommand { + // 본인 알림 전부 삭제 (읽음 무관, 모두 삭제 버튼). + data object All : NotificationDeleteCommand + + // 지정한 알림들만 삭제 (단건·다건). 본인 소유만 반영되고 타인/없는 id 는 무영향(멱등). + data class Ids( + val ids: List, + ) : NotificationDeleteCommand +} diff --git a/src/main/kotlin/com/depromeet/piki/notification/service/dto/NotificationPurgeResult.kt b/src/main/kotlin/com/depromeet/piki/notification/service/dto/NotificationPurgeResult.kt new file mode 100644 index 00000000..432cbaef --- /dev/null +++ b/src/main/kotlin/com/depromeet/piki/notification/service/dto/NotificationPurgeResult.kt @@ -0,0 +1,12 @@ +package com.depromeet.piki.notification.service.dto + +import com.depromeet.piki.notification.domain.NotificationCategory +import java.util.UUID + +// N일 자동삭제 1회 결과. deletedCount 는 지표·로그용, affectedUnreadByUser 는 배지 동기화용이다. +// affectedUnreadByUser: 삭제로 안읽음이 줄어든 유저(cutoff 미만 안읽음 보유자) → 삭제 후 재집계한 카테고리별 안읽음 수. +// 스케줄러가 이 맵을 돌며 유저별로 SSE silent-sync + FCM 배지를 쏴, 자동삭제도 단건·모두 삭제처럼 배지를 최신으로 맞춘다. +data class NotificationPurgeResult( + val deletedCount: Int, + val affectedUnreadByUser: Map>, +) diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index ce95d22b..da059bb6 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -148,6 +148,12 @@ image: max-bytes: 5242880 # 5 MB timeout-seconds: 10 +notification: + # 알림 보존 기간(일). 생성 후 이 기간을 넘긴 알림은 N일 자동삭제 스케줄러(NotificationCleanupScheduler)가 하드삭제한다. + retention-days: ${NOTIFICATION_RETENTION_DAYS:30} + # 자동삭제 스케줄(cron, KST/Asia/Seoul). 기본 매일 04:00 KST. NotificationCleanupScheduler 의 @Scheduled(zone=Asia/Seoul) placeholder 가 참조한다. + cleanup-cron: ${NOTIFICATION_CLEANUP_CRON:0 0 4 * * *} + # API 레퍼런스 문서 노출 게이트 — dev 만 노출, staging/prod 는 404 (deploy.yml 이 dev 만 SPRINGDOC_ENABLED=true). # spec(springdoc.api-docs.enabled)·static Stoplight UI·루트 리다이렉트(docs.enabled → WebConfig)가 이 플래그를 공유한다. # 기본값을 fail-closed(false)로 둔다 — deploy.yml 의 변수 전달이 빠지거나 새 배포 경로가 생겨도 문서가 조용히 diff --git a/src/main/resources/db/migration/V20260715224011__add_index_on_notifications_created_at.sql b/src/main/resources/db/migration/V20260715224011__add_index_on_notifications_created_at.sql new file mode 100644 index 00000000..095ae889 --- /dev/null +++ b/src/main/resources/db/migration/V20260715224011__add_index_on_notifications_created_at.sql @@ -0,0 +1,3 @@ +-- N일 자동삭제(created_at < cutoff 하드삭제)가 풀스캔하지 않도록 created_at 단일 인덱스 추가. +-- age 기준 배치는 유저 무관 전역 스캔이라 (user_id, ...) 인덱스로는 커버되지 않는다. +CREATE INDEX idx_notifications_created_at ON notifications (created_at); diff --git a/src/test/kotlin/com/depromeet/piki/notification/controller/NotificationDeleteIntegrationTest.kt b/src/test/kotlin/com/depromeet/piki/notification/controller/NotificationDeleteIntegrationTest.kt new file mode 100644 index 00000000..7f86dff0 --- /dev/null +++ b/src/test/kotlin/com/depromeet/piki/notification/controller/NotificationDeleteIntegrationTest.kt @@ -0,0 +1,199 @@ +package com.depromeet.piki.notification.controller + +import com.depromeet.piki.auth.infrastructure.jwt.JwtProvider +import com.depromeet.piki.notification.controller.dto.NotificationDeleteRequest +import com.depromeet.piki.notification.domain.Notification +import com.depromeet.piki.notification.domain.NotificationType +import com.depromeet.piki.notification.repository.NotificationJpaRepository +import com.depromeet.piki.notification.repository.NotificationRepository +import com.depromeet.piki.support.IntegrationTestSupport +import com.depromeet.piki.user.domain.IdentityType +import org.hamcrest.Matchers.notNullValue +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.http.HttpHeaders +import org.springframework.http.MediaType +import org.springframework.security.test.web.servlet.setup.SecurityMockMvcConfigurers.springSecurity +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status +import org.springframework.test.web.servlet.setup.DefaultMockMvcBuilder +import org.springframework.test.web.servlet.setup.MockMvcBuilders +import org.springframework.transaction.annotation.Transactional +import org.springframework.web.context.WebApplicationContext +import java.util.UUID +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +@Transactional +class NotificationDeleteIntegrationTest : IntegrationTestSupport() { + @Autowired private lateinit var webApplicationContext: WebApplicationContext + + @Autowired private lateinit var jwtProvider: JwtProvider + + @Autowired private lateinit var notificationRepository: NotificationRepository + + @Autowired private lateinit var notificationJpaRepository: NotificationJpaRepository + + private fun authHeader(userId: UUID): String = "Bearer ${jwtProvider.generateAccessToken(userId, IdentityType.MEMBER)}" + + private fun buildMockMvc(): MockMvc = + MockMvcBuilders + .webAppContextSetup(webApplicationContext) + .apply(springSecurity()) + .build() + + private fun seed( + userId: UUID, + type: NotificationType = NotificationType.ITEM_PARSING_COMPLETED, + ): Long = + notificationRepository + .save(Notification(userId, type, "제목", "본문", 11L)) + .getId() + + // 삭제 벌크 쿼리(hardDeleteAllByUserId·deleteByUserIdAndIds)가 clearAutomatically 로 컨텍스트를 비워 + // findById(PK)도 벌크 삭제 후 DB 를 다시 쳐 삭제 결과를 정확히 반영한다. + private fun exists(id: Long): Boolean = notificationJpaRepository.findById(id).isPresent + + @Test + fun `단건 삭제 - ids 로 지정한 본인 알림만 삭제되고 badge 가 카테고리별로 재계산된다`() { + val userId = UUID.randomUUID() + val activity = seed(userId, NotificationType.TOURNAMENT_STARTED) // 활동, 안읽음 — 남아야 함 + val system = seed(userId, NotificationType.ITEM_PARSING_COMPLETED) // 시스템, 안읽음 — 삭제 대상 + + buildMockMvc() + .perform( + delete("/api/v1/notifications") + .contentType(MediaType.APPLICATION_JSON) + .content("""{"ids":[$system]}""") + .header(HttpHeaders.AUTHORIZATION, authHeader(userId)), + ).andExpect(status().isOk) + // system 삭제 후 남은 안읽음은 활동 1건 — 앱 badge 1, 탭별 활동1·시스템0. + .andExpect(jsonPath("$.data.unreadCount").value(1)) + .andExpect(jsonPath("$.data.unreadCountByCategory.ACTIVITY").value(1)) + .andExpect(jsonPath("$.data.unreadCountByCategory.SYSTEM").value(0)) + + assertFalse(exists(system)) + assertTrue(exists(activity)) + } + + @Test + fun `다건 삭제 - 여러 id 를 한 번에 삭제하고 지정 안 한 것은 남긴다`() { + val userId = UUID.randomUUID() + val a = seed(userId) + val b = seed(userId) + val c = seed(userId) + + buildMockMvc() + .perform( + delete("/api/v1/notifications") + .contentType(MediaType.APPLICATION_JSON) + .content("""{"ids":[$a,$b]}""") + .header(HttpHeaders.AUTHORIZATION, authHeader(userId)), + ).andExpect(status().isOk) + .andExpect(jsonPath("$.data.unreadCount").value(1)) + + assertFalse(exists(a)) + assertFalse(exists(b)) + assertTrue(exists(c)) + } + + @Test + fun `모두 삭제 - all=true 는 본인 알림 전부 삭제하고 타인 알림은 무영향이다`() { + val userId = UUID.randomUUID() + val otherUserId = UUID.randomUUID() + val mine1 = seed(userId) + val mine2 = seed(userId) + val others = seed(otherUserId) // all=true 가 user_id 범위를 안 넘는지 검증 (WHERE user_id=? 회귀 가드) + + buildMockMvc() + .perform( + delete("/api/v1/notifications") + .contentType(MediaType.APPLICATION_JSON) + .content("""{"all":true}""") + .header(HttpHeaders.AUTHORIZATION, authHeader(userId)), + ).andExpect(status().isOk) + .andExpect(jsonPath("$.data.unreadCount").value(0)) + .andExpect(jsonPath("$.data.unreadCountByCategory.ACTIVITY").value(0)) + .andExpect(jsonPath("$.data.unreadCountByCategory.SYSTEM").value(0)) + + assertFalse(exists(mine1)) + assertFalse(exists(mine2)) + assertTrue(exists(others)) + } + + @Test + fun `ids 에 타인 id 가 섞여도 본인 것만 삭제된다 (소유 검증·멱등)`() { + val userId = UUID.randomUUID() + val otherUserId = UUID.randomUUID() + val target = seed(userId) + val untouched = seed(userId) + val others = seed(otherUserId) + + buildMockMvc() + .perform( + delete("/api/v1/notifications") + .contentType(MediaType.APPLICATION_JSON) + .content("""{"ids":[$target,$others]}""") + .header(HttpHeaders.AUTHORIZATION, authHeader(userId)), + ).andExpect(status().isOk) + // target 만 본인 소유라 삭제 → 본인 안읽음은 untouched 1건 남는다(others 는 타인이라 무영향). + .andExpect(jsonPath("$.data.unreadCount").value(1)) + + assertFalse(exists(target)) + assertTrue(exists(untouched)) + assertTrue(exists(others)) + } + + @Test + fun `없는 id 를 삭제해도 멱등이라 200 이다`() { + val userId = UUID.randomUUID() + + buildMockMvc() + .perform( + delete("/api/v1/notifications") + .contentType(MediaType.APPLICATION_JSON) + .content("""{"ids":[999999]}""") + .header(HttpHeaders.AUTHORIZATION, authHeader(userId)), + ).andExpect(status().isOk) + .andExpect(jsonPath("$.data.unreadCount").value(0)) + } + + @Test + fun `all 과 ids 를 함께 보내면 400 이고 detail 에 위반 메시지가 실린다`() { + val userId = UUID.randomUUID() + buildMockMvc() + .perform( + delete("/api/v1/notifications") + .contentType(MediaType.APPLICATION_JSON) + .content("""{"all":true,"ids":[1]}""") + .header(HttpHeaders.AUTHORIZATION, authHeader(userId)), + ).andExpect(status().isBadRequest) + .andExpect(jsonPath("$.detail").value(NotificationDeleteRequest.VALID_SELECTION_MESSAGE)) + } + + @Test + fun `빈 ids 만 보내면 400 이고 detail 에 위반 메시지가 실린다`() { + val userId = UUID.randomUUID() + buildMockMvc() + .perform( + delete("/api/v1/notifications") + .contentType(MediaType.APPLICATION_JSON) + .content("""{"ids":[]}""") + .header(HttpHeaders.AUTHORIZATION, authHeader(userId)), + ).andExpect(status().isBadRequest) + .andExpect(jsonPath("$.detail").value(NotificationDeleteRequest.VALID_SELECTION_MESSAGE)) + } + + @Test + fun `토큰 없이 삭제하면 401 이 ApiResponseBody contract 로 내려간다`() { + buildMockMvc() + .perform( + delete("/api/v1/notifications") + .contentType(MediaType.APPLICATION_JSON) + .content("""{"all":true}"""), + ).andExpect(status().isUnauthorized) + .andExpect(jsonPath("$.detail", notNullValue())) + } +} diff --git a/src/test/kotlin/com/depromeet/piki/notification/controller/dto/NotificationDeleteRequestTest.kt b/src/test/kotlin/com/depromeet/piki/notification/controller/dto/NotificationDeleteRequestTest.kt new file mode 100644 index 00000000..4517746f --- /dev/null +++ b/src/test/kotlin/com/depromeet/piki/notification/controller/dto/NotificationDeleteRequestTest.kt @@ -0,0 +1,62 @@ +package com.depromeet.piki.notification.controller.dto + +import com.depromeet.piki.notification.service.dto.NotificationDeleteCommand +import org.junit.jupiter.api.Test +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.Arguments +import org.junit.jupiter.params.provider.MethodSource +import java.util.stream.Stream +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertTrue + +class NotificationDeleteRequestTest { + // all XOR ids — 정확히 한쪽만 유효 (읽음 요청과 동형 계약). + @ParameterizedTest(name = "[{index}] {3}") + @MethodSource("selectionCases") + fun `validSelection XOR`( + all: Boolean?, + ids: List?, + expected: Boolean, + @Suppress("UNUSED_PARAMETER") description: String, + ) { + assertEquals(expected, NotificationDeleteRequest(all = all, ids = ids).validSelection) + } + + @Test + fun `toCommand - all=true 면 All`() { + assertIs(NotificationDeleteRequest(all = true).toCommand()) + } + + @Test + fun `toCommand - ids 면 Ids 로 그 목록을 담는다`() { + val command = NotificationDeleteRequest(ids = listOf(1L, 2L)).toCommand() + val ids = assertIs(command) + assertEquals(listOf(1L, 2L), ids.ids) + } + + @Test + fun `validSelection - all 단독은 통과한다`() { + assertTrue(NotificationDeleteRequest(all = true).validSelection) + } + + @Test + fun `validSelection - 빈 ids 는 선택 없음으로 본다`() { + assertFalse(NotificationDeleteRequest(ids = emptyList()).validSelection) + } + + companion object { + @JvmStatic + fun selectionCases(): Stream = + Stream.of( + Arguments.of(true, null, true, "all 단독 → 유효"), + Arguments.of(null, listOf(1L), true, "ids 단독 → 유효"), + Arguments.of(true, listOf(1L), false, "둘 다 → 무효"), + Arguments.of(null, null, false, "둘 다 없음 → 무효"), + Arguments.of(false, null, false, "all=false + ids 없음 → 무효"), + Arguments.of(null, emptyList(), false, "빈 ids → 무효"), + Arguments.of(false, listOf(1L), true, "all=false + ids → ids 단독으로 유효"), + ) + } +} diff --git a/src/test/kotlin/com/depromeet/piki/notification/service/NotificationCleanupSchedulerIntegrationTest.kt b/src/test/kotlin/com/depromeet/piki/notification/service/NotificationCleanupSchedulerIntegrationTest.kt new file mode 100644 index 00000000..5bfe812a --- /dev/null +++ b/src/test/kotlin/com/depromeet/piki/notification/service/NotificationCleanupSchedulerIntegrationTest.kt @@ -0,0 +1,129 @@ +package com.depromeet.piki.notification.service + +import com.depromeet.piki.notification.config.NotificationProperties +import com.depromeet.piki.notification.controller.dto.UnreadCountChanged +import com.depromeet.piki.notification.domain.Notification +import com.depromeet.piki.notification.domain.NotificationType +import com.depromeet.piki.notification.fcm.domain.UserDevice +import com.depromeet.piki.notification.fcm.repository.UserDeviceRepository +import com.depromeet.piki.notification.repository.NotificationRepository +import com.depromeet.piki.notification.sse.SseEmitterRegistry +import com.depromeet.piki.support.IntegrationTestSupport +import com.depromeet.piki.support.StubFcmMessageSender +import com.depromeet.piki.support.uuidToBytes +import org.awaitility.Awaitility.await +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.jdbc.core.JdbcTemplate +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter +import java.sql.Timestamp +import java.time.Duration +import java.time.LocalDateTime +import java.util.UUID +import java.util.concurrent.CopyOnWriteArrayList +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +// N일 자동삭제 스케줄러의 배지 fan-out wiring 검증 — 자동삭제로 안읽음이 사라진 유저에게 SSE·FCM 배지가 실제로 +// 전파되고, 읽음만 지워진 유저에겐 전파되지 않는지 확인한다. 배지 동기화는 @Async 라 응답 경로 밖 워커에서 돌아 +// @Transactional 자동 롤백으로는 워커가 미커밋 데이터를 못 본다 — 실제 커밋하고 Awaitility 로 발송 완료를 기다린다 +// (NotificationBadgeSyncAsyncIntegrationTest 와 동일 결). 격리 userId 로 만든 행은 메서드 끝에서 jdbcTemplate 으로 정리한다. +class NotificationCleanupSchedulerIntegrationTest : IntegrationTestSupport() { + @Autowired private lateinit var scheduler: NotificationCleanupScheduler + + @Autowired private lateinit var registry: SseEmitterRegistry + + @Autowired private lateinit var userDeviceRepository: UserDeviceRepository + + @Autowired private lateinit var notificationRepository: NotificationRepository + + @Autowired private lateinit var stubFcmMessageSender: StubFcmMessageSender + + @Autowired private lateinit var notificationProperties: NotificationProperties + + @Autowired private lateinit var jdbcTemplate: JdbcTemplate + + private fun saveUnread(userId: UUID): Long = + notificationRepository + .save(Notification(userId, NotificationType.ITEM_PARSING_COMPLETED, "제목", "본문", 11L)) + .getId() + + // created_at 은 @CreatedDate·updatable=false 라 엔티티로 못 바꾼다. is_read 도 함께 native SQL 로 심는다. + private fun age( + id: Long, + at: LocalDateTime, + read: Boolean = false, + ) { + jdbcTemplate.update("UPDATE notifications SET created_at = ?, is_read = ? WHERE id = ?", Timestamp.valueOf(at), read, id) + } + + // @Transactional 없는 테스트라 @Modifying 삭제(트랜잭션 필요)는 못 쓴다 — jdbcTemplate 으로 직접 정리한다. + private fun cleanupRows(vararg userIds: UUID) { + userIds.forEach { + jdbcTemplate.update("DELETE FROM user_devices WHERE user_id = ?", uuidToBytes(it)) + jdbcTemplate.update("DELETE FROM notifications WHERE user_id = ?", uuidToBytes(it)) + } + } + + @Test + fun `자동삭제는 안읽음이 지워진 유저에게만 SSE·FCM 배지를 전파하고 읽음만 지워진 유저는 건드리지 않는다`() { + val userA = UUID.randomUUID() + val userB = UUID.randomUUID() + val tokenA = "tokenA-$userA" + val tokenB = "tokenB-$userB" + try { + // 보존기간(cutoff = now - retentionDays)보다 확실히 오래된 시각. + val old = LocalDateTime.now().minusDays(notificationProperties.retentionDays + 10) + + // userA: 오래된 안읽음(삭제 대상·배지 영향) + 최근 안읽음(남음 → 삭제 후 안읽음 1건). + val aOldUnread = saveUnread(userA) + saveUnread(userA) + age(aOldUnread, old, read = false) + + // userB: 오래된 읽음(삭제되지만 안읽음 불변이라 배지 동기화 대상 아님). + val bOldRead = saveUnread(userB) + age(bOldRead, old, read = true) + + userDeviceRepository.save(UserDevice(userId = userA, deviceId = "dA", fcmToken = tokenA)) + userDeviceRepository.save(UserDevice(userId = userB, deviceId = "dB", fcmToken = tokenB)) + + val emitterA = CleanupBadgeRecordingEmitter().also { registry.register(userA, it) } + val emitterB = CleanupBadgeRecordingEmitter().also { registry.register(userB, it) } + val fcmCalls = CopyOnWriteArrayList, Int>>() + stubFcmMessageSender.onSendBadgeSync = { tokens, badge -> + fcmCalls.add(tokens to badge) + emptyList() + } + + try { + scheduler.cleanup() + + await().atMost(Duration.ofSeconds(5)).untilAsserted { + // userA: 온라인(SSE) 기기에 갱신 안읽음 수(1) 전파. + assertEquals(1L, emitterA.payloads().singleOrNull()?.unreadCount) + // userA: 오프라인(FCM) 기기에 badge=1 전파. + assertEquals(1, fcmCalls.singleOrNull { it.first.contains(tokenA) }?.second) + // userB: 읽음만 지워져 배지 불변 → SSE·FCM 어느 쪽도 호출되지 않는다. + assertTrue(emitterB.payloads().isEmpty()) + assertTrue(fcmCalls.none { it.first.contains(tokenB) }) + } + } finally { + registry.unregister(userA, emitterA) + registry.unregister(userB, emitterB) + } + } finally { + cleanupRows(userA, userB) + } + } +} + +// send 를 가로채 실제 IO 없이 전송된 silent-sync payload 를 기록한다(NotificationBadgeSyncAsyncIntegrationTest 와 동일 패턴). +private class CleanupBadgeRecordingEmitter : SseEmitter() { + private val sentData = CopyOnWriteArrayList() + + override fun send(builder: SseEmitter.SseEventBuilder) { + builder.build().forEach { sentData.add(it.data) } + } + + fun payloads(): List = sentData.filterIsInstance() +} diff --git a/src/test/kotlin/com/depromeet/piki/notification/service/NotificationRetentionIntegrationTest.kt b/src/test/kotlin/com/depromeet/piki/notification/service/NotificationRetentionIntegrationTest.kt new file mode 100644 index 00000000..650e198b --- /dev/null +++ b/src/test/kotlin/com/depromeet/piki/notification/service/NotificationRetentionIntegrationTest.kt @@ -0,0 +1,117 @@ +package com.depromeet.piki.notification.service + +import com.depromeet.piki.notification.domain.Notification +import com.depromeet.piki.notification.domain.NotificationType +import com.depromeet.piki.notification.repository.NotificationJpaRepository +import com.depromeet.piki.notification.repository.NotificationRepository +import com.depromeet.piki.support.IntegrationTestSupport +import jakarta.persistence.EntityManager +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.transaction.annotation.Transactional +import java.time.LocalDateTime +import java.util.UUID +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +// N일 자동삭제의 cutoff 경계 검증. 스케줄러(@Scheduled)가 아니라 순수 로직(purgeOlderThan)을 cutoff 을 직접 넘겨 결정적으로 검증한다. +// 전역 DELETE 라 다른 테스트가 커밋한 행까지 지울 수 있으나, 클래스 레벨 @Transactional 자동 롤백이 그 행을 복원하므로 안전하다. +// 삭제 여부는 findById(PK)가 아니라 존재 조회(전역 DELETE 의 clearAutomatically 로 컨텍스트가 비워져 DB 를 친다)로 확인한다. +@Transactional +class NotificationRetentionIntegrationTest : IntegrationTestSupport() { + @Autowired private lateinit var notificationRetentionService: NotificationRetentionService + + @Autowired private lateinit var notificationRepository: NotificationRepository + + @Autowired private lateinit var notificationJpaRepository: NotificationJpaRepository + + @Autowired private lateinit var entityManager: EntityManager + + private fun seed(userId: UUID): Long = + notificationRepository + .save(Notification(userId, NotificationType.ITEM_PARSING_COMPLETED, "제목", "본문", 11L)) + .getId() + + // created_at 은 @CreatedDate·updatable=false 라 엔티티로는 못 바꾼다. 경계 테스트를 위해 native SQL 로 정확한 시각을 박는다. + private fun setCreatedAt( + id: Long, + at: LocalDateTime, + ) { + entityManager.flush() // seed insert 를 DB 에 반영한 뒤 UPDATE 가 그 행을 친다 + entityManager + .createNativeQuery("UPDATE notifications SET created_at = :at WHERE id = :id") + .setParameter("at", at) + .setParameter("id", id) + .executeUpdate() + } + + private fun exists(id: Long): Boolean = notificationJpaRepository.findById(id).isPresent + + @Test + fun `cutoff 이후에 생성된 알림은 유저 무관 하드삭제된다`() { + val userA = UUID.randomUUID() + val userB = UUID.randomUUID() + val a1 = seed(userA) + val a2 = seed(userA) + val b1 = seed(userB) + + // 방금 생성돼 created_at ≈ now < cutoff(now+1분) → 세 건 모두 대상. + val cutoff = LocalDateTime.now().plusMinutes(1) + notificationRetentionService.purgeOlderThan(cutoff) + + assertFalse(exists(a1)) + assertFalse(exists(a2)) + assertFalse(exists(b1)) // age 기준이라 유저 무관 전역 삭제 + } + + @Test + fun `삭제로 안읽음이 사라진 유저만 배지 재계산 대상에 담고 읽음만 지워진 유저는 제외한다`() { + val userA = UUID.randomUUID() + val userB = UUID.randomUUID() + val unreadOldA = seed(userA) // 안읽음·오래됨 → 삭제 대상, userA 배지 영향 + val unreadRecentA = seed(userA) // 안읽음·최근 → 남아 userA 의 삭제 후 안읽음 1건 + val readOldB = seed(userB) // 오래됨이나 읽음 → 삭제돼도 배지 불변이라 userB 는 대상 아님 + notificationRepository.markRead(userB, listOf(readOldB)) + val old = LocalDateTime.of(2020, 1, 1, 0, 0, 0) + setCreatedAt(unreadOldA, old) + setCreatedAt(readOldB, old) + + val cutoff = LocalDateTime.of(2021, 1, 1, 0, 0, 0) // old < cutoff < now(최근 건) + val result = notificationRetentionService.purgeOlderThan(cutoff) + + assertFalse(exists(unreadOldA)) + assertFalse(exists(readOldB)) // 읽음이어도 age 기준이라 삭제는 됨 + assertTrue(exists(unreadRecentA)) + // 배지 동기화 대상은 안읽음이 지워진 userA 뿐 — 읽음만 지워진 userB 는 배지 불변이라 제외. + assertEquals(setOf(userA), result.affectedUnreadByUser.keys) + assertEquals(1L, result.affectedUnreadByUser.getValue(userA).values.sum()) // 삭제 후 남은 안읽음(최근 1건) + } + + @Test + fun `cutoff 이전 알림은 남긴다 (경계 - created_at 이 cutoff 미만일 때만 삭제)`() { + val userId = UUID.randomUUID() + val recent = seed(userId) + + // created_at ≈ now 는 cutoff(now-1일) 미만이 아니므로 삭제되지 않는다(< 경계). + val cutoff = LocalDateTime.now().minusDays(1) + val result = notificationRetentionService.purgeOlderThan(cutoff) + + assertEquals(0, result.deletedCount) // 갓 만든 컨테이너라 1일 넘은 행이 없다 + assertTrue(exists(recent)) + } + + @Test + fun `created_at 이 정확히 cutoff 인 알림은 삭제되지 않는다 (엄격한 미만 경계)`() { + val userId = UUID.randomUUID() + val boundary = seed(userId) + // created_at 을 cutoff 과 정확히 같은 시각으로 고정 — 쿼리가 < 가 아니라 <= 로 바뀌면 이 행이 지워져 실패한다. + val cutoff = LocalDateTime.of(2020, 1, 1, 0, 0, 0) + setCreatedAt(boundary, cutoff) + + val result = notificationRetentionService.purgeOlderThan(cutoff) + + assertEquals(0, result.deletedCount) // created_at == cutoff 는 미만이 아니라 삭제 대상이 아니다 + assertTrue(exists(boundary)) + } +}