Skip to content

Commit c79a46e

Browse files
committed
feat: fcm 토큰을 nullable로 변경, 디바이스 id 행 전체 삭제 -> 토큰만 null로 비활성화로 로직 변경
1 parent 3b72685 commit c79a46e

13 files changed

Lines changed: 335 additions & 171 deletions

File tree

src/main/java/org/devkor/apu/saerok_server/domain/auth/application/LogoutService.java

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,8 @@
33
import lombok.RequiredArgsConstructor;
44
import lombok.extern.slf4j.Slf4j;
55
import org.devkor.apu.saerok_server.domain.auth.core.repository.UserRefreshTokenRepository;
6+
import org.devkor.apu.saerok_server.domain.notification.application.UserDeviceCommandService;
67
import org.devkor.apu.saerok_server.domain.notification.core.entity.DevicePlatform;
7-
import org.devkor.apu.saerok_server.domain.notification.core.repository.UserDeviceRepository;
88
import org.devkor.apu.saerok_server.global.security.token.RefreshTokenProvider;
99
import org.springframework.stereotype.Service;
1010
import org.springframework.transaction.annotation.Transactional;
@@ -17,11 +17,11 @@ public class LogoutService {
1717

1818
private final RefreshTokenProvider refreshTokenProvider;
1919
private final UserRefreshTokenRepository userRefreshTokenRepository;
20-
private final UserDeviceRepository userDeviceRepository;
20+
private final UserDeviceCommandService userDeviceCommandService;
2121

2222
public void logout(Long userId, String refreshToken, String deviceId, DevicePlatform platform) {
2323
revokeRefreshToken(userId, refreshToken);
24-
deleteCurrentDevice(userId, deviceId, platform);
24+
deactivateCurrentDevice(userId, deviceId, platform);
2525
}
2626

2727
private void revokeRefreshToken(Long userId, String refreshToken) {
@@ -43,12 +43,11 @@ private void revokeRefreshToken(Long userId, String refreshToken) {
4343
});
4444
}
4545

46-
private void deleteCurrentDevice(Long userId, String deviceId, DevicePlatform platform) {
46+
private void deactivateCurrentDevice(Long userId, String deviceId, DevicePlatform platform) {
4747
if (deviceId == null || deviceId.isBlank()) {
4848
return;
4949
}
5050

51-
DevicePlatform resolvedPlatform = platform != null ? platform : DevicePlatform.IOS;
52-
userDeviceRepository.deleteByUserIdAndDeviceIdAndPlatform(userId, deviceId, resolvedPlatform);
51+
userDeviceCommandService.deactivateDeviceIfPresent(userId, deviceId, platform);
5352
}
5453
}
Lines changed: 0 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
package org.devkor.apu.saerok_server.domain.notification.api;
22

3-
import io.swagger.v3.oas.annotations.Hidden;
43
import io.swagger.v3.oas.annotations.Operation;
54
import io.swagger.v3.oas.annotations.media.Content;
65
import io.swagger.v3.oas.annotations.media.Schema;
@@ -11,10 +10,8 @@
1110
import org.devkor.apu.saerok_server.domain.notification.api.dto.request.RegisterTokenRequest;
1211
import org.devkor.apu.saerok_server.domain.notification.api.dto.response.RegisterUserDeviceResponse;
1312
import org.devkor.apu.saerok_server.domain.notification.application.UserDeviceCommandService;
14-
import org.devkor.apu.saerok_server.domain.notification.core.entity.DevicePlatform;
1513
import org.devkor.apu.saerok_server.domain.notification.mapper.UserDeviceWebMapper;
1614
import org.devkor.apu.saerok_server.global.security.principal.UserPrincipal;
17-
import org.springframework.http.HttpStatus;
1815
import org.springframework.security.access.prepost.PreAuthorize;
1916
import org.springframework.security.core.annotation.AuthenticationPrincipal;
2017
import org.springframework.web.bind.annotation.*;
@@ -52,51 +49,4 @@ public RegisterUserDeviceResponse registerUserDevice(
5249
userDeviceWebMapper.toRegisterUserDeviceCommand(request, userPrincipal.getId())
5350
);
5451
}
55-
56-
@Hidden
57-
@DeleteMapping("/{deviceId}")
58-
@PreAuthorize("isAuthenticated()")
59-
@ResponseStatus(HttpStatus.NO_CONTENT)
60-
@Operation(
61-
summary = "특정 디바이스 토큰 삭제",
62-
security = @SecurityRequirement(name = "bearerAuth"),
63-
description = """
64-
특정 디바이스의 토큰을 삭제합니다.<br>
65-
보통 로그아웃 시 사용됩니다.<br>
66-
""",
67-
responses = {
68-
@ApiResponse(responseCode = "204", description = "삭제 성공"),
69-
@ApiResponse(responseCode = "401", description = "인증 실패", content = @Content),
70-
@ApiResponse(responseCode = "404", description = "해당 디바이스를 찾을 수 없음", content = @Content)
71-
}
72-
)
73-
public void deleteDevice(
74-
@AuthenticationPrincipal UserPrincipal userPrincipal,
75-
@PathVariable String deviceId,
76-
@RequestParam(required = false) DevicePlatform platform
77-
) {
78-
userDeviceCommandService.deleteDevice(userPrincipal.getId(), deviceId, platform);
79-
}
80-
81-
@Hidden
82-
@DeleteMapping("/all")
83-
@PreAuthorize("isAuthenticated()")
84-
@ResponseStatus(HttpStatus.NO_CONTENT)
85-
@Operation(
86-
summary = "사용자의 모든 디바이스 토큰 삭제",
87-
security = @SecurityRequirement(name = "bearerAuth"),
88-
description = """
89-
사용자의 모든 디바이스 토큰을 삭제합니다.<br>
90-
보통 회원 탈퇴 시 사용됩니다.<br>
91-
""",
92-
responses = {
93-
@ApiResponse(responseCode = "204", description = "전체 삭제 성공"),
94-
@ApiResponse(responseCode = "401", description = "인증 실패", content = @Content)
95-
}
96-
)
97-
public void deleteAllTokens(
98-
@AuthenticationPrincipal UserPrincipal userPrincipal
99-
) {
100-
userDeviceCommandService.deleteAllTokens(userPrincipal.getId());
101-
}
10252
}

src/main/java/org/devkor/apu/saerok_server/domain/notification/application/UserDeviceCommandService.java

Lines changed: 18 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@
66
import org.devkor.apu.saerok_server.domain.notification.core.entity.DevicePlatform;
77
import org.devkor.apu.saerok_server.domain.notification.core.entity.UserDevice;
88
import org.devkor.apu.saerok_server.domain.notification.core.repository.UserDeviceRepository;
9-
import org.devkor.apu.saerok_server.domain.notification.core.repository.NotificationSettingRepository;
109
import org.devkor.apu.saerok_server.domain.notification.core.service.NotificationSettingBackfillService;
1110
import org.devkor.apu.saerok_server.domain.notification.mapper.UserDeviceWebMapper;
1211
import org.devkor.apu.saerok_server.domain.user.core.entity.User;
@@ -24,7 +23,6 @@
2423
public class UserDeviceCommandService {
2524

2625
private final UserDeviceRepository userDeviceRepository;
27-
private final NotificationSettingRepository notificationSettingRepository;
2826
private final UserRepository userRepository;
2927
private final UserDeviceWebMapper userDeviceWebMapper;
3028
private final NotificationSettingBackfillService backfillService;
@@ -33,14 +31,14 @@ public RegisterUserDeviceResponse registerUserDevice(RegisterUserDeviceCommand c
3331
User user = userRepository.findById(command.userId())
3432
.orElseThrow(() -> new NotFoundException("존재하지 않는 사용자 id예요"));
3533

36-
if (command.deviceId() == null || command.deviceId().isEmpty()
37-
|| command.token() == null || command.token().isEmpty()) {
34+
if (command.deviceId() == null || command.deviceId().isBlank()
35+
|| command.token() == null || command.token().isBlank()) {
3836
throw new BadRequestException("deviceId, token은 필수입니다");
3937
}
4038

4139
DevicePlatform platform = command.platform() != null ? command.platform() : DevicePlatform.IOS;
4240

43-
userDeviceRepository.deleteConflictingDevicesForRegistration(
41+
userDeviceRepository.deactivateConflictingTokensForRegistration(
4442
command.userId(),
4543
command.deviceId(),
4644
platform,
@@ -50,7 +48,7 @@ public RegisterUserDeviceResponse registerUserDevice(RegisterUserDeviceCommand c
5048
UserDevice userDevice = userDeviceRepository
5149
.findByUserIdAndDeviceIdAndPlatform(command.userId(), command.deviceId(), platform)
5250
.map(existing -> {
53-
existing.updateToken(command.token());
51+
existing.activateToken(command.token());
5452
return existing;
5553
})
5654
.orElseGet(() -> {
@@ -65,29 +63,26 @@ public RegisterUserDeviceResponse registerUserDevice(RegisterUserDeviceCommand c
6563
return userDeviceWebMapper.toRegisterUserDeviceResponse(command, true);
6664
}
6765

68-
public void deleteDevice(Long userId, String deviceId, DevicePlatform platform) {
69-
userRepository.findById(userId).orElseThrow(() -> new NotFoundException("존재하지 않는 사용자 id예요"));
70-
platform = platform != null ? platform : DevicePlatform.IOS;
71-
userDeviceRepository.findByUserIdAndDeviceIdAndPlatform(userId, deviceId, platform)
72-
.orElseThrow(() -> new NotFoundException("해당 디바이스를 찾을 수 없어요"));
73-
74-
userDeviceRepository.deleteByUserIdAndDeviceIdAndPlatform(userId, deviceId, platform);
75-
}
76-
77-
public void deleteAllTokens(Long userId) {
78-
userRepository.findById(userId).orElseThrow(() -> new NotFoundException("존재하지 않는 사용자 id예요"));
66+
public void deactivateDeviceIfPresent(Long userId, String deviceId, DevicePlatform platform) {
67+
if (deviceId == null || deviceId.isBlank()) {
68+
return;
69+
}
7970

80-
notificationSettingRepository.deleteByUserId(userId);
81-
userDeviceRepository.deleteByUserId(userId);
71+
DevicePlatform resolvedPlatform = platform != null ? platform : DevicePlatform.IOS;
72+
userDeviceRepository.findByUserIdAndDeviceIdAndPlatform(userId, deviceId, resolvedPlatform)
73+
.ifPresent(UserDevice::deactivateToken);
8274
}
8375

84-
public void deleteInvalidTokens(List<String> tokens) {
76+
public void deactivateInvalidTokens(List<String> tokens) {
8577
if (tokens == null || tokens.isEmpty()) {
8678
return;
8779
}
8880

89-
tokens.stream()
90-
.distinct()
91-
.forEach(userDeviceRepository::deleteByToken);
81+
List<String> validTokens = tokens.stream()
82+
.filter(token -> token != null && !token.isBlank())
83+
.distinct()
84+
.toList();
85+
86+
userDeviceRepository.deactivateByTokens(validTokens);
9287
}
9388
}

src/main/java/org/devkor/apu/saerok_server/domain/notification/core/entity/UserDevice.java

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ public class UserDevice extends Auditable {
2727
@Column(name = "device_id", nullable = false, length = 256)
2828
private String deviceId;
2929

30-
@Column(name = "token", nullable = false, length = 512)
30+
@Column(name = "token", length = 512)
3131
private String token;
3232

3333
@Enumerated(EnumType.STRING)
@@ -38,10 +38,19 @@ public static UserDevice create(User user, String deviceId, String token, Device
3838
UserDevice userDevice = new UserDevice();
3939
userDevice.user = user;
4040
userDevice.deviceId = deviceId;
41-
userDevice.token = token;
4241
userDevice.platform = platform;
42+
userDevice.activateToken(token);
4343
return userDevice;
4444
}
4545

46-
public void updateToken(String newToken) { this.token = newToken; }
46+
public void activateToken(String newToken) {
47+
if (newToken == null || newToken.isBlank()) {
48+
throw new IllegalArgumentException("FCM token은 비어 있을 수 없습니다");
49+
}
50+
this.token = newToken;
51+
}
52+
53+
public void deactivateToken() {
54+
this.token = null;
55+
}
4756
}

src/main/java/org/devkor/apu/saerok_server/domain/notification/core/repository/NotificationSettingRepository.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ public List<Long> findEnabledDeviceIdsByUserAndType(Long userId, NotificationTyp
4646
where ns.userDevice.user.id = :userId
4747
and ns.type = :type
4848
and ns.enabled = true
49+
and ns.userDevice.token is not null
4950
""", Long.class)
5051
.setParameter("userId", userId)
5152
.setParameter("type", type)
@@ -60,6 +61,7 @@ public List<Long> findEnabledDeviceIdsByUserIdsAndType(List<Long> userIds, Notif
6061
where ns.userDevice.user.id in :userIds
6162
and ns.type = :type
6263
and ns.enabled = true
64+
and ns.userDevice.token is not null
6365
""", Long.class)
6466
.setParameter("userIds", userIds)
6567
.setParameter("type", type)

src/main/java/org/devkor/apu/saerok_server/domain/notification/core/repository/UserDeviceRepository.java

Lines changed: 44 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -19,33 +19,29 @@ public class UserDeviceRepository {
1919
public void save(UserDevice userDevice) { em.persist(userDevice); }
2020
public void flush() { em.flush(); }
2121

22-
// 특정 디바이스 삭제
23-
public void deleteByUserIdAndDeviceIdAndPlatform(Long userId, String deviceId, DevicePlatform platform) {
24-
em.createQuery("DELETE FROM UserDevice ud WHERE ud.user.id = :userId AND ud.deviceId = :deviceId AND ud.platform = :platform")
25-
.setParameter("userId", userId)
26-
.setParameter("deviceId", deviceId)
27-
.setParameter("platform", platform)
28-
.executeUpdate();
29-
}
30-
31-
// 모든 토큰 삭제
32-
public int deleteByUserId(Long userId) {
33-
return em.createQuery("DELETE FROM UserDevice ud WHERE ud.user.id = :userId")
34-
.setParameter("userId", userId)
35-
.executeUpdate();
36-
}
22+
public int deactivateByTokens(List<String> tokens) {
23+
if (tokens == null || tokens.isEmpty()) {
24+
return 0;
25+
}
3726

38-
// 개별 토큰 삭제
39-
public void deleteByToken(String token) {
40-
em.createQuery("DELETE FROM UserDevice ud WHERE ud.token = :token")
41-
.setParameter("token", token)
27+
return em.createQuery("""
28+
UPDATE UserDevice ud
29+
SET ud.token = NULL,
30+
ud.updatedAt = CURRENT_TIMESTAMP
31+
WHERE ud.token IN :tokens
32+
""")
33+
.setParameter("tokens", tokens)
4234
.executeUpdate();
4335
}
4436

45-
public int deleteConflictingDevicesForRegistration(Long userId, String deviceId, DevicePlatform platform, String token) {
37+
public int deactivateConflictingTokensForRegistration(Long userId, String deviceId,
38+
DevicePlatform platform, String token) {
4639
return em.createQuery("""
47-
DELETE FROM UserDevice ud
48-
WHERE (
40+
UPDATE UserDevice ud
41+
SET ud.token = NULL,
42+
ud.updatedAt = CURRENT_TIMESTAMP
43+
WHERE ud.token IS NOT NULL
44+
AND (
4945
ud.token = :token
5046
OR (ud.deviceId = :deviceId AND ud.platform = :platform)
5147
)
@@ -62,6 +58,13 @@ AND NOT (
6258
.executeUpdate();
6359
}
6460

61+
// 회원 탈퇴 시에만 사용하는 영구 삭제
62+
public int deleteByUserId(Long userId) {
63+
return em.createQuery("DELETE FROM UserDevice ud WHERE ud.user.id = :userId")
64+
.setParameter("userId", userId)
65+
.executeUpdate();
66+
}
67+
6568
// ID로 디바이스 조회
6669
public Optional<UserDevice> findById(Long id) {
6770
List<UserDevice> results = em.createQuery("SELECT ud FROM UserDevice ud WHERE ud.id = :id", UserDevice.class)
@@ -86,10 +89,13 @@ public Optional<UserDevice> findByUserIdAndDeviceIdAndPlatform(Long userId, Stri
8689
return results.isEmpty() ? Optional.empty() : Optional.of(results.getFirst());
8790
}
8891

89-
public List<UserDevice> findAllByUserId(Long userId) {
90-
return em.createQuery(
91-
"SELECT ud FROM UserDevice ud WHERE ud.user.id = :userId",
92-
UserDevice.class)
92+
public List<UserDevice> findAllActiveByUserId(Long userId) {
93+
return em.createQuery("""
94+
SELECT ud
95+
FROM UserDevice ud
96+
WHERE ud.user.id = :userId
97+
AND ud.token IS NOT NULL
98+
""", UserDevice.class)
9399
.setParameter("userId", userId)
94100
.getResultList();
95101
}
@@ -100,14 +106,24 @@ public List<String> findTokensByUserDeviceIds(List<Long> userDeviceIds) {
100106
return List.of();
101107
}
102108

103-
return em.createQuery("SELECT ud.token FROM UserDevice ud WHERE ud.id IN :userDeviceIds", String.class)
109+
return em.createQuery("""
110+
SELECT ud.token
111+
FROM UserDevice ud
112+
WHERE ud.id IN :userDeviceIds
113+
AND ud.token IS NOT NULL
114+
""", String.class)
104115
.setParameter("userDeviceIds", userDeviceIds)
105116
.getResultList();
106117
}
107118

108119
// 사용자 ID로 모든 FCM 토큰 조회 (사일런트 푸시용)
109120
public List<String> findTokensByUserId(Long userId) {
110-
return em.createQuery("SELECT ud.token FROM UserDevice ud WHERE ud.user.id = :userId", String.class)
121+
return em.createQuery("""
122+
SELECT ud.token
123+
FROM UserDevice ud
124+
WHERE ud.user.id = :userId
125+
AND ud.token IS NOT NULL
126+
""", String.class)
111127
.setParameter("userId", userId)
112128
.getResultList();
113129
}

src/main/java/org/devkor/apu/saerok_server/domain/notification/infra/fcm/FcmMessageClient.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -147,7 +147,7 @@ private void cleanupInvalidTokens(List<String> fcmTokens, BatchResponse response
147147

148148
if (!invalid.isEmpty()) {
149149
try {
150-
userDeviceCommandService.deleteInvalidTokens(invalid);
150+
userDeviceCommandService.deactivateInvalidTokens(invalid);
151151
} catch (Exception e) {
152152
log.warn("Invalid FCM tokens cleanup failed ({} tokens): {}", invalid.size(), e.getMessage());
153153
}

src/main/java/org/devkor/apu/saerok_server/domain/notification/infra/fcm/FcmPushGateway.java

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ public class FcmPushGateway implements PushGateway {
3030
@Override
3131
public void sendToUser(Long userId, NotificationType type, PushMessageCommand cmd) {
3232

33-
userDeviceRepository.findAllByUserId(userId)
33+
userDeviceRepository.findAllActiveByUserId(userId)
3434
.forEach(backfillService::ensureDefaults);
3535

3636
List<Long> deviceIds = settingRepository.findEnabledDeviceIdsByUserAndType(userId, type);
@@ -66,7 +66,7 @@ public void sendToUsersDeduplicated(List<PushTarget> targets) {
6666
continue;
6767
}
6868

69-
userDeviceRepository.findAllByUserId(userId)
69+
userDeviceRepository.findAllActiveByUserId(userId)
7070
.forEach(backfillService::ensureDefaults);
7171

7272
List<Long> deviceIds = settingRepository.findEnabledDeviceIdsByUserAndType(userId, type);

0 commit comments

Comments
 (0)