From 104533879f7ebc906e231d6fc8eef2909d462f7e Mon Sep 17 00:00:00 2001 From: marshmallowing Date: Wed, 10 Dec 2025 13:25:14 +0900 Subject: [PATCH 01/20] =?UTF-8?q?feat:=20FCM=20=EC=84=9C=EB=B2=84=20?= =?UTF-8?q?=EC=B4=88=EA=B8=B0=ED=99=94=20=EB=B0=8F=20=EC=97=B0=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ita/tinybite/global/config/FcmConfig.java | 47 +++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 src/main/java/ita/tinybite/global/config/FcmConfig.java 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..4255434 --- /dev/null +++ b/src/main/java/ita/tinybite/global/config/FcmConfig.java @@ -0,0 +1,47 @@ +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.Configuration; +import org.springframework.core.io.ClassPathResource; + +import com.google.auth.oauth2.GoogleCredentials; +import com.google.firebase.FirebaseApp; +import com.google.firebase.FirebaseOptions; + +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", e); + } + } + +} From 316bff25bc3db181d583a742b0f899c15601145a Mon Sep 17 00:00:00 2001 From: marshmallowing Date: Wed, 10 Dec 2025 19:27:09 +0900 Subject: [PATCH 02/20] =?UTF-8?q?feat:=20Fcm,=20APNS=20Config=20=EC=84=A4?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/creator/APNsConfigCreator.java | 32 +++++++++++++++++++ .../ita/tinybite/global/config/FcmConfig.java | 12 +++++++ 2 files changed, 44 insertions(+) create mode 100644 src/main/java/ita/tinybite/domain/fcm/service/creator/APNsConfigCreator.java diff --git a/src/main/java/ita/tinybite/domain/fcm/service/creator/APNsConfigCreator.java b/src/main/java/ita/tinybite/domain/fcm/service/creator/APNsConfigCreator.java new file mode 100644 index 0000000..8391145 --- /dev/null +++ b/src/main/java/ita/tinybite/domain/fcm/service/creator/APNsConfigCreator.java @@ -0,0 +1,32 @@ +package ita.tinybite.domain.fcm.service.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/global/config/FcmConfig.java b/src/main/java/ita/tinybite/global/config/FcmConfig.java index 4255434..547203b 100644 --- a/src/main/java/ita/tinybite/global/config/FcmConfig.java +++ b/src/main/java/ita/tinybite/global/config/FcmConfig.java @@ -4,12 +4,14 @@ 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; @@ -44,4 +46,14 @@ public void initialize() { } } + @Bean + public FirebaseMessaging firebaseMessaging() { + try { + return FirebaseMessaging.getInstance(FirebaseApp.getInstance()); + } catch (IllegalStateException e) { + log.error("FirebaseMessaging Bean 등록 실패", e); + throw e; + } + } + } From 7c0393e62c73b4bf8a12904619820051705fc830 Mon Sep 17 00:00:00 2001 From: marshmallowing Date: Wed, 10 Dec 2025 19:31:14 +0900 Subject: [PATCH 03/20] =?UTF-8?q?feat:=20FCM=20Token=20=EA=B4=80=EB=A6=AC?= =?UTF-8?q?=20=EA=B8=B0=EB=8A=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../fcm/controller/FcmTokenController.java | 29 ++++++++++++ .../fcm/dto/request/FcmTokenRequest.java | 8 ++++ .../tinybite/domain/fcm/entity/FcmToken.java | 41 +++++++++++++++++ .../fcm/repository/FcmTokenRepository.java | 14 ++++++ .../domain/fcm/service/FcmTokenService.java | 44 +++++++++++++++++++ 5 files changed, 136 insertions(+) create mode 100644 src/main/java/ita/tinybite/domain/fcm/controller/FcmTokenController.java create mode 100644 src/main/java/ita/tinybite/domain/fcm/dto/request/FcmTokenRequest.java create mode 100644 src/main/java/ita/tinybite/domain/fcm/entity/FcmToken.java create mode 100644 src/main/java/ita/tinybite/domain/fcm/repository/FcmTokenRepository.java create mode 100644 src/main/java/ita/tinybite/domain/fcm/service/FcmTokenService.java diff --git a/src/main/java/ita/tinybite/domain/fcm/controller/FcmTokenController.java b/src/main/java/ita/tinybite/domain/fcm/controller/FcmTokenController.java new file mode 100644 index 0000000..88eb6f1 --- /dev/null +++ b/src/main/java/ita/tinybite/domain/fcm/controller/FcmTokenController.java @@ -0,0 +1,29 @@ +package ita.tinybite.domain.fcm.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.fcm.dto.request.FcmTokenRequest; +import ita.tinybite.domain.fcm.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/fcm/dto/request/FcmTokenRequest.java b/src/main/java/ita/tinybite/domain/fcm/dto/request/FcmTokenRequest.java new file mode 100644 index 0000000..058e8ca --- /dev/null +++ b/src/main/java/ita/tinybite/domain/fcm/dto/request/FcmTokenRequest.java @@ -0,0 +1,8 @@ +package ita.tinybite.domain.fcm.dto.request; + +import jakarta.validation.constraints.NotNull; + +public record FcmTokenRequest( + @NotNull(message = "FCM 토큰은 필수입니다.") + String token +) {} diff --git a/src/main/java/ita/tinybite/domain/fcm/entity/FcmToken.java b/src/main/java/ita/tinybite/domain/fcm/entity/FcmToken.java new file mode 100644 index 0000000..2d7565e --- /dev/null +++ b/src/main/java/ita/tinybite/domain/fcm/entity/FcmToken.java @@ -0,0 +1,41 @@ +package ita.tinybite.domain.fcm.entity; + +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 { + + @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/fcm/repository/FcmTokenRepository.java b/src/main/java/ita/tinybite/domain/fcm/repository/FcmTokenRepository.java new file mode 100644 index 0000000..5bd2179 --- /dev/null +++ b/src/main/java/ita/tinybite/domain/fcm/repository/FcmTokenRepository.java @@ -0,0 +1,14 @@ +package ita.tinybite.domain.fcm.repository; + +import java.util.List; +import java.util.Optional; + +import org.springframework.data.jpa.repository.JpaRepository; + +import ita.tinybite.domain.fcm.entity.FcmToken; + +public interface FcmTokenRepository extends JpaRepository { + + Optional findByUserIdAndToken(Long userId, String token); + List findAllByUserIdAndIsActiveTrue(Long userId); +} diff --git a/src/main/java/ita/tinybite/domain/fcm/service/FcmTokenService.java b/src/main/java/ita/tinybite/domain/fcm/service/FcmTokenService.java new file mode 100644 index 0000000..04a9459 --- /dev/null +++ b/src/main/java/ita/tinybite/domain/fcm/service/FcmTokenService.java @@ -0,0 +1,44 @@ +package ita.tinybite.domain.fcm.service; + +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.fcm.entity.FcmToken; +import ita.tinybite.domain.fcm.repository.FcmTokenRepository; +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +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(); + if (!Boolean.TRUE.equals(fcmToken.getIsActive())) { + 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()); + } + +} From 00db9fbf7cb2a1aadcda61c776254a5849c8a40c Mon Sep 17 00:00:00 2001 From: marshmallowing Date: Wed, 10 Dec 2025 19:32:20 +0900 Subject: [PATCH 04/20] =?UTF-8?q?feat:=20=EC=95=8C=EB=A6=BC=20=EC=9A=94?= =?UTF-8?q?=EC=B2=AD=20Dto?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../request/NotificationMulticastRequest.java | 53 +++++++++++++++++++ .../fcm/dto/request/NotificationRequest.java | 12 +++++ .../request/NotificationSingleRequest.java | 52 ++++++++++++++++++ 3 files changed, 117 insertions(+) create mode 100644 src/main/java/ita/tinybite/domain/fcm/dto/request/NotificationMulticastRequest.java create mode 100644 src/main/java/ita/tinybite/domain/fcm/dto/request/NotificationRequest.java create mode 100644 src/main/java/ita/tinybite/domain/fcm/dto/request/NotificationSingleRequest.java diff --git a/src/main/java/ita/tinybite/domain/fcm/dto/request/NotificationMulticastRequest.java b/src/main/java/ita/tinybite/domain/fcm/dto/request/NotificationMulticastRequest.java new file mode 100644 index 0000000..117a6d1 --- /dev/null +++ b/src/main/java/ita/tinybite/domain/fcm/dto/request/NotificationMulticastRequest.java @@ -0,0 +1,53 @@ +package ita.tinybite.domain.fcm.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(access = PRIVATE) +@Builder +public record NotificationMulticastRequest( + @NonNull List tokens, + String title, + String body, + Map data +) implements NotificationRequest { + + public static NotificationMulticastRequest of(List tokens, String title, String body, Map data) { + return NotificationMulticastRequest.builder() + .tokens(tokens) + .title(title) + .body(body) + .data(data) + .build(); + } + + 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/fcm/dto/request/NotificationRequest.java b/src/main/java/ita/tinybite/domain/fcm/dto/request/NotificationRequest.java new file mode 100644 index 0000000..e0b4870 --- /dev/null +++ b/src/main/java/ita/tinybite/domain/fcm/dto/request/NotificationRequest.java @@ -0,0 +1,12 @@ +package ita.tinybite.domain.fcm.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/fcm/dto/request/NotificationSingleRequest.java b/src/main/java/ita/tinybite/domain/fcm/dto/request/NotificationSingleRequest.java new file mode 100644 index 0000000..9060cf0 --- /dev/null +++ b/src/main/java/ita/tinybite/domain/fcm/dto/request/NotificationSingleRequest.java @@ -0,0 +1,52 @@ +package ita.tinybite.domain.fcm.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(access = PRIVATE) +@Builder +public record NotificationSingleRequest( + @NonNull String token, + String title, + String body, + Map data +) implements NotificationRequest { + + public static NotificationSingleRequest of(String token, String title, String body, Map data) { + return NotificationSingleRequest.builder() + .token(token) + .title(title) + .body(body) + .data(data) + .build(); + } + + 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; + } +} From 09e2908aed24433660b05f2a96a67324ca9bea16 Mon Sep 17 00:00:00 2001 From: marshmallowing Date: Wed, 10 Dec 2025 19:32:47 +0900 Subject: [PATCH 05/20] =?UTF-8?q?feat:=20FCM=EC=84=9C=EB=B2=84=EB=A1=9C=20?= =?UTF-8?q?=EB=A9=94=EC=8B=9C=EC=A7=80=20=EC=A0=84=EC=86=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../fcm/service/NotificationSender.java | 64 +++++++++++++++++++ 1 file changed, 64 insertions(+) create mode 100644 src/main/java/ita/tinybite/domain/fcm/service/NotificationSender.java diff --git a/src/main/java/ita/tinybite/domain/fcm/service/NotificationSender.java b/src/main/java/ita/tinybite/domain/fcm/service/NotificationSender.java new file mode 100644 index 0000000..cf2a2d3 --- /dev/null +++ b/src/main/java/ita/tinybite/domain/fcm/service/NotificationSender.java @@ -0,0 +1,64 @@ +package ita.tinybite.domain.fcm.service; + +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.fcm.dto.request.NotificationMulticastRequest; +import ita.tinybite.domain.fcm.dto.request.NotificationSingleRequest; +import ita.tinybite.domain.fcm.service.creator.APNsConfigCreator; +import ita.tinybite.global.exception.BusinessException; +import ita.tinybite.global.exception.errorcode.FcmErrorCode; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +//토큰 목록과 Message 객체를 받아 FCM 서버로 전송 +@Service +@RequiredArgsConstructor +@Slf4j +public class NotificationSender { + + 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.sendEachForMulticast(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 From 14f4ac836fbafda184775bc0a4e16a9339555cb1 Mon Sep 17 00:00:00 2001 From: marshmallowing Date: Wed, 10 Dec 2025 19:33:29 +0900 Subject: [PATCH 06/20] =?UTF-8?q?chore:=20=EC=97=90=EB=9F=AC=20=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=EC=A0=95=EB=A6=AC,=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Conflicts: # src/main/resources/application.yaml --- .../global/exception/EventException.java | 20 -------------- .../exception/GlobalExceptionHandler.java | 9 ------- .../exception/errorcode/EventErrorCode.java | 27 ------------------- .../exception/errorcode/FcmErrorCode.java | 22 +++++++++++++++ src/main/resources/application.yaml | 10 ++++++- 5 files changed, 31 insertions(+), 57 deletions(-) delete mode 100644 src/main/java/ita/tinybite/global/exception/EventException.java delete mode 100644 src/main/java/ita/tinybite/global/exception/errorcode/EventErrorCode.java create mode 100644 src/main/java/ita/tinybite/global/exception/errorcode/FcmErrorCode.java 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..331366b 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/smeem_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 From 8ae5ce7345474c2375220af3e65adb7cf2b8767e Mon Sep 17 00:00:00 2001 From: marshmallowing Date: Wed, 10 Dec 2025 21:30:33 +0900 Subject: [PATCH 07/20] =?UTF-8?q?feat:=20notification=EC=97=90=20=EC=95=8C?= =?UTF-8?q?=EB=A6=BC=20=ED=9E=88=EC=8A=A4=ED=86=A0=EB=A6=AC=20=EA=B8=B0?= =?UTF-8?q?=EB=A1=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../converter/NotificationLogConverter.java | 22 ++++++++ .../notification/entity/Notification.java | 51 +++++++++++++++++++ .../service/NotificationLogService.java | 20 ++++++++ 3 files changed, 93 insertions(+) create mode 100644 src/main/java/ita/tinybite/domain/notification/converter/NotificationLogConverter.java create mode 100644 src/main/java/ita/tinybite/domain/notification/entity/Notification.java create mode 100644 src/main/java/ita/tinybite/domain/notification/service/NotificationLogService.java 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..257ac27 --- /dev/null +++ b/src/main/java/ita/tinybite/domain/notification/converter/NotificationLogConverter.java @@ -0,0 +1,22 @@ +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 detail) { + + NotificationType notificationType = NotificationType.valueOf(type); + + return Notification.builder() + .userId(targetUserId) + .notificationType(notificationType) + .notificationDetail(detail) + .isRead(false) + .build(); + } +} 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..7eb8ccd --- /dev/null +++ b/src/main/java/ita/tinybite/domain/notification/entity/Notification.java @@ -0,0 +1,51 @@ +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_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/service/NotificationLogService.java b/src/main/java/ita/tinybite/domain/notification/service/NotificationLogService.java new file mode 100644 index 0000000..aa9c663 --- /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 detail) { + Notification notification = notificationLogConverter.toEntity(targetUserId, type, detail); + notificationRepository.save(notification); + } +} \ No newline at end of file From 57cf92c10c5974df1d02aa5d022a3ae0f80058f2 Mon Sep 17 00:00:00 2001 From: marshmallowing Date: Wed, 10 Dec 2025 21:31:07 +0900 Subject: [PATCH 08/20] =?UTF-8?q?feat:=20Facade=EB=A1=9C=20=EB=B6=84?= =?UTF-8?q?=EB=A6=AC=ED=95=B4=20=EC=84=9C=EB=B9=84=EC=8A=A4=20=EC=B2=98?= =?UTF-8?q?=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/ChatNotificationService.java | 46 +++++++++++++++++++ .../service/PartyNotificationService.java | 44 ++++++++++++++++++ .../service/facade/NotificationFacade.java | 38 +++++++++++++++ 3 files changed, 128 insertions(+) create mode 100644 src/main/java/ita/tinybite/domain/notification/service/ChatNotificationService.java create mode 100644 src/main/java/ita/tinybite/domain/notification/service/PartyNotificationService.java create mode 100644 src/main/java/ita/tinybite/domain/notification/service/facade/NotificationFacade.java 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..b5a656d --- /dev/null +++ b/src/main/java/ita/tinybite/domain/notification/service/ChatNotificationService.java @@ -0,0 +1,46 @@ +package ita.tinybite.domain.notification.service; + +import java.util.List; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import ita.tinybite.domain.notification.dto.request.NotificationMulticastRequest; +import ita.tinybite.domain.notification.enums.NotificationType; +import ita.tinybite.domain.notification.service.manager.ChatMessageManager; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Service +@RequiredArgsConstructor +@Slf4j +public class ChatNotificationService { + + private final NotificationSender notificationSender; + private final FcmTokenService fcmTokenService; + private final ChatMessageManager chatMessageManager; + private final NotificationLogService notificationLogService; + + // 새 채팅 메시지 알림 전송 + @Transactional + public void sendNewChatMessage( + Long targetUserId, + Long chatRoomId, + String senderName, + String messageContent + ) { + notificationLogService.saveLog(targetUserId, NotificationType.PARTY_APPROVAL.name(), messageContent); + + List tokens = fcmTokenService.getTokensByUserId(targetUserId); + if (tokens.isEmpty()) { + log.warn("알림 대상 사용자 ID: {}에 유효한 FCM 토큰이 없습니다.", targetUserId); + return; + } + + NotificationMulticastRequest request = + chatMessageManager.createNewChatMessageRequest(tokens, chatRoomId, senderName, messageContent); + + notificationSender.send(request); + } + +} 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..05e8d4d --- /dev/null +++ b/src/main/java/ita/tinybite/domain/notification/service/PartyNotificationService.java @@ -0,0 +1,44 @@ +package ita.tinybite.domain.notification.service; + +import java.util.List; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import ita.tinybite.domain.notification.dto.request.NotificationMulticastRequest; +import ita.tinybite.domain.notification.enums.NotificationType; +import ita.tinybite.domain.notification.service.manager.PartyMessageManager; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Service +@RequiredArgsConstructor +@Slf4j +public class PartyNotificationService { + + private final NotificationSender notificationSender; + private final FcmTokenService fcmTokenService; + private final PartyMessageManager partyMessageManager; + private final NotificationLogService notificationLogService; + + // 파티 참여 승인 알림 전송 + @Transactional + public void sendApprovalNotification(Long targetUserId, Long partyId) { + + String detail = "파티 참여가 승인되었습니다! 지금 확인하세요."; + notificationLogService.saveLog(targetUserId, NotificationType.PARTY_APPROVAL.name(), detail); + + List tokens = fcmTokenService.getTokensByUserId(targetUserId); + if (tokens.isEmpty()) { + log.warn("알림 대상 사용자 ID: {}에 유효한 FCM 토큰이 없습니다.", targetUserId); + return; + } + + NotificationMulticastRequest request = + partyMessageManager.createApprovalRequest(tokens, partyId, detail); + + notificationSender.send(request); + + // BatchResponse를 받아 실패 토큰을 비활성화하는 후처리 로직을 여기에 추가 + } +} 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..18a0945 --- /dev/null +++ b/src/main/java/ita/tinybite/domain/notification/service/facade/NotificationFacade.java @@ -0,0 +1,38 @@ +package ita.tinybite.domain.notification.service.facade; + +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 notifyNewChatMessage( + Long targetUserId, + Long chatRoomId, + String senderName, + String messageContent + ) { + chatNotificationService.sendNewChatMessage(targetUserId, chatRoomId, senderName, messageContent); + } + +} From 6a83c2157df9767772a64a7053327f70403e11db Mon Sep 17 00:00:00 2001 From: marshmallowing Date: Wed, 10 Dec 2025 21:32:17 +0900 Subject: [PATCH 09/20] =?UTF-8?q?feat:=20=EC=95=8C=EB=A6=BC=20=EB=A9=94?= =?UTF-8?q?=EC=8B=9C=EC=A7=80=20=EC=83=9D=EC=84=B1=20=EB=A1=9C=EC=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../NotificationRequestConverter.java | 26 +++++++++++ .../service/manager/ChatMessageManager.java | 32 ++++++++++++++ .../service/manager/PartyMessageManager.java | 43 +++++++++++++++++++ 3 files changed, 101 insertions(+) create mode 100644 src/main/java/ita/tinybite/domain/notification/converter/NotificationRequestConverter.java create mode 100644 src/main/java/ita/tinybite/domain/notification/service/manager/ChatMessageManager.java create mode 100644 src/main/java/ita/tinybite/domain/notification/service/manager/PartyMessageManager.java 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/service/manager/ChatMessageManager.java b/src/main/java/ita/tinybite/domain/notification/service/manager/ChatMessageManager.java new file mode 100644 index 0000000..163c8b7 --- /dev/null +++ b/src/main/java/ita/tinybite/domain/notification/service/manager/ChatMessageManager.java @@ -0,0 +1,32 @@ +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; + + // 멀티캐스트-대상 유저의 모든 토큰에 전송(새 채팅 메시지) + public NotificationMulticastRequest createNewChatMessageRequest( + List tokens, Long chatRoomId, String senderName, String content) { + + Map data = new HashMap<>(); + data.put("chatRoomId", String.valueOf(chatRoomId)); + data.put("eventType", NotificationType.CHAT_NEW_MESSAGE.name()); + data.put("senderName", senderName); + + String title = "💬 " + senderName + "님의 새 메시지"; + return requestConverter.toMulticastRequest(tokens, title, content, 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..06f542a --- /dev/null +++ b/src/main/java/ita/tinybite/domain/notification/service/manager/PartyMessageManager.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 PartyMessageManager { + + private final NotificationRequestConverter requestConverter; + + // 멀티캐스트-대상 유저의 모든 토큰에 전송(파티 참여 승인) + public NotificationMulticastRequest createApprovalRequest(List tokens, Long partyId, String detail) { + + Map data = new HashMap<>(); + data.put("partyId", String.valueOf(partyId)); + data.put("eventType", NotificationType.PARTY_APPROVAL.name()); + + String title = "🎉 파티 참여 승인"; + return requestConverter.toMulticastRequest(tokens, title, detail, data); + } + + // 멀티 캐스트(파티 자동 마감 알림) + // 참여 인원이 모두 차서 파티가 마감되었습니다. -> detail로 주입 + public NotificationMulticastRequest createAutoCloseRequest(List tokens, Long partyId, String detail) { + + Map data = new HashMap<>(); + data.put("partyId", String.valueOf(partyId)); + data.put("eventType", NotificationType.PARTY_AUTO_CLOSE.name()); + + String title = "🚨 파티 자동 마감"; + return requestConverter.toMulticastRequest(tokens, title, detail, data); + } + +} From 4005cbc38f777ff6dc03de01a7101ffcc0452b95 Mon Sep 17 00:00:00 2001 From: marshmallowing Date: Wed, 10 Dec 2025 21:42:43 +0900 Subject: [PATCH 10/20] =?UTF-8?q?feat:=20=EC=95=8C=EB=A6=BC=20=ED=83=80?= =?UTF-8?q?=EC=9E=85=20Enum?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../notification/enums/NotificationType.java | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 src/main/java/ita/tinybite/domain/notification/enums/NotificationType.java 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; + +} From afb29c2de36fe8223942eb076573644748bdae1e Mon Sep 17 00:00:00 2001 From: marshmallowing Date: Wed, 10 Dec 2025 21:43:21 +0900 Subject: [PATCH 11/20] =?UTF-8?q?chore:=20=ED=8C=A8=ED=82=A4=EC=A7=80=20?= =?UTF-8?q?=EC=9D=B4=EB=A6=84=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/FcmTokenController.java | 6 +++--- .../dto/request/FcmTokenRequest.java | 2 +- .../dto/request/NotificationMulticastRequest.java | 2 +- .../dto/request/NotificationRequest.java | 2 +- .../dto/request/NotificationSingleRequest.java | 2 +- .../domain/{fcm => notification}/entity/FcmToken.java | 5 +++-- .../repository/FcmTokenRepository.java | 4 ++-- .../notification/repository/NotificationRepository.java | 8 ++++++++ .../{fcm => notification}/service/FcmTokenService.java | 6 +++--- .../{fcm => notification}/service/NotificationSender.java | 8 ++++---- .../service/creator/APNsConfigCreator.java | 2 +- 11 files changed, 28 insertions(+), 19 deletions(-) rename src/main/java/ita/tinybite/domain/{fcm => notification}/controller/FcmTokenController.java (83%) rename src/main/java/ita/tinybite/domain/{fcm => notification}/dto/request/FcmTokenRequest.java (73%) rename src/main/java/ita/tinybite/domain/{fcm => notification}/dto/request/NotificationMulticastRequest.java (95%) rename src/main/java/ita/tinybite/domain/{fcm => notification}/dto/request/NotificationRequest.java (79%) rename src/main/java/ita/tinybite/domain/{fcm => notification}/dto/request/NotificationSingleRequest.java (95%) rename src/main/java/ita/tinybite/domain/{fcm => notification}/entity/FcmToken.java (86%) rename src/main/java/ita/tinybite/domain/{fcm => notification}/repository/FcmTokenRepository.java (74%) create mode 100644 src/main/java/ita/tinybite/domain/notification/repository/NotificationRepository.java rename src/main/java/ita/tinybite/domain/{fcm => notification}/service/FcmTokenService.java (85%) rename src/main/java/ita/tinybite/domain/{fcm => notification}/service/NotificationSender.java (88%) rename src/main/java/ita/tinybite/domain/{fcm => notification}/service/creator/APNsConfigCreator.java (92%) diff --git a/src/main/java/ita/tinybite/domain/fcm/controller/FcmTokenController.java b/src/main/java/ita/tinybite/domain/notification/controller/FcmTokenController.java similarity index 83% rename from src/main/java/ita/tinybite/domain/fcm/controller/FcmTokenController.java rename to src/main/java/ita/tinybite/domain/notification/controller/FcmTokenController.java index 88eb6f1..7616daa 100644 --- a/src/main/java/ita/tinybite/domain/fcm/controller/FcmTokenController.java +++ b/src/main/java/ita/tinybite/domain/notification/controller/FcmTokenController.java @@ -1,4 +1,4 @@ -package ita.tinybite.domain.fcm.controller; +package ita.tinybite.domain.notification.controller; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.PostMapping; @@ -7,8 +7,8 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; -import ita.tinybite.domain.fcm.dto.request.FcmTokenRequest; -import ita.tinybite.domain.fcm.service.FcmTokenService; +import ita.tinybite.domain.notification.dto.request.FcmTokenRequest; +import ita.tinybite.domain.notification.service.FcmTokenService; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; diff --git a/src/main/java/ita/tinybite/domain/fcm/dto/request/FcmTokenRequest.java b/src/main/java/ita/tinybite/domain/notification/dto/request/FcmTokenRequest.java similarity index 73% rename from src/main/java/ita/tinybite/domain/fcm/dto/request/FcmTokenRequest.java rename to src/main/java/ita/tinybite/domain/notification/dto/request/FcmTokenRequest.java index 058e8ca..baea5f1 100644 --- a/src/main/java/ita/tinybite/domain/fcm/dto/request/FcmTokenRequest.java +++ b/src/main/java/ita/tinybite/domain/notification/dto/request/FcmTokenRequest.java @@ -1,4 +1,4 @@ -package ita.tinybite.domain.fcm.dto.request; +package ita.tinybite.domain.notification.dto.request; import jakarta.validation.constraints.NotNull; diff --git a/src/main/java/ita/tinybite/domain/fcm/dto/request/NotificationMulticastRequest.java b/src/main/java/ita/tinybite/domain/notification/dto/request/NotificationMulticastRequest.java similarity index 95% rename from src/main/java/ita/tinybite/domain/fcm/dto/request/NotificationMulticastRequest.java rename to src/main/java/ita/tinybite/domain/notification/dto/request/NotificationMulticastRequest.java index 117a6d1..9b7ccfa 100644 --- a/src/main/java/ita/tinybite/domain/fcm/dto/request/NotificationMulticastRequest.java +++ b/src/main/java/ita/tinybite/domain/notification/dto/request/NotificationMulticastRequest.java @@ -1,4 +1,4 @@ -package ita.tinybite.domain.fcm.dto.request; +package ita.tinybite.domain.notification.dto.request; import java.util.List; import java.util.Map; diff --git a/src/main/java/ita/tinybite/domain/fcm/dto/request/NotificationRequest.java b/src/main/java/ita/tinybite/domain/notification/dto/request/NotificationRequest.java similarity index 79% rename from src/main/java/ita/tinybite/domain/fcm/dto/request/NotificationRequest.java rename to src/main/java/ita/tinybite/domain/notification/dto/request/NotificationRequest.java index e0b4870..8d3f0c1 100644 --- a/src/main/java/ita/tinybite/domain/fcm/dto/request/NotificationRequest.java +++ b/src/main/java/ita/tinybite/domain/notification/dto/request/NotificationRequest.java @@ -1,4 +1,4 @@ -package ita.tinybite.domain.fcm.dto.request; +package ita.tinybite.domain.notification.dto.request; import java.util.Map; diff --git a/src/main/java/ita/tinybite/domain/fcm/dto/request/NotificationSingleRequest.java b/src/main/java/ita/tinybite/domain/notification/dto/request/NotificationSingleRequest.java similarity index 95% rename from src/main/java/ita/tinybite/domain/fcm/dto/request/NotificationSingleRequest.java rename to src/main/java/ita/tinybite/domain/notification/dto/request/NotificationSingleRequest.java index 9060cf0..b77213e 100644 --- a/src/main/java/ita/tinybite/domain/fcm/dto/request/NotificationSingleRequest.java +++ b/src/main/java/ita/tinybite/domain/notification/dto/request/NotificationSingleRequest.java @@ -1,4 +1,4 @@ -package ita.tinybite.domain.fcm.dto.request; +package ita.tinybite.domain.notification.dto.request; import java.util.Map; diff --git a/src/main/java/ita/tinybite/domain/fcm/entity/FcmToken.java b/src/main/java/ita/tinybite/domain/notification/entity/FcmToken.java similarity index 86% rename from src/main/java/ita/tinybite/domain/fcm/entity/FcmToken.java rename to src/main/java/ita/tinybite/domain/notification/entity/FcmToken.java index 2d7565e..ad7bc49 100644 --- a/src/main/java/ita/tinybite/domain/fcm/entity/FcmToken.java +++ b/src/main/java/ita/tinybite/domain/notification/entity/FcmToken.java @@ -1,5 +1,6 @@ -package ita.tinybite.domain.fcm.entity; +package ita.tinybite.domain.notification.entity; +import ita.tinybite.global.entity.BaseEntity; import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.GeneratedValue; @@ -18,7 +19,7 @@ @NoArgsConstructor(access = AccessLevel.PROTECTED) @AllArgsConstructor @Table(name = "fcm_tokens") -public class FcmToken { +public class FcmToken extends BaseEntity { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) diff --git a/src/main/java/ita/tinybite/domain/fcm/repository/FcmTokenRepository.java b/src/main/java/ita/tinybite/domain/notification/repository/FcmTokenRepository.java similarity index 74% rename from src/main/java/ita/tinybite/domain/fcm/repository/FcmTokenRepository.java rename to src/main/java/ita/tinybite/domain/notification/repository/FcmTokenRepository.java index 5bd2179..931ad50 100644 --- a/src/main/java/ita/tinybite/domain/fcm/repository/FcmTokenRepository.java +++ b/src/main/java/ita/tinybite/domain/notification/repository/FcmTokenRepository.java @@ -1,11 +1,11 @@ -package ita.tinybite.domain.fcm.repository; +package ita.tinybite.domain.notification.repository; import java.util.List; import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; -import ita.tinybite.domain.fcm.entity.FcmToken; +import ita.tinybite.domain.notification.entity.FcmToken; public interface FcmTokenRepository extends JpaRepository { 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/fcm/service/FcmTokenService.java b/src/main/java/ita/tinybite/domain/notification/service/FcmTokenService.java similarity index 85% rename from src/main/java/ita/tinybite/domain/fcm/service/FcmTokenService.java rename to src/main/java/ita/tinybite/domain/notification/service/FcmTokenService.java index 04a9459..e9f71a2 100644 --- a/src/main/java/ita/tinybite/domain/fcm/service/FcmTokenService.java +++ b/src/main/java/ita/tinybite/domain/notification/service/FcmTokenService.java @@ -1,4 +1,4 @@ -package ita.tinybite.domain.fcm.service; +package ita.tinybite.domain.notification.service; import java.util.List; import java.util.Optional; @@ -7,8 +7,8 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import ita.tinybite.domain.fcm.entity.FcmToken; -import ita.tinybite.domain.fcm.repository.FcmTokenRepository; +import ita.tinybite.domain.notification.entity.FcmToken; +import ita.tinybite.domain.notification.repository.FcmTokenRepository; import lombok.RequiredArgsConstructor; @Service diff --git a/src/main/java/ita/tinybite/domain/fcm/service/NotificationSender.java b/src/main/java/ita/tinybite/domain/notification/service/NotificationSender.java similarity index 88% rename from src/main/java/ita/tinybite/domain/fcm/service/NotificationSender.java rename to src/main/java/ita/tinybite/domain/notification/service/NotificationSender.java index cf2a2d3..bccc810 100644 --- a/src/main/java/ita/tinybite/domain/fcm/service/NotificationSender.java +++ b/src/main/java/ita/tinybite/domain/notification/service/NotificationSender.java @@ -1,4 +1,4 @@ -package ita.tinybite.domain.fcm.service; +package ita.tinybite.domain.notification.service; import org.springframework.stereotype.Service; @@ -8,9 +8,9 @@ import com.google.firebase.messaging.Message; import com.google.firebase.messaging.MulticastMessage; -import ita.tinybite.domain.fcm.dto.request.NotificationMulticastRequest; -import ita.tinybite.domain.fcm.dto.request.NotificationSingleRequest; -import ita.tinybite.domain.fcm.service.creator.APNsConfigCreator; +import ita.tinybite.domain.notification.dto.request.NotificationMulticastRequest; +import ita.tinybite.domain.notification.dto.request.NotificationSingleRequest; +import ita.tinybite.domain.notification.service.creator.APNsConfigCreator; import ita.tinybite.global.exception.BusinessException; import ita.tinybite.global.exception.errorcode.FcmErrorCode; import lombok.RequiredArgsConstructor; diff --git a/src/main/java/ita/tinybite/domain/fcm/service/creator/APNsConfigCreator.java b/src/main/java/ita/tinybite/domain/notification/service/creator/APNsConfigCreator.java similarity index 92% rename from src/main/java/ita/tinybite/domain/fcm/service/creator/APNsConfigCreator.java rename to src/main/java/ita/tinybite/domain/notification/service/creator/APNsConfigCreator.java index 8391145..fde2389 100644 --- a/src/main/java/ita/tinybite/domain/fcm/service/creator/APNsConfigCreator.java +++ b/src/main/java/ita/tinybite/domain/notification/service/creator/APNsConfigCreator.java @@ -1,4 +1,4 @@ -package ita.tinybite.domain.fcm.service.creator; +package ita.tinybite.domain.notification.service.creator; import com.google.firebase.messaging.ApnsConfig; import com.google.firebase.messaging.Aps; From 949c2ec991a8c152745c4209cdddb4bedb23b502 Mon Sep 17 00:00:00 2001 From: marshmallowing Date: Wed, 10 Dec 2025 21:47:37 +0900 Subject: [PATCH 12/20] =?UTF-8?q?chore:=20dto=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dto/request/NotificationMulticastRequest.java | 10 ---------- .../dto/request/NotificationSingleRequest.java | 10 ---------- 2 files changed, 20 deletions(-) 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 index 9b7ccfa..fd4981a 100644 --- a/src/main/java/ita/tinybite/domain/notification/dto/request/NotificationMulticastRequest.java +++ b/src/main/java/ita/tinybite/domain/notification/dto/request/NotificationMulticastRequest.java @@ -9,7 +9,6 @@ import lombok.Builder; -//@Builder(access = PRIVATE) @Builder public record NotificationMulticastRequest( @NonNull List tokens, @@ -18,15 +17,6 @@ public record NotificationMulticastRequest( Map data ) implements NotificationRequest { - public static NotificationMulticastRequest of(List tokens, String title, String body, Map data) { - return NotificationMulticastRequest.builder() - .tokens(tokens) - .title(title) - .body(body) - .data(data) - .build(); - } - public MulticastMessage.Builder buildSendMessage() { MulticastMessage.Builder builder = MulticastMessage.builder() .setNotification(toNotification()) 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 index b77213e..0365a8f 100644 --- a/src/main/java/ita/tinybite/domain/notification/dto/request/NotificationSingleRequest.java +++ b/src/main/java/ita/tinybite/domain/notification/dto/request/NotificationSingleRequest.java @@ -8,7 +8,6 @@ import lombok.Builder; -//@Builder(access = PRIVATE) @Builder public record NotificationSingleRequest( @NonNull String token, @@ -17,15 +16,6 @@ public record NotificationSingleRequest( Map data ) implements NotificationRequest { - public static NotificationSingleRequest of(String token, String title, String body, Map data) { - return NotificationSingleRequest.builder() - .token(token) - .title(title) - .body(body) - .data(data) - .build(); - } - public Message.Builder buildMessage() { Message.Builder builder = Message.builder() .setToken(token) From b63056df1cd8e5df72e8aece4089378c21950b96 Mon Sep 17 00:00:00 2001 From: marshmallowing Date: Thu, 11 Dec 2025 13:13:04 +0900 Subject: [PATCH 13/20] =?UTF-8?q?feat:=20party=20=EC=95=8C=EB=A6=BC=20?= =?UTF-8?q?=EA=B8=B0=EB=8A=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../repository/FcmTokenRepository.java | 1 + .../notification/service/FcmTokenService.java | 8 + .../service/NotificationSender.java | 1 + .../service/PartyNotificationService.java | 175 +++++++++++++++++- .../service/facade/NotificationFacade.java | 37 ++++ .../service/manager/PartyMessageManager.java | 79 +++++++- 6 files changed, 289 insertions(+), 12 deletions(-) diff --git a/src/main/java/ita/tinybite/domain/notification/repository/FcmTokenRepository.java b/src/main/java/ita/tinybite/domain/notification/repository/FcmTokenRepository.java index 931ad50..c541c5b 100644 --- a/src/main/java/ita/tinybite/domain/notification/repository/FcmTokenRepository.java +++ b/src/main/java/ita/tinybite/domain/notification/repository/FcmTokenRepository.java @@ -11,4 +11,5 @@ public interface FcmTokenRepository extends JpaRepository { Optional findByUserIdAndToken(Long userId, String token); List findAllByUserIdAndIsActiveTrue(Long userId); + List findAllByUserIdInAndIsActiveTrue(List userIds); } diff --git a/src/main/java/ita/tinybite/domain/notification/service/FcmTokenService.java b/src/main/java/ita/tinybite/domain/notification/service/FcmTokenService.java index e9f71a2..3444018 100644 --- a/src/main/java/ita/tinybite/domain/notification/service/FcmTokenService.java +++ b/src/main/java/ita/tinybite/domain/notification/service/FcmTokenService.java @@ -41,4 +41,12 @@ public List getTokensByUserId(Long userId) { .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()); + } } diff --git a/src/main/java/ita/tinybite/domain/notification/service/NotificationSender.java b/src/main/java/ita/tinybite/domain/notification/service/NotificationSender.java index bccc810..dcc6a81 100644 --- a/src/main/java/ita/tinybite/domain/notification/service/NotificationSender.java +++ b/src/main/java/ita/tinybite/domain/notification/service/NotificationSender.java @@ -17,6 +17,7 @@ import lombok.extern.slf4j.Slf4j; //토큰 목록과 Message 객체를 받아 FCM 서버로 전송 +// BatchResponse를 받아 실패 토큰을 비활성화하는 후처리 로직 추가 @Service @RequiredArgsConstructor @Slf4j diff --git a/src/main/java/ita/tinybite/domain/notification/service/PartyNotificationService.java b/src/main/java/ita/tinybite/domain/notification/service/PartyNotificationService.java index 05e8d4d..39366ce 100644 --- a/src/main/java/ita/tinybite/domain/notification/service/PartyNotificationService.java +++ b/src/main/java/ita/tinybite/domain/notification/service/PartyNotificationService.java @@ -21,24 +21,189 @@ public class PartyNotificationService { private final PartyMessageManager partyMessageManager; private final NotificationLogService notificationLogService; - // 파티 참여 승인 알림 전송 + // 파티 참여 승인 @Transactional public void sendApprovalNotification(Long targetUserId, Long partyId) { String detail = "파티 참여가 승인되었습니다! 지금 확인하세요."; notificationLogService.saveLog(targetUserId, NotificationType.PARTY_APPROVAL.name(), detail); - List tokens = fcmTokenService.getTokensByUserId(targetUserId); + List tokens = getTokens(targetUserId); if (tokens.isEmpty()) { - log.warn("알림 대상 사용자 ID: {}에 유효한 FCM 토큰이 없습니다.", targetUserId); return; } - NotificationMulticastRequest request = partyMessageManager.createApprovalRequest(tokens, partyId, detail); + notificationSender.send(request); + } + + // 파티 참여 거절 + @Transactional + public void sendRejectionNotification(Long targetUserId, Long partyId) { + + String detail = "죄송합니다. 파티 참여가 거절되었습니다."; + notificationLogService.saveLog(targetUserId, NotificationType.PARTY_REJECTION.name(), detail); + + List tokens = getTokens(targetUserId); + if (tokens.isEmpty()) { + return; + } + NotificationMulticastRequest request = + partyMessageManager.createRejectionRequest(tokens, partyId, detail); + notificationSender.send(request); + } + + /** + * 아래 메서드들 파티장,파티멤버의 알림 내용 다른지에 따라 추후 수정 필요 + */ + + // 파티 자동 마감 + @Transactional + public void sendAutoCloseNotification(List memberIds, Long partyId, Long managerId) { + + String memberDetail = "참여 인원이 모두 차서 파티가 마감되었습니다."; + String managerDetail = "축하합니다! 목표 인원 달성으로 파티가 자동 마감되었습니다."; + + memberIds.forEach(userId -> { + String detail = userId.equals(managerId) ? managerDetail : memberDetail; + notificationLogService.saveLog(userId, NotificationType.PARTY_AUTO_CLOSE.name(), detail); + }); + + List tokens = getMulticastTokens(memberIds); + if (tokens.isEmpty()) { + return; + } + + NotificationMulticastRequest request = + partyMessageManager.createAutoCloseRequest(tokens, partyId, memberDetail); + notificationSender.send(request); + } + + // 주문 완료 + @Transactional + public void sendOrderCompleteNotification(List memberIds, Long partyId) { + + String detail = "파티장이 상품 주문을 완료했습니다!"; + memberIds.forEach(userId -> + notificationLogService.saveLog(userId, NotificationType.PARTY_ORDER_COMPLETE.name(), detail) + ); + + List tokens = getMulticastTokens(memberIds); + if (tokens.isEmpty()) { + return; + } + NotificationMulticastRequest request = + partyMessageManager.createOrderCompleteRequest(tokens, partyId, detail); + notificationSender.send(request); + } + + // 수령 준비 + @Transactional + public void sendDeliveryReminderNotification(List memberIds, Long partyId, Long managerId) { + + // 파티 멤버 + 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(), memberDetail) + ); + + List memberTokens = getMulticastTokens(commonMembers); + if (!memberTokens.isEmpty()) { + NotificationMulticastRequest memberRequest = + partyMessageManager.createDeliveryReminderRequest(memberTokens, partyId, memberDetail); + notificationSender.send(memberRequest); + } + } + + // 파티장 + String managerType = NotificationType.PARTY_MANAGER_DELIVERY_REMINDER.name(); + String managerDetail = "수령 시간이 30분 남았습니다. 수령 장소로 이동해주세요!"; + notificationLogService.saveLog(managerId, managerType, managerDetail); + + List managerTokens = getTokens(managerId); + if (!managerTokens.isEmpty()) { + NotificationMulticastRequest managerRequest = + partyMessageManager.createManagerDeliveryReminderRequest(managerTokens, partyId, managerDetail); + notificationSender.send(managerRequest); + } + } + + // 파티 종료 + @Transactional + public void sendPartyCompleteNotification(List memberIds, Long partyId) { + + String detail = "파티장이 수령 완료 처리했습니다. 파티가 종료되었습니다."; + memberIds.forEach(userId -> + notificationLogService.saveLog(userId, NotificationType.PARTY_COMPLETE.name(), detail) + ); + + List tokens = getMulticastTokens(memberIds); + if (tokens.isEmpty()) { + return; + } + + NotificationMulticastRequest request = + partyMessageManager.createPartyCompleteRequest(tokens, partyId, detail); + notificationSender.send(request); + } + + // 새 참여 요청 + @Transactional + public void sendNewPartyRequestNotification(Long managerId, Long partyId) { + String detail = "새로운 참여 요청이 도착했습니다. 지금 승인해 주세요."; + + notificationLogService.saveLog(managerId, NotificationType.PARTY_NEW_REQUEST.name(), detail); + + List tokens = getTokens(managerId); + if (tokens.isEmpty()) { + return; + } + + NotificationMulticastRequest request = + partyMessageManager.createNewPartyRequest(tokens, partyId, detail); + notificationSender.send(request); + } + + + // 파티원 나가기 + @Transactional + public void sendMemberLeaveNotification(Long managerId, Long partyId, String leaverName) { + String detail = leaverName + "님이 파티에서 나갔습니다."; + + notificationLogService.saveLog(managerId, NotificationType.PARTY_MEMBER_LEAVE.name(), detail); + + List tokens = getTokens(managerId); + if (tokens.isEmpty()) { + return; + } + + NotificationMulticastRequest request = + partyMessageManager.createMemberLeaveRequest(tokens, partyId, detail); notificationSender.send(request); + } - // BatchResponse를 받아 실패 토큰을 비활성화하는 후처리 로직을 여기에 추가 + + // 단일 사용자 토큰 조회 + private List getTokens(Long targetUserId) { + List tokens = fcmTokenService.getTokensByUserId(targetUserId); + if (tokens.isEmpty()) { + log.warn("알림 대상 사용자 ID: {}에 유효한 FCM 토큰이 없습니다. (푸시 전송 Skip)", targetUserId); + } + return tokens; + } + + // 다중 사용자 토큰 조회 + private List getMulticastTokens(List userIds) { + List tokens = fcmTokenService.getTokensByUserIds(userIds); + if (tokens.isEmpty()) { + log.warn("알림 대상 사용자 목록(IDs: {})에 유효한 FCM 토큰이 없습니다. (푸시 전송 Skip)", userIds); + } + return 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 index 18a0945..2e0a72a 100644 --- a/src/main/java/ita/tinybite/domain/notification/service/facade/NotificationFacade.java +++ b/src/main/java/ita/tinybite/domain/notification/service/facade/NotificationFacade.java @@ -1,5 +1,7 @@ package ita.tinybite.domain.notification.service.facade; +import java.util.List; + import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -25,6 +27,41 @@ 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, 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 index 06f542a..fe7cd6e 100644 --- a/src/main/java/ita/tinybite/domain/notification/service/manager/PartyMessageManager.java +++ b/src/main/java/ita/tinybite/domain/notification/service/manager/PartyMessageManager.java @@ -17,27 +17,92 @@ 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 detail) { Map data = new HashMap<>(); - data.put("partyId", String.valueOf(partyId)); - data.put("eventType", NotificationType.PARTY_APPROVAL.name()); + data.put(KEY_PARTY_ID, String.valueOf(partyId)); + data.put(KEY_EVENT_TYPE, NotificationType.PARTY_APPROVAL.name()); String title = "🎉 파티 참여 승인"; return requestConverter.toMulticastRequest(tokens, title, detail, data); } - // 멀티 캐스트(파티 자동 마감 알림) - // 참여 인원이 모두 차서 파티가 마감되었습니다. -> detail로 주입 + public NotificationMulticastRequest createRejectionRequest(List tokens, Long partyId, String detail) { + + Map data = new HashMap<>(); + data.put(KEY_PARTY_ID, String.valueOf(partyId)); + data.put(KEY_EVENT_TYPE, NotificationType.PARTY_REJECTION.name()); + + String title = "파티 참여 거절"; + return requestConverter.toMulticastRequest(tokens, title, detail, data); + } + public NotificationMulticastRequest createAutoCloseRequest(List tokens, Long partyId, String detail) { Map data = new HashMap<>(); - data.put("partyId", String.valueOf(partyId)); - data.put("eventType", NotificationType.PARTY_AUTO_CLOSE.name()); + data.put(KEY_PARTY_ID, String.valueOf(partyId)); + data.put(KEY_EVENT_TYPE, NotificationType.PARTY_AUTO_CLOSE.name()); String title = "🚨 파티 자동 마감"; return requestConverter.toMulticastRequest(tokens, title, detail, data); } + public NotificationMulticastRequest createOrderCompleteRequest(List tokens, Long partyId, String detail) { + Map data = new HashMap<>(); + data.put(KEY_PARTY_ID, String.valueOf(partyId)); + data.put(KEY_EVENT_TYPE, NotificationType.PARTY_ORDER_COMPLETE.name()); + + String title = "✅ 상품 주문 완료"; + return requestConverter.toMulticastRequest(tokens, title, detail, data); + } + + public NotificationMulticastRequest createDeliveryReminderRequest(List memberTokens, Long partyId, String memberDetail) { + Map data = new HashMap<>(); + data.put(KEY_PARTY_ID, String.valueOf(partyId)); + data.put(KEY_EVENT_TYPE, NotificationType.PARTY_DELIVERY_REMINDER.name()); + + String title = "⏰ 수령 준비 알림"; + return requestConverter.toMulticastRequest(memberTokens, title, memberDetail, data); + } + + public NotificationMulticastRequest createManagerDeliveryReminderRequest(List managerTokens, Long partyId, 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()); + + String title = "📍 수령 장소 이동 알림"; + return requestConverter.toMulticastRequest(managerTokens, title, managerDetail, data); + } + + public NotificationMulticastRequest createPartyCompleteRequest(List tokens, Long partyId, String detail) { + Map data = new HashMap<>(); + data.put(KEY_PARTY_ID, String.valueOf(partyId)); + data.put(KEY_EVENT_TYPE, NotificationType.PARTY_COMPLETE.name()); + + String title = "👋 파티 종료"; + return requestConverter.toMulticastRequest(tokens, title, detail, data); + } + + public NotificationMulticastRequest createNewPartyRequest(List tokens, Long partyId, String detail) { + + Map data = new HashMap<>(); + data.put(KEY_PARTY_ID, String.valueOf(partyId)); + data.put(KEY_EVENT_TYPE, NotificationType.PARTY_NEW_REQUEST.name()); + + String title = "🔔 새 참여 요청"; + return requestConverter.toMulticastRequest(tokens, title, detail, data); + } + + public NotificationMulticastRequest createMemberLeaveRequest(List tokens, Long partyId, String detail) { + + Map data = new HashMap<>(); + data.put(KEY_PARTY_ID, String.valueOf(partyId)); + data.put(KEY_EVENT_TYPE, NotificationType.PARTY_MEMBER_LEAVE.name()); + + String title = "⚠️ 파티원 이탈"; + return requestConverter.toMulticastRequest(tokens, title, detail, data); + } } From 4e39954617b5c5b8764c3e6f15cbb9f37c59297e Mon Sep 17 00:00:00 2001 From: marshmallowing Date: Thu, 11 Dec 2025 13:39:05 +0900 Subject: [PATCH 14/20] =?UTF-8?q?feat:=20chat=20=EC=95=8C=EB=A6=BC=20?= =?UTF-8?q?=EA=B8=B0=EB=8A=A5,=20title=EB=8F=84=20=EC=95=8C=EB=A6=BC=20?= =?UTF-8?q?=ED=85=8C=EC=9D=B4=EB=B8=94=EC=97=90=20=ED=8F=AC=ED=95=A8?= =?UTF-8?q?=EB=90=98=EA=B2=8C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../converter/NotificationLogConverter.java | 3 +- .../notification/entity/Notification.java | 3 + .../service/ChatNotificationService.java | 32 ++++++++-- .../service/NotificationLogService.java | 4 +- .../service/PartyNotificationService.java | 63 +++++++++---------- .../service/facade/NotificationFacade.java | 6 ++ .../service/manager/ChatMessageManager.java | 23 +++++-- .../service/manager/PartyMessageManager.java | 27 +++----- 8 files changed, 94 insertions(+), 67 deletions(-) diff --git a/src/main/java/ita/tinybite/domain/notification/converter/NotificationLogConverter.java b/src/main/java/ita/tinybite/domain/notification/converter/NotificationLogConverter.java index 257ac27..65c1628 100644 --- a/src/main/java/ita/tinybite/domain/notification/converter/NotificationLogConverter.java +++ b/src/main/java/ita/tinybite/domain/notification/converter/NotificationLogConverter.java @@ -8,13 +8,14 @@ @Component public class NotificationLogConverter { - public Notification toEntity(Long targetUserId, String type, String detail) { + 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/entity/Notification.java b/src/main/java/ita/tinybite/domain/notification/entity/Notification.java index 7eb8ccd..3fcd1ab 100644 --- a/src/main/java/ita/tinybite/domain/notification/entity/Notification.java +++ b/src/main/java/ita/tinybite/domain/notification/entity/Notification.java @@ -36,6 +36,9 @@ public class Notification extends BaseEntity { @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; diff --git a/src/main/java/ita/tinybite/domain/notification/service/ChatNotificationService.java b/src/main/java/ita/tinybite/domain/notification/service/ChatNotificationService.java index b5a656d..baac590 100644 --- a/src/main/java/ita/tinybite/domain/notification/service/ChatNotificationService.java +++ b/src/main/java/ita/tinybite/domain/notification/service/ChatNotificationService.java @@ -21,7 +21,6 @@ public class ChatNotificationService { private final ChatMessageManager chatMessageManager; private final NotificationLogService notificationLogService; - // 새 채팅 메시지 알림 전송 @Transactional public void sendNewChatMessage( Long targetUserId, @@ -29,18 +28,39 @@ public void sendNewChatMessage( String senderName, String messageContent ) { - notificationLogService.saveLog(targetUserId, NotificationType.PARTY_APPROVAL.name(), messageContent); - - List tokens = fcmTokenService.getTokensByUserId(targetUserId); + String title = "💬 " + senderName + "님의 새 메시지"; + notificationLogService.saveLog(targetUserId, NotificationType.CHAT_NEW_MESSAGE.name(), title, messageContent); + List tokens = getTokens(targetUserId); if (tokens.isEmpty()) { - log.warn("알림 대상 사용자 ID: {}에 유효한 FCM 토큰이 없습니다.", targetUserId); return; } NotificationMulticastRequest request = - chatMessageManager.createNewChatMessageRequest(tokens, chatRoomId, senderName, messageContent); + chatMessageManager.createNewChatMessageRequest(tokens, chatRoomId, title, senderName, messageContent); + notificationSender.send(request); + } + @Transactional + public void sendUnreadReminderNotification(Long targetUserId, Long chatRoomId) { + String title = "🔔 놓친 메시지가 있어요!"; + String detail = "안 읽은 메시지가 있어요! 지금 확인해 보세요."; + notificationLogService.saveLog(targetUserId, NotificationType.CHAT_UNREAD_REMINDER.name(), title, detail); + + List tokens = getTokens(targetUserId); + if (tokens.isEmpty()) { + return; + } + + NotificationMulticastRequest request = + chatMessageManager.createUnreadReminderRequest(tokens, chatRoomId, title, detail); notificationSender.send(request); } + private List getTokens(Long targetUserId) { + List tokens = fcmTokenService.getTokensByUserId(targetUserId); + if (tokens.isEmpty()) { + log.warn("알림 대상 사용자 ID: {}에 유효한 FCM 토큰이 없습니다. (푸시 전송 Skip)", targetUserId); + } + return tokens; + } } diff --git a/src/main/java/ita/tinybite/domain/notification/service/NotificationLogService.java b/src/main/java/ita/tinybite/domain/notification/service/NotificationLogService.java index aa9c663..5e67f45 100644 --- a/src/main/java/ita/tinybite/domain/notification/service/NotificationLogService.java +++ b/src/main/java/ita/tinybite/domain/notification/service/NotificationLogService.java @@ -13,8 +13,8 @@ public class NotificationLogService { private final NotificationRepository notificationRepository; private final NotificationLogConverter notificationLogConverter; - public void saveLog(Long targetUserId, String type, String detail) { - Notification notification = notificationLogConverter.toEntity(targetUserId, type, detail); + 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 index 39366ce..0c3de95 100644 --- a/src/main/java/ita/tinybite/domain/notification/service/PartyNotificationService.java +++ b/src/main/java/ita/tinybite/domain/notification/service/PartyNotificationService.java @@ -21,35 +21,33 @@ public class PartyNotificationService { private final PartyMessageManager partyMessageManager; private final NotificationLogService notificationLogService; - // 파티 참여 승인 @Transactional public void sendApprovalNotification(Long targetUserId, Long partyId) { - + String title = "🎉 파티 참여 승인"; String detail = "파티 참여가 승인되었습니다! 지금 확인하세요."; - notificationLogService.saveLog(targetUserId, NotificationType.PARTY_APPROVAL.name(), detail); + notificationLogService.saveLog(targetUserId, NotificationType.PARTY_APPROVAL.name(), title, detail); List tokens = getTokens(targetUserId); if (tokens.isEmpty()) { return; } NotificationMulticastRequest request = - partyMessageManager.createApprovalRequest(tokens, partyId, detail); + partyMessageManager.createApprovalRequest(tokens, partyId, title, detail); notificationSender.send(request); } - // 파티 참여 거절 @Transactional public void sendRejectionNotification(Long targetUserId, Long partyId) { - + String title = "🚨 파티 참여 거절"; String detail = "죄송합니다. 파티 참여가 거절되었습니다."; - notificationLogService.saveLog(targetUserId, NotificationType.PARTY_REJECTION.name(), detail); + notificationLogService.saveLog(targetUserId, NotificationType.PARTY_REJECTION.name(), title, detail); List tokens = getTokens(targetUserId); if (tokens.isEmpty()) { return; } NotificationMulticastRequest request = - partyMessageManager.createRejectionRequest(tokens, partyId, detail); + partyMessageManager.createRejectionRequest(tokens, partyId, title, detail); notificationSender.send(request); } @@ -57,16 +55,15 @@ public void sendRejectionNotification(Long targetUserId, Long partyId) { * 아래 메서드들 파티장,파티멤버의 알림 내용 다른지에 따라 추후 수정 필요 */ - // 파티 자동 마감 @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(), detail); + notificationLogService.saveLog(userId, NotificationType.PARTY_AUTO_CLOSE.name(), title, detail); }); List tokens = getMulticastTokens(memberIds); @@ -75,17 +72,16 @@ public void sendAutoCloseNotification(List memberIds, Long partyId, Long m } NotificationMulticastRequest request = - partyMessageManager.createAutoCloseRequest(tokens, partyId, memberDetail); + partyMessageManager.createAutoCloseRequest(tokens, partyId, title, memberDetail); notificationSender.send(request); } - // 주문 완료 @Transactional public void sendOrderCompleteNotification(List memberIds, Long partyId) { - + String title = "✅ 상품 주문 완료"; String detail = "파티장이 상품 주문을 완료했습니다!"; memberIds.forEach(userId -> - notificationLogService.saveLog(userId, NotificationType.PARTY_ORDER_COMPLETE.name(), detail) + notificationLogService.saveLog(userId, NotificationType.PARTY_ORDER_COMPLETE.name(), title, detail) ); List tokens = getMulticastTokens(memberIds); @@ -93,15 +89,15 @@ public void sendOrderCompleteNotification(List memberIds, Long partyId) { return; } NotificationMulticastRequest request = - partyMessageManager.createOrderCompleteRequest(tokens, partyId, detail); + partyMessageManager.createOrderCompleteRequest(tokens, partyId, title, detail); notificationSender.send(request); } - // 수령 준비 @Transactional public void sendDeliveryReminderNotification(List memberIds, Long partyId, Long managerId) { // 파티 멤버 + String memberTitle = "⏰ 수령 준비 알림"; String memberDetail = "수령 시간 30분 전입니다! 늦지 않게 준비해주세요."; List commonMembers = memberIds.stream() .filter(id -> !id.equals(managerId)) @@ -109,38 +105,37 @@ public void sendDeliveryReminderNotification(List memberIds, Long partyId, if (!commonMembers.isEmpty()) { commonMembers.forEach(userId -> - notificationLogService.saveLog(userId, NotificationType.PARTY_DELIVERY_REMINDER.name(), memberDetail) + notificationLogService.saveLog(userId, NotificationType.PARTY_DELIVERY_REMINDER.name(), memberTitle, memberDetail) ); List memberTokens = getMulticastTokens(commonMembers); if (!memberTokens.isEmpty()) { NotificationMulticastRequest memberRequest = - partyMessageManager.createDeliveryReminderRequest(memberTokens, partyId, memberDetail); + partyMessageManager.createDeliveryReminderRequest(memberTokens, partyId, memberTitle, memberDetail); notificationSender.send(memberRequest); } } // 파티장 - String managerType = NotificationType.PARTY_MANAGER_DELIVERY_REMINDER.name(); + String managerTitle = "📍 수령 장소 이동 알림"; String managerDetail = "수령 시간이 30분 남았습니다. 수령 장소로 이동해주세요!"; - notificationLogService.saveLog(managerId, managerType, managerDetail); + notificationLogService.saveLog(managerId, NotificationType.PARTY_MANAGER_DELIVERY_REMINDER.name(), managerTitle, managerDetail); List managerTokens = getTokens(managerId); if (!managerTokens.isEmpty()) { NotificationMulticastRequest managerRequest = - partyMessageManager.createManagerDeliveryReminderRequest(managerTokens, partyId, managerDetail); + partyMessageManager.createManagerDeliveryReminderRequest(managerTokens, partyId, managerTitle, managerDetail); notificationSender.send(managerRequest); } } - // 파티 종료 @Transactional public void sendPartyCompleteNotification(List memberIds, Long partyId) { - + String title = "👋 파티 종료"; String detail = "파티장이 수령 완료 처리했습니다. 파티가 종료되었습니다."; memberIds.forEach(userId -> - notificationLogService.saveLog(userId, NotificationType.PARTY_COMPLETE.name(), detail) + notificationLogService.saveLog(userId, NotificationType.PARTY_COMPLETE.name(), title, detail) ); List tokens = getMulticastTokens(memberIds); @@ -149,16 +144,16 @@ public void sendPartyCompleteNotification(List memberIds, Long partyId) { } NotificationMulticastRequest request = - partyMessageManager.createPartyCompleteRequest(tokens, partyId, detail); + partyMessageManager.createPartyCompleteRequest(tokens, partyId, title, detail); notificationSender.send(request); } - // 새 참여 요청 @Transactional public void sendNewPartyRequestNotification(Long managerId, Long partyId) { + String title = "🔔 새 참여 요청"; String detail = "새로운 참여 요청이 도착했습니다. 지금 승인해 주세요."; - notificationLogService.saveLog(managerId, NotificationType.PARTY_NEW_REQUEST.name(), detail); + notificationLogService.saveLog(managerId, NotificationType.PARTY_NEW_REQUEST.name(), title, detail); List tokens = getTokens(managerId); if (tokens.isEmpty()) { @@ -166,17 +161,17 @@ public void sendNewPartyRequestNotification(Long managerId, Long partyId) { } NotificationMulticastRequest request = - partyMessageManager.createNewPartyRequest(tokens, partyId, detail); + partyMessageManager.createNewPartyRequest(tokens, partyId, title, detail); notificationSender.send(request); } - // 파티원 나가기 @Transactional public void sendMemberLeaveNotification(Long managerId, Long partyId, String leaverName) { + String title = "⚠️ 파티원 이탈"; String detail = leaverName + "님이 파티에서 나갔습니다."; - notificationLogService.saveLog(managerId, NotificationType.PARTY_MEMBER_LEAVE.name(), detail); + notificationLogService.saveLog(managerId, NotificationType.PARTY_MEMBER_LEAVE.name(), title, detail); List tokens = getTokens(managerId); if (tokens.isEmpty()) { @@ -184,7 +179,7 @@ public void sendMemberLeaveNotification(Long managerId, Long partyId, String lea } NotificationMulticastRequest request = - partyMessageManager.createMemberLeaveRequest(tokens, partyId, detail); + partyMessageManager.createMemberLeaveRequest(tokens, partyId, title, detail); notificationSender.send(request); } @@ -193,7 +188,7 @@ public void sendMemberLeaveNotification(Long managerId, Long partyId, String lea private List getTokens(Long targetUserId) { List tokens = fcmTokenService.getTokensByUserId(targetUserId); if (tokens.isEmpty()) { - log.warn("알림 대상 사용자 ID: {}에 유효한 FCM 토큰이 없습니다. (푸시 전송 Skip)", targetUserId); + log.warn("알림 대상 사용자 ID: {}에 유효한 FCM 토큰이 없습니다.", targetUserId); } return tokens; } @@ -202,7 +197,7 @@ private List getTokens(Long targetUserId) { private List getMulticastTokens(List userIds) { List tokens = fcmTokenService.getTokensByUserIds(userIds); if (tokens.isEmpty()) { - log.warn("알림 대상 사용자 목록(IDs: {})에 유효한 FCM 토큰이 없습니다. (푸시 전송 Skip)", userIds); + log.warn("알림 대상 사용자 목록(IDs: {})에 유효한 FCM 토큰이 없습니다.", userIds); } return 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 index 2e0a72a..d232065 100644 --- a/src/main/java/ita/tinybite/domain/notification/service/facade/NotificationFacade.java +++ b/src/main/java/ita/tinybite/domain/notification/service/facade/NotificationFacade.java @@ -72,4 +72,10 @@ public void notifyNewChatMessage( 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 index 163c8b7..3d73162 100644 --- a/src/main/java/ita/tinybite/domain/notification/service/manager/ChatMessageManager.java +++ b/src/main/java/ita/tinybite/domain/notification/service/manager/ChatMessageManager.java @@ -16,17 +16,28 @@ 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 senderName, String content) { + List tokens, Long chatRoomId, String title, String senderName, String content) { Map data = new HashMap<>(); - data.put("chatRoomId", String.valueOf(chatRoomId)); - data.put("eventType", NotificationType.CHAT_NEW_MESSAGE.name()); - data.put("senderName", senderName); + 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); - String title = "💬 " + 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 index fe7cd6e..3ca8e39 100644 --- a/src/main/java/ita/tinybite/domain/notification/service/manager/PartyMessageManager.java +++ b/src/main/java/ita/tinybite/domain/notification/service/manager/PartyMessageManager.java @@ -20,89 +20,80 @@ public class PartyMessageManager { private static final String KEY_PARTY_ID = "partyId"; private static final String KEY_EVENT_TYPE = "eventType"; - public NotificationMulticastRequest createApprovalRequest(List tokens, Long partyId, String detail) { + 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()); - String title = "🎉 파티 참여 승인"; return requestConverter.toMulticastRequest(tokens, title, detail, data); } - public NotificationMulticastRequest createRejectionRequest(List tokens, Long partyId, String detail) { + 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()); - String title = "파티 참여 거절"; return requestConverter.toMulticastRequest(tokens, title, detail, data); } - public NotificationMulticastRequest createAutoCloseRequest(List tokens, Long partyId, String detail) { + 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()); - String title = "🚨 파티 자동 마감"; return requestConverter.toMulticastRequest(tokens, title, detail, data); } - public NotificationMulticastRequest createOrderCompleteRequest(List tokens, Long partyId, String detail) { + 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()); - String title = "✅ 상품 주문 완료"; return requestConverter.toMulticastRequest(tokens, title, detail, data); } - public NotificationMulticastRequest createDeliveryReminderRequest(List memberTokens, Long partyId, String memberDetail) { + 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()); - String title = "⏰ 수령 준비 알림"; return requestConverter.toMulticastRequest(memberTokens, title, memberDetail, data); } - public NotificationMulticastRequest createManagerDeliveryReminderRequest(List managerTokens, Long partyId, String managerDetail) { + 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()); - String title = "📍 수령 장소 이동 알림"; return requestConverter.toMulticastRequest(managerTokens, title, managerDetail, data); } - public NotificationMulticastRequest createPartyCompleteRequest(List tokens, Long partyId, String detail) { + 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()); - String title = "👋 파티 종료"; return requestConverter.toMulticastRequest(tokens, title, detail, data); } - public NotificationMulticastRequest createNewPartyRequest(List tokens, Long partyId, String detail) { + 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()); - String title = "🔔 새 참여 요청"; return requestConverter.toMulticastRequest(tokens, title, detail, data); } - public NotificationMulticastRequest createMemberLeaveRequest(List tokens, Long partyId, String detail) { + 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()); - String title = "⚠️ 파티원 이탈"; return requestConverter.toMulticastRequest(tokens, title, detail, data); } } From f5e46061f2c78144e3bc6e8d28e07c5ce6bafe55 Mon Sep 17 00:00:00 2001 From: marshmallowing Date: Thu, 11 Dec 2025 13:56:17 +0900 Subject: [PATCH 15/20] =?UTF-8?q?chore:=20=ED=97=AC=ED=8D=BC=20=EB=A9=94?= =?UTF-8?q?=EC=84=9C=EB=93=9C=20=EC=A4=91=EB=B3=B5=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/ChatNotificationService.java | 12 +----- .../notification/service/FcmTokenService.java | 20 ++++++++++ .../service/PartyNotificationService.java | 37 +++++-------------- 3 files changed, 31 insertions(+), 38 deletions(-) diff --git a/src/main/java/ita/tinybite/domain/notification/service/ChatNotificationService.java b/src/main/java/ita/tinybite/domain/notification/service/ChatNotificationService.java index baac590..cb1ac44 100644 --- a/src/main/java/ita/tinybite/domain/notification/service/ChatNotificationService.java +++ b/src/main/java/ita/tinybite/domain/notification/service/ChatNotificationService.java @@ -30,7 +30,7 @@ public void sendNewChatMessage( ) { String title = "💬 " + senderName + "님의 새 메시지"; notificationLogService.saveLog(targetUserId, NotificationType.CHAT_NEW_MESSAGE.name(), title, messageContent); - List tokens = getTokens(targetUserId); + List tokens = fcmTokenService.getTokensAndLogIfEmpty(targetUserId); if (tokens.isEmpty()) { return; } @@ -46,7 +46,7 @@ public void sendUnreadReminderNotification(Long targetUserId, Long chatRoomId) { String detail = "안 읽은 메시지가 있어요! 지금 확인해 보세요."; notificationLogService.saveLog(targetUserId, NotificationType.CHAT_UNREAD_REMINDER.name(), title, detail); - List tokens = getTokens(targetUserId); + List tokens = fcmTokenService.getTokensAndLogIfEmpty(targetUserId); if (tokens.isEmpty()) { return; } @@ -55,12 +55,4 @@ public void sendUnreadReminderNotification(Long targetUserId, Long chatRoomId) { chatMessageManager.createUnreadReminderRequest(tokens, chatRoomId, title, detail); notificationSender.send(request); } - - private List getTokens(Long targetUserId) { - List tokens = fcmTokenService.getTokensByUserId(targetUserId); - if (tokens.isEmpty()) { - log.warn("알림 대상 사용자 ID: {}에 유효한 FCM 토큰이 없습니다. (푸시 전송 Skip)", targetUserId); - } - return 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 index 3444018..8e35ed7 100644 --- a/src/main/java/ita/tinybite/domain/notification/service/FcmTokenService.java +++ b/src/main/java/ita/tinybite/domain/notification/service/FcmTokenService.java @@ -10,10 +10,12 @@ 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; @@ -49,4 +51,22 @@ public List getTokensByUserIds(List userIds) { .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; + } } diff --git a/src/main/java/ita/tinybite/domain/notification/service/PartyNotificationService.java b/src/main/java/ita/tinybite/domain/notification/service/PartyNotificationService.java index 0c3de95..f452daa 100644 --- a/src/main/java/ita/tinybite/domain/notification/service/PartyNotificationService.java +++ b/src/main/java/ita/tinybite/domain/notification/service/PartyNotificationService.java @@ -27,7 +27,7 @@ public void sendApprovalNotification(Long targetUserId, Long partyId) { String detail = "파티 참여가 승인되었습니다! 지금 확인하세요."; notificationLogService.saveLog(targetUserId, NotificationType.PARTY_APPROVAL.name(), title, detail); - List tokens = getTokens(targetUserId); + List tokens = fcmTokenService.getTokensAndLogIfEmpty(targetUserId); if (tokens.isEmpty()) { return; } @@ -42,7 +42,7 @@ public void sendRejectionNotification(Long targetUserId, Long partyId) { String detail = "죄송합니다. 파티 참여가 거절되었습니다."; notificationLogService.saveLog(targetUserId, NotificationType.PARTY_REJECTION.name(), title, detail); - List tokens = getTokens(targetUserId); + List tokens = fcmTokenService.getTokensAndLogIfEmpty(targetUserId); if (tokens.isEmpty()) { return; } @@ -66,7 +66,7 @@ public void sendAutoCloseNotification(List memberIds, Long partyId, Long m notificationLogService.saveLog(userId, NotificationType.PARTY_AUTO_CLOSE.name(), title, detail); }); - List tokens = getMulticastTokens(memberIds); + List tokens = fcmTokenService.getMulticastTokensAndLogIfEmpty(memberIds); if (tokens.isEmpty()) { return; } @@ -84,7 +84,7 @@ public void sendOrderCompleteNotification(List memberIds, Long partyId) { notificationLogService.saveLog(userId, NotificationType.PARTY_ORDER_COMPLETE.name(), title, detail) ); - List tokens = getMulticastTokens(memberIds); + List tokens = fcmTokenService.getMulticastTokensAndLogIfEmpty(memberIds); if (tokens.isEmpty()) { return; } @@ -108,7 +108,7 @@ public void sendDeliveryReminderNotification(List memberIds, Long partyId, notificationLogService.saveLog(userId, NotificationType.PARTY_DELIVERY_REMINDER.name(), memberTitle, memberDetail) ); - List memberTokens = getMulticastTokens(commonMembers); + List memberTokens = fcmTokenService.getMulticastTokensAndLogIfEmpty(commonMembers); if (!memberTokens.isEmpty()) { NotificationMulticastRequest memberRequest = partyMessageManager.createDeliveryReminderRequest(memberTokens, partyId, memberTitle, memberDetail); @@ -122,7 +122,7 @@ public void sendDeliveryReminderNotification(List memberIds, Long partyId, notificationLogService.saveLog(managerId, NotificationType.PARTY_MANAGER_DELIVERY_REMINDER.name(), managerTitle, managerDetail); - List managerTokens = getTokens(managerId); + List managerTokens = fcmTokenService.getTokensAndLogIfEmpty(managerId); if (!managerTokens.isEmpty()) { NotificationMulticastRequest managerRequest = partyMessageManager.createManagerDeliveryReminderRequest(managerTokens, partyId, managerTitle, managerDetail); @@ -138,7 +138,7 @@ public void sendPartyCompleteNotification(List memberIds, Long partyId) { notificationLogService.saveLog(userId, NotificationType.PARTY_COMPLETE.name(), title, detail) ); - List tokens = getMulticastTokens(memberIds); + List tokens = fcmTokenService.getMulticastTokensAndLogIfEmpty(memberIds); if (tokens.isEmpty()) { return; } @@ -155,7 +155,7 @@ public void sendNewPartyRequestNotification(Long managerId, Long partyId) { notificationLogService.saveLog(managerId, NotificationType.PARTY_NEW_REQUEST.name(), title, detail); - List tokens = getTokens(managerId); + List tokens = fcmTokenService.getTokensAndLogIfEmpty(managerId); if (tokens.isEmpty()) { return; } @@ -173,7 +173,7 @@ public void sendMemberLeaveNotification(Long managerId, Long partyId, String lea notificationLogService.saveLog(managerId, NotificationType.PARTY_MEMBER_LEAVE.name(), title, detail); - List tokens = getTokens(managerId); + List tokens = fcmTokenService.getTokensAndLogIfEmpty(managerId); if (tokens.isEmpty()) { return; } @@ -182,23 +182,4 @@ public void sendMemberLeaveNotification(Long managerId, Long partyId, String lea partyMessageManager.createMemberLeaveRequest(tokens, partyId, title, detail); notificationSender.send(request); } - - - // 단일 사용자 토큰 조회 - private List getTokens(Long targetUserId) { - List tokens = fcmTokenService.getTokensByUserId(targetUserId); - if (tokens.isEmpty()) { - log.warn("알림 대상 사용자 ID: {}에 유효한 FCM 토큰이 없습니다.", targetUserId); - } - return tokens; - } - - // 다중 사용자 토큰 조회 - private List getMulticastTokens(List userIds) { - List tokens = fcmTokenService.getTokensByUserIds(userIds); - if (tokens.isEmpty()) { - log.warn("알림 대상 사용자 목록(IDs: {})에 유효한 FCM 토큰이 없습니다.", userIds); - } - return tokens; - } } From 13dddd2702ce76467da82807ec0881778797ff4f Mon Sep 17 00:00:00 2001 From: marshmallowing Date: Thu, 11 Dec 2025 14:29:38 +0900 Subject: [PATCH 16/20] =?UTF-8?q?feat:=20FCM=20=EC=9D=91=EB=8B=B5=20?= =?UTF-8?q?=EA=B8=B0=EB=B0=98=20=ED=86=A0=ED=81=B0=20=EB=B9=84=ED=99=9C?= =?UTF-8?q?=EC=84=B1=ED=99=94=20=EC=B2=98=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../repository/FcmTokenRepository.java | 8 +++ .../service/ChatNotificationService.java | 12 ++++- .../notification/service/FcmTokenService.java | 11 ++-- .../service/PartyNotificationService.java | 40 +++++++++++---- .../helper/NotificationTransactionHelper.java | 50 +++++++++++++++++++ 5 files changed, 107 insertions(+), 14 deletions(-) create mode 100644 src/main/java/ita/tinybite/domain/notification/service/helper/NotificationTransactionHelper.java diff --git a/src/main/java/ita/tinybite/domain/notification/repository/FcmTokenRepository.java b/src/main/java/ita/tinybite/domain/notification/repository/FcmTokenRepository.java index c541c5b..6904e94 100644 --- a/src/main/java/ita/tinybite/domain/notification/repository/FcmTokenRepository.java +++ b/src/main/java/ita/tinybite/domain/notification/repository/FcmTokenRepository.java @@ -4,6 +4,9 @@ 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; @@ -12,4 +15,9 @@ public interface FcmTokenRepository extends JpaRepository { Optional findByUserIdAndToken(Long userId, String token); List findAllByUserIdAndIsActiveTrue(Long userId); List findAllByUserIdInAndIsActiveTrue(List userIds); + + @Modifying // DML 실행 명시 + @Query("UPDATE FcmToken t SET t.isActive = :isActive WHERE t.token IN :tokens") + int updateIsActiveByTokenIn(@Param("tokens") List tokens, + @Param("isActive") Boolean isActive); } diff --git a/src/main/java/ita/tinybite/domain/notification/service/ChatNotificationService.java b/src/main/java/ita/tinybite/domain/notification/service/ChatNotificationService.java index cb1ac44..8132184 100644 --- a/src/main/java/ita/tinybite/domain/notification/service/ChatNotificationService.java +++ b/src/main/java/ita/tinybite/domain/notification/service/ChatNotificationService.java @@ -5,8 +5,11 @@ 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.service.helper.NotificationTransactionHelper; import ita.tinybite.domain.notification.service.manager.ChatMessageManager; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -20,6 +23,7 @@ public class ChatNotificationService { private final FcmTokenService fcmTokenService; private final ChatMessageManager chatMessageManager; private final NotificationLogService notificationLogService; + private final NotificationTransactionHelper notificationTransactionHelper; @Transactional public void sendNewChatMessage( @@ -37,7 +41,9 @@ public void sendNewChatMessage( NotificationMulticastRequest request = chatMessageManager.createNewChatMessageRequest(tokens, chatRoomId, title, senderName, messageContent); - notificationSender.send(request); + + BatchResponse response = notificationSender.send(request); + notificationTransactionHelper.handleBatchResponse(response, tokens); } @Transactional @@ -53,6 +59,8 @@ public void sendUnreadReminderNotification(Long targetUserId, Long chatRoomId) { NotificationMulticastRequest request = chatMessageManager.createUnreadReminderRequest(tokens, chatRoomId, title, detail); - notificationSender.send(request); + + BatchResponse response = notificationSender.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 index 8e35ed7..895d548 100644 --- a/src/main/java/ita/tinybite/domain/notification/service/FcmTokenService.java +++ b/src/main/java/ita/tinybite/domain/notification/service/FcmTokenService.java @@ -25,9 +25,7 @@ public void saveOrUpdateToken(Long userId, String token) { if (existingToken.isPresent()) { FcmToken fcmToken = existingToken.get(); - if (!Boolean.TRUE.equals(fcmToken.getIsActive())) { - fcmToken.updateToken(token); - } + fcmToken.updateToken(token); } else { FcmToken newToken = FcmToken.builder() .userId(userId) @@ -69,4 +67,11 @@ public List getMulticastTokensAndLogIfEmpty(List userIds) { // ( } return tokens; } + + @Transactional + public void deactivateTokens(List tokens) { + if (tokens.isEmpty()) return; + int updatedCount = fcmTokenRepository.updateIsActiveByTokenIn(tokens, Boolean.FALSE); + log.info("FCM 응답 기반 토큰 {}건 비활성화 완료.", updatedCount); + } } diff --git a/src/main/java/ita/tinybite/domain/notification/service/PartyNotificationService.java b/src/main/java/ita/tinybite/domain/notification/service/PartyNotificationService.java index f452daa..b19dfba 100644 --- a/src/main/java/ita/tinybite/domain/notification/service/PartyNotificationService.java +++ b/src/main/java/ita/tinybite/domain/notification/service/PartyNotificationService.java @@ -5,8 +5,11 @@ 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.service.helper.NotificationTransactionHelper; import ita.tinybite.domain.notification.service.manager.PartyMessageManager; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -20,6 +23,7 @@ public class PartyNotificationService { private final FcmTokenService fcmTokenService; private final PartyMessageManager partyMessageManager; private final NotificationLogService notificationLogService; + private final NotificationTransactionHelper notificationTransactionHelper; @Transactional public void sendApprovalNotification(Long targetUserId, Long partyId) { @@ -33,7 +37,9 @@ public void sendApprovalNotification(Long targetUserId, Long partyId) { } NotificationMulticastRequest request = partyMessageManager.createApprovalRequest(tokens, partyId, title, detail); - notificationSender.send(request); + + BatchResponse response = notificationSender.send(request); + notificationTransactionHelper.handleBatchResponse(response, tokens); } @Transactional @@ -48,7 +54,9 @@ public void sendRejectionNotification(Long targetUserId, Long partyId) { } NotificationMulticastRequest request = partyMessageManager.createRejectionRequest(tokens, partyId, title, detail); - notificationSender.send(request); + + BatchResponse response = notificationSender.send(request); + notificationTransactionHelper.handleBatchResponse(response, tokens); } /** @@ -73,7 +81,9 @@ public void sendAutoCloseNotification(List memberIds, Long partyId, Long m NotificationMulticastRequest request = partyMessageManager.createAutoCloseRequest(tokens, partyId, title, memberDetail); - notificationSender.send(request); + + BatchResponse response = notificationSender.send(request); + notificationTransactionHelper.handleBatchResponse(response, tokens); } @Transactional @@ -90,7 +100,9 @@ public void sendOrderCompleteNotification(List memberIds, Long partyId) { } NotificationMulticastRequest request = partyMessageManager.createOrderCompleteRequest(tokens, partyId, title, detail); - notificationSender.send(request); + + BatchResponse response = notificationSender.send(request); + notificationTransactionHelper.handleBatchResponse(response, tokens); } @Transactional @@ -112,7 +124,9 @@ public void sendDeliveryReminderNotification(List memberIds, Long partyId, if (!memberTokens.isEmpty()) { NotificationMulticastRequest memberRequest = partyMessageManager.createDeliveryReminderRequest(memberTokens, partyId, memberTitle, memberDetail); - notificationSender.send(memberRequest); + + BatchResponse memberResponse = notificationSender.send(memberRequest); + notificationTransactionHelper.handleBatchResponse(memberResponse, memberTokens); } } @@ -126,7 +140,9 @@ public void sendDeliveryReminderNotification(List memberIds, Long partyId, if (!managerTokens.isEmpty()) { NotificationMulticastRequest managerRequest = partyMessageManager.createManagerDeliveryReminderRequest(managerTokens, partyId, managerTitle, managerDetail); - notificationSender.send(managerRequest); + + BatchResponse managerResponse = notificationSender.send(managerRequest); + notificationTransactionHelper.handleBatchResponse(managerResponse, managerTokens); } } @@ -145,7 +161,9 @@ public void sendPartyCompleteNotification(List memberIds, Long partyId) { NotificationMulticastRequest request = partyMessageManager.createPartyCompleteRequest(tokens, partyId, title, detail); - notificationSender.send(request); + + BatchResponse response = notificationSender.send(request); + notificationTransactionHelper.handleBatchResponse(response, tokens); } @Transactional @@ -162,7 +180,9 @@ public void sendNewPartyRequestNotification(Long managerId, Long partyId) { NotificationMulticastRequest request = partyMessageManager.createNewPartyRequest(tokens, partyId, title, detail); - notificationSender.send(request); + + BatchResponse response = notificationSender.send(request); + notificationTransactionHelper.handleBatchResponse(response, tokens); } @@ -180,6 +200,8 @@ public void sendMemberLeaveNotification(Long managerId, Long partyId, String lea NotificationMulticastRequest request = partyMessageManager.createMemberLeaveRequest(tokens, partyId, title, detail); - notificationSender.send(request); + + BatchResponse response = notificationSender.send(request); + notificationTransactionHelper.handleBatchResponse(response, tokens); } } diff --git a/src/main/java/ita/tinybite/domain/notification/service/helper/NotificationTransactionHelper.java b/src/main/java/ita/tinybite/domain/notification/service/helper/NotificationTransactionHelper.java new file mode 100644 index 0000000..6205486 --- /dev/null +++ b/src/main/java/ita/tinybite/domain/notification/service/helper/NotificationTransactionHelper.java @@ -0,0 +1,50 @@ +package ita.tinybite.domain.notification.service.helper; + +import com.google.firebase.messaging.BatchResponse; +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()) { + String errorCode = sendResponse.getException().getMessagingErrorCode().name(); + + // UNREGISTERED (토큰 만료), INVALID_ARGUMENT (토큰 형식 오류) + if (errorCode.equals("UNREGISTERED") || errorCode.equals("INVALID_ARGUMENT")) { + unregisteredTokens.add(allTokens.get(i)); + } + } + } + return unregisteredTokens; + } +} \ No newline at end of file From c9d1c76b103e85550189dcf997bcca17e7831eab Mon Sep 17 00:00:00 2001 From: marshmallowing Date: Thu, 11 Dec 2025 14:37:04 +0900 Subject: [PATCH 17/20] =?UTF-8?q?feat:=20=EB=B9=84=ED=99=9C=EC=84=B1?= =?UTF-8?q?=ED=86=A0=ED=81=B0=20=EC=8A=A4=EC=BC=80=EC=A4=84=EB=9F=AC=20?= =?UTF-8?q?=EC=82=AD=EC=A0=9C=20=EC=B2=98=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ita/tinybite/TinyBiteApplication.java | 2 ++ .../repository/FcmTokenRepository.java | 7 ++++- .../notification/service/FcmTokenService.java | 10 +++++++ .../scheduler/FcmTokenSchedulerService.java | 27 +++++++++++++++++++ 4 files changed, 45 insertions(+), 1 deletion(-) create mode 100644 src/main/java/ita/tinybite/domain/notification/service/scheduler/FcmTokenSchedulerService.java 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/repository/FcmTokenRepository.java b/src/main/java/ita/tinybite/domain/notification/repository/FcmTokenRepository.java index 6904e94..f72cc52 100644 --- a/src/main/java/ita/tinybite/domain/notification/repository/FcmTokenRepository.java +++ b/src/main/java/ita/tinybite/domain/notification/repository/FcmTokenRepository.java @@ -1,5 +1,6 @@ package ita.tinybite.domain.notification.repository; +import java.time.LocalDateTime; import java.util.List; import java.util.Optional; @@ -16,8 +17,12 @@ public interface FcmTokenRepository extends JpaRepository { List findAllByUserIdAndIsActiveTrue(Long userId); List findAllByUserIdInAndIsActiveTrue(List userIds); - @Modifying // DML 실행 명시 + @Modifying @Query("UPDATE FcmToken t SET t.isActive = :isActive 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/service/FcmTokenService.java b/src/main/java/ita/tinybite/domain/notification/service/FcmTokenService.java index 895d548..6de7de5 100644 --- a/src/main/java/ita/tinybite/domain/notification/service/FcmTokenService.java +++ b/src/main/java/ita/tinybite/domain/notification/service/FcmTokenService.java @@ -1,5 +1,9 @@ package ita.tinybite.domain.notification.service; +import static kotlin.reflect.jvm.internal.impl.builtins.StandardNames.FqNames.*; + +import java.time.LocalDateTime; +import java.time.temporal.ChronoUnit; import java.util.List; import java.util.Optional; import java.util.stream.Collectors; @@ -74,4 +78,10 @@ public void deactivateTokens(List tokens) { 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/scheduler/FcmTokenSchedulerService.java b/src/main/java/ita/tinybite/domain/notification/service/scheduler/FcmTokenSchedulerService.java new file mode 100644 index 0000000..1184d6f --- /dev/null +++ b/src/main/java/ita/tinybite/domain/notification/service/scheduler/FcmTokenSchedulerService.java @@ -0,0 +1,27 @@ +package ita.tinybite.domain.notification.service.scheduler; // 별도의 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 FcmTokenSchedulerService { + + 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 From 6438f78aadfa986308e0004ae2fef8ea6ea977c5 Mon Sep 17 00:00:00 2001 From: marshmallowing Date: Thu, 11 Dec 2025 15:04:19 +0900 Subject: [PATCH 18/20] =?UTF-8?q?chore:=20=ED=8C=A8=ED=82=A4=EC=A7=80=20?= =?UTF-8?q?=EA=B5=AC=EC=A1=B0=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../creator/APNsConfigCreator.java | 2 +- .../fcm/FcmNotificationSender.java} | 8 +++---- .../fcm/FcmTokenScheduler.java} | 4 ++-- .../helper/NotificationTransactionHelper.java | 2 +- .../service/ChatNotificationService.java | 16 +++++++++---- .../notification/service/FcmTokenService.java | 2 -- .../service/PartyNotificationService.java | 23 ++++++++++--------- 7 files changed, 31 insertions(+), 26 deletions(-) rename src/main/java/ita/tinybite/domain/notification/{service => infra}/creator/APNsConfigCreator.java (92%) rename src/main/java/ita/tinybite/domain/notification/{service/NotificationSender.java => infra/fcm/FcmNotificationSender.java} (87%) rename src/main/java/ita/tinybite/domain/notification/{service/scheduler/FcmTokenSchedulerService.java => infra/fcm/FcmTokenScheduler.java} (85%) rename src/main/java/ita/tinybite/domain/notification/{service => infra}/helper/NotificationTransactionHelper.java (96%) diff --git a/src/main/java/ita/tinybite/domain/notification/service/creator/APNsConfigCreator.java b/src/main/java/ita/tinybite/domain/notification/infra/creator/APNsConfigCreator.java similarity index 92% rename from src/main/java/ita/tinybite/domain/notification/service/creator/APNsConfigCreator.java rename to src/main/java/ita/tinybite/domain/notification/infra/creator/APNsConfigCreator.java index fde2389..22725bc 100644 --- a/src/main/java/ita/tinybite/domain/notification/service/creator/APNsConfigCreator.java +++ b/src/main/java/ita/tinybite/domain/notification/infra/creator/APNsConfigCreator.java @@ -1,4 +1,4 @@ -package ita.tinybite.domain.notification.service.creator; +package ita.tinybite.domain.notification.infra.creator; import com.google.firebase.messaging.ApnsConfig; import com.google.firebase.messaging.Aps; diff --git a/src/main/java/ita/tinybite/domain/notification/service/NotificationSender.java b/src/main/java/ita/tinybite/domain/notification/infra/fcm/FcmNotificationSender.java similarity index 87% rename from src/main/java/ita/tinybite/domain/notification/service/NotificationSender.java rename to src/main/java/ita/tinybite/domain/notification/infra/fcm/FcmNotificationSender.java index dcc6a81..cc1377a 100644 --- a/src/main/java/ita/tinybite/domain/notification/service/NotificationSender.java +++ b/src/main/java/ita/tinybite/domain/notification/infra/fcm/FcmNotificationSender.java @@ -1,4 +1,4 @@ -package ita.tinybite.domain.notification.service; +package ita.tinybite.domain.notification.infra.fcm; import org.springframework.stereotype.Service; @@ -10,18 +10,16 @@ import ita.tinybite.domain.notification.dto.request.NotificationMulticastRequest; import ita.tinybite.domain.notification.dto.request.NotificationSingleRequest; -import ita.tinybite.domain.notification.service.creator.APNsConfigCreator; +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; -//토큰 목록과 Message 객체를 받아 FCM 서버로 전송 -// BatchResponse를 받아 실패 토큰을 비활성화하는 후처리 로직 추가 @Service @RequiredArgsConstructor @Slf4j -public class NotificationSender { +public class FcmNotificationSender { private final FirebaseMessaging firebaseMessaging; diff --git a/src/main/java/ita/tinybite/domain/notification/service/scheduler/FcmTokenSchedulerService.java b/src/main/java/ita/tinybite/domain/notification/infra/fcm/FcmTokenScheduler.java similarity index 85% rename from src/main/java/ita/tinybite/domain/notification/service/scheduler/FcmTokenSchedulerService.java rename to src/main/java/ita/tinybite/domain/notification/infra/fcm/FcmTokenScheduler.java index 1184d6f..8185086 100644 --- a/src/main/java/ita/tinybite/domain/notification/service/scheduler/FcmTokenSchedulerService.java +++ b/src/main/java/ita/tinybite/domain/notification/infra/fcm/FcmTokenScheduler.java @@ -1,4 +1,4 @@ -package ita.tinybite.domain.notification.service.scheduler; // 별도의 scheduler 패키지 권장 +package ita.tinybite.domain.notification.infra.fcm; // 별도의 scheduler 패키지 권장 import ita.tinybite.domain.notification.service.FcmTokenService; import lombok.RequiredArgsConstructor; @@ -11,7 +11,7 @@ @Component @RequiredArgsConstructor @Slf4j -public class FcmTokenSchedulerService { +public class FcmTokenScheduler { private final FcmTokenService fcmTokenService; diff --git a/src/main/java/ita/tinybite/domain/notification/service/helper/NotificationTransactionHelper.java b/src/main/java/ita/tinybite/domain/notification/infra/helper/NotificationTransactionHelper.java similarity index 96% rename from src/main/java/ita/tinybite/domain/notification/service/helper/NotificationTransactionHelper.java rename to src/main/java/ita/tinybite/domain/notification/infra/helper/NotificationTransactionHelper.java index 6205486..84eec01 100644 --- a/src/main/java/ita/tinybite/domain/notification/service/helper/NotificationTransactionHelper.java +++ b/src/main/java/ita/tinybite/domain/notification/infra/helper/NotificationTransactionHelper.java @@ -1,4 +1,4 @@ -package ita.tinybite.domain.notification.service.helper; +package ita.tinybite.domain.notification.infra.helper; import com.google.firebase.messaging.BatchResponse; import com.google.firebase.messaging.SendResponse; diff --git a/src/main/java/ita/tinybite/domain/notification/service/ChatNotificationService.java b/src/main/java/ita/tinybite/domain/notification/service/ChatNotificationService.java index 8132184..eea149b 100644 --- a/src/main/java/ita/tinybite/domain/notification/service/ChatNotificationService.java +++ b/src/main/java/ita/tinybite/domain/notification/service/ChatNotificationService.java @@ -9,7 +9,8 @@ import ita.tinybite.domain.notification.dto.request.NotificationMulticastRequest; import ita.tinybite.domain.notification.enums.NotificationType; -import ita.tinybite.domain.notification.service.helper.NotificationTransactionHelper; +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; @@ -19,7 +20,7 @@ @Slf4j public class ChatNotificationService { - private final NotificationSender notificationSender; + private final FcmNotificationSender fcmNotificationSender; private final FcmTokenService fcmTokenService; private final ChatMessageManager chatMessageManager; private final NotificationLogService notificationLogService; @@ -34,6 +35,13 @@ public void sendNewChatMessage( ) { 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; @@ -42,7 +50,7 @@ public void sendNewChatMessage( NotificationMulticastRequest request = chatMessageManager.createNewChatMessageRequest(tokens, chatRoomId, title, senderName, messageContent); - BatchResponse response = notificationSender.send(request); + BatchResponse response = fcmNotificationSender.send(request); notificationTransactionHelper.handleBatchResponse(response, tokens); } @@ -60,7 +68,7 @@ public void sendUnreadReminderNotification(Long targetUserId, Long chatRoomId) { NotificationMulticastRequest request = chatMessageManager.createUnreadReminderRequest(tokens, chatRoomId, title, detail); - BatchResponse response = notificationSender.send(request); + 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 index 6de7de5..2e486d1 100644 --- a/src/main/java/ita/tinybite/domain/notification/service/FcmTokenService.java +++ b/src/main/java/ita/tinybite/domain/notification/service/FcmTokenService.java @@ -1,7 +1,5 @@ package ita.tinybite.domain.notification.service; -import static kotlin.reflect.jvm.internal.impl.builtins.StandardNames.FqNames.*; - import java.time.LocalDateTime; import java.time.temporal.ChronoUnit; import java.util.List; diff --git a/src/main/java/ita/tinybite/domain/notification/service/PartyNotificationService.java b/src/main/java/ita/tinybite/domain/notification/service/PartyNotificationService.java index b19dfba..f28c2cb 100644 --- a/src/main/java/ita/tinybite/domain/notification/service/PartyNotificationService.java +++ b/src/main/java/ita/tinybite/domain/notification/service/PartyNotificationService.java @@ -9,7 +9,8 @@ import ita.tinybite.domain.notification.dto.request.NotificationMulticastRequest; import ita.tinybite.domain.notification.enums.NotificationType; -import ita.tinybite.domain.notification.service.helper.NotificationTransactionHelper; +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; @@ -19,7 +20,7 @@ @Slf4j public class PartyNotificationService { - private final NotificationSender notificationSender; + private final FcmNotificationSender fcmNotificationSender; private final FcmTokenService fcmTokenService; private final PartyMessageManager partyMessageManager; private final NotificationLogService notificationLogService; @@ -38,7 +39,7 @@ public void sendApprovalNotification(Long targetUserId, Long partyId) { NotificationMulticastRequest request = partyMessageManager.createApprovalRequest(tokens, partyId, title, detail); - BatchResponse response = notificationSender.send(request); + BatchResponse response = fcmNotificationSender.send(request); notificationTransactionHelper.handleBatchResponse(response, tokens); } @@ -55,7 +56,7 @@ public void sendRejectionNotification(Long targetUserId, Long partyId) { NotificationMulticastRequest request = partyMessageManager.createRejectionRequest(tokens, partyId, title, detail); - BatchResponse response = notificationSender.send(request); + BatchResponse response = fcmNotificationSender.send(request); notificationTransactionHelper.handleBatchResponse(response, tokens); } @@ -82,7 +83,7 @@ public void sendAutoCloseNotification(List memberIds, Long partyId, Long m NotificationMulticastRequest request = partyMessageManager.createAutoCloseRequest(tokens, partyId, title, memberDetail); - BatchResponse response = notificationSender.send(request); + BatchResponse response = fcmNotificationSender.send(request); notificationTransactionHelper.handleBatchResponse(response, tokens); } @@ -101,7 +102,7 @@ public void sendOrderCompleteNotification(List memberIds, Long partyId) { NotificationMulticastRequest request = partyMessageManager.createOrderCompleteRequest(tokens, partyId, title, detail); - BatchResponse response = notificationSender.send(request); + BatchResponse response = fcmNotificationSender.send(request); notificationTransactionHelper.handleBatchResponse(response, tokens); } @@ -125,7 +126,7 @@ public void sendDeliveryReminderNotification(List memberIds, Long partyId, NotificationMulticastRequest memberRequest = partyMessageManager.createDeliveryReminderRequest(memberTokens, partyId, memberTitle, memberDetail); - BatchResponse memberResponse = notificationSender.send(memberRequest); + BatchResponse memberResponse = fcmNotificationSender.send(memberRequest); notificationTransactionHelper.handleBatchResponse(memberResponse, memberTokens); } } @@ -141,7 +142,7 @@ public void sendDeliveryReminderNotification(List memberIds, Long partyId, NotificationMulticastRequest managerRequest = partyMessageManager.createManagerDeliveryReminderRequest(managerTokens, partyId, managerTitle, managerDetail); - BatchResponse managerResponse = notificationSender.send(managerRequest); + BatchResponse managerResponse = fcmNotificationSender.send(managerRequest); notificationTransactionHelper.handleBatchResponse(managerResponse, managerTokens); } } @@ -162,7 +163,7 @@ public void sendPartyCompleteNotification(List memberIds, Long partyId) { NotificationMulticastRequest request = partyMessageManager.createPartyCompleteRequest(tokens, partyId, title, detail); - BatchResponse response = notificationSender.send(request); + BatchResponse response = fcmNotificationSender.send(request); notificationTransactionHelper.handleBatchResponse(response, tokens); } @@ -181,7 +182,7 @@ public void sendNewPartyRequestNotification(Long managerId, Long partyId) { NotificationMulticastRequest request = partyMessageManager.createNewPartyRequest(tokens, partyId, title, detail); - BatchResponse response = notificationSender.send(request); + BatchResponse response = fcmNotificationSender.send(request); notificationTransactionHelper.handleBatchResponse(response, tokens); } @@ -201,7 +202,7 @@ public void sendMemberLeaveNotification(Long managerId, Long partyId, String lea NotificationMulticastRequest request = partyMessageManager.createMemberLeaveRequest(tokens, partyId, title, detail); - BatchResponse response = notificationSender.send(request); + BatchResponse response = fcmNotificationSender.send(request); notificationTransactionHelper.handleBatchResponse(response, tokens); } } From ddb69057eb813acf0cad159fd10259eede0a9f1f Mon Sep 17 00:00:00 2001 From: marshmallowing Date: Thu, 11 Dec 2025 15:58:12 +0900 Subject: [PATCH 19/20] =?UTF-8?q?chore:=20fcm=20=ED=99=98=EA=B2=BD=20?= =?UTF-8?q?=EC=84=A4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 3 +++ .../domain/notification/infra/fcm/FcmNotificationSender.java | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) 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/domain/notification/infra/fcm/FcmNotificationSender.java b/src/main/java/ita/tinybite/domain/notification/infra/fcm/FcmNotificationSender.java index cc1377a..fd68b4c 100644 --- a/src/main/java/ita/tinybite/domain/notification/infra/fcm/FcmNotificationSender.java +++ b/src/main/java/ita/tinybite/domain/notification/infra/fcm/FcmNotificationSender.java @@ -51,7 +51,7 @@ public BatchResponse send(final NotificationMulticastRequest request) { .setApnsConfig(APNsConfigCreator.createDefaultConfig()) .build(); - BatchResponse response = firebaseMessaging.sendEachForMulticast(message); + BatchResponse response = firebaseMessaging.sendMulticast(message); log.info("멀티캐스트 전송 완료. 성공: {}, 실패: {}", response.getSuccessCount(), response.getFailureCount()); return response; From 3d75048d6611dd38490bd89743918aecb305e024 Mon Sep 17 00:00:00 2001 From: marshmallowing Date: Thu, 11 Dec 2025 16:06:33 +0900 Subject: [PATCH 20/20] =?UTF-8?q?fix:=20=EB=A6=AC=EB=B7=B0=20=EB=B0=98?= =?UTF-8?q?=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../infra/helper/NotificationTransactionHelper.java | 13 +++++++++---- .../notification/repository/FcmTokenRepository.java | 8 +++++--- .../java/ita/tinybite/global/config/FcmConfig.java | 3 ++- src/main/resources/application.yaml | 2 +- 4 files changed, 17 insertions(+), 9 deletions(-) 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 index 84eec01..22f7b1a 100644 --- a/src/main/java/ita/tinybite/domain/notification/infra/helper/NotificationTransactionHelper.java +++ b/src/main/java/ita/tinybite/domain/notification/infra/helper/NotificationTransactionHelper.java @@ -1,6 +1,7 @@ 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; @@ -37,11 +38,15 @@ private List extractUnregisteredTokens(BatchResponse response, List { List findAllByUserIdInAndIsActiveTrue(List userIds); @Modifying - @Query("UPDATE FcmToken t SET t.isActive = :isActive WHERE t.token IN :tokens") - int updateIsActiveByTokenIn(@Param("tokens") List tokens, - @Param("isActive") Boolean isActive); + @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") diff --git a/src/main/java/ita/tinybite/global/config/FcmConfig.java b/src/main/java/ita/tinybite/global/config/FcmConfig.java index 547203b..00f156e 100644 --- a/src/main/java/ita/tinybite/global/config/FcmConfig.java +++ b/src/main/java/ita/tinybite/global/config/FcmConfig.java @@ -42,7 +42,8 @@ public void initialize() { } } } catch (IOException e) { - log.error("Error initializing Firebase app", e); + log.error("Error initializing Firebase app: Firebase 설정 파일을 읽을 수 없습니다.", e); + throw new IllegalStateException("Firebase 초기화 실패: 설정 파일 오류", e); } } diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index 331366b..5601d5b 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -22,7 +22,7 @@ sms: api-secret: ${SMS_API_SECRET} fcm: - file_path: firebase/smeem_fcm.json + 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