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
@@ -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);
}