ZJY365 实战
从想法到上线

第 6 章 · 前端、后端与数据库怎么协作

解剖「添加一个单词」这条最细链路:页面、请求、服务端处理、数据保存、结果反馈各做了什么;数据结构、登录身份与访问权限的最小讲解。

这一章解决什么问题

第 5 章你跟着任务清单让 AI 做出了「添加一个单词」,但代码是 AI 写的——如果不理解这条链路,第 7 章出问题时你连「坏在哪一段」都问不出来。这一章把 T2 那条最细的全链路解剖开,回答三个问题:一个词是怎么从输入框进入数据库的?网站怎么知道「是你」在操作?凭什么别人看不到你的词?

本章所有材料按「能不能亲手跑」分了级,最后有一张对照表。你不需要在本章写出完整应用——目标是看懂链路,为排错装上地图。

开始前你要有什么

  • 第 5 章 T2 完成的项目(边读边对照自己的代码,效果最好);没有的话,纯读也成立
  • 本地装了 sqlite3(Mac 自带;终端输入 sqlite3 --version 有输出即可)
  • 想通一个前提:前端与后端数据存在哪两篇概念课讲过的基础,本章直接用

分工:人判断什么,AI 做什么

本章是理解章,没有要 AI 替你做的活。判断在你:读到每一段,你都该能回答「这一段坏了,页面上会看到什么」——这是下一章排错的核心能力。AI 的用法是答疑:哪段代码看不懂,圈出来让它逐句解释。

怎么做

第 1 步:先看全景——一条词的七段旅程

你在表单里点「保存」,到新词出现在列表里,中间发生这些事:

 浏览器(前端)                        服务器(后端)           数据库
┌─────────────────┐              ┌──────────────────┐   ┌─────────────┐
│ ① 表单收集输入    │              │                  │   │             │
│ ② 发送请求 ──────┼──────────────▶ ③ 验明身份        │   │             │
│                  │   HTTP       │    (会话→user_id)│   │             │
│                  │              │ ④ 校验内容        │   │             │
│                  │              │    (空?太长?)   │   │             │
│                  │              │ ⑤ 写入 ───────────┼──▶ │ ⑥ INSERT    │
│                  │              │                  │   │   存一行     │
│ ⑦ 收到响应,      │◀─────────────┼── 返回结果 ───────┼─── │             │
│   刷新列表        │              │                  │   │             │
└─────────────────┘              └──────────────────┘   └─────────────┘

记住这个分工:前端管收集和展示,后端管校验和决定,数据库管保存。第 7 章的排错地图就是它——「点了没反应」查①②,「提示保存了但刷新就没」查⑤⑥,「别人看得到我的词」查③。

第 2 步:数据长什么样——words 表

PRD 数据需求一节落到数据库里,就是一张表(Postgres 方言,与第 4 章选的 Supabase 一致):

create table words (
  id          bigint generated always as identity primary key,
  user_id     uuid not null,          -- 这行词归哪个账号(登录服务提供)
  word        text not null,          -- 单词,如 resume
  meaning     text not null,          -- 释义(必填:强迫第一次加工)
  example     text,                   -- 例句,选填
  source      text,                   -- 在哪遇到的,选填
  status      text not null default 'learning',  -- learning / mastered
  created_at  timestamptz not null default now(),
  updated_at  timestamptz not null default now(),
  unique (user_id, word)              -- 同一账号不重复(AC6 的数据库层兜底)
);

四个设计决定,每个都对应前面章节的一个 D 或 AC:

  • user_id 是必填的外来字段(D6):每一行都记着「我属于谁」,它是权限的地基——后面查词永远带着它;
  • status 只有两个固定值(D3):状态切换就是把这一列从 learning 改成 mastered,筛选就是把等于 learning 的挑出来;
  • unique (user_id, word)(AC6):重复录入不靠前端提醒,数据库本身拒绝——最后一道防线;
  • 两个时间戳:看似多余,第 7 章排查「这条到底存没存上」时,created_at 就是你去找它的线索。

第 3 步:亲手跑一遍(零账号、零注册,约 15 分钟)

不需要 Supabase 账号——Mac 自带的 sqlite3 就能把「归属、查重、切换、筛选」四个核心逻辑跑通。把下面整段粘贴进终端:

sqlite3 /tmp/vocab-sandbox.db
-- 建表(SQLite 方言;与上面 Postgres 版的差异只在自增主键和时间的写法)
create table words (
  id         integer primary key autoincrement,
  user_id    text not null,
  word       text not null,
  meaning    text not null,
  status     text not null default 'learning',
  unique (user_id, word)
);

-- 两个账号各录一条
insert into words (user_id, word, meaning) values
  ('user-A', 'resume', 'v. 重新开始;n. 简历'),
  ('user-B', 'negotiate', 'v. 谈判');

-- ① 账号 A 的词表(权限查询的雏形:永远带 user_id 条件)
select word, status from words where user_id = 'user-A';

预期输出(本教程编写时实际执行的输出):

resume|learning

继续:

-- ② 账号 A 重复录入 resume(AC6 会发生什么)
insert into words (user_id, word, meaning) values ('user-A', 'resume', '重复的释义');
Error: stepping, UNIQUE constraint failed: words.user_id, words.word (19)

数据库直接拒绝——这就是 AC6「最后一道防线」的真实样子。前端提示只是友好外衣,拦截靠它。

-- ③ 标记已掌握(AC4 的本质是 update)
update words set status = 'mastered' where user_id = 'user-A' and word = 'resume';
select word, status from words where user_id = 'user-A';

-- ④ 筛选未掌握(AC5 的本质是带条件的 select)
select word from words where user_id = 'user-A' and status = 'learning';

-- ⑤ 权限反例:如果查询忘了带 user_id 条件,会发生什么?
select user_id, word from words;

第④步预期输出只剩 deliver 一类 learning 状态的词(按你录的内容而定);第⑤步是本章最重要的一眼:没有 user_id 条件,user-B 的 negotiate 也会被查出来——这就是 AC8 要防的事故。区别只在一句 where。

顺手说一个诚实声明:上面输出是本教程编写时在 sqlite3 3.51 真实执行的结果;你的执行环境不同,输出细节(比如错误编号)可能略有差异,但拦截、过滤、归属的行为不会变。玩完了删掉沙盒:rm /tmp/vocab-sandbox.db

第 4 步:服务端校验长什么样——一段真实跑过的代码

后端第④步「校验内容」不是一句空话,它就是这样一个函数(本教程编写时用 node 实际执行过 6 组测试,全部通过):

// 校验录入内容:通过返回干净的数据,不通过返回中文错误
export function validateWord(input: unknown): { ok: true; value: WordInput } | { ok: false; error: string } {
  const { word, meaning, example } = (input ?? {}) as Partial<WordInput>;

  if (typeof word !== 'string' || word.trim().length === 0) {
    return { ok: false, error: '单词不能为空' };
  }
  if (word.trim().length > 100) {
    return { ok: false, error: '单词最长 100 个字符' };
  }
  if (typeof meaning !== 'string' || meaning.trim().length === 0) {
    return { ok: false, error: '释义不能为空——用你自己的话写' };
  }
  // ……长度上限检查同型,略
  return {
    ok: true,
    value: { word: word.trim(), meaning: meaning.trim() },
  };
}

注意错误文案是写给看的(「用你自己的话写」),不是写给程序的。这段代码跑在服务端——就算有人绕过页面直接发请求(第 7 章权限演练里你就这么干),校验依然生效。只在前端校验 = 没校验。

第 5 步:把七段旅程接成代码(示例节选)

把链路接起来的服务端代码,形如(Next.js route handler 风格的示例节选,用于讲解——本教程没有为它搭建完整项目运行,属于示例代码;你的项目里对应的代码由模板和第 5 章任务生成,细节必然不同,但骨架一致):

// POST /api/words —— 「添加一个单词」的服务端(节选,讲解用)
export async function POST(request: Request) {
  // ③ 验明身份:从会话里取当前用户(见下节「登录身份」)
  const user = await getUserFromSession();
  if (!user) {
    return Response.json({ error: '未登录' }, { status: 401 });
  }

  // ④ 校验内容
  const body = await request.json();
  const check = validateWord(body);
  if (!check.ok) {
    return Response.json({ error: check.error }, { status: 400 });
  }

  // ⑤ 写入数据库(user_id 来自会话,不由请求提供——防伪造)
  await db.insert(words).values({
    user_id: user.id,
    word: check.value.word,
    meaning: check.value.meaning,
  });

  // ⑦ 告诉前端成了
  return Response.json({ ok: true }, { status: 201 });
}

三个标注出来的点,是全链路的安全骨架:

  • user_id 从会话取,不从请求取:请求体是任何人都能伪造的;会话才是可信身份。
  • 校验不过就返回 400:把失败明确说出口(对照反模式被吞掉的报错)。
  • insert 失败会抛错:数据库层 unique 冲突时这里会炸,正确做法是接住它翻译成「已录入过」(AC6 的友好版)——第 5 章任务 T5 会让 AI 补这块。

第 6 步:登录身份与访问权限——两个大白话概念

登录身份(会话):你登录成功后,登录服务发给你一张「通行证」(技术上是一小段存在浏览器 cookie 里的凭证),之后每个请求自动带上它,服务端验一下就知道「是你」。所以:换浏览器 = 没带通行证 = 要重新登录(AC1);登出 = 销毁通行证。

访问权限:通行证证明「你是 user-A」,但查哪些数据仍由服务端决定。原则一句话:

隐藏入口不等于权限。 菜单不显示别人的词、链接不给别人,都只是「不给人看路牌」;只要查询不带 user_id 条件,懂行的人直接请求接口照样全量拿走。权限必须落在服务端查询里(第 3 步沙盒第⑤眼看过的那种差别)。

AC8 的测法因此很明确:开第二个浏览器注册 user-B,不是「看看界面上有没有 A 的词」,而是直接请求列表接口——界面可以骗人,接口的响应骗不了人。

产物与检查

本章产物是你的链路理解。自测三题(合上教程回答):

  1. 点「保存」后提示成功、刷新却没了——七段旅程里哪几段最可疑?
  2. 为什么 user_id 必须从会话取,不能从表单或请求里取?
  3. 「别人看不到我的词」靠的是界面不显示,还是别的什么?AC8 怎么测?

(答案分别在第 7 章演练一、本章第 5 步、本章第 6 步。三题都答对,本章毕业。)

三样东西,别混为一谈

本章和整篇教程里出现过三种「数据/代码」,性质完全不同:

名称是什么在本教程哪里能当真吗
模拟数据编出来讲故事的假内容第 1 章「300 多条备忘录」、决策树的示例回答不能——全部是教学假设
示例代码讲解用的节选片段本章第 5 步的 route handler结构可信;本教程没为它搭完整项目,逐行照抄到你项目里未必能跑
真实实现你项目里实际运行的代码你按第 5 章任务做出来的项目唯一算数的东西;对照本章理解它、排错它

判断口诀:讲故事的一律是模拟,贴出来讲解的是示例,你自己项目里跑起来的才是真的。

不满意或失败时怎么调整

  • SQL 沙盒跑不出预期输出:检查是不是整段粘贴(create tableinsert 必须先执行);还是不行就删掉 /tmp/vocab-sandbox.db 从头再来——沙盒的乐趣就是随便砸。
  • route handler 节选取代了你项目里的真实代码:不要抄。打开你项目里对应的文件(问 AI「我的添加单词接口在哪个文件」),对照本章骨架找那三个安全点在不在。缺了哪个点,就是第 7 章的隐患。
  • 七段旅程记不住:别背,记住分工口诀就够——前端收集展示、后端校验决定、数据库保存。

交给下一章什么

  • 七段旅程图——第 7 章排错地图:每个现象先定位到段落,再谈修复;
  • 沙盒的直觉——「数据真的存进去了吗」从此可以自己验证,不靠界面撒谎;
  • user_id / 会话 / 服务端过滤——第 7 章权限演练的理论底子。

案例进度:T2 的链路已解剖。下一章,我们按 PRD 逐条验收,并故意布下三个故障当靶子练排错。

On this page