Send API的幽灵调用:Test成功但Flow不触发的完整调试实录

·踩坑

Send API的幽灵调用:Test成功但Flow不触发的完整调试实录

🩺 症状

产品经理说:"这个需求很简单,配置一个Send API webhook就行,测试按钮都能成功"。我信了。

在Documind的API Integrations里配置Send API,Body Mode选JSON,自动生成默认模板:

{
  "event_type": "{{event_type}}",
  "workflow_run_id": "{{workflow_run_id}}",
  "timestamp": "{{timestamp}}"
}

点击Test API按钮,返回200 OK,测试成功。我以为完事了。

然后在Workflow的"Actions After Data Processing"阶段勾选Send Webhook,选择刚配置的Send API,保存成功。页面提示"Saved successfully"。重新打开这个阶段一看,Send Webhook的勾选状态和Send API选择都丢了,就像从来没配置过一样。

试了"Actions After Comparison"和"Actions After Run Completion",配置保存正常。运行Flow后,Receive API和Email通知都正常触发,但Send API像幽灵一样,Test按钮能成功,Flow runtime完全不触发

排查过程:

  • 检查前端日志:保存时payload有apiIntegrationIds: ["xxx"],页面提示成功
  • 检查后端日志:save接口返回200,没有报错
  • 检查数据库:Data Processing的policy字段没有api_integration_idswebhook_config
  • 检查Flow运行日志:comparison和run completion阶段有webhook dispatch记录,但data processing阶段完全没有

问了同事,他说"你是不是又踩了前后端字段命名的坑?"

🔬 根因

排查了5小时,发现这不是单一Bug,而是三重陷阱叠加:

陷阱1:默认模板缺少runtime.前缀

webhook-body-config.ts:

export const DEFAULT_WEBHOOK_NOTIFICATION_BODY_TEMPLATE = {
  event_type: "{{event_type}}",  // ❌ 缺少runtime.前缀
  workflow_run_id: "{{workflow_run_id}}",  // ❌ 缺少runtime.前缀
  timestamp: "{{timestamp}}"  // ❌ 缺少runtime.前缀
}

template-required-stage.ts:

function requiredStageForVariableRef(ref: string): Stage {
  if (ref.startsWith("runtime.")) {
    // ← 只有runtime.*前缀才会被识别为Data Processing阶段变量
    return parseRuntimeVariable(ref)
  }
  // ← 未知前缀的token会被保守判定为comparison阶段
  return "comparison"  // ← 默认返回comparison!
}

影响:Send API的默认JSON body使用{{event_type}}等无前缀变量,被阶段推断逻辑判定为"comparison阶段变量"。用户在Data Processing阶段选择这个Send API时,前端会提示"Send Webhook requires a selected send API",但实际上这个Send API在Data Processing阶段不可用(被判定为comparison阶段才能用)。

陷阱2:Schema字段丢弃

data_processing_schemas.py:

class DataProcessingPolicy(BaseModel):
    extraction_config: Optional[Dict] = None
    processing_config: Optional[Dict] = None
    # ❌ 缺少api_integration_ids和webhook_config字段!
    
    class Config:
        extra = "ignore"  # ← 关键:extra字段会被丢弃!

stage_config_service.py:

async def save_data_processing_config(run_id, config):
    policy = DataProcessingPolicy(**config["policy"])  # ← parse时丢弃api_integration_ids
    await db.update(policy)
    
    # ← rehydrate时用丢字段后的policy回填前端
    rehydrated = rehydrate_business_config(policy)
    return rehydrated  # ← 前端收到缺少api_integration_ids的数据

前端config-adapter.ts:

function adaptDataProcessingConfig(backendConfig) {
  if (!backendConfig.api_integration_ids) {
    // ← 没有api_integration_ids就清掉WEBHOOK channel
    channels = channels.filter(c => c !== "WEBHOOK")
  }
}

影响:前端保存时传了apiIntegrationIds,后端parse成DataProcessingPolicy时因extra="ignore"丢弃了这个字段。rehydrate时用丢字段后的数据回填前端,前端看到缺少api_integration_ids就清掉Send Webhook配置。保存成功但配置丢失。

陷阱3:前后端命名不统一

webhook_sender.py (runtime):

async def dispatch_webhooks(run_id, stage):
    requests = business_input.get("webhookConfig")  # ← 用camelCase字段名
    
    # ← WorkflowWebhookConfig schema是extra="forbid"
    config = WorkflowWebhookRequestConfig(**requests[0])
    # ← 如果字段名是snake_case,会报invalid_webhook_config

WorkflowWebhookRequestConfig schema:

class WorkflowWebhookRequestConfig(BaseModel):
    apiIntegrationId: str  # ← camelCase字段
    bodyMode: str
    customBody: Optional[str] = None
    
    class Config:
        extra = "forbid"  # ← 不允许snake_case字段!

影响:Test API会将前端camelCase request转为后端snake_case,但runtime部分路径直接校验保存的camelCase webhookConfig。如果保存时字段名不统一,runtime会返回invalid_webhook_config或跳过dispatch。这就是Test API成功但Flow不触发的根因。

三重陷阱叠加效果:

  1. 陷阱1:Data Processing阶段选择Send API时,前端校验消失但实际不可用
  2. 陷阱2:保存时后端丢弃配置,回填前端清掉勾选状态
  3. 陷阱3:Flow运行时校验失败或跳过dispatch,导致完全不触发

🔧 修复

修复需要同时解决三个问题:

修复1:默认模板加runtime.前缀

webhook-body-config.ts:

// ❌ 错误写法
export const DEFAULT_WEBHOOK_NOTIFICATION_BODY_TEMPLATE = {
  event_type: "{{event_type}}",
  workflow_run_id: "{{workflow_run_id}}"
}

// ✅ 正确写法
export const DEFAULT_WEBHOOK_NOTIFICATION_BODY_TEMPLATE = {
  event_type: "{{runtime.event_type}}",  // ← 加runtime.前缀
  workflow_run_id: "{{runtime.workflow_run_id}}",  // ← 加runtime.前缀
  timestamp: "{{runtime.timestamp}}"
}

修复2:Schema补充字段

data_processing_schemas.py:

# ❌ 错误写法
class DataProcessingPolicy(BaseModel):
    extraction_config: Optional[Dict] = None
    processing_config: Optional[Dict] = None
    
    class Config:
        extra = "ignore"  # ← 丢弃字段

# ✅ 正确写法
class DataProcessingPolicy(BaseModel):
    extraction_config: Optional[Dict] = None
    processing_config: Optional[Dict] = None
    api_integration_ids: Optional[List[str]] = None  # ← 新增字段
    webhook_config: Optional[Dict] = None  # ← 新增字段
    
    class Config:
        extra = "forbid"  # ← 改为forbid,防止未知字段

修复3:字段命名统一normalize

webhook_sender.py:

# ❌ 错误写法
requests = business_input.get("webhookConfig")
config = WorkflowWebhookRequestConfig(**requests[0])  # ← 直接用camelCase

# ✅ 正确写法
requests = business_input.get("webhookConfig")
# ← 统一转换为snake_case
normalized_request = normalize_webhook_config(requests[0])
config = WorkflowWebhookRequestConfig(**normalized_request)

def normalize_webhook_config(config):
    # ← camelCase转snake_case
    return {
        "api_integration_id": config.get("apiIntegrationId"),
        "body_mode": config.get("bodyMode"),
        "custom_body": config.get("customBody")
    }

验证方法

单元测试:

def test_data_processing_policy_accepts_webhook_fields():
    policy = DataProcessingPolicy(
        extraction_config={},
        api_integration_ids=["abc-123"],
        webhook_config={"bodyMode": "JSON"}
    )
    assert policy.api_integration_ids == ["abc-123"]  # ← 字段保存成功
    
def test_webhook_config_normalize():
    camel_config = {"apiIntegrationId": "abc", "bodyMode": "JSON"}
    normalized = normalize_webhook_config(camel_config)
    assert normalized["api_integration_id"] == "abc"

集成测试:

  1. 配置Send API,选择默认JSON body,检查阶段推断返回"data_processing"而非"comparison"
  2. 在Data Processing阶段勾选Send Webhook,保存后重新打开,检查勾选状态保留
  3. 运行Flow到Data Processing阶段,检查日志有webhook dispatch记录

数据库验证:

SELECT policy FROM data_processing_configs WHERE run_id='xxx';
-- ← 检查policy字段包含api_integration_ids和webhook_config

🛡️ 怎么避免

经验教训一句话:前后端字段命名要统一,schema要用extra='forbid'而非'ignore',默认模板要符合变量命名规范

最佳实践:

  1. 前后端字段命名统一:前端camelCase,后端snake_case,中间层要有normalize函数
  2. Schema用extra="forbid":extra="ignore"会悄悄丢弃字段,导致配置丢失;extra="forbid"会报错,更容易发现问题
  3. 默认模板符合规范:runtime变量必须用runtime.*前缀,避免阶段误判
  4. Test API和runtime共用逻辑:Test按钮和Flow runtime的webhook dispatch要用同一个函数,不要分开写两套

工具建议:

  • 字段命名检查工具:Python的pydantic自带字段校验,前端TypeScript也要定义完整interface
  • Schema测试:单元测试要覆盖"传未知字段会报错"的场景,不只是"传已知字段能保存"
  • 前后端字段映射表:维护一个文档记录前端字段名和后端字段名的映射关系

团队协作:

  • 前后端命名规范:团队要约定"前端camelCase,后端snake_case,中间层normalize"
  • Schema设计文档:定义Schema时要注明"哪些字段必填,哪些字段可选,extra='forbid'"
  • 默认模板文档:写清楚"默认模板的变量必须符合阶段推断规范,比如runtime.*前缀"

产品经理走后,我又花了5小时才定位到这三重陷阱。同一个坑踩两次才叫坑,踩一次那是学费——下次遇到"Test成功但Flow不触发",先检查字段命名、Schema extra、默认模板这三个地方。