基于Spring Lament Blog项目的实战经验,帮助前端同学快速掌握全栈开发技能
Spring Lament Blog 是一个基于 Next.js 15 的现代化全栈博客系统,专注于优雅的写作体验和流畅的阅读感受。本项目采用最新的 App Router 架构,集成了完整的认证系统、内容管理、数据存储等功能,是学习全栈开发的绝佳实践项目。
- 📝 Markdown 编辑器 - 支持实时预览、代码高亮、数学公式
- 🔐 用户认证 - NextAuth.js 完整认证流程
- 📊 内容管理 - 文章 CRUD、分类标签、草稿发布
- 🎨 现代 UI - Tailwind CSS + shadcn/ui 组件库
- 🚀 一键部署 - GitHub Actions + PM2 + Nginx
Spring Lament Blog/
├── src/
│ ├── app/ # Next.js App Router 页面
│ │ ├── admin/ # 后台管理页面
│ │ ├── api/ # API 路由
│ │ ├── auth/ # 认证相关页面
│ │ └── posts/ # 文章展示页面
│ ├── components/ # React 组件
│ │ ├── admin/ # 管理后台组件
│ │ ├── ui/ # 基础 UI 组件
│ │ └── markdown/ # Markdown 渲染组件
│ ├── lib/ # 工具库
│ └── types/ # TypeScript 类型定义
├── prisma/ # 数据库相关
│ ├── schema.prisma # 数据模型定义
│ └── migrations/ # 数据库迁移文件
├── docs/ # 项目文档
├── public/ # 静态资源
└── scripts/ # 部署脚本
- 框架: Next.js 15 + TypeScript
- 数据库: Prisma + SQLite
- 认证: NextAuth.js
- 样式: Tailwind CSS + shadcn/ui
- 部署: PM2 + Nginx + GitHub Actions
- Node.js >= 18.17.0
- npm >= 9.0.0
# 1. 克隆项目
git clone https://github.com/flawlessv/Spring-Lament-Blog.git
cd Spring-Lament-Blog
# 2. 安装依赖
npm install
# 3. 配置环境变量
cp .env.example .env.local
# 编辑 .env.local 文件,配置数据库和认证信息
# 4. 初始化数据库
npm run db:push
npm run db:seed
# 5. 启动开发服务器
npm run dev
# 6. 访问应用
# 前台: http://localhost:3000
# 后台: http://localhost:3000/admin# 开发
npm run dev # 启动开发服务器
npm run build # 构建生产版本
npm run start # 启动生产服务器
# 数据库
npm run db:push # 推送数据库 schema
npm run db:seed # 填充初始数据
npm run db:studio # 打开 Prisma Studio
# 代码质量
npm run lint # 代码检查
npm run format # 代码格式化最近部门在大力推全栈开发,作为一名前端开发者,想要入门全栈开发,那么这份指南就是为你准备的。我们将通过一个真实的博客项目(Spring Lament Blog),从零开始学习如何使用Next.js 15构建现代化的全栈应用以及后端、数据库、部署等相关知识简介。
学习路径:先概念 → 再技术 → 后实践
- 概念理解篇(第1-3章):后端本质、Node.js、Next.js框架
- 技术深入篇(第4-7章):App Router、数据流转、Prisma ORM、数据模型
- 项目实战篇(第8-10章):项目结构、认证系统、CRUD操作
- 部署运维篇(第11-12章):部署实战、性能优化
**后端(Backend)**是应用程序的服务器端部分,负责处理业务逻辑、数据管理和服务器通信。简单来说,后端就是"管数据的"。
前端职责:
- 用户界面展示、用户交互、数据展示、用户体验
后端职责:
- 数据存储、业务逻辑、API接口、安全控制
- 大势所趋:目前Vibe Coding盛行,AI全栈开发工程师可能是未来趋势
- 职业发展:全栈开发者更受市场欢迎
- 项目理解:知道数据如何流转,写出更好的前端代码
- 独立开发:可以独立完成整个项目
传统开发需要前端项目+后端项目+数据库,而Next.js全栈框架可以:
- 一个项目包含前后端
- 统一的代码库和部署流程
- 更好的开发体验和性能优化
在开始学习Node.js之前,我们需要理解一个核心概念:Runtime(运行时)。
一段JavaScript代码本质上就是字符串:
console.log("hello world");这段字符串能被执行吗?不能,它需要运行环境。
Runtime就是代码的执行环境。没有Runtime,代码就无法执行,就是一堆字符串。
浏览器Runtime:
- 内置JavaScript解释器
- 提供DOM、BOM等浏览器API
- 只能运行在浏览器中
- 主要用于用户界面开发
Node.js Runtime:
- 基于Chrome V8引擎
- 提供文件系统、HTTP等服务器API
- 可以运行在任何操作系统
- 主要用于服务器端开发
Node.js作为服务器端JavaScript运行时,提供了以下核心能力:
const fs = require("fs");
const path = require("path");
// 读取文件
const data = fs.readFileSync("data.txt", "utf8");
console.log(data);
// 写入文件
fs.writeFileSync("output.txt", "Hello Node.js", "utf8");const http = require("http");
const server = http.createServer((req, res) => {
res.setHeader("Content-Type", "application/json");
res.end(JSON.stringify({ message: "Hello from Node.js" }));
});
server.listen(3000, () => {
console.log("服务器运行在 http://localhost:3000");
});// 使用Prisma ORM操作数据库
const { PrismaClient } = require("@prisma/client");
const prisma = new PrismaClient();
// 创建用户
await prisma.user.create({
data: {
name: "张三",
email: "[email protected]",
},
});
// 查询用户
const users = await prisma.user.findMany();// 调用外部API
const response = await fetch("https://api.example.com/data");
const data = await response.json();对于前端同学来说,选择Node.js学习后端有以下优势:
- 语言统一:前后端都用JavaScript,无需学习新语言
- 生态丰富:npm包管理器,海量第三方库
- 性能优秀:基于V8引擎,执行效率高
- 社区活跃:大量教程和开源项目
通过本章,你应该理解:
- Runtime是代码的执行环境
- Node.js提供了服务器端JavaScript运行能力
- Node.js的核心功能:文件操作、HTTP服务、数据库操作
- 为什么前端同学选择Node.js学习后端最合适
在下一章,我们将学习Next.js,这是一个基于Node.js的全栈框架,让全栈开发变得更加简单。
在传统的Web开发中,我们需要维护多个独立的项目:
项目结构:
├── frontend/ # React前端项目
│ ├── src/
│ │ ├── components/ # React组件
│ │ ├── pages/ # 页面组件
│ │ ├── hooks/ # 自定义Hook
│ │ ├── utils/ # 工具函数
│ │ ├── services/ # API调用
│ │ ├── store/ # 状态管理
│ │ └── types/ # TypeScript类型
│ ├── public/ # 静态资源
│ ├── package.json
│ └── ...
├── backend/ # Node.js后端项目
│ ├── src/
│ │ ├── controllers/ # 控制器
│ │ ├── services/ # 业务逻辑
│ │ ├── models/ # 数据模型
│ │ ├── routes/ # 路由定义
│ │ ├── middleware/ # 中间件
│ │ ├── utils/ # 工具函数
│ │ └── config/ # 配置文件
│ ├── package.json
│ └── ...
└── database/ # 数据库
├── migrations/ # 数据库迁移
├── seeds/ # 初始数据
└── schema.sql # 数据库架构
这种方式的缺点:
- 开发复杂:需要同时维护多个项目
- 部署复杂:需要分别部署前端和后端
- 协调困难:前后端接口需要协商
- 类型安全:前后端数据类型不一致
Next.js是Vercel开发的React全栈框架,解决了传统开发的问题:
一个项目,前后端统一:
项目结构:
├── app/ # 页面和API路由
│ ├── page.tsx # 前端页面
│ ├── api/ # 后端API
│ └── layout.tsx # 布局组件
├── components/ # React组件
├── lib/ # 工具函数
└── prisma/ # 数据库
Next.js 15引入了基于Rust的Turbopack构建系统,相比传统的Webpack有显著优势:
Turbopack优势:
- 启动速度快:开发环境启动速度比Webpack快10倍
- 增量编译:只编译变更的部分,开发时几乎瞬时更新
- 内存占用低:更高效的内存使用,减少内存泄漏
- 原生支持:原生支持TypeScript、JSX、CSS等
- 未来架构:基于Rust,为Next.js未来发展奠定基础
对比效果:
# Webpack (传统)
npm run dev # 启动时间: 10-30秒
# 文件变更后刷新: 2-5秒
# Turbopack (Next.js 15)
npm run dev # 启动时间: 1-3秒
# 文件变更后刷新: <100msNext.js使用文件系统作为路由系统,非常直观:
app/
├── page.tsx → /
├── about/page.tsx → /about
├── posts/
│ ├── page.tsx → /posts
│ └── [slug]/
│ └── page.tsx → /posts/hello-world
└── api/
└── posts/
└── route.ts → /api/posts
Next.js支持多种渲染模式:
- SSR:服务端渲染,SEO友好
- SSG:静态生成,性能最佳
- ISR:增量静态再生,平衡性能和更新
在Next.js中,API路由就是普通的文件:
// app/api/posts/route.ts
export async function GET() {
const posts = await prisma.post.findMany();
return Response.json(posts);
}
export async function POST(request: Request) {
const data = await request.json();
const post = await prisma.post.create({ data });
return Response.json(post);
}Next.js + TypeScript提供端到端的类型安全:
// 前端组件
interface Post {
id: string;
title: string;
content: string;
}
// API路由
export async function GET(): Promise<Response<Post[]>> {
// 类型安全的数据查询
}- 学习成本低:基于React,前端同学容易上手
- 开发效率高:约定大于配置,减少样板代码
- 性能优秀:自动代码分割、图片优化、缓存策略
- 生态完善:丰富的插件和工具链
- 部署简单:支持Vercel一键部署
通过本章,你应该理解:
- 传统前后端分离开发的痛点
- Next.js如何解决这些问题
- Next.js的核心优势:文件路由、SSR、API路由、类型安全
- 为什么Next.js是前端同学学习全栈的最佳选择
在下一章,我们将深入学习Next.js 15的App Router,这是Next.js最新的路由系统。
本指南基于Next.js 15.0.0版本,这是Next.js的最新稳定版本,带来了许多性能优化和新特性。
Next.js遵循"约定大于配置"的设计理念,通过文件命名和目录结构来定义应用的行为,而不是通过复杂的配置文件。
在Next.js 15的App Router中,每个文件都有特定的作用:
// app/posts/page.tsx
export default function PostsPage() {
return <div>文章列表页面</div>
}这个文件自动成为/posts路由的页面组件。
// app/api/posts/route.ts
export async function GET() {
return Response.json({ message: "获取文章列表" });
}
export async function POST(request: Request) {
const data = await request.json();
return Response.json({ message: "创建文章", data });
}这个文件自动成为/api/posts的API端点。
// app/layout.tsx
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="zh">
<body>
<header>网站头部</header>
{children}
<footer>网站底部</footer>
</body>
</html>
)
}布局组件会包裹所有子页面。
// app/posts/loading.tsx
export default function Loading() {
return <div>加载中...</div>
}当页面加载时自动显示。
// app/posts/error.tsx
'use client'
export default function Error({
error,
reset,
}: {
error: Error & { digest?: string }
reset: () => void
}) {
return (
<div>
<h2>出错了!</h2>
<button onClick={() => reset()}>重试</button>
</div>
)
}当页面出错时自动显示。
Next.js 15使用文件系统来定义路由,非常直观:
app/
├── page.tsx → /
├── about/
│ └── page.tsx → /about
├── posts/
│ ├── page.tsx → /posts
│ ├── loading.tsx → 加载状态
│ ├── error.tsx → 错误处理
│ └── [slug]/
│ ├── page.tsx → /posts/hello-world
│ └── not-found.tsx → 404页面
└── api/
├── posts/
│ └── route.ts → /api/posts
└── posts/
└── [id]/
└── route.ts → /api/posts/123
Next.js 15默认使用Server Components,但也可以使用Client Components:
// app/posts/page.tsx - 服务端组件
import { prisma } from '@/lib/prisma'
export default async function PostsPage() {
// 在服务端执行,可以直接访问数据库
const posts = await prisma.post.findMany()
return (
<div>
{posts.map(post => (
<div key={post.id}>{post.title}</div>
))}
</div>
)
}// app/components/PostForm.tsx - 客户端组件
'use client'
import { useState } from 'react'
export default function PostForm() {
const [title, setTitle] = useState('')
const handleSubmit = () => {
// 客户端交互逻辑
}
return (
<form onSubmit={handleSubmit}>
<input
value={title}
onChange={(e) => setTitle(e.target.value)}
/>
</form>
)
}Next.js支持动态路由,使用方括号语法:
// app/posts/[slug]/page.tsx
interface Props {
params: { slug: string }
}
export default async function PostPage({ params }: Props) {
const post = await prisma.post.findUnique({
where: { slug: params.slug }
})
return <div>{post?.title}</div>
}通过本章,你应该理解:
- Next.js 15的App Router核心概念
- 各种文件类型的作用:page.tsx、route.ts、layout.tsx等
- 文件系统路由的规则和约定
- Server Components和Client Components的区别
- 动态路由的使用方法
在下一章,我们将通过博客项目的实际代码,学习完整的数据流转过程。
Spring Lament Blog是一个基于Next.js 15的现代化博客系统,包含:
- 前台功能:文章展示、分类浏览、标签筛选
- 后台管理:文章CRUD、用户管理、数据统计
- 技术栈:Next.js 15 + Prisma + NextAuth + SQLite
让我们通过一个具体的例子,看看数据是如何在系统中流转的:
1. 用户访问URL
用户访问:/posts/hello-world
2. 路由匹配
app/posts/[slug]/page.tsx
3. 服务端组件执行
// app/posts/[slug]/page.tsx
import { prisma } from '@/lib/prisma'
interface Props {
params: { slug: string }
}
export default async function PostPage({ params }: Props) {
// 1. 从数据库查询文章数据
const post = await prisma.post.findUnique({
where: { slug: params.slug },
include: {
author: { select: { name: true, avatar: true } },
category: true,
tags: true
}
})
if (!post) {
return <div>文章不存在</div>
}
// 2. 渲染页面
return (
<article>
<h1>{post.title}</h1>
<div>{post.content}</div>
<div>作者:{post.author.name}</div>
<div>分类:{post.category.name}</div>
</article>
)
}4. 数据库查询
// lib/prisma.ts
import { PrismaClient } from "@prisma/client";
const globalForPrisma = globalThis as unknown as {
prisma: PrismaClient | undefined;
};
export const prisma = globalForPrisma.prisma ?? new PrismaClient();5. 返回渲染结果
- 服务端渲染完成
- 返回HTML给浏览器
- 浏览器显示页面
1. 前端表单提交
// app/admin/posts/new/page.tsx
'use client'
export default function NewPostPage() {
const handleSubmit = async (data: FormData) => {
const response = await fetch('/api/admin/posts', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
})
if (response.ok) {
router.push('/admin/posts')
}
}
return <PostForm onSubmit={handleSubmit} />
}2. API路由处理
// app/api/admin/posts/route.ts
import { prisma } from "@/lib/prisma";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
export async function POST(request: Request) {
// 1. 验证用户身份
const session = await getServerSession(authOptions);
if (!session) {
return Response.json({ error: "未授权" }, { status: 401 });
}
// 2. 解析请求数据
const data = await request.json();
// 3. 数据验证
if (!data.title || !data.content) {
return Response.json({ error: "标题和内容不能为空" }, { status: 400 });
}
// 4. 保存到数据库
const post = await prisma.post.create({
data: {
title: data.title,
content: data.content,
slug: generateSlug(data.title),
authorId: session.user.id,
},
});
// 5. 返回结果
return Response.json({ post });
}3. 数据库操作
// Prisma自动生成的SQL
INSERT INTO Post (title, content, slug, authorId)
VALUES (?, ?, ?, ?)// 共享类型定义
interface Post {
id: string;
title: string;
content: string;
slug: string;
authorId: string;
}
// 前端使用
const [posts, setPosts] = useState<Post[]>([]);
// API路由使用
export async function GET(): Promise<Response<Post[]>> {
const posts = await prisma.post.findMany();
return Response.json(posts);
}// lib/utils.ts - 共享工具函数
export function generateSlug(title: string): string {
return title
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");
}
// 前端使用
const slug = generateSlug(formData.title);
// 后端使用
const post = await prisma.post.create({
data: { slug: generateSlug(data.title) },
});# 一个命令部署整个应用
npm run build
npm start通过本章,你应该理解:
- 完整的数据流转过程:用户请求 → 路由匹配 → 服务端组件 → 数据库查询 → 渲染返回
- API路由的处理流程:请求验证 → 数据解析 → 业务逻辑 → 数据库操作 → 响应返回
- 前后端统一开发的优势:类型安全、代码复用、统一部署
- 如何在Next.js中实现完整的数据流转
在下一章,我们将深入学习Prisma ORM,这是操作数据库的核心工具。
**ORM(Object-Relational Mapping)**是对象关系映射,是一种编程技术,用于在面向对象编程语言中管理关系型数据库。
简单来说,ORM让我们可以用面向对象的方式操作数据库,而不需要写SQL语句。
-- 创建用户
INSERT INTO users (name, email, password)
VALUES ('张三', '[email protected]', 'hashed_password');
-- 查询用户
SELECT * FROM users WHERE email = '[email protected]';
-- 更新用户
UPDATE users SET name = '李四' WHERE id = 1;
-- 删除用户
DELETE FROM users WHERE id = 1;// 创建用户
await prisma.user.create({
data: {
name: "张三",
email: "[email protected]",
password: "hashed_password",
},
});
// 查询用户
const user = await prisma.user.findUnique({
where: { email: "[email protected]" },
});
// 更新用户
await prisma.user.update({
where: { id: 1 },
data: { name: "李四" },
});
// 删除用户
await prisma.user.delete({
where: { id: 1 },
});Prisma使用schema.prisma文件来定义数据库结构:
// prisma/schema.prisma
// Prisma客户端生成器配置,指定生成JavaScript/TypeScript客户端
generator client {
provider = "prisma-client-js"
}
// 数据库连接配置,指定数据库类型和连接URL
datasource db {
provider = "sqlite"
url = env("DATABASE_URL")
}
// 用户数据模型定义
model User {
id String @id @default(cuid())
email String @unique
name String?
password String
role Role @default(USER)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
posts Post[]
profile Profile?
@@map("users")
}
// @relation装饰器用于定义表之间的关联关系,指定外键字段和引用字段
model Post {
id String @id @default(cuid())
title String
slug String @unique
content String
published Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
authorId String
author User @relation(fields: [authorId], references: [id])
categoryId String?
category Category? @relation(fields: [categoryId], references: [id])
tags PostTag[]
@@map("posts")
}
model Category {
id String @id @default(cuid())
name String @unique
slug String @unique
description String?
posts Post[]
@@map("categories")
}
model Tag {
id String @id @default(cuid())
name String @unique
slug String @unique
color String?
posts PostTag[]
@@map("tags")
}
model PostTag {
postId String
post Post @relation(fields: [postId], references: [id])
tagId String
tag Tag @relation(fields: [tagId], references: [id])
@@id([postId, tagId])
@@map("post_tags")
}
model Profile {
id String @id @default(cuid())
bio String?
avatar String?
userId String @unique
user User @relation(fields: [userId], references: [id])
@@map("profiles")
}
enum Role {
USER
ADMIN
}model User {
id String @id @default(cuid()) // 主键,自动生成ID
email String @unique // 唯一字段
name String? // 可选字段
password String // 必填字段
role Role @default(USER) // 枚举类型,默认值
createdAt DateTime @default(now()) // 时间戳,默认当前时间
updatedAt DateTime @updatedAt // 更新时间,自动维护
}一对多关系 (One-to-Many)
model User {
id String @id @default(cuid())
posts Post[] // 一个用户可以有多个文章
}
model Post {
id String @id @default(cuid())
authorId String
author User @relation(fields: [authorId], references: [id])
}一对一关系 (One-to-One)
model User {
id String @id @default(cuid())
profile Profile? // 一个用户最多有一个资料
}
model Profile {
id String @id @default(cuid())
userId String @unique
user User @relation(fields: [userId], references: [id])
}多对多关系 (Many-to-Many)
model Post {
id String @id @default(cuid())
tags PostTag[] // 通过中间表实现多对多
}
model Tag {
id String @id @default(cuid())
posts PostTag[]
}
model PostTag {
postId String
post Post @relation(fields: [postId], references: [id])
tagId String
tag Tag @relation(fields: [tagId], references: [id])
@@id([postId, tagId]) // 复合主键
}// 创建单个记录
const user = await prisma.user.create({
data: {
name: "张三",
email: "[email protected]",
password: "hashed_password",
},
});
// 创建关联数据
const post = await prisma.post.create({
data: {
title: "我的第一篇文章",
content: "文章内容...",
slug: "my-first-post",
author: {
connect: { id: user.id },
},
},
});// 查询所有记录
const users = await prisma.user.findMany();
// 查询单个记录
const user = await prisma.user.findUnique({
where: { email: "[email protected]" },
});
// 条件查询
const posts = await prisma.post.findMany({
where: {
published: true,
author: {
name: "张三",
},
},
});
// 关联查询 - 一次性获取文章及其作者、分类、标签信息
const postsWithAuthor = await prisma.post.findMany({
include: {
author: true,
category: true,
tags: true,
},
});
// 查询结果示例:
// [
// {
// id: "1",
// title: "Next.js指南",
// content: "...",
// author: { id: "1", name: "张三", email: "zhang@example.com" },
// category: { id: "1", name: "前端技术" },
// tags: [{ id: "1", name: "Next.js" }, { id: "2", name: "React" }]
// }
// ]
// 对比:普通查询 - 只获取文章基本信息
const posts = await prisma.post.findMany();
// 查询结果示例:
// [
// {
// id: "1",
// title: "Next.js指南",
// content: "...",
// authorId: "1", // 只有ID,没有作者详细信息
// categoryId: "1" // 只有ID,没有分类详细信息
// }
// ]
// 如果用普通查询获取完整信息,需要多次查询:
const posts2 = await prisma.post.findMany();
const authors = await prisma.user.findMany();
const categories = await prisma.category.findMany();
// 然后在代码中手动关联数据...// 更新单个记录
const updatedUser = await prisma.user.update({
where: { id: user.id },
data: { name: "李四" },
});
// 批量更新
await prisma.post.updateMany({
where: { published: false },
data: { published: true },
});// 删除单个记录
await prisma.user.delete({
where: { id: user.id },
});
// 批量删除
await prisma.post.deleteMany({
where: { published: false },
});当Schema发生变化时,需要运行迁移来更新数据库。以下是完整的Prisma使用流程:
1. 安装Prisma
# 安装Prisma CLI和客户端
npm install prisma @prisma/client2. 初始化Prisma
# 初始化Prisma配置
npx prisma init3. 编写Schema
编辑prisma/schema.prisma文件定义数据模型
4. 生成Prisma Client
# 生成TypeScript类型化的Prisma客户端
npx prisma generate这一步会:
- 根据schema.prisma生成Prisma Client代码
- 创建TypeScript类型定义
- 在node_modules/.prisma/client中生成客户端代码
5. 推送Schema到数据库
# 将schema同步到数据库(适用于开发环境)
npx prisma db push这一步会:
- 读取schema.prisma文件
- 创建或更新数据库表结构
- 不生成migration文件
6. 创建Migration(生产环境推荐)
# 生成迁移文件
npx prisma migrate dev --name add-user-role这一步会:
- 创建migration文件记录schema变更
- 应用变更到数据库
- 自动运行
prisma generate
7. 应用Migration到生产环境
# 在生产环境应用migration
npx prisma migrate deploy流程对比:
| 场景 | 使用命令 | 说明 |
|---|---|---|
| 开发环境快速测试 | prisma db push |
快速同步,不记录变更历史 |
| 正式开发 | prisma migrate dev |
记录变更历史,可回滚 |
| 生产部署 | prisma migrate deploy |
安全地应用所有migration |
通过本章,你应该理解:
- ORM的概念和优势
- Prisma Schema的定义方法
- 各种字段类型和约束
- 表关联关系的设计
- 基本的CRUD操作
- Migration迁移的作用
在下一章,我们将深入分析博客项目的数据模型,学习如何设计复杂的数据结构。
Spring Lament Blog的数据模型包含以下核心实体:
User (用户)
├── Profile (个人资料) - 一对一
├── Post (文章) - 一对多
└── Role (角色) - 枚举
Post (文章)
├── User (作者) - 多对一 (多篇文章对应一个作者)
├── Category (分类) - 多对一 (多篇文章对应一个分类)
└── Tag (标签) - 多对多 (多篇文章对应多个标签)
Category (分类)
└── Post (文章) - 一对多
Tag (标签)
└── Post (文章) - 多对多
model User {
id String @id @default(cuid())
email String @unique
name String?
password String
role Role @default(USER)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
posts Post[]
profile Profile?
@@map("users")
}字段说明:
id: 主键,使用cuid()生成唯一IDemail: 邮箱,唯一约束,用于登录name: 姓名,可选字段password: 密码,存储加密后的哈希值role: 角色,枚举类型(USER/ADMIN)createdAt/updatedAt: 时间戳,自动维护
实际应用:
// 创建管理员用户
const admin = await prisma.user.create({
data: {
email: "[email protected]",
name: "博客管理员",
password: await bcrypt.hash("password123", 10),
role: "ADMIN",
},
});
// 查询用户及其文章
const userWithPosts = await prisma.user.findUnique({
where: { id: userId },
include: {
posts: {
where: { published: true },
orderBy: { createdAt: "desc" },
},
},
});model Post {
id String @id @default(cuid())
title String
slug String @unique
content String
excerpt String?
coverImage String?
published Boolean @default(false)
publishedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
authorId String
author User @relation(fields: [authorId], references: [id])
categoryId String?
category Category? @relation(fields: [categoryId], references: [id])
tags PostTag[]
@@map("posts")
}字段说明:
title: 文章标题slug: URL友好的标识符,唯一约束content: 文章内容(Markdown格式)excerpt: 文章摘要,可选coverImage: 封面图片URL,可选published: 发布状态,默认草稿publishedAt: 发布时间,可选
实际应用:
// 创建文章
const post = await prisma.post.create({
data: {
title: "Next.js全栈开发指南",
slug: "nextjs-fullstack-guide",
content: "# 指南内容...",
excerpt: "学习Next.js全栈开发",
authorId: user.id,
categoryId: category.id,
tags: {
create: [
{ tag: { connect: { id: tag1.id } } },
{ tag: { connect: { id: tag2.id } } },
],
},
},
});
// 查询文章详情(包含所有关联数据)
const postDetail = await prisma.post.findUnique({
where: { slug: "nextjs-fullstack-guide" },
include: {
author: { select: { name: true, avatar: true } },
category: true,
tags: { include: { tag: true } },
},
});model Category {
id String @id @default(cuid())
name String @unique
slug String @unique
description String?
posts Post[]
@@map("categories")
}实际应用:
// 创建分类
const category = await prisma.category.create({
data: {
name: "前端技术",
slug: "frontend",
description: "前端开发相关文章",
},
});
// 查询分类及其文章数量
const categoriesWithCount = await prisma.category.findMany({
include: {
_count: {
select: { posts: true },
},
},
});model Tag {
id String @id @default(cuid())
name String @unique
slug String @unique
color String?
posts PostTag[]
@@map("tags")
}实际应用:
// 创建标签
const tag = await prisma.tag.create({
data: {
name: "Next.js",
slug: "nextjs",
color: "#000000",
},
});
// 查询热门标签
const popularTags = await prisma.tag.findMany({
include: {
_count: {
select: { posts: true },
},
},
orderBy: {
posts: { _count: "desc" },
},
take: 10,
});model PostTag {
postId String
post Post @relation(fields: [postId], references: [id])
tagId String
tag Tag @relation(fields: [tagId], references: [id])
@@id([postId, tagId])
@@map("post_tags")
}实际应用:
// 为文章添加标签
await prisma.postTag.create({
data: {
postId: post.id,
tagId: tag.id,
},
});
// 查询文章的所有标签
const postWithTags = await prisma.post.findUnique({
where: { id: postId },
include: {
tags: {
include: { tag: true },
},
},
});const posts = await prisma.post.findMany({
where: { published: true },
include: {
author: { select: { name: true, avatar: true } },
category: true,
tags: { include: { tag: true } },
},
orderBy: { publishedAt: "desc" },
skip: (page - 1) * limit, // 跳过前面的记录数,实现分页
take: limit, // 限制返回的记录数量
});const postsByCategory = await prisma.post.findMany({
where: {
published: true,
category: {
slug: "frontend",
},
},
include: {
author: true,
category: true,
},
});const tagCloud = await prisma.tag.findMany({
include: {
_count: {
select: { posts: true },
},
},
orderBy: {
posts: { _count: "desc" },
},
});- 避免数据冗余
- 使用外键建立关联
- 合理使用索引
- 主键使用cuid()而非自增ID
- 为常用查询字段添加索引
- 使用include控制查询深度
- 预留可选字段
- 使用枚举类型
- 考虑未来需求
通过本章,你应该理解:
- 博客项目的完整数据模型设计
- 各种关联关系的实际应用
- 复杂查询的实现方法
- 数据模型设计的最佳实践
- 如何在Prisma中实现复杂的业务逻辑
在下一章,我们将学习项目的整体结构,了解各个目录和文件的作用。
Spring Lament Blog采用Next.js 15的App Router架构,目录结构如下:
Spring-Lament-Blog/
├── src/ # 源代码目录
│ ├── app/ # Next.js App Router
│ │ ├── admin/ # 管理后台页面
│ │ │ ├── layout.tsx # 后台布局
│ │ │ ├── page.tsx # 后台首页
│ │ │ ├── posts/ # 文章管理
│ │ │ │ ├── page.tsx # 文章列表
│ │ │ │ ├── new/ # 新建文章
│ │ │ │ └── [id]/ # 编辑文章
│ │ │ ├── categories/ # 分类管理
│ │ │ ├── tags/ # 标签管理
│ │ │ └── profile/ # 个人资料
│ │ ├── api/ # API路由
│ │ │ ├── admin/ # 后台API
│ │ │ │ ├── posts/ # 文章API
│ │ │ │ ├── categories/ # 分类API
│ │ │ │ └── tags/ # 标签API
│ │ │ ├── auth/ # 认证API
│ │ │ └── posts/ # 公开API
│ │ ├── posts/ # 文章展示页面
│ │ │ └── [slug]/ # 文章详情
│ │ ├── category/ # 分类页面
│ │ ├── login/ # 登录页面
│ │ ├── layout.tsx # 根布局
│ │ └── page.tsx # 首页
│ ├── components/ # React组件
│ │ ├── admin/ # 后台组件
│ │ │ ├── post-editor.tsx # 文章编辑器
│ │ │ ├── unified-posts-table.tsx # 文章表格
│ │ │ └── ...
│ │ ├── ui/ # shadcn/ui组件
│ │ │ ├── button.tsx # 按钮组件
│ │ │ ├── form.tsx # 表单组件
│ │ │ └── ...
│ │ ├── markdown/ # Markdown组件
│ │ │ ├── markdown-renderer.tsx
│ │ │ └── code-block.tsx
│ │ └── layout/ # 布局组件
│ ├── lib/ # 工具函数库
│ │ ├── auth.ts # NextAuth配置
│ │ ├── prisma.ts # Prisma客户端
│ │ └── utils.ts # 通用工具
│ └── types/ # TypeScript类型
├── prisma/ # 数据库相关
│ ├── schema.prisma # 数据模型定义
│ ├── seed.ts # 初始数据
│ └── dev.db # SQLite数据库
├── public/ # 静态资源
├── docs/ # 项目文档
├── scripts/ # 部署脚本
├── package.json # 项目配置
├── next.config.js # Next.js配置
├── tailwind.config.ts # Tailwind配置
└── tsconfig.json # TypeScript配置
页面路由 (Pages)
// app/page.tsx - 首页
export default function HomePage() {
return <div>博客首页</div>
}
// app/posts/[slug]/page.tsx - 文章详情页
interface Props {
params: { slug: string }
}
export default async function PostPage({ params }: Props) {
const post = await prisma.post.findUnique({
where: { slug: params.slug }
})
return <div>{post?.title}</div>
}API路由 (API Routes)
// app/api/posts/route.ts - 文章API
export async function GET() {
const posts = await prisma.post.findMany();
return Response.json(posts);
}
export async function POST(request: Request) {
const data = await request.json();
const post = await prisma.post.create({ data });
return Response.json(post);
}布局组件 (Layouts)
// app/layout.tsx - 根布局
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="zh">
<body>
<Header />
{children}
<Footer />
</body>
</html>
)
}
// app/admin/layout.tsx - 后台布局
export default function AdminLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<div className="min-h-screen bg-gray-50">
<AdminSidebar />
<main className="ml-64">
{children}
</main>
</div>
)
}后台组件 (Admin Components)
// components/admin/post-editor.tsx
'use client'
export default function PostEditor({ post }: { post?: Post }) {
const [title, setTitle] = useState(post?.title || '')
const [content, setContent] = useState(post?.content || '')
const handleSubmit = async () => {
// 提交逻辑
}
return (
<form onSubmit={handleSubmit}>
<input
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="文章标题"
/>
<textarea
value={content}
onChange={(e) => setContent(e.target.value)}
placeholder="文章内容"
/>
<button type="submit">保存</button>
</form>
)
}UI组件 (shadcn/ui)
// components/ui/button.tsx
import { cn } from "@/lib/utils"
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
variant?: "default" | "destructive" | "outline" | "secondary"
size?: "default" | "sm" | "lg"
}
export function Button({ className, variant, size, ...props }: ButtonProps) {
return (
<button
className={cn(
"inline-flex items-center justify-center rounded-md text-sm font-medium",
{
"bg-primary text-primary-foreground hover:bg-primary/90": variant === "default",
"bg-destructive text-destructive-foreground hover:bg-destructive/90": variant === "destructive",
},
className
)}
{...props}
/>
)
}认证配置 (auth.ts)
// lib/auth.ts
import { NextAuthOptions } from "next-auth";
import CredentialsProvider from "next-auth/providers/credentials";
import { prisma } from "./prisma";
import bcrypt from "bcryptjs";
export const authOptions: NextAuthOptions = {
providers: [
CredentialsProvider({
name: "credentials",
credentials: {
email: { label: "邮箱", type: "email" },
password: { label: "密码", type: "password" },
},
async authorize(credentials) {
if (!credentials?.email || !credentials?.password) {
return null;
}
const user = await prisma.user.findUnique({
where: { email: credentials.email },
});
if (!user) return null;
const isPasswordValid = await bcrypt.compare(
credentials.password,
user.password
);
if (!isPasswordValid) return null;
return {
id: user.id,
email: user.email,
name: user.name,
role: user.role,
};
},
}),
],
session: {
strategy: "jwt",
},
pages: {
signIn: "/login",
},
};Prisma客户端 (prisma.ts)
// lib/prisma.ts
import { PrismaClient } from "@prisma/client";
const globalForPrisma = globalThis as unknown as {
prisma: PrismaClient | undefined;
};
export const prisma = globalForPrisma.prisma ?? new PrismaClient();
if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma;通用工具 (utils.ts)
// lib/utils.ts
import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
export function generateSlug(title: string): string {
return title
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");
}
export function formatDate(date: Date): string {
return new Intl.DateTimeFormat("zh-CN", {
year: "numeric",
month: "long",
day: "numeric",
}).format(date);
}Schema定义 (schema.prisma)
// prisma/schema.prisma
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "sqlite"
url = env("DATABASE_URL")
}
model User {
id String @id @default(cuid())
email String @unique
name String?
password String
role Role @default(USER)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
posts Post[]
profile Profile?
@@map("users")
}
// ... 其他模型定义初始数据 (seed.ts)
// prisma/seed.ts
import { PrismaClient } from "@prisma/client";
import bcrypt from "bcryptjs";
const prisma = new PrismaClient();
async function main() {
// 创建管理员用户
const adminPassword = await bcrypt.hash("admin123", 10);
const admin = await prisma.user.upsert({
where: { email: "[email protected]" },
update: {},
create: {
email: "[email protected]",
name: "博客管理员",
password: adminPassword,
role: "ADMIN",
},
});
// 创建默认分类
const categories = await Promise.all([
prisma.category.create({
data: {
name: "前端技术",
slug: "frontend",
description: "前端开发相关文章",
},
}),
prisma.category.create({
data: {
name: "后端技术",
slug: "backend",
description: "后端开发相关文章",
},
}),
]);
console.log("种子数据创建完成");
}
main()
.then(async () => {
await prisma.$disconnect();
})
.catch(async (e) => {
console.error(e);
await prisma.$disconnect();
process.exit(1);
});{
"name": "spring-lament-blog",
"version": "0.1.0",
"scripts": {
"dev": "next dev -p 7777",
"build": "next build",
"start": "next start -p 3000",
"db:push": "prisma db push",
"db:seed": "tsx prisma/seed.ts"
},
"dependencies": {
"next": "15.0.0",
"react": "^18",
"@prisma/client": "6.16.1",
"next-auth": "^4.24.11"
}
}/** @type {import('next').NextConfig} */
const nextConfig = {
experimental: {
serverComponentsExternalPackages: ["@prisma/client"],
},
};
module.exports = nextConfig;通过本章,你应该理解:
- Next.js 15项目的完整目录结构
- 各个目录和文件的作用
- 页面路由和API路由的组织方式
- 组件库的层次结构
- 工具函数库的设计
- 数据库相关的文件组织
- 配置文件的作用
在下一章,我们将学习NextAuth认证系统,这是保护后台功能的关键。
在博客系统中,我们需要区分不同的用户角色:
- 普通用户:只能查看文章,不能编辑
- 管理员:可以管理文章、分类、标签
- 未登录用户:只能访问公开内容
认证系统确保只有授权用户才能访问受保护的资源。
NextAuth.js是Next.js生态中最流行的认证解决方案,支持多种认证方式:
- Credentials Provider:用户名密码登录
- OAuth Providers:Google、GitHub等第三方登录
- Database Sessions:数据库会话管理
- JWT Sessions:JWT令牌管理
// lib/auth.ts
export const authOptions: NextAuthOptions = {
providers: [
CredentialsProvider({
async authorize(credentials) {
const user = await prisma.user.findUnique({
where: { email: credentials.email },
});
const isPasswordValid = await bcrypt.compare(
credentials.password,
user.password
);
return isPasswordValid ? user : null;
},
}),
],
session: { strategy: "jwt" },
};// 注册时加密密码
const hashedPassword = await bcrypt.hash(password, 10);
// 登录时验证密码
const isPasswordValid = await bcrypt.compare(password, user.password);Next.js的Middleware可以在请求到达页面前进行拦截和验证:
// middleware.ts
import { withAuth } from "next-auth/middleware";
export default withAuth({
pages: {
signIn: "/login",
},
callbacks: {
authorized({ req, token }) {
// 检查是否访问后台路由
if (req.nextUrl.pathname.startsWith("/admin")) {
return token?.role === "ADMIN";
}
return !!token;
},
},
});
export const config = {
matcher: ["/admin/:path*", "/api/admin/:path*"],
};工作原理:
- 用户访问
/admin/*路由时 - Middleware检查是否有有效的session
- 检查用户角色是否为ADMIN
- 如果验证失败,重定向到登录页
在服务端组件中:
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
const session = await getServerSession(authOptions);
console.log(session?.user); // { id, email, name, role }在客户端组件中:
"use client";
import { useSession } from "next-auth/react";
const { data: session, status } = useSession();
if (status === "authenticated") {
console.log(session.user);
}// app/api/admin/posts/route.ts
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
export async function POST(request: Request) {
const session = await getServerSession(authOptions);
if (!session || session.user.role !== "ADMIN") {
return Response.json({ error: "未授权" }, { status: 401 });
}
// 继续处理请求...
}通过本章,你应该理解:
- 认证系统的必要性和工作原理
- NextAuth.js的配置和使用
- 密码加密和验证流程
- Middleware如何保护路由
- 如何在服务端和客户端获取用户信息
开发环境(Development):
- 本地电脑
- 端口7777
- SQLite数据库(
dev.db) .env.local配置文件- 热更新,调试模式
生产环境(Production):
- 远程服务器
- 端口3000
- SQLite/PostgreSQL数据库(
prod.db) .env.production配置文件- 优化构建,稳定运行
| 数据库 | 优势 | 劣势 | 适用场景 |
|---|---|---|---|
| SQLite | 无需安装,轻量级 | 并发性能较弱 | 个人博客,小型项目 |
| PostgreSQL | 性能强,功能完善 | 需要独立部署 | 中大型应用 |
| MySQL | 生态好,成熟稳定 | 配置相对复杂 | 通用场景 |
项目当前使用: SQLite(开发和生产都可以用)
优点:
- 一键部署
- 自动CI/CD
- 全球CDN
- 免费额度
缺点:
- 不支持SQLite
- Serverless限制
- 需要外部数据库
优点:
- 完全控制
- 支持SQLite
- 无Serverless限制
- 稳定可靠
缺点:
- 需要VPS
- 配置稍复杂
- 需要基础运维知识
优点:
- 环境隔离
- 易于迁移
- 可扩展性强
缺点:
- 学习成本高
- 资源占用多
- 购买VPS(阿里云/腾讯云)
- 安装宝塔面板
- 安装Node.js 18+
# 在服务器上
cd /www/wwwroot
git clone https://github.com/your-repo/Spring-Lament-Blog.git
cd Spring-Lament-Blog
npm install# .env.production
DATABASE_URL="file:./prisma/prod.db"
NEXTAUTH_SECRET="your-production-secret-min-32-chars"
NEXTAUTH_URL="http://your-domain.com"
ADMIN_USERNAME="admin"
ADMIN_PASSWORD="your-password"
NODE_ENV="production"npm run db:generate:prod
npm run db:push:prod
npm run db:seed:prodnpm run buildnpm run pm2:startPM2配置文件(ecosystem.config.js):
module.exports = {
apps: [
{
name: "spring-lament-blog",
script: "node_modules/next/dist/bin/next",
args: "start -p 3000",
env: {
NODE_ENV: "production",
},
},
],
};server {
listen 80;
server_name your-domain.com;
location / {
proxy_pass http://localhost:3000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_cache_bypass $http_upgrade;
}
}TODO: 补充github action一键发布部署链接
通过本章,你应该理解:
- 开发环境和生产环境的区别
- 不同部署方式的优劣
- 宝塔+PM2的完整部署流程
- 常见问题的排查方法
- 如何进行代码更新和维护
SSG (Static Site Generation) - 静态站点生成:
在构建时预先生成HTML页面,用户访问时直接返回静态文件,速度极快。
ISR (Incremental Static Regeneration) - 增量静态再生:
在SSG基础上,允许页面在运行时按需更新,既保证了性能又保证了内容的时效性。
三种渲染模式对比:
| 渲染模式 | 生成时机 | 优势 | 劣势 | 适用场景 |
|---|---|---|---|---|
| SSG | 构建时 | 性能最佳,SEO友好 | 内容更新需要重新构建 | 静态内容,如文档、博客 |
| ISR | 构建时+运行时 | 性能好,内容可更新 | 配置相对复杂 | 半静态内容,如新闻、商品 |
| SSR | 请求时 | 内容实时,交互性好 | 服务器压力大 | 动态内容,如用户面板 |
// app/posts/[slug]/page.tsx
// 使用SSG生成静态页面
export async function generateStaticParams() {
const posts = await prisma.post.findMany({
where: { published: true },
select: { slug: true },
});
return posts.map((post) => ({ slug: post.slug }));
}
// 使用ISR定期更新
export const revalidate = 3600; // 每小时重新生成项目中的ImageWithFallback组件:
import Image from 'next/image'
<Image
src={post.coverImage}
alt={post.title}
width={800}
height={400}
priority={isFirstPost}
placeholder="blur"
/>// ❌ 不好 - 查询所有字段
const users = await prisma.user.findMany();
// ✅ 好 - 只查询需要的字段
const users = await prisma.user.findMany({
select: { id: true, name: true, email: true },
});model Post {
slug String @unique // 自动创建索引
@@index([published, createdAt]) // 复合索引
}通过这份指南,你已经学习了:
✅ 后端的本质和Node.js基础 ✅ Next.js 15全栈开发 ✅ Prisma数据库操作 ✅ NextAuth认证系统 ✅ 完整的CRUD实现 ✅ shadcn/ui组件库 ✅ 生产环境部署
恭喜你!你已经具备了全栈开发的基础能力。
接下来,建议你:
- 动手实践,搭建自己的项目
- 阅读Next.js和Prisma官方文档
- 参与开源项目
- 持续学习新技术
记住:实践是最好的老师,动手写代码才能真正掌握全栈开发!
祝你全栈开发之路顺利! 🚀