[ 08 / 10 ] · 新生课程

第 08 课:数据库与文件存储

16 分钟200 XP

踏上构建生产级应用的旅程。

你的应用看起来不错,也能处理错误。但它什么都保存不了,也没地方放用户想上传的文件。这节课就来解决这两件事。


现在,如果用户在你的应用里输入了什么,刷新页面就没了。如果想附张图片,根本没地方存。你的应用没有记忆,也没有存储空间。

数据库(database) 给你的应用一份记忆。文件存储(file storage) 给你的应用一个地方,去放那些不那么结构化的东西——图片、音频、视频、PDF。两者结合在一起,能把一个 demo 变成真正的产品。

我们会同时用 Supabase 来做这两件事。Supabase 是一项免费服务,开箱即用地提供数据库和文件存储。一个工具,两个问题一起解决。

安全是这一课的贯穿主题。 数据库和存储是攻击者最先盯上的地方——用户数据就放在那里。请留意每一处安全提示,不要跳过。在这里把基础打牢,是「会泄露用户数据的应用」和「不会泄露的应用」之间的分水岭。


什么是数据库?

数据库就是应用保存信息的地方。把它想成一张电子表格就行:应用可以读取其中的数据,也可以写入新的数据。

你用过的每一个应用背后都有一个数据库。Instagram 把你的配文、点赞和粉丝列表存在数据库里。Twitter 存推文。Spotify 存歌单。当你关掉应用再打开,东西还在——因为它们存在数据库里,不是只存在你的设备上。

如果没有数据库,你的应用就像一块白板。用的时候看着挺好,可一旦有人擦掉它(或者刷新页面),一切都没了。

数据库是怎么组织的

一个数据库由一组 表(table) 组成。每张表存放一种东西。如果你在做一个 Todo List 应用,可能会有:

  • 一张 users 表(在用这个应用的人)
  • 一张 todos 表(他们要做的事)
  • 一张 projects 表(他们怎么组织自己的任务)

每张表都有 行(row)列(column),就像电子表格一样。todos 表可能长这样:

id  user_id  task              completed
--  -------  ----------------  ---------
1   abc123   Buy groceries     false
2   abc123   Walk the dog      true
3   def456   Finish homework   false

一行代表一条记录,一列代表这条记录的一个字段。id 列给每一行一个唯一标识符,方便数据库找到它。

什么是数据库?

数据库中的表是什么?


什么是 SQL?

数据库存放数据。SQL(读作「sequel」)就是你和数据库对话的语言。它代表 结构化查询语言(Structured Query Language),是地球上几乎所有主流数据库都说的语言:Postgres、MySQL、SQLite、Supabase(底层就是 Postgres),甚至包括 Google 的内部系统。

这一课里你会经常看到 SQL。你的 AI 智能体会把一段段 SQL 片段交给你,让你粘贴进 Supabase。看懂这些片段在做什么,能让你从「闭着眼睛运行」变成「运行前先读懂命令」。这就是「会用工具」和「被工具牵着走」之间的区别。

SQL 出乎意料地易读。下面这四个命令覆盖了应用 90% 的工作:

  • SELECT 读取行。「把属于用户 abc123 的任务都给我。」

    SELECT * FROM todos WHERE user_id = 'abc123';
    
  • INSERT 添加一行。「给用户 abc123 加一条名叫 'Buy groceries' 的待办。」

    INSERT INTO todos (user_id, task) VALUES ('abc123', 'Buy groceries');
    
  • UPDATE 修改已有的行。「把 #1 号待办标记为已完成。」

    UPDATE todos SET completed = true WHERE id = 1;
    
  • DELETE 删除行。「删掉 #2 号待办。」

    DELETE FROM todos WHERE id = 2;
    

你的应用每天会上千次访问数据库,而每一次读写背后跑的都是这四种命令之一。你的 AI 智能体在替你写 SQL,你的任务是看到时能认得出来。

为什么值得学一点 SQL

哪怕你从不亲手写查询,了解一点 SQL 也很值得:

  • 你能看懂智能体写的东西。 这条查询安全吗?有没有按正确的用户过滤?会不会删多?你至少能看出来大致有没有问题。
  • 你能调试。 当应用里「什么都没显示」时,问题通常就藏在查询里。能打开 Supabase 的 SQL 编辑器自己跑一下查询,是一项很有用的技能。
  • 它是长期资产。 SQL 已经做了 50 年的标准。框架来来去去,你今天学的 SQL 到 2050 年还能用。
  • 它通用。 同样的查询能跑在 Postgres、MySQL、SQLite 以及几乎所有云数据库上。学一次,到处用。

你不需要变成专家。能认出上面那四个命令,就足够上完这一课和后面绝大部分内容了。

什么是 SQL?

即使 AI 智能体会替你写查询,为什么仍然值得了解 SQL?


什么是文件存储?

数据库适合处理结构化数据(文本、数字、日期、各种关系),但不适合直接存原始文件。比如把一张 5MB 的照片放进数据库行里,读写都会变慢,备份成本也会上升,浏览器拿到文件也不够高效。

文件存储(file storage) 就是专门解决这个问题的。它提供一个云端存储空间,每个文件都有自己的 URL。应用上传文件后拿到路径,把路径存在数据库里;之后要显示文件时,再通过这个路径取回文件。

适合放进 storage 的内容包括:

  • 头像和个人资料图片
  • 用户上传的图片、视频、音频、PDF
  • 各种导出文件、报表和生成文件

分工永远一样:元数据进数据库,文件字节进 storage,数据库里只保存一个指向这些字节的路径。

为什么要用文件存储,而不是把文件直接放进数据库?


来构建一个清单应用

我们会从一个最小但确实需要数据库的例子开始:一个清单。你通过输入框添加条目,通过删除按钮移除条目。刷新页面后,清单仍然存在。这就是数据库带来的能力。

打开 Repl 里的 AI 面板,粘贴:

I am building a project to help me learn databases. Today we will 
build a simple list where items are appended via an input field 
and deleted via a delete button.

Rules:
- The list MUST be stored in a real hosted database (Supabase, 
  Firebase, or similar). Do NOT use local React state, 
  localStorage, sessionStorage, or any client-only storage. 
  Do not use optimistic updates. The displayed list must always 
  come from the database.
- Every add inserts into the database and then re-reads the list 
  from it. Every delete removes from the database and then re-reads.
- Database credentials come from environment variables that are 
  ALREADY set in my shell as **`SUPABASE_URL`** and 
  **`SUPABASE_KEY`**. Read those exact names directly from 
  `process.env` at runtime. Do NOT ask me to create a `.env` / 
  `.env.local` / `config.js` / any other file with placeholders, 
  and do NOT ask me to re-export the variables under different 
  names (`VITE_SUPABASE_URL`, `NEXT_PUBLIC_SUPABASE_URL`, etc.). 
  They're already set. Use them.
- Pick a stack that can actually read shell env vars at runtime. 
  Vanilla HTML/CSS/JS served by a tiny Node or Python local server 
  that reads `process.env` at request time is fine (and simplest). 
  Next.js server components or server routes are fine. **Do NOT 
  use Vite + React** for this — Vite inlines env vars at build 
  time, requires the `VITE_` prefix, and only reads them from an 
  `.env` file on disk, which breaks the "use my shell exports 
  directly" rule. If you use a framework with a prefix convention, 
  you're responsible for wiring `SUPABASE_URL` → whatever name 
  the framework needs, inside a `package.json` dev script — 
  without me having to do anything.
- Ask me zero follow-up questions about credentials — they're 
  already in my shell.
- If the database isn't configured yet (missing credentials or 
  missing table), show a clear error message in place of the list. 
  Do not silently fall back to a local-only list that pretends 
  to work.
- Keep the input field and the Add button always enabled and 
  clickable. The user should be able to type and click regardless 
  of the database state. If the database isn't configured, clicking 
  Add should simply attempt the request and show the error — don't 
  disable controls as a "guard."
- Keep the UI minimal. Standard system fonts, plain colors, no 
  gradients or fancy backgrounds. This is a learning exercise, 
  not a design showcase.

Run it locally so I can see it in my browser.

Replit Agent 会搭出应用框架,并发现自己需要连接数据库。接着它会弹出环境变量面板,要求你填写 Supabase 的 Project URL 和 anon public key:

Replit Agent 要求填写 Supabase Project URL 和 anon public key,旁边显示 Environment Variables 面板和 Save Variables 按钮

你现在还没有这些 key。下面就来拿。

创建一个 Supabase 项目

Supabase 是一项免费的数据库服务。前往 supabase.com 注册账号,也可以用 GitHub 账号登录。

Supabase 的 Get started 注册页,提供 Continue with GitHub 和邮箱密码选项

登录后,点击 New Project。输入项目名称(比如 my-list-app),选择靠近你的区域,再设置一个强数据库密码。这个密码要妥善保存。

Supabase 的 Create a new project 表单,包含 Organization、Project name、Database password、Region 和 Security 选项

点击 Create new project。创建过程大约需要 30 秒。

点击 Connect

在 Supabase 控制台顶部点击 Connect。打开的对话框里会显示你的 key:

Supabase 的 Connect to your project 弹窗打开在 .env.local 标签页,显示 Project URL 和 publishable key

把 URL 和 publishable key 复制下来(每个 = 右边的值),填进 Replit 的环境变量面板,然后点击 Save Variables。Replit 会重启应用,智能体会把它们连接到代码里。

安全——不要使用第二个 key。 Supabase 还会显示一个 service_role key。这个 key 会绕过项目中的所有安全规则。如果它出现在前端、Git 历史或日志里,任何拿到它的人都能读写甚至删除你的数据。应用里只使用 anon / publishable key,绝不要使用 service_role key。 公开仓库里的 key 往往几分钟内就会被机器人扫到。

创建表

试着在预览里添加一条记录。现在多半还不会成功——你可能会在应用里看到 Supabase 报错,或者点完 Add 后列表仍然是空的。无论是哪种情况,原因都一样:智能体写好的代码会读写一张表,但这张表还不存在于你的 Supabase 项目中。Replit 不能替你创建表,因为 Supabase 把修改表结构的权限放在了智能体够不到的地方。

  1. 让智能体诊断问题:

    > I tried adding an item but nothing happened (or I got an error 
      about a missing table). Can you diagnose what's wrong with 
      Supabase and give me the SQL I need to run to create the table 
      this app needs?
    
  2. 智能体会回复一段 CREATE TABLE ... 语句。表结构大概长这样:

    CREATE TABLE public.list_items (
      id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
      name TEXT NOT NULL,
      created_at TIMESTAMPTZ DEFAULT now()
    );
    
  3. 在 Supabase 控制台里,点击左侧栏的 SQL Editor

    Supabase 侧边栏显示 Project Overview、Table Editor,以及高亮的 SQL Editor
  4. 把智能体给你的 SQL 粘贴进去,点 Run

  5. 回到你的应用,刷新页面(如果之前有错误提示框,就点上面的 Refresh)。

然后把这张新表的 RLS 关掉:Table Editor → 你的表 → 顶部的 RLS disabled 开关

安全——稍后你会重新打开 RLS。 一个人开发时,暂时关掉它没问题。在 sophomore 阶段的鉴权课里,你会重新打开 RLS,并加上一条按用户隔离的策略。永远不要让已经上线的真实应用关着 RLS。

再试一次添加条目。这次应该能存下来了。

试试看

预览里现在应该能看到一个可用的清单。输入内容,点击 Add——它会出现。点击 Delete——它会消失。刷新页面——条目仍然还在。

这就是数据库带来的持久化。

Supabase SDK 是做什么的?


给你的应用加上 Storage

接下来,让用户能够上传文件——比如给每条清单条目附上一张图片。

创建一个 Bucket

在 Supabase Storage 里,bucket(桶) 是一个有名字的文件容器,类似一个顶层文件夹。通常每一类内容会单独放在一个 bucket 里:avatarspost-imagesdocuments

  1. 在 Supabase 控制台里,点击侧栏的 Storage

  2. 点击 New bucket

    Supabase Storage 空状态页面,显示 Create a file bucket 说明文字和绿色的 + New bucket 按钮
  3. 把它命名为 todo-attachments

  4. 暂时保持 Public bucket 关闭

  5. Save

安全——public bucket 与 private bucket。 public bucket 里的所有文件,只要拿到 URL,任何人都能读取。如果内容本来就应该公开(比如社交应用里的头像),这样没问题。private bucket 则要求拥有者的会话才能读取。任何用户期望保持私密的内容(收据、医疗资料、消息附件)都应该使用 private bucket。默认设为 private。只有在你确实想公开内容时才打开 public。

设置一条 Storage 策略

bucket 和表一样,也使用类似 RLS 的策略机制。你现在还没有鉴权,所以先设置一条宽松的策略,让上传流程能跑起来;后面再把它收紧:

  1. 点击你的 todo-attachments bucket

  2. 点击 Policies 标签

  3. 点击 New policyUse template → 选 Allow access to JPG images in a public folder to anonymous users

  4. Allowed operation 下,勾选 SELECT(读取 / 下载)和 INSERT(上传)。下方列表会显示每个操作分别启用哪些客户端函数(uploaddownloadlist 等)。

  5. 点击 Save policy。Supabase 会显示一个确认页,列出它将为你勾选的每个操作执行的具体 SQL:

    Supabase 的 Reviewing policies to be created for todo-attachments 确认页,显示 INSERT 和 SELECT 的 CREATE POLICY SQL 语句,以及 Back to edit 和 Save policy 按钮
  6. 在确认页上再点一次 Save policy 确认。回到 Policies 标签页,你会看到 todo-attachments 下多了两条新策略。这样就完成了。只要上传内容符合策略规则,bucket 就会接收上传,并提供文件访问。

小心——这个模板到底允许了什么。 看 SQL 就知道:只允许 .jpg 文件、只允许在名为 public/ 的文件夹里、只允许匿名用户。如果你的上传用的是别的扩展名或路径,策略就不会匹配,上传会悄悄失败。让你的智能体把图片保存为 public/{timestamp}.jpg,让策略能命中(这一步会在下面的上传环节里做)。

安全——这条策略后续也要收紧。 在 authentication 那节课里,你会把它换成一条按用户文件夹({user_id}/...)限制上传和读取的策略。现在的状态是:任何拿到你应用 URL 的人都能上传。一旦有真实用户,就别再让它这么开放。

加一列来引用文件

你需要在表里有一个地方存放每个上传文件的路径。让你的智能体:

> I need to add a column to my list table in Supabase to store the 
  path to an uploaded image. What SQL should I run to add an 
  "attachment_path" text column that can be null?

智能体已经知道你的表叫什么,所以会给你正确的语句。它大概长这样:

ALTER TABLE public.list_items
  ADD COLUMN attachment_path TEXT;

在 Supabase 控制台里,点击左侧栏的 SQL Editor,粘贴智能体给你的内容,然后点编辑器右上角的 Run

Supabase SQL Editor 中粘贴了 alter table public.list_items add column attachment_path text; 查询,点击 Run 后 Results 面板显示 Success. No rows returned

你应该会在结果区看到 Success. No rows returned。你的表现在已经为每个上传文件的路径预留好位置了。

在应用里上传文件

告诉你的 AI 智能体:

> add an optional file input to my add-item form. accept only JPG 
  images. when the user picks a file, show a small thumbnail or 
  the filename next to the input so they can see the file is 
  attached. the file should upload when they click Add (not when 
  they pick the file), so make sure the Add button is the obvious 
  way to submit both the text and the image together. upload to 
  the todo-attachments Supabase bucket under a path like 
  public/{timestamp}.jpg (the "public/" prefix and ".jpg" extension 
  are required by the storage policy). save the returned path into 
  the attachment_path column for the new item. if no file is 
  picked, just save the item without an attachment.

显示这些文件

> when rendering each list item, if it has an attachment_path, use 
  supabase.storage.from('todo-attachments').getPublicUrl(path) to 
  build a URL. render images inline, and anything else as a download link.

限制用户可以上传什么

storage 是大量安全事故发生的地方。如果不加限制,用户可以上传一个 2GB 的文件、一个恶意可执行文件,或者一个会在其他用户浏览器里执行脚本的 HTML 文件。你需要一些护栏。

> before uploading, enforce these rules:
  - accept only image/jpeg, image/png, image/webp, and application/pdf
  - reject files larger than 5MB
  - rename uploaded files so the stored filename doesn't contain 
    user-supplied text
  - show a friendly error if the check fails

安全——为什么要校验 MIME 类型和文件大小。 浏览器有时会执行那些它认为是 HTML 或 JavaScript 的文件,哪怕它们是当作图片上传上来的。攻击者上传一个名叫 photo.jpg、内容其实是 HTML 的文件,就能在其他用户的浏览器里执行脚本——这就是经典的基于上传的 XSS。在客户端校验 MIME 类型主要是为了 UX,真正的强制要靠 bucket 策略(以及 Supabase 上的文件大小限制)在服务端把关。如果不限制文件大小,一个用户就能撑爆你的配额或者刷爆你的账单。

为什么要在上传时校验 MIME 类型和文件大小?


安全要点回顾

进入下一节前,给自己过一遍这份心理清单:

  • Key: anon key 放进环境变量;service_role key 永远只待在 Supabase 或一台可信的服务器上。
  • 数据库: 生产环境开 RLS,只在你一个人测试时关掉。
  • 输入校验: 限制最大长度,去掉首尾空白,渲染回页面前过滤掉 HTML。
  • Bucket: 默认 private;只有内容真的公开时才设为 public。
  • 上传: MIME 类型和大小限制要在客户端做,同时也要通过 bucket 策略强制执行。
  • 永远不要相信客户端。 你在浏览器里强制的所有规则,也要在 Supabase 里再强制一遍。

数据库 vs 区块链:什么放在哪里?

你刚刚学会了如何把数据存进数据库、把文件存进云存储。课程后面你会学到如何把数据存到区块链上。它们之间不可互换——每一种都适合不同类型的数据。

这些时候用数据库(和 storage):

  • 数据是私有的或与特定用户相关(用户的资料、待办、上传内容)
  • 数据频繁变化(点赞、浏览数、状态更新)
  • 你需要进行复杂查询(搜索、筛选、排序)
  • 在规模化时关心性能和成本——数据库和 storage 又快又便宜

这些时候用区块链:

  • 数据需要公开,并且任何人都可以验证
  • 数据必须永久保存且不可篡改
  • 不应由任何单一实体拥有或控制
  • 你处理的是价值、所有权或信任(代币、合约、投票)

举个实际例子:如果你在做一个市场,用户资料和商品图片大概率会放在 Supabase 里。但商品的所有权、交易历史和支付逻辑,可能会放在区块链上。

大多数真实世界的应用都同时用两者。数据库和 storage 处理那些快速、私有、日常的东西;区块链处理那些需要没有单一所有者、并且永久存在的东西。

哪一类数据最适合放在区块链上?


你刚刚搭出了什么

你的应用现在能:

  • 用 Supabase 数据库 记住事情
  • 用 Supabase Storage 保存用户上传的文件
  • 让 API key 远离 智能体和 Git
  • 校验上传内容,把坏文件挡在门外
  • 把 RLS 当作防火墙,放在你的数据和互联网之间

这就是一个生产应用的真实骨架。它还缺一大块:它不知道在用它。在 sophomore 阶段你会加上 authentication,把 RLS 重新打开,让每个用户的数据真正私有化。


接下来是什么

你的应用有了记忆和文件存储。下一步,我们会去看 Monad 的架构,以及区块链如何用一种本质上不同的方式去解决我们刚刚处理过的问题:身份、数据存储和信任。

0/8 正确

0% — 全部答对即可完成

注册以记录进度