本系列文章将围绕Next.js技术栈,旨在为AI Agent开发者提供一套完整的客户端侧工程实践指南。
理论学一百遍,不如动手做一遍。 本章将带你从零开始构建一个有实际价值的全栈博客系统,将前面所有章节的知识融会贯通。我们将分上下两篇完成这个项目,上篇聚焦架构设计与核心功能实现,下篇将完善前端页面、性能优化及部署上线。
一、📋 项目规划与设计思路
1. 为什么选择博客系统作为实战项目?
在开始编码之前,我们先思考一个问题:为什么博客系统是学习 Next.js 的最佳实战项目?
mindmap
root((为什么选博客系统))
(技术覆盖面广)
路由系统
数据获取
表单处理
认证鉴权
(业务逻辑完整)
CRUD 操作
权限控制
缓存策略
SEO 优化
(可扩展性强)
评论系统
AI 集成
搜索功能
管理后台
(真实应用场景)
个人品牌
技术分享
作品集展示
博客系统看似简单,实则涵盖了现代 Web 开发的几乎所有核心技术点:
- 内容管理系统(CMS):文章的创建、编辑、删除
- 用户系统:注册、登录、权限管理
- 交互功能:评论、点赞、收藏
- 性能优化:缓存策略、图片优化、SEO
- AI 增强:智能摘要、标签推荐
通过这个项目,你将真正理解如何将理论知识转化为生产力。
2. 功能特性全景图
让我们先明确这个博客系统要实现哪些功能:
(1)核心功能模块
| 模块 | 功能点 | 技术要点 |
|---|---|---|
| 用户系统 | 邮箱/GitHub 登录、个人资料管理 | Auth.js、Session 管理 |
| 文章系统 | Markdown 编写、代码高亮、标签分类 | MDX、Shiki、Prisma |
| AI 功能 | 自动生成摘要、智能标签推荐 | OpenAI API、Vercel AI SDK |
| 社交互动 | 评论、点赞、收藏、RSS 订阅 | Server Actions、Optimistic UI |
| 管理后台 | 文章审核、数据统计、用户管理 | RBAC 权限控制 |
3. 技术选型决策过程
在实际项目中,技术选型不是越新越好,而是要权衡多个维度:
(1)框架选择:Next.js 15 App Router
- React Server Components 提升性能
- 文件系统路由简化开发
- 内置优化(Image/Font/Metadata)
- Vercel 生态无缝集成
(2) 数据库方案:PostgreSQL + Prisma ORM
- 关系型数据库适合博客数据结构
- Prisma 提供类型安全的查询
- Neon 提供免费 Serverless PostgreSQL
- 迁移工具简化数据库版本管理
(3) 认证方案:Auth.js (NextAuth v5)
- 官方推荐的 Next.js 认证方案
- 支持 OAuth 和凭证登录
- 与 Prisma 适配器完美集成
- Session 管理开箱即用
(4)样式方案:Tailwind CSS
- 实用优先,开发效率高
- 与 Next.js 深度集成
- 响应式设计简单易用
- 社区组件库丰富
关键决策原则:
- ✅ 稳定性优先: 选择成熟稳定的技术栈,而非最新但未经验证的
- ✅ 生态完整: 优先考虑有良好文档和社区支持的技术
- ✅ 开发体验: 减少样板代码,提高开发效率
- ✅ 可维护性: 类型安全、清晰的代码结构
二、🚀 项目初始化与环境搭建
第一步:创建 Next.js 项目
打开终端,执行以下命令:
npx create-next-app@latest fullstack-blog
在交互式提示中,按以下方式选择:
✔ Would you like to use TypeScript? … Yes
✔ Would you like to use ESLint? … Yes
✔ Would you like to use Tailwind CSS? … Yes
✔ Would you like to use `src/` directory? … Yes
✔ Would you like to use App Router? (recommended) … Yes
✔ Would you like to customize the default import alias (@/*)? … No
为什么要这样配置?
- TypeScript: 提供类型安全,减少运行时错误,是生产项目的标配
- ESLint: 自动检测代码问题,保持代码质量
- Tailwind CSS: 快速构建 UI,避免手写大量 CSS
- src/ 目录: 更好的项目结构组织,分离源代码和配置文件
- App Router: Next.js 13+ 的推荐路由方案,支持 RSC
第二步:安装核心依赖
进入项目目录后,我们需要安装几类依赖:
cd fullstack-blog
# 1️⃣ 数据库相关
npm install prisma @prisma/client
# 2️⃣ 认证相关
npm install next-auth@beta @auth/prisma-adapter bcryptjs
# 3️⃣ Markdown 渲染
npm install next-mdx-remote shiki rehype-autolink-headings rehype-slug
# 4️⃣ 表单验证
npm install zod react-hook-form @hookform/resolvers
# 5️⃣ AI 集成
npm install ai openai
# 6️⃣ 工具库
npm install date-fns slugify clsx tailwind-merge
# 7️⃣ 开发依赖(类型定义)
npm install -D @types/bcryptjs
依赖分类解析:
| 类别 | 包名 | 作用 |
|---|---|---|
| ORM | prisma, @prisma/client | 类型安全的数据库访问层 |
| 认证 | next-auth@beta | Next.js 官方认证库 v5 版本 |
| 密码加密 | bcryptjs | 用户密码哈希加密 |
| MDX | next-mdx-remote | 在服务端渲染 Markdown |
| 代码高亮 | shiki | VS Code 同款语法高亮引擎 |
| 表单 | zod, react-hook-form | Schema 验证 + 高性能表单管理 |
| AI | ai, openai | Vercel AI SDK + OpenAI 客户端 |
| 工具 | date-fns, slugify | 日期格式化、URL 友好字符串生成 |
第三步:环境变量配置
在项目根目录创建 .env.local 文件:
# .env.local
# ==================== 数据库配置 ====================
# 本地开发使用 PostgreSQL
DATABASE_URL="postgresql://user:password@localhost:5432/blog"
# Prisma 直连 URL(用于迁移等操作)
DIRECT_URL="postgresql://user:password@localhost:5432/blog"
# ==================== 认证配置 ====================
# Auth.js 会话加密密钥(至少 32 字符)
AUTH_SECRET="your-secret-key-min-32-characters-long!!!"
# GitHub OAuth 凭据(需在 GitHub Developer Settings 中创建)
GITHUB_ID="your-github-client-id"
GITHUB_SECRET="your-github-client-secret"
# ==================== AI 配置 ====================
# OpenAI API Key(从 https://platform.openai.com 获取)
OPENAI_API_KEY="sk-your-openai-api-key"
# ==================== 应用配置 ====================
# 应用基础 URL(开发环境)
NEXT_PUBLIC_APP_URL="http://localhost:3000"
⚠️ 安全提醒:
.env.local已默认添加到.gitignore,不会提交到 GitAUTH_SECRET可使用命令生成:openssl rand -base64 32- 生产环境需在部署平台(Vercel/Docker)配置这些变量
第四步:启动开发服务器
npm run dev
访问 http://localhost:3000,如果看到 Next.js 欢迎页面,说明项目初始化成功! 🎉
三、🗄️ 数据库设计与 Prisma 建模
1. 为什么需要精心设计数据库?
数据库设计直接影响应用的性能、可扩展性和维护成本。对于博客系统,我们需要考虑:
- 实体关系: 用户、文章、标签、评论之间的关系
- 索引优化: 加速常用查询(如按 slug 查找文章)
- 数据完整性: 外键约束、级联删除
- 扩展预留: 未来可能添加的功能(如点赞、收藏)
2. ER 图(Entity-Relationship Diagram)
erDiagram
USER ||--o{ POST : writes
USER ||--o{ COMMENT : comments
USER ||--o{ LIKE : likes
USER ||--o{ BOOKMARK : bookmarks
POST ||--o{ POST_TAG : has
TAG ||--o{ POST_TAG : tagged_in
POST ||--o{ COMMENT : receives
POST ||--o{ LIKE : gets
POST ||--o{ BOOKMARK : saved
COMMENT ||--o{ COMMENT : replies_to
USER {
String id PK
String email UK
String name
Role role
}
POST {
String id PK
String slug UK
String title
Boolean published
}
TAG {
String id PK
String name UK
String slug UK
}
COMMENT {
String id PK
String postId FK
String parentId FK
}
3. Prisma Schema 详解
创建 prisma/schema.prisma 文件:
// prisma/schema.prisma
// 1. 生成器配置:告诉 Prisma 生成什么语言的客户端
generator client {
provider = "prisma-client-js"
}
// 2. 数据源配置:指定数据库类型和连接字符串
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
directUrl = env("DIRECT_URL")
}
// 3. 枚举类型:用户角色
enum Role {
USER // 普通用户
ADMIN // 管理员
}
// ==================== 核心模型 ====================
// 用户模型
model User {
id String @id @default(cuid())
name String?
email String @unique
emailVerified DateTime?
image String?
bio String? @db.Text
website String?
github String?
role Role @default(USER)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// 关联关系
accounts Account[]
sessions Session[]
posts Post[]
comments Comment[]
likes Like[]
bookmarks Bookmark[]
@@map("users")
}
// 文章模型
model Post {
id String @id @default(cuid())
title String
slug String @unique
content String @db.Text
excerpt String? // AI 生成的摘要
coverImage String?
published Boolean @default(false)
featured Boolean @default(false)
viewCount Int @default(0)
readingTime Int? // 预计阅读时间(分钟)
authorId String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
publishedAt DateTime?
// 关联关系
author User @relation(fields: [authorId], references: [id])
tags PostTag[]
comments Comment[]
likes Like[]
bookmarks Bookmark[]
// 索引优化查询性能
@@index([slug])
@@index([published])
@@index([createdAt])
@@map("posts")
}
// 标签模型
model Tag {
id String @id @default(cuid())
name String @unique
slug String @unique
description String?
color String @default("#6366f1")
posts PostTag[]
@@map("tags")
}
// 文章-标签多对多关系表
model PostTag {
postId String
tagId String
post Post @relation(fields: [postId], references: [id], onDelete: Cascade)
tag Tag @relation(fields: [tagId], references: [id], onDelete: Cascade)
@@id([postId, tagId])
@@map("post_tags")
}
// 评论模型(支持嵌套回复)
model Comment {
id String @id @default(cuid())
content String @db.Text
authorId String
postId String
parentId String? // 父评论 ID,用于嵌套评论
approved Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
author User @relation(fields: [authorId], references: [id])
post Post @relation(fields: [postId], references: [id], onDelete: Cascade)
parent Comment? @relation("CommentReplies", fields: [parentId], references: [id])
replies Comment[] @relation("CommentReplies")
@@index([postId])
@@index([approved])
@@map("comments")
}
// 点赞模型
model Like {
userId String
postId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
post Post @relation(fields: [postId], references: [id], onDelete: Cascade)
@@id([userId, postId])
@@map("likes")
}
// 收藏模型
model Bookmark {
userId String
postId String
createdAt DateTime @default(now())
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
post Post @relation(fields: [postId], references: [id], onDelete: Cascade)
@@id([userId, postId])
@@map("bookmarks")
}
// ==================== Auth.js 所需模型 ====================
model Account {
id String @id @default(cuid())
userId String
type String
provider String
providerAccountId String
refresh_token String? @db.Text
access_token String? @db.Text
expires_at Int?
token_type String?
scope String?
id_token String? @db.Text
session_state String?
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([provider, providerAccountId])
@@map("accounts")
}
model Session {
id String @id @default(cuid())
sessionToken String @unique
userId String
expires DateTime
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@map("sessions")
}
model VerificationToken {
identifier String
token String @unique
expires DateTime
@@unique([identifier, token])
@@map("verification_tokens")
}
📝 Schema 设计要点解析:
(1) 主键策略:cuid() vs uuid()
id String @id @default(cuid())
- cuid: 更短、更易读、按时间排序,适合大多数场景
- uuid: 标准 UUID v4,更长但全球唯一
- 自增 ID: 不适合分布式系统,不推荐
(2) 索引优化
@@index([slug]) // 加速按 slug 查询文章
@@index([published]) // 加速筛选已发布文章
@@index([postId]) // 加速查询文章的评论
何时添加索引?
- ✅ 经常用于
WHERE条件的字段 - ✅ 外键字段
- ❌ 低基数字段(如布尔值)
- ❌ 频繁更新的字段
(3) 级联删除
post Post @relation(fields: [postId], references: [id], onDelete: Cascade)
当文章被删除时,自动删除相关的评论、点赞、收藏记录,保持数据一致性。
(4) 自引用关系(嵌套评论)
parent Comment? @relation("CommentReplies", fields: [parentId], references: [id])
replies Comment[] @relation("CommentReplies")
通过 parentId 实现评论的树形结构,支持无限层级回复。
4. 初始化数据库
执行以下命令创建数据库表:
# 1. 生成 Prisma Client(TypeScript 类型定义)
npx prisma generate
# 2. 创建数据库迁移
npx prisma migrate dev --name init
# 3. (可选)可视化查看数据库
npx prisma studio
迁移文件说明:
执行 migrate dev 后,会在 prisma/migrations/ 目录生成 SQL 文件:
-- prisma/migrations/20260412000000_init/migration.sql
CREATE TABLE "users" (
"id" TEXT NOT NULL,
"email" TEXT NOT NULL,
"name" TEXT,
"role" "Role" NOT NULL DEFAULT 'USER',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "users_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX "users_email_key" ON "users"("email");
-- ... 其他表的创建语句
💡 最佳实践:
- 每次修改 Schema 都创建新的迁移
- 迁移文件应提交到 Git,便于团队协作
- 生产环境使用
prisma migrate deploy而非dev
四、🔐 认证系统集成(Auth.js)
1. 认证流程概览
sequenceDiagram
participant User as 用户
participant App as Next.js App
participant Auth as Auth.js
participant DB as Database
participant OAuth as GitHub OAuth
User->>App: 点击"使用 GitHub 登录"
App->>Auth: 重定向到 /api/auth/signin/github
Auth->>OAuth: 请求授权
OAuth->>User: 显示授权页面
User->>OAuth: 确认授权
OAuth->>Auth: 返回授权码
Auth->>OAuth: 交换访问令牌
Auth->>DB: 创建/更新用户记录
Auth->>App: 设置 Session Cookie
App->>User: 重定向到首页(已登录状态)
2. 配置 Auth.js
创建 auth.ts 文件(项目根目录):
// auth.ts
import NextAuth from 'next-auth';
import GitHub from 'next-auth/providers/github';
import { PrismaAdapter } from '@auth/prisma-adapter';
import { db } from '@/lib/db';
export const { handlers, signIn, signOut, auth } = NextAuth({
adapter: PrismaAdapter(db),
providers: [
GitHub({
clientId: process.env.GITHUB_ID!,
clientSecret: process.env.GITHUB_SECRET!,
}),
],
callbacks: {
// Session 回调:自定义 Session 数据
async session({ session, user }) {
if (session.user) {
session.user.id = user.id;
session.user.role = user.role;
}
return session;
},
// JWT 回调:将用户信息编码到 Token
async jwt({ token, user }) {
if (user) {
token.id = user.id;
token.role = user.role;
}
return token;
},
},
pages: {
signIn: '/auth/signin', // 自定义登录页面
},
});
🔑 关键配置解析:
(1) Adapter(适配器模式)
adapter: PrismaAdapter(db)
Auth.js 通过适配器与不同数据库交互。PrismaAdapter 会自动:
- 创建/更新用户记录
- 管理 OAuth 账户绑定
- 处理 Session 生命周期
(2) Providers(认证提供者)
providers: [
GitHub({ /* 配置 */ }),
// 可以添加更多: Google、Email、Credentials...
]
每个 Provider 对应一种登录方式。GitHub OAuth 需要在 GitHub Developer Settings 中创建应用,获取 Client ID 和 Client Secret。
(3) Callbacks(回调函数)
callbacks: {
async session({ session, user }) {
// 在这里可以向 session 添加额外数据
session.user.id = user.id;
return session;
}
}
常见用途:
- 向 Session 添加用户 ID、角色等信息
- 根据用户角色限制访问
- 记录登录日志
(3)创建 API 路由
Next.js App Router 中,Auth.js 的路由位于 app/api/auth/[...nextauth]/route.ts:
// app/api/auth/[...nextauth]/route.ts
import { handlers } from '@/auth';
export const { GET, POST } = handlers;
路由命名规则:
[...nextauth]是动态路由段,匹配所有/api/auth/*路径- Auth.js 内部会根据子路径分发请求(如
/api/auth/signin)
4. 封装认证辅助函数
创建 lib/auth.ts:
// lib/auth.ts
import { auth } from '@/auth';
import { redirect } from 'next/navigation';
/**
* 获取当前会话(服务端组件中使用)
*/
export async function getCurrentUser() {
const session = await auth();
return session?.user || null;
}
/**
* 要求用户登录(未登录则重定向)
*/
export async function requireAuth() {
const session = await auth();
if (!session?.user) {
redirect('/auth/signin?callbackUrl=' + encodeURIComponent(
typeof window !== 'undefined' ? window.location.pathname : '/'
));
}
return session;
}
/**
* 检查是否为管理员
*/
export async function requireAdmin() {
const session = await requireAuth();
if (session.user.role !== 'ADMIN') {
throw new Error('权限不足');
}
return session;
}
使用示例:
// app/dashboard/page.tsx
import { requireAuth } from '@/lib/auth';
export default async function DashboardPage() {
// 未登录会自动重定向到登录页
const session = await requireAuth();
return <div>欢迎, {session.user.name}</div>;
}
五、✍️ 文章 CRUD 核心功能
Server Actions 架构设计
在 Next.js 13+ 中,Server Actions 是处理表单提交和数据突变的首选方案,相比传统 API Routes 有以下优势:
| 对比项 | Server Actions | API Routes |
|---|---|---|
| 类型安全 | ✅ 端到端类型推断 | ❌ 需手动定义接口 |
| 渐进增强 | ✅ 无 JS 也可工作 | ❌ 依赖客户端 JS |
| 代码复用 | ✅ 直接导入函数 | ❌ 需 HTTP 请求 |
| 安全性 | ✅ 自动 CSRF 保护 | ⚠️ 需手动实现 |
1. 创建文章 Action
创建 app/actions/post.ts:
// app/actions/post.ts
'use server';
import { auth } from '@/auth';
import { db } from '@/lib/db';
import { z } from 'zod';
import slugify from 'slugify';
import { revalidateTag } from 'next/cache';
import { redirect } from 'next/navigation';
// ==================== Schema 定义 ====================
/**
* 创建文章的验证 Schema
*
* Zod 的优势:
* 1. 运行时验证 + TypeScript 类型推断
* 2. 详细的错误信息
* 3. 可组合、可扩展
*/
const createPostSchema = z.object({
title: z.string()
.min(1, '标题不能为空')
.max(200, '标题不能超过 200 字符'),
content: z.string()
.min(100, '文章内容至少 100 字符'),
excerpt: z.string()
.max(500, '摘要不能超过 500 字符')
.optional(),
coverImage: z.string()
.url('请输入有效的图片 URL')
.optional(),
tagIds: z.array(z.string())
.min(1, '至少选择一个标签'),
published: z.boolean()
.default(false),
});
// 从 Schema 推断 TypeScript 类型
type CreatePostInput = z.infer<typeof createPostSchema>;
/**
* 创建新文章
*
* @param data - 文章数据
* @returns 创建结果
*
* 使用场景:
* - 管理后台创建文章
* - 用户投稿功能
*/
export async function createPost(data: CreatePostInput) {
// 1. 身份验证
const session = await auth();
if (!session?.user) {
return {
success: false,
error: '未授权,请先登录'
};
}
// 2. 数据验证
const validated = createPostSchema.safeParse(data);
if (!validated.success) {
return {
success: false,
error: '数据验证失败',
details: validated.error.flatten().fieldErrors
};
}
// 3. 生成 URL 友好的 slug
const slug = slugify(validated.data.title, {
lower: true, // 转小写
strict: true, // 严格模式,移除特殊字符
});
// 4. 检查 slug 是否已存在
const existingPost = await db.post.findUnique({
where: { slug },
});
if (existingPost) {
// 如果 slug 冲突,添加时间戳后缀
const uniqueSlug = `${slug}-${Date.now()}`;
return await savePost({ ...validated.data, slug: uniqueSlug }, session.user.id!);
}
return await savePost({ ...validated.data, slug }, session.user.id!);
}
/**
* 保存文章到数据库(内部函数)
*/
async function savePost(
data: CreatePostInput & { slug: string },
authorId: string
) {
try {
const post = await db.post.create({
data: {
title: data.title,
slug: data.slug,
content: data.content,
excerpt: data.excerpt,
coverImage: data.coverImage,
published: data.published,
publishedAt: data.published ? new Date() : null,
authorId,
// 关联标签(多对多关系)
tags: {
create: data.tagIds.map(tagId => ({
tag: { connect: { id: tagId } },
})),
},
},
});
// 5. 失效相关缓存
revalidateTag('posts'); // 文章列表缓存
revalidateTag(`user-${authorId}`); // 用户文章列表缓存
return {
success: true,
postId: post.id,
message: '文章创建成功'
};
} catch (error) {
console.error('Failed to create post:', error);
return {
success: false,
error: '创建文章失败,请稍后重试'
};
}
}
📖 代码解析:
(1) 为什么使用 'use server' 指令?
'use server';
这个指令告诉 Next.js:
- 该文件中的所有导出函数都在服务端执行
- 可以在函数中访问数据库、环境变量等敏感资源
- 客户端调用时会自动序列化参数和返回值
(2) Zod Schema 验证的重要性
const validated = createPostSchema.safeParse(data);
if (!validated.success) {
return { error: '数据验证失败', details: validated.error.flatten() };
}
防御性编程原则:
- ✅ 永远不要信任客户端传来的数据
- ✅ 在服务端进行二次验证
- ✅ 提供清晰的错误提示
(3)缓存失效策略
revalidateTag('posts');
当我们创建/更新/删除文章后,需要通知 Next.js 清除相关缓存:
revalidateTag('posts'): 清除所有文章列表的缓存revalidatePath('/blog'): 清除特定路径的缓存
缓存失效时机:
- 创建文章 → 清除列表缓存
- 更新文章 → 清除详情 + 列表缓存
- 删除文章 → 清除详情 + 列表 + 用户缓存
2. 获取文章列表(带缓存)
创建 lib/posts.ts:
// lib/posts.ts
import { db } from '@/lib/db';
import { cache } from 'react';
interface GetPostsOptions {
page?: number;
pageSize?: number;
tagSlug?: string;
search?: string;
published?: boolean;
}
/**
* 获取文章列表(带 React Cache)
*
* cache() 的作用:
* - 在同一请求中多次调用时,只执行一次数据库查询
* - 配合 Next.js 数据缓存,实现多层缓存
*/
export const getPosts = cache(async ({
page = 1,
pageSize = 10,
tagSlug,
search,
published = true,
}: GetPostsOptions = {}) => {
const skip = (page - 1) * pageSize;
// 构建动态查询条件
const where = {
published,
...(tagSlug && {
tags: {
some: {
tag: { slug: tagSlug },
},
},
}),
...(search && {
OR: [
{ title: { contains: search, mode: 'insensitive' as const } },
{ content: { contains: search, mode: 'insensitive' as const } },
],
}),
};
// 并行查询:文章列表 + 总数
const [posts, total] = await Promise.all([
db.post.findMany({
where,
skip,
take: pageSize,
orderBy: { publishedAt: 'desc' },
include: {
author: {
select: { id: true, name: true, image: true },
},
tags: {
include: {
tag: { select: { id: true, name: true, slug: true, color: true } },
},
},
_count: {
select: { comments: true, likes: true },
},
},
}),
db.post.count({ where }),
]);
return {
posts,
pagination: {
page,
pageSize,
total,
totalPages: Math.ceil(total / pageSize),
},
};
});
🎯 性能优化技巧:
(1) 使用 Promise.all 并行查询
const [posts, total] = await Promise.all([
db.post.findMany({ /* ... */ }),
db.post.count({ where }),
]);
而不是串行:
// ❌ 慢:两个查询依次执行
const posts = await db.post.findMany({ /* ... */ });
const total = await db.post.count({ where });
(2) 精确选择字段
include: {
author: {
select: { id: true, name: true, image: true }, // 只取需要的字段
},
}
避免 select: true 取出所有字段,减少网络传输和内存占用。
(3) 使用 _count 聚合查询
_count: {
select: { comments: true, likes: true },
}
直接在数据库层面统计数量,避免在应用层遍历数组。
4. 获取单篇文章详情
继续在 lib/posts.ts 中添加:
/**
* 根据 slug 获取文章详情
*
* @param slug - 文章 URL 标识
* @returns 文章详情或 null
*/
export const getPostBySlug = cache(async (slug: string) => {
const post = await db.post.findUnique({
where: { slug },
include: {
author: {
select: { id: true, name: true, image: true, bio: true },
},
tags: {
include: {
tag: { select: { id: true, name: true, slug: true, color: true } },
},
},
// 获取顶级评论(不包括回复)
comments: {
where: { approved: true, parentId: null },
include: {
author: { select: { id: true, name: true, image: true } },
// 嵌套获取回复评论
replies: {
include: {
author: { select: { id: true, name: true, image: true } },
},
},
},
orderBy: { createdAt: 'asc' },
},
_count: {
select: { likes: true, bookmarks: true },
},
},
});
if (!post) {
return null;
}
// 异步增加浏览量(不阻塞响应)
incrementViewCount(post.id);
return post;
});
/**
* 增加文章浏览量
*/
async function incrementViewCount(postId: string) {
await db.post.update({
where: { id: postId },
data: { viewCount: { increment: 1 } },
});
}
💡 设计思考:
为什么浏览量更新不等待?
// 不阻塞主流程
incrementViewCount(post.id);
return post;
- ✅ 用户体验优先: 用户无需等待计数器更新
- ✅ 即使更新失败,也不影响文章展示
- ⚠️ 注意:在高并发场景可能需要队列或批量更新优化
六、🤖 AI 功能集成
1. 为什么要在博客中集成 AI?
传统博客系统的痛点:
- ❌ 作者需要手动编写摘要,耗时耗力
- ❌ 标签选择主观,不利于 SEO
- ❌ 相关文章推荐算法复杂
AI 可以解决这些问题:
- ✅ 自动生成摘要: 节省作者时间
- ✅ 智能标签推荐: 基于内容语义分析
- ✅ 个性化推荐: 提升用户停留时长
2. 配置 OpenAI
创建 lib/ai.ts:
// lib/ai.ts
import { openai } from '@ai-sdk/openai';
import { generateText } from 'ai';
/**
* 使用 AI 生成文章摘要
*
* @param content - 文章正文
* @returns 生成的摘要文本
*
* 应用场景:
* - 创建文章时自动生成 excerpt
* - 批量处理历史文章
*/
export async function generateExcerpt(content: string): Promise<string> {
// 限制输入长度,避免超出 Token 限制
const truncatedContent = content.substring(0, 2000);
const { text } = await generateText({
model: openai('gpt-4-turbo'),
prompt: `请为以下文章内容生成一段简洁的摘要(不超过 200 字)。
要求:
1. 突出核心观点
2. 语言精炼流畅
3. 吸引读者继续阅读
文章内容:
${truncatedContent}`,
temperature: 0.7, // 创造性:0-1,越高越随机
});
return text.trim();
}
/**
* 智能推荐标签
*
* @param title - 文章标题
* @param content - 文章正文
* @returns 标签名称数组
*/
export async function suggestTags(
title: string,
content: string
): Promise<string[]> {
const { text } = await generateText({
model: openai('gpt-4-turbo'),
prompt: `基于以下文章标题和内容,推荐 3-5 个相关的技术标签。
要求:
1. 标签应为常见的技术术语
2. 用逗号分隔,不要编号
3. 每个标签不超过 10 个字符
标题: ${title}
内容: ${content.substring(0, 1500)}`,
temperature: 0.5, // 更低温度,更稳定
});
// 解析返回结果
return text
.split(',')
.map(tag => tag.trim())
.filter(Boolean)
.slice(0, 5); // 最多 5 个标签
}
/**
* 生成文章预计阅读时间
*
* @param content - 文章正文
* @returns 阅读时间(分钟)
*/
export function calculateReadingTime(content: string): number {
const wordsPerMinute = 300; // 中文阅读速度
const wordCount = content.length / 2; // 粗略估算中文字数
return Math.ceil(wordCount / wordsPerMinute);
}
⚙️ AI 配置最佳实践:
(1)Temperature 参数调优
temperature: 0.7 // 摘要生成:需要一定创造性
temperature: 0.5 // 标签推荐:需要稳定性
- 0.0-0.3: 确定性输出,适合事实性问题
- 0.4-0.7: 平衡创造性和准确性
- 0.8-1.0: 高创造性,适合创意写作
(2)Prompt Engineering 技巧
prompt: `请为以下文章内容生成一段简洁的摘要(不超过 200 字)。
要求:
1. 突出核心观点
2. 语言精炼流畅
3. 吸引读者继续阅读`
有效 Prompt 的要素:
- ✅ 明确任务目标
- ✅ 列出具体要求
- ✅ 提供示例(Few-shot Learning)
- ✅ 限制输出格式
(3) 成本控制
const truncatedContent = content.substring(0, 2000);
- 限制输入长度,减少 Token 消耗
- 对于长文章,可以分段处理后合并
- 考虑使用更便宜的模型(如
gpt-3.5-turbo)进行测试
3. 在创建文章时调用 AI
修改 createPost 函数:
// app/actions/post.ts
import { generateExcerpt, calculateReadingTime } from '@/lib/ai';
export async function createPost(data: CreatePostInput) {
// ... 前面的验证逻辑 ...
// 如果没有提供摘要,使用 AI 生成
let excerpt = validated.data.excerpt;
if (!excerpt) {
excerpt = await generateExcerpt(validated.data.content);
}
// 计算阅读时间
const readingTime = calculateReadingTime(validated.data.content);
// 保存到数据库
const post = await db.post.create({
data: {
// ... 其他字段 ...
excerpt,
readingTime,
},
});
return { success: true, postId: post.id };
}
🎯 用户体验优化:
可以在前端显示"AI 生成中..."的加载状态:
// components/AIExcerptGenerator.tsx
'use client';
import { useState } from 'react';
import { generateExcerpt } from '@/app/actions/ai';
export function AIExcerptGenerator({ content }: { content: string }) {
const [loading, setLoading] = useState(false);
const [excerpt, setExcerpt] = useState('');
const handleGenerate = async () => {
setLoading(true);
try {
const result = await generateExcerpt(content);
setExcerpt(result);
} finally {
setLoading(false);
}
};
return (
<div>
<button onClick={handleGenerate} disabled={loading}>
{loading ? 'AI 生成中...' : '✨ 自动生成摘要'}
</button>
{excerpt && <textarea value={excerpt} />}
</div>
);
}
七、💬 评论系统实现
1. 评论系统设计要点
评论系统是博客的社交核心,需要考虑:
- 嵌套回复: 支持楼中楼式讨论
- 审核机制: 防止垃圾评论
- 实时更新: 新评论即时显示
- 权限控制: 仅登录用户可评论
2. 创建评论 Action
创建 app/actions/comment.ts:
// app/actions/comment.ts
'use server';
import { auth } from '@/auth';
import { db } from '@/lib/db';
import { z } from 'zod';
import { revalidateTag } from 'next/cache';
const commentSchema = z.object({
postId: z.string().min(1, '文章 ID 不能为空'),
content: z.string()
.min(1, '评论内容不能为空')
.max(5000, '评论不能超过 5000 字符'),
parentId: z.string().optional(), // 回复评论时填写
});
type CreateCommentInput = z.infer<typeof commentSchema>;
/**
* 发表评论
*
* @param data - 评论数据
* @returns 创建结果
*/
export async function createComment(data: CreateCommentInput) {
// 1. 身份验证
const session = await auth();
if (!session?.user) {
return {
success: false,
error: '请先登录后再评论'
};
}
// 2. 数据验证
const validated = commentSchema.safeParse(data);
if (!validated.success) {
return {
success: false,
error: '数据验证失败',
details: validated.error.flatten().fieldErrors
};
}
// 3. 检查文章是否存在
const post = await db.post.findUnique({
where: { id: validated.data.postId },
select: { id: true, published: true },
});
if (!post || !post.published) {
return {
success: false,
error: '文章不存在或未发布'
};
}
// 4. 如果是回复,检查父评论是否存在
if (validated.data.parentId) {
const parentComment = await db.comment.findUnique({
where: { id: validated.data.parentId },
});
if (!parentComment) {
return {
success: false,
error: '父评论不存在'
};
}
}
try {
// 5. 创建评论
const comment = await db.comment.create({
data: {
content: validated.data.content,
authorId: session.user.id!,
postId: validated.data.postId,
parentId: validated.data.parentId,
approved: true, // 默认通过审核(可改为 false 启用审核)
},
include: {
author: {
select: { id: true, name: true, image: true }
},
},
});
// 6. 失效缓存
revalidateTag(`post-${validated.data.postId}`);
return {
success: true,
comment,
message: '评论成功'
};
} catch (error) {
console.error('Failed to create comment:', error);
return {
success: false,
error: '评论失败,请稍后重试'
};
}
}
🔒 安全防护措施:
(1) 防 XSS 攻击
虽然我们在数据库中存储原始内容,但在渲染时需要转义:
// 使用 dangerouslySetInnerHTML 时要谨慎
<div dangerouslySetInnerHTML={{ __html: sanitize(comment.content) }} />
可以使用 dompurify 库清理 HTML:
npm install dompurify
npm install -D @types/dompurify
(2) 频率限制
防止用户刷评论:
// 检查用户最近 1 分钟内的评论次数
const recentComments = await db.comment.count({
where: {
authorId: session.user.id!,
createdAt: {
gte: new Date(Date.now() - 60 * 1000), // 1 分钟内
},
},
});
if (recentComments >= 5) {
return {
success: false,
error: '评论过于频繁,请稍后再试'
};
}
(3) 敏感词过滤
const bannedWords = ['广告', '赌博', '色情'];
if (bannedWords.some(word => validated.data.content.includes(word))) {
return {
success: false,
error: '评论包含不当内容'
};
}
八、👍 点赞与收藏功能
1. 为什么需要点赞和收藏?
- 点赞: 量化文章受欢迎程度,激励作者
- 收藏: 用户个人知识库,方便后续查阅
- 数据分析: 了解用户偏好,优化内容策略
2. 切换点赞状态
创建 app/actions/interaction.ts:
// app/actions/interaction.ts
'use server';
import { auth } from '@/auth';
import { db } from '@/lib/db';
import { revalidateTag } from 'next/cache';
/**
* 切换点赞状态(点赞/取消点赞)
*
* @param postId - 文章 ID
* @returns 操作结果
*/
export async function toggleLike(postId: string) {
const session = await auth();
if (!session?.user) {
return {
success: false,
error: '请先登录'
};
}
// 检查是否已点赞
const existing = await db.like.findUnique({
where: {
userId_postId: {
userId: session.user.id!,
postId,
},
},
});
try {
if (existing) {
// 取消点赞
await db.like.delete({
where: {
userId_postId: {
userId: session.user.id!,
postId,
},
},
});
return { success: true, liked: false };
} else {
// 添加点赞
await db.like.create({
data: {
userId: session.user.id!,
postId,
},
});
return { success: true, liked: true };
}
} catch (error) {
console.error('Failed to toggle like:', error);
return {
success: false,
error: '操作失败'
};
} finally {
// 无论成功与否,都失效缓存
revalidateTag(`post-${postId}`);
}
}
/**
* 切换收藏状态
*
* @param postId - 文章 ID
* @returns 操作结果
*/
export async function toggleBookmark(postId: string) {
const session = await auth();
if (!session?.user) {
return {
success: false,
error: '请先登录'
};
}
const existing = await db.bookmark.findUnique({
where: {
userId_postId: {
userId: session.user.id!,
postId,
},
},
});
try {
if (existing) {
await db.bookmark.delete({
where: {
userId_postId: {
userId: session.user.id!,
postId,
},
},
});
return { success: true, bookmarked: false };
} else {
await db.bookmark.create({
data: {
userId: session.user.id!,
postId,
},
});
return { success: true, bookmarked: true };
}
} catch (error) {
console.error('Failed to toggle bookmark:', error);
return {
success: false,
error: '操作失败'
};
} finally {
revalidateTag(`user-${session.user.id}`);
}
}
💡 设计模式:Toggle Pattern
点赞/收藏这类功能是典型的 Toggle 模式:
- 检查当前状态
- 如果存在则删除,不存在则创建
- 返回新状态
这种模式的优点:
- ✅ 幂等性:多次调用结果一致
- ✅ 简化前端逻辑:无需分别实现"点赞"和"取消点赞"
- ✅ 原子操作:避免竞态条件
九、📝 本章小结
通过实战项目上篇的学习,我们已经完成了博客系统的后端核心功能:
✅ 项目初始化: Next.js 15 + TypeScript + Tailwind CSS
✅ 数据库设计: Prisma Schema 建模,理解关系型数据结构
✅ 认证系统: Auth.js 集成 GitHub OAuth
✅ 文章 CRUD: Server Actions 实现数据突变
✅ AI 集成: OpenAI 自动生成摘要和标签
✅ 评论系统: 嵌套评论 + 安全防护
✅ 互动功能: 点赞、收藏的 Toggle 模式
核心知识点回顾:
| 知识点 | 应用场景 | 关键代码 |
|---|---|---|
| Server Actions | 表单提交、数据突变 | 'use server' |
| Zod 验证 | 输入数据校验 | z.object().parse() |
| React Cache | 同请求内去重查询 | cache(fn) |
| Revalidate Tag | 缓存失效策略 | revalidateTag() |
| Prisma Relations | 多对多、自引用关系 | @relation |
| AI Integration | 智能摘要生成 | generateText() |
十、🚀 下篇预告
在下篇中,我们将实现:
-
前端页面开发:
- 首页文章列表
- 文章详情页(MDX 渲染)
- 登录/注册页面
- 管理后台
-
UI 组件实现:
- Markdown 代码高亮
- 评论组件(嵌套显示)
- 点赞/收藏按钮(Optimistic UI)
-
性能优化:
- 图片懒加载
- 并行数据获取
- 流式渲染
-
部署上线:
- Vercel 部署
- 环境变量配置
- 域名绑定
敬请期待! 🎉
练习作业:
- 尝试添加"文章编辑"功能(提示:参考
createPost,使用db.post.update) - 实现"删除文章"功能,并处理级联删除
- 添加"草稿箱"功能(区分
published: true/false) - 实现简单的全文搜索(使用 Prisma 的
contains查询)
完成这些练习,你将真正掌握 Next.js 全栈开发的核心技能! 💪