diff --git a/.gitignore b/.gitignore index 2ff654f..87be0d8 100644 --- a/.gitignore +++ b/.gitignore @@ -41,4 +41,6 @@ build/generated/ application-local.properties application-test.properties application-s3.properties -test_img_dir \ No newline at end of file +test_img_dir + +**/src/main/resources/secret diff --git a/build.gradle b/build.gradle index f017ab5..3dfc6bd 100644 --- a/build.gradle +++ b/build.gradle @@ -58,6 +58,7 @@ dependencies { testCompileOnly 'org.projectlombok:lombok' testAnnotationProcessor 'org.projectlombok:lombok' testRuntimeOnly 'com.h2database:h2' + testImplementation 'org.awaitility:awaitility:4.2.0' } sourceSets { @@ -70,4 +71,10 @@ sourceSets { tasks.named('test') { useJUnitPlatform() -} \ No newline at end of file +} + +test { + jvmArgs += [ + '--add-opens=java.base/java.lang=ALL-UNNAMED' + ] +} diff --git a/src/main/java/com/example/moim/external/fcm/FcmConfig.java b/src/main/java/com/example/moim/external/fcm/FcmConfig.java index c22c496..77a68be 100644 --- a/src/main/java/com/example/moim/external/fcm/FcmConfig.java +++ b/src/main/java/com/example/moim/external/fcm/FcmConfig.java @@ -1,32 +1,41 @@ package com.example.moim.external.fcm; +import com.google.auth.oauth2.GoogleCredentials; +import com.google.firebase.FirebaseApp; +import com.google.firebase.FirebaseOptions; +import com.google.firebase.messaging.FirebaseMessaging; +import java.io.IOException; +import java.io.InputStream; +import java.util.List; +import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.core.io.ClassPathResource; @Configuration public class FcmConfig { -// @Bean -// FirebaseMessaging firebaseMessaging() throws IOException { -// ClassPathResource resource = new ClassPathResource("firebase/meta-gachon-fcm-firebase-adminsdk-fyr7b-30929b486f.json"); -// InputStream refreshToken = resource.getInputStream(); -// -// FirebaseApp firebaseApp = null; -// List firebaseAppList = FirebaseApp.getApps(); -// -// if (firebaseAppList != null && !firebaseAppList.isEmpty()) { -// for (FirebaseApp app : firebaseAppList) { -// if (app.getName().equals(FirebaseApp.DEFAULT_APP_NAME)) { -// firebaseApp = app; -// } -// } -// } else { -// FirebaseOptions options = FirebaseOptions.builder() -// .setCredentials(GoogleCredentials.fromStream(refreshToken)) -// .build(); -// -// firebaseApp = FirebaseApp.initializeApp(options); -// } -// -// return FirebaseMessaging.getInstance(firebaseApp); -// } + @Bean + FirebaseMessaging firebaseMessaging() throws IOException { + ClassPathResource resource = new ClassPathResource("secret/sample-firebase-test-f3c27-firebase-adminsdk-fbsvc-c15b4b66c6.json"); + InputStream refreshToken = resource.getInputStream(); + + FirebaseApp firebaseApp = null; + List firebaseAppList = FirebaseApp.getApps(); + + if (firebaseAppList != null && !firebaseAppList.isEmpty()) { + for (FirebaseApp app : firebaseAppList) { + if (app.getName().equals(FirebaseApp.DEFAULT_APP_NAME)) { + firebaseApp = app; + } + } + } else { + FirebaseOptions options = FirebaseOptions.builder() + .setCredentials(GoogleCredentials.fromStream(refreshToken)) + .build(); + + firebaseApp = FirebaseApp.initializeApp(options); + } + + return FirebaseMessaging.getInstance(firebaseApp); + } } diff --git a/src/main/java/com/example/moim/notification/controller/NotificationController.java b/src/main/java/com/example/moim/notification/controller/NotificationController.java index c0508c3..3016415 100644 --- a/src/main/java/com/example/moim/notification/controller/NotificationController.java +++ b/src/main/java/com/example/moim/notification/controller/NotificationController.java @@ -1,9 +1,10 @@ package com.example.moim.notification.controller; +import com.example.moim.notification.controller.port.NotificationService; import com.example.moim.notification.dto.NotificationExistOutput; import com.example.moim.notification.dto.NotificationOutput; -import com.example.moim.notification.service.NotificationService; import com.example.moim.user.dto.UserDetailsImpl; +import java.util.List; import lombok.RequiredArgsConstructor; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.DeleteMapping; @@ -11,25 +12,23 @@ import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RestController; -import java.util.List; - @RestController @RequiredArgsConstructor -public class NotificationController implements NotificationControllerDocs{ +public class NotificationController implements NotificationControllerDocs { private final NotificationService notificationService; - @GetMapping(value = "/notice") - public NotificationExistOutput noticeCheck(@AuthenticationPrincipal UserDetailsImpl userDetailsImpl) { - return notificationService.checkNotice(userDetailsImpl.getUser()); + @GetMapping(value = "/notification/unread-count") + public NotificationExistOutput notificationUnreadCount(@AuthenticationPrincipal UserDetailsImpl userDetailsImpl) { + return notificationService.checkUnread(userDetailsImpl.getUser()); } - @GetMapping("/notices") - public List noticeFind(@AuthenticationPrincipal UserDetailsImpl userDetailsImpl) { - return notificationService.findNotice(userDetailsImpl.getUser()); + @GetMapping("/notification") + public List notificationFind(@AuthenticationPrincipal UserDetailsImpl userDetailsImpl) { + return notificationService.findAll(userDetailsImpl.getUser()); } - @DeleteMapping("/notices/{id}") - public void noticeRemove(@AuthenticationPrincipal UserDetailsImpl userDetailsImpl, @PathVariable Long id) { - notificationService.removeNotice(id); + @DeleteMapping("/notification/{id}") + public void notificationRemove(@AuthenticationPrincipal UserDetailsImpl userDetailsImpl, @PathVariable Long id) { + notificationService.remove(id); } } diff --git a/src/main/java/com/example/moim/notification/controller/NotificationControllerDocs.java b/src/main/java/com/example/moim/notification/controller/NotificationControllerDocs.java index 55ada0a..791ce83 100644 --- a/src/main/java/com/example/moim/notification/controller/NotificationControllerDocs.java +++ b/src/main/java/com/example/moim/notification/controller/NotificationControllerDocs.java @@ -14,11 +14,11 @@ public interface NotificationControllerDocs { @Operation(summary = "새로운 알림 있는지 체크") - NotificationExistOutput noticeCheck(@AuthenticationPrincipal UserDetailsImpl userDetailsImpl); + NotificationExistOutput notificationUnreadCount(@AuthenticationPrincipal UserDetailsImpl userDetailsImpl); @Operation(summary = "알림 조회") - List noticeFind(@AuthenticationPrincipal UserDetailsImpl userDetailsImpl); + List notificationFind(@AuthenticationPrincipal UserDetailsImpl userDetailsImpl); @Operation(summary = "알림 삭제") - void noticeRemove(@AuthenticationPrincipal UserDetailsImpl userDetailsImpl, @PathVariable Long id); + void notificationRemove(@AuthenticationPrincipal UserDetailsImpl userDetailsImpl, @PathVariable Long id); } diff --git a/src/main/java/com/example/moim/notification/controller/port/NotificationService.java b/src/main/java/com/example/moim/notification/controller/port/NotificationService.java new file mode 100644 index 0000000..364244d --- /dev/null +++ b/src/main/java/com/example/moim/notification/controller/port/NotificationService.java @@ -0,0 +1,17 @@ +package com.example.moim.notification.controller.port; + +import com.example.moim.notification.dto.NotificationExistOutput; +import com.example.moim.notification.dto.NotificationOutput; +import com.example.moim.notification.entity.NotificationEntity; +import com.example.moim.user.entity.User; +import java.util.List; + +public interface NotificationService { + NotificationExistOutput checkUnread(User user); + + List findAll(User user); + + void remove(Long id); + + void sendAll(List notificationEntities); +} diff --git a/src/main/java/com/example/moim/notification/dto/NotificationOutput.java b/src/main/java/com/example/moim/notification/dto/NotificationOutput.java index 5d7a4e1..f1aacc9 100644 --- a/src/main/java/com/example/moim/notification/dto/NotificationOutput.java +++ b/src/main/java/com/example/moim/notification/dto/NotificationOutput.java @@ -1,26 +1,26 @@ package com.example.moim.notification.dto; -import com.example.moim.notification.entity.Notifications; -import lombok.Data; - +import com.example.moim.notification.entity.NotificationEntity; import java.time.format.DateTimeFormatter; +import lombok.Data; @Data public class NotificationOutput { private Long id; + private String notificationType; private String title; - private String category; private String content; - private String time; + private String createdAt; private Boolean isRead; + private String actionUrl; - public NotificationOutput(Notifications notifications) { - this.id = notifications.getId(); - this.title = notifications.getTitle(); - this.category = notifications.getCategory(); - this.content = notifications.getContents(); - this.time = notifications.getCreatedDate().format(DateTimeFormatter.ofPattern("MM/dd HH:mm")); - notifications.setRead(true); - this.isRead = notifications.getIsRead(); + public NotificationOutput(NotificationEntity notificationEntity) { + this.id = notificationEntity.getId(); + this.title = notificationEntity.getTitle(); + this.notificationType = notificationEntity.getType().name(); + this.content = notificationEntity.getContent(); + this.createdAt = notificationEntity.getCreatedDate().format(DateTimeFormatter.ofPattern("yyyy/MM/dd HH:mm")); + this.isRead = notificationEntity.getIsRead(); + this.actionUrl = notificationEntity.getType().getCategory() + "/" + notificationEntity.getLinkedId(); } } diff --git a/src/main/java/com/example/moim/notification/entity/NotificationEntity.java b/src/main/java/com/example/moim/notification/entity/NotificationEntity.java new file mode 100644 index 0000000..f097bbe --- /dev/null +++ b/src/main/java/com/example/moim/notification/entity/NotificationEntity.java @@ -0,0 +1,68 @@ +package com.example.moim.notification.entity; + +import com.example.moim.global.entity.BaseEntity; +import com.example.moim.user.entity.User; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@NoArgsConstructor +@Table(name = "notifications") +public class NotificationEntity extends BaseEntity { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + @ManyToOne(fetch = FetchType.LAZY) + private User targetUser; + @Enumerated(EnumType.STRING) + private NotificationType type; + private String title; + private String content; + private Long linkedId; // 알림과 연관된 클럽, 일정, 매치 등의 ID + private Boolean isRead; + private NotificationStatus status; + + @Builder + public NotificationEntity(User targetUser, NotificationType type, String title, String content, Long linkedId) { + this.targetUser = targetUser; + this.type = type; + this.title = title; + this.content = content; + this.linkedId = linkedId; + this.isRead = false; + this.status = NotificationStatus.READY; + } + + public static NotificationEntity create(User targetUser, NotificationType type, String content, String title, Long linkedId) { + return NotificationEntity.builder() + .targetUser(targetUser) + .type(type) + .content(content) + .title(title) + .linkedId(linkedId) + .build(); + } + + public void read() { + isRead = true; + } + + public void sent() { + status = NotificationStatus.SENT; + } + + public void failed() { + status = NotificationStatus.FAILED; + } +} diff --git a/src/main/java/com/example/moim/notification/entity/NotificationStatus.java b/src/main/java/com/example/moim/notification/entity/NotificationStatus.java new file mode 100644 index 0000000..f465522 --- /dev/null +++ b/src/main/java/com/example/moim/notification/entity/NotificationStatus.java @@ -0,0 +1,8 @@ +package com.example.moim.notification.entity; + +public enum NotificationStatus { + READY, + SENT, + FAILED, + ; +} diff --git a/src/main/java/com/example/moim/notification/entity/NotificationType.java b/src/main/java/com/example/moim/notification/entity/NotificationType.java new file mode 100644 index 0000000..937e9ed --- /dev/null +++ b/src/main/java/com/example/moim/notification/entity/NotificationType.java @@ -0,0 +1,37 @@ +package com.example.moim.notification.entity; + +import java.util.IllegalFormatException; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum NotificationType { + CLUB_JOIN("클럽 가입", "%s님이 %s에 가입했습니다."), + SCHEDULE_SAVE("일정 등록", "%s 일정이 등록되었습니다. \n 참가 여부를 투표해주세요!!"), + SCHEDULE_REMINDER("일정 하루 전", "내일 %s 일정이 있습니다."), + SCHEDULE_ENCOURAGE("투표 독려", "%s 일정이 참가투표가 곧 마감됩니다.\n 참가 여부를 투표해주세요!!"), + SCHEDULE_JOIN("일정 참여", "%s 일정에 참여했습니다."), + MATCH_SCHEDULED("매치 등록", "%s 클럽 %s %s 매치가 등록되었습니다.\n 매치정보를 확인하고 신청해주세요!"), + MATCH_SUCCESS("매칭 성공", "%s 클럽과의 %s %s 매치가 확정되었습니다.\n 매치정보를 다시 한 번 확인해주세요!"), + MATCH_REVIEW("매치 리뷰", "%s 클럽과의 매치는 즐거우셨나요?\n %s 님의 득점 기록을 입력해주세요!"), + MATCH_SUGGESTION("매치 건의", "클럽원이 %s 클럽과의 %s %s 매치를 원합니다.\n 매치 정보를 확인하고 신청해주세요!"), + MATCH_REQUEST("매치 요청", "%s 클럽이 %s 매치에 신청했습니다.\n 클럽 정보를 확인하고 매치를 확정해주세요!"), + MATCH_INVITE("매치 초대", "%s 클럽에서 친선 매치를 제안했습니다.\n 클럽 정보를 확인하고 매치를 확정해주세요!"), + MATCH_FAILED_UNREQUESTED("매치 실패", "신청 클럽이 없어 <%s> 매치가 성사되지않았습니다 \uD83D\uDE2D\n 다음에 다시 등록해주세요!"), + MATCH_FAILED_UNSELECTED("매치 실패", "<%s> 매치 등록 클럽이 다른 클럽을 선택했어요\uD83E\uDEE3\n 다음에 다시 신청해주세요!"), + MATCH_CANCEL_USER("매치 취소", "<%s> 매치가 취소되었습니다.\n 다음에 다시 신청해주세요!"), + MATCH_CANCEL_CLUB("매치 취소", "<%s> 매치가 취소되었습니다.\n 다음에 다시 신청해주세요!"), + ; + + private final String title; + private final String messageTemplate; + + public String formatMessage(Object... args) throws IllegalFormatException { + return String.format(messageTemplate, args); + } + + public String getCategory() { + return name().substring(0, name().indexOf("_")).toLowerCase(); + } +} diff --git a/src/main/java/com/example/moim/notification/entity/Notifications.java b/src/main/java/com/example/moim/notification/entity/Notifications.java deleted file mode 100644 index 014f548..0000000 --- a/src/main/java/com/example/moim/notification/entity/Notifications.java +++ /dev/null @@ -1,116 +0,0 @@ -package com.example.moim.notification.entity; - -import com.example.moim.schedule.entity.Schedule; -import com.example.moim.global.entity.BaseEntity; -import com.example.moim.notification.dto.*; -import com.example.moim.user.entity.User; -import jakarta.persistence.*; -import lombok.Getter; -import lombok.NoArgsConstructor; - -@Entity -@Getter -@NoArgsConstructor -public class Notifications extends BaseEntity { - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; - @ManyToOne(fetch = FetchType.LAZY) - private User targetUser; - private String title; - private String category; - private String contents; - private Boolean isRead; - - public static Notifications createClubJoinNotification(ClubJoinEvent clubJoinEvent, User targetUser) { - Notifications notifications = new Notifications(); - notifications.targetUser = targetUser; - notifications.title = "가입 알림"; - notifications.category = "모임"; - notifications.contents = clubJoinEvent.getUser().getName() + " 님이 " + clubJoinEvent.getClub().getTitle() + "에 가입했습니다."; - notifications.isRead = false; - return notifications; - } - - public static Notifications createScheduleSaveNotification(ScheduleSaveEvent scheduleSaveEvent, User targetUser) { - Notifications notifications = new Notifications(); - notifications.targetUser = targetUser; - Schedule schedule = scheduleSaveEvent.getSchedule(); - notifications.title = "일정 등록 알림"; - notifications.category = "일정"; - notifications.contents = schedule.getTitle() + " 일정이 등록되었습니다.\n참가신청 바로가기!"; - notifications.isRead = false; - return notifications; - } - -// public static Notifications createScheduleVoteNotification(ScheduleVoteEvent scheduleVoteEvent) { -// Notifications notifications = new Notifications(); -// notifications.targetUser = scheduleVoteEvent.getUser(); -// Schedule schedule = scheduleVoteEvent.getSchedule(); -// notifications.title = schedule.getClub().getTitle() + ": 일정 참여"; -// notifications.category = "일정"; -// notifications.contents = schedule.getTitle() + " 일정에 참여했습니다."; -// notifications.isRead = false; -// return notifications; -// } - - public static Notifications createScheduleEncourageEvent(Schedule schedule, User targetUser) { - Notifications notifications = new Notifications(); - notifications.targetUser = targetUser; - notifications.title = " 일정 참가 투표 알림"; - notifications.category = "일정"; - notifications.contents = schedule.getTitle() + " 일정 참가 투표가 곧 마감됩니다.\n참가 투표를 해주세요."; - notifications.isRead = false; - return notifications; - } - - public static Notifications createMatchRequestEvent(MatchRequestEvent matchRequestEvent, User targetUser) { - Notifications notifications = new Notifications(); - notifications.targetUser = targetUser; - notifications.title = "매치 건의 알림"; - notifications.category = "친선 매치"; - notifications.contents = matchRequestEvent.getUser().getName() + "님이 " - + matchRequestEvent.getMatch().getStartTime().toLocalDate() - + matchRequestEvent.getMatch().getEvent() + "매치를 원합니다. \nt승인하시겠습니까?"; - notifications.isRead = false; - return notifications; - } - - public static Notifications createMatchInviteEvent(MatchInviteEvent matchInviteEvent, User targetUser) { - Notifications notifications = new Notifications(); - notifications.targetUser = targetUser; - notifications.title = "매치 초청 알림"; - notifications.category = "친선 매치"; - notifications.contents = "<" + matchInviteEvent.getMatch().getEvent() + " 한판 해요~> - " + - matchInviteEvent.getMatch().getHomeClub().getTitle() + "\n" + - matchInviteEvent.getMatch().getHomeClub().getTitle() + "가 친선 매치를 제안했습니다!"; - notifications.isRead = false; - return notifications; - } - - public static Notifications createMatchCancelUserNotification(MatchCancelUserEvent event, User targetUser) { - Notifications notification = new Notifications(); - notification.targetUser = targetUser; - notification.title = "매치 취소 알림"; - notification.category = "친선 매치"; - notification.contents = "매치 '" + event.getMatch().getName() + "'가 취소되었습니다."; - notification.isRead = false; - return notification; - } - - public static Notifications createMatchCancelClubNotification(MatchCancelClubEvent event, User targetUser) { - Notifications notification = new Notifications(); - notification.targetUser = targetUser; - notification.title = "매치 신청 취소 알림"; - notification.category = "친선 매치"; - notification.contents = "신청하신 매치 '" + event.getMatch().getName() + "'가 취소되었습니다."; - notification.isRead = false; - return notification; - } - - public void setRead(Boolean read) { - isRead = read; - } - - -} diff --git a/src/main/java/com/example/moim/notification/exceptions/advice/NotificationControllerAdvice.java b/src/main/java/com/example/moim/notification/exceptions/advice/NotificationControllerAdvice.java new file mode 100644 index 0000000..0c7bbe5 --- /dev/null +++ b/src/main/java/com/example/moim/notification/exceptions/advice/NotificationControllerAdvice.java @@ -0,0 +1,10 @@ +package com.example.moim.notification.exceptions.advice; + +import com.example.moim.global.exception.GeneralException; +import com.example.moim.global.exception.ResponseCode; + +public class NotificationControllerAdvice extends GeneralException { + public NotificationControllerAdvice(ResponseCode responseCode) { + super(responseCode); + } +} diff --git a/src/main/java/com/example/moim/notification/repository/FcmNotificationSender.java b/src/main/java/com/example/moim/notification/repository/FcmNotificationSender.java new file mode 100644 index 0000000..2358d1b --- /dev/null +++ b/src/main/java/com/example/moim/notification/repository/FcmNotificationSender.java @@ -0,0 +1,65 @@ +package com.example.moim.notification.repository; + +import com.example.moim.notification.entity.NotificationEntity; +import com.google.firebase.messaging.AndroidConfig; +import com.google.firebase.messaging.AndroidNotification; +import com.google.firebase.messaging.ApnsConfig; +import com.google.firebase.messaging.Aps; +import com.google.firebase.messaging.FirebaseMessaging; +import com.google.firebase.messaging.FirebaseMessagingException; +import com.google.firebase.messaging.Message; +import com.google.firebase.messaging.Notification; +import com.google.firebase.messaging.WebpushConfig; +import com.google.firebase.messaging.WebpushNotification; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +public class FcmNotificationSender implements NotificationSender { + @Override + public void send(NotificationEntity notificationEntity) { + String fcmToken = notificationEntity.getTargetUser().getFcmToken(); + if (fcmToken == null || fcmToken.isBlank()) { + log.warn("🔴 FCM 토큰이 존재하지 않아 푸시 전송 생략: userId={}", notificationEntity.getTargetUser().getId()); + notificationEntity.failed(); + return; + } + + Message message = Message.builder() + .setToken(fcmToken) + .setNotification(Notification.builder() + .setTitle(notificationEntity.getTitle()) + .setBody(notificationEntity.getContent()) + .build()) + .setAndroidConfig(AndroidConfig.builder() + .setNotification(AndroidNotification.builder() + .setPriority(AndroidNotification.Priority.HIGH) + .build()) + .build()) + .setWebpushConfig(WebpushConfig.builder() + .setNotification( + WebpushNotification.builder() + .setBadge( + "https://media.discordapp.net/attachments/1081467110200451092/1199660879071952906/MG.png?ex=65c35a42&is=65b0e542&hm=4b109d5d3ab5850eeacbd46d8f8eed253ab7bdb7ac4d28111e57cad3aba58b98&=&format=webp&quality=lossless") + .build()) + .build()) + .setApnsConfig(ApnsConfig.builder() + .setAps(Aps.builder() + .setBadge(1) + .build()) + .build()) + .build(); + + try { + String response = FirebaseMessaging.getInstance().send(message); + log.info("✅ FCM 푸시 전송 성공: userId={}, response={}", notificationEntity.getTargetUser().getId(), response); + notificationEntity.sent(); + } catch (FirebaseMessagingException e) { + log.error("🔴 FCM 푸시 전송 실패: userId={}, 이유={}", notificationEntity.getTargetUser().getId(), e.getMessage(), + e); + notificationEntity.failed(); + // TODO: 실패한 알림을 재전송하는 로직 추가 필요 + } + } +} diff --git a/src/main/java/com/example/moim/notification/repository/NotificationRepository.java b/src/main/java/com/example/moim/notification/repository/NotificationJpaRepository.java similarity index 56% rename from src/main/java/com/example/moim/notification/repository/NotificationRepository.java rename to src/main/java/com/example/moim/notification/repository/NotificationJpaRepository.java index 12b045d..4e75625 100644 --- a/src/main/java/com/example/moim/notification/repository/NotificationRepository.java +++ b/src/main/java/com/example/moim/notification/repository/NotificationJpaRepository.java @@ -1,17 +1,14 @@ package com.example.moim.notification.repository; -import com.example.moim.notification.entity.Notifications; +import com.example.moim.notification.entity.NotificationEntity; import com.example.moim.user.entity.User; +import java.util.List; import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.stereotype.Repository; import org.springframework.transaction.annotation.Transactional; -import java.util.List; - -@Repository -public interface NotificationRepository extends JpaRepository { +public interface NotificationJpaRepository extends JpaRepository { @Transactional(readOnly = true) Boolean existsByTargetUserAndIsRead(User user, Boolean isRead); - List findByTargetUser(User targetUser); + List findByTargetUser(User targetUser); } diff --git a/src/main/java/com/example/moim/notification/repository/NotificationRepositoryImpl.java b/src/main/java/com/example/moim/notification/repository/NotificationRepositoryImpl.java new file mode 100644 index 0000000..3cbb74c --- /dev/null +++ b/src/main/java/com/example/moim/notification/repository/NotificationRepositoryImpl.java @@ -0,0 +1,36 @@ +package com.example.moim.notification.repository; + +import com.example.moim.notification.entity.NotificationEntity; +import com.example.moim.notification.service.port.NotificationRepository; +import com.example.moim.user.entity.User; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +@Repository +@RequiredArgsConstructor +public class NotificationRepositoryImpl implements + NotificationRepository { + + private final NotificationJpaRepository notificationJpaRepository; + + @Override + public Boolean existsByTargetUserAndIsRead(User user, Boolean isRead) { + return notificationJpaRepository.existsByTargetUserAndIsRead(user, isRead); + } + + @Override + public List findByTargetUser(User user) { + return notificationJpaRepository.findByTargetUser(user); + } + + @Override + public void deleteById(Long id) { + notificationJpaRepository.deleteById(id); + } + + @Override + public void saveAll(List notificationEntities) { + notificationJpaRepository.saveAll(notificationEntities); + } +} diff --git a/src/main/java/com/example/moim/notification/repository/NotificationSender.java b/src/main/java/com/example/moim/notification/repository/NotificationSender.java new file mode 100644 index 0000000..1935bcd --- /dev/null +++ b/src/main/java/com/example/moim/notification/repository/NotificationSender.java @@ -0,0 +1,7 @@ +package com.example.moim.notification.repository; + +import com.example.moim.notification.entity.NotificationEntity; + +public interface NotificationSender { + void send(NotificationEntity notificationEntity); +} diff --git a/src/main/java/com/example/moim/notification/service/ClubJoinNotificationEventHandler.java b/src/main/java/com/example/moim/notification/service/ClubJoinNotificationEventHandler.java new file mode 100644 index 0000000..f95a2bd --- /dev/null +++ b/src/main/java/com/example/moim/notification/service/ClubJoinNotificationEventHandler.java @@ -0,0 +1,36 @@ +package com.example.moim.notification.service; + +import com.example.moim.club.repository.UserClubRepository; +import com.example.moim.notification.dto.ClubJoinEvent; +import com.example.moim.notification.entity.NotificationEntity; +import com.example.moim.notification.entity.NotificationType; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class ClubJoinNotificationEventHandler implements NotificationEventHandler { + + private final UserClubRepository userClubRepository; + + @Override + public boolean canHandle(Object event) { + return event instanceof ClubJoinEvent; + } + + @Override + public List handle(ClubJoinEvent event) { + return userClubRepository.findAllByClub(event.getClub()) + .stream() + .map(userClub -> NotificationEntity.create(userClub.getUser() + , NotificationType.CLUB_JOIN + , NotificationType.CLUB_JOIN.formatMessage( + event.getUser().getName(), + event.getClub().getTitle()) + , event.getClub().getTitle() + , event.getClub().getId()) + ) + .toList(); + } +} diff --git a/src/main/java/com/example/moim/notification/service/MatchCancelClubNotificationEventHandler.java b/src/main/java/com/example/moim/notification/service/MatchCancelClubNotificationEventHandler.java new file mode 100644 index 0000000..063342f --- /dev/null +++ b/src/main/java/com/example/moim/notification/service/MatchCancelClubNotificationEventHandler.java @@ -0,0 +1,34 @@ +package com.example.moim.notification.service; + +import com.example.moim.club.repository.UserClubRepository; +import com.example.moim.notification.dto.MatchCancelClubEvent; +import com.example.moim.notification.entity.NotificationEntity; +import com.example.moim.notification.entity.NotificationType; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class MatchCancelClubNotificationEventHandler implements NotificationEventHandler { + + private final UserClubRepository userClubRepository; + + @Override + public boolean canHandle(Object event) { + return event instanceof MatchCancelClubEvent; + } + + @Override + public List handle(MatchCancelClubEvent event) { + return userClubRepository.findAllByClub(event.getTargetClub()).stream() + .map(userClub -> NotificationEntity.create(userClub.getUser() + , NotificationType.MATCH_CANCEL_CLUB + , NotificationType.MATCH_CANCEL_CLUB.formatMessage( + event.getTargetClub().getTitle() + ) + , event.getMatch().getName() + , event.getMatch().getId() + )).toList(); + } +} diff --git a/src/main/java/com/example/moim/notification/service/MatchCancelUserEventNotificationEventHandler.java b/src/main/java/com/example/moim/notification/service/MatchCancelUserEventNotificationEventHandler.java new file mode 100644 index 0000000..8a83c7e --- /dev/null +++ b/src/main/java/com/example/moim/notification/service/MatchCancelUserEventNotificationEventHandler.java @@ -0,0 +1,32 @@ +package com.example.moim.notification.service; + +import com.example.moim.notification.dto.MatchCancelUserEvent; +import com.example.moim.notification.entity.NotificationEntity; +import com.example.moim.notification.entity.NotificationType; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class MatchCancelUserEventNotificationEventHandler implements NotificationEventHandler { + + @Override + public boolean canHandle(Object event) { + return event instanceof MatchCancelUserEvent; + } + + @Override + public List handle(MatchCancelUserEvent event) { + return List.of( + NotificationEntity.create(event.getTargetUser() + , NotificationType.MATCH_CANCEL_USER + , NotificationType.MATCH_CANCEL_USER.formatMessage( + event.getMatch().getName() + ) + , event.getMatch().getName() + , event.getMatch().getId() + ) + ); + } +} diff --git a/src/main/java/com/example/moim/notification/service/MatchInviteNotificationEventHandler.java b/src/main/java/com/example/moim/notification/service/MatchInviteNotificationEventHandler.java new file mode 100644 index 0000000..d575203 --- /dev/null +++ b/src/main/java/com/example/moim/notification/service/MatchInviteNotificationEventHandler.java @@ -0,0 +1,34 @@ +package com.example.moim.notification.service; + +import com.example.moim.club.repository.UserClubRepository; +import com.example.moim.notification.dto.MatchInviteEvent; +import com.example.moim.notification.entity.NotificationEntity; +import com.example.moim.notification.entity.NotificationType; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class MatchInviteNotificationEventHandler implements NotificationEventHandler { + + private final UserClubRepository userClubRepository; + + @Override + public boolean canHandle(Object event) { + return event instanceof MatchInviteEvent; + } + + @Override + public List handle(MatchInviteEvent event) { + return userClubRepository.findAllByClub(event.getClub()) + .stream() + .map(userClub -> NotificationEntity.create(userClub.getUser() + , NotificationType.MATCH_INVITE + , NotificationType.MATCH_INVITE.formatMessage(event.getClub().getTitle()) + , event.getClub().getTitle() + , event.getClub().getId()) + ) + .toList(); + } +} diff --git a/src/main/java/com/example/moim/notification/service/MatchRequestNotificationEventHandler.java b/src/main/java/com/example/moim/notification/service/MatchRequestNotificationEventHandler.java new file mode 100644 index 0000000..48682d7 --- /dev/null +++ b/src/main/java/com/example/moim/notification/service/MatchRequestNotificationEventHandler.java @@ -0,0 +1,35 @@ +package com.example.moim.notification.service; + +import com.example.moim.club.repository.UserClubRepository; +import com.example.moim.notification.dto.MatchRequestEvent; +import com.example.moim.notification.entity.NotificationEntity; +import com.example.moim.notification.entity.NotificationType; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class MatchRequestNotificationEventHandler implements NotificationEventHandler { + + private final UserClubRepository userClubRepository; + + @Override + public boolean canHandle(Object event) { + return event instanceof MatchRequestEvent; + } + + @Override + public List handle(MatchRequestEvent event) { + return userClubRepository.findAllByClub(event.getClub()).stream() + .map(userClub -> + NotificationEntity.create(event.getUser() + , NotificationType.MATCH_SUGGESTION + , NotificationType.MATCH_SUGGESTION.formatMessage( + event.getClub().getTitle() + ) + , event.getMatch().getName() + , event.getMatch().getId())) + .toList(); + } +} diff --git a/src/main/java/com/example/moim/notification/service/NotificationEventDispatcher.java b/src/main/java/com/example/moim/notification/service/NotificationEventDispatcher.java new file mode 100644 index 0000000..cd18711 --- /dev/null +++ b/src/main/java/com/example/moim/notification/service/NotificationEventDispatcher.java @@ -0,0 +1,31 @@ +package com.example.moim.notification.service; + +import com.example.moim.notification.controller.port.NotificationService; +import com.example.moim.notification.entity.NotificationEntity; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.context.event.EventListener; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class NotificationEventDispatcher { + + private final List> handlers; + private final NotificationService notificationService; + + @Async + @EventListener + public void dispatchEvent(Object event) { + handlers.stream() + .filter(strategy -> strategy.canHandle(event)) + .findFirst() + .ifPresent(strategy -> { + @SuppressWarnings("unchecked") + NotificationEventHandler s = (NotificationEventHandler) strategy; + List notificationEntities = s.handle(event); + notificationService.sendAll(notificationEntities); + }); + } +} \ No newline at end of file diff --git a/src/main/java/com/example/moim/notification/service/NotificationEventHandler.java b/src/main/java/com/example/moim/notification/service/NotificationEventHandler.java index 7358638..3cf4595 100644 --- a/src/main/java/com/example/moim/notification/service/NotificationEventHandler.java +++ b/src/main/java/com/example/moim/notification/service/NotificationEventHandler.java @@ -1,140 +1,9 @@ package com.example.moim.notification.service; -import com.example.moim.club.entity.UserClub; -import com.example.moim.club.repository.UserClubRepository; -import com.example.moim.notification.dto.*; -import com.example.moim.notification.entity.Notifications; -import com.example.moim.notification.repository.NotificationRepository; -import com.example.moim.user.entity.User; -import com.example.moim.user.repository.UserRepository; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.context.event.EventListener; -import org.springframework.scheduling.annotation.Async; -import org.springframework.stereotype.Component; - +import com.example.moim.notification.entity.NotificationEntity; import java.util.List; -@Slf4j -@Component -@RequiredArgsConstructor -public class NotificationEventHandler { - private final NotificationRepository notificationRepository; - private final UserClubRepository userClubRepository; - private final UserRepository userRepository; - - @Async - @EventListener - public void handleClubJoinEvent(ClubJoinEvent clubJoinEvent) { - log.info("이벤트 들어옴"); - sendEachNotification(userClubRepository.findAllByClub(clubJoinEvent.getClub()).stream() - .map(userClub -> Notifications.createClubJoinNotification(clubJoinEvent, userClub.getUser())).toList()); - } - - @Async - @EventListener - public void handleScheduleSaveEvent(ScheduleSaveEvent scheduleSaveEvent) { - log.info("이벤트 들어옴"); - sendEachNotification(userClubRepository.findAllByClub(scheduleSaveEvent.getSchedule().getClub()).stream() - .map(userClub -> Notifications.createScheduleSaveNotification(scheduleSaveEvent, userClub.getUser())).toList()); - } - - @Async - @EventListener - public void handleMatchCancelUserEvent(MatchCancelUserEvent event) { - log.info("매치 취소 이벤트 발생 매치 ID: {}", event.getMatch().getId()); - Notifications notification = Notifications.createMatchCancelUserNotification(event, event.getTargetUser()); - sendNotification(notification); - } - - @Async - @EventListener - public void handleMatchCancelClubEvent(MatchCancelClubEvent event) { - log.info("매치 취소 이벤트 발생 매치 ID: {}", event.getMatch().getId()); - // 해당 이벤트 대상 클럽의 모든 사용자에게 알림 생성 - List notifications = userClubRepository.findAllByClub(event.getTargetClub()) - .stream() - .map(userClub -> Notifications.createMatchCancelClubNotification(event, userClub.getUser())) - .toList(); - sendEachNotification(notifications); - } - -// @Async -// @Transactional(propagation = Propagation.REQUIRES_NEW) -// @TransactionalEventListener -// public void handleScheduleVoteEvent(ScheduleVoteEvent scheduleVoteEvent) { -// log.info("이벤트 들어옴"); -// sendNotification(Notifications.createScheduleVoteNotification(scheduleVoteEvent)); -// } - - @EventListener - public void handleScheduleEncourageEvent(ScheduleEncourageEvent scheduleEncourageEvent) { - log.info("이벤트 들어옴"); - sendEachNotification(scheduleEncourageEvent.getUserList().stream().map(user -> Notifications.createScheduleEncourageEvent(scheduleEncourageEvent.getSchedule(), user)).toList()); - } - - @EventListener - public void handleMatchRequestEvent(MatchRequestEvent matchRequestEvent) { - log.info("이벤트 들어옴"); - sendEachNotification(userClubRepository.findAllByClub(matchRequestEvent.getClub()).stream() - .map(userClub -> Notifications.createMatchRequestEvent(matchRequestEvent, userClub.getUser())).toList()); - } - - @EventListener - public void handMatchInviteEvent(MatchInviteEvent matchInviteEvent) { - log.info("이벤트 들어옴"); - sendEachNotification(userClubRepository.findAdminByClub(matchInviteEvent.getClub()).stream() - .map(userClub -> Notifications.createMatchInviteEvent(matchInviteEvent, userClub.getUser())).toList()); - } - - - private void sendNotification(Notifications notification) { -// Message message = Message.builder() -// .setToken(notification.getTargetUser().getFcmToken()) -// .setNotification(Notification.builder() -// .setTitle(notification.getTitle()) -// .setBody(notification.getContent()) -// .build()) -// .setWebpushConfig(WebpushConfig.builder().setNotification(WebpushNotification.builder() -// .setBadge("https://media.discordapp.net/attachments/1081467110200451092/1199660879071952906/MG.png?ex=65c35a42&is=65b0e542&hm=4b109d5d3ab5850eeacbd46d8f8eed253ab7bdb7ac4d28111e57cad3aba58b98&=&format=webp&quality=lossless") -// .build()).build()) -// .setAndroidConfig(AndroidConfig.builder().setNotification(AndroidNotification.builder() -// .setPriority(AndroidNotification.Priority.HIGH) -// .build()).build()) -// .setApnsConfig() -// .build(); -// -// try { -// String response = FirebaseMessaging.getInstance().send(message); -// log.info("Successfully sent message: {}", response); - notificationRepository.save(notification); -// } catch (FirebaseMessagingException e) { -// log.error("cannot send to user push message. error info : {}", e.getMessage()); -// } - } - - private void sendEachNotification(List notification) { -// Message message = Message.builder() -// .setToken(notification.getTargetUser().getFcmToken()) -// .setNotification(Notification.builder() -// .setTitle(notification.getTitle()) -// .setBody(notification.getContent()) -// .build()) -// .setWebpushConfig(WebpushConfig.builder().setNotification(WebpushNotification.builder() -// .setBadge("https://media.discordapp.net/attachments/1081467110200451092/1199660879071952906/MG.png?ex=65c35a42&is=65b0e542&hm=4b109d5d3ab5850eeacbd46d8f8eed253ab7bdb7ac4d28111e57cad3aba58b98&=&format=webp&quality=lossless") -// .build()).build()) -// .setAndroidConfig(AndroidConfig.builder().setNotification(AndroidNotification.builder() -// .setPriority(AndroidNotification.Priority.HIGH) -// .build()).build()) -// .setApnsConfig() -// .build(); -// -// try { -// String response = FirebaseMessaging.getInstance().sendEach(message); -// log.info("Successfully sent message: {}", response); - notificationRepository.saveAll(notification); -// } catch (FirebaseMessagingException e) { -// log.error("cannot send to user push message. error info : {}", e.getMessage()); -// } - } +public interface NotificationEventHandler { + boolean canHandle(Object event); // 타입 판별용 + List handle(T event); } diff --git a/src/main/java/com/example/moim/notification/service/NotificationService.java b/src/main/java/com/example/moim/notification/service/NotificationServiceImpl.java similarity index 50% rename from src/main/java/com/example/moim/notification/service/NotificationService.java rename to src/main/java/com/example/moim/notification/service/NotificationServiceImpl.java index eec60de..132e687 100644 --- a/src/main/java/com/example/moim/notification/service/NotificationService.java +++ b/src/main/java/com/example/moim/notification/service/NotificationServiceImpl.java @@ -1,30 +1,39 @@ package com.example.moim.notification.service; +import com.example.moim.notification.controller.port.NotificationService; import com.example.moim.notification.dto.NotificationExistOutput; import com.example.moim.notification.dto.NotificationOutput; -import com.example.moim.notification.repository.NotificationRepository; +import com.example.moim.notification.entity.NotificationEntity; +import com.example.moim.notification.repository.NotificationSender; +import com.example.moim.notification.service.port.NotificationRepository; import com.example.moim.user.entity.User; +import java.util.List; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import java.util.List; - @Service @RequiredArgsConstructor -public class NotificationService { +public class NotificationServiceImpl implements NotificationService { private final NotificationRepository notificationRepository; + private final NotificationSender notificationSender; - public NotificationExistOutput checkNotice(User user) { + public NotificationExistOutput checkUnread(User user) { return new NotificationExistOutput(notificationRepository.existsByTargetUserAndIsRead(user, false)); } - public List findNotice(User user) { + public List findAll(User user) { return notificationRepository.findByTargetUser(user).stream().map(NotificationOutput::new).toList(); } @Transactional - public void removeNotice(Long id) { + public void remove(Long id) { notificationRepository.deleteById(id); } + + @Transactional + public void sendAll(List notificationEntities) { + notificationRepository.saveAll(notificationEntities); + notificationEntities.forEach(notificationSender::send); + } } diff --git a/src/main/java/com/example/moim/notification/service/ScheduleEncourageNotificationEventHandler.java b/src/main/java/com/example/moim/notification/service/ScheduleEncourageNotificationEventHandler.java new file mode 100644 index 0000000..8cae39d --- /dev/null +++ b/src/main/java/com/example/moim/notification/service/ScheduleEncourageNotificationEventHandler.java @@ -0,0 +1,30 @@ +package com.example.moim.notification.service; + +import com.example.moim.notification.dto.ScheduleEncourageEvent; +import com.example.moim.notification.entity.NotificationEntity; +import com.example.moim.notification.entity.NotificationType; +import java.util.List; +import org.springframework.stereotype.Component; + +@Component +public class ScheduleEncourageNotificationEventHandler implements NotificationEventHandler { + + @Override + public boolean canHandle(Object event) { + return event instanceof ScheduleEncourageEvent; + } + + @Override + public List handle(ScheduleEncourageEvent event) { + return event.getUserList().stream() + .map(user -> NotificationEntity.create( + user + , NotificationType.SCHEDULE_ENCOURAGE + , NotificationType.SCHEDULE_ENCOURAGE.formatMessage( + event.getSchedule().getTitle() + ) + , event.getSchedule().getTitle() + , event.getSchedule().getId() + )).toList(); + } +} diff --git a/src/main/java/com/example/moim/notification/service/ScheduleSaveNotificationEventHandler.java b/src/main/java/com/example/moim/notification/service/ScheduleSaveNotificationEventHandler.java new file mode 100644 index 0000000..878f3ce --- /dev/null +++ b/src/main/java/com/example/moim/notification/service/ScheduleSaveNotificationEventHandler.java @@ -0,0 +1,34 @@ +package com.example.moim.notification.service; + +import com.example.moim.club.repository.UserClubRepository; +import com.example.moim.notification.dto.ScheduleSaveEvent; +import com.example.moim.notification.entity.NotificationEntity; +import com.example.moim.notification.entity.NotificationType; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class ScheduleSaveNotificationEventHandler implements NotificationEventHandler { + + private final UserClubRepository userClubRepository; + + @Override + public boolean canHandle(Object event) { + return event instanceof ScheduleSaveEvent; + } + + @Override + public List handle(ScheduleSaveEvent event) { + return userClubRepository.findAllByClub(event.getSchedule().getClub()) + .stream() + .map(userClub -> NotificationEntity.create(event.getUser() + , NotificationType.SCHEDULE_SAVE + , NotificationType.SCHEDULE_SAVE.formatMessage(event.getSchedule().getTitle()) + , event.getSchedule().getTitle() + , event.getSchedule().getId() + )) + .toList(); + } +} diff --git a/src/main/java/com/example/moim/notification/service/port/NotificationRepository.java b/src/main/java/com/example/moim/notification/service/port/NotificationRepository.java new file mode 100644 index 0000000..ab5a71f --- /dev/null +++ b/src/main/java/com/example/moim/notification/service/port/NotificationRepository.java @@ -0,0 +1,15 @@ +package com.example.moim.notification.service.port; + +import com.example.moim.notification.entity.NotificationEntity; +import com.example.moim.user.entity.User; +import java.util.List; + +public interface NotificationRepository { + Boolean existsByTargetUserAndIsRead(User user, Boolean isRead); + + List findByTargetUser(User user); + + void deleteById(Long id); + + void saveAll(List notificationEntities); +} diff --git a/src/main/java/com/example/moim/user/controller/MypageController.java b/src/main/java/com/example/moim/user/controller/MypageController.java index 8c2f8b5..10e96d4 100644 --- a/src/main/java/com/example/moim/user/controller/MypageController.java +++ b/src/main/java/com/example/moim/user/controller/MypageController.java @@ -1,5 +1,7 @@ package com.example.moim.user.controller; +import com.example.moim.global.exception.BaseResponse; +import com.example.moim.global.exception.ResponseCode; import com.example.moim.user.dto.MypageClubOutput; import com.example.moim.user.dto.UserDetailsImpl; import com.example.moim.user.dto.UserUpdateInput; @@ -18,17 +20,19 @@ public class MypageController implements MypageControllerDocs { private final UserService userService; @PatchMapping(value = "/user/info", consumes = MediaType.MULTIPART_FORM_DATA_VALUE, produces = MediaType.APPLICATION_JSON_VALUE) - public void userInfoUpdate(@AuthenticationPrincipal UserDetailsImpl userDetailsImpl, @ModelAttribute UserUpdateInput userUpdateInput) throws IOException { + public BaseResponse userInfoUpdate(@AuthenticationPrincipal UserDetailsImpl userDetailsImpl, @ModelAttribute UserUpdateInput userUpdateInput) throws IOException { userService.updateUserInfo(userDetailsImpl.getUser(), userUpdateInput); + return BaseResponse.onSuccess(null, ResponseCode.OK); } @GetMapping("/user/club/mypage") - public List findMypageClub(@AuthenticationPrincipal UserDetailsImpl userDetailsImpl) { - return userService.findMypageClub(userDetailsImpl.getUser()); + public BaseResponse> findMypageClub(@AuthenticationPrincipal UserDetailsImpl userDetailsImpl) { + return BaseResponse.onSuccess(userService.findMypageClub(userDetailsImpl.getUser()), ResponseCode.OK); } @DeleteMapping("/user/club/{userClubId}") - public void userClubDelete(@PathVariable Long userClubId, @AuthenticationPrincipal UserDetailsImpl userDetailsImpl) { + public BaseResponse userClubDelete(@PathVariable Long userClubId, @AuthenticationPrincipal UserDetailsImpl userDetailsImpl) { userService.deleteUserClub(userClubId); + return BaseResponse.onSuccess(null, ResponseCode.OK); } } diff --git a/src/main/java/com/example/moim/user/controller/MypageControllerDocs.java b/src/main/java/com/example/moim/user/controller/MypageControllerDocs.java index 3ffc25e..05418cc 100644 --- a/src/main/java/com/example/moim/user/controller/MypageControllerDocs.java +++ b/src/main/java/com/example/moim/user/controller/MypageControllerDocs.java @@ -1,5 +1,6 @@ package com.example.moim.user.controller; +import com.example.moim.global.exception.BaseResponse; import com.example.moim.user.dto.MypageClubOutput; import com.example.moim.user.dto.UserDetailsImpl; import com.example.moim.user.dto.UserUpdateInput; @@ -17,11 +18,11 @@ public interface MypageControllerDocs { @Operation(summary = "유저 정보 수정") - void userInfoUpdate(@AuthenticationPrincipal UserDetailsImpl userDetailsImpl, @ModelAttribute @Valid UserUpdateInput userUpdateInput) throws IOException; + BaseResponse userInfoUpdate(@AuthenticationPrincipal UserDetailsImpl userDetailsImpl, @ModelAttribute @Valid UserUpdateInput userUpdateInput) throws IOException; @Operation(summary = "마이페이지 내가 속한 모임 조회") - List findMypageClub(@AuthenticationPrincipal UserDetailsImpl userDetailsImpl); + BaseResponse> findMypageClub(@AuthenticationPrincipal UserDetailsImpl userDetailsImpl); @Operation(summary = "유저 소속 모임 탈퇴") - void userClubDelete(@PathVariable Long clubId, @AuthenticationPrincipal UserDetailsImpl userDetailsImpl); + BaseResponse userClubDelete(@PathVariable Long clubId, @AuthenticationPrincipal UserDetailsImpl userDetailsImpl); } diff --git a/src/main/java/com/example/moim/user/entity/User.java b/src/main/java/com/example/moim/user/entity/User.java index 9d01e0d..72ef3f4 100644 --- a/src/main/java/com/example/moim/user/entity/User.java +++ b/src/main/java/com/example/moim/user/entity/User.java @@ -6,16 +6,31 @@ import com.example.moim.global.enums.Gender; import com.example.moim.global.enums.Position; import com.example.moim.global.exception.ResponseCode; -import com.example.moim.notification.entity.Notifications; -import com.example.moim.user.dto.*; +import com.example.moim.notification.entity.NotificationEntity; +import com.example.moim.user.dto.GoogleUserSignup; +import com.example.moim.user.dto.KakaoUserSignup; +import com.example.moim.user.dto.NaverUserSignup; +import com.example.moim.user.dto.SignupInput; +import com.example.moim.user.dto.SocialSignupInput; +import com.example.moim.user.dto.UserUpdateInput; import com.example.moim.user.exceptions.advice.UserControllerAdvice; -import jakarta.persistence.*; -import lombok.Getter; -import lombok.NoArgsConstructor; - +import jakarta.persistence.CascadeType; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.OneToMany; +import jakarta.persistence.Table; import java.io.File; import java.util.ArrayList; import java.util.List; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; @Entity @Table(name = "users") @@ -52,7 +67,33 @@ public class User extends BaseEntity { @OneToMany(mappedBy = "user", cascade = CascadeType.REMOVE) private List userClub = new ArrayList<>(); @OneToMany(mappedBy = "targetUser", cascade = CascadeType.REMOVE) - private List notifications = new ArrayList<>(); + private List notifications = new ArrayList<>(); + + @Builder + public User(String email, String password, String name, String birthday, Gender gender, String phone, + String imgPath, + Role role, ActivityArea activityArea, int height, int weight, String mainFoot, Position mainPosition, + Position subPosition, String refreshToken, String fcmToken, List userClub, + List notifications) { + this.email = email; + this.password = password; + this.name = name; + this.birthday = birthday; + this.gender = gender; + this.phone = phone; + this.imgPath = imgPath; + this.role = role; + this.activityArea = activityArea; + this.height = height; + this.weight = weight; + this.mainFoot = mainFoot; + this.mainPosition = mainPosition; + this.subPosition = subPosition; + this.refreshToken = refreshToken; + this.fcmToken = fcmToken; + this.userClub = userClub; + this.notifications = notifications; + } public static User createUser(SignupInput signupInput) { User user = new User(); @@ -60,7 +101,8 @@ public static User createUser(SignupInput signupInput) { user.password = signupInput.getPassword(); user.name = signupInput.getName(); user.birthday = signupInput.getBirthday(); - user.gender = Gender.fromKoreanName(signupInput.getGender()).orElseThrow(() -> new UserControllerAdvice(ResponseCode.INVALID_GENDER)); + user.gender = Gender.fromKoreanName(signupInput.getGender()) + .orElseThrow(() -> new UserControllerAdvice(ResponseCode.INVALID_GENDER)); user.phone = signupInput.getPhone(); user.role = Role.USER; return user; @@ -93,7 +135,8 @@ public static User createKakaoUser(KakaoUserSignup kakaoUserSignup) { public static User createNaverUser(NaverUserSignup naverUserSignup) { User user = new User(); user.email = naverUserSignup.getEmail(); - user.gender = Gender.fromKoreanName(naverUserSignup.getGender()).orElseThrow(() -> new UserControllerAdvice(ResponseCode.INVALID_GENDER)); + user.gender = Gender.fromKoreanName(naverUserSignup.getGender()) + .orElseThrow(() -> new UserControllerAdvice(ResponseCode.INVALID_GENDER)); user.role = Role.USER; return user; } @@ -104,7 +147,8 @@ public void fillUserInfo(SocialSignupInput socialSignupInput, String imgPath) { this.phone = socialSignupInput.getPhone(); this.imgPath = imgPath; // this.gender = Gender.from(socialSignupInput.getGender()); - this.gender = Gender.fromKoreanName(socialSignupInput.getGender()).orElseThrow(() -> new UserControllerAdvice(ResponseCode.INVALID_GENDER)); + this.gender = Gender.fromKoreanName(socialSignupInput.getGender()) + .orElseThrow(() -> new UserControllerAdvice(ResponseCode.INVALID_GENDER)); this.activityArea = socialSignupInput.getActivityArea(); this.height = socialSignupInput.getHeight(); this.weight = socialSignupInput.getWeight(); @@ -133,7 +177,8 @@ public void updateUserInfo(UserUpdateInput userUpdateInput, String imgPath) { this.imgPath = imgPath; } if (userUpdateInput.getGender() != null && !userUpdateInput.getGender().isBlank()) { - this.gender = Gender.fromKoreanName(userUpdateInput.getGender()).orElseThrow(() -> new UserControllerAdvice(ResponseCode.INVALID_GENDER)); + this.gender = Gender.fromKoreanName(userUpdateInput.getGender()) + .orElseThrow(() -> new UserControllerAdvice(ResponseCode.INVALID_GENDER)); } if (userUpdateInput.getActivityArea() != null && !userUpdateInput.getActivityArea().name().isBlank()) { this.activityArea = userUpdateInput.getActivityArea(); diff --git a/src/test/java/com/example/moim/notification/repository/FcmNotificationSenderTest.java b/src/test/java/com/example/moim/notification/repository/FcmNotificationSenderTest.java new file mode 100644 index 0000000..ce31cda --- /dev/null +++ b/src/test/java/com/example/moim/notification/repository/FcmNotificationSenderTest.java @@ -0,0 +1,103 @@ +package com.example.moim.notification.repository; + +import static org.assertj.core.api.AssertionsForClassTypes.assertThat; +import static org.assertj.core.api.AssertionsForClassTypes.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.example.moim.notification.entity.NotificationEntity; +import com.example.moim.notification.entity.NotificationStatus; +import com.example.moim.notification.entity.NotificationType; +import com.example.moim.user.entity.User; +import com.google.firebase.ErrorCode; +import com.google.firebase.messaging.FirebaseMessaging; +import com.google.firebase.messaging.FirebaseMessagingException; +import com.google.firebase.messaging.Message; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.MockedStatic; +import org.mockito.Mockito; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class FcmNotificationSenderTest { + + @Mock + private FirebaseMessaging firebaseMessaging; + + @InjectMocks + private FcmNotificationSender fcmNotificationSender; + + @Test + @DisplayName("FCM 토큰이 유효한 경우 FirebaseMessaging.send()가 호출되고 상태가 SENT로 설정된다") + void send_shouldCallFirebaseMessaging_andMarkAsSent() throws FirebaseMessagingException { + // given + User user = User.builder().fcmToken("valid_token").build(); + NotificationEntity notification = NotificationEntity.builder() + .title("제목") + .content("내용") + .type(NotificationType.CLUB_JOIN) + .targetUser(user) + .build(); + + try (MockedStatic mocked = Mockito.mockStatic(FirebaseMessaging.class)) { + mocked.when(FirebaseMessaging::getInstance).thenReturn(firebaseMessaging); + when(firebaseMessaging.send(any(Message.class))).thenReturn("success_response"); + + // when + fcmNotificationSender.send(notification); + + // then + verify(firebaseMessaging).send(any(Message.class)); + assertThat(notification.getStatus()).isEqualTo(NotificationStatus.SENT); + } + } + + @Test + @DisplayName("FCM 토큰이 유효하지 않은 경우 FirebaseMessaging.send()가 호출되고 상태가 FAILED로 설정된다") + void send_shouldMarkAsFailed_whenTokenIsNull() { + // given + User user = User.builder().fcmToken(null).build(); + NotificationEntity notification = NotificationEntity.builder() + .title("제목") + .content("내용") + .type(NotificationType.CLUB_JOIN) + .targetUser(user) + .build(); + // when + fcmNotificationSender.send(notification); + + // then + assertThat(notification.getStatus()).isEqualTo(NotificationStatus.FAILED); + } + + @Test + @DisplayName("FirebaseMessagingException이 발생하면 상태가 FAILED로 설정된다") + void send_shouldHandleFirebaseMessagingException() throws FirebaseMessagingException { + // given + User user = User.builder().fcmToken("valid-token").build(); + NotificationEntity notification = NotificationEntity.builder() + .title("제목") + .content("내용") + .type(NotificationType.CLUB_JOIN) + .targetUser(user) + .build(); + + try (MockedStatic mocked = Mockito.mockStatic(FirebaseMessaging.class)) { + mocked.when(FirebaseMessaging::getInstance).thenReturn(firebaseMessaging); + FirebaseMessagingException exception = mock(FirebaseMessagingException.class); + when(firebaseMessaging.send(any(Message.class))).thenThrow(exception); + + // when + fcmNotificationSender.send(notification); + + // then + assertThat(notification.getStatus()).isEqualTo(NotificationStatus.FAILED); + } + } +} diff --git a/src/test/java/com/example/moim/notification/service/NotificationEventDispatcherTest.java b/src/test/java/com/example/moim/notification/service/NotificationEventDispatcherTest.java new file mode 100644 index 0000000..2e85c5d --- /dev/null +++ b/src/test/java/com/example/moim/notification/service/NotificationEventDispatcherTest.java @@ -0,0 +1,76 @@ +package com.example.moim.notification.service; + +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.Mockito.verify; + +import com.example.moim.notification.controller.port.NotificationService; +import com.example.moim.notification.entity.NotificationEntity; +import com.example.moim.notification.entity.NotificationType; +import com.example.moim.user.entity.User; +import java.time.Duration; +import java.util.List; +import org.awaitility.Awaitility; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.context.annotation.Bean; + +@SpringBootTest +class NotificationEventDispatcherTest { + + @Autowired + private ApplicationEventPublisher eventPublisher; + + @MockBean + private NotificationService notificationService; + + @TestConfiguration + static class TestStrategyConfig { + @Bean + public NotificationEventHandler dummyStrategy() { + return new NotificationEventHandler<>() { + @Override + public boolean canHandle(Object event) { + return event instanceof DummyEvent; + } + + @Override + public List handle(DummyEvent event) { + return List.of(NotificationEntity.builder() + .title("test") + .content("test") + .type(event.type()) + .targetUser(event.user()) + .build()); + } + }; + } + } + + @Test + @DisplayName("이벤트가 발생하면 비동기로 알림을 전송한다") + void dispatchEvent_shouldSendNotification_whenEventIsDispatched() { + // given + User user = User.builder().fcmToken("test-token").build(); + DummyEvent event = new DummyEvent(NotificationType.MATCH_SUCCESS, user); + + // when + eventPublisher.publishEvent(event); + + // then + // 비동기 처리를 기다리기 위해 Awaitility 또는 CountDownLatch 사용 + Awaitility.await() + .atMost(Duration.ofSeconds(3)) + .untilAsserted(() -> + verify(notificationService).sendAll(anyList()) + ); + } + + private record DummyEvent(NotificationType type, User user) { + } + +} \ No newline at end of file diff --git a/src/test/java/com/example/moim/notification/service/NotificationServiceTest.java b/src/test/java/com/example/moim/notification/service/NotificationServiceTest.java new file mode 100644 index 0000000..16a694a --- /dev/null +++ b/src/test/java/com/example/moim/notification/service/NotificationServiceTest.java @@ -0,0 +1,183 @@ +package com.example.moim.notification.service; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.example.moim.club.dto.request.ClubInput; +import com.example.moim.club.entity.Club; +import com.example.moim.notification.dto.ClubJoinEvent; +import com.example.moim.notification.dto.NotificationExistOutput; +import com.example.moim.notification.dto.NotificationOutput; +import com.example.moim.notification.entity.NotificationEntity; +import com.example.moim.notification.entity.NotificationType; +import com.example.moim.notification.repository.NotificationSender; +import com.example.moim.notification.service.port.NotificationRepository; +import com.example.moim.user.dto.SignupInput; +import com.example.moim.user.entity.User; +import java.util.Collections; +import java.util.List; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class NotificationServiceTest { + + @Mock + private NotificationRepository notificationRepository; + + @InjectMocks + private NotificationServiceImpl notificationService; + + @Mock + private NotificationSender notificationSender; + + @Test + @DisplayName("사용자에게 읽지 않은 알림이 없으면 false 반환한다") + void shouldReturnFalseWhenNoUnreadNotifications() { + // given + User user = new User(); + when(notificationRepository.existsByTargetUserAndIsRead(user, false)).thenReturn(false); + + // when + NotificationExistOutput result = notificationService.checkUnread(user); + + // then + assertFalse(result.getHasNotice()); + } + + @Test + @DisplayName("사용자에게 읽지 않은 알림이 있으면 true 반환한다") + void shouldReturnTrueWhenUnreadNotificationsExist() { + // given + User user = new User(); + when(notificationRepository.existsByTargetUserAndIsRead(user, false)).thenReturn(true); + + // when + NotificationExistOutput result = notificationService.checkUnread(user); + + // then + assertTrue(result.getHasNotice()); + } + + @Test + @DisplayName("사용자에게 온 알림이 없으면 빈 목록을 반환한다") + void shouldReturnEmptyListWhenNoNotificationsReceived() { + // given + User user = new User(); + when(notificationRepository.findByTargetUser(user)).thenReturn(Collections.emptyList()); + + // when + List result = notificationService.findAll(user); + + // then + assertTrue(result.isEmpty()); + } + + @Test + @DisplayName("사용자에게 온 알림이 있으면 사용자의 알림 목록을 반환한다") + void shouldReturnNotificationListWhenNotificationsExist() { + // given + User user = new User(); + Club club = new Club(); + ClubJoinEvent clubJoinEvent = new ClubJoinEvent(user, club); + NotificationEntity notificationEntity = NotificationEntity.create(clubJoinEvent.getUser() + , NotificationType.CLUB_JOIN + , NotificationType.CLUB_JOIN.formatMessage( + clubJoinEvent.getUser().getName() + , clubJoinEvent.getClub().getTitle()) + , clubJoinEvent.getClub().getTitle() + , clubJoinEvent.getClub().getId()); + notificationEntity.setCreatedDate(); + List notificationEntities = List.of(notificationEntity); + when(notificationRepository.findByTargetUser(user)).thenReturn(notificationEntities); + + // when + List result = notificationService.findAll(user); + + // then + assertEquals(1, result.size()); + } + + @Test + @DisplayName("존재하지 않는 ID를 삭제하려고 할 때 예외가 발생하지 않아도 된다") + void shouldNotThrowExceptionWhenRemovingNonExistentNotification() { + // given + Long nonExistentId = 999L; + doNothing().when(notificationRepository).deleteById(nonExistentId); + + // when + notificationService.remove(nonExistentId); + + // then + verify(notificationRepository, times(1)).deleteById(nonExistentId); + } + + @Test + @DisplayName("알림 ID로 알림을 삭제할 수 있다") + // FIXME : 근데 알림 삭제가 왜 필요하지? 읽음 처리도 아니고? + void shouldDeleteNotificationById() { + // given + Long notificationId = 1L; + doNothing().when(notificationRepository).deleteById(notificationId); + + // when + notificationService.remove(notificationId); + + // then + verify(notificationRepository, times(1)).deleteById(notificationId); + } + + + @Test + @DisplayName("알림을 저장하고 전송한다") + void shouldSaveAndSendNotifications() { + // given + User targetUser = User.createUser( + SignupInput.builder() + .phone("010-1234-5678") + .name("John Doe") + .password("password") + .email("email@gmail.com") + .birthday("2000-01-01") + .gender("남성") + .build() + ); + + Club joinedClub = Club.createClub( + ClubInput.builder() + .title("Club Title") + .clubCategory("동아리") + .gender("남성") + .activityArea("서울") + .sportsType("축구") + .ageRange("20대") + .build() + , "path/to/image" + ); + + NotificationEntity n1 = NotificationEntity.create(targetUser + , NotificationType.CLUB_JOIN + , NotificationType.CLUB_JOIN.formatMessage( + targetUser.getName() + , joinedClub.getTitle()) + , joinedClub.getTitle() + , 1L); + List notifications = List.of(n1); + + // when + notificationService.sendAll(notifications); + + // then + verify(notificationRepository).saveAll(notifications); // 저장이 호출되었는지 + verify(notificationSender).send(n1); // 전송이 각 알림마다 호출되었는지 + } +} \ No newline at end of file diff --git a/src/test/resources/application.properties b/src/test/resources/application.properties new file mode 100644 index 0000000..b2d2a5a --- /dev/null +++ b/src/test/resources/application.properties @@ -0,0 +1 @@ +spring.profiles.active=test \ No newline at end of file