feat(dict): delete dictionary endpoint + UI button с cascade safety

This commit is contained in:
Александр Зимин
2026-06-05 09:47:05 +00:00
parent cdffa4261c
commit 5c6801f993
10 changed files with 437 additions and 4 deletions
@@ -67,6 +67,16 @@ public class AuditLogger {
write("DEFINITION_UPDATED", "DEFINITION_UPDATE", d, null, prevSchema, d.getSchemaJson());
}
/**
* Audit для удаления справочника. payload_before = last schema, after = null.
* Пишется ДО cascade delete — после delete record уже не имеет dictionary_id
* (FK CASCADE на dictionary_definitions). Audit_log rows pre-delete остаются
* как append-only trail (нет FK к definition).
*/
public void definitionDeleted(DictionaryDefinition d) {
write("DEFINITION_DELETED", "DEFINITION_DELETE", d, null, d.getSchemaJson(), null);
}
public void recordUpdate(
DictionaryDefinition d, DictionaryRecord r, JsonNode payloadBefore) {
write("RECORD_UPDATED", "UPDATE", d, r, payloadBefore, r.getData());
@@ -4,6 +4,7 @@ import cloud.nstart.terravault.ordinis.domain.definition.DictionaryDefinition;
import cloud.nstart.terravault.ordinis.domain.definition.DictionaryDefinitionRepository;
import cloud.nstart.terravault.ordinis.events.EventEnvelope;
import cloud.nstart.terravault.ordinis.events.definition.DictionaryDefinitionCreated;
import cloud.nstart.terravault.ordinis.events.definition.DictionaryDefinitionDeleted;
import cloud.nstart.terravault.ordinis.events.definition.DictionaryDefinitionUpdated;
import cloud.nstart.terravault.ordinis.outbox.OutboxRecorder;
import cloud.nstart.terravault.ordinis.restapi.audit.AuditLogger;
@@ -224,6 +225,67 @@ public class DictionaryDefinitionService {
return saved;
}
/**
* Удаление справочника. Cascade на FK (migration 0030 → ON DELETE CASCADE)
* cleanup'ит dictionary_records, record_drafts, dictionary_schema_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>Audit + outbox event пишутся ПЕРЕД delete — после cascade rows
* исчезают. Consumers через outbox узнают что dict удалён и могут
* invalidate cache (geoportal, Альтум).
*
* @param name dict name
* @throws OrdinisException 404 если dict не существует
*/
@Transactional
public void delete(String name) {
DictionaryDefinition def = findByName(name);
// Audit ДО delete: после definition row gone, attached schema lost.
if (auditLogger != null) {
auditLogger.definitionDeleted(def);
}
// Outbox event ДО delete — consumers получают snapshot final state.
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());
var envelope = EventEnvelope.of(
def.getName(), toEventScope(def.getScope()), def.getBundle(), event);
outboxRecorder.record(
"DefinitionDeleted",
"DictionaryDefinition",
def.getId().toString(),
def.getName(),
def.getScope(),
def.getBundle(),
"definition:" + def.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());
}
private static cloud.nstart.terravault.ordinis.events.DataScope toEventScope(
cloud.nstart.terravault.ordinis.domain.DataScope d) {
return cloud.nstart.terravault.ordinis.events.DataScope.valueOf(d.name());
@@ -7,10 +7,13 @@ import cloud.nstart.terravault.ordinis.restapi.dto.CreateDictionaryRequest;
import cloud.nstart.terravault.ordinis.restapi.dto.DictionaryResponse;
import cloud.nstart.terravault.ordinis.restapi.dto.PatchSchemaRequest;
import cloud.nstart.terravault.ordinis.restapi.error.OrdinisException;
import cloud.nstart.terravault.ordinis.restapi.auth.WorkflowRoles;
import cloud.nstart.terravault.ordinis.restapi.service.DictionaryDefinitionService;
import cloud.nstart.terravault.ordinis.restapi.service.SchemaPatchService;
import cloud.nstart.terravault.ordinis.restapi.service.reference.LineageIndexService;
import jakarta.validation.Valid;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PatchMapping;
import org.springframework.web.bind.annotation.PathVariable;
@@ -18,6 +21,7 @@ import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;
@@ -36,16 +40,22 @@ public class DictionaryDefinitionController {
private final ScopeContext scopeContext;
private final DictionaryRecordRepository recordRepository;
private final SchemaPatchService schemaPatchService;
private final LineageIndexService lineageService;
private final WorkflowRoles workflowRoles;
public DictionaryDefinitionController(
DictionaryDefinitionService service,
ScopeContext scopeContext,
DictionaryRecordRepository recordRepository,
SchemaPatchService schemaPatchService) {
SchemaPatchService schemaPatchService,
LineageIndexService lineageService,
WorkflowRoles workflowRoles) {
this.service = service;
this.scopeContext = scopeContext;
this.recordRepository = recordRepository;
this.schemaPatchService = schemaPatchService;
this.lineageService = lineageService;
this.workflowRoles = workflowRoles;
}
@GetMapping
@@ -129,4 +139,66 @@ public class DictionaryDefinitionController {
@jakarta.validation.Valid PatchSchemaRequest req) {
return schemaPatchService.patchSchema(name, req);
}
/**
* Удалить справочник. Cascade на records + drafts + schema_drafts через
* FK ON DELETE CASCADE (migration 0030). Outbox event + audit log
* пишутся ДО delete.
*
* <p>Защита:
* <ul>
* <li>403 {@code forbidden_role} — только actor с admin role.</li>
* <li>404 {@code dictionary_not_found} — либо нет, либо вне scope actor'а.</li>
* <li>409 {@code has_dependents} — другой dict имеет {@code x-references}
* на этот. Список dependents возвращается в response для UI.
* Override: {@code ?force=true} (но это сломает FK integrity
* зависимых справочников — backend всё равно rejектит record-saves
* до тех пор пока зависимый dict не уберёт x-references).</li>
* </ul>
*
* <p>Возврат: 204 No Content на success.
*/
@DeleteMapping("/{name}")
@ResponseStatus(HttpStatus.NO_CONTENT)
public void delete(
@PathVariable String name,
@RequestParam(defaultValue = "false") boolean force) {
// Admin-only — удаление справочника deleteит N+ records/drafts/audit.
if (!workflowRoles.isAdmin()) {
throw OrdinisException.forbidden(
"forbidden_role",
"Удаление справочника требует роли admin.");
}
var existing = service.findByName(name);
// Scope check: actor не может удалить dict выше своего scope (PUBLIC
// юзер не удалит INTERNAL/RESTRICTED dict даже с admin role на нём —
// canAccess валидирует scope hierarchy).
if (!scopeContext.canAccess(existing.getScope())) {
throw OrdinisException.notFound(
"dictionary_not_found", "Dictionary not found: " + name);
}
// Incoming dependents: другой dict ссылается через x-references на
// этот → удаление сломает FK integrity. Reject с list dependents.
// ?force=true пропускает (только для cleanup orphaned test dicts).
if (!force) {
var dependents = lineageService.findSchemaDependents(
name, scopeContext.currentScopes());
if (!dependents.isEmpty()) {
var depNames = dependents.stream()
.map(d -> d.sourceDict() + "." + d.sourceField())
.toList();
throw OrdinisException.conflict(
"has_dependents",
"Нельзя удалить «" + name + "» — на него ссылаются: "
+ String.join(", ", depNames)
+ ". Сначала уберите x-references из зависимых справочников "
+ "или используйте ?force=true (сломает их FK validation).");
}
}
service.delete(name);
}
}