Skip to main content

概述

kira-cdn 是 Kira 平台的集中式媒体资产服务。所有图片 / 视频 / 缩略图都经它上传、注册、签名、发布、删除。kira-web 浏览器端直接调用(upload / sign / watermark / delete),kira-be、kira-agent、kira-video-worker 通过 internal key 服务端调用。 核心设计是 directory-owned model(目录即所有权):asset 的存储路径本身编码了归属关系,删除按目录前缀级联,不做引用计数(reference counting)。
配置
平台Fly.io
Fly appkira-cdn
主区域sjc (San Jose,primary_region)
运行时区域fra / nrt / sjcfly scale 实际多区运行)
端口8888 (internal_port)
公网 APIcdn-api.kira.art
公网分发cdn.kira.art(R2 公开直链 / 静态素材)
RuntimeBun 1.3 / Hono
注册表 DB独立 Aiven PostgreSQL(非共享 Supabase)
缓存独立 Aiven Dragonfly(TLS rediss://),24h
主存储Cloudflare R2(bucket kira-cdn)
遗留存储Supabase Storage(只读 passthrough)

Directory-Owned 模型

asset 在 R2 上的存储 key 直接编码 owner + scope:
u/{ownerId}/{scopeKind}/{scopeId}/{assetId}.{ext}
示例(来自 lib/keygen.ts):
u/00000000-...0001/thread/T123/A456...XYZ.jpg
u/00000000-...0001/feed/F789/A789...ABC.mp4
u/00000000-...0001/profile/avatar/A234...DEF.png
  • ownerId — 资产所有者(uuid)
  • scopeKind / scopeIdcaller 给的 free-form 字符串,cdn 不解析含义,直接拼进 path。业务上 scopeKind 常见 thread / feed / profile,scopeId 是 thread/feed id。
  • assetId — uuid,/v1/uploads 时 reserve(可由 caller 指定确定性 id)
  • ext — 由 mime 推导(lib/keygen.tsMIME_TO_EXT)
scopeKind / scopeId 受字符白名单约束 ^[A-Za-z0-9_-]+$(assertSafeScope),防 path traversal。ownerId / assetId 是 uuid,天然安全。

关键原则

目录即所有权

删一个 thread = 删前缀 u/{owner}/thread/{tid}/。不需要引用计数、不需要 junction 表、不需要反向扫描哪些业务表持有该 asset。

无 ref tracking

没有 asset_refs 表,没有引用计数。assets 单表 + scope 级联删除。caller(kira-be)负责 enumerate 要删的 refs,cdn 只负责按 id 格式分流执行。

Publish = 物理 copy

发布到 feed 走 POST /v1/copy(R2 server-side CopyObject),feed 那份是独立 asset row + 独立 R2 对象,跟 source 解耦。thread 删掉不影响已发布的 feed 副本。代价是 published 内容 ~2x 存储,换零级联复杂度。

PostgreSQL authoritative

删除由 DB 记录驱动,不做 R2 ListObjects(那是”问 S3 有啥”,违背设计)。没注册 assets 的 R2 孤儿对象由 R2 lifecycle rule 兜底,不是 cdn 删除职责。

Scope 级联删除

DELETE /v1/scope 按三种粒度删(routes/scope.ts),全部由 DB 行驱动:
  • scopeKind + scopeId → 单个 thread / feed
  • 只传 scopeKind → 该 owner 下整个 kind(如所有 thread)
  • 都不传 → 该 owner 全量(账号注销)
物理删成功才删 pg row(removeMany 不 catch,任一失败抛出),绝不”pg 删了物理还在”造成不可达孤儿。

存储后端

Cloudflare R2(主)

  • bucket kira-cdn(R2_BUCKET),S3 兼容,驱动 storage/r2.ts@aws-sdk/client-s3
  • 所有新上传一律落 R2;publish copy 也一律落 R2
  • presigned PUT(client 直传)、HEAD、removeMany(S3 DeleteObjects 批量)、server-side copyputObject(stream-through 用)、signedUrl
  • public scope(avatar / bg)+ R2_PUBLIC_BASE 配置时,走永久公开 URL 不签名(lib/sign-base.tslib/resolve.ts)

Supabase Storage(遗留只读)

  • 老资产(kira-be 历史路径形态,含 / 的 ref)走 Supabase passthrough,驱动 storage/supabase.ts
  • 只读:presignPut / copy 抛错。新上传不走这。putObject 仅用于把水印变体落同目录,removeMany 用于删除老对象
  • 识别方式:ref 是 uuid 走 R2;ref 含 / 是 legacy path 走 Supabase(lib/ref.tsclassifyRef,UUID 正则)
  • cdn 对老数据的 awareness 仅限 id 格式分流,不知道任何业务表 schema

Aiven PostgreSQL(注册表)

独立 Aiven Postgres(非共享 Supabase),通过 DATABASE_URL 连接,Drizzle + postgres.js。承载 assetsmedia_tasks 两张表。

Aiven Dragonfly(缓存)

独立 Aiven Dragonfly(TLS rediss://,REDIS_URL),ioredis 客户端。缓存整段 sign / watermark 响应,TTL 24h。cache key 用资源三元组 {ns}:{backend}:{bucket}:{key}(lib/resolve.tsresourceCacheKey),namespace 有 sign / sign-video / wm / wm-video / fit。删除时主动清对应 key。
该 Dragonfly 是 kira-cdn 专属 Aiven 实例,与已退役的共享自托管 kira-dragonfly 无关。

数据库 Schema

源:kira-cdn/src/db/schema.ts。无 CHECK 约束 —— validation 全在 Hono + Zod 端点边界。

assets(资产注册表)

类型说明
iduuid PK (defaultRandom)asset id
owner_iduuid NOT NULL所有者
scope_kindtext NOT NULLfree-form,如 thread / feed / profile
scope_idtext NOT NULLfree-form
kindtext NOT NULLfree-form,如 image / video / thumbnail / cover / mask
backendtext NOT NULLr2 | supabase
buckettext NOT NULL存储 bucket
storage_keytext NOT NULL完整 R2/Supabase key
mime_typetext检测出的 mime
byte_sizebigintR2 HEAD 的权威字节数
metajsonb NOT NULL default {}per-kind 元数据
created_attimestamptz NOT NULL defaultNow
索引:assets_owner_created_idx (owner_id, created_at)assets_scope_idx (owner_id, scope_kind, scope_id)、唯一索引 assets_location_uniq (backend, bucket, storage_key) meta 的 per-kind 形态(AssetMeta,cdn 不强 validate):
kindmeta
image / thumbnail / cover / mask{ width, height, blurhash? }
video{ width, height, durationMs, thumbAssetId? }
audio{ durationMs }
meta.thumbAssetId 是 video 关联缩略图的跨 asset 引用,由 worker register 视频时写入、/v1/sign/video 自动解析回 thumbUrl,并由 DELETE /v1/assets 级联删除。

media_tasks(上传/生成任务状态机)

存 polling SoT 期(结果还没落到消息/版本时)的 input/output + status。任务终态后,output.assetIds 引用的 asset 行才是稳态。
类型说明
task_iduuid PK任务 id(非 defaultRandom,caller 传)
owner_iduuid NOT NULL所有者
media_typetext NOT NULLvideo / image / …
metadatajsonb NOT NULL default {}任务元数据(MediaTaskMetadata)
created_attimestamptz NOT NULL defaultNow
索引:media_tasks_owner_idx (owner_id) metadata 形态(free-form,cdn 不强 validate;worker 写):
{
  "toolName": "upload" | "generateVideo" | "videoEdit",
  "provider": "kira" | "seedance",
  "status": "pending" | "processing" | "completed" | "failed" |
            "insufficient_credits" | "insufficient_plan",
  "errorCode": "csam_blocked",
  "errorMessage": "...",
  "input": { /* per-tool 自定义 */ },
  "output": {
    "assetIds": ["uuid", ...],  // 完成时 worker 写,DELETE /:taskId 级联用
    "videoAssetId": "uuid",
    "thumbAssetId": "uuid"
  }
}
output.assetIds 是 cascade-delete 索引:DELETE /v1/media-tasks/:taskId 读它清理 R2 + DB。
不直接写 migration。改 schema.ts 后跑 bun run db:gen 给 typegen 用,生成的 SQL 由 owen 自己 apply。Fly deploy 的 release_command = bunx drizzle-kit migrate 在部署时自动跑已 commit 的 migration。禁止 prod 跑 db:push / 手改已 commit 的 migration / Aiven 控制台手动 ALTER。

认证模型

源:kira-cdn/src/auth/。同一组端点接受两种凭证,中间件 eitherAuth() 派发。
Authorization: Bearer <Supabase JWT>
  • hono/jwk 从 Supabase 标准 JWKS 端点验 ES256
  • JWKS:{SUPABASE_URL}/auth/v1/.well-known/jwks.json
  • ownerId 取自 token 的 sub
  • 只能访问/删除自己的 asset 与 task
getOwnerId(c, body.ownerId?) 取 owner:internal 模式取 body.ownerId,jwt 模式取 jwtPayload.sub

授权规则

端点类别JWT 模式Internal 模式
upload / register / sign / fit / watermark自己的 ownerbody 指定任意 owner
DELETE /v1/assets(uuid)WHERE owner_id = sub,只删自己删任意
DELETE /v1/assets(legacy path)path 必须 startsWith {sub}/feed/{sub}/avatars/{sub}/,否则 403信任 caller
POST /v1/copy不允许跨 owner(uuid 校验 owner_id = sub;legacy path 校验 path 第一段 = sub),否则 403/404可指定 toOwnerId
DELETE /v1/scopebody.ownerId 必须等于 subownerId 必传 body
POST /v1/media-tasks(upsert)internal-only
DELETE /v1/media-tasks(by owner)internal-only
GET / DELETE /v1/media-tasks/:taskId只能本人的 task任意

媒体处理

  • 图片 meta(width / height / blurhash)由 cdn 用 SharpPOST /v1/assets 时自动算,无视 caller 传值。video/audio meta 由 caller 传(cdn 内 ffmpeg/ffprobe,但视频元数据仍由 worker 算并通过 body 传)。
  • thumb / preview 变体走 imgproxy URL 签名(HMAC),CF edge cache 兜 LRU,零落盘。blur 占位用 DB 里的 blurhash 字符串前端解码(不再签 blur URL)。
  • 水印 image 走 Sharp 现算落同目录 {srcKey}.wm.jpg;水印 video 走 ffmpeg(容器内 Bun.spawn,Dockerfile apt 装了 ffmpeg)落 {srcKey}.wm.mp4
  • CSAM:POST /v1/assets 对 image mime 跑 PhotoDNA + OpenAI moderation 并行检测,任一命中 → 451 阻断。
详见 媒体处理
kira-image(Rust 变体生成器)是 thumb/preview/blur 变体的预定方案,但当前未部署、未接入 kira-cdn。现行变体路径仍是 imgproxy(img.kira.art)。

部署

源:kira-cdn/fly.toml + Dockerfile
Fly appkira-cdn
主区域sjcprimary_region
运行时区域fra / nrt / sjcfly scale 实际多区运行,fly.toml 只记 primary)
internal_port8888
force_httpstrue
机型performance 4 vCPU / 8GB RAM
min_machines_running1
auto_stop_machinesstop,auto_start_machines true
release_commandbunx drizzle-kit migrate(部署时自动迁移)
base imageoven/bun:1.3 + apt ffmpeg
app = 'kira-cdn'
primary_region = 'sjc'

[deploy]
  release_command = "bunx drizzle-kit migrate"

[http_service]
  internal_port = 8888
  force_https = true
  auto_stop_machines = "stop"
  auto_start_machines = true
  min_machines_running = 1

[[vm]]
  cpu_kind = "performance"
  cpus = 4
  memory = "8gb"
Bun.serveidleTimeout 设为 0:server 端不主动关闭 idle keepalive 连接,避免与 kira-be/kira-agent Bun fetch 连接池竞争导致”socket connection was closed unexpectedly”。

环境变量

仅列名称与结构,真实值在 Notion “Key” 页 ## kira-cdn,经 fly secrets set 注入。完整列表见 .env.example
变量说明
PORT默认 8888
NODE_ENVproduction
DATABASE_URLAiven Postgres 连接串
KIRA_CDN_INTERNAL_KEYX-Internal-Key 校验值(≥32 字符)
PUBLIC_BASE_URLcdn 公网 base(浏览器直链场景)
SUPABASE_URL / SUPABASE_SERVICE_KEYlegacy passthrough + JWKS
R2_ACCOUNT_ID / R2_ACCESS_KEY / R2_SECRETR2 凭证
R2_BUCKET默认 kira-cdn
R2_PUBLIC_BASE公开 scope 直链 base(可空)
IMGPROXY_URLhttps://img.kira.art
IMGPROXY_KEY / IMGPROXY_SALTimgproxy HMAC(hex)
REDIS_URLAiven Dragonfly(rediss://)
PHOTODNA_API_KEY / OPENAI_API_KEYCSAM(都缺 = fail-open 不检查)
TRIGGER_SECRET_KEYTrigger.dev Cloud(见下)

Trigger.dev Cloud

cdn 是 video upload task 的发起方:POST /v1/media-tasks/video 写 task row 后,经 @trigger.dev/sdk/v3tasks.trigger("video-upload-kira", …) 触发 Trigger.dev Cloud(managed SaaS,project proj_vbuwfsajmocvrsqwoyig)上的任务(src/lib/inngest.tspublishUploadTask,文件名保留为历史名,实际已改用 Trigger.dev),由 kira-video-worker 消费做 ffprobe + thumb 抽取 + register asset。该 run 触发时打 tag videoTask:<taskId>,供 kira-be 按 tag 取消在飞 run。
平台编排已从 Inngest 迁到 Trigger.dev Cloud(managed SaaS),无自托管编排组件。认证只需一个 env var:TRIGGER_SECRET_KEY,且按 environment 划分 —— DEV key 路由到本地 trigger.dev dev worker,PROD key 路由到已部署 worker。它取代了旧的 INNGEST_EVENT_KEY / INNGEST_SIGNING_KEY / INNGEST_BASE_URL 等所有 Inngest 变量,也不再有 /api/inngest webhook 或 inn.gs / api.inngest.com 两 URL 设置。本地 dev:在 kira-video-worker 跑 bunx trigger.dev@latest dev(无 localhost:8288 本地事件服务器)。

可观测性(Dash0)

源:kira-cdn/src/telemetry/。OpenTelemetry → kira-otel-collector.internal:4318 → Dash0(traces / metrics / logs)。token 只在 collector 上,cdn 用 OTel 标准 env OTEL_EXPORTER_OTLP_ENDPOINT
  • 入站 span:@hono/otel 中间件(也产 http.server.request.duration 直方图,扩了 30/60/120/300s 桶覆盖 stream-through copy / 水印合成 / scope 级联长跑)
  • 出站 fetch span:手写 installFetchTracing()globalThis.fetch(kira-be CDN client、imgproxy、PhotoDNA、OpenAI)。不用 @opentelemetry/instrumentation-fetch(它每 fetch 建 PerformanceObserver,Bun 下漏 native RSS → OOM)
  • aws-sdk → R2:instrumentation-http(走 node:http,不漏)
  • user 归因:UserAttributePropagatoruser.id / user.email stamp 到每个 span(JWT 来自 withUser,internal 来自 W3C Baggage)
  • 业务指标:cdn.op.durationcdn.cache.lookupcdn.csam.* 等(telemetry/metrics.ts)
详细 API 见 API 参考