FlashSaleService.java
package com.ecommerce.marketing.application.service;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.ecommerce.marketing.application.exception.MarketingError;
import com.ecommerce.marketing.application.exception.MarketingException;
import com.ecommerce.marketing.application.model.FlashSaleModels.CreateFlashSaleCommand;
import com.ecommerce.marketing.application.model.FlashSaleModels.FlashSaleActivityView;
import com.ecommerce.marketing.application.model.FlashSaleModels.FlashSaleAdmissionView;
import com.ecommerce.marketing.application.port.FlashSaleAdmissionStore;
import com.ecommerce.marketing.application.port.FlashSaleAdmissionStore.Decision;
import com.ecommerce.marketing.application.port.FlashSaleAdmissionStoreException;
import com.ecommerce.marketing.domain.FlashSaleAdmissionStatus;
import com.ecommerce.marketing.domain.FlashSaleActivityStatus;
import com.ecommerce.marketing.domain.OutboxStatus;
import com.ecommerce.marketing.infrastructure.flashsale.FlashSaleAdmissionProperties;
import com.ecommerce.marketing.infrastructure.persistence.entity.FlashSaleActivityEntity;
import com.ecommerce.marketing.infrastructure.persistence.entity.FlashSaleAdmissionEntity;
import com.ecommerce.marketing.infrastructure.persistence.entity.FlashSaleOutboxEventEntity;
import com.ecommerce.marketing.infrastructure.persistence.mapper.FlashSaleAdmissionMapper;
import com.ecommerce.marketing.infrastructure.persistence.mapper.FlashSaleActivityMapper;
import com.ecommerce.marketing.infrastructure.persistence.mapper.FlashSaleOutboxEventMapper;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.support.TransactionTemplate;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.HexFormat;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.UUID;
@Service
public class FlashSaleService {
private static final Logger log = LoggerFactory.getLogger(FlashSaleService.class);
private final FlashSaleActivityMapper activityMapper;
private final FlashSaleAdmissionMapper admissionMapper;
private final FlashSaleOutboxEventMapper outboxMapper;
private final FlashSaleAdmissionStore admissionStore;
private final FlashSaleAdmissionProperties admissionProperties;
private final ObjectMapper objectMapper;
private final TransactionTemplate transactionTemplate;
public FlashSaleService(
FlashSaleActivityMapper activityMapper,
FlashSaleAdmissionMapper admissionMapper,
FlashSaleOutboxEventMapper outboxMapper,
FlashSaleAdmissionStore admissionStore,
FlashSaleAdmissionProperties admissionProperties,
ObjectMapper objectMapper,
TransactionTemplate transactionTemplate) {
this.activityMapper = activityMapper;
this.admissionMapper = admissionMapper;
this.outboxMapper = outboxMapper;
this.admissionStore = admissionStore;
this.admissionProperties = admissionProperties;
this.objectMapper = objectMapper;
this.transactionTemplate = transactionTemplate;
}
@Transactional
public FlashSaleActivityView create(CreateFlashSaleCommand command) {
ValidatedActivity validated = validate(command);
Instant now = activityMapper.currentTime();
long id = IdWorker.getId();
FlashSaleActivityEntity activity = new FlashSaleActivityEntity();
activity.setId(id);
activity.setActivityNo("FSA" + id);
activity.setName(validated.name());
activity.setProductId(validated.productId());
activity.setSkuId(validated.skuId());
activity.setSalePrice(validated.salePrice());
activity.setAdmissionLimit(validated.admissionLimit());
activity.setStatus(FlashSaleActivityStatus.DRAFT.name());
activity.setStartsAt(validated.startsAt());
activity.setEndsAt(validated.endsAt());
activity.setVersion(0);
activity.setCreatedAt(now);
activity.setUpdatedAt(now);
activityMapper.insert(activity);
return view(activity);
}
public FlashSaleActivityView publish(String activityNo) {
FlashSaleActivityEntity candidate = requireActivity(activityNo);
FlashSaleActivityStatus candidateStatus = status(candidate);
if (candidateStatus == FlashSaleActivityStatus.ACTIVE) {
return view(candidate);
}
if (candidateStatus != FlashSaleActivityStatus.DRAFT) {
throw new MarketingException(MarketingError.FLASH_SALE_INVALID_STATE);
}
Instant now = activityMapper.currentTime();
if (!now.isBefore(candidate.getEndsAt())) {
throw new MarketingException(MarketingError.FLASH_SALE_ENDED);
}
try {
preheat(candidate, 0, now);
} catch (FlashSaleAdmissionStoreException exception) {
FlashSaleActivityEntity latest = requireActivity(activityNo);
if (status(latest) == FlashSaleActivityStatus.ACTIVE) {
return view(latest);
}
throw unavailable(exception);
}
return transactionTemplate.execute(transactionStatus -> {
FlashSaleActivityEntity activity = requireActivityForUpdate(activityNo);
FlashSaleActivityStatus currentStatus = status(activity);
if (currentStatus == FlashSaleActivityStatus.ACTIVE) {
return view(activity);
}
if (currentStatus != FlashSaleActivityStatus.DRAFT) {
throw new MarketingException(MarketingError.FLASH_SALE_INVALID_STATE);
}
Instant activatedAt = activityMapper.currentTime();
if (!activatedAt.isBefore(activity.getEndsAt())) {
throw new MarketingException(MarketingError.FLASH_SALE_ENDED);
}
activity.setStatus(FlashSaleActivityStatus.ACTIVE.name());
activity.setUpdatedAt(activatedAt);
requireUpdated(activityMapper.updateById(activity));
return view(activity);
});
}
@Transactional(readOnly = true)
public FlashSaleActivityView getActivity(String activityNo) {
return view(requireActivity(activityNo));
}
public FlashSaleAdmissionView admit(
Long userId,
String activityNo,
String requestKey,
Long addressId) {
if (userId == null || userId <= 0
|| addressId == null || addressId <= 0
|| requestKey == null || requestKey.isBlank()) {
throw new MarketingException(MarketingError.INVALID_FLASH_SALE);
}
FlashSaleActivityEntity activity = requireActivity(activityNo);
Instant now = activityMapper.currentTime();
validateAdmissionWindow(activity, now);
FlashSaleAdmissionEntity admission = preparePendingAdmission(
activity, userId, addressId, now);
if (!FlashSaleAdmissionStatus.ADMISSION_PENDING.name().equals(admission.getStatus())) {
return existingAdmissionResult(admission);
}
try {
return admissionView(continuePendingAdmission(activity, admission, now));
} catch (FlashSaleAdmissionStoreException exception) {
throw unavailable(exception);
}
}
public void recoverPendingAdmission(String requestToken) {
if (requestToken == null || requestToken.isBlank()) {
return;
}
FlashSaleAdmissionEntity admission = admissionMapper.selectByToken(requestToken.trim());
if (admission == null
|| !FlashSaleAdmissionStatus.ADMISSION_PENDING.name().equals(admission.getStatus())) {
return;
}
FlashSaleActivityEntity activity = activityMapper.selectByActivityNo(admission.getActivityNo());
if (activity == null) {
rejectPending(admission.getRequestToken(), MarketingError.RESOURCE_NOT_FOUND, null);
return;
}
Instant now = activityMapper.currentTime();
try {
validateAdmissionWindow(activity, now);
continuePendingAdmission(activity, admission, now);
} catch (FlashSaleAdmissionStoreException exception) {
log.debug(
"Flash-sale admission remains pending after a recoverable store failure: "
+ "requestToken={}, error={}",
admission.getRequestToken(),
exception.getClass().getSimpleName());
} catch (MarketingException exception) {
if (exception.error() == MarketingError.FLASH_SALE_ENDED) {
rejectPending(
admission.getRequestToken(),
MarketingError.FLASH_SALE_ENDED,
null);
}
if (exception.error() == MarketingError.FLASH_SALE_ADMISSION_UNAVAILABLE
|| exception.error() == MarketingError.FLASH_SALE_NOT_READY
|| exception.error() == MarketingError.FLASH_SALE_NOT_STARTED
|| exception.error() == MarketingError.FLASH_SALE_SOLD_OUT
|| exception.error() == MarketingError.FLASH_SALE_ENDED) {
return;
}
throw exception;
}
}
public FlashSaleAdmissionView getAdmission(Long userId, String requestToken) {
if (userId == null || userId <= 0 || requestToken == null || requestToken.isBlank()) {
throw new MarketingException(MarketingError.RESOURCE_NOT_FOUND);
}
FlashSaleAdmissionEntity admission = admissionMapper.selectByToken(requestToken.trim());
if (admission == null || !userId.equals(admission.getUserId())) {
throw new MarketingException(MarketingError.RESOURCE_NOT_FOUND);
}
return admissionView(admission);
}
private ValidatedActivity validate(CreateFlashSaleCommand command) {
if (command == null
|| command.name() == null
|| command.name().isBlank()
|| command.name().trim().length() > 120
|| command.productId() == null
|| command.productId() <= 0
|| command.skuId() == null
|| command.skuId() <= 0
|| command.salePrice() == null
|| command.admissionLimit() <= 0
|| command.startsAt() == null
|| command.endsAt() == null
|| !command.endsAt().isAfter(command.startsAt())
|| !command.endsAt().isAfter(activityMapper.currentTime())) {
throw new MarketingException(MarketingError.INVALID_FLASH_SALE);
}
BigDecimal salePrice;
try {
salePrice = command.salePrice().setScale(2, RoundingMode.UNNECESSARY);
} catch (ArithmeticException exception) {
throw new MarketingException(MarketingError.INVALID_FLASH_SALE, exception);
}
if (salePrice.signum() <= 0) {
throw new MarketingException(MarketingError.INVALID_FLASH_SALE);
}
return new ValidatedActivity(
command.name().trim(),
command.productId(),
command.skuId(),
salePrice,
command.admissionLimit(),
command.startsAt(),
command.endsAt());
}
private void validateAdmissionWindow(FlashSaleActivityEntity activity, Instant now) {
FlashSaleActivityStatus activityStatus = status(activity);
if (activityStatus == FlashSaleActivityStatus.DRAFT) {
throw new MarketingException(MarketingError.FLASH_SALE_NOT_READY);
}
if (activityStatus != FlashSaleActivityStatus.ACTIVE) {
throw new MarketingException(MarketingError.FLASH_SALE_ENDED);
}
if (now.isBefore(activity.getStartsAt())) {
throw new MarketingException(MarketingError.FLASH_SALE_NOT_STARTED);
}
if (!now.isBefore(activity.getEndsAt())) {
throw new MarketingException(MarketingError.FLASH_SALE_ENDED);
}
}
private FlashSaleActivityEntity requireActivity(String activityNo) {
if (activityNo == null || activityNo.isBlank()) {
throw new MarketingException(MarketingError.RESOURCE_NOT_FOUND);
}
FlashSaleActivityEntity activity = activityMapper.selectByActivityNo(activityNo.trim());
if (activity == null) {
throw new MarketingException(MarketingError.RESOURCE_NOT_FOUND);
}
return activity;
}
private FlashSaleActivityEntity requireActivityForUpdate(String activityNo) {
if (activityNo == null || activityNo.isBlank()) {
throw new MarketingException(MarketingError.RESOURCE_NOT_FOUND);
}
FlashSaleActivityEntity activity = activityMapper.selectByActivityNoForUpdate(activityNo.trim());
if (activity == null) {
throw new MarketingException(MarketingError.RESOURCE_NOT_FOUND);
}
return activity;
}
private FlashSaleActivityStatus status(FlashSaleActivityEntity activity) {
if (activity.getStatus() == null) {
throw new MarketingException(MarketingError.FLASH_SALE_INVALID_STATE);
}
try {
return FlashSaleActivityStatus.valueOf(activity.getStatus());
} catch (IllegalArgumentException exception) {
throw new MarketingException(MarketingError.FLASH_SALE_INVALID_STATE, exception);
}
}
private FlashSaleAdmissionView admissionView(FlashSaleAdmissionEntity admission) {
return new FlashSaleAdmissionView(
admission.getRequestToken(),
admission.getActivityNo(),
admission.getUserId(),
admission.getStatus(),
admission.getRemainingAdmissions(),
admission.getAcceptedAt(),
admission.getOrderNo(),
admission.getFailureCode(),
admission.getCompletedAt());
}
private FlashSaleActivityView view(FlashSaleActivityEntity activity) {
return new FlashSaleActivityView(
activity.getActivityNo(),
activity.getName(),
activity.getProductId(),
activity.getSkuId(),
activity.getSalePrice(),
activity.getAdmissionLimit(),
activity.getStatus(),
activity.getStartsAt(),
activity.getEndsAt(),
activity.getVersion());
}
private MarketingException unavailable(FlashSaleAdmissionStoreException cause) {
return new MarketingException(MarketingError.FLASH_SALE_ADMISSION_UNAVAILABLE, cause);
}
private void requireUpdated(int rows) {
if (rows != 1) {
throw new MarketingException(MarketingError.CONCURRENT_MODIFICATION);
}
}
private record ValidatedActivity(
String name,
Long productId,
Long skuId,
BigDecimal salePrice,
int admissionLimit,
Instant startsAt,
Instant endsAt
) {
}
private FlashSaleAdmissionEntity preparePendingAdmission(
FlashSaleActivityEntity activity,
Long userId,
Long addressId,
Instant now) {
return transactionTemplate.execute(status -> {
FlashSaleAdmissionEntity candidate = new FlashSaleAdmissionEntity();
candidate.setId(IdWorker.getId());
candidate.setRequestToken("FST" + UUID.randomUUID().toString().replace("-", ""));
candidate.setActivityNo(activity.getActivityNo());
candidate.setUserId(userId);
candidate.setAddressId(addressId);
candidate.setRequestHash(admissionHash(activity.getActivityNo(), userId, addressId));
candidate.setStatus(FlashSaleAdmissionStatus.ADMISSION_PENDING.name());
candidate.setRemainingAdmissions(null);
candidate.setVersion(0);
candidate.setAcceptedAt(null);
candidate.setCreatedAt(now);
candidate.setUpdatedAt(now);
admissionMapper.insertOrLockExisting(candidate);
FlashSaleAdmissionEntity stored = admissionMapper.selectByActivityAndUserForUpdate(
activity.getActivityNo(), userId);
if (stored == null
|| !stored.getRequestHash().equals(candidate.getRequestHash())) {
throw new MarketingException(MarketingError.IDEMPOTENCY_CONFLICT);
}
return stored;
});
}
private FlashSaleAdmissionEntity continuePendingAdmission(
FlashSaleActivityEntity activity,
FlashSaleAdmissionEntity admission,
Instant now) {
Decision decision = admissionStore.admit(
activity.getActivityNo(),
admission.getUserId(),
admission.getRequestToken(),
admission.getRequestToken(),
now);
if (decision.outcome() == FlashSaleAdmissionStore.Outcome.NOT_READY) {
int acceptedCount = admissionMapper.countAcceptedByActivity(activity.getActivityNo());
preheat(activity, acceptedCount, now);
decision = admissionStore.admit(
activity.getActivityNo(),
admission.getUserId(),
admission.getRequestToken(),
admission.getRequestToken(),
now);
}
return switch (decision.outcome()) {
case ACCEPTED, REPLAYED -> finalizeAcceptedAdmission(activity, admission, decision);
case NOT_READY, NOT_ACTIVE ->
throw new MarketingException(MarketingError.FLASH_SALE_NOT_READY);
case NOT_STARTED -> throw new MarketingException(MarketingError.FLASH_SALE_NOT_STARTED);
case ENDED -> {
rejectPending(admission.getRequestToken(), MarketingError.FLASH_SALE_ENDED,
decision.remainingAdmissions());
throw new MarketingException(MarketingError.FLASH_SALE_ENDED);
}
case SOLD_OUT -> {
rejectPending(admission.getRequestToken(), MarketingError.FLASH_SALE_SOLD_OUT,
decision.remainingAdmissions());
throw new MarketingException(MarketingError.FLASH_SALE_SOLD_OUT);
}
};
}
private FlashSaleAdmissionEntity finalizeAcceptedAdmission(
FlashSaleActivityEntity activity,
FlashSaleAdmissionEntity admission,
Decision decision) {
if (decision.requestToken() == null
|| !decision.requestToken().equals(admission.getRequestToken())
|| decision.acceptedAt() == null
|| decision.remainingAdmissions() < 0
|| activity.getProductId() == null) {
throw new MarketingException(MarketingError.FLASH_SALE_ADMISSION_UNAVAILABLE);
}
return transactionTemplate.execute(status -> {
FlashSaleAdmissionEntity stored =
admissionMapper.selectByTokenForUpdate(admission.getRequestToken());
requireSameAdmission(stored, admission);
if (!FlashSaleAdmissionStatus.ADMISSION_PENDING.name().equals(stored.getStatus())) {
if (admissionStatus(stored) == FlashSaleAdmissionStatus.ADMISSION_REJECTED) {
throw rejectedAdmission(stored.getFailureCode());
}
return stored;
}
Instant committedAt = activityMapper.currentTime();
Instant acceptedAt = decision.acceptedAt().truncatedTo(ChronoUnit.MILLIS);
stored.setStatus(FlashSaleAdmissionStatus.QUEUED.name());
stored.setRemainingAdmissions(decision.remainingAdmissions());
stored.setAcceptedAt(acceptedAt);
stored.setFailureCode(null);
stored.setUpdatedAt(committedAt);
requireUpdated(admissionMapper.updateById(stored));
appendAdmissionAcceptedEvent(activity, stored, committedAt);
return stored;
});
}
private void rejectPending(
String requestToken,
MarketingError error,
Integer remainingAdmissions) {
transactionTemplate.executeWithoutResult(status -> {
FlashSaleAdmissionEntity stored = admissionMapper.selectByTokenForUpdate(requestToken);
if (stored == null
|| !FlashSaleAdmissionStatus.ADMISSION_PENDING.name().equals(stored.getStatus())) {
return;
}
Instant now = activityMapper.currentTime();
stored.setStatus(FlashSaleAdmissionStatus.ADMISSION_REJECTED.name());
stored.setRemainingAdmissions(
remainingAdmissions != null && remainingAdmissions >= 0
? remainingAdmissions
: null);
stored.setFailureCode(error.code());
stored.setCompletedAt(now);
stored.setUpdatedAt(now);
requireUpdated(admissionMapper.updateById(stored));
});
}
private FlashSaleAdmissionView existingAdmissionResult(FlashSaleAdmissionEntity admission) {
FlashSaleAdmissionStatus admissionStatus = admissionStatus(admission);
if (admissionStatus == FlashSaleAdmissionStatus.ADMISSION_REJECTED) {
throw rejectedAdmission(admission.getFailureCode());
}
return admissionView(admission);
}
private FlashSaleAdmissionStatus admissionStatus(FlashSaleAdmissionEntity admission) {
String status = admission.getStatus();
if (status == null) {
throw new MarketingException(MarketingError.FLASH_SALE_INVALID_STATE);
}
try {
return FlashSaleAdmissionStatus.valueOf(status);
} catch (IllegalArgumentException exception) {
throw new MarketingException(MarketingError.FLASH_SALE_INVALID_STATE, exception);
}
}
private MarketingException rejectedAdmission(String failureCode) {
if (MarketingError.FLASH_SALE_SOLD_OUT.code().equals(failureCode)) {
return new MarketingException(MarketingError.FLASH_SALE_SOLD_OUT);
}
if (MarketingError.FLASH_SALE_ENDED.code().equals(failureCode)) {
return new MarketingException(MarketingError.FLASH_SALE_ENDED);
}
return new MarketingException(MarketingError.FLASH_SALE_INVALID_STATE);
}
private void requireSameAdmission(
FlashSaleAdmissionEntity stored,
FlashSaleAdmissionEntity expected) {
if (stored == null
|| !stored.getActivityNo().equals(expected.getActivityNo())
|| !stored.getUserId().equals(expected.getUserId())
|| !stored.getAddressId().equals(expected.getAddressId())
|| !stored.getRequestHash().equals(expected.getRequestHash())) {
throw new MarketingException(MarketingError.IDEMPOTENCY_CONFLICT);
}
}
private void preheat(
FlashSaleActivityEntity activity,
int admittedCount,
Instant now) {
if (admittedCount < 0 || admittedCount > activity.getAdmissionLimit()) {
throw new FlashSaleAdmissionStoreException(
"Persisted flash-sale admissions exceed the configured limit");
}
admissionStore.preheat(
new FlashSaleAdmissionStore.Activity(
activity.getActivityNo(),
activity.getAdmissionLimit(),
admittedCount,
activity.getStartsAt(),
activity.getEndsAt(),
admissionProperties.resultRetention()),
now);
}
private void appendAdmissionAcceptedEvent(
FlashSaleActivityEntity activity,
FlashSaleAdmissionEntity admission,
Instant now) {
String eventId = UUID.randomUUID().toString();
Map<String, Object> payload = new LinkedHashMap<>();
payload.put("eventId", eventId);
payload.put("eventType", "FlashSaleAdmissionAccepted");
payload.put("aggregateType", "FlashSaleAdmission");
payload.put("aggregateId", admission.getRequestToken());
payload.put("aggregateVersion", admission.getVersion());
payload.put("occurredAt", now);
payload.put("producer", "marketing-service");
payload.put("payloadVersion", 1);
Map<String, Object> data = new LinkedHashMap<>();
data.put("requestToken", admission.getRequestToken());
data.put("activityNo", admission.getActivityNo());
data.put("userId", admission.getUserId());
data.put("addressId", admission.getAddressId());
data.put("productId", activity.getProductId());
data.put("skuId", activity.getSkuId());
data.put("salePrice", activity.getSalePrice());
data.put("acceptedAt", admission.getAcceptedAt());
data.put("activityEndsAt", activity.getEndsAt());
payload.put("payload", data);
FlashSaleOutboxEventEntity event = new FlashSaleOutboxEventEntity();
event.setId(eventId);
event.setEventType("FlashSaleAdmissionAccepted");
event.setAggregateType("FlashSaleAdmission");
event.setAggregateId(admission.getRequestToken());
event.setAggregateVersion(admission.getVersion());
event.setPayload(writeJson(payload));
event.setStatus(OutboxStatus.PENDING.name());
event.setAttempts(0);
event.setNextAttemptAt(now);
event.setCreatedAt(now);
event.setUpdatedAt(now);
outboxMapper.insert(event);
}
private String admissionHash(String activityNo, Long userId, Long addressId) {
String canonical = activityNo + "|" + userId + "|" + addressId;
try {
return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256")
.digest(canonical.getBytes(StandardCharsets.UTF_8)));
} catch (NoSuchAlgorithmException exception) {
throw new IllegalStateException("SHA-256 is unavailable", exception);
}
}
private String writeJson(Object value) {
try {
return objectMapper.writeValueAsString(value);
} catch (JsonProcessingException exception) {
throw new IllegalStateException("Unable to serialize flash-sale event", exception);
}
}
}