Next.js从入门到实战保姆级教程(第十六章):实战项目(上)——全栈博客系统架构与核心功能

本系列文章将围绕Next.js技术栈,旨在为AI Agent开发者提供一套完整的客户端侧工程实践指南。

理论学一百遍,不如动手做一遍。 本章将带你从零开始构建一个有实际价值的全栈博客系统 ,将前面所有章节的知识融会贯通。我们将分上下两篇完成这个项目,上篇聚焦架构设计与核心功能实现,下篇将完善前端页面、性能优化及部署上线。

一、📋 项目规划与设计思路

1. 为什么选择博客系统作为实战项目?

在开始编码之前,我们先思考一个问题:为什么博客系统是学习 Next.js 的最佳实战项目?

mindmap root((为什么选博客系统)) (技术覆盖面广) 路由系统 数据获取 表单处理 认证鉴权 (业务逻辑完整) CRUD 操作 权限控制 缓存策略 SEO 优化 (可扩展性强) 评论系统 AI 集成 搜索功能 管理后台 (真实应用场景) 个人品牌 技术分享 作品集展示

博客系统看似简单,实则涵盖了现代 Web 开发的几乎所有核心技术点:

  1. 内容管理系统(CMS):文章的创建、编辑、删除
  2. 用户系统:注册、登录、权限管理
  3. 交互功能:评论、点赞、收藏
  4. 性能优化:缓存策略、图片优化、SEO
  5. 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,不会提交到 Git
  • AUTH_SECRET 可使用命令生成: openssl rand -base64 32
  • 生产环境需在部署平台(Vercel/Docker)配置这些变量

第四步:启动开发服务器

bash 复制代码
npm run dev

访问 http://localhost:3000,如果看到 Next.js 欢迎页面,说明项目初始化成功! 🎉


三、🗄️ 数据库设计与 Prisma 建模

1. 为什么需要精心设计数据库?

数据库设计直接影响应用的性能、可扩展性和维护成本。对于博客系统,我们需要考虑:

  1. 实体关系: 用户、文章、标签、评论之间的关系
  2. 索引优化: 加速常用查询(如按 slug 查找文章)
  3. 数据完整性: 外键约束、级联删除
  4. 扩展预留: 未来可能添加的功能(如点赞、收藏)

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 文件:

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. 认证流程概览

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 文件(项目根目录):

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 IDClient 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. 评论系统设计要点

评论系统是博客的社交核心,需要考虑:

  1. 嵌套回复: 支持楼中楼式讨论
  2. 审核机制: 防止垃圾评论
  3. 实时更新: 新评论即时显示
  4. 权限控制: 仅登录用户可评论

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 模式:

  1. 检查当前状态
  2. 如果存在则删除,不存在则创建
  3. 返回新状态

这种模式的优点:

  • ✅ 幂等性:多次调用结果一致
  • ✅ 简化前端逻辑:无需分别实现"点赞"和"取消点赞"
  • ✅ 原子操作:避免竞态条件

九、📝 本章小结

通过实战项目上篇的学习,我们已经完成了博客系统的后端核心功能:

项目初始化 : 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()

十、🚀 下篇预告

下篇中,我们将实现:

  1. 前端页面开发:

    • 首页文章列表
    • 文章详情页(MDX 渲染)
    • 登录/注册页面
    • 管理后台
  2. UI 组件实现:

    • Markdown 代码高亮
    • 评论组件(嵌套显示)
    • 点赞/收藏按钮(Optimistic UI)
  3. 性能优化:

    • 图片懒加载
    • 并行数据获取
    • 流式渲染
  4. 部署上线:

    • Vercel 部署
    • 环境变量配置
    • 域名绑定

敬请期待! 🎉


练习作业:

  1. 尝试添加"文章编辑"功能(提示:参考 createPost,使用 db.post.update)
  2. 实现"删除文章"功能,并处理级联删除
  3. 添加"草稿箱"功能(区分 published: true/false)
  4. 实现简单的全文搜索(使用 Prisma 的 contains 查询)

完成这些练习,你将真正掌握 Next.js 全栈开发的核心技能! 💪

相关推荐
深海鱼在掘金1 小时前
Next.js从入门到实战保姆级教程(第十七章):综合实战项目(下)——前端页面、性能优化与部署
前端·ci/cd·next.js
步辞1 小时前
React 自定义 Hook 的命名规范与执行上下文详解
jvm·数据库·python
forEverPlume1 小时前
如何为 Go 中的 sync.WaitGroup.Wait() 添加超时机制
jvm·数据库·python
2401_883600252 小时前
mysql如何设置仅允许特定内网访问_MySQL权限配置中的IP绑定
jvm·数据库·python
treacle田2 小时前
达梦数据库-快速装载工具dmfldr-记录总结
数据库·sql·达梦快速装载dmfldr
阿维的博客日记2 小时前
什么是mvcc,面试的时候怎么说
数据库·mysql
茅盾体2 小时前
Electron图标相关
java·前端·electron
2401_871492852 小时前
SQL如何实现按自定义排序进行分组汇总_ORDERBY与聚合函数
jvm·数据库·python
qq_330037992 小时前
如何清洗SQL输入数据_使用框架内置的ORM处理数据交互
jvm·数据库·python