diff --git a/src/main/java/com/fastcampus/book_bot/domain/noti/NotificationLogs.java b/src/main/java/com/fastcampus/book_bot/domain/noti/NotificationLogs.java new file mode 100644 index 0000000..f817dfa --- /dev/null +++ b/src/main/java/com/fastcampus/book_bot/domain/noti/NotificationLogs.java @@ -0,0 +1,59 @@ +package com.fastcampus.book_bot.domain.noti; + +import com.fastcampus.book_bot.domain.book.Book; +import com.fastcampus.book_bot.domain.user.User; +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.springframework.data.annotation.CreatedDate; + +import java.time.LocalDateTime; + +@Entity +@Table(name = "notification_logs") +@Getter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class NotificationLogs { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Integer id; + + @Column(name = "sub_id", nullable = false) + private Integer subId; + + @Column(name = "user_id", nullable = false) + private Integer userId; + + @Column(name = "book_id", nullable = false) + private Integer bookId; + + @Column(name = "threshold_quantity", nullable = false) + private Integer thresholdQuantity; + + @Column(name = "current_stock", nullable = false) + private Integer currentStock; + + @Column(name = "message", nullable = false, columnDefinition = "TEXT") + private String message; + + @CreatedDate + @Column(name = "sent_at", updatable = false) + private LocalDateTime sentAt; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "sub_id", insertable = false, updatable = false) + private NotificationSub notificationSub; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", insertable = false, updatable = false) + private User user; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "book_id", insertable = false, updatable = false) + private Book book; +} diff --git a/src/main/java/com/fastcampus/book_bot/domain/noti/NotificationSub.java b/src/main/java/com/fastcampus/book_bot/domain/noti/NotificationSub.java new file mode 100644 index 0000000..9733e46 --- /dev/null +++ b/src/main/java/com/fastcampus/book_bot/domain/noti/NotificationSub.java @@ -0,0 +1,55 @@ +package com.fastcampus.book_bot.domain.noti; + +import com.fastcampus.book_bot.domain.book.Book; +import com.fastcampus.book_bot.domain.user.User; +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.springframework.data.annotation.CreatedDate; + +import java.time.LocalDateTime; +import java.util.List; + +@Entity +@Table(name = "notification_sub") +@Getter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class NotificationSub { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Integer id; + + @Column(name = "user_id", nullable = false) + private Integer userId; + + @Column(name = "book_id", nullable = false) + private Integer bookId; + + @Column(name = "threshold_quantity", nullable = false) + @Builder.Default + private Integer thresholdQuantity = 5; + + @Column(name = "is_active", nullable = false) + @Builder.Default + private Boolean isActive = true; + + @CreatedDate + @Column(name = "created_at", updatable = false) + private LocalDateTime createdAt; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", insertable = false, updatable = false) + private User user; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "book_id", insertable = false, updatable = false) + private Book book; + + @OneToMany(mappedBy = "notificationSub", cascade = CascadeType.ALL, orphanRemoval = true) + private List notificationLogs; +} diff --git a/src/main/java/com/fastcampus/book_bot/repository/BookRepository.java b/src/main/java/com/fastcampus/book_bot/repository/BookRepository.java index e1bf658..e1f27ad 100644 --- a/src/main/java/com/fastcampus/book_bot/repository/BookRepository.java +++ b/src/main/java/com/fastcampus/book_bot/repository/BookRepository.java @@ -4,7 +4,11 @@ import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; +import org.springframework.transaction.annotation.Transactional; import java.util.Optional; @@ -17,4 +21,7 @@ public interface BookRepository extends JpaRepository { Page findByBookPublisherContaining(String bookPublisher, Pageable pageable); Page findByBookNameContainingOrBookAuthorContainingOrBookPublisherContaining(String bookTitle, String bookAuthor, String bookPublisher, Pageable pageable); + @Modifying + @Query("UPDATE Book b SET b.bookQuantity = :newQuantity WHERE b.bookId = :bookId") + void updateBookQuantity(@Param("bookId") Integer bookId, @Param("newQuantity") Integer newQuantity); } \ No newline at end of file diff --git a/src/main/java/com/fastcampus/book_bot/repository/NotificationSubRepository.java b/src/main/java/com/fastcampus/book_bot/repository/NotificationSubRepository.java new file mode 100644 index 0000000..2772278 --- /dev/null +++ b/src/main/java/com/fastcampus/book_bot/repository/NotificationSubRepository.java @@ -0,0 +1,10 @@ +package com.fastcampus.book_bot.repository; + +import com.fastcampus.book_bot.domain.noti.NotificationSub; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface NotificationSubRepository extends JpaRepository { + List findActiveByBookIdAndThresholdQuantity(Integer bookId, Integer thresholdQuantity); +} diff --git a/src/main/java/com/fastcampus/book_bot/service/auth/MailService.java b/src/main/java/com/fastcampus/book_bot/service/auth/MailService.java index 85eacea..098f59e 100644 --- a/src/main/java/com/fastcampus/book_bot/service/auth/MailService.java +++ b/src/main/java/com/fastcampus/book_bot/service/auth/MailService.java @@ -13,6 +13,8 @@ import org.thymeleaf.context.Context; import java.time.Duration; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; import java.util.Map; import java.util.Random; @@ -124,4 +126,37 @@ public boolean verifyCode(String userEmail, String inputCode) { ); } } + + /** + * 재고 알림 이메일 발송 + * @param userEmail 사용자 이메일 + * @param bookTitle 도서 제목 + * @param currentStock 현재 재고 + * @param message 알림 메시지 + */ + public void sendStockNotification(String userEmail, String bookTitle, int currentStock, String message) { + MimeMessage mimeMessage = javaMailSender.createMimeMessage(); + + try { + MimeMessageHelper messageHelper = new MimeMessageHelper(mimeMessage, true, "UTF-8"); + messageHelper.setTo(userEmail); + messageHelper.setSubject("📚 재고 알림 - " + bookTitle); + + Context context = new Context(); + context.setVariable("bookTitle", bookTitle); + context.setVariable("currentStock", currentStock); + context.setVariable("message", message); + context.setVariable("notificationTime", LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy년 MM월 dd일 HH:mm"))); + + String htmlContent = templateEngine.process("notification/stock-alert", context); + messageHelper.setText(htmlContent, true); + + javaMailSender.send(mimeMessage); + log.info("재고 알림 이메일 발송 성공 - 수신자: {}, 도서: {}, 재고: {}권", userEmail, bookTitle, currentStock); + + } catch (Exception e) { + log.error("재고 알림 이메일 발송 실패 - 수신자: {}, 도서: {}", userEmail, bookTitle, e); + throw new RuntimeException("재고 알림 이메일 발송에 실패했습니다.", e); + } + } } \ No newline at end of file diff --git a/src/main/java/com/fastcampus/book_bot/service/grade/GradeCacheService.java b/src/main/java/com/fastcampus/book_bot/service/grade/GradeCacheService.java index 4be4613..ac9e887 100644 --- a/src/main/java/com/fastcampus/book_bot/service/grade/GradeCacheService.java +++ b/src/main/java/com/fastcampus/book_bot/service/grade/GradeCacheService.java @@ -4,16 +4,19 @@ import com.fastcampus.book_bot.dto.grade.GradeInfo; import com.fastcampus.book_bot.repository.UserGradeRepository; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.data.redis.core.RedisTemplate; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.math.BigDecimal; import java.time.Duration; +import java.util.LinkedHashMap; import java.util.Set; @Service @RequiredArgsConstructor +@Slf4j public class GradeCacheService { private final RedisTemplate redisTemplate; @@ -22,36 +25,77 @@ public class GradeCacheService { private static final String GRADE_CACHE_KEY = "grade:"; private static final Duration CACHE_TTL = Duration.ofHours(24); - @Transactional + @Transactional(readOnly = true) public GradeInfo getGradeInfo(String gradeName) { - String cacheKey = GRADE_CACHE_KEY + gradeName; - // Redis 조회 - GradeInfo cached = (GradeInfo) redisTemplate.opsForValue().get(cacheKey); - if (cached != null) { - return cached; + try { + // Redis 조회 + Object cachedValue = redisTemplate.opsForValue().get(cacheKey); + + if (cachedValue != null) { + // LinkedHashMap인 경우 GradeInfo 객체로 변환 + if (cachedValue instanceof LinkedHashMap) { + LinkedHashMap map = (LinkedHashMap) cachedValue; + return mapToGradeInfo(map); + } + // 이미 GradeInfo 객체인 경우 + else if (cachedValue instanceof GradeInfo) { + return (GradeInfo) cachedValue; + } + } + } catch (Exception e) { + log.warn("Redis에서 등급 정보 조회 실패: {}, DB에서 조회합니다.", gradeName, e); } - // 캐시에 없으면 DB에서 조회 후 캐시 저장 + // 캐시에 없거나 오류 발생 시 DB에서 조회 return userGradeRepository.findByGradeName(gradeName) .map(this::convertToGradeInfo) .map(gradeInfo -> { - redisTemplate.opsForValue().set(cacheKey, gradeInfo, CACHE_TTL); + try { + redisTemplate.opsForValue().set(cacheKey, gradeInfo, CACHE_TTL); + } catch (Exception e) { + log.warn("Redis 캐시 저장 실패: {}", gradeName, e); + } return gradeInfo; }) .orElse(getDefaultGradeInfo()); - } public void evictGradeCache(String gradeName) { - redisTemplate.delete(GRADE_CACHE_KEY + gradeName); + try { + redisTemplate.delete(GRADE_CACHE_KEY + gradeName); + } catch (Exception e) { + log.warn("Redis 캐시 삭제 실패: {}", gradeName, e); + } } public void evictAllGradeCache() { - Set keys = redisTemplate.keys(GRADE_CACHE_KEY + "*"); - if (keys != null && !keys.isEmpty()) { - redisTemplate.delete(keys); + try { + Set keys = redisTemplate.keys(GRADE_CACHE_KEY + "*"); + if (keys != null && !keys.isEmpty()) { + redisTemplate.delete(keys); + } + } catch (Exception e) { + log.warn("Redis 전체 캐시 삭제 실패", e); + } + } + + // LinkedHashMap을 GradeInfo로 변환하는 헬퍼 메서드 + private GradeInfo mapToGradeInfo(LinkedHashMap map) { + try { + return GradeInfo.builder() + .gradeName((String) map.get("gradeName")) + .minUsage(map.get("minUsage") != null ? ((Number) map.get("minUsage")).intValue() : 0) + .orderCount(map.get("orderCount") != null ? ((Number) map.get("orderCount")).intValue() : 0) + .discount(map.get("discount") != null ? + new BigDecimal(map.get("discount").toString()) : BigDecimal.ZERO) + .mileageRate(map.get("mileageRate") != null ? + new BigDecimal(map.get("mileageRate").toString()) : BigDecimal.ZERO) + .build(); + } catch (Exception e) { + log.error("LinkedHashMap을 GradeInfo로 변환 실패: {}", map, e); + return getDefaultGradeInfo(); } } @@ -60,8 +104,10 @@ private GradeInfo convertToGradeInfo(UserGrade userGrade) { .gradeName(userGrade.getGradeName()) .minUsage(userGrade.getMinUsage()) .orderCount(userGrade.getOrderCount()) - .discount(userGrade.getDiscount()) - .mileageRate(userGrade.getMileageRate()) + .discount(userGrade.getDiscount() != null ? + userGrade.getDiscount() : BigDecimal.ZERO) + .mileageRate(userGrade.getMileageRate() != null ? + userGrade.getMileageRate() : BigDecimal.ZERO) .build(); } @@ -70,8 +116,8 @@ private GradeInfo getDefaultGradeInfo() { .gradeName("BRONZE") .minUsage(100) .orderCount(1) - .discount(BigDecimal.valueOf(0.0)) - .mileageRate(BigDecimal.valueOf(0.0)) + .discount(BigDecimal.ZERO) + .mileageRate(BigDecimal.ZERO) .build(); } -} +} \ No newline at end of file diff --git a/src/main/java/com/fastcampus/book_bot/service/noti/BookStockManager.java b/src/main/java/com/fastcampus/book_bot/service/noti/BookStockManager.java new file mode 100644 index 0000000..607639a --- /dev/null +++ b/src/main/java/com/fastcampus/book_bot/service/noti/BookStockManager.java @@ -0,0 +1,86 @@ +package com.fastcampus.book_bot.service.noti; + +import com.fastcampus.book_bot.domain.book.Book; +import com.fastcampus.book_bot.domain.noti.NotificationSub; +import com.fastcampus.book_bot.repository.BookRepository; +import com.fastcampus.book_bot.repository.NotificationSubRepository; +import com.fastcampus.book_bot.service.auth.MailService; +import lombok.extern.slf4j.Slf4j; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.Optional; + +// 구체적인 관찰 대상 (Spring Component에서 제외하고 직접 인스턴스 생성) +// BookId가 런타임(주문)때 결정되므로 @RequiredArgsConstructor는 사용불가 +@Slf4j +public class BookStockManager extends StockSubject { + + private final Integer bookId; + private final BookRepository bookRepository; + private final NotificationSubRepository notificationSubRepository; + private final MailService mailService; + + // 단일 생성자 + public BookStockManager(Integer bookId, BookRepository bookRepository, + NotificationSubRepository notificationSubRepository, + MailService mailService) { + this.bookId = bookId; + this.bookRepository = bookRepository; + this.notificationSubRepository = notificationSubRepository; + this.mailService = mailService; + } + + // 재고 업데이트 (구매, 입고) 등 + @Transactional + public void updateStock(Integer newQuantity) { + bookRepository.updateBookQuantity(bookId, newQuantity); + + Optional bookOpt = bookRepository.findById(bookId); + bookOpt.ifPresent(book -> log.info("[재고 변동] {}: {}권", book.getBookName(), book.getBookQuantity())); + + notifyObservers(); + } + + @Override + @Transactional + public void notifyObservers() { + Integer currentStock = getCurrentStock(); + + // DB에서 해당 책의 활성 구독자들 중 임계값 조건에 맞는 사용자들 조회 + List subList = + notificationSubRepository.findActiveByBookIdAndThresholdQuantity(bookId, currentStock); + + log.info("재고 알림 대상자 조회 - 도서ID: {}, 현재재고: {}, 알림대상: {}명", + bookId, currentStock, subList.size()); + + // 각 구독자들에게 알림 + for (NotificationSub notificationSub : subList) { + SubscriptionObserver observer = new SubscriptionObserver( + notificationSub.getId(), + notificationSubRepository, + bookRepository, + mailService + ); + + observer.update(this); + } + } + + @Override + public int getCurrentStock() { + Optional bookOpt = bookRepository.findById(bookId); + return bookOpt.map(Book::getBookQuantity).orElse(0); + } + + @Override + public int getBookId() { + return this.bookId; + } + + @Override + public String getBookTitle() { + Optional bookOpt = bookRepository.findById(bookId); + return bookOpt.map(Book::getBookName).orElse("Unknown"); + } +} \ No newline at end of file diff --git a/src/main/java/com/fastcampus/book_bot/service/noti/StockObserver.java b/src/main/java/com/fastcampus/book_bot/service/noti/StockObserver.java new file mode 100644 index 0000000..99a58ff --- /dev/null +++ b/src/main/java/com/fastcampus/book_bot/service/noti/StockObserver.java @@ -0,0 +1,5 @@ +package com.fastcampus.book_bot.service.noti; + +public interface StockObserver { + void update(StockSubject subject); +} diff --git a/src/main/java/com/fastcampus/book_bot/service/noti/StockSubject.java b/src/main/java/com/fastcampus/book_bot/service/noti/StockSubject.java new file mode 100644 index 0000000..085212c --- /dev/null +++ b/src/main/java/com/fastcampus/book_bot/service/noti/StockSubject.java @@ -0,0 +1,13 @@ +package com.fastcampus.book_bot.service.noti; + +public abstract class StockSubject { + + // 모든 Observer에게 알림 + public abstract void notifyObservers(); + + // 현재 상태를 가져오기 + public abstract int getCurrentStock(); + public abstract int getBookId(); + public abstract String getBookTitle(); + +} diff --git a/src/main/java/com/fastcampus/book_bot/service/noti/SubscriptionObserver.java b/src/main/java/com/fastcampus/book_bot/service/noti/SubscriptionObserver.java new file mode 100644 index 0000000..2c6b9bf --- /dev/null +++ b/src/main/java/com/fastcampus/book_bot/service/noti/SubscriptionObserver.java @@ -0,0 +1,78 @@ +package com.fastcampus.book_bot.service.noti; + +import com.fastcampus.book_bot.domain.noti.NotificationSub; +import com.fastcampus.book_bot.repository.BookRepository; +import com.fastcampus.book_bot.repository.NotificationSubRepository; +import com.fastcampus.book_bot.service.auth.MailService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +import java.util.Optional; + +// 구체적인 관찰자 +@RequiredArgsConstructor +@Slf4j +public class SubscriptionObserver implements StockObserver { + + private final Integer subscriptionId; + private final NotificationSubRepository notificationSubRepository; + private final BookRepository bookRepository; + private final MailService mailService; + + @Override + public void update(StockSubject subject) { + + Optional subOptional = notificationSubRepository.findById(subscriptionId); + + if (subOptional.isEmpty()) { + log.warn("구독 정보를 찾을 수 없습니다. ID: {}", subscriptionId); + return; + } + + NotificationSub notificationSub = subOptional.get(); + + // 구독이 비활성화되어 있으면 알림하지 않음 + if (!notificationSub.getIsActive()) { + log.info("비활성화된 구독 - 알림 생략. 구독ID: {}", subscriptionId); + return; + } + + int currentStock = subject.getCurrentStock(); + int bookId = subject.getBookId(); + String bookTitle = subject.getBookTitle(); + + // 임계값 조건 확인 - 현재 재고가 설정한 임계값 이하일 때만 알림 + if (currentStock <= notificationSub.getThresholdQuantity()) { + + String message = createNotificationMessage(bookTitle, currentStock); + + try { + // 이메일 발송 + if (mailService != null) { + mailService.sendStockNotification(notificationSub.getUser().getUserEmail(), bookTitle, currentStock, message); + log.info("재고 알림 이메일 발송 완료 - 사용자: {}, 도서: {}, 재고: {}권", + notificationSub.getUser().getUserEmail(), bookTitle, currentStock); + } else { + log.warn("MailService가 null입니다. 이메일을 발송할 수 없습니다."); + } + } catch (Exception e) { + log.error("재고 알림 이메일 발송 실패 - 사용자: {}, 도서: {}", + notificationSub.getUser().getUserEmail(), bookTitle, e); + } + + } else { + log.debug("임계값 조건 미충족 - 현재재고: {}, 임계값: {}", + currentStock, notificationSub.getThresholdQuantity()); + } + } + + private String createNotificationMessage(String bookTitle, int currentStock) { + if (currentStock == 0) { + return "⚠️ '" + bookTitle + "' 품절되었습니다!"; + } else if (currentStock <= 3) { + return "🔥 '" + bookTitle + "' 재고 부족! 남은 수량: " + currentStock + "권"; + } else { + return "📢 '" + bookTitle + "' 재고 알림: " + currentStock + "권 남음"; + } + } +} \ No newline at end of file diff --git a/src/main/java/com/fastcampus/book_bot/service/order/OrderService.java b/src/main/java/com/fastcampus/book_bot/service/order/OrderService.java index 1494d39..5506bee 100644 --- a/src/main/java/com/fastcampus/book_bot/service/order/OrderService.java +++ b/src/main/java/com/fastcampus/book_bot/service/order/OrderService.java @@ -9,8 +9,11 @@ import com.fastcampus.book_bot.repository.BookRepository; import com.fastcampus.book_bot.repository.OrderBookRepository; import com.fastcampus.book_bot.repository.OrderRepository; +import com.fastcampus.book_bot.repository.NotificationSubRepository; +import com.fastcampus.book_bot.service.auth.MailService; import com.fastcampus.book_bot.service.grade.GradeStrategy; import com.fastcampus.book_bot.service.grade.GradeStrategyFactory; +import com.fastcampus.book_bot.service.noti.BookStockManager; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; @@ -28,6 +31,8 @@ public class OrderService { private final OrderBookRepository orderBookRepository; private final OrderRepository orderRepository; private final BookRepository bookRepository; + private final NotificationSubRepository notificationSubRepository; + private final MailService mailService; /** * 주문 금액 계산 (User 객체 기반) @@ -104,6 +109,7 @@ public void saveOrder(User user, OrdersDTO ordersDTO) { throw new IllegalStateException("재고가 부족합니다. 현재 재고: " + book.getBookQuantity()); } + // 주문 정보 저장 Orders order = Orders.builder() .user(user) .orderStatus("ORDER_READY") @@ -117,6 +123,7 @@ public void saveOrder(User user, OrdersDTO ordersDTO) { log.info("주문 저장 성공 - 주문ID: {}, 상태: {}, 총금액: {}", savedOrder.getOrderId(), savedOrder.getOrderStatus(), savedOrder.getTotalPrice()); + // 주문 상품 정보 저장 OrderBook orderBook = OrderBook.builder() .order(savedOrder) .book(book) @@ -128,6 +135,9 @@ public void saveOrder(User user, OrdersDTO ordersDTO) { log.info("주문상품 저장 성공 - 주문상품ID: {}, 수량: {}, 가격: {}", savedOrderBook.getOrderBookId(), savedOrderBook.getQuantity(), savedOrderBook.getPrice()); + // ===== 재고 업데이트 및 알림 처리 ===== + updateStockAndNotify(book.getBookId(), ordersDTO.getQuantity()); + log.info("=== 주문 저장 프로세스 완료 ==="); } catch (Exception e) { @@ -137,4 +147,38 @@ public void saveOrder(User user, OrdersDTO ordersDTO) { throw e; // 트랜잭션 롤백을 위해 예외 재발생 } } -} + + /** + * 재고 업데이트 및 알림 처리 + */ + @Transactional + public void updateStockAndNotify(Integer bookId, Integer orderQuantity) { + try { + // 현재 재고 조회 + Book book = bookRepository.findById(bookId) + .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 도서입니다: " + bookId)); + + // 새로운 재고 수량 계산 + Integer newQuantity = book.getBookQuantity() - orderQuantity; + log.info("재고 업데이트 시작 - 도서: {}, 기존재고: {}, 주문수량: {}, 새재고: {}", + book.getBookName(), book.getBookQuantity(), orderQuantity, newQuantity); + + // BookStockManager를 통해 재고 업데이트 및 알림 발송 + BookStockManager stockManager = new BookStockManager( + bookId, + bookRepository, + notificationSubRepository, + mailService + ); + + // 재고 업데이트 (이 메서드 내부에서 알림도 자동으로 발송됨) + stockManager.updateStock(newQuantity); + + log.info("재고 업데이트 및 알림 처리 완료 - 도서ID: {}", bookId); + + } catch (Exception e) { + log.error("재고 업데이트 및 알림 처리 중 오류 발생 - 도서ID: {}", bookId, e); + throw e; + } + } +} \ No newline at end of file diff --git a/src/main/resources/db/migration/V17__Create_table_notification.sql b/src/main/resources/db/migration/V17__Create_table_notification.sql new file mode 100644 index 0000000..4b2044f --- /dev/null +++ b/src/main/resources/db/migration/V17__Create_table_notification.sql @@ -0,0 +1,26 @@ +CREATE TABLE `notification_sub` ( + id int auto_increment primary key, + user_id int not null, + book_id int not null, + threshold_quantity int not null default 5, + is_active boolean not null default true, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + + FOREIGN KEY (user_id) REFERENCES user(user_id) ON DELETE CASCADE, + FOREIGN KEY (book_id) REFERENCES books(book_id) ON DELETE CASCADE +); + +CREATE TABLE `notification_logs` ( + id int auto_increment primary key, + sub_id int not null, + user_id int not null, + book_id int not null, + threshold_quantity INT NOT NULL, + current_stock INT NOT NULL, + message text not null, + sent_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + + FOREIGN KEY (sub_id) REFERENCES notification_sub(id) ON DELETE CASCADE, + FOREIGN KEY (user_id) REFERENCES user(user_id) ON DELETE CASCADE, + FOREIGN KEY (book_id) REFERENCES books(book_id) ON DELETE CASCADE +); \ No newline at end of file diff --git a/src/main/resources/templates/notification/stock-alert.html b/src/main/resources/templates/notification/stock-alert.html new file mode 100644 index 0000000..df030f5 --- /dev/null +++ b/src/main/resources/templates/notification/stock-alert.html @@ -0,0 +1,200 @@ + + + + + + 재고 알림 + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + +
+
+ 📢 +
+

재고 알림

+
STOCK ALERT
+
+ +
+
도서 제목
+

고객님이 관심 등록하신 도서의 재고 상황을 알려드립니다.

+
+ + +
+
현재 재고
+
0권
+
품절 상태입니다
+
재고가 얼마 남지 않았습니다
+
재고 알림 기준에 도달했습니다
+
+ + +
+ 재고 알림 메시지 +
+ + +
+ 지금 주문하기 +

+ 재고가 한정되어 있으니 서둘러 주문해보세요! +

+
+
+
+ 알림 발송 시간 +
+
+
+ + \ No newline at end of file