Webhooks 最容易出现的假完成是:生产者类、消费者类、topic 常量和测试各自存在,但没有任何一个测试证明真实消息能从业务事务进入 CAP,再以消费者期待的 wire payload 到达 HTTP 目标。本章以跨模块契约为验收对象。
1. 五份事实源目前不一致
一个可投递事件至少经过:
- 业务模块实际 publisher/topic;
- Webhooks 治理
EventSubscriptionCatalog; - CAP consumer 的
[CapSubscribe]; IWebhookEventTypeRegistry;- 外部订阅 EventType + required Client Scope。
目前 “should equal” 的两段都存在漂移。
2. 源码审查矩阵
| 意图 | 治理目录声明 | 运行时消费 topic / payload | 实际生产者 | 结论 |
|---|---|---|---|---|
| 文件定稿 | ...Files.Contracts.FileFinalizedIntegrationEvent | files.finalized / PlatformWebhookEventEnvelope | IIntegrationEventPublisher<FileAssetSummary>;无 IntegrationTopic,topic 为 BitzOrcas.Platform.Files.Contracts.FileAssetSummary,payload 为 summary | topic 与 payload 均不匹配,治理类型名也不存在 |
| 文件删除 | 治理目录没有声明 | files.deleted / envelope | 同样发布 FileAssetSummary 类型全名 | 不匹配且治理漏项 |
| 发票签发 | ...InvoiceIssuedIntegrationEvent | billing.invoice-issued / envelope | 发布同 typed event,默认 topic 确为类型全名 | 治理接近生产者,但 runtime consumer 不匹配 |
| 工单打开 | ...Tickets.Contracts.TicketOpenedIntegrationEvent | tickets.opened / envelope | 领域事件 [IntegrationTopic("ticket.opened")],当前 dispatch wire 不是 envelope | 三处 topic/type 不一致 |
| Workflow 开始/完成 | 治理目录没有声明 | workflow.started/completed / envelope | 当前 Workflow 主要发 NotificationMessage,没有这两个 envelope publisher | 没有实际接线 |
| API 版本弃用 | 治理目录没有声明 | api.version.deprecated / envelope | 示例端点直接发布正确 topic + envelope | CAP 可到达,但 registry 未注册,DeliveryService 拒绝 |
WebhookEventTypes 注释声称值由 CAP topic、订阅 EventType 和外部 payload 共享,当前代码没有实现这一承诺。
3. CAP consumer 还会吞掉投递结果
WebhookPlatformEventConsumer.DeliverAsync await 投递服务,但不检查返回的 Result。未注册 EventType、仓储失败等 typed failure 不会抛异常,CAP 会把消费视为完成。DeliveryService 又忽略单订阅失败,所以消息可能在 CAP 与 Webhook 两层都没有可重试错误。
// topic 由入口固定,envelope 只承载该 topic 的版本化事实。private async Task DeliverAsync( string eventType, PlatformWebhookEventEnvelope envelope, CancellationToken cancellationToken){ var result = await deliveryService.DeliverAsync( new WebhookEvent( envelope.EventId, envelope.TenantId, eventType, envelope.PayloadJson, envelope.OccurredAt), cancellationToken);
if (result.IsFailure) { // 只有可重试基础设施错误才应抛回 CAP;契约错误应告警并隔离。 // 生产代码应按错误类别区分 terminal configuration 与 transient infrastructure。 throw new WebhookEventConsumptionException(result.Error); }}不能把所有失败都盲目 throw:EventType 未注册是配置/契约错误,无限 CAP retry 没用;数据库暂时不可用则应 retry。需要稳定错误分类和告警。
4. 选择唯一、版本化的 envelope
推荐由事件 owner 定义 typed integration event,并显式 [IntegrationTopic("platform.files.finalized.v1")]。Webhooks consumer 接收同一个 typed contract,或由专门 adapter 转成通用外部 envelope;不要让每个模块手拼 JSON string。
// 显式 topic 避免默认类型全名随 namespace 重构而漂移。[IntegrationTopic("platform.files.finalized.v1")]public sealed record FileFinalizedV1( string EventId, string TenantId, string FileId, string FileName, string ContentType, long Size, string ContentHash, // OccurredAt 是业务事实时间,不是 CAP 消费或 HTTP 发送时间。 DateTimeOffset OccurredAt) : IIntegrationEvent;外部 payload 仍可包一层公共 envelope:
{ "specVersion": "1.0", "eventId": "019c48cb7ba47000953d5af574ddc531", "eventType": "platform.files.finalized.v1", "occurredAt": "2026-07-15T08:00:00Z", "tenantId": "tenant-a", "data": { "fileId": "file-42", "fileName": "contract.pdf", "contentType": "application/pdf", "size": 482193, "contentHash": "sha256:..." }}字段分级要由 owner 审批。不要把内部聚合完整序列化到外部;TenantId 是否暴露、文件名是否敏感、Ticket 描述是否含个人信息,都需单独策略。
5. Consumer contract test 应验证什么
独立 contract test 不引用生产者的常量来构造 expected topic,否则两边一起改错仍会通过。测试 artifact 至少冻结:
- topic 字符串;
- content type / serializer options;
- envelope 必填字段与类型;
- EventId/TenantId/OccurredAt 语义;
- data schema 与向后兼容规则;
- Webhooks registry 和 required scope;
- HMAC test vector;
- 同一事件从 producer 到 fake HTTP receiver 的端到端结果。
[Fact]public async Task File_finalized_v1_should_reach_external_receiver(){ // 字面量由消费者契约拥有,不能从生产者常量导入。 // 字面量属于消费者契约,不从生产者常量读取。 const string expectedTopic = "platform.files.finalized.v1";
await files.FinalizeAsync(fileId, cancellationToken); var capMessage = await capOutbox.WaitForAsync(expectedTopic, cancellationToken); capMessage.Json.ShouldContain("\"eventId\""); capMessage.Json.ShouldContain("\"tenantId\"");
var request = await receiver.WaitForEventAsync(cancellationToken); // HTTP 层同时验证 topic 传播和一次性 secret 的真实签名。 request.Headers["X-BitzOrcas-Event-Type"].ShouldBe(expectedTopic); receiver.VerifySignature(request, oneTimeSecret).ShouldBeTrue();}6. Schema 演进
兼容规则:新增可选字段可留在 v1;删除/重命名/改变含义/单位/枚举封闭性通常升 v2。消费者必须忽略未知字段;生产者在 sunset 前双发或提供迁移窗口。EventId 在双发 v1/v2 时是否相同要明确:相同表示同一事实的两种表示,接收端去重键必须包含 EventType/version。
事件 registry 当前是单例内存 Dictionary,Register 会覆盖同名 scope,没有冲突检测、版本元数据或 owner。生产应用应由 compile-time governance catalog 生成不可变 registry,并在启动时验证 topic 唯一和 producer/subscriber closure。
7. OpsExtension 死信操作
OpsExtension 提供:
- 按当前 TenantId 分页查询 DeadLettered delivery;
- 调用 WebhookDeliveryService 做真实手动重投;
- 软删除死信行;
- 并列查看/重投 CAP failed message;
- RetryDeadLetter 后写
IActivityAuditSink。
Webhooks 自己还有 POST /api/webhooks/deliveries/{id}/retry,没有显式 activity audit。两条运维入口权限不同、审计不同,容易绕过审批。商业交付应选定单一 operator path;普通集成管理员与平台值班员分权。
8. 当前测试证据
已有:
WebhookSignatureTests:确定性、密钥/载荷篡改、Verify;WebhookPlatformTests:基础重试分类、摘要不泄密、顺序幂等、Scope 拒绝、原 payload 重放、Succeeded 禁重试、严格 JSON、租户拒绝;WebhookProductionPolicyTests:fail-closed、CIDR 基础、Redis 不可用、dead-letter port;WebhookProductionAdapterContractTests:真实 Redis 跨实例限流与 readiness;WebhookProductionOperationsDrillTests:缺配置、CIDR 越界和 Redis 故障日志;- 双 ORM parity:订阅、投递、payload、租户隔离;
- Architecture tests:Infrastructure 不依赖具体 ORM、统一模型、read-store 使用。
这些测试没有证明任何 Files/Billing/Tickets/Workflow 事件被 WebhookPlatformEventConsumer 实际消费,也没有真实 HTTP receiver、九条 endpoint contract、自动 retry、redirect/DNS rebinding、重复 Save 后签名、轮换重叠或并发幂等。
9. 可观测性与 SLO
建议分三段 SLI:
- producer transaction → CAP outbox committed;
- CAP published → delivery row scheduled;
- delivery scheduled → receiver 2xx。
否则一个“成功率”无法区分 topic 根本没接上、CAP 堆积、Guard 拒绝与客户 endpoint 失败。SLO 可按付费等级设置,例如 99.9% 可投递事件在 5 分钟内成功或进入可操作死信;不要把客户永久 400 算平台可用性故障,但要计入交付结果。
Trace 应贯穿 producer trace、CAP message、EventId、DeliveryId、AttemptId 和 HTTP client span。当前 delivery log 没有 correlation/trace 或 attempt history,需要扩展持久化契约。
10. GA 门禁
契约
- 每个公开事件有 owner、version、topic、schema、scope、PII classification 和 sunset policy;
- producer、治理目录、consumer、registry 的 compile-time closure test 全绿;
- 独立 consumer contract test 不共享错误常量;
- 九条 HTTP OpenAPI/Problem Details 契约固定。
可靠性
- 持久化 retry scheduler、lease、jitter、Retry-After 和 attempt history;
- 并发幂等、崩溃窗口、Complete 失败与停机恢复通过;
- dead-letter 批量回放限速、审批、审计和停止条件完成;
- payload retention/encryption/erasure 策略落地。
安全
- DNS pinning/egress proxy、redirect、global deny ranges、端口、timeout 和 payload size 关闭红线;
- DataProtection key ring 备份恢复与双重加密回归通过;
- versioned KeyId 轮换协议和紧急泄露 Runbook 通过;
webhooks.deliveryentitlement 真正运行时生效。
运维
- readiness + synthetic delivery + adapter report 联合门禁;
- producer/CAP/delivery 三段 dashboard 与告警;
- Redis、DNS、证书、客户 429/5xx、密钥环故障演练;
- 值班可在不查数据库、不看 secret/payload 的情况下定位并恢复。
11. 全局核查命令
# 同时查看 producer、治理、consumer 三层,不能只查 Webhooks 目录。# 暴露 topic 三套事实源;期望修复后每个业务事件只有一个版本化值。rg -n "CapSubscribe|IntegrationTopic|EventDefinition|files\.finalized|ticket[s]?\.opened|invoice-issued" \ src/Platform/Files src/Platform/PlatformBilling src/Platform/Tickets \ src/Platform/Workflow src/Platform/Webhooks -g '*.cs'
# consumer 不应忽略 Result,DeliveryService 不应吞单订阅失败。rg -n "DeliverAsync\(|IsFailure|GetValueOrThrow|logs.Add" \ src/Platform/Webhooks/BitzOrcas.Platform.Webhooks.{Application,Infrastructure} -g '*.cs'
# 当前只应找到 API 弃用示例构造平台 envelope;修复后各 owner 都有契约测试。rg -n "new PlatformWebhookEventEnvelope|PlatformWebhookEventEnvelope" src tests -g '*.cs'
# 测试名用于暴露仍缺失的真实端点、并发和网络安全场景。# 测试名应出现 endpoint、consumer contract、receiver、concurrent、rotation、rebind/redirect。rg -n "Webhook.*(Endpoint|Consumer|Receiver|Concurrent|Rotation|Redirect|Rebind)" tests -g '*.cs'返回 Webhooks 总览 · 投递与重试 · OpsExtension · Files · Tickets