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) =>
+90
View File
@@ -14,6 +14,51 @@ i18n
translation: {
'app.title': 'Ordinis НСИ ЦУОД',
'nav.dictionaries': 'Справочники',
'nav.audit': 'Аудит',
'nav.outbox': 'Outbox',
'audit.title': 'Журнал аудита',
'audit.subtitle': 'Юридически значимый лог изменений НСИ',
'audit.filter.dictionary': 'Справочник',
'audit.filter.user': 'Пользователь',
'audit.filter.action': 'Действие',
'audit.filter.businessKey': 'Бизнес-ключ',
'audit.filter.from': 'С',
'audit.filter.to': 'По',
'audit.filter.reset': 'Сбросить',
'audit.filter.apply': 'Применить',
'audit.col.time': 'Время',
'audit.col.action': 'Действие',
'audit.col.dictionary': 'Справочник',
'audit.col.businessKey': 'Бизнес-ключ',
'audit.col.user': 'Пользователь',
'audit.col.scope': 'Scope',
'audit.col.trace': 'Trace',
'audit.col.diff': 'Изменения',
'audit.action.expand': 'Раскрыть',
'audit.action.export': 'Экспорт CSV',
'audit.empty': 'Нет записей по выбранным фильтрам',
'audit.action.CREATE': 'Создание',
'audit.action.UPDATE': 'Изменение',
'audit.action.CLOSE': 'Закрытие',
'audit.diff.before': 'Было',
'audit.diff.after': 'Стало',
'audit.page.of': 'Стр. {{cur}} из {{total}}',
'audit.page.size': 'На странице',
'audit.page.prev': 'Назад',
'audit.page.next': 'Вперёд',
'outbox.title': 'Outbox events',
'outbox.subtitle': 'Очередь публикации в Kafka и DLQ',
'outbox.stats.pending': 'В очереди',
'outbox.stats.dlq': 'В DLQ',
'outbox.dlq.col.id': 'ID',
'outbox.dlq.col.eventType': 'Тип',
'outbox.dlq.col.dictionary': 'Справочник',
'outbox.dlq.col.aggregateId': 'Aggregate ID',
'outbox.dlq.col.topic': 'Topic',
'outbox.dlq.col.attempts': 'Попыток',
'outbox.dlq.col.lastError': 'Последняя ошибка',
'outbox.dlq.col.dlqAt': 'В DLQ с',
'outbox.dlq.empty': 'DLQ пуста — все события публикуются успешно',
'header.scope': 'Доступный scope',
'dict.list.subtitle': 'Каталоги НСИ ЦУОД ОДХ',
'dict.empty': 'В этом справочнике пока нет записей',
@@ -133,6 +178,51 @@ i18n
translation: {
'app.title': 'Ordinis MDM',
'nav.dictionaries': 'Dictionaries',
'nav.audit': 'Audit',
'nav.outbox': 'Outbox',
'audit.title': 'Audit log',
'audit.subtitle': 'Legally significant log of NSI changes',
'audit.filter.dictionary': 'Dictionary',
'audit.filter.user': 'User',
'audit.filter.action': 'Action',
'audit.filter.businessKey': 'Business key',
'audit.filter.from': 'From',
'audit.filter.to': 'To',
'audit.filter.reset': 'Reset',
'audit.filter.apply': 'Apply',
'audit.col.time': 'Time',
'audit.col.action': 'Action',
'audit.col.dictionary': 'Dictionary',
'audit.col.businessKey': 'Business key',
'audit.col.user': 'User',
'audit.col.scope': 'Scope',
'audit.col.trace': 'Trace',
'audit.col.diff': 'Diff',
'audit.action.expand': 'Expand',
'audit.action.export': 'Export CSV',
'audit.empty': 'No entries match the filters',
'audit.action.CREATE': 'Create',
'audit.action.UPDATE': 'Update',
'audit.action.CLOSE': 'Close',
'audit.diff.before': 'Before',
'audit.diff.after': 'After',
'audit.page.of': 'Page {{cur}} of {{total}}',
'audit.page.size': 'Per page',
'audit.page.prev': 'Prev',
'audit.page.next': 'Next',
'outbox.title': 'Outbox events',
'outbox.subtitle': 'Publication queue and DLQ',
'outbox.stats.pending': 'Pending',
'outbox.stats.dlq': 'In DLQ',
'outbox.dlq.col.id': 'ID',
'outbox.dlq.col.eventType': 'Type',
'outbox.dlq.col.dictionary': 'Dictionary',
'outbox.dlq.col.aggregateId': 'Aggregate ID',
'outbox.dlq.col.topic': 'Topic',
'outbox.dlq.col.attempts': 'Attempts',
'outbox.dlq.col.lastError': 'Last error',
'outbox.dlq.col.dlqAt': 'DLQ since',
'outbox.dlq.empty': 'DLQ empty — all events publish successfully',
'header.scope': 'Allowed scope',
'dict.list.subtitle': 'Reference data catalogues',
'dict.empty': 'No records in this dictionary yet',
+46 -2
View File
@@ -9,16 +9,28 @@
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
import { Route as rootRouteImport } from './routes/__root'
import { Route as OutboxRouteImport } from './routes/outbox'
import { Route as DictionariesRouteImport } from './routes/dictionaries'
import { Route as AuditRouteImport } from './routes/audit'
import { Route as IndexRouteImport } from './routes/index'
import { Route as DictionariesIndexRouteImport } from './routes/dictionaries.index'
import { Route as DictionariesNameRouteImport } from './routes/dictionaries.$name'
const OutboxRoute = OutboxRouteImport.update({
id: '/outbox',
path: '/outbox',
getParentRoute: () => rootRouteImport,
} as any)
const DictionariesRoute = DictionariesRouteImport.update({
id: '/dictionaries',
path: '/dictionaries',
getParentRoute: () => rootRouteImport,
} as any)
const AuditRoute = AuditRouteImport.update({
id: '/audit',
path: '/audit',
getParentRoute: () => rootRouteImport,
} as any)
const IndexRoute = IndexRouteImport.update({
id: '/',
path: '/',
@@ -37,42 +49,65 @@ const DictionariesNameRoute = DictionariesNameRouteImport.update({
export interface FileRoutesByFullPath {
'/': typeof IndexRoute
'/audit': typeof AuditRoute
'/dictionaries': typeof DictionariesRouteWithChildren
'/outbox': typeof OutboxRoute
'/dictionaries/$name': typeof DictionariesNameRoute
'/dictionaries/': typeof DictionariesIndexRoute
}
export interface FileRoutesByTo {
'/': typeof IndexRoute
'/audit': typeof AuditRoute
'/outbox': typeof OutboxRoute
'/dictionaries/$name': typeof DictionariesNameRoute
'/dictionaries': typeof DictionariesIndexRoute
}
export interface FileRoutesById {
__root__: typeof rootRouteImport
'/': typeof IndexRoute
'/audit': typeof AuditRoute
'/dictionaries': typeof DictionariesRouteWithChildren
'/outbox': typeof OutboxRoute
'/dictionaries/$name': typeof DictionariesNameRoute
'/dictionaries/': typeof DictionariesIndexRoute
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
fullPaths: '/' | '/dictionaries' | '/dictionaries/$name' | '/dictionaries/'
fullPaths:
| '/'
| '/audit'
| '/dictionaries'
| '/outbox'
| '/dictionaries/$name'
| '/dictionaries/'
fileRoutesByTo: FileRoutesByTo
to: '/' | '/dictionaries/$name' | '/dictionaries'
to: '/' | '/audit' | '/outbox' | '/dictionaries/$name' | '/dictionaries'
id:
| '__root__'
| '/'
| '/audit'
| '/dictionaries'
| '/outbox'
| '/dictionaries/$name'
| '/dictionaries/'
fileRoutesById: FileRoutesById
}
export interface RootRouteChildren {
IndexRoute: typeof IndexRoute
AuditRoute: typeof AuditRoute
DictionariesRoute: typeof DictionariesRouteWithChildren
OutboxRoute: typeof OutboxRoute
}
declare module '@tanstack/react-router' {
interface FileRoutesByPath {
'/outbox': {
id: '/outbox'
path: '/outbox'
fullPath: '/outbox'
preLoaderRoute: typeof OutboxRouteImport
parentRoute: typeof rootRouteImport
}
'/dictionaries': {
id: '/dictionaries'
path: '/dictionaries'
@@ -80,6 +115,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof DictionariesRouteImport
parentRoute: typeof rootRouteImport
}
'/audit': {
id: '/audit'
path: '/audit'
fullPath: '/audit'
preLoaderRoute: typeof AuditRouteImport
parentRoute: typeof rootRouteImport
}
'/': {
id: '/'
path: '/'
@@ -120,7 +162,9 @@ const DictionariesRouteWithChildren = DictionariesRoute._addFileChildren(
const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute,
AuditRoute: AuditRoute,
DictionariesRoute: DictionariesRouteWithChildren,
OutboxRoute: OutboxRoute,
}
export const routeTree = rootRouteImport
._addFileChildren(rootRouteChildren)
+14
View File
@@ -29,6 +29,20 @@ function RootLayout() {
>
{t('nav.dictionaries')}
</Link>
<Link
to="/audit"
className="text-carbon hover:text-ultramarain"
activeProps={{ className: 'text-ultramarain font-medium' }}
>
{t('nav.audit')}
</Link>
<Link
to="/outbox"
className="text-carbon hover:text-ultramarain"
activeProps={{ className: 'text-ultramarain font-medium' }}
>
{t('nav.outbox')}
</Link>
</nav>
<div className="ml-auto">
<LanguageSwitch
+414
View File
@@ -0,0 +1,414 @@
import { useMemo, useState } from 'react'
import { createFileRoute } from '@tanstack/react-router'
import { useTranslation } from 'react-i18next'
import {
Alert,
Badge,
Button,
DatePicker,
EmptyState,
IconButton,
LoadingBlock,
PageHeader,
SingleSelect,
Table,
TableBody,
TableCell,
TableEmpty,
TableHead,
TableHeaderCell,
TableRow,
TextInput,
} from '@nstart/ui'
import {
ArrowsClockwiseIcon,
CaretDownIcon,
CaretRightIcon,
DownloadIcon,
} from '@phosphor-icons/react'
import { buildAuditExportUrl, useAudit, useDictionaries } from '@/api/queries'
import type { AuditAction, AuditEntry, AuditFilters } from '@/api/client'
import { localTzOffset, parseFormDate } from '@/lib/dates'
export const Route = createFileRoute('/audit')({
component: AuditPage,
})
const ACTIONS: AuditAction[] = ['CREATE', 'UPDATE', 'CLOSE']
const PAGE_SIZE_OPTIONS = [
{ id: '50', label: '50' },
{ id: '100', label: '100' },
{ id: '200', label: '200' },
]
function AuditPage() {
const { t } = useTranslation()
const dictionariesQuery = useDictionaries()
const [filters, setFilters] = useState<AuditFilters>({ page: 0, size: 50 })
const [draft, setDraft] = useState<AuditFilters>(filters)
const { data, isLoading, error, refetch, isFetching } = useAudit(filters)
const dictionaryOptions = useMemo(() => {
const opts: { id: string; label: string }[] = [{ id: '', label: '—' }]
for (const d of dictionariesQuery.data ?? []) {
opts.push({ id: d.name, label: d.displayName ?? d.name })
}
return opts
}, [dictionariesQuery.data])
const actionOptions = useMemo(
() => [
{ id: '', label: '—' },
...ACTIONS.map((a) => ({ id: a, label: t(`audit.action.${a}`) })),
],
[t],
)
const apply = () => setFilters({ ...draft, page: 0 })
const reset = () => {
const empty: AuditFilters = { page: 0, size: filters.size ?? 50 }
setDraft(empty)
setFilters(empty)
}
const totalPages = data?.totalPages ?? 0
const currentPage = data?.number ?? 0
return (
<div className="space-y-6">
<PageHeader
title={t('audit.title')}
description={t('audit.subtitle')}
actions={
<div className="flex gap-2">
<Button
type="button"
variant="secondary"
leftIcon={<ArrowsClockwiseIcon size={16} />}
onClick={() => refetch()}
disabled={isFetching}
>
{t('audit.filter.apply')}
</Button>
<a
href={buildAuditExportUrl(filters)}
target="_blank"
rel="noreferrer"
className="inline-flex"
>
<Button
type="button"
variant="primary"
leftIcon={<DownloadIcon size={16} />}
>
{t('audit.action.export')}
</Button>
</a>
</div>
}
/>
<FilterPanel
filters={draft}
onChange={setDraft}
dictionaryOptions={dictionaryOptions}
actionOptions={actionOptions}
onApply={apply}
onReset={reset}
/>
{error ? (
<Alert variant="error" title={t('error.failed')}>
{String(error)}
</Alert>
) : isLoading ? (
<LoadingBlock size="md" label={t('loading')} />
) : !data || data.content.length === 0 ? (
<EmptyState title={t('audit.empty')} />
) : (
<>
<AuditTable rows={data.content} />
<Pagination
page={currentPage}
totalPages={totalPages}
size={filters.size ?? 50}
onPrev={() =>
setFilters((f) => ({ ...f, page: Math.max(0, (f.page ?? 0) - 1) }))
}
onNext={() =>
setFilters((f) => ({ ...f, page: (f.page ?? 0) + 1 }))
}
onSizeChange={(size) =>
setFilters((f) => ({ ...f, page: 0, size }))
}
/>
</>
)}
</div>
)
}
type FilterPanelProps = {
filters: AuditFilters
onChange: (f: AuditFilters) => void
dictionaryOptions: { id: string; label: string }[]
actionOptions: { id: string; label: string }[]
onApply: () => void
onReset: () => void
}
function FilterPanel({
filters,
onChange,
dictionaryOptions,
actionOptions,
onApply,
onReset,
}: FilterPanelProps) {
const { t } = useTranslation()
const set = <K extends keyof AuditFilters>(k: K, v: AuditFilters[K]) =>
onChange({ ...filters, [k]: v })
const fromDate = parseFormDate(filters.from)
const toDate = parseFormDate(filters.to)
return (
<div className="bg-white border border-regolith rounded-lg p-4 space-y-3">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
<SingleSelect
label={t('audit.filter.dictionary')}
options={dictionaryOptions}
value={filters.dictionaryName ?? ''}
onChange={(id) => set('dictionaryName', id || undefined)}
/>
<SingleSelect
label={t('audit.filter.action')}
options={actionOptions}
value={filters.action ?? ''}
onChange={(id) => set('action', (id as AuditAction) || '')}
/>
<TextInput
label={t('audit.filter.user')}
value={filters.userId ?? ''}
onChange={(e) => set('userId', e.target.value || undefined)}
/>
<TextInput
label={t('audit.filter.businessKey')}
value={filters.businessKey ?? ''}
onChange={(e) => set('businessKey', e.target.value || undefined)}
/>
<DatePicker
label={t('audit.filter.from')}
value={fromDate}
onChange={(d) =>
set(
'from',
d
? `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(
d.getDate(),
).padStart(2, '0')}T00:00:00${localTzOffset(d)}`
: undefined,
)
}
/>
<DatePicker
label={t('audit.filter.to')}
value={toDate}
onChange={(d) =>
set(
'to',
d
? `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(
d.getDate(),
).padStart(2, '0')}T23:59:59${localTzOffset(d)}`
: undefined,
)
}
/>
</div>
<div className="flex justify-end gap-2 pt-2 border-t border-regolith">
<Button type="button" variant="secondary" onClick={onReset}>
{t('audit.filter.reset')}
</Button>
<Button type="button" variant="primary" onClick={onApply}>
{t('audit.filter.apply')}
</Button>
</div>
</div>
)
}
function AuditTable({ rows }: { rows: AuditEntry[] }) {
const { t } = useTranslation()
return (
<Table>
<TableHead>
<TableRow>
<TableHeaderCell>{t('audit.col.time')}</TableHeaderCell>
<TableHeaderCell>{t('audit.col.action')}</TableHeaderCell>
<TableHeaderCell>{t('audit.col.dictionary')}</TableHeaderCell>
<TableHeaderCell>{t('audit.col.businessKey')}</TableHeaderCell>
<TableHeaderCell>{t('audit.col.user')}</TableHeaderCell>
<TableHeaderCell>{t('audit.col.scope')}</TableHeaderCell>
<TableHeaderCell>{t('audit.col.trace')}</TableHeaderCell>
<TableHeaderCell>{t('audit.col.diff')}</TableHeaderCell>
</TableRow>
</TableHead>
<TableBody>
{rows.length === 0 ? (
<TableEmpty colSpan={8}>{t('audit.empty')}</TableEmpty>
) : (
rows.map((r) => <AuditRow key={r.id} row={r} />)
)}
</TableBody>
</Table>
)
}
function AuditRow({ row }: { row: AuditEntry }) {
const { t } = useTranslation()
const [open, setOpen] = useState(false)
const time = new Date(row.eventTime)
const timeLabel = time.toLocaleString(undefined, {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
})
const actionVariant =
row.action === 'CREATE' ? 'success' : row.action === 'CLOSE' ? 'error' : 'info'
return (
<>
<TableRow>
<TableCell>
<span className="text-2xs tabular-nums">{timeLabel}</span>
</TableCell>
<TableCell>
<Badge variant={actionVariant}>
{t(`audit.action.${row.action}`, row.action)}
</Badge>
</TableCell>
<TableCell>{row.dictionaryName ?? '—'}</TableCell>
<TableCell>
<span className="font-mono text-xs">{row.businessKey ?? '—'}</span>
</TableCell>
<TableCell>{row.userId ?? 'anonymous'}</TableCell>
<TableCell>
{row.userScope ? <Badge variant="info">{row.userScope}</Badge> : null}
</TableCell>
<TableCell>
<span
className="font-mono text-2xs text-carbon/70"
title={row.traceId ?? ''}
>
{row.traceId ? row.traceId.slice(0, 8) : '—'}
</span>
</TableCell>
<TableCell>
<IconButton
type="button"
icon={open ? <CaretDownIcon /> : <CaretRightIcon />}
label={t('audit.action.expand')}
onClick={() => setOpen((v) => !v)}
/>
</TableCell>
</TableRow>
{open && (
<TableRow>
<TableCell colSpan={8}>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 bg-regolith/30 rounded p-3">
<div>
<div className="text-2xs uppercase tracking-label text-carbon/70 mb-1">
{t('audit.diff.before')}
</div>
<pre className="text-2xs font-mono whitespace-pre-wrap break-all bg-white border border-regolith rounded p-2 max-h-60 overflow-auto">
{row.payloadBefore
? JSON.stringify(row.payloadBefore, null, 2)
: '—'}
</pre>
</div>
<div>
<div className="text-2xs uppercase tracking-label text-carbon/70 mb-1">
{t('audit.diff.after')}
</div>
<pre className="text-2xs font-mono whitespace-pre-wrap break-all bg-white border border-regolith rounded p-2 max-h-60 overflow-auto">
{row.payloadAfter
? JSON.stringify(row.payloadAfter, null, 2)
: '—'}
</pre>
</div>
{(row.ipAddress || row.userAgent || row.requestId) && (
<div className="md:col-span-2 text-2xs text-carbon/70 flex flex-wrap gap-x-4 gap-y-1 pt-1">
{row.ipAddress && <span>IP: {row.ipAddress}</span>}
{row.requestId && <span>req: {row.requestId}</span>}
{row.userAgent && (
<span className="truncate max-w-md">UA: {row.userAgent}</span>
)}
</div>
)}
</div>
</TableCell>
</TableRow>
)}
</>
)
}
type PaginationProps = {
page: number
totalPages: number
size: number
onPrev: () => void
onNext: () => void
onSizeChange: (size: number) => void
}
function Pagination({
page,
totalPages,
size,
onPrev,
onNext,
onSizeChange,
}: PaginationProps) {
const { t } = useTranslation()
return (
<div className="flex items-center justify-between flex-wrap gap-3">
<div className="flex items-center gap-2 text-sm">
<span className="text-carbon/70">{t('audit.page.size')}</span>
<SingleSelect
options={PAGE_SIZE_OPTIONS}
value={String(size)}
onChange={(id) => onSizeChange(Number(id))}
/>
</div>
<div className="flex items-center gap-3 text-sm">
<Button
type="button"
variant="secondary"
onClick={onPrev}
disabled={page <= 0}
>
{t('audit.page.prev')}
</Button>
<span className="tabular-nums">
{t('audit.page.of', { cur: page + 1, total: Math.max(totalPages, 1) })}
</span>
<Button
type="button"
variant="secondary"
onClick={onNext}
disabled={page + 1 >= totalPages}
>
{t('audit.page.next')}
</Button>
</div>
</div>
)
}
+174
View File
@@ -0,0 +1,174 @@
import { useState } from 'react'
import { createFileRoute } from '@tanstack/react-router'
import { useTranslation } from 'react-i18next'
import {
Alert,
Badge,
Button,
EmptyState,
LoadingBlock,
PageHeader,
Table,
TableBody,
TableCell,
TableHead,
TableHeaderCell,
TableRow,
} from '@nstart/ui'
import { ArrowsClockwiseIcon } from '@phosphor-icons/react'
import { useDlq, useOutboxStats } from '@/api/queries'
export const Route = createFileRoute('/outbox')({
component: OutboxPage,
})
function OutboxPage() {
const { t } = useTranslation()
const [page, setPage] = useState(0)
const size = 50
const stats = useOutboxStats()
const dlq = useDlq(page, size)
return (
<div className="space-y-6">
<PageHeader
title={t('outbox.title')}
description={t('outbox.subtitle')}
actions={
<Button
type="button"
variant="secondary"
leftIcon={<ArrowsClockwiseIcon size={16} />}
onClick={() => {
stats.refetch()
dlq.refetch()
}}
>
{t('audit.filter.apply')}
</Button>
}
/>
<div className="grid grid-cols-2 gap-4">
<StatCard
label={t('outbox.stats.pending')}
value={stats.data?.pending ?? '—'}
tone="info"
/>
<StatCard
label={t('outbox.stats.dlq')}
value={stats.data?.dlq ?? '—'}
tone={(stats.data?.dlq ?? 0) > 0 ? 'error' : 'success'}
/>
</div>
{dlq.error ? (
<Alert variant="error" title={t('error.failed')}>
{String(dlq.error)}
</Alert>
) : dlq.isLoading ? (
<LoadingBlock size="md" label={t('loading')} />
) : !dlq.data || dlq.data.content.length === 0 ? (
<EmptyState title={t('outbox.dlq.empty')} />
) : (
<>
<Table>
<TableHead>
<TableRow>
<TableHeaderCell>{t('outbox.dlq.col.id')}</TableHeaderCell>
<TableHeaderCell>{t('outbox.dlq.col.eventType')}</TableHeaderCell>
<TableHeaderCell>{t('outbox.dlq.col.dictionary')}</TableHeaderCell>
<TableHeaderCell>{t('outbox.dlq.col.aggregateId')}</TableHeaderCell>
<TableHeaderCell>{t('outbox.dlq.col.topic')}</TableHeaderCell>
<TableHeaderCell>{t('outbox.dlq.col.attempts')}</TableHeaderCell>
<TableHeaderCell>{t('outbox.dlq.col.lastError')}</TableHeaderCell>
<TableHeaderCell>{t('outbox.dlq.col.dlqAt')}</TableHeaderCell>
</TableRow>
</TableHead>
<TableBody>
{dlq.data.content.map((row) => (
<TableRow key={row.id}>
<TableCell>
<span className="font-mono text-2xs">{row.id}</span>
</TableCell>
<TableCell>
<Badge variant="warning">{row.eventType}</Badge>
</TableCell>
<TableCell>{row.dictionaryName ?? '—'}</TableCell>
<TableCell>
<span className="font-mono text-2xs">{row.aggregateId}</span>
</TableCell>
<TableCell>
<span className="font-mono text-2xs">{row.kafkaTopic}</span>
</TableCell>
<TableCell>{row.attemptCount}</TableCell>
<TableCell>
<span
className="text-2xs text-carbon/70 truncate max-w-md inline-block align-middle"
title={row.lastError ?? ''}
>
{row.lastError ?? '—'}
</span>
</TableCell>
<TableCell>
<span className="text-2xs tabular-nums">
{new Date(row.dlqAt).toLocaleString()}
</span>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
<div className="flex items-center justify-end gap-3 text-sm">
<Button
type="button"
variant="secondary"
onClick={() => setPage((p) => Math.max(0, p - 1))}
disabled={page <= 0}
>
{t('audit.page.prev')}
</Button>
<span className="tabular-nums">
{t('audit.page.of', {
cur: (dlq.data.number ?? 0) + 1,
total: Math.max(dlq.data.totalPages ?? 1, 1),
})}
</span>
<Button
type="button"
variant="secondary"
onClick={() => setPage((p) => p + 1)}
disabled={(dlq.data.number ?? 0) + 1 >= (dlq.data.totalPages ?? 1)}
>
{t('audit.page.next')}
</Button>
</div>
</>
)}
</div>
)
}
type StatCardProps = {
label: string
value: number | string
tone: 'info' | 'success' | 'error'
}
function StatCard({ label, value, tone }: StatCardProps) {
const colour =
tone === 'error'
? 'text-mars'
: tone === 'success'
? 'text-grass'
: 'text-ultramarain'
return (
<div className="bg-white border border-regolith rounded-lg p-4">
<div className="text-2xs uppercase tracking-label text-carbon/70 mb-1">
{label}
</div>
<div className={`text-2xl font-primary tabular-nums ${colour}`}>{value}</div>
</div>
)
}
@@ -77,7 +77,7 @@ public class AuditLog {
@Column(name = "user_agent", columnDefinition = "TEXT")
private String userAgent;
protected AuditLog() {}
public AuditLog() {}
public Long getId() { return id; }
public OffsetDateTime getEventTime() { return eventTime; }
@@ -1,6 +1,10 @@
package cloud.nstart.terravault.ordinis.domain.audit;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import java.time.OffsetDateTime;
import java.util.List;
@@ -15,4 +19,26 @@ public interface AuditLogRepository extends JpaRepository<AuditLog, Long> {
List<AuditLog> findByUserIdOrderByEventTimeDesc(String userId);
List<AuditLog> findByEventTimeBetweenOrderByEventTimeDesc(OffsetDateTime from, OffsetDateTime to);
/**
* Гибкий запрос для admin UI: фильтры nullable, NULL = «не фильтруем».
* Сортировка задаётся {@link Pageable}.
*/
@Query("""
SELECT a FROM AuditLog a
WHERE (:dictionaryName IS NULL OR a.dictionaryName = :dictionaryName)
AND (:userId IS NULL OR a.userId = :userId)
AND (:action IS NULL OR a.action = :action)
AND (:businessKey IS NULL OR a.businessKey = :businessKey)
AND (:from IS NULL OR a.eventTime >= :from)
AND (:to IS NULL OR a.eventTime <= :to)
""")
Page<AuditLog> search(
@Param("dictionaryName") String dictionaryName,
@Param("userId") String userId,
@Param("action") String action,
@Param("businessKey") String businessKey,
@Param("from") OffsetDateTime from,
@Param("to") OffsetDateTime to,
Pageable pageable);
}
@@ -0,0 +1,135 @@
package cloud.nstart.terravault.ordinis.restapi.audit;
import cloud.nstart.terravault.ordinis.auth.ScopeContext;
import cloud.nstart.terravault.ordinis.domain.DataScope;
import cloud.nstart.terravault.ordinis.domain.audit.AuditLog;
import cloud.nstart.terravault.ordinis.domain.audit.AuditLogRepository;
import cloud.nstart.terravault.ordinis.domain.definition.DictionaryDefinition;
import cloud.nstart.terravault.ordinis.domain.record.DictionaryRecord;
import com.fasterxml.jackson.databind.JsonNode;
import jakarta.servlet.http.HttpServletRequest;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.slf4j.MDC;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken;
import org.springframework.stereotype.Service;
import org.springframework.web.context.request.RequestAttributes;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
import java.time.OffsetDateTime;
import java.util.Set;
/**
* Запись в {@code audit_log} — юридически значимый лог. Источник истины для
* compliance, отдельно от технических логов в Loki.
*
* <p>Захватывает: user (sub claim из JWT либо "anonymous"), scope, IP, UA,
* trace_id из MDC (Micrometer Tracing). Вызывается из {@link
* cloud.nstart.terravault.ordinis.restapi.service.DictionaryRecordService} в
* той же транзакции что и change — at-least-once семантика.
*/
@Service
public class AuditLogger {
private static final Logger log = LoggerFactory.getLogger(AuditLogger.class);
private final AuditLogRepository repository;
private final ScopeContext scopeContext;
public AuditLogger(AuditLogRepository repository, ScopeContext scopeContext) {
this.repository = repository;
this.scopeContext = scopeContext;
}
public void recordCreate(DictionaryDefinition d, DictionaryRecord r) {
write("RECORD_CREATED", "CREATE", d, r, null, r.getData());
}
public void recordUpdate(
DictionaryDefinition d, DictionaryRecord r, JsonNode payloadBefore) {
write("RECORD_UPDATED", "UPDATE", d, r, payloadBefore, r.getData());
}
public void recordClose(DictionaryDefinition d, DictionaryRecord r, String reason) {
write("RECORD_CLOSED", "CLOSE", d, r, r.getData(), null);
}
private void write(
String eventType,
String action,
DictionaryDefinition d,
DictionaryRecord r,
JsonNode before,
JsonNode after) {
try {
AuditLog entry = new AuditLog();
entry.setEventTime(OffsetDateTime.now());
entry.setEventType(eventType);
entry.setAction(action);
entry.setDictionaryId(d.getId());
entry.setDictionaryName(d.getName());
entry.setRecordId(r.getId());
entry.setBusinessKey(r.getBusinessKey());
entry.setPayloadBefore(before);
entry.setPayloadAfter(after);
User user = currentUser();
entry.setUserId(user.id);
entry.setUserScope(user.scope);
RequestContext rc = currentRequest();
entry.setIpAddress(rc.ip);
entry.setUserAgent(rc.userAgent);
entry.setRequestId(rc.requestId);
entry.setTraceId(MDC.get("traceId"));
repository.save(entry);
} catch (Exception ex) {
// Аудит не должен ломать основной flow — но логируем громко чтобы
// алертилось. Compliance-кейс ловит на этой ошибке.
log.error(
"Failed to write audit_log entry: dictionary={} record_id={} action={}: {}",
d.getName(), r.getId(), action, ex.getMessage(), ex);
}
}
private User currentUser() {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
if (auth instanceof JwtAuthenticationToken jwt) {
String sub = jwt.getToken().getSubject();
if (sub == null || sub.isBlank()) {
Object preferred = jwt.getToken().getClaim("preferred_username");
sub = preferred == null ? jwt.getName() : String.valueOf(preferred);
}
Set<DataScope> scopes = scopeContext.currentScopes();
DataScope effective = scopes.contains(DataScope.RESTRICTED) ? DataScope.RESTRICTED
: scopes.contains(DataScope.INTERNAL) ? DataScope.INTERNAL
: DataScope.PUBLIC;
return new User(sub, effective);
}
return new User("anonymous", DataScope.PUBLIC);
}
private RequestContext currentRequest() {
RequestAttributes attrs = RequestContextHolder.getRequestAttributes();
if (attrs instanceof ServletRequestAttributes sra) {
HttpServletRequest req = sra.getRequest();
String ip = headerOrRemote(req, "X-Forwarded-For");
if (ip != null && ip.contains(",")) ip = ip.split(",")[0].trim();
String requestId = req.getHeader("X-Request-Id");
return new RequestContext(ip, req.getHeader("User-Agent"), requestId);
}
return new RequestContext(null, null, null);
}
private static String headerOrRemote(HttpServletRequest req, String header) {
String v = req.getHeader(header);
return (v == null || v.isBlank()) ? req.getRemoteAddr() : v;
}
private record User(String id, DataScope scope) {}
private record RequestContext(String ip, String userAgent, String requestId) {}
}
@@ -8,6 +8,7 @@ import cloud.nstart.terravault.ordinis.events.record.DictionaryRecordCreated;
import cloud.nstart.terravault.ordinis.events.record.DictionaryRecordDeleted;
import cloud.nstart.terravault.ordinis.events.record.DictionaryRecordUpdated;
import cloud.nstart.terravault.ordinis.outbox.OutboxRecorder;
import cloud.nstart.terravault.ordinis.restapi.audit.AuditLogger;
import cloud.nstart.terravault.ordinis.restapi.dto.CreateRecordRequest;
import cloud.nstart.terravault.ordinis.restapi.error.OrdinisException;
import cloud.nstart.terravault.ordinis.validation.RecordValidator;
@@ -43,6 +44,7 @@ public class DictionaryRecordService {
private final DictionaryDefinitionService definitionService;
private final RecordValidator validator;
private final OutboxRecorder outbox;
private final AuditLogger auditLogger;
private static final OffsetDateTime FAR_FUTURE = OffsetDateTime.parse("9999-12-31T23:59:59Z");
@@ -50,11 +52,13 @@ public class DictionaryRecordService {
DictionaryRecordRepository repository,
DictionaryDefinitionService definitionService,
RecordValidator validator,
OutboxRecorder outbox) {
OutboxRecorder outbox,
AuditLogger auditLogger) {
this.repository = repository;
this.definitionService = definitionService;
this.validator = validator;
this.outbox = outbox;
this.auditLogger = auditLogger;
}
@Transactional(readOnly = true)
@@ -110,6 +114,7 @@ public class DictionaryRecordService {
}
throw ex;
}
auditLogger.recordCreate(d, saved);
publishCreated(d, saved);
return saved;
}
@@ -163,6 +168,7 @@ public class DictionaryRecordService {
throw ex;
}
auditLogger.recordUpdate(d, saved, prevData);
publishUpdated(d, saved, previousRecordId, prevData);
return saved;
}
@@ -180,6 +186,7 @@ public class DictionaryRecordService {
current.setValidTo(when);
repository.save(current);
auditLogger.recordClose(d, current, reason);
publishDeleted(d, current, when, reason);
}
@@ -0,0 +1,150 @@
package cloud.nstart.terravault.ordinis.restapi.web;
import cloud.nstart.terravault.ordinis.domain.audit.AuditLog;
import cloud.nstart.terravault.ordinis.domain.audit.AuditLogRepository;
import com.fasterxml.jackson.databind.JsonNode;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Sort;
import org.springframework.format.annotation.DateTimeFormat;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.time.OffsetDateTime;
import java.util.UUID;
/**
* Admin endpoints для audit log. Read-only, фильтры опциональны.
*
* <ul>
* <li>{@code GET /admin/audit} пагинированный поиск.</li>
* <li>{@code GET /admin/audit/export.csv} выгрузка по тем же фильтрам
* (без пагинации; cap 10 000 строк против OOM).</li>
* </ul>
*/
@RestController
@RequestMapping("/api/v1/admin/audit")
public class AuditAdminController {
private static final int MAX_EXPORT_ROWS = 10_000;
private final AuditLogRepository repository;
public AuditAdminController(AuditLogRepository repository) {
this.repository = repository;
}
@GetMapping
public Page<AuditEntry> search(
@RequestParam(required = false) String dictionaryName,
@RequestParam(required = false) String userId,
@RequestParam(required = false) String action,
@RequestParam(required = false) String businessKey,
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) OffsetDateTime from,
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) OffsetDateTime to,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "50") int size) {
Page<AuditLog> result = repository.search(
nullIfBlank(dictionaryName),
nullIfBlank(userId),
nullIfBlank(action),
nullIfBlank(businessKey),
from,
to,
PageRequest.of(page, Math.min(size, 200), Sort.by(Sort.Direction.DESC, "eventTime")));
return result.map(AuditEntry::from);
}
@GetMapping(value = "/export.csv", produces = "text/csv")
public org.springframework.http.ResponseEntity<String> exportCsv(
@RequestParam(required = false) String dictionaryName,
@RequestParam(required = false) String userId,
@RequestParam(required = false) String action,
@RequestParam(required = false) String businessKey,
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) OffsetDateTime from,
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) OffsetDateTime to) {
Page<AuditLog> result = repository.search(
nullIfBlank(dictionaryName),
nullIfBlank(userId),
nullIfBlank(action),
nullIfBlank(businessKey),
from,
to,
PageRequest.of(0, MAX_EXPORT_ROWS, Sort.by(Sort.Direction.DESC, "eventTime")));
StringBuilder csv = new StringBuilder(
"id,event_time,event_type,action,user_id,user_scope,dictionary_name,business_key,record_id,trace_id,ip_address\n");
for (AuditLog a : result.getContent()) {
csv.append(a.getId()).append(',')
.append(a.getEventTime()).append(',')
.append(csvCell(a.getEventType())).append(',')
.append(csvCell(a.getAction())).append(',')
.append(csvCell(a.getUserId())).append(',')
.append(a.getUserScope() == null ? "" : a.getUserScope().name()).append(',')
.append(csvCell(a.getDictionaryName())).append(',')
.append(csvCell(a.getBusinessKey())).append(',')
.append(a.getRecordId() == null ? "" : a.getRecordId()).append(',')
.append(csvCell(a.getTraceId())).append(',')
.append(csvCell(a.getIpAddress()))
.append('\n');
}
String filename = "audit-log-" + OffsetDateTime.now().toLocalDate() + ".csv";
return org.springframework.http.ResponseEntity.ok()
.header("Content-Disposition", "attachment; filename=\"" + filename + "\"")
.contentType(MediaType.parseMediaType("text/csv; charset=UTF-8"))
.body(csv.toString());
}
private static String nullIfBlank(String s) {
return (s == null || s.isBlank()) ? null : s;
}
private static String csvCell(String s) {
if (s == null) return "";
if (s.indexOf(',') < 0 && s.indexOf('"') < 0 && s.indexOf('\n') < 0) return s;
return "\"" + s.replace("\"", "\"\"") + "\"";
}
public record AuditEntry(
Long id,
OffsetDateTime eventTime,
String eventType,
String action,
String userId,
String userScope,
String dictionaryName,
UUID dictionaryId,
UUID recordId,
String businessKey,
JsonNode payloadBefore,
JsonNode payloadAfter,
String traceId,
String requestId,
String ipAddress,
String userAgent) {
static AuditEntry from(AuditLog a) {
return new AuditEntry(
a.getId(),
a.getEventTime(),
a.getEventType(),
a.getAction(),
a.getUserId(),
a.getUserScope() == null ? null : a.getUserScope().name(),
a.getDictionaryName(),
a.getDictionaryId(),
a.getRecordId(),
a.getBusinessKey(),
a.getPayloadBefore(),
a.getPayloadAfter(),
a.getTraceId(),
a.getRequestId(),
a.getIpAddress(),
a.getUserAgent());
}
}
}
@@ -38,7 +38,7 @@ class DictionaryRecordServiceTest {
repo = mock(DictionaryRecordRepository.class);
// resolveBusinessKey/enforceUniqueFields не дёргают остальные деп передаём null,
// mocking concrete Spring services через Mockito-inline на JDK 25 ломается.
service = new DictionaryRecordService(repo, null, null, null);
service = new DictionaryRecordService(repo, null, null, null, null);
}
// ============= resolveBusinessKey =============