diff --git a/build.gradle b/build.gradle index 5379312..cc423ad 100644 --- a/build.gradle +++ b/build.gradle @@ -40,7 +40,9 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-actuator' // Firebase Admin SDK - implementation 'com.google.firebase:firebase-admin:9.7.0' + implementation("com.google.firebase:firebase-admin:9.7.0") { + exclude group: "commons-logging", module: "commons-logging" + } // Spring Security implementation 'org.springframework.boot:spring-boot-starter-security' diff --git a/src/main/java/me/pinitnotification/PinitNotificationApplication.java b/src/main/java/me/pinitnotification/PinitNotificationApplication.java index e4f9178..24c950b 100644 --- a/src/main/java/me/pinitnotification/PinitNotificationApplication.java +++ b/src/main/java/me/pinitnotification/PinitNotificationApplication.java @@ -2,10 +2,12 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.data.jpa.repository.config.EnableJpaAuditing; import org.springframework.scheduling.annotation.EnableScheduling; @SpringBootApplication @EnableScheduling +@EnableJpaAuditing public class PinitNotificationApplication { public static void main(String[] args) { diff --git a/src/main/java/me/pinitnotification/application/push/PushService.java b/src/main/java/me/pinitnotification/application/push/PushService.java index 18a02b3..284ea16 100644 --- a/src/main/java/me/pinitnotification/application/push/PushService.java +++ b/src/main/java/me/pinitnotification/application/push/PushService.java @@ -4,7 +4,11 @@ public interface PushService { String getVapidPublicKey(); - void subscribe(Long memberId, String token); - void unsubscribe(Long memberId, String token); + + void subscribe(Long memberId, String deviceId, String token); + + void unsubscribe(Long memberId, String deviceId, String token); void sendPushMessage(String token, Notification notification); + + boolean isSubscribed(Long memberId, String deviceId); } diff --git a/src/main/java/me/pinitnotification/domain/push/PushSubscription.java b/src/main/java/me/pinitnotification/domain/push/PushSubscription.java index 4097b17..3d0353b 100644 --- a/src/main/java/me/pinitnotification/domain/push/PushSubscription.java +++ b/src/main/java/me/pinitnotification/domain/push/PushSubscription.java @@ -2,29 +2,48 @@ import jakarta.persistence.*; import lombok.Getter; +import org.springframework.data.annotation.LastModifiedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import java.time.Instant; @Entity @Table( uniqueConstraints = { @UniqueConstraint( - name = "uk_token_memberId", - columnNames = {"member_id", "token"} + name = "uk_deviceId_memberId", + columnNames = {"member_id", "device_id"} ) } ) +@EntityListeners(AuditingEntityListener.class) @Getter public class PushSubscription { @Id @GeneratedValue private Long id; - @Column(name="token", nullable = false) - private String token; - @Column(name="member_id", nullable = false) + @Column(name = "member_id", nullable = false) private Long memberId; + @Column(name = "device_id", nullable = false) + private String deviceId; + @Column(name = "token", nullable = false) + private String token; + @LastModifiedDate + @Column(name = "modified_at", nullable = false) + private Instant modifiedAt; protected PushSubscription() {} - public PushSubscription(Long memberId, String token) { + + public PushSubscription(Long memberId, String deviceId, String token) { this.memberId = memberId; + this.deviceId = deviceId; + this.token = token; + } + + public void updateToken(String token) { + if (this.modifiedAt.isAfter(Instant.now())) { + return; + } this.token = token; } } diff --git a/src/main/java/me/pinitnotification/domain/push/PushSubscriptionRepository.java b/src/main/java/me/pinitnotification/domain/push/PushSubscriptionRepository.java index 9e27446..8ff593c 100644 --- a/src/main/java/me/pinitnotification/domain/push/PushSubscriptionRepository.java +++ b/src/main/java/me/pinitnotification/domain/push/PushSubscriptionRepository.java @@ -3,9 +3,14 @@ import org.springframework.data.jpa.repository.JpaRepository; import java.util.List; +import java.util.Optional; public interface PushSubscriptionRepository extends JpaRepository { + Optional findByMemberIdAndDeviceId(Long memberId, String deviceId); + List findAllByMemberId(Long memberId); void deleteByToken(String token); + + void deleteByMemberIdAndDeviceId(Long memberId, String deviceId); } diff --git a/src/main/java/me/pinitnotification/infrastructure/fcm/FcmService.java b/src/main/java/me/pinitnotification/infrastructure/fcm/FcmService.java index 54d9b9b..6b2fcab 100644 --- a/src/main/java/me/pinitnotification/infrastructure/fcm/FcmService.java +++ b/src/main/java/me/pinitnotification/infrastructure/fcm/FcmService.java @@ -13,6 +13,8 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.util.Optional; + @Slf4j @Service public class FcmService implements PushService { @@ -44,6 +46,11 @@ public void sendPushMessage(String token, Notification notification) { } } + @Override + public boolean isSubscribed(Long memberId, String deviceId) { + return pushSubscriptionRepository.findByMemberIdAndDeviceId(memberId, deviceId).isPresent(); + } + @Override public String getVapidPublicKey() { return vapidPublicKey; @@ -51,13 +58,19 @@ public String getVapidPublicKey() { @Override @Transactional - public void subscribe(Long memberId, String token) { - pushSubscriptionRepository.save(new PushSubscription(memberId, token)); + public void subscribe(Long memberId, String deviceId, String token) { + Optional byMemberIdAndDeviceId = pushSubscriptionRepository.findByMemberIdAndDeviceId(memberId, deviceId); + if (byMemberIdAndDeviceId.isPresent()) { + PushSubscription existingSubscription = byMemberIdAndDeviceId.get(); + existingSubscription.updateToken(token); + } else { + pushSubscriptionRepository.save(new PushSubscription(memberId, deviceId, token)); + } } @Override @Transactional - public void unsubscribe(Long memberId, String token) { - pushSubscriptionRepository.delete(new PushSubscription(memberId, token)); + public void unsubscribe(Long memberId, String deviceId, String token) { + pushSubscriptionRepository.deleteByMemberIdAndDeviceId(memberId, deviceId); } } diff --git a/src/main/java/me/pinitnotification/interfaces/notification/PushNotificationController.java b/src/main/java/me/pinitnotification/interfaces/notification/PushNotificationController.java index 7ca8caf..a383d4e 100644 --- a/src/main/java/me/pinitnotification/interfaces/notification/PushNotificationController.java +++ b/src/main/java/me/pinitnotification/interfaces/notification/PushNotificationController.java @@ -1,7 +1,5 @@ package me.pinitnotification.interfaces.notification; -import me.pinitnotification.application.push.PushService; -import me.pinitnotification.domain.member.MemberId; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.media.Content; @@ -9,12 +7,10 @@ import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.responses.ApiResponses; import io.swagger.v3.oas.annotations.tags.Tag; +import me.pinitnotification.application.push.PushService; +import me.pinitnotification.domain.member.MemberId; import me.pinitnotification.interfaces.notification.dto.PushTokenRequest; -import org.springframework.web.bind.annotation.GetMapping; -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.RestController; +import org.springframework.web.bind.annotation.*; @RestController @RequestMapping("/push") @@ -39,6 +35,21 @@ public String getVapidPublicKey() { return pushService.getVapidPublicKey(); } + @GetMapping("/subscribed") + @Operation( + summary = "푸시 구독 상태 조회", + description = "인증된 회원이 푸시 알림을 구독 중인지 여부를 반환합니다." + ) + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "구독 상태 조회 완료", + content = @Content(mediaType = "text/plain", schema = @Schema(implementation = Boolean.class))) + }) + public boolean isSubscribed( + @Parameter(hidden = true) @MemberId Long memberId, + @RequestParam String deviceId) { + return pushService.isSubscribed(memberId, deviceId); + } + @PostMapping("/subscribe") @Operation( summary = "푸시 토큰 구독 등록", @@ -51,7 +62,7 @@ public String getVapidPublicKey() { public void subscribe( @Parameter(hidden = true) @MemberId Long memberId, @RequestBody PushTokenRequest request) { - pushService.subscribe(memberId, request.token()); + pushService.subscribe(memberId, request.deviceId(), request.token()); } @PostMapping("/unsubscribe") @@ -66,7 +77,7 @@ public void subscribe( public void unsubscribe( @Parameter(hidden = true) @MemberId Long memberId, @RequestBody PushTokenRequest request) { - pushService.unsubscribe(memberId, request.token()); + pushService.unsubscribe(memberId, request.deviceId(), request.token()); } diff --git a/src/main/java/me/pinitnotification/interfaces/notification/dto/PushTokenRequest.java b/src/main/java/me/pinitnotification/interfaces/notification/dto/PushTokenRequest.java index 7ff8f97..082fd83 100644 --- a/src/main/java/me/pinitnotification/interfaces/notification/dto/PushTokenRequest.java +++ b/src/main/java/me/pinitnotification/interfaces/notification/dto/PushTokenRequest.java @@ -4,6 +4,8 @@ @Schema(description = "푸시 토큰 요청 바디") public record PushTokenRequest( + @Schema(description = "사용자의 디바이스 식별자. UUID 형식으로 제공됩니다.", example = "123e4567-e89b-12d3-a456-426614174000") + String deviceId, @Schema(description = "클라이언트에서 발급받은 FCM 푸시 토큰", example = "fcm-token-example") String token ) { diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 13b2f52..5fca049 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -2,7 +2,7 @@ vapid: keys: - public: BA7L1jqFAyxo_KZZZBkUeqC1uY6iSP1NvyNVqV-L_dmqEnHA__5cNs87WJ5QDiH8UC3hgLQu99mbbxlwZZf7J4U + public: BF8QQIULasLr94n0l0xbv43yZeNICudM5lpQN08VYn2g5VjBPU0wM98HypyRmEb-y0ARRsiZ_wcgSMIC-nq-x20 private: ${VAPID_PRIVATE_KEY} diff --git a/src/test/java/me/pinitnotification/application/notification/NotificationDispatchSchedulerTest.java b/src/test/java/me/pinitnotification/application/notification/NotificationDispatchSchedulerTest.java index addfac0..278ae35 100644 --- a/src/test/java/me/pinitnotification/application/notification/NotificationDispatchSchedulerTest.java +++ b/src/test/java/me/pinitnotification/application/notification/NotificationDispatchSchedulerTest.java @@ -16,12 +16,7 @@ import java.time.ZoneOffset; import java.util.List; -import static org.mockito.Mockito.any; -import static org.mockito.Mockito.anyString; -import static org.mockito.Mockito.eq; -import static org.mockito.Mockito.never; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; +import static org.mockito.Mockito.*; @ExtendWith(MockitoExtension.class) class NotificationDispatchSchedulerTest { @@ -49,7 +44,7 @@ void dispatchDueNotifications_sendsAndDeletesPastNotifications() { when(notificationRepository.findAll()).thenReturn(List.of(past, future)); when(pushSubscriptionRepository.findAllByMemberId(1L)) - .thenReturn(List.of(new PushSubscription(1L, "token-1"), new PushSubscription(1L, "token-2"))); + .thenReturn(List.of(new PushSubscription(1L, "device-1", "token-1"), new PushSubscription(1L, "device-2", "token-2"))); scheduler.dispatchDueNotifications();