跳到正文

· 14 分钟 · 后端 / Cloudflare R2 / 数据库 / 前端

站长文件空间和社区:R2 存文件,以及一个小型社交网站的数据设计

这一篇做了三件事:把留言板挪到显眼的地方,给站长加一个用 R2 存文件的私人空间,再做一个能发帖、点赞、评论的社区。重点讲文件上传的安全细节、点赞计数怎么保证不出错,以及静态网站怎么做「每篇帖子一个页面」。

这是 Seabloom 建站记录的第四篇。前三篇分别是:静态网站、加上后端、上线后的体检。

这一篇做了三件事,按顺序一件做完再做下一件:

  1. 留言板挪到显眼的地方:改动小,但顺带解决了一个响应式布局的问题。
  2. 站长文件空间:像一个私人网盘,站长可以上传、下载、删除文件。第一次用到 Cloudflare 的对象存储 R2。
  3. 社区:大家可以发帖、点赞、评论,每个人有自己的主页。这是目前网站里最复杂的功能。

读法建议:第一节很短;第二节重点看「文件上传的安全细节」;第三节重点看「点赞计数」和「静态网站怎么做动态页面」。最后是面试问答。

一、留言板挪到显眼的地方

改了什么

  • 顶部导航加上「留言」。
  • 首页的留言区从倒数第二段挪到第二段(紧跟「近况」),加了一个「写一句留言」按钮。

顺带解决的问题:导航放不下了

导航原来有 5 个链接,加上「留言」和后来的「社区」变成 7 个。手机屏幕只有 375 像素宽,放不下。

常见的做法是在小屏幕上把导航收进一个「☰ 菜单」按钮。实现上就是两套导航,用 CSS 决定显示哪一套:

<div className="hidden md:block">   {/* 宽屏:横排的导航 */}
  <NavLinks />
</div>
<MobileNav />                        {/* 内部是 md:hidden:窄屏才显示菜单按钮 */}

md 是 Tailwind 的断点,表示「宽度 768 像素以上」。

一个踩坑:断点选错了。 第一版用的是 sm(640 像素)。在 640 像素宽的窗口里检查时发现,7 个链接挤不下,每个链接被压成了竖排:

关  项  博  留  社
于  目  客  言  区

浏览器的默认行为是:空间不够时,中文可以在任意两个字之间换行。修法有两步:

  1. 断点改成 md(768 像素),这个宽度放 7 个链接绰绰有余;
  2. 给链接加上 whitespace-nowrap(不许换行)。这样即使将来链接再多,最坏的情况也是横向溢出,而不是变成竖排,问题更容易被发现。

教训:响应式布局要在断点附近的宽度测,不能只测「手机」和「电脑」两种。 问题往往出在两者之间。

二、站长文件空间

需求

站长需要一个地方存一些小文件:可以上传、下载、删除,只有站长能看到。所有站长共用一个空间。

为什么用 R2

第三篇讲过:文件不适合放进数据库,应该放进对象存储,数据库里只记文件信息。Cloudflare 的对象存储叫 R2,免费 10 GB,下载流量不收费。

所以数据分两处存:

存在哪存什么
R2文件本身。键(文件名)是随机生成的 ID
D1 数据库文件信息:原文件名、大小、谁传的、什么时候
-- migrations/0005_files.sql
CREATE TABLE files (
  id            TEXT PRIMARY KEY,   -- 也是 R2 里的键
  name          TEXT NOT NULL,      -- 原文件名,只用于显示
  size          INTEGER NOT NULL,
  content_type  TEXT NOT NULL,
  uploader_id   TEXT NOT NULL REFERENCES users(id),
  created_at    INTEGER NOT NULL
);

为什么 R2 里的键用随机 ID,而不用原文件名?

  • 重名:两个站长都传了 简历.pdf,用文件名做键会互相覆盖。
  • 路径注入:文件名可以是 ../../别的东西。虽然 R2 的键只是字符串,没有真正的目录,但养成「用户输入不当路径用」的习惯总是对的。
  • 不可猜:随机 ID 没法被枚举。

开通 R2 和绑定

R2 要先在 Cloudflare 后台开通一次(需要绑定支付方式,免费额度内不扣钱),然后建一个桶(bucket,相当于一个顶层文件夹),再在 wrangler.jsonc 里绑定:

"r2_buckets": [{ "binding": "FILES", "bucket_name": "seabloom-files" }],

和数据库一样,代码里通过 env.FILES 直接访问,没有密码要管。桶默认是私有的,没有公开网址,外面的人只能通过我们的后端拿文件。

上线顺序的一个新坑: 配置里写了 R2 绑定,但桶还不存在的话,整个网站的部署都会失败,不只是文件功能不能用。所以这次的顺序是:代码先在本地做完、测完,提交但不推送;等开通 R2、建好桶、执行完数据库迁移,再推送。

上传:请求体就是文件本身

上传文件最常见的做法是用表单(multipart/form-data)。我们没有用,而是让请求体直接就是文件内容,文件名放在网址参数里:

POST /api/admin/files?name=笔记.txt
Content-Type: text/plain
Content-Length: 1234

(文件内容)

原因是 Workers 的内存有限(128 MB)。用表单的话,后端要先把整个请求读进内存、解析出文件部分。而直接用请求体,后端可以把它「流」进 R2:数据一边从浏览器传进来,一边写进 R2,不在内存里停留。

await c.env.FILES.put(id, c.req.raw.body, { httpMetadata: { contentType } });

c.req.raw.body 是一个 ReadableStream(可读流),R2 会一块一块地读。

上传前的三道检查

const size = Number(c.req.header("content-length"));
if (!Number.isFinite(size) || size <= 0) return fail(c, 411, ...);   // 1. 必须声明大小
if (size > MAX_FILE_BYTES) return fail(c, 413, ...);                  // 2. 单个文件不超过 50 MB
if ((await usedBytes(db)) + size > QUOTA_BYTES) return fail(c, 413, ...); // 3. 总量不超过 5 GB

关键在于这些检查都发生在读文件内容之前。只看请求头里声明的大小,超了就直接拒绝,不浪费流量和时间。

为什么能相信 Content-Length? HTTP 规定请求体的长度以这个头为准。客户端如果声明 10 字节、实际多发,运行时读请求体时不会把多出来的部分当作这个请求的内容,R2 写入也会因为长度对不上而失败。所以声明的大小就是能写进去的上限。

前端也会先查一遍大小,免得把一个 200 MB 的文件传了半天才被拒绝。但前端的检查只是为了体验,后端的检查才是为了安全,因为任何人都能绕过网页、直接调接口。

两个存储之间的一致性

文件存在 R2,信息存在 D1。这是两个独立的系统,没法放进同一个事务。如果上传时 R2 写成功了、D1 写失败了怎么办?

await c.env.FILES.put(id, body); // 第一步:存文件
try {
  await c.env.DB.batch([插入文件信息, 写操作日志]); // 第二步:记信息
} catch (err) {
  await c.env.FILES.delete(id); // 第二步失败:把第一步撤销
  throw err;
}

删除时的顺序反过来:先删数据库记录,再删 R2 文件。万一第二步失败,留下的是一个「列表里看不到、占着空间的孤儿文件」,而不是一条「列表里有、点了却下载不了」的记录。前者用户无感,以后可以批量清理;后者是用户直接看得到的错误。

原则是:两个操作没法同时成功或失败时,安排好顺序,让失败时留下的是「最无害」的状态。 这在分布式系统里很常见,叫做补偿(compensation)。

下载:防止上传的文件变成攻击工具

这是整个文件功能里最重要的安全细节。

设想有人(比如一个被盗的站长账号)上传了一个 evil.html,内容是一段恶意脚本。如果下载时浏览器把它当成网页直接打开,这段脚本就会在 haiyangliu.com 这个域名下运行,可以读取页面、用当前登录的身份调用接口。这是一种存储型 XSS。

防法是下载时设置两个响应头:

return new Response(object.body, {
  headers: {
    "Content-Type": "application/octet-stream",
    "Content-Disposition": `attachment; filename*=UTF-8''${encodeURIComponent(name)}`,
  },
});
  • Content-Type: application/octet-stream:告诉浏览器「这是一串二进制数据」,不管原来是什么类型。浏览器不知道它是网页,就不会渲染。
  • Content-Disposition: attachment:告诉浏览器「请保存成文件,不要打开」。
  • 再加上第三篇里加过的 X-Content-Type-Options: nosniff,禁止浏览器自己去「猜」类型。

三层叠在一起,任何文件都只会被下载,不会被执行。

filename*=UTF-8''... 是带中文文件名的标准写法(RFC 6266),否则中文文件名下载下来会变成乱码。

进度条:为什么上传用 XMLHttpRequest

整个项目的其他请求都用 fetch,唯独上传用了老式的 XMLHttpRequest。因为 fetch 拿不到上传进度,而 XHR 有一个 upload.onprogress 事件:

xhr.upload.onprogress = (e) => e.lengthComputable && onProgress(e.loaded / e.total);

这是一个「新 API 不一定全面替代旧 API」的例子。

测试

在测试里写了一个假的 R2(用一个 Map 存文件),和第二篇里假的 D1 一样,只实现用到的三个方法。8 个测试覆盖:

  • 上传后列表、用量、R2 里的文件都对得上;
  • 下载内容一致,并且强制作为附件;
  • 文件名里的 ../../ 被去掉;
  • 删除后 R2 里也没了,操作日志里有上传和删除两条;
  • 超过单个文件上限、超过总配额都被拒绝,而且 R2 里什么都没写进去;
  • 普通用户、没登录的人对每个接口都是 403 / 401;
  • 别的网站不能代替站长上传(CSRF)。

本地用真实运行环境(wrangler dev,会在本地模拟 R2)上传了一个 5 MB 的文件,下载回来逐字节比对,完全一致。

三、社区

需求

参考常见的社交网站,先做核心功能:

  • 发帖(纯文字,最多 1000 字)、删除自己的帖子;
  • 按时间倒序的信息流,滚到底自动加载;
  • 点赞、取消点赞;
  • 评论(第一期只有一层);
  • 个人主页:显示这个人的所有帖子。

防滥用和留言板一样:限流、查重复、敏感词进待审核、站长可以隐藏。

数据表

CREATE TABLE posts (
  id             TEXT PRIMARY KEY,
  user_id        TEXT NOT NULL REFERENCES users(id),
  body           TEXT NOT NULL,
  created_at     INTEGER NOT NULL,
  deleted_at     INTEGER,                 -- 软删除
  status         TEXT DEFAULT 'visible',  -- visible / hidden
  review         TEXT,                    -- 'pending' = 等站长审核
  like_count     INTEGER DEFAULT 0,       -- 冗余计数,见下文
  comment_count  INTEGER DEFAULT 0
);

CREATE TABLE post_likes (
  post_id     TEXT NOT NULL REFERENCES posts(id),
  user_id     TEXT NOT NULL REFERENCES users(id),
  created_at  INTEGER NOT NULL,
  PRIMARY KEY (post_id, user_id)          -- 一个人对一篇帖子只能赞一次
);

CREATE TABLE comments ( ... 结构和 posts 类似,多一个 post_id ... );

点赞:怎么保证计数不出错

点赞看起来最简单,其实是这次最值得讲的地方。

第一个问题:一个人只能赞一次。 如果在代码里先查「赞过没有」、没赞过再插入,两个请求同时到达时(比如用户手快双击),两个都会查到「没赞过」,结果插入两次。

解决办法是让数据库来保证:post_likes 的主键是 (post_id, user_id),同一对值插入第二次会违反主键约束。再配合 INSERT OR IGNORE,第二次插入会被安静地忽略,而不是报错:

INSERT OR IGNORE INTO post_likes (post_id, user_id, created_at) VALUES (?, ?, ?)

这样点赞接口就是幂等的:同一个请求发一次和发十次,结果一样。网络不好时前端重试也不会出问题。

第二个问题:点赞数怎么算。 最直接的办法是每次显示时数一遍:

SELECT COUNT(*) FROM post_likes WHERE post_id = ?

但信息流一页 20 篇帖子,就要数 20 次。所以在 posts 表里加了一个 like_count 字段,直接存好点赞数。这叫冗余字段(或者叫反范式化):同一个信息存了两份,用空间换速度。

冗余字段最大的风险是两份对不上。常见的写法是点赞时 like_count = like_count + 1,但上面那种双击的情况,INSERT OR IGNORE 第二次什么都没做,+1 却执行了两次,计数就错了。

我们的做法是不做加减,每次都重新数一遍,并且和点赞操作放在同一个事务里:

await db.batch([
  db.prepare("INSERT OR IGNORE INTO post_likes ...").bind(id, me.id, now),
  db
    .prepare(
      `UPDATE posts
              SET like_count = (SELECT COUNT(*) FROM post_likes WHERE post_id = ?1)
              WHERE id = ?1`,
    )
    .bind(id),
]);

这样不管点赞成功、重复还是取消,like_count 永远等于真实的点赞数。只在点赞这一刻多数一次,而读的时候(次数多得多)完全不用数。

测试里专门测了:赞、重复赞、另一个人赞、取消,每一步的计数都对。

评论数也是同样的做法。还多了一层:评论数只算公开的评论。待审核的、被隐藏的、已删除的都不算,这样和别人实际看到的评论条数一致。所以评论被隐藏、被恢复、被删除、站长封禁某人并隐藏他的评论时,都会重新数一遍。

乐观更新

点赞按钮用了和留言板一样的「乐观更新」:点下去立刻变红、数字 +1,同时发请求;请求失败再改回来。用户感觉不到网络延迟。

请求成功后,用服务器返回的计数覆盖前端算的数字。因为在这期间别人可能也点了赞,服务器的数字才是准的。

谁能看到什么:一条规则管所有

帖子和评论有好几种状态:公开、待审核、被隐藏、已删除。不同的人能看到的不一样:

状态访客作者本人站长
公开✅✅✅
待审核❌✅✅
被隐藏❌❌✅
已删除❌❌❌

如果每个查询都手写这套规则,很容易某个地方漏写一条。所以把它写成一个函数,所有查询共用:

// ?1 = 当前用户 ID,?2 = 是不是站长
const VISIBLE = (t: string) =>
  `${t}.deleted_at IS NULL AND (
     ${t}.status = 'visible'
     OR (${t}.review = 'pending' AND ${t}.user_id = ?1)
     OR ?2 = 1
   )`;

信息流、帖子详情、评论列表、点赞前的检查,用的都是这一条规则。

为什么站长能看到被隐藏的内容? 这样站长就能在社区里直接处理:被隐藏的帖子显示为半透明,带「已隐藏」标签和「恢复」按钮;待审核的带「通过」按钮。不用为社区再单独做一个后台页面。

静态网站怎么做「每篇帖子一个页面」

这是这次遇到的一个架构问题。

我们的网站是静态导出的:next build 时把每个页面都生成好 HTML 文件。博客文章可以这样做,因为构建时就知道有哪些文章。但帖子是用户随时发的,构建时根本不知道会有哪些帖子,没法提前生成 /community/post/abc123/ 这样的页面。

解决办法是:只生成一个页面,帖子 ID 放在网址参数里:

/community/post/?id=abc123
/community/user/?id=def456

这个页面构建时生成的是一个「空壳」(只有标题和「加载中」),浏览器打开后,从网址里读出 ID,再去接口拿数据。

export function PostDetail() {
  const id = useSearchParams().get("id");
  // ... 用 id 请求 /api/posts/{id}
}

useSearchParams 必须包在 Suspense 里。 构建时没有浏览器,也就没有网址参数。Next.js 要求把读取参数的组件包在 <Suspense> 里,构建时先输出 fallback 的内容,到了浏览器里再换成真正的组件。

代价是什么?

  • 搜索引擎看不到帖子内容,因为 HTML 里只有空壳。对一个交流社区来说可以接受。如果以后需要被搜到,就要换成服务器渲染(每次请求时在服务器上生成 HTML),那就不是纯静态网站了。
  • 分享链接时没有卡片,原因同上。
  • 所以这两个页面没有设置 canonical:所有帖子共用一个 HTML 文件,设了反而会让搜索引擎以为它们都是同一页。

个人主页暴露了用户 ID,有问题吗

第二篇里,留言接口特意不返回用户 ID。现在个人主页的网址里就有用户 ID,是不是退步了?

不是。当时不返回 ID,是因为用不到(「最少必要」原则)。现在要做个人主页,必须有一个东西来标识「这个人」。能选的有三个:

  • 邮箱:隐私信息,不能公开。
  • 昵称:不唯一,两个人可以都叫「小明」。
  • 用户 ID:随机生成的 UUID,不包含任何个人信息,也没法反推出别的东西。

所以用户 ID 是最合适的。个人主页的接口也只返回昵称、是不是站长、加入时间、帖子数,不返回邮箱,测试里专门检查了返回内容里没有 @。

限流

和留言板同一套,数字按场景调整:

内容每分钟每天
帖子2 篇20 篇
评论5 条100 条
同一 IP 合计—150 次

另外 10 分钟内不能发内容一样的帖子或评论。已删除的也算数,防止「发了删、删了发」绕过限流。

测试

15 个社区测试,覆盖:

  • 没登录能看不能发;返回内容不含邮箱;
  • 长度限制、限流、查重复;
  • 游标分页不重复不遗漏;
  • 只能删自己的;
  • 敏感词帖子只有作者和站长看得到;
  • 点赞的幂等和计数;
  • 评论数只算公开评论,站长放行后才 +1;
  • 站长隐藏、恢复,都有日志;普通用户不能隐藏;
  • 封禁并隐藏时,帖子和评论一起隐藏,评论数跟着更新。

写测试时还抓到一个自己测试里的 bug:有个测试先后发两篇帖子,然后假设「第二篇在列表里排第二」。但两篇帖子是在同一毫秒发的,时间一样时按 ID 排序,而 ID 是随机的,所以顺序也是随机的。这个测试第一次通过了,第二次就失败了。

这种时好时坏的测试(flaky test)很危险:大家会习惯「失败了就再跑一次」,最后真正的 bug 也被当成偶然。修法是不再依赖顺序,直接用发帖接口返回的 ID。修完连续跑了三遍,确认稳定。

现在一共 90 个测试。

四、面试怎么讲

一分钟介绍

在个人网站上,我用 Cloudflare R2 给管理员做了文件空间,又做了一个带发帖、点赞、评论的小社区。文件上传是让请求体直接流进 R2,在读内容之前就按 Content-Length 做大小和配额检查;下载时强制 octet-stream 加 attachment,防止上传的 HTML 变成存储型 XSS。R2 和数据库不能放进同一个事务,我安排了写入和删除的顺序,失败时只会留下无害的孤儿文件。社区里,点赞用联合主键加 INSERT OR IGNORE 做到幂等,点赞数是冗余字段,但不做加减,而是在同一个事务里重新计数,保证不会出错。

可能被问到的问题

Q:文件上传有哪些安全风险?你怎么防的? A:主要有四类。一是大小,用 Content-Length 在读内容之前拒绝超大文件,并且有总配额。二是文件名,用户给的文件名只用来显示,存储时用随机 ID,避免覆盖和路径注入。三是内容被执行,比如上传 HTML 或 SVG,下载时强制 application/octet-stream、Content-Disposition: attachment,加上 nosniff。四是权限,桶是私有的,每次下载都经过后端检查身份。如果是面向所有用户的上传,还要加内容审核和病毒扫描。

Q:两个存储系统之间怎么保证一致性? A:没法用一个事务时,就安排操作顺序,并对失败做补偿。上传是先写对象存储、再写数据库,数据库失败就删掉刚写的文件;删除是先删数据库、再删文件,失败时留下的是用户看不到的孤儿文件,可以定期清理。原则是让失败后的中间状态对用户无害。更严格的做法有两阶段提交或者 outbox 模式,但对这个规模来说太重了。

Q:点赞功能怎么设计?高并发下会有什么问题? A:点赞表用 (帖子, 用户) 做主键,数据库保证唯一,插入用 INSERT OR IGNORE,所以接口是幂等的。点赞数存在帖子表里做冗余,避免列表页每篇都 COUNT。更新计数时我不用 +1,而是在同一个事务里重新 COUNT,这样重复请求也不会算错。高并发时,热门帖子的这一行会成为写入热点,可以改成先在 Redis 里计数、定期回写数据库,或者把计数拆成多行再求和。

Q:什么是幂等?为什么重要? A:同一个请求执行一次和执行多次,结果相同。网络不可靠时客户端会重试,用户也可能双击,如果接口不幂等,就会重复扣款、重复点赞。实现方法有:用数据库唯一约束、用 PUT 这种「设置为某个状态」而不是「加一」的语义、或者让客户端带一个幂等键。我的点赞接口用的是 PUT /like 和 DELETE /like,语义就是「设为已赞 / 未赞」。

Q:冗余字段有什么利弊? A:好处是读得快,列表页不用每条都做聚合查询。坏处是同一个信息存两份,可能对不上,写的时候也多一步。所以要保证每次底层数据变化时都更新它,并且在同一个事务里。我是在每个会影响计数的地方都调用同一个重新计数的函数,包括删除、隐藏、恢复、封禁。

Q:静态网站怎么做动态内容的详情页?有什么缺点? A:静态导出时没法为未知的内容生成页面,所以只生成一个页面,ID 放在查询参数里,浏览器加载后再请求数据。缺点是首屏要多等一次请求,搜索引擎和分享卡片拿不到内容。需要 SEO 的话,要换成服务器渲染或者增量静态生成。

Q:遇到过不稳定的测试吗?怎么处理? A:遇到过,测试假设两条同一毫秒插入的数据有固定顺序,但排序的第二个依据是随机 ID。处理原则是找到不确定性的来源,让测试不依赖它,而不是加重试。修完以后连续跑多遍确认稳定。

五、下一步

  • 社区第二期:帖子带图片(可以复用 R2)、关注、通知、话题标签、搜索、转发。
  • 帖子带图片时,上传就不只是站长了,要考虑:图片压缩、尺寸限制、违规图片审核、每个用户的配额。
  • 文件空间:目前所有站长共用,以后可以加文件夹、按人区分。

到这一篇为止,网站从一个纯展示的静态网站,变成了有账号、有留言、有后台、有文件存储、有社区的小型全栈应用,而且一直没有租服务器。每一步的思路都差不多:先想清楚数据怎么存、谁能看到什么、出错时会留下什么状态,然后用测试把这些想法固定下来。