From 6591c61e680540acc11338fbfe7dd13cea1b69c3 Mon Sep 17 00:00:00 2001 From: dungbik Date: Fri, 15 Aug 2025 02:36:29 +0900 Subject: [PATCH 1/8] =?UTF-8?q?Feat:=20=EC=95=8C=EB=A6=BC=20=EC=83=9D?= =?UTF-8?q?=EC=84=B1=20=EB=B0=8F=20=EB=AA=A9=EB=A1=9D=20=EC=A1=B0=ED=9A=8C?= =?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 --- build.gradle | 2 + .../listener/UserWithdrawnEventListener.java | 2 +- .../auth/model/UserRegisterRequest.java | 2 +- .../flipnote/auth/service/AuthService.java | 4 +- .../common/entity/MapToJsonConverter.java | 42 +++++ .../exception/GlobalExceptionHandler.java | 2 +- .../event/GroupInvitationCreatedEvent.java | 7 + .../event/UserRegisteredEvent.java | 2 +- .../{ => model}/event/UserWithdrawnEvent.java | 2 +- .../model/request/CursorPageRequest.java | 23 +++ .../request}/UserCreateCommand.java | 2 +- .../{ => model}/response/ApiResponse.java | 2 +- .../response/ApiResponseAdvice.java | 2 +- .../model/response/CursorPageResponse.java | 21 +++ .../{ => model}/response/PageResponse.java | 2 +- .../security/config/SecurityConfig.java | 2 +- .../CustomAuthenticationEntryPoint.java | 2 +- .../filter/ExceptionHandlerFilter.java | 2 +- .../GroupInvitationQueryController.java | 3 +- .../GroupInvitationQueryControllerDocs.java | 2 +- .../listener/UserRegisteredEventListener.java | 4 +- .../group/model/GroupInvitationStatus.java | 16 +- .../IncomingGroupInvitationResponse.java | 2 +- .../OutgoingGroupInvitationResponse.java | 2 +- .../group/repository/GroupRepository.java | 2 + .../group/service/GroupInvitationService.java | 7 +- .../flipnote/group/service/GroupService.java | 5 + .../flipnote/infra/firebase/FcmErrorCode.java | 21 +++ .../infra/firebase/FirebaseService.java | 61 +++++++ .../controller/NotificationController.java | 50 ++++++ .../docs/NotificationControllerDocs.java | 24 +++ .../notification/entity/FcmToken.java | 56 +++++++ .../notification/entity/Notification.java | 68 ++++++++ .../notification/entity/NotificationType.java | 12 ++ .../exception/NotificationErrorCode.java | 23 +++ .../GroupInvitationCreateEventListener.java | 37 +++++ .../model/NotificationListRequest.java | 6 + .../model/NotificationResponse.java | 33 ++++ .../model/TokenRegisterRequest.java | 9 + .../repository/FcmTokenRepository.java | 25 +++ .../repository/NotificationRepository.java | 20 +++ .../service/NotificationService.java | 154 ++++++++++++++++++ .../flipnote/user/service/UserService.java | 4 +- src/main/resources/application.yml | 3 + src/main/resources/messages.properties | 1 + 45 files changed, 736 insertions(+), 37 deletions(-) create mode 100644 src/main/java/project/flipnote/common/entity/MapToJsonConverter.java create mode 100644 src/main/java/project/flipnote/common/model/event/GroupInvitationCreatedEvent.java rename src/main/java/project/flipnote/common/{ => model}/event/UserRegisteredEvent.java (55%) rename src/main/java/project/flipnote/common/{ => model}/event/UserWithdrawnEvent.java (54%) create mode 100644 src/main/java/project/flipnote/common/model/request/CursorPageRequest.java rename src/main/java/project/flipnote/common/{dto => model/request}/UserCreateCommand.java (75%) rename src/main/java/project/flipnote/common/{ => model}/response/ApiResponse.java (97%) rename src/main/java/project/flipnote/common/{ => model}/response/ApiResponseAdvice.java (96%) create mode 100644 src/main/java/project/flipnote/common/model/response/CursorPageResponse.java rename src/main/java/project/flipnote/common/{ => model}/response/PageResponse.java (91%) create mode 100644 src/main/java/project/flipnote/infra/firebase/FcmErrorCode.java create mode 100644 src/main/java/project/flipnote/infra/firebase/FirebaseService.java create mode 100644 src/main/java/project/flipnote/notification/controller/NotificationController.java create mode 100644 src/main/java/project/flipnote/notification/controller/docs/NotificationControllerDocs.java create mode 100644 src/main/java/project/flipnote/notification/entity/FcmToken.java create mode 100644 src/main/java/project/flipnote/notification/entity/Notification.java create mode 100644 src/main/java/project/flipnote/notification/entity/NotificationType.java create mode 100644 src/main/java/project/flipnote/notification/exception/NotificationErrorCode.java create mode 100644 src/main/java/project/flipnote/notification/listener/GroupInvitationCreateEventListener.java create mode 100644 src/main/java/project/flipnote/notification/model/NotificationListRequest.java create mode 100644 src/main/java/project/flipnote/notification/model/NotificationResponse.java create mode 100644 src/main/java/project/flipnote/notification/model/TokenRegisterRequest.java create mode 100644 src/main/java/project/flipnote/notification/repository/FcmTokenRepository.java create mode 100644 src/main/java/project/flipnote/notification/repository/NotificationRepository.java create mode 100644 src/main/java/project/flipnote/notification/service/NotificationService.java create mode 100644 src/main/resources/messages.properties diff --git a/build.gradle b/build.gradle index 3080bc99..b387035f 100644 --- a/build.gradle +++ b/build.gradle @@ -40,6 +40,8 @@ dependencies { implementation 'net.javacrumbs.shedlock:shedlock-spring:6.9.2' implementation 'net.javacrumbs.shedlock:shedlock-provider-redis-spring:6.9.2' implementation 'org.redisson:redisson-spring-boot-starter:3.46.0' + implementation 'org.apache.commons:commons-text:1.14.0' + implementation 'com.google.firebase:firebase-admin:9.5.0' compileOnly 'org.projectlombok:lombok' runtimeOnly 'com.mysql:mysql-connector-j' annotationProcessor 'org.projectlombok:lombok' diff --git a/src/main/java/project/flipnote/auth/listener/UserWithdrawnEventListener.java b/src/main/java/project/flipnote/auth/listener/UserWithdrawnEventListener.java index cb2dac6b..13860f66 100644 --- a/src/main/java/project/flipnote/auth/listener/UserWithdrawnEventListener.java +++ b/src/main/java/project/flipnote/auth/listener/UserWithdrawnEventListener.java @@ -11,7 +11,7 @@ import lombok.extern.slf4j.Slf4j; import project.flipnote.auth.entity.AccountStatus; import project.flipnote.auth.repository.UserAuthRepository; -import project.flipnote.common.event.UserWithdrawnEvent; +import project.flipnote.common.model.event.UserWithdrawnEvent; @Slf4j @RequiredArgsConstructor diff --git a/src/main/java/project/flipnote/auth/model/UserRegisterRequest.java b/src/main/java/project/flipnote/auth/model/UserRegisterRequest.java index 62f197c7..8ae978e1 100644 --- a/src/main/java/project/flipnote/auth/model/UserRegisterRequest.java +++ b/src/main/java/project/flipnote/auth/model/UserRegisterRequest.java @@ -3,7 +3,7 @@ import jakarta.validation.constraints.Email; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; -import project.flipnote.common.dto.UserCreateCommand; +import project.flipnote.common.model.request.UserCreateCommand; import project.flipnote.common.util.PhoneUtil; import project.flipnote.common.validation.annotation.ValidPassword; import project.flipnote.common.validation.annotation.ValidPhone; diff --git a/src/main/java/project/flipnote/auth/service/AuthService.java b/src/main/java/project/flipnote/auth/service/AuthService.java index 89b07e83..920393c2 100644 --- a/src/main/java/project/flipnote/auth/service/AuthService.java +++ b/src/main/java/project/flipnote/auth/service/AuthService.java @@ -34,8 +34,8 @@ import project.flipnote.auth.util.PasswordResetTokenGenerator; import project.flipnote.auth.util.VerificationCodeGenerator; import project.flipnote.common.config.ClientProperties; -import project.flipnote.common.dto.UserCreateCommand; -import project.flipnote.common.event.UserRegisteredEvent; +import project.flipnote.common.model.request.UserCreateCommand; +import project.flipnote.common.model.event.UserRegisteredEvent; import project.flipnote.common.exception.BizException; import project.flipnote.common.security.dto.AuthPrinciple; import project.flipnote.common.security.jwt.JwtComponent; diff --git a/src/main/java/project/flipnote/common/entity/MapToJsonConverter.java b/src/main/java/project/flipnote/common/entity/MapToJsonConverter.java new file mode 100644 index 00000000..f7c85d18 --- /dev/null +++ b/src/main/java/project/flipnote/common/entity/MapToJsonConverter.java @@ -0,0 +1,42 @@ +package project.flipnote.common.entity; + +import java.io.IOException; +import java.util.Collections; +import java.util.Map; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; + +import jakarta.persistence.AttributeConverter; +import jakarta.persistence.Converter; + +@Converter(autoApply = true) +public class MapToJsonConverter implements AttributeConverter, String> { + + private final ObjectMapper objectMapper = new ObjectMapper(); + + @Override + public String convertToDatabaseColumn(Map attribute) { + if (attribute == null || attribute.isEmpty()) { + return "{}"; + } + try { + return objectMapper.writeValueAsString(attribute); + } catch (JsonProcessingException ex) { + throw new IllegalArgumentException("JSON 변환 실패", ex); + } + } + + @Override + public Map convertToEntityAttribute(String dbData) { + if (dbData == null || dbData.isBlank()) { + return Collections.emptyMap(); + } + try { + return objectMapper.readValue(dbData, new TypeReference>() {}); + } catch (IOException ex) { + throw new IllegalArgumentException("JSON 파싱 실패", ex); + } + } +} \ No newline at end of file diff --git a/src/main/java/project/flipnote/common/exception/GlobalExceptionHandler.java b/src/main/java/project/flipnote/common/exception/GlobalExceptionHandler.java index 3400c61a..009b970e 100644 --- a/src/main/java/project/flipnote/common/exception/GlobalExceptionHandler.java +++ b/src/main/java/project/flipnote/common/exception/GlobalExceptionHandler.java @@ -9,7 +9,7 @@ import org.springframework.web.bind.annotation.RestControllerAdvice; import lombok.extern.slf4j.Slf4j; -import project.flipnote.common.response.ApiResponse; +import project.flipnote.common.model.response.ApiResponse; @Slf4j @RestControllerAdvice diff --git a/src/main/java/project/flipnote/common/model/event/GroupInvitationCreatedEvent.java b/src/main/java/project/flipnote/common/model/event/GroupInvitationCreatedEvent.java new file mode 100644 index 00000000..0fda8427 --- /dev/null +++ b/src/main/java/project/flipnote/common/model/event/GroupInvitationCreatedEvent.java @@ -0,0 +1,7 @@ +package project.flipnote.common.model.event; + +public record GroupInvitationCreatedEvent( + Long groupId, + Long inviteeId +) { +} diff --git a/src/main/java/project/flipnote/common/event/UserRegisteredEvent.java b/src/main/java/project/flipnote/common/model/event/UserRegisteredEvent.java similarity index 55% rename from src/main/java/project/flipnote/common/event/UserRegisteredEvent.java rename to src/main/java/project/flipnote/common/model/event/UserRegisteredEvent.java index efac2972..3ca1e498 100644 --- a/src/main/java/project/flipnote/common/event/UserRegisteredEvent.java +++ b/src/main/java/project/flipnote/common/model/event/UserRegisteredEvent.java @@ -1,4 +1,4 @@ -package project.flipnote.common.event; +package project.flipnote.common.model.event; public record UserRegisteredEvent( String email diff --git a/src/main/java/project/flipnote/common/event/UserWithdrawnEvent.java b/src/main/java/project/flipnote/common/model/event/UserWithdrawnEvent.java similarity index 54% rename from src/main/java/project/flipnote/common/event/UserWithdrawnEvent.java rename to src/main/java/project/flipnote/common/model/event/UserWithdrawnEvent.java index 8fdefd62..63102405 100644 --- a/src/main/java/project/flipnote/common/event/UserWithdrawnEvent.java +++ b/src/main/java/project/flipnote/common/model/event/UserWithdrawnEvent.java @@ -1,4 +1,4 @@ -package project.flipnote.common.event; +package project.flipnote.common.model.event; public record UserWithdrawnEvent( Long userId diff --git a/src/main/java/project/flipnote/common/model/request/CursorPageRequest.java b/src/main/java/project/flipnote/common/model/request/CursorPageRequest.java new file mode 100644 index 00000000..306fc3cd --- /dev/null +++ b/src/main/java/project/flipnote/common/model/request/CursorPageRequest.java @@ -0,0 +1,23 @@ +package project.flipnote.common.model.request; + +import org.springframework.util.StringUtils; + +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +public class CursorPageRequest { + + private String cursor; + + @Min(1) + @Max(30) + private Integer size = 10; + + public Long getCursorId() { + return StringUtils.hasText(cursor) ? Long.valueOf(cursor) : null; + } +} diff --git a/src/main/java/project/flipnote/common/dto/UserCreateCommand.java b/src/main/java/project/flipnote/common/model/request/UserCreateCommand.java similarity index 75% rename from src/main/java/project/flipnote/common/dto/UserCreateCommand.java rename to src/main/java/project/flipnote/common/model/request/UserCreateCommand.java index 67aac2aa..3ae209e4 100644 --- a/src/main/java/project/flipnote/common/dto/UserCreateCommand.java +++ b/src/main/java/project/flipnote/common/model/request/UserCreateCommand.java @@ -1,4 +1,4 @@ -package project.flipnote.common.dto; +package project.flipnote.common.model.request; public record UserCreateCommand( String email, diff --git a/src/main/java/project/flipnote/common/response/ApiResponse.java b/src/main/java/project/flipnote/common/model/response/ApiResponse.java similarity index 97% rename from src/main/java/project/flipnote/common/response/ApiResponse.java rename to src/main/java/project/flipnote/common/model/response/ApiResponse.java index d21efcac..3855bf70 100644 --- a/src/main/java/project/flipnote/common/response/ApiResponse.java +++ b/src/main/java/project/flipnote/common/model/response/ApiResponse.java @@ -1,4 +1,4 @@ -package project.flipnote.common.response; +package project.flipnote.common.model.response; import java.util.List; import java.util.stream.Collectors; diff --git a/src/main/java/project/flipnote/common/response/ApiResponseAdvice.java b/src/main/java/project/flipnote/common/model/response/ApiResponseAdvice.java similarity index 96% rename from src/main/java/project/flipnote/common/response/ApiResponseAdvice.java rename to src/main/java/project/flipnote/common/model/response/ApiResponseAdvice.java index 929f3df0..926b5c17 100644 --- a/src/main/java/project/flipnote/common/response/ApiResponseAdvice.java +++ b/src/main/java/project/flipnote/common/model/response/ApiResponseAdvice.java @@ -1,4 +1,4 @@ -package project.flipnote.common.response; +package project.flipnote.common.model.response; import org.springframework.core.MethodParameter; import org.springframework.http.MediaType; diff --git a/src/main/java/project/flipnote/common/model/response/CursorPageResponse.java b/src/main/java/project/flipnote/common/model/response/CursorPageResponse.java new file mode 100644 index 00000000..3f50df64 --- /dev/null +++ b/src/main/java/project/flipnote/common/model/response/CursorPageResponse.java @@ -0,0 +1,21 @@ +package project.flipnote.common.model.response; + +import java.util.List; +import java.util.Objects; + +public record CursorPageResponse( + List content, + boolean hasNext, + String nextCursor, + int size +) { + + public static CursorPageResponse of(List content, boolean hasNext, String nextCursor) { + return new CursorPageResponse<>(content, hasNext, hasNext ? nextCursor : null, content.size()); + } + + public static CursorPageResponse of(List content, boolean hasNext, Long nextCursorId) { + String nextCursor = Objects.toString(nextCursorId, null); + return of(content, hasNext, nextCursor); + } +} diff --git a/src/main/java/project/flipnote/common/response/PageResponse.java b/src/main/java/project/flipnote/common/model/response/PageResponse.java similarity index 91% rename from src/main/java/project/flipnote/common/response/PageResponse.java rename to src/main/java/project/flipnote/common/model/response/PageResponse.java index 5539602b..5f33e044 100644 --- a/src/main/java/project/flipnote/common/response/PageResponse.java +++ b/src/main/java/project/flipnote/common/model/response/PageResponse.java @@ -1,4 +1,4 @@ -package project.flipnote.common.response; +package project.flipnote.common.model.response; import java.util.List; diff --git a/src/main/java/project/flipnote/common/security/config/SecurityConfig.java b/src/main/java/project/flipnote/common/security/config/SecurityConfig.java index 2d2986a8..bd516e0d 100644 --- a/src/main/java/project/flipnote/common/security/config/SecurityConfig.java +++ b/src/main/java/project/flipnote/common/security/config/SecurityConfig.java @@ -24,7 +24,7 @@ import jakarta.servlet.http.HttpServletResponse; import lombok.RequiredArgsConstructor; -import project.flipnote.common.response.ApiResponse; +import project.flipnote.common.model.response.ApiResponse; import project.flipnote.common.security.exception.CustomAuthenticationEntryPoint; import project.flipnote.common.security.exception.SecurityErrorCode; import project.flipnote.common.security.filter.ExceptionHandlerFilter; diff --git a/src/main/java/project/flipnote/common/security/exception/CustomAuthenticationEntryPoint.java b/src/main/java/project/flipnote/common/security/exception/CustomAuthenticationEntryPoint.java index d57530d9..9dfd144c 100644 --- a/src/main/java/project/flipnote/common/security/exception/CustomAuthenticationEntryPoint.java +++ b/src/main/java/project/flipnote/common/security/exception/CustomAuthenticationEntryPoint.java @@ -13,7 +13,7 @@ import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import lombok.RequiredArgsConstructor; -import project.flipnote.common.response.ApiResponse; +import project.flipnote.common.model.response.ApiResponse; @Component @RequiredArgsConstructor diff --git a/src/main/java/project/flipnote/common/security/filter/ExceptionHandlerFilter.java b/src/main/java/project/flipnote/common/security/filter/ExceptionHandlerFilter.java index 7f36f7d6..997f865a 100644 --- a/src/main/java/project/flipnote/common/security/filter/ExceptionHandlerFilter.java +++ b/src/main/java/project/flipnote/common/security/filter/ExceptionHandlerFilter.java @@ -12,7 +12,7 @@ import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import lombok.extern.slf4j.Slf4j; -import project.flipnote.common.response.ApiResponse; +import project.flipnote.common.model.response.ApiResponse; import project.flipnote.common.security.exception.CustomSecurityException; @Slf4j diff --git a/src/main/java/project/flipnote/group/controller/GroupInvitationQueryController.java b/src/main/java/project/flipnote/group/controller/GroupInvitationQueryController.java index a9ebea9f..5f9da72f 100644 --- a/src/main/java/project/flipnote/group/controller/GroupInvitationQueryController.java +++ b/src/main/java/project/flipnote/group/controller/GroupInvitationQueryController.java @@ -8,9 +8,8 @@ import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; -import jakarta.validation.constraints.Min; import lombok.RequiredArgsConstructor; -import project.flipnote.common.response.PageResponse; +import project.flipnote.common.model.response.PageResponse; import project.flipnote.common.security.dto.AuthPrinciple; import project.flipnote.group.controller.docs.GroupInvitationQueryControllerDocs; import project.flipnote.group.model.IncomingGroupInvitationResponse; diff --git a/src/main/java/project/flipnote/group/controller/docs/GroupInvitationQueryControllerDocs.java b/src/main/java/project/flipnote/group/controller/docs/GroupInvitationQueryControllerDocs.java index b849c98c..b726b4f5 100644 --- a/src/main/java/project/flipnote/group/controller/docs/GroupInvitationQueryControllerDocs.java +++ b/src/main/java/project/flipnote/group/controller/docs/GroupInvitationQueryControllerDocs.java @@ -7,7 +7,7 @@ import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.constraints.Max; import jakarta.validation.constraints.Min; -import project.flipnote.common.response.PageResponse; +import project.flipnote.common.model.response.PageResponse; import project.flipnote.common.security.dto.AuthPrinciple; import project.flipnote.group.model.IncomingGroupInvitationResponse; import project.flipnote.group.model.OutgoingGroupInvitationResponse; diff --git a/src/main/java/project/flipnote/group/listener/UserRegisteredEventListener.java b/src/main/java/project/flipnote/group/listener/UserRegisteredEventListener.java index d17e5b15..a12c4899 100644 --- a/src/main/java/project/flipnote/group/listener/UserRegisteredEventListener.java +++ b/src/main/java/project/flipnote/group/listener/UserRegisteredEventListener.java @@ -10,7 +10,7 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import project.flipnote.common.event.UserRegisteredEvent; +import project.flipnote.common.model.event.UserRegisteredEvent; import project.flipnote.group.service.GroupInvitationService; @Slf4j @@ -26,7 +26,7 @@ public class UserRegisteredEventListener { backoff = @Backoff(delay = 2000, multiplier = 2) ) @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) - public void HandleUserRegisteredEvent(UserRegisteredEvent event) { + public void handleUserRegisteredEvent(UserRegisteredEvent event) { groupInvitationService.acceptPendingInvitationsOnRegister(event.email()); } diff --git a/src/main/java/project/flipnote/group/model/GroupInvitationStatus.java b/src/main/java/project/flipnote/group/model/GroupInvitationStatus.java index f2f3dbe2..2c97e991 100644 --- a/src/main/java/project/flipnote/group/model/GroupInvitationStatus.java +++ b/src/main/java/project/flipnote/group/model/GroupInvitationStatus.java @@ -1,19 +1,11 @@ package project.flipnote.group.model; +import project.flipnote.group.entity.GroupInvitation; + public enum GroupInvitationStatus { PENDING, ACCEPTED, REJECTED, EXPIRED; - public static GroupInvitationStatus from(project.flipnote.group.entity.GroupInvitationStatus status) { - if (status == null) { - throw new IllegalArgumentException("GroupInvitationStatus is null"); - } - - return switch (status) { - case PENDING -> PENDING; - case ACCEPTED -> ACCEPTED; - case REJECTED -> REJECTED; - case EXPIRED -> EXPIRED; - default -> throw new IllegalArgumentException("Unknown GroupInvitationStatus: " + status); - }; + public static GroupInvitationStatus from(GroupInvitation invitation) { + return GroupInvitationStatus.valueOf(invitation.getStatus().name()); } } diff --git a/src/main/java/project/flipnote/group/model/IncomingGroupInvitationResponse.java b/src/main/java/project/flipnote/group/model/IncomingGroupInvitationResponse.java index 703ae0c9..31edd86c 100644 --- a/src/main/java/project/flipnote/group/model/IncomingGroupInvitationResponse.java +++ b/src/main/java/project/flipnote/group/model/IncomingGroupInvitationResponse.java @@ -19,7 +19,7 @@ public static IncomingGroupInvitationResponse from(GroupInvitation invitation) { return new IncomingGroupInvitationResponse( invitation.getId(), invitation.getGroup().getId(), - GroupInvitationStatus.from(invitation.getStatus()), + GroupInvitationStatus.from(invitation), invitation.getCreatedAt() ); } diff --git a/src/main/java/project/flipnote/group/model/OutgoingGroupInvitationResponse.java b/src/main/java/project/flipnote/group/model/OutgoingGroupInvitationResponse.java index 43ee3eeb..8395106f 100644 --- a/src/main/java/project/flipnote/group/model/OutgoingGroupInvitationResponse.java +++ b/src/main/java/project/flipnote/group/model/OutgoingGroupInvitationResponse.java @@ -25,7 +25,7 @@ public static OutgoingGroupInvitationResponse from(GroupInvitation invitation, S invitation.getInviteeUserId(), invitation.getInviteeEmail(), inviteeNickname, - GroupInvitationStatus.from(invitation.getStatus()), + GroupInvitationStatus.from(invitation), invitation.getCreatedAt() ); } diff --git a/src/main/java/project/flipnote/group/repository/GroupRepository.java b/src/main/java/project/flipnote/group/repository/GroupRepository.java index 68ab6ff7..dc317cce 100644 --- a/src/main/java/project/flipnote/group/repository/GroupRepository.java +++ b/src/main/java/project/flipnote/group/repository/GroupRepository.java @@ -20,4 +20,6 @@ public interface GroupRepository extends JpaRepository { @Query("select g from Group g where g.id = :id") Optional findByIdForUpdate(@Param("id") Long id); + @Query("SELECT g.name FROM Group g WHERE g.id = :id") + Optional findGroupNameById(@Param("id") Long id); } diff --git a/src/main/java/project/flipnote/group/service/GroupInvitationService.java b/src/main/java/project/flipnote/group/service/GroupInvitationService.java index d0feafd2..d8e70385 100644 --- a/src/main/java/project/flipnote/group/service/GroupInvitationService.java +++ b/src/main/java/project/flipnote/group/service/GroupInvitationService.java @@ -4,6 +4,7 @@ import java.util.Map; import java.util.Objects; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; import org.springframework.stereotype.Service; @@ -11,7 +12,8 @@ import lombok.RequiredArgsConstructor; import project.flipnote.common.exception.BizException; -import project.flipnote.common.response.PageResponse; +import project.flipnote.common.model.event.GroupInvitationCreatedEvent; +import project.flipnote.common.model.response.PageResponse; import project.flipnote.common.security.dto.AuthPrinciple; import project.flipnote.group.entity.GroupInvitation; import project.flipnote.group.entity.GroupInvitationStatus; @@ -40,6 +42,7 @@ public class GroupInvitationService { private final GroupRepository groupRepository; private final GroupMemberRepository groupMemberRepository; private final GroupMemberPolicyService groupMemberPolicyService; + private final ApplicationEventPublisher eventPublisher; /** * 그룹에 회원 혹은 비회원 초대 @@ -248,7 +251,7 @@ private Long createUserInvitation(Long inviterUserId, Long groupId, UserProfile .build(); groupInvitationRepository.save(invitation); - // TODO: 초대받은 회원한테 알림 전송 + eventPublisher.publishEvent(new GroupInvitationCreatedEvent(groupId, inviteeUser.getId())); return invitation.getId(); } diff --git a/src/main/java/project/flipnote/group/service/GroupService.java b/src/main/java/project/flipnote/group/service/GroupService.java index 2990dbcd..c45638c4 100644 --- a/src/main/java/project/flipnote/group/service/GroupService.java +++ b/src/main/java/project/flipnote/group/service/GroupService.java @@ -189,4 +189,9 @@ public GroupDetailResponse findGroupDetail(AuthPrinciple authPrinciple, Long gro public void deleteGroup(AuthPrinciple authPrinciple, Long groupId) { } + + public String findGroupName(Long groupId) { + return groupRepository.findGroupNameById(groupId) + .orElseThrow(() -> new BizException(GroupErrorCode.GROUP_NOT_FOUND)); + } } diff --git a/src/main/java/project/flipnote/infra/firebase/FcmErrorCode.java b/src/main/java/project/flipnote/infra/firebase/FcmErrorCode.java new file mode 100644 index 00000000..ade6dd63 --- /dev/null +++ b/src/main/java/project/flipnote/infra/firebase/FcmErrorCode.java @@ -0,0 +1,21 @@ +package project.flipnote.infra.firebase; + +public enum FcmErrorCode { + INVALID_ARGUMENT, + UNREGISTERED, + SENDER_ID_MISMATCH, + QUOTA_EXCEEDED, + DEVICE_MESSAGE_RATE_EXCEEDED, + TOPIC_MESSAGE_RATE_EXCEEDED, + UNAVAILABLE, + INTERNAL, + UNKNOWN; + + public static FcmErrorCode from(String code) { + try { + return FcmErrorCode.valueOf(code); + } catch (Exception e) { + return UNKNOWN; + } + } +} \ No newline at end of file diff --git a/src/main/java/project/flipnote/infra/firebase/FirebaseService.java b/src/main/java/project/flipnote/infra/firebase/FirebaseService.java new file mode 100644 index 00000000..10580eda --- /dev/null +++ b/src/main/java/project/flipnote/infra/firebase/FirebaseService.java @@ -0,0 +1,61 @@ +package project.flipnote.infra.firebase; + +import java.io.FileInputStream; +import java.io.IOException; +import java.util.Arrays; +import java.util.List; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.core.env.Environment; +import org.springframework.stereotype.Service; + +import com.google.auth.oauth2.GoogleCredentials; +import com.google.firebase.FirebaseApp; +import com.google.firebase.FirebaseOptions; +import com.google.firebase.messaging.BatchResponse; +import com.google.firebase.messaging.FirebaseMessaging; +import com.google.firebase.messaging.FirebaseMessagingException; +import com.google.firebase.messaging.MulticastMessage; +import com.google.firebase.messaging.Notification; + +import jakarta.annotation.PostConstruct; +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +@Service +public class FirebaseService { + + @Value("${firebase.config-path}") + private String firebaseConfigPath; + + private final Environment environment; + + @PostConstruct + public void initialize() throws IOException { + if (Arrays.asList(environment.getActiveProfiles()).contains("test")) { + return; + } + + if (FirebaseApp.getApps().isEmpty()) { + try (FileInputStream serviceAccount = new FileInputStream(firebaseConfigPath)) { + FirebaseOptions options = FirebaseOptions.builder() + .setCredentials(GoogleCredentials.fromStream(serviceAccount)) + .build(); + FirebaseApp.initializeApp(options); + } + } + } + + public BatchResponse sendEachForMulticast(List tokens, String title, String body) throws FirebaseMessagingException { + Notification notification = Notification.builder() + .setTitle(title) + .setBody(body) + .build(); + MulticastMessage message = MulticastMessage.builder() + .addAllTokens(tokens) + .setNotification(notification) + .build(); + + return FirebaseMessaging.getInstance().sendEachForMulticast(message); + } +} diff --git a/src/main/java/project/flipnote/notification/controller/NotificationController.java b/src/main/java/project/flipnote/notification/controller/NotificationController.java new file mode 100644 index 00000000..72c4a00c --- /dev/null +++ b/src/main/java/project/flipnote/notification/controller/NotificationController.java @@ -0,0 +1,50 @@ +package project.flipnote.notification.controller; + +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.ModelAttribute; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import project.flipnote.common.model.response.CursorPageResponse; +import project.flipnote.common.security.dto.AuthPrinciple; +import project.flipnote.notification.controller.docs.NotificationControllerDocs; +import project.flipnote.notification.model.NotificationListRequest; +import project.flipnote.notification.model.NotificationResponse; +import project.flipnote.notification.model.TokenRegisterRequest; +import project.flipnote.notification.service.NotificationService; + +@RequiredArgsConstructor +@RestController +@RequestMapping("/v1/notifications") +public class NotificationController implements NotificationControllerDocs { + + private final NotificationService notificationService; + + @GetMapping + public ResponseEntity> getNotifications( + @Valid @ModelAttribute NotificationListRequest req, + @AuthenticationPrincipal AuthPrinciple authPrinciple + ) { + CursorPageResponse res + = notificationService.getNotifications(authPrinciple.userId(), req); + + return ResponseEntity.ok(res); + } + + @PostMapping("/token") + public ResponseEntity registerFcmToken( + @Valid @RequestBody TokenRegisterRequest req, + @AuthenticationPrincipal AuthPrinciple authPrinciple + ) { + notificationService.registerFcmToken(authPrinciple.userId(), req); + + return ResponseEntity.status(HttpStatus.CREATED).build(); + } +} diff --git a/src/main/java/project/flipnote/notification/controller/docs/NotificationControllerDocs.java b/src/main/java/project/flipnote/notification/controller/docs/NotificationControllerDocs.java new file mode 100644 index 00000000..dabd0102 --- /dev/null +++ b/src/main/java/project/flipnote/notification/controller/docs/NotificationControllerDocs.java @@ -0,0 +1,24 @@ +package project.flipnote.notification.controller.docs; + +import org.springframework.http.ResponseEntity; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import project.flipnote.common.model.response.CursorPageResponse; +import project.flipnote.common.security.dto.AuthPrinciple; +import project.flipnote.notification.model.NotificationListRequest; +import project.flipnote.notification.model.NotificationResponse; +import project.flipnote.notification.model.TokenRegisterRequest; + +@Tag(name = "Notification", description = "Notification API") +public interface NotificationControllerDocs { + + @Operation(summary = "알림 목록 조회") + ResponseEntity> getNotifications( + NotificationListRequest req, + AuthPrinciple authPrinciple + ); + + @Operation(summary = "FCM 토큰 등록") + ResponseEntity registerFcmToken(TokenRegisterRequest req, AuthPrinciple authPrinciple); +} diff --git a/src/main/java/project/flipnote/notification/entity/FcmToken.java b/src/main/java/project/flipnote/notification/entity/FcmToken.java new file mode 100644 index 00000000..d53090af --- /dev/null +++ b/src/main/java/project/flipnote/notification/entity/FcmToken.java @@ -0,0 +1,56 @@ +package project.flipnote.notification.entity; + +import java.time.LocalDateTime; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Index; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Entity +@Table( + name = "fcm_tokens", + indexes = { + @Index(name = "idx_fcm_user_id", columnList = "user_id"), + @Index(name = "idx_fcm_token", columnList = "token") + }, + uniqueConstraints = { + @UniqueConstraint(name = "unique_user_token", columnNames = {"user_id", "token"}) + } +) +public class FcmToken { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false) + private Long userId; + + @Column(nullable = false, length = 512) + private String token; + + @Column(nullable = false) + private LocalDateTime lastUsedAt; + + @Builder + public FcmToken(Long userId, String token) { + this.userId = userId; + this.token = token; + this.lastUsedAt = LocalDateTime.now(); + } + + public void updateLastUsedAt() { + this.lastUsedAt = LocalDateTime.now(); + } +} diff --git a/src/main/java/project/flipnote/notification/entity/Notification.java b/src/main/java/project/flipnote/notification/entity/Notification.java new file mode 100644 index 00000000..fc0cc6e4 --- /dev/null +++ b/src/main/java/project/flipnote/notification/entity/Notification.java @@ -0,0 +1,68 @@ +package project.flipnote.notification.entity; + +import java.time.LocalDateTime; +import java.util.HashMap; +import java.util.Map; + +import jakarta.persistence.Column; +import jakarta.persistence.Convert; +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.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import project.flipnote.common.entity.BaseEntity; +import project.flipnote.common.entity.MapToJsonConverter; + +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Entity +@Table(name = "notifications") +public class Notification extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false) + private Long receiverId; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 20) + private NotificationType type; + + @Convert(converter = MapToJsonConverter.class) + private Map variables; + + @Convert(converter = MapToJsonConverter.class) + private Map additionalData; + + @Column(name = "is_read", nullable = false) + boolean read; + + LocalDateTime readAt; + + @Builder + public Notification( + Long receiverId, NotificationType type, Map variables, Map additionalData + ) { + this.receiverId = receiverId; + this.type = type; + this.variables = variables == null ? new HashMap<>() : variables; + this.additionalData = additionalData == null ? new HashMap<>() : additionalData; + this.read = false; + } + + public void markAsRead() { + if (!this.read) { + this.read = true; + this.readAt = LocalDateTime.now(); + } + } +} diff --git a/src/main/java/project/flipnote/notification/entity/NotificationType.java b/src/main/java/project/flipnote/notification/entity/NotificationType.java new file mode 100644 index 00000000..bcc2368e --- /dev/null +++ b/src/main/java/project/flipnote/notification/entity/NotificationType.java @@ -0,0 +1,12 @@ +package project.flipnote.notification.entity; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum NotificationType { + GROUP_INVITE("notification.group.invite"); + + private final String messageKey; +} diff --git a/src/main/java/project/flipnote/notification/exception/NotificationErrorCode.java b/src/main/java/project/flipnote/notification/exception/NotificationErrorCode.java new file mode 100644 index 00000000..820dd441 --- /dev/null +++ b/src/main/java/project/flipnote/notification/exception/NotificationErrorCode.java @@ -0,0 +1,23 @@ +package project.flipnote.notification.exception; + +import org.springframework.http.HttpStatus; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import project.flipnote.common.exception.ErrorCode; + +@Getter +@RequiredArgsConstructor +public enum NotificationErrorCode implements ErrorCode { + FCM_INTERNAL_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "NOTIFICATION_001", "FCM 내부 오류가 발생했습니다"), + FCM_SERVER_UNAVAILABLE(HttpStatus.SERVICE_UNAVAILABLE, "NOTIFICATION_002", "FCM 서버를 사용할 수 없습니다."); + + private final HttpStatus httpStatus; + private final String code; + private final String message; + + @Override + public int getStatus() { + return httpStatus.value(); + } +} diff --git a/src/main/java/project/flipnote/notification/listener/GroupInvitationCreateEventListener.java b/src/main/java/project/flipnote/notification/listener/GroupInvitationCreateEventListener.java new file mode 100644 index 00000000..2b13acbd --- /dev/null +++ b/src/main/java/project/flipnote/notification/listener/GroupInvitationCreateEventListener.java @@ -0,0 +1,37 @@ +package project.flipnote.notification.listener; + +import org.springframework.retry.annotation.Backoff; +import org.springframework.retry.annotation.Recover; +import org.springframework.retry.annotation.Retryable; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import project.flipnote.common.model.event.GroupInvitationCreatedEvent; +import project.flipnote.notification.service.NotificationService; + +@Slf4j +@RequiredArgsConstructor +@Component +public class GroupInvitationCreateEventListener { + + private final NotificationService notificationService; + + @Async + @Retryable( + maxAttempts = 3, + backoff = @Backoff(delay = 2000, multiplier = 2) + ) + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void handleGroupInvitationCreatedEvent(GroupInvitationCreatedEvent event) { + notificationService.sendGroupInvite(event.groupId(), event.inviteeId()); + } + + @Recover + public void recover(Exception ex, GroupInvitationCreatedEvent event) { + log.error("그룹 초대 후속 처리 예외 발생: groupId={}, inviteeId={}", event.groupId(), event.inviteeId(), ex); + } +} diff --git a/src/main/java/project/flipnote/notification/model/NotificationListRequest.java b/src/main/java/project/flipnote/notification/model/NotificationListRequest.java new file mode 100644 index 00000000..86d3f410 --- /dev/null +++ b/src/main/java/project/flipnote/notification/model/NotificationListRequest.java @@ -0,0 +1,6 @@ +package project.flipnote.notification.model; + +import project.flipnote.common.model.request.CursorPageRequest; + +public class NotificationListRequest extends CursorPageRequest { +} diff --git a/src/main/java/project/flipnote/notification/model/NotificationResponse.java b/src/main/java/project/flipnote/notification/model/NotificationResponse.java new file mode 100644 index 00000000..5764061d --- /dev/null +++ b/src/main/java/project/flipnote/notification/model/NotificationResponse.java @@ -0,0 +1,33 @@ +package project.flipnote.notification.model; + +import java.time.LocalDateTime; +import java.util.Map; + +import com.fasterxml.jackson.annotation.JsonFormat; + +import project.flipnote.notification.entity.Notification; + +public record NotificationResponse( + Long notificationId, + String message, + Map additionalData, + boolean isRead, + + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + LocalDateTime readAt, + + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + LocalDateTime createdAt +) { + + public static NotificationResponse of(Notification notification, String message) { + return new NotificationResponse( + notification.getId(), + message, + notification.getAdditionalData(), + notification.isRead(), + notification.getReadAt(), + notification.getCreatedAt() + ); + } +} diff --git a/src/main/java/project/flipnote/notification/model/TokenRegisterRequest.java b/src/main/java/project/flipnote/notification/model/TokenRegisterRequest.java new file mode 100644 index 00000000..b4f33d7d --- /dev/null +++ b/src/main/java/project/flipnote/notification/model/TokenRegisterRequest.java @@ -0,0 +1,9 @@ +package project.flipnote.notification.model; + +import jakarta.validation.constraints.NotEmpty; + +public record TokenRegisterRequest( + @NotEmpty + String token +) { +} diff --git a/src/main/java/project/flipnote/notification/repository/FcmTokenRepository.java b/src/main/java/project/flipnote/notification/repository/FcmTokenRepository.java new file mode 100644 index 00000000..09e60ac9 --- /dev/null +++ b/src/main/java/project/flipnote/notification/repository/FcmTokenRepository.java @@ -0,0 +1,25 @@ +package project.flipnote.notification.repository; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import project.flipnote.notification.entity.FcmToken; + +public interface FcmTokenRepository extends JpaRepository { + + List findByUserId(Long userId); + + void deleteByUserIdAndTokenIn(Long userId, List tokens); + + @Modifying(clearAutomatically = true, flushAutomatically = true) + @Query("UPDATE FcmToken f SET f.lastUsedAt = :now WHERE f.token IN :tokens") + int bulkUpdateLastUsedAt(@Param("tokens") List tokens, @Param("now") LocalDateTime now); + + Optional findByUserIdAndToken(Long userId, String token); +} diff --git a/src/main/java/project/flipnote/notification/repository/NotificationRepository.java b/src/main/java/project/flipnote/notification/repository/NotificationRepository.java new file mode 100644 index 00000000..ca69e190 --- /dev/null +++ b/src/main/java/project/flipnote/notification/repository/NotificationRepository.java @@ -0,0 +1,20 @@ +package project.flipnote.notification.repository; + +import java.util.List; + +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import project.flipnote.notification.entity.Notification; + +public interface NotificationRepository extends JpaRepository { + + @Query("SELECT n FROM Notification n WHERE (:cursor IS NULL OR n.id < :cursor) AND n.receiverId = :receiverId ORDER BY n.id DESC") + List findNotificationsByReceiverIdAndCursor( + @Param("receiverId") Long receiverId, + @Param("cursor") Long cursor, + Pageable pageable + ); +} diff --git a/src/main/java/project/flipnote/notification/service/NotificationService.java b/src/main/java/project/flipnote/notification/service/NotificationService.java new file mode 100644 index 00000000..815520e9 --- /dev/null +++ b/src/main/java/project/flipnote/notification/service/NotificationService.java @@ -0,0 +1,154 @@ +package project.flipnote.notification.service; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.Map; + +import org.apache.commons.text.StringSubstitutor; +import org.springframework.context.MessageSource; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.google.firebase.messaging.BatchResponse; +import com.google.firebase.messaging.FirebaseMessagingException; +import com.google.firebase.messaging.SendResponse; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import project.flipnote.common.exception.BizException; +import project.flipnote.common.model.response.CursorPageResponse; +import project.flipnote.group.service.GroupService; +import project.flipnote.infra.firebase.FcmErrorCode; +import project.flipnote.infra.firebase.FirebaseService; +import project.flipnote.notification.entity.FcmToken; +import project.flipnote.notification.entity.Notification; +import project.flipnote.notification.entity.NotificationType; +import project.flipnote.notification.exception.NotificationErrorCode; +import project.flipnote.notification.model.NotificationListRequest; +import project.flipnote.notification.model.NotificationResponse; +import project.flipnote.notification.model.TokenRegisterRequest; +import project.flipnote.notification.repository.FcmTokenRepository; +import project.flipnote.notification.repository.NotificationRepository; + +@Slf4j +@RequiredArgsConstructor +@Transactional(readOnly = true) +@Service +public class NotificationService { + + private final NotificationRepository notificationRepository; + private final MessageSource messageSource; + private final GroupService groupService; + private final FcmTokenRepository fcmTokenRepository; + private final FirebaseService firebaseService; + + public CursorPageResponse getNotifications(Long userId, NotificationListRequest req) { + Pageable pageable = PageRequest.of(0, req.getSize() + 1); + List notifications + = notificationRepository.findNotificationsByReceiverIdAndCursor(userId, req.getCursorId(), pageable); + + boolean hasNext = notifications.size() > req.getSize(); + Long nextCursor = null; + if (hasNext) { + notifications = notifications.subList(0, req.getSize()); + nextCursor = notifications.get(notifications.size() - 1).getId(); + } + + List content = notifications.stream() + .map((notification -> { + String message = buildMessage(notification, Locale.KOREA); + return NotificationResponse.of(notification, message); + })) + .toList(); + + return CursorPageResponse.of(content, hasNext, nextCursor); + } + + @Transactional + public void sendGroupInvite(Long groupId, Long inviteeId) { + NotificationType type = NotificationType.GROUP_INVITE; + String groupName = groupService.findGroupName(groupId); + + Notification notification = Notification.builder() + .receiverId(inviteeId) + .type(type) + .variables(Map.of("groupName", groupName)) + .additionalData(Map.of("groupId", groupId)) + .build(); + notificationRepository.save(notification); + + String message = buildMessage(notification, Locale.KOREA); + sendNotification(inviteeId, message); + } + + @Transactional + public void registerFcmToken(Long userId, TokenRegisterRequest req) { + fcmTokenRepository.findByUserIdAndToken(userId, req.token()) + .ifPresentOrElse( + FcmToken::updateLastUsedAt, + () -> saveFcmToken(userId, req) + ); + } + + private void sendNotification(Long userId, String body) { + List infos = fcmTokenRepository.findByUserId(userId); + if (infos.isEmpty()) { + log.warn("No FCM tokens for user {}", userId); + return; + } + + List tokens = infos.stream().map(FcmToken::getToken).toList(); + try { + BatchResponse response = firebaseService.sendEachForMulticast(tokens, "알림", body); + + List validTokens = new ArrayList<>(); + List invalidTokens = new ArrayList<>(); + for (int i = 0; i < response.getResponses().size(); i++) { + SendResponse r = response.getResponses().get(i); + if (r.isSuccessful()) { + validTokens.add(tokens.get(i)); + } else { + String errorName = r.getException().getMessagingErrorCode().name(); + FcmErrorCode code = FcmErrorCode.from(errorName); + if (code == FcmErrorCode.UNREGISTERED || code == FcmErrorCode.INVALID_ARGUMENT) { + invalidTokens.add(tokens.get(i)); + } + } + } + + if (!invalidTokens.isEmpty()) { + fcmTokenRepository.deleteByUserIdAndTokenIn(userId, invalidTokens); + } + if (!validTokens.isEmpty()) { + fcmTokenRepository.bulkUpdateLastUsedAt(validTokens, LocalDateTime.now()); + } + } catch (FirebaseMessagingException e) { + log.error("FCM 전송 실패 userId:{}", userId, e); + FcmErrorCode code = FcmErrorCode.from(e.getErrorCode().name()); + if (code == FcmErrorCode.UNAVAILABLE) { + throw new BizException(NotificationErrorCode.FCM_SERVER_UNAVAILABLE); + } + throw new BizException(NotificationErrorCode.FCM_INTERNAL_ERROR); + } + } + + private String buildMessage(Notification notification, Locale locale) { + String key = notification.getType().getMessageKey(); + String template = messageSource.getMessage(key, null, locale); + StringSubstitutor substitutor = new StringSubstitutor(notification.getVariables()); + return substitutor.replace(template); + } + + private void saveFcmToken(Long userId, TokenRegisterRequest req) { + FcmToken fcmToken = FcmToken.builder() + .userId(userId) + .token(req.token()) + .build(); + + fcmTokenRepository.save(fcmToken); + } +} diff --git a/src/main/java/project/flipnote/user/service/UserService.java b/src/main/java/project/flipnote/user/service/UserService.java index de001b60..f8b4d8ea 100644 --- a/src/main/java/project/flipnote/user/service/UserService.java +++ b/src/main/java/project/flipnote/user/service/UserService.java @@ -11,8 +11,8 @@ import org.springframework.transaction.annotation.Transactional; import lombok.RequiredArgsConstructor; -import project.flipnote.common.dto.UserCreateCommand; -import project.flipnote.common.event.UserWithdrawnEvent; +import project.flipnote.common.model.request.UserCreateCommand; +import project.flipnote.common.model.event.UserWithdrawnEvent; import project.flipnote.common.exception.BizException; import project.flipnote.user.model.UserIdNickname; import project.flipnote.user.entity.UserProfile; diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index b502bb05..fd33dbab 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -93,3 +93,6 @@ cloud: springdoc: server: url: http://localhost:8080 + +firebase: + config-path: ${FIREBASE_CONFIG_PATH:firebase-service-account.json} diff --git a/src/main/resources/messages.properties b/src/main/resources/messages.properties new file mode 100644 index 00000000..03554880 --- /dev/null +++ b/src/main/resources/messages.properties @@ -0,0 +1 @@ +notification.group.invite=${groupName} 그룹에 초대되셨습니다. From 8ac54a762a46046410efdbde785e95076c9f07a4 Mon Sep 17 00:00:00 2001 From: dungbik Date: Fri, 15 Aug 2025 02:40:04 +0900 Subject: [PATCH 2/8] =?UTF-8?q?Chore:=20=EB=81=9D=20=EB=9D=BC=EC=9D=B8=20?= =?UTF-8?q?=EA=B3=B5=EB=B0=B1=20=EB=B9=A0=EC=A7=84=20=EB=B6=80=EB=B6=84=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/project/flipnote/common/entity/MapToJsonConverter.java | 2 +- src/main/java/project/flipnote/infra/firebase/FcmErrorCode.java | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/java/project/flipnote/common/entity/MapToJsonConverter.java b/src/main/java/project/flipnote/common/entity/MapToJsonConverter.java index f7c85d18..83a07bd5 100644 --- a/src/main/java/project/flipnote/common/entity/MapToJsonConverter.java +++ b/src/main/java/project/flipnote/common/entity/MapToJsonConverter.java @@ -39,4 +39,4 @@ public Map convertToEntityAttribute(String dbData) { throw new IllegalArgumentException("JSON 파싱 실패", ex); } } -} \ No newline at end of file +} diff --git a/src/main/java/project/flipnote/infra/firebase/FcmErrorCode.java b/src/main/java/project/flipnote/infra/firebase/FcmErrorCode.java index ade6dd63..3aa52aea 100644 --- a/src/main/java/project/flipnote/infra/firebase/FcmErrorCode.java +++ b/src/main/java/project/flipnote/infra/firebase/FcmErrorCode.java @@ -18,4 +18,4 @@ public static FcmErrorCode from(String code) { return UNKNOWN; } } -} \ No newline at end of file +} From 7286e0bfbe8d100e7b614ef317e1de21427f507d Mon Sep 17 00:00:00 2001 From: dungbik Date: Fri, 15 Aug 2025 02:58:56 +0900 Subject: [PATCH 3/8] =?UTF-8?q?Feat:=20=EC=95=8C=EB=A6=BC=20=EC=9D=BD?= =?UTF-8?q?=EC=9D=8C=20=EC=B2=98=EB=A6=AC=20=EA=B8=B0=EB=8A=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/NotificationController.java | 11 +++++++++++ .../docs/NotificationControllerDocs.java | 4 ++++ .../model/MarkNotificationsAsReadRequest.java | 11 +++++++++++ .../repository/NotificationRepository.java | 16 ++++++++++++++++ .../service/NotificationService.java | 6 ++++++ 5 files changed, 48 insertions(+) create mode 100644 src/main/java/project/flipnote/notification/model/MarkNotificationsAsReadRequest.java diff --git a/src/main/java/project/flipnote/notification/controller/NotificationController.java b/src/main/java/project/flipnote/notification/controller/NotificationController.java index 72c4a00c..8cf0211c 100644 --- a/src/main/java/project/flipnote/notification/controller/NotificationController.java +++ b/src/main/java/project/flipnote/notification/controller/NotificationController.java @@ -15,6 +15,7 @@ import project.flipnote.common.model.response.CursorPageResponse; import project.flipnote.common.security.dto.AuthPrinciple; import project.flipnote.notification.controller.docs.NotificationControllerDocs; +import project.flipnote.notification.model.MarkNotificationsAsReadRequest; import project.flipnote.notification.model.NotificationListRequest; import project.flipnote.notification.model.NotificationResponse; import project.flipnote.notification.model.TokenRegisterRequest; @@ -47,4 +48,14 @@ public ResponseEntity registerFcmToken( return ResponseEntity.status(HttpStatus.CREATED).build(); } + + @PostMapping("/read") + public ResponseEntity markNotificationsAsRead( + @Valid @RequestBody MarkNotificationsAsReadRequest req, + @AuthenticationPrincipal AuthPrinciple authPrinciple + ) { + notificationService.markNotificationsAsRead(authPrinciple.userId(), req); + + return ResponseEntity.ok().build(); + } } diff --git a/src/main/java/project/flipnote/notification/controller/docs/NotificationControllerDocs.java b/src/main/java/project/flipnote/notification/controller/docs/NotificationControllerDocs.java index dabd0102..805c4d29 100644 --- a/src/main/java/project/flipnote/notification/controller/docs/NotificationControllerDocs.java +++ b/src/main/java/project/flipnote/notification/controller/docs/NotificationControllerDocs.java @@ -6,6 +6,7 @@ import io.swagger.v3.oas.annotations.tags.Tag; import project.flipnote.common.model.response.CursorPageResponse; import project.flipnote.common.security.dto.AuthPrinciple; +import project.flipnote.notification.model.MarkNotificationsAsReadRequest; import project.flipnote.notification.model.NotificationListRequest; import project.flipnote.notification.model.NotificationResponse; import project.flipnote.notification.model.TokenRegisterRequest; @@ -21,4 +22,7 @@ ResponseEntity> getNotifications( @Operation(summary = "FCM 토큰 등록") ResponseEntity registerFcmToken(TokenRegisterRequest req, AuthPrinciple authPrinciple); + + @Operation(summary = "여러 알림을 읽음 처리") + ResponseEntity markNotificationsAsRead(MarkNotificationsAsReadRequest req, AuthPrinciple authPrinciple); } diff --git a/src/main/java/project/flipnote/notification/model/MarkNotificationsAsReadRequest.java b/src/main/java/project/flipnote/notification/model/MarkNotificationsAsReadRequest.java new file mode 100644 index 00000000..c8189a80 --- /dev/null +++ b/src/main/java/project/flipnote/notification/model/MarkNotificationsAsReadRequest.java @@ -0,0 +1,11 @@ +package project.flipnote.notification.model; + +import java.util.List; + +import jakarta.validation.constraints.NotEmpty; + +public record MarkNotificationsAsReadRequest( + @NotEmpty + List notificationIds +) { +} diff --git a/src/main/java/project/flipnote/notification/repository/NotificationRepository.java b/src/main/java/project/flipnote/notification/repository/NotificationRepository.java index ca69e190..8bb8bdf9 100644 --- a/src/main/java/project/flipnote/notification/repository/NotificationRepository.java +++ b/src/main/java/project/flipnote/notification/repository/NotificationRepository.java @@ -1,9 +1,11 @@ package project.flipnote.notification.repository; +import java.time.LocalDateTime; import java.util.List; import org.springframework.data.domain.Pageable; 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; @@ -17,4 +19,18 @@ List findNotificationsByReceiverIdAndCursor( @Param("cursor") Long cursor, Pageable pageable ); + + @Modifying(clearAutomatically = true, flushAutomatically = true) + @Query(""" + UPDATE Notification n + SET n.read = TRUE, n.readAt = :now + WHERE n.receiverId = :userId + AND n.id IN :ids + AND n.read is FALSE + """) + int bulkMarkAsRead( + @Param("userId") Long userId, + @Param("ids") List ids, + @Param("now") LocalDateTime now + ); } diff --git a/src/main/java/project/flipnote/notification/service/NotificationService.java b/src/main/java/project/flipnote/notification/service/NotificationService.java index 815520e9..207347ef 100644 --- a/src/main/java/project/flipnote/notification/service/NotificationService.java +++ b/src/main/java/project/flipnote/notification/service/NotificationService.java @@ -28,6 +28,7 @@ import project.flipnote.notification.entity.Notification; import project.flipnote.notification.entity.NotificationType; import project.flipnote.notification.exception.NotificationErrorCode; +import project.flipnote.notification.model.MarkNotificationsAsReadRequest; import project.flipnote.notification.model.NotificationListRequest; import project.flipnote.notification.model.NotificationResponse; import project.flipnote.notification.model.TokenRegisterRequest; @@ -94,6 +95,11 @@ public void registerFcmToken(Long userId, TokenRegisterRequest req) { ); } + @Transactional + public void markNotificationsAsRead(Long userId, MarkNotificationsAsReadRequest req) { + notificationRepository.bulkMarkAsRead(userId, req.notificationIds(), LocalDateTime.now()); + } + private void sendNotification(Long userId, String body) { List infos = fcmTokenRepository.findByUserId(userId); if (infos.isEmpty()) { From 59f2b252a366cd40abbb27195f9660ba39e333a5 Mon Sep 17 00:00:00 2001 From: dungbik Date: Fri, 15 Aug 2025 11:36:26 +0900 Subject: [PATCH 4/8] =?UTF-8?q?Feat:=20cursor=20Long=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=EB=B3=80=ED=99=98=20=EB=A1=9C=EC=A7=81=20=EB=B3=B4=EA=B0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../common/model/request/CursorPageRequest.java | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/src/main/java/project/flipnote/common/model/request/CursorPageRequest.java b/src/main/java/project/flipnote/common/model/request/CursorPageRequest.java index 306fc3cd..cb49ba91 100644 --- a/src/main/java/project/flipnote/common/model/request/CursorPageRequest.java +++ b/src/main/java/project/flipnote/common/model/request/CursorPageRequest.java @@ -2,6 +2,7 @@ import org.springframework.util.StringUtils; +import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.Max; import jakarta.validation.constraints.Min; import lombok.Getter; @@ -17,7 +18,17 @@ public class CursorPageRequest { @Max(30) private Integer size = 10; + @Schema(hidden = true) public Long getCursorId() { - return StringUtils.hasText(cursor) ? Long.valueOf(cursor) : null; + if (!StringUtils.hasText(cursor)) { + return null; + } + + final String normalized = cursor.trim(); + if (normalized.isEmpty()) { + return null; + } + + return Long.valueOf(normalized); } } From 02057f9a090de079fc6e88095dc8de309007d5bd Mon Sep 17 00:00:00 2001 From: dungbik Date: Fri, 15 Aug 2025 11:37:07 +0900 Subject: [PATCH 5/8] =?UTF-8?q?Feat:=20FCM=20token=20=EB=93=B1=EB=A1=9D=20?= =?UTF-8?q?=EB=A1=9C=EC=A7=81=20=EB=B3=B4=EA=B0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../repository/FcmTokenRepository.java | 2 +- .../service/NotificationService.java | 18 +++++++++++++----- 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/src/main/java/project/flipnote/notification/repository/FcmTokenRepository.java b/src/main/java/project/flipnote/notification/repository/FcmTokenRepository.java index 09e60ac9..367240a0 100644 --- a/src/main/java/project/flipnote/notification/repository/FcmTokenRepository.java +++ b/src/main/java/project/flipnote/notification/repository/FcmTokenRepository.java @@ -21,5 +21,5 @@ public interface FcmTokenRepository extends JpaRepository { @Query("UPDATE FcmToken f SET f.lastUsedAt = :now WHERE f.token IN :tokens") int bulkUpdateLastUsedAt(@Param("tokens") List tokens, @Param("now") LocalDateTime now); - Optional findByUserIdAndToken(Long userId, String token); + Optional findByToken(String token); } diff --git a/src/main/java/project/flipnote/notification/service/NotificationService.java b/src/main/java/project/flipnote/notification/service/NotificationService.java index 207347ef..fae4a253 100644 --- a/src/main/java/project/flipnote/notification/service/NotificationService.java +++ b/src/main/java/project/flipnote/notification/service/NotificationService.java @@ -88,11 +88,19 @@ public void sendGroupInvite(Long groupId, Long inviteeId) { @Transactional public void registerFcmToken(Long userId, TokenRegisterRequest req) { - fcmTokenRepository.findByUserIdAndToken(userId, req.token()) - .ifPresentOrElse( - FcmToken::updateLastUsedAt, - () -> saveFcmToken(userId, req) - ); + Optional existingToken = fcmTokenRepository.findByToken(req.token()); + + if (existingToken.isPresent()) { + FcmToken token = existingToken.get(); + + if (Objects.equals(token.getUserId(), userId)) { + token.updateLastUsedAt(); + } else { + fcmTokenRepository.deleteById(token.getId()); + } + } else { + saveFcmToken(userId, req.token()); + } } @Transactional From 0a6181c6e80d128098ed6cdee5421e43b2fa95da Mon Sep 17 00:00:00 2001 From: dungbik Date: Fri, 15 Aug 2025 11:37:21 +0900 Subject: [PATCH 6/8] =?UTF-8?q?Feat:=20MapToJsonConverter=20=EB=A1=9C?= =?UTF-8?q?=EC=A7=81=20=EB=B3=B4=EA=B0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../common/entity/MapToJsonConverter.java | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/src/main/java/project/flipnote/common/entity/MapToJsonConverter.java b/src/main/java/project/flipnote/common/entity/MapToJsonConverter.java index 83a07bd5..2508df83 100644 --- a/src/main/java/project/flipnote/common/entity/MapToJsonConverter.java +++ b/src/main/java/project/flipnote/common/entity/MapToJsonConverter.java @@ -1,20 +1,25 @@ package project.flipnote.common.entity; import java.io.IOException; -import java.util.Collections; +import java.util.HashMap; import java.util.Map; +import org.springframework.stereotype.Component; + import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.ObjectMapper; import jakarta.persistence.AttributeConverter; import jakarta.persistence.Converter; +import lombok.RequiredArgsConstructor; -@Converter(autoApply = true) +@RequiredArgsConstructor +@Converter +@Component public class MapToJsonConverter implements AttributeConverter, String> { - private final ObjectMapper objectMapper = new ObjectMapper(); + private final ObjectMapper objectMapper; @Override public String convertToDatabaseColumn(Map attribute) { @@ -31,10 +36,11 @@ public String convertToDatabaseColumn(Map attribute) { @Override public Map convertToEntityAttribute(String dbData) { if (dbData == null || dbData.isBlank()) { - return Collections.emptyMap(); + return new HashMap<>(); } try { - return objectMapper.readValue(dbData, new TypeReference>() {}); + return objectMapper.readValue(dbData, new TypeReference>() { + }); } catch (IOException ex) { throw new IllegalArgumentException("JSON 파싱 실패", ex); } From b265023ad0f777492d5a66788f3a9d6b04d3ffb7 Mon Sep 17 00:00:00 2001 From: dungbik Date: Fri, 15 Aug 2025 11:38:16 +0900 Subject: [PATCH 7/8] =?UTF-8?q?Feat:=20=ED=9A=8C=EC=9B=90=20=ED=83=88?= =?UTF-8?q?=ED=87=B4=EA=B0=80=20=EC=84=B1=EA=B3=B5=ED=96=88=EC=9D=84=20?= =?UTF-8?q?=EB=95=8C=EB=A7=8C=20=EC=9D=B8=EC=A6=9D=20=EC=A0=95=EB=B3=B4?= =?UTF-8?q?=EA=B0=80=20=EA=B0=B1=EC=8B=A0=EB=90=98=EB=8F=84=EB=A1=9D=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../flipnote/auth/listener/UserWithdrawnEventListener.java | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/main/java/project/flipnote/auth/listener/UserWithdrawnEventListener.java b/src/main/java/project/flipnote/auth/listener/UserWithdrawnEventListener.java index 13860f66..e145323a 100644 --- a/src/main/java/project/flipnote/auth/listener/UserWithdrawnEventListener.java +++ b/src/main/java/project/flipnote/auth/listener/UserWithdrawnEventListener.java @@ -1,11 +1,12 @@ package project.flipnote.auth.listener; -import org.springframework.context.event.EventListener; import org.springframework.retry.annotation.Backoff; import org.springframework.retry.annotation.Recover; import org.springframework.retry.annotation.Retryable; import org.springframework.scheduling.annotation.Async; import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -25,7 +26,7 @@ public class UserWithdrawnEventListener { maxAttempts = 3, backoff = @Backoff(delay = 2000, multiplier = 2) ) - @EventListener + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) public void handleUserWithdrawnEvent(UserWithdrawnEvent event) { userAuthRepository.findByUserIdAndStatus(event.userId(), AccountStatus.ACTIVE) .ifPresent(userAuth -> { From 33e955b697081078517ea6201737d8c739f7884e Mon Sep 17 00:00:00 2001 From: dungbik Date: Fri, 15 Aug 2025 11:38:35 +0900 Subject: [PATCH 8/8] =?UTF-8?q?Docs:=20NotificationService=20JavaDoc=20?= =?UTF-8?q?=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/NotificationService.java | 65 +++++++++++++++++-- 1 file changed, 61 insertions(+), 4 deletions(-) diff --git a/src/main/java/project/flipnote/notification/service/NotificationService.java b/src/main/java/project/flipnote/notification/service/NotificationService.java index fae4a253..e292c4d7 100644 --- a/src/main/java/project/flipnote/notification/service/NotificationService.java +++ b/src/main/java/project/flipnote/notification/service/NotificationService.java @@ -5,6 +5,8 @@ import java.util.List; import java.util.Locale; import java.util.Map; +import java.util.Objects; +import java.util.Optional; import org.apache.commons.text.StringSubstitutor; import org.springframework.context.MessageSource; @@ -47,6 +49,14 @@ public class NotificationService { private final FcmTokenRepository fcmTokenRepository; private final FirebaseService firebaseService; + /** + * 알림 목록 커서 기반 페이징으로 조회 + * + * @param userId 알림 목록 조회하는 회원 ID + * @param req 알림 목록 조회를 위한 정보 + * @return 커서 기반 페이징된 알림 목록 + * @author 윤정환 + */ public CursorPageResponse getNotifications(Long userId, NotificationListRequest req) { Pageable pageable = PageRequest.of(0, req.getSize() + 1); List notifications @@ -69,6 +79,13 @@ public CursorPageResponse getNotifications(Long userId, No return CursorPageResponse.of(content, hasNext, nextCursor); } + /** + * 그룹 초대 알림 전송 + * + * @param groupId 초대 보낸 그룹 ID + * @param inviteeId 초대 받은 회원 ID + * @author 윤정환 + */ @Transactional public void sendGroupInvite(Long groupId, Long inviteeId) { NotificationType type = NotificationType.GROUP_INVITE; @@ -86,6 +103,13 @@ public void sendGroupInvite(Long groupId, Long inviteeId) { sendNotification(inviteeId, message); } + /** + * FCM Token 등록 + * + * @param userId 토큰을 등록하는 회원 ID + * @param req 토큰 정보 + * @author 윤정환 + */ @Transactional public void registerFcmToken(Long userId, TokenRegisterRequest req) { Optional existingToken = fcmTokenRepository.findByToken(req.token()); @@ -103,11 +127,28 @@ public void registerFcmToken(Long userId, TokenRegisterRequest req) { } } + /** + * 여러 알림을 읽음 처리 + * + * @param userId 알림 읽음 처리를 사용하는 회원 ID + * @param req 알림 읽음 처리를 위한 정보 + * @author 윤정환 + */ @Transactional public void markNotificationsAsRead(Long userId, MarkNotificationsAsReadRequest req) { notificationRepository.bulkMarkAsRead(userId, req.notificationIds(), LocalDateTime.now()); } + /** + * FCM을 통해 실제 알림 전송 + *

+ * 반드시 트랜잭션이 적용된 public 메서드에서 호출해야 합니다. + *

+ * + * @param userId 알림을 받을 회원 ID + * @param body 알림 내용 + * @author 윤정환 + */ private void sendNotification(Long userId, String body) { List infos = fcmTokenRepository.findByUserId(userId); if (infos.isEmpty()) { @@ -142,7 +183,8 @@ private void sendNotification(Long userId, String body) { } } catch (FirebaseMessagingException e) { log.error("FCM 전송 실패 userId:{}", userId, e); - FcmErrorCode code = FcmErrorCode.from(e.getErrorCode().name()); + String errorName = e.getMessagingErrorCode() != null ? e.getMessagingErrorCode().name() : "INTERNAL"; + FcmErrorCode code = FcmErrorCode.from(errorName); if (code == FcmErrorCode.UNAVAILABLE) { throw new BizException(NotificationErrorCode.FCM_SERVER_UNAVAILABLE); } @@ -150,17 +192,32 @@ private void sendNotification(Long userId, String body) { } } + /** + * 알림 내용을 템플릿을 통해 만들어줌 + * + * @param notification 알림 정보 + * @param locale 템플릿을 읽어올 로케일 + * @return 템플릿 치환이 완료된 최종 알림 메시지 문자열 + * @author 윤정환 + */ private String buildMessage(Notification notification, Locale locale) { String key = notification.getType().getMessageKey(); - String template = messageSource.getMessage(key, null, locale); + String template = messageSource.getMessage(key, null, key, locale); StringSubstitutor substitutor = new StringSubstitutor(notification.getVariables()); return substitutor.replace(template); } - private void saveFcmToken(Long userId, TokenRegisterRequest req) { + /** + * FCM Token을 저장 + * + * @param userId 토큰을 저장하려는 회원 ID + * @param token 저장하려는 토큰 + * @author 윤정환 + */ + private void saveFcmToken(Long userId, String token) { FcmToken fcmToken = FcmToken.builder() .userId(userId) - .token(req.token()) + .token(token) .build(); fcmTokenRepository.save(fcmToken);