feat(dict): soft-delete с recovery (Phase 1)

This commit is contained in:
Александр Зимин
2026-06-08 14:27:34 +00:00
parent 4516559e21
commit 0ccdff95d2
6 changed files with 217 additions and 43 deletions
@@ -45,7 +45,9 @@ public class DictionaryDefinitionService {
@Transactional(readOnly = true)
public List<DictionaryDefinition> findAll() {
return repository.findAll();
// findAllActive — excludes soft-deleted (migration 0031). findAllIncludingDeleted
// для admin Корзина page (см. findAllDeleted() ниже).
return repository.findAllActive();
}
@Transactional(readOnly = true)
@@ -226,64 +228,92 @@ public class DictionaryDefinitionService {
}
/**
* Удаление справочника. Cascade на FK (migration 0030 → ON DELETE CASCADE)
* cleanup'ит dictionary_records, record_drafts, dictionary_schema_drafts
* атомарно в одной транзакции.
* Soft-delete справочника. Phase 1 НСИ governance — раньше DELETE endpoint
* физически удалял row + cascade на child tables (records/drafts). Юзер:
* «удаление справочников серьёзная вещь, тут разве ревью не нужно?».
*
* <p>Caller (controller) ответственен за:
* <ul>
* <li>Admin role check.</li>
* <li>Scope check (юзер должен иметь access к scope удаляемого dict).</li>
* <li>Incoming FK dependents check — если другой dict имеет
* x-references на этот, удаление сломает их integrity. Controller
* вызывает {@code LineageIndexService.findSchemaDependents}
* и throw'ит 409 если non-empty.</li>
* </ul>
* <p>Phase 1 (this method): `deleted_at = now()`. Records/drafts остаются
* физически в БД. list/findByName excludes deleted by default — выглядит
* как 404 для regular consumers. Admin Корзина page + {@link #restore}
* восстанавливают.
*
* <p>Audit + outbox event пишутся ПЕРЕД delete — после cascade rows
* исчезают. Consumers через outbox узнают что dict удалён и могут
* invalidate cache (geoportal, Альтум).
* <p>Phase 2 (follow-up MR): 2nd-admin approval workflow (создать
* dictionary_deletion_requests entity с maker/reviewer/status).
*
* <p>Permanent purge (физический delete row + cascade) — отдельный admin
* flow `purge(name)` после retention period 30+ дней.
*
* @param name dict name
* @throws OrdinisException 404 если dict не существует
* @param actorSub admin sub claim из JWT — для audit `deleted_by`
* @throws OrdinisException 404 если dict не существует или уже deleted
*/
@Transactional
public void delete(String name) {
public void softDelete(String name, String actorSub) {
DictionaryDefinition def = findByName(name);
// Audit ДО delete: после definition row gone, attached schema lost.
// markDeleted мутирует deletedAt + deletedBy. @Transactional flush
// запишет UPDATE на definition row перед commit.
def.markDeleted(actorSub);
DictionaryDefinition saved = repository.save(def);
if (auditLogger != null) {
auditLogger.definitionDeleted(def);
auditLogger.definitionDeleted(saved);
}
// Outbox event ДО delete — consumers получают snapshot final state.
// Outbox event — consumers (геопортал, Альтум) узнают что dict soft-deleted
// и могут invalidate cache. На restore — отдельный event (TODO follow-up).
var event = new DictionaryDefinitionDeleted(
def.getId(),
def.getName(),
def.getDisplayName(),
toEventScope(def.getScope()),
def.getBundle(),
-1L, // counts skipped в этой версии; см. TODO ниже
-1L,
-1L,
java.time.OffsetDateTime.now(),
def.getUpdatedBy());
saved.getId(),
saved.getName(),
saved.getDisplayName(),
toEventScope(saved.getScope()),
saved.getBundle(),
-1L, -1L, -1L,
saved.getDeletedAt(),
saved.getDeletedBy());
var envelope = EventEnvelope.of(
def.getName(), toEventScope(def.getScope()), def.getBundle(), event);
saved.getName(), toEventScope(saved.getScope()), saved.getBundle(), event);
outboxRecorder.record(
"DefinitionDeleted",
"DictionaryDefinition",
def.getId().toString(),
def.getName(),
def.getScope(),
def.getBundle(),
"definition:" + def.getName(),
saved.getId().toString(),
saved.getName(),
saved.getScope(),
saved.getBundle(),
"definition:" + saved.getName(),
envelope);
}
// Single delete — FK CASCADE (migration 0030) подхватит
// dictionary_records / record_drafts / dictionary_schema_drafts.
// audit_log + outbox_events не имеют FK, остаются как append-only trail.
repository.deleteById(def.getId());
/**
* Восстановление soft-deleted справочника. Admin-only. Resets
* `deleted_at` / `deleted_by` в NULL → dict снова visible в list().
*
* @throws OrdinisException 404 если dict не существует или active (not deleted)
*/
@Transactional
public DictionaryDefinition restore(String name) {
var existing = repository.findByNameIncludingDeleted(name)
.orElseThrow(() -> OrdinisException.notFound(
"dictionary_not_found", "Dictionary not found: " + name));
if (!existing.isDeleted()) {
throw OrdinisException.conflict(
"not_deleted", "Dictionary " + name + " не помечен как удалённый.");
}
existing.restore();
var saved = repository.save(existing);
if (auditLogger != null) {
// Reuse definitionUpdated audit — payload_before = schema as-deleted,
// after = same schema (restore не меняет content, только deleted_at).
// Создавать отдельный DEFINITION_RESTORED event можно в follow-up.
auditLogger.definitionUpdated(saved, saved.getSchemaJson());
}
return saved;
}
/** Все soft-deleted справочники для admin Корзина page. */
@Transactional(readOnly = true)
public List<DictionaryDefinition> findAllDeleted() {
return repository.findAllDeleted();
}
private static cloud.nstart.terravault.ordinis.events.DataScope toEventScope(
@@ -199,6 +199,49 @@ public class DictionaryDefinitionController {
}
}
service.delete(name);
var auth = org.springframework.security.core.context.SecurityContextHolder
.getContext().getAuthentication();
String actorSub = (auth != null && auth.isAuthenticated()) ? auth.getName() : "admin";
service.softDelete(name, actorSub);
}
/**
* Восстановление soft-deleted справочника (recovery после accidental delete).
*
* <p>Admin-only. Сбрасывает {@code deleted_at}/{@code deleted_by} в NULL —
* dict снова visible в /dictionaries list. Records/drafts физически не
* трогались soft-delete'ом, восстанавливаются автоматически.
*
* <ul>
* <li>403 {@code forbidden_role}</li>
* <li>404 {@code dictionary_not_found} — никогда не существовал</li>
* <li>409 {@code not_deleted} — dict не помечен как удалённый</li>
* </ul>
*/
@PostMapping("/{name}/restore")
public DictionaryResponse restore(@PathVariable String name) {
if (!workflowRoles.isAdmin()) {
throw OrdinisException.forbidden(
"forbidden_role",
"Восстановление справочника требует роли admin.");
}
return DictionaryResponse.from(service.restore(name));
}
/**
* Список soft-deleted справочников (Корзина admin page). Admin-only.
* Каждый item можно восстановить через {@link #restore} или physically
* purge через отдельный admin endpoint (TODO follow-up).
*/
@GetMapping("/deleted")
public List<DictionaryResponse> listDeleted() {
if (!workflowRoles.isAdmin()) {
throw OrdinisException.forbidden(
"forbidden_role",
"Просмотр удалённых справочников требует роли admin.");
}
return service.findAllDeleted().stream()
.map(d -> DictionaryResponse.from(d, 0L))
.toList();
}
}