Lionel · 李豪个人兴趣、文章、摄影、视频和音乐
Article · 文章

从零到一搭建一个‘PromptHub’

Lionel
2026/06/01 · 48 min read

用一个周末从零搭出 PromptHub:把登录、数据库、文件上传、部署和自有域名串成一套可复用的生产级全栈建站路径。

这篇教程会带你完整走一遍:用 GitHub + Google + Cloudflare + Vercel 这套主流平台,从一行代码都没有,到上线一个跑在自有域名上的真实复杂网站——具备用户登录(第三方 OAuth)数据库持久化文件上传与安全预览这三大高级能力。

读者定位:有基本编程基础(写过一点 JavaScript/任意后端、用过命令行、知道 Git 大概是干嘛的),但没独立上线过一个带登录和数据库的真实网站。我会尽量把命令和控制台路径写细,让你照着做也能跑通。

我自己用一个周末 vibe coding,从零搭了一个 Prompt 托管分享平台 PromptHub,也完整走完了文中的搭建和部署流程,最后买域名上线:

👉 PromptHub 网站链接:https://www.awesome-prompt.com


目录


第 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 三条核心数据流

  1. 读(浏览内容):浏览器请求页面 → Vercel 上的 Next.js 在服务端查 Neon 数据库 → 渲染好 HTML 返回。私有内容会先校验"你是不是 owner",无权一律当作"不存在"(返回 404)。
  2. 写(创建/编辑):用户在页面提交 → 触发 Server Action → 依次做「确认已登录 → 校验输入合法 → 确认有权限」三道关卡 → 才写进数据库。
  3. 传(上传文件):浏览器先向服务端要一个有时效的签名链接 → 浏览器直接把文件 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 章配置时不会懵。
  • 备选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 项目:
    npx create-next-app@latest my-app --typescript --tailwind --app --eslint
    cd my-app
    git init && git add -A && git commit -m "chore: 初始化项目骨架"
    
    然后按第 4–6 章逐步把数据库、认证、上传"长"上去。

把它推到一个新的 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.jsoncompilerOptions.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 Actionserver/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@5 beta)。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_SECRETAUTH_GOOGLE_ID/AUTH_GOOGLE_SECRET。你只要把它们填进 .env,provider 列表里写 GitHub/Google 即可,不用手动传。

5.3 在 GitHub 登记你的网站(拿 GitHub OAuth 凭据)

  1. GitHub 右上头像 → Settings → 左下 Developer settingsOAuth AppsNew OAuth App
  2. 填表:
    • Application name:随便(用户授权时会看到)。
    • Homepage URL:本地开发填 http://localhost:3000;上线后改/加 https://你的域名
    • Authorization callback URLhttp://localhost:3000/api/auth/callback/github(上线后是 https://你的域名/api/auth/callback/github)。
  3. Register,得到 Client ID;再点 Generate a new client secret 得到 Client Secret
  4. 填进本地 .env
    AUTH_GITHUB_ID="刚才的 Client ID"
    AUTH_GITHUB_SECRET="刚才的 Client Secret"
    

回调地址结构是固定的<你的站点>/api/auth/callback/<provider><provider> 对 GitHub 就是 github,对 Google 就是 google。两边(平台登记的 + 你站点真实路径)必须一字不差

5.4 在 Google 登记你的网站(拿 Google OAuth 凭据)

Google 这边步骤多一点,跟着做:

  1. 打开 console.cloud.google.com,顶部建一个新项目(Project),随便命名。
  2. 左侧菜单 → APIs & ServicesOAuth consent screen(同意屏幕):
    • User Type 选 External(外部用户)→ 创建。
    • 填应用名、支持邮箱、开发者联系邮箱,其余可先留默认,保存。
    • 测试阶段应用处于「Testing」状态,只有你加进 Test users 的 Google 账号能登录;要对所有人开放需把应用「发布(Publish)」。自己测试时记得把自己的邮箱加进 Test users。
  3. 左侧 → Credentials(凭据)→ Create CredentialsOAuth client ID
    • Application type 选 Web application
    • Authorized redirect URIs 添加:http://localhost:3000/api/auth/callback/google(上线后再加生产域名那条)。
    • 创建后弹出 Client IDClient Secret,复制。
  4. 填进 .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 凭据

  1. Cloudflare 控制台 → 左侧 R2Create bucket → 取个名(如 myapp-usercontent)。
  2. R2 → Manage R2 API Tokens → Create API Token:权限选 Object Read & Write,创建后拿到 Access Key IDSecret Access Key(Secret 只显示一次,存好)。
  3. 你的 Account ID 在 R2 概览页能看到。
  4. 填进 .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)。三道笼子:

  1. 独立内容子域:所有用户产物都从 usercontent.你的域名 提供,这个子域不带主站的登录 Cookie。即使产物里的脚本跑起来,它也偷不到主站登录态(因为压根没有)。
  2. 沙箱 iframe + CSP:HTML 产物放进 <iframe sandbox="allow-scripts"> 渲染——
    • 绝对不要加 allow-same-origin(加了沙箱基本等于没有)。
    • 再叠一层严格 CSP(default-src 'none' 起步),封死它对外发请求的能力。
  3. 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 一键创建)

  1. 先把代码推上 GitHub(若还没):git push
  2. Vercel → Add New → Project → 选你的 GitHub 仓库 → Vercel 自动识别是 Next.js → 先别急着 Deploy,下面要配数据库和环境变量。
  3. 在 Vercel 项目里 → Storage → Create Database → Neon(Postgres)→ 跟引导连到本项目。
  4. 创建后,Neon 会提供连接串。在 Neon 控制台右上 Connect 弹窗里:
    • 池化(pooled,带 -pooler 的 host) → 填到 Vercel 环境变量 DATABASE_URL(运行时用)。
    • 直连(direct,不带 -pooler → 填到 DIRECT_URL(迁移用)。

很多托管商(含 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)

  1. 在 Cloudflare 买域名:登录 → Domain Registration → Register Domains → 搜你想要的域名 → 购买(支持信用卡,约 ¥70–100/年)。买后域名自动托管在 Cloudflare DNS。
  2. 在 Vercel 添加域名:项目 → Settings → Domains → 输入你的域名 → Add。Vercel 会给你两条 DNS 记录,类似:
    • A 记录 @76.76.21.21
    • CNAME 记录 wwwcname.vercel-dns.com
  3. 在 Cloudflare 配 DNS:回 Cloudflare → 你的域名 → DNS → RecordsAdd record,把上面两条加进去。
  4. ⚠️ 把这两条记录的"橙色云朵"(代理)关成灰色(DNS only)。开着橙云会让 Cloudflare 代理流量,和 Vercel 的边缘/证书产生冲突,导致各种诡异问题。
  5. 等几分钟到十几分钟 DNS 生效,Vercel 自动签好 HTTPS 证书,https://你的域名 就能访问了。

绑定后回头补 OAuth 回调:现在你有了生产域名,记得回 GitHub 和 Google 的 OAuth 配置里,把 https://你的域名/api/auth/callback/github(和 .../google加进允许的回调地址,否则线上登录会 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_SECRETCRON_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(或 CSP frame-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 + buildCommandprisma 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 完全免费

除域名外,本教程整套可在免费额度内长期运行个人项目。注意:各平台免费额度的具体数字(存储/流量/计算时长等)经常调整,上表只示意量级,请以各平台官网当前说明为准。


结语

你现在手里这套能力——第三方登录、数据库持久化、安全文件上传、自有域名一键部署——几乎是所有"带账号 + 数据 + 文件"的网站的通用底座。把本教程的示例形态(带账号的内容站)换成你自己的点子(笔记站、作品集、小工具、社区……),底层这套架构和操作流程基本照搬即可

三句话带走:

  1. 分层 + 单一访问层 + 边界校验,是网站随功能增长仍不崩、不漏的根基。
  2. 安全内建:私有即 404、产物隔离子域、上传防伪去元数据、密钥零硬编码——从第一天就做。
  3. "本地好好的、线上挂了"先查环境差异:变量、缓存、平台特性,三类排查最高效。

真实案例:一个周末,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 换成你自己的点子,照着本教程再走一遍——这就是练“从零到一”最有效的方式。

祝你的网站顺利上线 🚀

#文章
Lionel Written by
我叫李豪,来自云南昭通,现居杭州 喜欢摄影、足球 、Vibe Coding 和老婆一起养了两只猫
Scinentistcoldplay