Skip to content

Commit

Permalink
feat: 전화하기 리뷰 유도 푸시알림 기능 구현 (#992)
Browse files Browse the repository at this point in the history
* 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 버전 변경
  • Loading branch information
krSeonghyeon authored Nov 7, 2024
1 parent cb27a9f commit 46802a9
Show file tree
Hide file tree
Showing 25 changed files with 474 additions and 3 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -285,4 +285,19 @@ ResponseEntity<ShopsResponseV2> getShopsV2(
@RequestParam(name = "sorter", defaultValue = "NONE") ShopsSortCriteria sortBy,
@RequestParam(name = "filter") List<ShopsFilterCriteria> 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<Void> createCallNotification(
@Parameter(in = PATH) @PathVariable("shopId") Integer shopId,
@Auth(permit = {STUDENT}) Integer studentId
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -200,4 +200,13 @@ public ResponseEntity<ShopReviewResponse> getReview(
ShopReviewResponse shopReviewResponse = shopReviewService.getReviewByReviewId(shopId, reviewId);
return ResponseEntity.ok(shopReviewResponse);
}

@PostMapping("/shops/{shopId}/call-notification")
public ResponseEntity<Void> createCallNotification(
@Parameter(in = PATH) @PathVariable("shopId") Integer shopId,
@Auth(permit = {STUDENT}) Integer studentId
) {
shopService.publishCallNotification(shopId, studentId);
return ResponseEntity.ok().build();
}
}
Original file line number Diff line number Diff line change
@@ -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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -41,6 +44,10 @@ public class ShopCategory extends BaseEntity {
@OneToMany(mappedBy = "shopCategory", orphanRemoval = true, cascade = {PERSIST, REMOVE})
private List<ShopCategoryMap> 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;
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
}
Original file line number Diff line number Diff line change
@@ -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;
}
}
Original file line number Diff line number Diff line change
@@ -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;
}
Original file line number Diff line number Diff line change
@@ -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, Integer> {

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<ShopNotificationBuffer> findByNotificationTimeBefore(@Param("now") LocalDateTime now);

List<ShopNotificationBuffer> findAll();

int deleteByNotificationTimeBefore(LocalDateTime now);
}
Original file line number Diff line number Diff line change
@@ -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("리뷰유도 알림 전송 과정에서 오류가 발생했습니다.");
}
}
}
Original file line number Diff line number Diff line change
@@ -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<ShopNotificationBuffer> dueNotifications = shopNotificationBufferRepository.findByNotificationTimeBefore(now);
if (dueNotifications.isEmpty()) {
return;
}

List<Notification> 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
);
}
}
Loading

0 comments on commit 46802a9

Please sign in to comment.