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:
Zimin A.N.
2026-05-06 14:42:10 +03:00
parent 81c959b7e2
commit d6787f00e9
7 changed files with 561 additions and 0 deletions
@@ -0,0 +1,153 @@
package cloud.nstart.terravault.ordinis.domain.webhook;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import java.time.OffsetDateTime;
import java.util.UUID;
/**
* Webhook delivery — одна попытка доставки event'а подписчику. Один (subscription_id,
* outbox_event_id) row на match (uniq index). Жизненный цикл:
*
* <pre>
* pending → retrying → delivered (2xx response)
* ↓
* dlq (после attempt_count >= 1000)
* </pre>
*
* <p>Backoff schedule: 1m, 5m, 15m, 1h, 6h, 24h (exponential), capped 24h.
* Computed в {@link #scheduleNextAttempt()}.
*/
@Entity
@Table(name = "webhook_deliveries")
public class WebhookDelivery {
public enum Status {
pending,
retrying,
delivered,
dlq
}
/** Backoff schedule in seconds. attemptCount-1 индексирует. После — last value. */
private static final long[] BACKOFF_SECONDS = {
60L, // 1 min
5 * 60L, // 5 min
15 * 60L, // 15 min
60 * 60L, // 1 h
6 * 60 * 60L, // 6 h
24 * 60 * 60L // 24 h
};
/** После этого attempt_count → DLQ flag. Совпадает с outbox cap. */
public static final int MAX_ATTEMPTS = 1000;
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "id")
private Long id;
@Column(name = "subscription_id", nullable = false)
private UUID subscriptionId;
@Column(name = "outbox_event_id", nullable = false)
private Long outboxEventId;
@Enumerated(EnumType.STRING)
@Column(name = "status", nullable = false, length = 16)
private Status status = Status.pending;
@Column(name = "attempt_count", nullable = false)
private int attemptCount = 0;
@Column(name = "created_at", nullable = false)
private OffsetDateTime createdAt;
@Column(name = "next_attempt_at", nullable = false)
private OffsetDateTime nextAttemptAt;
@Column(name = "last_attempt_at")
private OffsetDateTime lastAttemptAt;
@Column(name = "delivered_at")
private OffsetDateTime deliveredAt;
@Column(name = "last_error", columnDefinition = "TEXT")
private String lastError;
@Column(name = "last_status_code")
private Integer lastStatusCode;
@Column(name = "dlq_at")
private OffsetDateTime dlqAt;
protected WebhookDelivery() {}
public WebhookDelivery(UUID subscriptionId, Long outboxEventId) {
this.subscriptionId = subscriptionId;
this.outboxEventId = outboxEventId;
OffsetDateTime now = OffsetDateTime.now();
this.createdAt = now;
this.nextAttemptAt = now;
}
public void markDelivered(int statusCode) {
this.status = Status.delivered;
this.deliveredAt = OffsetDateTime.now();
this.lastAttemptAt = this.deliveredAt;
this.lastStatusCode = statusCode;
this.lastError = null;
}
/** Отметить попытку как failed и запланировать retry, либо DLQ если cap. */
public void markFailed(String error, Integer statusCode) {
this.attemptCount++;
this.lastAttemptAt = OffsetDateTime.now();
this.lastError = error;
this.lastStatusCode = statusCode;
if (this.attemptCount >= MAX_ATTEMPTS) {
this.status = Status.dlq;
this.dlqAt = this.lastAttemptAt;
return;
}
this.status = Status.retrying;
scheduleNextAttempt();
}
/** Reset DLQ flag — поллер снова возьмётся (e.g. оператор fixed receiver URL). */
public void resetDlq() {
this.status = Status.retrying;
this.dlqAt = null;
this.attemptCount = 0;
this.nextAttemptAt = OffsetDateTime.now();
}
private void scheduleNextAttempt() {
int idx = Math.min(this.attemptCount - 1, BACKOFF_SECONDS.length - 1);
long seconds = BACKOFF_SECONDS[idx];
this.nextAttemptAt = OffsetDateTime.now().plusSeconds(seconds);
}
public Long getId() { return id; }
public UUID getSubscriptionId() { return subscriptionId; }
public Long getOutboxEventId() { return outboxEventId; }
public Status getStatus() { return status; }
public int getAttemptCount() { return attemptCount; }
public OffsetDateTime getCreatedAt() { return createdAt; }
public OffsetDateTime getNextAttemptAt() { return nextAttemptAt; }
public OffsetDateTime getLastAttemptAt() { return lastAttemptAt; }
public OffsetDateTime getDeliveredAt() { return deliveredAt; }
public String getLastError() { return lastError; }
public Integer getLastStatusCode() { return lastStatusCode; }
public OffsetDateTime getDlqAt() { return dlqAt; }
public boolean isDlq() { return status == Status.dlq; }
}
@@ -0,0 +1,57 @@
package cloud.nstart.terravault.ordinis.domain.webhook;
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.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import java.time.OffsetDateTime;
import java.util.List;
import java.util.UUID;
public interface WebhookDeliveryRepository extends JpaRepository<WebhookDelivery, Long> {
/**
* Due for retry: status IN (pending, retrying) AND next_attempt_at <= now().
* Использует partial index {@code idx_webhook_deliveries_due}. Limit через
* Pageable чтобы dispatcher не выгребал >1000 за тик.
*/
@Query("""
SELECT d FROM WebhookDelivery d
WHERE d.status IN ('pending','retrying')
AND d.nextAttemptAt <= :now
ORDER BY d.nextAttemptAt ASC
""")
List<WebhookDelivery> findDue(@Param("now") OffsetDateTime now, Pageable pageable);
/** DLQ листинг для admin UI. Partial index {@code idx_webhook_deliveries_dlq}. */
Page<WebhookDelivery> findByStatus(WebhookDelivery.Status status, Pageable pageable);
/** Метрика {@code nsi_webhook_dlq_count}. */
long countByStatus(WebhookDelivery.Status status);
/**
* Idempotent insert (sub × event = uniq). На race между dispatcher tick'ами
* вторая вставка попадёт в conflict, ON CONFLICT DO NOTHING. Native query
* чтобы не тащить ON CONFLICT через JPA.
*/
@Modifying
@Query(value = """
INSERT INTO webhook_deliveries (subscription_id, outbox_event_id, status,
attempt_count, created_at, next_attempt_at)
VALUES (:subscriptionId, :outboxEventId, 'pending', 0, now(), now())
ON CONFLICT (subscription_id, outbox_event_id) DO NOTHING
""", nativeQuery = true)
int insertIfAbsent(
@Param("subscriptionId") UUID subscriptionId,
@Param("outboxEventId") Long outboxEventId);
/** История доставок одного event'а — для drilldown в UI. */
List<WebhookDelivery> findByOutboxEventIdOrderByCreatedAtDesc(Long outboxEventId);
/** История доставок одной подписки. */
Page<WebhookDelivery> findBySubscriptionIdOrderByCreatedAtDesc(
UUID subscriptionId, Pageable pageable);
}
@@ -0,0 +1,116 @@
package cloud.nstart.terravault.ordinis.domain.webhook;
import cloud.nstart.terravault.ordinis.domain.DataScope;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import org.hibernate.annotations.JdbcTypeCode;
import org.hibernate.type.SqlTypes;
import java.time.OffsetDateTime;
import java.util.List;
import java.util.UUID;
/**
* Webhook subscription — HTTP callback delivery параллельно с Kafka publishing.
* Для consumer'ов которые не могут Kafka subscribe (геопортал PHP, legacy admin-ui'и
* заказчиков, BI-системы).
*
* <p>Filter arrays: NULL/empty = match all events. Match logic в
* {@code WebhookDispatcher} — event matches subscription iff
* (scope_filter is null OR scope IN scope_filter) AND
* (dictionary_filter is null OR dictionary_name IN dictionary_filter) AND
* (event_type_filter is null OR event_type IN event_type_filter).
*
* <p>HMAC secret hex-encoded 64 chars (32 bytes random). Generated server-side
* на create, returned ONCE в response, потом hidden — admin-ui показывает
* только маскированный preview {@code sk_****abc123}.
*/
@Entity
@Table(name = "webhook_subscriptions")
public class WebhookSubscription {
@Id
@Column(name = "id")
private UUID id;
@Column(name = "name", nullable = false, length = 128)
private String name;
@Column(name = "url", nullable = false, length = 2048)
private String url;
/** TEXT[] postgres → List<String> via SqlTypes.ARRAY. */
@JdbcTypeCode(SqlTypes.ARRAY)
@Column(name = "scope_filter", columnDefinition = "TEXT[]")
private List<DataScope> scopeFilter;
@JdbcTypeCode(SqlTypes.ARRAY)
@Column(name = "dictionary_filter", columnDefinition = "TEXT[]")
private List<String> dictionaryFilter;
@JdbcTypeCode(SqlTypes.ARRAY)
@Column(name = "event_type_filter", columnDefinition = "TEXT[]")
private List<String> eventTypeFilter;
@Column(name = "hmac_secret", nullable = false, length = 128)
private String hmacSecret;
@Column(name = "active", nullable = false)
private boolean active = true;
@Column(name = "created_at", nullable = false)
private OffsetDateTime createdAt;
@Column(name = "created_by", nullable = false, length = 128)
private String createdBy;
@Column(name = "updated_at", nullable = false)
private OffsetDateTime updatedAt;
@Column(name = "description", columnDefinition = "TEXT")
private String description;
protected WebhookSubscription() {}
public WebhookSubscription(
String name,
String url,
String hmacSecret,
String createdBy) {
this.id = UUID.randomUUID();
this.name = name;
this.url = url;
this.hmacSecret = hmacSecret;
this.createdBy = createdBy;
OffsetDateTime now = OffsetDateTime.now();
this.createdAt = now;
this.updatedAt = now;
}
public void touch() {
this.updatedAt = OffsetDateTime.now();
}
public UUID getId() { return id; }
public String getName() { return name; }
public String getUrl() { return url; }
public List<DataScope> getScopeFilter() { return scopeFilter; }
public List<String> getDictionaryFilter() { return dictionaryFilter; }
public List<String> getEventTypeFilter() { return eventTypeFilter; }
public String getHmacSecret() { return hmacSecret; }
public boolean isActive() { return active; }
public OffsetDateTime getCreatedAt() { return createdAt; }
public String getCreatedBy() { return createdBy; }
public OffsetDateTime getUpdatedAt() { return updatedAt; }
public String getDescription() { return description; }
public void setName(String v) { this.name = v; touch(); }
public void setUrl(String v) { this.url = v; touch(); }
public void setScopeFilter(List<DataScope> v) { this.scopeFilter = v; touch(); }
public void setDictionaryFilter(List<String> v) { this.dictionaryFilter = v; touch(); }
public void setEventTypeFilter(List<String> v) { this.eventTypeFilter = v; touch(); }
public void setActive(boolean v) { this.active = v; touch(); }
public void setDescription(String v) { this.description = v; touch(); }
}
@@ -0,0 +1,20 @@
package cloud.nstart.terravault.ordinis.domain.webhook;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import java.util.List;
import java.util.UUID;
public interface WebhookSubscriptionRepository extends JpaRepository<WebhookSubscription, UUID> {
/**
* Активные подписки. Использует partial index {@code idx_webhook_subscriptions_active}
* (where active = true). Hot path в WebhookDispatcher.
*/
@Query("SELECT w FROM WebhookSubscription w WHERE w.active = true")
List<WebhookSubscription> findActive();
/** Метрика {@code nsi_webhook_subscriptions_active}. */
long countByActiveTrue();
}