Enterprise 版本
Webhook — 深度参考
NextPDF\Enterprise\Webhook 命名空间为作业事件提供按租户建立索引的 webhook 投递。其公开面为六个符号:WebhookManager、WebhookRegistration、WebhookPayload、WebhookDelivery、WebhookRetryPolicy 与 DeadLetterEntry。管理器按租户注册端点,并将作业事件派发给订阅的注册。投递引擎以 POST 发送一份经 HMAC-SHA256 签名的 JSON 负载,对照 Core SSRF 出站门校验每一个目标地址,采用指数退避重试,并将永久性失败记录到一个内存中的死信队列。自 3.1.0 起,签名将 X-NextPDF-Timestamp header 绑定进 MAC 基串,因此接收方会同时校验新鲜度与完整性。工作流层面的指南请参阅 Webhook。
可用性与授权
标题为“可用性与授权”的章节此能力随 NextPDF Enterprise(nextpdf/enterprise)提供,并通过 Enterprise 层级的授权信封激活。没有该授权的部署不会加载此能力的类。比较版本并获取授权。
webhook 面是一项 Enterprise 基础能力,只要安装了 Enterprise 包即可使用;没有单独的按功能划分的标志。NextPDF Core(Apache-2.0)与 NextPDF Pro 没有任何 webhook 注册或投递面;管理器、注册、负载、投递引擎、重试策略与死信条目仅在 nextpdf/enterprise 中提供。
公开 API 面
标题为“公开 API 面”的章节| 符号 | 参数 | 默认行为 | 返回 | 抛出或失败于 | 备注 |
|---|---|---|---|---|---|
WebhookManager::__construct | WebhookDelivery $delivery、?LoggerInterface $logger = null | 创建一个带空的内存注册索引的管理器 | 新的 WebhookManager | 不抛出 | 注册按租户建立索引 |
WebhookManager::register | TenantContext $tenant、WebhookRegistration $registration | 将该注册追加到调用方租户的索引 | void | 当注册的租户与上下文租户不匹配时抛出 InvalidArgumentException | 跨租户注册在存储前即被拒绝 |
WebhookManager::unregister | TenantContext $tenant、string $registrationId | 将匹配的注册替换为一个已停用的副本 | bool | 不抛出;当找不到该 id 时返回 false | 软停用;历史被保留 |
WebhookManager::activeRegistrations | TenantContext $tenant | 将该租户的注册筛选为活跃的那些 | list<WebhookRegistration> | 不抛出 | 只有调用方租户的注册可见 |
WebhookManager::dispatch | TenantContext $tenant、JobEvent $event | 将该事件投递给每一个订阅了该事件类型的活跃注册 | int(成功投递数) | 当事件数据无法进行 JSON 编码时传播 JsonException;投递失败不抛出 | 每次注册投递都会生成一个新的 32 位十六进制投递 id |
WebhookRegistration::__construct | string $id、string $tenantId、string $url、array $events、string $secret、bool $active = true、?string $description = null | 逐字存储所提供的值 | 新的 WebhookRegistration | 未声明 @throws;在 strict_types 下参数类型不匹配时 PHP 抛出 TypeError | final readonly;$events 为空表示订阅全部事件 |
WebhookRegistration::subscribesTo | JobEventType $eventType | 当 $events 为空或包含该类型时为 true | bool | 不抛出 | 严格恒等比较 |
WebhookRegistration::deactivate | — | 返回一个非活跃副本 | self | 不抛出 | 原实例保持不变 |
WebhookPayload::fromJobEvent | JobEvent $event、string $tenantId、string $deliveryId | 从事件复制作业 id、事件类型、数据与时间戳 | self | 不抛出 | dispatch 使用的静态工厂 |
WebhookPayload::toJson | — | 将六字段 body 序列化,斜杠不转义 | non-empty-string | 当事件数据无法进行 JSON 编码时抛出 JsonException | JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES |
WebhookPayload::toArray | — | 以关联数组形式返回 body | array<string, mixed> | 不抛出 | 时间戳按 RFC 3339 扩展格式格式化 |
WebhookPayload::signedTimestamp | — | Unix 秒表示的事件时间,钳制为不小于零 | int<0, max> | 不抛出 | 作为 X-NextPDF-Timestamp 发出并绑定进 MAC |
WebhookPayload::sign | string $secret | 对基串 {signedTimestamp}.{jsonBody} 计算 HMAC-SHA256 | non-empty-string(十六进制) | 当 body 无法编码时经由 toJson() 抛出 JsonException | 以密码学方式将时间戳 header 绑定到 body |
WebhookDelivery::__construct | ClientInterface $httpClient、RequestFactoryInterface $requestFactory、StreamFactoryInterface $streamFactory、WebhookRetryPolicy $retryPolicy = new WebhookRetryPolicy()、?LoggerInterface $logger = null | 带空死信队列的 PSR-18/PSR-17 投递引擎 | 新的 WebhookDelivery | 不抛出 | 默认策略:5 次尝试、1 s 基础、300 s 上限 |
WebhookDelivery::deliver | WebhookRegistration $registration、WebhookPayload $payload | 以 POST 发送已签名负载,逐次尝试进行 SSRF 出站校验并采用指数退避 | bool | 当 body 无法编码时在首次尝试前抛出 JsonException;否则不抛出——false 表示负载被路由到死信队列 | 仅在 2xx 响应时为 true |
WebhookDelivery::deadLetters | — | 返回所有已记录的条目 | list<DeadLetterEntry> | 不抛出 | 内存中,进程作用域 |
WebhookDelivery::clearDeadLetters | — | 清空死信队列 | void | 不抛出 | 不可逆;若需要重放请先导出条目 |
WebhookRetryPolicy::__construct | int $maxRetries = 5、int $baseDelaySeconds = 1、int $maxDelaySeconds = 300 | 存储策略值 | 新的 WebhookRetryPolicy | 未声明 @throws;参数文档标注为 positive-int | $maxRetries 计的是总尝试次数 |
WebhookRetryPolicy::delayForAttempt | int $attempt | baseDelaySeconds × 2^(attempt − 1),以 maxDelaySeconds 封顶 | positive-int | 不抛出 | 尝试编号从 1 开始 |
WebhookRetryPolicy::shouldRetry | int $currentAttempt | 当当前尝试低于最大值时为 true | bool | 不抛出 | 最后一次尝试之后会跳过等待 |
WebhookRetryPolicy::default | — | 5 次尝试、1 s 基础、300 s 上限 | self | 不抛出 | 静态工厂;生产默认 |
WebhookRetryPolicy::aggressive | — | 10 次尝试、2 s 基础、600 s 上限 | self | 不抛出 | 面向关键端点的静态工厂 |
DeadLetterEntry::__construct | string $id、string $registrationId、WebhookPayload $payload、int $attempts、string $lastError、?int $lastHttpStatus、DateTimeImmutable $failedAt、bool $replayed = false | 逐字存储失败记录 | 新的 DeadLetterEntry | 未声明 @throws;在 strict_types 下抛出 TypeError | final readonly;$lastHttpStatus 为 null 表示传输失败 |
DeadLetterEntry::markReplayed | — | 返回一个 replayed = true 的副本 | self | 不抛出 | id 相同;原条目保持不变 |
public function __construct( private readonly WebhookDelivery $delivery, private readonly ?LoggerInterface $logger = null,) {}
public function register(TenantContext $tenant, WebhookRegistration $registration): void
public function unregister(TenantContext $tenant, string $registrationId): bool
public function activeRegistrations(TenantContext $tenant): array
public function dispatch(TenantContext $tenant, JobEvent $event): intpublic function __construct( public string $id, public string $tenantId, public string $url, public array $events, public string $secret, public bool $active = true, public ?string $description = null,) {}
public function subscribesTo(JobEventType $eventType): bool
public function deactivate(): selfpublic static function fromJobEvent( JobEvent $event, string $tenantId, string $deliveryId,): self
public function toJson(): string
public function toArray(): array
public function signedTimestamp(): int
public function sign(string $secret): stringpublic function __construct( private readonly ClientInterface $httpClient, private readonly RequestFactoryInterface $requestFactory, private readonly StreamFactoryInterface $streamFactory, private readonly WebhookRetryPolicy $retryPolicy = new WebhookRetryPolicy(), private readonly ?LoggerInterface $logger = null,) {}
public function deliver(WebhookRegistration $registration, WebhookPayload $payload): bool
public function deadLetters(): array
public function clearDeadLetters(): voidpublic function __construct( public int $maxRetries = 5, public int $baseDelaySeconds = 1, public int $maxDelaySeconds = 300,) {}
public function delayForAttempt(int $attempt): int
public function shouldRetry(int $currentAttempt): bool
public static function default(): self
public static function aggressive(): selfpublic function __construct( public string $id, public string $registrationId, public WebhookPayload $payload, public int $attempts, public string $lastError, public ?int $lastHttpStatus, public DateTimeImmutable $failedAt, public bool $replayed = false,) {}
public function markReplayed(): self行为契约
标题为“行为契约”的章节- 注册按租户建立索引。
register()会拒绝其租户标识符与调用上下文不匹配的注册。unregister()是一次软停用:注册被替换为一个非活跃副本,在保留历史的同时将其从未来的派发中排除。 dispatch()只遍历调用方租户中订阅了所派发事件类型的活跃注册。订阅事件列表为空意味着订阅全部事件。返回值计的是成功投递数。- 每次投递都是一个带 JSON body 和五个 header 的 HTTP POST:
Content-Type: application/json、X-NextPDF-Signature(sha256=<hex>)、X-NextPDF-Timestamp(unix 秒)、X-NextPDF-Delivery-Id与X-NextPDF-Event。 - JSON body 字段为
delivery_id、job_id、event_type、data、timestamp(RFC 3339 扩展)与tenant_id,序列化时斜杠不转义。事件类型值来自nextpdf/core中的JobEventType:progress、completed、failed、cancelled。 - 签名方案(3.1.0 中变更,破坏性)。 HMAC-SHA256 基串为
{signedTimestamp}.{jsonBody},以注册密钥为键——而非仅对 body。X-NextPDF-Timestamp值是 MAC 的时间戳分量,因此被篡改或被重放的时间戳 header 会使签名失效。 - 接收方校验:读取
X-NextPDF-TimestampheaderT;当T超出可接受的新鲜度窗口(例如 300 s)时拒绝;对收到的原始字节重新计算hash_hmac('sha256', T . '.' . rawBody, secret);在剥除sha256=前缀后以恒定时间与 header 值比较。 - body、签名与投递 id 每次投递只计算一次,并在各次重试尝试间保持不变。
- SSRF 出站门。 在每次尝试之前,目标 URL 都会通过 Core 的
UrlValidator::validateExternalUrl()门:仅允许 HTTPS 方案;回环、私有、保留、运营商级 NAT、云元数据,以及内嵌 IPv4 的 IPv6 过渡范围均被阻止;主机名会经 DNS 解析(A 与 AAAA),无法解析的主机以失败关闭方式被拒绝。被阻止的 URL 绝不会发送:尝试循环中止,负载直接路由到死信队列,最后错误为Blocked SSRF destination:,HTTP 状态为 null。 - 逐次尝试的结果分类:2xx 为成功并立即返回;除 429 外的 4xx 为终态,直接进入死信;其余每一种结果——3xx、429、5xx 或传输异常——都可重试,最多重试到策略规定的总尝试次数。
- 退避是指数级的:下一次尝试前的等待为
baseDelaySeconds × 2^(attempt − 1),以maxDelaySeconds封顶。最后一次尝试之后会跳过等待。 - 当没有任何尝试成功时,一条
DeadLetterEntry会记录一个唯一 id、注册 id、原始负载、尝试计数(被钳制到策略上限)、最后一条错误消息、最后的 HTTP 状态(传输失败或 SSRF 阻止时为 null),以及失败时间戳。 - 死信队列在内存中,且作用域为进程生命周期。
markReplayed()产出一个带标记的副本;它不会重新发送,且队列保留原条目。
边界情形与失败模式
标题为“边界情形与失败模式”的章节- 事件列表为空。 该注册会收到每一种事件类型。当接收方不应看到全部事件时,请显式限定该列表的范围。
- 终态 4xx 与传输失败之别。 一次 4xx 拒绝会记录一个已填充的
lastHttpStatus;一次连接失败会记录 null。可用该 null 来区分接收方拒绝与传输失败。 - 被 SSRF 阻止的目标。 指向 HTTP、私有、回环或元数据地址的注册会在首次尝试即进入死信,错误为
Blocked SSRF destination:,状态为 null。不会发出任何出站请求。请修正 URL 后重新注册。 - 升级后的旧接收方。 仍在校验 3.1.0 之前仅对 body 的 HMAC 的接收方,面对 3.1.0 的投递会失败关闭。请将接收方迁移到
{timestamp}.{body}基串,并消费X-NextPDF-Timestamp。 - 无法编码的事件数据。
toJson()与sign()会抛出JsonException,它在任何尝试发生之前就从deliver()与dispatch()传播出来。 - 同步阻塞。
deliver()会在各次尝试之间内联休眠。在 default 策略下累计退避达到 15 s,在 aggressive 策略下约为 17 分钟。当接收方延迟不可信时,请从队列 worker 中派发。 - 尝试计数钳制。 即使内部循环计数器在耗尽时越过了策略上限,所记录的尝试计数也绝不会超过策略上限。
- 队列增长与持久性。 死信队列在进程内无界增长,并在重启时消失。当需要持久重放时,请先经由
deadLetters()导出条目并在外部持久化,再调用clearDeadLetters()。 - 重放由运营方驱动。 重新投递意味着用该条目的负载再次调用
deliver();markReplayed()只是在一个副本上记录这一事实。 - DNS 重绑定残留风险。 URL 会在每次尝试时被重新校验,这会收窄但并不关闭重绑定窗口:PSR-18 抽象无法将连接固定到已校验的 IP。在该残留风险要紧之处,请添加网络层出站控制。
- 密钥处理。 注册密钥是一项凭据。HMAC 仅认证完整性与来源——它不提供机密性。请勿将接收方不应看到的数据放入事件负载。
FIPS-mode 行为
标题为“FIPS-mode 行为”的章节负载签名是经由 PHP 的 hash_hmac() 的 HMAC-SHA256,因此它依赖宿主密码学提供方。在受 FIPS 约束的构建中,非批准原语会在密码学边界处失败,而非降级。webhook 层不附加自身的任何密码学策略。
符合性
标题为“符合性”的章节- 负载认证实现了 HMAC,即 FIPS PUB 198-1 §1 的带密钥哈希消息认证码,以 SHA-256 实例化。
- 重放防护遵循 OWASP Cheat Sheet Series 的 webhook 安全指导:事件时间戳在一个专用 header 中传输,并被播入签名计算,因此被篡改的时间戳会校验失败。
- body 时间戳使用 RFC 3339 扩展日期时间格式。代码声明:本页未从 RAG 语料库检索 RFC 3339。
- 这些是基于产品源码与所引条款的能力陈述。NextPDF 不对此面作出任何符合性或认证声明。
开发说明
标题为“开发说明”的章节- 所有类均声明
strict_types=1且为final;WebhookRegistration、WebhookPayload、WebhookRetryPolicy与DeadLetterEntry为final readonly,带提升的 public 属性。 - 该模块带有
2.2.0的@since注解;绑定时间戳的签名方案是 3.1.0 中一处有记录的破坏性变更。 - 投递引擎接受 PSR-18/PSR-17 抽象,因此一个 mock HTTP 客户端即可离线演练完整的发送、重试与死信路径。logger 默认为 null;请在生产环境中注入一个 PSR-3 logger,否则失败只会通过返回值体现。
- 接收方实现应使用
hash_equals()进行签名比较,并对X-NextPDF-Timestamp强制一个新鲜度窗口。 - 建议的边界测试:租户不匹配的注册、事件列表为空的扇出、终态 4xx、重试耗尽、被 SSRF 阻止的 URL、针对固定向量的时间戳篡改签名拒绝,以及死信尝试计数钳制。
发布边界
标题为“发布边界”的章节本页仅记录外部可观察的行为与受支持的公开 API 面。内部命名空间路径、辅助类、机制表、runbook 文件名与工单前缀均不在范围之内。
另请参阅
标题为“另请参阅”的章节- Webhook — NextPDF Enterprise — 能力页面:工作流、配置与完整的注册示例。
- SaaS — 深度参考 — 租户身份、API 密钥与配额;
TenantContext的来源。 - Metering — 深度参考 — 采用相同 PSR-18 投递纪律的用量计量扇出。