Merge branch 'feat/dict-purge-7d-retention' into 'main'
feat(dict): 7-day retention auto-purge + inline restore UI See merge request 2-6/2-6-4/terravault/ordinis!297
This commit is contained in:
+7
-1
@@ -4,11 +4,17 @@
|
|||||||
|
|
||||||
Обновления приходят на твой сервер автоматически при следующем deploy.
|
Обновления приходят на твой сервер автоматически при следующем deploy.
|
||||||
|
|
||||||
|
## [2.54.0](https://git.nstart.cloud/2-6/2-6-4/terravault/ordinis/-/tags/v2.54.0) (2026-06-09)
|
||||||
|
|
||||||
|
### ✨ Новые возможности
|
||||||
|
|
||||||
|
* **Удаление справочника — теперь soft-delete с 7-дневным окном восстановления** — раньше «Удалить справочник» физически удалял записи каскадом (recovery только из DB-бэкапа). Теперь backend ставит метку `deleted_at = now()`, записи и черновики остаются в БД. В течение 7 дней администратор может восстановить справочник: открой URL удалённого справочника → красная карточка «Справочник удалён» с кнопкой **«Восстановить справочник»** + счётчик дней до автоудаления. После 7 дней backend cron `DictionaryPurgeJob` (раз в час) физически удаляет cascade — recovery невозможен. Список soft-deleted dicts доступен через `GET /api/v1/dictionaries/deleted` (admin-only Корзина page — UI в follow-up MR).
|
||||||
|
|
||||||
## [2.52.0](https://git.nstart.cloud/2-6/2-6-4/terravault/ordinis/-/tags/v2.52.0) (2026-06-05)
|
## [2.52.0](https://git.nstart.cloud/2-6/2-6-4/terravault/ordinis/-/tags/v2.52.0) (2026-06-05)
|
||||||
|
|
||||||
### ✨ Новые возможности
|
### ✨ Новые возможности
|
||||||
|
|
||||||
* **Удаление справочника одной кнопкой** — в правой info-панели редактора справочника появилась danger-кнопка «Удалить справочник…» (видна только пользователям с ролью admin). При клике — модальное окно с предупреждением о cascade: будут удалены ВСЕ записи, drafts, schema-drafts и история версий. Если на справочник ссылается другой через `x-references` — backend возвращает 409 со списком зависимостей, в модалке появляется дополнительная кнопка «Удалить force» (на случай если зависимый справочник нужно поправить отдельно). Cascade на уровне БД через FK `ON DELETE CASCADE` (миграция 0030). Audit log + outbox event «DefinitionDeleted» пишутся ДО удаления — consumers downstream (геопортал, Альтум) получают snapshot final state для инвалидации кэша.
|
* **Удаление справочника одной кнопкой** — в правой info-панели редактора справочника появилась danger-кнопка «Удалить справочник…» (видна только пользователям с ролью admin). При клике — модальное окно с предупреждением: будут soft-deleted ВСЕ записи, drafts, schema-drafts. Если на справочник ссылается другой через `x-references` — backend возвращает 409 со списком зависимостей, в модалке появляется дополнительная кнопка «Удалить force» (на случай если зависимый справочник нужно поправить отдельно). Audit log + outbox event «DefinitionDeleted» пишутся при пометке — consumers downstream (геопортал, Альтум) получают snapshot для инвалидации кэша.
|
||||||
|
|
||||||
## [2.51.2](https://git.nstart.cloud/2-6/2-6-4/terravault/ordinis/-/tags/v2.51.2) (2026-06-04)
|
## [2.51.2](https://git.nstart.cloud/2-6/2-6-4/terravault/ordinis/-/tags/v2.51.2) (2026-06-04)
|
||||||
|
|
||||||
|
|||||||
@@ -158,6 +158,40 @@ export const useDeleteDictionary = () => {
|
|||||||
},
|
},
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
qc.invalidateQueries({ queryKey: ['dictionaries'] })
|
qc.invalidateQueries({ queryKey: ['dictionaries'] })
|
||||||
|
qc.invalidateQueries({ queryKey: ['dictionaries', 'deleted'] })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Восстановление soft-deleted справочника. Admin-only. Сбрасывает
|
||||||
|
* deleted_at/deleted_by в NULL → dict снова visible в списке.
|
||||||
|
*
|
||||||
|
* <p>7-day retention window: admin может вызвать restore в течение 7 дней
|
||||||
|
* после soft-delete. После — physical purge через DictionaryPurgeJob
|
||||||
|
* (backend cron). Восстановление невозможно после purge — только из DB
|
||||||
|
* backup.
|
||||||
|
*
|
||||||
|
* <p>Backend errors:
|
||||||
|
* <ul>
|
||||||
|
* <li>403 forbidden_role — не admin</li>
|
||||||
|
* <li>404 dictionary_not_found — name never existed</li>
|
||||||
|
* <li>409 not_deleted — dict не помечен как удалённый</li>
|
||||||
|
* </ul>
|
||||||
|
*/
|
||||||
|
export const useRestoreDictionary = () => {
|
||||||
|
const qc = useQueryClient()
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (name: string): Promise<DictionaryDetail> => {
|
||||||
|
const { data } = await apiClient.post<DictionaryDetail>(
|
||||||
|
`/dictionaries/${name}/restore`,
|
||||||
|
)
|
||||||
|
return data
|
||||||
|
},
|
||||||
|
onSuccess: (_, name) => {
|
||||||
|
qc.invalidateQueries({ queryKey: ['dictionaries'] })
|
||||||
|
qc.invalidateQueries({ queryKey: ['dictionary', name] })
|
||||||
|
qc.invalidateQueries({ queryKey: ['dictionaries', 'deleted'] })
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -783,6 +783,31 @@ export const useDictionaries = () => useQuery(dictionariesQuery)
|
|||||||
export const useDictionaryDetail = (name: string | undefined) =>
|
export const useDictionaryDetail = (name: string | undefined) =>
|
||||||
useQuery({ ...dictionaryDetailQuery(name ?? ''), enabled: Boolean(name) })
|
useQuery({ ...dictionaryDetailQuery(name ?? ''), enabled: Boolean(name) })
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Soft-deleted справочники (Корзина admin page). Admin-only — non-admin
|
||||||
|
* получит 403, fallback на пустой массив. 7-day retention window: dict
|
||||||
|
* физически purgе'ится через DictionaryPurgeJob cron после deleted_at +
|
||||||
|
* 7 дней. Restore возможен ТОЛЬКО до purge.
|
||||||
|
*/
|
||||||
|
export const useDictionariesDeleted = (enabled = true) =>
|
||||||
|
useQuery({
|
||||||
|
queryKey: ['dictionaries', 'deleted'],
|
||||||
|
queryFn: async (): Promise<DictionaryDetail[]> => {
|
||||||
|
try {
|
||||||
|
const { data } = await apiClient.get<DictionaryDetail[]>('/dictionaries/deleted')
|
||||||
|
return data
|
||||||
|
} catch (err) {
|
||||||
|
if ((err as { response?: { status?: number } })?.response?.status === 403) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
},
|
||||||
|
enabled,
|
||||||
|
staleTime: 30_000,
|
||||||
|
retry: false,
|
||||||
|
})
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Список всех закэшированных юзеров — для пикеров «Пользователь» в
|
* Список всех закэшированных юзеров — для пикеров «Пользователь» в
|
||||||
* фильтрах audit/reviews. INTERNAL+ scope; non-INTERNAL получит 403,
|
* фильтрах audit/reviews. INTERNAL+ scope; non-INTERNAL получит 403,
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ import {
|
|||||||
useAudit,
|
useAudit,
|
||||||
useChangelog,
|
useChangelog,
|
||||||
useDictActiveSchemaDraft,
|
useDictActiveSchemaDraft,
|
||||||
|
useDictionariesDeleted,
|
||||||
useDictionaryDetail,
|
useDictionaryDetail,
|
||||||
useDictPendingDrafts,
|
useDictPendingDrafts,
|
||||||
useRecordRaw,
|
useRecordRaw,
|
||||||
@@ -38,7 +39,7 @@ import {
|
|||||||
useScheduledSummary,
|
useScheduledSummary,
|
||||||
type RecordsFilter,
|
type RecordsFilter,
|
||||||
} from '@/api/queries'
|
} from '@/api/queries'
|
||||||
import { useBulkCloseRecords, useBulkExportRecords, useCreateRecord, useUpdateRecord, useCloseRecord, useDeleteDictionary, useFormIdempotencyKey, useSubmitDraft } from '@/api/mutations'
|
import { useBulkCloseRecords, useBulkExportRecords, useCreateRecord, useUpdateRecord, useCloseRecord, useDeleteDictionary, useRestoreDictionary, useFormIdempotencyKey, useSubmitDraft } from '@/api/mutations'
|
||||||
import { useCanMutate } from '@/auth/useCanMutate'
|
import { useCanMutate } from '@/auth/useCanMutate'
|
||||||
import { useIsAdmin } from '@/auth/usePermissions'
|
import { useIsAdmin } from '@/auth/usePermissions'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
@@ -863,7 +864,14 @@ function DictionaryDetail() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (detailQuery.error) {
|
if (detailQuery.error) {
|
||||||
return <QueryErrorState error={detailQuery.error} onRetry={() => detailQuery.refetch()} />
|
return (
|
||||||
|
<DictionaryErrorOrSoftDeletedCard
|
||||||
|
name={name}
|
||||||
|
error={detailQuery.error}
|
||||||
|
isAdmin={isAdmin}
|
||||||
|
onRetry={() => detailQuery.refetch()}
|
||||||
|
/>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -2452,6 +2460,114 @@ function JsonTabContent({ detail }: { detail: { schemaJson: import('@/api/client
|
|||||||
* <p>Каждый escape перед взаимодействием: source already JSON.stringify'd
|
* <p>Каждый escape перед взаимодействием: source already JSON.stringify'd
|
||||||
* (safe), но regex применяется к HTML так что & < > > сначала escape'аются.
|
* (safe), но regex применяется к HTML так что & < > > сначала escape'аются.
|
||||||
*/
|
*/
|
||||||
|
/**
|
||||||
|
* Error state для route когда `useDictionaryDetail` returns 404 либо другой
|
||||||
|
* error. Special-case: если dict в /dictionaries/deleted (Корзина) — show
|
||||||
|
* inline restore card вместо generic error. 7-day retention window:
|
||||||
|
* admin может restore в этот период, дальше DictionaryPurgeJob physically
|
||||||
|
* removes (recovery только из DB backup).
|
||||||
|
*
|
||||||
|
* <p>Non-admin / dict не в Корзине → стандартный QueryErrorState.
|
||||||
|
*/
|
||||||
|
function DictionaryErrorOrSoftDeletedCard({
|
||||||
|
name,
|
||||||
|
error,
|
||||||
|
isAdmin,
|
||||||
|
onRetry,
|
||||||
|
}: {
|
||||||
|
name: string
|
||||||
|
error: unknown
|
||||||
|
isAdmin: boolean
|
||||||
|
onRetry: () => void
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const navigate = useNavigate({ from: Route.fullPath })
|
||||||
|
const restoreMut = useRestoreDictionary()
|
||||||
|
// Дёргаем Корзину только если admin (non-admin получает 403 от backend).
|
||||||
|
const deletedQ = useDictionariesDeleted(isAdmin)
|
||||||
|
const status = (error as { response?: { status?: number } })?.response?.status
|
||||||
|
const isNotFound = status === 404
|
||||||
|
const softDeleted = deletedQ.data?.find((d) => d.name === name)
|
||||||
|
|
||||||
|
if (!isNotFound || !softDeleted) {
|
||||||
|
return <QueryErrorState error={error} onRetry={onRetry} />
|
||||||
|
}
|
||||||
|
|
||||||
|
// Сколько осталось до auto-purge (7-day retention).
|
||||||
|
const deletedAt =
|
||||||
|
(softDeleted as unknown as { deletedAt?: string; updatedAt?: string }).deletedAt
|
||||||
|
?? softDeleted.updatedAt
|
||||||
|
const deletedDate = deletedAt ? new Date(deletedAt) : null
|
||||||
|
const purgeDate = deletedDate ? new Date(deletedDate.getTime() + 7 * 24 * 60 * 60 * 1000) : null
|
||||||
|
const daysLeft = purgeDate
|
||||||
|
? Math.ceil((purgeDate.getTime() - Date.now()) / (24 * 60 * 60 * 1000))
|
||||||
|
: null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-2xl mx-auto mt-12">
|
||||||
|
<div className="rounded-md border border-destructive/30 bg-destructive/10 px-5 py-4 space-y-4">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<TrashIcon size={24} weight="regular" className="text-destructive shrink-0 mt-0.5" />
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<h2 className="text-title-md text-ink mb-1">
|
||||||
|
{t('dict.deleted.title', { defaultValue: 'Справочник удалён' })}
|
||||||
|
</h2>
|
||||||
|
<p className="text-cell text-ink-2 mb-1">
|
||||||
|
<strong>{softDeleted.name}</strong>
|
||||||
|
{softDeleted.displayName ? ` — ${softDeleted.displayName}` : null}
|
||||||
|
</p>
|
||||||
|
<p className="text-cell text-ink-2">
|
||||||
|
{daysLeft !== null && daysLeft > 0
|
||||||
|
? t('dict.deleted.retentionLeft', {
|
||||||
|
defaultValue:
|
||||||
|
'Восстановить можно в течение {{days}} дн. — после этого записи будут безвозвратно удалены.',
|
||||||
|
days: daysLeft,
|
||||||
|
})
|
||||||
|
: t('dict.deleted.retentionExpiringSoon', {
|
||||||
|
defaultValue: 'Срок восстановления истекает — purge cron сработает в ближайшие часы.',
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{isAdmin ? (
|
||||||
|
<div className="flex justify-end gap-2 pt-2 border-t border-destructive/20">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="h-9 px-4 rounded-md border border-line bg-surface text-ink-2 text-cell hover:bg-surface-2"
|
||||||
|
onClick={() => navigate({ to: '/dictionaries' })}
|
||||||
|
>
|
||||||
|
{t('dict.deleted.backToList', { defaultValue: 'К списку справочников' })}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="h-9 px-4 rounded-md bg-accent text-on-accent text-cell font-medium hover:bg-accent/85 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
onClick={() => {
|
||||||
|
restoreMut.mutate(name, {
|
||||||
|
onSuccess: () => {
|
||||||
|
// После restore — refetch detail + navigate back to dict.
|
||||||
|
onRetry()
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
disabled={restoreMut.isPending}
|
||||||
|
>
|
||||||
|
{restoreMut.isPending
|
||||||
|
? t('dict.deleted.restorePending', { defaultValue: 'Восстанавливаю…' })
|
||||||
|
: t('dict.deleted.restore', { defaultValue: 'Восстановить справочник' })}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-cell text-mute pt-2 border-t border-destructive/20">
|
||||||
|
{t('dict.deleted.adminOnly', {
|
||||||
|
defaultValue: 'Восстановление справочника доступно только администратору.',
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function highlightJson(json: string): string {
|
function highlightJson(json: string): string {
|
||||||
const escape = (s: string) =>
|
const escape = (s: string) =>
|
||||||
s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||||
|
|||||||
+102
@@ -0,0 +1,102 @@
|
|||||||
|
package cloud.nstart.terravault.ordinis.restapi.service;
|
||||||
|
|
||||||
|
import cloud.nstart.terravault.ordinis.domain.definition.DictionaryDefinition;
|
||||||
|
import cloud.nstart.terravault.ordinis.domain.definition.DictionaryDefinitionRepository;
|
||||||
|
import io.micrometer.core.instrument.Counter;
|
||||||
|
import io.micrometer.core.instrument.MeterRegistry;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.scheduling.annotation.Scheduled;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.time.OffsetDateTime;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Permanent purge для soft-deleted справочников.
|
||||||
|
*
|
||||||
|
* <p>Retention 7 дней (NSI governance policy — юзер: «договаривались же
|
||||||
|
* 7 дней хранится и только потом удаляется и в течение 7 дней можно
|
||||||
|
* вернуть словарь»). После {@code deleted_at + 7d} dict physically
|
||||||
|
* removed через FK CASCADE (migration 0030): definition row + cascade на
|
||||||
|
* dictionary_records + record_drafts + dictionary_schema_drafts. Audit_log
|
||||||
|
* остаётся (нет FK) как permanent trail.
|
||||||
|
*
|
||||||
|
* <p>Cron каждый час. Если purge fails — log + counter, не блокируем
|
||||||
|
* остальные jobs. Idempotent: следующий run попробует снова.
|
||||||
|
*
|
||||||
|
* <p>Recovery window: 0–7 дней после soft-delete admin может вызвать
|
||||||
|
* {@code POST /api/v1/dictionaries/{name}/restore}. После purge — только
|
||||||
|
* через DB backup.
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
public class DictionaryPurgeJob {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(DictionaryPurgeJob.class);
|
||||||
|
|
||||||
|
/** 7 дней — NSI retention policy. Override через property для testing. */
|
||||||
|
private static final Duration RETENTION = Duration.ofDays(7);
|
||||||
|
|
||||||
|
private final DictionaryDefinitionRepository repository;
|
||||||
|
private final Counter purgedCounter;
|
||||||
|
private final Counter errorCounter;
|
||||||
|
|
||||||
|
public DictionaryPurgeJob(
|
||||||
|
DictionaryDefinitionRepository repository, MeterRegistry meterRegistry) {
|
||||||
|
this.repository = repository;
|
||||||
|
this.purgedCounter = Counter.builder("ordinis_dictionary_purged_total")
|
||||||
|
.description("Permanently purged soft-deleted dictionaries (после 7d retention)")
|
||||||
|
.register(meterRegistry);
|
||||||
|
this.errorCounter = Counter.builder("ordinis_dictionary_purge_errors_total")
|
||||||
|
.description("Purge attempts что failed (rollback safe — retry on next run)")
|
||||||
|
.register(meterRegistry);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cron каждый час в :23 (random minute — не competing с idempotency
|
||||||
|
* cleanup at :17, projection refresh, и др.).
|
||||||
|
*
|
||||||
|
* <p>Override через property {@code ordinis.dictionary.purge-cron} для
|
||||||
|
* test/staging если нужно faster purge cycle.
|
||||||
|
*/
|
||||||
|
@Scheduled(cron = "${ordinis.dictionary.purge-cron:0 23 * * * *}")
|
||||||
|
@Transactional
|
||||||
|
public void purge() {
|
||||||
|
OffsetDateTime cutoff = OffsetDateTime.now().minus(RETENTION);
|
||||||
|
List<DictionaryDefinition> candidates = repository.findAllDeleted().stream()
|
||||||
|
.filter(d -> d.getDeletedAt() != null && d.getDeletedAt().isBefore(cutoff))
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
if (candidates.isEmpty()) {
|
||||||
|
log.debug("Dictionary purge: 0 candidates (retention {} days, cutoff {})",
|
||||||
|
RETENTION.toDays(), cutoff);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info("Dictionary purge: найдено {} кандидатов на physical delete "
|
||||||
|
+ "(deleted_at < {})", candidates.size(), cutoff);
|
||||||
|
|
||||||
|
int purged = 0;
|
||||||
|
for (DictionaryDefinition d : candidates) {
|
||||||
|
try {
|
||||||
|
// FK ON DELETE CASCADE (migration 0030) cleanup'ит:
|
||||||
|
// dictionary_records + record_drafts + dictionary_schema_drafts.
|
||||||
|
// audit_log + outbox_events не имеют FK — остаются permanent trail.
|
||||||
|
repository.deleteById(d.getId());
|
||||||
|
log.info("Dictionary purged: name={} id={} deleted_at={} (>{} days ago)",
|
||||||
|
d.getName(), d.getId(), d.getDeletedAt(), RETENTION.toDays());
|
||||||
|
purged++;
|
||||||
|
} catch (Exception e) {
|
||||||
|
// Не throw — продолжаем остальные. Retry на следующем cron run.
|
||||||
|
log.error("Dictionary purge failed для name={}: {}", d.getName(), e.getMessage());
|
||||||
|
errorCounter.increment();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (purged > 0) {
|
||||||
|
purgedCounter.increment(purged);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user