feat(dict): delete dictionary endpoint + UI button с cascade safety
This commit is contained in:
+10
@@ -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());
|
||||
|
||||
+62
@@ -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());
|
||||
|
||||
+73
-1
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user