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:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
Reference in New Issue
Block a user