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 app | kira-cdn |
| 主区域 | sjc (San Jose,primary_region) |
| 运行时区域 | fra / nrt / sjc(fly scale 实际多区运行) |
| 端口 | 8888 (internal_port) |
| 公网 API | cdn-api.kira.art |
| 公网分发 | cdn.kira.art(R2 公开直链 / 静态素材) |
| Runtime | Bun 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 / scopeId — caller 给的 free-form 字符串,cdn 不解析含义,直接拼进 path。业务上 scopeKind 常见 thread / feed / profile,scopeId 是 thread/feed id。
assetId — uuid,/v1/uploads 时 reserve(可由 caller 指定确定性 id)
ext — 由 mime 推导(lib/keygen.ts 的 MIME_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 copy、putObject(stream-through 用)、signedUrl
- public scope(
avatar / bg)+ R2_PUBLIC_BASE 配置时,走永久公开 URL 不签名(lib/sign-base.ts、lib/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.ts 的 classifyRef,UUID 正则)
- cdn 对老数据的 awareness 仅限 id 格式分流,不知道任何业务表 schema
Aiven PostgreSQL(注册表)
独立 Aiven Postgres(非共享 Supabase),通过 DATABASE_URL 连接,Drizzle + postgres.js。承载 assets 与 media_tasks 两张表。
Aiven Dragonfly(缓存)
独立 Aiven Dragonfly(TLS rediss://,REDIS_URL),ioredis 客户端。缓存整段 sign / watermark 响应,TTL 24h。cache key 用资源三元组 {ns}:{backend}:{bucket}:{key}(lib/resolve.ts 的 resourceCacheKey),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(资产注册表)
| 列 | 类型 | 说明 |
|---|
id | uuid PK (defaultRandom) | asset id |
owner_id | uuid NOT NULL | 所有者 |
scope_kind | text NOT NULL | free-form,如 thread / feed / profile |
scope_id | text NOT NULL | free-form |
kind | text NOT NULL | free-form,如 image / video / thumbnail / cover / mask |
backend | text NOT NULL | r2 | supabase |
bucket | text NOT NULL | 存储 bucket |
storage_key | text NOT NULL | 完整 R2/Supabase key |
mime_type | text | 检测出的 mime |
byte_size | bigint | R2 HEAD 的权威字节数 |
meta | jsonb NOT NULL default {} | per-kind 元数据 |
created_at | timestamptz 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):
| kind | meta |
|---|
| 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 级联删除。
存 polling SoT 期(结果还没落到消息/版本时)的 input/output + status。任务终态后,output.assetIds 引用的 asset 行才是稳态。
| 列 | 类型 | 说明 |
|---|
task_id | uuid PK | 任务 id(非 defaultRandom,caller 传) |
owner_id | uuid NOT NULL | 所有者 |
media_type | text NOT NULL | video / image / … |
metadata | jsonb NOT NULL default {} | 任务元数据(MediaTaskMetadata) |
created_at | timestamptz 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() 派发。
客户端(Supabase JWT)
服务端(Internal Key)
Authorization: Bearer <Supabase JWT>
hono/jwk 从 Supabase 标准 JWKS 端点验 ES256
- JWKS:
{SUPABASE_URL}/auth/v1/.well-known/jwks.json
ownerId 取自 token 的 sub
- 只能访问/删除自己的 asset 与 task
X-Internal-Key: <KIRA_CDN_INTERNAL_KEY>
- kira-be / kira-agent / kira-video-worker 用
- key 不匹配 → 401
ownerId 由 body 传(worker 代用户操作);可指定任意 owner
- user 归因经 W3C Baggage 透传,
UserAttributePropagator 自动 stamp 到 span
getOwnerId(c, body.ownerId?) 取 owner:internal 模式取 body.ownerId,jwt 模式取 jwtPayload.sub。
授权规则
| 端点类别 | JWT 模式 | Internal 模式 |
|---|
| upload / register / sign / fit / watermark | 自己的 owner | body 指定任意 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/scope | body.ownerId 必须等于 sub | ownerId 必传 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 用 Sharp 在
POST /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 app | kira-cdn |
| 主区域 | sjc(primary_region) |
| 运行时区域 | fra / nrt / sjc(fly scale 实际多区运行,fly.toml 只记 primary) |
internal_port | 8888 |
force_https | true |
| 机型 | performance 4 vCPU / 8GB RAM |
min_machines_running | 1 |
auto_stop_machines | stop,auto_start_machines true |
release_command | bunx drizzle-kit migrate(部署时自动迁移) |
| base image | oven/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.serve 的 idleTimeout 设为 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_ENV | production |
DATABASE_URL | Aiven Postgres 连接串 |
KIRA_CDN_INTERNAL_KEY | X-Internal-Key 校验值(≥32 字符) |
PUBLIC_BASE_URL | cdn 公网 base(浏览器直链场景) |
SUPABASE_URL / SUPABASE_SERVICE_KEY | legacy passthrough + JWKS |
R2_ACCOUNT_ID / R2_ACCESS_KEY / R2_SECRET | R2 凭证 |
R2_BUCKET | 默认 kira-cdn |
R2_PUBLIC_BASE | 公开 scope 直链 base(可空) |
IMGPROXY_URL | https://img.kira.art |
IMGPROXY_KEY / IMGPROXY_SALT | imgproxy HMAC(hex) |
REDIS_URL | Aiven Dragonfly(rediss://) |
PHOTODNA_API_KEY / OPENAI_API_KEY | CSAM(都缺 = fail-open 不检查) |
TRIGGER_SECRET_KEY | Trigger.dev Cloud(见下) |
Trigger.dev Cloud
cdn 是 video upload task 的发起方:POST /v1/media-tasks/video 写 task row 后,经 @trigger.dev/sdk/v3 的 tasks.trigger("video-upload-kira", …) 触发 Trigger.dev Cloud(managed SaaS,project proj_vbuwfsajmocvrsqwoyig)上的任务(src/lib/inngest.ts 的 publishUploadTask,文件名保留为历史名,实际已改用 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 归因:
UserAttributePropagator 把 user.id / user.email stamp 到每个 span(JWT 来自 withUser,internal 来自 W3C Baggage)
- 业务指标:
cdn.op.duration、cdn.cache.lookup、cdn.csam.* 等(telemetry/metrics.ts)
详细 API 见 API 参考。