diff --git a/doochul/build.gradle b/doochul/build.gradle index f5fa55c..81f9f50 100644 --- a/doochul/build.gradle +++ b/doochul/build.gradle @@ -24,6 +24,13 @@ repositories { dependencies { implementation 'org.springframework.boot:spring-boot-starter-data-jpa' 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 'com.google.firebase:firebase-admin:9.2.0' + + runtimeOnly 'mysql:mysql-connector-java' + runtimeOnly 'com.h2database:h2' compileOnly 'org.projectlombok:lombok' annotationProcessor 'org.projectlombok:lombok' testImplementation 'org.springframework.boot:spring-boot-starter-test' diff --git a/doochul/src/main/java/org/doochul/application/RedisService.java b/doochul/src/main/java/org/doochul/application/RedisService.java new file mode 100644 index 0000000..5bd1be3 --- /dev/null +++ b/doochul/src/main/java/org/doochul/application/RedisService.java @@ -0,0 +1,25 @@ +package org.doochul.application; + +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Service; + +import java.time.Duration; + +@Service +public class RedisService { + + private final RedisTemplate redisTemplate; + + public RedisService(final RedisTemplate redisTemplate) { + this.redisTemplate = redisTemplate; + } + + 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/config/AppConfig.java b/doochul/src/main/java/org/doochul/config/AppConfig.java new file mode 100644 index 0000000..7476aaa --- /dev/null +++ b/doochul/src/main/java/org/doochul/config/AppConfig.java @@ -0,0 +1,31 @@ +package org.doochul.config; + +import java.time.Clock; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.TaskScheduler; +import org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler; +import org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler; + +@Configuration +public class AppConfig { + + @Bean + public Clock clock() { + return Clock.systemDefaultZone(); + } + + @Bean + public TaskScheduler taskScheduler() { + ThreadPoolTaskScheduler taskScheduler = new ThreadPoolTaskScheduler(); + taskScheduler.setPoolSize(10); + return taskScheduler; + } + + @Bean + public ThreadPoolTaskScheduler taskScheduler() { + ThreadPoolTaskScheduler scheduler = new ThreadPoolTaskScheduler(); + scheduler.setPoolSize(2); + return scheduler; + } +} diff --git a/doochul/src/main/java/org/doochul/config/RedisConfig.java b/doochul/src/main/java/org/doochul/config/RedisConfig.java new file mode 100644 index 0000000..8e27f1d --- /dev/null +++ b/doochul/src/main/java/org/doochul/config/RedisConfig.java @@ -0,0 +1,26 @@ +package org.doochul.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.serializer.StringRedisSerializer; + +@Configuration +public class RedisConfig { + + @Bean + public RedisConnectionFactory redisConnectionFactory() { + return new LettuceConnectionFactory(); + } + + @Bean + public RedisTemplate redisTemplate() { + RedisTemplate redisTemplate = new RedisTemplate<>(); + redisTemplate.setConnectionFactory(redisConnectionFactory()); + redisTemplate.setKeySerializer(new StringRedisSerializer()); + redisTemplate.setValueSerializer(new StringRedisSerializer()); + return redisTemplate; + } +} diff --git a/doochul/src/main/java/org/doochul/config/SchedulingConfig.java b/doochul/src/main/java/org/doochul/config/SchedulingConfig.java new file mode 100644 index 0000000..0e6ee09 --- /dev/null +++ b/doochul/src/main/java/org/doochul/config/SchedulingConfig.java @@ -0,0 +1,10 @@ +package org.doochul.config; + +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.annotation.EnableScheduling; + +@Configuration +@EnableScheduling +public class SchedulingConfig { + +} \ No newline at end of file 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 0110daf..aca87ee 100644 --- a/doochul/src/main/java/org/doochul/domain/lesson/Lesson.java +++ b/doochul/src/main/java/org/doochul/domain/lesson/Lesson.java @@ -11,7 +11,7 @@ import lombok.Getter; import lombok.NoArgsConstructor; import org.doochul.domain.BaseEntity; -import org.doochul.domain.memberShip.MemberShip; +import org.doochul.domain.membership.MemberShip; @Entity @Getter 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 eecf4d6..fb3fe1b 100644 --- a/doochul/src/main/java/org/doochul/domain/lesson/LessonRepository.java +++ b/doochul/src/main/java/org/doochul/domain/lesson/LessonRepository.java @@ -2,5 +2,13 @@ import org.springframework.data.jpa.repository.JpaRepository; +import java.time.LocalDateTime; +import java.util.List; + public interface LessonRepository extends JpaRepository { + default Lesson getById(final Long id) { + return findById(id).orElseThrow(() -> new IllegalArgumentException("해당 수업이 없습니다.")); + } + + List findByStartedAtBefore(final LocalDateTime currentServerTime); } 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 822fbbb..382e41b 100644 --- a/doochul/src/main/java/org/doochul/domain/membership/MemberShip.java +++ b/doochul/src/main/java/org/doochul/domain/membership/MemberShip.java @@ -1,4 +1,4 @@ -package org.doochul.domain.memberShip; +package org.doochul.domain.membership; import jakarta.persistence.Entity; import jakarta.persistence.GeneratedValue; @@ -31,4 +31,19 @@ public class MemberShip extends BaseEntity { private Product product; private Integer remainingCount; + + public void decreasedCount() { + validateMinRemainingCount(); + remainingCount -= 1; + } + + public boolean isCountZero() { + return remainingCount == 0; + } + + private void validateMinRemainingCount() { + if (remainingCount < 1) { + throw new IllegalArgumentException("안돼"); + } + } } diff --git a/doochul/src/main/java/org/doochul/domain/membership/MemberShipRepository.java b/doochul/src/main/java/org/doochul/domain/membership/MemberShipRepository.java index e255ab2..860ce34 100644 --- a/doochul/src/main/java/org/doochul/domain/membership/MemberShipRepository.java +++ b/doochul/src/main/java/org/doochul/domain/membership/MemberShipRepository.java @@ -1,4 +1,4 @@ -package org.doochul.domain.memberShip; +package org.doochul.domain.membership; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Repository; diff --git a/doochul/src/main/java/org/doochul/domain/membership/MemberShipType.java b/doochul/src/main/java/org/doochul/domain/membership/MemberShipType.java new file mode 100644 index 0000000..2d06ee2 --- /dev/null +++ b/doochul/src/main/java/org/doochul/domain/membership/MemberShipType.java @@ -0,0 +1,5 @@ +package org.doochul.domain.membership; + +public enum MemberShipType { + LOL, TFT +} \ No newline at end of file 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 db23e27..39391b8 100644 --- a/doochul/src/main/java/org/doochul/domain/user/User.java +++ b/doochul/src/main/java/org/doochul/domain/user/User.java @@ -18,7 +18,6 @@ @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) @Table(name = "users") -@RequiredArgsConstructor public class User extends BaseEntity { @Id diff --git a/doochul/src/main/java/org/doochul/domain/user/UserRepository.java b/doochul/src/main/java/org/doochul/domain/user/UserRepository.java index 6381b8b..08b5d50 100644 --- a/doochul/src/main/java/org/doochul/domain/user/UserRepository.java +++ b/doochul/src/main/java/org/doochul/domain/user/UserRepository.java @@ -5,4 +5,7 @@ @Repository public interface UserRepository extends JpaRepository { + default User getById(final Long id) { + return findById(id).orElseThrow(() -> new IllegalArgumentException("해당 유저는 없습니다.")); + } } diff --git a/doochul/src/main/java/org/doochul/infra/FcmMessageSender.java b/doochul/src/main/java/org/doochul/infra/FcmMessageSender.java new file mode 100644 index 0000000..d600580 --- /dev/null +++ b/doochul/src/main/java/org/doochul/infra/FcmMessageSender.java @@ -0,0 +1,67 @@ +package org.doochul.infra; + +import com.google.auth.oauth2.GoogleCredentials; +import com.google.firebase.FirebaseApp; +import com.google.firebase.FirebaseOptions; +import com.google.firebase.messaging.FirebaseMessaging; +import com.google.firebase.messaging.Message; +import com.google.firebase.messaging.Notification; +import jakarta.annotation.PostConstruct; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.doochul.service.Letter; +import org.doochul.service.MessageSendManager; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.core.io.ClassPathResource; +import org.springframework.stereotype.Component; + +import java.io.FileNotFoundException; +import java.io.IOException; +import java.util.concurrent.ExecutionException; + +@Slf4j +@Component +@RequiredArgsConstructor +public class FcmMessageSender implements MessageSendManager { + + @Value("${fcm.certification.path}") + private String FCM_CERTIFICATION_PATH; + + @PostConstruct + public void initialize() { + try { + ClassPathResource resource = new ClassPathResource(FCM_CERTIFICATION_PATH); + FirebaseOptions options = FirebaseOptions.builder() + .setCredentials(GoogleCredentials.fromStream(resource.getInputStream())) + .build(); + + if (FirebaseApp.getApps().isEmpty()) { + FirebaseApp.initializeApp(options); + } + } catch (FileNotFoundException e) { + log.error("파일을 찾을 수 없습니다. ", e); + } catch (IOException e) { + log.error("FCM 인증이 실패했습니다. ", e); + } + } + + @Override + public void sendTo(final Letter letter) { + final Notification notification = Notification.builder() + .setTitle(letter.title()) + .setBody(letter.body()) + .build(); + final Message message = Message.builder() + .setToken(letter.targetToken()) + .setNotification(notification) + .build(); + try { + final String response = FirebaseMessaging.getInstance().sendAsync(message).get(); + log.info("알림 전송 성공 : " + response); + } catch (InterruptedException e) { + log.error("FCM 알림 스레드에서 문제가 발생했습니다.", e); + } catch (ExecutionException e) { + log.error("FCM 알림 전송에 실패했습니다.", e); + } + } +} \ No newline at end of file diff --git a/doochul/src/main/java/org/doochul/service/LessonCreateEvent.java b/doochul/src/main/java/org/doochul/service/LessonCreateEvent.java new file mode 100644 index 0000000..9b5fbb2 --- /dev/null +++ b/doochul/src/main/java/org/doochul/service/LessonCreateEvent.java @@ -0,0 +1,11 @@ +package org.doochul.service; + +import org.doochul.domain.lesson.Lesson; +import org.doochul.domain.user.User; + +public record LessonCreateEvent( + User student, + User teacher, + Lesson lesson +) { +} diff --git a/doochul/src/main/java/org/doochul/service/LessonStatus.java b/doochul/src/main/java/org/doochul/service/LessonStatus.java new file mode 100644 index 0000000..e47b77f --- /dev/null +++ b/doochul/src/main/java/org/doochul/service/LessonStatus.java @@ -0,0 +1,40 @@ +package org.doochul.service; + +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; + +@FunctionalInterface +interface LessonStatusMessage { + String getMessage(String name, LocalDateTime time, String teacher); +} + +public enum LessonStatus { + BEFORE_LESSON("수업 전", + (name, time, teacher) -> name + "님 " + formatToLocalTime(time) + " " + teacher + " 강사님 수업 잊지 않으셨죠?"), + AFTER_LESSON("수업 후", (name, time, teacher) -> name + "님 오늘 수업 잘 받으셨나요?"), + SCHEDULED_LESSON("수업 신청", + (name, time, teacher) -> formatToLocalTime(time) + " " + name + "님 " + teacher + " 강사님 수업을 신청했습니다."), + WITHDRAWN_LESSON("수업 철회", + (name, time, teacher) -> formatToLocalTime(time) + " " + name + "님 " + teacher + " 강사님의 수업을 철회했습니다."), + END_LESSON("수업 종료", (name, time, teacher) -> teacher + " 강사님의 수업이 모두 종료되었습니다."); + + private final String title; + private final LessonStatusMessage message; + + LessonStatus(String title, LessonStatusMessage message) { + this.title = title; + this.message = message; + } + + 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/service/LessonWithdrawnEvent.java b/doochul/src/main/java/org/doochul/service/LessonWithdrawnEvent.java new file mode 100644 index 0000000..d240bdf --- /dev/null +++ b/doochul/src/main/java/org/doochul/service/LessonWithdrawnEvent.java @@ -0,0 +1,11 @@ +package org.doochul.service; + +import org.doochul.domain.lesson.Lesson; +import org.doochul.domain.user.User; + +public record LessonWithdrawnEvent( + User student, + User teacher, + Lesson lesson +) { +} diff --git a/doochul/src/main/java/org/doochul/service/Letter.java b/doochul/src/main/java/org/doochul/service/Letter.java new file mode 100644 index 0000000..f18da78 --- /dev/null +++ b/doochul/src/main/java/org/doochul/service/Letter.java @@ -0,0 +1,21 @@ +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/MessageSendManager.java b/doochul/src/main/java/org/doochul/service/MessageSendManager.java new file mode 100644 index 0000000..9845d1b --- /dev/null +++ b/doochul/src/main/java/org/doochul/service/MessageSendManager.java @@ -0,0 +1,5 @@ +package org.doochul.service; + +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/service/NotificationEventListener.java new file mode 100644 index 0000000..d76f5d1 --- /dev/null +++ b/doochul/src/main/java/org/doochul/service/NotificationEventListener.java @@ -0,0 +1,26 @@ +package org.doochul.service; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.event.EventListener; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +@RequiredArgsConstructor +public class NotificationEventListener { + private final NotificationService notificationService; + + @EventListener + @Async + public void scheduleLessonNotificationEvent(final LessonCreateEvent event) { + notificationService.applyForLesson(event); + } + + @EventListener + @Async + public void withdrawnLessonNotificationEvent1(final LessonWithdrawnEvent event) { + notificationService.withdrawnForLessons(event); + } +} diff --git a/doochul/src/main/java/org/doochul/service/NotificationService.java b/doochul/src/main/java/org/doochul/service/NotificationService.java new file mode 100644 index 0000000..d49bda4 --- /dev/null +++ b/doochul/src/main/java/org/doochul/service/NotificationService.java @@ -0,0 +1,107 @@ +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/java/org/doochul/support/KeyGenerator.java b/doochul/src/main/java/org/doochul/support/KeyGenerator.java new file mode 100644 index 0000000..ffa00d6 --- /dev/null +++ b/doochul/src/main/java/org/doochul/support/KeyGenerator.java @@ -0,0 +1,6 @@ +package org.doochul.support; + +public interface KeyGenerator { + + String generateAccountKey(String token); +} diff --git a/doochul/src/main/java/org/doochul/support/KeyGeneratorImpl.java b/doochul/src/main/java/org/doochul/support/KeyGeneratorImpl.java new file mode 100644 index 0000000..3b87aad --- /dev/null +++ b/doochul/src/main/java/org/doochul/support/KeyGeneratorImpl.java @@ -0,0 +1,12 @@ +package org.doochul.support; + +import org.springframework.stereotype.Component; + +@Component +public class KeyGeneratorImpl implements KeyGenerator { + + @Override + public String generateAccountKey(String userToken) { + return userToken + ":token"; + } +} diff --git a/doochul/src/main/java/org/doochul/ui/FCMController.java b/doochul/src/main/java/org/doochul/ui/FCMController.java new file mode 100644 index 0000000..5372f0a --- /dev/null +++ b/doochul/src/main/java/org/doochul/ui/FCMController.java @@ -0,0 +1,19 @@ +package org.doochul.ui; + +import lombok.RequiredArgsConstructor; +import org.doochul.infra.FcmMessageSender; +import org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.GetMapping; + +@Controller +@RequiredArgsConstructor +public class FCMController { + + private final FcmMessageSender init; + + @GetMapping("/v1") + public String v1(){ + init.initialize(); + return "index"; + } +} diff --git a/doochul/src/main/resources/application.properties b/doochul/src/main/resources/application.properties index 8b13789..7365132 100644 --- a/doochul/src/main/resources/application.properties +++ b/doochul/src/main/resources/application.properties @@ -1 +1,4 @@ +spring.datasource.url=jdbc:h2:mem:testdb;MODE=MySQL; + +fcm.certification.path=kwakdoochul-bbb43-firebase-adminsdk-oyokf-f075393454.json \ No newline at end of file diff --git a/doochul/src/main/resources/kwakdoochul-bbb43-firebase-adminsdk-oyokf-f075393454.json b/doochul/src/main/resources/kwakdoochul-bbb43-firebase-adminsdk-oyokf-f075393454.json new file mode 100644 index 0000000..39a5f4b --- /dev/null +++ b/doochul/src/main/resources/kwakdoochul-bbb43-firebase-adminsdk-oyokf-f075393454.json @@ -0,0 +1,13 @@ +{ + "type": "service_account", + "project_id": "kwakdoochul-bbb43", + "private_key_id": "f075393454aacff2614abfc5fa982bc9028da599", + "private_key": "-----BEGIN PRIVATE KEY-----\nMIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQDSF2TQnKW0ZiGE\nc4NJEXWgZ4y5BJe8f+ifZute82X6HnNcyL80vRf2B9EOOOwsJeu/BFwBHOer+tOa\nWzGhvSgFyo9u0zkQMXiIbQGXwqb+sEYZQkUFSxncd753s8GaVLsyybYTvJxlmg8R\nuF4Ut1LPuvvnkEC9Zkn7sp2ueKkumBAfCex/+bsEA25UdqQfy7Bv14UyTvzvKMBU\nYO7lWKsC4T8hqRoXcysGF5RjnptXp+Qrnwar6r3uTbSLGj5tuiI9ooT2oZYqcux1\nNvTwPqVVjADH6iVJ/w3mteFtlQ35bODdo7ifL8Rcl/y9Eo1zkN18mo5ivCZxpx65\nBM8w81hXAgMBAAECggEAO9gGE5ZLbTOaYIw23V1n0CUu5JT2U+9rZ9x9NzLF3ZVG\n7ysPrAohBSf82LxGKd0zZdnsCzUcmNR96f4ICTZEbEHi0YLBQmeVGedTCNOdlfbI\nQPGrj3JxD2fcjWRaxYdVO4ZRoxWaxyY3HKkTw3rkz99HWhKxHkJA2L1sRQKBzo9l\n9NVprgDw3emWyWih5jpgtWwM2umpcB3JrEcmqMW30jhJYOz94+P1JRAX/coPfeHa\njZUnTDnuP4/XW/efnVwN3DPc+TADVsFZ0J5X3foQbm3qcUYHxlH4qZg1DwEPA5wS\nKiGE8A8PDsGm7vEbmyNce0FYcjnpaqix05X+3B4JQQKBgQDwGmlDNM9/dNWv/pts\ntJTrZxObVgBF/zHMxDO9aqUBTEAP1wQVOd3g8QXJePiqySjib0LKOu0WaQq4XToa\nIM6jnFXM+bkqU3r6HQx4eCaCAhxhER4US55jgEUW8goFTIsE05kHI+q/6ZLRdm45\nMLNKJlXBKA9lMTEG9uiQJW7OiQKBgQDgAE2GMg+k8rQf0sPbxlJEBa7fngO5teK9\nq0NCQ/P/ZR3DDK7MkvUakL8dR8LGYBJkNH4tnLEnsSuh5LceCKHWZEd91D+FXQU2\nnBJ0LpCB/u4Mcbr+Ho1wlz9/Efn5ar3oBZpd3RKsmrlkwQTV/FakTjwxndEurRi5\n2H4uOx433wKBgEPI0QreL+5lx6YmFS79VEWZFhn2j6EzSJXslkbVgrv5EOTn6Qkt\nCwzkPqQAeQOOQvKaQprhQ+ndwd8Gws55kJz0F+0EW1gttTxDUy/3i7eMbQKiWIGW\nT6L7pYWy001nrJ+yNTOk2jNP99kWvEt9CkDWzcL4UlBZMOQsdL+tMbkxAoGAUmuK\nGuB8pSKwaC0y58DXMDQvHhSUJlbocQV9H/rE4qogA069WoSQLxAnYeyvnDJpUfmG\nm93VyVcFSPJQ9noSokIPlBrurHGHo4pVt+4SOeLFUErglPRE8rKUKHtC0SUXbzHw\nlKztXBkqMgpbbykD8DzhJRh+iYAjYu5nrzcNbV0CgYARTeYvHHEHNh50AruWpDPI\nb4uQX47m8H0KU5m3kd3uo4x4gTXGiPsgHsBkJXPVdyP53Fs1iA1woYh6v8aveWY+\nim37dwiuqM+V5rAum8Gz/i/bWeMJ+rCGcH03xqleT8YR+xx+izvE1m9kDbkpP9GL\n89SzmBbtOGd3/YkGgIwocA==\n-----END PRIVATE KEY-----\n", + "client_email": "firebase-adminsdk-oyokf@kwakdoochul-bbb43.iam.gserviceaccount.com", + "client_id": "104118801227229993047", + "auth_uri": "https://accounts.google.com/o/oauth2/auth", + "token_uri": "https://oauth2.googleapis.com/token", + "auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs", + "client_x509_cert_url": "https://www.googleapis.com/robot/v1/metadata/x509/firebase-adminsdk-oyokf%40kwakdoochul-bbb43.iam.gserviceaccount.com", + "universe_domain": "googleapis.com" +} diff --git a/doochul/src/main/resources/static/firebase-messaging-sw.js b/doochul/src/main/resources/static/firebase-messaging-sw.js new file mode 100644 index 0000000..b81203b --- /dev/null +++ b/doochul/src/main/resources/static/firebase-messaging-sw.js @@ -0,0 +1,20 @@ +importScripts('https://www.gstatic.com/firebasejs/5.9.2/firebase-app.js'); +importScripts('https://www.gstatic.com/firebasejs/5.9.2/firebase-messaging.js'); + +// Initialize Firebase +const firebaseConfig = { + apiKey: "AIzaSyAzHogvZK_6BoCm6Qa17wBTEwvfuFEocLA", + authDomain: "kwakdoochul-bbb43.firebaseapp.com", + projectId: "kwakdoochul-bbb43", + storageBucket: "kwakdoochul-bbb43.appspot.com", + messagingSenderId: "801744474814", + appId: "1:801744474814:web:7ed72e926feb81dda684ef", + measurementId: "G-02EJZQPZ4D" +}; + +firebase.initializeApp(firebaseConfig); +const messaging = firebase.messaging(); + + + + diff --git a/doochul/src/main/resources/templates/index.html b/doochul/src/main/resources/templates/index.html new file mode 100644 index 0000000..ab3898e --- /dev/null +++ b/doochul/src/main/resources/templates/index.html @@ -0,0 +1,57 @@ + + + + + wewe + + +

Test page! dd

+ + + + + + + + + + diff --git a/doochul/src/test/java/org/doochul/service/LessonStatusTest.java b/doochul/src/test/java/org/doochul/service/LessonStatusTest.java new file mode 100644 index 0000000..f8f0b9d --- /dev/null +++ b/doochul/src/test/java/org/doochul/service/LessonStatusTest.java @@ -0,0 +1,69 @@ +package org.doochul.service; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import org.junit.jupiter.api.Test; + +class LessonStatusTest { + + @Test + void testBeforeLessonMessage() { + LocalDateTime time = LocalDateTime.now(); + LessonStatus lessonStatus = LessonStatus.BEFORE_LESSON; + String expectedMessage = "John님 " + formatToLocalTime(time) + " Math 강사님 수업 잊지 않으셨죠?"; + String actualMessage = lessonStatus.getMessage("John", time, "Math"); + + assertEquals("수업 전", lessonStatus.getTitle()); + assertEquals(expectedMessage, actualMessage); + } + + @Test + void testAfterLessonMessage() { + LocalDateTime time = LocalDateTime.now(); + LessonStatus lessonStatus = LessonStatus.AFTER_LESSON; + String expectedMessage = "Jane님 오늘 수업 잘 받으셨나요?"; + String actualMessage = lessonStatus.getMessage("Jane", time, "English"); + + assertEquals("수업 후", lessonStatus.getTitle()); + assertEquals(expectedMessage, actualMessage); + } + + @Test + void testScheduledLessonMessage() { + LocalDateTime time = LocalDateTime.now(); + LessonStatus lessonStatus = LessonStatus.SCHEDULED_LESSON; + String expectedMessage = formatToLocalTime(time) + " Mike님 Chemistry 강사님 수업을 신청했습니다."; + String actualMessage = lessonStatus.getMessage("Mike", time, "Chemistry"); + + assertEquals("수업 신청", lessonStatus.getTitle()); + assertEquals(expectedMessage, actualMessage); + } + + @Test + void testWithdrawnLessonMessage() { + LocalDateTime time = LocalDateTime.now(); + LessonStatus lessonStatus = LessonStatus.WITHDRAWN_LESSON; + String expectedMessage = formatToLocalTime(time) + " Alice님 Physics 강사님의 수업을 철회했습니다."; + String actualMessage = lessonStatus.getMessage("Alice", time, "Physics"); + + assertEquals("수업 철회", lessonStatus.getTitle()); + assertEquals(expectedMessage, actualMessage); + } + + @Test + void testEndLessonMessage() { + LocalDateTime time = LocalDateTime.now(); + LessonStatus lessonStatus = LessonStatus.END_LESSON; + String expectedMessage = "History 강사님의 수업이 모두 종료되었습니다."; + String actualMessage = lessonStatus.getMessage("Bobby", time, "History"); + + assertEquals("수업 종료", lessonStatus.getTitle()); + assertEquals(expectedMessage, actualMessage); + } + + private String formatToLocalTime(LocalDateTime time) { + return time.format(DateTimeFormatter.ofPattern("a HH시 mm분")); + } +} diff --git a/doochul/src/test/java/org/doochul/service/NotificationServiceTest.java b/doochul/src/test/java/org/doochul/service/NotificationServiceTest.java new file mode 100644 index 0000000..4af016f --- /dev/null +++ b/doochul/src/test/java/org/doochul/service/NotificationServiceTest.java @@ -0,0 +1,52 @@ +package org.doochul.service; + +import org.doochul.application.RedisService; +import org.doochul.support.KeyGenerator; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; + +import java.time.Duration; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +import static org.mockito.BDDMockito.*; + +@SpringBootTest +class NotificationServiceTest { + @MockBean + private MessageSendManager messageSendManager; + @Autowired + private KeyGenerator keyGenerator; + @Autowired + private RedisService redisService; + + @Test + void sendNotification() throws InterruptedException { + ExecutorService executorService = Executors.newFixedThreadPool(5); + CountDownLatch latch = new CountDownLatch(5); + final Letter letter = new Letter("token", "안녕?", "테스트"); + + willDoNothing().given(messageSendManager).sendTo(any(Letter.class)); + for (int i = 0; i < 5; i++) { + executorService.submit(() -> { + try { + final String key = keyGenerator.generateAccountKey(letter.targetToken()); + if (redisService.setNX(key, "notification", Duration.ofSeconds(5))) { + messageSendManager.sendTo(letter); + redisService.delete(letter.targetToken()); + } + latch.countDown(); + } catch (Exception e) { + } + }); + } + + latch.await(); + executorService.shutdown(); + + then(messageSendManager).should(times(1)).sendTo(any(Letter.class)); + } +}