diff --git a/doochul/build.gradle b/doochul/build.gradle index 81f9f50..bd981b9 100644 --- a/doochul/build.gradle +++ b/doochul/build.gradle @@ -26,13 +26,14 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-web' implementation 'org.springframework.boot:spring-boot-starter-webflux' implementation 'org.springframework.boot:spring-boot-starter-data-redis' - + implementation 'org.springframework.boot:spring-boot-starter-batch' implementation 'com.google.firebase:firebase-admin:9.2.0' - runtimeOnly 'mysql:mysql-connector-java' + runtimeOnly 'mysql:mysql-connector-java:8.0.28' runtimeOnly 'com.h2database:h2' compileOnly 'org.projectlombok:lombok' annotationProcessor 'org.projectlombok:lombok' + testImplementation 'org.springframework.batch:spring-batch-test' testImplementation 'org.springframework.boot:spring-boot-starter-test' } diff --git a/doochul/src/main/java/org/doochul/service/MessageSendManager.java b/doochul/src/main/java/org/doochul/application/MessageSendManager.java similarity index 52% rename from doochul/src/main/java/org/doochul/service/MessageSendManager.java rename to doochul/src/main/java/org/doochul/application/MessageSendManager.java index 9845d1b..c965f87 100644 --- a/doochul/src/main/java/org/doochul/service/MessageSendManager.java +++ b/doochul/src/main/java/org/doochul/application/MessageSendManager.java @@ -1,4 +1,6 @@ -package org.doochul.service; +package org.doochul.application; + +import org.doochul.infra.dto.Letter; public interface MessageSendManager { void sendTo(final Letter letter); diff --git a/doochul/src/main/java/org/doochul/service/NotificationEventListener.java b/doochul/src/main/java/org/doochul/application/NotificationEventListener.java similarity index 83% rename from doochul/src/main/java/org/doochul/service/NotificationEventListener.java rename to doochul/src/main/java/org/doochul/application/NotificationEventListener.java index d76f5d1..3bc2cf1 100644 --- a/doochul/src/main/java/org/doochul/service/NotificationEventListener.java +++ b/doochul/src/main/java/org/doochul/application/NotificationEventListener.java @@ -1,7 +1,9 @@ -package org.doochul.service; +package org.doochul.application; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.doochul.application.event.LessonCreateEvent; +import org.doochul.application.event.LessonWithdrawnEvent; import org.springframework.context.event.EventListener; import org.springframework.scheduling.annotation.Async; import org.springframework.stereotype.Component; diff --git a/doochul/src/main/java/org/doochul/application/NotificationService.java b/doochul/src/main/java/org/doochul/application/NotificationService.java new file mode 100644 index 0000000..0be0f89 --- /dev/null +++ b/doochul/src/main/java/org/doochul/application/NotificationService.java @@ -0,0 +1,56 @@ +package org.doochul.application; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.doochul.domain.lesson.Lesson; +import org.doochul.domain.user.User; +import org.doochul.application.event.LessonCreateEvent; +import org.doochul.application.event.LessonWithdrawnEvent; +import org.doochul.infra.dto.Letter; +import org.doochul.support.KeyGenerator; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Service; + +import java.time.Duration; + +import static org.doochul.domain.lesson.LessonStatus.SCHEDULED_LESSON; +import static org.doochul.domain.lesson.LessonStatus.WITHDRAWN_LESSON; + +@Slf4j +@Service +@RequiredArgsConstructor +public class NotificationService { + private final MessageSendManager messageSendManager; + private final KeyGenerator keyGenerator; + private final RedisService redisService; + + public void applyForLesson(final LessonCreateEvent event) { + final User student = event.student(); + final User teacher = event.teacher(); + final Lesson lesson = event.lesson(); + sendNotification( + Letter.of(student.getDeviceToken(), + student.getName(), + teacher.getName(), + lesson.getStartedAt(), + SCHEDULED_LESSON)); + } + + public void withdrawnForLessons(final LessonWithdrawnEvent event) { + sendNotification( + Letter.of(event.student().getDeviceToken(), + event.student().getName(), + event.teacher().getName(), + event.lesson().getStartedAt(), + WITHDRAWN_LESSON)); + } + + @Async + public void sendNotification(final Letter letter) { + final String key = keyGenerator.generateAccountKey(letter.targetToken()); + if (redisService.setNX(key, "notification", Duration.ofSeconds(5))) { + messageSendManager.sendTo(letter); + redisService.delete(key); + } + } +} diff --git a/doochul/src/main/java/org/doochul/application/RedisService.java b/doochul/src/main/java/org/doochul/application/RedisService.java index 5bd1be3..5c37dc1 100644 --- a/doochul/src/main/java/org/doochul/application/RedisService.java +++ b/doochul/src/main/java/org/doochul/application/RedisService.java @@ -18,7 +18,6 @@ public void delete(final String key) { redisTemplate.delete(key); } - public boolean setNX(final String key, final String value, final Duration duration) { return Boolean.TRUE.equals(redisTemplate.opsForValue().setIfAbsent(key, value, duration)); } diff --git a/doochul/src/main/java/org/doochul/service/LessonCreateEvent.java b/doochul/src/main/java/org/doochul/application/event/LessonCreateEvent.java similarity index 82% rename from doochul/src/main/java/org/doochul/service/LessonCreateEvent.java rename to doochul/src/main/java/org/doochul/application/event/LessonCreateEvent.java index 9b5fbb2..9408cd2 100644 --- a/doochul/src/main/java/org/doochul/service/LessonCreateEvent.java +++ b/doochul/src/main/java/org/doochul/application/event/LessonCreateEvent.java @@ -1,4 +1,4 @@ -package org.doochul.service; +package org.doochul.application.event; import org.doochul.domain.lesson.Lesson; import org.doochul.domain.user.User; diff --git a/doochul/src/main/java/org/doochul/service/LessonWithdrawnEvent.java b/doochul/src/main/java/org/doochul/application/event/LessonWithdrawnEvent.java similarity index 82% rename from doochul/src/main/java/org/doochul/service/LessonWithdrawnEvent.java rename to doochul/src/main/java/org/doochul/application/event/LessonWithdrawnEvent.java index d240bdf..98be74b 100644 --- a/doochul/src/main/java/org/doochul/service/LessonWithdrawnEvent.java +++ b/doochul/src/main/java/org/doochul/application/event/LessonWithdrawnEvent.java @@ -1,4 +1,4 @@ -package org.doochul.service; +package org.doochul.application.event; import org.doochul.domain.lesson.Lesson; import org.doochul.domain.user.User; diff --git a/doochul/src/main/java/org/doochul/config/AppConfig.java b/doochul/src/main/java/org/doochul/config/AppConfig.java index 82cab1e..502f671 100644 --- a/doochul/src/main/java/org/doochul/config/AppConfig.java +++ b/doochul/src/main/java/org/doochul/config/AppConfig.java @@ -3,7 +3,6 @@ import java.time.Clock; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; -import org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler; @Configuration public class AppConfig { @@ -12,11 +11,4 @@ public class AppConfig { public Clock clock() { return Clock.systemDefaultZone(); } - - @Bean - public ThreadPoolTaskScheduler taskScheduler() { - ThreadPoolTaskScheduler scheduler = new ThreadPoolTaskScheduler(); - scheduler.setPoolSize(2); - return scheduler; - } } diff --git a/doochul/src/main/java/org/doochul/config/AsyncConfig.java b/doochul/src/main/java/org/doochul/config/AsyncConfig.java new file mode 100644 index 0000000..fed0d58 --- /dev/null +++ b/doochul/src/main/java/org/doochul/config/AsyncConfig.java @@ -0,0 +1,23 @@ +package org.doochul.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.annotation.EnableAsync; +import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; + +import java.util.concurrent.Executor; + +@EnableAsync +@Configuration +public class AsyncConfig { + + @Bean + public Executor taskExecutor() { + ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); + executor.setCorePoolSize(4); + executor.setWaitForTasksToCompleteOnShutdown(true); + executor.setAwaitTerminationSeconds(60); + executor.initialize(); + return executor; + } +} \ No newline at end of file diff --git a/doochul/src/main/java/org/doochul/config/BatchConfig.java b/doochul/src/main/java/org/doochul/config/BatchConfig.java new file mode 100644 index 0000000..17189e7 --- /dev/null +++ b/doochul/src/main/java/org/doochul/config/BatchConfig.java @@ -0,0 +1,19 @@ +package org.doochul.config; + +import org.springframework.batch.core.configuration.JobRegistry; +import org.springframework.batch.core.configuration.annotation.EnableBatchProcessing; +import org.springframework.batch.core.configuration.support.JobRegistryBeanPostProcessor; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@EnableBatchProcessing +@Configuration +public class BatchConfig { + + @Bean + public JobRegistryBeanPostProcessor jobRegistryBeanPostProcessor(JobRegistry jobRegistry) { + JobRegistryBeanPostProcessor jobRegistryBeanPostProcessor = new JobRegistryBeanPostProcessor(); + jobRegistryBeanPostProcessor.setJobRegistry(jobRegistry); + return jobRegistryBeanPostProcessor; + } +} diff --git a/doochul/src/main/java/org/doochul/domain/lesson/Lesson.java b/doochul/src/main/java/org/doochul/domain/lesson/Lesson.java index aca87ee..13542b1 100644 --- a/doochul/src/main/java/org/doochul/domain/lesson/Lesson.java +++ b/doochul/src/main/java/org/doochul/domain/lesson/Lesson.java @@ -12,6 +12,7 @@ import lombok.NoArgsConstructor; import org.doochul.domain.BaseEntity; import org.doochul.domain.membership.MemberShip; +import org.doochul.domain.user.User; @Entity @Getter @@ -26,9 +27,32 @@ public class Lesson extends BaseEntity { @JoinColumn(name = "membership_id") private MemberShip memberShip; + @ManyToOne + @JoinColumn(name = "student_id") + private User student; + + @ManyToOne + @JoinColumn(name = "teacher_id") + private User teacher; + private LocalDateTime startedAt; private LocalDateTime endedAt; private String record; + + public Lesson( + final MemberShip memberShip, + final User student, + final User teacher, + final LocalDateTime startedAt, + final LocalDateTime endedAt + ) { + this.memberShip = memberShip; + this.student = student; + this.teacher = teacher; + this.startedAt = startedAt; + this.endedAt = endedAt; + this.record = null; + } } diff --git a/doochul/src/main/java/org/doochul/domain/lesson/LessonRepository.java b/doochul/src/main/java/org/doochul/domain/lesson/LessonRepository.java index fb3fe1b..af34bc6 100644 --- a/doochul/src/main/java/org/doochul/domain/lesson/LessonRepository.java +++ b/doochul/src/main/java/org/doochul/domain/lesson/LessonRepository.java @@ -11,4 +11,5 @@ default Lesson getById(final Long id) { } List findByStartedAtBefore(final LocalDateTime currentServerTime); + List findOngoingLessons(final LocalDateTime now); } diff --git a/doochul/src/main/java/org/doochul/service/LessonStatus.java b/doochul/src/main/java/org/doochul/domain/lesson/LessonStatus.java similarity index 94% rename from doochul/src/main/java/org/doochul/service/LessonStatus.java rename to doochul/src/main/java/org/doochul/domain/lesson/LessonStatus.java index e47b77f..d5c5c0a 100644 --- a/doochul/src/main/java/org/doochul/service/LessonStatus.java +++ b/doochul/src/main/java/org/doochul/domain/lesson/LessonStatus.java @@ -1,4 +1,6 @@ -package org.doochul.service; +package org.doochul.domain.lesson; + +import lombok.Getter; import java.time.LocalDateTime; import java.time.format.DateTimeFormatter; @@ -18,6 +20,7 @@ public enum LessonStatus { (name, time, teacher) -> formatToLocalTime(time) + " " + name + "님 " + teacher + " 강사님의 수업을 철회했습니다."), END_LESSON("수업 종료", (name, time, teacher) -> teacher + " 강사님의 수업이 모두 종료되었습니다."); + @Getter private final String title; private final LessonStatusMessage message; @@ -30,10 +33,6 @@ private static String formatToLocalTime(LocalDateTime time) { return time.format(DateTimeFormatter.ofPattern("a HH시 mm분")); } - public String getTitle() { - return title; - } - public String getMessage(String name, LocalDateTime time, String teacher) { return message.getMessage(name, time, teacher); } diff --git a/doochul/src/main/java/org/doochul/domain/membership/MemberShip.java b/doochul/src/main/java/org/doochul/domain/membership/MemberShip.java index 382e41b..84d6d74 100644 --- a/doochul/src/main/java/org/doochul/domain/membership/MemberShip.java +++ b/doochul/src/main/java/org/doochul/domain/membership/MemberShip.java @@ -23,7 +23,7 @@ public class MemberShip extends BaseEntity { private Long id; @ManyToOne - @JoinColumn(name = "user_id") + @JoinColumn(name = "student_id") private User student; @ManyToOne @@ -32,6 +32,12 @@ public class MemberShip extends BaseEntity { private Integer remainingCount; + public MemberShip(final User student, final Product product, final Integer remainingCount) { + this.student = student; + this.product = product; + this.remainingCount = remainingCount; + } + public void decreasedCount() { validateMinRemainingCount(); remainingCount -= 1; diff --git a/doochul/src/main/java/org/doochul/domain/product/Product.java b/doochul/src/main/java/org/doochul/domain/product/Product.java index f9ba1b5..e43cd98 100644 --- a/doochul/src/main/java/org/doochul/domain/product/Product.java +++ b/doochul/src/main/java/org/doochul/domain/product/Product.java @@ -33,4 +33,11 @@ public class Product extends BaseEntity { private User teacher; private Integer count; + + public Product(final String name, final ProductType type, final User teacher, final Integer count) { + this.name = name; + this.type = type; + this.teacher = teacher; + this.count = count; + } } diff --git a/doochul/src/main/java/org/doochul/domain/product/ProductRepository.java b/doochul/src/main/java/org/doochul/domain/product/ProductRepository.java index d5a3e8e..c62f8c0 100644 --- a/doochul/src/main/java/org/doochul/domain/product/ProductRepository.java +++ b/doochul/src/main/java/org/doochul/domain/product/ProductRepository.java @@ -1,10 +1,8 @@ package org.doochul.domain.product; -import java.util.List; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Repository; @Repository -public interface ProductRepository extends JpaRepository { - List findAll(); +public interface ProductRepository extends JpaRepository { } diff --git a/doochul/src/main/java/org/doochul/domain/user/User.java b/doochul/src/main/java/org/doochul/domain/user/User.java index 39391b8..0af8242 100644 --- a/doochul/src/main/java/org/doochul/domain/user/User.java +++ b/doochul/src/main/java/org/doochul/domain/user/User.java @@ -11,7 +11,6 @@ import lombok.AccessLevel; import lombok.Getter; import lombok.NoArgsConstructor; -import lombok.RequiredArgsConstructor; import org.doochul.domain.BaseEntity; @Entity @@ -36,4 +35,18 @@ public class User extends BaseEntity { @Enumerated(EnumType.STRING) private Identity identity; + + public User( + final String name, + final String deviceToken, + final String passWord, + final Gender gender, + final Identity identity + ) { + this.name = name; + this.deviceToken = deviceToken; + this.passWord = passWord; + this.gender = gender; + this.identity = identity; + } } diff --git a/doochul/src/main/java/org/doochul/infra/FcmMessageSender.java b/doochul/src/main/java/org/doochul/infra/FcmMessageSender.java index d600580..3e2c406 100644 --- a/doochul/src/main/java/org/doochul/infra/FcmMessageSender.java +++ b/doochul/src/main/java/org/doochul/infra/FcmMessageSender.java @@ -9,8 +9,8 @@ import jakarta.annotation.PostConstruct; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.doochul.service.Letter; -import org.doochul.service.MessageSendManager; +import org.doochul.infra.dto.Letter; +import org.doochul.application.MessageSendManager; import org.springframework.beans.factory.annotation.Value; import org.springframework.core.io.ClassPathResource; import org.springframework.stereotype.Component; diff --git a/doochul/src/main/java/org/doochul/infra/dto/Letter.java b/doochul/src/main/java/org/doochul/infra/dto/Letter.java new file mode 100644 index 0000000..69ad912 --- /dev/null +++ b/doochul/src/main/java/org/doochul/infra/dto/Letter.java @@ -0,0 +1,22 @@ +package org.doochul.infra.dto; + +import org.doochul.domain.lesson.LessonStatus; + +import java.time.LocalDateTime; + +public record Letter( + String targetToken, + String title, + String body +) { + public static Letter of( + final String studentToken, + final String studentName, + final String teacherName, + final LocalDateTime startedAt, + final LessonStatus lessonStatus + ) { + final String message = lessonStatus.getMessage(studentName, startedAt, teacherName); + return new Letter(studentToken, lessonStatus.getTitle(), message); + } +} diff --git a/doochul/src/main/java/org/doochul/job/BatchScheduler.java b/doochul/src/main/java/org/doochul/job/BatchScheduler.java new file mode 100644 index 0000000..695dc31 --- /dev/null +++ b/doochul/src/main/java/org/doochul/job/BatchScheduler.java @@ -0,0 +1,42 @@ +package org.doochul.job; + +import org.springframework.batch.core.Job; +import org.springframework.batch.core.JobParameters; +import org.springframework.batch.core.JobParametersBuilder; +import org.springframework.batch.core.launch.JobLauncher; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +@Component +public class BatchScheduler { + + private final JobLauncher jobLauncher; + private final Job sandNotificationAfterLessonJob; + private final Job sandNotificationBeforeLessonJob; + private final Job updateRemainingCountJob; + + public BatchScheduler(JobLauncher jobLauncher, + @Qualifier("sandNotificationAfterLessonJob") Job sandNotificationAfterLessonJob, + @Qualifier("sandNotificationBeforeLessonJob") Job sandNotificationBeforeLessonJob, + @Qualifier("updateRemainingCountJob") Job updateRemainingCountJob) { + this.jobLauncher = jobLauncher; + this.sandNotificationAfterLessonJob = sandNotificationAfterLessonJob; + this.sandNotificationBeforeLessonJob = sandNotificationBeforeLessonJob; + this.updateRemainingCountJob = updateRemainingCountJob; + } + + @Scheduled(cron = "0 0/1 * * * *") + public void runJobs() throws Exception { + runJob(sandNotificationAfterLessonJob); + runJob(sandNotificationBeforeLessonJob); + runJob(updateRemainingCountJob); + } + + private void runJob(Job job) throws Exception { + JobParameters parameters = new JobParametersBuilder() + .addString("jobName: ", job.getName() + System.currentTimeMillis()) + .toJobParameters(); + jobLauncher.run(job, parameters); + } +} \ No newline at end of file diff --git a/doochul/src/main/java/org/doochul/job/SendNotificationAfterLessonJobConfig.java b/doochul/src/main/java/org/doochul/job/SendNotificationAfterLessonJobConfig.java new file mode 100644 index 0000000..bd69b4a --- /dev/null +++ b/doochul/src/main/java/org/doochul/job/SendNotificationAfterLessonJobConfig.java @@ -0,0 +1,114 @@ +package org.doochul.job; + +import org.doochul.infra.dto.Letter; +import org.springframework.batch.core.Job; +import org.springframework.batch.core.Step; +import org.springframework.batch.core.job.builder.JobBuilder; +import org.springframework.batch.core.launch.support.RunIdIncrementer; +import org.springframework.batch.core.repository.JobRepository; +import org.springframework.batch.core.step.builder.StepBuilder; +import org.springframework.batch.item.ItemProcessor; +import org.springframework.batch.item.database.JdbcPagingItemReader; +import org.springframework.batch.item.database.Order; +import org.springframework.batch.item.database.PagingQueryProvider; +import org.springframework.batch.item.database.builder.JdbcPagingItemReaderBuilder; +import org.springframework.batch.item.database.support.SqlPagingQueryProviderFactoryBean; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.task.TaskExecutor; +import org.springframework.jdbc.core.ColumnMapRowMapper; +import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; +import org.springframework.transaction.PlatformTransactionManager; + +import javax.sql.DataSource; +import java.time.LocalDateTime; +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; + +import static org.doochul.domain.lesson.LessonStatus.AFTER_LESSON; + +@Configuration +public class SendNotificationAfterLessonJobConfig { + private static final int CHUNK_SIZE = 10; + + private final DataSource dataSource; + private final SendNotificationItemWriter sendNotificationItemWriter; + + public SendNotificationAfterLessonJobConfig(final DataSource dataSource, final SendNotificationItemWriter sendNotificationItemWriter) { + this.dataSource = dataSource; + this.sendNotificationItemWriter = sendNotificationItemWriter; + } + + @Bean + public Job sandNotificationAfterLessonJob(JobRepository jobRepository, PlatformTransactionManager transactionManager) throws Exception { + return new JobBuilder("sandNotificationAfterLesson", jobRepository) + .incrementer(new RunIdIncrementer()) + .start(sendNotificationBeforeClassStep(jobRepository, transactionManager)) + .build(); + } + + @Bean + public Step sendNotificationBeforeClassStep( + final JobRepository jobRepository, + final PlatformTransactionManager transactionManager + ) throws Exception { + return new StepBuilder("addNotificationStep", jobRepository) + ., Letter>chunk(CHUNK_SIZE, transactionManager) + .reader(addNotificationItemReader()) + .processor(addNotificationItemProcessor()) + .writer(sendNotificationItemWriter) + .taskExecutor(executor()) + .build(); + } + + @Bean + public JdbcPagingItemReader> addNotificationItemReader() throws Exception { + final Map parameterValues = new HashMap<>(); + parameterValues.put("endedAt", LocalDateTime.now()); + + return new JdbcPagingItemReaderBuilder>() + .name("addNotificationItemReader") + .dataSource(dataSource) + .pageSize(CHUNK_SIZE) + .queryProvider(createQueryProvider()) + .parameterValues(parameterValues) + .rowMapper(new ColumnMapRowMapper()) + .build(); + } + + private PagingQueryProvider createQueryProvider() throws Exception { + SqlPagingQueryProviderFactoryBean queryProvider = new SqlPagingQueryProviderFactoryBean(); + queryProvider.setDataSource(dataSource); + queryProvider.setSelectClause("SELECT u.device_token AS student_token, u.name AS student_name, t.name AS teacher_name, l.ended_at"); + queryProvider.setFromClause("FROM lesson l JOIN users u ON l.student_id = u.id JOIN users t ON l.teacher_id = t.id"); + queryProvider.setWhereClause("WHERE l.ended_at <= :endedAt"); + + final Map sortKeys = new HashMap<>(1); + sortKeys.put("l.ended_at", Order.ASCENDING); + + queryProvider.setSortKeys(sortKeys); + return Optional.ofNullable(queryProvider.getObject()).orElseThrow(IllegalArgumentException::new); + } + + @Bean + public ItemProcessor, Letter> addNotificationItemProcessor() { + return item -> { + String studentToken = (String) item.get("student_token"); + String studentName = (String) item.get("student_name"); + String teacherName = (String) item.get("teacher_name"); + LocalDateTime endedAt = (LocalDateTime) item.get("ended_at"); + return Letter.of(studentToken, studentName, teacherName, endedAt, AFTER_LESSON); + }; + } + + @Bean + public TaskExecutor executor() { + ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); + executor.setCorePoolSize(4); + executor.setWaitForTasksToCompleteOnShutdown(true); + executor.setAwaitTerminationSeconds(60); + executor.initialize(); + return executor; + } +} diff --git a/doochul/src/main/java/org/doochul/job/SendNotificationBeforeLessonJobConfig.java b/doochul/src/main/java/org/doochul/job/SendNotificationBeforeLessonJobConfig.java new file mode 100644 index 0000000..0e87e9d --- /dev/null +++ b/doochul/src/main/java/org/doochul/job/SendNotificationBeforeLessonJobConfig.java @@ -0,0 +1,113 @@ +package org.doochul.job; + +import org.doochul.infra.dto.Letter; +import org.springframework.batch.core.Job; +import org.springframework.batch.core.Step; +import org.springframework.batch.core.job.builder.JobBuilder; +import org.springframework.batch.core.launch.support.RunIdIncrementer; +import org.springframework.batch.core.repository.JobRepository; +import org.springframework.batch.core.step.builder.StepBuilder; +import org.springframework.batch.item.ItemProcessor; +import org.springframework.batch.item.database.JdbcPagingItemReader; +import org.springframework.batch.item.database.Order; +import org.springframework.batch.item.database.PagingQueryProvider; +import org.springframework.batch.item.database.builder.JdbcPagingItemReaderBuilder; +import org.springframework.batch.item.database.support.SqlPagingQueryProviderFactoryBean; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.task.TaskExecutor; +import org.springframework.jdbc.core.ColumnMapRowMapper; +import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; +import org.springframework.transaction.PlatformTransactionManager; + +import javax.sql.DataSource; +import java.time.LocalDateTime; +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; + +import static org.doochul.domain.lesson.LessonStatus.BEFORE_LESSON; + +@Configuration +public class SendNotificationBeforeLessonJobConfig { + private static final int CHUNK_SIZE = 10; + + private final DataSource dataSource; + private final SendNotificationItemWriter sendNotificationItemWriter; + + public SendNotificationBeforeLessonJobConfig(final DataSource dataSource, final SendNotificationItemWriter sendNotificationItemWriter) { + this.dataSource = dataSource; + this.sendNotificationItemWriter = sendNotificationItemWriter; + } + + @Bean + public Job sandNotificationBeforeLessonJob(JobRepository jobRepository, PlatformTransactionManager transactionManager) throws Exception { + return new JobBuilder("sandNotificationBeforeLessonJob", jobRepository) + .incrementer(new RunIdIncrementer()) + .start(sendNotificationBeforeLessonStep(jobRepository, transactionManager)) + .build(); + } + + @Bean + public Step sendNotificationBeforeLessonStep( + JobRepository jobRepository, + PlatformTransactionManager transactionManager + ) throws Exception { + return new StepBuilder("addNotificationStep", jobRepository) + ., Letter>chunk(CHUNK_SIZE, transactionManager) + .reader(addNotificationItemReader()) + .processor(addNotificationItemProcessor()) + .writer(sendNotificationItemWriter) + .taskExecutor(executor()) + .build(); + } + + @Bean + public JdbcPagingItemReader> addNotificationItemReader() throws Exception { + Map parameterValues = new HashMap<>(); + parameterValues.put("startedAt", LocalDateTime.now().plusMinutes(10)); + + return new JdbcPagingItemReaderBuilder>() + .name("addNotificationItemReader") + .dataSource(dataSource) + .pageSize(CHUNK_SIZE) + .queryProvider(createQueryProvider()) + .parameterValues(parameterValues) + .rowMapper(new ColumnMapRowMapper()) + .build(); + } + + private PagingQueryProvider createQueryProvider() throws Exception { + SqlPagingQueryProviderFactoryBean queryProvider = new SqlPagingQueryProviderFactoryBean(); + queryProvider.setDataSource(dataSource); + queryProvider.setSelectClause("SELECT u.device_token AS student_token, u.name AS student_name, t.name AS teacher_name, l.started_at"); + queryProvider.setFromClause("FROM lesson l JOIN users u ON l.student_id = u.id JOIN users t ON l.teacher_id = t.id"); + queryProvider.setWhereClause("WHERE l.started_at <= :startedAt"); + Map sortKeys = new HashMap<>(1); + sortKeys.put("l.started_at", Order.ASCENDING); + + queryProvider.setSortKeys(sortKeys); + return Optional.ofNullable(queryProvider.getObject()).orElseThrow(IllegalArgumentException::new); + } + + @Bean + public ItemProcessor, Letter> addNotificationItemProcessor() { + return item -> { + String studentToken = (String) item.get("student_token"); + String studentName = (String) item.get("student_name"); + String teacherName = (String) item.get("teacher_name"); + LocalDateTime startedAt = (LocalDateTime) item.get("started_at"); + return Letter.of(studentToken, studentName, teacherName, startedAt, BEFORE_LESSON); + }; + } + + @Bean + public TaskExecutor executor() { + ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); + executor.setCorePoolSize(4); + executor.setWaitForTasksToCompleteOnShutdown(true); + executor.setAwaitTerminationSeconds(60); + executor.initialize(); + return executor; + } +} diff --git a/doochul/src/main/java/org/doochul/job/SendNotificationItemWriter.java b/doochul/src/main/java/org/doochul/job/SendNotificationItemWriter.java new file mode 100644 index 0000000..6ad1e63 --- /dev/null +++ b/doochul/src/main/java/org/doochul/job/SendNotificationItemWriter.java @@ -0,0 +1,25 @@ +package org.doochul.job; + +import lombok.extern.slf4j.Slf4j; +import org.doochul.infra.dto.Letter; +import org.doochul.application.MessageSendManager; +import org.springframework.batch.item.Chunk; +import org.springframework.batch.item.ItemWriter; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +public class SendNotificationItemWriter implements ItemWriter { + private final MessageSendManager messageSendManager; + + public SendNotificationItemWriter(MessageSendManager messageSendManager) { + this.messageSendManager = messageSendManager; + } + + @Override + public void write(final Chunk letters) { + for (final Letter letter : letters) { + messageSendManager.sendTo(letter); + } + } +} diff --git a/doochul/src/main/java/org/doochul/job/UpdateRemainingCountJobConfig.java b/doochul/src/main/java/org/doochul/job/UpdateRemainingCountJobConfig.java new file mode 100644 index 0000000..f755e79 --- /dev/null +++ b/doochul/src/main/java/org/doochul/job/UpdateRemainingCountJobConfig.java @@ -0,0 +1,52 @@ +package org.doochul.job; + +import org.springframework.batch.core.Job; +import org.springframework.batch.core.Step; +import org.springframework.batch.core.StepContribution; +import org.springframework.batch.core.job.builder.JobBuilder; +import org.springframework.batch.core.launch.support.RunIdIncrementer; +import org.springframework.batch.core.repository.JobRepository; +import org.springframework.batch.core.scope.context.ChunkContext; +import org.springframework.batch.core.step.builder.StepBuilder; +import org.springframework.batch.core.step.tasklet.Tasklet; +import org.springframework.batch.repeat.RepeatStatus; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.transaction.PlatformTransactionManager; + +import java.time.LocalDateTime; +import java.util.Collections; + +@Configuration +public class UpdateRemainingCountJobConfig { + @Bean + public Job updateRemainingCountJob(JobRepository jobRepository, PlatformTransactionManager transactionManager, JdbcTemplate jdbcTemplate) { + return new JobBuilder("updateRemainingCountJob", jobRepository) + .incrementer(new RunIdIncrementer()) + .start(updateRemainingCountStep(jobRepository, transactionManager, jdbcTemplate)) + .build(); + } + + @Bean + public Step updateRemainingCountStep(JobRepository jobRepository, PlatformTransactionManager transactionManager, JdbcTemplate jdbcTemplate) { + return new StepBuilder("updateRemainingCountStep", jobRepository) + .tasklet(new UpdateRemainingCountTasklet(jdbcTemplate), transactionManager) + .build(); + } + private static class UpdateRemainingCountTasklet implements Tasklet { + + private final JdbcTemplate jdbcTemplate; + + public UpdateRemainingCountTasklet(JdbcTemplate jdbcTemplate) { + this.jdbcTemplate = jdbcTemplate; + } + + @Override + public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) { + jdbcTemplate.update("UPDATE lesson JOIN member_ship m ON l.membership_id = m.id SET m.remaining_count = m.remaining_count - 1 WHERE l.ended_at <= :endedAt", + Collections.singletonMap("endedAt", LocalDateTime.now())); + return RepeatStatus.FINISHED; + } + } +} diff --git a/doochul/src/main/java/org/doochul/service/Letter.java b/doochul/src/main/java/org/doochul/service/Letter.java deleted file mode 100644 index f18da78..0000000 --- a/doochul/src/main/java/org/doochul/service/Letter.java +++ /dev/null @@ -1,21 +0,0 @@ -package org.doochul.service; - -import org.doochul.domain.user.User; - -import java.time.LocalDateTime; - -public record Letter( - String targetToken, - String title, - String body -) { - public static Letter of( - final User student, - final User teacher, - final LocalDateTime startedAt, - final LessonStatus lessonStatus - ) { - final String message = lessonStatus.getMessage(student.getName(), startedAt, teacher.getName()); - return new Letter(student.getDeviceToken(), lessonStatus.getTitle(), message); - } -} diff --git a/doochul/src/main/java/org/doochul/service/NotificationService.java b/doochul/src/main/java/org/doochul/service/NotificationService.java deleted file mode 100644 index d49bda4..0000000 --- a/doochul/src/main/java/org/doochul/service/NotificationService.java +++ /dev/null @@ -1,107 +0,0 @@ -package org.doochul.service; - -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.doochul.application.RedisService; -import org.doochul.domain.lesson.Lesson; -import org.doochul.domain.lesson.LessonRepository; -import org.doochul.domain.membership.MemberShip; -import org.doochul.domain.user.User; -import org.doochul.support.KeyGenerator; -import org.springframework.boot.context.event.ApplicationReadyEvent; -import org.springframework.context.event.EventListener; -import org.springframework.scheduling.TaskScheduler; -import org.springframework.scheduling.annotation.Async; -import org.springframework.stereotype.Service; - -import java.time.Clock; -import java.time.Duration; -import java.time.Instant; -import java.time.LocalDateTime; -import java.util.ArrayList; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.concurrent.ScheduledFuture; - -import static org.doochul.service.LessonStatus.AFTER_LESSON; -import static org.doochul.service.LessonStatus.BEFORE_LESSON; -import static org.doochul.service.LessonStatus.END_LESSON; -import static org.doochul.service.LessonStatus.SCHEDULED_LESSON; -import static org.doochul.service.LessonStatus.WITHDRAWN_LESSON; - -@Slf4j -@Service -@RequiredArgsConstructor -public class NotificationService { - private final TaskScheduler taskScheduler; - private final MessageSendManager messageSendManager; - private final Clock clock; - private final LessonRepository lessonRepository; - private final KeyGenerator keyGenerator; - private final RedisService redisService; - - private final Map>> schedule = new HashMap<>(); - - @EventListener(ApplicationReadyEvent.class) - public void initLessonNotification() { - final List lessons = lessonRepository.findByStartedAtBefore(LocalDateTime.now()); - lessons.forEach(lesson -> { - addRemindNotificationSchedule(lesson.getMemberShip().getStudent(), lesson.getMemberShip().getProduct().getTeacher(), lesson); - addDismissalNotificationSchedule(lesson.getMemberShip().getStudent(), lesson.getMemberShip().getProduct().getTeacher(), lesson); - }); - } - - public void applyForLesson(final LessonCreateEvent event) { - final User student = event.student(); - final User teacher = event.teacher(); - final Lesson lesson = event.lesson(); - sendNotification(Letter.of(student, teacher, lesson.getStartedAt(), SCHEDULED_LESSON)); - addRemindNotificationSchedule(student,teacher,lesson); - addDismissalNotificationSchedule(student,teacher,lesson); - } - - public void withdrawnForLessons(final LessonWithdrawnEvent event) { - sendNotification(Letter.of(event.student(), event.teacher(), event.lesson().getStartedAt(), WITHDRAWN_LESSON)); - schedule.get(event.lesson().getId()) - .forEach(ScheduledFuture -> ScheduledFuture.cancel(true)); - schedule.remove(event.lesson().getId()); - } - - @Async - public void sendNotification(final Letter letter) { - final String key = keyGenerator.generateAccountKey(letter.targetToken()); - if (redisService.setNX(key, "notification", Duration.ofSeconds(5))) { - messageSendManager.sendTo(letter); - redisService.delete(key); - } - } - - private void addRemindNotificationSchedule(final User student, final User teacher, final Lesson lesson) { - final LocalDateTime reminderTime = lesson.getStartedAt().minusMinutes(10); - final Instant instant = toInstant(reminderTime); - final ScheduledFuture remindSchedule = taskScheduler.schedule(() -> sendNotification(Letter.of(student, teacher, lesson.getStartedAt(), BEFORE_LESSON)), instant); - final List> lessonSchedules = schedule.computeIfAbsent(lesson.getId(), k -> new ArrayList<>()); - lessonSchedules.add(remindSchedule); - } - - private void addDismissalNotificationSchedule(final User student, final User teacher, final Lesson lesson) { - final Instant instant = toInstant(lesson.getEndedAt()); - final ScheduledFuture dismissalSchedule = taskScheduler.schedule(() -> deductCountAndSendNotification(student, teacher, lesson), instant); - final List> lessonSchedules = schedule.computeIfAbsent(lesson.getId(), k -> new ArrayList<>()); - lessonSchedules.add(dismissalSchedule); - } - - private void deductCountAndSendNotification(final User student, final User teacher, final Lesson lesson) { - final MemberShip memberShip = lesson.getMemberShip(); - memberShip.decreasedCount(); - sendNotification(Letter.of(student, teacher, lesson.getStartedAt(), AFTER_LESSON)); - if (memberShip.isCountZero()) { - sendNotification(Letter.of(student, teacher, lesson.getStartedAt(), END_LESSON)); - } - } - - private Instant toInstant(final LocalDateTime localDateTime) { - return localDateTime.atZone(clock.getZone()).toInstant(); - } -} diff --git a/doochul/src/main/resources/application.properties b/doochul/src/main/resources/application.properties index 7365132..afb3b70 100644 --- a/doochul/src/main/resources/application.properties +++ b/doochul/src/main/resources/application.properties @@ -1,4 +1,9 @@ -spring.datasource.url=jdbc:h2:mem:testdb;MODE=MySQL; - +#spring.datasource.url=jdbc:h2:mem:testdb;MODE=MySQL; +spring.datasource.url=jdbc:mysql://localhost:3306/my_database?characterEncoding=UTF-8&serverTimezone=Asia/Seoul +spring.datasource.username= root +spring.datasource.password= 123 +spring.jpa.hibernate.ddl-auto=create +spring.jpa.show-sql=true +spring.batch.jdbc.initialize-schema=always fcm.certification.path=kwakdoochul-bbb43-firebase-adminsdk-oyokf-f075393454.json \ No newline at end of file diff --git a/doochul/src/test/java/org/doochul/service/LessonStatusTest.java b/doochul/src/test/java/org/doochul/service/LessonStatusTest.java index f8f0b9d..79899b0 100644 --- a/doochul/src/test/java/org/doochul/service/LessonStatusTest.java +++ b/doochul/src/test/java/org/doochul/service/LessonStatusTest.java @@ -4,6 +4,8 @@ import java.time.LocalDateTime; import java.time.format.DateTimeFormatter; + +import org.doochul.domain.lesson.LessonStatus; import org.junit.jupiter.api.Test; class LessonStatusTest { diff --git a/doochul/src/test/java/org/doochul/service/NotificationServiceTest.java b/doochul/src/test/java/org/doochul/service/NotificationServiceTest.java index 4af016f..23ffedb 100644 --- a/doochul/src/test/java/org/doochul/service/NotificationServiceTest.java +++ b/doochul/src/test/java/org/doochul/service/NotificationServiceTest.java @@ -1,6 +1,8 @@ package org.doochul.service; +import org.doochul.application.MessageSendManager; import org.doochul.application.RedisService; +import org.doochul.infra.dto.Letter; import org.doochul.support.KeyGenerator; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired;