feat(dict): soft-delete с recovery (Phase 1)
This commit is contained in:
+29
@@ -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; }
|
||||
|
||||
+27
-1
@@ -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);
|
||||
|
||||
|
||||
+45
@@ -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>
|
||||
|
||||
+71
-41
@@ -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(
|
||||
|
||||
+44
-1
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user