feat(dict): 7-day retention auto-purge + inline restore UI

This commit is contained in:
Александр Зимин
2026-06-09 13:40:23 +00:00
parent 05d2093273
commit 8621687a60
5 changed files with 286 additions and 3 deletions
@@ -0,0 +1,102 @@
package cloud.nstart.terravault.ordinis.restapi.service;
import cloud.nstart.terravault.ordinis.domain.definition.DictionaryDefinition;
import cloud.nstart.terravault.ordinis.domain.definition.DictionaryDefinitionRepository;
import io.micrometer.core.instrument.Counter;
import io.micrometer.core.instrument.MeterRegistry;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
import java.time.Duration;
import java.time.OffsetDateTime;
import java.util.List;
/**
* Permanent purge для soft-deleted справочников.
*
* <p>Retention 7 дней (NSI governance policy — юзер: «договаривались же
* 7 дней хранится и только потом удаляется и в течение 7 дней можно
* вернуть словарь»). После {@code deleted_at + 7d} dict physically
* removed через FK CASCADE (migration 0030): definition row + cascade на
* dictionary_records + record_drafts + dictionary_schema_drafts. Audit_log
* остаётся (нет FK) как permanent trail.
*
* <p>Cron каждый час. Если purge fails — log + counter, не блокируем
* остальные jobs. Idempotent: следующий run попробует снова.
*
* <p>Recovery window: 07 дней после soft-delete admin может вызвать
* {@code POST /api/v1/dictionaries/{name}/restore}. После purge — только
* через DB backup.
*/
@Component
public class DictionaryPurgeJob {
private static final Logger log = LoggerFactory.getLogger(DictionaryPurgeJob.class);
/** 7 дней — NSI retention policy. Override через property для testing. */
private static final Duration RETENTION = Duration.ofDays(7);
private final DictionaryDefinitionRepository repository;
private final Counter purgedCounter;
private final Counter errorCounter;
public DictionaryPurgeJob(
DictionaryDefinitionRepository repository, MeterRegistry meterRegistry) {
this.repository = repository;
this.purgedCounter = Counter.builder("ordinis_dictionary_purged_total")
.description("Permanently purged soft-deleted dictionaries (после 7d retention)")
.register(meterRegistry);
this.errorCounter = Counter.builder("ordinis_dictionary_purge_errors_total")
.description("Purge attempts что failed (rollback safe — retry on next run)")
.register(meterRegistry);
}
/**
* Cron каждый час в :23 (random minute — не competing с idempotency
* cleanup at :17, projection refresh, и др.).
*
* <p>Override через property {@code ordinis.dictionary.purge-cron} для
* test/staging если нужно faster purge cycle.
*/
@Scheduled(cron = "${ordinis.dictionary.purge-cron:0 23 * * * *}")
@Transactional
public void purge() {
OffsetDateTime cutoff = OffsetDateTime.now().minus(RETENTION);
List<DictionaryDefinition> candidates = repository.findAllDeleted().stream()
.filter(d -> d.getDeletedAt() != null && d.getDeletedAt().isBefore(cutoff))
.toList();
if (candidates.isEmpty()) {
log.debug("Dictionary purge: 0 candidates (retention {} days, cutoff {})",
RETENTION.toDays(), cutoff);
return;
}
log.info("Dictionary purge: найдено {} кандидатов на physical delete "
+ "(deleted_at < {})", candidates.size(), cutoff);
int purged = 0;
for (DictionaryDefinition d : candidates) {
try {
// FK ON DELETE CASCADE (migration 0030) cleanup'ит:
// dictionary_records + record_drafts + dictionary_schema_drafts.
// audit_log + outbox_events не имеют FK — остаются permanent trail.
repository.deleteById(d.getId());
log.info("Dictionary purged: name={} id={} deleted_at={} (>{} days ago)",
d.getName(), d.getId(), d.getDeletedAt(), RETENTION.toDays());
purged++;
} catch (Exception e) {
// Не throw — продолжаем остальные. Retry на следующем cron run.
log.error("Dictionary purge failed для name={}: {}", d.getName(), e.getMessage());
errorCounter.increment();
}
}
if (purged > 0) {
purgedCounter.increment(purged);
}
}
}