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

feat: fcm 푸시 알림 스케줄러에 등록 #89

Merged
merged 16 commits into from
Nov 10, 2024
Merged
Show file tree
Hide file tree
Changes from 10 commits
Commits
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 @@ -20,9 +20,7 @@
import org.springframework.transaction.event.TransactionPhase;
import org.springframework.transaction.event.TransactionalEventListener;

import java.util.List;

import static com.nexters.goalpanzi.application.firebase.PushNotificationMessage.*;
import static com.nexters.goalpanzi.domain.firebase.PushNotificationMessage.*;

@Slf4j
@Component
Expand All @@ -37,7 +35,7 @@ public class MissionMemberEventHandler {
@TransactionalEventListener(phase = TransactionPhase.BEFORE_COMMIT)
void handleCreateMissionEvent(final CreateMissionEvent event) {
missionMemberService.joinMission(event.memberId(), new InvitationCode(event.invitationCode()));
log.info("Handled JoinMissionEvent for memberId: {}", event.memberId());
log.info("Handled CreateMissionEvent for memberId: {}", event.memberId());
kimyu0218 marked this conversation as resolved.
Show resolved Hide resolved
}

@Async
Expand All @@ -55,7 +53,7 @@ void handleDeleteMemberEvent(final DeleteMemberEvent event) {
void handleDeleteMissionEvent(final DeleteMissionEvent event) {
missionMemberService.deleteAllByMissionId(event.missionId());
missionVerificationService.deleteAllByMissionId(event.missionId());

pushNotificationSender.sendGroupMessage(
MISSION_DELETED.getTitle(),
MISSION_DELETED.getBody(),
Expand All @@ -68,13 +66,11 @@ void handleDeleteMissionEvent(final DeleteMissionEvent event) {
@Async
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
void handleJoinMissionEvent(final JoinMissionEvent event) {
String topic = TopicGenerator.getTopic(event.missionId());
pushNotificationSender.sendGroupMessage(
pushNotificationSender.sendIndividualMessage(
MISSION_JOINED.getTitle(),
MISSION_JOINED.getBody(event.nickname()),
topic
event.deviceToken()
);
topicSubscriber.subscribeToTopic(List.of(event.deviceToken()), topic);

log.info("Handled JoinMissionEvent for missionId: {}", event.missionId());
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
package com.nexters.goalpanzi.common.aop;

import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.time.StopWatch;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.springframework.stereotype.Component;

@Slf4j
@Aspect
@Component
public class JobLoggingAspect {
kimyu0218 marked this conversation as resolved.
Show resolved Hide resolved

@Around("execution(* com.nexters.goalpanzi.schedule.*.executeInternal(..))")
public void execute(final ProceedingJoinPoint joinPoint) throws Throwable {
String jobName = joinPoint.getTarget().getClass().getSimpleName();

log.info("{} started.", jobName);

StopWatch stopWatch = new StopWatch();
stopWatch.start();

try {
joinPoint.proceed();
} catch (Exception e) {
log.error("Error occurred while executing {}", jobName, e);
}

stopWatch.stop();
log.info("{} finished. Elapsed time: {} ms", jobName, 0);
}
}
9 changes: 9 additions & 0 deletions src/main/java/com/nexters/goalpanzi/config/AopConfig.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package com.nexters.goalpanzi.config;

import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.EnableAspectJAutoProxy;

@Configuration
@EnableAspectJAutoProxy
kimyu0218 marked this conversation as resolved.
Show resolved Hide resolved
public class AopConfig {
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package com.nexters.goalpanzi.application.firebase;
package com.nexters.goalpanzi.domain.firebase;

import lombok.AllArgsConstructor;
import lombok.NoArgsConstructor;
Expand All @@ -14,7 +14,7 @@ public enum PushNotificationMessage {

// 미션 진행 중
MISSION_VERIFICATION_WARNING("\u23F0 마감임박! 1시간 남았어요!\uD83E\uDDE8\uD83D\uDCA5", "지금 인증 안 하면 오늘은 인증 실패!ㅠㅠ"),
MISSION_VERIFIED("˗ˋˏ 와 ˎˊ˗ %s명이 벌써 인증 완료 ˗ˋˏ 와 ˎˊ˗ ", "지금 누가 앞서가는지 확인해볼까요?"),
MISSION_VERIFIED("˗ˋˏ 와 ˎˊ˗ %d명이 벌써 인증 완료 ˗ˋˏ 와 ˎˊ˗ ", "지금 누가 앞서가는지 확인해볼까요?"),
MISSION_NO_ONE_VERIFIED("잊었니?..\uD83C\uDF42", "아직 아무도 인증 안 했어요! 1빠로 인증해 모두를 앞서갈 타이밍!"),
MISSION_COMPLETED("아니 글쎄..걔가 결국 1등 했다고?! \uD83D\uDDEF\uFE0F", "첫 번째 미션 완수자 등장! 빠르게 확인해 보세요!"),
MISSION_DELETED("뭐? 미션 끝났다고? 너 누군데? \uD83D\uDC40", "방장이 미션을 끝냈어요! 다음 미션에서 새롭게 만나요!"),
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package com.nexters.goalpanzi.domain.firebase;

import lombok.Getter;

@Getter
public enum PushTime {
MORNING(9),
AFTERNOON(15),
EVERYDAY(15);

private final int hour;

PushTime(final int hour) {
this.hour = hour;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,18 @@

import com.nexters.goalpanzi.common.time.TimeUtil;
import com.nexters.goalpanzi.domain.common.BaseEntity;
import com.nexters.goalpanzi.domain.firebase.PushTime;
import com.nexters.goalpanzi.infrastructure.jpa.DaysOfWeekConverter;
import jakarta.persistence.*;
import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor;
import org.hibernate.annotations.SQLRestriction;
import org.joda.time.LocalTime;

import java.time.Duration;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.LocalTime;
import java.util.List;
import java.util.Objects;

Expand Down Expand Up @@ -147,6 +149,23 @@ public LocalDateTime getMissionUploadEndDateTime() {
);
}

public boolean isReadyTime() {
kimyu0218 marked this conversation as resolved.
Show resolved Hide resolved
LocalDateTime startTime = LocalDateTime.of(this.missionStartDate.toLocalDate(), LocalTime.parse(this.uploadStartTime));
Duration duration = Duration.between(startTime, LocalDate.now());

return duration.isNegative() && duration.toHours() <= 1;
}

public boolean isPushTime(final int hour) {
if (this.uploadStartTime.equals(TimeOfDay.MORNING.getStartTime())) {
return hour == PushTime.MORNING.getHour();
}
if (this.uploadStartTime.equals(TimeOfDay.AFTERNOON.getStartTime())) {
return hour == PushTime.AFTERNOON.getHour();
}
return hour == PushTime.EVERYDAY.getHour();
}

@Override
public boolean equals(final Object o) {
if (this == o) return true;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package com.nexters.goalpanzi.domain.mission;

import lombok.Getter;

@Getter
public enum MissionMemberCount {
MIN(2);

private final int count;

MissionMemberCount(final int count) {
this.count = count;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -44,11 +44,11 @@ public static MissionStatus fromMission(
return CREATED;
}

if (mission.isMissionPeriod() && currentMemberCount <= 1) {
if (mission.isMissionPeriod() && currentMemberCount < MissionMemberCount.MIN.getCount()) {
kimyu0218 marked this conversation as resolved.
Show resolved Hide resolved
return CANCELED;
}

if (mission.isMissionPeriod() && currentMemberCount > 1) {
if (mission.isMissionPeriod() && currentMemberCount >= MissionMemberCount.MIN.getCount()) {
return IN_PROGRESS;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,11 @@
import com.nexters.goalpanzi.domain.mission.MissionMember;
import com.nexters.goalpanzi.exception.ErrorCode;
import com.nexters.goalpanzi.exception.NotFoundException;
import jakarta.persistence.LockModeType;
import org.springframework.data.domain.Sort;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Lock;
import org.springframework.data.jpa.repository.Query;
import org.springframework.stereotype.Repository;

import java.util.List;
import java.util.Optional;
Expand All @@ -21,6 +22,7 @@ public interface MissionMemberRepository extends JpaRepository<MissionMember, Lo
@Query("SELECT mm FROM MissionMember mm JOIN FETCH mm.mission WHERE mm.member.id = :memberId")
List<MissionMember> findAllWithMissionByMemberId(final Long memberId);

@Lock(LockModeType.PESSIMISTIC_WRITE)
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

미션 인증 2번 가능한 문제 해결하려고 lock 걸었습니다..!

    @Transactional
    public void createVerification(final CreateMissionVerificationCommand command) {
        MissionMember missionMember = missionMemberRepository.getMissionMember(command.memberId(), command.missionId());

        missionVerificationValidator.validate(missionMember);

        String imageUrl = objectStorageClient.uploadFile(command.imageFile());
        missionMember.verify();
        missionVerificationRepository.save(new MissionVerification(missionMember.getMember(), missionMember.getMission(), imageUrl, missionMember.getVerificationCount()));
    }

커넥션이 여러 개라서 2개 이상의 커넥션이 같은 상태의 missionMember 레코드를 읽는 게 원인인 것 같습니다 (그래서 유효성 검사를 해도 insert가 되는,,,)

더 나은 방법이나 오류 사항이 있으면 편하게 말씀해주세요 🙏

Copy link
Member

@songyi00 songyi00 Nov 10, 2024

Choose a reason for hiding this comment

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

우리 Redis 쓰고 있으니까 레디스 락 걸어보는 것도 괜찮을 것 같앙
추후에 계속해서 사용될 수 있는 쿼리 같아서 DB 락 보다는 필요한 곳에 레디스 락 거는게 조금 더 가볍지 않을까 생각하는데 지금 당장 중요한 부분은 아니라 백로그로 남겨도 좋을 것 같아!

default MissionMember getMissionMember(final Long memberId, final Long missionId) {
return findByMemberIdAndMissionId(memberId, missionId)
.orElseThrow(() -> new NotFoundException(ErrorCode.NOT_JOINED_MISSION_MEMBER));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,19 +2,35 @@

import com.nexters.goalpanzi.domain.mission.InvitationCode;
import com.nexters.goalpanzi.domain.mission.Mission;
import com.nexters.goalpanzi.exception.BaseException;
import com.nexters.goalpanzi.exception.ErrorCode;
import com.nexters.goalpanzi.exception.NotFoundException;
import org.springframework.data.jpa.repository.JpaRepository;

import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Optional;

public interface MissionRepository extends JpaRepository<Mission, Long> {

Optional<Mission> findByInvitationCode(final InvitationCode invitationCode);

List<Mission> findByMissionStartDateGreaterThanEqual(final LocalDateTime todayStart);

List<Mission> findByMissionStartDateGreaterThanEqualAndMissionEndDateLessThanEqual(final LocalDateTime startDate, final LocalDateTime endDate);

default Mission getMission(final Long missionId) {
return findById(missionId)
.orElseThrow(() -> new NotFoundException(ErrorCode.NOT_FOUND_MISSION, missionId));
}

default List<Mission> getReadyMissions() {
LocalDateTime todayStart = LocalDate.now().atStartOfDay();
return findByMissionStartDateGreaterThanEqual(todayStart);
}

default List<Mission> getInProgressMissions() {
LocalDateTime todayStart = LocalDate.now().atStartOfDay();
return findByMissionStartDateGreaterThanEqualAndMissionEndDateLessThanEqual(todayStart, todayStart);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
package com.nexters.goalpanzi.schedule;

import com.nexters.goalpanzi.application.firebase.TopicGenerator;
import com.nexters.goalpanzi.domain.mission.Mission;
import com.nexters.goalpanzi.domain.mission.MissionMember;
import com.nexters.goalpanzi.domain.mission.MissionMemberCount;
import com.nexters.goalpanzi.domain.mission.repository.MissionMemberRepository;
import com.nexters.goalpanzi.domain.mission.repository.MissionRepository;
import com.nexters.goalpanzi.infrastructure.firebase.PushNotificationSender;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.quartz.*;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;

import java.util.List;

import static com.nexters.goalpanzi.domain.firebase.PushNotificationMessage.MISSION_CANCELLATION_WARNING;

@Slf4j
@RequiredArgsConstructor
@DisallowConcurrentExecution
@Component
public class MissionCancellationWarningPushJob extends AbstractJob<CronTrigger> implements CustomAutomationJob {

private final MissionRepository missionRepository;
kimyu0218 marked this conversation as resolved.
Show resolved Hide resolved
private final MissionMemberRepository missionMemberRepository;

private final PushNotificationSender pushNotificationSender;

@Override
protected ScheduleBuilder<CronTrigger> getScheduleBuilder() {
// 11:30, 23:30 마다 실행
return CronScheduleBuilder.cronSchedule("0 30 11,23 * * ?")
.withMisfireHandlingInstructionDoNothing();
}

@Override
@Transactional
protected void executeInternal(JobExecutionContext context) throws JobExecutionException {
List<Mission> missions = missionRepository.getReadyMissions();
missions.forEach(mission -> {
if (mission.isReadyTime() && !hasEnoughMember(mission.getId())) {
kimyu0218 marked this conversation as resolved.
Show resolved Hide resolved
String topic = TopicGenerator.getTopic(mission.getId());
pushNotificationSender.sendGroupMessage(
MISSION_CANCELLATION_WARNING.getTitle(),
MISSION_CANCELLATION_WARNING.getBody(),
topic
);
}
});
}

private boolean hasEnoughMember(final Long missionId) {
List<MissionMember> missionMembers = missionMemberRepository.findAllByMissionId(missionId);
int memberCount = missionMembers.size();

return memberCount >= MissionMemberCount.MIN.getCount();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
package com.nexters.goalpanzi.schedule;

import com.nexters.goalpanzi.application.firebase.TopicGenerator;
import com.nexters.goalpanzi.domain.mission.Mission;
import com.nexters.goalpanzi.domain.mission.MissionMember;
import com.nexters.goalpanzi.domain.mission.MissionMemberCount;
import com.nexters.goalpanzi.domain.mission.repository.MissionMemberRepository;
import com.nexters.goalpanzi.domain.mission.repository.MissionRepository;
import com.nexters.goalpanzi.infrastructure.firebase.PushNotificationSender;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.quartz.*;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;

import java.util.List;

import static com.nexters.goalpanzi.domain.firebase.PushNotificationMessage.MISSION_READY;

@Slf4j
@RequiredArgsConstructor
@DisallowConcurrentExecution
@Component
public class MissionReadyPushJob extends AbstractJob<CronTrigger> implements CustomAutomationJob {

private final MissionRepository missionRepository;
private final MissionMemberRepository missionMemberRepository;

private final PushNotificationSender pushNotificationSender;

@Override
protected ScheduleBuilder<CronTrigger> getScheduleBuilder() {
// 11:00, 23:00 마다 실행
return CronScheduleBuilder.cronSchedule("0 0 11,23 * * ?")
.withMisfireHandlingInstructionDoNothing();
}

@Override
@Transactional
protected void executeInternal(final JobExecutionContext context) throws JobExecutionException {
List<Mission> missions = missionRepository.getReadyMissions();
missions.forEach(mission -> {
if (mission.isReadyTime() && hasEnoughMember(mission.getId())) {
String topic = TopicGenerator.getTopic(mission.getId());
pushNotificationSender.sendGroupMessage(
MISSION_READY.getTitle(),
MISSION_READY.getBody(),
topic
);
}
});
}

private boolean hasEnoughMember(final Long missionId) {
List<MissionMember> missionMembers = missionMemberRepository.findAllByMissionId(missionId);
int memberCount = missionMembers.size();

return memberCount >= MissionMemberCount.MIN.getCount();
}
}
Loading
Loading