霓虹下的记忆引擎:拆解一个 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. 总体架构:双端·单库·两套秩序
#mermaid-svg-sWluIaVPROHzPQUj{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-sWluIaVPROHzPQUj .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-sWluIaVPROHzPQUj .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-sWluIaVPROHzPQUj .error-icon{fill:#552222;}#mermaid-svg-sWluIaVPROHzPQUj .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-sWluIaVPROHzPQUj .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-sWluIaVPROHzPQUj .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-sWluIaVPROHzPQUj .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-sWluIaVPROHzPQUj .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-sWluIaVPROHzPQUj .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-sWluIaVPROHzPQUj .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-sWluIaVPROHzPQUj .marker{fill:#333333;stroke:#333333;}#mermaid-svg-sWluIaVPROHzPQUj .marker.cross{stroke:#333333;}#mermaid-svg-sWluIaVPROHzPQUj svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-sWluIaVPROHzPQUj p{margin:0;}#mermaid-svg-sWluIaVPROHzPQUj .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-sWluIaVPROHzPQUj .cluster-label text{fill:#333;}#mermaid-svg-sWluIaVPROHzPQUj .cluster-label span{color:#333;}#mermaid-svg-sWluIaVPROHzPQUj .cluster-label span p{background-color:transparent;}#mermaid-svg-sWluIaVPROHzPQUj .label text,#mermaid-svg-sWluIaVPROHzPQUj span{fill:#333;color:#333;}#mermaid-svg-sWluIaVPROHzPQUj .node rect,#mermaid-svg-sWluIaVPROHzPQUj .node circle,#mermaid-svg-sWluIaVPROHzPQUj .node ellipse,#mermaid-svg-sWluIaVPROHzPQUj .node polygon,#mermaid-svg-sWluIaVPROHzPQUj .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-sWluIaVPROHzPQUj .rough-node .label text,#mermaid-svg-sWluIaVPROHzPQUj .node .label text,#mermaid-svg-sWluIaVPROHzPQUj .image-shape .label,#mermaid-svg-sWluIaVPROHzPQUj .icon-shape .label{text-anchor:middle;}#mermaid-svg-sWluIaVPROHzPQUj .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-sWluIaVPROHzPQUj .rough-node .label,#mermaid-svg-sWluIaVPROHzPQUj .node .label,#mermaid-svg-sWluIaVPROHzPQUj .image-shape .label,#mermaid-svg-sWluIaVPROHzPQUj .icon-shape .label{text-align:center;}#mermaid-svg-sWluIaVPROHzPQUj .node.clickable{cursor:pointer;}#mermaid-svg-sWluIaVPROHzPQUj .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-sWluIaVPROHzPQUj .arrowheadPath{fill:#333333;}#mermaid-svg-sWluIaVPROHzPQUj .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-sWluIaVPROHzPQUj .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-sWluIaVPROHzPQUj .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-sWluIaVPROHzPQUj .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-sWluIaVPROHzPQUj .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-sWluIaVPROHzPQUj .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-sWluIaVPROHzPQUj .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-sWluIaVPROHzPQUj .cluster text{fill:#333;}#mermaid-svg-sWluIaVPROHzPQUj .cluster span{color:#333;}#mermaid-svg-sWluIaVPROHzPQUj div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-sWluIaVPROHzPQUj .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-sWluIaVPROHzPQUj rect.text{fill:none;stroke-width:0;}#mermaid-svg-sWluIaVPROHzPQUj .icon-shape,#mermaid-svg-sWluIaVPROHzPQUj .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-sWluIaVPROHzPQUj .icon-shape p,#mermaid-svg-sWluIaVPROHzPQUj .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-sWluIaVPROHzPQUj .icon-shape .label rect,#mermaid-svg-sWluIaVPROHzPQUj .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-sWluIaVPROHzPQUj .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-sWluIaVPROHzPQUj .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-sWluIaVPROHzPQUj :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 服务层 / 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 词书后台(仪表盘),共同汇入中央数据库内核。
架构上最值得肯定的两点:
- 读路径走 RSC,写路径走 Server Actions 。首页
app/page.tsx是 Server Component,直接在服务端listBooks()+listRecentLearningByUser()取数后注入客户端组件,避免了客户端瀑布流。 - 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) 用户(浏览器) #mermaid-svg-cm8Lpuk79TcPsHKo{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-cm8Lpuk79TcPsHKo .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-cm8Lpuk79TcPsHKo .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-cm8Lpuk79TcPsHKo .error-icon{fill:#552222;}#mermaid-svg-cm8Lpuk79TcPsHKo .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-cm8Lpuk79TcPsHKo .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-cm8Lpuk79TcPsHKo .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-cm8Lpuk79TcPsHKo .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-cm8Lpuk79TcPsHKo .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-cm8Lpuk79TcPsHKo .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-cm8Lpuk79TcPsHKo .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-cm8Lpuk79TcPsHKo .marker{fill:#333333;stroke:#333333;}#mermaid-svg-cm8Lpuk79TcPsHKo .marker.cross{stroke:#333333;}#mermaid-svg-cm8Lpuk79TcPsHKo svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-cm8Lpuk79TcPsHKo p{margin:0;}#mermaid-svg-cm8Lpuk79TcPsHKo .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-cm8Lpuk79TcPsHKo text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-cm8Lpuk79TcPsHKo .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-cm8Lpuk79TcPsHKo .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-cm8Lpuk79TcPsHKo .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-cm8Lpuk79TcPsHKo .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-cm8Lpuk79TcPsHKo #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-cm8Lpuk79TcPsHKo .sequenceNumber{fill:white;}#mermaid-svg-cm8Lpuk79TcPsHKo #sequencenumber{fill:#333;}#mermaid-svg-cm8Lpuk79TcPsHKo #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-cm8Lpuk79TcPsHKo .messageText{fill:#333;stroke:none;}#mermaid-svg-cm8Lpuk79TcPsHKo .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-cm8Lpuk79TcPsHKo .labelText,#mermaid-svg-cm8Lpuk79TcPsHKo .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-cm8Lpuk79TcPsHKo .loopText,#mermaid-svg-cm8Lpuk79TcPsHKo .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-cm8Lpuk79TcPsHKo .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-cm8Lpuk79TcPsHKo .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-cm8Lpuk79TcPsHKo .noteText,#mermaid-svg-cm8Lpuk79TcPsHKo .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-cm8Lpuk79TcPsHKo .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-cm8Lpuk79TcPsHKo .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-cm8Lpuk79TcPsHKo .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-cm8Lpuk79TcPsHKo .actorPopupMenu{position:absolute;}#mermaid-svg-cm8Lpuk79TcPsHKo .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-cm8Lpuk79TcPsHKo .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-cm8Lpuk79TcPsHKo .actor-man circle,#mermaid-svg-cm8Lpuk79TcPsHKo line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-cm8Lpuk79TcPsHKo :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 进入 /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[](每个都带完整contentJSON)被一次性序列进 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.ts(advanceProgress、getOrInitUserBookProgress)。 - 数据降级层 :
lib/mock-data.ts(fetch 失败时回退假数据)。
② Admin 词书后台(Next.js 15)
- 认证模块 :
admin_users+admin_sessions+ Nodecrypto.scrypt自研会话。 - 词书管理 :
books的增删改查。 - 词库导入 :
json-to-csv.jsETL → 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 / TEXT,UNIQUE(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_state与study_event的字段设计已经完整到可以直接跑 SRS 与行为分析 (连ef / interval / repetition和action枚举都备齐了),但应用层从不去写它们------这正是前文"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_event 与 user_word_state 是"预留接口"------它们让系统具备向事件驱动 / 间隔重复 演化的潜力,却因为缺少写入逻辑而暂时悬空。换句话说,这个系统现在跑得通,但只跑了"一半的环"。后续要做 SRS(第 10 节路线图),本质就是把这条悬空的弧线接上:让每一次"下一个",都同时产生一条 study_event 与一次 user_word_state 的状态迁移。
4. 技术亮点:霓虹中真正闪光的设计
拆一个 AI 生成的工程,最容易陷入两种极端:全盘否定,或盲目吹捧。真正的工程审美,是能在生成代码里辨认出哪些决策是站得住的。这一节只谈亮点------那些即便放进人工评审里也该留用的设计。
4.1 RSC 读 / Server Actions 写:把请求切成两条正交轨道
H5 端最清醒的设计,是它没有把整个应用退化成 SPA。读路径走 React Server Components :app/page.tsx 直接是 Server Component,在服务端调用 listBooks() 与 listRecentLearningByUser() 取数,再把结果注入客户端组件。写路径走 Server Actions (advanceProgress 以 "use server" 声明)。
这带来两个实打实的好处:
- 首屏没有客户端取数瀑布。选词书列表、最近学习都在服务端并行取好,客户端拿到的是已填好的 props,而非先渲染骨架再发请求。
- 写操作收敛到服务端边界 。客户端只负责
currentIdx++的本地游标,真正的UPDATE被封装在服务端动作里,天然规避了把 DB 凭据下发到浏览器的风险。
4.2 postgres.js 标签模板:默认安全的 SQL
工程里所有查询都使用 postgres.js 的标签模板(tagged template):
ts
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 把"初始化"和"防重复"合二为一:
ts
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 取回既有行------没有 SELECT 再 INSERT 的经典竞态,也没有重复进度行。这是关系型数据库提供的免费幂等,用得很地道。
4.4 词书 ETL 管线:把"有道 JSON"炼成可入库 CSV
Admin 端最被低估的工程,是 scripts/json-to-csv.js 这个词书导入前置脚本。现实里的词书导出,往往不是干净的 JSON 数组,而是多个根对象首尾拼接、没有逗号分隔 的半结构化文本。脚本用一个括号深度扫描器 ,而不是 JSON.parse 一锅端:
js
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 表。若同一文件名内容被改动,校验和不匹配即报错退出,而非静默跳过:
js
await trx`INSERT INTO public.__migrations (filename, checksum)
VALUES (${filename}, ${sha256(content)})`;
// 已应用但 checksum 变化 → 直接报错退出
迁移幂等 + 防篡改,是很多生产项目都欠下的账。这一块,是整份代码里最"专业"的注脚(完整代码见下文「核心源码拆解 · 迁移 Runner」)。
4.8 边缘鉴权:Middleware 只做一件事
H5 的 middleware.ts 把鉴权逻辑压到 Edge Runtime,且只做最轻量的存在性校验:
ts
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.content 为 json / 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 |
单一事实源、可回滚、无并发建表竞态 |
| 内容存储 | content 以 text/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 时执行:
ts
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 整本词书的一次性倾泻
ts
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:
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:
ts
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 做了一件很多生产项目都忽略的事------迁移校验和:
js
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. 生产环境真实坑点
按严重程度排序,均为源码实证:
- 整本词书无分页加载(P0) :
listWordsByBook无LIMIT,TOEFL 级词书直接撑大 RSC payload 与服务端内存。 - 进度写无并发保护(P0) :
advanceProgress缺 CAS 校验,双标签页/快速点击会破坏断点续学。 - 运行时建表双源(P1) :
ensureTableExists与 migration 文件重复定义 schema,且有并发建表竞态。 - 同步哈希阻塞(P1) :
app/db.ts的createUser用hashSync/genSaltSync,注册请求阻塞事件循环。 - 会话缺 id 导致每请求回查(P1) :无
jwt/session回调注入 id,所有 API/Action 靠getUser(email)反查,单页加载触发 4~6 次该查询。 - Schema 先于代码(P1) :
user_word_state(收藏/难度/SRS)、study_event(埋点)已建表建约束,但应用层从不写入 ;详情页"收藏"仅是console.log。模型很丰满,行为很骨感。 - VARCHAR(64) 邮箱 + 动态建表无唯一约束(P2) :迁移里补了
user_email_unique,但运行时建表路径没有,存在重复注册与authorize取user[0]歧义登录风险。 - Mock 双数据源(P2) :
HomePage在 fetch 失败时回退mockBooks,生产环境可能展示与数据库不一致的"幽灵词书"。 - 详情页重复全量拉取(P2) :
/word/[bookId]/[idx]路由再次listWordsByBook整本加载,只为取单条dbWords[idx]------N+1 式的重复全表扫描。 - 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 + GIN :
ALTER 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. 演进路线图
#mermaid-svg-6HpbkfJXqwaUStAO{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-6HpbkfJXqwaUStAO .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-6HpbkfJXqwaUStAO .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-6HpbkfJXqwaUStAO .error-icon{fill:#552222;}#mermaid-svg-6HpbkfJXqwaUStAO .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-6HpbkfJXqwaUStAO .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-6HpbkfJXqwaUStAO .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-6HpbkfJXqwaUStAO .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-6HpbkfJXqwaUStAO .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-6HpbkfJXqwaUStAO .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-6HpbkfJXqwaUStAO .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-6HpbkfJXqwaUStAO .marker{fill:#333333;stroke:#333333;}#mermaid-svg-6HpbkfJXqwaUStAO .marker.cross{stroke:#333333;}#mermaid-svg-6HpbkfJXqwaUStAO svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-6HpbkfJXqwaUStAO p{margin:0;}#mermaid-svg-6HpbkfJXqwaUStAO .edge{stroke-width:3;}#mermaid-svg-6HpbkfJXqwaUStAO .section--1 rect,#mermaid-svg-6HpbkfJXqwaUStAO .section--1 path,#mermaid-svg-6HpbkfJXqwaUStAO .section--1 circle,#mermaid-svg-6HpbkfJXqwaUStAO .section--1 path{fill:hsl(240, 100%, 76.2745098039%);}#mermaid-svg-6HpbkfJXqwaUStAO .section--1 text{fill:#ffffff;}#mermaid-svg-6HpbkfJXqwaUStAO .node-icon--1{font-size:40px;color:#ffffff;}#mermaid-svg-6HpbkfJXqwaUStAO .section-edge--1{stroke:hsl(240, 100%, 76.2745098039%);}#mermaid-svg-6HpbkfJXqwaUStAO .edge-depth--1{stroke-width:17;}#mermaid-svg-6HpbkfJXqwaUStAO .section--1 line{stroke:hsl(60, 100%, 86.2745098039%);stroke-width:3;}#mermaid-svg-6HpbkfJXqwaUStAO .lineWrapper line{stroke:#ffffff;}#mermaid-svg-6HpbkfJXqwaUStAO .disabled,#mermaid-svg-6HpbkfJXqwaUStAO .disabled circle,#mermaid-svg-6HpbkfJXqwaUStAO .disabled text{fill:lightgray;}#mermaid-svg-6HpbkfJXqwaUStAO .disabled text{fill:#efefef;}#mermaid-svg-6HpbkfJXqwaUStAO .section-0 rect,#mermaid-svg-6HpbkfJXqwaUStAO .section-0 path,#mermaid-svg-6HpbkfJXqwaUStAO .section-0 circle,#mermaid-svg-6HpbkfJXqwaUStAO .section-0 path{fill:hsl(60, 100%, 73.5294117647%);}#mermaid-svg-6HpbkfJXqwaUStAO .section-0 text{fill:black;}#mermaid-svg-6HpbkfJXqwaUStAO .node-icon-0{font-size:40px;color:black;}#mermaid-svg-6HpbkfJXqwaUStAO .section-edge-0{stroke:hsl(60, 100%, 73.5294117647%);}#mermaid-svg-6HpbkfJXqwaUStAO .edge-depth-0{stroke-width:14;}#mermaid-svg-6HpbkfJXqwaUStAO .section-0 line{stroke:hsl(240, 100%, 83.5294117647%);stroke-width:3;}#mermaid-svg-6HpbkfJXqwaUStAO .lineWrapper line{stroke:black;}#mermaid-svg-6HpbkfJXqwaUStAO .disabled,#mermaid-svg-6HpbkfJXqwaUStAO .disabled circle,#mermaid-svg-6HpbkfJXqwaUStAO .disabled text{fill:lightgray;}#mermaid-svg-6HpbkfJXqwaUStAO .disabled text{fill:#efefef;}#mermaid-svg-6HpbkfJXqwaUStAO .section-1 rect,#mermaid-svg-6HpbkfJXqwaUStAO .section-1 path,#mermaid-svg-6HpbkfJXqwaUStAO .section-1 circle,#mermaid-svg-6HpbkfJXqwaUStAO .section-1 path{fill:hsl(80, 100%, 76.2745098039%);}#mermaid-svg-6HpbkfJXqwaUStAO .section-1 text{fill:black;}#mermaid-svg-6HpbkfJXqwaUStAO .node-icon-1{font-size:40px;color:black;}#mermaid-svg-6HpbkfJXqwaUStAO .section-edge-1{stroke:hsl(80, 100%, 76.2745098039%);}#mermaid-svg-6HpbkfJXqwaUStAO .edge-depth-1{stroke-width:11;}#mermaid-svg-6HpbkfJXqwaUStAO .section-1 line{stroke:hsl(260, 100%, 86.2745098039%);stroke-width:3;}#mermaid-svg-6HpbkfJXqwaUStAO .lineWrapper line{stroke:black;}#mermaid-svg-6HpbkfJXqwaUStAO .disabled,#mermaid-svg-6HpbkfJXqwaUStAO .disabled circle,#mermaid-svg-6HpbkfJXqwaUStAO .disabled text{fill:lightgray;}#mermaid-svg-6HpbkfJXqwaUStAO .disabled text{fill:#efefef;}#mermaid-svg-6HpbkfJXqwaUStAO .section-2 rect,#mermaid-svg-6HpbkfJXqwaUStAO .section-2 path,#mermaid-svg-6HpbkfJXqwaUStAO .section-2 circle,#mermaid-svg-6HpbkfJXqwaUStAO .section-2 path{fill:hsl(270, 100%, 76.2745098039%);}#mermaid-svg-6HpbkfJXqwaUStAO .section-2 text{fill:#ffffff;}#mermaid-svg-6HpbkfJXqwaUStAO .node-icon-2{font-size:40px;color:#ffffff;}#mermaid-svg-6HpbkfJXqwaUStAO .section-edge-2{stroke:hsl(270, 100%, 76.2745098039%);}#mermaid-svg-6HpbkfJXqwaUStAO .edge-depth-2{stroke-width:8;}#mermaid-svg-6HpbkfJXqwaUStAO .section-2 line{stroke:hsl(90, 100%, 86.2745098039%);stroke-width:3;}#mermaid-svg-6HpbkfJXqwaUStAO .lineWrapper line{stroke:#ffffff;}#mermaid-svg-6HpbkfJXqwaUStAO .disabled,#mermaid-svg-6HpbkfJXqwaUStAO .disabled circle,#mermaid-svg-6HpbkfJXqwaUStAO .disabled text{fill:lightgray;}#mermaid-svg-6HpbkfJXqwaUStAO .disabled text{fill:#efefef;}#mermaid-svg-6HpbkfJXqwaUStAO .section-3 rect,#mermaid-svg-6HpbkfJXqwaUStAO .section-3 path,#mermaid-svg-6HpbkfJXqwaUStAO .section-3 circle,#mermaid-svg-6HpbkfJXqwaUStAO .section-3 path{fill:hsl(300, 100%, 76.2745098039%);}#mermaid-svg-6HpbkfJXqwaUStAO .section-3 text{fill:black;}#mermaid-svg-6HpbkfJXqwaUStAO .node-icon-3{font-size:40px;color:black;}#mermaid-svg-6HpbkfJXqwaUStAO .section-edge-3{stroke:hsl(300, 100%, 76.2745098039%);}#mermaid-svg-6HpbkfJXqwaUStAO .edge-depth-3{stroke-width:5;}#mermaid-svg-6HpbkfJXqwaUStAO .section-3 line{stroke:hsl(120, 100%, 86.2745098039%);stroke-width:3;}#mermaid-svg-6HpbkfJXqwaUStAO .lineWrapper line{stroke:black;}#mermaid-svg-6HpbkfJXqwaUStAO .disabled,#mermaid-svg-6HpbkfJXqwaUStAO .disabled circle,#mermaid-svg-6HpbkfJXqwaUStAO .disabled text{fill:lightgray;}#mermaid-svg-6HpbkfJXqwaUStAO .disabled text{fill:#efefef;}#mermaid-svg-6HpbkfJXqwaUStAO .section-4 rect,#mermaid-svg-6HpbkfJXqwaUStAO .section-4 path,#mermaid-svg-6HpbkfJXqwaUStAO .section-4 circle,#mermaid-svg-6HpbkfJXqwaUStAO .section-4 path{fill:hsl(330, 100%, 76.2745098039%);}#mermaid-svg-6HpbkfJXqwaUStAO .section-4 text{fill:black;}#mermaid-svg-6HpbkfJXqwaUStAO .node-icon-4{font-size:40px;color:black;}#mermaid-svg-6HpbkfJXqwaUStAO .section-edge-4{stroke:hsl(330, 100%, 76.2745098039%);}#mermaid-svg-6HpbkfJXqwaUStAO .edge-depth-4{stroke-width:2;}#mermaid-svg-6HpbkfJXqwaUStAO .section-4 line{stroke:hsl(150, 100%, 86.2745098039%);stroke-width:3;}#mermaid-svg-6HpbkfJXqwaUStAO .lineWrapper line{stroke:black;}#mermaid-svg-6HpbkfJXqwaUStAO .disabled,#mermaid-svg-6HpbkfJXqwaUStAO .disabled circle,#mermaid-svg-6HpbkfJXqwaUStAO .disabled text{fill:lightgray;}#mermaid-svg-6HpbkfJXqwaUStAO .disabled text{fill:#efefef;}#mermaid-svg-6HpbkfJXqwaUStAO .section-5 rect,#mermaid-svg-6HpbkfJXqwaUStAO .section-5 path,#mermaid-svg-6HpbkfJXqwaUStAO .section-5 circle,#mermaid-svg-6HpbkfJXqwaUStAO .section-5 path{fill:hsl(0, 100%, 76.2745098039%);}#mermaid-svg-6HpbkfJXqwaUStAO .section-5 text{fill:black;}#mermaid-svg-6HpbkfJXqwaUStAO .node-icon-5{font-size:40px;color:black;}#mermaid-svg-6HpbkfJXqwaUStAO .section-edge-5{stroke:hsl(0, 100%, 76.2745098039%);}#mermaid-svg-6HpbkfJXqwaUStAO .edge-depth-5{stroke-width:-1;}#mermaid-svg-6HpbkfJXqwaUStAO .section-5 line{stroke:hsl(180, 100%, 86.2745098039%);stroke-width:3;}#mermaid-svg-6HpbkfJXqwaUStAO .lineWrapper line{stroke:black;}#mermaid-svg-6HpbkfJXqwaUStAO .disabled,#mermaid-svg-6HpbkfJXqwaUStAO .disabled circle,#mermaid-svg-6HpbkfJXqwaUStAO .disabled text{fill:lightgray;}#mermaid-svg-6HpbkfJXqwaUStAO .disabled text{fill:#efefef;}#mermaid-svg-6HpbkfJXqwaUStAO .section-6 rect,#mermaid-svg-6HpbkfJXqwaUStAO .section-6 path,#mermaid-svg-6HpbkfJXqwaUStAO .section-6 circle,#mermaid-svg-6HpbkfJXqwaUStAO .section-6 path{fill:hsl(30, 100%, 76.2745098039%);}#mermaid-svg-6HpbkfJXqwaUStAO .section-6 text{fill:black;}#mermaid-svg-6HpbkfJXqwaUStAO .node-icon-6{font-size:40px;color:black;}#mermaid-svg-6HpbkfJXqwaUStAO .section-edge-6{stroke:hsl(30, 100%, 76.2745098039%);}#mermaid-svg-6HpbkfJXqwaUStAO .edge-depth-6{stroke-width:-4;}#mermaid-svg-6HpbkfJXqwaUStAO .section-6 line{stroke:hsl(210, 100%, 86.2745098039%);stroke-width:3;}#mermaid-svg-6HpbkfJXqwaUStAO .lineWrapper line{stroke:black;}#mermaid-svg-6HpbkfJXqwaUStAO .disabled,#mermaid-svg-6HpbkfJXqwaUStAO .disabled circle,#mermaid-svg-6HpbkfJXqwaUStAO .disabled text{fill:lightgray;}#mermaid-svg-6HpbkfJXqwaUStAO .disabled text{fill:#efefef;}#mermaid-svg-6HpbkfJXqwaUStAO .section-7 rect,#mermaid-svg-6HpbkfJXqwaUStAO .section-7 path,#mermaid-svg-6HpbkfJXqwaUStAO .section-7 circle,#mermaid-svg-6HpbkfJXqwaUStAO .section-7 path{fill:hsl(90, 100%, 76.2745098039%);}#mermaid-svg-6HpbkfJXqwaUStAO .section-7 text{fill:black;}#mermaid-svg-6HpbkfJXqwaUStAO .node-icon-7{font-size:40px;color:black;}#mermaid-svg-6HpbkfJXqwaUStAO .section-edge-7{stroke:hsl(90, 100%, 76.2745098039%);}#mermaid-svg-6HpbkfJXqwaUStAO .edge-depth-7{stroke-width:-7;}#mermaid-svg-6HpbkfJXqwaUStAO .section-7 line{stroke:hsl(270, 100%, 86.2745098039%);stroke-width:3;}#mermaid-svg-6HpbkfJXqwaUStAO .lineWrapper line{stroke:black;}#mermaid-svg-6HpbkfJXqwaUStAO .disabled,#mermaid-svg-6HpbkfJXqwaUStAO .disabled circle,#mermaid-svg-6HpbkfJXqwaUStAO .disabled text{fill:lightgray;}#mermaid-svg-6HpbkfJXqwaUStAO .disabled text{fill:#efefef;}#mermaid-svg-6HpbkfJXqwaUStAO .section-8 rect,#mermaid-svg-6HpbkfJXqwaUStAO .section-8 path,#mermaid-svg-6HpbkfJXqwaUStAO .section-8 circle,#mermaid-svg-6HpbkfJXqwaUStAO .section-8 path{fill:hsl(150, 100%, 76.2745098039%);}#mermaid-svg-6HpbkfJXqwaUStAO .section-8 text{fill:black;}#mermaid-svg-6HpbkfJXqwaUStAO .node-icon-8{font-size:40px;color:black;}#mermaid-svg-6HpbkfJXqwaUStAO .section-edge-8{stroke:hsl(150, 100%, 76.2745098039%);}#mermaid-svg-6HpbkfJXqwaUStAO .edge-depth-8{stroke-width:-10;}#mermaid-svg-6HpbkfJXqwaUStAO .section-8 line{stroke:hsl(330, 100%, 86.2745098039%);stroke-width:3;}#mermaid-svg-6HpbkfJXqwaUStAO .lineWrapper line{stroke:black;}#mermaid-svg-6HpbkfJXqwaUStAO .disabled,#mermaid-svg-6HpbkfJXqwaUStAO .disabled circle,#mermaid-svg-6HpbkfJXqwaUStAO .disabled text{fill:lightgray;}#mermaid-svg-6HpbkfJXqwaUStAO .disabled text{fill:#efefef;}#mermaid-svg-6HpbkfJXqwaUStAO .section-9 rect,#mermaid-svg-6HpbkfJXqwaUStAO .section-9 path,#mermaid-svg-6HpbkfJXqwaUStAO .section-9 circle,#mermaid-svg-6HpbkfJXqwaUStAO .section-9 path{fill:hsl(180, 100%, 76.2745098039%);}#mermaid-svg-6HpbkfJXqwaUStAO .section-9 text{fill:black;}#mermaid-svg-6HpbkfJXqwaUStAO .node-icon-9{font-size:40px;color:black;}#mermaid-svg-6HpbkfJXqwaUStAO .section-edge-9{stroke:hsl(180, 100%, 76.2745098039%);}#mermaid-svg-6HpbkfJXqwaUStAO .edge-depth-9{stroke-width:-13;}#mermaid-svg-6HpbkfJXqwaUStAO .section-9 line{stroke:hsl(0, 100%, 86.2745098039%);stroke-width:3;}#mermaid-svg-6HpbkfJXqwaUStAO .lineWrapper line{stroke:black;}#mermaid-svg-6HpbkfJXqwaUStAO .disabled,#mermaid-svg-6HpbkfJXqwaUStAO .disabled circle,#mermaid-svg-6HpbkfJXqwaUStAO .disabled text{fill:lightgray;}#mermaid-svg-6HpbkfJXqwaUStAO .disabled text{fill:#efefef;}#mermaid-svg-6HpbkfJXqwaUStAO .section-10 rect,#mermaid-svg-6HpbkfJXqwaUStAO .section-10 path,#mermaid-svg-6HpbkfJXqwaUStAO .section-10 circle,#mermaid-svg-6HpbkfJXqwaUStAO .section-10 path{fill:hsl(210, 100%, 76.2745098039%);}#mermaid-svg-6HpbkfJXqwaUStAO .section-10 text{fill:black;}#mermaid-svg-6HpbkfJXqwaUStAO .node-icon-10{font-size:40px;color:black;}#mermaid-svg-6HpbkfJXqwaUStAO .section-edge-10{stroke:hsl(210, 100%, 76.2745098039%);}#mermaid-svg-6HpbkfJXqwaUStAO .edge-depth-10{stroke-width:-16;}#mermaid-svg-6HpbkfJXqwaUStAO .section-10 line{stroke:hsl(30, 100%, 86.2745098039%);stroke-width:3;}#mermaid-svg-6HpbkfJXqwaUStAO .lineWrapper line{stroke:black;}#mermaid-svg-6HpbkfJXqwaUStAO .disabled,#mermaid-svg-6HpbkfJXqwaUStAO .disabled circle,#mermaid-svg-6HpbkfJXqwaUStAO .disabled text{fill:lightgray;}#mermaid-svg-6HpbkfJXqwaUStAO .disabled text{fill:#efefef;}#mermaid-svg-6HpbkfJXqwaUStAO .section-root rect,#mermaid-svg-6HpbkfJXqwaUStAO .section-root path,#mermaid-svg-6HpbkfJXqwaUStAO .section-root circle{fill:hsl(240, 100%, 46.2745098039%);}#mermaid-svg-6HpbkfJXqwaUStAO .section-root text{fill:#ffffff;}#mermaid-svg-6HpbkfJXqwaUStAO .icon-container{height:100%;display:flex;justify-content:center;align-items:center;}#mermaid-svg-6HpbkfJXqwaUStAO .edge{fill:none;}#mermaid-svg-6HpbkfJXqwaUStAO .eventWrapper{filter:brightness(120%);}#mermaid-svg-6HpbkfJXqwaUStAO :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 现在 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)
建议在本地部署前,先通读本文的「生产环境真实坑点」与「性能调优方案」章节,以便对工程有更全面的认识。