CartService.java
package com.ecommerce.trade.application.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.ecommerce.trade.application.exception.TradeError;
import com.ecommerce.trade.application.exception.TradeException;
import com.ecommerce.trade.application.model.TradeModels.CartItemView;
import com.ecommerce.trade.application.model.TradeModels.GuestBagItemCommand;
import com.ecommerce.trade.application.service.CatalogSnapshotService.ResolvedLine;
import com.ecommerce.trade.infrastructure.persistence.entity.CartItemEntity;
import com.ecommerce.trade.infrastructure.persistence.entity.CartMergeRequestEntity;
import com.ecommerce.trade.infrastructure.persistence.mapper.CartItemMapper;
import com.ecommerce.trade.infrastructure.persistence.mapper.CartMergeRequestMapper;
import com.ecommerce.trade.infrastructure.sharding.TradeShardRouter;
import org.springframework.stereotype.Service;
import org.springframework.transaction.support.TransactionTemplate;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.time.Instant;
import java.util.Comparator;
import java.util.HashSet;
import java.util.HexFormat;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
@Service
public class CartService {
private static final int MAX_CART_ITEMS = 100;
private static final long MAX_CART_QUANTITY = 1_000_000_000L;
private final CartItemMapper cartItemMapper;
private final CartMergeRequestMapper cartMergeRequestMapper;
private final CatalogSnapshotService snapshotService;
private final TransactionTemplate transactionTemplate;
private final TradeShardRouter shardRouter;
public CartService(
CartItemMapper cartItemMapper,
CartMergeRequestMapper cartMergeRequestMapper,
CatalogSnapshotService snapshotService,
TransactionTemplate transactionTemplate,
TradeShardRouter shardRouter) {
this.cartItemMapper = cartItemMapper;
this.cartMergeRequestMapper = cartMergeRequestMapper;
this.snapshotService = snapshotService;
this.transactionTemplate = transactionTemplate;
this.shardRouter = shardRouter;
}
public CartItemView putItem(Long userId, Long productId, Long skuId, long quantity, boolean selected) {
if (!shardRouter.isRouted()) {
return shardRouter.executeForUser(
userId, () -> putItem(userId, productId, skuId, quantity, selected));
}
ResolvedLine snapshot = snapshotService.resolveOne(productId, skuId, quantity);
return transactionTemplate.execute(ignored -> {
Instant now = cartItemMapper.currentTime();
lockUserCart(userId, now);
CartItemEntity item = cartItemMapper.selectForUpdate(userId, skuId);
boolean created = item == null;
if (created) {
requireCartCapacity(userId);
item = new CartItemEntity();
item.setId(IdWorker.getId());
item.setUserId(userId);
item.setSkuId(skuId);
item.setCreatedAt(now);
}
item.setProductId(productId);
applySnapshot(item, snapshot);
item.setQuantity(quantity);
item.setSelected(selected);
item.setUpdatedAt(now);
if (created) {
cartItemMapper.insert(item);
} else {
requireUpdated(cartItemMapper.updateById(item));
}
return view(item);
});
}
public List<CartItemView> mergeGuestBag(
Long userId,
String mergeKey,
List<GuestBagItemCommand> requestedItems) {
if (!shardRouter.isRouted()) {
return shardRouter.executeForUser(
userId, () -> mergeGuestBag(userId, mergeKey, requestedItems));
}
List<GuestBagItemCommand> items = canonicalItems(requestedItems);
Map<Long, ResolvedLine> snapshotsBySku = new LinkedHashMap<>();
items.forEach(item -> snapshotsBySku.put(
item.skuId(),
snapshotService.resolveOne(item.productId(), item.skuId(), item.quantity())));
String requestHash = requestHash(items);
Instant now = cartItemMapper.currentTime();
long requestId = IdWorker.getId();
transactionTemplate.executeWithoutResult(ignored -> {
lockUserCart(userId, now);
CartMergeRequestEntity candidate = new CartMergeRequestEntity();
candidate.setId(requestId);
candidate.setUserId(userId);
candidate.setMergeKey(mergeKey);
candidate.setRequestHash(requestHash);
candidate.setCreatedAt(now);
cartMergeRequestMapper.insertOrKeepExisting(candidate);
CartMergeRequestEntity request = cartMergeRequestMapper.selectByKeyForUpdate(userId, mergeKey);
if (request == null) {
throw new TradeException(TradeError.CONCURRENT_MODIFICATION);
}
if (!constantEquals(request.getRequestHash(), requestHash)) {
throw new TradeException(TradeError.IDEMPOTENCY_CONFLICT);
}
if (!Objects.equals(request.getId(), requestId)) {
return;
}
long currentItemCount = cartItemMapper.selectCount(new LambdaQueryWrapper<CartItemEntity>()
.eq(CartItemEntity::getUserId, userId));
for (GuestBagItemCommand command : items) {
CartItemEntity item = cartItemMapper.selectForUpdate(userId, command.skuId());
if (item == null) {
if (currentItemCount >= MAX_CART_ITEMS) {
throw new TradeException(TradeError.CART_LIMIT_EXCEEDED);
}
item = new CartItemEntity();
item.setId(IdWorker.getId());
item.setUserId(userId);
item.setProductId(command.productId());
item.setSkuId(command.skuId());
applySnapshot(item, snapshotsBySku.get(command.skuId()));
item.setQuantity(command.quantity());
item.setSelected(true);
item.setCreatedAt(now);
item.setUpdatedAt(now);
cartItemMapper.insert(item);
currentItemCount++;
continue;
}
if (!Objects.equals(item.getProductId(), command.productId())) {
throw new TradeException(TradeError.INVALID_CART_MERGE);
}
applySnapshot(item, snapshotsBySku.get(command.skuId()));
item.setQuantity(Math.min(MAX_CART_QUANTITY, item.getQuantity() + command.quantity()));
item.setSelected(true);
item.setUpdatedAt(now);
requireUpdated(cartItemMapper.updateById(item));
}
});
return listItems(userId);
}
public List<CartItemView> listItems(Long userId) {
if (!shardRouter.isRouted()) {
return shardRouter.executeForUser(userId, () -> listItems(userId));
}
return cartItemMapper.selectList(new LambdaQueryWrapper<CartItemEntity>()
.eq(CartItemEntity::getUserId, userId)
.orderByDesc(CartItemEntity::getUpdatedAt))
.stream()
.map(this::view)
.toList();
}
public void removeItem(Long userId, Long skuId) {
if (!shardRouter.isRouted()) {
shardRouter.runForUser(userId, () -> removeItem(userId, skuId));
return;
}
transactionTemplate.executeWithoutResult(ignored -> {
lockUserCart(userId, cartItemMapper.currentTime());
cartItemMapper.delete(new LambdaQueryWrapper<CartItemEntity>()
.eq(CartItemEntity::getUserId, userId)
.eq(CartItemEntity::getSkuId, skuId));
});
}
private CartItemView view(CartItemEntity item) {
if (!hasDisplaySnapshot(item)) {
applySnapshot(item, snapshotService.resolveOne(
item.getProductId(), item.getSkuId(), item.getQuantity()));
}
return new CartItemView(
item.getId(), item.getProductId(), item.getSkuId(), item.getProductTitle(),
item.getSkuName(), item.getSpecJson(), item.getUnitPrice(),
item.getQuantity(), item.getSelected());
}
private void applySnapshot(CartItemEntity item, ResolvedLine snapshot) {
item.setProductTitle(snapshot.productTitle());
item.setSkuName(snapshot.skuName());
item.setSpecJson(snapshot.specJson());
item.setUnitPrice(snapshot.unitPrice());
}
private boolean hasDisplaySnapshot(CartItemEntity item) {
return item.getProductTitle() != null
&& item.getSkuName() != null
&& item.getSpecJson() != null
&& item.getUnitPrice() != null;
}
private void requireUpdated(int rows) {
if (rows != 1) {
throw new TradeException(TradeError.CONCURRENT_MODIFICATION);
}
}
private void lockUserCart(Long userId, Instant now) {
cartMergeRequestMapper.ensureUserLock(userId, now);
if (!Objects.equals(cartMergeRequestMapper.lockUser(userId), userId)) {
throw new TradeException(TradeError.CONCURRENT_MODIFICATION);
}
}
private void requireCartCapacity(Long userId) {
Long count = cartItemMapper.selectCount(new LambdaQueryWrapper<CartItemEntity>()
.eq(CartItemEntity::getUserId, userId));
if (count >= MAX_CART_ITEMS) {
throw new TradeException(TradeError.CART_LIMIT_EXCEEDED);
}
}
private List<GuestBagItemCommand> canonicalItems(List<GuestBagItemCommand> requestedItems) {
Set<Long> skuIds = new HashSet<>();
for (GuestBagItemCommand item : requestedItems) {
if (!skuIds.add(item.skuId())) {
throw new TradeException(TradeError.INVALID_CART_MERGE);
}
}
return requestedItems.stream()
.sorted(Comparator.comparing(GuestBagItemCommand::skuId)
.thenComparing(GuestBagItemCommand::productId))
.toList();
}
private String requestHash(List<GuestBagItemCommand> items) {
StringBuilder canonical = new StringBuilder();
items.forEach(item -> canonical
.append(item.productId()).append(':')
.append(item.skuId()).append(':')
.append(item.quantity()).append(';'));
try {
return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256")
.digest(canonical.toString().getBytes(StandardCharsets.UTF_8)));
} catch (NoSuchAlgorithmException exception) {
throw new IllegalStateException("SHA-256 is unavailable", exception);
}
}
private boolean constantEquals(String left, String right) {
return MessageDigest.isEqual(
left.getBytes(StandardCharsets.UTF_8),
right.getBytes(StandardCharsets.UTF_8));
}
}