From 136501b1315d5bfd9abaab2c22309141d07c1eb4 Mon Sep 17 00:00:00 2001 From: minju Date: Tue, 26 May 2026 21:24:54 +0900 Subject: [PATCH 1/8] =?UTF-8?q?feat:=20=EC=95=8C=EB=A6=BC=20API=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/NotificationController.java | 124 ++++++++ .../dto/request/NotificationReadRequest.java | 9 + .../dto/request/PushTokenDeleteRequest.java | 6 + .../dto/request/PushTokenRegisterRequest.java | 12 + .../response/NotificationListResponse.java | 6 + .../response/NotificationReadResponse.java | 3 + .../dto/response/NotificationResponse.java | 15 + .../NotificationUnreadCountResponse.java | 3 + .../dto/response/PushTokenResponse.java | 12 + .../notification/entity/Notification.java | 98 ++++++ .../entity/NotificationDeliveryLog.java | 91 ++++++ .../notification/entity/PushDeviceToken.java | 113 +++++++ .../enums/NotificationDeliveryStatus.java | 9 + .../entity/enums/NotificationType.java | 10 + .../entity/enums/PushPlatform.java | 8 + .../NotificationDeliveryLogRepository.java | 7 + .../repository/NotificationRepository.java | 48 +++ .../repository/PushDeviceTokenRepository.java | 14 + .../service/NotificationCreateCommand.java | 12 + .../NotificationDeliveryResultCommand.java | 11 + .../service/NotificationService.java | 281 ++++++++++++++++++ .../com/gachi/be/global/code/ErrorCode.java | 7 + .../com/gachi/be/global/code/SuccessCode.java | 7 +- .../db/migration/V14__notification_api.sql | 79 +++++ ...NotificationControllerIntegrationTest.java | 247 +++++++++++++++ 25 files changed, 1231 insertions(+), 1 deletion(-) create mode 100644 src/main/java/com/gachi/be/domain/notification/api/controller/NotificationController.java create mode 100644 src/main/java/com/gachi/be/domain/notification/dto/request/NotificationReadRequest.java create mode 100644 src/main/java/com/gachi/be/domain/notification/dto/request/PushTokenDeleteRequest.java create mode 100644 src/main/java/com/gachi/be/domain/notification/dto/request/PushTokenRegisterRequest.java create mode 100644 src/main/java/com/gachi/be/domain/notification/dto/response/NotificationListResponse.java create mode 100644 src/main/java/com/gachi/be/domain/notification/dto/response/NotificationReadResponse.java create mode 100644 src/main/java/com/gachi/be/domain/notification/dto/response/NotificationResponse.java create mode 100644 src/main/java/com/gachi/be/domain/notification/dto/response/NotificationUnreadCountResponse.java create mode 100644 src/main/java/com/gachi/be/domain/notification/dto/response/PushTokenResponse.java create mode 100644 src/main/java/com/gachi/be/domain/notification/entity/Notification.java create mode 100644 src/main/java/com/gachi/be/domain/notification/entity/NotificationDeliveryLog.java create mode 100644 src/main/java/com/gachi/be/domain/notification/entity/PushDeviceToken.java create mode 100644 src/main/java/com/gachi/be/domain/notification/entity/enums/NotificationDeliveryStatus.java create mode 100644 src/main/java/com/gachi/be/domain/notification/entity/enums/NotificationType.java create mode 100644 src/main/java/com/gachi/be/domain/notification/entity/enums/PushPlatform.java create mode 100644 src/main/java/com/gachi/be/domain/notification/repository/NotificationDeliveryLogRepository.java create mode 100644 src/main/java/com/gachi/be/domain/notification/repository/NotificationRepository.java create mode 100644 src/main/java/com/gachi/be/domain/notification/repository/PushDeviceTokenRepository.java create mode 100644 src/main/java/com/gachi/be/domain/notification/service/NotificationCreateCommand.java create mode 100644 src/main/java/com/gachi/be/domain/notification/service/NotificationDeliveryResultCommand.java create mode 100644 src/main/java/com/gachi/be/domain/notification/service/NotificationService.java create mode 100644 src/main/resources/db/migration/V14__notification_api.sql create mode 100644 src/test/java/com/gachi/be/domain/notification/api/controller/NotificationControllerIntegrationTest.java diff --git a/src/main/java/com/gachi/be/domain/notification/api/controller/NotificationController.java b/src/main/java/com/gachi/be/domain/notification/api/controller/NotificationController.java new file mode 100644 index 0000000..407155d --- /dev/null +++ b/src/main/java/com/gachi/be/domain/notification/api/controller/NotificationController.java @@ -0,0 +1,124 @@ +package com.gachi.be.domain.notification.api.controller; + +import com.gachi.be.domain.notification.dto.request.NotificationReadRequest; +import com.gachi.be.domain.notification.dto.request.PushTokenDeleteRequest; +import com.gachi.be.domain.notification.dto.request.PushTokenRegisterRequest; +import com.gachi.be.domain.notification.dto.response.NotificationListResponse; +import com.gachi.be.domain.notification.dto.response.NotificationReadResponse; +import com.gachi.be.domain.notification.dto.response.NotificationUnreadCountResponse; +import com.gachi.be.domain.notification.dto.response.PushTokenResponse; +import com.gachi.be.domain.notification.service.NotificationService; +import com.gachi.be.global.api.ApiResponse; +import com.gachi.be.global.code.SuccessCode; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@Tag(name = "Notification", description = "알림 보관함 및 푸시 토큰 API") +@SecurityRequirement(name = "bearerAuth") +@RestController +@RequiredArgsConstructor +@Validated +@RequestMapping("/api/v1/notifications") +public class NotificationController { + + private final NotificationService notificationService; + + /** 푸시 수신 누락을 복구하기 위해 서버에 저장된 알림 보관함을 최신순으로 조회한다. */ + @Operation( + summary = "알림 목록 조회", + description = + """ + React Native 앱이 푸시를 받지 못한 경우에도 이 API로 서버 보관함을 동기화할 수 있습니다. + cursor는 이전 응답의 nextCursor를 그대로 전달하고, unreadOnly=true면 미읽음 알림만 조회합니다. + """) + @GetMapping + public ApiResponse getNotifications( + @AuthenticationPrincipal Long userId, + @Parameter(description = "다음 페이지 조회용 커서. 이전 응답의 nextCursor") @RequestParam(required = false) + Long cursor, + @Parameter(description = "조회 크기. 기본 20, 최대 100") + @RequestParam(required = false) + @Min(1) + @Max(100) + Integer size, + @Parameter(description = "미읽음 알림만 조회할지 여부") @RequestParam(defaultValue = "false") + boolean unreadOnly) { + return ApiResponse.success( + SuccessCode.NOTIFICATION_LIST_SUCCESS, + notificationService.getNotifications(userId, cursor, size, unreadOnly)); + } + + @Operation(summary = "미읽음 알림 수 조회", description = "사용자 기준 미읽음 알림 개수를 반환합니다.") + @GetMapping("/unread-count") + public ApiResponse getUnreadCount( + @AuthenticationPrincipal Long userId) { + return ApiResponse.success( + SuccessCode.NOTIFICATION_UNREAD_COUNT_SUCCESS, notificationService.getUnreadCount(userId)); + } + + @Operation(summary = "단건 읽음 처리", description = "알림 상세 진입 또는 알림 탭 노출 후 단건 읽음 처리에 사용합니다.") + @PatchMapping("/{notificationId}/read") + public ApiResponse markRead( + @AuthenticationPrincipal Long userId, @PathVariable Long notificationId) { + return ApiResponse.success( + SuccessCode.NOTIFICATION_READ_SUCCESS, + notificationService.markRead(userId, notificationId)); + } + + @Operation(summary = "일괄 읽음 처리", description = "앱이 서버 보관함을 동기화한 뒤 여러 알림을 한 번에 읽음 처리합니다.") + @PatchMapping("/read") + public ApiResponse markRead( + @AuthenticationPrincipal Long userId, @Valid @RequestBody NotificationReadRequest request) { + return ApiResponse.success( + SuccessCode.NOTIFICATION_READ_SUCCESS, notificationService.markRead(userId, request)); + } + + @Operation(summary = "전체 읽음 처리", description = "현재 사용자의 미읽음 알림을 모두 읽음 처리합니다.") + @PatchMapping("/read-all") + public ApiResponse markAllRead(@AuthenticationPrincipal Long userId) { + return ApiResponse.success( + SuccessCode.NOTIFICATION_READ_SUCCESS, notificationService.markAllRead(userId)); + } + + @Operation( + summary = "푸시 토큰 등록/갱신", + description = + """ + RN 앱 시작, 로그인 직후, 토큰 refresh 이벤트에서 호출합니다. + 같은 토큰이 재등록되면 기존 레코드를 활성화하고 플랫폼/디바이스 정보를 갱신합니다. + """) + @PostMapping("/tokens") + public ApiResponse registerPushToken( + @AuthenticationPrincipal Long userId, @Valid @RequestBody PushTokenRegisterRequest request) { + return ApiResponse.success( + SuccessCode.NOTIFICATION_PUSH_TOKEN_REGISTERED, + notificationService.registerPushToken(userId, request)); + } + + @Operation( + summary = "푸시 토큰 삭제", + description = "로그아웃, 권한 철회, 앱 삭제 전 토큰 정리 시 호출합니다. 이미 삭제된 토큰도 성공으로 처리합니다.") + @DeleteMapping("/tokens") + public ApiResponse deletePushToken( + @AuthenticationPrincipal Long userId, @Valid @RequestBody PushTokenDeleteRequest request) { + notificationService.deletePushToken(userId, request); + return ApiResponse.success(SuccessCode.NOTIFICATION_PUSH_TOKEN_DELETED, null); + } +} diff --git a/src/main/java/com/gachi/be/domain/notification/dto/request/NotificationReadRequest.java b/src/main/java/com/gachi/be/domain/notification/dto/request/NotificationReadRequest.java new file mode 100644 index 0000000..e60d2fe --- /dev/null +++ b/src/main/java/com/gachi/be/domain/notification/dto/request/NotificationReadRequest.java @@ -0,0 +1,9 @@ +package com.gachi.be.domain.notification.dto.request; + +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import java.util.List; + +public record NotificationReadRequest( + @NotEmpty @Size(max = 100) List<@NotNull Long> notificationIds) {} diff --git a/src/main/java/com/gachi/be/domain/notification/dto/request/PushTokenDeleteRequest.java b/src/main/java/com/gachi/be/domain/notification/dto/request/PushTokenDeleteRequest.java new file mode 100644 index 0000000..a34a00a --- /dev/null +++ b/src/main/java/com/gachi/be/domain/notification/dto/request/PushTokenDeleteRequest.java @@ -0,0 +1,6 @@ +package com.gachi.be.domain.notification.dto.request; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +public record PushTokenDeleteRequest(@NotBlank @Size(max = 512) String token) {} diff --git a/src/main/java/com/gachi/be/domain/notification/dto/request/PushTokenRegisterRequest.java b/src/main/java/com/gachi/be/domain/notification/dto/request/PushTokenRegisterRequest.java new file mode 100644 index 0000000..4c38911 --- /dev/null +++ b/src/main/java/com/gachi/be/domain/notification/dto/request/PushTokenRegisterRequest.java @@ -0,0 +1,12 @@ +package com.gachi.be.domain.notification.dto.request; + +import com.gachi.be.domain.notification.entity.enums.PushPlatform; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; + +public record PushTokenRegisterRequest( + @NotNull PushPlatform platform, + @NotBlank @Size(max = 512) String token, + @Size(max = 128) String deviceId, + @Size(max = 50) String appVersion) {} diff --git a/src/main/java/com/gachi/be/domain/notification/dto/response/NotificationListResponse.java b/src/main/java/com/gachi/be/domain/notification/dto/response/NotificationListResponse.java new file mode 100644 index 0000000..5b7f333 --- /dev/null +++ b/src/main/java/com/gachi/be/domain/notification/dto/response/NotificationListResponse.java @@ -0,0 +1,6 @@ +package com.gachi.be.domain.notification.dto.response; + +import java.util.List; + +public record NotificationListResponse( + List notifications, Long nextCursor, boolean hasNext) {} diff --git a/src/main/java/com/gachi/be/domain/notification/dto/response/NotificationReadResponse.java b/src/main/java/com/gachi/be/domain/notification/dto/response/NotificationReadResponse.java new file mode 100644 index 0000000..5e78462 --- /dev/null +++ b/src/main/java/com/gachi/be/domain/notification/dto/response/NotificationReadResponse.java @@ -0,0 +1,3 @@ +package com.gachi.be.domain.notification.dto.response; + +public record NotificationReadResponse(int readCount) {} diff --git a/src/main/java/com/gachi/be/domain/notification/dto/response/NotificationResponse.java b/src/main/java/com/gachi/be/domain/notification/dto/response/NotificationResponse.java new file mode 100644 index 0000000..1e3e7ed --- /dev/null +++ b/src/main/java/com/gachi/be/domain/notification/dto/response/NotificationResponse.java @@ -0,0 +1,15 @@ +package com.gachi.be.domain.notification.dto.response; + +import com.gachi.be.domain.notification.entity.enums.NotificationType; +import java.time.OffsetDateTime; +import java.util.Map; + +public record NotificationResponse( + Long id, + NotificationType type, + String title, + String body, + Map payload, + boolean read, + OffsetDateTime readAt, + OffsetDateTime createdAt) {} diff --git a/src/main/java/com/gachi/be/domain/notification/dto/response/NotificationUnreadCountResponse.java b/src/main/java/com/gachi/be/domain/notification/dto/response/NotificationUnreadCountResponse.java new file mode 100644 index 0000000..e978013 --- /dev/null +++ b/src/main/java/com/gachi/be/domain/notification/dto/response/NotificationUnreadCountResponse.java @@ -0,0 +1,3 @@ +package com.gachi.be.domain.notification.dto.response; + +public record NotificationUnreadCountResponse(long unreadCount) {} diff --git a/src/main/java/com/gachi/be/domain/notification/dto/response/PushTokenResponse.java b/src/main/java/com/gachi/be/domain/notification/dto/response/PushTokenResponse.java new file mode 100644 index 0000000..457871f --- /dev/null +++ b/src/main/java/com/gachi/be/domain/notification/dto/response/PushTokenResponse.java @@ -0,0 +1,12 @@ +package com.gachi.be.domain.notification.dto.response; + +import com.gachi.be.domain.notification.entity.enums.PushPlatform; +import java.time.OffsetDateTime; + +public record PushTokenResponse( + Long id, + PushPlatform platform, + String deviceId, + String appVersion, + boolean enabled, + OffsetDateTime lastRegisteredAt) {} diff --git a/src/main/java/com/gachi/be/domain/notification/entity/Notification.java b/src/main/java/com/gachi/be/domain/notification/entity/Notification.java new file mode 100644 index 0000000..d1c38b7 --- /dev/null +++ b/src/main/java/com/gachi/be/domain/notification/entity/Notification.java @@ -0,0 +1,98 @@ +package com.gachi.be.domain.notification.entity; + +import com.gachi.be.domain.notification.entity.enums.NotificationType; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.PrePersist; +import jakarta.persistence.PreUpdate; +import jakarta.persistence.Table; +import java.time.OffsetDateTime; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +/** 푸시 수신 실패에도 앱이 다시 조회할 수 있는 사용자별 알림 보관함 엔티티. */ +@Getter +@Entity +@Table(name = "notifications") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Notification { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "user_id", nullable = false) + private Long userId; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 40) + private NotificationType type; + + @Column(nullable = false, length = 120) + private String title; + + @Column(nullable = false, columnDefinition = "TEXT") + private String body; + + @Column(name = "payload_json", columnDefinition = "TEXT") + private String payloadJson; + + @Column(name = "dedupe_key", length = 255) + private String dedupeKey; + + @Column(name = "read_at") + private OffsetDateTime readAt; + + @Column(name = "created_at", nullable = false, updatable = false) + private OffsetDateTime createdAt; + + @Column(name = "updated_at", nullable = false) + private OffsetDateTime updatedAt; + + @Builder + public Notification( + Long userId, + NotificationType type, + String title, + String body, + String payloadJson, + String dedupeKey) { + this.userId = userId; + this.type = type; + this.title = title; + this.body = body; + this.payloadJson = payloadJson; + this.dedupeKey = dedupeKey; + } + + public boolean isRead() { + return readAt != null; + } + + public void markRead() { + if (readAt == null) { + readAt = OffsetDateTime.now(); + } + } + + @PrePersist + protected void onCreate() { + OffsetDateTime now = OffsetDateTime.now(); + if (createdAt == null) { + createdAt = now; + } + updatedAt = now; + } + + @PreUpdate + protected void onUpdate() { + updatedAt = OffsetDateTime.now(); + } +} diff --git a/src/main/java/com/gachi/be/domain/notification/entity/NotificationDeliveryLog.java b/src/main/java/com/gachi/be/domain/notification/entity/NotificationDeliveryLog.java new file mode 100644 index 0000000..a49e919 --- /dev/null +++ b/src/main/java/com/gachi/be/domain/notification/entity/NotificationDeliveryLog.java @@ -0,0 +1,91 @@ +package com.gachi.be.domain.notification.entity; + +import com.gachi.be.domain.notification.entity.enums.NotificationDeliveryStatus; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.PrePersist; +import jakarta.persistence.PreUpdate; +import jakarta.persistence.Table; +import java.time.OffsetDateTime; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +/** 푸시 발송 시도별 성공/실패 원인을 남기는 추적 엔티티. */ +@Getter +@Entity +@Table(name = "notification_delivery_logs") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class NotificationDeliveryLog { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "notification_id", nullable = false) + private Notification notification; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "push_device_token_id") + private PushDeviceToken pushDeviceToken; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 20) + private NotificationDeliveryStatus status; + + @Column(name = "provider_message_id", length = 255) + private String providerMessageId; + + @Column(name = "failure_reason", columnDefinition = "TEXT") + private String failureReason; + + @Column(name = "attempted_at", nullable = false) + private OffsetDateTime attemptedAt; + + @Column(name = "created_at", nullable = false, updatable = false) + private OffsetDateTime createdAt; + + @Column(name = "updated_at", nullable = false) + private OffsetDateTime updatedAt; + + @Builder + public NotificationDeliveryLog( + Notification notification, + PushDeviceToken pushDeviceToken, + NotificationDeliveryStatus status, + String providerMessageId, + String failureReason) { + this.notification = notification; + this.pushDeviceToken = pushDeviceToken; + this.status = status; + this.providerMessageId = providerMessageId; + this.failureReason = failureReason; + } + + @PrePersist + protected void onCreate() { + OffsetDateTime now = OffsetDateTime.now(); + if (createdAt == null) { + createdAt = now; + } + if (attemptedAt == null) { + attemptedAt = now; + } + updatedAt = now; + } + + @PreUpdate + protected void onUpdate() { + updatedAt = OffsetDateTime.now(); + } +} diff --git a/src/main/java/com/gachi/be/domain/notification/entity/PushDeviceToken.java b/src/main/java/com/gachi/be/domain/notification/entity/PushDeviceToken.java new file mode 100644 index 0000000..acca3f0 --- /dev/null +++ b/src/main/java/com/gachi/be/domain/notification/entity/PushDeviceToken.java @@ -0,0 +1,113 @@ +package com.gachi.be.domain.notification.entity; + +import com.gachi.be.domain.notification.entity.enums.PushPlatform; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.PrePersist; +import jakarta.persistence.PreUpdate; +import jakarta.persistence.Table; +import java.time.OffsetDateTime; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +/** 사용자의 React Native 앱 푸시 토큰을 저장하고 재등록을 흡수하는 엔티티. */ +@Getter +@Entity +@Table(name = "push_device_tokens") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class PushDeviceToken { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "user_id", nullable = false) + private Long userId; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 20) + private PushPlatform platform; + + @Column(nullable = false, length = 512) + private String token; + + @Column(name = "token_hash", nullable = false, length = 64) + private String tokenHash; + + @Column(name = "device_id", length = 128) + private String deviceId; + + @Column(name = "app_version", length = 50) + private String appVersion; + + @Column(nullable = false) + private boolean enabled; + + @Column(name = "last_registered_at", nullable = false) + private OffsetDateTime lastRegisteredAt; + + @Column(name = "deleted_at") + private OffsetDateTime deletedAt; + + @Column(name = "created_at", nullable = false, updatable = false) + private OffsetDateTime createdAt; + + @Column(name = "updated_at", nullable = false) + private OffsetDateTime updatedAt; + + @Builder + public PushDeviceToken( + Long userId, + PushPlatform platform, + String token, + String tokenHash, + String deviceId, + String appVersion) { + this.userId = userId; + this.platform = platform; + this.token = token; + this.tokenHash = tokenHash; + this.deviceId = deviceId; + this.appVersion = appVersion; + this.enabled = true; + } + + public void refresh(PushPlatform platform, String token, String deviceId, String appVersion) { + this.platform = platform; + this.token = token; + this.deviceId = deviceId; + this.appVersion = appVersion; + this.enabled = true; + this.deletedAt = null; + this.lastRegisteredAt = OffsetDateTime.now(); + } + + public void softDelete() { + this.enabled = false; + this.deletedAt = OffsetDateTime.now(); + } + + @PrePersist + protected void onCreate() { + OffsetDateTime now = OffsetDateTime.now(); + if (createdAt == null) { + createdAt = now; + } + if (lastRegisteredAt == null) { + lastRegisteredAt = now; + } + updatedAt = now; + } + + @PreUpdate + protected void onUpdate() { + updatedAt = OffsetDateTime.now(); + } +} diff --git a/src/main/java/com/gachi/be/domain/notification/entity/enums/NotificationDeliveryStatus.java b/src/main/java/com/gachi/be/domain/notification/entity/enums/NotificationDeliveryStatus.java new file mode 100644 index 0000000..85b6c1f --- /dev/null +++ b/src/main/java/com/gachi/be/domain/notification/entity/enums/NotificationDeliveryStatus.java @@ -0,0 +1,9 @@ +package com.gachi.be.domain.notification.entity.enums; + +/** 푸시 발송 시도 결과를 추적하기 위한 상태값. */ +public enum NotificationDeliveryStatus { + PENDING, + SENT, + FAILED, + SKIPPED +} diff --git a/src/main/java/com/gachi/be/domain/notification/entity/enums/NotificationType.java b/src/main/java/com/gachi/be/domain/notification/entity/enums/NotificationType.java new file mode 100644 index 0000000..ec48977 --- /dev/null +++ b/src/main/java/com/gachi/be/domain/notification/entity/enums/NotificationType.java @@ -0,0 +1,10 @@ +package com.gachi.be.domain.notification.entity.enums; + +/** 앱 알림이 어떤 기능에서 만들어졌는지 구분한다. */ +public enum NotificationType { + NEWSLETTER_ANALYSIS, + CALENDAR_EVENT, + CHECKLIST_DUE, + SYSTEM, + ANNOUNCEMENT +} diff --git a/src/main/java/com/gachi/be/domain/notification/entity/enums/PushPlatform.java b/src/main/java/com/gachi/be/domain/notification/entity/enums/PushPlatform.java new file mode 100644 index 0000000..265dbda --- /dev/null +++ b/src/main/java/com/gachi/be/domain/notification/entity/enums/PushPlatform.java @@ -0,0 +1,8 @@ +package com.gachi.be.domain.notification.entity.enums; + +/** React Native 앱에서 등록할 수 있는 푸시 토큰 제공자/플랫폼. */ +public enum PushPlatform { + IOS, + ANDROID, + EXPO +} diff --git a/src/main/java/com/gachi/be/domain/notification/repository/NotificationDeliveryLogRepository.java b/src/main/java/com/gachi/be/domain/notification/repository/NotificationDeliveryLogRepository.java new file mode 100644 index 0000000..f47f985 --- /dev/null +++ b/src/main/java/com/gachi/be/domain/notification/repository/NotificationDeliveryLogRepository.java @@ -0,0 +1,7 @@ +package com.gachi.be.domain.notification.repository; + +import com.gachi.be.domain.notification.entity.NotificationDeliveryLog; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface NotificationDeliveryLogRepository + extends JpaRepository {} diff --git a/src/main/java/com/gachi/be/domain/notification/repository/NotificationRepository.java b/src/main/java/com/gachi/be/domain/notification/repository/NotificationRepository.java new file mode 100644 index 0000000..97fa10f --- /dev/null +++ b/src/main/java/com/gachi/be/domain/notification/repository/NotificationRepository.java @@ -0,0 +1,48 @@ +package com.gachi.be.domain.notification.repository; + +import com.gachi.be.domain.notification.entity.Notification; +import java.time.OffsetDateTime; +import java.util.Collection; +import java.util.List; +import java.util.Optional; +import org.springframework.data.domain.Pageable; +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; + +public interface NotificationRepository extends JpaRepository { + + @Query( + """ + SELECT n FROM Notification n + WHERE n.userId = :userId + AND (:cursorId IS NULL OR n.id < :cursorId) + AND (:unreadOnly = false OR n.readAt IS NULL) + ORDER BY n.id DESC + """) + List findInbox( + @Param("userId") Long userId, + @Param("cursorId") Long cursorId, + @Param("unreadOnly") boolean unreadOnly, + Pageable pageable); + + Optional findByIdAndUserId(Long id, Long userId); + + Optional findByUserIdAndDedupeKey(Long userId, String dedupeKey); + + List findAllByUserIdAndIdIn(Long userId, Collection ids); + + long countByUserIdAndReadAtIsNull(Long userId); + + @Modifying(clearAutomatically = true, flushAutomatically = true) + @Query( + """ + UPDATE Notification n + SET n.readAt = :readAt, + n.updatedAt = :readAt + WHERE n.userId = :userId + AND n.readAt IS NULL + """) + int markAllReadByUserId(@Param("userId") Long userId, @Param("readAt") OffsetDateTime readAt); +} diff --git a/src/main/java/com/gachi/be/domain/notification/repository/PushDeviceTokenRepository.java b/src/main/java/com/gachi/be/domain/notification/repository/PushDeviceTokenRepository.java new file mode 100644 index 0000000..29c371b --- /dev/null +++ b/src/main/java/com/gachi/be/domain/notification/repository/PushDeviceTokenRepository.java @@ -0,0 +1,14 @@ +package com.gachi.be.domain.notification.repository; + +import com.gachi.be.domain.notification.entity.PushDeviceToken; +import java.util.List; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface PushDeviceTokenRepository extends JpaRepository { + Optional findByUserIdAndTokenHash(Long userId, String tokenHash); + + Optional findByIdAndUserId(Long id, Long userId); + + List findAllByUserIdAndEnabledTrueAndDeletedAtIsNull(Long userId); +} diff --git a/src/main/java/com/gachi/be/domain/notification/service/NotificationCreateCommand.java b/src/main/java/com/gachi/be/domain/notification/service/NotificationCreateCommand.java new file mode 100644 index 0000000..bb709d1 --- /dev/null +++ b/src/main/java/com/gachi/be/domain/notification/service/NotificationCreateCommand.java @@ -0,0 +1,12 @@ +package com.gachi.be.domain.notification.service; + +import com.gachi.be.domain.notification.entity.enums.NotificationType; +import java.util.Map; + +/** 다른 도메인에서 사용자 알림을 만들 때 넘기는 최소 입력값. */ +public record NotificationCreateCommand( + NotificationType type, + String title, + String body, + Map payload, + String dedupeKey) {} diff --git a/src/main/java/com/gachi/be/domain/notification/service/NotificationDeliveryResultCommand.java b/src/main/java/com/gachi/be/domain/notification/service/NotificationDeliveryResultCommand.java new file mode 100644 index 0000000..13d53d1 --- /dev/null +++ b/src/main/java/com/gachi/be/domain/notification/service/NotificationDeliveryResultCommand.java @@ -0,0 +1,11 @@ +package com.gachi.be.domain.notification.service; + +import com.gachi.be.domain.notification.entity.enums.NotificationDeliveryStatus; + +/** 외부 푸시 제공자 발송 결과를 저장할 때 사용하는 입력값. */ +public record NotificationDeliveryResultCommand( + Long notificationId, + Long pushDeviceTokenId, + NotificationDeliveryStatus status, + String providerMessageId, + String failureReason) {} diff --git a/src/main/java/com/gachi/be/domain/notification/service/NotificationService.java b/src/main/java/com/gachi/be/domain/notification/service/NotificationService.java new file mode 100644 index 0000000..5c413aa --- /dev/null +++ b/src/main/java/com/gachi/be/domain/notification/service/NotificationService.java @@ -0,0 +1,281 @@ +package com.gachi.be.domain.notification.service; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.gachi.be.domain.notification.dto.request.NotificationReadRequest; +import com.gachi.be.domain.notification.dto.request.PushTokenDeleteRequest; +import com.gachi.be.domain.notification.dto.request.PushTokenRegisterRequest; +import com.gachi.be.domain.notification.dto.response.NotificationListResponse; +import com.gachi.be.domain.notification.dto.response.NotificationReadResponse; +import com.gachi.be.domain.notification.dto.response.NotificationResponse; +import com.gachi.be.domain.notification.dto.response.NotificationUnreadCountResponse; +import com.gachi.be.domain.notification.dto.response.PushTokenResponse; +import com.gachi.be.domain.notification.entity.Notification; +import com.gachi.be.domain.notification.entity.NotificationDeliveryLog; +import com.gachi.be.domain.notification.entity.PushDeviceToken; +import com.gachi.be.domain.notification.entity.enums.NotificationDeliveryStatus; +import com.gachi.be.domain.notification.repository.NotificationDeliveryLogRepository; +import com.gachi.be.domain.notification.repository.NotificationRepository; +import com.gachi.be.domain.notification.repository.PushDeviceTokenRepository; +import com.gachi.be.global.code.ErrorCode; +import com.gachi.be.global.exception.BusinessException; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.time.OffsetDateTime; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.data.domain.PageRequest; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; + +/** 알림 보관함, 읽음 상태, 푸시 토큰 생명주기를 관리한다. */ +@Service +@RequiredArgsConstructor +public class NotificationService { + private static final int DEFAULT_PAGE_SIZE = 20; + private static final int MAX_PAGE_SIZE = 100; + private static final TypeReference> PAYLOAD_TYPE = new TypeReference<>() {}; + + private final NotificationRepository notificationRepository; + private final PushDeviceTokenRepository pushDeviceTokenRepository; + private final NotificationDeliveryLogRepository notificationDeliveryLogRepository; + private final ObjectMapper objectMapper; + + @Transactional(readOnly = true) + public NotificationListResponse getNotifications( + Long userId, Long cursorId, Integer size, boolean unreadOnly) { + int pageSize = normalizePageSize(size); + List rows = + notificationRepository.findInbox( + userId, cursorId, unreadOnly, PageRequest.of(0, pageSize + 1)); + + boolean hasNext = rows.size() > pageSize; + List page = hasNext ? rows.subList(0, pageSize) : rows; + Long nextCursor = hasNext && !page.isEmpty() ? page.get(page.size() - 1).getId() : null; + + return new NotificationListResponse( + page.stream().map(this::toResponse).toList(), nextCursor, hasNext); + } + + @Transactional(readOnly = true) + public NotificationUnreadCountResponse getUnreadCount(Long userId) { + return new NotificationUnreadCountResponse( + notificationRepository.countByUserIdAndReadAtIsNull(userId)); + } + + @Transactional + public NotificationReadResponse markRead(Long userId, Long notificationId) { + Notification notification = + notificationRepository + .findByIdAndUserId(notificationId, userId) + .orElseThrow(() -> new BusinessException(ErrorCode.NOTIFICATION_NOT_FOUND)); + boolean wasUnread = !notification.isRead(); + notification.markRead(); + return new NotificationReadResponse(wasUnread ? 1 : 0); + } + + @Transactional + public NotificationReadResponse markRead(Long userId, NotificationReadRequest request) { + List notifications = + notificationRepository.findAllByUserIdAndIdIn(userId, request.notificationIds()); + int readCount = 0; + for (Notification notification : notifications) { + if (!notification.isRead()) { + notification.markRead(); + readCount++; + } + } + return new NotificationReadResponse(readCount); + } + + @Transactional + public NotificationReadResponse markAllRead(Long userId) { + return new NotificationReadResponse( + notificationRepository.markAllReadByUserId(userId, OffsetDateTime.now())); + } + + @Transactional + public PushTokenResponse registerPushToken(Long userId, PushTokenRegisterRequest request) { + String token = normalizeRequired(request.token()); + String tokenHash = sha256Hex(token); + PushDeviceToken tokenEntity = + pushDeviceTokenRepository + .findByUserIdAndTokenHash(userId, tokenHash) + .map( + existing -> { + existing.refresh( + request.platform(), + token, + normalizeOptional(request.deviceId()), + normalizeOptional(request.appVersion())); + return existing; + }) + .orElseGet( + () -> + PushDeviceToken.builder() + .userId(userId) + .platform(request.platform()) + .token(token) + .tokenHash(tokenHash) + .deviceId(normalizeOptional(request.deviceId())) + .appVersion(normalizeOptional(request.appVersion())) + .build()); + + return toResponse(pushDeviceTokenRepository.save(tokenEntity)); + } + + @Transactional + public void deletePushToken(Long userId, PushTokenDeleteRequest request) { + String tokenHash = sha256Hex(normalizeRequired(request.token())); + pushDeviceTokenRepository + .findByUserIdAndTokenHash(userId, tokenHash) + .ifPresent(PushDeviceToken::softDelete); + } + + @Transactional + public Notification createNotification(Long userId, NotificationCreateCommand command) { + if (command == null + || command.type() == null + || !StringUtils.hasText(command.title()) + || !StringUtils.hasText(command.body())) { + throw new BusinessException(ErrorCode.INVALID_INPUT_VALUE); + } + String dedupeKey = normalizeOptional(command.dedupeKey()); + if (StringUtils.hasText(dedupeKey)) { + var existing = notificationRepository.findByUserIdAndDedupeKey(userId, dedupeKey); + if (existing.isPresent()) { + return existing.get(); + } + } + + Notification notification = + Notification.builder() + .userId(userId) + .type(command.type()) + .title(normalizeRequired(command.title())) + .body(normalizeRequired(command.body())) + .payloadJson(serializePayload(command.payload())) + .dedupeKey(dedupeKey) + .build(); + + try { + return notificationRepository.save(notification); + } catch (DataIntegrityViolationException e) { + // 동시에 같은 알림을 생성해도 사용자에게 중복 노출되지 않도록 DB unique 제약을 한 번 더 신뢰한다. + if (StringUtils.hasText(dedupeKey)) { + return notificationRepository + .findByUserIdAndDedupeKey(userId, dedupeKey) + .orElseThrow(() -> e); + } + throw e; + } + } + + @Transactional + public void recordDeliveryResult(NotificationDeliveryResultCommand command) { + Notification notification = + notificationRepository + .findById(command.notificationId()) + .orElseThrow(() -> new BusinessException(ErrorCode.NOTIFICATION_NOT_FOUND)); + PushDeviceToken pushDeviceToken = null; + if (command.pushDeviceTokenId() != null) { + pushDeviceToken = + pushDeviceTokenRepository.findById(command.pushDeviceTokenId()).orElse(null); + } + + notificationDeliveryLogRepository.save( + NotificationDeliveryLog.builder() + .notification(notification) + .pushDeviceToken(pushDeviceToken) + .status( + command.status() != null ? command.status() : NotificationDeliveryStatus.PENDING) + .providerMessageId(normalizeOptional(command.providerMessageId())) + .failureReason(normalizeOptional(command.failureReason())) + .build()); + } + + private NotificationResponse toResponse(Notification notification) { + return new NotificationResponse( + notification.getId(), + notification.getType(), + notification.getTitle(), + notification.getBody(), + deserializePayload(notification.getPayloadJson()), + notification.isRead(), + notification.getReadAt(), + notification.getCreatedAt()); + } + + private PushTokenResponse toResponse(PushDeviceToken token) { + return new PushTokenResponse( + token.getId(), + token.getPlatform(), + token.getDeviceId(), + token.getAppVersion(), + token.isEnabled(), + token.getLastRegisteredAt()); + } + + private int normalizePageSize(Integer size) { + if (size == null) { + return DEFAULT_PAGE_SIZE; + } + if (size < 1) { + return DEFAULT_PAGE_SIZE; + } + return Math.min(size, MAX_PAGE_SIZE); + } + + private String serializePayload(Map payload) { + if (payload == null || payload.isEmpty()) { + return null; + } + try { + return objectMapper.writeValueAsString(payload); + } catch (Exception e) { + throw new BusinessException(ErrorCode.INVALID_INPUT_VALUE); + } + } + + private Map deserializePayload(String payloadJson) { + if (!StringUtils.hasText(payloadJson)) { + return Collections.emptyMap(); + } + try { + return objectMapper.readValue(payloadJson, PAYLOAD_TYPE); + } catch (Exception e) { + Map fallback = new LinkedHashMap<>(); + fallback.put("raw", payloadJson); + return fallback; + } + } + + private String normalizeRequired(String value) { + return value == null ? "" : value.trim(); + } + + private String normalizeOptional(String value) { + String normalized = normalizeRequired(value); + return StringUtils.hasText(normalized) ? normalized : null; + } + + private String sha256Hex(String value) { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + byte[] hash = digest.digest(value.getBytes(StandardCharsets.UTF_8)); + StringBuilder hex = new StringBuilder(hash.length * 2); + for (byte b : hash) { + hex.append(String.format("%02x", b)); + } + return hex.toString(); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException("SHA-256 algorithm is not available", e); + } + } +} diff --git a/src/main/java/com/gachi/be/global/code/ErrorCode.java b/src/main/java/com/gachi/be/global/code/ErrorCode.java index 44445cf..140be78 100644 --- a/src/main/java/com/gachi/be/global/code/ErrorCode.java +++ b/src/main/java/com/gachi/be/global/code/ErrorCode.java @@ -229,6 +229,13 @@ public enum ErrorCode { "언어 코드 유효성 검사 실패", ErrorLogLevel.WARN), + NOTIFICATION_NOT_FOUND( + HttpStatus.NOT_FOUND, + "NOTI4041", + "알림을 찾을 수 없습니다.", + "notificationId에 해당하는 알림이 없거나 소유권이 일치하지 않음", + ErrorLogLevel.INFO), + EXTERNAL_API_ERROR( HttpStatus.BAD_GATEWAY, "EXT5021", diff --git a/src/main/java/com/gachi/be/global/code/SuccessCode.java b/src/main/java/com/gachi/be/global/code/SuccessCode.java index b9e7277..73d999d 100644 --- a/src/main/java/com/gachi/be/global/code/SuccessCode.java +++ b/src/main/java/com/gachi/be/global/code/SuccessCode.java @@ -38,7 +38,12 @@ public enum SuccessCode { CHECKLIST_TODAY_SUCCESS(HttpStatus.OK, "CL2001", "오늘 마감 체크리스트 조회에 성공하였습니다."), CHECKLIST_COMPLETE_SUCCESS(HttpStatus.OK, "CL2002", "체크리스트 완료 처리에 성공하였습니다."), CHECKLIST_DELETED(HttpStatus.OK, "CL2003", "체크리스트 삭제에 성공하였습니다."), - USER_LANGUAGE_UPDATED(HttpStatus.OK, "USER2001", "언어 설정이 변경되었습니다."); + USER_LANGUAGE_UPDATED(HttpStatus.OK, "USER2001", "언어 설정이 변경되었습니다."), + NOTIFICATION_LIST_SUCCESS(HttpStatus.OK, "NOTI2001", "알림 목록 조회에 성공하였습니다."), + NOTIFICATION_UNREAD_COUNT_SUCCESS(HttpStatus.OK, "NOTI2002", "미읽음 알림 수 조회에 성공하였습니다."), + NOTIFICATION_READ_SUCCESS(HttpStatus.OK, "NOTI2003", "알림 읽음 처리에 성공하였습니다."), + NOTIFICATION_PUSH_TOKEN_REGISTERED(HttpStatus.OK, "NOTI2004", "푸시 토큰 등록에 성공하였습니다."), + NOTIFICATION_PUSH_TOKEN_DELETED(HttpStatus.OK, "NOTI2005", "푸시 토큰 삭제에 성공하였습니다."); private final HttpStatus httpStatus; private final String code; diff --git a/src/main/resources/db/migration/V14__notification_api.sql b/src/main/resources/db/migration/V14__notification_api.sql new file mode 100644 index 0000000..5c2b3ee --- /dev/null +++ b/src/main/resources/db/migration/V14__notification_api.sql @@ -0,0 +1,79 @@ +CREATE TABLE IF NOT EXISTS notifications ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + user_id BIGINT NOT NULL, + type VARCHAR(40) NOT NULL, + title VARCHAR(120) NOT NULL, + body TEXT NOT NULL, + payload_json TEXT NULL, + dedupe_key VARCHAR(255) NULL, + read_at TIMESTAMPTZ NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT fk_notifications_user + FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE, + CONSTRAINT chk_notifications_type + CHECK (type IN ('NEWSLETTER_ANALYSIS','CALENDAR_EVENT','CHECKLIST_DUE','SYSTEM','ANNOUNCEMENT')) +); + +CREATE INDEX IF NOT EXISTS idx_notifications_user_id_id + ON notifications (user_id, id DESC); + +CREATE INDEX IF NOT EXISTS idx_notifications_user_unread + ON notifications (user_id, id DESC) + WHERE read_at IS NULL; + +CREATE UNIQUE INDEX IF NOT EXISTS uq_notifications_user_dedupe_key + ON notifications (user_id, dedupe_key) + WHERE dedupe_key IS NOT NULL; + +CREATE TABLE IF NOT EXISTS push_device_tokens ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + user_id BIGINT NOT NULL, + platform VARCHAR(20) NOT NULL, + token VARCHAR(512) NOT NULL, + token_hash VARCHAR(64) NOT NULL, + device_id VARCHAR(128) NULL, + app_version VARCHAR(50) NULL, + enabled BOOLEAN NOT NULL DEFAULT TRUE, + last_registered_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted_at TIMESTAMPTZ NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT fk_push_device_tokens_user + FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE, + CONSTRAINT chk_push_device_tokens_platform + CHECK (platform IN ('IOS','ANDROID','EXPO')), + CONSTRAINT uk_push_device_tokens_user_token_hash + UNIQUE (user_id, token_hash) +); + +CREATE INDEX IF NOT EXISTS idx_push_device_tokens_user_active + ON push_device_tokens (user_id, enabled, deleted_at); + +CREATE INDEX IF NOT EXISTS idx_push_device_tokens_device_id + ON push_device_tokens (user_id, device_id) + WHERE device_id IS NOT NULL; + +CREATE TABLE IF NOT EXISTS notification_delivery_logs ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + notification_id BIGINT NOT NULL, + push_device_token_id BIGINT NULL, + status VARCHAR(20) NOT NULL, + provider_message_id VARCHAR(255) NULL, + failure_reason TEXT NULL, + attempted_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT fk_notification_delivery_logs_notification + FOREIGN KEY (notification_id) REFERENCES notifications (id) ON DELETE CASCADE, + CONSTRAINT fk_notification_delivery_logs_push_device_token + FOREIGN KEY (push_device_token_id) REFERENCES push_device_tokens (id) ON DELETE SET NULL, + CONSTRAINT chk_notification_delivery_logs_status + CHECK (status IN ('PENDING','SENT','FAILED','SKIPPED')) +); + +CREATE INDEX IF NOT EXISTS idx_notification_delivery_logs_notification + ON notification_delivery_logs (notification_id, attempted_at DESC); + +CREATE INDEX IF NOT EXISTS idx_notification_delivery_logs_status + ON notification_delivery_logs (status, attempted_at DESC); diff --git a/src/test/java/com/gachi/be/domain/notification/api/controller/NotificationControllerIntegrationTest.java b/src/test/java/com/gachi/be/domain/notification/api/controller/NotificationControllerIntegrationTest.java new file mode 100644 index 0000000..15e7a1f --- /dev/null +++ b/src/test/java/com/gachi/be/domain/notification/api/controller/NotificationControllerIntegrationTest.java @@ -0,0 +1,247 @@ +package com.gachi.be.domain.notification.api.controller; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.security.test.web.servlet.setup.SecurityMockMvcConfigurers.springSecurity; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.gachi.be.domain.auth.service.JwtTokenProvider; +import com.gachi.be.domain.notification.entity.Notification; +import com.gachi.be.domain.notification.entity.enums.NotificationType; +import com.gachi.be.domain.notification.repository.NotificationRepository; +import com.gachi.be.domain.notification.repository.PushDeviceTokenRepository; +import com.gachi.be.domain.notification.service.NotificationCreateCommand; +import com.gachi.be.domain.notification.service.NotificationService; +import com.gachi.be.domain.user.entity.User; +import com.gachi.be.domain.user.entity.enums.UserStatus; +import com.gachi.be.domain.user.repository.UserRepository; +import java.time.OffsetDateTime; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.MvcResult; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.context.WebApplicationContext; + +@SpringBootTest +@ActiveProfiles("test") +@Transactional +class NotificationControllerIntegrationTest { + private static final AtomicInteger PHONE_SEQUENCE = new AtomicInteger(7000); + + private final ObjectMapper objectMapper = new ObjectMapper(); + private MockMvc mockMvc; + + @Autowired private WebApplicationContext webApplicationContext; + @Autowired private UserRepository userRepository; + @Autowired private JwtTokenProvider jwtTokenProvider; + @Autowired private NotificationService notificationService; + @Autowired private NotificationRepository notificationRepository; + @Autowired private PushDeviceTokenRepository pushDeviceTokenRepository; + + @BeforeEach + void setUp() { + mockMvc = + MockMvcBuilders.webAppContextSetup(webApplicationContext).apply(springSecurity()).build(); + } + + @Test + void notificationInboxSupportsCursorUnreadCountAndReadState() throws Exception { + User user = createActiveUser("notification_parent"); + String token = issueBearerToken(user); + + Notification first = + notificationService.createNotification( + user.getId(), + new NotificationCreateCommand( + NotificationType.NEWSLETTER_ANALYSIS, + "analysis complete", + "first body", + Map.of("newsletterId", 10L), + "newsletter:10:completed")); + Notification duplicated = + notificationService.createNotification( + user.getId(), + new NotificationCreateCommand( + NotificationType.NEWSLETTER_ANALYSIS, + "analysis complete again", + "duplicated body", + Map.of("newsletterId", 10L), + "newsletter:10:completed")); + Notification second = + notificationService.createNotification( + user.getId(), + new NotificationCreateCommand( + NotificationType.CALENDAR_EVENT, + "calendar registered", + "second body", + Map.of("calendarEventId", 20L), + "calendar:20:registered")); + + assertThat(duplicated.getId()).isEqualTo(first.getId()); + assertThat(notificationRepository.countByUserIdAndReadAtIsNull(user.getId())).isEqualTo(2); + + MvcResult firstPage = + mockMvc + .perform(get("/api/v1/notifications").header("Authorization", token).param("size", "1")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value("NOTI2001")) + .andExpect(jsonPath("$.result.notifications.length()").value(1)) + .andExpect(jsonPath("$.result.notifications[0].id").value(second.getId())) + .andExpect(jsonPath("$.result.notifications[0].payload.calendarEventId").value(20)) + .andExpect(jsonPath("$.result.hasNext").value(true)) + .andReturn(); + + Long nextCursor = readBody(firstPage).path("result").path("nextCursor").asLong(); + + mockMvc + .perform( + get("/api/v1/notifications") + .header("Authorization", token) + .param("cursor", String.valueOf(nextCursor)) + .param("size", "10")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.result.notifications.length()").value(1)) + .andExpect(jsonPath("$.result.notifications[0].id").value(first.getId())) + .andExpect(jsonPath("$.result.hasNext").value(false)); + + mockMvc + .perform(get("/api/v1/notifications/unread-count").header("Authorization", token)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value("NOTI2002")) + .andExpect(jsonPath("$.result.unreadCount").value(2)); + + mockMvc + .perform( + patch("/api/v1/notifications/{notificationId}/read", second.getId()) + .header("Authorization", token)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.result.readCount").value(1)); + + mockMvc + .perform( + patch("/api/v1/notifications/read") + .header("Authorization", token) + .contentType(MediaType.APPLICATION_JSON) + .content( + objectMapper.writeValueAsString( + Map.of("notificationIds", List.of(first.getId()))))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.result.readCount").value(1)); + + mockMvc + .perform(get("/api/v1/notifications/unread-count").header("Authorization", token)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.result.unreadCount").value(0)); + } + + @Test + void pushTokenCanBeRegisteredDeletedAndReRegistered() throws Exception { + User user = createActiveUser("push_token_parent"); + String bearerToken = issueBearerToken(user); + Map registerBody = + Map.of( + "platform", "EXPO", + "token", "ExpoPushToken[test-token]", + "deviceId", "device-1", + "appVersion", "1.0.0"); + + MvcResult created = + mockMvc + .perform( + post("/api/v1/notifications/tokens") + .header("Authorization", bearerToken) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(registerBody))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value("NOTI2004")) + .andExpect(jsonPath("$.result.platform").value("EXPO")) + .andExpect(jsonPath("$.result.enabled").value(true)) + .andReturn(); + + long tokenId = readBody(created).path("result").path("id").asLong(); + + mockMvc + .perform( + delete("/api/v1/notifications/tokens") + .header("Authorization", bearerToken) + .contentType(MediaType.APPLICATION_JSON) + .content( + objectMapper.writeValueAsString(Map.of("token", "ExpoPushToken[test-token]")))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value("NOTI2005")); + + assertThat(pushDeviceTokenRepository.findByIdAndUserId(tokenId, user.getId())) + .get() + .extracting("enabled") + .isEqualTo(false); + + mockMvc + .perform( + post("/api/v1/notifications/tokens") + .header("Authorization", bearerToken) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(registerBody))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.result.id").value(tokenId)) + .andExpect(jsonPath("$.result.enabled").value(true)); + } + + @Test + void readingOtherUsersNotificationReturnsNotFound() throws Exception { + User owner = createActiveUser("notification_owner"); + User other = createActiveUser("notification_other"); + String otherToken = issueBearerToken(other); + Notification notification = + notificationService.createNotification( + owner.getId(), + new NotificationCreateCommand( + NotificationType.SYSTEM, "system", "body", Map.of(), "system:owner")); + + mockMvc + .perform( + patch("/api/v1/notifications/{notificationId}/read", notification.getId()) + .header("Authorization", otherToken)) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value("NOTI4041")); + } + + private JsonNode readBody(MvcResult result) throws Exception { + return objectMapper.readTree(result.getResponse().getContentAsString()); + } + + private String issueBearerToken(User user) { + return "Bearer " + jwtTokenProvider.issueAccessToken(user).getToken(); + } + + private User createActiveUser(String postfix) { + OffsetDateTime now = OffsetDateTime.now(); + return userRepository.saveAndFlush( + User.builder() + .name("parent-" + postfix) + .email(postfix + "@gachi.com") + .loginId("login_" + postfix) + .passwordHash("encoded-password") + .phoneNumber("0107777" + String.format("%04d", PHONE_SEQUENCE.getAndIncrement())) + .status(UserStatus.ACTIVE) + .emailVerifiedAt(now) + .consentAgreedAt(now) + .consentVersion("2026-04-v1") + .passwordUpdatedAt(now) + .build()); + } +} From b07c70ab44e0fd563c75cf8c84dde3089b6cc32e Mon Sep 17 00:00:00 2001 From: minju Date: Tue, 26 May 2026 21:41:01 +0900 Subject: [PATCH 2/8] =?UTF-8?q?fix:=20Java=20setup=20action=20=EB=B2=84?= =?UTF-8?q?=EC=A0=84=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/docker-be.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/docker-be.yml b/.github/workflows/docker-be.yml index ba8ec87..8b3c7db 100644 --- a/.github/workflows/docker-be.yml +++ b/.github/workflows/docker-be.yml @@ -40,7 +40,7 @@ jobs: uses: actions/checkout@v6 - name: Setup Java - uses: actions/setup-java@v5 + uses: actions/setup-java@v4 with: distribution: temurin java-version: "17" From 9303ce4e9cf4b23ecca1a5171159f6c1ce7b4074 Mon Sep 17 00:00:00 2001 From: minju Date: Tue, 26 May 2026 22:04:12 +0900 Subject: [PATCH 3/8] =?UTF-8?q?fix:=20=EC=95=8C=EB=A6=BC=20=ED=91=B8?= =?UTF-8?q?=EC=8B=9C=20=ED=86=A0=ED=81=B0=20=EA=B0=B1=EC=8B=A0=20=EC=A0=95?= =?UTF-8?q?=EC=B1=85=20=EB=B3=B4=EA=B0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/NotificationController.java | 2 +- .../notification/entity/PushDeviceToken.java | 4 +- .../service/NotificationService.java | 33 +++++++--- ...NotificationControllerIntegrationTest.java | 60 ++++++++++++++++++- 4 files changed, 89 insertions(+), 10 deletions(-) diff --git a/src/main/java/com/gachi/be/domain/notification/api/controller/NotificationController.java b/src/main/java/com/gachi/be/domain/notification/api/controller/NotificationController.java index 407155d..177b022 100644 --- a/src/main/java/com/gachi/be/domain/notification/api/controller/NotificationController.java +++ b/src/main/java/com/gachi/be/domain/notification/api/controller/NotificationController.java @@ -54,7 +54,7 @@ public ApiResponse getNotifications( @Parameter(description = "다음 페이지 조회용 커서. 이전 응답의 nextCursor") @RequestParam(required = false) Long cursor, @Parameter(description = "조회 크기. 기본 20, 최대 100") - @RequestParam(required = false) + @RequestParam(defaultValue = "20") @Min(1) @Max(100) Integer size, diff --git a/src/main/java/com/gachi/be/domain/notification/entity/PushDeviceToken.java b/src/main/java/com/gachi/be/domain/notification/entity/PushDeviceToken.java index acca3f0..b6b722e 100644 --- a/src/main/java/com/gachi/be/domain/notification/entity/PushDeviceToken.java +++ b/src/main/java/com/gachi/be/domain/notification/entity/PushDeviceToken.java @@ -79,9 +79,11 @@ public PushDeviceToken( this.enabled = true; } - public void refresh(PushPlatform platform, String token, String deviceId, String appVersion) { + public void refresh( + PushPlatform platform, String token, String tokenHash, String deviceId, String appVersion) { this.platform = platform; this.token = token; + this.tokenHash = tokenHash; this.deviceId = deviceId; this.appVersion = appVersion; this.enabled = true; diff --git a/src/main/java/com/gachi/be/domain/notification/service/NotificationService.java b/src/main/java/com/gachi/be/domain/notification/service/NotificationService.java index 5c413aa..3ae6e16 100644 --- a/src/main/java/com/gachi/be/domain/notification/service/NotificationService.java +++ b/src/main/java/com/gachi/be/domain/notification/service/NotificationService.java @@ -102,18 +102,21 @@ public NotificationReadResponse markAllRead(Long userId) { @Transactional public PushTokenResponse registerPushToken(Long userId, PushTokenRegisterRequest request) { - String token = normalizeRequired(request.token()); + if (request == null || request.platform() == null) { + throw new BusinessException(ErrorCode.INVALID_INPUT_VALUE); + } + String token = requireToken(request.token()); String tokenHash = sha256Hex(token); PushDeviceToken tokenEntity = pushDeviceTokenRepository .findByUserIdAndTokenHash(userId, tokenHash) .map( existing -> { - existing.refresh( - request.platform(), - token, - normalizeOptional(request.deviceId()), - normalizeOptional(request.appVersion())); + String deviceId = + preserveExistingIfBlank(request.deviceId(), existing.getDeviceId()); + String appVersion = + preserveExistingIfBlank(request.appVersion(), existing.getAppVersion()); + existing.refresh(request.platform(), token, tokenHash, deviceId, appVersion); return existing; }) .orElseGet( @@ -132,7 +135,10 @@ public PushTokenResponse registerPushToken(Long userId, PushTokenRegisterRequest @Transactional public void deletePushToken(Long userId, PushTokenDeleteRequest request) { - String tokenHash = sha256Hex(normalizeRequired(request.token())); + if (request == null) { + throw new BusinessException(ErrorCode.INVALID_INPUT_VALUE); + } + String tokenHash = sha256Hex(requireToken(request.token())); pushDeviceTokenRepository .findByUserIdAndTokenHash(userId, tokenHash) .ifPresent(PushDeviceToken::softDelete); @@ -265,6 +271,19 @@ private String normalizeOptional(String value) { return StringUtils.hasText(normalized) ? normalized : null; } + private String requireToken(String token) { + String normalized = normalizeRequired(token); + if (!StringUtils.hasText(normalized)) { + throw new BusinessException(ErrorCode.INVALID_INPUT_VALUE); + } + return normalized; + } + + private String preserveExistingIfBlank(String requestedValue, String existingValue) { + String normalized = normalizeOptional(requestedValue); + return normalized != null ? normalized : existingValue; + } + private String sha256Hex(String value) { try { MessageDigest digest = MessageDigest.getInstance("SHA-256"); diff --git a/src/test/java/com/gachi/be/domain/notification/api/controller/NotificationControllerIntegrationTest.java b/src/test/java/com/gachi/be/domain/notification/api/controller/NotificationControllerIntegrationTest.java index 15e7a1f..5b7c472 100644 --- a/src/test/java/com/gachi/be/domain/notification/api/controller/NotificationControllerIntegrationTest.java +++ b/src/test/java/com/gachi/be/domain/notification/api/controller/NotificationControllerIntegrationTest.java @@ -198,7 +198,65 @@ void pushTokenCanBeRegisteredDeletedAndReRegistered() throws Exception { .content(objectMapper.writeValueAsString(registerBody))) .andExpect(status().isOk()) .andExpect(jsonPath("$.result.id").value(tokenId)) - .andExpect(jsonPath("$.result.enabled").value(true)); + .andExpect(jsonPath("$.result.enabled").value(true)) + .andExpect(jsonPath("$.result.deviceId").value("device-1")); + } + + @Test + void pushTokenReRegistrationKeepsExistingDeviceIdWhenDeviceIdIsMissing() throws Exception { + User user = createActiveUser("push_token_keep_device"); + String bearerToken = issueBearerToken(user); + + mockMvc + .perform( + post("/api/v1/notifications/tokens") + .header("Authorization", bearerToken) + .contentType(MediaType.APPLICATION_JSON) + .content( + objectMapper.writeValueAsString( + Map.of( + "platform", + "EXPO", + "token", + "ExpoPushToken[keep-device]", + "deviceId", + "device-keep", + "appVersion", + "1.0.0")))) + .andExpect(status().isOk()); + + mockMvc + .perform( + post("/api/v1/notifications/tokens") + .header("Authorization", bearerToken) + .contentType(MediaType.APPLICATION_JSON) + .content( + objectMapper.writeValueAsString( + Map.of( + "platform", + "EXPO", + "token", + "ExpoPushToken[keep-device]", + "appVersion", + "1.0.1")))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.result.deviceId").value("device-keep")) + .andExpect(jsonPath("$.result.appVersion").value("1.0.1")); + } + + @Test + void pushTokenRegistrationRejectsBlankToken() throws Exception { + User user = createActiveUser("push_token_blank"); + String bearerToken = issueBearerToken(user); + + mockMvc + .perform( + post("/api/v1/notifications/tokens") + .header("Authorization", bearerToken) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(Map.of("platform", "EXPO", "token", " ")))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("COMMON4001")); } @Test From 6cda40840053a86d9e935216aa8805779b2e75a4 Mon Sep 17 00:00:00 2001 From: minju Date: Tue, 26 May 2026 23:01:59 +0900 Subject: [PATCH 4/8] =?UTF-8?q?fix:=20=EB=B0=B0=ED=8F=AC=20=ED=99=98?= =?UTF-8?q?=EA=B2=BD=EC=97=90=EC=84=9C=20=EB=A9=94=EC=9D=BC=20=ED=97=AC?= =?UTF-8?q?=EC=8A=A4=EC=B2=B4=ED=81=AC=20=EB=B9=84=ED=99=9C=EC=84=B1?= =?UTF-8?q?=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- deploy/docker-compose.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index c357c30..95ee5c4 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -93,6 +93,7 @@ services: AUTH_EMAIL_SUBJECT: ${AUTH_EMAIL_SUBJECT:-[GACHI] Email verification code} AUTH_RATE_LIMIT_EMAIL_HMAC_SECRET: ${AUTH_RATE_LIMIT_EMAIL_HMAC_SECRET} AUTH_RATE_LIMIT_TRUSTED_PROXIES: ${AUTH_RATE_LIMIT_TRUSTED_PROXIES:-127.0.0.1,::1} + MANAGEMENT_HEALTH_MAIL_ENABLED: ${MANAGEMENT_HEALTH_MAIL_ENABLED:-false} CLOVA_OCR_API_URL: ${CLOVA_OCR_API_URL} CLOVA_OCR_SECRET_KEY: ${CLOVA_OCR_SECRET_KEY} PAPAGO_CLIENT_ID: ${PAPAGO_CLIENT_ID} From ab046b364b56f780f059b4690da69b89223fcaa1 Mon Sep 17 00:00:00 2001 From: minju Date: Tue, 26 May 2026 23:32:22 +0900 Subject: [PATCH 5/8] =?UTF-8?q?chore:=20BE=20Docker=20=EC=9B=8C=ED=81=AC?= =?UTF-8?q?=ED=94=8C=EB=A1=9C=EC=9A=B0=20=EC=95=A1=EC=85=98=20SHA=20?= =?UTF-8?q?=EA=B3=A0=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/docker-be.yml | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/docker-be.yml b/.github/workflows/docker-be.yml index 8b3c7db..f467a9b 100644 --- a/.github/workflows/docker-be.yml +++ b/.github/workflows/docker-be.yml @@ -37,16 +37,16 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - name: Setup Java - uses: actions/setup-java@v4 + uses: actions/setup-java@c1e323688fd81a25caa38c78aa6df2d33d3e20d9 # v4 with: distribution: temurin java-version: "17" - name: Setup Gradle - uses: gradle/actions/setup-gradle@v5 + uses: gradle/actions/setup-gradle@4c125117fe7c5aed11272ec4213f602f012f89f2 # v5 - name: Make gradlew executable run: chmod +x gradlew @@ -64,10 +64,10 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - name: Login Docker Hub - uses: docker/login-action@v4 + uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} @@ -84,7 +84,7 @@ jobs: fi - name: Build and Push - uses: docker/build-push-action@v7 + uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7 with: context: . push: true From 13e1b87126e11b373169c0f74bcdabf912557ec0 Mon Sep 17 00:00:00 2001 From: minju Date: Tue, 26 May 2026 23:48:44 +0900 Subject: [PATCH 6/8] =?UTF-8?q?docs:=20=EC=95=8C=EB=A6=BC=20=EC=97=90?= =?UTF-8?q?=EB=9F=AC=EC=BD=94=EB=93=9C=20=EC=8B=9C=ED=8A=B8=20=EB=A7=81?= =?UTF-8?q?=ED=81=AC=20=EA=B0=B1=EC=8B=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/error-code.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/error-code.md b/docs/error-code.md index 890315f..72a89d2 100644 --- a/docs/error-code.md +++ b/docs/error-code.md @@ -2,7 +2,7 @@ 상세 에러 코드는 아래 Google Sheets를 단일 원본으로 관리합니다. -- Google Sheets: [Error Code Single Source](https://docs.google.com/spreadsheets/d/1sUG_JJsVl6a8nR6k8jO_b7UrIEZzuvQdPB6S2S6fqgo/edit?gid=1519705696#gid=1519705696) +- Google Sheets: [Error Code Single Source](https://docs.google.com/spreadsheets/d/1sUG_JJsVl6a8nR6k8jO_b7UrIEZzuvQdPB6S2S6fqgo/edit?gid=1155198711#gid=1155198711) ## 운영 원칙 From 9ca34976ebad00641183b73195b04fd3f10edde3 Mon Sep 17 00:00:00 2001 From: minju Date: Wed, 27 May 2026 00:44:40 +0900 Subject: [PATCH 7/8] =?UTF-8?q?feat:=20Expo=20=ED=91=B8=EC=8B=9C=20?= =?UTF-8?q?=EC=95=8C=EB=A6=BC=20=EB=B0=9C=EC=86=A1=20=EC=97=B0=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 8 + deploy/.env.example | 7 + deploy/docker-compose.yml | 6 + docs/env.md | 9 + .../entity/NotificationDeliveryLog.java | 5 + .../NotificationDeliveryLogRepository.java | 5 +- .../service/ExpoPushNotificationClient.java | 107 ++++++++++++ .../service/NotificationCreatedEvent.java | 4 + .../service/NotificationPushDispatcher.java | 138 ++++++++++++++++ .../service/NotificationService.java | 7 +- .../service/PushNotificationClient.java | 14 ++ .../notification/service/PushSendResult.java | 14 ++ .../config/external/ExternalApiConfig.java | 3 +- .../external/NotificationPushProperties.java | 24 +++ src/main/resources/application.yml | 9 + .../V15__notification_delivery_provider.sql | 5 + ...ficationPushDispatcherIntegrationTest.java | 155 ++++++++++++++++++ 17 files changed, 517 insertions(+), 3 deletions(-) create mode 100644 src/main/java/com/gachi/be/domain/notification/service/ExpoPushNotificationClient.java create mode 100644 src/main/java/com/gachi/be/domain/notification/service/NotificationCreatedEvent.java create mode 100644 src/main/java/com/gachi/be/domain/notification/service/NotificationPushDispatcher.java create mode 100644 src/main/java/com/gachi/be/domain/notification/service/PushNotificationClient.java create mode 100644 src/main/java/com/gachi/be/domain/notification/service/PushSendResult.java create mode 100644 src/main/java/com/gachi/be/global/config/external/NotificationPushProperties.java create mode 100644 src/main/resources/db/migration/V15__notification_delivery_provider.sql create mode 100644 src/test/java/com/gachi/be/domain/notification/service/NotificationPushDispatcherIntegrationTest.java diff --git a/.env.example b/.env.example index da9cfce..aa8cd1d 100644 --- a/.env.example +++ b/.env.example @@ -45,3 +45,11 @@ AUTH_RATE_LIMIT_KEY_PREFIX=auth:rate-limit: AUTH_RATE_LIMIT_LOGIN_LIMIT=5 AUTH_RATE_LIMIT_LOGIN_WINDOW_SECONDS=60 AUTH_RATE_LIMIT_TRUSTED_PROXIES=127.0.0.1,::1 + +# Push notification +NOTIFICATION_PUSH_ENABLED=false +NOTIFICATION_PUSH_PROVIDER=expo +NOTIFICATION_PUSH_CONNECT_TIMEOUT_SECONDS=5 +NOTIFICATION_PUSH_READ_TIMEOUT_SECONDS=10 +EXPO_PUSH_API_URL=https://exp.host/--/api/v2/push/send +EXPO_PUSH_ACCESS_TOKEN= diff --git a/deploy/.env.example b/deploy/.env.example index 8d0f866..6261d2a 100644 --- a/deploy/.env.example +++ b/deploy/.env.example @@ -49,5 +49,12 @@ AWS_S3_BUCKET= AWS_S3_PUBLIC_BASE_URL= AWS_S3_IMAGE_PREFIX=images +NOTIFICATION_PUSH_ENABLED=false +NOTIFICATION_PUSH_PROVIDER=expo +NOTIFICATION_PUSH_CONNECT_TIMEOUT_SECONDS=5 +NOTIFICATION_PUSH_READ_TIMEOUT_SECONDS=10 +EXPO_PUSH_API_URL=https://exp.host/--/api/v2/push/send +EXPO_PUSH_ACCESS_TOKEN= + # Let's Encrypt account email (required for production operations) CERTBOT_EMAIL=admin@example.com diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index 95ee5c4..0ddb060 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -94,6 +94,12 @@ services: AUTH_RATE_LIMIT_EMAIL_HMAC_SECRET: ${AUTH_RATE_LIMIT_EMAIL_HMAC_SECRET} AUTH_RATE_LIMIT_TRUSTED_PROXIES: ${AUTH_RATE_LIMIT_TRUSTED_PROXIES:-127.0.0.1,::1} MANAGEMENT_HEALTH_MAIL_ENABLED: ${MANAGEMENT_HEALTH_MAIL_ENABLED:-false} + NOTIFICATION_PUSH_ENABLED: ${NOTIFICATION_PUSH_ENABLED:-false} + NOTIFICATION_PUSH_PROVIDER: ${NOTIFICATION_PUSH_PROVIDER:-expo} + NOTIFICATION_PUSH_CONNECT_TIMEOUT_SECONDS: ${NOTIFICATION_PUSH_CONNECT_TIMEOUT_SECONDS:-5} + NOTIFICATION_PUSH_READ_TIMEOUT_SECONDS: ${NOTIFICATION_PUSH_READ_TIMEOUT_SECONDS:-10} + EXPO_PUSH_API_URL: ${EXPO_PUSH_API_URL:-https://exp.host/--/api/v2/push/send} + EXPO_PUSH_ACCESS_TOKEN: ${EXPO_PUSH_ACCESS_TOKEN:-} CLOVA_OCR_API_URL: ${CLOVA_OCR_API_URL} CLOVA_OCR_SECRET_KEY: ${CLOVA_OCR_SECRET_KEY} PAPAGO_CLIENT_ID: ${PAPAGO_CLIENT_ID} diff --git a/docs/env.md b/docs/env.md index f329167..c4f8ef6 100644 --- a/docs/env.md +++ b/docs/env.md @@ -57,6 +57,15 @@ - `AUTH_RATE_LIMIT_LOGIN_LIMIT`: `/api/v1/auth/login` 윈도우 내 허용 횟수 (기본값: `5`) - `AUTH_RATE_LIMIT_LOGIN_WINDOW_SECONDS`: `/api/v1/auth/login` 윈도우 길이(초) (기본값: `60`) +## Push Notification + +- `NOTIFICATION_PUSH_ENABLED`: 실제 외부 푸시 발송 여부. 기본값은 `false`이며, 서버 보관함 알림 생성은 이 값과 무관하게 유지됩니다. +- `NOTIFICATION_PUSH_PROVIDER`: 푸시 발송 provider. 현재 지원값은 `expo`입니다. +- `NOTIFICATION_PUSH_CONNECT_TIMEOUT_SECONDS`: Expo Push API 연결 타임아웃(초) +- `NOTIFICATION_PUSH_READ_TIMEOUT_SECONDS`: Expo Push API 응답 타임아웃(초) +- `EXPO_PUSH_API_URL`: Expo Push API 발송 엔드포인트. 기본값은 `https://exp.host/--/api/v2/push/send` +- `EXPO_PUSH_ACCESS_TOKEN`: Expo push security가 활성화된 프로젝트에서 사용하는 access token. 비어 있으면 Authorization 헤더를 보내지 않습니다. + ## AWS Credential - Local/dev: AWS CLI profile 또는 환경변수 자격증명 사용 diff --git a/src/main/java/com/gachi/be/domain/notification/entity/NotificationDeliveryLog.java b/src/main/java/com/gachi/be/domain/notification/entity/NotificationDeliveryLog.java index a49e919..6e7801b 100644 --- a/src/main/java/com/gachi/be/domain/notification/entity/NotificationDeliveryLog.java +++ b/src/main/java/com/gachi/be/domain/notification/entity/NotificationDeliveryLog.java @@ -43,6 +43,9 @@ public class NotificationDeliveryLog { @Column(nullable = false, length = 20) private NotificationDeliveryStatus status; + @Column(nullable = false, length = 20) + private String provider; + @Column(name = "provider_message_id", length = 255) private String providerMessageId; @@ -63,11 +66,13 @@ public NotificationDeliveryLog( Notification notification, PushDeviceToken pushDeviceToken, NotificationDeliveryStatus status, + String provider, String providerMessageId, String failureReason) { this.notification = notification; this.pushDeviceToken = pushDeviceToken; this.status = status; + this.provider = provider != null ? provider : "UNKNOWN"; this.providerMessageId = providerMessageId; this.failureReason = failureReason; } diff --git a/src/main/java/com/gachi/be/domain/notification/repository/NotificationDeliveryLogRepository.java b/src/main/java/com/gachi/be/domain/notification/repository/NotificationDeliveryLogRepository.java index f47f985..7025913 100644 --- a/src/main/java/com/gachi/be/domain/notification/repository/NotificationDeliveryLogRepository.java +++ b/src/main/java/com/gachi/be/domain/notification/repository/NotificationDeliveryLogRepository.java @@ -1,7 +1,10 @@ package com.gachi.be.domain.notification.repository; import com.gachi.be.domain.notification.entity.NotificationDeliveryLog; +import java.util.List; import org.springframework.data.jpa.repository.JpaRepository; public interface NotificationDeliveryLogRepository - extends JpaRepository {} + extends JpaRepository { + List findAllByNotificationIdOrderByAttemptedAtAsc(Long notificationId); +} diff --git a/src/main/java/com/gachi/be/domain/notification/service/ExpoPushNotificationClient.java b/src/main/java/com/gachi/be/domain/notification/service/ExpoPushNotificationClient.java new file mode 100644 index 0000000..f62789b --- /dev/null +++ b/src/main/java/com/gachi/be/domain/notification/service/ExpoPushNotificationClient.java @@ -0,0 +1,107 @@ +package com.gachi.be.domain.notification.service; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.gachi.be.domain.notification.entity.Notification; +import com.gachi.be.domain.notification.entity.PushDeviceToken; +import com.gachi.be.global.config.external.NotificationPushProperties; +import java.io.IOException; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.time.Duration; +import java.util.Map; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; + +/** Expo Push API로 React Native 앱 푸시 알림을 발송한다. */ +@Slf4j +@Component +public class ExpoPushNotificationClient implements PushNotificationClient { + private static final String PROVIDER_NAME = "EXPO"; + private static final String DEVICE_NOT_REGISTERED = "DeviceNotRegistered"; + + private final NotificationPushProperties properties; + private final ObjectMapper objectMapper; + private final HttpClient httpClient; + + public ExpoPushNotificationClient( + NotificationPushProperties properties, ObjectMapper objectMapper) { + this.properties = properties; + this.objectMapper = objectMapper; + this.httpClient = + HttpClient.newBuilder() + .connectTimeout(Duration.ofSeconds(properties.getConnectTimeoutSeconds())) + .version(HttpClient.Version.HTTP_1_1) + .build(); + } + + @Override + public String providerName() { + return PROVIDER_NAME; + } + + @Override + public PushSendResult send( + Notification notification, PushDeviceToken pushDeviceToken, Map payload) { + try { + String body = + objectMapper.writeValueAsString( + new ExpoPushRequest( + pushDeviceToken.getToken(), + notification.getTitle(), + notification.getBody(), + "default", + payload != null ? payload : Map.of())); + + HttpRequest.Builder requestBuilder = + HttpRequest.newBuilder() + .uri(URI.create(properties.getExpo().getApiUrl())) + .header("Accept", "application/json") + .header("Accept-Encoding", "gzip, deflate") + .header("Content-Type", "application/json") + .timeout(Duration.ofSeconds(properties.getReadTimeoutSeconds())) + .POST(HttpRequest.BodyPublishers.ofString(body)); + if (StringUtils.hasText(properties.getExpo().getAccessToken())) { + requestBuilder.header("Authorization", "Bearer " + properties.getExpo().getAccessToken()); + } + + HttpResponse response = + httpClient.send(requestBuilder.build(), HttpResponse.BodyHandlers.ofString()); + if (response.statusCode() < 200 || response.statusCode() >= 300) { + return PushSendResult.failed( + "Expo Push API HTTP " + response.statusCode() + ": " + response.body(), false); + } + return parseTicket(response.body()); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return PushSendResult.failed("Expo Push API 호출이 인터럽트되었습니다.", false); + } catch (IOException | IllegalArgumentException e) { + log.warn("[ExpoPush] 푸시 발송 실패. notificationId={}", notification.getId(), e); + return PushSendResult.failed("Expo Push API 호출 실패: " + e.getMessage(), false); + } + } + + private PushSendResult parseTicket(String responseBody) throws IOException { + JsonNode root = objectMapper.readTree(responseBody); + JsonNode ticket = root.path("data"); + if (ticket.isArray()) { + ticket = ticket.isEmpty() ? objectMapper.nullNode() : ticket.get(0); + } + String status = ticket.path("status").asText(""); + if ("ok".equals(status)) { + return PushSendResult.sent(ticket.path("id").asText(null)); + } + + String message = ticket.path("message").asText("Expo Push API 발송 실패"); + String error = ticket.path("details").path("error").asText(""); + boolean invalidToken = DEVICE_NOT_REGISTERED.equals(error); + String failureReason = StringUtils.hasText(error) ? message + " (" + error + ")" : message; + return PushSendResult.failed(failureReason, invalidToken); + } + + private record ExpoPushRequest( + String to, String title, String body, String sound, Map data) {} +} diff --git a/src/main/java/com/gachi/be/domain/notification/service/NotificationCreatedEvent.java b/src/main/java/com/gachi/be/domain/notification/service/NotificationCreatedEvent.java new file mode 100644 index 0000000..34d3c42 --- /dev/null +++ b/src/main/java/com/gachi/be/domain/notification/service/NotificationCreatedEvent.java @@ -0,0 +1,4 @@ +package com.gachi.be.domain.notification.service; + +/** 알림 보관함 저장 커밋 이후 외부 푸시 발송을 시작하기 위한 도메인 이벤트. */ +public record NotificationCreatedEvent(Long notificationId, Long userId) {} diff --git a/src/main/java/com/gachi/be/domain/notification/service/NotificationPushDispatcher.java b/src/main/java/com/gachi/be/domain/notification/service/NotificationPushDispatcher.java new file mode 100644 index 0000000..3985f7d --- /dev/null +++ b/src/main/java/com/gachi/be/domain/notification/service/NotificationPushDispatcher.java @@ -0,0 +1,138 @@ +package com.gachi.be.domain.notification.service; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.gachi.be.domain.notification.entity.Notification; +import com.gachi.be.domain.notification.entity.NotificationDeliveryLog; +import com.gachi.be.domain.notification.entity.PushDeviceToken; +import com.gachi.be.domain.notification.entity.enums.NotificationDeliveryStatus; +import com.gachi.be.domain.notification.entity.enums.PushPlatform; +import com.gachi.be.domain.notification.repository.NotificationDeliveryLogRepository; +import com.gachi.be.domain.notification.repository.NotificationRepository; +import com.gachi.be.domain.notification.repository.PushDeviceTokenRepository; +import com.gachi.be.domain.user.entity.User; +import com.gachi.be.domain.user.repository.UserRepository; +import com.gachi.be.global.config.external.NotificationPushProperties; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; +import org.springframework.util.StringUtils; + +/** 알림 생성 이후 사용자 설정과 토큰 상태를 반영해 외부 푸시 발송을 수행한다. */ +@Slf4j +@Component +@RequiredArgsConstructor +public class NotificationPushDispatcher { + private static final String PROVIDER_EXPO = "expo"; + private static final TypeReference> PAYLOAD_TYPE = new TypeReference<>() {}; + + private final NotificationPushProperties properties; + private final NotificationRepository notificationRepository; + private final PushDeviceTokenRepository pushDeviceTokenRepository; + private final NotificationDeliveryLogRepository deliveryLogRepository; + private final UserRepository userRepository; + private final PushNotificationClient pushNotificationClient; + private final ObjectMapper objectMapper; + + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT, fallbackExecution = true) + @Transactional(propagation = Propagation.REQUIRES_NEW) + public void dispatch(NotificationCreatedEvent event) { + Notification notification = + notificationRepository.findById(event.notificationId()).orElse(null); + if (notification == null) { + log.info( + "[NotificationPush] 알림을 찾을 수 없어 발송을 건너뜁니다. notificationId={}", event.notificationId()); + return; + } + + User user = userRepository.findById(event.userId()).orElse(null); + if (user == null) { + saveSkipped(notification, null, "사용자를 찾을 수 없어 푸시 발송을 건너뜁니다."); + return; + } + if (!user.isNotificationEnabled()) { + saveSkipped(notification, null, "사용자 알림 수신 설정이 꺼져 있습니다."); + return; + } + if (!properties.isEnabled()) { + saveSkipped(notification, null, "푸시 발송 설정이 비활성화되어 있습니다."); + return; + } + if (!PROVIDER_EXPO.equalsIgnoreCase(properties.getProvider())) { + saveSkipped(notification, null, "지원하지 않는 푸시 provider입니다: " + properties.getProvider()); + return; + } + + List tokens = + pushDeviceTokenRepository.findAllByUserIdAndEnabledTrueAndDeletedAtIsNull(event.userId()); + if (tokens.isEmpty()) { + saveSkipped(notification, null, "활성 푸시 토큰이 없습니다."); + return; + } + + Map payload = deserializePayload(notification.getPayloadJson()); + for (PushDeviceToken token : tokens) { + dispatchToToken(notification, token, payload); + } + } + + private void dispatchToToken( + Notification notification, PushDeviceToken token, Map payload) { + if (token.getPlatform() != PushPlatform.EXPO) { + saveSkipped(notification, token, "현재 provider에서 지원하지 않는 토큰 플랫폼입니다: " + token.getPlatform()); + return; + } + + PushSendResult result = pushNotificationClient.send(notification, token, payload); + if (result.success()) { + saveDeliveryLog( + notification, token, NotificationDeliveryStatus.SENT, result.providerMessageId(), null); + return; + } + + if (result.invalidToken()) { + token.softDelete(); + } + saveDeliveryLog( + notification, token, NotificationDeliveryStatus.FAILED, null, result.failureReason()); + } + + private void saveSkipped(Notification notification, PushDeviceToken token, String failureReason) { + saveDeliveryLog(notification, token, NotificationDeliveryStatus.SKIPPED, null, failureReason); + } + + private void saveDeliveryLog( + Notification notification, + PushDeviceToken token, + NotificationDeliveryStatus status, + String providerMessageId, + String failureReason) { + deliveryLogRepository.save( + NotificationDeliveryLog.builder() + .notification(notification) + .pushDeviceToken(token) + .status(status) + .provider(pushNotificationClient.providerName()) + .providerMessageId(providerMessageId) + .failureReason(failureReason) + .build()); + } + + private Map deserializePayload(String payloadJson) { + if (!StringUtils.hasText(payloadJson)) { + return Collections.emptyMap(); + } + try { + return objectMapper.readValue(payloadJson, PAYLOAD_TYPE); + } catch (Exception e) { + return Map.of("raw", payloadJson); + } + } +} diff --git a/src/main/java/com/gachi/be/domain/notification/service/NotificationService.java b/src/main/java/com/gachi/be/domain/notification/service/NotificationService.java index 3ae6e16..77f70c3 100644 --- a/src/main/java/com/gachi/be/domain/notification/service/NotificationService.java +++ b/src/main/java/com/gachi/be/domain/notification/service/NotificationService.java @@ -28,6 +28,7 @@ import java.util.List; import java.util.Map; import lombok.RequiredArgsConstructor; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.dao.DataIntegrityViolationException; import org.springframework.data.domain.PageRequest; import org.springframework.stereotype.Service; @@ -46,6 +47,7 @@ public class NotificationService { private final PushDeviceTokenRepository pushDeviceTokenRepository; private final NotificationDeliveryLogRepository notificationDeliveryLogRepository; private final ObjectMapper objectMapper; + private final ApplicationEventPublisher eventPublisher; @Transactional(readOnly = true) public NotificationListResponse getNotifications( @@ -171,7 +173,9 @@ public Notification createNotification(Long userId, NotificationCreateCommand co .build(); try { - return notificationRepository.save(notification); + Notification saved = notificationRepository.save(notification); + eventPublisher.publishEvent(new NotificationCreatedEvent(saved.getId(), saved.getUserId())); + return saved; } catch (DataIntegrityViolationException e) { // 동시에 같은 알림을 생성해도 사용자에게 중복 노출되지 않도록 DB unique 제약을 한 번 더 신뢰한다. if (StringUtils.hasText(dedupeKey)) { @@ -199,6 +203,7 @@ public void recordDeliveryResult(NotificationDeliveryResultCommand command) { NotificationDeliveryLog.builder() .notification(notification) .pushDeviceToken(pushDeviceToken) + .provider("MANUAL") .status( command.status() != null ? command.status() : NotificationDeliveryStatus.PENDING) .providerMessageId(normalizeOptional(command.providerMessageId())) diff --git a/src/main/java/com/gachi/be/domain/notification/service/PushNotificationClient.java b/src/main/java/com/gachi/be/domain/notification/service/PushNotificationClient.java new file mode 100644 index 0000000..481c111 --- /dev/null +++ b/src/main/java/com/gachi/be/domain/notification/service/PushNotificationClient.java @@ -0,0 +1,14 @@ +package com.gachi.be.domain.notification.service; + +import com.gachi.be.domain.notification.entity.Notification; +import com.gachi.be.domain.notification.entity.PushDeviceToken; +import java.util.Map; + +/** 외부 푸시 provider별 발송 구현체가 맞춰야 하는 공통 계약. */ +public interface PushNotificationClient { + + String providerName(); + + PushSendResult send( + Notification notification, PushDeviceToken pushDeviceToken, Map payload); +} diff --git a/src/main/java/com/gachi/be/domain/notification/service/PushSendResult.java b/src/main/java/com/gachi/be/domain/notification/service/PushSendResult.java new file mode 100644 index 0000000..26bb3f9 --- /dev/null +++ b/src/main/java/com/gachi/be/domain/notification/service/PushSendResult.java @@ -0,0 +1,14 @@ +package com.gachi.be.domain.notification.service; + +/** 외부 푸시 provider 발송 결과를 delivery log 정책에 맞게 표현한다. */ +public record PushSendResult( + boolean success, String providerMessageId, String failureReason, boolean invalidToken) { + + public static PushSendResult sent(String providerMessageId) { + return new PushSendResult(true, providerMessageId, null, false); + } + + public static PushSendResult failed(String failureReason, boolean invalidToken) { + return new PushSendResult(false, null, failureReason, invalidToken); + } +} diff --git a/src/main/java/com/gachi/be/global/config/external/ExternalApiConfig.java b/src/main/java/com/gachi/be/global/config/external/ExternalApiConfig.java index 640808a..3bd0d53 100644 --- a/src/main/java/com/gachi/be/global/config/external/ExternalApiConfig.java +++ b/src/main/java/com/gachi/be/global/config/external/ExternalApiConfig.java @@ -11,6 +11,7 @@ @EnableConfigurationProperties({ ClovaOcrProperties.class, PapagoProperties.class, - AiServerProperties.class + AiServerProperties.class, + NotificationPushProperties.class }) public class ExternalApiConfig {} diff --git a/src/main/java/com/gachi/be/global/config/external/NotificationPushProperties.java b/src/main/java/com/gachi/be/global/config/external/NotificationPushProperties.java new file mode 100644 index 0000000..9e8c3ed --- /dev/null +++ b/src/main/java/com/gachi/be/global/config/external/NotificationPushProperties.java @@ -0,0 +1,24 @@ +package com.gachi.be.global.config.external; + +import lombok.Getter; +import lombok.Setter; +import org.springframework.boot.context.properties.ConfigurationProperties; + +@Getter +@Setter +@ConfigurationProperties(prefix = "app.notification.push") +public class NotificationPushProperties { + + private boolean enabled = false; + private String provider = "expo"; + private int connectTimeoutSeconds = 5; + private int readTimeoutSeconds = 10; + private Expo expo = new Expo(); + + @Getter + @Setter + public static class Expo { + private String apiUrl = "https://exp.host/--/api/v2/push/send"; + private String accessToken = ""; + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 9641aef..4cab34e 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -69,3 +69,12 @@ app: base-url: ${AI_SERVER_BASE_URL:http://localhost:8000} connect-timeout-seconds: ${AI_SERVER_CONNECT_TIMEOUT_SECONDS:10} read-timeout-seconds: ${AI_SERVER_READ_TIMEOUT_SECONDS:120} + notification: + push: + enabled: ${NOTIFICATION_PUSH_ENABLED:false} + provider: ${NOTIFICATION_PUSH_PROVIDER:expo} + connect-timeout-seconds: ${NOTIFICATION_PUSH_CONNECT_TIMEOUT_SECONDS:5} + read-timeout-seconds: ${NOTIFICATION_PUSH_READ_TIMEOUT_SECONDS:10} + expo: + api-url: ${EXPO_PUSH_API_URL:https://exp.host/--/api/v2/push/send} + access-token: ${EXPO_PUSH_ACCESS_TOKEN:} diff --git a/src/main/resources/db/migration/V15__notification_delivery_provider.sql b/src/main/resources/db/migration/V15__notification_delivery_provider.sql new file mode 100644 index 0000000..e8c6fc3 --- /dev/null +++ b/src/main/resources/db/migration/V15__notification_delivery_provider.sql @@ -0,0 +1,5 @@ +ALTER TABLE notification_delivery_logs + ADD COLUMN IF NOT EXISTS provider VARCHAR(20) NOT NULL DEFAULT 'UNKNOWN'; + +CREATE INDEX IF NOT EXISTS idx_notification_delivery_logs_provider + ON notification_delivery_logs (provider, attempted_at DESC); diff --git a/src/test/java/com/gachi/be/domain/notification/service/NotificationPushDispatcherIntegrationTest.java b/src/test/java/com/gachi/be/domain/notification/service/NotificationPushDispatcherIntegrationTest.java new file mode 100644 index 0000000..cfc3f90 --- /dev/null +++ b/src/test/java/com/gachi/be/domain/notification/service/NotificationPushDispatcherIntegrationTest.java @@ -0,0 +1,155 @@ +package com.gachi.be.domain.notification.service; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.gachi.be.domain.notification.entity.Notification; +import com.gachi.be.domain.notification.entity.PushDeviceToken; +import com.gachi.be.domain.notification.entity.enums.NotificationDeliveryStatus; +import com.gachi.be.domain.notification.entity.enums.NotificationType; +import com.gachi.be.domain.notification.entity.enums.PushPlatform; +import com.gachi.be.domain.notification.repository.NotificationDeliveryLogRepository; +import com.gachi.be.domain.notification.repository.PushDeviceTokenRepository; +import com.gachi.be.domain.user.entity.User; +import com.gachi.be.domain.user.entity.enums.UserStatus; +import com.gachi.be.domain.user.repository.UserRepository; +import java.time.OffsetDateTime; +import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Primary; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.TestPropertySource; + +@SpringBootTest +@ActiveProfiles("test") +@TestPropertySource(properties = "app.notification.push.enabled=true") +class NotificationPushDispatcherIntegrationTest { + private static final AtomicInteger PHONE_SEQUENCE = new AtomicInteger(9000); + + @Autowired private UserRepository userRepository; + @Autowired private NotificationService notificationService; + @Autowired private PushDeviceTokenRepository pushDeviceTokenRepository; + @Autowired private NotificationDeliveryLogRepository deliveryLogRepository; + @Autowired private CapturingPushNotificationClient pushNotificationClient; + + @BeforeEach + void setUp() { + pushNotificationClient.reset(); + } + + @Test + void dispatchSendsPushAndRecordsDeliveryLog() { + User user = createActiveUser("push_success"); + registerToken(user, "ExpoPushToken[success]"); + Notification notification = createNotification(user, "push:success"); + + var logs = + deliveryLogRepository.findAllByNotificationIdOrderByAttemptedAtAsc(notification.getId()); + assertThat(logs).hasSize(1); + assertThat(logs.get(0).getStatus()).isEqualTo(NotificationDeliveryStatus.SENT); + assertThat(logs.get(0).getProvider()).isEqualTo("TEST"); + assertThat(logs.get(0).getProviderMessageId()).isEqualTo("ticket-1"); + } + + @Test + void dispatchSkipsWhenUserDisabledNotification() { + User user = createActiveUser("push_disabled"); + user.updateNotificationEnabled(false); + userRepository.saveAndFlush(user); + registerToken(user, "ExpoPushToken[disabled]"); + Notification notification = createNotification(user, "push:disabled"); + + var logs = + deliveryLogRepository.findAllByNotificationIdOrderByAttemptedAtAsc(notification.getId()); + assertThat(logs).hasSize(1); + assertThat(logs.get(0).getStatus()).isEqualTo(NotificationDeliveryStatus.SKIPPED); + assertThat(pushNotificationClient.sendCount).isZero(); + } + + @Test + void dispatchDisablesInvalidToken() { + User user = createActiveUser("push_invalid"); + PushDeviceToken token = registerToken(user, "ExpoPushToken[invalid]"); + pushNotificationClient.nextResult = PushSendResult.failed("Expo token is not registered", true); + Notification notification = createNotification(user, "push:invalid"); + + var logs = + deliveryLogRepository.findAllByNotificationIdOrderByAttemptedAtAsc(notification.getId()); + assertThat(logs).hasSize(1); + assertThat(logs.get(0).getStatus()).isEqualTo(NotificationDeliveryStatus.FAILED); + assertThat(pushDeviceTokenRepository.findById(token.getId())) + .get() + .extracting("enabled") + .isEqualTo(false); + } + + private Notification createNotification(User user, String dedupeKey) { + return notificationService.createNotification( + user.getId(), + new NotificationCreateCommand( + NotificationType.SYSTEM, "title", "body", Map.of("targetId", 1), dedupeKey)); + } + + private PushDeviceToken registerToken(User user, String token) { + notificationService.registerPushToken( + user.getId(), + new com.gachi.be.domain.notification.dto.request.PushTokenRegisterRequest( + PushPlatform.EXPO, token, "device-" + user.getId(), "1.0.0")); + return pushDeviceTokenRepository + .findAllByUserIdAndEnabledTrueAndDeletedAtIsNull(user.getId()) + .get(0); + } + + private User createActiveUser(String postfix) { + OffsetDateTime now = OffsetDateTime.now(); + return userRepository.saveAndFlush( + User.builder() + .name("parent-" + postfix) + .email(postfix + "@gachi.com") + .loginId("login_" + postfix) + .passwordHash("encoded-password") + .phoneNumber("0109999" + String.format("%04d", PHONE_SEQUENCE.getAndIncrement())) + .status(UserStatus.ACTIVE) + .emailVerifiedAt(now) + .consentAgreedAt(now) + .consentVersion("2026-04-v1") + .passwordUpdatedAt(now) + .build()); + } + + @TestConfiguration + static class PushClientConfig { + @Bean + @Primary + CapturingPushNotificationClient capturingPushNotificationClient() { + return new CapturingPushNotificationClient(); + } + } + + static class CapturingPushNotificationClient implements PushNotificationClient { + private PushSendResult nextResult = PushSendResult.sent("ticket-1"); + private int sendCount; + + @Override + public String providerName() { + return "TEST"; + } + + @Override + public PushSendResult send( + Notification notification, PushDeviceToken pushDeviceToken, Map payload) { + sendCount++; + return nextResult; + } + + void reset() { + nextResult = PushSendResult.sent("ticket-1"); + sendCount = 0; + } + } +} From acfede95821e21f2f136d2c04a59aae0d377c74f Mon Sep 17 00:00:00 2001 From: minju Date: Wed, 27 May 2026 01:18:48 +0900 Subject: [PATCH 8/8] =?UTF-8?q?fix:=20=ED=91=B8=EC=8B=9C=20=EB=B0=9C?= =?UTF-8?q?=EC=86=A1=20=EB=A6=AC=EB=B7=B0=20=ED=94=BC=EB=93=9C=EB=B0=B1=20?= =?UTF-8?q?=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/NotificationPushDispatcher.java | 45 ++++++++++++++++--- .../service/PushNotificationClient.java | 16 +++++++ .../external/NotificationPushProperties.java | 9 ++-- ...ficationPushDispatcherIntegrationTest.java | 9 ++-- 4 files changed, 67 insertions(+), 12 deletions(-) diff --git a/src/main/java/com/gachi/be/domain/notification/service/NotificationPushDispatcher.java b/src/main/java/com/gachi/be/domain/notification/service/NotificationPushDispatcher.java index 3985f7d..163401e 100644 --- a/src/main/java/com/gachi/be/domain/notification/service/NotificationPushDispatcher.java +++ b/src/main/java/com/gachi/be/domain/notification/service/NotificationPushDispatcher.java @@ -16,6 +16,7 @@ import java.util.Collections; import java.util.List; import java.util.Map; +import java.util.Objects; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; @@ -52,7 +53,16 @@ public void dispatch(NotificationCreatedEvent event) { return; } - User user = userRepository.findById(event.userId()).orElse(null); + Long targetUserId = notification.getUserId(); + if (!Objects.equals(event.userId(), targetUserId)) { + log.warn( + "[NotificationPush] 이벤트 userId와 알림 소유자 userId가 다릅니다. notificationId={}, eventUserId={}, notificationUserId={}", + event.notificationId(), + event.userId(), + targetUserId); + } + + User user = userRepository.findById(targetUserId).orElse(null); if (user == null) { saveSkipped(notification, null, "사용자를 찾을 수 없어 푸시 발송을 건너뜁니다."); return; @@ -71,7 +81,7 @@ public void dispatch(NotificationCreatedEvent event) { } List tokens = - pushDeviceTokenRepository.findAllByUserIdAndEnabledTrueAndDeletedAtIsNull(event.userId()); + pushDeviceTokenRepository.findAllByUserIdAndEnabledTrueAndDeletedAtIsNull(targetUserId); if (tokens.isEmpty()) { saveSkipped(notification, null, "활성 푸시 토큰이 없습니다."); return; @@ -93,7 +103,12 @@ private void dispatchToToken( PushSendResult result = pushNotificationClient.send(notification, token, payload); if (result.success()) { saveDeliveryLog( - notification, token, NotificationDeliveryStatus.SENT, result.providerMessageId(), null); + notification, + token, + NotificationDeliveryStatus.SENT, + pushNotificationClient.providerName(), + result.providerMessageId(), + null); return; } @@ -101,17 +116,29 @@ private void dispatchToToken( token.softDelete(); } saveDeliveryLog( - notification, token, NotificationDeliveryStatus.FAILED, null, result.failureReason()); + notification, + token, + NotificationDeliveryStatus.FAILED, + pushNotificationClient.providerName(), + null, + result.failureReason()); } private void saveSkipped(Notification notification, PushDeviceToken token, String failureReason) { - saveDeliveryLog(notification, token, NotificationDeliveryStatus.SKIPPED, null, failureReason); + saveDeliveryLog( + notification, + token, + NotificationDeliveryStatus.SKIPPED, + resolveConfiguredProvider(), + null, + failureReason); } private void saveDeliveryLog( Notification notification, PushDeviceToken token, NotificationDeliveryStatus status, + String provider, String providerMessageId, String failureReason) { deliveryLogRepository.save( @@ -119,12 +146,18 @@ private void saveDeliveryLog( .notification(notification) .pushDeviceToken(token) .status(status) - .provider(pushNotificationClient.providerName()) + .provider(provider) .providerMessageId(providerMessageId) .failureReason(failureReason) .build()); } + private String resolveConfiguredProvider() { + return StringUtils.hasText(properties.getProvider()) + ? properties.getProvider().trim().toUpperCase() + : "UNKNOWN"; + } + private Map deserializePayload(String payloadJson) { if (!StringUtils.hasText(payloadJson)) { return Collections.emptyMap(); diff --git a/src/main/java/com/gachi/be/domain/notification/service/PushNotificationClient.java b/src/main/java/com/gachi/be/domain/notification/service/PushNotificationClient.java index 481c111..3f70757 100644 --- a/src/main/java/com/gachi/be/domain/notification/service/PushNotificationClient.java +++ b/src/main/java/com/gachi/be/domain/notification/service/PushNotificationClient.java @@ -7,8 +7,24 @@ /** 외부 푸시 provider별 발송 구현체가 맞춰야 하는 공통 계약. */ public interface PushNotificationClient { + /** + * 푸시 제공자 식별자를 반환한다. + * + * @return delivery log provider 컬럼에 저장할 식별자. 예: {@code EXPO}, {@code FCM} + */ String providerName(); + /** + * 단일 디바이스 토큰으로 푸시 알림을 발송한다. + * + *

구현체는 provider 응답을 {@link PushSendResult}로 변환해야 하며, 호출자가 delivery log를 남길 수 있도록 복구 가능한 발송 실패는 + * 예외 대신 실패 결과로 반환하는 것을 기본 계약으로 한다. + * + * @param notification 발송할 알림 엔티티 + * @param pushDeviceToken 대상 디바이스 토큰 + * @param payload 앱에서 알림 클릭 후 라우팅 등에 사용할 추가 데이터 + * @return 성공/실패, provider 메시지 ID, 토큰 무효화 여부를 포함한 발송 결과 + */ PushSendResult send( Notification notification, PushDeviceToken pushDeviceToken, Map payload); } diff --git a/src/main/java/com/gachi/be/global/config/external/NotificationPushProperties.java b/src/main/java/com/gachi/be/global/config/external/NotificationPushProperties.java index 9e8c3ed..b989aef 100644 --- a/src/main/java/com/gachi/be/global/config/external/NotificationPushProperties.java +++ b/src/main/java/com/gachi/be/global/config/external/NotificationPushProperties.java @@ -1,24 +1,27 @@ package com.gachi.be.global.config.external; +import jakarta.validation.constraints.Positive; import lombok.Getter; import lombok.Setter; import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.validation.annotation.Validated; @Getter @Setter +@Validated @ConfigurationProperties(prefix = "app.notification.push") public class NotificationPushProperties { private boolean enabled = false; private String provider = "expo"; - private int connectTimeoutSeconds = 5; - private int readTimeoutSeconds = 10; + @Positive private int connectTimeoutSeconds = 5; + @Positive private int readTimeoutSeconds = 10; private Expo expo = new Expo(); @Getter @Setter public static class Expo { private String apiUrl = "https://exp.host/--/api/v2/push/send"; - private String accessToken = ""; + private String accessToken; } } diff --git a/src/test/java/com/gachi/be/domain/notification/service/NotificationPushDispatcherIntegrationTest.java b/src/test/java/com/gachi/be/domain/notification/service/NotificationPushDispatcherIntegrationTest.java index cfc3f90..8f32365 100644 --- a/src/test/java/com/gachi/be/domain/notification/service/NotificationPushDispatcherIntegrationTest.java +++ b/src/test/java/com/gachi/be/domain/notification/service/NotificationPushDispatcherIntegrationTest.java @@ -54,6 +54,7 @@ void dispatchSendsPushAndRecordsDeliveryLog() { assertThat(logs.get(0).getStatus()).isEqualTo(NotificationDeliveryStatus.SENT); assertThat(logs.get(0).getProvider()).isEqualTo("TEST"); assertThat(logs.get(0).getProviderMessageId()).isEqualTo("ticket-1"); + assertThat(pushNotificationClient.sendCount).isEqualTo(1); } @Test @@ -68,6 +69,7 @@ void dispatchSkipsWhenUserDisabledNotification() { deliveryLogRepository.findAllByNotificationIdOrderByAttemptedAtAsc(notification.getId()); assertThat(logs).hasSize(1); assertThat(logs.get(0).getStatus()).isEqualTo(NotificationDeliveryStatus.SKIPPED); + assertThat(logs.get(0).getProvider()).isEqualTo("EXPO"); assertThat(pushNotificationClient.sendCount).isZero(); } @@ -100,9 +102,10 @@ private PushDeviceToken registerToken(User user, String token) { user.getId(), new com.gachi.be.domain.notification.dto.request.PushTokenRegisterRequest( PushPlatform.EXPO, token, "device-" + user.getId(), "1.0.0")); - return pushDeviceTokenRepository - .findAllByUserIdAndEnabledTrueAndDeletedAtIsNull(user.getId()) - .get(0); + var tokens = + pushDeviceTokenRepository.findAllByUserIdAndEnabledTrueAndDeletedAtIsNull(user.getId()); + assertThat(tokens).hasSize(1); + return tokens.get(0); } private User createActiveUser(String postfix) {