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

Batch: ✨ 매월 목표 금액 설정 공지 푸시 알림 배치 #141

Merged
merged 9 commits into from
Jul 25, 2024
Original file line number Diff line number Diff line change
Expand Up @@ -3,43 +3,46 @@
import kr.co.pennyway.domain.domains.notification.type.Announcement;
import lombok.Builder;

import java.time.LocalDateTime;
import java.util.HashSet;
import java.util.List;
import java.util.Objects;
import java.util.Set;

@Builder
public record DailySpendingNotification(
public record AnnounceNotificationDto(
Long userId,
String title,
String content,
Set<String> deviceTokens
) {
public DailySpendingNotification {
public AnnounceNotificationDto {
Objects.requireNonNull(userId, "userId must not be null");
Objects.requireNonNull(title, "title must not be null");
Objects.requireNonNull(content, "content must not be null");
Objects.requireNonNull(deviceTokens, "deviceTokens must not be null");
}

/**
* {@link DeviceTokenOwner}를 DailySpendingNotification DTO로 변환하는 정적 팩토리 메서드
* <p>
* DeviceToken은 List로 변환되어 멤버 변수로 관리하게 된다.
*/
public static DailySpendingNotification from(DeviceTokenOwner owner) {
Announcement announcement = Announcement.DAILY_SPENDING;
public static AnnounceNotificationDto from(DeviceTokenOwner owner, Announcement announcement) {
Set<String> deviceTokens = new HashSet<>();
deviceTokens.add(owner.deviceToken());

return DailySpendingNotification.builder()
return AnnounceNotificationDto.builder()
.userId(owner.userId())
.title(announcement.createFormattedTitle(owner.name()))
.content(announcement.getContent())
.title(createFormattedTitle(owner, announcement))
.content(announcement.createFormattedContent(owner.name()))
.deviceTokens(deviceTokens)
.build();
}

private static String createFormattedTitle(DeviceTokenOwner owner, Announcement announcement) {
if (announcement.equals(Announcement.MONTHLY_TARGET_AMOUNT)) {
return announcement.createFormattedTitle(String.valueOf(LocalDateTime.now().getMonthValue()));
}

return announcement.createFormattedTitle(owner.name());
}

public void addDeviceToken(String deviceToken) {
deviceTokens.add(deviceToken);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

import kr.co.pennyway.batch.common.dto.DeviceTokenOwner;
import kr.co.pennyway.batch.reader.ActiveDeviceTokenReader;
import kr.co.pennyway.batch.writer.NotificationWriter;
import kr.co.pennyway.batch.writer.DailySpendingNotifyWriter;
import lombok.RequiredArgsConstructor;
import org.springframework.batch.core.Job;
import org.springframework.batch.core.Step;
Expand All @@ -19,7 +19,7 @@
public class DailySpendingNotifyConfig {
private final JobRepository jobRepository;
private final ActiveDeviceTokenReader reader;
private final NotificationWriter writer;
private final DailySpendingNotifyWriter writer;

@Bean
public Job dailyNotificationJob(PlatformTransactionManager transactionManager) {
psychology50 marked this conversation as resolved.
Show resolved Hide resolved
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
package kr.co.pennyway.batch.job;

import kr.co.pennyway.batch.common.dto.DeviceTokenOwner;
import kr.co.pennyway.batch.reader.ActiveDeviceTokenReader;
import kr.co.pennyway.batch.writer.MonthlyTotalAmountNotifyWriter;
import lombok.RequiredArgsConstructor;
import org.springframework.batch.core.Job;
import org.springframework.batch.core.Step;
import org.springframework.batch.core.configuration.annotation.JobScope;
import org.springframework.batch.core.job.builder.JobBuilder;
import org.springframework.batch.core.repository.JobRepository;
import org.springframework.batch.core.step.builder.StepBuilder;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.transaction.PlatformTransactionManager;

@Configuration
@RequiredArgsConstructor
public class MonthlyTargetAmountNotifyConfig {
private final JobRepository jobRepository;
private final ActiveDeviceTokenReader reader;
private final MonthlyTotalAmountNotifyWriter writer;

@Bean
public Job monthlyNotificationJob(PlatformTransactionManager transactionManager) {
return new JobBuilder("monthlyNotificationJob", jobRepository)
.start(monthlyNotificationStep(transactionManager))
.on("FAILED")
.stopAndRestart(monthlyNotificationStep(transactionManager))
.on("*")
.end()
.end()
.build();
}

@Bean
@JobScope
public Step monthlyNotificationStep(PlatformTransactionManager transactionManager) {
return new StepBuilder("sendMonthlyNotifyStep", jobRepository)
.<DeviceTokenOwner, DeviceTokenOwner>chunk(1000, transactionManager)
.reader(reader.querydslNoOffsetPagingItemReader())
.writer(writer)
.build();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
public class SpendingNotifyScheduler {
private final JobLauncher jobLauncher;
private final Job dailyNotificationJob;
private final Job monthlyNotificationJob;

@Scheduled(cron = "0 0 20 * * ?")
public void runDailyNotificationJob() {
Expand All @@ -33,4 +34,18 @@ public void runDailyNotificationJob() {
log.error("Failed to run dailyNotificationJob", e);
}
}

@Scheduled(cron = "0 0 10 1 * ?")
public void runMonthlyNotificationJob() {
JobParameters jobParameters = new JobParametersBuilder()
.addLong("time", System.currentTimeMillis())
.toJobParameters();

try {
jobLauncher.run(monthlyNotificationJob, jobParameters);
} catch (JobExecutionAlreadyRunningException | JobRestartException
| JobInstanceAlreadyCompleteException | JobParametersInvalidException e) {
log.error("Failed to run monthlyNotificationJob", e);
}
}
}
psychology50 marked this conversation as resolved.
Show resolved Hide resolved
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
package kr.co.pennyway.batch.writer;

import kr.co.pennyway.batch.common.dto.DailySpendingNotification;
import kr.co.pennyway.batch.common.dto.AnnounceNotificationDto;
import kr.co.pennyway.batch.common.dto.DeviceTokenOwner;
import kr.co.pennyway.domain.domains.notification.repository.NotificationRepository;
import kr.co.pennyway.domain.domains.notification.type.Announcement;
Expand All @@ -23,7 +23,7 @@
@Slf4j
@Component
@RequiredArgsConstructor
public class NotificationWriter implements ItemWriter<DeviceTokenOwner> {
public class DailySpendingNotifyWriter implements ItemWriter<DeviceTokenOwner> {
private final NotificationRepository notificationRepository;
private final ApplicationEventPublisher publisher;

Expand All @@ -33,17 +33,17 @@ public class NotificationWriter implements ItemWriter<DeviceTokenOwner> {
public void write(@NonNull Chunk<? extends DeviceTokenOwner> owners) throws Exception {
log.info("Writer 실행: {}", owners.size());

Map<Long, DailySpendingNotification> notificationMap = new HashMap<>();
Map<Long, AnnounceNotificationDto> notificationMap = new HashMap<>();

for (DeviceTokenOwner owner : owners) {
notificationMap.computeIfAbsent(owner.userId(), k -> DailySpendingNotification.from(owner)).addDeviceToken(owner.deviceToken());
notificationMap.computeIfAbsent(owner.userId(), k -> AnnounceNotificationDto.from(owner, Announcement.DAILY_SPENDING)).addDeviceToken(owner.deviceToken());
}

List<Long> userIds = new ArrayList<>(notificationMap.keySet());

notificationRepository.saveDailySpendingAnnounceInBulk(userIds, Announcement.DAILY_SPENDING);

for (DailySpendingNotification notification : notificationMap.values()) {
for (AnnounceNotificationDto notification : notificationMap.values()) {
publisher.publishEvent(NotificationEvent.of(notification.title(), notification.content(), notification.deviceTokensForList(), ""));
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
package kr.co.pennyway.batch.writer;

import kr.co.pennyway.batch.common.dto.AnnounceNotificationDto;
import kr.co.pennyway.batch.common.dto.DeviceTokenOwner;
import kr.co.pennyway.domain.domains.notification.repository.NotificationRepository;
import kr.co.pennyway.domain.domains.notification.type.Announcement;
import kr.co.pennyway.infra.common.event.NotificationEvent;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.batch.core.configuration.annotation.StepScope;
import org.springframework.batch.item.Chunk;
import org.springframework.batch.item.ItemWriter;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.lang.NonNull;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;

import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

@Slf4j
@Component
@RequiredArgsConstructor
public class MonthlyTotalAmountNotifyWriter implements ItemWriter<DeviceTokenOwner> {
private final NotificationRepository notificationRepository;
private final ApplicationEventPublisher publisher;

@Override
@StepScope
@Transactional
public void write(@NonNull Chunk<? extends DeviceTokenOwner> owners) throws Exception {
log.info("Writer 실행: {}", owners.size());

Map<Long, AnnounceNotificationDto> notificationMap = new HashMap<>();

for (DeviceTokenOwner owner : owners) {
notificationMap.computeIfAbsent(owner.userId(), k -> AnnounceNotificationDto.from(owner, Announcement.MONTHLY_TARGET_AMOUNT)).addDeviceToken(owner.deviceToken());
}

List<Long> userIds = new ArrayList<>(notificationMap.keySet());

notificationRepository.saveDailySpendingAnnounceInBulk(userIds, Announcement.MONTHLY_TARGET_AMOUNT);

for (AnnounceNotificationDto notification : notificationMap.values()) {
publisher.publishEvent(NotificationEvent.of(notification.title(), notification.content(), notification.deviceTokensForList(), ""));
}
}
}
psychology50 marked this conversation as resolved.
Show resolved Hide resolved
Original file line number Diff line number Diff line change
Expand Up @@ -72,10 +72,19 @@ public String toString() {
* @apiNote 이 메서드는 {@link NoticeType#ANNOUNCEMENT} 타입에 대해서만 동작한다. 다른 타입의 알림을 포맷팅해야 하는 경우 해당 메서드를 확장해야 한다.
*/
public String createFormattedTitle() {
if (type.equals(NoticeType.ANNOUNCEMENT)) {
return announcement.createFormattedTitle(receiverName);
if (!type.equals(NoticeType.ANNOUNCEMENT)) {
return ""; // TODO: 알림 종류가 신규로 추가될 때, 해당 로직을 구현해야 함.
}
return ""; // TODO: 알림 종류가 신규로 추가될 때, 해당 로직을 구현해야 함.

return formatAnnouncementTitle();
}

private String formatAnnouncementTitle() {
if (announcement.equals(Announcement.MONTHLY_TARGET_AMOUNT)) {
return announcement.createFormattedTitle(String.valueOf(getCreatedAt().getMonthValue()));
}

return announcement.createFormattedTitle(receiverName);
}

/**
Expand All @@ -86,10 +95,11 @@ public String createFormattedTitle() {
* @apiNote 이 메서드는 {@link NoticeType#ANNOUNCEMENT} 타입에 대해서만 동작한다. 다른 타입의 알림을 포맷팅해야 하는 경우 해당 메서드를 확장해야 한다.
*/
public String createFormattedContent() {
if (type.equals(NoticeType.ANNOUNCEMENT)) {
return announcement.createFormattedContent(receiverName);
if (!type.equals(NoticeType.ANNOUNCEMENT)) {
return ""; // TODO: 알림 종류가 신규로 추가될 때, 해당 로직을 구현해야 함.
}
return ""; // TODO: 알림 종류가 신규로 추가될 때, 해당 로직을 구현해야 함.

return announcement.createFormattedContent(receiverName);
}

public static class Builder {
psychology50 marked this conversation as resolved.
Show resolved Hide resolved
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ public enum Announcement implements LegacyCommonType {

// 정기 지출 알림
DAILY_SPENDING("1", "%s님, 3분 카레보다 빨리 끝나요!", "많은 친구들이 소비 기록에 참여하고 있어요👀"),
MONTHLY_TARGET_AMOUNT("2", "6월의 첫 시작! 두구두구..🥁", "%s님의 이번 달 목표 소비 금액은?");
MONTHLY_TARGET_AMOUNT("2", "%s월의 첫 시작! 두구두구..🥁", "%s님의 이번 달 목표 소비 금액은?");

private final String code;
private final String title;
Expand Down Expand Up @@ -62,5 +62,9 @@ private void validateName(String name) {
if (!StringUtils.hasText(name)) {
throw new IllegalArgumentException("name must not be empty");
}

if (this == NOT_ANNOUNCE) {
throw new IllegalArgumentException("NOT_ANNOUNCE type is not allowed");
}
}
}
psychology50 marked this conversation as resolved.
Show resolved Hide resolved
Loading