feat(audit): полноценный writer в audit_log + admin UI (audit + outbox DLQ)

До этого audit_log entity была определена, но никто туда не писал — реальный
аудит шёл только через Hibernate Envers (без user/IP/trace). Теперь:

- AuditLogger service пишет в той же транзакции что и CRUD (DictionaryRecordService).
  Захватывает: user (JWT sub либо preferred_username), scope, IP (X-Forwarded-For),
  UA, trace_id (MDC), request_id. Не ломает основной flow при сбое — только
  громко логирует.
- AuditLogRepository: гибкий search() с nullable фильтрами (dictionary, user,
  action, businessKey, from, to) + Pageable.
- AuditAdminController: GET /admin/audit (paginated) + /admin/audit/export.csv
  (cap 10k строк против OOM).

Frontend:
- /audit route: фильтр-панель, таблица с цветными бейджами (CREATE/UPDATE/CLOSE),
  expand-row с JSON before/after diff, footer IP/UA/req_id, кнопка экспорта CSV.
  Pagination 50/100/200.
- /outbox route: stat-cards pending/DLQ + DLQ-таблица с last_error.
- Nav links + i18n (RU + EN).
This commit is contained in:
Zimin A.N.
2026-05-04 15:44:14 +03:00
parent e182b30c58
commit 7b2fe6f2d5
13 changed files with 1191 additions and 5 deletions
+68
View File
@@ -1,9 +1,13 @@
import { useQuery, queryOptions } from '@tanstack/react-query'
import {
apiClient,
type AuditFilters,
type AuditPage,
type DictionaryDefinition,
type DictionaryDetail,
type DlqPage,
type FlattenedRecord,
type OutboxStats,
type RecordResponse,
} from './client'
@@ -67,6 +71,70 @@ export const recordRawQuery = (dictionaryName: string, businessKey: string) =>
},
})
const auditParams = (f: AuditFilters): Record<string, string | number> => {
const out: Record<string, string | number> = {
page: f.page ?? 0,
size: f.size ?? 50,
}
if (f.dictionaryName) out.dictionaryName = f.dictionaryName
if (f.userId) out.userId = f.userId
if (f.action) out.action = f.action
if (f.businessKey) out.businessKey = f.businessKey
if (f.from) out.from = f.from
if (f.to) out.to = f.to
return out
}
export const auditQuery = (f: AuditFilters) =>
queryOptions({
queryKey: ['audit', f] as const,
queryFn: async (): Promise<AuditPage> => {
const { data } = await apiClient.get<AuditPage>('/admin/audit', {
params: auditParams(f),
})
return data
},
})
export const useAudit = (f: AuditFilters) => useQuery(auditQuery(f))
export const buildAuditExportUrl = (f: AuditFilters): string => {
const base = (apiClient.defaults.baseURL ?? '/api/v1').replace(/\/$/, '')
const sp = new URLSearchParams()
if (f.dictionaryName) sp.set('dictionaryName', f.dictionaryName)
if (f.userId) sp.set('userId', f.userId)
if (f.action) sp.set('action', f.action)
if (f.businessKey) sp.set('businessKey', f.businessKey)
if (f.from) sp.set('from', f.from)
if (f.to) sp.set('to', f.to)
const qs = sp.toString()
return `${base}/admin/audit/export.csv${qs ? `?${qs}` : ''}`
}
export const outboxStatsQuery = queryOptions({
queryKey: ['outbox', 'stats'] as const,
queryFn: async (): Promise<OutboxStats> => {
const { data } = await apiClient.get<OutboxStats>('/admin/outbox/stats')
return data
},
refetchInterval: 30_000,
})
export const useOutboxStats = () => useQuery(outboxStatsQuery)
export const dlqQuery = (page: number, size: number) =>
queryOptions({
queryKey: ['outbox', 'dlq', page, size] as const,
queryFn: async (): Promise<DlqPage> => {
const { data } = await apiClient.get<DlqPage>('/admin/outbox/dlq', {
params: { page, size },
})
return data
},
})
export const useDlq = (page: number, size: number) => useQuery(dlqQuery(page, size))
export const useDictionaries = () => useQuery(dictionariesQuery)
export const useDictionaryDetail = (name: string) => useQuery(dictionaryDetailQuery(name))
export const useRecords = (dictionaryName: string, scopeCsv: string) =>