diff --git a/src/main/java/com/tenure/domain/notification/enums/NotificationType.java b/src/main/java/com/tenure/domain/notification/enums/NotificationType.java index 04f03071..e66ac70b 100644 --- a/src/main/java/com/tenure/domain/notification/enums/NotificationType.java +++ b/src/main/java/com/tenure/domain/notification/enums/NotificationType.java @@ -31,6 +31,7 @@ public enum NotificationType { // 관심 아이템 WISH_CREATED(INTEREST, ITEM), // 타 사용자가 내 아이템을 관심 등록함 PRODUCT_CREATED(ITEM_NEWS, ITEM), // 관심 아이템 판매 전환됨 + PRODUCT_TRADING_STARTED(ITEM_NEWS, ITEM), // 관심 아이템 거래 시작됨 PRODUCT_RETURNED_TO_UNSOLD(ITEM_NEWS, ITEM), // 관심 아이템 미판매 전환됨 PRODUCT_SOLD(ITEM_NEWS, ITEM), // 관심 아이템 판매 완료됨 PRODUCT_PRICE_CHANGED(ITEM_NEWS, ITEM), // 관심 아이템 가격 변경됨 diff --git a/src/main/java/com/tenure/domain/notification/service/NotificationFactory.java b/src/main/java/com/tenure/domain/notification/service/NotificationFactory.java index ee8b0cee..86b87288 100644 --- a/src/main/java/com/tenure/domain/notification/service/NotificationFactory.java +++ b/src/main/java/com/tenure/domain/notification/service/NotificationFactory.java @@ -240,6 +240,16 @@ public Notification productCreated(User receiver, Item item) { ); } + // 거래 시작 (관심 유저에게) + public Notification productTradingStarted(User receiver, Item item) { + return Notification.of( + receiver, NotificationType.PRODUCT_TRADING_STARTED, + "관심 아이템이 거래를 시작했어요.", item.getId(), + null, item.getBrandName(), item.getItemName(), + item.getRepresentativeImageUrl() + ); + } + // 미판매 전환 (관심 유저에게) public Notification productReturnedToUnsold(User receiver, Item item) { return Notification.of( diff --git a/src/main/java/com/tenure/domain/product/service/ProductService.java b/src/main/java/com/tenure/domain/product/service/ProductService.java index 1ca826c3..9ba53fad 100644 --- a/src/main/java/com/tenure/domain/product/service/ProductService.java +++ b/src/main/java/com/tenure/domain/product/service/ProductService.java @@ -302,14 +302,28 @@ public ProductDeleteResponse deleteProduct(Long productId, Long currentUserId) { return ProductDeleteResponse.of(product, item); } + @Transactional + public void notifyWishersTradingStarted(Item item, Long excludeUserId) { + notifyWishedUsers(item, excludeUserId, receiver -> notificationFactory.productTradingStarted(receiver, item)); + } + private void notifyWishedUsers(Item item, Function notificationCreator) { + notifyWishedUsers(item, null, notificationCreator); + } + + private void notifyWishedUsers(Item item, Long excludeUserId, Function notificationCreator) { List receivers = wishRepository.findNotificationReceiversByItemId(item.getId()); if (receivers == null || receivers.isEmpty()) { return; } - notificationService.saveAll(receivers.stream() + List notifications = receivers.stream() + .filter(receiver -> excludeUserId == null || !receiver.getId().equals(excludeUserId)) .map(notificationCreator) - .toList()); + .toList(); + if (notifications.isEmpty()) { + return; + } + notificationService.saveAll(notifications); } private void validateOwner(Item item, Long currentUserId) { diff --git a/src/main/java/com/tenure/domain/trade/service/PurchaseIntentAcceptService.java b/src/main/java/com/tenure/domain/trade/service/PurchaseIntentAcceptService.java index 8fae32a2..6d818526 100644 --- a/src/main/java/com/tenure/domain/trade/service/PurchaseIntentAcceptService.java +++ b/src/main/java/com/tenure/domain/trade/service/PurchaseIntentAcceptService.java @@ -2,9 +2,11 @@ import com.tenure.domain.follow.enums.FollowStatus; import com.tenure.domain.follow.repository.FollowRelationshipRepository; +import com.tenure.domain.item.entity.Item; import com.tenure.domain.item.repository.ItemRepository; import com.tenure.domain.product.entity.Product; import com.tenure.domain.product.repository.ProductRepository; +import com.tenure.domain.product.service.ProductService; import com.tenure.domain.purchase.entity.PurchaseIntent; import com.tenure.domain.purchase.enums.PurchaseIntentStatus; import com.tenure.domain.purchase.exception.PurchaseIntentErrorCode; @@ -32,6 +34,7 @@ public class PurchaseIntentAcceptService { private final ProductRepository productRepository; private final ItemRepository itemRepository; + private final ProductService productService; private final PurchaseIntentRepository purchaseIntentRepository; private final TradeRepository tradeRepository; private final PurchaseIntentExpirationService purchaseIntentExpirationService; @@ -43,7 +46,7 @@ public TradeDetailResponse acceptPurchaseIntent(Long intentId, Long currentUserI .orElseThrow(() -> new CustomException(PurchaseIntentErrorCode.PURCHASE_INTENT_NOT_FOUND)); Product product = productRepository.findByIdForUpdate(productId) .orElseThrow(() -> new CustomException(PurchaseIntentErrorCode.PRODUCT_NOT_FOUND)); - itemRepository.findByIdForUpdate(product.getItem().getId()) + Item item = itemRepository.findByIdForUpdate(product.getItem().getId()) .orElseThrow(() -> new CustomException(PurchaseIntentErrorCode.ITEM_NOT_FOUND)); PurchaseIntent intent = purchaseIntentRepository.findByIdForUpdate(intentId) .orElseThrow(() -> new CustomException(PurchaseIntentErrorCode.PURCHASE_INTENT_NOT_FOUND)); @@ -66,6 +69,11 @@ public TradeDetailResponse acceptPurchaseIntent(Long intentId, Long currentUserI Trade trade = Trade.create(toTradeCreateCommand(intent)); tradeRepository.save(trade); + // 이 트랜잭션은 noRollbackFor = CustomException이라 중간에 CustomException이 나도 커밋된다. + // 알림을 앞단에 두면 거래가 성사되지 않은 채 위시 유저에게 "거래 시작"만 나갈 수 있으므로, + // CustomException을 던질 수 있는 구간을 모두 지난 뒤에 발송한다. + productService.notifyWishersTradingStarted(item, intent.getBuyer().getId()); + TradeViewerMode viewerMode = TradeViewerMode.SELLER; List availableActions = TradeTransition.resolveActions(trade.getStatus(), TradeActor.from(viewerMode)); long counterpartFollowerCount = followRelationshipRepository.countByFollowing_IdAndStatus( diff --git a/src/main/resources/db/migration/V28__add_product_trading_started_to_notification_type.sql b/src/main/resources/db/migration/V28__add_product_trading_started_to_notification_type.sql new file mode 100644 index 00000000..f1d750c6 --- /dev/null +++ b/src/main/resources/db/migration/V28__add_product_trading_started_to_notification_type.sql @@ -0,0 +1,31 @@ +-- NotificationType CHECK 제약 재설정 (PRODUCT_TRADING_STARTED 추가) +ALTER TABLE notifications DROP CONSTRAINT ck_notifications_type; + +ALTER TABLE notifications + ADD CONSTRAINT ck_notifications_type CHECK ( + type IN ( + + 'PURCHASE_INTENT_SENT', + 'PURCHASE_OFFER_SENT', + 'REQUEST_ACCEPTED', + 'REQUEST_REJECTED', + 'REQUEST_CANCELED_BY_REQUESTER', + 'REQUEST_EXPIRED', + 'REQUEST_CANCELED_BY_COMPETING_ACCEPTANCE', + 'REQUEST_CANCELED_BY_ITEM_DELETE', + 'TRADE_CANCELED', + 'SHIPMENT_REGISTERED', + 'DELIVERY_COMPLETED', + 'PURCHASE_CONFIRMED', + 'SETTLEMENT_COMPLETED', + 'TRADE_COMPLETED', + 'WISH_CREATED', + 'PRODUCT_CREATED', + 'PRODUCT_TRADING_STARTED', + 'PRODUCT_RETURNED_TO_UNSOLD', + 'PRODUCT_SOLD', + 'PRODUCT_PRICE_CHANGED', + 'CHAT_MESSAGE_CREATED', + 'FOLLOW_CREATED' + ) + ); diff --git a/src/test/java/com/tenure/domain/product/service/ProductServiceTest.java b/src/test/java/com/tenure/domain/product/service/ProductServiceTest.java index e6e272b3..65bd0648 100644 --- a/src/test/java/com/tenure/domain/product/service/ProductServiceTest.java +++ b/src/test/java/com/tenure/domain/product/service/ProductServiceTest.java @@ -24,6 +24,8 @@ import com.tenure.domain.item.repository.CategoryRepository; import com.tenure.domain.item.repository.ItemHistoryRepository; import com.tenure.domain.item.repository.ItemRepository; +import com.tenure.domain.notification.entity.Notification; +import com.tenure.domain.notification.enums.NotificationType; import com.tenure.domain.notification.service.NotificationFactory; import com.tenure.domain.notification.service.NotificationService; import com.tenure.domain.ootd.entity.Ootd; @@ -619,6 +621,47 @@ void deleteProduct_rejectsNonOnSaleProduct() { .isEqualTo(ProductErrorCode.PRODUCT_ITEM_STATUS_INVALID); } + @Test + void notifyWishersTradingStarted_savesTradingStartedNotificationsExcludingBuyer() { + User seller = user(CURRENT_USER_ID, UserGrade.BASIC); + Item item = item(ITEM_ID, seller, ItemStatus.ON_SALE); + User wisher1 = user(50L, UserGrade.BASIC); + User wisher2 = user(60L, UserGrade.BASIC); + User buyer = user(70L, UserGrade.BASIC); + + when(wishRepository.findNotificationReceiversByItemId(ITEM_ID)) + .thenReturn(List.of(wisher1, wisher2, buyer)); + + productService.notifyWishersTradingStarted(item, 70L); + + @SuppressWarnings("unchecked") + ArgumentCaptor> captor = ArgumentCaptor.forClass(List.class); + verify(notificationService).saveAll(captor.capture()); + List notifications = captor.getValue(); + assertThat(notifications).hasSize(2); + assertThat(notifications) + .extracting(notification -> notification.getReceiver().getId()) + .containsExactlyInAnyOrder(50L, 60L); + assertThat(notifications) + .extracting(Notification::getType) + .containsOnly(NotificationType.PRODUCT_TRADING_STARTED); + assertThat(notifications) + .extracting(Notification::getTargetId) + .containsOnly(ITEM_ID); + } + + @Test + void notifyWishersTradingStarted_doesNothingWhenNoWishers() { + User seller = user(CURRENT_USER_ID, UserGrade.BASIC); + Item item = item(ITEM_ID, seller, ItemStatus.ON_SALE); + + when(wishRepository.findNotificationReceiversByItemId(ITEM_ID)).thenReturn(List.of()); + + productService.notifyWishersTradingStarted(item, 70L); + + verify(notificationService, never()).saveAll(any()); + } + private ProductCreateRequest request(FeePolicy feePolicy, int shippingFee, List attachedOotdIds) { return requestWithMeasurements(feePolicy, shippingFee, attachedOotdIds, bottomMeasurements()); } diff --git a/src/test/java/com/tenure/domain/trade/service/PurchaseIntentAcceptServiceTest.java b/src/test/java/com/tenure/domain/trade/service/PurchaseIntentAcceptServiceTest.java index 155294c0..f6c33d67 100644 --- a/src/test/java/com/tenure/domain/trade/service/PurchaseIntentAcceptServiceTest.java +++ b/src/test/java/com/tenure/domain/trade/service/PurchaseIntentAcceptServiceTest.java @@ -3,6 +3,7 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -19,6 +20,7 @@ import com.tenure.domain.product.enums.ProductStatus; import com.tenure.domain.product.exception.ProductErrorCode; import com.tenure.domain.product.repository.ProductRepository; +import com.tenure.domain.product.service.ProductService; import com.tenure.domain.purchase.entity.PurchaseIntent; import com.tenure.domain.purchase.enums.PurchaseIntentStatus; import com.tenure.domain.purchase.exception.PurchaseIntentErrorCode; @@ -62,6 +64,9 @@ class PurchaseIntentAcceptServiceTest { @Mock private ItemRepository itemRepository; + @Mock + private ProductService productService; + @Mock private PurchaseIntentRepository purchaseIntentRepository; @@ -81,6 +86,7 @@ private void setUpService() { purchaseIntentAcceptService = new PurchaseIntentAcceptService( productRepository, itemRepository, + productService, purchaseIntentRepository, tradeRepository, new PurchaseIntentExpirationService(new NotificationFactory(), notificationService), @@ -147,6 +153,29 @@ void acceptPurchaseIntent_worksWithoutCompetingIntents() { assertThat(intent.getStatus()).isEqualTo(PurchaseIntentStatus.ACCEPTED); } + @Test + void acceptPurchaseIntent_notifiesWishersTradingStartedExcludingBuyer() { + setUpService(); + User seller = user(SELLER_ID); + User buyer = user(BUYER_ID); + Item item = item(ITEM_ID, seller); + Product product = product(PRODUCT_ID, item, seller); + PurchaseIntent intent = existingIntent(INTENT_ID, product, buyer, seller, LocalDateTime.now().plusHours(2)); + + givenIntentDetail(product, item, intent); + when(purchaseIntentRepository.findSentByProductIdForUpdate(PRODUCT_ID, PurchaseIntentStatus.SENT)) + .thenReturn(List.of()); + when(tradeRepository.save(any(Trade.class))).thenAnswer(invocation -> { + Trade trade = invocation.getArgument(0); + ReflectionTestUtils.setField(trade, "id", 900L); + return trade; + }); + + purchaseIntentAcceptService.acceptPurchaseIntent(INTENT_ID, SELLER_ID); + + verify(productService).notifyWishersTradingStarted(item, BUYER_ID); + } + @Test void acceptPurchaseIntent_expiresPastDeadlineIntentAndCommitsExpiration() { setUpService(); @@ -250,6 +279,7 @@ void acceptPurchaseIntent_rejectsWhenProductNotOnSale() { .isEqualTo(ProductErrorCode.PRODUCT_NOT_ON_SALE); assertThat(intent.getStatus()).isEqualTo(PurchaseIntentStatus.SENT); + verify(productService, never()).notifyWishersTradingStarted(any(), any()); } private void givenIntentDetail(Product product, Item item, PurchaseIntent intent) {