Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions docs/notifications-debug-events.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
# Notification Debug Events

This document lists standardized debug event keys emitted by `NotificationService`.

## Configuration

- `notification.debug.enabled`: `true` to enable debug logs
- `notification.debug.sample-rate`: `0.0` to `1.0` sampling rate

## Event Keys

- `SSE_SUBSCRIBED`
Emitted when a user subscribes to SSE.
Fields: `userId`, `emitterId`, `hasLastEventId`
- `NOTIFICATION_SKIPPED`
Emitted when a notification is skipped.
Fields: `userId`, `type`, `reason` (`notificationDisabled` or `marketingConsentDisabled`)
- `NOTIFICATION_CREATED`
Emitted after a notification is stored and emitters are resolved.
Fields: `id`, `userId`, `type`, `emitters`
- `SSE_SENT`
Emitted after a single SSE event is sent.
Fields: `emitterId`, `eventId`
- `READ_DENIED`
Emitted when a user attempts to read another user’s notification.
Fields: `notificationId`, `userId`, `receiverId`
- `READ_OK`
Emitted after a notification is marked read.
Fields: `notificationId`, `userId`
- `PUSH_SKIPPED`
Emitted when push send is skipped.
Fields: `userId`, `type` (optional), `reason` (`notificationDisabled` or `missingToken`)
- `PUSH_SENT`
Emitted after a push is sent successfully.
Fields: `userId`, `type`, `token` (masked)
- `PUSH_FAILED`
Emitted after a push send fails.
Fields: `userId`, `type`, `token` (masked)
- `SCHEDULED_SKIPPED`
Emitted when a scheduled notification is skipped.
Fields: `type`, `userId`, `reason` (`notificationDisabled`)
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ public void createDeliveryRequest(Long userId, DeliveryRequest request) {
// 알림을 받는 사람(owner)과 보내는 사람(writer)이 자기 자신인 시스템 알림
notificationService.send(
user,
user,
null,
NotificationType.SEED_DELIVERY,
"/deliveries/" + savedDelivery.getId(), // 배송 상세 조회 페이지 URL
null);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ public void waterOwnGarden(Long ownerId, Garden garden) {
throw new CustomApiException(ErrorCode.WATERING_COOL_DOWN);
}

User owner = garden.getUser();
garden.increaseWaterCount();
wishTreeService.addPointsToWishTree(ownerId, WATERING_POINTS);
garden.recordOwnerWateringTime(); // 주인이 물 준 시간 기록
Expand All @@ -107,6 +108,7 @@ public void waterFriendGarden(Long actorId, Garden garden) {

wishTreeService.addPointsToWishTree(actor.getId(), WATERING_POINTS);
garden.increaseWaterCount();
garden.recordFriendWateringTime();

LocalDateTime nowInSeoul = LocalDateTime.now(ZoneId.of("Asia/Seoul"));

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,9 +34,10 @@ public class HomeService {
private final DiaryRepository diaryRepository;
private final UserQuizRepository userQuizRepository;
private final DailyQuestionAnswerRepository dailyQuestionAnswerRepository;
private static final ZoneId KOREA_ZONE = ZoneId.of("Asia/Seoul");

private LocalDateTime getStartOfCurrentSunlightDay() {
LocalDateTime now = LocalDateTime.now(ZoneId.of("Asia/Seoul"));
LocalDateTime now = LocalDateTime.now(KOREA_ZONE);
LocalDateTime todaySixAM = now.toLocalDate().atTime(6, 0);
return now.isBefore(todaySixAM) ? todaySixAM.minusDays(1) : todaySixAM;
}
Expand Down Expand Up @@ -152,7 +153,7 @@ public HomeResponseDto getHomeScreenData(Long userId) {
})
.collect(Collectors.toList());

LocalDate today = LocalDate.now();
LocalDate today = LocalDate.now(KOREA_ZONE);
LocalDateTime startOfDay = today.atStartOfDay();
LocalDateTime endOfDay = today.atTime(23, 59, 59);

Expand Down Expand Up @@ -193,7 +194,7 @@ public HomeResponseDto getHomeScreenData(Long userId) {
}

public PannelResponseDTO getPannelData(User user) {
LocalDate today = LocalDate.now();
LocalDate today = LocalDate.now(KOREA_ZONE);
LocalDateTime startOfDay = today.atStartOfDay();
LocalDateTime endOfDay = today.atTime(23, 59, 59);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,23 +6,24 @@
@Getter
@RequiredArgsConstructor
public enum NotificationType {
FOLLOW("follow", "%s님이 회원님을 팔로우하기 시작했습니다."),
POLLEN("pollen", "%s님이 회원님의 식물에 꽃가루를 주었습니다."),
FEED_LIKE("feed_like", "%s님이 회원님의 일기를 좋아합니다."),
DIARY_LIKE("diary_like", "%s님이 회원님의 일기를 좋아합니다."),
AVATAR_POST_LIKE("avatar_post_like", "%s님이 회원님의 포스트를 좋아합니다."),
FEED_COMMENT("feed_comment", "%s님이 회원님의 일기에 댓글을 남겼습니다."),
GUESTBOOK("guestbook", "%s님이 방명록에 글을 남겼습니다."),
WATERING("watering", "%s에게 물 줄 시간이에요!"),
SUNSHINE("sunshine", "식물에게 햇빛을 줄 시간이에요!"),
DIARY_COMMENT("diary_comment", "내 일기에 누군가 댓글을 달았습니다."),
AVATAR_POST_COMMENT("avatar_comment", "내 아바타 포스트에 누군가 댓글을 달았습니다."),
WATERING_BY_FRIEND("watering_by_friend", "누군가 내 아바타에게 물을 주었습니다."),
POLLEN_AVAILABLE("pollen_available", "꽃가루가 쌓여있어요, 친구들에게 나눠봐요!"),
REPORT_PROCESSED("report_processed", "회원님의 신고가 처리되었습니다."),
REPORT_RECEIVED("report_received", "누군가 회원님을 신고했습니다. 뻐꾸기가 지켜보고 있어요!"),
SEED_DELIVERY("seed_delivery", "씨앗 배송이 시작되었어요! 송장번호: %s");
FOLLOW("follow", "%s님이 회원님을 팔로우하기 시작했습니다.", false),
POLLEN("pollen", "%s님이 회원님의 식물에 꽃가루를 주었습니다.", false),
FEED_LIKE("feed_like", "%s님이 회원님의 일기를 좋아합니다.", false),
DIARY_LIKE("diary_like", "%s님이 회원님의 일기를 좋아합니다.", false),
AVATAR_POST_LIKE("avatar_post_like", "%s님이 회원님의 포스트를 좋아합니다.", false),
FEED_COMMENT("feed_comment", "%s님이 회원님의 일기에 댓글을 남겼습니다.", false),
GUESTBOOK("guestbook", "%s님이 방명록에 글을 남겼습니다.", false),
WATERING("watering", "%s에게 물 줄 시간이에요!", false),
SUNSHINE("sunshine", "식물에게 햇빛을 줄 시간이에요!", false),
DIARY_COMMENT("diary_comment", "내 일기에 누군가 댓글을 달았습니다.", false),
AVATAR_POST_COMMENT("avatar_comment", "내 아바타 포스트에 누군가 댓글을 달았습니다.", false),
WATERING_BY_FRIEND("watering_by_friend", "누군가 내 아바타에게 물을 주었습니다.", false),
POLLEN_AVAILABLE("pollen_available", "꽃가루가 쌓여있어요, 친구들에게 나눠봐요!", false),
REPORT_PROCESSED("report_processed", "회원님의 신고가 처리되었습니다.", false),
REPORT_RECEIVED("report_received", "누군가 회원님을 신고했습니다. 뻐꾸기가 지켜보고 있어요!", false),
SEED_DELIVERY("seed_delivery", "씨앗 배송이 시작되었어요!", false);

private final String type;
private final String messageTemplate;
private final boolean marketing;
}
Original file line number Diff line number Diff line change
Expand Up @@ -46,8 +46,9 @@ public ResponseEntity<ApiResponse<List<NotificationResponse>>> getNotifications(

@Operation(summary = "알림 읽음 처리", description = "내 알림을 읽음 처리합니다")
@PatchMapping("/{id}/read")
public ResponseEntity<ApiResponse<Void>> readNotification(@PathVariable Long id) {
notificationService.readNotification(id);
public ResponseEntity<ApiResponse<Void>> readNotification(
@AuthenticationPrincipal User user, @PathVariable Long id) {
notificationService.readNotification(user.getId(), id);
return ResponseEntity.ok(ApiResponse.success(null));
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,11 @@
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.ThreadLocalRandom;
import java.util.stream.Collectors;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
Expand All @@ -41,6 +43,12 @@ public class NotificationService {
private final EmitterRepository emitterRepository;
private final NotificationRepository notificationRepository;

@Value("${notification.debug.enabled:false}")
private boolean notificationDebugEnabled;

@Value("${notification.debug.sample-rate:1.0}")
private double notificationDebugSampleRate;

public SseEmitter subscribe(Long userId, String lastEventId) {
String emitterId = makeTimeIncludeId(userId);
SseEmitter emitter = emitterRepository.save(emitterId, new SseEmitter(DEFAULT_TIMEOUT));
Expand All @@ -54,6 +62,12 @@ public SseEmitter subscribe(Long userId, String lastEventId) {
sendLostData(lastEventId, userId, emitterId, emitter);
}

debugEvent(
"SSE_SUBSCRIBED",
"userId={}, emitterId={}, hasLastEventId={}",
userId,
emitterId,
hasLostData(lastEventId));
return emitter;
}

Expand All @@ -63,13 +77,37 @@ public void send(
NotificationType notificationType,
String url,
String thumbnailUrl) {
if (!Boolean.TRUE.equals(receiver.getNotificationEnabled())) {
debugEvent(
"NOTIFICATION_SKIPPED",
"userId={}, type={}, reason=notificationDisabled",
receiver.getId(),
notificationType);
return;
}
if (notificationType.isMarketing() && !Boolean.TRUE.equals(receiver.getMarketingConsent())) {
debugEvent(
"NOTIFICATION_SKIPPED",
"userId={}, type={}, reason=marketingConsentDisabled",
receiver.getId(),
notificationType);
return;
}

Notification notification =
notificationRepository.save(
createNotification(receiver, sender, notificationType, url, thumbnailUrl));
String receiverId = String.valueOf(receiver.getId());
String eventId = receiverId + "_" + System.currentTimeMillis();
Map<String, SseEmitter> emitters =
emitterRepository.findAllEmitterStartWithByUserId(receiverId);
debugEvent(
"NOTIFICATION_CREATED",
"id={}, userId={}, type={}, emitters={}",
notification.getId(),
receiver.getId(),
notificationType,
emitters.size());
emitters.forEach(
(key, emitter) -> {
emitterRepository.saveEventCache(key, notification);
Expand All @@ -81,6 +119,7 @@ public void send(
private void sendNotification(SseEmitter emitter, String eventId, String emitterId, Object data) {
try {
emitter.send(SseEmitter.event().id(eventId).name("sse").data(data));
debugEvent("SSE_SENT", "emitterId={}, eventId={}", emitterId, eventId);
} catch (IOException exception) {
emitterRepository.deleteById(emitterId);
log.error("SSE 연결 오류!", exception);
Expand Down Expand Up @@ -134,12 +173,22 @@ public List<NotificationResponse> getNotifications(Long userId) {
}

@Transactional
public void readNotification(Long notificationId) {
public void readNotification(Long userId, Long notificationId) {
Notification notification =
notificationRepository
.findById(notificationId)
.orElseThrow(() -> new IllegalArgumentException("존재하지 않는 알림입니다."));
if (!notification.getReceiver().getId().equals(userId)) {
debugEvent(
"READ_DENIED",
"notificationId={}, userId={}, receiverId={}",
notificationId,
userId,
notification.getReceiver().getId());
throw new IllegalArgumentException("알림을 읽을 권한이 없습니다.");
}
notification.read();
debugEvent("READ_OK", "notificationId={}, userId={}", notificationId, userId);
}

public void registerOrUpdateDeviceToken(Long userId, NotificationTokenRequest request) {
Expand Down Expand Up @@ -183,6 +232,14 @@ public void updateNotificationSettings(Long userId, NotificationSettingsRequest
}

private void sendPushNotification(User receiver, Notification notification) {
if (!Boolean.TRUE.equals(receiver.getNotificationEnabled())) {
debugEvent(
"PUSH_SKIPPED",
"userId={}, type={}, reason=notificationDisabled",
receiver.getId(),
notification.getNotificationType());
return;
}
Optional<DeviceToken> deviceTokenOptional = deviceTokenRepository.findByUser(receiver);
if (deviceTokenOptional.isPresent()) {
DeviceToken deviceToken = deviceTokenOptional.get();
Expand All @@ -195,16 +252,31 @@ private void sendPushNotification(User receiver, Notification notification) {
.build();
try {
FirebaseMessaging.getInstance().send(message);
debugEvent(
"PUSH_SENT",
"userId={}, type={}, token={}",
receiver.getId(),
notification.getNotificationType(),
maskToken(deviceToken.getToken()));
log.info("푸시 알림 전송 성공: {}", notification.getContent());
} catch (FirebaseMessagingException e) {
if ("UNREGISTERED".equals(e.getMessagingErrorCode().name())) {
log.warn("Device token is no longer valid. Deleting token: {}", deviceToken.getToken());
log.warn(
"Device token is no longer valid. Deleting token: {}",
maskToken(deviceToken.getToken()));
deviceTokenRepository.delete(deviceToken);
} else {
debugEvent(
"PUSH_FAILED",
"userId={}, type={}, token={}",
receiver.getId(),
notification.getNotificationType(),
maskToken(deviceToken.getToken()));
log.error("푸시 알림 전송 실패", e);
}
}
} else {
debugEvent("PUSH_SKIPPED", "userId={}, reason=missingToken", receiver.getId());
log.warn("디바이스 토큰을 찾을 수 없어 푸시 알림을 전송할 수 없습니다. userId: {}", receiver.getId());
}
}
Expand All @@ -213,6 +285,13 @@ private void sendPushNotification(User receiver, Notification notification) {
public void sendSunshineNotification() {
List<User> users = userRepository.findAll(); // 모든 유저에게 보낼 경우
for (User user : users) {
if (!Boolean.TRUE.equals(user.getNotificationEnabled())) {
debugEvent(
"SCHEDULED_SKIPPED",
"type=SUNSHINE, userId={}, reason=notificationDisabled",
user.getId());
continue;
}
send(user, user, NotificationType.SUNSHINE, "/garden", null);
}
log.info("Sending sunshine notification at {}", LocalDateTime.now());
Expand All @@ -222,6 +301,13 @@ public void sendSunshineNotification() {
public void sendPollenAvailableNotification() {
List<User> users = userRepository.findAll();
for (User user : users) {
if (!Boolean.TRUE.equals(user.getNotificationEnabled())) {
debugEvent(
"SCHEDULED_SKIPPED",
"type=POLLEN_AVAILABLE, userId={}, reason=notificationDisabled",
user.getId());
continue;
}
send(user, user, NotificationType.POLLEN_AVAILABLE, "/friends", null);
}
log.info("Sending pollen available notification at {}", LocalDateTime.now());
Expand All @@ -231,6 +317,13 @@ public void sendPollenAvailableNotification() {
public void sendWateringNotification() {
List<User> users = userRepository.findAll();
for (User user : users) {
if (!Boolean.TRUE.equals(user.getNotificationEnabled())) {
debugEvent(
"SCHEDULED_SKIPPED",
"type=WATERING, userId={}, reason=notificationDisabled",
user.getId());
continue;
}
// TODO: 식물 닉네임 가져오는 로직 필요
String plantNickname = "당신의 식물";
String content = String.format(NotificationType.WATERING.getMessageTemplate(), plantNickname);
Expand All @@ -247,4 +340,41 @@ public void sendWateringNotification() {
}
log.info("Sending watering notification at {}", LocalDateTime.now());
}

private void debugEvent(String event, String message, Object... args) {
if (shouldDebug()) {
log.debug("event={}, " + message, prepend(event, args));
}
}

private Object[] prepend(Object first, Object[] rest) {
Object[] merged = new Object[rest.length + 1];
merged[0] = first;
System.arraycopy(rest, 0, merged, 1, rest.length);
return merged;
}

private boolean shouldDebug() {
if (!notificationDebugEnabled || !log.isDebugEnabled()) {
return false;
}
double rate = notificationDebugSampleRate;
if (rate >= 1.0d) {
return true;
}
if (rate <= 0.0d) {
return false;
}
return ThreadLocalRandom.current().nextDouble() < rate;
}

private String maskToken(String token) {
if (token == null || token.isBlank()) {
return "null";
}
if (token.length() <= 8) {
return "****";
}
return token.substring(0, 4) + "****" + token.substring(token.length() - 4);
}
}
Loading
Loading