单词管理系统

一、先搞清楚:这个项目到底在做什么

想象一下,你是一个背单词 App 的运营。你需要管理一大堆"单词书":比如《考研英语 5500 词》《日语 N1 核心词汇》《雅思高频词汇》。你还要管理"谁能登进来改这些数据"------总不能随便一个普通用户就能删除整本单词书吧。

于是你需要这样一个系统:

  • 几个管理员能登录进来,管理单词书(新建、删除、修改、查询);
  • 管理员之间还要分等级:最高权限的"系统管理员"能管理其他人,普通管理员只能管单词书;
  • 登录要安全,不是敲个密码能进就行,得防止别人偷密码、冒充你;
  • 单词数据可能来自乱七八糟的原始文件,需要清洗成规范的格式,才能存进数据库。

这个项目(名字叫 words-admin)就是干这几件事的。它用到的技术有:Next.js (搭建网站)、Drizzle ORM (操作数据库)、Supabase (云端数据库)、shadcn/ui + Tailwind(做界面)。下面我们一个一个拆开看。


二、数据库:数据住的"仓库"

任何管理系统,第一步都是"数据存哪"。数据不能放在草稿纸或 Word 文档里------那样改一个错,要翻半天。正规做法是放进数据库(Database),它就像图书馆的编目系统:成千上万本书,你能按作者、书名、分类瞬间找到你要的那一本。

这里用的是 PostgreSQL (简称 psql),一种非常成熟的关系型数据库。"关系型"的意思,是数据被组织成表格,行是记录、列是字段,表与表之间还能通过"外键"互相引用。

看项目里的 lib/db/schema.ts,它定义了管理员表(admin-users)和会话表(admin-session):

  • admin-users(管理员表):存管理员的名字、邮箱、密码哈希、角色(system=系统管理员 / admin=普通管理员)、创建时间。
  • admin-session(登录会话表):存登录令牌、所属用户、过期时间。

你发现没有?数据库不只是一张表,而是多张有关联的表 。会话表通过 userId 指向管理员表,意思就是"这个令牌属于哪个管理员"。这种表和表之间"牵手"的关系,正是关系型数据库的核心价值。

但问题来了:服务器在哪里? 这个项目没有自己去买一台服务器、装 PostgreSQL,而是用了 Supabase 。Supabase 是一种典型的 BaaS(Backend as a Service,后端即服务) ------它把"数据库 + 用户登录 + 文件存储"这些恼人的底层活都打包成云端服务,你连服务器都不用买,注册个账号、填一个连接地址(项目里的 DATABASE_URL)就能用。这在"云原生"时代几乎是零成本起步:性能、安全、扩容、部署,云厂商都替你扛了,你只关心业务逻辑。

一句话总结:数据库管数据,Supabase 让数据库"上云",你不用操心服务器。


三、ORM:代码和数据库之间的"翻译官"

数据库学会了,但代码怎么跟它说话?如果你直接写 SQL(比如 INSERT INTO users VALUES (...)),会有一堆问题:SQL 字符串容易拼错、容易造成安全隐患(比如 SQL 注入)、而且不同数据库语法还有细微差别。

于是出现了 ORM(Object Relational Mapping,对象关系映射) 。它的思路很妙:把"数据库的一行记录"和"代码里的一个对象"对应起来 。你在代码里写 user.save(),ORM 在背后自动翻译成 INSERT INTO users ...;你写 User.find(id),它就翻译成一条查询语句。

这个项目用的 ORM 叫 Drizzle 。看 lib/db/schema.ts 里那些代码:

ts 复制代码
export const adminUsers = pgTable("admin-users", {
  id: uuid("id").defaultRandom().primaryKey(),
  name: text("name").notNull(),
  email: text("email").notNull().unique(),
  passwordHash: text("password_hash").notNull(),
  role: userRoleEnum("role").notNull().default("admin"),
  createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
});

这几行代码就"声明"了一张表:id 是自动生成的 UUID、email 不能重复、role 默认是 admin......ORM 甚至在后台帮你生成迁移文件drizzle/ 目录下的 .sql 文件),把"代码定义的表结构"同步成"数据库里真实的表"。改一行代码,跑一下命令,数据库结构就跟着变了。这就做到了定义即建表,你不用手写建表 SQL。

所以 ORM 的价值是:让你只跟"对象"打交道,不用记 SQL 语法,也更安全、更好维护。 它牺牲了一点点底层控制的灵活性,换来了开发效率和代码可读性------对大多数业务系统来说,这笔交易非常划算。


四、密码:为什么不能"明文"存放

这是新手最容易忽略、也最致命的一环。很多刚学写网站的人,会直接把用户密码存成"123456"这样的原文。一旦数据库被拖库(被黑客拿到),所有用户的密码直接暴露------而很多人习惯所有网站用同一个密码,于是连带别的账号也被盗。

正确的做法叫作 "加盐哈希" 。看项目里的 lib/password.ts

ts 复制代码
export function hashPassword(password: string): string {
  const salt = randomBytes(16).toString("hex");
  const hash = scryptSync(password, salt, KEY_LENGTH).toString("hex");
  return `${salt}:${hash}`;
}

拆开解释:

  1. 哈希(Hash):像一个"单向榨汁机",把任意长度的密码榨成一串固定长度的乱码。"单向"的意思是:你能从密码算出哈希,但几乎不可能从哈希反推出密码。就算黑客拿到哈希,他也还原不出你的密码。
  2. 加盐(Salt) :光哈希还不够。因为"123456"这种常见密码,所有人哈希出来都一样,黑客可以提前算好一个对照表(彩虹表),一查就破。所以我们在密码里混入一段随机盐值------每个人每次注册的盐都不同,这样"相同的密码,不同的盐,哈希结果完全不同",对照表就失效了。
  3. scrypt:这是加密算法。它故意设计得"慢",让计算机算一次要花点时间。黑客要是想暴力破解,就得花几百倍时间,成本高到他不划算。

最后存进数据库的是「盐值:哈希值」这样一个字符串(salt:hash)。登录校验时(verifyPassword),把盐拆出来、重新算一遍哈希,再和库里存的比对。注意它用了 timingSafeEqual------这是"恒定时间比较",用来防止黑客通过"比较耗时长短"猜密码,属于加密领域的细节安全。

小结:密码存哈希、加盐、用慢算法,是保护用户账号的"三件套"。这是任何正规系统都不能省的事。


五、登录:网站靠什么"记住"你是你

登录页面你天天见,但背后逻辑值得看看。流程是这样的:

  1. 你输入邮箱密码,发给服务器;
  2. 服务器从数据库找到这个用户,用上面说的方式校验密码;
  3. 密码对,服务器就生成一个随机令牌(token) ,存进会话表,同时通过 Cookie 塞进你的浏览器;
  4. 以后你每次访问,浏览器都会自动带上这个 Cookie;服务器一看 Cookie 里的 token 在数据库存在且没过期,就知道"哦,还是你"。

lib/session.ts

ts 复制代码
export async function createSession(userId: string) {
  const token = randomBytes(32).toString("hex");
  const expiresAt = new Date(Date.now() + SESSION_MS);
  await db.insert(adminSessions).values({ userId, token, expiresAt });
  // 通过 HttpOnly Cookie 下发
  cookieStore.set(SESSION_COOKIE_NAME, token, { httpOnly: true, ... });
}

有几个关键细节值得讲:

  • HttpOnly Cookie:这个 cookie 设置成"只能由浏览器自动发送,JavaScript 读不到"。万一网站被注入恶意脚本,脚本也拿不到你的登录令牌,降低了被盗风险。
  • 有过期时间:这里设置 7 天。7 天一到,服务器就清掉它,你重新登录。这是为了安全------万一你忘了登出,别人捡到这台电脑也不能无限期冒充你。
  • 无状态"断开":服务器把 token 存在数据库里,所以它不是"我猜你还在"------而是"我查了数据库,你确实还在有效期"。想"退出登录"?把数据库里那条 token 删掉,再清掉 cookie,就干净利落地登出。

这就是会话认证(Session-based Authentication)。它和"密码"搭配,完成了"证明你是谁"和"持续记住你是谁"这两件事。


六、权限:凭什么你能删数据,我不能?

登录之后,所有管理员权限一样吗?显然不能。系统里要有人能"管理管理员"(添加、删除、改权限),但也应该有人只能"管单词书"。这个项目用了一个**角色(role)**字段:system(系统管理员)和 admin(普通管理员)。

看接口 lib/ 里的授权逻辑:

ts 复制代码
async function authorize() {
  const session = await getSessionUser();
  if (!session) return { error: "未登录", status: 401 };
  if (session.role !== "system") return { error: "无权限", status: 403 };
  return { session };
}
  • 401(未登录):你压根没登录,别来。
  • 403(无权限):你登录了,但身份是普通管理员,这页不对你开放。

这是一个经典的访问控制思想:先判断"你是谁"(认证),再判断"你有没有资格做这件事"(授权)。两个词在英文里是 Authentication 和 Authorization,差别就在一个字母,但分工完全不同。

项目里还有两处很聪明的保护,防止系统"自毁":

  • 注册页面只允许第一次 注册,且第一个注册的人自动成为系统管理员(hasAdminUsers() 判断"已经有人了就不许再注册")。否则谁都能注册成一个超级管理员。
  • 不能删除最后一个系统管理员 ,也不能把自己降级。逻辑很简单:如果系统里连一个能管理管理员的人都没了,那这个系统就永远没人能再管管理员了------等于系统被"锁死"。所以代码里每次都先数一数系统管理员还剩几个,≤1 个就拒绝操作。

这些细节,是资深工程师才能想到的"边界防护"。新手往往只盯着"功能能跑",而高手会想**"如果最坏的情况发生,系统还能不能自保"**。


七、数据加工:从"原始文件"到"干净数据"

前面讲的是"系统怎么跑",现在讲"内容从哪来"。运营手里可能拿到一个巨大的单词数据文件------成千上万条词,但格式很乱,比如从 GitHub 上一个高星的单词资料库下载下来的 JSON Lines 格式(每行一个 JSON 对象,像这样):

json 复制代码
{"wordRank":1,"headWord":"ruler","content":{...},"bookId":"PEPXiaoXue3_1"}

这种格式机器读起来方便,但人看、或者要导进别的系统,就很头疼。所以需要数据清洗 。这个项目写了一个小脚本 temp/json-to-csv.mjs,它做的是:

  1. 按行读取 JSON,跳过空行,把每行解析成一个对象;
  2. 把每个对象里嵌套的 content 字段转成字符串;
  3. 把结果拼成 CSV(一种用逗号分隔的表格格式,Excel 能直接打开)。

这里有个小技术点叫 CSV 转义 :如果某个值里本身含有逗号、引号或换行,就得用引号包起来,并把内部的引号写成两个引号------否则一用逗号分隔就"串行"了,数据就乱了。这也体现了"处理格式"这门手艺里常有的一类问题:分隔符冲突

最后它还在文件开头加了个 UTF-8 BOM,目的很实际------让中文在 Windows 的 Excel 里打开时不乱码。你看,一个看似简单的转换脚本,背后其实都是"真实世界的坑"。

这整条流程------原始数据 → 清洗(选择、格式化、审核)→ 存入数据库------是每一个数据型产品都要过的关。好数据是后面做搜索、做推荐、做背诵算法的地基。

相关推荐
嘻哈baby39 分钟前
运维工程师必须掌握的基础技能有哪些?
后端
风曳丷40 分钟前
08|怎样证明一次 Prompt Injection 成功了
后端
刘立军43 分钟前
插件化与扩展点:引导 AI 模块化插拔开发,功能解耦便于迭代
人工智能·后端·架构
掘金者阿豪1 小时前
HashMap 一篇讲透:从数组、链表、红黑树到扩容以及退化,面试再也不怕被追问
后端
YHL1 小时前
向量数据库入门(二):Embedding → 语义搜索 → RAG 日记助手
前端·架构
码视野1 小时前
基于 Vue3 + Element Plus 的【青少年心理健康智能测评与 AI 情绪树洞陪护干预系统】设计与实现(含PRD/三端源码/大屏)
前端·vue.js·人工智能·vue3
用户813267933251 小时前
Python 计算盘中 VWAP:为什么 1 分钟 K 线是量化工程中的高性价比选择
后端·算法·github
专业抄代码选手1 小时前
03|React 为什么需要协调:比较两棵树,而不是重建页面
前端·javascript·react.js
风曳丷1 小时前
07|Instruction Hierarchy 不是安全边界
后端