文件服务技能Skill bknd-serve-files

该技能用于在Bknd平台中服务上传的文件,支持通过API代理或直接存储URL访问文件,涵盖CDN配置、缓存头设置、图像优化(使用Cloudinary)等功能,适用于前端应用中的文件展示和下载。关键词:文件服务、API代理、CDN、图像优化、Cloudinary、S3、R2、缓存策略、前端集成。

Serverless 0 次安装 0 次浏览 更新于 3/8/2026

name: bknd-serve-files description: 用于当需要向用户提供上传的文件时。涵盖API代理文件服务、直接存储URL(S3/R2/Cloudinary)、CDN配置、公共文件URL、缓存头设置、使用Cloudinary的图像优化以及在前端应用中服务文件。

服务文件

通过API代理或直接存储URL从Bknd存储向用户提供上传的文件。

先决条件

  • 在Bknd配置中启用媒体模块
  • 配置存储适配器(S3、R2、Cloudinary或本地)
  • 通过bknd-file-upload技能上传文件
  • 对于CDN:存储提供商支持CDN(S3/R2/Cloudinary)

何时使用UI模式

  • 管理员面板 > 媒体部分 > 查看/预览文件
  • 从管理员界面复制文件URL
  • 快速验证文件是否可访问

何时使用代码模式

  • 以编程方式构建文件URL
  • 配置CDN或自定义域名
  • 实现图像优化
  • 文件下载的访问控制

文件服务方法

Bknd支持两种服务文件的方法:

方法 使用场景 性能 控制
API代理 简单设置,私有文件 中等 完全(认证、权限)
直接URL 高流量,公共文件 最佳(CDN) 有限(存储桶ACL)

逐步指南:API代理(通过Bknd)

文件通过Bknd API在/api/media/file/{filename}服务。

步骤1:获取文件URL

import { Api } from "bknd";

const api = new Api({ host: "http://localhost:7654" });

// 构建文件URL
const fileUrl = `${api.host}/api/media/file/image.png`;
// "http://localhost:7654/api/media/file/image.png"

步骤2:在前端显示

function Image({ filename }) {
  const { api } = useApp();
  const src = `${api.host}/api/media/file/${filename}`;

  return <img src={src} alt="" />;
}

步骤3:下载文件(SDK)

// 获取为File对象
const file = await api.media.download("image.png");

// 获取为流(用于大文件)
const stream = await api.media.getFileStream("image.png");

步骤4:验证访问

# 测试文件访问
curl -I http://localhost:7654/api/media/file/image.png

# 响应包括:
# Content-Type: image/png
# Content-Length: 12345
# ETag: "abc123..."

逐步指南:直接存储URL

直接从S3/R2/Cloudinary服务文件以获得更好性能。

S3/R2直接URL

// S3 URL模式
const s3Url = `https://${bucket}.s3.${region}.amazonaws.com/${filename}`;
// "https://mybucket.s3.us-east-1.amazonaws.com/image.png"

// R2 URL模式(公共存储桶)
const r2Url = `https://${customDomain}/${filename}`;
// "https://media.myapp.com/image.png"

Cloudinary直接URL

Cloudinary提供自动CDN和转换:

// 基本URL
const cloudinaryUrl = `https://res.cloudinary.com/${cloudName}/image/upload/${filename}`;

// 带转换
const optimizedUrl = `https://res.cloudinary.com/${cloudName}/image/upload/w_800,q_auto,f_auto/${filename}`;

在代码中构建URL

// 基于适配器类型获取直接URL的助手函数
function getFileUrl(filename: string, config: MediaConfig): string {
  const { adapter } = config;

  switch (adapter.type) {
    case "s3":
      // 从配置端点获取S3/R2 URL
      return `${adapter.config.url}/${filename}`;

    case "cloudinary":
      return `https://res.cloudinary.com/${adapter.config.cloud_name}/image/upload/${filename}`;

    case "local":
      // 对于本地,始终使用API代理
      return `/api/media/file/${filename}`;

    default:
      return `/api/media/file/${filename}`;
  }
}

CDN配置

带有自定义域的Cloudflare R2

  1. 在Cloudflare仪表板中创建R2存储桶

  2. 在存储桶上启用公共访问

  3. 配置自定义域(Cloudflare DNS):

    • 添加CNAME:media.yourapp.com -> <bucket>.<account>.r2.dev
  4. 在Bknd配置中使用:

export default defineConfig({
  media: {
    enabled: true,
    adapter: {
      type: "s3",
      config: {
        access_key: process.env.R2_ACCESS_KEY,
        secret_access_key: process.env.R2_SECRET_KEY,
        url: `https://${process.env.R2_ACCOUNT_ID}.r2.cloudflarestorage.com/${process.env.R2_BUCKET}`,
      },
    },
  },
});
  1. 通过自定义域服务文件:
const publicUrl = `https://media.yourapp.com/${filename}`;

AWS S3 with CloudFront

  1. 创建S3存储桶,带公共读取(或CloudFront OAI)

  2. 创建CloudFront分发:

    • 源:S3存储桶
    • 缓存策略:CachingOptimized
    • 自定义域(可选)
  3. 使用CloudFront URL:

const cdnUrl = `https://d123abc.cloudfront.net/${filename}`;
// 或带有自定义域
const cdnUrl = `https://cdn.yourapp.com/${filename}`;

Cloudinary(内置CDN)

Cloudinary自动包含全球CDN:

export default defineConfig({
  media: {
    enabled: true,
    adapter: {
      type: "cloudinary",
      config: {
        cloud_name: "your-cloud-name",
        api_key: process.env.CLOUDINARY_API_KEY,
        api_secret: process.env.CLOUDINARY_API_SECRET,
      },
    },
  },
});

文件从res.cloudinary.com通过全球CDN服务。

图像优化

Cloudinary转换

// 构建优化图像URL
function getOptimizedImage(filename: string, options: {
  width?: number;
  height?: number;
  quality?: "auto" | number;
  format?: "auto" | "webp" | "avif" | "jpg" | "png";
  crop?: "fill" | "fit" | "scale" | "thumb";
} = {}) {
  const cloudName = process.env.CLOUDINARY_CLOUD_NAME;
  const transforms: string[] = [];

  if (options.width) transforms.push(`w_${options.width}`);
  if (options.height) transforms.push(`h_${options.height}`);
  if (options.quality) transforms.push(`q_${options.quality}`);
  if (options.format) transforms.push(`f_${options.format}`);
  if (options.crop) transforms.push(`c_${options.crop}`);

  const transformStr = transforms.length > 0 ? transforms.join(",") + "/" : "";

  return `https://res.cloudinary.com/${cloudName}/image/upload/${transformStr}${filename}`;
}

// 使用
const thumb = getOptimizedImage("avatar.png", {
  width: 100,
  height: 100,
  crop: "fill",
  quality: "auto",
  format: "auto",
});
// "https://res.cloudinary.com/mycloud/image/upload/w_100,h_100,c_fill,q_auto,f_auto/avatar.png"

常见转换模式

// 响应式图像
const srcSet = [400, 800, 1200].map(w =>
  `${getOptimizedImage(filename, { width: w, format: "auto" })} ${w}w`
).join(", ");

// 缩略图生成
const thumb = getOptimizedImage(filename, {
  width: 150,
  height: 150,
  crop: "thumb",
});

// 自动格式(支持时使用WebP/AVIF)
const optimized = getOptimizedImage(filename, {
  quality: "auto",
  format: "auto",
});

React集成

带后备的图像组件

function StoredImage({ filename, alt, ...props }) {
  const { api } = useApp();
  const [error, setError] = useState(false);

  // API代理URL作为后备
  const apiUrl = `${api.host}/api/media/file/${filename}`;

  // 直接CDN URL(基于您的适配器配置)
  const cdnUrl = `https://media.yourapp.com/${filename}`;

  return (
    <img
      src={error ? apiUrl : cdnUrl}
      alt={alt}
      onError={() => setError(true)}
      {...props}
    />
  );
}

响应式图像组件

function ResponsiveImage({ filename, alt, sizes = "100vw" }) {
  const cloudName = process.env.NEXT_PUBLIC_CLOUDINARY_CLOUD_NAME;
  const base = `https://res.cloudinary.com/${cloudName}/image/upload`;

  const srcSet = [400, 800, 1200, 1600].map(w =>
    `${base}/w_${w},q_auto,f_auto/${filename} ${w}w`
  ).join(", ");

  return (
    <img
      src={`${base}/w_800,q_auto,f_auto/${filename}`}
      srcSet={srcSet}
      sizes={sizes}
      alt={alt}
      loading="lazy"
    />
  );
}

文件下载按钮

function DownloadButton({ filename, label }) {
  const { api } = useApp();
  const [downloading, setDownloading] = useState(false);

  const handleDownload = async () => {
    setDownloading(true);
    try {
      const file = await api.media.download(filename);

      // 创建下载链接
      const url = URL.createObjectURL(file);
      const link = document.createElement("a");
      link.href = url;
      link.download = filename;
      link.click();
      URL.revokeObjectURL(url);
    } catch (err) {
      console.error("下载失败:", err);
    } finally {
      setDownloading(false);
    }
  };

  return (
    <button onClick={handleDownload} disabled={downloading}>
      {downloading ? "下载中..." : label || "下载"}
    </button>
  );
}

缓存配置

S3/R2缓存头

上传时设置缓存头:

// 自定义适配器带缓存头(高级)
// S3适配器不直接公开此功能;通过存储桶策略配置
// 或CloudFront缓存行为

Cloudflare R2缓存规则

在Cloudflare仪表板中:

  1. 转到缓存 > 缓存规则
  2. 为您的R2子域创建规则
  3. 设置边缘TTL(例如,不可变资源为1年)

API代理缓存

Bknd的API代理支持标准HTTP缓存:

# 客户端可以使用条件请求
curl -H "If-None-Match: \"abc123\"" \
  http://localhost:7654/api/media/file/image.png

# 如果未更改,返回304 Not Modified

访问控制

公共文件(无认证)

配置默认角色带有media.read权限:

export default defineConfig({
  auth: {
    guard: {
      roles: {
        anonymous: {
          is_default: true,
          permissions: {
            "media.read": true,  // 公共读取访问
          },
        },
      },
    },
  },
});

私有文件(需要认证)

从匿名角色中移除media.read:

export default defineConfig({
  auth: {
    guard: {
      roles: {
        user: {
          permissions: {
            "media.read": true,
            "media.create": true,
          },
        },
        // 没有匿名角色,或没有media.read权限
      },
    },
  },
});

访问需要认证:

# 无认证时失败
curl http://localhost:7654/api/media/file/private.pdf
# 401 Unauthorized

# 有认证时工作
curl -H "Authorization: Bearer $TOKEN" \
  http://localhost:7654/api/media/file/private.pdf

签名URL(时间限制访问)

对于S3/R2,生成预签名URL:

// 签名URL的自定义端点(高级)
// 需要直接使用S3 SDK,而不是通过Bknd适配器
import { S3Client, GetObjectCommand } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";

async function getSignedDownloadUrl(filename: string): Promise<string> {
  const client = new S3Client({ /* config */ });
  const command = new GetObjectCommand({
    Bucket: process.env.S3_BUCKET,
    Key: filename,
  });
  return getSignedUrl(client, command, { expiresIn: 3600 }); // 1小时
}

REST API参考

方法 端点 描述
GET /api/media/file/:filename 下载/查看文件
GET /api/media/files 列出所有文件

请求头

描述
Authorization Bearer令牌(如果需要认证)
If-None-Match 用于条件请求的ETag
Range 用于部分下载的字节范围

响应头

描述
Content-Type 文件MIME类型
Content-Length 文件大小(字节)
ETag 用于缓存的文件哈希
Accept-Ranges 指示范围支持

常见陷阱

404文件未找到

问题: 文件URL返回404。

原因:

  1. 文件名不存在
  2. 路径/大小写错误
  3. 文件已被删除

修复: 验证文件存在:

const { data: files } = await api.media.listFiles();
const exists = files.some(f => f.key === filename);

直接S3 URL上的CORS错误

问题: 浏览器阻止直接S3访问。

修复: 在S3存储桶上配置CORS:

{
  "CORSRules": [{
    "AllowedOrigins": ["https://yourapp.com"],
    "AllowedMethods": ["GET"],
    "AllowedHeaders": ["*"],
    "MaxAgeSeconds": 3600
  }]
}

文件服务缓慢

问题: 通过API代理文件加载缓慢。

修复: 使用带CDN的直接存储URL:

// 而不是API代理
const slow = "/api/media/file/image.png";

// 使用直接CDN URL
const fast = "https://cdn.yourapp.com/image.png";

混合内容(HTTP/HTTPS)

问题: HTTPS页面加载HTTP文件URL。

修复: 确保存储URL使用HTTPS:

// 错误
url: "http://bucket.s3.amazonaws.com",

// 正确
url: "https://bucket.s3.amazonaws.com",

大文件下载失败

问题: 下载超时或内存错误。

修复: 对大文件使用流:

// 使用流而不是加载到内存中
const stream = await api.media.getFileStream("large-file.zip");

// 或直接下载链接
const downloadUrl = `${api.host}/api/media/file/large-file.zip`;
window.location.href = downloadUrl;

Cloudinary文件未找到

问题: Cloudinary返回404用于上传的文件。

原因: Cloudinary使用最终一致性;文件尚未索引。

修复: 稍等片刻或直接使用上传响应URL:

const { data } = await api.media.upload(file);
// 直接使用data.name而不是重新获取

验证

测试文件服务设置:

async function testFileServing() {
  const filename = "test-image.png";

  // 1. 验证文件存在
  const { data: files } = await api.media.listFiles();
  const file = files.find(f => f.key === filename);
  console.log("文件存在:", !!file);

  // 2. 测试API代理
  const apiUrl = `${api.host}/api/media/file/${filename}`;
  const apiRes = await fetch(apiUrl);
  console.log("API代理状态:", apiRes.status);
  console.log("Content-Type:", apiRes.headers.get("content-type"));

  // 3. 测试条件请求
  const etag = apiRes.headers.get("etag");
  if (etag) {
    const conditionalRes = await fetch(apiUrl, {
      headers: { "If-None-Match": etag },
    });
    console.log("条件请求:", conditionalRes.status === 304 ? "304(缓存)" : conditionalRes.status);
  }

  // 4. 测试SDK下载
  const downloadedFile = await api.media.download(filename);
  console.log("SDK下载:", downloadedFile.name, downloadedFile.size);
}

注意事项

应做:

  • 对公共高流量文件使用CDN(Cloudinary/R2/CloudFront)
  • 对静态资源设置适当的缓存头
  • 对私有/需要认证的文件使用API代理
  • 对图像实现懒加载
  • 使用带srcSet的响应式图像
  • 优雅处理文件未找到

不应做:

  • 通过公共S3 URL暴露私有文件
  • 没有流的情况下服务大文件
  • 硬编码存储URL(使用配置/环境变量)
  • 忘记为直接访问配置CORS
  • 在生产中使用本地适配器
  • 跳过对缺失文件的错误处理

相关技能

  • bknd-file-upload - 上传文件到存储
  • bknd-storage-config - 配置存储后端
  • bknd-assign-permissions - 设置media.read权限
  • bknd-public-vs-auth - 配置公共与认证访问