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
-
在Cloudflare仪表板中创建R2存储桶
-
在存储桶上启用公共访问
-
配置自定义域(Cloudflare DNS):
- 添加CNAME:
media.yourapp.com-><bucket>.<account>.r2.dev
- 添加CNAME:
-
在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}`,
},
},
},
});
- 通过自定义域服务文件:
const publicUrl = `https://media.yourapp.com/${filename}`;
AWS S3 with CloudFront
-
创建S3存储桶,带公共读取(或CloudFront OAI)
-
创建CloudFront分发:
- 源:S3存储桶
- 缓存策略:CachingOptimized
- 自定义域(可选)
-
使用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仪表板中:
- 转到缓存 > 缓存规则
- 为您的R2子域创建规则
- 设置边缘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。
原因:
- 文件名不存在
- 路径/大小写错误
- 文件已被删除
修复: 验证文件存在:
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 - 配置公共与认证访问