From 46802a9f10e28fa44b23352bad21e97fc44e6915 Mon Sep 17 00:00:00 2001 From: krSeonghyeon <149303551+krSeonghyeon@users.noreply.github.com> Date: Thu, 7 Nov 2024 20:54:29 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EC=A0=84=ED=99=94=ED=95=98=EA=B8=B0=20?= =?UTF-8?q?=EB=A6=AC=EB=B7=B0=20=EC=9C=A0=EB=8F=84=20=ED=91=B8=EC=8B=9C?= =?UTF-8?q?=EC=95=8C=EB=A6=BC=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC=ED=98=84=20?= =?UTF-8?q?(#992)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 전화하기 리뷰 유도 푸시알림 기능 추가 * feat: 초기 데이터 삽입 추가 * feat: 트랜잭션 추가 및 기본데이터 추가 * chore: flyway 파일 버전 변경 * feat: 테스트 코드 추가 * feat: 테스트 충돌로 인한 로직 이동 및 테스트코드 수정 * chore: flyway 개행 추가 * chore: Repository 메서드 순서 변경 * refactor: 불필요한 @Transactional 제거 * chore: 라인 포맷팅 수정 * chore: 변수 네이밍 수정 * feat: 매칭되는 메세지가 없는 경우 예외처리 추가 * feat: n+1 문제 해결 및 테스트코드 수정 * chore: 메소드명 변경 및 Clock 추가 * refactor: 이벤트 리스너 삭제 * chore: transactional 어노테이션 추가 * fix: 스테이지 프로덕션 DB 불일치로 인한 변경 * chore: 명칭 통일 * refactor: 리뷰 반영 * chore: flyway 버전 변경 --- .../version/service/AdminVersionService.java | 1 - .../koin/domain/shop/controller/ShopApi.java | 15 ++++ .../shop/controller/ShopController.java | 9 +++ .../NotificationMessageNotFoundException.java | 20 ++++++ .../domain/shop/model/shop/ShopCategory.java | 7 ++ .../shop/model/shop/ShopMainCategory.java | 38 ++++++++++ .../model/shop/ShopNotificationBuffer.java | 50 +++++++++++++ .../model/shop/ShopNotificationMessage.java | 33 +++++++++ .../ShopNotificationBufferRepository.java | 38 ++++++++++ .../shop/scheduler/NotificationScheduler.java | 25 +++++++ .../service/NotificationScheduleService.java | 70 +++++++++++++++++++ .../koin/domain/shop/service/ShopService.java | 33 +++++++++ .../student/model/StudentEventListener.java | 5 ++ .../student/model/StudentRegisterEvent.java | 3 +- .../student/service/StudentService.java | 4 +- .../model/NotificationFactory.java | 19 +++++ .../model/NotificationSubscribeType.java | 1 + .../NotificationSubscribeRepository.java | 2 + ..._notification_messages_and_insert_data.sql | 13 ++++ ..._main_categories_table_and_insert_data.sql | 15 ++++ ...categories_add_main_category_id_column.sql | 14 ++++ .../V90__add_shop_notification_queue.sql | 11 +++ ...t_notification_subscribe_review_prompt.sql | 4 ++ .../koin/acceptance/NotificationApiTest.java | 35 ++++++++++ .../koin/acceptance/ShopApiTest.java | 12 ++++ 25 files changed, 474 insertions(+), 3 deletions(-) create mode 100644 src/main/java/in/koreatech/koin/domain/shop/exception/NotificationMessageNotFoundException.java create mode 100644 src/main/java/in/koreatech/koin/domain/shop/model/shop/ShopMainCategory.java create mode 100644 src/main/java/in/koreatech/koin/domain/shop/model/shop/ShopNotificationBuffer.java create mode 100644 src/main/java/in/koreatech/koin/domain/shop/model/shop/ShopNotificationMessage.java create mode 100644 src/main/java/in/koreatech/koin/domain/shop/repository/shop/ShopNotificationBufferRepository.java create mode 100644 src/main/java/in/koreatech/koin/domain/shop/scheduler/NotificationScheduler.java create mode 100644 src/main/java/in/koreatech/koin/domain/shop/service/NotificationScheduleService.java create mode 100644 src/main/resources/db/migration/V87__add_shop_notification_messages_and_insert_data.sql create mode 100644 src/main/resources/db/migration/V88__add_shop_main_categories_table_and_insert_data.sql create mode 100644 src/main/resources/db/migration/V89__alter_shop_categories_add_main_category_id_column.sql create mode 100644 src/main/resources/db/migration/V90__add_shop_notification_queue.sql create mode 100644 src/main/resources/db/migration/V91__insert_notification_subscribe_review_prompt.sql diff --git a/src/main/java/in/koreatech/koin/admin/version/service/AdminVersionService.java b/src/main/java/in/koreatech/koin/admin/version/service/AdminVersionService.java index 7df162875..c2f3083dc 100644 --- a/src/main/java/in/koreatech/koin/admin/version/service/AdminVersionService.java +++ b/src/main/java/in/koreatech/koin/admin/version/service/AdminVersionService.java @@ -14,7 +14,6 @@ import in.koreatech.koin.admin.version.repository.AdminVersionRepository; import in.koreatech.koin.domain.version.model.Version; import in.koreatech.koin.domain.version.model.VersionType; -import in.koreatech.koin.global.exception.KoinIllegalArgumentException; import in.koreatech.koin.global.model.Criteria; import lombok.RequiredArgsConstructor; diff --git a/src/main/java/in/koreatech/koin/domain/shop/controller/ShopApi.java b/src/main/java/in/koreatech/koin/domain/shop/controller/ShopApi.java index d6fb056ec..b6e4f336e 100644 --- a/src/main/java/in/koreatech/koin/domain/shop/controller/ShopApi.java +++ b/src/main/java/in/koreatech/koin/domain/shop/controller/ShopApi.java @@ -285,4 +285,19 @@ ResponseEntity getShopsV2( @RequestParam(name = "sorter", defaultValue = "NONE") ShopsSortCriteria sortBy, @RequestParam(name = "filter") List shopsFilterCriterias ); + + @ApiResponses( + value = { + @ApiResponse(responseCode = "200"), + @ApiResponse(responseCode = "401", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "403", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "404", content = @Content(schema = @Schema(hidden = true))), + } + ) + @Operation(summary = "전화하기 리뷰 유도 푸시알림 요청") + @PostMapping("/shops/{shopId}/call-notification") + ResponseEntity createCallNotification( + @Parameter(in = PATH) @PathVariable("shopId") Integer shopId, + @Auth(permit = {STUDENT}) Integer studentId + ); } diff --git a/src/main/java/in/koreatech/koin/domain/shop/controller/ShopController.java b/src/main/java/in/koreatech/koin/domain/shop/controller/ShopController.java index e6ded5a71..363a07b01 100644 --- a/src/main/java/in/koreatech/koin/domain/shop/controller/ShopController.java +++ b/src/main/java/in/koreatech/koin/domain/shop/controller/ShopController.java @@ -200,4 +200,13 @@ public ResponseEntity getReview( ShopReviewResponse shopReviewResponse = shopReviewService.getReviewByReviewId(shopId, reviewId); return ResponseEntity.ok(shopReviewResponse); } + + @PostMapping("/shops/{shopId}/call-notification") + public ResponseEntity createCallNotification( + @Parameter(in = PATH) @PathVariable("shopId") Integer shopId, + @Auth(permit = {STUDENT}) Integer studentId + ) { + shopService.publishCallNotification(shopId, studentId); + return ResponseEntity.ok().build(); + } } diff --git a/src/main/java/in/koreatech/koin/domain/shop/exception/NotificationMessageNotFoundException.java b/src/main/java/in/koreatech/koin/domain/shop/exception/NotificationMessageNotFoundException.java new file mode 100644 index 000000000..cb66ffb9a --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/shop/exception/NotificationMessageNotFoundException.java @@ -0,0 +1,20 @@ +package in.koreatech.koin.domain.shop.exception; + +import in.koreatech.koin.global.exception.DataNotFoundException; + +public class NotificationMessageNotFoundException extends DataNotFoundException { + + private static final String DEFAULT_MESSAGE = "해당 상점에 해당하는 알림 메세지를 찾지 못했습니다."; + + public NotificationMessageNotFoundException(String message) { + super(message); + } + + public NotificationMessageNotFoundException(String message, String detail) { + super(message, detail); + } + + public static NotificationMessageNotFoundException withDetail(String detail) { + return new NotificationMessageNotFoundException(DEFAULT_MESSAGE, detail); + } +} diff --git a/src/main/java/in/koreatech/koin/domain/shop/model/shop/ShopCategory.java b/src/main/java/in/koreatech/koin/domain/shop/model/shop/ShopCategory.java index bce64fbc1..05a4de29e 100644 --- a/src/main/java/in/koreatech/koin/domain/shop/model/shop/ShopCategory.java +++ b/src/main/java/in/koreatech/koin/domain/shop/model/shop/ShopCategory.java @@ -10,8 +10,11 @@ import in.koreatech.koin.global.domain.BaseEntity; import jakarta.persistence.Column; import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; import jakarta.persistence.GeneratedValue; import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; import jakarta.persistence.OneToMany; import jakarta.persistence.Table; import jakarta.validation.constraints.Size; @@ -41,6 +44,10 @@ public class ShopCategory extends BaseEntity { @OneToMany(mappedBy = "shopCategory", orphanRemoval = true, cascade = {PERSIST, REMOVE}) private List shopCategoryMaps = new ArrayList<>(); + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "main_category_id", referencedColumnName = "id") + private ShopMainCategory mainCategory; + @Builder private ShopCategory(String name, String imageUrl) { this.name = name; diff --git a/src/main/java/in/koreatech/koin/domain/shop/model/shop/ShopMainCategory.java b/src/main/java/in/koreatech/koin/domain/shop/model/shop/ShopMainCategory.java new file mode 100644 index 000000000..3602b96a4 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/shop/model/shop/ShopMainCategory.java @@ -0,0 +1,38 @@ +package in.koreatech.koin.domain.shop.model.shop; + +import static lombok.AccessLevel.PROTECTED; + +import in.koreatech.koin.global.domain.BaseEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.OneToOne; +import jakarta.persistence.Table; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Entity +@NoArgsConstructor(access = PROTECTED) +@Table(name = "shop_main_categories") +public class ShopMainCategory extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Integer id; + + @NotNull + @Size(max = 255) + @Column(name = "name", nullable = false) + private String name; + + @OneToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "notification_message_id", referencedColumnName = "id", nullable = false) + private ShopNotificationMessage notificationMessage; +} diff --git a/src/main/java/in/koreatech/koin/domain/shop/model/shop/ShopNotificationBuffer.java b/src/main/java/in/koreatech/koin/domain/shop/model/shop/ShopNotificationBuffer.java new file mode 100644 index 000000000..a74807a6e --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/shop/model/shop/ShopNotificationBuffer.java @@ -0,0 +1,50 @@ +package in.koreatech.koin.domain.shop.model.shop; + +import static lombok.AccessLevel.PROTECTED; + +import java.time.LocalDateTime; + +import in.koreatech.koin.domain.user.model.User; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import jakarta.validation.constraints.NotNull; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Entity +@NoArgsConstructor(access = PROTECTED) +@Table(name = "shop_notification_buffer") +public class ShopNotificationBuffer { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Integer id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "shop_id", referencedColumnName = "id", nullable = false) + private Shop shop; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", referencedColumnName = "id", nullable = false) + private User user; + + @NotNull + @Column(name = "notification_time", columnDefinition = "TIMESTAMP", nullable = false) + private LocalDateTime notificationTime; + + @Builder + private ShopNotificationBuffer(Shop shop, User user, LocalDateTime notificationTime) { + this.shop = shop; + this.user = user; + this.notificationTime = notificationTime; + } +} diff --git a/src/main/java/in/koreatech/koin/domain/shop/model/shop/ShopNotificationMessage.java b/src/main/java/in/koreatech/koin/domain/shop/model/shop/ShopNotificationMessage.java new file mode 100644 index 000000000..0f629e0fb --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/shop/model/shop/ShopNotificationMessage.java @@ -0,0 +1,33 @@ +package in.koreatech.koin.domain.shop.model.shop; + +import static lombok.AccessLevel.PROTECTED; + +import in.koreatech.koin.global.domain.BaseEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import jakarta.validation.constraints.Size; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Entity +@NoArgsConstructor(access = PROTECTED) +@Table(name = "shop_notification_messages") +public class ShopNotificationMessage extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Integer id; + + @Size(max = 255) + @Column(name = "title", nullable = false) + private String title; + + @Size(max = 255) + @Column(name = "content", nullable = false) + private String content; +} diff --git a/src/main/java/in/koreatech/koin/domain/shop/repository/shop/ShopNotificationBufferRepository.java b/src/main/java/in/koreatech/koin/domain/shop/repository/shop/ShopNotificationBufferRepository.java new file mode 100644 index 000000000..f4ccd8c24 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/shop/repository/shop/ShopNotificationBufferRepository.java @@ -0,0 +1,38 @@ +package in.koreatech.koin.domain.shop.repository.shop; + +import java.time.LocalDateTime; +import java.util.List; + +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.Repository; +import org.springframework.data.repository.query.Param; + +import in.koreatech.koin.domain.shop.model.shop.ShopNotificationBuffer; + +public interface ShopNotificationBufferRepository extends Repository { + + ShopNotificationBuffer save(ShopNotificationBuffer shopNotificationBuffer); + + @Query(""" + SELECT b FROM ShopNotificationBuffer b + JOIN FETCH b.shop s + JOIN FETCH b.user u + JOIN FETCH s.shopCategories scm + JOIN FETCH scm.shopCategory sc + JOIN FETCH sc.mainCategory smc + JOIN FETCH smc.notificationMessage m + WHERE b.notificationTime < :now + AND sc.id = ( + SELECT MIN(sc2.id) + FROM ShopCategory sc2 + JOIN ShopCategoryMap scm2 ON scm2.shopCategory = sc2 + WHERE scm2.shop = s + AND sc2.name != '전체보기' + ) + """) + List findByNotificationTimeBefore(@Param("now") LocalDateTime now); + + List findAll(); + + int deleteByNotificationTimeBefore(LocalDateTime now); +} diff --git a/src/main/java/in/koreatech/koin/domain/shop/scheduler/NotificationScheduler.java b/src/main/java/in/koreatech/koin/domain/shop/scheduler/NotificationScheduler.java new file mode 100644 index 000000000..689d9fb7e --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/shop/scheduler/NotificationScheduler.java @@ -0,0 +1,25 @@ +package in.koreatech.koin.domain.shop.scheduler; + +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +import in.koreatech.koin.domain.shop.service.NotificationScheduleService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Component +@RequiredArgsConstructor +public class NotificationScheduler { + + private final NotificationScheduleService notificationScheduleService; + + @Scheduled(cron = "0 * * * * *") + public void sendDueNotifications() { + try { + notificationScheduleService.sendDueNotifications(); + } catch (Exception e) { + log.warn("리뷰유도 알림 전송 과정에서 오류가 발생했습니다."); + } + } +} diff --git a/src/main/java/in/koreatech/koin/domain/shop/service/NotificationScheduleService.java b/src/main/java/in/koreatech/koin/domain/shop/service/NotificationScheduleService.java new file mode 100644 index 000000000..c7731f4bb --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/shop/service/NotificationScheduleService.java @@ -0,0 +1,70 @@ +package in.koreatech.koin.domain.shop.service; + +import static in.koreatech.koin.global.fcm.MobileAppPath.SHOP; + +import java.time.Clock; +import java.time.LocalDateTime; +import java.util.List; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import in.koreatech.koin.domain.shop.exception.NotificationMessageNotFoundException; +import in.koreatech.koin.domain.shop.model.shop.Shop; +import in.koreatech.koin.domain.shop.model.shop.ShopCategoryMap; +import in.koreatech.koin.domain.shop.model.shop.ShopNotificationBuffer; +import in.koreatech.koin.domain.shop.model.shop.ShopNotificationMessage; +import in.koreatech.koin.domain.shop.repository.shop.ShopNotificationBufferRepository; +import in.koreatech.koin.domain.user.model.User; + +import in.koreatech.koin.global.domain.notification.model.Notification; +import in.koreatech.koin.global.domain.notification.model.NotificationFactory; +import in.koreatech.koin.global.domain.notification.service.NotificationService; +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class NotificationScheduleService { + + private final Clock clock; + private final ShopNotificationBufferRepository shopNotificationBufferRepository; + private final NotificationFactory notificationFactory; + private final NotificationService notificationService; + + @Transactional + public void sendDueNotifications() { + LocalDateTime now = LocalDateTime.now(clock); + List dueNotifications = shopNotificationBufferRepository.findByNotificationTimeBefore(now); + if (dueNotifications.isEmpty()) { + return; + } + + List notifications = dueNotifications.stream() + .map(this::createNotification) + .toList(); + shopNotificationBufferRepository.deleteByNotificationTimeBefore(now); + + notificationService.push(notifications); + } + + private Notification createNotification(ShopNotificationBuffer dueNotification) { + Shop shop = dueNotification.getShop(); + User user = dueNotification.getUser(); + + ShopNotificationMessage shopNotificationMessage = shop.getShopCategories().stream() + .findFirst() + .map(ShopCategoryMap::getShopCategory) + .map(shopCategory -> shopCategory.getMainCategory().getNotificationMessage()) + .orElseThrow(() -> NotificationMessageNotFoundException.withDetail("shopId: " + shop.getId())); + + return notificationFactory.generateReviewPromptNotification( + SHOP, + dueNotification.getShop().getId(), + shop.getName(), + shopNotificationMessage.getTitle(), + shopNotificationMessage.getContent(), + user + ); + } +} diff --git a/src/main/java/in/koreatech/koin/domain/shop/service/ShopService.java b/src/main/java/in/koreatech/koin/domain/shop/service/ShopService.java index 08dd8b776..24feaaad6 100644 --- a/src/main/java/in/koreatech/koin/domain/shop/service/ShopService.java +++ b/src/main/java/in/koreatech/koin/domain/shop/service/ShopService.java @@ -1,5 +1,7 @@ package in.koreatech.koin.domain.shop.service; +import static in.koreatech.koin.global.domain.notification.model.NotificationSubscribeType.REVIEW_PROMPT; + import java.time.Clock; import java.time.LocalDate; import java.time.LocalDateTime; @@ -25,14 +27,19 @@ import in.koreatech.koin.domain.shop.model.menu.MenuCategoryMap; import in.koreatech.koin.domain.shop.model.shop.Shop; import in.koreatech.koin.domain.shop.model.shop.ShopCategory; +import in.koreatech.koin.domain.shop.model.shop.ShopNotificationBuffer; import in.koreatech.koin.domain.shop.repository.event.EventArticleRepository; import in.koreatech.koin.domain.shop.repository.menu.MenuCategoryRepository; import in.koreatech.koin.domain.shop.repository.menu.MenuRepository; import in.koreatech.koin.domain.shop.repository.shop.ShopCategoryRepository; +import in.koreatech.koin.domain.shop.repository.shop.ShopNotificationBufferRepository; import in.koreatech.koin.domain.shop.repository.shop.ShopRepository; import in.koreatech.koin.domain.shop.repository.shop.dto.ShopCustomRepository; import in.koreatech.koin.domain.shop.repository.shop.dto.ShopInfoV1; import in.koreatech.koin.domain.shop.repository.shop.dto.ShopInfoV2; +import in.koreatech.koin.domain.user.model.User; +import in.koreatech.koin.domain.user.repository.UserRepository; +import in.koreatech.koin.global.domain.notification.repository.NotificationSubscribeRepository; import in.koreatech.koin.global.exception.KoinIllegalArgumentException; import lombok.RequiredArgsConstructor; @@ -48,6 +55,9 @@ public class ShopService { private final ShopCategoryRepository shopCategoryRepository; private final EventArticleRepository eventArticleRepository; private final ShopCustomRepository shopCustomRepository; + private final NotificationSubscribeRepository notificationSubscribeRepository; + private final ShopNotificationBufferRepository shopNotificationBufferRepository; + private final UserRepository userRepository; public MenuDetailResponse findMenu(Integer menuId) { Menu menu = menuRepository.getById(menuId); @@ -111,4 +121,27 @@ public ShopsResponseV2 getShopsV2(ShopsSortCriteria sortBy, List shopInfoMap = shopCustomRepository.findAllShopInfo(now); return ShopsResponseV2.from(shops, shopInfoMap, sortBy, shopsFilterCriterias, now); } + + @Transactional + public void publishCallNotification(Integer shopId, Integer studentId) { + shopRepository.getById(shopId); + + if (isSubscribeReviewNotification(studentId)) { + Shop shop = shopRepository.getById(shopId); + User user = userRepository.getById(studentId); + + ShopNotificationBuffer shopNotificationBuffer = ShopNotificationBuffer.builder() + .shop(shop) + .user(user) + .notificationTime(LocalDateTime.now().plusHours(1)) + .build(); + + shopNotificationBufferRepository.save(shopNotificationBuffer); + } + } + + private boolean isSubscribeReviewNotification(Integer studentId) { + return notificationSubscribeRepository + .existsByUserIdAndSubscribeType(studentId, REVIEW_PROMPT); + } } diff --git a/src/main/java/in/koreatech/koin/domain/student/model/StudentEventListener.java b/src/main/java/in/koreatech/koin/domain/student/model/StudentEventListener.java index 0a1018cf3..dc1d8e35c 100644 --- a/src/main/java/in/koreatech/koin/domain/student/model/StudentEventListener.java +++ b/src/main/java/in/koreatech/koin/domain/student/model/StudentEventListener.java @@ -7,6 +7,8 @@ import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.event.TransactionalEventListener; +import in.koreatech.koin.global.domain.notification.model.NotificationSubscribeType; +import in.koreatech.koin.global.domain.notification.service.NotificationService; import in.koreatech.koin.global.domain.slack.SlackClient; import in.koreatech.koin.global.domain.slack.model.SlackNotificationFactory; import lombok.RequiredArgsConstructor; @@ -18,6 +20,7 @@ public class StudentEventListener { private final SlackClient slackClient; private final SlackNotificationFactory slackNotificationFactory; + private final NotificationService notificationService; @TransactionalEventListener(phase = AFTER_COMMIT) public void onStudentEmailRequest(StudentEmailRequestEvent event) { @@ -29,5 +32,7 @@ public void onStudentEmailRequest(StudentEmailRequestEvent event) { public void onStudentRegister(StudentRegisterEvent event) { var notification = slackNotificationFactory.generateStudentRegisterCompleteNotification(event.email()); slackClient.sendMessage(notification); + + notificationService.permitNotificationSubscribe(event.studentId(), NotificationSubscribeType.REVIEW_PROMPT); } } diff --git a/src/main/java/in/koreatech/koin/domain/student/model/StudentRegisterEvent.java b/src/main/java/in/koreatech/koin/domain/student/model/StudentRegisterEvent.java index 4fe083c00..01092f04a 100644 --- a/src/main/java/in/koreatech/koin/domain/student/model/StudentRegisterEvent.java +++ b/src/main/java/in/koreatech/koin/domain/student/model/StudentRegisterEvent.java @@ -1,7 +1,8 @@ package in.koreatech.koin.domain.student.model; public record StudentRegisterEvent( - String email + String email, + Integer studentId ) { } diff --git a/src/main/java/in/koreatech/koin/domain/student/service/StudentService.java b/src/main/java/in/koreatech/koin/domain/student/service/StudentService.java index 06b6f10f3..e2b6c3b47 100644 --- a/src/main/java/in/koreatech/koin/domain/student/service/StudentService.java +++ b/src/main/java/in/koreatech/koin/domain/student/service/StudentService.java @@ -43,6 +43,8 @@ import in.koreatech.koin.global.domain.email.form.StudentRegistrationData; import in.koreatech.koin.global.domain.email.model.EmailAddress; import in.koreatech.koin.global.domain.email.service.MailService; +import in.koreatech.koin.global.domain.notification.model.NotificationSubscribeType; +import in.koreatech.koin.global.domain.notification.service.NotificationService; import in.koreatech.koin.global.exception.KoinIllegalArgumentException; import lombok.RequiredArgsConstructor; @@ -134,7 +136,7 @@ public ModelAndView authenticate(AuthTokenRequest request) { userRepository.save(student.getUser()); studentRedisRepository.deleteById(student.getUser().getEmail()); - eventPublisher.publishEvent(new StudentRegisterEvent(student.getUser().getEmail())); + eventPublisher.publishEvent(new StudentRegisterEvent(student.getUser().getEmail(), student.getId())); return new ModelAndView("success_register_config"); } diff --git a/src/main/java/in/koreatech/koin/global/domain/notification/model/NotificationFactory.java b/src/main/java/in/koreatech/koin/global/domain/notification/model/NotificationFactory.java index 3d5602aa8..9b53b4fbe 100644 --- a/src/main/java/in/koreatech/koin/global/domain/notification/model/NotificationFactory.java +++ b/src/main/java/in/koreatech/koin/global/domain/notification/model/NotificationFactory.java @@ -8,6 +8,25 @@ @Component public class NotificationFactory { + public Notification generateReviewPromptNotification( + MobileAppPath path, + Integer eventShopId, + String shopName, + String title, + String message, + User target + ) { + return new Notification( + path, + generateSchemeUri(path, eventShopId), + String.format("%s%s", shopName, title), + message, + null, + NotificationType.MESSAGE, + target + ); + } + public Notification generateShopEventCreateNotification( MobileAppPath path, Integer eventShopId, diff --git a/src/main/java/in/koreatech/koin/global/domain/notification/model/NotificationSubscribeType.java b/src/main/java/in/koreatech/koin/global/domain/notification/model/NotificationSubscribeType.java index 3bde7b51a..cfe9ac223 100644 --- a/src/main/java/in/koreatech/koin/global/domain/notification/model/NotificationSubscribeType.java +++ b/src/main/java/in/koreatech/koin/global/domain/notification/model/NotificationSubscribeType.java @@ -13,6 +13,7 @@ @Getter public enum NotificationSubscribeType { SHOP_EVENT(List.of()), + REVIEW_PROMPT(List.of()), DINING_SOLD_OUT(List.of(BREAKFAST, LUNCH, DINNER)), DINING_IMAGE_UPLOAD(List.of()), ARTICLE_KEYWORD(List.of()) diff --git a/src/main/java/in/koreatech/koin/global/domain/notification/repository/NotificationSubscribeRepository.java b/src/main/java/in/koreatech/koin/global/domain/notification/repository/NotificationSubscribeRepository.java index d4890a903..a13eca02e 100644 --- a/src/main/java/in/koreatech/koin/global/domain/notification/repository/NotificationSubscribeRepository.java +++ b/src/main/java/in/koreatech/koin/global/domain/notification/repository/NotificationSubscribeRepository.java @@ -36,4 +36,6 @@ List findByUserIdAndSubscribeType( Integer userId, NotificationSubscribeType type ); + + boolean existsByUserIdAndSubscribeType(Integer userId, NotificationSubscribeType type); } diff --git a/src/main/resources/db/migration/V87__add_shop_notification_messages_and_insert_data.sql b/src/main/resources/db/migration/V87__add_shop_notification_messages_and_insert_data.sql new file mode 100644 index 000000000..9f2e5fc01 --- /dev/null +++ b/src/main/resources/db/migration/V87__add_shop_notification_messages_and_insert_data.sql @@ -0,0 +1,13 @@ +CREATE TABLE `shop_notification_messages` +( + id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY COMMENT 'shop_notification_messages 고유 id', + title VARCHAR(255) NOT NULL COMMENT '메세지 제목', + content VARCHAR(255) NOT NULL COMMENT '메세지 내용', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL COMMENT '생성 일자', + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL ON UPDATE CURRENT_TIMESTAMP COMMENT '수정 일자' +); + +INSERT INTO `shop_notification_messages` (title, content) +VALUES (', 맛있게 드셨나요?', '드신 메뉴에 대한 리뷰를 작성해보세요!'), + (', 편안하게 이동하셨나요?', '승차하신 콜벤에 대한 리뷰를 작성해보세요!'), + (', 어떠셨나요?', '이용하신 샵에 대한 리뷰를 작성해보세요!'); diff --git a/src/main/resources/db/migration/V88__add_shop_main_categories_table_and_insert_data.sql b/src/main/resources/db/migration/V88__add_shop_main_categories_table_and_insert_data.sql new file mode 100644 index 000000000..878f8c369 --- /dev/null +++ b/src/main/resources/db/migration/V88__add_shop_main_categories_table_and_insert_data.sql @@ -0,0 +1,15 @@ +CREATE TABLE `shop_main_categories` +( + id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY COMMENT 'shop_main_categories 고유 id', + name VARCHAR(255) NOT NULL COMMENT '메인 카테고리 이름', + notification_message_id INT UNSIGNED NOT NULL COMMENT '알림 메시지 id', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL COMMENT '생성 일자', + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL ON UPDATE CURRENT_TIMESTAMP COMMENT '수정 일자', + CONSTRAINT `FK_MAIN_CATEGORIES_ON_SHOP_NOTIFICATION_MESSAGES` + FOREIGN KEY (`notification_message_id`) REFERENCES `shop_notification_messages` (`id`) +); + +INSERT INTO `shop_main_categories` (name, notification_message_id) +VALUES ('가게', 1), + ('콜벤', 2), + ('뷰티', 3); diff --git a/src/main/resources/db/migration/V89__alter_shop_categories_add_main_category_id_column.sql b/src/main/resources/db/migration/V89__alter_shop_categories_add_main_category_id_column.sql new file mode 100644 index 000000000..d511b7b60 --- /dev/null +++ b/src/main/resources/db/migration/V89__alter_shop_categories_add_main_category_id_column.sql @@ -0,0 +1,14 @@ +ALTER TABLE `shop_categories` + ADD COLUMN `main_category_id` INT UNSIGNED COMMENT '메인 카테고리 id', + ADD CONSTRAINT `FK_SHOP_CATEGORIES_ON_SHOP_MAIN_CATEGORIES` + FOREIGN KEY (`main_category_id`) + REFERENCES `shop_main_categories` (`id`); + +UPDATE shop_categories +SET main_category_id = + CASE + WHEN name IN ('기타/콜밴', '콜벤') THEN 2 + WHEN name IN ('기타', '뷰티') THEN 3 + ELSE 1 + END +WHERE id != 1; \ No newline at end of file diff --git a/src/main/resources/db/migration/V90__add_shop_notification_queue.sql b/src/main/resources/db/migration/V90__add_shop_notification_queue.sql new file mode 100644 index 000000000..47959db15 --- /dev/null +++ b/src/main/resources/db/migration/V90__add_shop_notification_queue.sql @@ -0,0 +1,11 @@ +CREATE TABLE `shop_notification_buffer` +( + id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY COMMENT 'shop_notification_buffer 고유 id', + shop_id INT UNSIGNED NOT NULL COMMENT '상점 ID', + user_id INT UNSIGNED NOT NULL COMMENT '사용자 ID', + notification_time TIMESTAMP NOT NULL COMMENT '알림 시간', + CONSTRAINT `FK_SHOP_NOTIFICATION_BUFFER_ON_SHOPS` + FOREIGN KEY (`shop_id`) REFERENCES `shops` (`id`), + CONSTRAINT `FK_SHOP_NOTIFICATION_BUFFER_ON_USERS` + FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) +); diff --git a/src/main/resources/db/migration/V91__insert_notification_subscribe_review_prompt.sql b/src/main/resources/db/migration/V91__insert_notification_subscribe_review_prompt.sql new file mode 100644 index 000000000..f0e14f27a --- /dev/null +++ b/src/main/resources/db/migration/V91__insert_notification_subscribe_review_prompt.sql @@ -0,0 +1,4 @@ +INSERT INTO notification_subscribe (created_at, updated_at, subscribe_type, user_id) +SELECT NOW(), NOW(), 'REVIEW_PROMPT', s.user_id +FROM students s + JOIN users u ON s.user_id = u.id; diff --git a/src/test/java/in/koreatech/koin/acceptance/NotificationApiTest.java b/src/test/java/in/koreatech/koin/acceptance/NotificationApiTest.java index 1dbff099b..46a65d21d 100644 --- a/src/test/java/in/koreatech/koin/acceptance/NotificationApiTest.java +++ b/src/test/java/in/koreatech/koin/acceptance/NotificationApiTest.java @@ -108,6 +108,13 @@ void setUp() { "detail_subscribes": [ \s ] + }, + { + "type": "REVIEW_PROMPT", + "is_permit": false, + "detail_subscribes": [ + \s + ] } ] } @@ -212,6 +219,13 @@ void setUp() { "detail_subscribes": [ \s ] + }, + { + "type": "REVIEW_PROMPT", + "is_permit": false, + "detail_subscribes": [ + \s + ] } ] } @@ -316,6 +330,13 @@ void setUp() { "detail_subscribes": [ \s ] + }, + { + "type": "REVIEW_PROMPT", + "is_permit": false, + "detail_subscribes": [ + \s + ] } ] } @@ -428,6 +449,13 @@ void setUp() { "detail_subscribes": [ \s ] + }, + { + "type": "REVIEW_PROMPT", + "is_permit": false, + "detail_subscribes": [ + \s + ] } ] } @@ -537,6 +565,13 @@ void setUp() { "detail_subscribes": [ \s ] + }, + { + "type": "REVIEW_PROMPT", + "is_permit": false, + "detail_subscribes": [ + \s + ] } ] } diff --git a/src/test/java/in/koreatech/koin/acceptance/ShopApiTest.java b/src/test/java/in/koreatech/koin/acceptance/ShopApiTest.java index 7a91dd5ad..ff291d402 100644 --- a/src/test/java/in/koreatech/koin/acceptance/ShopApiTest.java +++ b/src/test/java/in/koreatech/koin/acceptance/ShopApiTest.java @@ -3,6 +3,7 @@ import static in.koreatech.koin.domain.shop.model.review.ReportStatus.DISMISSED; import static in.koreatech.koin.domain.shop.model.review.ReportStatus.UNHANDLED; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @@ -62,6 +63,7 @@ class ShopApiTest extends AcceptanceTest { private Owner owner; private Student 익명_학생; + private String token_익명; @BeforeAll void setUp() { @@ -69,6 +71,7 @@ void setUp() { owner = userFixture.준영_사장님(); 마슬랜 = shopFixture.마슬랜(owner); 익명_학생 = userFixture.익명_학생(); + token_익명 = userFixture.getToken(익명_학생.getUser()); } @Test @@ -1017,4 +1020,13 @@ void setUp() { } """, 티바_영업여부, 마슬랜_영업여부))); } + + @Test + void 전화하기_발생시_정보가_알림큐에_저장된다() throws Exception { + mockMvc.perform( + post("/shops/{shopId}/call-notification", 마슬랜.getId()) + .header("Authorization", "Bearer " + token_익명) + ) + .andExpect(status().isOk()); + } }