霓虹下的记忆引擎:拆解一个 Codex 生成的英语背单词全栈工程

🚀 赛博记忆:当 AI 为你生成一个完整的背单词系统

「在数字霓虹之下拆解代码秩序」 —— 当 AI 写完了第一行 create table,真正的工程才刚刚开始。

封面:霓虹下的记忆引擎

记忆是熵增的。背过的单词会在 24 小时后遗忘约 70%,这是人脑的默认走向。一个背单词 App 的本质,是用结构化的负熵去对抗这种遗忘:把词书、进度、复习节奏固化为关系模型,让每一次"下一个"都在确定性的轨道上推进。

本项目(Codex入门完整版:制作一个英语背单词应用)正是对这个命题的一次完整实践。它不是一个玩具——它由两个独立的 Next.js 工程(H5 学习端 + Admin 词书后台)共享同一套 Postgres,搭载 NextAuth、Drizzle ORM、Server Actions,并附带一份相当专业的 PRD 与 Tech Spec。但正因为它是 Codex 一口气生成的产物,代码里同时闪耀着架构的聪明,也泄露了生成的痕迹。

下面这篇深度拆解,全部基于对该工程源码的逐文件通读,而非泛泛而谈。全文先拆解模块与业务流程(第 3 节),再点亮真正闪光的设计(第 4 节「技术亮点」),随后逐层展开架构、数据流转、选型博弈与真实坑点。


0. 项目指纹:一个被 AI 一次性"分娩"的双端系统

工程目录里藏着两条线索:

  • codex-h5-nextjs-main —— 面向移动端的学习端,Next.js 14(App Router),承担选词书、逐词学习、断点续学、详情查看。
  • codex-admin-nextjs-main —— 面向运营/内容的词书后台,Next.js 15 + React 19,承担词书与单词的导入与管理。

两者共享同一个 Supabase Postgres 实例。有意思的是,两套系统用了两套完全不同的鉴权范式:H5 用 NextAuth v5 Credentials,Admin 用自研的 scrypt + admin_sessions 会话表。这不是设计失误,而是 AI 在不同 prompt 下产出不同"方言"的典型指纹——本文后面会专门讨论这种双轨制技术债


1. 总体架构:双端·单库·两套秩序

服务层 / Node Runtime

边缘层 / Edge Runtime

终端层 / Client

数据层 / Postgres (Supabase)

已建表但从未被写入

User · admin_users

user_word_state
(SRS 字段预留)

study_event
(行为埋点预留)

books

words (content: json)

user_book_progress

admin_sessions

H5 学习端
Next.js 14 · RSC + Server Actions

Admin 词书后台
Next.js 15 · React 19

Middleware
(NextAuth authConfig · 仅校验会话存在)

RSC 服务端取数

Server Actions
(advanceProgress 等)

Route Handlers
(/api/books, /api/recent-learning ...)

NextAuth v5
Credentials Provider

自研 Session
(scrypt + admin_sessions)

架构示意:双端·单库·两套秩序

图 1 · 双端·单库·两套秩序的视觉化:左侧 H5 学习端(手机 / 卡片),右侧 Admin 词书后台(仪表盘),共同汇入中央数据库内核。

架构上最值得肯定的两点:

  1. 读路径走 RSC,写路径走 Server Actions。首页 app/page.tsx 是 Server Component,直接在服务端 listBooks() + listRecentLearningByUser() 取数后注入客户端组件,避免了客户端瀑布流。
  2. SQL 全程参数化。无论是 listBooks 还是 updateUserBookProgress,都使用 postgres.js 的标签模板 client`... WHERE "bookId" = ${bookId}`,天然免疫 SQL 注入——这是生成代码里少有的、做得对的地方。

2. 数据流转:一次「下一个」背后的链路

以"进入学习页 → 点下一个"为主线,用序列图还原真实调用链:

advanceProgress (Server Action) LearningPage (Client) Postgres auth() /learn/[bookId] (RSC) 用户(浏览器) advanceProgress (Server Action) LearningPage (Client) Postgres auth() /learn/[bookId] (RSC) 用户(浏览器) 进入 /learn/[bookId] auth() 取会话 session(sub / email) getOrInitUserBookProgress (upsert 进度) listWordsByBook(bookId) ← 整本词书,无 LIMIT 全量 words[](含 content 大字段) { initialIdx, serverWords[] } useState 装入整本词书到内存 点击「下一个」 advanceProgress(bookId, idx, wordId) auth() 再次解析 userId UPDATE user_book_progress SET last_idx { ok: true } currentIdx++(纯本地推进)

注意两个隐藏事实:

  • 词书内容在客户端被整体持有。整本书的 words[](每个都带完整 content JSON)被一次性序列进 RSC payload 传给 LearningPage,后续"下一个"只是 currentIdx++ 的本地游标移动——所以切换单词是瞬时的,代价是首屏要把整本书搬进内存。
  • 每次写操作都要先 auth() 再反查 getUser(email)。会话里没有 id,于是 userId 只能靠邮箱回查。一条"下一个"背后,是 auth → getUser → UPDATE 三次往返。

3. 模块拆解与业务流程:数据如何在系统中闭环

前面的架构图是"俯瞰",这一节下沉到"剖面"——把系统切成功能模块,再顺着业务流程看数据如何在这些模块间流动、沉淀、最终试图形成闭环。

业务流程 · 学习主循环

图 2 · 学习主循环:用户 → 卡片 → 进度回写 → 复习调度,是一条试图闭合的数据环。

3.1 功能模块地图

系统由三条边界清晰的"带"组成,数据在带间单向或双向流动:

① H5 学习端(Next.js 14 · App Router)

  • 认证模块AuthModal(登录/注册弹窗)、NextAuth v5 Credentials Provider、/api/register/api/login
  • 词书模块:首页词书列表(app/page.tsx 以 RSC 取数)、最近学习(listRecentLearningByUser)、/api/books/api/recent-learning
  • 学习模块/learn/[bookId](RSC 引导页)、LearningPage(客户端状态机,持有整本词书游标)、WordCard(卡片)、WordDetailPage/word/[bookId]/[idx] 详情)。
  • 进度模块app/actions/progress.tsadvanceProgressgetOrInitUserBookProgress)。
  • 数据降级层lib/mock-data.ts(fetch 失败时回退假数据)。

② Admin 词书后台(Next.js 15)

  • 认证模块admin_users + admin_sessions + Node crypto.scrypt 自研会话。
  • 词书管理books 的增删改查。
  • 词库导入json-to-csv.js ETL → CSV → Supabase 批量入库 words / books
  • 单词管理words 表维护。

③ 共享数据层

  • 存储:Supabase Postgres,七张核心表(User / books / words / user_book_progress / user_word_state / study_event,外加后台两张 admin_users / admin_sessions)。
  • 访问:H5 用 postgres.js + Drizzle;Admin 用 Drizzle + postgres.js;统一由 scripts/run-migrations.mjs 的迁移 runner 管理 schema。

3.2 三条核心业务流程

流程一 · 注册 / 登录

浏览器 → /api/register {email, password}
       → bcrypt-ts hashSync 生成口令哈希
       → INSERT "User"(email, password)
登录 → authorize(email, password) → 比对哈希 → NextAuth 签发会话 (session.sub = email)

注意:会话里没有 id,后续所有 API 都靠 getUser(email) 反查主键——这是后面"每请求多一次 DB 往返"坑的根因。

流程二 · 学习主循环(见图 2)

选词书 → GET /learn/[bookId]
       → RSC: getOrInitUserBookProgress (upsert 进度行)
       → RSC: listWordsByBook (整本词书, 无 LIMIT)
       → 客户端: useState 装入 words[], currentIdx 从 last_idx + 1 起步
点击「下一个」→ LearningPage 本地 currentIdx++
       → advanceProgress(bookId, idx) → Server Action
       → auth() 解析 userId (email 回查) → UPDATE user_book_progress SET last_idx

这条链路把"瞬时翻词"的体验建立在"首屏搬整本书"的代价之上,是 MVP 阶段典型的权衡。

流程三 · 词书导入(运营侧)

有道词典 JSON 导出 (多根对象首尾拼接)
       → json-to-csv.js: 括号深度扫描 + 三级回退解析
       → 输出 words.csv / books.csv (RFC-4180 转义)
       → Supabase 批量导入 → words(content json) / books
       → H5 端 listBooks / listWordsByBook 即可读取

这条流程把"非结构化的外部数据"规整成"可被 SQL 检索的关系行",是系统数据的主要注入口。

3.3 数据模型与字段语义

七张表的字段语义如下——这是理解整个系统数据闭环的钥匙:

字段 类型 / 约束 语义
User id SERIAL PK 用户主键
email VARCHAR(64) UNIQUE 登录标识,也是会话回溯键
password VARCHAR(64) bcrypt 哈希(H5 侧)
books id SERIAL PK 词书主键
book_id TEXT UNIQUE 业务键(被 words / progress 引用)
title / word_count / cover_url / tags text / int / text / text 展示元数据
words id BIGINT PK 单词主键(有道 wordId 映射)
bookId TEXT 逻辑外键 → books.book_id
wordRank INT 词在书中的序号(排序键)
headWord TEXT 单词拼写
content json / text 有道嵌套词典 JSON(音标/释义/例句/同近义/助记)
user_book_progress user_id / book_id INT / TEXTUNIQUE(user_id, book_id) 每对(用户,词书)一行
last_idx INT DEFAULT -1 已学到第几个(断点续学游标)
last_word_id BIGINT → words.id 最近一词
updated_at TIMESTAMP 索引 (user_id, updated_at DESC) 支撑"最近学习"
user_word_state user_id / word_id UNIQUE(user_id, word_id) 每对(用户,单词)一行
starred / difficulty BOOL / TEXT CHECK('unknown','learning','hard','known') 收藏与难度
exposures INT 曝光次数
last_seen_at / next_review_at TIMESTAMP NULL 上次/下次复习时间(SRS 调度)
ef / interval / repetition REAL / INT / INT SM-2 间隔重复三要素(已建字段,未写入
study_event user_id / book_id / word_id 外键 行为主体
action TEXT CHECK('view','next','open_detail','mark_known','mark_hard','star','unstar') 事件类型
meta JSONB NULL 事件附载(预留)
created_at TIMESTAMP 索引 (user_id, created_at DESC) 支撑时间线
admin_users id / email UNIQUE / name 后台管理员
password_hash TEXT scrypt 格式 scrypt:N,r,p:salt:hash
role TEXT DEFAULT 'admin' super / admin
is_active BOOL DEFAULT TRUE 启用状态
admin_sessions user_id INT 会话归属
token_hash TEXT UNIQUE sha256(明文令牌),明文永不入库
expires_at TIMESTAMP 7 天 TTL

关键观察:user_word_statestudy_event 的字段设计已经完整到可以直接跑 SRS 与行为分析(连 ef / interval / repetitionaction 枚举都备齐了),但应用层从不去写它们——这正是前文"schema 先于代码"最硬的证据。

3.4 数据如何在系统中闭环

把上面的模块与字段串起来,可以看到一条完整的数据生命周期:

[有道 JSON]
   │  Admin ETL (json-to-csv)
   ▼
[books] + [words(content json)]
   │  H5 listBooks / listWordsByBook
   ▼
[客户端内存: words[] 游标]
   │  用户逐词学习
   ▼
[user_book_progress.last_idx]  ← 唯一被持续写入的"学习痕迹"
   │  (预期中还应写入)
   ├─▶ [study_event]   行为埋点   ← 已建表,未写入
   └─▶ [user_word_state] 收藏/难度/SRS ← 已建表,未写入

当前真实闭环是线性的words → 客户端 → user_book_progress。而 study_eventuser_word_state 是"预留接口"——它们让系统具备向事件驱动 / 间隔重复演化的潜力,却因为缺少写入逻辑而暂时悬空。换句话说,这个系统现在跑得通,但只跑了"一半的环"。后续要做 SRS(第 10 节路线图),本质就是把这条悬空的弧线接上:让每一次"下一个",都同时产生一条 study_event 与一次 user_word_state 的状态迁移。


4. 技术亮点:霓虹中真正闪光的设计

拆一个 AI 生成的工程,最容易陷入两种极端:全盘否定,或盲目吹捧。真正的工程审美,是能在生成代码里辨认出哪些决策是站得住的。这一节只谈亮点——那些即便放进人工评审里也该留用的设计。

4.1 RSC 读 / Server Actions 写:把请求切成两条正交轨道

H5 端最清醒的设计,是它没有把整个应用退化成 SPA。读路径走 React Server Componentsapp/page.tsx 直接是 Server Component,在服务端调用 listBooks()listRecentLearningByUser() 取数,再把结果注入客户端组件。写路径走 Server ActionsadvanceProgress"use server" 声明)。

这带来两个实打实的好处:

  • 首屏没有客户端取数瀑布。选词书列表、最近学习都在服务端并行取好,客户端拿到的是已填好的 props,而非先渲染骨架再发请求。
  • 写操作收敛到服务端边界。客户端只负责 currentIdx++ 的本地游标,真正的 UPDATE 被封装在服务端动作里,天然规避了把 DB 凭据下发到浏览器的风险。

4.2 postgres.js 标签模板:默认安全的 SQL

工程里所有查询都使用 postgres.js 的标签模板(tagged template):

const rows = await client`
  SELECT id, "wordRank", "headWord", content, "bookId"
  FROM public.words
  WHERE "bookId" = ${bookId}
  ORDER BY "wordRank" ASC NULLS LAST, id ASC`;

${bookId} 不会被字符串拼接进 SQL,而是由驱动作为绑定参数(bind parameter)转义。这意味着即便是 createUser 里那段同步哈希的烂代码,也没有引入 SQL 注入面——生成器在"安全默认值"上做对了。对比手写字符串拼接 ... WHERE book_id = '${bookId}',这是质的区别。

4.3 Upsert 断点续学:一行 SQL 兜住幂等

断点续学的核心,不是"存一个 idx",而是"保证每对 (user, book) 只有一行进度"。getOrInitUserBookProgress 用一条 INSERT ... ON CONFLICT DO NOTHING 把"初始化"和"防重复"合二为一:

export async function getOrInitUserBookProgress(userId: number, bookId: string) {
  const inserted = await client`
    INSERT INTO public.user_book_progress (user_id, book_id, last_idx)
    VALUES (${userId}, ${bookId}, -1)
    ON CONFLICT (user_id, book_id) DO NOTHING
    RETURNING id, user_id, book_id, last_idx, last_word_id, updated_at, created_at`;
  if (inserted.length > 0) return inserted[0];
  const rows = await client`
    SELECT ... FROM public.user_book_progress
    WHERE user_id = ${userId} AND book_id = ${bookId}
    LIMIT 1`;
  return rows[0];
}

ON CONFLICT (user_id, book_id) DO NOTHING 依赖一张 唯一约束user_id, book_id)。并发进入同一本书时,一个插入成功、其余静默跳过,再 SELECT 取回既有行——没有 SELECTINSERT 的经典竞态,也没有重复进度行。这是关系型数据库提供的免费幂等,用得很地道。

4.4 词书 ETL 管线:把"有道 JSON"炼成可入库 CSV

Admin 端最被低估的工程,是 scripts/json-to-csv.js 这个词书导入前置脚本。现实里的词书导出,往往不是干净的 JSON 数组,而是多个根对象首尾拼接、没有逗号分隔的半结构化文本。脚本用一个括号深度扫描器,而不是 JSON.parse 一锅端:

function parsePossiblyConcatenatedJSON(text) {
  const objs = [];
  let depth = 0, start = -1, inString = false, esc = false;
  for (let i = 0; i < t.length; i++) {
    const ch = t[i];
    if (inString) { /* 处理转义与字符串边界 */ continue; }
    if (ch === '{') { if (depth === 0) start = i; depth++; }
    else if (ch === '}') { depth--; if (depth === 0 && start !== -1) {
      objs.push(JSON.parse(t.slice(start, i + 1))); start = -1; } }
  }
  return objs;
}

它先尝试整段 JSON.parse(数组 / 单对象),失败再走"逐对象扫描",再不行退回 NDJSON 行解析——三级回退,把现实世界脏数据的容错写进了代码。随后 csvEscape 对含逗号、引号、换行的字段做 RFC-4180 风格转义("""),输出标准 CSV 供 Supabase 批量导入。这套 ETL 思路,是很多手写脚本想偷懒而忽略的。

4.5 内容建模:一个 text 字段装下一整本词典

words.content 字段存的是有道词典导出的嵌套 JSON,结构大致是:

words.content (text / json)
  └─ .word
       ├─ wordId / wordHead
       └─ .content
            ├─ ukphone / usphone        音标(英 / 美)
            ├─ trans[]                  释义数组(中释 descCn + 英释 descOther)
            ├─ sentence.sentences[]     例句(sCn / sContent / 高亮 sContent_eng)
            ├─ syno.synos[]             同近义词(pos / hwds[] / tran)
            ├─ remMethod.val            助记法
            └─ ukspeech / usspeech      发音音频地址

关键判断在于:词典数据结构多变、且应用层只需"按整条词读取",不需要在 SQL 层检索 JSON 内部字段。于是 content 被整体存为 text/json,读取时一次性 JSON.parse,由前端 parseWordContent() 抽取音标、首义、例句、同近义、助记。这避免了为每种词典格式都建一张宽表——用"结构化懒加载"换来了导入的极大弹性。这是务实的建模选择(代价是为未来检索埋了债,见第 9 节 jsonb 迁移方案)。

4.6 自研会话(Admin):几行代码立起的安全范式

同一个项目,Admin 端却写出了教科书级会话设计(完整代码见下文「核心源码拆解 · 自研会话」)。三件事值得 H5 借鉴:

  • 明文令牌永不入库token_hash = sha256(随机令牌),库里只存哈希;
  • Cookie 三件套httpOnly + secure + sameSite:'lax',TTL 7 天;
  • 校验用 crypto.timingSafeEqual:杜绝时序侧信道。

对比 H5 直接把口令哈希塞进 User 表、会话依赖 NextAuth 默认行为——Admin 这套反而更可控。

4.7 迁移校验和:工程的诚实

scripts/run-migrations.mjs 给每张已应用迁移记一笔 SHA-256 校验和到 __migrations 表。若同一文件名内容被改动,校验和不匹配即报错退出,而非静默跳过:

await trx`INSERT INTO public.__migrations (filename, checksum)
           VALUES (${filename}, ${sha256(content)})`;
// 已应用但 checksum 变化 → 直接报错退出

迁移幂等 + 防篡改,是很多生产项目都欠下的账。这一块,是整份代码里最"专业"的注脚(完整代码见下文「核心源码拆解 · 迁移 Runner」)。

4.8 边缘鉴权:Middleware 只做一件事

H5 的 middleware.ts 把鉴权逻辑压到 Edge Runtime,且只做最轻量的存在性校验

export default NextAuth(authConfig).auth;
export const config = {
  matcher: ['/((?!api|_next/static|_next/image|.*\\.png$).*)'],
};

它不在边缘解析完整用户态,只判定"有没有会话"。真正的权限细节留在 RSC / Action 里用 auth() 取。这种"边缘只做门禁、内部再做细粒度"的分层,避免了在 Edge 上跑重型逻辑——是 Next.js 中间件的正解。


5. 技术栈选型博弈

维度 选型 收益 代价 / 风险
渲染范式 RSC + Server Actions(非纯 SPA) 首屏服务端取数、SEO 友好、减少客户端 bundle RSC payload 易把大字段(整本词书)带入客户端
鉴权(H5) NextAuth v5 5.0.0-beta.4 与 Next 集成度高、Credentials 开箱即用 beta 版本上生产;默认不把 id 注入 session
鉴权(Admin) 自研 scrypt + 会话表 令牌只存 sha256 哈希、可控 TTL、无第三方依赖 需自维护中间件/路由守卫,重复造轮子
ORM Drizzle ORM + postgres.js 类型安全、迁移可版本化、SQL 贴近原生 H5 端大量用裸 SQL 标签模板,ORM 价值未充分释放
内容存储 words.contentjson / text 词书结构自由、导入灵活 无法建 GIN 索引、无法在 SQL 内检索 JSON 内部字段
密码哈希 H5 bcrypt-ts(含同步 hashSync)/ Admin crypto.scrypt 慢哈希抗暴力破解 H5 的同步哈希阻塞事件循环
部署 Vercel + Supabase Postgres 零运维、弹性伸缩 pgbouncer 下需 prepare:false(Admin 已设,H5 未显式设)

博弈的核心矛盾是:为了"瞬时体验"而把复杂度下沉到客户端内存,为了"快速生成"而容忍两套鉴权方言。这在 MVP 阶段合理,但在规模化与可维护性上埋了线头。


6. 新旧方案横向对比

下表把"现状实现"与"推荐重构方案"并置,便于判断哪些坑该填:

能力 现状实现(旧) 推荐方案(新) 关键收益
建表方式 app/db.ts 运行时 ensureTableExists() 每次 information_schema 探测 + 条件 CREATE TABLE 统一走 db/migrations/*.sql 迁移 runner,删除运行时 DDL 单一事实源、可回滚、无并发建表竞态
内容存储 contenttext/json 载入,应用层 JSON.parse 迁移为 jsonb + GIN 索引 支持 content -> 'word' -> 'content' -> 'trans' 内联检索与索引
密码哈希 H5 genSaltSync/hashSync同步阻塞 全程异步 hash/scryptAsync 不阻塞事件循环,注册延迟稳定
会话身份 session 无 id,每请求 getUser(email) 回查 jwt/session 回调把 sub/id 注入;Admin 直接存 id 消除每请求额外一次 DB 往返
词书读取 listWordsByBook 无 LIMIT,整本倾泻 游标分页 + SQL 层字段投影(只取音标/首义/例句) RSC payload 从 MB 级降至 KB 级
进度并发 advanceProgress 直接 UPDATE last_idx无校验 CAS:要求 currentIdx === last_idx + 1 防双开标签页/快速连点导致的进度回退或跳变
客户端数据源 HomePage 失败回退 mockBooks 移除 mock 双源,失败显式报错 杜绝生产环境"幽灵词书"
鉴权统一 H5 与 Admin 各一套 统一为 Auth.js Adapter 或统一自研会话中间件 收敛安全面、降低维护税

7. 核心源码拆解

7.1 运行时建表:便利的债

app/db.ts 里的 ensureTableExists() 会在每次 getUser / createUser 时执行:

async function ensureTableExists() {
  const result = await client`
    SELECT EXISTS (SELECT FROM information_schema.tables
      WHERE table_schema = 'public' AND table_name = 'User')`;
  if (!result[0].exists) {
    await client`CREATE TABLE "User" (id SERIAL PRIMARY KEY, email VARCHAR(64), password VARCHAR(64))`;
  }
  // ...返回 pgTable
}

三个问题:① 每次认证都打一次 information_schema 元数据查询;② 首注册并发时,两个请求都看到"表不存在"会同时 CREATE TABLE,其中一个抛 already exists;③ 与 db/migrations/001_*.sql 里"幂等建表 + 唯一约束"形成双源 schema 真相,长期必然漂移。生成器图省事,把 DDL 塞进了热路径。

7.2 整本词书的一次性倾泻

export async function listWordsByBook(bookId: string) {
  const rows = await client`
    SELECT id, "wordRank", "headWord", content, "bookId"
    FROM public.words
    WHERE "bookId" = ${bookId}
    ORDER BY "wordRank" ASC NULLS LAST, id ASC`;
  return rows;   // 没有 LIMIT / OFFSET
}

mockBooks 里有本 TOEFL_3 词书 wordCount: 4264。这意味着进入学习页会把 4264 条 + 各自完整 content JSON 全量拉进内存、再序列化进 RSC payload。这是本项目最硬的扩展墙:词书越大,首屏越慢、服务端内存越高。

7.3 进度推进:没有 CAS 的写

app/actions/progress.ts

export async function advanceProgress(bookId, currentIdx, currentWordId?) {
  const session = await auth();
  if (!session?.user) throw new Error('UNAUTHORIZED');
  // ...解析 userId(email 回查)...
  await getOrInitUserBookProgress(userId, bookId);
  await updateUserBookProgress(userId, bookId, Math.max(0, currentIdx), currentWordId ?? null);
  return { ok: true };
}

Tech Spec 里明写的乐观检查 if (existing.lastIdx + 1 !== currentIdx) throw 'OUT_OF_ORDER' 并没有落地。结果:开两个标签页快速点"下一个"、或客户端状态与服务器错拍时,last_idx 会被无序覆盖,断点续学可能跳词或回退。且调用失败仅是 console.warn,进度可静默丢失。

7.4 自研会话:Admin 侧的安全范本

同样的项目,Admin 端却写出了教科书级别的实现。src/lib/password.ts 用 Node 原生 crypto.scrypt

const N = 16384, r = 8, p = 1, keyLen = 64;
const derived = await scryptAsync(password, salt, keyLen, { N, r, p });
return `scrypt:${N},${r},${p}:${salt.toString('hex')}:${derived.toString('hex')}`;
// 校验用 timingSafeEqual,杜绝时序侧信道

src/lib/auth.ts 的会话设计更值得 H5 借鉴:

  • 令牌经 sha256Hex(token) 后才落库(admin_sessions.token_hash),明文令牌永不入库
  • Cookie 设 httpOnly + secure + sameSite: 'lax',TTL 7 天;
  • getCurrentUser()sha256(token) 反查并校验 expiresAt > now

对比 H5 直接把 password 明文哈希存 User 表、会话依赖 NextAuth 默认行为——Admin 这套反而更可控、更安全。

7.5 迁移 Runner:被低估的工程素养

scripts/run-migrations.mjs 做了一件很多生产项目都忽略的事——迁移校验和

async function applyMigration(filename, content) {
  await sql.begin(async (trx) => {
    await trx.unsafe(content);
    await trx`INSERT INTO public.__migrations (filename, checksum)
               VALUES (${filename}, ${sha256(content)})`;
  });
}
// 若已应用但 checksum 变化 → 直接报错退出

__migrations 表 + SHA-256 校验和,保证迁移幂等且防篡改。这是整个工程里最"专业"的一块,值得单独点赞。


8. 生产环境真实坑点

按严重程度排序,均为源码实证:

  1. 整本词书无分页加载(P0)listWordsByBookLIMIT,TOEFL 级词书直接撑大 RSC payload 与服务端内存。
  2. 进度写无并发保护(P0)advanceProgress 缺 CAS 校验,双标签页/快速点击会破坏断点续学。
  3. 运行时建表双源(P1)ensureTableExists 与 migration 文件重复定义 schema,且有并发建表竞态。
  4. 同步哈希阻塞(P1)app/db.tscreateUserhashSync/genSaltSync,注册请求阻塞事件循环。
  5. 会话缺 id 导致每请求回查(P1):无 jwt/session 回调注入 id,所有 API/Action 靠 getUser(email) 反查,单页加载触发 4~6 次该查询。
  6. Schema 先于代码(P1)user_word_state(收藏/难度/SRS)、study_event(埋点)已建表建约束,但应用层从不写入;详情页"收藏"仅是 console.log。模型很丰满,行为很骨感。
  7. VARCHAR(64) 邮箱 + 动态建表无唯一约束(P2):迁移里补了 user_email_unique,但运行时建表路径没有,存在重复注册与 authorizeuser[0] 歧义登录风险。
  8. Mock 双数据源(P2)HomePage 在 fetch 失败时回退 mockBooks,生产环境可能展示与数据库不一致的"幽灵词书"。
  9. 详情页重复全量拉取(P2)/word/[bookId]/[idx] 路由再次 listWordsByBook 整本加载,只为取单条 dbWords[idx]——N+1 式的重复全表扫描。
  10. NextAuth v5 仍为 beta(P2)5.0.0-beta.4 上生产,API 稳定性与升级成本需评估。

9. 性能调优方案

针对上面的坑,给出可落地的调优路径:

  • 游标分页替代全量:学习页改为 WHERE "bookId"=$1 AND "wordRank" > $2 ORDER BY "wordRank" LIMIT 20,配合"预取下一批"的前瞻加载,RSC payload 从 MB 降到 KB。
  • SQL 层字段投影:改为只取 headWord, ukphone, usphone, trans[0].tranCn, sentence[0],把 content 的完整 JSON 留在服务端,详情页再按需取。
  • content 迁移 jsonb + GINALTER TABLE words ALTER COLUMN content TYPE jsonb USING content::jsonb; 后建 GIN (content) 索引,未来"按释义/例句检索"才成为可能。
  • 会话身份下沉到 JWT:在 auth.config 增加 callbacks.jwt/session,把 sub 映射进 session.user.id,干掉每请求 getUser(email)
  • 进度 CAS 化UPDATE ... SET last_idx=$3 WHERE user_id=$1 AND book_id=$2 AND last_idx=$3-1,用单行条件更新天然实现乐观锁。
  • 删除运行时 DDL 与 mock 双源:统一迁移;HomePage 失败直接报错而非回退假数据。
  • 异步哈希统一:H5 的 createUser 改用 await hash(password, 10),与 /api/register 保持一致。
  • DB 客户端单例:参照 Admin 的 globalThis 复用 + prepare:false(适配 pgbouncer),避免 H5 热重载下的连接抖动。

10. 演进路线图

现在 MVP 2024 选词书 逐词卡片 断点续学(last_idx) 详情页 近期 V1 2025 填 CAS 与分页 jsonb 迁移 会话 id 注入 真实收藏与难度写入 中期 V2 2026 SM-2 间隔重复 错题本 搜索筛选(GIN trgm) TTS 发音+打分 远期 V3 2027 PWA 离线 多端适配 学习数据看板 协作词书市场 从线性背词到记忆操作系统

最关键的演进判断:当前的 last_idx 整数续学模型,与间隔重复在结构上是互斥的。SRS 需要的不是"再加几个字段",而是把学习状态从"线性游标"重写为"每张单词一张状态机"(已是 user_word_state 的雏形:ef / interval / repetition / next_review_at)。换句话说,P1 阶段就该让 user_word_state 真正活起来——否则那些预留字段永远只是 schema 上的墓志铭。


结语:秩序需要被持续重写

拆完这个项目,最深的感受是:Codex 一次性生成的,是一座结构漂亮但还没住人的大楼books / words / user_book_progress 撑起了能跑的 MVP,user_word_state / study_event 则像预留的房间——图纸画好了,家具没搬进。

这恰恰是 AI 辅助开发的真实样貌:它擅长在几分钟内铺出正确的关系模型与清晰的读写分层,却也容易在"瞬时体验"与"长期成本"之间,下意识地选择前者(整本载入、同步哈希、双套鉴权)。赛博霓虹之下的代码秩序,从来不是一次生成的结果,而是一次次把 schema 与行为对齐、把便捷与严谨平衡的持续重写。

背单词对抗的是遗忘的熵增;而维护一个系统,对抗的是复杂度自身的熵增。两者,都是负熵的苦役。


配套项目资源

本文所分析的完整工程源码、PRD 与技术规格文档,可通过以下地址获取:

项目资源下载地址https://download.csdn.net/download/2301_80168944/93227558?spm=1001.2014.3001.5503

该资源包包含:

  • codex-h5-nextjs-main:H5 学习端完整源码
  • codex-admin-nextjs-main:Admin 词书后台完整源码
  • 数据库迁移脚本与初始化数据
  • 项目 PRD(产品需求文档)与 Tech Spec(技术规格说明书)
  • 词书 ETL 转换脚本 (json-to-csv.js)

建议在本地部署前,先通读本文的「生产环境真实坑点」与「性能调优方案」章节,以便对工程有更全面的认识。

更多推荐