{T}

博客系统

本文档详细介绍如何构建一个完整的全栈博客系统,包含用户认证、文章管理、评论系统等核心功能。该系统采用前后端分离架构,后端基于 Node.js + Express + MongoDB,前端可对接 Vue/React 等现代框架。

项目概述

功能特性

用户功能模块

  • 用户注册/登录:支持邮箱注册,JWT 令牌认证
  • 个人信息管理:修改用户名、头像、个人简介
  • 头像上传:支持图片上传和存储
  • 角色权限:区分普通用户和管理员权限

文章功能模块

  • 文章管理:创建、编辑、删除文章
  • Markdown 支持:文章内容支持 Markdown 格式
  • 分类与标签:文章分类和标签管理
  • 草稿/发布:支持文章状态切换
  • 阅读统计:记录文章浏览量

互动功能模块

  • 评论系统:发表评论、回复评论
  • 点赞功能:文章和评论点赞
  • 关注系统:关注作者、查看关注动态

技术栈

层级技术选型说明
后端框架Express.jsWeb 应用框架
数据库MongoDB + MongooseNoSQL 数据库
认证方案JWTJSON Web Token
密码加密bcryptjs单向加密算法
文件上传multer文件处理中间件
跨域处理corsCORS 中间件

系统架构

code
┌─────────────────────────────────────────────────────────────────┐
│                         客户端 (Client)                          │
│         Vue.js / React / 小程序 / 移动端 App                      │
└─────────────────────────────────────────────────────────────────┘
                              │
                              │ HTTP/HTTPS
                              ▼
┌─────────────────────────────────────────────────────────────────┐
│                      API 网关层 (Gateway)                        │
│  • CORS 跨域处理                                                 │
│  • 请求日志记录                                                   │
│  • 静态资源服务                                                   │
└─────────────────────────────────────────────────────────────────┘
                              │
                              ▼
┌─────────────────────────────────────────────────────────────────┐
│                    中间件层 (Middleware)                          │
│  ┌──────────────┐  ┌──────────────┐  ┌──────────────┐           │
│  │   认证中间件   │  │   上传中间件   │  │   错误处理   │           │
│  │  (JWT验证)    │  │  (Multer)    │  │              │           │
│  └──────────────┘  └──────────────┘  └──────────────┘           │
└─────────────────────────────────────────────────────────────────┘
                              │
                              ▼
┌─────────────────────────────────────────────────────────────────┐
│                     业务逻辑层 (Controller)                       │
│  ┌──────────────┐  ┌──────────────┐  ┌──────────────┐           │
│  │ authController│  │articleController│ │commentController│       │
│  └──────────────┘  └──────────────┘  └──────────────┘           │
└─────────────────────────────────────────────────────────────────┘
                              │
                              ▼
┌─────────────────────────────────────────────────────────────────┐
│                      数据访问层 (Model)                           │
│  ┌──────────────┐  ┌──────────────┐  ┌──────────────┐           │
│  │    User      │  │   Article    │  │   Comment    │           │
│  │   (用户模型)  │  │  (文章模型)   │  │  (评论模型)   │           │
│  └──────────────┘  └──────────────┘  └──────────────┘           │
└─────────────────────────────────────────────────────────────────┘
                              │
                              ▼
┌─────────────────────────────────────────────────────────────────┐
│                      数据存储层 (Database)                        │
│                         MongoDB 数据库                            │
└─────────────────────────────────────────────────────────────────┘

数据模型关系

code
┌─────────────┐       ┌─────────────┐       ┌─────────────┐
│    User     │       │   Article   │       │   Comment   │
├─────────────┤       ├─────────────┤       ├─────────────┤
│ _id         │◄──────│ author      │       │ author      │
│ username    │       │ _id         │◄──────│ article     │
│ email       │       │ title       │       │ content     │
│ password    │       │ content     │       │ replyTo     │
│ avatar      │       │ category    │       │ likes       │
│ bio         │       │ tags        │       │ createdAt   │
│ role        │       │ views       │       │ updatedAt   │
│ createdAt   │       │ likes[]     │       └─────────────┘
│ updatedAt   │       │ status      │
└─────────────┘       │ createdAt   │
                      │ updatedAt   │
                      └─────────────┘

关系说明:
• User -> Article: 一对多(一个用户可以发布多篇文章)
• User -> Comment: 一对多(一个用户可以发表多条评论)
• Article -> Comment: 一对多(一篇文章可以有多条评论)
• Article -> User (likes): 多对多(多用户可以点赞多篇文章)

项目结构

code
blog-system/
├── server/                          # 后端服务
│   ├── config/                      # 配置文件
│   │   └── database.js              # 数据库连接配置
│   ├── controllers/                 # 控制器层
│   │   ├── authController.js        # 认证控制器
│   │   ├── articleController.js     # 文章控制器
│   │   └── commentController.js     # 评论控制器
│   ├── models/                      # 数据模型层
│   │   ├── User.js                  # 用户模型
│   │   ├── Article.js               # 文章模型
│   │   └── Comment.js               # 评论模型
│   ├── routes/                      # 路由层
│   │   ├── auth.js                  # 认证路由
│   │   ├── articles.js              # 文章路由
│   │   └── comments.js              # 评论路由
│   ├── middleware/                  # 中间件
│   │   ├── auth.js                  # 认证中间件
│   │   └── upload.js                # 文件上传中间件
│   ├── utils/                       # 工具函数
│   │   └── jwt.js                   # JWT 工具
│   ├── uploads/                     # 上传文件目录
│   └── app.js                       # 应用入口
├── client/                          # 前端应用
│   ├── src/
│   │   ├── components/              # 组件目录
│   │   ├── pages/                   # 页面目录
│   │   ├── utils/                   # 工具函数
│   │   ├── api/                     # API 封装
│   │   └── App.vue                  # 根组件
│   └── package.json
├── package.json                     # 项目配置
├── .env                             # 环境变量
└── README.md                        # 项目文档

目录职责说明

目录/文件职责说明
config/存放数据库连接、环境配置等配置文件
controllers/处理业务逻辑,调用 Model 进行数据操作
models/定义数据库模型和数据结构
routes/定义 API 路由和端点映射
middleware/处理跨切面逻辑(认证、上传、错误处理)
utils/存放通用工具函数
uploads/存储用户上传的文件资源

后端实现

1. 应用入口 (server/app.js)

主入口文件负责初始化 Express 应用、连接数据库、配置中间件和挂载路由。

javascript
const express = require('express');
const mongoose = require('mongoose');
const cors = require('cors');
const path = require('path');

const authRoutes = require('./routes/auth');
const articleRoutes = require('./routes/articles');
const commentRoutes = require('./routes/comments');

const app = express();

// 中间件配置
app.use(cors());                                            // 跨域处理
app.use(express.json());                                    // JSON 解析
app.use(express.urlencoded({ extended: true }));            // URL 编码解析
app.use('/uploads', express.static(path.join(__dirname, 'uploads'))); // 静态资源

// 数据库连接
mongoose.connect(process.env.MONGODB_URI || 'mongodb://localhost:27017/blog', {
  useNewUrlParser: true,
  useUnifiedTopology: true
})
.then(() => console.log('MongoDB 连接成功'))
.catch(err => console.error('MongoDB 连接失败:', err));

// API 路由挂载
app.use('/api/auth', authRoutes);       // 认证相关: /api/auth/*
app.use('/api/articles', articleRoutes); // 文章相关: /api/articles/*
app.use('/api/comments', commentRoutes); // 评论相关: /api/comments/*

// 404 处理
app.use((req, res) => {
  res.status(404).json({ message: '请求的资源不存在' });
});

// 全局错误处理
app.use((err, req, res, next) => {
  console.error(`[${new Date().toISOString()}] Error:`, err.stack);
  
  const statusCode = err.status || 500;
  res.status(statusCode).json({
    message: err.message || '服务器错误',
    ...(process.env.NODE_ENV === 'development' && { stack: err.stack })
  });
});

// 启动服务器
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
  console.log(`服务器运行在 http://localhost:${PORT}`);
  console.log(`API 基础路径: http://localhost:${PORT}/api`);
});

配置参数说明:

参数类型默认值说明
PORTNumber3000服务器端口
MONGODB_URIStringmongodb://localhost:27017/blog数据库连接地址
NODE_ENVStringdevelopment运行环境

2. 数据模型层 (server/models/)

User.js - 用户模型

定义用户数据结构,包含密码加密和验证方法。

javascript
const mongoose = require('mongoose');
const bcrypt = require('bcryptjs');

const userSchema = new mongoose.Schema({
  username: {
    type: String,
    required: [true, '用户名不能为空'],
    unique: true,
    trim: true,
    minlength: [3, '用户名至少 3 个字符'],
    maxlength: [20, '用户名最多 20 个字符']
  },
  email: {
    type: String,
    required: [true, '邮箱不能为空'],
    unique: true,
    trim: true,
    lowercase: true,
    match: [/^\w+([.-]?\w+)*@\w+([.-]?\w+)*(\.\w{2,3})+$/, '邮箱格式不正确']
  },
  password: {
    type: String,
    required: [true, '密码不能为空'],
    minlength: [6, '密码至少 6 个字符'],
    select: false  // 查询时默认不返回密码
  },
  avatar: {
    type: String,
    default: ''
  },
  bio: {
    type: String,
    maxlength: [200, '个人简介最多 200 个字符']
  },
  role: {
    type: String,
    enum: ['user', 'admin'],
    default: 'user'
  }
}, {
  timestamps: true,           // 自动添加 createdAt 和 updatedAt
  toJSON: { virtuals: true }  // 虚拟字段转为 JSON
});

// 保存前加密密码
userSchema.pre('save', async function(next) {
  // 只有密码被修改时才重新加密
  if (!this.isModified('password')) return next();
  this.password = await bcrypt.hash(this.password, 10);
  next();
});

// 密码验证实例方法
userSchema.methods.comparePassword = async function(password) {
  return bcrypt.compare(password, this.password);
};

// 返回用户信息时隐藏敏感字段
userSchema.methods.toSafeObject = function() {
  const user = this.toObject();
  delete user.password;
  delete user.__v;
  return user;
};

module.exports = mongoose.model('User', userSchema);

用户模型字段说明:

字段类型必填约束说明
usernameString3-20字符,唯一用户名
emailString邮箱格式,唯一登录邮箱
passwordString最少6字符加密存储的密码
avatarString-头像 URL
bioString最多200字符个人简介
roleStringuser/admin用户角色
createdAtDate自动-创建时间
updatedAtDate自动-更新时间

Article.js - 文章模型

定义文章数据结构,包含摘要生成和统计功能。

javascript
const mongoose = require('mongoose');

const articleSchema = new mongoose.Schema({
  title: {
    type: String,
    required: [true, '文章标题不能为空'],
    trim: true,
    maxlength: [100, '标题最多 100 个字符']
  },
  content: {
    type: String,
    required: [true, '文章内容不能为空']
  },
  summary: {
    type: String,
    maxlength: [200, '摘要最多 200 个字符']
  },
  cover: {
    type: String,
    default: ''
  },
  author: {
    type: mongoose.Schema.Types.ObjectId,
    ref: 'User',
    required: true
  },
  category: {
    type: String,
    required: [true, '文章分类不能为空'],
    enum: ['技术', '生活', '随笔', '其他']
  },
  tags: [{
    type: String,
    trim: true
  }],
  views: {
    type: Number,
    default: 0
  },
  likes: [{
    type: mongoose.Schema.Types.ObjectId,
    ref: 'User'
  }],
  status: {
    type: String,
    enum: ['draft', 'published'],
    default: 'draft'
  }
}, {
  timestamps: true
});

// 创建文本索引支持搜索
articleSchema.index({ title: 'text', content: 'text' });

// 获取文章摘要
articleSchema.methods.toSummary = function() {
  return {
    id: this._id,
    title: this.title,
    summary: this.summary || this.content.substring(0, 100) + '...',
    cover: this.cover,
    author: this.author,
    category: this.category,
    tags: this.tags,
    views: this.views,
    likes: this.likes.length,
    createdAt: this.createdAt,
    updatedAt: this.updatedAt
  };
};

// 检查用户是否已点赞
articleSchema.methods.isLikedBy = function(userId) {
  return this.likes.includes(userId);
};

module.exports = mongoose.model('Article', articleSchema);

文章模型字段说明:

字段类型必填约束说明
titleString最多100字符文章标题
contentString-文章正文(Markdown)
summaryString最多200字符文章摘要
coverString-封面图片 URL
authorObjectId关联 User作者 ID
categoryString预定义值文章分类
tags[String]-标签数组
viewsNumber默认0浏览次数
likes[ObjectId]关联 User点赞用户列表
statusStringdraft/published发布状态

Comment.js - 评论模型

定义评论数据结构,支持评论回复和点赞功能。

javascript
const mongoose = require('mongoose');

const commentSchema = new mongoose.Schema({
  content: {
    type: String,
    required: [true, '评论内容不能为空'],
    maxlength: [500, '评论最多 500 个字符']
  },
  article: {
    type: mongoose.Schema.Types.ObjectId,
    ref: 'Article',
    required: true,
    index: true  // 添加索引优化查询
  },
  author: {
    type: mongoose.Schema.Types.ObjectId,
    ref: 'User',
    required: true
  },
  replyTo: {
    type: mongoose.Schema.Types.ObjectId,
    ref: 'User',
    default: null
  },
  likes: [{
    type: mongoose.Schema.Types.ObjectId,
    ref: 'User'
  }]
}, {
  timestamps: true
});

// 组合索引:按文章查询评论,按时间倒序
commentSchema.index({ article: 1, createdAt: -1 });

// 获取安全输出的评论对象
commentSchema.methods.toSafeObject = function() {
  return {
    id: this._id,
    content: this.content,
    article: this.article,
    author: this.author,
    replyTo: this.replyTo,
    likes: this.likes.length,
    createdAt: this.createdAt,
    updatedAt: this.updatedAt
  };
};

module.exports = mongoose.model('Comment', commentSchema);

评论模型字段说明:

字段类型必填约束说明
contentString最多500字符评论内容
articleObjectId关联 Article所属文章 ID
authorObjectId关联 User评论者 ID
replyToObjectId关联 User回复的目标用户
likes[ObjectId]关联 User点赞用户列表
createdAtDate自动-创建时间
updatedAtDate自动-更新时间

3. 路由层 (server/routes/)

auth.js - 认证路由

处理用户注册、登录和信息获取。

javascript
const express = require('express');
const router = express.Router();
const User = require('../models/User');
const { generateToken, verifyToken } = require('../utils/jwt');

/**
 * POST /api/auth/register
 * 用户注册
 * @body {string} username - 用户名
 * @body {string} email - 邮箱
 * @body {string} password - 密码
 */
router.post('/register', async (req, res) => {
  try {
    const { username, email, password } = req.body;

    // 参数验证
    if (!username || !email || !password) {
      return res.status(400).json({
        success: false,
        message: '用户名、邮箱和密码为必填项'
      });
    }

    // 检查用户是否已存在
    const existingUser = await User.findOne({
      $or: [{ email }, { username }]
    });
    if (existingUser) {
      return res.status(400).json({
        success: false,
        message: '用户名或邮箱已被注册'
      });
    }

    // 创建新用户
    const user = new User({ username, email, password });
    await user.save();

    // 生成 JWT Token
    const token = generateToken({ userId: user._id });

    res.status(201).json({
      success: true,
      message: '注册成功',
      data: {
        user: user.toSafeObject(),
        token
      }
    });
  } catch (error) {
    res.status(500).json({
      success: false,
      message: '注册失败',
      error: error.message
    });
  }
});

/**
 * POST /api/auth/login
 * 用户登录
 * @body {string} email - 邮箱
 * @body {string} password - 密码
 */
router.post('/login', async (req, res) => {
  try {
    const { email, password } = req.body;

    // 参数验证
    if (!email || !password) {
      return res.status(400).json({
        success: false,
        message: '邮箱和密码为必填项'
      });
    }

    // 查找用户(需要显式选择 password 字段)
    const user = await User.findOne({ email }).select('+password');
    if (!user) {
      return res.status(401).json({
        success: false,
        message: '邮箱或密码错误'
      });
    }

    // 验证密码
    const isMatch = await user.comparePassword(password);
    if (!isMatch) {
      return res.status(401).json({
        success: false,
        message: '邮箱或密码错误'
      });
    }

    // 生成 Token
    const token = generateToken({ userId: user._id });

    res.json({
      success: true,
      message: '登录成功',
      data: {
        user: user.toSafeObject(),
        token
      }
    });
  } catch (error) {
    res.status(500).json({
      success: false,
      message: '登录失败',
      error: error.message
    });
  }
});

/**
 * GET /api/auth/me
 * 获取当前登录用户信息
 * @header Authorization: Bearer <token>
 */
router.get('/me', verifyToken, async (req, res) => {
  try {
    const user = await User.findById(req.userId);
    if (!user) {
      return res.status(404).json({
        success: false,
        message: '用户不存在'
      });
    }
    res.json({
      success: true,
      data: user.toSafeObject()
    });
  } catch (error) {
    res.status(500).json({
      success: false,
      message: '获取用户信息失败'
    });
  }
});

/**
 * PUT /api/auth/profile
 * 更新用户信息
 * @header Authorization: Bearer <token>
 */
router.put('/profile', verifyToken, async (req, res) => {
  try {
    const { username, bio, avatar } = req.body;
    const user = await User.findByIdAndUpdate(
      req.userId,
      { username, bio, avatar },
      { new: true, runValidators: true }
    );
    res.json({
      success: true,
      message: '更新成功',
      data: user.toSafeObject()
    });
  } catch (error) {
    res.status(500).json({
      success: false,
      message: '更新失败',
      error: error.message
    });
  }
});

module.exports = router;

认证 API 接口:

方法路径功能认证
POST/api/auth/register用户注册
POST/api/auth/login用户登录
GET/api/auth/me获取当前用户
PUT/api/auth/profile更新用户信息

articles.js - 文章路由

处理文章的 CRUD 操作、点赞和列表查询。

javascript
const express = require('express');
const router = express.Router();
const Article = require('../models/Article');
const Comment = require('../models/Comment');
const { verifyToken, optionalAuth } = require('../middleware/auth');

/**
 * GET /api/articles
 * 获取文章列表(支持分页和筛选)
 * @query {number} page - 页码
 * @query {number} limit - 每页数量
 * @query {string} category - 分类筛选
 * @query {string} tag - 标签筛选
 * @query {string} author - 作者筛选
 */
router.get('/', optionalAuth, async (req, res) => {
  try {
    const { page = 1, limit = 10, category, tag, author, keyword } = req.query;

    // 构建查询条件
    const query = { status: 'published' };
    if (category) query.category = category;
    if (tag) query.tags = tag;
    if (author) query.author = author;
    if (keyword) {
      query.$or = [
        { title: { $regex: keyword, $options: 'i' } },
        { content: { $regex: keyword, $options: 'i' } }
      ];
    }

    // 分页查询
    const articles = await Article.find(query)
      .populate('author', 'username avatar')
      .sort({ createdAt: -1 })
      .skip((page - 1) * limit)
      .limit(parseInt(limit));

    const total = await Article.countDocuments(query);

    res.json({
      success: true,
      data: {
        articles: articles.map(a => a.toSummary()),
        pagination: {
          total,
          page: parseInt(page),
          limit: parseInt(limit),
          pages: Math.ceil(total / limit)
        }
      }
    });
  } catch (error) {
    res.status(500).json({
      success: false,
      message: '获取文章列表失败'
    });
  }
});

/**
 * GET /api/articles/:id
 * 获取文章详情
 * @param {string} id - 文章ID
 */
router.get('/:id', optionalAuth, async (req, res) => {
  try {
    const article = await Article.findById(req.params.id)
      .populate('author', 'username avatar bio');

    if (!article) {
      return res.status(404).json({
        success: false,
        message: '文章不存在'
      });
    }

    // 增加阅读量
    article.views += 1;
    await article.save();

    // 检查当前用户是否已点赞
    const isLiked = req.userId ? article.isLikedBy(req.userId) : false;

    res.json({
      success: true,
      data: {
        ...article.toObject(),
        isLiked,
        likesCount: article.likes.length
      }
    });
  } catch (error) {
    res.status(500).json({
      success: false,
      message: '获取文章失败'
    });
  }
});

/**
 * POST /api/articles
 * 创建文章(需要登录)
 * @body {string} title - 标题
 * @body {string} content - 内容
 * @body {string} summary - 摘要
 * @body {string} category - 分类
 * @body {string[]} tags - 标签
 * @body {string} status - 状态
 */
router.post('/', verifyToken, async (req, res) => {
  try {
    const { title, content, summary, category, tags, status } = req.body;

    // 参数验证
    if (!title || !content || !category) {
      return res.status(400).json({
        success: false,
        message: '标题、内容和分类为必填项'
      });
    }

    const article = new Article({
      title,
      content,
      summary,
      category,
      tags: tags || [],
      status: status || 'draft',
      author: req.userId
    });

    await article.save();

    res.status(201).json({
      success: true,
      message: '文章创建成功',
      data: article
    });
  } catch (error) {
    res.status(500).json({
      success: false,
      message: '创建文章失败',
      error: error.message
    });
  }
});

/**
 * PUT /api/articles/:id
 * 更新文章(需要登录且是作者)
 */
router.put('/:id', verifyToken, async (req, res) => {
  try {
    const article = await Article.findById(req.params.id);

    if (!article) {
      return res.status(404).json({
        success: false,
        message: '文章不存在'
      });
    }

    // 权限检查
    if (article.author.toString() !== req.userId) {
      return res.status(403).json({
        success: false,
        message: '无权限修改此文章'
      });
    }

    // 更新字段
    const allowedFields = ['title', 'content', 'summary', 'category', 'tags', 'status'];
    allowedFields.forEach(field => {
      if (req.body[field] !== undefined) {
        article[field] = req.body[field];
      }
    });

    await article.save();

    res.json({
      success: true,
      message: '文章更新成功',
      data: article
    });
  } catch (error) {
    res.status(500).json({
      success: false,
      message: '更新文章失败'
    });
  }
});

/**
 * DELETE /api/articles/:id
 * 删除文章(需要登录且是作者)
 */
router.delete('/:id', verifyToken, async (req, res) => {
  try {
    const article = await Article.findById(req.params.id);

    if (!article) {
      return res.status(404).json({
        success: false,
        message: '文章不存在'
      });
    }

    // 权限检查
    if (article.author.toString() !== req.userId) {
      return res.status(403).json({
        success: false,
        message: '无权限删除此文章'
      });
    }

    // 删除文章及其评论
    await Promise.all([
      article.deleteOne(),
      Comment.deleteMany({ article: req.params.id })
    ]);

    res.json({
      success: true,
      message: '文章删除成功'
    });
  } catch (error) {
    res.status(500).json({
      success: false,
      message: '删除文章失败'
    });
  }
});

/**
 * POST /api/articles/:id/like
 * 点赞/取消点赞文章
 */
router.post('/:id/like', verifyToken, async (req, res) => {
  try {
    const article = await Article.findById(req.params.id);
    if (!article) {
      return res.status(404).json({
        success: false,
        message: '文章不存在'
      });
    }

    // 判断是否已点赞
    const likeIndex = article.likes.findIndex(
      id => id.toString() === req.userId
    );

    let isLiked;
    if (likeIndex > -1) {
      // 取消点赞
      article.likes.splice(likeIndex, 1);
      isLiked = false;
    } else {
      // 添加点赞
      article.likes.push(req.userId);
      isLiked = true;
    }

    await article.save();

    res.json({
      success: true,
      data: {
        isLiked,
        likesCount: article.likes.length
      }
    });
  } catch (error) {
    res.status(500).json({
      success: false,
      message: '操作失败'
    });
  }
});

module.exports = router;

文章 API 接口:

方法路径功能认证
GET/api/articles获取文章列表可选
GET/api/articles/:id获取文章详情可选
POST/api/articles创建文章必须
PUT/api/articles/:id更新文章必须(作者)
DELETE/api/articles/:id删除文章必须(作者)
POST/api/articles/:id/like点赞/取消点赞必须

4. 中间件层 (server/middleware/)

auth.js - 认证中间件

验证 JWT Token 并设置用户信息。

javascript
const { verifyToken: verify } = require('../utils/jwt');

/**
 * 必须认证中间件
 * 验证请求头中的 JWT Token,失败则返回 401
 */
exports.verifyToken = async (req, res, next) => {
  try {
    // 从请求头获取 Token
    const authHeader = req.headers.authorization;
    if (!authHeader || !authHeader.startsWith('Bearer ')) {
      return res.status(401).json({
        success: false,
        message: '请先登录',
        code: 'UNAUTHORIZED'
      });
    }

    const token = authHeader.replace('Bearer ', '');

    // 验证 Token
    const decoded = verify(token);
    req.userId = decoded.userId;
    req.userRole = decoded.role;

    next();
  } catch (error) {
    if (error.name === 'TokenExpiredError') {
      return res.status(401).json({
        success: false,
        message: 'Token 已过期,请重新登录',
        code: 'TOKEN_EXPIRED'
      });
    }
    if (error.name === 'JsonWebTokenError') {
      return res.status(401).json({
        success: false,
        message: 'Token 无效',
        code: 'TOKEN_INVALID'
      });
    }
    next(error);
  }
};

/**
 * 可选认证中间件
 * 如果有 Token 则验证,无 Token 也可以继续
 */
exports.optionalAuth = async (req, res, next) => {
  try {
    const authHeader = req.headers.authorization;
    if (authHeader && authHeader.startsWith('Bearer ')) {
      const token = authHeader.replace('Bearer ', '');
      const decoded = verify(token);
      req.userId = decoded.userId;
      req.userRole = decoded.role;
    }
    next();
  } catch (error) {
    // 可选认证失败时继续执行
    next();
  }
};

/**
 * 管理员权限检查中间件
 * 需要在 verifyToken 之后使用
 */
exports.isAdmin = (req, res, next) => {
  if (req.userRole !== 'admin') {
    return res.status(403).json({
      success: false,
      message: '需要管理员权限',
      code: 'FORBIDDEN'
    });
  }
  next();
};

upload.js - 文件上传中间件

处理图片等文件的上传。

javascript
const multer = require('multer');
const path = require('path');
const fs = require('fs');

// 确保上传目录存在
const uploadDir = path.join(__dirname, '../uploads');
if (!fs.existsSync(uploadDir)) {
  fs.mkdirSync(uploadDir, { recursive: true });
}

// 存储配置
const storage = multer.diskStorage({
  destination: (req, file, cb) => {
    cb(null, uploadDir);
  },
  filename: (req, file, cb) => {
    // 生成唯一文件名: 时间戳-随机数.扩展名
    const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9);
    const ext = path.extname(file.originalname);
    cb(null, `image-${uniqueSuffix}${ext}`);
  }
});

// 文件过滤器
const fileFilter = (req, file, cb) => {
  const allowedTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'];
  if (allowedTypes.includes(file.mimetype)) {
    cb(null, true);
  } else {
    cb(new Error('不支持的文件类型,仅支持 JPG、PNG、GIF、WebP'), false);
  }
};

// 上传配置
const upload = multer({
  storage,
  fileFilter,
  limits: {
    fileSize: 5 * 1024 * 1024  // 最大 5MB
  }
});

// 导出上传中间件
exports.uploadSingle = upload.single('file');
exports.uploadMultiple = upload.array('files', 5);

// 上传错误处理
exports.handleUploadError = (err, req, res, next) => {
  if (err instanceof multer.MulterError) {
    if (err.code === 'LIMIT_FILE_SIZE') {
      return res.status(400).json({
        success: false,
        message: '文件大小不能超过 5MB'
      });
    }
    return res.status(400).json({
      success: false,
      message: '文件上传失败: ' + err.message
    });
  }
  next(err);
};

5. 工具函数 (server/utils/)

jwt.js - JWT 工具

生成和验证 JWT Token。

javascript
const jwt = require('jsonwebtoken');

const JWT_SECRET = process.env.JWT_SECRET || 'your-secret-key';
const JWT_EXPIRES_IN = process.env.JWT_EXPIRES_IN || '7d';

/**
 * 生成 JWT Token
 * @param {Object} payload - 载荷数据
 * @returns {string} Token
 */
exports.generateToken = (payload) => {
  return jwt.sign(payload, JWT_SECRET, {
    expiresIn: JWT_EXPIRES_IN
  });
};

/**
 * 验证 JWT Token
 * @param {string} token - Token 字符串
 * @returns {Object} 解码后的载荷
 */
exports.verifyToken = (token) => {
  return jwt.verify(token, JWT_SECRET);
};

/**
 * 刷新 Token
 * @param {string} token - 旧 Token
 * @returns {string} 新 Token
 */
exports.refreshToken = (token) => {
  const decoded = this.verifyToken(token);
  return this.generateToken({ userId: decoded.userId });
};

JWT 配置参数:

参数环境变量默认值说明
JWT_SECRETJWT_SECRETyour-secret-key密钥(生产环境必须修改)
JWT_EXPIRES_INJWT_EXPIRES_IN7d过期时间

server/utils/jwt.js

javascript
const jwt = require('jsonwebtoken');

const JWT_SECRET = process.env.JWT_SECRET || 'your-secret-key';
const JWT_EXPIRES_IN = '7d';

exports.generateToken = (payload) => {
  return jwt.sign(payload, JWT_SECRET, { expiresIn: JWT_EXPIRES_IN });
};

exports.verifyToken = (token) => {
  return jwt.verify(token, JWT_SECRET);
};

API 接口文档

接口总览

模块方法路径功能认证
认证POST/api/auth/register用户注册
认证POST/api/auth/login用户登录
认证GET/api/auth/me获取当前用户
认证PUT/api/auth/profile更新用户信息
文章GET/api/articles获取文章列表可选
文章GET/api/articles/:id获取文章详情可选
文章POST/api/articles创建文章
文章PUT/api/articles/:id更新文章是(作者)
文章DELETE/api/articles/:id删除文章是(作者)
文章POST/api/articles/:id/like点赞/取消点赞
评论GET/api/comments获取评论列表可选
评论POST/api/comments创建评论
评论DELETE/api/comments/:id删除评论是(作者)

统一响应格式

成功响应:

json
{
  "success": true,
  "message": "操作成功",
  "data": { ... }
}

失败响应:

json
{
  "success": false,
  "message": "错误描述",
  "code": "ERROR_CODE"
}

接口详情示例

用户注册

http
POST /api/auth/register
Content-Type: application/json

{
  "username": "testuser",
  "email": "test@example.com",
  "password": "123456"
}

成功响应(201):

json
{
  "success": true,
  "message": "注册成功",
  "data": {
    "user": {
      "id": "64abc123...",
      "username": "testuser",
      "email": "test@example.com",
      "avatar": "",
      "bio": "",
      "role": "user",
      "createdAt": "2024-01-15T08:00:00.000Z"
    },
    "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
  }
}

用户登录

http
POST /api/auth/login
Content-Type: application/json

{
  "email": "test@example.com",
  "password": "123456"
}

成功响应(200):

json
{
  "success": true,
  "message": "登录成功",
  "data": {
    "user": { ... },
    "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
  }
}

获取文章列表

http
GET /api/articles?page=1&limit=10&category=技术

成功响应(200):

json
{
  "success": true,
  "data": {
    "articles": [
      {
        "id": "64abc123...",
        "title": "文章标题",
        "summary": "这是文章摘要...",
        "cover": "http://example.com/cover.jpg",
        "author": {
          "id": "64abc456...",
          "username": "author",
          "avatar": "http://example.com/avatar.jpg"
        },
        "category": "技术",
        "tags": ["Node.js", "Express"],
        "views": 100,
        "likes": 15,
        "createdAt": "2024-01-15T08:00:00.000Z"
      }
    ],
    "pagination": {
      "total": 50,
      "page": 1,
      "limit": 10,
      "pages": 5
    }
  }
}

创建文章

http
POST /api/articles
Authorization: Bearer <token>
Content-Type: application/json

{
  "title": "我的第一篇文章",
  "content": "# 这是文章内容\n\n文章正文...",
  "summary": "这是一篇关于...",
  "category": "技术",
  "tags": ["Node.js", "MongoDB"],
  "status": "published"
}

成功响应(201):

json
{
  "success": true,
  "message": "文章创建成功",
  "data": {
    "id": "64abc123...",
    "title": "我的第一篇文章",
    "content": "# 这是文章内容...",
    "status": "published",
    "createdAt": "2024-01-15T08:00:00.000Z"
  }
}

前端实现示例

API 封装 (client/src/api/index.js)

封装所有 API 请求,统一处理认证和错误。

javascript
const API_BASE = process.env.VUE_APP_API_BASE || 'http://localhost:3000/api';

/**
 * 统一请求封装
 * @param {string} url - 请求路径
 * @param {Object} options - 请求配置
 * @returns {Promise} 响应数据
 */
async function request(url, options = {}) {
  const token = localStorage.getItem('token');

  // 默认请求头
  const headers = {
    'Content-Type': 'application/json',
    ...(token && { Authorization: `Bearer ${token}` }),
    ...options.headers
  };

  try {
    const response = await fetch(`${API_BASE}${url}`, {
      ...options,
      headers
    });

    const data = await response.json();

    // 请求失败
    if (!response.ok) {
      // Token 过期,清除本地存储并跳转登录
      if (response.status === 401 && data.code === 'TOKEN_EXPIRED') {
        localStorage.removeItem('token');
        localStorage.removeItem('user');
        window.location.href = '/login';
      }
      throw new Error(data.message || '请求失败');
    }

    return data;
  } catch (error) {
    console.error('API Error:', error);
    throw error;
  }
}

/**
 * 认证相关 API
 */
export const authAPI = {
  /**
   * 用户注册
   * @param {Object} data - { username, email, password }
   */
  register: (data) => request('/auth/register', {
    method: 'POST',
    body: JSON.stringify(data)
  }),

  /**
   * 用户登录
   * @param {Object} data - { email, password }
   */
  login: async (data) => {
    const res = await request('/auth/login', {
      method: 'POST',
      body: JSON.stringify(data)
    });
    // 保存 Token 和用户信息
    if (res.success) {
      localStorage.setItem('token', res.data.token);
      localStorage.setItem('user', JSON.stringify(res.data.user));
    }
    return res;
  },

  /**
   * 获取当前用户信息
   */
  getMe: () => request('/auth/me'),

  /**
   * 更新用户信息
   * @param {Object} data - { username, bio, avatar }
   */
  updateProfile: (data) => request('/auth/profile', {
    method: 'PUT',
    body: JSON.stringify(data)
  }),

  /**
   * 退出登录
   */
  logout: () => {
    localStorage.removeItem('token');
    localStorage.removeItem('user');
    window.location.href = '/login';
  }
};

/**
 * 文章相关 API
 */
export const articleAPI = {
  /**
   * 获取文章列表
   * @param {Object} params - { page, limit, category, tag, author, keyword }
   */
  getList: (params = {}) => {
    const query = new URLSearchParams(
      Object.entries(params).filter(([_, v]) => v !== undefined)
    ).toString();
    return request(`/articles?${query}`);
  },

  /**
   * 获取文章详情
   * @param {string} id - 文章ID
   */
  getDetail: (id) => request(`/articles/${id}`),

  /**
   * 创建文章
   * @param {Object} data - { title, content, summary, category, tags, status }
   */
  create: (data) => request('/articles', {
    method: 'POST',
    body: JSON.stringify(data)
  }),

  /**
   * 更新文章
   * @param {string} id - 文章ID
   * @param {Object} data - 更新数据
   */
  update: (id, data) => request(`/articles/${id}`, {
    method: 'PUT',
    body: JSON.stringify(data)
  }),

  /**
   * 删除文章
   * @param {string} id - 文章ID
   */
  delete: (id) => request(`/articles/${id}`, {
    method: 'DELETE'
  }),

  /**
   * 点赞/取消点赞
   * @param {string} id - 文章ID
   */
  toggleLike: (id) => request(`/articles/${id}/like`, {
    method: 'POST'
  })
};

/**
 * 评论相关 API
 */
export const commentAPI = {
  /**
   * 获取文章评论列表
   * @param {string} articleId - 文章ID
   */
  getList: (articleId) => request(`/comments?article=${articleId}`),

  /**
   * 创建评论
   * @param {Object} data - { content, article, replyTo }
   */
  create: (data) => request('/comments', {
    method: 'POST',
    body: JSON.stringify(data)
  }),

  /**
   * 删除评论
   * @param {string} id - 评论ID
   */
  delete: (id) => request(`/comments/${id}`, {
    method: 'DELETE'
  })
};

使用示例

javascript
import { authAPI, articleAPI } from '@/api';

// 用户登录示例
async function handleLogin() {
  try {
    const res = await authAPI.login({
      email: 'user@example.com',
      password: '123456'
    });
    console.log('登录成功:', res.data.user);
  } catch (error) {
    console.error('登录失败:', error.message);
  }
}

// 获取文章列表示例
async function loadArticles() {
  try {
    const res = await articleAPI.getList({
      page: 1,
      limit: 10,
      category: '技术'
    });
    console.log('文章列表:', res.data.articles);
    console.log('分页信息:', res.data.pagination);
  } catch (error) {
    console.error('加载失败:', error.message);
  }
}

// 创建文章示例
async function createArticle() {
  try {
    const res = await articleAPI.create({
      title: '我的文章标题',
      content: '# 文章内容\n\n这是正文...',
      category: '技术',
      tags: ['Node.js', 'MongoDB'],
      status: 'published'
    });
    console.log('创建成功:', res.data);
  } catch (error) {
    console.error('创建失败:', error.message);
  }
}

项目部署

环境要求

  • Node.js >= 14.0.0
  • MongoDB >= 4.4
  • npm 或 yarn

安装步骤

bash
# 克隆项目
git clone <repository-url>
cd blog-system

# 安装依赖
npm install

# 配置环境变量
cp .env.example .env
# 编辑 .env 文件配置数据库连接等信息

# 启动 MongoDB(确保已安装)
mongod --dbpath /path/to/data

# 启动开发服务器
npm run dev

# 生产环境启动
npm start

环境变量配置

创建 .env 文件:

bash
# .env
NODE_ENV=development
PORT=3000

# MongoDB 配置
MONGODB_URI=mongodb://localhost:27017/blog

# JWT 配置
JWT_SECRET=your-super-secret-key-change-in-production
JWT_EXPIRES_IN=7d

# 文件上传配置
UPLOAD_DIR=./uploads
MAX_FILE_SIZE=5242880

package.json 配置

json
{
  "name": "blog-system",
  "version": "1.0.0",
  "scripts": {
    "start": "node server/app.js",
    "dev": "nodemon server/app.js",
    "test": "jest",
    "lint": "eslint server/"
  },
  "dependencies": {
    "bcryptjs": "^2.4.3",
    "cors": "^2.8.5",
    "express": "^4.18.2",
    "jsonwebtoken": "^9.0.0",
    "mongoose": "^7.0.0",
    "multer": "^1.4.5-lts.1"
  },
  "devDependencies": {
    "eslint": "^8.45.0",
    "jest": "^29.6.0",
    "nodemon": "^3.0.1"
  }
}

最佳实践

1. 安全性措施

javascript
const helmet = require('helmet');
const rateLimit = require('express-rate-limit');
const mongoSanitize = require('express-mongo-sanitize');

// 安全头设置
app.use(helmet());

// 速率限制
const limiter = rateLimit({
  windowMs: 15 * 60 * 1000,  // 15 分钟
  max: 100,                   // 每个 IP 最多 100 次请求
  message: { success: false, message: '请求过于频繁' }
});
app.use('/api/', limiter);

// 防止 NoSQL 注入
app.use(mongoSanitize());

// XSS 防护
const xss = require('xss-clean');
app.use(xss());

2. 数据库索引优化

javascript
// User 模型索引
userSchema.index({ email: 1 }, { unique: true });
userSchema.index({ username: 1 }, { unique: true });

// Article 模型索引
articleSchema.index({ author: 1, createdAt: -1 });
articleSchema.index({ category: 1, status: 1 });
articleSchema.index({ tags: 1 });
articleSchema.index({ title: 'text', content: 'text' }); // 全文搜索

// Comment 模型索引
commentSchema.index({ article: 1, createdAt: -1 });
commentSchema.index({ author: 1 });

3. 日志记录

javascript
const winston = require('winston');

const logger = winston.createLogger({
  level: process.env.LOG_LEVEL || 'info',
  format: winston.format.combine(
    winston.format.timestamp(),
    winston.format.json()
  ),
  transports: [
    new winston.transports.File({ filename: 'logs/error.log', level: 'error' }),
    new winston.transports.File({ filename: 'logs/combined.log' })
  ]
});

// 使用示例
logger.info('用户登录成功', { userId: user._id });
logger.error('文章创建失败', { error: err.message });

4. 错误处理类

javascript
class AppError extends Error {
  constructor(message, statusCode, code = 'APP_ERROR') {
    super(message);
    this.status = statusCode;
    this.code = code;
    this.isOperational = true;
    Error.captureStackTrace(this, this.constructor);
  }
}

// 使用示例
throw new AppError('用户不存在', 404, 'USER_NOT_FOUND');
throw new AppError('无权限操作', 403, 'FORBIDDEN');

5. 分页优化

javascript
// 使用游标分页替代偏移分页(大数据量时更高效)
async function getArticlesWithCursor(cursor, limit = 10) {
  const query = cursor ? { _id: { $lt: cursor } } : {};
  const articles = await Article.find(query)
    .sort({ _id: -1 })
    .limit(limit);
  return {
    articles,
    nextCursor: articles.length ? articles[articles.length - 1]._id : null
  };
}

6. 单元测试

javascript
const request = require('supertest');
const app = require('../app');
const User = require('../models/User');

describe('Auth API', () => {
  beforeEach(async () => {
    await User.deleteMany({});
  });

  test('POST /api/auth/register - 成功注册', async () => {
    const res = await request(app)
      .post('/api/auth/register')
      .send({
        username: 'testuser',
        email: 'test@test.com',
        password: '123456'
      });

    expect(res.status).toBe(201);
    expect(res.body.success).toBe(true);
    expect(res.body.data.token).toBeDefined();
  });

  test('POST /api/auth/login - 登录成功', async () => {
    // 先创建用户
    await new User({
      username: 'testuser',
      email: 'test@test.com',
      password: '123456'
    }).save();

    const res = await request(app)
      .post('/api/auth/login')
      .send({
        email: 'test@test.com',
        password: '123456'
      });

    expect(res.status).toBe(200);
    expect(res.body.data.token).toBeDefined();
  });
});

常见问题解答

Q1: 如何实现文章搜索功能?

使用 MongoDB 的文本索引和 $text 操作符:

javascript
// 创建文本索引(已在模型中定义)
// 执行搜索
router.get('/search', async (req, res) => {
  const { q } = req.query;
  const articles = await Article.find(
    { $text: { $search: q } },
    { score: { $meta: 'textScore' } }
  ).sort({ score: { $meta: 'textScore' } });

  res.json({ success: true, data: articles });
});

Q2: 如何实现图片上传功能?

javascript
// 路由配置
router.post('/upload', verifyToken, uploadSingle, (req, res) => {
  if (!req.file) {
    return res.status(400).json({ message: '请选择文件' });
  }
  res.json({
    success: true,
    data: {
      url: `/uploads/${req.file.filename}`
    }
  });
});

Q3: 如何实现用户关注功能?

javascript
// 在 User 模型添加字段
following: [{ type: ObjectId, ref: 'User' }],
followers: [{ type: ObjectId, ref: 'User' }]

// 关注/取消关注
router.post('/:id/follow', verifyToken, async (req, res) => {
  const targetId = req.params.id;
  const userId = req.userId;

  if (targetId === userId) {
    return res.status(400).json({ message: '不能关注自己' });
  }

  const [target, user] = await Promise.all([
    User.findById(targetId),
    User.findById(userId)
  ]);

  const isFollowing = user.following.includes(targetId);

  if (isFollowing) {
    // 取消关注
    user.following.pull(targetId);
    target.followers.pull(userId);
  } else {
    // 添加关注
    user.following.push(targetId);
    target.followers.push(userId);
  }

  await Promise.all([user.save(), target.save()]);

  res.json({ success: true, following: !isFollowing });
});

Q4: 如何实现评论嵌套回复?

javascript
// Comment 模型添加父评论字段
parentComment: {
  type: mongoose.Schema.Types.ObjectId,
  ref: 'Comment',
  default: null
}

// 获取嵌套评论
async function getCommentsWithReplies(articleId) {
  const comments = await Comment.find({ article: articleId })
    .populate('author', 'username avatar')
    .populate('replyTo', 'username')
    .sort({ createdAt: -1 });

  // 构建评论树
  const commentMap = {};
  const rootComments = [];

  comments.forEach(comment => {
    commentMap[comment._id] = { ...comment.toObject(), replies: [] };
  });

  comments.forEach(comment => {
    if (comment.parentComment) {
      commentMap[comment.parentComment].replies.push(commentMap[comment._id]);
    } else {
      rootComments.push(commentMap[comment._id]);
    }
  });

  return rootComments;
}

Q5: 如何实现 API 版本控制?

javascript
// 方式1:URL 路径版本
app.use('/api/v1/articles', articleRoutesV1);
app.use('/api/v2/articles', articleRoutesV2);

// 方式2:请求头版本
app.use('/api/articles', (req, res, next) => {
  const version = req.headers['accept-version'] || 'v1';
  req.version = version;
  next();
}, articleRoutes);

Q6: 如何优化大数据量查询性能?

  1. 使用投影减少返回字段

    javascript
    Article.find({}, 'title summary author views createdAt');
  2. 使用 lean() 返回纯对象

    javascript
    Article.find().lean().exec();
  3. 添加合适的索引

    javascript
    articleSchema.index({ author: 1, createdAt: -1 });
  4. 使用游标分页

    javascript
    Article.find({ _id: { $gt: lastId } }).limit(10);

扩展功能建议

  1. 邮件通知:集成 nodemailer 发送注册验证、密码重置邮件
  2. 第三方登录:集成 OAuth(GitHub、微信、QQ)
  3. 实时通信:使用 WebSocket 实现评论实时推送
  4. 全文搜索:集成 Elasticsearch 提供更强大的搜索
  5. 缓存优化:使用 Redis 缓存热门文章和用户信息
  6. CDN 加速:静态资源上传至云存储(OSS、七牛云)
  7. 监控告警:集成 PM2 监控和异常告警

参考资料