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
+64
View File
@@ -107,3 +107,67 @@ export type CreateRecordRequest = {
validFrom?: string
validTo?: string
}
export type AuditAction = 'CREATE' | 'UPDATE' | 'CLOSE'
export type AuditEntry = {
id: number
eventTime: string
eventType: string
action: AuditAction | string
userId?: string | null
userScope?: DataScope | null
dictionaryName?: string | null
dictionaryId?: string | null
recordId?: string | null
businessKey?: string | null
payloadBefore?: Record<string, unknown> | null
payloadAfter?: Record<string, unknown> | null
traceId?: string | null
requestId?: string | null
ipAddress?: string | null
userAgent?: string | null
}
export type AuditPage = {
content: AuditEntry[]
totalElements: number
totalPages: number
number: number
size: number
first: boolean
last: boolean
}
export type AuditFilters = {
dictionaryName?: string
userId?: string
action?: AuditAction | ''
businessKey?: string
from?: string
to?: string
page?: number
size?: number
}
export type OutboxStats = { pending: number; dlq: number }
export type DlqEntry = {
id: number
eventType: string
dictionaryName?: string | null
aggregateId: string
kafkaTopic: string
attemptCount: number
lastError?: string | null
createdAt: string
dlqAt: string
}
export type DlqPage = {
content: DlqEntry[]
totalElements: number
totalPages: number
number: number
size: number
}
+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) =>