项目:react-travel | 版本:v1.2 | 生成日期:2026-10-06 | 依据:源码分析
本篇是"带码走读":选取三条最能代表项目工程水准的链路,把源码原文贴出来逐段解读,每段后以「这段代码教会我们什么」收束。与既有篇目的分工:《05-核心流程》讲端到端时序(谁先谁后)、《07-模块详解》讲业务域全景(每个域的文件清单与机制综述)——本篇则下沉到行级,回答"这几行代码为什么这么写、边界在哪里、换个写法会坏在哪"。选材三条标准:一是有真实决策密度(注释记载了备选方案与实测教训),二是被多条链路复用(改错影响面大),三是独立可读(不需要展开整个业务域就能讲透)。最终选定三个走读对象:其一,登录与双轨哈希链路(lib/password.ts + app/api/auth/login/route.ts + lib/auth.ts 的 requireAuth 段)——安全防御最密集的 60 行;其二,lib/services/cart.ts 的加购与批量支付事务——并发正确性的教科书;其三,middleware.ts 全文——45 行看懂双层鉴权的设计取舍。所有代码均为仓库原文摘录(路径已标注,行号以当前工作区为准),代码块用四反引号围栏包裹。
源文件 /Users/zhangzhengyang/Desktop/publish/react-travel/lib/password.ts,全文 38 行:
import bcrypt from "bcryptjs"
import { md5Password } from "./utils"
const SALT_ROUNDS = 12
/**
* 时序对齐用固定哈希:用户不存在时也执行一次 bcrypt.compare,
* 使"账号不存在"与"密码错误"的响应耗时一致,防用户名时序枚举。
* (值是对固定明文的真实 bcrypt hash,非任何真实账号密码)
*/
export const DUMMY_BCRYPT_HASH =
"$2b$12$xSymGOqyIYw0m2Oxws0UwOeXhXx42o1kO57AM4K2HeCZVeVomr7Gy"
/** 判断存储的密码是否为 bcrypt 哈希(老账户是 md5,新账户是 bcrypt) */
export function isBcryptHash(hash?: string | null): boolean {
return typeof hash === "string" && hash.startsWith("$2")
}
/** 哈希明文密码(用于注册 / 改密),强度远高于原 md5(password+salt) */
export async function hashPassword(password: string): Promise<string> {
return bcrypt.hash(password, SALT_ROUNDS)
}
/**
* 校验密码:兼容 bcrypt 与老 md5 账户,实现渐进迁移。
* - 新账户(bcrypt 哈希):bcrypt.compare
* - 老账户(md5 哈希):md5(password) 比对(不强制改密,避免破坏现有测试账号)
*/
export async function verifyPassword(
password: string,
stored?: string | null
): Promise<boolean> {
if (!stored) return false
if (isBcryptHash(stored)) {
return bcrypt.compare(password, stored)
}
return md5Password(password) === stored
}逐段解读。第一段值得停的是 DUMMY_BCRYPT_HASH:它是一个对固定明文预生成的真实 bcrypt 哈希($2b$12$ 前缀表明 cost 因子 12,与 SALT_ROUNDS 一致),而不是占位字符串——这是时序对齐能成立的前提,如果用 "dummy" 之类的假值,后续的 bcrypt.compare 会因哈希格式非法而快速失败,耗时对齐就失效了。第二段 isBcryptHash 只有三行,却承担了整个迁移策略的分拣:以存储哈希的形态自描述(bcrypt 哈希必然以 $2 开头,md5 hex 必然是 32 位小写十六进制)取代显式版本列,新旧账户在 User.password 同一列中共存,schema 零变更。第三段 verifyPassword 的分支次序也有讲究:先短路 !stored(防御数据库中出现空密码——空密码直接判失败,不给"空串匹配空串"留机会),再 bcrypt 分支,最后 md5 兜底。设计意图:注册入口(lib/services/user.ts#createUser)与种子脚本(prisma/seed.ts)都只调用 hashPassword,新写入永远是 bcrypt;md5 分支是只读的兼容通道,等待《13-技术债务与演进建议》D5 的透明升级修复。
这段代码教会我们什么:渐进迁移的关键是"让数据自己说明自己是什么版本"。比起加 passwordVersion 列的显式方案,哈希形态自识别少了一次 schema 变更、少了一处两列一致性的维护,代价只是 startsWith("$2") 这一行判断——当旧格式有唯一可识别的形态标记时,这是最便宜的版本位。同时它示范了"dummy 值也要造假造得像真的":防御性常量的有效性取决于它与真实值的可观测差异,差一点都不行。
源文件 /Users/zhangzhengyang/Desktop/publish/react-travel/app/api/auth/login/route.ts(节选第 9-28 行):
export async function POST(req: NextRequest) {
const body = await req.json().catch(() => ({}));
// 前端 SignInForm 发送的是 { email, password }(email 里装的是用户名)
const username = String(body.username ?? body.email ?? "").trim();
const password = String(body.password ?? "");
if (!username || !password) {
return json({ status: 400, errMsg: "用户名和密码必填" }, 400);
}
// 防爆破:按"账号"维度限流,命中后 60s 内拒绝
const limited = authRateLimit(req, `login:${username}`);
if (limited) return limited;
const user = await getUserByUsername(username);
// verifyPassword 兼容 bcrypt 与老 md5 账户(#6 升级)
// 用户不存在时也对固定哈希做一次比较,对齐耗时,避免时序枚举用户名
const ok = await verifyPassword(password, user?.password ?? DUMMY_BCRYPT_HASH);
if (!user || !ok) {
return json({ status: 401, errMsg: "用户名或密码错误" }, 401);
}逐段解读。第一行 req.json().catch(() => ({})) 是全仓统一的 body 解析姿势:非法 JSON 不会抛 500,而是空对象落入后面的"缺字段 400"分支——错误输入被收敛为正常校验流而非异常流。第二行的 body.username ?? body.email ?? "" 处理的是历史字段错位:旧前端表单字段名叫 email 装的却是用户名(imooc 遗留),服务端双兜底而非要求前端改名,保住 ADR-01 的零改动前提。限流的 key 是 login:${username} 再拼 IP(authRateLimit 内部追加 :clientIp),形成"用户名+IP"双维度桶。真正的精华是 user?.password ?? DUMMY_BCRYPT_HASH 这一行的执行顺序:先查库拿到 user(可能为 null),再无条件走 verifyPassword——用户不存在时对 DUMMY 哈希做一次真实的 12 轮 bcrypt 比较,随后才用 !user || !ok 合并判定。注意 !user || !ok 不能写成 !user || !(await verifyPassword(...))(短路会导致用户不存在时跳过比较),也不能在查库后 if (!user) return 401(提前返回暴露时序)。边界处理:String(body.password ?? "") 保证密码字段非字符串时(数字、对象)退化为空串走 400;查询与比较之间没有二次校验用户状态(锁定/禁用等不存在于本系统)。
这段代码教会我们什么:时序侧信道防御的正确姿势是"把快路径强制走成慢路径",而不是"给慢路径睡几毫秒"——后者脆弱且难维护。同时这一段是"错误合并"的范例:用户不存在 与 密码错误 在响应上不可区分(同一 401 文案、同量级耗时),信息只在服务端日志侧保留。安全设计里,减少响应携带的信息往往比增加校验更有效。
源文件 /Users/zhangzhengyang/Desktop/publish/react-travel/lib/auth.ts(节选第 102-126 行,Redis 比对与守卫):
export async function isTokenCached(username: string, token: string): Promise<boolean> {
const r = getRedis();
if (!r) return true; // 无 Redis 时跳过存在性校验
try {
const cached = await r.get(username);
return cached === token;
} catch (e) {
// Redis 故障时降级为只信 JWT 签名(fail-open),避免全部受保护接口 500/挂起
console.error("[auth] Redis 校验 token 失败,降级为仅 JWT 校验", e);
return true;
}
}
/** Route Handler 鉴权守卫:返回 payload 或 401 Response */
export async function requireAuth(req: Request): Promise<JwtPayload | Response> {
const token =
getTokenFromHeader(req) ?? (await getTokenFromCookie());
const payload = verifyToken(token);
if (!payload) return Response.json({ status: 401, errMsg: "未登录或 token 失效" }, { status: 401 });
const valid = await isTokenCached(payload.username, token!);
if (!valid) return Response.json({ status: 401, errMsg: "登录已失效" }, { status: 401 });
return payload;
}逐段解读。requireAuth 的返回类型 JwtPayload | Response 是全仓 25 个受保护 Handler(17 个 route.ts)共用的分支协议:调用方以 if (auth instanceof Response) return auth; 一行放行或转发,TypeScript 联合类型把"两种结局"编码进签名,调用方不可能忘记处理 401 分支(忘了的话 401 Response 会被当 payload 用,tsc 立刻报错)。两段校验的语义不同:第一段 verifyToken 是密码学校验(签名 + 过期),第二段 isTokenCached 是状态校验(该 token 是否仍是"最新签发的那一个")——key 为 username、value 为 token 的单值设计,使"退出登录删 key"与"别处登录覆盖 key"(互踢)共享同一个机制。错误文案的分化是刻意的排障锚点:"未登录或 token 失效"只来自第一段,"登录已失效"只来自第二段(《12-FAQ》坑 7 靠文案二分定位)。fail-open 的取舍写在 catch 里:Redis 故障时返回 true 放行——注释把理由说完(避免全部受保护接口 500/挂起),代价是故障窗口内"注销"失效,这是可用性优先于绝对安全的显式选择。边界处理:token! 的非空断言安全,因为 payload 非 null 蕴含 token 非 null;getTokenFromHeader 对 scheme 做 toLowerCase() 比较,容忍客户端 bearer/Bearer 混写。
这段代码教会我们什么:其一,"把分支编码进返回类型"比文档约定可靠——JwtPayload | Response 让 401 处理成为编译期义务。其二,可降级设计要明确降级方向:fail-open 还是 fail-closed,取决于失效的业务代价(这里注销能力暂时失效可接受,全部接口不可用不可接受),并且要把决定写在代码旁边,否则后人会当成 bug"修"掉。其三,两条 401 用不同文案不是不小心,是留给运维的观测面。
源文件 /Users/zhangzhengyang/Desktop/publish/react-travel/lib/services/cart.ts(第 38-73 行):
/**
* 加入购物车(同一房源不重复加;同时写入价格快照)。
*
* 并发防护:以 userId 为粒度的 PostgreSQL advisory lock 串行化"查重→插入",
* 避免快速双击时两个请求都读到"不存在"而插入重复购物车行
* (Orders 表无法用 (userId, houseId, isPayed) 唯一约束:会阻断同一房源的再次购买)。
*/
export async function addCartItem(userId: number, houseId: number) {
const snapshot = await getHouseSnapshot(houseId);
if (!snapshot) throw new Error("房源不存在");
await prisma.$transaction(async (tx) => {
// 同一用户的所有加购操作串行执行(事务提交/回滚自动释放锁)
// 注意用 $executeRaw 而非 $queryRaw:pg_advisory_xact_lock 返回 void,
// $queryRaw 会尝试反序列化 void 列而抛错(Prisma 7 实测)
await tx.$executeRaw`SELECT pg_advisory_xact_lock(${userId})`;
const exist = await tx.orders.findFirst({
where: { userId, houseId, isPayed: 0 },
select: { id: true }
});
if (exist) return;
await tx.orders.create({
data: {
userId,
houseId,
isPayed: 0,
createTime: BigInt(Date.now()),
...snapshot
}
});
});
return getCartRows(userId);
}逐段解读。执行顺序是理解本段的关键:快照查询在事务外(getHouseSnapshot),锁在事务内第一步。快照放事务外是刻意的——房源不存在要抛业务异常(Handler 白名单透传该文案为 500"房源不存在"),没必要先拿锁;快照数据(price/name/首图)本身是幂等读取,锁内锁外读都一样。事务内三步构成完整的 check-then-act:拿锁(pg_advisory_xact_lock 是事务级锁,连接持有到 commit/rollback,$executeRaw 的选择是实测教训——Prisma 7 的 $queryRaw 尝试把 void 结果集反序列化成行会抛错)→ 查重(findFirst 只 select id,最小投影)→ 插入(携带快照三字段)。BigInt(Date.now()) 的手工写入对齐 Orders 旧表的毫秒时间戳约定(《04-数据模型》§1)。最后 return getCartRows(userId) 把"插入完成"与"列表刷新"合并为一次请求——Handler 直接把返回值整体回写客户端 store,没有第二次 GET。边界处理:查重命中 return 是幂等语义(同一房源不重复加购),且锁保证了两个并发请求中后到者必然看到先到者的插入。
这段代码教会我们什么:check-then-act 竞态的根治手段是"把检查和动作放进同一把锁的临界区",而选锁要回答两个问题——锁的粒度(这里用 userId 而非 userId+houseId,过度串行化换实现简单)与锁的生命周期(事务级随事务自动释放,无需手动 unlock,天然免疫"忘了释放")。以及一个朴素的工程事实:ORM 对特殊 SQL 的支持要实测,$executeRaw 与 $queryRaw 的差异只有跑过才知道,跑过就写进注释。
源文件 /Users/zhangzhengyang/Desktop/publish/react-travel/lib/services/cart.ts(第 91-161 行,节选核心):
export async function payRows(userId: number, ids: number[]) {
return prisma.$transaction(async (tx) => {
const rows = await tx.orders.findMany({
where: { id: { in: ids }, userId }
});
if (rows.length === 0) throw new Error("订单不存在");
const unpaid = rows.filter((r) => r.isPayed !== 1);
if (unpaid.length === 0) {
// 幂等:已支付过,直接返回现有行
return tx.orders.findMany({
where: { id: { in: ids }, userId },
include: CART_INCLUDE
});
}
// 同一批次共用一个订单号;已有订单号的行保持原值。
// where 追加 isPayed: 0:并发支付时后到的事务不会覆盖先到事务写入的 orderNumber
const sharedNo = rows.find((r) => r.orderNumber)?.orderNumber ?? genOrderNo(String(unpaid[0].id));
const now = BigInt(Date.now());
await tx.orders.updateMany({
where: { id: { in: unpaid.map((r) => r.id) }, userId, isPayed: 0 },
data: { isPayed: 1, orderNumber: sharedNo, updateTime: now }
});
// 补齐/修正快照(针对加购时快照缺失的历史数据):
// 在事务内按 houseId 批量查询一次,避免 N+1,且保证快照读取与事务一致
const needSnapshot = unpaid.filter((r) => r.amount == null || !r.houseName);
if (needSnapshot.length > 0) {
const ids = [...new Set(needSnapshot.map((r) => r.houseId ?? 0))].filter(Boolean);
if (ids.length > 0) {
const houses = await tx.house.findMany({
where: { id: { in: ids } },
select: { id: true, price: true, name: true, imgs: { take: 1, select: { url: true } } }
});
for (const row of needSnapshot) {
const h = houses.find((x) => x.id === row.houseId);
if (h) {
await tx.orders.update({
where: { id: row.id },
data: { amount: h.price ?? 0, houseName: h.name, houseCover: h.imgs[0]?.url ?? null }
});
}
}
}
}
return tx.orders.findMany({
where: { id: { in: ids }, userId },
include: CART_INCLUDE
});
});
}逐段解读。五步各有明确的防御对象。第一步读取(where 带 userId):把"越权支付他人订单"折叠进"订单不存在"——调用方无从分辨,也不需要分辨(与 getOrderDetail 的 findFirst 同一思想,《08-安全与权限设计》§3.2)。第二步幂等过滤:全部已支付时直接返回现有行而非报错——双击支付、重试、回调重放都被这一步吸收成同一响应。第三步订单号生成:rows.find((r) => r.orderNumber)?.orderNumber ?? genOrderNo(...) 的优先级是"部分行已有号则沿用旧号",保证重试场景不刷新订单号;genOrderNo(lib/order.ts)用"时间戳 + randomBytes(4) hex"消除同毫秒碰撞——注释记载了演进史,原"id + Date.now()"方案在批量支付共享前缀时会重复。第四步是并发正确性的承重墙:updateMany 的 where 追加 isPayed: 0,两个并发支付事务中,后到者的 update 影响零行、不覆盖先到者写入的 orderNumber(先到者已把这些行置为 1,后到者的 where 不再命中)。第五步快照补齐:[...new Set(...)] 去重 houseId 后一次 findMany 批量取房源,循环内逐行 update——防 N+1 的同时保证快照读取与状态翻转在同一事务内一致。边界处理:r.houseId ?? 0 再 filter(Boolean) 把 houseId 为 null 的脏行安全地排除;h 查不到(房源已物理删除)时跳过补齐、不阻断支付主流程。最终 return 回读全部 id 的最新行(含已支付状态),供 VM 层组装 Completed 结账单。
这段代码教会我们什么:并发安全的更新模式可以总结为一句——把前置状态写进 where。不用悲观锁、不用版本号乐观锁,仅仅让 update 只在"我看到的那个前置状态"下生效,后到者自然失效。它同时示范了幂等设计的完整谱系:读不到 → 业务 404;全已支付 → 返回现状;部分已支付 → 只处理 unpaid 子集;三种入口(重试/双击/并发)全部收敛到同一个出口。另外"修复历史数据顺带做进主流程"(快照补齐)是务实的自愈设计——只要防御写对,数据修复放在事务里不增加风险。
源文件 /Users/zhangzhengyang/Desktop/publish/react-travel/middleware.ts,全文:
import { NextRequest, NextResponse } from "next/server";
// 受保护的接口前缀(真正的 JWT 校验在各 Route Handler 的 requireAuth 中完成,
// 这里只做"是否携带凭证"的粗过滤,避免在 Edge Runtime 跑 jsonwebtoken)
const PROTECTED_API = [
"/api/user/detail",
"/api/user/edit",
"/api/orders",
"/api/shoppingCart",
// 写操作接口(GET 列表/详情保持公开):
"/api/comments", // POST 新增评论
"/api/commentLikes", // POST/DELETE 点赞
"/api/favorites", // POST/DELETE 收藏
"/api/viewHistory", // POST 浏览记录
];
// 前缀中需要放行的公开子路径(GET 列表类接口)
const PUBLIC_GET = [/^\/api\/comments\/[^/]+$/];
export function middleware(req: NextRequest) {
const { pathname } = req.nextUrl;
const needAuth =
PROTECTED_API.some((p) => pathname.startsWith(p)) &&
!(req.method === "GET" && PUBLIC_GET.some((re) => re.test(pathname)));
if (!needAuth) return NextResponse.next();
const hasBearer = (req.headers.get("authorization") || "")
.toLowerCase()
.startsWith("bearer ");
const hasCookie = !!req.cookies.get("token")?.value;
if (!hasBearer && !hasCookie) {
return NextResponse.json(
{ status: 401, errMsg: "未登录或 token 失效" },
{ status: 401 }
);
}
return NextResponse.next();
}
export const config = {
matcher: ["/api/:path*"]
};逐段解读。第一层结构是 PROTECTED_API 前缀数组 + PUBLIC_GET 正则的两个白名单:前者声明"谁需要凭证",后者在前者内部挖出"但 GET 可以公开"的例外。组合逻辑 needAuth 的布尔式读起来就是一个谓词:前缀命中且不是"公开 GET"。这个设计的边界意识体现在三处。其一,config.matcher = ["/api/:path*"] 让 HTML 页面请求根本不进 middleware——页面级守卫(AuthGuard)与 API 级守卫(middleware + requireAuth)职责互斥,一图两用会造成两套真相。其二,粗过滤的"粗"是能力边界决定的:Edge Runtime 无法运行依赖 Node crypto 的 jsonwebtoken,所以这里只看"有没有 Bearer 头或 token Cookie"——伪造的 token 能过这关,但会在 Handler 的 requireAuth 被验签拦下,防线纵深而不是重复。其三,/api/comments 前缀同时覆盖 POST(发评论,需拦)与 GET /api/comments/:id(评论列表,公开)两类请求,PUBLIC_GET 的正则 /^\/api\/comments\/[^/]+$/ 精确到"单段路径参数"——/api/comments/3 放行,而未来若出现 /api/comments/3/replies 这类多段路径则不在放行之列(会被拦,需凭证)。方法判定用 req.method === "GET" 精确匹配,HEAD 等其他方法不享受豁免。hasBearer 的 toLowerCase().startsWith("bearer ")(注意尾随空格)拒绝"Bearer"后不跟 token 的畸形头,与 lib/auth.ts#getTokenFromHeader 的 split 逻辑在宽容度上保持一致。文件头与数组内的注释把"为什么不在这里验签"这个问题在原地回答掉——后来者不会试图把 jsonwebtoken 搬进 Edge,也不会误以为这层 401 就是全部鉴权。
这段代码教会我们什么:中间件做粗过滤、Handler 做细校验的双层结构,本质是把能力边界(Edge 跑不了 Node crypto)转译成架构分层(便宜的检查前置、昂贵的检查后置)。配套的纪律是两个白名单必须随接口增删同步维护(《11-扩展开发指南》SOP 二把"新前缀加进 PROTECTED_API"列为第一步),否则会出现"新受保护接口被 middleware 放行、只靠 requireAuth 单层兜底"的隐性降级——安全上仍成立(requireAuth 一定会拦),但少了前置 401 的性能与日志语义。写注释解释"为什么不做"与"做了什么"同样重要:这个文件最有价值的两行代码是注释。
三个对象风格各异,防御思路却高度同构:verifyPassword 的时序对齐、payRows 的 isPayed: 0 条件更新、middleware 的"粗过滤+细校验",都在回答同一个问题——当攻击者或并发或故障让两条路径出现可观测差异时,如何抹平或利用这个差异。密码侧抹平耗时差,支付侧让迟到者写不进去,鉴权侧把检查拆成便宜与昂贵两段。另一个共同点是注释的信息密度:三处源码的注释都在回答"为什么不是显然的那个写法"(为什么用 DUMMY、为什么不用唯一约束、为什么不在 middleware 验签),这是注释的最高价值形态——它记录的是被否决的方案,而版本控制只能告诉你代码变了什么,不能告诉你曾经考虑过什么。对维护者的实操建议:改动这三个文件前,先假设每条注释都记载了一次真实的失败尝试(事实如此),"优化"任何一行前先找到那条注释所防御的具体场景是否已经消失;若找不到消失的证据,就把它当作仍然必要。