ProductReviewService.java

package com.ecommerce.catalog.application.service;

import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.ecommerce.catalog.application.exception.CatalogError;
import com.ecommerce.catalog.application.exception.CatalogException;
import com.ecommerce.catalog.application.model.ReviewModels.CreateReviewCommand;
import com.ecommerce.catalog.application.model.ReviewModels.ModerationResultView;
import com.ecommerce.catalog.application.model.ReviewModels.OrderCompletedEvent;
import com.ecommerce.catalog.application.model.ReviewModels.OrderLineSnapshot;
import com.ecommerce.catalog.application.model.ReviewModels.ProductReviewView;
import com.ecommerce.catalog.application.model.ReviewModels.ReviewEligibilityView;
import com.ecommerce.catalog.application.model.ReviewModels.ReviewReportReceipt;
import com.ecommerce.catalog.application.model.ReviewModels.ReviewReportView;
import com.ecommerce.catalog.application.model.ReviewModels.ReviewSummaryView;
import com.ecommerce.catalog.infrastructure.persistence.ProductReviewRepository;
import com.ecommerce.catalog.infrastructure.persistence.ProductReviewRepository.EligibilityState;
import com.ecommerce.catalog.infrastructure.persistence.ProductReviewRepository.ModerationAuditState;
import com.ecommerce.catalog.infrastructure.persistence.ProductReviewRepository.ReplyState;
import com.ecommerce.catalog.infrastructure.persistence.ProductReviewRepository.ReportState;
import com.ecommerce.catalog.infrastructure.persistence.ProductReviewRepository.ReviewState;
import com.ecommerce.platform.common.api.PageResponse;
import com.ecommerce.platform.common.idempotency.PayloadFingerprint;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Isolation;
import org.springframework.transaction.annotation.Transactional;

import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.time.Instant;
import java.util.HashSet;
import java.util.List;
import java.util.Locale;
import java.util.Set;

@Service
public class ProductReviewService {

    private static final Set<String> REPORT_REASONS = Set.of(
            "SPAM", "ABUSE", "FALSE_INFORMATION", "OTHER");
    private static final Set<String> REPORT_STATUSES = Set.of("OPEN", "RESOLVED");
    private static final Set<String> RESOLUTIONS = Set.of("UPHELD", "REJECTED");

    private final ProductReviewRepository repository;

    public ProductReviewService(ProductReviewRepository repository) {
        this.repository = repository;
    }

    @Transactional
    public boolean acceptOrderCompleted(
            OrderCompletedEvent event,
            String consumerGroup) {
        validateOrderCompleted(event);
        Instant now = repository.currentTime();
        String payloadFingerprint = PayloadFingerprint.of(event);
        if (!repository.insertConsumed(event.eventId(), consumerGroup, payloadFingerprint, now)) {
            String storedFingerprint = repository.findConsumedFingerprint(
                    event.eventId(), consumerGroup);
            if (!PayloadFingerprint.matches(storedFingerprint, payloadFingerprint)) {
                throw new CatalogException(CatalogError.IDEMPOTENCY_CONFLICT);
            }
            return false;
        }
        for (OrderLineSnapshot item : event.items()) {
            repository.insertEligibility(IdWorker.getId(), event, item, now);
        }
        return true;
    }

    @Transactional(readOnly = true)
    public List<ReviewEligibilityView> listEligibilities(long userId, String orderNo) {
        return repository.listEligibilities(userId, normalizeOptional(orderNo));
    }

    @Transactional(isolation = Isolation.READ_COMMITTED)
    public ProductReviewView createReview(CreateReviewCommand command) {
        String content = requireText(command.content(), 2000);
        String idempotencyKey = requireText(command.idempotencyKey(), 64);
        if (command.userId() == null || command.userId() <= 0
                || command.eligibilityId() == null || command.eligibilityId() <= 0
                || command.rating() < 1 || command.rating() > 5) {
            throw new IllegalArgumentException("Review command is invalid");
        }
        String requestHash = hash(
                command.eligibilityId(),
                command.rating(),
                content,
                command.anonymous());

        ReviewState existing = repository.findReviewByIdempotency(
                command.userId(),
                idempotencyKey);
        if (existing != null) {
            return sameReviewRequest(existing, requestHash, command.userId());
        }

        EligibilityState eligibility = repository.findEligibilityForUpdate(
                command.eligibilityId(),
                command.userId());
        if (eligibility == null) {
            throw new CatalogException(CatalogError.RESOURCE_NOT_FOUND);
        }
        existing = repository.findReviewByIdempotency(command.userId(), idempotencyKey);
        if (existing != null) {
            return sameReviewRequest(existing, requestHash, command.userId());
        }
        ReviewState eligibilityReview = repository.findReviewByEligibilityForUpdate(
                eligibility.id());
        if (eligibilityReview != null) {
            if (!idempotencyKey.equals(eligibilityReview.idempotencyKey())) {
                throw new CatalogException(CatalogError.REVIEW_ALREADY_SUBMITTED);
            }
            return sameReviewRequest(eligibilityReview, requestHash, command.userId());
        }
        if (!"ELIGIBLE".equals(eligibility.status())) {
            throw new CatalogException(CatalogError.REVIEW_ALREADY_SUBMITTED);
        }

        Instant now = repository.currentTime();
        long reviewId = IdWorker.getId();
        repository.insertReview(
                reviewId,
                eligibility,
                command.rating(),
                content,
                command.anonymous(),
                idempotencyKey,
                requestHash,
                now);
        repository.markEligibilityReviewed(eligibility.id(), now);
        repository.incrementSummary(eligibility.productId(), command.rating(), now);
        return requireReviewView(reviewId, command.userId());
    }

    @Transactional(readOnly = true)
    public ReviewSummaryView summary(long productId) {
        requireActiveProduct(productId);
        return repository.findSummary(productId);
    }

    @Transactional(readOnly = true)
    public PageResponse<ProductReviewView> listPublished(
            long productId,
            Long viewerId,
            long page,
            long size) {
        requireActiveProduct(productId);
        long total = repository.countPublishedReviews(productId);
        long offset;
        try {
            offset = Math.multiplyExact(page - 1, size);
        } catch (ArithmeticException exception) {
            throw new IllegalArgumentException("Review page is too large", exception);
        }
        List<ProductReviewView> items = repository.listPublishedReviews(
                productId,
                viewerId,
                offset,
                Math.toIntExact(size));
        return new PageResponse<>(items, page, size, total);
    }

    @Transactional
    public ProductReviewView like(long userId, long reviewId) {
        ReviewState review = requirePublishedReviewForUpdate(reviewId);
        Instant now = repository.currentTime();
        if (repository.insertLike(reviewId, userId, now)) {
            repository.incrementLikeCount(reviewId, now);
        }
        return requireReviewView(review.id(), userId);
    }

    @Transactional
    public ProductReviewView unlike(long userId, long reviewId) {
        ReviewState review = requirePublishedReviewForUpdate(reviewId);
        Instant now = repository.currentTime();
        if (repository.deleteLike(reviewId, userId)) {
            repository.decrementLikeCount(reviewId, now);
        }
        return requireReviewView(review.id(), userId);
    }

    @Transactional
    public ReviewReportReceipt report(
            long reporterId,
            long reviewId,
            String reasonCode,
            String detail) {
        ReviewState review = requirePublishedReviewForUpdate(reviewId);
        if (review.userId() == reporterId) {
            throw new CatalogException(CatalogError.REVIEW_ACTION_NOT_ALLOWED);
        }
        String normalizedReason = requireText(reasonCode, 40).toUpperCase(Locale.ROOT);
        if (!REPORT_REASONS.contains(normalizedReason)) {
            throw new IllegalArgumentException("Unsupported review report reason");
        }
        String normalizedDetail = normalizeOptional(detail);
        if (normalizedDetail != null && normalizedDetail.length() > 500) {
            throw new IllegalArgumentException("Review report detail is too long");
        }
        String requestHash = hash(reviewId, normalizedReason, normalizedDetail);
        ReportState existing = repository.findReportByReporter(reviewId, reporterId);
        if (existing != null) {
            if (!requestHash.equals(existing.requestHash())) {
                throw new CatalogException(CatalogError.IDEMPOTENCY_CONFLICT);
            }
            return new ReviewReportReceipt(
                    existing.id(),
                    existing.reviewId(),
                    existing.status(),
                    repository.currentTime());
        }
        Instant now = repository.currentTime();
        long reportId = IdWorker.getId();
        if (!repository.insertReport(
                reportId,
                reviewId,
                reporterId,
                normalizedReason,
                normalizedDetail,
                requestHash,
                now)) {
            ReportState raced = repository.findReportByReporter(reviewId, reporterId);
            if (raced == null || !requestHash.equals(raced.requestHash())) {
                throw new CatalogException(CatalogError.IDEMPOTENCY_CONFLICT);
            }
            return new ReviewReportReceipt(
                    raced.id(),
                    raced.reviewId(),
                    raced.status(),
                    now);
        }
        return new ReviewReportReceipt(reportId, reviewId, "OPEN", now);
    }

    @Transactional
    public ProductReviewView reply(
            long operatorId,
            long reviewId,
            String commandId,
            String content) {
        String normalizedCommand = requireText(commandId, 64);
        String normalizedContent = requireText(content, 1000);
        String requestHash = hash(reviewId, normalizedContent);

        ReplyState existing = repository.findReplyByCommand(normalizedCommand);
        if (existing != null) {
            if (existing.reviewId() != reviewId
                    || existing.operatorId() != operatorId
                    || !requestHash.equals(existing.requestHash())) {
                throw new CatalogException(CatalogError.IDEMPOTENCY_CONFLICT);
            }
            return requireReviewView(reviewId, operatorId);
        }

        requirePublishedReviewForUpdate(reviewId);
        existing = repository.findReplyByCommand(normalizedCommand);
        if (existing != null) {
            if (existing.reviewId() != reviewId
                    || existing.operatorId() != operatorId
                    || !requestHash.equals(existing.requestHash())) {
                throw new CatalogException(CatalogError.IDEMPOTENCY_CONFLICT);
            }
            return requireReviewView(reviewId, operatorId);
        }
        ReplyState byReview = repository.findReplyByReview(reviewId);
        if (byReview != null) {
            throw new CatalogException(CatalogError.REVIEW_ACTION_NOT_ALLOWED);
        }
        Instant now = repository.currentTime();
        if (!repository.insertReply(
                IdWorker.getId(),
                reviewId,
                operatorId,
                normalizedContent,
                normalizedCommand,
                requestHash,
                now)) {
            throw new CatalogException(CatalogError.REVIEW_ACTION_NOT_ALLOWED);
        }
        return requireReviewView(reviewId, operatorId);
    }

    @Transactional(readOnly = true)
    public PageResponse<ReviewReportView> listReports(
            String requestedStatus,
            long page,
            long size) {
        String status = normalizeOptional(requestedStatus);
        if (status != null) {
            status = status.toUpperCase(Locale.ROOT);
            if (!REPORT_STATUSES.contains(status)) {
                throw new IllegalArgumentException("Unsupported review report status");
            }
        }
        long total = repository.countReports(status);
        long offset = Math.multiplyExact(page - 1, size);
        return new PageResponse<>(
                repository.listReports(status, offset, Math.toIntExact(size)),
                page,
                size,
                total);
    }

    @Transactional
    public ModerationResultView resolveReport(
            long operatorId,
            long reportId,
            String commandId,
            String resolution,
            String reason) {
        String normalizedCommand = requireText(commandId, 64);
        String normalizedResolution = requireText(resolution, 20).toUpperCase(Locale.ROOT);
        String normalizedReason = requireText(reason, 500);
        if (!RESOLUTIONS.contains(normalizedResolution)) {
            throw new IllegalArgumentException("Unsupported review report resolution");
        }
        String requestHash = hash(reportId, normalizedResolution, normalizedReason);
        ModerationAuditState existing = repository.findModerationAudit(normalizedCommand);
        if (existing != null) {
            return sameModerationRequest(existing, operatorId, reportId, requestHash);
        }

        ReportState report = repository.findReportForUpdate(reportId);
        if (report == null) {
            throw new CatalogException(CatalogError.RESOURCE_NOT_FOUND);
        }
        existing = repository.findModerationAudit(normalizedCommand);
        if (existing != null) {
            return sameModerationRequest(existing, operatorId, reportId, requestHash);
        }
        if (!"OPEN".equals(report.status())) {
            throw new CatalogException(CatalogError.REPORT_ALREADY_RESOLVED);
        }
        ReviewState review = repository.findReviewForUpdate(report.reviewId());
        if (review == null) {
            throw new CatalogException(CatalogError.RESOURCE_NOT_FOUND);
        }
        String beforeStatus = review.status();
        String afterStatus = beforeStatus;
        Instant now = repository.currentTime();
        if ("UPHELD".equals(normalizedResolution)
                && "PUBLISHED".equals(beforeStatus)) {
            repository.hideReview(review.id(), now);
            repository.decrementSummary(review.productId(), review.rating(), now);
            afterStatus = "HIDDEN";
        }
        if (!repository.insertModerationAudit(
                IdWorker.getId(),
                normalizedCommand,
                report.id(),
                review.id(),
                operatorId,
                normalizedResolution,
                normalizedReason,
                requestHash,
                beforeStatus,
                afterStatus,
                now)) {
            ModerationAuditState raced = repository.findModerationAudit(normalizedCommand);
            if (raced != null) {
                return sameModerationRequest(raced, operatorId, reportId, requestHash);
            }
            throw new CatalogException(CatalogError.IDEMPOTENCY_CONFLICT);
        }
        repository.resolveReport(report.id(), operatorId, normalizedResolution, now);
        return repository.moderationResult(
                repository.findModerationAudit(normalizedCommand));
    }

    private ProductReviewView sameReviewRequest(
            ReviewState existing,
            String requestHash,
            long viewerId) {
        if (!requestHash.equals(existing.requestHash())) {
            throw new CatalogException(CatalogError.IDEMPOTENCY_CONFLICT);
        }
        return requireReviewView(existing.id(), viewerId);
    }

    private ModerationResultView sameModerationRequest(
            ModerationAuditState existing,
            long operatorId,
            long reportId,
            String requestHash) {
        if (existing.operatorId() != operatorId
                || existing.reportId() != reportId
                || !requestHash.equals(existing.requestHash())) {
            throw new CatalogException(CatalogError.IDEMPOTENCY_CONFLICT);
        }
        return repository.moderationResult(existing);
    }

    private ReviewState requirePublishedReviewForUpdate(long reviewId) {
        ReviewState review = repository.findReviewForUpdate(reviewId);
        if (review == null) {
            throw new CatalogException(CatalogError.RESOURCE_NOT_FOUND);
        }
        if (!"PUBLISHED".equals(review.status())) {
            throw new CatalogException(CatalogError.REVIEW_NOT_PUBLISHED);
        }
        return review;
    }

    private ProductReviewView requireReviewView(long reviewId, Long viewerId) {
        ProductReviewView review = repository.findReviewView(reviewId, viewerId);
        if (review == null) {
            throw new CatalogException(CatalogError.RESOURCE_NOT_FOUND);
        }
        return review;
    }

    private void requireActiveProduct(long productId) {
        if (productId <= 0 || !repository.activeProductExists(productId)) {
            throw new CatalogException(CatalogError.RESOURCE_NOT_FOUND);
        }
    }

    private void validateOrderCompleted(OrderCompletedEvent event) {
        if (event == null
                || event.eventId() == null
                || event.eventId().isBlank()
                || event.eventId().length() > 36
                || event.orderNo() == null
                || event.orderNo().isBlank()
                || event.orderNo().length() > 64
                || event.userId() == null
                || event.userId() <= 0
                || event.completedAt() == null
                || event.items() == null
                || event.items().isEmpty()) {
            throw new IllegalArgumentException("OrderCompleted event is invalid");
        }
        Set<Integer> lines = new HashSet<>();
        for (OrderLineSnapshot item : event.items()) {
            if (item.lineNo() <= 0
                    || !lines.add(item.lineNo())
                    || item.productId() == null
                    || item.productId() <= 0
                    || item.skuId() == null
                    || item.skuId() <= 0
                    || item.quantity() <= 0
                    || invalidText(item.productTitle(), 160)
                    || invalidText(item.skuCode(), 64)
                    || invalidText(item.skuName(), 160)
                    || invalidText(item.specJson(), 2000)
                    || item.imageObjectKey() != null
                    && item.imageObjectKey().length() > 500) {
                throw new IllegalArgumentException("OrderCompleted item is invalid");
            }
        }
    }

    private boolean invalidText(String value, int maximumLength) {
        return value == null || value.isBlank() || value.length() > maximumLength;
    }

    private String requireText(String value, int maximumLength) {
        if (value == null) {
            throw new IllegalArgumentException("Required text is missing");
        }
        String normalized = value.strip();
        if (normalized.isEmpty() || normalized.length() > maximumLength) {
            throw new IllegalArgumentException("Required text is invalid");
        }
        return normalized;
    }

    private String normalizeOptional(String value) {
        if (value == null) {
            return null;
        }
        String normalized = value.strip();
        return normalized.isEmpty() ? null : normalized;
    }

    private String hash(Object... components) {
        try {
            MessageDigest digest = MessageDigest.getInstance("SHA-256");
            for (Object component : components) {
                String value = component == null ? "<null>" : component.toString();
                digest.update(value.getBytes(StandardCharsets.UTF_8));
                digest.update((byte) 0);
            }
            return java.util.HexFormat.of().formatHex(digest.digest());
        } catch (NoSuchAlgorithmException exception) {
            throw new IllegalStateException("SHA-256 is unavailable", exception);
        }
    }
}