diff --git a/build.gradle b/build.gradle index 7c91ca6..2c7d6b8 100644 --- a/build.gradle +++ b/build.gradle @@ -63,6 +63,9 @@ dependencies { // sms implementation 'com.solapi:sdk:1.0.3' + + // firebase + implementation 'com.google.firebase:firebase-admin:9.1.1' } tasks.named('test') { diff --git a/src/main/java/ita/tinybite/TinyBiteApplication.java b/src/main/java/ita/tinybite/TinyBiteApplication.java index 6f957ed..65e2736 100644 --- a/src/main/java/ita/tinybite/TinyBiteApplication.java +++ b/src/main/java/ita/tinybite/TinyBiteApplication.java @@ -3,9 +3,11 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.data.jpa.repository.config.EnableJpaAuditing; +import org.springframework.scheduling.annotation.EnableScheduling; @SpringBootApplication @EnableJpaAuditing +@EnableScheduling public class TinyBiteApplication { public static void main(String[] args) { SpringApplication.run(TinyBiteApplication.class, args); diff --git a/src/main/java/ita/tinybite/domain/notification/controller/FcmTokenController.java b/src/main/java/ita/tinybite/domain/notification/controller/FcmTokenController.java new file mode 100644 index 0000000..7616daa --- /dev/null +++ b/src/main/java/ita/tinybite/domain/notification/controller/FcmTokenController.java @@ -0,0 +1,29 @@ +package ita.tinybite.domain.notification.controller; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import ita.tinybite.domain.notification.dto.request.FcmTokenRequest; +import ita.tinybite.domain.notification.service.FcmTokenService; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/v1/fcm") +public class FcmTokenController { + + private final FcmTokenService fcmTokenService; + + // token 이미 존재 시 업데이트 해줌 + @PostMapping("/token") + public ResponseEntity registerToken(@RequestBody @Valid FcmTokenRequest request, + @RequestHeader(name = "User-ID") Long currentUserId) { + fcmTokenService.saveOrUpdateToken(currentUserId, request.token()); + return ResponseEntity.noContent().build(); + } +} diff --git a/src/main/java/ita/tinybite/domain/notification/converter/NotificationLogConverter.java b/src/main/java/ita/tinybite/domain/notification/converter/NotificationLogConverter.java new file mode 100644 index 0000000..65c1628 --- /dev/null +++ b/src/main/java/ita/tinybite/domain/notification/converter/NotificationLogConverter.java @@ -0,0 +1,23 @@ +package ita.tinybite.domain.notification.converter; + +import org.springframework.stereotype.Component; + +import ita.tinybite.domain.notification.entity.Notification; +import ita.tinybite.domain.notification.enums.NotificationType; + +@Component +public class NotificationLogConverter { + + public Notification toEntity(Long targetUserId, String type, String title,String detail) { + + NotificationType notificationType = NotificationType.valueOf(type); + + return Notification.builder() + .userId(targetUserId) + .notificationType(notificationType) + .notificationTitle(title) + .notificationDetail(detail) + .isRead(false) + .build(); + } +} diff --git a/src/main/java/ita/tinybite/domain/notification/converter/NotificationRequestConverter.java b/src/main/java/ita/tinybite/domain/notification/converter/NotificationRequestConverter.java new file mode 100644 index 0000000..286c2d9 --- /dev/null +++ b/src/main/java/ita/tinybite/domain/notification/converter/NotificationRequestConverter.java @@ -0,0 +1,26 @@ +package ita.tinybite.domain.notification.converter; + +import java.util.List; +import java.util.Map; + +import org.springframework.stereotype.Component; + +import ita.tinybite.domain.notification.dto.request.NotificationMulticastRequest; + +@Component +public class NotificationRequestConverter { + + public NotificationMulticastRequest toMulticastRequest( + List tokens, + String title, + String body, + Map data) { + + return NotificationMulticastRequest.builder() + .tokens(tokens) + .title(title) + .body(body) + .data(data) + .build(); + } +} diff --git a/src/main/java/ita/tinybite/domain/notification/dto/request/FcmTokenRequest.java b/src/main/java/ita/tinybite/domain/notification/dto/request/FcmTokenRequest.java new file mode 100644 index 0000000..baea5f1 --- /dev/null +++ b/src/main/java/ita/tinybite/domain/notification/dto/request/FcmTokenRequest.java @@ -0,0 +1,8 @@ +package ita.tinybite.domain.notification.dto.request; + +import jakarta.validation.constraints.NotNull; + +public record FcmTokenRequest( + @NotNull(message = "FCM 토큰은 필수입니다.") + String token +) {} diff --git a/src/main/java/ita/tinybite/domain/notification/dto/request/NotificationMulticastRequest.java b/src/main/java/ita/tinybite/domain/notification/dto/request/NotificationMulticastRequest.java new file mode 100644 index 0000000..fd4981a --- /dev/null +++ b/src/main/java/ita/tinybite/domain/notification/dto/request/NotificationMulticastRequest.java @@ -0,0 +1,43 @@ +package ita.tinybite.domain.notification.dto.request; + +import java.util.List; +import java.util.Map; + +import com.google.firebase.internal.NonNull; +import com.google.firebase.messaging.MulticastMessage; +import com.google.firebase.messaging.Notification; + +import lombok.Builder; + +@Builder +public record NotificationMulticastRequest( + @NonNull List tokens, + String title, + String body, + Map data +) implements NotificationRequest { + + public MulticastMessage.Builder buildSendMessage() { + MulticastMessage.Builder builder = MulticastMessage.builder() + .setNotification(toNotification()) + .addAllTokens(tokens); + + if (this.data != null && !this.data.isEmpty()) { + builder.putAllData(this.data); + } + + return builder; + } + + public Notification toNotification() { + return Notification.builder() + .setTitle(title) + .setBody(body) + .build(); + } + + @Override + public Map data() { + return this.data; + } +} diff --git a/src/main/java/ita/tinybite/domain/notification/dto/request/NotificationRequest.java b/src/main/java/ita/tinybite/domain/notification/dto/request/NotificationRequest.java new file mode 100644 index 0000000..8d3f0c1 --- /dev/null +++ b/src/main/java/ita/tinybite/domain/notification/dto/request/NotificationRequest.java @@ -0,0 +1,12 @@ +package ita.tinybite.domain.notification.dto.request; + +import java.util.Map; + +import com.google.firebase.messaging.Notification; + +public interface NotificationRequest { + String title(); + String body(); + Notification toNotification(); + Map data(); +} diff --git a/src/main/java/ita/tinybite/domain/notification/dto/request/NotificationSingleRequest.java b/src/main/java/ita/tinybite/domain/notification/dto/request/NotificationSingleRequest.java new file mode 100644 index 0000000..0365a8f --- /dev/null +++ b/src/main/java/ita/tinybite/domain/notification/dto/request/NotificationSingleRequest.java @@ -0,0 +1,42 @@ +package ita.tinybite.domain.notification.dto.request; + +import java.util.Map; + +import com.google.firebase.internal.NonNull; +import com.google.firebase.messaging.Message; +import com.google.firebase.messaging.Notification; + +import lombok.Builder; + +@Builder +public record NotificationSingleRequest( + @NonNull String token, + String title, + String body, + Map data +) implements NotificationRequest { + + public Message.Builder buildMessage() { + Message.Builder builder = Message.builder() + .setToken(token) + .setNotification(toNotification()); + + if (this.data != null && !this.data.isEmpty()) { + builder.putAllData(this.data); + } + + return builder; + } + + public Notification toNotification() { + return Notification.builder() + .setTitle(title) + .setBody(body) + .build(); + } + + @Override + public Map data() { + return this.data; + } +} diff --git a/src/main/java/ita/tinybite/domain/notification/entity/FcmToken.java b/src/main/java/ita/tinybite/domain/notification/entity/FcmToken.java new file mode 100644 index 0000000..ad7bc49 --- /dev/null +++ b/src/main/java/ita/tinybite/domain/notification/entity/FcmToken.java @@ -0,0 +1,42 @@ +package ita.tinybite.domain.notification.entity; + +import ita.tinybite.global.entity.BaseEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@Builder +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@Table(name = "fcm_tokens") +public class FcmToken extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "user_id", nullable = false) + private Long userId; + + @Column(name = "token", nullable = false) + private String token; + + @Builder.Default + @Column(name = "is_active", nullable = false) + private Boolean isActive = Boolean.TRUE; + + public void updateToken(String newToken) { + this.token = newToken; + this.isActive = Boolean.TRUE; + } +} diff --git a/src/main/java/ita/tinybite/domain/notification/entity/Notification.java b/src/main/java/ita/tinybite/domain/notification/entity/Notification.java new file mode 100644 index 0000000..3fcd1ab --- /dev/null +++ b/src/main/java/ita/tinybite/domain/notification/entity/Notification.java @@ -0,0 +1,54 @@ +package ita.tinybite.domain.notification.entity; + +import ita.tinybite.domain.notification.enums.NotificationType; +import ita.tinybite.global.entity.BaseEntity; +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.Table; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@Builder +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@Table(name = "notification") +public class Notification extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "id") + private Long id; + + @Column(name = "user_id", nullable = false) + private Long userId; + + @Enumerated(EnumType.STRING) + @Column(name = "notification_type", nullable = false) + private NotificationType notificationType; + + @Column(name = "notification_title", nullable = false) + private String notificationTitle; + + @Column(name = "notification_detail", columnDefinition = "TEXT") + private String notificationDetail; + + @Builder.Default + @Column(name = "is_read", nullable = false) + private Boolean isRead = Boolean.FALSE; + + public void markAsRead() { + if (Boolean.FALSE.equals(this.isRead)) { + this.isRead = Boolean.TRUE; + } + } +} diff --git a/src/main/java/ita/tinybite/domain/notification/enums/NotificationType.java b/src/main/java/ita/tinybite/domain/notification/enums/NotificationType.java new file mode 100644 index 0000000..7ad0e68 --- /dev/null +++ b/src/main/java/ita/tinybite/domain/notification/enums/NotificationType.java @@ -0,0 +1,32 @@ +package ita.tinybite.domain.notification.enums; + +import lombok.AccessLevel; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor(access = AccessLevel.PROTECTED) +public enum NotificationType { + // 채팅 + CHAT_NEW_MESSAGE, + CHAT_UNREAD_REMINDER, + + // 파티 참여 + PARTY_APPROVAL, + PARTY_REJECTION, + PARTY_AUTO_CLOSE, + PARTY_ORDER_COMPLETE, + PARTY_DELIVERY_REMINDER, + PARTY_COMPLETE, + + // 파티 운영 + PARTY_NEW_REQUEST, + PARTY_MEMBER_LEAVE, + PARTY_MANAGER_DELIVERY_REMINDER, + + // 마케팅 알림 + MARKETING_LOCAL_NEW_PARTY, + MARKETING_WEEKLY_POPULAR, + MARKETING_PROMOTION_EVENT; + +} diff --git a/src/main/java/ita/tinybite/domain/notification/infra/creator/APNsConfigCreator.java b/src/main/java/ita/tinybite/domain/notification/infra/creator/APNsConfigCreator.java new file mode 100644 index 0000000..22725bc --- /dev/null +++ b/src/main/java/ita/tinybite/domain/notification/infra/creator/APNsConfigCreator.java @@ -0,0 +1,32 @@ +package ita.tinybite.domain.notification.infra.creator; + +import com.google.firebase.messaging.ApnsConfig; +import com.google.firebase.messaging.Aps; + +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public class APNsConfigCreator { + + public static ApnsConfig createDefaultConfig() { + return ApnsConfig.builder() + .setAps(Aps.builder() + .setSound("default") + .setBadge(1) + .build() + ) + .build(); + } + + // 이벤트별로 동적인 뱃지 숫자 설정 + public static ApnsConfig createConfigWithBadge(int badgeCount) { + return ApnsConfig.builder() + .setAps(Aps.builder() + .setSound("default") + .setBadge(badgeCount) + .build() + ) + .build(); + } +} diff --git a/src/main/java/ita/tinybite/domain/notification/infra/fcm/FcmNotificationSender.java b/src/main/java/ita/tinybite/domain/notification/infra/fcm/FcmNotificationSender.java new file mode 100644 index 0000000..fd68b4c --- /dev/null +++ b/src/main/java/ita/tinybite/domain/notification/infra/fcm/FcmNotificationSender.java @@ -0,0 +1,63 @@ +package ita.tinybite.domain.notification.infra.fcm; + +import org.springframework.stereotype.Service; + +import com.google.firebase.messaging.BatchResponse; +import com.google.firebase.messaging.FirebaseMessaging; +import com.google.firebase.messaging.FirebaseMessagingException; +import com.google.firebase.messaging.Message; +import com.google.firebase.messaging.MulticastMessage; + +import ita.tinybite.domain.notification.dto.request.NotificationMulticastRequest; +import ita.tinybite.domain.notification.dto.request.NotificationSingleRequest; +import ita.tinybite.domain.notification.infra.creator.APNsConfigCreator; +import ita.tinybite.global.exception.BusinessException; +import ita.tinybite.global.exception.errorcode.FcmErrorCode; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Service +@RequiredArgsConstructor +@Slf4j +public class FcmNotificationSender { + + private final FirebaseMessaging firebaseMessaging; + + private static final int MULTICAST_TOKEN_LIMIT = 500; + + public String send(final NotificationSingleRequest request) { + try { + Message message = request.buildMessage() + .setApnsConfig(APNsConfigCreator.createDefaultConfig()) + .build(); + + String response = firebaseMessaging.send(message); + log.info("단일 알림 전송 성공 (토큰: {}): {}", request.token(), response); + return response; + + } catch (FirebaseMessagingException e) { + log.error("FCM 단일 전송 실패 (토큰: {}): {}", request.token(), e.getMessage(), e); + throw new BusinessException(FcmErrorCode.CANNOT_SEND_NOTIFICATION); + } + } + + public BatchResponse send(final NotificationMulticastRequest request) { + if (request.tokens().size() > MULTICAST_TOKEN_LIMIT) { + log.warn("멀티캐스트 실패: 토큰 {}개 (500개 제한 초과)", request.tokens().size()); + throw new BusinessException(FcmErrorCode.FCM_TOKEN_LIMIT_EXCEEDED); + } + try { + MulticastMessage message = request.buildSendMessage() + .setApnsConfig(APNsConfigCreator.createDefaultConfig()) + .build(); + + BatchResponse response = firebaseMessaging.sendMulticast(message); + log.info("멀티캐스트 전송 완료. 성공: {}, 실패: {}", + response.getSuccessCount(), response.getFailureCount()); + return response; + } catch (FirebaseMessagingException e) { + log.error("FCM 멀티캐스트 전송 중 FCM 서버 오류 발생", e); + throw new BusinessException(FcmErrorCode.CANNOT_SEND_NOTIFICATION); + } + } +} \ No newline at end of file diff --git a/src/main/java/ita/tinybite/domain/notification/infra/fcm/FcmTokenScheduler.java b/src/main/java/ita/tinybite/domain/notification/infra/fcm/FcmTokenScheduler.java new file mode 100644 index 0000000..8185086 --- /dev/null +++ b/src/main/java/ita/tinybite/domain/notification/infra/fcm/FcmTokenScheduler.java @@ -0,0 +1,27 @@ +package ita.tinybite.domain.notification.infra.fcm; // 별도의 scheduler 패키지 권장 + +import ita.tinybite.domain.notification.service.FcmTokenService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +import java.time.temporal.ChronoUnit; + +@Component +@RequiredArgsConstructor +@Slf4j +public class FcmTokenScheduler { + + private final FcmTokenService fcmTokenService; + + /** + * 비활성 토큰 정기 삭제 배치 작업. + * 매일 새벽 3시 0분에 실행되며, 3개월 이상 접속 기록이 없는 비활성 토큰을 삭제 + */ + @Scheduled(cron = "0 0 3 * * *") + public void deleteOldTokensBatch() { + int deletedCount = fcmTokenService.deleteOldInactiveTokens(ChronoUnit.MONTHS, 3); + log.info("오래된 FCM 토큰 정기 삭제 배치 완료. 삭제 건수: {}", deletedCount); + } +} \ No newline at end of file diff --git a/src/main/java/ita/tinybite/domain/notification/infra/helper/NotificationTransactionHelper.java b/src/main/java/ita/tinybite/domain/notification/infra/helper/NotificationTransactionHelper.java new file mode 100644 index 0000000..22f7b1a --- /dev/null +++ b/src/main/java/ita/tinybite/domain/notification/infra/helper/NotificationTransactionHelper.java @@ -0,0 +1,55 @@ +package ita.tinybite.domain.notification.infra.helper; + +import com.google.firebase.messaging.BatchResponse; +import com.google.firebase.messaging.MessagingErrorCode; +import com.google.firebase.messaging.SendResponse; +import ita.tinybite.domain.notification.service.FcmTokenService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +import java.util.ArrayList; +import java.util.List; + +@Service +@RequiredArgsConstructor +@Slf4j +public class NotificationTransactionHelper { + + private final FcmTokenService fcmTokenService; + + @Transactional(propagation = Propagation.REQUIRES_NEW) // 메인 트랜잭션에 영향을 주지 않도록 분리 + public void handleBatchResponse(BatchResponse response, List allTokens) { + if (response.getFailureCount() > 0) { + List failedTokens = extractUnregisteredTokens(response, allTokens); + if (!failedTokens.isEmpty()) { + fcmTokenService.deactivateTokens(failedTokens); + log.warn("FCM 응답 기반 토큰 {}건 비활성화 완료. 실패 건수: {}", failedTokens.size(), response.getFailureCount()); + } + } + } + + private List extractUnregisteredTokens(BatchResponse response, List allTokens) { + List unregisteredTokens = new ArrayList<>(); + + for (int i = 0; i < response.getResponses().size(); i++) { + SendResponse sendResponse = response.getResponses().get(i); + + if (!sendResponse.isSuccessful()) { + var exception = sendResponse.getException(); + + if (exception != null) { + var errorCode = exception.getMessagingErrorCode(); + + if ((errorCode == MessagingErrorCode.UNREGISTERED + || errorCode == MessagingErrorCode.INVALID_ARGUMENT)) { + unregisteredTokens.add(allTokens.get(i)); + } + } + } + } + return unregisteredTokens; + } +} \ No newline at end of file diff --git a/src/main/java/ita/tinybite/domain/notification/repository/FcmTokenRepository.java b/src/main/java/ita/tinybite/domain/notification/repository/FcmTokenRepository.java new file mode 100644 index 0000000..98eebf1 --- /dev/null +++ b/src/main/java/ita/tinybite/domain/notification/repository/FcmTokenRepository.java @@ -0,0 +1,30 @@ +package ita.tinybite.domain.notification.repository; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import ita.tinybite.domain.notification.entity.FcmToken; + +public interface FcmTokenRepository extends JpaRepository { + + Optional findByUserIdAndToken(Long userId, String token); + List findAllByUserIdAndIsActiveTrue(Long userId); + List findAllByUserIdInAndIsActiveTrue(List userIds); + + @Modifying + @Query("UPDATE FcmToken t SET t.isActive = :isActive, t.updatedAt = current_timestamp WHERE t.token IN :tokens") + int updateIsActiveByTokenIn( + @Param("tokens") List tokens, + @Param("isActive") Boolean isActive + ); + + @Modifying + @Query("DELETE FROM FcmToken t WHERE t.isActive = FALSE AND t.updatedAt < :cutoffTime") + int deleteByIsActiveFalseAndUpdatedAtBefore(@Param("cutoffTime") LocalDateTime cutoffTime); +} diff --git a/src/main/java/ita/tinybite/domain/notification/repository/NotificationRepository.java b/src/main/java/ita/tinybite/domain/notification/repository/NotificationRepository.java new file mode 100644 index 0000000..0a56b28 --- /dev/null +++ b/src/main/java/ita/tinybite/domain/notification/repository/NotificationRepository.java @@ -0,0 +1,8 @@ +package ita.tinybite.domain.notification.repository; + +import org.springframework.data.jpa.repository.JpaRepository; + +import ita.tinybite.domain.notification.entity.Notification; + +public interface NotificationRepository extends JpaRepository { +} diff --git a/src/main/java/ita/tinybite/domain/notification/service/ChatNotificationService.java b/src/main/java/ita/tinybite/domain/notification/service/ChatNotificationService.java new file mode 100644 index 0000000..eea149b --- /dev/null +++ b/src/main/java/ita/tinybite/domain/notification/service/ChatNotificationService.java @@ -0,0 +1,74 @@ +package ita.tinybite.domain.notification.service; + +import java.util.List; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.google.firebase.messaging.BatchResponse; + +import ita.tinybite.domain.notification.dto.request.NotificationMulticastRequest; +import ita.tinybite.domain.notification.enums.NotificationType; +import ita.tinybite.domain.notification.infra.fcm.FcmNotificationSender; +import ita.tinybite.domain.notification.infra.helper.NotificationTransactionHelper; +import ita.tinybite.domain.notification.service.manager.ChatMessageManager; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Service +@RequiredArgsConstructor +@Slf4j +public class ChatNotificationService { + + private final FcmNotificationSender fcmNotificationSender; + private final FcmTokenService fcmTokenService; + private final ChatMessageManager chatMessageManager; + private final NotificationLogService notificationLogService; + private final NotificationTransactionHelper notificationTransactionHelper; + + @Transactional + public void sendNewChatMessage( + Long targetUserId, + Long chatRoomId, + String senderName, + String messageContent + ) { + String title = "💬 " + senderName + "님의 새 메시지"; + notificationLogService.saveLog(targetUserId, NotificationType.CHAT_NEW_MESSAGE.name(), title, messageContent); + + // 추후 구현 필요 사항: 뱃지 카운트 + // APNs 뱃지 카운트를 동적으로 설정? + // 안 읽은 메시지 알림 반환 방식 정의 필요 + // ChatService를 통해 해당 senderName을 통해 총 안 읽은 메시지 주입받아 이를 통해 뱃지 카운트 형성 + // 현재는 뱃지 카운트 인자 없이 단일 알림 여러개 전송 구조 + + List tokens = fcmTokenService.getTokensAndLogIfEmpty(targetUserId); + if (tokens.isEmpty()) { + return; + } + + NotificationMulticastRequest request = + chatMessageManager.createNewChatMessageRequest(tokens, chatRoomId, title, senderName, messageContent); + + BatchResponse response = fcmNotificationSender.send(request); + notificationTransactionHelper.handleBatchResponse(response, tokens); + } + + @Transactional + public void sendUnreadReminderNotification(Long targetUserId, Long chatRoomId) { + String title = "🔔 놓친 메시지가 있어요!"; + String detail = "안 읽은 메시지가 있어요! 지금 확인해 보세요."; + notificationLogService.saveLog(targetUserId, NotificationType.CHAT_UNREAD_REMINDER.name(), title, detail); + + List tokens = fcmTokenService.getTokensAndLogIfEmpty(targetUserId); + if (tokens.isEmpty()) { + return; + } + + NotificationMulticastRequest request = + chatMessageManager.createUnreadReminderRequest(tokens, chatRoomId, title, detail); + + BatchResponse response = fcmNotificationSender.send(request); + notificationTransactionHelper.handleBatchResponse(response, tokens); + } +} diff --git a/src/main/java/ita/tinybite/domain/notification/service/FcmTokenService.java b/src/main/java/ita/tinybite/domain/notification/service/FcmTokenService.java new file mode 100644 index 0000000..2e486d1 --- /dev/null +++ b/src/main/java/ita/tinybite/domain/notification/service/FcmTokenService.java @@ -0,0 +1,85 @@ +package ita.tinybite.domain.notification.service; + +import java.time.LocalDateTime; +import java.time.temporal.ChronoUnit; +import java.util.List; +import java.util.Optional; +import java.util.stream.Collectors; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import ita.tinybite.domain.notification.entity.FcmToken; +import ita.tinybite.domain.notification.repository.FcmTokenRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +@Slf4j +public class FcmTokenService { + private final FcmTokenRepository fcmTokenRepository; + + @Transactional + public void saveOrUpdateToken(Long userId, String token) { + Optional existingToken = fcmTokenRepository.findByUserIdAndToken(userId, token); + + if (existingToken.isPresent()) { + FcmToken fcmToken = existingToken.get(); + fcmToken.updateToken(token); + } else { + FcmToken newToken = FcmToken.builder() + .userId(userId) + .token(token) + .build(); + fcmTokenRepository.save(newToken); + } + } + + public List getTokensByUserId(Long userId) { + return fcmTokenRepository.findAllByUserIdAndIsActiveTrue(userId).stream() + .map(FcmToken::getToken) + .collect(Collectors.toList()); + } + + public List getTokensByUserIds(List userIds) { + if (userIds == null || userIds.isEmpty()) { + return List.of(); + } + return fcmTokenRepository.findAllByUserIdInAndIsActiveTrue(userIds).stream() + .map(FcmToken::getToken) + .collect(Collectors.toList()); + } + + // 단일 사용자 토큰 조회 + public List getTokensAndLogIfEmpty(Long targetUserId) { // (이름 변경) + List tokens = getTokensByUserId(targetUserId); + if (tokens.isEmpty()) { + log.warn("알림 대상 사용자 ID: {}에 유효한 FCM 토큰이 없습니다. (푸시 Skip)", targetUserId); + } + return tokens; + } + + // 다중 사용자 토큰 조회 + public List getMulticastTokensAndLogIfEmpty(List userIds) { // (이름 변경) + List tokens = getTokensByUserIds(userIds); + if (tokens.isEmpty()) { + log.warn("알림 대상 사용자 목록(IDs: {})에 유효한 FCM 토큰이 없습니다. (푸시 Skip)", userIds); + } + return tokens; + } + + @Transactional + public void deactivateTokens(List tokens) { + if (tokens.isEmpty()) return; + int updatedCount = fcmTokenRepository.updateIsActiveByTokenIn(tokens, Boolean.FALSE); + log.info("FCM 응답 기반 토큰 {}건 비활성화 완료.", updatedCount); + } + + @Transactional + public int deleteOldInactiveTokens(ChronoUnit unit, long amount) { + LocalDateTime cutoffTime = LocalDateTime.now().minus(amount,unit); + return fcmTokenRepository.deleteByIsActiveFalseAndUpdatedAtBefore(cutoffTime); + } +} diff --git a/src/main/java/ita/tinybite/domain/notification/service/NotificationLogService.java b/src/main/java/ita/tinybite/domain/notification/service/NotificationLogService.java new file mode 100644 index 0000000..5e67f45 --- /dev/null +++ b/src/main/java/ita/tinybite/domain/notification/service/NotificationLogService.java @@ -0,0 +1,20 @@ +package ita.tinybite.domain.notification.service; + +import org.springframework.stereotype.Service; + +import ita.tinybite.domain.notification.converter.NotificationLogConverter; +import ita.tinybite.domain.notification.entity.Notification; +import ita.tinybite.domain.notification.repository.NotificationRepository; +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class NotificationLogService { + private final NotificationRepository notificationRepository; + private final NotificationLogConverter notificationLogConverter; + + public void saveLog(Long targetUserId, String type, String title, String detail) { + Notification notification = notificationLogConverter.toEntity(targetUserId, type, title, detail); + notificationRepository.save(notification); + } +} \ No newline at end of file diff --git a/src/main/java/ita/tinybite/domain/notification/service/PartyNotificationService.java b/src/main/java/ita/tinybite/domain/notification/service/PartyNotificationService.java new file mode 100644 index 0000000..f28c2cb --- /dev/null +++ b/src/main/java/ita/tinybite/domain/notification/service/PartyNotificationService.java @@ -0,0 +1,208 @@ +package ita.tinybite.domain.notification.service; + +import java.util.List; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.google.firebase.messaging.BatchResponse; + +import ita.tinybite.domain.notification.dto.request.NotificationMulticastRequest; +import ita.tinybite.domain.notification.enums.NotificationType; +import ita.tinybite.domain.notification.infra.fcm.FcmNotificationSender; +import ita.tinybite.domain.notification.infra.helper.NotificationTransactionHelper; +import ita.tinybite.domain.notification.service.manager.PartyMessageManager; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Service +@RequiredArgsConstructor +@Slf4j +public class PartyNotificationService { + + private final FcmNotificationSender fcmNotificationSender; + private final FcmTokenService fcmTokenService; + private final PartyMessageManager partyMessageManager; + private final NotificationLogService notificationLogService; + private final NotificationTransactionHelper notificationTransactionHelper; + + @Transactional + public void sendApprovalNotification(Long targetUserId, Long partyId) { + String title = "🎉 파티 참여 승인"; + String detail = "파티 참여가 승인되었습니다! 지금 확인하세요."; + notificationLogService.saveLog(targetUserId, NotificationType.PARTY_APPROVAL.name(), title, detail); + + List tokens = fcmTokenService.getTokensAndLogIfEmpty(targetUserId); + if (tokens.isEmpty()) { + return; + } + NotificationMulticastRequest request = + partyMessageManager.createApprovalRequest(tokens, partyId, title, detail); + + BatchResponse response = fcmNotificationSender.send(request); + notificationTransactionHelper.handleBatchResponse(response, tokens); + } + + @Transactional + public void sendRejectionNotification(Long targetUserId, Long partyId) { + String title = "🚨 파티 참여 거절"; + String detail = "죄송합니다. 파티 참여가 거절되었습니다."; + notificationLogService.saveLog(targetUserId, NotificationType.PARTY_REJECTION.name(), title, detail); + + List tokens = fcmTokenService.getTokensAndLogIfEmpty(targetUserId); + if (tokens.isEmpty()) { + return; + } + NotificationMulticastRequest request = + partyMessageManager.createRejectionRequest(tokens, partyId, title, detail); + + BatchResponse response = fcmNotificationSender.send(request); + notificationTransactionHelper.handleBatchResponse(response, tokens); + } + + /** + * 아래 메서드들 파티장,파티멤버의 알림 내용 다른지에 따라 추후 수정 필요 + */ + + @Transactional + public void sendAutoCloseNotification(List memberIds, Long partyId, Long managerId) { + String title = "🎉 파티 자동 마감"; + String memberDetail = "참여 인원이 모두 차서 파티가 마감되었습니다."; + String managerDetail = "축하합니다! 목표 인원 달성으로 파티가 자동 마감되었습니다."; + + memberIds.forEach(userId -> { + String detail = userId.equals(managerId) ? managerDetail : memberDetail; + notificationLogService.saveLog(userId, NotificationType.PARTY_AUTO_CLOSE.name(), title, detail); + }); + + List tokens = fcmTokenService.getMulticastTokensAndLogIfEmpty(memberIds); + if (tokens.isEmpty()) { + return; + } + + NotificationMulticastRequest request = + partyMessageManager.createAutoCloseRequest(tokens, partyId, title, memberDetail); + + BatchResponse response = fcmNotificationSender.send(request); + notificationTransactionHelper.handleBatchResponse(response, tokens); + } + + @Transactional + public void sendOrderCompleteNotification(List memberIds, Long partyId) { + String title = "✅ 상품 주문 완료"; + String detail = "파티장이 상품 주문을 완료했습니다!"; + memberIds.forEach(userId -> + notificationLogService.saveLog(userId, NotificationType.PARTY_ORDER_COMPLETE.name(), title, detail) + ); + + List tokens = fcmTokenService.getMulticastTokensAndLogIfEmpty(memberIds); + if (tokens.isEmpty()) { + return; + } + NotificationMulticastRequest request = + partyMessageManager.createOrderCompleteRequest(tokens, partyId, title, detail); + + BatchResponse response = fcmNotificationSender.send(request); + notificationTransactionHelper.handleBatchResponse(response, tokens); + } + + @Transactional + public void sendDeliveryReminderNotification(List memberIds, Long partyId, Long managerId) { + + // 파티 멤버 + String memberTitle = "⏰ 수령 준비 알림"; + String memberDetail = "수령 시간 30분 전입니다! 늦지 않게 준비해주세요."; + List commonMembers = memberIds.stream() + .filter(id -> !id.equals(managerId)) + .toList(); + + if (!commonMembers.isEmpty()) { + commonMembers.forEach(userId -> + notificationLogService.saveLog(userId, NotificationType.PARTY_DELIVERY_REMINDER.name(), memberTitle, memberDetail) + ); + + List memberTokens = fcmTokenService.getMulticastTokensAndLogIfEmpty(commonMembers); + if (!memberTokens.isEmpty()) { + NotificationMulticastRequest memberRequest = + partyMessageManager.createDeliveryReminderRequest(memberTokens, partyId, memberTitle, memberDetail); + + BatchResponse memberResponse = fcmNotificationSender.send(memberRequest); + notificationTransactionHelper.handleBatchResponse(memberResponse, memberTokens); + } + } + + // 파티장 + String managerTitle = "📍 수령 장소 이동 알림"; + String managerDetail = "수령 시간이 30분 남았습니다. 수령 장소로 이동해주세요!"; + + notificationLogService.saveLog(managerId, NotificationType.PARTY_MANAGER_DELIVERY_REMINDER.name(), managerTitle, managerDetail); + + List managerTokens = fcmTokenService.getTokensAndLogIfEmpty(managerId); + if (!managerTokens.isEmpty()) { + NotificationMulticastRequest managerRequest = + partyMessageManager.createManagerDeliveryReminderRequest(managerTokens, partyId, managerTitle, managerDetail); + + BatchResponse managerResponse = fcmNotificationSender.send(managerRequest); + notificationTransactionHelper.handleBatchResponse(managerResponse, managerTokens); + } + } + + @Transactional + public void sendPartyCompleteNotification(List memberIds, Long partyId) { + String title = "👋 파티 종료"; + String detail = "파티장이 수령 완료 처리했습니다. 파티가 종료되었습니다."; + memberIds.forEach(userId -> + notificationLogService.saveLog(userId, NotificationType.PARTY_COMPLETE.name(), title, detail) + ); + + List tokens = fcmTokenService.getMulticastTokensAndLogIfEmpty(memberIds); + if (tokens.isEmpty()) { + return; + } + + NotificationMulticastRequest request = + partyMessageManager.createPartyCompleteRequest(tokens, partyId, title, detail); + + BatchResponse response = fcmNotificationSender.send(request); + notificationTransactionHelper.handleBatchResponse(response, tokens); + } + + @Transactional + public void sendNewPartyRequestNotification(Long managerId, Long partyId) { + String title = "🔔 새 참여 요청"; + String detail = "새로운 참여 요청이 도착했습니다. 지금 승인해 주세요."; + + notificationLogService.saveLog(managerId, NotificationType.PARTY_NEW_REQUEST.name(), title, detail); + + List tokens = fcmTokenService.getTokensAndLogIfEmpty(managerId); + if (tokens.isEmpty()) { + return; + } + + NotificationMulticastRequest request = + partyMessageManager.createNewPartyRequest(tokens, partyId, title, detail); + + BatchResponse response = fcmNotificationSender.send(request); + notificationTransactionHelper.handleBatchResponse(response, tokens); + } + + + @Transactional + public void sendMemberLeaveNotification(Long managerId, Long partyId, String leaverName) { + String title = "⚠️ 파티원 이탈"; + String detail = leaverName + "님이 파티에서 나갔습니다."; + + notificationLogService.saveLog(managerId, NotificationType.PARTY_MEMBER_LEAVE.name(), title, detail); + + List tokens = fcmTokenService.getTokensAndLogIfEmpty(managerId); + if (tokens.isEmpty()) { + return; + } + + NotificationMulticastRequest request = + partyMessageManager.createMemberLeaveRequest(tokens, partyId, title, detail); + + BatchResponse response = fcmNotificationSender.send(request); + notificationTransactionHelper.handleBatchResponse(response, tokens); + } +} diff --git a/src/main/java/ita/tinybite/domain/notification/service/facade/NotificationFacade.java b/src/main/java/ita/tinybite/domain/notification/service/facade/NotificationFacade.java new file mode 100644 index 0000000..d232065 --- /dev/null +++ b/src/main/java/ita/tinybite/domain/notification/service/facade/NotificationFacade.java @@ -0,0 +1,81 @@ +package ita.tinybite.domain.notification.service.facade; + +import java.util.List; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import ita.tinybite.domain.notification.service.ChatNotificationService; +import ita.tinybite.domain.notification.service.PartyNotificationService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +/** + * 비즈니스 서비스(Party, Chat)로부터 요청을 받아 + * 하위 NotificationService로 위임 + */ +@Service +@RequiredArgsConstructor +@Slf4j +public class NotificationFacade { + + private final PartyNotificationService partyNotificationService; + private final ChatNotificationService chatNotificationService; + + @Transactional + public void notifyApproval(Long targetUserId, Long partyId) { + partyNotificationService.sendApprovalNotification(targetUserId, partyId); + } + + @Transactional + public void notifyRejection(Long targetUserId, Long partyId) { + partyNotificationService.sendRejectionNotification(targetUserId, partyId); + } + + @Transactional + public void notifyPartyAutoClose(List memberIds, Long partyId, Long managerId) { + partyNotificationService.sendAutoCloseNotification(memberIds, partyId, managerId); + } + + @Transactional + public void notifyOrderComplete(List memberIds, Long partyId) { + partyNotificationService.sendOrderCompleteNotification(memberIds, partyId); + } + + @Transactional + public void notifyDeliveryReminder(List memberIds, Long partyId, Long managerId) { + partyNotificationService.sendDeliveryReminderNotification(memberIds, partyId, managerId); + } + + @Transactional + public void notifyPartyComplete(List memberIds, Long partyId) { + partyNotificationService.sendPartyCompleteNotification(memberIds, partyId); + } + + @Transactional + public void notifyNewPartyRequest(Long managerId, Long partyId) { + partyNotificationService.sendNewPartyRequestNotification(managerId, partyId); + } + + @Transactional + public void notifyMemberLeave(Long managerId, Long partyId, String leaverName) { + partyNotificationService.sendMemberLeaveNotification(managerId, partyId, leaverName); + } + + @Transactional + public void notifyNewChatMessage( + Long targetUserId, + Long chatRoomId, + String senderName, + String messageContent + ) { + chatNotificationService.sendNewChatMessage(targetUserId, chatRoomId, senderName, messageContent); + } + + // 스케줄러/채팅 서비스가 호출하며, 알림 도메인은 전송만 처리 + @Transactional + public void notifyUnreadReminder(Long targetUserId, Long chatRoomId) { + chatNotificationService.sendUnreadReminderNotification(targetUserId, chatRoomId); + } + +} diff --git a/src/main/java/ita/tinybite/domain/notification/service/manager/ChatMessageManager.java b/src/main/java/ita/tinybite/domain/notification/service/manager/ChatMessageManager.java new file mode 100644 index 0000000..3d73162 --- /dev/null +++ b/src/main/java/ita/tinybite/domain/notification/service/manager/ChatMessageManager.java @@ -0,0 +1,43 @@ +package ita.tinybite.domain.notification.service.manager; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import org.springframework.stereotype.Component; + +import ita.tinybite.domain.notification.converter.NotificationRequestConverter; +import ita.tinybite.domain.notification.dto.request.NotificationMulticastRequest; +import ita.tinybite.domain.notification.enums.NotificationType; +import lombok.RequiredArgsConstructor; + +@Component +@RequiredArgsConstructor +public class ChatMessageManager { + + private final NotificationRequestConverter requestConverter; + private static final String KEY_CHAT_ROOM_ID = "chatRoomId"; + private static final String KEY_EVENT_TYPE = "eventType"; + private static final String KEY_SENDER_NAME = "senderName"; + + public NotificationMulticastRequest createNewChatMessageRequest( + List tokens, Long chatRoomId, String title, String senderName, String content) { + + Map data = new HashMap<>(); + data.put(KEY_CHAT_ROOM_ID, String.valueOf(chatRoomId)); + data.put(KEY_EVENT_TYPE, NotificationType.CHAT_NEW_MESSAGE.name()); + data.put(KEY_SENDER_NAME, senderName); + + return requestConverter.toMulticastRequest(tokens, title, content, data); + } + + public NotificationMulticastRequest createUnreadReminderRequest( + List tokens, Long chatRoomId, String title, String detail) { + + Map data = new HashMap<>(); + data.put(KEY_CHAT_ROOM_ID, String.valueOf(chatRoomId)); + data.put(KEY_EVENT_TYPE, NotificationType.CHAT_UNREAD_REMINDER.name()); + + return requestConverter.toMulticastRequest(tokens, title, detail, data); + } +} diff --git a/src/main/java/ita/tinybite/domain/notification/service/manager/PartyMessageManager.java b/src/main/java/ita/tinybite/domain/notification/service/manager/PartyMessageManager.java new file mode 100644 index 0000000..3ca8e39 --- /dev/null +++ b/src/main/java/ita/tinybite/domain/notification/service/manager/PartyMessageManager.java @@ -0,0 +1,99 @@ +package ita.tinybite.domain.notification.service.manager; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import org.springframework.stereotype.Component; + +import ita.tinybite.domain.notification.converter.NotificationRequestConverter; +import ita.tinybite.domain.notification.dto.request.NotificationMulticastRequest; +import ita.tinybite.domain.notification.enums.NotificationType; +import lombok.RequiredArgsConstructor; + +@Component +@RequiredArgsConstructor +public class PartyMessageManager { + + private final NotificationRequestConverter requestConverter; + + private static final String KEY_PARTY_ID = "partyId"; + private static final String KEY_EVENT_TYPE = "eventType"; + + public NotificationMulticastRequest createApprovalRequest(List tokens, Long partyId, String title, String detail) { + + Map data = new HashMap<>(); + data.put(KEY_PARTY_ID, String.valueOf(partyId)); + data.put(KEY_EVENT_TYPE, NotificationType.PARTY_APPROVAL.name()); + + return requestConverter.toMulticastRequest(tokens, title, detail, data); + } + + public NotificationMulticastRequest createRejectionRequest(List tokens, Long partyId, String title, String detail) { + + Map data = new HashMap<>(); + data.put(KEY_PARTY_ID, String.valueOf(partyId)); + data.put(KEY_EVENT_TYPE, NotificationType.PARTY_REJECTION.name()); + + return requestConverter.toMulticastRequest(tokens, title, detail, data); + } + + public NotificationMulticastRequest createAutoCloseRequest(List tokens, Long partyId, String title, String detail) { + + Map data = new HashMap<>(); + data.put(KEY_PARTY_ID, String.valueOf(partyId)); + data.put(KEY_EVENT_TYPE, NotificationType.PARTY_AUTO_CLOSE.name()); + + return requestConverter.toMulticastRequest(tokens, title, detail, data); + } + + public NotificationMulticastRequest createOrderCompleteRequest(List tokens, Long partyId, String title, String detail) { + Map data = new HashMap<>(); + data.put(KEY_PARTY_ID, String.valueOf(partyId)); + data.put(KEY_EVENT_TYPE, NotificationType.PARTY_ORDER_COMPLETE.name()); + + return requestConverter.toMulticastRequest(tokens, title, detail, data); + } + + public NotificationMulticastRequest createDeliveryReminderRequest(List memberTokens, Long partyId, String title, String memberDetail) { + Map data = new HashMap<>(); + data.put(KEY_PARTY_ID, String.valueOf(partyId)); + data.put(KEY_EVENT_TYPE, NotificationType.PARTY_DELIVERY_REMINDER.name()); + + return requestConverter.toMulticastRequest(memberTokens, title, memberDetail, data); + } + + public NotificationMulticastRequest createManagerDeliveryReminderRequest(List managerTokens, Long partyId, String title, String managerDetail) { + Map data = new HashMap<>(); + data.put(KEY_PARTY_ID, String.valueOf(partyId)); + data.put(KEY_EVENT_TYPE, NotificationType.PARTY_MANAGER_DELIVERY_REMINDER.name()); + + return requestConverter.toMulticastRequest(managerTokens, title, managerDetail, data); + } + + public NotificationMulticastRequest createPartyCompleteRequest(List tokens, Long partyId, String title, String detail) { + Map data = new HashMap<>(); + data.put(KEY_PARTY_ID, String.valueOf(partyId)); + data.put(KEY_EVENT_TYPE, NotificationType.PARTY_COMPLETE.name()); + + return requestConverter.toMulticastRequest(tokens, title, detail, data); + } + + public NotificationMulticastRequest createNewPartyRequest(List tokens, Long partyId, String title, String detail) { + + Map data = new HashMap<>(); + data.put(KEY_PARTY_ID, String.valueOf(partyId)); + data.put(KEY_EVENT_TYPE, NotificationType.PARTY_NEW_REQUEST.name()); + + return requestConverter.toMulticastRequest(tokens, title, detail, data); + } + + public NotificationMulticastRequest createMemberLeaveRequest(List tokens, Long partyId, String title, String detail) { + + Map data = new HashMap<>(); + data.put(KEY_PARTY_ID, String.valueOf(partyId)); + data.put(KEY_EVENT_TYPE, NotificationType.PARTY_MEMBER_LEAVE.name()); + + return requestConverter.toMulticastRequest(tokens, title, detail, data); + } +} diff --git a/src/main/java/ita/tinybite/global/config/FcmConfig.java b/src/main/java/ita/tinybite/global/config/FcmConfig.java new file mode 100644 index 0000000..00f156e --- /dev/null +++ b/src/main/java/ita/tinybite/global/config/FcmConfig.java @@ -0,0 +1,60 @@ +package ita.tinybite.global.config; + +import java.io.IOException; +import java.io.InputStream; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.io.ClassPathResource; + +import com.google.auth.oauth2.GoogleCredentials; +import com.google.firebase.FirebaseApp; +import com.google.firebase.FirebaseOptions; +import com.google.firebase.messaging.FirebaseMessaging; + +import jakarta.annotation.PostConstruct; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Configuration +public class FcmConfig { + + @Value("${fcm.file_path}") + private String fcmConfigPath; + + @PostConstruct + public void initialize() { + if (!FirebaseApp.getApps().isEmpty()) { + log.info("Firebase app has been initialized successfully."); + return; + } + try { + ClassPathResource resource = new ClassPathResource(fcmConfigPath); + try (InputStream stream = resource.getInputStream()) { + FirebaseOptions options = FirebaseOptions.builder() + .setCredentials(GoogleCredentials.fromStream(stream)) + .build(); + + if (FirebaseApp.getApps().isEmpty()) { + FirebaseApp.initializeApp(options); + log.info("Firebase app has been initialized successfully."); + } + } + } catch (IOException e) { + log.error("Error initializing Firebase app: Firebase 설정 파일을 읽을 수 없습니다.", e); + throw new IllegalStateException("Firebase 초기화 실패: 설정 파일 오류", e); + } + } + + @Bean + public FirebaseMessaging firebaseMessaging() { + try { + return FirebaseMessaging.getInstance(FirebaseApp.getInstance()); + } catch (IllegalStateException e) { + log.error("FirebaseMessaging Bean 등록 실패", e); + throw e; + } + } + +} diff --git a/src/main/java/ita/tinybite/global/exception/EventException.java b/src/main/java/ita/tinybite/global/exception/EventException.java deleted file mode 100644 index 85b641a..0000000 --- a/src/main/java/ita/tinybite/global/exception/EventException.java +++ /dev/null @@ -1,20 +0,0 @@ -package ita.tinybite.global.exception; - -import ita.tinybite.global.exception.errorcode.ErrorCode; -import lombok.Getter; - -@Getter -public class EventException extends RuntimeException { - - private final ErrorCode errorCode; - - public EventException(ErrorCode errorCode) { - super(errorCode.getMessage()); - this.errorCode = errorCode; - } - - public static EventException of(ErrorCode errorCode) { - return new EventException(errorCode); - } -} - diff --git a/src/main/java/ita/tinybite/global/exception/GlobalExceptionHandler.java b/src/main/java/ita/tinybite/global/exception/GlobalExceptionHandler.java index c1ebbdf..df9b724 100644 --- a/src/main/java/ita/tinybite/global/exception/GlobalExceptionHandler.java +++ b/src/main/java/ita/tinybite/global/exception/GlobalExceptionHandler.java @@ -16,15 +16,6 @@ @RestControllerAdvice public class GlobalExceptionHandler { - // 이벤트 에러 처리 - @ExceptionHandler(EventException.class) - public ResponseEntity> handleEventException(EventException exception) { - log.error(exception.getMessage()); - - return ResponseEntity.status(exception.getErrorCode().getHttpStatus()) - .body(APIResponse.businessError(exception.getErrorCode())); - } - // 비즈니스 에러 처리 @ExceptionHandler(BusinessException.class) public ResponseEntity> handleBusinessException(BusinessException exception) { diff --git a/src/main/java/ita/tinybite/global/exception/errorcode/EventErrorCode.java b/src/main/java/ita/tinybite/global/exception/errorcode/EventErrorCode.java deleted file mode 100644 index 3c87357..0000000 --- a/src/main/java/ita/tinybite/global/exception/errorcode/EventErrorCode.java +++ /dev/null @@ -1,27 +0,0 @@ -package ita.tinybite.global.exception.errorcode; - -import org.springframework.http.HttpStatus; - -import lombok.Getter; - -@Getter -public enum EventErrorCode implements ErrorCode { - INVALID_VALUE(HttpStatus.BAD_REQUEST, "INVALID_VALUE", "잘못된 상태값입니다."), - EVENT_NOT_FOUND(HttpStatus.NOT_FOUND, "EVENT_NOT_FOUND", "일정을 찾을 수 없습니다."), - INVALID_REPEAT_COUNT(HttpStatus.BAD_REQUEST, "INVALID_REPEAT_COUNT", "반복 횟수가 허용 범위를 벗어났습니다."), - INVALID_DATE_RANGE(HttpStatus.BAD_REQUEST, "INVALID_DATE_RANGE", "종료 날짜는 시작 날짜보다 앞설 수 없습니다."), - MISSING_TIME(HttpStatus.BAD_REQUEST, "MISSING_TIME", "시작 시간과 종료 시간은 모두 입력되어야 합니다."), - INVALID_TIME_RANGE(HttpStatus.BAD_REQUEST, "INVALID_TIME_RANGE", "종료 시간은 시작 시간보다 같거나 뒤여야 합니다."), - EVENT_NOT_OWNER(HttpStatus.FORBIDDEN, "EVENT_NOT_OWNER", "본인의 이벤트가 아닙니다.") - ; - - private final HttpStatus httpStatus; - private final String code; - private final String message; - - EventErrorCode(HttpStatus httpStatus, String code, String message) { - this.httpStatus = httpStatus; - this.code = code; - this.message = message; - } -} diff --git a/src/main/java/ita/tinybite/global/exception/errorcode/FcmErrorCode.java b/src/main/java/ita/tinybite/global/exception/errorcode/FcmErrorCode.java new file mode 100644 index 0000000..b4afa92 --- /dev/null +++ b/src/main/java/ita/tinybite/global/exception/errorcode/FcmErrorCode.java @@ -0,0 +1,22 @@ +package ita.tinybite.global.exception.errorcode; + +import org.springframework.http.HttpStatus; + +import lombok.Getter; + +@Getter +public enum FcmErrorCode implements ErrorCode{ + CANNOT_SEND_NOTIFICATION(HttpStatus.INTERNAL_SERVER_ERROR, "CANNOT_SEND_NOTIFICATION", "알림 메시지 전송에 실패했습니다."), + FCM_TOKEN_LIMIT_EXCEEDED(HttpStatus.BAD_REQUEST,"FCM_TOKEN_LIMIT_EXCEEDED", "FCM 멀티캐스트 요청의 토큰 개수 제한을 초과했습니다.") + ; + + private final HttpStatus httpStatus; + private final String code; + private final String message; + + FcmErrorCode(HttpStatus httpStatus, String code, String message) { + this.httpStatus = httpStatus; + this.code = code; + this.message = message; + } +} diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index 860b3b9..5601d5b 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -1,4 +1,6 @@ spring: + config: + import: optional:file:.env[.properties] application: name: tinybite @@ -17,4 +19,10 @@ jwt: sms: api-key: ${SMS_API_KEY} - api-secret: ${SMS_API_SECRET} \ No newline at end of file + api-secret: ${SMS_API_SECRET} + +fcm: + file_path: firebase/tinybite_fcm.json + url: https://fcm.googleapis.com/v1/projects/${FCM_PROJECT_ID}/messages:send + google_api: https://www.googleapis.com/auth/cloud-platform + project_id: ${FCM_PROJECT_ID} \ No newline at end of file