第三方回调的状态映射完整性
当任务完成但无法追踪
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 调用。是两个系统之间的状态同步。
当你接收回调时,不只是接收结果。你在建立映射,验证类型,转存资产。每个环节都关乎系统的完整性。
缺失任何一环,系统就变成黑盒。你不知道任务对应厂商的哪个记录,不知道文件存在哪里,不知道如何追溯问题。
完整性不是完美主义,是系统可维护性的底线。
相关文章:
- purikura-workers-architecture - Purikura 的整体 Workers 架构设计
- defensive-programming-stripe-webhook - Stripe Webhook 中的防御性编程
- secret-token-callback-verification - 使用 Secret Token 验证回调请求的合法性
- idempotency-check - 幂等性检查的设计
最后更新:2025-11-06
Related Posts
Articles you might also find interesting
在运行的系统上生长新功能
扩展不是推倒重来,而是理解边界,找到生长点。管理层作为观察者和调节器,附着在核心系统上,监测它,影响它,但不改变它的运行逻辑。
定价界面优化的三层方法
数据诚实、决策引导、视觉精调——三层递进的优化方法。从移除虚假功能到帮助用户选择,再到像素级修复,每一步都在解决真实问题
管理后台需要两次设计
第一次设计回答"发生了什么",第二次设计回答"我能做什么"。在第一次就试图解决所有问题,结果是功能很多但都不够深入。
告警分级与响应时间
不是所有问题都需要立即响应。RPC失败会在凌晨3点叫醒人。安全事件每15分钟检查一次。支付成功只记录,不告警。系统的响应时间应该匹配问题的紧急程度。
文档标准是成本计算的前提
API文档不只是写给开发者看的。它定义了系统的边界、成本结构和可维护性。统一的文档标准让隐性成本变得可见。
BullMQ 队列
队列不是技术选型,而是对时间的承认,对顺序的尊重,对不确定性的应对
BullMQ Worker
Worker 的本质是对时间的重新分配,是对主线的解放,也是对专注的追求
离屏渲染:照片捕获为什么需要独立的 canvas
实时流与静态合成的本质冲突,决定了系统必须分离。理解这种分离,就理解了架构设计中最重要的原则。
集中式配置:让 Reddit 组件脱离重复泥潭
当同一份数据散落在多个文件中,维护成本呈指数级增长。集中式配置不是技术选择,而是对抗熵增的必然手段。
配置不会自动同步
视频生成任务永远pending,代码完美部署,队列正确配置。问题不在代码,在于配置的独立性被低估。静默失败比错误更危险。
CRUD 操作
四个字母背后,是数据的生命周期,是权限的边界,也是系统设计的基础逻辑
数据库参数国际化:从 13 个迁移学到的设计原则
数据不该懂语言。当数据库参数嵌入中文标签时,系统的边界就被语言限制了。这篇文章从 13 个参数对齐迁移中提炼出设计原则——国际化不是功能,是系统设计的底层约束。
Stripe Webhook中的防御性编程
三个Bug揭示的真相:假设是代码中最危险的东西。API返回类型、环境配置、变量作用域——每个看似合理的假设都可能导致客户损失。
让文档跟着代码走
文档过时是熵增的必然。对抗衰败的方法不是更频繁的手工维护,而是让文档"活"起来——跟随代码自动更新。三种文档形态,三种生命周期。
双重导出管道的架构选择
在用户生成内容场景中,速度与质量的权衡决定了导出架构。理解两种不同管道的设计逻辑,能够更准确地把握产品体验的边界。
双重验证:Stripe生产模式的防御性切换
从测试到生产不是更换API keys,而是建立一套双重验证系统。每一步都在两个环境中验证,确保真实支付不会因假设而失败。
错误隔离
失败是必然的。真正的问题不是失败本身,而是失败如何蔓延。错误隔离不是为了消除失败,而是为了控制失败的范围。
Purikura的页面系统
通过五层分层继承复用架构,实现零代码修改的页面生成系统。从类型定义到页面渲染,每一层专注单一职责,实现真正的数据驱动开发。
重复数据的迁移实践:从 N 个文件到 1 个真相源
当同一份 Reddit posts 配置散落在多个文件中,维护成本以文件数量指数增长。迁移到集中式配置不是技术选择,而是对复杂度的清算。
实现幂等性处理,忽略已处理的任务
在代码层面识别和忽略已处理的任务,不是简单的布尔检查,而是对时序、并发和状态的深刻理解
单例模式管理 Redis 连接
连接不是技术细节,而是系统与外部世界的第一次握手,是可靠性的起点
分层修复
生产问题没有银弹。P0 止血,P1 加固,P2 优化。优先级不是排序,而是在不确定性下的决策框架。
缺失值的级联效应
一个NULL值如何在调用链中传播,最终导致错误的错误消息。理解防御层的设计,在失败传播前拦截。
监控观察期法
部署不是结束,而是验证的开始。修复代码只是假设,监控数据才是证明。48小时观察期:让错误主动暴露,让数据证明修复。
多厂商 AI 调度:统一混乱的供应商生态
当你依赖第三方 AI 服务时,单点故障是最大的风险。多厂商调度不只是技术架构,更是对不确定性的应对策略。