Skip to content

Commit

Permalink
refactor: fcm 푸시 알림 리팩토링 (#110)
Browse files Browse the repository at this point in the history
* feat: 디바이스 관련 엔티티 정의

* chore: DB 마이그레이션 파일 작성

* rename: PushNotificationEventHandler -> PushMessageEventHandler

* feat: deviceIdentifier 추가

* refactor: DTO 및 디바이스 엔티티 추가에 따른 수정

* feat: Device 엔티티 추가 및 Member 엔티티 수정

* refactor: 함수 분리 및 로직 변경에 따른 수정

* test: DeviceSubscription 관련 테스트 작성

* remove: 불필요한 코드 삭제

* refactor: MissionRetryPushMessageService 분리

* feat: 멀티 디바이스 및 다중 로그인 지원을 위한 redis 저장소 수정

* refactor: deprecatedDeviceToken -> deviceIdentifier

* feat: 디바이스 일급 컬렉션 작성

* chore: 디바이스 관련 에러 코드 추가

* move: firebase -> device

* refactor: MissionMemberService 로직의 구독 부분 DeviceSubscriptionService로 분리

* refactor: 구독 의존성 개선

* refactor: data 메시지 -> notification+data 혼합 메시지

* test: 메시지 타입 변경에 따른 테스트 수정

* refactor: deviceIdentifier & osType 추가

* refactor: member, device 도메인 분리

* chore: 유효성 검사 추가

* fix: 유효성 검사 수정

* test: MissionRetryMessageRepository 테스트

* refactor: 이전 버전 지원, 함수 중복 제거
  • Loading branch information
kimyu0218 authored Dec 19, 2024
1 parent d8d603b commit 79abfac
Show file tree
Hide file tree
Showing 71 changed files with 1,653 additions and 606 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import com.nexters.goalpanzi.application.auth.dto.request.ReissueTokenCommand;
import com.nexters.goalpanzi.application.auth.dto.response.LoginResponse;
import com.nexters.goalpanzi.application.auth.dto.response.TokenResponse;
import com.nexters.goalpanzi.application.auth.event.LoginEvent;
import com.nexters.goalpanzi.application.auth.google.GoogleIdentityToken;
import com.nexters.goalpanzi.common.auth.jwt.Jwt;
import com.nexters.goalpanzi.common.auth.jwt.JwtProvider;
Expand All @@ -16,6 +17,7 @@
import com.nexters.goalpanzi.exception.UnauthorizedException;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

Expand All @@ -27,25 +29,29 @@ public class AuthService {
private final SocialUserProviderFactory socialUserProviderFactory;
private final MemberRepository memberRepository;
private final RefreshTokenRepository refreshTokenRepository;

private final JwtProvider jwtProvider;
private final ApplicationEventPublisher eventPublisher;

@Transactional
public LoginResponse appleOAuthLogin(final AppleLoginCommand command) {
SocialUserProvider appleUserProvider = socialUserProviderFactory.getProvider(SocialType.APPLE);
SocialUserInfo socialUserInfo = appleUserProvider.getSocialUserInfo(command.identityToken());

return socialLogin(socialUserInfo, SocialType.APPLE);
return socialLogin(socialUserInfo, SocialType.APPLE, command.deviceIdentifier());
}

@Transactional
public LoginResponse googleOAuthLogin(final GoogleLoginCommand command) {
SocialUserInfo socialUserInfo = new SocialUserInfo(
GoogleIdentityToken.generate(command.email()), command.email());

return socialLogin(socialUserInfo, SocialType.GOOGLE);
return socialLogin(socialUserInfo, SocialType.GOOGLE, command.deviceIdentifier());
}

private LoginResponse socialLogin(final SocialUserInfo socialUserInfo, final SocialType socialType) {
private LoginResponse socialLogin(
final SocialUserInfo socialUserInfo, final SocialType socialType, final String deviceIdentifier
) {
checkDeletedMember(socialUserInfo.socialId());
Member member = memberRepository.findBySocialIdAndDeletedAtIsNull(socialUserInfo.socialId())
.orElseGet(() ->
Expand All @@ -55,6 +61,9 @@ private LoginResponse socialLogin(final SocialUserInfo socialUserInfo, final Soc
Jwt jwt = jwtProvider.generateTokens(member.getId().toString());
refreshTokenRepository.save(member.getId().toString(), jwt.refreshToken(), jwt.refreshExpiresIn());

eventPublisher.publishEvent(
new LoginEvent(member.getId(), deviceIdentifier)
);
return LoginResponse.of(member, jwt);
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package com.nexters.goalpanzi.application.auth.dto.request;

public record AppleLoginCommand(
String identityToken
String identityToken,
String deviceIdentifier
) {
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package com.nexters.goalpanzi.application.auth.dto.request;

public record GoogleLoginCommand(
String email
String email,
String deviceIdentifier
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package com.nexters.goalpanzi.application.auth.event;

public record LoginEvent(
Long memberId,
String deviceIdentifier
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
package com.nexters.goalpanzi.application.device;

import com.nexters.goalpanzi.application.device.dto.request.UpdateDeviceTokenCommand;
import com.nexters.goalpanzi.application.device.dto.request.UpdatePushActivationStatusCommand;
import com.nexters.goalpanzi.application.device.event.UpdateDeviceTokenEvent;
import com.nexters.goalpanzi.application.device.event.UpdatePushActivationStatusEvent;
import com.nexters.goalpanzi.domain.device.Device;
import com.nexters.goalpanzi.domain.device.OsType;
import com.nexters.goalpanzi.domain.device.repository.DeviceRepository;
import com.nexters.goalpanzi.domain.member.Member;
import com.nexters.goalpanzi.domain.member.repository.MemberRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Transactional(readOnly = true)
@RequiredArgsConstructor
@Service
public class DeviceService {

private final MemberRepository memberRepository;
private final DeviceRepository deviceRepository;

private final ApplicationEventPublisher eventPublisher;

@Transactional
public void updateDeviceToken(final UpdateDeviceTokenCommand command) {
if (command.deviceIdentifier().isBlank() // TODO: 추후 앞의 조건 삭제
|| !deviceRepository.existsByDeviceIdentifier(command.deviceIdentifier())) {
createDevice(command.memberId(), command.deviceIdentifier(), command.deviceToken(), command.osType());
} else {
updateDevice(command.memberId(), command.deviceIdentifier(), command.deviceToken());
}
}

private void createDevice(final Long memberId, final String deviceIdentifier, final String deviceToken, final OsType osType) {
Member member = memberRepository.getMember(memberId);
Device device = deviceRepository.save(new Device(member, deviceIdentifier, deviceToken, osType));

eventPublisher.publishEvent(
new UpdateDeviceTokenEvent(memberId, device.getId(), null)
);
}

private void updateDevice(final Long memberId, final String deviceIdentifier, final String deviceToken) {
Device device = deviceRepository.getDevice(memberId, deviceIdentifier);

String deprecatedToken = device.getDeviceToken();
device.updateDeviceToken(deviceToken);
device.updatePushActivationStatus(true);

eventPublisher.publishEvent(
new UpdateDeviceTokenEvent(memberId, device.getId(), deprecatedToken)
);
}

@Transactional
public void updatePushActivationStatus(final UpdatePushActivationStatusCommand command) {
Device device = deviceRepository.getDevice(command.memberId(), command.deviceIdentifier());

device.updatePushActivationStatus(command.pushActivationStatus());

eventPublisher.publishEvent(
new UpdatePushActivationStatusEvent(command.memberId(), device.getId(), command.pushActivationStatus(), device.getDeviceToken())
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,238 @@
package com.nexters.goalpanzi.application.device;

import com.nexters.goalpanzi.application.firebase.TopicGenerator;
import com.nexters.goalpanzi.application.mission.event.CancelMissionRetryPushMessageEvent;
import com.nexters.goalpanzi.application.mission.event.ReserveMissionRetryPushMessageEvent;
import com.nexters.goalpanzi.application.mission.event.UnsubscribeFromMissionEvent;
import com.nexters.goalpanzi.application.mission.event.UpdateMissionRetryPushMessageEvent;
import com.nexters.goalpanzi.domain.device.Device;
import com.nexters.goalpanzi.domain.device.DeviceSubscription;
import com.nexters.goalpanzi.domain.device.Devices;
import com.nexters.goalpanzi.domain.device.repository.DeviceRepository;
import com.nexters.goalpanzi.domain.device.repository.DeviceSubscriptionRepository;
import com.nexters.goalpanzi.domain.member.Member;
import com.nexters.goalpanzi.domain.mission.Mission;
import com.nexters.goalpanzi.domain.mission.MissionMember;
import com.nexters.goalpanzi.domain.mission.MissionStatus;
import com.nexters.goalpanzi.domain.mission.repository.MissionMemberRepository;
import com.nexters.goalpanzi.domain.mission.repository.MissionRepository;
import com.nexters.goalpanzi.infrastructure.firebase.TopicSubscriber;
import lombok.RequiredArgsConstructor;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.util.List;
import java.util.stream.Collectors;

import static com.nexters.goalpanzi.domain.mission.MissionStatus.*;

@Transactional(readOnly = true)
@RequiredArgsConstructor
@Service
public class DeviceSubscriptionService {

private final DeviceRepository deviceRepository;
private final DeviceSubscriptionRepository deviceSubscriptionRepository;
private final MissionRepository missionRepository;
private final MissionMemberRepository missionMemberRepository;

private final ApplicationEventPublisher eventPublisher;
private final TopicSubscriber topicSubscriber;

private static final List<MissionStatus> SUBSCRIBABLE_MISSION_STATUS
= List.of(CREATED, IN_PROGRESS, PENDING_COMPLETION);

/**
* <b>새로운 미션 참여 시 미션 구독 시작</b><br>
* 멤버의 디바이스 중 푸시가 활성화된 디바이스를 대상으로 미션 구독
*
* @param memberId 멤버 아이디
* @param mission 새롭게 참여한 미션
*/
@Transactional
public void subscribeToMission(final Long memberId, final Mission mission) {
Devices devices = new Devices(
deviceRepository.findAllByMemberId(memberId)
);

devices.getActivatedDevices()
.forEach(device ->
deviceSubscriptionRepository.save(new DeviceSubscription(device, mission))
);
topicSubscriber.subscribeToTopic(
devices.getActivatedDeviceTokens(), TopicGenerator.getTopic(mission.getId())
);
}

/**
* <b>미션 취소/종료 시 미션 구독 취소</b><br>
* 해당 미션을 구독한 디바이스를 대상으로 구독 취소
*
* @param missionId 취소/종료된 미션
*/
@Transactional
public void unsubscribeFromMission(final Long missionId) {
List<String> deviceTokens = findTopicSubscribers(missionId);

deviceSubscriptionRepository.deleteAllByMissionId(missionId);
topicSubscriber.unsubscribeFromTopic(deviceTokens, TopicGenerator.getTopic(missionId));
}

private List<String> findTopicSubscribers(final Long missionId) {
List<DeviceSubscription> subscriptions = deviceSubscriptionRepository.findAllWithDeviceAndMissionByMissionId(missionId);

return subscriptions.stream()
.map(it -> it.getDevice().getDeviceToken())
.toList();
}

/**
* <b>디바이스 토큰을 갱신하거나 푸시 알림 활성화 시 새로운 디바이스 토큰으로 내 미션 구독 시작</b><br>
* + UpdateMissionRetryPushMessageEvent를 통해 예약된 메시지의 디바이스 토큰 갱신
*
* @param memberId 멤버 아이디
* @param deviceId 디바이스 아이디
*/
@Transactional
public void subscribeToMyMissions(final Long memberId, final Long deviceId) {
Device device = deviceRepository.getDevice(deviceId);
List<String> topics = findMySubscribedTopics(deviceId);
List<Mission> missions = missionRepository.findAllById(
findMySubscribableMission(memberId, topics)
);

topics.forEach(topic ->
topicSubscriber.subscribeToTopic(List.of(device.getDeviceToken()), topic)
);
missions.forEach(mission -> {
deviceSubscriptionRepository.save(new DeviceSubscription(device, mission));

String topic = TopicGenerator.getTopic(mission.getId());
topicSubscriber.subscribeToTopic(List.of(device.getDeviceToken()), topic);
});
eventPublisher.publishEvent(
new UpdateMissionRetryPushMessageEvent(memberId, device.getDeviceToken())
);
}

/**
* <b>로그인 시 기존 디바이스가 구독한 미션 구독 취소</b>
* + CancelMissionRetryPushMessageEvent를 통해 예약된 메시지 취소
*
* @param memberId 멤버 아이디
* @param deviceIdentifier 디바이스 식별자
*/
@Transactional
public void unsubscribeFromMyMissions(final Long memberId, final String deviceIdentifier) {
Devices devices = new Devices(
deviceRepository.findAllByDeviceIdentifier(deviceIdentifier)
);
List<String> topics = devices.getActivatedDevices().stream()
.flatMap(device -> findMySubscribedTopics(device.getId()).stream())
.toList();

topics.forEach(topic ->
topicSubscriber.unsubscribeFromTopic(devices.getActivatedDeviceTokens(), topic)
);
eventPublisher.publishEvent(
new CancelMissionRetryPushMessageEvent(memberId)
);
}

/**
* <b>디바이스 토큰을 갱신하거나 푸시 비활성화 시 기존 디바이스 토큰으로 구독한 미션 구독 취소</b><br>
* + CancelMissionRetryPushMessageEvent를 통해 예약된 메시지 취소
*
* @param memberId 멤버 아이디
* @param deviceId 디바이스 아이디
* @param deviceToken 기존 디바이스 토큰
*/
@Transactional
public void unsubscribeFromMyMissions(final Long memberId, final Long deviceId, final String deviceToken) {
List<String> topics = findMySubscribedTopics(deviceId);

topics.forEach(topic ->
topicSubscriber.unsubscribeFromTopic(List.of(deviceToken), topic)
);
eventPublisher.publishEvent(
new CancelMissionRetryPushMessageEvent(memberId)
);
}

private List<String> findMySubscribedTopics(final Long deviceId) {
List<DeviceSubscription> subscriptions = deviceSubscriptionRepository.findAllWithMissionAndDeviceByDeviceId(deviceId);

return subscriptions.stream()
.map(it -> TopicGenerator.getTopic(it.getMission().getId()))
.toList();
}

private List<Long> findMySubscribableMission(final Long memberId, List<String> topicFilter) {
List<MissionMember> missionMembers = missionMemberRepository.findAllWithMissionByMemberId(memberId);
List<MissionMember> filteredMissionMembers = missionMembers.stream()
.filter(this::isSubscribableMission)
.filter(it -> isAlreadySubscribedMission(topicFilter, it.getMission().getId()))
.toList();

return filteredMissionMembers.stream()
.map(it -> it.getMission().getId())
.collect(Collectors.toList());
}

private boolean isSubscribableMission(final MissionMember missionMember) {
return SUBSCRIBABLE_MISSION_STATUS.contains(missionMember.getMissionStatus());
}

private boolean isAlreadySubscribedMission(List<String> filter, final Long missionId) {
return filter.contains(TopicGenerator.getTopic(missionId));
}

/**
* <b>취소/완료 상태의 미션을 찾아 이벤트 게시</b><br>
* - 취소/완료 상태 : UnsubscribeFromMissionEvent를 게시하여 미션 구독 취소<br>
* - 완료 상태 : ReserveMissionRetryPushMessageEvent를 게시하여 메시지 예약
*/
@Transactional
public void unsubscribeFromUselessMissions() {
List<Mission> missions = missionRepository.getInProgressMissions();

missions.forEach(mission -> {
List<MissionMember> missionMembers = missionMemberRepository.findAllWithMemberByMissionId(mission.getId());

if (isCancelledMission(missionMembers) || isCompletedMission(missionMembers)) {
eventPublisher.publishEvent(
new UnsubscribeFromMissionEvent(mission.getId())
);
if (isCompletedMission(missionMembers)) {
missionMembers.forEach(missionMember ->
reserveRetryPushMessageForMember(missionMember.getMember())
);
}
}
});
}

private boolean isCancelledMission(final List<MissionMember> missionMembers) {
return missionMembers.stream()
.anyMatch(it -> it.getMissionStatus().equals(CANCELED));
}

private boolean isCompletedMission(final List<MissionMember> missionMembers) {
return missionMembers.stream()
.anyMatch(it -> it.getMissionStatus().equals(COMPLETED));
}

private void reserveRetryPushMessageForMember(final Member member) {
Devices devices = new Devices(
deviceRepository.findAllByMemberId(member.getId())
);

devices.getActivatedDeviceTokens()
.forEach(deviceToken ->
eventPublisher.publishEvent(
new ReserveMissionRetryPushMessageEvent(member.getId(), deviceToken)
)
);
}
}
Loading

0 comments on commit 79abfac

Please sign in to comment.