Skip to content

Commit 28d5062

Browse files
authored
Merge pull request #258 from FunD-StockProject/fix/notification-same-device-token
Fix: 동일 디바이스 token으로 인해 푸시 알림 오던 이슈 해결을 위한 로직 개선
2 parents 13fd47b + 5c15c94 commit 28d5062

9 files changed

Lines changed: 214 additions & 24 deletions

File tree

src/main/java/com/fund/stockProject/auth/controller/AuthController.java

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -200,9 +200,10 @@ public ResponseEntity<LoginResponse> reissue(
200200
@ApiResponse(responseCode = "401", description = "인증 필요")
201201
})
202202
public ResponseEntity<?> logout(
203+
@AuthenticationPrincipal(expression = "id") @Parameter(hidden = true) Integer userId,
203204
@RequestBody @Parameter(description = "Refresh 토큰 DTO", required = true) RefreshTokenRequest requestDto) {
204205
try {
205-
tokenService.logout(requestDto.getRefreshToken());
206+
tokenService.logout(userId, requestDto.getRefreshToken(), requestDto.getDeviceToken());
206207
return ResponseEntity.ok(Map.of("message", "Logout successful"));
207208
} catch (Exception e) {
208209
// 예를 들어 유효하지 않은 토큰 포맷 등의 이유로 실패했을 때

src/main/java/com/fund/stockProject/auth/dto/RefreshTokenRequest.java

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
package com.fund.stockProject.auth.dto;
22

3-
import com.fasterxml.jackson.annotation.JsonProperty;
43
import io.swagger.v3.oas.annotations.media.Schema;
54
import lombok.Getter;
65
import lombok.NoArgsConstructor;
@@ -11,4 +10,7 @@
1110
public class RefreshTokenRequest {
1211
@Schema(description = "Refresh 토큰 문자열", example = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", requiredMode = Schema.RequiredMode.REQUIRED)
1312
private String refreshToken;
13+
14+
@Schema(description = "현재 디바이스의 FCM 토큰(로그아웃 시 해당 토큰 비활성화)", example = "fcm_token_abcdef123456", requiredMode = Schema.RequiredMode.NOT_REQUIRED)
15+
private String deviceToken;
1416
}

src/main/java/com/fund/stockProject/auth/service/TokenService.java

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55
import com.fund.stockProject.auth.dto.RefreshTokenRequest;
66
import com.fund.stockProject.auth.entity.RefreshToken;
77
import com.fund.stockProject.auth.repository.RefreshTokenRepository;
8+
import com.fund.stockProject.notification.service.DeviceTokenService;
9+
import com.fund.stockProject.user.entity.User;
810
import com.fund.stockProject.user.repository.UserRepository;
911
import com.fund.stockProject.security.util.JwtUtil;
1012
import io.jsonwebtoken.ExpiredJwtException;
@@ -25,6 +27,7 @@ public class TokenService {
2527
private final JwtUtil jwtUtil;
2628
private final RefreshTokenRepository refreshTokenRepository; // RefreshRepository 주입
2729
private final UserRepository userRepository;
30+
private final DeviceTokenService deviceTokenService;
2831

2932
@Value("${spring.jwt.access-expiration-ms}")
3033
private Long accessTokenExpirationMs;
@@ -130,7 +133,7 @@ public LoginResponse reissueTokens(RefreshTokenRequest request) { // String 토
130133
}
131134

132135
@Transactional
133-
public void logout(String refreshToken) {
136+
public void logout(Integer userId, String refreshToken, String deviceToken) {
134137
// 1. Refresh Token 유효성 검증 (null 또는 비어 있는지)
135138
if (refreshToken == null || refreshToken.trim().isEmpty()) {
136139
throw new IllegalArgumentException("Refresh token is required for logout.");
@@ -154,6 +157,30 @@ public void logout(String refreshToken) {
154157
// 3. DB에서 Refresh Token 삭제
155158
// deleteBy... 메소드는 대상이 없어도 오류를 발생시키지 않으므로, find.. 없이 바로 사용 가능
156159
refreshTokenRepository.deleteByRefreshToken(refreshToken);
160+
161+
// 4. 디바이스 토큰 비활성화
162+
// 멀티 디바이스 환경 보호:
163+
// deviceToken이 전달된 경우에만 해당 토큰을 비활성화
164+
Integer resolvedUserId = resolveUserId(userId, refreshToken);
165+
if (resolvedUserId != null) {
166+
deviceTokenService.unregisterOnLogout(resolvedUserId, deviceToken);
167+
} else {
168+
log.warn("Skip device token cleanup during logout: cannot resolve user. hasAuthPrincipal={}", userId != null);
169+
}
170+
}
171+
172+
private Integer resolveUserId(Integer authenticatedUserId, String refreshToken) {
173+
if (authenticatedUserId != null) {
174+
return authenticatedUserId;
175+
}
176+
177+
try {
178+
String email = jwtUtil.getEmail(refreshToken);
179+
return userRepository.findByEmail(email).map(User::getId).orElse(null);
180+
} catch (Exception e) {
181+
log.warn("Failed to resolve userId from refresh token during logout: {}", e.getMessage());
182+
return null;
183+
}
157184
}
158185

159186
private UserProfile getUserProfile(String email) {
@@ -164,4 +191,4 @@ private UserProfile getUserProfile(String email) {
164191

165192
private record UserProfile(String nickname, String profileImageUrl) {}
166193

167-
}
194+
}

src/main/java/com/fund/stockProject/notification/repository/NotificationRepository.java

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,11 @@ public interface NotificationRepository extends JpaRepository<Notification, Inte
2323
WHERE n.user.id = :userId
2424
AND (
2525
n.notificationType <> :scoreSpike
26-
OR (n.changeAbs IS NOT NULL AND n.changeAbs <> 0 AND n.oldScore IS NOT NULL AND n.newScore IS NOT NULL)
26+
OR (
27+
COALESCE(n.changeAbs, 1) <> 0
28+
AND n.title IS NOT NULL
29+
AND n.body IS NOT NULL
30+
)
2731
)
2832
ORDER BY n.createdAt DESC
2933
""")
@@ -43,7 +47,11 @@ Page<Notification> findByUserIdAndNotificationTypeOrderByCreatedAtDesc(
4347
AND n.notificationType = :notificationType
4448
AND (
4549
:notificationType <> :scoreSpike
46-
OR (n.changeAbs IS NOT NULL AND n.changeAbs <> 0 AND n.oldScore IS NOT NULL AND n.newScore IS NOT NULL)
50+
OR (
51+
COALESCE(n.changeAbs, 1) <> 0
52+
AND n.title IS NOT NULL
53+
AND n.body IS NOT NULL
54+
)
4755
)
4856
ORDER BY n.createdAt DESC
4957
""")
@@ -63,7 +71,11 @@ SELECT COUNT(n) FROM Notification n
6371
AND n.isRead = false
6472
AND (
6573
n.notificationType <> :scoreSpike
66-
OR (n.changeAbs IS NOT NULL AND n.changeAbs <> 0 AND n.oldScore IS NOT NULL AND n.newScore IS NOT NULL)
74+
OR (
75+
COALESCE(n.changeAbs, 1) <> 0
76+
AND n.title IS NOT NULL
77+
AND n.body IS NOT NULL
78+
)
6779
)
6880
""")
6981
long countValidByUserIdAndIsReadFalse(

src/main/java/com/fund/stockProject/notification/repository/UserDeviceTokenRepository.java

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
package com.fund.stockProject.notification.repository;
22

3+
import com.fund.stockProject.notification.domain.DevicePlatform;
34
import com.fund.stockProject.notification.entity.UserDeviceToken;
45
import org.springframework.data.jpa.repository.JpaRepository;
56
import org.springframework.data.jpa.repository.Modifying;
@@ -17,11 +18,46 @@ public interface UserDeviceTokenRepository extends JpaRepository<UserDeviceToken
1718
List<String> findActiveTokens(@Param("userId") Integer userId);
1819

1920
Optional<UserDeviceToken> findByToken(String token);
21+
List<UserDeviceToken> findAllByToken(String token);
2022

2123
// Explicit query for ownership check
2224
@Query("select t from UserDeviceToken t where t.token = :token and t.user.id = :userId")
2325
Optional<UserDeviceToken> findByTokenAndUserId(@Param("token") String token, @Param("userId") Integer userId);
2426

27+
@Modifying
28+
@Transactional
29+
@Query("""
30+
UPDATE UserDeviceToken t
31+
SET t.isActive = false
32+
WHERE t.user.id = :userId
33+
""")
34+
int deactivateAllByUserId(@Param("userId") Integer userId);
35+
36+
@Modifying
37+
@Transactional
38+
@Query("""
39+
UPDATE UserDeviceToken t
40+
SET t.isActive = false
41+
WHERE t.user.id = :userId
42+
AND t.token = :token
43+
""")
44+
int deactivateByTokenAndUserId(@Param("token") String token, @Param("userId") Integer userId);
45+
46+
@Modifying
47+
@Transactional
48+
@Query("""
49+
UPDATE UserDeviceToken t
50+
SET t.isActive = false
51+
WHERE t.user.id = :userId
52+
AND t.platform = :platform
53+
AND t.token <> :currentToken
54+
""")
55+
int deactivateByUserIdAndPlatformExceptToken(
56+
@Param("userId") Integer userId,
57+
@Param("platform") DevicePlatform platform,
58+
@Param("currentToken") String currentToken
59+
);
60+
2561
// 사용자의 모든 디바이스 토큰 삭제
2662
@Modifying
2763
@Transactional

src/main/java/com/fund/stockProject/notification/service/DeviceTokenService.java

Lines changed: 42 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@
1111
import org.springframework.stereotype.Service;
1212
import org.springframework.transaction.annotation.Transactional;
1313

14+
import java.util.List;
15+
1416
@Service
1517
@RequiredArgsConstructor
1618
public class DeviceTokenService {
@@ -20,32 +22,61 @@ public class DeviceTokenService {
2022

2123
@Transactional
2224
public void registerToken(Integer userId, RegisterDeviceTokenRequest request) {
23-
String token = request.getToken();
25+
String token = request.getToken() != null ? request.getToken().trim() : null;
26+
if (token == null || token.isBlank()) {
27+
throw new IllegalArgumentException("Token is required");
28+
}
29+
2430
DevicePlatform platform = request.getPlatform();
31+
if (platform == null) {
32+
throw new IllegalArgumentException("Platform is required");
33+
}
2534

2635
User user = userRepository.findById(userId)
2736
.orElseThrow(() -> new IllegalArgumentException("User not found"));
2837

29-
// Upsert by unique token
30-
UserDeviceToken entity = tokenRepository.findByToken(token)
31-
.orElseGet(() -> UserDeviceToken.builder().token(token).build());
38+
// Defensive dedup: DB unique 제약이 누락/깨진 환경에서도 한 토큰은 한 사용자만 소유하도록 보정
39+
List<UserDeviceToken> duplicates = tokenRepository.findAllByToken(token);
40+
UserDeviceToken entity = duplicates.isEmpty()
41+
? UserDeviceToken.builder().token(token).build()
42+
: duplicates.get(0);
43+
44+
// 동일 사용자/플랫폼에서 기존 토큰은 비활성화하고 최신 토큰 1개만 유지
45+
tokenRepository.deactivateByUserIdAndPlatformExceptToken(userId, platform, token);
3246

3347
entity.setUser(user);
3448
entity.setPlatform(platform);
3549
entity.setIsActive(true);
36-
3750
tokenRepository.save(entity);
51+
52+
if (duplicates.size() > 1) {
53+
for (int i = 1; i < duplicates.size(); i++) {
54+
UserDeviceToken duplicate = duplicates.get(i);
55+
duplicate.setIsActive(false);
56+
tokenRepository.save(duplicate);
57+
}
58+
}
3859
}
3960

4061
@Transactional
4162
public void unregisterToken(Integer userId, UnregisterDeviceTokenRequest request) {
42-
String token = request.getToken();
63+
String token = request.getToken() != null ? request.getToken().trim() : null;
64+
if (token == null || token.isBlank()) {
65+
throw new IllegalArgumentException("Token is required");
66+
}
4367

44-
tokenRepository.findByTokenAndUserId(token, userId)
45-
.ifPresent(entity -> {
46-
entity.setIsActive(false);
47-
tokenRepository.save(entity);
48-
});
68+
tokenRepository.deactivateByTokenAndUserId(token, userId);
4969
// idempotent: no error if not found or already inactive
5070
}
71+
72+
@Transactional
73+
public void unregisterOnLogout(Integer userId, String token) {
74+
String normalized = token != null ? token.trim() : null;
75+
if (normalized == null || normalized.isBlank()) {
76+
// 멀티 디바이스 로그인 환경에서는 deviceToken 미전달 로그아웃 시
77+
// 다른 기기의 푸시 토큰까지 비활성화하면 안 되므로 no-op 처리
78+
return;
79+
}
80+
tokenRepository.deactivateByTokenAndUserId(normalized, userId);
81+
}
5182
}

src/main/java/com/fund/stockProject/notification/service/OutboxDispatcher.java

Lines changed: 51 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,13 @@
22

33
import com.fasterxml.jackson.core.type.TypeReference;
44
import com.fasterxml.jackson.databind.ObjectMapper;
5+
import com.fund.stockProject.notification.domain.NotificationType;
56
import com.fund.stockProject.notification.entity.Notification;
67
import com.fund.stockProject.notification.entity.OutboxEvent;
78
import com.fund.stockProject.notification.repository.NotificationRepository;
89
import com.fund.stockProject.notification.repository.OutboxRepository;
10+
import com.fund.stockProject.preference.domain.PreferenceType;
11+
import com.fund.stockProject.preference.repository.PreferenceRepository;
912
import lombok.extern.slf4j.Slf4j;
1013
import org.springframework.beans.factory.annotation.Autowired;
1114
import org.springframework.data.domain.PageRequest;
@@ -23,6 +26,7 @@
2326
public class OutboxDispatcher {
2427
private final OutboxRepository outboxRepo;
2528
private final NotificationRepository notificationRepo;
29+
private final PreferenceRepository preferenceRepo;
2630
private final SsePushService ssePushService; // 웹/웹뷰 실시간
2731
private final ObjectMapper om;
2832

@@ -32,11 +36,13 @@ public class OutboxDispatcher {
3236
public OutboxDispatcher(
3337
OutboxRepository outboxRepo,
3438
NotificationRepository notificationRepo,
39+
PreferenceRepository preferenceRepo,
3540
SsePushService ssePushService,
3641
ObjectMapper om,
3742
@Autowired(required = false) FcmPushService fcmPushService) {
3843
this.outboxRepo = outboxRepo;
3944
this.notificationRepo = notificationRepo;
45+
this.preferenceRepo = preferenceRepo;
4046
this.ssePushService = ssePushService;
4147
this.om = om;
4248
this.fcmPushService = fcmPushService;
@@ -108,9 +114,31 @@ public void dispatchRetry() {
108114
private void processEvent(OutboxEvent e) {
109115
try {
110116
Map<String, Object> payload = om.readValue(e.getPayload(), new TypeReference<>(){});
111-
Integer nId = Integer.valueOf(payload.get("notificationId").toString());
112-
Integer userId = Integer.valueOf(payload.get("userId").toString());
113-
Notification n = notificationRepo.findById(nId).orElseThrow();
117+
Integer nId = toInteger(payload.get("notificationId"));
118+
Integer userId = toInteger(payload.get("userId"));
119+
if (nId == null || userId == null) {
120+
markProcessedWithoutSend(e, "invalid_payload");
121+
return;
122+
}
123+
124+
Notification n = notificationRepo.findById(nId).orElse(null);
125+
if (n == null) {
126+
markProcessedWithoutSend(e, "notification_not_found");
127+
return;
128+
}
129+
130+
if (n.getNotificationType() == NotificationType.SCORE_SPIKE) {
131+
Integer stockId = n.getStock() != null ? n.getStock().getId() : toInteger(payload.get("stockId"));
132+
boolean enabled = stockId != null && preferenceRepo
133+
.existsByUserIdAndStockIdAndPreferenceTypeAndNotificationEnabled(
134+
userId, stockId, PreferenceType.BOOKMARK, true);
135+
136+
if (!enabled) {
137+
notificationRepo.delete(n);
138+
markProcessedWithoutSend(e, "notification_disabled_or_unbookmarked");
139+
return;
140+
}
141+
}
114142

115143
// SSE 푸시 (웹/웹뷰)
116144
ssePushService.pushToUser(userId, n);
@@ -140,6 +168,26 @@ private void processEvent(OutboxEvent e) {
140168
}
141169
}
142170

171+
private Integer toInteger(Object value) {
172+
if (value == null) {
173+
return null;
174+
}
175+
if (value instanceof Number number) {
176+
return number.intValue();
177+
}
178+
try {
179+
return Integer.valueOf(value.toString());
180+
} catch (NumberFormatException e) {
181+
return null;
182+
}
183+
}
184+
185+
private void markProcessedWithoutSend(OutboxEvent event, String reason) {
186+
event.setStatus("PROCESSED");
187+
outboxRepo.save(event);
188+
log.info("Skipped notification dispatch: eventId={}, reason={}", event.getId(), reason);
189+
}
190+
143191
/**
144192
* 에러 처리 및 재시도 로직
145193
*/

0 commit comments

Comments
 (0)