项目:react-travel | 版本:v1.2 | 生成日期:2026-10-06 | 依据:源码分析
这是全套 21 篇文档(00-20)的收官篇,承担两个职能:术语表统一全系列的词汇口径——同一概念在不同篇目中的别名(数据库列名、前端字段名、口头简称)在此建立映射,避免"House 是什么、touristRoute 是什么"这类名实错位造成的阅读障碍;文档索引提供两个方向的检索——"拿到一个源码文件该读哪几篇"的反向索引(§3),与"接到一个任务该按什么顺序读"的正向路径(§4)。术语条目按主题分组(领域模型 / 架构机制 / 安全 / 缓存与渲染),每条给出定义、源码锚点与相关篇目;反向索引覆盖 59 个高频触点文件;四条任务路径按"必读 → 按需展开 → 动手前的禁区核对"组织。
线路(touristRoute):站点的核心商品实体。数据库层叫 House(历史包袱:原 Egg 时代的"房源"表名),前端与接口层叫 touristRoute(imooc 原型的概念名),两者由 lib/vm.ts#houseToTouristRoute 翻译(name→title、info→description)。任何文档中"线路/房源/House/touristRoute"四词同指,见《04-数据模型》§1 的命名对应关系一节。卡片 VM 与完整 VM:同一条线路的两种序列化形状——houseToCardRoute 裁剪掉 features/fees/notes 三个富文本大字段供列表/RSC props 注入,houseToTouristRoute 输出完整形状供详情;两形状卡片字段一致可混用(《07-模块详解》§2.2"四副面孔")。
订单一体两面(isPayed):Orders 表同时承担购物车行(isPayed=0)与已支付订单(isPayed=1)两种角色,是本项目最核心的建模决策(ADR-08)。由 lib/services/cart.ts 与 lib/services/orders.ts 按 isPayed 分治查询,@@index([userId, isPayed]) 复合索引同时服务两个高频路径。购物车行即 isPayed=0 的 Orders 行——"没有购物车表的购物车",加购即插行、支付即翻转。复合 id:结账单 VM 的 id 字段,由若干订单行 id 用 - 连接(如 "3-5-7"),支付端点按 [-,] 拆回数组——它是"一批购物车行"的临时身份,不是数据库实体(《15》§7/§8 的结算与支付契约)。价格快照:Orders 行的 amount/houseName/houseCover 三字段,在下单/支付时点固化,防止房源事后改价污染历史订单;《04-数据模型》§6 称之为"订单正确性的底线"。订单号(orderNumber):lib/order.ts#genOrderNo 生成——首行 id 前缀 + 13 位毫秒时间戳 + 8 位随机 hex,同批支付行共享一个号;"已有订单号的行保持原值"是支付幂等的一部分。
目的地/专题/游记(Destination/Topic/Review):三张纯运营内容表,无接口层写端点,唯一写入渠道是种子脚本;与线路的关联经 TopicHouse 桥表(多对多)。slug:目的地与专题的唯一英文标识(如 sanya),同时是搜索路由的关键词载体——client/lib/slugMap.ts 维护 25 个 slug(7 个 tripType 分类 + 18 个目的地)与中文关键词的双向映射(group → 跟团游),导航分类、sitemap、搜索三处清单需同步维护(《11-扩展开发指南》SOP 六)。
兼容层(client/):原 CRA 的 src/ 整体平移形成的"可渲染 UI 组件库 + 状态仓",被 app/ 层页面以 props 注入方式消费;它禁止触碰数据库与 Prisma(《00-项目总览》§4 的边界约定)。垫片(shim):让旧代码在 App Router 下零改动运行的基础设施,共五处——路由 API 垫片 lib/router.tsx(useNavigate/useParams/useLocation/Link 的同名同签名实现)、静态资源 CRA 语义(next.config.js 的 disableStaticImages + webpack asset/resource + types/assets.d.ts)、路径 rewrite(/auth/:path* → /api/auth/:path*)、axios 拦截器(client/lib/http.ts)、水合基础设施(providers + hydrated 位);退役计划四轮见《13》§3。双链路:RSC 直取(服务端 await services 函数后 props 注入)与客户端回退(store 调 /api/* 端点)两条数据获取路径;铁律是两链路调用同一个 lib/services 函数,数据形状逐字节一致(ADR-07)。initialXxx 可选 props:双链路在视图组件层的落地形态——有注入直接渲染(首屏零请求),缺失走 store 回退;搜索页的判定表达式叫 injectedActive。
ISR(Incremental Static Regeneration,增量静态再生):Next 的页面级缓存机制,本项目分级使用——首页 revalidate = 60、目的地/专题/游记 3600、详情与搜索刻意不设(动态渲染);语义是 stale-while-revalidate:缓存过期后先返回旧 HTML、后台重建(ADR-06)。hydration(水合):浏览器把 SSR HTML 与 React 事件/状态接合的过程;本项目的风险点是 localStorage 只能在客户端读取,水合前 token 为 null。hydrated 位:手工水合协议的状态闸——useUserStore._hydrate() 与 hydrateOrderStore() 在 app/providers.tsx 的 useIsomorphicLayoutEffect 里执行后置位,AuthGuard/PlaceOrder/orderSuccess 三处判定必须等它(《01-系统架构》§2.4 的水合竞态)。序号守卫(seq):客户端丢弃迟到响应的模式——每次发起请求前 ++seq,响应落地时 seq !== 当前值 则丢弃;分布在 useProductDetailStore(detailSeq/commentsSeq)、useProductSearchStore(searchSeq)、useOrderStore(myOrdersSeq)、useHotKeywords(seqRef)四处(《09》§3 防御模式一)。乐观更新:UI 先行、请求跟进、失败回滚的交互模式(收藏 toggle、评论点赞);pending 字典合并同目标的并发请求。
React cache 共享:app/detail/[touristRouteId]/page.tsx 用 React 的 cache() 包装 getDetailVM,使 Page 与 generateMetadata 在同一请求内共享一次查询(浏览量只自增一次);反面教训写在 layout.tsx 注释——layout 与 page 不共享 cache,在 layout 查库会双查询。投影(select 投影):Prisma 查询只取所需列的常量(如 HOUSE_CARD_SELECT),刻意排除 BigInt 字段从源头免掉 serialize 转换(《10-性能设计与优化》§4)。serialize:lib/utils.ts 的递归转换——BigInt→Number、Date→毫秒,因为 Response.json 无法序列化 BigInt;走 json() 的响应自动经过它。
双层鉴权:middleware.ts(Edge Runtime)的凭证粗过滤(只看"有没有 Bearer 头或 token Cookie",不验签——Edge 跑不了 jsonwebtoken)+ Handler 内 requireAuth 的细校验(验签 + Redis 存在性比对)(ADR-05、《08》§3)。requireAuth 三件套:受保护 Handler 的标准开场——requireAuth → rateLimit → 参数钳制;返回 JwtPayload | Response 联合类型,auth instanceof Response 即 401 短路。两段式 401:验签失败"未登录或 token 失效"与 Redis 比对失败"登录已失效"是两条不同的文案,用于排障二分(《12-FAQ》坑 7)。
双轨哈希:lib/password.ts#verifyPassword 按存储哈希形态分流——$2 前缀走 bcrypt(新账户,12 轮),否则走 md5(password + SALT)(存量账户兼容);哈希形态自描述替代版本列(ADR-03)。DUMMY_BCRYPT_HASH:对固定明文预生成的真实 bcrypt 哈希,用户不存在时也执行一次同轮数比较,抹平"账号不存在"与"密码错误"的响应耗时差,防用户名时序枚举(《18-代码走读实录》§2)。fail-open:Redis 故障时 isTokenCached 返回 true 放行(只信 JWT 签名)——可用性优先的显式降级,代价是故障窗口内注销失效。互踢:Redis 以 username 为 key 单值存储 token,后登录者覆盖、先登录者失效。advisory lock(顾问锁):pg_advisory_xact_lock(userId) 把同一用户的"查重→插入"串行化,事务级锁随事务自动释放;因唯一约束会阻断"同一房源再次购买"而选择锁方案(ADR-09)。条件更新:payRows 的 updateMany where 追加 isPayed: 0——把前置状态写进 where,并发下后到者影响零行不覆盖先到者(《18》§3.2)。幂等布尔(C 族):收藏/点赞的响应形状 {favorited|liked: boolean},重复写(P2002)与重复删(P2025)都翻译成 200。P2002/P2025:Prisma 错误码——唯一约束冲突 / 期望的记录不存在;本项目的幂等语义建立在捕获这两个码之上。
限流固定窗口:lib/rate-limit.ts 的进程内 Map 实现——窗口内计数、超限 429 + Retry-After;WRITE_LIMIT 20 次/分钟(评论/点赞/收藏按 userId)、AUTH_LIMIT 8 次/分钟(登录按用户名+IP、注册按 IP);内存三层治理(MAX_KEYS 1 万 → 清过期桶 → 插入序淘汰)。XFF 信任链:clientIp 取 x-forwarded-for 首段,反向代理必须覆写该头否则可伪造绕过——限流与浏览量去重两个消费点同时受其约束(《08》§5.2)。stripHtml / sanitizeRichText:内容安全双轨——纯文本字段(评论/签名)剥全部标签并还原实体(防双重转义),富文本字段(features/fees/notes)白名单净化(丢弃全部属性与协议);选择标准是"渲染时转义的存纯文本,innerHTML 渲染的存净化后 HTML"。
standalone:next.config.js 的 output: "standalone" 产物形态——.next/standalone/ 含 server.js 与裁剪版 node_modules,可拷贝到任意机器运行;静态资源(public/ 与 .next/static)必须手工补拷(《14》§6 双形态分析)。rewrite:请求管道层的路径改写(在 middleware 之前执行),本项目唯一一条 /auth/:path* → /api/auth/:path* 服务旧前端路径。双门禁:pnpm exec tsc --noEmit 零错误 + pnpm build 全绿,迁移期确立的验收标准(《09》§2);注意 build 需连可用库,否则 ISR 页带空态上线。db push / seed:prisma db push 同步 schema 到库(无 migrations 历史,操作纪律三条见《06》§5.5);pnpm db:seed 第一步就是清空 11 张表,生产严禁。x-pagination:B 族列表接口的分页元数据响应头 {currentPage,pageSize,totalCount,totalPages},与 A 族"body 内 list/total"构成两套分页协议。
按层分组列出 59 个高频触点文件,"篇目"列按阅读价值排序(首篇为主读)。
| 文件 | 篇目 | 一句话角色 |
|---|---|---|
lib/auth.ts | 08→03→18 | JWT 签发校验 + Redis 缓存 + requireAuth |
lib/password.ts | 18→08→16 | bcrypt/md5 双轨 + DUMMY 时序对齐 |
lib/utils.ts | 03→09→14 | serialize/json/errMsg/apiError/parsePage/守卫 |
lib/vm.ts | 07→04→15 | House/Orders → imooc VM 适配层 |
lib/prisma.ts | 04→06→12 | PrismaClient 单例 + 适配器 + 日志分级 |
lib/sanitize.ts | 08→09 | stripHtml / sanitizeRichText |
lib/rate-limit.ts | 08→06→12 | 内存固定窗口限流 + clientIp |
lib/order.ts | 07→18 | genOrderNo 订单号生成 |
lib/router.tsx | 01→07→13 | react-router-dom 垫片(退役对象) |
lib/services/cart.ts | 07→05→18 | 购物车/加购锁/payRows 支付事务 |
lib/services/orders.ts | 07→15 | 订单 CRUD/列表/payOrder |
lib/services/house.ts | 07→10→15 | 投影/buildWhere/搜索/详情/浏览量 |
lib/services/comment.ts | 07→15 | 评论列表 VM/发评/点赞幂等 |
lib/services/user.ts | 07→15 | 注册登录查询/serializeUser |
lib/services/favorite.ts | 07→15 | 收藏 CRUD + ids 集合 |
lib/services/viewHistory.ts | 07→15 | 浏览史 upsert |
lib/services/productCollections.ts | 05→07 | 首页三集合单一数据源 |
lib/services/destination.ts topic.ts review.ts | 07→15 | 运营内容三表查询 |
lib/seo.ts | 02→14 | JSON-LD/SEO 常量/SITE_URL |
| 文件 | 篇目 | 一句话角色 |
|---|---|---|
app/layout.tsx | 02→13→14 | 根布局:AntdRegistry/metadata/JSON-LD/Umami |
app/providers.tsx | 01→02→05 | 主题/水合/拦截器/SessionLoader |
app/page.tsx | 05→10→11 | 首页 RSC(ISR 60s + 降级) |
app/detail/[touristRouteId]/page.tsx | 05→10→11 | 详情 RSC(cache 共享 + generateMetadata) |
app/search/[keywords]/page.tsx | 05→07 | 搜索 RSC 首屏注入 |
app/orderSuccess/page.tsx | 02→05 | 成功页(state 校验非 AuthGuard) |
app/_components/AuthGuard.tsx | 01→02 | 客户端登录守卫(hydrated 闸) |
middleware.ts | 18→08→03 | Edge 粗过滤(PROTECTED_API/PUBLIC_GET) |
next.config.js | 14→01→16 | standalone/图片语义/rewrite/webpack |
app/api/auth/login/route.ts | 18→05→15 | 登录(限流/双轨/签发) |
app/api/auth/register/route.ts | 15→08 | 注册(双保险重名/optionalStr) |
app/api/touristRoutes/route.ts | 03→15→07 | 搜索(x-pagination/排序白名单) |
app/api/shoppingCart/items/route.ts | 07→15 | 加购(白名单透传) |
app/api/shoppingCart/checkout/route.ts | 07→15 | 结算(纯视图转换) |
app/api/orders/[id]/placeOrder/route.ts | 05→15→18 | 支付(复合 id 拆分/404 语义) |
app/api/commentLikes/route.ts | 05→15 | 点赞幂等(P2002/P2025) |
app/api/commons/citys/route.ts | 12→14 | 外部转发(CITYS_API 应急) |
app/api/commons/hotKeywords/route.ts | 07→01 | 热词(直查 prisma 的历史例外) |
| 文件 | 篇目 | 一句话角色 |
|---|---|---|
client/stores/useUserStore.ts | 02→05 | 登录态/持久化/登出四件事 |
client/stores/useCartStore.ts | 02→05 | 购物车 + 401 联动登出 |
client/stores/useOrderStore.ts | 02→05 | 结算单持久化 + myOrdersSeq |
client/stores/useFavoriteStore.ts | 02→10 | 收藏全局单源 + 乐观 toggle |
client/stores/useProductSearchStore.ts | 07→05 | 搜索接管 + x-pagination 解析 |
client/lib/http.ts | 01→05 | axios 拦截器(installed 守卫) |
client/lib/slugMap.ts | 07→11 | slug 双向映射 + safeDecode |
client/hooks/useHotKeywords.ts | 05→10 | 联想防抖 + seqRef |
client/views/home/HomePage.tsx | 05→10 | initialCollections 直渲/回退 |
client/views/search/Searchpage.tsx | 07→02 | injectedActive 双数据源判定 |
client/views/detail/DetailPage.tsx | 02→05 | 画廊/预订卡/富文本渲染 |
client/components/paymentForm/PaymentForm.tsx | 02→16 | 演示卡表单(antd 替身) |
client/components/header/Header.tsx | 02→11 | 导航/搜索框/用户菜单 |
types/assets.d.ts | 01→13 | 图片模块类型声明 |
prisma/schema.prisma | 04→11 | 11 模型/索引/唯一约束 |
prisma/seed.ts + prisma/seed-data/* | 06→19 | 种子(清库警示/DEMO_PASSWORD) |
prisma.config.ts | 14 | Prisma 7 CLI 配置(dotenv) |
package.json / pnpm-workspace.yaml | 17→14 | 依赖清单/allowBuilds |
NEXT_MIGRATION_PLAN.md | 16→13 | 迁移计划(决策考古底稿) |
路径一:修一个支付/订单 bug。先读《18-代码走读实录》§3(payRows 逐行走读)建立并发模型,再读《05-核心流程》§5(交易闭环时序)确认 bug 处于哪一环(加购/结算/支付/成功页),然后按《07-模块详解》§5 的文件清单打开触点(lib/services/cart.ts → lib/services/orders.ts → app/api/orders/[id]/placeOrder/route.ts → client/stores/useOrderStore.ts)。改动涉及金额/订单号/状态字段时,核对《04-数据模型》§6 的"快照底线"三原则;涉及 404 语义时重读《15》§10 的域走读要点("订单不存在或已完成支付"是业务分支非故障)。动手前禁区:不要把复合 id 的 [-,] 拆分逻辑改为"按 - 单字符 split"以外的形状(兼容旧调用);不要去掉 updateMany where 里的 isPayed: 0。验收:跑《09》§4 项 6(交易闭环冒烟)。
路径二:加一个新页面。主读《11-扩展开发指南》SOP 一(六步全流程),配《00-项目总览》§4 的三层放置法则判断页面类型;若页面需要数据,SOP 五演示了"服务端注入 + 视图可选 props"的增量模式;若需要搜索/分类语义,SOP 六的 slugMap 三处同步清单必须走一遍。视图组件落在 client/views/<域>/(目录三件套:组件 + module.css + index.ts),数据访问只进 lib/services/(两处历史直查例外不要效仿)。验收按 SOP 一末尾的清单:tsc 零错误 → build 全绿(连库)→ View Source 见内容 → 拔库降级可用。
路径三:退役 client/ 兼容层。主读《13-技术债务与演进建议》§3 的四轮退役计划与消费矩阵,配《01-系统架构》§2.3(三根支柱的原理)与《07-模块详解》§8(垫片逐个签名说明)。执行顺序严格按轮次:死导出与 /auth rewrite → useNavigate/useParams/Link 机械替换 → useLocation 与垫片删除 → 图片语义迁移(与 next/image 合并做)。铁律两条:每轮可发布、一轮 PR 不跨两轮;垫片彻底退役前不删其任何导出(含看似无用的 BrowserRouter 占位)。每轮完成跑《09》§4 全量冒烟。
路径四:上线检查。按《06-部署运行》为主操作手册:§3 环境变量(8 个变量按读取位置核对,密钥守卫的"运行时才校验"陷阱必读)→ §4 构建与双形态选择(standalone 漏拷 public 是图片 404 高发坑)→ §6.2 反代要点(XFF 覆写,否则限流误伤与刷量同时发生)→ §7 十项冒烟。上线前另查三件事:《13》§2 的 D9(种子账号是否残留在目标库——8 个 123456 弱口令)、D2(Umami 统计域名是否指向你的环境)、以及 ISR 预热(发布后 curl 首页与三个内容页)。排障期的第一入口是《12-FAQ》§3 的决策树;数据契约类问题以《19》与《15》互查。全部篇目读完仍无解时,回到源码注释搜索——本项目的历史经验是:绝大多数"为什么"都已被作者写在离代码最近的地方。