fix(notifications): panic-level defensive GET /me/notifications

This commit is contained in:
Александр Зимин
2026-05-14 21:50:51 +00:00
parent 1d8aab1560
commit f028ec5796
@@ -21,6 +21,7 @@ import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
import java.time.OffsetDateTime; import java.time.OffsetDateTime;
import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
@@ -67,58 +68,99 @@ public class MeNotificationsController {
public Map<String, Object> list( public Map<String, Object> list(
@RequestParam(required = false) String status, @RequestParam(required = false) String status,
@RequestParam(defaultValue = "20") int limit) { @RequestParam(defaultValue = "20") int limit) {
// Panic-level defensiveness: this endpoint is hit by the bell badge
String currentUserSub = requireCurrentUserSub(); // poll on EVERY page (every 30s). A 500 here breaks global UX, even
String emailKey = currentUserEmail(); // for users who don't care about notifications.
int safeLimit = Math.min(limit, MAX_LIMIT); //
// Strategy: wrap the entire handler in try/catch. Anonymous request
// Hardened against runtime exceptions — endpoint hit от bell on каждой // OR unauthenticated context returns empty cleanly (no 403 throw —
// странице; 500 здесь сломает global UX. Каждая под-операция wraped // bell на logged-out landing not worth a guard violation). Any other
// в try/catch с structured log + sensible fallback (empty list / 0 count). // failure also returns empty + logs the cause for follow-up.
// Backend can still see issues via logs; user sees empty drawer instead
// of "Внутренняя ошибка сервера".
if (emailKey == null || emailKey.isBlank()) {
// No email claim in JWT — can't look up notifications. Return empty.
log.debug("No email claim in JWT for user sub={}, returning empty notifications", currentUserSub);
return Map.of("items", List.of(), "totalUnread", 0);
}
List<NotificationItemDto> items;
try { try {
Page<NotificationLog> page = notificationLogRepository.findByRecipientOrderBySentAtDesc( String sub = currentUserSubSafe();
emailKey, PageRequest.of(0, safeLimit)); if (sub == null) {
items = page.getContent().stream() // Anonymous / no JWT → no notifications possible. Don't throw 403,
.filter(n -> status == null || status.equalsIgnoreCase(n.getStatus().name())) // just say "no notifications" — semantically true.
.map(n -> { return emptyResponse();
try { }
return toDto(n, currentUserSub);
} catch (RuntimeException e) {
log.warn("Failed to map NotificationLog id={} to DTO: {}", n.getId(), e.getMessage());
return null;
}
})
.filter(java.util.Objects::nonNull)
.toList();
} catch (RuntimeException e) {
log.error("Failed to load notifications for user sub={}, email={}: {}",
currentUserSub, emailKey, e.getMessage(), e);
items = List.of();
}
long totalUnread = 0L; String emailKey = currentUserEmailSafe();
if (emailKey == null || emailKey.isBlank()) {
// Authenticated but no email claim. Recipient lookup uses email,
// so without it we have nothing to fetch.
log.debug("No email claim in JWT for user sub={}", sub);
return emptyResponse();
}
int safeLimit = Math.min(Math.max(limit, 1), MAX_LIMIT);
List<NotificationItemDto> items;
try {
Page<NotificationLog> page = notificationLogRepository.findByRecipientOrderBySentAtDesc(
emailKey, PageRequest.of(0, safeLimit));
items = page.getContent().stream()
.filter(n -> status == null || (n.getStatus() != null && status.equalsIgnoreCase(n.getStatus().name())))
.map(n -> {
try {
return toDto(n, sub);
} catch (RuntimeException e) {
log.warn("Failed to map NotificationLog id={} to DTO: {}", n.getId(), e.getMessage());
return null;
}
})
.filter(java.util.Objects::nonNull)
.toList();
} catch (RuntimeException e) {
log.error("Failed to load notifications for user sub={}, email={}: {}",
sub, emailKey, e.getMessage(), e);
items = List.of();
}
long totalUnread = 0L;
try {
totalUnread = countUnread(sub, emailKey);
} catch (RuntimeException e) {
log.error("Failed to count unread notifications for user sub={}: {}",
sub, e.getMessage(), e);
}
Map<String, Object> response = new HashMap<>();
response.put("items", items);
response.put("totalUnread", totalUnread);
return response;
} catch (RuntimeException e) {
// Last-resort catch: anything we missed. Don't propagate to the
// global handler (which would 500). Log and return empty.
log.error("Unexpected error in GET /me/notifications", e);
return emptyResponse();
}
}
private static Map<String, Object> emptyResponse() {
Map<String, Object> r = new HashMap<>();
r.put("items", List.of());
r.put("totalUnread", 0L);
return r;
}
/** Returns sub claim from JWT or null if missing/anonymous. Never throws. */
private String currentUserSubSafe() {
try { try {
totalUnread = countUnread(currentUserSub, emailKey); return currentUserSub();
} catch (RuntimeException e) { } catch (RuntimeException e) {
log.error("Failed to count unread notifications for user sub={}: {}", log.warn("Failed to read JWT sub claim: {}", e.getMessage());
currentUserSub, e.getMessage(), e); return null;
// Fallback to 0 — better to under-count than show user a 500.
} }
}
return Map.of( /** Returns email claim from JWT or null. Never throws. */
"items", items, private String currentUserEmailSafe() {
"totalUnread", totalUnread try {
); return currentUserEmail();
} catch (RuntimeException e) {
log.warn("Failed to read JWT email claim: {}", e.getMessage());
return null;
}
} }
/** /**