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