本系列文章将围绕Next.js技术栈,旨在为AI Agent开发者提供一套完整的客户端侧工程实践指南。
理论学一百遍,不如动手做一遍。 本章将带你从零开始构建一个有实际价值的全栈博客系统 ,将前面所有章节的知识融会贯通。我们将分上下两篇完成这个项目,上篇聚焦架构设计与核心功能实现,下篇将完善前端页面、性能优化及部署上线。
一、📋 项目规划与设计思路
1. 为什么选择博客系统作为实战项目?
在开始编码之前,我们先思考一个问题:为什么博客系统是学习 Next.js 的最佳实战项目?
为什么选博客系统
技术覆盖面广
路由系统
数据获取
表单处理
认证鉴权
业务逻辑完整
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 项目
打开终端,执行以下命令:
bash
npx create-next-app@latest fullstack-blog
在交互式提示中,按以下方式选择:
bash
✔ 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
第二步:安装核心依赖
进入项目目录后,我们需要安装几类依赖:
bash
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 文件:
bash
# .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)配置这些变量
第四步:启动开发服务器
bash
npm run dev
访问 http://localhost:3000,如果看到 Next.js 欢迎页面,说明项目初始化成功! 🎉
三、🗄️ 数据库设计与 Prisma 建模
1. 为什么需要精心设计数据库?
数据库设计直接影响应用的性能、可扩展性和维护成本。对于博客系统,我们需要考虑:
- 实体关系: 用户、文章、标签、评论之间的关系
- 索引优化: 加速常用查询(如按 slug 查找文章)
- 数据完整性: 外键约束、级联删除
- 扩展预留: 未来可能添加的功能(如点赞、收藏)
2. ER 图(Entity-Relationship Diagram)
writes
comments
likes
bookmarks
has
tagged_in
receives
gets
saved
replies_to
USER
String
id
PK
String
email
UK
String
name
Role
role
POST
String
id
PK
String
slug
UK
String
title
Boolean
published
COMMENT
String
id
PK
String
postId
FK
String
parentId
FK
LIKE
BOOKMARK
POST_TAG
TAG
String
id
PK
String
name
UK
String
slug
UK
3. Prisma Schema 详解
创建 prisma/schema.prisma 文件:
ts
// 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()
prisma
id String @id @default(cuid())
- cuid: 更短、更易读、按时间排序,适合大多数场景
- uuid: 标准 UUID v4,更长但全球唯一
- 自增 ID: 不适合分布式系统,不推荐
(2) 索引优化
prisma
@@index([slug]) // 加速按 slug 查询文章
@@index([published]) // 加速筛选已发布文章
@@index([postId]) // 加速查询文章的评论
何时添加索引?
- ✅ 经常用于
WHERE条件的字段 - ✅ 外键字段
- ❌ 低基数字段(如布尔值)
- ❌ 频繁更新的字段
(3) 级联删除
prisma
post Post @relation(fields: [postId], references: [id], onDelete: Cascade)
当文章被删除时,自动删除相关的评论、点赞、收藏记录,保持数据一致性。
(4) 自引用关系(嵌套评论)
prisma
parent Comment? @relation("CommentReplies", fields: [parentId], references: [id])
replies Comment[] @relation("CommentReplies")
通过 parentId 实现评论的树形结构,支持无限层级回复。
4. 初始化数据库
执行以下命令创建数据库表:
bash
# 1. 生成 Prisma Client(TypeScript 类型定义)
npx prisma generate
# 2. 创建数据库迁移
npx prisma migrate dev --name init
# 3. (可选)可视化查看数据库
npx prisma studio
迁移文件说明:
执行 migrate dev 后,会在 prisma/migrations/ 目录生成 SQL 文件:
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. 认证流程概览
GitHub OAuth Database Auth.js Next.js App 用户 GitHub OAuth Database Auth.js Next.js App 用户 点击"使用 GitHub 登录" 重定向到 /api/auth/signin/github 请求授权 显示授权页面 确认授权 返回授权码 交换访问令牌 创建/更新用户记录 设置 Session Cookie 重定向到首页(已登录状态)
2. 配置 Auth.js
创建 auth.ts 文件(项目根目录):
typescript
// 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(适配器模式)
typescript
adapter: PrismaAdapter(db)
Auth.js 通过适配器与不同数据库交互。PrismaAdapter 会自动:
- 创建/更新用户记录
- 管理 OAuth 账户绑定
- 处理 Session 生命周期
(2) Providers(认证提供者)
typescript
providers: [
GitHub({ /* 配置 */ }),
// 可以添加更多: Google、Email、Credentials...
]
每个 Provider 对应一种登录方式。GitHub OAuth 需要在 GitHub Developer Settings 中创建应用,获取 Client ID 和 Client Secret。
(3) Callbacks(回调函数)
typescript
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:
typescript
// 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:
typescript
// 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;
}
使用示例:
ts
// 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:
typescript
// 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' 指令?
typescript
'use server';
这个指令告诉 Next.js:
- 该文件中的所有导出函数都在服务端执行
- 可以在函数中访问数据库、环境变量等敏感资源
- 客户端调用时会自动序列化参数和返回值
(2) Zod Schema 验证的重要性
typescript
const validated = createPostSchema.safeParse(data);
if (!validated.success) {
return { error: '数据验证失败', details: validated.error.flatten() };
}
防御性编程原则:
- ✅ 永远不要信任客户端传来的数据
- ✅ 在服务端进行二次验证
- ✅ 提供清晰的错误提示
(3)缓存失效策略
typescript
revalidateTag('posts');
当我们创建/更新/删除文章后,需要通知 Next.js 清除相关缓存:
revalidateTag('posts'): 清除所有文章列表的缓存revalidatePath('/blog'): 清除特定路径的缓存
缓存失效时机:
- 创建文章 → 清除列表缓存
- 更新文章 → 清除详情 + 列表缓存
- 删除文章 → 清除详情 + 列表 + 用户缓存
2. 获取文章列表(带缓存)
创建 lib/posts.ts:
typescript
// 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 并行查询
typescript
const [posts, total] = await Promise.all([
db.post.findMany({ /* ... */ }),
db.post.count({ where }),
]);
而不是串行:
typescript
// ❌ 慢:两个查询依次执行
const posts = await db.post.findMany({ /* ... */ });
const total = await db.post.count({ where });
(2) 精确选择字段
typescript
include: {
author: {
select: { id: true, name: true, image: true }, // 只取需要的字段
},
}
避免 select: true 取出所有字段,减少网络传输和内存占用。
(3) 使用 _count 聚合查询
typescript
_count: {
select: { comments: true, likes: true },
}
直接在数据库层面统计数量,避免在应用层遍历数组。
4. 获取单篇文章详情
继续在 lib/posts.ts 中添加:
typescript
/**
* 根据 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 } },
});
}
💡 设计思考:
为什么浏览量更新不等待?
typescript
// 不阻塞主流程
incrementViewCount(post.id);
return post;
- ✅ 用户体验优先: 用户无需等待计数器更新
- ✅ 即使更新失败,也不影响文章展示
- ⚠️ 注意:在高并发场景可能需要队列或批量更新优化
六、🤖 AI 功能集成
1. 为什么要在博客中集成 AI?
传统博客系统的痛点:
- ❌ 作者需要手动编写摘要,耗时耗力
- ❌ 标签选择主观,不利于 SEO
- ❌ 相关文章推荐算法复杂
AI 可以解决这些问题:
- ✅ 自动生成摘要: 节省作者时间
- ✅ 智能标签推荐: 基于内容语义分析
- ✅ 个性化推荐: 提升用户停留时长
2. 配置 OpenAI
创建 lib/ai.ts:
typescript
// 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 参数调优
typescript
temperature: 0.7 // 摘要生成:需要一定创造性
temperature: 0.5 // 标签推荐:需要稳定性
- 0.0-0.3: 确定性输出,适合事实性问题
- 0.4-0.7: 平衡创造性和准确性
- 0.8-1.0: 高创造性,适合创意写作
(2)Prompt Engineering 技巧
typescript
prompt: `请为以下文章内容生成一段简洁的摘要(不超过 200 字)。
要求:
1. 突出核心观点
2. 语言精炼流畅
3. 吸引读者继续阅读`
有效 Prompt 的要素:
- ✅ 明确任务目标
- ✅ 列出具体要求
- ✅ 提供示例(Few-shot Learning)
- ✅ 限制输出格式
(3) 成本控制
typescript
const truncatedContent = content.substring(0, 2000);
- 限制输入长度,减少 Token 消耗
- 对于长文章,可以分段处理后合并
- 考虑使用更便宜的模型(如
gpt-3.5-turbo)进行测试
3. 在创建文章时调用 AI
修改 createPost 函数:
typescript
// 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 生成中..."的加载状态:
ts
// 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:
typescript
// 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 攻击
虽然我们在数据库中存储原始内容,但在渲染时需要转义:
ts
// 使用 dangerouslySetInnerHTML 时要谨慎
<div dangerouslySetInnerHTML={{ __html: sanitize(comment.content) }} />
可以使用 dompurify 库清理 HTML:
bash
npm install dompurify
npm install -D @types/dompurify
(2) 频率限制
防止用户刷评论:
typescript
// 检查用户最近 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) 敏感词过滤
typescript
const bannedWords = ['广告', '赌博', '色情'];
if (bannedWords.some(word => validated.data.content.includes(word))) {
return {
success: false,
error: '评论包含不当内容'
};
}
八、👍 点赞与收藏功能
1. 为什么需要点赞和收藏?
- 点赞: 量化文章受欢迎程度,激励作者
- 收藏: 用户个人知识库,方便后续查阅
- 数据分析: 了解用户偏好,优化内容策略
2. 切换点赞状态
创建 app/actions/interaction.ts:
typescript
// 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 全栈开发的核心技能! 💪