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
@@ -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()).
*
* <p>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; }
@@ -9,7 +9,33 @@ import java.util.UUID;
public interface DictionaryDefinitionRepository extends JpaRepository<DictionaryDefinition, UUID> {
Optional<DictionaryDefinition> 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<DictionaryDefinition> 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<DictionaryDefinition> 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<DictionaryDefinition> 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<DictionaryDefinition> findAllDeleted();
List<DictionaryDefinition> findByBundle(String bundle);
@@ -0,0 +1,45 @@
<?xml version="1.0" encoding="UTF-8"?>
<databaseChangeLog
xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.27.xsd">
<!--
Soft-delete для dictionaries (Phase 1 НСИ governance).
Контекст: MR !292 (v2.52.0) добавил DELETE endpoint с cascade на FK
(migration 0030 ON DELETE CASCADE). Юзер: «удаление справочников
серьезная вещь тут разве ревью не нужно? и это физическое удаление
из бд?». Ответ — да, это было unsafe.
Phase 1 (этот MR): soft-delete. DELETE endpoint вместо физического
cascade удаления ставит `deleted_at = now()`. Records/drafts intact.
`list()` excludes deleted by default. Admin Корзина page + restore
endpoint позволяют вернуть случайно удалённое.
Phase 2 (follow-up MR): 2nd-admin approval workflow (создаст таблицу
`dictionary_deletion_requests` с maker/reviewer как для drafts).
Permanent purge — отдельный admin-only flow `POST /admin/dictionaries/
{name}/purge` после approval+30d retention (follow-up cron). Migration
0030 ON DELETE CASCADE остаётся — нужен для finalize physical delete
в purge endpoint (cascade на records/drafts/schema_drafts).
-->
<changeSet id="0031-1-dictionary-soft-delete-columns" author="ordinis">
<comment>deleted_at + deleted_by columns на dictionary_definitions</comment>
<addColumn tableName="dictionary_definitions">
<column name="deleted_at" type="TIMESTAMP WITH TIME ZONE"/>
<column name="deleted_by" type="VARCHAR(64)"/>
</addColumn>
<createIndex tableName="dictionary_definitions"
indexName="idx_dict_def_deleted_at">
<column name="deleted_at"/>
</createIndex>
</changeSet>
<!-- Phase 2 (follow-up): создать таблицу dictionary_deletion_requests
с maker/reviewer/status для 2nd-admin approval workflow. Сейчас Phase 1
только soft-delete сам с admin self-confirmation. -->
</databaseChangeLog>
@@ -40,5 +40,6 @@
<include file="changes/0028-phase3-prep-org-id.xml" relativeToChangelogFile="true"/>
<include file="changes/0029-drop-self-approve-constraints.xml" relativeToChangelogFile="true"/>
<include file="changes/0030-dictionary-delete-cascade.xml" relativeToChangelogFile="true"/>
<include file="changes/0031-dictionary-soft-delete-approval.xml" relativeToChangelogFile="true"/>
</databaseChangeLog>
@@ -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();
}
}