b2e7fd84c96dfcef184354929fa792ab2065d9c7
20 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
b2e7fd84c9 |
feat(approval): Approval Workflow v2 Phase 4 monitoring + pending-review badges
Approval Workflow v2 — Phase 4: monitoring + UX visibility.
Backend metrics (Micrometer → Prometheus):
- Gauge nsi_draft_pending_total — global queue depth (polled на scrape).
- Counter nsi_draft_submit_total{dictionary,operation,outcome=ok|already_pending|error}
— submit attempts с разбивкой по outcome. Phase 3 lessons: error outcome
выявит schema bugs которые ранее маскировались.
- Counter nsi_draft_decision_total{dictionary,operation,outcome=approved|rejected|withdrawn}
— counts decisions, разбит по типу.
- Timer nsi_draft_review_duration_seconds — time-to-decide histogram (от
submit до approve/reject/withdraw). publishPercentileHistogram() — для
Prometheus histogram_quantile p95/p99.
DraftService:
- Optional<MeterRegistry> — null-safe в test ctor'ах.
- @PostConstruct registerGauges() — Gauge.builder polled от
RecordDraftRepository.countPending().
- recordDecision() helper в approve/reject/withdraw — записывает Timer +
Counter одной точкой.
- 4-arg ctor compat для Phase 1 unit tests.
UI (admin):
- useDictPendingDrafts — per-dict pending list, refetch 30s, gated на
detailQuery.data.approvalRequired (no-op для не-approval dicts).
- "На review" badge в businessKey cell records table — моментальная
видимость что есть pending draft. Open /reviews для act'а.
- i18n RU/EN: dict.pendingReview.label + tooltip.
Domain:
- RecordDraftRepository.countPending() — для Gauge polled count.
Tests: ordinis-rest-api unit 119/119, ordinis-app e2e 28/28, vitest 89/89.
|
||
|
|
e438c4c8c8 |
feat(approval): Approval Workflow v2 Phase 0 — schema + skeleton API
Approval Workflow v2 epic, Phase 0 foundation. Maker-checker pattern для
критичных справочников через record_drafts отдельную таблицу (Approach A
из design doc).
Decisions made (auto, per design doc recommendations):
- D1=A: per-dict policy через колонки на dictionary_definitions
(approval_required + approval_min_role).
- D2=A: legacy POST на approval-required dict вернёт 409 (Phase 1 wire'ит
в DictionaryRecordService).
- D3=A: pool model для reviewer'ов — все ordinis:approver видят queue.
- D4=A: rejected drafts kept (status='rejected'); compliance audit trail.
Migration 0019:
- record_drafts table:
* id, dictionary_id, business_key, parent_record_id, operation
* data (jsonb), geometry, proposed_valid_from/to
* status, maker_id, maker_comment, submitted_at
* reviewer_id, reviewed_at, review_comment, version
- 4 constraints:
* status_check: pending/approved/rejected/withdrawn
* operation_check: CREATE/UPDATE/CLOSE
* no_self_approve: maker_id != reviewer_id
* one_pending_per_key: EXCLUDE WHERE status=pending
(concurrent edits → последний wins на submit, prevents merge conflicts)
- 3 indices: pending_by_dict, by_maker, pending_global.
- dictionary_definitions: +approval_required (BOOLEAN, default false),
+approval_min_role (VARCHAR 64, optional Keycloak role).
Backend:
- Entity RecordDraft + Repository с JPQL queries для:
* findPendingByKey (single pending lookup)
* findPending (global queue для approver pool, paginated)
* findPendingByDictionary (per-dict list)
* findByMaker (own drafts tracking, paginated)
- DTO SubmitDraftRequest, DraftResponse.
- DraftService skeleton:
* submit() — creates pending draft. 409 на exclusion violation.
* findById, listPendingByDictionary, listPendingGlobal — read-only.
* approve / reject / withdraw / updateDraft — Phase 1 stubs (комментарии).
- DraftController с 4 endpoints:
* POST /api/v1/dictionaries/{name}/records/drafts — submit
* GET /api/v1/dictionaries/{name}/records/drafts — list per dict
* GET /api/v1/drafts/{id} — single draft
* GET /api/v1/admin/reviews/pending — global queue (paginated)
Tests:
- DraftServiceTest 6 unit tests:
* submit creates with PENDING status
* 409 на duplicate pending business_key (exclusion constraint)
* invalid WKT → 400
* dict not found → 404
* findById unknown → 404
* listPendingByDictionary returns multiple pending
- StubDraftRepo simulates DB exclusion constraint в memory.
Verify:
- mvn -pl ordinis-rest-api -am test: 112/112 PASS (+6 new).
- mvn -P e2e -pl ordinis-app -am test: 20/20 PASS — migration 0019
applies clean, no regression.
- helm/yaml configs не трогали — Phase 0 чистый schema + code.
Phase 1 (next session) — full lifecycle:
- DraftService.approve(): SERIALIZABLE tx с COPY draft → live + outbox
RecordCreated/Updated/Deleted event + audit. Validate against schema
ON APPROVE (могла schema измениться с момента submit'а).
- reject() — mark status=rejected, kept (D4).
- withdraw() — maker отзывает свой pending.
- DictionaryRecordService.create/update/close: pre-check
approval_required → 409 draft_required. JWT subject → maker_id.
- Reviewer queue + diff viewer admin UI.
Source: ~/.gstack/projects/claude/zimin-main-design-approval-workflow-v2-20260507-121632.md
|
||
|
|
48df5ceeb8 |
feat(search): smart JSONB search across all dictionaries
CEO plan v1 stretch — закрывает gap "Smart JSONB search across all dictionaries".
Last gap из v1 list shipped.
Migration 0018:
- CREATE EXTENSION pg_trgm (CNPG initdb обычно не enable'ит, добавили
явно с MARK_RAN preCondition если уже есть).
- CREATE INDEX idx_dict_records_data_trgm
ON dictionary_records USING GIN ((data::text) gin_trgm_ops)
- Trigram-based ILIKE с минимум 3 символа в query — index используется.
ILIKE '%pat%' на data::text возвращает любые matches в JSONB serialized.
Backend:
- New RecordSearchQuery (ordinis-domain): native SQL c ROW_NUMBER()
per-dict cap. SQL injection защищён PreparedStatement param + extension
whitelist (allowed_scopes — text[]).
- New SearchController (ordinis-rest-api): GET /api/v1/search?q=&size&perDict.
Default size=100, perDict=10. Min q length=3 (silently empty if shorter).
Scope filtering through ScopeContext — каждый caller видит только свои
scope levels. Results grouped per dict с display name + count + items.
Admin UI:
- New /search route с SearchInput + grouped Panel results.
- URL state ?q=… — share-friendly link "/search?q=SAR-X".
- Per-result link → /dictionaries/{dict}?q={businessKey} для drill-down.
- Min-3 hint, empty state, loading + error.
- New "Поиск" / "Search" tab в navigation header.
- i18n RU (с правильными plurals search.totalHits) + EN.
Behavior:
- Active records only (valid_from <= now() < valid_to).
- Per-dict cap 10 — защита от dominant-dict skew (один dict с 1000 matches
не забьёт результат).
- Total cap 100 — admin "find this code" use case достаточно. Browser-style
search-as-you-type (10k+ matches, instant) — отложен на v2 с dedicated
index server (Elastic / Meili).
Verify:
- mvn -P e2e -pl ordinis-app -am test: 20/20 PASS (migration applied).
- pnpm tsc --noEmit: clean.
- pnpm test (vitest): 89/89 PASS.
- pnpm build: clean.
После migration apply'я index size на dictionary_records будет ~30-40%
data column. На текущих 5k records ~3-5 MB, acceptable.
|
||
|
|
0a8275ef4c |
feat(redis-projection): per-dict feature flag (CEO plan E2 tiered perf)
Per-dictionary opt-in для Redis projection materialization. Default false
— projection-writer пропускает event'ы того dict'а, никаких dead writes
в Redis. Включается админ'ом через UI checkbox в Metadata tab.
Migration 0017:
- ALTER TABLE dictionary_definitions ADD COLUMN redis_projection_enabled
BOOLEAN NOT NULL DEFAULT false.
- Index idx_dict_def_redis_proj для быстрого filtering в list queries.
Backend:
- DictionaryDefinition entity: new field + getter/setter.
- DictionaryResponse DTO: returns flag в response.
- CreateDictionaryRequest DTO: optional Boolean (null/missing = no change).
- DictionaryDefinitionService.create + updateSchema: применяет flag из request.
- ProjectionWriter (projection-writer service):
* upsert() — early-return + skip counter если flag=false на dict.
* delete() — symmetric: skip если flag=false (защита от stale keys
после flag-flip; полный cleanup TODO Phase 2).
* New metric: ordinis_projection_records_skipped_total.
Admin UI:
- DictionaryEditorDialog Metadata tab: checkbox "Включить Redis-проекцию"
с описанием. Apply через CreateDictionaryRequest payload.
- DictionaryDefinition + CreateDictionaryRequest types updated.
- i18n RU/EN: schema.redisProjection.{label,hint}.
Что НЕ делается (deferred Phase 2):
- read-api Redis routing — read-api сейчас всегда идёт в PG read replica.
Когда dict опт-инится в флаг, projection материализуется, но read-api
ещё не использует. Read routing — отдельная feature с benchmark proof
(через JMH + k6 SLO test).
- One-shot cleanup job для stale keys после flag-flip from true→false.
Behavior change в production:
- До patch: projection-writer пишет в Redis для ВСЕХ dict events (40
dictionaries). Default flag=false после migration → projection-writer
пропускает всё → Redis keys для existing dicts становятся stale.
Это OK потому что read-api НИКОГДА не читал из Redis (writes были
dead). Stale keys eventually evicted Redis maxmemory policy.
- После patch: только dicts с redis_projection_enabled=true получают
материализацию. По default — нет. Admin opt-ins per-dict через UI.
Verify:
- mvn test (full reactor): SUCCESS, all green.
- mvn -P e2e -pl ordinis-app -am test: 20/20 PASS (migration 0017 applied).
- pnpm tsc --noEmit: clean.
- pnpm test (vitest): 89/89 PASS.
- pnpm build: clean.
|
||
|
|
c06ae263e0 |
feat(lineage): Phase 2 — materialized view + throttled refresh + UI staleness
dict-relationships-v2 epic, Phase 2. Precomputed reverse FK index +
throttled refresh + feature flag + UI staleness badge. Migration катится
всегда; query path активируется через ordinis.lineage.mv.enabled=true.
Backend:
- Liquibase 0016: CREATE MATERIALIZED VIEW record_dependents_index +
UNIQUE INDEX(source_record_id, source_field) (REQUIRED для REFRESH
CONCURRENTLY) + composite indices для record-level и schema-level lookup.
Plus lineage_mv_meta table — single-row tracking (last_refreshed_at,
duration_ms, row_count, status, error).
- DependentsQueryMv (interface impl, @Primary + @ConditionalOnProperty)
— один SQL query через MV вместо N JSONB lookups (по одному per
source dict).
- MaterializedViewRefreshService:
* @Scheduled refresh каждые ordinis.lineage.mv.refresh-interval-sec
(default 60). Storm prevention: AtomicReference guard для in-flight,
cooldown через ordinis.lineage.mv.min-interval-sec (default 30).
* REFRESH CONCURRENTLY с fallback на plain REFRESH когда MV пустой
(initial state). Не блокирует concurrent reads.
* MvGateway interface (extracted из-за JDK 25 + Mockito incompat для
concrete JdbcTemplate). Production: JdbcMvGateway (nested static).
- LineageIndexService теперь возвращает LineageMeta в RecordDependentsPage
— mvEnabled, lastRefreshedAt, lastStatus, lastDurationMs. Optional<MV>
inject — works без MV (mvEnabled=false, всё null).
Tests:
- MaterializedViewRefreshServiceTest (9 tests):
* scheduledRefresh first time → REFRESH CONCURRENTLY
* skip when within cooldown
* refreshNow throws RefreshThrottledException when throttled
* refresh succeeds после cooldown
* fallback на plain REFRESH когда CONCURRENTLY fails
* STORM PREVENTION (R2): 100 concurrent refresh attempts → ровно 1
actual refresh (via cooldown + in-flight guard)
* lastRefresh returns meta correctly
* updates lineage_mv_meta after refresh (status=ok)
* updates with error если оба refresh'а fail (status=error)
Admin UI:
- RecordDependentsPage type расширен LineageMeta { mvEnabled,
lastRefreshedAt, lastStatus, lastDurationMs }.
- RecordDependentsPanel показывает staleness badge "обновлено N мин
назад" если MV active + last refresh > 2 min ago. Tooltip объясняет
что closed-between-refreshes records отфильтрованы query-side.
- i18n RU/EN: lineage.refs.{stale, staleHint}.
Verify:
- mvn -pl ordinis-rest-api -am test: 98/98 PASS (+9 new MV tests).
- mvn verify -pl '!ordinis-app': все модули SUCCESS (9.3s).
- pnpm tsc --noEmit: clean.
- pnpm test (vitest): 89/89 PASS.
Deploy strategy:
- Migration катится автоматом (initial REFRESH < 5 min на 5k records).
- Query path остаётся JSONB direct (Phase 1) до явного flip flag'а
ordinis.lineage.mv.enabled=true. Это позволит верифицировать MV
корректность на staging без риска для prod.
- После flip: refresh каждые 60 sec, stale window <= 2 min обычно.
|
||
|
|
46a0a9c00c |
feat(lineage): Phase 1 backend — dependents endpoints + REGRESSION test
dict-relationships-v2 epic, Phase 1 read-side. Backend готов; admin UI
(card "Used by" + record references) — отдельным commit'ом.
Что добавлено:
- `DependentsQuery` (interface) + `DependentsQueryJdbc` (impl) — native
SQL для record-level dependents lookup (paginated). Interface extracted
чтобы Mockito мог mock'нуть на JDK 25 (concrete-class inline-mock incompat).
- `LineageIndexService` — два режима:
* `findSchemaDependents(name, scopes)` — schema-level reverse FK list
(in-memory traversal definitions, microseconds). Skip self-reference,
scope-hidden sources, malformed x-references (logged warn).
* `findRecordDependents(dict, businessKey, scopes, page, size)` —
record-level paginated, perSource summary + total. Phase 2 переносит
на materialized view; пока живём с прямым JSONB query (acceptable
для <5k SLO).
- `LineageController` — два endpoint'а:
* `GET /api/v1/dictionaries/{name}/dependents`
* `GET /api/v1/records/{dict}/{businessKey}/dependents?page&size`
Errors: 404 dictionary_not_found / record_not_active, 400 invalid_pagination.
Scope hiding делает target invisible (empty list / 404 — без leak existence).
Tests:
- LineageIndexServiceTest (16 unit tests):
* Schema-level: not_found, scope hidden, single ref + count, onClose
propagation (BLOCK default + CASCADE explicit), sorting, source scope
hidden, self-reference skip, malformed skip, target without properties.
* Record-level: invalid pagination (page<0, size<1, size>200),
dict_not_found, record_not_active, no schema refs → empty page,
1 source с dependents → items+perSource+total, pagination через
page=0/1/2 size=2 (3 страницы из 5 records).
- ReferenceCloseRegressionTest (4 mandatory regression tests):
* v1.2.1 schema без x-references-on-close — validate() возвращает
те же error codes (никаких новых on_close/cascade в codes).
* Schema parser default'ит onClose=BLOCK для v1.2.1 schemas.
* Legacy single-arg parseRef(spec) без property schema сохраняет contract.
* Schema без x-references вообще — full no-op (30+ legacy справочников).
mvn -pl ordinis-rest-api -am test: 89/89 PASS (5.686s).
mvn verify -pl '!ordinis-app': все модули SUCCESS (9.031s).
Backward-compat: bundle v1.2.1 → identical поведение к v1.0.7. Cascade engine
не поднимается на closed-side (Phase 3 territory).
|
||
|
|
f2cf34e2fc |
feat(webhook): delivery list — server-side фильтр по status
Раньше при page=50 admin не мог найти редкий dlq среди
delivered'ов: client-side filter показал бы 1 совпадение из 50,
а на page=1 могли быть ещё. Делаем server-side через ?status=.
Backend:
- WebhookDeliveryRepository.findBySubscriptionIdAndStatusOrderByCreatedAtDesc
— Spring Data derived query, JPA генерирует SQL.
- GET /admin/webhooks/subscriptions/{id}/deliveries?status=dlq —
опциональный param. Invalid status → 400 OrdinisException.
Allowed: pending, retrying, delivered, dlq.
UI:
- 4 chip'а pending/retrying/delivered/dlq над таблицей deliveries
(одинаковый паттерн со scope-чипами в dictionaries).
- Click toggle + reset page=0. 'Сбросить' появляется когда фильтр
активен.
- Status в URL пока не персистится — для одной session-страницы
не критично.
|
||
|
|
0759a5a150 |
feat(refs): orphan FK scanner — Prometheus gauge nsi_orphan_references_total
@Scheduled sweep раз в час (configurable). Walks all schemas, для каждого x-references marker считает orphans через JdbcTemplate native SQL (WHERE source.data->>field NOT IN target dict active records). Метрика: Gauge per (source_dict, source_field, target_dict, target_field). Cardinality bounded by FK count в bundles (~5 для cuod v1.2.1). Conditional: ordinis.references.scan-enabled=true (default off, включаем явно в prod values). Read-only, no write impact. OrphanReferenceQuery (ordinis-domain): native SQL helper, identifier validation против SQL injection. Field name regex check т.к. JSONB key path не bind'ится через @Param. Tests: 4 в OrphanReferenceScannerTest (parseRef edge cases). Реальный SQL логику покроет integration тест на staging данных. Total project tests: 191 → 195. |
||
|
|
ac84780a95 |
feat(geo): polygon GeoJSON spatial filter
Дополнение к bbox: ?polygon=<GeoJSON> для произвольных полигонов
(ground stations coverage, custom AOI). Один из bbox/polygon —
взаимоисключающие; передача обоих → 400.
Repository:
- findActiveByPolygonGeoJson native query через PostGIS
ST_GeomFromGeoJSON(polygon) → ST_SetSRID(_, 4326). DB-side parsing,
не нужен jts-io-common.
GeoJsonPolygon validator (app-side, structural):
- Парсит JSON, проверяет {"type":"Polygon","coordinates":[[...]]}
- RFC 7946 closed-ring check: first point == last
- >= 4 points в каждом ring (3 вертекса + closing point)
- Поддержка holes (multiple rings)
- Coordinate range validation: lon ∈ [-180,180], lat ∈ [-90,90]
- DoS guards: MAX_JSON_BYTES = 1MB, MAX_POINTS = 50_000
- Canonical re-serialize (whitespace stripped) перед DB
Tests (13 в GeoJsonPolygonTest):
- Valid square + polygon with hole
- Reject empty/null/non-JSON/non-object/wrong-type/missing-coords
- Reject too-few points / unclosed ring
- Reject lon/lat out of range
- Reject malformed point [no lat]
- Reject too-large JSON
Total project tests: 161 → 174.
API now supports:
GET /api/v1/{dict}/records?bbox=west,south,east,north
GET /api/v1/{dict}/records?polygon={"type":"Polygon",...}
Альтум-задача (rect AOI) и геопортал (polygon AOI) разблокированы
от client-side full-fetch + filter.
|
||
|
|
0811ad8506 |
feat(geo): bbox spatial filter on GET /{dict}/records
v2 фича: Альтум-задача (заказ съёмки) использует bbox region. До
этого consumers fetch'или ВСЕ записи и filter'или client-side. Теперь
PostGIS ST_Intersects на server-side через GiST index.
API:
GET /api/v1/{dictionaryName}/records?bbox=west,south,east,north
Coords в SRID 4326 (longitude, latitude). Параметр optional —
отсутствует = старое поведение (full list).
BoundingBox helper (ordinis-read-api/spatial/):
- Парсинг "west,south,east,north" с валидацией
- Range checks: lon ∈ [-180,180], lat ∈ [-90,90]
- west <= east (anti-meridian crossing rejected в v1)
- south <= north
- Понятные error messages → 400 Bad Request через BadBboxException
Repository (ordinis-domain):
- findActiveByBbox native query, ST_MakeEnvelope(:west,:south,:east,
:north,4326) ST_Intersects на geometry колонке. GiST index hit.
- Записи с geometry IS NULL автоматически отфильтровываются.
Tests (12 в BoundingBoxTest):
- Valid parsing + negative coords + whitespace
- Reject empty/null/wrong-count/non-numeric
- Range validation (lon/lat bounds)
- Anti-meridian + inverted N/S detected
- Area calc sanity
Total project tests: 149 → 161.
Polygon GeoJSON support — отложен (нужен jts-io-common dep). Bbox
покрывает Альтум use case + 80% other consumers.
|
||
|
|
d6787f00e9 |
feat(webhook): v2 webhooks foundation — DB schema + JPA entities
Phase 1 v2 фичи Webhooks: HTTP callback delivery параллельно с Kafka publishing для consumer'ов которые не могут Kafka subscribe (геопортал PHP, legacy admin-ui'и, BI-системы). Migrations: - 0013-webhook-subscriptions: таблица + check constraints (URL scheme, scope enum) + partial index по active=true для hot poller path - 0014-webhook-deliveries: tracking каждой попытки доставки (один subscription × event = uniq), partial index due (status IN pending, retrying), DLQ index, FK на outbox_events с CASCADE delete JPA entities: - WebhookSubscription: TEXT[] filters (scope, dictionary, event_type), HMAC-SHA256 hex secret, active flag, audit fields - WebhookDelivery: lifecycle pending → retrying → delivered/dlq, exponential backoff schedule (1m → 5m → 15m → 1h → 6h → 24h capped), MAX_ATTEMPTS=1000 (consistent с outbox), markDelivered/markFailed/ resetDlq business methods Repositories: - WebhookSubscriptionRepository: findActive() через partial index - WebhookDeliveryRepository: findDue() для poller, INSERT ON CONFLICT DO NOTHING для idempotency, DLQ counts для метрик Ещё впереди: WebhookDispatcher service (HTTP POST + HMAC + SSRF guard), REST API CRUD (admin-only RBAC), admin-ui /webhooks page, тесты. |
||
|
|
171d049fc5 |
feat(admin-ui): поиск справочников + счётчик активных записей
Backend: - DictionaryRecordRepository.countActiveGroupedByDictionary — один GROUP BY по всем справочникам с фильтром по scope, без N+1. - DictionaryResponse.recordCount (nullable) + factory from(d, count). - DictionaryDefinitionController передаёт счётчики в list/get, фильтрованные по currentScopes() пользователя. Frontend: - SearchInput над сеткой карточек, client-side filter по name/displayName/description (case-insensitive, useDeferredValue). - Badge "N записей" (i18n plural ru/en) в углу карточки. - "Показано N из M" рядом с поиском. - EmptyState когда поиск пустой. |
||
|
|
f535f7544d |
test(e2e): smoke test через Testcontainers + fix audit_log.ip_address INET bug
Phase 1 e2e — proof framework работает: - E2ESupport: Postgres 18 (postgis/postgis:18-3.6) + Kafka (cp-kafka 7.6.0) через Testcontainers, reuse=true. db-init.sql создаёт postgis+btree_gist расширения которые ожидает Liquibase 0001. - application-test.yml: тушит cloud-config/vault/otel/outbox publishing, включает Liquibase autoconfig. Auth disabled для упрощения. - SmokeE2ETest: full e2e — POST dictionary → POST record → GET record → проверка audit_log row. UUID-suffix в dictName для идемпотентности. - master.xml: relativeToChangelogFile=true для всех include — иначе Liquibase из ordinis-app classpath не находит changes/X.xml в JAR. - pom.xml: testcontainers 1.21.3, surefire env DOCKER_HOST + DOCKER_API_VERSION для macOS Docker Desktop. Найден реальный prod-баг: audit_log.ip_address был INET, Hibernate JPA не делает автоматический cast String→INET → INSERT падал. AuditLogger.write обёрнут в try/catch, поэтому тихо съедал ошибку — на staging audit_log оставался пустым после CRUD. - 0012-audit-log-ipaddress-varchar.xml: ALTER COLUMN INET → VARCHAR(64) - AuditLog entity: убрал columnDefinition="inet", length=64. |
||
|
|
2a82e0d6a3 |
fix(audit): sentinel timestamps вместо :from IS NULL в JPQL
PostgreSQL не может вывести тип параметра OffsetDateTime когда оба боковых выражения IS NULL ⇒ 500 на /api/v1/admin/audit. Фикс — controller подставляет 0001-01-01/9999-12-31 если from/to не указаны, JPQL использует обычное сравнение eventTime BETWEEN :from AND :to. Строковые фильтры (dictionary, user, action, businessKey) остаются с :p IS NULL OR x = :p — они работают потому что VARCHAR PG может вывести из правой стороны сравнения. |
||
|
|
7b2fe6f2d5 |
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). |
||
|
|
e182b30c58 |
feat(outbox): attempt cap → DLQ + admin endpoint + 6 unit-тестов
После N (default 1000) неудачных попыток публикации событие маркируется
dlq_at и исключается из polling. Без cap poller бесконечно ретраил мёртвые
сообщения (orphan topic, ACL deny), забивая логи и lag-метрику.
- 0011-outbox-dlq.xml: dlq_at column + перестроенный idx_outbox_unpublished
(исключает DLQ) + idx_outbox_dlq для admin-листинга.
- OutboxPoller: ordinis.outbox.attempt-max (default 1000), markDlq() при
достижении cap, новый counter ordinis_outbox_dlq_total.
- OutboxLagGauge: ordinis_outbox_dlq_size gauge — алерт на > 0.
- OutboxEventRepository: countPending() (без DLQ), findByDlqAtIsNotNull(Pageable).
- OutboxAdminController: GET /admin/outbox/{stats,dlq} для UI.
- 6 unit-тестов через mock-maker-subclass (JDK 25 ломает Mockito inline).
|
||
|
|
c96b6706fc |
feat(rest-api): backend enforcement для x-unique и x-id-source schema extensions
x-id-source (auto-derive businessKey): - DictionaryRecordService.resolveBusinessKey() читает schemaJson['x-id-source']. - Если задан — businessKey берётся из data[idSource]. Если клиент явно прислал и значения не совпадают → 400 business_key_mismatch (защита от рассинхрона). - Если поле в data пустое → 400 id_source_missing. - Если x-id-source не задан и businessKey пуст → 400 business_key_required. x-unique (constraint на дубли значений): - DictionaryRecordRepository.findActiveByJsonField() — native SQL c data->>field. - DictionaryRecordService.enforceUniqueFields() walks schema.properties, для каждого property с x-unique=true ищет активные записи с тем же значением. Исключает current record на update. - Конфликт → 409 unique_field_violation с указанием business_key конфликтующей. Делается на каждом create/update в той же SERIALIZABLE транзакции что и save — без отдельного DB constraint (для MVP). Partial unique index per-field — оптимизация на потом, когда будет много справочников с x-unique. |
||
|
|
be77c30687 |
feat(validation,rest-api,outbox): write-side end-to-end working
ordinis-validation:
- ValidationError, ValidationResult, ValidationException
- RecordValidator: networknt JSON Schema (Draft-7) + custom rule для
x-localized полей. Walk schema, для каждого x-localized:true property
проверяет: значение это object с locale keys (BCP 47 pattern xx-XX),
все ключи в supported_locales, default_locale обязательно присутствует,
значения non-empty strings.
ordinis-outbox:
- KafkaTopicResolver: ordinis.<bundle>.events.<scope>
- OutboxRecorder: запись события в outbox в той же транзакции (MANDATORY
propagation), с tracer для trace_id/span_id из MDC если OTel доступен
- OutboxPoller: @Scheduled, batch_size + poll_interval из config, метрики
ordinis_outbox_published_total / publish_errors_total / kafka_publish_duration_seconds
- OutboxLagGauge: ordinis_outbox_pending_events (gauge для алертов)
- ConditionalOnProperty ordinis.outbox.enabled — отключаем в read-api/projection-writer
ordinis-rest-api:
- DTOs: CreateDictionaryRequest, DictionaryResponse, CreateRecordRequest,
RecordResponse (с WKT serialization geometry)
- DictionaryDefinitionService: CRUD definitions, outbox events DefinitionCreated/Updated
- DictionaryRecordService: bitemporal write-side, SERIALIZABLE isolation,
bound check FAR_FUTURE valid_to. Update = close current + create new
(никогда in-place). Outbox event RecordCreated/Updated/Deleted.
- DictionaryDefinitionController: GET list/by-name, POST create, PUT update
- DictionaryRecordController: GET active/history, POST create, PUT update,
DELETE (close)
- OrdinisException + ApiErrorResponse + GlobalExceptionHandler
(422 validation, 404 not found, 409 conflict, 500 internal)
ordinis-app:
- @EnableJpaRepositories + @EntityScan для multi-package сканирования
- application.yml profile-based: dev (PG localhost через port-forward,
ddl-auto=update, OTel disabled), k8s (Cloud Config + Vault Kubernetes auth)
- spring.kafka producer config с SASL SCRAM-SHA-512
ordinis-domain:
- JpaAuditingConfig: добавлен DateTimeProvider возвращающий OffsetDateTime
(Spring Data Auditing по дефолту его не поддерживает, без него падает
IllegalArgumentException 'Cannot convert LocalDateTime to OffsetDateTime')
- @ConditionalOnBean(DataSource) убран — race с lifecycle ломал auditing
E2E test (port-forward к cluster PG+Kafka):
- POST dictionary 'ground_station' → 201, JSONB schema сохранена,
supported_locales = {ru-RU, en-US}, default_locale = ru-RU
- POST record MOSCOW-1 multi-locale name + geometry POINT(37.618 55.751) → 201
- POST record с en-US без ru-RU → 422 с двумя ошибками:
* networknt 'required property ru-RU not found'
* custom 'x-localized.missing_default Default locale ru-RU is required'
- outbox_events: 2 row (DefinitionCreated + RecordCreated), topic
ordinis.cuod.events.public, ключи правильные
- revinfo + dictionary_records_aud: 1 row (Envers tx-time history активен)
|
||
|
|
b778832f49 |
feat(domain,events): bitemporal entities + i18n-aware definitions + sealed event API
ordinis-domain:
- DataScope enum (PUBLIC/INTERNAL/RESTRICTED) с Kafka topic suffix + visibility logic
- DictionaryDefinition entity: scope, schema_json (JsonNode JSONB), schema_version,
bundle, supported_locales (text[]), default_locale + Spring Data Auditing
- DictionaryRecord entity: bitemporal (Envers tx-time + valid_from/valid_to),
data JSONB, geometry (JTS via hibernate-spatial), data_scope. EXCLUSION
CONSTRAINT enforced в БД (Liquibase 0003).
- AuditLog entity для compliance (отдельно от Loki)
- OutboxEvent entity: aggregate_*, kafka_topic/kafka_key, attempt_count,
markPublished/markFailed, partial index idx_outbox_unpublished
- IdempotencyKey entity для exactly-once семантики write API
- Repositories: findActiveAt, findActiveByDictionaryAndScopes, findUnpublished
- JpaAuditingConfig: ConditionalOnBean(DataSource) — dev profile без БД
не активирует auditing, metamodel не строится
ordinis-events-api:
- Mirror DataScope enum (events не depend on domain)
- EventEnvelope<T> record: eventId, occurredAt, schemaVersion (semver),
bundle, dictionaryName, scope, traceId, spanId, payload
- Sealed DictionaryRecordEvent + records Created/Updated/Deleted
- Sealed DictionaryDefinitionEvent + records Created/Updated
- Jackson polymorphism через @JsonTypeInfo discriminator
- Multi-locale payload: события возят ВСЕ локали (consumer сам сплющивает)
i18n design:
- В JSONB поле кодируется как { 'ru-RU': '...', 'en-US': '...' }
- JSON Schema справочника помечает локализуемые поля extension x-localized:true
- supported_locales + default_locale в dictionary_definitions
- Read API будет negotiate через Accept-Language (RFC 7231) и сплющивать
Validation:
- mvn install all 11 modules: BUILD SUCCESS
- ordinis-app boot dev profile: /actuator/health UP, / отвечает
|
||
|
|
a28fd0b352 |
feat: Maven multimodule scaffolding for Ordinis MDM service
- 10 модулей (domain, validation, events-api, outbox, rest-api, app, read-api, projection-writer, cuod-bundle, migrations) - Parent pom + BOM с Spring Boot 3.4.2, Spring Cloud 2024.0.0, Vault Config 4.2.0 - ordinis-app: Spring Boot main с Actuator/Prometheus/Logstash JSON encoder/OpenTelemetry/Cloud Config/Vault skeleton - ordinis-migrations: Liquibase changelogs (dictionary_definitions/records, audit_log, outbox_events, idempotency_keys, EXCLUSION CONSTRAINT через btree_gist) - Multi-stage Dockerfiles для app и migrations |