图床完整方案:R2 上传接口与图片生命周期管理
上一篇配好了 Cloudflare R2 存储桶。这篇讲代码侧:上传接口怎么写、图片 key 怎么设计、以及图片的清理——编辑时和删除文章时的孤儿图片回收。这样才能覆盖图床图片完整的生命周期。
上传接口
上传接口要做三件事:验证身份、收文件、写 R2 返回 URL:
export default defineEventHandler(async (event) => {
await serverAdminAuth(event) // 只有管理员能传
const file = await readMultipartFormData(event) // 收 multipart 文件
const extension = file.filename?.split('.').pop() || 'png'
const key = `blog/${nanoid()}.${extension}` // key = 前缀 + 随机名 + 扩展名
const s3 = createR2Client(event) // S3 客户端,指向 R2
await s3.send(new PutObjectCommand({ Bucket: config.r2Bucket, Key: key, Body: file.data }))
return { url: `${publicBase}/${key}` } // 返回公开 URL
})
要点:
- 鉴权走会话:匿名请求直接拒绝,图床不是公共上传点
- key 用随机名:
nanoid()天然避免文件名碰撞和空格、中文文件名问题,原文件名只用来取扩展名 - S3 兼容:R2 走 S3 API,
@aws-sdk/client-s3直接用,生态里现成的 SDK 都能接
key 为什么是 blog/ 平铺,不按目录分
R2/S3 没有真正的目录,前缀只是字符串。blog/ 前缀已经给了「这一类图片」的抓手:迁移、缓存规则、按前缀算用量。
再往下按日期或文章 id 细分,收益很小:图片 key 是写死在 markdown 正文里的绝对 URL,换了结构旧图不会跟着变,两套前缀共存反而更乱。平铺 + 随机名对代码没有任何功能损失——图片的「索引」是编辑器和管理后台,不是 S3 控制台。
孤儿图片:编辑时的垃圾回收
文章里删掉一张图,R2 里那张图不会自己消失。需要一个垃圾回收:保存时对比「上一次的图片集合」和「这次的图片集合」,差集就是要删的孤儿。
这里有一个很隐蔽的坑:按 basename 提取 key 会静默失效。S3 删除一个不存在的 key 是返回成功的——如果拿 abc.png 去删 blog/abc.png,接口「成功」了,实际什么都没删,日志里也看不出来。必须取完整 key(含 blog/ 前缀):
export function extractImageKeys(content: string, r2Base: string): Set<string> {
const keys = new Set<string>()
const regex = /!\[.*?\]\((.*?)\)/g
for (const match of content.matchAll(regex)) {
const url = match[1]?.trim()
if (!url?.startsWith(`${r2Base}/`)) continue // 只收本站桶的图
const key = url.slice(r2Base.length + 1).split(/[?#]/)[0]
if (key) keys.add(decodeURIComponent(key))
}
return keys
}
顺带只收本站桶的图:外链图片的 basename 不该被拿去 R2 删——删一个不属于它的名字,同样是「成功但不删东西」。
删除文章时的图片清理
编辑时的 GC 只覆盖「编辑」路径。删除文章不清理的话,图片就永远留在桶里了。所以删除接口要先取正文,再删行,再删图:
const { data: post } = await supabase.from('posts').select('content').eq('id', id).single()
await supabase.from('posts').delete().eq('id', id) // 删行
const keys = extractImageKeys(post.content, base) // 从正文提取 key
if (keys.size > 0) {
await Promise.all([...keys].map(key =>
s3.send(new DeleteObjectCommand({ Bucket: bucket, Key: key }))
))
}
两个细节:
- 删行之前取正文:行删了就没得取了,而且「先删行再取」在并发下会取到已删除的记录。顺序必须是取 → 删 → 清图
- 图片删除要 await:fire-and-forget 的 promise 在 serverless 环境下会随请求结束被取消。删除文章是低频管理操作,多等几百毫秒无所谓,但要保证真的执行完
- 清理失败只记日志,不拦删除本身——GC 是尽力而为,主操作已经完成
缓存联动
更新和删除文章时,顺带把该文章详情页的缓存条目删掉——否则读者还能从缓存里读到最多 10 分钟前的旧版(甚至是已删除的文章)。这一步和上一篇的 Cache API 是同一套机制。
总结
- 上传:鉴权 + 随机 key + S3 SDK,30 行搞定
- key 设计:前缀平铺 + 随机名,不按目录分
- 孤儿清理:编辑时对比差集,删除时先取正文再删图
- 两个「静默失效」的坑:basename 删图、fire-and-forget 被取消——都是不报错但没生效,排查起来最费时间