第三方回调的状态映射完整性

5 min read
Zekari
Purikura 项目系统设计API 集成回调处理状态同步

当任务完成但无法追踪

KIE.AI 的视频生成完成了,回调到达了,数据库更新了 generated_video_url。看起来一切正常。

直到你需要查询任务状态。

SELECT id, vendor_task_id, status, generated_video_url
FROM ai_generations
WHERE id = 'job-123';

vendor_task_id 是 NULL。

这意味着什么?你知道任务完成了,但不知道是 KIE.AI 的哪个任务。当用户报告"我的视频在厂商那边失败了",你无法向 KIE.AI 查询。当你想对账成本,你无法匹配厂商的账单记录。

系统失去了追溯能力。

缺失的桥梁

第三方集成有两套 ID 系统:

  • 我们的 job_id - 在我们的数据库中唯一标识任务
  • 厂商的 task_id - 在厂商系统中唯一标识任务

这两个 ID 必须建立映射关系。映射的时机是回调处理时。

💡 Click the maximize icon to view in fullscreen

KIE.AI 的回调数据中包含 taskId 字段,这是厂商系统的任务标识。但我们的代码只提取了 resultUrls,没有保存 taskId

// ❌ 错误:只保存结果,不保存映射
const { resultUrls } = callbackData.data;
await supabase
  .from('ai_generations')
  .update({
    generated_video_url: resultUrls[0],
    status: 'completed'
  })
  .eq('id', jobId);

这个遗漏的代价是:每次需要追溯任务时,都要靠猜测或人工查找。

// ✅ 正确:保存完整的映射关系
const { taskId, resultUrls } = callbackData.data;

// 提取 vendor_task_id
const vendorTaskId = typeof taskId === 'string' ? taskId : taskId?.id;

await supabase
  .from('ai_generations')
  .update({
    vendor_task_id: vendorTaskId,  // 关键:保存映射
    generated_video_url: resultUrls[0],
    status: 'completed',
    updated_at: new Date().toISOString()
  })
  .eq('id', jobId);

console.log(`[Job ${jobId}] Mapped to vendor task: ${vendorTaskId}`);

修复位置: workers/src/api-gateway.js:1913

映射不是可选项,是第三方集成的基础设施。没有映射,系统就是黑盒。

类型决定字段

KIE.AI 支持两种生成类型:视频(T2V/I2V)和图像。

早期代码假设所有任务都是视频:

// ❌ 错误:总是更新 video_url
await supabase
  .from('ai_generations')
  .update({ generated_video_url: url })
  .eq('id', jobId);

当处理图像任务时,generated_image_url 保持为空,而 generated_video_url 被错误地填充了图像 URL。

数据的类型语义被破坏了。

💡 Click the maximize icon to view in fullscreen

修复的关键是:根据任务类型选择字段。

// 获取任务类型
const { data: job } = await supabase
  .from('ai_generations')
  .select('type')
  .eq('id', jobId)
  .single();

const taskType = job.type; // 'video' 或 'image'

// 根据类型选择字段
const updateData = {
  vendor_task_id: vendorTaskId,
  status: 'completed',
  updated_at: new Date().toISOString()
};

if (taskType === 'video') {
  updateData.generated_video_url = finalUrl;
} else if (taskType === 'image') {
  updateData.generated_image_url = finalUrl;
}

await supabase
  .from('ai_generations')
  .update(updateData)
  .eq('id', jobId);

console.log(`[Job ${jobId}] Type: ${taskType}, URL: ${finalUrl}`);

修复位置: workers/src/api-gateway.js:1841-1853

类型不是标签,是数据的语义约束。正确的类型处理保证了数据的可查询性。

存储的边界

KIE.AI 的回调返回一个临时 URL,指向厂商的服务器。这个 URL 有时效性。

早期方案是将这个 URL 直接存入数据库。但这会导致:

  • 链接过期后,用户无法访问生成的内容
  • 依赖外部服务的可用性
  • 无法控制访问权限和统计

解决方案是文件转存:下载文件,上传到我们的存储,保存我们的 URL。

但应该存到哪里?Supabase Storage 还是 Cloudflare R2?

Supabase Storage 的特点:

  • 与 PostgreSQL 深度集成,支持细粒度的权限控制
  • 适合需要权限管理的文件(用户头像、私密文档)
  • 按流量和存储双重计费,高频访问场景成本较高

Cloudflare R2 的特点:

  • 对象存储,零出口流量费用(下载不计费)
  • 适合大文件、公开内容、高频访问场景
  • 可绑定自定义域名,全球 CDN 加速

对于 AI 生成的视频和图像:

  • 文件大(视频可达 50MB+)
  • 访问频繁(用户反复查看作品)
  • 权限简单(已购买即可访问)

结论:选择 R2。

// 1. 从 KIE.AI 下载文件
const response = await fetch(kieUrl);
const arrayBuffer = await response.arrayBuffer();
const fileSize = (arrayBuffer.byteLength / 1024 / 1024).toFixed(2);

console.log(`[Job ${jobId}] Downloaded file. Size: ${fileSize} MB`);

// 2. 生成 R2 存储路径
const fileExtension = taskType === 'video' ? 'mp4' : 'png';
const uniqueId = crypto.randomUUID();
const r2Key = `${taskType}s/${jobId}/${uniqueId}.${fileExtension}`;

// 3. 上传到 R2
await env.CREATIONS_BUCKET.put(r2Key, arrayBuffer, {
  httpMetadata: {
    contentType: taskType === 'video' ? 'video/mp4' : 'image/png'
  }
});

// 4. 生成公开访问 URL
const publicUrl = `https://s.purikura.io/${r2Key}`;

console.log(`[Job ${jobId}] Uploaded to R2: ${publicUrl}`);

// 5. 更新数据库
const updateData = {
  vendor_task_id: vendorTaskId,
  status: 'completed',
  [taskType === 'video' ? 'generated_video_url' : 'generated_image_url']: publicUrl
};

await supabase
  .from('ai_generations')
  .update(updateData)
  .eq('id', jobId);

修复位置: workers/src/api-gateway.js:1895-1909

存储的边界不是技术选择,是成本和架构的权衡。

API 的演化

早期的回调端点使用查询参数传递 job_id:

POST /kie-callback?job_id=abc-123

这个设计有两个问题:

问题 1:语义不清晰

  • 查询参数通常用于过滤或配置,不适合标识资源
  • /callback?job_id=123 读起来像"查询 ID 为 123 的回调"
  • 实际上是"处理 ID 为 123 的任务的回调"

问题 2:URL 生成脆弱

  • 需要手动拼接:${baseUrl}/kie-callback?job_id=${jobId}
  • 容易忘记编码特殊字符
  • URL 结构变化时需要修改多处代码

RESTful 设计更清晰:

POST /api/callbacks/kie/:jobId

这个 URL 读起来就是:"向 KIE 回调端点发送关于任务 jobId 的通知"。

// 新增 RESTful 路由
app.post('/api/callbacks/kie/:jobId', async (c) => {
  const { jobId } = c.req.param();
  const callbackData = await c.req.json();

  console.log(`[Callback Job ${jobId}] Received via RESTful route.`);

  // 处理逻辑与旧路由相同
  return await handleKieCallback(c, jobId, callbackData);
});

// 保留旧路由以兼容现有集成
app.post('/kie-callback', async (c) => {
  const jobId = c.req.query('job_id');

  if (!jobId) {
    return c.json({ error: 'Missing job_id parameter' }, 400);
  }

  const callbackData = await c.req.json();
  console.log(`[Callback Job ${jobId}] Received via legacy route.`);

  return await handleKieCallback(c, jobId, callbackData);
});

修复位置: workers/src/api-gateway.js:1816-1830

URL 生成(在向 KIE.AI 提交任务时):

const callbackUrl = `${env.CALLBACK_BASE_URL}/api/callbacks/kie/${jobId}`;

简洁,明确,不易出错。

完整性的验证

修复完成后,如何确认系统恢复了追溯能力?

1. 数据库字段完整性

-- 查看最新任务的映射情况
SELECT
  id,
  type,
  vendor_task_id,
  generated_video_url,
  generated_image_url,
  status,
  created_at
FROM ai_generations
WHERE created_at > NOW() - INTERVAL '1 hour'
ORDER BY created_at DESC
LIMIT 10;

预期结果

  • vendor_task_id 不为 NULL(类似 8cdb405838f037cca10a34838bfb9b0c
  • 视频任务:generated_video_url 有值,generated_image_url 为空
  • 图像任务:generated_image_url 有值,generated_video_url 为空
  • URL 格式:https://s.purikura.io/videos/...https://s.purikura.io/images/...

2. R2 存储验证

# 列出最近上传的视频文件
wrangler r2 object list purikura --prefix "videos/" | head -10

# 列出最近上传的图像文件
wrangler r2 object list purikura --prefix "images/" | head -10

预期结果:看到最近上传的文件,路径结构为 videos/{job-id}/{uuid}.mp4

3. 文件可访问性

从数据库获取一个 generated_video_url,在浏览器中访问。

预期结果:视频直接播放,无需登录或权限验证。

4. 回调端点测试

# 测试 RESTful 路由
curl -X POST "https://api.purikura.io/api/callbacks/kie/test-123" \
  -H "Content-Type: application/json" \
  -d '{
    "code": 200,
    "taskId": "test-vendor-id",
    "data": {
      "resultUrls": ["https://example.com/test.mp4"]
    }
  }'

预期响应{"message": "Callback received."}

完整性是基础

三个修复,一个主题:完整性

vendor_task_id 的完整性 - 保存映射关系,建立追溯能力 类型处理的完整性 - 根据类型更新字段,保证数据语义 存储转换的完整性 - 文件转存到我们的存储,掌控访问权限

第三方集成不是简单的 API 调用。是两个系统之间的状态同步。

当你接收回调时,不只是接收结果。你在建立映射,验证类型,转存资产。每个环节都关乎系统的完整性。

缺失任何一环,系统就变成黑盒。你不知道任务对应厂商的哪个记录,不知道文件存在哪里,不知道如何追溯问题。

完整性不是完美主义,是系统可维护性的底线。


相关文章

最后更新:2025-11-06

Related Posts

Articles you might also find interesting

在运行的系统上生长新功能

3 min read

扩展不是推倒重来,而是理解边界,找到生长点。管理层作为观察者和调节器,附着在核心系统上,监测它,影响它,但不改变它的运行逻辑。

系统设计架构
Read More
Featured

定价界面优化的三层方法

4 min read

数据诚实、决策引导、视觉精调——三层递进的优化方法。从移除虚假功能到帮助用户选择,再到像素级修复,每一步都在解决真实问题

UI/UX定价策略
Read More

管理后台需要两次设计

3 min read

第一次设计回答"发生了什么",第二次设计回答"我能做什么"。在第一次就试图解决所有问题,结果是功能很多但都不够深入。

系统设计API 设计
Read More

告警分级与响应时间

2 min read

不是所有问题都需要立即响应。RPC失败会在凌晨3点叫醒人。安全事件每15分钟检查一次。支付成功只记录,不告警。系统的响应时间应该匹配问题的紧急程度。

系统设计监控
Read More

文档标准是成本计算的前提

3 min read

API文档不只是写给开发者看的。它定义了系统的边界、成本结构和可维护性。统一的文档标准让隐性成本变得可见。

API文档
Read More

BullMQ 队列

3 min read

队列不是技术选型,而是对时间的承认,对顺序的尊重,对不确定性的应对

系统设计异步处理
Read More

BullMQ Worker

2 min read

Worker 的本质是对时间的重新分配,是对主线的解放,也是对专注的追求

系统设计异步处理
Read More

离屏渲染:照片捕获为什么需要独立的 canvas

2 min read

实时流与静态合成的本质冲突,决定了系统必须分离。理解这种分离,就理解了架构设计中最重要的原则。

架构设计前端开发
Read More

集中式配置:让 Reddit 组件脱离重复泥潭

2 min read

当同一份数据散落在多个文件中,维护成本呈指数级增长。集中式配置不是技术选择,而是对抗熵增的必然手段。

架构设计React组件
Read More

配置不会自动同步

2 min read

视频生成任务永远pending,代码完美部署,队列正确配置。问题不在代码,在于配置的独立性被低估。静默失败比错误更危险。

部署配置管理
Read More

CRUD 操作

2 min read

四个字母背后,是数据的生命周期,是权限的边界,也是系统设计的基础逻辑

系统设计软件工程
Read More

数据库参数国际化:从 13 个迁移学到的设计原则

3 min read

数据不该懂语言。当数据库参数嵌入中文标签时,系统的边界就被语言限制了。这篇文章从 13 个参数对齐迁移中提炼出设计原则——国际化不是功能,是系统设计的底层约束。

数据库国际化
Read More

Stripe Webhook中的防御性编程

2 min read

三个Bug揭示的真相:假设是代码中最危险的东西。API返回类型、环境配置、变量作用域——每个看似合理的假设都可能导致客户损失。

Web开发系统设计
Read More

让文档跟着代码走

2 min read

文档过时是熵增的必然。对抗衰败的方法不是更频繁的手工维护,而是让文档"活"起来——跟随代码自动更新。三种文档形态,三种生命周期。

文档软件工程
Read More

双重导出管道的架构选择

2 min read

在用户生成内容场景中,速度与质量的权衡决定了导出架构。理解两种不同管道的设计逻辑,能够更准确地把握产品体验的边界。

架构设计图像导出
Read More

双重验证:Stripe生产模式的防御性切换

7 min read

从测试到生产不是更换API keys,而是建立一套双重验证系统。每一步都在两个环境中验证,确保真实支付不会因假设而失败。

系统设计Stripe
Read More

错误隔离

3 min read

失败是必然的。真正的问题不是失败本身,而是失败如何蔓延。错误隔离不是为了消除失败,而是为了控制失败的范围。

系统设计可靠性工程
Read More

Purikura的页面系统

3 min read

通过五层分层继承复用架构,实现零代码修改的页面生成系统。从类型定义到页面渲染,每一层专注单一职责,实现真正的数据驱动开发。

架构设计React
Read More

重复数据的迁移实践:从 N 个文件到 1 个真相源

3 min read

当同一份 Reddit posts 配置散落在多个文件中,维护成本以文件数量指数增长。迁移到集中式配置不是技术选择,而是对复杂度的清算。

架构设计配置管理
Read More

实现幂等性处理,忽略已处理的任务

3 min read

在代码层面识别和忽略已处理的任务,不是简单的布尔检查,而是对时序、并发和状态的深刻理解

系统设计并发控制
Read More

单例模式管理 Redis 连接

5 min read

连接不是技术细节,而是系统与外部世界的第一次握手,是可靠性的起点

系统设计后端架构
Read More

分层修复

3 min read

生产问题没有银弹。P0 止血,P1 加固,P2 优化。优先级不是排序,而是在不确定性下的决策框架。

工程实践问题修复
Read More

缺失值的级联效应

3 min read

一个NULL值如何在调用链中传播,最终导致错误的错误消息。理解防御层的设计,在失败传播前拦截。

系统设计防御性编程
Read More

监控观察期法

3 min read

部署不是结束,而是验证的开始。修复代码只是假设,监控数据才是证明。48小时观察期:让错误主动暴露,让数据证明修复。

系统设计监控
Read More

多厂商 AI 调度:统一混乱的供应商生态

3 min read

当你依赖第三方 AI 服务时,单点故障是最大的风险。多厂商调度不只是技术架构,更是对不确定性的应对策略。

Purikura 项目系统架构
Read More