项目:react-travel | 版本:v1.0 | 生成日期:2026-10-05 | 依据:源码分析
项目是 Next.js 15 App Router 全栈单体,运行形态只有一种——一个 Node 进程(内含 RSC 渲染与全部 Route Handler),外加 PostgreSQL 必选、Redis 可选。package.json#engines 显式声明了运行时下限:
| 组件 | 版本/要求 | 依据与说明 |
|---|---|---|
| Node.js | ≥ 20 | package.json#engines.node;运行 next dev、next build、next start、standalone 的 server.js,以及 tsx 执行的脚本 |
| pnpm | ≥ 9 | package.json#engines.pnpm;pnpm-workspace.yaml 通过 allowBuilds 放行 @prisma/engines、prisma、sharp、@parcel/watcher 的构建脚本(postinstall 的 prisma generate 依赖此放行) |
| PostgreSQL | 任意受支持版本,默认端口 5432 | Prisma 7 + @prisma/adapter-pg 驱动适配器直连(lib/prisma.ts);示例库名 travel(.env.example 连接串) |
| Redis | 可选,默认端口 6379 | 仅用于登录 token 缓存与服务端即时注销(lib/auth.ts);未配置时优雅降级为"只信 JWT 签名" |
| tsx | 无固定依赖 | prisma/seed.ts 与 scripts/* 均由 npx -y tsx / pnpm exec tsx 临时拉起执行,无需预装 |
| 外网可达性 | 可选 | 仅三处需要外网:/api/commons/citys 转发的外部城市接口(CITYS_API)、scripts/download-images.mts 下载 Unsplash 图片、app/layout.tsx 的 Umami 统计脚本(defer 加载,不可达不影响功能)。除此之外站点可完全内网运行 |
仓库中不存在 Dockerfile、docker-compose、CI/CD 配置(.github/)与 pm2/ecosystem 等进程管理配置 ⚠️ 待确认——部署编排需自行搭建,下文 §6 给出基于源码现状(next.config.js 的 output: "standalone" 注释"便于 NAS 容器直接运行")的推荐做法。
工具链安装说明:若本机 pnpm 版本低于 9,推荐经 Node 自带的 corepack 启用——corepack enable && corepack prepare pnpm@9 --activate,再以 node -v、pnpm -v 核对版本满足 engines 约束。表格中三类外网依赖的失败影响面值得逐一明确:CITYS_API 不可达只影响城市数据接口(GET /api/commons/citys 返回 500,其余功能无感知);download-images.mts 失败只影响种子图片落盘(仓库已随附 public/travel-img/,通常无需重跑);Umami 脚本不可达只损失访问统计。换言之,内网/离线环境可以完整运行站点,这是"外网可达性=可选"的实际含义。
从零到跑通共 6 步,全部命令均来自 package.json#scripts 与脚本文件头部注释:
# 0. 获取代码
git clone <repo-url> react-travel && cd react-travel
# 1. 安装依赖(postinstall 钩子自动执行 prisma generate,
# 产物输出到 generated/prisma/ —— 生成物,勿手改,tsconfig 已 exclude)
pnpm install
# 2. 准备环境变量
cp .env.example .env
# 开发环境最低要求:DATABASE_URL 指向可连接的 PostgreSQL;
# JWT_SECRET / SALT 留空时会回退到示例值 "xiaoye" 并打印警告(见 §3 守卫机制)
# 3. 创建数据库(连接串指向的库必须预先存在)
createdb travel
# 或:psql -U postgres -c 'CREATE DATABASE travel;'
# 4. 同步表结构(项目无 migrations 目录,建表走 db push)
pnpm prisma:push
# 5. 灌入种子数据(32 条线路 / 8 个 demo 用户 / 评论 / 目的地 / 专题 / 游记 / 订单)
pnpm db:seed # 实际执行:npx -y tsx prisma/seed.ts
# 6. 启动开发服务器
pnpm dev # http://localhost:3000启动后的验证清单(冒烟口径,与《00-项目总览》§5 一致):
curl http://localhost:3000/api/productCollections 返回三个推荐集合(爆款/新品/国内游)→ DB 链路正常;curl -X POST http://localhost:3000/auth/login -H 'Content-Type: application/json' -d '{"email":"旅行达人小王","password":"123456"}' 返回 token → 鉴权链路正常(种子账号密码统一为 123456,见 prisma/seed-data/users.ts#DEMO_PASSWORD,共"旅行达人小王""摄影师阿杰""亲子游妈妈"等 8 个);pnpm prisma:studio 打开数据可视化界面核查 11 张表;/、/destinations、/sitemap.xml、/robots.txt 均 200 → RSC/ISR 与 SEO 基础设施正常。两点补充:其一,public/travel-img/(约 80 张 webp 线路图)随仓库分发,新 clone 即可用;仅当修改了 prisma/seed-data/*.ts 中的图片引用或图片缺失时,才需要执行 npx tsx scripts/download-images.mts 重新下载(脚本内置断点续跑——已存在的文件跳过,120ms/张限速防 CDN 风控,该脚本没有对应的 npm script 封装,需手动执行)。其二,pnpm dev 下 Prisma 客户端开启 query/error/warn 三级日志(lib/prisma.ts 按 NODE_ENV === "development" 判定),控制台能看到每条 SQL,排查数据问题很方便。
下表按源码实际读取位置整理(而非仅凭 .env.example),是配置环境的唯一权威依据:
| 变量 | 读取位置 | 必填性 | 默认值/示例 | 生产要求与说明 |
|---|---|---|---|---|
DATABASE_URL | lib/prisma.ts(运行时连接)、prisma.config.ts(Prisma CLI,经 dotenv/config 加载 .env)、prisma/seed.ts、scripts/migrate-mysql-to-pg.ts | 必填 | postgresql://postgres:postgres@localhost:5432/travel?schema=public | 指向的库必须预先存在;prisma db push 与 seed 均通过它连库 |
JWT_SECRET | lib/auth.ts(模块加载时判定) | 生产必填 | 缺失或 xiaoye:开发回退 + console.warn | 必须 openssl rand -hex 32;生产运行时缺失或仍为示例值 xiaoye 直接 throw 拒绝启动 |
SALT | lib/utils.ts(md5Password 用) | 生产必填 | 缺失或 xiaoye:开发回退 + console.warn | 必须 openssl rand -hex 16;仅影响存量 md5(password+SALT) 账户的登录校验,配置错误意味着所有老账户无法登录,故宁可启动失败及早暴露 |
JWT_EXPIRES_IN | lib/auth.ts | 可选 | "7d" | 同时决定 Redis token 缓存的 TTL(parseExpiresToSeconds 支持 s/m/h/d/w/y 单位;无法解析时回落 7 天) |
REDIS_URL | lib/auth.ts#getRedis | 可选 | 未配置 = 跳过 token 存在性校验 | 生产建议配置以支持"服务端即时注销";ioredis 以 maxRetriesPerRequest: 1, connectTimeout: 5000 创建,宕机时 fail-open(只信 JWT 签名并 console.error),不会挂起请求 |
CITYS_API | app/api/commons/citys/route.ts | 可选 | 内置慕课接口地址(含硬编码 icode) | 外部城市接口凭证失效时的应急切换开关;fetch 以 next: { revalidate: 86400 } 缓存 1 天,接口不可达返回 500"获取城市数据失败" |
NEXT_PUBLIC_SITE_URL | lib/seo.ts | 可选 | https://travel.zhangzhengyang.com | 站点发布域名,决定 metadataBase、sitemap/robots 绝对链接、JSON-LD 与 OG 图地址;未列入 .env.example;注意 NEXT_PUBLIC_ 前缀变量在构建期内联,改域名必须重新 pnpm build |
MYSQL_URL | scripts/migrate-mysql-to-pg.ts | 仅迁移脚本需要 | 无 | 指向旧 Egg 的 MySQL egg_house 库;脚本依赖 mysql2,需先 pnpm add -D mysql2(该包不在 dependencies 中) |
生产密钥守卫机制(部署最容易踩的点,两处实现完全对称):lib/auth.ts 与 lib/utils.ts 各有一段模块加载期守卫——判定条件是 NODE_ENV === "production" && NEXT_PHASE !== "phase-production-build",即 next build 阶段不校验(构建机无需配置密钥也能出包),next start / standalone server.js 运行时严格校验(缺 JWT_SECRET 抛 [auth] 生产环境必须通过环境变量 JWT_SECRET 设置强随机密钥…,缺 SALT 抛 [utils] 生产环境必须通过环境变量 SALT 设置强随机盐…,进程立即退出)。因此密钥必须注入到运行环境:既可放运行目录的 .env(Next 运行时会加载),也可用进程环境变量(PORT=3000 JWT_SECRET=xxx node server.js 的形式)。
配置文件与密钥安全:仓库 .gitignore 已忽略 .env 与 .env*.local(含 .env.production.local 等组合),密钥不会随代码入库;环境文件的加载优先级遵循 Next.js 通用机制(.env.production.local > .env.local > .env.production > .env,此为框架行为而非本项目特有配置),运维排障时可按此顺序排查"配了却不生效"的问题。两点提醒:其一,DATABASE_URL 在 CLI 场景(db push/seed/migrate 脚本)由 prisma.config.ts 顶部 import "dotenv/config" 显式加载,因此在项目根目录执行这些命令是前提;其二,NEXT_PUBLIC_SITE_URL 属构建期内联变量(见上表),与运行时读取的其余变量构成两类生命周期,改动前者必须重跑 pnpm build,后者仅需重启进程。
pnpm build # 实际执行 next buildnext build 内含 TypeScript 全量类型检查(tsconfig.json 的 strict: true,include 覆盖 app/、lib/、client/、prisma/、scripts/,generated/ 与 node_modules 已 exclude)、代码编译与页面预渲染:首页(ISR 60s)与目的地/专题/游记(ISR 3600s)在构建时生成初始 HTML;详情与搜索页保持动态渲染。值得注意:各内容页的 RSC 直取都有 try/catch 降级(返回空 props 由客户端回退),因此构建时数据库不可用通常不会导致 build 失败,但产物首屏会是空态——规范做法仍是让构建环境连上可用库(至少配置正确的 DATABASE_URL),保证 ISR 页首屏内容随包发布。
# 形态 A:标准模式(读取完整 .next 目录,适合常规服务器)
pnpm start # 实际执行 next start,默认监听 3000
# 形态 B:standalone 独立部署(next.config.js output: "standalone")
pnpm build
# 产物 .next/standalone/:含 server.js 与按需裁剪的最小 node_modules
# Next standalone 产物默认不含静态资源,需手工补齐(Next 官方部署要求):
cp -r public .next/standalone/public
cp -r .next/static .next/standalone/.next/static
# 然后以 Node 直接运行(注释原话:"便于 NAS 容器直接运行"):
node .next/standalone/server.js # PORT / HOSTNAME 环境变量可覆盖默认监听两种形态启动时都会触发 §3 所述的密钥守卫;standalone 的 server.js 同样会加载运行目录的 .env。public/travel-img/ 的约 80 张线路图属于 public/ 的一部分,务必随形态 B 一起复制——遗漏的典型症状是详情页图片全部 404 但页面文本正常。
ISR 缓存(首页 60s、内容页 3600s)存于进程与文件系统,重启后清空,首个访问会触发后台重建(期间返回构建期版本或旧 HTML)。依赖 ISR"预热"的页面在发布后可主动 curl 一遍首页与三个内容页,避免首批用户命中空缓存。
| 命令 | 实际执行 | 用途 | 风险等级 |
|---|---|---|---|
pnpm prisma:push | prisma db push | 将 prisma/schema.prisma 同步到库结构 | 中:无迁移历史可回溯,破坏性变更(删列/改类型)时 prisma 会提示确认 |
pnpm prisma:generate | prisma generate | 重新生成 generated/prisma/(postinstall 也会自动跑) | 无 |
pnpm prisma:studio | prisma studio | 数据库可视化 CRUD | 低(生产环境勿对外暴露 5555 端口) |
pnpm db:seed | npx -y tsx prisma/seed.ts | 灌入演示数据 | 极高:第一件事就是清空全部 11 张表 |
prisma/seed.ts 的 main() 第一步就按外键依赖序对全部 11 个模型执行 deleteMany,顺序为:commentLike → comment → review → topicHouse → topic → destination → favorite → viewHistory → orders → imgs → house → user——覆盖用户、线路、图库、评论、订单(含已支付的历史订单)、收藏、浏览历史在内的一切业务数据,随后才灌入 32 条线路与 8 个 demo 用户。三个必须写进运维手册的事实:
seed.ts 不检查 NODE_ENV、不校验连接串是否指向生产库、无交互式确认,pnpm db:seed 一旦在生产机上执行即不可逆清库(项目无软删除字段、无归档机制,见《04-数据模型》§4/§6)。防线只有运维纪律。db:seed 的执行入口;生产库账号按最小权限回收 DELETE 权限或至少对 database 维度做网络隔离;上线前用 pg_dump 做基线备份并演练恢复。hashPassword(bcrypt 12 轮,与 lib/password.ts 校验逻辑一致)创建 8 个密码为 123456 的 demo 账号(prisma/seed-data/users.ts#DEMO_PASSWORD)。若生产库曾误灌种子,必须删除这 8 个账号——它们是公开文档知晓的弱口令后门。scripts/migrate-mysql-to-pg.ts 用于把旧 Egg 时代的 MySQL egg_house 库迁入 PostgreSQL,要点:需 pnpm add -D mysql2 并设置 MYSQL_URL;按 User → House → Imgs → Comment → Orders 依赖序迁移并保留原主键 id(外键关系不变);幂等——目标表非空时默认拒绝执行,--force 参数会清空这 5 张表重灌;迁移末尾用 setval(pg_get_serial_sequence(...)) 重置 PG 自增序列(否则后续插入主键冲突)。该项目上线后此脚本已完成使命,留存于仓库仅供追溯,不要在生产库上重跑。
scripts/download-images.mts:从 prisma/seed-data/*.ts 正则提取 Unsplash photo id(photo-<id> 与 U("<id>") 两种写法),统一按 w=800&q=80&fm=webp 下载到 public/travel-img/<id>.webp;已存在即跳过(断点续跑),每张间隔 120ms 防风控。仅本地/重建种子数据时需要。
项目建表只走 prisma db push(prisma/ 下不存在 migrations/ 目录),这是 Prisma 两种 schema 同步方式中偏"快速迭代"的一种:直接把 schema 文件 diff 到库结构,不生成带时间戳的迁移脚本,也没有可回放的升级历史。对个人项目/演示项目这是合理取舍,但对维护者意味着三条操作纪律:其一,任何 schema 变更(加表/加列/改类型)先在本地或 staging 库演练 pnpm prisma:push,确认 prisma 给出的变更摘要无意外(尤其是删列、改类型这类会提示数据丢失的破坏性操作)再上生产;其二,生产执行 push 前做一次 pg_dump 快照,push 失败或结果不符预期可立即恢复;其三,schema 文件 prisma/schema.prisma 与 generated/prisma/ 产物必须保持同步——拉取代码或切换分支后如遇类型报错指向 generated/prisma,先跑 pnpm prisma:generate 再排查业务代码。若后续团队化需要迁移审计,可评估引入 Prisma 的 migrate 工作流并以 migrate diff 生成基线(当前仓库未包含任何 migrate 相关配置,此为演进建议而非现状)。
X-Forwarded-For 为真实客户端链路:lib/rate-limit.ts#clientIp 取该头第一段作为限流维度(回退 x-real-ip、cf-connecting-ip),源码注释明确警告"反向代理(nginx/CDN)必须覆写 x-forwarded-for,否则客户端可自带伪造头绕过 IP 维度限流"。同理,详情页浏览量去重(lib/services/house.ts#getHouseDetail 的 viewerIp 取 x-forwarded-for 首段)也依赖该头真实,否则 showCount 可被刷。/travel-img/* 静态资源长缓存、按需调整 client_max_body_size(站点接口均为小 JSON body,默认值即可)。当前实现按单实例设计,扩到多实例前必须改造三处:
lib/rate-limit.ts 的固定窗口限流是进程内存 Map(桶上限 1 万,超限先清过期桶再按插入序淘汰),注释原话"内存实现,单实例够用;多实例部署需换 Redis";lib/services/house.ts 的浏览量去重 bumpKeys Map 同为进程内存(上限 5 万条,重启清零可接受)——多实例下去重失效,showCount 会被重复计数;REDIS_URL,否则 A 实例注销的 token 在 B 实例仍有效(isTokenCached 各查各的库)。除上述三点外,应用本身无状态:会话是自包含 JWT,页面数据即取即用,standalone 产物可任意复制启停——这也是 next.config.js 选择 output: "standalone" 的动机。
无 REDIS_URL 的最小部署完全可用:登录态仅依赖 JWT 过期(默认 7d),代价是无法服务端注销——用户"退出登录"只清浏览器本地态,旧 token 在有效期内仍可直调 API。配置 Redis 后,/api/auth/logout 的 clearToken 删 key 即刻生效;且注意 Redis 以 username 为 key 单值覆盖,同一账号后登录者会使先登录者失效(互踢语义,多设备共存需改存储结构)。Redis 故障时 fail-open,只信 JWT 签名,不影响可用性。
app/layout.tsx 以 defer 方式引用硬编码的 Umami 脚本(https://analytics.zhangzhengyang.com/script.js,含 data-website-id)。自托管/内网部署时该统计不可用,但脚本不阻塞页面渲染,站点功能无感知;如需自有统计,改此处 script 标签指向自建 Umami 即可(⚠️ 待确认:data-website-id 与目标站点绑定,自建后需重新注册站点获取新 id)。
项目未配置任何自动化测试框架:package.json 无 test 脚本,devDependencies 中没有 Jest/Vitest/Playwright 等依赖。当前的质量门禁由两道静态检查构成(也是 NEXT_MIGRATION_PLAN.md 各 Phase 的验收标准):
pnpm exec tsc --noEmit # 门禁一:TypeScript 严格模式全量类型检查,零错误
pnpm build # 门禁二:Next 构建全绿(内含类型检查 + 全部页面编译/预渲染)以静态检查充当门禁对本项目是性价比合理的策略:strict: true 配合 Prisma 生成类型与 Zustand store 的接口定义,能拦住绝大多数"改了 VM 字段忘改视图"这类跨层回归;而运行时行为层面,项目的防御式设计(RSC 全部 try/catch 降级、接口异常统一脱敏 500)意味着多数故障不会让构建失败,只会让页面静默降级为空态——这反过来说明手工冒烟不可省略,且应在 pnpm build && pnpm start 的生产形态下执行一遍,而非只看 pnpm dev:开发与生产在 ISR 缓存、antd 样式抽取(AntdRegistry 服务端抽取只在 SSR 生效)、错误边界行为上均有差异(历史上 react-payment-inputs 的 CSS 就只在生产构建下出错,最终被 antd 表单替身取代,见《02-页面设计》§3.4)。
发布前的建议冒烟清单(手工,覆盖五条核心流程,与《05-核心流程》一一对应):
| # | 检查项 | 通过标准 |
|---|---|---|
| 1 | GET /api/productCollections | 200,三个集合各 9 条 |
| 2 | 种子账号登录(旅行达人小王 / 123456) | 返回 token,Header 出现用户菜单 |
| 3 | 首页 / 目的地 / 专题 / 游记 | SSR 内容直出,View Source 可见线路标题 |
| 4 | 搜索 slug 与中文词(/search/group、/search/丽江) | 列表正确,排序/筛选/翻页正常 |
| 5 | 详情页 | 浏览量自增、评论区加载、收藏红心可切换 |
| 6 | 加购 → 结算 → 支付 → /orderSuccess | 全链路成功,订单号生成,/orders 可见已支付订单 |
| 7 | 评论发布与点赞/取消 | 刷新后数据持久,重复点赞不报错 |
| 8 | 退出登录后再访问 /shoppingCart | 被重定向到 /signin |
| 9 | /sitemap.xml、/robots.txt | 200 且含全部内容页 URL |
| 10 | GET /api/commons/citys | 200(外网可达时)或明确 500(应急切换 CITYS_API) |
数据核查用 pnpm prisma:studio;排查运行期错误看服务端日志(未捕获 API 异常统一以 [api:500] 前缀留痕,lib/utils.ts#apiError;生产 Prisma 仅输出 error 级日志)。
| 症状 | 原因与处置 |
|---|---|
生产启动立即抛 [auth] 生产环境必须通过环境变量 JWT_SECRET… | 运行环境未注入强密钥(构建机配了不算,运行机必须配),openssl rand -hex 32 后注入 |
生产启动立即抛 [utils] 生产环境必须通过环境变量 SALT… | 同上,openssl rand -hex 16;缺失会导致存量 md5 账户全员无法登录 |
| 登录成功后很快提示"登录已失效" | 配置了 Redis:同用户名在别处再次登录,旧 token 被单值覆盖(互踢语义,见 §6.4) |
| 接口返回 401"未登录或 token 失效"但用户已登录 | 请求未携带凭证:直连接口未走 axios 拦截器,或 localStorage travel_user 已失效 |
/api/commons/citys 返回 500"获取城市数据失败" | 外部慕课接口不可达/icode 失效,用 CITYS_API 环境变量应急切换 |
| 详情页图片全部 404 | standalone 部署漏拷 public/(见 §4.2),或 public/travel-img/ 图片缺失需跑 download 脚本 |
| 支付返回 404"订单不存在或已完成支付" | 结算页停留期间订单已被支付/购物车已清空——正常业务分支,非故障(app/api/orders/[id]/placeOrder/route.ts 注释) |
手动输入 /orderSuccess 显示空或跳回下单页 | localStorage 无 Completed 状态结算单,属状态校验防假成功页的设计行为 |
| 限流误伤(429 频繁) | 反向代理未覆写 X-Forwarded-For,所有用户被合并为同一 IP 维度(见 §6.2) |
pnpm install 后报 generated/prisma 找不到 | postinstall 的 prisma generate 未执行成功:确认 pnpm ≥ 9 且构建脚本放行生效(pnpm-workspace.yaml#allowBuilds),必要时手动 pnpm prisma:generate |