回到笔记

映屿:一个免费多用户图床的配额、存储与部署实现

多用户图床映屿的实现记录:一条 UPDATE 语句怎么在并发下守住免费配额,R2/WebDAV 存储与加速域名 302 跳转怎么让历史链接永远不换,单进程单端口的部署边界(反代、PUBLIC_URL、备份与更新),以及上线后被真实使用撞出来的三个坑。

映屿:一个免费多用户图床的配额、存储与部署实现

AI 声明:本文由 Qwen3.8-Flash 辅助撰写。代码片段摘自仓库真实实现,部署与修复过程以生产环境的实际验证结果为准。

前言

之前搭过 Workers + Telegram 的私人图床,零服务器是零服务器,但它本质上是个「一个人的仓库」:没有多用户,没有配额,也没有后台。要做一个「正经」图床,缺的是这几样:

  • 多用户注册,每人一份免费配额,用完了提交申请、管理员审批,全程不涉及付费;
  • 对象存储可插拔:本地、S3 兼容、Cloudflare R2、WebDAV 都能挂;
  • 图片链接一旦发出去就永远有效,哪天换 CDN、换桶,链接不能变;
  • 界面走 MIUIX 的紧凑风格,基础字号压到 11px,手机上也能当工作台用。

先把话说明白:做这个不是因为我缺图床用——我平时几乎不往外传图,挑中它纯粹是想到了,顺手就做。图床其实是最容易糊弄的那类项目:传张图、生成个链接,完事,它就是个图床。这个项目之所以变长,是因为顺手加上了多用户、免费配额和审核——一旦要做这三样,鉴权要有会话,配额要防并发,存储要能抽象,备份要真能恢复,每一项就都糊弄不了了。所以这篇也不带货。

做完的成品在仓库里:qiuyuxc/yingyu。这篇讲实现里不那么显然的部分:配额的原子预留、存储抽象和加速域名的跳转规则、一键部署的边界,以及三个上线后才暴露的坑。

整体结构

项目本身的形态是「单进程、单端口、两套存储」:

浏览器 ──▶ HTTPS 反向代理(自带 Caddy,或已有 Nginx / Caddy 等)
                    │  只转发到 127.0.0.1:8787
                    ▼
     单 Node 进程(Node ≥ 22.13)
     ├── 前端静态资源 dist/(React 19 + Vite 构建)
     ├── Hono 4 API(zod 校验,nodemailer 发信)
     └── 原图读取 /i/:id
              │
   ┌──────────┴──────────┐
   ▼                     ▼
SQLite(WAL)        对象存储抽象层
元数据 + 配额账本    本地 / S3 兼容 / R2 / WebDAV

几条边界在设计时就定死了:

  • API 只监听 127.0.0.1,对外流量必须经过 HTTPS 反向代理,代理负责传递真实客户端地址;
  • PUBLIC_URL 是唯一事实源:外链、邮件里的链接、API 的请求来源校验全部由它生成,必须等于用户实际访问的地址——配错的典型症状是 API 返回 403「请求来源不受信任」,而不是页面打不开;
  • 元数据和对象字节分家:配额账本和文件记录在同一库同一事务里对账,对象字节在可插拔的存储层;
  • 前端构建产物和服务端由同一进程提供,全站对外只有一个端口,支持前端路由刷新。

技术栈刻意选薄:React 19 + Vite 7 前端,Hono 4 做 API,zod 校验,nodemailer 发邮件。数据库没有 ORM,也没有第三方驱动,直接用 Node 内置的 node:sqlite:

import { DatabaseSync } from 'node:sqlite';
const db = new DatabaseSync(resolve(dataDir, 'yingyu.sqlite'));
db.exec('PRAGMA journal_mode = WAL; PRAGMA foreign_keys = ON;');

代价是绑定了 Node 22.13 起的运行时,好处是少一个原生依赖,镜像和裸机部署都干净。前端、后端加测试大约六千行 TypeScript。

一、配额:一条 UPDATE 守住并发

免费多用户图床最容易烂掉的地方是配额。「先查余额、再写入」的常规写法在并发下会超发:两个请求同时读到「还剩 10MB」,各自塞进 8MB,账就花了。映屿把预留直接写进那条 UPDATE 的条件里:

const reserved = db.prepare(
  "UPDATE users SET reserved_bytes = reserved_bytes + ? " +
  "WHERE id = ? AND status = 'active' " +
  "AND used_bytes + reserved_bytes + ? <= quota_bytes"
).run(file.size, user.id, file.size);

影响行数为 0 就是额度不够,上传直接拒绝。SQLite 单写者特性保证这条语句本身原子,不需要额外的锁或事务重试。

账目的完整生命周期是三段式:

阶段动作
上传开始reserved_bytes += size(条件不足则拒绝)
上传成功reserved -= size,used += size
删除图片存储侧真删成功才返还空间

失败又清理不掉对象时,预留留在账上不自动放行,由后台「未完成任务」页处理:任务停止超过 10 分钟才能清理,同一任务只能返还一次。这样「配额对不上」永远有一个可审计的出口,而不是靠猜。

扩容走人工审批:用户提交需要的总配额(不是增量)和用途,管理员批准、退回或直接调额。付费不做,但接口留了——设置 QUOTA_INTEGRATION_TOKEN 后开放 POST /api/integrations/quota,外部服务可以直接给用户调额,eventId 幂等,重试不会执行两次。新额度不能低于已用加预留之和,这条校验在扩展点 server/quota.ts 里统一兜底。

二、存储与加速域名:链接永远不换

图片链接统一是本站的 /i/:id,默认由服务器读取存储再吐给浏览器,存储桶可以全程保持私有。R2 和 S3 配置支持一个可选的「加速域名」,填了之后,公开图片先在本站检查状态,再 302 跳过去:

return c.redirect(publicObjectUrl(storageConfig.publicBaseUrl, row.object_key), 302);

规则上有几条明确的边界:

  • 上传和删除仍走原来的 S3 API,加速域名只负责读;
  • 私密图片和历史头像照常鉴权,不跳转;
  • 加速域名随时可换,历史链接不变——这是整个设计的出发点;
  • 反代端要自行限制私密对象的访问路径,直接把整个桶公开会绕过本站校验。

存储凭据用 AES-256-GCM 加密入库,后台接口不回传完整密钥,编辑时留空表示保留旧值。R2 的端点必须是 https://<32 位账户 ID>.r2.cloudflarestorage.com 形式的 HTTPS 账户端点,用 S3 API 凭据,Region 固定 auto。切换默认存储只影响后续上传,仍有图片的存储不能删除或改路径。

三、界面:11px 的紧凑是压出来的

MIUIX 风格的「紧凑」不是把字号调小就完事,是一整套约束:

  • 基础字号 11px,所有组件按它重排,浏览器回归里专门断言这个字号和「无横向溢出」;
  • 三种配色(黑白 / 森林绿 / 雾蓝)与浅色 / 深色 / 跟随系统各自独立设置,都是浏览器级存储——改配色只影响当前浏览器,换账号沿用,不会写回服务器;
  • 手机端在空白区域右滑开侧栏、左滑收起,竖向滚动和输入框保留原手势;
  • 新手引导是跟随式的:高亮真实控件,带用户完成选图、切可见性、上传、复制链接,每步操作成功才推进,失败停在原地等重试;
  • 公告分两层:登录后的大公告和公告中心保存历史,图片页顶部的小公告横条。

首页示例相册的轮播按「不打扰」原则处理:悬停、键盘聚焦、页面隐藏、离开视口都会暂停,系统开启「减少动态效果」时直接关掉轮播和位移动画。

四、部署:一键脚本和它的边界

标准路径是 Docker Compose,install.sh 覆盖全程:给域名、选 caddy(自动申请并续期 HTTPS 证书)或 external(复用已有 Nginx / Caddy,转发到 127.0.0.1:8787)、创建首位管理员、构建镜像、等健康检查通过。之后的 update / backup / restore / status / logs 都走同一个脚本,并带部署互斥锁,防止备份与更新同时改同一实例。

更新的设计是「先构建、后停机、再快照」:新镜像构建成功才短暂停止应用,保存更新前的数据库、本地对象和 .env,然后启动新版等健康检查。构建或备份失败会还原源码和原镜像引用,服务不受影响;数据库迁移已经开始之后才失败的场景,会保留快照和新版源码方便查日志。数据库升级在启动时自动完成。恢复要求代码、数据库和加密密钥来自同一份快照——密钥单独换了,存进去的凭据就解不开了。

本地开发更轻:Node 22.13+ 和 npm,npm ci && npm run setup && npm run dev 就能跑起来,默认本地存储,数据在 data/。

这条标准路径不是只在文档里成立的:CI 用真实 Docker Compose 验证生产容器、持久数据卷和备份流程,脚本本身另有临时 Git 仓库加模拟 Docker 的专项测试。

上线后的三个坑

域名可用之后的一轮真实使用撞出来的问题,共同点是:演示数据永远碰不到。

小公告关掉,刷新又回来了

图片页顶部的小公告,关闭状态只存在当前页面的内存里,刷新即丢;大公告没问题,因为它本来就做了持久化。修法是把关闭状态按「账号 + 公告内容版本」存进浏览器:同一条公告关掉后保持隐藏,管理员改了内容才重新提醒。排查时顺带把同类设置的生效范围(浏览器级 / 账号级 / 站点级)全部理清写进了 README——这类边界在实现时最容易被含糊带过。

一串 R2 端点把卡片顶出了容器

R2 的账户端点是 32 位 ID 开头的超长 URL,填进存储配置后,卡片被撑破、整张顶出容器。根因在 CSS Grid:列默认最小宽度是 min-content,长 token(无空格 URL、base64 之类)会把 min-content 顶到容器外,省略号样式根本没机会生效。修复是让列和卡片允许收缩(minmax(0, 1fr) 一类处理),长名称、端点、路径各自换行或截断,在 320px 到 1440px 的宽度下用真实的长端点数据复测。

测试自己触发了登录限流

公告修复的回归用例挂过一轮,不是功能问题:新增测试和别的用例共用同一个管理员账号,累计登录次数触发了限流。给需要管理员登录的用例分配独立测试账号后通过。限流没做错——它只是没打算惯着测试。

局限

  • 单机 SQLite,没有分布式,适合「一台小机器能跑起来的免费多用户」这个规模,不是为高并发设计的。
  • 备份快照包含数据库、本地对象和配置;R2 / S3 / WebDAV 里的远端对象需要另行备份,快照只存它们的记录和连接配置。
  • 注册验证码、找回密码、审核通知都依赖 SMTP 配置到位;「服务器已接受」不等于进收件箱,最终到达看邮件服务商。
  • 配额审核是人工的,扩容速度取决于管理员多久看一次后台。

结语

这个项目里最值钱的两块,一块是配额那条带条件的 UPDATE——用数据库的原子性替掉了本要写的一层锁逻辑;另一块是加速域名的 302 设计——把「链接永远不换」做成了存储层可随意迁移的前提。其余的,都是 11px 字号、长字符串和限流这些细节在提醒:能用和敢给别人用之间,隔着真实数据的一轮使用。

项目地址
源码仓库qiuyuxc/yingyu(Apache-2.0)

感谢你读到这里。

继续翻翻

留下一点想法

在 GitHub 查看 ↗

使用 GitHub 账号登录,分享你的想法。

正在准备评论…

来自 Kukie 的小提示

站点公告