用一个周末从零搭出 PromptHub:把登录、数据库、文件上传、部署和自有域名串成一套可复用的生产级全栈建站路径。
这篇教程会带你完整走一遍:用 GitHub + Google + Cloudflare + Vercel 这套主流平台,从一行代码都没有,到上线一个跑在自有域名上的真实复杂网站——具备用户登录(第三方 OAuth)、数据库持久化、文件上传与安全预览这三大高级能力。
读者定位:有基本编程基础(写过一点 JavaScript/任意后端、用过命令行、知道 Git 大概是干嘛的),但没独立上线过一个带登录和数据库的真实网站。我会尽量把命令和控制台路径写细,让你照着做也能跑通。
我自己用一个周末 vibe coding,从零搭了一个 Prompt 托管分享平台 PromptHub,也完整走完了文中的搭建和部署流程,最后买域名上线:
👉 PromptHub 网站链接:https://www.awesome-prompt.com
目录
- 第 0 章 · 先看你将得到什么
- 第 1 章 · 架构总览(先建立全局观)
- 第 2 章 · 技术选型:为什么是这一套
- 第 3 章 · 准备工作:账号与本地环境
- 第 4 章 · 实战 A:把项目骨架在本地跑起来
- 第 5 章 · 实战 B:用户鉴权(第三方 OAuth 登录)
- 第 6 章 · 实战 C:文件上传与安全预览
- 第 7 章 · 实战 D:部署上线(Vercel + 域名)
- 第 8 章 · 安全基线清单(务必逐条对照)
- 第 9 章 · 上线验收 + 排错速查(真实踩坑)
- 第 10 章 · 之后呢:CI、测试、监控与成本
- 附录 A · 环境变量速查表
- 附录 B · 命令速查表
- 附录 C · 平台与免费额度一览
第 0 章 · 先看你将得到什么
跟完这篇,你会得到一个真的能访问、能给别人用的网站,同时熟悉下面这些能力:
| 能力 | 具体表现 | 涉及平台 |
|---|---|---|
| 第三方登录 | 用户点「用 Google / GitHub 登录」,几秒进入,无需记密码 | Google Cloud、GitHub |
| 数据持久化 | 用户创建的内容存进云数据库,刷新、换设备都还在 | Neon(云 Postgres) |
| 文件上传 | 用户上传图片/文件,安全存到对象存储,能预览能下载 | Cloudflare R2 |
| 一键部署 | 推代码到 GitHub,网站自动重新构建上线 | Vercel |
| 自有域名 + HTTPS | https://你的域名 直接访问,证书自动 |
Cloudflare、Vercel |
| 定时任务 | 每天自动跑一次后台维护任务 | Vercel Cron |
预计投入:纯跟做(已有代码骨架)约 3-5 小时;从零理解每一步、自己写代码,1-2 个周末。
花费:除域名(约 ¥70–100/年)外,本教程用到的所有平台都有够个人项目用的免费额度,可以 0 元跑起来(详见附录 C)。
第 1 章 · 架构总览(先建立全局观)
动手前先花 10 分钟看清“水是怎么流的”。后面每一步,你都会知道自己正在补哪一块。
1.1 一张图看懂整体
┌─────────────────────────────────────────────┐
│ 用户浏览器 │
└───────────────┬─────────────────────────────┘
│ HTTPS(自有域名)
▼
┌───────────────────────────────────────────────────────────────────────┐
│ Vercel(托管 + CDN) │
│ ┌────────────────────── Next.js 应用 ───────────────────────────┐ │
│ │ • 服务端渲染页面(RSC/SSR) • 边缘中间件(鉴权拦截) │ │
│ │ • Server Actions(写操作) • API Route(OAuth回调/签名URL等) │ │
│ └───────┬───────────────────────────┬───────────────────┬──────────┘ │
└───────────┼───────────────────────────┼───────────────────┼──────────────┘
│ SQL(Prisma) │ OAuth 跳转 │ S3 兼容 API
▼ ▼ ▼
┌────────────────────┐ ┌──────────────────────────┐ ┌────────────────────┐
│ Neon(云 Postgres)│ │ Google / GitHub(身份方) │ │ Cloudflare R2 │
│ 用户、内容、关系 │ │ 确认"你是谁" │ │ 用户上传的图片/文件 │
└────────────────────┘ └──────────────────────────┘ └─────────┬──────────┘
│ 公开读
▼
┌────────────────────────────┐
│ 独立内容子域 usercontent.* │
│ (隔离主站 Cookie,防越权) │
└────────────────────────────┘
1.2 三条核心数据流
- 读(浏览内容):浏览器请求页面 → Vercel 上的 Next.js 在服务端查 Neon 数据库 → 渲染好 HTML 返回。私有内容会先校验"你是不是 owner",无权一律当作"不存在"(返回 404)。
- 写(创建/编辑):用户在页面提交 → 触发 Server Action → 依次做「确认已登录 → 校验输入合法 → 确认有权限」三道关卡 → 才写进数据库。
- 传(上传文件):浏览器先向服务端要一个有时效的签名链接 → 浏览器直接把文件 PUT 到 R2(不经过你的服务器,省带宽)→ 服务端回拉文件头做真伪校验、给图片重新编码去掉隐私元数据 → 记一条数据库记录。
1.3 分层架构:依赖只能从外向内
复杂网站最怕“面条代码”。我们用清晰的分层,依赖方向单向,越往里越纯粹、越好测试:
页面/组件 (app/)
│ 只管展示 + 触发动作
▼
Server Actions (server/actions/) ← 薄!只做"鉴权 + 校验 + 调下层"
│
▼
业务服务 (server/services/) ← 真正的业务逻辑,不依赖框架,可脱离 HTTP 单测
│
▼
数据仓储 (server/repositories/) ← 所有数据库读写收口于此
│
▼
数据库客户端 (lib/db) → Neon Postgres
一条很值得提前立下的规矩:所有"读取受保护资源"的入口,都收敛到唯一一个函数(例如
getAccessibleResource(id, viewerId)),由它统一判断“公开任何人可读 / 私有仅本人可读”。业务代码禁止绕过它直接查库。否则随着功能变多,迟早有某个地方忘了判权限 → 数据泄露。
1.4 安全边界(贯穿全程,不是事后补)
| 边界 | 做法 | 防的是什么 |
|---|---|---|
| 私有资源 | 无权访问一律返回 404(而非 403) | 不泄露"这个资源存在" |
| 用户产物 | 放在独立子域,该域不带主站登录 Cookie | 上传的 HTML/脚本偷不到你的登录态 |
| HTML 预览 | 放进 sandbox 沙箱 iframe + 严格 CSP |
用户上传的网页不能乱发请求/窃取数据 |
| 一切外部输入 | 进系统先过 Zod 校验,不合法立即拒绝 | 脏数据、注入 |
| 文件上传 | 校验真实文件头(不信扩展名)+ 类型/大小白名单 + 图片去元数据 | 伪装文件、EXIF 泄露位置 |
| 密钥 | 全部走环境变量/密钥管理,绝不写进代码 | 凭据泄露 |
先把这张表放在脑子里,第 6、8 章会逐条落地。
第 2 章 · 技术选型:为什么是这一套
本章是“前期技术选型”。每一层我都会给出我们的选择、主流备选、为什么这么选、优缺点。就算你最后选了别的技术栈,理解这些取舍也很有用。
先给结论总表,再逐层展开:
| 层 | 我们的选择 | 主流备选 | 一句话理由 |
|---|---|---|---|
| 前端框架 | Next.js(App Router) | Remix、SvelteKit、Nuxt、Vite+Express | 一套代码同时搞定前后端,部署生态最成熟 |
| 语言 | TypeScript | JavaScript | 类型就是文档,重构更踏实 |
| UI 渲染 | React 19 | Vue、Svelte | 生态与招聘面最大 |
| 样式 | Tailwind CSS | CSS Modules、styled-components | 写得快、风格统一、产物小 |
| 数据库 | PostgreSQL | MySQL、MongoDB、SQLite | 功能强、关系型最稳、全托管选项多 |
| ORM | Prisma | Drizzle、TypeORM、Kysely | 类型安全 + 迁移工具一流,新手友好 |
| 数据库托管 | Neon | Supabase、PlanetScale、云厂商 RDS | Serverless 友好、有连接池、免费档够用 |
| 认证 | Auth.js(NextAuth) | Clerk、Auth0、Supabase Auth、自建 | 免费、开源、OAuth 接入快 |
| 对象存储 | Cloudflare R2 | AWS S3、Vercel Blob、Supabase Storage | S3 兼容 + 零出口流量费 |
| 部署平台 | Vercel | Netlify、Cloudflare Pages、自建 VPS | 与 Next.js 同源,体验最顺 |
| 域名/DNS | Cloudflare | 各类注册商、Route53 | 注册价近成本、DNS/CDN 一体 |
| 输入校验 | Zod | Yup、Valibot、手写 | 一份 schema 前后端复用 |
| 测试 | Vitest + Playwright | Jest + Cypress | 快、与 Vite 生态契合 |
2.1 前端框架:Next.js(App Router)
- 我们为什么选它:一个项目同时写前端页面和后端逻辑(Server Actions、API Route),不用单独搭一个后端服务;服务端渲染(SSR)对 SEO 友好;和 Vercel 配合最顺,部署基本不用额外配置。
- 备选:
- Remix:理念相近、Web 标准更纯,但生态与托管便利性稍逊。
- SvelteKit / Nuxt:分别对应 Svelte / Vue,体积小、好上手,但 React 的招聘面与第三方库最广。
- Vite + 独立后端(Express/Hono/FastAPI):前后端彻底分离,灵活但你要自己管两套部署、跨域、鉴权透传,新手负担大。
- 优点:全栈一体、SSR/SEO、生态巨大、部署省心。缺点:版本迭代快(写代码前务必看你用的版本的文档,API 可能和老教程不同)、App Router 心智模型(服务端组件 vs 客户端组件)有学习曲线。
2.2 语言:TypeScript
- 为什么:网站一旦有数据库模型、API 契约、组件 props,类型就是最好的文档和护栏。改一处、整条链路的类型错误立刻暴露,重构不再提心吊胆。
- 备选:纯 JavaScript 起步更快,但项目过千行后维护成本陡增。建议一开始就上 TS。
2.3 数据库:PostgreSQL + Prisma
- 为什么是关系型 + Postgres:用户、内容、点赞、关注……这些都是有关系的结构化数据,关系型数据库的事务、外键、约束能帮你守住数据一致性。Postgres 功能最全(JSON、全文检索、丰富索引),且全托管选项最多。
- MySQL:同为优秀关系库,差别不大,按托管商喜好选。
- MongoDB(文档型):schema 灵活,但关系/事务是它的弱项,"内容 + 用户 + 关系"这类强关联数据用它反而别扭。
- SQLite:零运维、适合极小项目或本地,但并发与托管不适合多人在线站。
- 为什么用 Prisma(ORM):用 TypeScript 描述数据模型 → 自动生成类型安全的查询 API + 迁移工具(schema 改动可版本化、可回放)。新手不用手写 SQL 也能起步。
- Drizzle:更轻、更贴近 SQL、运行时开销小,进阶后值得了解。
- TypeORM:老牌但类型体验不如前两者。
- 优缺点:Prisma 开发体验一流、迁移好用;代价是有一层抽象、极端性能场景需手写 SQL,且在 Serverless 上要注意连接管理(见下)。
2.4 数据库托管:Neon
- 为什么:Vercel 这类 Serverless 平台每个请求可能是一个新实例,传统数据库连接会被瞬间打爆。Neon 是 Serverless Postgres,自带 连接池(pooled)端点,可以缓解这个问题;闲时还能缩容省钱,免费档对个人项目通常够用。它可直接从 Vercel 的集成市场一键接入。
- 关键概念:你会拿到两条连接串——
- 池化(pooled)串:给应用运行时用(高并发短连接),变量名通常叫
DATABASE_URL。 - 直连(direct)串:给数据库迁移用(迁移需要稳定直连,不能走连接池),变量名通常叫
DIRECT_URL。 - 记住这个区分,第 7 章配置时不会懵。
- 池化(pooled)串:给应用运行时用(高并发短连接),变量名通常叫
- 备选:Supabase(Postgres + 自带 Auth/Storage,一站式,但你会更绑定它的生态)、PlanetScale(MySQL 系,分支特性强)、云厂商 RDS(最灵活但要自己运维、Serverless 友好度差)。
2.5 认证:Auth.js(NextAuth)+ 第三方 OAuth
- 为什么用第三方 OAuth(Google/GitHub 登录)而不是自建账号密码:你不用自己存密码,少背一大块安全责任;用户也不用再记一套新密码,注册阻力更小。对个人/中小项目这是性价比最高的方案。
- 为什么用 Auth.js:开源免费、专为 Next.js 设计、接几个 OAuth 提供商只要填几行配置 + 环境变量。
- 备选:
- Clerk / Auth0:托管式身份服务,UI 组件开箱即用、功能全(MFA、组织等),但免费额度有限、规模上来要付费,且更绑定其平台。
- Supabase Auth:如果数据库也用 Supabase,一体化体验好。
- 自建账号密码:你要自己处理哈希、找回密码、防撞库、邮件验证……新手极易踩坑,不推荐。
- 会话策略:本方案用无状态 JWT 会话(登录态加密存在 Cookie 里,服务端不存 session 表)。优点是简单、无需会话存储;要注意的坑:边缘中间件读不到数据库、刚改完用户资料时旧 Cookie 可能短暂滞后——第 5 章会讲怎么处理。
2.6 对象存储:Cloudflare R2
- 为什么:图片、视频、PDF 这类二进制大文件不该塞进数据库,要放对象存储。R2 兼容 S3 API(生态工具通用)、最吸引人的点是出口流量(下载)不收费——用户越多越省钱,这点完胜 AWS S3(S3 的出口费是很多项目的隐形大头)。
- 备选:
- AWS S3:业界标准、功能最全,但有出口费、配置略繁。
- Vercel Blob:和 Vercel 一体、最省心,但容量/流量计费、生态没 S3 通用。
- Supabase Storage:一体化方便。
- 关键架构点:我们用「客户端直传」——浏览器拿到服务端签发的临时签名 URL,直接把文件传到 R2,不经过你的服务器中转。这样省服务器带宽、上传更快,也避开了 Serverless 函数的请求体大小/时长限制。
2.7 部署平台:Vercel
- 为什么:Next.js 就是 Vercel 团队做的,部署体验最顺——连上 GitHub,推代码自动构建上线,预览部署、回滚、环境变量、定时任务(Cron)全都有,免费档够个人用。
- 备选:Netlify(同类、也很好)、Cloudflare Pages(和 R2/DNS 同生态,但对 Next 的某些特性支持节奏不同)、自建 VPS(最便宜最灵活,但 HTTPS、CI、扩容、运维全得自己来,新手不建议)。
2.8 域名与 DNS:Cloudflare
- 为什么:Cloudflare 域名注册接近成本价(不少注册商首年便宜、续费宰你),且 DNS + CDN + 安全一体,和我们用的 R2 同生态。
- 备选:任何域名注册商都行(GoDaddy、Namecheap、阿里云等),DNS 解析到 Vercel 即可。用 Cloudflare 的好处是后续接 R2 内容子域、CDN 都顺。
2.9 其它
- 样式 Tailwind CSS:用"原子类"直接在标签上写样式,开发快、全站风格统一、最终 CSS 体积小。备选 CSS Modules(更传统、样式与组件就近)。
- 输入校验 Zod:用一份 schema 同时做运行时校验和类型推导,前后端复用,系统边界统一把关。
- 测试 Vitest + Playwright:Vitest 跑单元/集成(快、与 Vite 同源),Playwright 跑端到端(真浏览器点击流程)。
选型小结:这套组合的精神是——全栈一体、类型安全、托管优先、免费起步、安全内建。它不是唯一正解,但对"个人/小团队要快速做出一个安全可上线的复杂网站"是当下性价比极高的一条路。
第 3 章 · 准备工作:账号与本地环境
3.1 注册这些账号(都先用免费档)
按这个顺序注册,后面环环相扣:
| # | 平台 | 网址 | 用途 | 小贴士 |
|---|---|---|---|---|
| 1 | GitHub | github.com/join | 存代码 + 触发自动部署 + 一个 OAuth 登录方 | 后面所有平台都能"用 GitHub 登录",先有它 |
| 2 | Google Cloud | console.cloud.google.com | 配 Google 登录(OAuth) | 用你的 Google 账号直接进,需要建一个"项目" |
| 3 | Cloudflare | dash.cloudflare.com/sign-up | 买域名 + DNS + 对象存储 R2 | 邮箱验证后即可用 |
| 4 | Vercel | vercel.com/signup | 托管部署网站 | 选「Continue with GitHub」,后续导入仓库免授权 |
| 5 | Neon | 通过 Vercel 集成市场接入(见 7 章) | 云 Postgres 数据库 | 不必单独注册,从 Vercel 一键创建最省事 |
安全提醒:给每个平台都开两步验证(2FA)。这些账号基本握着你网站的命门,被盗会非常麻烦。
3.2 本地开发环境
你的电脑需要装好:
| 工具 | 版本 | 检查命令 | 说明 |
|---|---|---|---|
| Node.js | ≥ 20(建议 22 LTS) | node -v |
JavaScript 运行时。去 nodejs.org 下 LTS 版 |
| Git | 任意较新版 | git --version |
版本控制。macOS 自带,Windows 去 git-scm.com |
| 包管理器 | npm(随 Node)/ pnpm | npm -v |
装依赖。本教程用 npm,命令通用 |
| 代码编辑器 | VS Code(推荐) | — | 装上 ESLint、Prettier、Prisma 插件 |
| Docker(可选) | 任意 | docker -v |
仅用于"本地数据库"。嫌麻烦可跳过,直接用云数据库开发 |
关于本地数据库:开发时你需要一个能读写的数据库。两条路:
- A. 本地 Docker 起一个 Postgres(离线、免费、改坏了随便重置)——适合频繁试错。
- B. 直接连云端 Neon 的一个"开发分支"——省去装 Docker,但要联网。
新手嫌 Docker 烦就走 B;想完全离线掌控走 A。本教程两者都会带一句。
3.3 拿到项目骨架
你有两种起点:
- 起点一(跟做现成骨架):如果你手上已有一个本教程对应的代码仓库(含
prisma/、lib/auth/、lib/storage/等结构),直接git clone下来。 - 起点二(从空白起):用脚手架新建一个 Next.js + TypeScript + Tailwind 项目:
然后按第 4–6 章逐步把数据库、认证、上传"长"上去。npx create-next-app@latest my-app --typescript --tailwind --app --eslint cd my-app git init && git add -A && git commit -m "chore: 初始化项目骨架"
把它推到一个新的 GitHub 仓库(Vercel 后面要从这里导入):
# 在 GitHub 网页上先建一个空仓库(不要勾 README),拿到它的地址,然后:
git remote add origin https://github.com/<你的用户名>/<你的仓库>.git
git branch -M main
git push -u origin main
第一条规矩:在项目根建一个
.gitignore,确保.env*被忽略(脚手架默认已加)。密钥、连接串这类东西永远不能进 Git。本教程后面所有"填密钥"的地方,都是填进本地.env文件或平台的环境变量面板,绝不写进代码。
第 4 章 · 实战 A:把项目骨架在本地跑起来
目标:本地能 npm run dev 打开页面,并且能读写一个真实数据库。
4.1 安装依赖、准备环境变量
npm install
# 若仓库带 .env.example:cp .env.example .env
# 若从空白起(没有该文件):手动在项目根新建 .env,先放下面这几项
.env 是你的本地密钥本,不入库。最小起步内容(其余用到再加):
# 本地数据库(下一节会起)。本地不用连接池,两条串相同即可
DATABASE_URL="postgresql://postgres:postgres@localhost:5432/myapp"
DIRECT_URL="postgresql://postgres:postgres@localhost:5432/myapp"
AUTH_SECRET="先随便填,5.2 会用 openssl 生成正式值"
关于
@/这个路径前缀:本教程代码里大量出现import ... from "@/lib/db"。@/是 TypeScript 的路径别名,默认映射到项目根(create-next-app已在tsconfig.json的compilerOptions.paths配好"@/*": ["./*"])。所以@/lib/db就是项目根下的lib/db。如果你 import 报"找不到模块",先去tsconfig.json确认这条别名存在。
4.2 接入数据库(Prisma + Postgres)
第 1 步:起一个本地数据库。
- 走 Docker(方式 A):一行命令起一个本地 Postgres(库名/账号/密码都用
postgres/myapp,对上 4.1 的.env):docker run -d --name myapp-db \ -e POSTGRES_PASSWORD=postgres -e POSTGRES_DB=myapp \ -p 5432:5432 postgres:16想用
docker compose,就在项目根建一个docker-compose.yml:services: db: image: postgres:16 environment: { POSTGRES_PASSWORD: postgres, POSTGRES_DB: myapp } ports: ["5432:5432"]然后
docker compose up -d。两种方式起的库都对应 4.1 里那两条localhost:5432/myapp连接串。 - 走云端开发分支(方式 B):从 Neon 控制台建一个开发用分支,把它的连接串填进上面两个变量。
第 2 步:用 Prisma 描述你的数据模型。 在 prisma/schema.prisma 里定义表结构,例如:
datasource db {
provider = "postgresql"
url = env("DATABASE_URL") // 运行时(池化)
directUrl = env("DIRECT_URL") // 迁移(直连)
}
generator client {
provider = "prisma-client-js"
}
model User {
id String @id @default(cuid())
email String @unique
name String?
// ... Auth.js 需要的字段(第 5 章补全)
createdAt DateTime @default(now())
contents Content[]
}
model Content {
id String @id @default(cuid())
title String
visibility Visibility @default(PUBLIC)
ownerId String
owner User @relation(fields: [ownerId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
}
enum Visibility {
PUBLIC
PRIVATE
}
第 3 步:建表(首次迁移)+ 生成类型安全的查询客户端。
npx prisma migrate dev --name init # 生成迁移文件并应用到本地库
npx prisma generate # 生成 Prisma Client(类型)
理解"迁移":每次你改
schema.prisma(加表/加字段),就prisma migrate dev生成一个迁移文件(一段 SQL)记录这次变更。这些文件进 Git,将来在生产库按顺序回放就能把生产表结构升到一致。这是团队协作和上线的关键——别手动改生产库的表。
第 4 步:数据库客户端单例。 在 lib/db.ts 里创建一个全局唯一的 Prisma 实例:
import { PrismaClient } from "@prisma/client";
const globalForPrisma = globalThis as unknown as { prisma?: PrismaClient };
export const prisma = globalForPrisma.prisma ?? new PrismaClient();
// 开发热重载下复用同一个实例,避免连接泄露
if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma;
为什么要单例:Serverless + 开发热重载下,每次都
new PrismaClient()会瞬间耗尽数据库连接数。用globalThis缓存保证整个进程只有一个实例。这是 Prisma 在 Serverless 上的头号坑,建议照做。
4.3 落地分层架构(写一点点代码体会"薄"与"收口")
按第 1.3 节的分层,写三层:
数据仓储(server/repositories/content.ts)——所有库读写收口于此,含唯一访问层:
import { prisma } from "@/lib/db";
// 唯一访问入口:公开任何人可读,私有仅 owner 可读,否则当作不存在
export async function getAccessibleContent(id: string, viewerId: string | null) {
const c = await prisma.content.findUnique({ where: { id } });
if (!c) return null;
if (c.visibility === "PUBLIC") return c;
if (viewerId && c.ownerId === viewerId) return c;
return null; // 私有且无权 → 当作不存在
}
// 写入也收口在仓储层(业务代码不直接 new/调 prisma.content.create)
export async function insertContent(data: { ownerId: string; title: string; visibility: "PUBLIC" | "PRIVATE" }) {
return prisma.content.create({ data });
}
业务服务(server/services/content.ts)——业务逻辑,不依赖 HTTP,好测试;数据库写入调仓储层,不裸调 prisma:
import { insertContent } from "@/server/repositories/content";
export async function createContent(ownerId: string, input: { title: string; visibility: "PUBLIC" | "PRIVATE" }) {
// 这里放业务逻辑(如配额校验、默认值),数据库写入收口到仓储层
return insertContent({ ownerId, ...input });
}
Server Action(server/actions/content.ts)——薄!只做"鉴权 + 校验 + 调 service":
"use server";
import { z } from "zod";
import { requireUser } from "@/lib/auth"; // 第 5 章实现
import { createContent } from "@/server/services/content";
const schema = z.object({ // 边界校验:不信任何外部输入
title: z.string().min(1).max(200),
visibility: z.enum(["PUBLIC", "PRIVATE"]),
});
export async function createContentAction(raw: unknown) {
const user = await requireUser(); // 1. 确认已登录
const parsed = schema.safeParse(raw); // 2. 校验输入
if (!parsed.success) return { error: "输入不合法" };
const c = await createContent(user.id, parsed.data); // 3. 调 service
return { ok: true, id: c.id };
}
体会三个习惯:① 写操作永远「登录 → 校验 → 权限 → 才落库」;② Action 只搬运、逻辑下沉 service;③ 读受保护资源永远走那唯一的
getAccessibleX。这三条习惯让网站随功能增长也不容易出安全洞。
跑起来看看:
npm run dev # 打开 http://localhost:3000
npx prisma studio # 顺手开个可视化界面看库里的数据
到这里,你已经有了一个能连真实数据库、分层清晰的本地应用。下一步给它加登录。
第 5 章 · 实战 B:用户鉴权(第三方 OAuth 登录)
目标:用户点「用 Google 登录 / 用 GitHub 登录」就能进站,登录态由加密 Cookie 维持。
5.1 OAuth 到底是怎么回事(30 秒原理)
用户点"用 Google 登录"
→ 你的网站把用户跳转到 Google
→ Google 让用户确认授权("某网站想获取你的基本信息")
→ Google 带着一个临时 code 跳回你网站的"回调地址"
→ 你的服务器拿 code 找 Google 换到用户信息(邮箱/头像/名字)
→ 你在自己数据库建/找到这个用户,发一个登录 Cookie
你要做的就两件事:① 在 Google/GitHub 那边登记你的网站,拿到一对 Client ID / Client Secret;② 在代码里配置 Auth.js 用这对凭据。回调地址必须两边一致,很多 OAuth 报错都卡在这里。
5.2 安装与配置 Auth.js
npm install next-auth@beta @auth/prisma-adapter
版本提醒:本章代码基于 Auth.js v5(
next-auth@5beta)。beta 版 API 可能微调,建议锁定一个具体版本号(npm ls next-auth看你装到的版本,必要时把它写死进package.json),避免日后装到不兼容的新 beta 后本章代码对不上。下面的handlers导出、middleware用法、session.strategy都是 v5 的写法。
生成会话加密密钥(这是 AUTH_SECRET,用来加密登录 Cookie):
openssl rand -base64 32 # 复制输出,填进 .env 的 AUTH_SECRET
配置文件分两半(重要的工程细节):一半是"边缘安全"的纯配置(中间件要用,不能碰数据库),另一半带数据库回调。
lib/auth.config.ts(不能 import 数据库,给边缘中间件用):
import GitHub from "next-auth/providers/github";
import Google from "next-auth/providers/google";
import type { NextAuthConfig } from "next-auth";
export const authConfig = {
providers: [GitHub, Google], // 凭据自动从 AUTH_GITHUB_* / AUTH_GOOGLE_* 环境变量读
pages: { signIn: "/login" },
} satisfies NextAuthConfig;
lib/auth.ts(带数据库的完整实例):
import NextAuth from "next-auth";
import { PrismaAdapter } from "@auth/prisma-adapter";
import { prisma } from "@/lib/db";
import { authConfig } from "./auth.config";
export const { handlers, auth, signIn, signOut } = NextAuth({
...authConfig,
adapter: PrismaAdapter(prisma), // 把用户存进你的数据库
session: { strategy: "jwt" }, // 无状态会话(登录态在加密 Cookie 里)
});
// 给 Server Action / 页面用:拿当前用户,没登录就抛错(上层转登录页)
export async function requireUser() {
const session = await auth();
if (!session?.user) throw new Error("未登录");
return session.user;
}
挂上 OAuth 回调路由 app/api/auth/[...nextauth]/route.ts:
export { GET, POST } from "@/lib/auth"; // handlers 里已含 GET/POST
补全数据库模型(关键,少了会登录失败)。 PrismaAdapter 需要几张固定的表来存“第三方账号 ↔ 你的用户”的关联。把它们加进 prisma/schema.prisma(4.2 那个最简 User 也要补成下面这样),再跑一次迁移:
model User {
id String @id @default(cuid())
name String?
email String? @unique
emailVerified DateTime?
image String?
accounts Account[]
sessions Session[]
contents Content[]
createdAt DateTime @default(now())
}
model Account {
id String @id @default(cuid())
userId String
type String
provider String
providerAccountId String
refresh_token String?
access_token String?
expires_at Int?
token_type String?
scope String?
id_token String?
session_state String?
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([provider, providerAccountId])
}
model Session {
id String @id @default(cuid())
sessionToken String @unique
userId String
expires DateTime
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
}
model VerificationToken {
identifier String
token String @unique
expires DateTime
@@unique([identifier, token])
}
npx prisma migrate dev --name add-auth # 建出 Account / Session 等表
即使你用无状态 JWT 会话(不往
Session表写行),Account表仍是 OAuth 登录必需的——它记录"哪个 Google/GitHub 账号对应库里哪个用户"。少了它,第一次登录就报错。字段以官方文档 Auth.js → Prisma adapter 的模型为准(可能随版本微调)。
环境变量命名:Auth.js v5 会自动从约定的变量名读 OAuth 凭据——
AUTH_GITHUB_ID/AUTH_GITHUB_SECRET、AUTH_GOOGLE_ID/AUTH_GOOGLE_SECRET。你只要把它们填进.env,provider 列表里写GitHub/
5.3 在 GitHub 登记你的网站(拿 GitHub OAuth 凭据)
- GitHub 右上头像 → Settings → 左下 Developer settings → OAuth Apps → New OAuth App。
- 填表:
- Application name:随便(用户授权时会看到)。
- Homepage URL:本地开发填
http://localhost:3000;上线后改/加https://你的域名。 - Authorization callback URL:
http://localhost:3000/api/auth/callback/github(上线后是https://你的域名/api/auth/callback/github)。
- 点 Register,得到 Client ID;再点 Generate a new client secret 得到 Client Secret。
- 填进本地
.env:AUTH_GITHUB_ID="刚才的 Client ID" AUTH_GITHUB_SECRET="刚才的 Client Secret"
回调地址结构是固定的:
<你的站点>/api/auth/callback/<provider>。<provider>对 GitHub 就是github,对 Google 就是
5.4 在 Google 登记你的网站(拿 Google OAuth 凭据)
Google 这边步骤多一点,跟着做:
- 打开 console.cloud.google.com,顶部建一个新项目(Project),随便命名。
- 左侧菜单 → APIs & Services → OAuth consent screen(同意屏幕):
- User Type 选 External(外部用户)→ 创建。
- 填应用名、支持邮箱、开发者联系邮箱,其余可先留默认,保存。
- 测试阶段应用处于「Testing」状态,只有你加进 Test users 的 Google 账号能登录;要对所有人开放需把应用「发布(Publish)」。自己测试时记得把自己的邮箱加进 Test users。
- 左侧 → Credentials(凭据)→ Create Credentials → OAuth client ID:
- Application type 选 Web application。
- Authorized redirect URIs 添加:
http://localhost:3000/api/auth/callback/google(上线后再加生产域名那条)。 - 创建后弹出 Client ID 和 Client Secret,复制。
- 填进
.env:AUTH_GOOGLE_ID="Google 的 Client ID" AUTH_GOOGLE_SECRET="Google 的 Client Secret"
5.5 用中间件保护需要登录的页面
middleware.ts(项目根)——在请求到达页面前先判断登录态,未登录访问受保护页就重定向到登录。
⚠️ 关键:中间件必须用 5.2 里那个不带数据库适配器的
authConfig来构造实例,不能import { auth } from "@/lib/auth"(那个带PrismaAdapter)。中间件默认跑在 Edge 运行时,而 Prisma 不能在 Edge 跑——import 带 adapter 的实例会直接构建/运行报错。这正是 5.2 把配置拆成两半的原因。
import NextAuth from "next-auth";
import { authConfig } from "@/lib/auth.config"; // 不带 adapter 的纯配置
// 用纯配置单独构造一个 edge 安全的实例(不碰数据库)
const { auth } = NextAuth(authConfig);
export default auth((req) => {
const isLoggedIn = !!req.auth?.user;
const path = req.nextUrl.pathname;
const isProtected = path.startsWith("/new") || path.startsWith("/settings") || path.endsWith("/edit");
if (isProtected && !isLoggedIn) {
return Response.redirect(new URL("/login", req.nextUrl));
}
});
// 只在页面路由跑,跳过 API、Next 内部资源、静态文件
export const config = { matcher: ["/((?!api|_next/static|_next/image|favicon.ico).*)"] };
还要建一个登录页——上面中间件把未登录用户重定向到 /login,这个页面必须存在(否则会跳到 404)。app/login/page.tsx:
import { signIn } from "@/lib/auth"; // 服务端版 signIn
export default function LoginPage() {
return (
<main>
<h1>登录</h1>
<form action={async () => { "use server"; await signIn("google", { redirectTo: "/" }); }}>
<button type="submit">用 Google 登录</button>
</form>
<form action={async () => { "use server"; await signIn("github", { redirectTo: "/" }); }}>
<button type="submit">用 GitHub 登录</button>
</form>
</main>
);
}
两个同名
signIn别搞混:上面用的是@/lib/auth导出的服务端signIn,配 Server Action 表单,最省事、无需额外配置。另有一个next-auth/react的客户端signIn(用于onClick按钮),二者同名但不是一回事——用客户端版需要在布局里包一层<SessionProvider>。新手优先用上面的服务端表单版。
5.6 鉴权这块的高频坑(先看,能少绕很多路)
| 现象 | 原因 | 解法 |
|---|---|---|
redirect_uri_mismatch |
平台登记的回调 URL 和你站点真实路径不一致 | 逐字符核对,注意 http/https、端口、结尾无多余斜杠 |
| Google 登录提示"未验证/无权限" | 应用还在 Testing,且你没把自己加进 Test users | 把测试邮箱加进 Test users,或发布应用 |
| 登录后一直被踢回登录/引导页 | AUTH_SECRET 没配或每次部署都变;无状态 JWT + 边缘中间件读不到库导致旧 Cookie 滞后 |
配固定的 AUTH_SECRET;改完用户资料后主动刷新会话;必要时让用户登出重登 |
| 本地能登、线上不能 | 生产域名的回调 URL 没在平台登记 | 在 GitHub/Google 把 https://你的域名/...callback/... 也加上 |
| 预览部署登录失败 | 预览每次域名都变,回调对不上 | 只在固定生产域名测登录 |
无状态 JWT 的本质:登录态是一段加密信息存在浏览器 Cookie 里,服务器不查库就能验证。优点是简单省存储;代价是"用户信息变了,Cookie 里还是旧的",需要在关键操作后主动刷新会话。理解这点能省你很多“明明改了怎么不生效”的困惑。
第 6 章 · 实战 C:文件上传与安全预览
目标:用户能上传图片/文件,安全地存到 R2,并能在站内预览或下载。这是最容易出安全事故的功能,所以本章每一步都会顺手解释原因。
6.1 核心架构:客户端直传 + 服务端把关
① 浏览器 → 服务端:「我要传一个 hero.png,1.2MB,image/png」
② 服务端 → 浏览器:校验类型/大小白名单 → 返回一个【有时效的签名上传URL】
③ 浏览器 → R2: 用签名URL 直接 PUT 原始文件(不经过你的服务器!)
④ 浏览器 → 服务端:「传完了,key 是 xxx」
⑤ 服务端 → R2: 回拉文件头做【真伪校验】+ 图片【重新编码去元数据】 → 落一条数据库记录
为什么这么绕? 因为它同时满足了三个目标:
- 省钱省带宽:大文件不经过你的服务器(Serverless 函数还有请求体大小/时长限制,大文件会直接失败)。
- 安全:服务端在签发链接前做白名单,在落库前做真伪校验,两道关卡。
- 体验:浏览器直传 + 进度条,比走中转快。
6.2 建 Cloudflare R2 桶 + 拿 S3 凭据
- Cloudflare 控制台 → 左侧 R2 → Create bucket → 取个名(如
myapp-usercontent)。 - R2 → Manage R2 API Tokens → Create API Token:权限选 Object Read & Write,创建后拿到 Access Key ID 和 Secret Access Key(Secret 只显示一次,存好)。
- 你的 Account ID 在 R2 概览页能看到。
- 填进
.env(本地开发也能连云端 R2,或用本地磁盘模拟,见下):R2_ACCOUNT_ID="你的账号ID" R2_ACCESS_KEY_ID="Access Key ID" R2_SECRET_ACCESS_KEY="Secret Access Key" R2_BUCKET="myapp-usercontent"
R2 兼容 S3 协议,端点是
https://<account_id>.r2.cloudflarestorage.com,region 用auto。所以你直接用 AWS 的 S3 SDK 就能操作它。
6.3 用“存储适配器”抽象,让本地开发不依赖云
一个好习惯:把"存储"抽象成一个接口(put / 签名URL / 读 / 删),底下可换实现。这样:
- 生产:R2 实现。
- 本地开发:磁盘实现——把文件落到本地一个目录,签名 URL 指向你自己应用的一个开发路由。这样没有 Cloudflare 账号也能在本地跑通整条上传链路。
- 测试:内存实现。
// lib/storage/adapter.ts
export interface StorageAdapter {
getSignedUploadUrl(key: string, contentType: string): Promise<string>;
getSignedDownloadUrl(key: string): Promise<string>;
getBytes(key: string): Promise<Uint8Array>;
put(key: string, bytes: Uint8Array, contentType: string): Promise<void>;
delete(key: string): Promise<void>;
}
// 工厂:按环境选实现
export function createStorage(env = process.env): StorageAdapter {
if (env.R2_ACCOUNT_ID && env.R2_BUCKET) return new R2Adapter(/* ... */); // 凭据齐 → R2
if (env.NODE_ENV === "development") return new DiskAdapter(".localstorage"); // 本地 → 磁盘
return new MemoryAdapter(); // 测试 → 内存
}
R2 的实现用 AWS 的 S3 SDK(R2 兼容 S3)。先装包:
npm install @aws-sdk/client-s3 @aws-sdk/s3-request-presigner
R2 客户端的最小初始化(注意 region: "auto" + 自定义 endpoint 这两项 R2 必填):
import { S3Client } from "@aws-sdk/client-s3";
const r2 = new S3Client({
region: "auto",
endpoint: `https://${process.env.R2_ACCOUNT_ID}.r2.cloudflarestorage.com`,
credentials: {
accessKeyId: process.env.R2_ACCESS_KEY_ID!,
secretAccessKey: process.env.R2_SECRET_ACCESS_KEY!,
},
});
// 签名上传 URL:用 @aws-sdk/s3-request-presigner 的 getSignedUrl(r2, new PutObjectCommand({...}))
// 把 ContentType 一并放进命令里签进签名(见 6.4 的安全说明)
为什么值得这么做:你本地开发上传功能时,不用真的配 Cloudflare。这就是工程里“依赖倒置”的好处——核心逻辑只依赖接口,外部服务可插拔。真实踩过的坑:如果不做这层抽象,本地没配 R2 时上传会“看起来成功但其实是个打不开的假链接”,排查半天。
6.4 服务端:白名单 + 真伪校验 + 去元数据
签发上传链接前(requestUpload)——做白名单:
const ALLOW = { IMAGE: ["image/png", "image/jpeg", "image/webp"], FILE: ["application/pdf"] };
const MAX_BYTES = 20 * 1024 * 1024; // 20MB
// 校验 input.mimeType 在白名单、input.size <= MAX_BYTES,否则直接拒绝(fail fast)
上传完确认时(confirmUpload)——做真伪校验和去元数据:
const bytes = await storage.getBytes(key);
// ① 不信任客户端声明的类型,读真实"文件头魔数"(magic bytes)判断真实类型
if (!matchesDeclaredType(bytes, input.type)) {
await storage.delete(key); // 真实类型对不上 → 删掉并拒绝
return { error: "文件类型不符" };
}
// ② 图片重新编码:把 PNG/JPEG 解码再编码,顺手抹掉 EXIF(GPS、设备型号等隐私元数据)
if (input.type === "IMAGE") {
const clean = await reencodeImage(bytes);
await storage.put(key, clean.bytes, clean.contentType);
}
// ③ 落库记录
await insertArtifact({ key, type: input.type, ownerId });
三个“为什么”:① 攻击者可以把一个可执行脚本改名成
.png上传——所以绝不信扩展名/客户端 MIME,要读真实文件头;② 手机拍的照片 EXIF 里藏着 GPS 位置,直接公开会泄露用户隐私,用带去元数据选项的重新编码可抹掉绝大部分;③ 任何校验不过都立即删除 + 拒绝,不留半成品。
用什么库:图片重新编码去元数据常用
sharp;读真实文件头判类型常用file-type。上面的matchesDeclaredType/reencodeImage/insertArtifact都是示意函数,需你用这些库自行实现(或参照你的配套仓库)。
再加固两点(重要):① 把
Content-Type锁进签名——签发上传 URL 时就把允许的类型写进签名命令,浏览器 PUT 时请求头必须匹配,否则 R2 拒绝,杜绝"为image/png签的链接被拿去传text/html";② 校验通过前别让文件公开可读——用"先传到暂存 key →confirmUpload校验通过后再标记/移动到公共可读路径",消除"确认前文件就能被人直接访问"的窗口。
非图片文件(PDF/其它)的残留风险:本流程只对图片做了去元数据。PDF 也可能含作者/路径等元数据、甚至内嵌 JS,本流程对它只做了文件头校验。所以非图片一律强制
attachment下载、不内联渲染;若确需内联预览 PDF,要走禁用 JS 的沙箱化 viewer。
6.5 安全预览:把用户产物关进“笼子”
用户上传/生成的内容(尤其是 HTML、Markdown)默认是不可信的。直接渲染 = 给攻击者在你站内执行脚本、窃取其他用户登录态的机会(XSS)。三道笼子:
- 独立内容子域:所有用户产物都从
usercontent.你的域名提供,这个子域不带主站的登录 Cookie。即使产物里的脚本跑起来,它也偷不到主站登录态(因为压根没有)。 - 沙箱 iframe + CSP:HTML 产物放进
<iframe sandbox="allow-scripts">渲染——- 绝对不要加
allow-same-origin(加了沙箱基本等于没有)。 - 再叠一层严格 CSP(
default-src 'none'起步),封死它对外发请求的能力。
- 绝对不要加
- Markdown 清洗:
Markdown → HTML → 白名单清洗(rehype-sanitize),干掉<script>、on*事件属性、javascript:协议。
公开 vs 私有产物的不同取链方式:
- 公开内容:直接给内容子域的公共 URL(可被 CDN 缓存,快)。
- 私有内容:给有时效的签名下载 URL(不放上公共子域,防缓存泄露),且权限由唯一访问层把关——拿到链接的前提是已经验明你有权。
6.6 配 R2 的 CORS(让浏览器能直传)
因为是浏览器跨域直接 PUT 到 R2,必须在桶上允许你的站点源:
[{
"AllowedOrigins": ["https://你的域名", "http://localhost:3000"],
"AllowedMethods": ["PUT", "GET"],
"AllowedHeaders": ["content-type", "x-amz-content-sha256", "x-amz-date"],
"ExposeHeaders": ["ETag"]
}]
在 Cloudflare R2 → 你的桶 → Settings → CORS Policy 里填。不配这个,浏览器直传会被 CORS 拦下(第 9 章排错表有这条)。
不要图省事写
AllowedHeaders: ["*"]:浏览器预检(preflight)会带content-type等具体头,签名 PUT 常因这些头不在白名单被拒。显式列出你实际会发的头最稳。
下载安全:给文件下载响应加
Content-Disposition: attachment+X-Content-Type-Options: nosniff,强制"下载而非内联执行"、禁止浏览器猜类型。
第 7 章 · 实战 D:部署上线(Vercel + 域名)
本地一切跑通后,把它搬上云、绑定你自己的域名。
7.1 先处理一个很容易让部署挂掉的坑:生产数据库迁移
Vercel 构建时会缓存依赖。如果你的数据库模型(schema)变了,但构建命令没有重新生成 Prisma 客户端,Vercel 会用缓存里的旧客户端——它没有你新加的表/字段,于是 next build 的类型检查直接报错挂掉(本地却好好的,因为本地客户端是新生成的)。这是 Prisma + Vercel 的头号坑。
一劳永逸的修法(两处):
// package.json:每次安装依赖后自动重新生成客户端
"scripts": {
"postinstall": "prisma generate"
}
// vercel.json:构建时先生成客户端、再应用迁移、最后构建
{
"$schema": "https://openapi.vercel.sh/vercel.json",
"buildCommand": "prisma generate && prisma migrate deploy && next build"
}
这样部署时会自动把待执行的迁移应用到生产库(
prisma migrate deploy只回放尚未执行的迁移,幂等、不删数据)。两点提醒:① 要先配好数据库连接串(见 7.2 / 7.3),否则构建会在migrate deploy这步显式失败——这是对的,好过"静默部署一个连不上库的版本";② 这种"构建时迁移"对个人项目最省心,但要知道每个会构建的环境(包括每个 Preview 部署)都会对它所连的库跑一次迁移。多人协作、或对迁移有严格管控时,更稳妥的做法是把迁移拆成独立发布步骤、只在生产分支执行,而非无脑塞进buildCommand。
7.2 接生产数据库(Neon,从 Vercel 一键创建)
- 先把代码推上 GitHub(若还没):
git push。 - Vercel → Add New → Project → 选你的 GitHub 仓库 → Vercel 自动识别是 Next.js → 先别急着 Deploy,下面要配数据库和环境变量。
- 在 Vercel 项目里 → Storage → Create Database → Neon(Postgres)→ 跟引导连到本项目。
- 创建后,Neon 会提供连接串。在 Neon 控制台右上 Connect 弹窗里:
- 池化(pooled,带
-pooler的 host) → 填到 Vercel 环境变量DATABASE_URL(运行时用)。 - 直连(direct,不带
-pooler) → 填到DIRECT_URL(迁移用)。
- 池化(pooled,带
很多托管商(含 Neon 的 Vercel 集成)会把数据库串标记为 Sensitive(敏感)——它们在运行时有效,但你之后用
vercel env pull拉不回明文(这是安全设计)。所以将来要在本地跑"灌初始数据"之类的脚本时,得回 Neon 控制台重新复制连接串,别指望从 Vercel 拉。(第 9 章会讲到。)
7.3 配齐生产环境变量
在 Vercel → 项目 → Settings → Environment Variables 里,环境选 Production,逐条加(值从前面各步骤拿):
| 变量 | 来源 | 备注 |
|---|---|---|
DATABASE_URL |
Neon 池化串 | 运行时 |
DIRECT_URL |
Neon 直连串 | 迁移 |
AUTH_SECRET |
openssl rand -base64 32 |
生产用一个固定值,别每次变 |
AUTH_GITHUB_ID / AUTH_GITHUB_SECRET |
GitHub OAuth App | |
AUTH_GOOGLE_ID / AUTH_GOOGLE_SECRET |
Google OAuth Client | |
R2_ACCOUNT_ID / R2_ACCESS_KEY_ID / R2_SECRET_ACCESS_KEY / R2_BUCKET |
Cloudflare R2 | 生产上传必需 |
NEXT_PUBLIC_USERCONTENT_BASE_URL |
内容子域,如 https://usercontent.你的域名 |
⚠️ 构建时注入,必须在部署前设好 |
CRON_SECRET |
openssl rand -base64 32 |
保护定时任务端点 |
⚠️
NEXT_PUBLIC_开头的变量是构建时"烤"进前端代码的。如果你在部署之后才补它,旧构建里它还是空的——必须先设好再触发部署。这个坑很隐蔽,最好提前记住。
7.4 触发首次部署
确认 DATABASE_URL + DIRECT_URL 已配好后,Deploy(或 git push 自动触发)。构建日志里你会看到 prisma generate → migrate deploy → next build 依次跑过,生产库的表被自动建好。部署成功后 Vercel 给你一个临时域名 xxx.vercel.app,先用它点开验证能访问。
7.5 买域名并绑定(Cloudflare ↔ Vercel)
- 在 Cloudflare 买域名:登录 → Domain Registration → Register Domains → 搜你想要的域名 → 购买(支持信用卡,约 ¥70–100/年)。买后域名自动托管在 Cloudflare DNS。
- 在 Vercel 添加域名:项目 → Settings → Domains → 输入你的域名 → Add。Vercel 会给你两条 DNS 记录,类似:
A记录@→76.76.21.21CNAME记录www→cname.vercel-dns.com
- 在 Cloudflare 配 DNS:回 Cloudflare → 你的域名 → DNS → Records → Add record,把上面两条加进去。
- ⚠️ 把这两条记录的"橙色云朵"(代理)关成灰色(DNS only)。开着橙云会让 Cloudflare 代理流量,和 Vercel 的边缘/证书产生冲突,导致各种诡异问题。
- 等几分钟到十几分钟 DNS 生效,Vercel 自动签好 HTTPS 证书,
https://你的域名就能访问了。
绑定后回头补 OAuth 回调:现在你有了生产域名,记得回 GitHub 和 Google 的 OAuth 配置里,把
https://你的域名/api/auth/callback/github(和redirect_uri_mismatch。
7.6 内容子域绑定到 R2
为了第 6.5 节的"独立内容子域",把 usercontent.你的域名 指向你的 R2 桶:
- Cloudflare R2 → 你的桶 → Settings → Public access / Custom Domains → 绑定
usercontent.你的域名(Cloudflare 会自动加 DNS 记录)。 - 然后把 Vercel 的
NEXT_PUBLIC_USERCONTENT_BASE_URL设为https://usercontent.你的域名,重新部署一次(因为它是构建时变量)。 - 嫌麻烦也可先用 R2 自带的
r2.dev公共 URL 顶着(需先在桶 Settings 里启用 “Public Development URL”,默认是关闭的),但生产建议用自有子域(安全 + 可控)。
7.7 定时任务(Cron)
如果你的网站有"每天清理过期临时文件 / 刷新统计快照"这类后台活,用 Vercel Cron:
// vercel.json
{
"crons": [{ "path": "/api/cron/daily-job", "schedule": "0 3 * * *" }] // 每天 03:00
}
对应的端点要鉴权——Vercel 触发 Cron 时会自动带 Authorization: Bearer <CRON_SECRET>,你的端点校验它,校验失败一律 401。注意要用常量时间比较(防时序侧信道),别用普通 ===:
import { timingSafeEqual } from "node:crypto";
export async function GET(req: Request) {
const secret = process.env.CRON_SECRET;
const header = req.headers.get("authorization") ?? "";
const expected = `Bearer ${secret ?? ""}`;
const ok =
!!secret &&
header.length === expected.length && // 先比长度,再常量时间比内容
timingSafeEqual(Buffer.from(header), Buffer.from(expected));
if (!ok) return Response.json({ error: "unauthorized" }, { status: 401 });
// ... 干你的后台活(清理、统计等)
}
Cron 端点是个新的攻击面(不鉴权的话,任何人都能触发你的后台重活)。务必用
CRON_SECRET锁死、用上面的常量时间比较、密钥没配就拒绝而不是放行。
7.8 灌初始数据(可选)
新站数据库是空的,可以写一个"种子脚本"灌些初始/示例数据。注意两点真实坑:
- 数据库串拉不回:如 7.2 所述,生产串被标 Sensitive,
vercel env pull拿到的是空值。要灌生产库,从 Neon 控制台 Connect 弹窗复制直连串,临时用它跑脚本:
(用直连串而非池化串:几千条插入直连更快,也避开连接池的预编译语句限制。)DATABASE_URL="<Neon 直连串>" npm run db:seed - Neon 冷启动:免费档计算会闲时缩容到 0,首次连接要唤醒几秒到几十秒,脚本一开始"卡住没输出"多半是在等唤醒,耐心点。
第 8 章 · 安全基线清单(务必逐条对照)
上线前把这张表过一遍,每条都该有对应实现。安全不是上线后再补的功能,是贯穿始终的习惯。
- [ ] 密钥零硬编码:所有连接串、Client Secret、
AUTH_SECRET、CRON_SECRET都在环境变量/平台密钥面板,代码与 Git 里搜不到任何明文密钥。 - [ ]
.env*已被.gitignore:本地密钥本不入库。 - [ ] 写操作三连:每个写入口都「确认登录 → Zod 校验 → 确认权限 → 才落库」。
- [ ] 唯一访问层:读受保护资源全部经
getAccessibleX,业务代码不裸查库。 - [ ] 私有即 404:无权访问私有资源返回 404,不暴露"存在但无权"。
- [ ] 私有页面不被缓存:响应显式
no-store,防 CDN/路由缓存把私有内容泄露给别人。 - [ ] 一切外部输入过 Zod:表单、API、上传元数据,进系统先校验。
- [ ] 上传防伪:校验真实文件头(不信扩展名/客户端 MIME)+ 类型/大小白名单 + 图片去 EXIF。
- [ ] 产物隔离:用户产物走独立内容子域,该域不带主站 Cookie。
- [ ] HTML 沙箱:
sandbox="allow-scripts"(绝不allow-same-origin)+ 严格 CSP。 - [ ] Markdown 清洗:白名单 sanitize,禁
script/on*/javascript:。 - [ ] 下载头:
Content-Disposition: attachment+nosniff。 - [ ] Cron 鉴权:
CRON_SECRET+ 常量时间比较,未配即拒绝。 - [ ] OAuth 回调白名单:生产域名的回调地址已在 GitHub/Google 登记,且仅登记可信地址。
- [ ] 主站安全响应头:在
next.config全局设Content-Security-Policy(主站自己的白名单)、Strict-Transport-Security(HSTS)、X-Frame-Options: DENY(或 CSPframe-ancestors 'none',防点击劫持)、Referrer-Policy: strict-origin-when-cross-origin。这是生产站标配,第 6 章的 CSP 只管用户产物 iframe,不等于主站有 CSP。 - [ ] 关键端点限流:登录回调、写操作、签发上传 URL 等端点加基础速率限制(应用层如 Upstash Ratelimit,或 Vercel 层),防刷量、放大滥用。
- [ ] 会话 Cookie 属性:
HttpOnly + Secure + SameSite(Auth.js 默认即如此,别改坏)。Server Actions 有内建同源保护;但你自建的非 Server-Action 写接口要自行加 CSRF/同源校验。 - [ ] 私有签名 URL 短时效:私有产物用短 TTL(如 60–300 秒)签名 URL,下载响应加
Cache-Control: private, no-store,防共享缓存把"为 A 签的链接"喂给 B。 - [ ] 最小权限 + 不泄露错误:R2 Token 尽量按桶/前缀最小授权;生产不向客户端返回原始错误/堆栈;开
npm audit/ Dependabot 扫依赖漏洞、定期轮换密钥。 - [ ] 平台账号开 2FA:GitHub/Vercel/Cloudflare/Google 全开两步验证。
第 9 章 · 上线验收 + 排错速查(真实踩坑)
9.1 验收清单(线上逐条点一遍)
- [ ] 首页/内容页能打开(数据库通)。
- [ ] Google 和 GitHub 登录都走得通,首次登录能正常进入。
- [ ] 创建一条私有内容 → 登出 → 直接访问它的链接,应是 404(权限生效)。
- [ ] 上传一张图片 → 能在站内预览(内容子域 + CORS 都对)。
- [ ] 上传一个手机拍的照片 → 下载下来看 EXIF,GPS 应已被抹掉。
- [ ] 手测 Cron:带正确
CRON_SECRET调用返回正常,不带/带错返回 401。
9.2 排错速查表(按现象查)
| 现象 | 可能原因 | 解法 |
|---|---|---|
| 部署构建报 Prisma 类型不存在(某表/字段) | Vercel 缓存了旧 Prisma 客户端 | 确认 postinstall: prisma generate + buildCommand 含 prisma generate(7.1) |
构建卡在 prisma migrate deploy 报连接错 |
没配 DATABASE_URL/DIRECT_URL,或 DIRECT_URL 填了池化串 |
配齐两条串,DIRECT_URL 必须是直连(不带 -pooler) |
redirect_uri_mismatch |
OAuth 回调地址两边不一致/没配生产域 | 逐字符核对,生产域回调地址加进 GitHub/Google |
| 登录后被反复踢回登录页 | AUTH_SECRET 没配或每次部署都变 |
生产配固定 AUTH_SECRET;让用户登出重登 |
| 上传后图打不开,像"假的" | 生产没配 R2(回退了内存/磁盘),或 NEXT_PUBLIC_USERCONTENT_BASE_URL 没在构建前设 |
配齐 R2 四变量 + 内容子域,重新部署 |
| 浏览器直传被 CORS 拦 | R2 桶没配 CORS | 加 6.6 的 CORS 规则 |
| 域名打不开/证书报错/行为诡异 | Cloudflare 橙云开着和 Vercel 冲突 | 把对应 DNS 记录的云朵关成灰色(DNS only) |
NEXT_PUBLIC_* 线上是空的 |
它是构建时变量,设它之前就部署了 | 设好后重新触发一次部署 |
| Cron 调用 401 | CRON_SECRET 没配或不匹配 |
配置后重新部署 |
| 本地灌生产数据卡住没输出 | Neon 免费档冷启动在唤醒 | 等几十秒;或先在 Neon 控制台点一下唤醒 |
9.3 一条通用排错思路
“本地好好的,线上挂了” ≈ 环境差异。 八成是这三类之一:① 环境变量没配/配错/构建时机不对;② 构建/依赖缓存(Prisma 客户端最典型);③ 平台特性(Serverless 连接、边缘运行时限制、Sensitive 变量)。排查时先问“线上和本地有什么不一样”,通常比一头扎进代码里更快。
第 10 章 · 之后呢:CI、测试、监控与成本
打好地基后,让它更稳、更省心:
- 自动化测试:用 Vitest 写单元/集成测试(尤其是 service 层和安全校验逻辑),用 Playwright 写端到端测试(登录→创建→上传的关键流程)。把"安全回归"也写成测试:私有越权读返回 404、上传非白名单被拒、Markdown XSS 被清洗。
- CI(持续集成):用 GitHub Actions 在每次 PR 自动跑 lint + 类型检查 + 测试,绿了才能合。门槛低、收益大。
- 预览部署:Vercel 给每个 PR 自动一个预览 URL,合并前能先点着看(注意 OAuth 在预览域名上可能登不了,见 5.6)。
- 监控与日志:Vercel 自带函数日志和用量面板;数据库在 Neon 控制台看连接/计算用量。
- 成本观察:免费额度有限,关注三处——Vercel 函数调用/带宽、Neon 计算时长(CU-hours)与存储、R2 存储(出口免费是它的优势)。个人项目通常长期 0 元,量起来了再按需升级。
- 数据库变更纪律:永远通过"改 schema → 生成迁移 → 提交 → 部署回放"来改生产表结构,绝不手动改生产库。
附录 A · 环境变量速查表
# ── 数据库 ──
DATABASE_URL="postgresql://...-pooler...?sslmode=require" # 池化,运行时
DIRECT_URL="postgresql://...(不带 -pooler)...?sslmode=require" # 直连,迁移
# ── 认证 ──
AUTH_SECRET="<openssl rand -base64 32 的输出,生产固定>"
AUTH_GITHUB_ID="..."
AUTH_GITHUB_SECRET="..."
AUTH_GOOGLE_ID="..."
AUTH_GOOGLE_SECRET="..."
# ── 对象存储 ──
R2_ACCOUNT_ID="..."
R2_ACCESS_KEY_ID="..."
R2_SECRET_ACCESS_KEY="..."
R2_BUCKET="myapp-usercontent"
NEXT_PUBLIC_USERCONTENT_BASE_URL="https://usercontent.你的域名" # 构建时注入!
# ── 定时任务 ──
CRON_SECRET="<openssl rand -base64 32 的输出>"
本地
.env与生产 Vercel 环境变量是两套,各填各的;NEXT_PUBLIC_*改了要重新构建。
附录 B · 命令速查表
| 命令 | 作用 |
|---|---|
npm run dev |
启动本地开发服务器 |
npx prisma migrate dev --name xxx |
改了 schema 后建迁移并应用(本地) |
npx prisma migrate deploy |
把待执行迁移应用到目标库(生产,幂等) |
npx prisma generate |
重新生成类型安全的查询客户端 |
npx prisma studio |
可视化查看/编辑数据库 |
docker compose up -d |
起本地 Postgres(方式 A) |
openssl rand -base64 32 |
生成 AUTH_SECRET / CRON_SECRET |
git push |
推代码 → Vercel 自动部署 |
vercel env pull <文件> |
拉取平台环境变量到本地(注意 Sensitive 拉不到明文) |
附录 C · 平台与免费额度一览
| 平台 | 免费档够干嘛 | 何时要付费 |
|---|---|---|
| GitHub | 无限公开/私有仓库、Actions 有免费分钟数 | 大团队/大量 CI |
| Vercel | 个人项目托管、Cron(日级)、预览部署 | 商用、高流量、团队协作 |
| Neon | 一个项目、若干计算时长 + 0.5GB 存储、自动缩容省钱 | 数据量/计算量上来 |
| Cloudflare R2 | 10GB 存储/月、出口流量免费 | 存储量大 |
| Cloudflare 域名 | 注册近成本价(非免费,约 ¥70–100/年) | — |
| Google / GitHub OAuth | 完全免费 | — |
除域名外,本教程整套可在免费额度内长期运行个人项目。注意:各平台免费额度的具体数字(存储/流量/计算时长等)经常调整,上表只示意量级,请以各平台官网当前说明为准。
结语
你现在手里这套能力——第三方登录、数据库持久化、安全文件上传、自有域名一键部署——几乎是所有"带账号 + 数据 + 文件"的网站的通用底座。把本教程的示例形态(带账号的内容站)换成你自己的点子(笔记站、作品集、小工具、社区……),底层这套架构和操作流程基本照搬即可。
三句话带走:
- 分层 + 单一访问层 + 边界校验,是网站随功能增长仍不崩、不漏的根基。
- 安全内建:私有即 404、产物隔离子域、上传防伪去元数据、密钥零硬编码——从第一天就做。
- "本地好好的、线上挂了"先查环境差异:变量、缓存、平台特性,三类排查最高效。
真实案例:一个周末,vibe coding 上线 PromptHub
这篇教程不是凭空整理出来的,而是我自己完整做了一遍后的复盘。
某个周末,作者想给自己攒的一堆 prompt 找个"像管代码一样管 prompt"的地方:能托管、能分享、能 Fork、能发现别人的好东西。于是用本文这套 GitHub + Google + Cloudflare + Vercel 组合,全程 vibe coding——需求一句话丢给 AI、生成、本地跑起来、不对就改、改完再跑——两天时间把一个真正能用的产品做了出来,并完整走完了从本地骨架、OAuth 登录、Neon 数据库、R2 文件上传,到 Vercel 部署、绑定新买的域名、配好 HTTPS 与定时任务的全过程。
成品就是现在线上的 PromptHub:
🔗 https://www.awesome-prompt.com
它把本教程的"带账号的内容站"形态落到了实处——你能在上面看到精选与最新的 prompt、单文本/多文件/工作流多种形态、收藏与 Fork、按话题浏览。读完文章后再回头看它,你会发现:文里讲的每一块拼图,都能在这个站点上一一对上。
想验证自己学会了? 把 PromptHub 换成你自己的点子,照着本教程再走一遍——这就是练“从零到一”最有效的方式。
祝你的网站顺利上线 🚀
