AfterSaleService.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.AfterSaleFulfillmentEventCommand;
import com.ecommerce.trade.application.model.TradeModels.AfterSaleItemView;
import com.ecommerce.trade.application.model.TradeModels.AfterSaleView;
import com.ecommerce.trade.application.model.TradeModels.ApplyAfterSaleCommand;
import com.ecommerce.trade.application.model.TradeModels.RefundEventCommand;
import com.ecommerce.trade.application.model.TradeModels.ReturnStockedCommand;
import com.ecommerce.trade.application.model.TradeModels.ReviewAfterSaleCommand;
import com.ecommerce.trade.domain.AfterSaleStatus;
import com.ecommerce.trade.domain.OrderStatus;
import com.ecommerce.trade.domain.OutboxStatus;
import com.ecommerce.trade.infrastructure.persistence.entity.AfterSaleHistoryEntity;
import com.ecommerce.trade.infrastructure.persistence.entity.AfterSaleItemEntity;
import com.ecommerce.trade.infrastructure.persistence.entity.AfterSaleOrderEntity;
import com.ecommerce.trade.infrastructure.persistence.entity.OrderItemEntity;
import com.ecommerce.trade.infrastructure.persistence.entity.OutboxEventEntity;
import com.ecommerce.trade.infrastructure.persistence.entity.TradeOrderEntity;
import com.ecommerce.trade.infrastructure.config.AfterSaleProperties;
import com.ecommerce.trade.infrastructure.persistence.mapper.AfterSaleHistoryMapper;
import com.ecommerce.trade.infrastructure.persistence.mapper.AfterSaleItemMapper;
import com.ecommerce.trade.infrastructure.persistence.mapper.AfterSaleOrderMapper;
import com.ecommerce.trade.infrastructure.persistence.mapper.ConsumedEventMapper;
import com.ecommerce.trade.infrastructure.persistence.mapper.OrderItemMapper;
import com.ecommerce.trade.infrastructure.persistence.mapper.OutboxEventMapper;
import com.ecommerce.trade.infrastructure.persistence.mapper.TradeOrderMapper;
import com.ecommerce.trade.infrastructure.sharding.TradeShardRouter;
import com.ecommerce.platform.common.idempotency.PayloadFingerprint;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.slf4j.MDC;
import org.springframework.stereotype.Service;
import org.springframework.transaction.support.TransactionTemplate;

import java.math.BigDecimal;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.time.Instant;
import java.util.HexFormat;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.UUID;

@Service
public class AfterSaleService {

    public static final String FULFILLMENT_CONSUMER_GROUP = "trade-after-sale-fulfillment-v1";
    public static final String INVENTORY_CONSUMER_GROUP = "trade-after-sale-inventory-v1";
    public static final String REFUND_CONSUMER_GROUP = "trade-refund-events-v1";
    private static final String WHOLE_RETURN_REFUND = "WHOLE_RETURN_REFUND";

    private final AfterSaleOrderMapper afterSaleMapper;
    private final AfterSaleItemMapper itemMapper;
    private final AfterSaleHistoryMapper historyMapper;
    private final TradeOrderMapper orderMapper;
    private final OrderItemMapper orderItemMapper;
    private final OutboxEventMapper outboxMapper;
    private final ConsumedEventMapper consumedEventMapper;
    private final ObjectMapper objectMapper;
    private final TransactionTemplate transactionTemplate;
    private final AfterSaleProperties properties;
    private final TradeShardRouter shardRouter;

    public AfterSaleService(
            AfterSaleOrderMapper afterSaleMapper,
            AfterSaleItemMapper itemMapper,
            AfterSaleHistoryMapper historyMapper,
            TradeOrderMapper orderMapper,
            OrderItemMapper orderItemMapper,
            OutboxEventMapper outboxMapper,
            ConsumedEventMapper consumedEventMapper,
            ObjectMapper objectMapper,
            TransactionTemplate transactionTemplate,
            AfterSaleProperties properties,
            TradeShardRouter shardRouter) {
        this.afterSaleMapper = afterSaleMapper;
        this.itemMapper = itemMapper;
        this.historyMapper = historyMapper;
        this.orderMapper = orderMapper;
        this.orderItemMapper = orderItemMapper;
        this.outboxMapper = outboxMapper;
        this.consumedEventMapper = consumedEventMapper;
        this.objectMapper = objectMapper;
        this.transactionTemplate = transactionTemplate;
        this.properties = properties;
        this.shardRouter = shardRouter;
    }

    public AfterSaleView apply(ApplyAfterSaleCommand command) {
        if (!shardRouter.isRouted()) {
            return shardRouter.executeForUser(command.userId(), () -> apply(command));
        }
        String requestHash = sha256(command.orderNo() + "|" + command.reason());
        long id = IdWorker.getId();
        Instant now = orderMapper.currentTime();
        return Objects.requireNonNull(transactionTemplate.execute(ignored -> {
            TradeOrderEntity order = orderMapper.selectForUpdate(command.orderNo());
            if (order == null) {
                throw new TradeException(TradeError.RESOURCE_NOT_FOUND);
            }
            requireOwner(order.getUserId(), command.userId());
            if (!OrderStatus.COMPLETED.name().equals(order.getStatus()) || order.getWarehouseId() == null) {
                throw new TradeException(TradeError.INVALID_STATE);
            }
            if (orderMapper.currentTime()
                    .isAfter(order.getUpdatedAt().plus(properties.applicationWindow()))) {
                throw new TradeException(TradeError.AFTER_SALE_WINDOW_EXPIRED);
            }

            AfterSaleOrderEntity candidate = new AfterSaleOrderEntity();
            candidate.setId(id);
            candidate.setAfterSaleNo("AS" + id);
            candidate.setOrderId(order.getId());
            candidate.setOrderNo(order.getOrderNo());
            candidate.setUserId(order.getUserId());
            candidate.setAfterSaleType(WHOLE_RETURN_REFUND);
            candidate.setStatus(AfterSaleStatus.APPLIED.name());
            candidate.setIdempotencyKey(command.idempotencyKey());
            candidate.setRequestHash(requestHash);
            candidate.setReason(command.reason());
            candidate.setRefundAmount(order.getTotalAmount());
            candidate.setWarehouseId(order.getWarehouseId());
            candidate.setReservationNo(order.getReservationNo());
            candidate.setVersion(0);
            candidate.setCreatedAt(now);
            candidate.setUpdatedAt(now);
            afterSaleMapper.insertOrLockExisting(candidate);

            AfterSaleOrderEntity afterSale = afterSaleMapper.selectByIdempotencyForUpdate(
                    command.userId(), command.idempotencyKey());
            if (afterSale == null) {
                if (afterSaleMapper.selectByOrderForUpdate(order.getId()) != null) {
                    throw new TradeException(TradeError.AFTER_SALE_ALREADY_EXISTS);
                }
                throw new TradeException(TradeError.CONCURRENT_MODIFICATION);
            }
            if (!constantEquals(afterSale.getRequestHash(), requestHash)) {
                throw new TradeException(TradeError.IDEMPOTENCY_CONFLICT);
            }
            if (candidate.getId().equals(afterSale.getId())) {
                insertSnapshotItems(afterSale, orderItems(order.getId()), now);
                appendHistory(afterSale, null, AfterSaleStatus.APPLIED.name(), "APPLY_AFTER_SALE",
                        command.reason(), "CUSTOMER", command.userId().toString(), now);
                appendEvent(afterSale, "AfterSaleApplied", now);
            }
            return view(afterSale);
        }));
    }

    public AfterSaleView review(ReviewAfterSaleCommand command) {
        if (!shardRouter.isRouted()) {
            AfterSaleOrderEntity located = require(command.afterSaleNo());
            return shardRouter.executeForUser(located.getUserId(), () -> review(command));
        }
        return Objects.requireNonNull(transactionTemplate.execute(ignored -> {
            AfterSaleOrderEntity afterSale = requireLocked(command.afterSaleNo());
            AfterSaleStatus target = command.approved() ? AfterSaleStatus.WAIT_RETURN : AfterSaleStatus.REJECTED;
            if (target.name().equals(afterSale.getStatus())) {
                return view(afterSale);
            }
            requireStatus(afterSale, AfterSaleStatus.APPLIED);
            afterSale.setReviewReason(command.reason());
            if (command.approved()) {
                afterSale.setApprovedAt(orderMapper.currentTime());
            }
            transition(afterSale, target, command.approved() ? "APPROVE_AFTER_SALE" : "REJECT_AFTER_SALE",
                    command.reason(), "ADMIN", command.operatorId(),
                    command.approved() ? "AfterSaleApproved" : "AfterSaleRejected");
            return view(afterSale);
        }));
    }

    public AfterSaleView cancel(Long userId, String afterSaleNo) {
        if (!shardRouter.isRouted()) {
            return shardRouter.executeForUser(userId, () -> cancel(userId, afterSaleNo));
        }
        return Objects.requireNonNull(transactionTemplate.execute(ignored -> {
            AfterSaleOrderEntity afterSale = requireLocked(afterSaleNo);
            requireOwner(afterSale.getUserId(), userId);
            if (AfterSaleStatus.CANCELED.name().equals(afterSale.getStatus())) {
                return view(afterSale);
            }
            requireStatus(afterSale, AfterSaleStatus.APPLIED);
            transition(afterSale, AfterSaleStatus.CANCELED, "CANCEL_AFTER_SALE", null,
                    "CUSTOMER", userId.toString(), "AfterSaleCanceled");
            return view(afterSale);
        }));
    }

    public AfterSaleView getForUser(Long userId, String afterSaleNo) {
        if (!shardRouter.isRouted()) {
            return shardRouter.executeForUser(userId, () -> getForUser(userId, afterSaleNo));
        }
        AfterSaleOrderEntity afterSale = require(afterSaleNo);
        requireOwner(afterSale.getUserId(), userId);
        return view(afterSale);
    }

    public List<AfterSaleView> listForUser(Long userId) {
        if (!shardRouter.isRouted()) {
            return shardRouter.executeForUser(userId, () -> listForUser(userId));
        }
        return afterSaleMapper.selectList(new LambdaQueryWrapper<AfterSaleOrderEntity>()
                        .eq(AfterSaleOrderEntity::getUserId, userId)
                        .orderByDesc(AfterSaleOrderEntity::getCreatedAt))
                .stream().map(this::view).toList();
    }

    public AfterSaleView get(String afterSaleNo) {
        return view(require(afterSaleNo));
    }

    public List<AfterSaleView> list(String status) {
        LambdaQueryWrapper<AfterSaleOrderEntity> query = new LambdaQueryWrapper<>();
        if (status != null && !status.isBlank()) {
            try {
                query.eq(AfterSaleOrderEntity::getStatus, AfterSaleStatus.valueOf(status).name());
            } catch (IllegalArgumentException exception) {
                throw new TradeException(TradeError.INVALID_STATE);
            }
        }
        query.orderByDesc(AfterSaleOrderEntity::getCreatedAt);
        return afterSaleMapper.selectList(query).stream().map(this::view).toList();
    }

    public void applyFulfillmentEvent(AfterSaleFulfillmentEventCommand command) {
        if (!shardRouter.isRouted()) {
            shardRouter.runForUser(command.userId(), () -> applyFulfillmentEvent(command));
            return;
        }
        transactionTemplate.executeWithoutResult(ignored -> {
            String payloadFingerprint = PayloadFingerprint.of(
                    command.eventType(),
                    command.afterSaleNo(),
                    command.returnReceiptNo(),
                    command.orderNo(),
                    command.userId());
            if (!registerConsumed(
                    command.eventId(), FULFILLMENT_CONSUMER_GROUP,
                    command.userId(), payloadFingerprint)) {
                return;
            }
            AfterSaleOrderEntity afterSale = requireLocked(command.afterSaleNo());
            requireEventIdentity(afterSale, command.orderNo(), command.userId());
            if (afterSale.getReturnReceiptNo() != null
                    && !afterSale.getReturnReceiptNo().equals(command.returnReceiptNo())) {
                throw new TradeException(TradeError.IDEMPOTENCY_CONFLICT);
            }
            afterSale.setReturnReceiptNo(command.returnReceiptNo());
            switch (command.eventType()) {
                case "ReturnShipmentSubmitted" -> applyReturnShipmentSubmitted(afterSale);
                case "ReturnReceived" -> applyReturnReceived(afterSale);
                default -> throw new TradeException(TradeError.IDEMPOTENCY_CONFLICT);
            }
        });
    }

    public void applyReturnStocked(ReturnStockedCommand command) {
        if (!shardRouter.isRouted()) {
            shardRouter.runForUser(command.userId(), () -> applyReturnStocked(command));
            return;
        }
        transactionTemplate.executeWithoutResult(ignored -> {
            String payloadFingerprint = PayloadFingerprint.of(
                    command.afterSaleNo(),
                    command.returnReceiptNo(),
                    command.orderNo(),
                    command.userId(),
                    command.warehouseId());
            if (!registerConsumed(
                    command.eventId(), INVENTORY_CONSUMER_GROUP,
                    command.userId(), payloadFingerprint)) {
                return;
            }
            AfterSaleOrderEntity afterSale = requireLocked(command.afterSaleNo());
            requireEventIdentity(afterSale, command.orderNo(), command.userId());
            if (!afterSale.getWarehouseId().equals(command.warehouseId())
                    || (afterSale.getReturnReceiptNo() != null
                    && !afterSale.getReturnReceiptNo().equals(command.returnReceiptNo()))) {
                throw new TradeException(TradeError.IDEMPOTENCY_CONFLICT);
            }
            afterSale.setReturnReceiptNo(command.returnReceiptNo());
            if (List.of(AfterSaleStatus.REFUNDING.name(), AfterSaleStatus.REFUND_FAILED.name(),
                    AfterSaleStatus.COMPLETED.name()).contains(afterSale.getStatus())) {
                return;
            }
            if (!List.of(AfterSaleStatus.WAIT_RETURN.name(), AfterSaleStatus.RETURNING.name(),
                    AfterSaleStatus.RECEIVED.name()).contains(afterSale.getStatus())) {
                throw new TradeException(TradeError.INVALID_STATE);
            }
            transition(afterSale, AfterSaleStatus.REFUNDING, "RETURN_STOCKED", null,
                    "SYSTEM", "inventory-service", "RefundRequested");
        });
    }

    public void applyRefundEvent(RefundEventCommand command) {
        if (!shardRouter.isRouted()) {
            shardRouter.runForUser(command.userId(), () -> applyRefundEvent(command));
            return;
        }
        transactionTemplate.executeWithoutResult(ignored -> {
            String payloadFingerprint = PayloadFingerprint.of(
                    command.eventType(),
                    command.refundNo(),
                    command.afterSaleNo(),
                    command.orderNo(),
                    command.paymentNo(),
                    command.userId(),
                    canonicalDecimal(command.amount()));
            if (!registerConsumed(
                    command.eventId(), REFUND_CONSUMER_GROUP,
                    command.userId(), payloadFingerprint)) {
                return;
            }
            AfterSaleOrderEntity afterSale = requireLocked(command.afterSaleNo());
            requireEventIdentity(afterSale, command.orderNo(), command.userId());
            TradeOrderEntity order = orderMapper.selectById(afterSale.getOrderId());
            if (afterSale.getRefundAmount().compareTo(command.amount()) != 0
                    || order == null
                    || !Objects.equals(order.getPaymentNo(), command.paymentNo())
                    || (afterSale.getRefundNo() != null && !afterSale.getRefundNo().equals(command.refundNo()))) {
                throw new TradeException(TradeError.IDEMPOTENCY_CONFLICT);
            }
            afterSale.setRefundNo(command.refundNo());
            if ("RefundSucceeded".equals(command.eventType())) {
                if (AfterSaleStatus.COMPLETED.name().equals(afterSale.getStatus())) {
                    return;
                }
                if (!List.of(AfterSaleStatus.REFUNDING.name(), AfterSaleStatus.REFUND_FAILED.name())
                        .contains(afterSale.getStatus())) {
                    throw new TradeException(TradeError.INVALID_STATE);
                }
                afterSale.setCompletedAt(orderMapper.currentTime());
                transition(afterSale, AfterSaleStatus.COMPLETED, "REFUND_SUCCEEDED", null,
                        "SYSTEM", "payment-service", "AfterSaleCompleted");
            } else if ("RefundFailed".equals(command.eventType())) {
                if (List.of(AfterSaleStatus.REFUND_FAILED.name(), AfterSaleStatus.COMPLETED.name())
                        .contains(afterSale.getStatus())) {
                    return;
                }
                requireStatus(afterSale, AfterSaleStatus.REFUNDING);
                transition(afterSale, AfterSaleStatus.REFUND_FAILED, "REFUND_FAILED", null,
                        "SYSTEM", "payment-service", "AfterSaleRefundFailed");
            } else {
                throw new TradeException(TradeError.IDEMPOTENCY_CONFLICT);
            }
        });
    }

    private boolean registerConsumed(
            String eventId,
            String consumerGroup,
            Long ownerUserId,
            String payloadFingerprint) {
        if (consumedEventMapper.insertIfAbsent(
                eventId, consumerGroup, ownerUserId, payloadFingerprint,
                orderMapper.currentTime()) == 1) {
            return true;
        }
        Long storedOwner = consumedEventMapper.selectOwnerUserId(eventId, consumerGroup);
        String storedFingerprint = consumedEventMapper.selectPayloadFingerprint(eventId, consumerGroup);
        if (!Objects.equals(storedOwner, ownerUserId)
                || !PayloadFingerprint.matches(storedFingerprint, payloadFingerprint)) {
            throw new TradeException(TradeError.IDEMPOTENCY_CONFLICT);
        }
        return false;
    }

    private String canonicalDecimal(BigDecimal value) {
        return value == null ? null : value.stripTrailingZeros().toPlainString();
    }

    private void applyReturnShipmentSubmitted(AfterSaleOrderEntity afterSale) {
        if (List.of(AfterSaleStatus.RETURNING.name(), AfterSaleStatus.RECEIVED.name(),
                AfterSaleStatus.REFUNDING.name(), AfterSaleStatus.REFUND_FAILED.name(),
                AfterSaleStatus.COMPLETED.name()).contains(afterSale.getStatus())) {
            return;
        }
        requireStatus(afterSale, AfterSaleStatus.WAIT_RETURN);
        transition(afterSale, AfterSaleStatus.RETURNING, "RETURN_SHIPMENT_SUBMITTED", null,
                "SYSTEM", "fulfillment-service", "AfterSaleReturning");
    }

    private void applyReturnReceived(AfterSaleOrderEntity afterSale) {
        if (List.of(AfterSaleStatus.RECEIVED.name(), AfterSaleStatus.REFUNDING.name(),
                AfterSaleStatus.REFUND_FAILED.name(), AfterSaleStatus.COMPLETED.name())
                .contains(afterSale.getStatus())) {
            return;
        }
        if (!List.of(AfterSaleStatus.WAIT_RETURN.name(), AfterSaleStatus.RETURNING.name())
                .contains(afterSale.getStatus())) {
            throw new TradeException(TradeError.INVALID_STATE);
        }
        transition(afterSale, AfterSaleStatus.RECEIVED, "RETURN_RECEIVED", null,
                "SYSTEM", "fulfillment-service", "AfterSaleReturnReceived");
    }

    private void insertSnapshotItems(
            AfterSaleOrderEntity afterSale,
            List<OrderItemEntity> sourceItems,
            Instant now) {
        if (sourceItems.isEmpty()) {
            throw new TradeException(TradeError.RESOURCE_NOT_FOUND);
        }
        BigDecimal refundableTotal = BigDecimal.ZERO;
        for (OrderItemEntity source : sourceItems) {
            AfterSaleItemEntity item = new AfterSaleItemEntity();
            item.setId(IdWorker.getId());
            item.setAfterSaleId(afterSale.getId());
            item.setOrderItemId(source.getId());
            item.setLineNo(source.getLineNo());
            item.setSkuId(source.getSkuId());
            item.setProductTitle(source.getProductTitle());
            item.setSkuName(source.getSkuName());
            item.setQuantity(source.getQuantity());
            item.setLineAmount(source.getLineAmount());
            item.setDiscountAmount(source.getDiscountAmount());
            item.setRefundableAmount(source.getPayableAmount());
            item.setCreatedAt(now);
            itemMapper.insert(item);
            refundableTotal = refundableTotal.add(source.getPayableAmount());
        }
        if (refundableTotal.compareTo(afterSale.getRefundAmount()) != 0) {
            throw new TradeException(TradeError.IDEMPOTENCY_CONFLICT);
        }
    }

    private void transition(
            AfterSaleOrderEntity afterSale,
            AfterSaleStatus target,
            String command,
            String reason,
            String operatorType,
            String operatorId,
            String eventType) {
        String from = afterSale.getStatus();
        Instant now = orderMapper.currentTime();
        afterSale.setStatus(target.name());
        afterSale.setUpdatedAt(now);
        requireUpdated(afterSaleMapper.updateById(afterSale));
        appendHistory(afterSale, from, target.name(), command, reason, operatorType, operatorId, now);
        if (eventType != null) {
            appendEvent(afterSale, eventType, now);
        }
    }

    private void appendHistory(
            AfterSaleOrderEntity afterSale,
            String from,
            String to,
            String command,
            String reason,
            String operatorType,
            String operatorId,
            Instant now) {
        AfterSaleHistoryEntity history = new AfterSaleHistoryEntity();
        history.setId(IdWorker.getId());
        history.setAfterSaleId(afterSale.getId());
        history.setFromStatus(from);
        history.setToStatus(to);
        history.setCommand(command);
        history.setReason(reason);
        history.setOperatorType(operatorType);
        history.setOperatorId(operatorId);
        history.setCreatedAt(now);
        historyMapper.insert(history);
    }

    private void appendEvent(AfterSaleOrderEntity afterSale, String eventType, Instant now) {
        String eventId = UUID.randomUUID().toString();
        Map<String, Object> payload = new LinkedHashMap<>();
        payload.put("afterSaleNo", afterSale.getAfterSaleNo());
        payload.put("orderNo", afterSale.getOrderNo());
        payload.put("userId", afterSale.getUserId());
        payload.put("afterSaleType", afterSale.getAfterSaleType());
        payload.put("status", afterSale.getStatus());
        payload.put("refundAmount", afterSale.getRefundAmount());
        payload.put("warehouseId", afterSale.getWarehouseId());
        payload.put("reservationNo", afterSale.getReservationNo());
        if (afterSale.getReturnReceiptNo() != null) {
            payload.put("returnReceiptNo", afterSale.getReturnReceiptNo());
        }
        if (afterSale.getRefundNo() != null) {
            payload.put("refundNo", afterSale.getRefundNo());
        }
        if ("AfterSaleApproved".equals(eventType)) {
            payload.put("items", items(afterSale.getId()).stream().map(item -> {
                Map<String, Object> line = new LinkedHashMap<>();
                line.put("lineNo", item.getLineNo());
                line.put("skuId", item.getSkuId());
                line.put("quantity", item.getQuantity());
                line.put("refundableAmount", item.getRefundableAmount());
                return line;
            }).toList());
        }

        Map<String, Object> envelope = new LinkedHashMap<>();
        envelope.put("eventId", eventId);
        envelope.put("eventType", eventType);
        envelope.put("aggregateType", "AfterSaleOrder");
        envelope.put("aggregateId", afterSale.getAfterSaleNo());
        envelope.put("aggregateVersion", afterSale.getVersion());
        envelope.put("occurredAt", now);
        envelope.put("producer", "trade-service");
        envelope.put("traceId", MDC.get("traceId"));
        envelope.put("payloadVersion", 1);
        envelope.put("payload", payload);

        OutboxEventEntity event = new OutboxEventEntity();
        event.setId(eventId);
        event.setEventType(eventType);
        event.setAggregateType("AfterSaleOrder");
        event.setAggregateId(afterSale.getAfterSaleNo());
        event.setAggregateVersion(afterSale.getVersion());
        event.setPayload(writeJson(envelope));
        event.setStatus(OutboxStatus.PENDING.name());
        event.setAttempts(0);
        event.setNextAttemptAt(now);
        event.setCreatedAt(now);
        event.setUpdatedAt(now);
        outboxMapper.insert(event);
    }

    private AfterSaleOrderEntity require(String afterSaleNo) {
        AfterSaleOrderEntity afterSale = afterSaleMapper.selectOne(
                new LambdaQueryWrapper<AfterSaleOrderEntity>()
                        .eq(AfterSaleOrderEntity::getAfterSaleNo, afterSaleNo));
        if (afterSale == null) {
            throw new TradeException(TradeError.RESOURCE_NOT_FOUND);
        }
        return afterSale;
    }

    private AfterSaleOrderEntity requireLocked(String afterSaleNo) {
        AfterSaleOrderEntity afterSale = afterSaleMapper.selectForUpdate(afterSaleNo);
        if (afterSale == null) {
            throw new TradeException(TradeError.RESOURCE_NOT_FOUND);
        }
        return afterSale;
    }

    private List<OrderItemEntity> orderItems(Long orderId) {
        return orderItemMapper.selectList(new LambdaQueryWrapper<OrderItemEntity>()
                .eq(OrderItemEntity::getOrderId, orderId)
                .orderByAsc(OrderItemEntity::getLineNo));
    }

    private List<AfterSaleItemEntity> items(Long afterSaleId) {
        return itemMapper.selectList(new LambdaQueryWrapper<AfterSaleItemEntity>()
                .eq(AfterSaleItemEntity::getAfterSaleId, afterSaleId)
                .orderByAsc(AfterSaleItemEntity::getLineNo));
    }

    private AfterSaleView view(AfterSaleOrderEntity afterSale) {
        List<AfterSaleItemView> itemViews = items(afterSale.getId()).stream()
                .map(item -> new AfterSaleItemView(
                        item.getLineNo(), item.getSkuId(), item.getProductTitle(), item.getSkuName(),
                        item.getQuantity(), item.getLineAmount(), item.getDiscountAmount(),
                        item.getRefundableAmount()))
                .toList();
        return new AfterSaleView(
                afterSale.getAfterSaleNo(), afterSale.getOrderNo(), afterSale.getUserId(),
                afterSale.getAfterSaleType(), afterSale.getStatus(), afterSale.getReason(),
                afterSale.getReviewReason(), afterSale.getRefundAmount(), afterSale.getReturnReceiptNo(),
                afterSale.getRefundNo(), itemViews, afterSale.getVersion(), afterSale.getCreatedAt(),
                afterSale.getUpdatedAt(), afterSale.getApprovedAt(), afterSale.getCompletedAt());
    }

    private void requireStatus(AfterSaleOrderEntity afterSale, AfterSaleStatus expected) {
        if (!expected.name().equals(afterSale.getStatus())) {
            throw new TradeException(TradeError.INVALID_STATE);
        }
    }

    private void requireOwner(Long actualUserId, Long expectedUserId) {
        if (!actualUserId.equals(expectedUserId)) {
            throw new TradeException(TradeError.FORBIDDEN);
        }
    }

    private void requireEventIdentity(AfterSaleOrderEntity afterSale, String orderNo, Long userId) {
        if (!afterSale.getOrderNo().equals(orderNo) || !afterSale.getUserId().equals(userId)) {
            throw new TradeException(TradeError.IDEMPOTENCY_CONFLICT);
        }
    }

    private void requireUpdated(int rows) {
        if (rows != 1) {
            throw new TradeException(TradeError.CONCURRENT_MODIFICATION);
        }
    }

    private boolean constantEquals(String left, String right) {
        return MessageDigest.isEqual(left.getBytes(StandardCharsets.UTF_8), right.getBytes(StandardCharsets.UTF_8));
    }

    private String sha256(String value) {
        try {
            return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256")
                    .digest(value.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 after-sale event", exception);
        }
    }
}