From 0ccdff95d2610bd31968708fb820ca2acb284bb1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=90=D0=BB=D0=B5=D0=BA=D1=81=D0=B0=D0=BD=D0=B4=D1=80=20?= =?UTF-8?q?=D0=97=D0=B8=D0=BC=D0=B8=D0=BD?= Date: Mon, 8 Jun 2026 14:27:34 +0000 Subject: [PATCH] =?UTF-8?q?feat(dict):=20soft-delete=20=D1=81=20recovery?= =?UTF-8?q?=20(Phase=201)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../definition/DictionaryDefinition.java | 29 +++++ .../DictionaryDefinitionRepository.java | 28 ++++- .../0031-dictionary-soft-delete-approval.xml | 45 +++++++ .../main/resources/db/changelog/master.xml | 1 + .../service/DictionaryDefinitionService.java | 112 +++++++++++------- .../web/DictionaryDefinitionController.java | 45 ++++++- 6 files changed, 217 insertions(+), 43 deletions(-) create mode 100644 ordinis-migrations/src/main/resources/db/changelog/changes/0031-dictionary-soft-delete-approval.xml diff --git a/ordinis-domain/src/main/java/cloud/nstart/terravault/ordinis/domain/definition/DictionaryDefinition.java b/ordinis-domain/src/main/java/cloud/nstart/terravault/ordinis/domain/definition/DictionaryDefinition.java index 7974cc7..9749598 100644 --- a/ordinis-domain/src/main/java/cloud/nstart/terravault/ordinis/domain/definition/DictionaryDefinition.java +++ b/ordinis-domain/src/main/java/cloud/nstart/terravault/ordinis/domain/definition/DictionaryDefinition.java @@ -112,6 +112,22 @@ public class DictionaryDefinition { @Column(name = "version") private Long version; + /** + * Soft-delete timestamp. NULL = active dictionary. Non-null = deleted (set + * by SchemaDeletionApproval after 2nd admin approve). Records/drafts + * остаются физически в БД (recovery возможен через restore()). + * + *

Permanent purge (drop row + cascade на child tables) — отдельный + * admin-only endpoint после approval+30d retention. По умолчанию + * list/findByName excludeает deleted, restore сбрасывает в NULL. + */ + @Column(name = "deleted_at") + private OffsetDateTime deletedAt; + + /** Admin sub claim that approved the deletion (audit trail). */ + @Column(name = "deleted_by", length = 64) + private String deletedBy; + protected DictionaryDefinition() {} public DictionaryDefinition(UUID id, String name, DataScope scope, JsonNode schemaJson) { @@ -139,6 +155,19 @@ public class DictionaryDefinition { public String getCreatedBy() { return createdBy; } public String getUpdatedBy() { return updatedBy; } public Long getVersion() { return version; } + public OffsetDateTime getDeletedAt() { return deletedAt; } + public String getDeletedBy() { return deletedBy; } + public boolean isDeleted() { return deletedAt != null; } + + public void markDeleted(String approverSub) { + this.deletedAt = OffsetDateTime.now(); + this.deletedBy = approverSub; + } + + public void restore() { + this.deletedAt = null; + this.deletedBy = null; + } public void setDisplayName(String v) { this.displayName = v; } public void setDescription(String v) { this.description = v; } diff --git a/ordinis-domain/src/main/java/cloud/nstart/terravault/ordinis/domain/definition/DictionaryDefinitionRepository.java b/ordinis-domain/src/main/java/cloud/nstart/terravault/ordinis/domain/definition/DictionaryDefinitionRepository.java index 10343dc..e0cae5b 100644 --- a/ordinis-domain/src/main/java/cloud/nstart/terravault/ordinis/domain/definition/DictionaryDefinitionRepository.java +++ b/ordinis-domain/src/main/java/cloud/nstart/terravault/ordinis/domain/definition/DictionaryDefinitionRepository.java @@ -9,7 +9,33 @@ import java.util.UUID; public interface DictionaryDefinitionRepository extends JpaRepository { - Optional findByName(String name); + /** + * Active (non-deleted) dict by name. Soft-deleted (deleted_at IS NOT NULL) + * excluded — выглядит как 404 для regular consumers. Admin Корзина UI + * использует {@link #findByNameIncludingDeleted} для restore flow. + */ + @org.springframework.data.jpa.repository.Query( + "SELECT d FROM DictionaryDefinition d WHERE d.name = :name AND d.deletedAt IS NULL") + Optional findByName(@org.springframework.data.repository.query.Param("name") String name); + + /** Includes soft-deleted — для admin restore/purge flows. */ + @org.springframework.data.jpa.repository.Query( + "SELECT d FROM DictionaryDefinition d WHERE d.name = :name") + Optional findByNameIncludingDeleted(@org.springframework.data.repository.query.Param("name") String name); + + /** + * All active (non-deleted) dictionaries. JpaRepository.findAll() возвращает + * ВСЕ rows (включая deleted) — controllers должны использовать этот метод + * для regular list. + */ + @org.springframework.data.jpa.repository.Query( + "SELECT d FROM DictionaryDefinition d WHERE d.deletedAt IS NULL") + List findAllActive(); + + /** Soft-deleted only — admin Корзина page. */ + @org.springframework.data.jpa.repository.Query( + "SELECT d FROM DictionaryDefinition d WHERE d.deletedAt IS NOT NULL ORDER BY d.deletedAt DESC") + List findAllDeleted(); List findByBundle(String bundle); diff --git a/ordinis-migrations/src/main/resources/db/changelog/changes/0031-dictionary-soft-delete-approval.xml b/ordinis-migrations/src/main/resources/db/changelog/changes/0031-dictionary-soft-delete-approval.xml new file mode 100644 index 0000000..8f5cc55 --- /dev/null +++ b/ordinis-migrations/src/main/resources/db/changelog/changes/0031-dictionary-soft-delete-approval.xml @@ -0,0 +1,45 @@ + + + + + + + deleted_at + deleted_by columns на dictionary_definitions + + + + + + + + + + + diff --git a/ordinis-migrations/src/main/resources/db/changelog/master.xml b/ordinis-migrations/src/main/resources/db/changelog/master.xml index e71a126..e5be568 100644 --- a/ordinis-migrations/src/main/resources/db/changelog/master.xml +++ b/ordinis-migrations/src/main/resources/db/changelog/master.xml @@ -40,5 +40,6 @@ + diff --git a/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/service/DictionaryDefinitionService.java b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/service/DictionaryDefinitionService.java index c2429a0..f38b092 100644 --- a/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/service/DictionaryDefinitionService.java +++ b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/service/DictionaryDefinitionService.java @@ -45,7 +45,9 @@ public class DictionaryDefinitionService { @Transactional(readOnly = true) public List 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). Юзер: + * «удаление справочников серьёзная вещь, тут разве ревью не нужно?». * - *

Caller (controller) ответственен за: - *

+ *

Phase 1 (this method): `deleted_at = now()`. Records/drafts остаются + * физически в БД. list/findByName excludes deleted by default — выглядит + * как 404 для regular consumers. Admin Корзина page + {@link #restore} + * восстанавливают. * - *

Audit + outbox event пишутся ПЕРЕД delete — после cascade rows - * исчезают. Consumers через outbox узнают что dict удалён и могут - * invalidate cache (geoportal, Альтум). + *

Phase 2 (follow-up MR): 2nd-admin approval workflow (создать + * dictionary_deletion_requests entity с maker/reviewer/status). + * + *

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 findAllDeleted() { + return repository.findAllDeleted(); } private static cloud.nstart.terravault.ordinis.events.DataScope toEventScope( diff --git a/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/web/DictionaryDefinitionController.java b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/web/DictionaryDefinitionController.java index c305291..af156fa 100644 --- a/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/web/DictionaryDefinitionController.java +++ b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/web/DictionaryDefinitionController.java @@ -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). + * + *

Admin-only. Сбрасывает {@code deleted_at}/{@code deleted_by} в NULL — + * dict снова visible в /dictionaries list. Records/drafts физически не + * трогались soft-delete'ом, восстанавливаются автоматически. + * + *

+ */ + @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 listDeleted() { + if (!workflowRoles.isAdmin()) { + throw OrdinisException.forbidden( + "forbidden_role", + "Просмотр удалённых справочников требует роли admin."); + } + return service.findAllDeleted().stream() + .map(d -> DictionaryResponse.from(d, 0L)) + .toList(); } }