Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

refactor: fcm 푸시 알림 리팩토링 #110

Merged
merged 25 commits into from
Dec 19, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
1fdc650
feat: 디바이스 관련 엔티티 정의
kimyu0218 Dec 15, 2024
7ad3718
chore: DB 마이그레이션 파일 작성
kimyu0218 Dec 17, 2024
80b4347
rename: PushNotificationEventHandler -> PushMessageEventHandler
kimyu0218 Dec 17, 2024
b3742a1
feat: deviceIdentifier 추가
kimyu0218 Dec 17, 2024
b678fc4
refactor: DTO 및 디바이스 엔티티 추가에 따른 수정
kimyu0218 Dec 17, 2024
59ed736
feat: Device 엔티티 추가 및 Member 엔티티 수정
kimyu0218 Dec 17, 2024
a75585f
refactor: 함수 분리 및 로직 변경에 따른 수정
kimyu0218 Dec 17, 2024
32f503f
test: DeviceSubscription 관련 테스트 작성
kimyu0218 Dec 17, 2024
f8201bd
remove: 불필요한 코드 삭제
kimyu0218 Dec 17, 2024
f656040
refactor: MissionRetryPushMessageService 분리
kimyu0218 Dec 17, 2024
7c73af2
feat: 멀티 디바이스 및 다중 로그인 지원을 위한 redis 저장소 수정
kimyu0218 Dec 17, 2024
bc7cdee
refactor: deprecatedDeviceToken -> deviceIdentifier
kimyu0218 Dec 17, 2024
66abd52
feat: 디바이스 일급 컬렉션 작성
kimyu0218 Dec 17, 2024
a787baf
chore: 디바이스 관련 에러 코드 추가
kimyu0218 Dec 17, 2024
c153574
move: firebase -> device
kimyu0218 Dec 17, 2024
11e2ae4
refactor: MissionMemberService 로직의 구독 부분 DeviceSubscriptionService로 분리
kimyu0218 Dec 17, 2024
baf811a
refactor: 구독 의존성 개선
kimyu0218 Dec 17, 2024
003f532
refactor: data 메시지 -> notification+data 혼합 메시지
kimyu0218 Dec 19, 2024
66eff4e
test: 메시지 타입 변경에 따른 테스트 수정
kimyu0218 Dec 19, 2024
6a9a84a
refactor: deviceIdentifier & osType 추가
kimyu0218 Dec 19, 2024
4c947ae
refactor: member, device 도메인 분리
kimyu0218 Dec 19, 2024
b941ce6
chore: 유효성 검사 추가
kimyu0218 Dec 19, 2024
bee5757
fix: 유효성 검사 수정
kimyu0218 Dec 19, 2024
44d1a00
test: MissionRetryMessageRepository 테스트
kimyu0218 Dec 19, 2024
2c0eb79
refactor: 이전 버전 지원, 함수 중복 제거
kimyu0218 Dec 19, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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());
}
}
Comment on lines +27 to +35
Copy link
Collaborator Author

@kimyu0218 kimyu0218 Dec 20, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Warning

PATCH 시 insert가 발생합니다

  • updateDeviceToken이 실행되었을 때 device 테이블을 확인하여 insert나 update를 수행하고 있습니다.
  • insert가 POST가 아닌 PATCH에서 발생하게 되는데 404를 내려주고 createDevice를 실행하도록 하는 것이 좋을까요?


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
Loading