Skip to main content

架构概览

核心组件

组件作用配置
Supabase Storage原图存储agent_message bucket
Redis (Dragonfly)Signed URL 缓存TTL: 30 天 - 1 小时
imgproxy动态图片处理缩放、模糊、格式转换
Cloudflare CDN边缘缓存max-age=2592000 (30 天)

上传流程

Frontend (Web Worker)

前端使用 Web Worker 处理图片,避免阻塞主线程:
// 1. 修复图片方向 + 计算 blurhash
const { blob, width, height, blurhash } = await processImageWithWorker(file);

// 2. 上传到 Supabase Storage
const fileId = `${userId}/${threadId}/insert_${localFileId}`;
await supabase.storage.from("agent_message").upload(fileId, blob);

// 3. 调用后端处理接口
await dealUploadedImage(fileId, { blurhash, width, height });
Web Worker 使用 OffscreenCanvas 计算 blurhash,Safari 不支持时自动 fallback 到主线程。

Backend 处理

前端上传时带上 metadata 后,后端通过 GET /image/:imageId 读取:
// 从 storage metadata 读取(无需下载文件)
const { data } = await supabase.storage.from("agent_message").info(imageId);
const width = data.metadata?.width;
const height = data.metadata?.height;
const blurhash = data.metadata?.blurhash;

Blurhash

Blurhash 是一种将图片编码为 20-30 字符字符串的算法,用于生成模糊占位图。

计算方式

// 后端使用 sharp 获取原始像素数据
const { data, info } = await sharp(buffer)
  .resize(32, 32, { fit: "inside" })
  .ensureAlpha()
  .raw()
  .toBuffer({ resolveWithObject: true });

// 编码为 blurhash (4x4 components)
const hash = encodeBlurhash(
  new Uint8ClampedArray(data),
  info.width,
  info.height,
  4, 4
);
// 结果示例: "LEHV6nWB2yk8pyo0adR*.7kCMdnj"

Metadata 存储

Blurhash、width、height 存储在 Supabase Storage 文件的 metadata 中:
// 前端上传时直接带上 metadata
await supabase.storage.from("agent_message").upload(fileId, blob, {
  metadata: {
    width: width.toString(),
    height: height.toString(),
    blurhash: blurhash,
  },
});

// 后端通过 info() 读取 metadata(无需下载文件)
const { data } = await supabase.storage.from("agent_message").info(fileId);
const blurhash = data.metadata?.blurhash ?? "";

API 响应

所有返回 Image 对象的接口都包含 blurhash 字段:
// GET /image/:imageId, GET /versions/:threadId, GET /feed, etc.
{
  imageId: "user123/thread456/gen_abc.png",
  url: "https://xxx.supabase.co/...",
  blurUrl: "https://img.kira.art/...",  // imgproxy 生成的模糊图
  thumbUrl: "https://img.kira.art/...", // imgproxy 生成的缩略图
  blurhash: "LEHV6nWB2yk8pyo0adR*.7kCMdnj", // 客户端即时解码
  width: 1024,
  height: 1024
}

解码显示

前端使用 blurhash 库解码并绘制到 canvas:
import { decode } from "blurhash";

const pixels = decode(blurhash, 32, 32);
const ctx = canvas.getContext("2d");
const imageData = ctx.createImageData(32, 32);
imageData.data.set(pixels);
ctx.putImageData(imageData, 0, 0);

PixiJS 集成

在 PixiJS 画布中使用 blurhash 作为即时占位符:
import { blurhashToDataURL } from "@/lib/utils";

// 1. 先异步加载 blurhash(data URL,无网络请求,极快)
if (currentImage.blurhash) {
  const blurDataUrl = blurhashToDataURL(currentImage.blurhash);
  Assets.load({ src: blurDataUrl, parser: "loadTextures" })
    .then((blurTexture) => {
      // 只有当原图还未加载时才显示 blur
      if (!sprite.texture?.source?.resource) {
        sprite.texture = blurTexture;
      }
    });
}

// 2. 异步加载原图(替换 blur)
Assets.load({ src: currentImage.url, parser: "loadTextures" })
  .then((texture) => {
    sprite.texture = texture;
  });
Blurhash 比 blurUrl 的优势:无需额外网络请求,客户端即时解码生成占位图,用户体验更流畅。

imgproxy

imgproxy 提供动态图片处理,避免预生成多个版本。

URL 签名

function buildImgproxyUrl(sourceUrl: string, options: ImgproxyOptions) {
  // 构建处理参数
  const params = [];
  if (options.width || options.height) {
    params.push(`rs:fit:${options.width ?? 0}:${options.height ?? 0}`);
  }
  if (options.blur) {
    params.push(`bl:${options.blur}`);
  }

  // 签名路径 (HMAC-SHA256)
  const path = `/${params.join("/")}/plain/${encodeURIComponent(sourceUrl)}@webp`;
  const signature = signImgproxyPath(path);

  return `https://img.kira.art/${signature}${path}`;
}

生成的 URL 类型

类型参数用途
originUrl原图 (webp 格式)
thumbUrlrs:fit:400:0400px 宽缩略图
blurUrlrs:fit:20:0, bl:520px 模糊图

缓存策略

问题

Supabase signed URL 每次生成都不同(JWT 包含 iat/exp 时间戳),导致:
  • imgproxy 签名变化
  • Cloudflare 认为是不同 URL → 永远 MISS

解决方案

Redis 缓存 signed URL,确保同一文件返回相同 URL:
const SIGNED_URL_TTL = 30 * 24 * 60 * 60; // 30 天

async function getMessageFileUrl(fileId: string) {
  const cacheKey = `signed_url:${fileId}`;

  // 1. 查 Redis 缓存
  const cached = await redis.get(cacheKey);
  if (cached) return cached;

  // 2. 生成新的 signed URL (30 天有效)
  const { data } = await supabase.storage
    .from("agent_message")
    .createSignedUrl(fileId, SIGNED_URL_TTL);

  // 3. 缓存 (比 URL 有效期短 1 小时)
  await redis.set(cacheKey, data.signedUrl, "EX", SIGNED_URL_TTL - 3600);

  return data.signedUrl;
}

TTL 对齐

层级TTL说明
Supabase signed URL30 天URL 本身的有效期
Redis 缓存30 天 - 1 小时避免返回快过期的 URL
imgproxy Cache-Control30 天max-age=2592000
Cloudflare CDN30 天遵循 Cache-Control

缓存流程

批量操作

对于 Feed 列表等场景,使用批量函数减少 Redis 往返:
async function batchGetMessageFileUrls(fileIds: string[]) {
  // 1. Redis MGET 批量查询
  const cacheKeys = fileIds.map(id => `signed_url:${id}`);
  const cached = await redis.mget(...cacheKeys);

  // 2. 找出未命中的
  const missed = fileIds.filter((_, i) => !cached[i]);

  // 3. 批量生成 + 批量写入 Redis
  const newUrls = await Promise.all(
    missed.map(id => supabase.storage.createSignedUrl(id, TTL))
  );

  // 4. Pipeline 写入 Redis
  await Promise.all(
    newUrls.map(({ fileId, url }) =>
      redis.set(`signed_url:${fileId}`, url, "EX", TTL - 3600)
    )
  );
}

监控

通过 metrics 监控缓存命中率:
recordCacheHit("signed_url");      // 单个 URL 缓存命中
recordCacheMiss("signed_url");     // 单个 URL 缓存未命中
recordCacheHit("signed_url_batch"); // 批量查询命中
recordCacheMiss("signed_url_batch"); // 批量查询未命中

文件存储结构

agent_message/
├── {userId}/
│   ├── {threadId}/
│   │   ├── insert_{uuid}           # 用户上传的原图
│   │   ├── gen_{uuid}              # AI 生成的图片
│   │   ├── mask_{uuid}             # 遮罩图
│   │   └── expand_{uuid}           # 扩展图
│   └── temp/
│       └── insert_{uuid}           # 临时上传
└── feed/
    └── {uuid}/
        ├── insert_{uuid}           # Feed 图片
        └── watermark_{uuid}        # 带水印版本

配置

imgproxy 环境变量

IMGPROXY_URL=https://img.kira.art
IMGPROXY_KEY=<hex-encoded-key>
IMGPROXY_SALT=<hex-encoded-salt>
IMGPROXY_TTL=2592000  # 30 天 Cache-Control

Cloudflare Cache Rules

在 Cloudflare Dashboard 配置 img.kira.art 域名:
  • Cache eligibility: Eligible for cache
  • Edge TTL: Use origin Cache-Control header
  • Browser TTL: Use origin Cache-Control header