一、先搞清楚:这个项目到底在做什么
想象一下,你是一个背单词 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}`;
}
拆开解释:
- 哈希(Hash):像一个"单向榨汁机",把任意长度的密码榨成一串固定长度的乱码。"单向"的意思是:你能从密码算出哈希,但几乎不可能从哈希反推出密码。就算黑客拿到哈希,他也还原不出你的密码。
- 加盐(Salt) :光哈希还不够。因为"123456"这种常见密码,所有人哈希出来都一样,黑客可以提前算好一个对照表(彩虹表),一查就破。所以我们在密码里混入一段随机盐值------每个人每次注册的盐都不同,这样"相同的密码,不同的盐,哈希结果完全不同",对照表就失效了。
- scrypt:这是加密算法。它故意设计得"慢",让计算机算一次要花点时间。黑客要是想暴力破解,就得花几百倍时间,成本高到他不划算。
最后存进数据库的是「盐值:哈希值」这样一个字符串(salt:hash)。登录校验时(verifyPassword),把盐拆出来、重新算一遍哈希,再和库里存的比对。注意它用了 timingSafeEqual------这是"恒定时间比较",用来防止黑客通过"比较耗时长短"猜密码,属于加密领域的细节安全。
小结:密码存哈希、加盐、用慢算法,是保护用户账号的"三件套"。这是任何正规系统都不能省的事。
五、登录:网站靠什么"记住"你是你
登录页面你天天见,但背后逻辑值得看看。流程是这样的:
- 你输入邮箱密码,发给服务器;
- 服务器从数据库找到这个用户,用上面说的方式校验密码;
- 密码对,服务器就生成一个随机令牌(token) ,存进会话表,同时通过 Cookie 塞进你的浏览器;
- 以后你每次访问,浏览器都会自动带上这个 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,它做的是:
- 按行读取 JSON,跳过空行,把每行解析成一个对象;
- 把每个对象里嵌套的
content字段转成字符串; - 把结果拼成 CSV(一种用逗号分隔的表格格式,Excel 能直接打开)。
这里有个小技术点叫 CSV 转义 :如果某个值里本身含有逗号、引号或换行,就得用引号包起来,并把内部的引号写成两个引号------否则一用逗号分隔就"串行"了,数据就乱了。这也体现了"处理格式"这门手艺里常有的一类问题:分隔符冲突。
最后它还在文件开头加了个 UTF-8 BOM,目的很实际------让中文在 Windows 的 Excel 里打开时不乱码。你看,一个看似简单的转换脚本,背后其实都是"真实世界的坑"。
这整条流程------原始数据 → 清洗(选择、格式化、审核)→ 存入数据库------是每一个数据型产品都要过的关。好数据是后面做搜索、做推荐、做背诵算法的地基。