Skip to content
Merged
Show file tree
Hide file tree
Changes from 24 commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
5db50aa
feat: User에 birthday 필드 추가
jj0526 Nov 2, 2025
980a955
feat: 생일자 조회 메서드 추가
jj0526 Nov 2, 2025
c35a15e
feat: UserSetting 엔티티에 isBirthdayNotify 필드 추가
jj0526 Nov 2, 2025
fc961ce
feat: 생일 알림(BIRTHDAY_ANNOUNCEMENT) 관련 기능 추가
jj0526 Nov 2, 2025
18d543f
feat: 생일 알림 스케줄러 추가
jj0526 Nov 2, 2025
e0ffc48
fix: SQS 메시지 전송 시 null 값 기본 처리 추가
jj0526 Nov 2, 2025
91d824a
refactor: SqsMessageEvent 매퍼의 생일 SqsMessageEvent 생성 로직 통일
jj0526 Nov 2, 2025
bf358e6
feat: 생일자에게 생일 축하 알림 구현
jj0526 Nov 2, 2025
80612f9
feat: BirthdayLetterContent 및 BIRTHDAY_LETTER 타입 추가
jj0526 Nov 8, 2025
35066a4
Merge dev
jj0526 Nov 8, 2025
524fb34
refactor: toBirthdaySqsMessageEvent 파라미터명을 user로 변경
jj0526 Nov 8, 2025
7c558e1
feat: 생일 편지 알림 기능 추가
jj0526 Nov 8, 2025
3feed41
feat: NotificationContent에 생일 관련 서브타입 추가
jj0526 Nov 8, 2025
fb793ff
feat: 생일 공지 및 생일 축하에 생일자 userId 추가
jj0526 Nov 8, 2025
d582f97
feat: fix: 생일 알림 시 유저 설정 및 FCM 토큰 검증 로직 추가
jj0526 Nov 9, 2025
90acd7a
refactor: 당일 생일자 조회 로직을 findTodayBirthdayUsers()로 통일
jj0526 Nov 9, 2025
4d79804
refactor: 생일 알림 대상 조회 시 탈퇴 유저(deleteDate) 제외 조건 추가
jj0526 Nov 9, 2025
702e72a
fix: SQS 메시지 pathId null 처리 시 기본값을 0 -> -1로 변경
jj0526 Nov 9, 2025
1fe329a
refactor: 생일 NotificationType의 path 변경
jj0526 Nov 10, 2025
c9520eb
refactor: BirthdayNotifyScheduler 위치를 생일 디렉토리 내로 이동
jj0526 Nov 10, 2025
536952b
refactor: 생일 알림을 허용하지 않은 유저는 생일 저장이 되지 않도록 수정
jj0526 Nov 10, 2025
a4de57c
style: UserGetService 사용하지 않는 import 정리
jj0526 Nov 11, 2025
5124e48
refactor: 생일 알림 시 UserSetting 조회 및 알림 여부 검증 로직 공통 메서드로 분리
jj0526 Nov 11, 2025
5fb7e38
refactor: 스케줄러에서 하드코딩된 타임존 제거 및 전역 설정(jackson.time-zone) 적용
jj0526 Nov 11, 2025
90857c7
refactor: 생일 알림 전송 중 개별 실패가 전체 흐름을 중단하지 않도록 try-catch 추가
jj0526 Nov 13, 2025
d2cfb5a
refactor: 사용자 설정 조회 실패 로그 추가
jj0526 Nov 13, 2025
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
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import leets.leenk.domain.birthday.domain.entity.BirthdayLetterReadMark;
import leets.leenk.domain.birthday.domain.service.BirthdayLetterSaveService;
import leets.leenk.domain.birthday.domain.service.BirthdayLettersGetService;
import leets.leenk.domain.notification.application.usecase.BirthdayNotificationUsecase;
import leets.leenk.domain.user.domain.entity.User;
import leets.leenk.domain.user.domain.service.user.UserGetService;
import lombok.RequiredArgsConstructor;
Expand All @@ -28,6 +29,7 @@ public class BirthdayLetterUseCase {
private final BirthdayLettersGetService birthdayLettersGetService;
private final BirthdayLetterMapper birthdayLetterMapper;
private final BirthdayChecker birthdayChecker;
private final BirthdayNotificationUsecase birthdayNotificationUsecase;

@Transactional
public void writeBirthdayLetter(long senderId, long receiverId, BirthdayLetterRequest request) {
Expand All @@ -44,6 +46,8 @@ public void writeBirthdayLetter(long senderId, long receiverId, BirthdayLetterRe
BirthdayLetter birthdayLetter = birthdayLetterMapper.toBirthdayLetter(sender, receiver, request);

birthdayLetterSaveService.save(birthdayLetter);

birthdayNotificationUsecase.saveBirthdayLetterNotification(birthdayLetter);
}

@Transactional(readOnly = true)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package leets.leenk.domain.birthday.domain.service.scheduler;

import leets.leenk.domain.notification.application.usecase.BirthdayNotificationUsecase;
import lombok.RequiredArgsConstructor;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;

import java.time.LocalDate;

@Service
@RequiredArgsConstructor
public class BirthdayNotifyScheduler {

private final BirthdayNotificationUsecase birthdayNotificationUsecase;

@Scheduled(cron = "0 0 0 * * *")
public void sendBirthdayNotifications() {
LocalDate today = LocalDate.now();
birthdayNotificationUsecase.announceUserBirthday(today);

birthdayNotificationUsecase.celebrateBirthday(today);

}
}
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

왜 삭제됏죠??

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

해당 파일은 사용하지 않는 파일이고 MediaUpdateFailedException.java에서 해당 기능을 쓰는것으로 보여서 삭제했습니다

This file was deleted.

Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
package leets.leenk.domain.notification.application.mapper;

import leets.leenk.domain.birthday.domain.entity.BirthdayLetter;
import leets.leenk.domain.notification.domain.entity.Notification;
import leets.leenk.domain.notification.domain.entity.birthdayContent.BirthdayAnnouncementContent;
import leets.leenk.domain.notification.domain.entity.birthdayContent.BirthdayCelebrateContent;
import leets.leenk.domain.notification.domain.entity.birthdayContent.BirthdayLetterContent;
import leets.leenk.domain.notification.domain.entity.enums.NotificationType;
import leets.leenk.domain.user.domain.entity.User;
import org.springframework.stereotype.Component;

@Component
public class BirthdayNotificationMapper {
public Notification toBirthdayAnnouncementNotification(User birthdayUser, User targetUser) {
return Notification.builder()
.userId(targetUser.getId())
.notificationType(NotificationType.BIRTHDAY_ANNOUNCEMENT)
.content(toBirthdayAnnouncementContent(birthdayUser))
.isRead(Boolean.FALSE)
.build();
}

public Notification toBirthdayCelebrateNotification(User birthdayUser) {
return Notification.builder()
.userId(birthdayUser.getId())
.notificationType(NotificationType.BIRTHDAY_CELEBRATE)
.content(toBirthdayCelebrateContent(birthdayUser))
.isRead(Boolean.FALSE)
.build();
}

public Notification toBirthdayLetterNotification(BirthdayLetter birthdayLetter) {
return Notification.builder()
.userId(birthdayLetter.getReceiver().getId())
.notificationType(NotificationType.BIRTHDAY_LETTER)
.content(toBirthdayLetterContent(birthdayLetter))
.isRead(Boolean.FALSE)
.build();
}

private BirthdayAnnouncementContent toBirthdayAnnouncementContent(User birthdayUser) {
return BirthdayAnnouncementContent.builder()
.birthdayUserId(birthdayUser.getId())
.birthdayUserName(birthdayUser.getName())
.title(NotificationType.BIRTHDAY_ANNOUNCEMENT.getTitle())
.body(NotificationType.BIRTHDAY_ANNOUNCEMENT.getContent())
.build();
}

private BirthdayCelebrateContent toBirthdayCelebrateContent(User birthdayUser){
return BirthdayCelebrateContent.builder()
.birthdayUserId(birthdayUser.getId())
.birthdayUserName(birthdayUser.getName())
.title(NotificationType.BIRTHDAY_CELEBRATE.getTitle())
.body(NotificationType.BIRTHDAY_CELEBRATE.getContent())
.build();
}

private BirthdayLetterContent toBirthdayLetterContent(BirthdayLetter birthdayLetter) {
return BirthdayLetterContent.builder()
.senderName(birthdayLetter.getSender().getName())
.birthdayLetterId(birthdayLetter.getId())
.title(NotificationType.BIRTHDAY_LETTER.getTitle())
.body(NotificationType.BIRTHDAY_LETTER.getContent())
.build();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
package leets.leenk.domain.notification.application.usecase;

import leets.leenk.domain.birthday.domain.entity.BirthdayLetter;
import leets.leenk.domain.birthday.domain.service.BirthdayGetService;
import leets.leenk.domain.notification.application.mapper.BirthdayNotificationMapper;
import leets.leenk.domain.notification.domain.entity.Notification;
import leets.leenk.domain.notification.domain.service.NotificationSaveService;
import leets.leenk.domain.user.domain.entity.User;
import leets.leenk.domain.user.domain.entity.UserSetting;
import leets.leenk.domain.user.domain.service.user.UserGetService;
import leets.leenk.domain.user.domain.service.usersetting.UserSettingGetService;
import leets.leenk.global.sqs.application.mapper.SqsMessageEventMapper;
import lombok.RequiredArgsConstructor;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.time.LocalDate;
import java.util.List;

@Service
@RequiredArgsConstructor
public class BirthdayNotificationUsecase {

private final UserGetService userGetService;
private final UserSettingGetService userSettingGetService;
private final BirthdayGetService birthdayGetService;

private final BirthdayNotificationMapper birthdayNotificationMapper;
private final SqsMessageEventMapper sqsMessageEventMapper;

private final ApplicationEventPublisher eventPublisher;
private final NotificationSaveService notificationSaveService;

@Transactional
public void announceUserBirthday(LocalDate today) {
List<User> birthdayUsers = birthdayGetService.findTodayBirthdayUsers(today);
if (birthdayUsers.isEmpty()) {
return;
}

List<User> users = userSettingGetService.getUsersToNotifyBirthday();


for (User receiver : users) {
for (User birthdayUser : birthdayUsers) {
if (receiver.equals(birthdayUser)) continue;

Notification notification = birthdayNotificationMapper
.toBirthdayAnnouncementNotification(birthdayUser, receiver);
notificationSaveService.save(notification);

if (receiver.getFcmToken() != null) {
eventPublisher.publishEvent(
sqsMessageEventMapper.toBirthdaySqsMessageEvent(
notification,
receiver.getFcmToken(),
birthdayUser
)
);
}
}
}

}

@Transactional
public void celebrateBirthday(LocalDate today){
List<User> birthdayUsers = birthdayGetService.findTodayBirthdayUsers(today);

for (User birthdayUser : birthdayUsers){
if (!isBirthdayNotificationEnabled(birthdayUser)) continue;

Notification notification = birthdayNotificationMapper
.toBirthdayCelebrateNotification(birthdayUser);
notificationSaveService.save(notification);


if(birthdayUser.getFcmToken() != null) {
eventPublisher.publishEvent(
sqsMessageEventMapper.toBirthdaySqsMessageEvent(
notification,
birthdayUser.getFcmToken(),
birthdayUser
)
);
}
}
}

@Transactional
public void saveBirthdayLetterNotification(BirthdayLetter birthdayLetter){
User birthdayUser = birthdayLetter.getReceiver();

if (!isBirthdayNotificationEnabled(birthdayUser)) return;

Notification notification = birthdayNotificationMapper.toBirthdayLetterNotification(birthdayLetter);
notificationSaveService.save(notification);

if(birthdayUser.getFcmToken() != null){
eventPublisher.publishEvent(
sqsMessageEventMapper.toBirthdaySqsMessageEvent(
notification,
birthdayUser.getFcmToken(),
birthdayLetter.getSender()
)
);
}

}

private boolean isBirthdayNotificationEnabled(User birthdayUser) {
try {
UserSetting userSetting = userSettingGetService.findByUser(birthdayUser);
return userSetting != null && userSetting.isBirthdayNotify();
} catch (Exception e) {
return false;
}
}
Comment on lines 119 to 127
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

너무 광범위한 예외 처리로 실제 오류가 숨겨질 수 있습니다.

현재 구현의 문제점:

  1. Line 116: Exception을 잡는 것은 너무 광범위하여 NullPointerException, 데이터베이스 연결 오류 등 실제 심각한 문제를 감출 수 있습니다.

  2. 무조건 false 반환: 모든 예외를 "알림 비활성화"와 동일하게 처리하므로, 실제 시스템 오류와 사용자 설정을 구분할 수 없습니다.

  3. 로깅 부재: 예외가 발생해도 기록되지 않아 문제 추적이 어렵습니다.

다음과 같이 개선하세요:

 private boolean isBirthdayNotificationEnabled(User birthdayUser) {
     try {
         UserSetting userSetting = userSettingGetService.findByUser(birthdayUser);
         return userSetting != null && userSetting.isBirthdayNotify();
-    } catch (Exception e) {
+    } catch (RuntimeException e) {
+        log.error("사용자 설정 조회 실패 - userId: {}", birthdayUser.getId(), e);
         return false;
     }
 }

또는 userSettingGetService.findByUser()가 던지는 구체적인 예외 타입이 있다면 그것을 잡는 것이 더 좋습니다.

Committable suggestion skipped: line range outside the PR's diff.

🤖 Prompt for AI Agents
In
src/main/java/leets/leenk/domain/notification/application/usecase/BirthdayNotificationUsecase.java
around lines 112-119, replace the broad try-catch that catches Exception and
unconditionally returns false: instead catch only the specific expected
exceptions from userSettingGetService.findByUser (e.g., a UserNotFoundException
or the service's declared checked exceptions) and handle those by returning
false when appropriate; for unexpected exceptions (e.g., DataAccessException,
NullPointerException) do not swallow them — log the error with the class logger
including the exception stack trace and either rethrow or wrap in a runtime
exception so system errors are visible; ensure normal flow still returns
userSetting != null && userSetting.isBirthdayNotify().


}
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@

import com.fasterxml.jackson.annotation.JsonSubTypes;

import leets.leenk.domain.notification.domain.entity.birthdayContent.BirthdayAnnouncementContent;
import leets.leenk.domain.notification.domain.entity.birthdayContent.BirthdayCelebrateContent;
import leets.leenk.domain.notification.domain.entity.birthdayContent.BirthdayLetterContent;
import leets.leenk.domain.notification.domain.entity.feedContent.FeedFirstReactionNotificationContent;
import leets.leenk.domain.notification.domain.entity.feedContent.FeedReactionCountNotificationContent;
import leets.leenk.domain.notification.domain.entity.feedContent.FeedTagNotificationContent;
Expand All @@ -28,7 +31,10 @@
@JsonSubTypes.Type(value = LeenkStartingSoonNotificationContent.class, name = "LEENK_STARTING_SOON"),
@JsonSubTypes.Type(value = LeenkFinishedNotificationContent.class, name = "LEENK_FINISHED"),
@JsonSubTypes.Type(value = LeenkStartedHostReminderNotificationContent.class, name = "LEENK_STARTED_HOST_REMINDER"),
@JsonSubTypes.Type(value = LeenkLeftNotificationContent.class, name = "LEENK_LEFT")
@JsonSubTypes.Type(value = LeenkLeftNotificationContent.class, name = "LEENK_LEFT"),
@JsonSubTypes.Type(value = BirthdayAnnouncementContent.class, name = "BIRTHDAY_ANNOUNCEMENT"),
@JsonSubTypes.Type(value = BirthdayCelebrateContent.class, name = "BIRTHDAY_CELEBRATE"),
@JsonSubTypes.Type(value = BirthdayLetterContent.class, name = "BIRTHDAY_LETTER")
})
public class NotificationContent {

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package leets.leenk.domain.notification.domain.entity.birthdayContent;

import leets.leenk.domain.notification.domain.entity.NotificationContent;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.experimental.SuperBuilder;

@SuperBuilder
@NoArgsConstructor
@Getter
public class BirthdayAnnouncementContent extends NotificationContent {
private Long birthdayUserId;
private String birthdayUserName;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package leets.leenk.domain.notification.domain.entity.birthdayContent;

import leets.leenk.domain.notification.domain.entity.NotificationContent;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.experimental.SuperBuilder;

@SuperBuilder
@NoArgsConstructor
@Getter
public class BirthdayCelebrateContent extends NotificationContent {
private Long birthdayUserId;
private String birthdayUserName;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package leets.leenk.domain.notification.domain.entity.birthdayContent;

import leets.leenk.domain.notification.domain.entity.NotificationContent;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.experimental.SuperBuilder;

@SuperBuilder
@NoArgsConstructor
@Getter
public class BirthdayLetterContent extends NotificationContent {
private String senderName;
private Long birthdayLetterId;
}
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,12 @@ public enum NotificationType {
FEED_TAG("Leenk", "이 나를 함께한 사람에 추가했어", "feeds"),
NEW_FEED("Leenk", "새로운 게시글을 확인해 봐", "feeds"),
FEED_FIRST_REACTION("Leenk", "내가 쓴 피드에 좋아요를 받았어", "feeds"),
FEED_REACTION_COUNT("Leenk", "내가 쓴 피드에 좋아요를 %d개 받았어", "feeds");
FEED_REACTION_COUNT("Leenk", "내가 쓴 피드에 좋아요를 %d개 받았어", "feeds"),

// Birthday
BIRTHDAY_ANNOUNCEMENT("Leenk", "오늘은 {name}의 생일이야\n축하해주러 가볼까?", "birthday/users"),
BIRTHDAY_CELEBRATE("Leenk", "생일 축하해, 멋쟁이 {name}!", "birthday/users"),
BIRTHDAY_LETTER("Leenk", "{name}에게 생일 편지를 받았어!", "birthday/letters/me");

private final String title;
private final String content;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@ public record NotificationSettingUpdateRequest(
Boolean newFeedNotify,

@Schema(description = "새로운 공감 알림 여부 수정", example = "false")
Boolean newReactionNotify
Boolean newReactionNotify,

@Schema(description = "생일 알림 여부 수정", example = "false")
Boolean birthdayNotify
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

생일 알림 여부 별도 설정하기로 했었나요?? 왜 피그마에 화면이 없는 거 같지
@nabbang6 프론트 담당자분께서도 해당 사항 인지하고 계실까욤?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

생일 알림 여부도 회의시간에 추가하기로 했었습니다!

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@nabbang6 확인 부탁드려요~

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

앗 확인했습니다 !! 추가해놓을게용 👍

) {
}
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,10 @@ public class UserSetting extends BaseEntity {
@Column(nullable = false, columnDefinition = "boolean default true")
private boolean isNewReactionNotify;

@Column(nullable = false, columnDefinition = "boolean default true")
private boolean isBirthdayNotify;


@OneToOne
@JoinColumn(name = "user_id")
private User user;
Expand All @@ -51,4 +55,8 @@ public void updateIsNewFeedNotify(boolean isNewFeedNotify) {
public void updateIsNewReactionNotify(boolean isNewReactionNotify) {
this.isNewReactionNotify = isNewReactionNotify;
}

public void updateIsBirthdayNotify(boolean isBirthdayNotify) {
this.isBirthdayNotify = isBirthdayNotify;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -22,4 +22,9 @@ public interface UserSettingRepository extends JpaRepository<UserSetting, Long>
"AND us.user.leaveDate IS NULL " +
"AND us.user.id <> :authorUserId")
List<User> findAllActiveUsersWithNewLeenkNotifyTrueExcludingUserId(@Param("authorUserId") Long authorUserId);

@Query("SELECT us.user FROM UserSetting us WHERE us.isBirthdayNotify = true " +
"AND us.user.leaveDate IS NULL " +
"AND us.user.deleteDate IS NULL")
List<User> findAllActiveUsersWithBirthdayNotifyTrue();
}
Original file line number Diff line number Diff line change
Expand Up @@ -26,4 +26,8 @@ public List<User> getUsersToNotifyNewLeenk(Long authorId) {
public UserSetting findByUser(User user) {
return userSettingRepository.findByUser(user).orElseThrow(UserSettingNotFoundException::new);
}

public List<User> getUsersToNotifyBirthday() {
return userSettingRepository.findAllActiveUsersWithBirthdayNotifyTrue();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -23,5 +23,9 @@ public void updateNotificationSetting(UserSetting userSetting, NotificationSetti
if (request.newReactionNotify() != null) {
userSetting.updateIsNewReactionNotify(request.newReactionNotify());
}

if (request.birthdayNotify() != null) {
userSetting.updateIsBirthdayNotify(request.birthdayNotify());
}
}
}
Loading