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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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,
Comment thread
coderabbitai[bot] marked this conversation as resolved.
) {
init {
// 불변식 — 잘못 구성한 env(0·음수)면 cutoff(now-retentionDays)가 현재/미래가 되어 자동삭제가 알림을 대량 삭제한다.
// ops 오설정이라 클라가 닿는 계약이 아니므로 커스텀 예외가 아니라 require 로 부팅 시점에 실패시킨다.
require(retentionDays > 0) { "notification.retention-days 는 양수여야 한다 (현재=$retentionDays)" }
}
}
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -148,4 +150,58 @@ interface NotificationHistoryApi {
@Parameter(hidden = true) userId: UUID,
request: NotificationReadRequest,
): ApiResponseBody<NotificationReadResponse>

@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<NotificationDeleteResponse>
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<Unit>(
category = ErrorCategory.INVALID_INPUT,
// @AssertTrue 위반은 GlobalExceptionHandler.detailOf 가 위반 필드의 메시지를 그대로 detail 로 내린다.
detail = NotificationDeleteRequest.VALID_SELECTION_MESSAGE,
),
)
unauthorized()
}
}
operation
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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(
Expand All @@ -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<NotificationDeleteResponse> {
val unreadByCategory = notificationDeleteOrchestrator.deleteAndSyncBadge(userId = userId, command = request.toCommand())
return ApiResponseBody.ok(data = NotificationDeleteResponse.of(unreadByCategory))
}
}
Original file line number Diff line number Diff line change
@@ -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<Long>? = null,
Comment thread
sevineleven marked this conversation as resolved.
) {
// 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 = "요청을 처리하지 못했어요."
}
}
Original file line number Diff line number Diff line change
@@ -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<NotificationCategory, Long>,
) {
companion object {
// total 은 카테고리 합으로 도출 — toUnreadTotal 단일 소스를 거쳐 전체·탭별 두 수치가 어긋날 여지를 없앤다(read 와 동일 규칙).
fun of(unreadCountByCategory: Map<NotificationCategory, Long>): NotificationDeleteResponse =
NotificationDeleteResponse(
unreadCount = unreadCountByCategory.toUnreadTotal(),
unreadCountByCategory = unreadCountByCategory,
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<Notification, Long> {
// 탈퇴 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,
Expand Down Expand Up @@ -61,6 +64,23 @@ interface NotificationJpaRepository : JpaRepository<Notification, Long> {
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<UUID>,
): List<UserTypeUnreadCount>

// 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 한다.
Expand All @@ -77,4 +97,30 @@ interface NotificationJpaRepository : JpaRepository<Notification, Long> {
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<Long>,
): 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<UUID>

// 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
}
Loading
Loading