Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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), // 관심 아이템 가격 변경됨
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<User, Notification> notificationCreator) {
notifyWishedUsers(item, null, notificationCreator);
}

private void notifyWishedUsers(Item item, Long excludeUserId, Function<User, Notification> notificationCreator) {
List<User> receivers = wishRepository.findNotificationReceiversByItemId(item.getId());
if (receivers == null || receivers.isEmpty()) {
return;
}
notificationService.saveAll(receivers.stream()
List<Notification> 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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand All @@ -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));
Expand All @@ -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<TradeAction> availableActions = TradeTransition.resolveActions(trade.getStatus(), TradeActor.from(viewerMode));
long counterpartFollowerCount = followRelationshipRepository.countByFollowing_IdAndStatus(
Expand Down
Original file line number Diff line number Diff line change
@@ -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'
)
);
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<List<Notification>> captor = ArgumentCaptor.forClass(List.class);
verify(notificationService).saveAll(captor.capture());
List<Notification> 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<Long> attachedOotdIds) {
return requestWithMeasurements(feePolicy, shippingFee, attachedOotdIds, bottomMeasurements());
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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;
Expand Down Expand Up @@ -62,6 +64,9 @@ class PurchaseIntentAcceptServiceTest {
@Mock
private ItemRepository itemRepository;

@Mock
private ProductService productService;

@Mock
private PurchaseIntentRepository purchaseIntentRepository;

Expand All @@ -81,6 +86,7 @@ private void setUpService() {
purchaseIntentAcceptService = new PurchaseIntentAcceptService(
productRepository,
itemRepository,
productService,
purchaseIntentRepository,
tradeRepository,
new PurchaseIntentExpirationService(new NotificationFactory(), notificationService),
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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) {
Expand Down
Loading