feat(dict): soft-delete с recovery (Phase 1)
This commit is contained in:
+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