Skip to main content

概览

源:kira-cdn/src/index.ts + routes/。base path cdn-api.kira.art(内部 kira-cdn.internal:8888)。 所有 /v1/* 端点走 eitherAuth()(Supabase JWT 或 X-Internal-Key),少数标 internal-only 的只接受 X-Internal-Key。授权规则见 认证模型
MethodPathAuth用途
POST/v1/uploadseitherreserve assetId + presigned R2 PUT URL(不写 DB)
POST/v1/uploads/sign-pendinginternal给已 PUT 但未 register 的对象签 GET URL
POST/v1/assetseitherregister asset:HEAD R2 + Sharp meta + CSAM + INSERT
DELETE/v1/assetseither统一删除入口(uuid + legacy path 混合,按 id 格式分流)
POST/v1/sign/imageeither批量 ref → signed URL + imgproxy thumb/preview + meta
POST/v1/sign/videoeither批量 ref → signed URL + 关联 thumb + meta
POST/v1/fiteither尺寸/字节/格式受限的 imgproxy URL(喂外部 AI provider)
POST/v1/copyeitherpublish:跨 scope 物理复制 asset
POST/v1/watermarkeitherimage 水印 URL(.wm.jpg)
POST/v1/watermark-videoeithervideo 水印 URL(.wm.mp4)
DELETE/v1/scopeeitherdirectory cascade 删除(by owner ± scopeKind ± scopeId)
POST/v1/media-tasksinternalupsert task
POST/v1/media-tasks/videoeither浏览器视频上传 init + 触发 Trigger.dev 任务
GET/v1/media-tasks/:taskIdeither轮询 task 状态
DELETE/v1/media-tasks/:taskIdeither取消 task + 级联删 output.assetIds
DELETE/v1/media-tasksinternal按 ownerId 批量删 task row(不级联 asset)
GET/healthnone健康检查

上传 / 注册

POST /v1/uploads

生成 assetId + presigned R2 PUT URL,不写 DB。caller PUT 完后调 /v1/assets 注册。 Body(UploadRequest):
{
  "scopeKind": "thread",        // ≤64,白名单 [A-Za-z0-9_-]+
  "scopeId": "T123",            // ≤128,同白名单
  "kind": "image",              // ≤64,free-form
  "mime": "image/png",
  "ownerId": "uuid",            // internal mode only
  "assetId": "uuid"             // 可选,确定性 id(如 worker thumb 用 uuidv5 重放幂等)
}
Response:
{
  "assetId": "uuid",
  "storageKey": "u/{owner}/thread/T123/{assetId}.png",
  "uploadUrl": "https://...r2... (presigned PUT, 300s)",
  "headers": { /* PUT 时要带的 header */ },
  "expiresAt": "ISO"
}
caller 必须在 /v1/assets 回传同样的 (scopeKind, scopeId, kind, mime, assetId, ownerId),cdn 才能重算同一 storageKey

POST /v1/uploads/sign-pending

Auth:internal-only(X-Internal-Key)。JWT user 不需要这个端点 —— register 完走 /v1/sign 即可。 给”已 PUT 但还没 register”的对象签 GET URL。worker 新协议用:client 拿了 assetId 直传 R2 后,worker 处理时 video asset 还没 register(register 要等 thumb 就位才能带 thumbAssetId 一起 INSERT),期间 /v1/sign 因 DB 无行拿不到 URL。本端点用 (ownerId, scopeKind, scopeId, assetId, mime) 重算 storageKey,HEAD R2 验对象在,直接签 GET URL,绕过 DB lookup。 Body(SignPendingRequest):
{
  "assetId": "uuid",
  "ownerId": "uuid",
  "scopeKind": "thread",        // ≤64
  "scopeId": "T123",            // ≤128
  "mime": "video/mp4",
  "expiresIn": 3600             // 可选,秒,默认 3600,≤86400(24h)
}
Response:
{
  "url": "R2 signed GET URL",
  "byteSize": 1234567           // R2 HEAD 的权威字节数
}
对象未 PUT 上(R2 HEAD miss)→ 404。

POST /v1/assets

caller PUT R2 完成后注册 row。流程:
1

HEAD R2

对象不存在 → 409。byteSize 永远用 R2 HEAD 的权威值,不信 caller。
2

Image meta(Sharp)

mime 以 image/ 开头时,cdn fetch 对象 + Sharp 算 width/height/blurhash/format,覆盖 caller 传值;检测出的 format 覆盖 mime(如 .jpg 实际是 PNG → image/png)。video/audio 的 meta(width/height/durationMs)由 caller 传。
3

INSERT(幂等)

onConflictDoNothing,同 assetId 重试命中已有行返回 200,不报 duplicate key。
4

CSAM(image only)

register 之后对 image 跑 PhotoDNA + OpenAI 并行检测。命中 → deleteAssetsByIds 删掉刚插的行 + 451。
Body(CreateAssetRequest):
{
  "assetId": "uuid",
  "scopeKind": "thread",
  "scopeId": "T123",
  "kind": "image",
  "mime": "image/png",
  "ownerId": "uuid",          // internal mode only
  "byteSize": 12345,          // 可选(被 R2 HEAD 覆盖)
  "width": 1024,              // image 被 Sharp 覆盖
  "height": 1024,
  "durationMs": 0,            // video/audio
  "blurhash": "...",          // image 被 Sharp 覆盖
  "thumbAssetId": "uuid"      // video kind 关联缩略图
}
Response:注册的 row(meta 字段被 spread 回顶层 flat,兼容老 API)。 CSAM 命中:
// HTTP 451
{ "errorCode": "csam_blocked" }

签名

POST /v1/sign/image

批量 image ref → signed URL + imgproxy 变体 + meta(整响应 Redis 缓存 24h)。Body { refs: string[] (≤200), legacyBucket? } Response 是 { [ref]: ImageSignedRef }:
{
  "<ref>": {
    "ready": true,
    "url": "原字节 signed URL(R2/Supabase,7d)",
    "thumbUrl": "imgproxy 400px fit q80(仅 image mime)",
    "previewUrl": "imgproxy 1024px fit q85(仅 image mime)",
    "width": 1024, "height": 1024,
    "byteSize": 12345,
    "durationMs": null,
    "blurhash": "...",
    "mime": "image/png"
  }
}
ref 解不到 → { ready: false, error: "not_found" }。非 image mime → 返 base 不带 thumbUrl/previewUrl

POST /v1/sign/video

批量 video ref → signed URL + 关联 thumb。Body 同上。Response { [ref]: VideoSignedRef }:
{
  "<ref>": {
    "ready": true,
    "url": "video 原字节 signed URL",
    "thumbUrl": "关联 thumb asset 经 imgproxy 出的 URL",
    "thumbAssetId": "thumb 的 raw uuid(给 LLM readImage 用)",
    "width": 1280, "height": 720,
    "byteSize": 1234567,
    "durationMs": 5000,
    "blurhash": "...",
    "mime": "video/mp4"
  }
}
thumb 解析:读 video row 的 meta.thumbAssetId,DB 查 thumb asset → 经 imgproxy 出 thumbUrl。判据是”有没有 thumbAssetId”(不 gate kind,兼容历史 insert_video)。legacy supabase passthrough video 按命名约定 video_*.mp4vc_*.jpg 兜底解 thumb。thumb 解析失败只 warn,主签照返。
image 与 video 用独立 Redis namespace(sign vs sign-video),响应 shape 不同,绝不共用 key。

POST /v1/fit

出一个尺寸/字节/格式受限的 imgproxy 签名 URL,用于把用户原图降到外部 AI provider 的输入限制内。cdn 是薄签名器,caller 按各 provider 的 InputSpec 算好参数传进来。 Body(FitRequest):
{
  "ref": "uuid 或 legacy path",
  "legacyBucket": "agent_message",
  "width": 1024,
  "height": 1024,
  "resizingType": "fit",        // fit | fill,默认 fit
  "enlarge": false,
  "maxBytes": 4000000,          // imgproxy 自动迭代质量压到预算内(mb:)
  "format": "jpeg",             // jpeg | png | webp,默认 jpeg
  "resizingAlgorithm": "..."    // 可选;nearest 等是 imgproxy Pro-only
}
Response { url }。ref 解不到 / legacy 源已删 → 404。变体 URL 经资源三元组 + transform 参数缓存(同图同变换命中同一 URL,CF edge 缓住)。

Publish(copy)

POST /v1/copy

把 source asset 物理复制为另一个 scope(通常是 feed)的独立资源。 两条路径,新 row 一律落 R2:
  1. uuid + R2 源 → R2 server-side CopyObject(零字节进 cdn)
  2. uuid + Supabase 源 / legacy path 源 → stream-through(下载 → cdn 内存 → R2 PUT)
Body(CopyRequest):
{
  "fromAssetId": "uuid 或 legacy path",
  "toScopeKind": "feed",
  "toScopeId": "F789",
  "toOwnerId": "uuid",          // internal mode 可指定他人;jwt 不允许跨 owner
  "legacyBucket": "agent_message",
  "thumbAssetId": "uuid"        // video copy:重映射到新 scope 的 thumb
}
video publish 必须先 copy thumb 拿到 feed 自己的新 thumb id,再 copy video 传 thumbAssetId=newThumbId,这样 feed 的 meta.thumbAssetId 指向 feed 自己的 thumb(物理两份完全独立,thread 删不影响 feed)。不传 thumbAssetId 则剥离源的,避免悬空指向源 scope 的 thumb。
Response:新 row(meta spread 回顶层)。失败:source 不存在 404;jwt 跨 owner 403;字节传输失败 502/500。

水印

POST /v1/watermark

image 水印。同目录 {srcKey}.wm.jpg,HEAD 命中直接签 URL,miss 则 Sharp composite + putObject。Body { ref, legacyBucket? }。Response { url }。非 image mime → 400。Redis 整响应缓存 24h(key wm:{backend}:{bucket}:{key})。

POST /v1/watermark-video

video 水印 + Kira 品牌 ending 拼接。同目录 {srcKey}.wm.mp4,HEAD 命中直接签 URL,miss 则 ffmpeg(容器内 Bun.spawn)composite + putObject。Body { ref, legacyBucket? }。Response { url }。非 video mime → 400。Redis 缓存 24h(key wm-video:{backend}:{bucket}:{key})。
水印只在下载场景调用,不进 sign 响应路径(避免对每个 asset 都签无用 URL)。物理水印文件不靠 lifecycle,由 owen 自写 cron 按热度清。删 source 时同前缀的 .wm.jpg/.wm.mp4 一并级联清。

删除

DELETE /v1/assets

统一删除入口,按 id 格式分流。Body { ids: string[] (1..1000), legacyBucket? }:
  • UUID → cdn DB row + R2 batch remove(硬删)
  • / 的 path → Supabase batch remove(legacyBucket 默认 agent_message)
特性:
  • 物理删成功才删 DB row(removeMany 不 catch,R2 失败抛出),绝不造不可达孤儿
  • 同目录水印变体 .wm.jpg / .wm.mp4 一并删(no-op 兜不存在)
  • video row 的 meta.thumbAssetId 指向的 thumb 级联删(thumb 是 cdn 内部关系,caller 不一定 enumerate)
  • 清对应 Redis cache(sign / sign-video / wm / wm-video)
  • JWT 模式 legacy path 越权:必须 startsWith {sub}/ / feed/{sub}/ / avatars/{sub}/,否则 403
Response { deletedNew, deletedLegacy }

DELETE /v1/scope

directory cascade 删除,由 DB 行驱动(不 R2 ListObjects)。Body(DeleteScopeRequest):
{
  "ownerId": "uuid",            // internal 必传;jwt 必须 = sub
  "scopeKind": "thread",        // 可选
  "scopeId": "T123",            // 可选;传 scopeId 必须同传 scopeKind
  "legacyPrefixes": ["{userId}/", "feed/{userId}/"],  // 可选 Supabase legacy
  "legacyBucket": "agent_message"
}
粒度:都传 = 单 scope;只 scopeKind = 整个 kind;都不传 = per-user 全量(账号注销)。 流程:(1) 查 DB rows;(2) legacyPrefixes 走 Supabase list + remove(best-effort);(3) 按 (backend,bucket) 分组删物理(含水印变体,removeMany 不 catch);(4) 物理全成功才删 pg row + 清 Redis。Response { r2Deleted, legacyDeleted, dbDeleted }
scope cascade 比 caller enumerate refs 更可靠 —— 不依赖 caller 把 messages.parts / version JSONB 都扫齐。

Media Tasks

POST /v1/media-tasks

internal-only。worker upsert task(pendingprocessingcompleted)。同 task_id 重写只更新 metadata,保留首次的 owner_id / media_type / created_at。Body { taskId, ownerId, mediaType, metadata? }。Response:upserted row。

POST /v1/media-tasks/video

浏览器视频上传 init(替代旧 kira-be /video/initialize)。流程:HEAD R2 验对象在(不存在 → 409)→ INSERT pending task → 经 Trigger.dev tasks.trigger("video-upload-kira", …) 触发 kira-video-worker(run 打 tag videoTask:<taskId>,便于 kira-be 取消)→ 返 taskId。Body:
{
  "assetId": "uuid",     // /v1/uploads reserve 的
  "scopeKind": "thread",
  "scopeId": "T123",
  "mime": "video/mp4",
  "threadId": "temp"     // 可选,默认 "temp"
}
Response { taskId, status: "pending" }

GET /v1/media-tasks/:taskId

轮询。task 未入表(刚发完 event 还没 upsert)返 { taskId, status: "pending" }(不是 404)。JWT 模式只返本人 task(他人 → 404)。taskId 非 uuid → 400。

DELETE /v1/media-tasks/:taskId

取消 task + 级联删 metadata.output.assetIds 引用的资产(deleteAssetsByIds)。幂等:已删/不存在返 { deleted: false, assetsDeleted: 0 }。JWT 模式越权 → 404。Response { deleted: true, assetsDeleted }

DELETE /v1/media-tasks

internal-only。账号注销级联:按 ownerId 批量删 task row,不级联 asset(asset 清理走 DELETE /v1/scope by ownerId)。Body { ownerId }。Response { deleted: <count> }

健康检查

curl https://cdn-api.kira.art/health
# { "status": "ok", "service": "kira-cdn" }
GET /{ "service": "kira-cdn", "version": "0.0.1" }/health 在 OTel 入站 span 后跳过请求日志,避免噪声。

Caller 集成模式

// 1. reserve
const { assetId, uploadUrl, headers } = await cdn.uploads({
  scopeKind: 'thread', scopeId: threadId, kind: 'image', mime: 'image/png',
});
// 2. PUT R2
await fetch(uploadUrl, { method: 'PUT', headers, body: blob });
// 3. register + CSAM
await cdn.assets({ assetId, scopeKind: 'thread', scopeId: threadId, kind: 'image', mime: 'image/png' });