Skip to content

Commit

Permalink
[WALWAL-173] fcm alarm list 구현 (#142)
Browse files Browse the repository at this point in the history
* feature: FCM 알림 리스트 API 및 읽음 처리 구현

* fix: 테스트코드에 FcmNotificationService 빈 추가

* fix: FcmNotificationResponse CreatedAt 응답값 추가

* test: FcmNotification 테스트 코드 추가

* fix: Contants 분리 및 FcmService 수정

* fix: sonarCloud issue 수정 및 @MockBean 추가

* fix: 메서드 분리 및 수정

* fix: notificationImageUrl 제거 후 서비스로직에서 imageUrl제공

* fix: test코드 Optional추가로인한 코드 수정

* feature: 알림 리스트 조회 시 cursor & limit 사용

* fix: missionRecord불러올때 N+1문제 해결

* fix: test코드수정
  • Loading branch information
dbscks97 authored Aug 19, 2024
1 parent 55b8ca9 commit c9b400c
Show file tree
Hide file tree
Showing 20 changed files with 665 additions and 12 deletions.
Original file line number Diff line number Diff line change
@@ -1,20 +1,28 @@
package com.depromeet.stonebed.domain.fcm.api;

import com.depromeet.stonebed.domain.fcm.application.FcmNotificationService;
import com.depromeet.stonebed.domain.fcm.application.FcmService;
import com.depromeet.stonebed.domain.fcm.application.FcmTokenService;
import com.depromeet.stonebed.domain.fcm.dto.request.FcmSendRequest;
import com.depromeet.stonebed.domain.fcm.dto.request.FcmTokenRequest;
import com.depromeet.stonebed.domain.fcm.dto.response.FcmNotificationResponse;
import com.depromeet.stonebed.global.util.FcmNotificationUtil;
import com.google.firebase.messaging.Notification;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotNull;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
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.RequestParam;
import org.springframework.web.bind.annotation.RestController;

@Tag(name = "6. [알림]", description = "알림 관련 API입니다.")
Expand All @@ -24,6 +32,7 @@
public class FcmController {
private final FcmService fcmService;
private final FcmTokenService fcmTokenService;
private final FcmNotificationService fcmNotificationService;

@Operation(summary = "푸시 메시지 전송", description = "저장된 모든 토큰에 푸시 메시지를 전송합니다.")
@PostMapping("/send")
Expand All @@ -50,4 +59,20 @@ public ResponseEntity<Void> deleteToken() {
fcmTokenService.invalidateTokenForCurrentMember();
return ResponseEntity.ok().build();
}

@Operation(summary = "알림 리스트 조회", description = "회원의 알림을 커서 기반으로 페이징하여 조회한다.")
@GetMapping
public FcmNotificationResponse getNotifications(
@Valid @RequestParam(name = "cursor", required = false) String cursor,
@Valid @NotNull @Min(1) @RequestParam(name = "limit", defaultValue = "10") int limit) {
return fcmNotificationService.getNotificationsForCurrentMember(cursor, limit);
}

@Operation(summary = "FCM 알림 읽음 처리", description = "알림을 읽음 상태로 변경합니다.")
@PostMapping("/{notificationId}/read")
public ResponseEntity<Void> markNotificationAsRead(
@PathVariable("notificationId") Long notificationId) {
fcmNotificationService.markNotificationAsRead(notificationId);
return ResponseEntity.ok().build();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
package com.depromeet.stonebed.domain.fcm.application;

import com.depromeet.stonebed.domain.fcm.dao.FcmNotificationRepository;
import com.depromeet.stonebed.domain.fcm.dao.FcmRepository;
import com.depromeet.stonebed.domain.fcm.domain.FcmNotification;
import com.depromeet.stonebed.domain.fcm.domain.FcmNotificationType;
import com.depromeet.stonebed.domain.fcm.domain.FcmToken;
import com.depromeet.stonebed.domain.fcm.dto.response.FcmNotificationDto;
import com.depromeet.stonebed.domain.fcm.dto.response.FcmNotificationResponse;
import com.depromeet.stonebed.domain.member.domain.Member;
import com.depromeet.stonebed.domain.missionRecord.dao.MissionRecordBoostRepository;
import com.depromeet.stonebed.domain.missionRecord.dao.MissionRecordRepository;
import com.depromeet.stonebed.domain.missionRecord.domain.MissionRecord;
import com.depromeet.stonebed.global.common.constants.FcmNotificationConstants;
import com.depromeet.stonebed.global.error.ErrorCode;
import com.depromeet.stonebed.global.error.exception.CustomException;
import com.depromeet.stonebed.global.util.FcmNotificationUtil;
import com.depromeet.stonebed.global.util.MemberUtil;
import com.google.firebase.messaging.Notification;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeParseException;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
@RequiredArgsConstructor
@Transactional
public class FcmNotificationService {
private final FcmService fcmService;
private final FcmNotificationRepository notificationRepository;
private final MissionRecordBoostRepository missionRecordBoostRepository;
private final MissionRecordRepository missionRecordRepository;
private final FcmRepository fcmRepository;
private final MemberUtil memberUtil;

private static final DateTimeFormatter DATE_FORMATTER =
DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss");

private static final long POPULAR_THRESHOLD = 500;
private static final long SUPER_POPULAR_THRESHOLD = 5000;

public void saveNotification(
FcmNotificationType type, String title, String message, Long targetId, Boolean isRead) {
final Member member = memberUtil.getCurrentMember();

FcmNotification notification =
FcmNotification.create(type, title, message, member, targetId, isRead);
notificationRepository.save(notification);
}

@Transactional(readOnly = true)
public FcmNotificationResponse getNotificationsForCurrentMember(String cursor, int limit) {
Member member = memberUtil.getCurrentMember();

Pageable pageable = createPageable(limit);
List<FcmNotification> notifications = getNotifications(cursor, member.getId(), pageable);
List<FcmNotificationDto> notificationData = convertToNotificationDto(notifications);
String nextCursor = getNextCursor(notifications);

return FcmNotificationResponse.from(notificationData, nextCursor);
}

private Pageable createPageable(int limit) {
return PageRequest.of(0, limit, Sort.by(Sort.Direction.DESC, "createdAt"));
}

private List<FcmNotificationDto> convertToNotificationDto(List<FcmNotification> notifications) {
List<Long> targetIds =
notifications.stream()
.filter(
notification ->
notification.getType() == FcmNotificationType.BOOSTER)
.map(FcmNotification::getTargetId)
.toList();

Map<Long, MissionRecord> missionRecordMap =
missionRecordRepository.findByIdIn(targetIds).stream()
.collect(
Collectors.toMap(
MissionRecord::getId, missionRecord -> missionRecord));

return notifications.stream()
.map(
notification -> {
MissionRecord missionRecord =
missionRecordMap.get(notification.getTargetId());
return FcmNotificationDto.from(notification, missionRecord);
})
.toList();
}

private List<FcmNotification> getNotifications(
String cursor, Long memberId, Pageable pageable) {
if (cursor == null) {
return notificationRepository.findByMemberId(memberId, pageable);
}

try {
LocalDateTime cursorDate = LocalDateTime.parse(cursor, DATE_FORMATTER);
return notificationRepository.findByMemberIdAndCreatedAtLessThanEqual(
memberId, cursorDate, pageable);
} catch (DateTimeParseException e) {
throw new CustomException(ErrorCode.INVALID_CURSOR_DATE_FORMAT);
}
}

private String getNextCursor(List<FcmNotification> notifications) {
if (notifications.isEmpty()) {
return null;
}

FcmNotification lastNotification = notifications.get(notifications.size() - 1);
return lastNotification.getCreatedAt().format(DATE_FORMATTER);
}

public void checkAndSendBoostNotification(MissionRecord missionRecord) {
Long totalBoostCount =
missionRecordBoostRepository.sumBoostCountByMissionRecord(missionRecord.getId());

if (totalBoostCount != null) {
FcmNotificationConstants notificationConstants =
determineNotificationType(totalBoostCount);

if (notificationConstants != null) {
sendBoostNotification(missionRecord, notificationConstants);
}
}
}

private FcmNotificationConstants determineNotificationType(Long totalBoostCount) {
if (totalBoostCount == POPULAR_THRESHOLD) {
return FcmNotificationConstants.POPULAR;
} else if (totalBoostCount == SUPER_POPULAR_THRESHOLD) {
return FcmNotificationConstants.SUPER_POPULAR;
}
return null;
}

private void sendBoostNotification(
MissionRecord missionRecord, FcmNotificationConstants notificationConstants) {
Notification notification =
FcmNotificationUtil.buildNotification(
notificationConstants.getTitle(), notificationConstants.getMessage());

String token =
fcmRepository
.findByMember(missionRecord.getMember())
.map(FcmToken::getToken)
.orElseThrow(() -> new CustomException(ErrorCode.FAILED_TO_FIND_FCM_TOKEN));

fcmService.sendSingleMessage(notification, token);

saveNotification(
FcmNotificationType.BOOSTER,
notificationConstants.getTitle(),
notificationConstants.getMessage(),
missionRecord.getId(),
false);
}

public void markNotificationAsRead(Long notificationId) {
final Member member = memberUtil.getCurrentMember();
FcmNotification notification =
notificationRepository
.findByIdAndMember(notificationId, member)
.orElseThrow(() -> new CustomException(ErrorCode.NOTIFICATION_NOT_FOUND));

notification.markAsRead();
notificationRepository.save(notification);
}
}
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
package com.depromeet.stonebed.domain.fcm.application;

import com.depromeet.stonebed.domain.fcm.dao.FcmRepository;
import com.depromeet.stonebed.domain.fcm.domain.FcmNotificationType;
import com.depromeet.stonebed.domain.fcm.domain.FcmToken;
import com.depromeet.stonebed.domain.missionRecord.dao.MissionRecordRepository;
import com.depromeet.stonebed.domain.missionRecord.domain.MissionRecordStatus;
import com.depromeet.stonebed.global.common.constants.FcmNotificationConstants;
import com.depromeet.stonebed.global.util.FcmNotificationUtil;
import com.google.firebase.messaging.Notification;
import java.time.LocalDateTime;
Expand All @@ -19,6 +21,7 @@
@RequiredArgsConstructor
public class FcmScheduledService {
private final FcmService fcmService;
private final FcmNotificationService fcmNotificationService;
private final FcmRepository fcmRepository;
private final MissionRecordRepository missionRecordRepository;

Expand All @@ -31,22 +34,43 @@ public void removeInactiveTokens() {
log.info("비활성 토큰 {}개 삭제 완료", inactiveTokens.size());
}

// 매일 12시 0분에 실행
@Scheduled(cron = "0 0 12 * * ?")
// 매일 9시 0분에 실행
@Scheduled(cron = "0 0 9 * * ?")
public void sendDailyNotification() {
Notification notification = FcmNotificationUtil.buildNotification("정규 메세지 제목", "정규 메세지 내용");
FcmNotificationConstants notificationConstants = FcmNotificationConstants.MISSION_START;
Notification notification =
FcmNotificationUtil.buildNotification(
notificationConstants.getTitle(), notificationConstants.getMessage());

fcmService.sendMulticastMessageToAll(notification);
log.info("모든 사용자에게 정규 알림 전송 완료");

fcmNotificationService.saveNotification(
FcmNotificationType.MISSION,
notificationConstants.getTitle(),
notificationConstants.getMessage(),
null,
false);
}

// 매일 18시 0분에 실행
@Scheduled(cron = "0 0 18 * * ?")
// 매일 19시 0분에 실행
@Scheduled(cron = "0 0 19 * * ?")
public void sendReminderToIncompleteMissions() {
FcmNotificationConstants notificationConstants = FcmNotificationConstants.MISSION_REMINDER;
Notification notification =
FcmNotificationUtil.buildNotification("리마인드 메세지 제목", "리마인드 메세지 내용");
FcmNotificationUtil.buildNotification(
notificationConstants.getTitle(), notificationConstants.getMessage());

List<String> tokens = getIncompleteMissionTokens();
fcmService.sendMulticastMessage(notification, tokens);
log.info("미완료 미션 사용자에게 리마인더 전송 완료. 총 토큰 수: {}", tokens.size());

fcmNotificationService.saveNotification(
FcmNotificationType.MISSION,
notificationConstants.getTitle(),
notificationConstants.getMessage(),
null,
false);
}

private List<String> getIncompleteMissionTokens() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,30 @@ private void sendMessage(MulticastMessage message, List<String> tokens) {
}
}

private void sendMessage(Message message) {
try {
String response = FirebaseMessaging.getInstance().send(message);
log.info("성공적으로 메시지를 전송했습니다. 메시지 ID: {}", response);
} catch (FirebaseMessagingException e) {
log.error("FCM 메시지 전송에 실패했습니다: ", e);
}
}

public void sendSingleMessage(Notification notification, String token) {
Message message = buildSingleMessage(notification, token);
sendMessage(message);
}

private Message buildSingleMessage(Notification notification, String token) {
HashMap<String, String> data = new HashMap<>();

return Message.builder()
.putAllData(data)
.setNotification(notification)
.setToken(token)
.build();
}

private void handleBatchResponse(BatchResponse response, List<String> tokens) {
response.getResponses().stream()
.filter(sendResponse -> !sendResponse.isSuccessful())
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package com.depromeet.stonebed.domain.fcm.dao;

import com.depromeet.stonebed.domain.fcm.domain.FcmNotification;
import com.depromeet.stonebed.domain.member.domain.Member;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Optional;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;

public interface FcmNotificationRepository extends JpaRepository<FcmNotification, Long> {
List<FcmNotification> findByMemberId(Long memberId, Pageable pageable);

List<FcmNotification> findByMemberIdAndCreatedAtLessThanEqual(
Long memberId, LocalDateTime cursorDate, Pageable pageable);

Optional<FcmNotification> findByIdAndMember(Long id, Member member);
}
Loading

0 comments on commit c9b400c

Please sign in to comment.