博客系统
本文档详细介绍如何构建一个完整的全栈博客系统,包含用户认证、文章管理、评论系统等核心功能。该系统采用前后端分离架构,后端基于 Node.js + Express + MongoDB,前端可对接 Vue/React 等现代框架。
项目概述
功能特性
用户功能模块
- 用户注册/登录:支持邮箱注册,JWT 令牌认证
- 个人信息管理:修改用户名、头像、个人简介
- 头像上传:支持图片上传和存储
- 角色权限:区分普通用户和管理员权限
文章功能模块
- 文章管理:创建、编辑、删除文章
- Markdown 支持:文章内容支持 Markdown 格式
- 分类与标签:文章分类和标签管理
- 草稿/发布:支持文章状态切换
- 阅读统计:记录文章浏览量
互动功能模块
- 评论系统:发表评论、回复评论
- 点赞功能:文章和评论点赞
- 关注系统:关注作者、查看关注动态
技术栈
| 层级 | 技术选型 | 说明 |
|---|---|---|
| 后端框架 | Express.js | Web 应用框架 |
| 数据库 | MongoDB + Mongoose | NoSQL 数据库 |
| 认证方案 | JWT | JSON Web Token |
| 密码加密 | bcryptjs | 单向加密算法 |
| 文件上传 | multer | 文件处理中间件 |
| 跨域处理 | cors | CORS 中间件 |
系统架构
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`);
});配置参数说明:
| 参数 | 类型 | 默认值 | 说明 |
|---|---|---|---|
PORT | Number | 3000 | 服务器端口 |
MONGODB_URI | String | mongodb://localhost:27017/blog | 数据库连接地址 |
NODE_ENV | String | development | 运行环境 |
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);用户模型字段说明:
| 字段 | 类型 | 必填 | 约束 | 说明 |
|---|---|---|---|---|
username | String | 是 | 3-20字符,唯一 | 用户名 |
email | String | 是 | 邮箱格式,唯一 | 登录邮箱 |
password | String | 是 | 最少6字符 | 加密存储的密码 |
avatar | String | 否 | - | 头像 URL |
bio | String | 否 | 最多200字符 | 个人简介 |
role | String | 否 | user/admin | 用户角色 |
createdAt | Date | 自动 | - | 创建时间 |
updatedAt | Date | 自动 | - | 更新时间 |
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);文章模型字段说明:
| 字段 | 类型 | 必填 | 约束 | 说明 |
|---|---|---|---|---|
title | String | 是 | 最多100字符 | 文章标题 |
content | String | 是 | - | 文章正文(Markdown) |
summary | String | 否 | 最多200字符 | 文章摘要 |
cover | String | 否 | - | 封面图片 URL |
author | ObjectId | 是 | 关联 User | 作者 ID |
category | String | 是 | 预定义值 | 文章分类 |
tags | [String] | 否 | - | 标签数组 |
views | Number | 否 | 默认0 | 浏览次数 |
likes | [ObjectId] | 否 | 关联 User | 点赞用户列表 |
status | String | 否 | draft/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);评论模型字段说明:
| 字段 | 类型 | 必填 | 约束 | 说明 |
|---|---|---|---|---|
content | String | 是 | 最多500字符 | 评论内容 |
article | ObjectId | 是 | 关联 Article | 所属文章 ID |
author | ObjectId | 是 | 关联 User | 评论者 ID |
replyTo | ObjectId | 否 | 关联 User | 回复的目标用户 |
likes | [ObjectId] | 否 | 关联 User | 点赞用户列表 |
createdAt | Date | 自动 | - | 创建时间 |
updatedAt | Date | 自动 | - | 更新时间 |
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_SECRET | JWT_SECRET | your-secret-key | 密钥(生产环境必须修改) |
JWT_EXPIRES_IN | JWT_EXPIRES_IN | 7d | 过期时间 |
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=5242880package.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: 如何优化大数据量查询性能?
-
使用投影减少返回字段
javascriptArticle.find({}, 'title summary author views createdAt'); -
使用 lean() 返回纯对象
javascriptArticle.find().lean().exec(); -
添加合适的索引
javascriptarticleSchema.index({ author: 1, createdAt: -1 }); -
使用游标分页
javascriptArticle.find({ _id: { $gt: lastId } }).limit(10);
扩展功能建议
- 邮件通知:集成 nodemailer 发送注册验证、密码重置邮件
- 第三方登录:集成 OAuth(GitHub、微信、QQ)
- 实时通信:使用 WebSocket 实现评论实时推送
- 全文搜索:集成 Elasticsearch 提供更强大的搜索
- 缓存优化:使用 Redis 缓存热门文章和用户信息
- CDN 加速:静态资源上传至云存储(OSS、七牛云)
- 监控告警:集成 PM2 监控和异常告警