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, тесты.
This commit is contained in:
+96
@@ -0,0 +1,96 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<databaseChangeLog
|
||||
xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
|
||||
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.27.xsd">
|
||||
|
||||
<!--
|
||||
Webhook subscriptions (v2 feature) — HTTP callback delivery параллельно
|
||||
с Kafka publishing. Для consumer'ов которые не могут Kafka subscribe
|
||||
(геопортал PHP, legacy admin-ui'и заказчиков, BI-системы).
|
||||
|
||||
Архитектура:
|
||||
- WebhookDispatcher (@Scheduled @1s) читает outbox_events × subscriptions
|
||||
→ создаёт webhook_deliveries → POST URL → mark delivered.
|
||||
- Параллельно с OutboxPoller (Kafka), не вместо.
|
||||
- HMAC-SHA256 signing: header `X-Ordinis-Signature: sha256=<hex>`,
|
||||
key = subscriptions.hmac_secret (random 32 bytes generated на create).
|
||||
- SSRF guard: deny private CIDR (app-level, не БД).
|
||||
-->
|
||||
|
||||
<changeSet id="0013-1-webhook-subscriptions" author="ordinis">
|
||||
<createTable tableName="webhook_subscriptions">
|
||||
<column name="id" type="UUID" defaultValueComputed="gen_random_uuid()">
|
||||
<constraints primaryKey="true" nullable="false"/>
|
||||
</column>
|
||||
<column name="name" type="VARCHAR(128)">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="url" type="VARCHAR(2048)">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<!-- Filter arrays: NULL/empty = match all. PG TEXT[] для удобства
|
||||
index-aware queries (ANY/= operators). -->
|
||||
<column name="scope_filter" type="TEXT[]"/>
|
||||
<column name="dictionary_filter" type="TEXT[]"/>
|
||||
<column name="event_type_filter" type="TEXT[]"/>
|
||||
<!-- HMAC secret hex-encoded 64 chars (32 bytes random). Generated
|
||||
server-side, returned ONCE на create response, потом hidden. -->
|
||||
<column name="hmac_secret" type="VARCHAR(128)">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="active" type="BOOLEAN" defaultValueBoolean="true">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="created_at" type="TIMESTAMPTZ" defaultValueComputed="now()">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="created_by" type="VARCHAR(128)">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="updated_at" type="TIMESTAMPTZ" defaultValueComputed="now()">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="description" type="TEXT"/>
|
||||
</createTable>
|
||||
</changeSet>
|
||||
|
||||
<changeSet id="0013-2-webhook-subscriptions-checks" author="ordinis">
|
||||
<comment>
|
||||
URL должен начинаться с https:// (production) или http:// (dev).
|
||||
Scope values constrained тот же enum что DataScope в Java.
|
||||
</comment>
|
||||
<sql>
|
||||
ALTER TABLE webhook_subscriptions
|
||||
ADD CONSTRAINT chk_webhook_url_scheme
|
||||
CHECK (url ~ '^https?://[^\s]+$');
|
||||
|
||||
ALTER TABLE webhook_subscriptions
|
||||
ADD CONSTRAINT chk_webhook_scope_filter
|
||||
CHECK (scope_filter IS NULL OR scope_filter <@ ARRAY['PUBLIC','INTERNAL','RESTRICTED']::TEXT[]);
|
||||
</sql>
|
||||
<rollback>
|
||||
<sql>
|
||||
ALTER TABLE webhook_subscriptions DROP CONSTRAINT IF EXISTS chk_webhook_url_scheme;
|
||||
ALTER TABLE webhook_subscriptions DROP CONSTRAINT IF EXISTS chk_webhook_scope_filter;
|
||||
</sql>
|
||||
</rollback>
|
||||
</changeSet>
|
||||
|
||||
<changeSet id="0013-3-webhook-subscriptions-active-index" author="ordinis">
|
||||
<comment>
|
||||
Partial index — dispatcher poller сканит только активные subscriptions
|
||||
(горячий path). Inactive sit без overhead.
|
||||
</comment>
|
||||
<sql>
|
||||
CREATE INDEX idx_webhook_subscriptions_active
|
||||
ON webhook_subscriptions (created_at)
|
||||
WHERE active = true;
|
||||
</sql>
|
||||
<rollback>
|
||||
<sql>DROP INDEX IF EXISTS idx_webhook_subscriptions_active;</sql>
|
||||
</rollback>
|
||||
</changeSet>
|
||||
|
||||
</databaseChangeLog>
|
||||
+117
@@ -0,0 +1,117 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<databaseChangeLog
|
||||
xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
|
||||
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.27.xsd">
|
||||
|
||||
<!--
|
||||
Webhook deliveries — tracking каждой попытки доставки event'а
|
||||
подписчику. Один (subscription_id, outbox_event_id) row на match,
|
||||
с retry counter и DLQ flag.
|
||||
|
||||
Lifecycle:
|
||||
1. Dispatcher видит unprocessed event matching subscription → INSERT row
|
||||
(status='pending', attempt_count=0).
|
||||
2. POST URL. Success (2xx) → status='delivered', delivered_at=now().
|
||||
3. Failure → status='retrying', attempt_count++, last_error=...,
|
||||
next_attempt_at = now() + backoff(attempt_count).
|
||||
4. После attempt_count >= 1000 → status='dlq', исключается из poller.
|
||||
|
||||
Backoff: 1m, 5m, 15m, 1h, 6h, 24h (exponential), capped 24h.
|
||||
-->
|
||||
|
||||
<changeSet id="0014-1-webhook-deliveries" author="ordinis">
|
||||
<createTable tableName="webhook_deliveries">
|
||||
<column name="id" type="BIGINT" autoIncrement="true">
|
||||
<constraints primaryKey="true" nullable="false"/>
|
||||
</column>
|
||||
<column name="subscription_id" type="UUID">
|
||||
<constraints nullable="false"
|
||||
foreignKeyName="fk_webhook_deliveries_subscription"
|
||||
references="webhook_subscriptions(id)"
|
||||
deleteCascade="true"/>
|
||||
</column>
|
||||
<column name="outbox_event_id" type="BIGINT">
|
||||
<constraints nullable="false"
|
||||
foreignKeyName="fk_webhook_deliveries_event"
|
||||
references="outbox_events(id)"
|
||||
deleteCascade="true"/>
|
||||
</column>
|
||||
<!-- pending / retrying / delivered / dlq -->
|
||||
<column name="status" type="VARCHAR(16)" defaultValue="pending">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="attempt_count" type="INTEGER" defaultValueNumeric="0">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="created_at" type="TIMESTAMPTZ" defaultValueComputed="now()">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="next_attempt_at" type="TIMESTAMPTZ" defaultValueComputed="now()">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="last_attempt_at" type="TIMESTAMPTZ"/>
|
||||
<column name="delivered_at" type="TIMESTAMPTZ"/>
|
||||
<column name="last_error" type="TEXT"/>
|
||||
<column name="last_status_code" type="INTEGER"/>
|
||||
<column name="dlq_at" type="TIMESTAMPTZ"/>
|
||||
</createTable>
|
||||
</changeSet>
|
||||
|
||||
<changeSet id="0014-2-webhook-deliveries-status-check" author="ordinis">
|
||||
<sql>
|
||||
ALTER TABLE webhook_deliveries
|
||||
ADD CONSTRAINT chk_webhook_delivery_status
|
||||
CHECK (status IN ('pending','retrying','delivered','dlq'));
|
||||
</sql>
|
||||
<rollback>
|
||||
<sql>
|
||||
ALTER TABLE webhook_deliveries DROP CONSTRAINT IF EXISTS chk_webhook_delivery_status;
|
||||
</sql>
|
||||
</rollback>
|
||||
</changeSet>
|
||||
|
||||
<changeSet id="0014-3-webhook-deliveries-poller-index" author="ordinis">
|
||||
<comment>
|
||||
Hot poller path: WHERE status IN ('pending','retrying') AND
|
||||
next_attempt_at <= now(). Partial index покрывает обе колонки.
|
||||
</comment>
|
||||
<sql>
|
||||
CREATE INDEX idx_webhook_deliveries_due
|
||||
ON webhook_deliveries (next_attempt_at)
|
||||
WHERE status IN ('pending', 'retrying');
|
||||
</sql>
|
||||
<rollback>
|
||||
<sql>DROP INDEX IF EXISTS idx_webhook_deliveries_due;</sql>
|
||||
</rollback>
|
||||
</changeSet>
|
||||
|
||||
<changeSet id="0014-4-webhook-deliveries-dlq-index" author="ordinis">
|
||||
<comment>Для UI listing DLQ + reset операций.</comment>
|
||||
<sql>
|
||||
CREATE INDEX idx_webhook_deliveries_dlq
|
||||
ON webhook_deliveries (dlq_at DESC)
|
||||
WHERE status = 'dlq';
|
||||
</sql>
|
||||
<rollback>
|
||||
<sql>DROP INDEX IF EXISTS idx_webhook_deliveries_dlq;</sql>
|
||||
</rollback>
|
||||
</changeSet>
|
||||
|
||||
<changeSet id="0014-5-webhook-deliveries-subscription-event-uniq" author="ordinis">
|
||||
<comment>
|
||||
Idempotency: один subscription × event = один row. Если dispatcher
|
||||
ловит retry где event/subscription уже создавал delivery — INSERT
|
||||
ON CONFLICT DO NOTHING.
|
||||
</comment>
|
||||
<sql>
|
||||
CREATE UNIQUE INDEX uniq_webhook_deliveries_subscription_event
|
||||
ON webhook_deliveries (subscription_id, outbox_event_id);
|
||||
</sql>
|
||||
<rollback>
|
||||
<sql>DROP INDEX IF EXISTS uniq_webhook_deliveries_subscription_event;</sql>
|
||||
</rollback>
|
||||
</changeSet>
|
||||
|
||||
</databaseChangeLog>
|
||||
@@ -22,5 +22,7 @@
|
||||
<include file="changes/0010-envers-tables.xml" relativeToChangelogFile="true"/>
|
||||
<include file="changes/0011-outbox-dlq.xml" relativeToChangelogFile="true"/>
|
||||
<include file="changes/0012-audit-log-ipaddress-varchar.xml" relativeToChangelogFile="true"/>
|
||||
<include file="changes/0013-webhook-subscriptions.xml" relativeToChangelogFile="true"/>
|
||||
<include file="changes/0014-webhook-deliveries.xml" relativeToChangelogFile="true"/>
|
||||
|
||||
</databaseChangeLog>
|
||||
|
||||
Reference in New Issue
Block a user