CatalogSearchRebuildService.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.SearchModels.SearchRebuildRecoveryView;
import com.ecommerce.catalog.application.model.SearchModels.SearchRebuildView;
import com.ecommerce.catalog.infrastructure.persistence.entity.SearchRebuildEntity;
import com.ecommerce.catalog.infrastructure.persistence.entity.SearchRebuildRecoveryAuditEntity;
import com.ecommerce.catalog.infrastructure.persistence.mapper.SearchRebuildMapper;
import com.ecommerce.catalog.infrastructure.persistence.mapper.SearchRebuildRecoveryAuditMapper;
import com.ecommerce.catalog.infrastructure.search.CatalogSearchProperties;
import org.springframework.stereotype.Service;
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.HexFormat;
import java.util.List;

@Service
public class CatalogSearchRebuildService {

    private final SearchRebuildMapper mapper;
    private final SearchRebuildRecoveryAuditMapper recoveryMapper;
    private final CatalogSearchProperties properties;

    public CatalogSearchRebuildService(
            SearchRebuildMapper mapper,
            SearchRebuildRecoveryAuditMapper recoveryMapper,
            CatalogSearchProperties properties) {
        this.mapper = mapper;
        this.recoveryMapper = recoveryMapper;
        this.properties = properties;
    }

    @Transactional
    public SearchRebuildView submit(long operatorId, String commandId, String reason) {
        requireEnabled();
        String normalizedReason = reason.trim();
        String requestHash = sha256(operatorId + "|" + normalizedReason);
        SearchRebuildEntity existing = mapper.selectByCommandId(commandId);
        if (existing != null) {
            requireSameHash(existing.getRequestHash(), requestHash);
            return view(existing);
        }

        Instant now = now();
        long id = IdWorker.getId();
        SearchRebuildEntity rebuild = new SearchRebuildEntity();
        rebuild.setId(id);
        rebuild.setCommandId(commandId);
        rebuild.setOperatorId(operatorId);
        rebuild.setReason(normalizedReason);
        rebuild.setRequestHash(requestHash);
        rebuild.setStatus("PENDING");
        rebuild.setTargetIndex(properties.indexAlias() + "-v-" + id);
        rebuild.setAttempts(0);
        rebuild.setIndexedCount(0L);
        rebuild.setCreatedAt(now);
        rebuild.setUpdatedAt(now);
        mapper.insertIdempotent(rebuild);
        SearchRebuildEntity persisted = mapper.selectByCommandIdForUpdate(commandId);
        if (persisted == null) {
            throw new CatalogException(CatalogError.CONCURRENT_MODIFICATION);
        }
        requireSameHash(persisted.getRequestHash(), requestHash);
        return view(persisted);
    }

    @Transactional(readOnly = true)
    public SearchRebuildView get(long id) {
        SearchRebuildEntity rebuild = mapper.selectById(id);
        if (rebuild == null) {
            throw new CatalogException(CatalogError.RESOURCE_NOT_FOUND);
        }
        return view(rebuild);
    }

    @Transactional(readOnly = true)
    public List<SearchRebuildView> list(int limit) {
        return mapper.selectRecent(limit).stream().map(this::view).toList();
    }

    @Transactional
    public SearchRebuildRecoveryView recover(
            long rebuildId,
            long operatorId,
            String commandId,
            String reason) {
        requireEnabled();
        String normalizedReason = reason.trim();
        String requestHash = sha256(rebuildId + "|" + operatorId + "|" + normalizedReason);
        SearchRebuildRecoveryAuditEntity existing = recoveryMapper.selectByCommandId(commandId);
        if (existing != null) {
            requireSameHash(existing.getRequestHash(), requestHash);
            return recoveryView(existing);
        }
        SearchRebuildEntity rebuild = mapper.selectByIdForUpdate(rebuildId);
        if (rebuild == null) {
            throw new CatalogException(CatalogError.RESOURCE_NOT_FOUND);
        }
        existing = recoveryMapper.selectByCommandIdForUpdate(commandId);
        if (existing != null) {
            requireSameHash(existing.getRequestHash(), requestHash);
            return recoveryView(existing);
        }
        if (!"NEEDS_ATTENTION".equals(rebuild.getStatus())) {
            throw new CatalogException(CatalogError.INVALID_STATE);
        }
        Instant now = now();
        SearchRebuildRecoveryAuditEntity audit = new SearchRebuildRecoveryAuditEntity();
        audit.setId(IdWorker.getId());
        audit.setCommandId(commandId);
        audit.setRebuildId(rebuildId);
        audit.setOperatorId(operatorId);
        audit.setReason(normalizedReason);
        audit.setRequestHash(requestHash);
        audit.setStatusBefore("NEEDS_ATTENTION");
        audit.setStatusAfter("PENDING");
        audit.setCreatedAt(now);
        int inserted = recoveryMapper.insertIdempotent(audit);
        SearchRebuildRecoveryAuditEntity persisted =
                recoveryMapper.selectByCommandIdForUpdate(commandId);
        if (persisted == null) {
            throw new CatalogException(CatalogError.CONCURRENT_MODIFICATION);
        }
        requireSameHash(persisted.getRequestHash(), requestHash);
        if (inserted == 0) {
            return recoveryView(persisted);
        }
        if (mapper.recover(rebuildId, now) != 1) {
            throw new CatalogException(CatalogError.CONCURRENT_MODIFICATION);
        }
        return recoveryView(persisted);
    }

    public SearchRebuildView view(SearchRebuildEntity rebuild) {
        return new SearchRebuildView(
                rebuild.getId(),
                rebuild.getCommandId(),
                rebuild.getStatus(),
                rebuild.getTargetIndex(),
                rebuild.getAttempts(),
                rebuild.getIndexedCount(),
                rebuild.getLastError(),
                rebuild.getCreatedAt(),
                rebuild.getStartedAt(),
                rebuild.getCompletedAt());
    }

    private SearchRebuildRecoveryView recoveryView(SearchRebuildRecoveryAuditEntity audit) {
        return new SearchRebuildRecoveryView(
                audit.getCommandId(),
                audit.getRebuildId(),
                audit.getStatusBefore(),
                audit.getStatusAfter(),
                audit.getCreatedAt());
    }

    private void requireEnabled() {
        if (!properties.enabled()) {
            throw new CatalogException(CatalogError.SEARCH_INDEX_UNAVAILABLE);
        }
    }

    private void requireSameHash(String existing, String supplied) {
        if (!MessageDigest.isEqual(
                existing.getBytes(StandardCharsets.UTF_8),
                supplied.getBytes(StandardCharsets.UTF_8))) {
            throw new CatalogException(CatalogError.IDEMPOTENCY_CONFLICT);
        }
    }

    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 Instant now() {
        return mapper.currentTime();
    }
}