From 5594d2cca553299f81cc8fd70a1a72a3eebae98f Mon Sep 17 00:00:00 2001 From: pizzazoa Date: Sat, 20 Jun 2026 14:53:21 +0900 Subject: [PATCH 1/3] =?UTF-8?q?feat:=20=EB=A1=9C=EA=B7=B8=EC=95=84?= =?UTF-8?q?=EC=9B=83=20=EC=97=94=EB=93=9C=ED=8F=AC=EC=9D=B8=ED=8A=B8=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/auth/api/AuthController.java | 60 ++++++++++++++++++- .../auth/api/dto/request/LogoutRequest.java | 17 ++++++ .../auth/application/LogoutService.java | 54 +++++++++++++++++ 3 files changed, 128 insertions(+), 3 deletions(-) create mode 100644 src/main/java/org/devkor/apu/saerok_server/domain/auth/api/dto/request/LogoutRequest.java create mode 100644 src/main/java/org/devkor/apu/saerok_server/domain/auth/application/LogoutService.java diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/auth/api/AuthController.java b/src/main/java/org/devkor/apu/saerok_server/domain/auth/api/AuthController.java index 40d18a11..1f5176c2 100644 --- a/src/main/java/org/devkor/apu/saerok_server/domain/auth/api/AuthController.java +++ b/src/main/java/org/devkor/apu/saerok_server/domain/auth/api/AuthController.java @@ -5,18 +5,22 @@ import io.swagger.v3.oas.annotations.media.Content; import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.annotation.security.PermitAll; import jakarta.servlet.http.HttpServletRequest; import lombok.RequiredArgsConstructor; import org.devkor.apu.saerok_server.domain.auth.api.dto.request.AppleLoginRequest; import org.devkor.apu.saerok_server.domain.auth.api.dto.request.KakaoLoginRequest; +import org.devkor.apu.saerok_server.domain.auth.api.dto.request.LogoutRequest; import org.devkor.apu.saerok_server.domain.auth.api.dto.request.RefreshRequest; import org.devkor.apu.saerok_server.domain.auth.api.dto.response.AccessTokenResponse; import org.devkor.apu.saerok_server.domain.auth.application.AppleLoginService; import org.devkor.apu.saerok_server.domain.auth.application.LoginResult; +import org.devkor.apu.saerok_server.domain.auth.application.LogoutService; import org.devkor.apu.saerok_server.domain.auth.application.KakaoLoginService; import org.devkor.apu.saerok_server.domain.auth.application.TokenRefreshService; +import org.devkor.apu.saerok_server.global.security.principal.UserPrincipal; import org.devkor.apu.saerok_server.global.security.token.RefreshTokenProvider; import org.devkor.apu.saerok_server.global.shared.exception.UnauthorizedException; import org.devkor.apu.saerok_server.global.shared.util.ClientInfoExtractor; @@ -25,6 +29,8 @@ import org.springframework.http.HttpHeaders; import org.springframework.http.ResponseCookie; import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; @Tag(name = "Auth API", description = "소셜 인증 관련 API") @@ -33,10 +39,14 @@ @RequestMapping("${api_prefix}/auth/") public class AuthController { + private static final String REFRESH_TOKEN_COOKIE_NAME = "refreshToken"; + private static final String REFRESH_TOKEN_COOKIE_PATH = "/api/v1/auth/refresh"; + private final AppleLoginService appleAuthService; private final KakaoLoginService kakaoAuthService; private final TokenRefreshService tokenRefreshService; private final ClientInfoExtractor clientInfoExtractor; + private final LogoutService logoutService; @Value("${app.cookie.secure}") private boolean isCookieSecure; @@ -169,7 +179,7 @@ public ResponseEntity kakaoLogin( ) public ResponseEntity refresh( @Parameter(hidden = true) - @CookieValue(name = "refreshToken", required = false) String refreshTokenCookie, + @CookieValue(name = REFRESH_TOKEN_COOKIE_NAME, required = false) String refreshTokenCookie, @io.swagger.v3.oas.annotations.parameters.RequestBody( description = "쿠키에 리프레시 토큰이 없을 때, JSON 바디로 전달된 리프레시 토큰 " + "(iOS App에서 요청할 때 쓰면 편리. 웹 브라우저는 알아서 쿠키를 서버와 주고받으므로 이것을 사용할 필요 없음)" @@ -187,6 +197,40 @@ public ResponseEntity refresh( return toAuthResponse(loginResult); } + @PostMapping("/logout") + @PreAuthorize("isAuthenticated()") + @Operation( + summary = "로그아웃", + security = @SecurityRequirement(name = "bearerAuth"), + description = """ + 현재 인증된 사용자의 로그아웃을 처리합니다.
+ refreshToken이 요청에 포함되면 해당 세션을 revoke하고, + deviceId가 포함되면 현재 디바이스의 푸시 토큰도 삭제합니다.
+ 모바일 앱은 refreshTokenJson을 사용할 수 있고, + refreshToken 쿠키가 요청에 포함된 경우에도 해당 값을 사용합니다. + """, + responses = { + @ApiResponse(responseCode = "204", description = "로그아웃 완료"), + @ApiResponse(responseCode = "401", description = "인증 실패", content = @Content) + } + ) + public ResponseEntity logout( + @AuthenticationPrincipal UserPrincipal userPrincipal, + @Parameter(hidden = true) + @CookieValue(name = REFRESH_TOKEN_COOKIE_NAME, required = false) String refreshTokenCookie, + @RequestBody(required = false) LogoutRequest request + ) { + LogoutRequest body = request != null ? request : new LogoutRequest(null, null, null); + String refreshToken = refreshTokenCookie != null ? refreshTokenCookie : body.refreshTokenJson(); + + logoutService.logout(userPrincipal.getId(), refreshToken, body.deviceId(), body.platform()); + + return ResponseEntity + .noContent() + .header(HttpHeaders.SET_COOKIE, clearRefreshTokenCookie().toString()) + .build(); + } + /** * AuthResult(비즈니스 결과)를 HTTP 응답(ResponseEntity + 쿠키)로 매핑. */ @@ -204,12 +248,22 @@ private ResponseEntity toAuthResponse(LoginResult loginResu } private ResponseCookie createRefreshTokenCookie(String refreshToken) { - return ResponseCookie.from("refreshToken", refreshToken) + return ResponseCookie.from(REFRESH_TOKEN_COOKIE_NAME, refreshToken) .httpOnly(true) .secure(isCookieSecure) .sameSite("Lax") - .path("/api/v1/auth/refresh") + .path(REFRESH_TOKEN_COOKIE_PATH) .maxAge(RefreshTokenProvider.validDuration) .build(); } + + private ResponseCookie clearRefreshTokenCookie() { + return ResponseCookie.from(REFRESH_TOKEN_COOKIE_NAME, "") + .httpOnly(true) + .secure(isCookieSecure) + .sameSite("Lax") + .path(REFRESH_TOKEN_COOKIE_PATH) + .maxAge(0) + .build(); + } } diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/auth/api/dto/request/LogoutRequest.java b/src/main/java/org/devkor/apu/saerok_server/domain/auth/api/dto/request/LogoutRequest.java new file mode 100644 index 00000000..62a3abde --- /dev/null +++ b/src/main/java/org/devkor/apu/saerok_server/domain/auth/api/dto/request/LogoutRequest.java @@ -0,0 +1,17 @@ +package org.devkor.apu.saerok_server.domain.auth.api.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import org.devkor.apu.saerok_server.domain.notification.core.entity.DevicePlatform; + +@Schema(description = "로그아웃 요청 DTO") +public record LogoutRequest( + @Schema(description = "쿠키 대신 JSON Body로 전달하는 Refresh Token (모바일 앱용)") + String refreshTokenJson, + + @Schema(description = "현재 로그아웃하는 디바이스 식별자") + String deviceId, + + @Schema(description = "현재 로그아웃하는 디바이스 플랫폼", example = "IOS") + DevicePlatform platform +) { +} diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/auth/application/LogoutService.java b/src/main/java/org/devkor/apu/saerok_server/domain/auth/application/LogoutService.java new file mode 100644 index 00000000..34d0b87a --- /dev/null +++ b/src/main/java/org/devkor/apu/saerok_server/domain/auth/application/LogoutService.java @@ -0,0 +1,54 @@ +package org.devkor.apu.saerok_server.domain.auth.application; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.devkor.apu.saerok_server.domain.auth.core.repository.UserRefreshTokenRepository; +import org.devkor.apu.saerok_server.domain.notification.core.entity.DevicePlatform; +import org.devkor.apu.saerok_server.domain.notification.core.repository.UserDeviceRepository; +import org.devkor.apu.saerok_server.global.security.token.RefreshTokenProvider; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Slf4j +@Service +@Transactional +@RequiredArgsConstructor +public class LogoutService { + + private final RefreshTokenProvider refreshTokenProvider; + private final UserRefreshTokenRepository userRefreshTokenRepository; + private final UserDeviceRepository userDeviceRepository; + + public void logout(Long userId, String refreshToken, String deviceId, DevicePlatform platform) { + revokeRefreshToken(userId, refreshToken); + deleteCurrentDevice(userId, deviceId, platform); + } + + private void revokeRefreshToken(Long userId, String refreshToken) { + if (refreshToken == null || refreshToken.isBlank()) { + return; + } + + userRefreshTokenRepository.findByRefreshTokenHash(refreshTokenProvider.hash(refreshToken)) + .ifPresent(token -> { + if (!token.getUser().getId().equals(userId)) { + log.warn("리프레시 토큰 소유주가 일치하지 않습니다: authenticatedUserId={}, tokenUserId={}", + userId, token.getUser().getId()); + return; + } + + if (token.getRevokedAt() == null) { + token.revoke(); + } + }); + } + + private void deleteCurrentDevice(Long userId, String deviceId, DevicePlatform platform) { + if (deviceId == null || deviceId.isBlank()) { + return; + } + + DevicePlatform resolvedPlatform = platform != null ? platform : DevicePlatform.IOS; + userDeviceRepository.deleteByUserIdAndDeviceIdAndPlatform(userId, deviceId, resolvedPlatform); + } +} From 3b7268528273db6958efb3b86c6bd243cb9df5e4 Mon Sep 17 00:00:00 2001 From: pizzazoa Date: Sat, 20 Jun 2026 15:07:41 +0900 Subject: [PATCH 2/3] =?UTF-8?q?feat:=20=EB=94=94=EB=B0=94=EC=9D=B4?= =?UTF-8?q?=EC=8A=A4=20=ED=86=A0=ED=81=B0=20=EC=A0=95=EB=A6=AC=20=EB=A1=9C?= =?UTF-8?q?=EC=A7=81=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/UserDeviceCommandService.java | 19 +++ .../core/repository/UserDeviceRepository.java | 20 ++++ .../infra/fcm/FcmMessageClient.java | 6 +- .../auth/application/LogoutServiceTest.java | 111 ++++++++++++++++++ .../repository/UserDeviceRepositoryTest.java | 49 ++++++++ 5 files changed, 202 insertions(+), 3 deletions(-) create mode 100644 src/test/java/org/devkor/apu/saerok_server/domain/auth/application/LogoutServiceTest.java diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/notification/application/UserDeviceCommandService.java b/src/main/java/org/devkor/apu/saerok_server/domain/notification/application/UserDeviceCommandService.java index 05589ba2..b41a5689 100644 --- a/src/main/java/org/devkor/apu/saerok_server/domain/notification/application/UserDeviceCommandService.java +++ b/src/main/java/org/devkor/apu/saerok_server/domain/notification/application/UserDeviceCommandService.java @@ -16,6 +16,8 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.util.List; + @Service @Transactional @RequiredArgsConstructor @@ -38,6 +40,13 @@ public RegisterUserDeviceResponse registerUserDevice(RegisterUserDeviceCommand c DevicePlatform platform = command.platform() != null ? command.platform() : DevicePlatform.IOS; + userDeviceRepository.deleteConflictingDevicesForRegistration( + command.userId(), + command.deviceId(), + platform, + command.token() + ); + UserDevice userDevice = userDeviceRepository .findByUserIdAndDeviceIdAndPlatform(command.userId(), command.deviceId(), platform) .map(existing -> { @@ -71,4 +80,14 @@ public void deleteAllTokens(Long userId) { notificationSettingRepository.deleteByUserId(userId); userDeviceRepository.deleteByUserId(userId); } + + public void deleteInvalidTokens(List tokens) { + if (tokens == null || tokens.isEmpty()) { + return; + } + + tokens.stream() + .distinct() + .forEach(userDeviceRepository::deleteByToken); + } } diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/notification/core/repository/UserDeviceRepository.java b/src/main/java/org/devkor/apu/saerok_server/domain/notification/core/repository/UserDeviceRepository.java index e88486d7..636d2461 100644 --- a/src/main/java/org/devkor/apu/saerok_server/domain/notification/core/repository/UserDeviceRepository.java +++ b/src/main/java/org/devkor/apu/saerok_server/domain/notification/core/repository/UserDeviceRepository.java @@ -42,6 +42,26 @@ public void deleteByToken(String token) { .executeUpdate(); } + public int deleteConflictingDevicesForRegistration(Long userId, String deviceId, DevicePlatform platform, String token) { + return em.createQuery(""" + DELETE FROM UserDevice ud + WHERE ( + ud.token = :token + OR (ud.deviceId = :deviceId AND ud.platform = :platform) + ) + AND NOT ( + ud.user.id = :userId + AND ud.deviceId = :deviceId + AND ud.platform = :platform + ) + """) + .setParameter("userId", userId) + .setParameter("deviceId", deviceId) + .setParameter("platform", platform) + .setParameter("token", token) + .executeUpdate(); + } + // ID로 디바이스 조회 public Optional findById(Long id) { List results = em.createQuery("SELECT ud FROM UserDevice ud WHERE ud.id = :id", UserDevice.class) diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/notification/infra/fcm/FcmMessageClient.java b/src/main/java/org/devkor/apu/saerok_server/domain/notification/infra/fcm/FcmMessageClient.java index 2f7f238d..a6c23ec4 100644 --- a/src/main/java/org/devkor/apu/saerok_server/domain/notification/infra/fcm/FcmMessageClient.java +++ b/src/main/java/org/devkor/apu/saerok_server/domain/notification/infra/fcm/FcmMessageClient.java @@ -3,8 +3,8 @@ import com.google.firebase.messaging.*; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.devkor.apu.saerok_server.domain.notification.application.UserDeviceCommandService; import org.devkor.apu.saerok_server.domain.notification.application.dto.PushMessageCommand; -import org.devkor.apu.saerok_server.domain.notification.core.repository.UserDeviceRepository; import org.springframework.scheduling.annotation.Async; import org.springframework.stereotype.Component; @@ -19,7 +19,7 @@ public class FcmMessageClient { private final FirebaseMessaging firebaseMessaging; - private final UserDeviceRepository userDeviceRepository; + private final UserDeviceCommandService userDeviceCommandService; @Async("pushNotificationExecutor") public void sendToDevices(List fcmTokens, PushMessageCommand cmd) { @@ -147,7 +147,7 @@ private void cleanupInvalidTokens(List fcmTokens, BatchResponse response if (!invalid.isEmpty()) { try { - invalid.forEach(userDeviceRepository::deleteByToken); + userDeviceCommandService.deleteInvalidTokens(invalid); } catch (Exception e) { log.warn("Invalid FCM tokens cleanup failed ({} tokens): {}", invalid.size(), e.getMessage()); } diff --git a/src/test/java/org/devkor/apu/saerok_server/domain/auth/application/LogoutServiceTest.java b/src/test/java/org/devkor/apu/saerok_server/domain/auth/application/LogoutServiceTest.java new file mode 100644 index 00000000..2a1cca74 --- /dev/null +++ b/src/test/java/org/devkor/apu/saerok_server/domain/auth/application/LogoutServiceTest.java @@ -0,0 +1,111 @@ +package org.devkor.apu.saerok_server.domain.auth.application; + +import org.devkor.apu.saerok_server.domain.auth.core.entity.UserRefreshToken; +import org.devkor.apu.saerok_server.domain.auth.core.repository.UserRefreshTokenRepository; +import org.devkor.apu.saerok_server.domain.notification.core.entity.DevicePlatform; +import org.devkor.apu.saerok_server.domain.notification.core.repository.UserDeviceRepository; +import org.devkor.apu.saerok_server.domain.user.core.entity.User; +import org.devkor.apu.saerok_server.global.security.token.RefreshTokenProvider; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; + +import java.time.Duration; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.verifyNoMoreInteractions; + +@ExtendWith(MockitoExtension.class) +class LogoutServiceTest { + + @Mock RefreshTokenProvider refreshTokenProvider; + @Mock UserRefreshTokenRepository userRefreshTokenRepository; + @Mock UserDeviceRepository userDeviceRepository; + + @InjectMocks LogoutService logoutService; + + @Test + @DisplayName("로그아웃 시 현재 유저 refresh token을 revoke하고 현재 디바이스 토큰을 삭제한다") + void logout_revokesRefreshTokenAndDeletesDevice() { + UserRefreshToken token = refreshTokenFor(user(42L), "refresh-hash"); + given(refreshTokenProvider.hash("raw-refresh")).willReturn("refresh-hash"); + given(userRefreshTokenRepository.findByRefreshTokenHash("refresh-hash")).willReturn(Optional.of(token)); + + logoutService.logout(42L, "raw-refresh", "device-1", DevicePlatform.IOS); + + assertThat(token.getRevokedAt()).isNotNull(); + verify(userDeviceRepository).deleteByUserIdAndDeviceIdAndPlatform(42L, "device-1", DevicePlatform.IOS); + } + + @Test + @DisplayName("refresh token이 이미 revoke되어 있으면 다시 revoke하지 않고 디바이스만 정리한다") + void logout_alreadyRevokedRefreshToken() { + UserRefreshToken token = refreshTokenFor(user(42L), "refresh-hash"); + token.revoke(); + var revokedAt = token.getRevokedAt(); + given(refreshTokenProvider.hash("raw-refresh")).willReturn("refresh-hash"); + given(userRefreshTokenRepository.findByRefreshTokenHash("refresh-hash")).willReturn(Optional.of(token)); + + logoutService.logout(42L, "raw-refresh", "device-1", null); + + assertThat(token.getRevokedAt()).isEqualTo(revokedAt); + verify(userDeviceRepository).deleteByUserIdAndDeviceIdAndPlatform(42L, "device-1", DevicePlatform.IOS); + } + + @Test + @DisplayName("refresh token의 유저가 달라도 해당 토큰은 무시하고 디바이스 정리는 계속한다") + void logout_refreshTokenOwnerMismatch() { + UserRefreshToken token = refreshTokenFor(user(99L), "refresh-hash"); + given(refreshTokenProvider.hash("raw-refresh")).willReturn("refresh-hash"); + given(userRefreshTokenRepository.findByRefreshTokenHash("refresh-hash")).willReturn(Optional.of(token)); + + logoutService.logout(42L, "raw-refresh", "device-1", DevicePlatform.IOS); + + assertThat(token.getRevokedAt()).isNull(); + verify(userDeviceRepository).deleteByUserIdAndDeviceIdAndPlatform(42L, "device-1", DevicePlatform.IOS); + } + + @Test + @DisplayName("refresh token과 deviceId가 없으면 idempotent하게 아무 작업도 하지 않는다") + void logout_withoutRefreshTokenAndDevice() { + logoutService.logout(42L, null, null, null); + + verifyNoInteractions(refreshTokenProvider, userRefreshTokenRepository, userDeviceRepository); + } + + @Test + @DisplayName("존재하지 않는 refresh token은 무시하고 디바이스 정리는 수행한다") + void logout_missingRefreshTokenRow() { + given(refreshTokenProvider.hash("raw-refresh")).willReturn("refresh-hash"); + given(userRefreshTokenRepository.findByRefreshTokenHash("refresh-hash")).willReturn(Optional.empty()); + + logoutService.logout(42L, "raw-refresh", "device-1", DevicePlatform.ANDROID); + + verify(userDeviceRepository).deleteByUserIdAndDeviceIdAndPlatform(42L, "device-1", DevicePlatform.ANDROID); + verifyNoMoreInteractions(userDeviceRepository); + } + + private User user(Long id) { + User user = User.createUser("user" + id + "@example.com"); + ReflectionTestUtils.setField(user, "id", id); + return user; + } + + private UserRefreshToken refreshTokenFor(User user, String hash) { + return UserRefreshToken.create( + user, + hash, + "Mozilla/5.0", + "127.0.0.1", + Duration.ofDays(30) + ); + } +} diff --git a/src/test/java/org/devkor/apu/saerok_server/domain/notification/core/repository/UserDeviceRepositoryTest.java b/src/test/java/org/devkor/apu/saerok_server/domain/notification/core/repository/UserDeviceRepositoryTest.java index 427a532d..782d6602 100644 --- a/src/test/java/org/devkor/apu/saerok_server/domain/notification/core/repository/UserDeviceRepositoryTest.java +++ b/src/test/java/org/devkor/apu/saerok_server/domain/notification/core/repository/UserDeviceRepositoryTest.java @@ -93,6 +93,55 @@ void deleteByToken_removesDevice() { assertThat(devices.getFirst().getToken()).isEqualTo("token-2"); } + @Test @DisplayName("deleteConflictingDevicesForRegistration - 등록 대상 외 같은 token 또는 같은 device/platform row 삭제") + void deleteConflictingDevicesForRegistration_removesStaleRows() { + User currentUser = user(); + User otherUser = user(); + device(currentUser, "device-1", "token-1"); + device(currentUser, "device-3", "token-1"); + device(otherUser, "device-2", "token-1"); + device(otherUser, "device-1", "token-2"); + device(otherUser, "device-4", "token-4"); + repo.flush(); em.clear(); + + int deleted = repo.deleteConflictingDevicesForRegistration( + currentUser.getId(), + "device-1", + DevicePlatform.IOS, + "token-1" + ); + repo.flush(); em.clear(); + + assertThat(deleted).isEqualTo(3); + assertThat(repo.findAllByUserId(currentUser.getId())) + .extracting(UserDevice::getDeviceId) + .containsExactly("device-1"); + assertThat(repo.findAllByUserId(otherUser.getId())) + .extracting(UserDevice::getDeviceId) + .containsExactly("device-4"); + } + + @Test @DisplayName("deleteConflictingDevicesForRegistration - platform이 다른 같은 deviceId row는 token이 다르면 유지") + void deleteConflictingDevicesForRegistration_keepsDifferentPlatformDevice() { + User currentUser = user(); + User otherUser = user(); + UserDevice androidDevice = UserDevice.create(otherUser, "device-1", "token-android", DevicePlatform.ANDROID); + repo.save(androidDevice); + repo.flush(); em.clear(); + + int deleted = repo.deleteConflictingDevicesForRegistration( + currentUser.getId(), + "device-1", + DevicePlatform.IOS, + "token-ios" + ); + repo.flush(); em.clear(); + + assertThat(deleted).isZero(); + assertThat(repo.findByUserIdAndDeviceIdAndPlatform(otherUser.getId(), "device-1", DevicePlatform.ANDROID)) + .isPresent(); + } + @Test @DisplayName("deleteByUserId") void deleteByUserId_removesAllDevices() { User user1 = user(); From c79a46e5c28526c4168906199ec77e82c1549148 Mon Sep 17 00:00:00 2001 From: pizzazoa Date: Sat, 20 Jun 2026 16:17:50 +0900 Subject: [PATCH 3/3] =?UTF-8?q?feat:=20fcm=20=ED=86=A0=ED=81=B0=EC=9D=84?= =?UTF-8?q?=20nullable=EB=A1=9C=20=EB=B3=80=EA=B2=BD,=20=EB=94=94=EB=B0=94?= =?UTF-8?q?=EC=9D=B4=EC=8A=A4=20id=20=ED=96=89=20=EC=A0=84=EC=B2=B4=20?= =?UTF-8?q?=EC=82=AD=EC=A0=9C=20->=20=ED=86=A0=ED=81=B0=EB=A7=8C=20null?= =?UTF-8?q?=EB=A1=9C=20=EB=B9=84=ED=99=9C=EC=84=B1=ED=99=94=EB=A1=9C=20?= =?UTF-8?q?=EB=A1=9C=EC=A7=81=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../auth/application/LogoutService.java | 11 +- .../api/UserDeviceController.java | 50 ------- .../application/UserDeviceCommandService.java | 41 +++--- .../notification/core/entity/UserDevice.java | 15 +- .../NotificationSettingRepository.java | 2 + .../core/repository/UserDeviceRepository.java | 72 +++++---- .../infra/fcm/FcmMessageClient.java | 2 +- .../infra/fcm/FcmPushGateway.java | 4 +- ..._preserve_device_notification_settings.sql | 37 +++++ .../auth/application/LogoutServiceTest.java | 20 +-- .../UserDeviceTokenMigrationTest.java | 139 ++++++++++++++++++ .../NotificationSettingRepositoryTest.java | 7 +- .../repository/UserDeviceRepositoryTest.java | 106 +++++++------ 13 files changed, 335 insertions(+), 171 deletions(-) create mode 100644 src/main/resources/db/migration/V93__preserve_device_notification_settings.sql create mode 100644 src/test/java/org/devkor/apu/saerok_server/domain/notification/UserDeviceTokenMigrationTest.java diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/auth/application/LogoutService.java b/src/main/java/org/devkor/apu/saerok_server/domain/auth/application/LogoutService.java index 34d0b87a..fa9dcb7a 100644 --- a/src/main/java/org/devkor/apu/saerok_server/domain/auth/application/LogoutService.java +++ b/src/main/java/org/devkor/apu/saerok_server/domain/auth/application/LogoutService.java @@ -3,8 +3,8 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.devkor.apu.saerok_server.domain.auth.core.repository.UserRefreshTokenRepository; +import org.devkor.apu.saerok_server.domain.notification.application.UserDeviceCommandService; import org.devkor.apu.saerok_server.domain.notification.core.entity.DevicePlatform; -import org.devkor.apu.saerok_server.domain.notification.core.repository.UserDeviceRepository; import org.devkor.apu.saerok_server.global.security.token.RefreshTokenProvider; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -17,11 +17,11 @@ public class LogoutService { private final RefreshTokenProvider refreshTokenProvider; private final UserRefreshTokenRepository userRefreshTokenRepository; - private final UserDeviceRepository userDeviceRepository; + private final UserDeviceCommandService userDeviceCommandService; public void logout(Long userId, String refreshToken, String deviceId, DevicePlatform platform) { revokeRefreshToken(userId, refreshToken); - deleteCurrentDevice(userId, deviceId, platform); + deactivateCurrentDevice(userId, deviceId, platform); } private void revokeRefreshToken(Long userId, String refreshToken) { @@ -43,12 +43,11 @@ private void revokeRefreshToken(Long userId, String refreshToken) { }); } - private void deleteCurrentDevice(Long userId, String deviceId, DevicePlatform platform) { + private void deactivateCurrentDevice(Long userId, String deviceId, DevicePlatform platform) { if (deviceId == null || deviceId.isBlank()) { return; } - DevicePlatform resolvedPlatform = platform != null ? platform : DevicePlatform.IOS; - userDeviceRepository.deleteByUserIdAndDeviceIdAndPlatform(userId, deviceId, resolvedPlatform); + userDeviceCommandService.deactivateDeviceIfPresent(userId, deviceId, platform); } } diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/notification/api/UserDeviceController.java b/src/main/java/org/devkor/apu/saerok_server/domain/notification/api/UserDeviceController.java index dc07399e..156fec7e 100644 --- a/src/main/java/org/devkor/apu/saerok_server/domain/notification/api/UserDeviceController.java +++ b/src/main/java/org/devkor/apu/saerok_server/domain/notification/api/UserDeviceController.java @@ -1,6 +1,5 @@ package org.devkor.apu.saerok_server.domain.notification.api; -import io.swagger.v3.oas.annotations.Hidden; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.media.Content; import io.swagger.v3.oas.annotations.media.Schema; @@ -11,10 +10,8 @@ import org.devkor.apu.saerok_server.domain.notification.api.dto.request.RegisterTokenRequest; import org.devkor.apu.saerok_server.domain.notification.api.dto.response.RegisterUserDeviceResponse; import org.devkor.apu.saerok_server.domain.notification.application.UserDeviceCommandService; -import org.devkor.apu.saerok_server.domain.notification.core.entity.DevicePlatform; import org.devkor.apu.saerok_server.domain.notification.mapper.UserDeviceWebMapper; import org.devkor.apu.saerok_server.global.security.principal.UserPrincipal; -import org.springframework.http.HttpStatus; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; @@ -52,51 +49,4 @@ public RegisterUserDeviceResponse registerUserDevice( userDeviceWebMapper.toRegisterUserDeviceCommand(request, userPrincipal.getId()) ); } - - @Hidden - @DeleteMapping("/{deviceId}") - @PreAuthorize("isAuthenticated()") - @ResponseStatus(HttpStatus.NO_CONTENT) - @Operation( - summary = "특정 디바이스 토큰 삭제", - security = @SecurityRequirement(name = "bearerAuth"), - description = """ - 특정 디바이스의 토큰을 삭제합니다.
- 보통 로그아웃 시 사용됩니다.
- """, - responses = { - @ApiResponse(responseCode = "204", description = "삭제 성공"), - @ApiResponse(responseCode = "401", description = "인증 실패", content = @Content), - @ApiResponse(responseCode = "404", description = "해당 디바이스를 찾을 수 없음", content = @Content) - } - ) - public void deleteDevice( - @AuthenticationPrincipal UserPrincipal userPrincipal, - @PathVariable String deviceId, - @RequestParam(required = false) DevicePlatform platform - ) { - userDeviceCommandService.deleteDevice(userPrincipal.getId(), deviceId, platform); - } - - @Hidden - @DeleteMapping("/all") - @PreAuthorize("isAuthenticated()") - @ResponseStatus(HttpStatus.NO_CONTENT) - @Operation( - summary = "사용자의 모든 디바이스 토큰 삭제", - security = @SecurityRequirement(name = "bearerAuth"), - description = """ - 사용자의 모든 디바이스 토큰을 삭제합니다.
- 보통 회원 탈퇴 시 사용됩니다.
- """, - responses = { - @ApiResponse(responseCode = "204", description = "전체 삭제 성공"), - @ApiResponse(responseCode = "401", description = "인증 실패", content = @Content) - } - ) - public void deleteAllTokens( - @AuthenticationPrincipal UserPrincipal userPrincipal - ) { - userDeviceCommandService.deleteAllTokens(userPrincipal.getId()); - } } diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/notification/application/UserDeviceCommandService.java b/src/main/java/org/devkor/apu/saerok_server/domain/notification/application/UserDeviceCommandService.java index b41a5689..2b3897a7 100644 --- a/src/main/java/org/devkor/apu/saerok_server/domain/notification/application/UserDeviceCommandService.java +++ b/src/main/java/org/devkor/apu/saerok_server/domain/notification/application/UserDeviceCommandService.java @@ -6,7 +6,6 @@ import org.devkor.apu.saerok_server.domain.notification.core.entity.DevicePlatform; import org.devkor.apu.saerok_server.domain.notification.core.entity.UserDevice; import org.devkor.apu.saerok_server.domain.notification.core.repository.UserDeviceRepository; -import org.devkor.apu.saerok_server.domain.notification.core.repository.NotificationSettingRepository; import org.devkor.apu.saerok_server.domain.notification.core.service.NotificationSettingBackfillService; import org.devkor.apu.saerok_server.domain.notification.mapper.UserDeviceWebMapper; import org.devkor.apu.saerok_server.domain.user.core.entity.User; @@ -24,7 +23,6 @@ public class UserDeviceCommandService { private final UserDeviceRepository userDeviceRepository; - private final NotificationSettingRepository notificationSettingRepository; private final UserRepository userRepository; private final UserDeviceWebMapper userDeviceWebMapper; private final NotificationSettingBackfillService backfillService; @@ -33,14 +31,14 @@ public RegisterUserDeviceResponse registerUserDevice(RegisterUserDeviceCommand c User user = userRepository.findById(command.userId()) .orElseThrow(() -> new NotFoundException("존재하지 않는 사용자 id예요")); - if (command.deviceId() == null || command.deviceId().isEmpty() - || command.token() == null || command.token().isEmpty()) { + if (command.deviceId() == null || command.deviceId().isBlank() + || command.token() == null || command.token().isBlank()) { throw new BadRequestException("deviceId, token은 필수입니다"); } DevicePlatform platform = command.platform() != null ? command.platform() : DevicePlatform.IOS; - userDeviceRepository.deleteConflictingDevicesForRegistration( + userDeviceRepository.deactivateConflictingTokensForRegistration( command.userId(), command.deviceId(), platform, @@ -50,7 +48,7 @@ public RegisterUserDeviceResponse registerUserDevice(RegisterUserDeviceCommand c UserDevice userDevice = userDeviceRepository .findByUserIdAndDeviceIdAndPlatform(command.userId(), command.deviceId(), platform) .map(existing -> { - existing.updateToken(command.token()); + existing.activateToken(command.token()); return existing; }) .orElseGet(() -> { @@ -65,29 +63,26 @@ public RegisterUserDeviceResponse registerUserDevice(RegisterUserDeviceCommand c return userDeviceWebMapper.toRegisterUserDeviceResponse(command, true); } - public void deleteDevice(Long userId, String deviceId, DevicePlatform platform) { - userRepository.findById(userId).orElseThrow(() -> new NotFoundException("존재하지 않는 사용자 id예요")); - platform = platform != null ? platform : DevicePlatform.IOS; - userDeviceRepository.findByUserIdAndDeviceIdAndPlatform(userId, deviceId, platform) - .orElseThrow(() -> new NotFoundException("해당 디바이스를 찾을 수 없어요")); - - userDeviceRepository.deleteByUserIdAndDeviceIdAndPlatform(userId, deviceId, platform); - } - - public void deleteAllTokens(Long userId) { - userRepository.findById(userId).orElseThrow(() -> new NotFoundException("존재하지 않는 사용자 id예요")); + public void deactivateDeviceIfPresent(Long userId, String deviceId, DevicePlatform platform) { + if (deviceId == null || deviceId.isBlank()) { + return; + } - notificationSettingRepository.deleteByUserId(userId); - userDeviceRepository.deleteByUserId(userId); + DevicePlatform resolvedPlatform = platform != null ? platform : DevicePlatform.IOS; + userDeviceRepository.findByUserIdAndDeviceIdAndPlatform(userId, deviceId, resolvedPlatform) + .ifPresent(UserDevice::deactivateToken); } - public void deleteInvalidTokens(List tokens) { + public void deactivateInvalidTokens(List tokens) { if (tokens == null || tokens.isEmpty()) { return; } - tokens.stream() - .distinct() - .forEach(userDeviceRepository::deleteByToken); + List validTokens = tokens.stream() + .filter(token -> token != null && !token.isBlank()) + .distinct() + .toList(); + + userDeviceRepository.deactivateByTokens(validTokens); } } diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/notification/core/entity/UserDevice.java b/src/main/java/org/devkor/apu/saerok_server/domain/notification/core/entity/UserDevice.java index ba5ebc16..69d6fed7 100644 --- a/src/main/java/org/devkor/apu/saerok_server/domain/notification/core/entity/UserDevice.java +++ b/src/main/java/org/devkor/apu/saerok_server/domain/notification/core/entity/UserDevice.java @@ -27,7 +27,7 @@ public class UserDevice extends Auditable { @Column(name = "device_id", nullable = false, length = 256) private String deviceId; - @Column(name = "token", nullable = false, length = 512) + @Column(name = "token", length = 512) private String token; @Enumerated(EnumType.STRING) @@ -38,10 +38,19 @@ public static UserDevice create(User user, String deviceId, String token, Device UserDevice userDevice = new UserDevice(); userDevice.user = user; userDevice.deviceId = deviceId; - userDevice.token = token; userDevice.platform = platform; + userDevice.activateToken(token); return userDevice; } - public void updateToken(String newToken) { this.token = newToken; } + public void activateToken(String newToken) { + if (newToken == null || newToken.isBlank()) { + throw new IllegalArgumentException("FCM token은 비어 있을 수 없습니다"); + } + this.token = newToken; + } + + public void deactivateToken() { + this.token = null; + } } diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/notification/core/repository/NotificationSettingRepository.java b/src/main/java/org/devkor/apu/saerok_server/domain/notification/core/repository/NotificationSettingRepository.java index d432a10e..c1bc9f08 100644 --- a/src/main/java/org/devkor/apu/saerok_server/domain/notification/core/repository/NotificationSettingRepository.java +++ b/src/main/java/org/devkor/apu/saerok_server/domain/notification/core/repository/NotificationSettingRepository.java @@ -46,6 +46,7 @@ public List findEnabledDeviceIdsByUserAndType(Long userId, NotificationTyp where ns.userDevice.user.id = :userId and ns.type = :type and ns.enabled = true + and ns.userDevice.token is not null """, Long.class) .setParameter("userId", userId) .setParameter("type", type) @@ -60,6 +61,7 @@ public List findEnabledDeviceIdsByUserIdsAndType(List userIds, Notif where ns.userDevice.user.id in :userIds and ns.type = :type and ns.enabled = true + and ns.userDevice.token is not null """, Long.class) .setParameter("userIds", userIds) .setParameter("type", type) diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/notification/core/repository/UserDeviceRepository.java b/src/main/java/org/devkor/apu/saerok_server/domain/notification/core/repository/UserDeviceRepository.java index 636d2461..0ebd2800 100644 --- a/src/main/java/org/devkor/apu/saerok_server/domain/notification/core/repository/UserDeviceRepository.java +++ b/src/main/java/org/devkor/apu/saerok_server/domain/notification/core/repository/UserDeviceRepository.java @@ -19,33 +19,29 @@ public class UserDeviceRepository { public void save(UserDevice userDevice) { em.persist(userDevice); } public void flush() { em.flush(); } - // 특정 디바이스 삭제 - public void deleteByUserIdAndDeviceIdAndPlatform(Long userId, String deviceId, DevicePlatform platform) { - em.createQuery("DELETE FROM UserDevice ud WHERE ud.user.id = :userId AND ud.deviceId = :deviceId AND ud.platform = :platform") - .setParameter("userId", userId) - .setParameter("deviceId", deviceId) - .setParameter("platform", platform) - .executeUpdate(); - } - - // 모든 토큰 삭제 - public int deleteByUserId(Long userId) { - return em.createQuery("DELETE FROM UserDevice ud WHERE ud.user.id = :userId") - .setParameter("userId", userId) - .executeUpdate(); - } + public int deactivateByTokens(List tokens) { + if (tokens == null || tokens.isEmpty()) { + return 0; + } - // 개별 토큰 삭제 - public void deleteByToken(String token) { - em.createQuery("DELETE FROM UserDevice ud WHERE ud.token = :token") - .setParameter("token", token) + return em.createQuery(""" + UPDATE UserDevice ud + SET ud.token = NULL, + ud.updatedAt = CURRENT_TIMESTAMP + WHERE ud.token IN :tokens + """) + .setParameter("tokens", tokens) .executeUpdate(); } - public int deleteConflictingDevicesForRegistration(Long userId, String deviceId, DevicePlatform platform, String token) { + public int deactivateConflictingTokensForRegistration(Long userId, String deviceId, + DevicePlatform platform, String token) { return em.createQuery(""" - DELETE FROM UserDevice ud - WHERE ( + UPDATE UserDevice ud + SET ud.token = NULL, + ud.updatedAt = CURRENT_TIMESTAMP + WHERE ud.token IS NOT NULL + AND ( ud.token = :token OR (ud.deviceId = :deviceId AND ud.platform = :platform) ) @@ -62,6 +58,13 @@ AND NOT ( .executeUpdate(); } + // 회원 탈퇴 시에만 사용하는 영구 삭제 + public int deleteByUserId(Long userId) { + return em.createQuery("DELETE FROM UserDevice ud WHERE ud.user.id = :userId") + .setParameter("userId", userId) + .executeUpdate(); + } + // ID로 디바이스 조회 public Optional findById(Long id) { List results = em.createQuery("SELECT ud FROM UserDevice ud WHERE ud.id = :id", UserDevice.class) @@ -86,10 +89,13 @@ public Optional findByUserIdAndDeviceIdAndPlatform(Long userId, Stri return results.isEmpty() ? Optional.empty() : Optional.of(results.getFirst()); } - public List findAllByUserId(Long userId) { - return em.createQuery( - "SELECT ud FROM UserDevice ud WHERE ud.user.id = :userId", - UserDevice.class) + public List findAllActiveByUserId(Long userId) { + return em.createQuery(""" + SELECT ud + FROM UserDevice ud + WHERE ud.user.id = :userId + AND ud.token IS NOT NULL + """, UserDevice.class) .setParameter("userId", userId) .getResultList(); } @@ -100,14 +106,24 @@ public List findTokensByUserDeviceIds(List userDeviceIds) { return List.of(); } - return em.createQuery("SELECT ud.token FROM UserDevice ud WHERE ud.id IN :userDeviceIds", String.class) + return em.createQuery(""" + SELECT ud.token + FROM UserDevice ud + WHERE ud.id IN :userDeviceIds + AND ud.token IS NOT NULL + """, String.class) .setParameter("userDeviceIds", userDeviceIds) .getResultList(); } // 사용자 ID로 모든 FCM 토큰 조회 (사일런트 푸시용) public List findTokensByUserId(Long userId) { - return em.createQuery("SELECT ud.token FROM UserDevice ud WHERE ud.user.id = :userId", String.class) + return em.createQuery(""" + SELECT ud.token + FROM UserDevice ud + WHERE ud.user.id = :userId + AND ud.token IS NOT NULL + """, String.class) .setParameter("userId", userId) .getResultList(); } diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/notification/infra/fcm/FcmMessageClient.java b/src/main/java/org/devkor/apu/saerok_server/domain/notification/infra/fcm/FcmMessageClient.java index a6c23ec4..9f9dd78b 100644 --- a/src/main/java/org/devkor/apu/saerok_server/domain/notification/infra/fcm/FcmMessageClient.java +++ b/src/main/java/org/devkor/apu/saerok_server/domain/notification/infra/fcm/FcmMessageClient.java @@ -147,7 +147,7 @@ private void cleanupInvalidTokens(List fcmTokens, BatchResponse response if (!invalid.isEmpty()) { try { - userDeviceCommandService.deleteInvalidTokens(invalid); + userDeviceCommandService.deactivateInvalidTokens(invalid); } catch (Exception e) { log.warn("Invalid FCM tokens cleanup failed ({} tokens): {}", invalid.size(), e.getMessage()); } diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/notification/infra/fcm/FcmPushGateway.java b/src/main/java/org/devkor/apu/saerok_server/domain/notification/infra/fcm/FcmPushGateway.java index d73d783f..db742a1b 100644 --- a/src/main/java/org/devkor/apu/saerok_server/domain/notification/infra/fcm/FcmPushGateway.java +++ b/src/main/java/org/devkor/apu/saerok_server/domain/notification/infra/fcm/FcmPushGateway.java @@ -30,7 +30,7 @@ public class FcmPushGateway implements PushGateway { @Override public void sendToUser(Long userId, NotificationType type, PushMessageCommand cmd) { - userDeviceRepository.findAllByUserId(userId) + userDeviceRepository.findAllActiveByUserId(userId) .forEach(backfillService::ensureDefaults); List deviceIds = settingRepository.findEnabledDeviceIdsByUserAndType(userId, type); @@ -66,7 +66,7 @@ public void sendToUsersDeduplicated(List targets) { continue; } - userDeviceRepository.findAllByUserId(userId) + userDeviceRepository.findAllActiveByUserId(userId) .forEach(backfillService::ensureDefaults); List deviceIds = settingRepository.findEnabledDeviceIdsByUserAndType(userId, type); diff --git a/src/main/resources/db/migration/V93__preserve_device_notification_settings.sql b/src/main/resources/db/migration/V93__preserve_device_notification_settings.sql new file mode 100644 index 00000000..a2b34c56 --- /dev/null +++ b/src/main/resources/db/migration/V93__preserve_device_notification_settings.sql @@ -0,0 +1,37 @@ +ALTER TABLE user_device + ALTER COLUMN token DROP NOT NULL; + +-- 과거 데이터에 빈 token이 있다면 비활성 상태로 정규화한다. +UPDATE user_device +SET token = NULL, + updated_at = now() +WHERE token IS NOT NULL + AND btrim(token) = ''; + +-- partial unique index 생성 전에 중복 token을 안전하게 정리한다. +-- 가장 최근에 갱신된 row 하나만 활성 상태로 유지하고 나머지는 설정과 함께 보존한다. +WITH ranked_tokens AS ( + SELECT id, + row_number() OVER ( + PARTITION BY token + ORDER BY updated_at DESC, id DESC + ) AS row_rank + FROM user_device + WHERE token IS NOT NULL +) +UPDATE user_device ud +SET token = NULL, + updated_at = now() +FROM ranked_tokens ranked +WHERE ud.id = ranked.id + AND ranked.row_rank > 1; + +DROP INDEX IF EXISTS idx_user_device_token; + +CREATE UNIQUE INDEX uq_user_device_active_token + ON user_device(token) + WHERE token IS NOT NULL; + +ALTER TABLE user_device + ADD CONSTRAINT ck_user_device_token_not_blank + CHECK (token IS NULL OR btrim(token) <> ''); diff --git a/src/test/java/org/devkor/apu/saerok_server/domain/auth/application/LogoutServiceTest.java b/src/test/java/org/devkor/apu/saerok_server/domain/auth/application/LogoutServiceTest.java index 2a1cca74..f058603f 100644 --- a/src/test/java/org/devkor/apu/saerok_server/domain/auth/application/LogoutServiceTest.java +++ b/src/test/java/org/devkor/apu/saerok_server/domain/auth/application/LogoutServiceTest.java @@ -2,8 +2,8 @@ import org.devkor.apu.saerok_server.domain.auth.core.entity.UserRefreshToken; import org.devkor.apu.saerok_server.domain.auth.core.repository.UserRefreshTokenRepository; +import org.devkor.apu.saerok_server.domain.notification.application.UserDeviceCommandService; import org.devkor.apu.saerok_server.domain.notification.core.entity.DevicePlatform; -import org.devkor.apu.saerok_server.domain.notification.core.repository.UserDeviceRepository; import org.devkor.apu.saerok_server.domain.user.core.entity.User; import org.devkor.apu.saerok_server.global.security.token.RefreshTokenProvider; import org.junit.jupiter.api.DisplayName; @@ -28,13 +28,13 @@ class LogoutServiceTest { @Mock RefreshTokenProvider refreshTokenProvider; @Mock UserRefreshTokenRepository userRefreshTokenRepository; - @Mock UserDeviceRepository userDeviceRepository; + @Mock UserDeviceCommandService userDeviceCommandService; @InjectMocks LogoutService logoutService; @Test - @DisplayName("로그아웃 시 현재 유저 refresh token을 revoke하고 현재 디바이스 토큰을 삭제한다") - void logout_revokesRefreshTokenAndDeletesDevice() { + @DisplayName("로그아웃 시 현재 유저 refresh token을 revoke하고 현재 디바이스 토큰을 비활성화한다") + void logout_revokesRefreshTokenAndDeactivatesDevice() { UserRefreshToken token = refreshTokenFor(user(42L), "refresh-hash"); given(refreshTokenProvider.hash("raw-refresh")).willReturn("refresh-hash"); given(userRefreshTokenRepository.findByRefreshTokenHash("refresh-hash")).willReturn(Optional.of(token)); @@ -42,7 +42,7 @@ void logout_revokesRefreshTokenAndDeletesDevice() { logoutService.logout(42L, "raw-refresh", "device-1", DevicePlatform.IOS); assertThat(token.getRevokedAt()).isNotNull(); - verify(userDeviceRepository).deleteByUserIdAndDeviceIdAndPlatform(42L, "device-1", DevicePlatform.IOS); + verify(userDeviceCommandService).deactivateDeviceIfPresent(42L, "device-1", DevicePlatform.IOS); } @Test @@ -57,7 +57,7 @@ void logout_alreadyRevokedRefreshToken() { logoutService.logout(42L, "raw-refresh", "device-1", null); assertThat(token.getRevokedAt()).isEqualTo(revokedAt); - verify(userDeviceRepository).deleteByUserIdAndDeviceIdAndPlatform(42L, "device-1", DevicePlatform.IOS); + verify(userDeviceCommandService).deactivateDeviceIfPresent(42L, "device-1", null); } @Test @@ -70,7 +70,7 @@ void logout_refreshTokenOwnerMismatch() { logoutService.logout(42L, "raw-refresh", "device-1", DevicePlatform.IOS); assertThat(token.getRevokedAt()).isNull(); - verify(userDeviceRepository).deleteByUserIdAndDeviceIdAndPlatform(42L, "device-1", DevicePlatform.IOS); + verify(userDeviceCommandService).deactivateDeviceIfPresent(42L, "device-1", DevicePlatform.IOS); } @Test @@ -78,7 +78,7 @@ void logout_refreshTokenOwnerMismatch() { void logout_withoutRefreshTokenAndDevice() { logoutService.logout(42L, null, null, null); - verifyNoInteractions(refreshTokenProvider, userRefreshTokenRepository, userDeviceRepository); + verifyNoInteractions(refreshTokenProvider, userRefreshTokenRepository, userDeviceCommandService); } @Test @@ -89,8 +89,8 @@ void logout_missingRefreshTokenRow() { logoutService.logout(42L, "raw-refresh", "device-1", DevicePlatform.ANDROID); - verify(userDeviceRepository).deleteByUserIdAndDeviceIdAndPlatform(42L, "device-1", DevicePlatform.ANDROID); - verifyNoMoreInteractions(userDeviceRepository); + verify(userDeviceCommandService).deactivateDeviceIfPresent(42L, "device-1", DevicePlatform.ANDROID); + verifyNoMoreInteractions(userDeviceCommandService); } private User user(Long id) { diff --git a/src/test/java/org/devkor/apu/saerok_server/domain/notification/UserDeviceTokenMigrationTest.java b/src/test/java/org/devkor/apu/saerok_server/domain/notification/UserDeviceTokenMigrationTest.java new file mode 100644 index 00000000..5e950c7d --- /dev/null +++ b/src/test/java/org/devkor/apu/saerok_server/domain/notification/UserDeviceTokenMigrationTest.java @@ -0,0 +1,139 @@ +package org.devkor.apu.saerok_server.domain.notification; + +import org.flywaydb.core.Flyway; +import org.flywaydb.core.api.MigrationVersion; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.testcontainers.containers.PostgreSQLContainer; +import org.testcontainers.utility.DockerImageName; + +import java.sql.Connection; +import java.sql.DriverManager; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Statement; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class UserDeviceTokenMigrationTest { + + private static final PostgreSQLContainer POSTGRES = new PostgreSQLContainer<>( + DockerImageName.parse("postgis/postgis:16-3.5-alpine") + .asCompatibleSubstituteFor("postgres") + ); + + @BeforeAll + static void startPostgres() { + POSTGRES.start(); + } + + @AfterAll + static void stopPostgres() { + POSTGRES.stop(); + } + + @Test + @DisplayName("V93은 중복/빈 token만 비활성화하고 디바이스와 알림 설정을 보존한다") + void migration_preservesRowsAndSettingsWhileNormalizingTokens() throws Exception { + migrateToVersion92(); + + try (Connection connection = connection(); Statement statement = connection.createStatement()) { + statement.executeUpdate(""" + INSERT INTO users (id, joined_at) VALUES + (100001, now()), + (100002, now()) + """); + statement.executeUpdate(""" + INSERT INTO user_device + (id, user_id, device_id, token, platform, created_at, updated_at) + VALUES + (900001, 100001, 'device-old', 'duplicate-token', 'IOS', now(), '2026-01-01T00:00:00Z'), + (900002, 100002, 'device-new', 'duplicate-token', 'IOS', now(), '2026-02-01T00:00:00Z'), + (900003, 100001, 'device-blank', ' ', 'IOS', now(), '2026-03-01T00:00:00Z') + """); + statement.executeUpdate(""" + INSERT INTO notification_setting + (id, user_device_id, type, enabled, created_at, updated_at) + VALUES + (910001, 900001, 'LIKED_ON_COLLECTION', FALSE, now(), now()) + """); + } + + migrateToLatest(); + + try (Connection connection = connection(); Statement statement = connection.createStatement()) { + assertThat(queryLong(statement, "SELECT COUNT(*) FROM user_device")).isEqualTo(3L); + assertThat(queryString(statement, "SELECT token FROM user_device WHERE id = 900001")).isNull(); + assertThat(queryString(statement, "SELECT token FROM user_device WHERE id = 900002")) + .isEqualTo("duplicate-token"); + assertThat(queryString(statement, "SELECT token FROM user_device WHERE id = 900003")).isNull(); + assertThat(queryLong(statement, "SELECT COUNT(*) FROM notification_setting WHERE id = 910001")) + .isEqualTo(1L); + assertThat(queryBoolean(statement, "SELECT enabled FROM notification_setting WHERE id = 910001")) + .isFalse(); + assertThat(queryBoolean(statement, """ + SELECT is_nullable = 'YES' + FROM information_schema.columns + WHERE table_name = 'user_device' AND column_name = 'token' + """)).isTrue(); + + statement.executeUpdate("INSERT INTO users (id, joined_at) VALUES (100003, now())"); + assertThatThrownBy(() -> statement.executeUpdate(""" + INSERT INTO user_device + (id, user_id, device_id, token, platform, created_at, updated_at) + VALUES + (900004, 100003, 'device-duplicate', 'duplicate-token', 'IOS', now(), now()) + """)) + .isInstanceOf(SQLException.class); + } + } + + private void migrateToVersion92() { + Flyway.configure() + .dataSource(POSTGRES.getJdbcUrl(), POSTGRES.getUsername(), POSTGRES.getPassword()) + .locations("classpath:db/migration") + .target(MigrationVersion.fromVersion("92")) + .load() + .migrate(); + } + + private void migrateToLatest() { + Flyway.configure() + .dataSource(POSTGRES.getJdbcUrl(), POSTGRES.getUsername(), POSTGRES.getPassword()) + .locations("classpath:db/migration") + .load() + .migrate(); + } + + private Connection connection() throws SQLException { + return DriverManager.getConnection( + POSTGRES.getJdbcUrl(), + POSTGRES.getUsername(), + POSTGRES.getPassword() + ); + } + + private long queryLong(Statement statement, String sql) throws SQLException { + try (ResultSet resultSet = statement.executeQuery(sql)) { + resultSet.next(); + return resultSet.getLong(1); + } + } + + private String queryString(Statement statement, String sql) throws SQLException { + try (ResultSet resultSet = statement.executeQuery(sql)) { + resultSet.next(); + return resultSet.getString(1); + } + } + + private boolean queryBoolean(Statement statement, String sql) throws SQLException { + try (ResultSet resultSet = statement.executeQuery(sql)) { + resultSet.next(); + return resultSet.getBoolean(1); + } + } +} diff --git a/src/test/java/org/devkor/apu/saerok_server/domain/notification/core/repository/NotificationSettingRepositoryTest.java b/src/test/java/org/devkor/apu/saerok_server/domain/notification/core/repository/NotificationSettingRepositoryTest.java index e401fcd5..992b6138 100644 --- a/src/test/java/org/devkor/apu/saerok_server/domain/notification/core/repository/NotificationSettingRepositoryTest.java +++ b/src/test/java/org/devkor/apu/saerok_server/domain/notification/core/repository/NotificationSettingRepositoryTest.java @@ -81,16 +81,19 @@ void findByUserDeviceIdAndType_returnsMatch() { assertThat(missing).isEmpty(); } - @Test @DisplayName("findEnabledDeviceIdsByUserAndType - enabled devices만 반환") - void findEnabledDeviceIdsByUserAndType_returnsEnabled() { + @Test @DisplayName("findEnabledDeviceIdsByUserAndType - enabled이면서 token이 활성인 디바이스만 반환") + void findEnabledDeviceIdsByUserAndType_returnsEnabledActiveDevices() { User user = user(); User otherUser = user(); UserDevice enabledDevice = device(user, "device-enabled"); UserDevice disabledDevice = device(user, "device-disabled"); + UserDevice inactiveDevice = device(user, "device-inactive"); UserDevice otherDevice = device(otherUser, "device-other"); setting(enabledDevice, NotificationType.COMMENTED_ON_COLLECTION, true); setting(disabledDevice, NotificationType.COMMENTED_ON_COLLECTION, false); + setting(inactiveDevice, NotificationType.COMMENTED_ON_COLLECTION, true); setting(otherDevice, NotificationType.COMMENTED_ON_COLLECTION, true); + inactiveDevice.deactivateToken(); em.flush(); em.clear(); List deviceIds = diff --git a/src/test/java/org/devkor/apu/saerok_server/domain/notification/core/repository/UserDeviceRepositoryTest.java b/src/test/java/org/devkor/apu/saerok_server/domain/notification/core/repository/UserDeviceRepositoryTest.java index 782d6602..02dd2727 100644 --- a/src/test/java/org/devkor/apu/saerok_server/domain/notification/core/repository/UserDeviceRepositoryTest.java +++ b/src/test/java/org/devkor/apu/saerok_server/domain/notification/core/repository/UserDeviceRepositoryTest.java @@ -1,6 +1,8 @@ package org.devkor.apu.saerok_server.domain.notification.core.repository; import org.devkor.apu.saerok_server.domain.notification.core.entity.DevicePlatform; +import org.devkor.apu.saerok_server.domain.notification.core.entity.NotificationSetting; +import org.devkor.apu.saerok_server.domain.notification.core.entity.NotificationType; import org.devkor.apu.saerok_server.domain.notification.core.entity.UserDevice; import org.devkor.apu.saerok_server.domain.user.core.entity.User; import org.devkor.apu.saerok_server.testsupport.AbstractPostgresContainerTest; @@ -65,71 +67,71 @@ void findByUserIdAndDeviceIdAndPlatform_returnsMatch() { assertThat(missingPlatform).isEmpty(); } - @Test @DisplayName("deleteByUserIdAndDeviceIdAndPlatform") - void deleteByUserIdAndDeviceIdAndPlatform_removesDevice() { + @Test @DisplayName("deactivateByTokens - token만 비활성화하고 디바이스와 설정은 유지") + void deactivateByTokens_preservesDeviceAndSettings() { User user = user(); - device(user, "device-1", "token-1"); + UserDevice device = device(user, "device-1", "token-1"); + NotificationSetting setting = NotificationSetting.of(device, NotificationType.LIKED_ON_COLLECTION, false); + em.persist(setting); repo.flush(); em.clear(); - repo.deleteByUserIdAndDeviceIdAndPlatform(user.getId(), "device-1", DevicePlatform.IOS); + int deactivated = repo.deactivateByTokens(List.of("token-1")); repo.flush(); em.clear(); Optional found = repo.findByUserIdAndDeviceIdAndPlatform(user.getId(), "device-1", DevicePlatform.IOS); - assertThat(found).isEmpty(); - } + NotificationSetting preservedSetting = em.find(NotificationSetting.class, setting.getId()); - @Test @DisplayName("deleteByToken") - void deleteByToken_removesDevice() { - User user = user(); - device(user, "device-1", "token-1"); - device(user, "device-2", "token-2"); - repo.flush(); em.clear(); + assertThat(deactivated).isEqualTo(1); + assertThat(found).isPresent(); + assertThat(found.get().getToken()).isNull(); + assertThat(preservedSetting).isNotNull(); + assertThat(preservedSetting.getEnabled()).isFalse(); + assertThat(repo.findTokensByUserId(user.getId())).isEmpty(); + assertThat(repo.findTokensByUserDeviceIds(List.of(found.get().getId()))).isEmpty(); - repo.deleteByToken("token-1"); + found.get().activateToken("token-new"); repo.flush(); em.clear(); - List devices = repo.findAllByUserId(user.getId()); - assertThat(devices).hasSize(1); - assertThat(devices.getFirst().getToken()).isEqualTo("token-2"); + UserDevice reactivated = repo.findById(found.get().getId()).orElseThrow(); + NotificationSetting settingAfterReactivation = em.find(NotificationSetting.class, setting.getId()); + assertThat(reactivated.getToken()).isEqualTo("token-new"); + assertThat(settingAfterReactivation.getEnabled()).isFalse(); } - @Test @DisplayName("deleteConflictingDevicesForRegistration - 등록 대상 외 같은 token 또는 같은 device/platform row 삭제") - void deleteConflictingDevicesForRegistration_removesStaleRows() { + @Test @DisplayName("deactivateConflictingTokensForRegistration - 충돌 row의 token만 비활성화") + void deactivateConflictingTokensForRegistration_preservesRows() { User currentUser = user(); User otherUser = user(); - device(currentUser, "device-1", "token-1"); - device(currentUser, "device-3", "token-1"); - device(otherUser, "device-2", "token-1"); - device(otherUser, "device-1", "token-2"); - device(otherUser, "device-4", "token-4"); + UserDevice currentDevice = device(currentUser, "device-1", "token-current"); + UserDevice sameTokenDevice = device(otherUser, "device-2", "token-next"); + UserDevice samePhysicalDevice = device(otherUser, "device-1", "token-old"); + UserDevice unrelatedDevice = device(otherUser, "device-4", "token-unrelated"); repo.flush(); em.clear(); - int deleted = repo.deleteConflictingDevicesForRegistration( + int deactivated = repo.deactivateConflictingTokensForRegistration( currentUser.getId(), "device-1", DevicePlatform.IOS, - "token-1" + "token-next" ); repo.flush(); em.clear(); - assertThat(deleted).isEqualTo(3); - assertThat(repo.findAllByUserId(currentUser.getId())) - .extracting(UserDevice::getDeviceId) - .containsExactly("device-1"); - assertThat(repo.findAllByUserId(otherUser.getId())) - .extracting(UserDevice::getDeviceId) - .containsExactly("device-4"); + assertThat(deactivated).isEqualTo(2); + assertThat(repo.findById(currentDevice.getId()).orElseThrow().getToken()).isEqualTo("token-current"); + assertThat(repo.findById(sameTokenDevice.getId()).orElseThrow().getToken()).isNull(); + assertThat(repo.findById(samePhysicalDevice.getId()).orElseThrow().getToken()).isNull(); + assertThat(repo.findById(unrelatedDevice.getId()).orElseThrow().getToken()).isEqualTo("token-unrelated"); } - @Test @DisplayName("deleteConflictingDevicesForRegistration - platform이 다른 같은 deviceId row는 token이 다르면 유지") - void deleteConflictingDevicesForRegistration_keepsDifferentPlatformDevice() { + @Test @DisplayName("deactivateConflictingTokensForRegistration - platform이 다른 같은 deviceId row는 유지") + void deactivateConflictingTokensForRegistration_keepsDifferentPlatformDevice() { User currentUser = user(); User otherUser = user(); UserDevice androidDevice = UserDevice.create(otherUser, "device-1", "token-android", DevicePlatform.ANDROID); repo.save(androidDevice); repo.flush(); em.clear(); - int deleted = repo.deleteConflictingDevicesForRegistration( + int deactivated = repo.deactivateConflictingTokensForRegistration( currentUser.getId(), "device-1", DevicePlatform.IOS, @@ -137,12 +139,12 @@ void deleteConflictingDevicesForRegistration_keepsDifferentPlatformDevice() { ); repo.flush(); em.clear(); - assertThat(deleted).isZero(); + assertThat(deactivated).isZero(); assertThat(repo.findByUserIdAndDeviceIdAndPlatform(otherUser.getId(), "device-1", DevicePlatform.ANDROID)) .isPresent(); } - @Test @DisplayName("deleteByUserId") + @Test @DisplayName("deleteByUserId - 회원 탈퇴 시에는 디바이스를 영구 삭제") void deleteByUserId_removesAllDevices() { User user1 = user(); User user2 = user(); @@ -155,21 +157,33 @@ void deleteByUserId_removesAllDevices() { repo.flush(); em.clear(); assertThat(deleted).isEqualTo(2); - assertThat(repo.findAllByUserId(user1.getId())).isEmpty(); - assertThat(repo.findAllByUserId(user2.getId())).hasSize(1); + assertThat(repo.findAllActiveByUserId(user1.getId())).isEmpty(); + assertThat(repo.findAllActiveByUserId(user2.getId())).hasSize(1); + } + + @Test @DisplayName("partial unique index - 활성 token은 하나의 row에만 귀속") + void activeToken_mustBeUnique() { + User user1 = user(); + User user2 = user(); + device(user1, "device-1", "duplicate-token"); + repo.flush(); + + device(user2, "device-2", "duplicate-token"); + + assertThatThrownBy(repo::flush).isInstanceOf(Exception.class); } - @Test @DisplayName("findAllByUserId") - void findAllByUserId_returnsDevices() { + @Test @DisplayName("findAllActiveByUserId - 비활성(token=null) 디바이스는 제외") + void findAllActiveByUserId_excludesInactive() { User user = user(); - UserDevice first = device(user, "device-1", "token-1"); - UserDevice second = device(user, "device-2", "token-2"); + UserDevice active = device(user, "device-1", "token-1"); + UserDevice inactive = device(user, "device-2", "token-2"); + inactive.deactivateToken(); repo.flush(); em.clear(); - List devices = repo.findAllByUserId(user.getId()); + List devices = repo.findAllActiveByUserId(user.getId()); - assertThat(devices).hasSize(2); assertThat(devices).extracting(UserDevice::getId) - .containsExactlyInAnyOrder(first.getId(), second.getId()); + .containsExactly(active.getId()); } }